current commit
261030.1 · test: use registerHooks() over module.register()
'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 | ',
    '# 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',
    './hooks.mjs',
    '--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 | ',
    '# 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', './hooks.mjs',
    '--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');
});