@pikku/cli 0.12.90 → 0.12.91
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/README.md +25 -2
- package/console-app/assets/{index-C5Bd44e4.js → index-CpkvmAGb.js} +140 -140
- package/console-app/assets/{index-CSzCJzBb.css → index-D0HG8q0B.css} +1 -1
- package/console-app/index.html +2 -2
- package/dist/.pikku/agent/pikku-agent-types.gen.d.ts +1 -1
- package/dist/.pikku/channel/pikku-channel-types.gen.d.ts +1 -1
- package/dist/.pikku/channel/pikku-channel-types.gen.js +1 -1
- package/dist/.pikku/cli/pikku-cli-channel.js +1 -1
- package/dist/.pikku/cli/pikku-cli-client.gen.d.ts +1 -1
- package/dist/.pikku/cli/pikku-cli-client.gen.js +1 -1
- package/dist/.pikku/cli/pikku-cli-contracts-meta.gen.d.ts +1 -1
- package/dist/.pikku/cli/pikku-cli-contracts-meta.gen.js +1 -1
- package/dist/.pikku/cli/pikku-cli-types.gen.d.ts +1 -1
- package/dist/.pikku/cli/pikku-cli-types.gen.js +1 -1
- package/dist/.pikku/cli/pikku-cli-wirings-meta.gen.js +1 -1
- package/dist/.pikku/cli/pikku-cli-wirings-meta.gen.json +22 -0
- package/dist/.pikku/cli/pikku-cli-wirings.gen.d.ts +1 -1
- package/dist/.pikku/cli/pikku-cli-wirings.gen.js +1 -1
- package/dist/.pikku/cli/pikku-cli.gen.d.ts +1 -1
- package/dist/.pikku/cli/pikku-cli.gen.js +1 -1
- package/dist/.pikku/console/pikku-node-types.gen.d.ts +1 -1
- package/dist/.pikku/function/pikku-function-types.gen.d.ts +4 -4
- package/dist/.pikku/function/pikku-function-types.gen.js +1 -1
- package/dist/.pikku/function/pikku-functions-meta.gen.js +1 -1
- package/dist/.pikku/function/pikku-functions-meta.gen.json +9 -9
- package/dist/.pikku/function/pikku-functions.gen.js +1 -1
- package/dist/.pikku/http/pikku-http-types.gen.d.ts +1 -1
- package/dist/.pikku/http/pikku-http-types.gen.js +1 -1
- package/dist/.pikku/mcp/pikku-mcp-types.gen.d.ts +1 -1
- package/dist/.pikku/mcp/pikku-mcp-types.gen.js +1 -1
- package/dist/.pikku/pikku-bootstrap-scenarios.gen.d.ts +8 -0
- package/dist/.pikku/pikku-bootstrap-scenarios.gen.js +8 -0
- package/dist/.pikku/pikku-bootstrap.gen.d.ts +1 -1
- package/dist/.pikku/pikku-bootstrap.gen.js +1 -1
- package/dist/.pikku/pikku-meta-service.gen.d.ts +1 -1
- package/dist/.pikku/pikku-meta-service.gen.js +1 -1
- package/dist/.pikku/pikku-services.gen.d.ts +1 -1
- package/dist/.pikku/pikku-types.gen.d.ts +1 -1
- package/dist/.pikku/pikku-types.gen.js +1 -1
- package/dist/.pikku/queue/pikku-queue-types.gen.d.ts +1 -1
- package/dist/.pikku/queue/pikku-queue-types.gen.js +1 -1
- package/dist/.pikku/queue/pikku-queue-workers-wirings-meta.gen.js +1 -1
- package/dist/.pikku/queue/pikku-queue-workers-wirings.gen.d.ts +1 -1
- package/dist/.pikku/queue/pikku-queue-workers-wirings.gen.js +1 -1
- package/dist/.pikku/rpc/pikku-rpc-wirings-meta.internal.gen.js +1 -1
- package/dist/.pikku/scenarios/pikku-scenario-functions-meta.gen.d.ts +1 -0
- package/dist/.pikku/scenarios/pikku-scenario-functions-meta.gen.js +10 -0
- package/dist/.pikku/scenarios/pikku-scenario-functions-meta.gen.json +1 -0
- package/dist/.pikku/scenarios/pikku-scenario-functions.gen.d.ts +4 -0
- package/dist/.pikku/scenarios/pikku-scenario-functions.gen.js +1 -0
- package/dist/.pikku/scenarios/pikku-scenario-wirings-meta.gen.d.ts +1 -0
- package/dist/.pikku/scenarios/pikku-scenario-wirings-meta.gen.js +10 -0
- package/dist/.pikku/scenarios/pikku-scenario-wirings.gen.d.ts +4 -0
- package/dist/.pikku/scenarios/pikku-scenario-wirings.gen.js +1 -0
- package/dist/.pikku/scheduler/pikku-scheduler-types.gen.d.ts +1 -1
- package/dist/.pikku/scheduler/pikku-scheduler-types.gen.js +1 -1
- package/dist/.pikku/schemas/register.gen.js +1 -1
- package/dist/.pikku/schemas/schemas/PikkuCLIConfig.schema.json +1 -1
- package/dist/.pikku/schemas/schemas/ScenarioRunInput.schema.json +1 -1
- package/dist/.pikku/scopes/pikku-scope-types.gen.d.ts +1 -1
- package/dist/.pikku/scopes/pikku-scope-types.gen.js +1 -1
- package/dist/.pikku/scopes/pikku-scopes.gen.d.ts +1 -1
- package/dist/.pikku/secrets/pikku-secret-types.gen.d.ts +1 -1
- package/dist/.pikku/secrets/pikku-secret-types.gen.js +1 -1
- package/dist/.pikku/secrets/pikku-secrets.gen.d.ts +1 -1
- package/dist/.pikku/secrets/pikku-secrets.gen.js +1 -1
- package/dist/.pikku/trigger/pikku-trigger-types.gen.d.ts +1 -1
- package/dist/.pikku/trigger/pikku-trigger-types.gen.js +1 -1
- package/dist/.pikku/variables/pikku-variable-types.gen.d.ts +1 -1
- package/dist/.pikku/variables/pikku-variable-types.gen.js +1 -1
- package/dist/.pikku/variables/pikku-variables.gen.d.ts +1 -1
- package/dist/.pikku/variables/pikku-variables.gen.js +1 -1
- package/dist/.pikku/workflow/pikku-scenario-actors.gen.d.ts +19 -0
- package/dist/.pikku/workflow/pikku-scenario-actors.gen.js +17 -0
- package/dist/.pikku/workflow/pikku-workflow-types.gen.d.ts +152 -3
- package/dist/.pikku/workflow/pikku-workflow-types.gen.js +39 -1
- package/dist/.pikku/workflow/pikku-workflow-wirings-meta.gen.js +1 -1
- package/dist/.pikku/workflow/pikku-workflow-wirings.gen.js +1 -1
- package/dist/bin/pikku-bin.mjs +2 -2
- package/dist/src/cli.wiring.js +22 -0
- package/dist/src/functions/commands/dev.js +2 -0
- package/dist/src/functions/commands/load-user-project.d.ts +7 -0
- package/dist/src/functions/commands/load-user-project.js +21 -0
- package/dist/src/functions/commands/pikku-command-bootstrap.js +12 -0
- package/dist/src/functions/commands/scenario-browser.d.ts +69 -0
- package/dist/src/functions/commands/scenario-browser.js +60 -0
- package/dist/src/functions/commands/scenario-formatter.d.ts +81 -0
- package/dist/src/functions/commands/scenario-formatter.js +120 -0
- package/dist/src/functions/commands/scenario-ladder.d.ts +66 -0
- package/dist/src/functions/commands/scenario-ladder.js +132 -0
- package/dist/src/functions/commands/scenario-plan.d.ts +61 -0
- package/dist/src/functions/commands/scenario-plan.js +106 -0
- package/dist/src/functions/commands/scenario.d.ts +18 -0
- package/dist/src/functions/commands/scenario.js +305 -38
- package/dist/src/functions/commands/serve.js +2 -0
- package/dist/src/functions/wirings/functions/pikku-command-functions.js +26 -8
- package/dist/src/functions/wirings/functions/serialize-function-types.js +3 -3
- package/dist/src/functions/wirings/scenarios/scenario-partition.d.ts +29 -0
- package/dist/src/functions/wirings/scenarios/scenario-partition.js +58 -0
- package/dist/src/functions/wirings/scenarios/serialize-feature-meta.d.ts +14 -0
- package/dist/src/functions/wirings/scenarios/serialize-feature-meta.js +29 -0
- package/dist/src/functions/wirings/scenarios/serialize-scenario-meta.d.ts +12 -0
- package/dist/src/functions/wirings/scenarios/serialize-scenario-meta.js +56 -0
- package/dist/src/functions/wirings/scenarios/serialize-scenario-registration.d.ts +7 -0
- package/dist/src/functions/wirings/scenarios/serialize-scenario-registration.js +40 -0
- package/dist/src/functions/wirings/workflow/pikku-command-workflow.js +43 -8
- package/dist/src/functions/wirings/workflow/serialize-scenario-actors.d.ts +1 -1
- package/dist/src/functions/wirings/workflow/serialize-scenario-actors.js +13 -2
- package/dist/src/functions/wirings/workflow/serialize-scenario-step-map.d.ts +4 -0
- package/dist/src/functions/wirings/workflow/serialize-scenario-step-map.js +62 -0
- package/dist/src/functions/wirings/workflow/serialize-workflow-bootstrap-map.js +10 -3
- package/dist/src/functions/wirings/workflow/serialize-workflow-map.js +10 -3
- package/dist/src/functions/wirings/workflow/serialize-workflow-meta.js +4 -4
- package/dist/src/functions/wirings/workflow/serialize-workflow-types.d.ts +1 -1
- package/dist/src/functions/wirings/workflow/serialize-workflow-types.js +232 -3
- package/dist/src/server/server-ready.d.ts +12 -0
- package/dist/src/server/server-ready.js +12 -0
- package/dist/src/server/spawn-dev-server.d.ts +50 -0
- package/dist/src/server/spawn-dev-server.js +112 -0
- package/dist/src/services.js +12 -1
- package/dist/src/utils/meta-diff.js +5 -2
- package/dist/src/utils/pikku-cli-config.js +26 -0
- package/dist/src/utils/resolve-scenario-actors.d.ts +23 -0
- package/dist/src/utils/resolve-scenario-actors.js +74 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +15 -5
- package/skills/pikku-scenario/SKILL.md +196 -29
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { CoreFeature, CoreWorkflow } from '@pikku/core/workflow';
|
|
2
|
+
/** One scenario run: a scenario name and, for a feature's paired entry, its input. */
|
|
3
|
+
export type ScenarioPlanEntry = {
|
|
4
|
+
scenarioName: string;
|
|
5
|
+
data?: unknown;
|
|
6
|
+
tags: string[];
|
|
7
|
+
/**
|
|
8
|
+
* Why this scenario is not part of a default run. It stays in the plan so
|
|
9
|
+
* the runner reports it as skipped rather than omitting it silently, and it
|
|
10
|
+
* is cleared when the scenario is named directly with `--flows` — the
|
|
11
|
+
* explicit ask that overrides the quarantine.
|
|
12
|
+
*/
|
|
13
|
+
skip?: string;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* A run unit. A feature is one group whose hooks run once around all of its
|
|
17
|
+
* entries; a scenario belonging to no feature is a group of one with no hooks.
|
|
18
|
+
*/
|
|
19
|
+
export type ScenarioPlanGroup = {
|
|
20
|
+
featureId?: string;
|
|
21
|
+
featureName?: string;
|
|
22
|
+
before?: CoreFeature['before'];
|
|
23
|
+
after?: CoreFeature['after'];
|
|
24
|
+
entries: ScenarioPlanEntry[];
|
|
25
|
+
};
|
|
26
|
+
export type ScenarioPlanInput = {
|
|
27
|
+
/** Scenarios the inspector found, in declaration order. */
|
|
28
|
+
scenarios: Array<{
|
|
29
|
+
name: string;
|
|
30
|
+
tags: string[];
|
|
31
|
+
skip?: string;
|
|
32
|
+
}>;
|
|
33
|
+
features: Map<string, CoreFeature>;
|
|
34
|
+
registrations: Map<string, CoreWorkflow>;
|
|
35
|
+
flows?: string[];
|
|
36
|
+
featureIds?: string[];
|
|
37
|
+
tags?: string[];
|
|
38
|
+
/**
|
|
39
|
+
* Tags that disqualify a scenario. `tags` is match-any with no negation, so
|
|
40
|
+
* without this a default run cannot express "everything except the
|
|
41
|
+
* live-model ones" — the shape every suite that holds a cluster back needs.
|
|
42
|
+
*/
|
|
43
|
+
excludeTags?: string[];
|
|
44
|
+
};
|
|
45
|
+
export type ScenarioPlan = {
|
|
46
|
+
groups: ScenarioPlanGroup[];
|
|
47
|
+
/** Feature entries whose scenario is not registered — reported, never skipped silently. */
|
|
48
|
+
unresolved: Array<{
|
|
49
|
+
featureId: string;
|
|
50
|
+
index: number;
|
|
51
|
+
}>;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Turn the registered scenarios and features into an ordered list of run units.
|
|
55
|
+
*
|
|
56
|
+
* Features come first, in registration order, then the scenarios that belong to
|
|
57
|
+
* no feature, in declaration order. Selection is a filter over that plan rather
|
|
58
|
+
* than a different traversal, so `--tags` narrowing a feature to two of its
|
|
59
|
+
* five scenarios still runs the feature's hooks exactly once around those two.
|
|
60
|
+
*/
|
|
61
|
+
export declare const buildScenarioPlan: ({ scenarios, features, registrations, flows, featureIds, tags, excludeTags, }: ScenarioPlanInput) => ScenarioPlan;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { resolveFeatureScenarios } from '@pikku/core/workflow';
|
|
2
|
+
/**
|
|
3
|
+
* Turn the registered scenarios and features into an ordered list of run units.
|
|
4
|
+
*
|
|
5
|
+
* Features come first, in registration order, then the scenarios that belong to
|
|
6
|
+
* no feature, in declaration order. Selection is a filter over that plan rather
|
|
7
|
+
* than a different traversal, so `--tags` narrowing a feature to two of its
|
|
8
|
+
* five scenarios still runs the feature's hooks exactly once around those two.
|
|
9
|
+
*/
|
|
10
|
+
export const buildScenarioPlan = ({ scenarios, features, registrations, flows, featureIds, tags, excludeTags, }) => {
|
|
11
|
+
const { entries, unresolved } = resolveFeatureScenarios(features, registrations);
|
|
12
|
+
if (flows) {
|
|
13
|
+
const known = new Set(scenarios.map((s) => s.name));
|
|
14
|
+
const unknown = flows.filter((name) => !known.has(name));
|
|
15
|
+
if (unknown.length > 0) {
|
|
16
|
+
throw new Error(`Unknown scenario(s): ${unknown.join(', ')}. Available: ${scenarios
|
|
17
|
+
.map((s) => s.name)
|
|
18
|
+
.join(', ')}`);
|
|
19
|
+
}
|
|
20
|
+
// A scenario every one of whose feature entries carries `data` has no input
|
|
21
|
+
// to run with when it is named directly — the feature is what supplies it,
|
|
22
|
+
// so the feature is what runs. A scenario referenced bare anywhere, or in no
|
|
23
|
+
// feature at all, runs standalone exactly as before.
|
|
24
|
+
for (const name of flows) {
|
|
25
|
+
const appearances = entries.filter((e) => e.scenarioName === name);
|
|
26
|
+
if (appearances.length === 0)
|
|
27
|
+
continue;
|
|
28
|
+
if (appearances.some((e) => e.data === undefined))
|
|
29
|
+
continue;
|
|
30
|
+
const containing = [...new Set(appearances.map((e) => e.featureId))];
|
|
31
|
+
throw new Error(`Scenario '${name}' is only ever run with data supplied by a feature, so there is nothing to run it with on its own — run the feature instead: --features ${containing.join(',')}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (featureIds) {
|
|
35
|
+
const unknown = featureIds.filter((id) => !features.has(id));
|
|
36
|
+
if (unknown.length > 0) {
|
|
37
|
+
throw new Error(`Unknown feature(s): ${unknown.join(', ')}. Available: ${[...features.keys()].join(', ') || '(none)'}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const skipReasons = new Map(scenarios
|
|
41
|
+
.filter((scenario) => scenario.skip)
|
|
42
|
+
.map((scenario) => [scenario.name, scenario.skip]));
|
|
43
|
+
const wantedFlows = flows ? new Set(flows) : undefined;
|
|
44
|
+
const wantedFeatures = featureIds ? new Set(featureIds) : undefined;
|
|
45
|
+
const wantedTags = tags ? new Set(tags) : undefined;
|
|
46
|
+
const unwantedTags = excludeTags ? new Set(excludeTags) : undefined;
|
|
47
|
+
// Naming a scenario with `--flows` is the explicit ask that overrides its
|
|
48
|
+
// quarantine; narrowing to its feature is not — a feature is a group, and
|
|
49
|
+
// running the group should not silently drag a quarantined member in.
|
|
50
|
+
const skipFor = (scenarioName) => wantedFlows?.has(scenarioName) ? undefined : skipReasons.get(scenarioName);
|
|
51
|
+
const keep = (entry, featureId) => {
|
|
52
|
+
if (wantedFeatures && !(featureId && wantedFeatures.has(featureId))) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
if (wantedFlows && !wantedFlows.has(entry.scenarioName)) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
if (wantedTags && !entry.tags.some((tag) => wantedTags.has(tag))) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
// Naming a scenario with `--flows` is the explicit ask, the same override
|
|
62
|
+
// that lifts a quarantine: it outranks an exclusion the caller did not aim
|
|
63
|
+
// at this scenario in particular.
|
|
64
|
+
if (unwantedTags &&
|
|
65
|
+
!wantedFlows?.has(entry.scenarioName) &&
|
|
66
|
+
entry.tags.some((tag) => unwantedTags.has(tag))) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
return true;
|
|
70
|
+
};
|
|
71
|
+
const groups = [];
|
|
72
|
+
for (const [featureId, feature] of features) {
|
|
73
|
+
const kept = entries
|
|
74
|
+
.filter((entry) => entry.featureId === featureId)
|
|
75
|
+
.map(({ scenarioName, data, tags: entryTags }) => ({
|
|
76
|
+
scenarioName,
|
|
77
|
+
data,
|
|
78
|
+
tags: entryTags,
|
|
79
|
+
skip: skipFor(scenarioName),
|
|
80
|
+
}))
|
|
81
|
+
.filter((entry) => keep(entry, featureId));
|
|
82
|
+
if (kept.length === 0)
|
|
83
|
+
continue;
|
|
84
|
+
groups.push({
|
|
85
|
+
featureId,
|
|
86
|
+
featureName: feature.name ?? featureId,
|
|
87
|
+
before: feature.before,
|
|
88
|
+
after: feature.after,
|
|
89
|
+
entries: kept,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const inAFeature = new Set(entries.map((entry) => entry.scenarioName));
|
|
93
|
+
for (const scenario of scenarios) {
|
|
94
|
+
if (inAFeature.has(scenario.name))
|
|
95
|
+
continue;
|
|
96
|
+
const entry = {
|
|
97
|
+
scenarioName: scenario.name,
|
|
98
|
+
tags: scenario.tags,
|
|
99
|
+
skip: skipFor(scenario.name),
|
|
100
|
+
};
|
|
101
|
+
if (!keep(entry))
|
|
102
|
+
continue;
|
|
103
|
+
groups.push({ entries: [entry] });
|
|
104
|
+
}
|
|
105
|
+
return { groups, unresolved };
|
|
106
|
+
};
|
|
@@ -2,16 +2,34 @@ export declare const scenarioList: import("#pikku").PikkuFunctionConfig<{}, void
|
|
|
2
2
|
export declare const scenarioRun: import("#pikku").PikkuFunctionConfig<{
|
|
3
3
|
environment: string;
|
|
4
4
|
flows?: string;
|
|
5
|
+
features?: string;
|
|
5
6
|
tags?: string;
|
|
7
|
+
excludeTags?: string;
|
|
6
8
|
coverage?: boolean;
|
|
9
|
+
browser?: boolean;
|
|
10
|
+
spawn?: boolean;
|
|
11
|
+
keepAlive?: boolean;
|
|
12
|
+
trace?: boolean;
|
|
7
13
|
}, void, "rpc" | "session", import("#pikku").PikkuFunctionSessionless<{
|
|
8
14
|
environment: string;
|
|
9
15
|
flows?: string;
|
|
16
|
+
features?: string;
|
|
10
17
|
tags?: string;
|
|
18
|
+
excludeTags?: string;
|
|
11
19
|
coverage?: boolean;
|
|
20
|
+
browser?: boolean;
|
|
21
|
+
spawn?: boolean;
|
|
22
|
+
keepAlive?: boolean;
|
|
23
|
+
trace?: boolean;
|
|
12
24
|
}, void, "rpc" | "session", import("#pikku").WiredServices> | import("#pikku").PikkuFunction<{
|
|
13
25
|
environment: string;
|
|
14
26
|
flows?: string;
|
|
27
|
+
features?: string;
|
|
15
28
|
tags?: string;
|
|
29
|
+
excludeTags?: string;
|
|
16
30
|
coverage?: boolean;
|
|
31
|
+
browser?: boolean;
|
|
32
|
+
spawn?: boolean;
|
|
33
|
+
keepAlive?: boolean;
|
|
34
|
+
trace?: boolean;
|
|
17
35
|
}, void, "rpc" | "session", import("#pikku").WiredServices>, undefined, undefined>;
|
|
@@ -2,8 +2,16 @@ import { resolve, join } from 'node:path';
|
|
|
2
2
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { pikkuSessionlessFunc } from '#pikku';
|
|
4
4
|
import { InMemoryWorkflowService, createHttpScenarioActors, } from '@pikku/core/services';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { PikkuScenarioService } from '@pikku/core/scenario';
|
|
6
|
+
import { pikkuState, getAllPackageStates } from '@pikku/core/internal';
|
|
7
|
+
import { resolveFeatureScenarios } from '@pikku/core/workflow';
|
|
8
|
+
import { loadScenarioBootstrap } from './load-user-project.js';
|
|
9
|
+
import { collectScenarioStepProse, scenarioBrowserSteps, scenarioFailureFromSteps, scenarioStepRows, } from './scenario-ladder.js';
|
|
10
|
+
import { formatScenarioReport } from './scenario-formatter.js';
|
|
11
|
+
import { resolveScenarioBrowserProvider, scenarioBrowserLifecycle, } from './scenario-browser.js';
|
|
12
|
+
import { resolveScenarioActors } from '../../utils/resolve-scenario-actors.js';
|
|
13
|
+
import { spawnDevServer } from '../../server/spawn-dev-server.js';
|
|
14
|
+
import { buildScenarioPlan } from './scenario-plan.js';
|
|
7
15
|
const isScenario = (wf) => wf?.scenario === true;
|
|
8
16
|
const listScenarios = (state) => Object.entries(state.workflows?.meta ?? {})
|
|
9
17
|
.filter(([, wf]) => isScenario(wf))
|
|
@@ -12,27 +20,71 @@ const listScenarios = (state) => Object.entries(state.workflows?.meta ?? {})
|
|
|
12
20
|
name: wf.name ?? id,
|
|
13
21
|
description: wf.description ?? wf.summary ?? wf.title ?? null,
|
|
14
22
|
tags: wf.tags ?? [],
|
|
23
|
+
skip: wf.skip,
|
|
15
24
|
}));
|
|
25
|
+
/**
|
|
26
|
+
* Features and scenario registrations, merged across the main package and any
|
|
27
|
+
* addon packages. A feature holds the very config objects the registrations
|
|
28
|
+
* hold, which is what makes membership resolvable by identity.
|
|
29
|
+
*/
|
|
30
|
+
const collectRegisteredWirings = () => {
|
|
31
|
+
const features = new Map();
|
|
32
|
+
const registrations = new Map();
|
|
33
|
+
for (const [packageName] of getAllPackageStates()) {
|
|
34
|
+
const scope = packageName === '__main__' ? null : packageName;
|
|
35
|
+
for (const [id, feature] of pikkuState(scope, 'workflows', 'features')) {
|
|
36
|
+
features.set(id, feature);
|
|
37
|
+
}
|
|
38
|
+
for (const [name, registration] of pikkuState(scope, 'workflows', 'registrations')) {
|
|
39
|
+
registrations.set(name, registration);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { features, registrations };
|
|
43
|
+
};
|
|
16
44
|
export const scenarioList = pikkuSessionlessFunc({
|
|
17
|
-
func: async ({ logger, getInspectorState }) => {
|
|
18
|
-
const state = await getInspectorState();
|
|
45
|
+
func: async ({ logger, config, getInspectorState }) => {
|
|
46
|
+
const state = await getInspectorState(false, false, false, true);
|
|
19
47
|
const flows = listScenarios(state);
|
|
20
48
|
if (flows.length === 0) {
|
|
21
49
|
logger.info('No scenarios found (pikkuScenario exports).');
|
|
22
50
|
return;
|
|
23
51
|
}
|
|
52
|
+
await loadScenarioBootstrap(resolve(config.rootDir, config.outDir));
|
|
53
|
+
const { features, registrations } = collectRegisteredWirings();
|
|
54
|
+
const { entries, unresolved } = resolveFeatureScenarios(features, registrations);
|
|
55
|
+
for (const { featureId, index } of unresolved) {
|
|
56
|
+
logger.error(`Feature '${featureId}' scenario #${index} is not a registered scenario — it will not run.`);
|
|
57
|
+
}
|
|
58
|
+
const describe = (name) => flows.find((flow) => flow.name === name)?.description;
|
|
59
|
+
for (const [featureId, feature] of features) {
|
|
60
|
+
const featureTags = feature.tags?.length
|
|
61
|
+
? ` [${feature.tags.join(', ')}]`
|
|
62
|
+
: '';
|
|
63
|
+
logger.info(`${feature.name ?? featureId}${featureTags}`);
|
|
64
|
+
if (feature.description) {
|
|
65
|
+
logger.info(` ${feature.description}`);
|
|
66
|
+
}
|
|
67
|
+
for (const entry of entries.filter((e) => e.featureId === featureId)) {
|
|
68
|
+
const data = entry.data ? ` ${JSON.stringify(entry.data)}` : '';
|
|
69
|
+
logger.info(` - ${entry.scenarioName}${data}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const inAFeature = new Set(entries.map((entry) => entry.scenarioName));
|
|
24
73
|
for (const flow of flows) {
|
|
74
|
+
if (inAFeature.has(flow.name))
|
|
75
|
+
continue;
|
|
25
76
|
const tags = flow.tags.length ? ` [${flow.tags.join(', ')}]` : '';
|
|
26
77
|
logger.info(`${flow.name}${tags}`);
|
|
27
|
-
|
|
28
|
-
|
|
78
|
+
const description = describe(flow.name);
|
|
79
|
+
if (description) {
|
|
80
|
+
logger.info(` ${description}`);
|
|
29
81
|
}
|
|
30
82
|
}
|
|
31
83
|
},
|
|
32
84
|
});
|
|
33
85
|
export const scenarioRun = pikkuSessionlessFunc({
|
|
34
|
-
func: async ({ logger, config, getInspectorState, variables }, { environment, flows, tags, coverage }) => {
|
|
35
|
-
const state = await getInspectorState(true);
|
|
86
|
+
func: async ({ logger, config, getInspectorState, variables }, { environment, flows, features, tags, excludeTags, coverage, browser = true, spawn = false, keepAlive = false, trace = false, }) => {
|
|
87
|
+
const state = await getInspectorState(true, false, false, true);
|
|
36
88
|
const environments = config.scenarios?.environments ?? {};
|
|
37
89
|
const env = environments[environment];
|
|
38
90
|
if (!env) {
|
|
@@ -42,20 +94,62 @@ export const scenarioRun = pikkuSessionlessFunc({
|
|
|
42
94
|
? `Configured environments: ${known.join(', ')}`
|
|
43
95
|
: `Add scenarios.environments to pikku.config.json, e.g. { "${environment}": { "apiUrl": "https://app.example.com/api" } }`));
|
|
44
96
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
97
|
+
if (spawn) {
|
|
98
|
+
const { hostname, port } = new URL(env.apiUrl);
|
|
99
|
+
const resolvedPort = Number(port || 80);
|
|
100
|
+
logger.info(`Starting a server for '${environment}' on ${env.apiUrl}`);
|
|
101
|
+
const server = await spawnDevServer({
|
|
102
|
+
cwd: config.rootDir,
|
|
103
|
+
port: resolvedPort,
|
|
104
|
+
hostname,
|
|
105
|
+
coverage,
|
|
106
|
+
env: { API_URL: env.apiUrl },
|
|
107
|
+
onOutput: (text) => process.stdout.write(text),
|
|
108
|
+
});
|
|
109
|
+
// Registered rather than wrapped in a try/finally: the run below sets
|
|
110
|
+
// process.exitCode and can throw, and an exit handler covers both without
|
|
111
|
+
// the whole command body having to nest inside one block.
|
|
112
|
+
if (!keepAlive) {
|
|
113
|
+
process.once('exit', server.stop);
|
|
114
|
+
process.once('SIGINT', () => {
|
|
115
|
+
server.stop();
|
|
116
|
+
process.exit(1);
|
|
117
|
+
});
|
|
118
|
+
process.once('SIGTERM', () => {
|
|
119
|
+
server.stop();
|
|
120
|
+
process.exit(1);
|
|
121
|
+
});
|
|
51
122
|
}
|
|
52
|
-
|
|
123
|
+
await server.waitUntilReady();
|
|
53
124
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
125
|
+
// Features live in runtime state, not inspector meta — their scenario lists
|
|
126
|
+
// may be built by an ordinary loop — so the project has to be loaded before
|
|
127
|
+
// anything can be selected.
|
|
128
|
+
await loadScenarioBootstrap(resolve(config.rootDir, config.outDir));
|
|
129
|
+
const { features: registeredFeatures, registrations } = collectRegisteredWirings();
|
|
130
|
+
const split = (value) => value ? value.split(',').map((part) => part.trim()) : undefined;
|
|
131
|
+
let { groups, unresolved } = buildScenarioPlan({
|
|
132
|
+
scenarios: listScenarios(state).map(({ name, tags: flowTags, skip }) => ({
|
|
133
|
+
name,
|
|
134
|
+
tags: flowTags,
|
|
135
|
+
skip,
|
|
136
|
+
})),
|
|
137
|
+
features: registeredFeatures,
|
|
138
|
+
registrations,
|
|
139
|
+
flows: split(flows),
|
|
140
|
+
featureIds: split(features),
|
|
141
|
+
tags: split(tags),
|
|
142
|
+
excludeTags: split(excludeTags),
|
|
143
|
+
});
|
|
144
|
+
if (unresolved.length > 0) {
|
|
145
|
+
for (const { featureId, index } of unresolved) {
|
|
146
|
+
logger.error(`Feature '${featureId}' scenario #${index} is not a registered scenario. ` +
|
|
147
|
+
`A feature references scenarios by imported identifier — a scenario built inline inside a feature is never registered.`);
|
|
148
|
+
}
|
|
149
|
+
process.exitCode = 1;
|
|
150
|
+
return;
|
|
57
151
|
}
|
|
58
|
-
if (
|
|
152
|
+
if (groups.length === 0) {
|
|
59
153
|
logger.error('No scenarios matched.');
|
|
60
154
|
process.exitCode = 1;
|
|
61
155
|
return;
|
|
@@ -65,15 +159,57 @@ export const scenarioRun = pikkuSessionlessFunc({
|
|
|
65
159
|
throw new Error('SCENARIO_ACTOR_SECRET is not set — scenario actors cannot sign in. ' +
|
|
66
160
|
'Export it in the environment running this command (never put it in pikku.config.json).');
|
|
67
161
|
}
|
|
162
|
+
// Declared actors plus one per persona nobody declared a body for. Resolved
|
|
163
|
+
// once: the HTTP actors and the Playwright provider below must see the same
|
|
164
|
+
// registry, and codegen resolved the same way to type `ScenarioActorName`.
|
|
165
|
+
const scenarioActors = resolveScenarioActors(config.scenarios);
|
|
68
166
|
const actors = createHttpScenarioActors({
|
|
69
167
|
apiUrl: env.apiUrl,
|
|
70
168
|
secret,
|
|
71
|
-
actors:
|
|
169
|
+
actors: scenarioActors,
|
|
72
170
|
signInPath: env.signInPath,
|
|
73
171
|
rpcPath: env.rpcPath,
|
|
74
172
|
});
|
|
75
|
-
|
|
173
|
+
const functionsMeta = state.functions?.meta ?? {};
|
|
174
|
+
const scenarioNames = new Set(groups.flatMap((group) => group.entries.map((e) => e.scenarioName)));
|
|
175
|
+
const browserStepsByFlow = new Map([...scenarioNames]
|
|
176
|
+
.map((name) => [
|
|
177
|
+
name,
|
|
178
|
+
scenarioBrowserSteps(state.workflows?.meta?.[name], functionsMeta),
|
|
179
|
+
])
|
|
180
|
+
.filter(([, steps]) => steps.length > 0));
|
|
181
|
+
// Two reasons a scenario is held back, both reported as SKIP rather than
|
|
182
|
+
// failed. `--no-browser` is the direct replacement for cucumber's `@console`
|
|
183
|
+
// tag, so a standard run stays green on a machine with no browser; a `skip`
|
|
184
|
+
// on the scenario itself is the project quarantining it, and the plan has
|
|
185
|
+
// already cleared that reason for anything named directly with `--flows`.
|
|
186
|
+
const skipReasonFor = (entry) => {
|
|
187
|
+
if (entry.skip)
|
|
188
|
+
return entry.skip;
|
|
189
|
+
if (!browser && browserStepsByFlow.has(entry.scenarioName)) {
|
|
190
|
+
return 'browser steps, --no-browser';
|
|
191
|
+
}
|
|
192
|
+
return undefined;
|
|
193
|
+
};
|
|
194
|
+
const skipped = [];
|
|
195
|
+
groups = groups
|
|
196
|
+
.map((group) => ({
|
|
197
|
+
...group,
|
|
198
|
+
entries: group.entries.filter((entry) => {
|
|
199
|
+
const reason = skipReasonFor(entry);
|
|
200
|
+
if (!reason)
|
|
201
|
+
return true;
|
|
202
|
+
skipped.push({ name: entry.scenarioName, reason });
|
|
203
|
+
return false;
|
|
204
|
+
}),
|
|
205
|
+
}))
|
|
206
|
+
.filter((group) => group.entries.length > 0);
|
|
76
207
|
const workflowService = new InMemoryWorkflowService();
|
|
208
|
+
const scenarioService = workflowService.setRunExtension((engine) => new PikkuScenarioService(engine));
|
|
209
|
+
scenarioService.setScenarioEnvironment({
|
|
210
|
+
apiUrl: env.apiUrl,
|
|
211
|
+
appUrl: env.appUrl,
|
|
212
|
+
});
|
|
77
213
|
pikkuState(null, 'package', 'singletonServices', {
|
|
78
214
|
logger,
|
|
79
215
|
workflowService,
|
|
@@ -86,7 +222,51 @@ export const scenarioRun = pikkuSessionlessFunc({
|
|
|
86
222
|
`'${environment}' (${env.apiUrl}), not local services.`);
|
|
87
223
|
},
|
|
88
224
|
};
|
|
225
|
+
const needsBrowser = groups.some((group) => group.entries.some((entry) => browserStepsByFlow.has(entry.scenarioName)));
|
|
226
|
+
const failureDir = join(resolve(config.rootDir, config.outDir), 'scenario-failures');
|
|
227
|
+
const browserLifecycle = scenarioBrowserLifecycle(needsBrowser
|
|
228
|
+
? await (async () => {
|
|
229
|
+
const provider = await resolveScenarioBrowserProvider({
|
|
230
|
+
environment,
|
|
231
|
+
apiUrl: env.apiUrl,
|
|
232
|
+
appUrl: env.appUrl,
|
|
233
|
+
secret,
|
|
234
|
+
actors: scenarioActors,
|
|
235
|
+
signInPath: env.signInPath,
|
|
236
|
+
failureDir,
|
|
237
|
+
browserScenarios: [...browserStepsByFlow.keys()],
|
|
238
|
+
driver: config.scenarios?.browserDriver,
|
|
239
|
+
});
|
|
240
|
+
scenarioService.setScenarioBrowserProvider(provider);
|
|
241
|
+
return provider;
|
|
242
|
+
})()
|
|
243
|
+
: undefined);
|
|
89
244
|
const results = [];
|
|
245
|
+
/**
|
|
246
|
+
* The step ladder is read back off the recorded run, so it needs no live
|
|
247
|
+
* step events — it is the same data the console renders. Joining it to the
|
|
248
|
+
* declared prose happens here, where the inspector state is; laying it out
|
|
249
|
+
* is the formatter's job.
|
|
250
|
+
*/
|
|
251
|
+
const readRunSteps = async (service, runId, flowName) => {
|
|
252
|
+
const prose = collectScenarioStepProse(state.workflows?.meta?.[flowName], functionsMeta);
|
|
253
|
+
const steps = (await service.getRunSteps(runId)).map((step) => ({
|
|
254
|
+
stepName: step.stepName,
|
|
255
|
+
status: step.status,
|
|
256
|
+
durationMs: step.succeededAt
|
|
257
|
+
? step.succeededAt.getTime() - step.createdAt.getTime()
|
|
258
|
+
: undefined,
|
|
259
|
+
error: step.error?.message,
|
|
260
|
+
stack: step.error?.stack,
|
|
261
|
+
expected: step.error?.expected,
|
|
262
|
+
input: step.data,
|
|
263
|
+
stepFunc: step.rpcName,
|
|
264
|
+
}));
|
|
265
|
+
return {
|
|
266
|
+
rows: scenarioStepRows(steps, prose),
|
|
267
|
+
failure: scenarioFailureFromSteps(steps, prose),
|
|
268
|
+
};
|
|
269
|
+
};
|
|
90
270
|
const coverageActor = coverage ? Object.values(actors)[0] : undefined;
|
|
91
271
|
let coverageActive = Boolean(coverageActor);
|
|
92
272
|
if (coverage && !coverageActor) {
|
|
@@ -106,8 +286,22 @@ export const scenarioRun = pikkuSessionlessFunc({
|
|
|
106
286
|
return null;
|
|
107
287
|
}
|
|
108
288
|
};
|
|
109
|
-
|
|
289
|
+
/**
|
|
290
|
+
* A feature hook is not a pikku function and not a run — the feature is a
|
|
291
|
+
* grouping, not something durable. It gets the same three arguments a
|
|
292
|
+
* scenario body gets, the CLI's own singletons included, and its result is
|
|
293
|
+
* discarded.
|
|
294
|
+
*/
|
|
295
|
+
const singletonServices = pikkuState(null, 'package', 'singletonServices');
|
|
296
|
+
const runFeatureHook = async (hook) => {
|
|
297
|
+
await hook(singletonServices, undefined, { actors });
|
|
298
|
+
};
|
|
299
|
+
const hookFailures = [];
|
|
300
|
+
const runEntry = async (label, scenarioName, data) => {
|
|
110
301
|
const startedAt = Date.now();
|
|
302
|
+
// Before the scenario, not after it: the last scenario's window is left
|
|
303
|
+
// open for headed debugging, while this one still starts clean.
|
|
304
|
+
await browserLifecycle.reset();
|
|
111
305
|
if (coverageActive) {
|
|
112
306
|
const reset = await invokeCoverage('pikkuScenarioResetLiveCoverage');
|
|
113
307
|
if (reset && reset.enabled === false) {
|
|
@@ -119,20 +313,27 @@ export const scenarioRun = pikkuSessionlessFunc({
|
|
|
119
313
|
await coverageActor?.invoke('pikkuScenarioResetStubs', null);
|
|
120
314
|
}
|
|
121
315
|
catch { }
|
|
316
|
+
let runId;
|
|
317
|
+
let runError;
|
|
122
318
|
try {
|
|
123
|
-
|
|
319
|
+
// The id comes back through the callback rather than the return value
|
|
320
|
+
// because a failing scenario throws instead of returning — and a failed
|
|
321
|
+
// run is exactly the one whose steps are worth reading.
|
|
322
|
+
;
|
|
323
|
+
({ runId } = await workflowService.startWorkflow(scenarioName, data, { type: 'cli' }, guardRpc, { actors, onRunCreated: (id) => (runId = id) }));
|
|
124
324
|
const run = await workflowService.getRun(runId);
|
|
125
325
|
if (run?.status === 'completed') {
|
|
126
326
|
results.push({
|
|
127
|
-
name:
|
|
327
|
+
name: label,
|
|
128
328
|
status: 'passed',
|
|
129
329
|
durationMs: Date.now() - startedAt,
|
|
130
330
|
output: run.output,
|
|
131
331
|
});
|
|
132
332
|
}
|
|
133
333
|
else {
|
|
334
|
+
runError = run?.error;
|
|
134
335
|
results.push({
|
|
135
|
-
name:
|
|
336
|
+
name: label,
|
|
136
337
|
status: 'failed',
|
|
137
338
|
durationMs: Date.now() - startedAt,
|
|
138
339
|
error: run?.error?.message ?? `status: ${run?.status}`,
|
|
@@ -140,22 +341,90 @@ export const scenarioRun = pikkuSessionlessFunc({
|
|
|
140
341
|
}
|
|
141
342
|
}
|
|
142
343
|
catch (e) {
|
|
344
|
+
runError = { stack: e?.stack };
|
|
143
345
|
results.push({
|
|
144
|
-
name:
|
|
346
|
+
name: label,
|
|
145
347
|
status: 'failed',
|
|
146
348
|
durationMs: Date.now() - startedAt,
|
|
147
349
|
error: e?.message ?? String(e),
|
|
148
350
|
});
|
|
149
351
|
}
|
|
352
|
+
const result = results[results.length - 1];
|
|
353
|
+
let stepFailure;
|
|
354
|
+
if (runId) {
|
|
355
|
+
const read = await readRunSteps(workflowService, runId, scenarioName);
|
|
356
|
+
result.steps = read.rows;
|
|
357
|
+
stepFailure = read.failure;
|
|
358
|
+
}
|
|
359
|
+
if (result.status === 'failed') {
|
|
360
|
+
result.failure = {
|
|
361
|
+
// A scenario can also fail outside any step — a hook, or the start
|
|
362
|
+
// itself — and then the run's own error is all there is to report.
|
|
363
|
+
...(stepFailure ?? {
|
|
364
|
+
message: result.error ?? 'unknown failure',
|
|
365
|
+
stack: runError?.stack,
|
|
366
|
+
expected: runError?.expected,
|
|
367
|
+
}),
|
|
368
|
+
browser: await browserLifecycle.captureFailure(label),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
150
371
|
if (coverageActive) {
|
|
151
372
|
const report = await invokeCoverage('pikkuScenarioTakeLiveCoverage');
|
|
152
373
|
if (report) {
|
|
153
|
-
scenarioCoverage[
|
|
374
|
+
scenarioCoverage[label] = report;
|
|
154
375
|
const covered = report.functions?.filter((f) => f.status === 'covered' || f.status === 'partial');
|
|
155
|
-
logger.info(` coverage: ${covered?.length ?? 0}/${report.summary?.total ?? 0} functions exercised by '${
|
|
376
|
+
logger.info(` coverage: ${covered?.length ?? 0}/${report.summary?.total ?? 0} functions exercised by '${label}'`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
for (const group of groups) {
|
|
381
|
+
const groupName = group.featureName ?? group.featureId;
|
|
382
|
+
const label = (entry) => {
|
|
383
|
+
const data = entry.data ? ` ${JSON.stringify(entry.data)}` : '';
|
|
384
|
+
return groupName
|
|
385
|
+
? `${groupName} › ${entry.scenarioName}${data}`
|
|
386
|
+
: entry.scenarioName;
|
|
387
|
+
};
|
|
388
|
+
let beforeError;
|
|
389
|
+
if (group.before) {
|
|
390
|
+
try {
|
|
391
|
+
await runFeatureHook(group.before);
|
|
392
|
+
}
|
|
393
|
+
catch (e) {
|
|
394
|
+
beforeError = e;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
try {
|
|
398
|
+
if (beforeError) {
|
|
399
|
+
// Setup failed, so nothing in the group ran. Reporting them as failed
|
|
400
|
+
// rather than skipped is the honest reading: they did not pass.
|
|
401
|
+
for (const entry of group.entries) {
|
|
402
|
+
results.push({
|
|
403
|
+
name: label(entry),
|
|
404
|
+
status: 'failed',
|
|
405
|
+
durationMs: 0,
|
|
406
|
+
error: `feature '${groupName}' before hook failed: ${beforeError?.message ?? beforeError}`,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
for (const entry of group.entries) {
|
|
412
|
+
await runEntry(label(entry), entry.scenarioName, entry.data);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
finally {
|
|
417
|
+
if (group.after) {
|
|
418
|
+
try {
|
|
419
|
+
await runFeatureHook(group.after);
|
|
420
|
+
}
|
|
421
|
+
catch (e) {
|
|
422
|
+
hookFailures.push(`feature '${groupName}' after hook failed: ${e?.message ?? e}`);
|
|
423
|
+
}
|
|
156
424
|
}
|
|
157
425
|
}
|
|
158
426
|
}
|
|
427
|
+
await browserLifecycle.close();
|
|
159
428
|
if (coverage && Object.keys(scenarioCoverage).length > 0) {
|
|
160
429
|
const coverageDir = join(resolve(config.rootDir, config.outDir), 'coverage');
|
|
161
430
|
mkdirSync(coverageDir, { recursive: true });
|
|
@@ -167,17 +436,15 @@ export const scenarioRun = pikkuSessionlessFunc({
|
|
|
167
436
|
}, null, 2) + '\n');
|
|
168
437
|
logger.info(`Scenario coverage → ${outFile}`);
|
|
169
438
|
}
|
|
170
|
-
const
|
|
171
|
-
for (const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
logger.error(`FAIL ${r.name} (${r.durationMs}ms): ${r.error}`);
|
|
177
|
-
}
|
|
439
|
+
const report = { environment, results, skipped, hookFailures };
|
|
440
|
+
for (const { level, text } of formatScenarioReport(report, {
|
|
441
|
+
trace,
|
|
442
|
+
projectRoot: config.rootDir,
|
|
443
|
+
})) {
|
|
444
|
+
logger[level](text);
|
|
178
445
|
}
|
|
179
|
-
|
|
180
|
-
if (failed.length > 0) {
|
|
446
|
+
const failed = results.filter((r) => r.status === 'failed');
|
|
447
|
+
if (failed.length > 0 || hookFailures.length > 0) {
|
|
181
448
|
process.exitCode = 1;
|
|
182
449
|
}
|
|
183
450
|
},
|