Skip to content

feat(testing): #542 capability + coverage polish for @rangojs/router/testing - #581

Merged
ivogt merged 1 commit into
mainfrom
feat/testing-542-polish
Jun 14, 2026
Merged

ivogt merged 1 commit into
mainfrom
feat/testing-542-polish

Conversation

@ivogt

@ivogt ivogt commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator

Closes #542 (follow-up to #533, the consumer testing primitives).

Problem

@rangojs/router/testing shipped in #533 with the right abstractions, but four documented "false-confidence traps" still passed green while proving nothing, and the dual cache-telemetry surface had no one-call assert. From a consumer's seat:

  • A test that calls renderRoute(...).navigate(...) and asserts useNavigation().state === "loading" passed — but the lifecycle is frozen at idle, so it was asserting a constant.
  • A handler that calls a "use cache" function tested under renderHandler ran the uncached body (no cacheStore was reachable through the primitive at all) and the test went green, "covering" caching while exercising none of it.
  • An rsc-project test missing the rangoTestAliases alias surfaced the context-stub failure as an opaque tree: undefined with no hint about the fix.
  • renderRoute({ request }) with a typo'd or mount-prefixed path silently rendered empty params (matchLeaf -> null -> {}) instead of failing.

This PR turns each trap into a loud, actionable failure (test-runner-gated, zero production effect), closes the renderHandler cache-capability gap, and adds assertCacheDecision so the telemetry path has a one-call assert symmetric with assertCacheStatus.

What a consumer writes / sees differently

B — renderHandler can now exercise the cached path

Before, there was no way to reach the cached branch from renderHandler; the handler's "use cache" fn always ran uncached and the test silently proved nothing.

// BEFORE — runs UNCACHED, green but meaningless
const { tree } = await renderHandler(ProductPage, { params: { slug: "wine" } });

// AFTER — seed the store (+ profiles) and the real cached path runs
import { MemorySegmentCacheStore } from "@rangojs/router/testing";
const { tree } = await renderHandler(ProductPage, {
  params: { slug: "wine" },
  cacheStore: new MemorySegmentCacheStore(),
  cacheProfiles: { default: { ttl: 60 } }, // needed for "use cache: default"
});

If you forget to seed it, the runtime now tells you (once per cached-fn id, under the test runner only):

[rango] "use cache" function "ProductPage#0" executed but no cacheStore was seeded;
the cached path is NOT under test (it ran uncached). Pass { cacheStore, cacheProfiles }
to runLoader/runMiddleware/renderHandler/runInRequestContext (or configure
createRouter({ cache }) for dispatch) to exercise it.

G — one-call telemetry assert

import { createCacheSink, assertCacheDecision } from "@rangojs/router/testing";

const { sink, events } = createCacheSink();
const router = createRouter({ telemetry: sink }).routes(urlpatterns);
// ...drive an RSC request through router.fetch...

// BEFORE — hand-filter and dig into segment shape
const decision = filterCacheDecisions(events)[0];
expect(decision.segments?.find((s) => s.id === "product.detail")?.cacheStatus).toBe("hit");

// AFTER — one call, parallel to assertCacheStatus(res, routeKey, expected)
assertCacheDecision(events, "product.detail", "hit");

filterCacheDecisions stays for when you need the raw per-segment shouldRevalidate field.

A / C / D — traps now fail loud

// A: relying on a non-idle transition state — now warns once, naming the hooks
const { getByTestId } = await renderRoute(routes, { request: "/p/1" });
// console.warn: "...useNavigation().state, useLinkStatus().pending, and
// useAction().state stay 'idle' here. ...use renderServerTree or e2e to assert
// pending/loading/submitting transition states."

// D: a request that cannot resolve the leaf — now throws instead of empty params
await renderRoute([{ path: "/c/:slug", Component: Probe }], { request: "/typo/wine" });
// Error: renderRoute: request "/typo/wine" does not match the leaf route "/c/:slug".
//   renderRoute paths are include-RELATIVE ... A mount does NOT auto-rewrite the
//   request ... If the request URL intentionally does not carry the params, pass
//   them explicitly via the `params` option to bypass this check.

// C (rsc project): a context-reading handler with no rangoTestAliases — the
// Flight path now rethrows naming the fix instead of an opaque tree: undefined.
await renderToFlightString(<ReadsContext />); // throws, message names rangoTestAliases

Semantics

Scenario Before After
renderRoute(...).navigate() then read useNavigation().state silently idle idle + one-time warn naming the hooks and the e2e/renderServerTree alternative
renderHandler runs a "use cache" fn, no cacheStore uncached, no signal uncached + one-time warn (per fn id); unchanged if the test wants uncached
renderHandler runs a "use cache" fn, cacheStore seeded not possible cached path runs for real
renderToFlightString/renderServerTree, missing rangoTestAliases opaque tree: undefined rethrow naming rangoTestAliases (non-genuine errors pass through unchanged)
renderRoute({ request }) request does not resolve the leaf empty params, green throws with guidance
renderRoute({ request, params }) request does not resolve the leaf empty-then-overridden no throw — explicit params are the documented escape hatch
Any of the above off the test runner (production) n/a every guard is isUnderTestRunner()-gated → no warn, no throw, byte-identical

The last two rows are the review follow-up: the D guard merges options.params over the URL-extracted params in resolve(), and the params docstring blesses passing them "to avoid relying on URL parsing", so a test seeding params by hand must not be rejected. The guard fires only when no params are supplied.

Code-change summary

  • src/cache/cache-runtime.ts — in the if (!store?.getItem) uncached bypass, a isUnderTestRunner()-gated console.warn deduped via a module-level warnedUncachedUnderTest: Set<string> (once per fn id). Chosen here because this is the single seam every "use cache" invocation funnels through, regardless of which primitive drove it.
  • src/testing/render-handler.ts — RenderHandlerOptions gains cacheStore?: SegmentCacheStore + cacheProfiles?: Record<string, CacheProfile>, threaded into createRequestContext so the real cached path is reachable.
  • src/testing/flight.ts — extracted isServerOnlyStubError(error) (shared with renderHandler) and a private rethrowFlightRenderError; the serializeNodeToFlight rethrow seam now rethrows an actionable rangoTestAliases-naming message for the stub error, original error otherwise. Both renderToFlightString and renderServerTree route through it.
  • src/testing/render-route.tsx — A: one-time navigate() lifecycle warn + a fidelity-contract bullet. D: a leaf-match guard that throws on an unresolvable explicit request, skipped when explicit params are present (the documented escape hatch). Both isUnderTestRunner()-gated.
  • src/testing/cache-status.ts — new assertCacheDecision(events, routeKey, expected) export (the telemetry counterpart of assertCacheStatus); module header reframed as "pick by transport".
  • src/testing/index.ts, src/testing/e2e/index.ts — export assertCacheDecision.
  • E was already code-complete in feat(testing): consumer testing primitives (@rangojs/router/testing) #533 (the $$id runtime fallback); this PR promotes the include-relative dispatch/assertGeneratedRoutesMatch recipe to a canonical quickstart and reframes F (steer to renderServerTree; renderToFlightString is the wire-shape escape hatch).
  • Docs/skills: docs/testing.md, docs/README.md, docs/internal/feature-map.md + feature-file-map.md (new export), and the skills/testing/* set (SKILL.md, render-handler.md, cache-prerender.md, flight.md, server-tree.md, setup.md).

Test plan

  • Userland through the public primitives (the dogfood guarantee):
    • src/testing/__tests__/cache-status.test.ts — assertCacheDecision pass / mismatch / missing-routeKey (lists seen ids) / "(none)".
    • src/testing/__tests__/render-route.test.tsx (29 tests) — navigation frozen-idle warns once; request/leaf validation rejects a typo and a mount-prefixed request, accepts the relative form under a dynamic mount and an omitted :locale?; accepts a non-matching request when explicit params are supplied, still rejects when params: {}; inert with no request; silent off the runner (vi.stubEnv("VITEST","")).
    • src/testing/__tests__/flight.rsc-test.tsx / flight-tree.rsc-test.tsx — the stub error is reclassified (/rangoTestAliases/); a genuine error is NOT (non-vacuity).
    • src/testing/__tests__/render-handler.rsc-test.tsx — cacheStore/cacheProfiles forwarded into the request context.
    • src/__tests__/public-consumer-imports.test.ts — assertCacheDecision/filterCacheDecisions importable from the public barrel.
  • Internal pin — src/cache/__tests__/cache-runtime-stale.test.ts: the uncached-bypass warn fires once for a given fn id; the body still ran.
  • pnpm run typecheck — 12/12.
  • pnpm run test:unit:all (repo root, recursive) — EXIT 0 (router node 217 files + rsc 54 tests; all consumer apps green).
  • pnpm run lint — 0 warnings / 0 errors.
  • pnpm run format — clean.
  • N/A e2e (dev + production) — every new guard is test-harness-only and isUnderTestRunner()-gated, so it has no runtime behavior to exercise in a browser; coverage is the unit/rsc userland suites above.

Notes / call-outs

  • Production is untouched. Every warn/throw is gated on isUnderTestRunner() (process.env.VITEST, deliberately not folded like NODE_ENV). The B path is purely advisory — a test that intends to run uncached keeps working, just with one warn.
  • Why the warn lives in cache-runtime.ts, not in renderHandler: the !store?.getItem bypass is the one seam every "use cache" call passes through (runLoader, runMiddleware, renderHandler, runInRequestContext, and dispatch). The B test asserts the option THREADING via getRequestContext rather than importing registerCachedFunction, because @vitejs/plugin-rsc/rsc does not resolve in a bare vitest worker; the warn-once behavior is pinned separately in cache-runtime-stale.test.ts (which mocks the plugin).
  • Review follow-up folded in: the D guard initially rejected an unresolvable request even when explicit params were supplied, contradicting the params "avoid relying on URL parsing" contract. Fixed to skip when params are present; two tests pin both sides.
  • Deliberately deferred: the literal "validate the mount prefix against the routes" item — renderRoute has no route tree and an existing test forbids policing mount-vs-path, so the real silent failure (request-vs-leaf) is what's guarded instead.

…testing

Turn the documented false-confidence traps in the testing primitives into loud
signals, close the cache-telemetry ergonomic gap, and lead the whole-app docs
with the recipe that actually works. All runtime guards are gated on
isUnderTestRunner() so production is never affected.

A - renderRoute frozen-idle: navigate() commits synchronously and never drives
the navigation lifecycle, so useNavigation/useLinkStatus/useAction stay "idle".
Warn once (per render, test-runner-gated) and document it in the fidelity
contract, so a test asserting a non-idle state is no longer a silent tautology.

B - "use cache" without a seeded store: registerCachedFunction's uncached bypass
now warns once per fn id under the test runner so a green test that proves
nothing about caching is loud. renderHandler gains { cacheStore, cacheProfiles }
(forwarded to the request context) so a handler invoking a cached fn can exercise
the cached path - previously there was no way to seed a store via renderHandler.

C - Flight context-alias trap: renderToFlightString / renderServerTree now
self-diagnose the missing rangoTestAliases setup, rethrowing an actionable
message instead of the opaque out-of-react-server stub error (renderHandler
already did this). isServerOnlyStubError is extracted to flight.ts and shared.

D - renderRoute mount/request mismatch: an explicit request that cannot resolve
the leaf route (a typo, or the mount-prefixed-vs-relative confusion) now throws
with guidance instead of silently rendering empty params. renderRoute paths are
include-relative; the mount does not rewrite the request.

E - whole-app import: the "$$id" gap was already closed (Prerender/createLoader/
Static/createHandle synthesize a runtime fallback id under the test runner). The
residual is module resolution, so promote the focused-include dispatch /
assertGeneratedRoutesMatch recipe to a canonical quickstart in the docs.

F - steer to renderServerTree over renderToFlightString for assertions (typed
boundary props); renderToFlightString + wire matchers are the wire-shape escape
hatch. Docs/skills re-emphasis only; both surfaces stay.

G - cache-telemetry consolidation: add assertCacheDecision(events, routeKey,
expected) - the one-call telemetry-path counterpart of assertCacheStatus - plus
a "pick by transport" decision table. Neither path is removed: the header is the
only signal a black-box Response carries; the sink is the only zero-prod-surface
path and the only one exposing per-segment shouldRevalidate.

Tests (userland, through the public primitives): render-route warn + request/leaf
validation (incl. dynamic mount and the off-runner gate); cache-status
assertCacheDecision pass/mismatch/missing; flight + flight-tree rsc reclassify
(and a non-vacuity negative); render-handler cacheStore threading; an internal
warn-once pin in cache-runtime-stale; assertCacheDecision nameability in
public-consumer-imports. Internal feature-map/feature-file-map updated for the
new export.
@pkg-pr-new

pkg-pr-new Bot commented Jun 14, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 0c16a0d

@ivogt
ivogt merged commit eec0270 into main Jun 14, 2026
25 checks passed
@ivogt
ivogt deleted the feat/testing-542-polish branch June 14, 2026 12:24
ivogt added a commit that referenced this pull request Jun 14, 2026
…r flight/handler primitives (#582)

Two `@rangojs/router/testing` parity gaps from #582 (split from #572).

1. dispatch() json route lacked production's nested-Promise guard. Production
   (response-route-handler) serializes a json() result with a replacer that throws
   RESPONSE_NOT_SERIALIZABLE on a nested unresolved Promise (the forgotten-await
   footgun: `() => ({ data: fetchSomething() })`); dispatch did a bare
   JSON.stringify and shipped `{"data":{}}` green while production 500s. Extracted
   the replacer into a shared lightweight module (rsc/json-route-result.ts,
   depends only on errors.js) used by BOTH, so a dispatch json test fails exactly
   where production fails. dispatch's catch already maps the thrown RouterError to
   the same problem+json 500.

   The helper lives in its OWN module, not response-route-handler.ts: importing
   the latter into dispatch dragged its heavy runtime graph, which transitively
   reaches a Vite virtual module and breaks a plain (non-Vite) vitest import
   (caught: run-in-request-context.test.ts went red).

2. The testing primitives leaked ctx.reverse to the GLOBAL route map
   (order-dependent on whatever router registered last). Added a scoped `routeMap`
   option to renderToFlightString/renderServerTree (mirroring renderHandler),
   threaded through setRequestContextParams(params, routeName, routeMap?) --
   backward compatible (production callers omit it and keep getGlobalRouteMap()).
   renderHandler now also passes routeMap to setRequestContextParams, so a nested
   server component reading getRequestContext().reverse scopes to the same map as
   the handler's ctx.reverse -- all three primitives now scope reverse uniformly.

Tests (red-before-green): a dispatch json route returning a nested Promise -> a
problem+json 500 (not a green {}); ctx.reverse() scoped to a provided routeMap
under renderToFlightString, renderServerTree, AND a nested server component in
renderHandler (vs "Unknown route" against the empty global map). Docs: flight
skill routeMap option, feature-file-map new module.

Out of #582 this PR: item 8 (renderRoute synthetic-id stamping) is already
addressed by #581 via a stable WeakMap + a documented input-mutation contract;
item 9 (dispatch executionContext=undefined) is correct for an in-process harness
with no platform execution context. Cross-seam + self-review items remain in #582.

Full gate green: typecheck, test:unit:all (incl. RSC suites), lint, format.
ivogt added a commit that referenced this pull request Jun 14, 2026
…r flight/handler primitives (#582) (#583)

Two `@rangojs/router/testing` parity gaps from #582 (split from #572).

1. dispatch() json route lacked production's nested-Promise guard. Production
   (response-route-handler) serializes a json() result with a replacer that throws
   RESPONSE_NOT_SERIALIZABLE on a nested unresolved Promise (the forgotten-await
   footgun: `() => ({ data: fetchSomething() })`); dispatch did a bare
   JSON.stringify and shipped `{"data":{}}` green while production 500s. Extracted
   the replacer into a shared lightweight module (rsc/json-route-result.ts,
   depends only on errors.js) used by BOTH, so a dispatch json test fails exactly
   where production fails. dispatch's catch already maps the thrown RouterError to
   the same problem+json 500.

   The helper lives in its OWN module, not response-route-handler.ts: importing
   the latter into dispatch dragged its heavy runtime graph, which transitively
   reaches a Vite virtual module and breaks a plain (non-Vite) vitest import
   (caught: run-in-request-context.test.ts went red).

2. The testing primitives leaked ctx.reverse to the GLOBAL route map
   (order-dependent on whatever router registered last). Added a scoped `routeMap`
   option to renderToFlightString/renderServerTree (mirroring renderHandler),
   threaded through setRequestContextParams(params, routeName, routeMap?) --
   backward compatible (production callers omit it and keep getGlobalRouteMap()).
   renderHandler now also passes routeMap to setRequestContextParams, so a nested
   server component reading getRequestContext().reverse scopes to the same map as
   the handler's ctx.reverse -- all three primitives now scope reverse uniformly.

Tests (red-before-green): a dispatch json route returning a nested Promise -> a
problem+json 500 (not a green {}); ctx.reverse() scoped to a provided routeMap
under renderToFlightString, renderServerTree, AND a nested server component in
renderHandler (vs "Unknown route" against the empty global map). Docs: flight
skill routeMap option, feature-file-map new module.

Out of #582 this PR: item 8 (renderRoute synthetic-id stamping) is already
addressed by #581 via a stable WeakMap + a documented input-mutation contract;
item 9 (dispatch executionContext=undefined) is correct for an in-process harness
with no platform execution context. Cross-seam + self-review items remain in #582.

Full gate green: typecheck, test:unit:all (incl. RSC suites), lint, format.
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.

Follow-up to #533: capability + coverage polish for @rangojs/router/testing

1 participant