Skip to content

Commit da83519

Browse files
miraoclaude
andcommitted
fix: aggregate custom reporter results in run-workers --by suite (#5411)
When using run-workers with --by suite, custom reporters (mochawesome, mocha-junit-reporter, etc.) only contained data from the last worker because each worker independently ran the reporter and overwrote the same output files. Strip custom reporters from worker configs and replay aggregated results through the reporter in the main thread after all workers complete. Handles both single-output and config.multiple (per-browser) scenarios with correct path resolution for nested reporter options. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bb6410e commit da83519

File tree

5 files changed

+367
-24
lines changed

5 files changed

+367
-24
lines changed

lib/workers.js

Lines changed: 190 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ const simplifyObject = object => {
102102
}, {})
103103
}
104104

105-
const createWorkerObjects = (testGroups, config, testRoot, options, selectedRuns) => {
105+
const createWorkerObjects = (testGroups, config, testRoot, options, selectedRuns, workerOutputGroups) => {
106106
selectedRuns = options && options.all && config.multiple ? Object.keys(config.multiple) : selectedRuns
107107
if (selectedRuns === undefined || !selectedRuns.length || config.multiple === undefined) {
108108
return testGroups.map((tests, index) => {
@@ -111,36 +111,23 @@ const createWorkerObjects = (testGroups, config, testRoot, options, selectedRuns
111111
workerObj.addTests(tests)
112112
workerObj.setTestRoot(testRoot)
113113
workerObj.addOptions(options)
114+
if (workerOutputGroups) {
115+
workerOutputGroups.set(index, { outputDir: config.output })
116+
}
114117
return workerObj
115118
})
116119
}
117120
const workersToExecute = []
121+
const workerOutputDirs = []
118122

119123
const currentOutputFolder = config.output
120-
let currentMochawesomeReportDir
121-
let currentMochaJunitReporterFile
122-
123-
if (config.mocha && config.mocha.reporterOptions) {
124-
currentMochawesomeReportDir = config.mocha.reporterOptions?.mochawesome.options.reportDir
125-
currentMochaJunitReporterFile = config.mocha.reporterOptions['mocha-junit-reporter'].options.mochaFile
126-
}
127124

128125
createRuns(selectedRuns, config).forEach(worker => {
129126
const separator = path.sep
130127
const _config = { ...config }
131128
let workerName = worker.name.replace(':', '_')
132129
_config.output = `${currentOutputFolder}${separator}${workerName}`
133-
if (config.mocha && config.mocha.reporterOptions) {
134-
_config.mocha.reporterOptions.mochawesome.options.reportDir = `${currentMochawesomeReportDir}${separator}${workerName}`
135-
136-
const _tempArray = currentMochaJunitReporterFile.split(separator)
137-
_tempArray.splice(
138-
_tempArray.findIndex(item => item.includes('.xml')),
139-
0,
140-
workerName,
141-
)
142-
_config.mocha.reporterOptions['mocha-junit-reporter'].options.mochaFile = _tempArray.join(separator)
143-
}
130+
workerOutputDirs.push(_config.output)
144131
workerName = worker.getOriginalName() || worker.getName()
145132
const workerConfig = worker.getConfig()
146133
workersToExecute.push(getOverridenConfig(workerName, workerConfig, _config))
@@ -149,12 +136,16 @@ const createWorkerObjects = (testGroups, config, testRoot, options, selectedRuns
149136
let index = 0
150137
testGroups.forEach(tests => {
151138
const testWorkerArray = []
152-
workersToExecute.forEach(finalConfig => {
153-
const workerObj = new WorkerObject(index++)
139+
workersToExecute.forEach((finalConfig, runIndex) => {
140+
const workerObj = new WorkerObject(index)
154141
workerObj.addConfig(finalConfig)
155142
workerObj.addTests(tests)
156143
workerObj.setTestRoot(testRoot)
157144
workerObj.addOptions(options)
145+
if (workerOutputGroups) {
146+
workerOutputGroups.set(index, { outputDir: workerOutputDirs[runIndex] })
147+
}
148+
index++
158149
testWorkerArray.push(workerObj)
159150
})
160151
workers.push(...testWorkerArray)
@@ -293,6 +284,9 @@ class Workers extends EventEmitter {
293284
this.isPoolMode = config.by === 'pool'
294285
this.activeWorkers = new Map()
295286
this.maxWorkers = numberOfWorkers // Track original worker count for pool mode
287+
this._savedReporterConfig = null
288+
this._workerOutputGroups = new Map()
289+
this._reporterTests = []
296290

297291
createOutputDir(config.testConfig)
298292
// Defer worker initialization until codecept is ready
@@ -316,7 +310,36 @@ class Workers extends EventEmitter {
316310
this.splitTestsByGroups(numberOfWorkers, config)
317311
// For function-based grouping, use the actual number of test groups created
318312
const actualNumberOfWorkers = isFunction(config.by) ? this.testGroups.length : numberOfWorkers
319-
this.workers = createWorkerObjects(this.testGroups, this.codecept.config, getTestRoot(config.testConfig), config.options, config.selectedRuns)
313+
314+
let codeceptConfig = this.codecept.config
315+
if (codeceptConfig.mocha && codeceptConfig.mocha.reporter) {
316+
const testRoot = getTestRoot(config.testConfig)
317+
const resolvedOutput = path.isAbsolute(codeceptConfig.output) ? codeceptConfig.output : path.join(testRoot, codeceptConfig.output)
318+
const reporterOpts = codeceptConfig.mocha.reporterOptions ? deepClone(codeceptConfig.mocha.reporterOptions) : {}
319+
const pathKeys = ['reportDir', 'output', 'mochaFile', 'stdout']
320+
const resolvePathsDeep = (obj) => {
321+
if (!obj || typeof obj !== 'object') return
322+
for (const key of Object.keys(obj)) {
323+
if (pathKeys.includes(key) && typeof obj[key] === 'string' && !path.isAbsolute(obj[key])) {
324+
obj[key] = path.join(testRoot, obj[key])
325+
} else if (typeof obj[key] === 'object' && obj[key] !== null) {
326+
resolvePathsDeep(obj[key])
327+
}
328+
}
329+
}
330+
resolvePathsDeep(reporterOpts)
331+
this._savedReporterConfig = {
332+
reporter: codeceptConfig.mocha.reporter,
333+
reporterOptions: reporterOpts,
334+
outputDir: resolvedOutput,
335+
}
336+
codeceptConfig = deepClone(codeceptConfig)
337+
codeceptConfig.mocha.reporter = null
338+
codeceptConfig.mocha.reporterOptions = {}
339+
codeceptConfig.output = resolvedOutput
340+
}
341+
342+
this.workers = createWorkerObjects(this.testGroups, codeceptConfig, getTestRoot(config.testConfig), config.options, config.selectedRuns, this._workerOutputGroups)
320343
this.numberOfWorkers = this.workers.length
321344
}
322345

@@ -618,8 +641,16 @@ class Workers extends EventEmitter {
618641
}
619642

620643
if (message.data.tests) {
644+
const outputGroup = this._workerOutputGroups.get(message.workerIndex - 1)
621645
message.data.tests.forEach(test => {
622-
Container.result().addTest(deserializeTest(test))
646+
const deserialized = deserializeTest(test)
647+
if (outputGroup) {
648+
deserialized._outputGroup = outputGroup
649+
}
650+
Container.result().addTest(deserialized)
651+
if (this._savedReporterConfig) {
652+
this._reporterTests.push(deserialized)
653+
}
623654
})
624655
}
625656

@@ -777,7 +808,142 @@ class Workers extends EventEmitter {
777808

778809
this.emit(event.all.result, Container.result())
779810
event.dispatcher.emit(event.workers.result, Container.result())
780-
this.emit('end') // internal event
811+
812+
this._runReporters().then(() => {
813+
this.emit('end')
814+
}).catch(err => {
815+
output.error(`Reporter error: ${err.message}`)
816+
this.emit('end')
817+
})
818+
}
819+
820+
async _runReporters() {
821+
if (!this._savedReporterConfig) return
822+
823+
const { reporter: reporterName, reporterOptions: savedReporterOptions, outputDir: defaultOutputDir } = this._savedReporterConfig
824+
825+
let ReporterClass
826+
try {
827+
if (typeof reporterName === 'function') {
828+
ReporterClass = reporterName
829+
} else {
830+
try {
831+
const mod = await import(reporterName)
832+
ReporterClass = mod.default || mod
833+
} catch {
834+
const { createRequire } = await import('module')
835+
const require = createRequire(import.meta.url)
836+
ReporterClass = require(reporterName)
837+
}
838+
}
839+
} catch (err) {
840+
output.error(`Could not load reporter "${reporterName}": ${err.message}`)
841+
return
842+
}
843+
844+
const createStatsCollector = (await import('mocha/lib/stats-collector.js')).default
845+
const MochaSuite = (await import('mocha/lib/suite.js')).default
846+
const MochaTest = (await import('mocha/lib/test.js')).default
847+
848+
const tests = this._reporterTests
849+
const groups = new Map()
850+
851+
for (const test of tests) {
852+
const groupKey = test._outputGroup?.outputDir || defaultOutputDir
853+
if (!groups.has(groupKey)) {
854+
groups.set(groupKey, { tests: [], outputDir: groupKey })
855+
}
856+
groups.get(groupKey).tests.push(test)
857+
}
858+
859+
for (const [, group] of groups) {
860+
try {
861+
const rootSuite = new MochaSuite('', null, true)
862+
rootSuite.root = true
863+
864+
const suiteMap = new Map()
865+
for (const test of group.tests) {
866+
const suiteTitle = test.parent?.title || 'Suite'
867+
if (!suiteMap.has(suiteTitle)) {
868+
suiteMap.set(suiteTitle, [])
869+
}
870+
suiteMap.get(suiteTitle).push(test)
871+
}
872+
873+
for (const [suiteTitle, suiteTests] of suiteMap) {
874+
const childSuite = MochaSuite.create(rootSuite, suiteTitle)
875+
childSuite.root = false
876+
for (const test of suiteTests) {
877+
const mochaTest = new MochaTest(test.title, () => {})
878+
mochaTest.state = test.state
879+
mochaTest.duration = test.duration || 0
880+
mochaTest.speed = test.duration > 75 ? 'slow' : (test.duration > 37 ? 'medium' : 'fast')
881+
mochaTest.file = test.file
882+
mochaTest.pending = test.state === 'pending' || test.state === 'skipped'
883+
if (test.err) {
884+
const err = new Error(test.err.message || '')
885+
err.stack = test.err.stack || ''
886+
err.name = test.err.name || 'Error'
887+
err.actual = test.err.actual
888+
err.expected = test.err.expected
889+
mochaTest.err = err
890+
}
891+
childSuite.addTest(mochaTest)
892+
}
893+
}
894+
895+
let reporterOptions = savedReporterOptions ? deepClone(savedReporterOptions) : {}
896+
if (group.outputDir && group.outputDir !== defaultOutputDir) {
897+
mkdirp.sync(group.outputDir)
898+
reporterOptions = replaceValueDeep(reporterOptions, 'reportDir', group.outputDir)
899+
reporterOptions = replaceValueDeep(reporterOptions, 'output', group.outputDir)
900+
reporterOptions = replaceValueDeep(reporterOptions, 'mochaFile', path.join(group.outputDir, 'report.xml'))
901+
reporterOptions = replaceValueDeep(reporterOptions, 'stdout', path.join(group.outputDir, 'console.log'))
902+
}
903+
904+
const runner = new EventEmitter()
905+
runner.suite = rootSuite
906+
runner.total = group.tests.length
907+
runner.failures = 0
908+
createStatsCollector(runner)
909+
910+
const cliKey = 'codeceptjs-cli-reporter'
911+
const internalCliKey = 'codeceptjs/lib/mocha/cli'
912+
const filteredOptions = { ...reporterOptions }
913+
delete filteredOptions[cliKey]
914+
delete filteredOptions[internalCliKey]
915+
916+
const reporterInstance = new ReporterClass(runner, { reporterOption: filteredOptions, reporterOptions: filteredOptions })
917+
918+
runner.emit('start')
919+
920+
for (const childSuite of rootSuite.suites) {
921+
runner.emit('suite', childSuite)
922+
for (const test of childSuite.tests) {
923+
runner.emit('test', test)
924+
if (test.pending) {
925+
runner.emit('pending', test)
926+
} else if (test.state === 'passed') {
927+
runner.emit('pass', test)
928+
} else if (test.state === 'failed') {
929+
runner.emit('fail', test, test.err)
930+
}
931+
runner.emit('test end', test)
932+
}
933+
runner.emit('suite end', childSuite)
934+
}
935+
936+
runner.emit('end')
937+
938+
if (typeof reporterInstance.done === 'function') {
939+
await new Promise((resolve) => {
940+
reporterInstance.done(runner.failures || 0, resolve)
941+
})
942+
}
943+
} catch (err) {
944+
output.error(`Reporter error: ${err.message}`)
945+
}
946+
}
781947
}
782948

783949
printResults() {
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export const config = {
2+
tests: './workers/*.js',
3+
timeout: 10000,
4+
output: './output/workers_mochawesome',
5+
helpers: {
6+
FileSystem: {},
7+
Workers: {
8+
require: './workers_helper',
9+
},
10+
},
11+
include: {},
12+
mocha: {
13+
reporter: 'mochawesome',
14+
reporterOptions: {
15+
reportDir: './output/workers_mochawesome',
16+
reportFilename: 'report',
17+
quiet: true,
18+
overwrite: true,
19+
json: true,
20+
html: true,
21+
},
22+
},
23+
name: 'sandbox',
24+
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
export const config = {
2+
tests: './workers/*.js',
3+
timeout: 10000,
4+
output: './output/workers_mochawesome',
5+
helpers: {
6+
FileSystem: {},
7+
Workers: {
8+
require: './workers_helper',
9+
},
10+
},
11+
include: {},
12+
mocha: {
13+
reporter: 'mochawesome',
14+
reporterOptions: {
15+
reportDir: './output/workers_mochawesome',
16+
reportFilename: 'report',
17+
quiet: true,
18+
overwrite: true,
19+
json: true,
20+
html: false,
21+
},
22+
},
23+
multiple: {
24+
parallel: {
25+
browsers: ['chrome', 'firefox'],
26+
},
27+
},
28+
name: 'sandbox',
29+
};

0 commit comments

Comments
 (0)