@pikku/core 0.12.69 → 0.12.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/CHANGELOG.md +422 -0
  2. package/LICENSE +21 -0
  3. package/README.md +34 -2
  4. package/dist/function/functions.types.d.ts +27 -0
  5. package/dist/index.d.ts +1 -1
  6. package/dist/internal.d.ts +1 -1
  7. package/dist/internal.js +1 -1
  8. package/dist/pikku-state.js +1 -0
  9. package/dist/services/http-scenario-actors.d.ts +12 -4
  10. package/dist/services/http-scenario-actors.js +47 -45
  11. package/dist/services/in-memory-queue-service.d.ts +6 -0
  12. package/dist/services/in-memory-queue-service.js +8 -1
  13. package/dist/services/in-memory-workflow-service.d.ts +3 -5
  14. package/dist/services/in-memory-workflow-service.js +10 -19
  15. package/dist/services/index.d.ts +2 -1
  16. package/dist/services/index.js +1 -0
  17. package/dist/services/meta-service.d.ts +5 -1
  18. package/dist/services/meta-service.js +44 -18
  19. package/dist/services/scenario-actors-service.d.ts +108 -2
  20. package/dist/services/scenario-actors-service.js +40 -1
  21. package/dist/services/workflow-service.d.ts +7 -5
  22. package/dist/types/core.types.d.ts +28 -3
  23. package/dist/types/state.types.d.ts +3 -1
  24. package/dist/wirings/actor-flow/actor-flow.types.d.ts +1 -1
  25. package/dist/wirings/actor-flow/index.d.ts +1 -1
  26. package/dist/wirings/actor-flow/run-conversation.d.ts +10 -10
  27. package/dist/wirings/actor-flow/run-conversation.js +27 -27
  28. package/dist/wirings/ai-agent/ai-agent-agui.js +0 -8
  29. package/dist/wirings/ai-agent/ai-agent-prepare.js +1 -2
  30. package/dist/wirings/ai-agent/ai-agent.types.d.ts +0 -6
  31. package/dist/wirings/cli/command-parser.js +11 -1
  32. package/dist/wirings/rpc/rpc-runner.js +1 -1
  33. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +52 -3
  34. package/dist/wirings/workflow/feature.d.ts +28 -0
  35. package/dist/wirings/workflow/feature.js +57 -0
  36. package/dist/wirings/workflow/graph/graph-runner.js +3 -2
  37. package/dist/wirings/workflow/graph/graph-validation.d.ts +0 -2
  38. package/dist/wirings/workflow/graph/graph-validation.js +0 -142
  39. package/dist/wirings/workflow/graph/index.d.ts +1 -1
  40. package/dist/wirings/workflow/graph/index.js +1 -1
  41. package/dist/wirings/workflow/index.d.ts +13 -3
  42. package/dist/wirings/workflow/index.js +15 -2
  43. package/dist/wirings/workflow/pikku-scenario-service.d.ts +121 -0
  44. package/dist/wirings/workflow/pikku-scenario-service.js +419 -0
  45. package/dist/wirings/workflow/pikku-workflow-service.d.ts +170 -23
  46. package/dist/wirings/workflow/pikku-workflow-service.js +338 -297
  47. package/dist/wirings/workflow/scenario-cookie-jar.d.ts +29 -0
  48. package/dist/wirings/workflow/scenario-cookie-jar.js +51 -0
  49. package/dist/wirings/workflow/scenario-poll.d.ts +20 -0
  50. package/dist/wirings/workflow/scenario-poll.js +25 -0
  51. package/dist/wirings/workflow/scenario-prose.d.ts +38 -0
  52. package/dist/wirings/workflow/scenario-prose.js +45 -0
  53. package/dist/wirings/workflow/scenario-step-guards.d.ts +16 -0
  54. package/dist/wirings/workflow/scenario-step-guards.js +29 -0
  55. package/dist/wirings/workflow/scenario-step.types.d.ts +148 -0
  56. package/dist/wirings/workflow/scenario-step.types.js +1 -0
  57. package/dist/wirings/workflow/workflow.types.d.ts +82 -8
  58. package/package.json +3 -1
  59. package/src/function/functions.types.ts +32 -0
  60. package/src/index.ts +1 -0
  61. package/src/internal.ts +5 -1
  62. package/src/pikku-state.ts +1 -0
  63. package/src/services/http-scenario-actors.test.ts +85 -1
  64. package/src/services/http-scenario-actors.ts +65 -51
  65. package/src/services/in-memory-queue-service.test.ts +66 -1
  66. package/src/services/in-memory-queue-service.ts +13 -2
  67. package/src/services/in-memory-workflow-service.ts +12 -25
  68. package/src/services/index.ts +5 -0
  69. package/src/services/meta-service.test.ts +79 -0
  70. package/src/services/meta-service.ts +61 -26
  71. package/src/services/scenario-actors-service.ts +157 -2
  72. package/src/services/workflow-service.ts +7 -4
  73. package/src/types/core.types.ts +34 -2
  74. package/src/types/state.types.ts +3 -0
  75. package/src/wirings/actor-flow/actor-flow.types.ts +1 -1
  76. package/src/wirings/actor-flow/index.ts +1 -1
  77. package/src/wirings/actor-flow/run-conversation.test.ts +12 -6
  78. package/src/wirings/actor-flow/run-conversation.ts +36 -41
  79. package/src/wirings/ai-agent/ai-agent-agui.test.ts +0 -16
  80. package/src/wirings/ai-agent/ai-agent-agui.ts +0 -9
  81. package/src/wirings/ai-agent/ai-agent-prepare.ts +1 -2
  82. package/src/wirings/ai-agent/ai-agent.types.ts +0 -7
  83. package/src/wirings/cli/command-parser.test.ts +60 -0
  84. package/src/wirings/cli/command-parser.ts +12 -1
  85. package/src/wirings/rpc/rpc-runner.test.ts +28 -5
  86. package/src/wirings/rpc/rpc-runner.ts +1 -1
  87. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +86 -2
  88. package/src/wirings/workflow/feature.test.ts +131 -0
  89. package/src/wirings/workflow/feature.ts +78 -0
  90. package/src/wirings/workflow/graph/graph-runner.ts +3 -2
  91. package/src/wirings/workflow/graph/graph-validation.test.ts +1 -144
  92. package/src/wirings/workflow/graph/graph-validation.ts +0 -196
  93. package/src/wirings/workflow/graph/index.ts +1 -5
  94. package/src/wirings/workflow/index.ts +73 -6
  95. package/src/wirings/workflow/pikku-scenario-service.ts +682 -0
  96. package/src/wirings/workflow/pikku-workflow-service.test.ts +55 -0
  97. package/src/wirings/workflow/pikku-workflow-service.ts +572 -419
  98. package/src/wirings/workflow/scenario-cookie-jar.test.ts +108 -0
  99. package/src/wirings/workflow/scenario-cookie-jar.ts +65 -0
  100. package/src/wirings/workflow/scenario-expectations.test.ts +153 -0
  101. package/src/wirings/workflow/scenario-hooks.test.ts +212 -0
  102. package/src/wirings/workflow/scenario-poll.test.ts +66 -0
  103. package/src/wirings/workflow/scenario-poll.ts +36 -0
  104. package/src/wirings/workflow/scenario-prose.test.ts +152 -0
  105. package/src/wirings/workflow/scenario-prose.ts +79 -0
  106. package/src/wirings/workflow/scenario-service.test.ts +155 -0
  107. package/src/wirings/workflow/scenario-step-guards.ts +43 -0
  108. package/src/wirings/workflow/scenario-step.test.ts +442 -9
  109. package/src/wirings/workflow/scenario-step.types.ts +157 -0
  110. package/src/wirings/workflow/workflow-dispatch-durability.test.ts +1 -1
  111. package/src/wirings/workflow/workflow-dispatch-payload.test.ts +59 -0
  112. package/src/wirings/workflow/workflow-mirror.test.ts +178 -0
  113. package/src/wirings/workflow/workflow-replay-snapshot.test.ts +139 -0
  114. package/src/wirings/workflow/workflow-run-context.test.ts +177 -0
  115. package/src/wirings/workflow/workflow-run-polling.test.ts +132 -0
  116. package/src/wirings/workflow/workflow-step-ordinal.test.ts +4 -4
  117. package/src/wirings/workflow/workflow.types.ts +99 -5
  118. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,682 @@
1
+ import { runPikkuFunc } from '../../function/function-runner.js'
2
+ import {
3
+ getSingletonServices,
4
+ getCreateWireServices,
5
+ pikkuState,
6
+ } from '../../pikku-state.js'
7
+ import { getDurationInMilliseconds } from '../../time-utils.js'
8
+ import { closeWireServices } from '../../utils.js'
9
+ import { PikkuError, addError } from '../../errors/error-handler.js'
10
+ import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
11
+ import { runScheduledTask } from '../scheduler/scheduler-runner.js'
12
+ import {
13
+ WorkflowStepNameNotString,
14
+ type RunLifecycleContext,
15
+ type WorkflowRunEngine,
16
+ type WorkflowRunExtension,
17
+ } from './pikku-workflow-service.js'
18
+ import type { PikkuWire } from '../../types/core.types.js'
19
+ import type { ScenarioActors } from '../../services/scenario-actors-service.js'
20
+ import type { CorePikkuFunctionConfig } from '../../function/functions.types.js'
21
+ import type {
22
+ ScenarioBrowserProvider,
23
+ ScenarioEnvironment,
24
+ ScenarioStepOptions,
25
+ ScenarioStepPhase,
26
+ } from './scenario-step.types.js'
27
+ import type {
28
+ PikkuScenarioWire,
29
+ PikkuWorkflowWire,
30
+ WorkflowExpectEventuallyOptions,
31
+ WorkflowExpectErrorOptions,
32
+ WorkflowExpectServiceOptions,
33
+ WorkflowQueueOptions,
34
+ } from './workflow.types.js'
35
+
36
+ /**
37
+ * A workflow service with the scenario capability attached — the two lines
38
+ * `pikku scenario run` needs, in one call so no caller has to remember that the
39
+ * capability is installed rather than inherited.
40
+ *
41
+ * The in-memory service is the right engine because a scenario run is a single
42
+ * external process driving a deployed app over its real transport: there is
43
+ * nothing to persist and no second worker to resume it.
44
+ */
45
+ export const createScenarioRunner = (
46
+ options: WorkflowQueueOptions = {}
47
+ ): {
48
+ workflowService: InMemoryWorkflowService
49
+ scenarioService: PikkuScenarioService
50
+ } => {
51
+ const workflowService = new InMemoryWorkflowService(options)
52
+ const scenarioService = workflowService.setRunExtension(
53
+ (engine) => new PikkuScenarioService(engine)
54
+ )
55
+ return { workflowService, scenarioService }
56
+ }
57
+
58
+ /**
59
+ * A scenario lifecycle callback, erased to its runtime shape. The typed form a
60
+ * project writes against lives on the generated `pikkuScenario` config; by the
61
+ * time the service reaches it, it is just a function taking the same three
62
+ * arguments the scenario body takes.
63
+ */
64
+ type ScenarioHook = (
65
+ services: any,
66
+ data: any,
67
+ wire: PikkuWire
68
+ ) => Promise<void> | void
69
+
70
+ /**
71
+ * Everything a scenario step needs from the wire that created it. Passed as a
72
+ * bundle so `given`/`when`/`then` stay one-liners.
73
+ */
74
+ interface ScenarioStepContext {
75
+ runId: string
76
+ workflowName: string
77
+ addonNamespace?: string | null
78
+ workflowWire: PikkuScenarioWire
79
+ rpcService: any
80
+ }
81
+
82
+ /**
83
+ * A scenario's `before` or `after` hook threw. The original error is kept as
84
+ * the `cause` so the failure that actually happened is never lost behind the
85
+ * label saying which phase it happened in.
86
+ */
87
+ export class ScenarioHookError extends PikkuError {
88
+ constructor(
89
+ public readonly scenarioName: string,
90
+ public readonly phase: 'before' | 'after',
91
+ cause: unknown
92
+ ) {
93
+ super(
94
+ `Scenario '${scenarioName}' ${phase} hook failed: ${
95
+ cause instanceof Error ? cause.message : String(cause)
96
+ }`
97
+ )
98
+ this.cause = cause
99
+ }
100
+ }
101
+ addError(ScenarioHookError, {
102
+ status: 500,
103
+ message: 'A scenario lifecycle hook failed.',
104
+ })
105
+
106
+ /**
107
+ * The scenario capability, layered onto a workflow service rather than being
108
+ * one.
109
+ *
110
+ * Every scenario affordance — steps, actors, lifecycle hooks, the browser
111
+ * provider, the assertion wire members — lives here rather than on
112
+ * `PikkuWorkflowService`, because a bundler drops an unused *module* but never
113
+ * an unused class member: anything declared on the workflow service ships in
114
+ * every server built on Pikku, along with everything it imports. Scenarios only
115
+ * ever run from `pikku scenario run`, so the whole surface stays behind an
116
+ * import only that runner makes.
117
+ *
118
+ * It is not a workflow service because a scenario is not a different kind of
119
+ * run — it is the same durable run with a step vocabulary on top. What it
120
+ * needs from the engine it gets through the narrow `WorkflowRunEngine` handle,
121
+ * which is why recording a step never became public API.
122
+ *
123
+ * ```ts
124
+ * const workflowService = new InMemoryWorkflowService()
125
+ * const scenarioService = workflowService.setRunExtension(
126
+ * (engine) => new PikkuScenarioService(engine)
127
+ * )
128
+ * ```
129
+ */
130
+ export class PikkuScenarioService implements WorkflowRunExtension {
131
+ // Scenario actors per run: live authenticated clients (cookie jars) are
132
+ // process-local by nature, so they ride this map, never the persisted wire.
133
+ private runActors = new Map<string, ScenarioActors>()
134
+ private scenarioBrowserProvider?: ScenarioBrowserProvider
135
+ private scenarioEnvironment?: ScenarioEnvironment
136
+
137
+ constructor(private readonly engine: WorkflowRunEngine) {}
138
+
139
+ /**
140
+ * Registered by `@pikku/playwright` (or any other driver) before a scenario
141
+ * runs. Absent means browser steps cannot run, which the CLI checks up front
142
+ * so a run fails fast rather than mid-flow.
143
+ */
144
+ public setScenarioBrowserProvider(
145
+ provider: ScenarioBrowserProvider | undefined
146
+ ) {
147
+ this.scenarioBrowserProvider = provider
148
+ }
149
+
150
+ public getScenarioBrowserProvider(): ScenarioBrowserProvider | undefined {
151
+ return this.scenarioBrowserProvider
152
+ }
153
+
154
+ /**
155
+ * The environment scenario steps run against, set once by the runner. It is
156
+ * per-service rather than per-run because a runner process targets exactly
157
+ * one environment for every scenario it executes.
158
+ */
159
+ public setScenarioEnvironment(env: ScenarioEnvironment | undefined) {
160
+ this.scenarioEnvironment = env
161
+ }
162
+
163
+ public getScenarioEnvironment(): ScenarioEnvironment | undefined {
164
+ return this.scenarioEnvironment
165
+ }
166
+
167
+ public async attachRunContext(
168
+ runId: string,
169
+ workflowMeta: any,
170
+ options?: { actors?: ScenarioActors }
171
+ ): Promise<void> {
172
+ const actors =
173
+ options?.actors ??
174
+ (workflowMeta.source === 'scenario'
175
+ ? await this.resolveScenarioActors()
176
+ : undefined)
177
+ if (actors) {
178
+ this.runActors.set(runId, actors)
179
+ }
180
+ }
181
+
182
+ public detachRunContext(runId: string): void {
183
+ this.runActors.delete(runId)
184
+ }
185
+
186
+ public decorateRunWire(
187
+ wire: PikkuWire,
188
+ context: {
189
+ runId: string
190
+ workflowMeta: any
191
+ workflowWire: PikkuWorkflowWire
192
+ }
193
+ ): void {
194
+ wire.scenario =
195
+ context.workflowMeta?.source === 'scenario'
196
+ ? (context.workflowWire as PikkuScenarioWire)
197
+ : undefined
198
+ wire.actors = this.runActors.get(context.runId)
199
+ }
200
+
201
+ public async onBeforeRunFunc(context: RunLifecycleContext): Promise<void> {
202
+ const hooks = this.scenarioHooks(context)
203
+ if (!hooks?.before) return
204
+ await this.runScenarioHook(
205
+ 'before',
206
+ context.workflowMeta.name,
207
+ hooks.before,
208
+ context.wire,
209
+ context.run.input,
210
+ context.packageName
211
+ )
212
+ }
213
+
214
+ public async onAfterRunFunc(
215
+ context: RunLifecycleContext,
216
+ outcome: 'completed' | 'failed' | 'interrupted',
217
+ failure: unknown
218
+ ): Promise<void> {
219
+ if (outcome === 'interrupted') return
220
+ const hooks = this.scenarioHooks(context)
221
+ if (!hooks?.after) return
222
+
223
+ const { runId, run, workflowMeta } = context
224
+ try {
225
+ await this.runScenarioHook(
226
+ 'after',
227
+ workflowMeta.name,
228
+ hooks.after,
229
+ context.wire,
230
+ run.input,
231
+ context.packageName
232
+ )
233
+ } catch (hookError: any) {
234
+ if (outcome === 'failed') {
235
+ // The scenario already failed for its own reason; a teardown failure is
236
+ // diagnostic context, never the headline.
237
+ if (failure instanceof Error && failure.cause === undefined) {
238
+ failure.cause = hookError
239
+ }
240
+ getSingletonServices()?.logger.error(
241
+ `Scenario ${workflowMeta.name} (run ${runId}) failed, and its after hook also failed:`,
242
+ hookError
243
+ )
244
+ } else {
245
+ await this.engine.updateRunStatus(runId, 'failed', undefined, {
246
+ message: hookError.message,
247
+ stack: hookError.stack,
248
+ code: hookError.code,
249
+ })
250
+ await this.engine.onChildWorkflowFailed(run, hookError)
251
+ throw hookError
252
+ }
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Hooks are a scenario affordance only: a plain workflow is durable and
258
+ * resumable, so a callback that reruns on every replay has no honest meaning
259
+ * there.
260
+ */
261
+ private scenarioHooks(
262
+ context: RunLifecycleContext
263
+ ): { before?: ScenarioHook; after?: ScenarioHook } | undefined {
264
+ return context.workflowMeta.source === 'scenario'
265
+ ? (context.workflow.func as {
266
+ before?: ScenarioHook
267
+ after?: ScenarioHook
268
+ })
269
+ : undefined
270
+ }
271
+
272
+ /**
273
+ * Run a scenario `before`/`after` hook.
274
+ *
275
+ * A hook is not a pikku function: it has no id, no meta and no schema, so it
276
+ * cannot go through `runPikkuFunc` and the runner records nothing for it. It
277
+ * gets exactly what the scenario body gets — the same wire (so `actors` is
278
+ * how it reaches the app), and singleton services composed with this
279
+ * invocation's wire services — and nothing else.
280
+ */
281
+ private async runScenarioHook(
282
+ phase: 'before' | 'after',
283
+ scenarioName: string,
284
+ hook: ScenarioHook,
285
+ wire: PikkuWire,
286
+ data: unknown,
287
+ packageName: string | null
288
+ ): Promise<void> {
289
+ const singletonServices = getSingletonServices()!
290
+ let createWireServices = getCreateWireServices()
291
+ if (packageName) {
292
+ const factories = pikkuState(packageName, 'package', 'factories')
293
+ if (factories?.createWireServices) {
294
+ createWireServices = factories.createWireServices
295
+ }
296
+ }
297
+
298
+ let wireServices: Record<string, unknown> | undefined
299
+ try {
300
+ wireServices = (await createWireServices?.(
301
+ singletonServices,
302
+ wire as any
303
+ )) as Record<string, unknown> | undefined
304
+ const services =
305
+ wireServices && Object.keys(wireServices).length > 0
306
+ ? { ...singletonServices, ...wireServices }
307
+ : singletonServices
308
+ await hook(services, data, wire)
309
+ } catch (error) {
310
+ throw new ScenarioHookError(scenarioName, phase, error)
311
+ } finally {
312
+ if (wireServices && Object.keys(wireServices).length > 0) {
313
+ await closeWireServices(singletonServices.logger, wireServices)
314
+ }
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Build HTTP scenario actors for a run started without them; undefined when
320
+ * SCENARIO_ACTOR_SECRET or the API URL is missing.
321
+ *
322
+ * The actor client is imported lazily so that even a runner bundle only pays
323
+ * for the AI persona conversation loop it pulls in when a scenario actually
324
+ * signs an actor in.
325
+ */
326
+ public async resolveScenarioActors(): Promise<ScenarioActors | undefined> {
327
+ const services = getSingletonServices()
328
+ const variables = services?.variables
329
+ const metaService = services?.metaService
330
+ if (!variables || !metaService) {
331
+ return undefined
332
+ }
333
+ const secret = await variables.get('SCENARIO_ACTOR_SECRET')
334
+ const apiUrl = await variables.get('API_URL')
335
+ if (!secret || !apiUrl) {
336
+ services?.logger?.warn(
337
+ 'A scenario was started without actors but SCENARIO_ACTOR_SECRET / API_URL is not configured — running without actors.'
338
+ )
339
+ return undefined
340
+ }
341
+ const actorsConfig = await metaService.getScenarioActorsMeta()
342
+ if (!actorsConfig || Object.keys(actorsConfig).length === 0) {
343
+ return undefined
344
+ }
345
+ const signInPath =
346
+ (await variables.get('SCENARIO_SIGN_IN_PATH')) ??
347
+ '/api/auth/sign-in/actor'
348
+ const rpcPath = (await variables.get('SCENARIO_RPC_PATH')) ?? '/rpc'
349
+ // A run started outside the CLI still targets an environment — its own.
350
+ this.scenarioEnvironment ??= {
351
+ apiUrl,
352
+ appUrl: (await variables.get('APP_URL')) ?? undefined,
353
+ }
354
+ const { createHttpScenarioActors } =
355
+ await import('../../services/http-scenario-actors.js')
356
+ return createHttpScenarioActors({
357
+ apiUrl,
358
+ secret,
359
+ actors: actorsConfig,
360
+ signInPath,
361
+ rpcPath,
362
+ })
363
+ }
364
+
365
+ public decorateWorkflowWire(
366
+ wire: PikkuWorkflowWire,
367
+ context: {
368
+ name: string
369
+ runId: string
370
+ rpcService: any
371
+ addonNamespace?: string | null
372
+ }
373
+ ): void {
374
+ const { name, runId, rpcService, addonNamespace } = context
375
+ const workflowWire = wire as PikkuScenarioWire
376
+ const scenarioStepContext = (): ScenarioStepContext => ({
377
+ runId,
378
+ workflowName: name,
379
+ addonNamespace,
380
+ workflowWire,
381
+ rpcService,
382
+ })
383
+ Object.assign(workflowWire, {
384
+ // Durable polling step: invoke an RPC (as an actor when options.as is
385
+ // set) until the predicate passes or `within` elapses. The whole poll is
386
+ // ONE recorded step, so replay returns the cached outcome.
387
+ expectEventually: async (
388
+ stepName: string,
389
+ rpcName: string,
390
+ data: any,
391
+ predicate: (output: any) => boolean,
392
+ options?: WorkflowExpectEventuallyOptions
393
+ ) => {
394
+ this.engine.verifyStepName(stepName)
395
+ const resolvedRpcName =
396
+ addonNamespace && !rpcName.includes(':')
397
+ ? `${addonNamespace}:${rpcName}`
398
+ : rpcName
399
+ const within = getDurationInMilliseconds(options?.within ?? '30s')
400
+ const interval = getDurationInMilliseconds(options?.interval ?? '1s')
401
+ return await this.engine.inlineStep(
402
+ runId,
403
+ stepName,
404
+ async () => {
405
+ const deadline = Date.now() + within
406
+ let last: any
407
+ while (true) {
408
+ last = options?.actor
409
+ ? await options.actor.invoke(resolvedRpcName, data)
410
+ : await rpcService.rpcWithWire(resolvedRpcName, data, {})
411
+ if (predicate(last)) return last
412
+ if (Date.now() + interval > deadline) {
413
+ throw new Error(
414
+ `[workflow] expectEventually '${stepName}' ('${resolvedRpcName}'` +
415
+ `${options?.actor ? ` as '${options.actor.name}'` : ''}) did not pass within ${within}ms; ` +
416
+ `last result: ${JSON.stringify(last)?.slice(0, 300)}`
417
+ )
418
+ }
419
+ await new Promise((resolve) => setTimeout(resolve, interval))
420
+ }
421
+ },
422
+ options
423
+ )
424
+ },
425
+
426
+ expectError: async (
427
+ stepName: string,
428
+ rpcName: string,
429
+ data: any,
430
+ options?: WorkflowExpectErrorOptions
431
+ ) => {
432
+ this.engine.verifyStepName(stepName)
433
+ const resolvedRpcName =
434
+ addonNamespace && !rpcName.includes(':')
435
+ ? `${addonNamespace}:${rpcName}`
436
+ : rpcName
437
+ return await this.engine.inlineStep(
438
+ runId,
439
+ stepName,
440
+ async () => {
441
+ let result: any
442
+ try {
443
+ result = options?.actor
444
+ ? await options.actor.invoke(resolvedRpcName, data)
445
+ : await rpcService.rpcWithWire(resolvedRpcName, data, {})
446
+ } catch (e: any) {
447
+ const message = e?.message ?? String(e)
448
+ if (options?.matches) {
449
+ const matched =
450
+ typeof options.matches === 'string'
451
+ ? message.includes(options.matches)
452
+ : options.matches.test(message)
453
+ if (!matched) {
454
+ throw new Error(
455
+ `[workflow] expectError '${stepName}' ('${resolvedRpcName}') threw, but the message did not match ${options.matches}: ${message}`
456
+ )
457
+ }
458
+ }
459
+ return message
460
+ }
461
+ throw new Error(
462
+ `[workflow] expectError '${stepName}' ('${resolvedRpcName}') expected an error but the call succeeded: ${JSON.stringify(result)?.slice(0, 300)}`
463
+ )
464
+ },
465
+ options
466
+ )
467
+ },
468
+
469
+ expectService: async (
470
+ stepName: string,
471
+ serviceMethod: string,
472
+ options?: WorkflowExpectServiceOptions
473
+ ) => {
474
+ this.engine.verifyStepName(stepName)
475
+ const [service, method] = serviceMethod.split('.')
476
+ if (!service || !method) {
477
+ throw new Error(
478
+ `[workflow] expectService '${stepName}' needs 'service.method', got '${serviceMethod}'`
479
+ )
480
+ }
481
+ await this.engine.inlineStep(
482
+ runId,
483
+ stepName,
484
+ async () => {
485
+ const rpcName = 'pikkuScenarioGetStubCalls'
486
+ const calls: Array<{
487
+ service: string
488
+ method: string
489
+ args: unknown[]
490
+ }> = options?.actor
491
+ ? await options.actor.invoke(rpcName, { service })
492
+ : await rpcService.rpcWithWire(rpcName, { service }, {})
493
+ const matching = (calls ?? []).filter(
494
+ (c) =>
495
+ c.service === service &&
496
+ c.method === method &&
497
+ (options?.calledWith === undefined ||
498
+ JSON.stringify(c.args?.[0]) ===
499
+ JSON.stringify(options.calledWith))
500
+ )
501
+ const expected = options?.times
502
+ const ok =
503
+ expected === undefined
504
+ ? matching.length > 0
505
+ : matching.length === expected
506
+ if (!ok) {
507
+ const seen =
508
+ (calls ?? [])
509
+ .map(
510
+ (c) =>
511
+ `${c.service}.${c.method}(${JSON.stringify(c.args?.[0])?.slice(0, 120) ?? ''})`
512
+ )
513
+ .join('\n ') || '(none)'
514
+ throw new Error(
515
+ `[workflow] expectService '${stepName}' expected ${expected ?? 'at least one'} call(s) to '${serviceMethod}'` +
516
+ `${options?.calledWith !== undefined ? ` with ${JSON.stringify(options.calledWith)}` : ''}, found ${matching.length}. Recorded:\n ${seen}`
517
+ )
518
+ }
519
+ },
520
+ options
521
+ )
522
+ },
523
+
524
+ // Scenario steps: a named `pikkuScenarioStep` run as one durable step.
525
+ // `given`/`when`/`then` are pure sugar over `step` — the phase only
526
+ // changes the prose a reporter renders.
527
+ step: (stepName, stepFunc, data, options) =>
528
+ this.scenarioStep(
529
+ 'step',
530
+ scenarioStepContext(),
531
+ stepName,
532
+ stepFunc,
533
+ data,
534
+ options
535
+ ),
536
+ given: (stepName, stepFunc, data, options) =>
537
+ this.scenarioStep(
538
+ 'given',
539
+ scenarioStepContext(),
540
+ stepName,
541
+ stepFunc,
542
+ data,
543
+ options
544
+ ),
545
+ when: (stepName, stepFunc, data, options) =>
546
+ this.scenarioStep(
547
+ 'when',
548
+ scenarioStepContext(),
549
+ stepName,
550
+ stepFunc,
551
+ data,
552
+ options
553
+ ),
554
+ then: (stepName, stepFunc, data, options) =>
555
+ this.scenarioStep(
556
+ 'then',
557
+ scenarioStepContext(),
558
+ stepName,
559
+ stepFunc,
560
+ data,
561
+ options
562
+ ),
563
+
564
+ runScheduledTask: async (taskName: string) => {
565
+ await runScheduledTask({ name: taskName })
566
+ },
567
+ } satisfies Omit<PikkuScenarioWire, keyof PikkuWorkflowWire>)
568
+ }
569
+
570
+ private async scenarioStep(
571
+ phase: ScenarioStepPhase,
572
+ context: ScenarioStepContext,
573
+ stepName: string,
574
+ stepFunc: string,
575
+ data?: any,
576
+ options?: ScenarioStepOptions
577
+ ): Promise<any> {
578
+ const { runId, workflowName, addonNamespace, workflowWire, rpcService } =
579
+ context
580
+ // Also the guard for `then` being a wire member: an accidental
581
+ // `await scenario` calls it with a resolve function, which lands here as a
582
+ // loud, named error instead of a silent hang.
583
+ this.engine.verifyStepName(stepName)
584
+ if (typeof stepFunc !== 'string') {
585
+ throw new WorkflowStepNameNotString(stepFunc)
586
+ }
587
+
588
+ const packageName =
589
+ addonNamespace && !stepFunc.includes(':') ? addonNamespace : null
590
+ const resolvedStepFunc =
591
+ addonNamespace && !stepFunc.includes(':')
592
+ ? `${addonNamespace}:${stepFunc}`
593
+ : stepFunc
594
+
595
+ const actor = options?.actor as ScenarioActors[string] | undefined
596
+ const description =
597
+ options?.description ??
598
+ this.scenarioStepDescription(packageName, resolvedStepFunc) ??
599
+ stepName
600
+
601
+ return await this.engine.inlineStep(
602
+ runId,
603
+ stepName,
604
+ async () => {
605
+ const wire: PikkuWire = {
606
+ workflow: workflowWire,
607
+ scenario: workflowWire,
608
+ rpc: rpcService?.wire?.rpc,
609
+ session: rpcService?.wire?.session,
610
+ pikkuUserId: workflowWire.pikkuUserId,
611
+ actors: this.runActors.get(runId),
612
+ scenarioStep: {
613
+ name: resolvedStepFunc,
614
+ stepName,
615
+ runId,
616
+ phase,
617
+ actor,
618
+ env: this.scenarioEnvironment,
619
+ },
620
+ }
621
+ if (this.requiresBrowser(packageName, resolvedStepFunc)) {
622
+ if (!this.scenarioBrowserProvider) {
623
+ throw new Error(
624
+ `[scenario] step '${resolvedStepFunc}' declares 'browser: true' but no browser provider is registered. ` +
625
+ `Install @pikku/playwright and register its provider, or run with --no-browser to skip browser steps.`
626
+ )
627
+ }
628
+ if (!actor) {
629
+ throw new Error(
630
+ `[scenario] step '${resolvedStepFunc}' declares 'browser: true' but was called without an actor. ` +
631
+ `Pass { actor: actors.<name> } so the browser signs in as that persona.`
632
+ )
633
+ }
634
+ wire.browser = await this.scenarioBrowserProvider.sessionFor(
635
+ actor.name
636
+ )
637
+ }
638
+ return await runPikkuFunc('workflow', workflowName, resolvedStepFunc, {
639
+ singletonServices: getSingletonServices()!,
640
+ createWireServices: getCreateWireServices(),
641
+ data: () => data,
642
+ wire,
643
+ packageName: packageName ?? undefined,
644
+ })
645
+ },
646
+ {
647
+ description,
648
+ // Retrying a failed assertion is the wrong behaviour for a test
649
+ // primitive, so steps opt out of the workflow-wide retry default.
650
+ retries: options?.retries ?? 0,
651
+ retryDelay: options?.retryDelay,
652
+ },
653
+ data,
654
+ resolvedStepFunc
655
+ )
656
+ }
657
+
658
+ private scenarioStepConfig(
659
+ packageName: string | null,
660
+ stepFunc: string
661
+ ): CorePikkuFunctionConfig<any, any> | undefined {
662
+ const localName =
663
+ packageName && stepFunc.startsWith(`${packageName}:`)
664
+ ? stepFunc.slice(packageName.length + 1)
665
+ : stepFunc
666
+ return pikkuState(packageName, 'function', 'functions').get(localName)
667
+ }
668
+
669
+ private scenarioStepDescription(
670
+ packageName: string | null,
671
+ stepFunc: string
672
+ ): string | undefined {
673
+ return this.scenarioStepConfig(packageName, stepFunc)?.description
674
+ }
675
+
676
+ private requiresBrowser(
677
+ packageName: string | null,
678
+ stepFunc: string
679
+ ): boolean {
680
+ return this.scenarioStepConfig(packageName, stepFunc)?.browser === true
681
+ }
682
+ }