@elench/testkit 0.1.112 → 0.1.114

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,6 +1,5 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import { execa } from "execa";
4
3
  import { bundleK6File } from "../bundler/index.mjs";
5
4
  import { resolveK6Binary } from "../config/binaries.mjs";
6
5
  import {
@@ -12,12 +11,12 @@ import {
12
11
  determineDefaultRuntimeFailure,
13
12
  extractDefaultRuntimeFatalDetail,
14
13
  } from "./default-runtime-errors.mjs";
15
- import { collectFailureDetailsFromRuntimeArtifacts } from "./failure-details.mjs";
14
+ import { collectFailureDetailsFromRuntimeArtifacts, collectCheckDetailsFromRuntimeArtifacts } from "./failure-details.mjs";
16
15
  import { RUNTIME_ARTIFACT_MARKER } from "../runtime-src/k6/artifacts.js";
17
16
  import { readDatabaseUrl } from "./state-io.mjs";
18
17
  import { buildTaskExecutionEnv } from "./template.mjs";
19
18
  import { registerManagedProcess, unregisterManagedProcess } from "./managed-processes.mjs";
20
- import { killChildProcess } from "./processes.mjs";
19
+ import { settleManagedSubprocess, startManagedSubprocess } from "./subprocess.mjs";
21
20
 
22
21
  export async function runHttpK6Task(targetConfig, task, lifecycle, lease, reporter = null) {
23
22
  const baseUrl = targetConfig.testkit.local?.baseUrl;
@@ -88,7 +87,7 @@ export async function runDefaultRuntimeTask(
88
87
  fs.mkdirSync(path.dirname(summaryFile), { recursive: true });
89
88
  const startedAt = Date.now();
90
89
  const fileTimeoutSeconds = targetConfig.testkit.execution.fileTimeoutSeconds;
91
- const subprocess = execa(
90
+ const subprocess = startManagedSubprocess(
92
91
  k6Binary,
93
92
  [...args.slice(0, 1), "--summary-export", summaryFile, ...args.slice(1)],
94
93
  {
@@ -102,8 +101,6 @@ export async function runDefaultRuntimeTask(
102
101
  },
103
102
  process.env
104
103
  ),
105
- reject: false,
106
- forceKillAfterDelay: 5_000,
107
104
  }
108
105
  );
109
106
  registerManagedProcess(lifecycle, subprocess, "SIGINT");
@@ -111,7 +108,9 @@ export async function runDefaultRuntimeTask(
111
108
  let result;
112
109
  let timedOut;
113
110
  try {
114
- ({ result, timedOut } = await settleSubprocess(subprocess, fileTimeoutSeconds));
111
+ ({ result, timedOut } = await settleManagedSubprocess(subprocess, {
112
+ timeoutMs: fileTimeoutSeconds * 1000 + 1_000,
113
+ }));
115
114
  } finally {
116
115
  unregisterManagedProcess(lifecycle, subprocess);
117
116
  }
@@ -147,6 +146,7 @@ export async function runDefaultRuntimeTask(
147
146
  : null,
148
147
  ]);
149
148
  const failureDetails = collectFailureDetailsFromRuntimeArtifacts(rawRuntimeArtifacts);
149
+ const checkDetails = collectCheckDetailsFromRuntimeArtifacts(rawRuntimeArtifacts);
150
150
  const fatalRuntimeDetail = extractDefaultRuntimeFatalDetail(result.stderr || "", getFirstLine);
151
151
  if (fatalRuntimeDetail && !failureDetails.some((detail) => detail?.kind === "runtime-exception")) {
152
152
  failureDetails.unshift(fatalRuntimeDetail);
@@ -165,31 +165,10 @@ export async function runDefaultRuntimeTask(
165
165
  finishedAt,
166
166
  artifacts: [...runtimeArtifacts, ...outputArtifacts],
167
167
  failureDetails,
168
+ checkDetails,
168
169
  };
169
170
  }
170
171
 
171
- export async function settleSubprocess(subprocess, fileTimeoutSeconds) {
172
- const timeoutMs = fileTimeoutSeconds * 1000 + 1_000;
173
- let timeoutHandle = null;
174
- let timedOut = false;
175
-
176
- try {
177
- return await Promise.race([
178
- subprocess.then((result) => ({ result, timedOut })),
179
- new Promise((resolve) => {
180
- timeoutHandle = setTimeout(async () => {
181
- timedOut = true;
182
- killChildProcess(subprocess, "SIGTERM");
183
- const result = await subprocess.catch((error) => error);
184
- resolve({ result, timedOut: true });
185
- }, timeoutMs);
186
- }),
187
- ]);
188
- } finally {
189
- if (timeoutHandle) clearTimeout(timeoutHandle);
190
- }
191
- }
192
-
193
172
  export function buildDefaultRuntimeSummaryPath(lease, task) {
194
173
  return path.join(lease.leaseDir, "default-runtime", `task-${task.id}.summary.json`);
195
174
  }
@@ -90,6 +90,28 @@ export function collectFailureDetailsFromRuntimeArtifacts(artifacts) {
90
90
  return mergeFailureDetails(details);
91
91
  }
92
92
 
93
+ export function collectCheckDetailsFromRuntimeArtifacts(artifacts) {
94
+ const checks = [];
95
+
96
+ for (const artifact of Array.isArray(artifacts) ? artifacts : []) {
97
+ if (artifact?.kind !== "testkit.checks") continue;
98
+ for (const check of Array.isArray(artifact?.data?.checks) ? artifact.data.checks : []) {
99
+ const name = normalizeNonEmptyString(check?.name) || "unnamed";
100
+ const passes = Math.max(0, Number(check?.passes) || 0);
101
+ const fails = Math.max(0, Number(check?.fails) || 0);
102
+ checks.push({
103
+ name,
104
+ path: normalizeStringArray(check?.path),
105
+ passes,
106
+ fails,
107
+ passed: passes > 0 && fails === 0,
108
+ });
109
+ }
110
+ }
111
+
112
+ return checks;
113
+ }
114
+
93
115
  function normalizeStringArray(value) {
94
116
  if (!Array.isArray(value)) return [];
95
117
  return value
@@ -1,8 +1,8 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import crypto from "crypto";
4
- import { execFileSync } from "child_process";
5
4
  import { destroyRuntimeDatabase, cleanupOrphanedLocalInfrastructure } from "../database/index.mjs";
5
+ import { killProcessTree, listDescendantPids } from "./processes.mjs";
6
6
 
7
7
  const RUN_SCHEMA_VERSION = 1;
8
8
  const RUNS_DIRNAME = path.join(".testkit", "_runs");
@@ -297,22 +297,7 @@ async function terminateOwnedProcess(service) {
297
297
  }
298
298
 
299
299
  function killProcessGroup(pid, signal) {
300
- try {
301
- process.kill(-pid, signal);
302
- return;
303
- } catch (error) {
304
- if (error?.code !== "ESRCH") {
305
- // Fall through and try the direct pid.
306
- } else {
307
- return;
308
- }
309
- }
310
-
311
- try {
312
- process.kill(pid, signal);
313
- } catch (error) {
314
- if (error?.code !== "ESRCH") throw error;
315
- }
300
+ killProcessTree(pid, signal);
316
301
  }
317
302
 
318
303
  function terminateDescendantProcesses(rootPid, signal) {
@@ -328,40 +313,6 @@ function terminateDescendantProcesses(rootPid, signal) {
328
313
  }
329
314
  }
330
315
 
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
-
365
316
  async function waitForPidExit(pid, timeoutMs) {
366
317
  const startedAt = Date.now();
367
318
  while (Date.now() - startedAt < timeoutMs) {
@@ -1,8 +1,9 @@
1
1
  import { killChildProcess } from "./processes.mjs";
2
+ import { terminateSubprocess } from "./subprocess.mjs";
2
3
 
3
4
  export function registerManagedProcess(lifecycle, subprocess, signal = "SIGINT") {
4
5
  lifecycle.registerProcess(subprocess, () => {
5
- killChildProcess(subprocess, signal);
6
+ void terminateSubprocess(subprocess, { gracefulSignal: signal });
6
7
  });
7
8
  if (lifecycle.isStopRequested()) {
8
9
  interruptManagedProcess(subprocess, signal);
@@ -3,7 +3,7 @@ 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, lease) {
6
+ export function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles, lease, options = {}) {
7
7
  if (!lease?.leaseDir) {
8
8
  throw new Error(
9
9
  `Playwright task for service "${targetConfig.name}" requires a lease-scoped directory`
@@ -17,6 +17,7 @@ export function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles, le
17
17
  const configPath = path.join(stateDir, "playwright.testkit.config.mjs");
18
18
  const baseConfigPath = findPlaywrightConfig(cwd);
19
19
  const normalizedFiles = requestedFiles.map(normalizePathSeparators);
20
+ const globalTimeoutMs = normalizeOptionalPositiveInteger(options.globalTimeoutMs, "globalTimeoutMs");
20
21
 
21
22
  let source = "";
22
23
  if (baseConfigPath) {
@@ -28,6 +29,7 @@ export function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles, le
28
29
  ` testDir: ${JSON.stringify(cwd)},\n` +
29
30
  ` testMatch: ${JSON.stringify(normalizedFiles)},\n` +
30
31
  ` outputDir: ${JSON.stringify(outputDir)},\n` +
32
+ (globalTimeoutMs ? ` globalTimeout: ${globalTimeoutMs},\n` : "") +
31
33
  ` workers: 1,\n` +
32
34
  ` fullyParallel: false,\n` +
33
35
  ` webServer: undefined,\n` +
@@ -38,6 +40,7 @@ export function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles, le
38
40
  ` testDir: ${JSON.stringify(cwd)},\n` +
39
41
  ` testMatch: ${JSON.stringify(normalizedFiles)},\n` +
40
42
  ` outputDir: ${JSON.stringify(outputDir)},\n` +
43
+ (globalTimeoutMs ? ` globalTimeout: ${globalTimeoutMs},\n` : "") +
41
44
  ` workers: 1,\n` +
42
45
  ` fullyParallel: false,\n` +
43
46
  `};\n`;
@@ -47,6 +50,15 @@ export function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles, le
47
50
  return configPath;
48
51
  }
49
52
 
53
+ function normalizeOptionalPositiveInteger(value, label) {
54
+ if (value == null) return null;
55
+ const normalized = Number(value);
56
+ if (!Number.isInteger(normalized) || normalized <= 0) {
57
+ throw new Error(`${label} must be a positive integer.`);
58
+ }
59
+ return normalized;
60
+ }
61
+
50
62
  export function resolvePlaywrightOutputDir(stateDir) {
51
63
  return path.join(stateDir, "playwright-output");
52
64
  }
@@ -1,14 +1,14 @@
1
1
  import path from "path";
2
- import { execa } from "execa";
2
+ import fs from "fs";
3
3
  import { parsePlaywrightJsonResults } from "../reporters/playwright.mjs";
4
4
  import { resolveServiceCwd } from "../config/paths.mjs";
5
- import { formatFileTimeoutBudgetError } from "../shared/file-timeout.mjs";
5
+ import { buildFileTimeoutEnv, formatFileTimeoutBudgetError } from "../shared/file-timeout.mjs";
6
6
  import { persistTaskOutputArtifacts } from "./artifacts.mjs";
7
- import { settleSubprocess } from "./default-runtime-runner.mjs";
8
7
  import { ensurePlaywrightTestConfig } from "./playwright-config.mjs";
9
8
  import { registerManagedProcess, unregisterManagedProcess } from "./managed-processes.mjs";
10
9
  import { normalizePathSeparators } from "./state.mjs";
11
10
  import { buildPlaywrightEnv } from "./template.mjs";
11
+ import { settleManagedSubprocess, startManagedSubprocess } from "./subprocess.mjs";
12
12
 
13
13
  export async function runPlaywrightTask(targetConfig, task, lifecycle, lease, reporter = null) {
14
14
  const local = targetConfig.testkit.local;
@@ -20,20 +20,36 @@ export async function runPlaywrightTask(targetConfig, task, lifecycle, lease, re
20
20
 
21
21
  const cwd = resolveServiceCwd(targetConfig.productDir, local.cwd);
22
22
  const requestedFile = path.relative(cwd, path.join(targetConfig.productDir, task.file));
23
- const playwrightConfigPath = ensurePlaywrightTestConfig(targetConfig, cwd, [requestedFile], lease);
24
23
  if (lifecycle.isStopRequested()) {
25
24
  throw new Error(`testkit run interrupted before starting ${task.file}`);
26
25
  }
27
26
  const startedAt = Date.now();
28
27
  const fileTimeoutSeconds = targetConfig.testkit.execution.fileTimeoutSeconds;
29
- const subprocess = execa(
28
+ const playwrightConfigPath = ensurePlaywrightTestConfig(
29
+ targetConfig,
30
+ cwd,
31
+ [requestedFile],
32
+ lease,
33
+ { globalTimeoutMs: fileTimeoutSeconds * 1000 }
34
+ );
35
+ const jsonReportPath = buildPlaywrightJsonReportPath(lease, task);
36
+ const subprocess = startManagedSubprocess(
30
37
  "npx",
31
38
  ["playwright", "test", "--config", playwrightConfigPath, "--reporter=json"],
32
39
  {
33
40
  cwd,
34
- env: buildPlaywrightEnv(targetConfig, local.baseUrl, lease, process.env),
35
- reject: false,
36
- forceKillAfterDelay: 5_000,
41
+ env: {
42
+ ...buildPlaywrightEnv(
43
+ targetConfig,
44
+ local.baseUrl,
45
+ lease,
46
+ {
47
+ ...process.env,
48
+ ...buildFileTimeoutEnv(fileTimeoutSeconds, startedAt),
49
+ }
50
+ ),
51
+ PLAYWRIGHT_JSON_OUTPUT_FILE: jsonReportPath,
52
+ },
37
53
  }
38
54
  );
39
55
  registerManagedProcess(lifecycle, subprocess, "SIGINT");
@@ -41,17 +57,30 @@ export async function runPlaywrightTask(targetConfig, task, lifecycle, lease, re
41
57
  let result;
42
58
  let timedOut;
43
59
  try {
44
- ({ result, timedOut } = await settleSubprocess(subprocess, fileTimeoutSeconds));
60
+ ({ result, timedOut } = await settleManagedSubprocess(subprocess, {
61
+ timeoutMs: fileTimeoutSeconds * 1000 + 1_000,
62
+ }));
45
63
  } finally {
46
64
  unregisterManagedProcess(lifecycle, subprocess);
47
65
  }
48
66
 
49
- const parsed = parsePlaywrightJsonResults(result.stdout || "", cwd);
67
+ const jsonReport = readOptionalText(jsonReportPath);
68
+ const stdoutReport = jsonReport ? "" : extractJsonReportFromStdout(result.stdout || "");
69
+ const parsed = parsePlaywrightJsonResults(jsonReport || stdoutReport, cwd);
50
70
  const finishedAt = Date.now();
51
71
  const durationMs = finishedAt - startedAt;
52
72
  const relativeFile = normalizePathSeparators(requestedFile);
53
73
  const fileResult = parsed.fileResults.get(relativeFile);
54
74
  const outputArtifacts = persistTaskOutputArtifacts(targetConfig.productDir, task, [
75
+ result.stdout
76
+ ? {
77
+ name: "playwright-stdout",
78
+ kind: "runtime.output",
79
+ summary: result.stdout.split(/\r?\n/).map((line) => line.trim()).find(Boolean) || "captured stdout",
80
+ stream: "stdout",
81
+ text: result.stdout,
82
+ }
83
+ : null,
55
84
  result.stderr
56
85
  ? {
57
86
  name: "playwright-stderr",
@@ -62,16 +91,19 @@ export async function runPlaywrightTask(targetConfig, task, lifecycle, lease, re
62
91
  }
63
92
  : null,
64
93
  ]);
65
- const genericError = timedOut
94
+ const parsedError = stripAnsi(parsed.errors[0] || "");
95
+ const globalTimeoutError = isPlaywrightGlobalTimeoutError(parsedError);
96
+ const genericError = timedOut || globalTimeoutError
66
97
  ? formatFileTimeoutBudgetError(fileTimeoutSeconds)
67
98
  : result.exitCode === 0
68
- ? parsed.errors[0] || null
69
- : parsed.errors[0] || result.stderr.trim() || `Playwright exited with code ${result.exitCode}`;
99
+ ? parsedError || null
100
+ : parsedError || firstNonEmptyLine(result.stderr) || firstNonEmptyLine(result.stdout) || `Playwright exited with code ${result.exitCode}`;
101
+ const failed = timedOut || globalTimeoutError || (fileResult ? fileResult.status === "failed" : genericError !== null);
70
102
 
71
103
  return {
72
104
  task,
73
- failed: timedOut ? true : fileResult ? fileResult.status === "failed" : result.exitCode !== 0,
74
- status: timedOut ? "failed" : fileResult?.status || (result.exitCode === 0 ? "passed" : "failed"),
105
+ failed,
106
+ status: timedOut ? "failed" : fileResult?.status || (failed ? "failed" : "passed"),
75
107
  error: timedOut ? genericError : fileResult?.error || genericError,
76
108
  durationMs: fileResult?.durationMs > 0 ? fileResult.durationMs : durationMs,
77
109
  startedAt,
@@ -80,3 +112,41 @@ export async function runPlaywrightTask(targetConfig, task, lifecycle, lease, re
80
112
  failureDetails: timedOut ? [] : fileResult?.failureDetails || [],
81
113
  };
82
114
  }
115
+
116
+ export function buildPlaywrightJsonReportPath(lease, task) {
117
+ if (!lease?.leaseDir) {
118
+ throw new Error(`Playwright task ${task?.file || ""} requires a lease-scoped directory`);
119
+ }
120
+ const reportDir = path.join(lease.leaseDir, "playwright-reports");
121
+ fs.mkdirSync(reportDir, { recursive: true });
122
+ return path.join(reportDir, `task-${task.id}.json`);
123
+ }
124
+
125
+ function readOptionalText(filePath) {
126
+ try {
127
+ return fs.readFileSync(filePath, "utf8");
128
+ } catch {
129
+ return "";
130
+ }
131
+ }
132
+
133
+ function extractJsonReportFromStdout(stdout) {
134
+ const trimmed = String(stdout || "").trim();
135
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return "";
136
+ return trimmed;
137
+ }
138
+
139
+ function firstNonEmptyLine(value) {
140
+ return stripAnsi(value)
141
+ .split(/\r?\n/)
142
+ .map((line) => line.trim())
143
+ .find(Boolean) || null;
144
+ }
145
+
146
+ function isPlaywrightGlobalTimeoutError(value) {
147
+ return /Timed out waiting \d+(?:\.\d+)?s for the test suite to run/i.test(value || "");
148
+ }
149
+
150
+ function stripAnsi(value) {
151
+ return String(value || "").replace(/\u001b\[[0-9;]*m/g, "");
152
+ }
@@ -1,4 +1,4 @@
1
- import { spawn } from "child_process";
1
+ import { execFileSync, spawn } from "child_process";
2
2
 
3
3
  export function normalizeServiceStartCommand(command) {
4
4
  if (typeof command !== "string") {
@@ -36,9 +36,16 @@ export function startDetachedCommand(command, cwd, env) {
36
36
 
37
37
  export function killChildProcess(child, signal) {
38
38
  if (!child?.pid) return;
39
+ killProcessTree(child.pid, signal);
40
+ }
41
+
42
+ export function killProcessTree(pid, signal) {
43
+ const normalizedPid = Number(pid);
44
+ if (!Number.isInteger(normalizedPid) || normalizedPid <= 0) return;
45
+ const descendants = listDescendantPids(normalizedPid);
39
46
 
40
47
  try {
41
- process.kill(-child.pid, signal);
48
+ process.kill(-normalizedPid, signal);
42
49
  return;
43
50
  } catch (error) {
44
51
  if (error?.code !== "ESRCH") {
@@ -49,10 +56,18 @@ export function killChildProcess(child, signal) {
49
56
  }
50
57
 
51
58
  try {
52
- child.kill(signal);
59
+ process.kill(normalizedPid, signal);
53
60
  } catch (error) {
54
61
  if (error?.code !== "ESRCH") throw error;
55
62
  }
63
+
64
+ for (const childPid of descendants) {
65
+ try {
66
+ process.kill(childPid, signal);
67
+ } catch (error) {
68
+ if (error?.code !== "ESRCH") throw error;
69
+ }
70
+ }
56
71
  }
57
72
 
58
73
  export function captureOutput(stream, options = {}) {
@@ -116,3 +131,44 @@ export async function stopChildProcess(child, outputDrains = []) {
116
131
  export function sleep(ms) {
117
132
  return new Promise((resolve) => setTimeout(resolve, ms));
118
133
  }
134
+
135
+ export function listDescendantPids(rootPid) {
136
+ const normalizedRootPid = Number(rootPid);
137
+ if (!Number.isInteger(normalizedRootPid) || normalizedRootPid <= 0) return [];
138
+
139
+ if (process.platform === "win32") {
140
+ return [];
141
+ }
142
+
143
+ try {
144
+ const output = execFileSync("ps", ["-eo", "pid=,ppid="], {
145
+ encoding: "utf8",
146
+ stdio: ["ignore", "pipe", "ignore"],
147
+ });
148
+ const childrenByParent = new Map();
149
+ for (const line of output.split(/\r?\n/)) {
150
+ const trimmed = line.trim();
151
+ if (!trimmed) continue;
152
+ const [pidRaw, parentRaw] = trimmed.split(/\s+/);
153
+ const pid = Number(pidRaw);
154
+ const parentPid = Number(parentRaw);
155
+ if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue;
156
+ const siblings = childrenByParent.get(parentPid) || [];
157
+ siblings.push(pid);
158
+ childrenByParent.set(parentPid, siblings);
159
+ }
160
+
161
+ const descendants = [];
162
+ const stack = [...(childrenByParent.get(normalizedRootPid) || [])];
163
+ while (stack.length > 0) {
164
+ const pid = stack.pop();
165
+ descendants.push(pid);
166
+ for (const childPid of childrenByParent.get(pid) || []) {
167
+ stack.push(childPid);
168
+ }
169
+ }
170
+ return descendants;
171
+ } catch {
172
+ return [];
173
+ }
174
+ }
@@ -48,6 +48,7 @@ export function buildServiceTrackers(servicePlans, startedAt) {
48
48
  status: "not_run",
49
49
  artifacts: [],
50
50
  failureDetails: [],
51
+ checkDetails: [],
51
52
  },
52
53
  ];
53
54
  }),
@@ -62,6 +63,7 @@ export function buildServiceTrackers(servicePlans, startedAt) {
62
63
  status: "skipped",
63
64
  artifacts: [],
64
65
  failureDetails: [],
66
+ checkDetails: [],
65
67
  },
66
68
  ]),
67
69
  ]),
@@ -125,6 +127,7 @@ export function recordTaskOutcome(trackers, task, outcome, finishedAt = Date.now
125
127
  existingFileResult.status = status;
126
128
  existingFileResult.artifacts = Array.isArray(outcome.artifacts) ? outcome.artifacts : [];
127
129
  existingFileResult.failureDetails = mergeFailureDetails(outcome.failureDetails);
130
+ existingFileResult.checkDetails = normalizeCheckDetails(outcome.checkDetails);
128
131
  } else {
129
132
  suite.fileResultsByPath.set(normalizedPath, {
130
133
  path: normalizedPath,
@@ -135,6 +138,7 @@ export function recordTaskOutcome(trackers, task, outcome, finishedAt = Date.now
135
138
  status,
136
139
  artifacts: Array.isArray(outcome.artifacts) ? outcome.artifacts : [],
137
140
  failureDetails: mergeFailureDetails(outcome.failureDetails),
141
+ checkDetails: normalizeCheckDetails(outcome.checkDetails),
138
142
  });
139
143
  }
140
144
  if (status === "failed" && !suite.failedFileSet.has(task.file)) {
@@ -258,6 +262,9 @@ function finalizeSuite(suite) {
258
262
  ...(Array.isArray(file.artifacts) && file.artifacts.length > 0
259
263
  ? { artifacts: file.artifacts }
260
264
  : {}),
265
+ ...(Array.isArray(file.checkDetails) && file.checkDetails.length > 0
266
+ ? { checkDetails: file.checkDetails }
267
+ : {}),
261
268
  }));
262
269
 
263
270
  return {
@@ -289,3 +296,27 @@ function normalizeOutcomeStatus(outcome) {
289
296
  if (outcome?.status === "skipped") return "skipped";
290
297
  return outcome?.failed ? "failed" : "passed";
291
298
  }
299
+
300
+ function normalizeCheckDetails(value) {
301
+ if (!Array.isArray(value)) return [];
302
+ return value.map((check) => {
303
+ const passes = normalizeCount(check?.passes);
304
+ const fails = normalizeCount(check?.fails);
305
+ return {
306
+ name: typeof check?.name === "string" && check.name.trim().length > 0 ? check.name.trim() : "unnamed",
307
+ path: Array.isArray(check?.path)
308
+ ? check.path
309
+ .map((entry) => String(entry || "").trim())
310
+ .filter(Boolean)
311
+ : [],
312
+ passes,
313
+ fails,
314
+ passed: passes > 0 && fails === 0,
315
+ };
316
+ });
317
+ }
318
+
319
+ function normalizeCount(value) {
320
+ const count = Number(value);
321
+ return Number.isFinite(count) && count > 0 ? count : 0;
322
+ }