diff --git a/lib/internal/test_runner/reporter/junit.js b/lib/internal/test_runner/reporter/junit.js new file mode 100644 index 00000000000000..c531783ca4746e --- /dev/null +++ b/lib/internal/test_runner/reporter/junit.js @@ -0,0 +1,146 @@ +'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 escapeProperty(s = '') { + return escapeContent(RegExpPrototypeSymbolReplace(/"/g, RegExpPrototypeSymbolReplace(/\n/g, s, ''), '\\"')); +} + +function escapeContent(s = '') { + return RegExpPrototypeSymbolReplace(//g, s, '>'), '<'); +} + +function treeToXML(tree) { + if (typeof tree === 'string') { + return `${escapeContent(tree)}\n`; + } + const { + tag, props, nesting, children, + } = tree; + const propsString = ArrayPrototypeJoin(ArrayPrototypeMap(ObjectEntries(props) + , ({ 0: key, 1: value }) => `${key}="${escapeProperty(String(value))}"`) + , ' '); + const indent = StringPrototypeRepeat('\t', nesting + 1); + if (!children?.length) { + return `${indent}<${tag} ${propsString}/>\n`; + } + const childrenString = ArrayPrototypeJoin(ArrayPrototypeMap(children ?? [], treeToXML), ''); + return `${indent}<${tag} ${propsString}>\n${childrenString}${indent}\n`; +} + +function isFailure(node) { + return (node?.children && ArrayPrototypeSome(node.children, (c) => c.tag === 'failure')) || node?.props?.failures; +} + +function isSkipped(node) { + return (node?.children && ArrayPrototypeSome(node.children, (c) => c.tag === 'skipped')) || node?.props?.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, + props: { __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.props.name !== event.data.name || + currentSuite.nesting !== event.data.nesting) { + startTest(event); + } + const currentTest = currentSuite; + if (currentSuite?.nesting === event.data.nesting) { + currentSuite = currentSuite.parent; + } + currentTest.props.time = NumberPrototypeToFixed(event.data.details.duration_ms / 1000, 6); + if (currentTest.children.length > 0) { + currentTest.tag = 'testsuite'; + currentTest.props.disabled = 0; + currentTest.props.errors = 0; + currentTest.props.tests = currentTest.children.length; + currentTest.props.failures = ArrayPrototypeFilter(currentTest.children, isFailure).length; + currentTest.props.skipped = ArrayPrototypeFilter(currentTest.children, isSkipped).length; + currentTest.props.hostname = HOSTNAME; + } else { + currentTest.tag = 'testcase'; + currentTest.props.classname = event.data.classname ?? 'test'; + if (event.data.skip) { + ArrayPrototypePush(currentTest.children, { + __proto__: null, nesting: event.data.nesting + 1, tag: 'skipped', + props: { __proto__: null, type: 'skipped', message: event.data.skip }, + }); + } + if (event.data.todo) { + ArrayPrototypePush(currentTest.children, { + __proto__: null, nesting: event.data.nesting + 1, tag: 'skipped', + props: { __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', + props: { __proto__: null, type: error?.failureType || error?.code, message: error?.message ?? '' }, + children: [inspectWithNoCustomRetry(error, inspectOptions)], + }); + currentTest.failures = 1; + currentTest.props.failure = error?.message ?? ''; + } + } + break; + } + case 'test:diagnostic': + 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 7923732f04dfcb..971534af3e8e8b 100644 --- a/lib/internal/test_runner/utils.js +++ b/lib/internal/test_runner/utils.js @@ -110,6 +110,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..e85fdee0d1a342 --- /dev/null +++ b/test/fixtures/test-runner/output/junit_reporter.snapshot @@ -0,0 +1,468 @@ + + + + + + + + + + + +[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..115ca3d76bb7c3 100644 --- a/test/parallel/test-runner-output.mjs +++ b/test/parallel/test-runner-output.mjs @@ -25,6 +25,13 @@ function replaceSpecDuration(str) { .replace(stackTraceBasePath, '$3'); } +function replaceJunitDuration(str) { + return str + .replaceAll(/time="0"/g, 'time="ZERO"') + .replaceAll(/time="[0-9.]+"/g, 'time="*"') + .replace(stackTraceBasePath, '$3'); +} + function removeWindowsPathEscaping(str) { return common.isWindows ? str.replaceAll(/\\\\/g, '\\') : str; } @@ -47,6 +54,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 +76,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 },