vitest-auto-spy 3.3.0 → 3.4.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.

Potentially problematic release.


This version of vitest-auto-spy might be problematic. Click here for more details.

package/AGENTS.md CHANGED
@@ -456,6 +456,34 @@ undo), `flushStrayRejections()` (takes what was captured and starts again from e
456
456
  `countStrayRejections()`. The `no-floating-assertion` lint rule catches the commonest shape before
457
457
  it ever runs (§16).
458
458
 
459
+ **The one that gets slower the longer the run goes on:** every `vi.fn()` and `vi.spyOn()` is added
460
+ to one `Set` inside `@vitest/spy`, because that is what `vi.clearAllMocks()` walks, and nothing takes
461
+ anything out of it again. With `isolate: false` the set is created once per worker and only grows:
462
+ `clearMocks: true` then walks every mock of every file already run **before every single test**, and
463
+ the worker holds all of them at once — their recorded arguments included, and through those whole
464
+ component trees.
465
+
466
+ ```ts
467
+ setupAutoSpy({ pruneMockRegistry: true }); // keep only the mocks that outlive a file
468
+ ```
469
+
470
+ The part to understand before turning it on is what must **not** be pruned. Dropping a mock from that
471
+ set means `clearMocks` can no longer see it, so its calls accumulate silently — harmless for a mock
472
+ that dies with its file, a bug for the module-level `vi.fn()` in a shared `*.mock.ts` that six spec
473
+ files import. The first file to import it creates it; drop it when that file ends and the file that
474
+ happens to run **second** fails on calls its predecessor made, which reads as flakiness because which
475
+ file is first is the runner's choice. So the split is drawn where it is observable: what is already in
476
+ the registry when a file's hooks start was created while the module graph was being evaluated and is
477
+ kept; everything added after that belongs to the file and goes when it ends. One case lands on the
478
+ wrong side — a module first loaded by a dynamic `import()` inside a test — and says so explicitly:
479
+
480
+ ```ts
481
+ export const navigation = { setFocus: keepMockRegistered(vi.fn()) };
482
+ ```
483
+
484
+ `trackMockRegistry()` installs the pair of hooks on its own, `pruneMockRegistry()` is the one-shot
485
+ sweep (it returns how many went) and `getMockRegistrySize()` reports what is left.
486
+
459
487
  Two more switches, both about the environment rather than the spies:
460
488
 
461
489
  ```ts
package/README.md CHANGED
@@ -1295,6 +1295,7 @@ single-purpose utility you can pick up independently — they all ride on the sa
1295
1295
  | `installPerTest(install)` | `/setup` | Re-install a stub before every test of the block — a `describe`-level stub is restored away after the first |
1296
1296
  | `setupAngularTestEnv(opts)` | `/angular` | Zone and zoneless spec files in one worker, switching platforms per file |
1297
1297
  | `restoreTimerGlobals()` | `/setup` | Put back timer globals that uninstalling the fakes deleted rather than restored |
1298
+ | `trackMockRegistry()` / `keepMockRegistered(mock)` | `/setup` | Keep @vitest/spy's mock registry to the mocks that outlive a file; mark one the split would miss ([details](#test-run-hygiene)) |
1298
1299
  | `errorHandler` | core | The `mustBeCalledWith` argument-mismatch reporter — swap it to customize failure output |
1299
1300
 
1300
1301
  A taste of the DI pair — provide the spy, inject it back fully typed:
@@ -1426,6 +1427,15 @@ expensive to diagnose when it is missing. The first three are on by default:
1426
1427
  migrated Angular monorepo — 1688 spec files, 11 587 tests, green, exit 0 — was hiding six defects
1427
1428
  of exactly that shape, two of them assertions that were simply false and one a `TypeError` thrown
1428
1429
  by production code.
1430
+ 8. **The mock registry, which nothing empties.** Opt-in. Every `vi.fn()` and `vi.spyOn()` is added to
1431
+ one `Set` inside `@vitest/spy` so that `vi.clearAllMocks()` has something to walk, and no API takes
1432
+ anything out of it again. With `isolate: false` that set is created once per worker and only
1433
+ grows, so `clearMocks: true` walks every mock of every file already run before each test, and the
1434
+ worker holds all of them at once — with their recorded arguments, and through those whole
1435
+ component trees. Pruning it is easy to get wrong in one specific way: drop the module-level
1436
+ `vi.fn()` that six spec files import from a shared `*.mock.ts` and nothing clears it any more, so
1437
+ its calls accumulate and the file that happens to run second fails on calls its predecessor made.
1438
+ `pruneMockRegistry` keeps what a file inherited and drops only what it added.
1429
1439
 
1430
1440
  | Option | Default | Notes |
1431
1441
  | --------------------- | --------- | ----------------------------------------------------------------------------- |
@@ -1438,6 +1448,7 @@ expensive to diagnose when it is missing. The first three are on by default:
1438
1448
  | `guardGlobals` | `'off'` | Report a test that redefines a global property as non-configurable |
1439
1449
  | `globalFakeTimers` | `false` | Fake timers for every test **and between them** — Jest's `enableGlobally` |
1440
1450
  | `restoreTimerGlobals` | `true` | Put back timer globals that uninstalling the fakes deleted |
1451
+ | `pruneMockRegistry` | `false` | Keep @vitest/spy's ever-growing mock registry to the mocks that outlive a file |
1441
1452
 
1442
1453
  `restoreMocks` is off by default because it also drops `vi.spyOn` stubs a suite installed in
1443
1454
  `beforeAll`; it is the knob to reach for when the run shares one environment across files.
package/dist/setup.d.ts CHANGED
@@ -270,6 +270,18 @@ interface SetupAutoSpyOptions {
270
270
  * installed on purpose. See {@link restoreTimerGlobals}.
271
271
  */
272
272
  restoreTimerGlobals?: boolean;
273
+ /**
274
+ * Keep `@vitest/spy`'s registry of every mock ever created down to the mocks that outlive a file.
275
+ * Default `false`, because it reaches into a set the runner does not expose.
276
+ *
277
+ * The registry exists so `vi.clearAllMocks()` has something to walk. With `isolate: false` it is
278
+ * created once per worker and only grows: `clearMocks: true` then walks every mock of every file
279
+ * already run before each test, and the heap holds all of them — with their recorded arguments,
280
+ * and through those whole component trees. Turning this on prunes what each file added once the
281
+ * file is over, and keeps what the file inherited. See {@link trackMockRegistry}, and
282
+ * {@link keepMockRegistered} for the one case the split gets wrong on its own.
283
+ */
284
+ pruneMockRegistry?: boolean;
273
285
  /**
274
286
  * Clear the `vitest-auto-spy/console` spies after every test. Default `true`.
275
287
  *
@@ -415,6 +427,69 @@ declare module 'vitest' {
415
427
  */
416
428
  declare function registerFocusMatchers(): void;
417
429
 
430
+ /**
431
+ * Capture `@vitest/spy`'s mock registry, or return `undefined` if this runner does not expose one
432
+ * the probe can confirm.
433
+ *
434
+ * Called once per worker: a second call hands back the first result, including a failed one.
435
+ *
436
+ * The capture clears every mock's recorded calls as a side effect, because `vi.clearAllMocks()` is
437
+ * what makes the set iterate. That is why {@link trackMockRegistry} does it in `beforeAll`, where
438
+ * no test has recorded anything yet.
439
+ */
440
+ declare function captureMockRegistry(): Set<unknown> | undefined;
441
+ /**
442
+ * Mark `mock` as one that outlives the file it was created in, so pruning never drops it.
443
+ *
444
+ * Needed only for a mock created inside a test or a hook and then shared with later files — a
445
+ * module loaded by a dynamic `import()` in a test, or a fixture cached in a module-level variable
446
+ * on first use. A `vi.fn()` at the top level of a module is kept without being told.
447
+ *
448
+ * @example
449
+ * ```ts
450
+ * // fixtures/navigation.mock.ts — imported by six spec files
451
+ * export const navigation = { setFocus: keepMockRegistered(vi.fn()) };
452
+ * ```
453
+ */
454
+ declare function keepMockRegistered<T>(mock: T): T;
455
+ /**
456
+ * Mark everything currently in the registry as long-lived.
457
+ *
458
+ * Run at the start of a file, this is the whole classification: what exists now was created while
459
+ * the modules were being evaluated, and everything added afterwards belongs to this file.
460
+ */
461
+ declare function keepRegisteredMocks(): void;
462
+ /**
463
+ * Drop every mock that is not marked long-lived from the registry, and report how many went.
464
+ *
465
+ * Safe to call without a capture, and safe to call twice — the second call finds nothing to do.
466
+ */
467
+ declare function pruneMockRegistry(): number;
468
+ /**
469
+ * Keep the registry to the mocks that outlive a file: capture it, mark what each file inherits, and
470
+ * prune what the file added once it is over.
471
+ *
472
+ * ```ts
473
+ * // vitest.setup.ts
474
+ * import { trackMockRegistry } from 'vitest-auto-spy/setup';
475
+ *
476
+ * trackMockRegistry();
477
+ * ```
478
+ *
479
+ * This is what `setupAutoSpy({ pruneMockRegistry: true })` installs; call it directly to have it
480
+ * without the rest.
481
+ */
482
+ declare function trackMockRegistry(): void;
483
+ /** How many mocks the registry holds, or `undefined` if it was never captured. For diagnostics. */
484
+ declare function getMockRegistrySize(): number | undefined;
485
+ /**
486
+ * Forget the capture and every long-lived mark.
487
+ *
488
+ * A worker never needs this — the registry it captured is the one it keeps. This module's own spec
489
+ * does, because it has to exercise both a successful and a failed capture in one process.
490
+ */
491
+ declare function resetMockRegistryTracking(): void;
492
+
418
493
  /**
419
494
  * The callback half of a scheduler call, spelled out so a wrapper can pass it along and — for a
420
495
  * one-shot scheduler — call it itself.
@@ -561,4 +636,4 @@ declare const BLOCKED_FETCH_MESSAGE = "[vitest-auto-spy] fetch is stubbed in uni
561
636
  */
562
637
  declare function blockNetwork(): void;
563
638
 
564
- export { BLOCKED_FETCH_MESSAGE, type CountingClock, type CountingClockOptions, type DuplicateCopiesReaction, type FakeTimersConfig, type GlobalPatchReaction, type PerTestHandle, type RejectionHost, type SchedulerHost, type SetupAutoSpyOptions, type StopTrackingRejections, type StopTrackingTimers, type StrayRejection, type SystemTime, advanceTimers, blockNetwork, cancelStrayTimers, countStrayRejections, countStrayTimers, flushStrayRejections, getWatchedTimerGlobals, guardGlobalPatches, installPerTest, mockNow, mockSystemTime, registerFocusMatchers, restoreTimerGlobals, setupAutoSpy, setupFakeTimers, trackStrayRejections, trackStrayTimers, useCountingClock, withSystemTime };
639
+ export { BLOCKED_FETCH_MESSAGE, type CountingClock, type CountingClockOptions, type DuplicateCopiesReaction, type FakeTimersConfig, type GlobalPatchReaction, type PerTestHandle, type RejectionHost, type SchedulerHost, type SetupAutoSpyOptions, type StopTrackingRejections, type StopTrackingTimers, type StrayRejection, type SystemTime, advanceTimers, blockNetwork, cancelStrayTimers, captureMockRegistry, countStrayRejections, countStrayTimers, flushStrayRejections, getMockRegistrySize, getWatchedTimerGlobals, guardGlobalPatches, installPerTest, keepMockRegistered, keepRegisteredMocks, mockNow, mockSystemTime, pruneMockRegistry, registerFocusMatchers, resetMockRegistryTracking, restoreTimerGlobals, setupAutoSpy, setupFakeTimers, trackMockRegistry, trackStrayRejections, trackStrayTimers, useCountingClock, withSystemTime };
package/dist/setup.js CHANGED
@@ -136,6 +136,80 @@ function guardGlobalPatches(reaction) {
136
136
  checkSealedAdditions(watched, reaction);
137
137
  });
138
138
  }
139
+ var registry;
140
+ var captureAttempted = false;
141
+ var longLived = /* @__PURE__ */ new WeakSet();
142
+ function isWeakKey(value) {
143
+ return typeof value === "function" || typeof value === "object" && value !== null;
144
+ }
145
+ function captureMockRegistry() {
146
+ if (captureAttempted) {
147
+ return registry;
148
+ }
149
+ captureAttempted = true;
150
+ const probe = vi.fn();
151
+ const originalForEach = Set.prototype.forEach;
152
+ let captured;
153
+ const remember = (set) => {
154
+ captured ??= set;
155
+ };
156
+ Set.prototype.forEach = function patchedForEach(...args) {
157
+ remember(this);
158
+ originalForEach.apply(this, args);
159
+ };
160
+ try {
161
+ vi.clearAllMocks();
162
+ } finally {
163
+ Set.prototype.forEach = originalForEach;
164
+ }
165
+ if (!captured?.has(probe)) {
166
+ return void 0;
167
+ }
168
+ captured.delete(probe);
169
+ registry = captured;
170
+ return registry;
171
+ }
172
+ function keepMockRegistered(mock) {
173
+ if (isWeakKey(mock)) {
174
+ longLived.add(mock);
175
+ }
176
+ return mock;
177
+ }
178
+ function keepRegisteredMocks() {
179
+ registry?.forEach((mock) => {
180
+ keepMockRegistered(mock);
181
+ });
182
+ }
183
+ function pruneMockRegistry() {
184
+ if (!registry) {
185
+ return 0;
186
+ }
187
+ let pruned = 0;
188
+ for (const mock of registry) {
189
+ if (!isWeakKey(mock) || !longLived.has(mock)) {
190
+ registry.delete(mock);
191
+ pruned += 1;
192
+ }
193
+ }
194
+ return pruned;
195
+ }
196
+ function trackMockRegistry() {
197
+ beforeAll(() => {
198
+ captureMockRegistry();
199
+ keepRegisteredMocks();
200
+ });
201
+ afterAll(() => {
202
+ pruneMockRegistry();
203
+ });
204
+ }
205
+ function getMockRegistrySize() {
206
+ return registry?.size;
207
+ }
208
+ function resetMockRegistryTracking() {
209
+ registry = void 0;
210
+ captureAttempted = false;
211
+ longLived = /* @__PURE__ */ new WeakSet();
212
+ }
139
213
 
140
214
  // src/lib/network-stub.ts
141
215
  var BLOCKED_FETCH_MESSAGE = "[vitest-auto-spy] fetch is stubbed in unit tests";
@@ -154,7 +228,7 @@ function describeTarget(input) {
154
228
  }
155
229
  var HANDLER_SLOT = "unhandledPromiseRejectionHandler";
156
230
  var MISSING_ZONE = "trackStrayRejections() found no zone.js on the host (`Zone.__symbol__` is not there), so there is no handler slot to claim. This module never imports zone.js \u2014 a zoneless project must not pull it in \u2014 which means the consumer loads it first: `import 'zone.js';` at the top of the setup file, or, under `@angular/build:unit-test`, the builder's own entry point does it. It throws rather than quietly doing nothing on purpose: without zone.js the global `Promise` is the platform one, whose unhandled rejections Vitest already reports and fails the run for, so a silent no-op here would read as \"the check is on\" while nothing was ever checked. Drop the option instead.";
157
- function registry() {
231
+ function registry2() {
158
232
  return globalThis.__vitestAutoSpyTrackedRejections__ ??= /* @__PURE__ */ new Map();
159
233
  }
160
234
  function defaultHost() {
@@ -182,7 +256,7 @@ function describeRejection(error) {
182
256
  return { reason, assertion: isAssertionFailure(reason), testName: expect.getState().currentTestName ?? "" };
183
257
  }
184
258
  function trackStrayRejections(host = defaultHost()) {
185
- const tracked = registry().get(host);
259
+ const tracked = registry2().get(host);
186
260
  if (tracked) {
187
261
  return tracked.stop;
188
262
  }
@@ -202,25 +276,25 @@ function trackStrayRejections(host = defaultHost()) {
202
276
  } else {
203
277
  Reflect.deleteProperty(zone, slot);
204
278
  }
205
- registry().delete(host);
279
+ registry2().delete(host);
206
280
  };
207
- registry().set(host, { captured, stop });
281
+ registry2().set(host, { captured, stop });
208
282
  return stop;
209
283
  }
210
284
  function countStrayRejections(host = defaultHost()) {
211
- const tracked = registry().get(host);
285
+ const tracked = registry2().get(host);
212
286
  if (!tracked) {
213
287
  throw new Error(withDocs("countStrayRejections() needs trackStrayRejections() to have run first.", DOCS_LINKS.setup));
214
288
  }
215
289
  return tracked.captured.length;
216
290
  }
217
291
  function flushStrayRejections(host = defaultHost()) {
218
- const tracked = registry().get(host);
292
+ const tracked = registry2().get(host);
219
293
  return tracked ? tracked.captured.splice(0) : [];
220
294
  }
221
295
 
222
296
  // src/lib/stray-timers.ts
223
- function registry2() {
297
+ function registry3() {
224
298
  return globalThis.__vitestAutoSpyTrackedSchedulers__ ??= /* @__PURE__ */ new Map();
225
299
  }
226
300
  function defaultHost2() {
@@ -278,7 +352,7 @@ function wrapFrameScheduler(host, frames) {
278
352
  };
279
353
  }
280
354
  function trackStrayTimers(host = defaultHost2()) {
281
- const tracked = registry2().get(host);
355
+ const tracked = registry3().get(host);
282
356
  if (tracked) {
283
357
  return tracked.stop;
284
358
  }
@@ -294,13 +368,13 @@ function trackStrayTimers(host = defaultHost2()) {
294
368
  const stop = () => {
295
369
  cancelStrayTimers(host);
296
370
  undo.forEach((restore) => restore());
297
- registry2().delete(host);
371
+ registry3().delete(host);
298
372
  };
299
- registry2().set(host, { handles, frames, stop });
373
+ registry3().set(host, { handles, frames, stop });
300
374
  return stop;
301
375
  }
302
376
  function cancelStrayTimers(host = defaultHost2()) {
303
- const tracked = registry2().get(host);
377
+ const tracked = registry3().get(host);
304
378
  if (!tracked) {
305
379
  return 0;
306
380
  }
@@ -318,7 +392,7 @@ function cancelStrayTimers(host = defaultHost2()) {
318
392
  return cancelled;
319
393
  }
320
394
  function countStrayTimers(host = defaultHost2()) {
321
- const tracked = registry2().get(host);
395
+ const tracked = registry3().get(host);
322
396
  if (!tracked) {
323
397
  throw new Error(withDocs("countStrayTimers() needs trackStrayTimers() to have run first.", DOCS_LINKS.setup));
324
398
  }
@@ -408,6 +482,9 @@ function setupAutoSpy(options = {}) {
408
482
  cancelStrayTimers();
409
483
  });
410
484
  }
485
+ if (options.pruneMockRegistry ?? false) {
486
+ trackMockRegistry();
487
+ }
411
488
  if (options.globalFakeTimers) {
412
489
  setupFakeTimers(options.globalFakeTimers === true ? void 0 : options.globalFakeTimers, { betweenTests: true });
413
490
  }
@@ -552,4 +629,4 @@ function registerFocusMatchers() {
552
629
  // src/setup.ts
553
630
  useVitestAdapter();
554
631
 
555
- export { BLOCKED_FETCH_MESSAGE, advanceTimers, blockNetwork, cancelStrayTimers, countStrayRejections, countStrayTimers, flushStrayRejections, getWatchedTimerGlobals, guardGlobalPatches, installPerTest, mockNow, mockSystemTime, registerFocusMatchers, restoreTimerGlobals, setupAutoSpy, setupFakeTimers, trackStrayRejections, trackStrayTimers, useCountingClock, withSystemTime };
632
+ export { BLOCKED_FETCH_MESSAGE, advanceTimers, blockNetwork, cancelStrayTimers, captureMockRegistry, countStrayRejections, countStrayTimers, flushStrayRejections, getMockRegistrySize, getWatchedTimerGlobals, guardGlobalPatches, installPerTest, keepMockRegistered, keepRegisteredMocks, mockNow, mockSystemTime, pruneMockRegistry, registerFocusMatchers, resetMockRegistryTracking, restoreTimerGlobals, setupAutoSpy, setupFakeTimers, trackMockRegistry, trackStrayRejections, trackStrayTimers, useCountingClock, withSystemTime };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vitest-auto-spy",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "description": "Auto-generate fully-typed test spies from a class — across Vitest, Bun and node:test, with NestJS/React/Vue/Svelte/Angular recipes. Drop-in replacement for jest-auto-spies.",
5
5
  "keywords": [
6
6
  "auto-mock",
@@ -104,6 +104,7 @@ it('loads', async () => {
104
104
  | a green run exiting 1 with `AbortError` under happy-dom | `setupAutoSpy({ blockNetwork: true })` |
105
105
  | timers or frames from a previous file failing this one | `setupAutoSpy({ strayTimers: true })` |
106
106
  | an assertion error in stderr, every test green and the run at 0 | `setupAutoSpy({ strayRejections: true })` — zone.js swallowed it |
107
+ | a run getting slower the longer it goes, on `isolate: false` | `setupAutoSpy({ pruneMockRegistry: true })` — the mock registry |
107
108
  | `Cannot read properties of undefined (reading 'now')` | `restoreTimerGlobals` — on by default |
108
109
  | a spy handed to an API typed against the real class | `asInstance()` / `asSpy()` |
109
110
  | the code under test does `new X()` (a global, a vendor SDK) | `mockConstructor(factory)` / `stubConstructor(obj, key, factory)` |