@elench/testkit 0.1.37 → 0.1.39

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,11 @@ 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
+ # Two reusable local stacks for browser-heavy suites
28
+ npx @elench/testkit --workers 6 --stack-mode pooled --stack-count 2
26
29
 
27
30
  # Run a deterministic shard
28
31
  npx @elench/testkit --shard 1/3
@@ -61,6 +64,11 @@ import {
61
64
  } from "@elench/testkit/setup";
62
65
 
63
66
  export default defineTestkitSetup({
67
+ execution: {
68
+ workers: 8,
69
+ stackMode: "shared",
70
+ stackCount: 1,
71
+ },
64
72
  services: {
65
73
  api: service({
66
74
  ...tsxService({
@@ -109,6 +117,7 @@ export default defineTestkitSetup({
109
117
  `testkit.setup.ts` is optional for simple repos, but it is the primary escape hatch
110
118
  for:
111
119
 
120
+ - worker and stack topology
112
121
  - multi-service graphs
113
122
  - local DB configuration
114
123
  - migrate / seed commands
@@ -197,7 +206,7 @@ Suite names are inferred from the colocated path:
197
206
  services that define `database: localDatabase(...)`.
198
207
 
199
208
  - template databases are cached
200
- - worker databases are cloned from templates
209
+ - stack databases are cloned from templates
201
210
  - template fingerprints are derived automatically from env files, migrate/seed
202
211
  config, and repo contents
203
212
 
package/lib/cli/args.mjs CHANGED
@@ -1,5 +1,10 @@
1
1
  import path from "path";
2
2
  import { normalizeTypeValues, parseSuiteSelectors } from "../runner/suite-selection.mjs";
3
+ import {
4
+ parseStackCountOption,
5
+ parseStackModeOption,
6
+ parseWorkersOption,
7
+ } from "../runner/execution-config.mjs";
3
8
 
4
9
  export const POSITIONAL_TYPES = new Set(["int", "e2e", "dal", "load", "pw", "all"]);
5
10
  export const LIFECYCLE = new Set(["status", "destroy", "cleanup"]);
@@ -40,13 +45,7 @@ export function parseSuiteOption(values) {
40
45
  return parseSuiteSelectors(input);
41
46
  }
42
47
 
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
- }
48
+ export { parseWorkersOption, parseStackModeOption, parseStackCountOption };
50
49
 
51
50
  export function parseShardOption(value) {
52
51
  if (!value) return null;
@@ -1,9 +1,11 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
- parseJobsOption,
4
3
  parseShardOption,
4
+ parseStackCountOption,
5
+ parseStackModeOption,
5
6
  parseSuiteOption,
6
7
  parseTypeOption,
8
+ parseWorkersOption,
7
9
  resolveRequestedFiles,
8
10
  resolveCliSelection,
9
11
  } from "./args.mjs";
@@ -55,9 +57,12 @@ describe("cli-args", () => {
55
57
  ]);
56
58
  });
57
59
 
58
- it("parses and validates jobs", () => {
59
- expect(parseJobsOption("3")).toBe(3);
60
- expect(() => parseJobsOption("0")).toThrow("Invalid --jobs value");
60
+ it("parses and validates execution options", () => {
61
+ expect(parseWorkersOption("3")).toBe(3);
62
+ expect(parseStackCountOption("2")).toBe(2);
63
+ expect(parseStackModeOption("pooled")).toBe("pooled");
64
+ expect(() => parseWorkersOption("0")).toThrow("Invalid --workers value");
65
+ expect(() => parseStackModeOption("legacy")).toThrow("Invalid --stack-mode value");
61
66
  });
62
67
 
63
68
  it("parses and validates shards", () => {
package/lib/cli/index.mjs CHANGED
@@ -1,10 +1,12 @@
1
1
  import { cac } from "cac";
2
2
  import { loadConfigs } from "../config/index.mjs";
3
3
  import {
4
- parseJobsOption,
5
4
  parseShardOption,
5
+ parseStackCountOption,
6
+ parseStackModeOption,
6
7
  parseSuiteOption,
7
8
  parseTypeOption,
9
+ parseWorkersOption,
8
10
  resolveRequestedFiles,
9
11
  resolveCliSelection,
10
12
  } from "./args.mjs";
@@ -22,9 +24,13 @@ export function run() {
22
24
  .option("-s, --suite <name>", "Run specific suite(s)", { default: [] })
23
25
  .option("-f, --file <path>", "Run specific file(s)", { default: [] })
24
26
  .option("--dir <path>", "Explicit product directory")
25
- .option("--jobs <n>", "Number of isolated worker stacks for the whole run", {
27
+ .option("--workers <n>", "Number of test executors for the whole run", {
26
28
  default: "1",
27
29
  })
30
+ .option("--stack-mode <mode>", "Stack topology: shared, pooled, or isolated", {
31
+ default: "isolated",
32
+ })
33
+ .option("--stack-count <n>", "Number of prepared stacks when stack-mode=pooled")
28
34
  .option("--shard <i/n>", "Run only shard i of n at suite granularity")
29
35
  .option("--write-status", "Write a deterministic testkit.status.json snapshot")
30
36
  .option("--allow-partial-status", "Allow --write-status for filtered runs")
@@ -62,7 +68,10 @@ export function run() {
62
68
  return;
63
69
  }
64
70
 
65
- const jobs = parseJobsOption(options.jobs);
71
+ const workers = parseWorkersOption(options.workers);
72
+ const stackMode = parseStackModeOption(options.stackMode);
73
+ const stackCount =
74
+ options.stackCount == null ? null : parseStackCountOption(options.stackCount);
66
75
  const shard = parseShardOption(options.shard);
67
76
  const typeValues = parseTypeOption(options.type, positionalType);
68
77
  const suiteSelectors = parseSuiteOption(options.suite);
@@ -77,7 +86,9 @@ export function run() {
77
86
  ...options,
78
87
  typeValues,
79
88
  fileNames,
80
- jobs,
89
+ workers,
90
+ stackMode,
91
+ stackCount,
81
92
  shard,
82
93
  serviceFilter: options.service || null,
83
94
  },
@@ -8,6 +8,7 @@ import {
8
8
  parseSuiteSelectors,
9
9
  suiteSelectionType,
10
10
  } from "../runner/suite-selection.mjs";
11
+ import { normalizeExecutionConfig, normalizeStackModeValue } from "../runner/execution-config.mjs";
11
12
 
12
13
  const TESTKIT_K6_BIN = "TESTKIT_K6_BIN";
13
14
  const DEFAULT_LOCAL_IMAGE = "pgvector/pgvector:pg16";
@@ -22,6 +23,7 @@ export function parseDotenv(filePath) {
22
23
  export async function loadConfigs(opts = {}) {
23
24
  const productDir = resolveProductDir(process.cwd(), opts.dir);
24
25
  const { setup, setupFile } = await loadTestkitSetup(productDir);
26
+ const execution = normalizeRepoExecution(setup.execution);
25
27
  const explicitServices = setup.services || {};
26
28
  const discovery = discoverProject(productDir, explicitServices);
27
29
  const serviceNames = new Set([
@@ -37,6 +39,7 @@ export async function loadConfigs(opts = {}) {
37
39
  productDir,
38
40
  setup,
39
41
  setupFile,
42
+ execution,
40
43
  explicitService: explicitServices[name] || {},
41
44
  discoveredService: discovery.services[name] || null,
42
45
  suites: discovery.suitesByService[name] || {},
@@ -95,13 +98,17 @@ function normalizeServiceConfig({
95
98
  productDir,
96
99
  setup,
97
100
  setupFile,
101
+ execution,
98
102
  explicitService,
99
103
  discoveredService,
100
104
  suites,
101
105
  }) {
102
106
  const local = normalizeLocalConfig(name, explicitService, discoveredService, productDir);
103
107
  const envFiles = inferEnvFiles(productDir, explicitService, local);
104
- const serviceEnv = loadServiceEnv(productDir, envFiles);
108
+ const serviceEnv = {
109
+ ...loadServiceEnv(productDir, envFiles),
110
+ ...(explicitService.env || {}),
111
+ };
105
112
  const database = normalizeDatabaseConfig(explicitService, name);
106
113
  const migrate = normalizeLifecycle(explicitService.migrate);
107
114
  const seed = normalizeLifecycle(explicitService.seed);
@@ -110,6 +117,10 @@ function normalizeServiceConfig({
110
117
  productDir,
111
118
  suites,
112
119
  });
120
+ const serviceExecution = normalizeServiceExecution(explicitService.execution, {
121
+ name,
122
+ suites,
123
+ });
113
124
 
114
125
  if (!explicitService.databaseFrom && !database && (migrate || seed)) {
115
126
  throw new Error(
@@ -137,10 +148,12 @@ function normalizeServiceConfig({
137
148
  telemetry: normalizeTelemetryConfig(setup.telemetry),
138
149
  suites,
139
150
  testkit: {
151
+ execution,
140
152
  dependsOn: explicitService.dependsOn || [],
141
153
  database,
142
154
  databaseFrom: explicitService.databaseFrom,
143
155
  envFiles,
156
+ serviceExecution,
144
157
  serviceEnv,
145
158
  migrate,
146
159
  seed,
@@ -151,7 +164,10 @@ function normalizeServiceConfig({
151
164
  }
152
165
 
153
166
  function normalizeLocalConfig(name, explicitService, discoveredService, productDir) {
154
- if (explicitService.local) {
167
+ if (Object.prototype.hasOwnProperty.call(explicitService, "local")) {
168
+ if (explicitService.local === false) {
169
+ return undefined;
170
+ }
155
171
  return {
156
172
  ...explicitService.local,
157
173
  cwd: explicitService.local.cwd || ".",
@@ -335,6 +351,103 @@ function normalizeSkipConfig(value, { name, productDir, suites }) {
335
351
  };
336
352
  }
337
353
 
354
+ function normalizeServiceExecution(value, { name, suites }) {
355
+ if (!value) return { suites: [], files: [], fileStackModeByPath: new Map() };
356
+
357
+ const discoveredSuites = [];
358
+ const discoveredFiles = new Set();
359
+ for (const [type, typedSuites] of Object.entries(suites || {})) {
360
+ for (const suite of typedSuites || []) {
361
+ discoveredSuites.push({
362
+ displayType: suiteSelectionType(type, suite.framework || "k6"),
363
+ name: suite.name,
364
+ });
365
+ for (const file of suite.files || []) {
366
+ discoveredFiles.add(file);
367
+ }
368
+ }
369
+ }
370
+
371
+ const suiteRules = [];
372
+ const seenSelectors = new Set();
373
+ for (const rule of value.suites || []) {
374
+ if (!rule || typeof rule !== "object") {
375
+ throw new Error(`Service "${name}" execution.suites entries must be objects`);
376
+ }
377
+ const selector = String(rule.selector || "").trim();
378
+ if (!selector) {
379
+ throw new Error(`Service "${name}" execution.suites entries require a selector`);
380
+ }
381
+ const parsed = parseSuiteSelectors([selector]);
382
+ if (parsed.length !== 1) {
383
+ throw new Error(`Service "${name}" execution.suites["${selector}"] is invalid`);
384
+ }
385
+ const parsedSelector = parsed[0];
386
+ if (parsedSelector.kind !== "typed") {
387
+ throw new Error(
388
+ `Service "${name}" execution.suites["${selector}"] must use a typed selector like int:health`
389
+ );
390
+ }
391
+ if (seenSelectors.has(parsedSelector.raw)) {
392
+ throw new Error(
393
+ `Service "${name}" defines duplicate execution.suites selector "${parsedSelector.raw}"`
394
+ );
395
+ }
396
+ const matched = discoveredSuites.some((suite) =>
397
+ matchesSuiteSelectors(suite.displayType, suite.name, [parsedSelector])
398
+ );
399
+ if (!matched) {
400
+ throw new Error(
401
+ `Service "${name}" execution.suites selector "${parsedSelector.raw}" did not match any discovered suite`
402
+ );
403
+ }
404
+ seenSelectors.add(parsedSelector.raw);
405
+ suiteRules.push({
406
+ selector: parsedSelector,
407
+ stackMode: normalizeStackModeValue(
408
+ rule.stackMode,
409
+ `Service "${name}" execution.suites["${parsedSelector.raw}"].stackMode`
410
+ ),
411
+ });
412
+ }
413
+
414
+ const fileRules = [];
415
+ const seenFiles = new Set();
416
+ for (const [index, rule] of (value.files || []).entries()) {
417
+ if (!rule || typeof rule !== "object") {
418
+ throw new Error(`Service "${name}" execution.files entries must be objects`);
419
+ }
420
+
421
+ const filePath = String(rule.path || "").trim();
422
+ if (!filePath) {
423
+ throw new Error(`Service "${name}" execution.files[${index}] requires a path`);
424
+ }
425
+ if (!discoveredFiles.has(filePath)) {
426
+ throw new Error(
427
+ `Service "${name}" execution.files["${filePath}"] did not match any discovered test file`
428
+ );
429
+ }
430
+ if (seenFiles.has(filePath)) {
431
+ throw new Error(`Service "${name}" defines duplicate execution.files path "${filePath}"`);
432
+ }
433
+
434
+ seenFiles.add(filePath);
435
+ fileRules.push({
436
+ path: filePath,
437
+ stackMode: normalizeStackModeValue(
438
+ rule.stackMode,
439
+ `Service "${name}" execution.files["${filePath}"].stackMode`
440
+ ),
441
+ });
442
+ }
443
+
444
+ return {
445
+ suites: suiteRules,
446
+ files: fileRules,
447
+ fileStackModeByPath: new Map(fileRules.map((rule) => [rule.path, rule.stackMode])),
448
+ };
449
+ }
450
+
338
451
  function inferEnvFiles(productDir, explicitService, local) {
339
452
  if (explicitService.envFile || explicitService.envFiles) {
340
453
  const files = [];
@@ -470,6 +583,17 @@ function normalizeTelemetryConfig(telemetry) {
470
583
  };
471
584
  }
472
585
 
586
+ function normalizeRepoExecution(execution) {
587
+ if (!execution) {
588
+ return normalizeExecutionConfig({
589
+ workers: 1,
590
+ stackMode: "isolated",
591
+ stackCount: 1,
592
+ });
593
+ }
594
+ return normalizeExecutionConfig(execution);
595
+ }
596
+
473
597
  function normalizePath(value) {
474
598
  return String(value || "")
475
599
  .split(path.sep)
@@ -17,7 +17,7 @@ export async function runHttpK6Batch(targetConfig, batch, lifecycle) {
17
17
  }
18
18
 
19
19
  console.log(
20
- `\n── ${targetConfig.workerLabel} ${batch.type}:${batch.tasks[0].suiteName}${formatBatchDescriptor(batch)} ──`
20
+ `\n── ${targetConfig.stackLabel} ${batch.type}:${batch.tasks[0].suiteName}${formatBatchDescriptor(batch)} ──`
21
21
  );
22
22
 
23
23
  return Promise.all(
@@ -32,7 +32,7 @@ export async function runDalBatch(targetConfig, batch, lifecycle) {
32
32
  }
33
33
 
34
34
  console.log(
35
- `\n── ${targetConfig.workerLabel} ${batch.type}:${batch.tasks[0].suiteName}${formatBatchDescriptor(batch)} ──`
35
+ `\n── ${targetConfig.stackLabel} ${batch.type}:${batch.tasks[0].suiteName}${formatBatchDescriptor(batch)} ──`
36
36
  );
37
37
 
38
38
  return Promise.all(
@@ -46,7 +46,7 @@ async function runHttpK6Task(targetConfig, task, baseUrl, lifecycle) {
46
46
  serviceName: targetConfig.name,
47
47
  sourceFile: path.join(targetConfig.productDir, task.file),
48
48
  });
49
- console.log(`·· ${targetConfig.workerLabel}:${task.suiteName} → ${task.file}`);
49
+ console.log(`·· ${targetConfig.stackLabel}:${task.suiteName} → ${task.file}`);
50
50
  return runDefaultRuntimeTask(
51
51
  targetConfig,
52
52
  task,
@@ -61,7 +61,7 @@ async function runDalTask(targetConfig, task, databaseUrl, lifecycle) {
61
61
  serviceName: targetConfig.name,
62
62
  sourceFile: path.join(targetConfig.productDir, task.file),
63
63
  });
64
- console.log(`·· ${targetConfig.workerLabel}:${task.suiteName} → ${task.file}`);
64
+ console.log(`·· ${targetConfig.stackLabel}:${task.suiteName} → ${task.file}`);
65
65
  return runDefaultRuntimeTask(
66
66
  targetConfig,
67
67
  task,
@@ -0,0 +1,108 @@
1
+ export const STACK_MODES = new Set(["shared", "pooled", "isolated"]);
2
+
3
+ export function parseWorkersOption(value) {
4
+ return parsePositiveInteger(value, "--workers");
5
+ }
6
+
7
+ export function parseStackCountOption(value) {
8
+ return parsePositiveInteger(value, "--stack-count");
9
+ }
10
+
11
+ export function parseStackModeOption(value) {
12
+ return normalizeStackModeValue(value, "--stack-mode");
13
+ }
14
+
15
+ export function normalizeStackModeValue(value, label = "execution.stackMode") {
16
+ const normalized = String(value || "").trim();
17
+ if (!STACK_MODES.has(normalized)) {
18
+ throw new Error(
19
+ `Invalid ${label} value "${value}". Expected one of: shared, pooled, isolated.`
20
+ );
21
+ }
22
+ return normalized;
23
+ }
24
+
25
+ export function resolveExecutionConfig({ cli = {}, repo = {} } = {}) {
26
+ return normalizeExecutionConfig({
27
+ workers: cli.workers ?? repo.workers ?? 1,
28
+ stackMode: cli.stackMode ?? repo.stackMode ?? "isolated",
29
+ stackCount: cli.stackCount ?? repo.stackCount ?? null,
30
+ });
31
+ }
32
+
33
+ export function normalizeExecutionConfig(input = {}) {
34
+ const workers = normalizePositiveInteger(input.workers, "execution.workers");
35
+ const stackMode = normalizeStackMode(input.stackMode);
36
+ const explicitStackCount =
37
+ input.stackCount == null ? null : normalizePositiveInteger(input.stackCount, "execution.stackCount");
38
+
39
+ if (stackMode === "shared") {
40
+ if (explicitStackCount !== null && explicitStackCount !== 1) {
41
+ throw new Error(`execution.stackCount must be 1 when stackMode is "shared".`);
42
+ }
43
+ return {
44
+ workers,
45
+ stackMode,
46
+ stackCount: 1,
47
+ };
48
+ }
49
+
50
+ if (stackMode === "pooled") {
51
+ return {
52
+ workers,
53
+ stackMode,
54
+ stackCount: explicitStackCount ?? 1,
55
+ };
56
+ }
57
+
58
+ if (explicitStackCount !== null && explicitStackCount !== workers) {
59
+ throw new Error(
60
+ `execution.stackCount must equal execution.workers when stackMode is "isolated".`
61
+ );
62
+ }
63
+ return {
64
+ workers,
65
+ stackMode,
66
+ stackCount: workers,
67
+ };
68
+ }
69
+
70
+ export function buildStackIds(execution) {
71
+ if (execution.stackMode === "shared") {
72
+ return ["shared"];
73
+ }
74
+
75
+ return Array.from({ length: execution.stackCount }, (_unused, index) => `stack-${index + 1}`);
76
+ }
77
+
78
+ export function resolveBatchStackMode(defaultMode, suiteMode = null) {
79
+ if (suiteMode == null) return defaultMode;
80
+ return normalizeStackMode(suiteMode);
81
+ }
82
+
83
+ export function resolveBatchAccessMode({ framework, type, stackMode }) {
84
+ if (stackMode === "isolated") return "exclusive";
85
+ if ((framework || "k6") === "playwright") return "exclusive";
86
+ if (type === "dal") return "exclusive";
87
+ return "shared";
88
+ }
89
+
90
+ function normalizeStackMode(value) {
91
+ return normalizeStackModeValue(value, "execution.stackMode");
92
+ }
93
+
94
+ function parsePositiveInteger(value, label) {
95
+ const parsed = Number.parseInt(String(value), 10);
96
+ if (!Number.isInteger(parsed) || parsed <= 0) {
97
+ throw new Error(`Invalid ${label} value "${value}". Expected a positive integer.`);
98
+ }
99
+ return parsed;
100
+ }
101
+
102
+ function normalizePositiveInteger(value, label) {
103
+ const parsed = Number(value);
104
+ if (!Number.isInteger(parsed) || parsed <= 0) {
105
+ throw new Error(`${label} must be a positive integer.`);
106
+ }
107
+ return parsed;
108
+ }
@@ -0,0 +1,101 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildStackIds,
4
+ normalizeExecutionConfig,
5
+ parseStackCountOption,
6
+ parseStackModeOption,
7
+ parseWorkersOption,
8
+ resolveBatchAccessMode,
9
+ resolveBatchStackMode,
10
+ resolveExecutionConfig,
11
+ } from "./execution-config.mjs";
12
+
13
+ describe("execution-config", () => {
14
+ it("parses worker and stack CLI options", () => {
15
+ expect(parseWorkersOption("8")).toBe(8);
16
+ expect(parseStackCountOption("2")).toBe(2);
17
+ expect(parseStackModeOption("pooled")).toBe("pooled");
18
+ expect(() => parseWorkersOption("0")).toThrow('Invalid --workers value "0"');
19
+ expect(() => parseStackModeOption("legacy")).toThrow('Invalid --stack-mode value "legacy"');
20
+ });
21
+
22
+ it("normalizes shared, pooled, and isolated execution shapes", () => {
23
+ expect(normalizeExecutionConfig({ workers: 8, stackMode: "shared" })).toEqual({
24
+ workers: 8,
25
+ stackMode: "shared",
26
+ stackCount: 1,
27
+ });
28
+
29
+ expect(normalizeExecutionConfig({ workers: 8, stackMode: "pooled", stackCount: 3 })).toEqual({
30
+ workers: 8,
31
+ stackMode: "pooled",
32
+ stackCount: 3,
33
+ });
34
+
35
+ expect(normalizeExecutionConfig({ workers: 8, stackMode: "isolated" })).toEqual({
36
+ workers: 8,
37
+ stackMode: "isolated",
38
+ stackCount: 8,
39
+ });
40
+ });
41
+
42
+ it("rejects invalid stack-count combinations", () => {
43
+ expect(() =>
44
+ normalizeExecutionConfig({ workers: 8, stackMode: "shared", stackCount: 2 })
45
+ ).toThrow('execution.stackCount must be 1 when stackMode is "shared"');
46
+
47
+ expect(() =>
48
+ normalizeExecutionConfig({ workers: 8, stackMode: "isolated", stackCount: 2 })
49
+ ).toThrow('execution.stackCount must equal execution.workers when stackMode is "isolated"');
50
+ });
51
+
52
+ it("applies CLI values over repo execution defaults", () => {
53
+ expect(
54
+ resolveExecutionConfig({
55
+ repo: { workers: 3, stackMode: "shared" },
56
+ cli: { workers: 6, stackMode: "pooled", stackCount: 2 },
57
+ })
58
+ ).toEqual({
59
+ workers: 6,
60
+ stackMode: "pooled",
61
+ stackCount: 2,
62
+ });
63
+ });
64
+
65
+ it("builds stack ids from the normalized execution shape", () => {
66
+ expect(buildStackIds({ stackMode: "shared", stackCount: 1 })).toEqual(["shared"]);
67
+ expect(buildStackIds({ stackMode: "pooled", stackCount: 2 })).toEqual([
68
+ "stack-1",
69
+ "stack-2",
70
+ ]);
71
+ });
72
+
73
+ it("resolves batch stack and access modes", () => {
74
+ expect(resolveBatchStackMode("shared", null)).toBe("shared");
75
+ expect(resolveBatchStackMode("shared", "isolated")).toBe("isolated");
76
+
77
+ expect(
78
+ resolveBatchAccessMode({
79
+ framework: "k6",
80
+ type: "integration",
81
+ stackMode: "shared",
82
+ })
83
+ ).toBe("shared");
84
+
85
+ expect(
86
+ resolveBatchAccessMode({
87
+ framework: "playwright",
88
+ type: "e2e",
89
+ stackMode: "pooled",
90
+ })
91
+ ).toBe("exclusive");
92
+
93
+ expect(
94
+ resolveBatchAccessMode({
95
+ framework: "k6",
96
+ type: "dal",
97
+ stackMode: "shared",
98
+ })
99
+ ).toBe("exclusive");
100
+ });
101
+ });
@@ -21,7 +21,7 @@ export function createRunLifecycle(productDir) {
21
21
  interruptReason: null,
22
22
  services: [],
23
23
  graphDirs: [],
24
- workerStateDirs: [],
24
+ stackStateDirs: [],
25
25
  runtimeStateDirs: [],
26
26
  };
27
27
  const signalListeners = [];
@@ -66,7 +66,7 @@ export function createRunLifecycle(productDir) {
66
66
  trackGraphContext(context) {
67
67
  mutate((draft) => {
68
68
  pushUnique(draft.graphDirs, context.graphDir);
69
- pushUnique(draft.workerStateDirs, context.workerStateDir);
69
+ pushUnique(draft.stackStateDirs, context.stackStateDir);
70
70
  for (const runtimeConfig of context.runtimeConfigs || []) {
71
71
  if (runtimeConfig.stateDir) pushUnique(draft.runtimeStateDirs, runtimeConfig.stateDir);
72
72
  }
@@ -78,7 +78,7 @@ export function createRunLifecycle(productDir) {
78
78
  draft.services = draft.services.filter((service) => service.pid !== child.pid);
79
79
  draft.services.push({
80
80
  serviceName: config.name,
81
- workerLabel: config.workerLabel,
81
+ stackLabel: config.stackLabel,
82
82
  command: config.testkit.local?.start || null,
83
83
  cwd,
84
84
  pid: child.pid,
@@ -195,8 +195,8 @@ export function findPortOwner(productDir, { host, port }) {
195
195
  }
196
196
 
197
197
  export function formatRunSummary(manifest) {
198
- const workerLabels = [...new Set((manifest.services || []).map((service) => service.workerLabel).filter(Boolean))];
199
- return `${manifest.runId} pid=${manifest.pid}${workerLabels.length > 0 ? ` workers=${workerLabels.join(",")}` : ""}`;
198
+ const stackLabels = [...new Set((manifest.services || []).map((service) => service.stackLabel).filter(Boolean))];
199
+ return `${manifest.runId} pid=${manifest.pid}${stackLabels.length > 0 ? ` stacks=${stackLabels.join(",")}` : ""}`;
200
200
  }
201
201
 
202
202
  export function isPidRunning(pid) {
@@ -220,8 +220,8 @@ async function cleanupRunManifest(productDir, manifest, { removeRuntimeState = f
220
220
  await destroyRuntimeDatabase({ productDir, stateDir });
221
221
  }
222
222
 
223
- for (const workerStateDir of [...new Set(manifest.workerStateDirs || [])].sort((a, b) => b.length - a.length)) {
224
- fs.rmSync(workerStateDir, { recursive: true, force: true });
223
+ for (const stackStateDir of [...new Set(manifest.stackStateDirs || [])].sort((a, b) => b.length - a.length)) {
224
+ fs.rmSync(stackStateDir, { recursive: true, force: true });
225
225
  }
226
226
 
227
227
  for (const graphDir of [...new Set(manifest.graphDirs || [])].sort((a, b) => b.length - a.length)) {