@pikku/core 0.12.67 → 0.12.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +397 -0
- package/README.md +34 -2
- package/dist/function/functions.types.d.ts +27 -0
- package/dist/index.d.ts +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/internal.js +1 -1
- package/dist/pikku-state.js +1 -0
- package/dist/services/http-scenario-actors.d.ts +12 -4
- package/dist/services/http-scenario-actors.js +47 -45
- package/dist/services/in-memory-workflow-service.d.ts +2 -2
- package/dist/services/in-memory-workflow-service.js +2 -2
- package/dist/services/index.d.ts +2 -1
- package/dist/services/index.js +1 -0
- package/dist/services/meta-service.d.ts +5 -1
- package/dist/services/meta-service.js +44 -18
- package/dist/services/scenario-actors-service.d.ts +108 -2
- package/dist/services/scenario-actors-service.js +40 -1
- package/dist/types/core.types.d.ts +21 -3
- package/dist/types/state.types.d.ts +3 -1
- package/dist/wirings/actor-flow/actor-flow.types.d.ts +1 -1
- package/dist/wirings/actor-flow/index.d.ts +1 -1
- package/dist/wirings/actor-flow/run-conversation.d.ts +10 -10
- package/dist/wirings/actor-flow/run-conversation.js +27 -27
- package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +18 -1
- package/dist/wirings/ai-agent/ai-agent-prepare.js +26 -4
- package/dist/wirings/cli/command-parser.js +11 -1
- package/dist/wirings/queue/index.d.ts +1 -1
- package/dist/wirings/queue/queue.types.d.ts +30 -0
- package/dist/wirings/rpc/rpc-runner.js +1 -1
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +52 -3
- package/dist/wirings/workflow/feature.d.ts +28 -0
- package/dist/wirings/workflow/feature.js +57 -0
- package/dist/wirings/workflow/index.d.ts +13 -2
- package/dist/wirings/workflow/index.js +15 -0
- package/dist/wirings/workflow/pikku-scenario-service.d.ts +121 -0
- package/dist/wirings/workflow/pikku-scenario-service.js +419 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +118 -12
- package/dist/wirings/workflow/pikku-workflow-service.js +166 -153
- package/dist/wirings/workflow/scenario-cookie-jar.d.ts +29 -0
- package/dist/wirings/workflow/scenario-cookie-jar.js +51 -0
- package/dist/wirings/workflow/scenario-poll.d.ts +20 -0
- package/dist/wirings/workflow/scenario-poll.js +25 -0
- package/dist/wirings/workflow/scenario-prose.d.ts +38 -0
- package/dist/wirings/workflow/scenario-prose.js +45 -0
- package/dist/wirings/workflow/scenario-step-guards.d.ts +16 -0
- package/dist/wirings/workflow/scenario-step-guards.js +29 -0
- package/dist/wirings/workflow/scenario-step.types.d.ts +148 -0
- package/dist/wirings/workflow/scenario-step.types.js +1 -0
- package/dist/wirings/workflow/workflow.types.d.ts +119 -2
- package/package.json +3 -1
- package/src/function/functions.types.ts +32 -0
- package/src/index.ts +1 -0
- package/src/internal.ts +5 -1
- package/src/pikku-state.ts +1 -0
- package/src/services/http-scenario-actors.test.ts +85 -1
- package/src/services/http-scenario-actors.ts +65 -51
- package/src/services/in-memory-workflow-service.test.ts +50 -1
- package/src/services/in-memory-workflow-service.ts +3 -2
- package/src/services/index.ts +5 -0
- package/src/services/meta-service.test.ts +79 -0
- package/src/services/meta-service.ts +61 -26
- package/src/services/scenario-actors-service.ts +157 -2
- package/src/types/core.types.ts +27 -2
- package/src/types/state.types.ts +3 -0
- package/src/wirings/actor-flow/actor-flow.types.ts +1 -1
- package/src/wirings/actor-flow/index.ts +1 -1
- package/src/wirings/actor-flow/run-conversation.test.ts +12 -6
- package/src/wirings/actor-flow/run-conversation.ts +36 -41
- package/src/wirings/ai-agent/ai-agent-prepare.test.ts +29 -0
- package/src/wirings/ai-agent/ai-agent-prepare.ts +38 -4
- package/src/wirings/cli/command-parser.test.ts +60 -0
- package/src/wirings/cli/command-parser.ts +12 -1
- package/src/wirings/queue/index.ts +2 -0
- package/src/wirings/queue/queue.types.ts +32 -0
- package/src/wirings/rpc/rpc-runner.test.ts +28 -5
- package/src/wirings/rpc/rpc-runner.ts +1 -1
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +86 -2
- package/src/wirings/workflow/feature.test.ts +131 -0
- package/src/wirings/workflow/feature.ts +78 -0
- package/src/wirings/workflow/index.ts +74 -0
- package/src/wirings/workflow/pikku-scenario-service.ts +682 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +126 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +306 -228
- package/src/wirings/workflow/scenario-cookie-jar.test.ts +108 -0
- package/src/wirings/workflow/scenario-cookie-jar.ts +65 -0
- package/src/wirings/workflow/scenario-hooks.test.ts +212 -0
- package/src/wirings/workflow/scenario-poll.test.ts +66 -0
- package/src/wirings/workflow/scenario-poll.ts +36 -0
- package/src/wirings/workflow/scenario-prose.test.ts +152 -0
- package/src/wirings/workflow/scenario-prose.ts +79 -0
- package/src/wirings/workflow/scenario-service.test.ts +155 -0
- package/src/wirings/workflow/scenario-step-guards.ts +43 -0
- package/src/wirings/workflow/scenario-step.test.ts +441 -8
- package/src/wirings/workflow/scenario-step.types.ts +157 -0
- package/src/wirings/workflow/workflow.types.ts +137 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -74,6 +74,20 @@ const testMeta: CLIMeta = {
|
|
|
74
74
|
positionals: [{ name: 'name', required: false }],
|
|
75
75
|
options: {},
|
|
76
76
|
},
|
|
77
|
+
serve: {
|
|
78
|
+
pikkuFuncId: 'serveFunc',
|
|
79
|
+
positionals: [],
|
|
80
|
+
options: {
|
|
81
|
+
browser: {
|
|
82
|
+
description: 'Open a browser',
|
|
83
|
+
default: true,
|
|
84
|
+
},
|
|
85
|
+
port: {
|
|
86
|
+
description: 'Port to listen on',
|
|
87
|
+
default: 8080,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
77
91
|
},
|
|
78
92
|
},
|
|
79
93
|
},
|
|
@@ -103,6 +117,52 @@ describe('Command Parser', () => {
|
|
|
103
117
|
assert.strictEqual(result.errors.length, 0)
|
|
104
118
|
})
|
|
105
119
|
|
|
120
|
+
test('should turn a boolean option off with --no-<flag>', () => {
|
|
121
|
+
const result = parseCLIArguments(
|
|
122
|
+
['serve', '--no-browser'],
|
|
123
|
+
'test-cli',
|
|
124
|
+
testMeta
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
assert.strictEqual(result.options.browser, false)
|
|
128
|
+
assert.strictEqual(result.errors.length, 0)
|
|
129
|
+
assert.deepStrictEqual(
|
|
130
|
+
result.warnings ?? [],
|
|
131
|
+
[],
|
|
132
|
+
'--no-browser is the negation of a known option, not an unknown one'
|
|
133
|
+
)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('should leave a boolean option at its default when not negated', () => {
|
|
137
|
+
const result = parseCLIArguments(['serve'], 'test-cli', testMeta)
|
|
138
|
+
|
|
139
|
+
assert.strictEqual(result.options.browser, true)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('should not consume the next argument when negating', () => {
|
|
143
|
+
const result = parseCLIArguments(
|
|
144
|
+
['serve', '--no-browser', '--port', '4077'],
|
|
145
|
+
'test-cli',
|
|
146
|
+
testMeta
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
assert.strictEqual(result.options.browser, false)
|
|
150
|
+
assert.strictEqual(result.options.port, 4077)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('should still warn about an unknown --no-<flag>', () => {
|
|
154
|
+
const result = parseCLIArguments(
|
|
155
|
+
['serve', '--no-telemetry'],
|
|
156
|
+
'test-cli',
|
|
157
|
+
testMeta
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
assert.ok(
|
|
161
|
+
(result.warnings ?? []).length > 0,
|
|
162
|
+
'negating an option that does not exist is a typo, not a feature'
|
|
163
|
+
)
|
|
164
|
+
})
|
|
165
|
+
|
|
106
166
|
test('should parse command with short flag', () => {
|
|
107
167
|
const result = parseCLIArguments(
|
|
108
168
|
['greet', 'Alice', '-l'],
|
|
@@ -210,8 +210,19 @@ export function parseCLIArguments(
|
|
|
210
210
|
|
|
211
211
|
if (arg.startsWith('--')) {
|
|
212
212
|
// Long option (--from-plan → fromPlan)
|
|
213
|
+
const negatedKey = arg.startsWith('--no-')
|
|
214
|
+
? toCamelCase(arg.slice(5))
|
|
215
|
+
: undefined
|
|
213
216
|
const equalIndex = arg.indexOf('=')
|
|
214
|
-
if (
|
|
217
|
+
if (
|
|
218
|
+
negatedKey &&
|
|
219
|
+
typeof availableOptions[negatedKey]?.default === 'boolean'
|
|
220
|
+
) {
|
|
221
|
+
// --no-<flag> turns a boolean option off. Only options that declare a
|
|
222
|
+
// boolean default can be negated, so a literal `--no-something` option
|
|
223
|
+
// name still parses as itself.
|
|
224
|
+
optionArgs[negatedKey] = false
|
|
225
|
+
} else if (equalIndex > 0) {
|
|
215
226
|
// --option=value format
|
|
216
227
|
const key = toCamelCase(arg.slice(2, equalIndex))
|
|
217
228
|
const optionDef = availableOptions[key]
|
|
@@ -31,6 +31,36 @@ export interface PikkuWorkerConfig {
|
|
|
31
31
|
maxStalledCount?: number
|
|
32
32
|
/** Condition to start processor at instance creation */
|
|
33
33
|
autorun?: boolean
|
|
34
|
+
/**
|
|
35
|
+
* Cap how many jobs of any one group ({@link JobOptions.group}) may run at
|
|
36
|
+
* once, so a single group can't occupy the whole worker. Lets one shared
|
|
37
|
+
* queue stay fair across producers instead of splitting it into one queue
|
|
38
|
+
* per producer — which multiplies polling cost on pull-based backends.
|
|
39
|
+
* Must not exceed {@link batchSize}.
|
|
40
|
+
*/
|
|
41
|
+
groupConcurrency?: number | GroupConcurrencyConfig
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Per-group concurrency limits, optionally varied by tier so slow groups can
|
|
46
|
+
* be allowed more (or fewer) slots than the default.
|
|
47
|
+
*/
|
|
48
|
+
export interface GroupConcurrencyConfig {
|
|
49
|
+
/** Limit applied to any group without a matching tier */
|
|
50
|
+
default: number
|
|
51
|
+
/** Per-tier overrides, keyed by {@link JobGroup.tier} */
|
|
52
|
+
tiers?: Record<string, number>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Fairness key for a job. Jobs sharing an `id` count against the same
|
|
57
|
+
* {@link PikkuWorkerConfig.groupConcurrency} limit.
|
|
58
|
+
*/
|
|
59
|
+
export interface JobGroup {
|
|
60
|
+
/** Group this job belongs to (e.g. a workflow name) */
|
|
61
|
+
id: string
|
|
62
|
+
/** Optional tier selecting a per-tier limit */
|
|
63
|
+
tier?: string
|
|
34
64
|
}
|
|
35
65
|
|
|
36
66
|
/**
|
|
@@ -113,6 +143,8 @@ export interface JobOptions {
|
|
|
113
143
|
jobId?: string
|
|
114
144
|
/** Pikku user ID to propagate to the queue worker for credential resolution */
|
|
115
145
|
pikkuUserId?: string
|
|
146
|
+
/** Fairness key — counts against the worker's {@link PikkuWorkerConfig.groupConcurrency} */
|
|
147
|
+
group?: JobGroup
|
|
116
148
|
}
|
|
117
149
|
|
|
118
150
|
/**
|
|
@@ -29,11 +29,13 @@ const registerFunction = (
|
|
|
29
29
|
expose,
|
|
30
30
|
pikkuFuncId,
|
|
31
31
|
tags,
|
|
32
|
+
scenarioStep,
|
|
32
33
|
}: {
|
|
33
34
|
packageName?: string | null
|
|
34
35
|
expose?: boolean
|
|
35
36
|
pikkuFuncId?: string
|
|
36
37
|
tags?: string[]
|
|
38
|
+
scenarioStep?: boolean
|
|
37
39
|
} = {}
|
|
38
40
|
) => {
|
|
39
41
|
addFunction(funcName, { func } as never, packageName)
|
|
@@ -44,6 +46,7 @@ const registerFunction = (
|
|
|
44
46
|
permissions: [],
|
|
45
47
|
expose,
|
|
46
48
|
tags,
|
|
49
|
+
scenarioStep,
|
|
47
50
|
} as never
|
|
48
51
|
}
|
|
49
52
|
|
|
@@ -312,6 +315,27 @@ describe('ContextAwareRPCService.rpcExposed', () => {
|
|
|
312
315
|
)
|
|
313
316
|
})
|
|
314
317
|
|
|
318
|
+
test('a scenario step is not callable over the network, even if marked exposed', async () => {
|
|
319
|
+
// A step may drive a browser or assert against fixtures, so it is a step
|
|
320
|
+
// RPC — dispatched by name inside a run, never reachable from outside it.
|
|
321
|
+
pikkuState(null, 'rpc', 'meta').seesAddonCard = 'seesAddonCard'
|
|
322
|
+
registerFunction('seesAddonCard', async () => ({ visible: true }), {
|
|
323
|
+
expose: true,
|
|
324
|
+
scenarioStep: true,
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
const service = new ContextAwareRPCService(
|
|
328
|
+
createServices(),
|
|
329
|
+
{} as never,
|
|
330
|
+
{}
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
await assert.rejects(
|
|
334
|
+
() => service.rpcExposed('seesAddonCard', {}),
|
|
335
|
+
RPCNotFoundError
|
|
336
|
+
)
|
|
337
|
+
})
|
|
338
|
+
|
|
315
339
|
test('runs exposed addon functions', async () => {
|
|
316
340
|
pikkuState(null, 'addons', 'packages').set('addon', {
|
|
317
341
|
package: '@addon/pkg',
|
|
@@ -669,7 +693,9 @@ describe('wireRemoteAddon dispatch', () => {
|
|
|
669
693
|
{}
|
|
670
694
|
)
|
|
671
695
|
|
|
672
|
-
const result = await service.rpc('registry:getOpenApi', {
|
|
696
|
+
const result = await service.rpc('registry:getOpenApi', {
|
|
697
|
+
name: 'stripe',
|
|
698
|
+
})
|
|
673
699
|
|
|
674
700
|
assert.deepEqual(result, { echoed: 42 })
|
|
675
701
|
assert.equal(calls.length, 1)
|
|
@@ -713,10 +739,7 @@ describe('wireRemoteAddon dispatch', () => {
|
|
|
713
739
|
)
|
|
714
740
|
|
|
715
741
|
await service.rpc('registry:getOpenApi', {})
|
|
716
|
-
assert.equal(
|
|
717
|
-
calls[0]!.init.headers.authorization,
|
|
718
|
-
'Bearer user-token-9'
|
|
719
|
-
)
|
|
742
|
+
assert.equal(calls[0]!.init.headers.authorization, 'Bearer user-token-9')
|
|
720
743
|
} finally {
|
|
721
744
|
restoreFetch()
|
|
722
745
|
}
|
|
@@ -165,7 +165,7 @@ export class ContextAwareRPCService {
|
|
|
165
165
|
if (!functionMeta) {
|
|
166
166
|
throw new RPCNotFoundError(funcName)
|
|
167
167
|
}
|
|
168
|
-
if (!functionMeta.expose) {
|
|
168
|
+
if (!functionMeta.expose || functionMeta.scenarioStep) {
|
|
169
169
|
throw new RPCNotFoundError(funcName)
|
|
170
170
|
}
|
|
171
171
|
return await this.rpc(funcName, data)
|
|
@@ -7,6 +7,10 @@ import type { StandardSchemaV1 } from '@standard-schema/spec'
|
|
|
7
7
|
|
|
8
8
|
import type { WorkflowRun } from '../workflow.types.js'
|
|
9
9
|
import type { ScenarioActor } from '../../../services/scenario-actors-service.js'
|
|
10
|
+
import type {
|
|
11
|
+
ScenarioStepOptions,
|
|
12
|
+
ScenarioStepPhase,
|
|
13
|
+
} from '../scenario-step.types.js'
|
|
10
14
|
|
|
11
15
|
/**
|
|
12
16
|
* Workflow step options
|
|
@@ -79,6 +83,19 @@ export type WorkflowWireDoInline = <T>(
|
|
|
79
83
|
options?: WorkflowStepOptions
|
|
80
84
|
) => Promise<T>
|
|
81
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Type signature for scenario.step/given/when/then - used by inspector.
|
|
88
|
+
*
|
|
89
|
+
* Deliberately mirrors WorkflowWireDoRPC's shape: the target is a string, not
|
|
90
|
+
* an imported symbol, so the extractor reads it as a literal.
|
|
91
|
+
*/
|
|
92
|
+
export type ScenarioStepInvocation = <TOutput = any, TInput = any>(
|
|
93
|
+
stepName: string,
|
|
94
|
+
stepFunc: string,
|
|
95
|
+
data?: TInput,
|
|
96
|
+
options?: ScenarioStepOptions
|
|
97
|
+
) => Promise<TOutput>
|
|
98
|
+
|
|
82
99
|
/**
|
|
83
100
|
* Type signature for workflow.sleep() - used by inspector
|
|
84
101
|
*/
|
|
@@ -180,6 +197,34 @@ export interface RpcStepMeta {
|
|
|
180
197
|
expectEventually?: boolean
|
|
181
198
|
}
|
|
182
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Scenario step metadata — a call to `scenario.step/given/when/then`.
|
|
202
|
+
*
|
|
203
|
+
* Distinct from RpcStepMeta on purpose: a step runs locally through
|
|
204
|
+
* runPikkuFunc and must never be treated as dispatchable on the queue/replay
|
|
205
|
+
* path, nor registered as a callable RPC (a browser-driving step must not be
|
|
206
|
+
* network-invocable).
|
|
207
|
+
*/
|
|
208
|
+
export interface ScenarioStepMeta {
|
|
209
|
+
type: 'scenarioStep'
|
|
210
|
+
/** Cache key (first argument), ordinal-suffixed by the engine when repeated */
|
|
211
|
+
stepName: string
|
|
212
|
+
/** Registered name of the step function being run */
|
|
213
|
+
stepFunc: string
|
|
214
|
+
/** Which keyword the reporter prefixes — given/when/then, or none for `step` */
|
|
215
|
+
phase: ScenarioStepPhase
|
|
216
|
+
/** Output variable name (if assigned) */
|
|
217
|
+
outputVar?: string
|
|
218
|
+
/** Input source mappings, or 'passthrough' when entire data is passed */
|
|
219
|
+
inputs?: Record<string, InputSource> | 'passthrough'
|
|
220
|
+
/** Step options */
|
|
221
|
+
options?: WorkflowStepOptions
|
|
222
|
+
/** Scenario actor name this step runs as ({ actor: actors.x }) */
|
|
223
|
+
actor?: string
|
|
224
|
+
/** Mirrors the step function's `browser: true` declaration */
|
|
225
|
+
browser?: boolean
|
|
226
|
+
}
|
|
227
|
+
|
|
183
228
|
/**
|
|
184
229
|
* Simple condition expression (leaf node)
|
|
185
230
|
*/
|
|
@@ -223,7 +268,7 @@ export interface BranchStepMeta {
|
|
|
223
268
|
export interface ParallelGroupStepMeta {
|
|
224
269
|
type: 'parallel'
|
|
225
270
|
/** Child steps to execute in parallel */
|
|
226
|
-
children: RpcStepMeta
|
|
271
|
+
children: Array<RpcStepMeta | ScenarioStepMeta>
|
|
227
272
|
}
|
|
228
273
|
|
|
229
274
|
/**
|
|
@@ -244,7 +289,7 @@ export interface FanoutStepMeta {
|
|
|
244
289
|
/** Execution mode */
|
|
245
290
|
mode: 'parallel' | 'sequential'
|
|
246
291
|
/** Steps to execute inline per iteration, in order */
|
|
247
|
-
body: Array<RpcStepMeta | SleepStepMeta | SuspendStepMeta>
|
|
292
|
+
body: Array<RpcStepMeta | SleepStepMeta | SuspendStepMeta | ScenarioStepMeta>
|
|
248
293
|
/** Time between iterations (sequential mode only) */
|
|
249
294
|
timeBetween?: string
|
|
250
295
|
}
|
|
@@ -409,6 +454,7 @@ export interface ArrayPredicateStepMeta {
|
|
|
409
454
|
*/
|
|
410
455
|
export type WorkflowStepMeta =
|
|
411
456
|
| RpcStepMeta
|
|
457
|
+
| ScenarioStepMeta
|
|
412
458
|
| BranchStepMeta
|
|
413
459
|
| ParallelGroupStepMeta
|
|
414
460
|
| FanoutStepMeta
|
|
@@ -509,5 +555,43 @@ export interface PikkuScenarioWire extends PikkuWorkflowWire {
|
|
|
509
555
|
options?: WorkflowExpectServiceOptions
|
|
510
556
|
) => Promise<void>
|
|
511
557
|
|
|
558
|
+
/**
|
|
559
|
+
* Run a registered scenario step. Shaped exactly like `do`'s RPC form —
|
|
560
|
+
* `(stepName, target, data, options)` — so the inspector reads the target as
|
|
561
|
+
* a string literal rather than resolving an imported symbol.
|
|
562
|
+
*
|
|
563
|
+
* The generated `TypedScenario` narrows these over `FlattenedScenarioStepMap`.
|
|
564
|
+
*/
|
|
565
|
+
step(
|
|
566
|
+
stepName: string,
|
|
567
|
+
stepFunc: string,
|
|
568
|
+
data?: any,
|
|
569
|
+
options?: ScenarioStepOptions
|
|
570
|
+
): Promise<any>
|
|
571
|
+
|
|
572
|
+
/** `step` with a "Given" prefix in the rendered prose */
|
|
573
|
+
given(
|
|
574
|
+
stepName: string,
|
|
575
|
+
stepFunc: string,
|
|
576
|
+
data?: any,
|
|
577
|
+
options?: ScenarioStepOptions
|
|
578
|
+
): Promise<any>
|
|
579
|
+
|
|
580
|
+
/** `step` with a "When" prefix in the rendered prose */
|
|
581
|
+
when(
|
|
582
|
+
stepName: string,
|
|
583
|
+
stepFunc: string,
|
|
584
|
+
data?: any,
|
|
585
|
+
options?: ScenarioStepOptions
|
|
586
|
+
): Promise<any>
|
|
587
|
+
|
|
588
|
+
/** `step` with a "Then" prefix in the rendered prose */
|
|
589
|
+
then(
|
|
590
|
+
stepName: string,
|
|
591
|
+
stepFunc: string,
|
|
592
|
+
data?: any,
|
|
593
|
+
options?: ScenarioStepOptions
|
|
594
|
+
): Promise<any>
|
|
595
|
+
|
|
512
596
|
runScheduledTask: (name: string) => Promise<unknown>
|
|
513
597
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { resolveFeatureScenarios } from './feature.js'
|
|
5
|
+
import type { CoreFeature, CoreWorkflow } from './workflow.types.js'
|
|
6
|
+
|
|
7
|
+
const scenario = (tags: string[] = []) =>
|
|
8
|
+
({ tags, func: async () => ({}) }) as any
|
|
9
|
+
|
|
10
|
+
const registered = (entries: Array<[string, any]>): Map<string, CoreWorkflow> =>
|
|
11
|
+
new Map(entries.map(([name, func]) => [name, { name, func }]))
|
|
12
|
+
|
|
13
|
+
describe('resolveFeatureScenarios', () => {
|
|
14
|
+
test('resolves imported identifiers to the names they are registered under', () => {
|
|
15
|
+
const lazyLoad = scenario()
|
|
16
|
+
const roundTrip = scenario()
|
|
17
|
+
const features = new Map<string, CoreFeature>([
|
|
18
|
+
[
|
|
19
|
+
'credentialFeature',
|
|
20
|
+
{
|
|
21
|
+
name: 'Credential API',
|
|
22
|
+
scenarios: [lazyLoad, roundTrip],
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
const { entries, unresolved } = resolveFeatureScenarios(
|
|
28
|
+
features,
|
|
29
|
+
registered([
|
|
30
|
+
['credentialLazyLoadScenario', lazyLoad],
|
|
31
|
+
['credentialRoundTripScenario', roundTrip],
|
|
32
|
+
])
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
assert.deepEqual(unresolved, [])
|
|
36
|
+
assert.deepEqual(
|
|
37
|
+
entries.map((e) => e.scenarioName),
|
|
38
|
+
['credentialLazyLoadScenario', 'credentialRoundTripScenario']
|
|
39
|
+
)
|
|
40
|
+
assert.equal(entries[0]!.featureName, 'Credential API')
|
|
41
|
+
assert.equal(entries[0]!.featureId, 'credentialFeature')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('keeps declaration order, including repeats of the same scenario', () => {
|
|
45
|
+
const roundTrip = scenario()
|
|
46
|
+
const features = new Map<string, CoreFeature>([
|
|
47
|
+
[
|
|
48
|
+
'credentialFeature',
|
|
49
|
+
{
|
|
50
|
+
name: 'Credential API',
|
|
51
|
+
scenarios: ['stripe', 'google', 'hmac-key'].map((name) => ({
|
|
52
|
+
scenario: roundTrip,
|
|
53
|
+
data: { name },
|
|
54
|
+
})),
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
])
|
|
58
|
+
|
|
59
|
+
const { entries } = resolveFeatureScenarios(
|
|
60
|
+
features,
|
|
61
|
+
registered([['credentialRoundTripScenario', roundTrip]])
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
assert.equal(entries.length, 3, 'a mapped loop is three separate runs')
|
|
65
|
+
assert.deepEqual(
|
|
66
|
+
entries.map((e) => e.data),
|
|
67
|
+
[{ name: 'stripe' }, { name: 'google' }, { name: 'hmac-key' }]
|
|
68
|
+
)
|
|
69
|
+
assert.ok(
|
|
70
|
+
entries.every((e) => e.scenarioName === 'credentialRoundTripScenario')
|
|
71
|
+
)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('a bare reference carries no data', () => {
|
|
75
|
+
const lazyLoad = scenario()
|
|
76
|
+
const { entries } = resolveFeatureScenarios(
|
|
77
|
+
new Map([['f', { name: 'F', scenarios: [lazyLoad] } as CoreFeature]]),
|
|
78
|
+
registered([['lazyLoadScenario', lazyLoad]])
|
|
79
|
+
)
|
|
80
|
+
assert.equal(entries[0]!.data, undefined)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("a scenario's effective tags union its own with the feature's", () => {
|
|
84
|
+
const lazyLoad = scenario(['scenario', 'credential'])
|
|
85
|
+
const { entries } = resolveFeatureScenarios(
|
|
86
|
+
new Map([
|
|
87
|
+
[
|
|
88
|
+
'f',
|
|
89
|
+
{
|
|
90
|
+
name: 'F',
|
|
91
|
+
tags: ['nightly', 'credential'],
|
|
92
|
+
scenarios: [lazyLoad],
|
|
93
|
+
} as CoreFeature,
|
|
94
|
+
],
|
|
95
|
+
]),
|
|
96
|
+
registered([['lazyLoadScenario', lazyLoad]])
|
|
97
|
+
)
|
|
98
|
+
assert.deepEqual(entries[0]!.tags, ['scenario', 'credential', 'nightly'])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('an unregistered scenario is reported, never silently matched by shape', () => {
|
|
102
|
+
const registeredScenario = scenario()
|
|
103
|
+
const lookalike = scenario()
|
|
104
|
+
const { entries, unresolved } = resolveFeatureScenarios(
|
|
105
|
+
new Map([
|
|
106
|
+
[
|
|
107
|
+
'f',
|
|
108
|
+
{
|
|
109
|
+
name: 'F',
|
|
110
|
+
scenarios: [registeredScenario, lookalike],
|
|
111
|
+
} as CoreFeature,
|
|
112
|
+
],
|
|
113
|
+
]),
|
|
114
|
+
registered([['realScenario', registeredScenario]])
|
|
115
|
+
)
|
|
116
|
+
assert.deepEqual(
|
|
117
|
+
entries.map((e) => e.scenarioName),
|
|
118
|
+
['realScenario']
|
|
119
|
+
)
|
|
120
|
+
assert.deepEqual(unresolved, [{ featureId: 'f', index: 1 }])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('a feature with no scenarios contributes nothing', () => {
|
|
124
|
+
const { entries, unresolved } = resolveFeatureScenarios(
|
|
125
|
+
new Map([['f', { name: 'F', scenarios: [] } as CoreFeature]]),
|
|
126
|
+
registered([])
|
|
127
|
+
)
|
|
128
|
+
assert.deepEqual(entries, [])
|
|
129
|
+
assert.deepEqual(unresolved, [])
|
|
130
|
+
})
|
|
131
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { pikkuState } from '../../pikku-state.js'
|
|
2
|
+
import type {
|
|
3
|
+
CoreFeature,
|
|
4
|
+
CoreWorkflow,
|
|
5
|
+
FeaturePlanEntry,
|
|
6
|
+
} from './workflow.types.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Register a feature under the name it is exported as. Called from generated
|
|
10
|
+
* wiring, the same way `addWorkflow` is.
|
|
11
|
+
*/
|
|
12
|
+
export const addFeature = (
|
|
13
|
+
featureId: string,
|
|
14
|
+
feature: CoreFeature,
|
|
15
|
+
packageName: string | null = null
|
|
16
|
+
) => {
|
|
17
|
+
pikkuState(packageName, 'workflows', 'features').set(featureId, feature)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve every feature's scenario references back to the names their
|
|
22
|
+
* scenarios are registered under.
|
|
23
|
+
*
|
|
24
|
+
* Matching is by **object identity**: `pikkuScenario` returns its config
|
|
25
|
+
* verbatim and `addWorkflow` registers that same object, so a feature holding
|
|
26
|
+
* the imported identifier holds the very object that was registered. Nothing
|
|
27
|
+
* is matched by shape, by name, or by any other guess — which is also why a
|
|
28
|
+
* scenario built inline inside a feature (and therefore never registered)
|
|
29
|
+
* comes back as unresolved rather than silently running as something else.
|
|
30
|
+
*
|
|
31
|
+
* Entries are returned in declaration order, features in registration order.
|
|
32
|
+
* A scenario's effective tags are its own plus the containing feature's, so
|
|
33
|
+
* `--tags credential` selects through the feature.
|
|
34
|
+
*/
|
|
35
|
+
export const resolveFeatureScenarios = (
|
|
36
|
+
features: Map<string, CoreFeature>,
|
|
37
|
+
registrations: Map<string, CoreWorkflow>
|
|
38
|
+
): {
|
|
39
|
+
entries: FeaturePlanEntry[]
|
|
40
|
+
unresolved: Array<{ featureId: string; index: number }>
|
|
41
|
+
} => {
|
|
42
|
+
const nameByConfig = new Map<unknown, string>()
|
|
43
|
+
for (const [name, registration] of registrations) {
|
|
44
|
+
nameByConfig.set(registration.func, name)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const entries: FeaturePlanEntry[] = []
|
|
48
|
+
const unresolved: Array<{ featureId: string; index: number }> = []
|
|
49
|
+
|
|
50
|
+
for (const [featureId, feature] of features) {
|
|
51
|
+
const scenarios = feature.scenarios ?? []
|
|
52
|
+
for (let index = 0; index < scenarios.length; index++) {
|
|
53
|
+
const entry = scenarios[index]!
|
|
54
|
+
const paired =
|
|
55
|
+
typeof entry === 'object' && entry !== null && 'scenario' in entry
|
|
56
|
+
const config = paired ? (entry as any).scenario : entry
|
|
57
|
+
const scenarioName = nameByConfig.get(config)
|
|
58
|
+
if (!scenarioName) {
|
|
59
|
+
unresolved.push({ featureId, index })
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
entries.push({
|
|
63
|
+
featureId,
|
|
64
|
+
featureName: feature.name ?? featureId,
|
|
65
|
+
scenarioName,
|
|
66
|
+
data: paired ? (entry as any).data : undefined,
|
|
67
|
+
tags: [
|
|
68
|
+
...new Set([
|
|
69
|
+
...((registrations.get(scenarioName)?.func as any)?.tags ?? []),
|
|
70
|
+
...(feature.tags ?? []),
|
|
71
|
+
]),
|
|
72
|
+
],
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { entries, unresolved }
|
|
78
|
+
}
|
|
@@ -11,6 +11,11 @@ export {
|
|
|
11
11
|
WorkflowApprovalResolvedError,
|
|
12
12
|
DEFAULT_STEP_RETRIES,
|
|
13
13
|
} from './pikku-workflow-service.js'
|
|
14
|
+
export type {
|
|
15
|
+
RunLifecycleContext,
|
|
16
|
+
WorkflowRunEngine,
|
|
17
|
+
WorkflowRunExtension,
|
|
18
|
+
} from './pikku-workflow-service.js'
|
|
14
19
|
export { deriveInvocationId, uuidv5 } from './workflow-invocation-id.js'
|
|
15
20
|
|
|
16
21
|
// Time-travel: reconstruct run state at any point from durable history
|
|
@@ -29,6 +34,7 @@ export type {
|
|
|
29
34
|
|
|
30
35
|
// Internal registration functions (used by generated code)
|
|
31
36
|
export { addWorkflow } from './dsl/workflow-runner.js'
|
|
37
|
+
export { addFeature, resolveFeatureScenarios } from './feature.js'
|
|
32
38
|
|
|
33
39
|
// Graph helpers (template, pikkuWorkflowGraph)
|
|
34
40
|
export { template, type TemplateString } from './graph/template.js'
|
|
@@ -59,6 +65,7 @@ export type {
|
|
|
59
65
|
// Re-export all types from workflow.types
|
|
60
66
|
export type {
|
|
61
67
|
WorkflowService,
|
|
68
|
+
WorkflowQueueOptions,
|
|
62
69
|
WorkflowServiceConfig,
|
|
63
70
|
WorkflowPlannedStep,
|
|
64
71
|
WorkflowRunWire,
|
|
@@ -71,6 +78,12 @@ export type {
|
|
|
71
78
|
WorkflowRunService,
|
|
72
79
|
WorkflowRunMirror,
|
|
73
80
|
CoreWorkflow,
|
|
81
|
+
CoreFeature,
|
|
82
|
+
CoreFeatureScenario,
|
|
83
|
+
FeatureMeta,
|
|
84
|
+
FeatureMetaEntry,
|
|
85
|
+
FeaturesMeta,
|
|
86
|
+
FeaturePlanEntry,
|
|
74
87
|
PikkuWorkflow,
|
|
75
88
|
ContextVariable,
|
|
76
89
|
WorkflowContext,
|
|
@@ -112,8 +125,69 @@ export type {
|
|
|
112
125
|
SwitchStepMeta,
|
|
113
126
|
FilterStepMeta,
|
|
114
127
|
ArrayPredicateStepMeta,
|
|
128
|
+
ScenarioStepInvocation,
|
|
129
|
+
ScenarioStepMeta,
|
|
115
130
|
WorkflowStepMeta,
|
|
116
131
|
WorkflowStepWire,
|
|
117
132
|
PikkuWorkflowWire,
|
|
118
133
|
PikkuScenarioWire,
|
|
119
134
|
} from './workflow.types.js'
|
|
135
|
+
|
|
136
|
+
/* ------------------------------------------------------------------ *
|
|
137
|
+
* Scenarios — writing a step
|
|
138
|
+
*
|
|
139
|
+
* What a step file imports. Everything here is reached from inside a
|
|
140
|
+
* `pikkuScenarioStep` body.
|
|
141
|
+
* ------------------------------------------------------------------ */
|
|
142
|
+
|
|
143
|
+
// The step's own wire, and the shape of what it was given
|
|
144
|
+
export type {
|
|
145
|
+
ScenarioStepPhase,
|
|
146
|
+
ScenarioStepOptions,
|
|
147
|
+
PikkuScenarioStepWire,
|
|
148
|
+
ScenarioEnvironment,
|
|
149
|
+
} from './scenario-step.types.js'
|
|
150
|
+
|
|
151
|
+
// Narrows the optional halves of the step wire, with a message that says what to do
|
|
152
|
+
export { requireActor, requireScenarioEnv } from './scenario-step-guards.js'
|
|
153
|
+
|
|
154
|
+
// Waits for the target to catch up, without every step writing the loop again
|
|
155
|
+
export { pollUntil, type PollOptions } from './scenario-poll.js'
|
|
156
|
+
|
|
157
|
+
// Persists cookies across a step's requests, the way a browser would
|
|
158
|
+
export { createCookieJar } from './scenario-cookie-jar.js'
|
|
159
|
+
export type { ScenarioCookieJar } from './scenario-cookie-jar.js'
|
|
160
|
+
|
|
161
|
+
// What the transport answered — an actor's `invokeRaw`, or a route reached
|
|
162
|
+
// directly — and the one way to POST JSON at a route and keep its status
|
|
163
|
+
export type {
|
|
164
|
+
ScenarioHttpResponse,
|
|
165
|
+
ScenarioJsonRequest,
|
|
166
|
+
} from '../../services/scenario-actors-service.js'
|
|
167
|
+
export {
|
|
168
|
+
readScenarioHttpResponse,
|
|
169
|
+
postScenarioJson,
|
|
170
|
+
} from '../../services/scenario-actors-service.js'
|
|
171
|
+
|
|
172
|
+
/* ------------------------------------------------------------------ *
|
|
173
|
+
* Scenarios — driving a browser
|
|
174
|
+
*
|
|
175
|
+
* How a step names an element, and what a driver package
|
|
176
|
+
* (`@pikku/playwright`, or another) implements.
|
|
177
|
+
* ------------------------------------------------------------------ */
|
|
178
|
+
|
|
179
|
+
export type {
|
|
180
|
+
PikkuBrowserWire,
|
|
181
|
+
TestIdSelector,
|
|
182
|
+
ScenarioBrowserProvider,
|
|
183
|
+
ScenarioBrowserFailure,
|
|
184
|
+
} from './scenario-step.types.js'
|
|
185
|
+
|
|
186
|
+
/* ------------------------------------------------------------------ *
|
|
187
|
+
* Scenarios — reporting a run
|
|
188
|
+
*
|
|
189
|
+
* Used by the CLI reporter and the console, so the two render the same
|
|
190
|
+
* sentence for the same step. A step body needs none of it.
|
|
191
|
+
* ------------------------------------------------------------------ */
|
|
192
|
+
|
|
193
|
+
export { composeStepProse, renderStepTemplate } from './scenario-prose.js'
|