From 710ad5e901ab1fc6e30b756a4888c08687b1a8c1 Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Mon, 20 Jul 2026 00:22:04 +0300 Subject: [PATCH] test: verify full public staging flows --- docs/verification.md | 32 ++ e2e/tests/active-failover.spec.ts | 12 +- e2e/tests/activity-moderation.spec.ts | 64 ++-- e2e/tests/auth-settings.spec.ts | 62 ++++ e2e/tests/helpers.ts | 179 +++++++++- e2e/tests/localization.spec.ts | 9 +- e2e/tests/mutual-aid.spec.ts | 59 +++- e2e/tests/zz-resilience.spec.ts | 4 +- lib/mix/tasks/wnh.staging_e2e.ex | 2 +- lib/mix/tasks/wnh.staging_full_e2e.ex | 479 ++++++++++++++++++++++++++ scripts/staging-full-e2e-run.sh | 227 ++++++++++++ 11 files changed, 1068 insertions(+), 61 deletions(-) create mode 100644 e2e/tests/auth-settings.spec.ts create mode 100644 lib/mix/tasks/wnh.staging_full_e2e.ex create mode 100755 scripts/staging-full-e2e-run.sh diff --git a/docs/verification.md b/docs/verification.md index 846a68a..8fdb782 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -654,6 +654,38 @@ scenario. Evidence is `output/e2e/20260719202632-3770163`; its containers, networks, and database volume were removed by the run-scoped cleanup. +The broader public-staging replay was then expanded without reusing or deleting +unrelated application records: + +- One run against `https://whoneedhelp.imalto.site` passed 3/3 Chromium + scenarios covering registration, email confirmation, password setup, email + change and re-login; medicine request, helper acceptance, private realtime + chat, browser geolocation sharing and deletion, handover confirmation and + double-blind reviews; motorcycle broken-chain roadside help with helper + withdrawal and requester cancellation; Activity creation, join approval, + approved group chat, reporting, evidence, blocking/unblocking, privacy + defaults, social-link add/remove, category proposal and moderator actions. +- The fixture tool first verified the exact database name and an unused + run-specific email prefix. Cleanup validated ownership of every related + request, assignment, activity, participant, message, report, proposal, + review, tracking, audit and push-job record before deleting it. It removed + only the six observed run users and their validated relationships. Exact + Mailpit message IDs for the run were deleted separately and their absence was + rechecked. The before/after counts across 19 application tables produced an + empty diff. Evidence is + `output/staging-full-e2e/20260719210733-461538`. +- The current isolated browser matrix passed bootstrap 1/1 and application + scenarios 30/30: 10 each in Chromium, Firefox and WebKit. It includes active + BEAM-node failure while chat and browser tracking are in use, followed by + reconnect on an available replica and continued chat/tracking operation. + Evidence is `output/e2e/20260719211815-708067`; the isolated containers, + networks and PostgreSQL volume were removed automatically. +- `./scripts/quality.sh` passed on the same source state: formatting, strict + compilation, xref, Credo, Sobelow, Dialyzer, 173 ExUnit tests, dependency + audits, Compose/Helm/observability checks and the configured image scans. + Evidence is + `output/regression/full-public-web-quality-20260719.log`. + ## Known work before a public production launch - Replace the temporary staging origin with the production-owned domain and diff --git a/e2e/tests/active-failover.spec.ts b/e2e/tests/active-failover.spec.ts index 9f137a3..31c4868 100644 --- a/e2e/tests/active-failover.spec.ts +++ b/e2e/tests/active-failover.spec.ts @@ -1,5 +1,7 @@ import { expect, test } from "@playwright/test"; import { + clickUntilVisible, + gotoLiveView, markerKinds, projectEmail, projectText, @@ -43,7 +45,7 @@ test("active chat and browser tracking recover after the serving BEAM node resta ); const requestTitle = projectText("Failover medicine pickup", testInfo.project.name); - await requester.page.goto("/requests/new"); + await gotoLiveView(requester.page, "/requests/new"); await selectOptionContaining(requester.page, "Category", "Medicine pickup"); await requester.page.getByLabel("Medicine pickup status").selectOption("reserved"); await requester.page.getByLabel("Short title").fill(requestTitle); @@ -70,9 +72,11 @@ test("active chat and browser tracking recover after the serving BEAM node resta longitude: 30.5237, accuracy: 5, }); - await helper.page.goto(requestURL); - await helper.page.getByRole("button", { name: "I can help" }).click(); - await expect(helper.page.getByRole("heading", { name: "Private match chat" })).toBeVisible(); + await gotoLiveView(helper.page, requestURL); + await clickUntilVisible( + helper.page.getByRole("button", { name: "I can help" }), + helper.page.getByRole("heading", { name: "Private match chat" }), + ); await expect(requester.page.getByRole("heading", { name: "Private match chat" })).toBeVisible(); const browserPosition = await helper.page.evaluate( diff --git a/e2e/tests/activity-moderation.spec.ts b/e2e/tests/activity-moderation.spec.ts index daa4ce8..846ede1 100644 --- a/e2e/tests/activity-moderation.spec.ts +++ b/e2e/tests/activity-moderation.spec.ts @@ -1,7 +1,10 @@ import { expect, test } from "@playwright/test"; import { captureBrowserFailures, + clickUntilVisible, + gotoLiveView, loginWithMagicLink, + loginWithPassword, projectEmail, projectText, registerAndConfirm, @@ -26,26 +29,33 @@ test("activity approval, privacy controls, reporting, and moderation work end to projectName, ); const proposalTitle = projectText("E2E quiet board games", projectName); - const organizer = await registerAndConfirm( - browser, - request, - organizerEmail, - "E2E Activity Organizer", - ); - const participant = await registerAndConfirm( - browser, - request, - participantEmail, - "E2E Activity Participant", - ); + const fixturePassword = process.env.E2E_FIXTURE_PASSWORD; + const organizer = + process.env.E2E_RUN_ID && fixturePassword + ? await loginWithPassword(browser, organizerEmail, fixturePassword) + : await registerAndConfirm( + browser, + request, + organizerEmail, + "E2E Activity Organizer", + ); + const participant = + process.env.E2E_RUN_ID && fixturePassword + ? await loginWithPassword(browser, participantEmail, fixturePassword) + : await registerAndConfirm( + browser, + request, + participantEmail, + "E2E Activity Participant", + ); const assertOrganizerClean = captureBrowserFailures(organizer.page); const assertParticipantClean = captureBrowserFailures(participant.page); - await participant.page.goto("/moderation"); + await gotoLiveView(participant.page, "/moderation"); await expect(participant.page).toHaveURL(/\/requests$/); await expect(participant.page.getByText("Moderator access is required.")).toBeVisible(); - await organizer.page.goto("/activities/new"); + await gotoLiveView(organizer.page, "/activities/new"); await selectOptionContaining(organizer.page, "Activity type", "Coffee or tea"); await organizer.page.getByLabel("Preferred setting").selectOption("cafe"); await organizer.page.getByLabel("Short title").fill(activityTitle); @@ -71,12 +81,14 @@ test("activity approval, privacy controls, reporting, and moderation work end to await expect(organizer.page).toHaveURL(/\/activities\/[0-9a-f-]+$/); const activityURL = organizer.page.url(); - await participant.page.goto(activityURL); + await gotoLiveView(participant.page, activityURL); await expect(participant.page.getByRole("heading", { name: "Approved group chat" })).toHaveCount( 0, ); - await participant.page.getByRole("button", { name: "Request to join" }).click(); - await expect(participant.page.getByText("Approval pending")).toBeVisible(); + await clickUntilVisible( + participant.page.getByRole("button", { name: "Request to join" }), + participant.page.getByText("Approval pending"), + ); await expect(organizer.page.getByText("E2E Activity Participant")).toBeVisible(); const organizerControls = organizer.page @@ -117,7 +129,7 @@ test("activity approval, privacy controls, reporting, and moderation work end to await expect(participant.page.getByText("Report sent to moderators.")).toBeVisible(); await waitForMapReady(participant.page); - await participant.page.goto("/categories/proposals"); + await gotoLiveView(participant.page, "/categories/proposals"); await participant.page.getByLabel("Proposed category").fill(proposalTitle); await participant.page.getByLabel("Mode").selectOption("activity"); await participant.page @@ -128,12 +140,12 @@ test("activity approval, privacy controls, reporting, and moderation work end to participant.page.getByText("Proposal published for community voting."), ).toBeVisible(); - await participant.page.goto(activityURL); + await gotoLiveView(participant.page, activityURL); await waitForMapReady(participant.page); await participant.page.getByRole("button", { name: "Block organizer" }).click(); await expect(participant.page).toHaveURL(/\/activities$/); await waitForMapReady(participant.page); - await participant.page.goto("/profile"); + await gotoLiveView(participant.page, "/profile"); const blockedUsers = participant.page.getByRole("heading", { name: "Blocked users" }).locator(".."); await expect(blockedUsers.getByText("E2E Activity Organizer")).toBeVisible(); await blockedUsers.getByRole("button", { name: "Unblock" }).click(); @@ -160,14 +172,18 @@ test("activity approval, privacy controls, reporting, and moderation work end to await socialSection.getByRole("button", { name: "Remove" }).click(); await expect(socialSection.getByText("No social links added.")).toBeVisible(); - await participant.page.goto("/requests/new"); + await gotoLiveView(participant.page, "/requests/new"); await expect(participant.page.getByLabel("Location visibility")).toHaveValue( "exact_for_active_match", ); - const admin = await loginWithMagicLink(browser, request, "e2e-admin@example.invalid"); + const adminEmail = process.env.E2E_ADMIN_EMAIL ?? "e2e-admin@example.invalid"; + const admin = + process.env.E2E_RUN_ID && fixturePassword + ? await loginWithPassword(browser, adminEmail, fixturePassword) + : await loginWithMagicLink(browser, request, adminEmail); const assertAdminClean = captureBrowserFailures(admin.page); - await admin.page.goto("/moderation"); + await gotoLiveView(admin.page, "/moderation"); await expect(admin.page.getByRole("heading", { name: "Moderation" })).toBeVisible(); const reportCard = admin.page @@ -199,7 +215,7 @@ test("activity approval, privacy controls, reporting, and moderation work end to await participantRow.getByRole("button", { name: "Save" }).click(); await expect(admin.page.getByText("User status updated.")).toBeVisible(); - await participant.page.goto("/categories/proposals"); + await gotoLiveView(participant.page, "/categories/proposals"); await participant.page.getByLabel("Proposed category").fill("E2E restricted proposal"); await participant.page.getByLabel("Mode").selectOption("help"); await participant.page diff --git a/e2e/tests/auth-settings.spec.ts b/e2e/tests/auth-settings.spec.ts new file mode 100644 index 0000000..7231814 --- /dev/null +++ b/e2e/tests/auth-settings.spec.ts @@ -0,0 +1,62 @@ +import { expect, test } from "@playwright/test"; +import { + captureBrowserFailures, + clickUntilVisible, + gotoWithTransientRetry, + loginWithPassword, + projectEmail, + registerAndConfirm, + waitForApplicationEmailLink, +} from "./helpers"; + +test("registration, email confirmation, password, and email change work end to end", async ({ + browser, + request, +}, testInfo) => { + const originalEmail = projectEmail("auth-user", testInfo.project.name); + const changedEmail = projectEmail("auth-user-changed", testInfo.project.name); + const password = "E2E public password 2026!"; + const user = await registerAndConfirm( + browser, + request, + originalEmail, + "E2E Auth User", + ); + const assertBrowserClean = captureBrowserFailures(user.page); + + await user.page.goto("/users/settings"); + const passwordForm = user.page.locator("#update_password"); + await passwordForm.getByLabel("New password", { exact: true }).fill(password); + await passwordForm.getByLabel("Confirm new password", { exact: true }).fill(password); + await clickUntilVisible( + passwordForm.getByRole("button", { name: "Save Password" }), + user.page.getByText("Password updated successfully."), + ); + + const emailForm = user.page.locator("#update_email"); + await emailForm.getByLabel("Email").fill(changedEmail); + await clickUntilVisible( + emailForm.getByRole("button", { name: "Change Email" }), + user.page.getByText("A link to confirm your email change has been sent"), + ); + + await gotoWithTransientRetry( + user.page, + await waitForApplicationEmailLink( + request, + changedEmail, + "/users/settings/confirm-email/", + ), + ); + await expect(user.page.getByText("Email changed successfully.")).toBeVisible(); + + await user.context.close(); + + const passwordLogin = await loginWithPassword(browser, changedEmail, password); + const assertPasswordLoginClean = captureBrowserFailures(passwordLogin.page); + await expect(passwordLogin.page.getByText(changedEmail, { exact: true })).toBeVisible(); + + assertBrowserClean(); + assertPasswordLoginClean(); + await passwordLogin.context.close(); +}); diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts index 98674b2..85004bd 100644 --- a/e2e/tests/helpers.ts +++ b/e2e/tests/helpers.ts @@ -3,7 +3,9 @@ import { Browser, BrowserContext, expect, + Locator, Page, + Request, } from "@playwright/test"; const mailpitURL = process.env.MAILPIT_URL; @@ -38,12 +40,84 @@ export async function newIsolatedContext(browser: Browser): 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; +} + +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"); @@ -95,11 +169,17 @@ export function captureBrowserFailures(page: Page): () => void { 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()); // MapLibre cancels in-flight raster tiles when a LiveView navigation - // destroys or re-centres a map. Actual tile/network failures retain their - // engine error and remain test failures. - if (rasterTile && cancelled) { + // 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) { return; } @@ -111,7 +191,7 @@ export function captureBrowserFailures(page: Page): () => void { return () => expect(failures, failures.join("\n")).toEqual([]); } -async function latestMessageID( +export async function latestMessageID( request: APIRequestContext, email: string, ): Promise { @@ -127,9 +207,10 @@ async function latestMessageID( return body.messages?.[0]?.ID; } -async function waitForMagicLink( +export async function waitForApplicationEmailLink( request: APIRequestContext, email: string, + pathPrefix: string, previousMessageID?: string, ): Promise { let messageID: string | undefined; @@ -155,13 +236,22 @@ async function waitForMagicLink( HTML?: string; }; const content = `${message.Text ?? ""}\n${message.HTML ?? ""}`; - const match = content.match(/https?:\/\/[^\s"'<>]+\/users\/log-in\/[^\s"'<>]+/); + 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 magic login link found for ${email}`); + throw new Error(`No application link starting with ${pathPrefix} found for ${email}`); } - const link = new URL(match[0].replaceAll("&", "&")); + const link = new URL(match); const applicationOrigin = new URL(baseURL); link.protocol = applicationOrigin.protocol; link.host = applicationOrigin.host; @@ -169,6 +259,19 @@ async function waitForMagicLink( 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, @@ -178,7 +281,7 @@ export async function registerAndConfirm( const context = await newIsolatedContext(browser); const page = await context.newPage(); - await page.goto("/users/register"); + await gotoWithTransientRetry(page, "/users/register"); await page.getByLabel("Email").fill(email); await page.getByLabel("Display name").fill(displayName); await page @@ -188,7 +291,7 @@ export async function registerAndConfirm( 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 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" }) @@ -207,7 +310,7 @@ export async function loginWithMagicLink( const page = await context.newPage(); const previousMessageID = await latestMessageID(request, email); - await page.goto("/users/log-in"); + 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(); @@ -217,7 +320,10 @@ export async function loginWithMagicLink( ), ).toBeVisible(); - await page.goto(await waitForMagicLink(request, email, previousMessageID)); + 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(); @@ -233,14 +339,49 @@ export async function loginWithPassword( const context = await newIsolatedContext(browser); const page = await context.newPage(); - await page.goto("/users/log-in"); - const form = page.locator("#login_form_password"); - await form.getByLabel("Email").fill(email); - await form.getByLabel("Password").fill(password); - await form.getByRole("button", { name: "Log in only this time" }).click(); - await expect(page.getByText(email, { exact: true })).toBeVisible(); + 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); - return { context, page, email }; + 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( diff --git a/e2e/tests/localization.spec.ts b/e2e/tests/localization.spec.ts index 7a798bb..340568e 100644 --- a/e2e/tests/localization.spec.ts +++ b/e2e/tests/localization.spec.ts @@ -1,5 +1,10 @@ import { expect, test } from "@playwright/test"; -import { newIsolatedContext, projectEmail, registerAndConfirm } from "./helpers"; +import { + gotoLiveView, + newIsolatedContext, + projectEmail, + registerAndConfirm, +} from "./helpers"; test("public language selection persists across pages", async ({ browser }) => { const context = await newIsolatedContext(browser); @@ -31,7 +36,7 @@ test("authenticated LiveView uses the selected browser language", async ({ ); await user.page.goto("/?locale=uk"); - await user.page.goto("/requests"); + await gotoLiveView(user.page, "/requests"); await expect(user.page.locator("html")).toHaveAttribute("lang", "uk"); await expect(user.page.getByRole("heading", { name: "Хто потребує допомоги?" })).toBeVisible(); diff --git a/e2e/tests/mutual-aid.spec.ts b/e2e/tests/mutual-aid.spec.ts index 1db13fe..ebb8f85 100644 --- a/e2e/tests/mutual-aid.spec.ts +++ b/e2e/tests/mutual-aid.spec.ts @@ -1,13 +1,14 @@ import { expect, test } from "@playwright/test"; import { captureBrowserFailures, + clickUntilVisible, + gotoLiveView, loginWithPassword, markerKinds, projectEmail, projectText, registerAndConfirm, selectOptionContaining, - waitForLiveViewConnected, waitForMapReady, } from "./helpers"; @@ -34,7 +35,7 @@ test("two users complete medicine tracking, handover, realtime chat, and blind r const assertRequesterClean = captureBrowserFailures(requester.page); const assertHelperClean = captureBrowserFailures(helper.page); - await requester.page.goto("/requests/new"); + await gotoLiveView(requester.page, "/requests/new"); await selectOptionContaining(requester.page, "Category", "Medicine pickup"); await requester.page.getByLabel("Medicine pickup status").selectOption("reserved"); await requester.page.getByLabel("Short title").fill(requestTitle); @@ -56,8 +57,7 @@ test("two users complete medicine tracking, handover, realtime chat, and blind r const requestURL = requester.page.url(); await expect(requester.page.getByRole("heading", { name: requestTitle })).toBeVisible(); - await helper.page.goto(requestURL); - await waitForLiveViewConnected(helper.page); + await gotoLiveView(helper.page, requestURL); await helper.page.getByRole("button", { name: "I can help" }).click(); await expect(helper.page.getByRole("heading", { name: "Private match chat" })).toBeVisible(); await expect(requester.page.getByRole("heading", { name: "Private match chat" })).toBeVisible(); @@ -112,11 +112,10 @@ test("two users complete medicine tracking, handover, realtime chat, and blind r ).toBeVisible(); await waitForMapReady(requester.page); - await requester.page.goto("/profile"); + await gotoLiveView(requester.page, "/profile"); await expect(requester.page.getByText("None revealed yet.")).toBeVisible(); await expect(requester.page.getByText("Clear and safe coordination.")).toHaveCount(0); - await requester.page.goto(requestURL); - await waitForLiveViewConnected(requester.page); + await gotoLiveView(requester.page, requestURL); await requester.page.getByLabel("Rating").selectOption("5"); await requester.page.getByLabel("Comment (optional)").fill("Reliable volunteer."); @@ -126,12 +125,54 @@ test("two users complete medicine tracking, handover, realtime chat, and blind r ).toBeVisible(); await waitForMapReady(requester.page); - await requester.page.goto("/profile"); + await gotoLiveView(requester.page, "/profile"); await expect(requester.page.getByText("Clear and safe coordination.")).toBeVisible(); await waitForMapReady(helper.page); - await helper.page.goto("/profile"); + await gotoLiveView(helper.page, "/profile"); await expect(helper.page.getByText("Reliable volunteer.")).toBeVisible(); + const roadsideTitle = projectText( + runID ? `Staging E2E motorcycle chain ${runID}` : "E2E motorcycle chain", + testInfo.project.name, + ); + await gotoLiveView(requester.page, "/requests/new"); + await selectOptionContaining( + requester.page, + "Category", + "Motorcycle roadside help", + ); + await requester.page.getByLabel("Motorcycle problem").selectOption("broken_chain"); + await requester.page + .getByLabel("Motorcycle is away from active traffic") + .selectOption("true"); + await requester.page.getByLabel("Short title").fill(roadsideTitle); + await requester.page + .getByLabel("What help do you need?") + .fill("The motorcycle is secured and needs help with a broken chain."); + await requester.page.getByLabel("Urgency").selectOption("today"); + await requester.page + .getByLabel("Request expires (UTC)") + .fill(new Date(Date.now() + 4 * 60 * 60 * 1000).toISOString().slice(0, 16)); + await requester.page.getByLabel("Approximate area").fill("E2E secured roadside area"); + await requester.page.getByLabel("Location visibility").selectOption("hidden"); + await requester.page.getByLabel("Latitude").fill("50.4501"); + await requester.page.getByLabel("Longitude").fill("30.5234"); + await requester.page.locator("#request-form input[type=checkbox]").last().check(); + await requester.page.getByRole("button", { name: "Publish request" }).click(); + await expect(requester.page.getByRole("heading", { name: roadsideTitle })).toBeVisible(); + + const roadsideURL = requester.page.url(); + await gotoLiveView(helper.page, roadsideURL); + await clickUntilVisible( + helper.page.getByRole("button", { name: "I can help" }), + helper.page.getByRole("button", { name: "Withdraw from this request" }), + ); + await clickUntilVisible( + helper.page.getByRole("button", { name: "Withdraw from this request" }), + helper.page.getByText("You withdrew from this request."), + ); + await expect(requester.page.getByText("Cancelled", { exact: true })).toBeVisible(); + assertRequesterClean(); assertHelperClean(); await requester.context.close(); diff --git a/e2e/tests/zz-resilience.spec.ts b/e2e/tests/zz-resilience.spec.ts index a6a9671..7773cc5 100644 --- a/e2e/tests/zz-resilience.spec.ts +++ b/e2e/tests/zz-resilience.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from "@playwright/test"; -import { projectEmail, registerAndConfirm } from "./helpers"; +import { gotoLiveView, projectEmail, registerAndConfirm } from "./helpers"; test("a disconnected LiveView announces recovery and clears it after reconnect", async ({ browser, @@ -12,7 +12,7 @@ test("a disconnected LiveView announces recovery and clears it after reconnect", "E2E Resilience", ); - await user.page.goto("/requests"); + await gotoLiveView(user.page, "/requests"); await expect .poll(() => user.page.evaluate(() => { diff --git a/lib/mix/tasks/wnh.staging_e2e.ex b/lib/mix/tasks/wnh.staging_e2e.ex index ebc7f86..8773e2f 100644 --- a/lib/mix/tasks/wnh.staging_e2e.ex +++ b/lib/mix/tasks/wnh.staging_e2e.ex @@ -286,7 +286,7 @@ defmodule Mix.Tasks.Wnh.StagingE2e do unexpected_proposal? = Repo.exists?(from(proposal in CategoryProposal, where: proposal.proposer_id in ^user_ids)) - unless length(request_ids) <= 1 and length(assignment_ids) <= 1 and + unless length(request_ids) <= 2 and length(assignment_ids) <= 2 and not unexpected_request? and not unexpected_assignment? and not unexpected_activity? and not unexpected_proposal? do Mix.raise("staging E2E users own records outside the exact medicine-flow scope") diff --git a/lib/mix/tasks/wnh.staging_full_e2e.ex b/lib/mix/tasks/wnh.staging_full_e2e.ex new file mode 100644 index 0000000..6f19f4d --- /dev/null +++ b/lib/mix/tasks/wnh.staging_full_e2e.ex @@ -0,0 +1,479 @@ +defmodule Mix.Tasks.Wnh.StagingFullE2e do + use Mix.Task + + import Ecto.Query + + alias Oban.Job + alias WhoNeedHelp.Accounts.{SocialIdentity, User} + alias WhoNeedHelp.Activities.{Activity, Participant} + alias WhoNeedHelp.Activities.Message, as: ActivityMessage + alias WhoNeedHelp.Catalog.{CategoryProposal, CategoryVote} + alias WhoNeedHelp.Help.{Assignment, HelpRequest} + alias WhoNeedHelp.Messaging.Message + alias WhoNeedHelp.Repo + alias WhoNeedHelp.Tracking.{Position, TrackingSession} + + alias WhoNeedHelp.Trust.{ + AbuseSignal, + AuditEvent, + Block, + Report, + Review + } + + @shortdoc "Prepares or removes the exact full public-staging browser fixture" + @confirmation "public-staging-full-e2e" + @precreated_roles ~w(requester helper activity-organizer activity-participant admin) + @registered_roles ~w(auth-user auth-user-changed) + + @impl Mix.Task + def run([action]) when action in ["prepare", "cleanup"] do + Mix.Task.run("app.start") + context = verified_context() + + case action do + "prepare" -> prepare(context) + "cleanup" -> cleanup(context) + end + end + + def run(_args) do + Mix.raise("usage: mix wnh.staging_full_e2e prepare|cleanup") + end + + defp verified_context do + run_id = required_env!("WNH_STAGING_E2E_RUN_ID") + expected_database = required_env!("WNH_STAGING_E2E_EXPECTED_DATABASE") + manifest_path = required_env!("WNH_STAGING_E2E_MANIFEST_PATH") |> Path.expand() + + unless Regex.match?(~r/^[a-z0-9-]+$/, run_id) do + Mix.raise("WNH_STAGING_E2E_RUN_ID may contain only lowercase letters, numbers, and dash") + end + + unless System.get_env("WNH_STAGING_E2E_CONFIRM") == @confirmation do + Mix.raise("WNH_STAGING_E2E_CONFIRM must equal #{@confirmation}") + end + + unless String.starts_with?(manifest_path, "/output/") do + Mix.raise("WNH_STAGING_E2E_MANIFEST_PATH must resolve below /output") + end + + %Postgrex.Result{rows: [[actual_database]]} = + Repo.query!("SELECT current_database()", [], log: false) + + unless actual_database == expected_database do + Mix.raise( + "refusing staging E2E mutation: expected database #{inspect(expected_database)}, " <> + "observed #{inspect(actual_database)}" + ) + end + + %{ + run_id: run_id, + database: actual_database, + manifest_path: manifest_path, + emails: emails(run_id) + } + end + + defp prepare(context) do + password = required_env!("WNH_STAGING_E2E_PASSWORD") + + unless byte_size(password) in 12..72 do + Mix.raise("WNH_STAGING_E2E_PASSWORD must contain between 12 and 72 bytes") + end + + assert_prefix_unused!(context) + + password_hash = Bcrypt.hash_pwd_salt(password) + now = DateTime.utc_now(:second) + + {:ok, users} = + Repo.transaction(fn -> + Map.new(@precreated_roles, fn role -> + user_role = if role == "admin", do: :admin, else: :user + + user = + insert_user!( + context.emails[role], + display_name(role), + password_hash, + user_role, + now + ) + + {role, %{"id" => user.id, "email" => user.email}} + end) + end) + + manifest = %{ + "schema_version" => 1, + "run_id" => context.run_id, + "database" => context.database, + "precreated_users" => users, + "allowed_emails" => context.emails |> Map.values() |> Enum.sort() + } + + context.manifest_path |> Path.dirname() |> File.mkdir_p!() + File.write!(context.manifest_path, Jason.encode_to_iodata!(manifest, pretty: true)) + File.chmod!(context.manifest_path, 0o600) + + Mix.shell().info("prepared exact full staging E2E users for #{context.run_id}") + end + + defp cleanup(context) do + manifest = context.manifest_path |> File.read!() |> Jason.decode!() + validate_manifest!(context, manifest) + assert_only_allowed_prefix_users!(context) + + users = + User + |> where([user], user.email in ^Map.values(context.emails)) + |> Repo.all() + + validate_precreated_users!(manifest, users) + + user_ids = Enum.map(users, & &1.id) + request_ids = ids(HelpRequest, :requester_id, user_ids) + + assignment_ids = + Assignment + |> where( + [assignment], + assignment.request_id in ^request_ids or assignment.helper_id in ^user_ids + ) + |> select([assignment], assignment.id) + |> Repo.all() + + validate_assignments!(assignment_ids, request_ids, user_ids) + + message_ids = ids(Message, :assignment_id, assignment_ids) + tracking_session_ids = ids(TrackingSession, :assignment_id, assignment_ids) + + activity_ids = ids(Activity, :creator_id, user_ids) + validate_activity_membership!(activity_ids, user_ids) + activity_participant_ids = ids(Participant, :activity_id, activity_ids) + activity_message_ids = ids(ActivityMessage, :activity_id, activity_ids) + + proposal_ids = ids(CategoryProposal, :proposer_id, user_ids) + validate_category_votes!(proposal_ids, user_ids) + + report_ids = + Report + |> where( + [report], + report.reporter_id in ^user_ids or report.request_id in ^request_ids or + report.assignment_id in ^assignment_ids or report.message_id in ^message_ids or + report.activity_id in ^activity_ids or + report.activity_message_id in ^activity_message_ids + ) + |> select([report], report.id) + |> Repo.all() + + validate_reviewed_records!(user_ids, report_ids, proposal_ids) + + abuse_signal_ids = + AbuseSignal + |> where( + [signal], + signal.subject_id in ^user_ids or signal.assignment_id in ^assignment_ids + ) + |> select([signal], signal.id) + |> Repo.all() + + validate_audits(%{ + users: user_ids, + requests: request_ids, + assignments: assignment_ids, + activities: activity_ids, + activity_participants: activity_participant_ids, + proposals: proposal_ids, + reports: report_ids, + signals: abuse_signal_ids + }) + + job_ids = fixture_push_job_ids(user_ids) + + {:ok, deleted} = + Repo.transaction(fn -> + %{ + push_jobs: delete_ids(Job, job_ids), + audit_events: + AuditEvent + |> where([event], event.actor_id in ^user_ids) + |> delete_count(), + reports: delete_ids(Report, report_ids), + activity_messages: delete_ids(ActivityMessage, activity_message_ids), + activity_participants: delete_ids(Participant, activity_participant_ids), + activities: delete_ids(Activity, activity_ids), + tracking_positions: + Position + |> where([position], position.tracking_session_id in ^tracking_session_ids) + |> delete_count(), + tracking_sessions: delete_ids(TrackingSession, tracking_session_ids), + messages: delete_ids(Message, message_ids), + abuse_signals: delete_ids(AbuseSignal, abuse_signal_ids), + reviews: + Review + |> where( + [review], + review.assignment_id in ^assignment_ids or review.reviewer_id in ^user_ids or + review.reviewee_id in ^user_ids + ) + |> delete_count(), + assignments: delete_ids(Assignment, assignment_ids), + requests: delete_ids(HelpRequest, request_ids), + category_votes: + CategoryVote + |> where( + [vote], + vote.proposal_id in ^proposal_ids or vote.user_id in ^user_ids + ) + |> delete_count(), + category_proposals: delete_ids(CategoryProposal, proposal_ids), + blocks: + Block + |> where([block], block.blocker_id in ^user_ids or block.blocked_id in ^user_ids) + |> delete_count(), + social_identities: + SocialIdentity + |> where([identity], identity.user_id in ^user_ids) + |> delete_count(), + users: + User + |> where([user], user.id in ^user_ids) + |> delete_count() + } + end) + + unless deleted.users == length(users) and deleted.users >= length(@precreated_roles) do + Mix.raise( + "cleanup removed #{deleted.users} users, expected exactly the #{length(users)} observed fixtures" + ) + end + + assert_prefix_unused!(context) + Mix.shell().info("removed exact full staging E2E fixture: #{inspect(deleted)}") + end + + defp validate_manifest!(context, manifest) do + expected_emails = context.emails |> Map.values() |> Enum.sort() + precreated = manifest["precreated_users"] + + valid_precreated? = + is_map(precreated) and + Enum.all?(@precreated_roles, fn role -> + case precreated[role] do + %{"id" => id, "email" => email} -> + uuid?(id) and email == context.emails[role] + + _other -> + false + end + end) + + unless manifest["schema_version"] == 1 and manifest["run_id"] == context.run_id and + manifest["database"] == context.database and + manifest["allowed_emails"] == expected_emails and valid_precreated? do + Mix.raise("full staging E2E manifest does not match the requested run and database") + end + end + + defp validate_precreated_users!(manifest, users) do + observed = Map.new(users, &{&1.email, &1.id}) + + unless Enum.all?(@precreated_roles, fn role -> + expected = manifest["precreated_users"][role] + observed[expected["email"]] == expected["id"] + end) do + Mix.raise("full staging E2E precreated user ownership does not match the manifest") + end + end + + defp validate_assignments!(assignment_ids, request_ids, user_ids) do + unexpected? = + Repo.exists?( + from assignment in Assignment, + where: + assignment.id in ^assignment_ids and + (assignment.request_id not in ^request_ids or assignment.helper_id not in ^user_ids) + ) + + if unexpected?, + do: Mix.raise("a full staging fixture assignment crosses into non-fixture data") + end + + defp validate_activity_membership!(activity_ids, user_ids) do + unexpected_participant? = + Repo.exists?( + from participant in Participant, + where: participant.user_id in ^user_ids and participant.activity_id not in ^activity_ids + ) + + unexpected_message? = + Repo.exists?( + from message in ActivityMessage, + where: message.sender_id in ^user_ids and message.activity_id not in ^activity_ids + ) + + if unexpected_participant? or unexpected_message? do + Mix.raise("a full staging fixture user is linked to a non-fixture activity") + end + end + + defp validate_category_votes!(proposal_ids, user_ids) do + unexpected? = + Repo.exists?( + from vote in CategoryVote, + where: vote.user_id in ^user_ids and vote.proposal_id not in ^proposal_ids + ) + + if unexpected?, do: Mix.raise("a full staging fixture vote targets a non-fixture proposal") + end + + defp validate_reviewed_records!(user_ids, report_ids, proposal_ids) do + unexpected_report? = + Repo.exists?( + from report in Report, + where: report.reviewed_by_id in ^user_ids and report.id not in ^report_ids + ) + + unexpected_proposal? = + Repo.exists?( + from proposal in CategoryProposal, + where: proposal.reviewed_by_id in ^user_ids and proposal.id not in ^proposal_ids + ) + + unexpected_signal? = + Repo.exists?( + from signal in AbuseSignal, + where: signal.reviewed_by_id in ^user_ids and signal.subject_id not in ^user_ids + ) + + if unexpected_report? or unexpected_proposal? or unexpected_signal? do + Mix.raise("the run-scoped moderator changed a non-fixture record") + end + end + + defp validate_audits(targets) do + allowed = + [ + {"user", targets.users}, + {"request", targets.requests}, + {"assignment", targets.assignments}, + {"activity", targets.activities}, + {"activity_participant", targets.activity_participants}, + {"category_proposal", targets.proposals}, + {"report", targets.reports}, + {"abuse_signal", targets.signals} + ] + |> Enum.flat_map(fn {type, ids} -> Enum.map(ids, &{type, &1}) end) + |> MapSet.new() + + unexpected = + AuditEvent + |> where([event], event.actor_id in ^targets.users) + |> select([event], {event.target_type, event.target_id, event.action}) + |> Repo.all() + |> Enum.reject(fn {type, id, _action} -> MapSet.member?(allowed, {type, id}) end) + + if unexpected != [] do + Mix.raise( + "run-scoped users produced audit events outside fixture data: #{inspect(unexpected)}" + ) + end + end + + defp fixture_push_job_ids(user_ids) do + recipients = Enum.map(user_ids, &"user:#{&1}") + + Job + |> where( + [job], + job.worker == "WhoNeedHelp.Push.DeliveryWorker" and + fragment("?->>'recipient' = ANY(?)", job.args, ^recipients) + ) + |> select([job], job.id) + |> Repo.all() + end + + defp assert_prefix_unused!(context) do + if Repo.exists?(from user in User, where: like(user.email, ^"#{prefix(context.run_id)}%")) do + Mix.raise("staging E2E users still exist for #{inspect(context.run_id)}") + end + end + + defp assert_only_allowed_prefix_users!(context) do + allowed = Map.values(context.emails) + + unexpected = + User + |> where([user], like(user.email, ^"#{prefix(context.run_id)}%")) + |> where([user], user.email not in ^allowed) + |> select([user], user.email) + |> Repo.all() + + if unexpected != [] do + Mix.raise("unexpected users share the staging run prefix: #{inspect(unexpected)}") + end + end + + defp ids(schema, field, parent_ids) do + schema + |> where([row], field(row, ^field) in ^parent_ids) + |> select([row], row.id) + |> Repo.all() + end + + defp delete_ids(_schema, []), do: 0 + + defp delete_ids(schema, ids) do + schema + |> where([row], row.id in ^ids) + |> delete_count() + end + + defp delete_count(query), do: query |> Repo.delete_all() |> elem(0) + + defp insert_user!(email, display_name, password_hash, role, now) do + %User{} + |> User.registration_changeset(%{ + "email" => email, + "display_name" => display_name, + "locale" => "en", + "terms_accepted" => true + }) + |> Ecto.Changeset.put_change(:hashed_password, password_hash) + |> Ecto.Changeset.put_change(:confirmed_at, now) + |> Ecto.Changeset.put_change(:role, role) + |> Repo.insert!() + end + + defp emails(run_id) do + (@precreated_roles ++ @registered_roles) + |> Map.new(fn role -> {role, "#{prefix(run_id)}#{role}@example.invalid"} end) + end + + defp prefix(run_id), do: "wnh-staging-e2e-#{run_id}-" + + defp display_name(role) do + role + |> String.replace("-", " ") + |> String.split() + |> Enum.map_join(" ", &String.capitalize/1) + |> then(&"Staging E2E #{&1}") + end + + defp required_env!(name) do + case System.get_env(name) do + value when is_binary(value) and value != "" -> value + _ -> Mix.raise("#{name} is required") + end + end + + defp uuid?(value) when is_binary(value) do + match?({:ok, _}, Ecto.UUID.cast(value)) + end + + defp uuid?(_value), do: false +end diff --git a/scripts/staging-full-e2e-run.sh b/scripts/staging-full-e2e-run.sh new file mode 100755 index 0000000..07e08d2 --- /dev/null +++ b/scripts/staging-full-e2e-run.sh @@ -0,0 +1,227 @@ +#!/bin/bash +set -euo pipefail +umask 077 + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +cd "$ROOT" + +ENV_FILE="$ROOT/.env" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing $ENV_FILE." >&2 + exit 1 +fi + +set -a +# shellcheck source=/dev/null +. "$ENV_FILE" +set +a + +: "${POSTGRES_DB:?POSTGRES_DB is missing from .env}" +: "${PHX_HOST:?PHX_HOST is missing from .env}" +: "${PHX_SCHEME:?PHX_SCHEME is missing from .env}" +: "${PHX_URL_PORT:?PHX_URL_PORT is missing from .env}" + +if [[ "$PHX_SCHEME" != https ]]; then + echo "Public staging E2E requires PHX_SCHEME=https." >&2 + exit 1 +fi + +run_id="$(date -u +%Y%m%d%H%M%S)-$$" +output_dir="$ROOT/output/staging-full-e2e/$run_id" +base_url="${PHX_SCHEME}://${PHX_HOST}" +mailpit_url=http://127.0.0.1:8027 +admin_email="wnh-staging-e2e-$run_id-admin@example.invalid" + +if [[ "$PHX_URL_PORT" != 443 ]]; then + base_url="${base_url}:${PHX_URL_PORT}" +fi + +mkdir -p "$output_dir" +chmod 700 "$ROOT/output" "$ROOT/output/staging-full-e2e" "$output_dir" + +fixture_password=$(openssl rand -base64 36 | tr -d '\n') +prepared=0 + +db_container=$(docker compose ps -q db) + +if [[ -z "$db_container" ]]; then + echo "The ordinary Compose database container is not running." >&2 + exit 1 +fi + +internal_network_id=$( + docker inspect "$db_container" | + jq -r '.[0].NetworkSettings.Networks | to_entries[] | select(.key | endswith("_internal")) | .value.NetworkID' | + head -n 1 +) + +if [[ -z "$internal_network_id" ]]; then + echo "Could not identify the ordinary Compose internal network." >&2 + exit 1 +fi + +snapshot_database() { + local destination=$1 + + docker compose exec -T db sh -c \ + 'psql --no-psqlrc --set ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB"' \ + >"$destination" <<'SQL' +BEGIN READ ONLY; +SELECT 'users' AS table_name, count(*) AS row_count FROM users +UNION ALL SELECT 'users_tokens', count(*) FROM users_tokens +UNION ALL SELECT 'help_requests', count(*) FROM help_requests +UNION ALL SELECT 'messages', count(*) FROM messages +UNION ALL SELECT 'help_assignments', count(*) FROM help_assignments +UNION ALL SELECT 'reports', count(*) FROM reports +UNION ALL SELECT 'reviews', count(*) FROM reviews +UNION ALL SELECT 'audit_events', count(*) FROM audit_events +UNION ALL SELECT 'abuse_signals', count(*) FROM abuse_signals +UNION ALL SELECT 'tracking_sessions', count(*) FROM tracking_sessions +UNION ALL SELECT 'tracking_positions', count(*) FROM tracking_positions +UNION ALL SELECT 'activities', count(*) FROM activities +UNION ALL SELECT 'activity_participants', count(*) FROM activity_participants +UNION ALL SELECT 'activity_messages', count(*) FROM activity_messages +UNION ALL SELECT 'category_proposals', count(*) FROM category_proposals +UNION ALL SELECT 'category_votes', count(*) FROM category_votes +UNION ALL SELECT 'blocks', count(*) FROM blocks +UNION ALL SELECT 'social_identities', count(*) FROM social_identities +UNION ALL SELECT 'rate_limit_buckets', count(*) FROM rate_limit_buckets +ORDER BY table_name; +COMMIT; +SQL +} + +run_fixture_tool() { + local action=$1 + + docker run --rm \ + --network "$internal_network_id" \ + --env-file "$ENV_FILE" \ + --env APP_ROLE=migrate \ + --env "WNH_STAGING_E2E_EXPECTED_DATABASE=$POSTGRES_DB" \ + --env WNH_STAGING_E2E_CONFIRM=public-staging-full-e2e \ + --env "WNH_STAGING_E2E_RUN_ID=$run_id" \ + --env "WNH_STAGING_E2E_PASSWORD=$fixture_password" \ + --env WNH_STAGING_E2E_MANIFEST_PATH=/output/fixture.json \ + --volume "$output_dir:/output" \ + who-need-help:staging-e2e-tools \ + mix wnh.staging_full_e2e "$action" +} + +cleanup_mailpit() { + local ids_file="$output_dir/mailpit-message-ids.txt" + local payload_file="$output_dir/mailpit-delete.json" + local role + + : >"$ids_file" + + for role in \ + requester \ + helper \ + activity-organizer \ + activity-participant \ + admin \ + auth-user \ + auth-user-changed + do + curl --fail --silent --show-error --get "$mailpit_url/api/v1/search" \ + --data-urlencode "query=to:\"wnh-staging-e2e-$run_id-$role@example.invalid\"" \ + --data-urlencode limit=100 | + jq -r '.messages[]?.ID' >>"$ids_file" + done + + sort -u -o "$ids_file" "$ids_file" + jq -Rn '[inputs | select(length > 0)] | {IDs: .}' <"$ids_file" >"$payload_file" + + if [[ "$(jq '.IDs | length' "$payload_file")" -gt 0 ]]; then + curl --fail --silent --show-error \ + --request DELETE \ + --header 'content-type: application/json' \ + --data-binary "@$payload_file" \ + "$mailpit_url/api/v1/messages" >"$output_dir/mailpit-delete-response.txt" + fi + + for role in \ + requester \ + helper \ + activity-organizer \ + activity-participant \ + admin \ + auth-user \ + auth-user-changed + do + remaining=$( + curl --fail --silent --show-error --get "$mailpit_url/api/v1/search" \ + --data-urlencode "query=to:\"wnh-staging-e2e-$run_id-$role@example.invalid\"" \ + --data-urlencode limit=1 | + jq '.messages | length' + ) + + if [[ "$remaining" -ne 0 ]]; then + echo "Mailpit still contains run-scoped messages for $role." >&2 + return 1 + fi + done +} + +cleanup() { + local status=$? + trap - EXIT HUP INT TERM + + if [[ "$prepared" -eq 1 ]]; then + if ! run_fixture_tool cleanup >"$output_dir/fixture-cleanup.log" 2>&1; then + echo "Exact full staging E2E cleanup failed; inspect $output_dir." >&2 + status=1 + else + snapshot_database "$output_dir/database-after.txt" + + if ! diff -u \ + "$output_dir/database-before.txt" \ + "$output_dir/database-after.txt" \ + >"$output_dir/database-cleanup.diff"; then + echo "Full staging E2E cleanup did not restore application table counts." >&2 + status=1 + fi + fi + + if ! cleanup_mailpit >"$output_dir/mailpit-cleanup.log" 2>&1; then + echo "Exact full staging E2E Mailpit cleanup failed; inspect $output_dir." >&2 + status=1 + fi + fi + + unset fixture_password + exit "$status" +} +trap cleanup EXIT HUP INT TERM + +curl --fail --silent --show-error "$base_url/healthz/ready" >"$output_dir/public-ready.txt" +snapshot_database "$output_dir/database-before.txt" + +docker build --target load_tools --tag who-need-help:staging-e2e-tools . \ + >"$output_dir/tools-build.log" +docker build --tag who-need-help-e2e-tests:local e2e \ + >"$output_dir/browser-build.log" + +run_fixture_tool prepare >"$output_dir/fixture-prepare.log" +prepared=1 + +docker run --rm \ + --network host \ + --user "$(id -u):$(id -g)" \ + --env "BASE_URL=$base_url" \ + --env "MAILPIT_URL=$mailpit_url" \ + --env "E2E_RUN_ID=$run_id" \ + --env "E2E_FIXTURE_PASSWORD=$fixture_password" \ + --env "E2E_ADMIN_EMAIL=$admin_email" \ + --env HOME=/tmp \ + --volume "$output_dir:/work/output" \ + who-need-help-e2e-tests:local \ + npx playwright test --project=chromium \ + tests/auth-settings.spec.ts \ + tests/mutual-aid.spec.ts \ + tests/activity-moderation.spec.ts + +echo "Full public staging E2E passed: $base_url" +echo "Evidence: $output_dir"