@rangojs/router 0.0.0-experimental.124 → 0.0.0-experimental.126
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 +6 -4
- package/dist/bin/rango.js +3 -4
- package/dist/vite/index.js +315 -68
- package/package.json +19 -18
- package/skills/breadcrumbs/SKILL.md +60 -0
- package/skills/hooks/SKILL.md +2 -2
- package/skills/route/SKILL.md +6 -0
- package/skills/server-actions/SKILL.md +25 -1
- package/skills/testing/SKILL.md +17 -17
- package/skills/testing/cache-prerender.md +29 -3
- package/skills/testing/flight.md +13 -10
- package/skills/testing/render-handler.md +3 -0
- package/skills/testing/server-tree.md +1 -1
- package/skills/testing/setup.md +1 -1
- package/src/__internal.ts +0 -65
- package/src/browser/action-coordinator.ts +1 -1
- package/src/browser/action-fence.ts +10 -0
- package/src/browser/event-controller.ts +1 -83
- package/src/browser/navigation-store-handle.ts +3 -4
- package/src/browser/navigation-store.ts +0 -39
- package/src/browser/navigation-transaction.ts +0 -32
- package/src/browser/partial-update.ts +23 -84
- package/src/browser/prefetch/cache.ts +6 -45
- package/src/browser/prefetch/queue.ts +6 -3
- package/src/browser/rango-state.ts +2 -23
- package/src/browser/react/Link.tsx +0 -2
- package/src/browser/react/NavigationProvider.tsx +2 -1
- package/src/browser/react/ScrollRestoration.tsx +10 -6
- package/src/browser/react/filter-segment-order.ts +0 -2
- package/src/browser/react/index.ts +0 -45
- package/src/browser/react/location-state-shared.ts +0 -13
- package/src/browser/react/location-state.ts +0 -1
- package/src/browser/react/use-action.ts +6 -15
- package/src/browser/react/use-handle.ts +0 -5
- package/src/browser/react/use-link-status.ts +0 -4
- package/src/browser/react/use-navigation.ts +0 -3
- package/src/browser/react/use-params.ts +0 -2
- package/src/browser/react/use-router.ts +2 -1
- package/src/browser/react/use-search-params.ts +0 -5
- package/src/browser/react/use-segments.ts +0 -13
- package/src/browser/rsc-router.tsx +10 -3
- package/src/browser/server-action-bridge.ts +51 -3
- package/src/browser/types.ts +23 -5
- package/src/browser/validate-redirect-origin.ts +43 -16
- package/src/build/index.ts +8 -9
- package/src/build/route-trie.ts +46 -11
- package/src/build/route-types/param-extraction.ts +6 -3
- package/src/build/route-types/router-processing.ts +0 -8
- package/src/cache/cache-policy.ts +0 -54
- package/src/cache/cache-runtime.ts +48 -24
- package/src/cache/cache-scope.ts +0 -27
- package/src/cache/cache-tag.ts +0 -37
- package/src/cache/cf/cf-cache-store.ts +72 -45
- package/src/cache/cf/index.ts +0 -24
- package/src/cache/document-cache.ts +10 -36
- package/src/cache/handle-snapshot.ts +0 -40
- package/src/cache/index.ts +0 -27
- package/src/cache/memory-segment-store.ts +0 -52
- package/src/cache/profile-registry.ts +6 -30
- package/src/cache/read-through-swr.ts +41 -11
- package/src/cache/segment-codec.ts +0 -16
- package/src/cache/types.ts +0 -98
- package/src/client.rsc.tsx +4 -22
- package/src/client.tsx +19 -32
- package/src/context-var.ts +12 -0
- package/src/defer.ts +196 -0
- package/src/deps/ssr.ts +0 -1
- package/src/handle.ts +2 -12
- package/src/handles/MetaTags.tsx +0 -14
- package/src/handles/breadcrumbs.ts +16 -5
- package/src/handles/meta.ts +0 -39
- package/src/host/cookie-handler.ts +0 -36
- package/src/host/errors.ts +0 -24
- package/src/host/index.ts +6 -0
- package/src/host/pattern-matcher.ts +7 -50
- package/src/host/router.ts +1 -65
- package/src/host/testing.ts +0 -16
- package/src/host/types.ts +6 -2
- package/src/href-client.ts +0 -4
- package/src/index.rsc.ts +27 -2
- package/src/index.ts +7 -0
- package/src/internal-debug.ts +2 -4
- package/src/loader.rsc.ts +4 -15
- package/src/loader.ts +3 -9
- package/src/network-error-thrower.tsx +1 -6
- package/src/outlet-provider.tsx +1 -5
- package/src/prerender/param-hash.ts +10 -11
- package/src/prerender/store.ts +23 -30
- package/src/prerender.ts +34 -0
- package/src/redirect-origin.ts +100 -0
- package/src/root-error-boundary.tsx +1 -19
- package/src/route-content-wrapper.tsx +1 -44
- package/src/route-definition/dsl-helpers.ts +7 -19
- package/src/route-definition/helpers-types.ts +3 -3
- package/src/route-definition/redirect.ts +43 -9
- package/src/route-definition/resolve-handler-use.ts +6 -0
- package/src/route-map-builder.ts +0 -16
- package/src/router/content-negotiation.ts +0 -13
- package/src/router/error-handling.ts +12 -16
- package/src/router/find-match.ts +4 -31
- package/src/router/intercept-resolution.ts +10 -1
- package/src/router/lazy-includes.ts +1 -57
- package/src/router/loader-resolution.ts +25 -23
- package/src/router/logging.ts +0 -6
- package/src/router/manifest.ts +1 -25
- package/src/router/match-api.ts +0 -20
- package/src/router/match-context.ts +0 -22
- package/src/router/match-handlers.ts +0 -43
- package/src/router/match-middleware/background-revalidation.ts +0 -7
- package/src/router/match-middleware/cache-lookup.ts +96 -179
- package/src/router/match-middleware/cache-store.ts +0 -31
- package/src/router/match-middleware/intercept-resolution.ts +0 -22
- package/src/router/match-middleware/segment-resolution.ts +0 -22
- package/src/router/match-pipelines.ts +1 -42
- package/src/router/match-result.ts +1 -52
- package/src/router/metrics.ts +0 -34
- package/src/router/middleware-types.ts +0 -116
- package/src/router/middleware.ts +77 -60
- package/src/router/navigation-snapshot.ts +0 -51
- package/src/router/params-util.ts +23 -0
- package/src/router/pattern-matching.ts +5 -56
- package/src/router/prerender-match.ts +56 -51
- package/src/router/request-classification.ts +1 -38
- package/src/router/revalidation.ts +14 -62
- package/src/router/route-snapshot.ts +0 -1
- package/src/router/router-context.ts +0 -27
- package/src/router/router-interfaces.ts +10 -0
- package/src/router/segment-resolution/fresh.ts +25 -57
- package/src/router/segment-resolution/helpers.ts +34 -0
- package/src/router/segment-resolution/loader-cache.ts +35 -23
- package/src/router/segment-resolution/revalidation.ts +188 -283
- package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
- package/src/router/segment-resolution.ts +4 -1
- package/src/router/segment-wrappers.ts +0 -3
- package/src/router/telemetry-otel.ts +0 -20
- package/src/router/telemetry.ts +0 -22
- package/src/router/timeout.ts +0 -20
- package/src/router/trie-matching.ts +66 -45
- package/src/router/types.ts +1 -63
- package/src/router/url-params.ts +0 -5
- package/src/router.ts +8 -11
- package/src/rsc/handler-context.ts +1 -0
- package/src/rsc/handler.ts +20 -4
- package/src/rsc/helpers.ts +71 -3
- package/src/rsc/json-route-result.ts +38 -0
- package/src/rsc/origin-guard.ts +9 -15
- package/src/rsc/progressive-enhancement.ts +10 -1
- package/src/rsc/redirect-guard.ts +99 -0
- package/src/rsc/response-route-handler.ts +23 -18
- package/src/rsc/rsc-rendering.ts +2 -7
- package/src/rsc/runtime-warnings.ts +14 -0
- package/src/rsc/server-action.ts +34 -29
- package/src/rsc/types.ts +6 -3
- package/src/search-params.ts +0 -16
- package/src/segment-loader-promise.ts +14 -2
- package/src/segment-system.tsx +79 -88
- package/src/server/handle-store.ts +7 -24
- package/src/server/loader-registry.ts +5 -24
- package/src/server/request-context.ts +29 -92
- package/src/ssr/index.tsx +14 -14
- package/src/static-handler.ts +2 -27
- package/src/testing/cache-status.ts +44 -48
- package/src/testing/collect-handle.ts +1 -24
- package/src/testing/dispatch.ts +43 -6
- package/src/testing/e2e/index.ts +1 -22
- package/src/testing/e2e/matchers.ts +0 -16
- package/src/testing/flight-matchers.ts +0 -13
- package/src/testing/flight-normalize.ts +3 -30
- package/src/testing/flight.ts +46 -48
- package/src/testing/generated-routes.ts +1 -41
- package/src/testing/index.ts +1 -21
- package/src/testing/internal/context.ts +3 -45
- package/src/testing/internal/seed-vars.ts +0 -26
- package/src/testing/render-handler.ts +31 -61
- package/src/testing/render-route.tsx +75 -103
- package/src/testing/run-loader.ts +0 -96
- package/src/testing/run-middleware.ts +0 -26
- package/src/theme/ThemeProvider.tsx +0 -52
- package/src/theme/ThemeScript.tsx +0 -6
- package/src/theme/constants.ts +0 -12
- package/src/theme/index.ts +0 -7
- package/src/theme/theme-context.ts +1 -5
- package/src/theme/theme-script.ts +0 -14
- package/src/theme/use-theme.ts +0 -3
- package/src/types/boundaries.ts +0 -35
- package/src/types/error-types.ts +25 -89
- package/src/types/global-namespace.ts +4 -14
- package/src/types/handler-context.ts +28 -9
- package/src/types/index.ts +0 -10
- package/src/types/request-scope.ts +0 -19
- package/src/types/route-config.ts +6 -50
- package/src/types/route-entry.ts +0 -6
- package/src/types/segments.ts +0 -13
- package/src/urls/include-helper.ts +0 -4
- package/src/urls/index.ts +0 -6
- package/src/urls/path-helper-types.ts +2 -2
- package/src/urls/path-helper.ts +0 -54
- package/src/urls/urls-function.ts +0 -13
- package/src/use-loader.tsx +0 -186
- package/src/vite/discovery/bundle-postprocess.ts +2 -1
- package/src/vite/discovery/discover-routers.ts +28 -18
- package/src/vite/discovery/prerender-collection.ts +2 -4
- package/src/vite/discovery/state.ts +5 -0
- package/src/vite/discovery/virtual-module-codegen.ts +1 -11
- package/src/vite/plugin-types.ts +35 -9
- package/src/vite/plugins/cjs-to-esm.ts +0 -11
- package/src/vite/plugins/client-ref-dedup.ts +0 -11
- package/src/vite/plugins/client-ref-hashing.ts +0 -10
- package/src/vite/plugins/cloudflare-protocol-stub.ts +0 -20
- package/src/vite/plugins/expose-action-id.ts +2 -73
- package/src/vite/plugins/expose-id-utils.ts +0 -55
- package/src/vite/plugins/expose-ids/export-analysis.ts +0 -38
- package/src/vite/plugins/expose-ids/handler-transform.ts +0 -15
- package/src/vite/plugins/expose-ids/loader-transform.ts +0 -15
- package/src/vite/plugins/expose-ids/router-transform.ts +0 -13
- package/src/vite/plugins/expose-internal-ids.ts +10 -0
- package/src/vite/plugins/performance-tracks.ts +0 -3
- package/src/vite/plugins/refresh-cmd.ts +1 -1
- package/src/vite/plugins/use-cache-transform.ts +21 -46
- package/src/vite/plugins/version-injector.ts +0 -20
- package/src/vite/plugins/version-plugin.ts +1 -49
- package/src/vite/plugins/virtual-entries.ts +0 -15
- package/src/vite/rango.ts +2 -108
- package/src/vite/router-discovery.ts +9 -1
- package/src/vite/utils/ast-handler-extract.ts +0 -16
- package/src/vite/utils/bundle-analysis.ts +6 -13
- package/src/vite/utils/client-chunks.ts +0 -6
- package/src/vite/utils/forward-user-plugins.ts +0 -22
- package/src/vite/utils/manifest-utils.ts +0 -4
- package/src/vite/utils/package-resolution.ts +1 -73
- package/src/vite/utils/prerender-utils.ts +0 -35
- package/src/vite/utils/shared-utils.ts +3 -35
- package/src/browser/shallow.ts +0 -40
- package/src/handles/index.ts +0 -7
- package/src/router/middleware-cookies.ts +0 -55
package/src/testing/dispatch.ts
CHANGED
|
@@ -37,6 +37,10 @@
|
|
|
37
37
|
* (?_rsc_partial / ?_rsc_action): converted to a 204 + X-RSC-Redirect via the
|
|
38
38
|
* real interceptRedirectForPartial, so fetch() does not auto-follow the 3xx —
|
|
39
39
|
* identical to production's no-location-state path.
|
|
40
|
+
* - The open-redirect guard (rsc/redirect-guard.ts) on full (browser-followed)
|
|
41
|
+
* redirects: a cross-origin Location is rewritten to the basename root unless
|
|
42
|
+
* redirect(url, { external: true }) opted out, mirroring production's single
|
|
43
|
+
* handler chokepoint. Soft partial/action redirects are 204 and pass through.
|
|
40
44
|
*
|
|
41
45
|
* What dispatch DOES NOT support (and why):
|
|
42
46
|
* - RSC component routes — rendering requires the Flight serializer + React
|
|
@@ -91,6 +95,13 @@ import {
|
|
|
91
95
|
interceptRedirectForPartial,
|
|
92
96
|
mergeStubHeadersAndFinalize,
|
|
93
97
|
} from "../rsc/helpers.js";
|
|
98
|
+
import { guardOutgoingRedirect } from "../rsc/redirect-guard.js";
|
|
99
|
+
import { stringifyJsonRouteResult } from "../rsc/json-route-result.js";
|
|
100
|
+
import {
|
|
101
|
+
EXTERNAL_REDIRECT_MARKER,
|
|
102
|
+
isExternalRedirect,
|
|
103
|
+
markExternalRedirect,
|
|
104
|
+
} from "../redirect-origin.js";
|
|
94
105
|
import { isWebSocketUpgradeResponse } from "../response-utils.js";
|
|
95
106
|
import type { Rango } from "../router/router-interfaces.js";
|
|
96
107
|
|
|
@@ -154,7 +165,8 @@ function toRequest(request: Request | string): Request {
|
|
|
154
165
|
/**
|
|
155
166
|
* Serialize a NON-Response response-route handler result, mirroring the
|
|
156
167
|
* router's handleResponseRoute() contract:
|
|
157
|
-
* - "json" serializes the value
|
|
168
|
+
* - "json" serializes the value (bare) with application/json, rejecting a nested
|
|
169
|
+
* unresolved Promise via the shared stringifyJsonRouteResult guard,
|
|
158
170
|
* - text/html/xml/md stringify with the mapped MIME type.
|
|
159
171
|
*
|
|
160
172
|
* A handler-returned Response is NOT routed here — callHandler re-wraps it via
|
|
@@ -167,7 +179,12 @@ function serializeResponseRouteResult(
|
|
|
167
179
|
responseType: string,
|
|
168
180
|
): Response {
|
|
169
181
|
if (responseType === "json") {
|
|
170
|
-
|
|
182
|
+
// Serialize through the SAME guard production uses: a nested unresolved
|
|
183
|
+
// Promise (forgotten await) throws RESPONSE_NOT_SERIALIZABLE here, caught by
|
|
184
|
+
// callHandler's catch and mapped to the identical typed 500 production
|
|
185
|
+
// returns -- so a dispatch json test fails exactly where production would,
|
|
186
|
+
// instead of silently emitting {} and passing.
|
|
187
|
+
return new Response(stringifyJsonRouteResult(result), {
|
|
171
188
|
status: 200,
|
|
172
189
|
headers: { "content-type": "application/json;charset=utf-8" },
|
|
173
190
|
});
|
|
@@ -252,16 +269,27 @@ function rewrapHandlerResponse(result: Response): Response {
|
|
|
252
269
|
}
|
|
253
270
|
const headers = new Headers();
|
|
254
271
|
result.headers.forEach((value, key) => {
|
|
272
|
+
// Mirror production: never copy the reserved external-redirect marker off a
|
|
273
|
+
// handler result (it is not a trust signal; the opt-in is the out-of-band
|
|
274
|
+
// brand transferred below).
|
|
275
|
+
if (key.toLowerCase() === EXTERNAL_REDIRECT_MARKER) return;
|
|
255
276
|
if (key.toLowerCase() === "set-cookie") {
|
|
256
277
|
headers.append(key, value);
|
|
257
278
|
} else {
|
|
258
279
|
headers.set(key, value);
|
|
259
280
|
}
|
|
260
281
|
});
|
|
261
|
-
|
|
282
|
+
const rewrapped = createResponseWithMergedHeaders(result.body, {
|
|
262
283
|
status: result.status,
|
|
263
284
|
headers,
|
|
264
285
|
});
|
|
286
|
+
// Mirror production's rewrapResponse: transfer the out-of-band external brand
|
|
287
|
+
// only from a genuinely branded result (a real redirect(url, { external:
|
|
288
|
+
// true })), never from a proxied upstream's forged header.
|
|
289
|
+
if (isExternalRedirect(result)) {
|
|
290
|
+
markExternalRedirect(rewrapped);
|
|
291
|
+
}
|
|
292
|
+
return rewrapped;
|
|
265
293
|
}
|
|
266
294
|
|
|
267
295
|
/**
|
|
@@ -506,7 +534,6 @@ export async function dispatch<TEnv = any>(
|
|
|
506
534
|
regex: null,
|
|
507
535
|
paramNames: [],
|
|
508
536
|
handler: mw.handler,
|
|
509
|
-
mountPrefix: null,
|
|
510
537
|
} as MiddlewareEntry<TEnv>,
|
|
511
538
|
params: mw.params,
|
|
512
539
|
}),
|
|
@@ -569,13 +596,23 @@ export async function dispatch<TEnv = any>(
|
|
|
569
596
|
// callbacks via finalizeResponse. dispatch is RSC-free, so the
|
|
570
597
|
// createRedirectFlightResponse stand-in falls back to the no-state
|
|
571
598
|
// 204 + X-RSC-Redirect (see the location-state divergence in the header).
|
|
599
|
+
let finalResponse: Response;
|
|
572
600
|
if (isPartial || isAction) {
|
|
573
601
|
const intercepted = interceptRedirectForPartial(
|
|
574
602
|
mwResponse,
|
|
575
603
|
(redirectUrl) => createSimpleRedirectResponse(redirectUrl),
|
|
576
604
|
);
|
|
577
|
-
|
|
605
|
+
finalResponse = finalizeResponse(intercepted ?? mwResponse);
|
|
606
|
+
} else {
|
|
607
|
+
finalResponse = finalizeResponse(mwResponse);
|
|
578
608
|
}
|
|
579
|
-
|
|
609
|
+
|
|
610
|
+
// Mirror production's single open-redirect chokepoint (handler.ts): every
|
|
611
|
+
// browser-followed (3xx + Location) redirect is same-origin guarded before
|
|
612
|
+
// it leaves -- a cross-origin Location is rewritten to the basename root
|
|
613
|
+
// unless redirect(url, { external: true }) opted out. Soft partial/action
|
|
614
|
+
// redirects are 204 + X-RSC-Redirect and pass through untouched (the client
|
|
615
|
+
// validates them), so this is a no-op for them.
|
|
616
|
+
return guardOutgoingRedirect(finalResponse, url.origin, router.basename);
|
|
580
617
|
});
|
|
581
618
|
}
|
package/src/testing/e2e/index.ts
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
// Public entry for the consumer e2e harness. `createRangoE2E({ test, expect })`
|
|
2
|
-
// wires the server fixture, page helpers, parity helpers, and matchers around
|
|
3
|
-
// the consumer's Playwright `test`/`expect` objects so this module never
|
|
4
|
-
// imports `@playwright/test` at runtime (type-only imports are erased).
|
|
5
|
-
|
|
6
1
|
import type { Expect, TestType } from "@playwright/test";
|
|
7
2
|
import {
|
|
8
3
|
createUseFixture,
|
|
@@ -35,14 +30,9 @@ import {
|
|
|
35
30
|
} from "./parity.js";
|
|
36
31
|
import { createRangoMatchers, type RangoMatchers } from "./matchers.js";
|
|
37
32
|
|
|
38
|
-
// Cache-status helpers are pure (cache-status.ts imports only TYPES), so they
|
|
39
|
-
// are safe to surface from this Playwright-runnable entry. Importing them from
|
|
40
|
-
// the `@rangojs/router/testing` barrel does NOT work in a plain Playwright
|
|
41
|
-
// runner — the barrel transitively pulls the build-only `@rangojs/router:version`
|
|
42
|
-
// virtual via the route-manifest path. Asserting cache status on a real
|
|
43
|
-
// response is an e2e activity, so this is their Playwright-safe home.
|
|
44
33
|
export {
|
|
45
34
|
assertCacheStatus,
|
|
35
|
+
assertCacheDecision,
|
|
46
36
|
parseCacheHeader,
|
|
47
37
|
createCacheSink,
|
|
48
38
|
filterCacheDecisions,
|
|
@@ -50,9 +40,6 @@ export {
|
|
|
50
40
|
type ExpectedCacheStatus,
|
|
51
41
|
type CacheStatusTarget,
|
|
52
42
|
} from "../cache-status.js";
|
|
53
|
-
|
|
54
|
-
// Re-export standalone helpers and all public types so the barrel can re-export
|
|
55
|
-
// them from a single module.
|
|
56
43
|
export {
|
|
57
44
|
testId,
|
|
58
45
|
waitForHydration,
|
|
@@ -87,7 +74,6 @@ export interface RangoE2E extends PageHelpers, Parity {
|
|
|
87
74
|
useFixture: (options: FixtureOptions) => Fixture;
|
|
88
75
|
testNoJs: TestType<any, any>;
|
|
89
76
|
rangoMatchers: RangoMatchers;
|
|
90
|
-
// Standalone helpers, re-surfaced for convenience.
|
|
91
77
|
testId: typeof testId;
|
|
92
78
|
waitForHydration: typeof waitForHydration;
|
|
93
79
|
waitForNavigation: typeof waitForNavigation;
|
|
@@ -102,12 +88,6 @@ export interface RangoE2E extends PageHelpers, Parity {
|
|
|
102
88
|
measureTime: typeof measureTime;
|
|
103
89
|
}
|
|
104
90
|
|
|
105
|
-
/**
|
|
106
|
-
* Wire the full e2e harness around a consumer's Playwright `test`/`expect`.
|
|
107
|
-
*
|
|
108
|
-
* @param defaultRoot - fallback app root for `parityDescribe` when a call omits
|
|
109
|
-
* `options.root`.
|
|
110
|
-
*/
|
|
111
91
|
export function createRangoE2E({
|
|
112
92
|
test,
|
|
113
93
|
expect,
|
|
@@ -132,7 +112,6 @@ export function createRangoE2E({
|
|
|
132
112
|
rangoMatchers,
|
|
133
113
|
...parity,
|
|
134
114
|
...pageHelpers,
|
|
135
|
-
// Standalone helpers.
|
|
136
115
|
testId,
|
|
137
116
|
waitForHydration,
|
|
138
117
|
waitForNavigation,
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// Custom Playwright matchers for Rango assertions. Returned as an object
|
|
2
|
-
// suitable for `expect.extend(...)`. v1 ships only `toHaveRangoPathname`.
|
|
3
|
-
|
|
4
1
|
import type { Expect, Page } from "@playwright/test";
|
|
5
2
|
|
|
6
3
|
interface MatcherResult {
|
|
@@ -12,17 +9,6 @@ export interface RangoMatchers {
|
|
|
12
9
|
toHaveRangoPathname: (page: Page, expected: string) => MatcherResult;
|
|
13
10
|
}
|
|
14
11
|
|
|
15
|
-
/**
|
|
16
|
-
* Build the matcher object for `expect.extend(createRangoMatchers(expect))`.
|
|
17
|
-
*
|
|
18
|
-
* `toHaveRangoPathname(page, expected)` asserts that the pathname of the page's
|
|
19
|
-
* current URL equals `expected`.
|
|
20
|
-
*
|
|
21
|
-
* TODO: `toHaveSegments` / `toHaveParams` are intentionally not implemented.
|
|
22
|
-
* They require a client-emitted signal (the active segment chain / resolved
|
|
23
|
-
* params exposed on the page) that does not exist yet; implementing them by
|
|
24
|
-
* scraping the DOM would be a guess. Add them once the router emits that signal.
|
|
25
|
-
*/
|
|
26
12
|
export function createRangoMatchers(_expect: Expect): RangoMatchers {
|
|
27
13
|
return {
|
|
28
14
|
toHaveRangoPathname(page: Page, expected: string): MatcherResult {
|
|
@@ -39,8 +25,6 @@ export function createRangoMatchers(_expect: Expect): RangoMatchers {
|
|
|
39
25
|
};
|
|
40
26
|
}
|
|
41
27
|
|
|
42
|
-
// Type augmentation so consumers can call `await expect(page).toHaveRangoPathname("/x")`
|
|
43
|
-
// after `expect.extend(rangoMatchers)`, without re-declaring the matcher.
|
|
44
28
|
declare global {
|
|
45
29
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
46
30
|
namespace PlaywrightTest {
|
|
@@ -42,14 +42,6 @@ interface MatcherResult {
|
|
|
42
42
|
message: () => string;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/**
|
|
46
|
-
* Matcher object for `expect.extend(flightMatchers)`.
|
|
47
|
-
*
|
|
48
|
-
* - `toMatchFlight(received, expected)` — `received` is a rendered Flight
|
|
49
|
-
* string; passes if its normalized form contains `expected`.
|
|
50
|
-
* - `toMatchFlightSnapshot(received)` — delegates to vitest's snapshot on the
|
|
51
|
-
* normalized Flight string.
|
|
52
|
-
*/
|
|
53
45
|
export const flightMatchers: {
|
|
54
46
|
toMatchFlight(received: string, expected: string): MatcherResult;
|
|
55
47
|
toMatchFlightSnapshot(received: string): MatcherResult;
|
|
@@ -78,12 +70,7 @@ export const flightMatchers: {
|
|
|
78
70
|
},
|
|
79
71
|
|
|
80
72
|
toMatchFlightSnapshot(received: string): MatcherResult {
|
|
81
|
-
// Delegate to vitest's snapshot engine on the normalized string. The
|
|
82
|
-
// snapshot is keyed by the current test file/title (vitest tracks this via
|
|
83
|
-
// the active test context), not by this call site, so delegating through a
|
|
84
|
-
// freshly imported `expect` is reliable.
|
|
85
73
|
expect(normalizeFlight(received)).toMatchSnapshot();
|
|
86
|
-
// toMatchSnapshot throws on mismatch; reaching here means it passed.
|
|
87
74
|
return {
|
|
88
75
|
pass: true,
|
|
89
76
|
message: () => "Flight snapshot matched.",
|
|
@@ -1,36 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
* normalizeFlight — scrub volatile bits from a Flight wire string so snapshots
|
|
3
|
-
* are stable across runs/machines.
|
|
4
|
-
*
|
|
5
|
-
* This is two regex replacements and NOTHING else. It is split out of flight.ts
|
|
6
|
-
* on purpose: flight.ts top-level imports the vendored react-server-dom
|
|
7
|
-
* serializer, which throws when imported outside the `react-server` export
|
|
8
|
-
* condition. The flight-matchers module (and a consumer's shared `setupFiles`
|
|
9
|
-
* that does `expect.extend(flightMatchers)`) must be importable under the PLAIN
|
|
10
|
-
* node condition, so the normalizer it needs cannot live next to that import.
|
|
11
|
-
* flight.ts re-exports normalizeFlight from here, so the public surface of the
|
|
12
|
-
* `@rangojs/router/testing/flight` entry is unchanged.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
// Volatile leading reference row: `:N<timestamp>` (dev debug-info anchor).
|
|
1
|
+
// Volatile leading reference row: `:N<timestamp>` (dev only).
|
|
16
2
|
const REFERENCE_ROW_RE = /^:N[\d.]+\n/;
|
|
17
|
-
// Absolute file:// paths
|
|
18
|
-
//
|
|
19
|
-
// path is a quoted JSON string immediately followed by `",<line>,<col>`. The
|
|
20
|
-
// lookahead scopes the scrub to exactly that frame shape, leaving a legitimate
|
|
21
|
-
// `file://` href in RENDERED content (e.g. `{"href":"file:///x"}`) untouched.
|
|
3
|
+
// Absolute file:// paths in dev stack rows. Pattern matches frames
|
|
4
|
+
// `["Component","file:///path",<line>,<col>...]` and scrubs the path only.
|
|
22
5
|
const FILE_URL_RE = /file:\/\/[^"\\]+(?=",\d+,\d+)/g;
|
|
23
6
|
|
|
24
|
-
/**
|
|
25
|
-
* Scrub volatile bits from a Flight string so snapshots are stable across runs
|
|
26
|
-
* and machines:
|
|
27
|
-
* - the leading `:N<timestamp>` reference row (dev only),
|
|
28
|
-
* - absolute `file://...` paths inside dev stack rows.
|
|
29
|
-
*
|
|
30
|
-
* Under NODE_ENV=production these rows are already absent; normalize is a
|
|
31
|
-
* no-op safety net there. In dev mode it removes the machine/clock-specific
|
|
32
|
-
* noise while leaving the rendered tree intact.
|
|
33
|
-
*/
|
|
34
7
|
export function normalizeFlight(flight: string): string {
|
|
35
8
|
return flight
|
|
36
9
|
.replace(REFERENCE_ROW_RE, "")
|
package/src/testing/flight.ts
CHANGED
|
@@ -70,6 +70,14 @@ export interface RenderToFlightStringOptions {
|
|
|
70
70
|
params?: Record<string, string>;
|
|
71
71
|
/** Matched route name (drives `ctx.routeName` and scoped reverse). */
|
|
72
72
|
routeName?: string;
|
|
73
|
+
/**
|
|
74
|
+
* Route name -> pattern map enabling a SCOPED `ctx.reverse()` (like
|
|
75
|
+
* `renderHandler`). Without it, a server component that reverses resolves
|
|
76
|
+
* against the GLOBAL route map and is order-dependent on whatever router
|
|
77
|
+
* registered last. Pass the router-under-test's map to make reversing
|
|
78
|
+
* deterministic.
|
|
79
|
+
*/
|
|
80
|
+
routeMap?: Record<string, string>;
|
|
73
81
|
/**
|
|
74
82
|
* Context variables visible to the rendered tree via `ctx.get(...)` — as a
|
|
75
83
|
* prior middleware would have set them. Seeds the SAME way the handler-test
|
|
@@ -83,12 +91,43 @@ export interface RenderToFlightStringOptions {
|
|
|
83
91
|
const DEFAULT_URL = "http://localhost/";
|
|
84
92
|
|
|
85
93
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* `
|
|
94
|
+
* True when `error` is the out-of-react-server stub thrown by index.ts's
|
|
95
|
+
* server-only exports (getRequestContext/cookies/headers/...) — i.e. the bare
|
|
96
|
+
* `@rangojs/router` specifier resolved to index.ts, not index.rsc.ts, because
|
|
97
|
+
* the rsc Vitest project is missing the `rangoTestAliases` alias. Matches both
|
|
98
|
+
* substrings of `serverOnlyStubError` (index.ts) so a normal app error cannot
|
|
99
|
+
* over-match. Shared with render-handler.ts so the two Flight primitives report
|
|
100
|
+
* the same misconfiguration identically.
|
|
101
|
+
*/
|
|
102
|
+
export function isServerOnlyStubError(error: unknown): boolean {
|
|
103
|
+
return (
|
|
104
|
+
error instanceof Error &&
|
|
105
|
+
error.message.includes("is only available from") &&
|
|
106
|
+
error.message.includes("react-server")
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Rethrow a server tree render error. When it is the missing-rsc-alias stub
|
|
112
|
+
* (above), rethrow an actionable message naming `rangoTestAliases` instead of
|
|
113
|
+
* the opaque stub text; otherwise rethrow the original unchanged. Classify the
|
|
114
|
+
* ORIGINAL error before constructing the wrapper so the wrapper's `Original: ...`
|
|
115
|
+
* echo (which re-embeds the matched substrings) never re-triggers the predicate.
|
|
91
116
|
*/
|
|
117
|
+
function rethrowFlightRenderError(error: unknown): never {
|
|
118
|
+
if (isServerOnlyStubError(error)) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`The server component called a server-only API ` +
|
|
121
|
+
`(getRequestContext/cookies/headers/...) but "@rangojs/router" resolved to ` +
|
|
122
|
+
`the out-of-react-server stub. Add rangoTestAliases({ preset }) to your ` +
|
|
123
|
+
`vitest.rsc.config.ts \`resolve.alias\` so the bare specifier maps to ` +
|
|
124
|
+
`index.rsc.ts (the real react-server implementations). ` +
|
|
125
|
+
`Original: ${(error as Error).message}`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
|
|
92
131
|
export function assertNoLegacyUrlOption(opts: object, fnName: string): void {
|
|
93
132
|
if ("url" in opts) {
|
|
94
133
|
throw new Error(
|
|
@@ -100,11 +139,6 @@ export function assertNoLegacyUrlOption(opts: object, fnName: string): void {
|
|
|
100
139
|
}
|
|
101
140
|
}
|
|
102
141
|
|
|
103
|
-
/**
|
|
104
|
-
* Wrap a single element in the minimal ResolvedSegment + RscPayload shape that
|
|
105
|
-
* mirrors Rango's wire format, so the serialized output matches what a real
|
|
106
|
-
* route segment would emit.
|
|
107
|
-
*/
|
|
108
142
|
function wrapAsPayload(element: ReactNode, pathname: string): RscPayload {
|
|
109
143
|
const segment: ResolvedSegment = {
|
|
110
144
|
id: "test",
|
|
@@ -137,20 +171,9 @@ export async function renderToFlightString(
|
|
|
137
171
|
opts: RenderToFlightStringOptions = {},
|
|
138
172
|
): Promise<string> {
|
|
139
173
|
assertNoLegacyUrlOption(opts, "renderToFlightString");
|
|
140
|
-
// Server-only trees: empty client manifest. A client reference would emit an
|
|
141
|
-
// unresolvable `I` row here; use renderServerTree (flight-tree.ts) when the
|
|
142
|
-
// tree has client boundaries you want to inspect.
|
|
143
174
|
return serializeToFlightString(element, opts, {});
|
|
144
175
|
}
|
|
145
176
|
|
|
146
|
-
/**
|
|
147
|
-
* Shared serialize core: set up a request context, wrap the element as a Rango
|
|
148
|
-
* payload, and serialize it with the given client-reference manifest. Used by
|
|
149
|
-
* {@link renderToFlightString} (empty manifest) and renderServerTree (a manifest
|
|
150
|
-
* that resolves every registered client reference).
|
|
151
|
-
*
|
|
152
|
-
* Must run under the `react-server` export condition (see module header).
|
|
153
|
-
*/
|
|
154
177
|
export async function serializeToFlightString(
|
|
155
178
|
element: ReactNode,
|
|
156
179
|
opts: RenderToFlightStringOptions,
|
|
@@ -167,39 +190,21 @@ export async function serializeToFlightString(
|
|
|
167
190
|
env: opts.env ?? {},
|
|
168
191
|
request,
|
|
169
192
|
url,
|
|
170
|
-
// Seed vars so a server component reading ctx.get(MyVar) during render sees
|
|
171
|
-
// them — same seeding the handler-test primitives use.
|
|
172
193
|
variables: seedVariables({}, opts.vars),
|
|
173
194
|
});
|
|
174
195
|
|
|
175
196
|
return runWithRequestContext(ctx, () => {
|
|
176
|
-
setRequestContextParams(opts.params ?? {}, opts.routeName);
|
|
197
|
+
setRequestContextParams(opts.params ?? {}, opts.routeName, opts.routeMap);
|
|
177
198
|
return serializeNodeToFlight(element, clientManifest, url.pathname);
|
|
178
199
|
});
|
|
179
200
|
}
|
|
180
201
|
|
|
181
|
-
/**
|
|
182
|
-
* Serialize a node to a Flight string, ASSUMING a request context is already
|
|
183
|
-
* active (i.e. called inside `runWithRequestContext`). This is the core
|
|
184
|
-
* `renderHandler` reuses: it enters its own context, builds a HandlerContext,
|
|
185
|
-
* invokes the handler, then serializes the returned RSC in that SAME context (so
|
|
186
|
-
* cookies/headers/vars/handles the handler set are all on one context).
|
|
187
|
-
*
|
|
188
|
-
* Must run under the `react-server` export condition (see module header).
|
|
189
|
-
*/
|
|
190
202
|
export async function serializeNodeToFlight(
|
|
191
203
|
node: ReactNode,
|
|
192
204
|
clientManifest: unknown,
|
|
193
205
|
pathname: string,
|
|
194
206
|
): Promise<string> {
|
|
195
207
|
const payload = wrapAsPayload(node, pathname);
|
|
196
|
-
// Capture (do NOT rethrow) the first render error. The serializer calls
|
|
197
|
-
// onError from its own scheduled work; throwing there escapes as an unhandled
|
|
198
|
-
// rejection AND leaves the stream un-closed, so the drain below would hang
|
|
199
|
-
// until the test times out. Production's onError returns void (rsc-rendering.ts)
|
|
200
|
-
// so the stream completes with an error row. We mirror that — let the stream
|
|
201
|
-
// finish — then surface the error as a clean rejection after draining, so
|
|
202
|
-
// `await expect(...).rejects.toThrow()` works.
|
|
203
208
|
let renderError: unknown;
|
|
204
209
|
let didError = false;
|
|
205
210
|
const stream = RSDServer.renderToReadableStream(payload, clientManifest, {
|
|
@@ -210,18 +215,11 @@ export async function serializeNodeToFlight(
|
|
|
210
215
|
}
|
|
211
216
|
},
|
|
212
217
|
});
|
|
213
|
-
// Drain inside the context so async components see ctx during streaming.
|
|
214
218
|
const text = await new Response(stream).text();
|
|
215
|
-
if (didError)
|
|
219
|
+
if (didError) rethrowFlightRenderError(renderError);
|
|
216
220
|
return text;
|
|
217
221
|
}
|
|
218
222
|
|
|
219
|
-
/**
|
|
220
|
-
* Smoke check that the vendored serializer subpath still resolves and exposes
|
|
221
|
-
* `renderToReadableStream`. The vendored path is private to plugin-rsc; a minor
|
|
222
|
-
* bump could relocate it. Call this in a test to fail loudly with a clear
|
|
223
|
-
* message instead of an opaque import error.
|
|
224
|
-
*/
|
|
225
223
|
export function assertFlightRuntimeAvailable(): void {
|
|
226
224
|
if (typeof RSDServer.renderToReadableStream !== "function") {
|
|
227
225
|
throw new Error(
|
|
@@ -32,14 +32,6 @@ interface RouterWithRouteMap {
|
|
|
32
32
|
findMatch?: (pathname: string) => unknown;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/**
|
|
36
|
-
* Derive a best-effort concrete path from a route pattern so `findMatch` can be
|
|
37
|
-
* invoked to expand a lazy include. `:param`, `:param(constraint)`, optional
|
|
38
|
-
* `:param?`, and `*` are all replaced with a literal segment. A constrained
|
|
39
|
-
* param may not match its constraint (so that one route's match fails), but
|
|
40
|
-
* since matching ANY route in an include expands ALL of the include's routes,
|
|
41
|
-
* a sibling route in the same include will still trigger expansion.
|
|
42
|
-
*/
|
|
43
35
|
function concretePath(pattern: string): string {
|
|
44
36
|
return (
|
|
45
37
|
pattern
|
|
@@ -50,17 +42,6 @@ function concretePath(pattern: string): string {
|
|
|
50
42
|
);
|
|
51
43
|
}
|
|
52
44
|
|
|
53
|
-
/**
|
|
54
|
-
* Force-expand the router's lazy `include()`d routes into `router.routeMap`.
|
|
55
|
-
*
|
|
56
|
-
* All Rango includes are lazy — their child routes only populate `routeMap` when
|
|
57
|
-
* the router first matches a path inside them (in production the build-time
|
|
58
|
-
* manifest virtual carries the full map; in a bare test that virtual is absent).
|
|
59
|
-
* To make the whole-app drift check work in a unit test, we trigger expansion by
|
|
60
|
-
* calling `findMatch` on a concrete path derived from each known pattern. This is
|
|
61
|
-
* idempotent and side-effect-free beyond populating the route map. Routers that
|
|
62
|
-
* don't expose `findMatch` (e.g. a plain `{ routeMap }` object) are left as-is.
|
|
63
|
-
*/
|
|
64
45
|
function expandLazyIncludes(
|
|
65
46
|
router: RouterWithRouteMap,
|
|
66
47
|
patterns: Iterable<string>,
|
|
@@ -70,10 +51,7 @@ function expandLazyIncludes(
|
|
|
70
51
|
for (const pattern of patterns) {
|
|
71
52
|
try {
|
|
72
53
|
findMatch.call(router, concretePath(pattern));
|
|
73
|
-
} catch {
|
|
74
|
-
// A pattern that fails to match (constrained param, etc.) is fine — a
|
|
75
|
-
// sibling route in the same include still triggers expansion.
|
|
76
|
-
}
|
|
54
|
+
} catch {}
|
|
77
55
|
}
|
|
78
56
|
}
|
|
79
57
|
|
|
@@ -100,11 +78,6 @@ export interface GeneratedRoutesDiff {
|
|
|
100
78
|
ok: boolean;
|
|
101
79
|
}
|
|
102
80
|
|
|
103
|
-
/**
|
|
104
|
-
* Normalize a route map value to its pattern string. Route maps may carry
|
|
105
|
-
* either a bare pattern string or a `{ path, ... }` object (for response/search
|
|
106
|
-
* routes); compare on the `path`.
|
|
107
|
-
*/
|
|
108
81
|
function patternOf(value: unknown): string {
|
|
109
82
|
if (typeof value === "string") return value;
|
|
110
83
|
if (
|
|
@@ -118,19 +91,12 @@ function patternOf(value: unknown): string {
|
|
|
118
91
|
return String(value);
|
|
119
92
|
}
|
|
120
93
|
|
|
121
|
-
/**
|
|
122
|
-
* Compute the diff between a router's runtime route map and a generated map.
|
|
123
|
-
*/
|
|
124
94
|
export function diffGeneratedRoutes(
|
|
125
95
|
router: RouterWithRouteMap,
|
|
126
96
|
generatedMap?: Record<string, unknown>,
|
|
127
97
|
): GeneratedRoutesDiff {
|
|
128
98
|
const generated = generatedMap ?? getGlobalRouteMap();
|
|
129
99
|
|
|
130
|
-
// Lazy `include()`d routes are absent from `routeMap` until first matched, so
|
|
131
|
-
// expand them first (using the generated patterns to drive the matches) —
|
|
132
|
-
// otherwise every included route is a false `missing`. No-op for plain
|
|
133
|
-
// `{ routeMap }` objects that don't expose `findMatch`.
|
|
134
100
|
expandLazyIncludes(
|
|
135
101
|
router,
|
|
136
102
|
Object.values(generated).map((v) => patternOf(v)),
|
|
@@ -155,12 +121,6 @@ export function diffGeneratedRoutes(
|
|
|
155
121
|
}
|
|
156
122
|
|
|
157
123
|
for (const name of Object.keys(runtime)) {
|
|
158
|
-
// Auto-generated internal names ($path_*/$prefix_*) live in the runtime
|
|
159
|
-
// mergedRouteMap but are deliberately excluded from the generated
|
|
160
|
-
// *.named-routes.gen.ts file (route-types-writer / runtime-discovery skip
|
|
161
|
-
// them). Reporting them as `extra` would throw on a perfectly in-sync app
|
|
162
|
-
// that simply uses an unnamed path()/include() route, so skip them here to
|
|
163
|
-
// match exactly the surface the generator emits.
|
|
164
124
|
if (isAutoGeneratedRouteName(name)) continue;
|
|
165
125
|
if (!(name in generated)) {
|
|
166
126
|
extra.push(name);
|
package/src/testing/index.ts
CHANGED
|
@@ -34,7 +34,6 @@
|
|
|
34
34
|
* - RSC: see @rangojs/router/testing/flight
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
|
-
// Unit
|
|
38
37
|
export { runMiddleware } from "./run-middleware.js";
|
|
39
38
|
export type {
|
|
40
39
|
RunMiddlewareOptions,
|
|
@@ -48,17 +47,12 @@ export type {
|
|
|
48
47
|
TestLoaderContext,
|
|
49
48
|
} from "./run-loader.js";
|
|
50
49
|
|
|
51
|
-
// Integration
|
|
52
50
|
export { dispatch } from "./dispatch.js";
|
|
53
51
|
export type { DispatchOptions } from "./dispatch.js";
|
|
54
52
|
|
|
55
|
-
// renderRoute lives at `@rangojs/router/testing/dom` — it pulls React, the
|
|
56
|
-
// browser runtime, and @testing-library/react types, which this barrel keeps
|
|
57
|
-
// out so node-only unit suites depend on none of them.
|
|
58
|
-
|
|
59
|
-
// Cross-cutting: cache/prerender status
|
|
60
53
|
export {
|
|
61
54
|
assertCacheStatus,
|
|
55
|
+
assertCacheDecision,
|
|
62
56
|
parseCacheHeader,
|
|
63
57
|
createCacheSink,
|
|
64
58
|
filterCacheDecisions,
|
|
@@ -68,9 +62,6 @@ export type {
|
|
|
68
62
|
CacheStatusTarget,
|
|
69
63
|
CacheSink,
|
|
70
64
|
} from "./cache-status.js";
|
|
71
|
-
// The telemetry event types a cache-status assertion inspects (createCacheSink
|
|
72
|
-
// records CacheDecisionEvents; filterCacheDecisions narrows them). Re-exported
|
|
73
|
-
// here so a test can annotate the events without reaching past `@rangojs/router/testing`.
|
|
74
65
|
export type {
|
|
75
66
|
TelemetryEvent,
|
|
76
67
|
TelemetrySink,
|
|
@@ -79,10 +70,8 @@ export type {
|
|
|
79
70
|
CacheSegmentStatus,
|
|
80
71
|
} from "../router/telemetry.js";
|
|
81
72
|
|
|
82
|
-
// Cross-cutting: handle collect/accumulator
|
|
83
73
|
export { collectHandle } from "./collect-handle.js";
|
|
84
74
|
|
|
85
|
-
// Cross-cutting: generated-route drift
|
|
86
75
|
export {
|
|
87
76
|
diffGeneratedRoutes,
|
|
88
77
|
assertGeneratedRoutesMatch,
|
|
@@ -92,7 +81,6 @@ export type {
|
|
|
92
81
|
GeneratedRouteMismatch,
|
|
93
82
|
} from "./generated-routes.js";
|
|
94
83
|
|
|
95
|
-
// Advanced: build a real RequestContext for bespoke loader/middleware setups
|
|
96
84
|
export {
|
|
97
85
|
createTestRequestContext,
|
|
98
86
|
runInRequestContext,
|
|
@@ -108,12 +96,4 @@ export type {
|
|
|
108
96
|
StateCookieSeed,
|
|
109
97
|
} from "./internal/context.js";
|
|
110
98
|
|
|
111
|
-
// The low-level context runner that enters a RequestContext (the same one the
|
|
112
|
-
// RSC handler uses for server actions). Re-exported so a ctx built with
|
|
113
|
-
// createTestRequestContext can be entered directly; runInRequestContext is the
|
|
114
|
-
// one-call convenience over createTestRequestContext + runWithRequestContext.
|
|
115
99
|
export { runWithRequestContext } from "../server/request-context.js";
|
|
116
|
-
|
|
117
|
-
// The E2E harness is NOT re-exported here: it must be imported from
|
|
118
|
-
// `@rangojs/router/testing/e2e` so it stays loadable in a plain Playwright
|
|
119
|
-
// runner (this barrel pulls in router-manifest code that needs Vite virtuals).
|