vitest-auto-spy 3.6.0 → 3.8.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.
Files changed (38) hide show
  1. package/AGENTS.md +94 -24
  2. package/README.md +136 -7
  3. package/dist/angular.d.ts +150 -8
  4. package/dist/angular.js +121 -6
  5. package/dist/bun-angular.d.ts +5 -5
  6. package/dist/bun-angular.js +5 -5
  7. package/dist/bun.d.ts +75 -22
  8. package/dist/bun.js +4 -4
  9. package/dist/{chunk-QGZRH5XG.js → chunk-2PFOBMTZ.js} +1 -1
  10. package/dist/{chunk-RJJJTLQ3.js → chunk-DNHLQG45.js} +1 -1
  11. package/dist/{chunk-LSDPJMNS.js → chunk-ISSGEVLO.js} +101 -29
  12. package/dist/{chunk-F3NMY5KU.js → chunk-TMO2UFLD.js} +95 -49
  13. package/dist/{chunk-OEQTH7RA.js → chunk-WT75WGQZ.js} +72 -31
  14. package/dist/cli.js +1150 -0
  15. package/dist/console.d.ts +1 -1
  16. package/dist/console.js +1 -1
  17. package/dist/{expect-emission-RtR1iYgI.d.ts → expect-emission-Cpj2GGcD.d.ts} +1 -1
  18. package/dist/index.d.ts +4 -4
  19. package/dist/index.js +4 -4
  20. package/dist/nestjs.d.ts +1 -1
  21. package/dist/nestjs.js +2 -2
  22. package/dist/node.cjs +170 -56
  23. package/dist/node.d.cts +60 -1821
  24. package/dist/node.d.ts +4 -4
  25. package/dist/node.js +4 -4
  26. package/dist/{prop-mock-CKFksCvA.d.ts → prop-mock-CeHqAeIG.d.ts} +1 -1
  27. package/dist/react.d.ts +4 -4
  28. package/dist/react.js +4 -4
  29. package/dist/rxjs.d.ts +2 -2
  30. package/dist/setup.d.ts +2 -2
  31. package/dist/svelte.d.ts +4 -4
  32. package/dist/svelte.js +4 -4
  33. package/dist/{types-dZUFYsox.d.ts → types-W3lPrwC7.d.ts} +3 -3
  34. package/dist/vue.d.ts +5 -5
  35. package/dist/vue.js +5 -5
  36. package/dist/{run-effect-C_7mFldc.d.ts → zoneless-Ch2Ym_6z.d.ts} +149 -82
  37. package/package.json +7 -3
  38. package/skills/vitest-auto-spy/SKILL.md +22 -3
package/AGENTS.md CHANGED
@@ -269,7 +269,7 @@ import { type Spy, injectSpy, mockReadonlyProp, provideAutoSpy } from 'vitest-au
269
269
 
270
270
  describe('TaskService', () => {
271
271
  let projects: Spy<ProjectStore>;
272
- let feed: Spy<FeedService>;
272
+ let feed: Spy<NewsFeedService>;
273
273
  let service: TaskService;
274
274
 
275
275
  beforeEach(() => {
@@ -277,12 +277,12 @@ describe('TaskService', () => {
277
277
  providers: [
278
278
  provideAutoSpy(NotificationService), // plain service — nothing to configure
279
279
  provideAutoSpy(ProjectStore, { instanceMethodsToSpyOn: ['current', 'isEmpty'] }), // signals
280
- provideAutoSpy(FeedService, { observablePropsToSpyOn: ['connected$'] }), // Observable props
280
+ provideAutoSpy(NewsFeedService, { observablePropsToSpyOn: ['connected$'] }), // Observable props
281
281
  ],
282
282
  });
283
283
 
284
284
  projects = injectSpy(ProjectStore);
285
- feed = injectSpy(FeedService);
285
+ feed = injectSpy(NewsFeedService);
286
286
 
287
287
  feed.connected$.nextWith(true); // seed the defaults every test needs, once
288
288
  projects.save.mockReturnValue(of(true));
@@ -369,6 +369,27 @@ const subject = feed.items$.returnSubject(); // ReplaySubject, for anything the
369
369
  `mock.settledResults` is native on Vitest and polyfilled on Bun / `node:test`, so it is identical on
370
370
  all three. Entries are `{ type: 'fulfilled' | 'incomplete' | 'rejected', value }`.
371
371
 
372
+ When the argument worth asserting on is one the **code under test built** — a callback, a config
373
+ object, an `AbortSignal` — describing its shape is the wrong tool. `expect.any(Function)` says a
374
+ function was passed; `captureArg` hands it to you so the test can call it:
375
+
376
+ ```ts
377
+ import { captureArg } from 'vitest-auto-spy';
378
+
379
+ const onDone = captureArg<() => void>();
380
+
381
+ expect(notifier.subscribe).toHaveBeenCalledWith('ready', onDone);
382
+
383
+ onDone.value(); // and now exercise what was passed
384
+ expect(component.finished()).toBe(true);
385
+ ```
386
+
387
+ `.values` holds every match, oldest first; `.captured` asks without triggering the "nothing was
388
+ captured" throw; `.reset()` lets one captor serve two phases. **Assertions only** — a captor matches
389
+ every value, so putting one in `calledWith` would configure a return for every call, which is
390
+ `mockReturnValue` spelled less clearly, and `calledWith` is typed to the method's own parameters so
391
+ it will not compile anyway.
392
+
372
393
  **The observable helpers are backed by a `ReplaySubject(1)` that belongs to the spy, and it is
373
394
  configuration — so it must be reset with the rest of it.** Two failures used to come out of that
374
395
  buffer outliving the test that filled it, and both were silent:
@@ -569,8 +590,8 @@ constructor and produces `Service<any>`, and the `any` surfaces much later as a
569
590
  it says "type parameter":
570
591
 
571
592
  ```ts
572
- const config = asSpy<RemoteConfigService>(TestBed.inject(RemoteConfigService)); // ✅
573
- const config = injectSpy<RemoteConfigService>(RemoteConfigService); // ✅
593
+ const config = asSpy<FeatureFlagService>(TestBed.inject(FeatureFlagService)); // ✅
594
+ const config = injectSpy<FeatureFlagService>(FeatureFlagService); // ✅
574
595
  ```
575
596
 
576
597
  ---
@@ -1067,6 +1088,7 @@ mechanisms, and a test that waits on the wrong one fails with a message that nam
1067
1088
  | effects + `afterNextRender` + CD | `await stable(fixture)` (`…/angular`) | `detectChanges()` alone |
1068
1089
  | timers, debounces, polling | `await advanceTimers(ms)` (`…/setup`) | `await Promise.resolve()` |
1069
1090
  | a dynamic `import()`, native `async` in a dep | `await flushEventLoop()` / `settleDynamicImport()` | `tick()`, `flushMicrotasks()`, microtasks |
1091
+ | an `httpResource()` / `resource()` / `rxResource` | `await settleResource(r)` (`…/angular`) | `flushEventLoopUntil` — it never ticks |
1070
1092
 
1071
1093
  ```ts
1072
1094
  import { flushEventLoop, settleDynamicImport } from 'vitest-auto-spy';
@@ -1088,9 +1110,14 @@ Three rules worth stating outright, because each of them cost a day somewhere:
1088
1110
  and a non-zero exit code.
1089
1111
 
1090
1112
  `flushEventLoopUntil(isDone, { turns, label })` is the same thing with a condition and a budget —
1091
- for a `resource()` leaving `loading`, a chunk becoming reachable, an SDK reporting itself ready. Use
1092
- it instead of a hand-tuned turn count: the count depends on the dependency, not on the spec, and a
1093
- condition that never holds fails naming the `label` rather than hanging until the runner's timeout.
1113
+ for a chunk becoming reachable, an SDK reporting itself ready, a queue draining. Use it instead of a
1114
+ hand-tuned turn count: the count depends on the dependency, not on the spec, and a condition that
1115
+ never holds fails naming the `label` rather than hanging until the runner's timeout.
1116
+
1117
+ **Not for an Angular `resource()` / `httpResource()`.** Those need a change-detection _tick_, and
1118
+ this helper only takes event-loop turns — a resource awaited through it finishes the whole budget
1119
+ having issued zero requests. `settleResource(resource, { turns, label })` from
1120
+ `vitest-auto-spy/angular` is that wait.
1094
1121
 
1095
1122
  `flushEventLoop(turns?)` takes real event-loop turns even while the timers are faked, without
1096
1123
  touching the clock. It is the honest name for the `await vi.advanceTimersByTimeAsync(0)` trick,
@@ -1160,10 +1187,10 @@ inlined when the mock would be installed, so the real implementation runs and th
1160
1187
  for the wrong reason or fails somewhere unrelated.
1161
1188
 
1162
1189
  ```ts
1163
- import * as engine from '@app/player-engine';
1190
+ import * as engine from '@app/pricing-engine';
1164
1191
 
1165
- vi.mock('@app/player-engine');
1166
- beforeEach(() => assertMocked(engine, { specifier: '@app/player-engine', exports: ['createEngine'] }));
1192
+ vi.mock('@app/pricing-engine');
1193
+ beforeEach(() => assertMocked(engine, { specifier: '@app/pricing-engine', exports: ['createEngine'] }));
1167
1194
  ```
1168
1195
 
1169
1196
  And when a mocked dependency probes itself with `mod.default ?? mod` — every package that ships both
@@ -1209,7 +1236,7 @@ result:
1209
1236
  ```ts
1210
1237
  provideAutoSpy(FavoritesService, {
1211
1238
  returns: { load: of([]) },
1212
- overrides: { favoritesCacheUpdated$: of(undefined), favoriteVODs: [] },
1239
+ overrides: { favoritesCacheUpdated$: of(undefined), favoriteItems: [] },
1213
1240
  });
1214
1241
 
1215
1242
  provideAutoSpyForToken(PRODUCTS, undefined, { returns: { getProducts: of([]), getById: of(null) } });
@@ -1332,8 +1359,8 @@ nodes, so the helper silently rips the element out of the fixture it was just as
1332
1359
  Worth reading before the rest of this section: it has now come up twice in one migration wave, and
1333
1360
  both times the failure landed in a different file from its cause.
1334
1361
 
1335
- `@Component({ providers: [RemoveProfileService] })` declares the provider on the **element**
1336
- injector, and a module-level `provideAutoSpy(RemoveProfileService)` in `configureTestingModule`
1362
+ `@Component({ providers: [DeleteAccountService] })` declares the provider on the **element**
1363
+ injector, and a module-level `provideAutoSpy(DeleteAccountService)` in `configureTestingModule`
1337
1364
  loses to it — so the component builds the **real** service. Nothing warns. What fails is whatever
1338
1365
  the real service touches first: in the observed case a logger, with
1339
1366
  `TypeError: Cannot read properties of undefined (reading 'pipe')`, which names neither the component
@@ -1343,10 +1370,10 @@ Two things fix it, and which one depends on whether the double is wanted:
1343
1370
 
1344
1371
  ```ts
1345
1372
  // keep a double, but put it where the component will look
1346
- const menu = overrideComponentProvider(SmartVodComponent, MenuBuilderService);
1373
+ const menu = overrideComponentProvider(CatalogPageComponent, NavigationBuilderService);
1347
1374
 
1348
1375
  // or take the component's own provider away, so the module-level one is reached again
1349
- TestBed.overrideComponent(ProfileComponent, { remove: { providers: [RemoveProfileService] } });
1376
+ TestBed.overrideComponent(ProfileComponent, { remove: { providers: [DeleteAccountService] } });
1350
1377
  ```
1351
1378
 
1352
1379
  `overrideComponentProvider` is the one to reach for by default — it also queues the component with
@@ -1371,10 +1398,10 @@ do that, because a testing-module provider loses to one the component declares:
1371
1398
  ```ts
1372
1399
  import { overrideAutoSpy, overrideComponentProvider } from 'vitest-auto-spy/angular';
1373
1400
 
1374
- const menu = overrideComponentProvider(SmartVodComponent, MenuBuilderService); // → Spy<MenuBuilderService>
1401
+ const menu = overrideComponentProvider(CatalogPageComponent, NavigationBuilderService); // → Spy<NavigationBuilderService>
1375
1402
 
1376
1403
  // or, when the component is already in the testing module:
1377
- TestBed.configureTestingModule({ … }).overrideProvider(PaymentToolService, overrideAutoSpy(PaymentToolService));
1404
+ TestBed.configureTestingModule({ … }).overrideProvider(PaymentMethodService, overrideAutoSpy(PaymentMethodService));
1378
1405
  ```
1379
1406
 
1380
1407
  `overrideProvider(X, provideAutoSpy(X))` is **not** broken, contrary to what this section used to
@@ -1474,12 +1501,29 @@ spies.get(PricingService).total.mockReturnValue(100);
1474
1501
  // NOTE: Injector.create() — it does NOT accept EnvironmentProviders (provideHttpClient() etc.)
1475
1502
 
1476
1503
  // zoneless waiting
1477
- await stable(fixture); // flush effects, then await the fixture
1504
+ await stable(fixture); // flush effects, then await the fixture; fails at 2000 ms naming the cause
1505
+ await stable(fixture, { timeout: 5000, label: 'the products fixture' });
1478
1506
  flushEffects(); // the no-fixture half: services, stores, runInInjectionContext
1479
1507
 
1508
+ // resources — one wait for httpResource(), resource() and rxResource()
1509
+ flushEffects(); // an httpResource issues NO request until something ticks
1510
+ httpTesting.expectOne('/api/products').flush([product]);
1511
+ await settleResource(products, { label: 'the product resource' });
1512
+
1513
+ // ...or skip the request entirely when it is not what the spec is about
1514
+ const products = mockResourceProp(service, 'products', []);
1515
+ products.set([product]); // 'resolved' products.loading() products.fail('offline')
1516
+ expect(products.reload).toHaveBeenCalled(); // reload is spied, and re-issues nothing
1517
+
1480
1518
  // signal assertions
1481
1519
  registerSignalMatchers(); // once, in the setup file
1482
1520
  expect(component.total).toHaveSignalValue(3);
1521
+
1522
+ // resource assertions — value AND status, which is the whole point
1523
+ registerResourceMatchers(); // once, in the setup file
1524
+ expect(component.products).toBeLoading();
1525
+ expect(component.products).toHaveResourceValue([product]);
1526
+ expect(component.products).toHaveResourceError(/503/);
1483
1527
  ```
1484
1528
 
1485
1529
  Two zoneless traps:
@@ -1489,6 +1533,19 @@ Two zoneless traps:
1489
1533
  - `expect(someSignal).toBeTruthy()` passes for **every** signal ever created — a signal is a
1490
1534
  function. Use `toHaveSignalValue`, which also rejects the missing-parentheses mistake.
1491
1535
 
1536
+ And one resource trap, which is the same shape one level up: an `httpResource()` reports `loading`
1537
+ with its **default** value until a tick _and_ a microtask after its response is flushed, so a spec
1538
+ that asserts too early asserts the default and passes. `settleResource` fails instead of passing
1539
+ emptily. Note the order — `flushEffects()` first (the request is issued there, not on creation),
1540
+ then the flush, then the wait.
1541
+
1542
+ `toHaveResourceValue` is the matcher form of that trap and the reason to prefer it over
1543
+ `expect(products.value()).toEqual(...)`: it **fails an unresolved resource even when the default
1544
+ value matches**, and names the status it was in. And when the request is not what the spec is about
1545
+ at all, do not arrange one — `mockResourceProp(service, 'products', [])` replaces the property with
1546
+ a double whose `set` / `fail` / `loading` move it directly, built from real `signal()`s so a
1547
+ `computed()` downstream still recomputes. Nothing is in flight, so there is nothing to await.
1548
+
1492
1549
  Per-file timing, to find which specs actually pay for `TestBed`:
1493
1550
 
1494
1551
  ```ts
@@ -1510,8 +1567,10 @@ if (process.env['SPEC_TIMING']) {
1510
1567
  preload = ["vitest-auto-spy/bun-angular"]
1511
1568
  ```
1512
1569
 
1513
- It re-exports everything in this section except `registerSignalMatchers` and the TestBed
1514
- diagnostics, which need the runner's `expect.extend` and suite-level hooks.
1570
+ It re-exports everything in this section except `registerSignalMatchers`,
1571
+ `registerResourceMatchers`, `mockSignalProp` / `mockResourceProp` and the TestBed diagnostics — the
1572
+ matchers and diagnostics need the runner's `expect.extend` and suite-level hooks, and the `mock*Prop`
1573
+ family is not re-exported there either.
1515
1574
 
1516
1575
  ---
1517
1576
 
@@ -1539,8 +1598,8 @@ which platform and which providers is not this library's decision.
1539
1598
  ### A dependency behind an `InjectionToken`
1540
1599
 
1541
1600
  ```ts
1542
- providers: [provideAutoSpyForToken(PIN_CODE_SERVICE_TOKEN)];
1543
- const pinCode = injectSpy(PIN_CODE_SERVICE_TOKEN); // Spy<PinCodeService>
1601
+ providers: [provideAutoSpyForToken(PASSCODE_SERVICE_TOKEN)];
1602
+ const passcode = injectSpy(PASSCODE_SERVICE_TOKEN); // Spy<PasscodeService>
1544
1603
  ```
1545
1604
 
1546
1605
  A token typed with an interface has no class to read, so the habit is a `…Mock` class written in the
@@ -1802,7 +1861,9 @@ packages, which a subpath export can never be.
1802
1861
  | a stub that works in the first test of the file and in no other | installed at `describe` level or in `beforeAll`, then restored away | install it in `beforeEach`, or `installPerTest(() => stub…())` |
1803
1862
  | 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` |
1804
1863
  | `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()` |
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 })` |
1864
+ | a hand-tuned number of turns waiting for a `resource()` to load | a resource needs a change-detection **tick**, not event-loop turns; `flushEventLoopUntil` never ticks and the resource never even issues its request | `flushEffects()`, flush the request, then `await settleResource(r, { label })` |
1865
+ | a `resource()` assertion that passes but reads the **default** value | the spec asserted before the resource left `loading` | `await settleResource(r, { label })` — it fails loudly instead |
1866
+ | a spec dying on the runner's 5 s file timeout right after `await stable(fixture)` | the fixture never stabilised — an unflushed request, a real `setInterval` | `stable` now fails at 2000 ms naming the cause; raise `{ timeout }` only once neither is true |
1806
1867
  | `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
1868
  | 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) |
1808
1869
  | `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 |
@@ -1898,11 +1959,20 @@ Run what the project actually has — check its `package.json` first.
1898
1959
  ```bash
1899
1960
  npx vitest run path/to/file.spec.ts # or: bun test path/to/file.test.ts
1900
1961
  npx tsc --noEmit # Spy<T> mistakes are compile errors, not runtime ones
1962
+ npx vitest-auto-spy doctor # suite-level defects that never fail a run
1901
1963
  ```
1902
1964
 
1903
1965
  Type errors matter here more than usual: most of this library's guarantees are type-level, so a
1904
1966
  suite that runs green but does not type-check is not done.
1905
1967
 
1968
+ `doctor` is read-only and finds what a green run cannot: a `tsconfig` `include` pattern that
1969
+ matches no file (so it type-checks nothing while `tsc --noEmit` still reports success), a
1970
+ production module importing a `*.spec.ts`, a spec importing another spec, a `@jest-environment`
1971
+ pragma the runner never reads, and configuration left behind for a runner that is gone. It is
1972
+ worth one run after any large edit to a test suite — especially after a codemod, which is where
1973
+ the eaten glob below came from. Full reference:
1974
+ <https://asdalexey.github.io/vitest-auto-spy/utilities/cli>.
1975
+
1906
1976
  ### If you are writing a codemod over specs
1907
1977
 
1908
1978
  Two traps, both found the hard way on rxjs-heavy code.
package/README.md CHANGED
@@ -16,7 +16,7 @@ identical API, with **RxJS** spies and **Angular / NestJS / React / Vue·Pinia /
16
16
  [![npm version](https://img.shields.io/npm/v/vitest-auto-spy?color=brightgreen&logo=npm)](https://www.npmjs.com/package/vitest-auto-spy)
17
17
  [![npm downloads](https://img.shields.io/npm/dm/vitest-auto-spy?color=brightgreen&logo=npm)](https://www.npmjs.com/package/vitest-auto-spy)
18
18
  [![CI](https://github.com/ASDAlexey/vitest-auto-spy/actions/workflows/ci.yml/badge.svg)](https://github.com/ASDAlexey/vitest-auto-spy/actions/workflows/ci.yml)
19
- [![minzipped size](https://img.shields.io/badge/minzip-12.5%20kB-brightgreen)](#install)
19
+ [![minzipped size](https://img.shields.io/badge/minzip-13.0%20kB-brightgreen)](#install)
20
20
  [![types](https://img.shields.io/npm/types/vitest-auto-spy?logo=typescript&logoColor=white)](https://www.npmjs.com/package/vitest-auto-spy)
21
21
  [![coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/ASDAlexey/vitest-auto-spy/actions/workflows/ci.yml)
22
22
  [![license](https://img.shields.io/npm/l/vitest-auto-spy?color=blue)](./LICENSE)
@@ -48,20 +48,21 @@ identical API, with **RxJS** spies and **Angular / NestJS / React / Vue·Pinia /
48
48
  - 📡 First-class RxJS `Observable` spying (`nextWith`, `nextWithValues`, `throwWith`, …)
49
49
  - ⚙️ Getter / setter spies via `accessorSpies`
50
50
  - 🧰 DI & mocking utilities — `provideAutoSpy` / `injectSpy` (Angular, NestJS, Vue), `createFunctionSpy`, `mockReadonlyProp` for signals
51
- - ⚡ Angular speed & zoneless helpers — `renderShallow` (**1.7×** on real component specs), `createWithAutoSpies`, `stable` / `flushEffects`, `toHaveSignalValue`, per-file `TestBed` timings
51
+ - ⚡ Angular speed & zoneless helpers — `renderShallow` (**1.7×** on real component specs), `createWithAutoSpies`, `stable` / `flushEffects`, `settleResource` for `httpResource()`, `toHaveSignalValue`, per-file `TestBed` timings
52
52
  - 🧱 The providers a testing module cannot reach — `overrideComponentProvider`, `provideAutoSpyForToken`, `assertNgModuleScopes`, `createDirectiveHost`
53
53
  - 📡 Observable assertions that fail on silence — `expectEmission` / `expectEmissions` / `expectNoEmission` / `expectCompletion` / `expectError`, no rxjs required, Angular `output()` included
54
54
  - 🏗️ Doubles for what the code builds itself — `mockConstructor` / `stubConstructor` for `new`, plus `stubMediaElement`, `stubAbortController` and the observer stubs
55
55
  - ⏳ Waiting that is not a guess — `flushEventLoop`, `settleDynamicImport`, `flushEventLoopUntil`, and a clock that survives fake timers (`mockSystemTime`, `useCountingClock`)
56
56
  - 🌀 `fakeAsync` / `waitForAsync` on Vitest — one import of `vitest-auto-spy/zone`; zone.js stays out of every other entry
57
57
  - 🧩 Module mocks that prove they applied — `assertMocked`, `moduleNamespace`, for a `vi.mock()` a bundler quietly ignored
58
- - 🧾 Fixtures without casts — deep-partial `createMock`, `narrow()`, `withOverrides()`, `asInstances()`
58
+ - 🧾 Fixtures without casts — deep-partial `createMock`, `narrow()`, `withOverrides()`, `asInstances()`, `captureArg()`
59
59
  - 🚚 A migration you can verify — `compareTestRuns` on the two JSON reports, `diffByField` for the assertion the reporter collapses
60
60
  - 📏 Lint rules and one-line test-run hygiene — twelve rules in `vitest-auto-spy/eslint-plugin` (two `--fix`, three suggestions), `setupAutoSpy()`
61
61
  - 🩺 [Editor diagnostics](#editor-diagnostics--webstorm--vs-code) — the same anti-patterns underlined while you type: native ESLint inspections in **WebStorm** and the other JetBrains IDEs, the ESLint extension in **VS Code**, no extra plugin either way
62
+ - 🔎 [`npx vitest-auto-spy doctor`](#the-cli--doctor-and-init) — suite-level defects **that never fail a run**: a `tsconfig` `include` matching no file, a production module importing a spec, a `@jest-environment` pragma the runner never reads, config left behind for a runner that is gone. Read-only, no config, exits 1 in CI
62
63
  - 🔇 Console spies — `import { consoleInfoSpy } from 'vitest-auto-spy/console'` silences `console` and asserts its calls
63
64
  - 🧭 [**Spec patterns**](https://asdalexey.github.io/vitest-auto-spy/recipes) — the shapes a ~370-file Angular suite converged on, and the traps that only surface at scale
64
- - 🤖 Built for AI agents too — an offline [`AGENTS.md`](#using-this-library-with-an-ai-agent) inside the package, a [per-agent map](#which-file-your-agent-reads) for **Claude Code**, **OpenAI Codex**, **GLM/z.ai**, **Cursor**, **Copilot**, **Gemini CLI** and the rest, `llms.txt` on the docs site, a Claude Code skill, and errors that name their own fix
65
+ - 🤖 Built for AI agents too — one `npx vitest-auto-spy init` writes the pointer into the files your agents actually read and specialises it for this repository, backed by an offline [`AGENTS.md`](#using-this-library-with-an-ai-agent) inside the package, a [per-agent map](#which-file-your-agent-reads) for **Claude Code**, **OpenAI Codex**, **GLM/z.ai**, **Cursor**, **Copilot**, **Gemini CLI** and the rest, `llms.txt` on the docs site, a Claude Code skill, and errors that name their own fix
65
66
  - 🟢 100% test coverage, **zero runtime dependencies** (in-tree arg serializer, no `javascript-stringify`)
66
67
 
67
68
  ## Table of contents
@@ -69,6 +70,9 @@ identical API, with **RxJS** spies and **Angular / NestJS / React / Vue·Pinia /
69
70
  - [Install](#install)
70
71
  - [Requirements](#requirements)
71
72
  - [Peer dependencies](#peer-dependencies)
73
+ - [The CLI — `doctor` and `init`](#the-cli--doctor-and-init)
74
+ - [`doctor` — defects that never fail](#doctor--defects-that-never-fail)
75
+ - [`init` — the pointer an agent reads](#init--the-pointer-an-agent-reads)
72
76
  - [Using this library with an AI agent](#using-this-library-with-an-ai-agent)
73
77
  - [Point your agent at it once](#point-your-agent-at-it-once)
74
78
  - [Which file your agent reads](#which-file-your-agent-reads)
@@ -112,6 +116,7 @@ identical API, with **RxJS** spies and **Angular / NestJS / React / Vue·Pinia /
112
116
  - [Shallow component rendering](#shallow-component-rendering)
113
117
  - [Building a class with auto-spied dependencies](#building-a-class-with-auto-spied-dependencies)
114
118
  - [Zoneless waiting](#zoneless-waiting)
119
+ - [Settling a `resource()` or `httpResource()`](#settling-a-resource-or-httpresource)
115
120
  - [Asserting a signal's value](#asserting-a-signals-value)
116
121
  - [Where a spec spends its time](#where-a-spec-spends-its-time)
117
122
  - [NestJS](#nestjs)
@@ -189,6 +194,65 @@ them only for the matching entry point. The package itself has **zero runtime de
189
194
  | `rxjs` | `vitest-auto-spy/rxjs` observable spies (and `Spy<T>` type-checking) — `>=7`, **no upper bound** (the rxjs 8 line included) | yes |
190
195
  | `@angular/core` | `vitest-auto-spy/angular` helpers | yes |
191
196
 
197
+ ## The CLI — `doctor` and `init`
198
+
199
+ The package ships one executable, with no dependencies and nothing to configure:
200
+
201
+ ```bash
202
+ npx vitest-auto-spy doctor # read-only. Exits 1 when it finds something
203
+ npx vitest-auto-spy init # writes the agent instructions pointer
204
+ ```
205
+
206
+ ### `doctor` — defects that never fail
207
+
208
+ Every check shares one property: **nothing consumes the result**. The suite is green,
209
+ `tsc --noEmit` reports zero errors, and the only reader of the stale thing is whoever opens the
210
+ file. That is why they survive for years, and why a per-file linter cannot find most of them — the
211
+ evidence is spread across files.
212
+
213
+ ```
214
+ $ npx vitest-auto-spy doctor
215
+ vitest-auto-spy doctor — /work/app
216
+ 1284 files, runner: vitest, entry: vitest-auto-spy/angular
217
+
218
+ error tsconfig-glob-matches-nothing libs/users/tsconfig.spec.json
219
+ The "include" pattern "src*.spec.ts" matches no file.
220
+ → A pattern that matches nothing type-checks nothing, and `tsc --noEmit` still reports
221
+ zero errors. Fix the glob or delete the entry.
222
+
223
+ 3 errors, 4 warnings, 1 note
224
+ ```
225
+
226
+ | Check | What it finds |
227
+ | ------------------------------ | -------------------------------------------------------------------- |
228
+ | `tsconfig-glob-matches-nothing` | An `include` pattern that matches no file — so it type-checks nothing |
229
+ | `tsconfig-file-missing` | A `files` entry naming a file that is gone |
230
+ | `spec-imported-by-non-spec` | A production module importing a `*.spec.ts` |
231
+ | `spec-exports-fixture` | A spec importing another spec, whose hooks then run in a foreign file |
232
+ | `foreign-runner-pragma` | `@jest-environment` left in a spec, which Vitest never reads |
233
+ | `dead-runner-config` | `jest.config.*` / `karma.conf.*` for a runner that is not installed |
234
+ | `orphan-runner-file` | A setup file only that dead config referenced |
235
+ | `angular-build-splitting-off` | `@angular/build` in `[22.1.5, 22.1.7)` — the OOM under `--coverage` |
236
+ | `no-agent-instructions` | No instruction file names the package. A note, not an error |
237
+
238
+ The check that motivated the tool: a spec showing `Cannot find name 'vi'` in the editor while
239
+ `tsc --noEmit` reported zero errors. A migration codemod editing `include` had eaten a `/**`,
240
+ turning `src/**/*.spec.ts` into `src*.spec.ts` — a valid glob that matches nothing. Nine of 152
241
+ spec tsconfigs still covered their specs.
242
+
243
+ `doctor` never writes. There is no `--fix`.
244
+
245
+ ### `init` — the pointer an agent reads
246
+
247
+ No coding agent scans dependencies for instructions, so the `AGENTS.md` and the skill shipped
248
+ inside this package's tarball are never discovered on their own. `init` writes the pointer into
249
+ the files that *are* read — `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, a Claude Code skill stub, and a
250
+ glob-scoped rule file for each tool whose directory already exists — and specialises it for this
251
+ repository's runner, framework and setup file. Everything sits between markers, so a re-run is a
252
+ no-op and `--uninstall` puts the files back.
253
+
254
+ Full reference, including the flags and the CI form: **[The CLI](https://asdalexey.github.io/vitest-auto-spy/utilities/cli)**.
255
+
192
256
  ## Using this library with an AI agent
193
257
 
194
258
  Most tests are now written with an assistant in the loop, so this package ships documentation
@@ -244,7 +308,14 @@ already exists.
244
308
 
245
309
  ### Install it in your agent
246
310
 
247
- Two commands at the repository root cover every tool in that table:
311
+ One command covers every tool in that table, and specialises the text for this repository:
312
+
313
+ ```bash
314
+ npx vitest-auto-spy init # write it
315
+ npx vitest-auto-spy init --check # CI: fail when it is missing or out of date
316
+ ```
317
+
318
+ By hand, the same thing is two commands at the repository root:
248
319
 
249
320
  ```bash
250
321
  # 1 — AGENTS.md: Codex, Cursor, Copilot, Cline, Windsurf, Zed, OpenCode, Qwen, Roo, Junie, Aider…
@@ -711,7 +782,7 @@ Node / Bun / React / Vue project pulls **neither rxjs nor Angular into its runti
711
782
  | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | :----: |
712
783
  | `vitest-auto-spy` | `createSpyFromClass`, `createAutoMock`, `createFunctionSpy`, sync + promise + accessor spies, `errorHandler`, types | `vitest` | ✅ |
713
784
  | `vitest-auto-spy/rxjs` | observable spies (`nextWith`, `nextWithValues`, `observablePropsToSpyOn`, …) + `createObservableWithValues` | `rxjs` | ✅ |
714
- | `vitest-auto-spy/angular` | `provideAutoSpy`, `injectSpy`, `renderShallow`, `createWithAutoSpies`, `stable`/`flushEffects`, the `mock*Prop` helpers, signal matchers, TestBed diagnostics | `@angular/core` | ✅ |
785
+ | `vitest-auto-spy/angular` | `provideAutoSpy`, `injectSpy`, `renderShallow`, `createWithAutoSpies`, `stable`/`flushEffects`, `settleResource`, `mockResourceProp`, the `mock*Prop` helpers, signal & resource matchers, TestBed diagnostics | `@angular/core` | ✅ |
715
786
  | `vitest-auto-spy/bun` | the same core, driven by Bun's `bun:test` mocks | `bun:test` | ✅ |
716
787
  | `vitest-auto-spy/bun-angular` | Angular's `TestBed` under `bun test` — DOM, JIT `templateUrl` resolution and a zoneless environment, from one preload | `bun:test`, `@angular/core` | ✅ |
717
788
  | `vitest-auto-spy/node` | the same core, driven by `node:test`'s `mock.fn()` | `node:test` | ✅ |
@@ -1255,6 +1326,60 @@ app the state that matters is signal-derived and effects are what move it forwar
1255
1326
  both, in the right order; `flushEffects` prefers `TestBed.tick()` (Angular ≥ 20) and falls back to
1256
1327
  `ApplicationRef.tick()`.
1257
1328
 
1329
+ The wait is bounded: `stable` gives the fixture **2000 ms** and then throws the cause, instead of
1330
+ letting the runner report a 5 s file-level timeout that names neither the helper nor the fixture.
1331
+ Pass `{ timeout, label }` to change either; `{ timeout: 0 }` waits indefinitely. The watchdog runs
1332
+ on a timer captured at import, so `vi.useFakeTimers()` cannot freeze it.
1333
+
1334
+ #### Settling a `resource()` or `httpResource()`
1335
+
1336
+ ```ts
1337
+ import { flushEffects, settleResource } from 'vitest-auto-spy/angular';
1338
+
1339
+ const products = TestBed.runInInjectionContext(() => httpResource<Product[]>(() => '/api/products'));
1340
+
1341
+ flushEffects(); // the request is issued here — not when the resource was created
1342
+ TestBed.inject(HttpTestingController).expectOne('/api/products').flush([product]);
1343
+ await settleResource(products, { label: 'the product resource' });
1344
+
1345
+ expect(products.value()).toEqual([product]);
1346
+ ```
1347
+
1348
+ Angular's resource primitives need a **different wait each** — measured on 21.2.17, an
1349
+ `httpResource` settles one tick + one microtask after its flush, a plain `resource()` takes two
1350
+ rounds of the same, and neither has made a request at all until something ticks. Getting it wrong
1351
+ asserts against the resource's _default_ value, which is a green test proving nothing.
1352
+ `settleResource` is the loop both converge under, with a turn budget and a failure that names the
1353
+ resource and the flush it is missing.
1354
+
1355
+ `flushEventLoopUntil` cannot do this: it takes real event-loop turns and never ticks, so a resource
1356
+ awaited through it finishes the budget having issued zero requests.
1357
+
1358
+ #### Driving a resource with no HTTP at all
1359
+
1360
+ ```ts
1361
+ import { mockResourceProp, registerResourceMatchers } from 'vitest-auto-spy/angular';
1362
+
1363
+ const products = mockResourceProp(service, 'products', []);
1364
+
1365
+ products.set([product]); // 'resolved'
1366
+ products.loading(); // back in flight
1367
+ products.fail('offline'); // 'error', error() is Error('offline')
1368
+
1369
+ expect(products.reload).toHaveBeenCalled(); // reload is spied and re-issues nothing
1370
+ ```
1371
+
1372
+ Everything above is the answer when the request _is_ the point. Often it is not — the spec is about
1373
+ the component's own logic and the value was chosen in advance. `mockResourceProp` replaces the
1374
+ property with a double the spec moves directly, so nothing is ever in flight: no tick, no
1375
+ `HttpTestingController`, no budget. It is built from real `signal()`s, so a `computed()` reading
1376
+ `products.value()` still recomputes and an `effect()` still runs. Undone by `restoreMockedProps()`.
1377
+
1378
+ And `registerResourceMatchers()` adds `toBeLoading` / `toHaveResourceValue` / `toHaveResourceError`,
1379
+ which read the value **and** the status. `toHaveResourceValue` is the one that earns its place: it
1380
+ fails an unresolved resource **even when its default value matches**, which is exactly the assertion
1381
+ `expect(products.value()).toEqual([])` lets through.
1382
+
1258
1383
  #### Asserting a signal's value
1259
1384
 
1260
1385
  ```ts
@@ -1468,12 +1593,15 @@ single-purpose utility you can pick up independently — they all ride on the sa
1468
1593
  | `assertMocked(namespace, opts?)` | core | Fail when the `vi.mock()` a spec relies on silently did not apply (a bundled alias, `isolate: false`) |
1469
1594
  | `moduleNamespace(exports, opts?)` | core | The `vi.mock` factory result an interop probe recognises — `default` + `__esModule` in place |
1470
1595
  | `diffByField(actual, expected)` | core | Which field of an array of records moved, and in how many elements — the diff the reporter collapses |
1596
+ | `captureArg<T>()` | core | Take hold of a callback or config the code under test built, instead of describing its shape — assertions only, never `calledWith` |
1471
1597
  | `asInstances(...spies)` | core | `asInstance` for a whole argument list — one edit against one compiler error, not five |
1472
1598
  | `narrow(value, guard)` / `narrow.byKey` / `narrow.observable` | core | The branch of a union a test knows it got, failing with the shape the value actually had |
1473
1599
  | `withOverrides(model, overrides?)` | core | A fixture from a model instance: its getters read once, as data — a spread drops them |
1474
1600
  | `compareTestRuns(a, b, root?)` | core | Whether a migration lost a test — the set of `file::name`, which matching counters cannot answer |
1475
1601
  | `provideAutoSpyForToken(TOKEN, overrides?)` | `/angular` | The provider for a dependency behind an `InjectionToken` — no stand-in class to write |
1476
1602
  | `createDirectiveHost({ template, scope, props })` | `/angular` | A standalone host for a directive under test, with its scope where the compiler reads it |
1603
+ | `mockResourceProp(obj, prop, initial)` | `/angular` | Drive a resource with no HTTP — `set` / `fail` / `loading`, plus a spied `reload` |
1604
+ | `registerResourceMatchers()` | `/angular` | Adds `toBeLoading` / `toHaveResourceValue` / `toHaveResourceError`; the value matcher fails an unresolved resource |
1477
1605
  | `registerDirectiveMatchers()` | `/angular` | Adds `expect(fixture).toHaveDirectiveApplied(Directive, selector?)` |
1478
1606
  | `installProxyZonePatch(opts?)` | `/zone` | `fakeAsync` / `waitForAsync` on Vitest — the patch `zone.js/testing` does not ship; `scope: 'callback'` per callback |
1479
1607
  | `autoMocked<T>(overrides?)` | core | `createAutoMock` typed as `T & Spy<T>`, for a collaborator passed as an argument rather than injected |
@@ -1931,7 +2059,8 @@ Both are the same object at runtime; only the view changes.
1931
2059
  | `describeDuplicateCopies()` / `getPackageCopies()` | The duplicate-install report, and the copies behind it |
1932
2060
  | `renderShallow(Component, opts?)` _(Angular)_ | `TestBed` component, minus its children and (by default) its template |
1933
2061
  | `createWithAutoSpies(Class, opts?)` _(Angular)_ | Build a class through Angular DI with every unprovided token auto-spied |
1934
- | `stable(fixture)` / `flushEffects()` _(Angular)_ | Zoneless waiting: flush effects, then await the fixture |
2062
+ | `stable(fixture, opts?)` / `flushEffects()` _(Angular)_ | Zoneless waiting: flush effects, then await the fixture, with a 2 s budget that names the cause |
2063
+ | `settleResource(resource, opts?)` _(Angular)_ | Tick until an `httpResource()` / `resource()` / `rxResource()` leaves `loading` |
1935
2064
  | `registerSignalMatchers()` _(Angular)_ | Adds `expect(sig).toHaveSignalValue(value)` |
1936
2065
  | `enableTestBedDiagnostics(opts?)` _(Angular)_ | Per-file report of how much of a spec's time went into `TestBed` |
1937
2066
  | `setupAngularTestEnv(opts)` _(Angular)_ | Zone and zoneless spec files in one worker, switching platforms per file |