Skip to content

Commit bdcb206

Browse files
committed
problem diagnosis
1 parent 8a85d54 commit bdcb206

1 file changed

Lines changed: 152 additions & 50 deletions

File tree

scripts/unit-coverage.mjs

Lines changed: 152 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mkdirSync, readdirSync, writeFileSync } from "node:fs";
1+
import { existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
22
import { spawnSync } from "node:child_process";
33

44
const args = new Set(process.argv.slice(2));
@@ -20,35 +20,58 @@ const advisoryFiles = [
2020
"src/services/solid/getData.ts",
2121
"src/services/solid/privacyEdit.ts",
2222
];
23+
const supportsCoverageInclude = process.allowedNodeEnvironmentFlags.has(
24+
"--test-coverage-include"
25+
);
26+
const coverageIncludeArgs = supportsCoverageInclude
27+
? [...new Set([...trackedFiles, ...advisoryFiles])].map(
28+
(file) => `--test-coverage-include=${file}`
29+
)
30+
: [];
2331

2432
const testFiles = readdirSync("tests/unit")
2533
.filter((fileName) => fileName.endsWith(".test.ts"))
2634
.sort()
2735
.map((fileName) => `./tests/unit/${fileName}`);
2836

29-
const nodeResult = spawnSync(
30-
"node",
31-
[
37+
function runCoverageAttempt({
38+
label,
39+
extraArgs = [],
40+
}) {
41+
const args = [
3242
"--test",
3343
"--experimental-test-coverage",
44+
...coverageIncludeArgs,
3445
"--import",
3546
"./tests/register-ts-loader.mjs",
47+
...extraArgs,
3648
...testFiles,
37-
],
38-
{ encoding: "utf8" }
39-
);
49+
];
50+
51+
const result = spawnSync("node", args, { encoding: "utf8" });
52+
const combinedOutput = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
53+
return {
54+
label,
55+
args,
56+
result,
57+
combinedOutput,
58+
};
59+
}
60+
61+
const primaryAttempt = runCoverageAttempt({ label: "primary" });
62+
let activeAttempt = primaryAttempt;
4063

4164
if (!quiet) {
42-
if (nodeResult.stdout) process.stdout.write(nodeResult.stdout);
43-
if (nodeResult.stderr) process.stderr.write(nodeResult.stderr);
65+
if (activeAttempt.result.stdout) process.stdout.write(activeAttempt.result.stdout);
66+
if (activeAttempt.result.stderr) process.stderr.write(activeAttempt.result.stderr);
4467
}
4568

46-
if (nodeResult.status !== 0) {
69+
if (activeAttempt.result.status !== 0) {
4770
if (quiet) {
48-
if (nodeResult.stdout) process.stdout.write(nodeResult.stdout);
49-
if (nodeResult.stderr) process.stderr.write(nodeResult.stderr);
71+
if (activeAttempt.result.stdout) process.stdout.write(activeAttempt.result.stdout);
72+
if (activeAttempt.result.stderr) process.stderr.write(activeAttempt.result.stderr);
5073
}
51-
process.exit(nodeResult.status ?? 1);
74+
process.exit(activeAttempt.result.status ?? 1);
5275
}
5376

5477
const coverageRegex =
@@ -94,45 +117,79 @@ function resolveMetricForFile(metricsByFile, filePath) {
94117
return null;
95118
}
96119

97-
const metricsByFile = new Map();
98-
/**
99-
* Parse coverage from combined stdout/stderr because some Node reporter setups
100-
* emit diagnostics to stderr in CI while using stdout locally.
101-
*/
102-
const rawCoverageOutput = `${nodeResult.stdout ?? ""}\n${nodeResult.stderr ?? ""}`;
103-
for (const line of stripAnsi(rawCoverageOutput).split("\n")) {
104-
const normalizedLine = line.replace(/^[#>\s]+/, "").trim();
105-
if (!normalizedLine) continue;
106-
if (normalizedLine.includes("start of coverage report")) continue;
107-
if (normalizedLine.includes("end of coverage report")) continue;
108-
if (
109-
normalizedLine.includes("file | line % | branch % | funcs %") ||
110-
normalizedLine.includes("File | % Stmts | % Branch | % Funcs | % Lines")
111-
) {
112-
continue;
113-
}
114-
if (
115-
normalizedLine.startsWith("all files") ||
116-
normalizedLine.startsWith("All files") ||
117-
normalizedLine.startsWith("...files") ||
118-
normalizedLine.startsWith("…files")
119-
) {
120-
continue;
120+
function parseMetricsFromCoverageOutput(rawCoverageOutput) {
121+
const metricsByFile = new Map();
122+
for (const line of stripAnsi(rawCoverageOutput).split("\n")) {
123+
const normalizedLine = line.replace(/^[#>\s]+/, "").trim();
124+
if (!normalizedLine) continue;
125+
if (normalizedLine.includes("start of coverage report")) continue;
126+
if (normalizedLine.includes("end of coverage report")) continue;
127+
if (
128+
normalizedLine.includes("file | line % | branch % | funcs %") ||
129+
normalizedLine.includes("File | % Stmts | % Branch | % Funcs | % Lines")
130+
) {
131+
continue;
132+
}
133+
if (
134+
normalizedLine.startsWith("all files") ||
135+
normalizedLine.startsWith("All files") ||
136+
normalizedLine.startsWith("...files") ||
137+
normalizedLine.startsWith("…files")
138+
) {
139+
continue;
140+
}
141+
if (/^-{3,}/.test(normalizedLine)) continue;
142+
143+
const match = normalizedLine.match(coverageRegex);
144+
if (!match) continue;
145+
146+
const [, file, linePct, branchPct, funcPct, uncovered] = match;
147+
const normalizedFile = normalizeCoveragePath(file);
148+
metricsByFile.set(normalizedFile, {
149+
file: normalizedFile,
150+
linePct: Number(linePct),
151+
branchPct: Number(branchPct),
152+
funcPct: Number(funcPct),
153+
uncovered: uncovered.trim(),
154+
});
121155
}
122-
if (/^-{3,}/.test(normalizedLine)) continue;
123-
124-
const match = normalizedLine.match(coverageRegex);
125-
if (!match) continue;
126-
127-
const [, file, linePct, branchPct, funcPct, uncovered] = match;
128-
const normalizedFile = normalizeCoveragePath(file);
129-
metricsByFile.set(normalizedFile, {
130-
file: normalizedFile,
131-
linePct: Number(linePct),
132-
branchPct: Number(branchPct),
133-
funcPct: Number(funcPct),
134-
uncovered: uncovered.trim(),
156+
return metricsByFile;
157+
}
158+
159+
let metricsByFile = parseMetricsFromCoverageOutput(activeAttempt.combinedOutput);
160+
const primaryMissing = trackedFiles.filter(
161+
(file) => !resolveMetricForFile(metricsByFile, file)
162+
);
163+
164+
if (primaryMissing.length === trackedFiles.length) {
165+
const lcovPath = "coverage/unit-retry.lcov";
166+
if (existsSync(lcovPath)) unlinkSync(lcovPath);
167+
168+
const retryAttempt = runCoverageAttempt({
169+
label: "retry-with-explicit-reporter",
170+
extraArgs: [
171+
"--test-reporter=tap",
172+
"--test-reporter-destination=stdout",
173+
"--test-reporter=lcov",
174+
`--test-reporter-destination=${lcovPath}`,
175+
],
135176
});
177+
178+
if (!quiet) {
179+
console.warn(
180+
"\nCoverage output from primary attempt did not include tracked files; retrying with explicit TAP+LCOV reporters."
181+
);
182+
if (retryAttempt.result.stdout) process.stdout.write(retryAttempt.result.stdout);
183+
if (retryAttempt.result.stderr) process.stderr.write(retryAttempt.result.stderr);
184+
}
185+
186+
if (retryAttempt.result.status === 0) {
187+
activeAttempt = retryAttempt;
188+
metricsByFile = parseMetricsFromCoverageOutput(activeAttempt.combinedOutput);
189+
} else if (quiet) {
190+
if (retryAttempt.result.stdout) process.stdout.write(retryAttempt.result.stdout);
191+
if (retryAttempt.result.stderr) process.stderr.write(retryAttempt.result.stderr);
192+
}
136193
}
137194

138195
const trackedMetrics = trackedFiles
@@ -232,6 +289,51 @@ for (const missingFile of missingFiles) {
232289
}
233290

234291
if (failures.length > 0) {
292+
if (missingFiles.length === trackedFiles.length) {
293+
const debugPayload = {
294+
generatedAt: new Date().toISOString(),
295+
nodeVersion: process.version,
296+
platform: process.platform,
297+
arch: process.arch,
298+
cwd: process.cwd(),
299+
testFileCount: testFiles.length,
300+
trackedFiles,
301+
advisoryFiles,
302+
coverageIncludeArgs,
303+
attempts: [
304+
{
305+
label: primaryAttempt.label,
306+
status: primaryAttempt.result.status,
307+
signal: primaryAttempt.result.signal,
308+
args: primaryAttempt.args,
309+
outputPreview: stripAnsi(primaryAttempt.combinedOutput).split("\n").slice(-120),
310+
},
311+
{
312+
label: activeAttempt.label,
313+
status: activeAttempt.result.status,
314+
signal: activeAttempt.result.signal,
315+
args: activeAttempt.args,
316+
outputPreview: stripAnsi(activeAttempt.combinedOutput).split("\n").slice(-120),
317+
},
318+
],
319+
message:
320+
"Coverage parsing found no tracked files even after retry. This usually indicates a Node test-coverage reporter regression in CI runtime.",
321+
};
322+
writeFileSync(
323+
"coverage/unit-coverage-debug.json",
324+
JSON.stringify(debugPayload, null, 2) + "\n",
325+
"utf8"
326+
);
327+
console.error(
328+
"\nCoverage diagnostics were written to coverage/unit-coverage-debug.json"
329+
);
330+
console.error(
331+
`Node runtime: ${process.version} (${process.platform}-${process.arch}).`
332+
);
333+
console.error(
334+
"Suggestion: pin Node to a known-good patch and inspect the debug artifact from the failing CI run."
335+
);
336+
}
235337
console.error("\nCoverage compliance failed:");
236338
failures.forEach((failure) => console.error(`- ${failure}`));
237339
process.exit(1);

0 commit comments

Comments
 (0)