vitest-auto-spy 3.4.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (46) hide show
  1. package/AGENTS.md +689 -37
  2. package/README.md +453 -165
  3. package/dist/angular.d.ts +4 -4
  4. package/dist/angular.js +6 -6
  5. package/dist/bun-angular.d.ts +5 -5
  6. package/dist/bun-angular.js +6 -6
  7. package/dist/bun.d.ts +49 -47
  8. package/dist/bun.js +5 -5
  9. package/dist/{chunk-EYXIU67M.js → chunk-F3NMY5KU.js} +4 -5
  10. package/dist/{chunk-7JTTEZGJ.js → chunk-LSDPJMNS.js} +51 -30
  11. package/dist/{chunk-2R5SDL7C.js → chunk-OEQTH7RA.js} +2 -1
  12. package/dist/chunk-QGZRH5XG.js +200 -0
  13. package/dist/{chunk-IJOUTA65.js → chunk-RJJJTLQ3.js} +217 -15
  14. package/dist/console.d.ts +1 -1
  15. package/dist/console.js +1 -1
  16. package/dist/eslint-plugin.cjs +740 -36
  17. package/dist/eslint-plugin.d.cts +69 -1
  18. package/dist/eslint-plugin.d.ts +69 -1
  19. package/dist/eslint-plugin.js +740 -36
  20. package/dist/expect-emission-RtR1iYgI.d.ts +289 -0
  21. package/dist/index.d.ts +4 -4
  22. package/dist/index.js +5 -5
  23. package/dist/nestjs.d.ts +1 -1
  24. package/dist/nestjs.js +2 -2
  25. package/dist/node.cjs +385 -107
  26. package/dist/node.d.cts +376 -28
  27. package/dist/node.d.ts +4 -4
  28. package/dist/node.js +5 -5
  29. package/dist/{prop-mock-CSREZ983.d.ts → prop-mock-CKFksCvA.d.ts} +15 -10
  30. package/dist/react.d.ts +4 -4
  31. package/dist/react.js +5 -5
  32. package/dist/{run-effect-Ch8q5Uq6.d.ts → run-effect-C_7mFldc.d.ts} +9 -4
  33. package/dist/rxjs.d.ts +2 -2
  34. package/dist/rxjs.js +37 -20
  35. package/dist/setup.d.ts +76 -27
  36. package/dist/setup.js +124 -19
  37. package/dist/svelte.d.ts +4 -4
  38. package/dist/svelte.js +5 -5
  39. package/dist/{types-nH9zvKRV.d.ts → types-dZUFYsox.d.ts} +112 -9
  40. package/dist/vue.d.ts +5 -5
  41. package/dist/vue.js +6 -6
  42. package/package.json +2 -2
  43. package/skills/vitest-auto-spy/SKILL.md +9 -1
  44. package/dist/chunk-SU5RBD7I.js +0 -148
  45. package/dist/expect-emission-CgAS7iiE.d.ts +0 -49
  46. package/dist/{chunk-T5VVDFYD.js → chunk-QGBNXDKU.js} +3 -3
package/AGENTS.md CHANGED
@@ -12,6 +12,12 @@ node_modules/vitest-auto-spy/AGENTS.md
12
12
 
13
13
  Working on the library's own source instead? Read `CONTRIBUTING.md` in the repository.
14
14
 
15
+ Setting this up for a team? The README section
16
+ [Using this library with an AI agent](https://github.com/ASDAlexey/vitest-auto-spy#using-this-library-with-an-ai-agent)
17
+ names the instruction file each agent reads — `AGENTS.md` for OpenAI Codex, Cursor, Copilot and most
18
+ of the field, `CLAUDE.md` for Claude Code and for GLM (z.ai) or Kimi running inside it, `GEMINI.md`
19
+ for Gemini CLI — and gives the two commands that install this pointer for all of them.
20
+
15
21
  | Resource | Where |
16
22
  | ----------------------- | -------------------------------------------------------------------- |
17
23
  | Spec patterns at scale | <https://asdalexey.github.io/vitest-auto-spy/recipes> |
@@ -62,24 +68,176 @@ Vitest refuses to be required).
62
68
  ```
63
69
  Do you have a real class at runtime?
64
70
  ├── yes → createSpyFromClass(Class, config?) → Spy<T>
71
+ │ (an `abstract class` DI token counts — see below)
65
72
  └── 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>
73
+ ├── yes, and it is INJECTED (DI, a field) → createAutoMock<T>(overrides?) → Spy<T>
74
+ ├── yes, and it is an ARGUMENT of the function under test, asserted on
75
+ │ → autoMocked<T>(overrides?) → T & Spy<T>
76
+ ├── yes, and reads chain (a.b.c()) → mockDeep<T>(overrides?) → DeepMockProxy<T>
77
+ ├── yes, and CALLS chain (a.b().c()) → mockDeep<T>({}, { selfReturning: true })
68
78
  └── no, it is only READ (DTO, config, route snapshot)
69
- → createMock<T>(partial?) → T (no spies)
79
+ → createMock<T>(partial?) → T (no spies)
70
80
 
71
81
  One standalone function? → createFunctionSpy<Fn>('name')
72
82
  Code under test does `new Foo()`? → a real class? createSpyClass(Foo)
73
83
  → only a shape? mockConstructor<T>(() => instance)
74
84
  → on a global? stubConstructor(globalThis, 'Image', factory)
75
85
  (a vi.fn() rejects `new` — see §12)
76
- Passed as an argument, not injected, and asserted on?
77
- → autoMocked<T>() (typed `T & Spy<T>`, no asInstance/asSpy)
78
86
  ```
79
87
 
88
+ `createAutoMock` and `autoMocked` build the same object; they differ only in the type you get back,
89
+ and the question that decides it is **how the double travels**. Through DI, it arrives as `Spy<T>`
90
+ and is only ever asserted on — `createAutoMock`. Handed to the function under test as an argument
91
+ (`detectVpnClient(url, logger)`, `applyPreferredTracks(target, …)`, `setLocalConfigEnabled(storage, …)`),
92
+ it has to satisfy `T` at the call site *and* expose the spy helpers at the assertion, and
93
+ `autoMocked<T>()` is that intersection — otherwise every call site needs an `asInstance()` and the
94
+ noise scales with the number of them.
95
+
96
+
80
97
  `createMock<T>()` is the one to reach for on data shapes — it returns a plain `T`, so it satisfies a
81
98
  `no-type-assertion` lint rule without an `eslint-disable` on every fixture.
82
99
 
100
+ **`mockDeep` builds depth on property access, not on calls** — the distinction the tree now spells
101
+ out, and the one that costs an afternoon otherwise. `mock.repo.user.find()` chains because every hop
102
+ but the last is a *read*. A node that is **called** returns what it was configured to return, and by
103
+ default that is `undefined`, so `mockDeep<AppLogger>().channel('app').info('x')` is a `TypeError` at
104
+ the second call — while `DeepMockProxy<AppLogger>` types it perfectly, so nothing warns. Pass
105
+ `{ selfReturning: true }` for a fluent API, or use `createAutoMock<T>()` with
106
+ `channel.mockReturnThis()` when only one method chains:
107
+
108
+ ```ts
109
+ const logger = mockDeep<AppLogger>({}, { selfReturning: true });
110
+
111
+ logger.channel('app').info('started');
112
+ expect(logger.channel('app').info).toHaveBeenCalledWith('started');
113
+ ```
114
+
115
+ Both bridges exist, and which one you need depends on the direction. What a self-returning **call**
116
+ hands back is typed as the *declared* return type, not as a spy — `asSpy<T>(…)` when the helpers are
117
+ needed. The **whole mock** is a `DeepMockProxy<T>`, which is not assignable to `T` for the same
118
+ reason `Spy<T>` is not (a mapped type cannot see private members) — `asInstance(…)` when it has to
119
+ go somewhere typed against the real thing:
120
+
121
+ ```ts
122
+ const logger = mockDeep<AppLogger>({}, { selfReturning: true });
123
+
124
+ boot(asInstance(logger)); // → AppLogger, for the API under test
125
+ asSpy<AppLogger>(logger.channel('app')).info.mockReturnValue(undefined); // → the helpers
126
+ ```
127
+
128
+ `asInstance` did not take a deep mock before 3.5.0, which left it with nowhere to go: this tree
129
+ sends you to `mockDeep` when the calls chain, and the result then fitted nothing that expected `T`.
130
+
131
+ **An `abstract class` is a class.** `abstract class LocalStorage extends AbstractStorage {}`,
132
+ provided in production as `{ provide: LocalStorage, useClass: BrowserLocalStorage }`, is the
133
+ standard Angular DI-token idiom, and `provideAutoSpy(LocalStorage)` / `createSpyFromClass(LocalStorage)`
134
+ take it — type and runtime both. Abstract members are erased before they reach a prototype, so there
135
+ is nothing to read there; when discovery comes back empty the factory hands back the `createAutoMock`
136
+ proxy instead of an empty object, and every method answers. Nothing to configure, and no reason to
137
+ reach for `{ provide: X, useValue: createAutoMock<X>() }` by hand any more.
138
+
139
+ That holds while the class is **fully** abstract. One concrete member — a helper, a getter — and
140
+ discovery is no longer empty, the fallback does not fire, and every `abstract` member is missing
141
+ while `Spy<T>` types it as present: the read is `undefined` and the call dies as
142
+ `… is not a function` in production code. Pass `{ fillMissing: true }` there
143
+ (`provideAutoSpy(LocalStorage, { fillMissing: true })`), which answers a name the prototype never
144
+ carried with a spy. It is opt-in because `abstract` is erased at runtime — filling every unknown key
145
+ by default would silence a real typo on every concrete class.
146
+
147
+ **`overrides: { key: undefined }` is a seed, not an omission**, and the difference is load-bearing.
148
+ `createAutoMock` reads its seed with `Reflect.ownKeys`, so a key written out with an explicit
149
+ `undefined` **is** in the store: reading it answers `undefined`. Leave it out and the same read
150
+ materialises a *function spy* — which is truthy, and sends `if (this.lastFocus)` down the branch the
151
+ spec was trying to close:
152
+
153
+ ```ts
154
+ createAutoMock<NavigationService>({ currentFocus: undefined, navRoot: undefined, selectors: 'button, a' });
155
+ // ^ "this member is data, and there is none" — not the same as omitting it
156
+ ```
157
+
158
+ This is the way to say "the member exists and is empty", and it is worth writing even when it looks
159
+ redundant.
160
+
161
+ ### What a Proxy-backed double cannot do
162
+
163
+ `createAutoMock` and `mockDeep` build a Proxy, not an object, and there is one place where the
164
+ difference shows: a Proxy answers only the operations its handler traps. Three of them used to be
165
+ missing, and each produced a *silent* wrong answer rather than an error — the worst failure mode
166
+ this library can have, because a checking test becomes a non-checking one and only the proxy's
167
+ source says so. Two are fixed; the third cannot be:
168
+
169
+ | Operation | Before 3.5.0 | Now |
170
+ | ------------------------------------ | ------------------------------------------------ | ------------------------------------------------------------ |
171
+ | `mockValueProp` & the other three | patch landed on the target; the double ignored it | works, and `restoreMockedProps()` undoes it |
172
+ | `delete mock.optionalMethod` | deleted nothing; the next read remade the spy | the member is absent, until something writes to it again |
173
+ | `Object.assign(real, mock)` | copies only the keys already **read** | still does — see below |
174
+
175
+ `ownKeys` cannot be completed: a type has no key list at runtime, which is the whole premise of
176
+ these two factories. So a spec that installs a double by **copying it onto a real instance** —
177
+ `Object.assign(player, engineDouble)` — gets whichever members happened to be touched first, and
178
+ every other call goes to the real implementation, silently. Use `createSpyFromClass` there: it
179
+ returns an ordinary object whose method keys are enumerable (lazy accessors, but enumerable), so
180
+ the copy is complete.
181
+
182
+ ### It answers everything, so it must not answer *these*
183
+
184
+ The same premise cuts the other way. A library that is handed an object and has to decide **what
185
+ kind of thing it is** asks by probing a key — and a double that answers every property answers the
186
+ probe too, at which point it stops being a double of `T` and becomes whatever was being looked for.
187
+ Four names are therefore answered with `undefined` unless the spec seeds them, alongside `then` and
188
+ every symbol, which always were:
189
+
190
+ | Key | Probed by | The double became |
191
+ | -------------- | --------------------------------------------- | ------------------- |
192
+ | `schedule` | `popScheduler` in `of` / `from` / `merge` / … | a scheduler |
193
+ | `lift` | `isObservable`, with `subscribe` | an Observable |
194
+ | `@@observable` | `isInteropObservable` in `innerFrom` | an interop stream |
195
+ | `getReader` | `isReadableStreamLike` in `innerFrom` | a ReadableStream |
196
+
197
+ The one that cost an afternoon reads like nothing at all:
198
+
199
+ ```ts
200
+ of(autoMocked<AnimationItem>()); // an Observable that never emits
201
+ ```
202
+
203
+ `of(...)` takes its **last argument** for a scheduler when `typeof x.schedule === 'function'`, so
204
+ the whole double was eaten as one, `of()` was left with an empty argument list, and the emission was
205
+ scheduled onto a spy that does nothing. The component under test kept its `null`, and what failed
206
+ was an assertion about an unrelated `emit()` three concerns away — nothing in the failure mentions
207
+ `of`. The workaround people find is `from([double])`; it is not needed any more.
208
+
209
+ **`subscribe` is deliberately not on that list.** It is an ordinary method name — a store, an
210
+ Angular `OutputEmitterRef`, an event bus — and `expect(store.subscribe).toHaveBeenCalledWith(cb)` is
211
+ a real assertion. Denying `lift` and `@@observable` already breaks the impersonation, so `subscribe`
212
+ on its own fools nothing: `from(double)` now fails with rxjs's own *"You provided an invalid object
213
+ where a stream was expected"*, loudly and in the right file.
214
+
215
+ If your type genuinely has one of the four, say so once and it comes back — the list is consulted
216
+ after the seed store:
217
+
218
+ ```ts
219
+ createAutoMock<TaskScheduler>({ schedule: vi.fn() });
220
+ ```
221
+
222
+ That is the trade the deny-list makes: without a seed the member is absent and the failure is an
223
+ immediate `TypeError: … is not a function` at the call site, instead of a silent one in another
224
+ file. A key is only added to that list with an observed mechanic behind it — never because the name
225
+ sounds protocol-ish — because every entry costs somebody the ability to mock a member of that name
226
+ without seeding it.
227
+
228
+ The tree asks whether the double is *called*, and there is a second question worth asking: whether
229
+ the code under test **writes to it**. `createAutoMock` is a proxy with a `set` trap over the same
230
+ cache its `get` trap answers from, so an assignment sticks and is read back — which makes it the
231
+ double for a DOM-ish object a library drives by assigning handlers, where a hand-written fake is
232
+ otherwise the only option:
233
+
234
+ ```ts
235
+ const xhr = createAutoMock<XhrLike>({ status: 0, timeout: 0, onload: null, onerror: null });
236
+
237
+ xhr.send.mockImplementation(() => respond(asInstance(xhr)));
238
+ // production does `xhr.onload = () => resolve(xhr.status !== 0)` — the proxy remembers it
239
+ ```
240
+
83
241
  ---
84
242
 
85
243
  ### Cost, so it stops being a question
@@ -211,6 +369,31 @@ const subject = feed.items$.returnSubject(); // ReplaySubject, for anything the
211
369
  `mock.settledResults` is native on Vitest and polyfilled on Bun / `node:test`, so it is identical on
212
370
  all three. Entries are `{ type: 'fulfilled' | 'incomplete' | 'rejected', value }`.
213
371
 
372
+ **The observable helpers are backed by a `ReplaySubject(1)` that belongs to the spy, and it is
373
+ configuration — so it must be reset with the rest of it.** Two failures used to come out of that
374
+ buffer outliving the test that filled it, and both were silent:
375
+
376
+ ```ts
377
+ // test 1
378
+ service.createSeamlessTransition.nextWith(uri); // buffered
379
+
380
+ // test 2 — the failure path is the point of this test
381
+ service.createSeamlessTransition.throwWith(error); // subscriber gets `uri` FIRST, then the error
382
+ ```
383
+
384
+ The code under test therefore ran the **success** branch on stale data, and the error branch arrived
385
+ one emission late. The second: `error()` and `complete()` close a Subject permanently, so a later
386
+ `nextWith` on that spy pushed into a dead subject and emitted nothing at all. Both are fixed —
387
+ `resetAutoSpy(spy)` now drops the subject, and a terminated one is replaced on the next
388
+ configuration.
389
+
390
+ What that does **not** change: `vi.clearAllMocks()` and `clearMocks: true` still cannot reach it,
391
+ for the same reason they cannot reach a `calledWith` chain — that state lives in this library's
392
+ closures, not on the runner's mock. So when a spy outlives a test — a TestBed built in `beforeAll`,
393
+ a spy hoisted to `describe` scope — put `resetAutoSpy(spy)` in `beforeEach`. Inside one test the
394
+ sequence `nextWith(a)` then `throwWith(e)` still means "emit a, then fail"; only a reset or a
395
+ terminal call starts a new stream.
396
+
214
397
  ---
215
398
 
216
399
  ## 5. `createSpyFromClass` configuration
@@ -227,6 +410,8 @@ createSpyFromClass(MyService, {
227
410
  settersToSpyOn: ['userName'],
228
411
  autoSpyAccessors: true, // discover every accessor on the prototype chain
229
412
  lazySpies: true, // build each method spy on first access
413
+ returns: { getProducts: of([]) }, // what a spied METHOD answers
414
+ overrides: { products$: subject }, // a member that is not a method result
230
415
  });
231
416
  ```
232
417
 
@@ -245,12 +430,30 @@ the reference suite). Method discovery walks the _prototype chain_; a callable a
245
430
  - an Angular `signal()` / `computed()` field — the dominant case in a signals codebase
246
431
  - an arrow-function property — `readonly reload = (): void => {}`
247
432
  - anything on an ngrx `signalStore()`, which puts **everything** on the instance
433
+ - **members Angular's own classes moved onto the instance** — `Router.currentNavigation` in
434
+ Angular 20 is `currentNavigation = this.navigationTransitions.currentNavigation.asReadonly()`
248
435
 
249
436
  ```ts
250
437
  createSpyFromClass(TaskStore, { instanceMethodsToSpyOn: ['count', 'reload'] });
251
438
  provideAutoSpy(ProjectStore, { instanceMethodsToSpyOn: ['current', 'isEmpty'] });
439
+ provideAutoSpy(Router, { instanceMethodsToSpyOn: ['currentNavigation'] });
440
+ ```
441
+
442
+ **The failure this produces says nothing about any of it.** The member is simply not on the spy, so
443
+ the next line reads `undefined` and configuring it throws:
444
+
445
+ ```
446
+ TypeError: Cannot read properties of undefined (reading 'mockReturnValue')
252
447
  ```
253
448
 
449
+ There is no better message to be had at runtime, and it is worth saying why rather than leaving it
450
+ looking like an oversight. Instance fields do not exist until a constructor has run, and this
451
+ library never constructs the class — that is what makes a spy safe to build from a service whose
452
+ constructor talks to the network. The only alternative would be to answer an unknown member with
453
+ *something*, and that something would be truthy: `if (service.optionalThing)` in the code under test
454
+ would then take the wrong branch, silently, which is the exact failure mode the protocol deny-list
455
+ in §2 exists to remove. A loud `TypeError` on the spec's own line is the better of the two.
456
+
254
457
  For an ngrx `signalStore()`, prefer `createAutoMock<T>()` over listing every member: it mocks from
255
458
  the type, needs no prototype, and the list cannot fall behind the store.
256
459
 
@@ -267,8 +470,31 @@ Also true, and worth not re-deriving:
267
470
 
268
471
  - **Inherited methods are spied** — discovery walks the whole chain (`Object.prototype` excluded).
269
472
  - **Constructor bodies never run.** The spy is assembled from the prototype.
270
- - **Abstract classes work at runtime** but TypeScript refuses them as `ClassType<T>`. Pass a
271
- concrete subclass and keep the abstract class as the DI token.
473
+ - **Abstract classes are accepted**, type and runtime both `ClassType<T>` carries an abstract
474
+ construct signature, and when the prototype turns out to be empty (abstract members are erased
475
+ before emit) the factory hands back the `createAutoMock` proxy instead of an empty object. Do
476
+ **not** pass a concrete subclass instead; this file used to say so, and it was wrong twice over.
477
+ - **An overloaded method is not collapsed.** The worry that `Spy<T>` types every generated
478
+ `api-mgw` client against its last signature does not hold: a four-overload
479
+ `MgwContentsService.getMoviesBySlug` types as it should, and hand-written `{ m: vi.fn() }` doubles
480
+ for those services convert with no changes to the assertions. When the *first* signature is the
481
+ useful one, name it on the **declaration only** — the factory's result assigns to it, so the type
482
+ argument is not written twice:
483
+
484
+ ```ts
485
+ let mapping: Spy<MgwMappingService, { overload: 'first' }>;
486
+
487
+ mapping = createSpyFromClass(MgwMappingService); // no second type argument here
488
+ ```
489
+
490
+ **A getter that returns a `Signal<T>` goes in `instanceMethodsToSpyOn`**, not in `gettersToSpyOn`.
491
+ `get isKidMode(): Signal<boolean> { return this._isKidMode.asReadonly(); }` is read as a property
492
+ and called as a function, and the accessor route makes you write
493
+ `accessorSpies.getters.isKidMode.mockReturnValue(signal(false))` — two levels deeper than the value
494
+ in question. Naming it as an instance method puts a plain spy at that key (the spy object has no
495
+ class prototype, so nothing is being shadowed), and
496
+ `service.isKidMode.mockReturnValue(false)` reads like every other member. `mockSignalProp` is the
497
+ other answer when the value has to change during the test.
272
498
 
273
499
  ### Getters and setters live in `accessorSpies`
274
500
 
@@ -282,6 +508,25 @@ settings.theme = 'light';
282
508
  expect(settings.accessorSpies.setters.theme).toHaveBeenCalledWith('light');
283
509
  ```
284
510
 
511
+ **Naming one half gets you the pair, when the class declares a pair.** `gettersToSpyOn: ['theme']`
512
+ on a class with both a getter and a setter installs both spies — mirroring reads the prototype
513
+ descriptor, so it only ever adds what the class already has, and a read-only member stays read-only.
514
+ Before 3.5.0 the assignment landed on the no-op setter the scaffolding installs: the write vanished,
515
+ `accessorSpies.setters.theme` was `undefined`, and the failure read
516
+ `Cannot read properties of undefined` three steps from the configuration behind it.
517
+
518
+ Only spy a getter when the spec asserts that it was **read**. To make one *answer* something, on a
519
+ spy that already exists, the pair above is one line — and it needs no `gettersToSpyOn` at the
520
+ factory, which is the part that is otherwise found by trial:
521
+
522
+ ```ts
523
+ mockReadonlyProp(settings, 'theme', 'dark'); // no gettersToSpyOn, no accessorSpies
524
+ ```
525
+
526
+ For a signal-valued property that is not merely convenience: a spied getter answers `undefined`
527
+ until it is configured, while `mockReadonlyProp(component, 'items', signal([]))` keeps every
528
+ `computed()` and `effect()` downstream of it reactive (§9).
529
+
285
530
  ---
286
531
 
287
532
  ## 6. `Spy<T>` is not assignable to `T` — this is intentional
@@ -350,16 +595,120 @@ proxies alike. Reach for these instead of looping over methods calling `mockClea
350
595
  emits, the callback never runs and nothing is asserted. Invert it — **the assertion is the `await`**:
351
596
 
352
597
  ```ts
353
- import { expectEmission, expectEmissions, expectNoEmission } from 'vitest-auto-spy';
598
+ import { expectCompletion, expectEmission, expectEmissions, expectError, expectNoEmission } from 'vitest-auto-spy';
354
599
 
355
- await expect(expectEmission(component.visible$)).resolves.toEqual([task]);
356
- await expect(expectEmissions(source$, 3)).resolves.toEqual([1, 2, 3]);
600
+ await expect(expectEmission(component.visible$)).resolves.toBe(true); // the first VALUE, not a list
601
+ await expect(expectEmission(tasks$)).resolves.toEqual({ id: 1 }); // the task itself, not `[task]`
602
+ await expect(expectEmissions(source$, 3)).resolves.toEqual([1, 2, 3]); // the list is this one
357
603
  await expectNoEmission(source$, { timeout: 50 });
604
+ await expectCompletion(service.purgeCache()); // "it finished" — the value is not the point
605
+ ```
606
+
607
+ Options: `{ timeout, label }`. `timeout` defaults to `1000` ms (`0` for `expectNoEmission`, whose
608
+ wait is a quiet window rather than a watchdog). The source is duck-typed, so rxjs `Observable`s,
609
+ `Subject`s, Angular `toObservable()` results, Angular `output()` (`OutputEmitterRef`, whose
610
+ `subscribe` takes a bare callback) and hand-rolled subscribables all work — and every helper infers
611
+ the emitted type, so `expectEmission(of(1))` is a `Promise<number>`.
612
+
613
+ `expectCompletion` is the one to reach for on a stream whose value is not the point — a save, a
614
+ purge, an `Observable<void>`, a `Subject` a teardown closes. `firstValueFrom` rejects such a stream
615
+ with rxjs's `EmptyError`, and the workaround people arrive at,
616
+ `lastValueFrom(x, { defaultValue: undefined })`, reads as though the default were the interesting
617
+ part. Emissions do not fail it: it asserts termination, nothing about what came before.
618
+
619
+ **To assert that production code pushed into a stream, do not use `observablePropsToSpyOn`.** That
620
+ option points the other way: it gives the spec `nextWith` so it can *feed* the double. When the
621
+ question is whether the code under test called `next` on a property, the double needs a real
622
+ `Subject` and a spy on its method:
623
+
624
+ ```ts
625
+ const forceRequery$ = new Subject<number>();
626
+
627
+ mockValueProp(state, 'forceRequeryAndStartPlaybackAt$', forceRequery$);
628
+ const next = vi.spyOn(forceRequery$, 'next');
629
+
630
+ service.seek(1000);
631
+ expect(next).toHaveBeenCalledWith(1000);
632
+ ```
633
+
634
+ `Spy<T>` types an Observable property as `AddObservableSpyMethods<O> & T[K]`, so `next` is there on
635
+ the type either way — which is exactly why this is worth saying: the code compiles against the spy
636
+ surface and asserts nothing.
637
+
638
+ **When the error *is* the assertion, use `expectError`.** The other helpers wrap a stream failure in
639
+ a new `Error` whose message names the stream — right for reporting an unexpected failure, useless
640
+ when the failure is the subject. `expectError` resolves *with* the error, exactly as it was thrown:
641
+
642
+ ```ts
643
+ await expect(expectError(service.load())).resolves.toBe(originalError);
644
+ expect(await expectError(process$)).toBeInstanceOf(UdmsStatusError);
645
+ expect((await expectError(account$)) as Error).toHaveProperty('message', 'websso fail');
646
+ ```
647
+
648
+ It waits for the error however late it arrives, and fails — naming the stream — if the stream
649
+ completes or stays quiet instead. The wrapped failures of the other helpers now also carry the
650
+ original on `cause`, so `rejects.toMatchObject({ cause: original })` works; prefer `expectError`,
651
+ which needs no unwrapping. `firstValueFrom(source$).rejects` remains fine too.
652
+
653
+ **Which emission counts** — `skip` and `until`, for the stream whose first value is always stale:
654
+
655
+ ```ts
656
+ await expect(expectEmission(isXl$, { skip: 1 })).resolves.toBe(true); // a shareReplay / BehaviorSubject
657
+ await expect(expectEmission(currentParams$, { until: (p) => p.channelId === expected })).resolves.toEqual(…);
658
+ ```
659
+
660
+ Both say in the assertion what `source$.pipe(skip(1))` / `pipe(filter(…))` say in the source, and
661
+ they keep the diagnosis: emissions that do not match are still counted, so a failure reads
662
+ `4 emission(s) received` rather than `0` and tells "the wrong thing fired" apart from "nothing
663
+ fired".
664
+
665
+ **`advance` closes the window between subscribing and awaiting.** A stream driven by a
666
+ `debounceTime`, a retry or a poll needs the clock moved *after* something is listening, and `await`
667
+ gives control away before the next statement runs:
668
+
669
+ ```ts
670
+ await expect(expectEmission(purchased$, { advance: () => vi.runAllTimers() })).resolves.toBe(false);
358
671
  ```
359
672
 
360
- Options: `{ timeout, label }`. `timeout` defaults to `1000` ms (`0` for `expectNoEmission`, and `0`
361
- disables the watchdog use it under fake timers). The source is duck-typed, so rxjs `Observable`s,
362
- `Subject`s, Angular `toObservable()` results and hand-rolled subscribables all work.
673
+ That replaces the fragile shape people arrive at hold the promise, advance, then await — which
674
+ breaks silently the moment somebody adds an `await` one line above it. It is a callback rather than
675
+ an `advanceTimers: true` flag because these helpers are in the core entry, which contains no test
676
+ runner: only the spec knows whether it is on `vi`, `bun:test` or `node:test`.
677
+
678
+ **The watchdog runs on real time, on purpose — even under fake timers.** A virtual one would race
679
+ the timers the spec advances: `expectEmission(source$, { timeout: 200 })` followed by
680
+ `vi.advanceTimersByTime(5_000)` would fire at 200 virtual ms and reject the stream the spec was
681
+ about to advance into. The cost is that in a suite with global fake timers a *failing* assertion
682
+ spends a real second. Do **not** answer that with `{ timeout: 0 }` at every call site — that
683
+ disables the watchdog, and the next silent stream hangs to the runner's own timeout with nothing
684
+ useful in the message. Lower the default once instead:
685
+
686
+ ```ts
687
+ // vitest.setup.ts
688
+ import { setEmissionTimeout } from 'vitest-auto-spy';
689
+ import { setupAutoSpy } from 'vitest-auto-spy/setup';
690
+
691
+ setupAutoSpy({ globalFakeTimers: true });
692
+ setEmissionTimeout(100); // the clock is frozen; a real second buys nothing
693
+ ```
694
+
695
+ **`expectEmission` subscribes when you call it, not when you await it**, and that is load-bearing
696
+ rather than an implementation detail. It is what converts the test whose source has to be poked
697
+ *after* somebody is listening — a router event, a `Subject` the spec pushes into, anything that
698
+ does not replay:
699
+
700
+ ```ts
701
+ const breadcrumbs = expectEmission(service.buildDynamicBreadcrumbs({ root })); // subscribed already
702
+
703
+ router.events.nextWith(navigationEnd); // …so this emission is not missed
704
+
705
+ await expect(breadcrumbs).resolves.toEqual([…]);
706
+ ```
707
+
708
+ `firstValueFrom` cannot do this half: it also subscribes eagerly, but there is nowhere to put the
709
+ line that triggers the source, because the `await` is the same statement as the subscription — so
710
+ the test deadlocks against a source that only emits once something pokes it. Hold the promise
711
+ first, poke, then await.
363
712
 
364
713
  ---
365
714
 
@@ -380,6 +729,32 @@ restoreMockedProps(); // put every patch back; each helper also returns its own
380
729
  properties. Never use bare `Object.defineProperty` in a spec: nothing restores the original
381
730
  descriptor, and under `isolate: false` the patch leaks into the next file.
382
731
 
732
+ **They work on `createAutoMock` and `mockDeep` doubles too** — which they did not until 3.5.0.
733
+ Both are Proxies, all four helpers are built on `Object.defineProperty`, and neither Proxy trapped
734
+ it: the patch landed on the Proxy's own target, the `get` trap never looked there, nothing threw,
735
+ and the test carried on reading the old value. If you have seen a spec build a double by hand —
736
+ real getters plus a `createFunctionSpy` per method — this is usually why.
737
+
738
+ **The second overload is a normal tool, not a last resort.** Each helper has a checked overload
739
+ (`K extends keyof T`) and a `(object, property: PropertyKey, value: unknown)` one behind it, and
740
+ the JSDoc calls the latter an escape hatch for `#private` fields. In practice it carries about half
741
+ of the real calls, all of them legitimate:
742
+
743
+ ```ts
744
+ mockValueProp(router, 'routerState', { snapshot: { url: '/home' } }); // a partial fixture of a fat type
745
+ mockValueProp(window, 'AudioContext', undefined); // "this platform does not ship the API"
746
+ mockValueProp(transitionEvent, 'propertyName', 'opacity'); // a field a synthetic DOM event lacks
747
+ mockValueProp(spy, 'products$', new Subject()); // a member the double does not have at all
748
+ ```
749
+
750
+ The last one is worth knowing on its own: patching a key the object never had **works and is undone
751
+ correctly** — the journal records the *absence* of a descriptor and puts it back by deleting the
752
+ property. That is how you add an Observable member that `provideAutoSpy` did not create because
753
+ `observablePropsToSpyOn` was not passed.
754
+
755
+ What the second overload costs is the property-name check, so a typo in the name compiles. Nothing
756
+ checks the *value* on either overload; that is deliberate, and the partial fixture above is why.
757
+
383
758
  ### Properties of DOM objects — the same helpers, and the reason to look for them
384
759
 
385
760
  `document.fullscreenElement`, `document.visibilityState`, `document.cookie`, `navigator.userAgent`,
@@ -420,6 +795,16 @@ setupAutoSpy(); // { duplicateCopies: 'throw', restoreProps: true, restoreMocks:
420
795
  check that fails the run, and (opt-in) `vi.restoreAllMocks()`. Turn on `restoreMocks: true` when the
421
796
  suite runs with `isolate: false`.
422
797
 
798
+ **The restore also runs from an `onTestFinished` net**, because the `afterEach` is not guaranteed
799
+ to. Vitest calls `afterEach` hooks in *reverse* registration order, so the setup file's is the last
800
+ one, and a hook the spec file registered takes the chain down with it when it throws — the patches
801
+ then travel into the next test and the failure surfaces somewhere that never touched them. One spec
802
+ kept `afterEach(() => vi.restoreAllMocks())`; migrating it to `gettersToSpyOn` made the restored
803
+ getter return `undefined`, `ngOnDestroy` called it as a signal, the `TypeError` aborted the hook,
804
+ and a template error about a null profile appeared in a different `describe`. The net puts the
805
+ properties back and warns with the count and the cause. `countMockedProps()` is exported if you
806
+ would rather assert it: `afterEach(() => expect(countMockedProps()).toBe(0))`.
807
+
423
808
  **The one that only bites at scale:** with `isolate: false`, a `setTimeout` or
424
809
  `requestAnimationFrame` a component schedules and never clears keeps running after its file is done,
425
810
  and fires while the **next** file is mid-test. It is reported against that innocent file, as
@@ -456,6 +841,11 @@ undo), `flushStrayRejections()` (takes what was captured and starts again from e
456
841
  `countStrayRejections()`. The `no-floating-assertion` lint rule catches the commonest shape before
457
842
  it ever runs (§16).
458
843
 
844
+ A rejection the runner has **already** blamed the finished test for is not reported again. An
845
+ `async` test that fails an assertion leaves its own `AssertionError` in both places, so a red run
846
+ used to print two messages per failure and the second one sent the reader hunting for a defect that
847
+ was not there. What is left is what the check is for: the rejections that fail no test at all.
848
+
459
849
  **The one that gets slower the longer the run goes on:** every `vi.fn()` and `vi.spyOn()` is added
460
850
  to one `Set` inside `@vitest/spy`, because that is what `vi.clearAllMocks()` walks, and nothing takes
461
851
  anything out of it again. With `isolate: false` the set is created once per worker and only grows:
@@ -487,13 +877,45 @@ sweep (it returns how many went) and `getMockRegistrySize()` reports what is lef
487
877
  Two more switches, both about the environment rather than the spies:
488
878
 
489
879
  ```ts
490
- setupAutoSpy({ blockNetwork: true }); // reject every fetch, naming what was requested
880
+ setupAutoSpy({ blockNetwork: true }); // fetch rejects, XHR fails, sendBeacon answers false
881
+ ```
882
+
883
+ Under happy-dom, which — unlike jsdom — implements `fetch`: a component that pulls a remote asset
884
+ really fetches it, nothing asserts on the response so the tests pass, and the aborts at teardown
885
+ fail the run with **no test named**. If a green run exits 1 with `DOMException [AbortError]`, this
886
+ is it.
887
+
888
+ Under jsdom too, for the other half. jsdom implements `XMLHttpRequest` in full, and plenty of
889
+ libraries never left it — `rmp-vast` pings every VAST tracker through a hand-rolled one
890
+ (`FW.ajax`) — so a suite with `blockNetwork: true` already on was still reaching the internet, one
891
+ ping per quartile per ad per test, and printing jsdom's `AggregateError at Object.dispatchError`
892
+ for every connection that failed. What a green run prints then depends on whether the machine has a
893
+ route out.
894
+
895
+ Every channel is closed by default; the object is for narrowing it:
896
+
897
+ | option | default | what it does |
898
+ | -------- | ---------- | ---------------------------------------------------------------------------------- |
899
+ | `fetch` | `true` | `fetch` rejects, naming what was requested |
900
+ | `xhr` | `'reject'` | `'reject'` fails the request (`status` 0, an `error` event); `'empty'` answers 200 with an empty body; `false` leaves XHR alone |
901
+ | `beacon` | `true` | `navigator.sendBeacon` answers `false` — only where the environment has one |
902
+
903
+ `'reject'` is the default because it is what `fetch` does: the code takes its failure branch, which
904
+ is the branch a unit test should be asserting on. `'empty'` is for a request whose response nobody
905
+ reads — a tracker ping, an analytics beacon — where failing it only trades one kind of noise for
906
+ another:
907
+
908
+ ```ts
909
+ setupAutoSpy({ blockNetwork: { xhr: 'empty' } }); // the ad-player suite's setting
491
910
  ```
492
911
 
493
- Only relevant under happy-dom, which unlike jsdom implements `fetch`. A component that pulls a
494
- remote asset then really fetches it; nothing asserts on the response, so the tests pass, and the
495
- aborts at teardown fail the run with **no test named**. If a green run exits 1 with
496
- `DOMException [AbortError]`, this is it.
912
+ A `data:` URL is always let through, and it is the only thing that is: that is the scheme a spec
913
+ serves its own fixtures from (`xhr.open('GET', \`data:application/xml,\${encodeURIComponent(vast)}\`)`),
914
+ and the only one a DOM answers without a socket. A **relative** URL is not exempt either the DOM
915
+ resolves it against the document origin, so a spec that reaches `/config` and passes is resting on
916
+ nothing listening on that port. `WebSocket` and `EventSource` are left alone: their failure is an
917
+ event on an object the code keeps and reconnects, so there is no blanket answer that is not itself
918
+ a behaviour change — `stubConstructor(globalThis, 'WebSocket', …)` is the tool for a spec with one.
497
919
 
498
920
  `restoreTimerGlobals` is on by default and needs no thought unless you turn it off: uninstalling
499
921
  fake timers under happy-dom **deletes** `Date` instead of restoring it (the global is inherited from
@@ -774,6 +1196,57 @@ const myService = injectSpy(MyService); // Spy<MyService>
774
1196
  `{ lazySpies: false }` to opt out. The spies never touch `NgZone`, so they work zoneless and with
775
1197
  zone.js alike.
776
1198
 
1199
+ The token may be an **abstract class** — `abstract class LocalStorage extends AbstractStorage {}`,
1200
+ the shape production provides with `useClass`. Its members are erased before they reach a prototype,
1201
+ so there is nothing to discover; the factory notices and returns the `createAutoMock` proxy, which
1202
+ answers every method of the declared type. `injectSpy(LocalStorage)` recognises it as an auto-spy
1203
+ and stays quiet.
1204
+
1205
+ **Seed the double in the provider, not in the `beforeEach` under it.** Both factories take both
1206
+ halves — `returns` for what a spied method answers, `overrides` for a member that is not a method
1207
+ result:
1208
+
1209
+ ```ts
1210
+ provideAutoSpy(FavoritesService, {
1211
+ returns: { load: of([]) },
1212
+ overrides: { favoritesCacheUpdated$: of(undefined), favoriteVODs: [] },
1213
+ });
1214
+
1215
+ provideAutoSpyForToken(PRODUCTS, undefined, { returns: { getProducts: of([]), getById: of(null) } });
1216
+ ```
1217
+
1218
+ A seeded `overrides` member is stored verbatim and is **no longer a spy**, so seed data there and
1219
+ name methods in `returns` when they must stay assertable. The reason to prefer this over a second
1220
+ statement is not brevity: the shortcut people take instead is an exported `const` provider carrying
1221
+ the values, and under `isolate: false` that is one set of spies shared by every file that imports
1222
+ it.
1223
+
1224
+ **`observablePropsToSpyOn` works on a token too**, and matters more there than on a class. A class
1225
+ tells the factory which members are methods; a type does not, so *every* unnamed key of a
1226
+ token-driven double is a function spy — including an `Observable` property, which the code under
1227
+ test then subscribes to as if it were a function, failing far from the double:
1228
+
1229
+ ```ts
1230
+ provideAutoSpyForToken(FAVORITES, undefined, { observablePropsToSpyOn: ['favorites$'] });
1231
+ // …
1232
+ injectSpy(FAVORITES).favorites$.nextWith([{ id: 1 }]);
1233
+ ```
1234
+
1235
+ A member also named in `overrides` keeps its seed — hand the double a real `Subject` there when the
1236
+ spec drives the stream itself, and name it here when `nextWith` is what the spec wants. That is the
1237
+ same precedence the class-based factory uses. Before 3.5.0 this option existed only on the class
1238
+ path, and reaching a token with observable members meant going back to a hand-written double —
1239
+ which is exactly what `prefer-provide-auto-spy` and `prefer-create-spy-from-class` exist to prevent.
1240
+
1241
+ **Do not write a local `injectSpy`.** A wrapper of the shape
1242
+ `TestBed.inject(token as never) as Spy<T>` — a double assertion, typed
1243
+ `<T>(token: abstract new (...args: never[]) => T)` — is a common thing to find already in a
1244
+ repository, and the library's is strictly wider: it takes `ClassType<T>`, an `InjectionToken<T>` and
1245
+ an abstract constructor, warns when the injector hands back something that is not a spy, and has no
1246
+ assertion for the project's lint rules to argue with. Two functions with the same name and different
1247
+ signatures means the import order decides which one a file gets. Delete the local one, or re-export
1248
+ the library's under that name.
1249
+
777
1250
  ### Signals — which helper depends on whose signal it is
778
1251
 
779
1252
  ```ts
@@ -854,6 +1327,32 @@ fail with something that has nothing to do with intersection.
854
1327
  `addedNodes` is a `NodeList`. Do not build one from a `DocumentFragment`: appending **moves** the
855
1328
  nodes, so the helper silently rips the element out of the fixture it was just asserted on.
856
1329
 
1330
+ ### A component's own `providers` win, and the symptom is nowhere near the cause
1331
+
1332
+ Worth reading before the rest of this section: it has now come up twice in one migration wave, and
1333
+ both times the failure landed in a different file from its cause.
1334
+
1335
+ `@Component({ providers: [RemoveProfileService] })` declares the provider on the **element**
1336
+ injector, and a module-level `provideAutoSpy(RemoveProfileService)` in `configureTestingModule`
1337
+ loses to it — so the component builds the **real** service. Nothing warns. What fails is whatever
1338
+ the real service touches first: in the observed case a logger, with
1339
+ `TypeError: Cannot read properties of undefined (reading 'pipe')`, which names neither the component
1340
+ nor the provider nor the spy.
1341
+
1342
+ Two things fix it, and which one depends on whether the double is wanted:
1343
+
1344
+ ```ts
1345
+ // keep a double, but put it where the component will look
1346
+ const menu = overrideComponentProvider(SmartVodComponent, MenuBuilderService);
1347
+
1348
+ // or take the component's own provider away, so the module-level one is reached again
1349
+ TestBed.overrideComponent(ProfileComponent, { remove: { providers: [RemoveProfileService] } });
1350
+ ```
1351
+
1352
+ `overrideComponentProvider` is the one to reach for by default — it also queues the component with
1353
+ the TestBed compiler, which `overrideProvider` alone does not do. Reach for the `remove` form when
1354
+ the module already provides the spy and the component's declaration is simply in the way.
1355
+
857
1356
  ### `injectSpy` cannot reach a component-level provider
858
1357
 
859
1358
  `injectSpy(X)` reads the **global** `TestBed` injector. A provider declared on the component
@@ -878,11 +1377,15 @@ const menu = overrideComponentProvider(SmartVodComponent, MenuBuilderService); /
878
1377
  TestBed.configureTestingModule({ … }).overrideProvider(PaymentToolService, overrideAutoSpy(PaymentToolService));
879
1378
  ```
880
1379
 
881
- Two silent failures this avoids. `overrideProvider(X, provideAutoSpy(X))` passes a _provider_ where
882
- `{ useValue }` is expected — no error, no warning, the test runs on the real service. And
883
- `overrideProvider` only reaches a component the TestBed compiler knows about, so a standalone
884
- component instantiated through a parent's template needs to be in `imports` first;
885
- `overrideComponentProvider` queues it.
1380
+ `overrideProvider(X, provideAutoSpy(X))` is **not** broken, contrary to what this section used to
1381
+ say: `provideAutoSpy` returns `{ provide, useValue }`, `overrideProvider` reads the `useValue` off
1382
+ it and ignores the extra `provide`, and the spy is installed. `overrideAutoSpy` is the right call
1383
+ because it says what it does and hands the spy back directly — not because the other form is a
1384
+ no-op.
1385
+
1386
+ The failure that is real: `overrideProvider` only reaches a component the TestBed compiler knows
1387
+ about, so a standalone component instantiated through a parent's template needs to be in `imports`
1388
+ first; `overrideComponentProvider` queues it.
886
1389
 
887
1390
  Do **not** reach for `TestBed.overrideComponent` here — see the next subsection for why it is worse
888
1391
  than the problem it solves.
@@ -1042,7 +1545,19 @@ const pinCode = injectSpy(PIN_CODE_SERVICE_TOKEN); // Spy<PinCodeService>
1042
1545
 
1043
1546
  A token typed with an interface has no class to read, so the habit is a `…Mock` class written in the
1044
1547
  spec — after which `Spy<Mock>` and `Spy<Interface>` disagree and somebody casts. Do not write
1045
- `TestBed.inject<any>(TOKEN)`; both of these accept a token.
1548
+ `TestBed.inject<any>(TOKEN)`; both of these accept a token. And it is `provideAutoSpyForToken`, not
1549
+ `provideAutoSpy`: the latter reads a class prototype, which a token does not have.
1550
+
1551
+ **The second argument is not optional as often as it looks.** A spy answers `undefined` until it is
1552
+ told otherwise, and that is fatal the moment the code under test *chains* off it — a constructor
1553
+ doing `inject(LOGGER).channel('auth').debug('…')` dies on the `.debug` of `undefined` before the
1554
+ spec's first line runs, because nothing in production wrote `?.` there. Seed the link:
1555
+
1556
+ ```ts
1557
+ provideAutoSpyForToken(LOGGER, { channel: vi.fn().mockReturnThis() });
1558
+ ```
1559
+
1560
+ For a chain more than one link long, `mockDeep<T>()` is the double that answers every level (§2).
1046
1561
 
1047
1562
  ### A host for a directive under test
1048
1563
 
@@ -1145,17 +1660,100 @@ import autoSpy from 'vitest-auto-spy/eslint-plugin';
1145
1660
  export default [{ files: ['**/*.spec.ts'], ...autoSpy.configs.recommended }];
1146
1661
  ```
1147
1662
 
1148
- | Rule | Level | Flags |
1149
- | ------------------------------ | ------- | ------------------------------------------------------------------------ |
1150
- | `no-expect-in-subscribe` | `error` | `expect()` inside `subscribe()` → `expectEmission` |
1151
- | `no-object-define-property` | `error` | `Object.defineProperty` in a spec → `mockReadonlyProp` / `mockValueProp` |
1152
- | `prefer-provide-auto-spy` | `warn` | `{ provide: X, useValue: { a: vi.fn() } }` `provideAutoSpy(X)` |
1153
- | `prefer-create-spy-from-class` | `warn` | an object literal of 2+ `vi.fn()`s → `createSpyFromClass` |
1154
- | `prefer-inject-spy` | `warn` | `vi.spyOn(TestBed.inject(X), 'm')` → `injectSpy(X)` |
1155
- | `no-shared-module-level-mock` | `error` | an **exported** value holding `vi.fn()`s → export a factory instead |
1156
- | `no-mocked-for-spy` | `warn` | `let s: Mocked<T>` → `Spy<T>` |
1157
- | `no-done-callback` | `error` | `it('x', (done) => …)` → `async` + an awaited assertion |
1158
- | `no-floating-assertion` | `error` | `expect()` in a `.then()` nobody awaits → `expect(await promise)` |
1663
+ | Rule | Level | Fix | Flags |
1664
+ | ------------------------------ | ------- | --------- | ------------------------------------------------------------------------ |
1665
+ | `no-expect-in-subscribe` | `error` | suggest | `expect()` inside `subscribe()` → `expectEmission` / `firstValueFrom` |
1666
+ | `no-object-define-property` | `error` | suggest | `Object.defineProperty` in a spec → `mockReadonlyProp` / `mockValueProp` |
1667
+ | `prefer-provide-auto-spy` | `warn` | — | a hand-rolled `useValue` **or** `useFactory` → `provideAutoSpy(Class)` / `provideAutoSpyForToken(TOKEN)` |
1668
+ | `prefer-create-spy-from-class` | `warn` | — | an object literal of 2+ `vi.fn()`s → `createSpyFromClass` (a factory's own seed is exempt) |
1669
+ | `prefer-inject-spy` | `warn` | suggest | `vi.spyOn(TestBed.inject(X), 'm')`, inline or via a `const` → `injectSpy(X).m` |
1670
+ | `no-shared-module-level-mock` | `error` | — | an **exported** value holding `vi.fn()`s → export a factory instead |
1671
+ | `no-mocked-for-spy` | `warn` | `--fix` | `Mocked<T>` in any type position → `Spy<T>`, import and all |
1672
+ | `prefer-as-spy` | `warn` | `--fix` | `TestBed.inject(X) as Spy<X>` → `asSpy<X>(TestBed.inject(X))`, import and all |
1673
+ | `no-done-callback` | `error` | — | `it('x', (done) => )` → `async` + an awaited assertion |
1674
+ | `no-floating-assertion` | `error` | — | `expect()` in a `.then()` nobody awaits → `expect(await promise)` |
1675
+ | `no-overridden-provider` | `error` | — | two providers for one token in one array → the earlier one never runs |
1676
+ | `no-inject-before-override` | `warn` | — | `TestBed.inject()` in a hook, in a suite that still calls `override*` |
1677
+
1678
+ Twelve rules; two fix on their own, three offer suggestions. `no-mocked-for-spy` only ever touches a
1679
+ **type position**, where a wrong rewrite is a compile error rather than a test that quietly changed
1680
+ meaning — so `--fix` renames the type, adds `import type { Spy } from 'vitest-auto-spy'` and drops
1681
+ the orphaned `Mocked` import. Every type position, not only a `let`: a factory's return type, a
1682
+ helper's parameter, and `as unknown as Mocked<T>`, which in one batch stood next to the declaration
1683
+ in all eight reports — fix one and leave the other and the file says both. It declines where it
1684
+ cannot prove the rename (a `Mocked` the file declares itself, a `Spy` that is already something
1685
+ else, `Mocked<{ a: Mock }>` rather than a named type) and reports without a fix.
1686
+
1687
+ `prefer-as-spy` is the same licence from the other end, and the one a migration meets in bulk: a
1688
+ `jest-auto-spies` suite writes `TestBed.inject(X) as Spy<X>` once per injected double, and that cast
1689
+ fails here with `TS2352` — `Spy<T>` adds `accessorSpies` and the per-method helpers, so neither type
1690
+ sufficiently overlaps the other. `asSpy` is a typed identity function, so `--fix` keeps the
1691
+ assertion the developer already made, carries the type arguments across (inference answers
1692
+ `Spy<Service<any>>` for a generic class), adds the `asSpy` import and removes a `Spy` import the
1693
+ rewrite orphans. A cast that hops through `unknown` is left alone — the hop says the value is not a
1694
+ `T` — except after `TestBed.inject(X)`, where the container returns `X` by construction and the hop
1695
+ was only silencing `TS2352`. Neither rule is for the object under test: a service the spec
1696
+ exercises is not a double, and typing it as the class is the repair there.
1697
+
1698
+ `no-expect-in-subscribe` reports one shape and **three different edits**, and says which: the
1699
+ subscription is the last thing the test does (invert it into `await firstValueFrom`); something
1700
+ after it is what makes the stream emit (hold the promise — `const p = expectEmission(src$)`, fire
1701
+ the trigger, `await p` — because inverting deadlocks); or the assertion is in the `error` branch
1702
+ (`await expect(firstValueFrom(src$)).rejects.toMatchObject(…)`). It also counts assertions the
1703
+ callback reaches through a helper it calls, which used to make `subscribe((d) => assertShape(d))`
1704
+ invisible. `prefer-provide-auto-spy` reads `useFactory` as well as `useValue`, through the function
1705
+ in the first case and not in the second — a factory's body is what DI ends up holding, while a
1706
+ function inside a `useValue` is a lazily-built double, i.e. the fix. The other three change behaviour — whether `injectSpy(X)`
1707
+ finds a spy is decided by a `provideAutoSpy(X)` usually written in another file, `mockValueProp`
1708
+ leaves the property writable and configurable, and `no-expect-in-subscribe` rewrites a whole test
1709
+ — so all three are suggestions an editor offers and a human accepts:
1710
+
1711
+ ```ts
1712
+ it('maps the products', () => // ❌ flagged, and a suggestion is offered
1713
+ new Promise<void>((done) => {
1714
+ service.getProducts(id).subscribe((products) => {
1715
+ expect(products).toEqual(expected);
1716
+ done();
1717
+ });
1718
+ }));
1719
+
1720
+ it('maps the products', async () => { // ✅ what accepting it produces
1721
+ const products = await firstValueFrom(service.getProducts(id));
1722
+
1723
+ expect(products).toEqual(expected);
1724
+ });
1725
+ ```
1726
+
1727
+ That template was 111 of 133 violations in one migration batch. The suggestion appears only for the
1728
+ exact frame above — one `subscribe` statement in the executor, one block-bodied callback, `done()`
1729
+ mentioned once and standing last — and the report itself now counts assertions per `subscribe`
1730
+ rather than one message per `expect`, which used to double the apparent size of the job.
1731
+
1732
+ `prefer-inject-spy` reads both spellings of the same mistake, which is the point of the second one:
1733
+
1734
+ ```ts
1735
+ vi.spyOn(TestBed.inject(DomainEventsService), 'announce'); // flagged, always was
1736
+ const domainEvents = TestBed.inject(DomainEventsService);
1737
+ const announceSpy = vi.spyOn(domainEvents, 'announce'); // flagged now
1738
+ ```
1739
+
1740
+ The variable is resolved through the scope manager, so it has to be a `const`/`let` initialised
1741
+ from `TestBed.inject(...)` and never assigned again — a name bound by an import, a parameter, or a
1742
+ `let` that is reassigned is left alone.
1743
+
1744
+ `no-inject-before-override` catches the trap this plugin's own advice sets. `TestBed.inject()` in a
1745
+ `beforeEach` — the line you write once `provideAutoSpy(X)` has taken away the literal you used to
1746
+ configure — instantiates the module, and every `TestBed.override*` afterwards throws, including one
1747
+ written above it inside a `createComponent` helper. Configure the double after the overrides
1748
+ (`injectSpy(X)` in the test), or keep the access lazy: `const api = () => injectSpy(Api)`. The check
1749
+ is order-free by design, since a helper declared above the hook still runs after it.
1750
+
1751
+ `no-overridden-provider` is the one that catches a defect rather than a habit. Angular keeps the
1752
+ last provider for a token, so `[provideAutoSpy(X), { provide: X, useValue: mockX }]` is not an
1753
+ auto-spy with configuration — the auto-spy is dead and the hand-rolled double is what DI hands out.
1754
+ Found on eight tokens of one file. It reads both spellings in either order, compares tokens as
1755
+ source text, and offers no fix: deleting either line is a valid repair and they mean opposite
1756
+ things.
1159
1757
 
1160
1758
  The legacy `.eslintrc` `plugins: []` form cannot work — it resolves names to `eslint-plugin-*`
1161
1759
  packages, which a subpath export can never be.
@@ -1179,6 +1777,7 @@ packages, which a subpath export can never be.
1179
1777
  | `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) |
1180
1778
  | a spy is never called, no warning | the method is an instance field, not on the prototype | `instanceMethodsToSpyOn`, or `createAutoMock<T>()` |
1181
1779
  | `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 |
1780
+ | `AggregateError at Object.dispatchError`, for a request nothing asserts on | jsdom really served an `XMLHttpRequest` — `blockNetwork` used to cover only `fetch` | `setupAutoSpy({ blockNetwork: true })`, or `{ xhr: 'empty' }` for tracker pings (§10) |
1182
1781
  | `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) |
1183
1782
  | `signal read during notification phase` | same — a stray `requestAnimationFrame` callback | same |
1184
1783
  | an assertion error printed to stderr, every test green and the run exiting 0 | zone.js swallowed a rejection nobody handled | `setupAutoSpy({ strayRejections: true })` fails the test it surfaced in (§10) |
@@ -1204,6 +1803,8 @@ packages, which a subpath export can never be.
1204
1803
  | a third-party library failing every other run, no test named | a test sealed a global with `Object.defineProperty` (non-configurable) | `setupAutoSpy({ guardGlobals: 'throw' })` names the file; then `mockValueProp` |
1205
1804
  | `expected [ { at: 1, …(5) }, …(8) ] to deeply equal [ { …(6) }, … ]` | one field moved in every element — usually a frozen clock or an id | `expect(diffByField(actual, expected)).toBeUndefined()` |
1206
1805
  | a hand-tuned number of turns waiting for a `resource()` to load | the hand-off count depends on the dependency, not on the spec | `await flushEventLoopUntil(() => r.status() !== 'loading', { label })` |
1806
+ | `flushEventLoopUntil` timing out on the **first** such test only, the rest green | a cold dynamic `import()` outran the turn budget; later tests hit the module cache | `await settleDynamicImport(() => import('…'))` — await the module, do not count turns |
1807
+ | a template error in a `describe` that never patched anything | a spec `afterEach` threw and skipped `setupAutoSpy`'s, so a `mock*Prop` patch travelled | upgrade — an `onTestFinished` net restores it and names the cause (§10) |
1207
1808
  | `Property 'mockReturnValue' does not exist on type 'never'` | a generic method with a conditional return type; the spy collapsed | upgrade — fixed in the types; the member now keeps its sync helper bundle |
1208
1809
  | `Type 'string' is not assignable to type 'never'` on `gettersToSpyOn` | the list used to reject callable values, i.e. every `Signal<T>` | upgrade — any string key is nameable; prefer `mockSignalProp` for a signal |
1209
1810
  | `Expected to be running in 'ProxyZone', but it was not found` | `zone.js/testing` patches jasmine/mocha/jest, not Vitest | `import 'vitest-auto-spy/zone'` after zone.js, with `globals: true` (§14) |
@@ -1211,6 +1812,15 @@ packages, which a subpath export can never be.
1211
1812
  | a spy that cannot take a real `signal()` in `mockReadonlyProp` | the value was typed against `Spy<T>[K]`, not against `T[K]` | upgrade — the `mock*Prop` helpers accept a `Spy<T>` and check against `T` |
1212
1813
  | `NG0303` / `NG0304` / nothing at all, from a directive spec | the host is `standalone: false`, or the module is in the TestBed | `createDirectiveHost({ template, scope: [Module] })` (§13) |
1213
1814
  | `TS2540: Cannot assign to 'X' because it is a read-only property` | a `readonly` field of an object under test | `mockValueProp(obj, 'X', value)` — on a class **getter**, `mockReadonlyProp` |
1815
+ | `TypeError: Cannot read properties of undefined (reading 'mockReturnValue')` | the member is an **instance field**, not on the prototype — `Router.currentNavigation` since Angular 20 | `provideAutoSpy(Router, { instanceMethodsToSpyOn: ['currentNavigation'] })` (§5) |
1816
+ | a stream built with `of(double)` that never emits | the double answered `schedule`, so rxjs ate it as the scheduler | upgrade — `schedule` / `lift` / `@@observable` / `getReader` are answered `undefined` (§2) |
1817
+ | `throwWith` reaching the success branch first, with the previous test's value | the spy's `ReplaySubject(1)` outlived the test that filled it | upgrade; and `resetAutoSpy(spy)` in `beforeEach` when the TestBed is built in `beforeAll` |
1818
+ | `nextWith` emitting nothing, on a spy an earlier test completed or errored | `error()`/`complete()` close a Subject for good | upgrade — the next configuration starts a new stream |
1819
+ | `accessorSpies.setters.X` is `undefined` after `gettersToSpyOn: ['X']` | only the named half was spied | upgrade — the declared other half is mirrored (§5) |
1820
+ | `TS2540` on a `Spy<T>` / `createAutoMock` member, which the runtime writes fine | `Spy<T>` is homomorphic, so it keeps the `readonly` of an abstract getter | `Mutable<Spy<T>>` for direct assignment, or `mockValueProp` for a patch that is undone |
1821
+ | a `mock*Prop` patch on a `createAutoMock` double that changes nothing | the Proxy had no `defineProperty` trap before 3.5.0 | upgrade — nothing to change in the spec |
1822
+ | `delete mock.optionalMethod` leaving the member present and truthy | the Proxy had no `deleteProperty` trap before 3.5.0 | upgrade; before that, `mock.optionalMethod = undefined` |
1823
+ | half a double's methods reaching the real implementation after `Object.assign` | `ownKeys` on a type-driven Proxy lists only the keys already read | `createSpyFromClass(X)` — a real object, with enumerable method keys |
1214
1824
  | `let s: MockInstance<() => unknown>` not matching anything | `MockInstance<F>` is invariant in `F`; Jest's `SpyInstance` was not | `MockInstance<T['method']>`, or better `injectSpy(X).method` |
1215
1825
  | two runs with the same totals, one of them missing a suite | a lost `describe` and a fixed flake cancel out in the counters | `compareTestRuns(before, after)` — compare the set of names, not the numbers |
1216
1826
  | a 30 s timeout, in a different file each run | module-level `vi.fn()` in a fixture shared by files | make the fixture a factory (§10) |
@@ -1230,6 +1840,19 @@ packages, which a subpath export can never be.
1230
1840
  | `vi.spyOn(TestBed.inject(X), 'method')` | `injectSpy(X).method` |
1231
1841
  | `Object.defineProperty(service, 'ready', { value: true })` | `mockReadonlyProp(service, 'ready', true)` |
1232
1842
  | `source$.subscribe(v => expect(v).toBe(1))` | `await expect(expectEmission(source$)).resolves.toBe(1)` |
1843
+ | `await lastValueFrom(done$, { defaultValue: undefined })` | `await expectCompletion(done$)` |
1844
+ | `{ timeout: 0 }` on every helper because timers are faked | `setEmissionTimeout(100)` once, in the setup file |
1845
+ | `{ provide: AbstractToken, useValue: createAutoMock<T>() }` | `provideAutoSpy(AbstractToken)` |
1846
+ | `mockDeep<T>()` for a chain that goes through a **call** | `mockDeep<T>({}, { selfReturning: true })` |
1847
+ | `await expect(expectEmission(x$)).rejects.toBe(originalError)` | `await expect(expectError(x$)).resolves.toBe(originalError)` |
1848
+ | `source$.pipe(skip(1))` / `pipe(filter(p))` in front of the helper | `expectEmission(source$, { skip: 1 })` / `{ until: p }` |
1849
+ | hold the promise, `vi.runAllTimers()`, then await | `expectEmission(source$, { advance: () => vi.runAllTimers() })` |
1850
+ | `injectSpy(X)` then a `mockReturnValue` per method in `beforeEach` | `provideAutoSpy(X, { returns: { … }, overrides: { … } })` |
1851
+ | a local `injectSpy` wrapper with `as never` + `as Spy<T>` | the library's — it also takes an `InjectionToken` |
1852
+ | a hand-written double for a token with `Observable` members | `provideAutoSpyForToken(T, undefined, { observablePropsToSpyOn: […] })` |
1853
+ | `mockDeep<T>() as unknown as T` to satisfy an API typed against `T` | `asInstance(mockDeep<T>())` |
1854
+ | `from([double])` to stop `of(double)` swallowing the double | `of(double)` — `schedule` is no longer answered (§2) |
1855
+ | `spy.instanceField.mockReturnValue(…)` on a member Angular moved | `provideAutoSpy(X, { instanceMethodsToSpyOn: ['…'] })` (§5) |
1233
1856
  | `expect(component.total).toBeTruthy()` (a signal) | `expect(component.total).toHaveSignalValue(3)` |
1234
1857
  | `fixture.detectChanges()` then assert signal state | `await stable(fixture)` then assert |
1235
1858
  | `onlyMethodsToSpyOn: [...]` "to add a method" | omit it, or use `instanceMethodsToSpyOn` |
@@ -1246,7 +1869,7 @@ packages, which a subpath export can never be.
1246
1869
  | ten `await Promise.resolve()` for a dynamic `import()` | `await settleDynamicImport(() => import('…'))` |
1247
1870
  | `await fixture.whenRenderingDone()` | `await stable(fixture)` |
1248
1871
  | an exported `const` provider with `vi.fn()` inside | an exported **factory** returning it (§10) |
1249
- | `.overrideProvider(X, provideAutoSpy(X))` (silent no-op) | `.overrideProvider(X, overrideAutoSpy(X))` |
1872
+ | `.overrideProvider(X, provideAutoSpy(X))` (works, but says the wrong thing) | `.overrideProvider(X, overrideAutoSpy(X))` |
1250
1873
  | `TestBed.overrideComponent` to swap a provider | `overrideComponentProvider(Cmp, X)` |
1251
1874
  | `{ target, isIntersecting } as unknown as IntersectionObserverEntry` | `intersectionEntry(target, true)` |
1252
1875
  | an assertion containing a date, with no clock set | `mockSystemTime(iso)` first |
@@ -1254,7 +1877,18 @@ packages, which a subpath export can never be.
1254
1877
  | `vi.mock('@angular/core')` to neutralise `effect()` | set the signals, `await stable(fixture)`, assert the result |
1255
1878
  | a second `vi.spyOn(console, 'error')` | `consoleErrorSpy` from `vitest-auto-spy/console` |
1256
1879
  | `mockReadonlyProp(c, 'items', vi.fn(() => []))` | `mockReadonlyProp(c, 'items', signal([]))` — a real signal |
1257
-
1880
+ | `spy.m.mockReturnValue(subject$)` for a `vi.fn(() => subject$)` | `spy.m.mockImplementation(() => subject$)` — the variable is re-read |
1881
+
1882
+
1883
+ **The one mechanical rename in a migration that is not equivalent.** `vi.fn(() => x)` reads `x`
1884
+ when the double is *called*; `mockReturnValue(x)` freezes the value `x` had when the double was
1885
+ *configured*. They are indistinguishable until the test reassigns `x` — and the commonest reason to
1886
+ do that is a fresh `Subject` after the previous one has been `error()`ed or completed, which is
1887
+ exactly the case a suite is testing when it reassigns. The double then keeps handing out the dead
1888
+ one: in one spec the service received a completed subject and silently skipped the modal it was
1889
+ meant to show, with the test still green. Carry `vi.fn(() => x)` over as
1890
+ `mockImplementation(() => x)`, and keep `mockReturnValue` for a literal. Worth saying out loud to
1891
+ anyone writing a codemod, because the rename looks like the safest edit in the file.
1258
1892
  ---
1259
1893
 
1260
1894
  ## 19. Before you report success
@@ -1268,3 +1902,21 @@ npx tsc --noEmit # Spy<T> mistakes are compile errors, not
1268
1902
 
1269
1903
  Type errors matter here more than usual: most of this library's guarantees are type-level, so a
1270
1904
  suite that runs green but does not type-check is not done.
1905
+
1906
+ ### If you are writing a codemod over specs
1907
+
1908
+ Two traps, both found the hard way on rxjs-heavy code.
1909
+
1910
+ **`String.prototype.replace` interprets `$` in the replacement.** `$&`, `` $` ``, `$'` and `$n` are
1911
+ substitution patterns, and `$'` — "everything after the match" — is one character away from every
1912
+ observable name in the codebase. A replacement containing `forceRequeryAndStartPlaybackAt$'`
1913
+ inserted the entire remainder of the file into itself and left an unterminated string; the only
1914
+ thing that caught it was ESLint's `Parsing error`. Pass a function, which is never interpreted:
1915
+
1916
+ ```ts
1917
+ source.replace(from, () => to); // not source.replace(from, to)
1918
+ ```
1919
+
1920
+ **`node.getStart()` excludes leading comments.** A codemod that replaces a range starting there
1921
+ silently eats the `// eslint-disable-next-line` above the node. Use `node.getFullStart()`, or count
1922
+ the comments before and after and compare against `HEAD`.