Skip to content

Commit 64ed596

Browse files
committed
fix(junit): report suite hook failures
1 parent 7cb68de commit 64ed596

2 files changed

Lines changed: 124 additions & 7 deletions

File tree

lib/plugin/junitReporter.js

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { DOMImplementation, XMLSerializer } from '@xmldom/xmldom'
77
import event from '../event.js'
88
import store from '../store.js'
99
import output from '../output.js'
10+
import container from '../container.js'
1011

1112
const defaultConfig = {
1213
outputName: 'report.xml',
@@ -18,6 +19,7 @@ const defaultConfig = {
1819
}
1920

2021
const INVALID_XML_CHARS = new RegExp('[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\uFFFE\\uFFFF]', 'g')
22+
const SUITE_HOOK_TITLE = /^"(before all|after all)" hook:/
2123

2224
/**
2325
*
@@ -66,6 +68,40 @@ export default function (config = {}) {
6668
config = Object.assign({}, defaultConfig, config)
6769

6870
let written = false
71+
let runnerAttached = false
72+
const hookFailures = []
73+
const seenHookFailures = new Set()
74+
75+
const attachRunner = () => {
76+
if (runnerAttached) return
77+
78+
const mocha = container.mocha()
79+
const runner = mocha && (mocha.runner || mocha.Runner)
80+
if (!runner || typeof runner.on !== 'function') return
81+
82+
runnerAttached = true
83+
runner.on('fail', (failed, err) => {
84+
if (!failed || failed.type !== 'hook' || !SUITE_HOOK_TITLE.test(failed.title || '')) return
85+
86+
const suite = failed.parent
87+
const suiteTitle = (suite && suite.title) || ''
88+
const key = `${suiteTitle}::${failed.title}`
89+
if (seenHookFailures.has(key)) return
90+
seenHookFailures.add(key)
91+
92+
hookFailures.push({
93+
title: failed.title || 'hook failed',
94+
state: 'failed',
95+
err: err || failed.err || {},
96+
parent: suite,
97+
file: failed.file || (suite && suite.file),
98+
tags: suiteTitle.match(/@[\\w-]+/g) || [],
99+
meta: {},
100+
steps: [],
101+
duration: failed.duration || 0,
102+
})
103+
})
104+
}
69105

70106
const writeReport = result => {
71107
if (written) return
@@ -76,25 +112,29 @@ export default function (config = {}) {
76112
mkdirp.sync(dir)
77113
const file = path.join(dir, config.outputName)
78114

79-
fs.writeFileSync(file, buildXml(result, config))
115+
fs.writeFileSync(file, buildXml(result, config, hookFailures))
80116
output.plugin('junitReporter', `JUnit report saved to ${file}`)
81117
}
82118

119+
event.dispatcher.on(event.all.before, attachRunner)
120+
event.dispatcher.on(event.suite.before, attachRunner)
121+
event.dispatcher.on(event.test.before, attachRunner)
83122
event.dispatcher.on(event.all.result, writeReport)
84123
event.dispatcher.on(event.workers.result, writeReport)
85124
}
86125

87-
function buildXml(result, config) {
126+
function buildXml(result, config, hookFailures = []) {
88127
const doc = new DOMImplementation().createDocument(null, null, null)
89-
const suites = groupBySuite(result.tests)
128+
const allTests = result.tests.concat(hookFailures)
129+
const suites = groupBySuite(allTests)
90130

91131
const root = doc.createElement('testsuites')
92132
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))
133+
setAttr(root, 'tests', allTests.length)
134+
setAttr(root, 'failures', countState(allTests, 'failed'))
135+
setAttr(root, 'skipped', countSkipped(allTests))
96136
setAttr(root, 'errors', 0)
97-
setAttr(root, 'time', toSeconds(sumDuration(result.tests)))
137+
setAttr(root, 'time', toSeconds(sumDuration(allTests)))
98138
setAttr(root, 'timestamp', toIso(result.stats && result.stats.start))
99139
doc.appendChild(root)
100140

test/unit/junitReporter_test.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import xml2js from 'xml2js'
88
import junitReporter from '../../lib/plugin/junitReporter.js'
99
import event from '../../lib/event.js'
1010
import store from '../../lib/store.js'
11+
import container from '../../lib/container.js'
1112
import Step from '../../lib/step/base.js'
1213
import MetaStep from '../../lib/step/meta.js'
1314

@@ -83,6 +84,22 @@ function parseReport(dir) {
8384
return new xml2js.Parser().parseStringPromise(fs.readFileSync(path.join(dir, 'report.xml'), 'utf8'))
8485
}
8586

87+
function stubMochaRunner() {
88+
const listeners = {}
89+
90+
container.append({
91+
mocha: {
92+
runner: {
93+
on(name, fn) {
94+
listeners[name] = fn
95+
},
96+
},
97+
},
98+
})
99+
100+
return listeners
101+
}
102+
86103
describe('JUnit Reporter Plugin', () => {
87104
let tmpDir
88105
let prevOutputDir
@@ -91,13 +108,20 @@ describe('JUnit Reporter Plugin', () => {
91108
prevOutputDir = store._outputDir
92109
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cjs-junit-'))
93110
store.outputDir = tmpDir
111+
event.dispatcher.removeAllListeners(event.all.before)
94112
event.dispatcher.removeAllListeners(event.all.result)
113+
event.dispatcher.removeAllListeners(event.suite.before)
114+
event.dispatcher.removeAllListeners(event.test.before)
95115
event.dispatcher.removeAllListeners(event.workers.result)
96116
})
97117

98118
afterEach(() => {
119+
event.dispatcher.removeAllListeners(event.all.before)
99120
event.dispatcher.removeAllListeners(event.all.result)
121+
event.dispatcher.removeAllListeners(event.suite.before)
122+
event.dispatcher.removeAllListeners(event.test.before)
100123
event.dispatcher.removeAllListeners(event.workers.result)
124+
container.append({ mocha: {} })
101125
store._outputDir = prevOutputDir
102126
fs.rmSync(tmpDir, { recursive: true, force: true })
103127
})
@@ -198,4 +222,57 @@ describe('JUnit Reporter Plugin', () => {
198222
const secondMtime = fs.statSync(path.join(tmpDir, 'report.xml')).mtimeMs
199223
expect(secondMtime).to.equal(firstMtime)
200224
})
225+
226+
it('adds suite-level hook failures as failed testcases', async () => {
227+
const listeners = stubMochaRunner()
228+
junitReporter({})
229+
event.dispatcher.emit(event.suite.before)
230+
231+
const suite = { title: 'Suite hooks @smoke', file: '/tests/hooks_test.js', startedAt: Date.now() }
232+
listeners.fail(
233+
{
234+
type: 'hook',
235+
title: '"before all" hook: BeforeSuite for "records suite hook failures"',
236+
parent: suite,
237+
file: suite.file,
238+
duration: 42,
239+
},
240+
new Error('BeforeSuite failed'),
241+
)
242+
listeners.fail(
243+
{
244+
type: 'hook',
245+
title: '"before each" hook: Before for "already reported by test"',
246+
parent: suite,
247+
file: suite.file,
248+
duration: 7,
249+
},
250+
new Error('Before failed'),
251+
)
252+
listeners.fail(
253+
{
254+
type: 'hook',
255+
title: '"before all" hook: BeforeSuite for "records suite hook failures"',
256+
parent: suite,
257+
file: suite.file,
258+
duration: 42,
259+
},
260+
new Error('duplicate suite failure'),
261+
)
262+
263+
event.dispatcher.emit(event.all.result, {
264+
tests: [],
265+
stats: { start: new Date(), passes: 0, failures: 0, pending: 0, tests: 0 },
266+
})
267+
268+
const parsed = await parseReport(tmpDir)
269+
expect(parsed.testsuites.$.tests).to.equal('1')
270+
expect(parsed.testsuites.$.failures).to.equal('1')
271+
272+
const suiteEl = parsed.testsuites.testsuite[0]
273+
expect(suiteEl.$.name).to.equal('Suite hooks @smoke')
274+
expect(suiteEl.$.tests).to.equal('1')
275+
expect(suiteEl.testcase[0].$.name).to.contain('"before all" hook')
276+
expect(suiteEl.testcase[0].failure[0].$.message).to.contain('BeforeSuite failed')
277+
})
201278
})

0 commit comments

Comments
 (0)