@elench/testkit 0.1.40 → 0.1.42

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.
Files changed (38) hide show
  1. package/README.md +27 -13
  2. package/bin/testkit.mjs +6 -1
  3. package/lib/cli/args.mjs +0 -4
  4. package/lib/cli/args.test.mjs +0 -5
  5. package/lib/cli/index.mjs +4 -11
  6. package/lib/config/index.mjs +78 -24
  7. package/lib/database/index.mjs +19 -7
  8. package/lib/database/naming.mjs +2 -2
  9. package/lib/database/naming.test.mjs +2 -2
  10. package/lib/runner/default-runtime-runner.mjs +52 -55
  11. package/lib/runner/execution-config.mjs +31 -70
  12. package/lib/runner/execution-config.test.mjs +30 -74
  13. package/lib/runner/formatting.mjs +0 -15
  14. package/lib/runner/formatting.test.mjs +0 -18
  15. package/lib/runner/lifecycle.mjs +106 -8
  16. package/lib/runner/orchestrator.mjs +16 -10
  17. package/lib/runner/planning.mjs +66 -138
  18. package/lib/runner/planning.test.mjs +101 -167
  19. package/lib/runner/playwright-config.mjs +13 -2
  20. package/lib/runner/playwright-config.test.mjs +26 -6
  21. package/lib/runner/playwright-runner.mjs +50 -56
  22. package/lib/runner/readiness.mjs +2 -2
  23. package/lib/runner/reporting.mjs +4 -3
  24. package/lib/runner/reporting.test.mjs +2 -5
  25. package/lib/runner/results.mjs +1 -1
  26. package/lib/runner/results.test.mjs +1 -1
  27. package/lib/runner/runtime-contexts.mjs +20 -24
  28. package/lib/runner/runtime-manager.mjs +228 -0
  29. package/lib/runner/runtime-manager.test.mjs +206 -0
  30. package/lib/runner/services.mjs +8 -6
  31. package/lib/runner/state.mjs +1 -2
  32. package/lib/runner/state.test.mjs +2 -4
  33. package/lib/runner/template.mjs +90 -60
  34. package/lib/runner/template.test.mjs +59 -27
  35. package/lib/runner/worker-loop.mjs +35 -32
  36. package/lib/setup/index.d.ts +15 -10
  37. package/package.json +1 -1
  38. package/lib/runner/stack-manager.mjs +0 -146
@@ -4,7 +4,7 @@ import {
4
4
  buildGraphDirName,
5
5
  buildRuntimeGraphs,
6
6
  buildTaskQueue,
7
- claimNextBatch,
7
+ claimNextTask,
8
8
  collectSuites,
9
9
  resolveRuntimeConfigs,
10
10
  } from "./planning.mjs";
@@ -14,20 +14,23 @@ function makeConfig(name, extras = {}) {
14
14
  return {
15
15
  name,
16
16
  suites: extras.suites || {},
17
- testkit: {
18
- dependsOn: extras.dependsOn || providedTestkit.dependsOn || [],
19
- execution: providedTestkit.execution || {
20
- workers: 1,
21
- stackMode: "isolated",
22
- stackCount: 1,
23
- },
24
- serviceExecution: providedTestkit.serviceExecution || {
25
- suites: [],
26
- files: [],
27
- fileStackModeByPath: new Map(),
28
- },
29
- ...providedTestkit,
17
+ testkit: {
18
+ dependsOn: extras.dependsOn || providedTestkit.dependsOn || [],
19
+ execution: providedTestkit.execution || {
20
+ workers: 1,
21
+ fileTimeoutSeconds: 60,
30
22
  },
23
+ runtime: providedTestkit.runtime || {
24
+ instances: 1,
25
+ maxConcurrentTasks: Number.POSITIVE_INFINITY,
26
+ },
27
+ requirements: providedTestkit.requirements || {
28
+ suites: [],
29
+ files: [],
30
+ fileLocksByPath: new Map(),
31
+ },
32
+ ...providedTestkit,
33
+ },
31
34
  ...extras,
32
35
  };
33
36
  }
@@ -67,17 +70,6 @@ describe("runner-planning", () => {
67
70
  },
68
71
  ],
69
72
  },
70
- testkit: {
71
- dependsOn: [],
72
- execution: {
73
- workers: 1,
74
- stackMode: "shared",
75
- stackCount: 1,
76
- },
77
- serviceExecution: {
78
- suites: [],
79
- },
80
- },
81
73
  });
82
74
 
83
75
  expect(collectSuites(config, ["int"], [], [])[0]).toMatchObject({
@@ -122,15 +114,6 @@ describe("runner-planning", () => {
122
114
  ],
123
115
  },
124
116
  testkit: {
125
- dependsOn: [],
126
- execution: {
127
- workers: 1,
128
- stackMode: "shared",
129
- stackCount: 1,
130
- },
131
- serviceExecution: {
132
- suites: [],
133
- },
134
117
  skip: {
135
118
  fileReasonByPath: new Map([
136
119
  ["__testkit__/billing/a.int.testkit.ts", "Billing is stubbed"],
@@ -153,21 +136,9 @@ describe("runner-planning", () => {
153
136
  totalFileCount: 2,
154
137
  }),
155
138
  ]);
156
-
157
- expect(collectSuites(config, ["int"], [], [], { ignoreSkipRules: true })).toEqual([
158
- expect.objectContaining({
159
- name: "billing",
160
- files: [
161
- "__testkit__/billing/a.int.testkit.ts",
162
- "__testkit__/billing/b.int.testkit.ts",
163
- ],
164
- skippedFiles: [],
165
- totalFileCount: 2,
166
- }),
167
- ]);
168
139
  });
169
140
 
170
- it("applies file-level stack isolation overrides within the same suite", () => {
141
+ it("applies lock requirements to matching suites and files", () => {
171
142
  const api = makeConfig("api", {
172
143
  suites: {
173
144
  integration: [
@@ -180,28 +151,31 @@ describe("runner-planning", () => {
180
151
  ],
181
152
  orderIndex: 0,
182
153
  weight: 2,
183
- maxFileConcurrency: 1,
184
154
  },
185
155
  ],
186
156
  },
187
157
  testkit: {
188
- execution: {
189
- workers: 8,
190
- stackMode: "shared",
191
- stackCount: 1,
158
+ runtime: {
159
+ instances: 3,
160
+ maxConcurrentTasks: 2,
192
161
  },
193
- serviceExecution: {
194
- suites: [],
162
+ requirements: {
163
+ suites: [
164
+ {
165
+ selector: { kind: "typed", type: "int", name: "routes", raw: "int:routes" },
166
+ locks: ["suite-lock"],
167
+ },
168
+ ],
195
169
  files: [
196
170
  {
197
171
  path: "src/api/routes/__testkit__/crawls-worker-concurrency.int.testkit.ts",
198
- stackMode: "isolated",
172
+ locks: ["worker-loop"],
199
173
  },
200
174
  ],
201
- fileStackModeByPath: new Map([
175
+ fileLocksByPath: new Map([
202
176
  [
203
177
  "src/api/routes/__testkit__/crawls-worker-concurrency.int.testkit.ts",
204
- "isolated",
178
+ ["worker-loop"],
205
179
  ],
206
180
  ]),
207
181
  },
@@ -239,21 +213,35 @@ describe("runner-planning", () => {
239
213
  expect.arrayContaining([
240
214
  expect.objectContaining({
241
215
  file: "src/api/routes/__testkit__/health.int.testkit.ts",
242
- stackMode: "shared",
243
- accessMode: "shared",
216
+ locks: ["suite-lock"],
244
217
  }),
245
218
  expect.objectContaining({
246
219
  file: "src/api/routes/__testkit__/crawls-worker-concurrency.int.testkit.ts",
247
- stackMode: "isolated",
248
- accessMode: "exclusive",
220
+ locks: ["suite-lock", "worker-loop"],
249
221
  }),
250
222
  ])
251
223
  );
252
224
  });
253
225
 
254
- it("applies shards, builds graphs, queues tasks, and claims batches", () => {
255
- const api = makeConfig("api");
256
- const frontend = makeConfig("frontend");
226
+ it("builds exact runtime graphs and claims single file tasks", () => {
227
+ const api = makeConfig("api", {
228
+ testkit: {
229
+ runtime: {
230
+ instances: 2,
231
+ maxConcurrentTasks: 4,
232
+ },
233
+ },
234
+ });
235
+ const frontend = makeConfig("frontend", {
236
+ dependsOn: ["api"],
237
+ testkit: {
238
+ runtime: {
239
+ instances: 1,
240
+ maxConcurrentTasks: 2,
241
+ },
242
+ },
243
+ });
244
+
257
245
  const plans = [
258
246
  {
259
247
  config: api,
@@ -269,9 +257,6 @@ describe("runner-planning", () => {
269
257
  files: ["a.js", "b.js"],
270
258
  orderIndex: 0,
271
259
  weight: 2,
272
- maxFileConcurrency: 2,
273
- stackMode: "isolated",
274
- accessMode: "exclusive",
275
260
  },
276
261
  ],
277
262
  },
@@ -286,12 +271,9 @@ describe("runner-planning", () => {
286
271
  name: "auth",
287
272
  type: "e2e",
288
273
  framework: "playwright",
289
- files: ["auth.spec.js", "signup.spec.js"],
274
+ files: ["auth.spec.js"],
290
275
  orderIndex: 0,
291
- weight: 2,
292
- maxFileConcurrency: 1,
293
- stackMode: "isolated",
294
- accessMode: "exclusive",
276
+ weight: 1,
295
277
  },
296
278
  ],
297
279
  },
@@ -300,112 +282,64 @@ describe("runner-planning", () => {
300
282
  expect(applyShard(["a", "b", "c", "d"], { index: 2, total: 2 })).toEqual(["b", "d"]);
301
283
 
302
284
  const graphs = buildRuntimeGraphs(plans);
303
- expect(graphs).toHaveLength(1);
304
- expect(plans[0].assignedGraphKey).toBe("api|frontend");
285
+ expect(graphs).toEqual([
286
+ expect.objectContaining({
287
+ key: "api",
288
+ instanceCount: 2,
289
+ maxConcurrentTasks: 4,
290
+ targetNames: ["api"],
291
+ }),
292
+ expect.objectContaining({
293
+ key: "api|frontend",
294
+ instanceCount: 1,
295
+ maxConcurrentTasks: 2,
296
+ targetNames: ["frontend"],
297
+ }),
298
+ ]);
305
299
  expect(buildGraphDirName(["api", "frontend"])).toBe("api__frontend");
306
300
 
307
301
  const queue = buildTaskQueue(plans, graphs, { files: {} });
308
- expect(queue).toHaveLength(4);
309
-
310
- const firstBatch = claimNextBatch(queue, "api|frontend");
311
- expect(firstBatch.tasks).toHaveLength(1);
312
- expect(firstBatch.framework).toBe("playwright");
313
- expect(firstBatch.accessMode).toBe("exclusive");
314
-
315
- const secondBatch = claimNextBatch(queue, "api|frontend");
316
- expect(secondBatch.tasks).toHaveLength(1);
317
- expect(secondBatch.framework).toBe("playwright");
318
-
319
- const thirdBatch = claimNextBatch(queue, "api|frontend");
320
- expect(thirdBatch.tasks).toHaveLength(2);
321
- expect(thirdBatch.framework).toBe("k6");
302
+ expect(queue).toHaveLength(3);
303
+ expect(claimNextTask(queue, "api|frontend")).toMatchObject({
304
+ framework: "playwright",
305
+ graphKey: "api|frontend",
306
+ file: "auth.spec.js",
307
+ });
308
+ expect(claimNextTask(queue, "api")).toMatchObject({
309
+ framework: "k6",
310
+ graphKey: "api",
311
+ });
322
312
  });
323
313
 
324
- it("allows Playwright suites to opt into bounded multi-file batches", () => {
325
- const frontend = makeConfig("frontend");
326
- const plans = [
314
+ it("skips blocked preferred-graph work and claims the next runnable task", () => {
315
+ const queue = [
327
316
  {
328
- config: frontend,
329
- skipped: false,
330
- runtimeConfigs: [frontend],
331
- runtimeNames: ["frontend"],
332
- runtimeKey: "frontend",
333
- suites: [
334
- {
335
- name: "auth",
336
- type: "e2e",
337
- framework: "playwright",
338
- files: ["a.spec.js", "b.spec.js", "c.spec.js"],
339
- orderIndex: 0,
340
- weight: 3,
341
- maxFileConcurrency: 2,
342
- stackMode: "isolated",
343
- accessMode: "exclusive",
344
- },
345
- ],
346
- },
347
- ];
348
-
349
- const graphs = buildRuntimeGraphs(plans);
350
- const queue = buildTaskQueue(plans, graphs, { files: {} });
351
-
352
- const firstBatch = claimNextBatch(queue, "frontend");
353
- expect(firstBatch.tasks.map((task) => task.file)).toEqual(["a.spec.js", "b.spec.js"]);
354
- expect(firstBatch.tasks).toHaveLength(2);
355
- expect(firstBatch.framework).toBe("playwright");
356
-
357
- const secondBatch = claimNextBatch(queue, "frontend");
358
- expect(secondBatch.tasks).toHaveLength(1);
359
- expect(secondBatch.framework).toBe("playwright");
360
- });
361
-
362
- it("applies typed suite execution rules from config", () => {
363
- const config = makeConfig("api", {
364
- suites: {
365
- integration: [{ name: "health", files: ["__testkit__/health/health.int.testkit.ts"] }],
366
- },
367
- testkit: {
368
- dependsOn: [],
369
- execution: {
370
- workers: 8,
371
- stackMode: "shared",
372
- stackCount: 1,
373
- },
374
- serviceExecution: {
375
- suites: [{ selector: { kind: "typed", type: "int", name: "health", raw: "int:health" }, stackMode: "isolated" }],
376
- },
317
+ id: 1,
318
+ graphKey: "api",
319
+ file: "blocked.js",
377
320
  },
378
- });
379
-
380
- const graphs = buildRuntimeGraphs([
381
321
  {
382
- config,
383
- skipped: false,
384
- runtimeConfigs: [config],
385
- runtimeNames: ["api"],
386
- runtimeKey: "api",
387
- suites: collectSuites(config, ["int"], [], []),
322
+ id: 2,
323
+ graphKey: "api|frontend",
324
+ file: "runnable.js",
388
325
  },
389
- ]);
390
- const queue = buildTaskQueue(
391
- [
392
- {
393
- config,
394
- skipped: false,
395
- runtimeConfigs: [config],
396
- runtimeNames: ["api"],
397
- runtimeKey: "api",
398
- assignedGraphKey: "api",
399
- suites: collectSuites(config, ["int"], [], []),
400
- },
401
- ],
402
- graphs,
403
- { files: {} }
326
+ ];
327
+
328
+ const claimed = claimNextTask(
329
+ queue,
330
+ "api",
331
+ (task) => task.file === "runnable.js"
404
332
  );
405
333
 
406
- expect(queue[0]).toMatchObject({
407
- stackMode: "isolated",
408
- accessMode: "exclusive",
334
+ expect(claimed).toMatchObject({
335
+ id: 2,
336
+ file: "runnable.js",
409
337
  });
338
+ expect(queue).toEqual([
339
+ expect.objectContaining({
340
+ id: 1,
341
+ file: "blocked.js",
342
+ }),
343
+ ]);
410
344
  });
411
345
  });
@@ -3,8 +3,14 @@ import path from "path";
3
3
  import { pathToFileURL } from "url";
4
4
  import { normalizePathSeparators } from "./state.mjs";
5
5
 
6
- export function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles) {
7
- const stateDir = targetConfig.stateDir || path.join(targetConfig.productDir, ".testkit");
6
+ export function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles, lease) {
7
+ if (!lease?.leaseDir) {
8
+ throw new Error(
9
+ `Playwright task for service "${targetConfig.name}" requires a lease-scoped directory`
10
+ );
11
+ }
12
+
13
+ const stateDir = lease.leaseDir;
8
14
  const outputDir = resolvePlaywrightOutputDir(stateDir);
9
15
  fs.mkdirSync(stateDir, { recursive: true });
10
16
  fs.mkdirSync(outputDir, { recursive: true });
@@ -22,6 +28,9 @@ export function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles) {
22
28
  ` testDir: ${JSON.stringify(cwd)},\n` +
23
29
  ` testMatch: ${JSON.stringify(normalizedFiles)},\n` +
24
30
  ` outputDir: ${JSON.stringify(outputDir)},\n` +
31
+ ` workers: 1,\n` +
32
+ ` fullyParallel: false,\n` +
33
+ ` webServer: undefined,\n` +
25
34
  `};\n`;
26
35
  } else {
27
36
  source =
@@ -29,6 +38,8 @@ export function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles) {
29
38
  ` testDir: ${JSON.stringify(cwd)},\n` +
30
39
  ` testMatch: ${JSON.stringify(normalizedFiles)},\n` +
31
40
  ` outputDir: ${JSON.stringify(outputDir)},\n` +
41
+ ` workers: 1,\n` +
42
+ ` fullyParallel: false,\n` +
32
43
  `};\n`;
33
44
  }
34
45
 
@@ -24,29 +24,49 @@ function makeTempDir(prefix) {
24
24
  }
25
25
 
26
26
  describe("runner-playwright-config", () => {
27
- it("uses a shard-local output directory under the state dir", async () => {
27
+ it("uses a lease-local output directory under the lease dir", async () => {
28
28
  const productDir = makeTempDir("testkit-playwright-product-");
29
- const stateDir = path.join(productDir, ".testkit", "stack-3");
29
+ const leaseDir = path.join(productDir, ".testkit", "leases", "lease-12");
30
30
  const cwd = path.join(productDir, "frontend");
31
31
  fs.mkdirSync(cwd, { recursive: true });
32
32
  fs.writeFileSync(
33
33
  path.join(cwd, "playwright.config.mjs"),
34
- "export default { outputDir: 'shared-test-results' };\n"
34
+ "export default { outputDir: 'shared-test-results', workers: 8, fullyParallel: true, webServer: { command: 'npm run dev', url: 'http://127.0.0.1:3000' } };\n"
35
35
  );
36
36
 
37
37
  const configPath = ensurePlaywrightTestConfig(
38
- { productDir, stateDir },
38
+ { name: "frontend", productDir, stateDir: path.join(productDir, ".testkit", "frontend") },
39
39
  cwd,
40
- ["frontend/__testkit__/homepage/homepage.pw.testkit.ts"]
40
+ ["frontend/__testkit__/homepage/homepage.pw.testkit.ts"],
41
+ { leaseDir }
41
42
  );
42
43
  const generated = await import(pathToFileURL(configPath).href + `?t=${Date.now()}`);
43
44
 
44
- const expectedOutputDir = resolvePlaywrightOutputDir(stateDir);
45
+ const expectedOutputDir = resolvePlaywrightOutputDir(leaseDir);
45
46
  expect(generated.default.outputDir).toBe(expectedOutputDir);
47
+ expect(generated.default.workers).toBe(1);
48
+ expect(generated.default.fullyParallel).toBe(false);
49
+ expect(generated.default.webServer).toBeUndefined();
46
50
  expect(fs.existsSync(expectedOutputDir)).toBe(true);
47
51
  expect(fs.readFileSync(configPath, "utf8")).toContain(
48
52
  `outputDir: ${JSON.stringify(expectedOutputDir)}`
49
53
  );
54
+ expect(fs.readFileSync(configPath, "utf8")).toContain("workers: 1");
55
+ expect(fs.readFileSync(configPath, "utf8")).toContain("fullyParallel: false");
56
+ });
57
+
58
+ it("requires a lease-scoped directory", () => {
59
+ const productDir = makeTempDir("testkit-playwright-product-");
60
+ const cwd = path.join(productDir, "frontend");
61
+ fs.mkdirSync(cwd, { recursive: true });
62
+
63
+ expect(() =>
64
+ ensurePlaywrightTestConfig(
65
+ { name: "frontend", productDir, stateDir: path.join(productDir, ".testkit", "frontend") },
66
+ cwd,
67
+ ["frontend/__testkit__/homepage/homepage.pw.testkit.ts"]
68
+ )
69
+ ).toThrow('Playwright task for service "frontend" requires a lease-scoped directory');
50
70
  });
51
71
 
52
72
  it("finds a supported playwright config file", () => {
@@ -1,16 +1,16 @@
1
1
  import path from "path";
2
2
  import { execa } from "execa";
3
- import {
4
- parsePlaywrightJsonResults,
5
- } from "../reporters/playwright.mjs";
6
- import { resolveServiceCwd, } from "../config/index.mjs";
7
- import { formatPlaywrightBatchFiles } from "./formatting.mjs";
8
- import { printBufferedOutput } from "./processes.mjs";
3
+ import { parsePlaywrightJsonResults } from "../reporters/playwright.mjs";
4
+ import { resolveServiceCwd } from "../config/index.mjs";
5
+ import { formatFileTimeoutBudgetError } from "../shared/file-timeout.mjs";
6
+ import { settleSubprocess } from "./default-runtime-runner.mjs";
9
7
  import { ensurePlaywrightTestConfig } from "./playwright-config.mjs";
10
- import { buildPlaywrightEnv } from "./template.mjs";
8
+ import { printBufferedOutput } from "./processes.mjs";
11
9
  import { normalizePathSeparators } from "./state.mjs";
10
+ import { buildPlaywrightEnv } from "./template.mjs";
11
+ import { killChildProcess } from "./processes.mjs";
12
12
 
13
- export async function runPlaywrightBatch(targetConfig, batch, lifecycle) {
13
+ export async function runPlaywrightTask(targetConfig, task, lifecycle, lease) {
14
14
  const local = targetConfig.testkit.local;
15
15
  if (!local?.baseUrl) {
16
16
  throw new Error(
@@ -18,69 +18,63 @@ export async function runPlaywrightBatch(targetConfig, batch, lifecycle) {
18
18
  );
19
19
  }
20
20
 
21
- console.log(
22
- `\n── ${targetConfig.stackLabel} playwright:${targetConfig.name} (${batch.tasks.length} file${batch.tasks.length === 1 ? "" : "s"})${formatPlaywrightBatchFiles(batch)} ──`
23
- );
24
-
25
21
  const cwd = resolveServiceCwd(targetConfig.productDir, local.cwd);
26
- const requestedFiles = batch.tasks.map((task) =>
27
- path.relative(cwd, path.join(targetConfig.productDir, task.file))
28
- );
29
- const playwrightConfigPath = ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles);
22
+ const requestedFile = path.relative(cwd, path.join(targetConfig.productDir, task.file));
23
+ const playwrightConfigPath = ensurePlaywrightTestConfig(targetConfig, cwd, [requestedFile], lease);
24
+ if (lifecycle.isStopRequested()) {
25
+ throw new Error(`testkit run interrupted before starting ${task.file}`);
26
+ }
30
27
  const startedAt = Date.now();
31
- const result = await execa(
28
+ const fileTimeoutSeconds = targetConfig.testkit.execution.fileTimeoutSeconds;
29
+ const subprocess = execa(
32
30
  "npx",
33
31
  ["playwright", "test", "--config", playwrightConfigPath, "--reporter=json"],
34
32
  {
35
33
  cwd,
36
- env: buildPlaywrightEnv(targetConfig, local.baseUrl, process.env),
34
+ env: buildPlaywrightEnv(targetConfig, local.baseUrl, lease, process.env),
37
35
  reject: false,
38
- cancelSignal: lifecycle.signal,
39
36
  forceKillAfterDelay: 5_000,
40
37
  }
41
38
  );
39
+ lifecycle.registerProcess(subprocess, () => {
40
+ killChildProcess(subprocess, "SIGINT");
41
+ });
42
+ if (lifecycle.isStopRequested()) {
43
+ const interruptSubprocess = () => killChildProcess(subprocess, "SIGINT");
44
+ if (subprocess.pid) interruptSubprocess();
45
+ else subprocess.once?.("spawn", interruptSubprocess);
46
+ }
47
+ console.log(`\n── ${targetConfig.runtimeLabel} playwright:${targetConfig.name} (${task.file}) ──`);
48
+ let result;
49
+ let timedOut;
50
+ try {
51
+ ({ result, timedOut } = await settleSubprocess(subprocess, fileTimeoutSeconds));
52
+ } finally {
53
+ lifecycle.unregisterProcess(subprocess.pid);
54
+ }
42
55
 
43
56
  if (result.stderr) {
44
- printBufferedOutput(result.stderr, `[${targetConfig.stackLabel}:${targetConfig.name}:playwright]`);
57
+ printBufferedOutput(result.stderr, `[${targetConfig.runtimeLabel}:${targetConfig.name}:playwright]`);
45
58
  }
46
59
 
47
- const parsed = parsePlaywrightJsonResults(result.stdout, cwd);
60
+ const parsed = parsePlaywrightJsonResults(result.stdout || "", cwd);
48
61
  const finishedAt = Date.now();
49
- const batchDurationMs = finishedAt - startedAt;
50
- const genericError =
51
- result.exitCode === 0
62
+ const durationMs = finishedAt - startedAt;
63
+ const relativeFile = normalizePathSeparators(requestedFile);
64
+ const fileResult = parsed.fileResults.get(relativeFile);
65
+ const genericError = timedOut
66
+ ? formatFileTimeoutBudgetError(fileTimeoutSeconds)
67
+ : result.exitCode === 0
52
68
  ? parsed.errors[0] || null
53
- : parsed.errors[0] ||
54
- result.stderr.trim() ||
55
- `Playwright exited with code ${result.exitCode}`;
69
+ : parsed.errors[0] || result.stderr.trim() || `Playwright exited with code ${result.exitCode}`;
56
70
 
57
- return batch.tasks.map((task) => {
58
- const relativeFile = normalizePathSeparators(
59
- path.relative(cwd, path.join(targetConfig.productDir, task.file))
60
- );
61
- const fileResult = parsed.fileResults.get(relativeFile);
62
- if (fileResult) {
63
- return {
64
- task,
65
- failed: fileResult.status === "failed",
66
- status: fileResult.status,
67
- error: fileResult.error,
68
- durationMs:
69
- fileResult.durationMs > 0
70
- ? fileResult.durationMs
71
- : Math.round(batchDurationMs / Math.max(1, batch.tasks.length)),
72
- startedAt,
73
- finishedAt,
74
- };
75
- }
76
-
77
- return {
78
- task,
79
- failed: result.exitCode !== 0,
80
- error: result.exitCode !== 0 ? genericError : null,
81
- durationMs: Math.round(batchDurationMs / Math.max(1, batch.tasks.length)),
82
- startedAt,
83
- finishedAt,
84
- };
85
- });
71
+ return {
72
+ task,
73
+ failed: timedOut ? true : fileResult ? fileResult.status === "failed" : result.exitCode !== 0,
74
+ status: timedOut ? "failed" : fileResult?.status || (result.exitCode === 0 ? "passed" : "failed"),
75
+ error: timedOut ? genericError : fileResult?.error || genericError,
76
+ durationMs: fileResult?.durationMs > 0 ? fileResult.durationMs : durationMs,
77
+ startedAt,
78
+ finishedAt,
79
+ };
86
80
  }
@@ -54,11 +54,11 @@ export async function assertLocalServicePortsAvailable(config, isPortInUse) {
54
54
  const owner = findPortOwner(config.productDir, socket);
55
55
  const ownerDetail = owner
56
56
  ? owner.active
57
- ? ` Active testkit run ${formatRunSummary(owner.manifest)} owns ${key} via ${owner.service.stackLabel}:${owner.service.serviceName}.`
57
+ ? ` Active testkit run ${formatRunSummary(owner.manifest)} owns ${key} via ${owner.service.runtimeLabel}:${owner.service.serviceName}.`
58
58
  : ` Stale testkit run ${formatRunSummary(owner.manifest)} owns ${key}.`
59
59
  : "";
60
60
  throw new Error(
61
- `Cannot start "${config.stackLabel}:${config.name}" because ${key} is already in use. ` +
61
+ `Cannot start "${config.runtimeLabel}:${config.name}" because ${key} is already in use. ` +
62
62
  `Stop the existing process and rerun testkit.${ownerDetail}`
63
63
  );
64
64
  }
@@ -96,7 +96,8 @@ export function buildRunArtifact({
96
96
  finishedAt,
97
97
  execution,
98
98
  workerCount,
99
- stackCount,
99
+ runtimeInstanceCount,
100
+ runtimeStats,
100
101
  typeValues,
101
102
  suiteSelectors,
102
103
  fileNames,
@@ -137,8 +138,8 @@ export function buildRunArtifact({
137
138
  workers: execution.workers,
138
139
  fileTimeoutSeconds: execution.fileTimeoutSeconds,
139
140
  workerCount,
140
- stackMode: execution.stackMode,
141
- stackCount,
141
+ runtimeInstanceCount,
142
+ runtimeStats: runtimeStats || [],
142
143
  dbBackend,
143
144
  types: typeValues,
144
145
  suiteSelectors: suiteSelectors.map((selector) => selector.raw),
@@ -54,11 +54,9 @@ describe("runner reporting", () => {
54
54
  execution: {
55
55
  workers: 2,
56
56
  fileTimeoutSeconds: 60,
57
- stackMode: "shared",
58
- stackCount: 1,
59
57
  },
60
58
  workerCount: 1,
61
- stackCount: 1,
59
+ runtimeInstanceCount: 2,
62
60
  typeValues: ["all"],
63
61
  suiteSelectors: [],
64
62
  fileNames: [],
@@ -85,8 +83,7 @@ describe("runner reporting", () => {
85
83
  workers: 2,
86
84
  fileTimeoutSeconds: 60,
87
85
  workerCount: 1,
88
- stackMode: "shared",
89
- stackCount: 1,
86
+ runtimeInstanceCount: 2,
90
87
  });
91
88
  expect(artifact.summary.services).toEqual({
92
89
  total: 1,
@@ -142,7 +142,7 @@ export function recordTaskOutcome(trackers, task, outcome, finishedAt = Date.now
142
142
  }
143
143
 
144
144
  export function recordGraphError(trackers, graph, message, now = Date.now()) {
145
- const targetNames = graph?.assignedTargets || [];
145
+ const targetNames = graph?.targetNames || [];
146
146
  for (const targetName of targetNames) {
147
147
  const tracker = trackers.get(targetName);
148
148
  if (tracker && !tracker.skipped) {