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 fully migrated. Comments ride their specifier,
and a clause carrying one is emitted one name per line. initnever overwrites a file it did not write..cursor/rules/vitest-auto-spy.mdcand
.claude/skills/vitest-auto-spy/SKILL.mdare exactly the paths a team plausibly authored before
discovering this CLI; the owned-file plan rewrote them regardless, and--uninstallthen deleted
the replacement. A file present without the managed markers is now left byte for byte, and named
in the warnings.codemodsays so when the repository scan stopped at its safety cap. Past 50 000 files the
scan truncates, and Nothing left to migrate off a truncated list is a claim about a tree the
tool never looked at.
Added
AbortSignal.abort(),AbortSignal.timeout()andAbortSignal.any()on the stub. The three
statics are how modern code makes a signal without a controller —fetch(url, { signal: AbortSignal.timeout(5_000) })most of all — and the stub had none of them, so a spec that called
stubAbortController()to fix the jsdom brand-check broke the code it was trying to test.
timeout()aborts throughsetTimeout, sovi.useFakeTimers()drives it exactly as it drives the
platform's, and with aTimeoutErrorrather than anAbortErrorbecause that is the distinction
the platform draws.video.currentTime = 0reaches the record and firestimeupdate.media.set()was the only
way in, and a player restarting itself assigns the field directly — the component's own
timeupdatehandler stayed unrun while the assertion read the new value, which looks like a bug in
the component. The stub'scurrentTimeis now a get/set pair, andmedia.set()is unchanged.VITEST_AUTO_SPY_SCAN_CAPraises the CLI's 50 000-file scan cap. The truncation warning above
tells the reader to raise the cap; this is the cap.
Size and memory
/dom-stubs is +219 B (5 030 → 5 249 B min+gzip, +4.4 %), the one entry past the 200 B
allowance, and it is all stub surface rather than machinery: 42 B for the happy-dom onabort
repair, and 177 B for the three AbortSignal statics, the DOMException reasons and the
currentTime setter — measured by building the entry with those two files at their previous
revision. The module graph is unchanged at 2 modules, so nothing new is pulled in; the bytes are
code this entry already had to have to stand in for the platform it stands in for. Every other entry
moved under 1 %: /angular +192 B (+1.0 %) for the override queue and the HTTP verification policy,
. / /react / /vue / /svelte +76 B (+0.5 %), /bun +69 B, /node +67 B, /bun-angular
+46 B, /setup +4 B, /eslint-plugin and /diagnostics unchanged, /rxjs −12 B.
📖 Documentation · 📦 npm · 📝 CHANGELOG · 🤖 AGENTS.md
Full changelog: v4.6.1...v5.0.0