| description | The two test lanes, the smoke lane's invariants, the node-only decision and its jsdom exception, the portal traps, spec conventions, and the per-edit budget. | |||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| paths |
|
This file is also a path-scoped rule: .claude/rules/testing.md symlinks here, so it loads
when a session reads a spec, a vitest config or the test tooling. The always-loaded summary —
the validation policy and the never-rules — is the root AGENTS.md § Testing.
Two lanes, kept separate so the fast one never touches the network: the unit lane
(vitest.config.ts, every *.test.ts(x) outside tests/smoke/, node) and the smoke lane
(vitest.smoke.config.ts, tests/smoke/**, fetch against a deployed preview).
pnpm test:smoke needs PREVIEW_URL and skips gracefully without one. In CI the smoke job
builds the app and publishes its own Workers preview, then reads the address its publish
step returned (#282). The same smoke specs also run against production, fired by the
deploy rather than by a PR (.github/workflows/production-smoke.yml) — see "The PR gate can
only ever read a preview" below.
-
A green Smoke check means the specs ran — nothing more. Given no
PREVIEW_URLthe specs skip themselves (test.skipIf(skipWithoutPreview)), so the job is what makes a missing preview loud. Exactly two states skip quietly, and both say so in the step summary: a forked pull request, which holds no Cloudflare credential by design, and amerge_groupentry, which has no pull-request number to key a preview on and whose required check the merge queue would stall on. Every other route to no preview fails the job — a same-repo pull request with the credential missing, a publish that produced no address, an address refused as not the app Worker's, and any state the report does not recognise. If you add a spec, guard it withtest.skipIf(skipWithoutPreview)like its siblings — the loudness belongs to the job, not the spec. -
A 200 status is not proof of anything.
assets.not_found_handlingserves the SPA shell, so Cloudflare answers 200text/htmlfor any path that is not a built asset — aspublic/_redirects's/* /index.html 200did on Pages before #283. A spec assertingres.status === 200therefore passes for a missing file too —embed.smoke.test.tslearned this the hard way. Assert on the body or the content type instead. -
A green Smoke check proves the specs ran against THIS commit's build, on the app's OWN Worker (issues #138, #282). The address comes from our own publish step, so the provenance half of this invariant is now structural: nothing is scraped, nothing is derived from a branch name, and no text anyone can edit is read at all. Two questions survive that, and the lane answers each separately.
Which build.
wrangler versions upload --preview-alias pr-<n>returns two addresses, and both are ours: thepr-<n>alias follows the pull request across pushes, and the version URL (the action'sdeployment-url) is pinned to the version just uploaded. ⚠ The specs must read the pinned one — reading the alias would let a later push's build answer for this commit's, which is #138's defect in its Workers spelling. The alias appears only in what a person is given; the action does not expose it, so the guard step reads it off wrangler's stdout.⚠ Never publish with
wrangler preview. For an assets-only Worker it uploads a placeholderno-op-worker.js(through wrangler 4.151.0), and the platform then serves thesingle-page-applicationfallback to browser navigations (Sec-Fetch-Mode: navigate) alone. Node'sfetchalways sendscors, so/searchcame back 404Not foundon the preview while production, deployed bywrangler deploy, served the shell to every client (PR #299). A preview that routes differently from production is the silent divergence this lane exists to refuse. There is no alias delete: Cloudflare keeps a Worker's 1,000 most recent aliases and drops the oldest, so nothing tears a preview down on close.Which Worker.
scripts/assert-preview-host.mjsrefuses an address that is not a preview of the Workerwrangler.jsoncnames. ⚠ A preview host is<preview>-<worker>.<subdomain>.workers.dev, so the match anchors the whole worker-name label segment at both ends: the first label ends at-<worker>, and the labels after it are exactly the account subdomain. A bare substring, a domain suffix test, an unanchored regex, or one extra label on either side each let some other Worker's deploy pass for the app's and collect a green check that verified the wrong site — a name that extendssahajatlas-web, a label that merely ends with it, a label inserted before the account subdomain, or the app's own production host.scripts/assert-preview-host.test.tspins the anchor against seven near-miss hosts, read off the configs. The substring defect reddens three of those; the suffix defect one.⚠ The playground is not one of those three, and was the clearest of them before #266. The app Worker was
sahajatlasthen, a prefix ofsahajatlas-design(#280), so a substring match accepted the playground's own preview host.sahajatlas-webshares only thesahajatlas-stem with it, so every defect above refuses it now and its case pins the config read instead of the anchor. Read the three off the test file, never off this paragraph.This is stronger than what it replaced, in two specific ways. Discovery (#122, #138) ranked candidates by provenance because a
pages.devlabel is globally first-come-first-served and one of its sources scraped URLs out of bot comments — so any installed GitHub App's bot could aim the lane at a host somebody else controlled. The middle label of a Workers preview host is our own account subdomain, and the address is one our own CI produced, so that whole class is gone rather than ranked. The staleness half is gone the same way: on PR #135 the Cloudflare comment for this project still named9326e3band offered that commit's deploy while the head was56c6b0d, which had deployed elsewhere — only the order the sources happened to run in kept the right URL that time. Our publish step cannot name another commit's build, because it is the build.
The first invariant says what GREEN means. It says nothing about whose fault RED is, and "no preview" is not one thing. The job's last step names the cause:
| cause | what it tells the reader |
|---|---|
| a forked pull request (no credential) | expected — annotated, exit 0 |
a merge_group entry (no PR number) |
expected — annotated, exit 0, so the queue completes |
| a missing Actions secret on a same-repo PR | enter all five — the plan step gates on every one, so a half-entered set cannot reach a build whose output a spec then blames |
| the publish produced no address | read the publish step — it is the upload's own log |
| the address was refused | read the guard step — a wrong --config is the usual cause |
| anything else | investigate — the plan step emitted a reason it does not know |
Only the first two exit 0, and both are stated. An unrecognised reason takes the loud branch, on purpose, so a future one cannot inherit the softer advice.
The vocabulary is deliberately shorter than it was. pending, unreachable, absent and
failed all described somebody else's build, back when the lane hunted for a Cloudflare Pages
deploy. Our own publish either succeeded or failed, and a failure is already a red step carrying
the upload's log — so a row for "a deploy exists but we stopped waiting" would now describe
nothing. ⚠ What the collapse must not lose is the lesson under failed: never send a reader to
re-run a job that will fail identically. Each row above points at the one step that can answer
it.
Four findings from the subsystem this replaced, worth keeping so nobody re-derives them:
-
A "Cloudflare is building" state IS observable — and a retrospective query will tell you it is not. The check run carried
status: in_progressfor the whole build. But Cloudflare setstarted_atonly when the run completed, so a finished run always showedstarted_at == completed_at. Sampling historical commits — what #124's evidence and a 230-run sweep of this repo both did — could not see the in-progress window at all, and concluded it never existed. #132 was written on that wrong conclusion, survived a full review pass, and only a live CI run caught it. To ask what an integration publishes while it works, watch a live build — a query over completed objects cannot answer it. (The check suite is useless alone: GitHub pre-creates one per installed app on every push, so Cloudflare's looks like any silent app's.) -
How long a Cloudflare Pages build took here, measured over 86 successful
Cloudflare Pages: sahajatlascheck runs — every commit onmainplus every pull-request head from #58 to #128, timed from the push landing to the run being posted:p50 99s · p75 138s · p90 232s · p95 373s · max 453s
The slow tail came from queueing, not from build size: the slowest samples clustered in windows where several pull requests deployed at once (280 / 301 / 373 / 393 / 453s formed one such window). The sibling
sahajatlas-designPages check run ran a median 41 seconds ahead of the app's, which is why it was no use as a second timing source once #279 moved the playground to Workers and deleted that project. The wait this justified was flat, at about 1.3× the slowest sample; the 6-minute deadline it replaced sat below the 95th percentile, which is how #124 went red on a healthy commit. Kept as the record of a lane this repo no longer runs: a Workers upload is live when the command returns, so there is no build to wait on and no deadline to tune. -
A passing unit spec is not evidence that the thing it tests is reachable. #132 added the
failedstatus, spec'dtimeoutStatusreturning it, then never destructuredfailureinmain()— sotimeoutStatusran without it, andfailedcould never occur in production while its four assertions stayed green. Both reviews and CI missed it, because the defect was in the wiring, which no pure test can see. When a helper's output feeds a caller, make sure something exercises the caller — a live run of the script against a real SHA is what caught this one. -
A check-run name identifies the integration as well as the target, and only both together identify a build. The app Worker first took the Pages project's own name, so
Workers Builds: sahajatlasandCloudflare Pages: sahajatlasshared this project's slug and differed only in their prefix (#280, #297). ⚠ That is whyproduction-smoke.ymlcompares the whole name with==— see the production lane below. The Pages project is gone (#283) and the Worker issahajatlas-websince 2026-11-01 (#266); the comparison stays as it is, and a dashboard rename has to movewrangler.jsonc'snamewith it.
The lane also covers embed.js, not only the standalone page — the embed is what a host
installs, so a deploy that breaks it while index.html stays healthy must not be a green check.
It also covers the public/ files only the platform executes: _headers and robots.txt. Both
are read on both sites. The deep-link fallback is assets.not_found_handling rather than a third
file: public/_redirects carried it until #283, and a Worker rejects its splat outright rather
than serving it (#279, #280, #283; docs/rules/deployment.md owns the mechanism).
Those are inert text in the repo, so no local gate can prove they work. ⚠ One claim about _headers is settled locally and must not be re-litigated
here: scripts/deploy-headers.test.ts holds "no two rules set the same header name on paths that
overlap" before a deploy, because the smoke lane reads two URLs after one deploy and cannot be
that mechanism.
robots.smoke.test.ts pins the #91 CORS headers against displacement by the #106 /* rule — a
claim about platform behaviour, asserted rather than left as a comment.
Every spec above asks whether something was deployed. boot-origins.smoke.test.ts asks
whether what was deployed points anywhere real — a different failure, with no overlap. For a
long time, every preview in this repo rendered a blank page while all of the above passed, because
_redirects, _headers, robots.txt, auto.js, and index.html were each deployed perfectly.
The cause was a build-time origin. VITE_HOST is baked into the bundle and composes the URL the
locale JSON fetches from. Production set it. The Preview environment did not. So previews
shipped .env's localhost:5174 to a pages.dev origin, and every locale fetch became a blocked
private-network request to the reviewer's own machine.
It is invisible because it does not degrade — it hangs. i18next's init never resolves, so
every component reading a translation suspends forever: no canvas, no content, no readiness
marker. A spec looking for missing strings would find nothing wrong, because there was no page to
look at.
So the spec reads the eager graph back off the deploy and checks the origin the app will actually
request — ${VITE_SAHAJCLOUD_URL}/api — naming the variable, not just the string, since "which
env var produced this" is the useful next question. ⚠ It checked two origins until #198. The
other was ${VITE_HOST}/locales/, the one this whole story is about, and the widget no longer
fetches locale JSON at all: every string comes from SahajCloud over the API origin, with an
English snapshot compiled in. The failure class is unchanged — one door instead of two, and that
door now carries the copy as well as the data.
- It is targeted, not a sweep. A first draft flagged any private host anywhere in the graph
and produced a false positive on a healthy deploy: react-router carries its own literal
http://localhostas thecreateURLbase for when there is nowindow.location. - Reading the string is deterministic. Dialling the origin is not. Anyone running
pnpm devhas something answering on 5174, so against the very deploy that shipped this bug, fetching the origin passed locally and would only have failed on a runner. - A missing match is a failure, not an empty pass. If an origin stops matching — a minifier change, a URL composed differently — the assertions would go vacuously green, the hardest kind of wrong to notice.
#232 added a second entry, and it asserts PRESENCE, not reachability. The crash-reporting
ingest host from VITE_SENTRY_DSN was set on neither Pages environment, so the only production
signal a widget inside other people's pages has took zero events for seven weeks. Its two caveats
live in the spec's own comment, where whoever edits it reads them.
⚠ This still does not prove the widget renders, and no fetch-based spec can — reading a
string back is a direct observation only because this particular defect is a string in the
bundle. A failure that only appears at runtime still needs a browser, which belongs to local
Playwright verification (see the note atop embed.smoke.test.ts).
ci.yml runs on pull_request and merge_group, and what it reads is a preview it published
itself — the host guard refuses the Worker's own production host, as discovery refused the bare
Pages project host before it. So until #244, no job had ever read the deploy visitors and host
sites actually get.
That gap is structural, not a coverage hole. VITE_* values are inlined at build time, and
the preview's are the ones ci.yml's publish step hands its own build — so the bundle the PR
gate reads can never carry the production deploy's values. (Before #282 the same gap came from
Pages building Preview and Production out of separate dashboard environments. The owner of the
values changed; the gap did not.) The VITE_HOST story above is that hole in one direction. A
production origin blanked by a dashboard edit is the same hole in the other, and running the PR
gate more often closes neither.
.github/workflows/production-smoke.yml runs these same 18 specs against production. Five things
about it are deliberate:
- The deploy fires it, not the clock. What the lane reads is deployed file content, which
changes only at deploy. A daily cron would re-read an unchanged artifact about 364 times a year
for one real signal per merge, and still be up to 24 hours late. A
pushtrigger fires before the deploy exists. So the trigger is Cloudflare's own check run, which is posted when the artifact under test comes into existence. - It reads
sahajatlas.com, the custom domain — not a vendor host, and not a URL from the event.docs/embedding.mdis what decides which host "production" means, andci-workflows.test.tspins the lane to that table's row. A custom domain and a vendor host serve one build, and their headers can diverge:public/_headersrecords #148,max-age=14400on the Pages-served custom domain againstmax-age=0on*.pages.dev, a defect that "was invisible on the host every check runs against". A preview is on a vendor host either way, so a production lane there would add no coverage and inherit that blindness. The host being a constant is also why the event is a clock rather than a source: a URL out of a payload would re-open #138's question — any installed App withdeployments: writeposts one, and a vendor subdomain is first-come-first-served — for a value that does not vary. - ⚠ #244 specified a
deployment_statustrigger, and that signal is the one this repo has measured as absent: Cloudflare posts neither commit statuses nor GitHub deployments here, measured on PR #120 and recorded by the discovery script #282 deleted — which is why the evidence now lives in this file. A workflow on an event that never arrives is invisible — no red check, no annotation, nothing to notice — so the trigger is the check run instead, at the cost of one skipped workflow run per check run in the repo. - The base URL is a bare literal, and
ci-workflows.test.tspins it as one — the regex matches a scalar, so putting an expression back fails the spec at collection. The lane takes noworkflow_dispatchinput for it: that input was the one way a dispatcher could aim a greenProduction Smokecheck at a host of their choosing. A guard still fails the job on an empty value, becauseskipWithoutPreviewwould otherwise turn one into sixteen self-skipping specs and a green check — the first invariant above, one level up. It cannot fire against a literal, and it shares its step with the specs so noif:can route around it once something makes it able to. - Every filter sits in the job's
if. A check run for a PR, or for the-designplayground, skips the job outright rather than reaching a step that reports green having read nothing.ci-workflows.test.tspins the hosts it names, because a one-sided rename would leave the lane waiting on a check run nobody posts again.
A red run files its own ticket, because no handler is reachable from here.
workflow-state.yml subscribes workflow_run to CI alone, and the dispatcher maps every CI
event it does see to an open PR whose head is that commit (sydevs/claude-workflow,
dispatcher/resolve.mjs). A merge commit on main has none, so a failure here produces no target,
no fix-ci run and no ticket; GitHub's failed-run email to whoever merged used to be the entire
alert. The lane's last step opens an issue instead, and three things about it are load-bearing:
- It files as
sydevs-bot, throughSYDEVS_BOT_PAT. The dispatcher's worklist labels key on the author, so an issue fromgithub-actions[bot]would land in the issue list and in nobody's queue — silent from this side, because the issue does get filed. The PAT carries its own scopes, which is why the lane'spermissions:stayscontents: read. - One open alert, not one per deploy. A production red outlives the deploy that found it: #148
lasted days. The step finds the open alert by its
production-redlabel and comments on it. The label is the identity on purpose — a bot-filed ticket has both its title and its body rewritten before a human reads it, and a label survives that. Nothing closes the alert; a human does, once the read is green. - It is gated on the smoke step, not on the job. A checkout or install failure is not a
production read, and a ticket claiming production is red when nothing read it is worse than no
ticket.
ci-workflows.test.tspins that gate and the token, since both fail silently.
One spec is scoped by environment, on purpose (#259): embed.smoke.test.ts's Turnstile case expects the committed test site key on an address assert-preview-host.mjs accepts as the app Worker's preview, and a real 0x4… key with no test key everywhere else — so a bypassed build gate still reddens production. The preview half keeps the production half from passing vacuously: the same patterns must find the test key there. Measured 2026-10-30: green on sahajatlas.com and on pr-344's preview, red against a locally served build carrying the test key and against one carrying no key. Every other spec reads both hosts alike. The lane is 18 cases, from 14 declarations — count it rather
than trusting a figure, since .each makes the two differ. Measured 2026-10-06 against the 16 it
was then: all 16 passed against sahajatlas.com. Re-measured 2026-10-13 against the playground
on its Worker (PREVIEW_URL=https://design.sahajatlas.com pnpm test:smoke, #279): 10 of the 17
failed, so the lane is not vacuously green. The ten are every spec that reads the app's own
files — boot-origins, both entry-file cache-headers, both embed, the three robots cases,
root and spa-fallback.
On 2026-10-03 the custom domain was 14 of 16, and that split was the lane's first find. The
two cache-header specs failed on the unhashed loader files while sahajatlas.pages.dev passed all
16 — #148, a zone-level Browser Cache TTL overriding public/_headers. It is why the lane reads
the custom domain and not a preview: no vendor host could have shown it, and the fix was a
dashboard setting rather than a commit. ⚠ Both measurements above were taken against *.pages.dev
previews and a Pages-served custom domain, so each is evidence about a host, not about a
platform. The preview the PR gate reads is a Worker since #282, and #283 moved sahajatlas.com
onto a Worker too. Re-measured 2026-10-30 against the Workers-served sahajatlas.com, right
after the cutover: all 17 passed. Whether a Workers host adds X-Robots-Tag: noindex of its
own accord is still not known: every Worker on a custom domain in this account serves our own
/* rule, so no host here can tell the platform from the rule. It decides whether four
assertions still test our own public/_headers rule: robots.smoke.test.ts at its three cases,
and cache-headers.smoke.test.ts's embed.js case. If the platform supplies the header, those
four stop telling our rule from a default.
Record the finding; do not weaken the specs for it — sydevs/SahajCloud#903's success list owns
the repair.
This mirrors WeMeditateWeb. The unit lane runs in the node environment by default, with no
@testing-library/react. It covers:
- Logic and contracts — schemas, stores, config, and the fetchers' zod parsing. The
per-request context
src/config/api/client.tsattaches is spec'd fromfetch.test.ts, which is why there is noclient.test.ts. - Presentational components — assert the SSR output with
renderToStaticMarkupfromreact-dom/server(seesrc/components/molecules/EventFacts/EventFacts.test.tsxfor the template). Hover, portals, focus, and map interaction belong in Ladle or the browser, not here.
jsdom is a devDependency, pinned to the 27.x line (28+ raises the engine floor above this
repo's engines.node, locking out a contributor on Node 20). The lane's default environment
stays node regardless.
A spec opts in per file with a // @vitest-environment jsdom docblock on line 1, keeping the fast
path fast. ⚠ Re-measure before quoting either half of this trade — nothing checks these two
numbers. Measured 2026-10-13 on 4 cores: the 115 node-only files take ~20s, and the 25 jsdom
files add ~15s on top, over 1714 tests. The per-file opt-in is what keeps the first number from
becoming the second. Neither half is the per-edit cost — the hook runs a slice, against the
budget at the end of this file.
Reach for jsdom only when the behaviour is a re-render SSR markup cannot express, an agreement
with a router or DOM API a pure test can only assume, or a library whose whole job IS the DOM.
These are the reference cases — the shapes of argument that qualify. The lane carries a few
more, opted in the same way (grep -rl '@vitest-environment jsdom' src/); this list said
"seven" while there were sixteen, so it is a set of precedents rather than a census.
src/views/reset-boundary.test.tsx— proves aresetKeyschange clears an already-thrown ErrorBoundary. Load-bearing because body-level boundaries reset on the query string while the drawer boundary keys on the pathname, so a boundary that never resets turns a transient failure permanent.src/lib/shape/routing.router.test.tsx— mounts the realRouterover our ownHistoryto prove they agree (#154). The cautionary tale for the rule above: a pure spec covered the mount decision exhaustively and still missed that react-router wrote#/!/gb/london, not#!/gb/london— it normalised the basename. When a helper feeds a third-party API, assert the round trip against that API.src/components/organisms/EventDetails/sanitize.test.ts— DOMPurify sanitizes by parsing a real document, so there is no pure half to extract. It asserts the CMS-prose allowlist is load-bearing, which it silently was not whileUSE_PROFILESsat beside it overriding it (#101), and round-tripslexicalToHtml's own output through the sanitizer.src/components/organisms/ReportIssueForm/ReportIssueForm.submit.test.tsx— drives a real submit and asserts the thank-you screen appears only for a RESOLVED post (#103). The SSR sibling spec reaches that screen through a story prop instead, which short-circuits the check — a spec that can only reach a state through the door the story uses is not covering the state, it is covering the door.src/lib/slot-decision.test.ts— the composed slot decision readswindow.innerWidth,outerWidth,screen.avail*, and an element's box, so it is DOM by definition. The pure halves stay table-driven with no DOM, inembed-slot.test.ts.src/views/MapFrame.test.tsx— the frame a contained map lives in (#169). It tests a timing property: the frame publishes itself in a callback ref and holds children back until it has, becauseoverlayContainer()reads in render bodies, andrenderToStaticMarkupruns no refs or effects.src/lib/overlay.test.ts— the portal target, and its one piece of module state (#161). It tests whether the expanded surface stays connected to the theme root — a detached target silently swallows every portal in the app.src/config/live-preview/boot.test.ts— the live-preview URL scrub IShistory.replaceStateoverwindow.location, so there is no pure half left oncestripLivePreviewTokenis extracted (and that half is tested in the same file, with no DOM). What the DOM buys is the assertion that matters: that the scrub takes the token and leaves the path, the other parameters and the hash — the old version replaced the whole URL.src/components/live-preview/LivePreviewController.transport.test.tsx— the library case, in its purest form:@payloadcms/live-preview-reactIS awindowmessage listener installed in an effect, and everything worth asserting is a property of the request that listener produces — the populate depth, the endpoint it is addressed at, the headers. The component rendersnull, so an SSR spec could only assert the absence of markup that was never going to exist. It also covers the two things the library omits, and each needed the right assertion to be non-vacuous: a refusal body is kept off the screen by the write-side zod parse either way, so what the spec pins is that the NEXT edit still renders — the library caches the merged result and addresses the following populate at<collection>/<that result's id>. Its submission arm (#163) is the same argument twice over: the waiting overlay is a portal, so only a DOM can say whether it is on screen, and the cache cannot — a later message carrying nopreviewEventleaves the cache entry exactly as it was while covering it with the skeleton.
This app is portal-dense — every vaul drawer, every Radix overlay, CompactEmbedView's expanded
dialog — and the node lane relates to them in two opposite ways, depending on one bit:
under renderToStaticMarkup |
what happens |
|---|---|
closed portal (open={false}) |
Radix wraps the child in <Presence>, so createPortal never runs. Nothing renders, nothing throws. |
| open portal | React throws — "Portals are not currently supported by the server renderer." |
So the trap is narrow: an absence assertion about a closed portal's contents passes for a
reason that has nothing to do with the property under test. Issue #161 wrote exactly that spec —
"a collapsed card does not render the interface" — watched it pass, and it kept passing with the
regression reintroduced, because the dialog was closed either way and there was never any markup
for not.toContain to find. CompactEmbedView.mount.test.tsx is the jsdom version that actually
holds.
⚠ Do not over-generalize this into "the node lane can never assert a portal." It does so loudly: a portal that renders when it should not makes SSR throw rather than pass quietly. That asymmetry is the useful part — the node lane is blind to a closed portal and deafening about an open one.
Write the spec, then reintroduce the defect and watch it go red, then restore. A green new test proves nothing on its own — twice in the #161 session, a freshly written spec passed against the very bug it was written for.
This applies to any new assertion, not only regression specs — #165 is why: three specs written for a brand-new feature were vacuous, because the rule was read as covering regressions only.
⚠ read() ?? fallback is the local shape of this trap. The history and the loader are full
of it, so a spec asserting the same value it passed as the fallback proves nothing — it passes
even if the code under test returns undefined. Pass a distinguishable fallback ('/fallback'),
as the query suite does and the path suite once did not.
Reintroduce the defect at the site that actually caused it. A second miss came from flipping
a nearby gate ({node && children} → {children}) that Radix already made unreachable while the
dialog was closed — a simulation that could not fail, so it checked nothing.
Use createRoot and React 18.3's exported act — don't add Testing Library for it. Prefer
extracting the pure part first, as reset-boundary's companion listResetKey
(src/lib/shape/path.ts) does — tested in the node lane with no DOM at all. Extracting first is
still right. It just is not enough on its own when the pure part encodes a foreign contract.
- Co-locate specs next to the code they cover:
Foo.tsx→Foo.test.tsx,store.ts→store.test.ts. Smoke specs are the only ones undertests/. - Import explicitly from
vitest(import { describe, it, expect, vi }), and useitin the unit lane (smoke specs keeptest/test.skipIf).globals: trueis set, but explicit imports keep the files honest under the type-checker. - Fixtures: reuse the schema-typed Ladle mocks (
src/mocks/) and inline small factory objects in the spec. Do not add atests/fixtures/dump — keep fixtures next to the assertions that use them. - Mock at the boundary with
vi.mock+vi.hoisted(seesrc/config/api/fetch.test.ts): mock@payloadcms/sdkand@/config/i18n, not our own logic. Do not test Radix, react-map-gl, or library internals — only our contracts.
Eleven specs assert that some list of call sites is CLOSED — the JSX anchors, the viewport reads,
the Radix poppers, the frame boxes, the SahajCloud reads, the form fields' 16px zoom floor. Nine of
them rolled their own readdirSync, in three shapes that were not interchangeable, so the next
would inherit whichever one its author copied.
sourceFiles() (src/test-support/source-tree.ts) is the one walk now, with
readSource / readSourceWithoutComments beside it and parseSource
(src/test-support/source-ast.ts) for the three specs that parse. A hand-rolled walk fails
pnpm lint: no-restricted-imports in eslint.config.js bans readdirSync from node:fs and fs
everywhere under src/ but test-support/, so regrowth is caught at the import instead of by a
grep nobody runs. importNames is what leaves a spec's single-file readFileSync alone.
A spec reading one file or two goes through readRepoSource
(src/test-support/repo-source.ts), whatever directory it sits in, and two lint rules hold that
one (#302, #289). Reaching a path the walk could serve is no reason to hand-roll a root: the
walk is what marks a walker, and that is a per-edit cost, not a question of where the file lives.
no-restricted-syntax carries both bans. The first fails any spec, anything in test-support/,
and any scripts/**/*.mjs, that writes new URL(…, import.meta.url): Vite rewrites that
expression under the WEB transform, which vitest picks for every jsdom spec, so fileURLToPath
throws AT IMPORT and the file dies before one assertion runs. It killed source-tree.ts that way.
⚠ Bare import.meta.url is left alone and stays a file: URL — measured — so the ban is on
the one expression, not the property, and src/styles/fonts.ts and .ladle/ keep the same form
as Vite's asset-URL idiom.
The first rule reaches scripts/**/*.mjs because five of them are imported by a spec, which
puts them in the same module graph as source-tree.ts was — the module the defect actually killed
was no spec either. A .mjs cannot import the .ts reader, so its root is
dirname(fileURLToPath(import.meta.url)) and the lint message says so.
The second fails a spec that writes fileURLToPath(import.meta.url) at all (#289), the safe
form the first leaves alone, because a hand-rolled root duplicates the reader and is what the next
author copies. The two rules own disjoint scopes — specs, and test-support/ plus
scripts/**/*.mjs — and share one selector constant, because ⚠ flat config replaces a rule's
options rather than merging them, so a second block matching a spec would silently drop the
first ban for every one of them.
It bans the shape, not import.meta.url itself: src/lib/shadow-patches.test.ts resolves into
node_modules with createRequire(import.meta.url), which no repo-relative reader can serve.
src/Widget.standalone.test.ts is the one spec left deriving a root, under a disable —
importGraph resolves specifiers on disk, so it needs a directory rather than text, and naming
repo-source for REPO_ROOT would add it to that module's own per-edit selection, which is now
at its ceiling.
Neither rule reaches a cwd-relative readFileSync('src/App.tsx', 'utf8'), the third shape
brand.test.ts carried until #289 — it works only while vitest runs from the repo root, and
nothing but review catches it.
repo-source.ts is the lower-level of the two: it owns the root source-tree.ts derives src/
from, and readSource reads through it. It is a separate module because importing
source-tree.ts walks src/ at module scope, so importing it costs a recursive readdirSync of
the whole tree on every run of every spec that does — which a spec reading one file would pay for
nothing. ⚠ The walker mark itself costs no selection: since #293 selectFrom no longer adds
the walkers to every edit, but it still selects one that names the edited module, so a walker runs
per edit like any other spec. lane.walkers is a register, read only by the closed-list pin.
⚠ The scopes did not merge, and must not — the eleven cover seven different file sets, and
source-tree.ts's own docblock owns that rule. Each spec passes its own sourceFiles({…})
options beside the assertions they serve, and source-tree.test.ts pins every option as biting
in both directions, because a scope that silently stopped matching would make all eleven closed
lists pass by vacuum.
This is one walk implementation, not fewer walks. Vitest gives each spec file its own module registry, so the memos are per-file and the tree is still read once per spec — do not expect a faster lane from it, and do not "fix" the memos to chase one. The lane's cost is #285.
A PostToolUse hook (.claude/hooks/unit-test.mjs) runs after editing a source file in either
directory the lane collects from — src/**/*.{ts,tsx} or scripts/**/*.{mjs,ts} — and reports
failures as non-blocking context. No hook type-checks an edit; run pnpm typecheck yourself.
That is the hook's whole command, wall clock, not the lane, which takes ~36s. Measured 2026-10-16
on 4 cores, median of three: 0.12s for a lane source with no spec of its own, 6.6s for
src/config/i18n.ts (12 specs, the slowest edit in the tree), and 4.5s for
src/test-support/repo-source.ts (the widest — #289 moved it there). The slowest edit
sits at about half the bound. Re-measured 2026-10-26, at 17 specs rather than 15: 4.2s for the
widest and 5.0s for i18n.ts, medians of three. Both fell while the widest selection grew, so
read the two figures as a pair taken on one machine, never as a trend.
⚠ The 0.12s class is smaller than #289 found it: src/views/FullInterface.tsx selected
nothing until MapFrame.test.tsx named it as a quoted repo-relative path, and now costs 2.2s
per edit for the wiring pin it previously had no per-edit coverage from at all. A quoted literal
buys selection, and selection is what the budget is spent on.
Re-measure by timing the hook itself, since the command it builds is the thing under budget:
echo "{\"tool_input\":{\"file_path\":\"$PWD/src/config/i18n.ts\"}}" > /tmp/edit.json
time CLAUDE_PROJECT_DIR=$PWD node .claude/hooks/unit-test.mjs < /tmp/edit.jsonThe hook ran the whole lane until #285, and that is how "under ~5s" stopped being true without anything failing. It reports non-blocking context, so a slower lane breaks no gate and shows up only as latency on every edit — which is why the bound is a stated number rather than a feeling.
scripts/select-unit-specs.mjs picks what one edit can break, from two sources that each reach a
coupling the other cannot:
- Co-located — the stem's own siblings, so
Foo.tsxreachesFoo.test.tsxandFoo.mount.test.tsxbut not every spec in their folder. This is what coversscripts/, whose eight specs the lane indexes and whose own edits select at most two — ~1.1s, or nothing at all for the six of its 21 sources no spec names. - Referencing — a spec that names the module. It resolves every quoted specifier in every
spec rather than matching the module's name, because one module is addressed three ways and a
spec picks whichever its own directory makes natural:
@/config/api/client,./client, or the quoted repo-relative pathsrc/App.tsxthatsrc/lib/brand.test.ts'sCOPY_FILESholds. A name match misses two of the three —fetch.test.tsis the only spec forclient.tsand reaches it as'./client', andChip.test.tsximports its own folder as'.'.
A third source selected the tree-walking specs until #293, and it was the whole floor.
Fourteen specs enumerate the source tree instead of importing from it — the eleven src/
inventories and source-tree.test.ts, found because they import the shared walk (#286), plus two
scripts/ specs found by an enumerating call (readdirSync( and its kin). Nothing in them
references the file that breaks them, so no reference can select one, and every one of them ran on
every edit. readLane still reports the set, because
select-unit-specs.test.ts pins it as a closed list. selectFrom no longer takes it, and the hook
needed no edit to stop running it — it exits before spawning vitest on an empty selection
(.claude/hooks/unit-test.mjs:32), which is also what keeps this change out of .claude/, a
protected path no unattended run can write. A separate vitest project for the inventories, which
#293 was filed with, was rejected on that: a config split and a .claude/ edit for the same
saving.
⚠ What that gives up is per-edit immediacy, and it applies to every edit, not to a subset. No
edit runs an inventory now, so an unregistered viewport read, JSX anchor, Radix popper, SahajCloud
read or t() key draws no immediate signal wherever it is added — src/config/translations.test.ts
is selected for 7 of 420 lane sources, by reference alone. 116 of those 420 select nothing at all
and spawn no vitest. The inventory that refuses the change still goes red in the lean gate
(.claude/skills/pr-prep/check.sh) and in CI, so the break surfaces in the same session: the lane
covers it, the inner loop no longer does.
⚠ The selection does not reach a spec coupled through a second module: editing geo.ts runs
geo.test.ts, never camera.test.ts. vitest related does reach that one, and measured 26s
for src/lib/geo.ts alone — 67 of the 140 files — because the module graph here is too connected
to select from. The full lane still runs in the lean gate (.claude/skills/pr-prep/check.sh) and
in CI, so this narrows the inner loop, not the gate.
What holds the budget is the selection's width, and scripts/select-unit-specs.test.ts pins
that: the widest edit in the tree, exhaustively through the real composition, at 18 against a
ceiling of 18 — no headroom, so the next spec reading a repo file through repo-source trips it,
and the answer is to time the hook and ratchet, never to drop a spec. ⚠ Two PRs can trip it
without either one doing so alone: #322's mobile-zoom.test.ts and #324's attribution.test.ts
each took a slot, and the pin went red on the merge rather than on either branch; #328's
Drawer.scroll.test.tsx is what ratcheted it from 17 to 18. ⚠ Width is not time, and which of
the pair is slower has flipped across machines — i18n.ts selects 12, four of them jsdom, and
measured 4.7s against the widest's 5.0s at eighteen (2026-10-27, 4 cores), having been the slower
of the two at seventeen — so width
bounds the slice, never the time, and a jump in either is a re-measure of both. It also pins each
of the three specifier forms, end to end against this tree; the tree-walking set as a closed list,
and its absence from the selection, where the fixture carries a walker of each kind, so a re-added
source fails there while a detector that stopped matching fails the closed list; and
vitest.config.ts's own include, so a spec in a new location fails here rather than quietly
going uncovered per edit. ⚠ A walker is found by importing
src/test-support/source-tree.ts or by an enumerating call, so one that reaches the tree through
some other helper is still invisible to that pin. The three times above are unchecked, like
every other measurement in this file.