oxmutate 1.2.2 → 1.2.4

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.
@@ -1,4 +1,4 @@
1
- import { cpSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
1
+ import { cpSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { ambientRuntime, run as spawn } from 'bellpull';
4
4
  import { ACTIVE_MUTANT_ENV, env } from './runtime.js';
@@ -11,8 +11,28 @@ export function makeSandbox(root) {
11
11
  for (const entry of readdirSync(root))
12
12
  if (!SKIP.has(entry))
13
13
  cpSync(path.join(root, entry), path.join(sandbox, entry), { recursive: true });
14
+ rebaseExtends(root, sandbox);
14
15
  return sandbox;
15
16
  }
17
+ const RELATIVE_EXTENDS = /("extends"\s*:\s*")(\.{1,2}\/[^"]*)(")/g;
18
+ function rebaseExtends(from, to) {
19
+ for (const entry of readdirSync(to, { withFileTypes: true })) {
20
+ if (SKIP.has(entry.name))
21
+ continue;
22
+ const copied = path.join(to, entry.name);
23
+ if (entry.isDirectory())
24
+ rebaseExtends(path.join(from, entry.name), copied);
25
+ else if (/^tsconfig.*\.json$/.test(entry.name)) {
26
+ const text = readFileSync(copied, 'utf8');
27
+ const rebased = text.replace(RELATIVE_EXTENDS, (_match, open, rel, close) => {
28
+ const relative = path.relative(to, path.resolve(from, rel)).split(path.sep).join('/');
29
+ return `${open}${relative.startsWith('.') ? relative : `./${relative}`}${close}`;
30
+ });
31
+ if (rebased !== text)
32
+ writeFileSync(copied, rebased);
33
+ }
34
+ }
35
+ }
16
36
  const MS_PER_MINUTE = 60_000;
17
37
  const OUTPUT_TAIL = 1500;
18
38
  const PROCESS_SLACK_MS = 5000;
@@ -159,7 +159,7 @@ export async function run(options) {
159
159
  if (fileErrors.length) {
160
160
  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('; ')}`);
161
161
  }
162
- return { tests: recs.flatMap((r) => r.tests ?? []), perTest, static: stat };
162
+ return { tests: recs.flatMap((r) => r.tests ?? []), perTest, static: stat, runOnce: recs.flatMap((r) => r.runOnce ?? []) };
163
163
  };
164
164
  progress.phase('dry run');
165
165
  const d0 = performance.now();
@@ -287,7 +287,7 @@ export async function run(options) {
287
287
  const unfinished = new Set();
288
288
  const makeUnit = (file, plan, depth = 0) => {
289
289
  const unit = { mode: 'mutant', files: [file], results: fileFor('unit', 'jsonl'), progress: fileFor('unit', 'progress'), plan: fileFor('plan', 'json') };
290
- writeFileSync(unit.plan, JSON.stringify({ tests: plan, hitLimits, skip: [], specTests: [...specOf.keys()] }));
290
+ writeFileSync(unit.plan, JSON.stringify({ tests: plan, hitLimits, skip: [], specTests: [...specOf.keys()], runOnce: dry.runOnce }));
291
291
  planUnits.set(unit.results, { file, plan, depth });
292
292
  stats.units++;
293
293
  return unit;
@@ -384,7 +384,7 @@ export async function run(options) {
384
384
  if (Object.keys(left).length === 0)
385
385
  return false;
386
386
  entry.plan = left;
387
- writeFileSync(unit.plan, JSON.stringify({ tests: left, hitLimits, skip: [], specTests: [...specOf.keys()] }));
387
+ writeFileSync(unit.plan, JSON.stringify({ tests: left, hitLimits, skip: [], specTests: [...specOf.keys()], runOnce: dry.runOnce }));
388
388
  return true;
389
389
  };
390
390
  await pool.drain(unitsForRound(toRun, (m) => fileOrder.get(m.id) ?? []), (_, id) => warmBudget(id), afterUnit, beforeUnit);
package/dist/runner.d.ts CHANGED
@@ -26,7 +26,18 @@ declare const OxmutateRunner_base: new (config: unknown) => {
26
26
  onBeforeCollect?(paths: string[]): unknown;
27
27
  importFile?(filepath: string, source: string): unknown;
28
28
  onBeforeRunSuite?(suite: AnyTask): unknown;
29
+ onAfterRunSuite?(suite: AnyTask): unknown;
30
+ onBeforeRunTask?(test: AnyTask): unknown;
29
31
  onCollectStart?(file: unknown): unknown;
32
+ /** Vitest's own: the current task, and the module graph a test file is invalidated in (Vitest 4). */
33
+ workerState?: {
34
+ current?: unknown;
35
+ evaluatedModules?: {
36
+ getModuleById: (id: string) => unknown;
37
+ invalidateModule: (node: unknown) => void;
38
+ };
39
+ };
40
+ _currentSpecification?: unknown;
30
41
  onBeforeTryTask?(test: AnyTask, options: {
31
42
  retry: number;
32
43
  repeats: number;
@@ -53,6 +64,7 @@ export default class OxmutateRunner extends OxmutateRunner_base {
53
64
  * each time (the family's colour setup sets TERM, COLORTERM and NO_COLOR), so they are not leaks.
54
65
  */
55
66
  importFile(filepath: string, source: string): unknown;
67
+ onAfterRunSuite(suite: AnyTask): Promise<unknown>;
56
68
  onBeforeRunSuite(suite: AnyTask): Promise<unknown>;
57
69
  /**
58
70
  * `static` mode (.sdlc/intents/oxmutate/staticwarm.qnt). Re-evaluating the project's modules in
@@ -63,6 +75,17 @@ export default class OxmutateRunner extends OxmutateRunner_base {
63
75
  * rest of the batch back to the fresh-worker path.
64
76
  */
65
77
  private decideStatic;
78
+ /**
79
+ * Collected here, before Vitest marks the test running: it refuses `it()` inside a running test.
80
+ * A file whose copies cannot be collected falls back to the canary restarting its context.
81
+ */
82
+ onBeforeRunTask(test: AnyTask): Promise<unknown>;
83
+ /**
84
+ * Fresh collections of `filepath` until there are `count`. Only the test file is evaluated again
85
+ * (Vitest 5 re-imports it; on Vitest 4 its module is invalidated first), so each costs the file's
86
+ * own top level, not its imports.
87
+ */
88
+ private collectCopies;
66
89
  /** Reset per-try state (snapshot counters, assertion counts) the way Vitest does for a retry. */
67
90
  private retryReset;
68
91
  onAfterRunFiles(files: AnyTask[]): unknown;
package/dist/runner.js CHANGED
@@ -82,6 +82,60 @@ let polluted = false;
82
82
  const STATIC_CANARY_MAX = 4;
83
83
  let canaryEvery = 1;
84
84
  const MAX_CANARY_EVERY = 8;
85
+ const runOnce = new Set(mode === 'mutant' ? (plan.runOnce ?? []) : []);
86
+ const probed = new Set();
87
+ const collections = new Map();
88
+ const taken = new Map();
89
+ const RUNS_PER_MUTANT = 4;
90
+ const RUNS_AFTER = 2;
91
+ async function findCollect() {
92
+ const browser = (await fromProject('vitest/internal/browser').catch(() => ({})));
93
+ if (browser.collectTests)
94
+ return browser.collectTests;
95
+ const vitestDir = path.dirname(createRequire(path.join(cwd(), 'package.json')).resolve('vitest/package.json'));
96
+ return (await fromProject('@vitest/runner', vitestDir).catch(() => ({}))).collectTests;
97
+ }
98
+ let collector;
99
+ const loadCollect = () => (collector ??= findCollect());
100
+ const find = (t, id) => (t.id === id ? t : t.tasks?.reduce((hit, c) => hit ?? find(c, id), undefined));
101
+ const hasBeforeAll = (t) => t.type !== 'test' && (statics.getSuiteHooks(t).beforeAll.length > 0 || (t.tasks ?? []).some(hasBeforeAll));
102
+ async function afterAllOf(t) {
103
+ if (t.type === 'test')
104
+ return;
105
+ for (const c of t.tasks ?? [])
106
+ await afterAllOf(c);
107
+ for (const hook of statics.getSuiteHooks(t).afterAll)
108
+ await hook(t);
109
+ }
110
+ function takeCopy(test) {
111
+ const n = taken.get(test.id) ?? 0;
112
+ taken.set(test.id, n + 1);
113
+ const file = collections.get(test.file?.filepath ?? '')?.[n];
114
+ return file && find(file, test.id);
115
+ }
116
+ const PROBE_ID = '__oxmutate_probe__';
117
+ async function probe(test, fn) {
118
+ const file = test.file?.filepath;
119
+ if (!file || probed.has(file))
120
+ return;
121
+ probed.add(file);
122
+ await afterEach(test, test.context, []);
123
+ const cleanups = await beforeEach(test, test.context);
124
+ ns.currentTestId = PROBE_ID;
125
+ try {
126
+ await fn();
127
+ }
128
+ catch {
129
+ runOnce.add(file);
130
+ }
131
+ finally {
132
+ ns.currentTestId = test.id;
133
+ if (ns.mutantCoverage)
134
+ delete ns.mutantCoverage.perTest[PROBE_ID];
135
+ for (const cleanup of cleanups.reverse())
136
+ await cleanup();
137
+ }
138
+ }
85
139
  const POLLUTION_ERROR_CHARS = 300;
86
140
  const FILE_ERROR_CHARS = 500;
87
141
  function emit(record) {
@@ -194,24 +248,38 @@ export default class OxmutateRunner extends Base {
194
248
  markTest(true);
195
249
  try {
196
250
  await fn();
251
+ if (mode === 'dry')
252
+ await probe(test, fn);
197
253
  }
198
254
  finally {
199
255
  markTest(false);
200
256
  }
201
257
  return;
202
258
  }
203
- const context = test.context;
259
+ let current = test;
260
+ let run = fn;
261
+ let context = test.context;
262
+ let invoked = false;
204
263
  const spec = specTests.has(test.id);
205
264
  const done = spec ? decidedBySpec : decided;
206
265
  const ids = polluted ? [] : (plan.tests[test.id] ?? []).filter((id) => !done.has(id));
207
266
  let cleanups = [];
208
267
  let attempt = 0;
209
268
  const teardown = async () => {
210
- await afterEach(test, context, cleanups);
269
+ await afterEach(current, context, cleanups);
211
270
  cleanups = [];
212
271
  };
213
272
  const setup = async () => {
214
- cleanups = await beforeEach(test, context);
273
+ if (invoked && test.file && runOnce.has(test.file.filepath)) {
274
+ const copy = takeCopy(test);
275
+ if (!copy)
276
+ throw new Error('oxmutate: a run-once test ran out of fresh collections');
277
+ current = copy;
278
+ run = statics.getTestFn(copy) ?? fn;
279
+ context = copy.context;
280
+ }
281
+ invoked = true;
282
+ cleanups = await beforeEach(current, context);
215
283
  await this.retryReset(test, ++attempt);
216
284
  };
217
285
  const fresh = async () => {
@@ -224,7 +292,7 @@ export default class OxmutateRunner extends Base {
224
292
  return true;
225
293
  await fresh();
226
294
  try {
227
- await fn();
295
+ await run();
228
296
  unchecked = [];
229
297
  canaryEvery = Math.min(MAX_CANARY_EVERY, canaryEvery * 2);
230
298
  return true;
@@ -245,7 +313,7 @@ export default class OxmutateRunner extends Base {
245
313
  let status = 'Survived';
246
314
  try {
247
315
  await setup();
248
- await fn();
316
+ await run();
249
317
  }
250
318
  catch (e) {
251
319
  status = isHitLimit(e) ? 'Timeout' : 'Killed';
@@ -282,7 +350,7 @@ export default class OxmutateRunner extends Base {
282
350
  if (attempt > 0)
283
351
  await fresh();
284
352
  try {
285
- await fn();
353
+ await run();
286
354
  }
287
355
  finally {
288
356
  for (const cleanup of cleanups.reverse())
@@ -294,6 +362,15 @@ export default class OxmutateRunner extends Base {
294
362
  pristine = snapshot();
295
363
  return super.importFile?.(filepath, source);
296
364
  }
365
+ async onAfterRunSuite(suite) {
366
+ const copies = suite.filepath ? collections.get(suite.filepath) : undefined;
367
+ if (suite.filepath && copies) {
368
+ collections.delete(suite.filepath);
369
+ for (const copy of copies)
370
+ await afterAllOf(copy);
371
+ }
372
+ return super.onAfterRunSuite?.(suite);
373
+ }
297
374
  async onBeforeRunSuite(suite) {
298
375
  if (mode === 'static' && suite.filepath && staticIds.length)
299
376
  await this.decideStatic(suite);
@@ -374,6 +451,44 @@ export default class OxmutateRunner extends Base {
374
451
  writeFileSync(progressPath, '');
375
452
  vi.resetModules();
376
453
  }
454
+ async onBeforeRunTask(test) {
455
+ const filepath = test.file?.filepath;
456
+ if (mode === 'mutant' && !polluted && filepath && runOnce.has(filepath)) {
457
+ const done = specTests.has(test.id) ? decidedBySpec : decided;
458
+ const runs = (plan.tests[test.id] ?? []).filter((id) => !done.has(id)).length * RUNS_PER_MUTANT + RUNS_AFTER;
459
+ if (!(await this.collectCopies(filepath, (taken.get(test.id) ?? 0) + runs)))
460
+ runOnce.delete(filepath);
461
+ }
462
+ return super.onBeforeRunTask?.(test);
463
+ }
464
+ async collectCopies(filepath, count) {
465
+ const files = collections.get(filepath) ?? [];
466
+ collections.set(filepath, files);
467
+ if (files.length >= count)
468
+ return true;
469
+ const collect = await loadCollect();
470
+ if (!collect)
471
+ return false;
472
+ const { workerState } = this;
473
+ const was = { current: workerState?.current, spec: this._currentSpecification };
474
+ try {
475
+ while (files.length < count) {
476
+ const node = workerState?.evaluatedModules?.getModuleById(filepath);
477
+ if (node)
478
+ workerState?.evaluatedModules?.invalidateModule(node);
479
+ const [file] = await collect([filepath], this);
480
+ if (!file || file.result?.state === 'fail' || hasBeforeAll(file))
481
+ return false;
482
+ files.push(file);
483
+ }
484
+ return true;
485
+ }
486
+ finally {
487
+ if (workerState)
488
+ workerState.current = was.current;
489
+ this._currentSpecification = was.spec;
490
+ }
491
+ }
377
492
  async retryReset(test, attempt) {
378
493
  await super.onBeforeTryTask?.(test, { retry: attempt, repeats: 0 });
379
494
  }
@@ -394,7 +509,7 @@ export default class OxmutateRunner extends Base {
394
509
  if (result?.state === 'fail' && !f.tasks?.length)
395
510
  fileErrors.push({ file: filepath, error: (result.errors?.[0]?.message ?? 'failed to collect').slice(0, FILE_ERROR_CHARS) });
396
511
  }
397
- emit({ type: 'dry', tests, coverage: ns.mutantCoverage, fileErrors });
512
+ emit({ type: 'dry', tests, coverage: ns.mutantCoverage, fileErrors, runOnce: [...runOnce] });
398
513
  }
399
514
  else if (mode === 'isolated') {
400
515
  const failed = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxmutate",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
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
6
  "repository": {