From eedd088b6d32b2d645f1ffe459368d5ffbe5ecf2 Mon Sep 17 00:00:00 2001 From: Piotr Mionskowski Date: Fri, 5 Dec 2025 09:43:46 +0100 Subject: [PATCH] Add support for open-test-reporting format Add a new parser for the open-test-reporting format developed by the JUnit team (https://github.com/ota4j-team/open-test-reporting). This format is a modern, framework-agnostic XML-based test reporting standard that supports rich metadata including tags, attachments, and infrastructure information. Features: - Auto-detection of both XML format variants: - Hierarchical format (h:execution) - tree-structured results - Event-based format (e:events) - streaming/real-time results - ISO 8601 duration parsing (e.g., PT1.234S) - Status mapping: SUCCESSFUL, SKIPPED, ABORTED, FAILED, ERRORED - Error message extraction from failed tests - Proper XML namespace handling Files added: - src/parsers/open-test-reporting/open-test-reporting-types.ts - src/parsers/open-test-reporting/open-test-reporting-parser.ts - __tests__/open-test-reporting.test.ts (20 tests) - __tests__/fixtures/open-test-reporting/*.xml --- .../__outputs__/open-test-reporting-events.md | 24 ++ .../open-test-reporting-hierarchy.md | 27 ++ .../open-test-reporting.test.ts.snap | 157 +++++++ .../fixtures/open-test-reporting/empty.xml | 4 + .../fixtures/open-test-reporting/events.xml | 71 ++++ .../open-test-reporting/hierarchy.xml | 60 +++ __tests__/open-test-reporting.test.ts | 344 +++++++++++++++ action.yml | 1 + src/main.ts | 3 + .../open-test-reporting-parser.ts | 390 ++++++++++++++++++ .../open-test-reporting-types.ts | 174 ++++++++ 11 files changed, 1255 insertions(+) create mode 100644 __tests__/__outputs__/open-test-reporting-events.md create mode 100644 __tests__/__outputs__/open-test-reporting-hierarchy.md create mode 100644 __tests__/__snapshots__/open-test-reporting.test.ts.snap create mode 100644 __tests__/fixtures/open-test-reporting/empty.xml create mode 100644 __tests__/fixtures/open-test-reporting/events.xml create mode 100644 __tests__/fixtures/open-test-reporting/hierarchy.xml create mode 100644 __tests__/open-test-reporting.test.ts create mode 100644 src/parsers/open-test-reporting/open-test-reporting-parser.ts create mode 100644 src/parsers/open-test-reporting/open-test-reporting-types.ts diff --git a/__tests__/__outputs__/open-test-reporting-events.md b/__tests__/__outputs__/open-test-reporting-events.md new file mode 100644 index 0000000..cabe095 --- /dev/null +++ b/__tests__/__outputs__/open-test-reporting-events.md @@ -0,0 +1,24 @@ +![Tests failed](https://img.shields.io/badge/tests-4%20passed%2C%201%20failed%2C%201%20skipped-critical) +|Report|Passed|Failed|Skipped|Time| +|:---|---:|---:|---:|---:| +|[fixtures/open-test-reporting/events.xml](#user-content-r0)|4 ✅|1 ❌|1 ⚪|1s| +## ❌ fixtures/open-test-reporting/events.xml +**6** tests were completed in **1s** with **4** passed, **1** failed and **1** skipped. +|Test suite|Passed|Failed|Skipped|Time| +|:---|---:|---:|---:|---:| +|[com.example.CalculatorTest](#user-content-r0s0)|2 ✅|1 ❌|1 ⚪|700ms| +|[com.example.StringUtilsTest](#user-content-r0s1)|2 ✅|||400ms| +### ❌ com.example.CalculatorTest +``` +✅ testAddition +✅ testSubtraction +❌ testDivision + java.lang.ArithmeticException: Division by zero +⚪ testMultiplication +``` +### ✅ com.example.StringUtilsTest +``` +TrimTests + ✅ testTrimLeft + ✅ testTrimRight +``` \ No newline at end of file diff --git a/__tests__/__outputs__/open-test-reporting-hierarchy.md b/__tests__/__outputs__/open-test-reporting-hierarchy.md new file mode 100644 index 0000000..8fc1a99 --- /dev/null +++ b/__tests__/__outputs__/open-test-reporting-hierarchy.md @@ -0,0 +1,27 @@ +![Tests failed](https://img.shields.io/badge/tests-5%20passed%2C%201%20failed%2C%201%20skipped-critical) +|Report|Passed|Failed|Skipped|Time| +|:---|---:|---:|---:|---:| +|[fixtures/open-test-reporting/hierarchy.xml](#user-content-r0)|5 ✅|1 ❌|1 ⚪|4s| +## ❌ fixtures/open-test-reporting/hierarchy.xml +**7** tests were completed in **4s** with **5** passed, **1** failed and **1** skipped. +|Test suite|Passed|Failed|Skipped|Time| +|:---|---:|---:|---:|---:| +|[com.example.EmptySuite](#user-content-r0s0)||||0ms| +|[com.example.PaymentServiceTest](#user-content-r0s1)|3 ✅|1 ❌||3s| +|[com.example.UserServiceTest](#user-content-r0s2)|2 ✅||1 ⚪|1s| +### ❌ com.example.PaymentServiceTest +``` +ValidationTests + ✅ testValidAmount + ✅ testInvalidAmount +ProcessingTests + ✅ testSuccessfulPayment + ❌ testPaymentTimeout + org.opentest4j.AssertionFailedError: Payment should complete within 500ms but took 700ms +``` +### ✅ com.example.UserServiceTest +``` +✅ testUserCreation +✅ testUserDeletion +⚪ testUserUpdate +``` \ No newline at end of file diff --git a/__tests__/__snapshots__/open-test-reporting.test.ts.snap b/__tests__/__snapshots__/open-test-reporting.test.ts.snap new file mode 100644 index 0000000..0203664 --- /dev/null +++ b/__tests__/__snapshots__/open-test-reporting.test.ts.snap @@ -0,0 +1,157 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`open-test-reporting tests report generation result matches snapshot for events format 1`] = ` +TestRunResult { + "path": "fixtures/open-test-reporting/events.xml", + "suites": [ + TestSuiteResult { + "groups": [ + TestGroupResult { + "name": "", + "tests": [ + TestCaseResult { + "error": undefined, + "name": "testAddition", + "result": "success", + "time": 100, + }, + TestCaseResult { + "error": undefined, + "name": "testSubtraction", + "result": "success", + "time": 150, + }, + TestCaseResult { + "error": { + "details": "java.lang.ArithmeticException: Division by zero", + "message": "java.lang.ArithmeticException: Division by zero", + }, + "name": "testDivision", + "result": "failed", + "time": 150, + }, + TestCaseResult { + "error": undefined, + "name": "testMultiplication", + "result": "skipped", + "time": 10, + }, + ], + }, + ], + "name": "com.example.CalculatorTest", + "totalTime": 700, + }, + TestSuiteResult { + "groups": [ + TestGroupResult { + "name": "TrimTests", + "tests": [ + TestCaseResult { + "error": undefined, + "name": "testTrimLeft", + "result": "success", + "time": 90, + }, + TestCaseResult { + "error": undefined, + "name": "testTrimRight", + "result": "success", + "time": 90, + }, + ], + }, + ], + "name": "com.example.StringUtilsTest", + "totalTime": 400, + }, + ], + "totalTime": 1100, +} +`; + +exports[`open-test-reporting tests report generation result matches snapshot for hierarchy format 1`] = ` +TestRunResult { + "path": "fixtures/open-test-reporting/hierarchy.xml", + "suites": [ + TestSuiteResult { + "groups": [ + TestGroupResult { + "name": "", + "tests": [ + TestCaseResult { + "error": undefined, + "name": "testUserCreation", + "result": "success", + "time": 123, + }, + TestCaseResult { + "error": undefined, + "name": "testUserDeletion", + "result": "success", + "time": 234, + }, + TestCaseResult { + "error": undefined, + "name": "testUserUpdate", + "result": "skipped", + "time": 45, + }, + ], + }, + ], + "name": "com.example.UserServiceTest", + "totalTime": 1234, + }, + TestSuiteResult { + "groups": [ + TestGroupResult { + "name": "ValidationTests", + "tests": [ + TestCaseResult { + "error": undefined, + "name": "testValidAmount", + "result": "success", + "time": 200, + }, + TestCaseResult { + "error": undefined, + "name": "testInvalidAmount", + "result": "success", + "time": 300, + }, + ], + }, + TestGroupResult { + "name": "ProcessingTests", + "tests": [ + TestCaseResult { + "error": undefined, + "name": "testSuccessfulPayment", + "result": "success", + "time": 500, + }, + TestCaseResult { + "error": { + "details": "org.opentest4j.AssertionFailedError: Payment should complete within 500ms but took 700ms", + "message": "org.opentest4j.AssertionFailedError: Payment should complete within 500ms but took 700ms", + }, + "name": "testPaymentTimeout", + "result": "failed", + "time": 700, + }, + ], + }, + ], + "name": "com.example.PaymentServiceTest", + "totalTime": 2500, + }, + TestSuiteResult { + "groups": [], + "name": "com.example.EmptySuite", + "totalTime": 0, + }, + ], + "totalTime": 3734, +} +`; diff --git a/__tests__/fixtures/open-test-reporting/empty.xml b/__tests__/fixtures/open-test-reporting/empty.xml new file mode 100644 index 0000000..8274100 --- /dev/null +++ b/__tests__/fixtures/open-test-reporting/empty.xml @@ -0,0 +1,4 @@ + + + diff --git a/__tests__/fixtures/open-test-reporting/events.xml b/__tests__/fixtures/open-test-reporting/events.xml new file mode 100644 index 0000000..a261935 --- /dev/null +++ b/__tests__/fixtures/open-test-reporting/events.xml @@ -0,0 +1,71 @@ + + + + build-agent-02 + runner + macOS + 8 + + + + + + + + + + + + + + + + + + + + + + java.lang.ArithmeticException: Division by zero + + + + + + + + Test disabled + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/__tests__/fixtures/open-test-reporting/hierarchy.xml b/__tests__/fixtures/open-test-reporting/hierarchy.xml new file mode 100644 index 0000000..10f03c4 --- /dev/null +++ b/__tests__/fixtures/open-test-reporting/hierarchy.xml @@ -0,0 +1,60 @@ + + + + build-agent-01 + ci + Linux + 4 + + + + + + + + + + + + + + Skipped: Database not available + + + + + + + + + + + + + + + + + + + + + + + + + + + + org.opentest4j.AssertionFailedError: Payment should complete within 500ms but took 700ms + + + + + + + + + + diff --git a/__tests__/open-test-reporting.test.ts b/__tests__/open-test-reporting.test.ts new file mode 100644 index 0000000..23d92ce --- /dev/null +++ b/__tests__/open-test-reporting.test.ts @@ -0,0 +1,344 @@ +import * as fs from 'fs' +import * as path from 'path' + +import {OpenTestReportingParser} from '../src/parsers/open-test-reporting/open-test-reporting-parser' +import {ParseOptions} from '../src/test-parser' +import {getReport} from '../src/report/get-report' +import {normalizeFilePath} from '../src/utils/path-utils' + +describe('open-test-reporting tests', () => { + const defaultOpts: ParseOptions = { + parseErrors: true, + trackedFiles: [] + } + + describe('hierarchy format', () => { + it('parses hierarchy format correctly', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'open-test-reporting', 'hierarchy.xml') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const parser = new OpenTestReportingParser(defaultOpts) + const result = await parser.parse(filePath, fileContent) + + // 3 suites: UserServiceTest, PaymentServiceTest, EmptySuite + expect(result.suites.length).toBe(3) + + // UserServiceTest: 3 tests (2 passed, 1 skipped) + const userSuite = result.suites.find(s => s.name === 'com.example.UserServiceTest') + expect(userSuite).toBeDefined() + expect(userSuite!.tests).toBe(3) + expect(userSuite!.passed).toBe(2) + expect(userSuite!.skipped).toBe(1) + expect(userSuite!.failed).toBe(0) + + // PaymentServiceTest: 4 tests (3 passed, 1 failed) with nested groups + const paymentSuite = result.suites.find(s => s.name === 'com.example.PaymentServiceTest') + expect(paymentSuite).toBeDefined() + expect(paymentSuite!.tests).toBe(4) + expect(paymentSuite!.passed).toBe(3) + expect(paymentSuite!.failed).toBe(1) + + // Check groups exist + expect(paymentSuite!.groups.length).toBe(2) + const validationGroup = paymentSuite!.groups.find(g => g.name === 'ValidationTests') + const processingGroup = paymentSuite!.groups.find(g => g.name === 'ProcessingTests') + expect(validationGroup).toBeDefined() + expect(processingGroup).toBeDefined() + expect(validationGroup!.tests.length).toBe(2) + expect(processingGroup!.tests.length).toBe(2) + + // EmptySuite: 0 tests + const emptySuite = result.suites.find(s => s.name === 'com.example.EmptySuite') + expect(emptySuite).toBeDefined() + expect(emptySuite!.tests).toBe(0) + }) + + it('parses duration correctly', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'open-test-reporting', 'hierarchy.xml') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const parser = new OpenTestReportingParser(defaultOpts) + const result = await parser.parse(filePath, fileContent) + + // UserServiceTest has duration PT1.234S = 1234ms + const userSuite = result.suites.find(s => s.name === 'com.example.UserServiceTest') + expect(userSuite!.time).toBeCloseTo(1234, 0) + + // Check individual test times + const group = userSuite!.groups[0] + const creationTest = group.tests.find(t => t.name === 'testUserCreation') + expect(creationTest!.time).toBeCloseTo(123, 0) // PT0.123S + }) + + it('extracts error message from failed tests', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'open-test-reporting', 'hierarchy.xml') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const parser = new OpenTestReportingParser(defaultOpts) + const result = await parser.parse(filePath, fileContent) + + const paymentSuite = result.suites.find(s => s.name === 'com.example.PaymentServiceTest') + const processingGroup = paymentSuite!.groups.find(g => g.name === 'ProcessingTests') + const timeoutTest = processingGroup!.tests.find(t => t.name === 'testPaymentTimeout') + + expect(timeoutTest!.result).toBe('failed') + expect(timeoutTest!.error).toBeDefined() + expect(timeoutTest!.error!.message).toContain('AssertionFailedError') + }) + + it('handles empty hierarchy file', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'open-test-reporting', 'empty.xml') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const parser = new OpenTestReportingParser(defaultOpts) + const result = await parser.parse(filePath, fileContent) + + expect(result.tests).toBe(0) + expect(result.suites.length).toBe(0) + expect(result.result).toBe('success') + }) + }) + + describe('events format', () => { + it('parses events format correctly', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'open-test-reporting', 'events.xml') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const parser = new OpenTestReportingParser(defaultOpts) + const result = await parser.parse(filePath, fileContent) + + // 2 suites: CalculatorTest, StringUtilsTest + expect(result.suites.length).toBe(2) + + // CalculatorTest: 4 tests + const calcSuite = result.suites.find(s => s.name === 'com.example.CalculatorTest') + expect(calcSuite).toBeDefined() + expect(calcSuite!.tests).toBe(4) + expect(calcSuite!.passed).toBe(2) + expect(calcSuite!.failed).toBe(1) + expect(calcSuite!.skipped).toBe(1) + + // StringUtilsTest: 2 tests in TrimTests group + const stringSuite = result.suites.find(s => s.name === 'com.example.StringUtilsTest') + expect(stringSuite).toBeDefined() + expect(stringSuite!.tests).toBe(2) + expect(stringSuite!.passed).toBe(2) + + // Check TrimTests group + const trimGroup = stringSuite!.groups.find(g => g.name === 'TrimTests') + expect(trimGroup).toBeDefined() + expect(trimGroup!.tests.length).toBe(2) + }) + + it('calculates duration from timestamps', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'open-test-reporting', 'events.xml') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const parser = new OpenTestReportingParser(defaultOpts) + const result = await parser.parse(filePath, fileContent) + + const calcSuite = result.suites.find(s => s.name === 'com.example.CalculatorTest') + // testAddition: 11:00:00.100 to 11:00:00.200 = 100ms + const addTest = calcSuite!.groups[0].tests.find(t => t.name === 'testAddition') + expect(addTest!.time).toBe(100) + }) + + it('extracts error message from failed events', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'open-test-reporting', 'events.xml') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const parser = new OpenTestReportingParser(defaultOpts) + const result = await parser.parse(filePath, fileContent) + + const calcSuite = result.suites.find(s => s.name === 'com.example.CalculatorTest') + const divTest = calcSuite!.groups[0].tests.find(t => t.name === 'testDivision') + + expect(divTest!.result).toBe('failed') + expect(divTest!.error).toBeDefined() + expect(divTest!.error!.message).toContain('ArithmeticException') + }) + }) + + describe('format auto-detection', () => { + it('auto-detects hierarchy format', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'open-test-reporting', 'hierarchy.xml') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const parser = new OpenTestReportingParser(defaultOpts) + const result = await parser.parse(filePath, fileContent) + + // Should parse successfully with hierarchy format + expect(result.suites.length).toBe(3) + }) + + it('auto-detects events format', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'open-test-reporting', 'events.xml') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const parser = new OpenTestReportingParser(defaultOpts) + const result = await parser.parse(filePath, fileContent) + + // Should parse successfully with events format + expect(result.suites.length).toBe(2) + }) + + it('throws error for invalid format', async () => { + const invalidXml = '' + const parser = new OpenTestReportingParser(defaultOpts) + + await expect(parser.parse('invalid.xml', invalidXml)).rejects.toThrow( + 'Unknown open-test-reporting format' + ) + }) + + it('throws error for invalid XML', async () => { + const invalidXml = 'not valid xml at all' + const parser = new OpenTestReportingParser(defaultOpts) + + await expect(parser.parse('invalid.xml', invalidXml)).rejects.toThrow('Invalid XML') + }) + }) + + describe('status mapping', () => { + it('maps SUCCESSFUL to success', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'open-test-reporting', 'hierarchy.xml') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const parser = new OpenTestReportingParser(defaultOpts) + const result = await parser.parse(filePath, fileContent) + + const userSuite = result.suites.find(s => s.name === 'com.example.UserServiceTest') + const creationTest = userSuite!.groups[0].tests.find(t => t.name === 'testUserCreation') + expect(creationTest!.result).toBe('success') + }) + + it('maps SKIPPED to skipped', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'open-test-reporting', 'hierarchy.xml') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const parser = new OpenTestReportingParser(defaultOpts) + const result = await parser.parse(filePath, fileContent) + + const userSuite = result.suites.find(s => s.name === 'com.example.UserServiceTest') + const updateTest = userSuite!.groups[0].tests.find(t => t.name === 'testUserUpdate') + expect(updateTest!.result).toBe('skipped') + }) + + it('maps FAILED to failed', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'open-test-reporting', 'hierarchy.xml') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const parser = new OpenTestReportingParser(defaultOpts) + const result = await parser.parse(filePath, fileContent) + + const paymentSuite = result.suites.find(s => s.name === 'com.example.PaymentServiceTest') + const processingGroup = paymentSuite!.groups.find(g => g.name === 'ProcessingTests') + const timeoutTest = processingGroup!.tests.find(t => t.name === 'testPaymentTimeout') + expect(timeoutTest!.result).toBe('failed') + }) + }) + + describe('report generation', () => { + it('generates report from hierarchy format', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'open-test-reporting', 'hierarchy.xml') + const outputPath = path.join(__dirname, '__outputs__', 'open-test-reporting-hierarchy.md') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const parser = new OpenTestReportingParser(defaultOpts) + const result = await parser.parse(filePath, fileContent) + + const report = getReport([result]) + fs.mkdirSync(path.dirname(outputPath), {recursive: true}) + fs.writeFileSync(outputPath, report) + + // Verify report contains expected content + expect(report).toContain('UserServiceTest') + expect(report).toContain('PaymentServiceTest') + expect(report).toContain('testPaymentTimeout') + }) + + it('generates report from events format', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'open-test-reporting', 'events.xml') + const outputPath = path.join(__dirname, '__outputs__', 'open-test-reporting-events.md') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const parser = new OpenTestReportingParser(defaultOpts) + const result = await parser.parse(filePath, fileContent) + + const report = getReport([result]) + fs.mkdirSync(path.dirname(outputPath), {recursive: true}) + fs.writeFileSync(outputPath, report) + + // Verify report contains expected content + expect(report).toContain('CalculatorTest') + expect(report).toContain('StringUtilsTest') + expect(report).toContain('testDivision') + }) + + it('result matches snapshot for hierarchy format', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'open-test-reporting', 'hierarchy.xml') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const parser = new OpenTestReportingParser(defaultOpts) + const result = await parser.parse(filePath, fileContent) + + expect(result).toMatchSnapshot() + }) + + it('result matches snapshot for events format', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'open-test-reporting', 'events.xml') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const parser = new OpenTestReportingParser(defaultOpts) + const result = await parser.parse(filePath, fileContent) + + expect(result).toMatchSnapshot() + }) + }) + + describe('duration parsing', () => { + it('parses seconds only format (PT0.123S)', async () => { + // This is tested through hierarchy.xml which uses PT0.123S format + const fixturePath = path.join(__dirname, 'fixtures', 'open-test-reporting', 'hierarchy.xml') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const parser = new OpenTestReportingParser(defaultOpts) + const result = await parser.parse(filePath, fileContent) + + const userSuite = result.suites.find(s => s.name === 'com.example.UserServiceTest') + // PT0.123S = 123ms + const creationTest = userSuite!.groups[0].tests.find(t => t.name === 'testUserCreation') + expect(creationTest!.time).toBeCloseTo(123, 0) + }) + + it('handles missing duration', async () => { + // EmptySuite has PT0S duration + const fixturePath = path.join(__dirname, 'fixtures', 'open-test-reporting', 'hierarchy.xml') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const parser = new OpenTestReportingParser(defaultOpts) + const result = await parser.parse(filePath, fileContent) + + const emptySuite = result.suites.find(s => s.name === 'com.example.EmptySuite') + expect(emptySuite!.time).toBe(0) + }) + }) +}) diff --git a/action.yml b/action.yml index 530435c..0cc6a15 100644 --- a/action.yml +++ b/action.yml @@ -32,6 +32,7 @@ inputs: - java-junit - jest-junit - mocha-json + - open-test-reporting - python-xunit - rspec-json - swift-xunit diff --git a/src/main.ts b/src/main.ts index e76992a..70c24f6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,6 +17,7 @@ import {GolangJsonParser} from './parsers/golang-json/golang-json-parser' import {JavaJunitParser} from './parsers/java-junit/java-junit-parser' import {JestJunitParser} from './parsers/jest-junit/jest-junit-parser' import {MochaJsonParser} from './parsers/mocha-json/mocha-json-parser' +import {OpenTestReportingParser} from './parsers/open-test-reporting/open-test-reporting-parser' import {PythonXunitParser} from './parsers/python-xunit/python-xunit-parser' import {RspecJsonParser} from './parsers/rspec-json/rspec-json-parser' import {SwiftXunitParser} from './parsers/swift-xunit/swift-xunit-parser' @@ -271,6 +272,8 @@ class TestReporter { return new JestJunitParser(options) case 'mocha-json': return new MochaJsonParser(options) + case 'open-test-reporting': + return new OpenTestReportingParser(options) case 'python-xunit': return new PythonXunitParser(options) case 'rspec-json': diff --git a/src/parsers/open-test-reporting/open-test-reporting-parser.ts b/src/parsers/open-test-reporting/open-test-reporting-parser.ts new file mode 100644 index 0000000..177e99f --- /dev/null +++ b/src/parsers/open-test-reporting/open-test-reporting-parser.ts @@ -0,0 +1,390 @@ +import {ParseOptions, TestParser} from '../../test-parser' +import {parseStringPromise} from 'xml2js' + +import { + OtrDocument, + OtrHierarchyDocument, + OtrEventsDocument, + OtrHierarchyNode, + OtrStartedEvent, + OtrFinishedEvent, + OtrResult, + OtrStatus +} from './open-test-reporting-types' + +import { + TestExecutionResult, + TestRunResult, + TestSuiteResult, + TestGroupResult, + TestCaseResult, + TestCaseError +} from '../../test-results' + +/** + * Parser for Open Test Reporting format (https://github.com/ota4j-team/open-test-reporting) + * Supports both hierarchical and event-based XML formats with auto-detection. + */ +export class OpenTestReportingParser implements TestParser { + constructor(readonly options: ParseOptions) {} + + async parse(filePath: string, content: string): Promise { + const report = await this.parseXml(filePath, content) + + // Auto-detect format based on root element + if (this.isHierarchyDocument(report)) { + return this.parseHierarchy(filePath, report) + } else if (this.isEventsDocument(report)) { + return this.parseEvents(filePath, report) + } + + throw new Error( + `Unknown open-test-reporting format at ${filePath}. Expected root element 'execution' (hierarchy) or 'events' (event-based).` + ) + } + + private async parseXml(filePath: string, content: string): Promise { + try { + // xml2js options to handle namespaced elements + return await parseStringPromise(content, { + explicitArray: true, + tagNameProcessors: [this.stripNamespacePrefix] + }) + } catch (e) { + throw new Error(`Invalid XML at ${filePath}\n\n${e}`) + } + } + + // Strip namespace prefixes (e.g., "h:execution" -> "execution") + private stripNamespacePrefix(name: string): string { + const colonIndex = name.indexOf(':') + return colonIndex !== -1 ? name.substring(colonIndex + 1) : name + } + + private isHierarchyDocument(doc: OtrDocument): doc is OtrHierarchyDocument { + return 'execution' in doc + } + + private isEventsDocument(doc: OtrDocument): doc is OtrEventsDocument { + return 'events' in doc + } + + // ============================================================================ + // Hierarchy Format Parsing + // ============================================================================ + + private parseHierarchy(filePath: string, doc: OtrHierarchyDocument): TestRunResult { + const execution = doc.execution + const roots = execution.root ?? [] + + // Each root node becomes a test suite + const suites = roots.map(root => this.parseHierarchyRoot(root)) + + // Calculate total time from all roots + const totalTime = roots.reduce((sum, root) => sum + this.parseDuration(root.$.duration), 0) + + return new TestRunResult(filePath, suites, totalTime) + } + + private parseHierarchyRoot(node: OtrHierarchyNode): TestSuiteResult { + const name = node.$.name + const time = this.parseDuration(node.$.duration) + const children = node.child ?? [] + + // Convert children to groups + // If all children are leaf nodes (no further children), create single group + // Otherwise, children with their own children become groups + const groups = this.buildGroups(children) + + return new TestSuiteResult(name, groups, time) + } + + private buildGroups(nodes: OtrHierarchyNode[]): TestGroupResult[] { + if (nodes.length === 0) { + return [] + } + + // Check if any node has children + const hasNestedNodes = nodes.some(n => n.child && n.child.length > 0) + + if (!hasNestedNodes) { + // All nodes are leaf tests - create a single unnamed group + const tests = nodes.map(n => this.nodeToTestCase(n)) + return [new TestGroupResult('', tests)] + } + + // Nodes with children become groups, leaf nodes go to default group + const groups: TestGroupResult[] = [] + const defaultGroupTests: TestCaseResult[] = [] + + for (const node of nodes) { + if (node.child && node.child.length > 0) { + // This node is a group - collect all leaf descendants as test cases + const tests = this.collectLeafNodes(node.child).map(n => this.nodeToTestCase(n)) + groups.push(new TestGroupResult(node.$.name, tests)) + } else { + // Leaf node goes to default group + defaultGroupTests.push(this.nodeToTestCase(node)) + } + } + + if (defaultGroupTests.length > 0) { + groups.unshift(new TestGroupResult('', defaultGroupTests)) + } + + return groups + } + + private collectLeafNodes(nodes: OtrHierarchyNode[]): OtrHierarchyNode[] { + const leaves: OtrHierarchyNode[] = [] + for (const node of nodes) { + if (node.child && node.child.length > 0) { + leaves.push(...this.collectLeafNodes(node.child)) + } else { + leaves.push(node) + } + } + return leaves + } + + private nodeToTestCase(node: OtrHierarchyNode): TestCaseResult { + const name = node.$.name + const time = this.parseDuration(node.$.duration) + const result = this.getExecutionResult(node.result) + const error = this.getTestCaseError(node.result) + + return new TestCaseResult(name, result, time, error) + } + + // ============================================================================ + // Event-Based Format Parsing + // ============================================================================ + + private parseEvents(filePath: string, doc: OtrEventsDocument): TestRunResult { + const events = doc.events + const startedEvents = events.started ?? [] + const finishedEvents = events.finished ?? [] + + // Build a map of id -> started event + const startedMap = new Map() + for (const started of startedEvents) { + startedMap.set(started.$.id, started) + } + + // Build a map of id -> finished event + const finishedMap = new Map() + for (const finished of finishedEvents) { + finishedMap.set(finished.$.id, finished) + } + + // Build tree structure from events + const tree = this.buildEventTree(startedEvents, finishedMap) + + // Convert tree to hierarchy and parse + const suites = tree.map(node => this.eventNodeToSuite(node, startedMap, finishedMap)) + + // Calculate total time + const totalTime = suites.reduce((sum, suite) => sum + suite.time, 0) + + return new TestRunResult(filePath, suites, totalTime) + } + + private buildEventTree( + startedEvents: OtrStartedEvent[], + finishedMap: Map + ): EventTreeNode[] { + // Find root events (no parentId) + const roots: EventTreeNode[] = [] + const nodeMap = new Map() + + // Create all nodes + for (const started of startedEvents) { + const finished = finishedMap.get(started.$.id) + nodeMap.set(started.$.id, { + id: started.$.id, + name: started.$.name, + startTime: started.$.time, + endTime: finished?.$.time, + parentId: started.$.parentId, + result: finished?.result, + children: [] + }) + } + + // Build parent-child relationships + for (const node of nodeMap.values()) { + if (node.parentId) { + const parent = nodeMap.get(node.parentId) + if (parent) { + parent.children.push(node) + } + } else { + roots.push(node) + } + } + + return roots + } + + private eventNodeToSuite( + node: EventTreeNode, + startedMap: Map, + finishedMap: Map + ): TestSuiteResult { + const time = this.calculateEventNodeDuration(node) + const groups = this.buildGroupsFromEventTree(node.children) + + return new TestSuiteResult(node.name, groups, time) + } + + private buildGroupsFromEventTree(nodes: EventTreeNode[]): TestGroupResult[] { + if (nodes.length === 0) { + return [] + } + + const hasNestedNodes = nodes.some(n => n.children.length > 0) + + if (!hasNestedNodes) { + const tests = nodes.map(n => this.eventNodeToTestCase(n)) + return [new TestGroupResult('', tests)] + } + + const groups: TestGroupResult[] = [] + const defaultGroupTests: TestCaseResult[] = [] + + for (const node of nodes) { + if (node.children.length > 0) { + const tests = this.collectLeafEventNodes(node.children).map(n => this.eventNodeToTestCase(n)) + groups.push(new TestGroupResult(node.name, tests)) + } else { + defaultGroupTests.push(this.eventNodeToTestCase(node)) + } + } + + if (defaultGroupTests.length > 0) { + groups.unshift(new TestGroupResult('', defaultGroupTests)) + } + + return groups + } + + private collectLeafEventNodes(nodes: EventTreeNode[]): EventTreeNode[] { + const leaves: EventTreeNode[] = [] + for (const node of nodes) { + if (node.children.length > 0) { + leaves.push(...this.collectLeafEventNodes(node.children)) + } else { + leaves.push(node) + } + } + return leaves + } + + private eventNodeToTestCase(node: EventTreeNode): TestCaseResult { + const time = this.calculateEventNodeDuration(node) + const result = this.getExecutionResult(node.result) + const error = this.getTestCaseError(node.result) + + return new TestCaseResult(node.name, result, time, error) + } + + private calculateEventNodeDuration(node: EventTreeNode): number { + if (!node.startTime || !node.endTime) { + return 0 + } + const start = new Date(node.startTime).getTime() + const end = new Date(node.endTime).getTime() + return Math.max(0, end - start) + } + + // ============================================================================ + // Helper Methods + // ============================================================================ + + /** + * Parse ISO 8601 duration string to milliseconds. + * Format: PT[n]H[n]M[n]S or PT[n]S (e.g., "PT0.013404S", "PT1H30M45.5S") + */ + private parseDuration(duration?: string): number { + if (!duration) { + return 0 + } + + // Match ISO 8601 duration: PT0H0M0.123S or PT0.123S + const match = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?/) + if (!match) { + return 0 + } + + const hours = parseFloat(match[1] || '0') + const minutes = parseFloat(match[2] || '0') + const seconds = parseFloat(match[3] || '0') + + return (hours * 3600 + minutes * 60 + seconds) * 1000 + } + + /** + * Map OTR status to TestExecutionResult + */ + private getExecutionResult(results?: OtrResult[]): TestExecutionResult { + if (!results || results.length === 0) { + return undefined + } + + const status = results[0].$?.status + switch (status) { + case 'SUCCESSFUL': + return 'success' + case 'SKIPPED': + return 'skipped' + case 'ABORTED': + case 'FAILED': + case 'ERRORED': + return 'failed' + default: + return undefined + } + } + + /** + * Extract error information from result + */ + private getTestCaseError(results?: OtrResult[]): TestCaseError | undefined { + if (!this.options.parseErrors) { + return undefined + } + + if (!results || results.length === 0) { + return undefined + } + + const result = results[0] + const status = result.$?.status + + // Only extract error for failure statuses + if (status !== 'FAILED' && status !== 'ERRORED' && status !== 'ABORTED') { + return undefined + } + + const reason = result.reason?.[0] + if (!reason) { + return undefined + } + + return { + message: reason, + details: reason + } + } +} + +// Internal type for building event tree +interface EventTreeNode { + id: string + name: string + startTime: string + endTime?: string + parentId?: string + result?: OtrResult[] + children: EventTreeNode[] +} diff --git a/src/parsers/open-test-reporting/open-test-reporting-types.ts b/src/parsers/open-test-reporting/open-test-reporting-types.ts new file mode 100644 index 0000000..0d66a86 --- /dev/null +++ b/src/parsers/open-test-reporting/open-test-reporting-types.ts @@ -0,0 +1,174 @@ +// Types for Open Test Reporting format (https://github.com/ota4j-team/open-test-reporting) +// Supports both hierarchical and event-based XML formats (version 0.2.0) + +// ============================================================================ +// Shared Core Types +// ============================================================================ + +export type OtrStatus = 'SUCCESSFUL' | 'SKIPPED' | 'ABORTED' | 'FAILED' | 'ERRORED' + +export interface OtrResult { + $?: { + status?: OtrStatus + } + reason?: string[] +} + +export interface OtrFilePosition { + $: { + line: string + column?: string + } +} + +export interface OtrFileSource { + $: { + path: string + } + filePosition?: OtrFilePosition[] +} + +export interface OtrDirectorySource { + $: { + path: string + } +} + +export interface OtrSources { + directorySource?: OtrDirectorySource[] + fileSource?: OtrFileSource[] +} + +export interface OtrDataEntry { + _: string + $: { + key: string + } +} + +export interface OtrData { + $: { + time: string + } + entry?: OtrDataEntry[] +} + +export interface OtrFile { + $: { + time: string + path: string + mediaType?: string + } +} + +export interface OtrOutput { + _: string + $: { + time: string + source: string + } +} + +export interface OtrAttachments { + data?: OtrData[] + file?: OtrFile[] + output?: OtrOutput[] +} + +export interface OtrTags { + tag?: string[] +} + +export interface OtrMetadata { + tags?: OtrTags[] +} + +export interface OtrInfrastructure { + hostName?: string[] + userName?: string[] + operatingSystem?: string[] + cpuCores?: string[] +} + +// ============================================================================ +// Hierarchical Format Types +// ============================================================================ + +export interface OtrHierarchyNode { + $: { + name: string + start: string // ISO 8601 datetime + duration?: string // ISO 8601 duration (e.g., "PT0.013404S") + } + result?: OtrResult[] + metadata?: OtrMetadata[] + sources?: OtrSources[] + attachments?: OtrAttachments[] + child?: OtrHierarchyNode[] +} + +export interface OtrHierarchyExecution { + $?: Record + infrastructure?: OtrInfrastructure[] + root?: OtrHierarchyNode[] +} + +export interface OtrHierarchyDocument { + execution: OtrHierarchyExecution +} + +// ============================================================================ +// Event-Based Format Types +// ============================================================================ + +export interface OtrStartedEvent { + $: { + id: string + name: string + time: string // ISO 8601 datetime + parentId?: string + } + metadata?: OtrMetadata[] + sources?: OtrSources[] + attachments?: OtrAttachments[] +} + +export interface OtrFinishedEvent { + $: { + id: string + time: string // ISO 8601 datetime + } + result?: OtrResult[] + metadata?: OtrMetadata[] + sources?: OtrSources[] + attachments?: OtrAttachments[] +} + +export interface OtrReportedEvent { + $: { + id: string + time: string // ISO 8601 datetime + } + result?: OtrResult[] + metadata?: OtrMetadata[] + sources?: OtrSources[] + attachments?: OtrAttachments[] +} + +export interface OtrEvents { + $?: Record + infrastructure?: OtrInfrastructure[] + started?: OtrStartedEvent[] + finished?: OtrFinishedEvent[] + reported?: OtrReportedEvent[] +} + +export interface OtrEventsDocument { + events: OtrEvents +} + +// ============================================================================ +// Union type for parsed XML document +// ============================================================================ + +export type OtrDocument = OtrHierarchyDocument | OtrEventsDocument