vitest-auto-spy 5.16.0 → 5.17.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/AGENTS.md CHANGED
@@ -1662,6 +1662,28 @@ a `useValue` provider or a test double — patch the prototype of the class the
1662
1662
  `guardPrototypePollution(reaction)` from `/setup` registers the same check on its own, for a suite
1663
1663
  that does not call `setupAutoSpy()`.
1664
1664
 
1665
+ ### Naming the test that left an attribute on `<body>`
1666
+
1667
+ ```ts
1668
+ setupAutoSpy({ documentPollution: 'throw' }); // default 'off'; 'throw' under preset: 'strict'
1669
+ setupAutoSpy({ documentPollution: { reaction: 'throw', nodes: true, ignoreAttributes: [/^data-cdk-/], ignoreNodes: 'style' } });
1670
+ ```
1671
+
1672
+ Under `isolate: false` every spec file in a worker shares one document. An attribute a component set
1673
+ on `<body>` — `renderer.setAttribute(document.body, 'data-reset-focus', '')` in an `effect` — that
1674
+ nothing took off changes the branch a later file's code takes, so that file fails, only when the two
1675
+ share a worker and never alone. The guard records the attributes of `<html>`, `<head>` and `<body>`
1676
+ before each test and compares them from `onTestFinished` — **after** the TestBed destroyed the
1677
+ fixtures in its own `afterEach`, so what a component removes in `ngOnDestroy` / `DestroyRef.onDestroy`
1678
+ is never reported. Every attribute added, changed or removed is named with both values, put back,
1679
+ and fails that test. A change made in a `beforeAll` and never undone fails the file, checked from a
1680
+ `beforeAll` cleanup after every `afterAll`. `nodes: true` also watches the child elements of `<head>`
1681
+ and `<body>`; it is off by default because a module that injects a stylesheet on first import does
1682
+ so once per worker. Blind spots: a write made while the spec file is imported, and a fixture kept
1683
+ alive by `teardown: { destroyAfterEach: false }` (reported against the test that rendered it).
1684
+ `guardDocumentPollution(option)` from `/setup` registers the same check on its own. Vitest only, like
1685
+ every `/setup` guard — `bun:test`, `node:test` and Rstest have no setup entry to host it.
1686
+
1665
1687
  ### Failing on console output nothing absorbed
1666
1688
 
1667
1689
  ```ts
@@ -1701,8 +1723,8 @@ frame outside `node_modules`:
1701
1723
  setupAutoSpy({ preset: 'strict' });
1702
1724
  ```
1703
1725
 
1704
- Sets `duplicateCopies`, `propsOutsideHooks`, `guardGlobals`, `prototypePollution`, `strayConsole` and
1705
- `misconfiguration` to `'throw'`, turns `strayTimers` on, and `strayRejections` on when zone.js is
1726
+ Sets `duplicateCopies`, `propsOutsideHooks`, `guardGlobals`, `prototypePollution`, `documentPollution`,
1727
+ `strayConsole` and `misconfiguration` to `'throw'`, turns `strayTimers` on, and `strayRejections` on when zone.js is
1706
1728
  loaded. An option passed alongside still wins. **Not** included: `strict` (strict doubles change what
1707
1729
  an unconfigured call returns — a semantic switch, not a grade; the name was taken, hence `preset`) and
1708
1730
  `unconfiguredReads`, its read side (survey with `onUnstubbedRead` before turning it on),
@@ -3459,6 +3481,9 @@ packages, which a subpath export can never be.
3459
3481
  | `Cannot set base providers because it has already been called` | zone and zoneless spec files sharing one worker | `setupAngularTestEnv({ zoneless, initZone, initZoneless })` (§13) |
3460
3482
  | a stub that works in the first test of the file and in no other | installed at `describe` level or in `beforeAll`, then restored away | install it in `beforeEach`, or `installPerTest(() => stub…())` |
3461
3483
  | a third-party library failing every other run, no test named | a test sealed a global with `Object.defineProperty` (non-configurable) | `setupAutoSpy({ guardGlobals: 'throw' })` names the file; then `mockValueProp` |
3484
+ | a spec green alone that fails only in a full run, on something it reads off `<body>` / `<html>` (`querySelector('[data-…]')`, a class) | an earlier file in the worker left an attribute on the shared document | `setupAutoSpy({ documentPollution: 'throw' })` (in `preset: 'strict'`) names the test; take the attribute off in `ngOnDestroy` / `DestroyRef.onDestroy` or an `afterEach` |
3485
+ | `A metric with the name … has already been registered` as a failed suite with 0 failed tests, only in a full run | under `@angular/build:unit-test` with `isolate: false` a workspace module can run its module scope once **per spec file**, while an external package such as `prom-client` keeps one default registry per worker | not something a hook can catch — it throws at import. Look the metric up before creating it: `register.getSingleMetric(name) ?? new Histogram({ name, … })`, or give the module its own `new Registry()` |
3486
+ | `NotSupportedError: This name has already been registered in the registry` at import | the same per-spec-file evaluation reaching a module-scope `customElements.define` — the registry belongs to the worker's document | `if (!customElements.get(name)) customElements.define(name, Element)`; a definition cannot be undone, so nothing can sweep it |
3462
3487
  | a block of files reported as failed suites with no stack, and zero failing tests | a test left an own enumerable key on `Object.prototype`; `mergeHooks` spreads it and collection dies | `setupAutoSpy()` guards it by default; `prototypePollution` tunes the reaction |
3463
3488
  | `expected [ { at: 1, …(5) }, …(8) ] to deeply equal [ { …(6) }, … ]` | one field moved in every element — usually a frozen clock or an id | `expect(diffByField(actual, expected)).toBeUndefined()` |
3464
3489
  | a hand-tuned number of turns waiting for a `resource()` to load | a resource needs a change-detection **tick**, not event-loop turns; `flushEventLoopUntil` never ticks and the resource never even issues its request | `flushEffects()`, flush the request, then `await settleResource(r, { label })` |
package/README.md CHANGED
@@ -2923,6 +2923,7 @@ single-purpose utility you can pick up independently — they all ride on the sa
2923
2923
  | `withoutStrayTimerTracking(work)` | `/setup` | Run setup work whose timers the stray-timer tracker neither counts nor cancels — jsdom schedules one per Web Storage write |
2924
2924
  | `describeStrayTimers()` | `/setup` | Every timer still pending, with its kind, the spec file that scheduled it and the scheduling frames — the list `onStrayTimers` gets |
2925
2925
  | `guardPrototypePollution(reaction)` | `/setup` | Name the test that left a key on `Object.prototype` — it stops later files collecting ([details](#test-run-hygiene)) |
2926
+ | `guardDocumentPollution(option)` | `/setup` | Name the test that left an attribute on `<html>` / `<body>`, and put it back ([details](#test-run-hygiene)) |
2926
2927
  | `installPerTest(install)` | `/setup` | Re-install a stub before every test of the block — a `describe`-level stub is restored away after the first |
2927
2928
  | `setupAngularTestEnv(opts)` | `/angular` | Zone and zoneless spec files in one worker, switching platforms per file |
2928
2929
  | `restoreTimerGlobals()` | `/setup` | Put back timer globals that uninstalling the fakes deleted rather than restored |
@@ -3202,8 +3203,16 @@ not a function` on Node 25, `undefined` on Node 26, under jsdom and happy-dom al
3202
3203
  console method a test replaced is put back after it, so one file's silence cannot reach the next,
3203
3204
  and the library's own warnings count like any other output. `allow: [...]` is the last resort,
3204
3205
  for environment noise no spec can reach.
3205
- 13. **Every guard at its strictest — `preset: 'strict'`.** `duplicateCopies`, `propsOutsideHooks`,
3206
- `guardGlobals`, `prototypePollution`, `strayConsole` and `misconfiguration` to `'throw'`,
3206
+ 13. **Attributes left on the shared document.** Opt-in, `documentPollution: 'throw'`, and on under
3207
+ `preset: 'strict'`. Under `isolate: false` a worker's spec files share one jsdom document, so a
3208
+ `data-*` attribute or a class a component set on `<body>` and never took off changes what a later
3209
+ file's code sees: `document.querySelector('[data-reset-focus]')` matches, a service returns early,
3210
+ and 34 of 209 tests fail only when the two files share a worker. The guard compares the attributes
3211
+ of `<html>`, `<head>` and `<body>` around every test, puts them back and fails the test that changed
3212
+ them; `{ nodes: true }` watches their child elements too. It looks after the TestBed's own teardown,
3213
+ so what a destroyed component cleans up is never reported.
3214
+ 14. **Every guard at its strictest — `preset: 'strict'`.** `duplicateCopies`, `propsOutsideHooks`,
3215
+ `guardGlobals`, `prototypePollution`, `documentPollution`, `strayConsole` and `misconfiguration` to `'throw'`,
3207
3216
  `strayTimers` on, `strayRejections` on where zone.js is loaded; an option passed alongside still
3208
3217
  wins. Not in it: strict doubles (a semantic switch, not a grade) and `unconfiguredReads`, their read
3209
3218
  side, `blockNetwork`, `restoreMocks`,
@@ -3224,6 +3233,7 @@ not a function` on Node 25, `undefined` on Node 26, under jsdom and happy-dom al
3224
3233
  | `blockNetwork` | `false` | Close every network channel the environment has — `true`, or a narrowing object |
3225
3234
  | `guardGlobals` | `'off'` | Report a test that redefines a global property as non-configurable |
3226
3235
  | `prototypePollution` | `'throw'` | Sweep and report an enumerable key a test left on a built-in prototype |
3236
+ | `documentPollution` | `'off'` | Put back and report an attribute a test left on `<html>` / `<head>` / `<body>` — `'warn'`, `'throw'`, or `{ reaction, nodes, ignoreAttributes, ignoreNodes }` |
3227
3237
  | `strayConsole` | `'off'` | Fail a test (or a file) whose console output nothing absorbed — `'warn'`, `'throw'`, or `{ reaction, allow }` |
3228
3238
  | `misconfiguration` | `'warn'` | `'throw'` fails the library's own misuse reports at the call site, every occurrence |
3229
3239
  | `preset` | — | `'strict'` starts every guard above at its strictest grade; explicit options still win |
@@ -3800,6 +3810,7 @@ another, and the next one is in that same file.
3800
3810
  | `guardStrayConsole(reaction)` / `withoutStrayTimerTracking(work)` _(`/setup`)_ | Fail a test on console output nothing absorbed; keep setup work's timers out of the stray-timer count |
3801
3811
  | `describeStrayTimers()` _(`/setup`)_ | Each pending timer with the spec file and frames that scheduled it — for a suite that sweeps by hand |
3802
3812
  | `guardPrototypePollution(reaction)` _(`/setup`)_ | Name the test that left a key on `Object.prototype`, before the next file fails to collect |
3813
+ | `guardDocumentPollution(option)` _(`/setup`)_ | Name the test that left an attribute on `<html>` / `<body>`, before a later file takes another branch on it |
3803
3814
  | `consoleDebugSpy` … `consoleWarnSpy` _(`/console`)_ | Silent typed spies replacing the global `console` methods on import |
3804
3815
  | `installConsoleSpies()` / `resetConsoleSpies()` / `restoreConsole()` | Install / clear / undo the console spies |
3805
3816
  | `explainSpy(spy, method?)` _(`/diagnostics`)_ | Every configured argument list next to every recorded call, attributed to the config it hit |
package/dist/cli.js CHANGED
@@ -2455,7 +2455,7 @@ function checkForeignPragma(graph) {
2455
2455
  }
2456
2456
 
2457
2457
  // src/cli/checks/export-map.generated.ts
2458
- var EXPORT_MAP_VERSION = "5.16.0";
2458
+ var EXPORT_MAP_VERSION = "5.17.0";
2459
2459
  var ENTRY_SPECIFIERS = "vitest-auto-spy vitest-auto-spy/bun vitest-auto-spy/bun-angular vitest-auto-spy/node vitest-auto-spy/rstest vitest-auto-spy/rxjs vitest-auto-spy/console vitest-auto-spy/dom-stubs vitest-auto-spy/diagnostics vitest-auto-spy/jasmine vitest-auto-spy/jasmine-compat vitest-auto-spy/observer-spy vitest-auto-spy/angular vitest-auto-spy/angular-http vitest-auto-spy/angular-router vitest-auto-spy/signal-forms vitest-auto-spy/nestjs vitest-auto-spy/react vitest-auto-spy/vue vitest-auto-spy/svelte vitest-auto-spy/setup vitest-auto-spy/zone vitest-auto-spy/eslint-plugin";
2460
2460
  var EXPORTED_BY = {
2461
2461
  AccessorImplementations: "0 1 2 3 4 12 17 18 19",
@@ -2571,6 +2571,8 @@ var EXPORTED_BY = {
2571
2571
  DirectiveHostOptions: "12",
2572
2572
  disableAngularDiagnostics: "12",
2573
2573
  disableTestBedDiagnostics: "12",
2574
+ DocumentPollutionOptions: "20",
2575
+ DocumentPollutionReaction: "20",
2574
2576
  DomRegistrar: "2",
2575
2577
  DuplicateCopiesReaction: "20",
2576
2578
  EmissionObserver: "0 1 2 3 4 12 17 18 19",
@@ -2612,6 +2614,7 @@ var EXPORTED_BY = {
2612
2614
  getWatchedTimerGlobals: "20",
2613
2615
  GlobalPatchReaction: "20",
2614
2616
  GlobalRegistratorOptions: "2",
2617
+ guardDocumentPollution: "20",
2615
2618
  guardGlobalPatches: "20",
2616
2619
  guardPrototypePollution: "20",
2617
2620
  guardStrayConsole: "20",
package/dist/setup.d.ts CHANGED
@@ -4,6 +4,39 @@ import { z as UnstubbedCallHandler, G as UnstubbedReadHandler } from './types-D3
4
4
  export { d as describeDuplicateCopies, g as getPackageCopies, t as takeStrictViolations } from './package-identity-lm-gqZAR.js';
5
5
  export { R as RestoreWebStorageOptions, r as restoreWebStorage } from './web-storage-dwQ9E7Lm.js';
6
6
 
7
+ /** How a per-test guard reacts to what it found: fail the test, only report it, or not run at all. */
8
+ type GuardReaction = 'off' | 'throw' | 'warn';
9
+
10
+ /** How {@link guardDocumentPollution} reacts to a document a test left changed. */
11
+ type DocumentPollutionReaction = GuardReaction;
12
+ /** The object form of `documentPollution`, for a reaction plus what a project cannot clean up. */
13
+ interface DocumentPollutionOptions {
14
+ /** Default `'throw'`. */
15
+ reaction?: DocumentPollutionReaction;
16
+ /**
17
+ * Also watch the element children of `<head>` and `<body>`. Default `false`: a module that injects a
18
+ * stylesheet when it is first imported does so once per worker, and a node check would charge that
19
+ * `<style>` to whichever test happened to import it first.
20
+ */
21
+ nodes?: boolean;
22
+ /** Attribute names left alone, as an exact name or a tested RegExp. Matched on all three elements. */
23
+ ignoreAttributes?: readonly (RegExp | string)[];
24
+ /** A CSS selector for child elements left alone when `nodes` is on — `'style, link[rel=stylesheet]'`. */
25
+ ignoreNodes?: string;
26
+ }
27
+ /**
28
+ * Watch `<html>`, `<head>` and `<body>` for attributes — and, when asked, children — a test leaves
29
+ * changed, put them back, and name the test that changed them.
30
+ *
31
+ * Registers the hooks itself; `setupAutoSpy({ documentPollution: … })` is how a project turns it on,
32
+ * and `preset: 'strict'` turns it on at `'throw'`. Off by default: a leftover attribute breaks a later
33
+ * file only when something reads it, and a suite that has lived with a few must not go red on upgrade.
34
+ *
35
+ * @param option `'throw'` fails the test (or, for a `beforeAll` leftover, the file), `'warn'` puts the
36
+ * document back and only reports it, `'off'` registers nothing — including the repair.
37
+ */
38
+ declare function guardDocumentPollution(option: DocumentPollutionOptions | DocumentPollutionReaction): void;
39
+
7
40
  /**
8
41
  * Fake-timer helpers — the boilerplate every suite that tests a debounce, a poll or a retry ends up
9
42
  * writing by hand, and the one mistake it makes while doing so.
@@ -86,9 +119,6 @@ interface SetupFakeTimersOptions {
86
119
  */
87
120
  declare function advanceTimers(ms?: number): Promise<void>;
88
121
 
89
- /** How a per-test guard reacts to what it found: fail the test, only report it, or not run at all. */
90
- type GuardReaction = 'off' | 'throw' | 'warn';
91
-
92
122
  /** How {@link guardGlobalPatches} reacts to a patch that cannot be undone. */
93
123
  type GlobalPatchReaction = GuardReaction;
94
124
  /**
@@ -587,6 +617,18 @@ interface SetupAutoSpyOptions {
587
617
  * {@link guardPrototypePollution}.
588
618
  */
589
619
  prototypePollution?: PrototypePollutionReaction;
620
+ /**
621
+ * Report — and put back — an attribute a test leaves added, changed or removed on `<html>`, `<head>`
622
+ * or `<body>`; with `{ nodes: true }`, a child element of `<head>` or `<body>` as well. Default
623
+ * `'off'`; `'throw'` under `preset: 'strict'`.
624
+ *
625
+ * Under `isolate: false` every file in a worker shares one document, so a `data-*` attribute or a
626
+ * class a component set on `<body>` and never took off changes the branch some later file's code
627
+ * takes — a failure in a file that never touched it, only when the two share a worker. Checked after
628
+ * the TestBed's own teardown, so what a destroyed component cleans up is never reported. See
629
+ * {@link guardDocumentPollution}.
630
+ */
631
+ documentPollution?: DocumentPollutionOptions | DocumentPollutionReaction;
590
632
  /**
591
633
  * Put back timer globals that uninstalling the fakes removed rather than restored. Default `true`:
592
634
  * it only ever replaces a global that has gone missing, so it cannot overwrite anything a spec
@@ -987,4 +1029,4 @@ declare function restoreTimerGlobals(): void;
987
1029
  /** The names this module watches — exported for the diagnostics that report what went missing. */
988
1030
  declare function getWatchedTimerGlobals(): string[];
989
1031
 
990
- export { BLOCKED_FETCH_MESSAGE, BLOCKED_XHR_MESSAGE, type BlockNetworkOptions, type CountingClock, type CountingClockOptions, type DuplicateCopiesReaction, type FakeTimersConfig, type GlobalPatchReaction, type MisconfigurationReaction, type PerTestHandle, type PrototypePollutionReaction, type RejectionHost, type SchedulerHost, type SetupAutoSpyOptions, type SetupAutoSpyPreset, type SpyEngine, type StopTrackingRejections, type StopTrackingTimers, type StrayConsoleOptions, type StrayConsoleReaction, type StrayRejection, type StrayTimer, type StrayTimerReport, type SwallowedStrictCallsReaction, type SystemTime, type UnconfiguredReadsReaction, type XhrBlockMode, advanceTimers, blockNetwork, cancelStrayTimers, captureMockRegistry, countStrayRejections, countStrayTimers, describeStrayTimers, flushStrayRejections, getMockRegistrySize, getSpyEngine, getWatchedTimerGlobals, guardGlobalPatches, guardPrototypePollution, guardStrayConsole, installPerTest, keepMockRegistered, keepRegisteredMocks, mockNow, mockSystemTime, pruneMockRegistry, registerFocusMatchers, resetMockRegistryTracking, restoreLongLivedImplementations, restoreTimerGlobals, setSpyEngine, setupAutoSpy, setupFakeTimers, trackMockRegistry, trackStrayRejections, trackStrayTimers, useCountingClock, withSystemTime, withoutStrayTimerTracking };
1032
+ export { BLOCKED_FETCH_MESSAGE, BLOCKED_XHR_MESSAGE, type BlockNetworkOptions, type CountingClock, type CountingClockOptions, type DocumentPollutionOptions, type DocumentPollutionReaction, type DuplicateCopiesReaction, type FakeTimersConfig, type GlobalPatchReaction, type MisconfigurationReaction, type PerTestHandle, type PrototypePollutionReaction, type RejectionHost, type SchedulerHost, type SetupAutoSpyOptions, type SetupAutoSpyPreset, type SpyEngine, type StopTrackingRejections, type StopTrackingTimers, type StrayConsoleOptions, type StrayConsoleReaction, type StrayRejection, type StrayTimer, type StrayTimerReport, type SwallowedStrictCallsReaction, type SystemTime, type UnconfiguredReadsReaction, type XhrBlockMode, advanceTimers, blockNetwork, cancelStrayTimers, captureMockRegistry, countStrayRejections, countStrayTimers, describeStrayTimers, flushStrayRejections, getMockRegistrySize, getSpyEngine, getWatchedTimerGlobals, guardDocumentPollution, guardGlobalPatches, guardPrototypePollution, guardStrayConsole, installPerTest, keepMockRegistered, keepRegisteredMocks, mockNow, mockSystemTime, pruneMockRegistry, registerFocusMatchers, resetMockRegistryTracking, restoreLongLivedImplementations, restoreTimerGlobals, setSpyEngine, setupAutoSpy, setupFakeTimers, trackMockRegistry, trackStrayRejections, trackStrayTimers, useCountingClock, withSystemTime, withoutStrayTimerTracking };
package/dist/setup.js CHANGED
@@ -10,7 +10,7 @@ import { defineHelper, setDefaultStrictMode, setMisconfigurationReaction, takeSt
10
10
  export { takeStrictViolations } from './chunk-MUJTPXMJ.js';
11
11
  import './chunk-OTOCFH6B.js';
12
12
  import { withDocs, DOCS_LINKS } from './chunk-Q477VHHF.js';
13
- import { beforeAll, beforeEach, afterEach, afterAll, vi, onTestFinished, expect } from 'vitest';
13
+ import { beforeAll, beforeEach, onTestFinished, afterEach, afterAll, vi, expect } from 'vitest';
14
14
  import { describeDuplicateCopies } from './shared-state.js';
15
15
  export { describeDuplicateCopies, getPackageCopies } from './shared-state.js';
16
16
 
@@ -110,6 +110,137 @@ function noticeAngularBuildSplitting(write = writeWarning, readVersion = readIns
110
110
  )
111
111
  );
112
112
  }
113
+ var QUOTED_VALUE_LENGTH = 80;
114
+ function resolveDocumentPollution(option) {
115
+ if (typeof option === "object") {
116
+ return {
117
+ reaction: option.reaction ?? "throw",
118
+ nodes: option.nodes ?? false,
119
+ ignoreAttributes: option.ignoreAttributes ?? [],
120
+ ignoreNodes: option.ignoreNodes
121
+ };
122
+ }
123
+ return { reaction: option ?? "off", nodes: false, ignoreAttributes: [], ignoreNodes: void 0 };
124
+ }
125
+ function isIgnoredAttribute(name, ignore) {
126
+ return ignore.some((pattern) => typeof pattern === "string" ? pattern === name : pattern.test(name));
127
+ }
128
+ function readAttributes(element, ignore) {
129
+ return new Map([...element.attributes].filter(({ name }) => !isIgnoredAttribute(name, ignore)).map(({ name, value }) => [name, value]));
130
+ }
131
+ function readChildren(element, options) {
132
+ if (!options.nodes) {
133
+ return void 0;
134
+ }
135
+ const ignore = options.ignoreNodes;
136
+ return [...element.children].filter((child) => ignore === void 0 || !child.matches(ignore));
137
+ }
138
+ function snapshotDocument(options, doc = Reflect.get(globalThis, "document")) {
139
+ if (!doc) {
140
+ return { watched: [], options };
141
+ }
142
+ const candidates = [
143
+ ["<html>", doc.documentElement],
144
+ ["<head>", doc.head],
145
+ ["<body>", doc.body]
146
+ ];
147
+ const watched = candidates.flatMap(
148
+ ([label, element]) => element ? [{ label, element, attributes: readAttributes(element, options.ignoreAttributes), children: readChildren(element, options) }] : []
149
+ );
150
+ return { watched, options };
151
+ }
152
+ function quote(value) {
153
+ const shown = value.length > QUOTED_VALUE_LENGTH ? `${value.slice(0, QUOTED_VALUE_LENGTH)}\u2026` : value;
154
+ return JSON.stringify(shown);
155
+ }
156
+ function restoreAttributes({ label, element, attributes }, ignore) {
157
+ const current = readAttributes(element, ignore);
158
+ const lines = [];
159
+ current.forEach((value, name) => {
160
+ const before = attributes.get(name);
161
+ if (before === void 0) {
162
+ lines.push(`${label} ${name}=${quote(value)} added`);
163
+ element.removeAttribute(name);
164
+ } else if (before !== value) {
165
+ lines.push(`${label} ${name} changed from ${quote(before)} to ${quote(value)}`);
166
+ element.setAttribute(name, before);
167
+ }
168
+ });
169
+ attributes.forEach((before, name) => {
170
+ if (!current.has(name)) {
171
+ lines.push(`${label} ${name} removed (was ${quote(before)})`);
172
+ element.setAttribute(name, before);
173
+ }
174
+ });
175
+ return lines;
176
+ }
177
+ function describeElement(element) {
178
+ const id = element.id ? ` id="${element.id}"` : "";
179
+ const className = element.getAttribute("class");
180
+ return `<${element.localName}${id}${className ? ` class="${className}"` : ""}>`;
181
+ }
182
+ function restoreChildren({ label, element, children }, options) {
183
+ const before = children ?? [];
184
+ const now = readChildren(element, options) ?? [];
185
+ const added = now.filter((child) => !before.includes(child));
186
+ const removed = before.filter((child) => child.parentNode !== element);
187
+ added.forEach((child) => child.remove());
188
+ let anchor = null;
189
+ for (const child of [...before].reverse()) {
190
+ if (child.parentNode !== element) {
191
+ element.insertBefore(child, anchor);
192
+ }
193
+ anchor = child;
194
+ }
195
+ return [
196
+ ...added.map((child) => `${label} child ${describeElement(child)} added`),
197
+ ...removed.map((child) => `${label} child ${describeElement(child)} removed`)
198
+ ];
199
+ }
200
+ function report(lines, scope) {
201
+ return withDocs(
202
+ `[vitest-auto-spy] ${scope} left the shared document changed:
203
+ ${lines.map((line) => ` - ${line}`).join("\n")}
204
+ Under \`isolate: false\` every later spec file in this worker runs against that document, and code that reads it \u2014 \`document.querySelector('[data-reset-focus]')\`, a class on <body> \u2014 takes another branch there, so the failure lands in a file that never touched it, and only when the two share a worker. The document has been put back. Undo the change where it was made: in the \`ngOnDestroy\` / \`DestroyRef.onDestroy\` of the component that set it, in an \`afterEach\` of this spec, or by destroying the fixture that owns it.`,
205
+ DOCS_LINKS.setup
206
+ );
207
+ }
208
+ function checkDocumentPollution(snapshot, scope, write = writeWarning) {
209
+ const { options } = snapshot;
210
+ const lines = snapshot.watched.flatMap((watched) => [
211
+ ...restoreAttributes(watched, options.ignoreAttributes),
212
+ ...restoreChildren(watched, options)
213
+ ]);
214
+ if (lines.length === 0) {
215
+ return;
216
+ }
217
+ const message = report(lines, scope);
218
+ if (options.reaction === "throw") {
219
+ throw new Error(message);
220
+ }
221
+ write(message);
222
+ }
223
+ function testScope() {
224
+ const { currentTestName, testPath } = expect.getState();
225
+ return `"${currentTestName ?? "this test"}" (${testPath ?? "this file"})`;
226
+ }
227
+ function fileScope() {
228
+ return `${expect.getState().testPath ?? "this file"}, outside any test (a beforeAll or afterAll),`;
229
+ }
230
+ function guardDocumentPollution(option) {
231
+ const options = resolveDocumentPollution(option);
232
+ if (options.reaction === "off") {
233
+ return;
234
+ }
235
+ beforeAll(() => {
236
+ const file = snapshotDocument(options);
237
+ return () => checkDocumentPollution(file, fileScope());
238
+ });
239
+ beforeEach(() => {
240
+ const test = snapshotDocument(options);
241
+ onTestFinished(() => checkDocumentPollution(test, testScope()));
242
+ });
243
+ }
113
244
 
114
245
  // src/lib/timer-globals.ts
115
246
  var TIMER_GLOBALS = [
@@ -234,7 +365,7 @@ function sealedAdditions({ object, names }) {
234
365
  }
235
366
  return added.filter((name) => isSealed(object, name));
236
367
  }
237
- function report({ name }, added) {
368
+ function report2({ name }, added) {
238
369
  const testPath = expect.getState().testPath ?? "this file";
239
370
  return withDocs(
240
371
  `[vitest-auto-spy] ${testPath} redefined ${added.map((property) => `${name}.${property}`).join(", ")} as a non-configurable own property, so nothing can put it back \u2014 not \`restoreMockedProps()\`, not \`vi.unstubAllGlobals()\`, not the next file's own \`Object.defineProperty\`. \`Object.defineProperty\` defaults \`configurable\` to \`false\`; use \`mockValueProp(${name}, '${added[0]}', value)\`, which records the descriptor it replaced and registers the undo.`,
@@ -244,7 +375,7 @@ function report({ name }, added) {
244
375
  function checkSealedAdditions(before, reaction) {
245
376
  const found = before.flatMap((snapshot) => {
246
377
  const added = sealedAdditions(snapshot);
247
- return added.length > 0 ? [report(snapshot, added)] : [];
378
+ return added.length > 0 ? [report2(snapshot, added)] : [];
248
379
  });
249
380
  reactToFindings(found, reaction);
250
381
  }
@@ -514,7 +645,7 @@ function pollutingAdditions({ object, keys }) {
514
645
  });
515
646
  return added;
516
647
  }
517
- function report2({ name }, added) {
648
+ function report3({ name }, added) {
518
649
  const testPath = expect.getState().testPath ?? "this file";
519
650
  return withDocs(
520
651
  `[vitest-auto-spy] ${testPath} left ${added.map((key) => `"${key}"`).join(", ")} on ${name} as an own enumerable property. Vitest walks a file's hooks with \`for\u2026in\`, so the extra key is spread as if it were an array and **every spec file after this one in the same worker fails to collect** \u2014 with no stack, because every frame of that error is filtered out of the report. The key has been taken back off so the rest of the run survives. Patch the prototype of the class the object came from, not the prototype of a plain object or of a test double: \`Object.getPrototypeOf(instance)\` is \`Object.prototype\` itself whenever \`instance\` is an object literal.`,
@@ -524,7 +655,7 @@ function report2({ name }, added) {
524
655
  function checkPrototypePollution(before, reaction) {
525
656
  const found = before.flatMap((snapshot) => {
526
657
  const added = pollutingAdditions(snapshot);
527
- return added.length > 0 ? [report2(snapshot, added)] : [];
658
+ return added.length > 0 ? [report3(snapshot, added)] : [];
528
659
  });
529
660
  reactToFindings(found, reaction);
530
661
  }
@@ -686,7 +817,7 @@ function restoreConsoleMethods(guard) {
686
817
  }
687
818
  var ABSORB_ADVICE = 'Absorb what the test expects: `installConsoleSpies()` from `vitest-auto-spy/console` in a `beforeEach`, then assert on `consoleErrorSpy` and its siblings \u2014 or `vi.spyOn(console, "error").mockImplementation(() => undefined)`. A `vi.spyOn` with no implementation calls through and still prints. Output the code should not make is a defect to fix, not to silence; `strayConsole: { allow: [...] }` is the last resort, for environment noise no spec can reach.';
688
819
  var IMPORTED_SPIES_ADVICE = "`vitest-auto-spy/console` is loaded in this worker, but under `strayConsole` importing a spy installs nothing: under `isolate: false` the import runs once per worker, so it cannot tell which file it belongs to. Call `installConsoleSpies()` in a `beforeEach`, or at the top of the file for all of its tests.";
689
- function quote(calls, total, withTest) {
820
+ function quote2(calls, total, withTest) {
690
821
  const lines = calls.map((call) => {
691
822
  const during = withTest && call.test !== void 0 ? ` (during "${call.test}")` : "";
692
823
  const text = call.text.split("\n").join("\n ");
@@ -704,7 +835,7 @@ ${IMPORTED_SPIES_ADVICE}` : ABSORB_ADVICE;
704
835
  const subject = file === void 0 ? `"${test ?? ""}" ${count}` : `${file} ${count} ${OUTSIDE_TEST}`;
705
836
  return withDocs(
706
837
  `[vitest-auto-spy] ${subject} and nothing absorbed it:
707
- ${quote(bucket.calls, bucket.total, file !== void 0)}
838
+ ${quote2(bucket.calls, bucket.total, file !== void 0)}
708
839
  ${advice}`,
709
840
  DOCS_LINKS.setup
710
841
  );
@@ -1114,14 +1245,14 @@ function reportDuplicateCopies(reaction) {
1114
1245
  if (reaction === "off") {
1115
1246
  return;
1116
1247
  }
1117
- const report3 = describeDuplicateCopies();
1118
- if (!report3) {
1248
+ const report4 = describeDuplicateCopies();
1249
+ if (!report4) {
1119
1250
  return;
1120
1251
  }
1121
1252
  if (reaction === "throw") {
1122
- throw new Error(report3);
1253
+ throw new Error(report4);
1123
1254
  }
1124
- console.warn(report3);
1255
+ console.warn(report4);
1125
1256
  }
1126
1257
  var LATE_ASSERTION_ADVICE = "An assertion that settles after its test has finished cannot fail it: the test it belongs to was reported green without ever running it. The usual causes are `.then(() => expect(...))` and an `async` helper called without `await` \u2014 return or await the promise so the assertion lands inside the test.";
1127
1258
  var UNHANDLED_ERROR_ADVICE = "A rejection nothing handled is a code path the suite never asserted on: under zone.js it fails no test, so the run stays green while the error scrolls past in stderr. Await the promise, or assert on it with `await expect(promise).rejects.toThrow(...)`.";
@@ -1288,6 +1419,7 @@ function applyPreset(options) {
1288
1419
  propsOutsideHooks: options.propsOutsideHooks ?? "throw",
1289
1420
  guardGlobals: options.guardGlobals ?? "throw",
1290
1421
  prototypePollution: options.prototypePollution ?? "throw",
1422
+ documentPollution: options.documentPollution ?? "throw",
1291
1423
  strayConsole: options.strayConsole ?? "throw",
1292
1424
  misconfiguration: options.misconfiguration ?? "throw",
1293
1425
  swallowedStrictCalls: options.swallowedStrictCalls ?? "throw",
@@ -1344,6 +1476,7 @@ function setupAutoSpy(input = {}) {
1344
1476
  armStrictMode(options);
1345
1477
  armUnconfiguredReads(options);
1346
1478
  armMisconfiguration(options.misconfiguration);
1479
+ guardDocumentPollution(options.documentPollution ?? "off");
1347
1480
  if (options.globalFakeTimers) {
1348
1481
  setupFakeTimers(options.globalFakeTimers === true ? void 0 : options.globalFakeTimers, { betweenTests: true });
1349
1482
  }
@@ -1505,4 +1638,4 @@ function registerFocusMatchers() {
1505
1638
  }
1506
1639
  useVitestAdapter();
1507
1640
 
1508
- export { BLOCKED_FETCH_MESSAGE, BLOCKED_XHR_MESSAGE, advanceTimers, blockNetwork, cancelStrayTimers, captureMockRegistry, countStrayRejections, countStrayTimers, describeStrayTimers, flushStrayRejections, getMockRegistrySize, getWatchedTimerGlobals, guardGlobalPatches, guardPrototypePollution, guardStrayConsole, installPerTest, keepMockRegistered, keepRegisteredMocks, mockNow, mockSystemTime, pruneMockRegistry, registerFocusMatchers, resetMockRegistryTracking, restoreLongLivedImplementations, restoreTimerGlobals, restoreWebStorage, setupAutoSpy, setupFakeTimers, trackMockRegistry, trackStrayRejections, trackStrayTimers, useCountingClock, withSystemTime, withoutStrayTimerTracking };
1641
+ export { BLOCKED_FETCH_MESSAGE, BLOCKED_XHR_MESSAGE, advanceTimers, blockNetwork, cancelStrayTimers, captureMockRegistry, countStrayRejections, countStrayTimers, describeStrayTimers, flushStrayRejections, getMockRegistrySize, getWatchedTimerGlobals, guardDocumentPollution, guardGlobalPatches, guardPrototypePollution, guardStrayConsole, installPerTest, keepMockRegistered, keepRegisteredMocks, mockNow, mockSystemTime, pruneMockRegistry, registerFocusMatchers, resetMockRegistryTracking, restoreLongLivedImplementations, restoreTimerGlobals, restoreWebStorage, setupAutoSpy, setupFakeTimers, trackMockRegistry, trackStrayRejections, trackStrayTimers, useCountingClock, withSystemTime, withoutStrayTimerTracking };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vitest-auto-spy",
3
- "version": "5.16.0",
3
+ "version": "5.17.0",
4
4
  "description": "Auto-generate fully-typed test spies from a class — across Vitest, Bun, node:test and Rstest, with Angular/NestJS/React/Vue/Svelte recipes. Drop-in replacement for jest-auto-spies and jasmine-auto-spies.",
5
5
  "keywords": [
6
6
  "auto-mock",
@@ -137,98 +137,101 @@ it('loads', async () => {
137
137
 
138
138
  ## Reach for these before hand-rolling
139
139
 
140
- | Situation | Helper |
141
- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
142
- | a `signal()` / `computed()` field on the class under test | `mockSignalProp(obj, prop, initial)` — writes through a writable one; an `input()` is refused |
143
- | a resource field, when the HTTP round trip is not the point | `mockResourceProp(obj, prop, initial)` — `set` / `fail` / `loading` |
144
- | the HTTP round trip _is_ the point | `expectRequest(url).flush(body)` — `/angular-http`, settling included |
145
- | a component or service that reads `ActivatedRoute` | `provideActivatedRoute({ params })`, then `injectActivatedRoute().setParams(…)` — `/angular-router` |
146
- | a guard or a class built with `new` that takes the route | `createActivatedRoute({ params })` — the same handle, no `TestBed` |
147
- | a node:test suite whose heap grows all run | `trackNodeMocks()` — `/node`, a private MockTracker |
148
- | a Nest provider whose constructor keeps changing | `createNestUnit(Target, { expose })` — built from its DI metadata |
149
- | asserting a resource's value _and_ status together | `registerResourceMatchers()` → `toHaveResourceValue` / `toBeLoading` |
150
- | a callback or config object the code under test built | `captureArg<T>()` in the assertion, then read `.value` |
151
- | an object the test already holds — a real service, a client, `TestBed.inject(X)` | `createSpyFromInstance(obj, config?)` — patches it in place, `restoreSpiedInstance` undoes it |
152
- | a `calledWith` that is not matching and you cannot see why | `explainSpy(spy, 'method')` from `/diagnostics` — configs, calls, which hit which |
153
- | a method that has to throw — for all calls, or for some arguments | `spy.m.failWith(err)` / `spy.m.calledWith(x).failWith(err)` — **not** `throwWith` |
154
- | a `TestBed` spec on Vitest 4.1+, to drop `let` + `beforeEach` | `extendWithAutoSpies(test, { cart: CartService })` (`/angular`) |
155
- | an `effect()` whose trigger is now a static signal | `runEffect(effectRef)` |
156
- | the component constructs its own `IntersectionObserver` | `stubIntersectionObserver()` (+ `Resize` / `Mutation`) |
157
- | a green run exiting 1 with `AbortError` under happy-dom | `setupAutoSpy({ blockNetwork: true })` |
158
- | a real request going out of a unit run (`fetch`, XHR, `sendBeacon`) | `setupAutoSpy({ blockNetwork: true })` — `{ xhr: 'empty' }` for pings |
159
- | timers or frames from a previous file failing this one | `setupAutoSpy({ strayTimers: true })` — mutes `--detect-async-leaks`, see below |
160
- | an assertion error in stderr, every test green and the run at 0 | `setupAutoSpy({ strayRejections: true })` — zone.js swallowed it |
161
- | a run getting slower the longer it goes, on `isolate: false` | `setupAutoSpy({ pruneMockRegistry: true })` — the mock registry |
162
- | `JavaScript heap out of memory` on a suite of wide generated clients | `createSpyFromClass(X, { lazySpies: 'proxy' })` — one trap object, not a placeholder per method |
163
- | `Cannot read properties of undefined (reading 'now')` | `restoreTimerGlobals` — on by default |
164
- | `localStorage.setItem is not a function`, or it is `undefined` | `restoreWebStorage` — on by default; Vitest's global filter, Node 25+ |
165
- | a spy handed to an API typed against the real class | `asInstance()` / `asSpy()` |
166
- | the code under test does `new X()` (a global, a vendor SDK) | `mockConstructor(factory)` / `stubConstructor(obj, key, factory)` |
167
- | `X is not a constructor`, with a stack in production code | same — a `vi.fn(() => …)` cannot serve `new` |
168
- | waiting for a dynamic `import()` under fake timers | `settleDynamicImport(() => import('…'))` / `flushEventLoop()` |
169
- | `addEventListener(…, { signal })` throwing about `EventTarget` | `stubAbortController()` |
170
- | `codemod` reporting a truncated repository scan | `VITEST_AUTO_SPY_SCAN_CAP=<n>` — raises the 50 000-file cap |
171
- | a suite ported from Jest's `fakeTimers.enableGlobally` | `setupAutoSpy({ globalFakeTimers: true })` |
172
- | `toHaveBeenCalledBefore` across an auto-spy and a hand-written `vi.fn()` | `setSpyEngine('runner')` / `getSpyEngine()` — `/setup`, Vitest only |
173
- | a nested `describe`'s `beforeAll` landing on real timers | `setupFakeTimers(cfg, { betweenTests: true })` |
174
- | setup hooks applying to the first spec file of a worker only | the setup module is cached — run coverage with `--isolate` |
175
- | `fakeAsync` inside `test.concurrent` | `installProxyZonePatch({ scope: 'callback' })` |
176
- | an assertion containing a date | `mockSystemTime(iso)` — never `vi.spyOn(globalThis, 'Date')` |
177
- | a spec asserting on tick _order_ under a frozen clock | `useCountingClock()` |
178
- | a dependency declared in the component's own `providers` | `overrideComponentProvider(Cmp, Token)` — it verifies on the first fixture that the override applied |
179
- | a double answering `undefined` for a method nobody configured | `{ strict: true }`, or `setupAutoSpy({ strict: true })` suite-wide |
180
- | a strict throw caught by a `try`/`catch` or an operator, the test still green | `setupAutoSpy({ swallowedStrictCalls: 'throw' })`; provoked on purpose — `takeStrictViolations()` from `/setup` |
181
- | a strict double's getter answering `undefined`, or its `items$` never emitting | `setupAutoSpy({ unconfiguredReads: 'throw' })` reports it after the test; survey first with `onUnstubbedRead` |
182
- | an `afterEach` that exists only to reset one spy | `using spy = createSpyFromClass(X)` — `[Symbol.dispose]` resets it |
183
- | a dead NgModule import, dead `schemas`, an unflushed HTTP request | `enableAngularDiagnostics()` in the setup file, after `initTestEnvironment` |
184
- | the spy is provided but the component resolves its own, so the test runs the real service | `enableAngularDiagnostics({ shadowedProviders })`, or `assertNoShadowedProviders(Component, fixture)` |
185
- | "which collaborators did this actually inject?" | `trackInjections([A, TOKEN])` — providers plus the record, not `vi.mock` |
186
- | `NG0303` / `NG0301` / `NG0304` from an imported NgModule | `assertNgModuleScopes(Module)` — an AOT bundle stripped its scope |
187
- | `Cannot read properties of undefined (reading 'provide')` in `di_setup` | `assertComponentDefIntact(Cmp)` — a barrel chunk left a hole in `ɵcmp` |
188
- | a focus assertion failing as `expected false to deeply equal true` | `registerFocusMatchers()` + `expect(el).toHaveFocus()` |
189
- | a collaborator passed as an argument, then asserted on | `autoMocked<T>()` — typed `T & Spy<T>` |
190
- | a `<video>` / `<audio>`: `play()` throws, `duration` is `NaN` | `stubMediaElement({ duration })`, then `media.set(el, …)` |
191
- | a `vi.mock()` that silently did nothing under a bundler | `assertMocked(ns, { specifier, exports })` |
192
- | `No "default" export is defined on the mock` | `vi.mock('x', () => moduleNamespace({ … }))` |
193
- | waiting for a `resource()` / an SDK to become ready | `flushEventLoopUntil(() => …, { label })` — budgeted, not tuned |
194
- | `expected [ { at: 1, …(5) }, …(8) ] to deeply equal …` | `expect(diffByField(actual, expected)).toBeUndefined()` |
195
- | a stub that works only in the first test of the file | `installPerTest(() => stub…())` — or install it in `beforeEach` |
196
- | a library failing every other run after a `defineProperty` on DOM | `setupAutoSpy({ guardGlobals: 'throw' })` names the file |
197
- | a block of files failing to collect, with no stack and zero failing tests | `setupAutoSpy()` names the file that left a key on `Object.prototype` (`prototypePollution`, on by default) |
198
- | the same check in a suite that does not call `setupAutoSpy()` | `guardPrototypePollution('throw')` from `/setup` |
199
- | console output a test never asserted on, or a warning from this library | `setupAutoSpy({ strayConsole: 'throw' })` fails that test; absorb with `installConsoleSpies()` in `beforeEach` |
200
- | every guard at its strictest grade in one line | `setupAutoSpy({ preset: 'strict' })` — plus `enableAngularDiagnostics()` for Angular |
201
- | an `onlyMethodsToSpyOn` typo or `injectSpy` on a real instance that only warned | `setupAutoSpy({ misconfiguration: 'throw' })` throws at the call |
202
- | which file and call scheduled a timer that outlived its file | `onStrayTimers: ({ timers }) => expect(timers).toEqual([])`, or `describeStrayTimers()` from `/setup` |
203
- | stray timers charged to a file that only writes `localStorage` (jsdom) | `withoutStrayTimerTracking(() => seed())` from `/setup` — its timers are neither counted nor cancelled |
204
- | `import { consoleErrorSpy }` silencing other files under `isolate: false` | `installConsoleSpies()` in `beforeEach`, `restoreConsole()` in `afterEach` — the import installs once per worker |
205
- | `Cannot set base providers because it has already been called` | `setupAngularTestEnv({ zoneless, initZone, initZoneless })` |
206
- | a dependency behind an `InjectionToken`, with no class to spy | `provideAutoSpyForToken(TOKEN)` + `injectSpy(TOKEN)`; a chained call → `{ selfReturning: ['channel'] }` as the third argument |
207
- | `Expected to be running in 'ProxyZone', but it was not found` | `import 'vitest-auto-spy/zone'` (needs `globals: true`) |
208
- | `Property 'mockReturnValue' does not exist on type 'never'` | upgrade — the spy no longer collapses on an unreadable return type |
209
- | `TS2345` inside `mockReturnValue` / `mockImplementation` / `mockResolvedValue` | the stub is checked against the method's return type now — fix the stub, not the spy |
210
- | `TS2540: Cannot assign to 'x'` on a double whose runtime write works | `Spy<T>` keeps `readonly` on purpose — `mockValueProp(spy, 'x', v)`, which a spied **accessor** also needs |
211
- | a signal-valued getter that `gettersToSpyOn` will not accept | it accepts any key now; for a signal prefer `mockSignalProp` |
212
- | five `asInstance(…)` in one call, found one per `tsc` run | `...asInstances(a, b, c, d, e)` |
213
- | `nextWith` demanding `HttpEvent<T>` on a generated client | `asSpy<Client, { overload: 'first' }>(…)` / `Overload<M, 0>` |
214
- | `not assignable to parameter of type 'HttpEvent<…>'` in a stub of the real body | same thing — the helpers read the **last** overload; `{ overload: { m: 'first' } }`, not `@ts-expect-error` |
215
- | a fixture that needs a nested object built by its own call | `createMock<T>({ a: { b: 1 } })` — deep partial, still exact |
216
- | the same 100-line model literal copied into eight specs (`TS1117`) | one `createFixtureFactory<T>(defaults)`; specs call it with what they change |
217
- | `'params' in link` ladders, or a cast, to pick a union branch | `narrow.byKey(link, 'params')` / `narrow.observable(x)` |
218
- | `{ ...modelInstance, flag: true }` losing every getter | `withOverrides(modelInstance, { flag: true })` |
219
- | `NG0303` / `NG0304` / silence from a directive spec | `createDirectiveHost({ template, scope: [Module] })` |
220
- | a hand-written `class MockChartComponent` restating a child's selector | `createComponentStub(ChartComponent)` (`/angular`) — selector, inputs, outputs read from `ɵcmp` |
221
- | a `TestingStorage` class for `localStorage` / `sessionStorage` | `stubWebStorage('localStorage', { items })` from `/dom-stubs` — `snapshot()` to assert |
222
- | `'x' does not exist in type 'MethodReturns<{ y: any; }>'` on a generic class | spell out the type argument — `createSpyFromClass<Config>(Config, …)`; `provideAutoSpy` infers it |
223
- | "did the migration lose a test?" with matching counters | `compareTestRuns(before, after)` |
224
- | an input that has to change after the first render | `await setInputs(fixture, { … })` — one `setInput` per name, one wait |
225
- | a component that navigates, or reads `router.url` | `provideRouterDouble({ url })` + `injectRouterDouble()` — `/angular-router` |
226
- | a component that reads `router.currentNavigation()` for `extras.state` or `trigger` | `setCurrentNavigation({ extras: { state } })` on the same handle — no `instanceMethodsToSpyOn` |
227
- | a `window` or `document` behind a DI token | `provideWindowDouble(WINDOW, { screen })` / `provideDocumentDouble({ … })` |
228
- | `MAT_DIALOG_DATA` and `MatDialogRef` provided by hand | `provideMatDialogData(TOKEN, data)` / `provideMatDialogRef(MatDialogRef)` + `injectMatDialogRef(Ref)` |
229
- | asserting a `computed()` did **not** recompute | `trackRecomputations(sig)` / `trackEffectRuns(ref)` — `{ count, stop() }` |
230
- | `form()` in a spec, or `errors()` read by hand | `createForm(model, schema)` + `registerFormMatchers()` — then `toHaveFieldErrors([…])` (`/signal-forms`) |
231
- | any of those doubles without a `TestBed` | `createRouterDouble` / `createWindowDouble` / `createDocumentDouble` / `createMatDialogRef` |
140
+ | Situation | Helper |
141
+ | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
142
+ | a `signal()` / `computed()` field on the class under test | `mockSignalProp(obj, prop, initial)` — writes through a writable one; an `input()` is refused |
143
+ | a resource field, when the HTTP round trip is not the point | `mockResourceProp(obj, prop, initial)` — `set` / `fail` / `loading` |
144
+ | the HTTP round trip _is_ the point | `expectRequest(url).flush(body)` — `/angular-http`, settling included |
145
+ | a component or service that reads `ActivatedRoute` | `provideActivatedRoute({ params })`, then `injectActivatedRoute().setParams(…)` — `/angular-router` |
146
+ | a guard or a class built with `new` that takes the route | `createActivatedRoute({ params })` — the same handle, no `TestBed` |
147
+ | a node:test suite whose heap grows all run | `trackNodeMocks()` — `/node`, a private MockTracker |
148
+ | a Nest provider whose constructor keeps changing | `createNestUnit(Target, { expose })` — built from its DI metadata |
149
+ | asserting a resource's value _and_ status together | `registerResourceMatchers()` → `toHaveResourceValue` / `toBeLoading` |
150
+ | a callback or config object the code under test built | `captureArg<T>()` in the assertion, then read `.value` |
151
+ | an object the test already holds — a real service, a client, `TestBed.inject(X)` | `createSpyFromInstance(obj, config?)` — patches it in place, `restoreSpiedInstance` undoes it |
152
+ | a `calledWith` that is not matching and you cannot see why | `explainSpy(spy, 'method')` from `/diagnostics` — configs, calls, which hit which |
153
+ | a method that has to throw — for all calls, or for some arguments | `spy.m.failWith(err)` / `spy.m.calledWith(x).failWith(err)` — **not** `throwWith` |
154
+ | a `TestBed` spec on Vitest 4.1+, to drop `let` + `beforeEach` | `extendWithAutoSpies(test, { cart: CartService })` (`/angular`) |
155
+ | an `effect()` whose trigger is now a static signal | `runEffect(effectRef)` |
156
+ | the component constructs its own `IntersectionObserver` | `stubIntersectionObserver()` (+ `Resize` / `Mutation`) |
157
+ | a green run exiting 1 with `AbortError` under happy-dom | `setupAutoSpy({ blockNetwork: true })` |
158
+ | a real request going out of a unit run (`fetch`, XHR, `sendBeacon`) | `setupAutoSpy({ blockNetwork: true })` — `{ xhr: 'empty' }` for pings |
159
+ | timers or frames from a previous file failing this one | `setupAutoSpy({ strayTimers: true })` — mutes `--detect-async-leaks`, see below |
160
+ | an assertion error in stderr, every test green and the run at 0 | `setupAutoSpy({ strayRejections: true })` — zone.js swallowed it |
161
+ | a run getting slower the longer it goes, on `isolate: false` | `setupAutoSpy({ pruneMockRegistry: true })` — the mock registry |
162
+ | `JavaScript heap out of memory` on a suite of wide generated clients | `createSpyFromClass(X, { lazySpies: 'proxy' })` — one trap object, not a placeholder per method |
163
+ | `Cannot read properties of undefined (reading 'now')` | `restoreTimerGlobals` — on by default |
164
+ | `localStorage.setItem is not a function`, or it is `undefined` | `restoreWebStorage` — on by default; Vitest's global filter, Node 25+ |
165
+ | a spy handed to an API typed against the real class | `asInstance()` / `asSpy()` |
166
+ | the code under test does `new X()` (a global, a vendor SDK) | `mockConstructor(factory)` / `stubConstructor(obj, key, factory)` |
167
+ | `X is not a constructor`, with a stack in production code | same — a `vi.fn(() => …)` cannot serve `new` |
168
+ | waiting for a dynamic `import()` under fake timers | `settleDynamicImport(() => import('…'))` / `flushEventLoop()` |
169
+ | `addEventListener(…, { signal })` throwing about `EventTarget` | `stubAbortController()` |
170
+ | `codemod` reporting a truncated repository scan | `VITEST_AUTO_SPY_SCAN_CAP=<n>` — raises the 50 000-file cap |
171
+ | a suite ported from Jest's `fakeTimers.enableGlobally` | `setupAutoSpy({ globalFakeTimers: true })` |
172
+ | `toHaveBeenCalledBefore` across an auto-spy and a hand-written `vi.fn()` | `setSpyEngine('runner')` / `getSpyEngine()` — `/setup`, Vitest only |
173
+ | a nested `describe`'s `beforeAll` landing on real timers | `setupFakeTimers(cfg, { betweenTests: true })` |
174
+ | setup hooks applying to the first spec file of a worker only | the setup module is cached — run coverage with `--isolate` |
175
+ | `fakeAsync` inside `test.concurrent` | `installProxyZonePatch({ scope: 'callback' })` |
176
+ | an assertion containing a date | `mockSystemTime(iso)` — never `vi.spyOn(globalThis, 'Date')` |
177
+ | a spec asserting on tick _order_ under a frozen clock | `useCountingClock()` |
178
+ | a dependency declared in the component's own `providers` | `overrideComponentProvider(Cmp, Token)` — it verifies on the first fixture that the override applied |
179
+ | a double answering `undefined` for a method nobody configured | `{ strict: true }`, or `setupAutoSpy({ strict: true })` suite-wide |
180
+ | a strict throw caught by a `try`/`catch` or an operator, the test still green | `setupAutoSpy({ swallowedStrictCalls: 'throw' })`; provoked on purpose — `takeStrictViolations()` from `/setup` |
181
+ | a strict double's getter answering `undefined`, or its `items$` never emitting | `setupAutoSpy({ unconfiguredReads: 'throw' })` reports it after the test; survey first with `onUnstubbedRead` |
182
+ | an `afterEach` that exists only to reset one spy | `using spy = createSpyFromClass(X)` — `[Symbol.dispose]` resets it |
183
+ | a dead NgModule import, dead `schemas`, an unflushed HTTP request | `enableAngularDiagnostics()` in the setup file, after `initTestEnvironment` |
184
+ | the spy is provided but the component resolves its own, so the test runs the real service | `enableAngularDiagnostics({ shadowedProviders })`, or `assertNoShadowedProviders(Component, fixture)` |
185
+ | "which collaborators did this actually inject?" | `trackInjections([A, TOKEN])` — providers plus the record, not `vi.mock` |
186
+ | `NG0303` / `NG0301` / `NG0304` from an imported NgModule | `assertNgModuleScopes(Module)` — an AOT bundle stripped its scope |
187
+ | `Cannot read properties of undefined (reading 'provide')` in `di_setup` | `assertComponentDefIntact(Cmp)` — a barrel chunk left a hole in `ɵcmp` |
188
+ | a focus assertion failing as `expected false to deeply equal true` | `registerFocusMatchers()` + `expect(el).toHaveFocus()` |
189
+ | a collaborator passed as an argument, then asserted on | `autoMocked<T>()` — typed `T & Spy<T>` |
190
+ | a `<video>` / `<audio>`: `play()` throws, `duration` is `NaN` | `stubMediaElement({ duration })`, then `media.set(el, …)` |
191
+ | a `vi.mock()` that silently did nothing under a bundler | `assertMocked(ns, { specifier, exports })` |
192
+ | `No "default" export is defined on the mock` | `vi.mock('x', () => moduleNamespace({ … }))` |
193
+ | waiting for a `resource()` / an SDK to become ready | `flushEventLoopUntil(() => …, { label })` — budgeted, not tuned |
194
+ | `expected [ { at: 1, …(5) }, …(8) ] to deeply equal …` | `expect(diffByField(actual, expected)).toBeUndefined()` |
195
+ | a stub that works only in the first test of the file | `installPerTest(() => stub…())` — or install it in `beforeEach` |
196
+ | a library failing every other run after a `defineProperty` on DOM | `setupAutoSpy({ guardGlobals: 'throw' })` names the file |
197
+ | a block of files failing to collect, with no stack and zero failing tests | `setupAutoSpy()` names the file that left a key on `Object.prototype` (`prototypePollution`, on by default) |
198
+ | the same check in a suite that does not call `setupAutoSpy()` | `guardPrototypePollution('throw')` from `/setup` |
199
+ | a spec failing only in a full run on an attribute of `<body>` / `<html>` another file left | `setupAutoSpy({ documentPollution: 'throw' })` names the test and puts the document back (in `preset: 'strict'`) |
200
+ | the same document check without `setupAutoSpy()` | `guardDocumentPollution('throw')` from `/setup` |
201
+ | `A metric with the name … has already been registered` / `customElements.define` twice, at import | a module scope evaluated once per spec file under the Angular builder — guard the registration (`getSingleMetric`, `customElements.get`) |
202
+ | console output a test never asserted on, or a warning from this library | `setupAutoSpy({ strayConsole: 'throw' })` fails that test; absorb with `installConsoleSpies()` in `beforeEach` |
203
+ | every guard at its strictest grade in one line | `setupAutoSpy({ preset: 'strict' })` — plus `enableAngularDiagnostics()` for Angular |
204
+ | an `onlyMethodsToSpyOn` typo or `injectSpy` on a real instance that only warned | `setupAutoSpy({ misconfiguration: 'throw' })` throws at the call |
205
+ | which file and call scheduled a timer that outlived its file | `onStrayTimers: ({ timers }) => expect(timers).toEqual([])`, or `describeStrayTimers()` from `/setup` |
206
+ | stray timers charged to a file that only writes `localStorage` (jsdom) | `withoutStrayTimerTracking(() => seed())` from `/setup` — its timers are neither counted nor cancelled |
207
+ | `import { consoleErrorSpy }` silencing other files under `isolate: false` | `installConsoleSpies()` in `beforeEach`, `restoreConsole()` in `afterEach` — the import installs once per worker |
208
+ | `Cannot set base providers because it has already been called` | `setupAngularTestEnv({ zoneless, initZone, initZoneless })` |
209
+ | a dependency behind an `InjectionToken`, with no class to spy | `provideAutoSpyForToken(TOKEN)` + `injectSpy(TOKEN)`; a chained call → `{ selfReturning: ['channel'] }` as the third argument |
210
+ | `Expected to be running in 'ProxyZone', but it was not found` | `import 'vitest-auto-spy/zone'` (needs `globals: true`) |
211
+ | `Property 'mockReturnValue' does not exist on type 'never'` | upgrade — the spy no longer collapses on an unreadable return type |
212
+ | `TS2345` inside `mockReturnValue` / `mockImplementation` / `mockResolvedValue` | the stub is checked against the method's return type now — fix the stub, not the spy |
213
+ | `TS2540: Cannot assign to 'x'` on a double whose runtime write works | `Spy<T>` keeps `readonly` on purpose — `mockValueProp(spy, 'x', v)`, which a spied **accessor** also needs |
214
+ | a signal-valued getter that `gettersToSpyOn` will not accept | it accepts any key now; for a signal prefer `mockSignalProp` |
215
+ | five `asInstance(…)` in one call, found one per `tsc` run | `...asInstances(a, b, c, d, e)` |
216
+ | `nextWith` demanding `HttpEvent<T>` on a generated client | `asSpy<Client, { overload: 'first' }>(…)` / `Overload<M, 0>` |
217
+ | `not assignable to parameter of type 'HttpEvent<…>'` in a stub of the real body | same thing — the helpers read the **last** overload; `{ overload: { m: 'first' } }`, not `@ts-expect-error` |
218
+ | a fixture that needs a nested object built by its own call | `createMock<T>({ a: { b: 1 } })` — deep partial, still exact |
219
+ | the same 100-line model literal copied into eight specs (`TS1117`) | one `createFixtureFactory<T>(defaults)`; specs call it with what they change |
220
+ | `'params' in link` ladders, or a cast, to pick a union branch | `narrow.byKey(link, 'params')` / `narrow.observable(x)` |
221
+ | `{ ...modelInstance, flag: true }` losing every getter | `withOverrides(modelInstance, { flag: true })` |
222
+ | `NG0303` / `NG0304` / silence from a directive spec | `createDirectiveHost({ template, scope: [Module] })` |
223
+ | a hand-written `class MockChartComponent` restating a child's selector | `createComponentStub(ChartComponent)` (`/angular`) — selector, inputs, outputs read from `ɵcmp` |
224
+ | a `TestingStorage` class for `localStorage` / `sessionStorage` | `stubWebStorage('localStorage', { items })` from `/dom-stubs` — `snapshot()` to assert |
225
+ | `'x' does not exist in type 'MethodReturns<{ y: any; }>'` on a generic class | spell out the type argument — `createSpyFromClass<Config>(Config, …)`; `provideAutoSpy` infers it |
226
+ | "did the migration lose a test?" with matching counters | `compareTestRuns(before, after)` |
227
+ | an input that has to change after the first render | `await setInputs(fixture, { … })` — one `setInput` per name, one wait |
228
+ | a component that navigates, or reads `router.url` | `provideRouterDouble({ url })` + `injectRouterDouble()` — `/angular-router` |
229
+ | a component that reads `router.currentNavigation()` for `extras.state` or `trigger` | `setCurrentNavigation({ extras: { state } })` on the same handle — no `instanceMethodsToSpyOn` |
230
+ | a `window` or `document` behind a DI token | `provideWindowDouble(WINDOW, { screen })` / `provideDocumentDouble({ … })` |
231
+ | `MAT_DIALOG_DATA` and `MatDialogRef` provided by hand | `provideMatDialogData(TOKEN, data)` / `provideMatDialogRef(MatDialogRef)` + `injectMatDialogRef(Ref)` |
232
+ | asserting a `computed()` did **not** recompute | `trackRecomputations(sig)` / `trackEffectRuns(ref)` — `{ count, stop() }` |
233
+ | `form()` in a spec, or `errors()` read by hand | `createForm(model, schema)` + `registerFormMatchers()` — then `toHaveFieldErrors([…])` (`/signal-forms`) |
234
+ | any of those doubles without a `TestBed` | `createRouterDouble` / `createWindowDouble` / `createDocumentDouble` / `createMatDialogRef` |
232
235
 
233
236
  ## Rules that prevent most of the mistakes
234
237