feat(v4): ship the test helpers on a /test subpath - #862
Conversation
`check:package` has thrown `TypeError: object is not iterable` since npm 12: `npm pack --json` answered with an array of packed packages up to npm 11 and answers with an object keyed by package name from 12 on. The script destructured the array form, so the check died before it packed anything — on this branch it took the whole packed-consumer suite with it, which is the only proof that an `exports` subpath resolves inside the tarball. One package is packed either way, so the fix reads its metadata out of whichever shape arrived. Both are kept because the two npm majors are both in use: CI installs Node 24, which still bundles npm 11. Unrelated to the rest of this branch; it is what had to be cleared to verify it.
Every v4 spec answers the same question — has the component mounted and
finished its writes? — and the answer is a five-round interleaving of a
10ms timer and `defaultScheduler.whenIdle()`. Those numbers encode the
mount observer's delivery latency and the scheduler's lane order, so a
consumer writing their own js-toolkit component cannot derive them. The
pieces were already public subpaths; the recipe was not.
`src/test/index.ts` ships it: `mount()`, `settle()`, `frames()`,
`waitFor()` and `resetDom()`. `settle`, `frames` and `resetDom` are the
existing `test-utils.ts` implementations, `mount` is the seven-line
block copied byte-identically into twelve spec files, and `waitFor` is
new.
**`waitFor` is the one that pays for itself.** REPORT.md §46 recorded
three specs flaking on a kept transition class, and the fix was a
hand-copied `waitForClass` now living in three files. The helper
generalises it: the predicate's truthy value comes back, so the same
call is a guard (`() => el.classList.contains('is-open')`) or a query
(`() => root.querySelector('.panel')`). It polls on the same ~10ms
cadence and drains the scheduler between attempts, and it throws with
the given message or one naming the timeout — a helper that hangs is
worse than no helper. Both halves of §46's rule are in its doc comment,
including the asymmetry: never poll for an absence, because
`leaveTransition()` clears the other direction's `to` synchronously.
**No test framework is imported**, and none may be: the module reads the
DOM and the scheduler only, so it works under Vitest, under Playwright
and on a plain browser page. Deliberately absent are `getInstance`
(`getInstances()` already answers it), `countRequestedFrames`, fetch
stubs, pointer sequences and event recorders — spec-specific, or better
served by `vi.fn()` and `@vitest/browser`'s `userEvent`.
`test-utils.ts` is untouched and gets no re-export shim. Migrating the
specs onto this module is a follow-up, gated on the concurrent
`$destroy` → `$unmount` rename.
`@studiometa/js-toolkit-v4/test` resolves to `dist/test/index.js`, next to `./utils` and shaped like it: one barrel, not one subpath per symbol. A test file loads several of these at once and none of them is on a page's critical path, so the tree-shaking argument that splits the other two does not apply. **Four places, all verified rather than assumed.** `scripts/build.js` needed no change, as expected: its `!test-utils.ts` exclusion is anchored at the source root and does not match `test/index.ts`. Confirmed by building — 260 modules, `dist/test/` emitted with its map and declaration. `scripts/check-package.js` needed none either. `dist/test/index.js` is neither `dist/test-utils.*` nor a `.spec`/`.bench`/`.fixtures` artifact, and the `distArtifact` regex already allows a nested directory. `scripts/generate-subpaths.js` **did**, and this is the one the plan missed: `--check` reports every `exports` key it did not itself produce, so a hand-written `./test` fails the lint as `exports: unexpected ./test`. `groupedExports()` now emits it, conditional on the barrel existing, so v3 — which has no `src/test/` — is unaffected. The map in `packages/v4/package.json` is the generator's own output. `tsconfig.json` and `vitest.config.js` map the package-name specifier to the source, as they already do for `./utils`; without that, the subpath a consumer gets would not resolve inside the repository. Two assertions pin the surface: one in the module's spec through the package name, and one in `test/package-node-consumer.js`, which runs against the packed tarball. The latter also proves the module loads outside a browser — it reaches for the DOM only inside the helpers that need one. The root barrel is untouched, so both hard-coded export counts stay at 86.
DESIGN gains a §13 stating what the `/test` subpath is, what its five helpers do and what it deliberately omits. RATIONALE gains the matching §13, which is mostly the record of a defect: three specs asserting a kept transition class after `settle()` passed in isolation and failed one run in three under load, and the mirror-image case explains why the rule is one-directional — `leaveTransition()` clears the other direction's `to` synchronously, so polling for an absence passes before anything has happened. The two files also record the refusals, so the next reader does not re-propose them: no test-framework dependency, one barrel rather than per-symbol subpaths, no `getInstance`, no frame counter, no fetch stub or event recorder, and no re-export shim in `test-utils.ts`.
Code ReviewRisk: Medium — issues that should be addressed before merge. Adds and publishes the v4 1 issue found:
Review usage: 253,056 in (205,283 cached) / 2,427 out tokens — $0.0474 (openrouter/openai/gpt-5.6-luna, thinking: low) Reviewed by @weareikko/code-review v0.9.5 for commit 9cf051c. Previous review runsPrevious run archived 2026-08-24T12:58:37ZCode ReviewRisk: Low — The change adds the v4 The MR ships framework-independent helpers for mounting, settling, frame waiting, polling, and DOM cleanup. It also updates package metadata, local resolution, subpath generation, documentation, and npm pack metadata handling for both supported output shapes. No issues found. Review usage: 32,125 in (16,018 cached) / 662 out tokens — $0.0129 (openrouter/openai/gpt-5.6-luna, thinking: low) Reviewed by @weareikko/code-review v0.9.5 for commit 752c73a. |
Export sizeBundled per export with peer dependencies left external, dynamic imports excluded and the output minified; sizes are gzipped. @studiometa/js-toolkit-v4
Unchanged (392)@studiometa/js-toolkit
@studiometa/js-toolkit-v4
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #862 +/- ##
=======================================
Coverage 97.04% 97.04%
=======================================
Files 175 175
Lines 4535 4535
Branches 1322 1323 +1
=======================================
Hits 4401 4401
Misses 122 122
Partials 12 12
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
v3 mount benchmarksBase and head measured on this runner, alternating over 3 rounds each; every value is the median of the round medians. Running both sides on one machine is what removes cross-machine noise — a cached baseline from another runner would put it back. A move under 25%, or on a benchmark under 5 ms, is not reported as a change: it is inside the measured noise of a shared runner. No benchmark moved beyond the noise floor. Within noise (10)
|
v4 mount benchmarksBase and head measured on this runner, alternating over 3 rounds each; every value is the median of the round medians. Running both sides on one machine is what removes cross-machine noise — a cached baseline from another runner would put it back. A move under 25%, or on a benchmark under 5 ms, is not reported as a change: it is inside the measured noise of a shared runner.
Within noise (17)
|
`registerComponent()` has no inverse by design — a page registers once and keeps it — and a test suite is the one caller for which that is wrong. The registry is page-wide and survives `resetDom()`, so a name taken by one spec is still taken in the next, and the workaround it forces is a counter minting `Widget-1`, `Widget-2`, which tells the reader nothing. `resetRegistry()` is coarse rather than a per-name `unregisterComponent()` because the targeted form cannot be written correctly: the element-to- controller map is a `WeakMap`, so nothing can enumerate it to dispose the live triggers it holds, and a `querySelectorAll()` sweep would still miss every detached element. Clearing everything works because the mutation-observer path already disposes a controller as its element leaves the DOM — which is why the doc comment makes `resetDom()` first part of the contract, says outright that the `WeakMap` is not cleared, and states where the call belongs: an `afterAll` or an explicit reset followed by re-registration, never a bare `afterEach`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NnepYhqjPMcFCSb43PoHRM
`vi.spyOn(console, 'warn')` appears forty times across the suite and twenty-six spec files touch diagnostics — several of them twice over: once to silence the console, and once, separately, to add a listener that cancels the event. That the second call is what silences the first is the whole mechanism, and none of it is guessable from outside: a consumer would have to know the channel name, the detail shape, and that cancelling a cancelable diagnostic suppresses its default sink. The spy is also the weaker assertion. It reads a formatted string, so it cannot tell one code from another, cannot see the severity or the reporting component, and breaks on a wording change — while a diagnostic reported with the wrong code passes it. `captureDiagnostics()` collapses collecting and silencing into one call and hands back both the codes and the full details, so the assertion lands on what the framework reported rather than on where it happened to print. The spec records one trap found on the way: `mockRestore()` also clears the call history, so a spy restored before its own `expect` always looks unused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NnepYhqjPMcFCSb43PoHRM
Seven spec files hand-roll a variant of this, and two shapes exist in the
wild: some collect the type only, some collect `{ type, detail }`. The richer
one ships, because mapping down to names is free and mapping up is impossible.
Recording several types into one array is the point rather than a convenience.
A component's contract is a sequence — `open` before `opened`, and `opened`
carrying the height it measured — and that relative order is visible nowhere
else; a per-listener spy that counts calls throws away both the order across
names and the payloads.
The doc comment states the timing rule the shape invites getting wrong.
`$emit` dispatches synchronously, but almost nothing calls it synchronously:
the emit follows a mount, a scheduled write or a transition, and the call that
started that chain has already returned. So the count is awaited through
`waitFor`, not read.
This reverses one line of the module's own "what was refused" list, where an
event recorder was grouped with fetch stubs and pointer sequences. That was
right about `vi.fn()` replacing a counter and wrong about what the seven copies
were doing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnepYhqjPMcFCSb43PoHRM
The implementation stays in `registry.ts`, next to the module-private state it clears; only the re-export lives here, because a test suite is its one caller and a page has no use for it. It is deliberately not on the root barrel, which keeps its export count at 86. Its cases show both halves of the reason it exists: the same name registered twice reports a conflict without it and registers cleanly after it, and a component registered earlier stops mounting once the registry is emptied. The block's own `afterEach` is the documented pattern in miniature — the reset, then the registrations still needed — because a bare reset in an `afterEach` would unregister this file's top-level components for every later test. `resetDom()` deliberately does not call it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NnepYhqjPMcFCSb43PoHRM
DESIGN §13 said "the five helpers" and listed event recorders among what was deliberately refused, so both statements had to change rather than be quietly left standing. The refusal is recorded as a reversal, with what it got right and what it got wrong. RATIONALE §13 gains the two arguments that are not obvious from the code: why a diagnostic is asserted on the channel and not on the console — the spy is not just redundant, it is the weaker assertion, and it passes for the wrong code — and why `resetRegistry()` is coarse, which is that the targeted per-name form cannot be written correctly over a `WeakMap` at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NnepYhqjPMcFCSb43PoHRM
| */ | ||
| export function recordEvents(target: EventTarget, ...types: string[]): EventRecording { | ||
| const events: RecordedEvent[] = []; | ||
| const listener = (event: Event) => { |
There was a problem hiding this comment.
issue: Enforce zero-duration timeouts before sleeping
When timeout is 0 and the predicate is falsy, Date.now() > deadline is usually false at the same millisecond, so the helper sleeps for 10ms and drains the scheduler before rejecting. This violates the documented timeout option and makes an explicit zero-timeout call non-immediate; use a deadline check that handles equality (or otherwise reject before the polling delay).
Confidence: high.
Reviewed by @weareikko/code-review v0.9.5 for commit 9cf051c.
The problem
Every v4 spec answers the same question — has the component mounted and finished its writes? — with the same block, and the answer is not guessable:
Those two numbers encode the mount observer's delivery latency and the scheduler's lane order. Neither half works alone:
whenIdle()can resolve before the observer has reported the element, and a timer can return between two lanes.defaultSchedulerandnextFrameare already public subpaths, so the pieces shipped and the recipe did not — a consumer writing their own js-toolkit component has no way to derive it.The second half of the problem is recorded as gap 46 in
migration/REPORT.md:settle()is generous, not deterministic. Three specs flaked on a kept transition class, passing six-for-six in isolation and failing roughly one run in three under full-suite load. Nothing was racing inside the components — a method that starts a transition does not hand it back, and a kept end state lands only afternextFrame(), thefromandactivestates, and either atransitionendor one more frame. The fix was a hand-copiedwaitForClass, now duplicated in three files. The framework should ship the primitive.The copying is already measurable inside the package: an identical seven-line
mountin twelve spec files, the same settle loop in eleven, threewaitForClassbodies, seven hand-rolled event recorders, andvi.spyOn(console, 'warn')forty times over.What ships
packages/v4/src/test/index.ts, on a new@studiometa/js-toolkit-v4/testsubpath:mount(html)div,innerHTML, append,settle(), return the wrappersettle()frames(count = 3)waitFor(predicate, options?)resetDom()captureDiagnostics(target?)recordEvents(target, …types){ type, detail }for several event names into one arrayresetRegistry()registerComponent()deliberately lacksZero test-framework dependency. Nothing imports a runner, an assertion library or a spy; the module reads the DOM, the scheduler and the framework's own channels, so it works under Vitest, under Playwright and on a plain browser page. The packed-consumer fixture proves it also loads under Node.
waitForreturns the predicate's truthy value, so the same call is a guard or a query:It polls on the ~10ms cadence the three
waitForClasscopies use and drains the scheduler between attempts, so it advances the framework's own work instead of spinning on timers. On timeout it throws, with the caller'smessageor one naming the timeout — a helper that hangs is worse than no helper.Its doc comment carries both halves of the gap-46 rule: assert a transition's end state by polling, or by awaiting
enter()/leave()where the component hands it back, never with a baresettle(); and never poll for an absence, becauseleaveTransition()clears the other direction'stosynchronously, so the poll passes before anything has happened.captureDiagnostics()— assert on the channel, not on the consolevi.spyOn(console, 'warn').mockImplementation(() => {})appears 40 times across the suite, and 26 spec files touch diagnostics. Several sites do it twice over: once to silence the console, and once, separately, to add anEVENTS.diagnosticlistener that callspreventDefault(). That the second is what silences the first is the whole mechanism, and none of it is derivable from outside — a consumer would have to know the channel name, the detail shape, and that cancelling a cancelable diagnostic is what suppresses its default sink.The spy is also the weaker assertion. It reads a formatted string, so it cannot tell
registry.conflictfromregistry.lazy-name-mismatch, cannot see the severity or the reporting component, and breaks on a wording change — while a diagnostic reported with the wrong code passes it. Most of those 40 sites therefore test the sink and believe they test the framework.Collecting and silencing are one step: each event is cancelled as it arrives.
entriescarries the wholeToolkitDiagnosticDetailfor the cases where the code alone is not the assertion.targetdefaults todocument, which sees everything — diagnostics bubble and compose — and an element scopes the capture to one subtree.One trap found on the way, recorded in the spec:
mockRestore()also clears the call history, so a spy restored before its ownexpectalways looks unused. Two of these cases silently passed for that reason before it was caught; the file restores in anafterEachnow.recordEvents(target, …types)— the order and the payloadsSeven spec files hand-roll a variant, in two shapes: some collect the type only, some collect
{ type, detail }. The richer one ships, because mapping down to names is free and mapping up is impossible.Taking several types is the point rather than a convenience. A component's contract is a sequence —
openbeforeopened, andopenedcarrying the height it measured — and that relative order is visible nowhere else; a per-listenervi.fn()throws away both the cross-name order and the payloads.The doc comment states the timing rule the shape invites getting wrong.
$emitdispatches synchronously, but almost nothing calls it synchronously: the emit follows a mount, a scheduled write or a transition, and the call that started that chain has already returned. So the count is awaited throughwaitFor, not read.resetRegistry()— the missing inverseregisterComponent()has no inverse by design: a page registers once and keeps it. A test suite is the one caller for which that is wrong — the registry is page-wide and survivesresetDom(), so a name taken by one spec is still taken in the next. Three migration specs already work around it with a counter mintingWidget-1,Widget-2, one of them under the comment/** Isolate tests because the page-wide registry survives resetDom(). */.It is coarse rather than a per-name
unregisterComponent(name)because the targeted form cannot be written correctly. Disposing one name's live triggers means finding the elements holding them, and the element→controller map is aWeakMap: it cannot be enumerated, so nothing can walk it, and aquerySelectorAll()sweep would still miss every detached element. The coarse form is correct for a reason outside itself — the mutation-observer path already disposes a controller as its element leaves the DOM — which is whyresetDom()first is part of the contract rather than advice, and why the doc comment says outright that theWeakMapis not cleared instead of implying a clean slate.It clears
registry,manifest,imports,responsiveElementsandpendingResponsiveElements, cancelsresponsiveTask, and callsunwatchBreakpoints, nulling both.isReplacementListenerAttachedis left alone: that listener is attached once at module scope and re-attaching it would double every reconciliation. The implementation lives inregistry.ts, next to the module-private state it clears; only the re-export is in the test barrel.The footgun is documented in the same place, because the shape of a spec file guarantees someone will hit it: registrations happen at module top level, so
resetRegistry()in anafterEachunregisters them for every later test in the file, and the failure surfaces in a test that looks unrelated.resetDom()deliberately does not call it. The module's own spec demonstrates the correct pattern — reset, then re-register what is still needed.What deliberately does not ship
getInstance(el, name)— the publicgetInstances()already answers it.countRequestedFrames— one caller, and a globalrequestAnimationFramepatch is a poor thing to hand out.vi.fn()and@vitest/browser'suserEventdo both better.test-utils.ts. That file is untouched. A shim would be both a compatibility layer and a temporary solution meant to be replaced.An event recorder was on that refusal list in the first round and now ships, which
RATIONALE.mdrecords as a reversal rather than quietly correcting: the refusal was right thatvi.fn()replaces a counter, and wrong about what the seven copies were actually doing.Plumbing — four places, one more than expected
packages/v4/package.json—./test→dist/test/index.{js,d.ts}, shaped like./utils.scripts/build.js— no change needed, as predicted: the!test-utils.tsexclusion is anchored at the source root and does not matchtest/index.ts. Verified by building.scripts/check-package.js— no change needed for the subpath:dist/test/index.jsis neitherdist/test-utils.*nor a spec/bench/fixture artifact, anddistArtifactalready allows nested directories.scripts/generate-subpaths.js— this one did need a change, and it was not in the plan.subpaths:checkreports everyexportskey it did not itself produce, so a hand-written./testfails the lint asexports: unexpected ./test.groupedExports()now emits it, conditional on the barrel existing, so v3 is unaffected. The map inpackage.jsonis the generator's own output.resetRegistry()needed no plumbing change: the generator enumeratessrc/index.tsandsrc/utils/index.tsonly, so a new export inregistry.tsthat is not on the root barrel mints no subpath.subpaths:checkconfirms it.tsconfig.jsonandvitest.config.jsalso map the package-name specifier to the source, as they already do for./utils; without it the subpath a consumer gets would not resolve inside the repository.One unrelated blocker, cleared first
check:packagewas already broken onmain:npm pack --jsonanswers with an array up to npm 11 and with an object keyed by package name from npm 12 on, andcheck-package.jsdestructured the array form, dying withTypeError: object is not iterablebefore packing anything. That is the only check which proves anexportssubpath resolves inside the tarball, so it had to be fixed to verify this branch at all. It is the first commit, it is minimal — read the one entry out of whichever shape arrived — and both shapes are kept because CI installs Node 24, which still bundles npm 11.Follow-up, deliberately not in this PR
This branch is purely additive: no
*.spec.tsfile outside the module's own is touched, andsrc/test-utils.tsis untouched. The cleanup — deleting the twelvemountcopies, the eleven settle-loop helpers, the threewaitForClasscopies, the seven event recorders and the fortyconsole.warnspies, and moving theTodoList/TodoItem/TodoCountfixtures out oftest-utils.ts— lands as a separate commit, gated on the concurrent$destroy/destroyed→$unmount/unmountedrename that is rewriting every v4 spec on another branch. Doing it here would guarantee a conflict.Verification
All run in
packages/v4on the final tree:npm run lint:typesnpm run check:diagnosticssinks are centralized and internal code references are tree-shakeablenpm run buildBuilding 260 modules... Done building!npm run check:package588 files, 293.4 kB packed— node, TypeScript and browser packed consumers all passnpm run test114 passed (114)files,1558 passed (1558)testsnpm run subpaths:checknpm run lint:fmt/oxlint --type-aware packages/v4context.ts)The new module's own spec covers 23 cases. From the first round:
waitFor's timeout path and both its messages, the returned truthy value, every falsy value treated as "not yet",mountreturning a settled wrapper whose scheduled writes have landed, andresetDomclearing between tests. Added here:captureDiagnosticscollecting a realregistry.conflictwith its full detail while the console stays untouched, its scoping to a target element and itsstop();recordEventscapturing a component's$emitwith the detail, several types in delivery order throughwaitFor, and ignoring a type it was not given; andresetRegistryfreeing a name that reports a conflict without it, dropping a lazy manifest entry without importing it, and leaving a previously registered component unable to mount. The/testsubpath test asserts the barrel serves exactly these eight helpers under the package name.Docs
DESIGN.md§13 and the matchingRATIONALE.md§13 — what the subpath is, why the recipe cannot be a documented snippet, why it takes no test-framework dependency, why it is a barrel rather than per-symbol subpaths, the record of the flake that produced the polling rule, why a diagnostic is asserted on the channel and not on the console, and whyresetRegistry()is coarse.packages/docsis untouched: it documents v3.