Releases: ASDAlexey/vitest-auto-spy
Release list
v5.0.1
npm i -D vitest-auto-spy@5.0.1
Fixes
- diagnostics: let explainSpy read a double from another entry point (5a061d0)
Build & packaging
- measure coverage with istanbul, which does not lose blocks (973f202)
- merge the workers' coverage in one call, not pair by pair (7b79c61)
- say why the coverage step failed, and try three times (375fc10)
- let the coverage retry fire on a runner, not just locally (d2fa52f)
Documentation
- ru: reword the Angular range row on the 5.0 upgrade page (d5b1a4b)
- readme: drop the trailing zero from the major headings (edcc587)
📖 Documentation · 📦 npm · 📝 CHANGELOG · 🤖 AGENTS.md
Full changelog: v5.0.0...v5.0.1
v5.0.0
npm i -D vitest-auto-spy@5.0.0
Why upgrade. Three peer and engine floors that this package can actually keep, and fifteen fixes
— most of them cases where a helper reported the wrong test, left the next one dirty, or edited the
wrong span of a file.
Changed — BREAKING
-
The Angular peer range is now
>=20.0.0, and@angular/platform-browseris declared. The
old>=16.0.0was not a promise this package could keep, and the two ways it broke were both
link-time rather than graceful:ɵSIGNAL(lib/run-effect.ts) is a value import on the eager path ofdist/angular.js,
and Angular did not export it before 18. A missing named ESM export fails the link, so on
Angular 16 or 17 the symptom was not "runEffectis unavailable" — the whole/angularentry
was unavailable,provideAutoSpyincluded.provideZonelessChangeDetection(src/bun-angular.ts) arrived in 20; in 18–19 the symbol
wasprovideExperimentalZonelessChangeDetection, and in 16–17 there was none.
Verified by downloading
@angular/core,@angular/commonand@angular/platform-browser16
through 22 and parsing their real export lists, not by reading release notes. The same check
corrected a belief this repository held in the other direction:platformBrowserTestingand
BrowserTestingModuleare exported identically from@angular/platform-browser/testingin
every major from 16 — what changed in 20 is thatplatform-browser-dynamicleft the picture.Nothing supported is dropped. On Angular's own policy — 6 months active plus 12 months LTS — 16,
17, 18 and 19 are all past end of life (19's LTS ended 2026-05-19); 20 is the oldest major still
receiving fixes, and it is exactly this package's technical floor.@angular/platform-browserwas never declared at all, althoughlib/directive-matchers.ts
importsByfrom it as a value and the/angularentry needs it at runtime. Under npm's hoisted
layout that worked by accident; under pnpm's isolated one it did not resolve. It is now an
optional peer with the same range.There is deliberately no upper bound. A bounded range would force a release for every Angular
major and hand consumersERESOLVEfor upgrading first. The real fragility isɵSIGNAL, a
private symbol, and a range cannot protect against it — reading it structurally can. -
The rxjs peer range is now
>=7.2.0, and the operators come from the root entry.
lib/observable-spy.tsimported six operators fromrxjs/operators, the legacy deep path that
rxjs 8 removes; the open-ended>=7.0.0therefore promised a version it could not serve.
rxjs re-exported every one of them fromrxjsitself in 7.2 (verified against 7.2.0, not
assumed), so the import moved and the floor moved with it. No Angular consumer pays anything:
Angular 16 through 22 all peer on^6.5.3 || ^7.4.0, so an Angular project already has more than
this asks. -
engines.nodeis now>=22. The old>=18outlived both versions it named: Node 18 went
end of life 2025-04-30 and Node 20 followed on 2026-04-30, so the floor promised maintenance
nobody was giving. Node 22 is the oldest release still in Active LTS, and it is what every number
on the performance page is measured on.enginesis advisory in npm's default configuration — an
install on an older runtime warns rather than fails — but a consumer running--engine-strict,
and every CI image pinned by this field, will see it. -
flushEffects()callsTestBed.tick()directly. TheApplicationRef.tick()fallback existed
for Angular below 20 and is now unreachable, and with it goes the spec that deletedTestBed.tick
at runtime purely to drive that branch to full coverage.
Unchanged, and worth saying because both were checked rather than assumed: vitest stays at
>=2.1.0 — every runner API this package uses unguarded exists in 2.1.0, and the one later
addition, vi.defineHelper (4.1), is feature-probed. zone.js stays absent from the peer list —
/zone reads globalThis.Zone and imports nothing from it.
Fixed
stubAbortController()firesonabortexactly once under happy-dom as well as jsdom.
happy-dom'sEventTargetinvokeson<type>properties itself and jsdom's does not, so the stub's
ownthis.signal.onabort?.(event)was a second call on one runtime and the only call on the other.
The property is parked for the dispatch and run from a{ once: true }listener instead, which is
one call on both. The DOM stubs now have a suite of their own under real happy-dom
(npm run test:happy-dom, in the gate and in CI) — until this release every one of them was
verified against jsdom alone and aenvironment: 'happy-dom'project was taking them on trust.- A signal's
reasonis theDOMExceptionthe platform produces, notnew Error('AbortError').
Code under test that branches onsignal.reason.name === 'AbortError'— the shape the platform
documents, and what anAbortError-vs-TimeoutErrordistinction is written against — took the
elseagainst the stub while passing in the browser. mockReadonlyProp/mockReadonlyPropGetterno longer leave the real setter live.
Object.definePropertyover an existing accessor pair keeps whatever the new descriptor does not
name, so defininggetalone left the object's ownsetin place: a write the code under test
made went into the real setter, silently, and the getter kept answering with the stub value. The
descriptor now namesset: undefined, which is what "readonly" was supposed to mean.mockDeepkeeps the call state attached when.mockis read before the first call. The spy
surface was read with the Proxy as the receiver, so the fast engine's lazymockgetter wrote its
state onto the node while the raw spy recorded calls into its own — and
const state = api.getName.mocktaken at the top of a test stayed empty however often the method
ran. Members are now read with the spy as the receiver.- A
delayonresolveWithcounts from the call, not from the line that configured it. The
delayed promise was built at configuration time, sovi.advanceTimersByTime()in the test body
raced a timer that had already been running sincebeforeEach; a per-call entry now builds its
promise when the call happens. - A spy's recorded result no longer settles on any thenable.
settleIntotreated anything with
a.thenas a promise to await, and a lazy query builder's.thenis the query — so reading the
result of one call executed it, andthenbeing a member another spy owned recorded a call nobody
made. Only a realPromiseis settled, which is the test Vitest's own runner applies. - A failing Angular teardown check leaves the next test a clean
TestBed. A throwingafterEach
makes Vitest skip that test's remainingafterEachhooks — the framework's own module teardown
among them — so the report named the next spec, dying on a stale module, instead of the one that
leaked. BothprovideHttpTesting()'s verification and
enableAngularDiagnostics({ pendingRequests })now reset before rethrowing. provideHttpTesting()keeps verifying after the first test of a file. The teardown check was
armed one shot per call, so hoisting the providers to a module constant — the ordinary
optimisation once a suite uses the helper in a dozen places — verified test one and let every later
test leak in silence. The setting is now the suite's policy, and each test'safterEachdecides
from whether that test'sTestBedhas anHttpTestingControllerat all.overrideComponentProviderno longer fails the next test with a stale queue. A test that
queued an override and never rendered — an@ifbranch not taken, a spec asserting only on the
service — left its entry and thecreateComponentwrapper installed acrossresetTestingModule;
the next test that did render verified the previous test's entry against its own fixture and
failed with a false the override did not apply. The queue and the wrapper are now dropped when
the framework resets the module.extendWithAutoSpies({ providers })wins over the generated spy, as its own documentation
said. The listed providers went in before the generated ones and Angular resolves duplicates
last-one-wins, so naming a token to override it had exactly no effect.- An
untilpredicate that throws fails the assertion instead of the timeout. rxjs routes a
throw from a subscriber callback toreportUnhandledErroron a fresh macrotask: the run collected
an unhandled error, the subscription and the watchdog stayed alive, and the eventual message
blamed the silence — did not emit within 5000 ms — rather than the predicate. The throw is now
caught where it happens and rejects with the predicate, the emission index and the cause. - The codemod stops rewriting prose inside comments and strings. Its mask scanned with one
String.replace, and the regex-literal alternative could swallow a span that began at a
division — running to the next/, taking an unseen comment or quote with it. The span was
handed back "unchanged" but consumed, so its contents stayed in the code mask and the transforms
edited the sentence inside. The scan now backs up to the slash and resumes one character later, and
a/is read as opening a regular expression only after a keyword or an operator. - The codemod keeps a comment inside an import clause attached to its specifier. The clause was
split on raw commas, so a comment containing one produced a specifier nobody exported; and the
rewritten statement joined every name onto one line, which put the closing}and the whole
from '…'inside a line comment — emitting a statement that no longer parses while the residue
check, now also commented out, reported the file as f...
v4.6.1
npm i -D vitest-auto-spy@4.6.1
Fixes
- update README.md (f3a209a)
📖 Documentation · 📦 npm · 📝 CHANGELOG · 🤖 AGENTS.md
Full changelog: v4.6.0...v4.6.1
v4.6.0
npm i -D vitest-auto-spy@4.6.0
Features
- site: mirror the docs under /ru/ and rework both landings (d50997e)
Fixes
- keep spies and matchers working on Vitest 5 (b9bfb19)
- eslint-plugin: two wrong prefer-render-shallow reports (bc9cee2)
- docs: search on the Russian landing uses the English index (ac438c5)
Build & packaging
- extract the blocks jscpd 5 reports as duplicates (b95cfca)
- move to Angular 22 and TypeScript 6 (145124a)
Documentation
- state Vitest 5 support on every surface (4075c45)
📖 Documentation · 📦 npm · 📝 CHANGELOG · 🤖 AGENTS.md
Full changelog: v4.5.1...v4.6.0
v4.5.1
npm i -D vitest-auto-spy@4.5.1
Fixes
- exempt a createDirectiveHost harness from the strict policy (59694fe)
- add NO_ERRORS_SCHEMA only where a schema can apply (623a9ea)
Documentation
- describe the render rule on every surface (c122f89)
📖 Documentation · 📦 npm · 📝 CHANGELOG · 🤖 AGENTS.md
Full changelog: v4.5.0...v4.5.1
v4.5.0
npm i -D vitest-auto-spy@4.5.0
Features
- eslint-plugin: report a render nothing in the file reads (a9f0e83)
Fixes
- site: show the language menu between 960px and 1280px (6ff3407)
Documentation
- document prefer-render-shallow on every surface (3f4b551)
- contributing: add the Russian landing to the surfaces table (68b2335)
- site: add a Russian landing behind a ru locale (a266d65)
📖 Documentation · 📦 npm · 📝 CHANGELOG · 🤖 AGENTS.md
Full changelog: v4.4.0...v4.5.0
v4.4.0
npm i -D vitest-auto-spy@4.4.0
Features
- setup: restore the Web Storage Vitest never copies over (0994be3)
Documentation
- performance: republish the bundle-size table from the baseline (686bd5d)
- document restoreWebStorage on every surface (7ee758f)
📖 Documentation · 📦 npm · 📝 CHANGELOG · 🤖 AGENTS.md
Full changelog: v4.3.0...v4.4.0
v4.3.0
npm i -D vitest-auto-spy@4.3.0
Features
- cli: add doctor checks that resolve a name, not a file (7e1bcc5)
- diagnostics: add explainSpy (c7f9503)
- spy: add createSpyFromInstance (9d936fc)
Fixes
- node: name node:test spies after the method (995516c)
- rxjs: report emission failures at the caller's line (2c8b7f2)
Performance
- build: pin only the emission timeout cell in the shared chunk (a06a348)
Documentation
- document the new helpers on every surface (9e22eac)
- add the @testing-library/angular migration page (9efe420)
📖 Documentation · 📦 npm · 📝 CHANGELOG · 🤖 AGENTS.md
Full changelog: v4.2.0...v4.3.0
v4.2.0
npm i -D vitest-auto-spy@4.2.0
Features
- bench: gate benchmark ratios against a committed baseline (f90ddf6)
- perf: add a cold-import harness with a module-graph gate (38df7e2)
- size: gate the min+gzip weight of every entry point (d2209a9)
Performance
- bench: measure a spied call against a batched plain call (d3347bb)
Build & packaging
- register the new gates as npm scripts and CI steps (6cdffa4)
- refresh the lockfile and gate on drift and formatting (39018f5)
Documentation
- republish every performance number from a fresh measurement (8583a65)
- bench: document the ratio gate and the batching it needs (42ea50f)
📖 Documentation · 📦 npm · 📝 CHANGELOG · 🤖 AGENTS.md
Full changelog: v4.1.0...v4.2.0
v4.1.0
npm i -D vitest-auto-spy@4.1.0
Why upgrade. A method spy is no longer a vi.fn(), and that is the whole release. The library
builds its own mock function — one shared prototype carrying the Mock surface, call state
allocated on the first call rather than at creation, no entry in any global registry — and every
table in the head-to-head benchmark changed hands.
| Case (micro, median p75 of seven runs) | 4.0.1 | 4.1.0 | best other arm | lead |
|---|---|---|---|---|
| small project — 6 methods, 1 called | — | 1.42 µs | 6.83 µs hand-written | 4.82× |
| medium project — 14 methods, 2 called | — | 2.67 µs | 15.92 µs hand-written | 5.97× |
| large project — 45 methods, 2 called | — | 5.79 µs | 52.79 µs hand-written | 9.11× |
| 10 methods, all 10 called → 14 methods, all 14 | 18.92 µs (0.66× — a loss) | 8.17 µs | 17.92 µs hand-written | 2.19× |
| 40 methods, all 40 called → 45 methods, all 45 | 75.33 µs (0.71× — a loss) | 26.12 µs | 62.04 µs hand-written | 2.37× |
| double from a type — 2 members | 3.58 µs (0.77× — a loss) | 1.00 µs | 2.79 µs vitest-mock-extended | 2.79× |
| double from a type — 40 members | 72.88 µs (0.80× — a loss) | 18.92 µs | 56.79 µs vitest-mock-extended | 3.00× |
| deep double, 3 levels, leaf called | 8.83 µs (0.61× — a loss) | 2.29 µs | 5.46 µs vitest-mock-extended | 2.38× |
| configure a return + 3 calls — from a type | 2.08 µs (0.76× — a loss) | 0.71 µs | 1.58 µs @golevelup | 2.24× |
calledWith dispatch, 2 configured + 1 miss |
0.54 µs (parity) | 0.17 µs | 0.54 µs vitest-mock-extended | 3.25× |
Six of those rows were losses and one was parity; the narrowest margin now is 2.19×. The class cases
changed shape in the same release (see below), so the two left-hand columns are the same operation
on a slightly different class where the row says so. Everything is the median p75 of seven
independent runs on one machine, npm run bench:vs:precise.
At suite scale the same change is worth a few per cent, not a multiple, and that row is published
too: npm run bench:suite on a 20-method class under isolate: true puts hand-written vi.fn()
doubles about 5 % ahead of this library at the median across 1 000 / 3 000 / 10 000 tests, where
they were 10-15 % ahead before — two runs of three rounds, individual rounds 0.81-1.01×. Building a
double is on the order of one per cent of what a test costs — a 10× win on the double cannot be worth
more than that on the run, and anybody quoting the micro-benchmark as a suite-level claim is quoting
it wrong. Against @bugsplat/vitest-auto-spies the same runs measure 1.63-1.72×.
What it costs. One thing, and it is namable: mock.invocationCallOrder counts on this library's
own scale, so toHaveBeenCalledBefore / toHaveBeenCalledAfter between an auto-spy and a
hand-written vi.fn() compares two counters that never met. Everything else a spec can observe is
identical, and the suite pins it by putting a spy and a vi.fn() through the same steps and
comparing their recorded state: vi.isMockFunction, every matcher, the snapshot serialiser, the
whole mockReturnValue / mockResolvedValue / mockImplementation family, mock.calls /
.results / .settledResults / .instances / .contexts / .lastCall, mockClear / mockReset
/ mockRestore, using, vi.clearAllMocks(), vi.resetAllMocks() and the clearMocks /
mockReset config keys. Nothing was removed or renamed.
Added
setSpyEngine(engine)/getSpyEngine(), onvitest-auto-spy/setup.'auto-spy'is the
default;'runner'builds every method spy fromvi.fn(), method for method, exactly as every
release before this one did. Doubles already built keep the engine they were built with. Vitest
only — on Bun andnode:testthe runner's own matchers recognise only the runner's own mocks, so
those entries keep using them.
Changed
- Method spies come from this library's own mock function on Vitest.
vi.fn()assigns some
twenty-five closures as own properties of every mock it creates, allocates six arrays of call
state up front, and registers the mock in a module-level strongSet— per method, on every
double a spec builds. The replacement is one function object and one small config record; the six
arrays appear on the first call, so a materialised method that is never called owns none of them. - A run-wide
vi.clearAllMocks()reaches those spies through one registeredvi.fn()of the
library's own. Vitest clears mocks by walking aSetinside@vitest/spythat onlyvi.fn()
andvi.spyOn()write to and that has no public API; the adapter registers a single mock whose
mockClearsweeps this library's spies instead.clearMocks: trueandmockReset: truein a
config keep working untouched, because Vitest applies both through those same two functions. - What it weighs. The engine adds +1.4 kB min+gzip to every entry that builds Vitest spies —
.12.94 → 14.33 kB,/angular16.84 → 18.23 kB,/setup10.60 → 12.09 kB,/nestjs9.54 →
10.95 kB — which is the mock function itself and buys the tables above. The entries that build no
Vitest spy are unchanged to the byte (/rxjs,/angular-http,/diagnostics,/jasmine-compat,
/observer-spy,/zone,/eslint-plugin), and/node,/bunand/bun-angularpay +0.12 kB
or less, not the full engine — the two predicates the settled-results polyfill needs moved out of
the engine module so the runtimes that never build a fast spy stop bundling one. - A sweep is now O(1) and holds nothing alive. It bumps a counter; each spy compares its own
stamp against it before it records or reports anything, and empties itself if it is behind. A
state object a spec is holding answers with the emptied arrays too, which is what the runner's own
state does. calledWith(x)with a single primitive argument is looked up by value. It was rendered into a
string key on every call of that spy — an array frommap, a string per argument, a joined
string, then a hash — for a lookup aMapdoes on the value with no allocation at all. Shapes
where the two disagree (a symbol, which renders by description;-0, which renders apart from0
and is the same key underSameValueZero) stay on the string path and keep the answer they had.- The benchmark's class cases are now measured project profiles rather than round numbers.
Across four private Angular suites — ~2 700 spec files, 2 742 doubles built from a class — the
service a spec doubles has 5–8 methods at the median, 12–16 at the p75 and 32–44 at the p90, and
the spec touches 1 of them at the median and 2 at the p90: 5–6 % of what it built. The cases
are that survey's median, p75 and p90, plus twoworst caseblocks where every method is called.
Fixed
pruneMockRegistry()no longer drops the mock that carries the sweep. It would have taken the
sentinel with everything else the file created, andvi.clearAllMocks()would then have gone
silently nowhere for the rest of the run — found underisolate: false, where it reproduced.
📖 Documentation · 📦 npm · 📝 CHANGELOG · 🤖 AGENTS.md
Full changelog: v4.0.1...v4.1.0