Skip to content

Commit f17a399

Browse files
committed
fix(test_runner): improve statement coverage implementation and tests
- Add allowHashBang to acorn parse options for shebang support - Reorder requires to follow ASCII convention - Reuse existing doesRangeContainOtherRange helper - Add comments to empty catch blocks for consistency - Remove else after return in findLineForOffset - Break kColumnsKeys across multiple lines (max-len) - Document statement coverage fields in test:coverage event schema - Migrate threshold tests to data-driven loop - Remove unused tmpdir import from test file - Add fixture proving statement != line coverage - Add fixture testing shebang file parsing - Add fixture testing graceful degradation for unparseable files Refs: #62340
1 parent bac6828 commit f17a399

File tree

9 files changed

+192
-73
lines changed

9 files changed

+192
-73
lines changed

doc/api/test.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3255,12 +3255,15 @@ are defined, while others are emitted in the order that the tests execute.
32553255
* `totalLineCount` {number} The total number of lines.
32563256
* `totalBranchCount` {number} The total number of branches.
32573257
* `totalFunctionCount` {number} The total number of functions.
3258+
* `totalStatementCount` {number} The total number of statements.
32583259
* `coveredLineCount` {number} The number of covered lines.
32593260
* `coveredBranchCount` {number} The number of covered branches.
32603261
* `coveredFunctionCount` {number} The number of covered functions.
3262+
* `coveredStatementCount` {number} The number of covered statements.
32613263
* `coveredLinePercent` {number} The percentage of lines covered.
32623264
* `coveredBranchPercent` {number} The percentage of branches covered.
32633265
* `coveredFunctionPercent` {number} The percentage of functions covered.
3266+
* `coveredStatementPercent` {number} The percentage of statements covered.
32643267
* `functions` {Array} An array of functions representing function
32653268
coverage.
32663269
* `name` {string} The name of the function.
@@ -3273,22 +3276,30 @@ are defined, while others are emitted in the order that the tests execute.
32733276
numbers and the number of times they were covered.
32743277
* `line` {number} The line number.
32753278
* `count` {number} The number of times the line was covered.
3279+
* `statements` {Array} An array of statements representing statement
3280+
coverage.
3281+
* `line` {number} The line number where the statement starts.
3282+
* `count` {number} The number of times the statement was executed.
32763283
* `thresholds` {Object} An object containing whether or not the coverage for
32773284
each coverage type.
32783285
* `function` {number} The function coverage threshold.
32793286
* `branch` {number} The branch coverage threshold.
32803287
* `line` {number} The line coverage threshold.
3288+
* `statement` {number} The statement coverage threshold.
32813289
* `totals` {Object} An object containing a summary of coverage for all
32823290
files.
32833291
* `totalLineCount` {number} The total number of lines.
32843292
* `totalBranchCount` {number} The total number of branches.
32853293
* `totalFunctionCount` {number} The total number of functions.
3294+
* `totalStatementCount` {number} The total number of statements.
32863295
* `coveredLineCount` {number} The number of covered lines.
32873296
* `coveredBranchCount` {number} The number of covered branches.
32883297
* `coveredFunctionCount` {number} The number of covered functions.
3298+
* `coveredStatementCount` {number} The number of covered statements.
32893299
* `coveredLinePercent` {number} The percentage of lines covered.
32903300
* `coveredBranchPercent` {number} The percentage of branches covered.
32913301
* `coveredFunctionPercent` {number} The percentage of functions covered.
3302+
* `coveredStatementPercent` {number} The percentage of statements covered.
32923303
* `workingDirectory` {string} The working directory when code coverage
32933304
began. This is useful for displaying relative path names in case the tests
32943305
changed the working directory of the Node.js process.

lib/internal/test_runner/coverage.js

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ const { tmpdir } = require('os');
3030
const { join, resolve, relative } = require('path');
3131
const { fileURLToPath, URL } = require('internal/url');
3232
const { kMappings, SourceMap } = require('internal/source_map/source_map');
33+
const { Parser: AcornParser } =
34+
require('internal/deps/acorn/acorn/dist/acorn');
35+
const { simple: acornWalkSimple } =
36+
require('internal/deps/acorn/acorn-walk/dist/walk');
3337
const {
3438
codes: {
3539
ERR_SOURCE_MAP_CORRUPT,
@@ -38,10 +42,6 @@ const {
3842
} = require('internal/errors');
3943
const { matchGlobPattern } = require('internal/fs/glob');
4044
const { constants: { kMockSearchParam } } = require('internal/test_runner/mock/loader');
41-
const { Parser: AcornParser } =
42-
require('internal/deps/acorn/acorn/dist/acorn');
43-
const { simple: acornWalkSimple } =
44-
require('internal/deps/acorn/acorn-walk/dist/walk');
4545

4646
// Statement types excluded from coverage: containers (BlockStatement)
4747
// and empty statements that carry no executable semantics.
@@ -89,6 +89,7 @@ class TestCoverage {
8989
try {
9090
source ??= readFileSync(fileURLToPath(fileUrl), 'utf8');
9191
} catch {
92+
// The file can no longer be read. Leave it out of statement coverage.
9293
this.#sourceStatements.set(fileUrl, null);
9394
return null;
9495
}
@@ -119,11 +120,14 @@ class TestCoverage {
119120
__proto__: null,
120121
ecmaVersion: 'latest',
121122
sourceType: 'script',
123+
allowHashBang: true,
122124
allowReturnOutsideFunction: true,
123125
allowImportExportEverywhere: true,
124126
allowAwaitOutsideFunction: true,
125127
});
126128
} catch {
129+
// Acorn could not parse the file (e.g. non-JS syntax, TypeScript).
130+
// Degrade gracefully — the file will report no statement coverage.
127131
this.#sourceStatements.set(fileUrl, null);
128132
return null;
129133
}
@@ -248,6 +252,7 @@ class TestCoverage {
248252
try {
249253
source = readFileSync(fileURLToPath(url), 'utf8');
250254
} catch {
255+
// The file can no longer be read. Skip it entirely.
251256
continue;
252257
}
253258

@@ -345,8 +350,7 @@ class TestCoverage {
345350

346351
for (let ri = 0; ri < allRanges.length; ++ri) {
347352
const range = allRanges[ri];
348-
if (range.startOffset <= stmt.startOffset &&
349-
range.endOffset >= stmt.endOffset) {
353+
if (doesRangeContainOtherRange(range, stmt)) {
350354
const size = range.endOffset - range.startOffset;
351355
if (size < bestSize) {
352356
bestCount = range.count;
@@ -846,7 +850,8 @@ function findLineForOffset(offset, lines) {
846850

847851
if (offset >= line.startOffset && offset <= line.endOffset) {
848852
return line;
849-
} else if (offset > line.endOffset) {
853+
}
854+
if (offset > line.endOffset) {
850855
start = mid + 1;
851856
} else {
852857
end = mid - 1;

lib/internal/test_runner/utils.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,10 @@ function formatUncoveredLines(lines, table) {
454454
}
455455

456456
const kColumns = ['stmts %', 'line %', 'branch %', 'funcs %'];
457-
const kColumnsKeys = ['coveredStatementPercent', 'coveredLinePercent', 'coveredBranchPercent', 'coveredFunctionPercent'];
457+
const kColumnsKeys = [
458+
'coveredStatementPercent', 'coveredLinePercent',
459+
'coveredBranchPercent', 'coveredFunctionPercent',
460+
];
458461
const kSeparator = ' | ';
459462

460463
function buildFileTree(summary) {
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
'use strict';
2+
const test = require('node:test');
3+
4+
// Multiple statements on one line — only first executes if condition met
5+
function riskyOperation(value) {
6+
if (value > 0) return value * 2; return -1;
7+
}
8+
9+
// Single line if/else — only one branch runs per call
10+
function classify(n) {
11+
if (n > 0) return 'positive'; else return 'non-positive';
12+
}
13+
14+
// One-liner function where early validation can skip later statements
15+
function processEntry(a) { if (!a) return null; return String(a).toUpperCase(); }
16+
17+
// Uncalled function with multiple inline statements — 0% statement coverage
18+
function neverCalled() { const a = 1; const b = 2; return a + b; }
19+
20+
test('multistatement coverage', () => {
21+
riskyOperation(5); // takes early return, second statement skipped
22+
classify(10); // takes positive branch, else branch skipped
23+
processEntry('hello'); // passes guard, both statements execute
24+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/usr/bin/env node
2+
'use strict';
3+
const test = require('node:test');
4+
5+
// This file has a shebang line. Without allowHashBang: true,
6+
// acorn would fail to parse it and statement coverage would
7+
// degrade to 0 total statements.
8+
9+
function greet(name) {
10+
return 'Hello, ' + name;
11+
}
12+
13+
// Uncalled — should show as uncovered statements
14+
function farewell(name) {
15+
return 'Goodbye, ' + name;
16+
}
17+
18+
test('shebang file coverage', () => {
19+
greet('world');
20+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
'use strict';
2+
const test = require('node:test');
3+
const unparseable = require('./coverage-unparseable.js');
4+
5+
test('require unparseable module', () => {
6+
// The unparseable module loads fine at runtime (CJS wrapper makes
7+
// top-level `using` valid) but acorn cannot parse the raw source.
8+
// Statement coverage should degrade gracefully for that file.
9+
require('node:assert').ok(unparseable.ok);
10+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
'use strict';
2+
// Top-level `using` is valid inside the CJS module wrapper (V8 wraps this
3+
// in a function) but the ECMAScript spec forbids `using` at the top level
4+
// of a Script. Since acorn parses raw source as sourceType:'script',
5+
// this file triggers a parse error -> getStatements() returns null.
6+
// Line, branch, and function coverage still work normally.
7+
using resource = { [Symbol.dispose]() {} };
8+
module.exports = { ok: true };

0 commit comments

Comments
 (0)