@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.
Files changed (96) hide show
  1. package/CHANGELOG.md +397 -0
  2. package/README.md +34 -2
  3. package/dist/function/functions.types.d.ts +27 -0
  4. package/dist/index.d.ts +1 -1
  5. package/dist/internal.d.ts +1 -1
  6. package/dist/internal.js +1 -1
  7. package/dist/pikku-state.js +1 -0
  8. package/dist/services/http-scenario-actors.d.ts +12 -4
  9. package/dist/services/http-scenario-actors.js +47 -45
  10. package/dist/services/in-memory-workflow-service.d.ts +2 -2
  11. package/dist/services/in-memory-workflow-service.js +2 -2
  12. package/dist/services/index.d.ts +2 -1
  13. package/dist/services/index.js +1 -0
  14. package/dist/services/meta-service.d.ts +5 -1
  15. package/dist/services/meta-service.js +44 -18
  16. package/dist/services/scenario-actors-service.d.ts +108 -2
  17. package/dist/services/scenario-actors-service.js +40 -1
  18. package/dist/types/core.types.d.ts +21 -3
  19. package/dist/types/state.types.d.ts +3 -1
  20. package/dist/wirings/actor-flow/actor-flow.types.d.ts +1 -1
  21. package/dist/wirings/actor-flow/index.d.ts +1 -1
  22. package/dist/wirings/actor-flow/run-conversation.d.ts +10 -10
  23. package/dist/wirings/actor-flow/run-conversation.js +27 -27
  24. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +18 -1
  25. package/dist/wirings/ai-agent/ai-agent-prepare.js +26 -4
  26. package/dist/wirings/cli/command-parser.js +11 -1
  27. package/dist/wirings/queue/index.d.ts +1 -1
  28. package/dist/wirings/queue/queue.types.d.ts +30 -0
  29. package/dist/wirings/rpc/rpc-runner.js +1 -1
  30. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +52 -3
  31. package/dist/wirings/workflow/feature.d.ts +28 -0
  32. package/dist/wirings/workflow/feature.js +57 -0
  33. package/dist/wirings/workflow/index.d.ts +13 -2
  34. package/dist/wirings/workflow/index.js +15 -0
  35. package/dist/wirings/workflow/pikku-scenario-service.d.ts +121 -0
  36. package/dist/wirings/workflow/pikku-scenario-service.js +419 -0
  37. package/dist/wirings/workflow/pikku-workflow-service.d.ts +118 -12
  38. package/dist/wirings/workflow/pikku-workflow-service.js +166 -153
  39. package/dist/wirings/workflow/scenario-cookie-jar.d.ts +29 -0
  40. package/dist/wirings/workflow/scenario-cookie-jar.js +51 -0
  41. package/dist/wirings/workflow/scenario-poll.d.ts +20 -0
  42. package/dist/wirings/workflow/scenario-poll.js +25 -0
  43. package/dist/wirings/workflow/scenario-prose.d.ts +38 -0
  44. package/dist/wirings/workflow/scenario-prose.js +45 -0
  45. package/dist/wirings/workflow/scenario-step-guards.d.ts +16 -0
  46. package/dist/wirings/workflow/scenario-step-guards.js +29 -0
  47. package/dist/wirings/workflow/scenario-step.types.d.ts +148 -0
  48. package/dist/wirings/workflow/scenario-step.types.js +1 -0
  49. package/dist/wirings/workflow/workflow.types.d.ts +119 -2
  50. package/package.json +3 -1
  51. package/src/function/functions.types.ts +32 -0
  52. package/src/index.ts +1 -0
  53. package/src/internal.ts +5 -1
  54. package/src/pikku-state.ts +1 -0
  55. package/src/services/http-scenario-actors.test.ts +85 -1
  56. package/src/services/http-scenario-actors.ts +65 -51
  57. package/src/services/in-memory-workflow-service.test.ts +50 -1
  58. package/src/services/in-memory-workflow-service.ts +3 -2
  59. package/src/services/index.ts +5 -0
  60. package/src/services/meta-service.test.ts +79 -0
  61. package/src/services/meta-service.ts +61 -26
  62. package/src/services/scenario-actors-service.ts +157 -2
  63. package/src/types/core.types.ts +27 -2
  64. package/src/types/state.types.ts +3 -0
  65. package/src/wirings/actor-flow/actor-flow.types.ts +1 -1
  66. package/src/wirings/actor-flow/index.ts +1 -1
  67. package/src/wirings/actor-flow/run-conversation.test.ts +12 -6
  68. package/src/wirings/actor-flow/run-conversation.ts +36 -41
  69. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +29 -0
  70. package/src/wirings/ai-agent/ai-agent-prepare.ts +38 -4
  71. package/src/wirings/cli/command-parser.test.ts +60 -0
  72. package/src/wirings/cli/command-parser.ts +12 -1
  73. package/src/wirings/queue/index.ts +2 -0
  74. package/src/wirings/queue/queue.types.ts +32 -0
  75. package/src/wirings/rpc/rpc-runner.test.ts +28 -5
  76. package/src/wirings/rpc/rpc-runner.ts +1 -1
  77. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +86 -2
  78. package/src/wirings/workflow/feature.test.ts +131 -0
  79. package/src/wirings/workflow/feature.ts +78 -0
  80. package/src/wirings/workflow/index.ts +74 -0
  81. package/src/wirings/workflow/pikku-scenario-service.ts +682 -0
  82. package/src/wirings/workflow/pikku-workflow-service.test.ts +126 -0
  83. package/src/wirings/workflow/pikku-workflow-service.ts +306 -228
  84. package/src/wirings/workflow/scenario-cookie-jar.test.ts +108 -0
  85. package/src/wirings/workflow/scenario-cookie-jar.ts +65 -0
  86. package/src/wirings/workflow/scenario-hooks.test.ts +212 -0
  87. package/src/wirings/workflow/scenario-poll.test.ts +66 -0
  88. package/src/wirings/workflow/scenario-poll.ts +36 -0
  89. package/src/wirings/workflow/scenario-prose.test.ts +152 -0
  90. package/src/wirings/workflow/scenario-prose.ts +79 -0
  91. package/src/wirings/workflow/scenario-service.test.ts +155 -0
  92. package/src/wirings/workflow/scenario-step-guards.ts +43 -0
  93. package/src/wirings/workflow/scenario-step.test.ts +441 -8
  94. package/src/wirings/workflow/scenario-step.types.ts +157 -0
  95. package/src/wirings/workflow/workflow.types.ts +137 -1
  96. 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 { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
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 = new InMemoryWorkflowService()
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 = new InMemoryWorkflowService()
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 = new InMemoryWorkflowService()
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 = new InMemoryWorkflowService()
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 = new InMemoryWorkflowService()
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 = new InMemoryWorkflowService()
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 = new InMemoryWorkflowService()
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
+ }