Skip to content

feat(testing): consumer testing primitives (@rangojs/router/testing) - #533

Merged
ivogt merged 42 commits into
mainfrom
feat/consumer-testing-primitives
Jun 13, 2026
Merged

ivogt merged 42 commits into
mainfrom
feat/consumer-testing-primitives

Conversation

@ivogt

@ivogt ivogt commented Jun 5, 2026 •

Copy link
Copy Markdown
Collaborator

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) and renderServerTree + tree finders (inspect real Flight output, not a wire string) — and the request argument has been unified on opts.request across every primitive. It was also brought onto main's bare-T + RFC 9457 response contract, gained whole-app-router import in bare tests (the $$id fallback now covers Prerender/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 fixed dispatch'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/router has 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/router resolves to server-only stubs (urls()/createRouter()/cookies() throw) outside the react-server condition, 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.

Import root Runtime Exports
@rangojs/router/testing Vitest (node/jsdom) runLoader, runMiddleware, dispatch, runInRequestContext, runWithRequestContext, createTestRequestContext, collectHandle, assertCacheStatus, parseCacheHeader, createCacheSink, filterCacheDecisions, diffGeneratedRoutes, assertGeneratedRoutesMatch, toRequest, seedVariables
@rangojs/router/testing/vitest Vitest config (shipped as compiled JS) rangoTestConfig, rangoTestAliases, rangoInlineDeps, rangoUseClientTransform
@rangojs/router/testing/dom Vitest + DOM + RTL renderRoute
@rangojs/router/testing/e2e Playwright createRangoE2E({ test, expect }) → useFixture, parityDescribe, expectParity, matchers, page helpers
@rangojs/router/testing/flight Vitest --conditions=react-server renderToFlightString, renderServerTree, findClientBoundaries, findElements, textContent, renderHandler, normalizeFlight, assertFlightRuntimeAvailable, assertFlightTreeRuntimeAvailable
@rangojs/router/testing/flight-matchers Vitest --conditions=react-server flightMatchers (toMatchFlight, toMatchFlightSnapshot)

Setup is now one line (the preset)

rangoTestConfig() returns the complete test-block fragment: the resolve aliases and the server.deps.inline contract (omitting the latter loads rango's TS source through Node and breaks on Node ≥ 23). It aliases the bare @rangojs/router to its real (react-server) entry while leaving React as the client build (so createContext and "use client" modules still work), and stubs the build-only @rangojs/router:version, @vitejs/plugin-rsc/rsc, and (preset "cloudflare") cloudflare:workers/cloudflare:email virtuals:

// vitest.config.ts  (node / DOM unit + integration tests)
import { defineConfig } from "vitest/config";
import { rangoTestConfig } from "@rangojs/router/testing/vitest";

export default defineConfig({
  test: {
    globals: true,
    include: ["test/**/*.test.{ts,tsx}"],
    environment: "node",
    ...rangoTestConfig({ preset: "cloudflare" }), // omit preset / "node" for non-CF apps
  },
});

The react-server project (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 importing getRequestContext()/cookies() from the bare specifier resolves to the throwing out-of-react-server stub (Vite doesn't reliably apply the condition to bare-package export resolution):

// vitest.rsc.config.ts
import { defineConfig } from "vitest/config";
import { rangoTestAliases, rangoUseClientTransform } from "@rangojs/router/testing/vitest";

export default defineConfig({
  plugins: [rangoUseClientTransform()], // auto-discovers "use client" islands from the server tree's imports
  resolve: { conditions: ["react-server"], alias: rangoTestAliases({ preset: "cloudflare" }) },
  test: { include: ["test/**/*.rsc-test.{ts,tsx}"], server: { deps: { inline: [/@rangojs\/router/] } } },
});

One name for "the request a primitive runs under": opts.request

BREAKING (experimental/preview only). The request argument previously had four spellings (positional request, opts.request, opts.url, opts.initialUrl). It is now opts.request everywhere — a Request, or a URL string. All ~20 in-repo call sites were migrated.

Primitive Before After
dispatch dispatch(router, request, { env }) dispatch(router, { request, env })
runMiddleware runMiddleware(mw, request, opts) runMiddleware(mw, { request, ...opts })
renderToFlightString renderToFlightString(el, { url }) renderToFlightString(el, { request })
renderServerTree renderServerTree(el, { url }) renderServerTree(el, { request })
renderRoute renderRoute(routes, { initialUrl }) renderRoute(routes, { request })
renderHandler / runLoader / runInRequestContext already opts.request unchanged

A string request gets opts.headers applied; a Request carries its own headers. For renderRoute the 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 a LoaderContext and lost real ctx semantics, or extracted+exported the raw body. Now runLoader accepts the registered createLoader() handle directly and runs it against a real RequestContext (cookies/headers/ctx.get/ctx.reverse resolve):

const data = await runLoader(ProductLoader, {        // the real handle, no body extraction
  params: { id: "42" },
  env: { DB: { get: async () => ({ name: "Widget" }) } },
  vars: { user: { id: 1 } },
  loaders: [[CartLoader, { count: 3 }]],             // seed ctx.use(CartLoader) by reference
  routeMap: { product: "/products/:id" },
});
expect(data.self).toBe("/products/42");

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:

const data = await runLoader(LivePricesLoader, {
  rendered: true,                                          // mock the render barrier
  handles: [[RenderedProducts, ["widget-a", "widget-b"]]], // seed the POST-collect value ctx.use(H) reads
});

Middleware ordering / short-circuit / cookie + header / flash — runMiddleware runs the chain against a real RequestContext and returns plain assertable views:

const { response, nextCalled, cookies, headers, locationState } = await runMiddleware(authGate, {
  request: "/dashboard",
  env,
});
expect(nextCalled).toBe(0);                 // short-circuited
expect(response.status).toBe(302);
expect(headers["x-frame-options"]).toBe("DENY");

A response route / redirect / 404 / middleware short-circuit — dispatch runs the real response-route guard stack (no RSC render) and returns a Response. The consumer's real router needs no cast (dispatch accepts the public Rango type):

const res = await dispatch(router, { request: "/legacy", env });
expect(res.status).toBe(308);
expect(res.headers.get("location")).toBe("/new");

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 @internal ctx.res/ctx.cookies(). runInRequestContext enters a real request context and snapshots effects whether fn returns or throws:

const { cookies, headers, thrown } = await runInRequestContext(async () => {
  cookies().set("session", token);          // standalone cookies() resolves under the entered ctx
  throw redirect("/dashboard");             // success path: set cookie, THEN redirect
}, { request: "/login", env });
expect(cookies.session).toBe(token);        // captured even though the action threw
expect(headers.location).toBe("/dashboard");
expect((thrown as Response).status).toBe(302);

A real route handler (ctx) => rsc (what you pass to path("/p/:slug", ProductPage) — a function, not a component) — previously only reachable via e2e, because you couldn't construct the HandlerContext the router builds. renderHandler builds the real one (params / reverse / ctx.get / ctx.header / cookies() / setLocationState), seeds ctx.use(loader) by reference and records ctx.use(Handle)(...) pushes, then serializes the returned RSC and deserializes it to an inspectable tree:

const { tree, cookies, handles, thrown } = await renderHandler(ProductPage, {
  request: "/products/42",
  params: { id: "42" },
  loaders: [[ProductLoader, { name: "Widget", price: 1999 }]], // seed ctx.use(loader), no real loader run
});
const [price] = findElements(tree, { testId: "price" });        // assert the rendered host element
expect(textContent(price)).toBe("$19.99");
expect(handles.get(Meta)).toEqual([{ title: "Widget" }]);       // ctx.use(Meta)({...}) captured
// a `throw redirect()` is captured on `thrown` (tree undefined); an unseeded ctx.use(loader) rejects with a clear setup error

A real async Server Component / Flight output — before, only renderToFlightString → assert on the wire string (brittle JSON.stringify(tree).toContain(...), no typed props, can't tell inlined from island). renderServerTree serializes to identical Flight bytes then deserializes back to a React element tree, so prop types survive the client boundary and islands keep their identity:

const { tree } = await renderServerTree(<ProductCard product={product} />);

// a client island located by the test id you passed it, not by component name
const [addToCart] = findClientBoundaries(tree, { testId: "add-to-cart" });
expect(addToCart.props.sku).toBe("WIDGET-42");
expect(addToCart.props.addedAt).toBeInstanceOf(Date);   // typed prop fidelity across the boundary

// the server-rendered HOST elements the Server Component produced (<article>, <h2>, ...)
const [title] = findElements(tree, { tag: "h2", testId: "title" });
expect(textContent(title)).toBe("Widget");

findClientBoundaries(tree, selector) and findElements(tree, selector) take a name/tag string or a selector object (name/testId/props subset deep-equal / where predicate, AND-ed). textContent(node) concatenates a subtree's text leaves. With rangoUseClientTransform() wired, client islands are auto-discovered from the tree's own imports — renderServerTree(<Page/>) needs no clientComponents list. Server components don't survive Flight as identities (they execute during serialization), so you match the host elements they rendered; client islands keep identity via findClientBoundaries.

A component reading router context — renderRoute, seeding by REFERENCE (a real createLoader/createLocationState/createHandle handle has an empty $$id in a bare test, so seeding by id would collide). mount models an include('/shop', …) subtree; basename is supported:

await renderRoute([{ path: "/cart", Component: CartBadge }], { loaders: [[CartLoader, cart]] });
await renderRoute([{ path: "/s", Component: FlashBanner }], { locationState: [[FlashMessage, { text: "Saved" }]] });
await renderRoute([{ path: "/p", Component: Trail }], { handles: [[Breadcrumbs, [{ label: "Home", href: "/" }]]] });

A handle's collect/accumulator (the createHandle(collect) mapper) — internal, so previously only e2e. collectHandle runs the REAL registered collect:

const PageTitle = createHandle<string, string>((s) => s.flat().at(-1) ?? "Home");
expect(collectHandle(PageTitle, [["Home"], ["Products"], ["Shoes"]])).toBe("Shoes");

ctx.reverse semantics (per helper, matching production)

reverse is 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:

Helper Production phase mirrored reverse built from Auto-fills current params?
runMiddleware (chain + returned ctx) app / response middleware route map only No
dispatch response-route handler route map only No
runLoader loader / segment handler route map + name + params Yes
renderHandler route handler (segment) route map + name + params Yes
renderRoute (useReverse) client the map passed to the hook Yes (current params via useParams(); map is caller-supplied)

runLoader's ctx.reverse throws when no routeMap is given (it does NOT fall back to the global route map, which would leak another test's routes).

Layer / coverage contract

You test… Layer Primitive
loader body data logic unit (node) runLoader
middleware ordering/short-circuit/cookie+header/flash unit (node) runMiddleware
response route / redirect / 404 / mw short-circuit integration (node) dispatch (no RSC render)
an action's cookie/flash/redirect effects unit (node) runInRequestContext
client component (params/loader/locationState/handle reads) unit (DOM) renderRoute
a handle's collect/accumulator unit (node) collectHandle
a real route handler (ctx)=>rsc + its rendered RSC + effects RSC unit renderHandler
real async Server Component / typed Flight props / islands RSC unit renderServerTree + findClientBoundaries / findElements / textContent
Flight wire string (snapshot/substring) RSC unit renderToFlightString + flightMatchers
generated-route drift (incl. includes) unit (node) assertGeneratedRoutesMatch
cache/prerender status header cross-cut assertCacheStatus
the real wiring — handlers actually push handles, routes attach loaders, cache() actually caches, location state flows, PE parity e2e createRangoE2E / parityDescribe / assertCacheStatus

The 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's rendered/handles options make unit-testable.

Hardening — what dogfooding + adversarial review caught and fixed

Wave 1: consumer ergonomics (each app has a test/FINDINGS.md)

Gap Fix
The documented dispatch setup (vi.mock + import router) doesn't work for a real app the rangoTestAliases/rangoTestConfig preset
dispatch(router) needed as any (public Rango type lacked findMatch/previewMatch/middleware) dispatch accepts the public Rango type
vars: [[k,v]] didn't infer; docs' own examples wouldn't compile accepts a Record or tuples
RunMiddlewareResult.ctx union lacked .cookies() typed as the RequestContext the chain ran under
runLoader ctx.reverse/ctx.get bound to global routes/vars, not the passed options relaxed TestLoaderContext
assertGeneratedRoutesMatch(router, …) false-flagged every include()d route as missing force-expands lazy includes before diffing
renderRoute + useLoader/useLocationState/useHandle broke for a real handle (empty $$id) seed by reference + synthetic ids; createHandle/createLoader/Prerender/Static assign a process-stable runtime fallback id under a test runner (a real build still throws for an un-injected shape — see Wave 4)
A handle's custom collect was unreachable in a bare test collectHandle + the fallback id

Wave 2: production-parity bugs (each with a regression test that fails without the fix)

Severity Gap (consumer-observable) Fix
High renderToFlightString hung (never rejected) when a Server Component threw capture the error, reject after draining (production onError returns void)
High assertGeneratedRoutesMatch reported auto-generated $path_/$prefix_ names as extra filter isAutoGeneratedRouteName from the extra diff
High dispatch short-circuited a _rsc_partial to X-RSC-Reload before global middleware (auth gate bypassed) run global middleware first; reload is the terminal handler
Medium runLoader/runMiddleware stored basename un-normalized → a Location the real app never emits normalize via a shared normalizeBasename extracted from createRouter
Medium runLoader returned seeded ctx.use(handle) data WITHOUT await ctx.rendered() (production throws) gate handle reads on the (mocked) render barrier
Medium use cache in a loader/middleware silently bypassed (taint/profile guards inert) added cacheStore/cacheProfiles options forwarded to createRequestContext
Medium cache-status docs/examples keyed by URL pattern but X-Rango-Cache keys by route name corrected examples/docstrings + router-options.ts to the route-name key
Low renderRoute had no basename option added a normalized basename wired into NavigationProvider
Low collectHandle passed empty per-segment arrays through (production drops them) filter empties before the collect

Wave 3: installed-consumer, RSC-config, and API-consistency fixes

Area Gap (consumer-observable) Fix
Installed consumer (Node ≥ 23) import { rangoTestAliases } in vitest.config.ts threw ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING (Node refuses to type-strip a .ts under node_modules) ship ./testing/vitest as compiled JS (dist/testing/vitest.js); add rangoTestConfig() so the server.deps.inline half can't be forgotten
Action-auth path createTestRequestContext's JSDoc told consumers to enter via runWithRequestContext(), which no public entry exported export runWithRequestContext; add runInRequestContext(fn, opts) (build + enter in one call)
RSC-config (react-server) a rendered handler/component importing getRequestContext()/cookies() from the bare specifier hit the throwing stub (renderHandler → tree: undefined); resolve.conditions alone wasn't enough the react-server Vitest project must ALSO alias bare @rangojs/router → index.rsc.ts via rangoTestAliases (React untouched); load-bearing (removing it fails the dogfood test)
runLoader ergonomics consumers had to extract+export a loader's raw body to test it runLoader accepts a registered createLoader() handle directly
Preset extensibility rangoTestAliases({ cloudflare: true }) — a boolean that couldn't grow { preset: "node" | "cloudflare" } mirrors rango({ preset }); pre-stable rename, no deprecated alias
API consistency the request argument had four spellings unified on opts.request (see contract table)
Result completeness a handler's headers (ctx.header(...)) only reachable via response.headers; a middleware's flash not surfaced headers added to runInRequestContext/runMiddleware/renderHandler results; locationState added to runMiddleware

Wave 4: bare-T dispatch, whole-router import, and loud-fail traps (later review rounds)

Area Gap (consumer-observable) Fix
dispatch JSON contract merged main (#539) changed response routes to a bare JSON value + RFC 9457 problem+json errors; dispatch (added on this branch) still emitted the old { data }/{ error } envelope and imported a renamed-away helper mirror the merged handler: bare JSON.stringify(result) (200, application/json); createProblemDetails(...) (application/problem+json) on error, resolving the effective ctx.setStatus() status before building the body; a handler-returned Response is re-wrapped byte-identically (WebSocket-upgrade bypass, statusText dropped, Set-Cookie preserved)
Whole-app router import a router using Prerender()/Static() threw "missing $$id" at construction in a bare test, so dispatch/assertGeneratedRoutesMatch against it were impossible without the plugin the test-runner-gated fallback id (Wave 1, extended to Prerender/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 $$id
renderHandler alias trap a handler reading getRequestContext()/cookies() without the rsc-project alias was swallowed as an opaque tree: undefined + a bare stub throw detect the server-only-stub throw and rethrow an actionable RenderHandlerSetupError pointing at rangoTestAliases
Production-inertness regression (review-caught) the $$id fallback gated on NODE_ENV === "development" fired in a real vite build too, silently masking an unsupported shape with a synthetic id; the gate also shipped a bare process.env access + the rich stack-parsing diagnostic into prod chunks gate on process.env.VITEST via a runtime-safe globalThis.process?.env?.VITEST helper; throw in dev AND production; keep the rich diagnostic behind the build-folded NODE_ENV branch (DCE'd, small throw ships) — verified by a rangojs-e2e-mini build + chunk grep

Considered and dropped: a renderRoute mount-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, and includes("$x") matches that substring (verified by probe).

Wave 5: full-PR adversarial review — dispatch topology, Flight-tree fidelity, harness failure paths

A 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.

Severity Gap (consumer-observable) Fix
High dispatch returned 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-circuits restructured to production topology: the global chain wraps a coreHandler that produces the 308/404 via createResponseWithMergedHeaders (middleware cookies/headers merge onto them), partial/action redirects go through the real interceptRedirectForPartial, and a terminal finalizeResponse drains onResponse on every exit
High findElements/textContent silently returned "" / missed text matches for async Server Components below nested host elements, and textContent was non-idempotent — the vendored RSC client mutates a chunk resolved_model → fulfilled on first read, and the walker skipped fulfilled chunks resolveServerLazy accepts fulfilled chunks; idempotency and the nested-async repro pinned
Medium renderHandler dropped a returned Response's body (rewrapped as new Response(null)); a function-form handle push (ctx.use(Meta)(() => …)) recorded [Function] instead of the evaluated value body preserved through the rewrap; function args evaluated exactly as production's push fn does
Medium runLoader: an UNSEEDED handle read after the mocked render barrier threw a misleading "used outside of handler context" (or silently leaked into the loaders-only use resolver) resolves via the registered collect over empty segments — collect([]), matching production
Medium harness fixture orphaned the spawned server when startup failed (cleanup assigned only after readiness); a failed pnpm build resolved silently and served the previous stale dist/ — green production tests against old code cleanup assigned at spawn; the build step rejects loudly with its exit code; mode is now required and validated eagerly (breaking for preview harness users)
Medium the pre-rename { url } / { initialUrl } options were silently ignored (plain JS, or a spread defeating TS excess-property checks) — a stale @Preview call site rendered against http://localhost/ explicit migration throws naming request
Low expect.extend(flightMatchers) in a setupFiles shared with a non-react-server project threw a React-internals error (the matchers transitively imported the react-server-only serializer) normalizeFlight hoisted to a serializer-free module — the matchers import anywhere; usage context stays RSC tests
Docs stale fragments from earlier API states: assertCacheStatus recipes passed a Playwright APIResponse directly (doesn't typecheck — needs { headers: new Headers(res.headers()) }), the RouteParams<pattern> form, the lifted $$id-limitation sentence, a false routeName scoped-reverse claim corrected against source; volatile FINDINGS.md test counts dropped

dispatch semantics, before vs. after (after = production behavior):

Path Before After
trailing-slash 308 returned pre-middleware global middleware wraps it: a 401 short-circuit wins; next() cookies/headers merge onto the 308
unmatched 404 returned pre-middleware global middleware wraps it: a 401 short-circuit wins
middleware 3xx on ?_rsc_partial / ?_rsc_action raw 302 (fetch would auto-follow) 204 + X-RSC-Redirect via the real interceptRedirectForPartial
ctx.onResponse() after a middleware short-circuit dropped fires (terminal finalizeResponse)
location-state-carrying partial redirect raw 302 204 + 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) and consumer-testing-harness.test.ts exercises expectParity({ 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 the expectParity JSDoc 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 invalidation cacheTag/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.

Gap (consumer-observable) Fix
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 (_rotateStateCookie early-returns without one), so a consumer could "assert" an action's invalidation and prove nothing the primitives now always seed a resolved name (default rango-state_router_0) so the call rotates and emits the Set-Cookie exactly as in production; a new opts.stateCookie ({ prefix?, routerId?, version? }) matches a custom createRouter({ stateCookiePrefix, id }); renderHandler/runInRequestContext return the resolved stateCookieName so you assert without recomputing it
the rango state cookie broke expectParity — it compares the whole document.cookie jar 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 (two consumer-testing-harness parity tests went red after the merge) expectParity excludes the rango state cookie (default prefix) automatically, gains an ignoreCookies?: 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-mismatches
server-side cache-tag invalidation (updateTag()) had no shown unit path it is unit-assertable in-process against a MemorySegmentCacheStore (which implements invalidateTags) seeded via cacheStore — await updateTag("products") evicts the tagged entry (read-your-own-writes); CFCacheStore + KV is only needed for the distributed cross-colo purge, which stays e2e

keepClientCache() 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:

// BEFORE: the call ran but rotated nothing (the stub had no cookie name) — green, proving nothing
const { response } = await runInRequestContext(() => logoutAction());
// response.headers.getSetCookie() === []   ← false confidence

// AFTER: the name is seeded, so the rotation Set-Cookie is real and assertable
const { response, stateCookieName } = await runInRequestContext(() => logoutAction());
expect(
  response.headers.getSetCookie().some((c) => c.startsWith(stateCookieName + "=")),
).toBe(true);

Client-cache directive semantics under the primitives (renderHandler / runInRequestContext / runLoader / runMiddleware):

Call (in a handler / action / loader) Observable under the primitives
invalidateClientCache() one rotation Set-Cookie on result.response (idempotent within a request); name = result.stateCookieName ({prefix}_{routerId}, value {version}:{timestamp})
invalidateClientCache() called N times still ONE Set-Cookie (idempotent)
neither directive NO rango-state Set-Cookie (seeding the name alone never rotates)
keepClientCache() x-rango-keep-cache: "1" on result.headers (idempotent); no cookie
keepClientCache() then invalidateClientCache() BOTH — the directive header AND the rotation Set-Cookie (independent server-side effects)
await updateTag("t") with a cacheStore every cacheStore entry tagged "t" is evicted; differently-tagged entries remain

Dogfood (through the public entry): cloudflare-basic gains test/client-cache.test.ts (real logoutAction → invalidateClientCache() + session clear; dismissBannerAction → keepClientCache() + cookie persist) and test/cache-tags.test.ts (publishProductAction → updateTag('products') evicts a tagged MemorySegmentCacheStore entry). StateCookieSeed is exported from @rangojs/router/testing and /testing/flight. Naming note: stateCookie's fields stay prefix/routerId (not createRouter's stateCookiePrefix/id) — nested under stateCookie the short names read cleaner and avoid a redundant stateCookie.stateCookiePrefix; the mapping is in the JSDoc.

Code-change summary

  • New (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 as dist/testing/vitest.js, plus vitest-stubs/; e2e harness under e2e/.
  • internal/seed-vars.ts: a react-server-safe VarsInit/seedVariables (dependency-free), shared by the node tier and the Flight tier so a Server Component reading getRequestContext().get(MyVar) during render is testable.
  • Cache-invalidation testability (Wave 6): internal/seed-vars.ts adds StateCookieSeed + resolveSeededStateCookieName (react-server-safe, via the env-agnostic router/state-cookie-name.ts); createTestRequestContext/renderHandler always seed a resolved state-cookie name and thread opts.stateCookie through runLoader/runMiddleware/runInRequestContext; renderHandler/runInRequestContext return stateCookieName. e2e/parity.ts excludes the rango state cookie from the jar comparison and adds ignoreCookies (importing the env-agnostic DEFAULT_STATE_COOKIE_PREFIX, so the e2e entry stays plain-Playwright-loadable).
  • Core — runtime fallback $$id, gated to a test runner: handle.ts / loader.ts / loader.rsc.ts / prerender.ts / static-handler.ts assign a process-stable fallback $$id when the Vite plugin injected none — only under a test runner (process.env.VITEST, probed runtime-safely through globalThis) — so useHandle/collectHandle/renderRoute/runLoader exercise the REAL fn AND a whole-app router using Prerender()/createLoader()/Static() constructs in a bare test (for dispatch/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-folded NODE_ENV branch (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-server Flight machinery: a Proxy client manifest resolves registered client references synchronously; rangoUseClientTransform mirrors 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.
  • Type/parity fixes: 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), shared normalizeBasename.
  • Dogfood apps: tests/cloudflare-basic, packages/rangojs-router/e2e/mini, tests/vite-rsc-demo each get node + react-server vitest configs (preset), tsconfig.test.json, and real suites across the entries — including built-in Meta/Breadcrumbs collect coverage (title templates, absolute, unset, merge, JSON-LD single + multi).
  • CI: unit-tests runs pnpm run test:unit and pnpm run test:unit:rsc (both recursive → router + every consumer app); added a test:unit:all shorthand. AGENTS.md updated to require all packages' unit suites.
  • Docs: docs/testing.md; skills/testing/SKILL.md split into a lean index + 14 per-primitive sub-files (each: Layer/Import/DSL header, Options table, Context table — the seeded ctx your code receives, Returns, recipe, Caveats); docs/README.md, feature-map.md, feature-file-map.md, /rango skill table; public-consumer-imports + installed-consumer-imports guards lock all six testing subpaths; installed-vitest-config guard 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:
    • router testing node (grown with Waves 4–5: dispatch bare-T + problem+json + the middleware-topology parity tests, returned-Response rewrap, the $$id-fallback / VITEST-gate guard tests, the no-react-server flight-matchers import test) + RSC 45 (flight 8 / render-handler 19 / flight-tree 18)
    • cloudflare-basic 42 node + 10 RSC (flight 3 / server-tree 7)
    • e2e/mini 16 node + 1 RSC
    • vite-rsc-demo 15 node + 1 RSC
    • includes every Wave-2/3/4/5 regression test (each verified to fail without its fix)
  • pnpm run lint (oxlint) / pnpm run format (oxfmt) — clean
  • Semantic matrix (dev + production) — unaffected; the $$id fallback fires only under a test runner (VITEST) and a real build throws for an un-injected shape (verified by a rangojs-e2e-mini production-build chunk inspection: runtime-safe globalThis check, rich diagnostic DCE'd)
  • pnpm check:e2e-bucketing — OK
  • Consumer e2e example (e2e/consumer-testing-harness.test.ts) — dev + production green
  • expectParity submit 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)
  • Cache-invalidation coverage (Wave 6) — router: +invalidateClientCache/keepClientCache assertions in render-handler.rsc-test.tsx (rotation, idempotency, custom seed, keep, keep+invalidate, Response-while-invalidating) and run-in-request-context.test.ts (action rotation/keep, https Secure, redirect-merge, stateCookieName); cloudflare-basic dogfood client-cache.test.ts + cache-tags.test.ts. The two consumer-testing-harness parity 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

  1. Interactive hydrated DOM renderServer (the in-process way to test the real loader/handle/cache wiring without a browser) remains deferred — the react-server vs 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.
  2. Optional: promote cache status from route-level to per-segment; apply the createHandle/createLoader fallback-id approach to createLocationState so renderRoute can drop its synthetic-id step.

ivogt added 6 commits June 5, 2026 06:57
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`.
@ivogt ivogt changed the title WIP: feat(testing): consumer testing primitives (@rangojs/router/testing) feat(testing): consumer testing primitives (@rangojs/router/testing) Jun 5, 2026
ivogt added 2 commits June 5, 2026 13:12
…-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.
@ivogt
ivogt force-pushed the feat/consumer-testing-primitives branch from 06cf7cd to 36f857b Compare June 5, 2026 10:18
@pkg-pr-new

pkg-pr-new Bot commented Jun 5, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@533

commit: 2f6142f

ivogt added 19 commits June 5, 2026 13:44
…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" }.
…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.
@ivogt
ivogt force-pushed the feat/consumer-testing-primitives branch from 97051ea to c0c107c Compare June 7, 2026 18:31
ivogt added a commit that referenced this pull request Jun 8, 2026
… 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.
ivogt added a commit that referenced this pull request Jun 9, 2026
…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.
ivogt added 5 commits June 9, 2026 08:07
…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.
Bring in main (#553 ctx.rendered() on streaming/loading() trees, #554 prefetch
warm-decode, release 0.0.0-experimental.117). Clean merge — no conflicts.

Gate: typecheck 12/12, test:unit:all EXIT=0 (router 3544 + 6 todo, 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).
@ivogt

ivogt commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up scope from #560 (rango state cookie + invalidateClientCache()/keepClientCache()), for when work here resumes — two intersections:

1. Two more export-condition APIs now hit the react-server aliasing requirement. invalidateClientCache() and keepClientCache() resolve via the react-server condition exactly like cookies() — so the rangoTestAliases rule (the react-server Vitest project must alias the bare @rangojs/router specifier, not just set resolve.conditions, or handlers under test silently get the wrong implementation) now covers them too. Concretely, the testing request-context stub needs the new members (_rotateStateCookie, _setKeepCacheDirective); a handler under renderHandler that calls invalidateClientCache() should rotate, not throw or no-op.

2. The new contracts want first-class assertions. renderHandler should expose response headers so a consumer test can assert:

  • a non-action handler emitted the rotation Set-Cookie (name {prefix}_{sanitizedRouterId}, value {version}:{timestamp}, idempotent — one header even if called twice);
  • an action set the keep directive (x-rango-keep-cache), and only within an action request.

keepClientCache() is deliberately an escape hatch for hot/no-op actions; consumer-side unit testability is how adopters gain confidence to reach for it, so these assertions are part of that API's story, not nice-to-haves.

Pre-existing note still open here: the branch carries the findClientBoundaries selector pass and the breaking opts.request unification unpublished — a fresh @preview from HEAD is the first step before dogfooding.

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.
ivogt added a commit that referenced this pull request Jun 13, 2026
…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.
@ivogt
ivogt force-pushed the feat/consumer-testing-primitives branch from 579ccde to 39dd803 Compare June 13, 2026 09:20
ivogt added 7 commits June 13, 2026 12:36
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant