@elench/testkit 0.1.55 → 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/run.mjs +2 -2
- package/lib/cli/entrypoint.mjs +2 -1
- package/lib/cli/viewer.mjs +30 -0
- package/lib/config/discovery.mjs +1 -0
- package/lib/config/discovery.test.mjs +8 -0
- package/lib/index.d.ts +58 -0
- package/lib/index.mjs +3 -0
- package/lib/runner/default-runtime-runner.mjs +4 -1
- package/lib/runner/orchestrator.mjs +14 -8
- package/lib/runner/planning.mjs +1 -1
- package/lib/runner/reporting.mjs +6 -0
- package/lib/runner/reporting.test.mjs +5 -0
- package/lib/runner/suite-selection.mjs +4 -4
- package/lib/runner/suite-selection.test.mjs +9 -2
- 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/package.json +1 -1
package/README.md
CHANGED
|
@@ -341,6 +341,27 @@ const suite = defineDalSuite(({ db }) => {
|
|
|
341
341
|
export default suite;
|
|
342
342
|
```
|
|
343
343
|
|
|
344
|
+
Scenario suites:
|
|
345
|
+
|
|
346
|
+
```ts
|
|
347
|
+
import { defineScenarioSuite } from "@elench/testkit";
|
|
348
|
+
|
|
349
|
+
const suite = defineScenarioSuite(({ rawReq, scenario }) => {
|
|
350
|
+
const plan = scenario.choose("journey", {
|
|
351
|
+
endpoint: scenario.pick("endpoint", ["/health", "/message"]),
|
|
352
|
+
includeHealthCheck: scenario.maybe("includeHealthCheck", 1),
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const selected = scenario.resource("selected-endpoint", () => rawReq("GET", plan.endpoint));
|
|
356
|
+
|
|
357
|
+
scenario.step("fetch selected endpoint", () => {
|
|
358
|
+
selected.get();
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
export default suite;
|
|
363
|
+
```
|
|
364
|
+
|
|
344
365
|
Low-level runtime primitives remain available:
|
|
345
366
|
|
|
346
367
|
```ts
|
|
@@ -375,6 +396,7 @@ Example layouts:
|
|
|
375
396
|
|
|
376
397
|
- `*.int.testkit.ts`
|
|
377
398
|
- `*.e2e.testkit.ts`
|
|
399
|
+
- `*.scenario.testkit.ts`
|
|
378
400
|
- `*.dal.testkit.ts`
|
|
379
401
|
- `*.load.testkit.ts`
|
|
380
402
|
- `*.pw.testkit.ts`
|
package/lib/bundler/index.mjs
CHANGED
|
@@ -132,7 +132,7 @@ function normalizeTestkitSuite(module) {
|
|
|
132
132
|
const candidate = module?.default;
|
|
133
133
|
if (!candidate || typeof candidate !== "object") {
|
|
134
134
|
throw new Error(
|
|
135
|
-
"testkit suite files must default-export the suite object returned by defineHttpSuite(...) or defineDalSuite(...). Example: export default defineHttpSuite(...) or const suite =
|
|
135
|
+
"testkit suite files must default-export the suite object returned by defineHttpSuite(...), defineScenarioSuite(...), or defineDalSuite(...). Example: export default defineHttpSuite(...) or const suite = defineScenarioSuite(...); export default suite;"
|
|
136
136
|
);
|
|
137
137
|
}
|
|
138
138
|
if (typeof candidate.exec !== "function") {
|
|
@@ -75,6 +75,35 @@ describe("runtime bundler", () => {
|
|
|
75
75
|
expect(bundled).toContain('import sql from "k6/x/sql"');
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
+
it("bundles scenario execution through the public package surface", async () => {
|
|
79
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-bundle-"));
|
|
80
|
+
cleanups.push(() => fs.rmSync(tmpDir, { force: true, recursive: true }));
|
|
81
|
+
|
|
82
|
+
const sourceFile = path.join(tmpDir, "scenario.js");
|
|
83
|
+
fs.writeFileSync(
|
|
84
|
+
sourceFile,
|
|
85
|
+
[
|
|
86
|
+
'import { defineScenarioSuite } from "@elench/testkit";',
|
|
87
|
+
"const suite = defineScenarioSuite(({ scenario }) => {",
|
|
88
|
+
" const plan = scenario.choose('journey', { endpoint: scenario.pick('endpoint', ['/a', '/b']) });",
|
|
89
|
+
" scenario.step('record choice', () => plan.endpoint);",
|
|
90
|
+
"});",
|
|
91
|
+
"export default suite;",
|
|
92
|
+
"",
|
|
93
|
+
].join("\n")
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const bundledFile = await bundleK6File({
|
|
97
|
+
productDir: tmpDir,
|
|
98
|
+
serviceName: "api",
|
|
99
|
+
sourceFile,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const bundled = fs.readFileSync(bundledFile, "utf8");
|
|
103
|
+
expect(bundled).toContain("defineScenarioSuite");
|
|
104
|
+
expect(bundled).toContain("createScenarioRuntime");
|
|
105
|
+
});
|
|
106
|
+
|
|
78
107
|
it("normalizes a default-exported suite object with no setup override", async () => {
|
|
79
108
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-bundle-"));
|
|
80
109
|
cleanups.push(() => fs.rmSync(tmpDir, { force: true, recursive: true }));
|
package/lib/cli/args.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
parseWorkersOption,
|
|
6
6
|
} from "../runner/execution-config.mjs";
|
|
7
7
|
|
|
8
|
-
export const POSITIONAL_TYPES = new Set(["int", "e2e", "dal", "load", "pw", "all"]);
|
|
8
|
+
export const POSITIONAL_TYPES = new Set(["int", "e2e", "scenario", "dal", "load", "pw", "all"]);
|
|
9
9
|
export const LIFECYCLE = new Set(["status", "destroy", "cleanup"]);
|
|
10
10
|
export const KNOWN_FAILURES_ACTIONS = new Set(["render", "validate"]);
|
|
11
11
|
export const RESERVED = new Set([...POSITIONAL_TYPES, ...LIFECYCLE]);
|
|
@@ -45,7 +45,7 @@ export function resolveCliSelection({ first, second, third }) {
|
|
|
45
45
|
} else if (first) {
|
|
46
46
|
throw new Error(
|
|
47
47
|
`Unknown argument "${first}". Expected a lifecycle command (status, destroy, cleanup) ` +
|
|
48
|
-
`or suite type (int, e2e, dal, load, pw, all).`
|
|
48
|
+
`or suite type (int, e2e, scenario, dal, load, pw, all).`
|
|
49
49
|
);
|
|
50
50
|
}
|
|
51
51
|
|
package/lib/cli/args.test.mjs
CHANGED
|
@@ -79,11 +79,17 @@ describe("cli-args", () => {
|
|
|
79
79
|
});
|
|
80
80
|
|
|
81
81
|
it("parses types and suite selectors", () => {
|
|
82
|
-
expect(parseTypeOption(["e2e,dal"], "int")).toEqual([
|
|
82
|
+
expect(parseTypeOption(["e2e,scenario,dal"], "int")).toEqual([
|
|
83
|
+
"int",
|
|
84
|
+
"e2e",
|
|
85
|
+
"scenario",
|
|
86
|
+
"dal",
|
|
87
|
+
]);
|
|
83
88
|
expect(() => parseTypeOption(["all", "int"])).toThrow("cannot be combined");
|
|
84
89
|
|
|
85
|
-
expect(parseSuiteOption(["auth,dal:queries"])).toEqual([
|
|
90
|
+
expect(parseSuiteOption(["auth,scenario:journeys,dal:queries"])).toEqual([
|
|
86
91
|
{ kind: "plain", name: "auth", raw: "auth" },
|
|
92
|
+
{ kind: "typed", type: "scenario", name: "journeys", raw: "scenario:journeys" },
|
|
87
93
|
{ kind: "typed", type: "dal", name: "queries", raw: "dal:queries" },
|
|
88
94
|
]);
|
|
89
95
|
});
|
|
@@ -26,7 +26,7 @@ export const runFlags = {
|
|
|
26
26
|
type: Flags.string({
|
|
27
27
|
char: "t",
|
|
28
28
|
multiple: true,
|
|
29
|
-
description: "Run specific suite type(s): int, e2e, dal, load, pw, all",
|
|
29
|
+
description: "Run specific suite type(s): int, e2e, scenario, dal, load, pw, all",
|
|
30
30
|
}),
|
|
31
31
|
suite: Flags.string({
|
|
32
32
|
char: "s",
|
|
@@ -47,6 +47,9 @@ export const runFlags = {
|
|
|
47
47
|
shard: Flags.string({
|
|
48
48
|
description: "Run only shard i of n at suite granularity",
|
|
49
49
|
}),
|
|
50
|
+
seed: Flags.string({
|
|
51
|
+
description: "Deterministic seed for scenario suites",
|
|
52
|
+
}),
|
|
50
53
|
"write-status": Flags.boolean({
|
|
51
54
|
description: "Write a deterministic testkit.status.json snapshot",
|
|
52
55
|
default: false,
|
|
@@ -111,6 +114,7 @@ export async function executeRunCommand(command, flags, positionalType = null) {
|
|
|
111
114
|
workers,
|
|
112
115
|
fileTimeoutSeconds,
|
|
113
116
|
shard,
|
|
117
|
+
scenarioSeed: flags.seed || null,
|
|
114
118
|
serviceFilter: flags.service || null,
|
|
115
119
|
reporter,
|
|
116
120
|
writeStatus: flags["write-status"],
|
package/lib/cli/commands/run.mjs
CHANGED
|
@@ -8,9 +8,9 @@ export default class RunCommand extends Command {
|
|
|
8
8
|
|
|
9
9
|
static args = {
|
|
10
10
|
type: Args.string({
|
|
11
|
-
description: "Optional suite type shortcut: int, e2e, dal, load, pw, all",
|
|
11
|
+
description: "Optional suite type shortcut: int, e2e, scenario, dal, load, pw, all",
|
|
12
12
|
required: false,
|
|
13
|
-
options: ["int", "e2e", "dal", "load", "pw", "all"],
|
|
13
|
+
options: ["int", "e2e", "scenario", "dal", "load", "pw", "all"],
|
|
14
14
|
}),
|
|
15
15
|
};
|
|
16
16
|
|
package/lib/cli/entrypoint.mjs
CHANGED
|
@@ -16,7 +16,7 @@ export function normalizeCliArgs(argv) {
|
|
|
16
16
|
"--version",
|
|
17
17
|
"-v",
|
|
18
18
|
]);
|
|
19
|
-
const runTypeShortcuts = new Set(["int", "e2e", "dal", "load", "pw", "all"]);
|
|
19
|
+
const runTypeShortcuts = new Set(["int", "e2e", "scenario", "dal", "load", "pw", "all"]);
|
|
20
20
|
const valueFlags = new Set([
|
|
21
21
|
"--dir",
|
|
22
22
|
"--service",
|
|
@@ -26,6 +26,7 @@ export function normalizeCliArgs(argv) {
|
|
|
26
26
|
"--workers",
|
|
27
27
|
"--file-timeout-seconds",
|
|
28
28
|
"--shard",
|
|
29
|
+
"--seed",
|
|
29
30
|
"--input",
|
|
30
31
|
"--output",
|
|
31
32
|
"--status",
|
package/lib/cli/viewer.mjs
CHANGED
|
@@ -190,6 +190,9 @@ export function formatArtifactPreview(payload, maxLines = 6) {
|
|
|
190
190
|
if (payload.kind === "agentic-query") {
|
|
191
191
|
return formatAgenticArtifact(payload, maxLines);
|
|
192
192
|
}
|
|
193
|
+
if (payload.kind === "testkit.scenario") {
|
|
194
|
+
return formatScenarioArtifact(payload, maxLines);
|
|
195
|
+
}
|
|
193
196
|
if (payload.kind === "testkit.http-traces") {
|
|
194
197
|
return formatHttpTraceArtifact(payload, maxLines);
|
|
195
198
|
}
|
|
@@ -230,12 +233,39 @@ function formatHttpTraceArtifact(payload, maxLines) {
|
|
|
230
233
|
return lines;
|
|
231
234
|
}
|
|
232
235
|
|
|
236
|
+
function formatScenarioArtifact(payload, maxLines) {
|
|
237
|
+
const artifact = payload.data || {};
|
|
238
|
+
const lines = [];
|
|
239
|
+
if (artifact.scenarioName) lines.push(`Scenario: ${artifact.scenarioName}`);
|
|
240
|
+
if (artifact.seed) lines.push(`Seed: ${artifact.seed}`);
|
|
241
|
+
const choiceEntries = Object.entries(artifact.choices || {});
|
|
242
|
+
if (choiceEntries.length > 0) {
|
|
243
|
+
lines.push(
|
|
244
|
+
`Choices: ${choiceEntries
|
|
245
|
+
.map(([key, value]) => `${key}=${formatScenarioChoiceValue(value)}`)
|
|
246
|
+
.join(", ")}`
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
const failedStep = (artifact.steps || []).find((step) => step.status === "failed");
|
|
250
|
+
if (failedStep) {
|
|
251
|
+
lines.push(`Failed Step: ${failedStep.name}`);
|
|
252
|
+
} else if (Array.isArray(artifact.steps) && artifact.steps.length > 0) {
|
|
253
|
+
lines.push(`Steps: ${artifact.steps.map((step) => step.name).join(" -> ")}`);
|
|
254
|
+
}
|
|
255
|
+
return lines.slice(0, maxLines);
|
|
256
|
+
}
|
|
257
|
+
|
|
233
258
|
function rankFailureDetails(details) {
|
|
234
259
|
return [...(Array.isArray(details) ? details : [])].sort((left, right) => {
|
|
235
260
|
return failureDetailRank(left) - failureDetailRank(right) || String(left?.key || "").localeCompare(String(right?.key || ""));
|
|
236
261
|
});
|
|
237
262
|
}
|
|
238
263
|
|
|
264
|
+
function formatScenarioChoiceValue(value) {
|
|
265
|
+
if (typeof value === "string") return value;
|
|
266
|
+
return JSON.stringify(value);
|
|
267
|
+
}
|
|
268
|
+
|
|
239
269
|
function failureDetailRank(detail) {
|
|
240
270
|
if (detail?.kind === "http-assertion") return 1;
|
|
241
271
|
if (detail?.request && detail?.response) return 2;
|
package/lib/config/discovery.mjs
CHANGED
|
@@ -5,6 +5,7 @@ const TESTKIT_DIRNAME = "__testkit__";
|
|
|
5
5
|
const DISCOVERY_RULES = [
|
|
6
6
|
{ suffix: ".int.testkit.ts", type: "integration", framework: "k6" },
|
|
7
7
|
{ suffix: ".e2e.testkit.ts", type: "e2e", framework: "k6" },
|
|
8
|
+
{ suffix: ".scenario.testkit.ts", type: "scenario", framework: "k6" },
|
|
8
9
|
{ suffix: ".dal.testkit.ts", type: "dal", framework: "k6" },
|
|
9
10
|
{ suffix: ".load.testkit.ts", type: "load", framework: "k6" },
|
|
10
11
|
{ suffix: ".pw.testkit.ts", type: "e2e", framework: "playwright" },
|
|
@@ -19,6 +19,7 @@ describe("filesystem-discovery", () => {
|
|
|
19
19
|
|
|
20
20
|
writeFile(productDir, "src/api/routes/__testkit__/auth/me.int.testkit.ts");
|
|
21
21
|
writeFile(productDir, "src/api/routes/__testkit__/health/ready.int.testkit.ts");
|
|
22
|
+
writeFile(productDir, "src/api/routes/__testkit__/journeys/smoke.scenario.testkit.ts");
|
|
22
23
|
writeFile(productDir, "frontend/app/__testkit__/homepage/homepage.pw.testkit.ts");
|
|
23
24
|
|
|
24
25
|
const suites = discoverSuites(productDir, {
|
|
@@ -46,6 +47,13 @@ describe("filesystem-discovery", () => {
|
|
|
46
47
|
framework: "k6",
|
|
47
48
|
},
|
|
48
49
|
]);
|
|
50
|
+
expect(suites.api.scenario).toEqual([
|
|
51
|
+
{
|
|
52
|
+
name: "journeys",
|
|
53
|
+
files: ["src/api/routes/__testkit__/journeys/smoke.scenario.testkit.ts"],
|
|
54
|
+
framework: "k6",
|
|
55
|
+
},
|
|
56
|
+
]);
|
|
49
57
|
expect(suites.frontend.e2e).toEqual([
|
|
50
58
|
{
|
|
51
59
|
name: "homepage",
|
package/lib/index.d.ts
CHANGED
|
@@ -38,6 +38,55 @@ export interface HttpSuiteContext<TSetup = unknown> {
|
|
|
38
38
|
session: TSetup | null;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
export interface ScenarioStepResult {
|
|
42
|
+
name: string;
|
|
43
|
+
status: "passed" | "failed";
|
|
44
|
+
startedAt?: string;
|
|
45
|
+
finishedAt?: string;
|
|
46
|
+
durationMs?: number;
|
|
47
|
+
failureCount?: number;
|
|
48
|
+
error?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ScenarioResource<TValue = unknown> {
|
|
52
|
+
get(): TValue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ScenarioRuntime {
|
|
56
|
+
readonly seed: string;
|
|
57
|
+
readonly scenarioName: string | null;
|
|
58
|
+
choose<TChoice extends unknown[]>(
|
|
59
|
+
name: string,
|
|
60
|
+
choices: TChoice
|
|
61
|
+
): TChoice[number];
|
|
62
|
+
choose<TShape extends Record<string, unknown>>(
|
|
63
|
+
name: string,
|
|
64
|
+
shape: TShape
|
|
65
|
+
): TShape;
|
|
66
|
+
maybe(name: string, probability?: number): boolean;
|
|
67
|
+
note<TValue = unknown>(name: string, value: TValue): TValue;
|
|
68
|
+
pick<TChoice extends unknown[]>(name: string, choices: TChoice): TChoice[number];
|
|
69
|
+
resource<TValue = unknown>(
|
|
70
|
+
name: string,
|
|
71
|
+
factory: () => TValue,
|
|
72
|
+
options?: { scope?: "file" | "scenario" | "step" }
|
|
73
|
+
): ScenarioResource<TValue>;
|
|
74
|
+
step<TValue = unknown>(name: string, fn: () => TValue): TValue;
|
|
75
|
+
snapshot(): {
|
|
76
|
+
schemaVersion: number;
|
|
77
|
+
seed: string;
|
|
78
|
+
scenarioName: string | null;
|
|
79
|
+
choices: Record<string, unknown>;
|
|
80
|
+
notes: Record<string, unknown>;
|
|
81
|
+
resources: Array<{ name: string; scope: "file" | "scenario" | "step" }>;
|
|
82
|
+
steps: ScenarioStepResult[];
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface ScenarioSuiteContext<TSetup = unknown> extends HttpSuiteContext<TSetup> {
|
|
87
|
+
scenario: ScenarioRuntime;
|
|
88
|
+
}
|
|
89
|
+
|
|
41
90
|
export interface HttpSuiteConfig<TSetup = unknown> {
|
|
42
91
|
auth?: AuthAdapter<TSetup> | null;
|
|
43
92
|
env?: RuntimeEnv;
|
|
@@ -68,6 +117,15 @@ export declare function defineHttpSuite<TSetup = unknown>(
|
|
|
68
117
|
run: (context: HttpSuiteContext<TSetup>) => unknown
|
|
69
118
|
): TestkitSuite<TSetup>;
|
|
70
119
|
|
|
120
|
+
export declare function defineScenarioSuite<TSetup = unknown>(
|
|
121
|
+
run: (context: ScenarioSuiteContext<TSetup>) => unknown
|
|
122
|
+
): TestkitSuite<TSetup>;
|
|
123
|
+
|
|
124
|
+
export declare function defineScenarioSuite<TSetup = unknown>(
|
|
125
|
+
config: HttpSuiteConfig<TSetup>,
|
|
126
|
+
run: (context: ScenarioSuiteContext<TSetup>) => unknown
|
|
127
|
+
): TestkitSuite<TSetup>;
|
|
128
|
+
|
|
71
129
|
export declare function defineDalSuite<TSetup = unknown>(
|
|
72
130
|
run: (context: DalSuiteContext<TSetup>) => unknown
|
|
73
131
|
): TestkitSuite<TSetup>;
|
package/lib/index.mjs
CHANGED
|
@@ -95,7 +95,10 @@ export async function runDefaultRuntimeTask(
|
|
|
95
95
|
env: buildTaskExecutionEnv(
|
|
96
96
|
targetConfig,
|
|
97
97
|
lease,
|
|
98
|
-
|
|
98
|
+
{
|
|
99
|
+
...buildFileTimeoutEnv(fileTimeoutSeconds, startedAt),
|
|
100
|
+
...(task.scenarioSeed ? { TESTKIT_SCENARIO_SEED: task.scenarioSeed } : {}),
|
|
101
|
+
},
|
|
99
102
|
process.env
|
|
100
103
|
),
|
|
101
104
|
reject: false,
|
|
@@ -146,6 +146,7 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
146
146
|
fileNames: requestedFiles,
|
|
147
147
|
shard: opts.shard || null,
|
|
148
148
|
serviceFilter: opts.serviceFilter || null,
|
|
149
|
+
scenarioSeed: opts.scenarioSeed || null,
|
|
149
150
|
metadata,
|
|
150
151
|
summarizeDbBackend,
|
|
151
152
|
serviceLogs: logRegistry.listServiceLogs(),
|
|
@@ -173,6 +174,9 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
173
174
|
const timings = loadTimings(productDir);
|
|
174
175
|
const graphs = buildRuntimeGraphs(executedPlans);
|
|
175
176
|
const queue = buildTaskQueue(executedPlans, graphs, timings);
|
|
177
|
+
for (const task of queue) {
|
|
178
|
+
task.scenarioSeed = opts.scenarioSeed || null;
|
|
179
|
+
}
|
|
176
180
|
workerCount = Math.max(1, Math.min(execution.workers, queue.length));
|
|
177
181
|
runtimeInstanceCount = graphs.reduce((sum, graph) => sum + graph.instanceCount, 0);
|
|
178
182
|
const workers = Array.from({ length: workerCount }, (_unused, index) =>
|
|
@@ -247,10 +251,11 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
247
251
|
runtimeStats,
|
|
248
252
|
typeValues,
|
|
249
253
|
suiteSelectors,
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
+
fileNames: requestedFiles,
|
|
255
|
+
shard: opts.shard || null,
|
|
256
|
+
serviceFilter: opts.serviceFilter || null,
|
|
257
|
+
scenarioSeed: opts.scenarioSeed || null,
|
|
258
|
+
metadata,
|
|
254
259
|
summarizeDbBackend,
|
|
255
260
|
serviceLogs: logRegistry.listServiceLogs(),
|
|
256
261
|
setupLogs: logRegistry.listSetupLogs(),
|
|
@@ -262,10 +267,11 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
262
267
|
results,
|
|
263
268
|
typeValues,
|
|
264
269
|
suiteSelectors,
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
270
|
+
fileNames: requestedFiles,
|
|
271
|
+
shard: opts.shard || null,
|
|
272
|
+
serviceFilter: opts.serviceFilter || null,
|
|
273
|
+
scenarioSeed: opts.scenarioSeed || null,
|
|
274
|
+
metadata,
|
|
269
275
|
})
|
|
270
276
|
: null;
|
|
271
277
|
const enrichedArtifacts = applyKnownFailuresToArtifacts(
|
package/lib/runner/planning.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
suiteSelectionType,
|
|
6
6
|
} from "./suite-selection.mjs";
|
|
7
7
|
|
|
8
|
-
const TYPE_ORDER = ["dal", "integration", "e2e", "load"];
|
|
8
|
+
const TYPE_ORDER = ["dal", "integration", "e2e", "scenario", "load"];
|
|
9
9
|
|
|
10
10
|
export function taskNeedsLocalRuntime(task) {
|
|
11
11
|
return task.type !== "dal";
|
package/lib/runner/reporting.mjs
CHANGED
|
@@ -8,6 +8,7 @@ export function buildStatusArtifact({
|
|
|
8
8
|
fileNames,
|
|
9
9
|
shard,
|
|
10
10
|
serviceFilter,
|
|
11
|
+
scenarioSeed,
|
|
11
12
|
metadata,
|
|
12
13
|
}) {
|
|
13
14
|
const executedResults = results.filter((result) => !result.skipped);
|
|
@@ -68,6 +69,7 @@ export function buildStatusArtifact({
|
|
|
68
69
|
fileNames: [...(fileNames || [])].sort(),
|
|
69
70
|
shard: shard || null,
|
|
70
71
|
serviceFilter: serviceFilter || null,
|
|
72
|
+
scenarioSeed: scenarioSeed || null,
|
|
71
73
|
};
|
|
72
74
|
scope.isFullRun =
|
|
73
75
|
scope.types.length === 1 &&
|
|
@@ -109,6 +111,7 @@ export function buildRunArtifact({
|
|
|
109
111
|
fileNames,
|
|
110
112
|
shard,
|
|
111
113
|
serviceFilter,
|
|
114
|
+
scenarioSeed,
|
|
112
115
|
metadata,
|
|
113
116
|
summarizeDbBackend,
|
|
114
117
|
serviceLogs = [],
|
|
@@ -156,6 +159,7 @@ export function buildRunArtifact({
|
|
|
156
159
|
fileNames,
|
|
157
160
|
shard,
|
|
158
161
|
serviceFilter,
|
|
162
|
+
scenarioSeed: scenarioSeed || null,
|
|
159
163
|
testkitVersion: metadata.testkitVersion,
|
|
160
164
|
},
|
|
161
165
|
summary: {
|
|
@@ -224,6 +228,7 @@ export function buildLiveRunArtifact({
|
|
|
224
228
|
fileNames,
|
|
225
229
|
shard,
|
|
226
230
|
serviceFilter,
|
|
231
|
+
scenarioSeed,
|
|
227
232
|
metadata,
|
|
228
233
|
summarizeDbBackend,
|
|
229
234
|
serviceLogs = [],
|
|
@@ -244,6 +249,7 @@ export function buildLiveRunArtifact({
|
|
|
244
249
|
fileNames,
|
|
245
250
|
shard,
|
|
246
251
|
serviceFilter,
|
|
252
|
+
scenarioSeed,
|
|
247
253
|
metadata,
|
|
248
254
|
summarizeDbBackend,
|
|
249
255
|
serviceLogs,
|
|
@@ -62,6 +62,7 @@ describe("runner reporting", () => {
|
|
|
62
62
|
fileNames: [],
|
|
63
63
|
shard: null,
|
|
64
64
|
serviceFilter: null,
|
|
65
|
+
scenarioSeed: "demo-seed",
|
|
65
66
|
metadata: {
|
|
66
67
|
git: {
|
|
67
68
|
branch: "main",
|
|
@@ -84,6 +85,7 @@ describe("runner reporting", () => {
|
|
|
84
85
|
fileTimeoutSeconds: 60,
|
|
85
86
|
workerCount: 1,
|
|
86
87
|
runtimeInstanceCount: 2,
|
|
88
|
+
scenarioSeed: "demo-seed",
|
|
87
89
|
});
|
|
88
90
|
expect(artifact.summary.services).toEqual({
|
|
89
91
|
total: 1,
|
|
@@ -220,6 +222,7 @@ describe("runner reporting", () => {
|
|
|
220
222
|
fileNames: ["tests/api/integration/b.int.testkit.ts"],
|
|
221
223
|
shard: null,
|
|
222
224
|
serviceFilter: "api",
|
|
225
|
+
scenarioSeed: "demo-seed",
|
|
223
226
|
metadata: {
|
|
224
227
|
git: {
|
|
225
228
|
branch: "main",
|
|
@@ -247,6 +250,7 @@ describe("runner reporting", () => {
|
|
|
247
250
|
fileNames: ["tests/api/integration/b.int.testkit.ts"],
|
|
248
251
|
shard: null,
|
|
249
252
|
serviceFilter: "api",
|
|
253
|
+
scenarioSeed: "demo-seed",
|
|
250
254
|
isFullRun: false,
|
|
251
255
|
},
|
|
252
256
|
summary: {
|
|
@@ -291,6 +295,7 @@ describe("runner reporting", () => {
|
|
|
291
295
|
fileNames: [],
|
|
292
296
|
shard: null,
|
|
293
297
|
serviceFilter: null,
|
|
298
|
+
scenarioSeed: null,
|
|
294
299
|
metadata: {
|
|
295
300
|
git: {
|
|
296
301
|
branch: "main",
|
|
@@ -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
|
});
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { fail } from "k6";
|
|
2
|
+
import {
|
|
3
|
+
defaultOptions,
|
|
4
|
+
emitFailureCollectionArtifact,
|
|
5
|
+
recordFailureDetail,
|
|
6
|
+
recordRuntimeFailure,
|
|
7
|
+
startFailureCollection,
|
|
8
|
+
} from "./checks.js";
|
|
9
|
+
import {
|
|
10
|
+
createHttpClient,
|
|
11
|
+
emitHttpTraceCollectionArtifact,
|
|
12
|
+
getEnv,
|
|
13
|
+
startHttpTraceCollection,
|
|
14
|
+
} from "./http.js";
|
|
15
|
+
import {
|
|
16
|
+
clearRuntimeContext,
|
|
17
|
+
registerRuntimeContext,
|
|
18
|
+
resolveHttpProfile,
|
|
19
|
+
} from "../../setup/runtime.mjs";
|
|
20
|
+
import { createScenarioRuntime } from "./scenario-runtime.js";
|
|
21
|
+
|
|
22
|
+
export function defineScenarioSuite(configOrRun, maybeRun) {
|
|
23
|
+
const { config, run } = normalizeSuiteArgs(configOrRun, maybeRun);
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
get options() {
|
|
27
|
+
return mergeProfileConfig(config).options || defaultOptions;
|
|
28
|
+
},
|
|
29
|
+
setup() {
|
|
30
|
+
const resolved = resolveRuntimeConfig(config);
|
|
31
|
+
startFailureCollection("setup");
|
|
32
|
+
startHttpTraceCollection("setup");
|
|
33
|
+
try {
|
|
34
|
+
registerRuntimeContext({ env: resolved.env, http: resolved.client.rawHttp || null });
|
|
35
|
+
if (typeof resolved.auth?.setup !== "function") return null;
|
|
36
|
+
return resolved.auth.setup({ env: resolved.env });
|
|
37
|
+
} catch (error) {
|
|
38
|
+
recordFailureDetail(buildRuntimeExceptionDetail("setup", error));
|
|
39
|
+
recordRuntimeFailure();
|
|
40
|
+
fail(formatFatalSuiteError("setup", error));
|
|
41
|
+
} finally {
|
|
42
|
+
emitFailureCollectionArtifact();
|
|
43
|
+
emitHttpTraceCollectionArtifact();
|
|
44
|
+
clearRuntimeContext();
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
exec(setupData) {
|
|
48
|
+
const resolved = resolveRuntimeConfig(config);
|
|
49
|
+
const scenario = createScenarioRuntime({
|
|
50
|
+
seed: resolved.env.rawEnv.TESTKIT_SCENARIO_SEED,
|
|
51
|
+
});
|
|
52
|
+
startFailureCollection("exec");
|
|
53
|
+
startHttpTraceCollection("exec");
|
|
54
|
+
try {
|
|
55
|
+
registerRuntimeContext({ env: resolved.env, http: resolved.client.rawHttp || null });
|
|
56
|
+
return run({
|
|
57
|
+
env: resolved.env,
|
|
58
|
+
req: resolved.client.request,
|
|
59
|
+
rawReq: resolved.client.raw,
|
|
60
|
+
getWithHeaders: resolved.client.getWithHeaders,
|
|
61
|
+
setupData,
|
|
62
|
+
session: setupData,
|
|
63
|
+
scenario,
|
|
64
|
+
});
|
|
65
|
+
} catch (error) {
|
|
66
|
+
recordFailureDetail(buildRuntimeExceptionDetail("exec", error));
|
|
67
|
+
recordRuntimeFailure();
|
|
68
|
+
fail(formatFatalSuiteError("exec", error));
|
|
69
|
+
} finally {
|
|
70
|
+
scenario.emitArtifact();
|
|
71
|
+
emitFailureCollectionArtifact();
|
|
72
|
+
emitHttpTraceCollectionArtifact();
|
|
73
|
+
clearRuntimeContext();
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizeSuiteArgs(configOrRun, maybeRun) {
|
|
80
|
+
if (typeof configOrRun === "function") {
|
|
81
|
+
return { config: {}, run: configOrRun };
|
|
82
|
+
}
|
|
83
|
+
if (typeof maybeRun !== "function") {
|
|
84
|
+
throw new Error("suite factory requires a run callback");
|
|
85
|
+
}
|
|
86
|
+
return { config: configOrRun || {}, run: maybeRun };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function callHeaders(builder, setupData, env) {
|
|
90
|
+
if (typeof builder !== "function") return {};
|
|
91
|
+
return builder(setupData, { env }) || {};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function mergeProfileConfig(config) {
|
|
95
|
+
if (!config?.profile) return config || {};
|
|
96
|
+
|
|
97
|
+
const profile = resolveHttpProfile(config.profile) || {};
|
|
98
|
+
return {
|
|
99
|
+
...profile,
|
|
100
|
+
...config,
|
|
101
|
+
auth: config.auth ?? profile.auth ?? null,
|
|
102
|
+
headers: config.headers ?? profile.headers,
|
|
103
|
+
rawHeaders: config.rawHeaders ?? profile.rawHeaders,
|
|
104
|
+
options: config.options ?? profile.options,
|
|
105
|
+
env: config.env ?? profile.env,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function resolveRuntimeConfig(config) {
|
|
110
|
+
const resolvedConfig = mergeProfileConfig(config);
|
|
111
|
+
const env = {
|
|
112
|
+
...(resolvedConfig.env || getEnv()),
|
|
113
|
+
rawEnv: __ENV,
|
|
114
|
+
};
|
|
115
|
+
const auth = resolvedConfig.auth || null;
|
|
116
|
+
const client = createHttpClient({
|
|
117
|
+
baseUrl: env.BASE,
|
|
118
|
+
routeHeaders: env.routeParams,
|
|
119
|
+
getHeaders(setupData) {
|
|
120
|
+
return {
|
|
121
|
+
...callHeaders(auth?.headers, setupData, env),
|
|
122
|
+
...callHeaders(resolvedConfig.headers, setupData, env),
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
getRawHeaders(setupData) {
|
|
126
|
+
return callHeaders(resolvedConfig.rawHeaders, setupData, env);
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
resolvedConfig,
|
|
132
|
+
env,
|
|
133
|
+
auth,
|
|
134
|
+
client,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function formatFatalSuiteError(phase, error) {
|
|
139
|
+
if (error instanceof Error) {
|
|
140
|
+
return `Uncaught testkit suite error during ${phase}: ${error.message}`;
|
|
141
|
+
}
|
|
142
|
+
return `Uncaught testkit suite error during ${phase}: ${String(error)}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function buildRuntimeExceptionDetail(phase, error) {
|
|
146
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
147
|
+
const stack = error instanceof Error && typeof error.stack === "string" ? error.stack : "";
|
|
148
|
+
const location = extractLocationFromStack(stack);
|
|
149
|
+
return {
|
|
150
|
+
kind: "runtime-exception",
|
|
151
|
+
key: location
|
|
152
|
+
? `${location.path}:${location.line}:${location.column}`
|
|
153
|
+
: `runtime-exception:${phase}:${message}`,
|
|
154
|
+
title: "Uncaught runtime exception",
|
|
155
|
+
message: `Uncaught testkit suite error during ${phase}: ${message}`,
|
|
156
|
+
location,
|
|
157
|
+
stack,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function extractLocationFromStack(stack) {
|
|
162
|
+
if (!stack) return null;
|
|
163
|
+
const matches = [...String(stack).matchAll(/(file:\/\/[^\s)]+|\/[^\s):]+):(\d+):(\d+)/g)].map(
|
|
164
|
+
(match) => ({
|
|
165
|
+
path: normalizeStackPath(match[1]),
|
|
166
|
+
line: Number(match[2]),
|
|
167
|
+
column: Number(match[3]),
|
|
168
|
+
})
|
|
169
|
+
);
|
|
170
|
+
return matches[0] || null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function normalizeStackPath(rawPath) {
|
|
174
|
+
if (typeof rawPath !== "string") return rawPath;
|
|
175
|
+
if (rawPath.startsWith("file://")) {
|
|
176
|
+
return rawPath.slice("file://".length);
|
|
177
|
+
}
|
|
178
|
+
return rawPath;
|
|
179
|
+
}
|