vitest-auto-spy 3.9.0 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -1886,8 +1886,9 @@ export default [{ files: ['**/*.spec.ts'], ...autoSpy.configs.recommended }];
1886
1886
  | `no-overridden-provider` | `error` | suggest | two providers for one token in one array → the earlier one never runs; the exact duplicate can be deleted |
1887
1887
  | `no-inject-before-override` | `warn` | — | `TestBed.inject()` in a hook, in a suite that still calls `override*` |
1888
1888
  | `no-import-time-spread` | `error` | suggest | `export const x = [...Imported]` at module scope → a `TypeError` while the bundle loads |
1889
+ | `no-unregistered-inject-spy` | `warn` | — | `injectSpy(X)` for a token this file never registered → the real instance, whose spy helpers exist only for the compiler |
1889
1890
 
1890
- Thirteen rules; two fix on their own, six offer suggestions. `no-mocked-for-spy` only ever touches a
1891
+ Fourteen rules; two fix on their own, six offer suggestions. `no-mocked-for-spy` only ever touches a
1891
1892
  **type position**, where a wrong rewrite is a compile error rather than a test that quietly changed
1892
1893
  meaning — so `--fix` renames the type, adds `import type { Spy } from 'vitest-auto-spy'` and drops
1893
1894
  the orphaned `Mocked` import. Every type position, not only a `let`: a factory's return type, a
package/README.md CHANGED
@@ -60,7 +60,7 @@ identical API, with **RxJS** spies and **Angular / NestJS / React / Vue·Pinia /
60
60
  - 🧩 Module mocks that prove they applied — `assertMocked`, `moduleNamespace`, for a `vi.mock()` a bundler quietly ignored
61
61
  - 🧾 Fixtures without casts — deep-partial `createMock`, `narrow()`, `withOverrides()`, `asInstances()`, `captureArg()`
62
62
  - 🚚 A migration you can verify — `compareTestRuns` on the two JSON reports, `diffByField` for the assertion the reporter collapses
63
- - 📏 Lint rules and one-line test-run hygiene — thirteen rules in `vitest-auto-spy/eslint-plugin` (two `--fix`, six suggestions), `setupAutoSpy()`
63
+ - 📏 Lint rules and one-line test-run hygiene — fourteen rules in `vitest-auto-spy/eslint-plugin` (two `--fix`, six suggestions), `setupAutoSpy()`
64
64
  - 🩺 [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
65
65
  - 🔎 [`npx vitest-auto-spy doctor`](#the-cli--doctor-codemod-and-init) — suite-level defects **that never fail a run**: a `tsconfig` `include` matching no file, a production module importing a spec, a `@jest-environment` pragma the runner never reads, config left behind for a runner that is gone. Read-only, no config, exits 1 in CI
66
66
  - 🚚 [`npx vitest-auto-spy codemod`](#codemod--migrating-a-suite-off-jest-auto-spies) — seven transforms that move a suite off `jest-auto-spies` and Jest, dry-run by default, with a `--verify` pass that also checks a file somebody edited by hand
@@ -2207,12 +2207,13 @@ export can never be.
2207
2207
  | `no-overridden-provider` | `error` | suggest | two providers for one token in one array → the earlier one never runs; the exact duplicate can be deleted |
2208
2208
  | `no-inject-before-override` | `warn` | — | `TestBed.inject()` in a hook, in a suite that still calls `override*` |
2209
2209
  | `no-import-time-spread` | `error` | suggest | `export const x = [...Imported]` at module scope → a `TypeError` while the bundle loads |
2210
+ | `no-unregistered-inject-spy` | `warn` | — | `injectSpy(X)` for a token this file never registered → the real instance, whose spy helpers exist only for the compiler |
2210
2211
 
2211
2212
  Every message ends with a link to the matching [recipe](#how-to-mock): a rule that only says
2212
2213
  "don't" moves the problem rather than solving it. Rules travel with the API they recommend, so they
2213
2214
  are versioned together and stop being re-written in every project that installs the package.
2214
2215
 
2215
- **Two of the thirteen fix on their own, six offer suggestions**, and the split is not about how hard
2216
+ **Two of the fourteen fix on their own, six offer suggestions**, and the split is not about how hard
2216
2217
  the rewrite is. `no-mocked-for-spy` touches a _declaration_: get it wrong and the file stops
2217
2218
  compiling, which is the loudest, cheapest failure there is — so `--fix` rewrites the type, adds
2218
2219
  `import type { Spy } from 'vitest-auto-spy'` and drops the `Mocked` import once nothing else uses
@@ -2270,6 +2271,20 @@ seven sites in an 8 673-file workspace, which is small enough to flag at the cur
2270
2271
  and an instance field are deliberately not reported — they run later than the module does — while a
2271
2272
  `static` field is.
2272
2273
 
2274
+ The fourteenth, `no-unregistered-inject-spy`, catches `injectSpy(X)` for a token nothing in the file
2275
+ registered as an auto-spy. What comes back is whatever Angular DI already had — the real service, or
2276
+ an object an imported testing module put there — and nothing complains, because `injectSpy` is
2277
+ declared to return a `Spy<T>`: the helpers are present for `tsc` and absent at run time, so the first
2278
+ `.mockReturnValue(…)` or `.calledWith(…)` throws on a real method. The library warns about exactly
2279
+ this at run time, and that warning is why the rule exists rather than why it is redundant — it does
2280
+ not fail the run, it scrolls past in a suite of a thousand files, and it arrives only for the tests
2281
+ that executed the line; in one consumer monorepo dozens of spec files print it on every CI run and it
2282
+ has never been acted on. Reading it at the cursor needs no types, only the file's own registrations,
2283
+ and the rule stays quiet unless it can read all of them: it needs at least one `provideAutoSpy`, and
2284
+ a spread, an unknown provider factory, `createWithAutoSpies`, `renderShallow` or
2285
+ `TestBed.overrideProvider` silences the file. A token handed a plain `useValue` is left to
2286
+ `prefer-provide-auto-spy` — two rules on one line only teach people to disable both.
2287
+
2273
2288
  ## Editor diagnostics — WebStorm & VS Code
2274
2289
 
2275
2290
  The rules above are worth more while the cursor is still on the line than they are in CI, because
@@ -2279,7 +2294,7 @@ package's own — it needs its ESLint integration switched on.
2279
2294
  ### WebStorm and the other JetBrains IDEs
2280
2295
 
2281
2296
  No plugin to install: WebStorm, IntelliJ IDEA Ultimate, PhpStorm, PyCharm Professional and RubyMine
2282
- all run ESLint natively, so the twelve rules appear inline, in the **Problems** tool window, and
2297
+ all run ESLint natively, so the fourteen rules appear inline, in the **Problems** tool window, and
2283
2298
  under **Code → Inspect Code** for the whole project.
2284
2299
 
2285
2300
  ```js
@@ -2296,7 +2311,7 @@ has supported flat config since 2023.3); scope the block to spec files yourself;
2296
2311
  the fixes and suggestions live.
2297
2312
 
2298
2313
  A native JetBrains plugin is **not** planned — it would duplicate an integration the IDE already has
2299
- and then keep a second copy of twelve rules, in Kotlin, in step with the TypeScript ones.
2314
+ and then keep a second copy of fourteen rules, in Kotlin, in step with the TypeScript ones.
2300
2315
 
2301
2316
  ### VS Code, Cursor, Windsurf, VSCodium
2302
2317
 
@@ -2462,11 +2477,13 @@ Releases are automated: merging a PR into `master` bumps the version from the
2462
2477
  Conventional Commit types and publishes to npm — see
2463
2478
  [CONTRIBUTING.md → Releasing](./CONTRIBUTING.md#releasing).
2464
2479
 
2465
- `auto-release.yml` is the **only** workflow that publishes `vitest-auto-spy`, and it authenticates
2466
- with npm **Trusted Publishing (OIDC)** — no npm token, and provenance attached by the registry.
2467
- A hand-pushed `v*` tag runs `release.yml`, which now only creates the GitHub Release; it does not
2468
- publish. The `vitest-auto-spies` alias package is a second npm package with its own publish, and it
2469
- still authenticates with `NPM_TOKEN` — npm allows one trusted-publisher workflow per package.
2480
+ `auto-release.yml` is the **only** entry point that publishes either package, and it authenticates
2481
+ with npm **Trusted Publishing (OIDC)** — no npm token anywhere in the repository, and provenance
2482
+ attached by the registry. A hand-pushed `v*` tag runs `release.yml`, which now only creates the
2483
+ GitHub Release; it does not publish. The `vitest-auto-spies` alias is a second npm package with its
2484
+ own publish (`publish-alias.yml`), but it is `workflow_call`-only and reached through
2485
+ `auto-release.yml`, because npm validates the workflow that *entered* the run, not the reusable one
2486
+ that runs `npm publish`.
2470
2487
 
2471
2488
  If this package saved you time, a ⭐ on [GitHub](https://github.com/ASDAlexey/vitest-auto-spy)
2472
2489
  helps others find it.
@@ -708,6 +708,77 @@ function breaksAnOverride(injection) {
708
708
  return countInSubtree(suite, (node) => isMemberCall(node, TEST_BED, OVERRIDES) && !runsFirst(node, injection, hook), true) > 0;
709
709
  }
710
710
 
711
+ // src/lib/eslint/unregistered-spy.ts
712
+ var PROVIDE_AUTO_SPY = "provideAutoSpy";
713
+ var INJECT_SPY = "injectSpy";
714
+ var AUTO_SPY_FACTORIES = /* @__PURE__ */ new Set(["createAutoMock", "createSpyFromClass", "createMock", "mockDeep"]);
715
+ var OPAQUE_HELPERS = /* @__PURE__ */ new Set(["createWithAutoSpies", "renderShallow"]);
716
+ function emptyRegistrations() {
717
+ return { autoSpies: /* @__PURE__ */ new Set(), opaqueTokens: /* @__PURE__ */ new Set(), injections: [], sawProvideAutoSpy: false, opaque: false };
718
+ }
719
+ function calleeName(node) {
720
+ return isIdentifier(node.callee) ? node.callee.name : void 0;
721
+ }
722
+ function isOverrideProvider(node) {
723
+ const { callee } = node;
724
+ return isMemberExpression(callee) && isIdentifier(callee.object) && callee.object.name === "TestBed" && isIdentifier(callee.property) && callee.property.name === "overrideProvider";
725
+ }
726
+ function readCall(context, node, tally) {
727
+ const name = calleeName(node);
728
+ const [first] = node.arguments;
729
+ if (isOverrideProvider(node) || name !== void 0 && OPAQUE_HELPERS.has(name)) {
730
+ tally.opaque = true;
731
+ return;
732
+ }
733
+ if (first === void 0) {
734
+ return;
735
+ }
736
+ if (name === PROVIDE_AUTO_SPY) {
737
+ tally.sawProvideAutoSpy = true;
738
+ tally.autoSpies.add(context.sourceCode.getText(first));
739
+ return;
740
+ }
741
+ if (name === INJECT_SPY) {
742
+ tally.injections.push({ node, token: context.sourceCode.getText(first) });
743
+ }
744
+ }
745
+ function isAutoSpyValue(value) {
746
+ return isCallExpression(value) && isIdentifier(value.callee) && AUTO_SPY_FACTORIES.has(value.callee.name);
747
+ }
748
+ function readProviderObject(context, element, tally) {
749
+ const provide = findProperty(element, "provide");
750
+ if (provide === void 0) {
751
+ tally.opaque = true;
752
+ return;
753
+ }
754
+ const token = context.sourceCode.getText(propertyValue(provide));
755
+ const useValue = findProperty(element, "useValue");
756
+ if (useValue !== void 0 && isAutoSpyValue(propertyValue(useValue))) {
757
+ tally.autoSpies.add(token);
758
+ return;
759
+ }
760
+ tally.opaqueTokens.add(token);
761
+ }
762
+ function readProviders(context, array, tally) {
763
+ for (const element of array.elements) {
764
+ if (element === null) {
765
+ tally.opaque = true;
766
+ continue;
767
+ }
768
+ if (isObjectExpression(element)) {
769
+ readProviderObject(context, element, tally);
770
+ } else if (!isCallExpression(element) || calleeName(element) !== PROVIDE_AUTO_SPY) {
771
+ tally.opaque = true;
772
+ }
773
+ }
774
+ }
775
+ function unregisteredInjections(tally) {
776
+ if (tally.opaque || !tally.sawProvideAutoSpy) {
777
+ return [];
778
+ }
779
+ return tally.injections.filter(({ token }) => !tally.autoSpies.has(token) && !tally.opaqueTokens.has(token));
780
+ }
781
+
711
782
  // src/lib/eslint/rules.ts
712
783
  var README = "https://github.com/ASDAlexey/vitest-auto-spy#how-to-mock";
713
784
  function defineRule(options) {
@@ -1116,6 +1187,29 @@ var noImportTimeSpread = defineRule({
1116
1187
  }
1117
1188
  })
1118
1189
  });
1190
+ var noUnregisteredInjectSpy = defineRule({
1191
+ anchor: "-a-service-behind-angular-di",
1192
+ description: "Do not read a token with injectSpy unless this file registered it as an auto-spy",
1193
+ messages: {
1194
+ noUnregisteredInjectSpy: "Nothing in this file registers `{{token}}` as an auto-spy, so this hands back whatever Angular DI already had \u2014 the real service, or a hand-rolled object some imported module provides. The line still compiles and the spec still runs: `injectSpy` types the result as a spy, so the helpers are there for the compiler and absent at run time, and the first `.mockReturnValue(\u2026)` or `.calledWith(\u2026)` throws on a real method. Add `provideAutoSpy({{token}})` to the providers, or read the real implementation with `TestBed.inject({{token}})` and say so. Nothing is reported from a file whose providers this cannot read in full \u2014 a spread, an unknown provider factory, `createWithAutoSpies` or `TestBed.overrideProvider` all silence it."
1195
+ },
1196
+ create: (context) => {
1197
+ const tally = emptyRegistrations();
1198
+ return {
1199
+ CallExpression: (node) => {
1200
+ readCall(context, node, tally);
1201
+ },
1202
+ 'Property[key.name="providers"] > ArrayExpression': (node) => {
1203
+ readProviders(context, node, tally);
1204
+ },
1205
+ "Program:exit": () => {
1206
+ unregisteredInjections(tally).forEach(({ node, token }) => {
1207
+ context.report({ node, messageId: "noUnregisteredInjectSpy", data: { token } });
1208
+ });
1209
+ }
1210
+ };
1211
+ }
1212
+ });
1119
1213
  var rules = {
1120
1214
  "prefer-provide-auto-spy": preferProvideAutoSpy,
1121
1215
  "prefer-create-spy-from-class": preferCreateSpyFromClass,
@@ -1129,7 +1223,8 @@ var rules = {
1129
1223
  "no-floating-assertion": noFloatingAssertion,
1130
1224
  "no-overridden-provider": noOverriddenProvider,
1131
1225
  "no-inject-before-override": noInjectBeforeOverride,
1132
- "no-import-time-spread": noImportTimeSpread
1226
+ "no-import-time-spread": noImportTimeSpread,
1227
+ "no-unregistered-inject-spy": noUnregisteredInjectSpy
1133
1228
  };
1134
1229
 
1135
1230
  // src/eslint-plugin.ts
@@ -1147,7 +1242,8 @@ var recommendedRules = {
1147
1242
  [`${PLUGIN_NAME}/no-floating-assertion`]: "error",
1148
1243
  [`${PLUGIN_NAME}/no-overridden-provider`]: "error",
1149
1244
  [`${PLUGIN_NAME}/no-inject-before-override`]: "warn",
1150
- [`${PLUGIN_NAME}/no-import-time-spread`]: "error"
1245
+ [`${PLUGIN_NAME}/no-import-time-spread`]: "error",
1246
+ [`${PLUGIN_NAME}/no-unregistered-inject-spy`]: "warn"
1151
1247
  };
1152
1248
  var plugin = {
1153
1249
  rules,
@@ -706,6 +706,77 @@ function breaksAnOverride(injection) {
706
706
  return countInSubtree(suite, (node) => isMemberCall(node, TEST_BED, OVERRIDES) && !runsFirst(node, injection, hook), true) > 0;
707
707
  }
708
708
 
709
+ // src/lib/eslint/unregistered-spy.ts
710
+ var PROVIDE_AUTO_SPY = "provideAutoSpy";
711
+ var INJECT_SPY = "injectSpy";
712
+ var AUTO_SPY_FACTORIES = /* @__PURE__ */ new Set(["createAutoMock", "createSpyFromClass", "createMock", "mockDeep"]);
713
+ var OPAQUE_HELPERS = /* @__PURE__ */ new Set(["createWithAutoSpies", "renderShallow"]);
714
+ function emptyRegistrations() {
715
+ return { autoSpies: /* @__PURE__ */ new Set(), opaqueTokens: /* @__PURE__ */ new Set(), injections: [], sawProvideAutoSpy: false, opaque: false };
716
+ }
717
+ function calleeName(node) {
718
+ return isIdentifier(node.callee) ? node.callee.name : void 0;
719
+ }
720
+ function isOverrideProvider(node) {
721
+ const { callee } = node;
722
+ return isMemberExpression(callee) && isIdentifier(callee.object) && callee.object.name === "TestBed" && isIdentifier(callee.property) && callee.property.name === "overrideProvider";
723
+ }
724
+ function readCall(context, node, tally) {
725
+ const name = calleeName(node);
726
+ const [first] = node.arguments;
727
+ if (isOverrideProvider(node) || name !== void 0 && OPAQUE_HELPERS.has(name)) {
728
+ tally.opaque = true;
729
+ return;
730
+ }
731
+ if (first === void 0) {
732
+ return;
733
+ }
734
+ if (name === PROVIDE_AUTO_SPY) {
735
+ tally.sawProvideAutoSpy = true;
736
+ tally.autoSpies.add(context.sourceCode.getText(first));
737
+ return;
738
+ }
739
+ if (name === INJECT_SPY) {
740
+ tally.injections.push({ node, token: context.sourceCode.getText(first) });
741
+ }
742
+ }
743
+ function isAutoSpyValue(value) {
744
+ return isCallExpression(value) && isIdentifier(value.callee) && AUTO_SPY_FACTORIES.has(value.callee.name);
745
+ }
746
+ function readProviderObject(context, element, tally) {
747
+ const provide = findProperty(element, "provide");
748
+ if (provide === void 0) {
749
+ tally.opaque = true;
750
+ return;
751
+ }
752
+ const token = context.sourceCode.getText(propertyValue(provide));
753
+ const useValue = findProperty(element, "useValue");
754
+ if (useValue !== void 0 && isAutoSpyValue(propertyValue(useValue))) {
755
+ tally.autoSpies.add(token);
756
+ return;
757
+ }
758
+ tally.opaqueTokens.add(token);
759
+ }
760
+ function readProviders(context, array, tally) {
761
+ for (const element of array.elements) {
762
+ if (element === null) {
763
+ tally.opaque = true;
764
+ continue;
765
+ }
766
+ if (isObjectExpression(element)) {
767
+ readProviderObject(context, element, tally);
768
+ } else if (!isCallExpression(element) || calleeName(element) !== PROVIDE_AUTO_SPY) {
769
+ tally.opaque = true;
770
+ }
771
+ }
772
+ }
773
+ function unregisteredInjections(tally) {
774
+ if (tally.opaque || !tally.sawProvideAutoSpy) {
775
+ return [];
776
+ }
777
+ return tally.injections.filter(({ token }) => !tally.autoSpies.has(token) && !tally.opaqueTokens.has(token));
778
+ }
779
+
709
780
  // src/lib/eslint/rules.ts
710
781
  var README = "https://github.com/ASDAlexey/vitest-auto-spy#how-to-mock";
711
782
  function defineRule(options) {
@@ -1114,6 +1185,29 @@ var noImportTimeSpread = defineRule({
1114
1185
  }
1115
1186
  })
1116
1187
  });
1188
+ var noUnregisteredInjectSpy = defineRule({
1189
+ anchor: "-a-service-behind-angular-di",
1190
+ description: "Do not read a token with injectSpy unless this file registered it as an auto-spy",
1191
+ messages: {
1192
+ noUnregisteredInjectSpy: "Nothing in this file registers `{{token}}` as an auto-spy, so this hands back whatever Angular DI already had \u2014 the real service, or a hand-rolled object some imported module provides. The line still compiles and the spec still runs: `injectSpy` types the result as a spy, so the helpers are there for the compiler and absent at run time, and the first `.mockReturnValue(\u2026)` or `.calledWith(\u2026)` throws on a real method. Add `provideAutoSpy({{token}})` to the providers, or read the real implementation with `TestBed.inject({{token}})` and say so. Nothing is reported from a file whose providers this cannot read in full \u2014 a spread, an unknown provider factory, `createWithAutoSpies` or `TestBed.overrideProvider` all silence it."
1193
+ },
1194
+ create: (context) => {
1195
+ const tally = emptyRegistrations();
1196
+ return {
1197
+ CallExpression: (node) => {
1198
+ readCall(context, node, tally);
1199
+ },
1200
+ 'Property[key.name="providers"] > ArrayExpression': (node) => {
1201
+ readProviders(context, node, tally);
1202
+ },
1203
+ "Program:exit": () => {
1204
+ unregisteredInjections(tally).forEach(({ node, token }) => {
1205
+ context.report({ node, messageId: "noUnregisteredInjectSpy", data: { token } });
1206
+ });
1207
+ }
1208
+ };
1209
+ }
1210
+ });
1117
1211
  var rules = {
1118
1212
  "prefer-provide-auto-spy": preferProvideAutoSpy,
1119
1213
  "prefer-create-spy-from-class": preferCreateSpyFromClass,
@@ -1127,7 +1221,8 @@ var rules = {
1127
1221
  "no-floating-assertion": noFloatingAssertion,
1128
1222
  "no-overridden-provider": noOverriddenProvider,
1129
1223
  "no-inject-before-override": noInjectBeforeOverride,
1130
- "no-import-time-spread": noImportTimeSpread
1224
+ "no-import-time-spread": noImportTimeSpread,
1225
+ "no-unregistered-inject-spy": noUnregisteredInjectSpy
1131
1226
  };
1132
1227
 
1133
1228
  // src/eslint-plugin.ts
@@ -1145,7 +1240,8 @@ var recommendedRules = {
1145
1240
  [`${PLUGIN_NAME}/no-floating-assertion`]: "error",
1146
1241
  [`${PLUGIN_NAME}/no-overridden-provider`]: "error",
1147
1242
  [`${PLUGIN_NAME}/no-inject-before-override`]: "warn",
1148
- [`${PLUGIN_NAME}/no-import-time-spread`]: "error"
1243
+ [`${PLUGIN_NAME}/no-import-time-spread`]: "error",
1244
+ [`${PLUGIN_NAME}/no-unregistered-inject-spy`]: "warn"
1149
1245
  };
1150
1246
  var plugin = {
1151
1247
  rules,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vitest-auto-spy",
3
- "version": "3.9.0",
3
+ "version": "3.11.0",
4
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",
@@ -238,7 +238,7 @@ npx vitest-auto-spy codemod --verify # after a migration: anything the transfor
238
238
  Most of this library's guarantees are type-level, so a green run that does not type-check is not
239
239
  done. Report failures with their output rather than describing them as passing.
240
240
 
241
- **After any `eslint --fix` over specs, run `npx tsc --noEmit`.** The thirteen rules in
241
+ **After any `eslint --fix` over specs, run `npx tsc --noEmit`.** The fourteen rules in
242
242
  `vitest-auto-spy/eslint-plugin` are lint, not typecheck: `no-mocked-for-spy` rewrites a declaration
243
243
  to `Spy<T>` and cannot see what the name is assigned two lines below, so a clean lint pass is not
244
244
  evidence that the types still hold. Where it cannot prove the rename it downgrades to a suggestion —