diff --git a/doc/api/test.md b/doc/api/test.md index 4ba66d408131b8..117dd9bce29d87 100644 --- a/doc/api/test.md +++ b/doc/api/test.md @@ -653,6 +653,9 @@ The following built-reporters are supported: where each passing test is represented by a `.`, and each failing test is represented by a `X`. +* `junit` + The junit reporter outputs test results in a jUnit XML format + When `stdout` is a [TTY][], the `spec` reporter is used by default. Otherwise, the `tap` reporter is used by default. @@ -664,11 +667,11 @@ to the test runner's output is required, use the events emitted by the The reporters are available via the `node:test/reporters` module: ```mjs -import { tap, spec, dot } from 'node:test/reporters'; +import { tap, spec, dot, junit } from 'node:test/reporters'; ``` ```cjs -const { tap, spec, dot } = require('node:test/reporters'); +const { tap, spec, dot, junit } = require('node:test/reporters'); ``` ### Custom reporters diff --git a/lib/internal/test_runner/reporter/junit.js b/lib/internal/test_runner/reporter/junit.js new file mode 100644 index 00000000000000..b45c233861c000 --- /dev/null +++ b/lib/internal/test_runner/reporter/junit.js @@ -0,0 +1,158 @@ +'use strict'; +const { + ArrayPrototypeFilter, + ArrayPrototypeMap, + ArrayPrototypeJoin, + ArrayPrototypePush, + ArrayPrototypeSome, + NumberPrototypeToFixed, + ObjectEntries, + RegExpPrototypeSymbolReplace, + String, + StringPrototypeRepeat, +} = primordials; + +const { inspectWithNoCustomRetry } = require('internal/errors'); +const { hostname } = require('os'); + +const inspectOptions = { __proto__: null, colors: false, breakLength: Infinity }; +const HOSTNAME = hostname(); + +function escapeAttribute(s = '') { + return escapeContent(RegExpPrototypeSymbolReplace(/"/g, RegExpPrototypeSymbolReplace(/\n/g, s, ''), '"')); +} + +function escapeContent(s = '') { + return RegExpPrototypeSymbolReplace(/\n`; + } + const attrsString = ArrayPrototypeJoin(ArrayPrototypeMap(ObjectEntries(attrs) + , ({ 0: key, 1: value }) => `${key}="${escapeAttribute(String(value))}"`) + , ' '); + if (!children?.length) { + return `${indent}<${tag} ${attrsString}/>\n`; + } + const childrenString = ArrayPrototypeJoin(ArrayPrototypeMap(children ?? [], treeToXML), ''); + return `${indent}<${tag} ${attrsString}>\n${childrenString}${indent}\n`; +} + +function isFailure(node) { + return (node?.children && ArrayPrototypeSome(node.children, (c) => c.tag === 'failure')) || node?.attrs?.failures; +} + +function isSkipped(node) { + return (node?.children && ArrayPrototypeSome(node.children, (c) => c.tag === 'skipped')) || node?.attrs?.failures; +} + +module.exports = async function* junitReporter(source) { + yield '\n'; + yield '\n'; + let currentSuite = null; + const roots = []; + + function startTest(event) { + const originalSuite = currentSuite; + currentSuite = { + __proto__: null, + attrs: { __proto__: null, name: event.data.name }, + nesting: event.data.nesting, + parent: currentSuite, + children: [], + }; + if (originalSuite?.children) { + ArrayPrototypePush(originalSuite.children, currentSuite); + } + if (!currentSuite.parent) { + ArrayPrototypePush(roots, currentSuite); + } + } + + for await (const event of source) { + switch (event.type) { + case 'test:start': { + startTest(event); + break; + } + case 'test:pass': + case 'test:fail': { + if (!currentSuite) { + startTest({ __proto__: null, data: { __proto__: null, name: 'root', nesting: 0 } }); + } + if (currentSuite.attrs.name !== event.data.name || + currentSuite.nesting !== event.data.nesting) { + startTest(event); + } + const currentTest = currentSuite; + if (currentSuite?.nesting === event.data.nesting) { + currentSuite = currentSuite.parent; + } + currentTest.attrs.time = NumberPrototypeToFixed(event.data.details.duration_ms / 1000, 6); + const nonCommentChildren = ArrayPrototypeFilter(currentTest.children, (c) => c.comment == null); + if (nonCommentChildren.length > 0) { + currentTest.tag = 'testsuite'; + currentTest.attrs.disabled = 0; + currentTest.attrs.errors = 0; + currentTest.attrs.tests = nonCommentChildren.length; + currentTest.attrs.failures = ArrayPrototypeFilter(currentTest.children, isFailure).length; + currentTest.attrs.skipped = ArrayPrototypeFilter(currentTest.children, isSkipped).length; + currentTest.attrs.hostname = HOSTNAME; + } else { + currentTest.tag = 'testcase'; + currentTest.attrs.classname = event.data.classname ?? 'test'; + if (event.data.skip) { + ArrayPrototypePush(currentTest.children, { + __proto__: null, nesting: event.data.nesting + 1, tag: 'skipped', + attrs: { __proto__: null, type: 'skipped', message: event.data.skip }, + }); + } + if (event.data.todo) { + ArrayPrototypePush(currentTest.children, { + __proto__: null, nesting: event.data.nesting + 1, tag: 'skipped', + attrs: { __proto__: null, type: 'todo', message: event.data.todo }, + }); + } + if (event.type === 'test:fail') { + const error = event.data.details?.error; + currentTest.children.push({ + __proto__: null, + nesting: event.data.nesting + 1, + tag: 'failure', + attrs: { __proto__: null, type: error?.failureType || error?.code, message: error?.message ?? '' }, + children: [inspectWithNoCustomRetry(error, inspectOptions)], + }); + currentTest.failures = 1; + currentTest.attrs.failure = error?.message ?? ''; + } + } + break; + } + case 'test:diagnostic': { + const parent = currentSuite?.children ?? roots; + ArrayPrototypePush(parent, { + __proto__: null, nesting: event.data.nesting, comment: event.data.message, + }); + break; + } default: + break; + } + } + for (const suite of roots) { + yield treeToXML(suite); + } + yield '\n'; +}; diff --git a/lib/internal/test_runner/utils.js b/lib/internal/test_runner/utils.js index d2cabbac9a2c66..ba1b4f0fa10869 100644 --- a/lib/internal/test_runner/utils.js +++ b/lib/internal/test_runner/utils.js @@ -116,6 +116,7 @@ const kBuiltinReporters = new SafeMap([ ['spec', 'internal/test_runner/reporter/spec'], ['dot', 'internal/test_runner/reporter/dot'], ['tap', 'internal/test_runner/reporter/tap'], + ['junit', 'internal/test_runner/reporter/junit'], ]); const kDefaultReporter = process.stdout.isTTY ? 'spec' : 'tap'; diff --git a/lib/test/reporters.js b/lib/test/reporters.js index 86aea679b52a7a..06a0b27ee58275 100644 --- a/lib/test/reporters.js +++ b/lib/test/reporters.js @@ -3,6 +3,7 @@ const { ObjectDefineProperties, ReflectConstruct } = primordials; let dot; +let junit; let spec; let tap; @@ -17,6 +18,15 @@ ObjectDefineProperties(module.exports, { return dot; }, }, + junit: { + __proto__: null, + configurable: true, + enumerable: true, + get() { + junit ??= require('internal/test_runner/reporter/junit'); + return junit; + }, + }, spec: { __proto__: null, configurable: true, diff --git a/test/fixtures/test-runner/output/junit_reporter.js b/test/fixtures/test-runner/output/junit_reporter.js new file mode 100644 index 00000000000000..1f49b3f6042d97 --- /dev/null +++ b/test/fixtures/test-runner/output/junit_reporter.js @@ -0,0 +1,7 @@ +'use strict'; +require('../../../common'); +const fixtures = require('../../../common/fixtures'); +const spawn = require('node:child_process').spawn; + +spawn(process.execPath, + ['--no-warnings', '--test-reporter', 'junit', fixtures.path('test-runner/output/output.js')], { stdio: 'inherit' }); diff --git a/test/fixtures/test-runner/output/junit_reporter.snapshot b/test/fixtures/test-runner/output/junit_reporter.snapshot new file mode 100644 index 00000000000000..6516387e7ed582 --- /dev/null +++ b/test/fixtures/test-runner/output/junit_reporter.snapshot @@ -0,0 +1,488 @@ + + + + + + + + + + + +[Error [ERR_TEST_FAILURE]: thrown from sync fail todo] { + failureType: 'testCodeFailure', + cause: Error: thrown from sync fail todo + * + * + * + * + * + * + at async Test.processPendingSubtests (node:internal/test_runner/test:374:7), + code: 'ERR_TEST_FAILURE' +} + + + + + +[Error [ERR_TEST_FAILURE]: thrown from sync fail todo with message] { + failureType: 'testCodeFailure', + cause: Error: thrown from sync fail todo with message + * + * + * + * + * + * + at async Test.processPendingSubtests (node:internal/test_runner/test:374:7), + code: 'ERR_TEST_FAILURE' +} + + + + + + + + + + + + +[Error [ERR_TEST_FAILURE]: thrown from sync throw fail] { + failureType: 'testCodeFailure', + cause: Error: thrown from sync throw fail + * + * + * + * + * + * + at async Test.processPendingSubtests (node:internal/test_runner/test:374:7), + code: 'ERR_TEST_FAILURE' +} + + + + + + + + +[Error [ERR_TEST_FAILURE]: thrown from async throw fail] { + failureType: 'testCodeFailure', + cause: Error: thrown from async throw fail + * + * + * + * + * + * + at async Test.processPendingSubtests (node:internal/test_runner/test:374:7), + code: 'ERR_TEST_FAILURE' +} + + + + + +[Error [ERR_TEST_FAILURE]: thrown from async throw fail] { + failureType: 'testCodeFailure', + cause: Error: thrown from async throw fail + * + * + * + * + * + * + at async Test.processPendingSubtests (node:internal/test_runner/test:374:7), + code: 'ERR_TEST_FAILURE' +} + + + + +[Error [ERR_TEST_FAILURE]: Expected values to be strictly equal: + +true !== false +] { + failureType: 'testCodeFailure', + cause: AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: + + true !== false + + * + * + * + * + * + * + * { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: true, + expected: false, + operator: 'strictEqual' + }, + code: 'ERR_TEST_FAILURE' +} + + + + + +[Error [ERR_TEST_FAILURE]: rejected from reject fail] { + failureType: 'testCodeFailure', + cause: Error: rejected from reject fail + * + * + * + * + * + * + at async Test.processPendingSubtests (node:internal/test_runner/test:374:7), + code: 'ERR_TEST_FAILURE' +} + + + + + + + + + + +Error [ERR_TEST_FAILURE]: thrown from subtest sync throw fail + * { + failureType: 'testCodeFailure', + cause: Error: thrown from subtest sync throw fail + * + * + * + * + * + * + * + * + * + at Test.postRun (node:internal/test_runner/test:715:19), + code: 'ERR_TEST_FAILURE' +} + + + + + + +[Error [ERR_TEST_FAILURE]: Symbol(thrown symbol from sync throw non-error fail)] { failureType: 'testCodeFailure', cause: Symbol(thrown symbol from sync throw non-error fail), code: 'ERR_TEST_FAILURE' } + + + + + + + + + + + +[Error [ERR_TEST_FAILURE]: test did not finish before its parent and was cancelled] { failureType: 'cancelledByParent', cause: 'test did not finish before its parent and was cancelled', code: 'ERR_TEST_FAILURE' } + + + + + + + + + + + + + + + +[Error [ERR_TEST_FAILURE]: this should be executed] { + failureType: 'testCodeFailure', + cause: Error: this should be executed + * + * + * + * + * + * + at async Test.processPendingSubtests (node:internal/test_runner/test:374:7), + code: 'ERR_TEST_FAILURE' +} + + + + + + + + + + + + + + + + + + + +[Error [ERR_TEST_FAILURE]: callback failure] { + failureType: 'testCodeFailure', + cause: Error: callback failure + * + at process.processImmediate (node:internal/timers:478:21), + code: 'ERR_TEST_FAILURE' +} + + + + + + + +[Error [ERR_TEST_FAILURE]: passed a callback but also returned a Promise] { failureType: 'callbackAndPromisePresent', cause: 'passed a callback but also returned a Promise', code: 'ERR_TEST_FAILURE' } + + + + +[Error [ERR_TEST_FAILURE]: thrown from callback throw] { + failureType: 'testCodeFailure', + cause: Error: thrown from callback throw + * + * + * + * + * + * + at async Test.processPendingSubtests (node:internal/test_runner/test:374:7), + code: 'ERR_TEST_FAILURE' +} + + + + +Error [ERR_TEST_FAILURE]: callback invoked multiple times + * + * { + failureType: 'multipleCallbackInvocations', + cause: 'callback invoked multiple times', + code: 'ERR_TEST_FAILURE' +} + + + + + +Error [ERR_TEST_FAILURE]: callback invoked multiple times + * { + failureType: 'uncaughtException', + cause: Error [ERR_TEST_FAILURE]: callback invoked multiple times + * { + failureType: 'multipleCallbackInvocations', + cause: 'callback invoked multiple times', + code: 'ERR_TEST_FAILURE' + }, + code: 'ERR_TEST_FAILURE' +} + + + + +Error [ERR_TEST_FAILURE]: thrown from callback async throw + * { + failureType: 'uncaughtException', + cause: Error: thrown from callback async throw + * + at process.processImmediate (node:internal/timers:478:21), + code: 'ERR_TEST_FAILURE' +} + + + + + + + + + + + + + + +[Error [ERR_TEST_FAILURE]: customized] { failureType: 'testCodeFailure', cause: customized, code: 'ERR_TEST_FAILURE' } + + + + +[Error [ERR_TEST_FAILURE]: { + foo: 1, + [Symbol(nodejs.util.inspect.custom)]: [Function: [nodejs.util.inspect.custom]] +}] { + failureType: 'testCodeFailure', + cause: { foo: 1, [Symbol(nodejs.util.inspect.custom)]: [Function: [nodejs.util.inspect.custom]] }, + code: 'ERR_TEST_FAILURE' +} + + + + + +Error [ERR_TEST_FAILURE]: thrown from subtest sync throw fails at first + * { + failureType: 'testCodeFailure', + cause: Error: thrown from subtest sync throw fails at first + * + * + * + * + * + * + * + * + * + at Test.postRun (node:internal/test_runner/test:715:19), + code: 'ERR_TEST_FAILURE' +} + + + + +Error [ERR_TEST_FAILURE]: thrown from subtest sync throw fails at second + * { + failureType: 'testCodeFailure', + cause: Error: thrown from subtest sync throw fails at second + * + * + * + * + * + * + * + * + * + at async Test.run (node:internal/test_runner/test:632:9), + code: 'ERR_TEST_FAILURE' +} + + + + + +[Error [ERR_TEST_FAILURE]: test timed out after 5ms] { failureType: 'testTimeoutFailure', cause: 'test timed out after 5ms', code: 'ERR_TEST_FAILURE' } + + + + +[Error [ERR_TEST_FAILURE]: test timed out after 5ms] { failureType: 'testTimeoutFailure', cause: 'test timed out after 5ms', code: 'ERR_TEST_FAILURE' } + + + + + + + +[Error [ERR_TEST_FAILURE]: custom error] { failureType: 'testCodeFailure', cause: 'custom error', code: 'ERR_TEST_FAILURE' } + + + + +Error [ERR_TEST_FAILURE]: foo + * { + failureType: 'uncaughtException', + cause: Error: foo + * + * + at process.processTimers (node:internal/timers:514:7), + code: 'ERR_TEST_FAILURE' +} + + + + +Error [ERR_TEST_FAILURE]: bar + * { + failureType: 'unhandledRejection', + cause: Error: bar + * + * + at process.processTimers (node:internal/timers:514:7), + code: 'ERR_TEST_FAILURE' +} + + + + +[Error [ERR_TEST_FAILURE]: Expected values to be loosely deep-equal: + +{ + bar: 1, + foo: 1 +} + +should loosely deep-equal + +<ref *1> { + bar: 2, + c: [Circular *1] +}] { + failureType: 'testCodeFailure', + cause: AssertionError [ERR_ASSERTION]: Expected values to be loosely deep-equal: + + { + bar: 1, + foo: 1 + } + + should loosely deep-equal + + <ref *1> { + bar: 2, + c: [Circular *1] + } + * { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: [Object], + expected: [Object], + operator: 'deepEqual' + }, + code: 'ERR_TEST_FAILURE' +} + + + + +Error [ERR_TEST_FAILURE]: test could not be started because its parent finished + * { + failureType: 'parentAlreadyFinished', + cause: 'test could not be started because its parent finished', + code: 'ERR_TEST_FAILURE' +} + + + + + + + + + + + + + + + + + + diff --git a/test/parallel/test-runner-output.mjs b/test/parallel/test-runner-output.mjs index a45ac62d5f0eb7..fff6fed92655e9 100644 --- a/test/parallel/test-runner-output.mjs +++ b/test/parallel/test-runner-output.mjs @@ -2,6 +2,7 @@ import * as common from '../common/index.mjs'; import * as fixtures from '../common/fixtures.mjs'; import * as snapshot from '../common/assertSnapshot.js'; import { describe, it } from 'node:test'; +import { hostname } from 'node:os'; const skipForceColors = process.config.variables.icu_gyp_path !== 'tools/icu/icu-generic.gyp' || @@ -25,6 +26,15 @@ function replaceSpecDuration(str) { .replace(stackTraceBasePath, '$3'); } +function replaceJunitDuration(str) { + return str + .replaceAll(/time="0"/g, 'time="ZERO"') + .replaceAll(/time="[0-9.]+"/g, 'time="*"') + .replaceAll(/duration_ms [0-9.]+/g, 'duration_ms *') + .replaceAll(hostname(), 'HOSTNAME') + .replace(stackTraceBasePath, '$3'); +} + function removeWindowsPathEscaping(str) { return common.isWindows ? str.replaceAll(/\\\\/g, '\\') : str; } @@ -47,6 +57,11 @@ const specTransform = snapshot.transform( snapshot.replaceWindowsLineEndings, snapshot.replaceStackTrace, ); +const junitTransform = snapshot.transform( + replaceJunitDuration, + snapshot.replaceWindowsLineEndings, + snapshot.replaceStackTrace, +); const tests = [ { name: 'test-runner/output/abort.js' }, @@ -64,6 +79,7 @@ const tests = [ { name: 'test-runner/output/no_tests.js' }, { name: 'test-runner/output/only_tests.js' }, { name: 'test-runner/output/dot_reporter.js' }, + { name: 'test-runner/output/junit_reporter.js', transform: junitTransform }, { name: 'test-runner/output/spec_reporter_successful.js', transform: specTransform }, { name: 'test-runner/output/spec_reporter.js', transform: specTransform }, { name: 'test-runner/output/spec_reporter_cli.js', transform: specTransform },