@@ -246,7 +246,6 @@ class Workers extends EventEmitter {
246246 this . numberOfWorkersRequested = numberOfWorkers
247247 // Track emitted pass events to avoid double-counting duplicates from retries/race conditions
248248 this . _passedUids = new Set ( )
249- this . _pendingPass = new Set ( )
250249
251250 createOutputDir ( config . testConfig )
252251 // Defer worker initialization until codecept is ready
@@ -255,14 +254,24 @@ class Workers extends EventEmitter {
255254 async _ensureInitialized ( ) {
256255 if ( ! this . codecept ) {
257256 this . codecept = await this . codeceptPromise
258- if ( typeof this . numberOfWorkersRequested === 'number' && this . numberOfWorkersRequested > 0 ) {
257+ // Initialize workers in these cases:
258+ // 1. Positive number requested AND no manual workers pre-spawned
259+ // 2. Function-based grouping (indicated by negative number) AND no manual workers pre-spawned
260+ const shouldAutoInit = this . workers . length === 0 && (
261+ ( Number . isInteger ( this . numberOfWorkersRequested ) && this . numberOfWorkersRequested > 0 ) ||
262+ ( this . numberOfWorkersRequested < 0 && isFunction ( this . config . by ) )
263+ )
264+
265+ if ( shouldAutoInit ) {
259266 this . _initWorkers ( this . numberOfWorkersRequested , this . config )
260267 }
261268 }
262269 }
263270
264271 _initWorkers ( numberOfWorkers , config ) {
265272 this . splitTestsByGroups ( numberOfWorkers , config )
273+ // For function-based grouping, use the actual number of test groups created
274+ const actualNumberOfWorkers = isFunction ( config . by ) ? this . testGroups . length : numberOfWorkers
266275 this . workers = createWorkerObjects ( this . testGroups , this . codecept . config , config . testConfig , config . options , config . selectedRuns )
267276 this . numberOfWorkers = this . workers . length
268277 }
@@ -301,10 +310,6 @@ class Workers extends EventEmitter {
301310 */
302311 spawn ( ) {
303312 const worker = new WorkerObject ( this . numberOfWorkers )
304- // Default testRoot to the configured testConfig location for manual spawns
305- if ( this . config ?. testConfig ) {
306- worker . setTestRoot ( this . config . testConfig )
307- }
308313 this . workers . push ( worker )
309314 this . numberOfWorkers += 1
310315 return worker
@@ -434,26 +439,43 @@ class Workers extends EventEmitter {
434439 this . emit ( event . test . started , deserializeTest ( message . data ) )
435440 break
436441 case event . test . failed :
437- if ( message ?. data ?. uid ) this . _pendingPass . delete ( message . data . uid )
438- this . emit ( event . test . failed , deserializeTest ( message . data ) )
442+ // Skip individual failed events - we'll emit based on finished state
439443 break
440444 case event . test . passed :
441- // Buffer pass until finished to avoid counting tests that will fail later
442- if ( message ?. data ?. uid ) this . _pendingPass . add ( message . data . uid )
445+ // Skip individual passed events - we'll emit based on finished state
443446 break
444447 case event . test . skipped :
445448 this . emit ( event . test . skipped , deserializeTest ( message . data ) )
446449 break
447450 case event . test . finished :
448- // Emit a deduped 'passed' only if it was pending and no error provided
449- if ( message ?. data ?. uid && ! message ?. data ?. err ) {
450- if ( ! this . _passedUids . has ( message . data . uid ) && this . _pendingPass . has ( message . data . uid ) ) {
451- this . _passedUids . add ( message . data . uid )
452- this . emit ( event . test . passed , deserializeTest ( message . data ) )
451+ // Handle different types of test completion properly
452+ {
453+ const data = message . data
454+ const uid = data ?. uid
455+ const isFailed = ! ! data ?. err || data ?. state === 'failed'
456+
457+ if ( uid ) {
458+ // Track states for each test UID
459+ if ( ! this . _testStates ) this . _testStates = new Map ( )
460+
461+ if ( ! this . _testStates . has ( uid ) ) {
462+ this . _testStates . set ( uid , { states : [ ] , lastData : data } )
463+ }
464+
465+ const testState = this . _testStates . get ( uid )
466+ testState . states . push ( { isFailed, data } )
467+ testState . lastData = data
468+ } else {
469+ // For tests without UID, emit immediately
470+ if ( isFailed ) {
471+ this . emit ( event . test . failed , deserializeTest ( data ) )
472+ } else {
473+ this . emit ( event . test . passed , deserializeTest ( data ) )
474+ }
453475 }
476+
477+ this . emit ( event . test . finished , deserializeTest ( data ) )
454478 }
455- if ( message ?. data ?. uid ) this . _pendingPass . delete ( message . data . uid )
456- this . emit ( event . test . finished , deserializeTest ( message . data ) )
457479 break
458480 case event . test . after :
459481 this . emit ( event . test . after , deserializeTest ( message . data ) )
@@ -470,6 +492,11 @@ class Workers extends EventEmitter {
470492 case event . step . failed :
471493 this . emit ( event . step . failed , message . data , message . data . error )
472494 break
495+ case event . hook . failed :
496+ // Count hook failures as test failures for event counting
497+ this . emit ( event . test . failed , { title : `Hook failure: ${ message . data . hookName || 'unknown' } ` , err : message . data . error } )
498+ this . emit ( event . hook . failed , message . data )
499+ break
473500 }
474501 } )
475502
@@ -493,6 +520,38 @@ class Workers extends EventEmitter {
493520 process . exitCode = 0
494521 }
495522
523+ // Emit states for all tracked tests before emitting results
524+ if ( this . _testStates ) {
525+ for ( const [ uid , { states, lastData } ] of this . _testStates ) {
526+ // For tests with retries configured, emit all failures + final success
527+ // For tests without retries, emit only final state
528+ const lastState = states [ states . length - 1 ]
529+
530+ // Check if this test had retries by looking for failure followed by success
531+ const hasRetryPattern = states . length > 1 &&
532+ states . some ( ( s , i ) => s . isFailed && i < states . length - 1 && ! states [ i + 1 ] . isFailed )
533+
534+ if ( hasRetryPattern ) {
535+ // Emit all intermediate failures and final success for retries
536+ for ( const state of states ) {
537+ if ( state . isFailed ) {
538+ this . emit ( event . test . failed , deserializeTest ( state . data ) )
539+ } else {
540+ this . emit ( event . test . passed , deserializeTest ( state . data ) )
541+ }
542+ }
543+ } else {
544+ // For non-retries (like step failures), emit only the final state
545+ if ( lastState . isFailed ) {
546+ this . emit ( event . test . failed , deserializeTest ( lastState . data ) )
547+ } else {
548+ this . emit ( event . test . passed , deserializeTest ( lastState . data ) )
549+ }
550+ }
551+ }
552+ this . _testStates . clear ( )
553+ }
554+
496555 this . emit ( event . all . result , Container . result ( ) )
497556 event . dispatcher . emit ( event . workers . result , Container . result ( ) )
498557 this . emit ( 'end' ) // internal event
@@ -517,7 +576,7 @@ class Workers extends EventEmitter {
517576 this . failuresLog . forEach ( log => output . print ( ...log ) )
518577 }
519578
520- output . result ( result . stats . passes , result . stats . failures , result . stats . pending , ms ( result . duration ) , result . stats . failedHooks )
579+ output . result ( result . stats ? .passes || 0 , result . stats ? .failures || 0 , result . stats ? .pending || 0 , ms ( result . duration ) , result . stats ? .failedHooks || 0 )
521580
522581 process . env . RUNS_WITH_WORKERS = 'false'
523582 }
0 commit comments