diff --git a/lib/mocha/hooks.js b/lib/mocha/hooks.js index 3393e3369..ded7f7b42 100644 --- a/lib/mocha/hooks.js +++ b/lib/mocha/hooks.js @@ -33,12 +33,22 @@ class Hook { } simplify() { + // this.runnable (context.ctx.test) is the hook's own runnable; its + // .parent is the real Mocha Suite that owns this hook. Included here so + // run-workers can forward suite identity to the main process, where + // listeners (e.g. junitReporter) have no other way to recover it — the + // worker's live Suite/ctx objects aren't serializable across the thread + // boundary, only these plain fields are. + const suite = this.runnable?.parent return { hookName: this.hookName, title: this.title, // test: this.test ? serializeTest(this.test) : null, // suite: this.suite ? serializeSuite(this.suite) : null, error: this.err ? serializeError(this.err) : null, + suiteTitle: suite?.title || null, + suiteFile: suite?.file || null, + suiteTags: suite?.tags || [], } } diff --git a/lib/plugin/junitReporter.js b/lib/plugin/junitReporter.js index 0fac0a8a8..a3f104e5d 100644 --- a/lib/plugin/junitReporter.js +++ b/lib/plugin/junitReporter.js @@ -67,13 +67,29 @@ export default function (config = {}) { let written = false const hookFailures = [] + // groupBySuite() (below) groups by object identity, not by value — reused + // across BeforeSuite/AfterSuite failures from the same worker-forwarded + // suite so they land in one , not one per failure. + const workerSuiteByKey = new Map() event.dispatcher.on(event.hook.failed, hook => { if (!hook || !['BeforeSuite', 'AfterSuite'].includes(hook.hookName)) return const err = hook.err || hook.error if (!err) return const runnable = hook.ctx && hook.ctx.test - const suite = runnable && runnable.parent + // Under run-workers, hook.ctx is absent — the failure arrives as the + // plain object from Hook.simplify() in the main process instead of a + // live Hook instance. Fall back to the suiteTitle/suiteFile/suiteTags + // simplify() carries across the worker boundary so the failure still + // groups under its real suite instead of the "Tests" fallback. + let suite = runnable && runnable.parent + if (!suite && hook.suiteTitle) { + const key = `${hook.suiteTitle} ${hook.suiteFile || ''}` + if (!workerSuiteByKey.has(key)) { + workerSuiteByKey.set(key, { title: hook.suiteTitle, file: hook.suiteFile, tags: hook.suiteTags }) + } + suite = workerSuiteByKey.get(key) + } hookFailures.push({ title: hook.title || `${hook.hookName} hook failed`, state: 'failed', diff --git a/test/data/sandbox/codecept.workers-junit.conf.js b/test/data/sandbox/codecept.workers-junit.conf.js new file mode 100644 index 000000000..e0def8932 --- /dev/null +++ b/test/data/sandbox/codecept.workers-junit.conf.js @@ -0,0 +1,20 @@ +export const config = { + tests: './workers-junit-suite-hooks/*.js', + timeout: 10000, + output: './output', + helpers: { + FileSystem: {}, + Workers: { + require: './workers_helper', + }, + }, + include: {}, + async bootstrap() {}, + mocha: {}, + plugins: { + junitReporter: { + enabled: true, + }, + }, + name: 'sandbox', +} diff --git a/test/data/sandbox/workers-junit-suite-hooks/junit_suite_hooks.worker.js b/test/data/sandbox/workers-junit-suite-hooks/junit_suite_hooks.worker.js new file mode 100644 index 000000000..db40e1494 --- /dev/null +++ b/test/data/sandbox/workers-junit-suite-hooks/junit_suite_hooks.worker.js @@ -0,0 +1,13 @@ +Feature('JunitWorkerSuiteHooks') + +BeforeSuite(async () => { + throw new Error('BeforeSuite worker failure') +}) + +Scenario('should not be executed either', ({ I }) => { + I.say('unreachable') +}) + +AfterSuite(async () => { + throw new Error('AfterSuite worker failure') +}) diff --git a/test/runner/run_workers_test.js b/test/runner/run_workers_test.js index e7b03f268..fe805602a 100644 --- a/test/runner/run_workers_test.js +++ b/test/runner/run_workers_test.js @@ -6,6 +6,7 @@ import fs from 'fs' import semver from 'semver' import { exec } from 'child_process' import { fileURLToPath } from 'url' +import xml2js from 'xml2js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -512,6 +513,34 @@ describe('CodeceptJS Workers Runner', function () { }) }) + it('should preserve suite identity for BeforeSuite/AfterSuite hook failures in the JUnit report', function (done) { + if (!semver.satisfies(process.version, '>=11.7.0')) this.skip('not for node version') + const reportFile = path.join(codecept_dir, 'output', 'report.xml') + if (fs.existsSync(reportFile)) fs.rmSync(reportFile) + + exec(`${codecept_run_glob('codecept.workers-junit.conf.js')} 1`, (err, stdout) => { + ;(async () => { + expect(stdout).toContain('BeforeSuite worker failure') + expect(stdout).toContain('AfterSuite worker failure') + expect(err.code).toEqual(1) + + expect(fs.existsSync(reportFile)).toEqual(true) + const parsed = await new xml2js.Parser().parseStringPromise(fs.readFileSync(reportFile, 'utf8')) + + // Both hook failures must land under the real suite name, grouped into a + // single , not split across two elements. + expect(parsed.testsuites.testsuite).toHaveLength(1) + const suiteEl = parsed.testsuites.testsuite[0] + expect(suiteEl.$.name).toEqual('JunitWorkerSuiteHooks') + expect(suiteEl.testcase).toHaveLength(2) + + const names = suiteEl.testcase.map(tc => tc.$.name) + expect(names.some(n => n.includes('BeforeSuite'))).toEqual(true) + expect(names.some(n => n.includes('AfterSuite'))).toEqual(true) + })().then(done, done) + }) + }) + it('should handle large worker count without inflating statistics', function (done) { if (!semver.satisfies(process.version, '>=11.7.0')) this.skip('not for node version') // Test with more workers than tests to ensure no inflation