Skip to content

Commit a95e904

Browse files
committed
feat: test_runner: add statement coverage support
Parse source files with acorn to extract AST statement nodes and map V8 coverage ranges to those statements, providing statement-level coverage metrics alongside existing line, branch, and function coverage. The implementation uses acorn-walk's Statement visitor to collect all non-BlockStatement nodes, then finds the most specific (smallest) V8 coverage range containing each statement to determine its execution count. Files that cannot be parsed by acorn gracefully degrade to 100% statement coverage. Adds --test-coverage-statements CLI option for setting minimum statement coverage thresholds, consistent with existing --test-coverage-lines, --test-coverage-branches, and --test-coverage-functions options. Refs: #54530
1 parent b328bf7 commit a95e904

7 files changed

Lines changed: 357 additions & 2 deletions

File tree

doc/api/cli.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2701,6 +2701,21 @@ added: v22.8.0
27012701
Require a minimum percent of covered lines. If code coverage does not reach
27022702
the threshold specified, the process will exit with code `1`.
27032703

2704+
### `--test-coverage-statements=threshold`
2705+
2706+
<!-- YAML
2707+
added: REPLACEME
2708+
-->
2709+
2710+
> Stability: 1 - Experimental
2711+
2712+
Require a minimum percent of covered statements. If code coverage does not reach
2713+
the threshold specified, the process will exit with code `1`.
2714+
2715+
Statement coverage uses acorn to parse source files and extract statement
2716+
nodes from the AST. The V8 coverage ranges are then mapped to these statements
2717+
to determine which ones were executed.
2718+
27042719
### `--test-force-exit`
27052720

27062721
<!-- YAML
@@ -3687,6 +3702,7 @@ one is included in the list below.
36873702
* `--test-coverage-functions`
36883703
* `--test-coverage-include`
36893704
* `--test-coverage-lines`
3705+
* `--test-coverage-statements`
36903706
* `--test-global-setup`
36913707
* `--test-isolation`
36923708
* `--test-name-pattern`

lib/internal/test_runner/coverage.js

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ const {
3838
} = require('internal/errors');
3939
const { matchGlobPattern } = require('internal/fs/glob');
4040
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');
4145

4246
const kCoverageFileRegex = /^coverage-(\d+)-(\d{13})-(\d+)\.json$/;
4347
const kIgnoreRegex = /\/\* node:coverage ignore next (?<count>\d+ )?\*\//;
@@ -69,6 +73,52 @@ class TestCoverage {
6973
}
7074

7175
#sourceLines = new SafeMap();
76+
#sourceStatements = new SafeMap();
77+
78+
getStatements(fileUrl, source) {
79+
if (this.#sourceStatements.has(fileUrl)) {
80+
return this.#sourceStatements.get(fileUrl);
81+
}
82+
83+
try {
84+
source ??= readFileSync(fileURLToPath(fileUrl), 'utf8');
85+
} catch {
86+
this.#sourceStatements.set(fileUrl, null);
87+
return null;
88+
}
89+
90+
const statements = [];
91+
try {
92+
const ast = AcornParser.parse(source, {
93+
__proto__: null,
94+
ecmaVersion: 'latest',
95+
sourceType: 'module',
96+
allowReturnOutsideFunction: true,
97+
allowImportExportEverywhere: true,
98+
allowAwaitOutsideFunction: true,
99+
});
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+
});
114+
} catch {
115+
this.#sourceStatements.set(fileUrl, null);
116+
return null;
117+
}
118+
119+
this.#sourceStatements.set(fileUrl, statements);
120+
return statements;
121+
}
72122

73123
getLines(fileUrl, source) {
74124
// Split the file source into lines. Make sure the lines maintain their
@@ -145,18 +195,22 @@ class TestCoverage {
145195
totalLineCount: 0,
146196
totalBranchCount: 0,
147197
totalFunctionCount: 0,
198+
totalStatementCount: 0,
148199
coveredLineCount: 0,
149200
coveredBranchCount: 0,
150201
coveredFunctionCount: 0,
202+
coveredStatementCount: 0,
151203
coveredLinePercent: 0,
152204
coveredBranchPercent: 0,
153205
coveredFunctionPercent: 0,
206+
coveredStatementPercent: 0,
154207
},
155208
thresholds: {
156209
__proto__: null,
157210
line: this.options.lineCoverage,
158211
branch: this.options.branchCoverage,
159212
function: this.options.functionCoverage,
213+
statement: this.options.statementCoverage,
160214
},
161215
};
162216

@@ -243,29 +297,83 @@ class TestCoverage {
243297
}
244298
}
245299

300+
// Compute statement coverage by mapping V8 ranges to AST statements.
301+
const statements = this.getStatements(url);
302+
let totalStatements = 0;
303+
let statementsCovered = 0;
304+
const statementReports = [];
305+
306+
if (statements) {
307+
for (let j = 0; j < statements.length; ++j) {
308+
const stmt = statements[j];
309+
let bestCount = 0;
310+
let bestSize = Infinity;
311+
let found = false;
312+
313+
for (let fi = 0; fi < functions.length; ++fi) {
314+
const { ranges } = functions[fi];
315+
for (let ri = 0; ri < ranges.length; ++ri) {
316+
const range = ranges[ri];
317+
if (range.startOffset <= stmt.startOffset &&
318+
range.endOffset >= stmt.endOffset) {
319+
const size = range.endOffset - range.startOffset;
320+
if (!found || size < bestSize) {
321+
bestCount = range.count;
322+
bestSize = size;
323+
}
324+
found = true;
325+
}
326+
}
327+
}
328+
329+
stmt.count = found ? bestCount : 0;
330+
331+
const stmtLine = findLineForOffset(stmt.startOffset, lines);
332+
const isIgnored = stmtLine != null && stmtLine.ignore;
333+
334+
if (!isIgnored) {
335+
totalStatements++;
336+
ArrayPrototypePush(statementReports, {
337+
__proto__: null,
338+
line: stmtLine?.line,
339+
count: stmt.count,
340+
});
341+
if (stmt.count > 0) {
342+
statementsCovered++;
343+
}
344+
}
345+
}
346+
}
347+
246348
ArrayPrototypePush(coverageSummary.files, {
247349
__proto__: null,
248350
path: fileURLToPath(url),
249351
totalLineCount: lines.length,
250352
totalBranchCount: totalBranches,
251353
totalFunctionCount: totalFunctions,
354+
totalStatementCount: totalStatements,
252355
coveredLineCount: coveredCnt,
253356
coveredBranchCount: branchesCovered,
254357
coveredFunctionCount: functionsCovered,
358+
coveredStatementCount: statementsCovered,
255359
coveredLinePercent: toPercentage(coveredCnt, lines.length),
256360
coveredBranchPercent: toPercentage(branchesCovered, totalBranches),
257361
coveredFunctionPercent: toPercentage(functionsCovered, totalFunctions),
362+
coveredStatementPercent: toPercentage(statementsCovered, totalStatements),
258363
functions: functionReports,
259364
branches: branchReports,
260365
lines: lineReports,
366+
statements: statementReports,
261367
});
262368

263369
coverageSummary.totals.totalLineCount += lines.length;
264370
coverageSummary.totals.totalBranchCount += totalBranches;
265371
coverageSummary.totals.totalFunctionCount += totalFunctions;
372+
coverageSummary.totals.totalStatementCount += totalStatements;
266373
coverageSummary.totals.coveredLineCount += coveredCnt;
267374
coverageSummary.totals.coveredBranchCount += branchesCovered;
268375
coverageSummary.totals.coveredFunctionCount += functionsCovered;
376+
coverageSummary.totals.coveredStatementCount += statementsCovered;
269377
}
270378

271379
coverageSummary.totals.coveredLinePercent = toPercentage(
@@ -280,6 +388,10 @@ class TestCoverage {
280388
coverageSummary.totals.coveredFunctionCount,
281389
coverageSummary.totals.totalFunctionCount,
282390
);
391+
coverageSummary.totals.coveredStatementPercent = toPercentage(
392+
coverageSummary.totals.coveredStatementCount,
393+
coverageSummary.totals.totalStatementCount,
394+
);
283395
coverageSummary.files.sort(sortCoverageFiles);
284396

285397
return coverageSummary;
@@ -695,4 +807,24 @@ function doesRangeContainOtherRange(range, otherRange) {
695807
range.endOffset >= otherRange.endOffset;
696808
}
697809

810+
function findLineForOffset(offset, lines) {
811+
let start = 0;
812+
let end = lines.length - 1;
813+
814+
while (start <= end) {
815+
const mid = MathFloor((start + end) / 2);
816+
const line = lines[mid];
817+
818+
if (offset >= line.startOffset && offset <= line.endOffset) {
819+
return line;
820+
} else if (offset > line.endOffset) {
821+
start = mid + 1;
822+
} else {
823+
end = mid - 1;
824+
}
825+
}
826+
827+
return null;
828+
}
829+
698830
module.exports = { setupCoverage, TestCoverage };

lib/internal/test_runner/test.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1367,6 +1367,9 @@ class Test extends AsyncResource {
13671367

13681368
{ __proto__: null, actual: coverage.totals.coveredFunctionPercent,
13691369
threshold: this.config.functionCoverage, name: 'function' },
1370+
1371+
{ __proto__: null, actual: coverage.totals.coveredStatementPercent,
1372+
threshold: this.config.statementCoverage, name: 'statement' },
13701373
];
13711374

13721375
for (let i = 0; i < coverages.length; i++) {

lib/internal/test_runner/utils.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ function parseCommandLine() {
227227
let lineCoverage;
228228
let branchCoverage;
229229
let functionCoverage;
230+
let statementCoverage;
230231
let destinations;
231232
let isolation;
232233
let only = getOptionValue('--test-only');
@@ -318,10 +319,12 @@ function parseCommandLine() {
318319
branchCoverage = getOptionValue('--test-coverage-branches');
319320
lineCoverage = getOptionValue('--test-coverage-lines');
320321
functionCoverage = getOptionValue('--test-coverage-functions');
322+
statementCoverage = getOptionValue('--test-coverage-statements');
321323

322324
validateInteger(branchCoverage, '--test-coverage-branches', 0, 100);
323325
validateInteger(lineCoverage, '--test-coverage-lines', 0, 100);
324326
validateInteger(functionCoverage, '--test-coverage-functions', 0, 100);
327+
validateInteger(statementCoverage, '--test-coverage-statements', 0, 100);
325328
}
326329

327330
if (rerunFailuresFilePath) {
@@ -351,6 +354,7 @@ function parseCommandLine() {
351354
branchCoverage,
352355
functionCoverage,
353356
lineCoverage,
357+
statementCoverage,
354358
only,
355359
reporters,
356360
setup,
@@ -449,8 +453,8 @@ function formatUncoveredLines(lines, table) {
449453
return ArrayPrototypeJoin(lines, ', ');
450454
}
451455

452-
const kColumns = ['line %', 'branch %', 'funcs %'];
453-
const kColumnsKeys = ['coveredLinePercent', 'coveredBranchPercent', 'coveredFunctionPercent'];
456+
const kColumns = ['stmts %', 'line %', 'branch %', 'funcs %'];
457+
const kColumnsKeys = ['coveredStatementPercent', 'coveredLinePercent', 'coveredBranchPercent', 'coveredFunctionPercent'];
454458
const kSeparator = ' | ';
455459

456460
function buildFileTree(summary) {

src/node_options.cc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -935,6 +935,11 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
935935
&EnvironmentOptions::test_coverage_lines,
936936
kAllowedInEnvvar,
937937
OptionNamespaces::kTestRunnerNamespace);
938+
AddOption("--test-coverage-statements",
939+
"the statement coverage minimum threshold",
940+
&EnvironmentOptions::test_coverage_statements,
941+
kAllowedInEnvvar,
942+
OptionNamespaces::kTestRunnerNamespace);
938943
AddOption("--test-isolation",
939944
"configures the type of test isolation used in the test runner",
940945
&EnvironmentOptions::test_isolation,

src/node_options.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ class EnvironmentOptions : public Options {
198198
uint64_t test_coverage_branches = 0;
199199
uint64_t test_coverage_functions = 0;
200200
uint64_t test_coverage_lines = 0;
201+
uint64_t test_coverage_statements = 0;
201202
bool test_runner_module_mocks = false;
202203
bool test_runner_update_snapshots = false;
203204
std::vector<std::string> test_name_pattern;

0 commit comments

Comments
 (0)