Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
132 changes: 132 additions & 0 deletions lib/internal/test_runner/coverage.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@
} = 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 kCoverageFileRegex = /^coverage-(\d+)-(\d{13})-(\d+)\.json$/;
const kIgnoreRegex = /\/\* node:coverage ignore next (?<count>\d+ )?\*\//;
Expand Down Expand Up @@ -69,6 +73,52 @@
}

#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 = [];
try {
const ast = AcornParser.parse(source, {
__proto__: null,
ecmaVersion: 'latest',
sourceType: 'module',
allowReturnOutsideFunction: true,
allowImportExportEverywhere: true,
allowAwaitOutsideFunction: true,
});

acornWalkSimple(ast, {

Check failure on line 101 in lib/internal/test_runner/coverage.js

View workflow job for this annotation

GitHub Actions / lint-js-and-md

Every object must have __proto__: null
Statement(node) {
if (node.type === 'BlockStatement') {
return;
}
ArrayPrototypePush(statements, {
__proto__: null,
startOffset: node.start,
endOffset: node.end,
count: 0,
});
},
});
} catch {
this.#sourceStatements.set(fileUrl, null);
return null;
}

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 +195,22 @@
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 Down Expand Up @@ -243,29 +297,83 @@
}
}

// Compute statement coverage by mapping V8 ranges to AST statements.
const statements = this.getStatements(url);
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 bestSize = Infinity;
let found = false;

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 (!found || size < bestSize) {
bestCount = range.count;
bestSize = size;
}
found = true;
}
}
}

stmt.count = found ? 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 +388,10 @@
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 +807,24 @@
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