@@ -246,7 +246,6 @@ class Workers extends EventEmitter {
246
246
this . numberOfWorkersRequested = numberOfWorkers
247
247
// Track emitted pass events to avoid double-counting duplicates from retries/race conditions
248
248
this . _passedUids = new Set ( )
249
- this . _pendingPass = new Set ( )
250
249
251
250
createOutputDir ( config . testConfig )
252
251
// Defer worker initialization until codecept is ready
@@ -255,14 +254,24 @@ class Workers extends EventEmitter {
255
254
async _ensureInitialized ( ) {
256
255
if ( ! this . codecept ) {
257
256
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 ) {
259
266
this . _initWorkers ( this . numberOfWorkersRequested , this . config )
260
267
}
261
268
}
262
269
}
263
270
264
271
_initWorkers ( numberOfWorkers , config ) {
265
272
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
266
275
this . workers = createWorkerObjects ( this . testGroups , this . codecept . config , config . testConfig , config . options , config . selectedRuns )
267
276
this . numberOfWorkers = this . workers . length
268
277
}
@@ -301,10 +310,6 @@ class Workers extends EventEmitter {
301
310
*/
302
311
spawn ( ) {
303
312
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
- }
308
313
this . workers . push ( worker )
309
314
this . numberOfWorkers += 1
310
315
return worker
@@ -434,26 +439,43 @@ class Workers extends EventEmitter {
434
439
this . emit ( event . test . started , deserializeTest ( message . data ) )
435
440
break
436
441
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
439
443
break
440
444
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
443
446
break
444
447
case event . test . skipped :
445
448
this . emit ( event . test . skipped , deserializeTest ( message . data ) )
446
449
break
447
450
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
+ }
453
475
}
476
+
477
+ this . emit ( event . test . finished , deserializeTest ( data ) )
454
478
}
455
- if ( message ?. data ?. uid ) this . _pendingPass . delete ( message . data . uid )
456
- this . emit ( event . test . finished , deserializeTest ( message . data ) )
457
479
break
458
480
case event . test . after :
459
481
this . emit ( event . test . after , deserializeTest ( message . data ) )
@@ -470,6 +492,11 @@ class Workers extends EventEmitter {
470
492
case event . step . failed :
471
493
this . emit ( event . step . failed , message . data , message . data . error )
472
494
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
473
500
}
474
501
} )
475
502
@@ -493,6 +520,38 @@ class Workers extends EventEmitter {
493
520
process . exitCode = 0
494
521
}
495
522
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
+
496
555
this . emit ( event . all . result , Container . result ( ) )
497
556
event . dispatcher . emit ( event . workers . result , Container . result ( ) )
498
557
this . emit ( 'end' ) // internal event
@@ -517,7 +576,7 @@ class Workers extends EventEmitter {
517
576
this . failuresLog . forEach ( log => output . print ( ...log ) )
518
577
}
519
578
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 )
521
580
522
581
process . env . RUNS_WITH_WORKERS = 'false'
523
582
}
0 commit comments