@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.
Files changed (127) hide show
  1. package/README.md +25 -2
  2. package/console-app/assets/{index-C5Bd44e4.js → index-CpkvmAGb.js} +140 -140
  3. package/console-app/assets/{index-CSzCJzBb.css → index-D0HG8q0B.css} +1 -1
  4. package/console-app/index.html +2 -2
  5. package/dist/.pikku/agent/pikku-agent-types.gen.d.ts +1 -1
  6. package/dist/.pikku/channel/pikku-channel-types.gen.d.ts +1 -1
  7. package/dist/.pikku/channel/pikku-channel-types.gen.js +1 -1
  8. package/dist/.pikku/cli/pikku-cli-channel.js +1 -1
  9. package/dist/.pikku/cli/pikku-cli-client.gen.d.ts +1 -1
  10. package/dist/.pikku/cli/pikku-cli-client.gen.js +1 -1
  11. package/dist/.pikku/cli/pikku-cli-contracts-meta.gen.d.ts +1 -1
  12. package/dist/.pikku/cli/pikku-cli-contracts-meta.gen.js +1 -1
  13. package/dist/.pikku/cli/pikku-cli-types.gen.d.ts +1 -1
  14. package/dist/.pikku/cli/pikku-cli-types.gen.js +1 -1
  15. package/dist/.pikku/cli/pikku-cli-wirings-meta.gen.js +1 -1
  16. package/dist/.pikku/cli/pikku-cli-wirings-meta.gen.json +22 -0
  17. package/dist/.pikku/cli/pikku-cli-wirings.gen.d.ts +1 -1
  18. package/dist/.pikku/cli/pikku-cli-wirings.gen.js +1 -1
  19. package/dist/.pikku/cli/pikku-cli.gen.d.ts +1 -1
  20. package/dist/.pikku/cli/pikku-cli.gen.js +1 -1
  21. package/dist/.pikku/console/pikku-node-types.gen.d.ts +1 -1
  22. package/dist/.pikku/function/pikku-function-types.gen.d.ts +4 -4
  23. package/dist/.pikku/function/pikku-function-types.gen.js +1 -1
  24. package/dist/.pikku/function/pikku-functions-meta.gen.js +1 -1
  25. package/dist/.pikku/function/pikku-functions-meta.gen.json +9 -9
  26. package/dist/.pikku/function/pikku-functions.gen.js +1 -1
  27. package/dist/.pikku/http/pikku-http-types.gen.d.ts +1 -1
  28. package/dist/.pikku/http/pikku-http-types.gen.js +1 -1
  29. package/dist/.pikku/mcp/pikku-mcp-types.gen.d.ts +1 -1
  30. package/dist/.pikku/mcp/pikku-mcp-types.gen.js +1 -1
  31. package/dist/.pikku/pikku-bootstrap-scenarios.gen.d.ts +8 -0
  32. package/dist/.pikku/pikku-bootstrap-scenarios.gen.js +8 -0
  33. package/dist/.pikku/pikku-bootstrap.gen.d.ts +1 -1
  34. package/dist/.pikku/pikku-bootstrap.gen.js +1 -1
  35. package/dist/.pikku/pikku-meta-service.gen.d.ts +1 -1
  36. package/dist/.pikku/pikku-meta-service.gen.js +1 -1
  37. package/dist/.pikku/pikku-services.gen.d.ts +1 -1
  38. package/dist/.pikku/pikku-types.gen.d.ts +1 -1
  39. package/dist/.pikku/pikku-types.gen.js +1 -1
  40. package/dist/.pikku/queue/pikku-queue-types.gen.d.ts +1 -1
  41. package/dist/.pikku/queue/pikku-queue-types.gen.js +1 -1
  42. package/dist/.pikku/queue/pikku-queue-workers-wirings-meta.gen.js +1 -1
  43. package/dist/.pikku/queue/pikku-queue-workers-wirings.gen.d.ts +1 -1
  44. package/dist/.pikku/queue/pikku-queue-workers-wirings.gen.js +1 -1
  45. package/dist/.pikku/rpc/pikku-rpc-wirings-meta.internal.gen.js +1 -1
  46. package/dist/.pikku/scenarios/pikku-scenario-functions-meta.gen.d.ts +1 -0
  47. package/dist/.pikku/scenarios/pikku-scenario-functions-meta.gen.js +10 -0
  48. package/dist/.pikku/scenarios/pikku-scenario-functions-meta.gen.json +1 -0
  49. package/dist/.pikku/scenarios/pikku-scenario-functions.gen.d.ts +4 -0
  50. package/dist/.pikku/scenarios/pikku-scenario-functions.gen.js +1 -0
  51. package/dist/.pikku/scenarios/pikku-scenario-wirings-meta.gen.d.ts +1 -0
  52. package/dist/.pikku/scenarios/pikku-scenario-wirings-meta.gen.js +10 -0
  53. package/dist/.pikku/scenarios/pikku-scenario-wirings.gen.d.ts +4 -0
  54. package/dist/.pikku/scenarios/pikku-scenario-wirings.gen.js +1 -0
  55. package/dist/.pikku/scheduler/pikku-scheduler-types.gen.d.ts +1 -1
  56. package/dist/.pikku/scheduler/pikku-scheduler-types.gen.js +1 -1
  57. package/dist/.pikku/schemas/register.gen.js +1 -1
  58. package/dist/.pikku/schemas/schemas/PikkuCLIConfig.schema.json +1 -1
  59. package/dist/.pikku/schemas/schemas/ScenarioRunInput.schema.json +1 -1
  60. package/dist/.pikku/scopes/pikku-scope-types.gen.d.ts +1 -1
  61. package/dist/.pikku/scopes/pikku-scope-types.gen.js +1 -1
  62. package/dist/.pikku/scopes/pikku-scopes.gen.d.ts +1 -1
  63. package/dist/.pikku/secrets/pikku-secret-types.gen.d.ts +1 -1
  64. package/dist/.pikku/secrets/pikku-secret-types.gen.js +1 -1
  65. package/dist/.pikku/secrets/pikku-secrets.gen.d.ts +1 -1
  66. package/dist/.pikku/secrets/pikku-secrets.gen.js +1 -1
  67. package/dist/.pikku/trigger/pikku-trigger-types.gen.d.ts +1 -1
  68. package/dist/.pikku/trigger/pikku-trigger-types.gen.js +1 -1
  69. package/dist/.pikku/variables/pikku-variable-types.gen.d.ts +1 -1
  70. package/dist/.pikku/variables/pikku-variable-types.gen.js +1 -1
  71. package/dist/.pikku/variables/pikku-variables.gen.d.ts +1 -1
  72. package/dist/.pikku/variables/pikku-variables.gen.js +1 -1
  73. package/dist/.pikku/workflow/pikku-scenario-actors.gen.d.ts +19 -0
  74. package/dist/.pikku/workflow/pikku-scenario-actors.gen.js +17 -0
  75. package/dist/.pikku/workflow/pikku-workflow-types.gen.d.ts +152 -3
  76. package/dist/.pikku/workflow/pikku-workflow-types.gen.js +39 -1
  77. package/dist/.pikku/workflow/pikku-workflow-wirings-meta.gen.js +1 -1
  78. package/dist/.pikku/workflow/pikku-workflow-wirings.gen.js +1 -1
  79. package/dist/bin/pikku-bin.mjs +2 -2
  80. package/dist/src/cli.wiring.js +22 -0
  81. package/dist/src/functions/commands/dev.js +2 -0
  82. package/dist/src/functions/commands/load-user-project.d.ts +7 -0
  83. package/dist/src/functions/commands/load-user-project.js +21 -0
  84. package/dist/src/functions/commands/pikku-command-bootstrap.js +12 -0
  85. package/dist/src/functions/commands/scenario-browser.d.ts +69 -0
  86. package/dist/src/functions/commands/scenario-browser.js +60 -0
  87. package/dist/src/functions/commands/scenario-formatter.d.ts +81 -0
  88. package/dist/src/functions/commands/scenario-formatter.js +120 -0
  89. package/dist/src/functions/commands/scenario-ladder.d.ts +66 -0
  90. package/dist/src/functions/commands/scenario-ladder.js +132 -0
  91. package/dist/src/functions/commands/scenario-plan.d.ts +61 -0
  92. package/dist/src/functions/commands/scenario-plan.js +106 -0
  93. package/dist/src/functions/commands/scenario.d.ts +18 -0
  94. package/dist/src/functions/commands/scenario.js +305 -38
  95. package/dist/src/functions/commands/serve.js +2 -0
  96. package/dist/src/functions/wirings/functions/pikku-command-functions.js +26 -8
  97. package/dist/src/functions/wirings/functions/serialize-function-types.js +3 -3
  98. package/dist/src/functions/wirings/scenarios/scenario-partition.d.ts +29 -0
  99. package/dist/src/functions/wirings/scenarios/scenario-partition.js +58 -0
  100. package/dist/src/functions/wirings/scenarios/serialize-feature-meta.d.ts +14 -0
  101. package/dist/src/functions/wirings/scenarios/serialize-feature-meta.js +29 -0
  102. package/dist/src/functions/wirings/scenarios/serialize-scenario-meta.d.ts +12 -0
  103. package/dist/src/functions/wirings/scenarios/serialize-scenario-meta.js +56 -0
  104. package/dist/src/functions/wirings/scenarios/serialize-scenario-registration.d.ts +7 -0
  105. package/dist/src/functions/wirings/scenarios/serialize-scenario-registration.js +40 -0
  106. package/dist/src/functions/wirings/workflow/pikku-command-workflow.js +43 -8
  107. package/dist/src/functions/wirings/workflow/serialize-scenario-actors.d.ts +1 -1
  108. package/dist/src/functions/wirings/workflow/serialize-scenario-actors.js +13 -2
  109. package/dist/src/functions/wirings/workflow/serialize-scenario-step-map.d.ts +4 -0
  110. package/dist/src/functions/wirings/workflow/serialize-scenario-step-map.js +62 -0
  111. package/dist/src/functions/wirings/workflow/serialize-workflow-bootstrap-map.js +10 -3
  112. package/dist/src/functions/wirings/workflow/serialize-workflow-map.js +10 -3
  113. package/dist/src/functions/wirings/workflow/serialize-workflow-meta.js +4 -4
  114. package/dist/src/functions/wirings/workflow/serialize-workflow-types.d.ts +1 -1
  115. package/dist/src/functions/wirings/workflow/serialize-workflow-types.js +232 -3
  116. package/dist/src/server/server-ready.d.ts +12 -0
  117. package/dist/src/server/server-ready.js +12 -0
  118. package/dist/src/server/spawn-dev-server.d.ts +50 -0
  119. package/dist/src/server/spawn-dev-server.js +112 -0
  120. package/dist/src/services.js +12 -1
  121. package/dist/src/utils/meta-diff.js +5 -2
  122. package/dist/src/utils/pikku-cli-config.js +26 -0
  123. package/dist/src/utils/resolve-scenario-actors.d.ts +23 -0
  124. package/dist/src/utils/resolve-scenario-actors.js +74 -0
  125. package/dist/tsconfig.tsbuildinfo +1 -1
  126. package/package.json +15 -5
  127. 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 { pikkuState } from '@pikku/core/internal';
6
- import { loadUserBootstrap } from './load-user-project.js';
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
- if (flow.description) {
28
- logger.info(` ${flow.description}`);
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
- let selected = listScenarios(state);
46
- if (flows) {
47
- const names = new Set(flows.split(',').map((f) => f.trim()));
48
- const unknown = [...names].filter((n) => !selected.some((f) => f.name === n));
49
- if (unknown.length) {
50
- throw new Error(`Unknown scenario(s): ${unknown.join(', ')}. Available: ${selected.map((f) => f.name).join(', ')}`);
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
- selected = selected.filter((f) => names.has(f.name));
123
+ await server.waitUntilReady();
53
124
  }
54
- if (tags) {
55
- const wanted = tags.split(',').map((t) => t.trim());
56
- selected = selected.filter((f) => wanted.some((t) => f.tags.includes(t)));
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 (selected.length === 0) {
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: config.scenarios?.actors ?? {},
169
+ actors: scenarioActors,
72
170
  signInPath: env.signInPath,
73
171
  rpcPath: env.rpcPath,
74
172
  });
75
- await loadUserBootstrap(resolve(config.rootDir, config.outDir));
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
- for (const flow of selected) {
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
- const { runId } = await workflowService.startWorkflow(flow.name, undefined, { type: 'cli' }, guardRpc, { actors });
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: flow.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: flow.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: flow.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[flow.name] = report;
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 '${flow.name}'`);
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 failed = results.filter((r) => r.status === 'failed');
171
- for (const r of results) {
172
- if (r.status === 'passed') {
173
- logger.info(`PASS ${r.name} (${r.durationMs}ms)${r.output !== undefined ? ` → ${JSON.stringify(r.output)}` : ''}`);
174
- }
175
- else {
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
- logger.info(`${results.length - failed.length}/${results.length} scenarios passed against '${environment}'`);
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
  },