@pikku/cli 0.12.89 → 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 (162) hide show
  1. package/README.md +25 -2
  2. package/console-app/assets/{index-BaOGIr2O.js → index-CpkvmAGb.js} +148 -148
  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 +21 -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-contracts-meta.gen.json +6 -1
  14. package/dist/.pikku/cli/pikku-cli-types.gen.d.ts +1 -1
  15. package/dist/.pikku/cli/pikku-cli-types.gen.js +1 -1
  16. package/dist/.pikku/cli/pikku-cli-wirings-meta.gen.js +1 -1
  17. package/dist/.pikku/cli/pikku-cli-wirings-meta.gen.json +52 -1
  18. package/dist/.pikku/cli/pikku-cli-wirings.gen.d.ts +1 -1
  19. package/dist/.pikku/cli/pikku-cli-wirings.gen.js +1 -1
  20. package/dist/.pikku/cli/pikku-cli.gen.d.ts +1 -1
  21. package/dist/.pikku/cli/pikku-cli.gen.js +1 -1
  22. package/dist/.pikku/console/pikku-node-types.gen.d.ts +1 -1
  23. package/dist/.pikku/function/pikku-function-types.gen.d.ts +4 -4
  24. package/dist/.pikku/function/pikku-function-types.gen.js +1 -1
  25. package/dist/.pikku/function/pikku-functions-meta.gen.js +1 -1
  26. package/dist/.pikku/function/pikku-functions-meta.gen.json +73 -13
  27. package/dist/.pikku/function/pikku-functions.gen.js +9 -1
  28. package/dist/.pikku/http/pikku-http-types.gen.d.ts +1 -1
  29. package/dist/.pikku/http/pikku-http-types.gen.js +1 -1
  30. package/dist/.pikku/mcp/pikku-mcp-types.gen.d.ts +1 -1
  31. package/dist/.pikku/mcp/pikku-mcp-types.gen.js +1 -1
  32. package/dist/.pikku/pikku-bootstrap-scenarios.gen.d.ts +8 -0
  33. package/dist/.pikku/pikku-bootstrap-scenarios.gen.js +8 -0
  34. package/dist/.pikku/pikku-bootstrap.gen.d.ts +1 -1
  35. package/dist/.pikku/pikku-bootstrap.gen.js +1 -1
  36. package/dist/.pikku/pikku-meta-service.gen.d.ts +1 -1
  37. package/dist/.pikku/pikku-meta-service.gen.js +1 -1
  38. package/dist/.pikku/pikku-services.gen.d.ts +1 -1
  39. package/dist/.pikku/pikku-types.gen.d.ts +1 -1
  40. package/dist/.pikku/pikku-types.gen.js +1 -1
  41. package/dist/.pikku/queue/pikku-queue-types.gen.d.ts +1 -1
  42. package/dist/.pikku/queue/pikku-queue-types.gen.js +1 -1
  43. package/dist/.pikku/queue/pikku-queue-workers-wirings-meta.gen.js +1 -1
  44. package/dist/.pikku/queue/pikku-queue-workers-wirings.gen.d.ts +1 -1
  45. package/dist/.pikku/queue/pikku-queue-workers-wirings.gen.js +1 -1
  46. package/dist/.pikku/rpc/pikku-rpc-wirings-meta.internal.gen.js +1 -1
  47. package/dist/.pikku/rpc/pikku-rpc-wirings-meta.internal.gen.json +4 -0
  48. package/dist/.pikku/scenarios/pikku-scenario-functions-meta.gen.d.ts +1 -0
  49. package/dist/.pikku/scenarios/pikku-scenario-functions-meta.gen.js +10 -0
  50. package/dist/.pikku/scenarios/pikku-scenario-functions-meta.gen.json +1 -0
  51. package/dist/.pikku/scenarios/pikku-scenario-functions.gen.d.ts +4 -0
  52. package/dist/.pikku/scenarios/pikku-scenario-functions.gen.js +1 -0
  53. package/dist/.pikku/scenarios/pikku-scenario-wirings-meta.gen.d.ts +1 -0
  54. package/dist/.pikku/scenarios/pikku-scenario-wirings-meta.gen.js +10 -0
  55. package/dist/.pikku/scenarios/pikku-scenario-wirings.gen.d.ts +4 -0
  56. package/dist/.pikku/scenarios/pikku-scenario-wirings.gen.js +1 -0
  57. package/dist/.pikku/scheduler/pikku-scheduler-types.gen.d.ts +1 -1
  58. package/dist/.pikku/scheduler/pikku-scheduler-types.gen.js +1 -1
  59. package/dist/.pikku/schemas/register.gen.js +9 -1
  60. package/dist/.pikku/schemas/schemas/DbBaselineInput.schema.json +1 -0
  61. package/dist/.pikku/schemas/schemas/DbCheckInput.schema.json +1 -0
  62. package/dist/.pikku/schemas/schemas/DbCodegenInput.schema.json +1 -0
  63. package/dist/.pikku/schemas/schemas/DbExportInput.schema.json +1 -0
  64. package/dist/.pikku/schemas/schemas/FabricValidateInput.schema.json +1 -1
  65. package/dist/.pikku/schemas/schemas/PikkuCLIConfig.schema.json +1 -1
  66. package/dist/.pikku/schemas/schemas/ScenarioRunInput.schema.json +1 -1
  67. package/dist/.pikku/scopes/pikku-scope-types.gen.d.ts +1 -1
  68. package/dist/.pikku/scopes/pikku-scope-types.gen.js +1 -1
  69. package/dist/.pikku/scopes/pikku-scopes.gen.d.ts +1 -1
  70. package/dist/.pikku/secrets/pikku-secret-types.gen.d.ts +1 -1
  71. package/dist/.pikku/secrets/pikku-secret-types.gen.js +1 -1
  72. package/dist/.pikku/secrets/pikku-secrets.gen.d.ts +1 -1
  73. package/dist/.pikku/secrets/pikku-secrets.gen.js +1 -1
  74. package/dist/.pikku/trigger/pikku-trigger-types.gen.d.ts +1 -1
  75. package/dist/.pikku/trigger/pikku-trigger-types.gen.js +1 -1
  76. package/dist/.pikku/variables/pikku-variable-types.gen.d.ts +1 -1
  77. package/dist/.pikku/variables/pikku-variable-types.gen.js +1 -1
  78. package/dist/.pikku/variables/pikku-variables.gen.d.ts +1 -1
  79. package/dist/.pikku/variables/pikku-variables.gen.js +1 -1
  80. package/dist/.pikku/workflow/pikku-scenario-actors.gen.d.ts +19 -0
  81. package/dist/.pikku/workflow/pikku-scenario-actors.gen.js +17 -0
  82. package/dist/.pikku/workflow/pikku-workflow-types.gen.d.ts +152 -3
  83. package/dist/.pikku/workflow/pikku-workflow-types.gen.js +39 -1
  84. package/dist/.pikku/workflow/pikku-workflow-wirings-meta.gen.js +1 -1
  85. package/dist/.pikku/workflow/pikku-workflow-wirings.gen.js +1 -1
  86. package/dist/bin/pikku-bin.mjs +2 -2
  87. package/dist/src/cli.wiring.js +42 -0
  88. package/dist/src/fabric/fabric-commands.d.ts +9 -3
  89. package/dist/src/fabric/fabric-commands.js +6 -0
  90. package/dist/src/fabric/functions/validate.function.d.ts +15 -5
  91. package/dist/src/fabric/functions/validate.function.js +69 -16
  92. package/dist/src/fabric/lib/config.d.ts +11 -1
  93. package/dist/src/fabric/lib/config.js +39 -1
  94. package/dist/src/fabric/lib/frontend-typecheck.d.ts +46 -0
  95. package/dist/src/fabric/lib/frontend-typecheck.js +139 -0
  96. package/dist/src/functions/commands/db-baseline.d.ts +13 -0
  97. package/dist/src/functions/commands/db-baseline.js +48 -0
  98. package/dist/src/functions/commands/db-check.d.ts +12 -0
  99. package/dist/src/functions/commands/db-check.js +64 -0
  100. package/dist/src/functions/commands/db-codegen.d.ts +11 -0
  101. package/dist/src/functions/commands/db-codegen.js +39 -0
  102. package/dist/src/functions/commands/db-export.d.ts +12 -0
  103. package/dist/src/functions/commands/db-export.js +31 -0
  104. package/dist/src/functions/commands/db-generate.js +19 -26
  105. package/dist/src/functions/commands/dev.js +2 -0
  106. package/dist/src/functions/commands/load-user-project.d.ts +7 -0
  107. package/dist/src/functions/commands/load-user-project.js +21 -0
  108. package/dist/src/functions/commands/pikku-command-bootstrap.js +12 -0
  109. package/dist/src/functions/commands/scenario-browser.d.ts +69 -0
  110. package/dist/src/functions/commands/scenario-browser.js +60 -0
  111. package/dist/src/functions/commands/scenario-formatter.d.ts +81 -0
  112. package/dist/src/functions/commands/scenario-formatter.js +120 -0
  113. package/dist/src/functions/commands/scenario-ladder.d.ts +66 -0
  114. package/dist/src/functions/commands/scenario-ladder.js +132 -0
  115. package/dist/src/functions/commands/scenario-plan.d.ts +61 -0
  116. package/dist/src/functions/commands/scenario-plan.js +106 -0
  117. package/dist/src/functions/commands/scenario.d.ts +18 -0
  118. package/dist/src/functions/commands/scenario.js +305 -38
  119. package/dist/src/functions/commands/serve.js +2 -0
  120. package/dist/src/functions/db/db-migrator.d.ts +35 -4
  121. package/dist/src/functions/db/db-migrator.js +67 -10
  122. package/dist/src/functions/db/local-db.d.ts +235 -13
  123. package/dist/src/functions/db/local-db.js +430 -45
  124. package/dist/src/functions/db/postgres/postgres-introspector.js +19 -1
  125. package/dist/src/functions/db/postgres/postgres-migrator.d.ts +1 -0
  126. package/dist/src/functions/db/postgres/postgres-migrator.js +4 -1
  127. package/dist/src/functions/db/sqlite/sqlite-introspector.js +2 -1
  128. package/dist/src/functions/db/sqlite/sqlite-migrator.d.ts +1 -0
  129. package/dist/src/functions/db/sqlite/sqlite-migrator.js +6 -1
  130. package/dist/src/functions/wirings/functions/pikku-command-functions.js +26 -8
  131. package/dist/src/functions/wirings/functions/serialize-function-types.js +3 -3
  132. package/dist/src/functions/wirings/scenarios/scenario-partition.d.ts +29 -0
  133. package/dist/src/functions/wirings/scenarios/scenario-partition.js +58 -0
  134. package/dist/src/functions/wirings/scenarios/serialize-feature-meta.d.ts +14 -0
  135. package/dist/src/functions/wirings/scenarios/serialize-feature-meta.js +29 -0
  136. package/dist/src/functions/wirings/scenarios/serialize-scenario-meta.d.ts +12 -0
  137. package/dist/src/functions/wirings/scenarios/serialize-scenario-meta.js +56 -0
  138. package/dist/src/functions/wirings/scenarios/serialize-scenario-registration.d.ts +7 -0
  139. package/dist/src/functions/wirings/scenarios/serialize-scenario-registration.js +40 -0
  140. package/dist/src/functions/wirings/workflow/pikku-command-workflow.js +43 -8
  141. package/dist/src/functions/wirings/workflow/serialize-scenario-actors.d.ts +1 -1
  142. package/dist/src/functions/wirings/workflow/serialize-scenario-actors.js +13 -2
  143. package/dist/src/functions/wirings/workflow/serialize-scenario-step-map.d.ts +4 -0
  144. package/dist/src/functions/wirings/workflow/serialize-scenario-step-map.js +62 -0
  145. package/dist/src/functions/wirings/workflow/serialize-workflow-bootstrap-map.js +10 -3
  146. package/dist/src/functions/wirings/workflow/serialize-workflow-map.js +10 -3
  147. package/dist/src/functions/wirings/workflow/serialize-workflow-meta.js +4 -4
  148. package/dist/src/functions/wirings/workflow/serialize-workflow-types.d.ts +1 -1
  149. package/dist/src/functions/wirings/workflow/serialize-workflow-types.js +232 -3
  150. package/dist/src/server/server-ready.d.ts +12 -0
  151. package/dist/src/server/server-ready.js +12 -0
  152. package/dist/src/server/spawn-dev-server.d.ts +50 -0
  153. package/dist/src/server/spawn-dev-server.js +112 -0
  154. package/dist/src/services.js +12 -1
  155. package/dist/src/utils/meta-diff.js +5 -2
  156. package/dist/src/utils/pikku-cli-config.js +26 -0
  157. package/dist/src/utils/resolve-scenario-actors.d.ts +23 -0
  158. package/dist/src/utils/resolve-scenario-actors.js +74 -0
  159. package/dist/tsconfig.tsbuildinfo +1 -1
  160. package/package.json +16 -6
  161. package/skills/pikku-i18n/SKILL.md +53 -6
  162. package/skills/pikku-scenario/SKILL.md +196 -29
@@ -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
+ };
@@ -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>;