Repository navigation
feat(testing): #542 capability + coverage polish for @rangojs/router/testing - #581
Merged
Merged
Conversation
…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.
commit: |
This was referenced Jun 14, 2026
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.
4 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #542 (follow-up to #533, the consumer testing primitives).
Problem
@rangojs/router/testingshipped 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:renderRoute(...).navigate(...)and assertsuseNavigation().state === "loading"passed — but the lifecycle is frozen atidle, so it was asserting a constant."use cache"function tested underrenderHandlerran the uncached body (nocacheStorewas reachable through the primitive at all) and the test went green, "covering" caching while exercising none of it.rangoTestAliasesalias surfaced the context-stub failure as an opaquetree: undefinedwith 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
renderHandlercache-capability gap, and addsassertCacheDecisionso the telemetry path has a one-call assert symmetric withassertCacheStatus.What a consumer writes / sees differently
B —
renderHandlercan now exercise the cached pathBefore, 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.If you forget to seed it, the runtime now tells you (once per cached-fn id, under the test runner only):
G — one-call telemetry assert
filterCacheDecisionsstays for when you need the raw per-segmentshouldRevalidatefield.A / C / D — traps now fail loud
Semantics
renderRoute(...).navigate()then readuseNavigation().stateidleidle+ one-time warn naming the hooks and the e2e/renderServerTreealternativerenderHandlerruns a"use cache"fn, nocacheStorerenderHandlerruns a"use cache"fn,cacheStoreseededrenderToFlightString/renderServerTree, missingrangoTestAliasestree: undefinedrangoTestAliases(non-genuine errors pass through unchanged)renderRoute({ request })request does not resolve the leafrenderRoute({ request, params })request does not resolve the leafparamsare the documented escape hatchisUnderTestRunner()-gated → no warn, no throw, byte-identicalThe last two rows are the review follow-up: the D guard merges
options.paramsover the URL-extracted params inresolve(), and theparamsdocstring 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 theif (!store?.getItem)uncached bypass, aisUnderTestRunner()-gatedconsole.warndeduped via a module-levelwarnedUncachedUnderTest: 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—RenderHandlerOptionsgainscacheStore?: SegmentCacheStore+cacheProfiles?: Record<string, CacheProfile>, threaded intocreateRequestContextso the real cached path is reachable.src/testing/flight.ts— extractedisServerOnlyStubError(error)(shared withrenderHandler) and a privaterethrowFlightRenderError; theserializeNodeToFlightrethrow seam now rethrows an actionablerangoTestAliases-naming message for the stub error, original error otherwise. BothrenderToFlightStringandrenderServerTreeroute through it.src/testing/render-route.tsx— A: one-timenavigate()lifecycle warn + a fidelity-contract bullet. D: a leaf-match guard that throws on an unresolvable explicitrequest, skipped when explicitparamsare present (the documented escape hatch). BothisUnderTestRunner()-gated.src/testing/cache-status.ts— newassertCacheDecision(events, routeKey, expected)export (the telemetry counterpart ofassertCacheStatus); module header reframed as "pick by transport".src/testing/index.ts,src/testing/e2e/index.ts— exportassertCacheDecision.$$idruntime fallback); this PR promotes the include-relativedispatch/assertGeneratedRoutesMatchrecipe to a canonical quickstart and reframes F (steer torenderServerTree;renderToFlightStringis the wire-shape escape hatch).docs/testing.md,docs/README.md,docs/internal/feature-map.md+feature-file-map.md(new export), and theskills/testing/*set (SKILL.md,render-handler.md,cache-prerender.md,flight.md,server-tree.md,setup.md).Test plan
src/testing/__tests__/cache-status.test.ts—assertCacheDecisionpass / 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 explicitparamsare supplied, still rejects whenparams: {}; inert with norequest; 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/cacheProfilesforwarded into the request context.src/__tests__/public-consumer-imports.test.ts—assertCacheDecision/filterCacheDecisionsimportable from the public barrel.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.isUnderTestRunner()-gated, so it has no runtime behavior to exercise in a browser; coverage is the unit/rsc userland suites above.Notes / call-outs
isUnderTestRunner()(process.env.VITEST, deliberately not folded likeNODE_ENV). The B path is purely advisory — a test that intends to run uncached keeps working, just with one warn.cache-runtime.ts, not inrenderHandler: the!store?.getItembypass is the one seam every"use cache"call passes through (runLoader,runMiddleware,renderHandler,runInRequestContext, anddispatch). The B test asserts the option THREADING viagetRequestContextrather than importingregisterCachedFunction, because@vitejs/plugin-rsc/rscdoes not resolve in a bare vitest worker; the warn-once behavior is pinned separately incache-runtime-stale.test.ts(which mocks the plugin).requesteven when explicitparamswere supplied, contradicting theparams"avoid relying on URL parsing" contract. Fixed to skip when params are present; two tests pin both sides.mountprefix against the routes" item —renderRoutehas 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.