@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
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { describe, test, beforeEach } from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { createScenarioRunner } from './pikku-scenario-service.js'
|
|
5
|
+
import type { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
|
|
5
6
|
import { pikkuState, resetPikkuState } from '../../pikku-state.js'
|
|
7
|
+
import { addFunction } from '../../function/function-runner.js'
|
|
6
8
|
import type { ScenarioActor } from '../../services/scenario-actors-service.js'
|
|
9
|
+
import type { PikkuWire } from '../../types/core.types.js'
|
|
10
|
+
import { requireActor, requireScenarioEnv } from './scenario-step-guards.js'
|
|
7
11
|
|
|
8
12
|
const noopLogger = { error() {}, info() {}, warn() {}, debug() {} }
|
|
9
13
|
|
|
@@ -20,6 +24,10 @@ const fakeActor = (
|
|
|
20
24
|
calls.push({ rpcName, data })
|
|
21
25
|
return handler(rpcName, data)
|
|
22
26
|
},
|
|
27
|
+
invokeRaw: async (rpcName: string, data: unknown) => {
|
|
28
|
+
calls.push({ rpcName, data })
|
|
29
|
+
return { status: 200, ok: true, body: await handler(rpcName, data) }
|
|
30
|
+
},
|
|
23
31
|
}
|
|
24
32
|
}
|
|
25
33
|
|
|
@@ -42,7 +50,7 @@ describe('scenario actor steps (workflow.do with `actor`)', () => {
|
|
|
42
50
|
beforeEach(() => resetPikkuState())
|
|
43
51
|
|
|
44
52
|
test('routes through the actor over the real transport, never internal rpc', async () => {
|
|
45
|
-
const ws =
|
|
53
|
+
const { workflowService: ws, scenarioService } = createScenarioRunner()
|
|
46
54
|
const customer = fakeActor('customer', async () => ({ todoId: 't1' }))
|
|
47
55
|
let internalCalls = 0
|
|
48
56
|
|
|
@@ -70,7 +78,7 @@ describe('scenario actor steps (workflow.do with `actor`)', () => {
|
|
|
70
78
|
})
|
|
71
79
|
|
|
72
80
|
test('step is recorded durably and replay returns the cached result without re-invoking', async () => {
|
|
73
|
-
const ws =
|
|
81
|
+
const { workflowService: ws, scenarioService } = createScenarioRunner()
|
|
74
82
|
let invocations = 0
|
|
75
83
|
const yasser = fakeActor('yasser', async () => ({ n: ++invocations }))
|
|
76
84
|
const runId = await setup(ws)
|
|
@@ -95,7 +103,7 @@ describe('scenario actor steps (workflow.do with `actor`)', () => {
|
|
|
95
103
|
})
|
|
96
104
|
|
|
97
105
|
test('actor steps never queue, even when the function is queue-eligible', async () => {
|
|
98
|
-
const ws =
|
|
106
|
+
const { workflowService: ws, scenarioService } = createScenarioRunner()
|
|
99
107
|
let queued = 0
|
|
100
108
|
const customer = fakeActor('customer', async () => ({}))
|
|
101
109
|
const runId = await setup(ws, {
|
|
@@ -119,7 +127,7 @@ describe('scenario actor steps (workflow.do with `actor`)', () => {
|
|
|
119
127
|
})
|
|
120
128
|
|
|
121
129
|
test('actor step failure surfaces the actor error and fails after retries', async () => {
|
|
122
|
-
const ws =
|
|
130
|
+
const { workflowService: ws, scenarioService } = createScenarioRunner()
|
|
123
131
|
const broken = fakeActor('broken', async () => {
|
|
124
132
|
throw new Error("[scenario] 'createTodo' as 'broken' returned 403: nope")
|
|
125
133
|
})
|
|
@@ -134,11 +142,235 @@ describe('scenario actor steps (workflow.do with `actor`)', () => {
|
|
|
134
142
|
})
|
|
135
143
|
})
|
|
136
144
|
|
|
145
|
+
const registerStep = (
|
|
146
|
+
name: string,
|
|
147
|
+
config: {
|
|
148
|
+
description?: string
|
|
149
|
+
browser?: boolean
|
|
150
|
+
func: (services: any, data: any, wire: PikkuWire) => Promise<unknown>
|
|
151
|
+
}
|
|
152
|
+
) => {
|
|
153
|
+
addFunction(name, config as any)
|
|
154
|
+
// `pikkuScenarioStep` maps to PikkuFunctionSessionless, which is what the
|
|
155
|
+
// inspector records — a step is driven by an actor, not by a wire session.
|
|
156
|
+
pikkuState(null, 'function', 'meta')[name] = {
|
|
157
|
+
pikkuFuncId: name,
|
|
158
|
+
sessionless: true,
|
|
159
|
+
} as any
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
describe('pikkuScenarioStep (scenario.step/given/when/then)', () => {
|
|
163
|
+
beforeEach(() => resetPikkuState())
|
|
164
|
+
|
|
165
|
+
test('the step func is called with the phase, step identity and data on the wire', async () => {
|
|
166
|
+
const { workflowService: ws, scenarioService } = createScenarioRunner()
|
|
167
|
+
const seen: PikkuWire[] = []
|
|
168
|
+
const payloads: unknown[] = []
|
|
169
|
+
registerStep('buysAnApple', {
|
|
170
|
+
func: async (_services, data, wire) => {
|
|
171
|
+
seen.push(wire)
|
|
172
|
+
payloads.push(data)
|
|
173
|
+
return { receipt: 'r1' }
|
|
174
|
+
},
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const runId = await setup(ws)
|
|
178
|
+
const wire = ws.createWorkflowWire('scenarioTest', runId, {})
|
|
179
|
+
const result = await wire.given('shopper buys an apple', 'buysAnApple', {
|
|
180
|
+
qty: 2,
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
assert.deepEqual(result, { receipt: 'r1' })
|
|
184
|
+
assert.deepEqual(payloads, [{ qty: 2 }])
|
|
185
|
+
assert.equal(seen.length, 1)
|
|
186
|
+
assert.equal(seen[0]!.scenarioStep?.phase, 'given')
|
|
187
|
+
assert.equal(seen[0]!.scenarioStep?.name, 'buysAnApple')
|
|
188
|
+
assert.equal(seen[0]!.scenarioStep?.stepName, 'shopper buys an apple')
|
|
189
|
+
assert.equal(seen[0]!.scenarioStep?.runId, runId)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('each phase records itself, and the scenario wire is reachable from the step', async () => {
|
|
193
|
+
const { workflowService: ws, scenarioService } = createScenarioRunner()
|
|
194
|
+
const phases: string[] = []
|
|
195
|
+
registerStep('noop', {
|
|
196
|
+
func: async (_services, _data, wire) => {
|
|
197
|
+
phases.push(wire.scenarioStep!.phase)
|
|
198
|
+
assert.equal(
|
|
199
|
+
wire.scenario?.runId,
|
|
200
|
+
wire.scenarioStep!.runId,
|
|
201
|
+
'a step can call back into the scenario it belongs to'
|
|
202
|
+
)
|
|
203
|
+
return null
|
|
204
|
+
},
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
const runId = await setup(ws)
|
|
208
|
+
const wire = ws.createWorkflowWire('scenarioTest', runId, {})
|
|
209
|
+
await wire.step('a', 'noop')
|
|
210
|
+
await wire.given('b', 'noop')
|
|
211
|
+
await wire.when('c', 'noop')
|
|
212
|
+
await wire.then('d', 'noop')
|
|
213
|
+
|
|
214
|
+
assert.deepEqual(phases, ['step', 'given', 'when', 'then'])
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
test('the actor is handed to the step rather than used to dispatch it', async () => {
|
|
218
|
+
const { workflowService: ws, scenarioService } = createScenarioRunner()
|
|
219
|
+
const shopper = fakeActor('shopper', async () => ({ ok: true }))
|
|
220
|
+
let received: unknown
|
|
221
|
+
registerStep('checksOut', {
|
|
222
|
+
func: async (_services, _data, wire) => {
|
|
223
|
+
received = wire.scenarioStep!.actor
|
|
224
|
+
return null
|
|
225
|
+
},
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
const runId = await setup(ws)
|
|
229
|
+
const wire = ws.createWorkflowWire('scenarioTest', runId, {})
|
|
230
|
+
await wire.when('shopper checks out', 'checksOut', undefined, {
|
|
231
|
+
actor: shopper,
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
assert.equal(received, shopper)
|
|
235
|
+
assert.equal(
|
|
236
|
+
shopper.calls.length,
|
|
237
|
+
0,
|
|
238
|
+
'a step runs locally — the actor is context, not the transport'
|
|
239
|
+
)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('a repeated step name gets its own durable row (#1), not the cached first result', async () => {
|
|
243
|
+
const { workflowService: ws, scenarioService } = createScenarioRunner()
|
|
244
|
+
let calls = 0
|
|
245
|
+
registerStep('clicksSave', {
|
|
246
|
+
func: async () => ++calls,
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
const runId = await setup(ws)
|
|
250
|
+
const wire = ws.createWorkflowWire('scenarioTest', runId, {})
|
|
251
|
+
const first = await wire.when('clicks save', 'clicksSave')
|
|
252
|
+
const second = await wire.when('clicks save', 'clicksSave')
|
|
253
|
+
|
|
254
|
+
assert.equal(first, 1)
|
|
255
|
+
assert.equal(second, 2, 'the second reach must actually run')
|
|
256
|
+
assert.equal((await ws.getStepState(runId, 'clicks save')).result, 1)
|
|
257
|
+
assert.equal((await ws.getStepState(runId, 'clicks save#1')).result, 2)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
test('a throwing step is not retried', async () => {
|
|
261
|
+
const { workflowService: ws, scenarioService } = createScenarioRunner()
|
|
262
|
+
let attempts = 0
|
|
263
|
+
registerStep('seesAReceipt', {
|
|
264
|
+
func: async () => {
|
|
265
|
+
attempts++
|
|
266
|
+
throw new Error('expected 1 item, got 0')
|
|
267
|
+
},
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
const runId = await setup(ws)
|
|
271
|
+
const wire = ws.createWorkflowWire('scenarioTest', runId, {})
|
|
272
|
+
|
|
273
|
+
await assert.rejects(
|
|
274
|
+
wire.then('shopper sees a receipt', 'seesAReceipt'),
|
|
275
|
+
/expected 1 item, got 0/
|
|
276
|
+
)
|
|
277
|
+
assert.equal(
|
|
278
|
+
attempts,
|
|
279
|
+
1,
|
|
280
|
+
'retrying a failed assertion is the wrong default for a test primitive'
|
|
281
|
+
)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test('an explicit retries option still wins over the zero default', async () => {
|
|
285
|
+
const { workflowService: ws, scenarioService } = createScenarioRunner()
|
|
286
|
+
let attempts = 0
|
|
287
|
+
registerStep('flaky', {
|
|
288
|
+
func: async () => {
|
|
289
|
+
if (++attempts < 3) {
|
|
290
|
+
throw new Error('not yet')
|
|
291
|
+
}
|
|
292
|
+
return 'settled'
|
|
293
|
+
},
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
const runId = await setup(ws)
|
|
297
|
+
const wire = ws.createWorkflowWire('scenarioTest', runId, {})
|
|
298
|
+
const result = await wire.step('waits for the page', 'flaky', undefined, {
|
|
299
|
+
retries: 3,
|
|
300
|
+
retryDelay: 1,
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
assert.equal(result, 'settled')
|
|
304
|
+
assert.equal(attempts, 3)
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
test('a browser step fails loudly when no provider is registered', async () => {
|
|
308
|
+
const { workflowService: ws, scenarioService } = createScenarioRunner()
|
|
309
|
+
const shopper = fakeActor('shopper', async () => ({}))
|
|
310
|
+
registerStep('visitsCheckout', {
|
|
311
|
+
browser: true,
|
|
312
|
+
func: async () => null,
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
const runId = await setup(ws)
|
|
316
|
+
const wire = ws.createWorkflowWire('scenarioTest', runId, {})
|
|
317
|
+
|
|
318
|
+
await assert.rejects(
|
|
319
|
+
wire.given('shopper visits checkout', 'visitsCheckout', undefined, {
|
|
320
|
+
actor: shopper,
|
|
321
|
+
}),
|
|
322
|
+
/no browser provider is registered/
|
|
323
|
+
)
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
test('a registered provider hands the step a session keyed by the actor', async () => {
|
|
327
|
+
const { workflowService: ws, scenarioService } = createScenarioRunner()
|
|
328
|
+
const shopper = fakeActor('shopper', async () => ({}))
|
|
329
|
+
const requested: string[] = []
|
|
330
|
+
const session = { actor: 'shopper' } as any
|
|
331
|
+
scenarioService.setScenarioBrowserProvider({
|
|
332
|
+
sessionFor: async (actorName: string) => {
|
|
333
|
+
requested.push(actorName)
|
|
334
|
+
return session
|
|
335
|
+
},
|
|
336
|
+
} as any)
|
|
337
|
+
|
|
338
|
+
let handed: unknown
|
|
339
|
+
registerStep('visitsCheckout', {
|
|
340
|
+
browser: true,
|
|
341
|
+
func: async (_services, _data, wire) => {
|
|
342
|
+
handed = wire.browser
|
|
343
|
+
return null
|
|
344
|
+
},
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
const runId = await setup(ws)
|
|
348
|
+
const wire = ws.createWorkflowWire('scenarioTest', runId, {})
|
|
349
|
+
await wire.given('shopper visits checkout', 'visitsCheckout', undefined, {
|
|
350
|
+
actor: shopper,
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
assert.deepEqual(requested, ['shopper'])
|
|
354
|
+
assert.equal(handed, session)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
test('a non-string step target is rejected instead of silently dispatching', async () => {
|
|
358
|
+
const { workflowService: ws, scenarioService } = createScenarioRunner()
|
|
359
|
+
const runId = await setup(ws)
|
|
360
|
+
const wire = ws.createWorkflowWire('scenarioTest', runId, {})
|
|
361
|
+
|
|
362
|
+
await assert.rejects(
|
|
363
|
+
(wire.step as any)('a name', async () => 'inline'),
|
|
364
|
+
/string/i
|
|
365
|
+
)
|
|
366
|
+
})
|
|
367
|
+
})
|
|
368
|
+
|
|
137
369
|
describe('workflow.expectEventually', () => {
|
|
138
370
|
beforeEach(() => resetPikkuState())
|
|
139
371
|
|
|
140
372
|
test('polls as the actor until the predicate passes', async () => {
|
|
141
|
-
const ws =
|
|
373
|
+
const { workflowService: ws, scenarioService } = createScenarioRunner()
|
|
142
374
|
let polls = 0
|
|
143
375
|
const sarah = fakeActor('sarah', async () => ({
|
|
144
376
|
notifications: ++polls >= 3 ? ['ping'] : [],
|
|
@@ -159,7 +391,7 @@ describe('workflow.expectEventually', () => {
|
|
|
159
391
|
})
|
|
160
392
|
|
|
161
393
|
test('fails with the last result when the deadline passes', async () => {
|
|
162
|
-
const ws =
|
|
394
|
+
const { workflowService: ws, scenarioService } = createScenarioRunner()
|
|
163
395
|
const sarah = fakeActor('sarah', async () => ({ notifications: [] }))
|
|
164
396
|
const runId = await setup(ws)
|
|
165
397
|
const wire = ws.createWorkflowWire('scenarioTest', runId, {})
|
|
@@ -177,7 +409,7 @@ describe('workflow.expectEventually', () => {
|
|
|
177
409
|
})
|
|
178
410
|
|
|
179
411
|
test('polls internally (rpcWithWire) when no actor is given', async () => {
|
|
180
|
-
const ws =
|
|
412
|
+
const { workflowService: ws, scenarioService } = createScenarioRunner()
|
|
181
413
|
let polls = 0
|
|
182
414
|
const runId = await setup(ws)
|
|
183
415
|
const rpc = {
|
|
@@ -196,3 +428,204 @@ describe('workflow.expectEventually', () => {
|
|
|
196
428
|
assert.equal(polls, 2)
|
|
197
429
|
})
|
|
198
430
|
})
|
|
431
|
+
|
|
432
|
+
describe('scenario step input is recorded on the run', () => {
|
|
433
|
+
beforeEach(() => resetPikkuState())
|
|
434
|
+
|
|
435
|
+
test('the run records the input each step was called with', async () => {
|
|
436
|
+
const { workflowService: ws, scenarioService } = createScenarioRunner()
|
|
437
|
+
registerStep('seesAddon', { func: async () => ({ visible: true }) })
|
|
438
|
+
|
|
439
|
+
const runId = await setup(ws)
|
|
440
|
+
const wire = ws.createWorkflowWire('scenarioTest', runId, {})
|
|
441
|
+
await wire.then('sees console', 'seesAddon', {
|
|
442
|
+
packageName: '@pikku/addon-console',
|
|
443
|
+
state: 'installed',
|
|
444
|
+
})
|
|
445
|
+
await wire.then('sees todos', 'seesAddon', {
|
|
446
|
+
packageName: '@pikku/addon-todos',
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
// A reporter renders each step's prose from this, so two calls to one step
|
|
450
|
+
// must be distinguishable by what they were asked to check.
|
|
451
|
+
const steps = await ws.getRunSteps(runId)
|
|
452
|
+
assert.deepEqual(
|
|
453
|
+
steps.map((step) => step.data),
|
|
454
|
+
[
|
|
455
|
+
{ packageName: '@pikku/addon-console', state: 'installed' },
|
|
456
|
+
{ packageName: '@pikku/addon-todos' },
|
|
457
|
+
]
|
|
458
|
+
)
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
test('a step called with no input records none', async () => {
|
|
462
|
+
const { workflowService: ws, scenarioService } = createScenarioRunner()
|
|
463
|
+
registerStep('resets', { func: async () => null })
|
|
464
|
+
|
|
465
|
+
const runId = await setup(ws)
|
|
466
|
+
const wire = ws.createWorkflowWire('scenarioTest', runId, {})
|
|
467
|
+
await wire.given('resets the app', 'resets')
|
|
468
|
+
|
|
469
|
+
const steps = await ws.getRunSteps(runId)
|
|
470
|
+
assert.equal(steps[0]!.data ?? null, null)
|
|
471
|
+
})
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
describe('scenario step names its function on the run', () => {
|
|
475
|
+
beforeEach(() => resetPikkuState())
|
|
476
|
+
|
|
477
|
+
test('the run records which step function ran, not only the step name', async () => {
|
|
478
|
+
const { workflowService: ws, scenarioService } = createScenarioRunner()
|
|
479
|
+
registerStep('seesAddon', { func: async () => ({ visible: true }) })
|
|
480
|
+
|
|
481
|
+
const runId = await setup(ws)
|
|
482
|
+
const wire = ws.createWorkflowWire('scenarioTest', runId, {})
|
|
483
|
+
await wire.then('sees console', 'seesAddon', { packageName: 'console' })
|
|
484
|
+
|
|
485
|
+
const steps = await ws.getRunSteps(runId)
|
|
486
|
+
assert.equal(
|
|
487
|
+
steps[0]!.rpcName,
|
|
488
|
+
'seesAddon',
|
|
489
|
+
'a reporter joins a step back to its declaration by function name'
|
|
490
|
+
)
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
test('a step name built at runtime still names its function', async () => {
|
|
494
|
+
const { workflowService: ws, scenarioService } = createScenarioRunner()
|
|
495
|
+
registerStep('seesAddon', { func: async () => ({ visible: true }) })
|
|
496
|
+
|
|
497
|
+
const runId = await setup(ws)
|
|
498
|
+
const wire = ws.createWorkflowWire('scenarioTest', runId, {})
|
|
499
|
+
// What a loop produces: the durable name is the interpolated one, so it
|
|
500
|
+
// matches no statically recorded step name.
|
|
501
|
+
for (const packageName of ['console', 'todos']) {
|
|
502
|
+
await wire.then(`sees ${packageName}`, 'seesAddon', { packageName })
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const steps = await ws.getRunSteps(runId)
|
|
506
|
+
assert.deepEqual(
|
|
507
|
+
steps.map((step) => [step.stepName, step.rpcName]),
|
|
508
|
+
[
|
|
509
|
+
['sees console', 'seesAddon'],
|
|
510
|
+
['sees todos', 'seesAddon'],
|
|
511
|
+
]
|
|
512
|
+
)
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
test('a plain inline step still records no function name', async () => {
|
|
516
|
+
const { workflowService: ws, scenarioService } = createScenarioRunner()
|
|
517
|
+
const runId = await setup(ws)
|
|
518
|
+
const wire = ws.createWorkflowWire('scenarioTest', runId, {})
|
|
519
|
+
await wire.do('computes', async () => 1)
|
|
520
|
+
|
|
521
|
+
const steps = await ws.getRunSteps(runId)
|
|
522
|
+
assert.equal(steps[0]!.rpcName ?? null, null)
|
|
523
|
+
})
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
describe('the scenario environment reaches the step wire', () => {
|
|
527
|
+
beforeEach(() => resetPikkuState())
|
|
528
|
+
|
|
529
|
+
test('a step reads the environment the run targets', async () => {
|
|
530
|
+
const { workflowService: ws, scenarioService } = createScenarioRunner()
|
|
531
|
+
let seen: unknown
|
|
532
|
+
registerStep('readsEnv', {
|
|
533
|
+
func: async (_services, _data, wire) => {
|
|
534
|
+
seen = wire.scenarioStep!.env
|
|
535
|
+
return null
|
|
536
|
+
},
|
|
537
|
+
})
|
|
538
|
+
scenarioService.setScenarioEnvironment({
|
|
539
|
+
apiUrl: 'https://staging.example.com/api',
|
|
540
|
+
appUrl: 'https://staging.example.com',
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
const runId = await setup(ws)
|
|
544
|
+
const wire = ws.createWorkflowWire('scenarioTest', runId, {})
|
|
545
|
+
await wire.when('reads the environment', 'readsEnv')
|
|
546
|
+
|
|
547
|
+
assert.deepEqual(seen, {
|
|
548
|
+
apiUrl: 'https://staging.example.com/api',
|
|
549
|
+
appUrl: 'https://staging.example.com',
|
|
550
|
+
})
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
test('a run started without one carries no environment', async () => {
|
|
554
|
+
const { workflowService: ws, scenarioService } = createScenarioRunner()
|
|
555
|
+
let seen: unknown = 'unset'
|
|
556
|
+
registerStep('readsEnv', {
|
|
557
|
+
func: async (_services, _data, wire) => {
|
|
558
|
+
seen = wire.scenarioStep!.env
|
|
559
|
+
return null
|
|
560
|
+
},
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
const runId = await setup(ws)
|
|
564
|
+
const wire = ws.createWorkflowWire('scenarioTest', runId, {})
|
|
565
|
+
await wire.when('reads the environment', 'readsEnv')
|
|
566
|
+
|
|
567
|
+
assert.equal(seen, undefined)
|
|
568
|
+
})
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
describe('requireActor / requireScenarioEnv', () => {
|
|
572
|
+
beforeEach(() => resetPikkuState())
|
|
573
|
+
|
|
574
|
+
test('requireActor returns the actor a step was called with', async () => {
|
|
575
|
+
const { workflowService: ws, scenarioService } = createScenarioRunner()
|
|
576
|
+
const shopper = fakeActor('shopper', async () => null)
|
|
577
|
+
let resolved: unknown
|
|
578
|
+
registerStep('needsAnActor', {
|
|
579
|
+
func: async (_services, _data, wire) => {
|
|
580
|
+
resolved = requireActor(wire.scenarioStep)
|
|
581
|
+
return null
|
|
582
|
+
},
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
const runId = await setup(ws)
|
|
586
|
+
const wire = ws.createWorkflowWire('scenarioTest', runId, {})
|
|
587
|
+
await wire.when('shopper acts', 'needsAnActor', undefined, {
|
|
588
|
+
actor: shopper,
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
assert.equal(resolved, shopper)
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
test('requireActor names the step when it was called without one', async () => {
|
|
595
|
+
const { workflowService: ws, scenarioService } = createScenarioRunner()
|
|
596
|
+
registerStep('needsAnActor', {
|
|
597
|
+
func: async (_services, _data, wire) => requireActor(wire.scenarioStep),
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
const runId = await setup(ws)
|
|
601
|
+
const wire = ws.createWorkflowWire('scenarioTest', runId, {})
|
|
602
|
+
await assert.rejects(
|
|
603
|
+
wire.when('nobody acts', 'needsAnActor'),
|
|
604
|
+
/needsAnActor.*actor/s
|
|
605
|
+
)
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
test('requireScenarioEnv returns the environment, or says how to declare one', async () => {
|
|
609
|
+
const { workflowService: ws, scenarioService } = createScenarioRunner()
|
|
610
|
+
registerStep('needsAnEnv', {
|
|
611
|
+
func: async (_services, _data, wire) =>
|
|
612
|
+
requireScenarioEnv(wire.scenarioStep),
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
const runId = await setup(ws)
|
|
616
|
+
const wire = ws.createWorkflowWire('scenarioTest', runId, {})
|
|
617
|
+
await assert.rejects(
|
|
618
|
+
wire.when('reads the api url', 'needsAnEnv'),
|
|
619
|
+
/needsAnEnv.*environment/s
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
scenarioService.setScenarioEnvironment({
|
|
623
|
+
apiUrl: 'http://localhost:4077/api',
|
|
624
|
+
})
|
|
625
|
+
const runId2 = await setup(ws)
|
|
626
|
+
const wire2 = ws.createWorkflowWire('scenarioTest', runId2, {})
|
|
627
|
+
assert.deepEqual(await wire2.when('reads the api url', 'needsAnEnv'), {
|
|
628
|
+
apiUrl: 'http://localhost:4077/api',
|
|
629
|
+
})
|
|
630
|
+
})
|
|
631
|
+
})
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { ScenarioActor } from '../../services/scenario-actors-service.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Scenario steps: named, typed units of scenario behaviour.
|
|
5
|
+
*
|
|
6
|
+
* A step's body is an ordinary pikku function, so it may drive a browser, call
|
|
7
|
+
* an RPC as its actor, or run a workflow. `given`/`when`/`then` are sugar over
|
|
8
|
+
* `step` — they only change the prose the reporter renders.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Which Gherkin-style keyword the reporter prefixes this step with. `step`
|
|
13
|
+
* renders no prefix at all.
|
|
14
|
+
*/
|
|
15
|
+
export type ScenarioStepPhase = 'step' | 'given' | 'when' | 'then'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Options accepted by `scenario.step/given/when/then`.
|
|
19
|
+
*
|
|
20
|
+
* Note the retry default differs from an ordinary workflow step: retrying a
|
|
21
|
+
* failed assertion is the wrong behaviour for a test primitive, so steps
|
|
22
|
+
* default to no retries.
|
|
23
|
+
*/
|
|
24
|
+
export interface ScenarioStepOptions {
|
|
25
|
+
/** The actor this step runs as. Required for steps declaring `browser: true`. */
|
|
26
|
+
actor?: unknown
|
|
27
|
+
/** Overrides the step's own `description` for this call site only. */
|
|
28
|
+
description?: string
|
|
29
|
+
/** Defaults to 0 for steps — a failed assertion must not be retried. */
|
|
30
|
+
retries?: number
|
|
31
|
+
retryDelay?: number | string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The environment a scenario run targets, as declared in pikku.config.json
|
|
36
|
+
* under `scenarios.environments`.
|
|
37
|
+
*/
|
|
38
|
+
export interface ScenarioEnvironment {
|
|
39
|
+
/** Base API URL of the target app, INCLUDING the HTTP prefix. */
|
|
40
|
+
apiUrl: string
|
|
41
|
+
/** Base URL of the app's UI, for environments with browser steps. */
|
|
42
|
+
appUrl?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The `scenarioStep` wire, present on every scenario step invocation.
|
|
47
|
+
*
|
|
48
|
+
* `TActor` is the project's own actor type, so a step reaches only the RPCs its
|
|
49
|
+
* actors can actually call. It defaults to the open `ScenarioActor` for a
|
|
50
|
+
* project that declares no registry.
|
|
51
|
+
*/
|
|
52
|
+
export interface PikkuScenarioStepWire<TActor = ScenarioActor> {
|
|
53
|
+
/** Registered step name (also its pikkuFuncId) */
|
|
54
|
+
name: string
|
|
55
|
+
/** Durable key within the run; may carry an `#ordinal` suffix when repeated */
|
|
56
|
+
stepName: string
|
|
57
|
+
runId: string
|
|
58
|
+
phase: ScenarioStepPhase
|
|
59
|
+
/**
|
|
60
|
+
* The actor this step runs as, when one was given. Call RPCs through it
|
|
61
|
+
* (`actor.invoke(...)`) so they run against the target environment as that
|
|
62
|
+
* persona.
|
|
63
|
+
*/
|
|
64
|
+
actor?: TActor
|
|
65
|
+
/**
|
|
66
|
+
* The environment this run targets. A step runs in the CLI process, where
|
|
67
|
+
* there is no `variables` service — this is how a raw-HTTP step learns the
|
|
68
|
+
* target's URL without reaching for `process.env`.
|
|
69
|
+
*/
|
|
70
|
+
env?: ScenarioEnvironment
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* How a browser step names an element.
|
|
75
|
+
*
|
|
76
|
+
* A `data-testid` on its own is rarely enough to name exactly one: `where`
|
|
77
|
+
* matches the element's own data attributes (so a step asserts a status
|
|
78
|
+
* without reading translated copy back to the app), `prefix` matches a family
|
|
79
|
+
* of ids, `containing` picks the match holding a piece of text, and `within`
|
|
80
|
+
* scopes the lookup to one row or section.
|
|
81
|
+
*
|
|
82
|
+
* Declared here so a step's input type is structural; the driver
|
|
83
|
+
* (`@pikku/playwright`) is what resolves it against a real page.
|
|
84
|
+
*/
|
|
85
|
+
export interface TestIdSelector {
|
|
86
|
+
testId: string
|
|
87
|
+
/** Match every test id beginning with `testId`, e.g. every `flow-card-*`. */
|
|
88
|
+
prefix?: boolean
|
|
89
|
+
/** Data attributes the element must also carry, e.g. `{ 'data-open': 'true' }`. */
|
|
90
|
+
where?: Record<string, string>
|
|
91
|
+
/** Narrow to the one match holding this text. */
|
|
92
|
+
containing?: string
|
|
93
|
+
/** Scope the lookup to one enclosing element, e.g. the row for one user. */
|
|
94
|
+
within?: TestIdSelector
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Structural browser handle, present only when the runner provisioned a
|
|
99
|
+
* browser for this step (`browser: true` on the step config).
|
|
100
|
+
*
|
|
101
|
+
* `@pikku/core` deliberately never imports playwright — it must stay
|
|
102
|
+
* dependency-free for edge runtimes. `@pikku/playwright` augments this
|
|
103
|
+
* interface via `declare module`, so `wire.browser.page` is a fully typed
|
|
104
|
+
* Playwright `Page` in a project that installs it.
|
|
105
|
+
*/
|
|
106
|
+
export interface PikkuBrowserWire {
|
|
107
|
+
/** The actor whose browser context this is */
|
|
108
|
+
readonly actor: string
|
|
109
|
+
goto(url: string): Promise<void>
|
|
110
|
+
screenshot(name?: string): Promise<Uint8Array>
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* What one actor's window looked like at the moment a scenario failed.
|
|
115
|
+
*
|
|
116
|
+
* A browser step fails with a selector timeout that says nothing about *why*
|
|
117
|
+
* the page never rendered. The answer is almost always in the page's own
|
|
118
|
+
* errors, which the driver has been collecting all along.
|
|
119
|
+
*/
|
|
120
|
+
export interface ScenarioBrowserFailure {
|
|
121
|
+
/** The actor whose window this is. */
|
|
122
|
+
actor: string
|
|
123
|
+
/** Where the window was pointed, when the driver can still report it. */
|
|
124
|
+
url?: string
|
|
125
|
+
/** Path the screenshot was written to; absent when none could be taken. */
|
|
126
|
+
screenshot?: string
|
|
127
|
+
consoleErrors: string[]
|
|
128
|
+
pageErrors: string[]
|
|
129
|
+
failedRequests: string[]
|
|
130
|
+
apiErrors: string[]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Supplied by `@pikku/playwright` (or any other driver) and consumed by the
|
|
135
|
+
* scenario runner. Declared here so the CLI depends only on core.
|
|
136
|
+
*
|
|
137
|
+
* `reset` and `captureFailure` are optional so a driver written against an
|
|
138
|
+
* earlier version keeps compiling; the runner treats a driver without them as
|
|
139
|
+
* one that simply offers no isolation and no diagnostics.
|
|
140
|
+
*/
|
|
141
|
+
export interface ScenarioBrowserProvider {
|
|
142
|
+
/** Resolve — creating on first use — the browser session for an actor. */
|
|
143
|
+
sessionFor(actorName: string): Promise<PikkuBrowserWire>
|
|
144
|
+
/**
|
|
145
|
+
* Discard every actor's per-scenario state — cookies, storage, open pages —
|
|
146
|
+
* while keeping the browser itself. Called between scenarios, so one
|
|
147
|
+
* scenario cannot leave the next signed in as somebody else.
|
|
148
|
+
*/
|
|
149
|
+
reset?(): Promise<void>
|
|
150
|
+
/**
|
|
151
|
+
* Snapshot every open window for a failed scenario. `label` identifies the
|
|
152
|
+
* scenario in artifact filenames. Never throws: a failure to capture must
|
|
153
|
+
* not replace the failure being captured.
|
|
154
|
+
*/
|
|
155
|
+
captureFailure?(label: string): Promise<ScenarioBrowserFailure[]>
|
|
156
|
+
close(): Promise<void>
|
|
157
|
+
}
|