@rangojs/router 0.0.0-experimental.d7eeaa75 → 0.0.0-experimental.dc2bd2b4
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 +48 -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 +647 -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 +2 -5
- 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 +21 -0
- package/src/testing/flight.entry.ts +22 -0
- package/src/testing/flight.ts +182 -0
- package/src/testing/generated-routes.ts +223 -0
- package/src/testing/index.ts +105 -0
- package/src/testing/internal/context.ts +193 -0
- package/src/testing/render-route.tsx +536 -0
- package/src/testing/run-loader.ts +296 -0
- package/src/testing/run-middleware.ts +170 -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 +183 -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,647 @@
|
|
|
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` (pass the **raw fn**, not `createLoader`) | `@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
|
+
| navigation, hydration, PE parity, view transitions, real SSR | e2e | `createRangoE2E` -> `parityDescribe`/`expectParity` | `@rangojs/router/testing/e2e` |
|
|
99
|
+
| cache hit/miss/stale, prerender (= a cache hit by design) | e2e + signal | `assertCacheStatus` / telemetry sink (gate on) | `@rangojs/router/testing` |
|
|
100
|
+
| generated route map drift vs runtime | unit (node) | `assertGeneratedRoutesMatch` | `@rangojs/router/testing` |
|
|
101
|
+
|
|
102
|
+
Cross-references: `/loader`, `/middleware`, `/server-actions`, `/caching`,
|
|
103
|
+
`/prerender`, `/typesafety`.
|
|
104
|
+
|
|
105
|
+
## Unit recipes (vitest, node)
|
|
106
|
+
|
|
107
|
+
### runMiddleware — ordering, short-circuit, cookie/header merge
|
|
108
|
+
|
|
109
|
+
Runs the chain through the router's **real** `executeMiddleware`, so
|
|
110
|
+
`next()`, return-Response short-circuit, throw-Response short-circuit,
|
|
111
|
+
double-next guards, and header/cookie merging behave exactly as in production.
|
|
112
|
+
`nextCalled` is `0` on short-circuit, `1` on pass-through. The returned `ctx` is
|
|
113
|
+
the underlying `RequestContext` — read `ctx.cookies()`, `ctx.get(...)`,
|
|
114
|
+
`ctx.res.headers`.
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import { describe, it, expect } from "vitest";
|
|
118
|
+
import { runMiddleware } from "@rangojs/router/testing";
|
|
119
|
+
import type { Middleware } from "@rangojs/router";
|
|
120
|
+
|
|
121
|
+
const requireUser: Middleware = async (ctx, next) => {
|
|
122
|
+
if (!ctx.get("user")) return new Response(null, { status: 401 });
|
|
123
|
+
return next();
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
it("passes through when the user is present", async () => {
|
|
127
|
+
const { response, nextCalled } = await runMiddleware(
|
|
128
|
+
requireUser,
|
|
129
|
+
"/dashboard",
|
|
130
|
+
{
|
|
131
|
+
vars: { user: { id: 1 } }, // object form; or [[key, value]] tuples (key may be a createVar())
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
expect(nextCalled).toBe(1);
|
|
135
|
+
expect(response.status).toBe(200);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("short-circuits (return OR throw Response) when unauthenticated", async () => {
|
|
139
|
+
const { response, nextCalled } = await runMiddleware(
|
|
140
|
+
requireUser,
|
|
141
|
+
"/dashboard",
|
|
142
|
+
);
|
|
143
|
+
expect(nextCalled).toBe(0);
|
|
144
|
+
expect(response.status).toBe(401);
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Seed prior-middleware state with `vars` (string key or `createVar()` handle).
|
|
149
|
+
Model the downstream route with `next`. Enable `ctx.reverse(...)` by passing
|
|
150
|
+
`routeMap` (and `routeName` for scoped `.name` resolution). Pass an array to run
|
|
151
|
+
several in order. Cookies set via `cookies().set(...)` surface both on
|
|
152
|
+
`ctx.cookies()` and on the merged response `Set-Cookie`.
|
|
153
|
+
|
|
154
|
+
### runLoader — one loader's data logic
|
|
155
|
+
|
|
156
|
+
Pass the **RAW loader function** `(ctx) => ...`, NOT a `createLoader(...)`
|
|
157
|
+
handle. `createLoader` relies on the Vite `$$id` injection for RSC
|
|
158
|
+
registration, which does not exist in a bare vitest process — calling it gives
|
|
159
|
+
you a handle with no `fn` to run. `runLoader` invokes your function directly
|
|
160
|
+
against a real `RequestContext`, so cookies, headers, `ctx.get`, and
|
|
161
|
+
`ctx.reverse` resolve.
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
import { runLoader } from "@rangojs/router/testing";
|
|
165
|
+
import { createVar } from "@rangojs/router";
|
|
166
|
+
|
|
167
|
+
// CORRECT: test the function body directly.
|
|
168
|
+
async function productLoaderBody(ctx) {
|
|
169
|
+
return { id: ctx.params.id, region: ctx.env.REGION, user: ctx.get(User) };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
it("reads params, env, and seeded vars", async () => {
|
|
173
|
+
const User = createVar<{ name: string }>();
|
|
174
|
+
const data = await runLoader(productLoaderBody, {
|
|
175
|
+
params: { id: "42" },
|
|
176
|
+
env: { REGION: "eu" },
|
|
177
|
+
vars: [[User, { name: "Ada" }]],
|
|
178
|
+
});
|
|
179
|
+
expect(data).toEqual({ id: "42", region: "eu", user: { name: "Ada" } });
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Options: `params` (also surfaced as `routeParams`), `search`, `env`, `vars`,
|
|
184
|
+
`method`/`body`/`formData`, `routeMap`/`routeName` (for `ctx.reverse`), and
|
|
185
|
+
`use` (a resolver for `ctx.use(OtherLoader)` composition — without it, `ctx.use`
|
|
186
|
+
runs the dependency's own `fn` if it carries one).
|
|
187
|
+
|
|
188
|
+
Two unit-only limitations to document in your test, not work around:
|
|
189
|
+
|
|
190
|
+
- `ctx.reverse(...)` **throws** unless you pass `routeMap`.
|
|
191
|
+
- `ctx.rendered()` **throws** (the DSL render barrier only exists in a full
|
|
192
|
+
match) and `ctx.isAction(...)` (the action-render context) is not available —
|
|
193
|
+
test those with `renderToFlightString` or e2e.
|
|
194
|
+
|
|
195
|
+
If your real loader source is `export const L = createLoader(async (ctx) => {...})`,
|
|
196
|
+
extract the inner async function so it is importable on its own, and register
|
|
197
|
+
the `createLoader` wrapper in `urls()`. Then `runLoader` tests the body and the
|
|
198
|
+
DSL/e2e tests cover registration.
|
|
199
|
+
|
|
200
|
+
COOKIE SEEDING: there is no `cookies`/`headers` option — seed a request cookie by
|
|
201
|
+
passing a full `Request` with the header, `runLoader(body, { request: new
|
|
202
|
+
Request("https://app.test/", { headers: { Cookie: "sid=abc" } }) })`. A loader
|
|
203
|
+
that reads `cookies()` then sees `abc`. (`search`/`method` are baked onto this
|
|
204
|
+
request for you, so pass a `Request` only when you need headers/cookies.)
|
|
205
|
+
|
|
206
|
+
### runInRequestContext — an action (or any fn) that reads request context
|
|
207
|
+
|
|
208
|
+
For a server ACTION (or any function) that authenticates off the request cookie
|
|
209
|
+
and calls `getRequestContext()` / `cookies()` but has no loader-context shape,
|
|
210
|
+
`runInRequestContext(fn, opts)` builds a real `RequestContext` (same `opts` as the
|
|
211
|
+
other primitives — `env`, `request`, `vars`, ...) AND enters it, so the function
|
|
212
|
+
runs exactly as in production. Its return value (including a promise) passes
|
|
213
|
+
straight through; the context stays active across awaits.
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
import { runInRequestContext } from "@rangojs/router/testing";
|
|
217
|
+
import { authorizeTenantAction } from "../src/actions/authorize"; // reads cookies()
|
|
218
|
+
|
|
219
|
+
it("authorizes when the session cookie is present", async () => {
|
|
220
|
+
const session = await runInRequestContext(
|
|
221
|
+
() => authorizeTenantAction(input),
|
|
222
|
+
{
|
|
223
|
+
env,
|
|
224
|
+
request: new Request("https://app.test/admin", {
|
|
225
|
+
headers: { Cookie: "sid=abc" },
|
|
226
|
+
}),
|
|
227
|
+
},
|
|
228
|
+
);
|
|
229
|
+
expect(session).toMatchObject({ tenant: "acme" });
|
|
230
|
+
});
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
For the low-level case where you already hold a context from
|
|
234
|
+
`createTestRequestContext(...)`, `runWithRequestContext(ctx, fn)` is re-exported
|
|
235
|
+
from `@rangojs/router/testing` to enter it directly; `runInRequestContext` is the
|
|
236
|
+
one-call convenience over the two.
|
|
237
|
+
|
|
238
|
+
### Your bindings are your seam (env.DB / Durable Objects / R2)
|
|
239
|
+
|
|
240
|
+
The node primitives test the router's seams; the moment your loader/middleware/
|
|
241
|
+
action calls a **platform binding** (`env.DB`, a Durable Object stub, `env.R2`),
|
|
242
|
+
you have crossed out of rango and into your app's I/O. rango deliberately ships
|
|
243
|
+
**no doubles** for these — they are app- and schema-specific — so the double is
|
|
244
|
+
yours to build and inject through the `env` option every primitive already takes:
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
await runLoader(bundleLoaderBody, { env: { DB: fakeD1 } });
|
|
248
|
+
await runMiddleware(requireMembership, "/t/acme/edit", { env: { DB: fakeD1 } });
|
|
249
|
+
await runInRequestContext(() => authorizeAction(input), {
|
|
250
|
+
env: { DB: fakeD1 },
|
|
251
|
+
request,
|
|
252
|
+
});
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Plan for this seam — it is usually the single biggest effort in a consumer unit
|
|
256
|
+
suite, and the work is in matching the **driver contract**, not the binding's
|
|
257
|
+
public API. The sharp edge: a `D1Database` double for **`drizzle-orm/d1`** must
|
|
258
|
+
serve **positional row arrays in schema-column order** for drizzle's `.raw()`
|
|
259
|
+
path (with the driver-level encodings so the decoder round-trips `Date`/JSON) —
|
|
260
|
+
NOT `{ column: value }` objects. A naive object-shaped double returns
|
|
261
|
+
silently-wrong or empty rows. Keep the double at the binding boundary; never mock
|
|
262
|
+
a rango primitive to dodge building it.
|
|
263
|
+
|
|
264
|
+
### renderRoute — a client component reading router context
|
|
265
|
+
|
|
266
|
+
RTL-style stub. Peer of React Router's `createRoutesStub` / Expo's
|
|
267
|
+
`renderRouter`. It mounts the router's real `NavigationProvider` plus a
|
|
268
|
+
synthetic segment tree so `useParams`, `useReverse`, `useNavigation`, `Outlet`,
|
|
269
|
+
`usePathname`, `useSearchParams`, and `useLoader`/`useFetchLoader` (reading
|
|
270
|
+
**seeded** data) resolve — no server, no Vite, no Flight round-trip. It is
|
|
271
|
+
`async` (lazy-loads `@testing-library/react`).
|
|
272
|
+
|
|
273
|
+
```tsx
|
|
274
|
+
// @vitest-environment happy-dom
|
|
275
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
276
|
+
import { cleanup } from "@testing-library/react";
|
|
277
|
+
import { renderRoute } from "@rangojs/router/testing/dom";
|
|
278
|
+
import { Outlet, useParams, useReverse } from "@rangojs/router/client";
|
|
279
|
+
|
|
280
|
+
afterEach(cleanup);
|
|
281
|
+
|
|
282
|
+
function Layout() {
|
|
283
|
+
return (
|
|
284
|
+
<div>
|
|
285
|
+
<span data-testid="shell">shell</span>
|
|
286
|
+
<Outlet />
|
|
287
|
+
</div>
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
function Product() {
|
|
291
|
+
const { productId } = useParams<{ productId: string }>();
|
|
292
|
+
const reverse = useReverse({ product: "/products/:productId" });
|
|
293
|
+
return (
|
|
294
|
+
<a data-testid="link" href={reverse("product", { productId: "2" })}>
|
|
295
|
+
{productId}
|
|
296
|
+
</a>
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
it("resolves params + reverse + Outlet through the layout chain", async () => {
|
|
301
|
+
const { getByTestId, router } = await renderRoute(
|
|
302
|
+
[
|
|
303
|
+
{ path: "/products", Component: Layout }, // layout (root)
|
|
304
|
+
{ path: "/products/:productId", Component: Product }, // leaf (last)
|
|
305
|
+
],
|
|
306
|
+
{ initialUrl: "/products/1" },
|
|
307
|
+
);
|
|
308
|
+
expect(getByTestId("shell").textContent).toBe("shell");
|
|
309
|
+
expect(getByTestId("link").getAttribute("href")).toBe("/products/2");
|
|
310
|
+
|
|
311
|
+
await router.navigate("/products/2"); // client-only nav, re-resolves the same routes
|
|
312
|
+
expect(router.pathname()).toBe("/products/2");
|
|
313
|
+
});
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
`RenderRouteSpec = { path, Component, layout?, loaderIds?, name? }`. The array
|
|
317
|
+
is the layout chain root-to-leaf; the **last** entry is the leaf route. Seed
|
|
318
|
+
loader reads with `options.loaderData` keyed by the loader's `$$id`; attach a
|
|
319
|
+
loader to a specific layout via that spec's `loaderIds`:
|
|
320
|
+
|
|
321
|
+
```tsx
|
|
322
|
+
const CartLoader = {
|
|
323
|
+
__brand: "loader",
|
|
324
|
+
$$id: "loaders/cart#CartLoader",
|
|
325
|
+
} as any;
|
|
326
|
+
await renderRoute(
|
|
327
|
+
[
|
|
328
|
+
{ path: "/shop", Component: CartLayout, loaderIds: [CartLoader.$$id] },
|
|
329
|
+
{ path: "/shop/item", Component: Page },
|
|
330
|
+
],
|
|
331
|
+
{ initialUrl: "/shop/item", loaderData: { [CartLoader.$$id]: { count: 3 } } },
|
|
332
|
+
);
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
FIDELITY CAVEAT — this is the **client tree only**. It does NOT catch
|
|
336
|
+
server/client boundary reference-identity remount bugs, real Flight
|
|
337
|
+
serialization errors, loader execution, middleware, or handler ordering. Those
|
|
338
|
+
are `renderToFlightString` / e2e territory. Loader data is seeded, never run.
|
|
339
|
+
Needs a DOM env (`// @vitest-environment happy-dom`, or jsdom) and the consumer
|
|
340
|
+
must install `@testing-library/react` (optional peer).
|
|
341
|
+
|
|
342
|
+
CATCH — streaming `use(promise)` Suspense content (e.g. an async breadcrumb
|
|
343
|
+
`content: Promise<ReactNode>`): a plain `Promise.resolve(node)` does NOT flush
|
|
344
|
+
its Suspense retry in RTL/happy-dom (renderRoute renders internally, not inside
|
|
345
|
+
an awaited `act`), so the DOM stays on the fallback. Assert the **pending**
|
|
346
|
+
fallback with a never-resolving `new Promise(() => {})`; for the **arrived**
|
|
347
|
+
state pass an already-settled promise so `use()` reads it synchronously:
|
|
348
|
+
`const p = Promise.resolve(node) as any; p.status = "fulfilled"; p.value = node;`.
|
|
349
|
+
The real pending→resolved transition is an e2e concern.
|
|
350
|
+
|
|
351
|
+
ARIA GOTCHA — query a `<Link>` by `getByRole("link")` only when it renders a bare
|
|
352
|
+
anchor. An explicit `role` on the link (e.g. `<Link role="tab">` in a tablist)
|
|
353
|
+
OVERRIDES the anchor's implicit `link` role, so `getByRole("link")` finds
|
|
354
|
+
nothing — query the explicit role (`getByRole("tab")`) or fall back to
|
|
355
|
+
`getByText`/`getByTestId` and assert `getAttribute("href")`.
|
|
356
|
+
|
|
357
|
+
### Type-level test: a reverse misuse should fail to compile
|
|
358
|
+
|
|
359
|
+
`reverse`/`href` are compile-time checked (`/typesafety`). Pin that contract
|
|
360
|
+
with `@ts-expect-error` — a _runtime_ test cannot.
|
|
361
|
+
|
|
362
|
+
```ts
|
|
363
|
+
import { useReverse } from "@rangojs/router/client";
|
|
364
|
+
const reverse = useReverse({ product: "/products/:productId" });
|
|
365
|
+
reverse("product", { productId: "2" }); // ok
|
|
366
|
+
// @ts-expect-error missing required param
|
|
367
|
+
reverse("product", {});
|
|
368
|
+
// @ts-expect-error unknown route name
|
|
369
|
+
reverse("nope", {});
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
## Integration recipes
|
|
373
|
+
|
|
374
|
+
### dispatch — request -> Response, without Flight
|
|
375
|
+
|
|
376
|
+
In-process matching + middleware, no RSC render. Covers `308` redirects
|
|
377
|
+
(trailing slash etc.) with `Location`, `404`, response routes
|
|
378
|
+
(json/text/html/xml/md with content negotiation), and **global + route-level
|
|
379
|
+
middleware** short-circuits with full `next()`/throw/header+cookie fidelity. It
|
|
380
|
+
reuses the router's own `previewMatch`, so middleware collection is the router's,
|
|
381
|
+
not a re-implementation. Hitting an RSC (component) route throws a clear
|
|
382
|
+
directive error.
|
|
383
|
+
|
|
384
|
+
SETUP CAVEAT (use the preset): `@rangojs/router` resolves to server-only STUBS
|
|
385
|
+
outside the `react-server` condition (urls/createRouter/cookies/getRequestContext
|
|
386
|
+
throw), and importing your router also pulls `@vitejs/plugin-rsc/rsc` (whose body
|
|
387
|
+
imports Vite virtuals). Vitest does not apply the `react-server` condition to
|
|
388
|
+
bare-package resolution. The preset `@rangojs/router/testing/vitest` handles all
|
|
389
|
+
of it — alias `@rangojs/router` to real impls + stub the virtuals — so no
|
|
390
|
+
per-file `vi.mock` is needed. Spread `rangoTestConfig(...)` into your `test`
|
|
391
|
+
block:
|
|
392
|
+
|
|
393
|
+
```ts
|
|
394
|
+
// vitest.config.ts
|
|
395
|
+
import { defineConfig } from "vitest/config";
|
|
396
|
+
import { rangoTestConfig } from "@rangojs/router/testing/vitest";
|
|
397
|
+
export default defineConfig({
|
|
398
|
+
test: {
|
|
399
|
+
globals: true,
|
|
400
|
+
include: ["test/**/*.test.{ts,tsx}"],
|
|
401
|
+
environment: "node",
|
|
402
|
+
...rangoTestConfig({ cloudflare: true }),
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
`rangoTestConfig` returns BOTH the resolve `alias` entries AND
|
|
408
|
+
`server.deps.inline: [/@rangojs[/\\]router/]`. The `deps.inline` half is
|
|
409
|
+
mandatory for an installed (node_modules) consumer: `@rangojs/router` ships as
|
|
410
|
+
TypeScript source, Vitest externalizes node_modules by default, and Node >= 23
|
|
411
|
+
refuses to type-strip `.ts` under `node_modules`
|
|
412
|
+
(`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`) — `deps.inline` forces Vite (not
|
|
413
|
+
Node) to transpile rango's source. The preset entry itself ships as compiled JS,
|
|
414
|
+
so the `import { rangoTestConfig }` line loads under plain Node config loading.
|
|
415
|
+
(If you need only the aliases, `rangoTestAliases(...)` is still exported, but then
|
|
416
|
+
you must wire `server.deps.inline` yourself.)
|
|
417
|
+
|
|
418
|
+
LIMITATION: the FULL router usually can't be imported in a bare test —
|
|
419
|
+
`Prerender()`/`createLoader()` need the plugin-injected `$$id` (real `Prerender()`
|
|
420
|
+
throws "missing $$id"). Build a router from a `Prerender`-free include (your API
|
|
421
|
+
routes); `dispatch` accepts the public router type with no cast:
|
|
422
|
+
|
|
423
|
+
```ts
|
|
424
|
+
import { describe, it, expect } from "vitest";
|
|
425
|
+
import { dispatch } from "@rangojs/router/testing";
|
|
426
|
+
import { createRouter } from "@rangojs/router";
|
|
427
|
+
import { apiPatterns } from "../src/api/urls"; // path.json(...) routes only
|
|
428
|
+
|
|
429
|
+
const router = createRouter().routes(apiPatterns);
|
|
430
|
+
|
|
431
|
+
it("serializes a JSON response route (auto-wrapped under data)", async () => {
|
|
432
|
+
const res = await dispatch(router, "/health");
|
|
433
|
+
expect(res.status).toBe(200);
|
|
434
|
+
expect(await res.json()).toEqual({ data: { status: "ok" } });
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("maps a thrown RouterError to its status + typed JSON envelope", async () => {
|
|
438
|
+
const res = await dispatch(router, "/products/999");
|
|
439
|
+
expect(res.status).toBe(404);
|
|
440
|
+
expect((await res.json()).error.code).toBe("NOT_FOUND");
|
|
441
|
+
});
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
### renderToFlightString — real async Server Component
|
|
445
|
+
|
|
446
|
+
A REAL Flight render of an async Server Component, in plain node — but ONLY
|
|
447
|
+
under the `react-server` condition (see the next section for the vitest
|
|
448
|
+
project). The render runs inside a request context, so async components can call
|
|
449
|
+
`getRequestContext()`, read params, cookies, etc.
|
|
450
|
+
|
|
451
|
+
```tsx
|
|
452
|
+
// flight.rsc-test.tsx (note the *.rsc-test suffix)
|
|
453
|
+
import { describe, it, expect } from "vitest";
|
|
454
|
+
import { renderToFlightString } from "@rangojs/router/testing/flight";
|
|
455
|
+
// Matchers are a SEPARATE subpath (they import vitest); renderToFlightString does not.
|
|
456
|
+
import { flightMatchers } from "@rangojs/router/testing/flight-matchers";
|
|
457
|
+
|
|
458
|
+
expect.extend(flightMatchers);
|
|
459
|
+
|
|
460
|
+
// Keep components PURE leaves: take data as props. Do NOT import a server API
|
|
461
|
+
// (getRequestContext, cookies) from the `@rangojs/router` barrel — under the
|
|
462
|
+
// react-server condition the bare specifier resolves to the throwing stub, so
|
|
463
|
+
// it cannot be flight-tested in a bare consumer project.
|
|
464
|
+
async function Greeting({ name }: { name: string }) {
|
|
465
|
+
await Promise.resolve();
|
|
466
|
+
return <div>Hello {name}!</div>;
|
|
467
|
+
}
|
|
468
|
+
async function ItemView({ id }: { id: string }) {
|
|
469
|
+
return <span>id={id}</span>;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
it("renders text and props", async () => {
|
|
473
|
+
expect(await renderToFlightString(<Greeting name="Ada" />)).toMatchFlight(
|
|
474
|
+
"Ada",
|
|
475
|
+
);
|
|
476
|
+
expect(await renderToFlightString(<ItemView id="42" />)).toMatchFlight("42");
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("matches a normalized snapshot", async () => {
|
|
480
|
+
expect(
|
|
481
|
+
await renderToFlightString(<Greeting name="World" />),
|
|
482
|
+
).toMatchFlightSnapshot();
|
|
483
|
+
});
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
`toMatchFlight(substring)` asserts the normalized Flight string CONTAINS the
|
|
487
|
+
substring (containment, not equality — the row framing is an internal serializer
|
|
488
|
+
detail). `toMatchFlightSnapshot()` snapshots the normalized payload. SCOPE:
|
|
489
|
+
server-only / leaf trees — a client component emits an unresolved `I[...]` import
|
|
490
|
+
row against the empty client manifest (fine for snapshotting shape, not
|
|
491
|
+
hydratable). A true interactive, clickable DOM `renderServer` is a DEFERRED
|
|
492
|
+
follow-up: the react-server-vs-default condition wall requires a two-environment
|
|
493
|
+
setup. For interactive server-component behavior today, use e2e.
|
|
494
|
+
|
|
495
|
+
## E2E recipes (Playwright)
|
|
496
|
+
|
|
497
|
+
Wire the harness once, passing your own Playwright `test`/`expect` (so
|
|
498
|
+
`@rangojs/router/testing/e2e` never imports `@playwright/test` at runtime — it is
|
|
499
|
+
an optional peer you install). Import the harness from the **`/e2e` entry** — the
|
|
500
|
+
unit barrel is not loadable in a plain Playwright runner:
|
|
501
|
+
|
|
502
|
+
```ts
|
|
503
|
+
// e2e/helper.ts
|
|
504
|
+
import { test, expect } from "@playwright/test";
|
|
505
|
+
import { createRangoE2E } from "@rangojs/router/testing/e2e";
|
|
506
|
+
|
|
507
|
+
export const e2e = createRangoE2E({
|
|
508
|
+
test,
|
|
509
|
+
expect,
|
|
510
|
+
defaultRoot: new URL("..", import.meta.url).pathname, // your app root
|
|
511
|
+
});
|
|
512
|
+
export const { useFixture, parityDescribe, expectParity, rangoMatchers } = e2e;
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
### parityDescribe REPLACES hand-titling `(production)`
|
|
516
|
+
|
|
517
|
+
This is THE mechanism that satisfies the dev+prod mandate structurally. One
|
|
518
|
+
declaration registers a dev describe (`name`) AND a production describe
|
|
519
|
+
(`` `${name} (production)` ``) from one body — the `(production)` suffix is
|
|
520
|
+
generated, so the prod suite can never drift into the dev bucket. Use `f.url(...)`
|
|
521
|
+
for navigation.
|
|
522
|
+
|
|
523
|
+
```ts
|
|
524
|
+
import { test, expect } from "@playwright/test";
|
|
525
|
+
import { parityDescribe, rangoMatchers } from "./helper";
|
|
526
|
+
// rangoMatchers ships the type augmentation, so `expect(page).toHaveRangoPathname`
|
|
527
|
+
// is typed after extend.
|
|
528
|
+
expect.extend(rangoMatchers);
|
|
529
|
+
|
|
530
|
+
parityDescribe("product navigation", (f) => {
|
|
531
|
+
test("navigates to a product and updates the pathname", async ({ page }) => {
|
|
532
|
+
await page.goto(f.url("/"));
|
|
533
|
+
await page.getByTestId("product-link").click();
|
|
534
|
+
await expect(page).toHaveRangoPathname("/products/1");
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
The body runs verbatim against a dev server (`pnpm dev`) and a built+previewed
|
|
540
|
+
server (`pnpm build` + `pnpm preview`). `useFixture` handles spawn, dep-optimizer
|
|
541
|
+
warmup, cross-platform process-group kill, and teardown.
|
|
542
|
+
|
|
543
|
+
### expectParity — JS path vs no-JS progressive enhancement
|
|
544
|
+
|
|
545
|
+
Runs one intent over the JS path and a fresh no-JS context, asserting the
|
|
546
|
+
observed testids, pathname, and cookies match. CONTRACT: PE parity only holds if
|
|
547
|
+
the submit target is a real `<form>` (no-JS does a native POST). Cookie
|
|
548
|
+
observation is `document.cookie` (non-HttpOnly only) in v1.
|
|
549
|
+
|
|
550
|
+
```ts
|
|
551
|
+
parityDescribe("add to cart parity", (f) => {
|
|
552
|
+
test("JS and no-JS produce the same result", async ({ page }) => {
|
|
553
|
+
await page.goto(f.url("/products/1"));
|
|
554
|
+
await expectParity(
|
|
555
|
+
page,
|
|
556
|
+
{ submit: { testId: "add-to-cart-form", data: { qty: "2" } } },
|
|
557
|
+
{ observe: ["cart-count", "flash"] },
|
|
558
|
+
);
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
`intent` is `{ navigate: string }` or `{ submit: { testId, data? } }`. Other
|
|
564
|
+
helpers from `createRangoE2E`: `waitForHydration`, `expectNoReload`,
|
|
565
|
+
`expectNoPageError`, `testId`, `waitForNavigation`, `goBack`/`goForward`,
|
|
566
|
+
`testNoJs` (a `test` with JS disabled). `rangoMatchers` ships
|
|
567
|
+
`toHaveRangoPathname` only — `toHaveSegments`/`toHaveParams` are a documented
|
|
568
|
+
future addition (they need a client-emitted signal that does not exist yet; do
|
|
569
|
+
not assume them).
|
|
570
|
+
|
|
571
|
+
## Cache / SWR / prerender recipes
|
|
572
|
+
|
|
573
|
+
The `X-Rango-Cache` header is emitted **only** when the gate is on:
|
|
574
|
+
`createRouter({ debugCacheSignal: true })` or `process.env.RANGO_TEST_SIGNALS === "1"`.
|
|
575
|
+
Off by default — zero production surface. v1 status is COARSE (route-level, keyed
|
|
576
|
+
by the route key — the route NAME, e.g. `product.detail`, NOT the URL pattern),
|
|
577
|
+
not per-individual-segment. `assertCacheStatus` reads that header.
|
|
578
|
+
|
|
579
|
+
```ts
|
|
580
|
+
// In a Playwright e2e, import cache-status helpers from the e2e entry (the
|
|
581
|
+
// `@rangojs/router/testing` barrel is Vitest-only — it pulls a build virtual).
|
|
582
|
+
import { assertCacheStatus } from "@rangojs/router/testing/e2e";
|
|
583
|
+
|
|
584
|
+
// e2e (the gate must be enabled on the app under test). The segment key is the
|
|
585
|
+
// route NAME the header carries, not the URL pattern ("/products/:id").
|
|
586
|
+
const res = await page.request.get(f.url("/products/1"));
|
|
587
|
+
assertCacheStatus(res, "product.detail", "miss");
|
|
588
|
+
const res2 = await page.request.get(f.url("/products/1"));
|
|
589
|
+
assertCacheStatus(res2, "product.detail", "hit");
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
Statuses: `"hit" | "miss" | "stale" | "prerendered" | "passthrough"`.
|
|
593
|
+
|
|
594
|
+
Zero-prod-surface alternative — the telemetry sink (no header at all):
|
|
595
|
+
|
|
596
|
+
```ts
|
|
597
|
+
import { createCacheSink, filterCacheDecisions } from "@rangojs/router/testing";
|
|
598
|
+
const { sink, events } = createCacheSink();
|
|
599
|
+
const router = createRouter({ telemetry: sink /* ... */ }).routes(urlpatterns);
|
|
600
|
+
// ...drive a request...
|
|
601
|
+
const decisions = filterCacheDecisions(events);
|
|
602
|
+
expect(decisions[0].segments?.[0].cacheStatus).toBe("hit");
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
PRERENDER: a pre-rendered route is **indistinguishable from a cache hit by
|
|
606
|
+
design** — the worker handles every request and looks up a stored Flight payload
|
|
607
|
+
(see `/prerender`). The browser cannot tell. So you cannot assert "prerendered"
|
|
608
|
+
from the rendered DOM; assert it via the signal (`assertCacheStatus(res, seg,
|
|
609
|
+
"prerendered")`), and run prerender assertions in **production** mode (build-time
|
|
610
|
+
artifacts only exist after `pnpm build`).
|
|
611
|
+
|
|
612
|
+
## Anti-patterns and gotchas
|
|
613
|
+
|
|
614
|
+
- **No dev-only e2e.** A `useFixture({ mode: "build" })` describe whose title
|
|
615
|
+
omits `(production)` silently lands in the dev bucket — prod coverage lost,
|
|
616
|
+
no error. Always use `parityDescribe`; never hand-title. `(prod)`,
|
|
617
|
+
`-build`, `-prod` do NOT count — the bucketing matches the literal
|
|
618
|
+
`(production)`.
|
|
619
|
+
- **Don't hand-mock the router provider** to test a client component — use
|
|
620
|
+
`renderRoute`, which mounts the real `NavigationProvider`.
|
|
621
|
+
- **Don't call `createLoader(...)` in a unit test** and try to invoke it.
|
|
622
|
+
Extract the body and pass it to `runLoader`.
|
|
623
|
+
- **`dispatch` needs the plugin-rsc mock** (or a Vite-RSC env). A bare import of
|
|
624
|
+
your router throws on Vite virtual modules otherwise.
|
|
625
|
+
- **`renderToFlightString` is not a node test.** It only runs under the
|
|
626
|
+
react-server vitest project; name files `*.rsc-test.{ts,tsx}` and run
|
|
627
|
+
`pnpm test:unit:rsc`. The main vitest project must NOT set the react-server
|
|
628
|
+
condition (it would flip React to the no-hooks server build and break every
|
|
629
|
+
`renderRoute`/client test).
|
|
630
|
+
- **Running an e2e subset:** add `--no-deps` — `--grep` does NOT filter
|
|
631
|
+
dependency projects, so grepping one production test otherwise pulls in the
|
|
632
|
+
whole dev suite. And `--grep` is a regex: a pasted title containing
|
|
633
|
+
`(production)` / `:locale?` / `[...]` mis-matches; grep a metacharacter-free
|
|
634
|
+
fragment.
|
|
635
|
+
|
|
636
|
+
## Pre-push checklist (mirror CLAUDE.md)
|
|
637
|
+
|
|
638
|
+
Before pushing, run all of these and fix any failure:
|
|
639
|
+
|
|
640
|
+
1. `pnpm run typecheck` (or `pnpm exec tsc --noEmit`)
|
|
641
|
+
2. `pnpm run test:unit` (node + DOM vitest)
|
|
642
|
+
3. `pnpm run test:unit:rsc` (the react-server Flight project)
|
|
643
|
+
4. `pnpm run lint`
|
|
644
|
+
5. `pnpm run format`
|
|
645
|
+
|
|
646
|
+
And: **every e2e has a production counterpart.** `parityDescribe` makes this
|
|
647
|
+
automatic — if you wrote a plain `test.describe` for a behavior, convert it.
|