oxmutate 1.0.3 → 1.2.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 +18 -9
- package/binding/oxmutate.darwin-arm64.node +0 -0
- package/binding/oxmutate.darwin-x64.node +0 -0
- package/binding/oxmutate.linux-arm64-gnu.node +0 -0
- package/binding/oxmutate.linux-x64-gnu.node +0 -0
- package/binding/oxmutate.linux-x64-musl.node +0 -0
- package/binding/oxmutate.win32-x64-msvc.node +0 -0
- package/dist/checker.js +1 -0
- package/dist/child.js +27 -1
- package/dist/coordinator.d.ts +6 -0
- package/dist/coordinator.js +193 -29
- package/dist/directives.js +55 -0
- package/dist/heartbeat.js +111 -0
- package/dist/lanes.js +1 -0
- package/dist/progress.js +1 -0
- package/dist/reports.js +1 -1
- package/dist/runner.d.ts +21 -0
- package/dist/runner.js +270 -11
- package/dist/runtime.js +1 -0
- package/dist/scheduler.js +51 -12
- package/dist/spec-oracle.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The watchdog's evidence for the difference between a mutant that loops and one that hangs
|
|
3
|
+
* (.sdlc/intents/oxmutate/stall.qnt, D-20261101-idle-stall-is-timeout).
|
|
4
|
+
*
|
|
5
|
+
* Two readings, both taken when a run passes its budget:
|
|
6
|
+
*
|
|
7
|
+
* - **The beat.** The test worker writes the time to its server's beat file every HEARTBEAT_MS
|
|
8
|
+
* from a real interval timer. The beat keeps coming only while the worker's event loop turns. A
|
|
9
|
+
* synchronous loop stops it, and so does a microtask loop.
|
|
10
|
+
* - **The CPU.** The server reports its process's CPU time (every thread, the worker included)
|
|
11
|
+
* every CPU_SAMPLE_MS. Over the last CPU_WINDOW_MS, a loop burns about a core.
|
|
12
|
+
*
|
|
13
|
+
* A run whose worker is inside a test body, with a fresh beat and under IDLE_CPU of a core, is
|
|
14
|
+
* **idle**: the test waits on something the mutant never lets happen (a prompt awaiting a
|
|
15
|
+
* keypress). Anything else is **busy**, including a worker still collecting its file: on a loaded
|
|
16
|
+
* machine a short budget can run out while modules load, at low CPU, and that is a slow machine. On a caique
|
|
17
|
+
* subset 18 of 19 stops read 0–2% CPU with a live beat, and the one loop read 72%
|
|
18
|
+
* (sc8-profile.md). An idle stop is a Timeout as it stands, the way Stryker scores it. A busy stop
|
|
19
|
+
* may be a slow test on a loaded machine, so it keeps its re-run at 3x.
|
|
20
|
+
*
|
|
21
|
+
* The beat file is the server's, named by its pid and set in its environment when it starts, so
|
|
22
|
+
* every worker it ever runs inherits it: a path set per unit could reach a worker that started
|
|
23
|
+
* under an earlier unit, and on caique two stops read a beat that never came.
|
|
24
|
+
*/
|
|
25
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
/** How often the worker writes its beat. */
|
|
28
|
+
export const HEARTBEAT_MS = 250;
|
|
29
|
+
/** A beat older than this means the worker's event loop is not turning: a loop, not a wait. */
|
|
30
|
+
export const STALE_MS = 1000;
|
|
31
|
+
/** How often a server reports its CPU time. */
|
|
32
|
+
export const CPU_SAMPLE_MS = 250;
|
|
33
|
+
/** The window a stop's CPU share is measured over. */
|
|
34
|
+
export const CPU_WINDOW_MS = 1000;
|
|
35
|
+
/** The shortest span of samples a CPU share is read from; less, and the stop counts as busy. */
|
|
36
|
+
export const MIN_CPU_SPAN_MS = 500;
|
|
37
|
+
/** Under this share of one core, with a fresh beat, a stopped run is idle. Measured: idle 0–2%, a loop 72–108%. */
|
|
38
|
+
export const IDLE_CPU = 0.25;
|
|
39
|
+
/** The environment variable that hands a server's beat file to its test workers. */
|
|
40
|
+
export const BEAT_ENV = 'OXMUTATE_BEAT';
|
|
41
|
+
/** A server's beat file, in the run's work directory. */
|
|
42
|
+
export const beatFile = (workDir, pid) => path.join(workDir, `beat-${String(pid)}`);
|
|
43
|
+
// Captured when this module is evaluated, before any test file can install fake timers: a faked
|
|
44
|
+
// clock would write a frozen time, and the watchdog would read a live worker as a stale one.
|
|
45
|
+
const { setInterval: realSetInterval, clearInterval: realClearInterval } = globalThis;
|
|
46
|
+
const realNow = Date.now.bind(Date);
|
|
47
|
+
/** The beat this thread runs: one at a time, whatever re-evaluates this module. */
|
|
48
|
+
// Stryker disable next-line StringLiteral: any key names one registry symbol on every evaluation of this module, which is all the key is for
|
|
49
|
+
const BEAT_KEY = Symbol.for('oxmutate.heartbeat');
|
|
50
|
+
/** Whether a test body is running in this worker (the runner marks it); the beat carries it. */
|
|
51
|
+
let inTest = false;
|
|
52
|
+
/** Mark a test body as running, or done. Only a wait inside a test body can be an idle stall. */
|
|
53
|
+
export function markTest(running) {
|
|
54
|
+
inTest = running;
|
|
55
|
+
}
|
|
56
|
+
/** Start this worker's beat into `file`. Does nothing when no beat file is set. */
|
|
57
|
+
export function startHeartbeat(file) {
|
|
58
|
+
if (!file)
|
|
59
|
+
return;
|
|
60
|
+
const holder = globalThis;
|
|
61
|
+
realClearInterval(holder[BEAT_KEY]);
|
|
62
|
+
/** One beat; false once the run's directory is gone: the run is over, and nothing reads the beat. */
|
|
63
|
+
const beat = () => {
|
|
64
|
+
try {
|
|
65
|
+
writeFileSync(file, inTest ? `${String(realNow())} test` : String(realNow()));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
};
|
|
72
|
+
beat();
|
|
73
|
+
holder[BEAT_KEY] = realSetInterval(() => {
|
|
74
|
+
if (!beat())
|
|
75
|
+
realClearInterval(holder[BEAT_KEY]);
|
|
76
|
+
}, HEARTBEAT_MS);
|
|
77
|
+
// Stryker disable next-line CallExpression: unref only lets a process exit while its beat runs; Vitest ends its workers regardless, so no verdict depends on it
|
|
78
|
+
holder[BEAT_KEY].unref();
|
|
79
|
+
}
|
|
80
|
+
/** The last beat of this server's workers. */
|
|
81
|
+
export function readBeat(file, now = Date.now()) {
|
|
82
|
+
let text;
|
|
83
|
+
try {
|
|
84
|
+
text = String(readFileSync(file));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return { age: Number.POSITIVE_INFINITY, inTest: false };
|
|
88
|
+
}
|
|
89
|
+
const [stamp, where] = text.split(' ');
|
|
90
|
+
const at = Number(stamp);
|
|
91
|
+
return { age: Number.isFinite(at) && at > 0 ? now - at : Number.POSITIVE_INFINITY, inTest: where === 'test' };
|
|
92
|
+
}
|
|
93
|
+
const US_PER_MS = 1000;
|
|
94
|
+
/**
|
|
95
|
+
* The share of one core a server used over the last CPU_WINDOW_MS before `now`, from its samples
|
|
96
|
+
* (oldest first). Undefined when the samples span less than MIN_CPU_SPAN_MS of that window.
|
|
97
|
+
*/
|
|
98
|
+
export function cpuShare(samples, now) {
|
|
99
|
+
const recent = samples.filter((s) => s.at >= now - CPU_WINDOW_MS && s.at <= now);
|
|
100
|
+
const first = recent[0];
|
|
101
|
+
const last = recent.at(-1);
|
|
102
|
+
// Stryker disable next-line LogicalOperator: the first and the last sample are both there or both missing, so `&&` reads the same
|
|
103
|
+
if (!first || !last || last.at - first.at < MIN_CPU_SPAN_MS)
|
|
104
|
+
return undefined;
|
|
105
|
+
return (last.us - first.us) / US_PER_MS / (last.at - first.at);
|
|
106
|
+
}
|
|
107
|
+
/** A stopped run is idle when its worker waits inside a test body, its event loop turning, and its server burns almost no CPU. */
|
|
108
|
+
export function classifyStall(o) {
|
|
109
|
+
return o.beat.inTest && o.beat.age < STALE_MS && (o.cpuShare ?? IDLE_CPU) < IDLE_CPU ? 'idle' : 'busy';
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=heartbeat.js.map
|
package/dist/lanes.js
CHANGED
|
@@ -49,6 +49,7 @@ export function shardOf(id, count) {
|
|
|
49
49
|
// Ids are hex (`native/src/stable_id.rs`), so a code point is a code unit and the shards are
|
|
50
50
|
// the ones `charCodeAt` gave.
|
|
51
51
|
for (let i = 0; i < id.length; i++) {
|
|
52
|
+
// Stryker disable next-line NumericLiteral: `i < id.length`, so codePointAt never returns undefined here
|
|
52
53
|
h ^= id.codePointAt(i) ?? 0;
|
|
53
54
|
h = Math.imul(h, FNV_PRIME) >>> 0;
|
|
54
55
|
}
|
package/dist/progress.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* shows the run moving without a carriage-return mess. Off unless a progress reporter is named.
|
|
6
6
|
*/
|
|
7
7
|
const REPORTERS = new Set(['progress', 'progress-append-only', 'ci', 'dots']);
|
|
8
|
+
// Stryker disable next-line NumericLiteral: a repaint interval; no test can see 1,000 ms against 1,001
|
|
8
9
|
const TTY_EVERY_MS = 1000;
|
|
9
10
|
const APPEND_EVERY_MS = 10_000;
|
|
10
11
|
const MS_PER_S = 1000;
|
package/dist/reports.js
CHANGED
|
@@ -142,7 +142,7 @@ export function textSummary(r, opts = {}) {
|
|
|
142
142
|
`oxmutate ${VERSION}: ${String(r.mutants.length)} mutants in ${String(s.files)} files, ${(s.wallMs / MS_PER_S).toFixed(1)} s`,
|
|
143
143
|
` mutation score ${r.score.mutationScore.toFixed(2)}% (covered ${r.score.covered.toFixed(2)}%)`,
|
|
144
144
|
` killed ${String(count('Killed'))} · timeout ${String(count('Timeout'))} · survived ${String(count('Survived'))} · no coverage ${String(count('NoCoverage'))} · ignored ${String(count('Ignored'))} · errors ${String(count('RuntimeError') + count('CompileError'))}`,
|
|
145
|
-
` instrument ${s.instrumentMs.toFixed(0)} ms · dry run ${(s.dryRunMs / MS_PER_S).toFixed(1)} s · mutants ${(s.mutationMs / MS_PER_S).toFixed(1)} s · isolated ${(s.isolatedMs / MS_PER_S).toFixed(1)} s`,
|
|
145
|
+
` instrument ${s.instrumentMs.toFixed(0)} ms · dry run ${(s.dryRunMs / MS_PER_S).toFixed(1)} s · mutants ${(s.mutationMs / MS_PER_S).toFixed(1)} s · static ${(s.staticMs / MS_PER_S).toFixed(1)} s · isolated ${(s.isolatedMs / MS_PER_S).toFixed(1)} s`,
|
|
146
146
|
];
|
|
147
147
|
const corrected = corrections(s);
|
|
148
148
|
if (corrected.length)
|
package/dist/runner.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ type AnyTask = {
|
|
|
13
13
|
duration?: number;
|
|
14
14
|
};
|
|
15
15
|
tasks?: AnyTask[] | undefined;
|
|
16
|
+
mode?: string;
|
|
17
|
+
context?: unknown;
|
|
16
18
|
};
|
|
17
19
|
/** The family's refusal shape for a missing optional peer (as `controlroom/ink`'s): a code and a fix. */
|
|
18
20
|
export declare class PeerMissingError extends Error {
|
|
@@ -21,6 +23,9 @@ export declare class PeerMissingError extends Error {
|
|
|
21
23
|
constructor(cause: unknown);
|
|
22
24
|
}
|
|
23
25
|
declare const OxmutateRunner_base: new (config: unknown) => {
|
|
26
|
+
onBeforeCollect?(paths: string[]): unknown;
|
|
27
|
+
importFile?(filepath: string, source: string): unknown;
|
|
28
|
+
onBeforeRunSuite?(suite: AnyTask): unknown;
|
|
24
29
|
onCollectStart?(file: unknown): unknown;
|
|
25
30
|
onBeforeTryTask?(test: AnyTask, options: {
|
|
26
31
|
retry: number;
|
|
@@ -42,6 +47,22 @@ export default class OxmutateRunner extends OxmutateRunner_base {
|
|
|
42
47
|
runTask(test: AnyTask & {
|
|
43
48
|
context?: unknown;
|
|
44
49
|
}): Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* The baseline is taken as the test file itself is imported (`source: 'collect'`), after setup
|
|
52
|
+
* files: those run before every collection, re-collections included, and write the same values
|
|
53
|
+
* each time (the family's colour setup sets TERM, COLORTERM and NO_COLOR), so they are not leaks.
|
|
54
|
+
*/
|
|
55
|
+
importFile(filepath: string, source: string): unknown;
|
|
56
|
+
onBeforeRunSuite(suite: AnyTask): Promise<unknown>;
|
|
57
|
+
/**
|
|
58
|
+
* `static` mode (.sdlc/intents/oxmutate/staticwarm.qnt). Re-evaluating the project's modules in
|
|
59
|
+
* this worker costs ~0.5 ms where a fresh worker per (mutant, file) cost ~80 ms (burgee/paratext,
|
|
60
|
+
* 2026-10-30). What a registry reset leaves behind is guarded three ways: the file's own unarmed
|
|
61
|
+
* collection must leave globalThis, process.env and the built-in prototypes as they were; so must
|
|
62
|
+
* each mutant; and an unarmed re-collection after each mutant must pass. Anything else hands the
|
|
63
|
+
* rest of the batch back to the fresh-worker path.
|
|
64
|
+
*/
|
|
65
|
+
private decideStatic;
|
|
45
66
|
/** Reset per-try state (snapshot counters, assertion counts) the way Vitest does for a retry. */
|
|
46
67
|
private retryReset;
|
|
47
68
|
onAfterRunFiles(files: AnyTask[]): unknown;
|
package/dist/runner.js
CHANGED
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
* failure there means an earlier mutant polluted shared state (the canary).
|
|
14
14
|
* - `isolated`: one mutant was armed before import (static mutants and canary re-runs). Run
|
|
15
15
|
* normally; any failed test kills it.
|
|
16
|
+
* - `static`: a batch of static mutants decided in this live worker (staticwarm.qnt). Before the file
|
|
17
|
+
* runs, each mutant is armed, the module registry is reset and the file is collected again, so
|
|
18
|
+
* its tests close over modules evaluated under the mutant; they run with their hooks, then an
|
|
19
|
+
* unarmed re-collection must pass. A file whose own collection touches globals, or a mutant that
|
|
20
|
+
* leaves a trace, sends the rest of the batch back to the fresh-worker path (`static-fallback`).
|
|
16
21
|
*
|
|
17
22
|
* Before each mutant the runner writes a progress line synchronously, so the coordinator can
|
|
18
23
|
* attribute and kill a worker stuck in a synchronous loop that the hit counter cannot break.
|
|
@@ -20,6 +25,7 @@
|
|
|
20
25
|
import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
|
|
21
26
|
import { createRequire } from 'node:module';
|
|
22
27
|
import path from 'node:path';
|
|
28
|
+
import { BEAT_ENV, markTest, startHeartbeat } from './heartbeat.js';
|
|
23
29
|
import { resolveEsm } from './resolve.js';
|
|
24
30
|
import { ACTIVE_MUTANT_ENV, FILE_KEY, NAMESPACE, cwd, env } from './runtime.js';
|
|
25
31
|
/**
|
|
@@ -60,6 +66,7 @@ const { Base, statics } = await loadBase();
|
|
|
60
66
|
const mode = env['OXMUTATE_MODE'] ?? 'dry';
|
|
61
67
|
const resultsPath = env['OXMUTATE_RESULTS'];
|
|
62
68
|
const progressPath = env['OXMUTATE_PROGRESS'];
|
|
69
|
+
startHeartbeat(env[BEAT_ENV]); // the watchdog tells a wait from a loop by it (heartbeat.ts)
|
|
63
70
|
const ns = (globalThis[NAMESPACE] ??= {});
|
|
64
71
|
ns.mutantCoverage ??= { static: {}, perTest: {} };
|
|
65
72
|
const plan = mode === 'mutant' && env['OXMUTATE_PLAN'] ? JSON.parse(readFileSync(env['OXMUTATE_PLAN'], 'utf8')) : { tests: {}, hitLimits: {}, skip: [] };
|
|
@@ -69,8 +76,57 @@ const specTests = new Set(plan.specTests ?? []);
|
|
|
69
76
|
const decidedBySpec = new Set();
|
|
70
77
|
/** `isolated`: when set, only these tests run (the mutant's covering tests); the rest pass untouched. */
|
|
71
78
|
const only = mode === 'isolated' && env['OXMUTATE_ONLY_TESTS'] ? new Set(JSON.parse(env['OXMUTATE_ONLY_TESTS'])) : undefined;
|
|
79
|
+
/** `static`: the static mutants this file run decides in the live worker. */
|
|
80
|
+
const staticIds = mode === 'static' && env['OXMUTATE_STATIC'] ? JSON.parse(env['OXMUTATE_STATIC']) : [];
|
|
81
|
+
/** `static`: each mutant's hit-count limit. */
|
|
82
|
+
const staticLimits = mode === 'static' && env['OXMUTATE_HIT_LIMITS'] ? JSON.parse(env['OXMUTATE_HIT_LIMITS']) : {};
|
|
83
|
+
// `isolated`: the mutant is armed from process start, so its hit counter is too. A loop it causes
|
|
84
|
+
// stops at the limit, as in warm runs, instead of holding the worker until the watchdog's budget:
|
|
85
|
+
// on oxmutate's own suite 17 runaways left the isolated phase idle 140 of 186 s (2026-10-30).
|
|
86
|
+
if (mode === 'isolated' && env['OXMUTATE_HIT_LIMIT']) {
|
|
87
|
+
ns.hitCount = 0;
|
|
88
|
+
ns.hitLimit = Number(env['OXMUTATE_HIT_LIMIT']);
|
|
89
|
+
}
|
|
90
|
+
/** Did the armed mutant's switch pass its limit, i.e. was this run stopped as a runaway? */
|
|
91
|
+
const ranAway = () => ns.hitCount !== undefined && ns.hitLimit !== undefined && ns.hitCount > ns.hitLimit;
|
|
92
|
+
// ── `static` mode: what a registry reset does not reset ─────────────────────────────────────────
|
|
93
|
+
/** Built-ins a module may patch in place; their own methods are compared by identity. */
|
|
94
|
+
const PROTOTYPES = {
|
|
95
|
+
Object: Object.prototype, Array: Array.prototype, String: String.prototype, Number: Number.prototype,
|
|
96
|
+
Boolean: Boolean.prototype, Function: Function.prototype, Promise: Promise.prototype, Map: Map.prototype,
|
|
97
|
+
Set: Set.prototype, RegExp: RegExp.prototype, Date: Date.prototype, Error: Error.prototype,
|
|
98
|
+
};
|
|
99
|
+
/** Keys Vitest and oxmutate own: they change by design while a file runs. */
|
|
100
|
+
const OWN_GLOBAL = /^(__vitest|__oxmutate__$|__VITEST|__vite)/;
|
|
101
|
+
/** globalThis, process.env and the built-in prototypes, as a map a later look can be compared with. */
|
|
102
|
+
function snapshot() {
|
|
103
|
+
const out = new Map();
|
|
104
|
+
const g = globalThis;
|
|
105
|
+
for (const k of Object.getOwnPropertyNames(g)) {
|
|
106
|
+
if (OWN_GLOBAL.test(k))
|
|
107
|
+
continue;
|
|
108
|
+
const d = Object.getOwnPropertyDescriptor(g, k);
|
|
109
|
+
out.set(`g:${k}`, d && 'value' in d ? d.value : d?.get);
|
|
110
|
+
}
|
|
111
|
+
for (const [k, v] of Object.entries(env))
|
|
112
|
+
out.set(`env:${k}`, v);
|
|
113
|
+
for (const [name, proto] of Object.entries(PROTOTYPES)) {
|
|
114
|
+
for (const k of Object.getOwnPropertyNames(proto))
|
|
115
|
+
out.set(`proto:${name}.${k}`, Object.getOwnPropertyDescriptor(proto, k)?.value);
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
/** How many differing keys a `static-fallback` record names. */
|
|
120
|
+
const DIFF_KEYS = 8;
|
|
121
|
+
/** The keys whose presence or value differs between two snapshots (the first few, for a report). */
|
|
122
|
+
const snapshotDiff = (a, b) => [...new Set([...a.keys(), ...b.keys()])].filter((k) => !(a.has(k) && b.has(k) && Object.is(a.get(k), b.get(k)))).slice(0, DIFF_KEYS);
|
|
123
|
+
const sameSnapshot = (a, b) => snapshotDiff(a, b).length === 0;
|
|
124
|
+
/** The state before this worker evaluated the test file: the baseline every later look compares with. */
|
|
125
|
+
let pristine;
|
|
72
126
|
/** Set when a mutant left state behind; the rest of this context runs no mutants. */
|
|
73
127
|
let polluted = false;
|
|
128
|
+
/** `static` mode: the most mutants decided between two unarmed re-collections (staticwarm.qnt `grouped`). */
|
|
129
|
+
const STATIC_CANARY_MAX = 4;
|
|
74
130
|
/** Mutants between canaries; doubles while the canary stays clean, back to 1 when it fails. */
|
|
75
131
|
let canaryEvery = 1;
|
|
76
132
|
const MAX_CANARY_EVERY = 8;
|
|
@@ -81,10 +137,16 @@ function emit(record) {
|
|
|
81
137
|
if (resultsPath)
|
|
82
138
|
appendFileSync(resultsPath, `${JSON.stringify(record)}\n`);
|
|
83
139
|
}
|
|
140
|
+
// The suites whose hooks wrap `test`, outermost first. Vitest 5 leaves a top-level `describe`'s
|
|
141
|
+
// `suite` unset and keeps file-level hooks on `test.file`, so the walk adds the file itself: without
|
|
142
|
+
// it a file-level `beforeEach` reset never re-ran between mutants and every canary read as pollution.
|
|
84
143
|
function chain(test) {
|
|
85
144
|
const suites = [];
|
|
86
145
|
for (let s = test.suite; s; s = s.suite)
|
|
87
146
|
suites.unshift(s);
|
|
147
|
+
const file = test.file;
|
|
148
|
+
if (file && suites[0] !== file)
|
|
149
|
+
suites.unshift(file);
|
|
88
150
|
return suites;
|
|
89
151
|
}
|
|
90
152
|
async function beforeEach(test, context) {
|
|
@@ -107,6 +169,61 @@ async function afterEach(test, context, cleanups) {
|
|
|
107
169
|
await cleanup();
|
|
108
170
|
}
|
|
109
171
|
const isHitLimit = (e) => e instanceof Error && /Hit count limit/.test(e.message);
|
|
172
|
+
/**
|
|
173
|
+
* Runs a file collected outside Vitest's own run (`static` mode): each suite's beforeAll/afterAll,
|
|
174
|
+
* each test with its beforeEach/afterEach chain, stopping at the first failure, which is all a
|
|
175
|
+
* mutant's verdict needs.
|
|
176
|
+
*/
|
|
177
|
+
async function runCollected(file, runnable) {
|
|
178
|
+
const failed = [];
|
|
179
|
+
let error;
|
|
180
|
+
const note = (t, e) => {
|
|
181
|
+
error ??= `${fullName(t)}: ${e instanceof Error ? e.message : String(e)}`.slice(0, POLLUTION_ERROR_CHARS);
|
|
182
|
+
};
|
|
183
|
+
let ran = 0;
|
|
184
|
+
const runSuite = async (suite) => {
|
|
185
|
+
const hooks = statics.getSuiteHooks(suite);
|
|
186
|
+
for (const hook of hooks.beforeAll)
|
|
187
|
+
await hook(suite);
|
|
188
|
+
try {
|
|
189
|
+
for (const t of suite.tasks ?? []) {
|
|
190
|
+
if (failed.length)
|
|
191
|
+
return;
|
|
192
|
+
if (t.type === 'suite') {
|
|
193
|
+
await runSuite(t);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const fn = runnable.has(t.id) ? statics.getTestFn(t) : undefined;
|
|
197
|
+
if (!fn)
|
|
198
|
+
continue;
|
|
199
|
+
const cleanups = await beforeEach(t, t.context);
|
|
200
|
+
ran++;
|
|
201
|
+
try {
|
|
202
|
+
await fn();
|
|
203
|
+
}
|
|
204
|
+
catch (e) {
|
|
205
|
+
failed.push(t.id);
|
|
206
|
+
note(t, e);
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
await afterEach(t, t.context, cleanups);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
for (const hook of hooks.afterAll)
|
|
215
|
+
await hook(suite);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
try {
|
|
219
|
+
await runSuite(file);
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
failed.push(file.id); // a hook threw: the file failed under the mutant, as Stryker scores it
|
|
223
|
+
note(file, e);
|
|
224
|
+
}
|
|
225
|
+
return { failed, ran, ...(error !== undefined && { error }) };
|
|
226
|
+
}
|
|
110
227
|
function fullName(t) {
|
|
111
228
|
const parts = [t.name];
|
|
112
229
|
for (let s = t.suite; s && s.type === 'suite' && s.name; s = s.suite)
|
|
@@ -136,7 +253,14 @@ export default class OxmutateRunner extends Base {
|
|
|
136
253
|
if (mode !== 'mutant') {
|
|
137
254
|
if (only && !only.has(test.id))
|
|
138
255
|
return;
|
|
139
|
-
|
|
256
|
+
// Only a wait inside a test body can be an idle stall; collection at low CPU is a slow machine.
|
|
257
|
+
markTest(true);
|
|
258
|
+
try {
|
|
259
|
+
await fn();
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
markTest(false);
|
|
263
|
+
}
|
|
140
264
|
return;
|
|
141
265
|
}
|
|
142
266
|
const context = test.context;
|
|
@@ -147,11 +271,18 @@ export default class OxmutateRunner extends Base {
|
|
|
147
271
|
const ids = polluted ? [] : (plan.tests[test.id] ?? []).filter((id) => !done.has(id));
|
|
148
272
|
let cleanups = [];
|
|
149
273
|
let attempt = 0;
|
|
150
|
-
const
|
|
274
|
+
const teardown = async () => {
|
|
151
275
|
await afterEach(test, context, cleanups);
|
|
276
|
+
cleanups = [];
|
|
277
|
+
};
|
|
278
|
+
const setup = async () => {
|
|
152
279
|
cleanups = await beforeEach(test, context);
|
|
153
280
|
await this.retryReset(test, ++attempt);
|
|
154
281
|
};
|
|
282
|
+
const fresh = async () => {
|
|
283
|
+
await teardown();
|
|
284
|
+
await setup();
|
|
285
|
+
};
|
|
155
286
|
// Mutants run since the last clean canary. The first of them started from a clean state, so its
|
|
156
287
|
// verdict stands whatever the next canary says; the rest are suspects if it fails.
|
|
157
288
|
let unchecked = [];
|
|
@@ -172,19 +303,20 @@ export default class OxmutateRunner extends Base {
|
|
|
172
303
|
return false;
|
|
173
304
|
}
|
|
174
305
|
};
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
306
|
+
// The mutant is armed through the test's own `beforeEach`, as Stryker arms it: the dry run credits
|
|
307
|
+
// code a hook runs to the test, so a mutant in a fixture builder or a seed is planned here, and with
|
|
308
|
+
// the hooks run unarmed it always survived, to be killed later by an isolated run that costs a
|
|
309
|
+
// fresh process (58 of 400 on a Mongo-backed suite, 2026-10-29). The previous run's `afterEach`
|
|
310
|
+
// runs first, unarmed: its cleanup belongs to that run.
|
|
311
|
+
const runArmed = async (id) => {
|
|
312
|
+
await teardown();
|
|
182
313
|
const t0 = performance.now();
|
|
183
314
|
ns.activeMutant = id;
|
|
184
315
|
ns.hitCount = 0;
|
|
185
316
|
ns.hitLimit = plan.hitLimits[id] ?? Number.MAX_SAFE_INTEGER;
|
|
186
317
|
let status = 'Survived';
|
|
187
318
|
try {
|
|
319
|
+
await setup();
|
|
188
320
|
await fn();
|
|
189
321
|
}
|
|
190
322
|
catch (e) {
|
|
@@ -195,9 +327,27 @@ export default class OxmutateRunner extends Base {
|
|
|
195
327
|
ns.hitCount = undefined;
|
|
196
328
|
ns.hitLimit = undefined;
|
|
197
329
|
}
|
|
330
|
+
return { status, ms: performance.now() - t0 };
|
|
331
|
+
};
|
|
332
|
+
for (const id of ids) {
|
|
333
|
+
if (done.has(id))
|
|
334
|
+
continue;
|
|
335
|
+
if (progressPath)
|
|
336
|
+
writeFileSync(progressPath, `${id} ${String(Date.now())}\n`);
|
|
337
|
+
let { status, ms } = await runArmed(id);
|
|
338
|
+
// A kill that follows unchecked runs may be one of their leftovers, which this run's own cleanup
|
|
339
|
+
// (a `finally`) then wipes, so the batch canary would pass and vouch for a wrong kill. Survivors
|
|
340
|
+
// are confirmed in isolation later; kills are checked here: a failing canary is pollution as
|
|
341
|
+
// usual (this verdict goes back with the unit), a passing one leaves the state clean to decide
|
|
342
|
+
// the mutant again from (.sdlc/intents/oxmutate/canary.qnt).
|
|
343
|
+
if (status !== 'Survived' && unchecked.length > 0) {
|
|
344
|
+
if (!(await canary()))
|
|
345
|
+
break;
|
|
346
|
+
({ status, ms } = await runArmed(id));
|
|
347
|
+
}
|
|
198
348
|
if (status !== 'Survived')
|
|
199
349
|
done.add(id);
|
|
200
|
-
emit({ type: 'mutant', id, test: test.id, status, ms
|
|
350
|
+
emit({ type: 'mutant', id, test: test.id, status, ms, ...(spec ? { spec: true } : {}) });
|
|
201
351
|
unchecked.push(id);
|
|
202
352
|
// The canary: the same test, unmutated, must still pass. If it fails, a mutant left state
|
|
203
353
|
// behind (a cache, a registry, a fixture) that would decide the next verdicts. Run after every
|
|
@@ -220,6 +370,114 @@ export default class OxmutateRunner extends Base {
|
|
|
220
370
|
await cleanup();
|
|
221
371
|
}
|
|
222
372
|
}
|
|
373
|
+
/**
|
|
374
|
+
* The baseline is taken as the test file itself is imported (`source: 'collect'`), after setup
|
|
375
|
+
* files: those run before every collection, re-collections included, and write the same values
|
|
376
|
+
* each time (the family's colour setup sets TERM, COLORTERM and NO_COLOR), so they are not leaks.
|
|
377
|
+
*/
|
|
378
|
+
importFile(filepath, source) {
|
|
379
|
+
if (mode === 'static' && source === 'collect' && !pristine)
|
|
380
|
+
pristine = snapshot();
|
|
381
|
+
return super.importFile?.(filepath, source);
|
|
382
|
+
}
|
|
383
|
+
async onBeforeRunSuite(suite) {
|
|
384
|
+
if (mode === 'static' && suite.filepath && staticIds.length)
|
|
385
|
+
await this.decideStatic(suite);
|
|
386
|
+
return super.onBeforeRunSuite?.(suite);
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* `static` mode (.sdlc/intents/oxmutate/staticwarm.qnt). Re-evaluating the project's modules in
|
|
390
|
+
* this worker costs ~0.5 ms where a fresh worker per (mutant, file) cost ~80 ms (burgee/paratext,
|
|
391
|
+
* 2026-10-30). What a registry reset leaves behind is guarded three ways: the file's own unarmed
|
|
392
|
+
* collection must leave globalThis, process.env and the built-in prototypes as they were; so must
|
|
393
|
+
* each mutant; and an unarmed re-collection after each mutant must pass. Anything else hands the
|
|
394
|
+
* rest of the batch back to the fresh-worker path.
|
|
395
|
+
*/
|
|
396
|
+
async decideStatic(suite) {
|
|
397
|
+
const filepath = suite.filepath;
|
|
398
|
+
// The tests this run will execute, after Vitest's own filters (a name pattern, `.only`, `.skip`):
|
|
399
|
+
// a re-collected file has the same test ids, and runs exactly these.
|
|
400
|
+
const runnable = new Set();
|
|
401
|
+
const visit = (t) => {
|
|
402
|
+
if (t.type === 'test' && t.mode === 'run')
|
|
403
|
+
runnable.add(t.id);
|
|
404
|
+
for (const c of t.tasks ?? [])
|
|
405
|
+
visit(c);
|
|
406
|
+
};
|
|
407
|
+
visit(suite);
|
|
408
|
+
const rest = [...staticIds];
|
|
409
|
+
const fallback = (reason, keys = []) => {
|
|
410
|
+
emit({ type: 'static-fallback', ids: rest.splice(0), reason, keys });
|
|
411
|
+
};
|
|
412
|
+
const before = pristine;
|
|
413
|
+
if (!before)
|
|
414
|
+
return fallback('no baseline');
|
|
415
|
+
const atCollect = snapshotDiff(before, snapshot());
|
|
416
|
+
if (atCollect.length)
|
|
417
|
+
return fallback('the file changed globals while it was collected', atCollect);
|
|
418
|
+
const vitest = (await fromProject('vitest'));
|
|
419
|
+
// `collectTests` is exported for Vitest's browser runner from an internal subpath: if a Vitest
|
|
420
|
+
// moves or drops it, the import fails and the fresh-worker path decides, as on Vitest 4.
|
|
421
|
+
const browser = (await fromProject('vitest/internal/browser').catch(() => ({})));
|
|
422
|
+
const { collectTests } = browser;
|
|
423
|
+
const { vi } = vitest;
|
|
424
|
+
if (!collectTests || !vi)
|
|
425
|
+
return fallback('this Vitest has no public collect'); // Vitest 4: the fresh path decides
|
|
426
|
+
const evaluate = async (armed) => {
|
|
427
|
+
ns.activeMutant = armed;
|
|
428
|
+
ns.hitCount = armed ? 0 : undefined;
|
|
429
|
+
ns.hitLimit = armed ? (staticLimits[armed] ?? Number.MAX_SAFE_INTEGER) : undefined;
|
|
430
|
+
try {
|
|
431
|
+
vi.resetModules();
|
|
432
|
+
const [file] = await collectTests([filepath], this);
|
|
433
|
+
const result = file ? await runCollected(file, runnable) : { failed: [filepath], ran: 0 };
|
|
434
|
+
return { ...result, timeout: ranAway() };
|
|
435
|
+
}
|
|
436
|
+
finally {
|
|
437
|
+
ns.activeMutant = undefined;
|
|
438
|
+
ns.hitCount = undefined;
|
|
439
|
+
ns.hitLimit = undefined;
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
// Verdicts since the last clean unarmed re-collection are held, not emitted: the re-collection runs
|
|
443
|
+
// after the first mutant, then after every 2, then every STATIC_CANARY_MAX while it stays clean, and
|
|
444
|
+
// one that fails sends every held mutant back with the rest. Each mutant's own snapshot diff still
|
|
445
|
+
// runs at once. The re-collection runs the file's tests unarmed, and after every mutant it was half
|
|
446
|
+
// of the live static phase, 6,713 s of compat-oracle's 11,193 s weekly run (sc8-profile.md,
|
|
447
|
+
// staticwarm.qnt `grouped`).
|
|
448
|
+
let held = [];
|
|
449
|
+
let every = 1;
|
|
450
|
+
const back = (reason, keys = []) => {
|
|
451
|
+
rest.unshift(...held.map((h) => h.id));
|
|
452
|
+
held = [];
|
|
453
|
+
fallback(reason, keys);
|
|
454
|
+
};
|
|
455
|
+
while (rest.length) {
|
|
456
|
+
const id = rest.shift();
|
|
457
|
+
if (progressPath)
|
|
458
|
+
writeFileSync(progressPath, `${id} ${String(Date.now())}\n`);
|
|
459
|
+
held.push({ id, verdict: await evaluate(id) });
|
|
460
|
+
const left = snapshotDiff(before, snapshot());
|
|
461
|
+
if (left.length)
|
|
462
|
+
return back('the mutant left a trace', left);
|
|
463
|
+
if (held.length < every && rest.length)
|
|
464
|
+
continue;
|
|
465
|
+
const unarmed = await evaluate(undefined);
|
|
466
|
+
emit({ type: 'recollection' });
|
|
467
|
+
if (unarmed.failed.length > 0)
|
|
468
|
+
return back('the unarmed re-collection failed', unarmed.error ? [unarmed.error] : []);
|
|
469
|
+
const after = snapshotDiff(before, snapshot());
|
|
470
|
+
if (after.length)
|
|
471
|
+
return back('the unarmed re-collection left a trace', after);
|
|
472
|
+
for (const { id: done, verdict } of held)
|
|
473
|
+
emit({ type: 'isolated', id: done, killed: verdict.failed.length > 0 && !verdict.timeout, failed: verdict.failed, ran: verdict.ran, ...(verdict.timeout && { timeout: true }) });
|
|
474
|
+
held = [];
|
|
475
|
+
every = Math.min(STATIC_CANARY_MAX, every * 2);
|
|
476
|
+
}
|
|
477
|
+
if (progressPath)
|
|
478
|
+
writeFileSync(progressPath, '');
|
|
479
|
+
vi.resetModules();
|
|
480
|
+
}
|
|
223
481
|
/** Reset per-try state (snapshot counters, assertion counts) the way Vitest does for a retry. */
|
|
224
482
|
async retryReset(test, attempt) {
|
|
225
483
|
await super.onBeforeTryTask?.(test, { retry: attempt, repeats: 0 });
|
|
@@ -262,7 +520,8 @@ export default class OxmutateRunner extends Base {
|
|
|
262
520
|
if (f.result?.state === 'fail' && !failed.length)
|
|
263
521
|
failed.push(f.id);
|
|
264
522
|
}
|
|
265
|
-
|
|
523
|
+
const timeout = ranAway();
|
|
524
|
+
emit({ type: 'isolated', id: env[ACTIVE_MUTANT_ENV] ?? ns.activeMutant, killed: failed.length > 0 && !timeout, failed, ran, ...(timeout && { timeout }) });
|
|
266
525
|
}
|
|
267
526
|
return super.onAfterRunFiles?.(files);
|
|
268
527
|
}
|
package/dist/runtime.js
CHANGED
|
@@ -18,6 +18,7 @@ export const ACTIVE_MUTANT_ENV = '__OXMUTATE_ACTIVE_MUTANT__';
|
|
|
18
18
|
// place the package touches it. `env` is the live object: a write here is a write to the process.
|
|
19
19
|
export const env = process.env;
|
|
20
20
|
export const argv = process.argv;
|
|
21
|
+
export const pid = process.pid;
|
|
21
22
|
export const cwd = () => process.cwd();
|
|
22
23
|
export const stderr = process.stderr;
|
|
23
24
|
export const exit = (code) => process.exit(code);
|