Skip to content

Commit

Permalink
test_runner: add junit reporter
Browse files Browse the repository at this point in the history
PR-URL: #49614
Reviewed-By: Chemi Atlow <chemi@atlow.co.il>
Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com>
Reviewed-By: Colin Ihrig <cjihrig@gmail.com>
  • Loading branch information
MoLow authored and targos committed Nov 27, 2023
1 parent 76a3563 commit 059b194
Show file tree
Hide file tree
Showing 7 changed files with 685 additions and 2 deletions.
7 changes: 5 additions & 2 deletions doc/api/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -643,17 +643,20 @@ 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.

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
Expand Down
158 changes: 158 additions & 0 deletions lib/internal/test_runner/reporter/junit.js
Original file line number Diff line number Diff line change
@@ -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, ''), '&quot;'));
}

function escapeContent(s = '') {
return RegExpPrototypeSymbolReplace(/</g, RegExpPrototypeSymbolReplace(/&/g, s, '&amp;'), '&lt;');
}

function escapeComment(s = '') {
return RegExpPrototypeSymbolReplace(/--/g, s, '&#45;&#45;');
}

function treeToXML(tree) {
if (typeof tree === 'string') {
return `${escapeContent(tree)}\n`;
}
const {
tag, attrs, nesting, children, comment,
} = tree;
const indent = StringPrototypeRepeat('\t', nesting + 1);
if (comment) {
return `${indent}<!-- ${escapeComment(comment)} -->\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}</${tag}>\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 '<?xml version="1.0" encoding="utf-8"?>\n';
yield '<testsuites>\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 '</testsuites>\n';
};
1 change: 1 addition & 0 deletions lib/internal/test_runner/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
10 changes: 10 additions & 0 deletions lib/test/reporters.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
const { ObjectDefineProperties, ReflectConstruct } = primordials;

let dot;
let junit;
let spec;
let tap;

Expand All @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions test/fixtures/test-runner/output/junit_reporter.js
Original file line number Diff line number Diff line change
@@ -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' });

0 comments on commit 059b194

Please sign in to comment.