@frontera-sdk/functions 1.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/testing.ts ADDED
@@ -0,0 +1,323 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import {
3
+ duplicateStepMessage,
4
+ duplicateSubmissionMessage,
5
+ emptySubmissionKeyMessage,
6
+ missingGrantMessage,
7
+ submitOutsideStepMessage,
8
+ } from './messages'
9
+ import type {
10
+ ActionSubmission,
11
+ ActionSubmitResult,
12
+ AutomationContext,
13
+ BlueprintQueryOptions,
14
+ BlueprintQueryResult,
15
+ Grant,
16
+ HttpRequest,
17
+ HttpResponse,
18
+ PluginCallResult,
19
+ } from './types'
20
+
21
+ /**
22
+ * A `ctx` you can hand your handler in a unit test.
23
+ *
24
+ * Until this existed the only way to find out whether an automation worked was
25
+ * to deploy it and run it — a loop measured in tens of seconds, against real
26
+ * data, for a question as small as "does the empty branch return the right
27
+ * shape".
28
+ *
29
+ * It enforces what the platform enforces, in the platform's own words: a
30
+ * missing grant and a repeated step name fail here exactly as they fail in
31
+ * production, so a green test means something.
32
+ *
33
+ * What it does NOT simulate is resumption. In production a handler is re-entered
34
+ * after every step, so code outside a step runs many times; here the handler is
35
+ * called once, straight through. Steps still memoize by name within the run, and
36
+ * everything the handler did is recorded on `calls`.
37
+ */
38
+
39
+ /**
40
+ * A call REFUSED before it happened is not recorded.
41
+ *
42
+ * A missing grant, a missing stub, a submit outside a step, a duplicate
43
+ * submission — none of these appear in `calls`, because none of them did
44
+ * anything. A real run differs here in one direction worth knowing: it writes
45
+ * an errored ctx-call row for a refused `ctx.action.submit`, so the Console
46
+ * trace shows the attempt where this list does not. Assert on the thrown error
47
+ * for a refusal, and on `calls` for what ran.
48
+ */
49
+ export interface TestCall {
50
+ kind: 'step' | 'log' | 'agent' | 'plugin' | 'http' | 'blueprint' | 'action' | 'file'
51
+ /** Step name, log message, agent slug, `install:capability`, URL, object type, or Action apiName. */
52
+ label: string
53
+ /** Present on a step: how it ended. */
54
+ status?: 'ok' | 'error'
55
+ }
56
+
57
+
58
+ export interface TestContextOptions {
59
+ runId?: string
60
+ workspaceId?: string
61
+ /** What the run was started with. Passed through verbatim — a unit test
62
+ * states exactly what the handler sees; defaults are `startRun`'s job. */
63
+ input?: Record<string, unknown>
64
+ /**
65
+ * The grants the manifest declares.
66
+ *
67
+ * Given, they are enforced — which is the point: a missing grant is one of
68
+ * the few automation bugs that only shows up in a deployed run, and it is
69
+ * exactly the kind a unit test should catch.
70
+ *
71
+ * Omitted, nothing is refused, so an existing test does not have to enumerate
72
+ * grants to keep passing.
73
+ */
74
+ grants?: readonly Grant[]
75
+ /** Per-slug agent answers. An unstubbed agent throws rather than answering. */
76
+ agents?: Record<string, (prompt: string) => Promise<{ text: string }> | { text: string }>
77
+ /** Per-fileId answers for `ctx.file`: what the resolved handle should carry.
78
+ * An unstubbed fileId throws — a fabricated handle is a false pass. */
79
+ files?: Record<string, { signedUrl: string; mimeType: string; sizeBytes: number; name: string | null }>
80
+ /**
81
+ * Per-install, per-capability plugin answers: `{ crm: { create_ticket: (input) => ({ data }) } }`.
82
+ * An unstubbed capability throws rather than answering — a fabricated
83
+ * `{ data: {} }` is a test that passes while asserting nothing.
84
+ */
85
+ plugins?: Record<
86
+ string,
87
+ Record<string, (input: Record<string, unknown>) => Promise<PluginCallResult> | PluginCallResult>
88
+ >
89
+ /** Answers outbound requests. Unstubbed, `ctx.http.fetch` throws. */
90
+ http?: (req: HttpRequest) => Promise<HttpResponse> | HttpResponse
91
+ /** Rows per object type. An unstubbed type returns no rows, which is a real
92
+ * answer and usually the branch worth testing. */
93
+ blueprint?: Record<string, BlueprintQueryResult<never> | BlueprintQueryResult<Record<string, unknown>>>
94
+ /**
95
+ * Per-apiName Action outcomes. An unstubbed Action throws rather than
96
+ * answering.
97
+ *
98
+ * Throws for the same reason the agent stub does, and the reason is sharper
99
+ * here: the returned `lifecycle` is a branch an author writes code against —
100
+ * `awaiting_approval` means a human still has to decide — so inventing
101
+ * `ready` would silently pick one arm and pass.
102
+ */
103
+ actions?: Record<
104
+ string,
105
+ (request: ActionSubmission) => Promise<ActionSubmitResult> | ActionSubmitResult
106
+ >
107
+ }
108
+
109
+ export interface TestContext {
110
+ ctx: AutomationContext
111
+ /** Everything the handler did, in order. */
112
+ calls: TestCall[]
113
+ /** Step names, in the order they ran. */
114
+ steps: string[]
115
+ logs: Array<{ message: string; data?: Record<string, unknown> }>
116
+ }
117
+
118
+ export function createTestContext(options: TestContextOptions = {}): TestContext {
119
+ const calls: TestCall[] = []
120
+ const steps: string[] = []
121
+ const logs: TestContext['logs'] = []
122
+ const seenNames = new Set<string>()
123
+ /**
124
+ * Which step the running code is inside.
125
+ *
126
+ * `AsyncLocalStorage`, matching the real context exactly, and NOT a stack.
127
+ * A stack gets the concurrent case wrong in the direction that matters:
128
+ * `Promise.all([ctx.step.run('a', …), ctx.action.submit(…)])` is legal, and
129
+ * with a shared mutable stack the bare submit sees `a` open and is allowed —
130
+ * so the test double passes what production refuses, which is the one failure
131
+ * mode a test double must not have.
132
+ *
133
+ * Enforced here for the same reason grants and duplicate names are: a rule
134
+ * the unit test does not apply is a rule the author meets for the first time
135
+ * in a deployed run.
136
+ */
137
+ const stepScope = new AsyncLocalStorage<{ stepName: string; submitted: Set<string> }>()
138
+
139
+ const requireGrant = (grant: string): void => {
140
+ // No grant list means the test is not about grants. Enforcing an empty list
141
+ // would fail every existing test for a reason its author never chose.
142
+ if (!options.grants) return
143
+ if (!options.grants.includes(grant as Grant)) throw new Error(missingGrantMessage(grant))
144
+ }
145
+
146
+ const ctx: AutomationContext = {
147
+ runId: options.runId ?? 'test-run',
148
+ workspaceId: options.workspaceId ?? 'test-workspace',
149
+ input: options.input ?? {},
150
+
151
+ step: {
152
+ async run<T>(name: string, fn: () => Promise<T>): Promise<T> {
153
+ if (seenNames.has(name)) throw new Error(duplicateStepMessage(name))
154
+ seenNames.add(name)
155
+ steps.push(name)
156
+ return await stepScope.run({ stepName: name, submitted: new Set<string>() }, async () => {
157
+ try {
158
+ const out = await fn()
159
+ calls.push({ kind: 'step', label: name, status: 'ok' })
160
+ return out
161
+ } catch (err) {
162
+ calls.push({ kind: 'step', label: name, status: 'error' })
163
+ throw err
164
+ }
165
+ })
166
+ },
167
+
168
+ async sleep(name: string): Promise<void> {
169
+ if (seenNames.has(name)) throw new Error(duplicateStepMessage(name))
170
+ seenNames.add(name)
171
+ steps.push(name)
172
+ // Recorded, never waited: a test suite that really slept out its
173
+ // backoffs would take minutes to say nothing.
174
+ calls.push({ kind: 'step', label: name, status: 'ok' })
175
+ },
176
+ },
177
+
178
+ async log(message, data) {
179
+ logs.push({ message, ...(data ? { data } : {}) })
180
+ calls.push({ kind: 'log', label: message })
181
+ },
182
+
183
+ async file(ref: { fileId: string }) {
184
+ calls.push({ kind: 'file', label: ref.fileId })
185
+ const stub = options.files?.[ref.fileId]
186
+ // Throwing beats a fabricated handle: a test that reads a file it never
187
+ // stubbed would otherwise pass on made-up bytes.
188
+ if (!stub) {
189
+ throw new Error(
190
+ `No file stub for "${ref.fileId}". Pass files: { '${ref.fileId}': { signedUrl: '…', ` +
191
+ "mimeType: '…', sizeBytes: 0, name: null } } to createTestContext.",
192
+ )
193
+ }
194
+ return { fileId: ref.fileId, ...stub }
195
+ },
196
+
197
+ agent(slug: string) {
198
+ return {
199
+ async run(prompt: string) {
200
+ requireGrant(`agent:${slug}:run`)
201
+ calls.push({ kind: 'agent', label: slug })
202
+ const stub = options.agents?.[slug]
203
+ // Throwing beats answering with an empty string: a test whose agent
204
+ // silently returns '' passes while asserting nothing about the step
205
+ // that matters most.
206
+ if (!stub) {
207
+ throw new Error(
208
+ `No agent stub for "${slug}". Pass agents: { '${slug}': () => ({ text: '…' }) } ` +
209
+ 'to createTestContext.',
210
+ )
211
+ }
212
+ return await stub(prompt)
213
+ },
214
+ }
215
+ },
216
+
217
+ plugin(install: string) {
218
+ return {
219
+ async call<T = unknown>(capability: string, input?: Record<string, unknown>) {
220
+ requireGrant(`plugin:${install}:${capability}`)
221
+ const stub = options.plugins?.[install]?.[capability]
222
+ // Before the record, matching the contract on `TestCall` and the
223
+ // `action` arm. (`agent` and `http` record first — a pre-existing
224
+ // divergence.)
225
+ if (!stub) {
226
+ throw new Error(
227
+ `No plugin stub for "${install}".${capability}. Pass ` +
228
+ // Quoted, unlike a bare identifier: an install name defaults to
229
+ // the catalog kind (kebab, e.g. "github-prod") and a capability
230
+ // can be dotted ("run.query") — neither survives as an object
231
+ // key without quotes, so the unquoted form the author would
232
+ // paste back in does not parse.
233
+ `plugins: { '${install}': { '${capability}': () => ({ data: … }) } } to createTestContext.`,
234
+ )
235
+ }
236
+ calls.push({ kind: 'plugin', label: `${install}:${capability}` })
237
+ return (await stub(input ?? {})) as PluginCallResult<T>
238
+ },
239
+ }
240
+ },
241
+
242
+ http: {
243
+ async fetch(req: HttpRequest) {
244
+ let host: string
245
+ try {
246
+ host = new URL(req.url).hostname.toLowerCase()
247
+ } catch {
248
+ throw new Error(`ctx.http: invalid URL ${req.url}`)
249
+ }
250
+ requireGrant(`http:${host}`)
251
+ calls.push({ kind: 'http', label: req.url })
252
+ // Same reasoning as the agent: a fabricated 200 is a false pass.
253
+ if (!options.http) {
254
+ throw new Error(
255
+ `No http stub. Pass http: (req) => ({ status: 200, headers: {}, body: '' }) ` +
256
+ 'to createTestContext.',
257
+ )
258
+ }
259
+ return await options.http(req)
260
+ },
261
+ },
262
+
263
+ action: {
264
+ async submit(request: ActionSubmission): Promise<ActionSubmitResult> {
265
+ const scope = stepScope.getStore()
266
+ if (!scope) throw new Error(submitOutsideStepMessage(request.action))
267
+ // The batch loop is the shape this catches, and a one-row fixture never
268
+ // reaches it — so the double has to enforce it or an author meets it
269
+ // for the first time on their second production row, after the first
270
+ // has already been applied.
271
+ if (request.submissionKey !== undefined && request.submissionKey.length === 0) {
272
+ throw new Error(emptySubmissionKeyMessage(request.action))
273
+ }
274
+ requireGrant(`governed:${request.action}`)
275
+ const stub = options.actions?.[request.action]
276
+ // Both refusals that mean "this never happened" come BEFORE the
277
+ // reservation, matching the runtime's grant check: reserving first left
278
+ // an author who fixed the missing stub and re-ran a loop facing a
279
+ // duplicate accusation for a call that never answered.
280
+ if (!stub) {
281
+ throw new Error(
282
+ `No action stub for "${request.action}". Pass actions: { '${request.action}': ` +
283
+ "() => ({ requestId: 'req-1', lifecycle: 'ready' }) } to createTestContext.",
284
+ )
285
+ }
286
+ const submissionIdentity = `${request.action}\u0000${request.submissionKey ?? ''}`
287
+ // Reserved synchronously and released on failure, matching the runtime
288
+ // exactly. A double that checked and recorded across an await would let
289
+ // `Promise.all([submit(x), submit(x)])` through — and a double that
290
+ // permits what production refuses is the one failure mode a double must
291
+ // not have.
292
+ if (scope.submitted.has(submissionIdentity)) {
293
+ throw new Error(duplicateSubmissionMessage(request.action))
294
+ }
295
+ scope.submitted.add(submissionIdentity)
296
+ calls.push({ kind: 'action', label: request.action })
297
+ try {
298
+ return await stub(request)
299
+ } catch (err) {
300
+ // A throwing stub stands in for a submission that never landed.
301
+ scope.submitted.delete(submissionIdentity)
302
+ throw err
303
+ }
304
+ },
305
+ },
306
+
307
+ blueprint: {
308
+ async query<T = Record<string, unknown>>(
309
+ objectType: string,
310
+ _options?: BlueprintQueryOptions,
311
+ ): Promise<BlueprintQueryResult<T>> {
312
+ requireGrant('blueprint:read')
313
+ calls.push({ kind: 'blueprint', label: objectType })
314
+ const stub = options.blueprint?.[objectType]
315
+ // Empty is a real answer, and the branch an author most often forgets
316
+ // to test — so this one defaults rather than throwing.
317
+ return (stub ?? { rows: [], hasMore: false }) as BlueprintQueryResult<T>
318
+ },
319
+ },
320
+ }
321
+
322
+ return { ctx, calls, steps, logs }
323
+ }