Repository navigation
feat(testing): consumer testing primitives (@rangojs/router/testing) - #533
Conversation
Add a consumer-facing testing surface across four runtime-scoped entries: - @rangojs/router/testing: runMiddleware, runLoader, dispatch, assertCacheStatus, assertGeneratedRoutesMatch, createTestRequestContext - @rangojs/router/testing/dom: renderRoute (RTL client-tree stub) - @rangojs/router/testing/e2e: createRangoE2E (useFixture, parityDescribe, expectParity, matchers) - @rangojs/router/testing/flight: renderToFlightString + toMatchFlight (real Flight) dispatch reuses the router's real previewMatch + executeMiddleware + createResponseWithMergedHeaders, so error envelopes, Vary: Accept, and onResponse/setStatus match production. Plus an additive, gated cache/introspection signal (debugCacheSignal / RANGO_TEST_SIGNALS -> X-Rango-Cache header + telemetry segments; zero prod surface by default), the /testing skill, docs/testing.md, doc-index updates, public/installed import guards for all four subpaths, and a dev+prod e2e example.
…st preset, fix consumer ergonomics Add real unit/integration/DOM/RSC test suites that exercise the new @rangojs/router/testing surface against actual apps (cloudflare-basic, mini, vite-rsc-demo), and fix the gaps that dogfooding surfaced. Infra: - Ship @rangojs/router/testing/vitest preset (rangoTestAliases) so a consumer can import their real router/loaders/middleware in a bare Vitest: aliases the bare @rangojs/router to its react-server entry (real impls, client React kept) and stubs the version/plugin-rsc/cloudflare virtuals. The documented "mock plugin-rsc + import router" recipe was insufficient for a real app. - dispatch() now accepts the public Rango router type (no cast). - runLoader/runMiddleware vars accept an object or [key,value] tuples (inference fix); RunMiddlewareResult.ctx is RequestContext (so ctx.cookies() works); runLoader exposes TestLoaderContext relaxing reverse/get to the test options. - generated-routes: diffGeneratedRoutes/assertGeneratedRoutesMatch force-expand lazy include()d routes before diffing, so the whole-app drift check works in a unit test (was false-flagging every included route as missing). - renderRoute: seed useLoader/useLocationState/useHandle BY REFERENCE via new loaders/locationState/handles options (synthetic ids for empty plugin-injected ids); export HandleDataSeed. - runLoader: opt-in rendered (mock the render barrier) + handles (seed ctx.use(Handle)) so a loader that does `await ctx.rendered(); ctx.use(H)` is unit-testable. Apps: per-app vitest config (preset), tsconfig.test.json, and test suites across all four entries; FINDINGS.md per app. Lockfile: tinyexec moved devDeps->deps. Docs: docs/testing.md + /testing skill + feature-map/feature-file-map updated; consumer-import guards lock the new ./testing/vitest subpath.
…Handle + createHandle fallback id) A consumer's handle collect function (the createHandle(collect) mapper that aggregates per-segment pushes) was not exercisable in a bare unit test: the collect is registered in a private module map keyed by the handle's $$id, ONLY when that id is non-empty — but the id is plugin-injected, so it's "" in a bare test, and the collect was silently dropped. renderRoute's handle seeding then fell back to the default flatten, and there was no way to test a custom collect. Fix: - createHandle now assigns a process-stable runtime fallback $$id when no build id was injected (after the dev-throw, which is preserved), and registers the collect unconditionally. Provably inert in production: the fallback never triggers when the Vite plugin injects the id, so server/client RSC recovery (which needs a shared stable id) is unaffected. Validated against the full router suite (3373 unit, incl. the create-id-guard dev-throw, zero regressions). - New collectHandle(handle, segments) testing primitive runs a handle's REAL registered collect on per-segment values and returns the accumulated result. - renderRoute's `handles` seeding now runs the handle's real collect (custom collects included), not just a flatten. Adds collect-handle tests (default flatten, last-wins, object-merge, dedupe), a renderRoute custom-collect test, and locks the new collectHandle export in the consumer-import guard. Docs/testing.md + feature maps updated.
…lose app reader gaps, wire app unit tests into CI
- collectHandle coverage of the SHIPPED built-in handles' accumulators (their
collect fns are internal, so collectHandle is the only unit path):
- Breadcrumbs: flatten across segments + dedupe by href (last wins).
- Meta: defaults, title override, title template (%s), absolute title, unset,
keyed merge/override, and JSON-LD (script:ld+json) — single block, MULTIPLE
blocks across segments and within one segment, and no-dedupe of same-@type
blocks (the multi-JSON-LD behavior).
- Close app-level reader gaps surfaced by a coverage audit:
- cloudflare-basic FeatureLoading (useLocationState) via the locationState option.
- vite-rsc-demo BreadcrumbNav (useHandle) via the handles option.
- CI: the consumer-app unit suites were not running. Add a recursive root
`test:unit:rsc` and point the e2e.yml unit-tests job at `pnpm run test:unit` +
`pnpm run test:unit:rsc` (router + cloudflare-basic + mini + vite-rsc-demo,
unit and Flight). Verified locally: test:unit runs all 4 (router 3387, cf 37,
mini 16, demo 11); test:unit:rsc runs all 4 (router 6, cf 3, mini 1, demo 1).
…test:unit + test:unit:rsc), not just the router The pre-push checklist scoped unit tests to packages/rangojs-router, so a change could pass the router's own suite while breaking a consumer app's @rangojs/router/testing dogfood suite. Point it at the recursive root scripts (pnpm run test:unit + test:unit:rsc), which run every package incl. cloudflare-basic/mini/vite-rsc-demo — matching what CI's unit-tests job now runs.
…ckages) One command instead of running the two recursive root scripts individually. AGENTS.md pre-push checklist now points at `pnpm run test:unit:all`.
…-primitives # Conflicts: # packages/rangojs-router/docs/internal/feature-file-map.md
… merged route modules) After merging main (per-route client chunking added css-importing route modules to mini), tsc -p tsconfig.test.json pulls those src files in via the test import graph and couldn't resolve ./chart.css — the test tsconfig restricted `types` and excluded vite/client's *.css ambient declaration. Add vite/client to all three app test tsconfigs.
06cf7cd to
36f857b
Compare
commit: |
…ction The router Playwright config has testDir: 'e2e', and the dev/production projects match broadly via testIgnore. The mini dogfood app lives at e2e/mini/ with Vitest test/*.test.tsx files; Playwright was collecting them and failing on import (@rangojs/router:version is a Vite-only virtual; vitest's expect collided with Playwright's via Symbol($$jest-matchers-object)). Add **/mini/** to both testIgnore lists. The HMR projects use explicit testMatch and were unaffected.
…ps, split flight matchers
Addresses a review pass over the testing surface plus the gap audit's
"looks-testable-but-isn't" traps.
Production / parity correctness (High):
- router.ts: guard `process.env.RANGO_TEST_SIGNALS` with `typeof process` so an
edge/browser bundle (where only NODE_ENV is folded) does not hit a raw
process.env read.
- dispatch: build ctx.reverse from the route map ALONE (no matched routeKey/
params), matching the production response-route handler — a test could
otherwise auto-fill a param that production throws on.
- dispatch: set `_basename` from the router's basename (production stores
router.basename), so redirect() basename-prefixing behaves as in a mounted app.
- dispatch: brand the response-handler context with NOCACHE_SYMBOL so a
"use cache" request-scope violation is detected exactly as in production.
Close false-confidence traps with real seeds:
- runLoader: `searchData` seeds the typed `ctx.search` (was frozen at {});
`basename` + `themeConfig` pass-throughs.
- runMiddleware: `basename` + `themeConfig` seeds.
- renderRoute: `themeConfig` option (was hardcoded null, so ThemeProvider never
wrapped).
- createTestRequestContext: `basename` + `themeConfig`.
Packaging / e2e helper fixes (Medium/Low):
- Split flightMatchers to its own `@rangojs/router/testing/flight-matchers`
subpath so importing `renderToFlightString` no longer pulls in Vitest; declare
vitest as an optional peer. Updated the 3 app flight tests + the import guard.
- parity: compare pathname + search + hash, not pathname alone.
- e2e page-helpers: goBack()/goForward() now wait for the URL to actually change
(a `/.*/ ` pattern matched the current URL immediately).
Docs:
- New "What these primitives deliberately don't cover" section (the traps +
seeds + what is e2e-only by construction).
- Fixed the Flight example (was importing getRequestContext from the barrel,
which the same section says cannot be flight-tested) -> pure leaf component.
- Deps snippet adds @testing-library/dom; setup prose points at the
rangoTestAliases() preset instead of "wire the Vite plugin".
- SKILL.md docs link -> absolute (docs/ is not shipped in the package).
Tests: searchData typed-search, basename redirect prefixing (runMiddleware),
dispatch reverse-no-autofill parity. Full suite green (typecheck/lint/format/
test:unit:all).
Deferred: a real dev+prod e2e asserting an emitted X-Rango-Cache header (the
cache signal is unit-tested only) — needs an app-config change + a new Playwright
suite; tracked as a follow-up.
…otocol guards, ergonomics
Follow-up to the first review batch.
Parity completeness (High):
- runMiddleware: build ctx.reverse from the route map ALONE (no routeName/params)
— the same fix already applied to dispatch; it was left incomplete here, so
middleware tests could auto-fill a param production does not.
- dispatch: short-circuit partial (_rsc_partial) requests to X-RSC-Reload BEFORE
the handler runs, matching response-route-handler.ts — a test could otherwise
assert data the real server never returns for a protocol request.
- dispatch: resolve the router's cache store (+ cacheProfiles) into the request
context like the production handler. Without a store, registerCachedFunction
bypasses BEFORE the NOCACHE check, so the M1 request-scope brand was inert; now
a "use cache" in a response-route handler reaches the detection.
Robustness / ergonomics (Medium):
- router.ts: `process.env?.RANGO_TEST_SIGNALS` via optional env access (a runtime
shim with `process` but no `env` would have thrown).
- runLoader: bake `search` into the request URL (helper) instead of mutating
ctx.searchParams after the fact, so ctx.request.url, ctx.url, and
ctx.searchParams all agree (production carries the query on the real request).
- theme seeds now take the raw `theme` shape (`createRouter({ theme })`: `true`
or `{ themes }`) and resolve internally via resolveThemeConfig, instead of the
unexported ResolvedThemeConfig — across runLoader/runMiddleware/renderRoute.
Docs:
- SKILL.md flight example: split matchers to the flight-matchers subpath and drop
the barrel getRequestContext (the example contradicted its own caveat) -> pure
leaf components.
- testing.md theme trap row -> `theme`.
Tests: runMiddleware reverse-no-autofill, dispatch _rsc_partial short-circuit,
runLoader search-in-request-URL; reverse-parity test now pins the exact output
(`/api/self/:id`). Full suite green.
… doc the use(promise) Suspense catch Adds unit coverage for the vite-rsc-demo blog Breadcrumbs handle, including the streamed-content case (blog handlers push breadcrumb items whose `content` is a Promise<ReactNode> rendered via React `use()` inside <Suspense>): - collectHandle accumulates blog items parent->child and preserves the streamed content reference (it is rendered, not serialized). - renderRoute(BreadcrumbNav): static labels + the ARRIVED streamed content (via a settled promise) render; a PENDING promise shows the Suspense skeleton fallback; no breadcrumbs -> renders nothing. The catch (now documented in docs/testing.md and the /testing SKILL): a plain `Promise.resolve(node)` passed to `use()` does NOT flush its Suspense retry in a bare RTL/happy-dom test (renderRoute renders internally, not inside an awaited act), so the DOM stays on the fallback. Assert the pending fallback with a never-resolving promise; for the arrived state pass an already-settled promise (status/value preset) so use() reads it synchronously. The real pending->resolved transition is e2e. Also seals the prior review pass: - render-route theme option doc: useTheme() THROWS without a provider (it does not return an inert default) — corrected. - Regression tests added for fixes that lacked them: runMiddleware reverse no-autofill parity, and dispatch wiring the router cache store into the context.
Third review pass on the consumer testing primitives. Each behavioral fix has a regression test verified to fail without it. High: - renderToFlightString no longer hangs when a Server Component throws: onError captures the error (matching production's void return) and the promise rejects after the stream drains, instead of an unhandled rejection + timeout. - assertGeneratedRoutesMatch no longer reports auto-generated $path_/$prefix_ runtime names as `extra`, so it stops throwing on an in-sync app that uses any unnamed path()/include() route. - dispatch runs GLOBAL middleware before the _rsc_partial short-circuit (an auth/redirect gate can block a partial), matching handler.ts; route-level middleware stays skipped on a partial, like handleResponseRoute. Medium: - Shared normalizeBasename (extracted from createRouter) used by the context helpers so a non-canonical basename yields the same redirect Location as prod. - runLoader/runMiddleware accept cacheStore/cacheProfiles (forwarded to createRequestContext) so `use cache` no longer silently bypasses; documented. - runLoader gates ctx.use(handle) on the render barrier (throws before await ctx.rendered(), like loader-resolution.ts). - cache-status docs/examples/router-options keyed by the route NAME (not the URL pattern) and paired with the RSC fetch path, not dispatch. - renderRoute gains a normalized basename option wired into NavigationProvider. - runMiddleware's returned ctx.reverse is map-only, matching the chain. Low: - runLoader ctx.reverse throws without routeMap (no global-map leak). - collectHandle drops empty per-segment arrays (parity with collectHandleData). - documented expectParity HttpOnly limitation and dispatch's non-firing onError. - removed the redundant react-server condition on ./testing/flight-matchers; added it to the installed-consumer-imports guard. - strengthened the trivially-true LoadingSpinner test. Docs: testing.md, SKILL.md, README.md, feature-map.md, feature-file-map.md updated (six testing entries, corrected cache-key shape and dispatch partial behavior).
… response (dev + prod) Closes the residual gap that let the cache-status docs ship a wrong, URL-pattern shaped key: no e2e previously exercised assertCacheStatus against a real response. Adds e2e/cache-signal.test.ts (cloudflare-basic), covering BOTH dev and production: a unique query forces a cold cache key per run, so /slow-cache (a cache() segment route) deterministically reports slowCache=miss then slowCache=hit. The test pins the route-NAME key and asserts the wrong (pattern-shaped) key throws. Enables debugCacheSignal on the test app's router (additive X-Rango-Cache header; test-app only). Packaging fix uncovered by the e2e: importing the cache-status helpers from the `@rangojs/router/testing` barrel does NOT work in a plain Playwright runner — the barrel transitively pulls the build-only `@rangojs/router:version` virtual (via the route-manifest path), so collection fails with "Cannot find package". The helpers are pure (cache-status.ts imports only types), so re-export them from the Playwright-safe `@rangojs/router/testing/e2e` entry. The documented e2e examples (testing.md, SKILL.md) now import assertCacheStatus from `@rangojs/router/testing/e2e`; the barrel stays the Vitest import. feature-map and the cache-status docstring note the per-runtime import path. Verified: cache-signal e2e green in dev + production (slowCache=miss -> hit in both); typecheck, test:unit:all, lint, format, check:e2e-bucketing all clean.
…Context reachable Two blockers found dogfooding @rangojs/router/testing in an external (node_modules-installed) consumer. Neither reproduces in-repo: pnpm symlinks resolve the package to a realpath outside node_modules, so Node type-strips its .ts and Vitest does not externalize it. Blocker 1: importing rangoTestAliases in a vitest.config.ts threw ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING on Node >=23 (a config is loaded by Node, which refuses to strip types from .ts under node_modules). Ship ./testing/vitest as compiled JS (dist/testing/vitest.js) like ./vite, the only other Node-loaded entry. Anchor the alias targets at the package root so they resolve to the shipped src/*.ts from both the src and dist layouts. Add rangoTestConfig() (aliases + the required server.deps.inline contract) and rangoInlineDeps; keep rangoTestAliases. Blocker 2: createTestRequestContext's JSDoc told consumers to enter the context via runWithRequestContext(), which was exported from no public entry, stranding the action-auth path. Add runInRequestContext(fn, opts) (build + enter in one call) and re-export runWithRequestContext from @rangojs/router/testing. Also: SKILL MiddlewareFn -> Middleware; document runLoader cookie seeding needs a full Request. Migrate the 3 in-repo dogfood vitest configs to rangoTestConfig.
… seam, Node>=23 compat, ARIA-role gotcha
Follow-ups from external-consumer feedback (super.menu, 102 tests).
- Add installed-vitest-config.test.ts: a regression guard that COPIES the
package into node_modules (realpath UNDER node_modules) and runs the documented
rangoTestConfig quickstart under Node. This is the only guard that reproduces
an installed consumer — the sibling installed-consumer-imports test symlinks
the package (realpath outside node_modules, pnpm layout), which is why the
type-stripping blocker shipped undetected. Plus a static check that
./testing/vitest stays compiled JS wired into `pnpm build`.
- Skill + docs: document the consumer-owned binding seam (env.DB / DO / R2) and
the drizzle-orm/d1 .raw() contract (positional rows in schema-column order,
not objects) where consumers lose the most time.
- Skill header: Node >= 23 / Vitest compatibility note (use rangoTestConfig so
the config loads and deps.inline transpiles rango's TS source).
- renderRoute recipe: ARIA gotcha — an explicit role on <Link> overrides the
implicit "link" role, so getByRole("link") finds nothing.
…t, type-test recipe
Acts on super.menu feedback round 3 (10 items; 4 built, the rest verified already-shipped / wrong-premise / deferred via an adversarial review).
- renderRoute gains `mount?` to model an include('/shop', ...) subtree: useMount/useHref/useReverse all resolve the prefix (was e2e-only). Threaded through buildSegments at both call sites so mountPath stays consistent across renders.
- runInRequestContext now returns { result, response, cookies, locationState } so an action's Set-Cookie / flash output is assertable without casting through the @internal ctx.res / ctx.cookies(); runMiddleware result gains `cookies`. Brand-new experimental API, so the reshape carries no migration debt. Adds snapshotRunEffects + RunInRequestContextResult export.
- docs/skill: drizzle-d1 .run() (writes -> {success,meta}) vs .raw() (reads -> positional rows) split; first-class type-test recipe (@ts-expect-error / expectTypeOf / .test-d.ts); clarify handles reach layout components (global accumulation), dispatch runs the real response-route guard stack (component-route middleware stays e2e), middleware runs before the render barrier (no handles/rendered by design), and why interactive renderServer is deferred (react-server vs client condition wall).
…stContext snapshots on throw #7 — runLoader accepts a registered createLoader() handle (removes the body-extraction tax): - createLoader (loader.rsc.ts) assigns a runtime-fallback $$id and registers its fn even without the Vite plugin (after the dev-throw), mirroring createHandle. Production-inert: the plugin always injects a stable id for supported, exported loaders on BOTH builds, so the fallback fires only in a bare test or an unsupported call shape (which is dev-caught and client-unreachable). Red-teamed (5 skeptics + judge: GO). - runLoader accepts ((ctx)=>T) | LoaderDefinition; recovers the fn via def.fn ?? getFetchableLoader($$id)?.fn; clear error when unrecoverable. cloudflare-basic dogfoods the real handle end-to-end. fix — runInRequestContext now snapshots whether fn RETURNS or THROWS, returning { result, thrown, response, cookies, locationState }. The dominant cookie+flash case is an action that sets them then `throw redirect()` on success; before, the snapshot ran only after a normal return, so that output was lost. thrown is captured (NOT re-thrown); on a thrown redirect, response merges its Location with the accumulated Set-Cookie. (In runInRequestContext the framework's redirect interceptor doesn't run, so the cookie stub isn't drained — the cookie stays readable.) Also: pin that renderRoute auto-fills an optional param present in the match (production parity via useReverse merging useParams()), and document optional-param-in-pattern (auto-fill, no mount) vs include-mount-prefix (the new mount option). Docs/skill/feature-map updated; the cookie-overlay app comment that endorsed the obsolete 'export the body' pattern is corrected.
… cloudflare:true
The boolean did not extend. Mirror the rango() Vite plugin's preset: "node" | "cloudflare" (default "node") so future presets are an additive VALUE, not a new option. `{ preset: "cloudflare" }` stubs the CF runtime virtuals (cloudflare:workers / cloudflare:email); `{}` / "node" does not. Pre-stable rename, no deprecated alias (API-hygiene rule).
Updated the 3 in-repo vitest configs, the installed-vitest-config quickstart guard (+ its load.mjs), docs/testing.md (incl. the reference signature), SKILL.md, and feature-map.md. Also corrected a stale SKILL decision-tree row that still said runLoader takes the raw fn only (it accepts a registered createLoader since the prior commit).
BREAKING (experimental/preview only): consumers on `{ cloudflare: true }` switch to `{ preset: "cloudflare" }`.
…able tree
Add renderServerTree to @rangojs/router/testing/flight: it serializes a server
component to real Flight (identical bytes to renderToFlightString) and then
deserializes it back to a React element tree, in the same react-server worker
(no subprocess, no DOM). The win over the wire string is typed prop fidelity
across the client boundary (a Date comes back a Date, a Map a Map) and
inlined-vs-island detection — which no framework ships as a unit-test primitive.
Scope is serialize + deserialize only. There is no hydration/interaction:
in-process happy-dom hydration re-tests React more than the app and misses the
only hydration bug worth a dedicated test (server/client divergence needs a real
browser). Interaction stays at the e2e tier.
API:
- renderServerTree(element, opts?: { ...RenderToFlightStringOptions, clientComponents? })
→ { flight, tree }.
- findClientBoundaries(tree, name?) → ClientBoundary[] ({ id, name, props,
element }). Always an array (optional name filter); destructure [first] for one
island, assert on .length when count matters.
- rangoUseClientTransform() (from @rangojs/router/testing/vitest) — a Vite plugin
for the rsc Vitest project that applies the "use client" transform, so client
islands are AUTO-DISCOVERED from the server tree's own imports. With it wired,
renderServerTree(<Page/>) needs nothing. clientComponents is the fallback for
configs without the transform.
- assertFlightTreeRuntimeAvailable() smoke-checks the vendored client subpaths.
Why a transform: the rsc Vitest project does not run the "use client" transform,
so a plainly-imported island is an unmarked function the serializer would render
server-side. rangoUseClientTransform mirrors plugin-rsc's client transform
(parseAstAsync + transformDirectiveProxyExport) to replace each "use client"
export with a registerClientReference keyed by the module path. Server components
(no directive) are untouched, so renderToFlightString of leaf trees is unaffected.
Mechanism: a Proxy client manifest resolves any registered client reference with
non-async metadata, so each deserialized boundary's payload is a clean
resolved_module whose value [id, [], name] is synchronously readable.
Deserialization uses the vendored client (@vitejs/plugin-rsc/react/browser) with a
throwing load (deserialize-only never executes a client ref); a small
__webpack_require__/__webpack_chunk_load__ globals shim stands in for the Vite
plugin transform that does not run in a bare test. Async server components
serialize as deferred chunks, so the tree walk materializes resolved_model server
lazies while leaving client-reference chunks as boundary markers.
flight.ts now exposes a shared serializeToFlightString(element, opts, manifest)
used by both renderToFlightString (empty manifest) and renderServerTree.
Tests: src/testing/__tests__/flight-tree.rsc-test.tsx (auto-discovery via the
transform, + a clientComponents fallback test) with a Counter.tsx fixture (no
.client suffix — "use client" is a directive, not a filename); a cloudflare-basic
dogfood (test/server-tree.rsc-test.tsx + PriceTag.tsx) wires the transform and
uses auto-discovery. Both rsc configs add rangoUseClientTransform().
Docs: testing.md, skills/testing/SKILL.md, docs/internal/feature-map.md.
…nMiddleware
The handler test primitive (runInRequestContext) already surfaced cookies and
locationState as plain assertable objects, but headers a handler set (via
ctx.header(...)) were only reachable through response.headers. Add a `headers`
field — the response-header view as a plain { name: value } object — to both
runInRequestContext and runMiddleware results, parallel to cookies/locationState.
- headers excludes set-cookie (that stays parsed on `cookies`); names lowercased.
- Sourced from the final response, so a thrown redirect's Location appears too
(headers.location), consistent with the existing `response` field.
- New exported helper headersToObject(headers) in testing/internal/context.ts,
used by both run* helpers.
Tests: run-in-request-context (header set, set-cookie excluded, redirect Location
on headers) and run-middleware (a security header a middleware set). Docs:
testing.md, skills/testing/SKILL.md, docs/internal/feature-map.md.
… HandlerContext
A Rango route handler is a pure function `(ctx) => rsc` (what you pass to
`path("/p/:slug", ProductPage)`), not a component — so testing one means running
it with the HandlerContext the router builds, then asserting the RSC it renders.
renderHandler does exactly that:
- builds the real HandlerContext (createHandlerContext) inside a request context,
with ctx.params / ctx.reverse / ctx.get / ctx.header / cookies() / setLocationState;
- overrides ctx.use so loaders are SEEDED by reference (no real loader run, the
runLoader model) and handles return a recording push fn (ctx.use(Meta)({...})
doesn't crash and is captured on result.handles);
- calls handler(ctx), serializes the returned RSC and deserializes it to an
inspectable tree (shared serialize/deserialize core with renderServerTree);
- returns { tree, flight, thrown, response, cookies, headers, locationState,
handles } — the render counterpart to runInRequestContext (same effect snapshot
PLUS the rendered RSC). A `throw redirect()` is captured on `thrown` (tree
undefined, since it produced a Response); an unseeded ctx.use(loader) rejects
with a clear setup error (distinct from a handler's intentional throw).
Refactors so renderHandler can reuse the machinery:
- flight.ts: extract `serializeNodeToFlight(node, manifest, pathname)` (in-context
serialize) from serializeToFlightString; add a `vars` seed to the render options
so a server component reading getRequestContext().get(MyVar) during render is
testable. renderServerTree/renderToFlightString are ELEMENT-only again (the
earlier thunk overload is removed — handlers go through renderHandler).
- flight-tree.ts: extract `deserializeFlight(flight)`; export makeClientManifest /
registerClientComponents.
- internal/seed-vars.ts: new react-server-safe module holding VarsInit +
seedVariables (depends only on the dependency-free context-var), shared by the
node tier (internal/context.ts re-exports) and the react-server Flight tier.
Tests: src/testing/__tests__/render-handler.rsc-test.tsx (params + seeded
ctx.use(Loader) + ctx.use(Meta) capture + client island + redirect effects +
unseeded-loader rejection) and a cloudflare-basic dogfood. Docs: testing.md
(new renderHandler section + cheatsheet/table), skills/testing/SKILL.md,
docs/internal/feature-map.md, docs/internal/feature-file-map.md.
…tContent; fix rsc-config alias trap
Two consumer-driven additions to @rangojs/router/testing/flight, plus a fix to
the documented react-server Vitest setup.
1. findClientBoundaries(tree, selector?) now takes a selector, not just a name.
A string still matches the export name (back-compat); a BoundarySelector object
filters client islands by `name` / `testId` (props["data-testid"]) / `props`
(subset, deep-equal: Date/Map/Set/array/nested aware) / `where`, AND-ed. So you
can locate an island by the test id you passed it, not only by component name.
2. findElements(tree, selector?) + textContent(node) select the SERVER-rendered
tree (the host elements a server component produced: <article>, <h2>, ...).
Each FoundElement is { tag, props, children, text, element }; selector = a host
tag string or { tag, testId, props, text (substring|RegExp), where }. host-only
by design (isReactElement guards against a payload object whose `type` is a
string prop). textContent concatenates a subtree's text leaves -> replaces the
`JSON.stringify(tree).toContain(...)` idiom. Caveat (documented): server
COMPONENTS don't survive Flight as identities (executed during serialization),
so you match the host elements they rendered; client islands keep identity via
findClientBoundaries. The two finders share one tree walk.
3. rsc-config fix: the react-server Vitest project must alias bare @rangojs/router
-> index.rsc.ts (via rangoTestAliases) IN ADDITION to resolve.conditions. A
rendered handler/component that imports getRequestContext()/cookies() from the
bare specifier otherwise resolves to the throwing out-of-react-server stub
(Vite does not reliably apply the condition to bare-package export resolution);
symptom: renderHandler returns tree: undefined with the stub error on `thrown`.
The alias points at index.rsc.ts (the real react-server build) and leaves React
untouched, so it does not crash the server React build. Dogfooded in
cloudflare-basic (a handler reading getRequestContext from the bare specifier)
and confirmed load-bearing (removing the alias fails the test). Corrected the
stale "Flight project can't import server APIs / the alias would crash" claims
in vitest.ts, docs/testing.md, and the cloudflare-basic rsc config.
Consistency (from an adversarial review): ClientBoundary now mirrors FoundElement
- props EXCLUDES children, with children exposed as its own field (was: boundary
props included children, element props excluded it). deepEqual compares Set
members structurally (was shallow has(), failing on equal-but-distinct objects).
Tests: src/testing/__tests__/flight-tree.rsc-test.tsx (selector by testId/props/
where, findElements by tag/testId/props/text/RegExp/predicate + document order +
boundary exclusion, textContent, children-split, Set-of-objects deep-equal) and a
cloudflare-basic dogfood (getRequestContext-from-bare handler + props selector).
Docs: docs/testing.md, skills/testing/SKILL.md, docs/internal/feature-map.md.
…loaders, runMiddleware locationState
Addresses the API-consistency findings from the testing-surface review. One
breaking change (the request argument) plus two additive parity fixes and the
documentation of two intentional differences.
BREAKING — one name for "the request a primitive runs under": opts.request
(a Request, or a URL string). Previously the argument had four spellings
(positional request, opts.request, opts.url, opts.initialUrl). Now:
dispatch(router, { request, env }) // was dispatch(router, request, { env })
runMiddleware(mw, { request, ...opts }) // was runMiddleware(mw, request, opts)
renderToFlightString(el, { request, ... }) // was { url }
renderServerTree(el, { request, ... }) // was { url }
renderRoute(routes, { request, ... }) // was { initialUrl }
renderHandler / runLoader / runInRequestContext // already opts.request, unchanged
renderRoute's request is the client initial location (only its URL is read).
flight's request accepts a Request (headers taken from it) or a URL string
(opts.headers applied). Migrated all ~20 caller files across the router tests,
e2e/mini, cloudflare-basic, and vite-rsc-demo.
ADDITIVE:
- runLoader gains `loaders: [[OtherLoader, data]]` — seed ctx.use(OtherLoader)
by reference, the same tuple shape as renderHandler/renderRoute (checked before
the dynamic `use` resolver). The same ctx.use(loader) call is now seeded the
same way everywhere.
- runMiddleware result gains `locationState` (a flash set via setLocationState /
redirect({ state })), for parity with runInRequestContext and renderHandler.
DOCUMENTED AS INTENTIONAL (not changed):
- `handles` shape: runLoader seeds the POST-collect accumulated value a loader
reads; renderRoute seeds the RAW pushed values[] (the collect runs on them).
Different layers, so the shapes differ on purpose.
- return shape: runLoader returns the loader data directly and dispatch returns a
Response, while the render/run primitives return an envelope (effect snapshot
and/or tree).
Tests: +3 (runLoader loaders by reference + precedence over use; runMiddleware
locationState via redirect({ state })). Docs: testing.md, skills/testing/SKILL.md,
docs/internal/feature-map.md updated to the unified contract, with the convention
and the two intentional differences stated.
Verified by two adversarial-review passes (migration fidelity, source
correctness, docs accuracy) before commit.
…b-files
Break skills/testing/SKILL.md (one 846-line file) into a lean index plus 14
self-contained sub-files, one per Rango primitive being tested. skills/ ships
in the published npm package, so each sub-file stands alone and links to the
long-form docs/testing.md by absolute URL.
Each sub-file follows one template: Layer/Import/DSL header, Options table,
Context table (the seeded ctx your code receives), Returns, a minimal recipe,
Caveats, and See also. The Context table is the explicit new surface: a reader
sees exactly what their loader/middleware/handler/action body receives.
SKILL.md is now the index — the decision-tree rows link to each sub-file.
API accuracy verified against src/testing/* and the context type sources;
notably: HandlerContext/MiddlewareContext have no cookies()/header()/setStatus
(cookies() is a standalone import; only the ALS-bound RequestContext carries the
internal header()/setStatus()); runLoader returns data directly; RouteParams
takes a route name + map, not a path pattern; path.json wraps a bare return as
{ data } but passes a returned Response through unwrapped.
Files:
- SKILL.md rewritten to the index
- new: setup, bindings, loader, middleware, server-actions, handles,
reverse-and-types, client-components, flight, server-tree, render-handler,
response-routes, e2e-parity, cache-prerender
…b comments The preset rename (163f5e6) updated the docs/configs but missed these two shipped stub comments, which still pointed consumers at the old rangoTestAliases({ cloudflare: true }) API. Update to { preset: "cloudflare" }.
864b674 to
97051ea
Compare
…539) main #539 changed the JSON response-route wire contract — bare handler value on success and an RFC 9457 problem+json body on error, dropping the { data }/{ error } envelope and RouterError.type. The testing module's dispatch() primitive was added on this branch, so #539 could not touch it; it mirrors handleResponseRoute and still emitted the old envelope (and imported the renamed-away createResponseErrorPayload). Bring it onto the merged contract, and close a pre-existing handler-returned-Response parity gap found in review. - src/testing/dispatch.ts: json success serializes the bare value (application/json); json error emits createProblemDetails(...) as application/problem+json. Resolve the effective ctx.setStatus() override BEFORE building the problem body so body.status/title track the actual HTTP status, matching handleResponseRoute's catch (P2). Import createResponseErrorPayload -> createProblemDetails. - src/testing/dispatch.ts: a handler that RETURNS a Response now goes through rewrapHandlerResponse, byte-identical to handleResponseRoute's rewrapResponse — WebSocket-upgrade bypass via mergeStubHeadersAndFinalize (status 101 throws in the Response constructor; the webSocket prop is otherwise lost), a Set-Cookie-preserving header rebuild, and statusText dropped — instead of the generic re-wrap that preserved statusText and reconstructed upgrades. - Tests (router unit + cloudflare-basic dogfood): assert bare values and the problem+json shape (title/status/detail/code, type omitted); drop the removed RouterError `type` option; add a P2 effective-status test; add 5 tests pinning the returned-Response rewrap (multiple Set-Cookie + statusText drop, setStatus override, stub-header merge, WebSocket pass-through without reconstruction). - Docs/skills (docs/testing.md, skills/testing/{response-routes,setup}.md, cloudflare-basic FINDINGS): bare value + problem+json. - feature-file-map.md conflict: keep main's updated Response-route note AND the branch's Consumer testing primitives row. Post-merge review polish (review of the whole testing module): - RSC config doc template + the 3 in-repo vitest.rsc.config.ts now carry the mandatory server.deps.inline (rangoInlineDeps) — an installed RSC consumer on Node >= 23 otherwise hit ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. - runMiddleware opts.request is now optional (parity with runLoader / runInRequestContext; defaults to http://localhost/). Type-only — emitted JS unchanged. - JSDoc/doc notes: flight-tree pool:"forks" requirement, renderRoute handle $$id mutation + persistent loaderData across navigate(), stale vi.mock cross-reference in the response-routes skill. - docs/testing.md "deliberately don't cover" trap table completed with 3 more false-confidence traps: "use cache" without a seeded cacheStore (silently uncached), whole-app router import blocked by Prerender/createLoader $$id, and the ctx.rendered() barrier-wiring loader trap. Capability polish (making these fail loud + full-router import) tracked in the #533 follow-up issue. Gate: typecheck 12/12, test:unit:all EXIT=0 (router 3487 + 3 todo, cloudflare-basic 39, vite-rsc-demo 15, mini 16, all RSC suites), lint 0/0, format clean. Two adversarial multi-lens review passes (bare-T migration, then the returned-Response fix) confirmed parity and surfaced no in-scope defects.
97051ea to
c0c107c
Compare
… production throw)
Review finding (Medium): gating the missing-$$id throw on NODE_ENV==="development"
meant the runtime fallback fired in ANY non-dev runtime, including a production
`vite build`. For an UNSUPPORTED handler shape (e.g. `export let X = Static(...)`)
the Vite plugin leaves the id un-injected and untracked with no warning, so a
production build that previously threw loud ("missing $$id") would silently assign
__rango_runtime_static_0 and degrade to a runtime static/prerender miss. The
"fallback never fires under the plugin" assumption only held for SUPPORTED shapes.
Fix: gate the fallback on `process.env.VITEST` — the only signal true in both
vitest projects (the RSC project forces NODE_ENV=production) yet absent in a real
build. The fallback now fires ONLY under a test runner; a missing id in dev OR a
production build throws, restoring the pre-PR safety net. Applied to all five
create-functions (handle.ts, loader.ts, loader.rsc.ts, prerender.ts,
static-handler.ts) — they shared the identical gate and the same latent bug.
Tests: the id-guard / fallback tests now toggle VITEST (not NODE_ENV) to exercise
the throw ("throws outside a test runner" cases). Docs (testing/vitest.ts JSDoc +
docs/testing.md) note the throw-on-unsupported-shape in a real build. Gate:
typecheck 12/12, test:unit:all EXIT=0 (router 3495 + 3 todo, cloudflare-basic 39,
RSC 27), lint 0/0, format.
Relates to #533, #542.
…gnostic dev/test-only
Review finding (Medium): the previous `!process.env.VITEST` guard shipped to
production with two problems. (1) It is a BARE `process.env` access — a runtime
without `process` (browser, certain edge/worker RSC envs) can ReferenceError before
reaching the intended Rango error. (2) Unlike `process.env.NODE_ENV` (folded by the
app build `define`), `VITEST` is not folded, so switching to it un-DCE'd the rich
stack-parsing `missingInjectedIdError` diagnostic — it shipped in every prod chunk.
Fix:
- New runtime-safe helper `isUnderTestRunner()` = `!!globalThis.process?.env?.VITEST`
(optional-chained — never a bare `process.env`). Used by all five create-fns.
- Restructure handle.ts / loader.ts / loader.rsc.ts so the rich diagnostic stays
behind `process.env.NODE_ENV !== "production"` (build-folded -> DCE'd, tree-shakes
missing-id-error.ts out) with a SMALL production throw in its place. Static() /
Prerender() already throw small messages — only the runtime-safe check changed.
Verified via `pnpm --filter rangojs-e2e-mini build` + chunk inspection: shipped .js
uses `globalThis.process?.env?.VITEST` / `{}.VITEST` (no bare access); the rich-only
markers ("Unsupported create", "created at", stack parsing, node_modules filtering)
are DCE'd (0 across all chunks); only the small throw ships.
Tests toggle VITEST (not NODE_ENV); added a prod-small-throw test (NODE_ENV=production
+ no VITEST -> small, non-stack-parsing message). Gate: typecheck 12/12, test:unit:all
EXIT=0 (router 3496 + 3 todo, RSC 27), lint 0/0, format.
Relates to #533, #542.
…eLoader/Static (#542) (#543) * feat(testing): loud-fail the alias trap + bare-import Prerender/createLoader/Static (#542) Follow-up to #533, capability + coverage polish. - renderHandler: detect the server-only-API stub throw (a handler reading getRequestContext()/cookies() when the rsc-project alias is missing) and rethrow an actionable RenderHandlerSetupError pointing at rangoTestAliases, instead of swallowing it into an opaque tree:undefined. Dual-substring match so a normal handler throw is NOT reclassified. - Bare-test $$id fallback for the component-handler kinds — Prerender() (prerender.ts) and Static() (static-handler.ts) — joining createHandle / createLoader. Each assigns a process-stable runtime fallback $$id when the Vite plugin injected none, gating the dev-throw on NODE_ENV, so a router using them CONSTRUCTS in bare vitest (for dispatch / assertGeneratedRoutesMatch). Provably inert in production (never fires under the plugin; prerender lookup keys on routeName+paramHash; the Static manifest keys on the plugin id; Static's $$id is read only during real RSC serving) — both red-teamed (Prerender 0/3 refuted; Static go-with-fix, the only fix being doc sync). Importing a real whole-router FILE can still fail on app-specific page-module imports / plugin virtual: modules — app/plugin-specific, not a handler-id limitation. - docs/skills/dogfood: corrected the import contract everywhere to the accurate statement (all three construct; the whole-router-file caveat is app/plugin module resolution). Steer to renderServerTree over raw renderToFlightString wire matchers; assertCacheStatus default, telemetry sink advanced. Considered and dropped: a renderRoute mount-prefix guard — production simply PREFIXES include-relative patterns (/shop + /shop -> /shop/shop), so a path starting with the mount is a legitimate shape, not an error. The mount trap stays documented, not enforced. (Also deferred, tracked in #542: idle-nav loud-fail; use-cache-without-store warn.) Tests: render-handler +2, render-route +1 (prefixing parity), prerender +3, static +4 (incl. a whole-router construct+dispatch integration test); updated the pre-existing static-handler-types "missing $$id" test to the gated contract. Gate: typecheck 12/12, test:unit:all EXIT=0 (router 3495 + 3 todo, RSC 27), lint 0/0, format. Relates to #533. * docs(testing): fix mangled Setup blockquote + stale Prerender-free notes [skip ci] The Setup-section import-contract blockquote was reflowed badly (lost the > quote prefix on continuation lines and ate spaces around inline code: $$idis, realrouter.tsx, pluginvirtual:). Convert it to two normal paragraphs so it renders correctly. Also clear stale Prerender-free / blocked-by-Prerender stragglers in cloudflare-basic + vite-rsc-demo FINDINGS to the new contract (the whole-router FILE import is blocked by app page-module deps / plugin virtual: modules, not by handler $$id, which now falls back). * fix(testing): gate the $$id fallback on VITEST, not NODE_ENV (restore production throw) Review finding (Medium): gating the missing-$$id throw on NODE_ENV==="development" meant the runtime fallback fired in ANY non-dev runtime, including a production `vite build`. For an UNSUPPORTED handler shape (e.g. `export let X = Static(...)`) the Vite plugin leaves the id un-injected and untracked with no warning, so a production build that previously threw loud ("missing $$id") would silently assign __rango_runtime_static_0 and degrade to a runtime static/prerender miss. The "fallback never fires under the plugin" assumption only held for SUPPORTED shapes. Fix: gate the fallback on `process.env.VITEST` — the only signal true in both vitest projects (the RSC project forces NODE_ENV=production) yet absent in a real build. The fallback now fires ONLY under a test runner; a missing id in dev OR a production build throws, restoring the pre-PR safety net. Applied to all five create-functions (handle.ts, loader.ts, loader.rsc.ts, prerender.ts, static-handler.ts) — they shared the identical gate and the same latent bug. Tests: the id-guard / fallback tests now toggle VITEST (not NODE_ENV) to exercise the throw ("throws outside a test runner" cases). Docs (testing/vitest.ts JSDoc + docs/testing.md) note the throw-on-unsupported-shape in a real build. Gate: typecheck 12/12, test:unit:all EXIT=0 (router 3495 + 3 todo, cloudflare-basic 39, RSC 27), lint 0/0, format. Relates to #533, #542. * fix(testing): runtime-safe test-runner check + keep the rich $$id diagnostic dev/test-only Review finding (Medium): the previous `!process.env.VITEST` guard shipped to production with two problems. (1) It is a BARE `process.env` access — a runtime without `process` (browser, certain edge/worker RSC envs) can ReferenceError before reaching the intended Rango error. (2) Unlike `process.env.NODE_ENV` (folded by the app build `define`), `VITEST` is not folded, so switching to it un-DCE'd the rich stack-parsing `missingInjectedIdError` diagnostic — it shipped in every prod chunk. Fix: - New runtime-safe helper `isUnderTestRunner()` = `!!globalThis.process?.env?.VITEST` (optional-chained — never a bare `process.env`). Used by all five create-fns. - Restructure handle.ts / loader.ts / loader.rsc.ts so the rich diagnostic stays behind `process.env.NODE_ENV !== "production"` (build-folded -> DCE'd, tree-shakes missing-id-error.ts out) with a SMALL production throw in its place. Static() / Prerender() already throw small messages — only the runtime-safe check changed. Verified via `pnpm --filter rangojs-e2e-mini build` + chunk inspection: shipped .js uses `globalThis.process?.env?.VITEST` / `{}.VITEST` (no bare access); the rich-only markers ("Unsupported create", "created at", stack parsing, node_modules filtering) are DCE'd (0 across all chunks); only the small throw ships. Tests toggle VITEST (not NODE_ENV); added a prod-small-throw test (NODE_ENV=production + no VITEST -> small, non-stack-parsing message). Gate: typecheck 12/12, test:unit:all EXIT=0 (router 3496 + 3 todo, RSC 27), lint 0/0, format. Relates to #533, #542.
Bring in main (#540-#551: dev/prod matching stability + lazy-include fixes, perf benchmarks, docs reorg/archive, release 0.0.0-experimental.116). Resolve the two internal docs-map conflicts (feature-file-map.md, feature-map.md) by keeping main's cell updates AND the branch's testing rows (the "Consumer testing primitives" row and the six ./testing* export-surface rows). Gate: typecheck 12/12, test:unit:all EXIT=0 (router 3537 + 6 todo, cloudflare-basic 39, RSC 27), lint 0/0, format.
dispatch: global middleware now wraps the trailing-slash 308 and the 404
(produced inside coreHandler via createResponseWithMergedHeaders, matching
rsc/handler.ts); middleware redirects on _rsc_partial/_rsc_action convert to
204 + X-RSC-Redirect via interceptRedirectForPartial; finalizeResponse drains
ctx.onResponse() on every exit path. Location-state-carrying partial redirects
documented as the one residual divergence (no Flight runtime).
flight-tree: resolveServerLazy also accepts fulfilled chunks — the vendored
client transitions resolved_model->fulfilled on first read, which made
textContent non-idempotent and FoundElement.text empty for async server
components below nested host elements. deepEqual gains a cycle guard;
textContent handles bigint.
render-handler: a returned Response keeps its body (was rewrapped to null);
function-form handle pushes are evaluated as production does.
run-loader: an unseeded handle read after the mocked barrier resolves via the
registered collect over empty segments (production collect([])), instead of
leaking into the loaders-only resolver or throwing a misleading error.
flight/render-route: legacy { url } / { initialUrl } options now throw with a
migration message instead of being silently ignored. flight-matchers no longer
require the react-server condition (normalizeFlight hoisted to a
serializer-free module).
e2e harness: fixture assigns cleanup immediately after spawn so startup
failures no longer orphan servers; a failed build rejects loudly instead of
silently serving stale dist/; mode is required and validated eagerly;
expectParity submit-path hazards (double execution, whole-jar cookie
comparison) documented in JSDoc and the skill.
docs: assertCacheStatus recipes wrap Playwright APIResponse headers;
RouteParams name+map form; lifted $$id-limitation sentence corrected;
routeName scoped-reverse claim removed (map-only, matching production);
findElements/ClientBoundary shapes corrected; stale FINDINGS.md counts dropped.
All regression tests verified to fail against the pre-fix code.
Adds a session-scoped progressively-enhanced counter to the test-app (/parity-counter: server action increments a cookie-backed count) and a submit-parity spec in consumer-testing-harness.test.ts, dev + production. Closes the last unexercised harness surface: the no-JS leg was proven to run by a negative probe (a non-session-scoped global counter fails the parity assertion under double execution, observed 1 vs 2). The cookie scoping satisfies the documented submit-intent requirement: each browser context observes only its own increments, so observed testids and cookie jars match across the JS and no-JS contexts. Includes the regenerated router.named-routes.gen.ts (parityCounter).
|
Follow-up scope from #560 (rango state cookie + 1. Two more export-condition APIs now hit the react-server aliasing requirement. 2. The new contracts want first-class assertions.
Pre-existing note still open here: the branch carries the |
Brings in #560 (rango state cookie + invalidateClientCache/keepClientCache), #534 (cache tag invalidation cacheTag/updateTag/revalidateTag), and the CFCacheStore latency-budget/SWR hardening (#558). Non-trivial conflict resolutions: - docs/internal/feature-map.md & feature-file-map.md: unioned both sides' tables. Took main's updated `.`/`./cache` rows (new cache-tag + client-cache exports) plus its `Client cache invalidation` feature row, and kept this branch's `./testing/*` export rows and `Consumer testing primitives` row. - tests/cloudflare-basic/src/urls.tsx: kept main's updateTag/revalidateTag imports but dropped `cookies` - this branch extracted the inline cookies().set("mw-overlay") into middleware/cookie-overlay.ts. - src/testing/render-route.tsx: removed the test NavigationBridge's updateAppShell member; main removed updateAppShell from the NavigationBridge type (app-shell updates now flow only through appShellRef). Verified green: typecheck (12/12), test:unit:all (3915 passed / 6 todo), lint (oxlint 0/0), format (oxfmt), check:e2e-bucketing.
…ags in unit primitives Closes the #560 follow-up flagged on PR #533: the consumer testing primitives now make the rango cache-invalidation directives assertable at the unit layer instead of silently no-opping. What a consumer writes / sees differently: - invalidateClientCache() now ROTATES the rango state cookie under renderHandler / runInRequestContext / runLoader / runMiddleware (the name is seeded by default, rango-state_router_0), so the rotation Set-Cookie is observable on result.response.headers.getSetCookie(). Previously it silently no-opped because the test stub omitted the cookie name (production always resolves one). - New opts.stateCookie ({ prefix?, routerId?, version? }) customizes the seeded name/version to match createRouter({ stateCookiePrefix, id }). - renderHandler / runInRequestContext now return the resolved `stateCookieName`, so a test asserts the rotation without recomputing it. - keepClientCache() already set the x-rango-keep-cache directive header; now it is documented and tested (idempotent). - StateCookieSeed exported from @rangojs/router/testing and /testing/flight. Dogfood (cloudflare-basic, through the public testing entry): - test/client-cache.test.ts: real logoutAction (invalidateClientCache + session clear) and dismissBannerAction (keepClientCache + cookie persist). - test/cache-tags.test.ts: real publishProductAction asserts updateTag('products') evicts a tagged entry in a MemorySegmentCacheStore (read-your-own-writes). This corrects an earlier wrong claim: server-side tag invalidation IS unit-assertable in-process; CFCacheStore+KV is only needed for the distributed cross-colo purge. Docs/skills: testing.md (trap-table clarification), render-handler.md, server-actions.md, loader.md, middleware.md, feature-map.md updated for the new option, the stateCookieName return, and the assertion pattern. Tests: +8 render-handler (rotation idempotent, custom seed, keep idempotent, Response-while-invalidating), +5 runInRequestContext (action rotation, keep, keep+invalidate, https Secure, redirect-merge), +3 cloudflare-basic dogfood. Verified: typecheck 12/12, test:unit:all (router 3801 unit + 45 RSC, cloudflare-basic 42), lint 0/0, format, check:e2e-bucketing.
…ags in unit primitives Closes the #560 follow-up flagged on PR #533: the consumer testing primitives now make the rango cache-invalidation directives assertable at the unit layer instead of silently no-opping. What a consumer writes / sees differently: - invalidateClientCache() now ROTATES the rango state cookie under renderHandler / runInRequestContext / runLoader / runMiddleware (the name is seeded by default, rango-state_router_0), so the rotation Set-Cookie is observable on result.response.headers.getSetCookie(). Previously it silently no-opped because the test stub omitted the cookie name (production always resolves one). - New opts.stateCookie ({ prefix?, routerId?, version? }) customizes the seeded name/version to match createRouter({ stateCookiePrefix, id }). - renderHandler / runInRequestContext now return the resolved `stateCookieName`, so a test asserts the rotation without recomputing it. - keepClientCache() already set the x-rango-keep-cache directive header; now it is documented and tested (idempotent). - StateCookieSeed exported from @rangojs/router/testing and /testing/flight. Dogfood (cloudflare-basic, through the public testing entry): - test/client-cache.test.ts: real logoutAction (invalidateClientCache + session clear) and dismissBannerAction (keepClientCache + cookie persist). - test/cache-tags.test.ts: real publishProductAction asserts updateTag('products') evicts a tagged entry in a MemorySegmentCacheStore (read-your-own-writes). This corrects an earlier wrong claim: server-side tag invalidation IS unit-assertable in-process; CFCacheStore+KV is only needed for the distributed cross-colo purge. Docs/skills: testing.md (trap-table clarification), render-handler.md, server-actions.md, loader.md, middleware.md, feature-map.md updated for the new option, the stateCookieName return, and the assertion pattern. Tests: +8 render-handler (rotation idempotent, custom seed, keep idempotent, Response-while-invalidating), +5 runInRequestContext (action rotation, keep, keep+invalidate, https Secure, redirect-merge), +3 cloudflare-basic dogfood. Verified: typecheck 12/12, test:unit:all (router 3801 unit + 45 RSC, cloudflare-basic 42), lint 0/0, format, check:e2e-bucketing.
579ccde to
39dd803
Compare
…o-JS jar comparison Merging #560 (rango state session cookie) made two consumer-testing-harness parity tests fail in CI. expectParity compares the whole document.cookie jar between the JS and no-JS transports, but the rango state cookie (rango-state_<id>) is written/rotated ONLY by the client runtime (it keys the client's prefetch/HTTP caches and drives the divergence observer), so it exists in the JS jar and never in the no-JS one. The full-jar equality therefore always diverged once the cookie existed: Expected: "parity-count=1; rango-state_5a0ebfec=19ec0492cf8:1781342582085" Received: "parity-count=1" expectParity now excludes the rango state cookie (default prefix rango-state) from the comparison automatically, and gains an `ignoreCookies?: ReadonlyArray<string | RegExp>` option for a custom stateCookiePrefix or other JS-only/volatile cookies (analytics, CSRF). The jar is also sorted before comparison so cookie ORDER differences no longer false-mismatch. The import is the env-agnostic DEFAULT_STATE_COOKIE_PREFIX constant, so the e2e entry stays loadable in a plain Playwright runner. Verified: the two previously-failing tests (e2e/consumer-testing-harness.test.ts "... matches across JS and no-JS") now pass in BOTH dev and production locally; typecheck 12/12, test:unit:all, lint, format.
Seven findings from dogfooding @rangojs/router/testing in an external app.
T3 (highest leverage) — a router with a `document` can now be imported in a bare
unit test. createRouter validates `document` is a "use client" component, which
the bare-test transform never marked, so the import threw and blocked
dispatch/assertGeneratedRoutesMatch against the real router. assertClientComponent
gains `{ allowServerInTest }`; under isUnderTestRunner() it waives ONLY the
"use client" requirement (the "not a JSX element" guard still fires) — a real
dev/build still throws. Mirrors the existing $$id-fallback gating.
T2 — runLoader effect observability. New `runLoaderResult(loader, opts)` returns
{ data, thrown, response, cookies, headers, locationState, stateCookieName } so an
auth-style loader that sets a Set-Cookie then `throw redirect(...)` is assertable;
bare `runLoader` (returns the data) is unchanged. run-loader shares
buildLoaderCtxOpts + runWithLoaderContext between the two.
T4 — testPattern(pattern, hostname, pathname = "/"): path-based host patterns
(`**.workers.dev/admin`) were untestable (pathname was hard-coded "/"). New
matchesHost(pattern, request) matches against a real Request via parseRequest.
T5 — re-export the TelemetryEvent member types (CacheDecisionEvent,
CacheSegmentSignal/Status, Request*/Loader* events, …) from the root + testing
entries, so a TelemetrySink author / cache-status test can name them.
T1/T6/T7 — docs: repoint vitest.ts's dangling "testing guide" refs at the shipped
skills/testing/setup.md and complete its rangoUseClientTransform config example;
show the useLoader envelope in render-route JSDoc; multi-app GeneratedRouteMap
caveat in the reverse-and-types + client-components skills.
Doc/feature-map propagation for the new public surface (docs ARE the consumer
contract): runLoaderResult added to skills/testing/loader.md + docs/testing.md
(surface table, cheatsheet, prose) + feature-map/feature-file-map; the path-aware
testPattern + matchesHost added to skills/host-router/SKILL.md + the ./host/testing
feature-map row; runLoaderResult + @rangojs/router/host/testing added to the TRUE
installed-consumer export-map guard (installed-consumer-imports.test.ts), not only
the source-alias guard.
Considered but NOT taken (deliberate): kept RunLoaderResult.data (loader-domain
vocabulary, not runInRequestContext's `result`); kept the telemetry re-export
duplicated across the two entry barrels (matches the dual-entry convention);
document-only test relaxation is at the right altitude.
/simplify pass (4 quality angles): extracted matchPatterns() in host/testing.ts and
buildRunSnapshot() in internal/context.ts (runInRequestContext + runLoaderResult
share the snapshot sequence).
Verified: typecheck 12/12, test:unit:all (router 3816 unit + 45 RSC, cloudflare-basic
42, mini 16, demo 15), lint 0/0, format, e2e-bucketing. NOT pushed (awaiting go-ahead).
#559 (docs(revalidate): ctx.isAction idioms + shop-demo adoption) touched cloudflare-basic/src/urls.tsx. Sole conflict was the @rangojs/router import line: resolved to { urls, updateTag, revalidateTag } — dropping both `cookies` (this branch extracted the inline cookies().set into middleware/cookie-overlay.ts, so the body no longer uses it) and `ResponseHandlerContext` (#559 removed the `(ctx: ResponseHandlerContext)` annotation in favor of inference). Verified neither is referenced in the merged body. Verified: typecheck 12/12, test:unit:all (router 3816 unit + 45 RSC, cf 42, mini 16, demo 15), lint 0/0, format.
…runLoaderResult.result, parity/snapshot robustness, T8 reverse typing
From the multi-agent review of the @rangojs/router/testing surface (+ external T8):
- runMiddleware now returns `stateCookieName` (parity with runInRequestContext /
runLoaderResult / renderHandler; it accepted `stateCookie` but never surfaced
the resolved name). +3 tests (invalidate/keep/seed).
- runLoaderResult envelope field `data` -> `result`, matching runInRequestContext's
envelope (the two snapshot primitives now agree). Tests + docs updated.
- expectParity: snapshot() gains `.first()` + `.catch(()=>null)` (parity with
readObserved — no unhandled rejection on a missing testid); settleSubmit warns
(instead of silently snapshotting) when the observed state never stabilizes.
- T8: createTestRequestContext().ctx.reverse is typed via the new
TestRequestContextObject so reversing a LOCAL routeMap name type-checks (was
typed against the global Rango.GeneratedRouteMap, forcing a cast). +1 test.
- dispatch: +_rsc_action redirect-interception test (204 + X-RSC-Redirect).
- render-route: +handles-persist-across-navigate, +locale-mount (include('/:locale?'))
tests. cache-status: route-name keys + a mixed-stream filterCacheDecisions test.
installed-vitest-config: assert the bare @rangojs/router -> index.rsc.ts alias.
headersToObject stays duplicated between render-handler (flight tier) and
internal/context (node tier) on purpose — context.ts is not react-server-safe
(why seed-vars was split out); documented in-code.
Consumer-contract doc fixes from the review: - loader.md: runLoaderResult documented (Returns + recipe); corrected the theme caveat (ctx.theme/setTheme are handler-only, not "inert") and the loaders-vs-use execution wording (the `use` resolver DOES run; only seeded tuples don't); noted searchData seeds verbatim (schema validation is e2e). - middleware.md: stateCookieName Returns row + stateCookie assertion note. - e2e-parity.md: ignoreCookies option row + the full expectParity opts signature. - response-routes.md: _rsc_action middleware-redirect (204 + X-RSC-Redirect) caveat. - handles.md: seeded handles persist across navigate() (like loaderData). - cache-prerender.md: clarified the dual import (testing vs testing/e2e). - SKILL.md decision tree: runLoaderResult row. docs/testing.md surface table: runLoaderResult's explicit envelope shape.
Output of the 2026-06-13 multi-agent review of @rangojs/router/testing: - docs/internal/reviews/testing-api-overview.md — high-level surface map (six entries + host/testing, primitives by layer, design constraints, honest limits). - docs/internal/reviews/testing-primitives-review-2026-06-13.md — the review record: totals (96 raised / 44 confirmed), what was fixed, documented-only decisions, confirmed-correct gates, the deferred PE-parity-retrofit follow-up, rejected false-positives, and the low/nit triage. Internal only (docs/ is excluded from the package files).
…nderHandler accuracy [skip ci] Follow-up doc-only fixes from test-app review (markdown only): - runLoaderResult was renamed to return `result` (1b9f266) but three doc spots still advertised `data` (a consumer following them would destructure `{ data }` and get undefined): docs/testing.md prose + cheatsheet signature, loader.md Returns note. All now `result` (= the loader's data). - testing-api-overview.md e2e harness was misdescribed: useFixture returns `{ mode, root, url(path?), proc() }` (not `{ url, page, context }`); parityDescribe is dev/prod BUCKETING (not JS-on/off); expectParity is `(page, intent, opts)` running a JS-vs-no-JS snapshot comparison (not an "assertion proxy"). Corrected against src/testing/e2e/parity.ts. - testing-api-overview.md "unseeded loaders" limit: renderRoute resolves an unseeded read to undefined, but renderHandler REJECTS with RenderHandlerSetupError — the two differ; corrected. Also added the now-returned stateCookieName to the runMiddleware return shape. [skip ci] — markdown only; CI on the prior push (2f6142f) already passed.
A consumer-facing testing surface for apps built on
@rangojs/router, validated by dogfooding it into three real apps (cloudflare-basic,e2e/mini,vite-rsc-demo) — which surfaced (and this PR fixes) every gap between the documented recipe and what a real consumer hits. Since the surface first landed it has grown two RSC primitives —renderHandler(run a real route handler) andrenderServerTree+ tree finders (inspect real Flight output, not a wire string) — and the request argument has been unified onopts.requestacross every primitive. It was also brought onto main's bare-T + RFC 9457 response contract, gained whole-app-router import in bare tests (the$$idfallback now coversPrerender/Static), and the fallback was hardened to fire only under a test runner — a real build throws for an unsupported handler shape (see Wave 4). A final adversarial review pass then fixeddispatch's middleware topology, Flight-tree text fidelity, and the harness's failure paths, and closed the submit-parity gap with a real PE-form e2e (see Wave 5). Finally it was brought onto main's #560/#534 — the rango state session cookie (invalidateClientCache()/keepClientCache()) and cache-tag invalidation (updateTag) — making their consumer-observable effects assertable and fixing the no-op and parity break the merge introduced (see Wave 6).Problem
A consumer building on
@rangojs/routerhas essentially one supported, route-aware way to test today: Playwright e2e against a real dev/prod server. Everything below that — a loader's data logic, a middleware's short-circuit, an action's set-cookie-then-redirect, a redirect/response-route's status+headers, a component reading router context, a real route handler's rendered RSC, a handle's accumulator, cache/prerender status — is either impossible in isolation or forces the consumer to copy Rango's internal fixture/helper code (vite-rsc-demo copy-pasted ~280 lines). The execution machinery that would make these testable exists but is internal-only.Worse, the naive setup does not work: a consumer's own router/loaders/middleware cannot be imported in a bare Vitest —
@rangojs/routerresolves to server-only stubs (urls()/createRouter()/cookies()throw) outside thereact-servercondition, and Vitest does not apply that condition to bare-package resolution. This PR closes both gaps and ships the setup as a one-line preset.The surface — six entries, one per test runtime
The split is forced by hard runtime walls (each documented in the guide). The main barrel references neither React, RTL, Playwright, nor the RSC runtime, so a node-only unit suite pulls in none of them.
@rangojs/router/testingrunLoader,runMiddleware,dispatch,runInRequestContext,runWithRequestContext,createTestRequestContext,collectHandle,assertCacheStatus,parseCacheHeader,createCacheSink,filterCacheDecisions,diffGeneratedRoutes,assertGeneratedRoutesMatch,toRequest,seedVariables@rangojs/router/testing/vitestrangoTestConfig,rangoTestAliases,rangoInlineDeps,rangoUseClientTransform@rangojs/router/testing/domrenderRoute@rangojs/router/testing/e2ecreateRangoE2E({ test, expect })→useFixture,parityDescribe,expectParity, matchers, page helpers@rangojs/router/testing/flight--conditions=react-serverrenderToFlightString,renderServerTree,findClientBoundaries,findElements,textContent,renderHandler,normalizeFlight,assertFlightRuntimeAvailable,assertFlightTreeRuntimeAvailable@rangojs/router/testing/flight-matchers--conditions=react-serverflightMatchers(toMatchFlight,toMatchFlightSnapshot)Setup is now one line (the preset)
rangoTestConfig()returns the completetest-block fragment: the resolve aliases and theserver.deps.inlinecontract (omitting the latter loads rango's TS source through Node and breaks on Node ≥ 23). It aliases the bare@rangojs/routerto its real (react-server) entry while leaving React as the client build (socreateContextand"use client"modules still work), and stubs the build-only@rangojs/router:version,@vitejs/plugin-rsc/rsc, and (preset"cloudflare")cloudflare:workers/cloudflare:emailvirtuals:The
react-serverproject (for*.rsc-test.tsx—renderToFlightString/renderServerTree/renderHandler) needs the same alias plus the condition and the client transform. The alias is load-bearing, not redundant: without it a handler/component importinggetRequestContext()/cookies()from the bare specifier resolves to the throwing out-of-react-serverstub (Vite doesn't reliably apply the condition to bare-package export resolution):One name for "the request a primitive runs under":
opts.requestBREAKING (experimental/preview only). The request argument previously had four spellings (positional
request,opts.request,opts.url,opts.initialUrl). It is nowopts.requesteverywhere — aRequest, or a URL string. All ~20 in-repo call sites were migrated.dispatchdispatch(router, request, { env })dispatch(router, { request, env })runMiddlewarerunMiddleware(mw, request, opts)runMiddleware(mw, { request, ...opts })renderToFlightStringrenderToFlightString(el, { url })renderToFlightString(el, { request })renderServerTreerenderServerTree(el, { url })renderServerTree(el, { request })renderRouterenderRoute(routes, { initialUrl })renderRoute(routes, { request })renderHandler/runLoader/runInRequestContextopts.requestA string request gets
opts.headersapplied; aRequestcarries its own headers. ForrenderRoutethe request is the client initial location (only its URL is read).What a consumer can now test (before / after)
A loader's data logic —
createLoader(fn)needs the Vite-injected$$id, so before this you hand-mocked aLoaderContextand lost realctxsemantics, or extracted+exported the raw body. NowrunLoaderaccepts the registeredcreateLoader()handle directly and runs it against a realRequestContext(cookies/headers/ctx.get/ctx.reverseresolve):A loader that awaits the render barrier then reads a handle (
await ctx.rendered(); ctx.use(H)) — previously un-unit-testable (ctx.rendered()threw). Now opt-in:Middleware ordering / short-circuit / cookie + header / flash —
runMiddlewareruns the chain against a realRequestContextand returns plain assertable views:A response route / redirect / 404 / middleware short-circuit —
dispatchruns the real response-route guard stack (no RSC render) and returns aResponse. The consumer's real router needs no cast (dispatchaccepts the publicRangotype):An action's set-cookie-then-redirect (server-action path) — the dominant case is an action that sets cookies/flash then
throw redirect()on success. Before, that output was only reachable through the@internalctx.res/ctx.cookies().runInRequestContextenters a real request context and snapshots effects whetherfnreturns or throws:A real route handler
(ctx) => rsc(what you pass topath("/p/:slug", ProductPage)— a function, not a component) — previously only reachable via e2e, because you couldn't construct theHandlerContextthe router builds.renderHandlerbuilds the real one (params / reverse /ctx.get/ctx.header/cookies()/setLocationState), seedsctx.use(loader)by reference and recordsctx.use(Handle)(...)pushes, then serializes the returned RSC and deserializes it to an inspectable tree:A real async Server Component / Flight output — before, only
renderToFlightString→ assert on the wire string (brittleJSON.stringify(tree).toContain(...), no typed props, can't tell inlined from island).renderServerTreeserializes to identical Flight bytes then deserializes back to a React element tree, so prop types survive the client boundary and islands keep their identity:findClientBoundaries(tree, selector)andfindElements(tree, selector)take a name/tag string or a selector object (name/testId/propssubset deep-equal /wherepredicate, AND-ed).textContent(node)concatenates a subtree's text leaves. WithrangoUseClientTransform()wired, client islands are auto-discovered from the tree's own imports —renderServerTree(<Page/>)needs noclientComponentslist. Server components don't survive Flight as identities (they execute during serialization), so you match the host elements they rendered; client islands keep identity viafindClientBoundaries.A component reading router context —
renderRoute, seeding by REFERENCE (a realcreateLoader/createLocationState/createHandlehandle has an empty$$idin a bare test, so seeding by id would collide).mountmodels aninclude('/shop', …)subtree;basenameis supported:A handle's
collect/accumulator (thecreateHandle(collect)mapper) — internal, so previously only e2e.collectHandleruns the REAL registered collect:ctx.reversesemantics (per helper, matching production)reverseis built from the route map ALONE for middleware/response-route handlers (no auto-fill of omitted:params), but WITH the current route name + params for loaders and route handlers (auto-filling omitted params from the current match is production behavior). The helpers mirror this exactly:reversebuilt fromrunMiddleware(chain + returnedctx)dispatchrunLoaderrenderHandlerrenderRoute(useReverse)useParams(); map is caller-supplied)runLoader'sctx.reversethrows when norouteMapis given (it does NOT fall back to the global route map, which would leak another test's routes).Layer / coverage contract
runLoaderrunMiddlewaredispatch(no RSC render)runInRequestContextrenderRoutecollectHandle(ctx)=>rsc+ its rendered RSC + effectsrenderHandlerrenderServerTree+findClientBoundaries/findElements/textContentrenderToFlightString+flightMatchersassertGeneratedRoutesMatchassertCacheStatuscache()actually caches, location state flows, PE paritycreateRangoE2E/parityDescribe/assertCacheStatusThe boundary is deliberate: the primitives test the pieces (loader body, middleware fn, handler render, seeded reads, a collect fn); the server-side wiring is e2e — except the rendered-barrier loader compute path, which
runLoader'srendered/handlesoptions make unit-testable.Hardening — what dogfooding + adversarial review caught and fixed
Wave 1: consumer ergonomics (each app has a
test/FINDINGS.md)dispatchsetup (vi.mock+import router) doesn't work for a real apprangoTestAliases/rangoTestConfigpresetdispatch(router)neededas any(publicRangotype lackedfindMatch/previewMatch/middleware)dispatchaccepts the publicRangotypevars: [[k,v]]didn't infer; docs' own examples wouldn't compileRecordor tuplesRunMiddlewareResult.ctxunion lacked.cookies()RequestContextthe chain ran underrunLoaderctx.reverse/ctx.getbound to global routes/vars, not the passed optionsTestLoaderContextassertGeneratedRoutesMatch(router, …)false-flagged everyinclude()d route as missingrenderRoute+useLoader/useLocationState/useHandlebroke for a real handle (empty$$id)createHandle/createLoader/Prerender/Staticassign a process-stable runtime fallback id under a test runner (a real build still throws for an un-injected shape — see Wave 4)collectwas unreachable in a bare testcollectHandle+ the fallback idWave 2: production-parity bugs (each with a regression test that fails without the fix)
renderToFlightStringhung (never rejected) when a Server Component threwonErrorreturns void)assertGeneratedRoutesMatchreported auto-generated$path_/$prefix_names asextraisAutoGeneratedRouteNamefrom theextradiffdispatchshort-circuited a_rsc_partialtoX-RSC-Reloadbefore global middleware (auth gate bypassed)runLoader/runMiddlewarestoredbasenameun-normalized → aLocationthe real app never emitsnormalizeBasenameextracted fromcreateRouterrunLoaderreturned seededctx.use(handle)data WITHOUTawait ctx.rendered()(production throws)use cachein a loader/middleware silently bypassed (taint/profile guards inert)cacheStore/cacheProfilesoptions forwarded tocreateRequestContextcache-statusdocs/examples keyed by URL pattern butX-Rango-Cachekeys by route namerouter-options.tsto the route-name keyrenderRoutehad nobasenameoptionbasenamewired intoNavigationProvidercollectHandlepassed empty per-segment arrays through (production drops them)Wave 3: installed-consumer, RSC-config, and API-consistency fixes
import { rangoTestAliases }invitest.config.tsthrewERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING(Node refuses to type-strip a.tsundernode_modules)./testing/vitestas compiled JS (dist/testing/vitest.js); addrangoTestConfig()so theserver.deps.inlinehalf can't be forgottencreateTestRequestContext's JSDoc told consumers to enter viarunWithRequestContext(), which no public entry exportedrunWithRequestContext; addrunInRequestContext(fn, opts)(build + enter in one call)react-server)getRequestContext()/cookies()from the bare specifier hit the throwing stub (renderHandler→tree: undefined);resolve.conditionsalone wasn't enoughreact-serverVitest project must ALSO alias bare@rangojs/router → index.rsc.tsviarangoTestAliases(React untouched); load-bearing (removing it fails the dogfood test)runLoaderergonomicsrunLoaderaccepts a registeredcreateLoader()handle directlyrangoTestAliases({ cloudflare: true })— a boolean that couldn't grow{ preset: "node" | "cloudflare" }mirrorsrango({ preset }); pre-stable rename, no deprecated aliasopts.request(see contract table)ctx.header(...)) only reachable viaresponse.headers; a middleware's flash not surfacedheadersadded torunInRequestContext/runMiddleware/renderHandlerresults;locationStateadded torunMiddlewareWave 4: bare-T
dispatch, whole-router import, and loud-fail traps (later review rounds)dispatchJSON contractdispatch(added on this branch) still emitted the old{ data }/{ error }envelope and imported a renamed-away helperJSON.stringify(result)(200,application/json);createProblemDetails(...)(application/problem+json) on error, resolving the effectivectx.setStatus()status before building the body; a handler-returnedResponseis re-wrapped byte-identically (WebSocket-upgrade bypass,statusTextdropped, Set-Cookie preserved)Prerender()/Static()threw "missing$$id" at construction in a bare test, sodispatch/assertGeneratedRoutesMatchagainst it were impossible without the pluginPrerender/Static) lets it construct; importing the real whole router file may still need the plugin for app page-module deps /virtual:modules — not the handler$$idrenderHandleralias trapgetRequestContext()/cookies()without the rsc-project alias was swallowed as an opaquetree: undefined+ a bare stub throwRenderHandlerSetupErrorpointing atrangoTestAliases$$idfallback gated onNODE_ENV === "development"fired in a realvite buildtoo, silently masking an unsupported shape with a synthetic id; the gate also shipped a bareprocess.envaccess + the rich stack-parsing diagnostic into prod chunksprocess.env.VITESTvia a runtime-safeglobalThis.process?.env?.VITESThelper; throw in dev AND production; keep the rich diagnostic behind the build-foldedNODE_ENVbranch (DCE'd, small throw ships) — verified by arangojs-e2e-minibuild + chunk grepConsidered and dropped: a
renderRoutemount-prefix guard — production simply prefixes include-relative patterns (/shop+/shop→/shop/shop), so a path that starts with the mount is a legitimate shape, not an error.Investigated and rejected as a non-bug: a claim that
toMatchFlight("$x")silently fails on leading-$content — Flight doubles the leading$to$$x, andincludes("$x")matches that substring (verified by probe).Wave 5: full-PR adversarial review —
dispatchtopology, Flight-tree fidelity, harness failure pathsA 4-agent review of the whole PR (node tier / Flight tier / harness+dogfood / docs, each finding verified empirically against production source) followed by a fix pass. Every code fix has a regression test verified to fail against the pre-fix code.
dispatchreturned the trailing-slash 308 and the 404 before global middleware ran — an auth gate's 401 never fired on those paths; a middleware 3xx on a partial came back raw;ctx.onResponse()callbacks were dropped on middleware short-circuitscoreHandlerthat produces the 308/404 viacreateResponseWithMergedHeaders(middleware cookies/headers merge onto them), partial/action redirects go through the realinterceptRedirectForPartial, and a terminalfinalizeResponsedrainsonResponseon every exitfindElements/textContentsilently returned""/ missedtextmatches for async Server Components below nested host elements, andtextContentwas non-idempotent — the vendored RSC client mutates a chunkresolved_model→fulfilledon first read, and the walker skippedfulfilledchunksresolveServerLazyacceptsfulfilledchunks; idempotency and the nested-async repro pinnedrenderHandlerdropped a returnedResponse's body (rewrapped asnew Response(null)); a function-form handle push (ctx.use(Meta)(() => …)) recorded[Function]instead of the evaluated valuerunLoader: an UNSEEDED handle read after the mocked render barrier threw a misleading "used outside of handler context" (or silently leaked into the loaders-onlyuseresolver)collect([]), matching productioncleanupassigned only after readiness); a failedpnpm buildresolved silently and served the previous staledist/— green production tests against old codecleanupassigned at spawn; the build step rejects loudly with its exit code;modeis now required and validated eagerly (breaking for preview harness users){ url }/{ initialUrl }options were silently ignored (plain JS, or a spread defeating TS excess-property checks) — a stale @Preview call site rendered againsthttp://localhost/requestexpect.extend(flightMatchers)in a setupFiles shared with a non-react-serverproject threw a React-internals error (the matchers transitively imported the react-server-only serializer)normalizeFlighthoisted to a serializer-free module — the matchers import anywhere; usage context stays RSC testsassertCacheStatusrecipes passed a PlaywrightAPIResponsedirectly (doesn't typecheck — needs{ headers: new Headers(res.headers()) }), theRouteParams<pattern>form, the lifted$$id-limitation sentence, a falserouteNamescoped-reverseclaimdispatchsemantics, before vs. after (after = production behavior):next()cookies/headers merge onto the 308?_rsc_partial/?_rsc_actionX-RSC-Redirectvia the realinterceptRedirectForPartialctx.onResponse()after a middleware short-circuitfinalizeResponse)X-RSC-Redirect; the Flight-embedded location state is absent — documented residual (dispatch has no Flight runtime)Submit-parity e2e (closes former Residual 1): the test-app gains a session-scoped PE counter (
/parity-counter— a server-action<form>increments a cookie-backed count) andconsumer-testing-harness.test.tsexercisesexpectParity({ submit })in dev + production. A negative probe proved the no-JS leg genuinely runs: with a non-session-scoped global counter, the parity assertion fails under double execution (JS context observes 1, the fresh no-JS context 2). The submit-intent requirements (mutated state must be session-scoped; the cookie check compares whole jars) are now documented in theexpectParityJSDoc and the e2e-parity skill.Wave 6: main's #560/#534 in the testing surface — assertable cache invalidation
Merged
main(#560 rango state session cookie +invalidateClientCache()/keepClientCache(), #534 cache-tag invalidationcacheTag/updateTag/revalidateTag, #558 CFCacheStore hardening) into the branch, then closed the gap those features opened: their consumer-observable effects were either untestable or broke an existing primitive.invalidateClientCache()in a handler/action/loader silently no-opped under the primitives — production always resolves a state-cookie name and rotates it, but the test stub omitted the name (_rotateStateCookieearly-returns without one), so a consumer could "assert" an action's invalidation and prove nothingrango-state_router_0) so the call rotates and emits theSet-Cookieexactly as in production; a newopts.stateCookie({ prefix?, routerId?, version? }) matches a customcreateRouter({ stateCookiePrefix, id });renderHandler/runInRequestContextreturn the resolvedstateCookieNameso you assert without recomputing itexpectParity— it compares the wholedocument.cookiejar across JS and no-JS, but that cookie is written/rotated only by the client runtime, so it lives in the JS jar and never the no-JS one (twoconsumer-testing-harnessparity tests went red after the merge)expectParityexcludes the rango state cookie (default prefix) automatically, gains anignoreCookies?: ReadonlyArray<string | RegExp>escape hatch (custom prefix / other JS-only cookies such as analytics/CSRF), and sorts the jar so cookie ORDER no longer false-mismatchesupdateTag()) had no shown unit pathMemorySegmentCacheStore(which implementsinvalidateTags) seeded viacacheStore—await updateTag("products")evicts the tagged entry (read-your-own-writes); CFCacheStore + KV is only needed for the distributed cross-colo purge, which stays e2ekeepClientCache()already set its directive header and needed no seeding; it is now documented and tested. Before / after — testing an action that invalidates the client cache:Client-cache directive semantics under the primitives (
renderHandler/runInRequestContext/runLoader/runMiddleware):invalidateClientCache()Set-Cookieonresult.response(idempotent within a request); name =result.stateCookieName({prefix}_{routerId}, value{version}:{timestamp})invalidateClientCache()called N timesSet-Cookie(idempotent)Set-Cookie(seeding the name alone never rotates)keepClientCache()x-rango-keep-cache: "1"onresult.headers(idempotent); no cookiekeepClientCache()theninvalidateClientCache()Set-Cookie(independent server-side effects)await updateTag("t")with acacheStorecacheStoreentry tagged"t"is evicted; differently-tagged entries remainDogfood (through the public entry):
cloudflare-basicgainstest/client-cache.test.ts(reallogoutAction→invalidateClientCache()+ session clear;dismissBannerAction→keepClientCache()+ cookie persist) andtest/cache-tags.test.ts(publishProductAction→updateTag('products')evicts a taggedMemorySegmentCacheStoreentry).StateCookieSeedis exported from@rangojs/router/testingand/testing/flight. Naming note:stateCookie's fields stayprefix/routerId(notcreateRouter'sstateCookiePrefix/id) — nested understateCookiethe short names read cleaner and avoid a redundantstateCookie.stateCookiePrefix; the mapping is in the JSDoc.Code-change summary
src/testing/**): the six entries + primitives +__tests__; node —run-loader,run-middleware,dispatch,internal/context(createTestRequestContext/runInRequestContext),collect-handle,cache-status,generated-routes,render-route; RSC —flight(renderToFlightString),flight-tree(renderServerTree/findClientBoundaries/findElements/textContent),render-handler; config —vitest.ts(rangoTestConfig/rangoTestAliases/rangoUseClientTransform) shipped asdist/testing/vitest.js, plusvitest-stubs/; e2e harness undere2e/.internal/seed-vars.ts: areact-server-safeVarsInit/seedVariables(dependency-free), shared by the node tier and the Flight tier so a Server Component readinggetRequestContext().get(MyVar)during render is testable.internal/seed-vars.tsaddsStateCookieSeed+resolveSeededStateCookieName(react-server-safe, via the env-agnosticrouter/state-cookie-name.ts);createTestRequestContext/renderHandleralways seed a resolved state-cookie name and threadopts.stateCookiethroughrunLoader/runMiddleware/runInRequestContext;renderHandler/runInRequestContextreturnstateCookieName.e2e/parity.tsexcludes the rango state cookie from the jar comparison and addsignoreCookies(importing the env-agnosticDEFAULT_STATE_COOKIE_PREFIX, so the e2e entry stays plain-Playwright-loadable).$$id, gated to a test runner:handle.ts/loader.ts/loader.rsc.ts/prerender.ts/static-handler.tsassign a process-stable fallback$$idwhen the Vite plugin injected none — only under a test runner (process.env.VITEST, probed runtime-safely throughglobalThis) — souseHandle/collectHandle/renderRoute/runLoaderexercise the REAL fn AND a whole-app router usingPrerender()/createLoader()/Static()constructs in a bare test (fordispatch/assertGeneratedRoutesMatch). Outside a test runner (dev OR a real build) a missing id throws for the genuinely-unsupported handler shape the plugin skips (export let, inline, non-exported) — restoring the loud build-time safety net. The rich stack-parsing diagnostic stays behind the build-foldedNODE_ENVbranch (DCE'd in production; a small throw ships). Red-teamed (Prerender 0/3 refuted; Static go-with-fix); production behavior verified by a mini build-chunk inspection.react-serverFlight machinery: a Proxy client manifest resolves registered client references synchronously;rangoUseClientTransformmirrors plugin-rsc's"use client"transform so islands are auto-discovered; a__webpack_require__globals shim stands in for the plugin transform that doesn't run in a bare test. Deserialize-only — never executes a client ref.dispatch(public router type),run-loader/run-middleware/internal/context(vars,TestLoaderContext,ctx: RequestContext),generated-routes(include expansion + auto-name filter),render-route(loaders/locationState/handles/mount/basename), sharednormalizeBasename.tests/cloudflare-basic,packages/rangojs-router/e2e/mini,tests/vite-rsc-demoeach get node +react-servervitest configs (preset),tsconfig.test.json, and real suites across the entries — including built-inMeta/Breadcrumbscollect coverage (title templates, absolute, unset, merge, JSON-LD single + multi).unit-testsrunspnpm run test:unitandpnpm run test:unit:rsc(both recursive → router + every consumer app); added atest:unit:allshorthand.AGENTS.mdupdated to require all packages' unit suites.docs/testing.md;skills/testing/SKILL.mdsplit into a lean index + 14 per-primitive sub-files (each: Layer/Import/DSL header, Options table, Context table — the seededctxyour code receives, Returns, recipe, Caveats);docs/README.md,feature-map.md,feature-file-map.md,/rangoskill table;public-consumer-imports+installed-consumer-importsguards lock all six testing subpaths;installed-vitest-configguard reproduces a true installed (under-node_modules) consumer.Test plan
pnpm run typecheck— clean (14 packages)pnpm run test:unit:all(recursive node +react-server, all packages) — green:dispatchbare-T + problem+json + the middleware-topology parity tests, returned-Responserewrap, the$$id-fallback / VITEST-gate guard tests, the no-react-serverflight-matchers import test) + RSC 45 (flight 8 / render-handler 19 / flight-tree 18)pnpm run lint(oxlint) /pnpm run format(oxfmt) — clean$$idfallback fires only under a test runner (VITEST) and a real build throws for an un-injected shape (verified by arangojs-e2e-miniproduction-build chunk inspection: runtime-safeglobalThischeck, rich diagnostic DCE'd)pnpm check:e2e-bucketing— OKe2e/consumer-testing-harness.test.ts) — dev + production greenexpectParitysubmit path — runtime-exercised in dev + production via the test-app's session-scoped PE counter form; the no-JS leg proven by a negative probe (see Wave 5)invalidateClientCache/keepClientCacheassertions inrender-handler.rsc-test.tsx(rotation, idempotency, custom seed, keep, keep+invalidate, Response-while-invalidating) andrun-in-request-context.test.ts(action rotation/keep, https Secure, redirect-merge,stateCookieName); cloudflare-basic dogfoodclient-cache.test.ts+cache-tags.test.ts. The twoconsumer-testing-harnessparity tests that regressed on the feat(router): rango state session cookie + invalidateClientCache/keepClientCache #560 merge now pass in dev + production (verified locally and in CI)Residuals
renderServer(the in-process way to test the real loader/handle/cache wiring without a browser) remains deferred — thereact-servervs default condition wall needs a two-environment setup, and in-process hydration would re-test React more than the app (the only hydration bug worth a test, server/client divergence, needs a real browser). Interaction stays at the e2e tier.createHandle/createLoaderfallback-id approach tocreateLocationStatesorenderRoutecan drop its synthetic-id step.