import url from 'node:url'
import os from 'node:os'
import type { RunnerStats, SuiteStats, TestStats } from '@wdio/reporter'
import WDIOReporter from '@wdio/reporter'
import junitReportBuilder, { type Builder as JUnitReportBuilder } from 'junit-report-builder'
import type { JUnitReporterOptions } from './types.js'
import { limit } from './utils.js'
import { events } from './common/api.js'
const ansiRegex = new RegExp([
'[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)',
'(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))'
].join('|'), 'g')
interface JunitReporterAdditionalInformation {
uid: string
workerConsoleLog: string
properties: { [keys: string]: string }
}
* Reporter that converts test results from a single instance/runner into an XML JUnit report. This class
* uses junit-report-builder (https://github.com/davidparsson/junit-report-builder) to build report.The report
* generated from this reporter should conform to the standard JUnit report schema
* (https://github.com/junit-team/junit5/blob/master/platform-tests/src/test/resources/jenkins-junit.xsd).
*/
class JunitReporter extends WDIOReporter {
private _suiteNameRegEx: RegExp
private _packageName?: string
private _suiteTitleLabel?: string
private _fileNameLabel?: string
private _activeFeature?: any
private _activeFeatureName?: any
private _testToAdditionalInformation: { [keys: string]: JunitReporterAdditionalInformation }
private _currentTest?: TestStats
private _originalStdoutWrite: Function
private _addWorkerLogs: boolean
private _isWindows: boolean
constructor(public options: JUnitReporterOptions) {
super(options)
this._isWindows = os.platform() === 'win32'
this._addWorkerLogs = options.addWorkerLogs ?? false
this._testToAdditionalInformation = {}
this._originalStdoutWrite = process.stdout.write.bind(process.stdout)
this._suiteNameRegEx = this.options.suiteNameFormat instanceof RegExp
? this.options.suiteNameFormat
: /[^a-zA-Z0-9@]+/
const processObj: any = process
if (this._addWorkerLogs) {
processObj.stdout.write = this._appendConsoleLog.bind(this)
}
processObj.on(events.addProperty, this._addPropertyToCurrentTest.bind(this))
}
onTestRetry(testStats: TestStats) {
testStats.skip('Retry')
}
onTestStart(test: TestStats) {
this._currentTest = test
this._testToAdditionalInformation[test.uid] = {
workerConsoleLog: '',
properties: {},
uid: test.uid
}
}
onRunnerEnd(runner: RunnerStats) {
const xml = this._buildJunitXml(runner)
this.write(xml)
}
private _addPropertyToCurrentTest(dataObj: { name: string, value: string }) {
if (this._currentTest?.uid) {
this._testToAdditionalInformation[this._currentTest.uid].properties[dataObj.name] = dataObj.value
}
}
private _appendConsoleLog(chunk: string, encoding: BufferEncoding, callback: ((err?: Error) => void)) {
if (this._currentTest?.uid) {
if (typeof chunk === 'string' && !chunk.includes('mwebdriver')) {
this._testToAdditionalInformation[this._currentTest.uid].workerConsoleLog = (this._testToAdditionalInformation[this._currentTest.uid].workerConsoleLog ?? '') + chunk
}
}
return this._originalStdoutWrite(chunk, encoding, callback)
}
private _prepareName(name = 'Skipped test') {
return name.split(this._suiteNameRegEx).filter(
(item) => item && item.length
).join(' ')
}
private _addFailedHooks(suite: SuiteStats) {
* Add failed hooks to suite as tests.
*/
const failedHooks = suite.hooks.filter(hook => hook.error && hook.title.match(/^"(before|after)( all| each)?" hook/))
failedHooks.forEach(hook => {
const { title, _duration, error, state } = hook
suite.tests.push({
_duration,
title,
error,
state: state as 'failed' | 'passed',
output: []
} as any)
})
return suite
}
private _addCucumberFeatureToBuilder(builder: any, runner: RunnerStats, specFileName: string, suite: SuiteStats) {
const featureName = !this.options.suiteNameFormat || this.options.suiteNameFormat instanceof RegExp
? this._prepareName(suite.title)
: this.options.suiteNameFormat({ name: this.options.suiteNameFormat.name, suite })
const filePath = specFileName.replace(process.cwd(), '.')
if (suite.type === 'feature') {
const feature = builder.testSuite()
.name(featureName)
.timestamp(suite.start)
.time(suite._duration / 1000)
.property('specId', 0)
.property(this._suiteTitleLabel, suite.title)
.property('capabilities', runner.sanitizedCapabilities)
.property(this._fileNameLabel, filePath)
this._activeFeature = feature
this._activeFeatureName = featureName
} else if (this._activeFeature) {
let scenario = suite
const testName = this._prepareName(suite.title)
const classNameFormat = this.options.classNameFormat ? this.options.classNameFormat({ packageName: this._packageName, activeFeatureName: this._activeFeatureName }) : `${this._packageName}.${this._activeFeatureName}`
const testCase = this._activeFeature.testCase()
.className(classNameFormat)
.name(`${testName}`)
.time(scenario._duration / 1000)
if (this.options.addFileAttribute) {
testCase.file(filePath)
}
scenario = this._addFailedHooks(scenario)
let stepsOutput = ''
let isFailing = false
for (const stepKey of Object.keys(scenario.tests)) {
if (stepKey === 'undefined') {
continue
}
let stepEmoji = '✅'
const step = scenario.tests[stepKey as any]
if (step.state === 'pending' || step.state === 'skipped') {
if (!isFailing) {
testCase.skipped()
}
stepEmoji = '⚠️'
} else if (step.state === 'failed') {
if (step.error) {
if (this.options.errorOptions) {
const errorOptions = this.options.errorOptions
for (const key of Object.keys(errorOptions)) {
testCase[key](step.error
? (step.error as any)[errorOptions[key]]
: null
)
}
} else {
testCase.failure(step.error.message)
}
testCase.standardError(`\n${step.error.stack}\n`)
} else {
testCase.failure()
}
isFailing = true
stepEmoji = '❗'
}
const output = this._getStandardOutput(step)
stepsOutput += output ? stepEmoji + ' ' + step.title : stepEmoji + ' ' + step.title + '\n' + output
}
testCase.standardOutput(`\n${stepsOutput}\n`)
}
return builder
}
private _addSuiteToBuilder(
builder: any,
runner: RunnerStats,
specFileName: string,
suite: SuiteStats
) {
const filePath = specFileName.replace(process.cwd(), '.')
const suiteName = !this.options.suiteNameFormat || this.options.suiteNameFormat instanceof RegExp
? this._prepareName(suite.title)
: this.options.suiteNameFormat({ name: this.options.suiteNameFormat.name, suite })
const testSuite = builder.testSuite()
.name(suiteName)
.timestamp(suite.start)
.time(suite._duration / 1000)
.property('specId', 0)
.property(this._suiteTitleLabel, suite.title)
.property('capabilities', runner.sanitizedCapabilities)
.property(this._fileNameLabel, filePath)
suite = this._addFailedHooks(suite)
for (const testKey of Object.keys(suite.tests)) {
if (testKey === 'undefined') {
continue
}
const test = suite.tests[testKey as any]
const testName = this._prepareName(test.title)
const classNameFormat = this.options.classNameFormat
? this.options.classNameFormat({ packageName: this._packageName, suite })
: `${this._packageName}.${(suite.fullTitle || suite.title).replace(/\s/g, '_')}`
const testCase = testSuite.testCase()
.className(classNameFormat)
.name(testName)
.time(test._duration / 1000)
if (this.options.addFileAttribute) {
testCase.file(filePath)
}
if (test.state === 'pending' || test.state === 'skipped') {
testCase.skipped()
if (test.error) {
testCase.standardError(`\n${test.error.stack?.replace(ansiRegex, '')}\n`)
}
} else if (test.state === 'failed') {
if (test.error) {
if (test.error.message) {
test.error.message = test.error.message.replace(ansiRegex, '')
}
if (this.options.errorOptions) {
const errorOptions = this.options.errorOptions
for (const key of Object.keys(errorOptions)) {
testCase[key]((test.error as any)[errorOptions[key]])
}
} else {
testCase.failure(test.error.message)
}
testCase.standardError(`\n${test.error.stack?.replace(ansiRegex, '')}\n`)
} else {
testCase.failure()
}
}
for (const propName of Object.keys(this._testToAdditionalInformation[test.uid]?.properties ?? {})) {
testCase.property(propName, this._testToAdditionalInformation[test.uid].properties[propName])
}
const output = this._getStandardOutput(test)
if (output) {
testCase.standardOutput(`\n${output}\n`)
}
}
return builder
}
private _buildJunitXml(runner: RunnerStats) {
const builder = (junitReportBuilder as unknown as JUnitReportBuilder).newBuilder()
if (runner.config.hostname !== undefined && runner.config.hostname.indexOf('browserstack') > -1) {
const browserstackSanitizedCapabilities = [
(runner.capabilities).device,
(runner.capabilities).os,
((runner.capabilities).os_version || '').replace(/\./g, '_'),
]
.filter(Boolean)
.map((capability) => capability!.toLowerCase())
.join('.')
.replace(/ /g, '') || runner.sanitizedCapabilities
this._packageName = this.options.packageName || browserstackSanitizedCapabilities
} else {
this._packageName = this.options.packageName || runner.sanitizedCapabilities
}
const isCucumberFrameworkRunner = runner.config.framework === 'cucumber'
if (isCucumberFrameworkRunner) {
this._packageName = `CucumberJUnitReport-${this._packageName}`
this._suiteTitleLabel = 'featureName'
this._fileNameLabel = 'featureFile'
} else {
this._suiteTitleLabel = 'suiteName'
this._fileNameLabel = 'file'
}
runner.specs.forEach((specFileName) => {
if (isCucumberFrameworkRunner) {
this._buildOrderedReport(builder, runner, specFileName, 'feature', isCucumberFrameworkRunner)
this._buildOrderedReport(builder, runner, specFileName, 'scenario', isCucumberFrameworkRunner)
} else {
this._buildOrderedReport(builder, runner, specFileName, '', isCucumberFrameworkRunner)
}
})
return builder.build() as unknown as string
}
private _buildOrderedReport(builder: JUnitReportBuilder, runner: RunnerStats, specFileName: string, type: string, isCucumberFrameworkRunner: boolean) {
const suiteKeys = Object.keys(this.suites)
if (suiteKeys.length === 0) {
* Because this function gets called twice for Cucumber, this conditional is to ensure that
* Cucumber and Mocha generate the same Junit report for empty suites. Otherwise, Cucumber reporter
* would generate two <testsuite>.
*/
if (!isCucumberFrameworkRunner || (isCucumberFrameworkRunner && type === 'feature')) {
const testCase = builder.testSuite().testCase().className('').name('')
return this.runnerStat?.error ? testCase.failure(this.runnerStat.error) : testCase.skipped()
}
}
for (const suiteKey of Object.keys(this.suites)) {
* ignore root before all
*/
if (suiteKey.match(/^"before all"/)) {
continue
}
const suite = this.suites[suiteKey]
const sameSpecFileName = this._sameFileName(specFileName, suite.file)
if (isCucumberFrameworkRunner && suite.type === type && sameSpecFileName) {
builder = this._addCucumberFeatureToBuilder(builder, runner, specFileName, suite)
} else if (!isCucumberFrameworkRunner && sameSpecFileName) {
builder = this._addSuiteToBuilder(builder, runner, specFileName, suite)
}
}
return builder
}
private _getStandardOutput(test: TestStats) {
let consoleOutput = ''
if (this._addWorkerLogs) {
consoleOutput = this._testToAdditionalInformation[test.uid]?.workerConsoleLog ?? ''
}
const commandText = this._getCommandStandardOutput(test)
let result = ''
if (consoleOutput !== '') {
result += consoleOutput
}
if (commandText !== '' && consoleOutput !== '') {
result += '\n...command output...\n\n'
}
result += commandText
return result
}
private _getCommandStandardOutput(test: TestStats) {
const standardOutput: string[] = []
test.output.forEach((data) => {
switch (data.type) {
case 'command':
standardOutput.push(
data.method
? `COMMAND: ${data.method.toUpperCase()} ` +
`${data.endpoint.replace(':sessionId', data.sessionId)} - ${this._format(data.body)}`
: `COMMAND: ${data.command} - ${this._format(data.params)}`
)
break
case 'result':
standardOutput.push(`RESULT: ${this._format(data.body)}`)
break
}
})
return standardOutput.length ? standardOutput.join('\n') : ''
}
private _format(val: any) {
return JSON.stringify(limit(val))
}
private _sameFileName(file1?: string, file2?: string) {
if (!file1 && !file2) {
return true
}
if (!file1 || !file2) {
return false
}
file1 = file1.startsWith('file://') ? url.fileURLToPath(file1) : file1
file2 = file2.startsWith('file://') ? url.fileURLToPath(file2) : file2
return file1.localeCompare(file2, undefined, { sensitivity: this._isWindows ? 'accent' : 'variant' }) === 0
}
}
export * from './common/api.js'
export default JunitReporter