vitest-auto-spy 1.12.0 → 2.0.2

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.
Files changed (69) hide show
  1. package/AGENTS.md +707 -0
  2. package/README.md +181 -36
  3. package/assets/one-api-three-runtimes.svg +9 -6
  4. package/dist/angular.d.ts +94 -136
  5. package/dist/angular.js +17 -118
  6. package/dist/bun-angular.d.ts +45 -530
  7. package/dist/bun-angular.js +25 -988
  8. package/dist/bun.d.ts +184 -9
  9. package/dist/bun.js +10 -34
  10. package/dist/{chunk-YUD7QJMP.js → chunk-6RGTS3E6.js} +1 -1
  11. package/dist/{chunk-E6UVGTHO.js → chunk-BUWKNMO3.js} +24 -12
  12. package/dist/{chunk-AT2AGHGX.js → chunk-DNKYISGG.js} +1 -1
  13. package/dist/chunk-JX5ROCS7.js +136 -0
  14. package/dist/chunk-KKXZ4JFP.js +19 -0
  15. package/dist/{chunk-7YSAUMPL.js → chunk-NCIGTWVZ.js} +83 -4
  16. package/dist/{chunk-QFTAM5KR.js → chunk-RZVTIR4R.js} +14 -2
  17. package/dist/chunk-WBQNVPQL.js +27 -0
  18. package/dist/{chunk-H2QNMFDC.js → chunk-XPUCQU4O.js} +1 -1
  19. package/dist/{chunk-CMSPQKEJ.js → chunk-ZJ7NH6LG.js} +6 -1
  20. package/dist/{chunk-5VURXYKY.js → chunk-ZJA7DTXR.js} +5 -4
  21. package/dist/console.d.ts +25 -3
  22. package/dist/console.js +4 -3
  23. package/dist/{expect-emission-DEFP9HqZ.d.ts → expect-emission-CyftZ6uw.d.ts} +73 -6
  24. package/dist/index.d.ts +4 -4
  25. package/dist/index.js +9 -8
  26. package/dist/nestjs.d.ts +21 -3
  27. package/dist/nestjs.js +5 -4
  28. package/dist/node.cjs +135 -14
  29. package/dist/node.d.cts +648 -6
  30. package/dist/node.d.ts +4 -4
  31. package/dist/node.js +9 -8
  32. package/dist/package-identity-B1pqa-Sh.d.ts +33 -0
  33. package/dist/react.d.ts +4 -4
  34. package/dist/react.js +9 -8
  35. package/dist/{angular.d.cts → run-effect-xYIXi0O_.d.ts} +62 -63
  36. package/dist/rxjs.d.ts +10 -3
  37. package/dist/rxjs.js +2 -1
  38. package/dist/setup.d.ts +152 -2
  39. package/dist/setup.js +158 -9
  40. package/dist/svelte.d.ts +4 -4
  41. package/dist/svelte.js +9 -8
  42. package/dist/{types-hTDGBx8V.d.ts → types-DlmJE_eU.d.ts} +35 -4
  43. package/dist/vue.d.ts +5 -5
  44. package/dist/vue.js +10 -9
  45. package/package.json +24 -26
  46. package/skills/vitest-auto-spy/SKILL.md +137 -0
  47. package/dist/angular.cjs +0 -995
  48. package/dist/bun.cjs +0 -970
  49. package/dist/bun.d.cts +0 -115
  50. package/dist/console.cjs +0 -459
  51. package/dist/console.d.cts +0 -39
  52. package/dist/expect-emission-DEFP9HqZ.d.cts +0 -97
  53. package/dist/index.cjs +0 -939
  54. package/dist/index.d.cts +0 -6
  55. package/dist/nestjs.cjs +0 -569
  56. package/dist/nestjs.d.cts +0 -23
  57. package/dist/package-identity-BMWNY1fi.d.cts +0 -31
  58. package/dist/package-identity-BMWNY1fi.d.ts +0 -31
  59. package/dist/react.cjs +0 -939
  60. package/dist/react.d.cts +0 -6
  61. package/dist/rxjs.cjs +0 -63
  62. package/dist/rxjs.d.cts +0 -23
  63. package/dist/setup.cjs +0 -122
  64. package/dist/setup.d.cts +0 -90
  65. package/dist/svelte.cjs +0 -939
  66. package/dist/svelte.d.cts +0 -6
  67. package/dist/types-hTDGBx8V.d.cts +0 -140
  68. package/dist/vue.cjs +0 -945
  69. package/dist/vue.d.cts +0 -25
package/AGENTS.md ADDED
@@ -0,0 +1,707 @@
1
+ # vitest-auto-spy — instructions for AI coding agents
2
+
3
+ You are looking at the agent-facing reference for **`vitest-auto-spy`**: typed test spies generated
4
+ from a class, a type, or nothing at all, on Vitest / `bun:test` / `node:test`.
5
+
6
+ This file is written for an agent **using** the library in someone's test suite. It is shipped
7
+ inside the npm package, so it is readable with no network:
8
+
9
+ ```
10
+ node_modules/vitest-auto-spy/AGENTS.md
11
+ ```
12
+
13
+ Working on the library's own source instead? Read `CONTRIBUTING.md` in the repository.
14
+
15
+ | Resource | Where |
16
+ | ----------------------- | -------------------------------------------------------------------- |
17
+ | Spec patterns at scale | <https://asdalexey.github.io/vitest-auto-spy/recipes> |
18
+ | Docs index for LLMs | <https://asdalexey.github.io/vitest-auto-spy/llms.txt> |
19
+ | Entire docs as one file | <https://asdalexey.github.io/vitest-auto-spy/llms-full.txt> |
20
+ | Human docs | <https://asdalexey.github.io/vitest-auto-spy/> |
21
+ | Source | <https://github.com/ASDAlexey/vitest-auto-spy> |
22
+ | Types | `node_modules/vitest-auto-spy/dist/index.d.ts` (and one per subpath) |
23
+
24
+ **Read `dist/*.d.ts` before inventing a call.** Every export is typed and documented there, and the
25
+ type is the authority when this file and the code disagree.
26
+
27
+ ---
28
+
29
+ ## 1. Pick the entry point first
30
+
31
+ Each entry registers its mock adapter **on import**. Importing the wrong one leaves the wrong
32
+ adapter installed and spies fail at runtime.
33
+
34
+ | Runner / framework | Import from |
35
+ | --------------------- | ------------------------------- |
36
+ | Vitest (default) | `vitest-auto-spy` |
37
+ | `bun test` | `vitest-auto-spy/bun` |
38
+ | `bun test` + Angular | `vitest-auto-spy/bun-angular` |
39
+ | `node --test` | `vitest-auto-spy/node` |
40
+ | Angular + Vitest | `vitest-auto-spy/angular` |
41
+ | NestJS | `vitest-auto-spy/nestjs` |
42
+ | React | `vitest-auto-spy/react` |
43
+ | Vue / Pinia | `vitest-auto-spy/vue` |
44
+ | Svelte | `vitest-auto-spy/svelte` |
45
+
46
+ Three add-ons, orthogonal to the runner:
47
+
48
+ | Add-on | Import | Needed for |
49
+ | ------------------------------- | ------------------------------- | ------------------------------------------------------- |
50
+ | Observable spies | `import 'vitest-auto-spy/rxjs'` | `nextWith` & friends. **Side-effect import, once.** |
51
+ | Console spies | `vitest-auto-spy/console` | silent typed spies over the global `console` |
52
+ | Setup helpers | `vitest-auto-spy/setup` | `setupAutoSpy()`, `setupFakeTimers()` |
53
+
54
+ The package is **ESM**. Only `vitest-auto-spy/node` and `vitest-auto-spy/eslint-plugin` also ship a
55
+ CommonJS build; every other subpath is ESM-only (a `require()` of a Vitest-backed entry always threw —
56
+ Vitest refuses to be required).
57
+
58
+ ---
59
+
60
+ ## 2. Pick the factory
61
+
62
+ ```
63
+ Do you have a real class at runtime?
64
+ ├── yes → createSpyFromClass(Class, config?) → Spy<T>
65
+ └── no → Is the double CALLED by the code under test?
66
+ ├── yes, and calls go one level deep → createAutoMock<T>(overrides?) → Spy<T>
67
+ ├── yes, and calls chain (a.b.c()) → mockDeep<T>(overrides?) → DeepMockProxy<T>
68
+ └── no, it is only READ (DTO, config, route snapshot)
69
+ → createMock<T>(partial?) → T (no spies)
70
+
71
+ One standalone function? → createFunctionSpy<Fn>('name')
72
+ Code under test does `new Foo()`? → createSpyClass(Foo) (a vi.fn() rejects `new`)
73
+ ```
74
+
75
+ `createMock<T>()` is the one to reach for on data shapes — it returns a plain `T`, so it satisfies a
76
+ `no-type-assertion` lint rule without an `eslint-disable` on every fixture.
77
+
78
+ ---
79
+
80
+ ### Cost, so it stops being a question
81
+
82
+ Building a spy is not a thing to optimise. Measured on a ten-method class:
83
+ `provideAutoSpy` ~8 µs, `createSpyFromClass` ~29 µs, `createAutoMock` ~33 µs, a `calledWith`
84
+ lookup ~0.7 µs. Five providers across two thousand tests is under a tenth of a second for the
85
+ whole run. Call the factory in `beforeEach` and look at `TestBed` instead — that is where a slow
86
+ spec spends its time.
87
+
88
+ `provideAutoSpy` is the fastest of the three because it defaults to `lazySpies: true`. The two
89
+ settings that do cost: `{ lazySpies: false }` gives that up, and `autoSpyAccessors: true` walks
90
+ the prototype chain uncached on every call — name the accessors instead.
91
+
92
+ ---
93
+ ## 3. The 90% recipe
94
+
95
+ Measured across a ~370-file Angular suite: `provideAutoSpy` appears in 371 files, `injectSpy` in
96
+ 308, `mockReadonlyProp` in 127, `instanceMethodsToSpyOn` in 103, `observablePropsToSpyOn` in 79 —
97
+ and bare `createSpyFromClass` in only 41. **In an Angular app the spy almost always arrives through
98
+ DI.** Write that shape first.
99
+
100
+ ```ts
101
+ import { signal } from '@angular/core';
102
+ import { TestBed } from '@angular/core/testing';
103
+ import { of } from 'rxjs';
104
+ import { injectSpy, mockReadonlyProp, provideAutoSpy, type Spy } from 'vitest-auto-spy/angular';
105
+
106
+ describe('TaskService', () => {
107
+ let projects: Spy<ProjectStore>;
108
+ let feed: Spy<FeedService>;
109
+ let service: TaskService;
110
+
111
+ beforeEach(() => {
112
+ TestBed.configureTestingModule({
113
+ providers: [
114
+ provideAutoSpy(NotificationService), // plain service — nothing to configure
115
+ provideAutoSpy(ProjectStore, { instanceMethodsToSpyOn: ['current', 'isEmpty'] }), // signals
116
+ provideAutoSpy(FeedService, { observablePropsToSpyOn: ['connected$'] }), // Observable props
117
+ ],
118
+ });
119
+
120
+ projects = injectSpy(ProjectStore);
121
+ feed = injectSpy(FeedService);
122
+
123
+ feed.connected$.nextWith(true); // seed the defaults every test needs, once
124
+ projects.save.mockReturnValue(of(true));
125
+
126
+ service = TestBed.inject(TaskService);
127
+ });
128
+
129
+ it('saves through the store', () => {
130
+ service.save(task);
131
+
132
+ expect(projects.save).toHaveBeenCalledWith(task);
133
+ });
134
+ });
135
+ ```
136
+
137
+ Outside Angular, or for a class you construct yourself:
138
+
139
+ ```ts
140
+ import { createSpyFromClass, type Spy } from 'vitest-auto-spy';
141
+
142
+ let users: Spy<UserService>;
143
+
144
+ beforeEach(() => {
145
+ users = createSpyFromClass(UserService);
146
+ });
147
+
148
+ it('loads', async () => {
149
+ users.load.calledWith(1).resolveWith({ id: 1 });
150
+
151
+ await expect(subject.open(1)).resolves.toEqual({ id: 1 });
152
+ expect(users.load).toHaveBeenCalledWith(1);
153
+ });
154
+ ```
155
+
156
+ Four conventions that carry most of the value:
157
+
158
+ 1. **One `configureTestingModule` per `describe`** — reconfiguring per `it()` pays for module
159
+ compilation on every test, the largest avoidable cost in an Angular suite.
160
+ 2. **Declare each spy as `Spy<T>`, never as `T`** (§6).
161
+ 3. **Seed defaults in `beforeEach`, override in the test.** An unconfigured method returns
162
+ `undefined`, and the failure surfaces far from its cause.
163
+ 4. **`provideAutoSpy` is lazy by default** — listing a wide service costs nothing for the methods a
164
+ test never touches.
165
+
166
+ ---
167
+
168
+ ## 4. Helpers a spied method earns from its return type
169
+
170
+ Every spied method is a real runner mock, so `mockReturnValue`, `mockImplementation`,
171
+ `toHaveBeenCalledWith` and the rest all work as usual. On top of that:
172
+
173
+ | Return type | Helpers added |
174
+ | -------------- | ---------------------------------------------------------------------------------------------------------- |
175
+ | anything | `calledWith(...args)` → `.mockReturnValue(v)` / `.returnValue(v)`, `mustBeCalledWith(...args)` → same |
176
+ | `Promise<T>` | `resolveWith(v)`, `rejectWith(v)`, `resolveWithPerCall([{ value }, …])` |
177
+ | `Observable<T>`| `nextWith(v)`, `nextOneTimeWith(v)`, `nextWithValues(configs)`, `nextWithPerCall(configs)`, `throwWith(v)`, `complete()`, `returnSubject()` |
178
+
179
+ `Observable` **properties** (not just methods) get the same helpers — list them in
180
+ `observablePropsToSpyOn`.
181
+
182
+ ```ts
183
+ // argument dispatch — other arguments return undefined
184
+ users.getName.calledWith(1).mockReturnValue('Ada');
185
+ // argument enforcement — other arguments throw
186
+ users.getName.mustBeCalledWith(1).mockReturnValue('Ada');
187
+ // asymmetric matchers work in both
188
+ users.save.calledWith(expect.objectContaining({ id: 1 })).mockReturnValue(true);
189
+
190
+ // promises
191
+ users.load.resolveWith({ id: 1 });
192
+ users.load.rejectWith('FAKE ERROR');
193
+ users.load.resolveWithPerCall([{ value: a }, { value: b }]);
194
+ expect(users.load.mock.settledResults).toEqual([{ type: 'fulfilled', value: { id: 1 } }]);
195
+
196
+ // observables — requires `import 'vitest-auto-spy/rxjs'` once
197
+ feed.items$.nextWith([item]); // emit, stream stays open
198
+ feed.items$.nextOneTimeWith([item]); // emit once, then complete
199
+ feed.items$.nextWithValues([{ value: a }, { value: b, delay: 100 }, { complete: true }]);
200
+ const [first$, second$] = feed.watch$.nextWithPerCall([{ value: 'a' }, { value: 'b', doNotComplete: true }]);
201
+ feed.items$.throwWith('FAKE ERROR');
202
+ const subject = feed.items$.returnSubject(); // ReplaySubject, for anything the helpers miss
203
+ ```
204
+
205
+ `mock.settledResults` is native on Vitest and polyfilled on Bun / `node:test`, so it is identical on
206
+ all three. Entries are `{ type: 'fulfilled' | 'incomplete' | 'rejected', value }`.
207
+
208
+ ---
209
+
210
+ ## 5. `createSpyFromClass` configuration
211
+
212
+ ```ts
213
+ createSpyFromClass(MyService); // every method on the prototype chain
214
+ createSpyFromClass(MyService, ['reload', 'count']); // those two ADDED to the discovered ones
215
+ createSpyFromClass(MyService, {
216
+ methodsToSpyOn: ['reload'], // ADDS (jest-auto-spies semantics)
217
+ onlyMethodsToSpyOn: ['getName'], // RESTRICTS — skips prototype discovery
218
+ instanceMethodsToSpyOn: ['reload'], // ADDS; same behaviour, clearer name
219
+ observablePropsToSpyOn: ['products$'],
220
+ gettersToSpyOn: ['userName'],
221
+ settersToSpyOn: ['userName'],
222
+ autoSpyAccessors: true, // discover every accessor on the prototype chain
223
+ lazySpies: true, // build each method spy on first access
224
+ });
225
+ ```
226
+
227
+ | Key | Semantics |
228
+ | ------------------------ | -------------------------------------------------------------------------- |
229
+ | `methodsToSpyOn` | **Additive**, as in `jest-auto-spies`. Same behaviour as `instanceMethodsToSpyOn`. |
230
+ | `onlyMethodsToSpyOn` | **Exhaustive whitelist.** Skips discovery; anything not listed is absent. |
231
+ | `instanceMethodsToSpyOn` | **Additive.** The name to prefer in new code (see below). |
232
+ | `autoSpyAccessors` | Merged with the explicit getter/setter lists. |
233
+ | `lazySpies` | Behaviour-identical; only changes *when* each spy is built. |
234
+
235
+ **`instanceMethodsToSpyOn` is not an edge case — it is a top-5 option** (103 of ~370 spec files in
236
+ the reference suite). Method discovery walks the *prototype chain*; a callable assigned to an
237
+ **instance field** is invisible to it:
238
+
239
+ - an Angular `signal()` / `computed()` field — the dominant case in a signals codebase
240
+ - an arrow-function property — `readonly reload = (): void => {}`
241
+ - anything on an ngrx `signalStore()`, which puts **everything** on the instance
242
+
243
+ ```ts
244
+ createSpyFromClass(TaskStore, { instanceMethodsToSpyOn: ['count', 'reload'] });
245
+ provideAutoSpy(ProjectStore, { instanceMethodsToSpyOn: ['current', 'isEmpty'] });
246
+ ```
247
+
248
+ For an ngrx `signalStore()`, prefer `createAutoMock<T>()` over listing every member: it mocks from
249
+ the type, needs no prototype, and the list cannot fall behind the store.
250
+
251
+ The symptom of getting this wrong is **a spy that is never called and no warning at all**: the
252
+ additive lists exist precisely to name things the prototype does not have, so a typo in one cannot
253
+ be told apart from an instance field and stays silent.
254
+
255
+ Only `onlyMethodsToSpyOn` warns, because only a restricting list can be silently destructive — a
256
+ misspelling there leaves the real method unspied, and the code under test then calls something that
257
+ is not there:
258
+ `[vitest-auto-spy] createSpyFromClass(X): onlyMethodsToSpyOn names method(s) that are not on the class prototype: …`.
259
+
260
+ Also true, and worth not re-deriving:
261
+
262
+ - **Inherited methods are spied** — discovery walks the whole chain (`Object.prototype` excluded).
263
+ - **Constructor bodies never run.** The spy is assembled from the prototype.
264
+ - **Abstract classes work at runtime** but TypeScript refuses them as `ClassType<T>`. Pass a
265
+ concrete subclass and keep the abstract class as the DI token.
266
+
267
+ ### Getters and setters live in `accessorSpies`
268
+
269
+ ```ts
270
+ const settings = createSpyFromClass(SettingsService, { gettersToSpyOn: ['theme'], settersToSpyOn: ['theme'] });
271
+
272
+ settings.accessorSpies.getters.theme.mockReturnValue('dark');
273
+ expect(settings.theme).toBe('dark'); // the property itself stays typed as `string`
274
+
275
+ settings.theme = 'light';
276
+ expect(settings.accessorSpies.setters.theme).toHaveBeenCalledWith('light');
277
+ ```
278
+
279
+ ---
280
+
281
+ ## 6. `Spy<T>` is not assignable to `T` — this is intentional
282
+
283
+ `Spy<T>` is a **mapped type**, so it drops `#private` and `private` members.
284
+
285
+ ```ts
286
+ let users: Spy<UserService> = createSpyFromClass(UserService); // ✅
287
+ let users: UserService = createSpyFromClass(UserService); // ❌ private members missing
288
+ ```
289
+
290
+ Do **not** patch this with `as any`, `as unknown as T`, or `@ts-expect-error`. Use the named views:
291
+
292
+ ```ts
293
+ import { asInstance, asSpy } from 'vitest-auto-spy';
294
+
295
+ asInstance(spy); // Spy<T> → T, for an API typed against the class
296
+ asSpy(TestBed.inject(CartService)); // T → Spy<T>, for the helpers
297
+ ```
298
+
299
+ Both are the same object at runtime. `injectSpy(X)` already returns `Spy<X>`.
300
+
301
+ ---
302
+
303
+ ## 7. Resetting
304
+
305
+ ```ts
306
+ import { clearAutoSpy, resetAutoSpy } from 'vitest-auto-spy';
307
+
308
+ clearAutoSpy(service); // recorded calls only — configured returns survive
309
+ resetAutoSpy(service); // calls AND configuration (calledWith / resolveWith / mockReturnValue)
310
+ ```
311
+
312
+ Both cover method spies **and** accessor spies, on `createSpyFromClass` spies and `createAutoMock`
313
+ proxies alike. Reach for these instead of looping over methods calling `mockClear` by hand.
314
+
315
+ ---
316
+
317
+ ## 8. Observable assertions (core entry — no rxjs needed)
318
+
319
+ `expect()` inside a `subscribe()` callback is the classic green-but-empty test: if the stream never
320
+ emits, the callback never runs and nothing is asserted. Invert it — **the assertion is the `await`**:
321
+
322
+ ```ts
323
+ import { expectEmission, expectEmissions, expectNoEmission } from 'vitest-auto-spy';
324
+
325
+ await expect(expectEmission(component.visible$)).resolves.toEqual([task]);
326
+ await expect(expectEmissions(source$, 3)).resolves.toEqual([1, 2, 3]);
327
+ await expectNoEmission(source$, { timeout: 50 });
328
+ ```
329
+
330
+ Options: `{ timeout, label }`. `timeout` defaults to `1000` ms (`0` for `expectNoEmission`, and `0`
331
+ disables the watchdog — use it under fake timers). The source is duck-typed, so rxjs `Observable`s,
332
+ `Subject`s, Angular `toObservable()` results and hand-rolled subscribables all work.
333
+
334
+ ---
335
+
336
+ ## 9. Patching properties (and putting them back)
337
+
338
+ ```ts
339
+ import { mockAccessorsProp, mockReadonlyProp, mockReadonlyPropGetter, mockValueProp, restoreMockedProps } from 'vitest-auto-spy';
340
+
341
+ mockReadonlyProp(service, 'isReady', true); // static value, signals included
342
+ mockReadonlyPropGetter(service, 'label', () => 'A'); // dynamic getter
343
+ mockValueProp(service, 'retries', 3); // plain writable value
344
+ mockAccessorsProp(service, 'theme'); // spied get + set
345
+
346
+ restoreMockedProps(); // put every patch back; each helper also returns its own undo
347
+ ```
348
+
349
+ `vi.restoreAllMocks()` does **not** undo these — it knows about spies, not about redefined
350
+ properties. Never use bare `Object.defineProperty` in a spec: nothing restores the original
351
+ descriptor, and under `isolate: false` the patch leaks into the next file.
352
+
353
+ ---
354
+
355
+ ## 10. Setup file
356
+
357
+ ```ts
358
+ // vitest.setup.ts
359
+ import 'vitest-auto-spy/rxjs'; // once — enables observable spies everywhere
360
+ import { setupAutoSpy } from 'vitest-auto-spy/setup';
361
+
362
+ setupAutoSpy(); // { duplicateCopies: 'throw', restoreProps: true, restoreMocks: false }
363
+ ```
364
+
365
+ `setupAutoSpy()` does three things: `restoreMockedProps()` in a global `afterEach`, a duplicate-install
366
+ check that fails the run, and (opt-in) `vi.restoreAllMocks()`. Turn on `restoreMocks: true` when the
367
+ suite runs with `isolate: false`.
368
+
369
+ **The one that only bites at scale:** with `isolate: false`, a `setTimeout` or
370
+ `requestAnimationFrame` a component schedules and never clears keeps running after its file is done,
371
+ and fires while the **next** file is mid-test. It is reported against that innocent file, as
372
+ `Schedulers cannot synchronously execute watches while scheduling`, `signal read during notification
373
+ phase`, or an unhandled rejection naming a component the failing file never imported. If you see any
374
+ of those, suspect the previous file, not the one that failed:
375
+
376
+ ```ts
377
+ setupAutoSpy({ strayTimers: true }); // wrap the schedulers, sweep the survivors in afterAll
378
+ ```
379
+
380
+ The pieces are exported too — `trackStrayTimers()` (idempotent, returns the undo),
381
+ `cancelStrayTimers()` (returns how many it cancelled) and `countStrayTimers()`, all from
382
+ `vitest-auto-spy/setup`. Use `expect(countStrayTimers()).toBe(0)` in an `afterEach` to make a leak
383
+ fail rather than be tidied away.
384
+
385
+ Two more switches, both about the environment rather than the spies:
386
+
387
+ ```ts
388
+ setupAutoSpy({ blockNetwork: true }); // reject every fetch, naming what was requested
389
+ ```
390
+
391
+ Only relevant under happy-dom, which — unlike jsdom — implements `fetch`. A component that pulls a
392
+ remote asset then really fetches it; nothing asserts on the response, so the tests pass, and the
393
+ aborts at teardown fail the run with **no test named**. If a green run exits 1 with
394
+ `DOMException [AbortError]`, this is it.
395
+
396
+ `restoreTimerGlobals` is on by default and needs no thought unless you turn it off: uninstalling
397
+ fake timers under happy-dom **deletes** `Date` instead of restoring it (the global is inherited from
398
+ the realm, not owned by `globalThis`), and with `isolate: false` the next file dies inside Vitest's
399
+ own `useFakeTimers` with `Cannot read properties of undefined (reading 'now')`. If you see that,
400
+ the file in the stack is not the cause.
401
+
402
+
403
+ Fake timers:
404
+
405
+ ```ts
406
+ import { advanceTimers, setupFakeTimers } from 'vitest-auto-spy/setup';
407
+
408
+ setupFakeTimers(); // install + restore, paired
409
+ await advanceTimers(5_000); // advance AND drain the microtasks a bare advanceTimersByTime leaves
410
+ ```
411
+
412
+ ---
413
+
414
+ ## 11. Angular
415
+
416
+ ```ts
417
+ import { injectSpy, provideAutoSpy } from 'vitest-auto-spy/angular';
418
+
419
+ TestBed.configureTestingModule({
420
+ providers: [provideAutoSpy(MyService), provideAutoSpy(ApiService, { methodsToSpyOn: ['get'] })],
421
+ });
422
+
423
+ const myService = injectSpy(MyService); // Spy<MyService>
424
+ ```
425
+
426
+ `provideAutoSpy` defaults to `lazySpies: true` (the plain `createSpyFromClass` does not). Pass
427
+ `{ lazySpies: false }` to opt out. The spies never touch `NgZone`, so they work zoneless and with
428
+ zone.js alike.
429
+
430
+ ### Signals — which helper depends on whose signal it is
431
+
432
+ ```ts
433
+ // a DEPENDENCY's signal — name it, then configure the mock like any other
434
+ provideAutoSpy(ProjectStore, { instanceMethodsToSpyOn: ['current'] });
435
+ injectSpy(ProjectStore).current.mockReturnValue({ id: 1 });
436
+
437
+ // the CLASS UNDER TEST's own signal / computed / input — replace the field with a REAL signal
438
+ mockReadonlyProp(component, 'selected', signal(true));
439
+ mockReadonlyProp(component, 'items', signal([]));
440
+ mockReadonlyProp(component, 'host', signal({ nativeElement: element }));
441
+
442
+ // a value that changes during the test — keep the signal, set it
443
+ const selected = signal(false);
444
+
445
+ mockReadonlyProp(component, 'selected', selected);
446
+ selected.set(true); // every computed reading it updates
447
+ ```
448
+
449
+ Pass a real `signal()`, not a `vi.fn()` returning a value — anything `computed()` downstream has to
450
+ recompute, and only a real signal notifies it.
451
+
452
+ `mockSignalProp` is that pair in one call, and hands back the writable half:
453
+
454
+ ```ts
455
+ import { mockSignalProp } from 'vitest-auto-spy/angular';
456
+
457
+ const selected = mockSignalProp(component, 'selected', false);
458
+
459
+ selected.set(true); // every computed reading it updates
460
+ ```
461
+
462
+ Use it whenever the value has to change during the test. `mockReadonlyProp` stays right when the
463
+ value is fixed for the whole test and you never need the handle.
464
+
465
+
466
+
467
+
468
+ ### Observers the component constructs itself
469
+
470
+ Do not assign `globalThis.IntersectionObserver` by hand: it stays assigned, and under
471
+ `isolate: false` the next file inherits it.
472
+
473
+ ```ts
474
+ import { intersectionEntry, stubIntersectionObserver } from 'vitest-auto-spy';
475
+
476
+ const observers = stubIntersectionObserver(); // also stubResizeObserver / stubMutationObserver
477
+
478
+ fixture.detectChanges(); // the component constructs it
479
+
480
+ observers.last.emit([intersectionEntry(element, true)]); // one batch, as the browser delivers it
481
+ await fixture.whenStable();
482
+
483
+ expect(observers.last.disconnected).toBe(true); // after the component is destroyed
484
+ ```
485
+
486
+ `restoreMockedProps()` puts the real constructor back, so `setupAutoSpy()` covers the teardown.
487
+ `observers.last` throws if the code under test constructed nothing — render first, and install the
488
+ stub before the construction, not after.
489
+ ### `injectSpy` cannot reach a component-level provider
490
+
491
+ `injectSpy(X)` reads the **global** `TestBed` injector. A provider declared on the component
492
+ (`@Component({ providers: [...] })`) lives in the element injector, which `TestBed.inject` never
493
+ sees. Go through the fixture and re-view the result:
494
+
495
+ ```ts
496
+ const player = asSpy(fixture.debugElement.injector.get(PlayerService));
497
+
498
+ player.play.mockReturnValue(true);
499
+ ```
500
+
501
+ ### Never mock `@angular/core` to control an `effect()`
502
+
503
+ Under the Angular unit-test builder the specs are bundled and `@angular/core` sits in a shared
504
+ chunk, so `vi.mock('@angular/core', …)` re-enters a chunk that is still initialising and fails with
505
+ `Cannot access '__vi_import_N__' before initialization`. The same applies to any module those shared
506
+ chunks depend on, and to `vi.mock()` with a relative path (`./`, `../`), which has no module
507
+ boundary left to replace once bundled.
508
+
509
+ Assert the effect's **result** instead — set the signals it reads, let it run, check what it
510
+ produced:
511
+
512
+ ```ts
513
+ mockReadonlyProp(component, 'state', signal(State.Selected));
514
+
515
+ await stable(fixture);
516
+
517
+ expect(component.icon()).toBe('favouritesFilled');
518
+ ```
519
+
520
+ When the effect will never become dirty on its own — because its trigger is now a static signal —
521
+ run that one effect directly:
522
+
523
+ ```ts
524
+ import { runEffect } from 'vitest-auto-spy/angular';
525
+
526
+ runEffect(component.highlightEffect); // runs the body now, with the current signal values
527
+ ```
528
+
529
+ `flushEffects()` runs everything currently dirty; `runEffect(ref)` runs one specific effect
530
+ regardless. Prefer asserting the result where practical — `runEffect` reads Angular's reactive node,
531
+ and throws with instructions if a future version moves it.
532
+
533
+ ### ngrx `rxMethod`
534
+
535
+ An `rxMethod` is a function with a `destroy` property. A bare mock has no `destroy`, so the
536
+ component's cleanup throws:
537
+
538
+ ```ts
539
+ const load = Object.assign(vi.fn(), { destroy: vi.fn() });
540
+ ```
541
+
542
+ ```ts
543
+ // shallow rendering — configureTestingModule + NO_ERRORS_SCHEMA + overrideComponent, in one call
544
+ const { fixture, component } = renderShallow(TaskListComponent, {
545
+ providers: [provideAutoSpy(TaskService)],
546
+ inputs: { projectId: 42 }, // signal inputs take the VALUE, not the signal
547
+ });
548
+ // other options: imports, keepTemplate, keepChildren, template, beforeCreate, detectChanges
549
+
550
+ // build a class through DI, every unprovided token auto-spied
551
+ const { instance, spies } = createWithAutoSpies(CartService, {
552
+ providers: [{ provide: TaxService, useValue: realTax }], // explicit providers win
553
+ });
554
+ spies.get(PricingService).total.mockReturnValue(100);
555
+ // NOTE: Injector.create() — it does NOT accept EnvironmentProviders (provideHttpClient() etc.)
556
+
557
+ // zoneless waiting
558
+ await stable(fixture); // flush effects, then await the fixture
559
+ flushEffects(); // the no-fixture half: services, stores, runInInjectionContext
560
+
561
+ // signal assertions
562
+ registerSignalMatchers(); // once, in the setup file
563
+ expect(component.total).toHaveSignalValue(3);
564
+ ```
565
+
566
+ Two zoneless traps:
567
+
568
+ - `fixture.detectChanges()` runs **one** change-detection pass and does **not** flush pending
569
+ effects. Asserting right after it reads state that has not finished computing. Use `await stable(fixture)`.
570
+ - `expect(someSignal).toBeTruthy()` passes for **every** signal ever created — a signal is a
571
+ function. Use `toHaveSignalValue`, which also rejects the missing-parentheses mistake.
572
+
573
+ Per-file timing, to find which specs actually pay for `TestBed`:
574
+
575
+ ```ts
576
+ import { enableTestBedDiagnostics } from 'vitest-auto-spy/angular';
577
+
578
+ if (process.env['SPEC_TIMING']) { enableTestBedDiagnostics(); }
579
+ ```
580
+
581
+ ### Angular under `bun test`
582
+
583
+ `vitest-auto-spy/bun-angular` is a **preload**, not a normal import — it installs a DOM, inlines
584
+ `templateUrl` / `styleUrls` through a `Bun.plugin` hook and boots a zoneless TestBed:
585
+
586
+ ```toml
587
+ # bunfig.toml
588
+ [test]
589
+ preload = ["vitest-auto-spy/bun-angular"]
590
+ ```
591
+
592
+ It re-exports everything in this section except `registerSignalMatchers` and the TestBed
593
+ diagnostics, which need the runner's `expect.extend` and suite-level hooks.
594
+
595
+ ---
596
+
597
+ ## 12. Other adapters
598
+
599
+ ```ts
600
+ // NestJS
601
+ import { injectSpy, provideAutoSpy } from 'vitest-auto-spy/nestjs';
602
+ const moduleRef = await Test.createTestingModule({ providers: [provideAutoSpy(MyService)] }).compile();
603
+ const spy = injectSpy(moduleRef, MyService);
604
+
605
+ // Vue / Pinia — provideAutoSpy(token, Class, methodsOrConfig?) returns a `global.provide` map
606
+ import { provideAutoSpy } from 'vitest-auto-spy/vue';
607
+ const provide = provideAutoSpy(UserServiceKey, UserService);
608
+ provide[UserServiceKey].getName.mockReturnValue('Ada');
609
+ mount(Greeting, { global: { provide } });
610
+ // a setup-store (`defineStore('x', () => …)`) is not a class — use createAutoMock<T>() there
611
+
612
+ // React / Svelte — the core API, re-exported with the right adapter registered
613
+ import { createSpyFromClass } from 'vitest-auto-spy/react';
614
+ ```
615
+
616
+ Console spies — importing the entry replaces `console.debug` / `error` / `info` / `log` / `time` /
617
+ `timeEnd` / `trace` / `warn` with silent typed spies, named `console<Method>Spy`:
618
+
619
+ ```ts
620
+ import { consoleInfoSpy, consoleWarnSpy, resetConsoleSpies, restoreConsole } from 'vitest-auto-spy/console';
621
+
622
+ expect(consoleInfoSpy).toHaveBeenCalledWith('done'); // the output is silenced, not printed
623
+ ```
624
+
625
+ Import your runtime entry (`…/bun`, `…/node`) **before** `…/console`, or it registers the Vitest
626
+ adapter. Prefer not to touch the real global? `createAutoMock<Console>()` gives a detached one.
627
+
628
+ ---
629
+
630
+ ## 13. ESLint plugin (flat config only)
631
+
632
+ ```js
633
+ import autoSpy from 'vitest-auto-spy/eslint-plugin';
634
+
635
+ export default [{ files: ['**/*.spec.ts'], ...autoSpy.configs.recommended }];
636
+ ```
637
+
638
+ | Rule | Level | Flags |
639
+ | ------------------------------ | ------- | -------------------------------------------------------------------- |
640
+ | `no-expect-in-subscribe` | `error` | `expect()` inside `subscribe()` → `expectEmission` |
641
+ | `no-object-define-property` | `error` | `Object.defineProperty` in a spec → `mockReadonlyProp` / `mockValueProp` |
642
+ | `prefer-provide-auto-spy` | `warn` | `{ provide: X, useValue: { a: vi.fn() } }` → `provideAutoSpy(X)` |
643
+ | `prefer-create-spy-from-class` | `warn` | an object literal of 2+ `vi.fn()`s → `createSpyFromClass` |
644
+ | `prefer-inject-spy` | `warn` | `vi.spyOn(TestBed.inject(X), 'm')` → `injectSpy(X)` |
645
+
646
+ The legacy `.eslintrc` `plugins: []` form cannot work — it resolves names to `eslint-plugin-*`
647
+ packages, which a subpath export can never be.
648
+
649
+ ---
650
+
651
+ ## 14. Error → fix
652
+
653
+ | Message contains | Cause | Fix |
654
+ | ------------------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------- |
655
+ | `No mock adapter registered` | no runtime entry was imported, or the wrong one | import `vitest-auto-spy` (Vitest) / `…/bun` / `…/node` once before creating spies |
656
+ | `Observable spies require rxjs` | the rxjs layer was never loaded | `import 'vitest-auto-spy/rxjs';` once, in the setup file |
657
+ | `requested method(s) not found on the class prototype` | typo, or an instance-field callable | fix the name, or move it to `instanceMethodsToSpyOn` |
658
+ | `was configured with 'mustBeCalledWith'` | the code called the spy with other arguments | that is the assertion firing — fix the code, or relax to `calledWith` |
659
+ | `advanceTimers() requires fake timers` | no fake timers installed | `setupFakeTimers()` or `vi.useFakeTimers()` first |
660
+ | `no DOM could be installed` | `bun-angular` preload with no DOM package | `bun add -d @happy-dom/global-registrator` (or `jsdom`) |
661
+ | `cannot read "…" referenced by …` | a `templateUrl` / `styleUrls` path does not resolve | fix the path, relative to the component file |
662
+ | duplicate-copy report from `setupAutoSpy()` | two installs, or one loaded as both ESM and CJS | dedupe the dependency; `setupAutoSpy({ duplicateCopies: 'warn' })` to downgrade |
663
+ | `Type 'Spy<T>' is not assignable to type 'T'` | `Spy<T>` drops private members — by design | declare as `Spy<T>`, or use `asInstance()` / `asSpy()` (§6) |
664
+ | a spy is never called, no warning | the method is an instance field, not on the prototype | `instanceMethodsToSpyOn`, or `createAutoMock<T>()` |
665
+ | `Cannot access '__vi_import_N__' before initialization` | `vi.mock()` on `@angular/core` or a relative path | you cannot mock it — the specs are bundled. Assert the result instead |
666
+ | `Schedulers cannot synchronously execute watches while scheduling` | a timer from a **previous** file, under `isolate: false` | track and cancel pending timers/frames in the setup file (§10) |
667
+ | `signal read during notification phase` | same — a stray `requestAnimationFrame` callback | same |
668
+ | `… .destroy is not a function` | an ngrx `rxMethod` replaced with a bare mock | `Object.assign(vi.fn(), { destroy: vi.fn() })` |
669
+ | `NullInjectorError` for a service you did provide | it is a component-level provider, not a module one | `asSpy(fixture.debugElement.injector.get(X))`, not `injectSpy(X)` |
670
+ | `runEffect(): … not an EffectRef returned by effect()` | passed the callback, a signal, or an unassigned field | pass what `effect()` returned; a field may need its lifecycle hook to run first |
671
+
672
+ ---
673
+
674
+ ## 15. Do not write this
675
+
676
+ | ❌ | ✅ |
677
+ | -------------------------------------------------------- | --------------------------------------------------------- |
678
+ | `import … from 'jest-auto-spies'` | `import … from 'vitest-auto-spy'` |
679
+ | `vitest-auto-spy` inside a `bun test` file | `vitest-auto-spy/bun` |
680
+ | `let s: MyService = createSpyFromClass(MyService)` | `let s: Spy<MyService> = …` |
681
+ | `createSpyFromClass(X) as unknown as X` | `asInstance(createSpyFromClass(X))` |
682
+ | `{ provide: X, useValue: { a: vi.fn(), b: vi.fn() } }` | `provideAutoSpy(X)` |
683
+ | `vi.spyOn(TestBed.inject(X), 'method')` | `injectSpy(X).method` |
684
+ | `Object.defineProperty(service, 'ready', { value: true })`| `mockReadonlyProp(service, 'ready', true)` |
685
+ | `source$.subscribe(v => expect(v).toBe(1))` | `await expect(expectEmission(source$)).resolves.toBe(1)` |
686
+ | `expect(component.total).toBeTruthy()` (a signal) | `expect(component.total).toHaveSignalValue(3)` |
687
+ | `fixture.detectChanges()` then assert signal state | `await stable(fixture)` then assert |
688
+ | `onlyMethodsToSpyOn: [...]` "to add a method" | omit it, or use `instanceMethodsToSpyOn` |
689
+ | a `vi.fn()` the code calls with `new` | `createSpyClass(Foo)` |
690
+ | `configureTestingModule` inside every `it()` | one per `describe` |
691
+ | `vi.mock('@angular/core')` to neutralise `effect()` | set the signals, `await stable(fixture)`, assert the result |
692
+ | a second `vi.spyOn(console, 'error')` | `consoleErrorSpy` from `vitest-auto-spy/console` |
693
+ | `mockReadonlyProp(c, 'items', vi.fn(() => []))` | `mockReadonlyProp(c, 'items', signal([]))` — a real signal |
694
+
695
+ ---
696
+
697
+ ## 16. Before you report success
698
+
699
+ Run what the project actually has — check its `package.json` first.
700
+
701
+ ```bash
702
+ npx vitest run path/to/file.spec.ts # or: bun test path/to/file.test.ts
703
+ npx tsc --noEmit # Spy<T> mistakes are compile errors, not runtime ones
704
+ ```
705
+
706
+ Type errors matter here more than usual: most of this library's guarantees are type-level, so a
707
+ suite that runs green but does not type-check is not done.