@pikku/core 0.12.69 → 0.12.71

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.
Files changed (118) hide show
  1. package/CHANGELOG.md +422 -0
  2. package/LICENSE +21 -0
  3. package/README.md +34 -2
  4. package/dist/function/functions.types.d.ts +27 -0
  5. package/dist/index.d.ts +1 -1
  6. package/dist/internal.d.ts +1 -1
  7. package/dist/internal.js +1 -1
  8. package/dist/pikku-state.js +1 -0
  9. package/dist/services/http-scenario-actors.d.ts +12 -4
  10. package/dist/services/http-scenario-actors.js +47 -45
  11. package/dist/services/in-memory-queue-service.d.ts +6 -0
  12. package/dist/services/in-memory-queue-service.js +8 -1
  13. package/dist/services/in-memory-workflow-service.d.ts +3 -5
  14. package/dist/services/in-memory-workflow-service.js +10 -19
  15. package/dist/services/index.d.ts +2 -1
  16. package/dist/services/index.js +1 -0
  17. package/dist/services/meta-service.d.ts +5 -1
  18. package/dist/services/meta-service.js +44 -18
  19. package/dist/services/scenario-actors-service.d.ts +108 -2
  20. package/dist/services/scenario-actors-service.js +40 -1
  21. package/dist/services/workflow-service.d.ts +7 -5
  22. package/dist/types/core.types.d.ts +28 -3
  23. package/dist/types/state.types.d.ts +3 -1
  24. package/dist/wirings/actor-flow/actor-flow.types.d.ts +1 -1
  25. package/dist/wirings/actor-flow/index.d.ts +1 -1
  26. package/dist/wirings/actor-flow/run-conversation.d.ts +10 -10
  27. package/dist/wirings/actor-flow/run-conversation.js +27 -27
  28. package/dist/wirings/ai-agent/ai-agent-agui.js +0 -8
  29. package/dist/wirings/ai-agent/ai-agent-prepare.js +1 -2
  30. package/dist/wirings/ai-agent/ai-agent.types.d.ts +0 -6
  31. package/dist/wirings/cli/command-parser.js +11 -1
  32. package/dist/wirings/rpc/rpc-runner.js +1 -1
  33. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +52 -3
  34. package/dist/wirings/workflow/feature.d.ts +28 -0
  35. package/dist/wirings/workflow/feature.js +57 -0
  36. package/dist/wirings/workflow/graph/graph-runner.js +3 -2
  37. package/dist/wirings/workflow/graph/graph-validation.d.ts +0 -2
  38. package/dist/wirings/workflow/graph/graph-validation.js +0 -142
  39. package/dist/wirings/workflow/graph/index.d.ts +1 -1
  40. package/dist/wirings/workflow/graph/index.js +1 -1
  41. package/dist/wirings/workflow/index.d.ts +13 -3
  42. package/dist/wirings/workflow/index.js +15 -2
  43. package/dist/wirings/workflow/pikku-scenario-service.d.ts +121 -0
  44. package/dist/wirings/workflow/pikku-scenario-service.js +419 -0
  45. package/dist/wirings/workflow/pikku-workflow-service.d.ts +170 -23
  46. package/dist/wirings/workflow/pikku-workflow-service.js +338 -297
  47. package/dist/wirings/workflow/scenario-cookie-jar.d.ts +29 -0
  48. package/dist/wirings/workflow/scenario-cookie-jar.js +51 -0
  49. package/dist/wirings/workflow/scenario-poll.d.ts +20 -0
  50. package/dist/wirings/workflow/scenario-poll.js +25 -0
  51. package/dist/wirings/workflow/scenario-prose.d.ts +38 -0
  52. package/dist/wirings/workflow/scenario-prose.js +45 -0
  53. package/dist/wirings/workflow/scenario-step-guards.d.ts +16 -0
  54. package/dist/wirings/workflow/scenario-step-guards.js +29 -0
  55. package/dist/wirings/workflow/scenario-step.types.d.ts +148 -0
  56. package/dist/wirings/workflow/scenario-step.types.js +1 -0
  57. package/dist/wirings/workflow/workflow.types.d.ts +82 -8
  58. package/package.json +3 -1
  59. package/src/function/functions.types.ts +32 -0
  60. package/src/index.ts +1 -0
  61. package/src/internal.ts +5 -1
  62. package/src/pikku-state.ts +1 -0
  63. package/src/services/http-scenario-actors.test.ts +85 -1
  64. package/src/services/http-scenario-actors.ts +65 -51
  65. package/src/services/in-memory-queue-service.test.ts +66 -1
  66. package/src/services/in-memory-queue-service.ts +13 -2
  67. package/src/services/in-memory-workflow-service.ts +12 -25
  68. package/src/services/index.ts +5 -0
  69. package/src/services/meta-service.test.ts +79 -0
  70. package/src/services/meta-service.ts +61 -26
  71. package/src/services/scenario-actors-service.ts +157 -2
  72. package/src/services/workflow-service.ts +7 -4
  73. package/src/types/core.types.ts +34 -2
  74. package/src/types/state.types.ts +3 -0
  75. package/src/wirings/actor-flow/actor-flow.types.ts +1 -1
  76. package/src/wirings/actor-flow/index.ts +1 -1
  77. package/src/wirings/actor-flow/run-conversation.test.ts +12 -6
  78. package/src/wirings/actor-flow/run-conversation.ts +36 -41
  79. package/src/wirings/ai-agent/ai-agent-agui.test.ts +0 -16
  80. package/src/wirings/ai-agent/ai-agent-agui.ts +0 -9
  81. package/src/wirings/ai-agent/ai-agent-prepare.ts +1 -2
  82. package/src/wirings/ai-agent/ai-agent.types.ts +0 -7
  83. package/src/wirings/cli/command-parser.test.ts +60 -0
  84. package/src/wirings/cli/command-parser.ts +12 -1
  85. package/src/wirings/rpc/rpc-runner.test.ts +28 -5
  86. package/src/wirings/rpc/rpc-runner.ts +1 -1
  87. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +86 -2
  88. package/src/wirings/workflow/feature.test.ts +131 -0
  89. package/src/wirings/workflow/feature.ts +78 -0
  90. package/src/wirings/workflow/graph/graph-runner.ts +3 -2
  91. package/src/wirings/workflow/graph/graph-validation.test.ts +1 -144
  92. package/src/wirings/workflow/graph/graph-validation.ts +0 -196
  93. package/src/wirings/workflow/graph/index.ts +1 -5
  94. package/src/wirings/workflow/index.ts +73 -6
  95. package/src/wirings/workflow/pikku-scenario-service.ts +682 -0
  96. package/src/wirings/workflow/pikku-workflow-service.test.ts +55 -0
  97. package/src/wirings/workflow/pikku-workflow-service.ts +572 -419
  98. package/src/wirings/workflow/scenario-cookie-jar.test.ts +108 -0
  99. package/src/wirings/workflow/scenario-cookie-jar.ts +65 -0
  100. package/src/wirings/workflow/scenario-expectations.test.ts +153 -0
  101. package/src/wirings/workflow/scenario-hooks.test.ts +212 -0
  102. package/src/wirings/workflow/scenario-poll.test.ts +66 -0
  103. package/src/wirings/workflow/scenario-poll.ts +36 -0
  104. package/src/wirings/workflow/scenario-prose.test.ts +152 -0
  105. package/src/wirings/workflow/scenario-prose.ts +79 -0
  106. package/src/wirings/workflow/scenario-service.test.ts +155 -0
  107. package/src/wirings/workflow/scenario-step-guards.ts +43 -0
  108. package/src/wirings/workflow/scenario-step.test.ts +442 -9
  109. package/src/wirings/workflow/scenario-step.types.ts +157 -0
  110. package/src/wirings/workflow/workflow-dispatch-durability.test.ts +1 -1
  111. package/src/wirings/workflow/workflow-dispatch-payload.test.ts +59 -0
  112. package/src/wirings/workflow/workflow-mirror.test.ts +178 -0
  113. package/src/wirings/workflow/workflow-replay-snapshot.test.ts +139 -0
  114. package/src/wirings/workflow/workflow-run-context.test.ts +177 -0
  115. package/src/wirings/workflow/workflow-run-polling.test.ts +132 -0
  116. package/src/wirings/workflow/workflow-step-ordinal.test.ts +4 -4
  117. package/src/wirings/workflow/workflow.types.ts +99 -5
  118. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,108 @@
1
+ import { describe, test, after } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { createServer, type Server } from 'node:http'
4
+
5
+ import { createCookieJar } from './scenario-cookie-jar.js'
6
+
7
+ // Minimal target: hands out cookies, and reports back what it was sent.
8
+ const startTarget = async () => {
9
+ let issued = 0
10
+ const server: Server = createServer((req, res) => {
11
+ if (req.url === '/set') {
12
+ issued++
13
+ res.setHeader('set-cookie', [
14
+ `session=s${issued}; Path=/; HttpOnly`,
15
+ `csrf=c${issued}; Path=/`,
16
+ ])
17
+ res.writeHead(200, { 'content-type': 'application/json' }).end('{}')
18
+ return
19
+ }
20
+ res.writeHead(200, { 'content-type': 'application/json' }).end(
21
+ JSON.stringify({
22
+ cookie: req.headers.cookie ?? '',
23
+ origin: req.headers.origin ?? '',
24
+ })
25
+ )
26
+ })
27
+ await new Promise<void>((done) => server.listen(0, '127.0.0.1', done))
28
+ const { port } = server.address() as { port: number }
29
+ return { server, apiUrl: `http://127.0.0.1:${port}` }
30
+ }
31
+
32
+ describe('createCookieJar', () => {
33
+ test('sends back the cookies the target set, on the next request', async () => {
34
+ const { server, apiUrl } = await startTarget()
35
+ after(() => server.close())
36
+
37
+ const { fetch: call } = createCookieJar(apiUrl)
38
+ await call(`${apiUrl}/set`)
39
+ const seen = await (await call(`${apiUrl}/echo`)).json()
40
+
41
+ assert.match(seen.cookie, /session=s1/)
42
+ assert.match(seen.cookie, /csrf=c1/)
43
+ })
44
+
45
+ test('stamps the origin Better Auth checks on state-changing calls', async () => {
46
+ const { server, apiUrl } = await startTarget()
47
+ after(() => server.close())
48
+
49
+ const seen = await (
50
+ await createCookieJar(apiUrl).fetch(`${apiUrl}/echo`)
51
+ ).json()
52
+
53
+ assert.equal(seen.origin, apiUrl)
54
+ })
55
+
56
+ test('replaces a cookie the target reissues rather than sending both', async () => {
57
+ const { server, apiUrl } = await startTarget()
58
+ after(() => server.close())
59
+
60
+ const { fetch: call } = createCookieJar(apiUrl)
61
+ await call(`${apiUrl}/set`)
62
+ await call(`${apiUrl}/set`)
63
+ const seen = await (await call(`${apiUrl}/echo`)).json()
64
+
65
+ assert.match(seen.cookie, /session=s2/)
66
+ assert.doesNotMatch(seen.cookie, /session=s1/)
67
+ })
68
+
69
+ test('keeps each jar to itself, so one actor never inherits another session', async () => {
70
+ const { server, apiUrl } = await startTarget()
71
+ after(() => server.close())
72
+
73
+ const first = createCookieJar(apiUrl)
74
+ await first.fetch(`${apiUrl}/set`)
75
+ const other = await (
76
+ await createCookieJar(apiUrl).fetch(`${apiUrl}/echo`)
77
+ ).json()
78
+
79
+ assert.equal(other.cookie, '')
80
+ })
81
+
82
+ test('forgets the session when cleared, so a re-login starts fresh', async () => {
83
+ const { server, apiUrl } = await startTarget()
84
+ after(() => server.close())
85
+
86
+ const jar = createCookieJar(apiUrl)
87
+ await jar.fetch(`${apiUrl}/set`)
88
+ assert.equal(jar.empty, false)
89
+ jar.clear()
90
+ assert.equal(jar.empty, true)
91
+
92
+ const seen = await (await jar.fetch(`${apiUrl}/echo`)).json()
93
+ assert.equal(seen.cookie, '')
94
+ })
95
+
96
+ test('leaves the caller headers alone', async () => {
97
+ const { server, apiUrl } = await startTarget()
98
+ after(() => server.close())
99
+
100
+ const headers = new Headers({ 'x-test': 'kept' })
101
+ const { fetch: call } = createCookieJar(apiUrl)
102
+ await call(`${apiUrl}/set`)
103
+ await call(`${apiUrl}/echo`, { headers })
104
+
105
+ assert.equal(headers.get('cookie'), null)
106
+ assert.equal(headers.get('x-test'), 'kept')
107
+ })
108
+ })
@@ -0,0 +1,65 @@
1
+ /** A session held the way a browser holds one, for code driving a real target. */
2
+ export interface ScenarioCookieJar {
3
+ /** `fetch`, but it sends what the target has set and keeps what it sets. */
4
+ fetch: typeof fetch
5
+ /** Forgets the session — what an actor does before signing in again. */
6
+ clear(): void
7
+ /**
8
+ * Whether the target has set anything yet. This is a fact about the jar, not
9
+ * about the session: a target that sets a CSRF or locale cookie before anyone
10
+ * signs in fills the jar without establishing one. Whoever needs to know
11
+ * whether a sign-in happened has to track the sign-in.
12
+ */
13
+ readonly empty: boolean
14
+ }
15
+
16
+ /**
17
+ * A `fetch` that remembers cookies.
18
+ *
19
+ * A browser persists the session cookie on its own; anything driving a target
20
+ * from this process has to. Every response is read, not just the sign-in, so a
21
+ * cookie the target rotates mid-session is followed rather than dropped.
22
+ *
23
+ * The jar is a closure local, so two jars never share a session — which is what
24
+ * lets one scenario sign in as several people without one of them inheriting
25
+ * the other's session.
26
+ *
27
+ * It also stamps `Origin`, because Better Auth rejects a state-changing POST
28
+ * whose Origin does not match its baseURL.
29
+ */
30
+ export const createCookieJar = (apiUrl: string): ScenarioCookieJar => {
31
+ const jar = new Map<string, string>()
32
+ const origin = new URL(apiUrl).origin
33
+ return {
34
+ fetch: async (input, init) => {
35
+ const headers = new Headers(init?.headers)
36
+ headers.set('origin', origin)
37
+ const held = [...jar].map(([name, value]) => `${name}=${value}`)
38
+ const caller = headers.get('cookie')
39
+ if (held.length > 0 || caller) {
40
+ headers.set('cookie', [caller, ...held].filter(Boolean).join('; '))
41
+ }
42
+ const response = await fetch(input, { ...init, headers })
43
+ for (const raw of response.headers.getSetCookie()) {
44
+ const [pair] = raw.split(';')
45
+ const separator = pair!.indexOf('=')
46
+ if (separator > 0) {
47
+ const name = pair!.slice(0, separator)
48
+ const value = pair!.slice(separator + 1)
49
+ // An empty value is how a target deletes a cookie — drop the name
50
+ // rather than holding a cookie whose value says it is gone.
51
+ if (value) {
52
+ jar.set(name, value)
53
+ } else {
54
+ jar.delete(name)
55
+ }
56
+ }
57
+ }
58
+ return response
59
+ },
60
+ clear: () => jar.clear(),
61
+ get empty() {
62
+ return jar.size === 0
63
+ },
64
+ }
65
+ }
@@ -0,0 +1,153 @@
1
+ import { describe, test, beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { createScenarioRunner } from './pikku-scenario-service.js'
5
+ import type { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
6
+ import { pikkuState, resetPikkuState } from '../../pikku-state.js'
7
+ import type { ScenarioActor } from '../../services/scenario-actors-service.js'
8
+
9
+ const noopLogger = { error() {}, info() {}, warn() {}, debug() {} }
10
+
11
+ const fakeActor = (
12
+ name: string,
13
+ handler: (rpcName: string, data: unknown) => Promise<unknown>
14
+ ): ScenarioActor => ({
15
+ name,
16
+ email: `${name}@actors.local`,
17
+ invoke: async (rpcName: string, data: unknown) => handler(rpcName, data),
18
+ invokeRaw: async (rpcName: string, data: unknown) => ({
19
+ status: 200,
20
+ ok: true,
21
+ body: await handler(rpcName, data),
22
+ }),
23
+ })
24
+
25
+ const setup = async (ws: InMemoryWorkflowService) => {
26
+ pikkuState(null, 'package', 'singletonServices', {
27
+ logger: noopLogger,
28
+ } as any)
29
+ const runId = await ws.createRun('scenarioTest', {}, true, 'hash', {
30
+ type: 'test',
31
+ } as any)
32
+ ws.registerInlineRun(runId)
33
+ return runId
34
+ }
35
+
36
+ /**
37
+ * The scenario wire, wrapped rather than returned directly: it carries a
38
+ * `then` — the Gherkin phase, not a promise — so awaiting one is awaiting a
39
+ * thenable, and the runtime calls it as a step.
40
+ */
41
+ const scenarioWire = async (rpcService: any = {}): Promise<{ wire: any }> => {
42
+ const { workflowService } = createScenarioRunner()
43
+ const ws = workflowService as InMemoryWorkflowService
44
+ const runId = await setup(ws)
45
+ return { wire: ws.createWorkflowWire('scenarioTest', runId, rpcService) }
46
+ }
47
+
48
+ describe('workflow.expectError', () => {
49
+ beforeEach(() => resetPikkuState())
50
+
51
+ test('returns the message when the call throws as expected', async () => {
52
+ const intruder = fakeActor('intruder', async () => {
53
+ throw new Error('Forbidden: not your todo')
54
+ })
55
+ const { wire } = await scenarioWire()
56
+
57
+ const message = await wire.expectError(
58
+ 'intruder cannot delete',
59
+ 'deleteTodo',
60
+ { todoId: 't1' },
61
+ { actor: intruder }
62
+ )
63
+
64
+ assert.equal(message, 'Forbidden: not your todo')
65
+ })
66
+
67
+ test('fails when the call succeeds instead of throwing', async () => {
68
+ const { wire } = await scenarioWire({
69
+ rpcWithWire: async () => ({ deleted: true }),
70
+ })
71
+
72
+ await assert.rejects(
73
+ wire.expectError('should have failed', 'deleteTodo', {}, { retries: 0 }),
74
+ /expected an error but the call succeeded/
75
+ )
76
+ })
77
+
78
+ test('fails when the error message does not match', async () => {
79
+ const { wire } = await scenarioWire({
80
+ rpcWithWire: async () => {
81
+ throw new Error('database offline')
82
+ },
83
+ })
84
+
85
+ await assert.rejects(
86
+ wire.expectError(
87
+ 'wrong reason',
88
+ 'deleteTodo',
89
+ {},
90
+ { matches: /Forbidden/, retries: 0 }
91
+ ),
92
+ /did not match .*database offline/
93
+ )
94
+ })
95
+ })
96
+
97
+ describe('workflow.expectService', () => {
98
+ beforeEach(() => resetPikkuState())
99
+
100
+ const stubCalls = (
101
+ calls: Array<{ service: string; method: string; args: unknown[] }>
102
+ ) => ({
103
+ rpcWithWire: async (rpcName: string) => {
104
+ assert.equal(rpcName, 'pikkuScenarioGetStubCalls')
105
+ return calls
106
+ },
107
+ })
108
+
109
+ const oneEmail = () =>
110
+ stubCalls([{ service: 'email', method: 'send', args: [{ to: 'a@b.c' }] }])
111
+
112
+ test('passes when the service method was called', async () => {
113
+ const { wire } = await scenarioWire(oneEmail())
114
+
115
+ await wire.expectService('an email went out', 'email.send')
116
+ })
117
+
118
+ test('a call count that does not match names what was recorded', async () => {
119
+ const { wire } = await scenarioWire(oneEmail())
120
+
121
+ await assert.rejects(
122
+ wire.expectService('two emails', 'email.send', {
123
+ times: 2,
124
+ retries: 0,
125
+ }),
126
+ /expected 2 call\(s\).*found 1.*email\.send/s
127
+ )
128
+ })
129
+
130
+ test('calledWith matches on the first argument', async () => {
131
+ const { wire } = await scenarioWire(oneEmail())
132
+
133
+ await wire.expectService('emailed the customer', 'email.send', {
134
+ calledWith: { to: 'a@b.c' },
135
+ })
136
+ await assert.rejects(
137
+ wire.expectService('emailed someone else', 'email.send', {
138
+ calledWith: { to: 'x@y.z' },
139
+ retries: 0,
140
+ }),
141
+ /found 0/
142
+ )
143
+ })
144
+
145
+ test('a name that is not service.method is rejected outright', async () => {
146
+ const { wire } = await scenarioWire(stubCalls([]))
147
+
148
+ await assert.rejects(
149
+ wire.expectService('bad name', 'emailsend'),
150
+ /needs 'service\.method'/
151
+ )
152
+ })
153
+ })
@@ -0,0 +1,212 @@
1
+ import { describe, test, beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { createScenarioRunner } from './pikku-scenario-service.js'
5
+ import { pikkuState, resetPikkuState } from '../../pikku-state.js'
6
+ import { addWorkflow } from './dsl/workflow-runner.js'
7
+ import type { ScenarioActors } from '../../services/scenario-actors-service.js'
8
+
9
+ const noopLogger = {
10
+ error() {},
11
+ info() {},
12
+ warn() {},
13
+ debug() {},
14
+ }
15
+
16
+ const actors = {
17
+ admin: {
18
+ name: 'admin',
19
+ email: 'admin@actors.local',
20
+ invoke: async () => ({}),
21
+ invokeRaw: async () => ({ status: 200, ok: true, body: {} }),
22
+ },
23
+ } as unknown as ScenarioActors
24
+
25
+ type ScenarioHooks = {
26
+ before?: (services: any, data: any, wire: any) => Promise<void> | void
27
+ after?: (services: any, data: any, wire: any) => Promise<void> | void
28
+ }
29
+
30
+ /**
31
+ * Registers a scenario (or, with `source: 'dsl'`, a plain workflow) and runs it
32
+ * inline, returning the error it threw rather than propagating — every hook
33
+ * assertion here is about what ran, not about how the caller saw it.
34
+ */
35
+ const runScenario = async (
36
+ name: string,
37
+ config: ScenarioHooks & { func: (...args: any[]) => any },
38
+ { input = {}, source = 'scenario' as 'scenario' | 'dsl' } = {}
39
+ ) => {
40
+ const { workflowService: ws, scenarioService } = createScenarioRunner()
41
+
42
+ pikkuState(null, 'package', 'singletonServices', {
43
+ logger: noopLogger,
44
+ marker: 'singleton',
45
+ } as any)
46
+
47
+ const metaState = pikkuState(null, 'workflows', 'meta')
48
+ metaState[name] = {
49
+ name,
50
+ pikkuFuncId: name,
51
+ source,
52
+ graphHash: `${name}-hash`,
53
+ } as any
54
+
55
+ const functionMetaState = pikkuState(null, 'function', 'meta')
56
+ functionMetaState[name] = {
57
+ name,
58
+ sessionless: true,
59
+ permissions: [],
60
+ } as any
61
+
62
+ addWorkflow(name, config as any)
63
+
64
+ let error: any
65
+ try {
66
+ await ws.startWorkflow(name, input, {} as any, {}, { actors })
67
+ } catch (e: any) {
68
+ error = e
69
+ }
70
+ const runs = await ws.listRuns({ workflowName: name })
71
+ const run = runs[0]
72
+ return { error, run, ws }
73
+ }
74
+
75
+ describe('scenario before/after hooks', () => {
76
+ beforeEach(() => resetPikkuState())
77
+
78
+ test('before runs, then the scenario, then after', async () => {
79
+ const order: string[] = []
80
+ await runScenario('hooksOrder', {
81
+ before: async () => {
82
+ order.push('before')
83
+ },
84
+ after: async () => {
85
+ order.push('after')
86
+ },
87
+ func: async () => {
88
+ order.push('func')
89
+ return { ok: true }
90
+ },
91
+ })
92
+ assert.deepEqual(order, ['before', 'func', 'after'])
93
+ })
94
+
95
+ test('after still runs when the scenario throws', async () => {
96
+ const order: string[] = []
97
+ const { error, run } = await runScenario('hooksAfterOnFailure', {
98
+ after: async () => {
99
+ order.push('after')
100
+ },
101
+ func: async () => {
102
+ order.push('func')
103
+ throw new Error('scenario blew up')
104
+ },
105
+ })
106
+ assert.deepEqual(order, ['func', 'after'])
107
+ assert.equal(error?.message, 'scenario blew up')
108
+ assert.equal(run?.status, 'failed')
109
+ })
110
+
111
+ test('before throwing skips the scenario but still runs after', async () => {
112
+ const order: string[] = []
113
+ const { error, run } = await runScenario('hooksBeforeThrows', {
114
+ before: async () => {
115
+ order.push('before')
116
+ throw new Error('setup failed')
117
+ },
118
+ after: async () => {
119
+ order.push('after')
120
+ },
121
+ func: async () => {
122
+ order.push('func')
123
+ return { ok: true }
124
+ },
125
+ })
126
+ assert.deepEqual(order, ['before', 'after'], 'the func must not run')
127
+ assert.match(String(error?.message), /setup failed/)
128
+ assert.equal(run?.status, 'failed')
129
+ })
130
+
131
+ test('after throwing fails a run that would otherwise have passed', async () => {
132
+ const { error, run } = await runScenario('hooksAfterThrows', {
133
+ after: async () => {
134
+ throw new Error('teardown failed')
135
+ },
136
+ func: async () => ({ ok: true }),
137
+ })
138
+ assert.match(String(error?.message), /teardown failed/)
139
+ assert.equal(run?.status, 'failed')
140
+ })
141
+
142
+ test('after throwing never masks the original failure', async () => {
143
+ const { error } = await runScenario('hooksAfterMasks', {
144
+ after: async () => {
145
+ throw new Error('teardown failed')
146
+ },
147
+ func: async () => {
148
+ throw new Error('the real failure')
149
+ },
150
+ })
151
+ assert.equal(
152
+ error?.message,
153
+ 'the real failure',
154
+ 'the scenario failure must be what the caller sees'
155
+ )
156
+ assert.match(
157
+ String((error as any)?.cause?.message),
158
+ /teardown failed/,
159
+ 'the teardown failure must survive as the cause'
160
+ )
161
+ })
162
+
163
+ test('hooks receive the services, the input and the run actors', async () => {
164
+ const seen: any[] = []
165
+ await runScenario(
166
+ 'hooksArgs',
167
+ {
168
+ before: async (services, data, wire) => {
169
+ seen.push({
170
+ marker: services.marker,
171
+ data,
172
+ actor: wire?.actors?.admin?.name,
173
+ })
174
+ },
175
+ after: async (services, data, wire) => {
176
+ seen.push({
177
+ marker: services.marker,
178
+ data,
179
+ actor: wire?.actors?.admin?.name,
180
+ })
181
+ },
182
+ func: async () => ({ ok: true }),
183
+ },
184
+ { input: { seed: 7 } }
185
+ )
186
+ assert.deepEqual(seen, [
187
+ { marker: 'singleton', data: { seed: 7 }, actor: 'admin' },
188
+ { marker: 'singleton', data: { seed: 7 }, actor: 'admin' },
189
+ ])
190
+ })
191
+
192
+ test('hooks are scenario-only — a plain DSL workflow never runs them', async () => {
193
+ const order: string[] = []
194
+ await runScenario(
195
+ 'hooksNotForWorkflows',
196
+ {
197
+ before: async () => {
198
+ order.push('before')
199
+ },
200
+ after: async () => {
201
+ order.push('after')
202
+ },
203
+ func: async () => {
204
+ order.push('func')
205
+ return { ok: true }
206
+ },
207
+ },
208
+ { source: 'dsl' }
209
+ )
210
+ assert.deepEqual(order, ['func'])
211
+ })
212
+ })
@@ -0,0 +1,66 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { pollUntil } from './scenario-poll.js'
5
+
6
+ describe('pollUntil', () => {
7
+ test('answers with the first attempt that produced something', async () => {
8
+ let attempts = 0
9
+ const found = await pollUntil(
10
+ () => {
11
+ attempts++
12
+ return attempts === 3 ? 'ready' : undefined
13
+ },
14
+ { intervalMs: 1 }
15
+ )
16
+
17
+ assert.equal(found, 'ready')
18
+ assert.equal(attempts, 3)
19
+ })
20
+
21
+ test('gives up at the deadline rather than looping forever', async () => {
22
+ const started = Date.now()
23
+ const found = await pollUntil(() => undefined, {
24
+ timeoutMs: 30,
25
+ intervalMs: 5,
26
+ })
27
+
28
+ assert.equal(found, undefined)
29
+ assert.ok(Date.now() - started < 1_000, 'returned promptly')
30
+ })
31
+
32
+ test('always attempts at least once, however short the deadline', async () => {
33
+ let attempts = 0
34
+ const found = await pollUntil(
35
+ () => {
36
+ attempts++
37
+ return 'immediate'
38
+ },
39
+ { timeoutMs: 0 }
40
+ )
41
+
42
+ assert.equal(attempts, 1)
43
+ assert.equal(found, 'immediate')
44
+ })
45
+
46
+ test('awaits an async attempt', async () => {
47
+ const found = await pollUntil(async () => 'awaited', { intervalMs: 1 })
48
+ assert.equal(found, 'awaited')
49
+ })
50
+
51
+ test('treats a falsy-but-defined answer as an answer', async () => {
52
+ // `false` is what a "did it happen?" probe returns, and it is a result —
53
+ // only `undefined` means "not yet".
54
+ let attempts = 0
55
+ const found = await pollUntil(
56
+ () => {
57
+ attempts++
58
+ return false
59
+ },
60
+ { timeoutMs: 50, intervalMs: 1 }
61
+ )
62
+
63
+ assert.equal(found, false)
64
+ assert.equal(attempts, 1)
65
+ })
66
+ })
@@ -0,0 +1,36 @@
1
+ /** How long to keep trying, and how long to wait between tries. */
2
+ export interface PollOptions {
3
+ /** Total time to keep attempting before giving up. Default 15s. */
4
+ timeoutMs?: number
5
+ /** Gap between attempts. Default 250ms. */
6
+ intervalMs?: number
7
+ }
8
+
9
+ /**
10
+ * Attempt something until it answers, or the deadline passes.
11
+ *
12
+ * `undefined` means "not yet" and nothing else — `false`, `0` and `''` are all
13
+ * answers, because a probe asking whether something happened reports `false`
14
+ * when it did not. Answering `undefined` at the deadline rather than throwing
15
+ * leaves the error to the caller, who is the only one who knows what was being
16
+ * waited for.
17
+ *
18
+ * A step that polls its target — a delivery reaching a terminal status, a run
19
+ * finishing — reaches for this rather than writing the deadline loop again.
20
+ */
21
+ export const pollUntil = async <T>(
22
+ attempt: () => Promise<T | undefined> | T | undefined,
23
+ { timeoutMs = 15_000, intervalMs = 250 }: PollOptions = {}
24
+ ): Promise<T | undefined> => {
25
+ const deadline = Date.now() + timeoutMs
26
+ for (;;) {
27
+ const answer = await attempt()
28
+ if (answer !== undefined) {
29
+ return answer
30
+ }
31
+ if (Date.now() >= deadline) {
32
+ return undefined
33
+ }
34
+ await new Promise((done) => setTimeout(done, intervalMs))
35
+ }
36
+ }