Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.2.0] - 2025-05-08

### Added

- Support for new `input.secret` methods.

## [0.1.0] - 2025-03-12

### Added
Expand Down
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@ const result = await runAirtableScript({

### Mocking user inputs

You can mock any `input` from either an automation input or user interaction using the `mockInput` setting:
You can mock any `input` from either an automation input or user interaction using the `mockInput` setting. Every [input method for extensions or automations](https://airtable.com/developers/scripting/api/input) are available to be mocked. Check out the [input.test.ts](./test/input.test.ts) file for examples.

#### Sample mock for an extension

```js
const results = await runAirtableScript({
Expand All @@ -157,7 +159,23 @@ const results = await runAirtableScript({
})
```

Every [input method for extensions or automations](https://airtable.com/developers/scripting/api/input) are available to be mocked. Check out the [input.test.ts](./test/input.test.ts) file for examples.
#### Sample mock for an auotmation

```js
const results = await runAirtableScript({
script: `
const config = await input.config()
output.inspect(config.name)
`,
base: randomRecords,
mockInput: {
// @ts-ignore
config: () => ({
name: 'Test name',
}),
},
})
```

### Results

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "jest-environment-airtable-script",
"version": "0.1.0",
"version": "0.2.0",
"description": "A jest environment for testing Airtable scripts in extensions and automations",
"license": "Apache-2.0",
"author": "",
Expand Down
39 changes: 35 additions & 4 deletions src/environment/console-aggregator.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
/**
* A standard placeholder for secret values that are redacted from console.log.
* Airtable seems to just track all the secret values and redact them using a search
* when outputting the console.
*/
const SECRET_VALUE_REDACTED: string = '[secret value redacted]'

type ConsoleMessage = {
type: 'log' | 'warn' | 'error'
message: string
Expand All @@ -8,6 +15,7 @@ type ConsoleAggregator = {
warn: (message: string) => void
error: (message: string) => void
_getMessages: () => ConsoleMessage[]
_addSecretValue: (value: string) => void
}

/**
Expand All @@ -20,20 +28,43 @@ type ConsoleAggregator = {
const consoleAggregator = (): ConsoleAggregator => {
const consoleMessages: ConsoleMessage[] = []

const secretValues: string[] = []
/**
* Removes any secret values from console messages and
* replaces them with a standard secret placeholder.
*/
const redactSecrets = (message: string): string => {
if (!secretValues.length) {
return message
}
return secretValues.reduce(
(acc, value) => acc.replace(value, SECRET_VALUE_REDACTED),
message
)
}

return {
log: (message: string) => {
consoleMessages.push({ type: 'log', message })
consoleMessages.push({ type: 'log', message: redactSecrets(message) })
},
warn: (message: string) => {
consoleMessages.push({ type: 'warn', message })
consoleMessages.push({ type: 'warn', message: redactSecrets(message) })
},
error: (message: string) => {
consoleMessages.push({ type: 'error', message })
consoleMessages.push({ type: 'error', message: redactSecrets(message) })
},
_getMessages: () => {
return consoleMessages
},
_addSecretValue: (value) => {
secretValues.push(value)
},
}
}

export { consoleAggregator, ConsoleAggregator, ConsoleMessage }
export {
consoleAggregator,
SECRET_VALUE_REDACTED,
ConsoleAggregator,
ConsoleMessage,
}
6 changes: 3 additions & 3 deletions src/environment/run-airtable-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ type RunScriptResult = {
output: Output
mutations: Mutation[]
console: ConsoleMessage[]
thrownError: false | unknown
thrownError: unknown
}

type RunContext = {
Expand All @@ -44,7 +44,7 @@ type RunContext = {
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
__mockFetch?: Function | false
__input?: unknown
__scriptError: false | unknown
__scriptError: false | Error
__defaultDateLocale: DefaultDateLocale
console: ConsoleAggregator
}
Expand Down Expand Up @@ -89,7 +89,7 @@ const runAirtableScript = async ({
vm.createContext(context)
vm.runInContext(sdkScript, context)

let thrownError: false | unknown = false
let thrownError: unknown

try {
// We need to run the script in an async function so that we can use await
Expand Down
1 change: 1 addition & 0 deletions src/environment/sdk/globals/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ declare global {
var __base: FixtureBase
var __defaultCursor: DefaultCursor | false
var __isAirtableScriptTestEnvironment: boolean
var __secretValues: string[]
var __mutations: Mutation[]
var __mockInput: { [key: string]: unknown } | undefined
var __mockFetch: Function | false
Expand Down
56 changes: 40 additions & 16 deletions src/environment/sdk/globals/input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ globalThis.__base = {
{
id: 'fld1',
name: 'Field 1',
type: 'text',
type: 'singleLineText',
},
{
id: 'fld2',
Expand Down Expand Up @@ -65,24 +65,46 @@ globalThis.__base = {
{
id: 'tbl2',
name: 'Table 2',
fields: [],
records: [],
views: [],
},
],
}

describe('automationInput', () => {
it('should return the results of a config callback', () => {
globalThis.__mockInput = {
config: () => ({ key: 'value' }),
}
const result = automationInput.config()
expect(result).toEqual({ key: 'value' })
describe('config', () => {
it('should return the results of a config callback', () => {
globalThis.__mockInput = {
config: () => ({ key: 'value' }),
}
const result = automationInput.config()
expect(result).toEqual({ key: 'value' })
})

it('should throw an error if no config callback is provided', () => {
globalThis.__mockInput = {}
expect(() => automationInput.config()).toThrow(
'input.config() is called, but mockInput.config() is not implemented'
)
})
})

it('should throw an error if no config callback is provided', () => {
globalThis.__mockInput = {}
expect(() => automationInput.config()).toThrow(
'input.config() is called, but mockInput.config() is not implemented'
)
describe('secret', () => {
it('should return the results of a secret callback', () => {
globalThis.__mockInput = {
secret: (key: string) => (key === 'testKey' ? 'value' : null),
}
const result = automationInput.secret('testKey')
expect(result).toEqual('value')
})

it('should throw an error if no secret callback is provided', () => {
globalThis.__mockInput = {}
expect(() => automationInput.secret('test')).toThrow(
'input.secret() is called, but mockInput.secret() is not implemented'
)
})
})
})

Expand Down Expand Up @@ -247,7 +269,7 @@ describe('extensionInput', () => {
// @ts-ignore
const table = globalThis.base.getTable('tbl1')
const result = await extensionInput.recordAsync(randomLabel, table)
expect(result.id).toEqual('rec1')
expect(result && result.id).toEqual('rec1')
})

it('should return a record when given a View object', async () => {
Expand All @@ -263,7 +285,7 @@ describe('extensionInput', () => {
// @ts-ignore
const view = globalThis.base.getTable('tbl1').getView('view1')
const result = await extensionInput.recordAsync(randomLabel, view)
expect(result.id).toEqual('rec2')
expect(result && result.id).toEqual('rec2')
})

it('should return a record when given a RecordQueryResult object', async () => {
Expand All @@ -281,7 +303,7 @@ describe('extensionInput', () => {
.getTable('tbl1')
.selectRecordsAsync()
const result = await extensionInput.recordAsync(randomLabel, records)
expect(result.id).toEqual('rec1')
expect(result && result.id).toEqual('rec1')
})

it('should return a record when given an array of records', async () => {
Expand All @@ -302,7 +324,7 @@ describe('extensionInput', () => {
randomLabel,
records.records
)
expect(result.id).toEqual('rec1')
expect(result && result.id).toEqual('rec1')
})

it('should throw an error if given an invalid source', async () => {
Expand All @@ -316,6 +338,7 @@ describe('extensionInput', () => {
},
}
await expect(
// @ts-ignore
extensionInput.recordAsync(randomLabel, 'tbl1')
).rejects.toThrow('Invalid source type')
})
Expand All @@ -324,6 +347,7 @@ describe('extensionInput', () => {
globalThis.__mockInput = {}
const randomLabel = `Record label ${Math.random()}`
await expect(
// @ts-ignore
extensionInput.recordAsync(randomLabel, 'tbl1')
).rejects.toThrow(
'input.recordAsync() is called, but mockInput.recordAsync() is not implemented'
Expand Down
26 changes: 22 additions & 4 deletions src/environment/sdk/globals/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type ExtensionInputConfig = {

type MockInput = {
config?: () => { [key: string]: unknown }
secret?: (key: string) => string
textAsync?: (label: string) => string
buttonsAsync?: (
label: string,
Expand Down Expand Up @@ -83,6 +84,7 @@ type MockInput = {

type AutomationInput = {
config: () => { [key: string]: unknown }
secret: (key: string) => string | number
}

type ExtensionInput = {
Expand All @@ -104,7 +106,7 @@ type ExtensionInput = {
recordAsync: (
label: string,
source: Table | View | Array<Record> | RecordQueryResult,
options: {
options?: {
fields?: Array<Field | string>
shouldAllowCreatingRecord?: boolean
}
Expand Down Expand Up @@ -138,7 +140,7 @@ const checkMockInput = (method: string): void => {

const automationInput: AutomationInput = {
/**
* Automations only get one source of input: an object of config values.
* Automations have a single configuration input.
* Returns an object with all input keys mapped to their corresponding values.
*
* @see https://airtable.com/developers/scripting/api/input#config
Expand All @@ -148,6 +150,22 @@ const automationInput: AutomationInput = {
// @ts-ignore
return (__mockInput as MockInput).config() || {}
},
/**
* Support for retrieving secret values from the Builder Hub.
*
* @see https://airtable.com/developers/scripting/api/input#secret
*/
secret: (key) => {
checkMockInput('secret')
// @ts-ignore
const secretValue = (__mockInput as MockInput).secret(key) || ''
// @ts-ignore
if (console._addSecretValue) {
// @ts-ignore
console._addSecretValue(secretValue)
}
return secretValue
},
}

const extensionInput: ExtensionInput = {
Expand Down Expand Up @@ -237,7 +255,7 @@ If the user picks a record, the record instance is returned. If the user dismiss
recordAsync: (
label,
source: Table | View | Array<Record> | RecordQueryResult,
options: {
options?: {
fields?: Array<Field | string>
shouldAllowCreatingRecord?: boolean
}
Expand All @@ -246,7 +264,7 @@ If the user picks a record, the record instance is returned. If the user dismiss
checkMockInput('recordAsync')
// @ts-ignore
const recordId = (__mockInput as MockInput).recordAsync(label, {
options,
options: options || {},
source,
})
if (source instanceof Table || source instanceof View) {
Expand Down
Loading