test: harden browser accessibility and resilience

This commit is contained in:
SimpleTest 2026-07-19 03:32:25 +03:00
parent 477517527f
commit 8fbac386e7
25 changed files with 358 additions and 94 deletions

View File

@ -12,7 +12,7 @@ TRAEFIK_ROUTER_RULE='PathPrefix(`/`)'
PHX_HOST=proxy PHX_HOST=proxy
PHX_SCHEME=http PHX_SCHEME=http
PHX_URL_PORT=80 PHX_URL_PORT=80
MAP_TILE_URL=http://tiles.e2e.invalid/{z}/{x}/{y}.png MAP_TILE_URL=http://proxy/__e2e__/map-tile.png?z={z}&x={x}&y={y}
POSTGRES_DB=who_need_help_e2e POSTGRES_DB=who_need_help_e2e
POSTGRES_USER=postgres POSTGRES_USER=postgres

View File

@ -172,6 +172,11 @@ the urgent medicine flow through matching, realtime chat, handover, and
double-blind reviews; and the Activity flow through join approval, private double-blind reviews; and the Activity flow through join approval, private
group chat, message-scoped reporting, blocking, privacy defaults, an unverified group chat, message-scoped reporting, blocking, privacy defaults, an unverified
social link, category moderation, report resolution, and account restriction. social link, category moderation, report resolution, and account restriction.
The same gate checks keyboard skip navigation, WCAG violations and contrast on
four public pages in both themes, horizontal overflow at three viewport widths,
an actual locally served raster map tile, and LiveView offline/reconnect UI.
An optional list of Playwright spec paths can be passed after the command for a
focused diagnostic run.
It retains traces, screenshots, video, and Compose logs under the ignored It retains traces, screenshots, video, and Compose logs under the ignored
`output/e2e/` directory on failure. The exact E2E project, database volume, and `output/e2e/` directory on failure. The exact E2E project, database volume, and
networks are removed automatically; the normal `who_need_help` Compose project networks are removed automatically; the normal `who_need_help` Compose project

View File

@ -30,21 +30,21 @@
--color-base-100: oklch(30.33% 0.016 252.42); --color-base-100: oklch(30.33% 0.016 252.42);
--color-base-200: oklch(25.26% 0.014 253.1); --color-base-200: oklch(25.26% 0.014 253.1);
--color-base-300: oklch(20.15% 0.012 254.09); --color-base-300: oklch(20.15% 0.012 254.09);
--color-base-content: oklch(97.807% 0.029 256.847); --color-base-content: #f3f7ff;
--color-primary: oklch(58% 0.233 277.117); --color-primary: #a5b4fc;
--color-primary-content: oklch(96% 0.018 272.314); --color-primary-content: #18162a;
--color-secondary: oklch(58% 0.233 277.117); --color-secondary: #a5b4fc;
--color-secondary-content: oklch(96% 0.018 272.314); --color-secondary-content: #18162a;
--color-accent: oklch(60% 0.25 292.717); --color-accent: oklch(60% 0.25 292.717);
--color-accent-content: oklch(96% 0.016 293.756); --color-accent-content: oklch(96% 0.016 293.756);
--color-neutral: oklch(37% 0.044 257.287); --color-neutral: oklch(37% 0.044 257.287);
--color-neutral-content: oklch(98% 0.003 247.858); --color-neutral-content: oklch(98% 0.003 247.858);
--color-info: oklch(58% 0.158 241.966); --color-info: oklch(58% 0.158 241.966);
--color-info-content: oklch(97% 0.013 236.62); --color-info-content: oklch(97% 0.013 236.62);
--color-success: oklch(60% 0.118 184.704); --color-success: oklch(75% 0.118 184.704);
--color-success-content: oklch(98% 0.014 180.72); --color-success-content: oklch(20% 0.014 180.72);
--color-warning: oklch(66% 0.179 58.318); --color-warning: #f59e0b;
--color-warning-content: oklch(98% 0.022 95.277); --color-warning-content: #241400;
--color-error: oklch(58% 0.253 17.585); --color-error: oklch(58% 0.253 17.585);
--color-error-content: oklch(96% 0.015 12.422); --color-error-content: oklch(96% 0.015 12.422);
--radius-selector: 0.25rem; --radius-selector: 0.25rem;
@ -66,8 +66,8 @@
--color-base-200: oklch(96% 0.001 286.375); --color-base-200: oklch(96% 0.001 286.375);
--color-base-300: oklch(92% 0.004 286.32); --color-base-300: oklch(92% 0.004 286.32);
--color-base-content: oklch(21% 0.006 285.885); --color-base-content: oklch(21% 0.006 285.885);
--color-primary: oklch(70% 0.213 47.604); --color-primary: #9a3412;
--color-primary-content: oklch(98% 0.016 73.684); --color-primary-content: #ffffff;
--color-secondary: oklch(55% 0.027 264.364); --color-secondary: oklch(55% 0.027 264.364);
--color-secondary-content: oklch(98% 0.002 247.839); --color-secondary-content: oklch(98% 0.002 247.839);
--color-accent: oklch(0% 0 0); --color-accent: oklch(0% 0 0);
@ -76,10 +76,10 @@
--color-neutral-content: oklch(98% 0 0); --color-neutral-content: oklch(98% 0 0);
--color-info: oklch(62% 0.214 259.815); --color-info: oklch(62% 0.214 259.815);
--color-info-content: oklch(97% 0.014 254.604); --color-info-content: oklch(97% 0.014 254.604);
--color-success: oklch(70% 0.14 182.503); --color-success: oklch(45% 0.12 182.503);
--color-success-content: oklch(98% 0.014 180.72); --color-success-content: oklch(98% 0.014 180.72);
--color-warning: oklch(66% 0.179 58.318); --color-warning: #b45309;
--color-warning-content: oklch(98% 0.022 95.277); --color-warning-content: #fffaf0;
--color-error: oklch(58% 0.253 17.585); --color-error: oklch(58% 0.253 17.585);
--color-error-content: oklch(96% 0.015 12.422); --color-error-content: oklch(96% 0.015 12.422);
--radius-selector: 0.25rem; --radius-selector: 0.25rem;
@ -105,6 +105,29 @@
/* This file is for your main application CSS */ /* This file is for your main application CSS */
.skip-link {
position: fixed;
z-index: 100;
inset-block-start: 0.5rem;
inset-inline-start: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
color: var(--color-primary-content);
background: var(--color-primary);
transform: translateY(-150%);
transition: transform 120ms ease;
}
.skip-link:focus {
transform: translateY(0);
}
[data-theme="dark"] .menu a,
[data-theme="dark"] .btn-ghost,
[data-theme="dark"] .btn-outline {
color: var(--color-base-content) !important;
}
.aid-map { .aid-map {
min-height: 22rem; min-height: 22rem;
border-radius: 1.25rem; border-radius: 1.25rem;

View File

@ -24,12 +24,22 @@ export const Hooks = {
zoom: 5 zoom: 5
}) })
this.map.addControl(new maplibregl.NavigationControl(), "top-right") this.map.addControl(new maplibregl.NavigationControl(), "top-right")
this.markLoading = () => {
this.el.dataset.mapReady = "false"
}
this.markReady = () => {
this.el.dataset.mapReady = "true"
}
this.map.on("dataloading", this.markLoading)
this.map.on("idle", this.markReady)
this.renderMarkers() this.renderMarkers()
}, },
updated() { updated() {
this.renderMarkers() this.renderMarkers()
}, },
destroyed() { destroyed() {
this.map?.off("dataloading", this.markLoading)
this.map?.off("idle", this.markReady)
this.map?.remove() this.map?.remove()
}, },
renderMarkers() { renderMarkers() {

View File

@ -6,7 +6,7 @@ services:
POSTGRES_USER: ${QUALITY_POSTGRES_USER:?Set QUALITY_POSTGRES_USER} POSTGRES_USER: ${QUALITY_POSTGRES_USER:?Set QUALITY_POSTGRES_USER}
POSTGRES_PASSWORD: ${QUALITY_POSTGRES_PASSWORD:?Set QUALITY_POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${QUALITY_POSTGRES_PASSWORD:?Set QUALITY_POSTGRES_PASSWORD}
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 3s interval: 3s
timeout: 3s timeout: 3s
retries: 20 retries: 20

View File

@ -52,7 +52,10 @@ services:
POSTGRES_USER: ${POSTGRES_USER:?Set POSTGRES_USER in .env} POSTGRES_USER: ${POSTGRES_USER:?Set POSTGRES_USER in .env}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] # The image starts a temporary Unix-socket-only PostgreSQL while it loads
# PostGIS into a fresh database, then stops it and starts the final server.
# Checking TCP prevents dependants from starting during that bootstrap window.
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 3s interval: 3s
timeout: 3s timeout: 3s
retries: 20 retries: 20

View File

@ -29,6 +29,7 @@ config :who_need_help,
generators: [timestamp_type: :utc_datetime, binary_id: true], generators: [timestamp_type: :utc_datetime, binary_id: true],
app_role: :web, app_role: :web,
codex_session_id: "not-configured", codex_session_id: "not-configured",
e2e_routes: false,
rate_limit_policies: %{}, rate_limit_policies: %{},
map_tile_url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png" map_tile_url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png"

View File

@ -19,6 +19,8 @@ force_ssl_hosts =
other -> raise "unsupported WNH_E2E_SSL_EXCLUDE_HOST: #{inspect(other)}" other -> raise "unsupported WNH_E2E_SSL_EXCLUDE_HOST: #{inspect(other)}"
end end
config :who_need_help, :e2e_routes, System.get_env("WNH_E2E_SSL_EXCLUDE_HOST") == "proxy"
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],

View File

@ -10,13 +10,13 @@ item below unless the evidence column explicitly describes a local mock.
| 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 | | 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 | | 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 | | 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 | | Localization and accessibility | Accessibility/keyboard/responsive/contrast browser gates pass; most product copy remains hard-coded English | EN/UK/RU catalogs cover product UI; extraction is current; the existing browser gates remain green |
| 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | Final regression | 150 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. The goal remains open while any row lacks reproducible local evidence.
@ -38,6 +38,14 @@ The goal remains open while any row lacks reproducible local evidence.
- Browser console errors, page errors, and unexpected failed requests are - Browser console errors, page errors, and unexpected failed requests are
test failures. Playwright traces, screenshots, video, JSON/HTML reports, and test failures. Playwright traces, screenshots, video, JSON/HTML reports, and
Compose logs are retained in ignored output on failure. Compose logs are retained in ignored output on failure.
- The browser gate also passes axe checks on the home, safety, login, and
registration pages in both light and dark themes, keyboard skip navigation,
and 360/768/1440-pixel overflow checks. Its local raster-tile endpoint exists
only in the E2E-compiled image, and map scenarios wait for MapLibre `idle`
before navigation.
- A forced LiveSocket transport loss while Chromium is offline exposes the
reconnect alert, and restoring the network clears it and reconnects the
authenticated LiveView.
- The isolated API 37 Android suite passes five device tests: missing and - The isolated API 37 Android suite passes five device tests: missing and
granted location permission, same-origin deep-link routing across Activity granted location permission, same-origin deep-link routing across Activity
recreation, native foreground location upload, notification Stop with remote recreation, native foreground location upload, notification Stop with remote
@ -47,7 +55,7 @@ The goal remains open while any row lacks reproducible local evidence.
- The containerized `scripts/quality.sh` gate passes ShellCheck, Hadolint, - The containerized `scripts/quality.sh` gate passes ShellCheck, Hadolint,
actionlint, all Compose renders, Helm lint, a Trivy scan of tracked source and actionlint, all Compose renders, Helm lint, a Trivy scan of tracked source and
the rendered Kubernetes manifest, compiler/xref/Credo/Sobelow/Dialyzer/Hex the rendered Kubernetes manifest, compiler/xref/Credo/Sobelow/Dialyzer/Hex
checks, 149 Phoenix tests, both npm audits, and a Trivy scan of the production checks, 150 Phoenix tests, both npm audits, and a Trivy scan of the production
release image. It creates random one-run database credentials and removes its release image. It creates random one-run database credentials and removes its
exact volume, networks, images, and source snapshot. exact volume, networks, images, and source snapshot.
- The checked-in GitHub Actions workflow runs the same isolated backend/security - The checked-in GitHub Actions workflow runs the same isolated backend/security
@ -58,6 +66,6 @@ The goal remains open while any row lacks reproducible local evidence.
Mailpit, both web replicas, and both worker replicas became Ready as non-root Mailpit, both web replicas, and both worker replicas became Ready as non-root
containers with read-only root filesystems, dropped capabilities, and containers with read-only root filesystems, dropped capabilities, and
RuntimeDefault seccomp where configured. The cross-node PubSub probe passed. RuntimeDefault seccomp where configured. The cross-node PubSub probe passed.
- The remaining rows above are still pending. Browser accessibility, - The remaining rows above are still pending. In particular, complete EN/UK/RU
responsive, offline, and reconnect checks remain part of their respective extraction and catalogs are not implied by the completed accessibility
later rows; this document is not a completion claim. checks; this document is not a completion claim.

View File

@ -1,6 +1,6 @@
# Who Need Help — implementation verification # Who Need Help — implementation verification
Observed on 2026-07-18 in the local workspace. This report separates observed Observed through 2026-07-19 in the local workspace. This report separates observed
results from product limits and unknown production properties. results from product limits and unknown production properties.
## Verified MVP capabilities ## Verified MVP capabilities
@ -11,19 +11,19 @@ results from product limits and unknown production properties.
| Urgent roadside help | Implemented and tested | Fuel, car wheel, bicycle, motorcycle, vehicle-breakdown, and secured-incident categories are seeded as a translated hierarchy. Server and LiveView tests exercise category paths, required fields, allowed values, boolean normalization, unknown-field rejection, and request creation. | Roadside requests require no immediate danger; this is not emergency response or professional recovery. | | Urgent roadside help | Implemented and tested | Fuel, car wheel, bicycle, motorcycle, vehicle-breakdown, and secured-incident categories are seeded as a translated hierarchy. Server and LiveView tests exercise category paths, required fields, allowed values, boolean normalization, unknown-field rejection, and request creation. | Roadside requests require no immediate danger; this is not emergency response or professional recovery. |
| Extensible categories | Implemented and tested | Categories and validated text/select/boolean fields are stored in PostgreSQL. Proposal, vote, approve, reject, and merge paths have automated tests. | Coffee, cinema, hiking, and other social activities remain separate from urgent-help safety and ranking rules. | | Extensible categories | Implemented and tested | Categories and validated text/select/boolean fields are stored in PostgreSQL. Proposal, vote, approve, reject, and merge paths have automated tests. | Coffee, cinema, hiking, and other social activities remain separate from urgent-help safety and ranking rules. |
| Separate Activity mode | Implemented and tested | Coffee, cinema, walk, and hiking categories use a separate activity lifecycle. Domain and two-client LiveView tests cover creation, join request, organizer approval, capacity enforcement, public/pending/chat privacy, exact-location disclosure to approved users, group chat, blocking, completion, and zero impact on helper reputation. Activity and message reports expose only the linked group conversation to an audited moderator; moderators can hide and restore reported activities. | This does not guarantee participant identity or physical safety. | | Separate Activity mode | Implemented and tested | Coffee, cinema, walk, and hiking categories use a separate activity lifecycle. Domain and two-client LiveView tests cover creation, join request, organizer approval, capacity enforcement, public/pending/chat privacy, exact-location disclosure to approved users, group chat, blocking, completion, and zero impact on helper reputation. Activity and message reports expose only the linked group conversation to an audited moderator; moderators can hide and restore reported activities. | This does not guarantee participant identity or physical safety. |
| Map and discovery | Implemented and browser-verified | A headed Chrome session rendered the MapLibre request map, marker, controls, attribution, and configured OpenStreetMap raster tiles. Tile requests returned HTTP 200 during the check. | A production operator must configure a tile provider appropriate for its policy and traffic. | | Map and discovery | Implemented and browser-verified | The committed isolated Chromium suite rendered request and Activity maps, waited for MapLibre `idle`, and completed with no console, page, or request failures against a local PNG raster fixture. An earlier headed session rendered the configured OpenStreetMap tiles. | A production operator must configure a tile provider appropriate for its policy and traffic. |
| Private matched chat | Implemented and cross-client verified | A message sent from the helper browser appeared in the requester's browser without reload. An earlier Android emulator run also sent a message that appeared in the requester browser in real time. | There is no unsolicited general-purpose inbox. | | Private matched chat | Implemented and cross-client verified | A message sent from the helper browser appeared in the requester's browser without reload. An earlier Android emulator run also sent a message that appeared in the requester browser in real time. | There is no unsolicited general-purpose inbox. |
| Consent-driven live tracking | Implemented and cross-client verified | On API 37, Android started `TrackingService` as a location foreground service with a persistent Stop notification. After Home minimized the Activity, an emulator coordinate change reached PostGIS. Notification Stop removed the service, notification, active session, and raw position. | Browsers stop with the page. Android has no `ACCESS_BACKGROUND_LOCATION`, unattended start, or route history. | | Consent-driven live tracking | Implemented and cross-client verified | On API 37, Android started `TrackingService` as a location foreground service with a persistent Stop notification. After Home minimized the Activity, an emulator coordinate change reached PostGIS. Notification Stop removed the service, notification, active session, and raw position. | Browsers stop with the page. Android has no `ACCESS_BACKGROUND_LOCATION`, unattended start, or route history. |
| Privacy settings | Implemented and browser-verified | The profile exposed hidden, approximate public, exact for active match, and explicit exact-public options. Blocking and current-position cleanup have automated tests. | Exact public location remains a user opt-in; legal privacy and retention text still requires jurisdiction-specific review before launch. | | Privacy settings | Implemented and browser-verified | The profile exposed hidden, approximate public, exact for active match, and explicit exact-public options. Blocking and current-position cleanup have automated tests. | Exact public location remains a user opt-in; legal privacy and retention text still requires jurisdiction-specific review before launch. |
| Reputation and anti-abuse | Implemented at MVP level | Handover codes, two-party completion, double-blind reviews, unique-counterpart ranking, optional movement/proximity evidence, reports, blocks, abuse signals, and moderator audit paths have automated tests. | The system is not bot-proof and does not claim identity verification. No punitive numeric policy is enabled without measured and approved thresholds. | | Reputation and anti-abuse | Implemented at MVP level | Handover codes, two-party completion, double-blind reviews, unique-counterpart ranking, optional movement/proximity evidence, reports, blocks, abuse signals, and moderator audit paths have automated tests. | The system is not bot-proof and does not claim identity verification. No punitive numeric policy is enabled without measured and approved thresholds. |
| Social profiles | Manual links implemented; GitHub verification implemented and automated-tested | Manual links cannot set verification fields. The optional GitHub flow uses state, PKCE, a user-bound one-time session, unique provider ownership, and an audit record; 149 tests pass, including callback replay/state checks. No access-token field exists and the controller receives only normalized identity attributes. | The staging operator has not supplied GitHub OAuth credentials, so the real external provider redirect/callback remains disabled and has not been browser-verified. Other providers remain manual/unverified. | | Social profiles | Manual links implemented; GitHub verification implemented and automated-tested | Manual links cannot set verification fields. The optional GitHub flow uses state, PKCE, a user-bound one-time session, unique provider ownership, and an audit record; 150 tests pass, including callback replay/state checks. No access-token field exists and the controller receives only normalized identity attributes. | The staging operator has not supplied GitHub OAuth credentials, so the real external provider redirect/callback remains disabled and has not been browser-verified. Other providers remain manual/unverified. |
| Voluntary thanks | Implemented as an external optional link | A helper can expose an optional link after completion; the UI states that the platform does not process the payment. | The platform does not provide payments, escrow, refunds, tax reporting, or payment guarantees. | | Voluntary thanks | Implemented as an external optional link | A helper can expose an optional link after completion; the UI states that the platform does not process the payment. | The platform does not provide payments, escrow, refunds, tax reporting, or payment guarantees. |
| Android client | Local and public-staging clients implemented and emulator-verified | The native packages `org.whoneedhelp.mobile.debug` and `org.whoneedhelp.mobile.staging` launch the same authenticated LiveView app. Public HTTPS login, map, two-way chat, permission prompts, minimized foreground-service location updates, notification Stop, deep-link routing, and server cleanup were exercised on API 37. | Production signing, Play Store publication, verified Android App Links, unattended/background-permission tracking, and iOS are not implemented. | | Android client | Local and public-staging clients implemented and emulator-verified | The native packages `org.whoneedhelp.mobile.debug` and `org.whoneedhelp.mobile.staging` launch the same authenticated LiveView app. Public HTTPS login, map, two-way chat, permission prompts, minimized foreground-service location updates, notification Stop, deep-link routing, and server cleanup were exercised on API 37. | Production signing, Play Store publication, verified Android App Links, unattended/background-permission tracking, and iOS are not implemented. |
| Multiple web/worker instances | Implemented and locally verified | Docker Compose and kind each ran 2 web and 2 worker replicas. The project probes cross-node Phoenix PubSub using different BEAM nodes. Kubernetes web/worker pods were Ready with zero restarts at the final observation. | Local PostGIS is a single instance. Production database HA, backups, and recovery are operator work and are not claimed complete. | | Multiple web/worker instances | Implemented and locally verified | Docker Compose and kind each ran 2 web and 2 worker replicas. The project probes cross-node Phoenix PubSub using different BEAM nodes. Kubernetes web/worker pods were Ready with zero restarts at the final observation. | Local PostGIS is a single instance. Production database HA, backups, and recovery are operator work and are not claimed complete. |
## Reproducible checks ## Reproducible checks
- `./scripts/test.sh`: 149 tests, 0 failures after the Android public-staging and - `./scripts/test.sh`: 150 tests, 0 failures after the Android public-staging and
realtime tracking-evidence changes realtime tracking-evidence changes
on Elixir 1.20.2 and Erlang/OTP 29.0.3. on Elixir 1.20.2 and Erlang/OTP 29.0.3.
- `mix compile --force --warnings-as-errors` and - `mix compile --force --warnings-as-errors` and
@ -31,7 +31,7 @@ results from product limits and unknown production properties.
- `./scripts/quality.sh` passed ShellCheck 0.11.0, Hadolint 2.14.0 at warning - `./scripts/quality.sh` passed ShellCheck 0.11.0, Hadolint 2.14.0 at warning
threshold, actionlint 1.7.12, all four Compose renders, Helm lint, Trivy threshold, actionlint 1.7.12, all four Compose renders, Helm lint, Trivy
source/rendered-manifest scanning, xref, Credo high-priority checks, Sobelow source/rendered-manifest scanning, xref, Credo high-priority checks, Sobelow
strict/private checks, Hex audit, 149 Phoenix tests, both npm audits, and the strict/private checks, Hex audit, 150 Phoenix tests, both npm audits, and the
production-image vulnerability scan. The rendered Helm manifest and Debian production-image vulnerability scan. The rendered Helm manifest and Debian
13.6 release image each reported zero HIGH/CRITICAL findings under the 13.6 release image each reported zero HIGH/CRITICAL findings under the
configured gates. configured gates.
@ -64,9 +64,13 @@ results from product limits and unknown production properties.
containers also used read-only root filesystems and dropped capabilities. containers also used read-only root filesystems and dropped capabilities.
The cross-node probe observed all four BEAM nodes, and live/readiness returned The cross-node probe observed all four BEAM nodes, and live/readiness returned
`ok`/`ready`. Database counts remained `0 users / 0 help requests`. `ok`/`ready`. Database counts remained `0 users / 0 help requests`.
- The committed browser suite was repeated after CSP and non-root E2E-runner - The committed browser suite passed its 1/1 bootstrap and all 6/6 Chromium
changes: its bootstrap test passed 1/1 and its three Chromium scenarios specs against a fresh PostGIS volume with two web and two worker replicas.
passed 3/3. The uniquely named Compose volume and networks were removed. It covered axe checks across four pages and two themes, keyboard navigation,
three responsive widths, Activity/moderation/privacy, the two-user medicine
handover and blind reviews, public boundaries, local raster maps, and an
offline LiveSocket transport failure/reconnect. The uniquely named Compose
volume and networks were removed.
- Browser verification used headed Chrome. The authenticated matched-request - Browser verification used headed Chrome. The authenticated matched-request
page rendered its chat, MapLibre marker, and live-location controls with no page rendered its chat, MapLibre marker, and live-location controls with no
console errors or warnings after the foreground-service rollout. console errors or warnings after the foreground-service rollout.
@ -300,7 +304,7 @@ this verification window.
After isolating the load-test Compose project from staging, the complete After isolating the load-test Compose project from staging, the complete
regression was repeated against the final source on 2026-07-18: regression was repeated against the final source on 2026-07-18:
- `./scripts/test.sh` rebuilt the test image and passed all 149 Phoenix tests; - `./scripts/test.sh` rebuilt the test image and passed all 150 Phoenix tests;
`mix format --check-formatted` and `mix compile --warnings-as-errors` also `mix format --check-formatted` and `mix compile --warnings-as-errors` also
passed. passed.
- The staging and load-test Compose configurations both rendered successfully. - The staging and load-test Compose configurations both rendered successfully.

24
e2e/package-lock.json generated
View File

@ -8,9 +8,23 @@
"name": "who-need-help-e2e", "name": "who-need-help-e2e",
"version": "0.1.0", "version": "0.1.0",
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "^4.12.1",
"@playwright/test": "1.61.1" "@playwright/test": "1.61.1"
} }
}, },
"node_modules/@axe-core/playwright": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz",
"integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"axe-core": "~4.12.1"
},
"peerDependencies": {
"playwright-core": ">= 1.0.0"
}
},
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.61.1", "version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
@ -27,6 +41,16 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/axe-core": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz",
"integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==",
"dev": true,
"license": "MPL-2.0",
"engines": {
"node": ">=4"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",

View File

@ -7,6 +7,7 @@
"test:headed": "playwright test --headed" "test:headed": "playwright test --headed"
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "^4.12.1",
"@playwright/test": "1.61.1" "@playwright/test": "1.61.1"
} }
} }

View File

@ -0,0 +1,64 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
import { newIsolatedContext } from "./helpers";
async function expectAccessible(page: import("@playwright/test").Page): Promise<void> {
const results = await new AxeBuilder({ page }).analyze();
expect(
results.violations,
results.violations
.map(
(violation) =>
`${violation.id}: ${violation.help}\n${violation.nodes
.map((node) => ` ${node.target.join(" ")}: ${node.failureSummary}`)
.join("\n")}`,
)
.join("\n\n"),
).toEqual([]);
}
test("public pages are accessible by keyboard in light and dark themes", async ({ browser }) => {
const context = await newIsolatedContext(browser);
const page = await context.newPage();
await page.goto("/");
await page.keyboard.press("Tab");
await expect(page.getByRole("link", { name: "Skip to main content" })).toBeFocused();
await page.keyboard.press("Enter");
await expect(page.locator("#main-content")).toBeFocused();
for (const theme of ["light", "dark"] as const) {
await page.getByRole("button", { name: `Use ${theme} theme` }).click();
await expect(page.locator("html")).toHaveAttribute("data-theme", theme);
for (const path of ["/", "/safety", "/users/log-in", "/users/register"]) {
await page.goto(path);
await expectAccessible(page);
}
}
await context.close();
});
test("public navigation does not overflow narrow or wide viewports", async ({ browser }) => {
for (const viewport of [
{ width: 360, height: 800 },
{ width: 768, height: 1024 },
{ width: 1440, height: 900 },
]) {
const context = await newIsolatedContext(browser);
const page = await context.newPage();
await page.setViewportSize(viewport);
await page.goto("/");
const dimensions = await page.evaluate(() => ({
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
}));
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth);
await expect(page.getByRole("navigation", { name: "Primary navigation" })).toBeVisible();
await context.close();
}
});

View File

@ -4,6 +4,7 @@ import {
loginWithMagicLink, loginWithMagicLink,
registerAndConfirm, registerAndConfirm,
selectOptionContaining, selectOptionContaining,
waitForMapReady,
} from "./helpers"; } from "./helpers";
test("activity approval, privacy controls, reporting, and moderation work end to end", async ({ test("activity approval, privacy controls, reporting, and moderation work end to end", async ({
@ -100,6 +101,7 @@ test("activity approval, privacy controls, reporting, and moderation work end to
await safetyControls.getByRole("button", { name: "Send report" }).click(); await safetyControls.getByRole("button", { name: "Send report" }).click();
await expect(participant.page.getByText("Report sent to moderators.")).toBeVisible(); await expect(participant.page.getByText("Report sent to moderators.")).toBeVisible();
await waitForMapReady(participant.page);
await participant.page.goto("/categories/proposals"); await participant.page.goto("/categories/proposals");
await participant.page.getByLabel("Proposed category").fill("E2E quiet board games"); await participant.page.getByLabel("Proposed category").fill("E2E quiet board games");
await participant.page.getByLabel("Mode").selectOption("activity"); await participant.page.getByLabel("Mode").selectOption("activity");
@ -112,8 +114,10 @@ test("activity approval, privacy controls, reporting, and moderation work end to
).toBeVisible(); ).toBeVisible();
await participant.page.goto(activityURL); await participant.page.goto(activityURL);
await waitForMapReady(participant.page);
await participant.page.getByRole("button", { name: "Block organizer" }).click(); await participant.page.getByRole("button", { name: "Block organizer" }).click();
await expect(participant.page).toHaveURL(/\/activities$/); await expect(participant.page).toHaveURL(/\/activities$/);
await waitForMapReady(participant.page);
await participant.page.goto("/profile"); await participant.page.goto("/profile");
const blockedUsers = participant.page.getByRole("heading", { name: "Blocked users" }).locator(".."); const blockedUsers = participant.page.getByRole("heading", { name: "Blocked users" }).locator("..");
await expect(blockedUsers.getByText("E2E Activity Organizer")).toBeVisible(); await expect(blockedUsers.getByText("E2E Activity Organizer")).toBeVisible();
@ -191,6 +195,7 @@ test("activity approval, privacy controls, reporting, and moderation work end to
participant.page.getByText("Confirm your account and ensure it is active first."), participant.page.getByText("Confirm your account and ensure it is active first."),
).toBeVisible(); ).toBeVisible();
await waitForMapReady(organizer.page);
assertOrganizerClean(); assertOrganizerClean();
assertParticipantClean(); assertParticipantClean();
assertAdminClean(); assertAdminClean();

View File

@ -13,11 +13,6 @@ if (!mailpitURL || !baseURL) {
throw new Error("MAILPIT_URL and BASE_URL are required"); throw new Error("MAILPIT_URL and BASE_URL are required");
} }
const tilePng = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAQAAAAEAAQMAAABmvDolAAAAA1BMVEXu8vcBVSTwAAAAH0lEQVRo3u3BAQ0AAADCoPdPbQ43oAAAAAAAAAAAvg0hAAABfxmcpwAAAABJRU5ErkJggg==",
"base64",
);
export type AuthenticatedBrowser = { export type AuthenticatedBrowser = {
context: BrowserContext; context: BrowserContext;
page: Page; page: Page;
@ -25,17 +20,15 @@ export type AuthenticatedBrowser = {
}; };
export async function newIsolatedContext(browser: Browser): Promise<BrowserContext> { export async function newIsolatedContext(browser: Browser): Promise<BrowserContext> {
const context = await browser.newContext(); return browser.newContext({ serviceWorkers: "block" });
}
await context.route("http://tiles.e2e.invalid/**", async (route) => { export async function waitForMapReady(page: Page): Promise<void> {
await route.fulfill({ const map = page.locator(".aid-map");
status: 200,
contentType: "image/png",
body: tilePng,
});
});
return context; if ((await map.count()) > 0) {
await expect(map).toHaveAttribute("data-map-ready", "true", { timeout: 15_000 });
}
} }
export function captureBrowserFailures(page: Page): () => void { export function captureBrowserFailures(page: Page): () => void {
@ -52,11 +45,9 @@ export function captureBrowserFailures(page: Page): () => void {
}); });
page.on("requestfailed", (request) => { page.on("requestfailed", (request) => {
const url = request.url(); failures.push(
`requestfailed: ${request.method()} ${request.url()} ${request.failure()?.errorText}`,
if (!url.startsWith("http://tiles.e2e.invalid/")) { );
failures.push(`requestfailed: ${request.method()} ${url} ${request.failure()?.errorText}`);
}
}); });
return () => expect(failures, failures.join("\n")).toEqual([]); return () => expect(failures, failures.join("\n")).toEqual([]);

View File

@ -3,6 +3,7 @@ import {
captureBrowserFailures, captureBrowserFailures,
registerAndConfirm, registerAndConfirm,
selectOptionContaining, selectOptionContaining,
waitForMapReady,
} from "./helpers"; } from "./helpers";
test("two users complete a medicine handover with realtime chat and blind reviews", async ({ test("two users complete a medicine handover with realtime chat and blind reviews", async ({
@ -85,6 +86,7 @@ test("two users complete a medicine handover with realtime chat and blind review
helper.page.getByText("Review saved. It appears after both participants review."), helper.page.getByText("Review saved. It appears after both participants review."),
).toBeVisible(); ).toBeVisible();
await waitForMapReady(requester.page);
await requester.page.goto("/profile"); await requester.page.goto("/profile");
await expect(requester.page.getByText("None revealed yet.")).toBeVisible(); await expect(requester.page.getByText("None revealed yet.")).toBeVisible();
await expect(requester.page.getByText("Clear and safe coordination.")).toHaveCount(0); await expect(requester.page.getByText("Clear and safe coordination.")).toHaveCount(0);
@ -97,8 +99,10 @@ test("two users complete a medicine handover with realtime chat and blind review
requester.page.getByText("Review saved. It appears after both participants review."), requester.page.getByText("Review saved. It appears after both participants review."),
).toBeVisible(); ).toBeVisible();
await waitForMapReady(requester.page);
await requester.page.goto("/profile"); await requester.page.goto("/profile");
await expect(requester.page.getByText("Clear and safe coordination.")).toBeVisible(); await expect(requester.page.getByText("Clear and safe coordination.")).toBeVisible();
await waitForMapReady(helper.page);
await helper.page.goto("/profile"); await helper.page.goto("/profile");
await expect(helper.page.getByText("Reliable volunteer.")).toBeVisible(); await expect(helper.page.getByText("Reliable volunteer.")).toBeVisible();

View File

@ -0,0 +1,53 @@
import { expect, test } from "@playwright/test";
import { registerAndConfirm } from "./helpers";
test("a disconnected LiveView announces recovery and clears it after reconnect", async ({
browser,
request,
}) => {
const user = await registerAndConfirm(
browser,
request,
"resilience@example.invalid",
"E2E Resilience",
);
await user.page.goto("/requests");
await expect
.poll(() =>
user.page.evaluate(() => {
const liveSocket = (
window as typeof window & {
liveSocket?: { isConnected: () => boolean };
}
).liveSocket;
return liveSocket?.isConnected() ?? false;
}),
)
.toBe(true);
await user.context.setOffline(true);
await user.page.evaluate(() => {
const liveSocket = (
window as typeof window & {
liveSocket?: { socket?: { conn?: { close: () => void } } };
}
).liveSocket;
liveSocket?.socket?.conn?.close();
});
await expect(user.page.getByText("We can't find the internet")).toBeVisible();
await expect(user.page.getByText("Attempting to reconnect").first()).toBeVisible();
await user.context.setOffline(false);
await user.page.evaluate(() => {
const liveSocket = (
window as typeof window & {
liveSocket?: { connect: () => void };
}
).liveSocket;
liveSocket?.connect();
});
await expect(user.page.getByText("We can't find the internet")).toBeHidden();
await expect(user.page.getByRole("heading", { name: "Who needs help?" })).toBeVisible();
await user.context.close();
});

View File

@ -35,15 +35,15 @@ defmodule WhoNeedHelpWeb.Layouts do
def app(assigns) do def app(assigns) do
~H""" ~H"""
<header class="navbar sticky top-0 z-30 border-b border-base-300 bg-base-100/90 px-4 backdrop-blur sm:px-6 lg:px-8"> <header class="sticky top-0 z-30 flex flex-col items-stretch gap-2 border-b border-base-300 bg-base-100/90 px-4 py-2 backdrop-blur sm:flex-row sm:items-center sm:px-6 lg:px-8">
<div class="flex-1"> <div class="flex-1">
<a href="/" class="flex w-fit items-center gap-3"> <a href="/" class="flex w-fit items-center gap-3" aria-label={gettext("Who Need Help home")}>
<span class="grid size-9 place-items-center rounded-xl bg-success text-xl text-success-content">+</span> <span class="grid size-9 place-items-center rounded-xl bg-success text-xl text-success-content">+</span>
<span class="font-black tracking-tight">Who Need Help</span> <span class="font-black tracking-tight">Who Need Help</span>
</a> </a>
</div> </div>
<div class="flex-none"> <nav class="min-w-0 flex-none" aria-label={gettext("Primary navigation")}>
<ul class="flex items-center gap-1 px-1"> <ul class="flex max-w-full items-center gap-1 overflow-x-auto px-1 pb-1 sm:pb-0">
<li> <li>
<.link navigate={~p"/requests"} class="btn btn-ghost btn-sm"> <.link navigate={~p"/requests"} class="btn btn-ghost btn-sm">
{gettext("Requests")} {gettext("Requests")}
@ -91,19 +91,19 @@ defmodule WhoNeedHelpWeb.Layouts do
</li> </li>
<% end %> <% end %>
</ul> </ul>
</div> </nav>
</header> </header>
<main class="px-4 py-8 sm:px-6 lg:px-8"> <main id="main-content" tabindex="-1" class="px-4 py-8 outline-none sm:px-6 lg:px-8">
<div class="mx-auto max-w-7xl space-y-4"> <div class="mx-auto max-w-7xl space-y-4">
{render_slot(@inner_block)} {render_slot(@inner_block)}
</div> </div>
</main> </main>
<footer class="border-t border-base-300 px-4 py-6 text-center text-xs text-base-content/55"> <footer class="border-t border-base-300 px-4 py-6 text-center text-xs text-base-content/70">
<.link href={~p"/safety"} class="link">Safety rules</.link> <.link href={~p"/safety"} class="link">{gettext("Safety rules")}</.link>
<span class="mx-2">·</span> <span class="mx-2">·</span>
<.link href={~p"/feedback"} class="link">Build Week feedback</.link> <.link href={~p"/feedback"} class="link">{gettext("Build Week feedback")}</.link>
</footer> </footer>
<.flash_group flash={@flash} /> <.flash_group flash={@flash} />
@ -173,6 +173,7 @@ defmodule WhoNeedHelpWeb.Layouts do
class="flex p-2 cursor-pointer w-1/3" class="flex p-2 cursor-pointer w-1/3"
phx-click={JS.dispatch("phx:set-theme")} phx-click={JS.dispatch("phx:set-theme")}
data-phx-theme="system" data-phx-theme="system"
aria-label={gettext("Use system theme")}
> >
<.icon name="hero-computer-desktop-micro" class="size-4 opacity-75 hover:opacity-100" /> <.icon name="hero-computer-desktop-micro" class="size-4 opacity-75 hover:opacity-100" />
</button> </button>
@ -181,6 +182,7 @@ defmodule WhoNeedHelpWeb.Layouts do
class="flex p-2 cursor-pointer w-1/3" class="flex p-2 cursor-pointer w-1/3"
phx-click={JS.dispatch("phx:set-theme")} phx-click={JS.dispatch("phx:set-theme")}
data-phx-theme="light" data-phx-theme="light"
aria-label={gettext("Use light theme")}
> >
<.icon name="hero-sun-micro" class="size-4 opacity-75 hover:opacity-100" /> <.icon name="hero-sun-micro" class="size-4 opacity-75 hover:opacity-100" />
</button> </button>
@ -189,6 +191,7 @@ defmodule WhoNeedHelpWeb.Layouts do
class="flex p-2 cursor-pointer w-1/3" class="flex p-2 cursor-pointer w-1/3"
phx-click={JS.dispatch("phx:set-theme")} phx-click={JS.dispatch("phx:set-theme")}
data-phx-theme="dark" data-phx-theme="dark"
aria-label={gettext("Use dark theme")}
> >
<.icon name="hero-moon-micro" class="size-4 opacity-75 hover:opacity-100" /> <.icon name="hero-moon-micro" class="size-4 opacity-75 hover:opacity-100" />
</button> </button>

View File

@ -2,6 +2,7 @@
<html <html
lang={Gettext.get_locale(WhoNeedHelpWeb.Gettext)} lang={Gettext.get_locale(WhoNeedHelpWeb.Gettext)}
data-map-tile-url={Application.fetch_env!(:who_need_help, :map_tile_url)} data-map-tile-url={Application.fetch_env!(:who_need_help, :map_tile_url)}
class="overflow-x-clip"
> >
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
@ -44,7 +45,14 @@
</script> </script>
</head> </head>
<body> <body>
<ul class="menu menu-horizontal relative z-40 flex w-full items-center justify-end gap-2 bg-base-100 px-4 text-xs sm:px-6 lg:px-8"> <a
href="#main-content"
class="skip-link"
>
{gettext("Skip to main content")}
</a>
<nav aria-label={gettext("Account and language")}>
<ul class="menu menu-horizontal relative z-40 flex w-full flex-wrap items-center justify-end gap-2 bg-base-100 px-4 text-xs sm:px-6 lg:px-8">
<%= if @current_scope do %> <%= if @current_scope do %>
<li> <li>
<span class="flex flex-col items-end leading-tight"> <span class="flex flex-col items-end leading-tight">
@ -70,11 +78,30 @@
</li> </li>
<% end %> <% end %>
<li class="flex flex-row gap-0"> <li class="flex flex-row gap-0">
<a href="?locale=en" aria-label="English">EN</a> <a
<a href="?locale=uk" aria-label="Українська">UK</a> href="?locale=en"
<a href="?locale=ru" aria-label="Русский">RU</a> aria-label="English"
aria-current={Gettext.get_locale(WhoNeedHelpWeb.Gettext) == "en" && "page"}
>
EN
</a>
<a
href="?locale=uk"
aria-label="Українська"
aria-current={Gettext.get_locale(WhoNeedHelpWeb.Gettext) == "uk" && "page"}
>
UK
</a>
<a
href="?locale=ru"
aria-label="Русский"
aria-current={Gettext.get_locale(WhoNeedHelpWeb.Gettext) == "ru" && "page"}
>
RU
</a>
</li> </li>
</ul> </ul>
</nav>
{@inner_content} {@inner_content}
</body> </body>
</html> </html>

View File

@ -0,0 +1,14 @@
defmodule WhoNeedHelpWeb.E2eTileController do
use WhoNeedHelpWeb, :controller
@tile_png Base.decode64!(
"iVBORw0KGgoAAAANSUhEUgAAAQAAAAEAAQMAAABmvDolAAAAA1BMVEXu8vcBVSTwAAAAH0lEQVRo3u3BAQ0AAADCoPdPbQ43oAAAAAAAAAAAvg0hAAABfxmcpwAAAABJRU5ErkJggg=="
)
def show(conn, _params) do
conn
|> put_resp_content_type("image/png")
|> put_resp_header("cache-control", "public, max-age=3600")
|> send_resp(:ok, @tile_png)
end
end

View File

@ -25,13 +25,13 @@
</.link> </.link>
<% end %> <% end %>
</div> </div>
<p class="mt-5 text-sm text-base-content/55"> <p class="mt-5 text-sm text-base-content/70">
Not an emergency or medical service. In immediate danger, contact local emergency services. Not an emergency or medical service. In immediate danger, contact local emergency services.
</p> </p>
</div> </div>
<div class="relative"> <div class="relative">
<div class="absolute -inset-6 rounded-[2rem] bg-success/10 blur-2xl"></div> <div class="absolute inset-0 rounded-[2rem] bg-success/10 blur-2xl"></div>
<div class="relative rounded-[2rem] border border-base-300 bg-base-100 p-7 shadow-xl"> <div class="relative rounded-[2rem] border border-base-300 bg-base-100 p-7 shadow-xl">
<div class="mb-6 flex items-center justify-between"> <div class="mb-6 flex items-center justify-between">
<span class="text-sm font-semibold text-success">Medicine pickup</span> <span class="text-sm font-semibold text-success">Medicine pickup</span>

View File

@ -41,6 +41,14 @@ defmodule WhoNeedHelpWeb.Router do
get "/metrics", MetricsController, :show get "/metrics", MetricsController, :show
end end
if Application.compile_env(:who_need_help, :e2e_routes) do
scope "/__e2e__", WhoNeedHelpWeb do
pipe_through :api
get "/map-tile.png", E2eTileController, :show
end
end
scope "/mobile", WhoNeedHelpWeb do scope "/mobile", WhoNeedHelpWeb do
pipe_through :mobile pipe_through :mobile

View File

@ -29,6 +29,10 @@ E2E_UID=$(id -u)
export E2E_GID export E2E_GID
E2E_GID=$(id -g) E2E_GID=$(id -g)
export CODEX_SESSION_ID="${CODEX_SESSION_ID:-${CODEX_THREAD_ID:-local-e2e}}" export CODEX_SESSION_ID="${CODEX_SESSION_ID:-${CODEX_THREAD_ID:-local-e2e}}"
# Keep raster-map traffic inside the isolated application network. MapLibre
# performs tile fetches from a worker, so browser-context request interception
# is not a reliable network boundary for these requests.
export MAP_TILE_URL="http://proxy/__e2e__/map-tile.png?z={z}&x={x}&y={y}"
compose() { compose() {
docker compose \ docker compose \
@ -75,7 +79,7 @@ if [ -z "$web_container" ]; then
fi fi
docker exec "$web_container" /app/bin/who_need_help rpc "$admin_expression" docker exec "$web_container" /app/bin/who_need_help rpc "$admin_expression"
compose run --rm --no-deps e2e compose run --rm --no-deps e2e npx playwright test "$@"
echo "Isolated browser E2E passed." echo "Isolated browser E2E passed."
echo "Artifacts: $output_dir" echo "Artifacts: $output_dir"

View File

@ -28,7 +28,7 @@ TRAEFIK_ROUTER_RULE='PathPrefix(\`/\`)'
PHX_HOST=proxy PHX_HOST=proxy
PHX_SCHEME=http PHX_SCHEME=http
PHX_URL_PORT=80 PHX_URL_PORT=80
MAP_TILE_URL=http://tiles.e2e.invalid/{z}/{x}/{y}.png MAP_TILE_URL=http://proxy/__e2e__/map-tile.png?z={z}&x={x}&y={y}
POSTGRES_DB=who_need_help_e2e POSTGRES_DB=who_need_help_e2e
POSTGRES_USER=postgres POSTGRES_USER=postgres
POSTGRES_PASSWORD=$postgres_password POSTGRES_PASSWORD=$postgres_password

View File

@ -0,0 +1,10 @@
defmodule WhoNeedHelpWeb.RouterBoundaryTest do
use ExUnit.Case, async: true
test "the local raster fixture route is absent outside an E2E-compiled release" do
refute Enum.any?(
WhoNeedHelpWeb.Router.__routes__(),
&(&1.path == "/__e2e__/map-tile.png")
)
end
end