@rangojs/router 0.0.0-experimental.d7eeaa75 → 0.0.0-experimental.dacec167
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -25
- package/dist/bin/rango.js +147 -57
- package/dist/testing/vitest.js +82 -0
- package/dist/vite/index.js +2151 -846
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +57 -11
- package/skills/breadcrumbs/SKILL.md +3 -1
- package/skills/bundle-analysis/SKILL.md +159 -0
- package/skills/cache-guide/SKILL.md +220 -30
- package/skills/caching/SKILL.md +116 -8
- package/skills/composability/SKILL.md +27 -2
- package/skills/document-cache/SKILL.md +78 -55
- package/skills/handler-use/SKILL.md +364 -0
- package/skills/hooks/SKILL.md +229 -20
- package/skills/host-router/SKILL.md +45 -20
- package/skills/i18n/SKILL.md +276 -0
- package/skills/intercept/SKILL.md +46 -4
- package/skills/layout/SKILL.md +28 -7
- package/skills/links/SKILL.md +247 -17
- package/skills/loader/SKILL.md +219 -9
- package/skills/middleware/SKILL.md +47 -12
- package/skills/migrate-nextjs/SKILL.md +562 -0
- package/skills/migrate-react-router/SKILL.md +769 -0
- package/skills/mime-routes/SKILL.md +27 -0
- package/skills/observability/SKILL.md +137 -0
- package/skills/parallel/SKILL.md +71 -6
- package/skills/prerender/SKILL.md +14 -33
- package/skills/rango/SKILL.md +242 -22
- package/skills/react-compiler/SKILL.md +168 -0
- package/skills/response-routes/SKILL.md +66 -9
- package/skills/route/SKILL.md +57 -4
- package/skills/router-setup/SKILL.md +3 -3
- package/skills/server-actions/SKILL.md +751 -0
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/testing/SKILL.md +778 -0
- package/skills/typesafety/SKILL.md +319 -27
- package/skills/use-cache/SKILL.md +34 -5
- package/skills/view-transitions/SKILL.md +294 -0
- package/src/__augment-tests__/augment.ts +81 -0
- package/src/__augment-tests__/augmented.check.ts +117 -0
- package/src/browser/action-coordinator.ts +53 -36
- package/src/browser/app-shell.ts +52 -0
- package/src/browser/event-controller.ts +86 -70
- package/src/browser/history-state.ts +21 -0
- package/src/browser/index.ts +3 -3
- package/src/browser/navigation-bridge.ts +84 -11
- package/src/browser/navigation-client.ts +76 -28
- package/src/browser/navigation-store.ts +32 -9
- package/src/browser/navigation-transaction.ts +10 -28
- package/src/browser/partial-update.ts +64 -26
- package/src/browser/prefetch/cache.ts +129 -21
- package/src/browser/prefetch/fetch.ts +148 -16
- package/src/browser/prefetch/queue.ts +36 -5
- package/src/browser/rango-state.ts +53 -13
- package/src/browser/react/Link.tsx +30 -2
- package/src/browser/react/NavigationProvider.tsx +72 -31
- package/src/browser/react/filter-segment-order.ts +51 -7
- package/src/browser/react/index.ts +3 -0
- package/src/browser/react/location-state-shared.ts +175 -4
- package/src/browser/react/location-state.ts +39 -13
- package/src/browser/react/use-handle.ts +17 -9
- package/src/browser/react/use-navigation.ts +22 -2
- package/src/browser/react/use-params.ts +20 -8
- package/src/browser/react/use-reverse.ts +106 -0
- package/src/browser/react/use-router.ts +22 -2
- package/src/browser/react/use-segments.ts +11 -8
- package/src/browser/response-adapter.ts +25 -0
- package/src/browser/rsc-router.tsx +64 -22
- package/src/browser/scroll-restoration.ts +22 -14
- package/src/browser/segment-reconciler.ts +36 -14
- package/src/browser/segment-structure-assert.ts +2 -2
- package/src/browser/server-action-bridge.ts +23 -30
- package/src/browser/types.ts +21 -0
- package/src/build/collect-fallback-refs.ts +107 -0
- package/src/build/generate-manifest.ts +60 -35
- package/src/build/generate-route-types.ts +2 -0
- package/src/build/index.ts +2 -0
- package/src/build/route-trie.ts +52 -25
- package/src/build/route-types/codegen.ts +4 -4
- package/src/build/route-types/include-resolution.ts +1 -1
- package/src/build/route-types/per-module-writer.ts +7 -4
- package/src/build/route-types/router-processing.ts +55 -14
- package/src/build/route-types/scan-filter.ts +1 -1
- package/src/build/route-types/source-scan.ts +118 -0
- package/src/build/runtime-discovery.ts +9 -20
- package/src/cache/cache-scope.ts +28 -42
- package/src/cache/cf/cf-cache-store.ts +54 -13
- package/src/client.rsc.tsx +3 -0
- package/src/client.tsx +92 -182
- package/src/context-var.ts +5 -5
- package/src/decode-loader-results.ts +36 -0
- package/src/errors.ts +30 -1
- package/src/handle.ts +26 -13
- package/src/host/index.ts +2 -2
- package/src/host/router.ts +129 -57
- package/src/host/types.ts +31 -2
- package/src/host/utils.ts +1 -1
- package/src/href-client.ts +140 -20
- package/src/index.rsc.ts +9 -4
- package/src/index.ts +53 -15
- package/src/loader-store.ts +500 -0
- package/src/loader.rsc.ts +21 -6
- package/src/loader.ts +3 -10
- package/src/missing-id-error.ts +68 -0
- package/src/outlet-context.ts +1 -1
- package/src/prerender.ts +4 -4
- package/src/response-utils.ts +37 -0
- package/src/reverse.ts +65 -36
- package/src/route-content-wrapper.tsx +6 -28
- package/src/route-definition/dsl-helpers.ts +384 -257
- package/src/route-definition/helper-factories.ts +29 -139
- package/src/route-definition/helpers-types.ts +100 -28
- package/src/route-definition/resolve-handler-use.ts +6 -0
- package/src/route-definition/use-item-types.ts +32 -0
- package/src/route-types.ts +26 -41
- package/src/router/basename.ts +14 -0
- package/src/router/content-negotiation.ts +15 -2
- package/src/router/error-handling.ts +1 -1
- package/src/router/handler-context.ts +21 -38
- package/src/router/intercept-resolution.ts +4 -18
- package/src/router/lazy-includes.ts +8 -8
- package/src/router/loader-resolution.ts +19 -2
- package/src/router/manifest.ts +22 -13
- package/src/router/match-api.ts +4 -3
- package/src/router/match-handlers.ts +63 -20
- package/src/router/match-middleware/cache-lookup.ts +44 -91
- package/src/router/match-middleware/cache-store.ts +3 -2
- package/src/router/match-result.ts +53 -32
- package/src/router/metrics.ts +1 -1
- package/src/router/middleware-types.ts +15 -26
- package/src/router/middleware.ts +99 -84
- package/src/router/pattern-matching.ts +101 -17
- package/src/router/prerender-match.ts +1 -1
- package/src/router/preview-match.ts +3 -1
- package/src/router/request-classification.ts +4 -28
- package/src/router/revalidation.ts +58 -2
- package/src/router/router-interfaces.ts +45 -28
- package/src/router/router-options.ts +40 -1
- package/src/router/router-registry.ts +2 -5
- package/src/router/segment-resolution/fresh.ts +27 -6
- package/src/router/segment-resolution/revalidation.ts +147 -106
- package/src/router/segment-resolution/view-transition-default.ts +36 -0
- package/src/router/substitute-pattern-params.ts +56 -0
- package/src/router/telemetry.ts +99 -0
- package/src/router/trie-matching.ts +18 -13
- package/src/router/types.ts +8 -0
- package/src/router/url-params.ts +49 -0
- package/src/router.ts +38 -23
- package/src/rsc/handler-context.ts +2 -2
- package/src/rsc/handler.ts +28 -69
- package/src/rsc/helpers.ts +91 -43
- package/src/rsc/index.ts +1 -1
- package/src/rsc/origin-guard.ts +28 -10
- package/src/rsc/progressive-enhancement.ts +4 -0
- package/src/rsc/response-route-handler.ts +46 -53
- package/src/rsc/rsc-rendering.ts +35 -51
- package/src/rsc/runtime-warnings.ts +9 -10
- package/src/rsc/server-action.ts +17 -37
- package/src/rsc/ssr-setup.ts +16 -0
- package/src/rsc/types.ts +8 -2
- package/src/search-params.ts +4 -4
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +132 -116
- package/src/serialize.ts +243 -0
- package/src/server/context.ts +143 -53
- package/src/server/cookie-store.ts +28 -4
- package/src/server/request-context.ts +20 -42
- package/src/ssr/index.tsx +5 -1
- package/src/static-handler.ts +1 -1
- package/src/testing/cache-status.ts +166 -0
- package/src/testing/collect-handle.ts +63 -0
- package/src/testing/dispatch.ts +440 -0
- package/src/testing/dom.entry.ts +22 -0
- package/src/testing/e2e/fixture.ts +154 -0
- package/src/testing/e2e/index.ts +149 -0
- package/src/testing/e2e/matchers.ts +51 -0
- package/src/testing/e2e/page-helpers.ts +272 -0
- package/src/testing/e2e/parity.ts +306 -0
- package/src/testing/e2e/server.ts +183 -0
- package/src/testing/flight-matchers.ts +104 -0
- package/src/testing/flight-runtime.d.ts +57 -0
- package/src/testing/flight-tree.ts +320 -0
- package/src/testing/flight.entry.ts +39 -0
- package/src/testing/flight.ts +197 -0
- package/src/testing/generated-routes.ts +223 -0
- package/src/testing/index.ts +106 -0
- package/src/testing/internal/context.ts +331 -0
- package/src/testing/internal/flight-client-globals.ts +30 -0
- package/src/testing/render-route.tsx +565 -0
- package/src/testing/run-loader.ts +341 -0
- package/src/testing/run-middleware.ts +188 -0
- package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
- package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
- package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
- package/src/testing/vitest-stubs/version.ts +5 -0
- package/src/testing/vitest.ts +270 -0
- package/src/types/global-namespace.ts +39 -26
- package/src/types/handler-context.ts +68 -50
- package/src/types/index.ts +1 -0
- package/src/types/loader-types.ts +5 -6
- package/src/types/request-scope.ts +126 -0
- package/src/types/route-entry.ts +11 -0
- package/src/types/segments.ts +35 -2
- package/src/urls/include-helper.ts +34 -67
- package/src/urls/index.ts +0 -3
- package/src/urls/path-helper-types.ts +41 -7
- package/src/urls/path-helper.ts +17 -52
- package/src/urls/pattern-types.ts +36 -19
- package/src/urls/response-types.ts +22 -29
- package/src/urls/type-extraction.ts +26 -116
- package/src/urls/urls-function.ts +1 -5
- package/src/use-loader.tsx +413 -42
- package/src/vite/debug.ts +185 -0
- package/src/vite/discovery/bundle-postprocess.ts +6 -6
- package/src/vite/discovery/discover-routers.ts +101 -51
- package/src/vite/discovery/discovery-errors.ts +194 -0
- package/src/vite/discovery/gate-state.ts +171 -0
- package/src/vite/discovery/prerender-collection.ts +67 -26
- package/src/vite/discovery/route-types-writer.ts +40 -84
- package/src/vite/discovery/self-gen-tracking.ts +27 -1
- package/src/vite/discovery/state.ts +33 -0
- package/src/vite/discovery/virtual-module-codegen.ts +13 -23
- package/src/vite/index.ts +2 -0
- package/src/vite/plugin-types.ts +67 -0
- package/src/vite/plugins/cjs-to-esm.ts +8 -7
- package/src/vite/plugins/client-ref-dedup.ts +16 -0
- package/src/vite/plugins/client-ref-hashing.ts +28 -5
- package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
- package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
- package/src/vite/plugins/expose-action-id.ts +54 -30
- package/src/vite/plugins/expose-id-utils.ts +12 -8
- package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
- package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
- package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
- package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
- package/src/vite/plugins/expose-internal-ids.ts +496 -486
- package/src/vite/plugins/performance-tracks.ts +29 -25
- package/src/vite/plugins/use-cache-transform.ts +65 -50
- package/src/vite/plugins/version-injector.ts +39 -23
- package/src/vite/plugins/version-plugin.ts +59 -2
- package/src/vite/plugins/virtual-entries.ts +2 -2
- package/src/vite/rango.ts +116 -29
- package/src/vite/router-discovery.ts +750 -100
- package/src/vite/utils/ast-handler-extract.ts +15 -15
- package/src/vite/utils/banner.ts +1 -1
- package/src/vite/utils/bundle-analysis.ts +4 -2
- package/src/vite/utils/client-chunks.ts +190 -0
- package/src/vite/utils/forward-user-plugins.ts +193 -0
- package/src/vite/utils/manifest-utils.ts +21 -5
- package/src/vite/utils/package-resolution.ts +41 -1
- package/src/vite/utils/prerender-utils.ts +21 -6
- package/src/vite/utils/shared-utils.ts +107 -26
- package/src/browser/action-response-classifier.ts +0 -99
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: testing
|
|
3
|
+
description: Test @rangojs/router apps — unit (loaders/middleware/reverse/components), integration (dispatch/Flight), and e2e (dev+prod parity, progressive enhancement)
|
|
4
|
+
argument-hint: [layer]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Testing @rangojs/router apps
|
|
8
|
+
|
|
9
|
+
Rango ships six consumer-facing testing entries, one per test runtime/dependency:
|
|
10
|
+
`@rangojs/router/testing` (unit + integration, under a Vite-driven Vitest
|
|
11
|
+
project), `@rangojs/router/testing/vitest` (the `rangoTestConfig`/`rangoTestAliases` setup preset),
|
|
12
|
+
`@rangojs/router/testing/dom` (`renderRoute`, needs RTL + a DOM env),
|
|
13
|
+
`@rangojs/router/testing/e2e` (the Playwright harness),
|
|
14
|
+
`@rangojs/router/testing/flight` (real Flight, react-server condition only), and
|
|
15
|
+
`@rangojs/router/testing/flight-matchers` (the Flight matchers).
|
|
16
|
+
The hard problem in an RSC app is that the layer you reach for is dictated by
|
|
17
|
+
**what the behavior touches** — a pure predicate is a one-line vitest test; a
|
|
18
|
+
real async Server Component cannot be a plain node test at all. Pick the layer
|
|
19
|
+
**first**, then the primitive. Reaching one layer too high (e2e for a reverse
|
|
20
|
+
function) is slow; one too low (a node test for Flight) fails to compile or
|
|
21
|
+
silently asserts nothing.
|
|
22
|
+
|
|
23
|
+
Compatibility (the setup that bit the first installed consumer — read before
|
|
24
|
+
writing `vitest.config.ts`):
|
|
25
|
+
|
|
26
|
+
- **Node >= 23:** use **`rangoTestConfig()`**, not the bare `rangoTestAliases()`.
|
|
27
|
+
`@rangojs/router` is consumed as SOURCE (its exports resolve to `./src/*.ts`),
|
|
28
|
+
and Node >= 23 refuses to type-strip `.ts` under `node_modules`
|
|
29
|
+
(`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`). `rangoTestConfig` ships as
|
|
30
|
+
compiled JS (so the config itself loads under Node) AND adds the required
|
|
31
|
+
`server.deps.inline: [/@rangojs[/\\]router/]` so Vite — not Node — transpiles
|
|
32
|
+
rango's source under test. With bare `rangoTestAliases` you must wire
|
|
33
|
+
`deps.inline` yourself.
|
|
34
|
+
- **Vitest:** the rango fragment goes under `test` (`test.alias` +
|
|
35
|
+
`test.server.deps.inline`, both returned by `rangoTestConfig`). The node/DOM
|
|
36
|
+
project keeps React as its CLIENT build; the Flight project uses the
|
|
37
|
+
`react-server` condition in a separate `vitest.rsc.config.ts`.
|
|
38
|
+
|
|
39
|
+
For the prose guide with full setup and migration, see
|
|
40
|
+
[`docs/testing.md`](https://github.com/ivogt/vite-rsc/blob/main/packages/rangojs-router/docs/testing.md)
|
|
41
|
+
(the `docs/` directory is not shipped in the published package, so this is an
|
|
42
|
+
absolute link).
|
|
43
|
+
|
|
44
|
+
## When to use
|
|
45
|
+
|
|
46
|
+
Use this skill when adding or changing tests for a Rango app: a loader,
|
|
47
|
+
middleware, a route map, a client component, a response route, cache/SWR
|
|
48
|
+
behavior, prerender, or a navigation/PE flow.
|
|
49
|
+
|
|
50
|
+
Two non-negotiable mandates (from the repo's `CLAUDE.md`, and they apply to
|
|
51
|
+
consumer apps too):
|
|
52
|
+
|
|
53
|
+
- **Every e2e covers BOTH dev and production.** A dev-only e2e is not
|
|
54
|
+
acceptable. Use `parityDescribe` — it generates the dev and production
|
|
55
|
+
describes from one body, so you cannot forget the prod half.
|
|
56
|
+
- **Progressive-enhancement parity** is a first-class assertion. A form-driven
|
|
57
|
+
flow must produce the same observable result with JS on and JS off. Use
|
|
58
|
+
`expectParity`.
|
|
59
|
+
|
|
60
|
+
## The read-first shape
|
|
61
|
+
|
|
62
|
+
Four import roots, each matched to the dependency/runtime that can load it —
|
|
63
|
+
this split is forced by hard walls, not preference:
|
|
64
|
+
|
|
65
|
+
- `@rangojs/router/testing` — unit + integration primitives. Run these under a
|
|
66
|
+
**Vite-driven Vitest** project with the rango Vite plugin active (the router
|
|
67
|
+
internals import the `@rangojs/router:version` virtual module; without the
|
|
68
|
+
plugin, alias `@rangojs/router:version`). It references neither React,
|
|
69
|
+
`@testing-library/react`, Playwright, nor the RSC runtime — a unit suite
|
|
70
|
+
testing only loaders/middleware/`dispatch` pulls in none of them.
|
|
71
|
+
- `@rangojs/router/testing/dom` — `renderRoute` (the RTL component stub). Kept
|
|
72
|
+
separate so the unit barrel above stays free of React/RTL; it lazy-loads
|
|
73
|
+
`@testing-library/react` at call time and needs a DOM env (happy-dom/jsdom).
|
|
74
|
+
- `@rangojs/router/testing/e2e` — the Playwright harness. Kept separate so it
|
|
75
|
+
loads in a plain (non-Vite) Playwright runner; the unit barrel pulls in
|
|
76
|
+
router-manifest code that a Playwright loader cannot resolve. The helpers take
|
|
77
|
+
your `test`/`expect` as parameters, so this entry never imports
|
|
78
|
+
`@playwright/test` at runtime.
|
|
79
|
+
- `@rangojs/router/testing/flight` — real Flight rendering. Its serializer loads
|
|
80
|
+
only under the `react-server` node condition; pulling it elsewhere throws.
|
|
81
|
+
|
|
82
|
+
The single rule that drives everything:
|
|
83
|
+
|
|
84
|
+
> **If the behavior needs a real Flight render, it cannot be a plain vitest node
|
|
85
|
+
> test.** It is either `renderToFlightString` (under the react-server vitest
|
|
86
|
+
> project) or an e2e test. There is no middle ground in node.
|
|
87
|
+
|
|
88
|
+
## Decision tree: behavior -> layer -> primitive
|
|
89
|
+
|
|
90
|
+
| The behavior is… | Layer | Primitive | Import root |
|
|
91
|
+
| --------------------------------------------------------------------------------------------------------- | ------------ | ---------------------------------------------------------------- | -------------------------------- |
|
|
92
|
+
| a pure function / `reverse` / a predicate (`revalidate`, `isAction`) | unit (node) | call it directly; `runMiddleware`/`runLoader` for ctx | `@rangojs/router/testing` |
|
|
93
|
+
| one loader's data logic | unit (node) | `runLoader` (a registered `createLoader` handle, or the raw fn) | `@rangojs/router/testing` |
|
|
94
|
+
| one middleware's ordering / short-circuit / cookie+header merge | unit (node) | `runMiddleware` | `@rangojs/router/testing` |
|
|
95
|
+
| a CLIENT component reading router context (`useParams`/`useReverse`/`Outlet`/`useNavigation`/`useLoader`) | unit (DOM) | `renderRoute` (needs happy-dom/jsdom + `@testing-library/react`) | `@rangojs/router/testing/dom` |
|
|
96
|
+
| a redirect / status / headers / cookies / **response route** (json/text/html/xml/md), no Flight | integration | `dispatch` (router -> Response) | `@rangojs/router/testing` |
|
|
97
|
+
| a real async **Server Component** / Flight serialization shape | RSC unit | `renderToFlightString` + `toMatchFlight` | `@rangojs/router/testing/flight` |
|
|
98
|
+
| a client island's **typed props** across the boundary / inlined-vs-island | RSC unit | `renderServerTree` + `findClientBoundaries` | `@rangojs/router/testing/flight` |
|
|
99
|
+
| navigation, hydration, PE parity, view transitions, real SSR | e2e | `createRangoE2E` -> `parityDescribe`/`expectParity` | `@rangojs/router/testing/e2e` |
|
|
100
|
+
| cache hit/miss/stale, prerender (= a cache hit by design) | e2e + signal | `assertCacheStatus` / telemetry sink (gate on) | `@rangojs/router/testing` |
|
|
101
|
+
| generated route map drift vs runtime | unit (node) | `assertGeneratedRoutesMatch` | `@rangojs/router/testing` |
|
|
102
|
+
|
|
103
|
+
Cross-references: `/loader`, `/middleware`, `/server-actions`, `/caching`,
|
|
104
|
+
`/prerender`, `/typesafety`.
|
|
105
|
+
|
|
106
|
+
## Unit recipes (vitest, node)
|
|
107
|
+
|
|
108
|
+
### runMiddleware — ordering, short-circuit, cookie/header merge
|
|
109
|
+
|
|
110
|
+
Runs the chain through the router's **real** `executeMiddleware`, so
|
|
111
|
+
`next()`, return-Response short-circuit, throw-Response short-circuit,
|
|
112
|
+
double-next guards, and header/cookie merging behave exactly as in production.
|
|
113
|
+
`nextCalled` is `0` on short-circuit, `1` on pass-through. The result also
|
|
114
|
+
carries `cookies` (the effective `{ name: value }` view — assert a cookie the
|
|
115
|
+
chain set without casting through the `@internal` `ctx.cookies()`). The returned
|
|
116
|
+
`ctx` is the underlying `RequestContext` for anything else (`ctx.get(...)`,
|
|
117
|
+
`ctx.res.headers`).
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { describe, it, expect } from "vitest";
|
|
121
|
+
import { runMiddleware } from "@rangojs/router/testing";
|
|
122
|
+
import type { Middleware } from "@rangojs/router";
|
|
123
|
+
|
|
124
|
+
const requireUser: Middleware = async (ctx, next) => {
|
|
125
|
+
if (!ctx.get("user")) return new Response(null, { status: 401 });
|
|
126
|
+
return next();
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
it("passes through when the user is present", async () => {
|
|
130
|
+
const { response, nextCalled } = await runMiddleware(
|
|
131
|
+
requireUser,
|
|
132
|
+
"/dashboard",
|
|
133
|
+
{
|
|
134
|
+
vars: { user: { id: 1 } }, // object form; or [[key, value]] tuples (key may be a createVar())
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
expect(nextCalled).toBe(1);
|
|
138
|
+
expect(response.status).toBe(200);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("short-circuits (return OR throw Response) when unauthenticated", async () => {
|
|
142
|
+
const { response, nextCalled } = await runMiddleware(
|
|
143
|
+
requireUser,
|
|
144
|
+
"/dashboard",
|
|
145
|
+
);
|
|
146
|
+
expect(nextCalled).toBe(0);
|
|
147
|
+
expect(response.status).toBe(401);
|
|
148
|
+
});
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Seed prior-middleware state with `vars` (string key or `createVar()` handle).
|
|
152
|
+
Model the downstream route with `next`. Enable `ctx.reverse(...)` by passing
|
|
153
|
+
`routeMap` (and `routeName` for scoped `.name` resolution). Pass an array to run
|
|
154
|
+
several in order. Cookies set via `cookies().set(...)` surface on the result's
|
|
155
|
+
`cookies` and on the merged response `Set-Cookie`.
|
|
156
|
+
|
|
157
|
+
There is no `handles`/`rendered` option (only `runLoader` has them): middleware
|
|
158
|
+
runs BEFORE the render barrier, so it has no post-barrier handle access in
|
|
159
|
+
production — `ctx.use(Handle)` after `ctx.rendered()` is a loader/handler
|
|
160
|
+
capability, not a middleware one. Read handle data in a loader and test it with
|
|
161
|
+
`runLoader`'s `handles`/`rendered`.
|
|
162
|
+
|
|
163
|
+
### runLoader — one loader's data logic
|
|
164
|
+
|
|
165
|
+
Pass a registered `createLoader()` handle **or** the raw loader body `(ctx) => ...`.
|
|
166
|
+
A handle's fn is recovered from the registry: `createLoader` assigns a
|
|
167
|
+
runtime-fallback `$$id` and registers the fn even without the Vite plugin, when
|
|
168
|
+
imported through the server build (`@rangojs/router` under the `rangoTestConfig`
|
|
169
|
+
preset). The raw body needs no build at all. Either way `runLoader` invokes the
|
|
170
|
+
function against a real `RequestContext`, so cookies, headers, `ctx.get`, and
|
|
171
|
+
`ctx.reverse` resolve. (A handle imported through the CLIENT build has its body
|
|
172
|
+
dropped — `runLoader` then throws a clear error pointing you to the preset or the
|
|
173
|
+
raw body.)
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
import { runLoader } from "@rangojs/router/testing";
|
|
177
|
+
import { createLoader, createVar } from "@rangojs/router";
|
|
178
|
+
|
|
179
|
+
const User = createVar<{ name: string }>();
|
|
180
|
+
// The registered loader — no separate body export needed for testability:
|
|
181
|
+
const ProductLoader = createLoader(async (ctx) => ({
|
|
182
|
+
id: ctx.params.id,
|
|
183
|
+
region: ctx.env.REGION,
|
|
184
|
+
user: ctx.get(User),
|
|
185
|
+
}));
|
|
186
|
+
|
|
187
|
+
it("reads params, env, and seeded vars", async () => {
|
|
188
|
+
const data = await runLoader(ProductLoader, {
|
|
189
|
+
params: { id: "42" },
|
|
190
|
+
env: { REGION: "eu" },
|
|
191
|
+
vars: [[User, { name: "Ada" }]],
|
|
192
|
+
});
|
|
193
|
+
expect(data).toEqual({ id: "42", region: "eu", user: { name: "Ada" } });
|
|
194
|
+
});
|
|
195
|
+
// runLoader(async (ctx) => ({ ... }), opts) — the bare body — works identically.
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Options: `params` (also surfaced as `routeParams`), `search`, `env`, `vars`,
|
|
199
|
+
`method`/`body`/`formData`, `routeMap`/`routeName` (for `ctx.reverse`), and
|
|
200
|
+
`use` (a resolver for `ctx.use(OtherLoader)` composition — without it, `ctx.use`
|
|
201
|
+
runs the dependency's own `fn` if it carries one).
|
|
202
|
+
|
|
203
|
+
Two unit-only limitations to document in your test, not work around:
|
|
204
|
+
|
|
205
|
+
- `ctx.reverse(...)` **throws** unless you pass `routeMap`.
|
|
206
|
+
- `ctx.rendered()` **throws** (the DSL render barrier only exists in a full
|
|
207
|
+
match) and `ctx.isAction(...)` (the action-render context) is not available —
|
|
208
|
+
test those with `renderToFlightString` or e2e.
|
|
209
|
+
|
|
210
|
+
No body extraction needed: `export const L = createLoader(async (ctx) => {...})`
|
|
211
|
+
can be imported and passed straight to `runLoader(L, ...)`. Exporting the inner
|
|
212
|
+
body separately is optional now (only if you want to test it without going
|
|
213
|
+
through `createLoader` at all).
|
|
214
|
+
|
|
215
|
+
COOKIE SEEDING: there is no `cookies`/`headers` option — seed a request cookie by
|
|
216
|
+
passing a full `Request` with the header, `runLoader(body, { request: new
|
|
217
|
+
Request("https://app.test/", { headers: { Cookie: "sid=abc" } }) })`. A loader
|
|
218
|
+
that reads `cookies()` then sees `abc`. (`search`/`method` are baked onto this
|
|
219
|
+
request for you, so pass a `Request` only when you need headers/cookies.)
|
|
220
|
+
|
|
221
|
+
### runInRequestContext — an action (or any fn) that reads request context
|
|
222
|
+
|
|
223
|
+
For a server ACTION (or any function) that authenticates off the request cookie
|
|
224
|
+
and calls `getRequestContext()` / `cookies()` but has no loader-context shape,
|
|
225
|
+
`runInRequestContext(fn, opts)` builds a real `RequestContext` (same `opts` as the
|
|
226
|
+
other primitives — `env`, `request`, `vars`, ...) AND enters it, so the function
|
|
227
|
+
runs exactly as in production. `fn` may be async; the context stays active across
|
|
228
|
+
its awaits. It captures the action's OUTPUT whether `fn` RETURNS or THROWS, so it
|
|
229
|
+
is assertable WITHOUT casting through the `@internal` `ctx.res` / `ctx.cookies()`:
|
|
230
|
+
|
|
231
|
+
- `result` — fn's return value (awaited), or `undefined` if it threw
|
|
232
|
+
- `thrown` — what `fn` threw (a redirect/notFound `Response` on the SUCCESS path), or `undefined`. Captured, NOT re-thrown — assert on it for a throwing action
|
|
233
|
+
- `response` — Set-Cookie / headers / status the run set; on a thrown redirect, that redirect's `Location` merged with the cookies
|
|
234
|
+
- `cookies` — the effective `{ name: value }` cookie view after the run
|
|
235
|
+
- `headers` — the response headers the run set (via `ctx.header(...)`, plus a thrown redirect's `Location`) as a plain `{ name: value }` object, EXCLUDING set-cookie (that's `cookies`); names lowercased. (`runMiddleware` returns the same `headers`.)
|
|
236
|
+
- `locationState` — the flash the action set via `ctx.setLocationState()` / `redirect({ state })`, resolved to the `{ key: value }` the client reads
|
|
237
|
+
|
|
238
|
+
The THROW path matters: the dominant cookie+flash case is an auth action that sets
|
|
239
|
+
a cookie + flash then `throw redirect("/app")` on success. Because the snapshot
|
|
240
|
+
fires on the throw too, you do NOT have to wrap the action in your own try/catch:
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
import { runInRequestContext } from "@rangojs/router/testing";
|
|
244
|
+
import { loginAction } from "../src/actions/login"; // sets a session cookie + flash, then throw redirect("/app")
|
|
245
|
+
|
|
246
|
+
it("sets the session cookie + flash and redirects", async () => {
|
|
247
|
+
const { thrown, cookies, locationState } = await runInRequestContext(
|
|
248
|
+
() => loginAction(input),
|
|
249
|
+
{
|
|
250
|
+
env,
|
|
251
|
+
request: new Request("https://app.test/admin", {
|
|
252
|
+
headers: { Cookie: "sid=abc" },
|
|
253
|
+
}),
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
expect((thrown as Response).headers.get("Location")).toBe("/app"); // redirected
|
|
257
|
+
expect(cookies.session).toBeDefined(); // cookie set before the throw, no @internal cast
|
|
258
|
+
expect(locationState).toEqual({ flash: { text: "Welcome back" } });
|
|
259
|
+
});
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
For the low-level case where you already hold a context from
|
|
263
|
+
`createTestRequestContext(...)`, `runWithRequestContext(ctx, fn)` is re-exported
|
|
264
|
+
from `@rangojs/router/testing` to enter it directly; `runInRequestContext` is the
|
|
265
|
+
one-call convenience over the two.
|
|
266
|
+
|
|
267
|
+
### Your bindings are your seam (env.DB / Durable Objects / R2)
|
|
268
|
+
|
|
269
|
+
The node primitives test the router's seams; the moment your loader/middleware/
|
|
270
|
+
action calls a **platform binding** (`env.DB`, a Durable Object stub, `env.R2`),
|
|
271
|
+
you have crossed out of rango and into your app's I/O. rango deliberately ships
|
|
272
|
+
**no doubles** for these — they are app- and schema-specific — so the double is
|
|
273
|
+
yours to build and inject through the `env` option every primitive already takes:
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
await runLoader(bundleLoaderBody, { env: { DB: fakeD1 } });
|
|
277
|
+
await runMiddleware(requireMembership, "/t/acme/edit", { env: { DB: fakeD1 } });
|
|
278
|
+
await runInRequestContext(() => authorizeAction(input), {
|
|
279
|
+
env: { DB: fakeD1 },
|
|
280
|
+
request,
|
|
281
|
+
});
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Plan for this seam — it is usually the single biggest effort in a consumer unit
|
|
285
|
+
suite, and the work is in matching the **driver contract**, not the binding's
|
|
286
|
+
public API. The sharp edge: a `D1Database` double for **`drizzle-orm/d1`** must
|
|
287
|
+
serve **positional row arrays in schema-column order** for drizzle's `.raw()`
|
|
288
|
+
path (with the driver-level encodings so the decoder round-trips `Date`/JSON) —
|
|
289
|
+
NOT `{ column: value }` objects. A naive object-shaped double returns
|
|
290
|
+
silently-wrong or empty rows. That contract is per-method: drizzle-d1 serves
|
|
291
|
+
SELECTs through `.raw()` (the positional rows above), but writes
|
|
292
|
+
(INSERT/UPDATE/DELETE) go through `.run()`, which returns `{ success, meta }` (no
|
|
293
|
+
rows) and bypasses the row responder entirely — model BOTH paths, a read-only
|
|
294
|
+
`.raw()` double silently no-ops every write. Keep the double at the binding
|
|
295
|
+
boundary; never mock a rango primitive to dodge building it.
|
|
296
|
+
|
|
297
|
+
### renderRoute — a client component reading router context
|
|
298
|
+
|
|
299
|
+
RTL-style stub. Peer of React Router's `createRoutesStub` / Expo's
|
|
300
|
+
`renderRouter`. It mounts the router's real `NavigationProvider` plus a
|
|
301
|
+
synthetic segment tree so `useParams`, `useReverse`, `useNavigation`, `Outlet`,
|
|
302
|
+
`usePathname`, `useSearchParams`, and `useLoader`/`useFetchLoader` (reading
|
|
303
|
+
**seeded** data) resolve — no server, no Vite, no Flight round-trip. It is
|
|
304
|
+
`async` (lazy-loads `@testing-library/react`).
|
|
305
|
+
|
|
306
|
+
```tsx
|
|
307
|
+
// @vitest-environment happy-dom
|
|
308
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
309
|
+
import { cleanup } from "@testing-library/react";
|
|
310
|
+
import { renderRoute } from "@rangojs/router/testing/dom";
|
|
311
|
+
import { Outlet, useParams, useReverse } from "@rangojs/router/client";
|
|
312
|
+
|
|
313
|
+
afterEach(cleanup);
|
|
314
|
+
|
|
315
|
+
function Layout() {
|
|
316
|
+
return (
|
|
317
|
+
<div>
|
|
318
|
+
<span data-testid="shell">shell</span>
|
|
319
|
+
<Outlet />
|
|
320
|
+
</div>
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
function Product() {
|
|
324
|
+
const { productId } = useParams<{ productId: string }>();
|
|
325
|
+
const reverse = useReverse({ product: "/products/:productId" });
|
|
326
|
+
return (
|
|
327
|
+
<a data-testid="link" href={reverse("product", { productId: "2" })}>
|
|
328
|
+
{productId}
|
|
329
|
+
</a>
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
it("resolves params + reverse + Outlet through the layout chain", async () => {
|
|
334
|
+
const { getByTestId, router } = await renderRoute(
|
|
335
|
+
[
|
|
336
|
+
{ path: "/products", Component: Layout }, // layout (root)
|
|
337
|
+
{ path: "/products/:productId", Component: Product }, // leaf (last)
|
|
338
|
+
],
|
|
339
|
+
{ initialUrl: "/products/1" },
|
|
340
|
+
);
|
|
341
|
+
expect(getByTestId("shell").textContent).toBe("shell");
|
|
342
|
+
expect(getByTestId("link").getAttribute("href")).toBe("/products/2");
|
|
343
|
+
|
|
344
|
+
await router.navigate("/products/2"); // client-only nav, re-resolves the same routes
|
|
345
|
+
expect(router.pathname()).toBe("/products/2");
|
|
346
|
+
});
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
`RenderRouteSpec = { path, Component, layout?, loaderIds?, name? }`. The array
|
|
350
|
+
is the layout chain root-to-leaf; the **last** entry is the leaf route. Seed
|
|
351
|
+
loader reads with `options.loaderData` keyed by the loader's `$$id`; attach a
|
|
352
|
+
loader to a specific layout via that spec's `loaderIds`:
|
|
353
|
+
|
|
354
|
+
```tsx
|
|
355
|
+
const CartLoader = {
|
|
356
|
+
__brand: "loader",
|
|
357
|
+
$$id: "loaders/cart#CartLoader",
|
|
358
|
+
} as any;
|
|
359
|
+
await renderRoute(
|
|
360
|
+
[
|
|
361
|
+
{ path: "/shop", Component: CartLayout, loaderIds: [CartLoader.$$id] },
|
|
362
|
+
{ path: "/shop/item", Component: Page },
|
|
363
|
+
],
|
|
364
|
+
{ initialUrl: "/shop/item", loaderData: { [CartLoader.$$id]: { count: 3 } } },
|
|
365
|
+
);
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
Seed `useHandle` reads with `handles: [[handle, pushedValues[]]]` and
|
|
369
|
+
`useLocationState` with `locationState: [[def, value]]` (both by reference).
|
|
370
|
+
Handle data is accumulated GLOBALLY (not segment-scoped like loaders), so a
|
|
371
|
+
LAYOUT component reading a handle (a `DetailLayout`/`ActionToolbar` reading
|
|
372
|
+
`EditTarget`/`PageEyebrow`) sees the seeded values, not just the leaf route.
|
|
373
|
+
|
|
374
|
+
Model an `include('/shop', …)` mount with the `mount` option: it wraps the
|
|
375
|
+
segment chain in a MountContext exactly as production, so `useMount()` returns
|
|
376
|
+
the prefix and `useHref`/`useReverse` resolve mount-prefixed URLs — a
|
|
377
|
+
mount-relative subtree (`/c/:slug` mounted under `/shop`) becomes reproducible at
|
|
378
|
+
the unit layer instead of e2e-only:
|
|
379
|
+
|
|
380
|
+
```tsx
|
|
381
|
+
await renderRoute([{ path: "/c/wine", Component: PDP }], { mount: "/shop" });
|
|
382
|
+
// useMount() -> "/shop"; useReverse({ product: "/c/:slug" })("product", { slug: "wine" }) -> "/shop/c/wine"
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
Don't confuse this with an OPTIONAL param in the matched pattern: `/:locale?/c/:group`
|
|
386
|
+
at `/en/c/wine` auto-fills `locale` from the match, so `reverse("group", { group })`
|
|
387
|
+
returns `/en/c/group` with NO `mount` needed (production parity — `useReverse`
|
|
388
|
+
merges `useParams()`). Use `mount` only for an `include()` prefix; a param-bearing
|
|
389
|
+
mount like `include("/:locale?", …)` resolves to a concrete prefix you pass as
|
|
390
|
+
`mount: "/en"`. A locale "dropping" from a reversed URL in a test is usually a
|
|
391
|
+
missing `mount` seed, not an auto-fill gap.
|
|
392
|
+
|
|
393
|
+
FIDELITY CAVEAT — this is the **client tree only**. It does NOT catch
|
|
394
|
+
server/client boundary reference-identity remount bugs, real Flight
|
|
395
|
+
serialization errors, loader execution, middleware, or handler ordering. Those
|
|
396
|
+
are `renderToFlightString` / e2e territory. Loader data is seeded, never run.
|
|
397
|
+
Needs a DOM env (`// @vitest-environment happy-dom`, or jsdom) and the consumer
|
|
398
|
+
must install `@testing-library/react` (optional peer).
|
|
399
|
+
|
|
400
|
+
CATCH — streaming `use(promise)` Suspense content (e.g. an async breadcrumb
|
|
401
|
+
`content: Promise<ReactNode>`): a plain `Promise.resolve(node)` does NOT flush
|
|
402
|
+
its Suspense retry in RTL/happy-dom (renderRoute renders internally, not inside
|
|
403
|
+
an awaited `act`), so the DOM stays on the fallback. Assert the **pending**
|
|
404
|
+
fallback with a never-resolving `new Promise(() => {})`; for the **arrived**
|
|
405
|
+
state pass an already-settled promise so `use()` reads it synchronously:
|
|
406
|
+
`const p = Promise.resolve(node) as any; p.status = "fulfilled"; p.value = node;`.
|
|
407
|
+
The real pending→resolved transition is an e2e concern.
|
|
408
|
+
|
|
409
|
+
ARIA GOTCHA — query a `<Link>` by `getByRole("link")` only when it renders a bare
|
|
410
|
+
anchor. An explicit `role` on the link (e.g. `<Link role="tab">` in a tablist)
|
|
411
|
+
OVERRIDES the anchor's implicit `link` role, so `getByRole("link")` finds
|
|
412
|
+
nothing — query the explicit role (`getByRole("tab")`) or fall back to
|
|
413
|
+
`getByText`/`getByTestId` and assert `getAttribute("href")`.
|
|
414
|
+
|
|
415
|
+
### Type-level tests — make misuse fail to compile
|
|
416
|
+
|
|
417
|
+
The reverse/href/params/env types are a real contract; a wrong route name,
|
|
418
|
+
missing param, or unknown binding should be a COMPILE error, not a runtime
|
|
419
|
+
surprise. This is the highest signal-per-cost test in the suite, but it runs at
|
|
420
|
+
typecheck time, not in the vitest runner — so it is its own layer, wired into CI
|
|
421
|
+
as a real step (`pnpm run typecheck` / `tsc --noEmit`). Three recipes, smallest
|
|
422
|
+
first:
|
|
423
|
+
|
|
424
|
+
1. Negative assertions with `@ts-expect-error` (a runtime test cannot do this) —
|
|
425
|
+
the directive ERRORS if the line below ever starts compiling, so a regressed
|
|
426
|
+
guard fails the typecheck:
|
|
427
|
+
|
|
428
|
+
```ts
|
|
429
|
+
import { useReverse } from "@rangojs/router/client";
|
|
430
|
+
const reverse = useReverse({ product: "/products/:productId" });
|
|
431
|
+
reverse("product", { productId: "2" }); // ok
|
|
432
|
+
// @ts-expect-error missing required param
|
|
433
|
+
reverse("product", {});
|
|
434
|
+
// @ts-expect-error unknown route name
|
|
435
|
+
reverse("nope", {});
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
2. Positive assertions with vitest's `expectTypeOf` — for pinning an INFERRED
|
|
439
|
+
type (a loader's return, a parsed search schema, a handle's accumulated
|
|
440
|
+
shape), in a normal `*.test.ts`:
|
|
441
|
+
|
|
442
|
+
```ts
|
|
443
|
+
import { expectTypeOf } from "vitest";
|
|
444
|
+
expectTypeOf(await runLoader(cartLoaderBody)).toEqualTypeOf<{
|
|
445
|
+
count: number;
|
|
446
|
+
}>();
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
3. A dedicated `*.test-d.ts` + `tsconfig.types.json` (extends base, includes only
|
|
450
|
+
those files; run `tsc -p tsconfig.types.json --noEmit`) for a large type
|
|
451
|
+
suite — the pattern rango itself uses for its augmentation contracts. Recipe 1
|
|
452
|
+
is enough for most apps; reach for 3 only when inline assertions clutter
|
|
453
|
+
runtime tests.
|
|
454
|
+
|
|
455
|
+
## Integration recipes
|
|
456
|
+
|
|
457
|
+
### dispatch — request -> Response, without Flight
|
|
458
|
+
|
|
459
|
+
In-process matching + middleware, no RSC render. Covers `308` redirects
|
|
460
|
+
(trailing slash etc.) with `Location`, `404`, response routes
|
|
461
|
+
(json/text/html/xml/md with content negotiation), and **global + route-level
|
|
462
|
+
middleware** short-circuits with full `next()`/throw/header+cookie fidelity. It
|
|
463
|
+
reuses the router's own `previewMatch`, so middleware collection is the router's,
|
|
464
|
+
not a re-implementation. Hitting an RSC (component) route throws a clear
|
|
465
|
+
directive error.
|
|
466
|
+
|
|
467
|
+
So `dispatch` IS the way to exercise a RESPONSE route's real route-level
|
|
468
|
+
middleware chain (the guard stack) against the actual registered tree. The gap:
|
|
469
|
+
a COMPONENT route's guard stack cannot run here (dispatch refuses it, and
|
|
470
|
+
`renderToFlightString`/`renderRoute` don't run route middleware) — assert that at
|
|
471
|
+
e2e, or extract the middleware fn and unit-test it with `runMiddleware`.
|
|
472
|
+
|
|
473
|
+
SETUP CAVEAT (use the preset): `@rangojs/router` resolves to server-only STUBS
|
|
474
|
+
outside the `react-server` condition (urls/createRouter/cookies/getRequestContext
|
|
475
|
+
throw), and importing your router also pulls `@vitejs/plugin-rsc/rsc` (whose body
|
|
476
|
+
imports Vite virtuals). Vitest does not apply the `react-server` condition to
|
|
477
|
+
bare-package resolution. The preset `@rangojs/router/testing/vitest` handles all
|
|
478
|
+
of it — alias `@rangojs/router` to real impls + stub the virtuals — so no
|
|
479
|
+
per-file `vi.mock` is needed. Spread `rangoTestConfig(...)` into your `test`
|
|
480
|
+
block:
|
|
481
|
+
|
|
482
|
+
```ts
|
|
483
|
+
// vitest.config.ts
|
|
484
|
+
import { defineConfig } from "vitest/config";
|
|
485
|
+
import { rangoTestConfig } from "@rangojs/router/testing/vitest";
|
|
486
|
+
export default defineConfig({
|
|
487
|
+
test: {
|
|
488
|
+
globals: true,
|
|
489
|
+
include: ["test/**/*.test.{ts,tsx}"],
|
|
490
|
+
environment: "node",
|
|
491
|
+
...rangoTestConfig({ preset: "cloudflare" }),
|
|
492
|
+
},
|
|
493
|
+
});
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
`rangoTestConfig` returns BOTH the resolve `alias` entries AND
|
|
497
|
+
`server.deps.inline: [/@rangojs[/\\]router/]`. The `deps.inline` half is
|
|
498
|
+
mandatory for an installed (node_modules) consumer: `@rangojs/router` ships as
|
|
499
|
+
TypeScript source, Vitest externalizes node_modules by default, and Node >= 23
|
|
500
|
+
refuses to type-strip `.ts` under `node_modules`
|
|
501
|
+
(`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`) — `deps.inline` forces Vite (not
|
|
502
|
+
Node) to transpile rango's source. The preset entry itself ships as compiled JS,
|
|
503
|
+
so the `import { rangoTestConfig }` line loads under plain Node config loading.
|
|
504
|
+
(If you need only the aliases, `rangoTestAliases(...)` is still exported, but then
|
|
505
|
+
you must wire `server.deps.inline` yourself.)
|
|
506
|
+
|
|
507
|
+
LIMITATION: the FULL router usually can't be imported in a bare test —
|
|
508
|
+
`Prerender()`/`createLoader()` need the plugin-injected `$$id` (real `Prerender()`
|
|
509
|
+
throws "missing $$id"). Build a router from a `Prerender`-free include (your API
|
|
510
|
+
routes); `dispatch` accepts the public router type with no cast:
|
|
511
|
+
|
|
512
|
+
```ts
|
|
513
|
+
import { describe, it, expect } from "vitest";
|
|
514
|
+
import { dispatch } from "@rangojs/router/testing";
|
|
515
|
+
import { createRouter } from "@rangojs/router";
|
|
516
|
+
import { apiPatterns } from "../src/api/urls"; // path.json(...) routes only
|
|
517
|
+
|
|
518
|
+
const router = createRouter().routes(apiPatterns);
|
|
519
|
+
|
|
520
|
+
it("serializes a JSON response route (auto-wrapped under data)", async () => {
|
|
521
|
+
const res = await dispatch(router, "/health");
|
|
522
|
+
expect(res.status).toBe(200);
|
|
523
|
+
expect(await res.json()).toEqual({ data: { status: "ok" } });
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("maps a thrown RouterError to its status + typed JSON envelope", async () => {
|
|
527
|
+
const res = await dispatch(router, "/products/999");
|
|
528
|
+
expect(res.status).toBe(404);
|
|
529
|
+
expect((await res.json()).error.code).toBe("NOT_FOUND");
|
|
530
|
+
});
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
### renderToFlightString — real async Server Component
|
|
534
|
+
|
|
535
|
+
A REAL Flight render of an async Server Component, in plain node — but ONLY
|
|
536
|
+
under the `react-server` condition (see the next section for the vitest
|
|
537
|
+
project). The render runs inside a request context, so async components can call
|
|
538
|
+
`getRequestContext()`, read params, cookies, etc.
|
|
539
|
+
|
|
540
|
+
```tsx
|
|
541
|
+
// flight.rsc-test.tsx (note the *.rsc-test suffix)
|
|
542
|
+
import { describe, it, expect } from "vitest";
|
|
543
|
+
import { renderToFlightString } from "@rangojs/router/testing/flight";
|
|
544
|
+
// Matchers are a SEPARATE subpath (they import vitest); renderToFlightString does not.
|
|
545
|
+
import { flightMatchers } from "@rangojs/router/testing/flight-matchers";
|
|
546
|
+
|
|
547
|
+
expect.extend(flightMatchers);
|
|
548
|
+
|
|
549
|
+
// Keep components PURE leaves: take data as props. Do NOT import a server API
|
|
550
|
+
// (getRequestContext, cookies) from the `@rangojs/router` barrel — under the
|
|
551
|
+
// react-server condition the bare specifier resolves to the throwing stub, so
|
|
552
|
+
// it cannot be flight-tested in a bare consumer project.
|
|
553
|
+
async function Greeting({ name }: { name: string }) {
|
|
554
|
+
await Promise.resolve();
|
|
555
|
+
return <div>Hello {name}!</div>;
|
|
556
|
+
}
|
|
557
|
+
async function ItemView({ id }: { id: string }) {
|
|
558
|
+
return <span>id={id}</span>;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
it("renders text and props", async () => {
|
|
562
|
+
expect(await renderToFlightString(<Greeting name="Ada" />)).toMatchFlight(
|
|
563
|
+
"Ada",
|
|
564
|
+
);
|
|
565
|
+
expect(await renderToFlightString(<ItemView id="42" />)).toMatchFlight("42");
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it("matches a normalized snapshot", async () => {
|
|
569
|
+
expect(
|
|
570
|
+
await renderToFlightString(<Greeting name="World" />),
|
|
571
|
+
).toMatchFlightSnapshot();
|
|
572
|
+
});
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
`toMatchFlight(substring)` asserts the normalized Flight string CONTAINS the
|
|
576
|
+
substring (containment, not equality — the row framing is an internal serializer
|
|
577
|
+
detail). `toMatchFlightSnapshot()` snapshots the normalized payload. SCOPE:
|
|
578
|
+
`renderToFlightString` returns the wire STRING; for typed assertions on a client
|
|
579
|
+
boundary's props, use `renderServerTree` (next).
|
|
580
|
+
|
|
581
|
+
### renderServerTree — serialize then deserialize to an inspectable tree
|
|
582
|
+
|
|
583
|
+
Same react-server project. Serializes the real Flight, then deserializes it to a
|
|
584
|
+
React element tree you can traverse. The win over the wire string: a client
|
|
585
|
+
boundary's props come back as REAL JS values (a `Date` is a `Date`), and you can
|
|
586
|
+
confirm a `"use client"` component crossed the boundary (an `I` row) vs being
|
|
587
|
+
inlined. No hydration / no interaction (that is the e2e tier).
|
|
588
|
+
|
|
589
|
+
Wire `rangoUseClientTransform()` into `vitest.rsc.config.ts`
|
|
590
|
+
(`plugins: [rangoUseClientTransform()]`, imported from `@rangojs/router/testing/vitest`)
|
|
591
|
+
so islands are auto-discovered from the server tree's own imports — pass nothing:
|
|
592
|
+
|
|
593
|
+
```tsx
|
|
594
|
+
import { it, expect } from "vitest";
|
|
595
|
+
import {
|
|
596
|
+
renderServerTree,
|
|
597
|
+
findClientBoundaries,
|
|
598
|
+
} from "@rangojs/router/testing/flight";
|
|
599
|
+
import { PriceTag } from "./PriceTag.js"; // a "use client" component (any filename)
|
|
600
|
+
|
|
601
|
+
async function Panel({ amount, asOf }: { amount: number; asOf: Date }) {
|
|
602
|
+
await Promise.resolve();
|
|
603
|
+
return <PriceTag amount={amount} currency="USD" asOf={asOf} />;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
it("client props survive the round trip", async () => {
|
|
607
|
+
const { flight, tree } = await renderServerTree(
|
|
608
|
+
<Panel amount={19.5} asOf={new Date("2026-01-02T00:00:00Z")} />,
|
|
609
|
+
);
|
|
610
|
+
expect(flight).toMatchFlight("PriceTag"); // wire assertions still work
|
|
611
|
+
const [tag] = findClientBoundaries(tree, "PriceTag");
|
|
612
|
+
expect(tag.props.amount).toBe(19.5); // a real number
|
|
613
|
+
expect(tag.props.asOf).toBeInstanceOf(Date); // a real Date, not "$D..."
|
|
614
|
+
});
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
`findClientBoundaries(tree, name?)` always returns an array (`{ id, name, props,
|
|
618
|
+
element }[]`) in document order, optionally filtered by export name; destructure
|
|
619
|
+
`const [tag] = …` for one island, assert `.length` when count matters (missing
|
|
620
|
+
name -> `[]`). Without the transform, register islands explicitly instead:
|
|
621
|
+
`renderServerTree(<Panel/>, { clientComponents: { PriceTag } })`. A true
|
|
622
|
+
interactive, clickable DOM `renderServer` is intentionally NOT shipped —
|
|
623
|
+
in-process happy-dom hydration re-tests React and misses server/client divergence
|
|
624
|
+
(which needs a real browser). Use e2e for interaction.
|
|
625
|
+
|
|
626
|
+
## E2E recipes (Playwright)
|
|
627
|
+
|
|
628
|
+
Wire the harness once, passing your own Playwright `test`/`expect` (so
|
|
629
|
+
`@rangojs/router/testing/e2e` never imports `@playwright/test` at runtime — it is
|
|
630
|
+
an optional peer you install). Import the harness from the **`/e2e` entry** — the
|
|
631
|
+
unit barrel is not loadable in a plain Playwright runner:
|
|
632
|
+
|
|
633
|
+
```ts
|
|
634
|
+
// e2e/helper.ts
|
|
635
|
+
import { test, expect } from "@playwright/test";
|
|
636
|
+
import { createRangoE2E } from "@rangojs/router/testing/e2e";
|
|
637
|
+
|
|
638
|
+
export const e2e = createRangoE2E({
|
|
639
|
+
test,
|
|
640
|
+
expect,
|
|
641
|
+
defaultRoot: new URL("..", import.meta.url).pathname, // your app root
|
|
642
|
+
});
|
|
643
|
+
export const { useFixture, parityDescribe, expectParity, rangoMatchers } = e2e;
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
### parityDescribe REPLACES hand-titling `(production)`
|
|
647
|
+
|
|
648
|
+
This is THE mechanism that satisfies the dev+prod mandate structurally. One
|
|
649
|
+
declaration registers a dev describe (`name`) AND a production describe
|
|
650
|
+
(`` `${name} (production)` ``) from one body — the `(production)` suffix is
|
|
651
|
+
generated, so the prod suite can never drift into the dev bucket. Use `f.url(...)`
|
|
652
|
+
for navigation.
|
|
653
|
+
|
|
654
|
+
```ts
|
|
655
|
+
import { test, expect } from "@playwright/test";
|
|
656
|
+
import { parityDescribe, rangoMatchers } from "./helper";
|
|
657
|
+
// rangoMatchers ships the type augmentation, so `expect(page).toHaveRangoPathname`
|
|
658
|
+
// is typed after extend.
|
|
659
|
+
expect.extend(rangoMatchers);
|
|
660
|
+
|
|
661
|
+
parityDescribe("product navigation", (f) => {
|
|
662
|
+
test("navigates to a product and updates the pathname", async ({ page }) => {
|
|
663
|
+
await page.goto(f.url("/"));
|
|
664
|
+
await page.getByTestId("product-link").click();
|
|
665
|
+
await expect(page).toHaveRangoPathname("/products/1");
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
The body runs verbatim against a dev server (`pnpm dev`) and a built+previewed
|
|
671
|
+
server (`pnpm build` + `pnpm preview`). `useFixture` handles spawn, dep-optimizer
|
|
672
|
+
warmup, cross-platform process-group kill, and teardown.
|
|
673
|
+
|
|
674
|
+
### expectParity — JS path vs no-JS progressive enhancement
|
|
675
|
+
|
|
676
|
+
Runs one intent over the JS path and a fresh no-JS context, asserting the
|
|
677
|
+
observed testids, pathname, and cookies match. CONTRACT: PE parity only holds if
|
|
678
|
+
the submit target is a real `<form>` (no-JS does a native POST). Cookie
|
|
679
|
+
observation is `document.cookie` (non-HttpOnly only) in v1.
|
|
680
|
+
|
|
681
|
+
```ts
|
|
682
|
+
parityDescribe("add to cart parity", (f) => {
|
|
683
|
+
test("JS and no-JS produce the same result", async ({ page }) => {
|
|
684
|
+
await page.goto(f.url("/products/1"));
|
|
685
|
+
await expectParity(
|
|
686
|
+
page,
|
|
687
|
+
{ submit: { testId: "add-to-cart-form", data: { qty: "2" } } },
|
|
688
|
+
{ observe: ["cart-count", "flash"] },
|
|
689
|
+
);
|
|
690
|
+
});
|
|
691
|
+
});
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
`intent` is `{ navigate: string }` or `{ submit: { testId, data? } }`. Other
|
|
695
|
+
helpers from `createRangoE2E`: `waitForHydration`, `expectNoReload`,
|
|
696
|
+
`expectNoPageError`, `testId`, `waitForNavigation`, `goBack`/`goForward`,
|
|
697
|
+
`testNoJs` (a `test` with JS disabled). `rangoMatchers` ships
|
|
698
|
+
`toHaveRangoPathname` only — `toHaveSegments`/`toHaveParams` are a documented
|
|
699
|
+
future addition (they need a client-emitted signal that does not exist yet; do
|
|
700
|
+
not assume them).
|
|
701
|
+
|
|
702
|
+
## Cache / SWR / prerender recipes
|
|
703
|
+
|
|
704
|
+
The `X-Rango-Cache` header is emitted **only** when the gate is on:
|
|
705
|
+
`createRouter({ debugCacheSignal: true })` or `process.env.RANGO_TEST_SIGNALS === "1"`.
|
|
706
|
+
Off by default — zero production surface. v1 status is COARSE (route-level, keyed
|
|
707
|
+
by the route key — the route NAME, e.g. `product.detail`, NOT the URL pattern),
|
|
708
|
+
not per-individual-segment. `assertCacheStatus` reads that header.
|
|
709
|
+
|
|
710
|
+
```ts
|
|
711
|
+
// In a Playwright e2e, import cache-status helpers from the e2e entry (the
|
|
712
|
+
// `@rangojs/router/testing` barrel is Vitest-only — it pulls a build virtual).
|
|
713
|
+
import { assertCacheStatus } from "@rangojs/router/testing/e2e";
|
|
714
|
+
|
|
715
|
+
// e2e (the gate must be enabled on the app under test). The segment key is the
|
|
716
|
+
// route NAME the header carries, not the URL pattern ("/products/:id").
|
|
717
|
+
const res = await page.request.get(f.url("/products/1"));
|
|
718
|
+
assertCacheStatus(res, "product.detail", "miss");
|
|
719
|
+
const res2 = await page.request.get(f.url("/products/1"));
|
|
720
|
+
assertCacheStatus(res2, "product.detail", "hit");
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
Statuses: `"hit" | "miss" | "stale" | "prerendered" | "passthrough"`.
|
|
724
|
+
|
|
725
|
+
Zero-prod-surface alternative — the telemetry sink (no header at all):
|
|
726
|
+
|
|
727
|
+
```ts
|
|
728
|
+
import { createCacheSink, filterCacheDecisions } from "@rangojs/router/testing";
|
|
729
|
+
const { sink, events } = createCacheSink();
|
|
730
|
+
const router = createRouter({ telemetry: sink /* ... */ }).routes(urlpatterns);
|
|
731
|
+
// ...drive a request...
|
|
732
|
+
const decisions = filterCacheDecisions(events);
|
|
733
|
+
expect(decisions[0].segments?.[0].cacheStatus).toBe("hit");
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
PRERENDER: a pre-rendered route is **indistinguishable from a cache hit by
|
|
737
|
+
design** — the worker handles every request and looks up a stored Flight payload
|
|
738
|
+
(see `/prerender`). The browser cannot tell. So you cannot assert "prerendered"
|
|
739
|
+
from the rendered DOM; assert it via the signal (`assertCacheStatus(res, seg,
|
|
740
|
+
"prerendered")`), and run prerender assertions in **production** mode (build-time
|
|
741
|
+
artifacts only exist after `pnpm build`).
|
|
742
|
+
|
|
743
|
+
## Anti-patterns and gotchas
|
|
744
|
+
|
|
745
|
+
- **No dev-only e2e.** A `useFixture({ mode: "build" })` describe whose title
|
|
746
|
+
omits `(production)` silently lands in the dev bucket — prod coverage lost,
|
|
747
|
+
no error. Always use `parityDescribe`; never hand-title. `(prod)`,
|
|
748
|
+
`-build`, `-prod` do NOT count — the bucketing matches the literal
|
|
749
|
+
`(production)`.
|
|
750
|
+
- **Don't hand-mock the router provider** to test a client component — use
|
|
751
|
+
`renderRoute`, which mounts the real `NavigationProvider`.
|
|
752
|
+
- **Don't call `createLoader(...)` in a unit test** and try to invoke it.
|
|
753
|
+
Extract the body and pass it to `runLoader`.
|
|
754
|
+
- **`dispatch` needs the plugin-rsc mock** (or a Vite-RSC env). A bare import of
|
|
755
|
+
your router throws on Vite virtual modules otherwise.
|
|
756
|
+
- **`renderToFlightString` is not a node test.** It only runs under the
|
|
757
|
+
react-server vitest project; name files `*.rsc-test.{ts,tsx}` and run
|
|
758
|
+
`pnpm test:unit:rsc`. The main vitest project must NOT set the react-server
|
|
759
|
+
condition (it would flip React to the no-hooks server build and break every
|
|
760
|
+
`renderRoute`/client test).
|
|
761
|
+
- **Running an e2e subset:** add `--no-deps` — `--grep` does NOT filter
|
|
762
|
+
dependency projects, so grepping one production test otherwise pulls in the
|
|
763
|
+
whole dev suite. And `--grep` is a regex: a pasted title containing
|
|
764
|
+
`(production)` / `:locale?` / `[...]` mis-matches; grep a metacharacter-free
|
|
765
|
+
fragment.
|
|
766
|
+
|
|
767
|
+
## Pre-push checklist (mirror CLAUDE.md)
|
|
768
|
+
|
|
769
|
+
Before pushing, run all of these and fix any failure:
|
|
770
|
+
|
|
771
|
+
1. `pnpm run typecheck` (or `pnpm exec tsc --noEmit`)
|
|
772
|
+
2. `pnpm run test:unit` (node + DOM vitest)
|
|
773
|
+
3. `pnpm run test:unit:rsc` (the react-server Flight project)
|
|
774
|
+
4. `pnpm run lint`
|
|
775
|
+
5. `pnpm run format`
|
|
776
|
+
|
|
777
|
+
And: **every e2e has a production counterpart.** `parityDescribe` makes this
|
|
778
|
+
automatic — if you wrote a plain `test.describe` for a behavior, convert it.
|