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