test: harden browser accessibility and resilience
This commit is contained in:
parent
477517527f
commit
8fbac386e7
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
24
e2e/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
64
e2e/tests/accessibility-resilience.spec.ts
Normal file
64
e2e/tests/accessibility-resilience.spec.ts
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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([]);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
53
e2e/tests/zz-resilience.spec.ts
Normal file
53
e2e/tests/zz-resilience.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
14
lib/who_need_help_web/controllers/e2e_tile_controller.ex
Normal file
14
lib/who_need_help_web/controllers/e2e_tile_controller.ex
Normal 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
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
10
test/who_need_help_web/router_boundary_test.exs
Normal file
10
test/who_need_help_web/router_boundary_test.exs
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user