diff --git a/lib/internal/main/test_runner.js b/lib/internal/main/test_runner.js index 3eae628b22597a..45e018a4f880b5 100644 --- a/lib/internal/main/test_runner.js +++ b/lib/internal/main/test_runner.js @@ -22,9 +22,8 @@ if (isUsingInspector()) { inspectPort = process.debugPort; } -const tapStream = run({ concurrency, inspectPort, watch: getOptionValue('--watch') }); -const reporters = setupTestReporters(tapStream); -reporters.pipe(process.stdout); -tapStream.once('test:fail', () => { +const reporterStream = run({ concurrency, inspectPort, watch: getOptionValue('--watch') }); +reporterStream.once('test:fail', () => { process.exitCode = kGenericUserError; }); +setupTestReporters(reporterStream); diff --git a/lib/internal/test_runner/harness.js b/lib/internal/test_runner/harness.js index 18360af1f8e0c1..d2b4e61150943a 100644 --- a/lib/internal/test_runner/harness.js +++ b/lib/internal/test_runner/harness.js @@ -110,7 +110,7 @@ function setup(root) { } root.startTime = hrtime(); - root.reporter.version(); + root.reporter.start(); wasRootSetup.add(root); return root; @@ -120,11 +120,10 @@ let globalRoot; function getGlobalRoot() { if (!globalRoot) { globalRoot = createTestTree(); - const reporter = setupTestReporters(globalRoot.reporter); - reporter.pipe(process.stdout); - reporter.once('test:fail', () => { + globalRoot.reporter.once('test:fail', () => { process.exitCode = kGenericUserError; }); + setupTestReporters(globalRoot.reporter); } return globalRoot; } diff --git a/lib/internal/test_runner/reporter_stream.js b/lib/internal/test_runner/reporter_stream.js index 18878808ef7d14..671507e2e0ac90 100644 --- a/lib/internal/test_runner/reporter_stream.js +++ b/lib/internal/test_runner/reporter_stream.js @@ -19,9 +19,9 @@ class ReporterStream extends Readable { this.#canPush = true; while (this.#buffer.length > 0) { - const chunk = ArrayPrototypeShift(this.#buffer); + const obj = ArrayPrototypeShift(this.#buffer); - if (!this.#tryPush(chunk)) { + if (!this.#tryPush(obj)) { return; } } @@ -35,8 +35,8 @@ class ReporterStream extends Readable { this.#emit('test:pass', { __proto__: null, name, nesting, testNumber, details, ...directive }); } - plan() { - // NOT IMPLEMENTED + plan(nesting, count, explanation) { + this.#emit('test:plan', { __proto__: null, nesting, count, explanation }); } getSkip(reason) { @@ -48,15 +48,15 @@ class ReporterStream extends Readable { } subtest(nesting, name) { - this.#emit('test:subtest', { nesting, name }); + this.#emit('test:subtest', { __proto__: null, nesting, name }); } diagnostic(nesting, message) { - this.#emit('test:diagnostic', { nesting, message }); + this.#emit('test:diagnostic', { __proto__: null, nesting, message }); } - version() { - // NOT IMPLEMENTED + start() { + this.#emit('test:start', { __proto__: null }); } #emit(type, data) { diff --git a/lib/internal/test_runner/runner.js b/lib/internal/test_runner/runner.js index 76efbf164c15ce..ebf1c67a6ebbb5 100644 --- a/lib/internal/test_runner/runner.js +++ b/lib/internal/test_runner/runner.js @@ -37,7 +37,6 @@ const { kEmptyObject } = require('internal/util'); const { createTestTree } = require('internal/test_runner/harness'); const { kSubtestsFailed, Test } = require('internal/test_runner/test'); const { TapParser } = require('internal/test_runner/tap_parser'); -const { kDefaultIndent } = require('internal/test_runner/tap_stream'); const { TokenKind } = require('internal/test_runner/tap_lexer'); const { @@ -130,6 +129,8 @@ function getRunArgs({ path, inspectPort }) { return argv; } +const kDefaultIndent = ' '; // 4 spaces + class FileTest extends Test { #buffer = []; #handleReportItem({ kind, node, nesting = 0 }) { diff --git a/lib/internal/test_runner/test.js b/lib/internal/test_runner/test.js index 8479fda46757c4..a68e060a72d589 100644 --- a/lib/internal/test_runner/test.js +++ b/lib/internal/test_runner/test.js @@ -7,7 +7,6 @@ const { ArrayPrototypeSlice, ArrayPrototypeSome, ArrayPrototypeUnshift, - Boolean, FunctionPrototype, MathMax, Number, @@ -34,7 +33,6 @@ const { } = require('internal/errors'); const { getOptionValue } = require('internal/options'); const { MockTracker } = require('internal/test_runner/mock'); -const { TapStream } = require('internal/test_runner/tap_stream'); const { ReporterStream } = require('internal/test_runner/reporter_stream'); const { convertStringToRegExp, @@ -67,7 +65,6 @@ const kHookFailure = 'hookFailed'; const kDefaultTimeout = null; const noop = FunctionPrototype; const isTestRunner = getOptionValue('--test'); -const hasReporters = Boolean(getOptionValue('--test-reporter')); const testOnlyFlag = !isTestRunner && getOptionValue('--test-only'); const testNamePatternFlag = isTestRunner ? null : getOptionValue('--test-name-pattern'); @@ -191,7 +188,7 @@ class Test extends AsyncResource { this.concurrency = 1; this.nesting = 0; this.only = testOnlyFlag; - this.reporter = hasReporters ? new ReporterStream() : new TapStream(); + this.reporter = new ReporterStream(); this.runOnlySubtests = this.only; this.testNumber = 0; this.timeout = kDefaultTimeout; diff --git a/lib/internal/test_runner/utils.js b/lib/internal/test_runner/utils.js index d5b152ac3e8e8e..bb687429532ef1 100644 --- a/lib/internal/test_runner/utils.js +++ b/lib/internal/test_runner/utils.js @@ -1,8 +1,13 @@ 'use strict'; -const { RegExp, RegExpPrototypeExec } = primordials; +const { ObjectGetOwnPropertyDescriptor, RegExp, RegExpPrototypeExec, SafeMap } = primordials; const { basename } = require('path'); +const { createWriteStream } = require('fs'); const { createDeferredPromise } = require('internal/util'); const { getOptionValue } = require('internal/options'); +const specReporter = require('test/reporter/spec'); +const dotReporter = require('test/reporter/dot'); +const tapReporter = require('test/reporter/tap'); + const { codes: { ERR_INVALID_ARG_VALUE, @@ -11,7 +16,6 @@ const { kIsNodeError, } = require('internal/errors'); const { compose } = require('stream'); -const { Module } = require('internal/modules/cjs/loader'); const kMultipleCallbackInvocations = 'multipleCallbackInvocations'; const kRegExpPattern = /^\/(.*)\/([a-z]*)$/; @@ -77,14 +81,56 @@ function convertStringToRegExp(str, name) { } } -let _module; -function setupTestReporters(reporter) { - if (getOptionValue('--test-reporter')) { - _module ??= new Module('node:test'); - const reporters = _module.require(getOptionValue('--test-reporter')); - return compose(reporter, reporters); +const kBuiltinDestinations = new SafeMap([ + ['stdout', process.stdout], + ['stderr', process.stderr], +]); + +const kBuiltinReporters = new SafeMap([ + ['spec', specReporter], + ['dot', dotReporter], + ['tap', tapReporter], +]); + +const kDefaultReporter = 'tap'; +const kDefaltDestination = 'stdout'; + +function getReportersMap(reporters, destinations) { + const result = []; + reporters.forEach((name, i) => { + const destination = kBuiltinDestinations.get(destinations[i]) ?? createWriteStream(destinations[i]); + let reporter = kBuiltinReporters.get(name) ?? require(name); + + if (ObjectGetOwnPropertyDescriptor(reporter.prototype, 'constructor')) { + reporter = new reporter(); + } + + result.push({ reporter, destination }); + }); + return result; +} + + +function setupTestReporters(reporterStream) { + const destinations = getOptionValue('--test-reporter-destination'); + const reporters = getOptionValue('--test-reporter'); + + if (reporters.length === 0 && destinations.length === 0) { + reporters.push(kDefaultReporter); + } + + if (reporters.length === 1 && destinations.length === 0) { + destinations.push(kDefaltDestination); + } + + if (destinations.length !== reporters.length) { + throw new ERR_INVALID_ARG_VALUE('The number of reporters and destinations must match'); + } + + const reportersMap = getReportersMap(reporters, destinations); + for (const { reporter, destination } of reportersMap) { + compose(reporterStream, reporter).pipe(destination); } - return reporter; } module.exports = { diff --git a/lib/test/reporter/spec.js b/lib/test/reporter/spec.js index 3aa51e617dd5a3..526cc9cd101162 100644 --- a/lib/test/reporter/spec.js +++ b/lib/test/reporter/spec.js @@ -65,4 +65,4 @@ class SpecReporter extends Transform { } } -module.exports = new SpecReporter(); +module.exports = SpecReporter; diff --git a/lib/internal/test_runner/tap_stream.js b/lib/test/reporter/tap.js similarity index 59% rename from lib/internal/test_runner/tap_stream.js rename to lib/test/reporter/tap.js index 9d50b4bf27cddb..7250044fff0883 100644 --- a/lib/internal/test_runner/tap_stream.js +++ b/lib/test/reporter/tap.js @@ -4,16 +4,15 @@ const { ArrayPrototypeJoin, ArrayPrototypeMap, ArrayPrototypePush, - ArrayPrototypeShift, ObjectEntries, + RegExpPrototypeSymbolReplace, + SafeMap, StringPrototypeReplaceAll, StringPrototypeToUpperCase, StringPrototypeSplit, StringPrototypeRepeat, - RegExpPrototypeSymbolReplace, } = primordials; const { inspectWithNoCustomRetry } = require('internal/errors'); -const Readable = require('internal/streams/readable'); const { isError, kEmptyObject } = require('internal/util'); const kDefaultIndent = ' '; // 4 spaces const kFrameStartRegExp = /^ {4}at /; @@ -24,119 +23,77 @@ let testModule; // Lazy loaded due to circular dependency. function lazyLoadTest() { testModule ??= require('internal/test_runner/test'); - return testModule; } -class TapStream extends Readable { - #buffer; - #canPush; - - constructor() { - super(); - this.#buffer = []; - this.#canPush = true; - } - _read() { - this.#canPush = true; - - while (this.#buffer.length > 0) { - const line = ArrayPrototypeShift(this.#buffer); - - if (!this.#tryPush(line)) { - return; +async function * tapReporter(source) { + for await (const { type, data } of source) { + switch (type) { + case 'test:fail': + yield reportTest(data.nesting, data.testNumber, 'not ok', data.name, data.directive); + yield reportDetails(data.nesting, data.details); + break; + case 'test:pass': + yield reportTest(data.nesting, data.testNumber, 'ok', data.name, data.directive); + yield reportDetails(data.nesting, data.details); + break; + case 'test:plan': { + const exp = `${data.explanation ? ` # ${tapEscape(data.explanation)}` : ''}`; + yield `${indent(data.nesting)}1..${data.count}${exp}\n`; + break; } + case 'test:subtest': + yield `${indent(data.nesting)}# Subtest: ${tapEscape(data.name)}\n`; + break; + case 'test:diagnostic': + yield `${indent(data.nesting)}# ${tapEscape(data.message)}\n`; + break; + case 'test:initialize': + yield `TAP version ${kDefaultTAPVersion}\n`; + break; } } +} - fail(nesting, testNumber, name, details, directive) { - this.emit('test:fail', { __proto__: null, name, nesting, testNumber, details, ...directive }); - this.#test(nesting, testNumber, 'not ok', name, directive); - this.#details(nesting, details); - } - - ok(nesting, testNumber, name, details, directive) { - this.emit('test:pass', { __proto__: null, name, nesting, testNumber, details, ...directive }); - this.#test(nesting, testNumber, 'ok', name, directive); - this.#details(nesting, details); - } - - plan(nesting, count, explanation) { - const exp = `${explanation ? ` # ${tapEscape(explanation)}` : ''}`; - - this.#tryPush(`${this.#indent(nesting)}1..${count}${exp}\n`); - } - - getSkip(reason) { - return { __proto__: null, skip: reason }; - } - - getTodo(reason) { - return { __proto__: null, todo: reason }; - } - - subtest(nesting, name) { - this.emit('test:subtest', { nesting, name }); - this.#tryPush(`${this.#indent(nesting)}# Subtest: ${tapEscape(name)}\n`); - } - - #details(nesting, data = kEmptyObject) { - const { error, duration, yaml } = data; - const indent = this.#indent(nesting); - let details = `${indent} ---\n`; +function reportTest(nesting, testNumber, status, name, directive = kEmptyObject) { + let line = `${indent(nesting)}${status} ${testNumber}`; - details += `${yaml ? yaml : ''}`; - details += jsToYaml(indent, 'duration_ms', duration); - details += jsToYaml(indent, null, error); - details += `${indent} ...\n`; - this.#tryPush(details); + if (name) { + line += ` ${tapEscape(`- ${name}`)}`; } - diagnostic(nesting, message) { - this.emit('test:diagnostic', { message, nesting }); - this.#tryPush(`${this.#indent(nesting)}# ${tapEscape(message)}\n`); - } + line += ArrayPrototypeJoin(ArrayPrototypeMap(ObjectEntries(directive), ({ 0: key, 1: value }) => ( + ` # ${StringPrototypeToUpperCase(key)}${value ? ` ${tapEscape(value)}` : ''}` + )), ''); - version(spec = kDefaultTAPVersion) { - this.#tryPush(`TAP version ${spec}\n`); - } - - #indent(nesting) { - return StringPrototypeRepeat(kDefaultIndent, nesting); - } + line += '\n'; - #test(nesting, testNumber, status, name, directive = kEmptyObject) { - if (this._readableState.objectMode) { - // early return - return; - } - let line = `${this.#indent(nesting)}${status} ${testNumber}`; - - if (name) { - line += ` ${tapEscape(`- ${name}`)}`; - } - - line += ArrayPrototypeJoin(ArrayPrototypeMap(ObjectEntries(directive), ({ 0: key, 1: value }) => ( - ` # ${StringPrototypeToUpperCase(key)}${value ? ` ${tapEscape(value)}` : ''}` - )), ''); + return line; +} - line += '\n'; - this.#tryPush(line); - } +function reportDetails(nesting, data = kEmptyObject) { + const { error, duration, yaml } = data; + const _indent = indent(nesting); + let details = `${_indent} ---\n`; - #tryPush(message) { - if (this.#canPush) { - this.#canPush = this.push(message); - } else { - ArrayPrototypePush(this.#buffer, message); - } + details += `${yaml ? yaml : ''}`; + details += jsToYaml(_indent, 'duration_ms', duration); + details += jsToYaml(_indent, null, error); + details += `${_indent} ...\n`; + return details; +} - return this.#canPush; +const memo = new SafeMap(); +function indent(nesting) { + if (!memo.has(nesting)) { + memo.set(nesting, StringPrototypeRepeat(kDefaultIndent, nesting)); } + return memo.get(nesting); } + // In certain places, # and \ need to be escaped as \# and \\. function tapEscape(input) { return StringPrototypeReplaceAll( @@ -269,4 +226,4 @@ function isAssertionLike(value) { return value && typeof value === 'object' && 'expected' in value && 'actual' in value; } -module.exports = { TapStream, kDefaultIndent }; +module.exports = tapReporter; diff --git a/src/node_options.cc b/src/node_options.cc index a4d3831986f678..3f4dc3f870aa40 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -551,6 +551,9 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() { AddOption("--test-reporter", "report test output using the given reporter", &EnvironmentOptions::test_reporter); + AddOption("--test-reporter-destination", + "report given reporter to the given destination", + &EnvironmentOptions::test_reporter_destination); AddOption("--test-only", "run tests with 'only' option set", &EnvironmentOptions::test_only, diff --git a/src/node_options.h b/src/node_options.h index 8a7a9adad8edd7..01db91b1db5498 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -155,7 +155,8 @@ class EnvironmentOptions : public Options { std::string diagnostic_dir; bool test_runner = false; std::vector test_name_pattern; - std::string test_reporter; + std::vector test_reporter; + std::vector test_reporter_destination; bool test_only = false; bool test_udp_no_try_send = false; bool throw_deprecation = false;