Skip to content

Commit 51bcffa

Browse files
fix(coverage): expand multi-line statements to all lines (#154)
1 parent 6d5ee8e commit 51bcffa

File tree

2 files changed

+148
-10
lines changed

2 files changed

+148
-10
lines changed

src/core/coverageProcessor.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import test from "ava";
22
import {
3+
extractLineCoverage,
34
filterScriptUrl,
45
takeAndProcessSnapshot,
56
V8CoverageData,
@@ -216,3 +217,93 @@ class Example {}
216217
t.truthy(ast);
217218
t.is(ast.type, "File");
218219
});
220+
221+
// --- extractLineCoverage ---
222+
223+
test("extractLineCoverage: expands multi-line statements to all lines", (t) => {
224+
// res.json({ ... }) spanning lines 14-20, covered
225+
const statementMap = {
226+
"0": { start: { line: 14, column: 2 }, end: { line: 20, column: 4 } },
227+
};
228+
const statementCounts = { "0": 1 };
229+
230+
const lines = extractLineCoverage(statementMap, statementCounts);
231+
232+
t.is(lines["14"], 1);
233+
t.is(lines["15"], 1);
234+
t.is(lines["16"], 1);
235+
t.is(lines["17"], 1);
236+
t.is(lines["18"], 1);
237+
t.is(lines["19"], 1);
238+
t.is(lines["20"], 1);
239+
});
240+
241+
test("extractLineCoverage: single-line statement works", (t) => {
242+
const statementMap = {
243+
"0": { start: { line: 5, column: 0 }, end: { line: 5, column: 30 } },
244+
};
245+
const statementCounts = { "0": 3 };
246+
247+
const lines = extractLineCoverage(statementMap, statementCounts);
248+
249+
t.is(lines["5"], 3);
250+
t.false("4" in lines);
251+
t.false("6" in lines);
252+
});
253+
254+
test("extractLineCoverage: inner uncovered statement overrides outer covered statement", (t) => {
255+
// Simulates: try { ... } catch (error) { res.status(500).json({...}) }
256+
// Outer try-catch block (lines 25-40) is covered (count=1)
257+
// Inner catch body (lines 36-39) is uncovered (count=0)
258+
const statementMap = {
259+
"0": { start: { line: 25, column: 0 }, end: { line: 40, column: 1 } },
260+
"1": { start: { line: 26, column: 4 }, end: { line: 27, column: 5 } },
261+
"2": { start: { line: 36, column: 4 }, end: { line: 39, column: 5 } },
262+
};
263+
const statementCounts = {
264+
"0": 1, // try-catch executed
265+
"1": 1, // try body executed
266+
"2": 0, // catch body NOT executed
267+
};
268+
269+
const lines = extractLineCoverage(statementMap, statementCounts);
270+
271+
// Try body lines should be covered
272+
t.is(lines["26"], 1);
273+
t.is(lines["27"], 1);
274+
275+
// Catch body lines should be uncovered (inner statement overrides outer)
276+
t.is(lines["36"], 0);
277+
t.is(lines["37"], 0);
278+
t.is(lines["38"], 0);
279+
t.is(lines["39"], 0);
280+
281+
// Outer try-catch structural lines are covered
282+
t.is(lines["25"], 1);
283+
t.is(lines["40"], 1);
284+
});
285+
286+
test("extractLineCoverage: same-size statements use max count (covered wins)", (t) => {
287+
// Two single-line statements on the same line: if (x) foo(); else bar();
288+
// foo() was called (count=1), bar() was not (count=0)
289+
const statementMap = {
290+
"0": { start: { line: 10, column: 0 }, end: { line: 10, column: 40 } },
291+
"1": { start: { line: 10, column: 7 }, end: { line: 10, column: 20 } },
292+
"2": { start: { line: 10, column: 26 }, end: { line: 10, column: 40 } },
293+
};
294+
const statementCounts = {
295+
"0": 1, // if statement executed
296+
"1": 1, // foo() called
297+
"2": 0, // bar() not called
298+
};
299+
300+
const lines = extractLineCoverage(statementMap, statementCounts);
301+
302+
// Line 10 should be covered because at least one statement on it was executed
303+
t.is(lines["10"], 1);
304+
});
305+
306+
test("extractLineCoverage: handles empty statement map", (t) => {
307+
const lines = extractLineCoverage({}, {});
308+
t.deepEqual(lines, {});
309+
});

src/core/coverageProcessor.ts

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,60 @@ export interface V8CoverageData {
5353
result: V8ScriptCoverage[];
5454
}
5555

56+
interface IstanbulStatementMap {
57+
start: { line: number; column: number };
58+
end: { line: number; column: number };
59+
}
60+
61+
/**
62+
* Extract per-line execution counts from Istanbul's statement map.
63+
*
64+
* Multi-line statements (e.g. res.json({...}) spanning lines 14-20) are
65+
* expanded to all lines in the range. When statements overlap (e.g. a
66+
* try-catch block contains a catch body), the innermost (most specific)
67+
* statement wins — so the catch body stays uncovered even though the
68+
* outer try-catch block is covered.
69+
*/
70+
export function extractLineCoverage(
71+
statementMap: Record<string, IstanbulStatementMap>,
72+
statementCounts: Record<string, number>,
73+
): Record<string, number> {
74+
const stmtEntries = Object.entries(statementCounts)
75+
.map(([stmtId, count]) => ({
76+
stmtMap: statementMap[stmtId],
77+
count,
78+
}))
79+
.filter((e) => e.stmtMap)
80+
.sort((a, b) => {
81+
const sizeA = a.stmtMap.end.line - a.stmtMap.start.line;
82+
const sizeB = b.stmtMap.end.line - b.stmtMap.start.line;
83+
return sizeB - sizeA; // largest first
84+
});
85+
86+
const lines: Record<string, number> = {};
87+
// Track the size of the statement that last wrote each line, so
88+
// same-size statements use Math.max (preserving covered status)
89+
// while strictly smaller statements override (inner wins over outer).
90+
const lineStmtSize: Record<string, number> = {};
91+
for (const { stmtMap, count } of stmtEntries) {
92+
const size = stmtMap.end.line - stmtMap.start.line;
93+
for (let lineNum = stmtMap.start.line; lineNum <= stmtMap.end.line; lineNum++) {
94+
const key = String(lineNum);
95+
const prevSize = lineStmtSize[key];
96+
if (prevSize === undefined || size < prevSize) {
97+
// Strictly smaller (more specific) statement — override
98+
lines[key] = count;
99+
lineStmtSize[key] = size;
100+
} else if (size === prevSize) {
101+
// Same size — take max (covered wins)
102+
lines[key] = Math.max(lines[key] ?? 0, count);
103+
}
104+
// size > prevSize: larger statement, skip — a more specific one already wrote this line
105+
}
106+
}
107+
return lines;
108+
}
109+
56110
/**
57111
* Filter a script URL to determine if it's a user source file.
58112
*/
@@ -286,17 +340,10 @@ export async function processV8CoverageFile(
286340
if (!fileKey) continue;
287341
const fileCov = istanbulData[fileKey];
288342

289-
// Extract line coverage from Istanbul statement map
290-
const lines: Record<string, number> = {};
291-
for (const [stmtId, count] of Object.entries(
343+
const lines = extractLineCoverage(
344+
fileCov.statementMap,
292345
fileCov.s as Record<string, number>,
293-
)) {
294-
const stmtMap = fileCov.statementMap[stmtId];
295-
if (stmtMap) {
296-
const line = String(stmtMap.start.line);
297-
lines[line] = Math.max(lines[line] ?? 0, count);
298-
}
299-
}
346+
);
300347

301348
// Extract branch coverage from Istanbul branch map
302349
let totalBranches = 0;

0 commit comments

Comments
 (0)