Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions doc/api/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -2701,6 +2701,21 @@ added: v22.8.0
Require a minimum percent of covered lines. If code coverage does not reach
the threshold specified, the process will exit with code `1`.

### `--test-coverage-statements=threshold`

<!-- YAML
added: REPLACEME
-->

> Stability: 1 - Experimental

Require a minimum percent of covered statements. If code coverage does not reach
the threshold specified, the process will exit with code `1`.

Statement coverage uses acorn to parse source files and extract statement
nodes from the AST. The V8 coverage ranges are then mapped to these statements
to determine which ones were executed.

### `--test-force-exit`

<!-- YAML
Expand Down Expand Up @@ -3687,6 +3702,7 @@ one is included in the list below.
* `--test-coverage-functions`
* `--test-coverage-include`
* `--test-coverage-lines`
* `--test-coverage-statements`
* `--test-global-setup`
* `--test-isolation`
* `--test-name-pattern`
Expand Down
170 changes: 169 additions & 1 deletion lib/internal/test_runner/coverage.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@ const {
} = require('internal/errors');
const { matchGlobPattern } = require('internal/fs/glob');
const { constants: { kMockSearchParam } } = require('internal/test_runner/mock/loader');
const { Parser: AcornParser } =
require('internal/deps/acorn/acorn/dist/acorn');
const { simple: acornWalkSimple } =
require('internal/deps/acorn/acorn-walk/dist/walk');

const kStatementTypes = [
'ExpressionStatement', 'ReturnStatement', 'ThrowStatement',
'IfStatement', 'WhileStatement', 'DoWhileStatement',
'ForStatement', 'ForInStatement', 'ForOfStatement',
'SwitchStatement', 'TryStatement', 'BreakStatement',
'ContinueStatement', 'VariableDeclaration', 'LabeledStatement',
'WithStatement', 'DebuggerStatement',
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like this level of hard coding, is there not a general Statement handler or something?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — acorn-walk's simple() does support a generic Statement category visitor that fires for every node dispatched in a statement position. Replaced the hardcoded array with a single visitor.Statement handler and a small deny-set for BlockStatement/EmptyStatement.

This also picks up ClassDeclaration, FunctionDeclaration, and StaticBlock which were missing from the original list, and stays forward-compatible with any future ESTree statement types.

Simplified the double parse (module→script fallback) to a single sourceType: 'script' pass with permissive flags too, since script mode + allowImportExportEverywhere handles both ESM and legacy CJS.

];

const kCoverageFileRegex = /^coverage-(\d+)-(\d{13})-(\d+)\.json$/;
const kIgnoreRegex = /\/\* node:coverage ignore next (?<count>\d+ )?\*\//;
Expand Down Expand Up @@ -69,6 +82,70 @@ class TestCoverage {
}

#sourceLines = new SafeMap();
#sourceStatements = new SafeMap();

getStatements(fileUrl, source) {
if (this.#sourceStatements.has(fileUrl)) {
return this.#sourceStatements.get(fileUrl);
}

try {
source ??= readFileSync(fileURLToPath(fileUrl), 'utf8');
} catch {
this.#sourceStatements.set(fileUrl, null);
return null;
}

const statements = [];

// Build a visitor with one handler per concrete statement type.
// acorn-walk does not fire a generic "Statement" visitor for concrete
// node types, so each type must be registered individually.
const visitor = { __proto__: null };
const handler = (node) => {
ArrayPrototypePush(statements, {
__proto__: null,
startOffset: node.start,
endOffset: node.end,
count: 0,
});
};
for (let i = 0; i < kStatementTypes.length; ++i) {
visitor[kStatementTypes[i]] = handler;
}

// Try parsing as a module first, fall back to script for files that
// use CommonJS syntax incompatible with module mode.
let ast;
try {
ast = AcornParser.parse(source, {
__proto__: null,
ecmaVersion: 'latest',
sourceType: 'module',
allowReturnOutsideFunction: true,
allowImportExportEverywhere: true,
allowAwaitOutsideFunction: true,
});
} catch {
try {
ast = AcornParser.parse(source, {
__proto__: null,
ecmaVersion: 'latest',
sourceType: 'script',
allowReturnOutsideFunction: true,
allowAwaitOutsideFunction: true,
});
} catch {
this.#sourceStatements.set(fileUrl, null);
return null;
}
}

acornWalkSimple(ast, visitor);

this.#sourceStatements.set(fileUrl, statements);
return statements;
}

getLines(fileUrl, source) {
// Split the file source into lines. Make sure the lines maintain their
Expand Down Expand Up @@ -145,18 +222,22 @@ class TestCoverage {
totalLineCount: 0,
totalBranchCount: 0,
totalFunctionCount: 0,
totalStatementCount: 0,
coveredLineCount: 0,
coveredBranchCount: 0,
coveredFunctionCount: 0,
coveredStatementCount: 0,
coveredLinePercent: 0,
coveredBranchPercent: 0,
coveredFunctionPercent: 0,
coveredStatementPercent: 0,
},
thresholds: {
__proto__: null,
line: this.options.lineCoverage,
branch: this.options.branchCoverage,
function: this.options.functionCoverage,
statement: this.options.statementCoverage,
},
};

Expand All @@ -174,7 +255,16 @@ class TestCoverage {
const functionReports = [];
const branchReports = [];

const lines = this.getLines(url);
// Read source once and pass to both getLines and getStatements to
// avoid double disk I/O for the same file.
let source;
try {
source = readFileSync(fileURLToPath(url), 'utf8');
} catch {
continue;
}

const lines = this.getLines(url, source);
if (!lines) {
continue;
}
Expand Down Expand Up @@ -243,29 +333,83 @@ class TestCoverage {
}
}

// Compute statement coverage by mapping V8 ranges to AST statements.
// Pass the source already read above to avoid double disk I/O.
const statements = this.getStatements(url, source);
let totalStatements = 0;
let statementsCovered = 0;
const statementReports = [];

if (statements) {
for (let j = 0; j < statements.length; ++j) {
const stmt = statements[j];
let bestCount = 0;
let bestRange = null;

for (let fi = 0; fi < functions.length; ++fi) {
const { ranges } = functions[fi];
for (let ri = 0; ri < ranges.length; ++ri) {
const range = ranges[ri];
if (range.startOffset <= stmt.startOffset &&
range.endOffset >= stmt.endOffset) {
const size = range.endOffset - range.startOffset;
if (bestRange === null ||
size < (bestRange.endOffset - bestRange.startOffset)) {
bestCount = range.count;
bestRange = range;
}
}
}
}

stmt.count = bestRange !== null ? bestCount : 0;

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

if (!isIgnored) {
totalStatements++;
ArrayPrototypePush(statementReports, {
__proto__: null,
line: stmtLine?.line,
count: stmt.count,
});
if (stmt.count > 0) {
statementsCovered++;
}
}
}
}

ArrayPrototypePush(coverageSummary.files, {
__proto__: null,
path: fileURLToPath(url),
totalLineCount: lines.length,
totalBranchCount: totalBranches,
totalFunctionCount: totalFunctions,
totalStatementCount: totalStatements,
coveredLineCount: coveredCnt,
coveredBranchCount: branchesCovered,
coveredFunctionCount: functionsCovered,
coveredStatementCount: statementsCovered,
coveredLinePercent: toPercentage(coveredCnt, lines.length),
coveredBranchPercent: toPercentage(branchesCovered, totalBranches),
coveredFunctionPercent: toPercentage(functionsCovered, totalFunctions),
coveredStatementPercent: toPercentage(statementsCovered, totalStatements),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm.. since getStatements can return null if acorn cannot successfully parse the input, totalStatements here is going to be 0 and toPercentage will return 100, which doesn't seem right.

functions: functionReports,
branches: branchReports,
lines: lineReports,
statements: statementReports,
});

coverageSummary.totals.totalLineCount += lines.length;
coverageSummary.totals.totalBranchCount += totalBranches;
coverageSummary.totals.totalFunctionCount += totalFunctions;
coverageSummary.totals.totalStatementCount += totalStatements;
coverageSummary.totals.coveredLineCount += coveredCnt;
coverageSummary.totals.coveredBranchCount += branchesCovered;
coverageSummary.totals.coveredFunctionCount += functionsCovered;
coverageSummary.totals.coveredStatementCount += statementsCovered;
}

coverageSummary.totals.coveredLinePercent = toPercentage(
Expand All @@ -280,6 +424,10 @@ class TestCoverage {
coverageSummary.totals.coveredFunctionCount,
coverageSummary.totals.totalFunctionCount,
);
coverageSummary.totals.coveredStatementPercent = toPercentage(
coverageSummary.totals.coveredStatementCount,
coverageSummary.totals.totalStatementCount,
);
coverageSummary.files.sort(sortCoverageFiles);

return coverageSummary;
Expand Down Expand Up @@ -695,4 +843,24 @@ function doesRangeContainOtherRange(range, otherRange) {
range.endOffset >= otherRange.endOffset;
}

function findLineForOffset(offset, lines) {
let start = 0;
let end = lines.length - 1;

while (start <= end) {
const mid = MathFloor((start + end) / 2);
const line = lines[mid];

if (offset >= line.startOffset && offset <= line.endOffset) {
return line;
} else if (offset > line.endOffset) {
start = mid + 1;
} else {
end = mid - 1;
}
}

return null;
}

module.exports = { setupCoverage, TestCoverage };
3 changes: 3 additions & 0 deletions lib/internal/test_runner/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1367,6 +1367,9 @@ class Test extends AsyncResource {

{ __proto__: null, actual: coverage.totals.coveredFunctionPercent,
threshold: this.config.functionCoverage, name: 'function' },

{ __proto__: null, actual: coverage.totals.coveredStatementPercent,
threshold: this.config.statementCoverage, name: 'statement' },
];

for (let i = 0; i < coverages.length; i++) {
Expand Down
8 changes: 6 additions & 2 deletions lib/internal/test_runner/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@
let lineCoverage;
let branchCoverage;
let functionCoverage;
let statementCoverage;
let destinations;
let isolation;
let only = getOptionValue('--test-only');
Expand Down Expand Up @@ -318,10 +319,12 @@
branchCoverage = getOptionValue('--test-coverage-branches');
lineCoverage = getOptionValue('--test-coverage-lines');
functionCoverage = getOptionValue('--test-coverage-functions');
statementCoverage = getOptionValue('--test-coverage-statements');

validateInteger(branchCoverage, '--test-coverage-branches', 0, 100);
validateInteger(lineCoverage, '--test-coverage-lines', 0, 100);
validateInteger(functionCoverage, '--test-coverage-functions', 0, 100);
validateInteger(statementCoverage, '--test-coverage-statements', 0, 100);
}

if (rerunFailuresFilePath) {
Expand Down Expand Up @@ -351,6 +354,7 @@
branchCoverage,
functionCoverage,
lineCoverage,
statementCoverage,
only,
reporters,
setup,
Expand Down Expand Up @@ -449,8 +453,8 @@
return ArrayPrototypeJoin(lines, ', ');
}

const kColumns = ['line %', 'branch %', 'funcs %'];
const kColumnsKeys = ['coveredLinePercent', 'coveredBranchPercent', 'coveredFunctionPercent'];
const kColumns = ['stmts %', 'line %', 'branch %', 'funcs %'];
const kColumnsKeys = ['coveredStatementPercent', 'coveredLinePercent', 'coveredBranchPercent', 'coveredFunctionPercent'];

Check failure on line 457 in lib/internal/test_runner/utils.js

View workflow job for this annotation

GitHub Actions / lint-js-and-md

This line has a length of 121. Maximum allowed is 120
const kSeparator = ' | ';

function buildFileTree(summary) {
Expand Down
5 changes: 5 additions & 0 deletions src/node_options.cc
Original file line number Diff line number Diff line change
Expand Up @@ -935,6 +935,11 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
&EnvironmentOptions::test_coverage_lines,
kAllowedInEnvvar,
OptionNamespaces::kTestRunnerNamespace);
AddOption("--test-coverage-statements",
"the statement coverage minimum threshold",
&EnvironmentOptions::test_coverage_statements,
kAllowedInEnvvar,
OptionNamespaces::kTestRunnerNamespace);
AddOption("--test-isolation",
"configures the type of test isolation used in the test runner",
&EnvironmentOptions::test_isolation,
Expand Down
1 change: 1 addition & 0 deletions src/node_options.h
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ class EnvironmentOptions : public Options {
uint64_t test_coverage_branches = 0;
uint64_t test_coverage_functions = 0;
uint64_t test_coverage_lines = 0;
uint64_t test_coverage_statements = 0;
bool test_runner_module_mocks = false;
bool test_runner_update_snapshots = false;
std::vector<std::string> test_name_pattern;
Expand Down
Loading
Loading