@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.
- package/README.md +22 -0
- package/lib/bundler/index.mjs +1 -1
- package/lib/bundler/index.test.mjs +29 -0
- package/lib/cli/args.mjs +2 -2
- package/lib/cli/args.test.mjs +8 -2
- package/lib/cli/command-helpers.mjs +5 -1
- package/lib/cli/commands/artifacts.mjs +2 -2
- package/lib/cli/commands/logs.mjs +2 -2
- package/lib/cli/commands/run.mjs +2 -2
- package/lib/cli/commands/show.mjs +2 -2
- package/lib/cli/db.mjs +17 -2
- package/lib/cli/entrypoint.mjs +2 -1
- package/lib/cli/presentation/run-reporter.mjs +25 -0
- package/lib/cli/presentation/run-reporter.test.mjs +80 -0
- package/lib/cli/tui/watch-app.mjs +134 -18
- package/lib/cli/viewer.mjs +67 -0
- package/lib/config/discovery.mjs +1 -0
- package/lib/config/discovery.test.mjs +8 -0
- package/lib/database/index.mjs +85 -11
- package/lib/database/template-steps.mjs +45 -6
- package/lib/database/template-steps.test.mjs +43 -0
- package/lib/index.d.ts +58 -0
- package/lib/index.mjs +3 -0
- package/lib/runner/artifacts.mjs +16 -0
- package/lib/runner/default-runtime-runner.mjs +4 -1
- package/lib/runner/logs.mjs +54 -6
- package/lib/runner/orchestrator.mjs +67 -14
- package/lib/runner/planning.mjs +1 -1
- package/lib/runner/reporting.mjs +58 -2
- package/lib/runner/reporting.test.mjs +85 -2
- package/lib/runner/runtime-contexts.mjs +3 -3
- package/lib/runner/runtime-preparation.mjs +31 -0
- package/lib/runner/setup-operations.mjs +115 -0
- package/lib/runner/setup-operations.test.mjs +94 -0
- package/lib/runner/suite-selection.mjs +4 -4
- package/lib/runner/suite-selection.test.mjs +9 -2
- package/lib/runner/template-steps.mjs +129 -11
- package/lib/runner/worker-loop.mjs +1 -1
- package/lib/runtime-src/k6/checks.js +9 -0
- package/lib/runtime-src/k6/scenario-runtime.js +234 -0
- package/lib/runtime-src/k6/scenario-suite.js +179 -0
- package/lib/toolchains/index.mjs +0 -4
- 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([
|
|
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({
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|