test: add isolated browser end-to-end flow
This commit is contained in:
parent
8dfd98b0ef
commit
f8412ee090
|
|
@ -41,6 +41,7 @@ erl_crash.dump
|
|||
# Static artifacts - These should be fetched and built inside the Docker image
|
||||
# https://phoenix.hexdocs.pm/Mix.Tasks.Phx.Gen.Release.html#module-docker
|
||||
/assets/node_modules/
|
||||
/e2e/node_modules/
|
||||
/priv/static/assets/
|
||||
/priv/static/cache_manifest.json
|
||||
/android/.gradle/
|
||||
|
|
|
|||
45
.env.e2e.example
Normal file
45
.env.e2e.example
Normal 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
2
.gitignore
vendored
|
|
@ -36,6 +36,7 @@ who_need_help-*.tar
|
|||
# In case you use Node.js/npm, you want to ignore these.
|
||||
npm-debug.log
|
||||
/assets/node_modules/
|
||||
/e2e/node_modules/
|
||||
/.tools/
|
||||
|
||||
# Local environment files can contain deployment credentials. Keep only the
|
||||
|
|
@ -44,6 +45,7 @@ npm-debug.log
|
|||
/.env.*
|
||||
!/.env.example
|
||||
!/.env.load.example
|
||||
!/.env.e2e.example
|
||||
|
||||
# Local browser automation state and generated verification artifacts.
|
||||
/.playwright-cli/
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ COPY mix.exs mix.lock ./
|
|||
RUN mix deps.get --only $MIX_ENV
|
||||
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
|
||||
# to ensure any relevant config change will trigger the dependencies
|
||||
# to be re-compiled.
|
||||
|
|
|
|||
17
README.md
17
README.md
|
|
@ -141,6 +141,23 @@ project's PostGIS service:
|
|||
|
||||
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
|
||||
|
||||
Register and confirm the first account, then explicitly bootstrap it:
|
||||
|
|
|
|||
36
compose.e2e.yaml
Normal file
36
compose.e2e.yaml
Normal 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]
|
||||
|
|
@ -11,12 +11,20 @@ config :who_need_help, WhoNeedHelpWeb.Endpoint,
|
|||
# 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.
|
||||
# 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,
|
||||
force_ssl: [
|
||||
rewrite_on: [:x_forwarded_proto],
|
||||
exclude: [
|
||||
paths: ["/healthz/live", "/healthz/ready"],
|
||||
hosts: ["localhost", "127.0.0.1"]
|
||||
hosts: force_ssl_hosts
|
||||
]
|
||||
]
|
||||
|
||||
|
|
|
|||
37
docs/local-hardening-plan.md
Normal file
37
docs/local-hardening-plan.md
Normal 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
11
e2e/Dockerfile
Normal 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
78
e2e/package-lock.json
generated
Normal 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
12
e2e/package.json
Normal 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
42
e2e/playwright.config.ts
Normal 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
160
e2e/tests/helpers.ts
Normal 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("&", "&"));
|
||||
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);
|
||||
}
|
||||
109
e2e/tests/mutual-aid.spec.ts
Normal file
109
e2e/tests/mutual-aid.spec.ts
Normal 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
24
e2e/tests/public.spec.ts
Normal 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
63
scripts/e2e-run.sh
Executable 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
26
scripts/e2e-stop.sh
Executable 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
60
scripts/ensure-local-e2e-env.sh
Executable 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."
|
||||
Loading…
Reference in New Issue
Block a user