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 { return browser.newContext({ serviceWorkers: "block" }); } export async function gotoWithTransientRetry( page: Page, url: string, attempts = 3, ): Promise { 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 { 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 { await expect(page.locator("[data-phx-main].phx-connected")).toHaveCount(1, { timeout: 15_000, }); } export async function gotoLiveView(page: Page, url: string): Promise { await gotoWithTransientRetry(page, url); await waitForLiveViewConnected(page); } export async function waitForMapReady(page: Page): Promise { 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 { 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 } = {}, ): () => void { const failures: string[] = []; 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); // 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. // Actual tile/network failures retain their engine error and remain test // failures. if ( (rasterTile && cancelled) || replacedDocumentAsset || recoverableOneTimeNavigation ) { return; } failures.push( `requestfailed: ${request.method()} ${request.url()} ${failure}`, ); }); return () => expect(failures, failures.join("\n")).toEqual([]); } export async function latestMessageID( request: APIRequestContext, email: string, ): Promise { 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 { 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("&", "&")) .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 { return waitForApplicationEmailLink( request, email, "/users/log-in/", previousMessageID, ); } export async function registerAndConfirm( browser: Browser, request: APIRequestContext, email: string, displayName: string, ): Promise { const context = await newIsolatedContext(browser); const page = await context.newPage(); await gotoWithTransientRetry(page, "/users/register"); await page.getByLabel("Email").fill(email); await page.getByLabel("Display name").fill(displayName); await page .getByLabel("I am 18 or older and accept the safety rules") .check(); await page.getByRole("button", { name: "Create an account" }).click(); await expect(page).toHaveURL(/\/users\/log-in$/); await expect(page.getByText(`An email was sent to ${email}`)).toBeVisible(); await gotoWithTransientRetry(page, await waitForMagicLink(request, email)); await expect(page.getByRole("heading", { name: `Welcome ${email}` })).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 { 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: "Log in with email" }).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.getByRole("heading", { name: `Welcome ${email}` })).toBeVisible(); await page.getByRole("button", { name: "Log me 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 { const context = await newIsolatedContext(browser); const page = await context.newPage(); for (let attempt = 1; attempt <= 3; attempt += 1) { 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); let navigationFailure = ""; const recordNavigationFailure = (request: Request): void => { if ( request.isNavigationRequest() && request.method() === "POST" && new URL(request.url()).pathname === "/users/log-in" ) { navigationFailure = request.failure()?.errorText ?? ""; } }; page.on("requestfailed", recordNavigationFailure); try { await form.getByRole("button", { name: "Log in only this time" }).click(); await expect(page).not.toHaveURL(/\/users\/log-in$/, { 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) ) { 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 { 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); }