feat: add privacy-safe PWA offline fallback

This commit is contained in:
SimpleTest 2026-07-20 00:33:32 +03:00
parent 710ad5e901
commit 7c1d24d356
10 changed files with 435 additions and 20 deletions

View File

@ -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

View File

@ -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"],
},
},
],
});

163
e2e/pwa/pwa.spec.ts Normal file
View File

@ -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<void> {
await page.evaluate(async () => {
await navigator.serviceWorker.ready;
if (!navigator.serviceWorker.controller) {
await new Promise<void>((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<string, [number, number]> = {};
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([]);
});

View File

@ -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

View File

@ -8,7 +8,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<meta name="theme-color" content="#16a34a" />
<meta name="theme-color" content="#047b68" />
<meta name="color-scheme" content="light dark" />
<.live_title default="Who Need Help" suffix=" · Who Need Help" phx-no-format>{assigns[:page_title]}</.live_title>
<link rel="manifest" href={~p"/manifest.webmanifest"} />

View File

@ -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

99
priv/static/offline.html Normal file
View File

@ -0,0 +1,99 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#047b68" />
<meta name="color-scheme" content="light dark" />
<title>Offline · Who Need Help</title>
<style>
:root {
color-scheme: light dark;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
background: #f7f8f7;
color: #1c1d20;
}
body {
display: grid;
min-height: 100vh;
margin: 0;
place-items: center;
}
main {
width: min(31rem, calc(100% - 3rem));
text-align: center;
}
img {
width: 5rem;
height: 5rem;
}
h1 {
margin: 1.5rem 0 0.75rem;
font-size: clamp(2rem, 8vw, 3.5rem);
line-height: 1;
}
p {
margin: 0 auto;
color: #61636a;
font-size: 1.0625rem;
line-height: 1.6;
}
button {
margin-top: 1.75rem;
border: 0;
border-radius: 0.5rem;
padding: 0.8rem 1.15rem;
background: #a53a16;
color: #fff;
font: inherit;
font-weight: 700;
cursor: pointer;
}
button:focus-visible {
outline: 3px solid #047b68;
outline-offset: 3px;
}
.translations {
margin-top: 1.25rem;
font-size: 0.875rem;
}
@media (prefers-color-scheme: dark) {
:root {
background: #151618;
color: #f5f5f4;
}
p {
color: #b9bbc1;
}
}
</style>
</head>
<body>
<main aria-labelledby="offline-title">
<img src="/images/logo.svg" alt="" width="80" height="80" />
<h1 id="offline-title">You're offline</h1>
<p>
Reconnect to view current requests, messages, maps, and live locations.
We do not save private pages on this device.
</p>
<form method="get">
<button type="submit">Try again</button>
</form>
<p class="translations">
Немає з’єднання · Нет соединения
</p>
</main>
</body>
</html>

View File

@ -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
}

60
scripts/staging-pwa-e2e-run.sh Executable file
View File

@ -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"

View File

@ -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",