1
0
Fork 0
mirror of https://github.com/dorny/test-reporter.git synced 2026-02-04 21:47:56 +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:
Piotr Mionskowski 2025-12-05 09:43:46 +01:00
parent ee446707ff
commit eedd088b6d
11 changed files with 1255 additions and 0 deletions

View file

@ -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':

View 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[]
}

View 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