diff --git a/lib/internal/test_runner/reporter/spec.js b/lib/internal/test_runner/reporter/spec.js index 3637c74111c4b7..afa8568d308ea4 100644 --- a/lib/internal/test_runner/reporter/spec.js +++ b/lib/internal/test_runner/reporter/spec.js @@ -6,6 +6,7 @@ const { ArrayPrototypeShift, ArrayPrototypeUnshift, hardenRegExp, + NumberPrototypeToFixed, RegExpPrototypeSymbolSplit, SafeMap, StringPrototypeRepeat, @@ -14,6 +15,7 @@ const assert = require('assert'); const Transform = require('internal/streams/transform'); const { inspectWithNoCustomRetry } = require('internal/errors'); const { green, blue, red, white, gray } = require('internal/util/colors'); +const { relative } = require('path'); const inspectOptions = { __proto__: null, colors: true, breakLength: Infinity }; @@ -29,6 +31,7 @@ const symbols = { 'test:fail': '\u2716 ', 'test:pass': '\u2714 ', 'test:diagnostic': '\u2139 ', + 'test:coverage': '\u2139 ', 'arrow:right': '\u25B6 ', 'hyphen:minus': '\uFE63 ', }; @@ -60,6 +63,39 @@ class SpecReporter extends Transform { ), `\n${indent} `); return `\n${indent} ${message}\n`; } + #coverageThresholdColor(coverage, color = blue) { + coverage = NumberPrototypeToFixed(coverage, 2); + if (coverage > 90) return `${green}${coverage}${color}`; + if (coverage < 50) return `${red}${coverage}${color}`; + return coverage; + } + #reportCoverage(nesting, symbol, color, summary) { + const indent = this.#indent(nesting); + let report = `${color}${indent}${symbol}========= coverage report =========\n`; + report += `${indent}${symbol}file | line % | branch % | funcs % | uncovered lines\n`; + for (let i = 0; i < summary.files.length; ++i) { + const { + path, + coveredLinePercent, + coveredBranchPercent, + coveredFunctionPercent, + uncoveredLineNumbers, + } = summary.files[i]; + const relativePath = relative(summary.workingDirectory, path); + const uncovered = ArrayPrototypeJoin(uncoveredLineNumbers, ', '); + report += `${indent}${symbol}${relativePath} | ${this.#coverageThresholdColor(coveredLinePercent)} | ${this.#coverageThresholdColor(coveredBranchPercent)} | ` + + `${this.#coverageThresholdColor(coveredFunctionPercent)} | ${gray}${uncovered}${color}\n`; + } + const { + coveredLinePercent, + coveredBranchPercent, + coveredFunctionPercent, + } = summary.totals; + report += `${indent}${symbol}all files | ${this.#coverageThresholdColor(coveredLinePercent)} | ${this.#coverageThresholdColor(coveredBranchPercent)} | ` + + `${this.#coverageThresholdColor(coveredFunctionPercent)} |\n`; + report += `${symbol}${indent}==================================== ${white}` + return report; + } #handleEvent({ type, data }) { let color = colors[type] ?? white; let symbol = symbols[type] ?? ' '; @@ -103,6 +139,8 @@ class SpecReporter extends Transform { break; case 'test:diagnostic': return `${color}${this.#indent(data.nesting)}${symbol}${data.message}${white}\n`; + case 'test:coverage': + return this.#reportCoverage(data.nesting, symbols['test:coverage'], blue, data.summary); } } _transform({ type, data }, encoding, callback) { diff --git a/test/parallel/test-runner-spec-coverage.js b/test/parallel/test-runner-spec-coverage.js new file mode 100644 index 00000000000000..3e9c04def90cb1 --- /dev/null +++ b/test/parallel/test-runner-spec-coverage.js @@ -0,0 +1,70 @@ +'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'); + +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 getCoverageFixtureReport() { + const report = [ + '\u2139 ========= coverage report =========', + '\u2139 file | line % | branch % | funcs % | uncovered lines', + '\u2139 test/fixtures/test-runner/coverage.js | 78.65 | 38.46 | 60.00 | 12, 13, 16, 17, 18, 19, 20, 21, 22, 27, 39, 43, 44, 61, 62, 66, 67, 71, 72', + '\u2139 test/fixtures/test-runner/invalid-tap.js | 100.00 | 100.00 | 100.00 | ', + '\u2139 test/fixtures/v8-coverage/throw.js | 71.43 | 50.00 | 100.00 | 5, 6', + '\u2139 all files | 78.35 | 43.75 | 60.00 |', + '\u2139 ====================================' + ].join('\n'); + + if (common.isWindows) { + return report.replaceAll('/', '\\'); + } + + return report; +} + +test('coverage is reported and dumped to NODE_V8_COVERAGE if present', (t) => { + if (!process.features.inspector) { + return; + } + + const fixture = fixtures.path('test-runner', 'coverage.js'); + const args = ['--experimental-test-coverage', '--test-reporter', 'spec', fixture]; + const options = { env: { ...process.env, NODE_V8_COVERAGE: tmpdir.path } }; + const result = spawnSync(process.execPath, args, options); + const report = getCoverageFixtureReport(); + + assert(result.stdout.toString().includes(report)); + assert.strictEqual(result.stderr.toString(), ''); + assert.strictEqual(result.status, 0); + assert(findCoverageFileForPid(result.pid)); +}); + +test('coverage is reported without NODE_V8_COVERAGE present', (t) => { + if (!process.features.inspector) { + return; + } + + const fixture = fixtures.path('test-runner', 'coverage.js'); + const args = ['--experimental-test-coverage', '--test-reporter', 'spec', fixture]; + const result = spawnSync(process.execPath, args); + const report = getCoverageFixtureReport(); + + assert(result.stdout.toString().includes(report)); + assert.strictEqual(result.stderr.toString(), ''); + assert.strictEqual(result.status, 0); + assert(!findCoverageFileForPid(result.pid)); +});