681 lines
20 KiB
TypeScript
681 lines
20 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 {
|
|
// Application readiness is asserted by the caller (and by
|
|
// waitForLiveViewConnected for LiveViews). Waiting for the browser's
|
|
// full load event couples navigation to disposable third-party map
|
|
// resources and can leave an otherwise rendered page blocked on a
|
|
// stalled response. DOMContentLoaded is the stable document boundary;
|
|
// the subsequent application-specific assertion remains authoritative.
|
|
await page.goto(url, { waitUntil: "domcontentloaded" });
|
|
return;
|
|
} catch (error) {
|
|
lastError = error;
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const transient = isTransientNavigationError(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 function isTransientNavigationError(message: string): boolean {
|
|
return (
|
|
isTransientNetworkError(message) ||
|
|
(message.includes("page.goto") &&
|
|
message.includes("Timeout") &&
|
|
message.includes("exceeded"))
|
|
);
|
|
}
|
|
|
|
const openStreetMapRasterTilePattern = /^\/\d+\/\d+\/\d+\.png$/;
|
|
|
|
export function isTransientOpenStreetMapTileFailure(
|
|
url: string,
|
|
failure: string,
|
|
): boolean {
|
|
try {
|
|
const parsed = new URL(url);
|
|
|
|
return (
|
|
parsed.origin === "https://tile.openstreetmap.org" &&
|
|
openStreetMapRasterTilePattern.test(parsed.pathname) &&
|
|
isTransientNetworkError(failure)
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
type BrowserConsoleFailure = {
|
|
text: string;
|
|
locationURL: string;
|
|
};
|
|
|
|
type TransientExternalTileFailure = {
|
|
url: string;
|
|
failure: string;
|
|
};
|
|
|
|
export function reconcileTransientOpenStreetMapConsoleFailures(
|
|
consoleFailures: BrowserConsoleFailure[],
|
|
transientTileFailures: TransientExternalTileFailure[],
|
|
): BrowserConsoleFailure[] {
|
|
const genericResourceBudgets = new Map<string, number>();
|
|
const failedTileURLs = new Set(transientTileFailures.map(({ url }) => url));
|
|
|
|
for (const { failure } of transientTileFailures) {
|
|
genericResourceBudgets.set(
|
|
failure,
|
|
(genericResourceBudgets.get(failure) ?? 0) + 1,
|
|
);
|
|
}
|
|
|
|
let genericMapLibreFetchBudget = transientTileFailures.length > 0 ? 1 : 0;
|
|
|
|
const consumeGenericResourceFailure = (text: string): boolean => {
|
|
for (const [failure, remaining] of genericResourceBudgets) {
|
|
if (remaining > 0 && text === `Failed to load resource: ${failure}`) {
|
|
genericResourceBudgets.set(failure, remaining - 1);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
return consoleFailures.filter(({ text, locationURL }) => {
|
|
const abortedE2eRasterTile =
|
|
text.includes("AJAXError: Failed to fetch (0):") &&
|
|
text.includes("/__e2e__/map-tile.png");
|
|
|
|
if (abortedE2eRasterTile) return false;
|
|
|
|
const knownTransientTileMessage =
|
|
failedTileURLs.has(locationURL) ||
|
|
[...failedTileURLs].some((url) => text.includes(url));
|
|
|
|
if (knownTransientTileMessage) {
|
|
consumeGenericResourceFailure(text);
|
|
return false;
|
|
}
|
|
|
|
if (consumeGenericResourceFailure(text)) return false;
|
|
|
|
if (genericMapLibreFetchBudget > 0 && text === "TypeError: Failed to fetch") {
|
|
genericMapLibreFetchBudget -= 1;
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
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 setRequestLocation(
|
|
page: Page,
|
|
options: {
|
|
label: string;
|
|
mode: "approximate_public" | "exact_public" | "hidden";
|
|
latitude?: string;
|
|
longitude?: string;
|
|
radiusMeters?: 500 | 1000 | 2000;
|
|
},
|
|
): Promise<void> {
|
|
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<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";
|
|
}
|
|
|
|
// 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<void> {
|
|
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<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 consoleFailures: BrowserConsoleFailure[] = [];
|
|
const transientOpenStreetMapTileFailures: TransientExternalTileFailure[] = [];
|
|
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") {
|
|
consoleFailures.push({
|
|
text: message.text(),
|
|
locationURL: message.location().url,
|
|
});
|
|
}
|
|
});
|
|
|
|
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);
|
|
const transientOpenStreetMapTile = isTransientOpenStreetMapTileFailure(
|
|
request.url(),
|
|
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;
|
|
}
|
|
|
|
// The public application intentionally uses the external OpenStreetMap
|
|
// raster-tile origin. A browser network-change event can fail only those
|
|
// disposable visual tiles while the same-origin workflow remains healthy.
|
|
// Reconcile the duplicate Chromium/MapLibre console messages at assertion
|
|
// time, but keep every non-transient tile failure and every application
|
|
// request failure fatal.
|
|
if (transientOpenStreetMapTile) {
|
|
transientOpenStreetMapTileFailures.push({
|
|
url: request.url(),
|
|
failure,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (
|
|
(rasterTile && cancelled) ||
|
|
replacedDocumentAsset ||
|
|
recoverableOneTimeNavigation
|
|
) {
|
|
return;
|
|
}
|
|
|
|
failures.push(
|
|
`requestfailed: ${request.method()} ${request.url()} ${failure}`,
|
|
);
|
|
});
|
|
|
|
return () => {
|
|
const unresolvedConsoleFailures =
|
|
reconcileTransientOpenStreetMapConsoleFailures(
|
|
consoleFailures,
|
|
transientOpenStreetMapTileFailures,
|
|
).map(({ text }) => `console: ${text}`);
|
|
const unresolved = [
|
|
...failures,
|
|
...unresolvedConsoleFailures,
|
|
...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("&", "&"))
|
|
.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 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<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 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<void> {
|
|
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 clickUntilVisible(
|
|
picker.locator("[data-category-picker-trigger]"),
|
|
option,
|
|
);
|
|
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);
|
|
}
|