Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
288 changes: 216 additions & 72 deletions lib/command/workers/runTests.js

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions lib/mocha/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
7 changes: 6 additions & 1 deletion lib/mocha/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,19 @@ 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,
}
}

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() {
Expand Down
18 changes: 18 additions & 0 deletions lib/mocha/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 || [],
Expand Down
11 changes: 9 additions & 2 deletions lib/output.js
Original file line number Diff line number Diff line change
Expand Up @@ -247,15 +247,22 @@ 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
print()
},
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}()`)}`)
},
},

Expand Down
210 changes: 201 additions & 9 deletions lib/workers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
})
}

Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Loading