Skip to content

Commit 6ec6690

Browse files
DavertMikDavertMikclaude
authored
feat(plugin): add junitReporter plugin (JUnit XML with steps & meta) (#5558)
New built-in `junitReporter` plugin that writes a standard JUnit `<testsuites>/<testsuite>/<testcase>` XML report after a run, consumable by Jenkins, GitLab CI, CircleCI, GitHub Actions test reporters, etc. Beyond plain pass/fail/skip + durations + error stack, each `<testcase>` includes CodeceptJS-specific data Mocha's reporter can't provide: - `<properties>` — the test's meta information: every `meta` key from `Scenario('...', { meta })`, plus its `tags` and `retries` - `<system-out>` — an indented step/substep log; substeps nested under their meta step, only failed steps marked - `<failure>` — message, type, stack, and (optionally) the step trace Reads the already-collected `test.steps` on `event.all.result` (and `event.workers.result`); builds XML via `@xmldom/xmldom`. Config: `outputName`, `output`, `testGroupName`, `attachMeta`, `attachSteps`, `stepsInFailure`. Co-authored-by: DavertMik <davert@testomat.io> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f922ac8 commit 6ec6690

3 files changed

Lines changed: 547 additions & 0 deletions

File tree

docs/plugins.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,49 @@ More config options are available:
569569
570570
* `config` (optional, default `{}`)
571571
572+
## junitReporter
573+
574+
Generates a JUnit-compatible XML report after a test run.
575+
576+
Unlike Mocha's `mocha-junit-reporter`, this plugin understands CodeceptJS steps and substeps.
577+
For every `<testcase>` it includes:
578+
579+
* `<properties>` — the test's meta information: every `meta` key from `Scenario('...', { meta })`, plus its `tags` and `retries`
580+
* `<system-out>` — an indented step/substep log (substeps are nested under their meta step); only failed steps are marked
581+
* `<failure>` — for failed tests: the error message, type, stack trace and (optionally) the step trace
582+
583+
The produced file is consumable by Jenkins, GitLab CI, CircleCI, GitHub Actions test reporters, etc.
584+
585+
#### Configuration
586+
587+
```js
588+
"plugins": {
589+
"junitReporter": {
590+
"enabled": true
591+
}
592+
}
593+
```
594+
595+
Possible config options:
596+
597+
* `outputName`: file name for the report. Default: `report.xml`.
598+
* `output`: directory where the report is stored, relative to the project root. Default: the `output` directory.
599+
* `testGroupName`: value of the `name` attribute on the root `<testsuites>` element. Default: `CodeceptJS`.
600+
* `attachMeta`: add the test's meta information (`meta` keys, `tags`, `retries`) as `<properties>`. Default: true.
601+
* `attachSteps`: add the step/substep log as `<system-out>`. Default: true.
602+
* `stepsInFailure`: append the step trace to the `<failure>` body. Default: true.
603+
604+
CLI examples:
605+
606+
npx codeceptjs run -p junitReporter
607+
npx codeceptjs run -p junitReporter:outputName=junit.xml
608+
609+
> ℹ When running with `run-workers`, steps are serialized between processes and substep nesting is flattened.
610+
611+
### Parameters
612+
613+
* `config` **any** (optional, default `{}`)
614+
572615
## pageInfo
573616
574617
Collects information from web page after each failed test and adds it to the test as an artifact.

lib/plugin/junitReporter.js

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
import fs from 'fs'
2+
import path from 'path'
3+
import os from 'os'
4+
import { mkdirp } from 'mkdirp'
5+
import { DOMImplementation, XMLSerializer } from '@xmldom/xmldom'
6+
7+
import event from '../event.js'
8+
import store from '../store.js'
9+
import output from '../output.js'
10+
11+
const defaultConfig = {
12+
outputName: 'report.xml',
13+
output: null,
14+
testGroupName: 'CodeceptJS',
15+
attachSteps: true,
16+
attachMeta: true,
17+
stepsInFailure: true,
18+
}
19+
20+
const INVALID_XML_CHARS = new RegExp('[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\uFFFE\\uFFFF]', 'g')
21+
22+
/**
23+
*
24+
* Generates a JUnit-compatible XML report after a test run.
25+
*
26+
* Unlike Mocha's `mocha-junit-reporter`, this plugin understands CodeceptJS steps and substeps.
27+
* For every `<testcase>` it includes:
28+
*
29+
* * `<properties>` — the test's meta information: every `meta` key from `Scenario('...', { meta })`, plus its `tags` and `retries`
30+
* * `<system-out>` — an indented step/substep log (substeps are nested under their meta step); only failed steps are marked
31+
* * `<failure>` — for failed tests: the error message, type, stack trace and (optionally) the step trace
32+
*
33+
* The produced file is consumable by Jenkins, GitLab CI, CircleCI, GitHub Actions test reporters, etc.
34+
*
35+
* #### Configuration
36+
*
37+
* ```js
38+
* "plugins": {
39+
* "junitReporter": {
40+
* "enabled": true
41+
* }
42+
* }
43+
* ```
44+
*
45+
* Possible config options:
46+
*
47+
* * `outputName`: file name for the report. Default: `report.xml`.
48+
* * `output`: directory where the report is stored, relative to the project root. Default: the `output` directory.
49+
* * `testGroupName`: value of the `name` attribute on the root `<testsuites>` element. Default: `CodeceptJS`.
50+
* * `attachMeta`: add the test's meta information (`meta` keys, `tags`, `retries`) as `<properties>`. Default: true.
51+
* * `attachSteps`: add the step/substep log as `<system-out>`. Default: true.
52+
* * `stepsInFailure`: append the step trace to the `<failure>` body. Default: true.
53+
*
54+
* CLI examples:
55+
*
56+
* ```
57+
* npx codeceptjs run -p junitReporter
58+
* npx codeceptjs run -p junitReporter:outputName=junit.xml
59+
* ```
60+
*
61+
* > ℹ When running with `run-workers`, steps are serialized between processes and substep nesting is flattened.
62+
*
63+
* @param {*} config
64+
*/
65+
export default function (config = {}) {
66+
config = Object.assign({}, defaultConfig, config)
67+
68+
let written = false
69+
70+
const writeReport = result => {
71+
if (written) return
72+
if (!result || !Array.isArray(result.tests)) return
73+
written = true
74+
75+
const dir = config.output ? path.resolve(store.codeceptDir || process.cwd(), config.output) : store.outputDir || process.cwd()
76+
mkdirp.sync(dir)
77+
const file = path.join(dir, config.outputName)
78+
79+
fs.writeFileSync(file, buildXml(result, config))
80+
output.plugin('junitReporter', `JUnit report saved to ${file}`)
81+
}
82+
83+
event.dispatcher.on(event.all.result, writeReport)
84+
event.dispatcher.on(event.workers.result, writeReport)
85+
}
86+
87+
function buildXml(result, config) {
88+
const doc = new DOMImplementation().createDocument(null, null, null)
89+
const suites = groupBySuite(result.tests)
90+
91+
const root = doc.createElement('testsuites')
92+
setAttr(root, 'name', config.testGroupName)
93+
setAttr(root, 'tests', result.tests.length)
94+
setAttr(root, 'failures', countState(result.tests, 'failed'))
95+
setAttr(root, 'skipped', countSkipped(result.tests))
96+
setAttr(root, 'errors', 0)
97+
setAttr(root, 'time', toSeconds(sumDuration(result.tests)))
98+
setAttr(root, 'timestamp', toIso(result.stats && result.stats.start))
99+
doc.appendChild(root)
100+
101+
suites.forEach((tests, index) => {
102+
const suite = tests[0] && tests[0].parent
103+
const suiteName = (suite && suite.title) || 'Tests'
104+
const suiteFile = (suite && suite.file) || (tests[0] && tests[0].file) || ''
105+
106+
const suiteEl = doc.createElement('testsuite')
107+
setAttr(suiteEl, 'name', suiteName)
108+
setAttr(suiteEl, 'id', index)
109+
setAttr(suiteEl, 'tests', tests.length)
110+
setAttr(suiteEl, 'failures', countState(tests, 'failed'))
111+
setAttr(suiteEl, 'skipped', countSkipped(tests))
112+
setAttr(suiteEl, 'errors', 0)
113+
setAttr(suiteEl, 'time', toSeconds(sumDuration(tests)))
114+
setAttr(suiteEl, 'timestamp', toIso(suite && suite.startedAt))
115+
setAttr(suiteEl, 'hostname', os.hostname())
116+
if (suiteFile) setAttr(suiteEl, 'file', suiteFile)
117+
root.appendChild(suiteEl)
118+
119+
for (const test of tests) {
120+
suiteEl.appendChild(buildTestCase(doc, test, suiteName, config))
121+
}
122+
})
123+
124+
return '<?xml version="1.0" encoding="UTF-8"?>\n' + new XMLSerializer().serializeToString(doc) + '\n'
125+
}
126+
127+
function buildTestCase(doc, test, suiteName, config) {
128+
const testEl = doc.createElement('testcase')
129+
setAttr(testEl, 'name', test.title || '(no title)')
130+
setAttr(testEl, 'classname', suiteName)
131+
setAttr(testEl, 'time', toSeconds(test.duration || 0))
132+
const file = test.file || (test.parent && test.parent.file)
133+
if (file) setAttr(testEl, 'file', file)
134+
135+
if (config.attachMeta) {
136+
const properties = metaProperties(test)
137+
if (properties.length) {
138+
const propertiesEl = doc.createElement('properties')
139+
for (const [name, value] of properties) {
140+
const prop = doc.createElement('property')
141+
setAttr(prop, 'name', name)
142+
setAttr(prop, 'value', value)
143+
propertiesEl.appendChild(prop)
144+
}
145+
testEl.appendChild(propertiesEl)
146+
}
147+
}
148+
149+
const flat = flattenSteps(Array.isArray(test.steps) ? test.steps : [])
150+
151+
if (test.state === 'skipped' || test.state === 'pending') {
152+
const skipped = doc.createElement('skipped')
153+
const reason = skipReason(test)
154+
if (reason) setAttr(skipped, 'message', reason)
155+
testEl.appendChild(skipped)
156+
} else if (test.state === 'failed') {
157+
const err = test.err || {}
158+
const failure = doc.createElement('failure')
159+
setAttr(failure, 'message', err.message || 'Test failed')
160+
setAttr(failure, 'type', err.name || 'Error')
161+
let body = err.stack || err.message || 'Test failed'
162+
if (config.stepsInFailure && flat.length) {
163+
body += '\n\nSteps:\n' + flat.map(stepLogLine).join('\n')
164+
}
165+
failure.appendChild(doc.createTextNode(cleanText(body)))
166+
testEl.appendChild(failure)
167+
}
168+
169+
if (config.attachSteps && flat.length) {
170+
const out = doc.createElement('system-out')
171+
out.appendChild(doc.createTextNode(cleanText(flat.map(stepLogLine).join('\n'))))
172+
testEl.appendChild(out)
173+
}
174+
175+
return testEl
176+
}
177+
178+
function metaProperties(test) {
179+
const props = []
180+
const meta = test.meta || {}
181+
for (const key of Object.keys(meta)) {
182+
if (meta[key] === undefined || meta[key] === null) continue
183+
props.push([key, stringifyMeta(meta[key])])
184+
}
185+
if (Array.isArray(test.tags) && test.tags.length) {
186+
props.push(['tags', test.tags.join(' ')])
187+
}
188+
if (test.retries > 0 || test.retryNum > 0) {
189+
props.push(['retries', String(test.retryNum || test.retries)])
190+
}
191+
return props
192+
}
193+
194+
function stringifyMeta(value) {
195+
if (typeof value === 'string') return value
196+
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
197+
try {
198+
return JSON.stringify(value)
199+
} catch (err) {
200+
return String(value)
201+
}
202+
}
203+
204+
function flattenSteps(steps) {
205+
const out = []
206+
let prevChain = []
207+
208+
for (const step of steps) {
209+
const chain = metaChain(step)
210+
211+
let common = 0
212+
while (common < chain.length && common < prevChain.length && chain[common].key === prevChain[common].key) common++
213+
214+
for (let d = common; d < chain.length; d++) {
215+
out.push({ depth: d, step: chain[d].step })
216+
}
217+
out.push({ depth: chain.length, step })
218+
prevChain = chain
219+
}
220+
221+
return out
222+
}
223+
224+
function metaChain(step) {
225+
const chain = []
226+
let meta = step && step.metaStep
227+
while (meta) {
228+
chain.unshift({ step: meta, key: meta })
229+
meta = meta.metaStep
230+
}
231+
if (!chain.length && step && step.parent && step.parent.title) {
232+
chain.push({ step: { title: step.parent.title, status: step.status }, key: `meta:${step.parent.title}` })
233+
}
234+
return chain
235+
}
236+
237+
function stepLogLine(entry) {
238+
const indent = ' '.repeat(entry.depth)
239+
const mark = entry.step && entry.step.status === 'failed' ? '[FAILED] ' : ''
240+
return `${indent}${mark}${stepText(entry.step)} (${stepDuration(entry.step)}ms)`
241+
}
242+
243+
function stepText(step) {
244+
if (step && typeof step.toString === 'function' && step.toString !== Object.prototype.toString) return step.toString()
245+
return (step && (step.title || step.name)) || 'step'
246+
}
247+
248+
function stepDuration(step) {
249+
if (!step) return 0
250+
if (typeof step.duration === 'number' && step.duration >= 0) return step.duration
251+
if (step.startTime && step.endTime) return Math.max(0, step.endTime - step.startTime)
252+
return 0
253+
}
254+
255+
function groupBySuite(tests) {
256+
const groups = []
257+
const byKey = new Map()
258+
for (const test of tests) {
259+
const key = test.parent || test
260+
if (!byKey.has(key)) {
261+
const list = []
262+
byKey.set(key, list)
263+
groups.push(list)
264+
}
265+
byKey.get(key).push(test)
266+
}
267+
return groups
268+
}
269+
270+
function skipReason(test) {
271+
if (test.opts && test.opts.skipInfo && test.opts.skipInfo.message) return test.opts.skipInfo.message
272+
if (test.meta && test.meta.skipReason) return test.meta.skipReason
273+
return ''
274+
}
275+
276+
function countState(tests, state) {
277+
return tests.filter(t => t.state === state).length
278+
}
279+
280+
function countSkipped(tests) {
281+
return tests.filter(t => t.state === 'skipped' || t.state === 'pending').length
282+
}
283+
284+
function sumDuration(tests) {
285+
return tests.reduce((sum, t) => sum + (t.duration || 0), 0)
286+
}
287+
288+
function toSeconds(ms) {
289+
return (Math.max(0, ms) / 1000).toFixed(3)
290+
}
291+
292+
function toIso(value) {
293+
const date = value ? new Date(value) : new Date()
294+
return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString()
295+
}
296+
297+
function cleanText(text) {
298+
return String(text == null ? '' : text).replace(INVALID_XML_CHARS, '')
299+
}
300+
301+
function setAttr(el, name, value) {
302+
el.setAttribute(name, cleanText(value))
303+
}

0 commit comments

Comments
 (0)