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
|
# 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
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.
|
# 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/
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
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.
|
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
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,
|
# 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
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
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