who_need_help/e2e/tests/helpers.ts

457 lines
13 KiB
TypeScript

import {
APIRequestContext,
Browser,
BrowserContext,
expect,
Locator,
Page,
Request,
} from "@playwright/test";
const mailpitURL = process.env.MAILPIT_URL;
const baseURL = process.env.BASE_URL;
if (!mailpitURL || !baseURL) {
throw new Error("MAILPIT_URL and BASE_URL are required");
}
export type AuthenticatedBrowser = {
context: BrowserContext;
page: Page;
email: string;
};
export function projectEmail(localPart: string, projectName: string): string {
const runID = process.env.E2E_RUN_ID;
if (runID) {
return `wnh-staging-e2e-${runID}-${localPart}@example.invalid`;
}
const scope = projectName.toLowerCase().replace(/[^a-z0-9-]/g, "-");
return `${localPart}+${scope}@example.invalid`;
}
export function projectText(value: string, projectName: string): string {
return `${value} [${projectName}]`;
}
export async function newIsolatedContext(browser: Browser): Promise<BrowserContext> {
return browser.newContext({ serviceWorkers: "block" });
}
export async function gotoWithTransientRetry(
page: Page,
url: string,
attempts = 3,
): Promise<void> {
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
await page.goto(url);
return;
} catch (error) {
lastError = error;
const message = error instanceof Error ? error.message : String(error);
const transient = isTransientNetworkError(message);
if (!transient || attempt === attempts) {
throw error;
}
await page.waitForTimeout(500 * attempt);
}
}
throw lastError;
}
export function isTransientNetworkError(message: string): boolean {
return [
"ERR_NETWORK_CHANGED",
"ERR_CONNECTION_RESET",
"ERR_CONNECTION_CLOSED",
"NS_ERROR_NET_RESET",
].some((value) => message.includes(value));
}
export async function clickUntilVisible(
trigger: Locator,
expected: Locator,
attempts = 3,
): Promise<void> {
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
if (await expected.isVisible()) {
return;
}
await trigger.click();
try {
await expect(expected).toBeVisible({ timeout: 5_000 });
return;
} catch (error) {
lastError = error;
if (attempt === attempts || !(await trigger.isVisible())) {
throw error;
}
await trigger.page().waitForTimeout(500 * attempt);
}
}
throw lastError;
}
export async function waitForLiveViewConnected(page: Page): Promise<void> {
await expect(page.locator("[data-phx-main].phx-connected")).toHaveCount(1, {
timeout: 15_000,
});
}
export async function gotoLiveView(page: Page, url: string): Promise<void> {
await gotoWithTransientRetry(page, url);
await waitForLiveViewConnected(page);
}
export async function waitForMapReady(page: Page): Promise<void> {
const map = page.locator(".aid-map");
if ((await map.count()) > 0) {
await expect
.poll(
async () => {
if ((await map.getAttribute("data-map-ready")) === "true") {
return "map";
}
if ((await map.locator("[data-map-fallback=true]").count()) === 1) {
return "fallback";
}
return null;
},
{ timeout: 15_000 },
)
.not.toBeNull();
}
}
export async function markerKinds(page: Page): Promise<string[]> {
const encoded = await page.locator("#show-map").getAttribute("data-markers");
const markers = JSON.parse(encoded ?? "[]") as Array<{ title?: string }>;
return markers.map(({ title }) => title ?? "");
}
export function captureBrowserFailures(
page: Page,
options: { recoverableOneTimeNavigations?: Set<string> } = {},
): () => void {
const failures: string[] = [];
const pendingTransientNavigations = new Map<string, string>();
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame()) {
pendingTransientNavigations.delete(frame.url());
}
});
page.on("console", (message) => {
if (message.type() === "error") {
failures.push(`console: ${message.text()}`);
}
});
page.on("pageerror", (error) => {
failures.push(`pageerror: ${error.message}`);
});
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "";
const cancelled =
failure.includes("Load request cancelled") ||
failure.includes("ERR_ABORTED") ||
failure.includes("NS_BINDING_ABORTED");
const rasterTile =
request.url().includes("/__e2e__/map-tile.png") ||
/\/\d+\/\d+\/\d+\.png(?:\?|$)/.test(request.url());
const replacedDocumentAsset =
cancelled &&
new URL(request.url()).origin === new URL(baseURL).origin &&
["script", "stylesheet"].includes(request.resourceType());
const recoverableOneTimeNavigation =
options.recoverableOneTimeNavigations?.has(request.url()) === true &&
isTransientNetworkError(failure);
const pendingTransientNavigation =
request.isNavigationRequest() &&
new URL(request.url()).origin === new URL(baseURL).origin &&
isTransientNetworkError(failure);
// MapLibre cancels in-flight raster tiles when a LiveView navigation
// destroys or re-centres a map. Chromium also aborts the old document's
// static assets when a bounded navigation retry replaces that document.
// A same-origin document navigation may fail once while the public tunnel
// changes networks. Keep that failure pending until the same URL actually
// commits; an unrecovered navigation remains a test failure.
if (pendingTransientNavigation) {
pendingTransientNavigations.set(
request.url(),
`requestfailed: ${request.method()} ${request.url()} ${failure}`,
);
return;
}
if (
(rasterTile && cancelled) ||
replacedDocumentAsset ||
recoverableOneTimeNavigation
) {
return;
}
failures.push(
`requestfailed: ${request.method()} ${request.url()} ${failure}`,
);
});
return () => {
const unresolved = [...failures, ...pendingTransientNavigations.values()];
expect(unresolved, unresolved.join("\n")).toEqual([]);
};
}
export async function latestMessageID(
request: APIRequestContext,
email: string,
): Promise<string | undefined> {
const response = await request.get(`${mailpitURL}/api/v1/search`, {
params: {
query: `to:"${email}"`,
limit: "10",
},
});
expect(response.ok()).toBeTruthy();
const body = (await response.json()) as { messages?: Array<{ ID?: string }> };
return body.messages?.[0]?.ID;
}
export async function waitForApplicationEmailLink(
request: APIRequestContext,
email: string,
pathPrefix: string,
previousMessageID?: string,
): Promise<string> {
let messageID: string | undefined;
await expect
.poll(
async () => {
messageID = await latestMessageID(request, email);
return messageID && messageID !== previousMessageID ? messageID : undefined;
},
{
message: `waiting for the Mailpit login message for ${email}`,
timeout: 15_000,
},
)
.toBeTruthy();
const response = await request.get(`${mailpitURL}/api/v1/message/${messageID}`);
expect(response.ok()).toBeTruthy();
const message = (await response.json()) as {
Text?: string;
HTML?: string;
};
const content = `${message.Text ?? ""}\n${message.HTML ?? ""}`;
const candidates = content.match(/https?:\/\/[^\s"'<>]+/g) ?? [];
const match = candidates
.map((candidate) => candidate.replaceAll("&amp;", "&"))
.find((candidate) => {
try {
return new URL(candidate).pathname.startsWith(pathPrefix);
} catch {
return false;
}
});
if (!match) {
throw new Error(`No application link starting with ${pathPrefix} found for ${email}`);
}
const link = new URL(match);
const applicationOrigin = new URL(baseURL);
link.protocol = applicationOrigin.protocol;
link.host = applicationOrigin.host;
return link.toString();
}
async function waitForMagicLink(
request: APIRequestContext,
email: string,
previousMessageID?: string,
): Promise<string> {
return waitForApplicationEmailLink(
request,
email,
"/users/log-in",
previousMessageID,
);
}
export async function registerAndConfirm(
browser: Browser,
request: APIRequestContext,
email: string,
displayName: string,
): Promise<AuthenticatedBrowser> {
const context = await newIsolatedContext(browser);
const page = await context.newPage();
await gotoWithTransientRetry(page, "/users/register");
const form = page.locator("#email_registration_form");
await form.getByLabel("Email").fill(email);
await form.getByLabel("Display name").fill(displayName);
await form
.getByLabel("I am 18 or older and accept the safety rules")
.check();
await form
.getByRole("button", { name: "Create account and email me a link" })
.click();
await expect(page).toHaveURL(/\/users\/log-in$/);
await expect(
page
.getByRole("alert")
.getByText(
"If this address can be registered or signed in, login instructions will arrive shortly.",
),
).toBeVisible();
await gotoWithTransientRetry(page, await waitForMagicLink(request, email));
await expect(page.locator("#magic-link-fragment-form")).toBeVisible();
await page
.getByRole("button", { name: "Confirm and log in only this time" })
.click();
await expect(page.getByText(email, { exact: true })).toBeVisible();
return { context, page, email };
}
export async function loginWithMagicLink(
browser: Browser,
request: APIRequestContext,
email: string,
): Promise<AuthenticatedBrowser> {
const context = await newIsolatedContext(browser);
const page = await context.newPage();
const previousMessageID = await latestMessageID(request, email);
await gotoWithTransientRetry(page, "/users/log-in");
const form = page.locator("#login_form_magic");
await form.getByLabel("Email").fill(email);
await form.getByRole("button", { name: "Email me a sign-in link" }).click();
await expect(
page.getByText(
"If your email is in our system, you will receive instructions for logging in shortly.",
),
).toBeVisible();
await gotoWithTransientRetry(
page,
await waitForMagicLink(request, email, previousMessageID),
);
await expect(page.locator("#magic-link-fragment-form")).toBeVisible();
await page
.getByRole("button", { name: "Confirm and log in only this time" })
.click();
await expect(page.getByText(email, { exact: true })).toBeVisible();
return { context, page, email };
}
export async function loginWithPassword(
browser: Browser,
email: string,
password: string,
): Promise<AuthenticatedBrowser> {
const context = await newIsolatedContext(browser);
const page = await context.newPage();
for (let attempt = 1; attempt <= 3; attempt += 1) {
let navigationFailure = "";
const recordNavigationFailure = (request: Request): void => {
if (
request.isNavigationRequest() &&
new URL(request.url()).origin === new URL(baseURL).origin &&
isTransientNetworkError(request.failure()?.errorText ?? "")
) {
navigationFailure = request.failure()?.errorText ?? "";
}
};
page.on("requestfailed", recordNavigationFailure);
try {
await gotoWithTransientRetry(page, "/users/log-in");
const form = page.locator("#login_form_password");
await form.getByLabel("Email").fill(email);
await form.getByLabel("Password").fill(password);
await expect(form.getByLabel("Password")).toHaveValue(password);
await form.getByRole("button", { name: "Log in only this time" }).click();
await expect(page).not.toHaveURL(/\/users\/log-in$/, { timeout: 10_000 });
await expect(page.getByText(email, { exact: true })).toBeVisible({
timeout: 10_000,
});
return { context, page, email };
} catch (error) {
if (await page.getByText("Invalid email or password").isVisible()) {
throw error;
}
if (
attempt === 3 ||
(!isTransientNetworkError(navigationFailure) &&
!isTransientNetworkError(
error instanceof Error ? error.message : String(error),
))
) {
throw error;
}
await page.waitForTimeout(500 * attempt);
} finally {
page.off("requestfailed", recordNavigationFailure);
}
}
throw new Error("Password login exhausted its bounded retry loop");
}
export async function selectOptionContaining(
page: Page,
label: string,
expectedText: string,
): Promise<void> {
await waitForLiveViewConnected(page);
const select = page.getByLabel(label);
const options = await select.locator("option").evaluateAll((nodes) =>
nodes.map((node) => ({
text: node.textContent?.trim() ?? "",
value: (node as HTMLOptionElement).value,
})),
);
const option = options.find(({ text }) => text.includes(expectedText));
if (!option) {
throw new Error(`No ${label} option contains ${expectedText}`);
}
await select.selectOption(option.value);
}