Files
node/test/parallel/test-runner-coverage.js
Christian Aurich 82fc59d4cb test: deflake test-runner-coverage
The subtests that set NODE_V8_COVERAGE leave a coverage-<pid>-*.json
file in the test tmpdir, and nothing removes it. The later subtests
spawn without NODE_V8_COVERAGE, so their coverage goes to a private
mkdtemp directory that is removed on cleanup rather than to the test
tmpdir. A match in findCoverageFileForPid(result.pid) can therefore only
come from a stale file whose pid has been reused, which is why the
failures are only reported on platforms that recycle pids quickly.

Refresh the tmpdir from an after() hook on each subtest that dumps into
it, so a failure in one of them cannot leave the file behind and turn
every later check into a second, misleading failure. Drop the flaky
designations.

Fixes: https://github.com/nodejs/node/issues/55154
Signed-off-by: Christian Aurich <christian.aurichzm@gmail.com>
PR-URL: https://github.com/nodejs/node/pull/65728
Reviewed-By: Chemi Atlow <chemi@atlow.co.il>
Reviewed-By: Filip Skokan <panva.ip@gmail.com>
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
2026-09-03 23:54:01 +00:00

660 lines
26 KiB
JavaScript

'use strict';
const common = require('../common');
const assert = require('node:assert');
const { spawnSync } = require('node:child_process');
const { readdirSync } = require('node:fs');
const { test } = require('node:test');
const fixtures = require('../common/fixtures');
const tmpdir = require('../common/tmpdir');
const skipIfNoInspector = {
skip: !process.features.inspector ? 'inspector disabled' : false
};
tmpdir.refresh();
function findCoverageFileForPid(pid) {
const pattern = `^coverage\\-${pid}\\-(\\d{13})\\-(\\d+)\\.json$`;
const regex = new RegExp(pattern);
return readdirSync(tmpdir.path).find((file) => {
return regex.test(file);
});
}
function getTapCoverageFixtureReport() {
const report = [
'# start of coverage report',
'# --------------------------------------------------------------------------------------------',
'# file | line % | branch % | funcs % | uncovered lines',
'# --------------------------------------------------------------------------------------------',
'# test | | | | ',
'# fixtures | | | | ',
'# test-runner | | | | ',
'# coverage.js | 78.65 | 38.46 | 60.00 | 12-13 16-22 27 39 43-44 61-62 66-67 71-72',
'# invalid-tap.js | 100.00 | 100.00 | 100.00 | ',
'# v8-coverage | | | | ',
'# throw.js | 71.43 | 50.00 | 100.00 | 5-6',
'# --------------------------------------------------------------------------------------------',
'# all files | 78.35 | 43.75 | 60.00 | ',
'# --------------------------------------------------------------------------------------------',
'# end of coverage report',
].join('\n');
if (common.isWindows) {
return report.replaceAll('/', '\\');
}
return report;
}
function getSpecCoverageFixtureReport() {
const report = [
'\u2139 start of coverage report',
'\u2139 --------------------------------------------------------------------------------------------',
'\u2139 file | line % | branch % | funcs % | uncovered lines',
'\u2139 --------------------------------------------------------------------------------------------',
'\u2139 test | | | | ',
'\u2139 fixtures | | | | ',
'\u2139 test-runner | | | | ',
'\u2139 coverage.js | 78.65 | 38.46 | 60.00 | 12-13 16-22 27 39 43-44 61-62 66-67 71-72',
'\u2139 invalid-tap.js | 100.00 | 100.00 | 100.00 | ',
'\u2139 v8-coverage | | | | ',
'\u2139 throw.js | 71.43 | 50.00 | 100.00 | 5-6',
'\u2139 --------------------------------------------------------------------------------------------',
'\u2139 all files | 78.35 | 43.75 | 60.00 | ',
'\u2139 --------------------------------------------------------------------------------------------',
'\u2139 end of coverage report',
].join('\n');
if (common.isWindows) {
return report.replaceAll('/', '\\');
}
return report;
}
function formatSpawnSyncResult(result) {
return [
`status: ${result.status}`,
`signal: ${result.signal}`,
`stdout:\n${result.stdout}`,
`stderr:\n${result.stderr}`,
].join('\n');
}
function assertIncludesReport(result, report) {
assert(
result.stdout.toString().includes(report),
formatSpawnSyncResult(result),
);
}
test('test coverage report', async (t) => {
await t.test('handles the inspector not being available', (t) => {
if (process.features.inspector) {
return;
}
const fixture = fixtures.path('test-runner', 'coverage.js');
const args = [
'--experimental-test-coverage',
'--test-coverage-exclude=!test/**',
fixture,
];
const result = spawnSync(process.execPath, args);
assert(!result.stdout.toString().includes('# start of coverage report'));
assert(result.stderr.toString().includes('coverage could not be collected'));
assert.strictEqual(result.status, 0);
assert(!findCoverageFileForPid(result.pid));
});
});
test('test tap coverage reporter', skipIfNoInspector, async (t) => {
await t.test('coverage is reported and dumped to NODE_V8_COVERAGE if present', (t) => {
// A dump left here would match the negative checks below on pid reuse.
t.after(() => tmpdir.refresh());
const fixture = fixtures.path('test-runner', 'coverage.js');
const args = [
'--experimental-test-coverage',
'--test-coverage-exclude=!test/**',
'--test-reporter',
'tap',
fixture,
];
const options = { env: { ...process.env, NODE_V8_COVERAGE: tmpdir.path } };
const result = spawnSync(process.execPath, args, options);
const report = getTapCoverageFixtureReport();
assertIncludesReport(result, report);
assert.strictEqual(result.stderr.toString(), '');
assert.strictEqual(result.status, 0);
assert(findCoverageFileForPid(result.pid));
});
await t.test('coverage is reported without NODE_V8_COVERAGE present', (t) => {
const fixture = fixtures.path('test-runner', 'coverage.js');
const args = [
'--experimental-test-coverage',
'--test-coverage-exclude=!test/**',
'--test-reporter',
'tap',
fixture,
];
const result = spawnSync(process.execPath, args);
const report = getTapCoverageFixtureReport();
assertIncludesReport(result, report);
assert.strictEqual(result.stderr.toString(), '');
assert.strictEqual(result.status, 0);
assert(!findCoverageFileForPid(result.pid));
});
});
test('test spec coverage reporter', skipIfNoInspector, async (t) => {
await t.test('coverage is reported and dumped to NODE_V8_COVERAGE if present', (t) => {
// A dump left here would match the negative checks below on pid reuse.
t.after(() => tmpdir.refresh());
const fixture = fixtures.path('test-runner', 'coverage.js');
const args = [
'--experimental-test-coverage',
'--test-coverage-exclude=!test/**',
'--test-reporter',
'spec',
fixture];
const options = { env: { ...process.env, NODE_V8_COVERAGE: tmpdir.path } };
const result = spawnSync(process.execPath, args, options);
const report = getSpecCoverageFixtureReport();
assertIncludesReport(result, report);
assert.strictEqual(result.stderr.toString(), '');
assert.strictEqual(result.status, 0);
assert(findCoverageFileForPid(result.pid));
});
await t.test('coverage is reported without NODE_V8_COVERAGE present', (t) => {
const fixture = fixtures.path('test-runner', 'coverage.js');
const args = [
'--experimental-test-coverage',
'--test-coverage-exclude=!test/**',
'--test-reporter',
'spec',
fixture];
const result = spawnSync(process.execPath, args);
const report = getSpecCoverageFixtureReport();
assertIncludesReport(result, report);
assert.strictEqual(result.stderr.toString(), '');
assert.strictEqual(result.status, 0);
assert(!findCoverageFileForPid(result.pid));
});
});
test('single process coverage is the same with --test', skipIfNoInspector, () => {
const fixture = fixtures.path('test-runner', 'coverage.js');
const args = [
'--test',
'--experimental-test-coverage',
'--test-coverage-exclude=!test/**',
'--test-reporter',
'tap',
fixture,
];
const result = spawnSync(process.execPath, args);
const report = getTapCoverageFixtureReport();
assert.strictEqual(result.stderr.toString(), '');
assertIncludesReport(result, report);
assert.strictEqual(result.status, 0);
assert(!findCoverageFileForPid(result.pid));
});
test('coverage is combined for multiple processes', skipIfNoInspector, () => {
let report = [
'# start of coverage report',
'# -------------------------------------------------------------------',
'# file | line % | branch % | funcs % | uncovered lines',
'# -------------------------------------------------------------------',
'# common.js | 89.86 | 62.50 | 100.00 | 8 13-14 18 34-35 53',
'# first.test.js | 83.33 | 100.00 | 50.00 | 5-6',
'# second.test.js | 100.00 | 100.00 | 100.00 | ',
'# third.test.js | 100.00 | 100.00 | 100.00 | ',
'# -------------------------------------------------------------------',
'# all files | 92.11 | 72.73 | 88.89 | ',
'# -------------------------------------------------------------------',
'# end of coverage report',
].join('\n');
if (common.isWindows) {
report = report.replaceAll('/', '\\');
}
const fixture = fixtures.path('v8-coverage', 'combined_coverage');
const args = [
'--test',
'--experimental-test-coverage',
'--test-coverage-exclude=!test/**',
'--test-reporter',
'tap',
];
const result = spawnSync(process.execPath, args, {
env: { ...process.env, NODE_TEST_TMPDIR: tmpdir.path },
cwd: fixture,
});
assert.strictEqual(result.stderr.toString(), '');
assertIncludesReport(result, report);
assert.strictEqual(result.status, 0);
});
test.skip('coverage works with isolation=none', skipIfNoInspector, common.mustCallAtLeast(() => {
// There is a bug in coverage calculation. The branch % in the common.js
// fixture is different depending on the test isolation mode. The 'none' mode
// is closer to what c8 reports here, so the bug is likely in the code that
// merges reports from different processes.
let report = [
'# start of coverage report',
'# -------------------------------------------------------------------',
'# file | line % | branch % | funcs % | uncovered lines',
'# -------------------------------------------------------------------',
'# common.js | 89.86 | 68.42 | 100.00 | 8 13-14 18 34-35 53',
'# first.test.js | 83.33 | 100.00 | 50.00 | 5-6',
'# second.test.js | 100.00 | 100.00 | 100.00 | ',
'# third.test.js | 100.00 | 100.00 | 100.00 | ',
'# -------------------------------------------------------------------',
'# all files | 92.11 | 76.00 | 88.89 | ',
'# -------------------------------------------------------------------',
'# end of coverage report',
].join('\n');
if (common.isWindows) {
report = report.replaceAll('/', '\\');
}
const fixture = fixtures.path('v8-coverage', 'combined_coverage');
const args = [
'--test',
'--experimental-test-coverage',
'--test-reporter',
'tap',
'--test-isolation=none',
];
const result = spawnSync(process.execPath, args, {
env: { ...process.env, NODE_TEST_TMPDIR: tmpdir.path },
cwd: fixture,
});
assert.strictEqual(result.stderr.toString(), '');
assertIncludesReport(result, report);
assert.strictEqual(result.status, 0);
}, 0));
test('coverage reports on lines, functions, and branches', skipIfNoInspector, async (t) => {
const fixture = fixtures.path('test-runner', 'coverage.js');
const child = spawnSync(process.execPath,
[
'--test',
'--experimental-test-coverage',
'--test-coverage-exclude=!test/**',
'--test-reporter',
fixtures.fileURL('test-runner/custom_reporters/coverage.mjs'),
fixture,
]);
assert.strictEqual(child.stderr.toString(), '');
const stdout = child.stdout.toString();
assert.notStrictEqual(stdout, '', formatSpawnSyncResult(child));
const coverage = JSON.parse(stdout);
await t.test('does not include node_modules', () => {
assert.strictEqual(coverage.summary.files.length, 3);
const files = ['coverage.js', 'invalid-tap.js', 'throw.js'];
coverage.summary.files.forEach(common.mustCallAtLeast((file, index) => {
assert.ok(file.path.endsWith(files[index]));
}));
});
const file = coverage.summary.files[0];
await t.test('reports on function coverage', () => {
const uncalledFunction = file.functions.find((f) => f.name === 'uncalledTopLevelFunction');
assert.strictEqual(uncalledFunction.count, 0);
assert.strictEqual(uncalledFunction.line, 16);
const calledTwice = file.functions.find((f) => f.name === 'fnWithControlFlow');
assert.strictEqual(calledTwice.count, 2);
assert.strictEqual(calledTwice.line, 35);
});
await t.test('reports on branch coverage', () => {
const uncalledBranch = file.branches.find((b) => b.line === 6);
assert.strictEqual(uncalledBranch.count, 0);
const calledTwice = file.branches.find((b) => b.line === 35);
assert.strictEqual(calledTwice.count, 2);
});
await t.test('reports on line coverage', () => {
[
{ line: 36, count: 2 },
{ line: 37, count: 1 },
{ line: 38, count: 1 },
{ line: 39, count: 0 },
{ line: 40, count: 1 },
{ line: 41, count: 1 },
{ line: 42, count: 1 },
{ line: 43, count: 0 },
{ line: 44, count: 0 },
].forEach((line) => {
const testLine = file.lines.find((l) => l.line === line.line);
assert.strictEqual(testLine.count, line.count);
});
});
});
test('coverage with ESM hook - source irrelevant', skipIfNoInspector, () => {
let report = [
'# start of coverage report',
'# ------------------------------------------------------------------',
'# file | line % | branch % | funcs % | uncovered lines',
'# ------------------------------------------------------------------',
'# hooks.mjs | 100.00 | 100.00 | 100.00 | ',
'# register-hooks.js | 100.00 | 100.00 | 100.00 | ',
'# virtual.js | 100.00 | 100.00 | 100.00 | ',
'# ------------------------------------------------------------------',
'# all files | 100.00 | 100.00 | 100.00 | ',
'# ------------------------------------------------------------------',
'# end of coverage report',
].join('\n');
if (common.isWindows) {
report = report.replaceAll('/', '\\');
}
const fixture = fixtures.path('test-runner', 'coverage-loader');
const args = [
'--import',
'./register-hooks.js',
'--test',
'--experimental-test-coverage',
'--test-coverage-exclude=!test/**',
'--test-reporter',
'tap',
'virtual.js',
];
const result = spawnSync(process.execPath, args, { cwd: fixture });
assert.strictEqual(result.stderr.toString(), '');
assertIncludesReport(result, report);
assert.strictEqual(result.status, 0);
});
test('coverage with ESM hook - source transpiled', skipIfNoInspector, () => {
let report = [
'# start of coverage report',
'# ------------------------------------------------------------------',
'# file | line % | branch % | funcs % | uncovered lines',
'# ------------------------------------------------------------------',
'# hooks.mjs | 100.00 | 100.00 | 100.00 | ',
'# register-hooks.js | 100.00 | 100.00 | 100.00 | ',
'# sum.test.ts | 100.00 | 100.00 | 100.00 | ',
'# sum.ts | 100.00 | 100.00 | 100.00 | ',
'# ------------------------------------------------------------------',
'# all files | 100.00 | 100.00 | 100.00 | ',
'# ------------------------------------------------------------------',
'# end of coverage report',
].join('\n');
if (common.isWindows) {
report = report.replaceAll('/', '\\');
}
const fixture = fixtures.path('test-runner', 'coverage-loader');
const args = [
'--import', './register-hooks.js',
'--test',
'--experimental-test-coverage',
'--test-coverage-exclude=!test/**',
'--test-reporter', 'tap', 'sum.test.ts',
];
const result = spawnSync(process.execPath, args, { cwd: fixture });
assert.strictEqual(result.stderr.toString(), '');
assertIncludesReport(result, report);
assert.strictEqual(result.status, 0);
});
test('coverage with excluded files', skipIfNoInspector, () => {
const fixture = fixtures.path('test-runner', 'coverage.js');
const args = [
'--experimental-test-coverage', '--test-reporter', 'tap',
'--test-coverage-exclude=test/*/test-runner/invalid-tap.js',
'--test-coverage-exclude=!test/**',
fixture];
const result = spawnSync(process.execPath, args);
const report = [
'# start of coverage report',
'# -----------------------------------------------------------------------------------------',
'# file | line % | branch % | funcs % | uncovered lines',
'# -----------------------------------------------------------------------------------------',
'# test | | | | ',
'# fixtures | | | | ',
'# test-runner | | | | ',
'# coverage.js | 78.65 | 38.46 | 60.00 | 12-13 16-22 27 39 43-44 61-62 66-67 71-72',
'# v8-coverage | | | | ',
'# throw.js | 71.43 | 50.00 | 100.00 | 5-6',
'# -----------------------------------------------------------------------------------------',
'# all files | 78.13 | 40.00 | 60.00 | ',
'# -----------------------------------------------------------------------------------------',
'# end of coverage report',
].join('\n');
if (common.isWindows) {
return report.replaceAll('/', '\\');
}
assertIncludesReport(result, report);
assert.strictEqual(result.status, 0);
assert(!findCoverageFileForPid(result.pid));
});
test('coverage with included files', skipIfNoInspector, () => {
const fixture = fixtures.path('test-runner', 'coverage.js');
const args = [
'--experimental-test-coverage', '--test-reporter', 'tap',
'--test-coverage-include=test/fixtures/test-runner/coverage.js',
'--test-coverage-include=test/fixtures/v8-coverage/throw.js',
'--test-coverage-exclude=!test/**',
fixture,
];
const result = spawnSync(process.execPath, args);
const report = [
'# start of coverage report',
'# -----------------------------------------------------------------------------------------',
'# file | line % | branch % | funcs % | uncovered lines',
'# -----------------------------------------------------------------------------------------',
'# test | | | | ',
'# fixtures | | | | ',
'# test-runner | | | | ',
'# coverage.js | 78.65 | 38.46 | 60.00 | 12-13 16-22 27 39 43-44 61-62 66-67 71-72',
'# v8-coverage | | | | ',
'# throw.js | 71.43 | 50.00 | 100.00 | 5-6',
'# -----------------------------------------------------------------------------------------',
'# all files | 78.13 | 40.00 | 60.00 | ',
'# -----------------------------------------------------------------------------------------',
'# end of coverage report',
].join('\n');
if (common.isWindows) {
return report.replaceAll('/', '\\');
}
assertIncludesReport(result, report);
assert.strictEqual(result.status, 0);
assert(!findCoverageFileForPid(result.pid));
});
test('coverage with included and excluded files', skipIfNoInspector, () => {
const fixture = fixtures.path('test-runner', 'coverage.js');
const args = [
'--experimental-test-coverage', '--test-reporter', 'tap',
'--test-coverage-include=test/fixtures/test-runner/*.js',
'--test-coverage-exclude=test/fixtures/test-runner/*-tap.js',
fixture,
];
const result = spawnSync(process.execPath, args);
const report = [
'# start of coverage report',
'# -----------------------------------------------------------------------------------------',
'# file | line % | branch % | funcs % | uncovered lines',
'# -----------------------------------------------------------------------------------------',
'# test | | | | ',
'# fixtures | | | | ',
'# test-runner | | | | ',
'# coverage.js | 78.65 | 38.46 | 60.00 | 12-13 16-22 27 39 43-44 61-62 66-67 71-72',
'# -----------------------------------------------------------------------------------------',
'# all files | 78.65 | 38.46 | 60.00 | ',
'# -----------------------------------------------------------------------------------------',
'# end of coverage report',
].join('\n');
if (common.isWindows) {
return report.replaceAll('/', '\\');
}
assertIncludesReport(result, report);
assert.strictEqual(result.status, 0);
assert(!findCoverageFileForPid(result.pid));
});
test('coverage does not include untested files by default', skipIfNoInspector, () => {
const fixtureDir = fixtures.path('test-runner', 'coverage-include-all');
const fixture = fixtures.path('test-runner', 'coverage-include-all', 'index.test.js');
const args = [
'--test', '--experimental-test-coverage', '--test-coverage-exclude=**/*.test.js',
'--test-reporter', 'tap', fixture,
];
const result = spawnSync(process.execPath, args, { cwd: fixtureDir });
const report = [
'# start of coverage report',
'# -----------------------------------------------------------',
'# file | line % | branch % | funcs % | uncovered lines',
'# -----------------------------------------------------------',
'# covered.js | 100.00 | 100.00 | 100.00 | ',
'# -----------------------------------------------------------',
'# all files | 100.00 | 100.00 | 100.00 | ',
'# -----------------------------------------------------------',
'# end of coverage report',
].join('\n');
assertIncludesReport(result, report);
assert.strictEqual(result.status, 0);
assert(!findCoverageFileForPid(result.pid));
});
test('coverage includes untested files', skipIfNoInspector, () => {
const fixtureDir = fixtures.path('test-runner', 'coverage-include-all');
const fixture = fixtures.path('test-runner', 'coverage-include-all', 'index.test.js');
const args = [
'--test', '--experimental-test-coverage', '--test-coverage-include-all',
'--test-coverage-exclude=**/*.test.js', '--test-reporter', 'tap', fixture,
];
const result = spawnSync(process.execPath, args, { cwd: fixtureDir });
const report = [
'# start of coverage report',
'# -------------------------------------------------------------',
'# file | line % | branch % | funcs % | uncovered lines',
'# -------------------------------------------------------------',
'# covered.js | 100.00 | 100.00 | 100.00 | ',
'# nested | | | | ',
'# deep.js | 0.00 | 100.00 | 100.00 | 1-5',
'# untested.js | 0.00 | 100.00 | 100.00 | 1-5',
'# -------------------------------------------------------------',
'# all files | 33.33 | 100.00 | 100.00 | ',
'# -------------------------------------------------------------',
'# end of coverage report',
].join('\n');
assertIncludesReport(result, report);
assert.strictEqual(result.status, 0);
assert(!findCoverageFileForPid(result.pid));
});
test('untested files honor coverage include and exclude globs', skipIfNoInspector, () => {
const fixtureDir = fixtures.path('test-runner', 'coverage-include-all');
const fixture = fixtures.path('test-runner', 'coverage-include-all', 'index.test.js');
const args = [
'--test', '--experimental-test-coverage', '--test-coverage-include-all',
'--test-coverage-exclude=nested/**', '--test-reporter', 'tap', fixture,
];
const result = spawnSync(process.execPath, args, { cwd: fixtureDir });
const stdout = result.stdout.toString();
assert.strictEqual(result.status, 0);
assert.match(stdout, /^# untested\.js/m);
assert.doesNotMatch(stdout, /deep\.js/);
assert(!findCoverageFileForPid(result.pid));
});
test('correctly prints the coverage report of files contained in parent directories', skipIfNoInspector, () => {
let report = [
'# start of coverage report',
'# --------------------------------------------------------------------------------------------',
'# file | line % | branch % | funcs % | uncovered lines',
'# --------------------------------------------------------------------------------------------',
'# .. | | | | ',
'# coverage.js | 78.65 | 38.46 | 60.00 | 12-13 16-22 27 39 43-44 61-62 66-67 71-72',
'# invalid-tap.js | 100.00 | 100.00 | 100.00 | ',
'# .. | | | | ',
'# v8-coverage | | | | ',
'# throw.js | 71.43 | 50.00 | 100.00 | 5-6',
'# --------------------------------------------------------------------------------------------',
'# all files | 78.35 | 43.75 | 60.00 | ',
'# --------------------------------------------------------------------------------------------',
'# end of coverage report',
].join('\n');
if (common.isWindows) {
report = report.replaceAll('/', '\\');
}
const fixture = fixtures.path('test-runner', 'coverage.js');
const args = [
'--test',
'--experimental-test-coverage',
'--test-coverage-exclude=!test/**',
'--test-reporter',
'tap',
fixture,
];
const result = spawnSync(process.execPath, args, {
env: { ...process.env, NODE_TEST_TMPDIR: tmpdir.path },
cwd: fixtures.path('test-runner', 'coverage'),
});
assert.strictEqual(result.stderr.toString(), '');
assertIncludesReport(result, report);
assert.strictEqual(result.status, 0);
});
// Regression test for https://github.com/nodejs/node/issues/61080
test('coverage with directory and file named "file"', skipIfNoInspector, () => {
const fixture = fixtures.path('test-runner', 'coverage-file-name', 'test.js');
const args = [
'--experimental-test-coverage',
'--test-reporter',
'tap',
fixture,
];
const result = spawnSync(process.execPath, args);
assert.strictEqual(result.stderr.toString(), '');
assert.strictEqual(result.status, 0);
assertIncludesReport(result, 'start of coverage report');
});