Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@rushstack/heft-jest-plugin",
"comment": "Add a pure JUnit serializer for validated Jest results without enabling report publication.",
"type": "minor"
}
],
"packageName": "@rushstack/heft-jest-plugin"
}
6 changes: 6 additions & 0 deletions heft-plugins/heft-jest-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

This is a Heft plugin for running Jest.

## JUnit serialization utility

`lib/JestJUnitReporting` serializes validated Jest results to XML without writing reports or changing Jest execution.
Callers supply canonical ordering, consistent results, and finite nonnegative assertion durations; legacy test files
without assertion details are rejected. This utility alone does not enable sharding or JUnit report publication.

## Links

- [CHANGELOG.md](
Expand Down
174 changes: 174 additions & 0 deletions heft-plugins/heft-jest-plugin/src/JestJUnitReporting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import { AnsiEscape } from '@rushstack/terminal';

export interface IJestJUnitAssertion {
fullName: string;
ancestorTitles: string[];
status: 'passed' | 'failed' | 'pending' | 'skipped' | 'disabled' | 'todo';
// eslint-disable-next-line @rushstack/no-new-null -- Jest does not time every assertion.
durationMs: number | null;
failureMessages: string[];
}

export interface IJestJUnitTestFile {
path: string;
failed: number;
// eslint-disable-next-line @rushstack/no-new-null -- Preserve explicit null in serialized Jest results.
failureMessage: string | null;
// eslint-disable-next-line @rushstack/no-new-null -- Preserve explicit null in serialized Jest results.
executionError: string | null;
snapshotFailure: boolean;
/** Legacy results may omit assertion details; the serializer rejects such test files. */
assertions?: IJestJUnitAssertion[];
}

/** The subset of validated, final Jest results consumed by the JUnit serializer. */
export interface IJestJUnitReport {
passWithNoTests: boolean;
success: boolean;
snapshotFailure: boolean;
testFiles: IJestJUnitTestFile[];
shards: { shardIndex: number; failureReasons: string[] }[];
coverage: { messages: string[] };
}

interface IJUnitSuite {
name: string;
cases: string[];
failures: number;
errors: number;
skipped: number;
}

function xml(value: string): string {
return (
AnsiEscape.removeCodes(value)
// XML 1.0 excludes control characters and unpaired UTF-16 surrogates.
// eslint-disable-next-line no-control-regex
.replace(/[^\u0009\u000a\u000d\u0020-\ud7ff\ue000-\ufffd\u{10000}-\u{10ffff}]/gu, '\uFFFD')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
.replace(/\n/g, '&#10;')
.replace(/\r/g, '&#13;')
.replace(/\t/g, '&#9;')
);
}

function createSuite(name: string): IJUnitSuite {
return { name, cases: [], failures: 0, errors: 0, skipped: 0 };
}

function addDiagnostic(suite: IJUnitSuite, name: string, message: string, error: boolean = false): void {
const element: string = error ? 'error' : 'failure';
if (error) {
suite.errors++;
} else {
suite.failures++;
}
suite.cases.push(
`<testcase name="${xml(name)}" classname="${xml(suite.name)}">` +
`<${element} message="${xml(name)}">${xml(message)}</${element}></testcase>`
);
}

/**
* Serializes real assertions and labeled diagnostics without writing files or running Jest.
* The caller must validate identities, counters, statuses, finite nonnegative durations, and the final verdict.
* Input array order is preserved; callers provide canonical ordering for deterministic merged reports.
* Missing assertion details are rejected rather than interpreting aggregate counts as passing tests.
*/
export function serializeJestJUnitReport(report: IJestJUnitReport): string {
const suites: IJUnitSuite[] = [];
for (const file of report.testFiles) {
if (!file.assertions) {
throw new Error(`JUnit requires assertion details for "${file.path}". Rerun legacy cached shards.`);
}
const suite: IJUnitSuite = createSuite(file.path);
for (const assertion of file.assertions) {
let body: string = '';
if (assertion.status === 'failed') {
suite.failures++;
body = `<failure message="${xml(assertion.fullName)}">${xml(assertion.failureMessages.join('\n'))}</failure>`;
} else if (assertion.status !== 'passed') {
suite.skipped++;
body = `<skipped message="${assertion.status}"/>`;
}
const time: string = assertion.durationMs === null ? '' : ` time="${assertion.durationMs / 1000}"`;
suite.cases.push(
`<testcase name="${xml(assertion.fullName)}" classname="${xml(
assertion.ancestorTitles.join(' ') || file.path
)}" file="${xml(file.path)}"${time}>${body}</testcase>`
);
}
if (file.executionError) {
addDiagnostic(suite, '[suite execution]', file.executionError, true);
} else if (file.failureMessage && file.failed === 0) {
addDiagnostic(suite, '[suite failure]', file.failureMessage);
}
if (file.snapshotFailure && suite.failures === 0 && suite.errors === 0) {
addDiagnostic(suite, '[snapshot validation]', 'Jest snapshot validation failed.');
}
suites.push(suite);
}
const finalChecks: IJUnitSuite = createSuite('Jest collation checks');
if (report.testFiles.length === 0 && !report.passWithNoTests) {
addDiagnostic(
finalChecks,
'[empty selection]',
'No Jest tests were selected and passWithNoTests is false.'
);
}
if (report.snapshotFailure && !report.testFiles.some((file) => file.snapshotFailure)) {
addDiagnostic(finalChecks, '[snapshot validation]', 'Jest snapshot validation failed.');
}
for (const shard of report.shards) {
if (shard.failureReasons.length === 1 && shard.failureReasons[0] === 'jest-unsuccessful') {
addDiagnostic(finalChecks, `[shard ${shard.shardIndex}]`, 'Jest returned an unsuccessful result.');
}
}
for (const message of report.coverage.messages) {
addDiagnostic(finalChecks, '[coverage threshold]', message);
}
if (
!report.success &&
finalChecks.failures === 0 &&
suites.every((suite) => suite.failures === 0 && suite.errors === 0)
) {
addDiagnostic(finalChecks, '[final verdict]', 'Jest collation returned an unsuccessful verdict.');
}
if (finalChecks.cases.length) {
suites.push(finalChecks);
}
const totals: { tests: number; failures: number; errors: number; skipped: number } = {
tests: 0,
failures: 0,
errors: 0,
skipped: 0
};
for (const suite of suites) {
totals.tests += suite.cases.length;
totals.failures += suite.failures;
totals.errors += suite.errors;
totals.skipped += suite.skipped;
}
const counts = (value: typeof totals): string =>
`tests="${value.tests}" failures="${value.failures}" errors="${value.errors}" skipped="${value.skipped}"`;
return (
'<?xml version="1.0" encoding="UTF-8"?>\n' +
`<testsuites ${counts(totals)}>\n` +
suites
.map(
(suite) =>
`<testsuite name="${xml(suite.name)}" ${counts({ ...suite, tests: suite.cases.length })}>\n` +
suite.cases.join('\n') +
'\n</testsuite>'
)
.join('\n') +
'\n</testsuites>\n'
);
}
193 changes: 193 additions & 0 deletions heft-plugins/heft-jest-plugin/src/test/JestJUnitReporting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import {
serializeJestJUnitReport,
type IJestJUnitAssertion,
type IJestJUnitReport
} from '../JestJUnitReporting';

function fixture(
statuses: IJestJUnitAssertion['status'][] = ['passed', 'failed', 'pending', 'skipped', 'disabled', 'todo']
): IJestJUnitReport {
const failed: number = statuses.filter((status) => status === 'failed').length;
return {
passWithNoTests: true,
success: failed === 0,
snapshotFailure: false,
testFiles: [
{
path: 'example.test.js',
failed,
failureMessage: null,
executionError: null,
snapshotFailure: false,
assertions: statuses.map((status) => ({
fullName: `group ${status}`,
ancestorTitles: ['group'],
status,
durationMs: status === 'passed' ? 1250 : null,
failureMessages: status === 'failed' ? ['actual <failure> & "detail"', 'second line'] : []
}))
}
],
shards: [
{ shardIndex: 1, failureReasons: failed ? ['failed-tests', 'failed-suites', 'jest-unsuccessful'] : [] }
],
coverage: { messages: [] }
};
}

describe('serializeJestJUnitReport', () => {
it('uses every real test identity and outcome, with milliseconds converted to seconds', () => {
expect(serializeJestJUnitReport(fixture())).toBe(
'<?xml version="1.0" encoding="UTF-8"?>\n' +
'<testsuites tests="6" failures="1" errors="0" skipped="4">\n' +
'<testsuite name="example.test.js" tests="6" failures="1" errors="0" skipped="4">\n' +
'<testcase name="group passed" classname="group" file="example.test.js" time="1.25"></testcase>\n' +
'<testcase name="group failed" classname="group" file="example.test.js">' +
'<failure message="group failed">actual &lt;failure&gt; &amp; &quot;detail&quot;&#10;second line</failure></testcase>\n' +
'<testcase name="group pending" classname="group" file="example.test.js"><skipped message="pending"/></testcase>\n' +
'<testcase name="group skipped" classname="group" file="example.test.js"><skipped message="skipped"/></testcase>\n' +
'<testcase name="group disabled" classname="group" file="example.test.js"><skipped message="disabled"/></testcase>\n' +
'<testcase name="group todo" classname="group" file="example.test.js"><skipped message="todo"/></testcase>\n' +
'</testsuite>\n</testsuites>\n'
);
});

it('preserves nesting, file-local order and zero durations, falling back to the file for unnamed groups', () => {
const report: IJestJUnitReport = fixture(['passed', 'passed']);
const [nested, ungrouped] = report.testFiles[0].assertions!;
nested.fullName = 'z group first';
nested.ancestorTitles = ['z', 'group'];
nested.durationMs = 0;
ungrouped.fullName = 'a second';
ungrouped.ancestorTitles = [];
ungrouped.durationMs = null;
const xml: string = serializeJestJUnitReport(report);
expect(xml).toContain(
'<testcase name="z group first" classname="z group" file="example.test.js" time="0"></testcase>\n' +
'<testcase name="a second" classname="example.test.js" file="example.test.js"></testcase>'
);
});

it('escapes attributes and text, removes ANSI, and replaces XML-invalid characters without losing surrogate pairs', () => {
const report: IJestJUnitReport = fixture(['failed']);
const raw: string =
'quotes "\' & < >\n\r\t\u0000\u000b\uD800x\uDC00\uFFFE\uFFFF\u{1F600}\u001b[31mred\u001b[0m';
const escaped: string =
'quotes &quot;&apos; &amp; &lt; &gt;&#10;&#13;&#9;\uFFFD\uFFFD\uFFFDx\uFFFD\uFFFD\uFFFD\u{1F600}red';
const assertion: IJestJUnitAssertion = report.testFiles[0].assertions![0];
report.testFiles[0].path = raw;
assertion.fullName = raw;
assertion.ancestorTitles = [raw];
assertion.failureMessages = [raw];
const xml: string = serializeJestJUnitReport(report);
expect(xml).toContain(`<testsuite name="${escaped}" tests="1" failures="1" errors="0" skipped="0">`);
expect(xml).toContain(
`<testcase name="${escaped}" classname="${escaped}" file="${escaped}">` +
`<failure message="${escaped}">${escaped}</failure></testcase>`
);
expect(xml).not.toContain('\u001b');
});

it.each([
['execution', '[suite execution]', 'Cannot load &lt;suite&gt;', true],
['suite', '[suite failure]', 'Suite &amp; teardown failed', false],
['file snapshot', '[snapshot validation]', 'Jest snapshot validation failed.', false],
['global snapshot', '[snapshot validation]', 'Jest snapshot validation failed.', false],
['coverage', '[coverage threshold]', 'Coverage threshold was not met', false],
['empty', '[empty selection]', 'No Jest tests were selected and passWithNoTests is false.', false],
['shard', '[shard 1]', 'Jest returned an unsuccessful result.', false],
['verdict', '[final verdict]', 'Jest collation returned an unsuccessful verdict.', false]
] as const)(
'reports a genuine %s diagnostic without manufacturing passing tests',
(kind, label, message, error) => {
const report: IJestJUnitReport = fixture([]);
report.success = false;
switch (kind) {
case 'execution':
report.testFiles[0].executionError = '\u001b[31mCannot load <suite>\u001b[0m';
report.testFiles[0].failureMessage = 'Duplicate suite failure';
break;
case 'suite':
report.testFiles[0].failureMessage = 'Suite & teardown failed';
break;
case 'file snapshot':
report.testFiles[0].snapshotFailure = report.snapshotFailure = true;
break;
case 'global snapshot':
report.snapshotFailure = true;
break;
case 'coverage':
report.coverage.messages = ['Coverage threshold was not met'];
break;
case 'empty':
report.testFiles = [];
report.passWithNoTests = false;
break;
case 'shard':
report.shards[0].failureReasons = ['jest-unsuccessful'];
break;
}
const xml: string = serializeJestJUnitReport(report);
const element: string = error ? 'error' : 'failure';
expect(xml).toContain(
`<testsuites tests="1" failures="${error ? 0 : 1}" errors="${error ? 1 : 0}" skipped="0">`
);
expect(xml.match(/<testcase /g)).toHaveLength(1);
expect(xml).toContain(`<testcase name="${label}"`);
expect(xml).toContain(`<${element} message="${label}">${message}</${element}></testcase>`);
}
);

it('does not duplicate assertion failures with suite, snapshot, shard or final-verdict diagnostics', () => {
const report: IJestJUnitReport = fixture(['failed']);
report.testFiles[0].failureMessage = 'Already represented by the failed assertion';
report.testFiles[0].snapshotFailure = report.snapshotFailure = true;
const xml: string = serializeJestJUnitReport(report);
expect(xml).toContain('<testsuites tests="1" failures="1" errors="0" skipped="0">');
expect(xml.match(/<testcase /g)).toHaveLength(1);
expect(xml).toContain('<testcase name="group failed"');
expect(xml).not.toContain('[snapshot validation]');
expect(xml).not.toContain('Jest collation checks');
});

it('totals multiple suites and final diagnostics without changing caller order or input', () => {
const report: IJestJUnitReport = fixture();
const second: IJestJUnitReport = fixture([]);
second.testFiles[0].path = 'another.test.js';
second.testFiles[0].executionError = 'Cannot load another suite';
report.testFiles.push(second.testFiles[0]);
report.coverage.messages = ['First threshold', 'Second threshold'];
const before: string = JSON.stringify(report);
const xml: string = serializeJestJUnitReport(report);
expect(xml).toContain('<testsuites tests="9" failures="3" errors="1" skipped="4">');
expect(xml.match(/<testcase /g)).toHaveLength(9);
expect(xml).toContain('<testsuite name="another.test.js" tests="1" failures="0" errors="1" skipped="0">');
expect(xml).toContain(
'<testsuite name="Jest collation checks" tests="2" failures="2" errors="0" skipped="0">'
);
expect(xml.indexOf('name="example.test.js"')).toBeLessThan(xml.indexOf('name="another.test.js"'));
expect(xml.indexOf('First threshold')).toBeLessThan(xml.indexOf('Second threshold'));
expect(JSON.stringify(report)).toBe(before);
expect(serializeJestJUnitReport(report)).toBe(xml);
});

it('does not fabricate tests for an allowed empty selection', () => {
const report: IJestJUnitReport = fixture([]);
report.testFiles = [];
expect(serializeJestJUnitReport(report)).toBe(
'<?xml version="1.0" encoding="UTF-8"?>\n' +
'<testsuites tests="0" failures="0" errors="0" skipped="0">\n\n</testsuites>\n'
);
});

it('refuses legacy test files without assertion details', () => {
const report: IJestJUnitReport = fixture(['passed']);
delete report.testFiles[0].assertions;
expect(() => serializeJestJUnitReport(report)).toThrow(
'JUnit requires assertion details for "example.test.js". Rerun legacy cached shards.'
);
});
});
Loading