Files
HiveGrid-Wireframes/hivegrid-cloud/tests/e2e/utils/navigation.ts
T

170 lines
7.2 KiB
TypeScript

import { test, type Page, type Response } from "@playwright/test";
import { encode } from "turbo-stream";
const SKIPPABLE_STATUSES = new Set([400, 403, 404]);
const STRIPE_HOST_PATTERN = /(^|\.)stripe\.com$/u;
/**
* Wait until the client app has hydrated (`<html data-hydrated="true">`, set by
* entry.client after the hydrateRoot commit). Server-rendered markup and
* progressive-enhanced <Form>s work before this, but pure-JS controls (sidebar
* drawer, command palette, dropdowns) only respond once React has attached
* event handlers. On slower production targets (boiler) a click fired before
* hydration is silently dropped, so interaction tests must wait for this first.
*/
export async function waitForHydration(page: Page, timeout = 15000): Promise<void> {
await page
.locator("html[data-hydrated='true']")
.waitFor({ state: "attached", timeout })
.catch(() => undefined);
}
export async function skipIfUnavailable(page: Page, response: Response | null, reason: string): Promise<void> {
if (/\/login(?:[?#].*)?$/u.test(new URL(page.url()).pathname)) {
if (process.env.REQUIRE_E2E_READINESS === "1") {
throw new Error(`${reason}: redirected to login while REQUIRE_E2E_READINESS=1`);
}
test.skip(true, `${reason}: redirected to login`);
}
const responseStatus = response?.status();
const bodyStatus = Number(await page.evaluate(() => document.body.dataset.statusCode ?? ""));
const status = SKIPPABLE_STATUSES.has(responseStatus ?? 0) ? responseStatus : bodyStatus;
if (SKIPPABLE_STATUSES.has(status)) {
if (process.env.REQUIRE_E2E_READINESS === "1") {
throw new Error(`${reason}: returned ${status} while REQUIRE_E2E_READINESS=1`);
}
test.skip(true, `${reason}: returned ${status}`);
}
}
export function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}
export function postDataIncludesField(postData: string | null, name: string, valuePattern: RegExp): boolean {
if (!postData) return false;
try {
const params = new URLSearchParams(postData);
const value = params.get(name);
if (value !== null) return valuePattern.test(value);
} catch {
// Fall through to multipart/string matching below.
}
const escapedName = escapeRegExp(name);
if (!new RegExp(`name="${escapedName}"`, "u").test(postData)) return false;
const multipartValue = postData.match(new RegExp(`name="${escapedName}"\\r?\\n\\r?\\n([^\\r\\n-]+)`, "u"))?.[1]?.trim();
if (multipartValue !== undefined) return valuePattern.test(multipartValue);
return valuePattern.test(postData);
}
/**
* Build a React Router v7 single-fetch action error response. Form actions post
* to the route's `.data` endpoint and the client decodes a turbo-stream body
* (content-type `text/x-script`), NOT raw JSON — a faked `application/json` body
* breaks the decoder so the route never receives `actionData` and the error UI
* never renders. This reproduces the exact envelope the server emits for an
* action that returns `{ error }` (captured live: an empty new-account name
* yields `[{"_1":2},"data",{"_3":4},"error","Name is required"]\n`), letting a
* test assert the real error path without triggering the action's side effect.
* The `X-Remix-Response: yes` header is required — RR7's single-fetch client
* rejects the response and renders the error boundary without it.
*/
export function singleFetchActionError(message: string): { status: number; body: string; contentType: string; headers: Record<string, string> } {
return {
status: 400,
contentType: "text/x-script",
body: `${JSON.stringify([{ _1: 2 }, "data", { _3: 4 }, "error", message])}\n`,
headers: { "x-remix-response": "yes" },
};
}
async function encodeTurboStream(value: unknown): Promise<string> {
const stream = encode(value);
const reader = stream.getReader();
const decoder = new TextDecoder();
let out = "";
for (;;) {
const { done, value: chunk } = await reader.read();
if (done) break;
out += decoder.decode(chunk, { stream: true });
}
out += decoder.decode();
return out;
}
/**
* Build a React Router v7 single-fetch action *success* response for an action
* that returns `payload`. Like {@link singleFetchActionError}, but for the
* resolved-data path: the server encodes `{ data: <actionReturnValue> }` via
* turbo-stream and the client (`unwrapSingleFetchResult`) hands `.data` to
* `useActionData()`. Fulfilling the `.data` POST with raw `application/json`
* instead breaks RR7's turbo-stream decoder, so `actionData` is never populated
* and the result/waiting UI never renders. We call the real `turbo-stream`
* `encode` (same package RR7 uses) so arbitrarily-shaped payloads — e.g. a full
* waiting-block execution — encode exactly as the server would. The
* `X-Remix-Response: yes` header is required or RR7 rejects the response.
*/
export async function singleFetchActionData(
payload: unknown,
status = 200
): Promise<{ status: number; body: string; contentType: string; headers: Record<string, string> }> {
return {
status,
contentType: "text/x-script",
body: await encodeTurboStream({ data: payload }),
headers: { "x-remix-response": "yes" },
};
}
export async function interceptCurrentRoutePost(
page: Page,
// When `passthrough` is set, the POST is captured but allowed to reach the
// server (used to assert the *real* single-fetch error response renders, since
// a faked JSON body is not valid turbo-stream and breaks RR7's decoder).
fulfill: { status?: number; body?: string; contentType?: string; headers?: Record<string, string>; passthrough?: boolean } = {}
): Promise<{ value: string | null }> {
const submittedPostData = { value: null as string | null };
const currentUrl = new URL(page.url());
// React Router v7 single-fetch posts form actions to the route's `.data`
// endpoint (e.g. `/path.data`) rather than the bare pathname. Accept either
// so the contract capture works for single-fetch and document submissions.
const singleFetchPathname = `${currentUrl.pathname}.data`;
await page.route("**/*", async (route) => {
const request = route.request();
const requestUrl = new URL(request.url());
if (
request.method() === "POST" &&
requestUrl.origin === currentUrl.origin &&
(requestUrl.pathname === currentUrl.pathname || requestUrl.pathname === singleFetchPathname)
) {
submittedPostData.value = request.postData();
if (fulfill.passthrough) {
await route.fallback();
return;
}
await route.fulfill({
status: fulfill.status ?? 204,
body: fulfill.body ?? "",
...(fulfill.contentType ? { contentType: fulfill.contentType } : {}),
...(fulfill.headers ? { headers: fulfill.headers } : {}),
});
return;
}
await route.fallback();
});
return submittedPostData;
}
export async function failOnStripeNetworkRequests(page: Page): Promise<void> {
await page.route("**/*", async (route) => {
const request = route.request();
const requestUrl = new URL(request.url());
if (STRIPE_HOST_PATTERN.test(requestUrl.hostname)) {
throw new Error(`Unexpected Stripe network request during E2E smoke: ${request.method()} ${request.url()}`);
}
await route.fallback();
});
}