From 9d98904ce6fac2c272b3221ea8e09e760f2e1f80 Mon Sep 17 00:00:00 2001 From: Andrian Budantsov Date: Tue, 10 Feb 2026 16:44:26 +0400 Subject: [PATCH] Fix JUnit XML parser: optional attributes and bare root - Make $ (attributes) optional on and elements so xml2js-parsed files without attributes pass Zod validation (#52) - Accept bare as root element by wrapping it in before validation, removing the need for junit-merge (#53) Closes #52 Closes #53 --- src/tests/junit-xml-parsing.spec.ts | 79 +++++++++++++++++++++++ src/utils/result-upload/junitXmlParser.ts | 33 ++++++---- 2 files changed, 101 insertions(+), 11 deletions(-) diff --git a/src/tests/junit-xml-parsing.spec.ts b/src/tests/junit-xml-parsing.spec.ts index aa367fd..aa0f90c 100644 --- a/src/tests/junit-xml-parsing.spec.ts +++ b/src/tests/junit-xml-parsing.spec.ts @@ -302,4 +302,83 @@ describe('Junit XML parsing', () => { // Should not include stdout or stderr for passed tests expect(testcases[0].message).toBe('') }) + + test('Should parse without attributes', async () => { + const xml = ` + + + + +` + + const testcases = await parseJUnitXml(xml, xmlBasePath, { + skipStdout: 'never', + skipStderr: 'never', + }) + + expect(testcases).toHaveLength(1) + expect(testcases[0].name).toBe('test1') + expect(testcases[0].folder).toBe('com.example.MyTest') + expect(testcases[0].status).toBe('passed') + }) + + test('Should parse without attributes inside ', async () => { + const xml = ` + + + + +` + + const testcases = await parseJUnitXml(xml, xmlBasePath, { + skipStdout: 'never', + skipStderr: 'never', + }) + + expect(testcases).toHaveLength(1) + expect(testcases[0].name).toBe('test1') + expect(testcases[0].folder).toBe('com.example.MyTest') + }) + + test('Should accept bare as root element', async () => { + const xml = ` + + + + java.lang.ArithmeticException + +` + + const testcases = await parseJUnitXml(xml, xmlBasePath, { + skipStdout: 'never', + skipStderr: 'never', + }) + + expect(testcases).toHaveLength(2) + expect(testcases[0].name).toBe('testAddition') + expect(testcases[0].folder).toBe('com.example.MathUtilsTest') + expect(testcases[0].status).toBe('passed') + expect(testcases[0].timeTaken).toBe(1) + + expect(testcases[1].name).toBe('testDivisionByZero') + expect(testcases[1].status).toBe('failed') + expect(testcases[1].message).toContain('ArithmeticException') + }) + + test('Should accept bare without attributes as root element', async () => { + const xml = ` + + +` + + const testcases = await parseJUnitXml(xml, xmlBasePath, { + skipStdout: 'never', + skipStderr: 'never', + }) + + expect(testcases).toHaveLength(1) + expect(testcases[0].name).toBe('test1') + expect(testcases[0].folder).toBe('') + }) }) diff --git a/src/utils/result-upload/junitXmlParser.ts b/src/utils/result-upload/junitXmlParser.ts index d24929f..6af54ee 100644 --- a/src/utils/result-upload/junitXmlParser.ts +++ b/src/utils/result-upload/junitXmlParser.ts @@ -56,18 +56,22 @@ const testCaseSchema = z.object({ const junitXmlSchema = z.object({ testsuites: z.object({ - $: z.object({ - name: z.string().optional(), - time: z.string().optional(), - timeStamp: z.string().optional(), - }), + $: z + .object({ + name: z.string().optional(), + time: z.string().optional(), + timeStamp: z.string().optional(), + }) + .optional(), testsuite: z.array( z.object({ - $: z.object({ - name: z.string().optional(), - time: z.string().optional(), - timeStamp: z.string().optional(), - }), + $: z + .object({ + name: z.string().optional(), + time: z.string().optional(), + timeStamp: z.string().optional(), + }) + .optional(), testcase: z.array(testCaseSchema).optional(), }) ), @@ -83,6 +87,13 @@ export const parseJUnitXml: Parser = async ( explicitCharkey: true, includeWhiteChars: true, }) + + // Accept bare as root by wrapping it in + if (xmlData.testsuite && !xmlData.testsuites) { + xmlData.testsuites = { testsuite: [xmlData.testsuite] } + delete xmlData.testsuite + } + const validated = junitXmlSchema.parse(xmlData) const testcases: TestCaseResult[] = [] const attachmentsPromises: Array<{ @@ -98,7 +109,7 @@ export const parseJUnitXml: Parser = async ( // grouping for test runners like pytest that put all tests in a single // generic suite (e.g., "pytest"). For runners where classname matches the // suite name (e.g., Playwright), this produces the same result. - const folder = tcase.$.classname ?? suite.$.name ?? '' + const folder = tcase.$.classname ?? suite.$?.name ?? '' const index = testcases.push({ ...result,