diff --git a/e2e/Dockerfile b/e2e/Dockerfile index b5e4197..83fb59d 100644 --- a/e2e/Dockerfile +++ b/e2e/Dockerfile @@ -5,9 +5,10 @@ WORKDIR /work COPY --chown=pwuser:pwuser package.json package-lock.json ./ RUN npm ci --ignore-scripts -COPY --chown=pwuser:pwuser playwright.config.ts playwright.bootstrap.config.ts ./ +COPY --chown=pwuser:pwuser playwright.config.ts playwright.bootstrap.config.ts playwright.pwa.config.ts ./ COPY --chown=pwuser:pwuser tests tests COPY --chown=pwuser:pwuser setup setup +COPY --chown=pwuser:pwuser pwa pwa USER pwuser diff --git a/e2e/playwright.pwa.config.ts b/e2e/playwright.pwa.config.ts new file mode 100644 index 0000000..6fe7320 --- /dev/null +++ b/e2e/playwright.pwa.config.ts @@ -0,0 +1,43 @@ +import { defineConfig, devices } from "@playwright/test"; + +const baseURL = process.env.BASE_URL; + +if (!baseURL || new URL(baseURL).protocol !== "https:") { + throw new Error("BASE_URL must be an HTTPS origin for the PWA test"); +} + +export default defineConfig({ + testDir: "./pwa", + outputDir: "./output/pwa-test-results", + fullyParallel: false, + workers: 1, + forbidOnly: true, + retries: 0, + timeout: 90_000, + expect: { + timeout: 15_000, + }, + reporter: [ + ["line"], + ["html", { outputFolder: "./output/pwa-html-report", open: "never" }], + ["json", { outputFile: "./output/pwa-results.json" }], + ], + use: { + baseURL, + ignoreHTTPSErrors: false, + serviceWorkers: "allow", + actionTimeout: 10_000, + navigationTimeout: 20_000, + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [ + { + name: "chromium-pwa", + use: { + ...devices["Desktop Chrome"], + }, + }, + ], +}); diff --git a/e2e/pwa/pwa.spec.ts b/e2e/pwa/pwa.spec.ts new file mode 100644 index 0000000..3559f55 --- /dev/null +++ b/e2e/pwa/pwa.spec.ts @@ -0,0 +1,163 @@ +import { expect, test } from "@playwright/test"; + +const CACHE = "who-need-help-static-v3"; +const STALE_CACHE = "who-need-help-static-v0"; +const SHELL_PATHS = [ + "/offline.html", + "/manifest.webmanifest", + "/assets/css/app.css", + "/assets/js/app.js", + "/images/logo.svg", + "/images/pwa-192.png", + "/images/pwa-512.png", + "/images/pwa-maskable-512.png", +]; + +async function waitForController(page: import("@playwright/test").Page): Promise { + await page.evaluate(async () => { + await navigator.serviceWorker.ready; + + if (!navigator.serviceWorker.controller) { + await new Promise((resolve) => { + navigator.serviceWorker.addEventListener("controllerchange", () => resolve(), { + once: true, + }); + }); + } + }); +} + +test("public HTTPS PWA installs, updates its cache, and falls back safely offline", async ({ + context, + page, + request, +}) => { + const consoleFailures: string[] = []; + + page.on("console", (message) => { + if (["warning", "error"].includes(message.type())) { + consoleFailures.push(`${message.type()}: ${message.text()}`); + } + }); + page.on("pageerror", (error) => consoleFailures.push(`pageerror: ${error.message}`)); + + const manifestResponse = await request.get("/manifest.webmanifest"); + expect(manifestResponse.status()).toBe(200); + expect(manifestResponse.headers()["content-type"]).toContain("application/manifest+json"); + + const manifest = (await manifestResponse.json()) as { + name: string; + short_name: string; + id: string; + start_url: string; + scope: string; + display: string; + icons: Array<{ src: string; sizes: string; type: string; purpose: string }>; + }; + + expect(manifest).toMatchObject({ + name: "Who Need Help", + short_name: "Who Need Help", + id: "/", + start_url: "/", + scope: "/", + display: "standalone", + }); + expect(manifest.icons).toEqual( + expect.arrayContaining([ + expect.objectContaining({ sizes: "192x192", purpose: "any" }), + expect.objectContaining({ sizes: "512x512", purpose: "any" }), + expect.objectContaining({ sizes: "512x512", purpose: "maskable" }), + expect.objectContaining({ sizes: "1024x1024", purpose: "maskable" }), + expect.objectContaining({ sizes: "any", purpose: "monochrome" }), + ]), + ); + + await page.goto("/"); + await waitForController(page); + + const iconDimensions = await page.evaluate(async () => { + const dimensions: Record = {}; + + for (const path of [ + "/images/pwa-192.png", + "/images/pwa-512.png", + "/images/pwa-maskable-512.png", + "/images/pwa-maskable-1024.png", + ]) { + const response = await fetch(path); + const image = await createImageBitmap(await response.blob()); + dimensions[path] = [image.width, image.height]; + image.close(); + } + + return dimensions; + }); + expect(iconDimensions).toEqual({ + "/images/pwa-192.png": [192, 192], + "/images/pwa-512.png": [512, 512], + "/images/pwa-maskable-512.png": [512, 512], + "/images/pwa-maskable-1024.png": [1024, 1024], + }); + + await expect + .poll(() => page.evaluate(() => caches.keys())) + .toContain(CACHE); + + const cachedShell = await page.evaluate(async (cacheName) => { + const cache = await caches.open(cacheName); + return (await cache.keys()).map((entry) => new URL(entry.url).pathname).sort(); + }, CACHE); + expect(cachedShell).toEqual(expect.arrayContaining(SHELL_PATHS)); + + await page.goto("/safety"); + await page.goto("/users/log-in"); + const cachedDocuments = await page.evaluate(async () => { + const entries = await Promise.all( + (await caches.keys()).map(async (cacheName) => { + const cache = await caches.open(cacheName); + return Promise.all( + (await cache.keys()).map(async (entry) => ({ + path: new URL(entry.url).pathname, + destination: entry.destination, + contentType: (await cache.match(entry))?.headers.get("content-type") ?? "", + })), + ); + }), + ); + + return entries.flat().filter((entry) => entry.contentType.includes("text/html")); + }); + expect(cachedDocuments).toEqual([ + expect.objectContaining({ path: "/offline.html" }), + ]); + + await page.evaluate((staleCache) => caches.open(staleCache), STALE_CACHE); + expect(await page.evaluate(() => caches.keys())).toContain(STALE_CACHE); + await page.evaluate(async () => { + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all(registrations.map((registration) => registration.unregister())); + }); + await page.close(); + + const updatedPage = await context.newPage(); + await updatedPage.goto("/"); + await waitForController(updatedPage); + await expect + .poll(() => updatedPage.evaluate(() => caches.keys())) + .not.toContain(STALE_CACHE); + expect(await updatedPage.evaluate(() => caches.keys())).toContain(CACHE); + + await context.setOffline(true); + const offlineResponse = await updatedPage.goto("/requests"); + expect(offlineResponse?.status()).toBe(200); + await expect(updatedPage.getByRole("heading", { name: "You're offline" })).toBeVisible(); + await expect(updatedPage.getByRole("button", { name: "Try again" })).toBeVisible(); + await expect(updatedPage.locator("img[src='/images/logo.svg']")).toBeVisible(); + expect(await updatedPage.evaluate(() => navigator.onLine)).toBe(false); + + await context.setOffline(false); + await updatedPage.reload(); + await expect(updatedPage.getByRole("heading", { name: "Log in", exact: true })).toBeVisible(); + expect(consoleFailures).toEqual([]); +}); diff --git a/lib/who_need_help_web.ex b/lib/who_need_help_web.ex index a81e1cf..b06699b 100644 --- a/lib/who_need_help_web.ex +++ b/lib/who_need_help_web.ex @@ -18,7 +18,7 @@ defmodule WhoNeedHelpWeb do """ def static_paths, - do: ~w(assets fonts images favicon.ico manifest.webmanifest robots.txt sw.js) + do: ~w(assets fonts images favicon.ico manifest.webmanifest offline.html robots.txt sw.js) def router do quote do diff --git a/lib/who_need_help_web/components/layouts/root.html.heex b/lib/who_need_help_web/components/layouts/root.html.heex index 16b8a47..84843ce 100644 --- a/lib/who_need_help_web/components/layouts/root.html.heex +++ b/lib/who_need_help_web/components/layouts/root.html.heex @@ -8,7 +8,7 @@ - + <.live_title default="Who Need Help" suffix=" · Who Need Help" phx-no-format>{assigns[:page_title]} diff --git a/lib/who_need_help_web/endpoint.ex b/lib/who_need_help_web/endpoint.ex index 0f87e81..7cf5381 100644 --- a/lib/who_need_help_web/endpoint.ex +++ b/lib/who_need_help_web/endpoint.ex @@ -25,7 +25,7 @@ defmodule WhoNeedHelpWeb.Endpoint do from: :who_need_help, gzip: not code_reloading?, only: WhoNeedHelpWeb.static_paths(), - only_matching: ~w(favicon manifest robots sw), + only_matching: ~w(favicon manifest offline robots sw), raise_on_missing_only: code_reloading? # Code reloading can be explicitly enabled under the diff --git a/priv/static/offline.html b/priv/static/offline.html new file mode 100644 index 0000000..c38b456 --- /dev/null +++ b/priv/static/offline.html @@ -0,0 +1,99 @@ + + + + + + + + Offline · Who Need Help + + + +
+ +

You're offline

+

+ Reconnect to view current requests, messages, maps, and live locations. + We do not save private pages on this device. +

+
+ +
+

+ Немає з’єднання · Нет соединения +

+
+ + diff --git a/priv/static/sw.js b/priv/static/sw.js index 23877d6..78949a1 100644 --- a/priv/static/sw.js +++ b/priv/static/sw.js @@ -1,41 +1,75 @@ -const CACHE = "who-need-help-static-v2" +const CACHE_PREFIX = "who-need-help-static-" +const CACHE = `${CACHE_PREFIX}v3` +const OFFLINE_URL = "/offline.html" const SHELL = [ + OFFLINE_URL, + "/manifest.webmanifest", "/assets/css/app.css", "/assets/js/app.js", "/images/logo.svg", "/images/pwa-192.png", - "/images/pwa-512.png" + "/images/pwa-512.png", + "/images/pwa-maskable-512.png" ] self.addEventListener("install", event => { event.waitUntil( - caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting()) + caches + .open(CACHE) + .then(cache => cache.addAll(SHELL)) + .then(() => self.skipWaiting()) ) }) self.addEventListener("activate", event => { event.waitUntil( - caches.keys().then(keys => - Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key))) - ).then(() => self.clients.claim()) + caches + .keys() + .then(keys => + Promise.all( + keys + .filter(key => key.startsWith(CACHE_PREFIX) && key !== CACHE) + .map(key => caches.delete(key)) + ) + ) + .then(() => self.clients.claim()) ) }) self.addEventListener("fetch", event => { const url = new URL(event.request.url) + const sameOrigin = url.origin === self.location.origin const isStatic = - url.origin === self.location.origin && + sameOrigin && (url.pathname.startsWith("/assets/") || url.pathname.startsWith("/images/")) - if (event.request.method === "GET" && isStatic) { + if (event.request.method !== "GET" || !sameOrigin) return + + if (event.request.mode === "navigate") { event.respondWith( - caches.match(event.request).then(cached => - cached || fetch(event.request).then(response => { - const copy = response.clone() - caches.open(CACHE).then(cache => cache.put(event.request, copy)) - return response - }) - ) + fetch(event.request).catch(async () => { + const fallback = await caches.match(OFFLINE_URL) + return fallback || Response.error() + }) ) + return + } + + if (isStatic) { + event.respondWith(cacheFirst(event)) } }) + +async function cacheFirst(event) { + const cached = await caches.match(event.request) + if (cached) return cached + + const response = await fetch(event.request) + + if (response.ok) { + const copy = response.clone() + event.waitUntil(caches.open(CACHE).then(cache => cache.put(event.request, copy))) + } + + return response +} diff --git a/scripts/staging-pwa-e2e-run.sh b/scripts/staging-pwa-e2e-run.sh new file mode 100755 index 0000000..11f796e --- /dev/null +++ b/scripts/staging-pwa-e2e-run.sh @@ -0,0 +1,60 @@ +#!/bin/bash +set -euo pipefail +umask 077 + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +cd "$ROOT" + +ENV_FILE="$ROOT/.env" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing $ENV_FILE." >&2 + exit 1 +fi + +set -a +# shellcheck source=/dev/null +. "$ENV_FILE" +set +a + +: "${PHX_HOST:?PHX_HOST is missing from .env}" +: "${PHX_SCHEME:?PHX_SCHEME is missing from .env}" +: "${PHX_URL_PORT:?PHX_URL_PORT is missing from .env}" + +if [[ "$PHX_SCHEME" != https ]]; then + echo "The PWA browser check requires PHX_SCHEME=https." >&2 + exit 1 +fi + +base_url="${PHX_SCHEME}://${PHX_HOST}" + +if [[ "$PHX_URL_PORT" != 443 ]]; then + base_url="${base_url}:${PHX_URL_PORT}" +fi + +run_id="$(date -u +%Y%m%d%H%M%S)-$$" +output_dir="$ROOT/output/staging-pwa-e2e/$run_id" +mkdir -p "$output_dir" +chmod 700 "$ROOT/output" "$ROOT/output/staging-pwa-e2e" "$output_dir" + +curl --fail --silent --show-error "$base_url/healthz/ready" \ + >"$output_dir/public-ready.json" +curl --fail --silent --show-error "$base_url/sw.js" \ + >"$output_dir/public-service-worker.js" +curl --fail --silent --show-error "$base_url/manifest.webmanifest" \ + >"$output_dir/public-manifest.webmanifest" + +docker build --tag who-need-help-e2e-tests:local e2e \ + >"$output_dir/browser-build.log" + +docker run --rm \ + --network host \ + --user "$(id -u):$(id -g)" \ + --env "BASE_URL=$base_url" \ + --env HOME=/tmp \ + --volume "$output_dir:/work/output" \ + who-need-help-e2e-tests:local \ + npx playwright test --config=playwright.pwa.config.ts + +echo "Public HTTPS PWA verification passed: $base_url" +echo "Evidence: $output_dir" diff --git a/test/who_need_help_web/controllers/page_controller_test.exs b/test/who_need_help_web/controllers/page_controller_test.exs index 0b0f033..e88d1d7 100644 --- a/test/who_need_help_web/controllers/page_controller_test.exs +++ b/test/who_need_help_web/controllers/page_controller_test.exs @@ -75,11 +75,26 @@ defmodule WhoNeedHelpWeb.PageControllerTest do conn = get(conn, "/sw.js") body = response(conn, 200) - assert body =~ ~s(const CACHE = "who-need-help-static-v2") + assert body =~ ~s(const CACHE_PREFIX = "who-need-help-static-") + assert body =~ ~s(const CACHE = `${CACHE_PREFIX}v3`) + assert body =~ ~s(const OFFLINE_URL = "/offline.html") + assert body =~ ~s(event.request.mode === "navigate") + assert body =~ ~S|key.startsWith(CACHE_PREFIX) && key !== CACHE| assert body =~ "self.skipWaiting()" assert body =~ "self.clients.claim()" end + test "GET /offline.html", %{conn: conn} do + conn = get(conn, "/offline.html") + html = html_response(conn, 200) + document = LazyHTML.from_document(html) + + assert Enum.count(LazyHTML.query(document, "main[aria-labelledby='offline-title']")) == 1 + assert Enum.count(LazyHTML.query(document, "img[src='/images/logo.svg'][alt='']")) == 1 + assert html =~ "You're offline" + assert html =~ "Try again" + end + test "brand raster fallbacks are served", %{conn: conn} do for path <- [ "/images/favicon-48.png",