@elench/testkit 0.1.54 → 0.1.56

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 (43) hide show
  1. package/README.md +22 -0
  2. package/lib/bundler/index.mjs +1 -1
  3. package/lib/bundler/index.test.mjs +29 -0
  4. package/lib/cli/args.mjs +2 -2
  5. package/lib/cli/args.test.mjs +8 -2
  6. package/lib/cli/command-helpers.mjs +5 -1
  7. package/lib/cli/commands/artifacts.mjs +2 -2
  8. package/lib/cli/commands/logs.mjs +2 -2
  9. package/lib/cli/commands/run.mjs +2 -2
  10. package/lib/cli/commands/show.mjs +2 -2
  11. package/lib/cli/db.mjs +17 -2
  12. package/lib/cli/entrypoint.mjs +2 -1
  13. package/lib/cli/presentation/run-reporter.mjs +25 -0
  14. package/lib/cli/presentation/run-reporter.test.mjs +80 -0
  15. package/lib/cli/tui/watch-app.mjs +134 -18
  16. package/lib/cli/viewer.mjs +67 -0
  17. package/lib/config/discovery.mjs +1 -0
  18. package/lib/config/discovery.test.mjs +8 -0
  19. package/lib/database/index.mjs +85 -11
  20. package/lib/database/template-steps.mjs +45 -6
  21. package/lib/database/template-steps.test.mjs +43 -0
  22. package/lib/index.d.ts +58 -0
  23. package/lib/index.mjs +3 -0
  24. package/lib/runner/artifacts.mjs +16 -0
  25. package/lib/runner/default-runtime-runner.mjs +4 -1
  26. package/lib/runner/logs.mjs +54 -6
  27. package/lib/runner/orchestrator.mjs +67 -14
  28. package/lib/runner/planning.mjs +1 -1
  29. package/lib/runner/reporting.mjs +58 -2
  30. package/lib/runner/reporting.test.mjs +85 -2
  31. package/lib/runner/runtime-contexts.mjs +3 -3
  32. package/lib/runner/runtime-preparation.mjs +31 -0
  33. package/lib/runner/setup-operations.mjs +115 -0
  34. package/lib/runner/setup-operations.test.mjs +94 -0
  35. package/lib/runner/suite-selection.mjs +4 -4
  36. package/lib/runner/suite-selection.test.mjs +9 -2
  37. package/lib/runner/template-steps.mjs +129 -11
  38. package/lib/runner/worker-loop.mjs +1 -1
  39. package/lib/runtime-src/k6/checks.js +9 -0
  40. package/lib/runtime-src/k6/scenario-runtime.js +234 -0
  41. package/lib/runtime-src/k6/scenario-suite.js +179 -0
  42. package/lib/toolchains/index.mjs +0 -4
  43. package/package.json +1 -1
@@ -0,0 +1,94 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { createRunLogRegistry } from "./logs.mjs";
6
+ import { createSetupOperationRegistry } from "./setup-operations.mjs";
7
+
8
+ const tempDirs = [];
9
+
10
+ afterEach(() => {
11
+ while (tempDirs.length > 0) {
12
+ fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
13
+ }
14
+ });
15
+
16
+ function makeTempDir(prefix) {
17
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
18
+ tempDirs.push(dir);
19
+ return dir;
20
+ }
21
+
22
+ function makeConfig(productDir) {
23
+ return {
24
+ name: "api",
25
+ runtimeLabel: "api",
26
+ productDir,
27
+ };
28
+ }
29
+
30
+ describe("setup operation registry", () => {
31
+ it("tracks running and finished setup operations with log refs", () => {
32
+ const productDir = makeTempDir("testkit-setup-ops-");
33
+ const logRegistry = createRunLogRegistry(productDir);
34
+ const changes = [];
35
+ const registry = createSetupOperationRegistry({
36
+ logRegistry,
37
+ onChange(operations) {
38
+ changes.push(operations);
39
+ },
40
+ });
41
+
42
+ const operation = registry.start({
43
+ config: makeConfig(productDir),
44
+ stage: "template:migrate:api:1",
45
+ kind: "setup-step",
46
+ summary: "command: node scripts/migrate.mjs",
47
+ });
48
+
49
+ expect(operation.logRef).toMatchObject({
50
+ path: ".testkit/results/setup/api__api__template-migrate-api-1.log",
51
+ stage: "template:migrate:api:1",
52
+ });
53
+
54
+ const finished = registry.finish(operation, {
55
+ status: "passed",
56
+ summary: "command: node scripts/migrate.mjs",
57
+ });
58
+
59
+ expect(finished.status).toBe("passed");
60
+ expect(finished.durationMs).toBeGreaterThanOrEqual(0);
61
+ expect(registry.listOperations()).toEqual([
62
+ expect.objectContaining({
63
+ stage: "template:migrate:api:1",
64
+ kind: "setup-step",
65
+ status: "passed",
66
+ summary: "command: node scripts/migrate.mjs",
67
+ logRef: operation.logRef,
68
+ }),
69
+ ]);
70
+ expect(changes).toHaveLength(2);
71
+
72
+ logRegistry.closeAll();
73
+ });
74
+
75
+ it("records cached setup operations without logs", () => {
76
+ const productDir = makeTempDir("testkit-setup-ops-cached-");
77
+ const registry = createSetupOperationRegistry();
78
+
79
+ const operation = registry.recordCached({
80
+ config: makeConfig(productDir),
81
+ stage: "template",
82
+ kind: "database-template",
83
+ summary: "template cache hit",
84
+ });
85
+
86
+ expect(operation).toMatchObject({
87
+ stage: "template",
88
+ kind: "database-template",
89
+ status: "cached",
90
+ summary: "template cache hit",
91
+ logRef: null,
92
+ });
93
+ });
94
+ });
@@ -1,4 +1,4 @@
1
- const USER_TYPES = new Set(["int", "e2e", "dal", "load", "pw", "all"]);
1
+ const USER_TYPES = new Set(["int", "e2e", "scenario", "dal", "load", "pw", "all"]);
2
2
 
3
3
  export function normalizeTypeValues(values = []) {
4
4
  const expanded = [];
@@ -9,7 +9,7 @@ export function normalizeTypeValues(values = []) {
9
9
  if (!value) continue;
10
10
  if (!USER_TYPES.has(value)) {
11
11
  throw new Error(
12
- `Unknown type "${value}". Expected one of: int, e2e, dal, load, pw, all.`
12
+ `Unknown type "${value}". Expected one of: int, e2e, scenario, dal, load, pw, all.`
13
13
  );
14
14
  }
15
15
  expanded.push(value);
@@ -25,7 +25,7 @@ export function normalizeTypeValues(values = []) {
25
25
  throw new Error(`"--type all" cannot be combined with other types.`);
26
26
  }
27
27
 
28
- const order = ["int", "e2e", "dal", "load", "pw", "all"];
28
+ const order = ["int", "e2e", "scenario", "dal", "load", "pw", "all"];
29
29
  return deduped.sort((left, right) => order.indexOf(left) - order.indexOf(right));
30
30
  }
31
31
 
@@ -52,7 +52,7 @@ export function parseSuiteSelectors(values = []) {
52
52
  const name = typeMatch[2].trim();
53
53
  if (!USER_TYPES.has(type) || type === "all") {
54
54
  throw new Error(
55
- `Unknown suite selector type "${type}". Expected one of: int, e2e, dal, load, pw.`
55
+ `Unknown suite selector type "${type}". Expected one of: int, e2e, scenario, dal, load, pw.`
56
56
  );
57
57
  }
58
58
  if (!name) {
@@ -11,14 +11,20 @@ import {
11
11
  describe("runner suite selection", () => {
12
12
  it("normalizes selected type values", () => {
13
13
  expect(normalizeTypeValues([])).toEqual(["all"]);
14
- expect(normalizeTypeValues(["int,e2e", "dal"])).toEqual(["int", "e2e", "dal"]);
14
+ expect(normalizeTypeValues(["int,e2e,scenario", "dal"])).toEqual([
15
+ "int",
16
+ "e2e",
17
+ "scenario",
18
+ "dal",
19
+ ]);
15
20
  expect(() => normalizeTypeValues(["all", "int"])).toThrow("cannot be combined");
16
21
  expect(() => normalizeTypeValues(["jest"])).toThrow("Unknown type");
17
22
  });
18
23
 
19
24
  it("parses suite selectors", () => {
20
- expect(parseSuiteSelectors(["auth,dal:queries"])).toEqual([
25
+ expect(parseSuiteSelectors(["auth,scenario:journeys,dal:queries"])).toEqual([
21
26
  { kind: "plain", name: "auth", raw: "auth" },
27
+ { kind: "typed", type: "scenario", name: "journeys", raw: "scenario:journeys" },
22
28
  { kind: "typed", type: "dal", name: "queries", raw: "dal:queries" },
23
29
  ]);
24
30
  expect(() => parseSuiteSelectors(["all:auth"])).toThrow("Unknown suite selector type");
@@ -36,6 +42,7 @@ describe("runner suite selection", () => {
36
42
 
37
43
  it("maps discovered suites to user-facing selection types", () => {
38
44
  expect(suiteSelectionType("integration", "k6")).toBe("int");
45
+ expect(suiteSelectionType("scenario", "k6")).toBe("scenario");
39
46
  expect(suiteSelectionType("e2e", "playwright")).toBe("pw");
40
47
  expect(suiteSelectionType("dal", "k6")).toBe("dal");
41
48
  });
@@ -23,16 +23,52 @@ const MODULE_RUNNER_ENTRY = path.join(
23
23
  "template-step-module-runner.mjs"
24
24
  );
25
25
 
26
- export async function runConfiguredSteps({ config, steps = [], env, labelPrefix, reporter = null }) {
26
+ export async function runConfiguredSteps({
27
+ config,
28
+ steps = [],
29
+ env,
30
+ labelPrefix,
31
+ reporter = null,
32
+ setupRegistry = null,
33
+ parentOperation = null,
34
+ }) {
27
35
  if (steps.length === 0) return;
28
36
  const resolvedToolchain = await resolveConfiguredToolchain(config);
29
37
  await announceResolvedToolchain(config, resolvedToolchain, reporter);
30
38
 
31
39
  for (const [index, step] of steps.entries()) {
32
40
  const label = `${labelPrefix}:${config.name}:${index + 1}`;
33
- if (reporter?.phaseStarted) reporter.phaseStarted(label);
34
- else console.log(`\n── ${label} ──`);
35
- await runConfiguredStep(config, step, env, resolvedToolchain);
41
+ const stepOperation = setupRegistry?.start({
42
+ config,
43
+ stage: label,
44
+ kind: "setup-step",
45
+ summary: summarizeConfiguredStep(step),
46
+ parentId: parentOperation?.id || parentOperation || null,
47
+ });
48
+ reporter?.phaseStarted?.(label);
49
+ try {
50
+ await runConfiguredStep(config, step, env, resolvedToolchain, {
51
+ reporter,
52
+ logRecord: stepOperation?._logRecord || null,
53
+ });
54
+ const finished = stepOperation
55
+ ? setupRegistry.finish(stepOperation, {
56
+ status: "passed",
57
+ summary: summarizeConfiguredStep(step),
58
+ })
59
+ : null;
60
+ if (finished) reporter?.setupOperationFinished?.(finished);
61
+ } catch (error) {
62
+ const finished = stepOperation
63
+ ? setupRegistry.finish(stepOperation, {
64
+ status: "failed",
65
+ summary: summarizeConfiguredStep(step),
66
+ error: error?.message || error,
67
+ })
68
+ : null;
69
+ if (finished) reporter?.setupOperationFinished?.(finished);
70
+ throw error;
71
+ }
36
72
  }
37
73
  }
38
74
 
@@ -65,22 +101,33 @@ export function resolveConfiguredPath(productDir, stepCwd, targetPath) {
65
101
  return path.resolve(resolveConfiguredCwd(productDir, stepCwd), targetPath);
66
102
  }
67
103
 
68
- async function runConfiguredStep(config, step, env, resolvedToolchain) {
104
+ async function runConfiguredStep(config, step, env, resolvedToolchain, options = {}) {
69
105
  const runtimeEnv = applyToolchainEnv(env, resolvedToolchain);
70
106
  const cwd = resolveConfiguredCwd(config.productDir, step.cwd);
107
+ const liveWriter =
108
+ options.reporter?.outputMode === "debug"
109
+ ? (line) => options.reporter.writeDebugLine?.(line)
110
+ : null;
71
111
 
72
112
  if (step.kind === "command") {
73
- await execaCommand(step.cmd, {
113
+ const child = execaCommand(step.cmd, {
74
114
  cwd,
75
115
  env: runtimeEnv,
76
- stdio: "inherit",
77
116
  shell: true,
117
+ stdout: "pipe",
118
+ stderr: "pipe",
119
+ reject: false,
120
+ });
121
+ await awaitCapturedProcess(child, {
122
+ livePrefix: `[${config.runtimeLabel || config.name}:${config.name}]`,
123
+ liveWriter,
124
+ logRecord: options.logRecord || null,
78
125
  });
79
126
  return;
80
127
  }
81
128
 
82
129
  if (step.kind === "sql-file") {
83
- await execa(
130
+ const child = execa(
84
131
  "psql",
85
132
  [
86
133
  runtimeEnv.DATABASE_URL,
@@ -93,9 +140,16 @@ async function runConfiguredStep(config, step, env, resolvedToolchain) {
93
140
  {
94
141
  cwd,
95
142
  env: runtimeEnv,
96
- stdio: "inherit",
143
+ stdout: "pipe",
144
+ stderr: "pipe",
145
+ reject: false,
97
146
  }
98
147
  );
148
+ await awaitCapturedProcess(child, {
149
+ livePrefix: `[${config.runtimeLabel || config.name}:${config.name}]`,
150
+ liveWriter,
151
+ logRecord: options.logRecord || null,
152
+ });
99
153
  return;
100
154
  }
101
155
 
@@ -116,15 +170,22 @@ async function runConfiguredStep(config, step, env, resolvedToolchain) {
116
170
  fs.writeFileSync(contextPath, JSON.stringify(context));
117
171
 
118
172
  try {
119
- await execa(
173
+ const child = execa(
120
174
  resolvedToolchain?.nodeExecutable || process.execPath,
121
175
  [MODULE_RUNNER_ENTRY, bundledModule.outputFile, exportName, contextPath],
122
176
  {
123
177
  cwd,
124
178
  env: runtimeEnv,
125
- stdio: "inherit",
179
+ stdout: "pipe",
180
+ stderr: "pipe",
181
+ reject: false,
126
182
  }
127
183
  );
184
+ await awaitCapturedProcess(child, {
185
+ livePrefix: `[${config.runtimeLabel || config.name}:${config.name}]`,
186
+ liveWriter,
187
+ logRecord: options.logRecord || null,
188
+ });
128
189
  } finally {
129
190
  fs.rmSync(contextPath, { force: true });
130
191
  }
@@ -199,3 +260,60 @@ function parseModuleSpecifier(specifier) {
199
260
  exportName: exportName || "default",
200
261
  };
201
262
  }
263
+
264
+ function summarizeConfiguredStep(step) {
265
+ if (step.kind === "command") return `command: ${String(step.cmd).trim()}`;
266
+ if (step.kind === "sql-file") return `sql: ${step.path}`;
267
+ if (step.kind === "module") return `module: ${step.specifier}`;
268
+ return step.kind;
269
+ }
270
+
271
+ async function awaitCapturedProcess(child, { livePrefix = "", liveWriter = null, logRecord = null } = {}) {
272
+ const drains = [
273
+ captureProcessOutput(child.stdout, "stdout", livePrefix, liveWriter, logRecord),
274
+ captureProcessOutput(child.stderr, "stderr", livePrefix, liveWriter, logRecord),
275
+ ];
276
+ const result = await child;
277
+ await Promise.all(drains);
278
+ if (result.exitCode !== 0) {
279
+ throw new Error(result.shortMessage || result.stderr || result.stdout || `Step failed with exit code ${result.exitCode}`);
280
+ }
281
+ }
282
+
283
+ function captureProcessOutput(stream, streamName, livePrefix, liveWriter, logRecord) {
284
+ if (!stream) return Promise.resolve();
285
+
286
+ let pending = "";
287
+ return new Promise((resolve) => {
288
+ let settled = false;
289
+ const settle = () => {
290
+ if (settled) return;
291
+ settled = true;
292
+ resolve();
293
+ };
294
+
295
+ stream.on("data", (chunk) => {
296
+ pending += chunk.toString();
297
+ const lines = pending.split(/\r?\n/);
298
+ pending = lines.pop() || "";
299
+ for (const line of lines) {
300
+ if (line.length === 0) continue;
301
+ if (logRecord) {
302
+ logRecord.stream.write(`${new Date().toISOString()} [${streamName}] ${line}\n`);
303
+ }
304
+ if (liveWriter) liveWriter(livePrefix ? `${livePrefix} ${line}` : line);
305
+ }
306
+ });
307
+ stream.on("end", () => {
308
+ if (pending.length > 0) {
309
+ if (logRecord) {
310
+ logRecord.stream.write(`${new Date().toISOString()} [${streamName}] ${pending}\n`);
311
+ }
312
+ if (liveWriter) liveWriter(livePrefix ? `${livePrefix} ${pending}` : pending);
313
+ }
314
+ settle();
315
+ });
316
+ stream.on("close", settle);
317
+ stream.on("error", settle);
318
+ });
319
+ }
@@ -2,7 +2,7 @@ import { formatError } from "./formatting.mjs";
2
2
  import { runDalTask, runHttpK6Task } from "./default-runtime-runner.mjs";
3
3
  import { runPlaywrightTask } from "./playwright-runner.mjs";
4
4
 
5
- const HTTP_K6_TYPES = new Set(["integration", "e2e", "load"]);
5
+ const HTTP_K6_TYPES = new Set(["integration", "e2e", "scenario", "load"]);
6
6
 
7
7
  export function createWorker(workerId, productDir) {
8
8
  return {
@@ -136,6 +136,15 @@ export function recordFailureDetail(detail) {
136
136
  existing.count += normalized.count;
137
137
  }
138
138
 
139
+ export function getFailureCollectionSnapshot() {
140
+ return {
141
+ phase: failureState.phase,
142
+ groupStack: [...failureState.groupStack],
143
+ failureCount: failureState.detailsByKey.size,
144
+ keys: [...failureState.detailsByKey.keys()].sort(),
145
+ };
146
+ }
147
+
139
148
  function createFailureState() {
140
149
  return {
141
150
  phase: "exec",
@@ -0,0 +1,234 @@
1
+ import { emitArtifact } from "./artifacts.js";
2
+ import { getFailureCollectionSnapshot, group } from "./checks.js";
3
+
4
+ const DEFAULT_SCENARIO_SEED = "default";
5
+
6
+ export function createScenarioRuntime(options = {}) {
7
+ const suiteSeed = normalizeSeed(options.seed);
8
+ const state = {
9
+ seed: suiteSeed,
10
+ scenarioName: null,
11
+ choices: {},
12
+ notes: {},
13
+ resources: [],
14
+ resourceState: new Map(),
15
+ steps: [],
16
+ };
17
+
18
+ return {
19
+ get seed() {
20
+ return state.seed;
21
+ },
22
+ get scenarioName() {
23
+ return state.scenarioName;
24
+ },
25
+ resource(name, factory, options = {}) {
26
+ return createScenarioResource(state, name, factory, options);
27
+ },
28
+ pick(name, choices) {
29
+ return pickChoice(state, name, choices);
30
+ },
31
+ maybe(name, probability = 0.5) {
32
+ return maybeChoice(state, name, probability);
33
+ },
34
+ choose(name, shapeOrChoices) {
35
+ return chooseScenario(state, name, shapeOrChoices);
36
+ },
37
+ note(name, value) {
38
+ const normalizedName = normalizeLabel(name, "note");
39
+ state.notes[normalizedName] = cloneForArtifact(value);
40
+ return value;
41
+ },
42
+ step(name, fn) {
43
+ return runScenarioStep(state, name, fn);
44
+ },
45
+ emitArtifact() {
46
+ emitScenarioArtifact(state);
47
+ },
48
+ snapshot() {
49
+ return buildScenarioArtifactPayload(state);
50
+ },
51
+ };
52
+ }
53
+
54
+ function createScenarioResource(state, name, factory, options = {}) {
55
+ const normalizedName = normalizeLabel(name, "resource");
56
+ if (typeof factory !== "function") {
57
+ throw new Error(`scenario.resource("${normalizedName}") requires a factory function`);
58
+ }
59
+
60
+ const scope = normalizeResourceScope(options.scope);
61
+ if (!state.resources.some((entry) => entry.name === normalizedName)) {
62
+ state.resources.push({ name: normalizedName, scope });
63
+ }
64
+
65
+ return {
66
+ get() {
67
+ if (scope === "step") {
68
+ return factory();
69
+ }
70
+
71
+ if (state.resourceState.has(normalizedName)) {
72
+ return state.resourceState.get(normalizedName);
73
+ }
74
+
75
+ const value = factory();
76
+ state.resourceState.set(normalizedName, value);
77
+ return value;
78
+ },
79
+ };
80
+ }
81
+
82
+ function pickChoice(state, name, choices) {
83
+ const normalizedName = normalizeLabel(name, "choice");
84
+ const values = Array.isArray(choices) ? choices : [];
85
+ if (values.length === 0) {
86
+ throw new Error(`scenario.pick("${normalizedName}") requires at least one choice`);
87
+ }
88
+
89
+ const index = chooseIndex(state.seed, state.scenarioName || "scenario", normalizedName, values.length);
90
+ const value = values[index];
91
+ state.choices[normalizedName] = cloneForArtifact(value);
92
+ return value;
93
+ }
94
+
95
+ function maybeChoice(state, name, probability = 0.5) {
96
+ const normalizedName = normalizeLabel(name, "choice");
97
+ const numericProbability = Number(probability);
98
+ if (!Number.isFinite(numericProbability) || numericProbability < 0 || numericProbability > 1) {
99
+ throw new Error(
100
+ `scenario.maybe("${normalizedName}") probability must be between 0 and 1`
101
+ );
102
+ }
103
+
104
+ const ratio = chooseRatio(state.seed, state.scenarioName || "scenario", normalizedName);
105
+ const value = ratio < numericProbability;
106
+ state.choices[normalizedName] = value;
107
+ return value;
108
+ }
109
+
110
+ function chooseScenario(state, name, shapeOrChoices) {
111
+ const normalizedName = normalizeLabel(name, "scenario");
112
+ state.scenarioName = normalizedName;
113
+
114
+ if (Array.isArray(shapeOrChoices)) {
115
+ const selected = pickChoice(state, `${normalizedName}:variant`, shapeOrChoices);
116
+ return selected;
117
+ }
118
+
119
+ if (!shapeOrChoices || typeof shapeOrChoices !== "object") {
120
+ throw new Error(`scenario.choose("${normalizedName}") requires an object or array`);
121
+ }
122
+
123
+ return shapeOrChoices;
124
+ }
125
+
126
+ function runScenarioStep(state, name, fn) {
127
+ const stepName = normalizeLabel(name, "unnamed step");
128
+ if (typeof fn !== "function") {
129
+ throw new Error(`scenario.step("${stepName}") requires a function`);
130
+ }
131
+
132
+ const entry = {
133
+ name: stepName,
134
+ startedAt: new Date().toISOString(),
135
+ durationMs: 0,
136
+ status: "passed",
137
+ };
138
+ state.steps.push(entry);
139
+
140
+ const startedAt = Date.now();
141
+ const before = getFailureCollectionSnapshot();
142
+
143
+ try {
144
+ return group(stepName, () => fn());
145
+ } catch (error) {
146
+ entry.status = "failed";
147
+ entry.error = error instanceof Error ? error.message : String(error);
148
+ throw error;
149
+ } finally {
150
+ const after = getFailureCollectionSnapshot();
151
+ entry.durationMs = Date.now() - startedAt;
152
+ entry.finishedAt = new Date().toISOString();
153
+ if (entry.status !== "failed" && after.failureCount > before.failureCount) {
154
+ entry.status = "failed";
155
+ entry.failureCount = after.failureCount - before.failureCount;
156
+ } else if (after.failureCount > before.failureCount) {
157
+ entry.failureCount = after.failureCount - before.failureCount;
158
+ }
159
+ }
160
+ }
161
+
162
+ function emitScenarioArtifact(state) {
163
+ const payload = buildScenarioArtifactPayload(state);
164
+ emitArtifact("scenario", payload, {
165
+ kind: "testkit.scenario",
166
+ summary: buildScenarioSummary(payload),
167
+ });
168
+ }
169
+
170
+ function buildScenarioArtifactPayload(state) {
171
+ return {
172
+ schemaVersion: 1,
173
+ seed: state.seed,
174
+ scenarioName: state.scenarioName,
175
+ choices: cloneForArtifact(state.choices),
176
+ notes: cloneForArtifact(state.notes),
177
+ resources: state.resources.map((entry) => ({ ...entry })),
178
+ steps: state.steps.map((entry) => ({ ...entry })),
179
+ };
180
+ }
181
+
182
+ function buildScenarioSummary(payload) {
183
+ const failedStep = payload.steps.find((entry) => entry.status === "failed");
184
+ if (failedStep) {
185
+ return `${payload.scenarioName || "scenario"} seed=${payload.seed} failed at ${failedStep.name}`;
186
+ }
187
+ return `${payload.scenarioName || "scenario"} seed=${payload.seed} (${payload.steps.length} steps)`;
188
+ }
189
+
190
+ function chooseIndex(seed, scenarioName, choiceName, length) {
191
+ if (length <= 1) return 0;
192
+ const value = hashString(`${seed}:${scenarioName}:${choiceName}`);
193
+ return value % length;
194
+ }
195
+
196
+ function chooseRatio(seed, scenarioName, choiceName) {
197
+ const value = hashString(`${seed}:${scenarioName}:${choiceName}`);
198
+ return value / 0xffffffff;
199
+ }
200
+
201
+ function hashString(input) {
202
+ let hash = 2166136261;
203
+ const source = String(input);
204
+ for (let index = 0; index < source.length; index += 1) {
205
+ hash ^= source.charCodeAt(index);
206
+ hash = Math.imul(hash, 16777619);
207
+ }
208
+ return hash >>> 0;
209
+ }
210
+
211
+ function normalizeSeed(value) {
212
+ if (value === undefined || value === null) return DEFAULT_SCENARIO_SEED;
213
+ const normalized = String(value).trim();
214
+ return normalized.length > 0 ? normalized : DEFAULT_SCENARIO_SEED;
215
+ }
216
+
217
+ function normalizeLabel(value, fallback) {
218
+ if (typeof value !== "string") return fallback;
219
+ const normalized = value.trim();
220
+ return normalized.length > 0 ? normalized : fallback;
221
+ }
222
+
223
+ function normalizeResourceScope(value) {
224
+ const normalized = normalizeLabel(value, "scenario");
225
+ if (normalized === "file") return "file";
226
+ if (normalized === "scenario") return "scenario";
227
+ if (normalized === "step") return "step";
228
+ throw new Error(`Unsupported scenario resource scope "${value}"`);
229
+ }
230
+
231
+ function cloneForArtifact(value) {
232
+ if (value === undefined) return null;
233
+ return JSON.parse(JSON.stringify(value));
234
+ }