170 lines
7.2 KiB
TypeScript
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();
|
|
});
|
|
}
|