vitest-auto-spy 3.7.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.
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
  ---
@@ -1166,10 +1187,10 @@ inlined when the mock would be installed, so the real implementation runs and th
1166
1187
  for the wrong reason or fails somewhere unrelated.
1167
1188
 
1168
1189
  ```ts
1169
- import * as engine from '@app/player-engine';
1190
+ import * as engine from '@app/pricing-engine';
1170
1191
 
1171
- vi.mock('@app/player-engine');
1172
- 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'] }));
1173
1194
  ```
1174
1195
 
1175
1196
  And when a mocked dependency probes itself with `mod.default ?? mod` — every package that ships both
@@ -1215,7 +1236,7 @@ result:
1215
1236
  ```ts
1216
1237
  provideAutoSpy(FavoritesService, {
1217
1238
  returns: { load: of([]) },
1218
- overrides: { favoritesCacheUpdated$: of(undefined), favoriteVODs: [] },
1239
+ overrides: { favoritesCacheUpdated$: of(undefined), favoriteItems: [] },
1219
1240
  });
1220
1241
 
1221
1242
  provideAutoSpyForToken(PRODUCTS, undefined, { returns: { getProducts: of([]), getById: of(null) } });
@@ -1338,8 +1359,8 @@ nodes, so the helper silently rips the element out of the fixture it was just as
1338
1359
  Worth reading before the rest of this section: it has now come up twice in one migration wave, and
1339
1360
  both times the failure landed in a different file from its cause.
1340
1361
 
1341
- `@Component({ providers: [RemoveProfileService] })` declares the provider on the **element**
1342
- 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`
1343
1364
  loses to it — so the component builds the **real** service. Nothing warns. What fails is whatever
1344
1365
  the real service touches first: in the observed case a logger, with
1345
1366
  `TypeError: Cannot read properties of undefined (reading 'pipe')`, which names neither the component
@@ -1349,10 +1370,10 @@ Two things fix it, and which one depends on whether the double is wanted:
1349
1370
 
1350
1371
  ```ts
1351
1372
  // keep a double, but put it where the component will look
1352
- const menu = overrideComponentProvider(SmartVodComponent, MenuBuilderService);
1373
+ const menu = overrideComponentProvider(CatalogPageComponent, NavigationBuilderService);
1353
1374
 
1354
1375
  // or take the component's own provider away, so the module-level one is reached again
1355
- TestBed.overrideComponent(ProfileComponent, { remove: { providers: [RemoveProfileService] } });
1376
+ TestBed.overrideComponent(ProfileComponent, { remove: { providers: [DeleteAccountService] } });
1356
1377
  ```
1357
1378
 
1358
1379
  `overrideComponentProvider` is the one to reach for by default — it also queues the component with
@@ -1377,10 +1398,10 @@ do that, because a testing-module provider loses to one the component declares:
1377
1398
  ```ts
1378
1399
  import { overrideAutoSpy, overrideComponentProvider } from 'vitest-auto-spy/angular';
1379
1400
 
1380
- const menu = overrideComponentProvider(SmartVodComponent, MenuBuilderService); // → Spy<MenuBuilderService>
1401
+ const menu = overrideComponentProvider(CatalogPageComponent, NavigationBuilderService); // → Spy<NavigationBuilderService>
1381
1402
 
1382
1403
  // or, when the component is already in the testing module:
1383
- TestBed.configureTestingModule({ … }).overrideProvider(PaymentToolService, overrideAutoSpy(PaymentToolService));
1404
+ TestBed.configureTestingModule({ … }).overrideProvider(PaymentMethodService, overrideAutoSpy(PaymentMethodService));
1384
1405
  ```
1385
1406
 
1386
1407
  `overrideProvider(X, provideAutoSpy(X))` is **not** broken, contrary to what this section used to
@@ -1489,9 +1510,20 @@ flushEffects(); // an httpResource issues NO request until something ticks
1489
1510
  httpTesting.expectOne('/api/products').flush([product]);
1490
1511
  await settleResource(products, { label: 'the product resource' });
1491
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
+
1492
1518
  // signal assertions
1493
1519
  registerSignalMatchers(); // once, in the setup file
1494
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/);
1495
1527
  ```
1496
1528
 
1497
1529
  Two zoneless traps:
@@ -1507,6 +1539,13 @@ that asserts too early asserts the default and passes. `settleResource` fails in
1507
1539
  emptily. Note the order — `flushEffects()` first (the request is issued there, not on creation),
1508
1540
  then the flush, then the wait.
1509
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
+
1510
1549
  Per-file timing, to find which specs actually pay for `TestBed`:
1511
1550
 
1512
1551
  ```ts
@@ -1528,8 +1567,10 @@ if (process.env['SPEC_TIMING']) {
1528
1567
  preload = ["vitest-auto-spy/bun-angular"]
1529
1568
  ```
1530
1569
 
1531
- It re-exports everything in this section except `registerSignalMatchers` and the TestBed
1532
- 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.
1533
1574
 
1534
1575
  ---
1535
1576
 
@@ -1557,8 +1598,8 @@ which platform and which providers is not this library's decision.
1557
1598
  ### A dependency behind an `InjectionToken`
1558
1599
 
1559
1600
  ```ts
1560
- providers: [provideAutoSpyForToken(PIN_CODE_SERVICE_TOKEN)];
1561
- const pinCode = injectSpy(PIN_CODE_SERVICE_TOKEN); // Spy<PinCodeService>
1601
+ providers: [provideAutoSpyForToken(PASSCODE_SERVICE_TOKEN)];
1602
+ const passcode = injectSpy(PASSCODE_SERVICE_TOKEN); // Spy<PasscodeService>
1562
1603
  ```
1563
1604
 
1564
1605
  A token typed with an interface has no class to read, so the habit is a `…Mock` class written in the
@@ -1918,11 +1959,20 @@ Run what the project actually has — check its `package.json` first.
1918
1959
  ```bash
1919
1960
  npx vitest run path/to/file.spec.ts # or: bun test path/to/file.test.ts
1920
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
1921
1963
  ```
1922
1964
 
1923
1965
  Type errors matter here more than usual: most of this library's guarantees are type-level, so a
1924
1966
  suite that runs green but does not type-check is not done.
1925
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
+
1926
1976
  ### If you are writing a codemod over specs
1927
1977
 
1928
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.7%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)
@@ -55,13 +55,14 @@ identical API, with **RxJS** spies and **Angular / NestJS / React / Vue·Pinia /
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)
@@ -190,6 +194,65 @@ them only for the matching entry point. The package itself has **zero runtime de
190
194
  | `rxjs` | `vitest-auto-spy/rxjs` observable spies (and `Spy<T>` type-checking) — `>=7`, **no upper bound** (the rxjs 8 line included) | yes |
191
195
  | `@angular/core` | `vitest-auto-spy/angular` helpers | yes |
192
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
+
193
256
  ## Using this library with an AI agent
194
257
 
195
258
  Most tests are now written with an assistant in the loop, so this package ships documentation
@@ -245,7 +308,14 @@ already exists.
245
308
 
246
309
  ### Install it in your agent
247
310
 
248
- 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:
249
319
 
250
320
  ```bash
251
321
  # 1 — AGENTS.md: Codex, Cursor, Copilot, Cline, Windsurf, Zed, OpenCode, Qwen, Roo, Junie, Aider…
@@ -712,7 +782,7 @@ Node / Bun / React / Vue project pulls **neither rxjs nor Angular into its runti
712
782
  | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | :----: |
713
783
  | `vitest-auto-spy` | `createSpyFromClass`, `createAutoMock`, `createFunctionSpy`, sync + promise + accessor spies, `errorHandler`, types | `vitest` | ✅ |
714
784
  | `vitest-auto-spy/rxjs` | observable spies (`nextWith`, `nextWithValues`, `observablePropsToSpyOn`, …) + `createObservableWithValues` | `rxjs` | ✅ |
715
- | `vitest-auto-spy/angular` | `provideAutoSpy`, `injectSpy`, `renderShallow`, `createWithAutoSpies`, `stable`/`flushEffects`, `settleResource`, 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` | ✅ |
716
786
  | `vitest-auto-spy/bun` | the same core, driven by Bun's `bun:test` mocks | `bun:test` | ✅ |
717
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` | ✅ |
718
788
  | `vitest-auto-spy/node` | the same core, driven by `node:test`'s `mock.fn()` | `node:test` | ✅ |
@@ -1285,6 +1355,31 @@ resource and the flush it is missing.
1285
1355
  `flushEventLoopUntil` cannot do this: it takes real event-loop turns and never ticks, so a resource
1286
1356
  awaited through it finishes the budget having issued zero requests.
1287
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
+
1288
1383
  #### Asserting a signal's value
1289
1384
 
1290
1385
  ```ts
@@ -1498,12 +1593,15 @@ single-purpose utility you can pick up independently — they all ride on the sa
1498
1593
  | `assertMocked(namespace, opts?)` | core | Fail when the `vi.mock()` a spec relies on silently did not apply (a bundled alias, `isolate: false`) |
1499
1594
  | `moduleNamespace(exports, opts?)` | core | The `vi.mock` factory result an interop probe recognises — `default` + `__esModule` in place |
1500
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` |
1501
1597
  | `asInstances(...spies)` | core | `asInstance` for a whole argument list — one edit against one compiler error, not five |
1502
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 |
1503
1599
  | `withOverrides(model, overrides?)` | core | A fixture from a model instance: its getters read once, as data — a spread drops them |
1504
1600
  | `compareTestRuns(a, b, root?)` | core | Whether a migration lost a test — the set of `file::name`, which matching counters cannot answer |
1505
1601
  | `provideAutoSpyForToken(TOKEN, overrides?)` | `/angular` | The provider for a dependency behind an `InjectionToken` — no stand-in class to write |
1506
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 |
1507
1605
  | `registerDirectiveMatchers()` | `/angular` | Adds `expect(fixture).toHaveDirectiveApplied(Directive, selector?)` |
1508
1606
  | `installProxyZonePatch(opts?)` | `/zone` | `fakeAsync` / `waitForAsync` on Vitest — the patch `zone.js/testing` does not ship; `scope: 'callback'` per callback |
1509
1607
  | `autoMocked<T>(overrides?)` | core | `createAutoMock` typed as `T & Spy<T>`, for a collaborator passed as an argument rather than injected |
package/dist/angular.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- export { l as AngularTokenProvider, A as AngularValueProvider, a as AutoSpiedInstance, C as ComponentInputs, b as CreateWithAutoSpiesOptions, R as RenderShallowOptions, c as ResourceStatusLike, S as SettleResourceOptions, d as ShallowRender, e as SpyRegistry, f as StableOptions, g as createWithAutoSpies, h as flushEffects, i as injectSpy, p as provideAutoSpy, m as provideAutoSpyForToken, r as renderShallow, j as runEffect, s as settleResource, k as stable } from './zoneless-DbVZ96iR.js';
1
+ export { l as AngularTokenProvider, A as AngularValueProvider, a as AutoSpiedInstance, C as ComponentInputs, b as CreateWithAutoSpiesOptions, R as RenderShallowOptions, c as ResourceStatusLike, S as SettleResourceOptions, d as ShallowRender, e as SpyRegistry, f as StableOptions, g as createWithAutoSpies, h as flushEffects, i as injectSpy, p as provideAutoSpy, m as provideAutoSpyForToken, r as renderShallow, j as runEffect, s as settleResource, k as stable } from './zoneless-Ch2Ym_6z.js';
2
2
  import { Type, Signal, WritableSignal } from '@angular/core';
3
- import { S as Spy, C as ClassType, a as ClassSpyConfiguration, O as OnlyMethodKeysOf } from './types-dZUFYsox.js';
4
- export { A as AccessorImplementations, R as RestoreProp, c as countMockedProps, m as mockAccessorsProp, a as mockReadonlyProp, b as mockReadonlyPropGetter, d as mockValueProp, r as restoreMockedProps } from './prop-mock-CKFksCvA.js';
5
- export { C as CallbackSubscribable, E as EmissionObserver, a as EmissionOptions, b as EmissionSource, S as SubscribableLike, e as expectCompletion, f as expectEmission, g as expectEmissions, h as expectError, i as expectNoEmission, s as setEmissionTimeout } from './expect-emission-RtR1iYgI.js';
3
+ import { S as Spy, C as ClassType, a as ClassSpyConfiguration, O as OnlyMethodKeysOf, j as AddSpyMethodsByReturnTypes } from './types-W3lPrwC7.js';
4
+ export { A as AccessorImplementations, R as RestoreProp, c as countMockedProps, m as mockAccessorsProp, a as mockReadonlyProp, b as mockReadonlyPropGetter, d as mockValueProp, r as restoreMockedProps } from './prop-mock-CeHqAeIG.js';
5
+ export { C as CallbackSubscribable, E as EmissionObserver, a as EmissionOptions, b as EmissionSource, S as SubscribableLike, e as expectCompletion, f as expectEmission, g as expectEmissions, h as expectError, i as expectNoEmission, s as setEmissionTimeout } from './expect-emission-Cpj2GGcD.js';
6
6
  import '@angular/core/testing';
7
7
  import 'rxjs';
8
8
  import 'vitest';
@@ -39,9 +39,9 @@ interface AutoSpyOverride<T> {
39
39
  * An auto-spy wrapped as a `TestBed.overrideProvider` value.
40
40
  *
41
41
  * ```ts
42
- * const payments = overrideAutoSpy(PaymentToolService);
42
+ * const payments = overrideAutoSpy(PaymentMethodService);
43
43
  *
44
- * TestBed.configureTestingModule({ imports: [CheckoutComponent] }).overrideProvider(PaymentToolService, payments);
44
+ * TestBed.configureTestingModule({ imports: [CheckoutComponent] }).overrideProvider(PaymentMethodService, payments);
45
45
  * payments.useValue.charge.resolveWith({ ok: true });
46
46
  * ```
47
47
  *
@@ -58,7 +58,7 @@ declare function overrideAutoSpy<T>(ObjectClass: ClassType<T>, methodsToSpyOnOrC
58
58
  * a component the testing module never mentions is never compiled by it.
59
59
  *
60
60
  * ```ts
61
- * const menu = overrideComponentProvider(SmartVodComponent, MenuBuilderService);
61
+ * const menu = overrideComponentProvider(CatalogPageComponent, NavigationBuilderService);
62
62
  *
63
63
  * menu.build.mockReturnValue([]); // the component's own provider is now the spy
64
64
  * const fixture = TestBed.createComponent(HostComponent);
@@ -212,6 +212,112 @@ declare module 'vitest' {
212
212
  */
213
213
  declare function registerDirectiveMatchers(): void;
214
214
 
215
+ /**
216
+ * Driving an Angular resource from a spec, without any HTTP at all.
217
+ *
218
+ * `httpResource()` and `resource()` are the primitives a modern Angular service exposes, and a spec
219
+ * that wants to assert "the component shows the empty state while products are loading" has, until
220
+ * now, had to produce that state the long way: configure `provideHttpClientTesting`, tick so the
221
+ * request is issued, find it on the `HttpTestingController`, flush it, then settle. Six steps and a
222
+ * real request, to arrive at a value the spec picked in advance.
223
+ *
224
+ * {@link settleResource} is the answer when the request is the point. This is the answer when it is
225
+ * not — the shallow one, for a suite that tests business logic and never wanted a request in the
226
+ * first place. The property is replaced by a hand-built double whose statuses the spec sets
227
+ * directly, so nothing is ever in flight and there is nothing to wait for: no tick, no flush, no
228
+ * budget, and no way for the test to pass against a resource's default value by accident.
229
+ *
230
+ * Reactivity is genuine, exactly as in {@link mockSignalProp}: the double is built out of real
231
+ * `signal()`s from `@angular/core`, so a `computed()` reading `products.value()` recomputes and an
232
+ * `effect()` watching `products.status()` runs. A plain object with the same keys would satisfy
233
+ * every read and notify nothing.
234
+ *
235
+ * `@angular/core` stays an optional peer the same way the rest of this surface does: `ResourceRef`
236
+ * is only ever a *type* here, and the value handed to the property is assembled from `signal()`.
237
+ */
238
+
239
+ /**
240
+ * The resource statuses Angular defines, as a string union.
241
+ *
242
+ * Declared here rather than imported so this module keeps `@angular/core` to a type-only
243
+ * dependency in spirit as well as in fact — Angular moved this from an enum to a union in v20, and
244
+ * a local union works against both without a version guard.
245
+ */
246
+ type ResourceDoubleStatus = 'error' | 'idle' | 'loading' | 'local' | 'reloading' | 'resolved';
247
+ /**
248
+ * The double installed on the property — the slice of `ResourceRef` a component actually reads.
249
+ *
250
+ * Structural on purpose: a component typed against `ResourceRef<T>` reads `value`, `status`,
251
+ * `error`, `isLoading`, `hasValue` and `reload`, and this provides all six with the same shapes.
252
+ * The members `ResourceRef` has that a *consumer* never calls — `asReadonly`, `destroy`, `update` —
253
+ * are deliberately absent, because a double that answers a call nobody should be making is how a
254
+ * typo survives a test run.
255
+ */
256
+ interface ResourceDouble<TValue> {
257
+ /** The current value. Writable through the returned handle, readonly to the code under test. */
258
+ value: Signal<TValue>;
259
+ /** `'resolved'` unless the spec moved it — see {@link MockedResource.loading} / `fail`. */
260
+ status: Signal<ResourceDoubleStatus>;
261
+ /** The error behind an `'error'` status, `undefined` otherwise. */
262
+ error: Signal<Error | undefined>;
263
+ /** `true` while the status is `'loading'` or `'reloading'`, matching Angular's own derivation. */
264
+ isLoading: Signal<boolean>;
265
+ /** `true` when the status is `'resolved'` or `'local'` — that is, when `value()` means anything. */
266
+ hasValue(): boolean;
267
+ /** Spied, and inert: a double has no request to re-issue, so the spec asserts the call instead. */
268
+ reload: AddSpyMethodsByReturnTypes<() => boolean>;
269
+ }
270
+ /** The spec's handle on a resource installed by {@link mockResourceProp}. */
271
+ interface MockedResource<TValue> {
272
+ /** Resolve the resource with a value — status `'resolved'`, error cleared. */
273
+ set(value: TValue): void;
274
+ /** Fail the resource — status `'error'`, `error()` set, `hasValue()` false. */
275
+ fail(error: Error | string): void;
276
+ /** Put the resource back in flight — status `'loading'`, `hasValue()` false. */
277
+ loading(): void;
278
+ /** The spied `reload()`; `expect(products.reload).toHaveBeenCalled()`. */
279
+ reload: AddSpyMethodsByReturnTypes<() => boolean>;
280
+ /** The double now behind the property, for asserting on it directly. */
281
+ resource: ResourceDouble<TValue>;
282
+ }
283
+ /**
284
+ * Replace a resource-valued property with a double the spec drives directly.
285
+ *
286
+ * ```ts
287
+ * const service = injectSpy(ProductService);
288
+ * const products = mockResourceProp(service, 'products', []);
289
+ *
290
+ * expect(component.emptyState()).toBe(true);
291
+ *
292
+ * products.set([product]);
293
+ * await stable(fixture);
294
+ *
295
+ * expect(component.emptyState()).toBe(false);
296
+ *
297
+ * products.fail('offline');
298
+ * expect(component.errorMessage()).toBe('offline');
299
+ * ```
300
+ *
301
+ * The resource starts `'resolved'` at `initialValue`, because that is the state a spec asserts
302
+ * against most and the one it would otherwise have to arrange. `loading()` and `fail()` are how the
303
+ * other two states are reached, and each is a single synchronous call — the point of this helper is
304
+ * that there is no asynchrony to get wrong. When a spec *does* want the real request path, that is
305
+ * `settleResource` over a real `httpResource`, not this.
306
+ *
307
+ * Undone by `restoreMockedProps()` like every other property patch, so a suite running
308
+ * `setupAutoSpy()` needs no teardown of its own.
309
+ *
310
+ * @param object The spy (or real instance) whose property to replace.
311
+ * @param property The resource-valued property.
312
+ * @param initialValue The value the resource starts resolved at.
313
+ * @returns The handle driving that resource — `set` / `fail` / `loading`, plus the spied `reload`.
314
+ */
315
+ declare function mockResourceProp<T, K extends keyof T>(object: T, property: K, initialValue: T[K] extends {
316
+ value: Signal<infer TValue>;
317
+ } ? TValue : never): MockedResource<T[K] extends {
318
+ value: Signal<infer TValue>;
319
+ } ? TValue : never>;
320
+
215
321
  /**
216
322
  * Driving a service's signal from a spec.
217
323
  *
@@ -264,6 +370,42 @@ declare function registerDirectiveMatchers(): void;
264
370
  */
265
371
  declare function mockSignalProp<T, K extends keyof T>(object: T, property: K, initialValue: T[K] extends Signal<infer TValue> ? TValue : never): WritableSignal<T[K] extends Signal<infer TValue> ? TValue : never>;
266
372
 
373
+ /** The slice of a resource these matchers read. `error` is absent on some hand-built doubles. */
374
+ interface ResourceLike<TValue = unknown> {
375
+ status(): string;
376
+ value(): TValue;
377
+ error?(): Error | undefined;
378
+ }
379
+ declare module 'vitest' {
380
+ interface Matchers<T = any> {
381
+ /** The resource is still in flight — `status()` is `'loading'` or `'reloading'`. */
382
+ toBeLoading(): T;
383
+ /** The resource has resolved *and* its value deep-equals the expected one. */
384
+ toHaveResourceValue(expected: unknown): T;
385
+ /** The resource has failed; with an argument, its error message matches too. */
386
+ toHaveResourceError(expected?: RegExp | string): T;
387
+ }
388
+ }
389
+ /**
390
+ * Register the resource matchers with the runner. Call once, from your setup file.
391
+ *
392
+ * @example
393
+ * ```ts
394
+ * registerResourceMatchers(); // once, in the setup file
395
+ *
396
+ * expect(component.products).toBeLoading();
397
+ *
398
+ * httpTesting.expectOne('/api/products').flush([product]);
399
+ * await settleResource(component.products);
400
+ *
401
+ * expect(component.products).toHaveResourceValue([product]);
402
+ * ```
403
+ *
404
+ * `toHaveResourceValue` deliberately fails a resource that is still loading even when its default
405
+ * value happens to match, because that is the assertion this whole family exists to stop passing.
406
+ */
407
+ declare function registerResourceMatchers(): void;
408
+
267
409
  /** Anything readable like a signal: `signal()`, `computed()`, `input()`, or a plain getter. */
268
410
  type SignalLike<T> = () => T;
269
411
  declare module 'vitest' {
@@ -378,4 +520,4 @@ declare function reportSpecTiming(timing: SpecTiming): void;
378
520
  */
379
521
  declare function enableTestBedDiagnostics(options?: TestBedDiagnosticsOptions): void;
380
522
 
381
- export { type AngularTestEnvMode, type AngularTestEnvOptions, type AutoSpyOverride, type DirectiveHostOptions, type SignalLike, type SpecTiming, type TestBedDiagnosticsOptions, assertNgModuleScopes, createDirectiveHost, disableTestBedDiagnostics, enableTestBedDiagnostics, formatSpecTiming, getTestBedTiming, instrumentTestBed, mockSignalProp, overrideAutoSpy, overrideComponentProvider, registerDirectiveMatchers, registerSignalMatchers, reportSpecTiming, setupAngularTestEnv };
523
+ export { type AngularTestEnvMode, type AngularTestEnvOptions, type AutoSpyOverride, type DirectiveHostOptions, type MockedResource, type ResourceDouble, type ResourceDoubleStatus, type ResourceLike, type SignalLike, type SpecTiming, type TestBedDiagnosticsOptions, assertNgModuleScopes, createDirectiveHost, disableTestBedDiagnostics, enableTestBedDiagnostics, formatSpecTiming, getTestBedTiming, instrumentTestBed, mockResourceProp, mockSignalProp, overrideAutoSpy, overrideComponentProvider, registerDirectiveMatchers, registerResourceMatchers, registerSignalMatchers, reportSpecTiming, setupAngularTestEnv };