@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
@@ -0,0 +1,121 @@
1
+ import { PikkuError } from '../../errors/error-handler.js';
2
+ import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js';
3
+ import { type RunLifecycleContext, type WorkflowRunEngine, type WorkflowRunExtension } from './pikku-workflow-service.js';
4
+ import type { PikkuWire } from '../../types/core.types.js';
5
+ import type { ScenarioActors } from '../../services/scenario-actors-service.js';
6
+ import type { ScenarioBrowserProvider, ScenarioEnvironment } from './scenario-step.types.js';
7
+ import type { PikkuWorkflowWire, WorkflowQueueOptions } from './workflow.types.js';
8
+ /**
9
+ * A workflow service with the scenario capability attached — the two lines
10
+ * `pikku scenario run` needs, in one call so no caller has to remember that the
11
+ * capability is installed rather than inherited.
12
+ *
13
+ * The in-memory service is the right engine because a scenario run is a single
14
+ * external process driving a deployed app over its real transport: there is
15
+ * nothing to persist and no second worker to resume it.
16
+ */
17
+ export declare const createScenarioRunner: (options?: WorkflowQueueOptions) => {
18
+ workflowService: InMemoryWorkflowService;
19
+ scenarioService: PikkuScenarioService;
20
+ };
21
+ /**
22
+ * A scenario's `before` or `after` hook threw. The original error is kept as
23
+ * the `cause` so the failure that actually happened is never lost behind the
24
+ * label saying which phase it happened in.
25
+ */
26
+ export declare class ScenarioHookError extends PikkuError {
27
+ readonly scenarioName: string;
28
+ readonly phase: 'before' | 'after';
29
+ constructor(scenarioName: string, phase: 'before' | 'after', cause: unknown);
30
+ }
31
+ /**
32
+ * The scenario capability, layered onto a workflow service rather than being
33
+ * one.
34
+ *
35
+ * Every scenario affordance — steps, actors, lifecycle hooks, the browser
36
+ * provider, the assertion wire members — lives here rather than on
37
+ * `PikkuWorkflowService`, because a bundler drops an unused *module* but never
38
+ * an unused class member: anything declared on the workflow service ships in
39
+ * every server built on Pikku, along with everything it imports. Scenarios only
40
+ * ever run from `pikku scenario run`, so the whole surface stays behind an
41
+ * import only that runner makes.
42
+ *
43
+ * It is not a workflow service because a scenario is not a different kind of
44
+ * run — it is the same durable run with a step vocabulary on top. What it
45
+ * needs from the engine it gets through the narrow `WorkflowRunEngine` handle,
46
+ * which is why recording a step never became public API.
47
+ *
48
+ * ```ts
49
+ * const workflowService = new InMemoryWorkflowService()
50
+ * const scenarioService = workflowService.setRunExtension(
51
+ * (engine) => new PikkuScenarioService(engine)
52
+ * )
53
+ * ```
54
+ */
55
+ export declare class PikkuScenarioService implements WorkflowRunExtension {
56
+ private readonly engine;
57
+ private runActors;
58
+ private scenarioBrowserProvider?;
59
+ private scenarioEnvironment?;
60
+ constructor(engine: WorkflowRunEngine);
61
+ /**
62
+ * Registered by `@pikku/playwright` (or any other driver) before a scenario
63
+ * runs. Absent means browser steps cannot run, which the CLI checks up front
64
+ * so a run fails fast rather than mid-flow.
65
+ */
66
+ setScenarioBrowserProvider(provider: ScenarioBrowserProvider | undefined): void;
67
+ getScenarioBrowserProvider(): ScenarioBrowserProvider | undefined;
68
+ /**
69
+ * The environment scenario steps run against, set once by the runner. It is
70
+ * per-service rather than per-run because a runner process targets exactly
71
+ * one environment for every scenario it executes.
72
+ */
73
+ setScenarioEnvironment(env: ScenarioEnvironment | undefined): void;
74
+ getScenarioEnvironment(): ScenarioEnvironment | undefined;
75
+ attachRunContext(runId: string, workflowMeta: any, options?: {
76
+ actors?: ScenarioActors;
77
+ }): Promise<void>;
78
+ detachRunContext(runId: string): void;
79
+ decorateRunWire(wire: PikkuWire, context: {
80
+ runId: string;
81
+ workflowMeta: any;
82
+ workflowWire: PikkuWorkflowWire;
83
+ }): void;
84
+ onBeforeRunFunc(context: RunLifecycleContext): Promise<void>;
85
+ onAfterRunFunc(context: RunLifecycleContext, outcome: 'completed' | 'failed' | 'interrupted', failure: unknown): Promise<void>;
86
+ /**
87
+ * Hooks are a scenario affordance only: a plain workflow is durable and
88
+ * resumable, so a callback that reruns on every replay has no honest meaning
89
+ * there.
90
+ */
91
+ private scenarioHooks;
92
+ /**
93
+ * Run a scenario `before`/`after` hook.
94
+ *
95
+ * A hook is not a pikku function: it has no id, no meta and no schema, so it
96
+ * cannot go through `runPikkuFunc` and the runner records nothing for it. It
97
+ * gets exactly what the scenario body gets — the same wire (so `actors` is
98
+ * how it reaches the app), and singleton services composed with this
99
+ * invocation's wire services — and nothing else.
100
+ */
101
+ private runScenarioHook;
102
+ /**
103
+ * Build HTTP scenario actors for a run started without them; undefined when
104
+ * SCENARIO_ACTOR_SECRET or the API URL is missing.
105
+ *
106
+ * The actor client is imported lazily so that even a runner bundle only pays
107
+ * for the AI persona conversation loop it pulls in when a scenario actually
108
+ * signs an actor in.
109
+ */
110
+ resolveScenarioActors(): Promise<ScenarioActors | undefined>;
111
+ decorateWorkflowWire(wire: PikkuWorkflowWire, context: {
112
+ name: string;
113
+ runId: string;
114
+ rpcService: any;
115
+ addonNamespace?: string | null;
116
+ }): void;
117
+ private scenarioStep;
118
+ private scenarioStepConfig;
119
+ private scenarioStepDescription;
120
+ private requiresBrowser;
121
+ }
@@ -0,0 +1,419 @@
1
+ import { runPikkuFunc } from '../../function/function-runner.js';
2
+ import { getSingletonServices, getCreateWireServices, pikkuState, } from '../../pikku-state.js';
3
+ import { getDurationInMilliseconds } from '../../time-utils.js';
4
+ import { closeWireServices } from '../../utils.js';
5
+ import { PikkuError, addError } from '../../errors/error-handler.js';
6
+ import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js';
7
+ import { runScheduledTask } from '../scheduler/scheduler-runner.js';
8
+ import { WorkflowStepNameNotString, } from './pikku-workflow-service.js';
9
+ /**
10
+ * A workflow service with the scenario capability attached — the two lines
11
+ * `pikku scenario run` needs, in one call so no caller has to remember that the
12
+ * capability is installed rather than inherited.
13
+ *
14
+ * The in-memory service is the right engine because a scenario run is a single
15
+ * external process driving a deployed app over its real transport: there is
16
+ * nothing to persist and no second worker to resume it.
17
+ */
18
+ export const createScenarioRunner = (options = {}) => {
19
+ const workflowService = new InMemoryWorkflowService(options);
20
+ const scenarioService = workflowService.setRunExtension((engine) => new PikkuScenarioService(engine));
21
+ return { workflowService, scenarioService };
22
+ };
23
+ /**
24
+ * A scenario's `before` or `after` hook threw. The original error is kept as
25
+ * the `cause` so the failure that actually happened is never lost behind the
26
+ * label saying which phase it happened in.
27
+ */
28
+ export class ScenarioHookError extends PikkuError {
29
+ scenarioName;
30
+ phase;
31
+ constructor(scenarioName, phase, cause) {
32
+ super(`Scenario '${scenarioName}' ${phase} hook failed: ${cause instanceof Error ? cause.message : String(cause)}`);
33
+ this.scenarioName = scenarioName;
34
+ this.phase = phase;
35
+ this.cause = cause;
36
+ }
37
+ }
38
+ addError(ScenarioHookError, {
39
+ status: 500,
40
+ message: 'A scenario lifecycle hook failed.',
41
+ });
42
+ /**
43
+ * The scenario capability, layered onto a workflow service rather than being
44
+ * one.
45
+ *
46
+ * Every scenario affordance — steps, actors, lifecycle hooks, the browser
47
+ * provider, the assertion wire members — lives here rather than on
48
+ * `PikkuWorkflowService`, because a bundler drops an unused *module* but never
49
+ * an unused class member: anything declared on the workflow service ships in
50
+ * every server built on Pikku, along with everything it imports. Scenarios only
51
+ * ever run from `pikku scenario run`, so the whole surface stays behind an
52
+ * import only that runner makes.
53
+ *
54
+ * It is not a workflow service because a scenario is not a different kind of
55
+ * run — it is the same durable run with a step vocabulary on top. What it
56
+ * needs from the engine it gets through the narrow `WorkflowRunEngine` handle,
57
+ * which is why recording a step never became public API.
58
+ *
59
+ * ```ts
60
+ * const workflowService = new InMemoryWorkflowService()
61
+ * const scenarioService = workflowService.setRunExtension(
62
+ * (engine) => new PikkuScenarioService(engine)
63
+ * )
64
+ * ```
65
+ */
66
+ export class PikkuScenarioService {
67
+ engine;
68
+ // Scenario actors per run: live authenticated clients (cookie jars) are
69
+ // process-local by nature, so they ride this map, never the persisted wire.
70
+ runActors = new Map();
71
+ scenarioBrowserProvider;
72
+ scenarioEnvironment;
73
+ constructor(engine) {
74
+ this.engine = engine;
75
+ }
76
+ /**
77
+ * Registered by `@pikku/playwright` (or any other driver) before a scenario
78
+ * runs. Absent means browser steps cannot run, which the CLI checks up front
79
+ * so a run fails fast rather than mid-flow.
80
+ */
81
+ setScenarioBrowserProvider(provider) {
82
+ this.scenarioBrowserProvider = provider;
83
+ }
84
+ getScenarioBrowserProvider() {
85
+ return this.scenarioBrowserProvider;
86
+ }
87
+ /**
88
+ * The environment scenario steps run against, set once by the runner. It is
89
+ * per-service rather than per-run because a runner process targets exactly
90
+ * one environment for every scenario it executes.
91
+ */
92
+ setScenarioEnvironment(env) {
93
+ this.scenarioEnvironment = env;
94
+ }
95
+ getScenarioEnvironment() {
96
+ return this.scenarioEnvironment;
97
+ }
98
+ async attachRunContext(runId, workflowMeta, options) {
99
+ const actors = options?.actors ??
100
+ (workflowMeta.source === 'scenario'
101
+ ? await this.resolveScenarioActors()
102
+ : undefined);
103
+ if (actors) {
104
+ this.runActors.set(runId, actors);
105
+ }
106
+ }
107
+ detachRunContext(runId) {
108
+ this.runActors.delete(runId);
109
+ }
110
+ decorateRunWire(wire, context) {
111
+ wire.scenario =
112
+ context.workflowMeta?.source === 'scenario'
113
+ ? context.workflowWire
114
+ : undefined;
115
+ wire.actors = this.runActors.get(context.runId);
116
+ }
117
+ async onBeforeRunFunc(context) {
118
+ const hooks = this.scenarioHooks(context);
119
+ if (!hooks?.before)
120
+ return;
121
+ await this.runScenarioHook('before', context.workflowMeta.name, hooks.before, context.wire, context.run.input, context.packageName);
122
+ }
123
+ async onAfterRunFunc(context, outcome, failure) {
124
+ if (outcome === 'interrupted')
125
+ return;
126
+ const hooks = this.scenarioHooks(context);
127
+ if (!hooks?.after)
128
+ return;
129
+ const { runId, run, workflowMeta } = context;
130
+ try {
131
+ await this.runScenarioHook('after', workflowMeta.name, hooks.after, context.wire, run.input, context.packageName);
132
+ }
133
+ catch (hookError) {
134
+ if (outcome === 'failed') {
135
+ // The scenario already failed for its own reason; a teardown failure is
136
+ // diagnostic context, never the headline.
137
+ if (failure instanceof Error && failure.cause === undefined) {
138
+ failure.cause = hookError;
139
+ }
140
+ getSingletonServices()?.logger.error(`Scenario ${workflowMeta.name} (run ${runId}) failed, and its after hook also failed:`, hookError);
141
+ }
142
+ else {
143
+ await this.engine.updateRunStatus(runId, 'failed', undefined, {
144
+ message: hookError.message,
145
+ stack: hookError.stack,
146
+ code: hookError.code,
147
+ });
148
+ await this.engine.onChildWorkflowFailed(run, hookError);
149
+ throw hookError;
150
+ }
151
+ }
152
+ }
153
+ /**
154
+ * Hooks are a scenario affordance only: a plain workflow is durable and
155
+ * resumable, so a callback that reruns on every replay has no honest meaning
156
+ * there.
157
+ */
158
+ scenarioHooks(context) {
159
+ return context.workflowMeta.source === 'scenario'
160
+ ? context.workflow.func
161
+ : undefined;
162
+ }
163
+ /**
164
+ * Run a scenario `before`/`after` hook.
165
+ *
166
+ * A hook is not a pikku function: it has no id, no meta and no schema, so it
167
+ * cannot go through `runPikkuFunc` and the runner records nothing for it. It
168
+ * gets exactly what the scenario body gets — the same wire (so `actors` is
169
+ * how it reaches the app), and singleton services composed with this
170
+ * invocation's wire services — and nothing else.
171
+ */
172
+ async runScenarioHook(phase, scenarioName, hook, wire, data, packageName) {
173
+ const singletonServices = getSingletonServices();
174
+ let createWireServices = getCreateWireServices();
175
+ if (packageName) {
176
+ const factories = pikkuState(packageName, 'package', 'factories');
177
+ if (factories?.createWireServices) {
178
+ createWireServices = factories.createWireServices;
179
+ }
180
+ }
181
+ let wireServices;
182
+ try {
183
+ wireServices = (await createWireServices?.(singletonServices, wire));
184
+ const services = wireServices && Object.keys(wireServices).length > 0
185
+ ? { ...singletonServices, ...wireServices }
186
+ : singletonServices;
187
+ await hook(services, data, wire);
188
+ }
189
+ catch (error) {
190
+ throw new ScenarioHookError(scenarioName, phase, error);
191
+ }
192
+ finally {
193
+ if (wireServices && Object.keys(wireServices).length > 0) {
194
+ await closeWireServices(singletonServices.logger, wireServices);
195
+ }
196
+ }
197
+ }
198
+ /**
199
+ * Build HTTP scenario actors for a run started without them; undefined when
200
+ * SCENARIO_ACTOR_SECRET or the API URL is missing.
201
+ *
202
+ * The actor client is imported lazily so that even a runner bundle only pays
203
+ * for the AI persona conversation loop it pulls in when a scenario actually
204
+ * signs an actor in.
205
+ */
206
+ async resolveScenarioActors() {
207
+ const services = getSingletonServices();
208
+ const variables = services?.variables;
209
+ const metaService = services?.metaService;
210
+ if (!variables || !metaService) {
211
+ return undefined;
212
+ }
213
+ const secret = await variables.get('SCENARIO_ACTOR_SECRET');
214
+ const apiUrl = await variables.get('API_URL');
215
+ if (!secret || !apiUrl) {
216
+ services?.logger?.warn('A scenario was started without actors but SCENARIO_ACTOR_SECRET / API_URL is not configured — running without actors.');
217
+ return undefined;
218
+ }
219
+ const actorsConfig = await metaService.getScenarioActorsMeta();
220
+ if (!actorsConfig || Object.keys(actorsConfig).length === 0) {
221
+ return undefined;
222
+ }
223
+ const signInPath = (await variables.get('SCENARIO_SIGN_IN_PATH')) ??
224
+ '/api/auth/sign-in/actor';
225
+ const rpcPath = (await variables.get('SCENARIO_RPC_PATH')) ?? '/rpc';
226
+ // A run started outside the CLI still targets an environment — its own.
227
+ this.scenarioEnvironment ??= {
228
+ apiUrl,
229
+ appUrl: (await variables.get('APP_URL')) ?? undefined,
230
+ };
231
+ const { createHttpScenarioActors } = await import('../../services/http-scenario-actors.js');
232
+ return createHttpScenarioActors({
233
+ apiUrl,
234
+ secret,
235
+ actors: actorsConfig,
236
+ signInPath,
237
+ rpcPath,
238
+ });
239
+ }
240
+ decorateWorkflowWire(wire, context) {
241
+ const { name, runId, rpcService, addonNamespace } = context;
242
+ const workflowWire = wire;
243
+ const scenarioStepContext = () => ({
244
+ runId,
245
+ workflowName: name,
246
+ addonNamespace,
247
+ workflowWire,
248
+ rpcService,
249
+ });
250
+ Object.assign(workflowWire, {
251
+ // Durable polling step: invoke an RPC (as an actor when options.as is
252
+ // set) until the predicate passes or `within` elapses. The whole poll is
253
+ // ONE recorded step, so replay returns the cached outcome.
254
+ expectEventually: async (stepName, rpcName, data, predicate, options) => {
255
+ this.engine.verifyStepName(stepName);
256
+ const resolvedRpcName = addonNamespace && !rpcName.includes(':')
257
+ ? `${addonNamespace}:${rpcName}`
258
+ : rpcName;
259
+ const within = getDurationInMilliseconds(options?.within ?? '30s');
260
+ const interval = getDurationInMilliseconds(options?.interval ?? '1s');
261
+ return await this.engine.inlineStep(runId, stepName, async () => {
262
+ const deadline = Date.now() + within;
263
+ let last;
264
+ while (true) {
265
+ last = options?.actor
266
+ ? await options.actor.invoke(resolvedRpcName, data)
267
+ : await rpcService.rpcWithWire(resolvedRpcName, data, {});
268
+ if (predicate(last))
269
+ return last;
270
+ if (Date.now() + interval > deadline) {
271
+ throw new Error(`[workflow] expectEventually '${stepName}' ('${resolvedRpcName}'` +
272
+ `${options?.actor ? ` as '${options.actor.name}'` : ''}) did not pass within ${within}ms; ` +
273
+ `last result: ${JSON.stringify(last)?.slice(0, 300)}`);
274
+ }
275
+ await new Promise((resolve) => setTimeout(resolve, interval));
276
+ }
277
+ }, options);
278
+ },
279
+ expectError: async (stepName, rpcName, data, options) => {
280
+ this.engine.verifyStepName(stepName);
281
+ const resolvedRpcName = addonNamespace && !rpcName.includes(':')
282
+ ? `${addonNamespace}:${rpcName}`
283
+ : rpcName;
284
+ return await this.engine.inlineStep(runId, stepName, async () => {
285
+ let result;
286
+ try {
287
+ result = options?.actor
288
+ ? await options.actor.invoke(resolvedRpcName, data)
289
+ : await rpcService.rpcWithWire(resolvedRpcName, data, {});
290
+ }
291
+ catch (e) {
292
+ const message = e?.message ?? String(e);
293
+ if (options?.matches) {
294
+ const matched = typeof options.matches === 'string'
295
+ ? message.includes(options.matches)
296
+ : options.matches.test(message);
297
+ if (!matched) {
298
+ throw new Error(`[workflow] expectError '${stepName}' ('${resolvedRpcName}') threw, but the message did not match ${options.matches}: ${message}`);
299
+ }
300
+ }
301
+ return message;
302
+ }
303
+ throw new Error(`[workflow] expectError '${stepName}' ('${resolvedRpcName}') expected an error but the call succeeded: ${JSON.stringify(result)?.slice(0, 300)}`);
304
+ }, options);
305
+ },
306
+ expectService: async (stepName, serviceMethod, options) => {
307
+ this.engine.verifyStepName(stepName);
308
+ const [service, method] = serviceMethod.split('.');
309
+ if (!service || !method) {
310
+ throw new Error(`[workflow] expectService '${stepName}' needs 'service.method', got '${serviceMethod}'`);
311
+ }
312
+ await this.engine.inlineStep(runId, stepName, async () => {
313
+ const rpcName = 'pikkuScenarioGetStubCalls';
314
+ const calls = options?.actor
315
+ ? await options.actor.invoke(rpcName, { service })
316
+ : await rpcService.rpcWithWire(rpcName, { service }, {});
317
+ const matching = (calls ?? []).filter((c) => c.service === service &&
318
+ c.method === method &&
319
+ (options?.calledWith === undefined ||
320
+ JSON.stringify(c.args?.[0]) ===
321
+ JSON.stringify(options.calledWith)));
322
+ const expected = options?.times;
323
+ const ok = expected === undefined
324
+ ? matching.length > 0
325
+ : matching.length === expected;
326
+ if (!ok) {
327
+ const seen = (calls ?? [])
328
+ .map((c) => `${c.service}.${c.method}(${JSON.stringify(c.args?.[0])?.slice(0, 120) ?? ''})`)
329
+ .join('\n ') || '(none)';
330
+ throw new Error(`[workflow] expectService '${stepName}' expected ${expected ?? 'at least one'} call(s) to '${serviceMethod}'` +
331
+ `${options?.calledWith !== undefined ? ` with ${JSON.stringify(options.calledWith)}` : ''}, found ${matching.length}. Recorded:\n ${seen}`);
332
+ }
333
+ }, options);
334
+ },
335
+ // Scenario steps: a named `pikkuScenarioStep` run as one durable step.
336
+ // `given`/`when`/`then` are pure sugar over `step` — the phase only
337
+ // changes the prose a reporter renders.
338
+ step: (stepName, stepFunc, data, options) => this.scenarioStep('step', scenarioStepContext(), stepName, stepFunc, data, options),
339
+ given: (stepName, stepFunc, data, options) => this.scenarioStep('given', scenarioStepContext(), stepName, stepFunc, data, options),
340
+ when: (stepName, stepFunc, data, options) => this.scenarioStep('when', scenarioStepContext(), stepName, stepFunc, data, options),
341
+ then: (stepName, stepFunc, data, options) => this.scenarioStep('then', scenarioStepContext(), stepName, stepFunc, data, options),
342
+ runScheduledTask: async (taskName) => {
343
+ await runScheduledTask({ name: taskName });
344
+ },
345
+ });
346
+ }
347
+ async scenarioStep(phase, context, stepName, stepFunc, data, options) {
348
+ const { runId, workflowName, addonNamespace, workflowWire, rpcService } = context;
349
+ // Also the guard for `then` being a wire member: an accidental
350
+ // `await scenario` calls it with a resolve function, which lands here as a
351
+ // loud, named error instead of a silent hang.
352
+ this.engine.verifyStepName(stepName);
353
+ if (typeof stepFunc !== 'string') {
354
+ throw new WorkflowStepNameNotString(stepFunc);
355
+ }
356
+ const packageName = addonNamespace && !stepFunc.includes(':') ? addonNamespace : null;
357
+ const resolvedStepFunc = addonNamespace && !stepFunc.includes(':')
358
+ ? `${addonNamespace}:${stepFunc}`
359
+ : stepFunc;
360
+ const actor = options?.actor;
361
+ const description = options?.description ??
362
+ this.scenarioStepDescription(packageName, resolvedStepFunc) ??
363
+ stepName;
364
+ return await this.engine.inlineStep(runId, stepName, async () => {
365
+ const wire = {
366
+ workflow: workflowWire,
367
+ scenario: workflowWire,
368
+ rpc: rpcService?.wire?.rpc,
369
+ session: rpcService?.wire?.session,
370
+ pikkuUserId: workflowWire.pikkuUserId,
371
+ actors: this.runActors.get(runId),
372
+ scenarioStep: {
373
+ name: resolvedStepFunc,
374
+ stepName,
375
+ runId,
376
+ phase,
377
+ actor,
378
+ env: this.scenarioEnvironment,
379
+ },
380
+ };
381
+ if (this.requiresBrowser(packageName, resolvedStepFunc)) {
382
+ if (!this.scenarioBrowserProvider) {
383
+ throw new Error(`[scenario] step '${resolvedStepFunc}' declares 'browser: true' but no browser provider is registered. ` +
384
+ `Install @pikku/playwright and register its provider, or run with --no-browser to skip browser steps.`);
385
+ }
386
+ if (!actor) {
387
+ throw new Error(`[scenario] step '${resolvedStepFunc}' declares 'browser: true' but was called without an actor. ` +
388
+ `Pass { actor: actors.<name> } so the browser signs in as that persona.`);
389
+ }
390
+ wire.browser = await this.scenarioBrowserProvider.sessionFor(actor.name);
391
+ }
392
+ return await runPikkuFunc('workflow', workflowName, resolvedStepFunc, {
393
+ singletonServices: getSingletonServices(),
394
+ createWireServices: getCreateWireServices(),
395
+ data: () => data,
396
+ wire,
397
+ packageName: packageName ?? undefined,
398
+ });
399
+ }, {
400
+ description,
401
+ // Retrying a failed assertion is the wrong behaviour for a test
402
+ // primitive, so steps opt out of the workflow-wide retry default.
403
+ retries: options?.retries ?? 0,
404
+ retryDelay: options?.retryDelay,
405
+ }, data, resolvedStepFunc);
406
+ }
407
+ scenarioStepConfig(packageName, stepFunc) {
408
+ const localName = packageName && stepFunc.startsWith(`${packageName}:`)
409
+ ? stepFunc.slice(packageName.length + 1)
410
+ : stepFunc;
411
+ return pikkuState(packageName, 'function', 'functions').get(localName);
412
+ }
413
+ scenarioStepDescription(packageName, stepFunc) {
414
+ return this.scenarioStepConfig(packageName, stepFunc)?.description;
415
+ }
416
+ requiresBrowser(packageName, stepFunc) {
417
+ return this.scenarioStepConfig(packageName, stepFunc)?.browser === true;
418
+ }
419
+ }