@rangojs/router 0.0.0-experimental.97 → 0.0.0-experimental.98914650

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 (356) hide show
  1. package/README.md +24 -9
  2. package/dist/bin/rango.js +157 -63
  3. package/dist/testing/vitest.js +82 -0
  4. package/dist/vite/index.js +1584 -639
  5. package/package.json +71 -21
  6. package/skills/api-client/SKILL.md +211 -0
  7. package/skills/breadcrumbs/SKILL.md +60 -0
  8. package/skills/bundle-analysis/SKILL.md +159 -0
  9. package/skills/cache-guide/SKILL.md +222 -30
  10. package/skills/caching/SKILL.md +263 -8
  11. package/skills/composability/SKILL.md +27 -2
  12. package/skills/css/SKILL.md +76 -0
  13. package/skills/document-cache/SKILL.md +78 -55
  14. package/skills/handler-use/SKILL.md +3 -1
  15. package/skills/hooks/SKILL.md +235 -28
  16. package/skills/host-router/SKILL.md +122 -22
  17. package/skills/i18n/SKILL.md +276 -0
  18. package/skills/intercept/SKILL.md +29 -5
  19. package/skills/layout/SKILL.md +13 -9
  20. package/skills/links/SKILL.md +173 -17
  21. package/skills/loader/SKILL.md +170 -23
  22. package/skills/middleware/SKILL.md +16 -10
  23. package/skills/migrate-nextjs/SKILL.md +38 -16
  24. package/skills/mime-routes/SKILL.md +27 -0
  25. package/skills/observability/SKILL.md +137 -0
  26. package/skills/parallel/SKILL.md +11 -7
  27. package/skills/prerender/SKILL.md +14 -33
  28. package/skills/rango/SKILL.md +250 -25
  29. package/skills/react-compiler/SKILL.md +168 -0
  30. package/skills/response-routes/SKILL.md +114 -47
  31. package/skills/route/SKILL.md +42 -5
  32. package/skills/router-setup/SKILL.md +3 -3
  33. package/skills/server-actions/SKILL.md +78 -42
  34. package/skills/tailwind/SKILL.md +27 -3
  35. package/skills/testing/SKILL.md +129 -0
  36. package/skills/testing/bindings.md +89 -0
  37. package/skills/testing/cache-prerender.md +124 -0
  38. package/skills/testing/client-components.md +122 -0
  39. package/skills/testing/e2e-parity.md +125 -0
  40. package/skills/testing/flight.md +92 -0
  41. package/skills/testing/handles.md +129 -0
  42. package/skills/testing/loader.md +128 -0
  43. package/skills/testing/middleware.md +99 -0
  44. package/skills/testing/render-handler.md +121 -0
  45. package/skills/testing/response-routes.md +95 -0
  46. package/skills/testing/reverse-and-types.md +84 -0
  47. package/skills/testing/server-actions.md +107 -0
  48. package/skills/testing/server-tree.md +128 -0
  49. package/skills/testing/setup.md +120 -0
  50. package/skills/typesafety/SKILL.md +316 -26
  51. package/skills/use-cache/SKILL.md +36 -5
  52. package/skills/vercel/SKILL.md +107 -0
  53. package/skills/view-transitions/SKILL.md +294 -0
  54. package/src/__augment-tests__/augment.ts +81 -0
  55. package/src/__augment-tests__/augmented.check.ts +116 -0
  56. package/src/__internal.ts +0 -65
  57. package/src/browser/action-coordinator.ts +53 -36
  58. package/src/browser/action-fence.ts +47 -0
  59. package/src/browser/app-shell.ts +14 -27
  60. package/src/browser/cookie-name.ts +140 -0
  61. package/src/browser/event-controller.ts +37 -143
  62. package/src/browser/history-state.ts +21 -0
  63. package/src/browser/index.ts +3 -3
  64. package/src/browser/invalidate-client-cache.ts +52 -0
  65. package/src/browser/navigation-bridge.ts +30 -59
  66. package/src/browser/navigation-client.ts +96 -84
  67. package/src/browser/navigation-store-handle.ts +38 -0
  68. package/src/browser/navigation-store.ts +32 -82
  69. package/src/browser/navigation-transaction.ts +9 -59
  70. package/src/browser/partial-update.ts +60 -127
  71. package/src/browser/prefetch/cache.ts +82 -72
  72. package/src/browser/prefetch/fetch.ts +108 -33
  73. package/src/browser/prefetch/queue.ts +6 -3
  74. package/src/browser/rango-state.ts +157 -115
  75. package/src/browser/react/Link.tsx +0 -2
  76. package/src/browser/react/NavigationProvider.tsx +41 -48
  77. package/src/browser/react/ScrollRestoration.tsx +10 -6
  78. package/src/browser/react/filter-segment-order.ts +0 -2
  79. package/src/browser/react/index.ts +0 -48
  80. package/src/browser/react/location-state-shared.ts +166 -8
  81. package/src/browser/react/location-state.ts +39 -14
  82. package/src/browser/react/use-action.ts +6 -15
  83. package/src/browser/react/use-handle.ts +17 -14
  84. package/src/browser/react/use-link-status.ts +0 -4
  85. package/src/browser/react/use-navigation.ts +0 -3
  86. package/src/browser/react/use-params.ts +11 -11
  87. package/src/browser/react/use-reverse.ts +106 -0
  88. package/src/browser/react/use-router.ts +20 -5
  89. package/src/browser/react/use-search-params.ts +0 -5
  90. package/src/browser/react/use-segments.ts +0 -13
  91. package/src/browser/response-adapter.ts +52 -1
  92. package/src/browser/rsc-router.tsx +70 -34
  93. package/src/browser/scroll-restoration.ts +22 -14
  94. package/src/browser/segment-structure-assert.ts +2 -2
  95. package/src/browser/server-action-bridge.ts +168 -44
  96. package/src/browser/types.ts +36 -21
  97. package/src/browser/validate-redirect-origin.ts +43 -16
  98. package/src/build/collect-fallback-refs.ts +107 -0
  99. package/src/build/generate-manifest.ts +60 -35
  100. package/src/build/generate-route-types.ts +3 -0
  101. package/src/build/index.ts +8 -2
  102. package/src/build/prefix-tree-utils.ts +123 -0
  103. package/src/build/route-trie.ts +89 -10
  104. package/src/build/route-types/codegen.ts +4 -4
  105. package/src/build/route-types/include-resolution.ts +1 -1
  106. package/src/build/route-types/param-extraction.ts +6 -3
  107. package/src/build/route-types/per-module-writer.ts +7 -4
  108. package/src/build/route-types/router-processing.ts +122 -22
  109. package/src/build/route-types/scan-filter.ts +1 -1
  110. package/src/build/route-types/source-scan.ts +118 -0
  111. package/src/build/runtime-discovery.ts +9 -20
  112. package/src/cache/cache-error.ts +104 -0
  113. package/src/cache/cache-policy.ts +68 -28
  114. package/src/cache/cache-runtime.ts +134 -32
  115. package/src/cache/cache-scope.ts +100 -74
  116. package/src/cache/cache-tag.ts +98 -0
  117. package/src/cache/cf/cf-cache-store.ts +2255 -238
  118. package/src/cache/cf/index.ts +6 -16
  119. package/src/cache/document-cache.ts +61 -20
  120. package/src/cache/handle-snapshot.ts +63 -0
  121. package/src/cache/index.ts +22 -20
  122. package/src/cache/memory-segment-store.ts +136 -37
  123. package/src/cache/profile-registry.ts +6 -30
  124. package/src/cache/read-through-swr.ts +41 -11
  125. package/src/cache/segment-codec.ts +0 -16
  126. package/src/cache/tag-invalidation.ts +230 -0
  127. package/src/cache/types.ts +33 -100
  128. package/src/cache/vercel/index.ts +11 -0
  129. package/src/cache/vercel/vercel-cache-store.ts +799 -0
  130. package/src/client.rsc.tsx +6 -21
  131. package/src/client.tsx +25 -61
  132. package/src/component-utils.ts +19 -0
  133. package/src/context-var.ts +17 -5
  134. package/src/decode-loader-results.ts +36 -0
  135. package/src/defer.ts +196 -0
  136. package/src/deps/ssr.ts +0 -1
  137. package/src/errors.ts +30 -4
  138. package/src/handle.ts +31 -23
  139. package/src/handles/MetaTags.tsx +0 -14
  140. package/src/handles/breadcrumbs.ts +16 -5
  141. package/src/handles/meta.ts +0 -39
  142. package/src/host/cookie-handler.ts +0 -36
  143. package/src/host/errors.ts +0 -24
  144. package/src/host/index.ts +8 -2
  145. package/src/host/pattern-matcher.ts +7 -50
  146. package/src/host/router.ts +107 -99
  147. package/src/host/testing.ts +40 -27
  148. package/src/host/types.ts +37 -4
  149. package/src/host/utils.ts +1 -1
  150. package/src/href-client.ts +137 -22
  151. package/src/index.rsc.ts +63 -9
  152. package/src/index.ts +64 -9
  153. package/src/internal-debug.ts +2 -4
  154. package/src/loader-store.ts +500 -0
  155. package/src/loader.rsc.ts +20 -13
  156. package/src/loader.ts +12 -11
  157. package/src/missing-id-error.ts +68 -0
  158. package/src/network-error-thrower.tsx +1 -6
  159. package/src/outlet-provider.tsx +1 -5
  160. package/src/prerender/param-hash.ts +10 -11
  161. package/src/prerender/store.ts +32 -37
  162. package/src/prerender.ts +61 -6
  163. package/src/redirect-origin.ts +100 -0
  164. package/src/response-utils.ts +9 -0
  165. package/src/reverse.ts +65 -40
  166. package/src/root-error-boundary.tsx +1 -19
  167. package/src/route-content-wrapper.tsx +7 -72
  168. package/src/route-definition/dsl-helpers.ts +244 -281
  169. package/src/route-definition/helper-factories.ts +29 -139
  170. package/src/route-definition/helpers-types.ts +40 -17
  171. package/src/route-definition/redirect.ts +43 -9
  172. package/src/route-definition/resolve-handler-use.ts +6 -0
  173. package/src/route-definition/use-item-types.ts +32 -0
  174. package/src/route-map-builder.ts +0 -16
  175. package/src/route-types.ts +19 -41
  176. package/src/router/basename.ts +14 -0
  177. package/src/router/content-negotiation.ts +15 -15
  178. package/src/router/error-handling.ts +13 -17
  179. package/src/router/find-match.ts +44 -23
  180. package/src/router/handler-context.ts +4 -41
  181. package/src/router/intercept-resolution.ts +14 -19
  182. package/src/router/lazy-includes.ts +9 -46
  183. package/src/router/loader-resolution.ts +91 -46
  184. package/src/router/logging.ts +0 -6
  185. package/src/router/manifest.ts +18 -29
  186. package/src/router/match-api.ts +0 -20
  187. package/src/router/match-context.ts +0 -22
  188. package/src/router/match-handlers.ts +57 -58
  189. package/src/router/match-middleware/background-revalidation.ts +0 -7
  190. package/src/router/match-middleware/cache-lookup.ts +150 -271
  191. package/src/router/match-middleware/cache-store.ts +3 -33
  192. package/src/router/match-middleware/intercept-resolution.ts +0 -22
  193. package/src/router/match-middleware/segment-resolution.ts +0 -22
  194. package/src/router/match-pipelines.ts +1 -42
  195. package/src/router/match-result.ts +31 -80
  196. package/src/router/metrics.ts +0 -34
  197. package/src/router/middleware-types.ts +5 -112
  198. package/src/router/middleware.ts +118 -133
  199. package/src/router/navigation-snapshot.ts +0 -51
  200. package/src/router/params-util.ts +23 -0
  201. package/src/router/pattern-matching.ts +62 -67
  202. package/src/router/prerender-match.ts +99 -63
  203. package/src/router/preview-match.ts +3 -1
  204. package/src/router/request-classification.ts +28 -62
  205. package/src/router/revalidation.ts +50 -56
  206. package/src/router/route-snapshot.ts +0 -1
  207. package/src/router/router-context.ts +0 -27
  208. package/src/router/router-interfaces.ts +68 -35
  209. package/src/router/router-options.ts +55 -1
  210. package/src/router/router-registry.ts +2 -5
  211. package/src/router/segment-resolution/fresh.ts +44 -63
  212. package/src/router/segment-resolution/helpers.ts +34 -0
  213. package/src/router/segment-resolution/loader-cache.ts +40 -37
  214. package/src/router/segment-resolution/revalidation.ts +203 -285
  215. package/src/router/segment-resolution/static-store.ts +19 -5
  216. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  217. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  218. package/src/router/segment-resolution.ts +4 -1
  219. package/src/router/segment-wrappers.ts +0 -3
  220. package/src/router/state-cookie-name.ts +33 -0
  221. package/src/router/substitute-pattern-params.ts +56 -0
  222. package/src/router/telemetry-otel.ts +0 -20
  223. package/src/router/telemetry.ts +96 -19
  224. package/src/router/timeout.ts +0 -20
  225. package/src/router/trie-matching.ts +87 -48
  226. package/src/router/types.ts +9 -63
  227. package/src/router/url-params.ts +0 -5
  228. package/src/router.ts +80 -41
  229. package/src/rsc/handler-context.ts +3 -2
  230. package/src/rsc/handler.ts +83 -78
  231. package/src/rsc/helpers.ts +93 -5
  232. package/src/rsc/index.ts +1 -1
  233. package/src/rsc/json-route-result.ts +38 -0
  234. package/src/rsc/manifest-init.ts +28 -41
  235. package/src/rsc/origin-guard.ts +39 -25
  236. package/src/rsc/progressive-enhancement.ts +12 -1
  237. package/src/rsc/redirect-guard.ts +99 -0
  238. package/src/rsc/response-error.ts +79 -12
  239. package/src/rsc/response-route-handler.ts +76 -62
  240. package/src/rsc/rsc-rendering.ts +41 -60
  241. package/src/rsc/runtime-warnings.ts +23 -10
  242. package/src/rsc/server-action.ts +62 -67
  243. package/src/rsc/ssr-setup.ts +16 -0
  244. package/src/rsc/types.ts +10 -5
  245. package/src/runtime-env.ts +18 -0
  246. package/src/search-params.ts +4 -20
  247. package/src/segment-loader-promise.ts +14 -2
  248. package/src/segment-system.tsx +199 -142
  249. package/src/serialize.ts +243 -0
  250. package/src/server/context.ts +150 -51
  251. package/src/server/cookie-store.ts +80 -5
  252. package/src/server/handle-store.ts +7 -24
  253. package/src/server/loader-registry.ts +5 -24
  254. package/src/server/request-context.ts +165 -87
  255. package/src/ssr/index.tsx +14 -14
  256. package/src/static-handler.ts +10 -13
  257. package/src/testing/cache-status.ts +162 -0
  258. package/src/testing/collect-handle.ts +40 -0
  259. package/src/testing/dispatch.ts +618 -0
  260. package/src/testing/dom.entry.ts +22 -0
  261. package/src/testing/e2e/fixture.ts +188 -0
  262. package/src/testing/e2e/index.ts +128 -0
  263. package/src/testing/e2e/matchers.ts +35 -0
  264. package/src/testing/e2e/page-helpers.ts +272 -0
  265. package/src/testing/e2e/parity.ts +387 -0
  266. package/src/testing/e2e/server.ts +195 -0
  267. package/src/testing/flight-matchers.ts +97 -0
  268. package/src/testing/flight-normalize.ts +11 -0
  269. package/src/testing/flight-runtime.d.ts +57 -0
  270. package/src/testing/flight-tree.ts +682 -0
  271. package/src/testing/flight.entry.ts +52 -0
  272. package/src/testing/flight.ts +232 -0
  273. package/src/testing/generated-routes.ts +183 -0
  274. package/src/testing/index.ts +99 -0
  275. package/src/testing/internal/context.ts +348 -0
  276. package/src/testing/internal/flight-client-globals.ts +30 -0
  277. package/src/testing/internal/seed-vars.ts +54 -0
  278. package/src/testing/render-handler.ts +330 -0
  279. package/src/testing/render-route.tsx +566 -0
  280. package/src/testing/run-loader.ts +378 -0
  281. package/src/testing/run-middleware.ts +205 -0
  282. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  283. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  284. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  285. package/src/testing/vitest-stubs/version.ts +5 -0
  286. package/src/testing/vitest.ts +305 -0
  287. package/src/theme/ThemeProvider.tsx +0 -52
  288. package/src/theme/ThemeScript.tsx +0 -6
  289. package/src/theme/constants.ts +0 -12
  290. package/src/theme/index.ts +0 -7
  291. package/src/theme/theme-context.ts +1 -5
  292. package/src/theme/theme-script.ts +0 -14
  293. package/src/theme/use-theme.ts +0 -3
  294. package/src/types/boundaries.ts +0 -35
  295. package/src/types/cache-types.ts +13 -4
  296. package/src/types/error-types.ts +30 -90
  297. package/src/types/global-namespace.ts +54 -41
  298. package/src/types/handler-context.ts +97 -22
  299. package/src/types/index.ts +1 -10
  300. package/src/types/loader-types.ts +6 -3
  301. package/src/types/request-scope.ts +0 -19
  302. package/src/types/route-config.ts +6 -50
  303. package/src/types/route-entry.ts +0 -6
  304. package/src/types/segments.ts +18 -14
  305. package/src/urls/include-helper.ts +9 -56
  306. package/src/urls/index.ts +1 -11
  307. package/src/urls/path-helper-types.ts +19 -5
  308. package/src/urls/path-helper.ts +17 -106
  309. package/src/urls/pattern-types.ts +36 -19
  310. package/src/urls/response-types.ts +20 -19
  311. package/src/urls/type-extraction.ts +58 -139
  312. package/src/urls/urls-function.ts +1 -18
  313. package/src/use-loader.tsx +292 -107
  314. package/src/vite/debug.ts +1 -0
  315. package/src/vite/discovery/bundle-postprocess.ts +8 -7
  316. package/src/vite/discovery/discover-routers.ts +95 -82
  317. package/src/vite/discovery/discovery-errors.ts +194 -0
  318. package/src/vite/discovery/prerender-collection.ts +26 -34
  319. package/src/vite/discovery/route-types-writer.ts +40 -84
  320. package/src/vite/discovery/state.ts +39 -1
  321. package/src/vite/discovery/virtual-module-codegen.ts +14 -34
  322. package/src/vite/index.ts +4 -0
  323. package/src/vite/plugin-types.ts +185 -10
  324. package/src/vite/plugins/cjs-to-esm.ts +3 -18
  325. package/src/vite/plugins/client-ref-dedup.ts +0 -11
  326. package/src/vite/plugins/client-ref-hashing.ts +12 -11
  327. package/src/vite/plugins/cloudflare-protocol-stub.ts +1 -21
  328. package/src/vite/plugins/expose-action-id.ts +4 -75
  329. package/src/vite/plugins/expose-id-utils.ts +3 -54
  330. package/src/vite/plugins/expose-ids/export-analysis.ts +76 -34
  331. package/src/vite/plugins/expose-ids/handler-transform.ts +6 -74
  332. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -20
  333. package/src/vite/plugins/expose-ids/router-transform.ts +0 -13
  334. package/src/vite/plugins/expose-internal-ids.ts +57 -67
  335. package/src/vite/plugins/performance-tracks.ts +9 -16
  336. package/src/vite/plugins/refresh-cmd.ts +1 -1
  337. package/src/vite/plugins/use-cache-transform.ts +26 -49
  338. package/src/vite/plugins/vercel-output.ts +258 -0
  339. package/src/vite/plugins/version-injector.ts +2 -32
  340. package/src/vite/plugins/version-plugin.ts +32 -23
  341. package/src/vite/plugins/virtual-entries.ts +35 -17
  342. package/src/vite/rango.ts +148 -115
  343. package/src/vite/router-discovery.ts +220 -68
  344. package/src/vite/utils/ast-handler-extract.ts +15 -31
  345. package/src/vite/utils/bundle-analysis.ts +10 -15
  346. package/src/vite/utils/client-chunks.ts +184 -0
  347. package/src/vite/utils/forward-user-plugins.ts +171 -0
  348. package/src/vite/utils/manifest-utils.ts +4 -59
  349. package/src/vite/utils/package-resolution.ts +1 -73
  350. package/src/vite/utils/prerender-utils.ts +0 -34
  351. package/src/vite/utils/shared-utils.ts +95 -43
  352. package/src/browser/action-response-classifier.ts +0 -99
  353. package/src/browser/react/use-client-cache.ts +0 -58
  354. package/src/browser/shallow.ts +0 -40
  355. package/src/handles/index.ts +0 -7
  356. package/src/router/middleware-cookies.ts +0 -55
@@ -0,0 +1,387 @@
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
+ import { DEFAULT_STATE_COOKIE_PREFIX } from "../../browser/cookie-name.js";
10
+
11
+ export interface ParityDescribeOptions extends Partial<
12
+ Omit<FixtureOptions, "mode">
13
+ > {}
14
+
15
+ export type ParityIntent =
16
+ | { navigate: string }
17
+ | { submit: { testId: string; data?: Record<string, string> } };
18
+
19
+ export interface ExpectParityOptions {
20
+ /** data-testid values whose text content must match across JS and no-JS. */
21
+ observe: string[];
22
+ /** Base URL to resolve a relative `navigate` intent against. */
23
+ baseURL?: string;
24
+ /**
25
+ * Escape hatch invoked after the intent is applied and before the snapshot is
26
+ * taken, on BOTH the JS and the no-JS transports. When provided for a `submit`
27
+ * intent it REPLACES the generic settle (the navigation/observed-change wait):
28
+ * you take responsibility for awaiting the post-submit effect — for example,
29
+ * awaiting a specific testid to reach a known value, or a network response the
30
+ * page does not reflect in the observed testids. Receives the page for the
31
+ * transport being snapshotted.
32
+ */
33
+ waitFor?: (page: Page) => Promise<void>;
34
+ /**
35
+ * Cookie NAMES to exclude from the JS-vs-no-JS jar comparison, matched exactly
36
+ * (string) or by pattern (RegExp). The rango state cookie (default prefix
37
+ * `rango-state`) is ALWAYS excluded — it is written/rotated only by the client
38
+ * runtime, so it is JS-only by design and never appears in the no-JS jar. Use
39
+ * this for a custom `stateCookiePrefix` (e.g. `[/^myapp-state_/]`) or any other
40
+ * volatile/JS-only cookie (analytics, CSRF) that should not break parity.
41
+ */
42
+ ignoreCookies?: ReadonlyArray<string | RegExp>;
43
+ }
44
+
45
+ export interface Parity {
46
+ /**
47
+ * Register a dev describe (title `name`) and a production describe (title
48
+ * `` `${name} (production)` ``) from one body. The `(production)` suffix is
49
+ * generated here, so the production suite always lands in the production
50
+ * bucket regardless of how the consumer names things.
51
+ *
52
+ * `options.root` is required, either here or as `defaultRoot` passed to the
53
+ * factory.
54
+ */
55
+ parityDescribe: (
56
+ name: string,
57
+ body: (f: Fixture) => void,
58
+ options?: ParityDescribeOptions,
59
+ ) => void;
60
+ /**
61
+ * Run a single `intent` over both the JS path (the given `page`) and a
62
+ * fresh no-JS context, then assert the observed snapshots are equal.
63
+ *
64
+ * PE parity only holds if the consumer's submit target is a real `<form>`
65
+ * that progressively enhances: with JS disabled the browser performs a native
66
+ * form POST, and the server must produce the same observable result the
67
+ * enhanced client path produces. Navigation intents must be plain links/URLs
68
+ * that resolve server-side without JS.
69
+ *
70
+ * For a `submit` intent the helper waits for the action's observable effect
71
+ * before snapshotting: either a navigation away from the form, or a change to
72
+ * one of the `observe` testids from its pre-submit value (then a brief
73
+ * stability confirm). A submit that produces neither within ~5s throws —
74
+ * include the testid that changes in `observe`, or pass `waitFor` to express
75
+ * the precise wait. This prevents snapshotting the pre-submit DOM when the
76
+ * action is slow.
77
+ *
78
+ * The snapshot is intentionally strict: it compares the text of every
79
+ * observed `data-testid`, the resulting `page.url()`, and the cookies visible
80
+ * via `document.cookie`. No ephemeral-value normalization is applied in v1;
81
+ * if a consumer's page renders nondeterministic values, exclude that testid
82
+ * from `observe`.
83
+ *
84
+ * LIMITATION: cookie parity is read from `document.cookie`, which by design
85
+ * EXCLUDES HttpOnly cookies. Session/auth cookies (the typical HttpOnly case)
86
+ * are therefore NOT compared — a PE/JS divergence in an HttpOnly cookie will
87
+ * not be caught here. Assert on those via `read_network_requests` / response
88
+ * Set-Cookie headers in a dedicated test, not expectParity.
89
+ *
90
+ * Submit-intent requirements (the `submit` path runs the intent TWICE against
91
+ * the same server, and compares whole cookie jars from two contexts):
92
+ * - DOUBLE EXECUTION: the JS path submits, then the no-JS pass reloads the
93
+ * same `originUrl` in a fresh context and submits AGAIN. Both hit the one
94
+ * running server, so a non-idempotent action runs twice. The no-JS snapshot
95
+ * then observes BOTH mutations unless the mutated state is per-session /
96
+ * per-context — e.g. an add-to-cart count only reaches the same value on
97
+ * both transports if the cart is session-scoped (the JS context and the
98
+ * fresh no-JS context are distinct sessions). A globally-shared counter
99
+ * would read N after the JS submit and N+1 after the no-JS submit and
100
+ * false-mismatch. Make the action's observable state session/context-scoped,
101
+ * or assert the submit path some other way.
102
+ * - COOKIE JAR vs DELTA: the cookie check compares the WHOLE `document.cookie`
103
+ * of two different contexts. The JS context carries every cookie accumulated
104
+ * before the intent (consent, analytics, prior navigations); the fresh no-JS
105
+ * context starts empty. Unrelated pre-existing cookies therefore false-
106
+ * mismatch — expectParity compares jars, not the per-submit cookie delta.
107
+ * Keep the JS context's pre-intent cookie state minimal, or assert the
108
+ * specific Set-Cookie elsewhere.
109
+ * - RANGO STATE COOKIE: the rango state cookie (default prefix `rango-state`)
110
+ * is written/rotated only by the client runtime, so it is JS-only by design
111
+ * and would always diverge — it is excluded from the comparison
112
+ * automatically. A custom `stateCookiePrefix` (or any other volatile/JS-only
113
+ * cookie) is excluded via `opts.ignoreCookies`.
114
+ */
115
+ expectParity: (
116
+ page: Page,
117
+ intent: ParityIntent,
118
+ opts: ExpectParityOptions,
119
+ ) => Promise<void>;
120
+ }
121
+
122
+ interface ParitySnapshot {
123
+ testIds: Record<string, string | null>;
124
+ url: string;
125
+ cookies: string;
126
+ }
127
+
128
+ async function applyIntent(
129
+ page: Page,
130
+ intent: ParityIntent,
131
+ baseURL?: string,
132
+ ): Promise<void> {
133
+ if ("navigate" in intent) {
134
+ const target = baseURL
135
+ ? new URL(intent.navigate, baseURL).href
136
+ : intent.navigate;
137
+ await page.goto(target, { waitUntil: "networkidle" });
138
+ return;
139
+ }
140
+ const form = page.locator(`[data-testid="${intent.submit.testId}"]`);
141
+ if (intent.submit.data) {
142
+ for (const [name, value] of Object.entries(intent.submit.data)) {
143
+ await form.locator(`[name="${name}"]`).fill(value);
144
+ }
145
+ }
146
+ // No post-click wait here: a native (no-JS) submit triggers a top-level
147
+ // navigation while an enhanced (JS) submit usually updates the DOM in place
148
+ // with no navigation, so a navigation-based wait races the click and can
149
+ // resolve before the effect lands. The post-action settle in expectParity is
150
+ // DOM-driven (settleSubmit) and works whether or not a navigation occurs.
151
+ await form
152
+ .locator('button[type="submit"], input[type="submit"]')
153
+ .first()
154
+ .click();
155
+ }
156
+
157
+ // Concatenate the observed testids' text into a single comparable string used
158
+ // to detect post-submit change/stability. "\0" marks an absent testid and
159
+ // "\x01" joins parts so two distinct testid layouts can never concatenate to the
160
+ // same string. Written as escaped control chars (not literal bytes) for source
161
+ // readability.
162
+ async function readObserved(page: Page, observe: string[]): Promise<string> {
163
+ const parts: string[] = [];
164
+ for (const id of observe) {
165
+ const text = await page
166
+ .locator(`[data-testid="${id}"]`)
167
+ .first()
168
+ .textContent()
169
+ .catch(() => null);
170
+ parts.push(`${id}=${text ?? "\0"}`);
171
+ }
172
+ return parts.join("\x01");
173
+ }
174
+
175
+ // Settle a submit's observable effect. An enhanced (JS) submit mutates the DOM
176
+ // in place with no navigation, while a native (no-JS) submit navigates — so we
177
+ // wait for EITHER a navigation away from the form OR a change in the observed
178
+ // testids from their pre-submit `baseline`, then confirm the new state is stable
179
+ // across two reads. Requiring an observed change (not just stability) closes the
180
+ // gap where a slow action leaves the pre-submit DOM momentarily stable and gets
181
+ // snapshotted as the "result". A submit that produces neither a navigation nor
182
+ // an observed change within the ceiling throws, rather than silently capturing
183
+ // the pre-submit state — pass `waitFor` when the observed set cannot capture the
184
+ // effect.
185
+ async function settleSubmit(
186
+ page: Page,
187
+ observe: string[],
188
+ baseline: string,
189
+ originUrl: string,
190
+ ): Promise<void> {
191
+ const pollIntervalMs = 50;
192
+ const maxAttempts = 100; // ~5s ceiling
193
+ let landed = false;
194
+ let last = baseline;
195
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
196
+ await page.waitForTimeout(pollIntervalMs);
197
+ const current = await readObserved(page, observe);
198
+ if (!landed) {
199
+ if (page.url() !== originUrl || current !== baseline) {
200
+ landed = true;
201
+ last = current;
202
+ }
203
+ continue;
204
+ }
205
+ if (current === last) return;
206
+ last = current;
207
+ }
208
+ if (!landed) {
209
+ throw new Error(
210
+ `expectParity: the submit intent produced no observable effect within 5s — ` +
211
+ `no navigation away from "${originUrl}" and no change to the observed ` +
212
+ `testids [${observe.join(", ")}]. Include the testid that changes in ` +
213
+ `\`observe\`, or pass \`waitFor\` to express the precise post-submit wait.`,
214
+ );
215
+ }
216
+ // Landed but never stabilized within the ceiling: warn (so a slow/flaky action
217
+ // is visible rather than silently snapshotting a mid-flight DOM), then fall
218
+ // through and snapshot the last-read state — the parity equality assertion
219
+ // still surfaces any JS-vs-no-JS mismatch. A throw here would risk failing a
220
+ // slow-but-correct submit, so this stays a warning.
221
+ console.warn(
222
+ `expectParity: the observed testids [${observe.join(", ")}] did not stabilize ` +
223
+ `within 5s; snapshotting the last-read state. If this is flaky, pass ` +
224
+ `\`waitFor\` to express the precise post-submit wait.`,
225
+ );
226
+ }
227
+
228
+ async function snapshot(
229
+ page: Page,
230
+ observe: string[],
231
+ ): Promise<ParitySnapshot> {
232
+ const testIds: Record<string, string | null> = {};
233
+ for (const id of observe) {
234
+ // Match readObserved: .first() (a testid may repeat) + .catch (a missing
235
+ // testid yields null, not an unhandled rejection).
236
+ testIds[id] = await page
237
+ .locator(`[data-testid="${id}"]`)
238
+ .first()
239
+ .textContent()
240
+ .catch(() => null);
241
+ }
242
+ return {
243
+ testIds,
244
+ url: page.url(),
245
+ cookies: await page.evaluate(() => document.cookie),
246
+ };
247
+ }
248
+
249
+ // Reduce a `document.cookie` string to the cookies that should match across the
250
+ // JS and no-JS transports. The rango state cookie (default prefix `rango-state`)
251
+ // is always dropped — it is written/rotated only by the client runtime, so it
252
+ // exists in the JS jar but never the no-JS one. `ignore` drops additional names
253
+ // (a custom stateCookiePrefix, or other JS-only/volatile cookies) by exact
254
+ // string or RegExp match. Returns a normalized, sorted `name=value; ...` string.
255
+ function parityCookies(
256
+ cookieString: string,
257
+ ignore: ReadonlyArray<string | RegExp>,
258
+ ): string {
259
+ const isIgnored = (name: string): boolean => {
260
+ if (name.startsWith(DEFAULT_STATE_COOKIE_PREFIX)) return true;
261
+ return ignore.some((m) =>
262
+ typeof m === "string" ? m === name : m.test(name),
263
+ );
264
+ };
265
+ return cookieString
266
+ .split(";")
267
+ .map((c) => c.trim())
268
+ .filter((c) => c.length > 0)
269
+ .filter((c) => {
270
+ const eq = c.indexOf("=");
271
+ const name = eq === -1 ? c : c.slice(0, eq);
272
+ return !isIgnored(name);
273
+ })
274
+ .sort()
275
+ .join("; ");
276
+ }
277
+
278
+ export function createParity({
279
+ test: _test,
280
+ expect,
281
+ useFixture,
282
+ defaultRoot,
283
+ }: {
284
+ test: TestType<any, any>;
285
+ expect: Expect;
286
+ useFixture: (options: FixtureOptions) => Fixture;
287
+ defaultRoot?: string;
288
+ }): Parity {
289
+ function parityDescribe(
290
+ name: string,
291
+ body: (f: Fixture) => void,
292
+ options?: ParityDescribeOptions,
293
+ ): void {
294
+ const root = options?.root ?? defaultRoot;
295
+ if (!root) {
296
+ throw new Error(
297
+ `parityDescribe("${name}") requires a root: pass options.root or set defaultRoot in createRangoE2E.`,
298
+ );
299
+ }
300
+ _test.describe(name, () => {
301
+ const f = useFixture({ ...options, root, mode: "dev" });
302
+ body(f);
303
+ });
304
+ _test.describe(`${name} (production)`, () => {
305
+ const f = useFixture({ ...options, root, mode: "build" });
306
+ body(f);
307
+ });
308
+ }
309
+
310
+ async function expectParity(
311
+ page: Page,
312
+ intent: ParityIntent,
313
+ opts: ExpectParityOptions,
314
+ ): Promise<void> {
315
+ // For a submit intent, the form lives on the page the caller already
316
+ // navigated to; capture that origin URL before the JS submit changes it so
317
+ // the no-JS path can load the same form, and so settleSubmit can detect a
318
+ // navigation away from it.
319
+ const originUrl = page.url();
320
+
321
+ // Settle the intent's observable effect before snapshotting. A `navigate`
322
+ // intent already awaited its navigation in applyIntent (page.goto), so only
323
+ // `submit` needs the DOM-driven settle, and only when no `waitFor` override
324
+ // is given. waitFor, when present, replaces the generic settle for submit
325
+ // and runs after the navigation for navigate — on whichever page is about to
326
+ // be snapshotted.
327
+ const settle = async (
328
+ target: Page,
329
+ baseline: string,
330
+ origin: string,
331
+ ): Promise<void> => {
332
+ if ("submit" in intent) {
333
+ if (opts.waitFor) {
334
+ await opts.waitFor(target);
335
+ } else {
336
+ await settleSubmit(target, opts.observe, baseline, origin);
337
+ }
338
+ } else if (opts.waitFor) {
339
+ await opts.waitFor(target);
340
+ }
341
+ };
342
+
343
+ // JS path: the given page (JS enabled by default). Read the pre-submit
344
+ // baseline before applying the intent so the settle can require a change.
345
+ const jsBaseline =
346
+ "submit" in intent ? await readObserved(page, opts.observe) : "";
347
+ await applyIntent(page, intent, opts.baseURL);
348
+ await settle(page, jsBaseline, originUrl);
349
+ const jsSnapshot = await snapshot(page, opts.observe);
350
+
351
+ // No-JS path: a fresh context with scripting disabled.
352
+ const browser = page.context().browser()!;
353
+ const noJsContext = await browser.newContext({ javaScriptEnabled: false });
354
+ try {
355
+ const noJsPage = await noJsContext.newPage();
356
+ if (!("navigate" in intent)) {
357
+ // A submit intent needs the form rendered first; start from the same
358
+ // URL the JS path observed the form on.
359
+ await noJsPage.goto(originUrl, { waitUntil: "networkidle" });
360
+ }
361
+ const noJsOrigin = noJsPage.url();
362
+ const noJsBaseline =
363
+ "submit" in intent ? await readObserved(noJsPage, opts.observe) : "";
364
+ await applyIntent(noJsPage, intent, opts.baseURL);
365
+ await settle(noJsPage, noJsBaseline, noJsOrigin);
366
+ const noJsSnapshot = await snapshot(noJsPage, opts.observe);
367
+
368
+ expect(noJsSnapshot.testIds).toEqual(jsSnapshot.testIds);
369
+ // Compare pathname + search + hash, not pathname alone: a JS vs no-JS flow
370
+ // that diverges only in query/hash (e.g. /search?q=a vs /search?q=b) would
371
+ // otherwise pass.
372
+ const locationOf = (u: string): string => {
373
+ const url = new URL(u);
374
+ return url.pathname + url.search + url.hash;
375
+ };
376
+ expect(locationOf(noJsSnapshot.url)).toEqual(locationOf(jsSnapshot.url));
377
+ const ignore = opts.ignoreCookies ?? [];
378
+ expect(parityCookies(noJsSnapshot.cookies, ignore)).toEqual(
379
+ parityCookies(jsSnapshot.cookies, ignore),
380
+ );
381
+ } finally {
382
+ await noJsContext.close();
383
+ }
384
+ }
385
+
386
+ return { parityDescribe, expectParity };
387
+ }
@@ -0,0 +1,195 @@
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
+ /**
16
+ * Resolves with the process's exit code (null if killed by signal) when it
17
+ * exits. Unlike `done`, callers can branch on a nonzero code. Used to fail
18
+ * the build step loudly; the long-running serve processes never inspect it.
19
+ */
20
+ exitCode: Promise<number | null>;
21
+ findPort: (timeoutMs?: number) => Promise<number>;
22
+ kill: () => void;
23
+ stdout: () => string;
24
+ stderr: () => string;
25
+ }
26
+
27
+ export function runCli(
28
+ options: { command: string; label?: string } & SpawnOptions,
29
+ ): RunCliHandle {
30
+ const [name, ...args] = options.command.split(" ");
31
+ // Vite registers `process.stdin.on("end", ...)` as parent-death detection and
32
+ // calls process.exit() when stdin reaches EOF, unless process.env.CI === "true"
33
+ // (see vite's setupSIGTERMListener). Servers spawned here receive an stdin that
34
+ // hits EOF immediately, so without CI=true the dev/preview server shuts itself
35
+ // down before it finishes starting. Real CI runners set CI=true; mirror that for
36
+ // locally-spawned servers so they stay alive for the duration of the tests.
37
+ const child = x(name!, args, {
38
+ nodeOptions: {
39
+ ...options,
40
+ env: { ...process.env, CI: "true", ...options.env },
41
+ },
42
+ }).process!;
43
+ const label = `[${options.label ?? "cli"}]`;
44
+ let stdout = "";
45
+ let stderr = "";
46
+ child.stdout!.on("data", (data) => {
47
+ stdout += stripVTControlCharacters(String(data));
48
+ if (process.env.TEST_DEBUG) {
49
+ console.log(styleText("cyan", label), data.toString());
50
+ }
51
+ });
52
+ child.stderr!.on("data", (data) => {
53
+ stderr += stripVTControlCharacters(String(data));
54
+ if (process.env.TEST_DEBUG) {
55
+ console.log(styleText("magenta", label), data.toString());
56
+ }
57
+ });
58
+ let resolveExitCode!: (code: number | null) => void;
59
+ const exitCode = new Promise<number | null>((resolve) => {
60
+ resolveExitCode = resolve;
61
+ });
62
+ const done = new Promise<void>((resolve) => {
63
+ child.on("exit", (code) => {
64
+ if (code !== 0 && code !== 143 && process.platform !== "win32") {
65
+ console.log(styleText("magenta", `${label}`), `exit code ${code}`);
66
+ }
67
+ resolveExitCode(code);
68
+ resolve();
69
+ });
70
+ });
71
+
72
+ async function findPort(timeoutMs = 60000): Promise<number> {
73
+ let stdout = "";
74
+ return new Promise((resolve, reject) => {
75
+ const timeout = setTimeout(() => {
76
+ reject(
77
+ new Error(
78
+ `Timed out waiting for server to start after ${timeoutMs}ms. Stdout: ${stdout}`,
79
+ ),
80
+ );
81
+ }, timeoutMs);
82
+
83
+ child.stdout!.on("data", (data) => {
84
+ stdout += stripVTControlCharacters(String(data));
85
+ const match = stdout.match(/http:\/\/localhost:(\d+)/);
86
+ if (match) {
87
+ clearTimeout(timeout);
88
+ resolve(Number(match[1]));
89
+ }
90
+ });
91
+
92
+ child.on("exit", (code) => {
93
+ clearTimeout(timeout);
94
+ if (code !== 0) {
95
+ reject(
96
+ new Error(`Server exited with code ${code}. Stdout: ${stdout}`),
97
+ );
98
+ }
99
+ });
100
+ });
101
+ }
102
+
103
+ function kill() {
104
+ if (process.platform === "win32") {
105
+ spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"]);
106
+ } else {
107
+ // Kill entire process group (Vite spawns child processes like workerd).
108
+ // Falls back to direct kill if process group kill fails.
109
+ try {
110
+ process.kill(-child.pid!, "SIGTERM");
111
+ } catch {
112
+ child.kill();
113
+ }
114
+ }
115
+ }
116
+
117
+ return {
118
+ proc: child,
119
+ done,
120
+ exitCode,
121
+ findPort,
122
+ kill,
123
+ stdout: () => stdout,
124
+ stderr: () => stderr,
125
+ };
126
+ }
127
+
128
+ export function tailOutput(text: string, maxChars = 4000): string {
129
+ if (!text) return "(empty)";
130
+ if (text.length <= maxChars) return text;
131
+ return `...${text.slice(-maxChars)}`;
132
+ }
133
+
134
+ export function createIsolatedViteCacheDir(
135
+ cwd: string,
136
+ projectName: string,
137
+ mode: "dev" | "build" | undefined,
138
+ ): string {
139
+ const safeProjectName = projectName.replace(/[^a-zA-Z0-9_-]/g, "-");
140
+ return path.join(
141
+ cwd,
142
+ ".vite-isolated",
143
+ `${safeProjectName}-${mode ?? "server"}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
144
+ );
145
+ }
146
+
147
+ export async function waitForReady(
148
+ url: string,
149
+ getOutput?: () => { stdout: string; stderr: string },
150
+ timeoutMs: number = process.env.CI ? 60000 : 30000,
151
+ ): Promise<void> {
152
+ const deadline = Date.now() + timeoutMs;
153
+ while (Date.now() < deadline) {
154
+ try {
155
+ const res = await fetch(url);
156
+ if (res.ok) return;
157
+ } catch {}
158
+ await new Promise((r) => setTimeout(r, 100));
159
+ }
160
+ const output = getOutput?.();
161
+ const details = output
162
+ ? `\nRecent stdout:\n${tailOutput(output.stdout)}\n\nRecent stderr:\n${tailOutput(output.stderr)}`
163
+ : "";
164
+ throw new Error(`Server not ready after ${timeoutMs}ms: ${url}${details}`);
165
+ }
166
+
167
+ /**
168
+ * Warm up an isolated dev server by making real SSR requests.
169
+ * The first SSR request triggers Vite's dep optimizer to discover SSR deps.
170
+ * After optimization, modules are re-evaluated and in-memory caches reset.
171
+ * We retry until the server returns a stable 200, absorbing the dep
172
+ * optimization cycle so subsequent test requests hit a settled server.
173
+ */
174
+ export async function warmupDevServer(url: string): Promise<void> {
175
+ const deadline = Date.now() + 30_000;
176
+ let lastOk = 0;
177
+ // Need two consecutive OK responses to confirm the server is settled
178
+ // (first OK may precede dep optimization, second confirms stability).
179
+ while (Date.now() < deadline && lastOk < 2) {
180
+ try {
181
+ const res = await fetch(url, {
182
+ headers: { accept: "text/html" },
183
+ });
184
+ if (res.ok) {
185
+ await res.text(); // consume body to complete SSR pipeline
186
+ lastOk++;
187
+ } else {
188
+ lastOk = 0;
189
+ }
190
+ } catch {
191
+ lastOk = 0;
192
+ }
193
+ await new Promise((r) => setTimeout(r, 1000));
194
+ }
195
+ }
@@ -0,0 +1,97 @@
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 from the serializer-free module, NOT ./flight.js: that module
33
+ // top-level imports the vendored react-server-dom serializer, which throws when
34
+ // loaded outside the `react-server` condition. flight-matchers must be
35
+ // importable under the plain node condition (a consumer's shared setupFiles
36
+ // does `expect.extend(flightMatchers)`), so it cannot transitively pull in the
37
+ // serializer.
38
+ import { normalizeFlight } from "./flight-normalize.js";
39
+
40
+ interface MatcherResult {
41
+ pass: boolean;
42
+ message: () => string;
43
+ }
44
+
45
+ export const flightMatchers: {
46
+ toMatchFlight(received: string, expected: string): MatcherResult;
47
+ toMatchFlightSnapshot(received: string): MatcherResult;
48
+ } = {
49
+ toMatchFlight(received: string, expected: string): MatcherResult {
50
+ if (typeof received !== "string") {
51
+ return {
52
+ pass: false,
53
+ message: () =>
54
+ "toMatchFlight expected a rendered Flight string (the result of " +
55
+ "`await renderToFlightString(...)`), but received " +
56
+ `${typeof received}. Render the element first: ` +
57
+ "`expect(await renderToFlightString(<C/>)).toMatchFlight(...)`.",
58
+ };
59
+ }
60
+ const normalized = normalizeFlight(received);
61
+ const pass = normalized.includes(expected);
62
+ return {
63
+ pass,
64
+ message: () =>
65
+ pass
66
+ ? `Expected Flight string not to contain ${JSON.stringify(expected)}.`
67
+ : `Expected Flight string to contain ${JSON.stringify(expected)}.\n` +
68
+ `Received (normalized):\n${normalized}`,
69
+ };
70
+ },
71
+
72
+ toMatchFlightSnapshot(received: string): MatcherResult {
73
+ expect(normalizeFlight(received)).toMatchSnapshot();
74
+ return {
75
+ pass: true,
76
+ message: () => "Flight snapshot matched.",
77
+ };
78
+ },
79
+ };
80
+
81
+ /**
82
+ * Vitest Assertion augmentation so `toMatchFlight` / `toMatchFlightSnapshot`
83
+ * are typed on `expect(...)`. Imported for its side-effecting type
84
+ * augmentation; importing flight-matchers (which imports this) is enough.
85
+ */
86
+ declare module "vitest" {
87
+ interface Assertion<T = any> {
88
+ /** Assert the normalized Flight string contains `expected`. */
89
+ toMatchFlight(expected: string): T;
90
+ /** Snapshot the normalized Flight string. */
91
+ toMatchFlightSnapshot(): T;
92
+ }
93
+ interface AsymmetricMatchersContaining {
94
+ toMatchFlight(expected: string): void;
95
+ toMatchFlightSnapshot(): void;
96
+ }
97
+ }
@@ -0,0 +1,11 @@
1
+ // Volatile leading reference row: `:N<timestamp>` (dev only).
2
+ const REFERENCE_ROW_RE = /^:N[\d.]+\n/;
3
+ // Absolute file:// paths in dev stack rows. Pattern matches frames
4
+ // `["Component","file:///path",<line>,<col>...]` and scrubs the path only.
5
+ const FILE_URL_RE = /file:\/\/[^"\\]+(?=",\d+,\d+)/g;
6
+
7
+ export function normalizeFlight(flight: string): string {
8
+ return flight
9
+ .replace(REFERENCE_ROW_RE, "")
10
+ .replace(FILE_URL_RE, "file://<path>");
11
+ }