test: verify full public staging flows

This commit is contained in:
SimpleTest 2026-07-20 00:22:04 +03:00
parent 1c11ff5ed2
commit 710ad5e901
11 changed files with 1068 additions and 61 deletions

View File

@ -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

View File

@ -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(

View File

@ -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

View File

@ -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();
});

View File

@ -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<BrowserConte
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 {
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<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 waitForMapReady(page: Page): Promise<void> {
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<string | undefined> {
@ -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<string> {
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("&amp;", "&"))
.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("&amp;", "&"));
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<string> {
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(

View File

@ -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();

View File

@ -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();

View File

@ -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(() => {

View File

@ -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")

View File

@ -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

227
scripts/staging-full-e2e-run.sh Executable file
View File

@ -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"