@mutineerjs/mutineer 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +52 -47
  2. package/dist/__tests__/index.spec.js +8 -0
  3. package/dist/bin/__tests__/mutineer.spec.js +7 -7
  4. package/dist/bin/mutineer.d.ts +1 -1
  5. package/dist/bin/mutineer.js +7 -4
  6. package/dist/core/__tests__/schemata.spec.js +62 -0
  7. package/dist/core/__tests__/sfc.spec.js +41 -1
  8. package/dist/core/schemata.js +15 -21
  9. package/dist/core/sfc.js +0 -4
  10. package/dist/core/variant-utils.js +0 -4
  11. package/dist/mutators/__tests__/utils.spec.js +65 -1
  12. package/dist/mutators/operator.js +13 -27
  13. package/dist/mutators/return-value.js +3 -7
  14. package/dist/mutators/utils.d.ts +2 -2
  15. package/dist/mutators/utils.js +59 -96
  16. package/dist/runner/__tests__/args.spec.js +8 -4
  17. package/dist/runner/__tests__/cache.spec.js +24 -0
  18. package/dist/runner/__tests__/changed.spec.js +75 -0
  19. package/dist/runner/__tests__/config.spec.js +50 -1
  20. package/dist/runner/__tests__/coverage-resolver.spec.js +88 -1
  21. package/dist/runner/__tests__/discover.spec.js +179 -0
  22. package/dist/runner/__tests__/orchestrator.spec.js +336 -11
  23. package/dist/runner/__tests__/pool-executor.spec.js +77 -0
  24. package/dist/runner/__tests__/ts-checker-worker.spec.d.ts +1 -0
  25. package/dist/runner/__tests__/ts-checker-worker.spec.js +66 -0
  26. package/dist/runner/__tests__/ts-checker.spec.js +89 -2
  27. package/dist/runner/args.d.ts +1 -1
  28. package/dist/runner/args.js +2 -2
  29. package/dist/runner/config.js +3 -4
  30. package/dist/runner/coverage-resolver.js +2 -1
  31. package/dist/runner/discover.js +2 -2
  32. package/dist/runner/jest/__tests__/adapter.spec.js +169 -0
  33. package/dist/runner/jest/__tests__/pool.spec.js +223 -1
  34. package/dist/runner/jest/adapter.js +3 -45
  35. package/dist/runner/jest/pool.js +4 -10
  36. package/dist/runner/jest/worker-runtime.js +2 -1
  37. package/dist/runner/orchestrator.js +8 -7
  38. package/dist/runner/pool-executor.js +7 -12
  39. package/dist/runner/shared/__tests__/strip-mutineer-args.spec.d.ts +1 -0
  40. package/dist/runner/shared/__tests__/strip-mutineer-args.spec.js +104 -0
  41. package/dist/runner/shared/__tests__/worker-script.spec.d.ts +1 -0
  42. package/dist/runner/shared/__tests__/worker-script.spec.js +32 -0
  43. package/dist/runner/shared/index.d.ts +4 -0
  44. package/dist/runner/shared/index.js +2 -0
  45. package/dist/runner/shared/pending-task.d.ts +9 -0
  46. package/dist/runner/shared/pending-task.js +1 -0
  47. package/dist/runner/shared/strip-mutineer-args.d.ts +11 -0
  48. package/dist/runner/shared/strip-mutineer-args.js +47 -0
  49. package/dist/runner/shared/worker-script.d.ts +5 -0
  50. package/dist/runner/shared/worker-script.js +12 -0
  51. package/dist/runner/ts-checker-worker.d.ts +10 -1
  52. package/dist/runner/ts-checker-worker.js +27 -25
  53. package/dist/runner/ts-checker.d.ts +6 -0
  54. package/dist/runner/ts-checker.js +1 -1
  55. package/dist/runner/vitest/__tests__/adapter.spec.js +294 -0
  56. package/dist/runner/vitest/__tests__/plugin.spec.js +28 -1
  57. package/dist/runner/vitest/__tests__/pool.spec.js +711 -0
  58. package/dist/runner/vitest/__tests__/redirect-loader.spec.js +116 -1
  59. package/dist/runner/vitest/__tests__/worker-runtime.spec.js +81 -0
  60. package/dist/runner/vitest/adapter.js +14 -46
  61. package/dist/runner/vitest/plugin.js +1 -7
  62. package/dist/runner/vitest/pool.js +6 -19
  63. package/dist/runner/vitest/redirect-loader.js +3 -1
  64. package/dist/runner/vitest/worker-runtime.js +16 -1
  65. package/dist/runner/vitest/worker.mjs +1 -0
  66. package/dist/types/config.d.ts +2 -2
  67. package/dist/types/mutant.d.ts +3 -0
  68. package/dist/utils/__tests__/PoolSpinner.spec.d.ts +1 -0
  69. package/dist/utils/__tests__/PoolSpinner.spec.js +15 -0
  70. package/dist/utils/__tests__/coverage.spec.js +89 -0
  71. package/dist/utils/__tests__/logger.spec.js +9 -0
  72. package/dist/utils/__tests__/progress.spec.js +38 -0
  73. package/dist/utils/__tests__/summary.spec.js +70 -31
  74. package/dist/utils/coverage.js +3 -4
  75. package/dist/utils/errors.d.ts +4 -0
  76. package/dist/utils/errors.js +6 -0
  77. package/dist/utils/summary.d.ts +2 -3
  78. package/dist/utils/summary.js +5 -6
  79. package/package.json +1 -1
  80. package/dist/utils/CompileErrors.d.ts +0 -7
  81. package/dist/utils/CompileErrors.js +0 -24
  82. package/dist/utils/__tests__/CompileErrors.spec.js +0 -96
  83. /package/dist/{utils/__tests__/CompileErrors.spec.d.ts → __tests__/index.spec.d.ts} +0 -0
@@ -245,4 +245,715 @@ describe('VitestPool', () => {
245
245
  expect(killSpy).toHaveBeenCalledWith(-42000, 'SIGKILL');
246
246
  killSpy.mockRestore();
247
247
  });
248
+ /** Helper: create pool, init, emit ready, return {pool, rl} */
249
+ async function initPool(concurrency = 1, opts = {}) {
250
+ const pool = new VitestPool({ cwd: '/test', concurrency, ...opts });
251
+ const initPromise = pool.init();
252
+ await new Promise((r) => setImmediate(r));
253
+ rlEmitters[0].emit('line', JSON.stringify({ type: 'ready', workerId: 'w0' }));
254
+ await initPromise;
255
+ return pool;
256
+ }
257
+ /** Helper: shutdown pool and emit ack so it resolves immediately */
258
+ async function shutdownPool(pool) {
259
+ const p = pool.shutdown();
260
+ await new Promise((r) => setImmediate(r));
261
+ for (const rl of rlEmitters) {
262
+ rl.emit('line', JSON.stringify({ type: 'shutdown', ok: true }));
263
+ }
264
+ await p;
265
+ }
266
+ it('handleMessage: ignores empty lines', async () => {
267
+ const pool = await initPool();
268
+ rlEmitters[0].emit('line', '');
269
+ rlEmitters[0].emit('line', ' ');
270
+ expect(true).toBe(true);
271
+ await shutdownPool(pool);
272
+ });
273
+ it('handleMessage: ignores non-JSON lines', async () => {
274
+ const pool = await initPool();
275
+ rlEmitters[0].emit('line', 'some debug output from vitest');
276
+ expect(true).toBe(true);
277
+ await shutdownPool(pool);
278
+ });
279
+ it('handleMessage: ignores invalid JSON lines', async () => {
280
+ const pool = await initPool();
281
+ rlEmitters[0].emit('line', '{invalid json}');
282
+ expect(true).toBe(true);
283
+ await shutdownPool(pool);
284
+ });
285
+ it('handleMessage: handles result message when no pending task', async () => {
286
+ const pool = await initPool();
287
+ // 'result' message with no pending task should be silently ignored
288
+ rlEmitters[0].emit('line', JSON.stringify({ type: 'result', killed: true, durationMs: 5 }));
289
+ expect(true).toBe(true);
290
+ await shutdownPool(pool);
291
+ });
292
+ it('handleMessage: handles shutdown message type', async () => {
293
+ const pool = await initPool();
294
+ // Emit 'shutdown' type — triggers this.emit('shutdown') on the worker
295
+ // which the worker's shutdown() handler listens for
296
+ const shutdownP = pool.shutdown();
297
+ await new Promise((r) => setImmediate(r));
298
+ // Instead of shutdown ack, test that 'shutdown' type message is handled
299
+ rlEmitters[0].emit('line', JSON.stringify({ type: 'shutdown', ok: true }));
300
+ await shutdownP;
301
+ expect(true).toBe(true);
302
+ });
303
+ it('handleMessage: handles result message with pending task', async () => {
304
+ const pool = await initPool(1, { timeoutMs: 30000 });
305
+ const mutant = {
306
+ id: '1',
307
+ name: 'm',
308
+ file: 'f',
309
+ code: 'c',
310
+ line: 1,
311
+ col: 1,
312
+ };
313
+ const runPromise = pool.run(mutant, ['t']);
314
+ // Wait for acquireWorker to resolve and worker.run() to set pendingTask
315
+ await new Promise((r) => setImmediate(r));
316
+ rlEmitters[0].emit('line', JSON.stringify({ type: 'result', killed: true, durationMs: 10 }));
317
+ const result = await runPromise;
318
+ expect(result.killed).toBe(true);
319
+ await shutdownPool(pool);
320
+ });
321
+ it('handleMessage: handles result with passingTests field', async () => {
322
+ const pool = await initPool(1, { timeoutMs: 30000 });
323
+ const mutant = {
324
+ id: '1',
325
+ name: 'm',
326
+ file: 'f',
327
+ code: 'c',
328
+ line: 1,
329
+ col: 1,
330
+ };
331
+ const runPromise = pool.run(mutant, ['t']);
332
+ // Wait for acquireWorker to resolve and worker.run() to set pendingTask
333
+ await new Promise((r) => setImmediate(r));
334
+ rlEmitters[0].emit('line', JSON.stringify({
335
+ type: 'result',
336
+ killed: false,
337
+ durationMs: 10,
338
+ passingTests: ['A > b'],
339
+ }));
340
+ const result = await runPromise;
341
+ expect(result.passingTests).toEqual(['A > b']);
342
+ await shutdownPool(pool);
343
+ });
344
+ it('process error event triggers handleExit', async () => {
345
+ const pool = new VitestPool({ cwd: '/test', concurrency: 1 });
346
+ const initPromise = pool.init();
347
+ await new Promise((r) => setImmediate(r));
348
+ rlEmitters[0].emit('line', JSON.stringify({ type: 'ready', workerId: 'w0' }));
349
+ await initPromise;
350
+ // Start a run to create a pending task
351
+ const mutant = {
352
+ id: '1',
353
+ name: 'm',
354
+ file: 'f',
355
+ code: 'c',
356
+ line: 1,
357
+ col: 1,
358
+ };
359
+ const runPromise = pool.run(mutant, ['t']);
360
+ // Let pool.run advance past acquireWorker so pendingTask is set
361
+ await new Promise((r) => setImmediate(r));
362
+ // Emit error on the process — should reject the pending task
363
+ mockProcesses[0].emit('error', new Error('ENOENT'));
364
+ await expect(runPromise).rejects.toThrow('Worker exited unexpectedly');
365
+ });
366
+ it('process exit event triggers handleExit with code', async () => {
367
+ const pool = new VitestPool({ cwd: '/test', concurrency: 1 });
368
+ const initPromise = pool.init();
369
+ await new Promise((r) => setImmediate(r));
370
+ rlEmitters[0].emit('line', JSON.stringify({ type: 'ready', workerId: 'w0' }));
371
+ await initPromise;
372
+ const mutant = {
373
+ id: '1',
374
+ name: 'm',
375
+ file: 'f',
376
+ code: 'c',
377
+ line: 1,
378
+ col: 1,
379
+ };
380
+ const runPromise = pool.run(mutant, ['t']);
381
+ // Let pool.run advance past acquireWorker so pendingTask is set
382
+ await new Promise((r) => setImmediate(r));
383
+ // Emit exit on process — should reject pending task
384
+ mockProcesses[0].emit('exit', 1);
385
+ await expect(runPromise).rejects.toThrow('Worker exited unexpectedly');
386
+ });
387
+ it('handleExit with no pending task just emits exit', async () => {
388
+ const pool = new VitestPool({ cwd: '/test', concurrency: 1 });
389
+ const initPromise = pool.init();
390
+ await new Promise((r) => setImmediate(r));
391
+ rlEmitters[0].emit('line', JSON.stringify({ type: 'ready', workerId: 'w0' }));
392
+ await initPromise;
393
+ // No pending task — process exit should not throw/reject anything
394
+ mockProcesses[0].emit('exit', 0);
395
+ expect(true).toBe(true);
396
+ // Pool handleWorkerExit fires: spawns new process, waits for ready
397
+ // Give event loop a few ticks to process the restart
398
+ await new Promise((r) => setImmediate(r));
399
+ await new Promise((r) => setImmediate(r));
400
+ // Emit ready for the restarted worker (if it was created)
401
+ if (rlEmitters.length > 1) {
402
+ rlEmitters[1].emit('line', JSON.stringify({ type: 'ready', workerId: 'w0' }));
403
+ await new Promise((r) => setImmediate(r));
404
+ }
405
+ // Shutdown
406
+ const shutdownPromise = pool.shutdown();
407
+ await new Promise((r) => setImmediate(r));
408
+ // Emit shutdown ack on all readline emitters
409
+ for (const rl of [...rlEmitters].reverse()) {
410
+ rl.emit('line', JSON.stringify({ type: 'shutdown', ok: true }));
411
+ }
412
+ await shutdownPromise;
413
+ });
414
+ it('stderr data handler writes to process.stderr when DEBUG active', async () => {
415
+ const origDebug = process.env.MUTINEER_DEBUG;
416
+ process.env.MUTINEER_DEBUG = '1';
417
+ const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
418
+ try {
419
+ const pool = await initPool();
420
+ // Emit data on stderr
421
+ mockProcesses[0].stderr.emit('data', Buffer.from('error output'));
422
+ await shutdownPool(pool);
423
+ }
424
+ finally {
425
+ if (origDebug === undefined)
426
+ delete process.env.MUTINEER_DEBUG;
427
+ else
428
+ process.env.MUTINEER_DEBUG = origDebug;
429
+ stderrSpy.mockRestore();
430
+ }
431
+ });
432
+ it('kill() with undefined pid falls back to process.kill(SIGKILL)', async () => {
433
+ const mockProc = new EventEmitter();
434
+ mockProc.stdout = new EventEmitter();
435
+ mockProc.stderr = new EventEmitter();
436
+ mockProc.stdin = { writes: [], write: vi.fn() };
437
+ mockProc.kill = vi.fn();
438
+ mockProc.pid = undefined; // pid is undefined — triggers fallback
439
+ vi.mocked(childProcess.spawn).mockReturnValueOnce(mockProc);
440
+ const rl = new EventEmitter();
441
+ vi.mocked(readline.createInterface).mockReturnValueOnce(rl);
442
+ // Use very short timeout so test doesn't wait long
443
+ const pool = new VitestPool({ cwd: '/test', concurrency: 1, timeoutMs: 50 });
444
+ const initPromise = pool.init();
445
+ await new Promise((r) => setImmediate(r));
446
+ rl.emit('line', JSON.stringify({ type: 'ready', workerId: 'w0' }));
447
+ await initPromise;
448
+ const mutant = {
449
+ id: '1',
450
+ name: 'm',
451
+ file: 'f',
452
+ code: 'c',
453
+ line: 1,
454
+ col: 1,
455
+ };
456
+ // Don't emit a result — let the 50ms timeout fire and call kill()
457
+ const result = await pool.run(mutant, ['t']);
458
+ expect(result.error).toBe('timeout');
459
+ // With pid=undefined, fallback to this.process.kill('SIGKILL')
460
+ expect(mockProc.kill).toHaveBeenCalledWith('SIGKILL');
461
+ });
462
+ it('kill() with process.kill throwing is ignored', async () => {
463
+ const pool = new VitestPool({ cwd: '/test', concurrency: 1, timeoutMs: 50 });
464
+ const mockProc = new EventEmitter();
465
+ mockProc.stdout = new EventEmitter();
466
+ mockProc.stderr = new EventEmitter();
467
+ mockProc.stdin = { writes: [], write: vi.fn() };
468
+ mockProc.kill = vi.fn();
469
+ mockProc.pid = 99999;
470
+ vi.mocked(childProcess.spawn).mockReturnValueOnce(mockProc);
471
+ const rl = new EventEmitter();
472
+ vi.mocked(readline.createInterface).mockReturnValueOnce(rl);
473
+ const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => {
474
+ throw new Error('ESRCH');
475
+ });
476
+ const initPromise = pool.init();
477
+ await new Promise((r) => setImmediate(r));
478
+ rl.emit('line', JSON.stringify({ type: 'ready', workerId: 'w0' }));
479
+ await initPromise;
480
+ const mutant = {
481
+ id: '1',
482
+ name: 'm',
483
+ file: 'f',
484
+ code: 'c',
485
+ line: 1,
486
+ col: 1,
487
+ };
488
+ const result = await pool.run(mutant, ['t']);
489
+ expect(result.error).toBe('timeout');
490
+ killSpy.mockRestore();
491
+ });
492
+ it('pool.run() throws when pool is shutting down', async () => {
493
+ const pool = new VitestPool({
494
+ cwd: '/test',
495
+ concurrency: 1,
496
+ createWorker: (id) => {
497
+ const worker = new EventEmitter();
498
+ worker.id = id;
499
+ worker.start = vi.fn().mockResolvedValue(undefined);
500
+ worker.isReady = vi.fn().mockReturnValue(true);
501
+ worker.isBusy = vi.fn().mockReturnValue(false);
502
+ worker.run = vi.fn().mockResolvedValue({ killed: true, durationMs: 1 });
503
+ worker.shutdown = vi.fn().mockResolvedValue(undefined);
504
+ worker.kill = vi.fn();
505
+ return worker;
506
+ },
507
+ });
508
+ await pool.init();
509
+ pool.shutdown(); // Don't await — pool is now shutting down
510
+ const mutant = {
511
+ id: '1',
512
+ name: 'm',
513
+ file: 'f',
514
+ code: 'c',
515
+ line: 1,
516
+ col: 1,
517
+ };
518
+ await expect(pool.run(mutant, ['t'])).rejects.toThrow('Pool is shutting down');
519
+ });
520
+ it('releaseWorker skips worker that is not ready', async () => {
521
+ let workerReady = true;
522
+ const pool = new VitestPool({
523
+ cwd: '/test',
524
+ concurrency: 1,
525
+ createWorker: (id) => {
526
+ const worker = new EventEmitter();
527
+ worker.id = id;
528
+ worker.start = vi.fn().mockResolvedValue(undefined);
529
+ worker.isReady = vi.fn(() => workerReady);
530
+ worker.isBusy = vi.fn().mockReturnValue(false);
531
+ worker.run = vi.fn().mockImplementation(async () => {
532
+ workerReady = false;
533
+ return { killed: true, durationMs: 1 };
534
+ });
535
+ worker.shutdown = vi.fn().mockResolvedValue(undefined);
536
+ worker.kill = vi.fn();
537
+ return worker;
538
+ },
539
+ });
540
+ await pool.init();
541
+ // Run once — worker becomes not ready after run
542
+ const mutant = {
543
+ id: '1',
544
+ name: 'm',
545
+ file: 'f',
546
+ code: 'c',
547
+ line: 1,
548
+ col: 1,
549
+ };
550
+ const result = await pool.run(mutant, ['t']);
551
+ expect(result.killed).toBe(true);
552
+ await pool.shutdown();
553
+ });
554
+ it('runWithPool maps timeout result', async () => {
555
+ const mockPool = {
556
+ run: vi
557
+ .fn()
558
+ .mockResolvedValue({
559
+ killed: false,
560
+ durationMs: 10000,
561
+ error: 'timeout',
562
+ }),
563
+ };
564
+ const mutant = {
565
+ id: '1',
566
+ name: 'm',
567
+ file: 'f',
568
+ code: 'c',
569
+ line: 1,
570
+ col: 1,
571
+ };
572
+ const result = await runWithPool(mockPool, mutant, ['t']);
573
+ expect(result).toEqual({
574
+ status: 'timeout',
575
+ durationMs: 10000,
576
+ error: 'timeout',
577
+ });
578
+ });
579
+ it('runWithPool maps error result (error with !killed)', async () => {
580
+ const mockPool = {
581
+ run: vi
582
+ .fn()
583
+ .mockResolvedValue({ killed: false, durationMs: 5, error: 'crash' }),
584
+ };
585
+ const mutant = {
586
+ id: '1',
587
+ name: 'm',
588
+ file: 'f',
589
+ code: 'c',
590
+ line: 1,
591
+ col: 1,
592
+ };
593
+ const result = await runWithPool(mockPool, mutant, ['t']);
594
+ expect(result).toEqual({ status: 'error', durationMs: 5, error: 'crash' });
595
+ });
596
+ it('runWithPool maps non-Error exception', async () => {
597
+ const mockPool = {
598
+ run: vi.fn().mockRejectedValue('string rejection'),
599
+ };
600
+ const mutant = {
601
+ id: '1',
602
+ name: 'm',
603
+ file: 'f',
604
+ code: 'c',
605
+ line: 1,
606
+ col: 1,
607
+ };
608
+ const result = await runWithPool(mockPool, mutant, []);
609
+ expect(result).toEqual({
610
+ status: 'error',
611
+ durationMs: 0,
612
+ error: 'string rejection',
613
+ });
614
+ });
615
+ it('pool.init() is a no-op when already initialised', async () => {
616
+ const pool = new VitestPool({
617
+ cwd: '/test',
618
+ concurrency: 1,
619
+ createWorker: (id) => {
620
+ const w = new EventEmitter();
621
+ w.id = id;
622
+ w.start = vi.fn().mockResolvedValue(undefined);
623
+ w.isReady = vi.fn().mockReturnValue(true);
624
+ w.isBusy = vi.fn().mockReturnValue(false);
625
+ w.run = vi.fn().mockResolvedValue({ killed: true, durationMs: 1 });
626
+ w.shutdown = vi.fn().mockResolvedValue(undefined);
627
+ w.kill = vi.fn();
628
+ return w;
629
+ },
630
+ });
631
+ await pool.init();
632
+ const workersBefore = pool.workers.length;
633
+ await pool.init(); // second call should be no-op
634
+ expect(pool.workers.length).toBe(workersBefore);
635
+ await pool.shutdown();
636
+ });
637
+ it('pool.shutdown() is a no-op when already shutting down', async () => {
638
+ const pool = new VitestPool({
639
+ cwd: '/test',
640
+ concurrency: 1,
641
+ createWorker: (id) => {
642
+ const w = new EventEmitter();
643
+ w.id = id;
644
+ w.start = vi.fn().mockResolvedValue(undefined);
645
+ w.isReady = vi.fn().mockReturnValue(true);
646
+ w.isBusy = vi.fn().mockReturnValue(false);
647
+ w.run = vi.fn().mockResolvedValue({ killed: true, durationMs: 1 });
648
+ w.shutdown = vi.fn().mockResolvedValue(undefined);
649
+ w.kill = vi.fn();
650
+ return w;
651
+ },
652
+ });
653
+ await pool.init();
654
+ const p1 = pool.shutdown();
655
+ const p2 = pool.shutdown(); // second call is a no-op
656
+ await Promise.all([p1, p2]);
657
+ expect(true).toBe(true);
658
+ });
659
+ it('handleMessage: handles result with nullish killed/durationMs', async () => {
660
+ const pool = await initPool(1, { timeoutMs: 30000 });
661
+ const mutant = {
662
+ id: '1',
663
+ name: 'm',
664
+ file: 'f',
665
+ code: 'c',
666
+ line: 1,
667
+ col: 1,
668
+ };
669
+ const runPromise = pool.run(mutant, ['t']);
670
+ await new Promise((r) => setImmediate(r));
671
+ // Send result without killed/durationMs to test ?? defaults
672
+ rlEmitters[0].emit('line', JSON.stringify({ type: 'result' }));
673
+ const result = await runPromise;
674
+ expect(result.killed).toBe(true); // ?? true default
675
+ expect(result.durationMs).toBe(0); // ?? 0 default
676
+ await shutdownPool(pool);
677
+ });
678
+ it('handleMessage: unknown message type is silently ignored', async () => {
679
+ const pool = await initPool();
680
+ rlEmitters[0].emit('line', JSON.stringify({ type: 'unknown-type', data: 'whatever' }));
681
+ expect(true).toBe(true);
682
+ await shutdownPool(pool);
683
+ });
684
+ it('handleExit during shuttingDown skips pendingTask rejection', async () => {
685
+ const pool = new VitestPool({ cwd: '/test', concurrency: 1 });
686
+ const initPromise = pool.init();
687
+ await new Promise((r) => setImmediate(r));
688
+ rlEmitters[0].emit('line', JSON.stringify({ type: 'ready', workerId: 'w0' }));
689
+ await initPromise;
690
+ // Start shutdown (sets shuttingDown=true)
691
+ const shutdownP = pool.shutdown();
692
+ await new Promise((r) => setImmediate(r));
693
+ // Emit process exit while shutting down — handleExit called with shuttingDown=true
694
+ // This covers the FALSE branch of (pendingTask && !shuttingDown)
695
+ mockProcesses[0].emit('exit', 0);
696
+ // Emit shutdown ack to resolve the shutdown
697
+ rlEmitters[0].emit('line', JSON.stringify({ type: 'shutdown', ok: true }));
698
+ await shutdownP;
699
+ expect(true).toBe(true);
700
+ });
701
+ it('worker.run() throws when not ready', async () => {
702
+ const pool = new VitestPool({
703
+ cwd: '/test',
704
+ concurrency: 1,
705
+ createWorker: (id) => {
706
+ const w = new EventEmitter();
707
+ w.id = id;
708
+ w.start = vi.fn().mockResolvedValue(undefined);
709
+ w.isReady = vi.fn().mockReturnValue(false); // always not ready
710
+ w.isBusy = vi.fn().mockReturnValue(false);
711
+ w.run = vi
712
+ .fn()
713
+ .mockRejectedValue(new Error(`Worker ${id} is not ready`));
714
+ w.shutdown = vi.fn().mockResolvedValue(undefined);
715
+ w.kill = vi.fn();
716
+ return w;
717
+ },
718
+ });
719
+ await pool.init();
720
+ const mutant = {
721
+ id: '1',
722
+ name: 'm',
723
+ file: 'f',
724
+ code: 'c',
725
+ line: 1,
726
+ col: 1,
727
+ };
728
+ await expect(pool.run(mutant, ['t'])).rejects.toThrow('is not ready');
729
+ await pool.shutdown();
730
+ });
731
+ it('worker.run() throws when busy', async () => {
732
+ const pool = new VitestPool({
733
+ cwd: '/test',
734
+ concurrency: 1,
735
+ createWorker: (id) => {
736
+ const w = new EventEmitter();
737
+ w.id = id;
738
+ w.start = vi.fn().mockResolvedValue(undefined);
739
+ w.isReady = vi.fn().mockReturnValue(true);
740
+ w.isBusy = vi.fn().mockReturnValue(true); // busy
741
+ w.run = vi.fn().mockRejectedValue(new Error(`Worker ${id} is busy`));
742
+ w.shutdown = vi.fn().mockResolvedValue(undefined);
743
+ w.kill = vi.fn();
744
+ return w;
745
+ },
746
+ });
747
+ await pool.init();
748
+ const mutant = {
749
+ id: '1',
750
+ name: 'm',
751
+ file: 'f',
752
+ code: 'c',
753
+ line: 1,
754
+ col: 1,
755
+ };
756
+ await expect(pool.run(mutant, ['t'])).rejects.toThrow('is busy');
757
+ await pool.shutdown();
758
+ });
759
+ it('acquireWorker queues second run when single worker is busy', async () => {
760
+ const pool = await initPool(1, { timeoutMs: 30000 });
761
+ const mutant = {
762
+ id: '1',
763
+ name: 'm',
764
+ file: 'f',
765
+ code: 'c',
766
+ line: 1,
767
+ col: 1,
768
+ };
769
+ // Start two runs simultaneously - second must queue since concurrency=1
770
+ const [run1, run2] = [
771
+ pool.run(mutant, ['t']),
772
+ pool.run({ ...mutant, id: '2' }, ['t']),
773
+ ];
774
+ // Let both acquireWorker() calls resolve (run1 gets worker, run2 queues)
775
+ await new Promise((r) => setImmediate(r));
776
+ // Send result for first run
777
+ rlEmitters[0].emit('line', JSON.stringify({ type: 'result', killed: true, durationMs: 5 }));
778
+ await new Promise((r) => setImmediate(r));
779
+ // After run1 finishes, releaseWorker gives worker to run2
780
+ // Send result for second run
781
+ rlEmitters[0].emit('line', JSON.stringify({ type: 'result', killed: false, durationMs: 3 }));
782
+ const [r1, r2] = await Promise.all([run1, run2]);
783
+ expect(r1.killed).toBe(true);
784
+ expect(r2.killed).toBe(false);
785
+ await shutdownPool(pool);
786
+ });
787
+ it('vitestConfig and vitestProject are passed to worker env', async () => {
788
+ let capturedEnv;
789
+ vi.mocked(childProcess.spawn).mockImplementationOnce((_cmd, _args, options) => {
790
+ capturedEnv = options?.env;
791
+ const proc = new EventEmitter();
792
+ proc.stdout = new EventEmitter();
793
+ proc.stderr = new EventEmitter();
794
+ proc.stdin = { writes: [], write: vi.fn() };
795
+ proc.kill = vi.fn();
796
+ return proc;
797
+ });
798
+ const rl = new EventEmitter();
799
+ vi.mocked(readline.createInterface).mockImplementationOnce(() => rl);
800
+ const pool = new VitestPool({
801
+ cwd: '/proj',
802
+ concurrency: 1,
803
+ vitestConfig: 'vitest.config.ts',
804
+ vitestProject: 'my-project',
805
+ });
806
+ const initP = pool.init();
807
+ await new Promise((r) => setImmediate(r));
808
+ expect(capturedEnv?.MUTINEER_VITEST_CONFIG).toBe('vitest.config.ts');
809
+ expect(capturedEnv?.MUTINEER_VITEST_PROJECT).toBe('my-project');
810
+ rl.emit('line', JSON.stringify({ type: 'ready', workerId: 'w0' }));
811
+ await initP;
812
+ const shutdownP = pool.shutdown();
813
+ await new Promise((r) => setImmediate(r));
814
+ rl.emit('line', JSON.stringify({ type: 'shutdown', ok: true }));
815
+ await shutdownP;
816
+ });
817
+ it('handleWorkerExit restart failure is silently caught', async () => {
818
+ let workerNum = 0;
819
+ const pool = new VitestPool({
820
+ cwd: '/test',
821
+ concurrency: 1,
822
+ createWorker: (id) => {
823
+ workerNum++;
824
+ const w = new EventEmitter();
825
+ w.id = id;
826
+ w.start =
827
+ workerNum === 1
828
+ ? vi.fn().mockResolvedValue(undefined)
829
+ : vi.fn().mockRejectedValue(new Error('restart failed'));
830
+ w.isReady = vi.fn().mockReturnValue(workerNum === 1);
831
+ w.isBusy = vi.fn().mockReturnValue(false);
832
+ w.run = vi.fn().mockResolvedValue({ killed: true, durationMs: 1 });
833
+ w.shutdown = vi.fn().mockResolvedValue(undefined);
834
+ w.kill = vi.fn();
835
+ return w;
836
+ },
837
+ });
838
+ await pool.init();
839
+ // Trigger worker exit (pool not shutting down → handleWorkerExit fires)
840
+ const worker = pool.workers[0];
841
+ worker.emit('exit', 1);
842
+ // Give event loop time for handleWorkerExit and failed restart
843
+ await new Promise((r) => setImmediate(r));
844
+ await new Promise((r) => setImmediate(r));
845
+ expect(true).toBe(true); // No uncaught error
846
+ await pool.shutdown();
847
+ });
848
+ it('kill() does nothing when process is already null', async () => {
849
+ const pool = new VitestPool({ cwd: '/test', concurrency: 1, timeoutMs: 50 });
850
+ const mockProc = new EventEmitter();
851
+ mockProc.stdout = new EventEmitter();
852
+ mockProc.stderr = new EventEmitter();
853
+ mockProc.stdin = { writes: [], write: vi.fn() };
854
+ mockProc.kill = vi.fn();
855
+ mockProc.pid = 12345;
856
+ vi.mocked(childProcess.spawn).mockReturnValueOnce(mockProc);
857
+ const rl = new EventEmitter();
858
+ vi.mocked(readline.createInterface).mockReturnValueOnce(rl);
859
+ const initP = pool.init();
860
+ await new Promise((r) => setImmediate(r));
861
+ rl.emit('line', JSON.stringify({ type: 'ready', workerId: 'w0' }));
862
+ await initP;
863
+ const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true);
864
+ // First run: timeout fires, calls kill() (sets process=null, ready=false)
865
+ const run1 = pool.run({ id: '1', name: 'm', file: 'f', code: 'c', line: 1, col: 1 }, ['t']);
866
+ const r1 = await run1;
867
+ expect(r1.error).toBe('timeout');
868
+ // Pool tries to restart — mock a new spawn that stays alive
869
+ if (rlEmitters.length > 1) {
870
+ rlEmitters[1].emit('line', JSON.stringify({ type: 'ready', workerId: 'w0' }));
871
+ }
872
+ killSpy.mockRestore();
873
+ await pool.shutdown();
874
+ });
875
+ it('process exit with null code uses ?? 1 fallback', async () => {
876
+ const pool = new VitestPool({ cwd: '/test', concurrency: 1 });
877
+ const initPromise = pool.init();
878
+ await new Promise((r) => setImmediate(r));
879
+ rlEmitters[0].emit('line', JSON.stringify({ type: 'ready', workerId: 'w0' }));
880
+ await initPromise;
881
+ const mutant = {
882
+ id: '1',
883
+ name: 'm',
884
+ file: 'f',
885
+ code: 'c',
886
+ line: 1,
887
+ col: 1,
888
+ };
889
+ const runPromise = pool.run(mutant, ['t']);
890
+ await new Promise((r) => setImmediate(r));
891
+ // Emit exit with null code — triggers `code ?? 1`
892
+ mockProcesses[0].emit('exit', null);
893
+ await expect(runPromise).rejects.toThrow('Worker exited unexpectedly with code 1');
894
+ });
895
+ it('pool.run() throws when pool not initialised', async () => {
896
+ const pool = new VitestPool({ cwd: '/test', concurrency: 1 });
897
+ const mutant = {
898
+ id: '1',
899
+ name: 'm',
900
+ file: 'f',
901
+ code: 'c',
902
+ line: 1,
903
+ col: 1,
904
+ };
905
+ await expect(pool.run(mutant, ['t'])).rejects.toThrow('Pool not initialised');
906
+ });
907
+ it('runWithPool maps killed=true to killed status', async () => {
908
+ const mockPool = {
909
+ run: vi.fn().mockResolvedValue({ killed: true, durationMs: 15 }),
910
+ };
911
+ const mutant = {
912
+ id: '1',
913
+ name: 'm',
914
+ file: 'f',
915
+ code: 'c',
916
+ line: 1,
917
+ col: 1,
918
+ };
919
+ const result = await runWithPool(mockPool, mutant, ['t']);
920
+ expect(result).toEqual({ status: 'killed', durationMs: 15 });
921
+ });
922
+ it('threads passingTests from WorkerMessage through to MutantRunSummary', async () => {
923
+ const pool = new VitestPool({
924
+ cwd: process.cwd(),
925
+ concurrency: 1,
926
+ timeoutMs: 5000,
927
+ createWorker: (id) => {
928
+ const worker = new EventEmitter();
929
+ worker.id = id;
930
+ worker.start = vi.fn().mockResolvedValue(undefined);
931
+ worker.isReady = vi.fn().mockReturnValue(true);
932
+ worker.isBusy = vi.fn().mockReturnValue(false);
933
+ worker.run = vi.fn().mockResolvedValue({
934
+ killed: false,
935
+ durationMs: 10,
936
+ passingTests: ['Suite > test one', 'Suite > test two'],
937
+ });
938
+ worker.shutdown = vi.fn().mockResolvedValue(undefined);
939
+ worker.kill = vi.fn();
940
+ return worker;
941
+ },
942
+ });
943
+ await pool.init();
944
+ const mutant = {
945
+ id: 'pt1',
946
+ name: 'mutant',
947
+ file: 'foo.ts',
948
+ code: 'x',
949
+ line: 1,
950
+ col: 1,
951
+ };
952
+ const result = await pool.run(mutant, ['foo.spec.ts']);
953
+ expect(result.passingTests).toEqual([
954
+ 'Suite > test one',
955
+ 'Suite > test two',
956
+ ]);
957
+ await pool.shutdown();
958
+ });
248
959
  });