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 +19 -1
- package/README.md +381 -138
- package/dist/eslint-plugin.cjs +70 -18
- package/dist/eslint-plugin.js +70 -18
- package/package.json +2 -2
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
|
-
|
|
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) —
|
|
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 —
|
|
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,
|
|
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
|
|
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
|
-
**
|
|
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.
|
|
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
|
package/dist/eslint-plugin.cjs
CHANGED
|
@@ -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
|
|
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 '${
|
|
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 '${
|
|
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 '${
|
|
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",
|
package/dist/eslint-plugin.js
CHANGED
|
@@ -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
|
|
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 '${
|
|
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 '${
|
|
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 '${
|
|
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.
|
|
4
|
-
"description": "Auto-generate fully-typed test spies from a class — across Vitest, Bun and node:test, with NestJS/React/Vue/Svelte
|
|
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",
|