@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
@@ -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
  },
@@ -11,6 +11,7 @@ import { resolveDb, createKysely, parseDatabaseUrl, } from '../db/local-db.js';
11
11
  import { loadUserBootstrap, loadUserModule } from './load-user-project.js';
12
12
  import { createDevAIAgentRunner } from './dev-ai-runner.js';
13
13
  import { resolveConsoleMount } from './serve-console.js';
14
+ import { serverReadyLine } from '../../server/server-ready.js';
14
15
  export const serve = pikkuSessionlessFunc({
15
16
  remote: true,
16
17
  func: async ({ logger, config, getInspectorState, variables, devServerRunner }, { port, console: serveConsole }) => {
@@ -136,6 +137,7 @@ export const serve = pikkuSessionlessFunc({
136
137
  if (consoleMount) {
137
138
  logger.info(`Pikku Console available at http://${hostname}:${resolvedPort}${consoleMount.urlPrefix}`);
138
139
  }
140
+ logger.info(serverReadyLine(hostname, resolvedPort));
139
141
  process.once('SIGINT', async () => {
140
142
  logger.info('Stopping server...');
141
143
  try {
@@ -1,3 +1,15 @@
1
+ /**
2
+ * The migrator's own bookkeeping table, which belongs to no dialect and to no
3
+ * project's schema.
4
+ *
5
+ * Every introspector hides it for that reason. Leaving it visible on one
6
+ * dialect and not the other is not cosmetic: a schema source exported from a
7
+ * database that has been migrated would publish `sql_migrations` as one of its
8
+ * own tables, and the consumer — which also has it — would then read the source
9
+ * as partially covered and emit column deltas instead of the source's own SQL,
10
+ * silently dropping its primary keys, indexes and constraints.
11
+ */
12
+ export declare const MIGRATION_TRACKING_TABLE = "sql_migrations";
1
13
  export declare class MigrationDriftError extends Error {
2
14
  readonly file: string;
3
15
  readonly recordedHash: string;
@@ -23,10 +35,29 @@ export interface MigrationExecutor {
23
35
  ensureTrackingTable(): Promise<void>;
24
36
  getApplied(): Promise<AppliedMigration[]>;
25
37
  runMigration(sql: string, name: string, hash: string): Promise<void>;
38
+ /**
39
+ * Record a migration as applied without running its SQL.
40
+ *
41
+ * For a database that already contains what the migration describes, because
42
+ * something created those tables before anyone wrote them down. Only ever
43
+ * called once the caller has confirmed that is actually true — recording a
44
+ * migration whose tables are absent leaves a database permanently behind with
45
+ * no pending migration to reveal it.
46
+ */
47
+ recordMigration(name: string, hash: string): Promise<void>;
26
48
  }
49
+ export declare function migrate(executor: MigrationExecutor, migrationsDir: string): Promise<MigrateResult>;
27
50
  /**
28
- * Apply pending migrations from `migrationsDir/*.sql` using the supplied
29
- * executor. Hashes raw file bytes on apply; subsequent runs re-hash and bail
30
- * with `MigrationDriftError` if any applied file has changed on disk.
51
+ * Record every pending migration as applied, without running any of it.
52
+ *
53
+ * The escape hatch for a database that already has the tables a migration
54
+ * creates — the shape you get when a runtime bootstrapped its own schema at
55
+ * boot and the migration writing it down was authored afterwards. Running that
56
+ * migration would fail on every existing deployment; skipping it forever would
57
+ * leave the history lying. Recording it says what is true.
58
+ *
59
+ * Deliberately unconditional here. Whether the database really does match is a
60
+ * question about schemas, not migration files, so the caller answers it first
61
+ * and this only runs once it has.
31
62
  */
32
- export declare function migrate(executor: MigrationExecutor, migrationsDir: string): Promise<MigrateResult>;
63
+ export declare function baselineMigrations(executor: MigrationExecutor, migrationsDir: string): Promise<string[]>;
@@ -1,6 +1,18 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { readFileSync, readdirSync } from 'node:fs';
2
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
+ /**
5
+ * The migrator's own bookkeeping table, which belongs to no dialect and to no
6
+ * project's schema.
7
+ *
8
+ * Every introspector hides it for that reason. Leaving it visible on one
9
+ * dialect and not the other is not cosmetic: a schema source exported from a
10
+ * database that has been migrated would publish `sql_migrations` as one of its
11
+ * own tables, and the consumer — which also has it — would then read the source
12
+ * as partially covered and emit column deltas instead of the source's own SQL,
13
+ * silently dropping its primary keys, indexes and constraints.
14
+ */
15
+ export const MIGRATION_TRACKING_TABLE = 'sql_migrations';
4
16
  export class MigrationDriftError extends Error {
5
17
  file;
6
18
  recordedHash;
@@ -31,9 +43,25 @@ function sha256(bytes) {
31
43
  * executor. Hashes raw file bytes on apply; subsequent runs re-hash and bail
32
44
  * with `MigrationDriftError` if any applied file has changed on disk.
33
45
  */
34
- export async function migrate(executor, migrationsDir) {
35
- await executor.ensureTrackingTable();
36
- const applied = await executor.getApplied();
46
+ /**
47
+ * The migrations on disk, or none.
48
+ *
49
+ * A project that has never generated a migration has no directory to read, and
50
+ * that is the ordinary first-run state rather than a failure — it is precisely
51
+ * the project `db generate` exists to serve.
52
+ */
53
+ const migrationFiles = (migrationsDir) => existsSync(migrationsDir)
54
+ ? readdirSync(migrationsDir)
55
+ .filter((f) => f.endsWith('.sql'))
56
+ .sort()
57
+ : [];
58
+ /**
59
+ * Re-hash every applied migration and bail if one has changed on disk.
60
+ *
61
+ * Applies to baselining as much as to migrating: recording a file as applied
62
+ * only means anything if the file is still the one that was applied.
63
+ */
64
+ function assertNoDrift(applied, migrationsDir) {
37
65
  for (const row of applied) {
38
66
  let currentHash = null;
39
67
  try {
@@ -46,13 +74,15 @@ export async function migrate(executor, migrationsDir) {
46
74
  throw new MigrationDriftError(row.name, row.hash, currentHash, row.applied_at, migrationsDir);
47
75
  }
48
76
  }
49
- const appliedSet = new Set(applied.map((r) => r.name));
50
- const files = readdirSync(migrationsDir)
51
- .filter((f) => f.endsWith('.sql'))
52
- .sort();
77
+ }
78
+ export async function migrate(executor, migrationsDir) {
79
+ await executor.ensureTrackingTable();
80
+ const applied = await executor.getApplied();
81
+ assertNoDrift(applied, migrationsDir);
82
+ const appliedNames = new Set(applied.map((r) => r.name));
53
83
  const result = { applied: [], skipped: [] };
54
- for (const name of files) {
55
- if (appliedSet.has(name)) {
84
+ for (const name of migrationFiles(migrationsDir)) {
85
+ if (appliedNames.has(name)) {
56
86
  result.skipped.push(name);
57
87
  continue;
58
88
  }
@@ -63,3 +93,30 @@ export async function migrate(executor, migrationsDir) {
63
93
  }
64
94
  return result;
65
95
  }
96
+ /**
97
+ * Record every pending migration as applied, without running any of it.
98
+ *
99
+ * The escape hatch for a database that already has the tables a migration
100
+ * creates — the shape you get when a runtime bootstrapped its own schema at
101
+ * boot and the migration writing it down was authored afterwards. Running that
102
+ * migration would fail on every existing deployment; skipping it forever would
103
+ * leave the history lying. Recording it says what is true.
104
+ *
105
+ * Deliberately unconditional here. Whether the database really does match is a
106
+ * question about schemas, not migration files, so the caller answers it first
107
+ * and this only runs once it has.
108
+ */
109
+ export async function baselineMigrations(executor, migrationsDir) {
110
+ await executor.ensureTrackingTable();
111
+ const applied = await executor.getApplied();
112
+ assertNoDrift(applied, migrationsDir);
113
+ const appliedNames = new Set(applied.map((r) => r.name));
114
+ const recorded = [];
115
+ for (const name of migrationFiles(migrationsDir)) {
116
+ if (appliedNames.has(name))
117
+ continue;
118
+ await executor.recordMigration(name, sha256(readFileSync(join(migrationsDir, name))));
119
+ recorded.push(name);
120
+ }
121
+ return recorded;
122
+ }