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)}` + ); +} + +/** + * 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}`); + } + ); + + 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.' + ); + }); +});