@elench/testkit 0.1.41 → 0.1.43

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.
@@ -1,11 +1,13 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import crypto from "crypto";
4
+ import { execFileSync } from "child_process";
4
5
  import { destroyRuntimeDatabase, cleanupOrphanedLocalInfrastructure } from "../database/index.mjs";
5
6
 
6
7
  const RUN_SCHEMA_VERSION = 1;
7
8
  const RUNS_DIRNAME = path.join(".testkit", "_runs");
8
9
  const TERMINATION_TIMEOUT_MS = 5_000;
10
+ const SHUTDOWN_HOLD_TIMEOUT_MS = 10_000;
9
11
 
10
12
  export function createRunLifecycle(productDir) {
11
13
  const runId = buildRunId();
@@ -25,6 +27,9 @@ export function createRunLifecycle(productDir) {
25
27
  runtimeStateDirs: [],
26
28
  };
27
29
  const signalListeners = [];
30
+ const managedProcesses = new Set();
31
+ let shutdownHold = null;
32
+ let shutdownHoldTimeout = null;
28
33
 
29
34
  function persist() {
30
35
  fs.mkdirSync(getRunsDir(productDir), { recursive: true });
@@ -36,6 +41,24 @@ export function createRunLifecycle(productDir) {
36
41
  persist();
37
42
  }
38
43
 
44
+ function ensureShutdownHold() {
45
+ if (shutdownHold) return;
46
+ shutdownHold = setInterval(() => {}, 1_000);
47
+ shutdownHoldTimeout = setTimeout(() => {
48
+ releaseShutdownHold();
49
+ }, SHUTDOWN_HOLD_TIMEOUT_MS);
50
+ }
51
+
52
+ function releaseShutdownHold() {
53
+ if (!shutdownHold) return;
54
+ clearInterval(shutdownHold);
55
+ shutdownHold = null;
56
+ if (shutdownHoldTimeout) {
57
+ clearTimeout(shutdownHoldTimeout);
58
+ shutdownHoldTimeout = null;
59
+ }
60
+ }
61
+
39
62
  const api = {
40
63
  runId,
41
64
  manifestPath,
@@ -55,9 +78,18 @@ export function createRunLifecycle(productDir) {
55
78
  });
56
79
  },
57
80
  requestStop(reason = "interrupted") {
81
+ ensureShutdownHold();
58
82
  if (!abortController.signal.aborted) {
59
83
  abortController.abort(new Error(`testkit run interrupted (${reason})`));
60
84
  }
85
+ for (const entry of managedProcesses) {
86
+ try {
87
+ entry.terminate?.();
88
+ } catch {
89
+ // Best-effort interruption only.
90
+ }
91
+ }
92
+ terminateDescendantProcesses(process.pid, "SIGINT");
61
93
  mutate((draft) => {
62
94
  draft.status = "interrupting";
63
95
  draft.interruptReason = reason;
@@ -72,7 +104,22 @@ export function createRunLifecycle(productDir) {
72
104
  }
73
105
  });
74
106
  },
75
- registerService(config, child, cwd) {
107
+ registerProcess(child, terminate) {
108
+ if (!child) return;
109
+ managedProcesses.add({
110
+ child,
111
+ terminate,
112
+ });
113
+ },
114
+ unregisterProcess(childPid) {
115
+ for (const entry of managedProcesses) {
116
+ if (entry.child?.pid === childPid) {
117
+ managedProcesses.delete(entry);
118
+ }
119
+ }
120
+ },
121
+ registerService(config, child, cwd, terminate) {
122
+ api.registerProcess(child, terminate);
76
123
  const ports = collectConfigPorts(config);
77
124
  mutate((draft) => {
78
125
  draft.services = draft.services.filter((service) => service.pid !== child.pid);
@@ -89,6 +136,7 @@ export function createRunLifecycle(productDir) {
89
136
  });
90
137
  },
91
138
  unregisterService(childPid) {
139
+ api.unregisterProcess(childPid);
92
140
  mutate((draft) => {
93
141
  draft.services = draft.services.filter((service) => service.pid !== childPid);
94
142
  });
@@ -116,6 +164,9 @@ export function createRunLifecycle(productDir) {
116
164
  removeManifest() {
117
165
  removeManifestFile(productDir, runId);
118
166
  },
167
+ dispose() {
168
+ releaseShutdownHold();
169
+ },
119
170
  };
120
171
 
121
172
  return api;
@@ -264,6 +315,53 @@ function killProcessGroup(pid, signal) {
264
315
  }
265
316
  }
266
317
 
318
+ function terminateDescendantProcesses(rootPid, signal) {
319
+ const descendants = listDescendantPids(rootPid);
320
+ for (const pid of descendants) {
321
+ try {
322
+ process.kill(pid, signal);
323
+ } catch (error) {
324
+ if (error?.code !== "ESRCH") {
325
+ // Best-effort interruption only.
326
+ }
327
+ }
328
+ }
329
+ }
330
+
331
+ function listDescendantPids(rootPid) {
332
+ try {
333
+ const output = execFileSync("ps", ["-eo", "pid=,ppid="], {
334
+ encoding: "utf8",
335
+ stdio: ["ignore", "pipe", "ignore"],
336
+ });
337
+ const childrenByParent = new Map();
338
+ for (const line of output.split(/\r?\n/)) {
339
+ const trimmed = line.trim();
340
+ if (!trimmed) continue;
341
+ const [pidRaw, parentRaw] = trimmed.split(/\s+/);
342
+ const pid = Number(pidRaw);
343
+ const parentPid = Number(parentRaw);
344
+ if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue;
345
+ const siblings = childrenByParent.get(parentPid) || [];
346
+ siblings.push(pid);
347
+ childrenByParent.set(parentPid, siblings);
348
+ }
349
+
350
+ const descendants = [];
351
+ const stack = [...(childrenByParent.get(rootPid) || [])];
352
+ while (stack.length > 0) {
353
+ const pid = stack.pop();
354
+ descendants.push(pid);
355
+ for (const childPid of childrenByParent.get(pid) || []) {
356
+ stack.push(childPid);
357
+ }
358
+ }
359
+ return descendants;
360
+ } catch {
361
+ return [];
362
+ }
363
+ }
364
+
267
365
  async function waitForPidExit(pid, timeoutMs) {
268
366
  const startedAt = Date.now();
269
367
  while (Date.now() - startedAt < timeoutMs) {
@@ -15,6 +15,7 @@ import {
15
15
  summarizeDbBackend,
16
16
  } from "./results.mjs";
17
17
  import { buildRunArtifact, buildStatusArtifact } from "./reporting.mjs";
18
+ import { applyKnownFailuresToArtifacts, loadKnownFailuresConfig } from "./triage.mjs";
18
19
  import { buildRunSummaryLines, formatError } from "./formatting.mjs";
19
20
  import {
20
21
  loadTimings,
@@ -56,6 +57,10 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
56
57
  },
57
58
  testkitVersion: readPackageMetadata().version,
58
59
  };
60
+ const knownFailures = loadKnownFailuresConfig(
61
+ productDir,
62
+ configs[0]?.testkit?.reporting || null
63
+ );
59
64
  const requestedFiles = opts.fileNames || [];
60
65
  if (requestedFiles.length > 0) {
61
66
  const unmatchedFiles = findUnmatchedRequestedFiles(
@@ -106,6 +111,7 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
106
111
  const executedPlans = servicePlans.filter((plan) => !plan.skipped);
107
112
  let workerCount = 0;
108
113
  let runtimeInstanceCount = 0;
114
+ let runtimeStats = [];
109
115
  let exitCode = 0;
110
116
  const lifecycle = createRunLifecycle(productDir);
111
117
  lifecycle.markRunning();
@@ -155,6 +161,7 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
155
161
  }
156
162
  }
157
163
  }
164
+ runtimeStats = runtimeManager.getStats();
158
165
  } finally {
159
166
  await runtimeManager.cleanupAll();
160
167
  }
@@ -166,7 +173,7 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
166
173
  results = configs.map((config) =>
167
174
  finalizeServiceResult(trackers.get(config.name), startedAt, finishedAt)
168
175
  );
169
- const artifact = buildRunArtifact({
176
+ const runArtifact = buildRunArtifact({
170
177
  productDir,
171
178
  results,
172
179
  startedAt,
@@ -174,6 +181,7 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
174
181
  execution,
175
182
  workerCount,
176
183
  runtimeInstanceCount,
184
+ runtimeStats,
177
185
  typeValues,
178
186
  suiteSelectors,
179
187
  fileNames: requestedFiles,
@@ -182,12 +190,8 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
182
190
  metadata,
183
191
  summarizeDbBackend,
184
192
  });
185
-
186
- writeRunArtifact(productDir, artifact);
187
- if (opts.writeStatus) {
188
- writeStatusArtifact(
189
- productDir,
190
- buildStatusArtifact({
193
+ const statusArtifact = opts.writeStatus
194
+ ? buildStatusArtifact({
191
195
  productDir,
192
196
  results,
193
197
  typeValues,
@@ -197,14 +201,26 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
197
201
  serviceFilter: opts.serviceFilter || null,
198
202
  metadata,
199
203
  })
200
- );
204
+ : null;
205
+ const enrichedArtifacts = applyKnownFailuresToArtifacts(
206
+ runArtifact,
207
+ statusArtifact,
208
+ knownFailures
209
+ );
210
+
211
+ writeRunArtifact(productDir, enrichedArtifacts.runArtifact);
212
+ if (opts.writeStatus) {
213
+ writeStatusArtifact(productDir, enrichedArtifacts.statusArtifact);
201
214
  }
202
215
 
203
216
  printRunSummary(results, finishedAt - startedAt);
204
- await reportTelemetry(telemetry, artifact);
217
+ await reportTelemetry(telemetry, enrichedArtifacts.runArtifact);
205
218
  if (results.some((result) => result.failed)) exitCode = 1;
206
219
  if (lifecycle.isStopRequested()) exitCode = Math.max(exitCode, 130);
207
220
  } finally {
221
+ if (lifecycle.isStopRequested()) {
222
+ exitCode = Math.max(exitCode, 130);
223
+ }
208
224
  lifecycle.removeSignalHandlers();
209
225
  lifecycle.markFinished(
210
226
  exitCode === 0 ? "finished" : lifecycle.isStopRequested() ? "interrupted" : "failed"
@@ -212,6 +228,7 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
212
228
  await cleanupRunById(productDir, lifecycle.runId);
213
229
  await cleanupRuns(productDir, { includeActive: false });
214
230
  lifecycle.removeManifest();
231
+ lifecycle.dispose();
215
232
  process.exitCode = exitCode;
216
233
  }
217
234
  }
@@ -113,6 +113,10 @@ export function buildRuntimeGraphs(servicePlans) {
113
113
  if (existing) {
114
114
  existing.targetNames.push(plan.config.name);
115
115
  existing.instanceCount = Math.max(existing.instanceCount, plan.config.testkit.runtime.instances);
116
+ existing.maxConcurrentTasks = Math.min(
117
+ existing.maxConcurrentTasks,
118
+ resolveGraphMaxConcurrentTasks(plan.runtimeConfigs)
119
+ );
116
120
  continue;
117
121
  }
118
122
 
@@ -123,6 +127,7 @@ export function buildRuntimeGraphs(servicePlans) {
123
127
  targetNames: [plan.config.name],
124
128
  dirName: buildGraphDirName(plan.runtimeNames),
125
129
  instanceCount: plan.config.testkit.runtime.instances,
130
+ maxConcurrentTasks: resolveGraphMaxConcurrentTasks(plan.runtimeConfigs),
126
131
  };
127
132
  graphs.push(graph);
128
133
  graphByRuntimeKey.set(plan.runtimeKey, graph);
@@ -166,6 +171,7 @@ export function buildTaskQueue(servicePlans, graphs, timings) {
166
171
  orderIndex: suite.orderIndex,
167
172
  file,
168
173
  locks: resolveTaskLocks(plan.config, suite, file),
174
+ resourceCost: 1,
169
175
  timingKey,
170
176
  estimatedDurationMs: estimateTaskDuration(timings, timingKey, suite),
171
177
  });
@@ -183,16 +189,25 @@ export function buildTaskQueue(servicePlans, graphs, timings) {
183
189
  );
184
190
  }
185
191
 
186
- export function claimNextTask(queue, preferredGraphKey) {
192
+ export function claimNextTask(queue, preferredGraphKey, isRunnable = () => true) {
187
193
  if (queue.length === 0) return null;
188
194
 
189
- let index = -1;
190
- if (preferredGraphKey) {
191
- index = queue.findIndex((task) => task.graphKey === preferredGraphKey);
195
+ const preferredIndexes = [];
196
+ const fallbackIndexes = [];
197
+ for (let index = 0; index < queue.length; index += 1) {
198
+ if (preferredGraphKey && queue[index].graphKey === preferredGraphKey) {
199
+ preferredIndexes.push(index);
200
+ } else {
201
+ fallbackIndexes.push(index);
202
+ }
203
+ }
204
+
205
+ for (const index of [...preferredIndexes, ...fallbackIndexes]) {
206
+ if (!isRunnable(queue[index])) continue;
207
+ return queue.splice(index, 1)[0];
192
208
  }
193
- if (index === -1) index = 0;
194
209
 
195
- return queue.splice(index, 1)[0];
210
+ return null;
196
211
  }
197
212
 
198
213
  export function buildGraphDirName(runtimeNames) {
@@ -267,3 +282,10 @@ function normalizePathSeparators(filePath) {
267
282
  function slugSegment(value) {
268
283
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "svc";
269
284
  }
285
+
286
+ function resolveGraphMaxConcurrentTasks(runtimeConfigs) {
287
+ return runtimeConfigs.reduce(
288
+ (currentMin, config) => Math.min(currentMin, config.testkit.runtime.maxConcurrentTasks),
289
+ Number.POSITIVE_INFINITY
290
+ );
291
+ }
@@ -22,6 +22,7 @@ function makeConfig(name, extras = {}) {
22
22
  },
23
23
  runtime: providedTestkit.runtime || {
24
24
  instances: 1,
25
+ maxConcurrentTasks: Number.POSITIVE_INFINITY,
25
26
  },
26
27
  requirements: providedTestkit.requirements || {
27
28
  suites: [],
@@ -156,6 +157,7 @@ describe("runner-planning", () => {
156
157
  testkit: {
157
158
  runtime: {
158
159
  instances: 3,
160
+ maxConcurrentTasks: 2,
159
161
  },
160
162
  requirements: {
161
163
  suites: [
@@ -226,6 +228,7 @@ describe("runner-planning", () => {
226
228
  testkit: {
227
229
  runtime: {
228
230
  instances: 2,
231
+ maxConcurrentTasks: 4,
229
232
  },
230
233
  },
231
234
  });
@@ -234,6 +237,7 @@ describe("runner-planning", () => {
234
237
  testkit: {
235
238
  runtime: {
236
239
  instances: 1,
240
+ maxConcurrentTasks: 2,
237
241
  },
238
242
  },
239
243
  });
@@ -282,11 +286,13 @@ describe("runner-planning", () => {
282
286
  expect.objectContaining({
283
287
  key: "api",
284
288
  instanceCount: 2,
289
+ maxConcurrentTasks: 4,
285
290
  targetNames: ["api"],
286
291
  }),
287
292
  expect.objectContaining({
288
293
  key: "api|frontend",
289
294
  instanceCount: 1,
295
+ maxConcurrentTasks: 2,
290
296
  targetNames: ["frontend"],
291
297
  }),
292
298
  ]);
@@ -304,4 +310,36 @@ describe("runner-planning", () => {
304
310
  graphKey: "api",
305
311
  });
306
312
  });
313
+
314
+ it("skips blocked preferred-graph work and claims the next runnable task", () => {
315
+ const queue = [
316
+ {
317
+ id: 1,
318
+ graphKey: "api",
319
+ file: "blocked.js",
320
+ },
321
+ {
322
+ id: 2,
323
+ graphKey: "api|frontend",
324
+ file: "runnable.js",
325
+ },
326
+ ];
327
+
328
+ const claimed = claimNextTask(
329
+ queue,
330
+ "api",
331
+ (task) => task.file === "runnable.js"
332
+ );
333
+
334
+ expect(claimed).toMatchObject({
335
+ id: 2,
336
+ file: "runnable.js",
337
+ });
338
+ expect(queue).toEqual([
339
+ expect.objectContaining({
340
+ id: 1,
341
+ file: "blocked.js",
342
+ }),
343
+ ]);
344
+ });
307
345
  });
@@ -28,6 +28,9 @@ export function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles, le
28
28
  ` testDir: ${JSON.stringify(cwd)},\n` +
29
29
  ` testMatch: ${JSON.stringify(normalizedFiles)},\n` +
30
30
  ` outputDir: ${JSON.stringify(outputDir)},\n` +
31
+ ` workers: 1,\n` +
32
+ ` fullyParallel: false,\n` +
33
+ ` webServer: undefined,\n` +
31
34
  `};\n`;
32
35
  } else {
33
36
  source =
@@ -35,6 +38,8 @@ export function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles, le
35
38
  ` testDir: ${JSON.stringify(cwd)},\n` +
36
39
  ` testMatch: ${JSON.stringify(normalizedFiles)},\n` +
37
40
  ` outputDir: ${JSON.stringify(outputDir)},\n` +
41
+ ` workers: 1,\n` +
42
+ ` fullyParallel: false,\n` +
38
43
  `};\n`;
39
44
  }
40
45
 
@@ -31,7 +31,7 @@ describe("runner-playwright-config", () => {
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(
@@ -44,10 +44,15 @@ describe("runner-playwright-config", () => {
44
44
 
45
45
  const expectedOutputDir = resolvePlaywrightOutputDir(leaseDir);
46
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();
47
50
  expect(fs.existsSync(expectedOutputDir)).toBe(true);
48
51
  expect(fs.readFileSync(configPath, "utf8")).toContain(
49
52
  `outputDir: ${JSON.stringify(expectedOutputDir)}`
50
53
  );
54
+ expect(fs.readFileSync(configPath, "utf8")).toContain("workers: 1");
55
+ expect(fs.readFileSync(configPath, "utf8")).toContain("fullyParallel: false");
51
56
  });
52
57
 
53
58
  it("requires a lease-scoped directory", () => {
@@ -8,6 +8,7 @@ import { ensurePlaywrightTestConfig } from "./playwright-config.mjs";
8
8
  import { printBufferedOutput } from "./processes.mjs";
9
9
  import { normalizePathSeparators } from "./state.mjs";
10
10
  import { buildPlaywrightEnv } from "./template.mjs";
11
+ import { killChildProcess } from "./processes.mjs";
11
12
 
12
13
  export async function runPlaywrightTask(targetConfig, task, lifecycle, lease) {
13
14
  const local = targetConfig.testkit.local;
@@ -17,11 +18,12 @@ export async function runPlaywrightTask(targetConfig, task, lifecycle, lease) {
17
18
  );
18
19
  }
19
20
 
20
- console.log(`\n── ${targetConfig.runtimeLabel} playwright:${targetConfig.name} (${task.file}) ──`);
21
-
22
21
  const cwd = resolveServiceCwd(targetConfig.productDir, local.cwd);
23
22
  const requestedFile = path.relative(cwd, path.join(targetConfig.productDir, task.file));
24
23
  const playwrightConfigPath = ensurePlaywrightTestConfig(targetConfig, cwd, [requestedFile], lease);
24
+ if (lifecycle.isStopRequested()) {
25
+ throw new Error(`testkit run interrupted before starting ${task.file}`);
26
+ }
25
27
  const startedAt = Date.now();
26
28
  const fileTimeoutSeconds = targetConfig.testkit.execution.fileTimeoutSeconds;
27
29
  const subprocess = execa(
@@ -31,11 +33,25 @@ export async function runPlaywrightTask(targetConfig, task, lifecycle, lease) {
31
33
  cwd,
32
34
  env: buildPlaywrightEnv(targetConfig, local.baseUrl, lease, process.env),
33
35
  reject: false,
34
- cancelSignal: lifecycle.signal,
35
36
  forceKillAfterDelay: 5_000,
36
37
  }
37
38
  );
38
- const { result, timedOut } = await settleSubprocess(subprocess, fileTimeoutSeconds);
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
+ }
39
55
 
40
56
  if (result.stderr) {
41
57
  printBufferedOutput(result.stderr, `[${targetConfig.runtimeLabel}:${targetConfig.name}:playwright]`);
@@ -60,5 +76,6 @@ export async function runPlaywrightTask(targetConfig, task, lifecycle, lease) {
60
76
  durationMs: fileResult?.durationMs > 0 ? fileResult.durationMs : durationMs,
61
77
  startedAt,
62
78
  finishedAt,
79
+ failureDetails: timedOut ? [] : fileResult?.failureDetails || [],
63
80
  };
64
81
  }
@@ -23,9 +23,15 @@ export function buildStatusArtifact({
23
23
  path: file.path,
24
24
  status: file.status,
25
25
  };
26
+ if (file.error) {
27
+ test.error = file.error;
28
+ }
26
29
  if (file.reason) {
27
30
  test.reason = file.reason;
28
31
  }
32
+ if (Array.isArray(file.failureDetails) && file.failureDetails.length > 0) {
33
+ test.failureDetails = file.failureDetails;
34
+ }
29
35
  tests.push(test);
30
36
  }
31
37
  }
@@ -72,7 +78,7 @@ export function buildStatusArtifact({
72
78
  scope.serviceFilter === null;
73
79
 
74
80
  return {
75
- schemaVersion: 3,
81
+ schemaVersion: 4,
76
82
  source: "testkit",
77
83
  notice: "Generated file. Do not edit manually.",
78
84
  product: {
@@ -97,6 +103,7 @@ export function buildRunArtifact({
97
103
  execution,
98
104
  workerCount,
99
105
  runtimeInstanceCount,
106
+ runtimeStats,
100
107
  typeValues,
101
108
  suiteSelectors,
102
109
  fileNames,
@@ -120,7 +127,7 @@ export function buildRunArtifact({
120
127
  const dbBackend = summarizeDbBackend(results);
121
128
 
122
129
  return {
123
- schemaVersion: 3,
130
+ schemaVersion: 4,
124
131
  source: "testkit",
125
132
  generatedAt: new Date(finishedAt).toISOString(),
126
133
  product: {
@@ -138,6 +145,7 @@ export function buildRunArtifact({
138
145
  fileTimeoutSeconds: execution.fileTimeoutSeconds,
139
146
  workerCount,
140
147
  runtimeInstanceCount,
148
+ runtimeStats: runtimeStats || [],
141
149
  dbBackend,
142
150
  types: typeValues,
143
151
  suiteSelectors: suiteSelectors.map((selector) => selector.raw),
@@ -78,7 +78,7 @@ describe("runner reporting", () => {
78
78
  });
79
79
 
80
80
  expect(artifact.product.name).toBe("my-product");
81
- expect(artifact.schemaVersion).toBe(3);
81
+ expect(artifact.schemaVersion).toBe(4);
82
82
  expect(artifact.run).toMatchObject({
83
83
  workers: 2,
84
84
  fileTimeoutSeconds: 60,
@@ -149,7 +149,7 @@ describe("runner reporting", () => {
149
149
  });
150
150
 
151
151
  expect(status).toEqual({
152
- schemaVersion: 3,
152
+ schemaVersion: 4,
153
153
  source: "testkit",
154
154
  notice: "Generated file. Do not edit manually.",
155
155
  product: {
@@ -1,4 +1,5 @@
1
1
  import path from "path";
2
+ import { mergeFailureDetails } from "./failure-details.mjs";
2
3
 
3
4
  export function buildServiceTrackers(servicePlans, startedAt) {
4
5
  const trackers = new Map();
@@ -46,6 +47,7 @@ export function buildServiceTrackers(servicePlans, startedAt) {
46
47
  reason: null,
47
48
  status: "not_run",
48
49
  artifacts: [],
50
+ failureDetails: [],
49
51
  },
50
52
  ];
51
53
  }),
@@ -59,6 +61,7 @@ export function buildServiceTrackers(servicePlans, startedAt) {
59
61
  reason: file.reason,
60
62
  status: "skipped",
61
63
  artifacts: [],
64
+ failureDetails: [],
62
65
  },
63
66
  ]),
64
67
  ]),
@@ -121,6 +124,7 @@ export function recordTaskOutcome(trackers, task, outcome, finishedAt = Date.now
121
124
  existingFileResult.reason = outcome.reason || null;
122
125
  existingFileResult.status = status;
123
126
  existingFileResult.artifacts = Array.isArray(outcome.artifacts) ? outcome.artifacts : [];
127
+ existingFileResult.failureDetails = mergeFailureDetails(outcome.failureDetails);
124
128
  } else {
125
129
  suite.fileResultsByPath.set(normalizedPath, {
126
130
  path: normalizedPath,
@@ -130,6 +134,7 @@ export function recordTaskOutcome(trackers, task, outcome, finishedAt = Date.now
130
134
  reason: outcome.reason || null,
131
135
  status,
132
136
  artifacts: Array.isArray(outcome.artifacts) ? outcome.artifacts : [],
137
+ failureDetails: mergeFailureDetails(outcome.failureDetails),
133
138
  });
134
139
  }
135
140
  if (status === "failed" && !suite.failedFileSet.has(task.file)) {
@@ -247,6 +252,9 @@ function finalizeSuite(suite) {
247
252
  durationMs: file.durationMs,
248
253
  error: file.error,
249
254
  reason: file.reason,
255
+ ...(Array.isArray(file.failureDetails) && file.failureDetails.length > 0
256
+ ? { failureDetails: file.failureDetails }
257
+ : {}),
250
258
  ...(Array.isArray(file.artifacts) && file.artifacts.length > 0
251
259
  ? { artifacts: file.artifacts }
252
260
  : {}),