@pikku/core 0.12.69 → 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 +334 -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/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/cli/command-parser.js +11 -1
- 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 +103 -10
- package/dist/wirings/workflow/pikku-workflow-service.js +80 -135
- 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 +81 -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/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/cli/command-parser.test.ts +60 -0
- package/src/wirings/cli/command-parser.ts +12 -1
- 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 +73 -0
- package/src/wirings/workflow/pikku-scenario-service.ts +682 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +55 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +196 -208
- 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 +98 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -3,7 +3,6 @@ import { pikkuWorkflowWorkerFunc, pikkuWorkflowOrchestratorFunc, pikkuWorkflowSl
|
|
|
3
3
|
import { wireQueueWorker } from '../queue/queue-runner.js';
|
|
4
4
|
import { getSingletonServices, getCreateWireServices, pikkuState, } from '../../pikku-state.js';
|
|
5
5
|
import { getDurationInMilliseconds } from '../../time-utils.js';
|
|
6
|
-
import { createHttpScenarioActors } from '../../services/http-scenario-actors.js';
|
|
7
6
|
const resolveWorkflowMeta = (name) => {
|
|
8
7
|
const rootMeta = pikkuState(null, 'workflows', 'meta');
|
|
9
8
|
if (rootMeta[name]) {
|
|
@@ -29,7 +28,6 @@ const resolveWorkflowMeta = (name) => {
|
|
|
29
28
|
return null;
|
|
30
29
|
};
|
|
31
30
|
const toKebab = (s) => s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
32
|
-
import { runScheduledTask } from '../scheduler/scheduler-runner.js';
|
|
33
31
|
import { continueGraph, executeGraphStep, runWorkflowGraph, runFromMeta, } from './graph/graph-runner.js';
|
|
34
32
|
import { PikkuError, addError, isExpectedError, } from '../../errors/error-handler.js';
|
|
35
33
|
import { RPCNotFoundError } from '../rpc/rpc-runner.js';
|
|
@@ -179,9 +177,7 @@ const WORKFLOW_END_STATES = new Set([
|
|
|
179
177
|
*/
|
|
180
178
|
export class PikkuWorkflowService {
|
|
181
179
|
inlineRuns = new Set();
|
|
182
|
-
|
|
183
|
-
// process-local by nature, so they ride this map, never the persisted wire.
|
|
184
|
-
runActors = new Map();
|
|
180
|
+
runExtension;
|
|
185
181
|
get logger() {
|
|
186
182
|
return getSingletonServices()?.logger;
|
|
187
183
|
}
|
|
@@ -420,7 +416,10 @@ export class PikkuWorkflowService {
|
|
|
420
416
|
* Creates pending step in both workflow_step and workflow_step_history
|
|
421
417
|
* @param runId - Run ID
|
|
422
418
|
* @param stepName - Step cache key
|
|
423
|
-
* @param rpcName - RPC
|
|
419
|
+
* @param rpcName - The name this step was dispatched by: an RPC for a
|
|
420
|
+
* `workflow.do` step, a step function for a scenario step, null for a
|
|
421
|
+
* closure. Nothing dispatches off this value — it is recorded so a reader
|
|
422
|
+
* can join a step back to the function that ran it.
|
|
424
423
|
* @param data - Step input data
|
|
425
424
|
* @param stepOptions - Step options (retries, retryDelay)
|
|
426
425
|
* @returns Step state with generated stepId
|
|
@@ -669,40 +668,34 @@ export class PikkuWorkflowService {
|
|
|
669
668
|
await getSingletonServices().schedulerService.scheduleRPC(duration, this.getConfig().sleeperRPCName, { runId, stepId });
|
|
670
669
|
return true;
|
|
671
670
|
}
|
|
672
|
-
/**
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
'/api/auth/sign-in/actor';
|
|
692
|
-
const rpcPath = (await variables.get('SCENARIO_RPC_PATH')) ?? '/rpc';
|
|
693
|
-
return createHttpScenarioActors({
|
|
694
|
-
apiUrl,
|
|
695
|
-
secret,
|
|
696
|
-
actors: actorsConfig,
|
|
697
|
-
signInPath,
|
|
698
|
-
rpcPath,
|
|
699
|
-
});
|
|
671
|
+
/**
|
|
672
|
+
* Install the one extension a run may have, built from a handle onto the run
|
|
673
|
+
* engine so that `inlineStep` and friends stay protected rather than becoming
|
|
674
|
+
* public API. Returns the extension, so the caller keeps a typed reference to
|
|
675
|
+
* whatever it just built.
|
|
676
|
+
*/
|
|
677
|
+
setRunExtension(create) {
|
|
678
|
+
const engine = {
|
|
679
|
+
inlineStep: this.inlineStep.bind(this),
|
|
680
|
+
updateRunStatus: this.updateRunStatus.bind(this),
|
|
681
|
+
onChildWorkflowFailed: this.onChildWorkflowFailed.bind(this),
|
|
682
|
+
verifyStepName: this.verifyStepName.bind(this),
|
|
683
|
+
};
|
|
684
|
+
const extension = create(engine);
|
|
685
|
+
this.runExtension = extension;
|
|
686
|
+
return extension;
|
|
687
|
+
}
|
|
688
|
+
getRunExtension() {
|
|
689
|
+
return this.runExtension;
|
|
700
690
|
}
|
|
701
691
|
/**
|
|
702
692
|
* Start a new workflow run
|
|
703
693
|
* Automatically detects workflow type (DSL or graph) from meta and executes accordingly
|
|
704
694
|
* @param options.inline - If true, execute workflow directly without queue service
|
|
705
695
|
* @param options.startNode - Starting node ID for graph workflows (from wire config)
|
|
696
|
+
* @param options.onRunCreated - Called with the run id the moment the run exists.
|
|
697
|
+
* An inline run that fails throws instead of returning, so this is the only
|
|
698
|
+
* way a caller can still read that run back — its steps, and which one failed.
|
|
706
699
|
*/
|
|
707
700
|
async startWorkflow(name, input, wire, rpcService, options) {
|
|
708
701
|
// Resolve workflow from static meta (root or addon namespace), then dynamic DB
|
|
@@ -738,13 +731,8 @@ export class PikkuWorkflowService {
|
|
|
738
731
|
deterministic: workflowMeta.deterministic,
|
|
739
732
|
plannedSteps: workflowMeta.plannedSteps,
|
|
740
733
|
});
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
? await this.resolveScenarioActors()
|
|
744
|
-
: undefined);
|
|
745
|
-
if (actors) {
|
|
746
|
-
this.runActors.set(runId, actors);
|
|
747
|
-
}
|
|
734
|
+
options?.onRunCreated?.(runId);
|
|
735
|
+
await this.runExtension?.attachRunContext(runId, workflowMeta, options);
|
|
748
736
|
if (shouldInline) {
|
|
749
737
|
this.inlineRuns.add(runId);
|
|
750
738
|
try {
|
|
@@ -777,7 +765,7 @@ export class PikkuWorkflowService {
|
|
|
777
765
|
}
|
|
778
766
|
finally {
|
|
779
767
|
this.inlineRuns.delete(runId);
|
|
780
|
-
this.
|
|
768
|
+
this.runExtension?.detachRunContext(runId);
|
|
781
769
|
}
|
|
782
770
|
}
|
|
783
771
|
else {
|
|
@@ -902,14 +890,30 @@ export class PikkuWorkflowService {
|
|
|
902
890
|
workflowWire.pikkuUserId = run.wire?.pikkuUserId;
|
|
903
891
|
const wire = {
|
|
904
892
|
workflow: workflowWire,
|
|
905
|
-
scenario: workflowMeta?.source === 'scenario' ? workflowWire : undefined,
|
|
906
893
|
pikkuUserId: run.wire?.pikkuUserId,
|
|
907
894
|
session: rpcService?.wire?.session,
|
|
908
895
|
rpc: rpcService?.wire?.rpc,
|
|
909
|
-
// User-flow actors registered for this run (see startWorkflow options)
|
|
910
|
-
actors: this.runActors.get(runId),
|
|
911
896
|
};
|
|
897
|
+
this.runExtension?.decorateRunWire(wire, {
|
|
898
|
+
runId,
|
|
899
|
+
workflowMeta,
|
|
900
|
+
workflowWire,
|
|
901
|
+
});
|
|
902
|
+
const lifecycle = {
|
|
903
|
+
runId,
|
|
904
|
+
run,
|
|
905
|
+
workflowMeta,
|
|
906
|
+
workflow,
|
|
907
|
+
wire,
|
|
908
|
+
packageName: pkgName,
|
|
909
|
+
};
|
|
910
|
+
// `interrupted` means the run has not reached a terminal state — it is
|
|
911
|
+
// suspended or waiting — so teardown would run while the run is still
|
|
912
|
+
// mid-flight.
|
|
913
|
+
let outcome = 'completed';
|
|
914
|
+
let failure;
|
|
912
915
|
try {
|
|
916
|
+
await this.runExtension?.onBeforeRunFunc(lifecycle);
|
|
913
917
|
const result = await runPikkuFunc('workflow', workflowMeta.name, workflowMeta.pikkuFuncId, {
|
|
914
918
|
singletonServices: getSingletonServices(),
|
|
915
919
|
wire,
|
|
@@ -921,10 +925,13 @@ export class PikkuWorkflowService {
|
|
|
921
925
|
await this.onChildWorkflowCompleted(run, result);
|
|
922
926
|
}
|
|
923
927
|
catch (error) {
|
|
928
|
+
failure = error;
|
|
924
929
|
if (error instanceof WorkflowAsyncException) {
|
|
930
|
+
outcome = 'interrupted';
|
|
925
931
|
throw error;
|
|
926
932
|
}
|
|
927
933
|
if (error instanceof WorkflowCancelledException) {
|
|
934
|
+
outcome = 'failed';
|
|
928
935
|
await this.updateRunStatus(runId, 'cancelled', undefined, {
|
|
929
936
|
message: error.message || 'Workflow cancelled',
|
|
930
937
|
stack: '',
|
|
@@ -934,6 +941,7 @@ export class PikkuWorkflowService {
|
|
|
934
941
|
throw error;
|
|
935
942
|
}
|
|
936
943
|
if (error instanceof WorkflowSuspendedException) {
|
|
944
|
+
outcome = 'interrupted';
|
|
937
945
|
await this.updateRunStatus(runId, 'suspended', undefined, {
|
|
938
946
|
message: error.message || 'Workflow suspended',
|
|
939
947
|
stack: '',
|
|
@@ -941,6 +949,7 @@ export class PikkuWorkflowService {
|
|
|
941
949
|
});
|
|
942
950
|
throw error;
|
|
943
951
|
}
|
|
952
|
+
outcome = 'failed';
|
|
944
953
|
await this.updateRunStatus(runId, 'failed', undefined, {
|
|
945
954
|
message: error.message,
|
|
946
955
|
stack: error.stack,
|
|
@@ -949,6 +958,9 @@ export class PikkuWorkflowService {
|
|
|
949
958
|
await this.onChildWorkflowFailed(run, error);
|
|
950
959
|
throw error;
|
|
951
960
|
}
|
|
961
|
+
finally {
|
|
962
|
+
await this.runExtension?.onAfterRunFunc(lifecycle, outcome, failure);
|
|
963
|
+
}
|
|
952
964
|
});
|
|
953
965
|
}
|
|
954
966
|
async onChildWorkflowCompleted(childRun, result) {
|
|
@@ -1328,7 +1340,21 @@ export class PikkuWorkflowService {
|
|
|
1328
1340
|
}
|
|
1329
1341
|
});
|
|
1330
1342
|
}
|
|
1331
|
-
async inlineStep(runId, logicalStepName, fn, stepOptions
|
|
1343
|
+
async inlineStep(runId, logicalStepName, fn, stepOptions,
|
|
1344
|
+
/**
|
|
1345
|
+
* The input this step was called with, recorded on the run so a reporter can
|
|
1346
|
+
* name the values under test. A closure step has none; a scenario step does.
|
|
1347
|
+
*/
|
|
1348
|
+
data = null,
|
|
1349
|
+
/**
|
|
1350
|
+
* The name this step was dispatched by, for the kinds of inline step that
|
|
1351
|
+
* have one. A closure step has no name; a scenario step is a step RPC, so
|
|
1352
|
+
* it records the step function that ran — which is the only way to join a
|
|
1353
|
+
* step back to its declaration when its durable name was built at runtime
|
|
1354
|
+
* (a step called in a loop reaches the run as `sees @pikku/addon-todos`,
|
|
1355
|
+
* declared as `sees ${packageName}`).
|
|
1356
|
+
*/
|
|
1357
|
+
rpcName = null) {
|
|
1332
1358
|
const fromStepName = this.lastStepName(runId);
|
|
1333
1359
|
const stepName = this.nextStepKey(runId, logicalStepName);
|
|
1334
1360
|
// Check if step already exists
|
|
@@ -1337,8 +1363,8 @@ export class PikkuWorkflowService {
|
|
|
1337
1363
|
stepState = await this.getStepState(runId, stepName);
|
|
1338
1364
|
}
|
|
1339
1365
|
catch {
|
|
1340
|
-
// Step doesn't exist - create it (inline,
|
|
1341
|
-
stepState = await this.insertStepState(runId, stepName,
|
|
1366
|
+
// Step doesn't exist - create it (inline, so never dispatched)
|
|
1367
|
+
stepState = await this.insertStepState(runId, stepName, rpcName, data, stepOptions, fromStepName);
|
|
1342
1368
|
}
|
|
1343
1369
|
if (stepState.status === 'succeeded') {
|
|
1344
1370
|
// Return cached result
|
|
@@ -1635,90 +1661,6 @@ export class PikkuWorkflowService {
|
|
|
1635
1661
|
return await this.inlineStep(runId, stepName, rpcNameOrFn, dataOrOptions);
|
|
1636
1662
|
}
|
|
1637
1663
|
},
|
|
1638
|
-
// Durable polling step: invoke an RPC (as an actor when options.as is
|
|
1639
|
-
// set) until the predicate passes or `within` elapses. The whole poll is
|
|
1640
|
-
// ONE recorded step, so replay returns the cached outcome.
|
|
1641
|
-
expectEventually: async (stepName, rpcName, data, predicate, options) => {
|
|
1642
|
-
this.verifyStepName(stepName);
|
|
1643
|
-
const resolvedRpcName = addonNamespace && !rpcName.includes(':')
|
|
1644
|
-
? `${addonNamespace}:${rpcName}`
|
|
1645
|
-
: rpcName;
|
|
1646
|
-
const within = getDurationInMilliseconds(options?.within ?? '30s');
|
|
1647
|
-
const interval = getDurationInMilliseconds(options?.interval ?? '1s');
|
|
1648
|
-
return await this.inlineStep(runId, stepName, async () => {
|
|
1649
|
-
const deadline = Date.now() + within;
|
|
1650
|
-
let last;
|
|
1651
|
-
while (true) {
|
|
1652
|
-
last = options?.actor
|
|
1653
|
-
? await options.actor.invoke(resolvedRpcName, data)
|
|
1654
|
-
: await rpcService.rpcWithWire(resolvedRpcName, data, {});
|
|
1655
|
-
if (predicate(last))
|
|
1656
|
-
return last;
|
|
1657
|
-
if (Date.now() + interval > deadline) {
|
|
1658
|
-
throw new Error(`[workflow] expectEventually '${stepName}' ('${resolvedRpcName}'` +
|
|
1659
|
-
`${options?.actor ? ` as '${options.actor.name}'` : ''}) did not pass within ${within}ms; ` +
|
|
1660
|
-
`last result: ${JSON.stringify(last)?.slice(0, 300)}`);
|
|
1661
|
-
}
|
|
1662
|
-
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
1663
|
-
}
|
|
1664
|
-
}, options);
|
|
1665
|
-
},
|
|
1666
|
-
expectError: async (stepName, rpcName, data, options) => {
|
|
1667
|
-
this.verifyStepName(stepName);
|
|
1668
|
-
const resolvedRpcName = addonNamespace && !rpcName.includes(':')
|
|
1669
|
-
? `${addonNamespace}:${rpcName}`
|
|
1670
|
-
: rpcName;
|
|
1671
|
-
return await this.inlineStep(runId, stepName, async () => {
|
|
1672
|
-
let result;
|
|
1673
|
-
try {
|
|
1674
|
-
result = options?.actor
|
|
1675
|
-
? await options.actor.invoke(resolvedRpcName, data)
|
|
1676
|
-
: await rpcService.rpcWithWire(resolvedRpcName, data, {});
|
|
1677
|
-
}
|
|
1678
|
-
catch (e) {
|
|
1679
|
-
const message = e?.message ?? String(e);
|
|
1680
|
-
if (options?.matches) {
|
|
1681
|
-
const matched = typeof options.matches === 'string'
|
|
1682
|
-
? message.includes(options.matches)
|
|
1683
|
-
: options.matches.test(message);
|
|
1684
|
-
if (!matched) {
|
|
1685
|
-
throw new Error(`[workflow] expectError '${stepName}' ('${resolvedRpcName}') threw, but the message did not match ${options.matches}: ${message}`);
|
|
1686
|
-
}
|
|
1687
|
-
}
|
|
1688
|
-
return message;
|
|
1689
|
-
}
|
|
1690
|
-
throw new Error(`[workflow] expectError '${stepName}' ('${resolvedRpcName}') expected an error but the call succeeded: ${JSON.stringify(result)?.slice(0, 300)}`);
|
|
1691
|
-
}, options);
|
|
1692
|
-
},
|
|
1693
|
-
expectService: async (stepName, serviceMethod, options) => {
|
|
1694
|
-
this.verifyStepName(stepName);
|
|
1695
|
-
const [service, method] = serviceMethod.split('.');
|
|
1696
|
-
if (!service || !method) {
|
|
1697
|
-
throw new Error(`[workflow] expectService '${stepName}' needs 'service.method', got '${serviceMethod}'`);
|
|
1698
|
-
}
|
|
1699
|
-
await this.inlineStep(runId, stepName, async () => {
|
|
1700
|
-
const rpcName = 'pikkuScenarioGetStubCalls';
|
|
1701
|
-
const calls = options?.actor
|
|
1702
|
-
? await options.actor.invoke(rpcName, { service })
|
|
1703
|
-
: await rpcService.rpcWithWire(rpcName, { service }, {});
|
|
1704
|
-
const matching = (calls ?? []).filter((c) => c.service === service &&
|
|
1705
|
-
c.method === method &&
|
|
1706
|
-
(options?.calledWith === undefined ||
|
|
1707
|
-
JSON.stringify(c.args?.[0]) ===
|
|
1708
|
-
JSON.stringify(options.calledWith)));
|
|
1709
|
-
const expected = options?.times;
|
|
1710
|
-
const ok = expected === undefined
|
|
1711
|
-
? matching.length > 0
|
|
1712
|
-
: matching.length === expected;
|
|
1713
|
-
if (!ok) {
|
|
1714
|
-
const seen = (calls ?? [])
|
|
1715
|
-
.map((c) => `${c.service}.${c.method}(${JSON.stringify(c.args?.[0])?.slice(0, 120) ?? ''})`)
|
|
1716
|
-
.join('\n ') || '(none)';
|
|
1717
|
-
throw new Error(`[workflow] expectService '${stepName}' expected ${expected ?? 'at least one'} call(s) to '${serviceMethod}'` +
|
|
1718
|
-
`${options?.calledWith !== undefined ? ` with ${JSON.stringify(options.calledWith)}` : ''}, found ${matching.length}. Recorded:\n ${seen}`);
|
|
1719
|
-
}
|
|
1720
|
-
}, options);
|
|
1721
|
-
},
|
|
1722
1664
|
// Implement workflow.sleep()
|
|
1723
1665
|
sleep: async (stepName, duration) => {
|
|
1724
1666
|
this.verifyStepName(stepName);
|
|
@@ -1732,10 +1674,13 @@ export class PikkuWorkflowService {
|
|
|
1732
1674
|
this.verifyStepName(reason);
|
|
1733
1675
|
return await this.approvalStep(runId, reason, options);
|
|
1734
1676
|
}),
|
|
1735
|
-
runScheduledTask: async (taskName) => {
|
|
1736
|
-
await runScheduledTask({ name: taskName });
|
|
1737
|
-
},
|
|
1738
1677
|
};
|
|
1678
|
+
this.runExtension?.decorateWorkflowWire(workflowWire, {
|
|
1679
|
+
name,
|
|
1680
|
+
runId,
|
|
1681
|
+
rpcService,
|
|
1682
|
+
addonNamespace,
|
|
1683
|
+
});
|
|
1739
1684
|
return workflowWire;
|
|
1740
1685
|
}
|
|
1741
1686
|
verifyStepName(stepName) {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** A session held the way a browser holds one, for code driving a real target. */
|
|
2
|
+
export interface ScenarioCookieJar {
|
|
3
|
+
/** `fetch`, but it sends what the target has set and keeps what it sets. */
|
|
4
|
+
fetch: typeof fetch;
|
|
5
|
+
/** Forgets the session — what an actor does before signing in again. */
|
|
6
|
+
clear(): void;
|
|
7
|
+
/**
|
|
8
|
+
* Whether the target has set anything yet. This is a fact about the jar, not
|
|
9
|
+
* about the session: a target that sets a CSRF or locale cookie before anyone
|
|
10
|
+
* signs in fills the jar without establishing one. Whoever needs to know
|
|
11
|
+
* whether a sign-in happened has to track the sign-in.
|
|
12
|
+
*/
|
|
13
|
+
readonly empty: boolean;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A `fetch` that remembers cookies.
|
|
17
|
+
*
|
|
18
|
+
* A browser persists the session cookie on its own; anything driving a target
|
|
19
|
+
* from this process has to. Every response is read, not just the sign-in, so a
|
|
20
|
+
* cookie the target rotates mid-session is followed rather than dropped.
|
|
21
|
+
*
|
|
22
|
+
* The jar is a closure local, so two jars never share a session — which is what
|
|
23
|
+
* lets one scenario sign in as several people without one of them inheriting
|
|
24
|
+
* the other's session.
|
|
25
|
+
*
|
|
26
|
+
* It also stamps `Origin`, because Better Auth rejects a state-changing POST
|
|
27
|
+
* whose Origin does not match its baseURL.
|
|
28
|
+
*/
|
|
29
|
+
export declare const createCookieJar: (apiUrl: string) => ScenarioCookieJar;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `fetch` that remembers cookies.
|
|
3
|
+
*
|
|
4
|
+
* A browser persists the session cookie on its own; anything driving a target
|
|
5
|
+
* from this process has to. Every response is read, not just the sign-in, so a
|
|
6
|
+
* cookie the target rotates mid-session is followed rather than dropped.
|
|
7
|
+
*
|
|
8
|
+
* The jar is a closure local, so two jars never share a session — which is what
|
|
9
|
+
* lets one scenario sign in as several people without one of them inheriting
|
|
10
|
+
* the other's session.
|
|
11
|
+
*
|
|
12
|
+
* It also stamps `Origin`, because Better Auth rejects a state-changing POST
|
|
13
|
+
* whose Origin does not match its baseURL.
|
|
14
|
+
*/
|
|
15
|
+
export const createCookieJar = (apiUrl) => {
|
|
16
|
+
const jar = new Map();
|
|
17
|
+
const origin = new URL(apiUrl).origin;
|
|
18
|
+
return {
|
|
19
|
+
fetch: async (input, init) => {
|
|
20
|
+
const headers = new Headers(init?.headers);
|
|
21
|
+
headers.set('origin', origin);
|
|
22
|
+
const held = [...jar].map(([name, value]) => `${name}=${value}`);
|
|
23
|
+
const caller = headers.get('cookie');
|
|
24
|
+
if (held.length > 0 || caller) {
|
|
25
|
+
headers.set('cookie', [caller, ...held].filter(Boolean).join('; '));
|
|
26
|
+
}
|
|
27
|
+
const response = await fetch(input, { ...init, headers });
|
|
28
|
+
for (const raw of response.headers.getSetCookie()) {
|
|
29
|
+
const [pair] = raw.split(';');
|
|
30
|
+
const separator = pair.indexOf('=');
|
|
31
|
+
if (separator > 0) {
|
|
32
|
+
const name = pair.slice(0, separator);
|
|
33
|
+
const value = pair.slice(separator + 1);
|
|
34
|
+
// An empty value is how a target deletes a cookie — drop the name
|
|
35
|
+
// rather than holding a cookie whose value says it is gone.
|
|
36
|
+
if (value) {
|
|
37
|
+
jar.set(name, value);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
jar.delete(name);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return response;
|
|
45
|
+
},
|
|
46
|
+
clear: () => jar.clear(),
|
|
47
|
+
get empty() {
|
|
48
|
+
return jar.size === 0;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** How long to keep trying, and how long to wait between tries. */
|
|
2
|
+
export interface PollOptions {
|
|
3
|
+
/** Total time to keep attempting before giving up. Default 15s. */
|
|
4
|
+
timeoutMs?: number;
|
|
5
|
+
/** Gap between attempts. Default 250ms. */
|
|
6
|
+
intervalMs?: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Attempt something until it answers, or the deadline passes.
|
|
10
|
+
*
|
|
11
|
+
* `undefined` means "not yet" and nothing else — `false`, `0` and `''` are all
|
|
12
|
+
* answers, because a probe asking whether something happened reports `false`
|
|
13
|
+
* when it did not. Answering `undefined` at the deadline rather than throwing
|
|
14
|
+
* leaves the error to the caller, who is the only one who knows what was being
|
|
15
|
+
* waited for.
|
|
16
|
+
*
|
|
17
|
+
* A step that polls its target — a delivery reaching a terminal status, a run
|
|
18
|
+
* finishing — reaches for this rather than writing the deadline loop again.
|
|
19
|
+
*/
|
|
20
|
+
export declare const pollUntil: <T>(attempt: () => Promise<T | undefined> | T | undefined, { timeoutMs, intervalMs }?: PollOptions) => Promise<T | undefined>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attempt something until it answers, or the deadline passes.
|
|
3
|
+
*
|
|
4
|
+
* `undefined` means "not yet" and nothing else — `false`, `0` and `''` are all
|
|
5
|
+
* answers, because a probe asking whether something happened reports `false`
|
|
6
|
+
* when it did not. Answering `undefined` at the deadline rather than throwing
|
|
7
|
+
* leaves the error to the caller, who is the only one who knows what was being
|
|
8
|
+
* waited for.
|
|
9
|
+
*
|
|
10
|
+
* A step that polls its target — a delivery reaching a terminal status, a run
|
|
11
|
+
* finishing — reaches for this rather than writing the deadline loop again.
|
|
12
|
+
*/
|
|
13
|
+
export const pollUntil = async (attempt, { timeoutMs = 15_000, intervalMs = 250 } = {}) => {
|
|
14
|
+
const deadline = Date.now() + timeoutMs;
|
|
15
|
+
for (;;) {
|
|
16
|
+
const answer = await attempt();
|
|
17
|
+
if (answer !== undefined) {
|
|
18
|
+
return answer;
|
|
19
|
+
}
|
|
20
|
+
if (Date.now() >= deadline) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
await new Promise((done) => setTimeout(done, intervalMs));
|
|
24
|
+
}
|
|
25
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ScenarioStepPhase } from './scenario-step.types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Renders the English sentence a reporter shows for a scenario step.
|
|
4
|
+
*
|
|
5
|
+
* This is the inversion of cucumber: rather than parsing English into a call,
|
|
6
|
+
* we render English out of a typed call, so the readable report survives
|
|
7
|
+
* without a regex registry paying for it.
|
|
8
|
+
*
|
|
9
|
+
* Lives in core so the CLI reporter and the console render identically.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Fill a step's `template` from the input that step was actually called with,
|
|
13
|
+
* so the reported sentence names the values under test — "sees @pikku/addon-todos"
|
|
14
|
+
* rather than "sees an addon in the gallery" repeated three times.
|
|
15
|
+
*
|
|
16
|
+
* A placeholder with no recorded value renders as nothing and the surrounding
|
|
17
|
+
* whitespace collapses, so an optional input that was omitted reads as a shorter
|
|
18
|
+
* sentence rather than a literal `{state}` leaking into the report.
|
|
19
|
+
*/
|
|
20
|
+
export declare const renderStepTemplate: (template: string, input: unknown) => string;
|
|
21
|
+
export declare const composeStepProse: ({ phase, description, template, input, actor, keywordWidth, }: {
|
|
22
|
+
phase: ScenarioStepPhase;
|
|
23
|
+
description: string;
|
|
24
|
+
/**
|
|
25
|
+
* The prose this step renders, with `{placeholders}` filled from `input`.
|
|
26
|
+
* Unlike `description`, which documents what the step does, this is what a
|
|
27
|
+
* reader of the report sees. Falls back to `description` when absent.
|
|
28
|
+
*/
|
|
29
|
+
template?: string;
|
|
30
|
+
/** The input this step was called with, as recorded on the run. */
|
|
31
|
+
input?: unknown;
|
|
32
|
+
actor?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Pad the keyword to this width so a ladder of steps lines its sentences up
|
|
35
|
+
* under each other. Omit for inline prose.
|
|
36
|
+
*/
|
|
37
|
+
keywordWidth?: number;
|
|
38
|
+
}) => string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders the English sentence a reporter shows for a scenario step.
|
|
3
|
+
*
|
|
4
|
+
* This is the inversion of cucumber: rather than parsing English into a call,
|
|
5
|
+
* we render English out of a typed call, so the readable report survives
|
|
6
|
+
* without a regex registry paying for it.
|
|
7
|
+
*
|
|
8
|
+
* Lives in core so the CLI reporter and the console render identically.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Fill a step's `template` from the input that step was actually called with,
|
|
12
|
+
* so the reported sentence names the values under test — "sees @pikku/addon-todos"
|
|
13
|
+
* rather than "sees an addon in the gallery" repeated three times.
|
|
14
|
+
*
|
|
15
|
+
* A placeholder with no recorded value renders as nothing and the surrounding
|
|
16
|
+
* whitespace collapses, so an optional input that was omitted reads as a shorter
|
|
17
|
+
* sentence rather than a literal `{state}` leaking into the report.
|
|
18
|
+
*/
|
|
19
|
+
export const renderStepTemplate = (template, input) => {
|
|
20
|
+
const values = input && typeof input === 'object' ? input : {};
|
|
21
|
+
return template
|
|
22
|
+
.replace(/\{(\w+)\}/g, (_match, key) => formatValue(values[key]))
|
|
23
|
+
.replace(/\s+/g, ' ')
|
|
24
|
+
.trim();
|
|
25
|
+
};
|
|
26
|
+
const formatValue = (value) => {
|
|
27
|
+
if (value === undefined || value === null) {
|
|
28
|
+
return '';
|
|
29
|
+
}
|
|
30
|
+
if (typeof value === 'object') {
|
|
31
|
+
return JSON.stringify(value);
|
|
32
|
+
}
|
|
33
|
+
return String(value);
|
|
34
|
+
};
|
|
35
|
+
export const composeStepProse = ({ phase, description, template, input, actor, keywordWidth, }) => {
|
|
36
|
+
const keyword = phase === 'step' ? '' : capitalise(phase);
|
|
37
|
+
const subject = actor ? `the ${actor}` : '';
|
|
38
|
+
const rendered = template ? renderStepTemplate(template, input) : description;
|
|
39
|
+
const sentence = [subject, rendered].filter(Boolean).join(' ');
|
|
40
|
+
if (keywordWidth === undefined) {
|
|
41
|
+
return [keyword, sentence].filter(Boolean).join(' ');
|
|
42
|
+
}
|
|
43
|
+
return `${keyword.padEnd(keywordWidth)} ${sentence}`;
|
|
44
|
+
};
|
|
45
|
+
const capitalise = (value) => value.charAt(0).toUpperCase() + value.slice(1);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { PikkuScenarioStepWire, ScenarioEnvironment } from './scenario-step.types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The actor this step was called with, or a loud error naming the step.
|
|
4
|
+
*
|
|
5
|
+
* A step that talks to the target app needs an identity, but `actor` is
|
|
6
|
+
* optional on the wire because a pure assertion step needs none. This is the
|
|
7
|
+
* one place that narrowing happens, so every step file stops writing its own
|
|
8
|
+
* `actorOf(...)` guard.
|
|
9
|
+
*/
|
|
10
|
+
export declare const requireActor: <TActor>(scenarioStep: PikkuScenarioStepWire<TActor> | undefined) => TActor;
|
|
11
|
+
/**
|
|
12
|
+
* The environment this run targets, or a loud error naming the step. A run
|
|
13
|
+
* started outside `pikku scenario run` only carries one when the server has
|
|
14
|
+
* `API_URL` configured.
|
|
15
|
+
*/
|
|
16
|
+
export declare const requireScenarioEnv: (scenarioStep: PikkuScenarioStepWire<unknown> | undefined) => ScenarioEnvironment;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The actor this step was called with, or a loud error naming the step.
|
|
3
|
+
*
|
|
4
|
+
* A step that talks to the target app needs an identity, but `actor` is
|
|
5
|
+
* optional on the wire because a pure assertion step needs none. This is the
|
|
6
|
+
* one place that narrowing happens, so every step file stops writing its own
|
|
7
|
+
* `actorOf(...)` guard.
|
|
8
|
+
*/
|
|
9
|
+
export const requireActor = (scenarioStep) => {
|
|
10
|
+
const actor = scenarioStep?.actor;
|
|
11
|
+
if (!actor) {
|
|
12
|
+
throw new Error(`[scenario] step '${scenarioStep?.name ?? 'unknown'}' was called without an actor. ` +
|
|
13
|
+
`Pass { actor: actors.<name> } so it runs as that persona.`);
|
|
14
|
+
}
|
|
15
|
+
return actor;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* The environment this run targets, or a loud error naming the step. A run
|
|
19
|
+
* started outside `pikku scenario run` only carries one when the server has
|
|
20
|
+
* `API_URL` configured.
|
|
21
|
+
*/
|
|
22
|
+
export const requireScenarioEnv = (scenarioStep) => {
|
|
23
|
+
const env = scenarioStep?.env;
|
|
24
|
+
if (!env) {
|
|
25
|
+
throw new Error(`[scenario] step '${scenarioStep?.name ?? 'unknown'}' needs the target environment, but this run carries none. ` +
|
|
26
|
+
`Run it through 'pikku scenario run <environment>' with scenarios.environments.<environment> declared in pikku.config.json.`);
|
|
27
|
+
}
|
|
28
|
+
return env;
|
|
29
|
+
};
|