@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.
- package/CHANGELOG.md +422 -0
- package/LICENSE +21 -0
- package/README.md +34 -2
- package/dist/function/functions.types.d.ts +27 -0
- package/dist/index.d.ts +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/internal.js +1 -1
- package/dist/pikku-state.js +1 -0
- package/dist/services/http-scenario-actors.d.ts +12 -4
- package/dist/services/http-scenario-actors.js +47 -45
- package/dist/services/in-memory-queue-service.d.ts +6 -0
- package/dist/services/in-memory-queue-service.js +8 -1
- package/dist/services/in-memory-workflow-service.d.ts +3 -5
- package/dist/services/in-memory-workflow-service.js +10 -19
- package/dist/services/index.d.ts +2 -1
- package/dist/services/index.js +1 -0
- package/dist/services/meta-service.d.ts +5 -1
- package/dist/services/meta-service.js +44 -18
- package/dist/services/scenario-actors-service.d.ts +108 -2
- package/dist/services/scenario-actors-service.js +40 -1
- package/dist/services/workflow-service.d.ts +7 -5
- package/dist/types/core.types.d.ts +28 -3
- package/dist/types/state.types.d.ts +3 -1
- package/dist/wirings/actor-flow/actor-flow.types.d.ts +1 -1
- package/dist/wirings/actor-flow/index.d.ts +1 -1
- package/dist/wirings/actor-flow/run-conversation.d.ts +10 -10
- package/dist/wirings/actor-flow/run-conversation.js +27 -27
- package/dist/wirings/ai-agent/ai-agent-agui.js +0 -8
- package/dist/wirings/ai-agent/ai-agent-prepare.js +1 -2
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +0 -6
- package/dist/wirings/cli/command-parser.js +11 -1
- package/dist/wirings/rpc/rpc-runner.js +1 -1
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +52 -3
- package/dist/wirings/workflow/feature.d.ts +28 -0
- package/dist/wirings/workflow/feature.js +57 -0
- package/dist/wirings/workflow/graph/graph-runner.js +3 -2
- package/dist/wirings/workflow/graph/graph-validation.d.ts +0 -2
- package/dist/wirings/workflow/graph/graph-validation.js +0 -142
- package/dist/wirings/workflow/graph/index.d.ts +1 -1
- package/dist/wirings/workflow/graph/index.js +1 -1
- package/dist/wirings/workflow/index.d.ts +13 -3
- package/dist/wirings/workflow/index.js +15 -2
- package/dist/wirings/workflow/pikku-scenario-service.d.ts +121 -0
- package/dist/wirings/workflow/pikku-scenario-service.js +419 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +170 -23
- package/dist/wirings/workflow/pikku-workflow-service.js +338 -297
- package/dist/wirings/workflow/scenario-cookie-jar.d.ts +29 -0
- package/dist/wirings/workflow/scenario-cookie-jar.js +51 -0
- package/dist/wirings/workflow/scenario-poll.d.ts +20 -0
- package/dist/wirings/workflow/scenario-poll.js +25 -0
- package/dist/wirings/workflow/scenario-prose.d.ts +38 -0
- package/dist/wirings/workflow/scenario-prose.js +45 -0
- package/dist/wirings/workflow/scenario-step-guards.d.ts +16 -0
- package/dist/wirings/workflow/scenario-step-guards.js +29 -0
- package/dist/wirings/workflow/scenario-step.types.d.ts +148 -0
- package/dist/wirings/workflow/scenario-step.types.js +1 -0
- package/dist/wirings/workflow/workflow.types.d.ts +82 -8
- package/package.json +3 -1
- package/src/function/functions.types.ts +32 -0
- package/src/index.ts +1 -0
- package/src/internal.ts +5 -1
- package/src/pikku-state.ts +1 -0
- package/src/services/http-scenario-actors.test.ts +85 -1
- package/src/services/http-scenario-actors.ts +65 -51
- package/src/services/in-memory-queue-service.test.ts +66 -1
- package/src/services/in-memory-queue-service.ts +13 -2
- package/src/services/in-memory-workflow-service.ts +12 -25
- package/src/services/index.ts +5 -0
- package/src/services/meta-service.test.ts +79 -0
- package/src/services/meta-service.ts +61 -26
- package/src/services/scenario-actors-service.ts +157 -2
- package/src/services/workflow-service.ts +7 -4
- package/src/types/core.types.ts +34 -2
- package/src/types/state.types.ts +3 -0
- package/src/wirings/actor-flow/actor-flow.types.ts +1 -1
- package/src/wirings/actor-flow/index.ts +1 -1
- package/src/wirings/actor-flow/run-conversation.test.ts +12 -6
- package/src/wirings/actor-flow/run-conversation.ts +36 -41
- package/src/wirings/ai-agent/ai-agent-agui.test.ts +0 -16
- package/src/wirings/ai-agent/ai-agent-agui.ts +0 -9
- package/src/wirings/ai-agent/ai-agent-prepare.ts +1 -2
- package/src/wirings/ai-agent/ai-agent.types.ts +0 -7
- package/src/wirings/cli/command-parser.test.ts +60 -0
- package/src/wirings/cli/command-parser.ts +12 -1
- package/src/wirings/rpc/rpc-runner.test.ts +28 -5
- package/src/wirings/rpc/rpc-runner.ts +1 -1
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +86 -2
- package/src/wirings/workflow/feature.test.ts +131 -0
- package/src/wirings/workflow/feature.ts +78 -0
- package/src/wirings/workflow/graph/graph-runner.ts +3 -2
- package/src/wirings/workflow/graph/graph-validation.test.ts +1 -144
- package/src/wirings/workflow/graph/graph-validation.ts +0 -196
- package/src/wirings/workflow/graph/index.ts +1 -5
- package/src/wirings/workflow/index.ts +73 -6
- package/src/wirings/workflow/pikku-scenario-service.ts +682 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +55 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +572 -419
- package/src/wirings/workflow/scenario-cookie-jar.test.ts +108 -0
- package/src/wirings/workflow/scenario-cookie-jar.ts +65 -0
- package/src/wirings/workflow/scenario-expectations.test.ts +153 -0
- package/src/wirings/workflow/scenario-hooks.test.ts +212 -0
- package/src/wirings/workflow/scenario-poll.test.ts +66 -0
- package/src/wirings/workflow/scenario-poll.ts +36 -0
- package/src/wirings/workflow/scenario-prose.test.ts +152 -0
- package/src/wirings/workflow/scenario-prose.ts +79 -0
- package/src/wirings/workflow/scenario-service.test.ts +155 -0
- package/src/wirings/workflow/scenario-step-guards.ts +43 -0
- package/src/wirings/workflow/scenario-step.test.ts +442 -9
- package/src/wirings/workflow/scenario-step.types.ts +157 -0
- package/src/wirings/workflow/workflow-dispatch-durability.test.ts +1 -1
- package/src/wirings/workflow/workflow-dispatch-payload.test.ts +59 -0
- package/src/wirings/workflow/workflow-mirror.test.ts +178 -0
- package/src/wirings/workflow/workflow-replay-snapshot.test.ts +139 -0
- package/src/wirings/workflow/workflow-run-context.test.ts +177 -0
- package/src/wirings/workflow/workflow-run-polling.test.ts +132 -0
- package/src/wirings/workflow/workflow-step-ordinal.test.ts +4 -4
- package/src/wirings/workflow/workflow.types.ts +99 -5
- 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
|
+
}
|