oxmutate 1.5.0 → 2.0.1

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.
package/README.md CHANGED
@@ -20,7 +20,7 @@
20
20
  <a href="https://www.npmjs.com/package/oxmutate?activeTab=code"><img src="https://img.shields.io/npm/unpacked-size/oxmutate?style=flat-square" alt="Unpacked size of the latest oxmutate release on npm" /></a>
21
21
  <a href="https://github.com/ofri-peretz/burgee/blob/main/packages/oxmutate/package.json"><img src="https://img.shields.io/badge/dependencies-2%20in%20family%2C%200%20outside-0a6b47?style=flat-square" alt="Two dependencies, all in the burgee family (bellpull, burgee), none outside it" /></a>
22
22
  <a href="https://github.com/ofri-peretz/burgee/blob/main/packages/oxmutate/package.json"><img src="https://img.shields.io/badge/types-included-blue?style=flat-square" alt="TypeScript types included for every entry point" /></a>
23
- <a href="https://github.com/ofri-peretz/burgee/blob/main/packages/oxmutate/package.json"><img src="https://img.shields.io/badge/Node.js-20.19%2B%20%7C%2022.13%2B-green?style=flat-square" alt="Node.js 20.19+ or 22.13+" /></a>
23
+ <a href="https://github.com/ofri-peretz/burgee/blob/main/packages/oxmutate/package.json"><img src="https://img.shields.io/badge/Node.js-22.13%2B-green?style=flat-square" alt="Node.js 22.13+" /></a>
24
24
  <a href="https://github.com/ofri-peretz/burgee/blob/main/packages/oxmutate/LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue?style=flat-square" alt="License: MIT" /></a>
25
25
  <a href="https://www.npmjs.com/package/oxmutate#provenance"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fregistry.npmjs.org%2Foxmutate%2Flatest&query=%24.dist.attestations.provenance~&label=npm&style=flat-square&color=0a6b47" alt="npm provenance of the latest release, read live from its registry attestation" /></a>
26
26
  </p>
@@ -335,7 +335,7 @@ The `oxmutate` bin is that API, declared once on burgee.
335
335
 
336
336
  ## Requirements
337
337
 
338
- Node 20.19 or later on the 20 line, or 22.13 or later. Vitest 4.1 or later in the project, for the
338
+ Node 22.13 or later. Vitest 4.1 or later in the project, for the
339
339
  default runner. macOS (arm64, x64), Linux (glibc x64 and arm64, musl x64) or Windows (x64).
340
340
 
341
341
  ## Where it sits
Binary file
Binary file
@@ -45,6 +45,19 @@ export interface RunStats {
45
45
  isolatedMs: number;
46
46
  wallMs: number;
47
47
  testExecutions: number;
48
+ /** Warm units, send to outcome, summed over servers: what `mutationMs` is in server time. */
49
+ warmUnitMs: number;
50
+ /** Of `warmUnitMs`, the time inside armed test runs. The rest is starting workers, collecting files, canaries and hooks. */
51
+ warmArmedMs: number;
52
+ /** Isolated runs by what sent the mutant there: the parts of `isolatedRuns`. */
53
+ isolatedByCause: {
54
+ static: number;
55
+ stopped: number;
56
+ unfinished: number;
57
+ rerun: number;
58
+ confirm: number;
59
+ flaky: number;
60
+ };
48
61
  watchdogKills: number;
49
62
  /** Of `watchdogKills`, the stops whose worker was idle, waiting (heartbeat.ts). In an isolated run each is a Timeout as it stands. */
50
63
  idleStalls: number;
@@ -54,8 +54,11 @@ function timeoutReason(v) {
54
54
  return DEADLOCK_REASON;
55
55
  if (v.idle)
56
56
  return 'its test waited past the budget with the worker idle: a hang, not a slow test';
57
+ if (v.slowTwice)
58
+ return "its tests failed only by their own timeout, in two runs";
57
59
  return 'stopped by the watchdog twice, the second time with 3x the budget';
58
60
  }
61
+ const slowKill = (v) => v?.status === 'Killed' && v.slow === true;
59
62
  const RERUN_SLACK = 3;
60
63
  export const MAX_TEST_MS = 5000;
61
64
  const MS_PER_MINUTE = 60_000;
@@ -73,7 +76,7 @@ export async function run(options) {
73
76
  const { root } = options;
74
77
  const work = mkdtempSync(path.join(tmpdir(), 'oxmutate-'));
75
78
  const stats = {
76
- files: 0, mutants: 0, instrumentMs: 0, dryRunMs: 0, mutationMs: 0, staticMs: 0, staticRecollections: 0, isolatedMs: 0, wallMs: 0, testExecutions: 0,
79
+ files: 0, mutants: 0, instrumentMs: 0, dryRunMs: 0, mutationMs: 0, staticMs: 0, staticRecollections: 0, isolatedMs: 0, wallMs: 0, testExecutions: 0, warmUnitMs: 0, warmArmedMs: 0, isolatedByCause: { static: 0, stopped: 0, unfinished: 0, rerun: 0, confirm: 0, flaky: 0 },
77
80
  watchdogKills: 0, idleStalls: 0, deadlocks: 0, crashes: 0, canaryFiles: 0, pollutionRestarts: 0, diskWrites: 0, auditSampled: 0, auditDisagreed: 0, survivorsOverturned: 0, timeoutsOverturned: 0, compileErrors: 0, isolatedRuns: 0, reused: 0, flakyTests: 0, flakyMutants: 0, units: 0, serversSpawned: 0,
78
81
  };
79
82
  let unitSeq = 0;
@@ -189,6 +192,9 @@ export async function run(options) {
189
192
  if (fileErrors.length) {
190
193
  throw new InstrumentFault(`${String(fileErrors.length)} test file(s) failed to load during the dry run, so their tests would be missing from every verdict: ${fileErrors.slice(0, 3).map((e) => `${path.relative(root, e.file)}: ${e.error}`).join('; ')}`);
191
194
  }
195
+ const unhandled = recs.reduce((n, r) => n + (r.unhandled ?? 0), 0);
196
+ if (unhandled > 0)
197
+ throw new InstrumentFault(`the suite raised ${String(unhandled)} unhandled error(s) with no mutant active, so no verdict can be trusted. fix: make \`vitest run\` pass with no "Unhandled Errors" first`);
192
198
  return { tests: recs.flatMap((r) => r.tests ?? []), perTest, static: stat, runOnce: recs.flatMap((r) => r.runOnce ?? []), modules: Object.assign({}, ...recs.map((r) => r.modules ?? {})) };
193
199
  };
194
200
  progress.phase('dry run');
@@ -412,6 +418,7 @@ export async function run(options) {
412
418
  const killedSoFar = (spec) => new Set([...runaways, ...crashedIds, ...records.filter((r) => r.type === 'mutant' && r.status !== 'Survived' && Boolean(r.spec) === spec).map((r) => r.id ?? '')]);
413
419
  const afterUnit = async (unit, outcome) => {
414
420
  inFlight.delete(unit.results);
421
+ stats.warmUnitMs += outcome.ms ?? 0;
415
422
  const wrote = await diskWrittenDuring(unit);
416
423
  if (wrote)
417
424
  return wrote;
@@ -484,6 +491,7 @@ export async function run(options) {
484
491
  if (r.type === 'mutant' && r.id)
485
492
  (byMutant.get(r.id) ?? byMutant.set(r.id, []).get(r.id))?.push(r);
486
493
  stats.testExecutions = records.filter((r) => r.type === 'mutant').length;
494
+ stats.warmArmedMs = records.reduce((sum, r) => sum + (r.type === 'mutant' ? (r.ms ?? 0) : 0), 0);
487
495
  stats.canaryFiles = new Set(records.filter((r) => r.type === 'polluted' && r.file).map((r) => r.file ?? '')).size;
488
496
  const isolatedQueue = [];
489
497
  for (const m of toRun) {
@@ -496,6 +504,7 @@ export async function run(options) {
496
504
  continue;
497
505
  }
498
506
  else if (runaways.has(m.id)) {
507
+ stats.isolatedByCause.stopped++;
499
508
  isolatedQueue.push(m);
500
509
  }
501
510
  else if (crashedIds.has(m.id)) {
@@ -514,6 +523,7 @@ export async function run(options) {
514
523
  m.testsCompleted = recs.length;
515
524
  }
516
525
  else {
526
+ stats.isolatedByCause.unfinished++;
517
527
  isolatedQueue.push(m);
518
528
  }
519
529
  }
@@ -652,7 +662,7 @@ export async function run(options) {
652
662
  if (rec.timeout)
653
663
  verdicts.set(rec.id, { status: 'Timeout', testsCompleted, ...(rec.deadlock && { idle: true, deadlock: true }) });
654
664
  else if (rec.killed)
655
- verdicts.set(rec.id, { status: 'Killed', killedBy: [...(prev?.killedBy ?? []), ...(rec.failed ?? [])], testsCompleted });
665
+ verdicts.set(rec.id, { status: 'Killed', killedBy: [...(prev?.killedBy ?? []), ...(rec.failed ?? [])], testsCompleted, slow: rec.slow === true && (prev?.status !== 'Killed' || prev.slow === true) });
656
666
  else
657
667
  verdicts.set(rec.id, { status: prev?.status ?? 'Survived', killedBy: prev?.killedBy, testsCompleted });
658
668
  }
@@ -674,15 +684,24 @@ export async function run(options) {
674
684
  const s0 = performance.now();
675
685
  const staticBack = env['OXMUTATE_ISOLATE_STATIC'] ? staticMutants : await decideStaticLive(staticMutants);
676
686
  stats.staticMs = performance.now() - s0;
687
+ stats.isolatedByCause.static = staticBack.length;
677
688
  isolatedQueue.push(...staticBack);
678
689
  progress.phase(`isolated runs (static mutants and stragglers: ${String(isolatedQueue.length)})`);
679
690
  const i0 = performance.now();
680
691
  const first = await runIsolated(isolatedQueue);
681
- const stopped = isolatedQueue.filter((m) => first.get(m.id)?.status === 'Timeout' && !first.get(m.id)?.idle);
692
+ const stopped = isolatedQueue.filter((m) => (first.get(m.id)?.status === 'Timeout' && !first.get(m.id)?.idle) || slowKill(first.get(m.id)));
693
+ stats.isolatedByCause.rerun += stopped.length;
682
694
  const again = await runIsolated(stopped, RERUN_SLACK);
683
695
  for (const m of stopped) {
684
696
  const v = again.get(m.id);
685
- if (v && v.status !== 'Timeout' && v.status !== 'RuntimeError') {
697
+ if (!v || v.status === 'RuntimeError')
698
+ continue;
699
+ if (slowKill(first.get(m.id))) {
700
+ first.set(m.id, slowKill(v) ? { status: 'Timeout', slowTwice: true, testsCompleted: v.testsCompleted } : v);
701
+ if (v.status === 'Survived')
702
+ stats.timeoutsOverturned++;
703
+ }
704
+ else if (v.status !== 'Timeout') {
686
705
  first.set(m.id, v);
687
706
  stats.timeoutsOverturned++;
688
707
  }
@@ -698,20 +717,22 @@ export async function run(options) {
698
717
  }
699
718
  }
700
719
  const survivors = toRun.filter((m) => m.status === 'Survived' && new Set(m.coveredBy).size > 1);
720
+ stats.isolatedByCause.confirm += survivors.length;
701
721
  const confirmed = await runIsolated(survivors);
702
- const stoppedHere = survivors.filter((m) => confirmed.get(m.id)?.status === 'Timeout' && !confirmed.get(m.id)?.idle);
722
+ const stoppedHere = survivors.filter((m) => (confirmed.get(m.id)?.status === 'Timeout' && !confirmed.get(m.id)?.idle) || slowKill(confirmed.get(m.id)));
723
+ stats.isolatedByCause.rerun += stoppedHere.length;
703
724
  const recheck = await runIsolated(stoppedHere, RERUN_SLACK);
704
725
  for (const m of stoppedHere) {
705
726
  const v = recheck.get(m.id);
706
727
  if (v)
707
- confirmed.set(m.id, v);
728
+ confirmed.set(m.id, slowKill(confirmed.get(m.id)) && slowKill(v) ? { status: 'Timeout', slowTwice: true, testsCompleted: v.testsCompleted } : v);
708
729
  }
709
730
  for (const m of survivors) {
710
731
  const v = confirmed.get(m.id);
711
732
  if (v && v.status !== 'Survived' && v.status !== 'RuntimeError') {
712
733
  m.status = v.status;
713
734
  m.killedBy = v.killedBy;
714
- m.statusReason = 'killed only when the mutant stays active across the whole test file (cross-test effect)';
735
+ m.statusReason = v.slowTwice ? timeoutReason(v) : 'killed only when the mutant stays active across the whole test file (cross-test effect)';
715
736
  stats.survivorsOverturned++;
716
737
  }
717
738
  }
@@ -732,6 +753,7 @@ export async function run(options) {
732
753
  const flakyCandidates = toRun.filter((m) => m.coveredBy.some((t) => flakyTests.has(t)));
733
754
  const seen = new Map(flakyCandidates.map((m) => [m.id, new Set([m.status === 'Timeout' ? 'Killed' : m.status])]));
734
755
  for (let n = 1; n <= options.flakyReruns && flakyCandidates.length; n++) {
756
+ stats.isolatedByCause.flaky += flakyCandidates.length;
735
757
  const again = await runIsolated(flakyCandidates);
736
758
  for (const m of flakyCandidates) {
737
759
  const v = again.get(m.id);
@@ -0,0 +1,22 @@
1
+ import { closeSync, openSync, readSync, writeSync } from 'node:fs';
2
+ const MARK_BYTES = 256;
3
+ export function markWriter(file) {
4
+ const fd = openSync(file, 'w');
5
+ let length = 0;
6
+ return {
7
+ write(text) {
8
+ const padded = text.padEnd(length);
9
+ length = padded.length;
10
+ writeSync(fd, padded, 0);
11
+ },
12
+ close: () => closeSync(fd),
13
+ };
14
+ }
15
+ export function markReader(file) {
16
+ const fd = openSync(file, 'w+');
17
+ const buffer = Buffer.alloc(MARK_BYTES);
18
+ return {
19
+ read: () => buffer.toString('utf8', 0, readSync(fd, buffer, 0, MARK_BYTES, 0)).trim(),
20
+ close: () => closeSync(fd),
21
+ };
22
+ }
package/dist/reports.d.ts CHANGED
@@ -6,7 +6,6 @@ export declare function strykerReport(r: RunResult): object;
6
6
  /** SARIF 2.1.0: one result per surviving or uncovered mutant, fingerprinted by stable id. */
7
7
  export declare function sarifReport(r: RunResult): object;
8
8
  export declare function describe(m: MutantResult): string;
9
- /** The terminal summary: the score, what to look at first, what the run corrected. */
10
9
  export declare function textSummary(r: RunResult, opts?: {
11
10
  survivors?: number;
12
11
  }): string;
package/dist/reports.js CHANGED
@@ -116,6 +116,16 @@ function frozenLine(r) {
116
116
  const sign = f.delta >= 0 ? '+' : '';
117
117
  return [` frozen oracle vs ${f.base}: ${f.headScore.toFixed(2)}% with the change's tests, ${f.baseScore.toFixed(2)}% with the base's (${sign}${f.delta.toFixed(2)}), ${String(f.weakened.length)} mutant(s) the change stopped detecting`];
118
118
  }
119
+ function whereItWent(s) {
120
+ const lines = [];
121
+ if (s.warmUnitMs > 0)
122
+ lines.push(` warm servers ran ${(s.warmUnitMs / MS_PER_S).toFixed(1)} s, ${(s.warmArmedMs / MS_PER_S).toFixed(1)} s of it inside test runs`);
123
+ const c = s.isolatedByCause;
124
+ const causes = [`static ${String(c.static)}`, `stopped ${String(c.stopped)}`, `unfinished ${String(c.unfinished)}`, `re-run ${String(c.rerun)}`, `survivors confirmed ${String(c.confirm)}`, `flaky ${String(c.flaky)}`];
125
+ if (s.isolatedRuns > 0)
126
+ lines.push(` isolated runs: ${causes.join(' · ')}`);
127
+ return lines;
128
+ }
119
129
  export function textSummary(r, opts = {}) {
120
130
  const count = (s) => r.mutants.filter((m) => m.status === s).length;
121
131
  const s = r.stats;
@@ -132,6 +142,7 @@ export function textSummary(r, opts = {}) {
132
142
  const corrected = corrections(s);
133
143
  if (corrected.length)
134
144
  lines.push(` corrected: ${corrected.join(' · ')}`);
145
+ lines.push(...whereItWent(s));
135
146
  lines.push(...frozenLine(r));
136
147
  lines.push(...specLines(r));
137
148
  const survivors = r.mutants.filter((m) => m.status === 'Survived' || m.status === 'NoCoverage');
package/dist/runner.d.ts CHANGED
@@ -95,6 +95,6 @@ export default class OxmutateRunner extends OxmutateRunner_base {
95
95
  private collectCopies;
96
96
  /** Reset per-try state (snapshot counters, assertion counts) the way Vitest does for a retry. */
97
97
  private retryReset;
98
- onAfterRunFiles(files: AnyTask[]): unknown;
98
+ onAfterRunFiles(files: AnyTask[]): Promise<unknown>;
99
99
  }
100
100
  export {};
package/dist/runner.js CHANGED
@@ -1,8 +1,9 @@
1
- import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { closeSync, openSync, readFileSync, writeSync } from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
3
  import { availableParallelism, loadavg } from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { BEAT_ENV, markTest, realNow, startHeartbeat } from './heartbeat.js';
6
+ import { markWriter } from './progress-file.js';
6
7
  import { isDeadlock, takeFloor, trackRpc, trackTimers, watch } from './quiescence.js';
7
8
  import { resolveEsm } from './resolve.js';
8
9
  import { ACTIVE_MUTANT_ENV, FILE_KEY, NAMESPACE, cwd, env } from './runtime.js';
@@ -53,6 +54,11 @@ if (mode === 'isolated' && env['OXMUTATE_HIT_LIMIT']) {
53
54
  ns.hitLimit = Number(env['OXMUTATE_HIT_LIMIT']);
54
55
  }
55
56
  const ranAway = () => ns.hitCount !== undefined && ns.hitLimit !== undefined && ns.hitCount > ns.hitLimit;
57
+ let unhandled = 0;
58
+ process.on('unhandledRejection', () => void unhandled++);
59
+ process.on('uncaughtException', () => void unhandled++);
60
+ const realSetImmediate = globalThis.setImmediate;
61
+ const settle = () => new Promise((resolve) => realSetImmediate(resolve));
56
62
  const deadlocks = mode === 'dry' ? undefined : watch();
57
63
  let deadlocked = false;
58
64
  let tornDown = false;
@@ -122,7 +128,7 @@ function takeCopy(test) {
122
128
  }
123
129
  const dryModules = new Map();
124
130
  const PROBE_ID = '__oxmutate_probe__';
125
- async function probe(test, fn) {
131
+ async function probe(test, fn, reset) {
126
132
  const file = test.file?.filepath;
127
133
  const scope = test.suite?.id ?? file;
128
134
  if (!file || !scope || probed.has(scope) || runOnce.has(file))
@@ -130,6 +136,7 @@ async function probe(test, fn) {
130
136
  probed.add(scope);
131
137
  await afterEach(test, test.context, []);
132
138
  const cleanups = await beforeEach(test, test.context);
139
+ await reset();
133
140
  ns.currentTestId = PROBE_ID;
134
141
  try {
135
142
  await fn();
@@ -147,9 +154,31 @@ async function probe(test, fn) {
147
154
  }
148
155
  const POLLUTION_ERROR_CHARS = 300;
149
156
  const FILE_ERROR_CHARS = 500;
157
+ let resultsFd;
158
+ let progress;
159
+ const OWN_TIMEOUT = /^(?:Test|Hook) timed out in \d+ms/u;
160
+ function ownTimeout(t) {
161
+ const errors = t.result?.errors ?? [];
162
+ return errors.length > 0 && errors.every((e) => OWN_TIMEOUT.test(e.message ?? ''));
163
+ }
150
164
  function emit(record) {
151
- if (resultsPath)
152
- appendFileSync(resultsPath, `${JSON.stringify(record)}\n`);
165
+ if (!resultsPath)
166
+ return;
167
+ resultsFd ??= openSync(resultsPath, 'a');
168
+ writeSync(resultsFd, `${JSON.stringify(record)}\n`);
169
+ }
170
+ function mark(text) {
171
+ if (!progressPath)
172
+ return;
173
+ progress ??= markWriter(progressPath);
174
+ progress.write(text);
175
+ }
176
+ function closeFiles() {
177
+ if (resultsFd !== undefined)
178
+ closeSync(resultsFd);
179
+ progress?.close();
180
+ resultsFd = undefined;
181
+ progress = undefined;
153
182
  }
154
183
  function chain(test) {
155
184
  const suites = [];
@@ -271,7 +300,7 @@ export default class OxmutateRunner extends Base {
271
300
  try {
272
301
  await guarded(test.id, fn);
273
302
  if (mode === 'dry')
274
- await probe(test, fn);
303
+ await probe(test, fn, () => this.retryReset(test, 1));
275
304
  }
276
305
  catch (e) {
277
306
  if (isDeadlock(e))
@@ -359,9 +388,13 @@ export default class OxmutateRunner extends Base {
359
388
  ns.hitLimit = plan.hitLimits[id] ?? Number.MAX_SAFE_INTEGER;
360
389
  let status = 'Survived';
361
390
  let deadlock = false;
391
+ const before = unhandled;
362
392
  try {
363
393
  await setup();
364
394
  await guarded(current.id, run);
395
+ await settle();
396
+ if (unhandled > before)
397
+ status = 'Killed';
365
398
  }
366
399
  catch (e) {
367
400
  deadlock = isDeadlock(e);
@@ -377,8 +410,7 @@ export default class OxmutateRunner extends Base {
377
410
  for (const id of ids) {
378
411
  if (done.has(id))
379
412
  continue;
380
- if (progressPath)
381
- writeFileSync(progressPath, `${id} ${String(realNow())}\n`);
413
+ mark(`${id} ${String(realNow())}\n`);
382
414
  let { status, ms, deadlock } = await runArmed(id);
383
415
  if (status === 'Polluted')
384
416
  break;
@@ -402,14 +434,13 @@ export default class OxmutateRunner extends Base {
402
434
  break;
403
435
  }
404
436
  if (tornDown) {
405
- if (progressPath)
406
- writeFileSync(progressPath, '');
437
+ mark('');
407
438
  return;
408
439
  }
409
440
  if (!polluted)
410
441
  await canary();
411
- if (progressPath && ids.length)
412
- writeFileSync(progressPath, '');
442
+ if (ids.length)
443
+ mark('');
413
444
  if (attempt > 0)
414
445
  await fresh();
415
446
  try {
@@ -474,10 +505,14 @@ export default class OxmutateRunner extends Base {
474
505
  ns.hitCount = armed ? 0 : undefined;
475
506
  ns.hitLimit = armed ? (staticLimits[armed] ?? Number.MAX_SAFE_INTEGER) : undefined;
476
507
  deadlocked = false;
508
+ const before = unhandled;
477
509
  try {
478
510
  vi.resetModules();
479
511
  const [file] = await collectTests([filepath], this);
480
512
  const result = file ? await runCollected(file, runnable) : { failed: [filepath], ran: 0 };
513
+ await settle();
514
+ if (unhandled > before && result.failed.length === 0)
515
+ result.failed.push(filepath);
481
516
  return { ...result, timeout: ranAway() || deadlocked, ...(deadlocked ? { deadlock: true } : {}) };
482
517
  }
483
518
  finally {
@@ -495,8 +530,7 @@ export default class OxmutateRunner extends Base {
495
530
  };
496
531
  while (rest.length) {
497
532
  const id = rest.shift();
498
- if (progressPath)
499
- writeFileSync(progressPath, `${id} ${String(realNow())}\n`);
533
+ mark(`${id} ${String(realNow())}\n`);
500
534
  held.push({ id, verdict: await evaluate(id) });
501
535
  if (held.at(-1)?.verdict.deadlock)
502
536
  return back('torn down: a deadlocked run is still pending in this worker');
@@ -517,8 +551,7 @@ export default class OxmutateRunner extends Base {
517
551
  held = [];
518
552
  every = Math.min(STATIC_CANARY_MAX, every * 2);
519
553
  }
520
- if (progressPath)
521
- writeFileSync(progressPath, '');
554
+ mark('');
522
555
  vi.resetModules();
523
556
  }
524
557
  async onBeforeRunTask(test) {
@@ -562,7 +595,8 @@ export default class OxmutateRunner extends Base {
562
595
  async retryReset(test, attempt) {
563
596
  await super.onBeforeTryTask?.(test, { retry: attempt, repeats: 0 });
564
597
  }
565
- onAfterRunFiles(files) {
598
+ async onAfterRunFiles(files) {
599
+ await settle();
566
600
  if (mode === 'dry') {
567
601
  const tests = [];
568
602
  const visit = (t, file) => {
@@ -579,27 +613,39 @@ export default class OxmutateRunner extends Base {
579
613
  if (result?.state === 'fail' && !f.tasks?.length)
580
614
  fileErrors.push({ file: filepath, error: (result.errors?.[0]?.message ?? 'failed to collect').slice(0, FILE_ERROR_CHARS) });
581
615
  }
582
- emit({ type: 'dry', tests, coverage: ns.mutantCoverage, fileErrors, runOnce: [...runOnce], modules: Object.fromEntries(dryModules) });
616
+ emit({ type: 'dry', tests, coverage: ns.mutantCoverage, fileErrors, runOnce: [...runOnce], modules: Object.fromEntries(dryModules), unhandled });
583
617
  }
584
618
  else if (mode === 'isolated') {
585
619
  const failed = [];
586
620
  let ran = 0;
621
+ let decisive = 0;
587
622
  const visit = (t) => {
588
623
  if (t.type === 'test' && (t.result?.state === 'pass' || t.result?.state === 'fail'))
589
624
  ran++;
590
- if (t.type === 'test' && t.result?.state === 'fail')
625
+ if (t.type === 'test' && t.result?.state === 'fail') {
591
626
  failed.push(t.id);
627
+ if (!ownTimeout(t))
628
+ decisive++;
629
+ }
592
630
  for (const c of t.tasks ?? [])
593
631
  visit(c);
594
632
  };
595
633
  for (const f of files) {
596
634
  visit(f);
597
- if (f.result?.state === 'fail' && !failed.length)
635
+ if (f.result?.state === 'fail' && !failed.length) {
598
636
  failed.push(f.id);
637
+ decisive++;
638
+ }
599
639
  }
640
+ const [first] = files;
641
+ if (unhandled > 0)
642
+ decisive++;
643
+ if (unhandled > 0 && !failed.length && first)
644
+ failed.push(first.id);
600
645
  const timeout = ranAway() || deadlocked;
601
- emit({ type: 'isolated', id: env[ACTIVE_MUTANT_ENV] ?? ns.activeMutant, killed: failed.length > 0 && !timeout, failed, ran, ...(timeout && { timeout }), ...(deadlocked && { deadlock: true }) });
646
+ emit({ type: 'isolated', id: env[ACTIVE_MUTANT_ENV] ?? ns.activeMutant, killed: failed.length > 0 && !timeout, failed, ran, ...(timeout && { timeout }), ...(deadlocked && { deadlock: true }), ...(failed.length > 0 && decisive === 0 && { slow: true }) });
602
647
  }
648
+ closeFiles();
603
649
  return super.onAfterRunFiles?.(files);
604
650
  }
605
651
  }
package/dist/scheduler.js CHANGED
@@ -3,6 +3,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { beatFile, classifyStall, CPU_WINDOW_MS, cpuShare, readBeat } from './heartbeat.js';
6
+ import { markReader } from './progress-file.js';
6
7
  import { env, stderr } from './runtime.js';
7
8
  const childJs = fileURLToPath(new URL('./child.js', import.meta.url));
8
9
  const STDERR_TAIL = 4000;
@@ -56,7 +57,8 @@ class Server {
56
57
  });
57
58
  }
58
59
  run(unit, budget) {
59
- writeFileSync(unit.progress, '');
60
+ const progress = markReader(unit.progress);
61
+ const sent = performance.now();
60
62
  return new Promise((resolve) => {
61
63
  let settled = false;
62
64
  const finish = (o) => {
@@ -64,18 +66,13 @@ class Server {
64
66
  return;
65
67
  settled = true;
66
68
  clearInterval(timer);
69
+ progress.close();
67
70
  this.onMessage = undefined;
68
71
  this.onExit = undefined;
69
- resolve(o);
72
+ resolve({ ...o, ms: performance.now() - sent });
70
73
  };
71
74
  const timer = setInterval(() => {
72
- let line = '';
73
- try {
74
- line = readFileSync(unit.progress, 'utf8').trim();
75
- }
76
- catch {
77
- return;
78
- }
75
+ const line = progress.read();
79
76
  if (!line)
80
77
  return;
81
78
  const [id = '', since = '0'] = line.split(' ');
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "oxmutate",
3
- "version": "1.5.0",
3
+ "version": "2.0.1",
4
4
  "description": "Mutation testing for JavaScript and TypeScript on the oxc engine, run warm inside the project's own Vitest, with CI lanes, SARIF and an agent surface. No dependency outside the burgee family. A Stryker migration path.",
5
5
  "license": "MIT",
6
+ "author": "Ofri Peretz <ofriperetzdev@gmail.com>",
6
7
  "repository": {
7
8
  "type": "git",
8
9
  "url": "git+https://github.com/ofri-peretz/burgee.git",
@@ -14,8 +15,13 @@
14
15
  "homepage": "https://oxmutate.interlace.tools",
15
16
  "type": "module",
16
17
  "private": false,
18
+ "sideEffects": [
19
+ "./dist/bin.js",
20
+ "./dist/child.js",
21
+ "./dist/runner.js"
22
+ ],
17
23
  "engines": {
18
- "node": "^20.19.0 || >=22.13.0"
24
+ "node": ">=22.13.0"
19
25
  },
20
26
  "bin": {
21
27
  "oxmutate": "./dist/bin.js"
@@ -57,6 +63,24 @@
57
63
  ]
58
64
  }
59
65
  },
66
+ "keywords": [
67
+ "mutation-testing",
68
+ "mutation",
69
+ "mutation-score",
70
+ "stryker",
71
+ "vitest",
72
+ "oxc",
73
+ "test-quality",
74
+ "sarif",
75
+ "typescript",
76
+ "javascript",
77
+ "ci",
78
+ "cli"
79
+ ],
80
+ "publishConfig": {
81
+ "access": "public",
82
+ "provenance": true
83
+ },
60
84
  "files": [
61
85
  "dist",
62
86
  "binding/oxmutate.*.node",
@@ -75,8 +99,8 @@
75
99
  "compat:mutation": "vitest run --config compat/vitest.config.ts"
76
100
  },
77
101
  "dependencies": {
78
- "bellpull": "^1.0.10",
79
- "burgee": "^1.9.2"
102
+ "bellpull": "^2.0.0",
103
+ "burgee": "^2.0.0"
80
104
  },
81
105
  "peerDependencies": {
82
106
  "vitest": ">=4.1.0"