import { APIRequestContext, Browser, BrowserContext, expect, Page, } 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 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 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 function captureBrowserFailures(page: Page): () => 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 ?? ""; if ( request.url().includes("/__e2e__/map-tile.png") && (failure.includes("Load request cancelled") || failure.includes("ERR_ABORTED") || failure.includes("NS_BINDING_ABORTED")) ) { return; } failures.push( `requestfailed: ${request.method()} ${request.url()} ${failure}`, ); }); return () => expect(failures, failures.join("\n")).toEqual([]); } 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; } async function waitForMagicLink( request: APIRequestContext, email: 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 match = content.match(/https?:\/\/[^\s"'<>]+\/users\/log-in\/[^\s"'<>]+/); if (!match) { throw new Error(`No magic login link found for ${email}`); } const link = new URL(match[0].replaceAll("&", "&")); const applicationOrigin = new URL(baseURL); link.protocol = applicationOrigin.protocol; link.host = applicationOrigin.host; return link.toString(); } export async function registerAndConfirm( browser: Browser, request: APIRequestContext, email: string, displayName: string, ): Promise { const context = await newIsolatedContext(browser); const page = await context.newPage(); await page.goto("/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 page.goto(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 page.goto("/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 page.goto(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 selectOptionContaining( page: Page, label: string, expectedText: string, ): Promise { 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); }