Skip to content

Commit 05a5e91

Browse files
committed
fix(test_runner): fix statement coverage visitor and edge cases
The acorn-walk `simple` function does not fire a generic "Statement" visitor for concrete statement node types, which meant the statements array was always empty and coveredStatementPercent was always 100%. Fix by enumerating each concrete statement type (ExpressionStatement, ReturnStatement, IfStatement, etc.) as individual visitor keys. Also fixes: - sourceType fallback: try 'module' first, catch and retry 'script' - Infinity primordial: replace bare Infinity with bestRange null pattern - double disk I/O: summary() now reads source once and passes it to both getLines() and getStatements() - tests: assert totalStatementCount > 0 to catch this regression
1 parent a95e904 commit 05a5e91

File tree

2 files changed

+71
-25
lines changed

2 files changed

+71
-25
lines changed

lib/internal/test_runner/coverage.js

Lines changed: 61 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,15 @@ const { Parser: AcornParser } =
4343
const { simple: acornWalkSimple } =
4444
require('internal/deps/acorn/acorn-walk/dist/walk');
4545

46+
const kStatementTypes = [
47+
'ExpressionStatement', 'ReturnStatement', 'ThrowStatement',
48+
'IfStatement', 'WhileStatement', 'DoWhileStatement',
49+
'ForStatement', 'ForInStatement', 'ForOfStatement',
50+
'SwitchStatement', 'TryStatement', 'BreakStatement',
51+
'ContinueStatement', 'VariableDeclaration', 'LabeledStatement',
52+
'WithStatement', 'DebuggerStatement',
53+
];
54+
4655
const kCoverageFileRegex = /^coverage-(\d+)-(\d{13})-(\d+)\.json$/;
4756
const kIgnoreRegex = /\/\* node:coverage ignore next (?<count>\d+ )?\*\//;
4857
const kLineEndingRegex = /\r?\n$/u;
@@ -88,34 +97,52 @@ class TestCoverage {
8897
}
8998

9099
const statements = [];
100+
101+
// Build a visitor with one handler per concrete statement type.
102+
// acorn-walk does not fire a generic "Statement" visitor for concrete
103+
// node types, so each type must be registered individually.
104+
const visitor = { __proto__: null };
105+
const handler = (node) => {
106+
ArrayPrototypePush(statements, {
107+
__proto__: null,
108+
startOffset: node.start,
109+
endOffset: node.end,
110+
count: 0,
111+
});
112+
};
113+
for (let i = 0; i < kStatementTypes.length; ++i) {
114+
visitor[kStatementTypes[i]] = handler;
115+
}
116+
117+
// Try parsing as a module first, fall back to script for files that
118+
// use CommonJS syntax incompatible with module mode.
119+
let ast;
91120
try {
92-
const ast = AcornParser.parse(source, {
121+
ast = AcornParser.parse(source, {
93122
__proto__: null,
94123
ecmaVersion: 'latest',
95124
sourceType: 'module',
96125
allowReturnOutsideFunction: true,
97126
allowImportExportEverywhere: true,
98127
allowAwaitOutsideFunction: true,
99128
});
100-
101-
acornWalkSimple(ast, {
102-
Statement(node) {
103-
if (node.type === 'BlockStatement') {
104-
return;
105-
}
106-
ArrayPrototypePush(statements, {
107-
__proto__: null,
108-
startOffset: node.start,
109-
endOffset: node.end,
110-
count: 0,
111-
});
112-
},
113-
});
114129
} catch {
115-
this.#sourceStatements.set(fileUrl, null);
116-
return null;
130+
try {
131+
ast = AcornParser.parse(source, {
132+
__proto__: null,
133+
ecmaVersion: 'latest',
134+
sourceType: 'script',
135+
allowReturnOutsideFunction: true,
136+
allowAwaitOutsideFunction: true,
137+
});
138+
} catch {
139+
this.#sourceStatements.set(fileUrl, null);
140+
return null;
141+
}
117142
}
118143

144+
acornWalkSimple(ast, visitor);
145+
119146
this.#sourceStatements.set(fileUrl, statements);
120147
return statements;
121148
}
@@ -228,7 +255,16 @@ class TestCoverage {
228255
const functionReports = [];
229256
const branchReports = [];
230257

231-
const lines = this.getLines(url);
258+
// Read source once and pass to both getLines and getStatements to
259+
// avoid double disk I/O for the same file.
260+
let source;
261+
try {
262+
source = readFileSync(fileURLToPath(url), 'utf8');
263+
} catch {
264+
continue;
265+
}
266+
267+
const lines = this.getLines(url, source);
232268
if (!lines) {
233269
continue;
234270
}
@@ -298,7 +334,8 @@ class TestCoverage {
298334
}
299335

300336
// Compute statement coverage by mapping V8 ranges to AST statements.
301-
const statements = this.getStatements(url);
337+
// Pass the source already read above to avoid double disk I/O.
338+
const statements = this.getStatements(url, source);
302339
let totalStatements = 0;
303340
let statementsCovered = 0;
304341
const statementReports = [];
@@ -307,8 +344,7 @@ class TestCoverage {
307344
for (let j = 0; j < statements.length; ++j) {
308345
const stmt = statements[j];
309346
let bestCount = 0;
310-
let bestSize = Infinity;
311-
let found = false;
347+
let bestRange = null;
312348

313349
for (let fi = 0; fi < functions.length; ++fi) {
314350
const { ranges } = functions[fi];
@@ -317,16 +353,16 @@ class TestCoverage {
317353
if (range.startOffset <= stmt.startOffset &&
318354
range.endOffset >= stmt.endOffset) {
319355
const size = range.endOffset - range.startOffset;
320-
if (!found || size < bestSize) {
356+
if (bestRange === null ||
357+
size < (bestRange.endOffset - bestRange.startOffset)) {
321358
bestCount = range.count;
322-
bestSize = size;
359+
bestRange = range;
323360
}
324-
found = true;
325361
}
326362
}
327363
}
328364

329-
stmt.count = found ? bestCount : 0;
365+
stmt.count = bestRange !== null ? bestCount : 0;
330366

331367
const stmtLine = findLineForOffset(stmt.startOffset, lines);
332368
const isIgnored = stmtLine != null && stmtLine.ignore;

test/parallel/test-runner-coverage-statements.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ test('statement coverage is reported via custom reporter', () => {
4040
summary.totals.coveredStatementPercent <= 100,
4141
`statement percent should be 0-100, got ${summary.totals.coveredStatementPercent}`);
4242

43+
// Regression: totalStatementCount must be > 0 for files with actual
44+
// statements. A zero here means the AST visitor is not firing.
45+
assert.ok(summary.totals.totalStatementCount > 0,
46+
`totalStatementCount must be > 0, got ${summary.totals.totalStatementCount}`);
47+
4348
// Each file should have statement data
4449
for (const file of summary.files) {
4550
assert.ok('coveredStatementPercent' in file,
@@ -74,6 +79,11 @@ test('statement coverage reports covered and uncovered statements', () => {
7479

7580
assert.ok(coverageFile, 'coverage.js should be in the report');
7681

82+
// Regression: totalStatementCount must be > 0 for files with actual
83+
// statements. A zero here means the AST visitor is not firing.
84+
assert.ok(coverageFile.totalStatementCount > 0,
85+
`totalStatementCount must be > 0, got ${coverageFile.totalStatementCount}`);
86+
7787
// The file has uncalled functions and dead branches, so statement
7888
// coverage should be less than 100%.
7989
assert.ok(coverageFile.coveredStatementPercent < 100,

0 commit comments

Comments
 (0)