mirror of
https://github.com/dorny/test-reporter.git
synced 2025-12-15 22:07:09 +01:00
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
This commit is contained in:
parent
ee446707ff
commit
eedd088b6d
11 changed files with 1255 additions and 0 deletions
24
__tests__/__outputs__/open-test-reporting-events.md
Normal file
24
__tests__/__outputs__/open-test-reporting-events.md
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|

|
||||||
|
|Report|Passed|Failed|Skipped|Time|
|
||||||
|
|:---|---:|---:|---:|---:|
|
||||||
|
|[fixtures/open-test-reporting/events.xml](#user-content-r0)|4 ✅|1 ❌|1 ⚪|1s|
|
||||||
|
## ❌ <a id="user-content-r0" href="#user-content-r0">fixtures/open-test-reporting/events.xml</a>
|
||||||
|
**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|
|
||||||
|
### ❌ <a id="user-content-r0s0" href="#user-content-r0s0">com.example.CalculatorTest</a>
|
||||||
|
```
|
||||||
|
✅ testAddition
|
||||||
|
✅ testSubtraction
|
||||||
|
❌ testDivision
|
||||||
|
java.lang.ArithmeticException: Division by zero
|
||||||
|
⚪ testMultiplication
|
||||||
|
```
|
||||||
|
### ✅ <a id="user-content-r0s1" href="#user-content-r0s1">com.example.StringUtilsTest</a>
|
||||||
|
```
|
||||||
|
TrimTests
|
||||||
|
✅ testTrimLeft
|
||||||
|
✅ testTrimRight
|
||||||
|
```
|
||||||
27
__tests__/__outputs__/open-test-reporting-hierarchy.md
Normal file
27
__tests__/__outputs__/open-test-reporting-hierarchy.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|

|
||||||
|
|Report|Passed|Failed|Skipped|Time|
|
||||||
|
|:---|---:|---:|---:|---:|
|
||||||
|
|[fixtures/open-test-reporting/hierarchy.xml](#user-content-r0)|5 ✅|1 ❌|1 ⚪|4s|
|
||||||
|
## ❌ <a id="user-content-r0" href="#user-content-r0">fixtures/open-test-reporting/hierarchy.xml</a>
|
||||||
|
**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|
|
||||||
|
### ❌ <a id="user-content-r0s1" href="#user-content-r0s1">com.example.PaymentServiceTest</a>
|
||||||
|
```
|
||||||
|
ValidationTests
|
||||||
|
✅ testValidAmount
|
||||||
|
✅ testInvalidAmount
|
||||||
|
ProcessingTests
|
||||||
|
✅ testSuccessfulPayment
|
||||||
|
❌ testPaymentTimeout
|
||||||
|
org.opentest4j.AssertionFailedError: Payment should complete within 500ms but took 700ms
|
||||||
|
```
|
||||||
|
### ✅ <a id="user-content-r0s2" href="#user-content-r0s2">com.example.UserServiceTest</a>
|
||||||
|
```
|
||||||
|
✅ testUserCreation
|
||||||
|
✅ testUserDeletion
|
||||||
|
⚪ testUserUpdate
|
||||||
|
```
|
||||||
157
__tests__/__snapshots__/open-test-reporting.test.ts.snap
Normal file
157
__tests__/__snapshots__/open-test-reporting.test.ts.snap
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
|
`;
|
||||||
4
__tests__/fixtures/open-test-reporting/empty.xml
Normal file
4
__tests__/fixtures/open-test-reporting/empty.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<h:execution xmlns:h="https://schemas.opentest4j.org/reporting/hierarchy/0.2.0"
|
||||||
|
xmlns="https://schemas.opentest4j.org/reporting/core/0.2.0">
|
||||||
|
</h:execution>
|
||||||
71
__tests__/fixtures/open-test-reporting/events.xml
Normal file
71
__tests__/fixtures/open-test-reporting/events.xml
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<e:events xmlns="https://schemas.opentest4j.org/reporting/core/0.2.0"
|
||||||
|
xmlns:e="https://schemas.opentest4j.org/reporting/events/0.2.0">
|
||||||
|
<infrastructure>
|
||||||
|
<hostName>build-agent-02</hostName>
|
||||||
|
<userName>runner</userName>
|
||||||
|
<operatingSystem>macOS</operatingSystem>
|
||||||
|
<cpuCores>8</cpuCores>
|
||||||
|
</infrastructure>
|
||||||
|
|
||||||
|
<!-- Suite: CalculatorTest starts -->
|
||||||
|
<e:started id="suite-1" name="com.example.CalculatorTest" time="2024-01-15T11:00:00.000Z"/>
|
||||||
|
|
||||||
|
<!-- Test: testAddition -->
|
||||||
|
<e:started id="test-1" name="testAddition" parentId="suite-1" time="2024-01-15T11:00:00.100Z"/>
|
||||||
|
<e:finished id="test-1" time="2024-01-15T11:00:00.200Z">
|
||||||
|
<result status="SUCCESSFUL"/>
|
||||||
|
</e:finished>
|
||||||
|
|
||||||
|
<!-- Test: testSubtraction -->
|
||||||
|
<e:started id="test-2" name="testSubtraction" parentId="suite-1" time="2024-01-15T11:00:00.250Z"/>
|
||||||
|
<e:finished id="test-2" time="2024-01-15T11:00:00.400Z">
|
||||||
|
<result status="SUCCESSFUL"/>
|
||||||
|
</e:finished>
|
||||||
|
|
||||||
|
<!-- Test: testDivision (fails) -->
|
||||||
|
<e:started id="test-3" name="testDivision" parentId="suite-1" time="2024-01-15T11:00:00.450Z"/>
|
||||||
|
<e:finished id="test-3" time="2024-01-15T11:00:00.600Z">
|
||||||
|
<result status="FAILED">
|
||||||
|
<reason>java.lang.ArithmeticException: Division by zero</reason>
|
||||||
|
</result>
|
||||||
|
</e:finished>
|
||||||
|
|
||||||
|
<!-- Test: testMultiplication (skipped) -->
|
||||||
|
<e:started id="test-4" name="testMultiplication" parentId="suite-1" time="2024-01-15T11:00:00.650Z"/>
|
||||||
|
<e:finished id="test-4" time="2024-01-15T11:00:00.660Z">
|
||||||
|
<result status="SKIPPED">
|
||||||
|
<reason>Test disabled</reason>
|
||||||
|
</result>
|
||||||
|
</e:finished>
|
||||||
|
|
||||||
|
<!-- Suite: CalculatorTest finishes -->
|
||||||
|
<e:finished id="suite-1" time="2024-01-15T11:00:00.700Z">
|
||||||
|
<result status="FAILED"/>
|
||||||
|
</e:finished>
|
||||||
|
|
||||||
|
<!-- Suite: StringUtilsTest starts -->
|
||||||
|
<e:started id="suite-2" name="com.example.StringUtilsTest" time="2024-01-15T11:00:01.000Z"/>
|
||||||
|
|
||||||
|
<!-- Group: TrimTests -->
|
||||||
|
<e:started id="group-1" name="TrimTests" parentId="suite-2" time="2024-01-15T11:00:01.100Z"/>
|
||||||
|
|
||||||
|
<e:started id="test-5" name="testTrimLeft" parentId="group-1" time="2024-01-15T11:00:01.110Z"/>
|
||||||
|
<e:finished id="test-5" time="2024-01-15T11:00:01.200Z">
|
||||||
|
<result status="SUCCESSFUL"/>
|
||||||
|
</e:finished>
|
||||||
|
|
||||||
|
<e:started id="test-6" name="testTrimRight" parentId="group-1" time="2024-01-15T11:00:01.210Z"/>
|
||||||
|
<e:finished id="test-6" time="2024-01-15T11:00:01.300Z">
|
||||||
|
<result status="SUCCESSFUL"/>
|
||||||
|
</e:finished>
|
||||||
|
|
||||||
|
<e:finished id="group-1" time="2024-01-15T11:00:01.310Z">
|
||||||
|
<result status="SUCCESSFUL"/>
|
||||||
|
</e:finished>
|
||||||
|
|
||||||
|
<!-- Suite: StringUtilsTest finishes -->
|
||||||
|
<e:finished id="suite-2" time="2024-01-15T11:00:01.400Z">
|
||||||
|
<result status="SUCCESSFUL"/>
|
||||||
|
</e:finished>
|
||||||
|
</e:events>
|
||||||
60
__tests__/fixtures/open-test-reporting/hierarchy.xml
Normal file
60
__tests__/fixtures/open-test-reporting/hierarchy.xml
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<h:execution xmlns:h="https://schemas.opentest4j.org/reporting/hierarchy/0.2.0"
|
||||||
|
xmlns="https://schemas.opentest4j.org/reporting/core/0.2.0">
|
||||||
|
<infrastructure>
|
||||||
|
<hostName>build-agent-01</hostName>
|
||||||
|
<userName>ci</userName>
|
||||||
|
<operatingSystem>Linux</operatingSystem>
|
||||||
|
<cpuCores>4</cpuCores>
|
||||||
|
</infrastructure>
|
||||||
|
|
||||||
|
<!-- Suite: UserServiceTest -->
|
||||||
|
<h:root duration="PT1.234S" name="com.example.UserServiceTest" start="2024-01-15T10:30:00.000Z">
|
||||||
|
<result status="SUCCESSFUL"/>
|
||||||
|
<h:child duration="PT0.123S" name="testUserCreation" start="2024-01-15T10:30:00.100Z">
|
||||||
|
<result status="SUCCESSFUL"/>
|
||||||
|
</h:child>
|
||||||
|
<h:child duration="PT0.234S" name="testUserDeletion" start="2024-01-15T10:30:00.300Z">
|
||||||
|
<result status="SUCCESSFUL"/>
|
||||||
|
</h:child>
|
||||||
|
<h:child duration="PT0.045S" name="testUserUpdate" start="2024-01-15T10:30:00.600Z">
|
||||||
|
<result status="SKIPPED">
|
||||||
|
<reason>Skipped: Database not available</reason>
|
||||||
|
</result>
|
||||||
|
</h:child>
|
||||||
|
</h:root>
|
||||||
|
|
||||||
|
<!-- Suite: PaymentServiceTest with nested groups -->
|
||||||
|
<h:root duration="PT2.5S" name="com.example.PaymentServiceTest" start="2024-01-15T10:30:02.000Z">
|
||||||
|
<result status="FAILED"/>
|
||||||
|
|
||||||
|
<!-- Group: ValidationTests -->
|
||||||
|
<h:child duration="PT0.8S" name="ValidationTests" start="2024-01-15T10:30:02.100Z">
|
||||||
|
<result status="SUCCESSFUL"/>
|
||||||
|
<h:child duration="PT0.2S" name="testValidAmount" start="2024-01-15T10:30:02.110Z">
|
||||||
|
<result status="SUCCESSFUL"/>
|
||||||
|
</h:child>
|
||||||
|
<h:child duration="PT0.3S" name="testInvalidAmount" start="2024-01-15T10:30:02.320Z">
|
||||||
|
<result status="SUCCESSFUL"/>
|
||||||
|
</h:child>
|
||||||
|
</h:child>
|
||||||
|
|
||||||
|
<!-- Group: ProcessingTests -->
|
||||||
|
<h:child duration="PT1.2S" name="ProcessingTests" start="2024-01-15T10:30:03.000Z">
|
||||||
|
<result status="FAILED"/>
|
||||||
|
<h:child duration="PT0.5S" name="testSuccessfulPayment" start="2024-01-15T10:30:03.100Z">
|
||||||
|
<result status="SUCCESSFUL"/>
|
||||||
|
</h:child>
|
||||||
|
<h:child duration="PT0.7S" name="testPaymentTimeout" start="2024-01-15T10:30:03.700Z">
|
||||||
|
<result status="FAILED">
|
||||||
|
<reason>org.opentest4j.AssertionFailedError: Payment should complete within 500ms but took 700ms</reason>
|
||||||
|
</result>
|
||||||
|
</h:child>
|
||||||
|
</h:child>
|
||||||
|
</h:root>
|
||||||
|
|
||||||
|
<!-- Suite: EmptySuite (edge case) -->
|
||||||
|
<h:root duration="PT0S" name="com.example.EmptySuite" start="2024-01-15T10:30:05.000Z">
|
||||||
|
<result status="SUCCESSFUL"/>
|
||||||
|
</h:root>
|
||||||
|
</h:execution>
|
||||||
344
__tests__/open-test-reporting.test.ts
Normal file
344
__tests__/open-test-reporting.test.ts
Normal file
|
|
@ -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 = '<?xml version="1.0"?><unknown><root/></unknown>'
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -32,6 +32,7 @@ inputs:
|
||||||
- java-junit
|
- java-junit
|
||||||
- jest-junit
|
- jest-junit
|
||||||
- mocha-json
|
- mocha-json
|
||||||
|
- open-test-reporting
|
||||||
- python-xunit
|
- python-xunit
|
||||||
- rspec-json
|
- rspec-json
|
||||||
- swift-xunit
|
- swift-xunit
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {GolangJsonParser} from './parsers/golang-json/golang-json-parser'
|
||||||
import {JavaJunitParser} from './parsers/java-junit/java-junit-parser'
|
import {JavaJunitParser} from './parsers/java-junit/java-junit-parser'
|
||||||
import {JestJunitParser} from './parsers/jest-junit/jest-junit-parser'
|
import {JestJunitParser} from './parsers/jest-junit/jest-junit-parser'
|
||||||
import {MochaJsonParser} from './parsers/mocha-json/mocha-json-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 {PythonXunitParser} from './parsers/python-xunit/python-xunit-parser'
|
||||||
import {RspecJsonParser} from './parsers/rspec-json/rspec-json-parser'
|
import {RspecJsonParser} from './parsers/rspec-json/rspec-json-parser'
|
||||||
import {SwiftXunitParser} from './parsers/swift-xunit/swift-xunit-parser'
|
import {SwiftXunitParser} from './parsers/swift-xunit/swift-xunit-parser'
|
||||||
|
|
@ -271,6 +272,8 @@ class TestReporter {
|
||||||
return new JestJunitParser(options)
|
return new JestJunitParser(options)
|
||||||
case 'mocha-json':
|
case 'mocha-json':
|
||||||
return new MochaJsonParser(options)
|
return new MochaJsonParser(options)
|
||||||
|
case 'open-test-reporting':
|
||||||
|
return new OpenTestReportingParser(options)
|
||||||
case 'python-xunit':
|
case 'python-xunit':
|
||||||
return new PythonXunitParser(options)
|
return new PythonXunitParser(options)
|
||||||
case 'rspec-json':
|
case 'rspec-json':
|
||||||
|
|
|
||||||
390
src/parsers/open-test-reporting/open-test-reporting-parser.ts
Normal file
390
src/parsers/open-test-reporting/open-test-reporting-parser.ts
Normal file
|
|
@ -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<TestRunResult> {
|
||||||
|
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<OtrDocument> {
|
||||||
|
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<string, OtrStartedEvent>()
|
||||||
|
for (const started of startedEvents) {
|
||||||
|
startedMap.set(started.$.id, started)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a map of id -> finished event
|
||||||
|
const finishedMap = new Map<string, OtrFinishedEvent>()
|
||||||
|
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<string, OtrFinishedEvent>
|
||||||
|
): EventTreeNode[] {
|
||||||
|
// Find root events (no parentId)
|
||||||
|
const roots: EventTreeNode[] = []
|
||||||
|
const nodeMap = new Map<string, EventTreeNode>()
|
||||||
|
|
||||||
|
// 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<string, OtrStartedEvent>,
|
||||||
|
finishedMap: Map<string, OtrFinishedEvent>
|
||||||
|
): 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[]
|
||||||
|
}
|
||||||
174
src/parsers/open-test-reporting/open-test-reporting-types.ts
Normal file
174
src/parsers/open-test-reporting/open-test-reporting-types.ts
Normal file
|
|
@ -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<string, string>
|
||||||
|
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<string, string>
|
||||||
|
infrastructure?: OtrInfrastructure[]
|
||||||
|
started?: OtrStartedEvent[]
|
||||||
|
finished?: OtrFinishedEvent[]
|
||||||
|
reported?: OtrReportedEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OtrEventsDocument {
|
||||||
|
events: OtrEvents
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Union type for parsed XML document
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type OtrDocument = OtrHierarchyDocument | OtrEventsDocument
|
||||||
Loading…
Add table
Add a link
Reference in a new issue