@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
@@ -397,14 +397,36 @@ wireCLI({
397
397
  description: 'Comma-separated flow names to run (default: all)',
398
398
  short: 'f',
399
399
  },
400
+ features: {
401
+ description: 'Comma-separated feature names to run (pikkuFeature exports) — the feature is the run unit, so its hooks run once around the group',
402
+ },
400
403
  tags: {
401
404
  description: 'Comma-separated tags — run flows matching any',
402
405
  short: 't',
403
406
  },
407
+ excludeTags: {
408
+ description: 'Comma-separated tags to hold back — flows matching any are not run, unless named directly with --flows',
409
+ },
404
410
  coverage: {
405
411
  description: 'Reset/snapshot server coverage per scenario (target must run with --coverage); writes coverage/scenario-coverage.json',
406
412
  default: false,
407
413
  },
414
+ browser: {
415
+ description: 'Run scenarios with browser steps. --no-browser skips them (they are reported as skipped, not failed)',
416
+ default: true,
417
+ },
418
+ spawn: {
419
+ description: "Start `pikku dev` on the environment's apiUrl for the run and stop it afterwards. Without it, the environment must already be serving",
420
+ default: false,
421
+ },
422
+ keepAlive: {
423
+ description: 'With --spawn, leave the server running after the run (dev loop)',
424
+ default: false,
425
+ },
426
+ trace: {
427
+ description: 'Keep every stack frame on a failure. Without it, only the project’s own frames are shown',
428
+ default: false,
429
+ },
408
430
  },
409
431
  }),
410
432
  list: pikkuCLICommand({
@@ -14,6 +14,7 @@ import { loadUserBootstrap, loadUserModule } from './load-user-project.js';
14
14
  import { startCoverageService } from './start-coverage.js';
15
15
  import { createDevAIAgentRunner } from './dev-ai-runner.js';
16
16
  import { resolveConsoleMount } from './serve-console.js';
17
+ import { serverReadyLine } from '../../server/server-ready.js';
17
18
  export const dev = pikkuSessionlessFunc({
18
19
  remote: true,
19
20
  func: async ({ logger, config, getInspectorState, invalidateInspectorState, variables, devServerRunner, }, { port, watch, hmr, coverage, test }, { rpc }) => {
@@ -238,6 +239,7 @@ export const dev = pikkuSessionlessFunc({
238
239
  if (consoleMount) {
239
240
  logger.info(`Pikku Console available at http://${hostname}:${resolvedPort}${consoleMount.urlPrefix}`);
240
241
  }
242
+ logger.info(serverReadyLine(hostname, resolvedPort));
241
243
  let configWatcher;
242
244
  let watcher;
243
245
  process.once('SIGINT', async () => {
@@ -4,6 +4,13 @@
4
4
  * `pikkuState`.
5
5
  */
6
6
  export declare function loadUserBootstrap(pikkuDir: string): Promise<void>;
7
+ /**
8
+ * Load the generated `pikku-bootstrap-scenarios.gen.{ts,js}`, which imports the
9
+ * app bootstrap and then registers the scenarios, features and steps that are
10
+ * deliberately kept out of it. Falls back to the app bootstrap for a project
11
+ * generated before the split existed.
12
+ */
13
+ export declare function loadScenarioBootstrap(pikkuDir: string): Promise<void>;
7
14
  /**
8
15
  * Import a user-source TypeScript file (e.g. their config or services
9
16
  * factory) so it can be loaded from inside @pikku/cli's compiled JS.
@@ -39,6 +39,27 @@ export async function loadUserBootstrap(pikkuDir) {
39
39
  ensureTsxRegistered();
40
40
  await importUserPath(bootstrapPath);
41
41
  }
42
+ /**
43
+ * Load the generated `pikku-bootstrap-scenarios.gen.{ts,js}`, which imports the
44
+ * app bootstrap and then registers the scenarios, features and steps that are
45
+ * deliberately kept out of it. Falls back to the app bootstrap for a project
46
+ * generated before the split existed.
47
+ */
48
+ export async function loadScenarioBootstrap(pikkuDir) {
49
+ const scenarioTs = join(pikkuDir, 'pikku-bootstrap-scenarios.gen.ts');
50
+ const scenarioJs = join(pikkuDir, 'pikku-bootstrap-scenarios.gen.js');
51
+ const scenarioPath = existsSync(scenarioTs)
52
+ ? scenarioTs
53
+ : existsSync(scenarioJs)
54
+ ? scenarioJs
55
+ : null;
56
+ if (!scenarioPath) {
57
+ await loadUserBootstrap(pikkuDir);
58
+ return;
59
+ }
60
+ ensureTsxRegistered();
61
+ await importUserPath(scenarioPath);
62
+ }
42
63
  /**
43
64
  * Import a user-source TypeScript file (e.g. their config or services
44
65
  * factory) so it can be loaded from inside @pikku/cli's compiled JS.
@@ -64,5 +64,17 @@ export const pikkuBootstrap = pikkuSessionlessFunc({
64
64
  })
65
65
  .join('\n');
66
66
  await writeFileInDir(logger, config.bootstrapFile, allBootstrapImports);
67
+ // The scenario bootstrap is the only entry point that registers scenarios,
68
+ // features and their steps. It pulls in the app bootstrap first so a runner
69
+ // needs to load one file, and so the meta files it merges onto are already
70
+ // in place however this module is reached.
71
+ const scenarioBootstrapImports = [
72
+ config.bootstrapFile,
73
+ config.scenarioStepsMetaFile,
74
+ config.scenarioStepsFile,
75
+ config.scenarioWiringsMetaFile,
76
+ config.scenarioWiringsFile,
77
+ ].map((to) => `import '${getFileImportRelativePath(config.scenarioBootstrapFile, to, config.packageMappings, config.forceRelativeImports)}'`);
78
+ await writeFileInDir(logger, config.scenarioBootstrapFile, scenarioBootstrapImports.join('\n'));
67
79
  },
68
80
  });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The scenario runner's browser lifecycle.
3
+ *
4
+ * Two jobs, both kept out of the run loop so they are testable without
5
+ * launching a browser: deciding whether a run needs a driver at all (and
6
+ * failing early, with instructions, when it does and cannot have one), and
7
+ * making the driver's optional isolation and diagnostics safe to call
8
+ * unconditionally.
9
+ */
10
+ import type { ScenarioBrowserFailure, ScenarioBrowserProvider } from '@pikku/core/workflow';
11
+ import type { ScenarioActorConfig } from '@pikku/core/services';
12
+ /** The default driver, used when a project names no other. */
13
+ export declare const DEFAULT_BROWSER_DRIVER = "@pikku/playwright";
14
+ export interface ScenarioBrowserDriverOptions {
15
+ secret: string;
16
+ actors: Record<string, ScenarioActorConfig>;
17
+ signInPath?: string;
18
+ failureDir?: string;
19
+ config: unknown;
20
+ }
21
+ /**
22
+ * What a browser driver package exports.
23
+ *
24
+ * Nothing here is playwright-specific: a driver is anything that can hand a
25
+ * scenario step a `ScenarioBrowserProvider`. `@pikku/playwright` is the one
26
+ * pikku ships, named by `scenarios.browserDriver` only as a default.
27
+ */
28
+ export interface ScenarioBrowserDriver {
29
+ createScenarioBrowserProvider?: (options: ScenarioBrowserDriverOptions) => ScenarioBrowserProvider;
30
+ /** The class form the bundled driver exports. */
31
+ PlaywrightScenarioBrowserProvider?: new (options: ScenarioBrowserDriverOptions) => ScenarioBrowserProvider;
32
+ /** Driver-specific config (headed, slowMo, timeouts) resolved from the env. */
33
+ browserConfigFromEnv?: (overrides: {
34
+ appUrl: string;
35
+ apiUrl: string;
36
+ }) => unknown;
37
+ }
38
+ export interface ResolveScenarioBrowserProviderOptions {
39
+ /** The environment name, for error messages that tell the user what to edit. */
40
+ environment: string;
41
+ apiUrl: string;
42
+ appUrl?: string;
43
+ secret: string;
44
+ actors: Record<string, ScenarioActorConfig>;
45
+ signInPath?: string;
46
+ /** Where failure screenshots are written. */
47
+ failureDir: string;
48
+ /** The scenarios that declared browser steps, named in the missing-driver error. */
49
+ browserScenarios: string[];
50
+ /** Package to drive the browser. Defaults to `@pikku/playwright`. */
51
+ driver?: string;
52
+ importDriver?: (specifier: string) => Promise<ScenarioBrowserDriver>;
53
+ }
54
+ /**
55
+ * Both failure modes here are discovered before the first scenario starts. A
56
+ * missing `appUrl` or an uninstalled driver found mid-run costs a whole run to
57
+ * learn about.
58
+ */
59
+ export declare const resolveScenarioBrowserProvider: ({ environment, apiUrl, appUrl, secret, actors, signInPath, failureDir, browserScenarios, driver, importDriver, }: ResolveScenarioBrowserProviderOptions) => Promise<ScenarioBrowserProvider>;
60
+ /**
61
+ * The three calls the run loop makes, safe against a run with no browser and
62
+ * against a driver that implements neither optional member.
63
+ */
64
+ export interface ScenarioBrowserLifecycle {
65
+ reset(): Promise<void>;
66
+ captureFailure(label: string): Promise<ScenarioBrowserFailure[]>;
67
+ close(): Promise<void>;
68
+ }
69
+ export declare const scenarioBrowserLifecycle: (provider: ScenarioBrowserProvider | undefined) => ScenarioBrowserLifecycle;
@@ -0,0 +1,60 @@
1
+ /** The default driver, used when a project names no other. */
2
+ export const DEFAULT_BROWSER_DRIVER = '@pikku/playwright';
3
+ /**
4
+ * Both failure modes here are discovered before the first scenario starts. A
5
+ * missing `appUrl` or an uninstalled driver found mid-run costs a whole run to
6
+ * learn about.
7
+ */
8
+ export const resolveScenarioBrowserProvider = async ({ environment, apiUrl, appUrl, secret, actors, signInPath, failureDir, browserScenarios, driver = DEFAULT_BROWSER_DRIVER, importDriver = (specifier) => import(specifier), }) => {
9
+ if (!appUrl) {
10
+ throw new Error(`Scenario environment '${environment}' has browser steps but no 'appUrl'. ` +
11
+ `Add it to scenarios.environments.${environment} in pikku.config.json, or run with --no-browser to skip them.`);
12
+ }
13
+ const module = await importDriver(driver).catch(() => {
14
+ const install = driver === DEFAULT_BROWSER_DRIVER
15
+ ? `Run 'yarn add -D ${driver} @playwright/test'`
16
+ : `Install '${driver}', or point scenarios.browserDriver at a package that is`;
17
+ throw new Error(`Scenarios ${browserScenarios.join(', ')} declare browser steps but '${driver}' could not be loaded. ` +
18
+ `${install}, or run with --no-browser to skip them.`);
19
+ });
20
+ const options = {
21
+ secret,
22
+ actors,
23
+ signInPath,
24
+ failureDir,
25
+ config: module.browserConfigFromEnv?.({ appUrl, apiUrl }) ?? {
26
+ appUrl,
27
+ apiUrl,
28
+ },
29
+ };
30
+ if (module.createScenarioBrowserProvider) {
31
+ return module.createScenarioBrowserProvider(options);
32
+ }
33
+ if (module.PlaywrightScenarioBrowserProvider) {
34
+ return new module.PlaywrightScenarioBrowserProvider(options);
35
+ }
36
+ throw new Error(`'${driver}' is not a scenario browser driver: it exports neither ` +
37
+ `'createScenarioBrowserProvider' nor a provider class. A driver returns an object with ` +
38
+ `sessionFor() and close(), and optionally reset() and captureFailure().`);
39
+ };
40
+ export const scenarioBrowserLifecycle = (provider) => ({
41
+ // Deliberately NOT swallowed: a browser that cannot be reset would run the
42
+ // next scenario against the last one's session, and a scenario failing on
43
+ // its reset is far easier to diagnose than one failing on stale state.
44
+ reset: async () => {
45
+ await provider?.reset?.();
46
+ },
47
+ captureFailure: async (label) => {
48
+ try {
49
+ return (await provider?.captureFailure?.(label)) ?? [];
50
+ }
51
+ catch {
52
+ // This runs while a scenario is already failing. Whatever went wrong
53
+ // here matters less than the error we were called to describe.
54
+ return [];
55
+ }
56
+ },
57
+ close: async () => {
58
+ await provider?.close();
59
+ },
60
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The scenario run report, and how it is rendered.
3
+ *
4
+ * Everything a run prints goes through here, so the shape of the output is one
5
+ * file rather than log calls scattered through the command. The report itself
6
+ * is plain serialisable data — no Maps, no meta handles — which is what makes a
7
+ * second formatter (JSON, JUnit) a matter of writing one function rather than
8
+ * unpicking the runner.
9
+ */
10
+ import type { ScenarioBrowserFailure } from '@pikku/core/workflow';
11
+ /** The longest gherkin keyword ("Given"), so sentences line up under each other. */
12
+ declare const KEYWORD_WIDTH = 5;
13
+ /** One step of a run, already joined to the prose that declared it. */
14
+ export interface ScenarioStepRow {
15
+ sentence: string;
16
+ status: string;
17
+ durationMs?: number;
18
+ error?: string;
19
+ }
20
+ /** Everything known about why one scenario failed. */
21
+ export interface ScenarioFailureDetail {
22
+ /** The rendered sentence of the failing step; absent if no step failed. */
23
+ sentence?: string;
24
+ message: string;
25
+ stack?: string;
26
+ /**
27
+ * True when the failure was a deliberate one (a PikkuError). Its message is
28
+ * the whole story, so the stack is noise.
29
+ */
30
+ expected?: boolean;
31
+ browser?: ScenarioBrowserFailure[];
32
+ }
33
+ export interface ScenarioResult {
34
+ name: string;
35
+ status: 'passed' | 'failed';
36
+ durationMs: number;
37
+ output?: unknown;
38
+ error?: string;
39
+ steps?: ScenarioStepRow[];
40
+ failure?: ScenarioFailureDetail;
41
+ }
42
+ /** One scenario that was not run, and why. */
43
+ export interface ScenarioSkip {
44
+ name: string;
45
+ reason: string;
46
+ }
47
+ export interface ScenarioRunReport {
48
+ environment: string;
49
+ results: ScenarioResult[];
50
+ /**
51
+ * Scenarios not run at all, each carrying why. A skip is only useful if the
52
+ * reader can tell a browser scenario held back by `--no-browser` from one the
53
+ * project itself quarantined, so the reason travels with the name rather than
54
+ * being assumed by the formatter.
55
+ */
56
+ skipped: ScenarioSkip[];
57
+ /** Feature-level hook failures, which belong to no single scenario. */
58
+ hookFailures: string[];
59
+ }
60
+ export interface ScenarioReportLine {
61
+ level: 'info' | 'error';
62
+ text: string;
63
+ }
64
+ export interface FormatScenarioReportOptions {
65
+ /** Keep every stack frame, including the framework's own. */
66
+ trace?: boolean;
67
+ /** Frames under this directory are the project's own. */
68
+ projectRoot?: string;
69
+ }
70
+ export declare const formatScenarioReport: (report: ScenarioRunReport, options?: FormatScenarioReportOptions) => ScenarioReportLine[];
71
+ export declare const buildStepLadder: (steps: ScenarioStepRow[]) => string[];
72
+ /**
73
+ * The indented block printed under a failed scenario's ladder.
74
+ *
75
+ * One line of `run.error.message` is what a browser step's "Timed out waiting
76
+ * for selector" looks like with all of its context removed. The page's own
77
+ * console errors and failed API calls almost always say why the selector never
78
+ * appeared, and the driver has been collecting them all along.
79
+ */
80
+ export declare const formatScenarioFailure: (failure: ScenarioFailureDetail, { trace, projectRoot }?: FormatScenarioReportOptions) => string[];
81
+ export { KEYWORD_WIDTH };
@@ -0,0 +1,120 @@
1
+ /** The longest gherkin keyword ("Given"), so sentences line up under each other. */
2
+ const KEYWORD_WIDTH = 5;
3
+ export const formatScenarioReport = (report, options = {}) => {
4
+ const lines = [];
5
+ const info = (text) => lines.push({ level: 'info', text });
6
+ const error = (text) => lines.push({ level: 'error', text });
7
+ for (const { name, reason } of report.skipped) {
8
+ info(`SKIP ${name} (${reason})`);
9
+ }
10
+ for (const result of report.results) {
11
+ if (result.status === 'passed') {
12
+ const output = result.output !== undefined ? ` → ${JSON.stringify(result.output)}` : '';
13
+ info(`PASS ${result.name} (${result.durationMs}ms)${output}`);
14
+ }
15
+ else {
16
+ error(`FAIL ${result.name} (${result.durationMs}ms): ${firstLine(result.error)}`);
17
+ }
18
+ for (const line of buildStepLadder(result.steps ?? [])) {
19
+ info(line);
20
+ }
21
+ if (result.failure) {
22
+ for (const line of formatScenarioFailure(result.failure, options)) {
23
+ error(line);
24
+ }
25
+ }
26
+ }
27
+ for (const hookFailure of report.hookFailures) {
28
+ error(hookFailure);
29
+ }
30
+ const failed = report.results.filter((r) => r.status === 'failed').length;
31
+ const skippedSuffix = report.skipped.length
32
+ ? `, ${report.skipped.length} skipped`
33
+ : '';
34
+ const hookSuffix = report.hookFailures.length
35
+ ? `, ${report.hookFailures.length} feature hook failure(s)`
36
+ : '';
37
+ info(`${report.results.length - failed}/${report.results.length} scenarios passed against '${report.environment}'${skippedSuffix}${hookSuffix}`);
38
+ return lines;
39
+ };
40
+ export const buildStepLadder = (steps) => {
41
+ const width = Math.max(0, ...steps.map(({ sentence }) => sentence.length));
42
+ return steps.map((step) => {
43
+ const glyph = step.status === 'succeeded' ? '✓' : '✗';
44
+ const detail = step.status === 'succeeded' || !step.error
45
+ ? formatDuration(step.durationMs)
46
+ : firstLine(step.error);
47
+ return ` ${step.sentence.padEnd(width)} ${glyph} ${detail}`;
48
+ });
49
+ };
50
+ /**
51
+ * The one line a summary gets. A browser timeout's message carries its whole
52
+ * call log; the failure block below prints all of it, so the row above only
53
+ * needs enough to recognise it by.
54
+ */
55
+ const firstLine = (message) => (message ?? '').split('\n')[0] ?? '';
56
+ /**
57
+ * The indented block printed under a failed scenario's ladder.
58
+ *
59
+ * One line of `run.error.message` is what a browser step's "Timed out waiting
60
+ * for selector" looks like with all of its context removed. The page's own
61
+ * console errors and failed API calls almost always say why the selector never
62
+ * appeared, and the driver has been collecting them all along.
63
+ */
64
+ export const formatScenarioFailure = (failure, { trace = false, projectRoot } = {}) => {
65
+ const lines = [];
66
+ if (failure.sentence) {
67
+ lines.push(` ✗ failed at: ${failure.sentence}`);
68
+ }
69
+ for (const line of failure.message.split('\n')) {
70
+ lines.push(` ${line}`);
71
+ }
72
+ for (const browser of failure.browser ?? []) {
73
+ lines.push(` browser (${browser.actor})${browser.url ? `: ${browser.url}` : ''}`);
74
+ const detail = (label, values) => {
75
+ for (const value of values) {
76
+ lines.push(` ${`${label}:`.padEnd(11)} ${value}`);
77
+ }
78
+ };
79
+ detail('console', browser.consoleErrors);
80
+ detail('page', browser.pageErrors);
81
+ detail('request', browser.failedRequests);
82
+ detail('api', browser.apiErrors);
83
+ if (browser.screenshot) {
84
+ lines.push(` screenshot: ${browser.screenshot}`);
85
+ }
86
+ }
87
+ // An expected failure is a deliberate one — where it was thrown from adds
88
+ // nothing the message and the ladder do not already say.
89
+ if (failure.stack && !failure.expected) {
90
+ for (const frame of stackFrames(failure.stack, trace, projectRoot)) {
91
+ lines.push(` ${frame}`);
92
+ }
93
+ }
94
+ return lines;
95
+ };
96
+ /**
97
+ * The project's own frames — the ones a reader can act on. Framework and node
98
+ * internals are dropped unless `trace`, or unless dropping them would leave
99
+ * nothing at all: some stack always beats no stack.
100
+ */
101
+ const stackFrames = (stack, trace, projectRoot) => {
102
+ const frames = stack
103
+ .split('\n')
104
+ .map((line) => line.trim())
105
+ .filter((line) => line.startsWith('at '));
106
+ if (trace || !projectRoot) {
107
+ return frames;
108
+ }
109
+ const own = frames.filter((frame) => frame.includes(projectRoot) && !frame.includes('node_modules'));
110
+ return own.length > 0 ? own : frames;
111
+ };
112
+ const formatDuration = (durationMs) => {
113
+ if (durationMs === undefined) {
114
+ return '';
115
+ }
116
+ return durationMs < 1000
117
+ ? `${durationMs}ms`
118
+ : `${(durationMs / 1000).toFixed(1)}s`;
119
+ };
120
+ export { KEYWORD_WIDTH };
@@ -0,0 +1,66 @@
1
+ import type { ScenarioStepPhase } from '@pikku/core/workflow';
2
+ import type { WorkflowStepMeta } from '@pikku/core/workflow/types';
3
+ import type { FunctionsMeta } from '@pikku/core';
4
+ import type { ScenarioFailureDetail, ScenarioStepRow } from './scenario-formatter.js';
5
+ export interface ScenarioStepProse {
6
+ phase: ScenarioStepPhase;
7
+ description: string;
8
+ template?: string;
9
+ actor?: string;
10
+ }
11
+ export interface ScenarioStepOutcome {
12
+ stepName: string;
13
+ status: string;
14
+ durationMs?: number;
15
+ error?: string;
16
+ /** The failing error's stack, as recorded on the run. */
17
+ stack?: string;
18
+ /**
19
+ * True when the failure was a deliberate one (a PikkuError). Its message is
20
+ * the whole story, so the stack is noise.
21
+ */
22
+ expected?: boolean;
23
+ /** The input this step was called with, as recorded on the run. */
24
+ input?: unknown;
25
+ /** The step function that ran it, as recorded on the run. */
26
+ stepFunc?: string;
27
+ }
28
+ /**
29
+ * One scenario's prose, indexed the two ways a run step can be joined back to
30
+ * its declaration.
31
+ */
32
+ export interface ScenarioProse {
33
+ byStepName: Map<string, ScenarioStepProse>;
34
+ /**
35
+ * The same prose keyed by step function, for steps whose durable name the
36
+ * static meta cannot predict: a step called in a loop is declared as
37
+ * `sees ${packageName}` and reaches the run as `sees @pikku/addon-todos`.
38
+ *
39
+ * A function called from several sites that do not agree on their prose is
40
+ * left out rather than guessed at — such a step falls back to its bare name,
41
+ * which is what it did before this index existed.
42
+ */
43
+ byStepFunc: Map<string, ScenarioStepProse>;
44
+ }
45
+ /**
46
+ * Build `durable step name → prose parts` for one scenario.
47
+ *
48
+ * Description precedence matches the runtime: the call site's `description`
49
+ * wins, then the step's own declared `description`, then the positional step
50
+ * name. A step's `template` is carried alongside and, when it exists, is what
51
+ * actually gets rendered — filled from the input recorded on the run.
52
+ */
53
+ export declare const collectScenarioStepProse: (workflowMeta: {
54
+ steps?: WorkflowStepMeta[];
55
+ } | undefined, functionsMeta: FunctionsMeta) => ScenarioProse;
56
+ /** The step functions in this scenario that declare `browser: true`. */
57
+ export declare const scenarioBrowserSteps: (workflowMeta: {
58
+ steps?: WorkflowStepMeta[];
59
+ } | undefined, functionsMeta: FunctionsMeta) => string[];
60
+ /** The run's steps as renderable rows, in the order they were recorded. */
61
+ export declare const scenarioStepRows: (steps: ScenarioStepOutcome[], prose: ScenarioProse) => ScenarioStepRow[];
62
+ /**
63
+ * Find the step a run died on and join it back to its declared prose, so a
64
+ * failure names the sentence a reader recognises rather than a durable key.
65
+ */
66
+ export declare const scenarioFailureFromSteps: (steps: ScenarioStepOutcome[], prose: ScenarioProse) => ScenarioFailureDetail | undefined;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The scenario step ladder — joining a recorded run back to the English that
3
+ * declared it.
4
+ *
5
+ * Cucumber parsed English into calls; this recovers English out of the typed
6
+ * calls the inspector already recorded, joined against the run the engine
7
+ * already persisted. No engine change, no step-event bus. Layout of the result
8
+ * belongs to `scenario-formatter.ts`; this module only decides what each step
9
+ * is called.
10
+ */
11
+ import { composeStepProse } from '@pikku/core/workflow';
12
+ import { KEYWORD_WIDTH } from './scenario-formatter.js';
13
+ /**
14
+ * Walk a workflow's steps — including the ones nested inside branches, fanouts
15
+ * and parallel groups — and yield every scenario step it declares.
16
+ */
17
+ function* walkScenarioSteps(steps) {
18
+ if (!Array.isArray(steps)) {
19
+ return;
20
+ }
21
+ for (const step of steps) {
22
+ const node = step;
23
+ if (node?.type === 'scenarioStep') {
24
+ yield node;
25
+ continue;
26
+ }
27
+ yield* walkScenarioSteps(node?.children);
28
+ yield* walkScenarioSteps(node?.body);
29
+ yield* walkScenarioSteps(node?.steps);
30
+ yield* walkScenarioSteps(node?.then);
31
+ yield* walkScenarioSteps(node?.else);
32
+ for (const branch of Object.values(node?.cases ?? {})) {
33
+ yield* walkScenarioSteps(branch);
34
+ }
35
+ }
36
+ }
37
+ /**
38
+ * Build `durable step name → prose parts` for one scenario.
39
+ *
40
+ * Description precedence matches the runtime: the call site's `description`
41
+ * wins, then the step's own declared `description`, then the positional step
42
+ * name. A step's `template` is carried alongside and, when it exists, is what
43
+ * actually gets rendered — filled from the input recorded on the run.
44
+ */
45
+ export const collectScenarioStepProse = (workflowMeta, functionsMeta) => {
46
+ const byStepName = new Map();
47
+ const byStepFunc = new Map();
48
+ const ambiguous = new Set();
49
+ for (const step of walkScenarioSteps(workflowMeta?.steps)) {
50
+ const stepMeta = functionsMeta[step.stepFunc];
51
+ const parts = {
52
+ phase: step.phase ?? 'step',
53
+ description: step.options?.description ?? stepMeta?.description ?? step.stepName,
54
+ // A call-site description is an explicit override, so it wins over the
55
+ // step's template the same way it wins over its description.
56
+ template: step.options?.description
57
+ ? undefined
58
+ : stepMeta?.scenarioStepTemplate,
59
+ actor: step.actor,
60
+ };
61
+ byStepName.set(step.stepName, parts);
62
+ const declared = byStepFunc.get(step.stepFunc);
63
+ if (declared && !sameProse(declared, parts)) {
64
+ ambiguous.add(step.stepFunc);
65
+ }
66
+ else {
67
+ byStepFunc.set(step.stepFunc, parts);
68
+ }
69
+ }
70
+ for (const stepFunc of ambiguous) {
71
+ byStepFunc.delete(stepFunc);
72
+ }
73
+ return { byStepName, byStepFunc };
74
+ };
75
+ const sameProse = (a, b) => a.phase === b.phase &&
76
+ a.description === b.description &&
77
+ a.template === b.template &&
78
+ a.actor === b.actor;
79
+ /** The step functions in this scenario that declare `browser: true`. */
80
+ export const scenarioBrowserSteps = (workflowMeta, functionsMeta) => {
81
+ const names = new Set();
82
+ for (const step of walkScenarioSteps(workflowMeta?.steps)) {
83
+ if (functionsMeta[step.stepFunc]?.scenarioStepBrowser === true) {
84
+ names.add(step.stepFunc);
85
+ }
86
+ }
87
+ return [...names];
88
+ };
89
+ /**
90
+ * Render one step's gherkin sentence.
91
+ *
92
+ * A repeated step is stored as `name#1`; its prose lives under the bare name.
93
+ * Only once neither name matches does the step function decide it, so a step
94
+ * recorded under the name it was declared with is never re-resolved by a
95
+ * function shared with another call site.
96
+ */
97
+ const stepSentence = (step, prose) => {
98
+ const parts = prose.byStepName.get(step.stepName) ??
99
+ prose.byStepName.get(baseName(step.stepName)) ??
100
+ (step.stepFunc ? prose.byStepFunc.get(step.stepFunc) : undefined);
101
+ return parts
102
+ ? composeStepProse({
103
+ ...parts,
104
+ input: step.input,
105
+ keywordWidth: KEYWORD_WIDTH,
106
+ })
107
+ : `${''.padEnd(KEYWORD_WIDTH)} ${step.stepName}`;
108
+ };
109
+ /** The run's steps as renderable rows, in the order they were recorded. */
110
+ export const scenarioStepRows = (steps, prose) => steps.map((step) => ({
111
+ sentence: stepSentence(step, prose),
112
+ status: step.status,
113
+ durationMs: step.durationMs,
114
+ error: step.error,
115
+ }));
116
+ const baseName = (stepName) => stepName.replace(/#\d+$/, '');
117
+ /**
118
+ * Find the step a run died on and join it back to its declared prose, so a
119
+ * failure names the sentence a reader recognises rather than a durable key.
120
+ */
121
+ export const scenarioFailureFromSteps = (steps, prose) => {
122
+ const failed = steps.find((step) => step.status !== 'succeeded');
123
+ if (!failed) {
124
+ return undefined;
125
+ }
126
+ return {
127
+ sentence: stepSentence(failed, prose).trimEnd(),
128
+ message: failed.error ?? `step status: ${failed.status}`,
129
+ stack: failed.stack,
130
+ expected: failed.expected,
131
+ };
132
+ };