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 setRequestLocation( page: Page, options: { label: string; mode: "approximate_public" | "exact_public" | "hidden"; latitude?: string; longitude?: string; radiusMeters?: 500 | 1000 | 2000; }, ): Promise { const workspace = page.locator("#request-location-workspace"); await workspace.getByLabel("Area or public landmark").fill(options.label); await workspace .locator( `input[name="help_request[location_visibility]"][value="${options.mode}"]`, ) .check(); if (options.mode === "hidden") { await expect(workspace.locator("#request-latitude")).toHaveValue(""); await expect(workspace.locator("#request-longitude")).toHaveValue(""); return; } if (options.mode === "approximate_public") { await workspace .locator( `input[name="help_request[location_radius_meters]"][value="${options.radiusMeters ?? 1000}"]`, ) .check(); } const manualCoordinates = workspace.locator("details.request-location-manual"); if (!(await manualCoordinates.getAttribute("open"))) { await manualCoordinates.locator("summary").click(); } await workspace.getByLabel("Latitude").fill(options.latitude ?? "50.4501"); await workspace.getByLabel("Longitude").fill(options.longitude ?? "30.5234"); } 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"; } // LiveView can replace the map container immediately after // MapLibre's load event. A visible canvas is authoritative evidence // that the replacement hook mounted successfully even if the // transient data attribute belonged to the previous node. const canvas = map.locator("canvas.maplibregl-canvas"); if ((await canvas.count()) === 1 && (await canvas.isVisible())) { return "map"; } return null; }, { timeout: 15_000 }, ) .not.toBeNull(); } } export async function waitForMapIdle(page: Page): Promise { const map = page.locator(".aid-map"); if ((await map.count()) > 0) { await expect .poll( async () => { if ((await map.getAttribute("data-map-idle")) === "true") { return "idle"; } 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[] = []; const pendingTransientNavigations = new Map(); page.on("framenavigated", (frame) => { if (frame === page.mainFrame()) { pendingTransientNavigations.delete(frame.url()); } }); page.on("console", (message) => { if (message.type() === "error") { const text = message.text(); const abortedE2eRasterTile = text.includes("AJAXError: Failed to fetch (0):") && text.includes("/__e2e__/map-tile.png"); // Chromium reports an aborted MapLibre worker fetch as both a console // error and a request failure when navigation destroys the old map. // requestfailed below remains authoritative: it ignores only explicit // browser cancellation and still records genuine tile failures. if (!abortedE2eRasterTile) failures.push(`console: ${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 { 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"); 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 Terms and Safety Rules") .check(); await form .getByRole("button", { name: "Create account and email me a link" }) .click(); await expect(page).toHaveURL(/\/users\/check-email\?flow=register$/); 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 { 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 { 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 page.getByText("Use a password instead", { exact: true }).click(); await expect(form).toBeVisible(); const emailInput = form.getByLabel("Email"); await emailInput.click(); await emailInput.pressSequentially(email); await expect(emailInput).toHaveValue(email); const passwordInput = form.getByLabel("Password"); await passwordInput.click(); await passwordInput.pressSequentially(password); await expect(passwordInput).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 { await waitForLiveViewConnected(page); const picker = page .locator(".category-picker") .filter({ has: page.locator(":scope > .label", { hasText: label }), }) .first(); const source = picker.locator("[data-category-picker-source]"); const option = picker .locator("[data-category-picker-option]") .filter({ hasText: expectedText }) .first(); await expect(picker, `No category picker labelled ${label}`).toBeVisible(); await picker.locator("[data-category-picker-trigger]").click(); await expect(option, `No ${label} option contains ${expectedText}`).toBeVisible(); const value = await option.getAttribute("data-value"); if (!value) { throw new Error(`${label} option ${expectedText} has no value`); } await option.click(); await expect(source).toHaveValue(value); }