@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.
Files changed (255) hide show
  1. package/README.md +120 -25
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/testing/vitest.js +82 -0
  4. package/dist/vite/index.js +2151 -846
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +57 -11
  7. package/skills/breadcrumbs/SKILL.md +3 -1
  8. package/skills/bundle-analysis/SKILL.md +159 -0
  9. package/skills/cache-guide/SKILL.md +220 -30
  10. package/skills/caching/SKILL.md +116 -8
  11. package/skills/composability/SKILL.md +27 -2
  12. package/skills/document-cache/SKILL.md +78 -55
  13. package/skills/handler-use/SKILL.md +364 -0
  14. package/skills/hooks/SKILL.md +229 -20
  15. package/skills/host-router/SKILL.md +45 -20
  16. package/skills/i18n/SKILL.md +276 -0
  17. package/skills/intercept/SKILL.md +46 -4
  18. package/skills/layout/SKILL.md +28 -7
  19. package/skills/links/SKILL.md +247 -17
  20. package/skills/loader/SKILL.md +219 -9
  21. package/skills/middleware/SKILL.md +47 -12
  22. package/skills/migrate-nextjs/SKILL.md +562 -0
  23. package/skills/migrate-react-router/SKILL.md +769 -0
  24. package/skills/mime-routes/SKILL.md +27 -0
  25. package/skills/observability/SKILL.md +137 -0
  26. package/skills/parallel/SKILL.md +71 -6
  27. package/skills/prerender/SKILL.md +14 -33
  28. package/skills/rango/SKILL.md +242 -22
  29. package/skills/react-compiler/SKILL.md +168 -0
  30. package/skills/response-routes/SKILL.md +66 -9
  31. package/skills/route/SKILL.md +57 -4
  32. package/skills/router-setup/SKILL.md +3 -3
  33. package/skills/server-actions/SKILL.md +751 -0
  34. package/skills/streams-and-websockets/SKILL.md +283 -0
  35. package/skills/testing/SKILL.md +778 -0
  36. package/skills/typesafety/SKILL.md +319 -27
  37. package/skills/use-cache/SKILL.md +34 -5
  38. package/skills/view-transitions/SKILL.md +294 -0
  39. package/src/__augment-tests__/augment.ts +81 -0
  40. package/src/__augment-tests__/augmented.check.ts +117 -0
  41. package/src/browser/action-coordinator.ts +53 -36
  42. package/src/browser/app-shell.ts +52 -0
  43. package/src/browser/event-controller.ts +86 -70
  44. package/src/browser/history-state.ts +21 -0
  45. package/src/browser/index.ts +3 -3
  46. package/src/browser/navigation-bridge.ts +84 -11
  47. package/src/browser/navigation-client.ts +76 -28
  48. package/src/browser/navigation-store.ts +32 -9
  49. package/src/browser/navigation-transaction.ts +10 -28
  50. package/src/browser/partial-update.ts +64 -26
  51. package/src/browser/prefetch/cache.ts +129 -21
  52. package/src/browser/prefetch/fetch.ts +148 -16
  53. package/src/browser/prefetch/queue.ts +36 -5
  54. package/src/browser/rango-state.ts +53 -13
  55. package/src/browser/react/Link.tsx +30 -2
  56. package/src/browser/react/NavigationProvider.tsx +72 -31
  57. package/src/browser/react/filter-segment-order.ts +51 -7
  58. package/src/browser/react/index.ts +3 -0
  59. package/src/browser/react/location-state-shared.ts +175 -4
  60. package/src/browser/react/location-state.ts +39 -13
  61. package/src/browser/react/use-handle.ts +17 -9
  62. package/src/browser/react/use-navigation.ts +22 -2
  63. package/src/browser/react/use-params.ts +20 -8
  64. package/src/browser/react/use-reverse.ts +106 -0
  65. package/src/browser/react/use-router.ts +22 -2
  66. package/src/browser/react/use-segments.ts +11 -8
  67. package/src/browser/response-adapter.ts +25 -0
  68. package/src/browser/rsc-router.tsx +64 -22
  69. package/src/browser/scroll-restoration.ts +22 -14
  70. package/src/browser/segment-reconciler.ts +36 -14
  71. package/src/browser/segment-structure-assert.ts +2 -2
  72. package/src/browser/server-action-bridge.ts +23 -30
  73. package/src/browser/types.ts +21 -0
  74. package/src/build/collect-fallback-refs.ts +107 -0
  75. package/src/build/generate-manifest.ts +60 -35
  76. package/src/build/generate-route-types.ts +2 -0
  77. package/src/build/index.ts +2 -0
  78. package/src/build/route-trie.ts +52 -25
  79. package/src/build/route-types/codegen.ts +4 -4
  80. package/src/build/route-types/include-resolution.ts +1 -1
  81. package/src/build/route-types/per-module-writer.ts +7 -4
  82. package/src/build/route-types/router-processing.ts +55 -14
  83. package/src/build/route-types/scan-filter.ts +1 -1
  84. package/src/build/route-types/source-scan.ts +118 -0
  85. package/src/build/runtime-discovery.ts +9 -20
  86. package/src/cache/cache-scope.ts +28 -42
  87. package/src/cache/cf/cf-cache-store.ts +54 -13
  88. package/src/client.rsc.tsx +3 -0
  89. package/src/client.tsx +92 -182
  90. package/src/context-var.ts +5 -5
  91. package/src/decode-loader-results.ts +36 -0
  92. package/src/errors.ts +30 -1
  93. package/src/handle.ts +26 -13
  94. package/src/host/index.ts +2 -2
  95. package/src/host/router.ts +129 -57
  96. package/src/host/types.ts +31 -2
  97. package/src/host/utils.ts +1 -1
  98. package/src/href-client.ts +140 -20
  99. package/src/index.rsc.ts +9 -4
  100. package/src/index.ts +53 -15
  101. package/src/loader-store.ts +500 -0
  102. package/src/loader.rsc.ts +21 -6
  103. package/src/loader.ts +3 -10
  104. package/src/missing-id-error.ts +68 -0
  105. package/src/outlet-context.ts +1 -1
  106. package/src/prerender.ts +4 -4
  107. package/src/response-utils.ts +37 -0
  108. package/src/reverse.ts +65 -36
  109. package/src/route-content-wrapper.tsx +6 -28
  110. package/src/route-definition/dsl-helpers.ts +384 -257
  111. package/src/route-definition/helper-factories.ts +29 -139
  112. package/src/route-definition/helpers-types.ts +100 -28
  113. package/src/route-definition/resolve-handler-use.ts +6 -0
  114. package/src/route-definition/use-item-types.ts +32 -0
  115. package/src/route-types.ts +26 -41
  116. package/src/router/basename.ts +14 -0
  117. package/src/router/content-negotiation.ts +15 -2
  118. package/src/router/error-handling.ts +1 -1
  119. package/src/router/handler-context.ts +21 -38
  120. package/src/router/intercept-resolution.ts +4 -18
  121. package/src/router/lazy-includes.ts +8 -8
  122. package/src/router/loader-resolution.ts +19 -2
  123. package/src/router/manifest.ts +22 -13
  124. package/src/router/match-api.ts +4 -3
  125. package/src/router/match-handlers.ts +63 -20
  126. package/src/router/match-middleware/cache-lookup.ts +44 -91
  127. package/src/router/match-middleware/cache-store.ts +3 -2
  128. package/src/router/match-result.ts +53 -32
  129. package/src/router/metrics.ts +1 -1
  130. package/src/router/middleware-types.ts +15 -26
  131. package/src/router/middleware.ts +99 -84
  132. package/src/router/pattern-matching.ts +101 -17
  133. package/src/router/prerender-match.ts +1 -1
  134. package/src/router/preview-match.ts +3 -1
  135. package/src/router/request-classification.ts +4 -28
  136. package/src/router/revalidation.ts +58 -2
  137. package/src/router/router-interfaces.ts +45 -28
  138. package/src/router/router-options.ts +40 -1
  139. package/src/router/router-registry.ts +2 -5
  140. package/src/router/segment-resolution/fresh.ts +27 -6
  141. package/src/router/segment-resolution/revalidation.ts +147 -106
  142. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  143. package/src/router/substitute-pattern-params.ts +56 -0
  144. package/src/router/telemetry.ts +99 -0
  145. package/src/router/trie-matching.ts +18 -13
  146. package/src/router/types.ts +8 -0
  147. package/src/router/url-params.ts +49 -0
  148. package/src/router.ts +38 -23
  149. package/src/rsc/handler-context.ts +2 -2
  150. package/src/rsc/handler.ts +28 -69
  151. package/src/rsc/helpers.ts +91 -43
  152. package/src/rsc/index.ts +1 -1
  153. package/src/rsc/origin-guard.ts +28 -10
  154. package/src/rsc/progressive-enhancement.ts +4 -0
  155. package/src/rsc/response-route-handler.ts +46 -53
  156. package/src/rsc/rsc-rendering.ts +35 -51
  157. package/src/rsc/runtime-warnings.ts +9 -10
  158. package/src/rsc/server-action.ts +17 -37
  159. package/src/rsc/ssr-setup.ts +16 -0
  160. package/src/rsc/types.ts +8 -2
  161. package/src/search-params.ts +4 -4
  162. package/src/segment-content-promise.ts +67 -0
  163. package/src/segment-loader-promise.ts +122 -0
  164. package/src/segment-system.tsx +132 -116
  165. package/src/serialize.ts +243 -0
  166. package/src/server/context.ts +143 -53
  167. package/src/server/cookie-store.ts +28 -4
  168. package/src/server/request-context.ts +20 -42
  169. package/src/ssr/index.tsx +5 -1
  170. package/src/static-handler.ts +1 -1
  171. package/src/testing/cache-status.ts +166 -0
  172. package/src/testing/collect-handle.ts +63 -0
  173. package/src/testing/dispatch.ts +440 -0
  174. package/src/testing/dom.entry.ts +22 -0
  175. package/src/testing/e2e/fixture.ts +154 -0
  176. package/src/testing/e2e/index.ts +149 -0
  177. package/src/testing/e2e/matchers.ts +51 -0
  178. package/src/testing/e2e/page-helpers.ts +272 -0
  179. package/src/testing/e2e/parity.ts +306 -0
  180. package/src/testing/e2e/server.ts +183 -0
  181. package/src/testing/flight-matchers.ts +104 -0
  182. package/src/testing/flight-runtime.d.ts +57 -0
  183. package/src/testing/flight-tree.ts +320 -0
  184. package/src/testing/flight.entry.ts +39 -0
  185. package/src/testing/flight.ts +197 -0
  186. package/src/testing/generated-routes.ts +223 -0
  187. package/src/testing/index.ts +106 -0
  188. package/src/testing/internal/context.ts +331 -0
  189. package/src/testing/internal/flight-client-globals.ts +30 -0
  190. package/src/testing/render-route.tsx +565 -0
  191. package/src/testing/run-loader.ts +341 -0
  192. package/src/testing/run-middleware.ts +188 -0
  193. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  194. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  195. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  196. package/src/testing/vitest-stubs/version.ts +5 -0
  197. package/src/testing/vitest.ts +270 -0
  198. package/src/types/global-namespace.ts +39 -26
  199. package/src/types/handler-context.ts +68 -50
  200. package/src/types/index.ts +1 -0
  201. package/src/types/loader-types.ts +5 -6
  202. package/src/types/request-scope.ts +126 -0
  203. package/src/types/route-entry.ts +11 -0
  204. package/src/types/segments.ts +35 -2
  205. package/src/urls/include-helper.ts +34 -67
  206. package/src/urls/index.ts +0 -3
  207. package/src/urls/path-helper-types.ts +41 -7
  208. package/src/urls/path-helper.ts +17 -52
  209. package/src/urls/pattern-types.ts +36 -19
  210. package/src/urls/response-types.ts +22 -29
  211. package/src/urls/type-extraction.ts +26 -116
  212. package/src/urls/urls-function.ts +1 -5
  213. package/src/use-loader.tsx +413 -42
  214. package/src/vite/debug.ts +185 -0
  215. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  216. package/src/vite/discovery/discover-routers.ts +101 -51
  217. package/src/vite/discovery/discovery-errors.ts +194 -0
  218. package/src/vite/discovery/gate-state.ts +171 -0
  219. package/src/vite/discovery/prerender-collection.ts +67 -26
  220. package/src/vite/discovery/route-types-writer.ts +40 -84
  221. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  222. package/src/vite/discovery/state.ts +33 -0
  223. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  224. package/src/vite/index.ts +2 -0
  225. package/src/vite/plugin-types.ts +67 -0
  226. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  227. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  228. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  229. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  230. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  231. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  232. package/src/vite/plugins/expose-action-id.ts +54 -30
  233. package/src/vite/plugins/expose-id-utils.ts +12 -8
  234. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  235. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  236. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  237. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  238. package/src/vite/plugins/expose-internal-ids.ts +496 -486
  239. package/src/vite/plugins/performance-tracks.ts +29 -25
  240. package/src/vite/plugins/use-cache-transform.ts +65 -50
  241. package/src/vite/plugins/version-injector.ts +39 -23
  242. package/src/vite/plugins/version-plugin.ts +59 -2
  243. package/src/vite/plugins/virtual-entries.ts +2 -2
  244. package/src/vite/rango.ts +116 -29
  245. package/src/vite/router-discovery.ts +750 -100
  246. package/src/vite/utils/ast-handler-extract.ts +15 -15
  247. package/src/vite/utils/banner.ts +1 -1
  248. package/src/vite/utils/bundle-analysis.ts +4 -2
  249. package/src/vite/utils/client-chunks.ts +190 -0
  250. package/src/vite/utils/forward-user-plugins.ts +193 -0
  251. package/src/vite/utils/manifest-utils.ts +21 -5
  252. package/src/vite/utils/package-resolution.ts +41 -1
  253. package/src/vite/utils/prerender-utils.ts +21 -6
  254. package/src/vite/utils/shared-utils.ts +107 -26
  255. package/src/browser/action-response-classifier.ts +0 -99
@@ -0,0 +1,306 @@
1
+ // Dev/production parity helpers. `parityDescribe` registers a dev and a
2
+ // production describe from a single body, auto-generating the `(production)`
3
+ // title suffix so a suite can never drift into the wrong dev/prod bucket.
4
+ // `expectParity` runs one intent over the JS and no-JS paths and asserts the
5
+ // observable result is identical (progressive-enhancement parity).
6
+
7
+ import type { Expect, Page, TestType } from "@playwright/test";
8
+ import type { Fixture, FixtureOptions } from "./fixture.js";
9
+
10
+ export interface ParityDescribeOptions extends Partial<
11
+ Omit<FixtureOptions, "mode">
12
+ > {}
13
+
14
+ export type ParityIntent =
15
+ | { navigate: string }
16
+ | { submit: { testId: string; data?: Record<string, string> } };
17
+
18
+ export interface ExpectParityOptions {
19
+ /** data-testid values whose text content must match across JS and no-JS. */
20
+ observe: string[];
21
+ /** Base URL to resolve a relative `navigate` intent against. */
22
+ baseURL?: string;
23
+ /**
24
+ * Escape hatch invoked after the intent is applied and before the snapshot is
25
+ * taken, on BOTH the JS and the no-JS transports. When provided for a `submit`
26
+ * intent it REPLACES the generic settle (the navigation/observed-change wait):
27
+ * you take responsibility for awaiting the post-submit effect — for example,
28
+ * awaiting a specific testid to reach a known value, or a network response the
29
+ * page does not reflect in the observed testids. Receives the page for the
30
+ * transport being snapshotted.
31
+ */
32
+ waitFor?: (page: Page) => Promise<void>;
33
+ }
34
+
35
+ export interface Parity {
36
+ /**
37
+ * Register a dev describe (title `name`) and a production describe (title
38
+ * `` `${name} (production)` ``) from one body. The `(production)` suffix is
39
+ * generated here, so the production suite always lands in the production
40
+ * bucket regardless of how the consumer names things.
41
+ *
42
+ * `options.root` is required, either here or as `defaultRoot` passed to the
43
+ * factory.
44
+ */
45
+ parityDescribe: (
46
+ name: string,
47
+ body: (f: Fixture) => void,
48
+ options?: ParityDescribeOptions,
49
+ ) => void;
50
+ /**
51
+ * Run a single `intent` over both the JS path (the given `page`) and a
52
+ * fresh no-JS context, then assert the observed snapshots are equal.
53
+ *
54
+ * PE parity only holds if the consumer's submit target is a real `<form>`
55
+ * that progressively enhances: with JS disabled the browser performs a native
56
+ * form POST, and the server must produce the same observable result the
57
+ * enhanced client path produces. Navigation intents must be plain links/URLs
58
+ * that resolve server-side without JS.
59
+ *
60
+ * For a `submit` intent the helper waits for the action's observable effect
61
+ * before snapshotting: either a navigation away from the form, or a change to
62
+ * one of the `observe` testids from its pre-submit value (then a brief
63
+ * stability confirm). A submit that produces neither within ~5s throws —
64
+ * include the testid that changes in `observe`, or pass `waitFor` to express
65
+ * the precise wait. This prevents snapshotting the pre-submit DOM when the
66
+ * action is slow.
67
+ *
68
+ * The snapshot is intentionally strict: it compares the text of every
69
+ * observed `data-testid`, the resulting `page.url()`, and the cookies visible
70
+ * via `document.cookie`. No ephemeral-value normalization is applied in v1;
71
+ * if a consumer's page renders nondeterministic values, exclude that testid
72
+ * from `observe`.
73
+ *
74
+ * LIMITATION: cookie parity is read from `document.cookie`, which by design
75
+ * EXCLUDES HttpOnly cookies. Session/auth cookies (the typical HttpOnly case)
76
+ * are therefore NOT compared — a PE/JS divergence in an HttpOnly cookie will
77
+ * not be caught here. Assert on those via `read_network_requests` / response
78
+ * Set-Cookie headers in a dedicated test, not expectParity.
79
+ */
80
+ expectParity: (
81
+ page: Page,
82
+ intent: ParityIntent,
83
+ opts: ExpectParityOptions,
84
+ ) => Promise<void>;
85
+ }
86
+
87
+ interface ParitySnapshot {
88
+ testIds: Record<string, string | null>;
89
+ url: string;
90
+ cookies: string;
91
+ }
92
+
93
+ async function applyIntent(
94
+ page: Page,
95
+ intent: ParityIntent,
96
+ baseURL?: string,
97
+ ): Promise<void> {
98
+ if ("navigate" in intent) {
99
+ const target = baseURL
100
+ ? new URL(intent.navigate, baseURL).href
101
+ : intent.navigate;
102
+ await page.goto(target, { waitUntil: "networkidle" });
103
+ return;
104
+ }
105
+ const form = page.locator(`[data-testid="${intent.submit.testId}"]`);
106
+ if (intent.submit.data) {
107
+ for (const [name, value] of Object.entries(intent.submit.data)) {
108
+ await form.locator(`[name="${name}"]`).fill(value);
109
+ }
110
+ }
111
+ // No post-click wait here: a native (no-JS) submit triggers a top-level
112
+ // navigation while an enhanced (JS) submit usually updates the DOM in place
113
+ // with no navigation, so a navigation-based wait races the click and can
114
+ // resolve before the effect lands. The post-action settle in expectParity is
115
+ // DOM-driven (settleSubmit) and works whether or not a navigation occurs.
116
+ await form
117
+ .locator('button[type="submit"], input[type="submit"]')
118
+ .first()
119
+ .click();
120
+ }
121
+
122
+ // Concatenate the observed testids' text into a single comparable string used
123
+ // to detect post-submit change/stability. "\0" marks an absent testid and
124
+ // "\x01" joins parts so two distinct testid layouts can never concatenate to the
125
+ // same string. Written as escaped control chars (not literal bytes) for source
126
+ // readability.
127
+ async function readObserved(page: Page, observe: string[]): Promise<string> {
128
+ const parts: string[] = [];
129
+ for (const id of observe) {
130
+ const text = await page
131
+ .locator(`[data-testid="${id}"]`)
132
+ .first()
133
+ .textContent()
134
+ .catch(() => null);
135
+ parts.push(`${id}=${text ?? "\0"}`);
136
+ }
137
+ return parts.join("\x01");
138
+ }
139
+
140
+ // Settle a submit's observable effect. An enhanced (JS) submit mutates the DOM
141
+ // in place with no navigation, while a native (no-JS) submit navigates — so we
142
+ // wait for EITHER a navigation away from the form OR a change in the observed
143
+ // testids from their pre-submit `baseline`, then confirm the new state is stable
144
+ // across two reads. Requiring an observed change (not just stability) closes the
145
+ // gap where a slow action leaves the pre-submit DOM momentarily stable and gets
146
+ // snapshotted as the "result". A submit that produces neither a navigation nor
147
+ // an observed change within the ceiling throws, rather than silently capturing
148
+ // the pre-submit state — pass `waitFor` when the observed set cannot capture the
149
+ // effect.
150
+ async function settleSubmit(
151
+ page: Page,
152
+ observe: string[],
153
+ baseline: string,
154
+ originUrl: string,
155
+ ): Promise<void> {
156
+ const pollIntervalMs = 50;
157
+ const maxAttempts = 100; // ~5s ceiling
158
+ let landed = false;
159
+ let last = baseline;
160
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
161
+ await page.waitForTimeout(pollIntervalMs);
162
+ const current = await readObserved(page, observe);
163
+ if (!landed) {
164
+ if (page.url() !== originUrl || current !== baseline) {
165
+ landed = true;
166
+ last = current;
167
+ }
168
+ continue;
169
+ }
170
+ if (current === last) return;
171
+ last = current;
172
+ }
173
+ if (!landed) {
174
+ throw new Error(
175
+ `expectParity: the submit intent produced no observable effect within 5s — ` +
176
+ `no navigation away from "${originUrl}" and no change to the observed ` +
177
+ `testids [${observe.join(", ")}]. Include the testid that changes in ` +
178
+ `\`observe\`, or pass \`waitFor\` to express the precise post-submit wait.`,
179
+ );
180
+ }
181
+ // Landed but never stabilized within the ceiling: fall through and snapshot
182
+ // the last-read state; the parity equality assertion surfaces any mismatch.
183
+ }
184
+
185
+ async function snapshot(
186
+ page: Page,
187
+ observe: string[],
188
+ ): Promise<ParitySnapshot> {
189
+ const testIds: Record<string, string | null> = {};
190
+ for (const id of observe) {
191
+ testIds[id] = await page.locator(`[data-testid="${id}"]`).textContent();
192
+ }
193
+ return {
194
+ testIds,
195
+ url: page.url(),
196
+ cookies: await page.evaluate(() => document.cookie),
197
+ };
198
+ }
199
+
200
+ export function createParity({
201
+ test: _test,
202
+ expect,
203
+ useFixture,
204
+ defaultRoot,
205
+ }: {
206
+ test: TestType<any, any>;
207
+ expect: Expect;
208
+ useFixture: (options: FixtureOptions) => Fixture;
209
+ defaultRoot?: string;
210
+ }): Parity {
211
+ function parityDescribe(
212
+ name: string,
213
+ body: (f: Fixture) => void,
214
+ options?: ParityDescribeOptions,
215
+ ): void {
216
+ const root = options?.root ?? defaultRoot;
217
+ if (!root) {
218
+ throw new Error(
219
+ `parityDescribe("${name}") requires a root: pass options.root or set defaultRoot in createRangoE2E.`,
220
+ );
221
+ }
222
+ _test.describe(name, () => {
223
+ const f = useFixture({ ...options, root, mode: "dev" });
224
+ body(f);
225
+ });
226
+ _test.describe(`${name} (production)`, () => {
227
+ const f = useFixture({ ...options, root, mode: "build" });
228
+ body(f);
229
+ });
230
+ }
231
+
232
+ async function expectParity(
233
+ page: Page,
234
+ intent: ParityIntent,
235
+ opts: ExpectParityOptions,
236
+ ): Promise<void> {
237
+ // For a submit intent, the form lives on the page the caller already
238
+ // navigated to; capture that origin URL before the JS submit changes it so
239
+ // the no-JS path can load the same form, and so settleSubmit can detect a
240
+ // navigation away from it.
241
+ const originUrl = page.url();
242
+
243
+ // Settle the intent's observable effect before snapshotting. A `navigate`
244
+ // intent already awaited its navigation in applyIntent (page.goto), so only
245
+ // `submit` needs the DOM-driven settle, and only when no `waitFor` override
246
+ // is given. waitFor, when present, replaces the generic settle for submit
247
+ // and runs after the navigation for navigate — on whichever page is about to
248
+ // be snapshotted.
249
+ const settle = async (
250
+ target: Page,
251
+ baseline: string,
252
+ origin: string,
253
+ ): Promise<void> => {
254
+ if ("submit" in intent) {
255
+ if (opts.waitFor) {
256
+ await opts.waitFor(target);
257
+ } else {
258
+ await settleSubmit(target, opts.observe, baseline, origin);
259
+ }
260
+ } else if (opts.waitFor) {
261
+ await opts.waitFor(target);
262
+ }
263
+ };
264
+
265
+ // JS path: the given page (JS enabled by default). Read the pre-submit
266
+ // baseline before applying the intent so the settle can require a change.
267
+ const jsBaseline =
268
+ "submit" in intent ? await readObserved(page, opts.observe) : "";
269
+ await applyIntent(page, intent, opts.baseURL);
270
+ await settle(page, jsBaseline, originUrl);
271
+ const jsSnapshot = await snapshot(page, opts.observe);
272
+
273
+ // No-JS path: a fresh context with scripting disabled.
274
+ const browser = page.context().browser()!;
275
+ const noJsContext = await browser.newContext({ javaScriptEnabled: false });
276
+ try {
277
+ const noJsPage = await noJsContext.newPage();
278
+ if (!("navigate" in intent)) {
279
+ // A submit intent needs the form rendered first; start from the same
280
+ // URL the JS path observed the form on.
281
+ await noJsPage.goto(originUrl, { waitUntil: "networkidle" });
282
+ }
283
+ const noJsOrigin = noJsPage.url();
284
+ const noJsBaseline =
285
+ "submit" in intent ? await readObserved(noJsPage, opts.observe) : "";
286
+ await applyIntent(noJsPage, intent, opts.baseURL);
287
+ await settle(noJsPage, noJsBaseline, noJsOrigin);
288
+ const noJsSnapshot = await snapshot(noJsPage, opts.observe);
289
+
290
+ expect(noJsSnapshot.testIds).toEqual(jsSnapshot.testIds);
291
+ // Compare pathname + search + hash, not pathname alone: a JS vs no-JS flow
292
+ // that diverges only in query/hash (e.g. /search?q=a vs /search?q=b) would
293
+ // otherwise pass.
294
+ const locationOf = (u: string): string => {
295
+ const url = new URL(u);
296
+ return url.pathname + url.search + url.hash;
297
+ };
298
+ expect(locationOf(noJsSnapshot.url)).toEqual(locationOf(jsSnapshot.url));
299
+ expect(noJsSnapshot.cookies).toEqual(jsSnapshot.cookies);
300
+ } finally {
301
+ await noJsContext.close();
302
+ }
303
+ }
304
+
305
+ return { parityDescribe, expectParity };
306
+ }
@@ -0,0 +1,183 @@
1
+ // Node-only server-lifecycle machinery for the e2e harness. Contains no
2
+ // Playwright imports so it can be loaded in a plain-node process. Lifted from
3
+ // the internal e2e/fixture.ts and parameterized for consumer apps.
4
+
5
+ import { type SpawnOptions, spawn } from "node:child_process";
6
+ import path from "node:path";
7
+ import { stripVTControlCharacters, styleText } from "node:util";
8
+ import { x } from "tinyexec";
9
+
10
+ export type { SpawnOptions };
11
+
12
+ export interface RunCliHandle {
13
+ proc: ReturnType<typeof x>["process"];
14
+ done: Promise<void>;
15
+ findPort: (timeoutMs?: number) => Promise<number>;
16
+ kill: () => void;
17
+ stdout: () => string;
18
+ stderr: () => string;
19
+ }
20
+
21
+ export function runCli(
22
+ options: { command: string; label?: string } & SpawnOptions,
23
+ ): RunCliHandle {
24
+ const [name, ...args] = options.command.split(" ");
25
+ // Vite registers `process.stdin.on("end", ...)` as parent-death detection and
26
+ // calls process.exit() when stdin reaches EOF, unless process.env.CI === "true"
27
+ // (see vite's setupSIGTERMListener). Servers spawned here receive an stdin that
28
+ // hits EOF immediately, so without CI=true the dev/preview server shuts itself
29
+ // down before it finishes starting. Real CI runners set CI=true; mirror that for
30
+ // locally-spawned servers so they stay alive for the duration of the tests.
31
+ const child = x(name!, args, {
32
+ nodeOptions: {
33
+ ...options,
34
+ env: { ...process.env, CI: "true", ...options.env },
35
+ },
36
+ }).process!;
37
+ const label = `[${options.label ?? "cli"}]`;
38
+ let stdout = "";
39
+ let stderr = "";
40
+ child.stdout!.on("data", (data) => {
41
+ stdout += stripVTControlCharacters(String(data));
42
+ if (process.env.TEST_DEBUG) {
43
+ console.log(styleText("cyan", label), data.toString());
44
+ }
45
+ });
46
+ child.stderr!.on("data", (data) => {
47
+ stderr += stripVTControlCharacters(String(data));
48
+ if (process.env.TEST_DEBUG) {
49
+ console.log(styleText("magenta", label), data.toString());
50
+ }
51
+ });
52
+ const done = new Promise<void>((resolve) => {
53
+ child.on("exit", (code) => {
54
+ if (code !== 0 && code !== 143 && process.platform !== "win32") {
55
+ console.log(styleText("magenta", `${label}`), `exit code ${code}`);
56
+ }
57
+ resolve();
58
+ });
59
+ });
60
+
61
+ async function findPort(timeoutMs = 60000): Promise<number> {
62
+ let stdout = "";
63
+ return new Promise((resolve, reject) => {
64
+ const timeout = setTimeout(() => {
65
+ reject(
66
+ new Error(
67
+ `Timed out waiting for server to start after ${timeoutMs}ms. Stdout: ${stdout}`,
68
+ ),
69
+ );
70
+ }, timeoutMs);
71
+
72
+ child.stdout!.on("data", (data) => {
73
+ stdout += stripVTControlCharacters(String(data));
74
+ const match = stdout.match(/http:\/\/localhost:(\d+)/);
75
+ if (match) {
76
+ clearTimeout(timeout);
77
+ resolve(Number(match[1]));
78
+ }
79
+ });
80
+
81
+ child.on("exit", (code) => {
82
+ clearTimeout(timeout);
83
+ if (code !== 0) {
84
+ reject(
85
+ new Error(`Server exited with code ${code}. Stdout: ${stdout}`),
86
+ );
87
+ }
88
+ });
89
+ });
90
+ }
91
+
92
+ function kill() {
93
+ if (process.platform === "win32") {
94
+ spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"]);
95
+ } else {
96
+ // Kill entire process group (Vite spawns child processes like workerd).
97
+ // Falls back to direct kill if process group kill fails.
98
+ try {
99
+ process.kill(-child.pid!, "SIGTERM");
100
+ } catch {
101
+ child.kill();
102
+ }
103
+ }
104
+ }
105
+
106
+ return {
107
+ proc: child,
108
+ done,
109
+ findPort,
110
+ kill,
111
+ stdout: () => stdout,
112
+ stderr: () => stderr,
113
+ };
114
+ }
115
+
116
+ export function tailOutput(text: string, maxChars = 4000): string {
117
+ if (!text) return "(empty)";
118
+ if (text.length <= maxChars) return text;
119
+ return `...${text.slice(-maxChars)}`;
120
+ }
121
+
122
+ export function createIsolatedViteCacheDir(
123
+ cwd: string,
124
+ projectName: string,
125
+ mode: "dev" | "build" | undefined,
126
+ ): string {
127
+ const safeProjectName = projectName.replace(/[^a-zA-Z0-9_-]/g, "-");
128
+ return path.join(
129
+ cwd,
130
+ ".vite-isolated",
131
+ `${safeProjectName}-${mode ?? "server"}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
132
+ );
133
+ }
134
+
135
+ export async function waitForReady(
136
+ url: string,
137
+ getOutput?: () => { stdout: string; stderr: string },
138
+ timeoutMs: number = process.env.CI ? 60000 : 30000,
139
+ ): Promise<void> {
140
+ const deadline = Date.now() + timeoutMs;
141
+ while (Date.now() < deadline) {
142
+ try {
143
+ const res = await fetch(url);
144
+ if (res.ok) return;
145
+ } catch {}
146
+ await new Promise((r) => setTimeout(r, 100));
147
+ }
148
+ const output = getOutput?.();
149
+ const details = output
150
+ ? `\nRecent stdout:\n${tailOutput(output.stdout)}\n\nRecent stderr:\n${tailOutput(output.stderr)}`
151
+ : "";
152
+ throw new Error(`Server not ready after ${timeoutMs}ms: ${url}${details}`);
153
+ }
154
+
155
+ /**
156
+ * Warm up an isolated dev server by making real SSR requests.
157
+ * The first SSR request triggers Vite's dep optimizer to discover SSR deps.
158
+ * After optimization, modules are re-evaluated and in-memory caches reset.
159
+ * We retry until the server returns a stable 200, absorbing the dep
160
+ * optimization cycle so subsequent test requests hit a settled server.
161
+ */
162
+ export async function warmupDevServer(url: string): Promise<void> {
163
+ const deadline = Date.now() + 30_000;
164
+ let lastOk = 0;
165
+ // Need two consecutive OK responses to confirm the server is settled
166
+ // (first OK may precede dep optimization, second confirms stability).
167
+ while (Date.now() < deadline && lastOk < 2) {
168
+ try {
169
+ const res = await fetch(url, {
170
+ headers: { accept: "text/html" },
171
+ });
172
+ if (res.ok) {
173
+ await res.text(); // consume body to complete SSR pipeline
174
+ lastOk++;
175
+ } else {
176
+ lastOk = 0;
177
+ }
178
+ } catch {
179
+ lastOk = 0;
180
+ }
181
+ await new Promise((r) => setTimeout(r, 1000));
182
+ }
183
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Vitest custom matchers for asserting on REAL Flight wire strings produced by
3
+ * {@link renderToFlightString}. Register with:
4
+ *
5
+ * import { expect } from "vitest";
6
+ * import { flightMatchers } from "@rangojs/router/testing/flight-matchers"; // or local path
7
+ * expect.extend(flightMatchers);
8
+ *
9
+ * Ergonomic shape (vitest `expect` is single-arg, so the matcher receives the
10
+ * ALREADY-RENDERED Flight string as `received`):
11
+ *
12
+ * const flight = await renderToFlightString(<Greeting name="Ada" />);
13
+ * expect(flight).toMatchFlight("Ada"); // substring containment
14
+ * expect(flight).toMatchFlightSnapshot(); // normalized snapshot
15
+ *
16
+ * `toMatchFlight(expected)` asserts the NORMALIZED Flight string CONTAINS
17
+ * `expected`. Containment (not equality) is the v1 contract because the Flight
18
+ * row prefixes/quoting are an internal serializer detail — tests should pin the
19
+ * rendered text/shape, not the exact framing. For an exact, drift-detecting
20
+ * assertion of the whole payload, use `toMatchFlightSnapshot()`.
21
+ *
22
+ * Both operate on normalized output (see normalizeFlight): the dev-only
23
+ * `:N<timestamp>` reference row and absolute `file://` paths are scrubbed so
24
+ * assertions are stable across runs/machines. Run snapshots under
25
+ * NODE_ENV=production for the cleanest, most stable payloads.
26
+ *
27
+ * Scope: server-only / leaf trees (a client component emits an unresolved
28
+ * `I[...]` import row against the empty client manifest — see flight.ts).
29
+ */
30
+
31
+ import { expect } from "vitest";
32
+ import { normalizeFlight } from "./flight.js";
33
+
34
+ interface MatcherResult {
35
+ pass: boolean;
36
+ message: () => string;
37
+ }
38
+
39
+ /**
40
+ * Matcher object for `expect.extend(flightMatchers)`.
41
+ *
42
+ * - `toMatchFlight(received, expected)` — `received` is a rendered Flight
43
+ * string; passes if its normalized form contains `expected`.
44
+ * - `toMatchFlightSnapshot(received)` — delegates to vitest's snapshot on the
45
+ * normalized Flight string.
46
+ */
47
+ export const flightMatchers: {
48
+ toMatchFlight(received: string, expected: string): MatcherResult;
49
+ toMatchFlightSnapshot(received: string): MatcherResult;
50
+ } = {
51
+ toMatchFlight(received: string, expected: string): MatcherResult {
52
+ if (typeof received !== "string") {
53
+ return {
54
+ pass: false,
55
+ message: () =>
56
+ "toMatchFlight expected a rendered Flight string (the result of " +
57
+ "`await renderToFlightString(...)`), but received " +
58
+ `${typeof received}. Render the element first: ` +
59
+ "`expect(await renderToFlightString(<C/>)).toMatchFlight(...)`.",
60
+ };
61
+ }
62
+ const normalized = normalizeFlight(received);
63
+ const pass = normalized.includes(expected);
64
+ return {
65
+ pass,
66
+ message: () =>
67
+ pass
68
+ ? `Expected Flight string not to contain ${JSON.stringify(expected)}.`
69
+ : `Expected Flight string to contain ${JSON.stringify(expected)}.\n` +
70
+ `Received (normalized):\n${normalized}`,
71
+ };
72
+ },
73
+
74
+ toMatchFlightSnapshot(received: string): MatcherResult {
75
+ // Delegate to vitest's snapshot engine on the normalized string. The
76
+ // snapshot is keyed by the current test file/title (vitest tracks this via
77
+ // the active test context), not by this call site, so delegating through a
78
+ // freshly imported `expect` is reliable.
79
+ expect(normalizeFlight(received)).toMatchSnapshot();
80
+ // toMatchSnapshot throws on mismatch; reaching here means it passed.
81
+ return {
82
+ pass: true,
83
+ message: () => "Flight snapshot matched.",
84
+ };
85
+ },
86
+ };
87
+
88
+ /**
89
+ * Vitest Assertion augmentation so `toMatchFlight` / `toMatchFlightSnapshot`
90
+ * are typed on `expect(...)`. Imported for its side-effecting type
91
+ * augmentation; importing flight-matchers (which imports this) is enough.
92
+ */
93
+ declare module "vitest" {
94
+ interface Assertion<T = any> {
95
+ /** Assert the normalized Flight string contains `expected`. */
96
+ toMatchFlight(expected: string): T;
97
+ /** Snapshot the normalized Flight string. */
98
+ toMatchFlightSnapshot(): T;
99
+ }
100
+ interface AsymmetricMatchersContaining {
101
+ toMatchFlight(expected: string): void;
102
+ toMatchFlightSnapshot(): void;
103
+ }
104
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Ambient declaration for the vendored react-server-dom serializer shipped
3
+ * inside @vitejs/plugin-rsc. The package ships no .d.ts for this private
4
+ * subpath, so we declare the minimal surface renderToFlightString uses.
5
+ *
6
+ * Only loadable under the `react-server` export condition (see flight.ts).
7
+ */
8
+ declare module "@vitejs/plugin-rsc/vendor/react-server-dom/server.edge" {
9
+ /**
10
+ * Serialize a server-component payload to a Flight wire stream.
11
+ *
12
+ * @param payload The value to serialize (Rango wraps a payload object).
13
+ * @param clientManifest Client-reference manifest; `{}` for server-only trees.
14
+ * @param options Render options; `onError` is invoked per render error.
15
+ */
16
+ export function renderToReadableStream(
17
+ payload: unknown,
18
+ clientManifest: unknown,
19
+ options?: { onError?: (error: unknown) => string | void },
20
+ ): ReadableStream<Uint8Array>;
21
+
22
+ /**
23
+ * Tag a value as a client reference. Mutates `impl` in place (defining
24
+ * `$$typeof`/`$$id`/`$$async`) and returns it, so a server tree that imports
25
+ * the same module binding renders it as a client boundary (an `I` row) rather
26
+ * than inlining it. `$$id` becomes `${id}#${exportName}`.
27
+ */
28
+ export function registerClientReference<T>(
29
+ impl: T,
30
+ id: string,
31
+ exportName: string,
32
+ ): T;
33
+ }
34
+
35
+ /**
36
+ * Vendored react-server-dom CLIENT deserializer wrappers shipped inside
37
+ * @vitejs/plugin-rsc. Used by renderServerTree to turn a Flight wire string
38
+ * back into an inspectable React element tree. These run in the same
39
+ * `react-server`-condition worker as the serializer (deserialize-only never
40
+ * renders, so the client React/react-dom imports they pull are inert).
41
+ */
42
+ declare module "@vitejs/plugin-rsc/react/browser" {
43
+ export function createFromReadableStream<T = unknown>(
44
+ stream: ReadableStream<Uint8Array>,
45
+ options?: { temporaryReferences?: unknown },
46
+ ): Promise<T>;
47
+ }
48
+
49
+ declare module "@vitejs/plugin-rsc/core/browser" {
50
+ /**
51
+ * Install the module loader the client deserializer resolves client
52
+ * references through. Init-once per worker (first call wins).
53
+ */
54
+ export function setRequireModule(options: {
55
+ load: (id: string) => unknown;
56
+ }): void;
57
+ }