From 8fbac386e734176248eb6b11161185ca4d70c16f Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Sun, 19 Jul 2026 03:32:25 +0300 Subject: [PATCH] test: harden browser accessibility and resilience --- .env.e2e.example | 2 +- README.md | 5 ++ assets/css/app.css | 51 ++++++++--- assets/js/hooks.js | 10 +++ compose.quality.yaml | 2 +- compose.yaml | 5 +- config/config.exs | 1 + config/prod.exs | 2 + docs/local-hardening-plan.md | 20 +++-- docs/verification.md | 22 +++-- e2e/package-lock.json | 24 +++++ e2e/package.json | 1 + e2e/tests/accessibility-resilience.spec.ts | 64 ++++++++++++++ e2e/tests/activity-moderation.spec.ts | 5 ++ e2e/tests/helpers.ts | 29 +++---- e2e/tests/mutual-aid.spec.ts | 4 + e2e/tests/zz-resilience.spec.ts | 53 +++++++++++ lib/who_need_help_web/components/layouts.ex | 21 +++-- .../components/layouts/root.html.heex | 87 ++++++++++++------- .../controllers/e2e_tile_controller.ex | 14 +++ .../controllers/page_html/home.html.heex | 4 +- lib/who_need_help_web/router.ex | 8 ++ scripts/e2e-run.sh | 6 +- scripts/ensure-local-e2e-env.sh | 2 +- .../router_boundary_test.exs | 10 +++ 25 files changed, 358 insertions(+), 94 deletions(-) create mode 100644 e2e/tests/accessibility-resilience.spec.ts create mode 100644 e2e/tests/zz-resilience.spec.ts create mode 100644 lib/who_need_help_web/controllers/e2e_tile_controller.ex create mode 100644 test/who_need_help_web/router_boundary_test.exs diff --git a/.env.e2e.example b/.env.e2e.example index b4fdb93..49a9a27 100644 --- a/.env.e2e.example +++ b/.env.e2e.example @@ -12,7 +12,7 @@ TRAEFIK_ROUTER_RULE='PathPrefix(`/`)' PHX_HOST=proxy PHX_SCHEME=http PHX_URL_PORT=80 -MAP_TILE_URL=http://tiles.e2e.invalid/{z}/{x}/{y}.png +MAP_TILE_URL=http://proxy/__e2e__/map-tile.png?z={z}&x={x}&y={y} POSTGRES_DB=who_need_help_e2e POSTGRES_USER=postgres diff --git a/README.md b/README.md index 8faf15e..c93f736 100644 --- a/README.md +++ b/README.md @@ -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 group chat, message-scoped reporting, blocking, privacy defaults, an unverified 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 `output/e2e/` directory on failure. The exact E2E project, database volume, and networks are removed automatically; the normal `who_need_help` Compose project diff --git a/assets/css/app.css b/assets/css/app.css index 1a03c9e..ddc3328 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -30,21 +30,21 @@ --color-base-100: oklch(30.33% 0.016 252.42); --color-base-200: oklch(25.26% 0.014 253.1); --color-base-300: oklch(20.15% 0.012 254.09); - --color-base-content: oklch(97.807% 0.029 256.847); - --color-primary: oklch(58% 0.233 277.117); - --color-primary-content: oklch(96% 0.018 272.314); - --color-secondary: oklch(58% 0.233 277.117); - --color-secondary-content: oklch(96% 0.018 272.314); + --color-base-content: #f3f7ff; + --color-primary: #a5b4fc; + --color-primary-content: #18162a; + --color-secondary: #a5b4fc; + --color-secondary-content: #18162a; --color-accent: oklch(60% 0.25 292.717); --color-accent-content: oklch(96% 0.016 293.756); --color-neutral: oklch(37% 0.044 257.287); --color-neutral-content: oklch(98% 0.003 247.858); --color-info: oklch(58% 0.158 241.966); --color-info-content: oklch(97% 0.013 236.62); - --color-success: oklch(60% 0.118 184.704); - --color-success-content: oklch(98% 0.014 180.72); - --color-warning: oklch(66% 0.179 58.318); - --color-warning-content: oklch(98% 0.022 95.277); + --color-success: oklch(75% 0.118 184.704); + --color-success-content: oklch(20% 0.014 180.72); + --color-warning: #f59e0b; + --color-warning-content: #241400; --color-error: oklch(58% 0.253 17.585); --color-error-content: oklch(96% 0.015 12.422); --radius-selector: 0.25rem; @@ -66,8 +66,8 @@ --color-base-200: oklch(96% 0.001 286.375); --color-base-300: oklch(92% 0.004 286.32); --color-base-content: oklch(21% 0.006 285.885); - --color-primary: oklch(70% 0.213 47.604); - --color-primary-content: oklch(98% 0.016 73.684); + --color-primary: #9a3412; + --color-primary-content: #ffffff; --color-secondary: oklch(55% 0.027 264.364); --color-secondary-content: oklch(98% 0.002 247.839); --color-accent: oklch(0% 0 0); @@ -76,10 +76,10 @@ --color-neutral-content: oklch(98% 0 0); --color-info: oklch(62% 0.214 259.815); --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-warning: oklch(66% 0.179 58.318); - --color-warning-content: oklch(98% 0.022 95.277); + --color-warning: #b45309; + --color-warning-content: #fffaf0; --color-error: oklch(58% 0.253 17.585); --color-error-content: oklch(96% 0.015 12.422); --radius-selector: 0.25rem; @@ -105,6 +105,29 @@ /* 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 { min-height: 22rem; border-radius: 1.25rem; diff --git a/assets/js/hooks.js b/assets/js/hooks.js index 1908390..34a4529 100644 --- a/assets/js/hooks.js +++ b/assets/js/hooks.js @@ -24,12 +24,22 @@ export const Hooks = { zoom: 5 }) 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() }, updated() { this.renderMarkers() }, destroyed() { + this.map?.off("dataloading", this.markLoading) + this.map?.off("idle", this.markReady) this.map?.remove() }, renderMarkers() { diff --git a/compose.quality.yaml b/compose.quality.yaml index 7294f00..7ce7a73 100644 --- a/compose.quality.yaml +++ b/compose.quality.yaml @@ -6,7 +6,7 @@ services: POSTGRES_USER: ${QUALITY_POSTGRES_USER:?Set QUALITY_POSTGRES_USER} POSTGRES_PASSWORD: ${QUALITY_POSTGRES_PASSWORD:?Set QUALITY_POSTGRES_PASSWORD} 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 timeout: 3s retries: 20 diff --git a/compose.yaml b/compose.yaml index df8f31e..587c493 100644 --- a/compose.yaml +++ b/compose.yaml @@ -52,7 +52,10 @@ services: POSTGRES_USER: ${POSTGRES_USER:?Set POSTGRES_USER in .env} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} 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 timeout: 3s retries: 20 diff --git a/config/config.exs b/config/config.exs index 510f3b7..c3b21fb 100644 --- a/config/config.exs +++ b/config/config.exs @@ -29,6 +29,7 @@ config :who_need_help, generators: [timestamp_type: :utc_datetime, binary_id: true], app_role: :web, codex_session_id: "not-configured", + e2e_routes: false, rate_limit_policies: %{}, map_tile_url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png" diff --git a/config/prod.exs b/config/prod.exs index 6451221..6095731 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -19,6 +19,8 @@ force_ssl_hosts = other -> raise "unsupported WNH_E2E_SSL_EXCLUDE_HOST: #{inspect(other)}" end +config :who_need_help, :e2e_routes, System.get_env("WNH_E2E_SSL_EXCLUDE_HOST") == "proxy" + config :who_need_help, WhoNeedHelpWeb.Endpoint, force_ssl: [ rewrite_on: [:x_forwarded_proto], diff --git a/docs/local-hardening-plan.md b/docs/local-hardening-plan.md index 92ba62e..3fb2789 100644 --- a/docs/local-hardening-plan.md +++ b/docs/local-hardening-plan.md @@ -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 | | Android UI | Two JVM unit-test files; no `androidTest` source set | Emulator instrumentation covers deep links, permissions, foreground tracking, notification Stop, lifecycle, and network failure | | CI and quality | No tracked CI workflow or static/security analysis dependencies | The same containerized gates pass locally and are represented in a validated CI workflow | -| Localization and accessibility | Navigation/error Gettext coverage exists, but most product copy is hard-coded English | EN/UK/RU catalogs cover product UI; extraction is current; automated accessibility, keyboard, responsive, and contrast checks pass | +| 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 | | Load and resilience | Public/readiness/heartbeat k6 profile exists | Authenticated writes, chat, tracking, reconnect, rolling replacement, and worker retry profiles pass without touching staging data | | Observability | Protected Prometheus text endpoint exists | Local Prometheus/Grafana/Alertmanager profile scrapes every replica and an induced isolated failure exercises alert delivery | | Backup | Validated local custom-format dump and restore drill exist | An encrypted artifact is uploaded to local S3-compatible MinIO and restored into a fresh database; corruption and interrupted-upload checks fail closed | | External boundaries | Mailpit and a fake GitHub strategy cover parts of SMTP/OAuth | Local protocol-level SMTP/OAuth mocks and the applicable push adapter boundary cover success, rejection, retry, replay, and timeout | -| Final regression | 149 Phoenix tests and manual cross-client evidence | Browser, Android, API, DB, WebSocket, backup, monitoring, failure, cleanup, docs, and clean Git are verified from the final commits | +| 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. @@ -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 test failures. Playwright traces, screenshots, video, JSON/HTML reports, and 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 granted location permission, same-origin deep-link routing across Activity 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, actionlint, all Compose renders, Helm lint, a Trivy scan of tracked source and 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 exact volume, networks, images, and source snapshot. - 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 containers with read-only root filesystems, dropped capabilities, and RuntimeDefault seccomp where configured. The cross-node PubSub probe passed. -- The remaining rows above are still pending. Browser accessibility, - responsive, offline, and reconnect checks remain part of their respective - later rows; this document is not a completion claim. +- The remaining rows above are still pending. In particular, complete EN/UK/RU + extraction and catalogs are not implied by the completed accessibility + checks; this document is not a completion claim. diff --git a/docs/verification.md b/docs/verification.md index 52509a0..350fce0 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -1,6 +1,6 @@ # 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. ## 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. | | 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. | -| 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. | | 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. | | 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. | | 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. | ## 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 on Elixir 1.20.2 and Erlang/OTP 29.0.3. - `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 threshold, actionlint 1.7.12, all four Compose renders, Helm lint, Trivy 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 13.6 release image each reported zero HIGH/CRITICAL findings under the configured gates. @@ -64,9 +64,13 @@ results from product limits and unknown production properties. containers also used read-only root filesystems and dropped capabilities. The cross-node probe observed all four BEAM nodes, and live/readiness returned `ok`/`ready`. Database counts remained `0 users / 0 help requests`. -- The committed browser suite was repeated after CSP and non-root E2E-runner - changes: its bootstrap test passed 1/1 and its three Chromium scenarios - passed 3/3. The uniquely named Compose volume and networks were removed. +- The committed browser suite passed its 1/1 bootstrap and all 6/6 Chromium + specs against a fresh PostGIS volume with two web and two worker replicas. + 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 page rendered its chat, MapLibre marker, and live-location controls with no 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 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 passed. - The staging and load-test Compose configurations both rendered successfully. diff --git a/e2e/package-lock.json b/e2e/package-lock.json index b4644b8..7ae7094 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -8,9 +8,23 @@ "name": "who-need-help-e2e", "version": "0.1.0", "devDependencies": { + "@axe-core/playwright": "^4.12.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": { "version": "1.61.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", @@ -27,6 +41,16 @@ "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": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", diff --git a/e2e/package.json b/e2e/package.json index fe49536..dfb6c60 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -7,6 +7,7 @@ "test:headed": "playwright test --headed" }, "devDependencies": { + "@axe-core/playwright": "^4.12.1", "@playwright/test": "1.61.1" } } diff --git a/e2e/tests/accessibility-resilience.spec.ts b/e2e/tests/accessibility-resilience.spec.ts new file mode 100644 index 0000000..a2bdf0e --- /dev/null +++ b/e2e/tests/accessibility-resilience.spec.ts @@ -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 { + 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(); + } +}); diff --git a/e2e/tests/activity-moderation.spec.ts b/e2e/tests/activity-moderation.spec.ts index 6424764..cb0db9d 100644 --- a/e2e/tests/activity-moderation.spec.ts +++ b/e2e/tests/activity-moderation.spec.ts @@ -4,6 +4,7 @@ import { loginWithMagicLink, registerAndConfirm, selectOptionContaining, + waitForMapReady, } from "./helpers"; 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 expect(participant.page.getByText("Report sent to moderators.")).toBeVisible(); + await waitForMapReady(participant.page); await participant.page.goto("/categories/proposals"); await participant.page.getByLabel("Proposed category").fill("E2E quiet board games"); await participant.page.getByLabel("Mode").selectOption("activity"); @@ -112,8 +114,10 @@ test("activity approval, privacy controls, reporting, and moderation work end to ).toBeVisible(); await participant.page.goto(activityURL); + await waitForMapReady(participant.page); await participant.page.getByRole("button", { name: "Block organizer" }).click(); await expect(participant.page).toHaveURL(/\/activities$/); + await waitForMapReady(participant.page); await participant.page.goto("/profile"); const blockedUsers = participant.page.getByRole("heading", { name: "Blocked users" }).locator(".."); 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."), ).toBeVisible(); + await waitForMapReady(organizer.page); assertOrganizerClean(); assertParticipantClean(); assertAdminClean(); diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts index 9d6f1ca..56c3a88 100644 --- a/e2e/tests/helpers.ts +++ b/e2e/tests/helpers.ts @@ -13,11 +13,6 @@ if (!mailpitURL || !baseURL) { throw new Error("MAILPIT_URL and BASE_URL are required"); } -const tilePng = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAQAAAAEAAQMAAABmvDolAAAAA1BMVEXu8vcBVSTwAAAAH0lEQVRo3u3BAQ0AAADCoPdPbQ43oAAAAAAAAAAAvg0hAAABfxmcpwAAAABJRU5ErkJggg==", - "base64", -); - export type AuthenticatedBrowser = { context: BrowserContext; page: Page; @@ -25,17 +20,15 @@ export type AuthenticatedBrowser = { }; export async function newIsolatedContext(browser: Browser): Promise { - const context = await browser.newContext(); + return browser.newContext({ serviceWorkers: "block" }); +} - await context.route("http://tiles.e2e.invalid/**", async (route) => { - await route.fulfill({ - status: 200, - contentType: "image/png", - body: tilePng, - }); - }); +export async function waitForMapReady(page: Page): Promise { + const map = page.locator(".aid-map"); - return context; + if ((await map.count()) > 0) { + await expect(map).toHaveAttribute("data-map-ready", "true", { timeout: 15_000 }); + } } export function captureBrowserFailures(page: Page): () => void { @@ -52,11 +45,9 @@ export function captureBrowserFailures(page: Page): () => void { }); page.on("requestfailed", (request) => { - const url = request.url(); - - if (!url.startsWith("http://tiles.e2e.invalid/")) { - failures.push(`requestfailed: ${request.method()} ${url} ${request.failure()?.errorText}`); - } + failures.push( + `requestfailed: ${request.method()} ${request.url()} ${request.failure()?.errorText}`, + ); }); return () => expect(failures, failures.join("\n")).toEqual([]); diff --git a/e2e/tests/mutual-aid.spec.ts b/e2e/tests/mutual-aid.spec.ts index 4f89111..8439b0a 100644 --- a/e2e/tests/mutual-aid.spec.ts +++ b/e2e/tests/mutual-aid.spec.ts @@ -3,6 +3,7 @@ import { captureBrowserFailures, registerAndConfirm, selectOptionContaining, + waitForMapReady, } from "./helpers"; 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."), ).toBeVisible(); + await waitForMapReady(requester.page); await requester.page.goto("/profile"); await expect(requester.page.getByText("None revealed yet.")).toBeVisible(); await expect(requester.page.getByText("Clear and safe coordination.")).toHaveCount(0); @@ -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."), ).toBeVisible(); + await waitForMapReady(requester.page); await requester.page.goto("/profile"); await expect(requester.page.getByText("Clear and safe coordination.")).toBeVisible(); + await waitForMapReady(helper.page); await helper.page.goto("/profile"); await expect(helper.page.getByText("Reliable volunteer.")).toBeVisible(); diff --git a/e2e/tests/zz-resilience.spec.ts b/e2e/tests/zz-resilience.spec.ts new file mode 100644 index 0000000..60b9eed --- /dev/null +++ b/e2e/tests/zz-resilience.spec.ts @@ -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(); +}); diff --git a/lib/who_need_help_web/components/layouts.ex b/lib/who_need_help_web/components/layouts.ex index 01bd2f4..75f7940 100644 --- a/lib/who_need_help_web/components/layouts.ex +++ b/lib/who_need_help_web/components/layouts.ex @@ -35,15 +35,15 @@ defmodule WhoNeedHelpWeb.Layouts do def app(assigns) do ~H""" -