|
1 | | -import { existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from "node:fs"; |
| 1 | +import { |
| 2 | + existsSync, |
| 3 | + mkdirSync, |
| 4 | + readFileSync, |
| 5 | + readdirSync, |
| 6 | + unlinkSync, |
| 7 | + writeFileSync, |
| 8 | +} from "node:fs"; |
2 | 9 | import { spawnSync } from "node:child_process"; |
3 | 10 |
|
4 | 11 | const args = new Set(process.argv.slice(2)); |
@@ -159,6 +166,56 @@ function parseMetricsFromCoverageOutput(rawCoverageOutput) { |
159 | 166 | return metricsByFile; |
160 | 167 | } |
161 | 168 |
|
| 169 | +/** |
| 170 | + * Parse LCOV records into the same metric shape used by the text-coverage parser. |
| 171 | + * This is used as a resilient fallback when Node's text coverage table is empty in CI. |
| 172 | + */ |
| 173 | +function parseMetricsFromLcovFile(lcovPath) { |
| 174 | + const metricsByFile = new Map(); |
| 175 | + if (!existsSync(lcovPath)) return metricsByFile; |
| 176 | + |
| 177 | + const content = readFileSync(lcovPath, "utf8"); |
| 178 | + const records = content.split("end_of_record"); |
| 179 | + for (const record of records) { |
| 180 | + const lines = record |
| 181 | + .split("\n") |
| 182 | + .map((line) => line.trim()) |
| 183 | + .filter(Boolean); |
| 184 | + if (lines.length === 0) continue; |
| 185 | + |
| 186 | + const sourceLine = lines.find((line) => line.startsWith("SF:")); |
| 187 | + if (!sourceLine) continue; |
| 188 | + const sourceFile = normalizeCoveragePath(sourceLine.slice(3)); |
| 189 | + |
| 190 | + let lf = 0; |
| 191 | + let lh = 0; |
| 192 | + let brf = 0; |
| 193 | + let brh = 0; |
| 194 | + let fnf = 0; |
| 195 | + let fnh = 0; |
| 196 | + |
| 197 | + for (const line of lines) { |
| 198 | + if (line.startsWith("LF:")) lf = Number(line.slice(3)) || 0; |
| 199 | + else if (line.startsWith("LH:")) lh = Number(line.slice(3)) || 0; |
| 200 | + else if (line.startsWith("BRF:")) brf = Number(line.slice(4)) || 0; |
| 201 | + else if (line.startsWith("BRH:")) brh = Number(line.slice(4)) || 0; |
| 202 | + else if (line.startsWith("FNF:")) fnf = Number(line.slice(4)) || 0; |
| 203 | + else if (line.startsWith("FNH:")) fnh = Number(line.slice(4)) || 0; |
| 204 | + } |
| 205 | + |
| 206 | + const pct = (hit, total) => (total > 0 ? (hit / total) * 100 : 100); |
| 207 | + metricsByFile.set(sourceFile, { |
| 208 | + file: sourceFile, |
| 209 | + linePct: pct(lh, lf), |
| 210 | + branchPct: pct(brh, brf), |
| 211 | + funcPct: pct(fnh, fnf), |
| 212 | + uncovered: "", |
| 213 | + }); |
| 214 | + } |
| 215 | + |
| 216 | + return metricsByFile; |
| 217 | +} |
| 218 | + |
162 | 219 | let metricsByFile = parseMetricsFromCoverageOutput(activeAttempt.combinedOutput); |
163 | 220 | const primaryMissing = trackedFiles.filter( |
164 | 221 | (file) => !resolveMetricForFile(metricsByFile, file) |
@@ -189,6 +246,13 @@ if (primaryMissing.length === trackedFiles.length) { |
189 | 246 | if (retryAttempt.result.status === 0) { |
190 | 247 | activeAttempt = retryAttempt; |
191 | 248 | metricsByFile = parseMetricsFromCoverageOutput(activeAttempt.combinedOutput); |
| 249 | + const retryMissing = trackedFiles.filter( |
| 250 | + (file) => !resolveMetricForFile(metricsByFile, file) |
| 251 | + ); |
| 252 | + // If table output is still empty, fall back to LCOV metrics produced by retry reporter. |
| 253 | + if (retryMissing.length === trackedFiles.length) { |
| 254 | + metricsByFile = parseMetricsFromLcovFile(lcovPath); |
| 255 | + } |
192 | 256 | } else if (quiet) { |
193 | 257 | if (retryAttempt.result.stdout) process.stdout.write(retryAttempt.result.stdout); |
194 | 258 | if (retryAttempt.result.stderr) process.stderr.write(retryAttempt.result.stderr); |
@@ -303,6 +367,8 @@ if (failures.length > 0) { |
303 | 367 | trackedFiles, |
304 | 368 | advisoryFiles, |
305 | 369 | coverageIncludeArgs, |
| 370 | + retryLcovPath: `${process.cwd()}/coverage/unit-retry.lcov`, |
| 371 | + retryLcovExists: existsSync(`${process.cwd()}/coverage/unit-retry.lcov`), |
306 | 372 | attempts: [ |
307 | 373 | { |
308 | 374 | label: primaryAttempt.label, |
|
0 commit comments