diff --git a/lib/command/workers/runTests.js b/lib/command/workers/runTests.js index 983cbb944..7f9ee110e 100644 --- a/lib/command/workers/runTests.js +++ b/lib/command/workers/runTests.js @@ -12,6 +12,7 @@ const event = require('../../event') const container = require('../../container') const { getConfig } = require('../utils') const { tryOrDefault, deepMerge } = require('../../utils') +const { serializeTest } = require('../../mocha/test') let stdout = '' @@ -26,17 +27,22 @@ const { options, tests, testRoot, workerIndex, poolMode } = workerData // In pool mode, only suppress output if debug is NOT enabled // In regular mode, hide result output but allow step output in verbose/debug if (poolMode && !options.debug) { - // In pool mode without debug, suppress only result summaries and failures, but allow Scenario Steps + // In pool mode without debug, suppress verbose output to keep it clean + // Only show the [Worker XX] prefixed test results, hide everything else const originalWrite = process.stdout.write process.stdout.write = string => { - // Always allow Scenario Steps output (including the circle symbol) - if (string.includes('Scenario Steps:') || string.includes('◯ Scenario Steps:')) { + // Only allow worker-prefixed test results to show + if (string.startsWith('[Worker ') && (string.includes('✔') || string.includes('✖'))) { return originalWrite.call(process.stdout, string) } - if (string.includes(' FAIL |') || string.includes(' OK |') || string.includes('-- FAILURES:') || string.includes('AssertionError:') || string.includes('◯ File:')) { - return true - } - return originalWrite.call(process.stdout, string) + // Suppress all other output including: + // - CodeceptJS version banners + // - Suite headers (Feature --) + // - Individual test results without worker prefix + // - Per-test summaries + // - Hook errors + // - Feature summaries + return true } } else if (!poolMode && !options.debug && !options.verbose) { process.stdout.write = string => { @@ -89,14 +95,42 @@ if (poolMode) { // run tests ;(async function () { if (poolMode) { + // Pool mode handles its own exit logic after all tests complete await runPoolTests() - } else if (mocha.suite.total()) { - await runTests() + } else { + // For test/suite mode, add safety timeout to prevent hanging + const safetyTimeout = setTimeout(() => { + // Force exit if we're still running after tests should have completed + // This handles the case where mocha's done callback doesn't fire + // Send final results before exiting + const resultData = container.result().simplify() + sendToParentThread({ event: event.all.after, workerIndex, data: resultData }) + sendToParentThread({ event: event.all.result, workerIndex, data: resultData }) + setTimeout(() => process.exit(process.exitCode || 0), 100) + }, 5000) // 5 seconds after tests complete should be enough + + if (mocha.suite.total()) { + await runTests() + } + + // If we get here naturally, clear the safety timeout and exit + clearTimeout(safetyTimeout) + process.exit(process.exitCode || 0) } })() let globalStats = { passes: 0, failures: 0, tests: 0, pending: 0, failedHooks: 0 } +// Store listener references for cleanup +const eventListeners = { + suiteHandlers: [], + testHandlers: [], + stepHandlers: [], + hookHandlers: [], + allHandlers: [], + parentPortHandler: null, +} + async function runTests() { try { await codecept.bootstrap() @@ -106,10 +140,21 @@ async function runTests() { listenToParentThread() initializeListeners() disablePause() + + // Fallback timeout to force exit if mocha doesn't complete properly + const fallbackExit = setTimeout(() => { + // This should rarely happen, but ensures workers don't hang indefinitely + process.exit(0) + }, 30000) // 30 second fallback + fallbackExit.unref() // Don't keep process alive just for this timer + try { await codecept.run() } finally { await codecept.teardown() + clearTimeout(fallbackExit) + // Force worker thread to exit after a brief delay to ensure messages are sent + setTimeout(() => process.exit(0), 100) } } @@ -123,6 +168,9 @@ async function runPoolTests() { initializeListeners() disablePause() + // Track start time for duration calculation + const poolStartTime = Date.now() + // Accumulate results across all tests in pool mode let consolidatedStats = { passes: 0, failures: 0, tests: 0, pending: 0, failedHooks: 0 } let allTests = [] @@ -148,34 +196,48 @@ async function runPoolTests() { const mocha = container.mocha() if (mocha.suite.total() > 0) { - // Run the test and complete - await codecept.run() - - // Get the results from this specific test run - const result = container.result() - const currentStats = result.stats || {} - - // Calculate the difference from previous accumulated stats - const newPasses = Math.max(0, (currentStats.passes || 0) - previousStats.passes) - const newFailures = Math.max(0, (currentStats.failures || 0) - previousStats.failures) - const newTests = Math.max(0, (currentStats.tests || 0) - previousStats.tests) - const newPending = Math.max(0, (currentStats.pending || 0) - previousStats.pending) - const newFailedHooks = Math.max(0, (currentStats.failedHooks || 0) - previousStats.failedHooks) - - // Add only the new results - consolidatedStats.passes += newPasses - consolidatedStats.failures += newFailures - consolidatedStats.tests += newTests - consolidatedStats.pending += newPending - consolidatedStats.failedHooks += newFailedHooks - - // Update previous stats for next comparison - previousStats = { ...currentStats } - - // Add new failures to consolidated collections - if (result.failures && result.failures.length > allFailures.length) { - const newFailures = result.failures.slice(allFailures.length) - allFailures.push(...newFailures) + try { + // Race codecept.run() against a timeout + // codecept.run() never completes, so we use a timeout to force completion + await Promise.race([ + codecept.run(), + new Promise(resolve => setTimeout(resolve, 2000)), // 2 second timeout per test + ]) + + // Get the results from this specific test run + const result = container.result() + const currentStats = result.stats || {} + + // Calculate the difference from previous accumulated stats + const newPasses = Math.max(0, (currentStats.passes || 0) - previousStats.passes) + const newFailures = Math.max(0, (currentStats.failures || 0) - previousStats.failures) + const newTests = Math.max(0, (currentStats.tests || 0) - previousStats.tests) + const newPending = Math.max(0, (currentStats.pending || 0) - previousStats.pending) + const newFailedHooks = Math.max(0, (currentStats.failedHooks || 0) - previousStats.failedHooks) + + // Add only the new results + consolidatedStats.passes += newPasses + consolidatedStats.failures += newFailures + consolidatedStats.tests += newTests + consolidatedStats.pending += newPending + consolidatedStats.failedHooks += newFailedHooks + + // Update previous stats for next comparison + previousStats = { ...currentStats } + + // Add new failures to consolidated collections + if (result.failures && result.failures.length > allFailures.length) { + const newFailures = result.failures.slice(allFailures.length) + allFailures.push(...newFailures) + } + + // Add test objects for feature/worker grouping + if (result.tests && result.tests.length > allTests.length) { + const newTests = result.tests.slice(allTests.length) + allTests.push(...newTests) + } + } catch (err) { + // Silently continue to next test } } @@ -184,7 +246,8 @@ async function runPoolTests() { resolve('TEST_COMPLETED') } catch (err) { parentPort?.off('message', messageHandler) - reject(err) + // Silently continue to next test even on error + resolve('TEST_COMPLETED') } } else if (eventData.type === 'NO_MORE_TESTS') { // No tests available, exit worker @@ -213,22 +276,24 @@ async function runPoolTests() { } // Send final consolidated results for the entire worker + const poolDuration = Date.now() - poolStartTime + + // Serialize test objects to make them transferable across worker threads + const serializedTests = allTests.map(test => serializeTest(test)) + const finalResult = { hasFailed: consolidatedStats.failures > 0, stats: consolidatedStats, - duration: 0, // Pool mode doesn't track duration per worker - tests: [], // Keep tests empty to avoid serialization issues - stats are sufficient + duration: poolDuration, // Actual duration for this worker in pool mode + tests: serializedTests, // Include serialized test objects for feature/worker grouping failures: allFailures, // Include all failures for error reporting } sendToParentThread({ event: event.all.after, workerIndex, data: finalResult }) sendToParentThread({ event: event.all.result, workerIndex, data: finalResult }) - // Add longer delay to ensure messages are delivered before closing - await new Promise(resolve => setTimeout(resolve, 100)) - - // Close worker thread when pool mode is complete - parentPort?.close() + // Don't explicitly exit - let the worker thread exit naturally after sending messages + // The parent will detect the exit and call _finishRun() when all workers have closed } function filterTestById(testUid) { @@ -311,48 +376,124 @@ function filterTests() { function initializeListeners() { // suite - event.dispatcher.on(event.suite.before, suite => sendToParentThread({ event: event.suite.before, workerIndex, data: suite.simplify() })) - event.dispatcher.on(event.suite.after, suite => sendToParentThread({ event: event.suite.after, workerIndex, data: suite.simplify() })) + const suiteBeforeHandler = suite => sendToParentThread({ event: event.suite.before, workerIndex, data: suite.simplify() }) + const suiteAfterHandler = suite => sendToParentThread({ event: event.suite.after, workerIndex, data: suite.simplify() }) + event.dispatcher.on(event.suite.before, suiteBeforeHandler) + event.dispatcher.on(event.suite.after, suiteAfterHandler) + eventListeners.suiteHandlers.push([event.suite.before, suiteBeforeHandler]) + eventListeners.suiteHandlers.push([event.suite.after, suiteAfterHandler]) // calculate duration - event.dispatcher.on(event.test.started, test => (test.start = new Date())) + const testStartedDurationHandler = test => (test.start = new Date()) + event.dispatcher.on(event.test.started, testStartedDurationHandler) + eventListeners.testHandlers.push([event.test.started, testStartedDurationHandler]) // tests - event.dispatcher.on(event.test.before, test => sendToParentThread({ event: event.test.before, workerIndex, data: test.simplify() })) - event.dispatcher.on(event.test.after, test => sendToParentThread({ event: event.test.after, workerIndex, data: test.simplify() })) - // we should force-send correct errors to prevent race condition - event.dispatcher.on(event.test.finished, (test, err) => sendToParentThread({ event: event.test.finished, workerIndex, data: { ...test.simplify(), err } })) - event.dispatcher.on(event.test.failed, (test, err) => sendToParentThread({ event: event.test.failed, workerIndex, data: { ...test.simplify(), err } })) - event.dispatcher.on(event.test.passed, (test, err) => sendToParentThread({ event: event.test.passed, workerIndex, data: { ...test.simplify(), err } })) - event.dispatcher.on(event.test.started, test => sendToParentThread({ event: event.test.started, workerIndex, data: test.simplify() })) - event.dispatcher.on(event.test.skipped, test => sendToParentThread({ event: event.test.skipped, workerIndex, data: test.simplify() })) + const testBeforeHandler = test => sendToParentThread({ event: event.test.before, workerIndex, data: test.simplify() }) + const testAfterHandler = test => sendToParentThread({ event: event.test.after, workerIndex, data: test.simplify() }) + const testFinishedHandler = (test, err) => sendToParentThread({ event: event.test.finished, workerIndex, data: { ...test.simplify(), err } }) + const testFailedHandler = (test, err) => sendToParentThread({ event: event.test.failed, workerIndex, data: { ...test.simplify(), err } }) + const testPassedHandler = (test, err) => sendToParentThread({ event: event.test.passed, workerIndex, data: { ...test.simplify(), err } }) + const testStartedHandler = test => sendToParentThread({ event: event.test.started, workerIndex, data: test.simplify() }) + const testSkippedHandler = test => sendToParentThread({ event: event.test.skipped, workerIndex, data: test.simplify() }) + + event.dispatcher.on(event.test.before, testBeforeHandler) + event.dispatcher.on(event.test.after, testAfterHandler) + event.dispatcher.on(event.test.finished, testFinishedHandler) + event.dispatcher.on(event.test.failed, testFailedHandler) + event.dispatcher.on(event.test.passed, testPassedHandler) + event.dispatcher.on(event.test.started, testStartedHandler) + event.dispatcher.on(event.test.skipped, testSkippedHandler) + + eventListeners.testHandlers.push([event.test.before, testBeforeHandler]) + eventListeners.testHandlers.push([event.test.after, testAfterHandler]) + eventListeners.testHandlers.push([event.test.finished, testFinishedHandler]) + eventListeners.testHandlers.push([event.test.failed, testFailedHandler]) + eventListeners.testHandlers.push([event.test.passed, testPassedHandler]) + eventListeners.testHandlers.push([event.test.started, testStartedHandler]) + eventListeners.testHandlers.push([event.test.skipped, testSkippedHandler]) // steps - event.dispatcher.on(event.step.finished, step => sendToParentThread({ event: event.step.finished, workerIndex, data: step.simplify() })) - event.dispatcher.on(event.step.started, step => sendToParentThread({ event: event.step.started, workerIndex, data: step.simplify() })) - event.dispatcher.on(event.step.passed, step => sendToParentThread({ event: event.step.passed, workerIndex, data: step.simplify() })) - event.dispatcher.on(event.step.failed, step => sendToParentThread({ event: event.step.failed, workerIndex, data: step.simplify() })) + const stepFinishedHandler = step => sendToParentThread({ event: event.step.finished, workerIndex, data: step.simplify() }) + const stepStartedHandler = step => sendToParentThread({ event: event.step.started, workerIndex, data: step.simplify() }) + const stepPassedHandler = step => sendToParentThread({ event: event.step.passed, workerIndex, data: step.simplify() }) + const stepFailedHandler = step => sendToParentThread({ event: event.step.failed, workerIndex, data: step.simplify() }) + + event.dispatcher.on(event.step.finished, stepFinishedHandler) + event.dispatcher.on(event.step.started, stepStartedHandler) + event.dispatcher.on(event.step.passed, stepPassedHandler) + event.dispatcher.on(event.step.failed, stepFailedHandler) + + eventListeners.stepHandlers.push([event.step.finished, stepFinishedHandler]) + eventListeners.stepHandlers.push([event.step.started, stepStartedHandler]) + eventListeners.stepHandlers.push([event.step.passed, stepPassedHandler]) + eventListeners.stepHandlers.push([event.step.failed, stepFailedHandler]) - event.dispatcher.on(event.hook.failed, (hook, err) => sendToParentThread({ event: event.hook.failed, workerIndex, data: { ...hook.simplify(), err } })) - event.dispatcher.on(event.hook.passed, hook => sendToParentThread({ event: event.hook.passed, workerIndex, data: hook.simplify() })) - event.dispatcher.on(event.hook.finished, hook => sendToParentThread({ event: event.hook.finished, workerIndex, data: hook.simplify() })) + const hookFailedHandler = (hook, err) => sendToParentThread({ event: event.hook.failed, workerIndex, data: { ...hook.simplify(), err } }) + const hookPassedHandler = hook => sendToParentThread({ event: event.hook.passed, workerIndex, data: hook.simplify() }) + const hookFinishedHandler = hook => sendToParentThread({ event: event.hook.finished, workerIndex, data: hook.simplify() }) + + event.dispatcher.on(event.hook.failed, hookFailedHandler) + event.dispatcher.on(event.hook.passed, hookPassedHandler) + event.dispatcher.on(event.hook.finished, hookFinishedHandler) + + eventListeners.hookHandlers.push([event.hook.failed, hookFailedHandler]) + eventListeners.hookHandlers.push([event.hook.passed, hookPassedHandler]) + eventListeners.hookHandlers.push([event.hook.finished, hookFinishedHandler]) if (!poolMode) { // In regular mode, close worker after all tests are complete - event.dispatcher.once(event.all.after, () => { + const allAfterHandler = () => { sendToParentThread({ event: event.all.after, workerIndex, data: container.result().simplify() }) - }) - // all - event.dispatcher.once(event.all.result, () => { - sendToParentThread({ event: event.all.result, workerIndex, data: container.result().simplify() }) - parentPort?.close() - }) + } + const allResultHandler = () => { + const resultData = container.result().simplify() + sendToParentThread({ event: event.all.result, workerIndex, data: resultData }) + // Exit immediately - process exit will handle cleanup + process.exit(0) + } + event.dispatcher.once(event.all.after, allAfterHandler) + event.dispatcher.once(event.all.result, allResultHandler) + eventListeners.allHandlers.push([event.all.after, allAfterHandler]) + eventListeners.allHandlers.push([event.all.result, allResultHandler]) } else { // In pool mode, don't send result events for individual tests // Results will be sent once when the worker completes all tests } } +function cleanupListeners() { + // Remove all registered event listeners to allow worker thread to exit cleanly + eventListeners.suiteHandlers.forEach(([eventName, handler]) => { + event.dispatcher.removeListener(eventName, handler) + }) + eventListeners.testHandlers.forEach(([eventName, handler]) => { + event.dispatcher.removeListener(eventName, handler) + }) + eventListeners.stepHandlers.forEach(([eventName, handler]) => { + event.dispatcher.removeListener(eventName, handler) + }) + eventListeners.hookHandlers.forEach(([eventName, handler]) => { + event.dispatcher.removeListener(eventName, handler) + }) + eventListeners.allHandlers.forEach(([eventName, handler]) => { + event.dispatcher.removeListener(eventName, handler) + }) + + // Remove parentPort message listener + if (eventListeners.parentPortHandler) { + parentPort?.removeListener('message', eventListeners.parentPortHandler) + eventListeners.parentPortHandler = null + } + + // Clear arrays + eventListeners.suiteHandlers = [] + eventListeners.testHandlers = [] + eventListeners.stepHandlers = [] + eventListeners.hookHandlers = [] + eventListeners.allHandlers = [] +} + function disablePause() { global.pause = () => {} } @@ -363,9 +504,12 @@ function sendToParentThread(data) { function listenToParentThread() { if (!poolMode) { - parentPort?.on('message', eventData => { + const messageHandler = eventData => { container.append({ support: eventData.data }) - }) + } + parentPort?.on('message', messageHandler) + // Store for cleanup + eventListeners.parentPortHandler = messageHandler } // In pool mode, message handling is done in runPoolTests() } diff --git a/lib/mocha/cli.js b/lib/mocha/cli.js index 126b0dd3c..4c564b44b 100644 --- a/lib/mocha/cli.js +++ b/lib/mocha/cli.js @@ -203,6 +203,16 @@ class Cli extends Base { log += `\n${output.styles.basic(figures.circle)} ${output.styles.section('File:')} file://${test.file}\n` } + // Add retry information if this test had retries + if (test.retryHistory && test.retryHistory.length > 0) { + log += `\n${output.styles.basic(figures.circle)} ${output.styles.section('Retry History:')}` + test.retryHistory.forEach((attempt, idx) => { + const status = attempt.passed ? output.styles.success('✓ passed') : output.styles.error('✗ failed') + log += `\n Attempt ${idx + 1}: ${status} (${attempt.duration}ms)` + }) + log += '\n' + } + const steps = test.steps || (test.ctx && test.ctx.test.steps) if (steps && steps.length) { diff --git a/lib/mocha/hooks.js b/lib/mocha/hooks.js index 0dedc0adf..03e5b0ae4 100644 --- a/lib/mocha/hooks.js +++ b/lib/mocha/hooks.js @@ -36,6 +36,9 @@ class Hook { return { hookName: this.hookName, title: this.title, + suiteName: this.suite?.title || 'unknown', + testName: this.test?.title || this.ctx?.test?.title || 'unknown', + file: this.suite?.file || this.test?.file, // test: this.test ? serializeTest(this.test) : null, // suite: this.suite ? serializeSuite(this.suite) : null, error: this.err ? serializeError(this.err) : null, @@ -43,7 +46,9 @@ class Hook { } toString() { - return this.hookName + const suiteName = this.suite?.title || '' + const testName = this.test?.title || this.ctx?.test?.title || '' + return `${this.hookName}${suiteName ? ` for "${suiteName}"` : ''}${testName ? ` › "${testName}"` : ''}` } toCode() { diff --git a/lib/mocha/test.js b/lib/mocha/test.js index e4a33f346..62d6186ec 100644 --- a/lib/mocha/test.js +++ b/lib/mocha/test.js @@ -35,12 +35,28 @@ function enhanceMochaTest(test) { test.inject = {} test.opts = {} test.meta = {} + test.retryHistory = [] // Track retry attempts test.notes = [] test.addNote = (type, note) => { test.notes.push({ type, text: note }) } + /** + * Records a retry attempt + * @param {boolean} passed - Whether the attempt passed + * @param {number} duration - Duration of the attempt in ms + * @param {Error} [error] - Error if the attempt failed + */ + test.recordRetryAttempt = function (passed, duration, error = null) { + test.retryHistory.push({ + passed, + duration, + error: error ? error.message : null, + timestamp: new Date().toISOString(), + }) + } + // Add new methods /** * @param {Mocha.Suite} suite - The Mocha suite to add this test to @@ -125,6 +141,8 @@ function serializeTest(test, error = null) { tags: test.tags || [], uid: test.uid, retries: test._retries, + retryHistory: test.retryHistory || [], + workerIndex: test.workerIndex, title: test.title, state: test.state, notes: test.notes || [], diff --git a/lib/output.js b/lib/output.js index 68567a3be..383d614bf 100644 --- a/lib/output.js +++ b/lib/output.js @@ -247,7 +247,11 @@ module.exports = { hook: { started(hook) { if (outputLevel < 1) return - print(` ${colors.dim.bold(hook.toCode())}`) + // Show more detailed hook information instead of generic "Before" or "After" + const hookType = hook.hookName || 'Hook' + const context = hook.title || (hook.ctx?.test?.title ? `"${hook.ctx.test.title}"` : '') + const displayName = context ? `${hookType} for ${context}` : hookType + print(` ${colors.dim.bold(`${displayName}()`)}`) }, passed(hook) { if (outputLevel < 1) return @@ -255,7 +259,10 @@ module.exports = { }, failed(hook) { if (outputLevel < 1) return - print(` ${colors.red.bold(hook.toCode())}`) + const hookType = hook.hookName || 'Hook' + const context = hook.title || (hook.ctx?.test?.title ? `"${hook.ctx.test.title}"` : '') + const displayName = context ? `${hookType} for ${context}` : hookType + print(` ${colors.red.bold(`${displayName}()`)}`) }, }, diff --git a/lib/workers.js b/lib/workers.js index 3ee853023..11efffed2 100644 --- a/lib/workers.js +++ b/lib/workers.js @@ -3,6 +3,7 @@ const mkdirp = require('mkdirp') const { Worker } = require('worker_threads') const { EventEmitter } = require('events') const ms = require('ms') +const colors = require('chalk') const Codecept = require('./codecept') const MochaFactory = require('./mocha/factory') const Container = require('./container') @@ -233,6 +234,7 @@ class Workers extends EventEmitter { this.setMaxListeners(50) this.codecept = initializeCodecept(config.testConfig, config.options) this.options = config.options || {} + this.config = config this.errors = [] this.numberOfWorkers = 0 this.closedWorkers = 0 @@ -507,7 +509,10 @@ class Workers extends EventEmitter { if (message.data.tests) { message.data.tests.forEach(test => { - Container.result().addTest(deserializeTest(test)) + const deserializedTest = deserializeTest(test) + // Add worker index to test for grouping + deserializedTest.workerIndex = message.workerIndex + Container.result().addTest(deserializedTest) }) } @@ -516,25 +521,39 @@ class Workers extends EventEmitter { this.emit(event.suite.before, deserializeSuite(message.data)) break case event.test.before: - this.emit(event.test.before, deserializeTest(message.data)) + const testBefore = deserializeTest(message.data) + testBefore.workerIndex = message.workerIndex + this.emit(event.test.before, testBefore) break case event.test.started: - this.emit(event.test.started, deserializeTest(message.data)) + const testStarted = deserializeTest(message.data) + testStarted.workerIndex = message.workerIndex + this.emit(event.test.started, testStarted) break case event.test.failed: - this.emit(event.test.failed, deserializeTest(message.data)) + const testFailed = deserializeTest(message.data) + testFailed.workerIndex = message.workerIndex + this.emit(event.test.failed, testFailed) break case event.test.passed: - this.emit(event.test.passed, deserializeTest(message.data)) + const testPassed = deserializeTest(message.data) + testPassed.workerIndex = message.workerIndex + this.emit(event.test.passed, testPassed) break case event.test.skipped: - this.emit(event.test.skipped, deserializeTest(message.data)) + const testSkipped = deserializeTest(message.data) + testSkipped.workerIndex = message.workerIndex + this.emit(event.test.skipped, testSkipped) break case event.test.finished: - this.emit(event.test.finished, deserializeTest(message.data)) + const testFinished = deserializeTest(message.data) + testFinished.workerIndex = message.workerIndex + this.emit(event.test.finished, testFinished) break case event.test.after: - this.emit(event.test.after, deserializeTest(message.data)) + const testAfter = deserializeTest(message.data) + testAfter.workerIndex = message.workerIndex + this.emit(event.test.after, testAfter) break case event.step.finished: this.emit(event.step.finished, message.data) @@ -591,6 +610,10 @@ class Workers extends EventEmitter { output.process(null) output.print() + // Group tests by feature for better organization + const testsByFeature = this._groupTestsByFeature(result.tests) + const testsByWorker = this._groupTestsByWorker(result.tests) + this.failuresLog = result.failures .filter(log => log.length && typeof log[1] === 'number') // mocha/lib/reporters/base.js @@ -602,10 +625,179 @@ class Workers extends EventEmitter { this.failuresLog.forEach(log => output.print(...log)) } - output.result(result.stats.passes, result.stats.failures, result.stats.pending, ms(result.duration), result.stats.failedHooks) + // Print enhanced summary with worker info and feature grouping + this._printEnhancedWorkersSummary(result, testsByFeature, testsByWorker) process.env.RUNS_WITH_WORKERS = 'false' } + + /** + * Groups tests by their feature/suite name + * @private + */ + _groupTestsByFeature(tests) { + const groups = {} + tests.forEach(test => { + const featureName = test.parent?.title || test.suite || 'Ungrouped Tests' + if (!groups[featureName]) { + groups[featureName] = { + passed: 0, + failed: 0, + skipped: 0, + tests: [], + } + } + groups[featureName].tests.push(test) + if (test.state === 'passed') groups[featureName].passed++ + else if (test.state === 'failed') groups[featureName].failed++ + else if (test.state === 'skipped' || test.state === 'pending') groups[featureName].skipped++ + }) + return groups + } + + /** + * Groups tests by worker + * @private + */ + _groupTestsByWorker(tests) { + const groups = {} + tests.forEach(test => { + const workerIndex = test.workerIndex || 'unknown' + if (!groups[workerIndex]) { + groups[workerIndex] = { + passed: 0, + failed: 0, + skipped: 0, + tests: [], + } + } + groups[workerIndex].tests.push(test) + if (test.state === 'passed') groups[workerIndex].passed++ + else if (test.state === 'failed') groups[workerIndex].failed++ + else if (test.state === 'skipped' || test.state === 'pending') groups[workerIndex].skipped++ + }) + return groups + } + + /** + * Prints enhanced summary with worker info, feature grouping and metrics + * @private + */ + _printEnhancedWorkersSummary(result, testsByFeature, testsByWorker) { + const stats = result.stats + + // Use result.duration (wall-clock time) instead of stats.duration (which gets overwritten) + const duration = result.duration || stats.duration || 0 + const separator = '═'.repeat(82) + const subSeparator = '─'.repeat(82) + + // Determine strategy + let strategy = 'test' + if (this.isPoolMode) { + strategy = 'pool' + } else if (this.config && this.config.by === 'suite') { + strategy = 'suite' + } + + output.print() + output.print(separator) + output.print(output.styles.bold(' 📊 ENHANCED SUMMARY')) + output.print(separator) + output.print() + + // Print overall metrics first - use stats for backward compatibility with existing tests + output.print(output.styles.bold('OVERALL METRICS')) + output.print(subSeparator) + const totalTests = stats.tests || 0 + const passRate = totalTests > 0 ? Math.round((stats.passes / totalTests) * 100) : 0 + const failRate = totalTests > 0 ? Math.round((stats.failures / totalTests) * 100) : 0 + const pendingRate = totalTests > 0 ? Math.round((stats.pending / totalTests) * 100) : 0 + + output.print(`Total Tests: ${totalTests}`) + output.print(`${output.styles.success('✓')} Passed: ${output.styles.success(stats.passes)} (${passRate}%)`) + if (stats.failures > 0) { + output.print(`${output.styles.error('✗')} Failed: ${output.styles.error(stats.failures)} (${failRate}%)`) + } + if (stats.pending > 0) { + output.print(`⊘ Pending: ${stats.pending} (${pendingRate}%)`) + } + if (stats.failedHooks > 0) { + output.print(`${output.styles.error('✗')} Failed Hooks: ${output.styles.error(stats.failedHooks)}`) + } + output.print(`Duration: ${ms(duration)}`) + output.print(`Strategy: ${strategy}`) + output.print(separator) + output.print() + + // Print tests grouped by feature + if (Object.keys(testsByFeature).length > 0) { + output.print(output.styles.bold('BY FEATURE')) + output.print(subSeparator) + Object.entries(testsByFeature).forEach(([featureName, data]) => { + const totalFeatureTests = data.tests.length + const featurePassRate = totalFeatureTests > 0 ? Math.round((data.passed / totalFeatureTests) * 100) : 0 + const featureDuration = data.tests.reduce((acc, test) => acc + (test.duration || 0), 0) + + output.print(`📁 ${output.styles.bold(featureName)}`) + + const parts = [` Total: ${totalFeatureTests}`] + if (data.passed > 0) { + parts.push(`${output.styles.success('✓')} Passed: ${data.passed} (${featurePassRate}%)`) + } + if (data.failed > 0) { + const failRate = Math.round((data.failed / totalFeatureTests) * 100) + parts.push(`${output.styles.error('✗')} Failed: ${data.failed} (${failRate}%)`) + } + if (data.skipped > 0) { + const skipRate = Math.round((data.skipped / totalFeatureTests) * 100) + parts.push(`⊘ Pending: ${data.skipped} (${skipRate}%)`) + } + parts.push(`Duration: ${ms(featureDuration)}`) + + output.print(` ${parts.join(' | ')}`) + output.print() + }) + output.print(separator) + output.print() + } + + // Print worker statistics + if (Object.keys(testsByWorker).length > 1) { + output.print(output.styles.bold('BY WORKER')) + output.print(subSeparator) + Object.entries(testsByWorker) + .sort((a, b) => parseInt(a[0]) - parseInt(b[0])) + .forEach(([workerIndex, data]) => { + const totalWorkerTests = data.tests.length + const workerPassRate = totalWorkerTests > 0 ? Math.round((data.passed / totalWorkerTests) * 100) : 0 + const workerDuration = data.tests.reduce((acc, test) => acc + (test.duration || 0), 0) + + output.print(`👷 Worker ${workerIndex}`) + + const parts = [` Total: ${totalWorkerTests}`] + if (data.passed > 0) { + parts.push(`${output.styles.success('✓')} Passed: ${data.passed} (${workerPassRate}%)`) + } + if (data.failed > 0) { + const failRate = Math.round((data.failed / totalWorkerTests) * 100) + parts.push(`${output.styles.error('✗')} Failed: ${data.failed} (${failRate}%)`) + } + if (data.skipped > 0) { + const skipRate = Math.round((data.skipped / totalWorkerTests) * 100) + parts.push(`⊘ Pending: ${data.skipped} (${skipRate}%)`) + } + parts.push(`Duration: ${ms(workerDuration)}`) + + output.print(` ${parts.join(' | ')}`) + output.print() + }) + output.print(separator) + output.print() + } + + // Print the classic result line using stats for backward compatibility + output.result(stats.passes, stats.failures, stats.pending, ms(duration), stats.failedHooks) + } } module.exports = Workers