feat: add privacy-safe PWA offline fallback
This commit is contained in:
parent
710ad5e901
commit
7c1d24d356
|
|
@ -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
|
||||
|
||||
|
|
|
|||
43
e2e/playwright.pwa.config.ts
Normal file
43
e2e/playwright.pwa.config.ts
Normal 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
163
e2e/pwa/pwa.spec.ts
Normal 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([]);
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"} />
|
||||
|
|
|
|||
|
|
@ -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
99
priv/static/offline.html
Normal 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>
|
||||
|
|
@ -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
60
scripts/staging-pwa-e2e-run.sh
Executable 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"
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user