test: add isolated browser end-to-end flow

This commit is contained in:
SimpleTest 2026-07-19 01:28:28 +03:00
parent 8dfd98b0ef
commit f8412ee090
18 changed files with 735 additions and 1 deletions

View File

@ -41,6 +41,7 @@ erl_crash.dump
# Static artifacts - These should be fetched and built inside the Docker image # Static artifacts - These should be fetched and built inside the Docker image
# https://phoenix.hexdocs.pm/Mix.Tasks.Phx.Gen.Release.html#module-docker # https://phoenix.hexdocs.pm/Mix.Tasks.Phx.Gen.Release.html#module-docker
/assets/node_modules/ /assets/node_modules/
/e2e/node_modules/
/priv/static/assets/ /priv/static/assets/
/priv/static/cache_manifest.json /priv/static/cache_manifest.json
/android/.gradle/ /android/.gradle/

45
.env.e2e.example Normal file
View File

@ -0,0 +1,45 @@
# This file documents the isolated browser-E2E inputs. The executable workflow
# generates .env.e2e with independent local secrets and mode 0600. It never
# reuses the staging Compose project or database volume.
HTTP_PORT=0
MAILPIT_PORT=0
TRAEFIK_TRUSTED_IPS=127.0.0.1/32
TRAEFIK_PROJECT_CONSTRAINT=GENERATED_UNIQUE_E2E_PROJECT
TRAEFIK_APP_NAME=GENERATED_UNIQUE_E2E_ROUTER
TRAEFIK_DOCKER_NETWORK=GENERATED_UNIQUE_E2E_NETWORK
TRAEFIK_ROUTER_RULE='PathPrefix(`/`)'
PHX_HOST=proxy
PHX_SCHEME=http
PHX_URL_PORT=80
MAP_TILE_URL=http://tiles.e2e.invalid/{z}/{x}/{y}.png
POSTGRES_DB=who_need_help_e2e
POSTGRES_USER=postgres
POSTGRES_PASSWORD=GENERATE_INDEPENDENT_E2E_DATABASE_PASSWORD
DATABASE_URL=ecto://postgres:GENERATE_URL_SAFE_PASSWORD@db/who_need_help_e2e
POOL_SIZE=10
SECRET_KEY_BASE=GENERATE_INDEPENDENT_E2E_SECRET_KEY_BASE
HANDOVER_SECRET=GENERATE_INDEPENDENT_E2E_HANDOVER_SECRET
RELEASE_COOKIE=GENERATE_INDEPENDENT_E2E_RELEASE_COOKIE
METRICS_TOKEN=GENERATE_INDEPENDENT_E2E_METRICS_TOKEN
SMTP_RELAY=mailpit
SMTP_PORT=1025
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_AUTH=never
SMTP_TLS=never
SMTP_SSL=false
EMAIL_FROM_NAME="Who Need Help E2E"
EMAIL_FROM_ADDRESS=e2e@example.invalid
CODEX_SESSION_ID=local-e2e
RATE_LIMIT_POLICIES_JSON={}
GITHUB_OAUTH_CLIENT_ID=
GITHUB_OAUTH_CLIENT_SECRET=
E2E_BASE_URL=http://proxy
E2E_MAILPIT_URL=http://mailpit:8025
E2E_OUTPUT_DIR=GENERATED_ABSOLUTE_IGNORED_OUTPUT_DIRECTORY

2
.gitignore vendored
View File

@ -36,6 +36,7 @@ who_need_help-*.tar
# In case you use Node.js/npm, you want to ignore these. # In case you use Node.js/npm, you want to ignore these.
npm-debug.log npm-debug.log
/assets/node_modules/ /assets/node_modules/
/e2e/node_modules/
/.tools/ /.tools/
# Local environment files can contain deployment credentials. Keep only the # Local environment files can contain deployment credentials. Keep only the
@ -44,6 +45,7 @@ npm-debug.log
/.env.* /.env.*
!/.env.example !/.env.example
!/.env.load.example !/.env.load.example
!/.env.e2e.example
# Local browser automation state and generated verification artifacts. # Local browser automation state and generated verification artifacts.
/.playwright-cli/ /.playwright-cli/

View File

@ -53,6 +53,9 @@ COPY mix.exs mix.lock ./
RUN mix deps.get --only $MIX_ENV RUN mix deps.get --only $MIX_ENV
RUN mkdir config RUN mkdir config
ARG WNH_E2E_SSL_EXCLUDE_HOST=
ENV WNH_E2E_SSL_EXCLUDE_HOST="${WNH_E2E_SSL_EXCLUDE_HOST}"
# copy compile-time config files before we compile dependencies # copy compile-time config files before we compile dependencies
# to ensure any relevant config change will trigger the dependencies # to ensure any relevant config change will trigger the dependencies
# to be re-compiled. # to be re-compiled.

View File

@ -141,6 +141,23 @@ project's PostGIS service:
It creates/updates only the project-scoped `who_need_help_test` database. It creates/updates only the project-scoped `who_need_help_test` database.
The browser E2E command creates a uniquely named, isolated Compose project with
its own PostGIS volume, Mailpit instance, Traefik proxy, two web replicas, and
two worker replicas:
```bash
./scripts/e2e-run.sh
```
On its first run it generates `.env.e2e` with independent random local secrets
and mode `0600`. The suite registers two users through real Mailpit messages,
completes an urgent medicine request through matching, realtime chat, handover,
and double-blind reviews, and checks public/authentication boundaries. It
retains traces, screenshots, video, and Compose logs under the ignored
`output/e2e/` directory on failure. The exact E2E project, database volume, and
networks are removed automatically; the normal `who_need_help` Compose project
is not recreated.
## First administrator ## First administrator
Register and confirm the first account, then explicitly bootstrap it: Register and confirm the first account, then explicitly bootstrap it:

36
compose.e2e.yaml Normal file
View File

@ -0,0 +1,36 @@
services:
migrate:
image: who-need-help:e2e
pull_policy: never
build:
args:
WNH_E2E_SSL_EXCLUDE_HOST: proxy
web:
image: who-need-help:e2e
pull_policy: never
worker:
image: who-need-help:e2e
pull_policy: never
e2e:
image: who-need-help-e2e-tests:local
build:
context: e2e
init: true
ipc: host
environment:
BASE_URL: ${E2E_BASE_URL:?Set E2E_BASE_URL in the generated E2E environment}
MAILPIT_URL: ${E2E_MAILPIT_URL:?Set E2E_MAILPIT_URL in the generated E2E environment}
CI: ${CI:-}
NO_PROXY: web,mailpit,db,proxy,localhost,127.0.0.1
no_proxy: web,mailpit,db,proxy,localhost,127.0.0.1
depends_on:
web:
condition: service_healthy
mailpit:
condition: service_started
volumes:
- ${E2E_OUTPUT_DIR:?Set an ignored E2E_OUTPUT_DIR}:/work/output
networks: [internal]

View File

@ -11,12 +11,20 @@ config :who_need_help, WhoNeedHelpWeb.Endpoint,
# Force using SSL in production. This also sets the "strict-security-transport" header, # Force using SSL in production. This also sets the "strict-security-transport" header,
# known as HSTS. If you have a health check endpoint, you may want to exclude it below. # known as HSTS. If you have a health check endpoint, you may want to exclude it below.
# Note `:force_ssl` is required to be set at compile-time. # Note `:force_ssl` is required to be set at compile-time.
force_ssl_hosts =
case System.get_env("WNH_E2E_SSL_EXCLUDE_HOST") do
nil -> ["localhost", "127.0.0.1"]
"" -> ["localhost", "127.0.0.1"]
"proxy" -> ["localhost", "127.0.0.1", "proxy"]
other -> raise "unsupported WNH_E2E_SSL_EXCLUDE_HOST: #{inspect(other)}"
end
config :who_need_help, WhoNeedHelpWeb.Endpoint, config :who_need_help, WhoNeedHelpWeb.Endpoint,
force_ssl: [ force_ssl: [
rewrite_on: [:x_forwarded_proto], rewrite_on: [:x_forwarded_proto],
exclude: [ exclude: [
paths: ["/healthz/live", "/healthz/ready"], paths: ["/healthz/live", "/healthz/ready"],
hosts: ["localhost", "127.0.0.1"] hosts: force_ssl_hosts
] ]
] ]

View File

@ -0,0 +1,37 @@
# Who Need Help — local hardening completion plan
This plan deliberately excludes buying a domain, renting another server, and
real third-party credentials. The existing local Docker/kind environments and
the temporary `whoneedhelp.imalto.site` staging origin are sufficient for every
item below unless the evidence column explicitly describes a local mock.
| Area | Observed baseline | Local completion evidence |
| --- | --- | --- |
| Browser E2E | Manual headed-Chrome scenarios exist; no committed browser suite | A fresh uniquely named Compose project runs two-user urgent help, Activity, moderation, privacy, and error paths; traces are retained on failure; its exact volume is removed |
| Android UI | Two JVM unit-test files; no `androidTest` source set | Emulator instrumentation covers deep links, permissions, foreground tracking, notification Stop, lifecycle, and network failure |
| CI and quality | No tracked CI workflow or static/security analysis dependencies | The same containerized gates pass locally and are represented in a validated CI workflow |
| Localization and accessibility | Navigation/error Gettext coverage exists, but most product copy is hard-coded English | EN/UK/RU catalogs cover product UI; extraction is current; automated accessibility, keyboard, responsive, and contrast checks pass |
| Database scale | Core discovery/chat/moderation lists call unbounded `Repo.all()` | Cursor-bounded queries pass behavior tests and measured `EXPLAIN ANALYZE` checks on an isolated generated dataset |
| Load and resilience | Public/readiness/heartbeat k6 profile exists | Authenticated writes, chat, tracking, reconnect, rolling replacement, and worker retry profiles pass without touching staging data |
| Observability | Protected Prometheus text endpoint exists | Local Prometheus/Grafana/Alertmanager profile scrapes every replica and an induced isolated failure exercises alert delivery |
| Backup | Validated local custom-format dump and restore drill exist | An encrypted artifact is uploaded to local S3-compatible MinIO and restored into a fresh database; corruption and interrupted-upload checks fail closed |
| External boundaries | Mailpit and a fake GitHub strategy cover parts of SMTP/OAuth | Local protocol-level SMTP/OAuth mocks and the applicable push adapter boundary cover success, rejection, retry, replay, and timeout |
| Final regression | 149 Phoenix tests and manual cross-client evidence | Browser, Android, API, DB, WebSocket, backup, monitoring, failure, cleanup, docs, and clean Git are verified from the final commits |
The goal remains open while any row lacks reproducible local evidence.
## Verified progress
- The isolated browser harness now generates independent local secrets, starts
two web and two worker replicas behind Traefik, and removes only its uniquely
named Compose project and PostGIS volume.
- The browser suite passes public/authentication boundaries and the complete
two-user medicine flow: Mailpit confirmation, category-driven request,
matching, bidirectional realtime chat, handover verification, bilateral
completion, and double-blind review reveal.
- Browser console errors, page errors, and unexpected failed requests are
test failures. Playwright traces, screenshots, video, JSON/HTML reports, and
Compose logs are retained in ignored output on failure.
- Activity, moderation, privacy/error paths, Android instrumentation, and the
remaining rows above are still pending; this document is not a completion
claim.

11
e2e/Dockerfile Normal file
View File

@ -0,0 +1,11 @@
FROM mcr.microsoft.com/playwright:v1.61.1-noble@sha256:5b8f294aff9041b7191c34a4bab3ac270157a28774d4b0660e9743297b697e48
WORKDIR /work
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY playwright.config.ts ./
COPY tests tests
CMD ["npx", "playwright", "test"]

78
e2e/package-lock.json generated Normal file
View File

@ -0,0 +1,78 @@
{
"name": "who-need-help-e2e",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "who-need-help-e2e",
"version": "0.1.0",
"devDependencies": {
"@playwright/test": "1.61.1"
}
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}

12
e2e/package.json Normal file
View File

@ -0,0 +1,12 @@
{
"name": "who-need-help-e2e",
"private": true,
"version": "0.1.0",
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed"
},
"devDependencies": {
"@playwright/test": "1.61.1"
}
}

42
e2e/playwright.config.ts Normal file
View File

@ -0,0 +1,42 @@
import { defineConfig, devices } from "@playwright/test";
const baseURL = process.env.BASE_URL;
if (!baseURL) {
throw new Error("BASE_URL is required");
}
export default defineConfig({
testDir: "./tests",
outputDir: "./output/test-results",
fullyParallel: false,
workers: 1,
forbidOnly: true,
retries: 0,
timeout: 90_000,
expect: {
timeout: 10_000,
},
reporter: [
["line"],
["html", { outputFolder: "./output/html-report", open: "never" }],
["json", { outputFile: "./output/results.json" }],
],
use: {
baseURL,
...devices["Desktop Chrome"],
actionTimeout: 10_000,
navigationTimeout: 20_000,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{
name: "chromium",
use: {
browserName: "chromium",
},
},
],
});

160
e2e/tests/helpers.ts Normal file
View File

@ -0,0 +1,160 @@
import {
APIRequestContext,
Browser,
BrowserContext,
expect,
Page,
} from "@playwright/test";
const mailpitURL = process.env.MAILPIT_URL;
const baseURL = process.env.BASE_URL;
if (!mailpitURL || !baseURL) {
throw new Error("MAILPIT_URL and BASE_URL are required");
}
const tilePng = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAQAAAAEAAQMAAABmvDolAAAAA1BMVEXu8vcBVSTwAAAAH0lEQVRo3u3BAQ0AAADCoPdPbQ43oAAAAAAAAAAAvg0hAAABfxmcpwAAAABJRU5ErkJggg==",
"base64",
);
export type AuthenticatedBrowser = {
context: BrowserContext;
page: Page;
email: string;
};
export async function newIsolatedContext(browser: Browser): Promise<BrowserContext> {
const context = await browser.newContext();
await context.route("http://tiles.e2e.invalid/**", async (route) => {
await route.fulfill({
status: 200,
contentType: "image/png",
body: tilePng,
});
});
return context;
}
export function captureBrowserFailures(page: Page): () => void {
const failures: string[] = [];
page.on("console", (message) => {
if (message.type() === "error") {
failures.push(`console: ${message.text()}`);
}
});
page.on("pageerror", (error) => {
failures.push(`pageerror: ${error.message}`);
});
page.on("requestfailed", (request) => {
const url = request.url();
if (!url.startsWith("http://tiles.e2e.invalid/")) {
failures.push(`requestfailed: ${request.method()} ${url} ${request.failure()?.errorText}`);
}
});
return () => expect(failures, failures.join("\n")).toEqual([]);
}
async function waitForMagicLink(request: APIRequestContext, email: string): Promise<string> {
let messageID: string | undefined;
await expect
.poll(
async () => {
const response = await request.get(`${mailpitURL}/api/v1/search`, {
params: {
query: `to:"${email}"`,
limit: "10",
},
});
expect(response.ok()).toBeTruthy();
const body = (await response.json()) as { messages?: Array<{ ID?: string }> };
messageID = body.messages?.[0]?.ID;
return messageID;
},
{
message: `waiting for the Mailpit login message for ${email}`,
timeout: 15_000,
},
)
.toBeTruthy();
const response = await request.get(`${mailpitURL}/api/v1/message/${messageID}`);
expect(response.ok()).toBeTruthy();
const message = (await response.json()) as {
Text?: string;
HTML?: string;
};
const content = `${message.Text ?? ""}\n${message.HTML ?? ""}`;
const match = content.match(/https?:\/\/[^\s"'<>]+\/users\/log-in\/[^\s"'<>]+/);
if (!match) {
throw new Error(`No magic login link found for ${email}`);
}
const link = new URL(match[0].replaceAll("&amp;", "&"));
const applicationOrigin = new URL(baseURL);
link.protocol = applicationOrigin.protocol;
link.host = applicationOrigin.host;
return link.toString();
}
export async function registerAndConfirm(
browser: Browser,
request: APIRequestContext,
email: string,
displayName: string,
): Promise<AuthenticatedBrowser> {
const context = await newIsolatedContext(browser);
const page = await context.newPage();
await page.goto("/users/register");
await page.getByLabel("Email").fill(email);
await page.getByLabel("Display name").fill(displayName);
await page
.getByLabel("I am 18 or older and accept the safety rules")
.check();
await page.getByRole("button", { name: "Create an account" }).click();
await expect(page).toHaveURL(/\/users\/log-in$/);
await expect(page.getByText(`An email was sent to ${email}`)).toBeVisible();
await page.goto(await waitForMagicLink(request, email));
await expect(page.getByRole("heading", { name: `Welcome ${email}` })).toBeVisible();
await page
.getByRole("button", { name: "Confirm and log in only this time" })
.click();
await expect(page.getByText(email, { exact: true })).toBeVisible();
return { context, page, email };
}
export async function selectOptionContaining(
page: Page,
label: string,
expectedText: string,
): Promise<void> {
const select = page.getByLabel(label);
const options = await select.locator("option").evaluateAll((nodes) =>
nodes.map((node) => ({
text: node.textContent?.trim() ?? "",
value: (node as HTMLOptionElement).value,
})),
);
const option = options.find(({ text }) => text.includes(expectedText));
if (!option) {
throw new Error(`No ${label} option contains ${expectedText}`);
}
await select.selectOption(option.value);
}

View File

@ -0,0 +1,109 @@
import { expect, test } from "@playwright/test";
import {
captureBrowserFailures,
registerAndConfirm,
selectOptionContaining,
} from "./helpers";
test("two users complete a medicine handover with realtime chat and blind reviews", async ({
browser,
request,
}) => {
const requester = await registerAndConfirm(
browser,
request,
"requester@example.invalid",
"E2E Requester",
);
const helper = await registerAndConfirm(
browser,
request,
"helper@example.invalid",
"E2E Helper",
);
const assertRequesterClean = captureBrowserFailures(requester.page);
const assertHelperClean = captureBrowserFailures(helper.page);
await requester.page.goto("/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("E2E medicine pickup");
await requester.page
.getByLabel("What help do you need?")
.fill("Please collect the legal medicine that is already reserved.");
await requester.page.getByLabel("Urgency").selectOption("now");
const expiresAt = new Date(Date.now() + 3 * 60 * 60 * 1000)
.toISOString()
.slice(0, 16);
await requester.page.getByLabel("Request expires (UTC)").fill(expiresAt);
await requester.page.getByLabel("Approximate area").fill("E2E central district");
await requester.page.getByLabel("Location visibility").selectOption("approximate_public");
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]").check();
await requester.page.getByRole("button", { name: "Publish request" }).click();
await expect(requester.page).toHaveURL(/\/requests\/[0-9a-f-]+$/);
const requestURL = requester.page.url();
await expect(requester.page.getByRole("heading", { name: "E2E medicine pickup" })).toBeVisible();
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 expect(requester.page.getByRole("heading", { name: "Private match chat" })).toBeVisible();
await helper.page
.getByPlaceholder("Write a safe coordination message…")
.fill("I am on my way to the pharmacy.");
await helper.page.getByRole("button", { name: "Send", exact: true }).click();
await expect(requester.page.getByText("I am on my way to the pharmacy.")).toBeVisible();
await requester.page
.getByPlaceholder("Write a safe coordination message…")
.fill("Thank you. The order is waiting at the counter.");
await requester.page.getByRole("button", { name: "Send", exact: true }).click();
await expect(helper.page.getByText("Thank you. The order is waiting at the counter.")).toBeVisible();
await helper.page.getByRole("button", { name: "Start helping" }).click();
const handoverCode = (
await requester.page.locator("text=HANDOVER CODE FOR THE HELPER").locator("..").locator(".font-mono").innerText()
).replace(/\s/g, "");
expect(handoverCode).toMatch(/^\d{6}$/);
await helper.page.getByLabel("Enter handover code").fill(handoverCode);
await helper.page.getByRole("button", { name: "Verify handover" }).click();
await expect(helper.page.getByText("Handover code verified.")).toBeVisible();
await helper.page.getByRole("button", { name: "Confirm my part is complete" }).click();
await requester.page.getByRole("button", { name: "Confirm my part is complete" }).click();
await expect(requester.page.getByRole("heading", { name: "Double-blind review" })).toBeVisible();
await expect(helper.page.getByRole("heading", { name: "Double-blind review" })).toBeVisible();
await helper.page.getByLabel("Rating").selectOption("5");
await helper.page.getByLabel("Comment (optional)").fill("Clear and safe coordination.");
await helper.page.getByRole("button", { name: "Submit review" }).click();
await expect(
helper.page.getByText("Review saved. It appears after both participants review."),
).toBeVisible();
await requester.page.goto("/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 requester.page.getByLabel("Rating").selectOption("5");
await requester.page.getByLabel("Comment (optional)").fill("Reliable volunteer.");
await requester.page.getByRole("button", { name: "Submit review" }).click();
await expect(
requester.page.getByText("Review saved. It appears after both participants review."),
).toBeVisible();
await requester.page.goto("/profile");
await expect(requester.page.getByText("Clear and safe coordination.")).toBeVisible();
await helper.page.goto("/profile");
await expect(helper.page.getByText("Reliable volunteer.")).toBeVisible();
assertRequesterClean();
assertHelperClean();
await requester.context.close();
await helper.context.close();
});

24
e2e/tests/public.spec.ts Normal file
View File

@ -0,0 +1,24 @@
import { expect, test } from "@playwright/test";
import { captureBrowserFailures } from "./helpers";
test("public safety pages load and private discovery requires authentication", async ({
page,
}) => {
const assertNoBrowserFailures = captureBrowserFailures(page);
await page.goto("/");
await expect(
page.getByRole("heading", { name: "Help can be closer than you think." }),
).toBeVisible();
await expect(page.getByText("Not an emergency or medical service.")).toBeVisible();
await page.getByRole("link", { name: "Safety rules" }).click();
await expect(page.getByRole("heading", { name: "Safety rules" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Medicine requests" })).toBeVisible();
await page.getByRole("link", { name: "Requests" }).click();
await expect(page).toHaveURL(/\/users\/log-in$/);
await expect(page.getByText("You must log in to access this page.")).toBeVisible();
assertNoBrowserFailures();
});

63
scripts/e2e-run.sh Executable file
View File

@ -0,0 +1,63 @@
#!/bin/sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$ROOT"
"$ROOT/scripts/ensure-local-e2e-env.sh"
run_id="$(date -u +%Y%m%d%H%M%S)-$$"
project="who_need_help_e2e_$run_id"
output_dir="$ROOT/output/e2e/$run_id"
case "$project" in
who_need_help_e2e_*) ;;
*)
echo "Refusing unexpected E2E project name: $project" >&2
exit 1
;;
esac
mkdir -p "$output_dir"
export TRAEFIK_PROJECT_CONSTRAINT="$project"
export TRAEFIK_APP_NAME="who-need-help-e2e-$run_id"
export TRAEFIK_DOCKER_NETWORK="${project}_internal"
export E2E_OUTPUT_DIR="$output_dir"
export CODEX_SESSION_ID=${CODEX_SESSION_ID:-${CODEX_THREAD_ID:-local-e2e}}
compose() {
docker compose \
--project-name "$project" \
--env-file "$ROOT/.env.e2e" \
--file "$ROOT/compose.yaml" \
--file "$ROOT/compose.e2e.yaml" \
"$@"
}
cleanup() {
status=$?
if [ "$status" -ne 0 ]; then
compose ps --all >"$output_dir/compose-ps.txt" 2>&1 || true
compose logs --no-color >"$output_dir/compose.log" 2>&1 || true
fi
if [ "${KEEP_E2E_STACK:-0}" = "1" ]; then
echo "Keeping isolated E2E project $project for inspection."
echo "Artifacts: $output_dir"
else
compose down --volumes --remove-orphans >/dev/null
fi
exit "$status"
}
trap cleanup EXIT HUP INT TERM
compose config --quiet
compose up -d --build --wait db mailpit migrate proxy web worker
compose build e2e
compose run --rm --no-deps e2e
echo "Isolated browser E2E passed."
echo "Artifacts: $output_dir"

26
scripts/e2e-stop.sh Executable file
View File

@ -0,0 +1,26 @@
#!/bin/sh
set -eu
if [ "$#" -ne 1 ]; then
echo "Usage: $0 who_need_help_e2e_RUN_ID" >&2
exit 1
fi
project=$1
case "$project" in
who_need_help_e2e_*) ;;
*)
echo "Refusing to stop a non-E2E Compose project: $project" >&2
exit 1
;;
esac
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
docker compose \
--project-name "$project" \
--env-file "$ROOT/.env.e2e" \
--file "$ROOT/compose.yaml" \
--file "$ROOT/compose.e2e.yaml" \
down --volumes --remove-orphans

60
scripts/ensure-local-e2e-env.sh Executable file
View File

@ -0,0 +1,60 @@
#!/bin/sh
set -eu
umask 077
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
target="$ROOT/.env.e2e"
if [ -f "$target" ]; then
chmod 600 "$target"
echo ".env.e2e already exists; no secret was changed."
exit 0
fi
postgres_password=$(openssl rand -hex 32)
secret_key_base=$(openssl rand -hex 64)
handover_secret=$(openssl rand -hex 64)
release_cookie=$(openssl rand -hex 64)
metrics_token=$(openssl rand -hex 32)
cat >"$target" <<EOF
HTTP_PORT=0
MAILPIT_PORT=0
TRAEFIK_TRUSTED_IPS=127.0.0.1/32
TRAEFIK_PROJECT_CONSTRAINT=generated-per-run
TRAEFIK_APP_NAME=generated-per-run
TRAEFIK_DOCKER_NETWORK=generated-per-run
TRAEFIK_ROUTER_RULE='PathPrefix(\`/\`)'
PHX_HOST=proxy
PHX_SCHEME=http
PHX_URL_PORT=80
MAP_TILE_URL=http://tiles.e2e.invalid/{z}/{x}/{y}.png
POSTGRES_DB=who_need_help_e2e
POSTGRES_USER=postgres
POSTGRES_PASSWORD=$postgres_password
DATABASE_URL=ecto://postgres:$postgres_password@db/who_need_help_e2e
POOL_SIZE=10
SECRET_KEY_BASE=$secret_key_base
HANDOVER_SECRET=$handover_secret
RELEASE_COOKIE=$release_cookie
METRICS_TOKEN=$metrics_token
SMTP_RELAY=mailpit
SMTP_PORT=1025
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_AUTH=never
SMTP_TLS=never
SMTP_SSL=false
EMAIL_FROM_NAME="Who Need Help E2E"
EMAIL_FROM_ADDRESS=e2e@example.invalid
CODEX_SESSION_ID=local-e2e
RATE_LIMIT_POLICIES_JSON={}
GITHUB_OAUTH_CLIENT_ID=
GITHUB_OAUTH_CLIENT_SECRET=
E2E_BASE_URL=http://proxy
E2E_MAILPIT_URL=http://mailpit:8025
E2E_OUTPUT_DIR=$ROOT/output/e2e/generated-per-run
EOF
chmod 600 "$target"
echo "Generated .env.e2e with independent local secrets and mode 0600."