oxmutate 1.4.0 → 2.0.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.
- 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 +16 -0
- package/dist/coordinator.js +51 -5
- package/dist/reports.d.ts +0 -1
- package/dist/reports.js +12 -1
- package/dist/runner.d.ts +2 -1
- package/dist/runner.js +59 -17
- package/dist/scheduler.js +8 -10
- 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,11 +45,27 @@ 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;
|
|
51
64
|
crashes: number;
|
|
52
65
|
canaryFiles: number;
|
|
66
|
+
/** Of the verdicts the incremental cache would have reused, how many were run afresh to check it, and how many disagreed on detected or not. */
|
|
67
|
+
auditSampled: number;
|
|
68
|
+
auditDisagreed: number;
|
|
53
69
|
/** Contexts restarted because a mutant left state behind (the per-execution canary fired). */
|
|
54
70
|
pollutionRestarts: number;
|
|
55
71
|
/** Units after which the project on disk had changed (diskguard.ts): put back, and their verdicts and those of the units running beside them re-run. */
|
package/dist/coordinator.js
CHANGED
|
@@ -12,6 +12,7 @@ import { globToRegExp, matchFiles } from './files.js';
|
|
|
12
12
|
import { changedLines, parseMutateSpec, shardOf, touches, withinRange } from './lanes.js';
|
|
13
13
|
import { native } from './native.js';
|
|
14
14
|
import { startProgress } from './progress.js';
|
|
15
|
+
import { VERSION } from './reports.js';
|
|
15
16
|
import { FILE_KEY, env, stderr } from './runtime.js';
|
|
16
17
|
import { createPool } from './scheduler.js';
|
|
17
18
|
import { summarizeSpec, tracesFor } from './spec-oracle.js';
|
|
@@ -27,7 +28,26 @@ function readRecords(file) {
|
|
|
27
28
|
.map((l) => JSON.parse(l));
|
|
28
29
|
}
|
|
29
30
|
const sha = (s) => createHash('sha256').update(s).digest('hex').slice(0, 16);
|
|
31
|
+
const LOCKFILES = ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'];
|
|
32
|
+
function lockfileHash(root) {
|
|
33
|
+
const found = LOCKFILES.map((f) => path.join(root, f)).find((f) => existsSync(f));
|
|
34
|
+
if (found)
|
|
35
|
+
return sha(readFileSync(found, 'utf8'));
|
|
36
|
+
const above = path.dirname(root);
|
|
37
|
+
return above === root ? 'none' : lockfileHash(above);
|
|
38
|
+
}
|
|
39
|
+
const verdictOptions = (o) => ({
|
|
40
|
+
timeoutMS: o.timeoutMS,
|
|
41
|
+
timeoutFactor: o.timeoutFactor,
|
|
42
|
+
excludedMutations: [...o.excludedMutations].sort(),
|
|
43
|
+
ignoreStatic: o.ignoreStatic,
|
|
44
|
+
strykerCompat: o.strykerCompat,
|
|
45
|
+
testOverrides: o.testOverrides,
|
|
46
|
+
vitestConfig: o.vitestConfig && existsSync(o.vitestConfig) ? sha(readFileSync(o.vitestConfig, 'utf8')) : null,
|
|
47
|
+
});
|
|
30
48
|
const UNIT_PAIRS = 400;
|
|
49
|
+
const detected = (s) => s === 'Killed' || s === 'Timeout';
|
|
50
|
+
const AUDIT_SHARE = 0.05;
|
|
31
51
|
const DEADLOCK_REASON = 'its test deadlocked: nothing left in the worker could resume it (a hang, called in under a second)';
|
|
32
52
|
function timeoutReason(v) {
|
|
33
53
|
if (v.deadlock)
|
|
@@ -53,8 +73,8 @@ export async function run(options) {
|
|
|
53
73
|
const { root } = options;
|
|
54
74
|
const work = mkdtempSync(path.join(tmpdir(), 'oxmutate-'));
|
|
55
75
|
const stats = {
|
|
56
|
-
files: 0, mutants: 0, instrumentMs: 0, dryRunMs: 0, mutationMs: 0, staticMs: 0, staticRecollections: 0, isolatedMs: 0, wallMs: 0, testExecutions: 0,
|
|
57
|
-
watchdogKills: 0, idleStalls: 0, deadlocks: 0, crashes: 0, canaryFiles: 0, pollutionRestarts: 0, diskWrites: 0, survivorsOverturned: 0, timeoutsOverturned: 0, compileErrors: 0, isolatedRuns: 0, reused: 0, flakyTests: 0, flakyMutants: 0, units: 0, serversSpawned: 0,
|
|
76
|
+
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
|
+
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,
|
|
58
78
|
};
|
|
59
79
|
let unitSeq = 0;
|
|
60
80
|
const fileFor = (kind, ext) => path.join(work, `${kind}-${String(unitSeq++)}.${ext}`);
|
|
@@ -169,7 +189,10 @@ export async function run(options) {
|
|
|
169
189
|
if (fileErrors.length) {
|
|
170
190
|
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('; ')}`);
|
|
171
191
|
}
|
|
172
|
-
|
|
192
|
+
const unhandled = recs.reduce((n, r) => n + (r.unhandled ?? 0), 0);
|
|
193
|
+
if (unhandled > 0)
|
|
194
|
+
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`);
|
|
195
|
+
return { tests: recs.flatMap((r) => r.tests ?? []), perTest, static: stat, runOnce: recs.flatMap((r) => r.runOnce ?? []), modules: Object.assign({}, ...recs.map((r) => r.modules ?? {})) };
|
|
173
196
|
};
|
|
174
197
|
progress.phase('dry run');
|
|
175
198
|
const d0 = performance.now();
|
|
@@ -263,13 +286,23 @@ export async function run(options) {
|
|
|
263
286
|
}
|
|
264
287
|
return h;
|
|
265
288
|
};
|
|
266
|
-
const
|
|
289
|
+
const envKey = sha(JSON.stringify({ oxmutate: VERSION, node: process.version, lock: lockfileHash(root), options: verdictOptions(options) }));
|
|
290
|
+
const fingerprint = (m) => {
|
|
291
|
+
const testFiles = new Set([...m.coveredBy.map((t) => testInfo.get(t)?.file ?? ''), ...(staticFiles.get(m.id) ?? [])]);
|
|
292
|
+
const graph = new Set([...testFiles].flatMap((f) => dry.modules[f] ?? []).filter((f) => !testFiles.has(f) && f !== path.join(root, m.file)));
|
|
293
|
+
return sha([envKey, sha(sources[m.file] ?? ''), ...[...testFiles].sort().map(fileHash), '|', ...[...graph].sort().map((f) => `${f}=${fileHash(f)}`)].join(':'));
|
|
294
|
+
};
|
|
295
|
+
const audited = new Map();
|
|
267
296
|
const cache = options.incremental && existsSync(options.incrementalFile) ? JSON.parse(readFileSync(options.incrementalFile, 'utf8')).mutants : {};
|
|
268
297
|
for (const m of mutants) {
|
|
269
298
|
if (m.status === 'Ignored' || (!m.static && m.coveredBy.length === 0))
|
|
270
299
|
continue;
|
|
271
300
|
const hit = cache[m.id];
|
|
272
301
|
if (hit && hit.fp === fingerprint(m)) {
|
|
302
|
+
if (Math.random() < AUDIT_SHARE) {
|
|
303
|
+
audited.set(m.id, hit.status);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
273
306
|
m.status = hit.status;
|
|
274
307
|
m.killedBy = hit.killedBy;
|
|
275
308
|
m.testsCompleted = hit.testsCompleted;
|
|
@@ -382,6 +415,7 @@ export async function run(options) {
|
|
|
382
415
|
const killedSoFar = (spec) => new Set([...runaways, ...crashedIds, ...records.filter((r) => r.type === 'mutant' && r.status !== 'Survived' && Boolean(r.spec) === spec).map((r) => r.id ?? '')]);
|
|
383
416
|
const afterUnit = async (unit, outcome) => {
|
|
384
417
|
inFlight.delete(unit.results);
|
|
418
|
+
stats.warmUnitMs += outcome.ms ?? 0;
|
|
385
419
|
const wrote = await diskWrittenDuring(unit);
|
|
386
420
|
if (wrote)
|
|
387
421
|
return wrote;
|
|
@@ -454,6 +488,7 @@ export async function run(options) {
|
|
|
454
488
|
if (r.type === 'mutant' && r.id)
|
|
455
489
|
(byMutant.get(r.id) ?? byMutant.set(r.id, []).get(r.id))?.push(r);
|
|
456
490
|
stats.testExecutions = records.filter((r) => r.type === 'mutant').length;
|
|
491
|
+
stats.warmArmedMs = records.reduce((sum, r) => sum + (r.type === 'mutant' ? (r.ms ?? 0) : 0), 0);
|
|
457
492
|
stats.canaryFiles = new Set(records.filter((r) => r.type === 'polluted' && r.file).map((r) => r.file ?? '')).size;
|
|
458
493
|
const isolatedQueue = [];
|
|
459
494
|
for (const m of toRun) {
|
|
@@ -466,6 +501,7 @@ export async function run(options) {
|
|
|
466
501
|
continue;
|
|
467
502
|
}
|
|
468
503
|
else if (runaways.has(m.id)) {
|
|
504
|
+
stats.isolatedByCause.stopped++;
|
|
469
505
|
isolatedQueue.push(m);
|
|
470
506
|
}
|
|
471
507
|
else if (crashedIds.has(m.id)) {
|
|
@@ -484,6 +520,7 @@ export async function run(options) {
|
|
|
484
520
|
m.testsCompleted = recs.length;
|
|
485
521
|
}
|
|
486
522
|
else {
|
|
523
|
+
stats.isolatedByCause.unfinished++;
|
|
487
524
|
isolatedQueue.push(m);
|
|
488
525
|
}
|
|
489
526
|
}
|
|
@@ -644,11 +681,13 @@ export async function run(options) {
|
|
|
644
681
|
const s0 = performance.now();
|
|
645
682
|
const staticBack = env['OXMUTATE_ISOLATE_STATIC'] ? staticMutants : await decideStaticLive(staticMutants);
|
|
646
683
|
stats.staticMs = performance.now() - s0;
|
|
684
|
+
stats.isolatedByCause.static = staticBack.length;
|
|
647
685
|
isolatedQueue.push(...staticBack);
|
|
648
686
|
progress.phase(`isolated runs (static mutants and stragglers: ${String(isolatedQueue.length)})`);
|
|
649
687
|
const i0 = performance.now();
|
|
650
688
|
const first = await runIsolated(isolatedQueue);
|
|
651
689
|
const stopped = isolatedQueue.filter((m) => first.get(m.id)?.status === 'Timeout' && !first.get(m.id)?.idle);
|
|
690
|
+
stats.isolatedByCause.rerun += stopped.length;
|
|
652
691
|
const again = await runIsolated(stopped, RERUN_SLACK);
|
|
653
692
|
for (const m of stopped) {
|
|
654
693
|
const v = again.get(m.id);
|
|
@@ -668,8 +707,10 @@ export async function run(options) {
|
|
|
668
707
|
}
|
|
669
708
|
}
|
|
670
709
|
const survivors = toRun.filter((m) => m.status === 'Survived' && new Set(m.coveredBy).size > 1);
|
|
710
|
+
stats.isolatedByCause.confirm += survivors.length;
|
|
671
711
|
const confirmed = await runIsolated(survivors);
|
|
672
712
|
const stoppedHere = survivors.filter((m) => confirmed.get(m.id)?.status === 'Timeout' && !confirmed.get(m.id)?.idle);
|
|
713
|
+
stats.isolatedByCause.rerun += stoppedHere.length;
|
|
673
714
|
const recheck = await runIsolated(stoppedHere, RERUN_SLACK);
|
|
674
715
|
for (const m of stoppedHere) {
|
|
675
716
|
const v = recheck.get(m.id);
|
|
@@ -702,6 +743,7 @@ export async function run(options) {
|
|
|
702
743
|
const flakyCandidates = toRun.filter((m) => m.coveredBy.some((t) => flakyTests.has(t)));
|
|
703
744
|
const seen = new Map(flakyCandidates.map((m) => [m.id, new Set([m.status === 'Timeout' ? 'Killed' : m.status])]));
|
|
704
745
|
for (let n = 1; n <= options.flakyReruns && flakyCandidates.length; n++) {
|
|
746
|
+
stats.isolatedByCause.flaky += flakyCandidates.length;
|
|
705
747
|
const again = await runIsolated(flakyCandidates);
|
|
706
748
|
for (const m of flakyCandidates) {
|
|
707
749
|
const v = again.get(m.id);
|
|
@@ -729,7 +771,11 @@ export async function run(options) {
|
|
|
729
771
|
stats.crashes = pool.stats.crashed;
|
|
730
772
|
if (options.incremental) {
|
|
731
773
|
mkdirSync(path.dirname(options.incrementalFile), { recursive: true });
|
|
732
|
-
|
|
774
|
+
stats.auditSampled = audited.size;
|
|
775
|
+
stats.auditDisagreed = [...audited].filter(([id, was]) => detected(byId.get(id)?.status) !== detected(was)).length;
|
|
776
|
+
if (stats.auditDisagreed > 0)
|
|
777
|
+
stderr.write(`oxmutate: ${String(stats.auditDisagreed)} of ${String(audited.size)} audited cached verdicts disagree with a fresh run; the incremental cache is rebuilt from this run\n`);
|
|
778
|
+
const next = stats.auditDisagreed > 0 ? {} : { ...cache };
|
|
733
779
|
for (const m of mutants) {
|
|
734
780
|
if (['Killed', 'Survived', 'Timeout'].includes(m.status) && !m.flaky)
|
|
735
781
|
next[m.id] = { fp: fingerprint(m), status: m.status, killedBy: m.killedBy, testsCompleted: m.testsCompleted };
|
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
|
@@ -102,7 +102,7 @@ function corrections(s) {
|
|
|
102
102
|
s.survivorsOverturned && `${String(s.survivorsOverturned)} survivor(s) killed by a whole-file re-run`,
|
|
103
103
|
s.timeoutsOverturned && `${String(s.timeoutsOverturned)} timeout(s) that finished given 3x the time`,
|
|
104
104
|
s.compileErrors && `${String(s.compileErrors)} survivor(s) that do not type-check, out of the score`,
|
|
105
|
-
s.reused && `${String(s.reused)} verdict(s) reused`,
|
|
105
|
+
s.reused && `${String(s.reused)} verdict(s) reused${s.auditSampled ? ` (${String(s.auditSampled)} audited, ${String(s.auditDisagreed)} disagreeing)` : ''}`,
|
|
106
106
|
s.crashes && `${String(s.crashes)} worker crash(es)`,
|
|
107
107
|
].filter((c) => Boolean(c));
|
|
108
108
|
}
|
|
@@ -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
|
@@ -35,6 +35,7 @@ declare const OxmutateRunner_base: new (config: unknown) => {
|
|
|
35
35
|
evaluatedModules?: {
|
|
36
36
|
getModuleById: (id: string) => unknown;
|
|
37
37
|
invalidateModule: (node: unknown) => void;
|
|
38
|
+
fileToModulesMap?: Map<string, unknown>;
|
|
38
39
|
};
|
|
39
40
|
};
|
|
40
41
|
_currentSpecification?: unknown;
|
|
@@ -94,6 +95,6 @@ export default class OxmutateRunner extends OxmutateRunner_base {
|
|
|
94
95
|
private collectCopies;
|
|
95
96
|
/** Reset per-try state (snapshot counters, assertion counts) the way Vitest does for a retry. */
|
|
96
97
|
private retryReset;
|
|
97
|
-
onAfterRunFiles(files: AnyTask[]): unknown
|
|
98
|
+
onAfterRunFiles(files: AnyTask[]): Promise<unknown>;
|
|
98
99
|
}
|
|
99
100
|
export {};
|
package/dist/runner.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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';
|
|
@@ -53,6 +53,11 @@ if (mode === 'isolated' && env['OXMUTATE_HIT_LIMIT']) {
|
|
|
53
53
|
ns.hitLimit = Number(env['OXMUTATE_HIT_LIMIT']);
|
|
54
54
|
}
|
|
55
55
|
const ranAway = () => ns.hitCount !== undefined && ns.hitLimit !== undefined && ns.hitCount > ns.hitLimit;
|
|
56
|
+
let unhandled = 0;
|
|
57
|
+
process.on('unhandledRejection', () => void unhandled++);
|
|
58
|
+
process.on('uncaughtException', () => void unhandled++);
|
|
59
|
+
const realSetImmediate = globalThis.setImmediate;
|
|
60
|
+
const settle = () => new Promise((resolve) => realSetImmediate(resolve));
|
|
56
61
|
const deadlocks = mode === 'dry' ? undefined : watch();
|
|
57
62
|
let deadlocked = false;
|
|
58
63
|
let tornDown = false;
|
|
@@ -120,8 +125,9 @@ function takeCopy(test) {
|
|
|
120
125
|
const file = collections.get(test.file?.filepath ?? '')?.[n];
|
|
121
126
|
return file && find(file, test.id);
|
|
122
127
|
}
|
|
128
|
+
const dryModules = new Map();
|
|
123
129
|
const PROBE_ID = '__oxmutate_probe__';
|
|
124
|
-
async function probe(test, fn) {
|
|
130
|
+
async function probe(test, fn, reset) {
|
|
125
131
|
const file = test.file?.filepath;
|
|
126
132
|
const scope = test.suite?.id ?? file;
|
|
127
133
|
if (!file || !scope || probed.has(scope) || runOnce.has(file))
|
|
@@ -129,6 +135,7 @@ async function probe(test, fn) {
|
|
|
129
135
|
probed.add(scope);
|
|
130
136
|
await afterEach(test, test.context, []);
|
|
131
137
|
const cleanups = await beforeEach(test, test.context);
|
|
138
|
+
await reset();
|
|
132
139
|
ns.currentTestId = PROBE_ID;
|
|
133
140
|
try {
|
|
134
141
|
await fn();
|
|
@@ -146,9 +153,31 @@ async function probe(test, fn) {
|
|
|
146
153
|
}
|
|
147
154
|
const POLLUTION_ERROR_CHARS = 300;
|
|
148
155
|
const FILE_ERROR_CHARS = 500;
|
|
156
|
+
let resultsFd;
|
|
157
|
+
let progressFd;
|
|
158
|
+
let markLength = 0;
|
|
149
159
|
function emit(record) {
|
|
150
|
-
if (resultsPath)
|
|
151
|
-
|
|
160
|
+
if (!resultsPath)
|
|
161
|
+
return;
|
|
162
|
+
resultsFd ??= openSync(resultsPath, 'a');
|
|
163
|
+
writeSync(resultsFd, `${JSON.stringify(record)}\n`);
|
|
164
|
+
}
|
|
165
|
+
function mark(text) {
|
|
166
|
+
if (!progressPath)
|
|
167
|
+
return;
|
|
168
|
+
progressFd ??= openSync(progressPath, 'w');
|
|
169
|
+
const padded = text.trimEnd().padEnd(markLength);
|
|
170
|
+
markLength = padded.length;
|
|
171
|
+
if (padded)
|
|
172
|
+
writeSync(progressFd, padded, 0);
|
|
173
|
+
}
|
|
174
|
+
function closeFiles() {
|
|
175
|
+
for (const fd of [resultsFd, progressFd])
|
|
176
|
+
if (fd !== undefined)
|
|
177
|
+
closeSync(fd);
|
|
178
|
+
resultsFd = undefined;
|
|
179
|
+
progressFd = undefined;
|
|
180
|
+
markLength = 0;
|
|
152
181
|
}
|
|
153
182
|
function chain(test) {
|
|
154
183
|
const suites = [];
|
|
@@ -270,7 +299,7 @@ export default class OxmutateRunner extends Base {
|
|
|
270
299
|
try {
|
|
271
300
|
await guarded(test.id, fn);
|
|
272
301
|
if (mode === 'dry')
|
|
273
|
-
await probe(test, fn);
|
|
302
|
+
await probe(test, fn, () => this.retryReset(test, 1));
|
|
274
303
|
}
|
|
275
304
|
catch (e) {
|
|
276
305
|
if (isDeadlock(e))
|
|
@@ -358,9 +387,13 @@ export default class OxmutateRunner extends Base {
|
|
|
358
387
|
ns.hitLimit = plan.hitLimits[id] ?? Number.MAX_SAFE_INTEGER;
|
|
359
388
|
let status = 'Survived';
|
|
360
389
|
let deadlock = false;
|
|
390
|
+
const before = unhandled;
|
|
361
391
|
try {
|
|
362
392
|
await setup();
|
|
363
393
|
await guarded(current.id, run);
|
|
394
|
+
await settle();
|
|
395
|
+
if (unhandled > before)
|
|
396
|
+
status = 'Killed';
|
|
364
397
|
}
|
|
365
398
|
catch (e) {
|
|
366
399
|
deadlock = isDeadlock(e);
|
|
@@ -376,8 +409,7 @@ export default class OxmutateRunner extends Base {
|
|
|
376
409
|
for (const id of ids) {
|
|
377
410
|
if (done.has(id))
|
|
378
411
|
continue;
|
|
379
|
-
|
|
380
|
-
writeFileSync(progressPath, `${id} ${String(realNow())}\n`);
|
|
412
|
+
mark(`${id} ${String(realNow())}\n`);
|
|
381
413
|
let { status, ms, deadlock } = await runArmed(id);
|
|
382
414
|
if (status === 'Polluted')
|
|
383
415
|
break;
|
|
@@ -401,14 +433,13 @@ export default class OxmutateRunner extends Base {
|
|
|
401
433
|
break;
|
|
402
434
|
}
|
|
403
435
|
if (tornDown) {
|
|
404
|
-
|
|
405
|
-
writeFileSync(progressPath, '');
|
|
436
|
+
mark('');
|
|
406
437
|
return;
|
|
407
438
|
}
|
|
408
439
|
if (!polluted)
|
|
409
440
|
await canary();
|
|
410
|
-
if (
|
|
411
|
-
|
|
441
|
+
if (ids.length)
|
|
442
|
+
mark('');
|
|
412
443
|
if (attempt > 0)
|
|
413
444
|
await fresh();
|
|
414
445
|
try {
|
|
@@ -425,6 +456,10 @@ export default class OxmutateRunner extends Base {
|
|
|
425
456
|
return super.importFile?.(filepath, source);
|
|
426
457
|
}
|
|
427
458
|
async onAfterRunSuite(suite) {
|
|
459
|
+
if (mode === 'dry' && suite.filepath) {
|
|
460
|
+
const files = [...(this.workerState?.evaluatedModules?.fileToModulesMap?.keys() ?? [])];
|
|
461
|
+
dryModules.set(suite.filepath, files.filter((f) => path.isAbsolute(f) && !f.split(path.sep).includes('node_modules')));
|
|
462
|
+
}
|
|
428
463
|
const copies = suite.filepath ? collections.get(suite.filepath) : undefined;
|
|
429
464
|
if (suite.filepath && copies) {
|
|
430
465
|
collections.delete(suite.filepath);
|
|
@@ -469,10 +504,14 @@ export default class OxmutateRunner extends Base {
|
|
|
469
504
|
ns.hitCount = armed ? 0 : undefined;
|
|
470
505
|
ns.hitLimit = armed ? (staticLimits[armed] ?? Number.MAX_SAFE_INTEGER) : undefined;
|
|
471
506
|
deadlocked = false;
|
|
507
|
+
const before = unhandled;
|
|
472
508
|
try {
|
|
473
509
|
vi.resetModules();
|
|
474
510
|
const [file] = await collectTests([filepath], this);
|
|
475
511
|
const result = file ? await runCollected(file, runnable) : { failed: [filepath], ran: 0 };
|
|
512
|
+
await settle();
|
|
513
|
+
if (unhandled > before && result.failed.length === 0)
|
|
514
|
+
result.failed.push(filepath);
|
|
476
515
|
return { ...result, timeout: ranAway() || deadlocked, ...(deadlocked ? { deadlock: true } : {}) };
|
|
477
516
|
}
|
|
478
517
|
finally {
|
|
@@ -490,8 +529,7 @@ export default class OxmutateRunner extends Base {
|
|
|
490
529
|
};
|
|
491
530
|
while (rest.length) {
|
|
492
531
|
const id = rest.shift();
|
|
493
|
-
|
|
494
|
-
writeFileSync(progressPath, `${id} ${String(realNow())}\n`);
|
|
532
|
+
mark(`${id} ${String(realNow())}\n`);
|
|
495
533
|
held.push({ id, verdict: await evaluate(id) });
|
|
496
534
|
if (held.at(-1)?.verdict.deadlock)
|
|
497
535
|
return back('torn down: a deadlocked run is still pending in this worker');
|
|
@@ -512,8 +550,7 @@ export default class OxmutateRunner extends Base {
|
|
|
512
550
|
held = [];
|
|
513
551
|
every = Math.min(STATIC_CANARY_MAX, every * 2);
|
|
514
552
|
}
|
|
515
|
-
|
|
516
|
-
writeFileSync(progressPath, '');
|
|
553
|
+
mark('');
|
|
517
554
|
vi.resetModules();
|
|
518
555
|
}
|
|
519
556
|
async onBeforeRunTask(test) {
|
|
@@ -557,7 +594,8 @@ export default class OxmutateRunner extends Base {
|
|
|
557
594
|
async retryReset(test, attempt) {
|
|
558
595
|
await super.onBeforeTryTask?.(test, { retry: attempt, repeats: 0 });
|
|
559
596
|
}
|
|
560
|
-
onAfterRunFiles(files) {
|
|
597
|
+
async onAfterRunFiles(files) {
|
|
598
|
+
await settle();
|
|
561
599
|
if (mode === 'dry') {
|
|
562
600
|
const tests = [];
|
|
563
601
|
const visit = (t, file) => {
|
|
@@ -574,7 +612,7 @@ export default class OxmutateRunner extends Base {
|
|
|
574
612
|
if (result?.state === 'fail' && !f.tasks?.length)
|
|
575
613
|
fileErrors.push({ file: filepath, error: (result.errors?.[0]?.message ?? 'failed to collect').slice(0, FILE_ERROR_CHARS) });
|
|
576
614
|
}
|
|
577
|
-
emit({ type: 'dry', tests, coverage: ns.mutantCoverage, fileErrors, runOnce: [...runOnce] });
|
|
615
|
+
emit({ type: 'dry', tests, coverage: ns.mutantCoverage, fileErrors, runOnce: [...runOnce], modules: Object.fromEntries(dryModules), unhandled });
|
|
578
616
|
}
|
|
579
617
|
else if (mode === 'isolated') {
|
|
580
618
|
const failed = [];
|
|
@@ -592,9 +630,13 @@ export default class OxmutateRunner extends Base {
|
|
|
592
630
|
if (f.result?.state === 'fail' && !failed.length)
|
|
593
631
|
failed.push(f.id);
|
|
594
632
|
}
|
|
633
|
+
const [first] = files;
|
|
634
|
+
if (unhandled > 0 && !failed.length && first)
|
|
635
|
+
failed.push(first.id);
|
|
595
636
|
const timeout = ranAway() || deadlocked;
|
|
596
637
|
emit({ type: 'isolated', id: env[ACTIVE_MUTANT_ENV] ?? ns.activeMutant, killed: failed.length > 0 && !timeout, failed, ran, ...(timeout && { timeout }), ...(deadlocked && { deadlock: true }) });
|
|
597
638
|
}
|
|
639
|
+
closeFiles();
|
|
598
640
|
return super.onAfterRunFiles?.(files);
|
|
599
641
|
}
|
|
600
642
|
}
|
package/dist/scheduler.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { fork } from 'node:child_process';
|
|
2
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { closeSync, openSync, readFileSync, readSync, 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';
|
|
@@ -8,6 +8,7 @@ const childJs = fileURLToPath(new URL('./child.js', import.meta.url));
|
|
|
8
8
|
const STDERR_TAIL = 4000;
|
|
9
9
|
const WATCHDOG_POLL_MS = 50;
|
|
10
10
|
const CLOSE_GRACE_MS = 2000;
|
|
11
|
+
const PROGRESS_BYTES = 256;
|
|
11
12
|
function inFlight(progress) {
|
|
12
13
|
try {
|
|
13
14
|
return readFileSync(progress, 'utf8').trim().split(' ')[0] || undefined;
|
|
@@ -56,7 +57,9 @@ class Server {
|
|
|
56
57
|
});
|
|
57
58
|
}
|
|
58
59
|
run(unit, budget) {
|
|
59
|
-
|
|
60
|
+
const progress = openSync(unit.progress, 'w+');
|
|
61
|
+
const mark = Buffer.alloc(PROGRESS_BYTES);
|
|
62
|
+
const sent = performance.now();
|
|
60
63
|
return new Promise((resolve) => {
|
|
61
64
|
let settled = false;
|
|
62
65
|
const finish = (o) => {
|
|
@@ -64,18 +67,13 @@ class Server {
|
|
|
64
67
|
return;
|
|
65
68
|
settled = true;
|
|
66
69
|
clearInterval(timer);
|
|
70
|
+
closeSync(progress);
|
|
67
71
|
this.onMessage = undefined;
|
|
68
72
|
this.onExit = undefined;
|
|
69
|
-
resolve(o);
|
|
73
|
+
resolve({ ...o, ms: performance.now() - sent });
|
|
70
74
|
};
|
|
71
75
|
const timer = setInterval(() => {
|
|
72
|
-
|
|
73
|
-
try {
|
|
74
|
-
line = readFileSync(unit.progress, 'utf8').trim();
|
|
75
|
-
}
|
|
76
|
-
catch {
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
76
|
+
const line = mark.toString('utf8', 0, readSync(progress, mark, 0, PROGRESS_BYTES, 0)).trim();
|
|
79
77
|
if (!line)
|
|
80
78
|
return;
|
|
81
79
|
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.0",
|
|
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"
|