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 @@
+
+|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 @@
+
+|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