@elench/testkit 0.1.38 → 0.1.40

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
@@ -21,8 +21,14 @@ npx @elench/testkit --type e2e
21
21
  npx @elench/testkit --type int,e2e,dal
22
22
  npx @elench/testkit --type pw
23
23
 
24
- # Parallelize with isolated worker stacks
25
- npx @elench/testkit --jobs 3
24
+ # Shared local stack with parallel workers
25
+ npx @elench/testkit --workers 8 --stack-mode shared
26
+
27
+ # One file-level wall clock budget for every suite file
28
+ npx @elench/testkit --file-timeout-seconds 60
29
+
30
+ # Two reusable local stacks for browser-heavy suites
31
+ npx @elench/testkit --workers 6 --stack-mode pooled --stack-count 2
26
32
 
27
33
  # Run a deterministic shard
28
34
  npx @elench/testkit --shard 1/3
@@ -61,6 +67,12 @@ import {
61
67
  } from "@elench/testkit/setup";
62
68
 
63
69
  export default defineTestkitSetup({
70
+ execution: {
71
+ workers: 8,
72
+ fileTimeoutSeconds: 60,
73
+ stackMode: "shared",
74
+ stackCount: 1,
75
+ },
64
76
  services: {
65
77
  api: service({
66
78
  ...tsxService({
@@ -109,6 +121,8 @@ export default defineTestkitSetup({
109
121
  `testkit.setup.ts` is optional for simple repos, but it is the primary escape hatch
110
122
  for:
111
123
 
124
+ - worker and stack topology
125
+ - per-file wall clock timeout budget
112
126
  - multi-service graphs
113
127
  - local DB configuration
114
128
  - migrate / seed commands
@@ -159,7 +173,20 @@ export default suite;
159
173
  Low-level runtime primitives remain available:
160
174
 
161
175
  ```ts
162
- import { check, group, http } from "@elench/testkit/runtime";
176
+ import { check, group, http, waitFor } from "@elench/testkit/runtime";
177
+ ```
178
+
179
+ `waitFor()` consumes the file budget configured by `execution.fileTimeoutSeconds`.
180
+ Consumers should not set local timeout values in test files.
181
+
182
+ ```ts
183
+ import { waitFor } from "@elench/testkit/runtime";
184
+
185
+ const response = waitFor(
186
+ () => req("GET", "/api/v1/jobs/123", setupData),
187
+ (res) => JSON.parse(res.body).data?.status === "completed",
188
+ { description: "job 123 to complete" }
189
+ );
163
190
  ```
164
191
 
165
192
  ## Discovery
@@ -197,7 +224,7 @@ Suite names are inferred from the colocated path:
197
224
  services that define `database: localDatabase(...)`.
198
225
 
199
226
  - template databases are cached
200
- - worker databases are cloned from templates
227
+ - stack databases are cloned from templates
201
228
  - template fingerprints are derived automatically from env files, migrate/seed
202
229
  config, and repo contents
203
230
 
package/lib/cli/args.mjs CHANGED
@@ -1,5 +1,11 @@
1
1
  import path from "path";
2
2
  import { normalizeTypeValues, parseSuiteSelectors } from "../runner/suite-selection.mjs";
3
+ import {
4
+ parseFileTimeoutOption,
5
+ parseStackCountOption,
6
+ parseStackModeOption,
7
+ parseWorkersOption,
8
+ } from "../runner/execution-config.mjs";
3
9
 
4
10
  export const POSITIONAL_TYPES = new Set(["int", "e2e", "dal", "load", "pw", "all"]);
5
11
  export const LIFECYCLE = new Set(["status", "destroy", "cleanup"]);
@@ -40,13 +46,12 @@ export function parseSuiteOption(values) {
40
46
  return parseSuiteSelectors(input);
41
47
  }
42
48
 
43
- export function parseJobsOption(value) {
44
- const jobs = Number.parseInt(String(value), 10);
45
- if (!Number.isInteger(jobs) || jobs <= 0) {
46
- throw new Error(`Invalid --jobs value "${value}". Expected a positive integer.`);
47
- }
48
- return jobs;
49
- }
49
+ export {
50
+ parseFileTimeoutOption,
51
+ parseWorkersOption,
52
+ parseStackModeOption,
53
+ parseStackCountOption,
54
+ };
50
55
 
51
56
  export function parseShardOption(value) {
52
57
  if (!value) return null;
@@ -1,9 +1,12 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
- parseJobsOption,
3
+ parseFileTimeoutOption,
4
4
  parseShardOption,
5
+ parseStackCountOption,
6
+ parseStackModeOption,
5
7
  parseSuiteOption,
6
8
  parseTypeOption,
9
+ parseWorkersOption,
7
10
  resolveRequestedFiles,
8
11
  resolveCliSelection,
9
12
  } from "./args.mjs";
@@ -55,9 +58,16 @@ describe("cli-args", () => {
55
58
  ]);
56
59
  });
57
60
 
58
- it("parses and validates jobs", () => {
59
- expect(parseJobsOption("3")).toBe(3);
60
- expect(() => parseJobsOption("0")).toThrow("Invalid --jobs value");
61
+ it("parses and validates execution options", () => {
62
+ expect(parseWorkersOption("3")).toBe(3);
63
+ expect(parseFileTimeoutOption("45")).toBe(45);
64
+ expect(parseStackCountOption("2")).toBe(2);
65
+ expect(parseStackModeOption("pooled")).toBe("pooled");
66
+ expect(() => parseWorkersOption("0")).toThrow("Invalid --workers value");
67
+ expect(() => parseFileTimeoutOption("0")).toThrow(
68
+ "Invalid --file-timeout-seconds value"
69
+ );
70
+ expect(() => parseStackModeOption("legacy")).toThrow("Invalid --stack-mode value");
61
71
  });
62
72
 
63
73
  it("parses and validates shards", () => {
package/lib/cli/index.mjs CHANGED
@@ -1,10 +1,13 @@
1
1
  import { cac } from "cac";
2
2
  import { loadConfigs } from "../config/index.mjs";
3
3
  import {
4
- parseJobsOption,
4
+ parseFileTimeoutOption,
5
5
  parseShardOption,
6
+ parseStackCountOption,
7
+ parseStackModeOption,
6
8
  parseSuiteOption,
7
9
  parseTypeOption,
10
+ parseWorkersOption,
8
11
  resolveRequestedFiles,
9
12
  resolveCliSelection,
10
13
  } from "./args.mjs";
@@ -22,9 +25,10 @@ export function run() {
22
25
  .option("-s, --suite <name>", "Run specific suite(s)", { default: [] })
23
26
  .option("-f, --file <path>", "Run specific file(s)", { default: [] })
24
27
  .option("--dir <path>", "Explicit product directory")
25
- .option("--jobs <n>", "Number of isolated worker stacks for the whole run", {
26
- default: "1",
27
- })
28
+ .option("--workers <n>", "Number of test executors for the whole run")
29
+ .option("--file-timeout-seconds <n>", "Per-file wall-clock timeout in seconds")
30
+ .option("--stack-mode <mode>", "Stack topology: shared, pooled, or isolated")
31
+ .option("--stack-count <n>", "Number of prepared stacks when stack-mode=pooled")
28
32
  .option("--shard <i/n>", "Run only shard i of n at suite granularity")
29
33
  .option("--write-status", "Write a deterministic testkit.status.json snapshot")
30
34
  .option("--allow-partial-status", "Allow --write-status for filtered runs")
@@ -62,7 +66,14 @@ export function run() {
62
66
  return;
63
67
  }
64
68
 
65
- const jobs = parseJobsOption(options.jobs);
69
+ const workers = options.workers == null ? null : parseWorkersOption(options.workers);
70
+ const fileTimeoutSeconds =
71
+ options.fileTimeoutSeconds == null
72
+ ? null
73
+ : parseFileTimeoutOption(options.fileTimeoutSeconds);
74
+ const stackMode = options.stackMode == null ? null : parseStackModeOption(options.stackMode);
75
+ const stackCount =
76
+ options.stackCount == null ? null : parseStackCountOption(options.stackCount);
66
77
  const shard = parseShardOption(options.shard);
67
78
  const typeValues = parseTypeOption(options.type, positionalType);
68
79
  const suiteSelectors = parseSuiteOption(options.suite);
@@ -77,7 +88,10 @@ export function run() {
77
88
  ...options,
78
89
  typeValues,
79
90
  fileNames,
80
- jobs,
91
+ workers,
92
+ fileTimeoutSeconds,
93
+ stackMode,
94
+ stackCount,
81
95
  shard,
82
96
  serviceFilter: options.service || null,
83
97
  },
@@ -8,6 +8,11 @@ import {
8
8
  parseSuiteSelectors,
9
9
  suiteSelectionType,
10
10
  } from "../runner/suite-selection.mjs";
11
+ import {
12
+ DEFAULT_FILE_TIMEOUT_SECONDS,
13
+ normalizeExecutionConfig,
14
+ normalizeStackModeValue,
15
+ } from "../runner/execution-config.mjs";
11
16
 
12
17
  const TESTKIT_K6_BIN = "TESTKIT_K6_BIN";
13
18
  const DEFAULT_LOCAL_IMAGE = "pgvector/pgvector:pg16";
@@ -22,6 +27,7 @@ export function parseDotenv(filePath) {
22
27
  export async function loadConfigs(opts = {}) {
23
28
  const productDir = resolveProductDir(process.cwd(), opts.dir);
24
29
  const { setup, setupFile } = await loadTestkitSetup(productDir);
30
+ const execution = normalizeRepoExecution(setup.execution);
25
31
  const explicitServices = setup.services || {};
26
32
  const discovery = discoverProject(productDir, explicitServices);
27
33
  const serviceNames = new Set([
@@ -37,6 +43,7 @@ export async function loadConfigs(opts = {}) {
37
43
  productDir,
38
44
  setup,
39
45
  setupFile,
46
+ execution,
40
47
  explicitService: explicitServices[name] || {},
41
48
  discoveredService: discovery.services[name] || null,
42
49
  suites: discovery.suitesByService[name] || {},
@@ -95,6 +102,7 @@ function normalizeServiceConfig({
95
102
  productDir,
96
103
  setup,
97
104
  setupFile,
105
+ execution,
98
106
  explicitService,
99
107
  discoveredService,
100
108
  suites,
@@ -113,6 +121,10 @@ function normalizeServiceConfig({
113
121
  productDir,
114
122
  suites,
115
123
  });
124
+ const serviceExecution = normalizeServiceExecution(explicitService.execution, {
125
+ name,
126
+ suites,
127
+ });
116
128
 
117
129
  if (!explicitService.databaseFrom && !database && (migrate || seed)) {
118
130
  throw new Error(
@@ -140,10 +152,12 @@ function normalizeServiceConfig({
140
152
  telemetry: normalizeTelemetryConfig(setup.telemetry),
141
153
  suites,
142
154
  testkit: {
155
+ execution,
143
156
  dependsOn: explicitService.dependsOn || [],
144
157
  database,
145
158
  databaseFrom: explicitService.databaseFrom,
146
159
  envFiles,
160
+ serviceExecution,
147
161
  serviceEnv,
148
162
  migrate,
149
163
  seed,
@@ -341,6 +355,103 @@ function normalizeSkipConfig(value, { name, productDir, suites }) {
341
355
  };
342
356
  }
343
357
 
358
+ function normalizeServiceExecution(value, { name, suites }) {
359
+ if (!value) return { suites: [], files: [], fileStackModeByPath: new Map() };
360
+
361
+ const discoveredSuites = [];
362
+ const discoveredFiles = new Set();
363
+ for (const [type, typedSuites] of Object.entries(suites || {})) {
364
+ for (const suite of typedSuites || []) {
365
+ discoveredSuites.push({
366
+ displayType: suiteSelectionType(type, suite.framework || "k6"),
367
+ name: suite.name,
368
+ });
369
+ for (const file of suite.files || []) {
370
+ discoveredFiles.add(file);
371
+ }
372
+ }
373
+ }
374
+
375
+ const suiteRules = [];
376
+ const seenSelectors = new Set();
377
+ for (const rule of value.suites || []) {
378
+ if (!rule || typeof rule !== "object") {
379
+ throw new Error(`Service "${name}" execution.suites entries must be objects`);
380
+ }
381
+ const selector = String(rule.selector || "").trim();
382
+ if (!selector) {
383
+ throw new Error(`Service "${name}" execution.suites entries require a selector`);
384
+ }
385
+ const parsed = parseSuiteSelectors([selector]);
386
+ if (parsed.length !== 1) {
387
+ throw new Error(`Service "${name}" execution.suites["${selector}"] is invalid`);
388
+ }
389
+ const parsedSelector = parsed[0];
390
+ if (parsedSelector.kind !== "typed") {
391
+ throw new Error(
392
+ `Service "${name}" execution.suites["${selector}"] must use a typed selector like int:health`
393
+ );
394
+ }
395
+ if (seenSelectors.has(parsedSelector.raw)) {
396
+ throw new Error(
397
+ `Service "${name}" defines duplicate execution.suites selector "${parsedSelector.raw}"`
398
+ );
399
+ }
400
+ const matched = discoveredSuites.some((suite) =>
401
+ matchesSuiteSelectors(suite.displayType, suite.name, [parsedSelector])
402
+ );
403
+ if (!matched) {
404
+ throw new Error(
405
+ `Service "${name}" execution.suites selector "${parsedSelector.raw}" did not match any discovered suite`
406
+ );
407
+ }
408
+ seenSelectors.add(parsedSelector.raw);
409
+ suiteRules.push({
410
+ selector: parsedSelector,
411
+ stackMode: normalizeStackModeValue(
412
+ rule.stackMode,
413
+ `Service "${name}" execution.suites["${parsedSelector.raw}"].stackMode`
414
+ ),
415
+ });
416
+ }
417
+
418
+ const fileRules = [];
419
+ const seenFiles = new Set();
420
+ for (const [index, rule] of (value.files || []).entries()) {
421
+ if (!rule || typeof rule !== "object") {
422
+ throw new Error(`Service "${name}" execution.files entries must be objects`);
423
+ }
424
+
425
+ const filePath = String(rule.path || "").trim();
426
+ if (!filePath) {
427
+ throw new Error(`Service "${name}" execution.files[${index}] requires a path`);
428
+ }
429
+ if (!discoveredFiles.has(filePath)) {
430
+ throw new Error(
431
+ `Service "${name}" execution.files["${filePath}"] did not match any discovered test file`
432
+ );
433
+ }
434
+ if (seenFiles.has(filePath)) {
435
+ throw new Error(`Service "${name}" defines duplicate execution.files path "${filePath}"`);
436
+ }
437
+
438
+ seenFiles.add(filePath);
439
+ fileRules.push({
440
+ path: filePath,
441
+ stackMode: normalizeStackModeValue(
442
+ rule.stackMode,
443
+ `Service "${name}" execution.files["${filePath}"].stackMode`
444
+ ),
445
+ });
446
+ }
447
+
448
+ return {
449
+ suites: suiteRules,
450
+ files: fileRules,
451
+ fileStackModeByPath: new Map(fileRules.map((rule) => [rule.path, rule.stackMode])),
452
+ };
453
+ }
454
+
344
455
  function inferEnvFiles(productDir, explicitService, local) {
345
456
  if (explicitService.envFile || explicitService.envFiles) {
346
457
  const files = [];
@@ -476,6 +587,18 @@ function normalizeTelemetryConfig(telemetry) {
476
587
  };
477
588
  }
478
589
 
590
+ function normalizeRepoExecution(execution) {
591
+ if (!execution) {
592
+ return normalizeExecutionConfig({
593
+ workers: 1,
594
+ fileTimeoutSeconds: DEFAULT_FILE_TIMEOUT_SECONDS,
595
+ stackMode: "isolated",
596
+ stackCount: 1,
597
+ });
598
+ }
599
+ return normalizeExecutionConfig(execution);
600
+ }
601
+
479
602
  function normalizePath(value) {
480
603
  return String(value || "")
481
604
  .split(path.sep)
@@ -9,6 +9,10 @@ import { determineDefaultRuntimeFailure } from "./default-runtime-errors.mjs";
9
9
  import { formatBatchDescriptor } from "./formatting.mjs";
10
10
  import { buildExecutionEnv } from "./template.mjs";
11
11
  import { readDatabaseUrl } from "./state-io.mjs";
12
+ import {
13
+ buildFileTimeoutEnv,
14
+ formatFileTimeoutBudgetError,
15
+ } from "../shared/file-timeout.mjs";
12
16
 
13
17
  export async function runHttpK6Batch(targetConfig, batch, lifecycle) {
14
18
  const baseUrl = targetConfig.testkit.local?.baseUrl;
@@ -17,7 +21,7 @@ export async function runHttpK6Batch(targetConfig, batch, lifecycle) {
17
21
  }
18
22
 
19
23
  console.log(
20
- `\n── ${targetConfig.workerLabel} ${batch.type}:${batch.tasks[0].suiteName}${formatBatchDescriptor(batch)} ──`
24
+ `\n── ${targetConfig.stackLabel} ${batch.type}:${batch.tasks[0].suiteName}${formatBatchDescriptor(batch)} ──`
21
25
  );
22
26
 
23
27
  return Promise.all(
@@ -32,7 +36,7 @@ export async function runDalBatch(targetConfig, batch, lifecycle) {
32
36
  }
33
37
 
34
38
  console.log(
35
- `\n── ${targetConfig.workerLabel} ${batch.type}:${batch.tasks[0].suiteName}${formatBatchDescriptor(batch)} ──`
39
+ `\n── ${targetConfig.stackLabel} ${batch.type}:${batch.tasks[0].suiteName}${formatBatchDescriptor(batch)} ──`
36
40
  );
37
41
 
38
42
  return Promise.all(
@@ -46,7 +50,7 @@ async function runHttpK6Task(targetConfig, task, baseUrl, lifecycle) {
46
50
  serviceName: targetConfig.name,
47
51
  sourceFile: path.join(targetConfig.productDir, task.file),
48
52
  });
49
- console.log(`·· ${targetConfig.workerLabel}:${task.suiteName} → ${task.file}`);
53
+ console.log(`·· ${targetConfig.stackLabel}:${task.suiteName} → ${task.file}`);
50
54
  return runDefaultRuntimeTask(
51
55
  targetConfig,
52
56
  task,
@@ -61,7 +65,7 @@ async function runDalTask(targetConfig, task, databaseUrl, lifecycle) {
61
65
  serviceName: targetConfig.name,
62
66
  sourceFile: path.join(targetConfig.productDir, task.file),
63
67
  });
64
- console.log(`·· ${targetConfig.workerLabel}:${task.suiteName} → ${task.file}`);
68
+ console.log(`·· ${targetConfig.stackLabel}:${task.suiteName} → ${task.file}`);
65
69
  return runDefaultRuntimeTask(
66
70
  targetConfig,
67
71
  task,
@@ -72,20 +76,27 @@ async function runDalTask(targetConfig, task, databaseUrl, lifecycle) {
72
76
 
73
77
  export async function runDefaultRuntimeTask(targetConfig, task, args, lifecycle, firstLine) {
74
78
  const k6Binary = resolveK6Binary();
79
+ const getFirstLine = firstLine || defaultFirstLine;
75
80
  const summaryFile = buildDefaultRuntimeSummaryPath(targetConfig, task);
76
81
  fs.mkdirSync(path.dirname(summaryFile), { recursive: true });
77
82
  const startedAt = Date.now();
78
- const result = await execa(
83
+ const fileTimeoutSeconds = targetConfig.testkit.execution.fileTimeoutSeconds;
84
+ const subprocess = execa(
79
85
  k6Binary,
80
86
  [...args.slice(0, 1), "--summary-export", summaryFile, ...args.slice(1)],
81
87
  {
82
88
  cwd: targetConfig.productDir,
83
- env: buildExecutionEnv(targetConfig, {}, process.env),
89
+ env: buildExecutionEnv(
90
+ targetConfig,
91
+ buildFileTimeoutEnv(fileTimeoutSeconds, startedAt),
92
+ process.env
93
+ ),
84
94
  reject: false,
85
95
  cancelSignal: lifecycle.signal,
86
96
  forceKillAfterDelay: 5_000,
87
97
  }
88
98
  );
99
+ const { result, timedOut } = await settleDefaultRuntimeProcess(subprocess, fileTimeoutSeconds);
89
100
 
90
101
  const stdout = parseDefaultRuntimeOutput(result.stdout || "");
91
102
  const stderr = parseDefaultRuntimeOutput(result.stderr || "");
@@ -98,7 +109,9 @@ export async function runDefaultRuntimeTask(targetConfig, task, args, lifecycle,
98
109
  task,
99
110
  [...stdout.artifacts, ...stderr.artifacts]
100
111
  );
101
- const runtimeError = determineDefaultRuntimeFailure(result, summary, firstLine);
112
+ const runtimeError = timedOut
113
+ ? formatFileTimeoutBudgetError(fileTimeoutSeconds)
114
+ : determineDefaultRuntimeFailure(result, summary, getFirstLine);
102
115
  const finishedAt = Date.now();
103
116
 
104
117
  return {
@@ -112,6 +125,35 @@ export async function runDefaultRuntimeTask(targetConfig, task, args, lifecycle,
112
125
  };
113
126
  }
114
127
 
128
+ function defaultFirstLine(output) {
129
+ return String(output || "")
130
+ .split(/\r?\n/)
131
+ .map((line) => line.trim())
132
+ .find(Boolean) || null;
133
+ }
134
+
135
+ async function settleDefaultRuntimeProcess(subprocess, fileTimeoutSeconds) {
136
+ const timeoutMs = fileTimeoutSeconds * 1000 + 1_000;
137
+ let timeoutHandle = null;
138
+ let timedOut = false;
139
+
140
+ try {
141
+ return await Promise.race([
142
+ subprocess.then((result) => ({ result, timedOut })),
143
+ new Promise((resolve) => {
144
+ timeoutHandle = setTimeout(async () => {
145
+ timedOut = true;
146
+ subprocess.kill("SIGTERM");
147
+ const result = await subprocess.catch((error) => error);
148
+ resolve({ result, timedOut: true });
149
+ }, timeoutMs);
150
+ }),
151
+ ]);
152
+ } finally {
153
+ if (timeoutHandle) clearTimeout(timeoutHandle);
154
+ }
155
+ }
156
+
115
157
  export function buildDefaultRuntimeSummaryPath(targetConfig, task) {
116
158
  return path.join(
117
159
  targetConfig.stateDir || path.join(targetConfig.productDir, ".testkit"),
@@ -0,0 +1,124 @@
1
+ import {
2
+ DEFAULT_FILE_TIMEOUT_SECONDS,
3
+ normalizeFileTimeoutSeconds,
4
+ parseFileTimeoutOption,
5
+ } from "../shared/file-timeout.mjs";
6
+
7
+ export const STACK_MODES = new Set(["shared", "pooled", "isolated"]);
8
+
9
+ export { DEFAULT_FILE_TIMEOUT_SECONDS, parseFileTimeoutOption };
10
+
11
+ export function parseWorkersOption(value) {
12
+ return parsePositiveInteger(value, "--workers");
13
+ }
14
+
15
+ export function parseStackCountOption(value) {
16
+ return parsePositiveInteger(value, "--stack-count");
17
+ }
18
+
19
+ export function parseStackModeOption(value) {
20
+ return normalizeStackModeValue(value, "--stack-mode");
21
+ }
22
+
23
+ export function normalizeStackModeValue(value, label = "execution.stackMode") {
24
+ const normalized = String(value || "").trim();
25
+ if (!STACK_MODES.has(normalized)) {
26
+ throw new Error(
27
+ `Invalid ${label} value "${value}". Expected one of: shared, pooled, isolated.`
28
+ );
29
+ }
30
+ return normalized;
31
+ }
32
+
33
+ export function resolveExecutionConfig({ cli = {}, repo = {} } = {}) {
34
+ return normalizeExecutionConfig({
35
+ workers: cli.workers ?? repo.workers ?? 1,
36
+ stackMode: cli.stackMode ?? repo.stackMode ?? "isolated",
37
+ stackCount: cli.stackCount ?? repo.stackCount ?? null,
38
+ fileTimeoutSeconds:
39
+ cli.fileTimeoutSeconds ?? repo.fileTimeoutSeconds ?? DEFAULT_FILE_TIMEOUT_SECONDS,
40
+ });
41
+ }
42
+
43
+ export function normalizeExecutionConfig(input = {}) {
44
+ const workers = normalizePositiveInteger(input.workers, "execution.workers");
45
+ const stackMode = normalizeStackMode(input.stackMode);
46
+ const explicitStackCount =
47
+ input.stackCount == null ? null : normalizePositiveInteger(input.stackCount, "execution.stackCount");
48
+ const fileTimeoutSeconds = normalizeFileTimeoutSeconds(
49
+ input.fileTimeoutSeconds ?? DEFAULT_FILE_TIMEOUT_SECONDS
50
+ );
51
+
52
+ if (stackMode === "shared") {
53
+ if (explicitStackCount !== null && explicitStackCount !== 1) {
54
+ throw new Error(`execution.stackCount must be 1 when stackMode is "shared".`);
55
+ }
56
+ return {
57
+ workers,
58
+ stackMode,
59
+ stackCount: 1,
60
+ fileTimeoutSeconds,
61
+ };
62
+ }
63
+
64
+ if (stackMode === "pooled") {
65
+ return {
66
+ workers,
67
+ stackMode,
68
+ stackCount: explicitStackCount ?? 1,
69
+ fileTimeoutSeconds,
70
+ };
71
+ }
72
+
73
+ if (explicitStackCount !== null && explicitStackCount !== workers) {
74
+ throw new Error(
75
+ `execution.stackCount must equal execution.workers when stackMode is "isolated".`
76
+ );
77
+ }
78
+ return {
79
+ workers,
80
+ stackMode,
81
+ stackCount: workers,
82
+ fileTimeoutSeconds,
83
+ };
84
+ }
85
+
86
+ export function buildStackIds(execution) {
87
+ if (execution.stackMode === "shared") {
88
+ return ["shared"];
89
+ }
90
+
91
+ return Array.from({ length: execution.stackCount }, (_unused, index) => `stack-${index + 1}`);
92
+ }
93
+
94
+ export function resolveBatchStackMode(defaultMode, suiteMode = null) {
95
+ if (suiteMode == null) return defaultMode;
96
+ return normalizeStackMode(suiteMode);
97
+ }
98
+
99
+ export function resolveBatchAccessMode({ framework, type, stackMode }) {
100
+ if (stackMode === "isolated") return "exclusive";
101
+ if ((framework || "k6") === "playwright") return "exclusive";
102
+ if (type === "dal") return "exclusive";
103
+ return "shared";
104
+ }
105
+
106
+ function normalizeStackMode(value) {
107
+ return normalizeStackModeValue(value, "execution.stackMode");
108
+ }
109
+
110
+ function parsePositiveInteger(value, label) {
111
+ const parsed = Number.parseInt(String(value), 10);
112
+ if (!Number.isInteger(parsed) || parsed <= 0) {
113
+ throw new Error(`Invalid ${label} value "${value}". Expected a positive integer.`);
114
+ }
115
+ return parsed;
116
+ }
117
+
118
+ function normalizePositiveInteger(value, label) {
119
+ const parsed = Number(value);
120
+ if (!Number.isInteger(parsed) || parsed <= 0) {
121
+ throw new Error(`${label} must be a positive integer.`);
122
+ }
123
+ return parsed;
124
+ }