diff --git a/src/core/coverageProcessor.test.ts b/src/core/coverageProcessor.test.ts index 8d9d134..155d2a7 100644 --- a/src/core/coverageProcessor.test.ts +++ b/src/core/coverageProcessor.test.ts @@ -1,5 +1,6 @@ import test from "ava"; import { + extractLineCoverage, filterScriptUrl, takeAndProcessSnapshot, V8CoverageData, @@ -216,3 +217,93 @@ class Example {} t.truthy(ast); t.is(ast.type, "File"); }); + +// --- extractLineCoverage --- + +test("extractLineCoverage: expands multi-line statements to all lines", (t) => { + // res.json({ ... }) spanning lines 14-20, covered + const statementMap = { + "0": { start: { line: 14, column: 2 }, end: { line: 20, column: 4 } }, + }; + const statementCounts = { "0": 1 }; + + const lines = extractLineCoverage(statementMap, statementCounts); + + t.is(lines["14"], 1); + t.is(lines["15"], 1); + t.is(lines["16"], 1); + t.is(lines["17"], 1); + t.is(lines["18"], 1); + t.is(lines["19"], 1); + t.is(lines["20"], 1); +}); + +test("extractLineCoverage: single-line statement works", (t) => { + const statementMap = { + "0": { start: { line: 5, column: 0 }, end: { line: 5, column: 30 } }, + }; + const statementCounts = { "0": 3 }; + + const lines = extractLineCoverage(statementMap, statementCounts); + + t.is(lines["5"], 3); + t.false("4" in lines); + t.false("6" in lines); +}); + +test("extractLineCoverage: inner uncovered statement overrides outer covered statement", (t) => { + // Simulates: try { ... } catch (error) { res.status(500).json({...}) } + // Outer try-catch block (lines 25-40) is covered (count=1) + // Inner catch body (lines 36-39) is uncovered (count=0) + const statementMap = { + "0": { start: { line: 25, column: 0 }, end: { line: 40, column: 1 } }, + "1": { start: { line: 26, column: 4 }, end: { line: 27, column: 5 } }, + "2": { start: { line: 36, column: 4 }, end: { line: 39, column: 5 } }, + }; + const statementCounts = { + "0": 1, // try-catch executed + "1": 1, // try body executed + "2": 0, // catch body NOT executed + }; + + const lines = extractLineCoverage(statementMap, statementCounts); + + // Try body lines should be covered + t.is(lines["26"], 1); + t.is(lines["27"], 1); + + // Catch body lines should be uncovered (inner statement overrides outer) + t.is(lines["36"], 0); + t.is(lines["37"], 0); + t.is(lines["38"], 0); + t.is(lines["39"], 0); + + // Outer try-catch structural lines are covered + t.is(lines["25"], 1); + t.is(lines["40"], 1); +}); + +test("extractLineCoverage: same-size statements use max count (covered wins)", (t) => { + // Two single-line statements on the same line: if (x) foo(); else bar(); + // foo() was called (count=1), bar() was not (count=0) + const statementMap = { + "0": { start: { line: 10, column: 0 }, end: { line: 10, column: 40 } }, + "1": { start: { line: 10, column: 7 }, end: { line: 10, column: 20 } }, + "2": { start: { line: 10, column: 26 }, end: { line: 10, column: 40 } }, + }; + const statementCounts = { + "0": 1, // if statement executed + "1": 1, // foo() called + "2": 0, // bar() not called + }; + + const lines = extractLineCoverage(statementMap, statementCounts); + + // Line 10 should be covered because at least one statement on it was executed + t.is(lines["10"], 1); +}); + +test("extractLineCoverage: handles empty statement map", (t) => { + const lines = extractLineCoverage({}, {}); + t.deepEqual(lines, {}); +}); diff --git a/src/core/coverageProcessor.ts b/src/core/coverageProcessor.ts index b4dd2fa..b04afb1 100644 --- a/src/core/coverageProcessor.ts +++ b/src/core/coverageProcessor.ts @@ -53,6 +53,60 @@ export interface V8CoverageData { result: V8ScriptCoverage[]; } +interface IstanbulStatementMap { + start: { line: number; column: number }; + end: { line: number; column: number }; +} + +/** + * Extract per-line execution counts from Istanbul's statement map. + * + * Multi-line statements (e.g. res.json({...}) spanning lines 14-20) are + * expanded to all lines in the range. When statements overlap (e.g. a + * try-catch block contains a catch body), the innermost (most specific) + * statement wins — so the catch body stays uncovered even though the + * outer try-catch block is covered. + */ +export function extractLineCoverage( + statementMap: Record, + statementCounts: Record, +): Record { + const stmtEntries = Object.entries(statementCounts) + .map(([stmtId, count]) => ({ + stmtMap: statementMap[stmtId], + count, + })) + .filter((e) => e.stmtMap) + .sort((a, b) => { + const sizeA = a.stmtMap.end.line - a.stmtMap.start.line; + const sizeB = b.stmtMap.end.line - b.stmtMap.start.line; + return sizeB - sizeA; // largest first + }); + + const lines: Record = {}; + // Track the size of the statement that last wrote each line, so + // same-size statements use Math.max (preserving covered status) + // while strictly smaller statements override (inner wins over outer). + const lineStmtSize: Record = {}; + for (const { stmtMap, count } of stmtEntries) { + const size = stmtMap.end.line - stmtMap.start.line; + for (let lineNum = stmtMap.start.line; lineNum <= stmtMap.end.line; lineNum++) { + const key = String(lineNum); + const prevSize = lineStmtSize[key]; + if (prevSize === undefined || size < prevSize) { + // Strictly smaller (more specific) statement — override + lines[key] = count; + lineStmtSize[key] = size; + } else if (size === prevSize) { + // Same size — take max (covered wins) + lines[key] = Math.max(lines[key] ?? 0, count); + } + // size > prevSize: larger statement, skip — a more specific one already wrote this line + } + } + return lines; +} + /** * Filter a script URL to determine if it's a user source file. */ @@ -286,17 +340,10 @@ export async function processV8CoverageFile( if (!fileKey) continue; const fileCov = istanbulData[fileKey]; - // Extract line coverage from Istanbul statement map - const lines: Record = {}; - for (const [stmtId, count] of Object.entries( + const lines = extractLineCoverage( + fileCov.statementMap, fileCov.s as Record, - )) { - const stmtMap = fileCov.statementMap[stmtId]; - if (stmtMap) { - const line = String(stmtMap.start.line); - lines[line] = Math.max(lines[line] ?? 0, count); - } - } + ); // Extract branch coverage from Istanbul branch map let totalBranches = 0;