@rangojs/router 0.0.0-experimental.b02a2fec → 0.0.0-experimental.b3f2d0d9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -25
- package/dist/bin/rango.js +147 -57
- package/dist/testing/vitest.js +82 -0
- package/dist/vite/index.js +2151 -846
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +57 -11
- package/skills/breadcrumbs/SKILL.md +3 -1
- package/skills/bundle-analysis/SKILL.md +159 -0
- package/skills/cache-guide/SKILL.md +220 -30
- package/skills/caching/SKILL.md +116 -8
- package/skills/composability/SKILL.md +27 -2
- package/skills/document-cache/SKILL.md +78 -55
- package/skills/handler-use/SKILL.md +364 -0
- package/skills/hooks/SKILL.md +229 -20
- package/skills/host-router/SKILL.md +45 -20
- package/skills/i18n/SKILL.md +276 -0
- package/skills/intercept/SKILL.md +46 -4
- package/skills/layout/SKILL.md +28 -7
- package/skills/links/SKILL.md +247 -17
- package/skills/loader/SKILL.md +219 -9
- package/skills/middleware/SKILL.md +47 -12
- package/skills/migrate-nextjs/SKILL.md +562 -0
- package/skills/migrate-react-router/SKILL.md +769 -0
- package/skills/mime-routes/SKILL.md +27 -0
- package/skills/observability/SKILL.md +137 -0
- package/skills/parallel/SKILL.md +71 -6
- package/skills/prerender/SKILL.md +14 -33
- package/skills/rango/SKILL.md +242 -22
- package/skills/react-compiler/SKILL.md +168 -0
- package/skills/response-routes/SKILL.md +66 -9
- package/skills/route/SKILL.md +57 -4
- package/skills/router-setup/SKILL.md +3 -3
- package/skills/server-actions/SKILL.md +751 -0
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/testing/SKILL.md +777 -0
- package/skills/typesafety/SKILL.md +319 -27
- package/skills/use-cache/SKILL.md +34 -5
- package/skills/view-transitions/SKILL.md +294 -0
- package/src/__augment-tests__/augment.ts +81 -0
- package/src/__augment-tests__/augmented.check.ts +117 -0
- package/src/browser/action-coordinator.ts +53 -36
- package/src/browser/app-shell.ts +52 -0
- package/src/browser/event-controller.ts +86 -70
- package/src/browser/history-state.ts +21 -0
- package/src/browser/index.ts +3 -3
- package/src/browser/navigation-bridge.ts +85 -12
- package/src/browser/navigation-client.ts +76 -28
- package/src/browser/navigation-store.ts +32 -9
- package/src/browser/navigation-transaction.ts +10 -28
- package/src/browser/partial-update.ts +64 -26
- package/src/browser/prefetch/cache.ts +129 -21
- package/src/browser/prefetch/fetch.ts +148 -16
- package/src/browser/prefetch/queue.ts +36 -5
- package/src/browser/rango-state.ts +53 -13
- package/src/browser/react/Link.tsx +30 -2
- package/src/browser/react/NavigationProvider.tsx +72 -31
- package/src/browser/react/filter-segment-order.ts +51 -7
- package/src/browser/react/index.ts +3 -0
- package/src/browser/react/location-state-shared.ts +175 -4
- package/src/browser/react/location-state.ts +39 -13
- package/src/browser/react/use-handle.ts +17 -9
- package/src/browser/react/use-navigation.ts +22 -2
- package/src/browser/react/use-params.ts +20 -8
- package/src/browser/react/use-reverse.ts +106 -0
- package/src/browser/react/use-router.ts +22 -2
- package/src/browser/react/use-segments.ts +11 -8
- package/src/browser/response-adapter.ts +25 -0
- package/src/browser/rsc-router.tsx +64 -22
- package/src/browser/scroll-restoration.ts +22 -14
- package/src/browser/segment-reconciler.ts +36 -14
- package/src/browser/segment-structure-assert.ts +2 -2
- package/src/browser/server-action-bridge.ts +23 -30
- package/src/browser/types.ts +21 -0
- package/src/build/collect-fallback-refs.ts +107 -0
- package/src/build/generate-manifest.ts +60 -35
- package/src/build/generate-route-types.ts +2 -0
- package/src/build/index.ts +2 -0
- package/src/build/route-trie.ts +52 -25
- package/src/build/route-types/codegen.ts +4 -4
- package/src/build/route-types/include-resolution.ts +1 -1
- package/src/build/route-types/per-module-writer.ts +7 -4
- package/src/build/route-types/router-processing.ts +55 -14
- package/src/build/route-types/scan-filter.ts +1 -1
- package/src/build/route-types/source-scan.ts +118 -0
- package/src/build/runtime-discovery.ts +9 -20
- package/src/cache/cache-scope.ts +28 -42
- package/src/cache/cf/cf-cache-store.ts +54 -13
- package/src/client.rsc.tsx +3 -0
- package/src/client.tsx +92 -182
- package/src/context-var.ts +5 -5
- package/src/decode-loader-results.ts +36 -0
- package/src/errors.ts +30 -1
- package/src/handle.ts +26 -13
- package/src/host/index.ts +2 -2
- package/src/host/router.ts +129 -57
- package/src/host/types.ts +31 -2
- package/src/host/utils.ts +1 -1
- package/src/href-client.ts +140 -20
- package/src/index.rsc.ts +9 -4
- package/src/index.ts +53 -15
- package/src/loader-store.ts +500 -0
- package/src/loader.rsc.ts +21 -6
- package/src/loader.ts +3 -10
- package/src/missing-id-error.ts +68 -0
- package/src/outlet-context.ts +1 -1
- package/src/prerender.ts +4 -4
- package/src/response-utils.ts +37 -0
- package/src/reverse.ts +65 -36
- package/src/route-content-wrapper.tsx +6 -28
- package/src/route-definition/dsl-helpers.ts +384 -257
- package/src/route-definition/helper-factories.ts +29 -139
- package/src/route-definition/helpers-types.ts +100 -28
- package/src/route-definition/resolve-handler-use.ts +6 -0
- package/src/route-definition/use-item-types.ts +32 -0
- package/src/route-types.ts +26 -41
- package/src/router/basename.ts +14 -0
- package/src/router/content-negotiation.ts +15 -2
- package/src/router/error-handling.ts +1 -1
- package/src/router/handler-context.ts +21 -38
- package/src/router/intercept-resolution.ts +4 -18
- package/src/router/lazy-includes.ts +8 -8
- package/src/router/loader-resolution.ts +19 -2
- package/src/router/manifest.ts +22 -13
- package/src/router/match-api.ts +4 -3
- package/src/router/match-handlers.ts +63 -20
- package/src/router/match-middleware/cache-lookup.ts +44 -91
- package/src/router/match-middleware/cache-store.ts +3 -2
- package/src/router/match-result.ts +53 -32
- package/src/router/metrics.ts +1 -1
- package/src/router/middleware-types.ts +15 -26
- package/src/router/middleware.ts +99 -84
- package/src/router/pattern-matching.ts +101 -17
- package/src/router/prerender-match.ts +1 -1
- package/src/router/preview-match.ts +3 -1
- package/src/router/request-classification.ts +4 -28
- package/src/router/revalidation.ts +58 -2
- package/src/router/router-interfaces.ts +45 -28
- package/src/router/router-options.ts +40 -1
- package/src/router/router-registry.ts +2 -5
- package/src/router/segment-resolution/fresh.ts +27 -6
- package/src/router/segment-resolution/revalidation.ts +147 -106
- package/src/router/segment-resolution/view-transition-default.ts +36 -0
- package/src/router/substitute-pattern-params.ts +56 -0
- package/src/router/telemetry.ts +99 -0
- package/src/router/trie-matching.ts +18 -13
- package/src/router/types.ts +8 -0
- package/src/router/url-params.ts +49 -0
- package/src/router.ts +38 -23
- package/src/rsc/handler-context.ts +2 -2
- package/src/rsc/handler.ts +28 -69
- package/src/rsc/helpers.ts +91 -43
- package/src/rsc/index.ts +1 -1
- package/src/rsc/origin-guard.ts +28 -10
- package/src/rsc/progressive-enhancement.ts +4 -0
- package/src/rsc/response-route-handler.ts +46 -53
- package/src/rsc/rsc-rendering.ts +35 -51
- package/src/rsc/runtime-warnings.ts +9 -10
- package/src/rsc/server-action.ts +17 -37
- package/src/rsc/ssr-setup.ts +16 -0
- package/src/rsc/types.ts +8 -2
- package/src/search-params.ts +4 -4
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +132 -116
- package/src/serialize.ts +243 -0
- package/src/server/context.ts +143 -53
- package/src/server/cookie-store.ts +28 -4
- package/src/server/request-context.ts +20 -42
- package/src/ssr/index.tsx +5 -1
- package/src/static-handler.ts +1 -1
- package/src/testing/cache-status.ts +166 -0
- package/src/testing/collect-handle.ts +63 -0
- package/src/testing/dispatch.ts +440 -0
- package/src/testing/dom.entry.ts +22 -0
- package/src/testing/e2e/fixture.ts +154 -0
- package/src/testing/e2e/index.ts +149 -0
- package/src/testing/e2e/matchers.ts +51 -0
- package/src/testing/e2e/page-helpers.ts +272 -0
- package/src/testing/e2e/parity.ts +306 -0
- package/src/testing/e2e/server.ts +183 -0
- package/src/testing/flight-matchers.ts +104 -0
- package/src/testing/flight-runtime.d.ts +57 -0
- package/src/testing/flight-tree.ts +320 -0
- package/src/testing/flight.entry.ts +39 -0
- package/src/testing/flight.ts +197 -0
- package/src/testing/generated-routes.ts +223 -0
- package/src/testing/index.ts +106 -0
- package/src/testing/internal/context.ts +304 -0
- package/src/testing/internal/flight-client-globals.ts +30 -0
- package/src/testing/render-route.tsx +565 -0
- package/src/testing/run-loader.ts +341 -0
- package/src/testing/run-middleware.ts +179 -0
- package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
- package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
- package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
- package/src/testing/vitest-stubs/version.ts +5 -0
- package/src/testing/vitest.ts +270 -0
- package/src/types/global-namespace.ts +39 -26
- package/src/types/handler-context.ts +68 -50
- package/src/types/index.ts +1 -0
- package/src/types/loader-types.ts +5 -6
- package/src/types/request-scope.ts +126 -0
- package/src/types/route-entry.ts +11 -0
- package/src/types/segments.ts +35 -2
- package/src/urls/include-helper.ts +34 -67
- package/src/urls/index.ts +0 -3
- package/src/urls/path-helper-types.ts +41 -7
- package/src/urls/path-helper.ts +17 -52
- package/src/urls/pattern-types.ts +36 -19
- package/src/urls/response-types.ts +22 -29
- package/src/urls/type-extraction.ts +26 -116
- package/src/urls/urls-function.ts +1 -5
- package/src/use-loader.tsx +413 -42
- package/src/vite/debug.ts +185 -0
- package/src/vite/discovery/bundle-postprocess.ts +6 -6
- package/src/vite/discovery/discover-routers.ts +101 -51
- package/src/vite/discovery/discovery-errors.ts +194 -0
- package/src/vite/discovery/gate-state.ts +171 -0
- package/src/vite/discovery/prerender-collection.ts +67 -26
- package/src/vite/discovery/route-types-writer.ts +40 -84
- package/src/vite/discovery/self-gen-tracking.ts +27 -1
- package/src/vite/discovery/state.ts +33 -0
- package/src/vite/discovery/virtual-module-codegen.ts +13 -23
- package/src/vite/index.ts +2 -0
- package/src/vite/plugin-types.ts +67 -0
- package/src/vite/plugins/cjs-to-esm.ts +8 -7
- package/src/vite/plugins/client-ref-dedup.ts +16 -0
- package/src/vite/plugins/client-ref-hashing.ts +28 -5
- package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
- package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
- package/src/vite/plugins/expose-action-id.ts +54 -30
- package/src/vite/plugins/expose-id-utils.ts +12 -8
- package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
- package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
- package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
- package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
- package/src/vite/plugins/expose-internal-ids.ts +496 -486
- package/src/vite/plugins/performance-tracks.ts +29 -25
- package/src/vite/plugins/use-cache-transform.ts +65 -50
- package/src/vite/plugins/version-injector.ts +39 -23
- package/src/vite/plugins/version-plugin.ts +59 -2
- package/src/vite/plugins/virtual-entries.ts +2 -2
- package/src/vite/rango.ts +116 -29
- package/src/vite/router-discovery.ts +750 -100
- package/src/vite/utils/ast-handler-extract.ts +15 -15
- package/src/vite/utils/banner.ts +1 -1
- package/src/vite/utils/bundle-analysis.ts +4 -2
- package/src/vite/utils/client-chunks.ts +190 -0
- package/src/vite/utils/forward-user-plugins.ts +193 -0
- package/src/vite/utils/manifest-utils.ts +21 -5
- package/src/vite/utils/package-resolution.ts +41 -1
- package/src/vite/utils/prerender-utils.ts +21 -6
- package/src/vite/utils/shared-utils.ts +107 -26
- package/src/browser/action-response-classifier.ts +0 -99
|
@@ -0,0 +1,149 @@
|
|
|
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
|
+
import type { Expect, TestType } from "@playwright/test";
|
|
7
|
+
import {
|
|
8
|
+
createUseFixture,
|
|
9
|
+
type Fixture,
|
|
10
|
+
type FixtureOptions,
|
|
11
|
+
} from "./fixture.js";
|
|
12
|
+
import {
|
|
13
|
+
createPageHelpers,
|
|
14
|
+
createStopwatch,
|
|
15
|
+
getHistoryState,
|
|
16
|
+
getNumericContent,
|
|
17
|
+
goBack,
|
|
18
|
+
goForward,
|
|
19
|
+
isVisibleInViewport,
|
|
20
|
+
measureTime,
|
|
21
|
+
type PageHelpers,
|
|
22
|
+
parseNumber,
|
|
23
|
+
type Stopwatch,
|
|
24
|
+
testId,
|
|
25
|
+
waitForElement,
|
|
26
|
+
waitForHydration,
|
|
27
|
+
waitForNavigation,
|
|
28
|
+
} from "./page-helpers.js";
|
|
29
|
+
import {
|
|
30
|
+
createParity,
|
|
31
|
+
type ExpectParityOptions,
|
|
32
|
+
type Parity,
|
|
33
|
+
type ParityDescribeOptions,
|
|
34
|
+
type ParityIntent,
|
|
35
|
+
} from "./parity.js";
|
|
36
|
+
import { createRangoMatchers, type RangoMatchers } from "./matchers.js";
|
|
37
|
+
|
|
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
|
+
export {
|
|
45
|
+
assertCacheStatus,
|
|
46
|
+
parseCacheHeader,
|
|
47
|
+
createCacheSink,
|
|
48
|
+
filterCacheDecisions,
|
|
49
|
+
type CacheSink,
|
|
50
|
+
type ExpectedCacheStatus,
|
|
51
|
+
type CacheStatusTarget,
|
|
52
|
+
} 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
|
+
export {
|
|
57
|
+
testId,
|
|
58
|
+
waitForHydration,
|
|
59
|
+
waitForNavigation,
|
|
60
|
+
goBack,
|
|
61
|
+
goForward,
|
|
62
|
+
getHistoryState,
|
|
63
|
+
waitForElement,
|
|
64
|
+
isVisibleInViewport,
|
|
65
|
+
parseNumber,
|
|
66
|
+
getNumericContent,
|
|
67
|
+
createStopwatch,
|
|
68
|
+
measureTime,
|
|
69
|
+
createPageHelpers,
|
|
70
|
+
createUseFixture,
|
|
71
|
+
createParity,
|
|
72
|
+
createRangoMatchers,
|
|
73
|
+
};
|
|
74
|
+
export type {
|
|
75
|
+
Fixture,
|
|
76
|
+
FixtureOptions,
|
|
77
|
+
PageHelpers,
|
|
78
|
+
Stopwatch,
|
|
79
|
+
Parity,
|
|
80
|
+
ParityIntent,
|
|
81
|
+
ParityDescribeOptions,
|
|
82
|
+
ExpectParityOptions,
|
|
83
|
+
RangoMatchers,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export interface RangoE2E extends PageHelpers, Parity {
|
|
87
|
+
useFixture: (options: FixtureOptions) => Fixture;
|
|
88
|
+
testNoJs: TestType<any, any>;
|
|
89
|
+
rangoMatchers: RangoMatchers;
|
|
90
|
+
// Standalone helpers, re-surfaced for convenience.
|
|
91
|
+
testId: typeof testId;
|
|
92
|
+
waitForHydration: typeof waitForHydration;
|
|
93
|
+
waitForNavigation: typeof waitForNavigation;
|
|
94
|
+
goBack: typeof goBack;
|
|
95
|
+
goForward: typeof goForward;
|
|
96
|
+
getHistoryState: typeof getHistoryState;
|
|
97
|
+
waitForElement: typeof waitForElement;
|
|
98
|
+
isVisibleInViewport: typeof isVisibleInViewport;
|
|
99
|
+
parseNumber: typeof parseNumber;
|
|
100
|
+
getNumericContent: typeof getNumericContent;
|
|
101
|
+
createStopwatch: typeof createStopwatch;
|
|
102
|
+
measureTime: typeof measureTime;
|
|
103
|
+
}
|
|
104
|
+
|
|
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
|
+
export function createRangoE2E({
|
|
112
|
+
test,
|
|
113
|
+
expect,
|
|
114
|
+
defaultRoot,
|
|
115
|
+
}: {
|
|
116
|
+
test: TestType<any, any>;
|
|
117
|
+
expect: Expect;
|
|
118
|
+
defaultRoot?: string;
|
|
119
|
+
}): RangoE2E {
|
|
120
|
+
const useFixture = createUseFixture(test);
|
|
121
|
+
const pageHelpers = createPageHelpers(expect);
|
|
122
|
+
const parity = createParity({ test, expect, useFixture, defaultRoot });
|
|
123
|
+
const rangoMatchers = createRangoMatchers(expect);
|
|
124
|
+
const testNoJs = test.extend({
|
|
125
|
+
javaScriptEnabled: ({}, use: (value: boolean) => Promise<void>) =>
|
|
126
|
+
use(false),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
useFixture,
|
|
131
|
+
testNoJs,
|
|
132
|
+
rangoMatchers,
|
|
133
|
+
...parity,
|
|
134
|
+
...pageHelpers,
|
|
135
|
+
// Standalone helpers.
|
|
136
|
+
testId,
|
|
137
|
+
waitForHydration,
|
|
138
|
+
waitForNavigation,
|
|
139
|
+
goBack,
|
|
140
|
+
goForward,
|
|
141
|
+
getHistoryState,
|
|
142
|
+
waitForElement,
|
|
143
|
+
isVisibleInViewport,
|
|
144
|
+
parseNumber,
|
|
145
|
+
getNumericContent,
|
|
146
|
+
createStopwatch,
|
|
147
|
+
measureTime,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Custom Playwright matchers for Rango assertions. Returned as an object
|
|
2
|
+
// suitable for `expect.extend(...)`. v1 ships only `toHaveRangoPathname`.
|
|
3
|
+
|
|
4
|
+
import type { Expect, Page } from "@playwright/test";
|
|
5
|
+
|
|
6
|
+
interface MatcherResult {
|
|
7
|
+
pass: boolean;
|
|
8
|
+
message: () => string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface RangoMatchers {
|
|
12
|
+
toHaveRangoPathname: (page: Page, expected: string) => MatcherResult;
|
|
13
|
+
}
|
|
14
|
+
|
|
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
|
+
export function createRangoMatchers(_expect: Expect): RangoMatchers {
|
|
27
|
+
return {
|
|
28
|
+
toHaveRangoPathname(page: Page, expected: string): MatcherResult {
|
|
29
|
+
const actual = new URL(page.url()).pathname;
|
|
30
|
+
const pass = actual === expected;
|
|
31
|
+
return {
|
|
32
|
+
pass,
|
|
33
|
+
message: () =>
|
|
34
|
+
pass
|
|
35
|
+
? `Expected pathname not to be "${expected}"`
|
|
36
|
+
: `Expected pathname "${expected}" but got "${actual}"`,
|
|
37
|
+
};
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Type augmentation so consumers can call `await expect(page).toHaveRangoPathname("/x")`
|
|
43
|
+
// after `expect.extend(rangoMatchers)`, without re-declaring the matcher.
|
|
44
|
+
declare global {
|
|
45
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
46
|
+
namespace PlaywrightTest {
|
|
47
|
+
interface Matchers<R, T> {
|
|
48
|
+
toHaveRangoPathname(expected: string): R;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// Page helpers for the consumer e2e harness. Type-only Playwright imports keep
|
|
2
|
+
// this module loadable in a plain-node process. Helpers needing assertions are
|
|
3
|
+
// returned from `createPageHelpers(expect)`; the rest are standalone.
|
|
4
|
+
|
|
5
|
+
import type { ConsoleMessage, Expect, Locator, Page } from "@playwright/test";
|
|
6
|
+
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// Standalone helpers (need only a page/locator)
|
|
9
|
+
// ============================================================================
|
|
10
|
+
|
|
11
|
+
/** Get a locator by data-testid attribute. */
|
|
12
|
+
export function testId(page: Page, id: string): Locator {
|
|
13
|
+
return page.locator(`[data-testid="${id}"]`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Wait for React hydration to complete and verify no hydration errors.
|
|
18
|
+
* Rango sets `data-hydrated` on `<html>` after hydration via useEffect; this
|
|
19
|
+
* is a stable readiness signal that does not rely on React internals.
|
|
20
|
+
*/
|
|
21
|
+
export async function waitForHydration(page: Page): Promise<void> {
|
|
22
|
+
const hydrationErrors: string[] = [];
|
|
23
|
+
|
|
24
|
+
const isHydrationError = (text: string) =>
|
|
25
|
+
text.includes("Hydration failed") ||
|
|
26
|
+
text.includes("hydration mismatch") ||
|
|
27
|
+
text.includes("Text content does not match") ||
|
|
28
|
+
text.includes("did not match") ||
|
|
29
|
+
text.includes("server rendered HTML") ||
|
|
30
|
+
text.includes("Hydration error");
|
|
31
|
+
|
|
32
|
+
const consoleHandler = (msg: ConsoleMessage) => {
|
|
33
|
+
const text = msg.text();
|
|
34
|
+
if (isHydrationError(text)) hydrationErrors.push(text);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// React throws hydration errors as page errors.
|
|
38
|
+
const pageErrorHandler = (error: Error) => {
|
|
39
|
+
if (isHydrationError(error.message)) hydrationErrors.push(error.message);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
page.on("console", consoleHandler);
|
|
43
|
+
page.on("pageerror", pageErrorHandler);
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
await page.waitForLoadState("domcontentloaded");
|
|
47
|
+
await page.waitForFunction(
|
|
48
|
+
() => document.documentElement.hasAttribute("data-hydrated"),
|
|
49
|
+
{ timeout: 20000 },
|
|
50
|
+
);
|
|
51
|
+
// Small delay to catch any async hydration errors.
|
|
52
|
+
await page.waitForTimeout(100);
|
|
53
|
+
if (hydrationErrors.length > 0) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`Hydration errors detected:\n${hydrationErrors.join("\n")}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
} finally {
|
|
59
|
+
page.off("console", consoleHandler);
|
|
60
|
+
page.off("pageerror", pageErrorHandler);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Wait for navigation to complete (URL change + network idle). */
|
|
65
|
+
export async function waitForNavigation(
|
|
66
|
+
page: Page,
|
|
67
|
+
expectedUrl: string | RegExp,
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
await page.waitForURL(expectedUrl, { waitUntil: "networkidle" });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Navigate back and wait for navigation to complete. */
|
|
73
|
+
export async function goBack(page: Page): Promise<void> {
|
|
74
|
+
// Wait for the URL to actually CHANGE. A `/.*/ ` pattern matches the current
|
|
75
|
+
// URL immediately, so it would resolve before the history navigation happened.
|
|
76
|
+
const from = page.url();
|
|
77
|
+
await Promise.all([
|
|
78
|
+
page.waitForURL((url) => url.toString() !== from, {
|
|
79
|
+
waitUntil: "networkidle",
|
|
80
|
+
}),
|
|
81
|
+
page.goBack(),
|
|
82
|
+
]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Navigate forward and wait for navigation to complete. */
|
|
86
|
+
export async function goForward(page: Page): Promise<void> {
|
|
87
|
+
const from = page.url();
|
|
88
|
+
await Promise.all([
|
|
89
|
+
page.waitForURL((url) => url.toString() !== from, {
|
|
90
|
+
waitUntil: "networkidle",
|
|
91
|
+
}),
|
|
92
|
+
page.goForward(),
|
|
93
|
+
]);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Get the current history state. */
|
|
97
|
+
export async function getHistoryState(page: Page): Promise<unknown> {
|
|
98
|
+
return page.evaluate(() => window.history.state);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Wait for an element to appear and be visible. */
|
|
102
|
+
export async function waitForElement(
|
|
103
|
+
page: Page,
|
|
104
|
+
selector: string,
|
|
105
|
+
timeout = 10000,
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
await page.locator(selector).waitFor({ state: "visible", timeout });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Check if an element is visible. */
|
|
111
|
+
export async function isVisibleInViewport(
|
|
112
|
+
page: Page,
|
|
113
|
+
selector: string,
|
|
114
|
+
): Promise<boolean> {
|
|
115
|
+
return page.locator(selector).isVisible();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Parse a number from text content (finds first number in string).
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* parseNumber("Load count: 42") // 42
|
|
123
|
+
* parseNumber(null) // 0
|
|
124
|
+
*/
|
|
125
|
+
export function parseNumber(text: string | null | undefined): number {
|
|
126
|
+
return parseInt(text?.match(/\d+/)?.[0] || "0", 10);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Get the numeric value from an element's text content. */
|
|
130
|
+
export async function getNumericContent(locator: Locator): Promise<number> {
|
|
131
|
+
const text = await locator.textContent();
|
|
132
|
+
return parseNumber(text);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface Stopwatch {
|
|
136
|
+
elapsed: () => number;
|
|
137
|
+
checkpoint: () => number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Create a stopwatch for timing multiple checkpoints. */
|
|
141
|
+
export function createStopwatch(): Stopwatch {
|
|
142
|
+
const startTime = Date.now();
|
|
143
|
+
return {
|
|
144
|
+
elapsed: () => Date.now() - startTime,
|
|
145
|
+
checkpoint: () => Date.now() - startTime,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Measure elapsed time for an async operation. Returns `{ elapsed, result }`
|
|
151
|
+
* where `elapsed` is in milliseconds.
|
|
152
|
+
*/
|
|
153
|
+
export async function measureTime<T>(
|
|
154
|
+
fn: () => Promise<T>,
|
|
155
|
+
): Promise<{ elapsed: number; result: T }> {
|
|
156
|
+
const startTime = Date.now();
|
|
157
|
+
const result = await fn();
|
|
158
|
+
const elapsed = Date.now() - startTime;
|
|
159
|
+
return { elapsed, result };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ============================================================================
|
|
163
|
+
// Factory-bound helpers (need the injected `expect`)
|
|
164
|
+
// ============================================================================
|
|
165
|
+
|
|
166
|
+
export interface PageHelpers {
|
|
167
|
+
/** Assert that no full page reload occurred between scope entry and exit. */
|
|
168
|
+
expectNoReload: (page: Page) => AsyncDisposable;
|
|
169
|
+
/** Collect page errors and assert none occurred on scope exit. */
|
|
170
|
+
expectNoPageError: (page: Page) => Disposable;
|
|
171
|
+
/** Wait for an element's text to change from its initial value. */
|
|
172
|
+
waitForTextChange: (
|
|
173
|
+
locator: Locator,
|
|
174
|
+
initialText: string,
|
|
175
|
+
timeout?: number,
|
|
176
|
+
) => Promise<void>;
|
|
177
|
+
/** Wait for a numeric value in an element to change. */
|
|
178
|
+
waitForNumericChange: (
|
|
179
|
+
locator: Locator,
|
|
180
|
+
initialValue: number,
|
|
181
|
+
timeout?: number,
|
|
182
|
+
) => Promise<void>;
|
|
183
|
+
/** Assert timing is within `expected +/- tolerance`. */
|
|
184
|
+
expectTiming: (elapsed: number, expected: number, tolerance: number) => void;
|
|
185
|
+
/** Assert timing is at least `minimum`. */
|
|
186
|
+
expectMinTiming: (elapsed: number, minimum: number) => void;
|
|
187
|
+
/** Assert timing is less than `maximum`. */
|
|
188
|
+
expectMaxTiming: (elapsed: number, maximum: number) => void;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function createPageHelpers(expect: Expect): PageHelpers {
|
|
192
|
+
function expectNoReload(page: Page): AsyncDisposable {
|
|
193
|
+
const setup = page.evaluate(() => {
|
|
194
|
+
const el = document.createElement("meta");
|
|
195
|
+
el.setAttribute("name", "x-reload-check");
|
|
196
|
+
document.head.append(el);
|
|
197
|
+
});
|
|
198
|
+
return {
|
|
199
|
+
[Symbol.asyncDispose]: async () => {
|
|
200
|
+
await setup;
|
|
201
|
+
// Keep the timeout small so a real reload is reported quickly, but not
|
|
202
|
+
// so small that a busy page produces a spurious failure (which would
|
|
203
|
+
// mask the real test error as a SuppressedError under `await using`).
|
|
204
|
+
await expect(page.locator(`meta[name="x-reload-check"]`)).toBeAttached({
|
|
205
|
+
timeout: 100,
|
|
206
|
+
});
|
|
207
|
+
await page.evaluate(() => {
|
|
208
|
+
document.querySelector(`meta[name="x-reload-check"]`)!.remove();
|
|
209
|
+
});
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function expectNoPageError(page: Page): Disposable {
|
|
215
|
+
const errors: Error[] = [];
|
|
216
|
+
page.on("pageerror", (error) => {
|
|
217
|
+
errors.push(error);
|
|
218
|
+
});
|
|
219
|
+
return {
|
|
220
|
+
[Symbol.dispose]: () => {
|
|
221
|
+
expect(errors).toEqual([]);
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function waitForTextChange(
|
|
227
|
+
locator: Locator,
|
|
228
|
+
initialText: string,
|
|
229
|
+
timeout = 10000,
|
|
230
|
+
): Promise<void> {
|
|
231
|
+
await expect
|
|
232
|
+
.poll(async () => await locator.textContent(), { timeout })
|
|
233
|
+
.not.toBe(initialText);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function waitForNumericChange(
|
|
237
|
+
locator: Locator,
|
|
238
|
+
initialValue: number,
|
|
239
|
+
timeout = 10000,
|
|
240
|
+
): Promise<void> {
|
|
241
|
+
await expect
|
|
242
|
+
.poll(async () => parseNumber(await locator.textContent()), { timeout })
|
|
243
|
+
.not.toBe(initialValue);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function expectTiming(
|
|
247
|
+
elapsed: number,
|
|
248
|
+
expected: number,
|
|
249
|
+
tolerance: number,
|
|
250
|
+
): void {
|
|
251
|
+
expect(elapsed).toBeGreaterThan(expected - tolerance);
|
|
252
|
+
expect(elapsed).toBeLessThan(expected + tolerance);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function expectMinTiming(elapsed: number, minimum: number): void {
|
|
256
|
+
expect(elapsed).toBeGreaterThan(minimum);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function expectMaxTiming(elapsed: number, maximum: number): void {
|
|
260
|
+
expect(elapsed).toBeLessThan(maximum);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
expectNoReload,
|
|
265
|
+
expectNoPageError,
|
|
266
|
+
waitForTextChange,
|
|
267
|
+
waitForNumericChange,
|
|
268
|
+
expectTiming,
|
|
269
|
+
expectMinTiming,
|
|
270
|
+
expectMaxTiming,
|
|
271
|
+
};
|
|
272
|
+
}
|