vitest-auto-spy 3.5.0 → 3.6.0

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

Potentially problematic release.


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

package/AGENTS.md CHANGED
@@ -12,6 +12,12 @@ node_modules/vitest-auto-spy/AGENTS.md
12
12
 
13
13
  Working on the library's own source instead? Read `CONTRIBUTING.md` in the repository.
14
14
 
15
+ Setting this up for a team? The README section
16
+ [Using this library with an AI agent](https://github.com/ASDAlexey/vitest-auto-spy#using-this-library-with-an-ai-agent)
17
+ names the instruction file each agent reads — `AGENTS.md` for OpenAI Codex, Cursor, Copilot and most
18
+ of the field, `CLAUDE.md` for Claude Code and for GLM (z.ai) or Kimi running inside it, `GEMINI.md`
19
+ for Gemini CLI — and gives the two commands that install this pointer for all of them.
20
+
15
21
  | Resource | Where |
16
22
  | ----------------------- | -------------------------------------------------------------------- |
17
23
  | Spec patterns at scale | <https://asdalexey.github.io/vitest-auto-spy/recipes> |
@@ -1663,12 +1669,13 @@ export default [{ files: ['**/*.spec.ts'], ...autoSpy.configs.recommended }];
1663
1669
  | `prefer-inject-spy` | `warn` | suggest | `vi.spyOn(TestBed.inject(X), 'm')`, inline or via a `const` → `injectSpy(X).m` |
1664
1670
  | `no-shared-module-level-mock` | `error` | — | an **exported** value holding `vi.fn()`s → export a factory instead |
1665
1671
  | `no-mocked-for-spy` | `warn` | `--fix` | `Mocked<T>` in any type position → `Spy<T>`, import and all |
1672
+ | `prefer-as-spy` | `warn` | `--fix` | `TestBed.inject(X) as Spy<X>` → `asSpy<X>(TestBed.inject(X))`, import and all |
1666
1673
  | `no-done-callback` | `error` | — | `it('x', (done) => …)` → `async` + an awaited assertion |
1667
1674
  | `no-floating-assertion` | `error` | — | `expect()` in a `.then()` nobody awaits → `expect(await promise)` |
1668
1675
  | `no-overridden-provider` | `error` | — | two providers for one token in one array → the earlier one never runs |
1669
1676
  | `no-inject-before-override` | `warn` | — | `TestBed.inject()` in a hook, in a suite that still calls `override*` |
1670
1677
 
1671
- Eleven rules; one fixes on its own, three offer suggestions. `no-mocked-for-spy` only ever touches a
1678
+ Twelve rules; two fix on their own, three offer suggestions. `no-mocked-for-spy` only ever touches a
1672
1679
  **type position**, where a wrong rewrite is a compile error rather than a test that quietly changed
1673
1680
  meaning — so `--fix` renames the type, adds `import type { Spy } from 'vitest-auto-spy'` and drops
1674
1681
  the orphaned `Mocked` import. Every type position, not only a `let`: a factory's return type, a
@@ -1677,6 +1684,17 @@ in all eight reports — fix one and leave the other and the file says both. It
1677
1684
  cannot prove the rename (a `Mocked` the file declares itself, a `Spy` that is already something
1678
1685
  else, `Mocked<{ a: Mock }>` rather than a named type) and reports without a fix.
1679
1686
 
1687
+ `prefer-as-spy` is the same licence from the other end, and the one a migration meets in bulk: a
1688
+ `jest-auto-spies` suite writes `TestBed.inject(X) as Spy<X>` once per injected double, and that cast
1689
+ fails here with `TS2352` — `Spy<T>` adds `accessorSpies` and the per-method helpers, so neither type
1690
+ sufficiently overlaps the other. `asSpy` is a typed identity function, so `--fix` keeps the
1691
+ assertion the developer already made, carries the type arguments across (inference answers
1692
+ `Spy<Service<any>>` for a generic class), adds the `asSpy` import and removes a `Spy` import the
1693
+ rewrite orphans. A cast that hops through `unknown` is left alone — the hop says the value is not a
1694
+ `T` — except after `TestBed.inject(X)`, where the container returns `X` by construction and the hop
1695
+ was only silencing `TS2352`. Neither rule is for the object under test: a service the spec
1696
+ exercises is not a double, and typing it as the class is the repair there.
1697
+
1680
1698
  `no-expect-in-subscribe` reports one shape and **three different edits**, and says which: the
1681
1699
  subscription is the last thing the test does (invert it into `await firstValueFrom`); something
1682
1700
  after it is what makes the stream emit (hold the promise — `const p = expectEmission(src$)`, fire
package/README.md CHANGED
@@ -29,7 +29,7 @@ identical API, with **RxJS** spies and **Angular / NestJS / React / Vue·Pinia /
29
29
 
30
30
  📚 [**Documentation**](https://asdalexey.github.io/vitest-auto-spy/) · 🧭 [**Spec patterns**](https://asdalexey.github.io/vitest-auto-spy/recipes) · 📦 [**npm**](https://www.npmjs.com/package/vitest-auto-spy) · 🐙 [**GitHub**](https://github.com/ASDAlexey/vitest-auto-spy) · 🔖 [**Changelog**](./CHANGELOG.md)
31
31
 
32
- 🤖 [**AGENTS.md**](./AGENTS.md) · 🔤 [**llms.txt**](https://asdalexey.github.io/vitest-auto-spy/llms.txt) · 📄 [**llms-full.txt**](https://asdalexey.github.io/vitest-auto-spy/llms-full.txt) — see [Using this library with an AI agent](#using-this-library-with-an-ai-agent)
32
+ 🤖 [**AGENTS.md**](./AGENTS.md) · 🔤 [**llms.txt**](https://asdalexey.github.io/vitest-auto-spy/llms.txt) · 📄 [**llms-full.txt**](https://asdalexey.github.io/vitest-auto-spy/llms-full.txt) — works with [Claude Code, OpenAI Codex, GLM, Cursor, Copilot, Gemini CLI and the rest](#which-file-your-agent-reads)
33
33
 
34
34
  <br/>
35
35
 
@@ -57,48 +57,78 @@ identical API, with **RxJS** spies and **Angular / NestJS / React / Vue·Pinia /
57
57
  - 🧩 Module mocks that prove they applied — `assertMocked`, `moduleNamespace`, for a `vi.mock()` a bundler quietly ignored
58
58
  - 🧾 Fixtures without casts — deep-partial `createMock`, `narrow()`, `withOverrides()`, `asInstances()`
59
59
  - 🚚 A migration you can verify — `compareTestRuns` on the two JSON reports, `diffByField` for the assertion the reporter collapses
60
- - 📏 Lint rules and one-line test-run hygiene — eleven rules in `vitest-auto-spy/eslint-plugin` (one `--fix`, three suggestions), `setupAutoSpy()`
60
+ - 📏 Lint rules and one-line test-run hygiene — twelve rules in `vitest-auto-spy/eslint-plugin` (two `--fix`, three suggestions), `setupAutoSpy()`
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
61
62
  - 🔇 Console spies — `import { consoleInfoSpy } from 'vitest-auto-spy/console'` silences `console` and asserts its calls
62
63
  - 🧭 [**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
63
- - 🤖 Built for AI agents too — an offline [`AGENTS.md`](#using-this-library-with-an-ai-agent) inside the package, `llms.txt` on the docs site, a Claude Code skill, and errors that name their own fix
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
64
65
  - 🟢 100% test coverage, **zero runtime dependencies** (in-tree arg serializer, no `javascript-stringify`)
65
66
 
66
67
  ## Table of contents
67
68
 
68
69
  - [Install](#install)
70
+ - [Requirements](#requirements)
71
+ - [Peer dependencies](#peer-dependencies)
69
72
  - [Using this library with an AI agent](#using-this-library-with-an-ai-agent)
73
+ - [Point your agent at it once](#point-your-agent-at-it-once)
74
+ - [Which file your agent reads](#which-file-your-agent-reads)
75
+ - [Install it in your agent](#install-it-in-your-agent)
76
+ - [OpenAI Codex](#openai-codex)
77
+ - [GLM (z.ai), Kimi K2 and other Claude-compatible models](#glm-zai-kimi-k2-and-other-claude-compatible-models)
78
+ - [Gemini CLI](#gemini-cli)
79
+ - [Claude Code plugin](#claude-code-plugin)
70
80
  - [Availability](#availability)
71
81
  - [Quick start](#quick-start)
72
82
  - [How to mock](#how-to-mock)
83
+ - [A service behind Angular DI](#how-to-mock-a-service-behind-angular-di)
84
+ - [A service without DI](#how-to-mock-a-service-without-di)
85
+ - [Reading a spy back from DI](#how-to-mock-reading-a-spy-back-from-di)
86
+ - [A whole class's dependencies at once](#how-to-mock-a-whole-classs-dependencies-at-once)
87
+ - [A readonly property or a signal](#how-to-mock-a-readonly-property-or-a-signal)
88
+ - [An Observable](#how-to-mock-an-observable)
89
+ - [A promise a test forgets to await](#how-to-mock-a-promise-a-test-forgets-to-await)
90
+ - [A component's children](#how-to-mock-a-components-children)
91
+ - [A class the code under test builds with `new`](#how-to-mock-a-class-the-code-under-test-builds-with-new)
92
+ - [A double more than one spec uses](#how-to-mock-a-double-more-than-one-spec-uses)
93
+ - [A pipe](#how-to-mock-a-pipe)
73
94
  - [Why](#why)
74
95
  - [How it works (and what it won't spy)](#how-it-works-and-what-it-wont-spy)
75
96
  - [Entry points & runtimes](#entry-points--runtimes)
97
+ - [Runtimes](#runtimes)
76
98
  - [Angular on Bun (`bun:test`)](#angular-on-bun-buntest)
77
99
  - [Comparison](#comparison)
78
100
  - [Migrating from jest-auto-spies](#migrating-from-jest-auto-spies)
79
101
  - [Configuration](#configuration)
102
+ - [Spying instance-assigned callables (`signal()`, arrow props, `signalStore()`)](#spying-instance-assigned-callables-signal-arrow-props-signalstore)
80
103
  - [Auto-mock by type (no class needed)](#auto-mock-by-type-no-class-needed)
81
104
  - [Synchronous methods](#synchronous-methods)
82
105
  - [Promise-returning methods](#promise-returning-methods)
83
106
  - [Observable methods & properties](#observable-returning-methods--observable-properties)
107
+ - [Standalone observable builder](#standalone-observable-builder)
84
108
  - [Getters & setters](#getters--setters)
85
109
  - [Framework adapters](#framework-adapters)
86
- - [NestJS](#nestjs)
87
- - [React (Testing Library)](#react-testing-library)
88
- - [Vue / Pinia](#vue--pinia)
89
- - [Svelte](#svelte)
90
110
  - [Angular](#angular)
111
+ - [Signal / readonly property mocking (bonus)](#signal--readonly-property-mocking-bonus)
91
112
  - [Shallow component rendering](#shallow-component-rendering)
92
113
  - [Building a class with auto-spied dependencies](#building-a-class-with-auto-spied-dependencies)
93
114
  - [Zoneless waiting](#zoneless-waiting)
94
115
  - [Asserting a signal's value](#asserting-a-signals-value)
95
116
  - [Where a spec spends its time](#where-a-spec-spends-its-time)
117
+ - [NestJS](#nestjs)
118
+ - [React (Testing Library)](#react-testing-library)
119
+ - [Vue / Pinia](#vue--pinia)
120
+ - [Svelte](#svelte)
121
+ - [Which factory, and what it costs](#which-factory-and-what-it-costs)
96
122
  - [Utilities](#utilities)
123
+ - [Console spies — `vitest-auto-spy/console`](#console-spies--vitest-auto-spyconsole)
97
124
  - [Observable assertions](#observable-assertions)
98
125
  - [Test-run hygiene](#test-run-hygiene)
99
126
  - [Fake timers](#fake-timers)
100
127
  - [Observer stubs](#observer-stubs)
101
128
  - [ESLint plugin](#eslint-plugin)
129
+ - [Editor diagnostics — WebStorm & VS Code](#editor-diagnostics--webstorm--vs-code)
130
+ - [WebStorm and the other JetBrains IDEs](#webstorm-and-the-other-jetbrains-ides)
131
+ - [VS Code, Cursor, Windsurf, VSCodium](#vs-code-cursor-windsurf-vscodium)
102
132
  - [Bridging `Spy<T>` and `T`](#bridging-spyt-and-t)
103
133
  - [API reference](#api-reference)
104
134
  - [FAQ & troubleshooting](#faq--troubleshooting)
@@ -170,12 +200,12 @@ the decision tree, the configuration semantics, an error→fix table and the ant
170
200
  | [`AGENTS.md`](./AGENTS.md) | `node_modules/vitest-auto-spy/AGENTS.md` | any agent, **offline** — it ships inside the npm tarball |
171
201
  | [`llms.txt`](https://asdalexey.github.io/vitest-auto-spy/llms.txt) | the docs site root | a crawler picking the one page it needs |
172
202
  | [`llms-full.txt`](https://asdalexey.github.io/vitest-auto-spy/llms-full.txt) | the docs site root | reading the entire documentation in one fetch |
173
- | A Claude Code skill | `skills/vitest-auto-spy/SKILL.md`, also in the tarball | Claude Code, loaded on demand |
203
+ | A Claude Code skill | `skills/vitest-auto-spy/SKILL.md`, also in the tarball | Claude Code — and any client that *is* it, GLM included |
174
204
  | Runtime error messages | every thrown error ends with `Docs: <url>` | reading a stack trace instead of guessing |
175
205
 
176
206
  ### Point your agent at it once
177
207
 
178
- Add this to your project's `CLAUDE.md`, `AGENTS.md`, `.cursorrules` or equivalent:
208
+ Add this to the instruction file your agent actually reads the table below says which one that is:
179
209
 
180
210
  ```md
181
211
  When writing or fixing tests that use `vitest-auto-spy`, first read
@@ -183,6 +213,171 @@ When writing or fixing tests that use `vitest-auto-spy`, first read
183
213
  the configuration semantics and the common mistakes.
184
214
  ```
185
215
 
216
+ The text is the same everywhere; only the filename changes. **Two files cover the whole field: a
217
+ root `AGENTS.md` and a root `CLAUDE.md`.** Put the identical block in both and every agent below is
218
+ served — including the ones your teammates use and you do not.
219
+
220
+ ### Which file your agent reads
221
+
222
+ | Agent | Instruction file it reads | Reads `AGENTS.md`? |
223
+ | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
224
+ | **Claude Code** | `CLAUDE.md` — project, `.claude/CLAUDE.md` and `~/.claude/CLAUDE.md`, all concatenated | **No.** Bridge with an `@AGENTS.md` import line, or a symlink |
225
+ | **OpenAI Codex** — the `codex` CLI, the IDE extension, Codex cloud | `AGENTS.md`, one per directory from the git root down to the cwd ([below](#openai-codex)) | native |
226
+ | **GLM (z.ai coding plan)**, **Kimi K2** | whatever their client reads — inside Claude Code that is `CLAUDE.md` ([below](#glm-zai-kimi-k2-and-other-claude-compatible-models)) | through the client |
227
+ | **Cursor** | root `AGENTS.md`; `.cursor/rules/*.mdc` for glob-scoped rules | native — and it applies a root `CLAUDE.md` the same always-on way |
228
+ | **GitHub Copilot** | root `AGENTS.md`; `.github/copilot-instructions.md` | native, coding agent included |
229
+ | **OpenCode** | `AGENTS.md`, then `CLAUDE.md`, per directory upwards | native |
230
+ | **Cline** | root `AGENTS.md`; the `.clinerules/` directory | native |
231
+ | **Windsurf / Cascade** | root `AGENTS.md`; `.windsurf/rules/*.md` (`.devin/rules/*.md` when present) | yes |
232
+ | **Zed** | **first match wins, no merging**: `.rules` → `.cursorrules` → `.windsurfrules` → `.clinerules` → `.github/copilot-instructions.md` → `AGENT.md` → `AGENTS.md` → `CLAUDE.md` → `GEMINI.md` | yes — only if nothing earlier in that list exists |
233
+ | **Gemini CLI** | `GEMINI.md` ([below](#gemini-cli)) | **not by default** |
234
+ | **Qwen Code** | `QWEN.md` | native fallback |
235
+ | **Roo Code** | root `AGENTS.md`; `.roo/rules/` | yes |
236
+ | **Junie** | root `AGENTS.md` — note that `.junie/AGENTS.md` replaces it outright | yes |
237
+ | **Aider** | nothing implicitly — list the file: `read: [AGENTS.md]` in `.aider.conf.yml` | on request |
238
+ | **Jules, Factory, goose, Amp, Warp, Devin, Kilo, Augment, VS Code** | root `AGENTS.md` | native |
239
+
240
+ **Do not create `.rules`, `.cursorrules`, `.windsurfrules` or `.clinerules` just to hold this
241
+ snippet.** Zed resolves that list first-match-wins with no merging, so a newly created legacy file
242
+ silently shadows the `AGENTS.md` the rest of the project relies on. Append to one only if it
243
+ already exists.
244
+
245
+ ### Install it in your agent
246
+
247
+ Two commands at the repository root cover every tool in that table:
248
+
249
+ ```bash
250
+ # 1 — AGENTS.md: Codex, Cursor, Copilot, Cline, Windsurf, Zed, OpenCode, Qwen, Roo, Junie, Aider…
251
+ cat >> AGENTS.md <<'MD'
252
+
253
+ ## Tests that use `vitest-auto-spy`
254
+
255
+ When writing or fixing tests that use `vitest-auto-spy`, first read
256
+ `node_modules/vitest-auto-spy/AGENTS.md`. It is the authoritative reference for the API,
257
+ the configuration semantics and the common mistakes.
258
+ MD
259
+
260
+ # 2 — CLAUDE.md: Claude Code, and GLM / Kimi running inside it. One line, no second copy to maintain
261
+ printf '\n@AGENTS.md\n' >> CLAUDE.md
262
+ ```
263
+
264
+ `@AGENTS.md` is Claude Code's own import syntax, so the instructions live in exactly one file. A
265
+ symlink (`ln -s AGENTS.md CLAUDE.md`) does the same job if you would rather not have the second file
266
+ at all.
267
+
268
+ Then, per tool — everything in the right-hand column is optional on top of those two files:
269
+
270
+ | Agent | Install |
271
+ | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
272
+ | **Claude Code** | `/plugin marketplace add ASDAlexey/vitest-auto-spy`, then `/plugin install vitest-auto-spy@vitest-auto-spy` — [the skill](#claude-code-plugin), no project files touched |
273
+ | **OpenAI Codex** | nothing more; optionally `~/.codex/config.toml` from [below](#openai-codex) |
274
+ | **GLM (z.ai)**, **Kimi K2** | identical to Claude Code — same client, same plugin command |
275
+ | **Cursor** | `.cursor/rules/vitest-auto-spy.mdc` to load it only for spec files (see below) |
276
+ | **GitHub Copilot** | `.github/instructions/vitest-auto-spy.instructions.md` (see below) |
277
+ | **Cline** | `.clinerules/vitest-auto-spy.md` — the same three lines, plus `paths: ["**/*.spec.ts","**/*.test.ts"]` |
278
+ | **Windsurf / Cascade** | `.windsurf/rules/vitest-auto-spy.md` with `trigger: glob` (see below) |
279
+ | **Roo Code** | `.roo/rules/vitest-auto-spy.md` — always on, so keep it to the three-line pointer |
280
+ | **Gemini CLI** | `GEMINI.md`, or the `.gemini/settings.json` patch from [below](#gemini-cli) |
281
+ | **Aider** | `.aider.conf.yml`: `read: [AGENTS.md]` |
282
+ | **Zed, OpenCode, Qwen Code, Junie, Jules…** | nothing — the root `AGENTS.md` is the whole install |
283
+
284
+ The glob-scoped variants, for the three tools whose format is not plain Markdown. Each body is the
285
+ same pointer; only the frontmatter differs:
286
+
287
+ ```md
288
+ <!-- .cursor/rules/vitest-auto-spy.mdc -->
289
+ ---
290
+ description: How to write tests with vitest-auto-spy
291
+ globs: **/*.spec.ts, **/*.spec.tsx, **/*.test.ts, **/*.test.tsx
292
+ alwaysApply: false
293
+ ---
294
+
295
+ Read `node_modules/vitest-auto-spy/AGENTS.md` before writing or fixing a spec that uses
296
+ `vitest-auto-spy` — the API, the configuration semantics and the common mistakes.
297
+ ```
298
+
299
+ ```md
300
+ <!-- .github/instructions/vitest-auto-spy.instructions.md -->
301
+ ---
302
+ applyTo: '**/*.spec.ts,**/*.spec.tsx,**/*.test.ts,**/*.test.tsx'
303
+ ---
304
+
305
+ Read `node_modules/vitest-auto-spy/AGENTS.md` before writing or fixing a spec that uses
306
+ `vitest-auto-spy`.
307
+ ```
308
+
309
+ ```md
310
+ <!-- .windsurf/rules/vitest-auto-spy.md — .devin/rules/ when that directory exists -->
311
+ ---
312
+ trigger: glob
313
+ globs: **/*.spec.ts, **/*.test.ts
314
+ ---
315
+
316
+ Read `node_modules/vitest-auto-spy/AGENTS.md` before writing or fixing a spec that uses
317
+ `vitest-auto-spy`.
318
+ ```
319
+
320
+ Cursor's `globs` is a **comma-separated string, not a YAML array**, and a Windsurf rule file is
321
+ capped at 12 000 characters — both are reasons the rule points at the reference instead of copying
322
+ it.
323
+
324
+ ### OpenAI Codex
325
+
326
+ Codex — the `codex` CLI, the IDE extension and Codex cloud — reads the open `AGENTS.md` convention,
327
+ so a root `AGENTS.md` is the whole integration. Two details decide whether it reaches the model at
328
+ all:
329
+
330
+ - **The chain is git-root→cwd, at most one file per directory** (`AGENTS.override.md` wins over
331
+ `AGENTS.md`), concatenated. In a monorepo, put the block in the package's own `AGENTS.md` too when
332
+ that package runs a different runner — it is the only way to say "this one is `bun test`, the one
333
+ next door is Vitest", which is exactly the distinction that decides which entry point gets
334
+ imported.
335
+ - **The whole chain is capped** by `project_doc_max_bytes`, **32 768 bytes by default**; anything
336
+ over budget is truncated with a warning. If your `AGENTS.md` is already long, keep the pointer
337
+ near the top of it.
338
+
339
+ For a repo that keeps its instructions in `CLAUDE.md`, teach Codex to fall back — this is global
340
+ config on your own machine, nothing to commit:
341
+
342
+ ```toml
343
+ # ~/.codex/config.toml
344
+ project_doc_fallback_filenames = ["CLAUDE.md"] # per directory, when no AGENTS.md is there
345
+ project_doc_max_bytes = 65536 # raise the 32 KB budget for a monorepo chain
346
+ ```
347
+
348
+ Codex cloud reads the same root `AGENTS.md`, and its agent has **no internet access by default** —
349
+ which is exactly why this reference ships inside the tarball rather than only on the docs site.
350
+ `node_modules/vitest-auto-spy/AGENTS.md` is on disk the moment the setup script has installed
351
+ dependencies, so nothing has to be fetched.
352
+
353
+ ### GLM (z.ai), Kimi K2 and other Claude-compatible models
354
+
355
+ GLM is a **model**, not an agent — the thing that reads files is the client you run it in.
356
+
357
+ The z.ai coding plan runs GLM **inside Claude Code**, by pointing `ANTHROPIC_BASE_URL` (with
358
+ `ANTHROPIC_AUTH_TOKEN`) at z.ai's Anthropic-compatible endpoint. File discovery is untouched by
359
+ that: `CLAUDE.md`, `.claude/skills/` and the [plugin](#claude-code-plugin) below behave exactly as
360
+ they do on Claude, because it is the same client. Kimi K2 driven through Claude Code is the same
361
+ story — and there the skill and the plugin are worth more than a pasted snippet, because they load
362
+ only when a spec actually mentions the library and cost no context the rest of the time.
363
+
364
+ Run GLM through a different client and that client decides: OpenCode, Cline, Roo Code and Kilo Code
365
+ all read the root `AGENTS.md`. Moonshot's own `kimi-cli` reads its own `AGENTS.md` chain, including
366
+ `.kimi/AGENTS.md`.
367
+
368
+ ### Gemini CLI
369
+
370
+ Gemini CLI reads `GEMINI.md` and does **not** read `AGENTS.md` by default. Either paste the snippet
371
+ into `GEMINI.md`, or name both files once:
372
+
373
+ ```json
374
+ // .gemini/settings.json
375
+ { "context": { "fileName": ["GEMINI.md", "AGENTS.md"] } }
376
+ ```
377
+
378
+ Qwen Code is derived from Gemini CLI and takes the same `context.fileName` setting, but already
379
+ falls back to `AGENTS.md` on its own.
380
+
186
381
  ### Claude Code plugin
187
382
 
188
383
  The repository is also a Claude Code marketplace, so the skill installs without touching your
@@ -194,7 +389,7 @@ project files:
194
389
  ```
195
390
 
196
391
  The skill loads only when a spec actually mentions the library, so it costs nothing the rest of
197
- the time.
392
+ the time. It works in any client that *is* Claude Code — the z.ai and Kimi setups above included.
198
393
 
199
394
  ## Availability
200
395
 
@@ -894,132 +1089,6 @@ None of them pull the framework into this package; they're recipes over the same
894
1089
  > ([Availability](#availability)). Each is a thin recipe over the same core, so you can equally copy it
895
1090
  > using the core `vitest-auto-spy` import directly.
896
1091
 
897
- ### NestJS
898
-
899
- Use `provideAutoSpy` to register a fully-mocked service in a `TestingModule`, then `injectSpy` to
900
- pull it back out already typed as `Spy<T>`. `@nestjs/common` / `@nestjs/testing` are your own
901
- (optional) peers — the helper imports neither:
902
-
903
- ```ts
904
- import { Test, type TestingModule } from '@nestjs/testing';
905
- import { beforeEach, expect, it } from 'vitest';
906
- import { injectSpy, provideAutoSpy } from 'vitest-auto-spy/nestjs';
907
-
908
- import { AuthService } from './auth.service';
909
- import { UserService } from './user.service';
910
-
911
- let moduleRef: TestingModule;
912
- let userServiceSpy: Spy<UserService>;
913
-
914
- beforeEach(async () => {
915
- moduleRef = await Test.createTestingModule({
916
- providers: [AuthService, provideAutoSpy(UserService)],
917
- }).compile();
918
-
919
- userServiceSpy = injectSpy(moduleRef, UserService);
920
- });
921
-
922
- it('logs in a known user', () => {
923
- userServiceSpy.findByEmail.mockReturnValue({ id: 1, name: 'Ada' });
924
-
925
- const auth = moduleRef.get(AuthService);
926
- expect(auth.login('ada@example.com')).toBeTruthy();
927
- expect(userServiceSpy.findByEmail).toHaveBeenCalledWith('ada@example.com');
928
- });
929
- ```
930
-
931
- ### React (Testing Library)
932
-
933
- React has no DI container, so there's no `provide*` helper — the recipe is: **spy the classes you
934
- own** (services, stores, API clients, hook deps), then pass the spy into a Context provider or hook.
935
- The spy is a plain object of spied functions, so it drops straight into `value={...}`:
936
-
937
- ```tsx
938
- import { render, screen } from '@testing-library/react';
939
- import { createSpyFromClass, type Spy } from 'vitest-auto-spy/react';
940
- import { CartContext, Cart } from './cart';
941
-
942
- class CartStore {
943
- getItemCount(): number { return 0; }
944
- checkout(token: string): Promise<{ orderId: string }> { /* ... */ }
945
- }
946
-
947
- let cart: Spy<CartStore>;
948
-
949
- beforeEach(() => {
950
- cart = createSpyFromClass(CartStore); // every method is now a spy
951
- });
952
-
953
- it('shows the item count from the injected store', () => {
954
- cart.getItemCount.mockReturnValue(3);
955
-
956
- render(
957
- <CartContext.Provider value={cart}>
958
- <Cart />
959
- </CartContext.Provider>,
960
- );
961
-
962
- expect(screen.getByText('3 items')).toBeInTheDocument();
963
- });
964
-
965
- it('drives async deps and asserts the component called them', async () => {
966
- cart.checkout.resolveWith({ orderId: 'ord_42' });
967
- // ...trigger checkout in the UI...
968
- expect(cart.checkout).toHaveBeenCalledWith('tok_abc');
969
- });
970
- ```
971
-
972
- ### Vue / Pinia
973
-
974
- `provideAutoSpy(token, Class)` returns a `{ [token]: Spy<T> }` map you can spread into
975
- `@vue/test-utils`' `global.provide`; for a class-based Pinia store, spy it directly:
976
-
977
- ```ts
978
- // (a) class-based service injected via provide / global.provide
979
- import { UserService, UserServiceKey } from '@/services/user.service';
980
- // (b) class-based Pinia store — every action becomes a spy
981
- import { CartStore } from '@/stores/cart.store';
982
- import { mount } from '@vue/test-utils';
983
- import { createSpyFromClass, provideAutoSpy } from 'vitest-auto-spy/vue';
984
-
985
- const provide = provideAutoSpy(UserServiceKey, UserService); // { [UserServiceKey]: Spy<UserService> }
986
- provide[UserServiceKey].getName.mockReturnValue('Fake Name');
987
-
988
- const wrapper = mount(UserBadge, { global: { provide } });
989
- expect(provide[UserServiceKey].getName).toHaveBeenCalled();
990
-
991
- const store = createSpyFromClass(CartStore);
992
- store.itemCount.mockReturnValue(3); // sync action/getter
993
- store.checkout.resolveWith({ orderId: 'ord_42' }); // async action (Promise)
994
- await store.checkout('tok_abc');
995
- expect(store.checkout).toHaveBeenCalledWith('tok_abc');
996
- ```
997
-
998
- ### Svelte
999
-
1000
- Svelte has no class-based DI, so it's a recipe: keep your logic in plain class-based
1001
- services/stores, spy the class, and hand the spy to the component the same way it receives the real
1002
- one (props, context, or a mocked module):
1003
-
1004
- ```ts
1005
- import { render } from '@testing-library/svelte';
1006
- import { createSpyFromClass } from 'vitest-auto-spy/svelte';
1007
-
1008
- import Cart from './Cart.svelte';
1009
- import { CartStore } from './cart-store';
1010
-
1011
- it('shows the cart total from the store', () => {
1012
- const cartStore = createSpyFromClass(CartStore); // every method is a spy
1013
-
1014
- cartStore.total.mockReturnValue(42);
1015
- cartStore.priceOf.calledWith('apple').mockReturnValue(7);
1016
-
1017
- render(Cart, { props: { store: cartStore } });
1018
-
1019
- expect(cartStore.total).toHaveBeenCalled();
1020
- });
1021
- ```
1022
-
1023
1092
  ### Angular
1024
1093
 
1025
1094
  <div align="center">
@@ -1227,6 +1296,132 @@ suite that wants the numbers without the per-file line.
1227
1296
  The clock is captured at import time, so a spec using `vi.useFakeTimers()` is still measured
1228
1297
  honestly rather than reported as free.
1229
1298
 
1299
+ ### NestJS
1300
+
1301
+ Use `provideAutoSpy` to register a fully-mocked service in a `TestingModule`, then `injectSpy` to
1302
+ pull it back out already typed as `Spy<T>`. `@nestjs/common` / `@nestjs/testing` are your own
1303
+ (optional) peers — the helper imports neither:
1304
+
1305
+ ```ts
1306
+ import { Test, type TestingModule } from '@nestjs/testing';
1307
+ import { beforeEach, expect, it } from 'vitest';
1308
+ import { injectSpy, provideAutoSpy } from 'vitest-auto-spy/nestjs';
1309
+
1310
+ import { AuthService } from './auth.service';
1311
+ import { UserService } from './user.service';
1312
+
1313
+ let moduleRef: TestingModule;
1314
+ let userServiceSpy: Spy<UserService>;
1315
+
1316
+ beforeEach(async () => {
1317
+ moduleRef = await Test.createTestingModule({
1318
+ providers: [AuthService, provideAutoSpy(UserService)],
1319
+ }).compile();
1320
+
1321
+ userServiceSpy = injectSpy(moduleRef, UserService);
1322
+ });
1323
+
1324
+ it('logs in a known user', () => {
1325
+ userServiceSpy.findByEmail.mockReturnValue({ id: 1, name: 'Ada' });
1326
+
1327
+ const auth = moduleRef.get(AuthService);
1328
+ expect(auth.login('ada@example.com')).toBeTruthy();
1329
+ expect(userServiceSpy.findByEmail).toHaveBeenCalledWith('ada@example.com');
1330
+ });
1331
+ ```
1332
+
1333
+ ### React (Testing Library)
1334
+
1335
+ React has no DI container, so there's no `provide*` helper — the recipe is: **spy the classes you
1336
+ own** (services, stores, API clients, hook deps), then pass the spy into a Context provider or hook.
1337
+ The spy is a plain object of spied functions, so it drops straight into `value={...}`:
1338
+
1339
+ ```tsx
1340
+ import { render, screen } from '@testing-library/react';
1341
+ import { createSpyFromClass, type Spy } from 'vitest-auto-spy/react';
1342
+ import { CartContext, Cart } from './cart';
1343
+
1344
+ class CartStore {
1345
+ getItemCount(): number { return 0; }
1346
+ checkout(token: string): Promise<{ orderId: string }> { /* ... */ }
1347
+ }
1348
+
1349
+ let cart: Spy<CartStore>;
1350
+
1351
+ beforeEach(() => {
1352
+ cart = createSpyFromClass(CartStore); // every method is now a spy
1353
+ });
1354
+
1355
+ it('shows the item count from the injected store', () => {
1356
+ cart.getItemCount.mockReturnValue(3);
1357
+
1358
+ render(
1359
+ <CartContext.Provider value={cart}>
1360
+ <Cart />
1361
+ </CartContext.Provider>,
1362
+ );
1363
+
1364
+ expect(screen.getByText('3 items')).toBeInTheDocument();
1365
+ });
1366
+
1367
+ it('drives async deps and asserts the component called them', async () => {
1368
+ cart.checkout.resolveWith({ orderId: 'ord_42' });
1369
+ // ...trigger checkout in the UI...
1370
+ expect(cart.checkout).toHaveBeenCalledWith('tok_abc');
1371
+ });
1372
+ ```
1373
+
1374
+ ### Vue / Pinia
1375
+
1376
+ `provideAutoSpy(token, Class)` returns a `{ [token]: Spy<T> }` map you can spread into
1377
+ `@vue/test-utils`' `global.provide`; for a class-based Pinia store, spy it directly:
1378
+
1379
+ ```ts
1380
+ // (a) class-based service injected via provide / global.provide
1381
+ import { UserService, UserServiceKey } from '@/services/user.service';
1382
+ // (b) class-based Pinia store — every action becomes a spy
1383
+ import { CartStore } from '@/stores/cart.store';
1384
+ import { mount } from '@vue/test-utils';
1385
+ import { createSpyFromClass, provideAutoSpy } from 'vitest-auto-spy/vue';
1386
+
1387
+ const provide = provideAutoSpy(UserServiceKey, UserService); // { [UserServiceKey]: Spy<UserService> }
1388
+ provide[UserServiceKey].getName.mockReturnValue('Fake Name');
1389
+
1390
+ const wrapper = mount(UserBadge, { global: { provide } });
1391
+ expect(provide[UserServiceKey].getName).toHaveBeenCalled();
1392
+
1393
+ const store = createSpyFromClass(CartStore);
1394
+ store.itemCount.mockReturnValue(3); // sync action/getter
1395
+ store.checkout.resolveWith({ orderId: 'ord_42' }); // async action (Promise)
1396
+ await store.checkout('tok_abc');
1397
+ expect(store.checkout).toHaveBeenCalledWith('tok_abc');
1398
+ ```
1399
+
1400
+ ### Svelte
1401
+
1402
+ Svelte has no class-based DI, so it's a recipe: keep your logic in plain class-based
1403
+ services/stores, spy the class, and hand the spy to the component the same way it receives the real
1404
+ one (props, context, or a mocked module):
1405
+
1406
+ ```ts
1407
+ import { render } from '@testing-library/svelte';
1408
+ import { createSpyFromClass } from 'vitest-auto-spy/svelte';
1409
+
1410
+ import Cart from './Cart.svelte';
1411
+ import { CartStore } from './cart-store';
1412
+
1413
+ it('shows the cart total from the store', () => {
1414
+ const cartStore = createSpyFromClass(CartStore); // every method is a spy
1415
+
1416
+ cartStore.total.mockReturnValue(42);
1417
+ cartStore.priceOf.calledWith('apple').mockReturnValue(7);
1418
+
1419
+ render(Cart, { props: { store: cartStore } });
1420
+
1421
+ expect(cartStore.total).toHaveBeenCalled();
1422
+ });
1423
+ ```
1424
+
1230
1425
  ### Which factory, and what it costs
1231
1426
 
1232
1427
  Reach for `provideAutoSpy` on Angular and `createSpyFromClass` everywhere else; use
@@ -1616,6 +1811,7 @@ export can never be.
1616
1811
  | `no-expect-in-subscribe` | `error` | suggest | `expect()` inside a `subscribe()` callback → `expectEmission` / `firstValueFrom` |
1617
1812
  | `no-shared-module-level-mock` | `error` | — | an **exported** value holding `vi.fn()`s → export a factory that returns it |
1618
1813
  | `no-mocked-for-spy` | `warn` | `--fix` | `Mocked<T>` in any type position → `Spy<T>`, import and all |
1814
+ | `prefer-as-spy` | `warn` | `--fix` | `TestBed.inject(X) as Spy<X>` → `asSpy<X>(TestBed.inject(X))`, import and all |
1619
1815
  | `no-done-callback` | `error` | — | `it('x', (done) => …)` → `async` + an awaited assertion |
1620
1816
  | `no-floating-assertion` | `error` | — | `expect()` in a `.then()` nobody awaits → `expect(await promise)` |
1621
1817
  | `no-overridden-provider` | `error` | — | two providers for one token in one array → the earlier one never runs |
@@ -1625,13 +1821,18 @@ Every message ends with a link to the matching [recipe](#how-to-mock): a rule th
1625
1821
  "don't" moves the problem rather than solving it. Rules travel with the API they recommend, so they
1626
1822
  are versioned together and stop being re-written in every project that installs the package.
1627
1823
 
1628
- **One of the eleven fixes on its own, three offer suggestions**, and the split is not about how hard
1824
+ **Two of the twelve fix on their own, three offer suggestions**, and the split is not about how hard
1629
1825
  the rewrite is. `no-mocked-for-spy` touches a *declaration*: get it wrong and the file stops
1630
1826
  compiling, which is the loudest, cheapest failure there is — so `--fix` rewrites the type, adds
1631
1827
  `import type { Spy } from 'vitest-auto-spy'` and drops the `Mocked` import once nothing else uses
1632
1828
  it. It stands back where it cannot prove the rename is Vitest's `Mocked` (a `Mocked` the file
1633
1829
  declares itself, a `Spy` that already means something else, an argument that is not a named type)
1634
- and reports without a fix. The other two change *behaviour* whether `injectSpy(X)` finds a spy
1830
+ and reports without a fix. `prefer-as-spy` earns the same licence from the other end: the cast it
1831
+ reports is the developer's own assertion that the value is a `Spy<X>`, and `asSpy` is a typed
1832
+ identity function — so the rewrite keeps that assertion whole, changes nothing but how it is
1833
+ spelled, and cannot reach run time. It arrives in batches, because `TestBed.inject(X) as Spy<X>`
1834
+ is written once per injected double in a `jest-auto-spies` suite and fails with `TS2352` here.
1835
+ The other two change *behaviour* — whether `injectSpy(X)` finds a spy
1635
1836
  depends on a `provideAutoSpy(X)` that usually lives in another file, and `mockValueProp` leaves the
1636
1837
  property writable and configurable — so they are offered as editor suggestions and applied by a
1637
1838
  human — as is `no-expect-in-subscribe`, which rewrites the whole
@@ -1640,6 +1841,48 @@ human — as is `no-expect-in-subscribe`, which rewrites the whole
1640
1841
  whose arguments the source does not contain (`createSpyFromClass` needs the class the object
1641
1842
  literal never names), and no per-node edit can do that.
1642
1843
 
1844
+ ## Editor diagnostics — WebStorm & VS Code
1845
+
1846
+ The rules above are worth more while the cursor is still on the line than they are in CI, because
1847
+ every shape they catch **passes**. They are ESLint rules, so no editor needs a plugin of this
1848
+ package's own — it needs its ESLint integration switched on.
1849
+
1850
+ ### WebStorm and the other JetBrains IDEs
1851
+
1852
+ No plugin to install: WebStorm, IntelliJ IDEA Ultimate, PhpStorm, PyCharm Professional and RubyMine
1853
+ all run ESLint natively, so the twelve rules appear inline, in the **Problems** tool window, and
1854
+ under **Code → Inspect Code** for the whole project.
1855
+
1856
+ ```js
1857
+ // eslint.config.js — flat config, at the repository root
1858
+ import autoSpy from 'vitest-auto-spy/eslint-plugin';
1859
+
1860
+ export default [{ files: ['**/*.spec.ts', '**/*.test.ts'], ...autoSpy.configs.recommended }];
1861
+ ```
1862
+
1863
+ Then **Settings → Languages & Frameworks → JavaScript → Code Quality Tools → ESLint → Automatic
1864
+ ESLint configuration**. Three things that otherwise read as "the rules do not work": flat config
1865
+ only (the legacy `.eslintrc` `plugins: [...]` form can never resolve a subpath export, and WebStorm
1866
+ has supported flat config since 2023.3); scope the block to spec files yourself; and `⌥⏎` is where
1867
+ the fixes and suggestions live.
1868
+
1869
+ A native JetBrains plugin is **not** planned — it would duplicate an integration the IDE already has
1870
+ and then keep a second copy of twelve rules, in Kotlin, in step with the TypeScript ones.
1871
+
1872
+ ### VS Code, Cursor, Windsurf, VSCodium
1873
+
1874
+ The same flat config, plus the [ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) —
1875
+ the rules then appear inline and in the Problems panel exactly as they do in WebStorm, and
1876
+ `source.fixAll.eslint` applies the one auto-fixable rule on save:
1877
+
1878
+ ```jsonc
1879
+ // .vscode/settings.json
1880
+ { "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" } }
1881
+ ```
1882
+
1883
+ Full details, including what each rule catches and why, in
1884
+ [Editor diagnostics](https://asdalexey.github.io/vitest-auto-spy/utilities/editor-diagnostics).
1885
+
1643
1886
  ## Bridging `Spy<T>` and `T`
1644
1887
 
1645
1888
  `Spy<T>` is a mapped type. It drops `#private` / `private` members, so it is **not** assignable to
@@ -114,6 +114,7 @@ function enclosingFunction(node) {
114
114
  }
115
115
 
116
116
  // src/lib/eslint/bindings.ts
117
+ var PACKAGE = "vitest-auto-spy";
117
118
  var IMPORT_DEFINITION = "ImportBinding";
118
119
  function findBinding(scope, name) {
119
120
  for (let current = scope; current; current = current.upper) {
@@ -286,6 +287,45 @@ ${base}}`;
286
287
  };
287
288
  }
288
289
 
290
+ // src/lib/eslint/injected-spy.ts
291
+ function isTestBedInject(node) {
292
+ if (!isCallExpression(node) || !isMemberExpression(node.callee) || !isIdentifier(node.callee.object) || !isIdentifier(node.callee.property)) {
293
+ return false;
294
+ }
295
+ return node.callee.object.name === "TestBed" && node.callee.property.name === "inject";
296
+ }
297
+ function injectedFromVariable(context, target) {
298
+ if (!isIdentifier(target)) {
299
+ return void 0;
300
+ }
301
+ const initializer = initializerOf(context.sourceCode.getScope(target), target);
302
+ return initializer && isTestBedInject(initializer) ? initializer : void 0;
303
+ }
304
+ function isAsExpression(node) {
305
+ return node.type === "TSAsExpression";
306
+ }
307
+ function assertedValue(node) {
308
+ const { expression } = node;
309
+ if (!isAsExpression(expression)) {
310
+ return expression;
311
+ }
312
+ return expression.typeAnnotation.type === "TSUnknownKeyword" && isTestBedInject(expression.expression) ? expression.expression : void 0;
313
+ }
314
+ function asSpyFixes(context, fixer, node, value, spy) {
315
+ const { sourceCode } = context;
316
+ const { typeArguments } = node.typeAnnotation;
317
+ const call = `asSpy${typeArguments ? sourceCode.getText(typeArguments) : ""}(${sourceCode.getText(value)})`;
318
+ const edits = [fixer.replaceText(node, call)];
319
+ if (bindingState(sourceCode.getScope(node), "asSpy") === "free") {
320
+ edits.push(insertImport(fixer, `import { asSpy } from '${PACKAGE}';`));
321
+ }
322
+ const orphaned = spy?.references.length === 1 ? dropNamedImport(sourceCode, fixer, spy) : void 0;
323
+ if (orphaned) {
324
+ edits.push(orphaned);
325
+ }
326
+ return edits;
327
+ }
328
+
289
329
  // src/lib/eslint/overridden-provider.ts
290
330
  var PROVIDER_FACTORIES = /* @__PURE__ */ new Set(["provideAutoSpy", "provideAutoSpyForToken"]);
291
331
  function providedToken(context, element) {
@@ -313,7 +353,7 @@ function overriddenProviders(context, node) {
313
353
  }
314
354
 
315
355
  // src/lib/eslint/prop-helpers.ts
316
- var PACKAGE = "vitest-auto-spy";
356
+ var PACKAGE2 = "vitest-auto-spy";
317
357
  function helperFor(property) {
318
358
  const name = propertyName(property);
319
359
  if (name === "value") {
@@ -349,7 +389,7 @@ function propHelperSuggestion(context, node) {
349
389
  fix: (fixer) => {
350
390
  const edits = [fixer.replaceText(node, replacement)];
351
391
  if (state === "free") {
352
- edits.push(insertImport(fixer, `import { ${rewrite.helper} } from '${PACKAGE}';`));
392
+ edits.push(insertImport(fixer, `import { ${rewrite.helper} } from '${PACKAGE2}';`));
353
393
  }
354
394
  return edits;
355
395
  }
@@ -475,7 +515,6 @@ function breaksAnOverride(injection) {
475
515
 
476
516
  // src/lib/eslint/rules.ts
477
517
  var README = "https://github.com/ASDAlexey/vitest-auto-spy#how-to-mock";
478
- var PACKAGE2 = "vitest-auto-spy";
479
518
  function defineRule(options) {
480
519
  const url = `${README}${options.anchor}`;
481
520
  const messages = Object.fromEntries(Object.entries(options.messages).map(([id, text]) => [id, `${text} Recipe: ${url}`]));
@@ -608,12 +647,6 @@ var preferCreateSpyFromClass = defineRule({
608
647
  }
609
648
  })
610
649
  });
611
- function isTestBedInject(node) {
612
- if (!isCallExpression(node) || !isMemberExpression(node.callee) || !isIdentifier(node.callee.object) || !isIdentifier(node.callee.property)) {
613
- return false;
614
- }
615
- return node.callee.object.name === "TestBed" && node.callee.property.name === "inject";
616
- }
617
650
  function memberName(node) {
618
651
  const value = node?.type === "Literal" ? Reflect.get(node, "value") : void 0;
619
652
  return typeof value === "string" && /^[$A-Z_a-z][\w$]*$/.test(value) ? value : void 0;
@@ -631,7 +664,7 @@ function injectSpySuggestion(context, node, injectCall) {
631
664
  fix: (fixer) => {
632
665
  const edits = [fixer.replaceText(node, replacement)];
633
666
  if (state === "free") {
634
- edits.push(insertImport(fixer, `import { injectSpy } from '${PACKAGE2}/angular';`));
667
+ edits.push(insertImport(fixer, `import { injectSpy } from '${PACKAGE}/angular';`));
635
668
  }
636
669
  return edits;
637
670
  }
@@ -659,13 +692,6 @@ var preferInjectSpy = defineRule({
659
692
  }
660
693
  })
661
694
  });
662
- function injectedFromVariable(context, target) {
663
- if (!isIdentifier(target)) {
664
- return void 0;
665
- }
666
- const initializer = initializerOf(context.sourceCode.getScope(target), target);
667
- return initializer && isTestBedInject(initializer) ? initializer : void 0;
668
- }
669
695
  var noObjectDefineProperty = defineRule({
670
696
  anchor: "-a-readonly-property-or-a-signal",
671
697
  description: "Patch properties with mockReadonlyProp / mockValueProp, which record the undo",
@@ -802,7 +828,7 @@ function namesOneType(reference) {
802
828
  function spyTypeFixes(context, fixer, node, mocked) {
803
829
  const edits = [fixer.replaceText(node, "Spy")];
804
830
  if (bindingState(context.sourceCode.getScope(node), "Spy") === "free") {
805
- edits.push(insertImport(fixer, `import type { Spy } from '${PACKAGE2}';`));
831
+ edits.push(insertImport(fixer, `import type { Spy } from '${PACKAGE}';`));
806
832
  }
807
833
  const orphaned = mocked?.references.length === 1 ? dropNamedImport(context.sourceCode, fixer, mocked) : void 0;
808
834
  if (orphaned) {
@@ -831,6 +857,30 @@ var noMockedForSpy = defineRule({
831
857
  }
832
858
  })
833
859
  });
860
+ var preferAsSpy = defineRule({
861
+ anchor: "-reading-a-spy-back-from-di",
862
+ description: "Read a spy back out of the container with asSpy(), not with a cast to Spy<T>",
863
+ fixable: true,
864
+ messages: {
865
+ preferAsSpy: "A cast is not how a spy comes back out of a container. `TestBed.inject(X) as Spy<X>` is the line a `jest-auto-spies` suite carries in every file, and it stops compiling here: `Spy<T>` adds `accessorSpies` and the per-method helpers, so neither type sufficiently overlaps the other and the line fails with `TS2352: Conversion of type \u2018X\u2019 to type \u2018Spy<X>\u2019 may be a mistake`. `asSpy(...)` makes exactly the same assertion as a typed identity function \u2014 the same object at run time, the same claim, no cast \u2014 and `injectSpy(X)` is that with the `TestBed.inject` folded in. Neither is for the object under test: a service a spec exercises is not a double, and typing it as the class is the repair there."
866
+ },
867
+ create: (context) => ({
868
+ 'TSAsExpression[typeAnnotation.type="TSTypeReference"][typeAnnotation.typeName.name="Spy"]': (node) => {
869
+ const spy = findBinding(context.sourceCode.getScope(node), "Spy");
870
+ if (spy && !spy.defs.some((definition) => definition.type === "ImportBinding")) {
871
+ return;
872
+ }
873
+ const value = assertedValue(node);
874
+ if (!value) {
875
+ return;
876
+ }
877
+ const rewritable = bindingState(context.sourceCode.getScope(node), "asSpy") !== "taken";
878
+ context.report(
879
+ rewritable ? { node, messageId: "preferAsSpy", fix: (fixer) => asSpyFixes(context, fixer, node, value, spy) } : { node, messageId: "preferAsSpy" }
880
+ );
881
+ }
882
+ })
883
+ });
834
884
  var noDoneCallback = defineRule({
835
885
  anchor: "-an-observable",
836
886
  description: "Vitest has no done callback \u2014 the first parameter of a test or hook is its TestContext",
@@ -887,6 +937,7 @@ var rules = {
887
937
  "no-expect-in-subscribe": noExpectInSubscribe,
888
938
  "no-shared-module-level-mock": noSharedModuleLevelMock,
889
939
  "no-mocked-for-spy": noMockedForSpy,
940
+ "prefer-as-spy": preferAsSpy,
890
941
  "no-done-callback": noDoneCallback,
891
942
  "no-floating-assertion": noFloatingAssertion,
892
943
  "no-overridden-provider": noOverriddenProvider,
@@ -899,6 +950,7 @@ var recommendedRules = {
899
950
  [`${PLUGIN_NAME}/prefer-provide-auto-spy`]: "warn",
900
951
  [`${PLUGIN_NAME}/prefer-create-spy-from-class`]: "warn",
901
952
  [`${PLUGIN_NAME}/prefer-inject-spy`]: "warn",
953
+ [`${PLUGIN_NAME}/prefer-as-spy`]: "warn",
902
954
  [`${PLUGIN_NAME}/no-object-define-property`]: "error",
903
955
  [`${PLUGIN_NAME}/no-expect-in-subscribe`]: "error",
904
956
  [`${PLUGIN_NAME}/no-shared-module-level-mock`]: "error",
@@ -112,6 +112,7 @@ function enclosingFunction(node) {
112
112
  }
113
113
 
114
114
  // src/lib/eslint/bindings.ts
115
+ var PACKAGE = "vitest-auto-spy";
115
116
  var IMPORT_DEFINITION = "ImportBinding";
116
117
  function findBinding(scope, name) {
117
118
  for (let current = scope; current; current = current.upper) {
@@ -284,6 +285,45 @@ ${base}}`;
284
285
  };
285
286
  }
286
287
 
288
+ // src/lib/eslint/injected-spy.ts
289
+ function isTestBedInject(node) {
290
+ if (!isCallExpression(node) || !isMemberExpression(node.callee) || !isIdentifier(node.callee.object) || !isIdentifier(node.callee.property)) {
291
+ return false;
292
+ }
293
+ return node.callee.object.name === "TestBed" && node.callee.property.name === "inject";
294
+ }
295
+ function injectedFromVariable(context, target) {
296
+ if (!isIdentifier(target)) {
297
+ return void 0;
298
+ }
299
+ const initializer = initializerOf(context.sourceCode.getScope(target), target);
300
+ return initializer && isTestBedInject(initializer) ? initializer : void 0;
301
+ }
302
+ function isAsExpression(node) {
303
+ return node.type === "TSAsExpression";
304
+ }
305
+ function assertedValue(node) {
306
+ const { expression } = node;
307
+ if (!isAsExpression(expression)) {
308
+ return expression;
309
+ }
310
+ return expression.typeAnnotation.type === "TSUnknownKeyword" && isTestBedInject(expression.expression) ? expression.expression : void 0;
311
+ }
312
+ function asSpyFixes(context, fixer, node, value, spy) {
313
+ const { sourceCode } = context;
314
+ const { typeArguments } = node.typeAnnotation;
315
+ const call = `asSpy${typeArguments ? sourceCode.getText(typeArguments) : ""}(${sourceCode.getText(value)})`;
316
+ const edits = [fixer.replaceText(node, call)];
317
+ if (bindingState(sourceCode.getScope(node), "asSpy") === "free") {
318
+ edits.push(insertImport(fixer, `import { asSpy } from '${PACKAGE}';`));
319
+ }
320
+ const orphaned = spy?.references.length === 1 ? dropNamedImport(sourceCode, fixer, spy) : void 0;
321
+ if (orphaned) {
322
+ edits.push(orphaned);
323
+ }
324
+ return edits;
325
+ }
326
+
287
327
  // src/lib/eslint/overridden-provider.ts
288
328
  var PROVIDER_FACTORIES = /* @__PURE__ */ new Set(["provideAutoSpy", "provideAutoSpyForToken"]);
289
329
  function providedToken(context, element) {
@@ -311,7 +351,7 @@ function overriddenProviders(context, node) {
311
351
  }
312
352
 
313
353
  // src/lib/eslint/prop-helpers.ts
314
- var PACKAGE = "vitest-auto-spy";
354
+ var PACKAGE2 = "vitest-auto-spy";
315
355
  function helperFor(property) {
316
356
  const name = propertyName(property);
317
357
  if (name === "value") {
@@ -347,7 +387,7 @@ function propHelperSuggestion(context, node) {
347
387
  fix: (fixer) => {
348
388
  const edits = [fixer.replaceText(node, replacement)];
349
389
  if (state === "free") {
350
- edits.push(insertImport(fixer, `import { ${rewrite.helper} } from '${PACKAGE}';`));
390
+ edits.push(insertImport(fixer, `import { ${rewrite.helper} } from '${PACKAGE2}';`));
351
391
  }
352
392
  return edits;
353
393
  }
@@ -473,7 +513,6 @@ function breaksAnOverride(injection) {
473
513
 
474
514
  // src/lib/eslint/rules.ts
475
515
  var README = "https://github.com/ASDAlexey/vitest-auto-spy#how-to-mock";
476
- var PACKAGE2 = "vitest-auto-spy";
477
516
  function defineRule(options) {
478
517
  const url = `${README}${options.anchor}`;
479
518
  const messages = Object.fromEntries(Object.entries(options.messages).map(([id, text]) => [id, `${text} Recipe: ${url}`]));
@@ -606,12 +645,6 @@ var preferCreateSpyFromClass = defineRule({
606
645
  }
607
646
  })
608
647
  });
609
- function isTestBedInject(node) {
610
- if (!isCallExpression(node) || !isMemberExpression(node.callee) || !isIdentifier(node.callee.object) || !isIdentifier(node.callee.property)) {
611
- return false;
612
- }
613
- return node.callee.object.name === "TestBed" && node.callee.property.name === "inject";
614
- }
615
648
  function memberName(node) {
616
649
  const value = node?.type === "Literal" ? Reflect.get(node, "value") : void 0;
617
650
  return typeof value === "string" && /^[$A-Z_a-z][\w$]*$/.test(value) ? value : void 0;
@@ -629,7 +662,7 @@ function injectSpySuggestion(context, node, injectCall) {
629
662
  fix: (fixer) => {
630
663
  const edits = [fixer.replaceText(node, replacement)];
631
664
  if (state === "free") {
632
- edits.push(insertImport(fixer, `import { injectSpy } from '${PACKAGE2}/angular';`));
665
+ edits.push(insertImport(fixer, `import { injectSpy } from '${PACKAGE}/angular';`));
633
666
  }
634
667
  return edits;
635
668
  }
@@ -657,13 +690,6 @@ var preferInjectSpy = defineRule({
657
690
  }
658
691
  })
659
692
  });
660
- function injectedFromVariable(context, target) {
661
- if (!isIdentifier(target)) {
662
- return void 0;
663
- }
664
- const initializer = initializerOf(context.sourceCode.getScope(target), target);
665
- return initializer && isTestBedInject(initializer) ? initializer : void 0;
666
- }
667
693
  var noObjectDefineProperty = defineRule({
668
694
  anchor: "-a-readonly-property-or-a-signal",
669
695
  description: "Patch properties with mockReadonlyProp / mockValueProp, which record the undo",
@@ -800,7 +826,7 @@ function namesOneType(reference) {
800
826
  function spyTypeFixes(context, fixer, node, mocked) {
801
827
  const edits = [fixer.replaceText(node, "Spy")];
802
828
  if (bindingState(context.sourceCode.getScope(node), "Spy") === "free") {
803
- edits.push(insertImport(fixer, `import type { Spy } from '${PACKAGE2}';`));
829
+ edits.push(insertImport(fixer, `import type { Spy } from '${PACKAGE}';`));
804
830
  }
805
831
  const orphaned = mocked?.references.length === 1 ? dropNamedImport(context.sourceCode, fixer, mocked) : void 0;
806
832
  if (orphaned) {
@@ -829,6 +855,30 @@ var noMockedForSpy = defineRule({
829
855
  }
830
856
  })
831
857
  });
858
+ var preferAsSpy = defineRule({
859
+ anchor: "-reading-a-spy-back-from-di",
860
+ description: "Read a spy back out of the container with asSpy(), not with a cast to Spy<T>",
861
+ fixable: true,
862
+ messages: {
863
+ preferAsSpy: "A cast is not how a spy comes back out of a container. `TestBed.inject(X) as Spy<X>` is the line a `jest-auto-spies` suite carries in every file, and it stops compiling here: `Spy<T>` adds `accessorSpies` and the per-method helpers, so neither type sufficiently overlaps the other and the line fails with `TS2352: Conversion of type \u2018X\u2019 to type \u2018Spy<X>\u2019 may be a mistake`. `asSpy(...)` makes exactly the same assertion as a typed identity function \u2014 the same object at run time, the same claim, no cast \u2014 and `injectSpy(X)` is that with the `TestBed.inject` folded in. Neither is for the object under test: a service a spec exercises is not a double, and typing it as the class is the repair there."
864
+ },
865
+ create: (context) => ({
866
+ 'TSAsExpression[typeAnnotation.type="TSTypeReference"][typeAnnotation.typeName.name="Spy"]': (node) => {
867
+ const spy = findBinding(context.sourceCode.getScope(node), "Spy");
868
+ if (spy && !spy.defs.some((definition) => definition.type === "ImportBinding")) {
869
+ return;
870
+ }
871
+ const value = assertedValue(node);
872
+ if (!value) {
873
+ return;
874
+ }
875
+ const rewritable = bindingState(context.sourceCode.getScope(node), "asSpy") !== "taken";
876
+ context.report(
877
+ rewritable ? { node, messageId: "preferAsSpy", fix: (fixer) => asSpyFixes(context, fixer, node, value, spy) } : { node, messageId: "preferAsSpy" }
878
+ );
879
+ }
880
+ })
881
+ });
832
882
  var noDoneCallback = defineRule({
833
883
  anchor: "-an-observable",
834
884
  description: "Vitest has no done callback \u2014 the first parameter of a test or hook is its TestContext",
@@ -885,6 +935,7 @@ var rules = {
885
935
  "no-expect-in-subscribe": noExpectInSubscribe,
886
936
  "no-shared-module-level-mock": noSharedModuleLevelMock,
887
937
  "no-mocked-for-spy": noMockedForSpy,
938
+ "prefer-as-spy": preferAsSpy,
888
939
  "no-done-callback": noDoneCallback,
889
940
  "no-floating-assertion": noFloatingAssertion,
890
941
  "no-overridden-provider": noOverriddenProvider,
@@ -897,6 +948,7 @@ var recommendedRules = {
897
948
  [`${PLUGIN_NAME}/prefer-provide-auto-spy`]: "warn",
898
949
  [`${PLUGIN_NAME}/prefer-create-spy-from-class`]: "warn",
899
950
  [`${PLUGIN_NAME}/prefer-inject-spy`]: "warn",
951
+ [`${PLUGIN_NAME}/prefer-as-spy`]: "warn",
900
952
  [`${PLUGIN_NAME}/no-object-define-property`]: "error",
901
953
  [`${PLUGIN_NAME}/no-expect-in-subscribe`]: "error",
902
954
  [`${PLUGIN_NAME}/no-shared-module-level-mock`]: "error",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vitest-auto-spy",
3
- "version": "3.5.0",
4
- "description": "Auto-generate fully-typed test spies from a class — across Vitest, Bun and node:test, with NestJS/React/Vue/Svelte/Angular recipes. Drop-in replacement for jest-auto-spies.",
3
+ "version": "3.6.0",
4
+ "description": "Auto-generate fully-typed test spies from a class — across Vitest, Bun and node:test, with Angular/NestJS/React/Vue/Svelte recipes. Drop-in replacement for jest-auto-spies.",
5
5
  "keywords": [
6
6
  "auto-mock",
7
7
  "automock",