@pikku/core 0.12.67 → 0.12.70
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 +397 -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-workflow-service.d.ts +2 -2
- package/dist/services/in-memory-workflow-service.js +2 -2
- 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/types/core.types.d.ts +21 -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-prepare.d.ts +18 -1
- package/dist/wirings/ai-agent/ai-agent-prepare.js +26 -4
- package/dist/wirings/cli/command-parser.js +11 -1
- package/dist/wirings/queue/index.d.ts +1 -1
- package/dist/wirings/queue/queue.types.d.ts +30 -0
- 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/index.d.ts +13 -2
- package/dist/wirings/workflow/index.js +15 -0
- 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 +118 -12
- package/dist/wirings/workflow/pikku-workflow-service.js +166 -153
- 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 +119 -2
- 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-workflow-service.test.ts +50 -1
- package/src/services/in-memory-workflow-service.ts +3 -2
- 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/types/core.types.ts +27 -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-prepare.test.ts +29 -0
- package/src/wirings/ai-agent/ai-agent-prepare.ts +38 -4
- package/src/wirings/cli/command-parser.test.ts +60 -0
- package/src/wirings/cli/command-parser.ts +12 -1
- package/src/wirings/queue/index.ts +2 -0
- package/src/wirings/queue/queue.types.ts +32 -0
- 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/index.ts +74 -0
- package/src/wirings/workflow/pikku-scenario-service.ts +682 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +126 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +306 -228
- 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-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 +441 -8
- package/src/wirings/workflow/scenario-step.types.ts +157 -0
- package/src/wirings/workflow/workflow.types.ts +137 -1
- 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,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
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { composeStepProse, renderStepTemplate } from './scenario-prose.js'
|
|
5
|
+
|
|
6
|
+
describe('renderStepTemplate', () => {
|
|
7
|
+
test('substitutes the step input into its placeholders', () => {
|
|
8
|
+
assert.equal(
|
|
9
|
+
renderStepTemplate('sees {packageName}', {
|
|
10
|
+
packageName: '@pikku/addon-stripe',
|
|
11
|
+
}),
|
|
12
|
+
'sees @pikku/addon-stripe'
|
|
13
|
+
)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('renders numbers and booleans as words rather than JSON', () => {
|
|
17
|
+
assert.equal(
|
|
18
|
+
renderStepTemplate('sees at least {atLeast} addons, listed {listed}', {
|
|
19
|
+
atLeast: 10,
|
|
20
|
+
listed: true,
|
|
21
|
+
}),
|
|
22
|
+
'sees at least 10 addons, listed true'
|
|
23
|
+
)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('an absent optional value leaves no gap in the sentence', () => {
|
|
27
|
+
assert.equal(
|
|
28
|
+
renderStepTemplate('sees {packageName} {state}', {
|
|
29
|
+
packageName: '@pikku/addon-stripe',
|
|
30
|
+
}),
|
|
31
|
+
'sees @pikku/addon-stripe'
|
|
32
|
+
)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('a placeholder repeated in the template is substituted each time', () => {
|
|
36
|
+
assert.equal(
|
|
37
|
+
renderStepTemplate('searches {query} and sees {query}', {
|
|
38
|
+
query: 'stripe',
|
|
39
|
+
}),
|
|
40
|
+
'searches stripe and sees stripe'
|
|
41
|
+
)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('a non-primitive value is serialised compactly', () => {
|
|
45
|
+
assert.equal(
|
|
46
|
+
renderStepTemplate('sends {payload}', { payload: { a: 1 } }),
|
|
47
|
+
'sends {"a":1}'
|
|
48
|
+
)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('no recorded input leaves the template legible rather than half-rendered', () => {
|
|
52
|
+
assert.equal(renderStepTemplate('sees {packageName}', undefined), 'sees')
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('composeStepProse', () => {
|
|
57
|
+
test('renders a template against the step input in place of the description', () => {
|
|
58
|
+
assert.equal(
|
|
59
|
+
composeStepProse({
|
|
60
|
+
phase: 'then',
|
|
61
|
+
description: 'sees an addon in the gallery',
|
|
62
|
+
template: 'sees {packageName}',
|
|
63
|
+
input: { packageName: '@pikku/addon-todos' },
|
|
64
|
+
actor: 'admin',
|
|
65
|
+
}),
|
|
66
|
+
'Then the admin sees @pikku/addon-todos'
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('falls back to the description when the step declares no template', () => {
|
|
71
|
+
assert.equal(
|
|
72
|
+
composeStepProse({
|
|
73
|
+
phase: 'then',
|
|
74
|
+
description: 'sees an addon in the gallery',
|
|
75
|
+
input: { packageName: '@pikku/addon-todos' },
|
|
76
|
+
actor: 'admin',
|
|
77
|
+
}),
|
|
78
|
+
'Then the admin sees an addon in the gallery'
|
|
79
|
+
)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('composeStepProse basics', () => {
|
|
84
|
+
test('renders the gherkin keyword, the actor and the description', () => {
|
|
85
|
+
assert.equal(
|
|
86
|
+
composeStepProse({
|
|
87
|
+
phase: 'given',
|
|
88
|
+
description: 'buys an apple',
|
|
89
|
+
actor: 'shopper',
|
|
90
|
+
}),
|
|
91
|
+
'Given the shopper buys an apple'
|
|
92
|
+
)
|
|
93
|
+
assert.equal(
|
|
94
|
+
composeStepProse({
|
|
95
|
+
phase: 'then',
|
|
96
|
+
description: 'sees a receipt',
|
|
97
|
+
actor: 'shopper',
|
|
98
|
+
}),
|
|
99
|
+
'Then the shopper sees a receipt'
|
|
100
|
+
)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('an actorless step reads as a plain sentence', () => {
|
|
104
|
+
assert.equal(
|
|
105
|
+
composeStepProse({
|
|
106
|
+
phase: 'given',
|
|
107
|
+
description: 'the app data is reset',
|
|
108
|
+
}),
|
|
109
|
+
'Given the app data is reset'
|
|
110
|
+
)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('the neutral `step` phase adds no keyword', () => {
|
|
114
|
+
assert.equal(
|
|
115
|
+
composeStepProse({
|
|
116
|
+
phase: 'step',
|
|
117
|
+
description: 'refreshes the dashboard',
|
|
118
|
+
actor: 'admin',
|
|
119
|
+
}),
|
|
120
|
+
'the admin refreshes the dashboard'
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('a keyword width lines the sentences up under each other', () => {
|
|
125
|
+
const rendered = (
|
|
126
|
+
[
|
|
127
|
+
{ phase: 'given', description: 'buys an apple' },
|
|
128
|
+
{ phase: 'when', description: 'checks out' },
|
|
129
|
+
{ phase: 'then', description: 'sees a receipt' },
|
|
130
|
+
{ phase: 'step', description: 'waits' },
|
|
131
|
+
] as const
|
|
132
|
+
).map((step) =>
|
|
133
|
+
composeStepProse({ ...step, actor: 'shopper', keywordWidth: 5 })
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
assert.deepEqual(rendered, [
|
|
137
|
+
'Given the shopper buys an apple',
|
|
138
|
+
'When the shopper checks out',
|
|
139
|
+
'Then the shopper sees a receipt',
|
|
140
|
+
' the shopper waits',
|
|
141
|
+
])
|
|
142
|
+
const columns = new Set(rendered.map((line) => line.indexOf('the shopper')))
|
|
143
|
+
assert.equal(columns.size, 1, 'every sentence starts in the same column')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('prose degrades to just the description when nothing else is known', () => {
|
|
147
|
+
assert.equal(
|
|
148
|
+
composeStepProse({ phase: 'step', description: 'does the thing' }),
|
|
149
|
+
'does the thing'
|
|
150
|
+
)
|
|
151
|
+
})
|
|
152
|
+
})
|