From c9f199a30cb62912443b42ac96d21f97a8dae737 Mon Sep 17 00:00:00 2001
From: Yashwanth Reddy Verupaka
<59129034+yashwanth195@users.noreply.github.com>
Date: Fri, 13 Nov 2026 10:50:05 -0800
Subject: [PATCH] Add Jest JUnit serializer
---
.../jest-junit-serializer_2026-11-13.json | 10 +
heft-plugins/heft-jest-plugin/README.md | 6 +
.../src/JestJUnitReporting.ts | 174 ++++++++++++++++
.../src/test/JestJUnitReporting.test.ts | 193 ++++++++++++++++++
4 files changed, 383 insertions(+)
create mode 100644 common/changes/@rushstack/heft-jest-plugin/jest-junit-serializer_2026-11-13.json
create mode 100644 heft-plugins/heft-jest-plugin/src/JestJUnitReporting.ts
create mode 100644 heft-plugins/heft-jest-plugin/src/test/JestJUnitReporting.test.ts
diff --git a/common/changes/@rushstack/heft-jest-plugin/jest-junit-serializer_2026-11-13.json b/common/changes/@rushstack/heft-jest-plugin/jest-junit-serializer_2026-11-13.json
new file mode 100644
index 00000000000..0a8a52d57ef
--- /dev/null
+++ b/common/changes/@rushstack/heft-jest-plugin/jest-junit-serializer_2026-11-13.json
@@ -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"
+}
diff --git a/heft-plugins/heft-jest-plugin/README.md b/heft-plugins/heft-jest-plugin/README.md
index 25f82e5ef62..8503ff9808c 100644
--- a/heft-plugins/heft-jest-plugin/README.md
+++ b/heft-plugins/heft-jest-plugin/README.md
@@ -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](
diff --git a/heft-plugins/heft-jest-plugin/src/JestJUnitReporting.ts b/heft-plugins/heft-jest-plugin/src/JestJUnitReporting.ts
new file mode 100644
index 00000000000..f8b6ec7a819
--- /dev/null
+++ b/heft-plugins/heft-jest-plugin/src/JestJUnitReporting.ts
@@ -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, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+ .replace(/\n/g, '
')
+ .replace(/\r/g, '
')
+ .replace(/\t/g, ' ')
+ );
+}
+
+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(
+ `` +
+ `<${element} message="${xml(name)}">${xml(message)}${element}>`
+ );
+}
+
+/**
+ * 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 = `${xml(assertion.failureMessages.join('\n'))}`;
+ } else if (assertion.status !== 'passed') {
+ suite.skipped++;
+ body = ``;
+ }
+ const time: string = assertion.durationMs === null ? '' : ` time="${assertion.durationMs / 1000}"`;
+ suite.cases.push(
+ `${body}`
+ );
+ }
+ 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 (
+ '\n' +
+ `\n` +
+ suites
+ .map(
+ (suite) =>
+ `\n` +
+ suite.cases.join('\n') +
+ '\n'
+ )
+ .join('\n') +
+ '\n\n'
+ );
+}
diff --git a/heft-plugins/heft-jest-plugin/src/test/JestJUnitReporting.test.ts b/heft-plugins/heft-jest-plugin/src/test/JestJUnitReporting.test.ts
new file mode 100644
index 00000000000..f40f95cb1f8
--- /dev/null
+++ b/heft-plugins/heft-jest-plugin/src/test/JestJUnitReporting.test.ts
@@ -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 & "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(
+ '\n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '' +
+ 'actual <failure> & "detail"
second line\n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '\n' +
+ '\n\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(
+ '\n' +
+ ''
+ );
+ });
+
+ 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 "' & < >
\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(``);
+ expect(xml).toContain(
+ `` +
+ `${escaped}`
+ );
+ expect(xml).not.toContain('\u001b');
+ });
+
+ it.each([
+ ['execution', '[suite execution]', 'Cannot load <suite>', true],
+ ['suite', '[suite failure]', 'Suite & 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 \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(
+ ``
+ );
+ expect(xml.match(/${message}${element}>`);
+ }
+ );
+
+ 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('');
+ expect(xml.match(/ {
+ 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('');
+ expect(xml.match(/');
+ expect(xml).toContain(
+ ''
+ );
+ 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(
+ '\n' +
+ '\n\n\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.'
+ );
+ });
+});