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 +2 -2
- package/binding/oxmutate.darwin-arm64.node +0 -0
- package/binding/oxmutate.darwin-x64.node +0 -0
- package/binding/oxmutate.win32-x64-msvc.node +0 -0
- package/dist/coordinator.d.ts +13 -0
- package/dist/coordinator.js +29 -7
- package/dist/progress-file.js +22 -0
- package/dist/reports.d.ts +0 -1
- package/dist/reports.js +11 -0
- package/dist/runner.d.ts +1 -1
- package/dist/runner.js +66 -20
- package/dist/scheduler.js +6 -9
- package/package.json +28 -4
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-
|
|
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
|
|
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
|
|
Binary file
|
package/dist/coordinator.d.ts
CHANGED
|
@@ -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;
|
package/dist/coordinator.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
406
|
-
writeFileSync(progressPath, '');
|
|
437
|
+
mark('');
|
|
407
438
|
return;
|
|
408
439
|
}
|
|
409
440
|
if (!polluted)
|
|
410
441
|
await canary();
|
|
411
|
-
if (
|
|
412
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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": "
|
|
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": "
|
|
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": "^
|
|
79
|
-
"burgee": "^
|
|
102
|
+
"bellpull": "^2.0.0",
|
|
103
|
+
"burgee": "^2.0.0"
|
|
80
104
|
},
|
|
81
105
|
"peerDependencies": {
|
|
82
106
|
"vitest": ">=4.1.0"
|