Skip to content

Commit 69ac079

Browse files
committed
chore: add input.secret support
1 parent dabbfa2 commit 69ac079

File tree

10 files changed

+205
-52
lines changed

10 files changed

+205
-52
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.2.0] - 2025-05-08
11+
12+
### Added
13+
14+
- Support for new `input.secret` methods.
15+
1016
## [0.1.0] - 2025-03-12
1117

1218
### Added

README.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,9 @@ const result = await runAirtableScript({
137137

138138
### Mocking user inputs
139139

140-
You can mock any `input` from either an automation input or user interaction using the `mockInput` setting:
140+
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.
141+
142+
#### Sample mock for an extension
141143

142144
```js
143145
const results = await runAirtableScript({
@@ -157,7 +159,23 @@ const results = await runAirtableScript({
157159
})
158160
```
159161

160-
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.
162+
#### Sample mock for an auotmation
163+
164+
```js
165+
const results = await runAirtableScript({
166+
script: `
167+
const config = await input.config()
168+
output.inspect(config.name)
169+
`,
170+
base: randomRecords,
171+
mockInput: {
172+
// @ts-ignore
173+
config: () => ({
174+
name: 'Test name',
175+
}),
176+
},
177+
})
178+
```
161179

162180
### Results
163181

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "jest-environment-airtable-script",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"description": "A jest environment for testing Airtable scripts in extensions and automations",
55
"license": "Apache-2.0",
66
"author": "",

src/environment/console-aggregator.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
/**
2+
* A standard placeholder for secret values that are redacted from console.log.
3+
* Airtable seems to just track all the secret values and redact them using a search
4+
* when outputting the console.
5+
*/
6+
const SECRET_VALUE_REDACTED: string = '[secret value redacted]'
7+
18
type ConsoleMessage = {
29
type: 'log' | 'warn' | 'error'
310
message: string
@@ -8,6 +15,7 @@ type ConsoleAggregator = {
815
warn: (message: string) => void
916
error: (message: string) => void
1017
_getMessages: () => ConsoleMessage[]
18+
_addSecretValue: (value: string) => void
1119
}
1220

1321
/**
@@ -20,20 +28,43 @@ type ConsoleAggregator = {
2028
const consoleAggregator = (): ConsoleAggregator => {
2129
const consoleMessages: ConsoleMessage[] = []
2230

31+
const secretValues: string[] = []
32+
/**
33+
* Removes any secret values from console messages and
34+
* replaces them with a standard secret placeholder.
35+
*/
36+
const redactSecrets = (message: string): string => {
37+
if (!secretValues.length) {
38+
return message
39+
}
40+
return secretValues.reduce(
41+
(acc, value) => acc.replace(value, SECRET_VALUE_REDACTED),
42+
message
43+
)
44+
}
45+
2346
return {
2447
log: (message: string) => {
25-
consoleMessages.push({ type: 'log', message })
48+
consoleMessages.push({ type: 'log', message: redactSecrets(message) })
2649
},
2750
warn: (message: string) => {
28-
consoleMessages.push({ type: 'warn', message })
51+
consoleMessages.push({ type: 'warn', message: redactSecrets(message) })
2952
},
3053
error: (message: string) => {
31-
consoleMessages.push({ type: 'error', message })
54+
consoleMessages.push({ type: 'error', message: redactSecrets(message) })
3255
},
3356
_getMessages: () => {
3457
return consoleMessages
3558
},
59+
_addSecretValue: (value) => {
60+
secretValues.push(value)
61+
},
3662
}
3763
}
3864

39-
export { consoleAggregator, ConsoleAggregator, ConsoleMessage }
65+
export {
66+
consoleAggregator,
67+
SECRET_VALUE_REDACTED,
68+
ConsoleAggregator,
69+
ConsoleMessage,
70+
}

src/environment/run-airtable-script.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ type RunScriptResult = {
2929
output: Output
3030
mutations: Mutation[]
3131
console: ConsoleMessage[]
32-
thrownError: false | unknown
32+
thrownError: false | Error
3333
}
3434

3535
type RunContext = {
@@ -44,7 +44,7 @@ type RunContext = {
4444
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
4545
__mockFetch?: Function | false
4646
__input?: unknown
47-
__scriptError: false | unknown
47+
__scriptError: false | Error
4848
__defaultDateLocale: DefaultDateLocale
4949
console: ConsoleAggregator
5050
}
@@ -89,7 +89,7 @@ const runAirtableScript = async ({
8989
vm.createContext(context)
9090
vm.runInContext(sdkScript, context)
9191

92-
let thrownError: false | unknown = false
92+
let thrownError: any
9393

9494
try {
9595
// We need to run the script in an async function so that we can use await

src/environment/sdk/globals/globals.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ declare global {
5959
var __base: FixtureBase
6060
var __defaultCursor: DefaultCursor | false
6161
var __isAirtableScriptTestEnvironment: boolean
62+
var __secretValues: string[]
6263
var __mutations: Mutation[]
6364
var __mockInput: { [key: string]: unknown } | undefined
6465
var __mockFetch: Function | false

src/environment/sdk/globals/input.test.ts

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ globalThis.__base = {
1313
{
1414
id: 'fld1',
1515
name: 'Field 1',
16-
type: 'text',
16+
type: 'singleLineText',
1717
},
1818
{
1919
id: 'fld2',
@@ -65,24 +65,46 @@ globalThis.__base = {
6565
{
6666
id: 'tbl2',
6767
name: 'Table 2',
68+
fields: [],
69+
records: [],
70+
views: [],
6871
},
6972
],
7073
}
7174

7275
describe('automationInput', () => {
73-
it('should return the results of a config callback', () => {
74-
globalThis.__mockInput = {
75-
config: () => ({ key: 'value' }),
76-
}
77-
const result = automationInput.config()
78-
expect(result).toEqual({ key: 'value' })
76+
describe('config', () => {
77+
it('should return the results of a config callback', () => {
78+
globalThis.__mockInput = {
79+
config: () => ({ key: 'value' }),
80+
}
81+
const result = automationInput.config()
82+
expect(result).toEqual({ key: 'value' })
83+
})
84+
85+
it('should throw an error if no config callback is provided', () => {
86+
globalThis.__mockInput = {}
87+
expect(() => automationInput.config()).toThrow(
88+
'input.config() is called, but mockInput.config() is not implemented'
89+
)
90+
})
7991
})
8092

81-
it('should throw an error if no config callback is provided', () => {
82-
globalThis.__mockInput = {}
83-
expect(() => automationInput.config()).toThrow(
84-
'input.config() is called, but mockInput.config() is not implemented'
85-
)
93+
describe('secret', () => {
94+
it('should return the results of a secret callback', () => {
95+
globalThis.__mockInput = {
96+
secret: (key: string) => (key === 'testKey' ? 'value' : null),
97+
}
98+
const result = automationInput.secret('testKey')
99+
expect(result).toEqual('value')
100+
})
101+
102+
it('should throw an error if no secret callback is provided', () => {
103+
globalThis.__mockInput = {}
104+
expect(() => automationInput.secret('test')).toThrow(
105+
'input.secret() is called, but mockInput.secret() is not implemented'
106+
)
107+
})
86108
})
87109
})
88110

@@ -247,7 +269,7 @@ describe('extensionInput', () => {
247269
// @ts-ignore
248270
const table = globalThis.base.getTable('tbl1')
249271
const result = await extensionInput.recordAsync(randomLabel, table)
250-
expect(result.id).toEqual('rec1')
272+
expect(result && result.id).toEqual('rec1')
251273
})
252274

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

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

287309
it('should return a record when given an array of records', async () => {
@@ -302,7 +324,7 @@ describe('extensionInput', () => {
302324
randomLabel,
303325
records.records
304326
)
305-
expect(result.id).toEqual('rec1')
327+
expect(result && result.id).toEqual('rec1')
306328
})
307329

308330
it('should throw an error if given an invalid source', async () => {
@@ -316,6 +338,7 @@ describe('extensionInput', () => {
316338
},
317339
}
318340
await expect(
341+
// @ts-ignore
319342
extensionInput.recordAsync(randomLabel, 'tbl1')
320343
).rejects.toThrow('Invalid source type')
321344
})
@@ -324,6 +347,7 @@ describe('extensionInput', () => {
324347
globalThis.__mockInput = {}
325348
const randomLabel = `Record label ${Math.random()}`
326349
await expect(
350+
// @ts-ignore
327351
extensionInput.recordAsync(randomLabel, 'tbl1')
328352
).rejects.toThrow(
329353
'input.recordAsync() is called, but mockInput.recordAsync() is not implemented'

src/environment/sdk/globals/input.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ type ExtensionInputConfig = {
3939

4040
type MockInput = {
4141
config?: () => { [key: string]: unknown }
42+
secret?: (key: string) => string
4243
textAsync?: (label: string) => string
4344
buttonsAsync?: (
4445
label: string,
@@ -83,6 +84,7 @@ type MockInput = {
8384

8485
type AutomationInput = {
8586
config: () => { [key: string]: unknown }
87+
secret: (key: string) => string | number
8688
}
8789

8890
type ExtensionInput = {
@@ -104,7 +106,7 @@ type ExtensionInput = {
104106
recordAsync: (
105107
label: string,
106108
source: Table | View | Array<Record> | RecordQueryResult,
107-
options: {
109+
options?: {
108110
fields?: Array<Field | string>
109111
shouldAllowCreatingRecord?: boolean
110112
}
@@ -138,7 +140,7 @@ const checkMockInput = (method: string): void => {
138140

139141
const automationInput: AutomationInput = {
140142
/**
141-
* Automations only get one source of input: an object of config values.
143+
* Automations have a single configuration input.
142144
* Returns an object with all input keys mapped to their corresponding values.
143145
*
144146
* @see https://airtable.com/developers/scripting/api/input#config
@@ -148,6 +150,22 @@ const automationInput: AutomationInput = {
148150
// @ts-ignore
149151
return (__mockInput as MockInput).config() || {}
150152
},
153+
/**
154+
* Support for retrieving secret values from the Builder Hub.
155+
*
156+
* @see https://airtable.com/developers/scripting/api/input#secret
157+
*/
158+
secret: (key) => {
159+
checkMockInput('secret')
160+
// @ts-ignore
161+
const secretValue = (__mockInput as MockInput).secret(key) || ''
162+
// @ts-ignore
163+
if (console._addSecretValue) {
164+
// @ts-ignore
165+
console._addSecretValue(secretValue)
166+
}
167+
return secretValue
168+
},
151169
}
152170

153171
const extensionInput: ExtensionInput = {
@@ -237,7 +255,7 @@ If the user picks a record, the record instance is returned. If the user dismiss
237255
recordAsync: (
238256
label,
239257
source: Table | View | Array<Record> | RecordQueryResult,
240-
options: {
258+
options?: {
241259
fields?: Array<Field | string>
242260
shouldAllowCreatingRecord?: boolean
243261
}
@@ -246,7 +264,7 @@ If the user picks a record, the record instance is returned. If the user dismiss
246264
checkMockInput('recordAsync')
247265
// @ts-ignore
248266
const recordId = (__mockInput as MockInput).recordAsync(label, {
249-
options,
267+
options: options || {},
250268
source,
251269
})
252270
if (source instanceof Table || source instanceof View) {

0 commit comments

Comments
 (0)