vitest-auto-spy 5.29.0 → 5.30.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
@@ -216,7 +216,10 @@ the test must not touch the real one.
216
216
  verbose for the most common thing it says — _observe one method of the object under test and let it
217
217
  run_ — so the helper says exactly that and hands back the single spy. It is also the drop-in for a
218
218
  bare `vi.spyOn(component, 'method')` where a preset's `no-restricted-properties` bans `vi.spyOn`:
219
- same default semantics, record and call through, and `restoreSpiedInstance` puts it back.
219
+ same default semantics, record and call through, and `restoreSpiedInstance` puts it back. It is a
220
+ core export — `import { spyOnOwnMethod } from 'vitest-auto-spy'`, or the runner entry on
221
+ `bun test` / `node --test` — while `vitest-auto-spy/angular` and `vitest-auto-spy/nestjs` have no
222
+ core re-export, so those suites import it from the root; `spyOnVoidMethod` below is the same.
220
223
 
221
224
  **Do not `createSpyFromInstance` a DOM node that must keep living a DOM life.** Discovery walks the
222
225
  node's prototype chain, and past the component's own class that chain is the engine's: happy-dom's
@@ -1508,15 +1511,22 @@ setupAutoSpy({ strayTimers: true }); // wrap the schedulers, sweep the survivors
1508
1511
 
1509
1512
  The pieces are exported too — `trackStrayTimers()` (idempotent, returns the undo),
1510
1513
  `cancelStrayTimers()` (returns how many it cancelled), `countStrayTimers()` and
1511
- `describeStrayTimers()` (each pending timer's kind, the spec file that scheduled it and up to five
1512
- frames), all from `vitest-auto-spy/setup`. Use `expect(countStrayTimers()).toBe(0)` in an `afterEach`
1513
- to make a leak fail rather than be tidied away, or take the per-file report from the sweep itself —
1514
- `timers` carries the same origins, so the failure diff names the file and the scheduling call:
1514
+ `describeStrayTimers()` (each pending timer's `kind`, the `delay` of a timeout or interval, the spec
1515
+ `file` that scheduled it and up to five `frames`), all from `vitest-auto-spy/setup`. Use
1516
+ `expect(countStrayTimers()).toBe(0)` in an `afterEach` to make a leak fail rather than be tidied
1517
+ away, or take the per-file report from the sweep itself — `timers` carries the same origins, so the
1518
+ failure diff names the file and the scheduling call:
1515
1519
 
1516
1520
  ```ts
1517
1521
  setupAutoSpy({ strayTimers: true, onStrayTimers: ({ timers }) => expect(timers).toEqual([]) });
1518
1522
  ```
1519
1523
 
1524
+ `frames` starts below the tracking wrapper and is up to forty frames deep, so a zone or an rxjs
1525
+ scheduler in between does not hide the caller: project frames come first; when the call came from
1526
+ dependencies only, their frames stand in. The package's own frames are never quoted, from source or
1527
+ from `dist`. A handler that prints its own message should print `kind`, `delay` and `frames[0]` —
1528
+ they are what makes a report that only reproduces under a slow coverage run actionable the first time.
1529
+
1520
1530
  A stray whose `file` is not the file that failed was scheduled after the previous file's sweep —
1521
1531
  the previous file is the one to fix.
1522
1532
 
@@ -3883,6 +3893,7 @@ blanket downgrade so those keep their severity; do not copy the two names into a
3883
3893
  | `no-unregistered-inject-spy` | `error` | — | `injectSpy(X)` for a token this file never registered → the real instance, whose spy helpers exist only for the compiler |
3884
3894
  | `prefer-render-shallow` | `warn` | suggest | `TestBed.createComponent` in a file that never reads the template → `renderShallow(X)`; 0.24× the per-test cycle at 100 children |
3885
3895
  | `prefer-set-inputs` | `warn` | suggest | a run of `fixture.componentRef.setInput('title', v)` on one fixture → `await setInputs(fixture, { title: v })` — the name is resolved against the compiled definition before the first write (an undeclared one is an `NG0303` and no change) and the value is typed. The run collapses into one call and a `detectChanges()` under it goes; offered, not applied, because `stable()` ticks and a zone.js suite answers that with `NG0101` |
3896
+ | `prefer-spy-on-own-method` | `warn` | `--fix` / suggest | `createSpyFromInstance(x, { onlyMethodsToSpyOn: ['m'], passthrough: true })` read for `m` alone — `.m` on the call, `const { m } = …`, a bare statement, or a name read only as `v.m` → `spyOnOwnMethod(x, 'm')`; the same whitelist with `returns: { m: undefined }` → `spyOnVoidMethod(x, 'm')`. Both fix, import and all; a `Spy<X>` annotation becomes `Spy<X>['m']` in a suggestion. A bare `returns: { m: undefined }` seed is a suggestion, offered only on a real event or element — `new MouseEvent(…)`, `document.createElement(…)`, `fixture.nativeElement` — never on a double |
3886
3897
  | `prefer-observer-stub` | `error` | — | a hand-rolled observer global → `stubIntersectionObserver()` / `stubResizeObserver()` / `stubMutationObserver()`; the manual save-and-restore goes too, `restoreMockedProps()` runs the undo |
3887
3898
  | `no-hand-assigned-global` | `error` | — | a double assigned to a global (`global.fetch = vi.fn()`, `window.matchMedia = vi.fn()`, `window.localStorage = { getItem: vi.fn() }`) with no restore in `afterEach` / `afterAll` / `onTestFinished` → `mockValueProp(globalThis, name, value)` or `vi.stubGlobal` + `unstubGlobals`; `blockNetwork()` for network globals, `stubWebStorage()` for the storages; the three observers stay with `prefer-observer-stub` |
3888
3899
  | `prefer-stub-response` | `error` | — | an object literal cast to `Response` (`as Response`, `as unknown as Response`, `<Response>{ … }`) or `createMock<Response>(…)` / `createAutoMock<Response>(…)` → `stubResponse({ body })` from `/setup`; the literal answers `undefined` for every member it does not list (`status`, `headers`, `text()`) and the cast is what makes that compile. `Response` must resolve to the **global**, so an Express handler's or a generated client's `Response` is never reported |
@@ -3910,14 +3921,14 @@ blanket downgrade so those keep their severity; do not copy the two names into a
3910
3921
  | `no-save-arguments-by-value` | `error` | — | `spy.calls.saveArgumentsByValue()` — a no-op here, so the spec silently asserts on post-mutation state |
3911
3922
  | `prefer-native-spy-api` | `error` | `--fix` / suggest | `.and` / `.calls` where the spy's own API says the same thing — turn it on for the last mile off the jasmine shim |
3912
3923
 
3913
- Forty-eight rules, **every one an `error` since 4.0.0 except `prefer-render-shallow`,
3924
+ Forty-nine rules, **every one an `error` since 4.0.0 except `prefer-render-shallow`,
3914
3925
  `no-stub-class-double`, `no-structural-double`, `prefer-create-mock`, `no-instance-lifecycle-spy`,
3915
- `prefer-set-inputs` and `no-unasserted-argument`**; five fix on their own, eighteen offer suggestions. Forty-five are syntactic; `no-private-member-access`, `no-mistyped-use-value` and
3926
+ `prefer-set-inputs`, `no-unasserted-argument` and `prefer-spy-on-own-method`**; six fix on their own, nineteen offer suggestions. Forty-six are syntactic; `no-private-member-access`, `no-mistyped-use-value` and
3916
3927
  `no-unknown-use-value-key` read types, and all three report nothing at all without `parserOptions.project` / `projectService`
3917
3928
  rather than guessing. `no-compile-components` waits the same way for a fact no file holds — which
3918
3929
  builder the project has — and reports nothing until `{ builder: 'inline-resources' }` states it. The config used to be a graded mix of `error` / `warn` / `off`, which decided for the
3919
3930
  consumer how much each finding mattered — a `warn` nothing reads is `off` with extra output. The
3920
- seven `warn`s left are not judgements about how much a finding matters. `prefer-render-shallow` is
3931
+ eight `warn`s left are not judgements about how much a finding matters. `prefer-render-shallow` is
3921
3932
  about the _kind_ of finding: every other rule names something wrong or dead, while this one names a
3922
3933
  file that could render more cheaply, and `renderShallow` is a migration a suite either takes or does
3923
3934
  not. At `error` the plugin would gate that migration — 491 findings across 398 of one consumer's
@@ -3940,7 +3951,8 @@ repair _needs_ rather than on its evidence: both of its readings are facts out o
3940
3951
  subject some other test in the same file pins with `toHaveBeenCalledWith`, a title saying `with` over
3941
3952
  a body whose every assertion is a bare call — but the repair is the argument list the test should
3942
3953
  have named, which is the one thing the rule cannot supply. Every `error` here names an edit or names
3943
- a helper; this one names a question for the author. Set any of the seven `warn`s to `'error'` once the batch is done. Three of
3954
+ a helper; this one names a question for the author. `prefer-spy-on-own-method` names a shorter spelling of a correct call — the
3955
+ `createSpyFromInstance` it reports does exactly what the helper does — and its exact shapes carry a fix. Set any of the eight `warn`s to `'error'` once the batch is done. Three of
3944
3956
  them can report on a _correct_ project, and only one has an option:
3945
3957
  `jasmine-namespace-without-entry` takes `['error', { setupModules: ['./test-setup'] }]`, naming the
3946
3958
  file where `enableJasmineCompat()` is called; `prefer-native-spy-api` goes `'off'` for as long as a
package/CHANGELOG.md CHANGED
@@ -10,6 +10,51 @@ The latest released version here must always match the one published on
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [5.30.0] - 2026-09-25
14
+
15
+ ### Added
16
+
17
+ - **`prefer-spy-on-own-method`, in `recommended` at `warn`.** It finds the long spelling of the two
18
+ 5.29 helpers, which a text search misses once the call spans several lines:
19
+ `createSpyFromInstance(x, { onlyMethodsToSpyOn: ['m'], passthrough: true })` read for `m` alone —
20
+ `.m` on the call, `const { m } = …`, a bare statement, or a name read only as `v.m` — is
21
+ `spyOnOwnMethod(x, 'm')`, and the same whitelist with `returns: { m: undefined }` is
22
+ `spyOnVoidMethod(x, 'm')`. Both are exact, so `--fix` rewrites the call and every `v.m` read, imports
23
+ the helper beside the factory and drops the factory's import once nothing else uses it. A
24
+ `Spy<X>` annotation becomes `Spy<X>['m']` in a suggestion. A bare `returns: { m: undefined }` seed,
25
+ whose discovery also spies every other method, is offered as a suggestion only on a real event or
26
+ element (`new MouseEvent(…)`, `document.createElement(…)`, `fixture.nativeElement`, one name away
27
+ included), never on a double. `warn`, because the call it reports is correct. `/eslint-plugin`
28
+ grows 51.61 → 53.56 kB min+gzip (+1.96 kB, +3.8 %), all of it this rule: the fixer that follows
29
+ every `v.m` read, rewrites imports and the `Spy<X>` annotation. The plugin runs in the linter, never
30
+ in a test, and no runtime entry imports it.
31
+
32
+ ### Fixed
33
+
34
+ - **A stray timer's origin names the code that scheduled it, not the tracking wrapper.** Built from
35
+ `dist`, the report quoted `at captureOrigin (…/node_modules/vitest-auto-spy/dist/setup.js)`: the
36
+ filter for the wrapper's own frames matched the source file name, which the bundle does not have,
37
+ and twelve frames ran out inside a zone or an rxjs scheduler before the first line outside
38
+ dependencies, so the fallback quoted the wrapper. The stack is now cut at the installed
39
+ `setTimeout` / `setInterval` / `requestAnimationFrame` wrapper and taken forty frames deep below it;
40
+ the package's own frames are never quoted, and when the call came from dependencies only, their
41
+ frames stand in. The same `dist` filter now applies to stray listeners, console calls and
42
+ rejections. A flaky stray that shows up only under a slow coverage run is now actionable from the
43
+ first report.
44
+ - **`StrayTimer` carries the `delay`, and the `--detect-async-leaks` warning prints it.** Each
45
+ timeout and interval in `describeStrayTimers()` and in `onStrayTimers`' `timers` now has the delay
46
+ it was scheduled with (a frame has none), and the warning reads `timeout (300 ms) from …` instead
47
+ of `timeout from …`. An additive field; a handler that prints its own message should print `kind`,
48
+ `delay` and `frames[0]`.
49
+
50
+ ### Docs
51
+
52
+ - **`AGENTS.md` says which entry `spyOnOwnMethod` and `spyOnVoidMethod` come from.** The §2
53
+ introduction sat in Angular-flavoured company, and `vitest-auto-spy/angular` has no core
54
+ re-export — an Angular-suite reader importing the helper there hit a missing export. The
55
+ paragraph now names the core entry (root, or the runner entry off Vitest), as the docs-site
56
+ page already does.
57
+
13
58
  ## [5.29.0] - 2026-09-24
14
59
 
15
60
  ### Added
@@ -7845,7 +7890,8 @@ by hand there, in more than one place, by more than one person.
7845
7890
  `mockAccessorsProp`.
7846
7891
  - Dual ESM + CJS build with type declarations; 100% test coverage.
7847
7892
 
7848
- [Unreleased]: https://github.com/ASDAlexey/vitest-auto-spy/compare/v5.29.0...HEAD
7893
+ [Unreleased]: https://github.com/ASDAlexey/vitest-auto-spy/compare/v5.30.0...HEAD
7894
+ [5.30.0]: https://github.com/ASDAlexey/vitest-auto-spy/compare/v5.29.0...v5.30.0
7849
7895
  [5.29.0]: https://github.com/ASDAlexey/vitest-auto-spy/compare/v5.28.0...v5.29.0
7850
7896
  [5.28.0]: https://github.com/ASDAlexey/vitest-auto-spy/compare/v5.27.0...v5.28.0
7851
7897
  [5.27.0]: https://github.com/ASDAlexey/vitest-auto-spy/compare/v5.26.0...v5.27.0
package/README.md CHANGED
@@ -81,7 +81,7 @@ faster at suite scale ([benchmarks](#benchmarks)) — and for
81
81
  - 🧩 Module mocks that prove they applied — `assertMocked`, `moduleNamespace`, for a `vi.mock()` a bundler quietly ignored; `moduleNamespace(await importOriginal(), { passthrough: true })` is Vitest's `{ spy: true }` with `calledWith` on top, on any runner
82
82
  - 🧾 Fixtures without casts — deep-partial `createMock`, `createFixture` / `createFixtureFactory`, `narrow()`, `withOverrides()`, `asInstances()`, `captureArg()`, and `outOfType()` for the fixture that is outside its type on purpose
83
83
  - 🚚 A migration you can verify — `vitest-auto-spy/diagnostics`: `compareTestRuns` on the two JSON reports, `summarizeTestRun` / `formatTestRunComparison` to read the answer, `diffByField` for the assertion the reporter collapses, `explainSpy` for a double that answered something you did not configure
84
- - 📏 Lint rules and one-line test-run hygiene — forty-eight rules in `vitest-auto-spy/eslint-plugin` (five `--fix`, eighteen suggestions, four of them for a suite mid-migration off jasmine), `setupAutoSpy()` — with `preset: 'strict'` for every guard at its strictest grade, and under `isolate: false` `strayListeners` / `restoreGlobals` for the listeners and raw global assignments a file leaves the next one, `restoreStorageSpies` (on by default) for the storage spy happy-dom will not let `mockRestore()` remove
84
+ - 📏 Lint rules and one-line test-run hygiene — forty-nine rules in `vitest-auto-spy/eslint-plugin` (six `--fix`, nineteen suggestions, four of them for a suite mid-migration off jasmine), `setupAutoSpy()` — with `preset: 'strict'` for every guard at its strictest grade, and under `isolate: false` `strayListeners` / `restoreGlobals` for the listeners and raw global assignments a file leaves the next one, `restoreStorageSpies` (on by default) for the storage spy happy-dom will not let `mockRestore()` remove
85
85
  - 🩺 [Editor diagnostics](#editor-diagnostics--webstorm--vs-code) — the same anti-patterns underlined while you type: native ESLint inspections in **WebStorm** and the other JetBrains IDEs, the ESLint extension in **VS Code**, no extra plugin either way
86
86
  - 🔎 [`npx vitest-auto-spy doctor`](#the-cli--doctor-perf-codemod-and-init) — suite-level defects **that never fail a run**: a `tsconfig` `include` matching no file, a production module importing a spec, a `@jest-environment` pragma the runner never reads, config left behind for a runner that is gone. Read-only, no config, exits 1 in CI
87
87
  - ⏱️ [`npx vitest-auto-spy perf`](#perf--where-the-cpu-time-actually-goes) — where a suite's CPU time actually goes, phase by phase, and which spec files to act on: the ones that reach no DOM and could run under `node`, the ones that import a barrel. Runs Vitest once with a reporter this package ships, reads `TestModule.diagnostic()`, names files, states the rule behind each finding
@@ -3206,7 +3206,7 @@ single-purpose utility you can pick up independently — they all ride on the sa
3206
3206
  | `guardGlobalPatches(reaction)` | `/setup` | Name the test that redefined a property of `document` / `navigator` / `globalThis` as non-configurable |
3207
3207
  | `guardStrayConsole(reaction)` | `/setup` | Fail a test that wrote to the console without absorbing it, and a file that wrote outside any test ([details](#test-run-hygiene)) |
3208
3208
  | `withoutStrayTimerTracking(work)` | `/setup` | Run setup work whose timers the stray-timer tracker neither counts nor cancels — jsdom schedules one per Web Storage write |
3209
- | `describeStrayTimers()` | `/setup` | Every timer still pending, with its kind, the spec file that scheduled it and the scheduling frames — the list `onStrayTimers` gets. Blind to anything scheduled under fake timers — `vi.useFakeTimers()` assigns over the wrapper |
3209
+ | `describeStrayTimers()` | `/setup` | Every timer still pending, with its kind and delay, the spec file that scheduled it and the scheduling frames — the list `onStrayTimers` gets. Blind to anything scheduled under fake timers — `vi.useFakeTimers()` assigns over the wrapper |
3210
3210
  | `guardPrototypePollution(reaction)` | `/setup` | Name the test that left a key on `Object.prototype` — it stops later files collecting ([details](#test-run-hygiene)) |
3211
3211
  | `guardDocumentPollution(option)` | `/setup` | Name the test that left an attribute on `<html>` / `<body>`, and put it back ([details](#test-run-hygiene)) |
3212
3212
  | `installPerTest(install)` | `/setup` | Re-install a stub before every test of the block — a `describe`-level stub is restored away after the first |
@@ -3790,7 +3790,7 @@ export default [
3790
3790
  ];
3791
3791
  ```
3792
3792
 
3793
- One of the four exceptions is `prefer-render-shallow`, which ships as a **`warn`**, and its reason is the kind
3793
+ One of the exceptions is `prefer-render-shallow`, which ships as a **`warn`**, and its reason is the kind
3794
3794
  of thing it says rather than how much it matters. Every other rule here names something wrong or dead
3795
3795
  — a double that drifts from its class, an assertion that never runs, a provider the container already
3796
3796
  dropped, a schema guarding nothing. That one names a file that could render more cheaply, which is a
@@ -3800,6 +3800,10 @@ it was measured against the rule reports 491 times across 398 files — a first
3800
3800
  have to answer with a project-wide `warn` of its own. A project that has decided to make the move sets
3801
3801
  it to `'error'` in the same one line that turns the others down.
3802
3802
 
3803
+ `prefer-spy-on-own-method` is graded on the same reading. The `createSpyFromInstance` call it reports is
3804
+ correct and does exactly what `spyOnOwnMethod` / `spyOnVoidMethod` does: the rule names a shorter
3805
+ spelling, not a defect, and its exact shapes carry a fix, so `'error'` is one `eslint --fix` away.
3806
+
3803
3807
  The other two, `no-stub-class-double` and `no-structural-double` (5.5.0), are graded on the
3804
3808
  _evidence_ rather than on the kind of finding. Both report the same drift
3805
3809
  `prefer-create-spy-from-class` reports at `error` — a double whose shape was written by hand and is
@@ -3862,6 +3866,7 @@ export can never be.
3862
3866
  | `no-unregistered-inject-spy` | `error` | — | `injectSpy(X)` for a token this file never registered → the real instance, whose spy helpers exist only for the compiler |
3863
3867
  | `prefer-render-shallow` | `warn` | suggest | `TestBed.createComponent` in a file that never reads the template → `renderShallow(X)`; 0.24× the per-test cycle at 100 children |
3864
3868
  | `prefer-set-inputs` | `warn` | suggest | a run of `fixture.componentRef.setInput('title', v)` on one fixture → `await setInputs(fixture, { title: v })`, which resolves every name against the compiled definition before it writes one — an undeclared name is an `NG0303` on the console and no change at all — and types the value. The suggestion collapses the run, drops a `detectChanges()` under it and makes the callback `async`; it is offered rather than applied, because `stable()` ticks and a zone.js suite can answer that with `NG0101` |
3869
+ | `prefer-spy-on-own-method` | `warn` | `--fix` / suggest | `createSpyFromInstance(x, { onlyMethodsToSpyOn: ['m'], passthrough: true })` read for `m` alone — `.m` on the call, `const { m } = …`, a bare statement, or a name read only as `v.m` → `spyOnOwnMethod(x, 'm')`; the same whitelist with `returns: { m: undefined }` → `spyOnVoidMethod(x, 'm')`. Both fix, import and all; a `Spy<X>` annotation becomes `Spy<X>['m']` in a suggestion. A bare `returns: { m: undefined }` seed is a suggestion, offered only on a real event or element — `new MouseEvent(…)`, `document.createElement(…)`, `fixture.nativeElement` — never on a double |
3865
3870
  | `prefer-observer-stub` | `error` | — | a hand-rolled `IntersectionObserver` / `ResizeObserver` / `MutationObserver` written into a global → `stubIntersectionObserver()` and friends, whose undo `restoreMockedProps()` already runs |
3866
3871
  | `no-hand-assigned-global` | `error` | — | `global.fetch = vi.fn(…)` and any other double assigned to a global with no restore in a teardown hook → `mockValueProp(globalThis, …)` / `vi.stubGlobal` + `unstubGlobals`, `blockNetwork()` for a spec that only stays offline, `stubWebStorage()` for the storages |
3867
3872
  | `prefer-stub-response` | `error` | — | an object literal cast to `Response` (`as Response`, `as unknown as Response`, `<Response>{ … }`) or a `createMock<Response>(…)` / `createAutoMock<Response>(…)` call → `stubResponse({ body })` from `/setup`, which builds the platform's own and answers every member; `Response` has to resolve to the global, so an Express handler's or a generated client's envelope of the same name is left alone |
@@ -3893,7 +3898,7 @@ Every message ends with a link to the matching [recipe](#how-to-mock): a rule th
3893
3898
  "don't" moves the problem rather than solving it. Rules travel with the API they recommend, so they
3894
3899
  are versioned together and stop being re-written in every project that installs the package.
3895
3900
 
3896
- **Five of the forty-eight fix on their own, eighteen offer suggestions**, and the split is not about how hard
3901
+ **Six of the forty-nine fix on their own, nineteen offer suggestions**, and the split is not about how hard
3897
3902
  the rewrite is. `no-mocked-for-spy` touches a _declaration_: get it wrong and the file stops
3898
3903
  compiling, which is the loudest, cheapest failure there is — so `--fix` rewrites the type, adds
3899
3904
  `import type { Spy } from 'vitest-auto-spy'` and drops the `Mocked` import once nothing else uses
@@ -4064,7 +4069,7 @@ package's own — it needs its ESLint integration switched on.
4064
4069
  ### WebStorm and the other JetBrains IDEs
4065
4070
 
4066
4071
  No plugin to install: WebStorm, IntelliJ IDEA Ultimate, PhpStorm, PyCharm Professional and RubyMine
4067
- all run ESLint natively, so the forty-eight rules appear inline, in the **Problems** tool window, and
4072
+ all run ESLint natively, so the forty-nine rules appear inline, in the **Problems** tool window, and
4068
4073
  under **Code → Inspect Code** for the whole project.
4069
4074
 
4070
4075
  ```js
@@ -4081,7 +4086,7 @@ has supported flat config since 2023.3); scope the block to spec files yourself;
4081
4086
  the fixes and suggestions live.
4082
4087
 
4083
4088
  A native JetBrains plugin is **not** planned — it would duplicate an integration the IDE already has
4084
- and then keep a second copy of forty-eight rules, in Kotlin, in step with the TypeScript ones.
4089
+ and then keep a second copy of forty-nine rules, in Kotlin, in step with the TypeScript ones.
4085
4090
 
4086
4091
  ### VS Code, Cursor, Windsurf, VSCodium
4087
4092
 
package/dist/cli.js CHANGED
@@ -3088,7 +3088,7 @@ function checkForeignPragma(graph) {
3088
3088
  }
3089
3089
 
3090
3090
  // src/cli/checks/export-map.generated.ts
3091
- var EXPORT_MAP_VERSION = "5.28.0";
3091
+ var EXPORT_MAP_VERSION = "5.29.0";
3092
3092
  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/diagnostics vitest-auto-spy/angular/doubles vitest-auto-spy/angular/matchers 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 vitest-auto-spy/perf-reporter";
3093
3093
  var EXPORTED_BY = {
3094
3094
  AccessorImplementations: "0 1 2 3 4 12 20 21 22",
@@ -478,8 +478,8 @@ var preferSettleDynamicImport = defineRule({
478
478
  }
479
479
  const messageId = consumption === "await" ? "awaitedDynamicImport" : "thenedDynamicImport";
480
480
  const suggestion = wrap(context, node);
481
- const report = { node, messageId };
482
- context.report(suggestion ? { ...report, suggest: [suggestion] } : report);
481
+ const report2 = { node, messageId };
482
+ context.report(suggestion ? { ...report2, suggest: [suggestion] } : report2);
483
483
  }
484
484
  })
485
485
  });
@@ -966,8 +966,8 @@ var noCompileComponents = defineRule({
966
966
  create: (context) => inlinesResources(context) && !namesIgnoredComponent(context) ? {
967
967
  'CallExpression[callee.computed=false][callee.property.name="compileComponents"]': (node) => {
968
968
  const suggestion = removal(context, node);
969
- const report = { node, messageId: "noCompileComponents" };
970
- context.report(suggestion ? { ...report, suggest: [suggestion] } : report);
969
+ const report2 = { node, messageId: "noCompileComponents" };
970
+ context.report(suggestion ? { ...report2, suggest: [suggestion] } : report2);
971
971
  }
972
972
  } : {}
973
973
  });
@@ -1792,8 +1792,8 @@ var preferCreateMock = defineRule({
1792
1792
  return;
1793
1793
  }
1794
1794
  const suggestion = buildFixture(context, node);
1795
- const report = { node, messageId: "castFixture", data: { type } };
1796
- context.report(suggestion ? { ...report, suggest: [suggestion] } : report);
1795
+ const report2 = { node, messageId: "castFixture", data: { type } };
1796
+ context.report(suggestion ? { ...report2, suggest: [suggestion] } : report2);
1797
1797
  }
1798
1798
  })
1799
1799
  });
@@ -1876,12 +1876,12 @@ var noMockCast = defineRule({
1876
1876
  const member = node.expression.property;
1877
1877
  const configured = isIdentifier(member) && CONFIGURATION_MEMBERS.has(member.name);
1878
1878
  const suggestion = readFromDi(context, node, node.expression);
1879
- const report = {
1879
+ const report2 = {
1880
1880
  node,
1881
1881
  messageId: configured ? "configurationCast" : "mockCast",
1882
1882
  data: { member: context.sourceCode.getText(member), type }
1883
1883
  };
1884
- context.report(suggestion ? { ...report, suggest: [suggestion] } : report);
1884
+ context.report(suggestion ? { ...report2, suggest: [suggestion] } : report2);
1885
1885
  }
1886
1886
  })
1887
1887
  });
@@ -1981,7 +1981,7 @@ var preferObserverStub = defineRule({
1981
1981
  preferObserverStubOverRunner: "`{{observer}}` is being faked through the runner. `{{helper}}()` from `vitest-auto-spy/dom-stubs` replaces the whole of that and returns a handle rather than a static field: `observers.last.emit([{{entry}}(\u2026)])` drives the callback, `observers.last.targets` is what was observed, `observers.last.disconnected` is the teardown assertion, and `observers.instances.length` counts constructions \u2014 which is what a spy on the constructor was being counted for. Two things the runner form does not give you. It is not a constructor: `vi.fn().mockImplementation((callback) => ({ observe() {} }))` is an arrow, `new {{observer}}(callback)` inside the component cannot call it, and the `TypeError` lands in application code with the spec still looking correct. And it is not undone: `{{helper}}()` registers its undo with `restoreMockedProps()`, which `setupAutoSpy()` runs after every test, so nothing has to remember `vi.unstubAllGlobals()` or an `afterEach`."
1982
1982
  },
1983
1983
  create: (context) => {
1984
- const report = (node, target, messageId) => context.report({ node, messageId, data: target });
1984
+ const report2 = (node, target, messageId) => context.report({ node, messageId, data: target });
1985
1985
  const spiedObserver = (node) => {
1986
1986
  const target = node.arguments[0];
1987
1987
  return target !== void 0 && isGlobalReceiver(target) ? observerNamed(node.arguments[1]) : void 0;
@@ -1990,7 +1990,7 @@ var preferObserverStub = defineRule({
1990
1990
  AssignmentExpression: (node) => {
1991
1991
  const target = observerOn(node.left);
1992
1992
  if (target !== void 0 && isDouble(context, node.right)) {
1993
- report(node, target, "preferObserverStub");
1993
+ report2(node, target, "preferObserverStub");
1994
1994
  }
1995
1995
  },
1996
1996
  CallExpression: (node) => {
@@ -1998,13 +1998,13 @@ var preferObserverStub = defineRule({
1998
1998
  const stubbed = observerNamed(node.arguments[0]);
1999
1999
  const value = node.arguments[1];
2000
2000
  if (stubbed !== void 0 && value !== void 0 && isDouble(context, value)) {
2001
- report(node, stubbed, "preferObserverStubOverRunner");
2001
+ report2(node, stubbed, "preferObserverStubOverRunner");
2002
2002
  }
2003
2003
  return;
2004
2004
  }
2005
2005
  const spied = isRunnerCall(node, SPY_ON3) ? spiedObserver(node) : void 0;
2006
2006
  if (spied !== void 0) {
2007
- report(node, spied, "preferObserverStubOverRunner");
2007
+ report2(node, spied, "preferObserverStubOverRunner");
2008
2008
  }
2009
2009
  }
2010
2010
  };
@@ -2387,7 +2387,7 @@ var preferNativeSpyApi = defineRule({
2387
2387
  preferNativeSpyApi: "`{{from}}` is the compatibility layer speaking; `{{to}}` is what this library calls the same thing, and the only spelling the rest of its documentation uses. The layer is a bridge \u2014 it exists so a `jasmine-auto-spies` suite runs before it is rewritten \u2014 so this is worth doing once the suite is green and not before: rewrite, then delete the `vitest-auto-spy/jasmine` import. `npx vitest-auto-spy codemod --from jasmine` does the whole suite in one pass. The edit is applied only where the receiver came out of one of this library\u2019s factories; anywhere else the same rewrite is offered as a suggestion, because a `.calls` on somebody else\u2019s object is somebody else\u2019s method."
2388
2388
  },
2389
2389
  create: (context) => {
2390
- const report = (rewrite2, spy) => {
2390
+ const report2 = (rewrite2, spy) => {
2391
2391
  if (!rewrite2) {
2392
2392
  return;
2393
2393
  }
@@ -2398,8 +2398,8 @@ var preferNativeSpyApi = defineRule({
2398
2398
  );
2399
2399
  };
2400
2400
  return {
2401
- 'MemberExpression[property.name="and"]': (node) => report(andRewrite(context, node), node.object),
2402
- 'MemberExpression[property.name="calls"]': (node) => report(callsRewrite(context, node), node.object)
2401
+ 'MemberExpression[property.name="and"]': (node) => report2(andRewrite(context, node), node.object),
2402
+ 'MemberExpression[property.name="calls"]': (node) => report2(callsRewrite(context, node), node.object)
2403
2403
  };
2404
2404
  }
2405
2405
  });
@@ -2416,15 +2416,15 @@ var noJasmineGlobals = defineRule({
2416
2416
  },
2417
2417
  create: (context) => ({
2418
2418
  'MemberExpression[object.name="jasmine"]': (node) => {
2419
- const report = jasmineMemberReport(context, node);
2420
- if (report) {
2421
- context.report({ node, ...report });
2419
+ const report2 = jasmineMemberReport(context, node);
2420
+ if (report2) {
2421
+ context.report({ node, ...report2 });
2422
2422
  }
2423
2423
  },
2424
2424
  [BARE_GLOBAL_SELECTOR]: (node) => {
2425
- const report = bareGlobalReport(context, node);
2426
- if (report) {
2427
- context.report({ node, ...report });
2425
+ const report2 = bareGlobalReport(context, node);
2426
+ if (report2) {
2427
+ context.report({ node, ...report2 });
2428
2428
  }
2429
2429
  },
2430
2430
  'MemberExpression[property.name="withContext"]': (node) => {
@@ -2736,12 +2736,12 @@ function describeReport(context, finding, dead) {
2736
2736
  const { fn, hook, node, placement, reset } = finding;
2737
2737
  const target = placement.statement ?? hookStatement(fn);
2738
2738
  const data = { flag: FLAG[reset.kind], hook };
2739
- const report = { data, messageId: dead ? "deadReset" : "repeatedReset", node };
2739
+ const report2 = { data, messageId: dead ? "deadReset" : "repeatedReset", node };
2740
2740
  if (target === void 0) {
2741
- return report;
2741
+ return report2;
2742
2742
  }
2743
2743
  const desc = placement.statement ? "Delete this reset" : `Delete this reset, and the ${hook} it is the whole of`;
2744
- return dead ? { ...report, fix: removal2(context, target) } : { ...report, suggest: [{ desc, fix: removal2(context, target) }] };
2744
+ return dead ? { ...report2, fix: removal2(context, target) } : { ...report2, suggest: [{ desc, fix: removal2(context, target) }] };
2745
2745
  }
2746
2746
  var noRedundantMockReset = defineRule({
2747
2747
  anchor: "-a-double-more-than-one-spec-uses",
@@ -3041,12 +3041,12 @@ var noOverriddenProvider = defineRule({
3041
3041
  return {
3042
3042
  ArrayExpression: (node) => {
3043
3043
  overriddenProviders(context, node).forEach(({ element, token, kind, survivor }) => {
3044
- const report = {
3044
+ const report2 = {
3045
3045
  node: element,
3046
3046
  messageId: OVERRIDE_MESSAGES[kind],
3047
3047
  data: { token, line: String(survivor.loc.start.line) }
3048
3048
  };
3049
- context.report(kind === "duplicate" ? { ...report, suggest: [deleteProviderSuggestion(context, element, token)] } : report);
3049
+ context.report(kind === "duplicate" ? { ...report2, suggest: [deleteProviderSuggestion(context, element, token)] } : report2);
3050
3050
  });
3051
3051
  if (isTestingModuleProviders(node)) {
3052
3052
  registrations.push(...survivingRegistrations(context, node));
@@ -3745,8 +3745,8 @@ var noReflectMemberAccess = defineRule({
3745
3745
  if (writes && isDouble2(context, target)) {
3746
3746
  const data = { key, target: context.sourceCode.getText(target) };
3747
3747
  const suggestion = restoreSuggestion(context, node);
3748
- const report = { node, messageId: "reflectSetOnDouble", data };
3749
- context.report(suggestion ? { ...report, suggest: [suggestion] } : report);
3748
+ const report2 = { node, messageId: "reflectSetOnDouble", data };
3749
+ context.report(suggestion ? { ...report2, suggest: [suggestion] } : report2);
3750
3750
  return;
3751
3751
  }
3752
3752
  const messageId = writes ? isFixtureLiteral(context, target) ? "reflectSetOnFixture" : "reflectSet" : "reflectGet";
@@ -4070,8 +4070,8 @@ var preferSetInputs = defineRule({
4070
4070
  }
4071
4071
  const callback = enclosingFunction(node);
4072
4072
  const rewritable = callback && isHookCallback(callback) && bindingState(context.sourceCode.getScope(node), HELPER4) !== "taken" ? callback : void 0;
4073
- const report = { node, messageId: "preferSetInputs" };
4074
- context.report(rewritable ? { ...report, suggest: [{ desc: EDIT, fix: rewrite(context, run, rewritable) }] } : report);
4073
+ const report2 = { node, messageId: "preferSetInputs" };
4074
+ context.report(rewritable ? { ...report2, suggest: [{ desc: EDIT, fix: rewrite(context, run, rewritable) }] } : report2);
4075
4075
  }
4076
4076
  };
4077
4077
  }
@@ -4275,9 +4275,9 @@ function reportBlock(context, block, names) {
4275
4275
  subject: context.sourceCode.getText(subject)
4276
4276
  };
4277
4277
  const statement = node.parent;
4278
- const report = { data, messageId: "noRedundantSmokeTest", node };
4278
+ const report2 = { data, messageId: "noRedundantSmokeTest", node };
4279
4279
  context.report(
4280
- isExpressionStatement(statement) ? { ...report, suggest: [{ desc: "Remove this test", fix: removal3(context, statement) }] } : report
4280
+ isExpressionStatement(statement) ? { ...report2, suggest: [{ desc: "Remove this test", fix: removal3(context, statement) }] } : report2
4281
4281
  );
4282
4282
  });
4283
4283
  }
@@ -4330,6 +4330,227 @@ var noRedundantSmokeTest = defineRule({
4330
4330
  }
4331
4331
  });
4332
4332
 
4333
+ // src/lib/eslint/spy-on-own-method.ts
4334
+ var FACTORY = "createSpyFromInstance";
4335
+ var OWN = "spyOnOwnMethod";
4336
+ var VOID = "spyOnVoidMethod";
4337
+ var CORE_ENTRIES = new Set(
4338
+ ["", "/bun", "/bun-angular", "/node", "/rstest", "/react", "/vue", "/svelte"].map((entry) => `${PACKAGE}${entry}`)
4339
+ );
4340
+ var METHOD_NAME = /^[$A-Z_a-z][\w$]*$/;
4341
+ var EVENT_CONSTRUCTOR = /^[A-Za-z]*Event$/;
4342
+ var DOM_GLOBALS = /* @__PURE__ */ new Set(["document", "window"]);
4343
+ var NODE_FACTORIES = /* @__PURE__ */ new Set(["createElement", "createElementNS", "createEvent", "getElementById", "querySelector"]);
4344
+ var NODE_MEMBERS = /* @__PURE__ */ new Set(["body", "documentElement"]);
4345
+ var FIXTURE_NAME2 = /fixture$/i;
4346
+ var DECLARATION_KINDS = /* @__PURE__ */ new Set(["const", "let", "var"]);
4347
+ function isObjectPattern(node) {
4348
+ return node.type === "ObjectPattern";
4349
+ }
4350
+ function isUnwrapped(node) {
4351
+ return isCast(node) || node.type === "TSNonNullExpression";
4352
+ }
4353
+ function unwrap(node) {
4354
+ return isUnwrapped(node) ? node.expression : node;
4355
+ }
4356
+ function methodString(node) {
4357
+ const value = node.type === "Literal" ? Reflect.get(node, "value") : void 0;
4358
+ return typeof value === "string" && METHOD_NAME.test(value) ? value : void 0;
4359
+ }
4360
+ function listedMethod(value) {
4361
+ const list = unwrap(value);
4362
+ const elements = isArrayExpression(list) ? list.elements : [];
4363
+ const names = elements.filter((element) => element !== null).map(methodString);
4364
+ return elements.length === 1 ? names[0] : void 0;
4365
+ }
4366
+ function isUndefined(node) {
4367
+ return isIdentifier(node) && node.name === "undefined";
4368
+ }
4369
+ function seededVoid(value) {
4370
+ const entries2 = isObjectExpression(value) ? value.properties : [];
4371
+ const seeds = entries2.filter((entry) => propertyName(entry) !== void 0 && isUndefined(propertyValue(entry)));
4372
+ const names = seeds.map((entry) => String(propertyName(entry))).filter((name) => METHOD_NAME.test(name));
4373
+ return entries2.length === 1 ? names[0] : void 0;
4374
+ }
4375
+ function seedOf(options) {
4376
+ if (!isObjectExpression(options)) {
4377
+ return void 0;
4378
+ }
4379
+ const keys = options.properties.map(propertyName);
4380
+ const listed = findProperty(options, "onlyMethodsToSpyOn");
4381
+ const method = listed ? listedMethod(listed.value) : void 0;
4382
+ const returns = findProperty(options, "returns");
4383
+ const seeded = returns ? seededVoid(returns.value) : void 0;
4384
+ const passthrough = findProperty(options, "passthrough")?.value;
4385
+ if (keys.length === 1 && seeded !== void 0) {
4386
+ return { helper: VOID, method: seeded, exact: false };
4387
+ }
4388
+ if (keys.length !== 2 || method === void 0) {
4389
+ return void 0;
4390
+ }
4391
+ if (passthrough?.type === "Literal" && Reflect.get(passthrough, "value") === true) {
4392
+ return { helper: OWN, method, exact: true };
4393
+ }
4394
+ return seeded === method ? { helper: VOID, method, exact: true } : void 0;
4395
+ }
4396
+ function readsMethod(member, method) {
4397
+ const named = member.computed ? methodString(member.property) : memberName(member);
4398
+ const { parent } = member;
4399
+ return named === method && !(isAssignmentExpression(parent) && parent.left === member);
4400
+ }
4401
+ function isRealObject(scope, node, follow) {
4402
+ const value = unwrap(node);
4403
+ if (isIdentifier(value)) {
4404
+ const bound = follow ? boundValueOf(scope, value) : void 0;
4405
+ return bound ? isRealObject(scope, bound, false) : DOM_GLOBALS.has(value.name) && bindingState(scope, value.name) === "free";
4406
+ }
4407
+ if (isNewExpression(value)) {
4408
+ return isIdentifier(value.callee) && EVENT_CONSTRUCTOR.test(value.callee.name) && bindingState(scope, value.callee.name) === "free";
4409
+ }
4410
+ if (isCallExpression(value)) {
4411
+ return isMemberExpression(value.callee) && NODE_FACTORIES.has(String(memberName(value.callee))) && isRealObject(scope, value.callee.object, follow);
4412
+ }
4413
+ const name = memberName(value);
4414
+ if (isMemberExpression(value) && name === "nativeElement") {
4415
+ return isRenderedBy(value.object);
4416
+ }
4417
+ return isMemberExpression(value) && NODE_MEMBERS.has(String(name)) && isRealObject(scope, value.object, follow);
4418
+ }
4419
+ function isRenderedBy(node) {
4420
+ if (isIdentifier(node)) {
4421
+ return FIXTURE_NAME2.test(node.name);
4422
+ }
4423
+ return memberName(node) === "debugElement" || isCallExpression(node) && memberName(node.callee) === "query";
4424
+ }
4425
+ function annotationEdit(context, id, method) {
4426
+ const annotation = id.typeAnnotation?.typeAnnotation;
4427
+ if (!annotation) {
4428
+ return { edits: [], typed: false, blocked: false };
4429
+ }
4430
+ const { sourceCode } = context;
4431
+ const spy = isTypeReference(annotation) && sourceCode.getText(annotation.typeName) === "Spy" && annotation.typeArguments?.params.length === 1;
4432
+ const edits = spy ? [[annotation, `${sourceCode.getText(annotation)}['${method}']`]] : [];
4433
+ return { edits, typed: true, blocked: !spy };
4434
+ }
4435
+ function variableRewrite(context, call, id, method) {
4436
+ const variable = findBinding(context.sourceCode.getScope(call), id.name);
4437
+ const declarator = variable?.defs.map((definition) => definition.node).find(isVariableDeclarator);
4438
+ if (!variable || !declarator || !isIdentifier(declarator.id)) {
4439
+ return void 0;
4440
+ }
4441
+ const declaration = declarator.parent;
4442
+ const writes = variable.references.filter((reference) => reference.writeExpr);
4443
+ const members = variable.references.flatMap((reference) => reference.writeExpr ? [] : [reference.identifier.parent]);
4444
+ const reads = members.filter((member) => isMemberExpression(member) && readsMethod(member, method));
4445
+ if (writes.length !== 1 || !writes.every((write) => write.writeExpr === call) || reads.length !== members.length || !DECLARATION_KINDS.has(String(Reflect.get(declaration, "kind"))) || declaration.parent.type === "ExportNamedDeclaration") {
4446
+ return void 0;
4447
+ }
4448
+ const annotation = annotationEdit(context, declarator.id, method);
4449
+ return { ...annotation, target: call, edits: [...annotation.edits, ...reads.map((read) => [read, id.name])] };
4450
+ }
4451
+ function patternRewrite(call, pattern, method) {
4452
+ const [property] = pattern.properties;
4453
+ const local = property && propertyName(property) === method ? propertyValue(property) : void 0;
4454
+ if (pattern.properties.length !== 1 || !local || !isIdentifier(local)) {
4455
+ return void 0;
4456
+ }
4457
+ return { target: call, edits: [[pattern, local.name]], typed: false, blocked: pattern.typeAnnotation !== void 0 };
4458
+ }
4459
+ function rewriteOf(context, call, method) {
4460
+ const { parent } = call;
4461
+ if (isExpressionStatement(parent)) {
4462
+ return { target: call, edits: [], typed: false, blocked: false };
4463
+ }
4464
+ if (isMemberExpression(parent) && parent.object === call) {
4465
+ return readsMethod(parent, method) ? { target: parent, edits: [], typed: false, blocked: false } : void 0;
4466
+ }
4467
+ if (isVariableDeclarator(parent) && isObjectPattern(parent.id)) {
4468
+ return patternRewrite(call, parent.id, method);
4469
+ }
4470
+ if (isVariableDeclarator(parent) && isIdentifier(parent.id)) {
4471
+ return variableRewrite(context, call, parent.id, method);
4472
+ }
4473
+ return isAssignmentExpression(parent) && isIdentifier(parent.left) && isExpressionStatement(parent.parent) ? variableRewrite(context, call, parent.left, method) : void 0;
4474
+ }
4475
+ function swapSpecifier(context, fixer, specifier, helper) {
4476
+ const { specifiers } = specifier.parent;
4477
+ const others = specifiers.filter((candidate) => candidate !== specifier).map((candidate) => context.sourceCode.getText(candidate));
4478
+ const keys = others.map((text) => text.toLowerCase());
4479
+ const ordered = keys.every((key, index) => keys.slice(0, index).every((before2) => before2 <= key));
4480
+ if (!ordered || !specifiers.every(isNamedImportSpecifier)) {
4481
+ return fixer.replaceText(specifier, helper);
4482
+ }
4483
+ others.splice(keys.filter((key) => key < helper.toLowerCase()).length, 0, helper);
4484
+ return fixer.replaceTextRange(
4485
+ [Math.min(...specifiers.map((candidate) => candidate.range[0])), Math.max(...specifiers.map((candidate) => candidate.range[1]))],
4486
+ others.join(", ")
4487
+ );
4488
+ }
4489
+ function importEdits(context, fixer, call, helper, factory) {
4490
+ const specifier = factory ? importSpecifierOf(factory) : void 0;
4491
+ const module = specifier ? String(specifier.parent.source.value) : PACKAGE;
4492
+ const home = CORE_ENTRIES.has(module) ? module : PACKAGE;
4493
+ const orphaned = factory?.references.length === 1;
4494
+ const drop = orphaned ? dropNamedImport(context.sourceCode, fixer, factory) : void 0;
4495
+ const dropped = drop ? [drop] : [];
4496
+ if (bindingState(context.sourceCode.getScope(call), helper) === "imported") {
4497
+ return dropped;
4498
+ }
4499
+ if (specifier && orphaned && home === module) {
4500
+ return [swapSpecifier(context, fixer, specifier, helper)];
4501
+ }
4502
+ return [importNamed(fixer, call, helper, home), ...dropped];
4503
+ }
4504
+ var MESSAGES = {
4505
+ ownMethod: "`createSpyFromInstance(\u2026, { onlyMethodsToSpyOn: ['{{method}}'], passthrough: true })`, read for `{{method}}` alone, says nothing `spyOnOwnMethod(target, '{{method}}')` does not: one method recorded and calling through, the rest of the object real, the spy handed back directly. Name the method once.",
4506
+ voidExact: "`createSpyFromInstance(\u2026, { onlyMethodsToSpyOn: ['{{method}}'], returns: { {{method}}: undefined } })` names `{{method}}` twice to say what `spyOnVoidMethod(target, '{{method}}')` says once: one void method recorded, answering `undefined`, the rest real.",
4507
+ voidOnRealObject: "This seeds `{{method}}` of a real event or element with `undefined`, and discovery spies every other method of it too \u2014 on a DOM node that reaches the engine's own internals. `spyOnVoidMethod(target, '{{method}}')` patches `{{method}}` alone. A suggestion, not a fix: check the test does not rely on another method being stubbed."
4508
+ };
4509
+ function report(context, call, target, seed, rewrite2) {
4510
+ const scope = context.sourceCode.getScope(call);
4511
+ const text = `${seed.helper}(${context.sourceCode.getText(target)}, '${seed.method}')`;
4512
+ const factory = findBinding(scope, FACTORY);
4513
+ const fix = (fixer) => [
4514
+ fixer.replaceText(rewrite2.target, text),
4515
+ ...rewrite2.edits.map(([node, replacement]) => fixer.replaceText(node, replacement)),
4516
+ ...importEdits(context, fixer, call, seed.helper, factory)
4517
+ ];
4518
+ const fixable = !rewrite2.blocked && !call.typeArguments && bindingState(scope, seed.helper) !== "taken";
4519
+ const safe = seed.exact && !rewrite2.typed;
4520
+ const messageId = seed.helper === OWN ? "ownMethod" : seed.exact ? "voidExact" : "voidOnRealObject";
4521
+ context.report({
4522
+ node: call,
4523
+ messageId,
4524
+ data: { method: seed.method },
4525
+ ...fixable && safe ? { fix } : {},
4526
+ ...fixable && !safe ? { suggest: [{ desc: `Replace with ${text}`, fix }] } : {}
4527
+ });
4528
+ }
4529
+ var preferSpyOnOwnMethod = defineRule({
4530
+ anchor: "-an-object-the-test-already-holds",
4531
+ description: "Spy one method of a real object with spyOnOwnMethod / spyOnVoidMethod instead of a one-method createSpyFromInstance",
4532
+ messages: MESSAGES,
4533
+ fixable: true,
4534
+ hasSuggestions: true,
4535
+ create: (context) => ({
4536
+ CallExpression: (call) => {
4537
+ const [target, options, ...rest] = call.arguments;
4538
+ if (!isIdentifier(call.callee) || call.callee.name !== FACTORY || !target || !options || rest.length > 0) {
4539
+ return;
4540
+ }
4541
+ const scope = context.sourceCode.getScope(call);
4542
+ const seed = seedOf(options);
4543
+ if (!seed || bindingState(scope, FACTORY) === "taken" || !seed.exact && !isRealObject(scope, target, true)) {
4544
+ return;
4545
+ }
4546
+ const rewrite2 = rewriteOf(context, call, seed.method);
4547
+ if (rewrite2) {
4548
+ report(context, call, target, seed, rewrite2);
4549
+ }
4550
+ }
4551
+ })
4552
+ });
4553
+
4333
4554
  // src/lib/eslint/stub-response.ts
4334
4555
  var RESPONSE = "Response";
4335
4556
  var TYPE_ARGUMENT_DOUBLES = /* @__PURE__ */ new Set(["createAutoMock", "createMock"]);
@@ -4781,8 +5002,8 @@ var noExpectInSubscribe = defineRule({
4781
5002
  "Program:exit": () => {
4782
5003
  assertions.forEach(({ count, repair }, subscribeCall) => {
4783
5004
  const suggestion = rewrites.get(subscribeCall);
4784
- const report = { node: subscribeCall, messageId: repair, data: { count: String(count) } };
4785
- context.report(suggestion ? { ...report, suggest: [suggestion] } : report);
5005
+ const report2 = { node: subscribeCall, messageId: repair, data: { count: String(count) } };
5006
+ context.report(suggestion ? { ...report2, suggest: [suggestion] } : report2);
4786
5007
  });
4787
5008
  }
4788
5009
  };
@@ -4958,8 +5179,8 @@ var noImportTimeSpread = defineRule({
4958
5179
  }
4959
5180
  const suggestion = lazyValueSuggestion(context, node);
4960
5181
  const messageId = spreadFailureMode(node) === "object" ? "noImportTimeSpreadObject" : "noImportTimeSpread";
4961
- const report = { node, messageId, data: { name: imported.name } };
4962
- context.report(suggestion ? { ...report, suggest: [suggestion] } : report);
5182
+ const report2 = { node, messageId, data: { name: imported.name } };
5183
+ context.report(suggestion ? { ...report2, suggest: [suggestion] } : report2);
4963
5184
  }
4964
5185
  })
4965
5186
  });
@@ -5031,6 +5252,7 @@ var rules = {
5031
5252
  "no-redundant-smoke-test": noRedundantSmokeTest,
5032
5253
  "no-self-called-spy": noSelfCalledSpy,
5033
5254
  "prefer-set-inputs": preferSetInputs,
5255
+ "prefer-spy-on-own-method": preferSpyOnOwnMethod,
5034
5256
  ...jasmineRules
5035
5257
  };
5036
5258
 
@@ -5081,6 +5303,7 @@ var recommendedRules = {
5081
5303
  [`${PLUGIN_NAME}/no-redundant-mock-reset`]: "error",
5082
5304
  [`${PLUGIN_NAME}/no-unasserted-argument`]: "warn",
5083
5305
  [`${PLUGIN_NAME}/prefer-set-inputs`]: "warn",
5306
+ [`${PLUGIN_NAME}/prefer-spy-on-own-method`]: "warn",
5084
5307
  [`${PLUGIN_NAME}/jasmine-namespace-without-entry`]: "error",
5085
5308
  [`${PLUGIN_NAME}/no-jasmine-globals`]: "error",
5086
5309
  [`${PLUGIN_NAME}/no-save-arguments-by-value`]: "error",
package/dist/setup.d.ts CHANGED
@@ -575,16 +575,24 @@ interface SchedulerHost {
575
575
  /** One outstanding callback, and where it came from — what {@link describeStrayTimers} hands back. */
576
576
  interface StrayTimer {
577
577
  readonly kind: 'frame' | 'interval' | 'timeout';
578
+ /** The delay a timeout or interval was scheduled with, in milliseconds; `undefined` for a frame. */
579
+ readonly delay?: number;
578
580
  /** The spec file that was running when it was scheduled; `undefined` outside a Vitest file. */
579
581
  readonly file: string | undefined;
580
- /** Up to five frames of the scheduling call, those outside dependencies first. */
582
+ /**
583
+ * Up to five frames of the scheduling call, starting below the tracking wrapper: those outside
584
+ * dependencies first, then — when the call came from dependencies only — theirs, never this package's.
585
+ */
581
586
  readonly frames: readonly string[];
582
587
  }
583
588
  /** The stack is taken now and formatted only if the callback turns out to be a stray. */
584
589
  interface Origin {
585
590
  readonly kind: StrayTimer['kind'];
591
+ readonly delay: number | undefined;
586
592
  readonly file: unknown;
587
- readonly trace: Error;
593
+ readonly trace: {
594
+ readonly stack?: string;
595
+ };
588
596
  }
589
597
  /** Undo the wrapping installed by {@link trackStrayTimers}, cancelling anything still outstanding. */
590
598
  type StopTrackingTimers = () => void;
package/dist/setup.js CHANGED
@@ -1102,12 +1102,15 @@ function currentSpecFile() {
1102
1102
 
1103
1103
  // src/lib/stack-frames.ts
1104
1104
  var FOREIGN_FRAME = /node_modules|node:internal|\(node:/;
1105
+ var LIBRARY_FRAME = /[/\\]vitest-auto-spy[/\\]dist[/\\]|[/\\]\.vite[/\\]deps[^/\\]*[/\\]vitest-auto-spy[^/\\]*\.js/;
1105
1106
  function stackFrames(stack) {
1106
1107
  return String(stack).split("\n").filter((line) => /^\s*at\s/.test(line)).map((line) => line.trim());
1107
1108
  }
1108
1109
  function ownFrames(frames, limit) {
1109
- const own = frames.filter((frame) => !FOREIGN_FRAME.test(frame));
1110
- return (own.length > 0 ? own : frames).slice(0, limit);
1110
+ const outside = frames.filter((frame) => !LIBRARY_FRAME.test(frame));
1111
+ const own = outside.filter((frame) => !FOREIGN_FRAME.test(frame));
1112
+ const shown = [own, outside].find((candidates) => candidates.length > 0) ?? frames;
1113
+ return shown.slice(0, limit);
1111
1114
  }
1112
1115
 
1113
1116
  // src/lib/stray-listeners.ts
@@ -2357,7 +2360,8 @@ function defaultHost2() {
2357
2360
  function defineScheduler(host, name, value) {
2358
2361
  Object.defineProperty(host, name, { configurable: true, writable: true, value });
2359
2362
  }
2360
- function scheduleTracked(schedule, callback, handles, kind, numeric) {
2363
+ function scheduleTracked(schedule, callback, handles, origin, numeric) {
2364
+ const { kind } = origin;
2361
2365
  const oneShot = kind !== "interval";
2362
2366
  let handle;
2363
2367
  const forgetting = (...args) => {
@@ -2369,19 +2373,32 @@ function scheduleTracked(schedule, callback, handles, kind, numeric) {
2369
2373
  callback(...args);
2370
2374
  };
2371
2375
  handle = schedule(oneShot && typeof callback === "function" ? forgetting : callback);
2372
- handles.set(handle, captureOrigin2(kind));
2376
+ handles.set(handle, captureOrigin2(origin));
2373
2377
  const id = numeric && numericIdOf(handle);
2374
2378
  if (numeric && id !== void 0) {
2375
2379
  numeric.set(id, handle);
2376
2380
  }
2377
2381
  return handle;
2378
2382
  }
2379
- function captureOrigin2(kind) {
2383
+ var ORIGIN_DEPTH = 40;
2384
+ function captureOrigin2({ kind, delay, boundary }) {
2380
2385
  const limit = Error.stackTraceLimit;
2381
- Error.stackTraceLimit = 12;
2382
- const trace = new Error();
2386
+ Error.stackTraceLimit = ORIGIN_DEPTH;
2387
+ const trace = captureTrace(boundary);
2383
2388
  Error.stackTraceLimit = limit;
2384
- return { kind, file: currentSpecFile(), trace };
2389
+ return { kind, delay, file: currentSpecFile(), trace };
2390
+ }
2391
+ function captureTrace(boundary) {
2392
+ if (typeof Error.captureStackTrace !== "function") {
2393
+ return new Error();
2394
+ }
2395
+ const holder = {};
2396
+ Error.captureStackTrace(holder, boundary);
2397
+ return holder;
2398
+ }
2399
+ function delayOf(value) {
2400
+ const delay = Number(value);
2401
+ return Number.isFinite(delay) && delay > 0 ? delay : 0;
2385
2402
  }
2386
2403
  function numericIdOf(handle) {
2387
2404
  return typeof handle === "object" && handle !== null && Symbol.toPrimitive in handle ? Number(handle) : void 0;
@@ -2415,7 +2432,8 @@ function wrapTimerScheduler(host, name, sets) {
2415
2432
  sets.opaque.add(handle);
2416
2433
  return handle;
2417
2434
  }
2418
- return scheduleTracked((tracked) => original(tracked, ...rest), callback, sets.handles, kind, sets.numeric);
2435
+ const origin = { kind, delay: delayOf(rest[0]), boundary: wrapper };
2436
+ return scheduleTracked((tracked) => original(tracked, ...rest), callback, sets.handles, origin, sets.numeric);
2419
2437
  });
2420
2438
  if (promisified !== void 0) {
2421
2439
  Object.defineProperty(wrapper, PROMISIFY_CUSTOM, { configurable: true, value: promisified });
@@ -2441,7 +2459,7 @@ function wrapFrameScheduler(host, frames, pause) {
2441
2459
  return () => void 0;
2442
2460
  }
2443
2461
  const request = defineHelper(
2444
- (callback) => pause.paused ? original(callback) : scheduleTracked((tracked) => original(tracked), callback, frames, "frame")
2462
+ (callback) => pause.paused ? original(callback) : scheduleTracked((tracked) => original(tracked), callback, frames, { kind: "frame", delay: void 0, boundary: request })
2445
2463
  );
2446
2464
  markOwnedPatch(request);
2447
2465
  host.requestAnimationFrame = request;
@@ -2535,9 +2553,10 @@ function pendingHandles(tracked) {
2535
2553
  return pending;
2536
2554
  }
2537
2555
  var OWN_MODULE_FRAME2 = /stray-timers\.[jt]s/;
2538
- function describeOrigin({ kind, file, trace }) {
2556
+ function describeOrigin({ kind, delay, file, trace }) {
2539
2557
  const frames = stackFrames(trace.stack).filter((frame) => !OWN_MODULE_FRAME2.test(frame));
2540
- return { kind, file: typeof file === "string" ? file : void 0, frames: ownFrames(frames, 5) };
2558
+ const described = { kind, file: typeof file === "string" ? file : void 0, frames: ownFrames(frames, 5) };
2559
+ return delay === void 0 ? described : { ...described, delay };
2541
2560
  }
2542
2561
  function describeStrayTimers(host = defaultHost2()) {
2543
2562
  const tracked = registry5().get(host);
@@ -2729,8 +2748,11 @@ function warnAboutSuppressedLeaks(cancelled, write = writeWarning, timers = [])
2729
2748
  );
2730
2749
  }
2731
2750
  function describeTimerOrigins(timers) {
2732
- const lines = timers.slice(0, 3).map(({ kind, file, frames }) => `
2733
- - ${kind} from ${file ?? "no spec file"} ${frames[0] ?? ""}`.trimEnd());
2751
+ const lines = timers.slice(0, 3).map(({ kind, delay, file, frames }) => {
2752
+ const scheduled = delay === void 0 ? kind : `${kind} (${delay} ms)`;
2753
+ return `
2754
+ - ${scheduled} from ${file ?? "no spec file"} ${frames[0] ?? ""}`.trimEnd();
2755
+ });
2734
2756
  return lines.length > 0 ? `
2735
2757
  Scheduled at:${lines.join("")}` : "";
2736
2758
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vitest-auto-spy",
3
- "version": "5.29.0",
3
+ "version": "5.30.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",
@@ -167,126 +167,126 @@ it('loads', async () => {
167
167
 
168
168
  ## Reach for these before hand-rolling
169
169
 
170
- | Situation | Helper |
171
- | ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
172
- | a `signal()` / `computed()` field on the class under test | `mockSignalProp(obj, prop, initial)` — writes through a writable one, `asReadonly()` included; an `input()` is refused |
173
- | a resource field, when the HTTP round trip is not the point | `mockResourceProp(obj, prop, initial)` — `set` / `fail` / `loading`; `value()` after `fail()` throws |
174
- | the HTTP round trip _is_ the point | `expectRequest(url).flush(body)` — `/angular-http`, settling included |
175
- | a component or service that reads `ActivatedRoute` | `provideActivatedRoute({ params })`, then `injectActivatedRoute().setParams(…)` — `/angular-router` |
176
- | a guard or a class built with `new` that takes the route | `createActivatedRoute({ params })` — the same handle, no `TestBed` |
177
- | a node:test suite whose heap grows all run | `trackNodeMocks()` — `/node`, a private MockTracker |
178
- | a Nest provider whose constructor keeps changing | `createNestUnit(Target, { expose })` — built from its DI metadata |
179
- | asserting a resource's value _and_ status together | `registerResourceMatchers()` (`/angular/matchers`) → `toHaveResourceValue` / `toBeLoading` |
180
- | a callback or config object the code under test built | `captureArg<T>()` in the assertion, then read `.value`; `{ where }` records only what it accepts |
181
- | an object the test already holds — a real service, a client, `TestBed.inject(X)` | `createSpyFromInstance(obj, config?)` — patches it in place, `restoreSpiedInstance` undoes it |
182
- | assert calls on a real object or service but keep it working | `createSpyFromInstance(obj, { passthrough: true })` — real method runs until configured; not with `strict: true` on the same call |
183
- | one method of the object under test, recorded and still running | `spyOnOwnMethod(sut, 'method')` — the `onlyMethodsToSpyOn` + `passthrough` packed; the drop-in for a bare banned `vi.spyOn` |
184
- | a native void method a handler is meant to call (`preventDefault`, `focus`) | `spyOnVoidMethod(event, 'preventDefault')` — records the call and seeds `undefined`, so `strict` does not throw on it |
185
- | a `vi.mock` factory's `vi.fn()` needs `calledWith` / `resolveWith` | `adoptMock(api.load).calledWith(7).resolveWith(…)` — same object, history kept |
186
- | real module, one case configured | `vi.mock('x', async (orig) => moduleNamespace(await orig(), { passthrough: true }))` |
187
- | feeding a stubbed `fetch` | `vi.fn(async () => stubResponse({ body: data }))` from `/setup` — a real `Response`, no `as Response`; a body reads once |
188
- | a backend that answers the JSON literal `null` | `stubResponse({ body: null })` — the literal, not "no body"; `undefined` or an omitted `body` is the way to send none |
189
- | the code under test lazy-loads on a click and the spec has no promise | `await settleDynamicImport(() => import('./thing'))` — a bare `await import(…)` waits for the module, not for the handler's continuation |
190
- | MSW handlers not answering under `setupAutoSpy({ blockNetwork: true })` | upgrade — `blockNetwork` now leaves `fetch` to MSW's interceptor; end the handlers with `http.all('*', () => HttpResponse.error())` |
191
- | `mockDeep` member read by index, or an unmocked call that should fail | `mock.items[0].x = 1` makes a real array (re-read `mock.items` after); `mockDeep<T>({}, { fallbackMockImplementation: () => { throw … } })` — options are the **second** argument |
192
- | mocking `PrismaClient` | `mockDeep<PrismaClient>()` + `resolveWith` / `rejectWith` / `resolveWithPerCall`; `$transaction.mockImplementation((run) => run(asInstance(prisma)))` |
193
- | `[Function] is not a spy or a call to a spy!` inside a Storybook `play` | import `expect` from `vitest` in that story file, or `setSpyEngine('runner')` from `/setup` |
194
- | `vi.spyOn(Class.prototype, 'x')` → `The property "x" is not defined on the object.` | `x` is an instance field (arrow, signal) — `createSpyFromClass(C, { instanceMethodsToSpyOn: ['x'] })` |
195
- | a spy or a `mock*Prop` patch leaking into the next `bun test` test | `/setup` is Vitest-only — preload `afterEach(() => { restoreMockedProps(); mock.restore(); })` |
196
- | a `calledWith` that is not matching and you cannot see why | `explainSpy(spy, 'method')` from `/diagnostics` — configs, calls, which hit which |
197
- | 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` |
198
- | a `TestBed` spec on Vitest 4.1+, to drop `let` + `beforeEach` | `extendWithAutoSpies(test, { cart: CartService })` (`/angular`) |
199
- | an `effect()` whose trigger is now a static signal | `runEffect(effectRef)` |
200
- | the component constructs its own `IntersectionObserver` | `stubIntersectionObserver()` (+ `Resize` / `Mutation`) — `last.host` is the callback’s 2nd argument |
201
- | a green run exiting 1 with `AbortError` under happy-dom | `setupAutoSpy({ blockNetwork: true })` |
202
- | a real request going out of a unit run (`fetch`, XHR, `sendBeacon`) | `setupAutoSpy({ blockNetwork: true })` — `{ xhr: 'empty' }` for pings |
203
- | timers or frames from a previous file failing this one | `setupAutoSpy({ strayTimers: true })` — mutes `--detect-async-leaks`; blind under fake timers |
204
- | an assertion error in stderr, every test green and the run at 0 | `setupAutoSpy({ strayRejections: true })` — zone.js swallowed it |
205
- | a run getting slower the longer it goes, on `isolate: false` | `setupAutoSpy({ pruneMockRegistry: true })` — the mock registry |
206
- | a listener or a replaced global from a previous file answering in this one, on `isolate: false` | `setupAutoSpy({ strayListeners: true, restoreGlobals: true })` |
207
- | the same, piece by piece | `trackStrayListeners` / `baselineStrayListeners` / `removeStrayListeners` / `countStrayListeners` / `describeStrayListeners`; `captureGlobalBaseline` / `restoreGlobals` |
208
- | a storage spy still recording after `vi.restoreAllMocks()` under happy-dom | `restoreStorageSpies` — on by default; `restoreStorageSpies()` alone |
209
- | a stream that must emit exactly these values and complete | `await expectAllEmissions(source$)` |
210
- | a fixture outside its type on purpose (the `null` a backend sends) | `outOfType<T>(value)` — no cast |
211
- | one setup file for the Angular unit-test builder and plain Vitest | `if (!isAngularUnitTestBuilder()) TestBed.initTestEnvironment(…)` |
212
- | `JavaScript heap out of memory` on a suite of wide generated clients | nothing to configure — the default shares one placeholder per method name (215 B per untouched 100-method double); `lazySpies: 'proxy'` retains 19× more now |
213
- | `Cannot read properties of undefined (reading 'now')` | `restoreTimerGlobals` — on by default |
214
- | `localStorage.setItem is not a function`, or it is `undefined` | `restoreWebStorage` — on by default; Vitest's global filter, Node 25+ |
215
- | a spy handed to an API typed against the real class | `asInstance()` / `asSpy()` |
216
- | the code under test does `new X()` (a global, a vendor SDK) | `mockConstructor(factory)` / `stubConstructor(obj, key, factory)` |
217
- | `X is not a constructor`, with a stack in production code | same — a `vi.fn(() => …)` cannot serve `new` |
218
- | waiting for a dynamic `import()` under fake timers | `settleDynamicImport(() => import('…'))` / `flushEventLoop()` |
219
- | `addEventListener(…, { signal })` throwing about `EventTarget` | `stubAbortController()` |
220
- | `codemod` reporting a truncated repository scan | `VITEST_AUTO_SPY_SCAN_CAP=<n>` — raises the 50 000-file cap |
221
- | a suite ported from Jest's `fakeTimers.enableGlobally` | `setupAutoSpy({ globalFakeTimers: true })` |
222
- | `toHaveBeenCalledBefore` across an auto-spy and a hand-written `vi.fn()` | `setSpyEngine('runner')` / `getSpyEngine()` — `/setup`, Vitest only |
223
- | a nested `describe`'s `beforeAll` landing on real timers | `setupFakeTimers(cfg, { betweenTests: true })` |
224
- | setup hooks applying to the first spec file of a worker only | the setup module is cached — run coverage with `--isolate` |
225
- | `fakeAsync` inside `test.concurrent` | `installProxyZonePatch({ scope: 'callback' })` |
226
- | an assertion containing a date | `mockSystemTime(iso)` — never `vi.spyOn(globalThis, 'Date')` |
227
- | a spec asserting on tick _order_ under a frozen clock | `useCountingClock()` |
228
- | a dependency declared in the component's own `providers` | `overrideComponentProvider(Cmp, Token)` — it verifies on the first fixture that the override applied |
229
- | a double answering `undefined` for a method nobody configured | `{ strict: true }`, or `setupAutoSpy({ strict: true })` suite-wide |
230
- | a strict throw caught by a `try`/`catch` or an operator, the test still green | `setupAutoSpy({ swallowedStrictCalls: 'throw' })`; provoked on purpose — `takeStrictViolations()` from `/setup` |
231
- | a strict double's getter answering `undefined`, or its `items$` never emitting | `setupAutoSpy({ unconfiguredReads: 'throw' })` reports it after the test; survey first with `onUnstubbedRead` |
232
- | an `afterEach` that exists only to reset one spy | `using spy = createSpyFromClass(X)` — `[Symbol.dispose]` runs `resetAutoSpy`, which is `vi.resetAllMocks()` for one double |
233
- | a dead NgModule import, dead `schemas`, an unflushed HTTP request | `enableAngularDiagnostics()` (`/angular/diagnostics`) in the setup file, after `initTestEnvironment` |
234
- | the spy is provided but the component resolves its own, so the test runs the real service | `enableAngularDiagnostics({ shadowedProviders })` or `assertNoShadowedProviders(Component, fixture)` — `/angular/diagnostics` |
235
- | "which collaborators did this actually inject?" | `trackInjections([A, TOKEN])` — providers plus the record, not `vi.mock` |
236
- | `NG0303` / `NG0301` / `NG0304` from an imported NgModule | `assertNgModuleScopes(Module)` — an AOT bundle stripped its scope |
237
- | `Cannot read properties of undefined (reading 'provide')` in `di_setup` | `assertComponentDefIntact(Cmp)` — a barrel chunk left a hole in `ɵcmp` |
238
- | a focus assertion failing as `expected false to deeply equal true` | `registerFocusMatchers()` + `expect(el).toHaveFocus()` |
239
- | a collaborator passed as an argument, then asserted on | `autoMocked<T>()` — typed `T & Spy<T>` |
240
- | a `<video>` / `<audio>`: `play()` throws, `duration` is `NaN` | `stubMediaElement({ duration })`, then `media.set(el, …)` |
241
- | a `vi.mock()` that silently did nothing under a bundler | `assertMocked(ns, { specifier, exports })` |
242
- | `No "default" export is defined on the mock` | `vi.mock('x', () => moduleNamespace({ … }))` — a `default` you spell out is kept |
243
- | waiting for a `resource()` / an SDK to become ready | `flushEventLoopUntil(() => …, { label })` — budgeted, not tuned |
244
- | `expected [ { at: 1, …(5) }, …(8) ] to deeply equal …` | `expect(diffByField(actual, expected)).toBeUndefined()` |
245
- | a stub that works only in the first test of the file | `installPerTest(() => stub…())` — or install it in `beforeEach` |
246
- | a library failing every other run after a `defineProperty` on DOM | `setupAutoSpy({ guardGlobals: 'throw' })` names the file — it sees an added name, not an existing one redefined |
247
- | 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) |
248
- | the same check in a suite that does not call `setupAutoSpy()` | `guardPrototypePollution('throw')` from `/setup` |
249
- | 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'`) |
250
- | the same document check without `setupAutoSpy()` | `guardDocumentPollution('throw')` from `/setup` |
251
- | `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`) |
252
- | console output a test never asserted on (a `'warn'` from this library does not count) | `setupAutoSpy({ strayConsole: 'throw' })` fails that test; absorb with `installConsoleSpies()` in `beforeEach` |
253
- | every guard at its strictest grade in one line | `setupAutoSpy({ preset: 'strict' })` — plus `enableAngularDiagnostics()` from `/angular/diagnostics` for Angular |
254
- | an `onlyMethodsToSpyOn` typo or `injectSpy` on a real instance that only warned | `setupAutoSpy({ misconfiguration: 'throw' })` throws at the call |
255
- | which file and call scheduled a timer that outlived its file | `onStrayTimers: ({ timers }) => expect(timers).toEqual([])`, or `describeStrayTimers()` from `/setup` |
256
- | stray timers charged to a file that only writes `localStorage` (jsdom) | `withoutStrayTimerTracking(() => seed())` from `/setup` — its timers are neither counted nor cancelled |
257
- | `import { consoleErrorSpy }` silencing other files under `isolate: false` | `installConsoleSpies()` in `beforeEach`, `restoreConsole()` in `afterEach` — the import installs once per worker |
258
- | `Cannot set base providers because it has already been called` | `setupAngularTestEnv({ zoneless, initZone, initZoneless })` |
259
- | a dependency behind an `InjectionToken`, with no class to spy | `provideAutoSpyForToken(TOKEN)` + `injectSpy(TOKEN)`; a chained call → `{ selfReturning: ['channel'] }` as the third argument |
260
- | `Expected to be running in 'ProxyZone', but it was not found` | `import 'vitest-auto-spy/zone'` (needs `globals: true`) |
261
- | `Property 'mockReturnValue' does not exist on type 'never'` | upgrade — the spy no longer collapses on an unreadable return type |
262
- | `TS2345` inside `mockReturnValue` / `mockImplementation` / `mockResolvedValue` | the stub is checked against the method's return type now — fix the stub, not the spy |
263
- | `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 |
264
- | a signal-valued getter that `gettersToSpyOn` will not accept | it accepts any key now; for a signal prefer `mockSignalProp` |
265
- | five `asInstance(…)` in one call, found one per `tsc` run | `...asInstances(a, b, c, d, e)` |
266
- | `nextWith` demanding `HttpEvent<T>` on a generated client | `asSpy<Client, { overload: 'first' }>(…)` / `Overload<M, 0>` |
267
- | `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` |
268
- | a fixture that needs a nested object built by its own call | `createMock<T>({ a: { b: 1 } })` — deep partial, still exact |
269
- | the same 100-line model literal copied into eight specs (`TS1117`) | one `createFixtureFactory<T>(defaults)`; specs call it with what they change |
270
- | `'params' in link` ladders, or a cast, to pick a union branch | `narrow.byKey(link, 'params')` / `narrow.observable(x)` |
271
- | `{ ...modelInstance, flag: true }` losing every getter | `withOverrides(modelInstance, { flag: true })` |
272
- | `NG0303` / `NG0304` / silence from a directive spec | `createDirectiveHost({ template, scope: [Module] })` |
273
- | a hand-written `class MockChartComponent` restating a child's selector | `createComponentStub(ChartComponent)` (`/angular`) — selector, inputs, outputs read from `ɵcmp` |
274
- | a `TestingStorage` class for `localStorage` / `sessionStorage` | `stubWebStorage('localStorage', { items })` from `/dom-stubs` — `snapshot()` to assert |
275
- | a hand-written `MockWorker` whose `addEventListener` sets `onmessage` | `stubWorker({ respond })` from `/dom-stubs` — `last.emit(data)`, `last.messages` |
276
- | `'x' does not exist in type 'MethodReturns<{ y: any; }>'` on a generic class | spell out the type argument — `createSpyFromClass<Config>(Config, …)`; `provideAutoSpy` infers it |
277
- | `injectSpy(ModalRef)` reads a generic class at its constraint, not its default | `injectSpy<ModalRef<Data>>(ModalRef)` — a constructor taking `T` hides the default |
278
- | "did the migration lose a test?" with matching counters | `compareTestRuns(before, after)` — `counts` reports `name (×2 → ×1)` |
279
- | an input that has to change after the first render | `await setInputs(fixture, { … })` — aliases and `hostDirectives` inputs resolve, unknown names refused |
280
- | a component that navigates, or reads `router.url` | `provideRouterDouble({ url })` + `injectRouterDouble()` — `/angular-router` |
281
- | a component that reads `router.currentNavigation()` for `extras.state` or `trigger` | `setCurrentNavigation({ extras: { state } })` on the same handle — no `instanceMethodsToSpyOn` |
282
- | the sequence of router events, not just the final URL | `collectRouterEvents(router.events)` — `expect([[NavigationStart, '/checkout'], [NavigationEnd, '/checkout']])` |
283
- | a component that reads `location.back()`, or asserts where a redirect landed | `provideLocationDouble()` + `injectLocationDouble()` — Angular's own `SpyLocation`; without the provider, `Location` is `providedIn: 'root'` and the real one answers |
284
- | "did A run before B?" across three collaborators | `createLog<'drop-cache' \| 'flush'>()` — hand `log.fn('drop-cache')` to each, assert `log.result()` once |
285
- | a `window` or `document` behind a DI token | `provideWindowDouble(WINDOW, { screen })` / `provideDocumentDouble({ … })` — `/angular/doubles` |
286
- | `MAT_DIALOG_DATA` and `MatDialogRef` provided by hand | `provideMatDialogData(TOKEN, data)` / `provideMatDialogRef(MatDialogRef)` + `injectMatDialogRef(Ref)` — `/angular/doubles` |
287
- | asserting a `computed()` did **not** recompute | `trackRecomputations(sig)` / `trackEffectRuns(ref)` — `{ count, stop() }` |
288
- | `form()` in a spec, or `errors()` read by hand | `createForm(model, schema)` + `registerFormMatchers()` — then `toHaveFieldErrors([…])` (`/signal-forms`) |
289
- | any of those doubles without a `TestBed` | `createRouterDouble` (`/angular-router`) / `createWindowDouble` / `createDocumentDouble` / `createMatDialogRef` (`/angular/doubles`) |
170
+ | Situation | Helper |
171
+ | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
172
+ | a `signal()` / `computed()` field on the class under test | `mockSignalProp(obj, prop, initial)` — writes through a writable one, `asReadonly()` included; an `input()` is refused |
173
+ | a resource field, when the HTTP round trip is not the point | `mockResourceProp(obj, prop, initial)` — `set` / `fail` / `loading`; `value()` after `fail()` throws |
174
+ | the HTTP round trip _is_ the point | `expectRequest(url).flush(body)` — `/angular-http`, settling included |
175
+ | a component or service that reads `ActivatedRoute` | `provideActivatedRoute({ params })`, then `injectActivatedRoute().setParams(…)` — `/angular-router` |
176
+ | a guard or a class built with `new` that takes the route | `createActivatedRoute({ params })` — the same handle, no `TestBed` |
177
+ | a node:test suite whose heap grows all run | `trackNodeMocks()` — `/node`, a private MockTracker |
178
+ | a Nest provider whose constructor keeps changing | `createNestUnit(Target, { expose })` — built from its DI metadata |
179
+ | asserting a resource's value _and_ status together | `registerResourceMatchers()` (`/angular/matchers`) → `toHaveResourceValue` / `toBeLoading` |
180
+ | a callback or config object the code under test built | `captureArg<T>()` in the assertion, then read `.value`; `{ where }` records only what it accepts |
181
+ | an object the test already holds — a real service, a client, `TestBed.inject(X)` | `createSpyFromInstance(obj, config?)` — patches it in place, `restoreSpiedInstance` undoes it |
182
+ | assert calls on a real object or service but keep it working | `createSpyFromInstance(obj, { passthrough: true })` — real method runs until configured; not with `strict: true` on the same call |
183
+ | one method of the object under test, recorded and still running | `spyOnOwnMethod(sut, 'method')` — the `onlyMethodsToSpyOn` + `passthrough` packed; the drop-in for a bare banned `vi.spyOn`; `prefer-spy-on-own-method` (`warn`, `--fix`) finds the long spelling |
184
+ | a native void method a handler is meant to call (`preventDefault`, `focus`) | `spyOnVoidMethod(event, 'preventDefault')` — records the call and seeds `undefined`, so `strict` does not throw on it |
185
+ | a `vi.mock` factory's `vi.fn()` needs `calledWith` / `resolveWith` | `adoptMock(api.load).calledWith(7).resolveWith(…)` — same object, history kept |
186
+ | real module, one case configured | `vi.mock('x', async (orig) => moduleNamespace(await orig(), { passthrough: true }))` |
187
+ | feeding a stubbed `fetch` | `vi.fn(async () => stubResponse({ body: data }))` from `/setup` — a real `Response`, no `as Response`; a body reads once |
188
+ | a backend that answers the JSON literal `null` | `stubResponse({ body: null })` — the literal, not "no body"; `undefined` or an omitted `body` is the way to send none |
189
+ | the code under test lazy-loads on a click and the spec has no promise | `await settleDynamicImport(() => import('./thing'))` — a bare `await import(…)` waits for the module, not for the handler's continuation |
190
+ | MSW handlers not answering under `setupAutoSpy({ blockNetwork: true })` | upgrade — `blockNetwork` now leaves `fetch` to MSW's interceptor; end the handlers with `http.all('*', () => HttpResponse.error())` |
191
+ | `mockDeep` member read by index, or an unmocked call that should fail | `mock.items[0].x = 1` makes a real array (re-read `mock.items` after); `mockDeep<T>({}, { fallbackMockImplementation: () => { throw … } })` — options are the **second** argument |
192
+ | mocking `PrismaClient` | `mockDeep<PrismaClient>()` + `resolveWith` / `rejectWith` / `resolveWithPerCall`; `$transaction.mockImplementation((run) => run(asInstance(prisma)))` |
193
+ | `[Function] is not a spy or a call to a spy!` inside a Storybook `play` | import `expect` from `vitest` in that story file, or `setSpyEngine('runner')` from `/setup` |
194
+ | `vi.spyOn(Class.prototype, 'x')` → `The property "x" is not defined on the object.` | `x` is an instance field (arrow, signal) — `createSpyFromClass(C, { instanceMethodsToSpyOn: ['x'] })` |
195
+ | a spy or a `mock*Prop` patch leaking into the next `bun test` test | `/setup` is Vitest-only — preload `afterEach(() => { restoreMockedProps(); mock.restore(); })` |
196
+ | a `calledWith` that is not matching and you cannot see why | `explainSpy(spy, 'method')` from `/diagnostics` — configs, calls, which hit which |
197
+ | 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` |
198
+ | a `TestBed` spec on Vitest 4.1+, to drop `let` + `beforeEach` | `extendWithAutoSpies(test, { cart: CartService })` (`/angular`) |
199
+ | an `effect()` whose trigger is now a static signal | `runEffect(effectRef)` |
200
+ | the component constructs its own `IntersectionObserver` | `stubIntersectionObserver()` (+ `Resize` / `Mutation`) — `last.host` is the callback’s 2nd argument |
201
+ | a green run exiting 1 with `AbortError` under happy-dom | `setupAutoSpy({ blockNetwork: true })` |
202
+ | a real request going out of a unit run (`fetch`, XHR, `sendBeacon`) | `setupAutoSpy({ blockNetwork: true })` — `{ xhr: 'empty' }` for pings |
203
+ | timers or frames from a previous file failing this one | `setupAutoSpy({ strayTimers: true })` — mutes `--detect-async-leaks`; blind under fake timers |
204
+ | an assertion error in stderr, every test green and the run at 0 | `setupAutoSpy({ strayRejections: true })` — zone.js swallowed it |
205
+ | a run getting slower the longer it goes, on `isolate: false` | `setupAutoSpy({ pruneMockRegistry: true })` — the mock registry |
206
+ | a listener or a replaced global from a previous file answering in this one, on `isolate: false` | `setupAutoSpy({ strayListeners: true, restoreGlobals: true })` |
207
+ | the same, piece by piece | `trackStrayListeners` / `baselineStrayListeners` / `removeStrayListeners` / `countStrayListeners` / `describeStrayListeners`; `captureGlobalBaseline` / `restoreGlobals` |
208
+ | a storage spy still recording after `vi.restoreAllMocks()` under happy-dom | `restoreStorageSpies` — on by default; `restoreStorageSpies()` alone |
209
+ | a stream that must emit exactly these values and complete | `await expectAllEmissions(source$)` |
210
+ | a fixture outside its type on purpose (the `null` a backend sends) | `outOfType<T>(value)` — no cast |
211
+ | one setup file for the Angular unit-test builder and plain Vitest | `if (!isAngularUnitTestBuilder()) TestBed.initTestEnvironment(…)` |
212
+ | `JavaScript heap out of memory` on a suite of wide generated clients | nothing to configure — the default shares one placeholder per method name (215 B per untouched 100-method double); `lazySpies: 'proxy'` retains 19× more now |
213
+ | `Cannot read properties of undefined (reading 'now')` | `restoreTimerGlobals` — on by default |
214
+ | `localStorage.setItem is not a function`, or it is `undefined` | `restoreWebStorage` — on by default; Vitest's global filter, Node 25+ |
215
+ | a spy handed to an API typed against the real class | `asInstance()` / `asSpy()` |
216
+ | the code under test does `new X()` (a global, a vendor SDK) | `mockConstructor(factory)` / `stubConstructor(obj, key, factory)` |
217
+ | `X is not a constructor`, with a stack in production code | same — a `vi.fn(() => …)` cannot serve `new` |
218
+ | waiting for a dynamic `import()` under fake timers | `settleDynamicImport(() => import('…'))` / `flushEventLoop()` |
219
+ | `addEventListener(…, { signal })` throwing about `EventTarget` | `stubAbortController()` |
220
+ | `codemod` reporting a truncated repository scan | `VITEST_AUTO_SPY_SCAN_CAP=<n>` — raises the 50 000-file cap |
221
+ | a suite ported from Jest's `fakeTimers.enableGlobally` | `setupAutoSpy({ globalFakeTimers: true })` |
222
+ | `toHaveBeenCalledBefore` across an auto-spy and a hand-written `vi.fn()` | `setSpyEngine('runner')` / `getSpyEngine()` — `/setup`, Vitest only |
223
+ | a nested `describe`'s `beforeAll` landing on real timers | `setupFakeTimers(cfg, { betweenTests: true })` |
224
+ | setup hooks applying to the first spec file of a worker only | the setup module is cached — run coverage with `--isolate` |
225
+ | `fakeAsync` inside `test.concurrent` | `installProxyZonePatch({ scope: 'callback' })` |
226
+ | an assertion containing a date | `mockSystemTime(iso)` — never `vi.spyOn(globalThis, 'Date')` |
227
+ | a spec asserting on tick _order_ under a frozen clock | `useCountingClock()` |
228
+ | a dependency declared in the component's own `providers` | `overrideComponentProvider(Cmp, Token)` — it verifies on the first fixture that the override applied |
229
+ | a double answering `undefined` for a method nobody configured | `{ strict: true }`, or `setupAutoSpy({ strict: true })` suite-wide |
230
+ | a strict throw caught by a `try`/`catch` or an operator, the test still green | `setupAutoSpy({ swallowedStrictCalls: 'throw' })`; provoked on purpose — `takeStrictViolations()` from `/setup` |
231
+ | a strict double's getter answering `undefined`, or its `items$` never emitting | `setupAutoSpy({ unconfiguredReads: 'throw' })` reports it after the test; survey first with `onUnstubbedRead` |
232
+ | an `afterEach` that exists only to reset one spy | `using spy = createSpyFromClass(X)` — `[Symbol.dispose]` runs `resetAutoSpy`, which is `vi.resetAllMocks()` for one double |
233
+ | a dead NgModule import, dead `schemas`, an unflushed HTTP request | `enableAngularDiagnostics()` (`/angular/diagnostics`) in the setup file, after `initTestEnvironment` |
234
+ | the spy is provided but the component resolves its own, so the test runs the real service | `enableAngularDiagnostics({ shadowedProviders })` or `assertNoShadowedProviders(Component, fixture)` — `/angular/diagnostics` |
235
+ | "which collaborators did this actually inject?" | `trackInjections([A, TOKEN])` — providers plus the record, not `vi.mock` |
236
+ | `NG0303` / `NG0301` / `NG0304` from an imported NgModule | `assertNgModuleScopes(Module)` — an AOT bundle stripped its scope |
237
+ | `Cannot read properties of undefined (reading 'provide')` in `di_setup` | `assertComponentDefIntact(Cmp)` — a barrel chunk left a hole in `ɵcmp` |
238
+ | a focus assertion failing as `expected false to deeply equal true` | `registerFocusMatchers()` + `expect(el).toHaveFocus()` |
239
+ | a collaborator passed as an argument, then asserted on | `autoMocked<T>()` — typed `T & Spy<T>` |
240
+ | a `<video>` / `<audio>`: `play()` throws, `duration` is `NaN` | `stubMediaElement({ duration })`, then `media.set(el, …)` |
241
+ | a `vi.mock()` that silently did nothing under a bundler | `assertMocked(ns, { specifier, exports })` |
242
+ | `No "default" export is defined on the mock` | `vi.mock('x', () => moduleNamespace({ … }))` — a `default` you spell out is kept |
243
+ | waiting for a `resource()` / an SDK to become ready | `flushEventLoopUntil(() => …, { label })` — budgeted, not tuned |
244
+ | `expected [ { at: 1, …(5) }, …(8) ] to deeply equal …` | `expect(diffByField(actual, expected)).toBeUndefined()` |
245
+ | a stub that works only in the first test of the file | `installPerTest(() => stub…())` — or install it in `beforeEach` |
246
+ | a library failing every other run after a `defineProperty` on DOM | `setupAutoSpy({ guardGlobals: 'throw' })` names the file — it sees an added name, not an existing one redefined |
247
+ | 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) |
248
+ | the same check in a suite that does not call `setupAutoSpy()` | `guardPrototypePollution('throw')` from `/setup` |
249
+ | 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'`) |
250
+ | the same document check without `setupAutoSpy()` | `guardDocumentPollution('throw')` from `/setup` |
251
+ | `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`) |
252
+ | console output a test never asserted on (a `'warn'` from this library does not count) | `setupAutoSpy({ strayConsole: 'throw' })` fails that test; absorb with `installConsoleSpies()` in `beforeEach` |
253
+ | every guard at its strictest grade in one line | `setupAutoSpy({ preset: 'strict' })` — plus `enableAngularDiagnostics()` from `/angular/diagnostics` for Angular |
254
+ | an `onlyMethodsToSpyOn` typo or `injectSpy` on a real instance that only warned | `setupAutoSpy({ misconfiguration: 'throw' })` throws at the call |
255
+ | which file and call scheduled a timer that outlived its file | `onStrayTimers: ({ timers }) => expect(timers).toEqual([])`, or `describeStrayTimers()` from `/setup` |
256
+ | stray timers charged to a file that only writes `localStorage` (jsdom) | `withoutStrayTimerTracking(() => seed())` from `/setup` — its timers are neither counted nor cancelled |
257
+ | `import { consoleErrorSpy }` silencing other files under `isolate: false` | `installConsoleSpies()` in `beforeEach`, `restoreConsole()` in `afterEach` — the import installs once per worker |
258
+ | `Cannot set base providers because it has already been called` | `setupAngularTestEnv({ zoneless, initZone, initZoneless })` |
259
+ | a dependency behind an `InjectionToken`, with no class to spy | `provideAutoSpyForToken(TOKEN)` + `injectSpy(TOKEN)`; a chained call → `{ selfReturning: ['channel'] }` as the third argument |
260
+ | `Expected to be running in 'ProxyZone', but it was not found` | `import 'vitest-auto-spy/zone'` (needs `globals: true`) |
261
+ | `Property 'mockReturnValue' does not exist on type 'never'` | upgrade — the spy no longer collapses on an unreadable return type |
262
+ | `TS2345` inside `mockReturnValue` / `mockImplementation` / `mockResolvedValue` | the stub is checked against the method's return type now — fix the stub, not the spy |
263
+ | `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 |
264
+ | a signal-valued getter that `gettersToSpyOn` will not accept | it accepts any key now; for a signal prefer `mockSignalProp` |
265
+ | five `asInstance(…)` in one call, found one per `tsc` run | `...asInstances(a, b, c, d, e)` |
266
+ | `nextWith` demanding `HttpEvent<T>` on a generated client | `asSpy<Client, { overload: 'first' }>(…)` / `Overload<M, 0>` |
267
+ | `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` |
268
+ | a fixture that needs a nested object built by its own call | `createMock<T>({ a: { b: 1 } })` — deep partial, still exact |
269
+ | the same 100-line model literal copied into eight specs (`TS1117`) | one `createFixtureFactory<T>(defaults)`; specs call it with what they change |
270
+ | `'params' in link` ladders, or a cast, to pick a union branch | `narrow.byKey(link, 'params')` / `narrow.observable(x)` |
271
+ | `{ ...modelInstance, flag: true }` losing every getter | `withOverrides(modelInstance, { flag: true })` |
272
+ | `NG0303` / `NG0304` / silence from a directive spec | `createDirectiveHost({ template, scope: [Module] })` |
273
+ | a hand-written `class MockChartComponent` restating a child's selector | `createComponentStub(ChartComponent)` (`/angular`) — selector, inputs, outputs read from `ɵcmp` |
274
+ | a `TestingStorage` class for `localStorage` / `sessionStorage` | `stubWebStorage('localStorage', { items })` from `/dom-stubs` — `snapshot()` to assert |
275
+ | a hand-written `MockWorker` whose `addEventListener` sets `onmessage` | `stubWorker({ respond })` from `/dom-stubs` — `last.emit(data)`, `last.messages` |
276
+ | `'x' does not exist in type 'MethodReturns<{ y: any; }>'` on a generic class | spell out the type argument — `createSpyFromClass<Config>(Config, …)`; `provideAutoSpy` infers it |
277
+ | `injectSpy(ModalRef)` reads a generic class at its constraint, not its default | `injectSpy<ModalRef<Data>>(ModalRef)` — a constructor taking `T` hides the default |
278
+ | "did the migration lose a test?" with matching counters | `compareTestRuns(before, after)` — `counts` reports `name (×2 → ×1)` |
279
+ | an input that has to change after the first render | `await setInputs(fixture, { … })` — aliases and `hostDirectives` inputs resolve, unknown names refused |
280
+ | a component that navigates, or reads `router.url` | `provideRouterDouble({ url })` + `injectRouterDouble()` — `/angular-router` |
281
+ | a component that reads `router.currentNavigation()` for `extras.state` or `trigger` | `setCurrentNavigation({ extras: { state } })` on the same handle — no `instanceMethodsToSpyOn` |
282
+ | the sequence of router events, not just the final URL | `collectRouterEvents(router.events)` — `expect([[NavigationStart, '/checkout'], [NavigationEnd, '/checkout']])` |
283
+ | a component that reads `location.back()`, or asserts where a redirect landed | `provideLocationDouble()` + `injectLocationDouble()` — Angular's own `SpyLocation`; without the provider, `Location` is `providedIn: 'root'` and the real one answers |
284
+ | "did A run before B?" across three collaborators | `createLog<'drop-cache' \| 'flush'>()` — hand `log.fn('drop-cache')` to each, assert `log.result()` once |
285
+ | a `window` or `document` behind a DI token | `provideWindowDouble(WINDOW, { screen })` / `provideDocumentDouble({ … })` — `/angular/doubles` |
286
+ | `MAT_DIALOG_DATA` and `MatDialogRef` provided by hand | `provideMatDialogData(TOKEN, data)` / `provideMatDialogRef(MatDialogRef)` + `injectMatDialogRef(Ref)` — `/angular/doubles` |
287
+ | asserting a `computed()` did **not** recompute | `trackRecomputations(sig)` / `trackEffectRuns(ref)` — `{ count, stop() }` |
288
+ | `form()` in a spec, or `errors()` read by hand | `createForm(model, schema)` + `registerFormMatchers()` — then `toHaveFieldErrors([…])` (`/signal-forms`) |
289
+ | any of those doubles without a `TestBed` | `createRouterDouble` (`/angular-router`) / `createWindowDouble` / `createDocumentDouble` / `createMatDialogRef` (`/angular/doubles`) |
290
290
 
291
291
  ## Rules that prevent most of the mistakes
292
292
 
@@ -444,7 +444,7 @@ npx vitest-auto-spy codemod --verify # after a migration: anything the transfor
444
444
  Most of this library's guarantees are type-level, so a green run that does not type-check is not
445
445
  done. Report failures with their output rather than describing them as passing.
446
446
 
447
- **After any `eslint --fix` over specs, run `npx tsc --noEmit`.** The forty-eight rules in
447
+ **After any `eslint --fix` over specs, run `npx tsc --noEmit`.** The forty-nine rules in
448
448
  `vitest-auto-spy/eslint-plugin` are lint, not typecheck: `no-mocked-for-spy` rewrites a declaration
449
449
  to `Spy<T>` and cannot see what the name is assigned two lines below, so a clean lint pass is not
450
450
  evidence that the types still hold. Where it cannot prove the rename it downgrades to a suggestion —