@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 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`
@@ -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 = defineHttpSuite(...); export default 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
 
@@ -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(["int", "e2e", "dal"]);
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"],
@@ -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
 
@@ -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",
@@ -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;
@@ -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
@@ -4,6 +4,9 @@ export {
4
4
  export {
5
5
  defineHttpSuite,
6
6
  } from "./runtime-src/k6/suite.js";
7
+ export {
8
+ defineScenarioSuite,
9
+ } from "./runtime-src/k6/scenario-suite.js";
7
10
 
8
11
  export function createAuthAdapter({ setup, headers } = {}) {
9
12
  return {
@@ -95,7 +95,10 @@ export async function runDefaultRuntimeTask(
95
95
  env: buildTaskExecutionEnv(
96
96
  targetConfig,
97
97
  lease,
98
- buildFileTimeoutEnv(fileTimeoutSeconds, startedAt),
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
- fileNames: requestedFiles,
251
- shard: opts.shard || null,
252
- serviceFilter: opts.serviceFilter || null,
253
- metadata,
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
- fileNames: requestedFiles,
266
- shard: opts.shard || null,
267
- serviceFilter: opts.serviceFilter || null,
268
- metadata,
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(
@@ -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";
@@ -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(["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
  });
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.55",
3
+ "version": "0.1.56",
4
4
  "description": "CLI for discovering and running local HTTP, DAL, and Playwright test suites",
5
5
  "type": "module",
6
6
  "types": "./lib/index.d.ts",