1- import { mkdirSync , readdirSync , writeFileSync } from "node:fs" ;
1+ import { existsSync , mkdirSync , readdirSync , unlinkSync , writeFileSync } from "node:fs" ;
22import { spawnSync } from "node:child_process" ;
33
44const 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
2432const 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
4164if ( ! 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
5477const 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
138195const trackedMetrics = trackedFiles
@@ -232,6 +289,51 @@ for (const missingFile of missingFiles) {
232289}
233290
234291if ( 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