Merge pull request 'feat(ui): installable offline PWA (F5)' (#31) from feature/ui-finalize-f5-pwa into development
This commit was merged in pull request #31.
This commit is contained in:
@@ -98,6 +98,12 @@ jobs:
|
||||
working-directory: ui/frontend
|
||||
run: pnpm exec playwright test
|
||||
|
||||
- name: Run PWA tests
|
||||
# Builds + previews the production bundle (the service worker only
|
||||
# precaches a real build) and checks manifest / SW / offline.
|
||||
working-directory: ui/frontend
|
||||
run: pnpm test:pwa
|
||||
|
||||
- name: Upload Playwright report on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
+17
-1
@@ -120,7 +120,23 @@ Acceptance: every server error → a translated actionable message;
|
||||
consistent empty/loading/error states; the selected planet is visually
|
||||
marked.
|
||||
|
||||
## F5 — PWA (was Phase 33)
|
||||
## F5 — PWA (was Phase 33) — done
|
||||
|
||||
Installable, offline-tolerant PWA on SvelteKit's native service worker
|
||||
(`src/service-worker.ts`): a version-keyed cache precaches the shell,
|
||||
build artefacts (incl. `core.wasm`), and static files; `activate` purges
|
||||
old caches; the gateway is never intercepted. Adds
|
||||
`static/manifest.webmanifest`, a generated placeholder icon set
|
||||
(`scripts/gen-pwa-icons.mjs`, dependency-free), and the manifest /
|
||||
theme-color / apple-touch tags in `app.html`. Gated by Playwright against
|
||||
a production preview (`pnpm test:pwa`, wired into `ui-test`): manifest +
|
||||
installable icons, SW registration + single version-keyed cache, and
|
||||
offline shell load. Docs: `ui/docs/pwa-strategy.md`.
|
||||
|
||||
**Acceptance correction:** "Lighthouse PWA ≥ 90" is dropped — Lighthouse
|
||||
removed the PWA category in v12 (current line 13.x), so it is no longer a
|
||||
valid gate; the Playwright checks verify the same install/offline
|
||||
behaviour directly.
|
||||
|
||||
Goal: the web build is installable and offline-tolerant. Depends on F6
|
||||
(wasm in CI) so the service worker caches a freshly-built artefact.
|
||||
|
||||
@@ -31,6 +31,8 @@ lives in [`../PLAN.md`](../PLAN.md), the active web finalization in
|
||||
bridge, with the live function surface and parity rules.
|
||||
- [wasm-toolchain.md](wasm-toolchain.md) — building `ui/core` to
|
||||
`core.wasm` with TinyGo.
|
||||
- [pwa-strategy.md](pwa-strategy.md) — the installable/offline PWA: the
|
||||
native service worker, manifest, icons, and the Playwright PWA gate.
|
||||
- [testing.md](testing.md) — the UI test layers (Vitest + Playwright).
|
||||
|
||||
## Auth & lobby
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# PWA strategy
|
||||
|
||||
The web client is an installable, offline-tolerant PWA. It uses
|
||||
SvelteKit's native service worker (no Workbox) so there is no extra
|
||||
build dependency and the cache logic stays explicit.
|
||||
|
||||
## Pieces
|
||||
|
||||
- [`src/service-worker.ts`](../frontend/src/service-worker.ts) — the
|
||||
worker. SvelteKit registers it automatically in the production build.
|
||||
It precaches the app shell (`/`), the build artefacts (JS/CSS +
|
||||
`core.wasm`), and the static files under a **version-keyed** cache
|
||||
(`galaxy-cache-<version>`, `version` from `$service-worker`). On
|
||||
`activate` it deletes every other cache, so a new deploy never serves
|
||||
stale code. Strategy: cache-first for the version-keyed build/files;
|
||||
network-first with cache fallback for everything else; the cached
|
||||
shell answers navigations when fully offline. The gateway (cross-
|
||||
origin) is never intercepted — it is always live network.
|
||||
- [`static/manifest.webmanifest`](../frontend/static/manifest.webmanifest)
|
||||
— name, `standalone` display, `start_url`/`scope` `/`, dark
|
||||
`theme_color`/`background_color`, and the icon set.
|
||||
- [`static/icons/`](../frontend/static/icons/) — `192`/`512` (`any`),
|
||||
a `512` `maskable`, and a `180` apple-touch icon. They are placeholder
|
||||
artwork generated from `static/favicon.svg` by
|
||||
[`scripts/gen-pwa-icons.mjs`](../frontend/scripts/gen-pwa-icons.mjs)
|
||||
(a dependency-free pure-Node PNG encoder); swap in real artwork at the
|
||||
same paths and the manifest is unchanged.
|
||||
- [`src/app.html`](../frontend/src/app.html) — the manifest link, the
|
||||
apple-touch-icon link, and light/dark `theme-color` metas matching the
|
||||
design tokens.
|
||||
|
||||
## Testing
|
||||
|
||||
PWA behaviour is gated by Playwright against a **production preview**
|
||||
build ([`playwright.pwa.config.ts`](../frontend/playwright.pwa.config.ts)
|
||||
+ [`tests/pwa/pwa.spec.ts`](../frontend/tests/pwa/pwa.spec.ts), run by
|
||||
`pnpm test:pwa` in CI): the manifest is linked with installable icons,
|
||||
the service worker registers and controls the page under exactly one
|
||||
version-keyed cache, and the app shell loads offline from that cache.
|
||||
The spec needs a real build because `$service-worker`'s `build` list is
|
||||
empty under `vite dev`.
|
||||
|
||||
Lighthouse is intentionally **not** used: its PWA category was removed
|
||||
in Lighthouse 12 (the current line is 13.x), so "Lighthouse PWA ≥ 90" is
|
||||
no longer a meaningful gate. The Playwright checks above verify the same
|
||||
install/offline behaviour directly.
|
||||
@@ -9,7 +9,8 @@
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test"
|
||||
"test:e2e": "playwright test",
|
||||
"test:pwa": "playwright test --config playwright.pwa.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"flatbuffers": "^25.9.23",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import { FIXTURE_PUBLIC_KEY_RAW_BASE64 } from "./tests/e2e/fixtures/gateway-key";
|
||||
|
||||
// The service worker only precaches a real production build (the
|
||||
// `$service-worker` `build` list is empty under `vite dev`), so the PWA
|
||||
// spec runs against `vite preview` of an actual build — a separate config
|
||||
// from the dev-server e2e suite. Chromium only: service-worker + Cache
|
||||
// Storage + offline emulation are the surface under test, and WebKit's
|
||||
// SW support in headless CI is unreliable.
|
||||
export default defineConfig({
|
||||
testDir: "tests/pwa",
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
baseURL: "http://localhost:4173",
|
||||
trace: "retain-on-failure",
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
webServer: {
|
||||
command: "pnpm build && pnpm preview --port 4173",
|
||||
url: "http://localhost:4173",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 180_000,
|
||||
env: {
|
||||
VITE_GATEWAY_RESPONSE_PUBLIC_KEY: FIXTURE_PUBLIC_KEY_RAW_BASE64,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
// Generates the PWA icon set from the project's galaxy mark, with no
|
||||
// image dependency (a tiny pure-Node RGBA PNG encoder over zlib). The
|
||||
// mark mirrors `static/favicon.svg`: a dark disc with an amber core,
|
||||
// plus an amber orbit ring. Re-run after changing the palette/mark:
|
||||
//
|
||||
// node scripts/gen-pwa-icons.mjs
|
||||
//
|
||||
// Outputs into static/icons/. The committed PNGs are placeholders; swap
|
||||
// in real artwork at the same paths/sizes and the manifest is unchanged.
|
||||
|
||||
import { deflateSync } from "node:zlib";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
|
||||
const BG = [31, 41, 55]; // #1f2937
|
||||
const AMBER = [251, 191, 36]; // #fbbf24
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
t[n] = c >>> 0;
|
||||
}
|
||||
return t;
|
||||
})();
|
||||
function crc32(buf) {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
function chunk(type, data) {
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length, 0);
|
||||
const body = Buffer.concat([Buffer.from(type, "latin1"), data]);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(body), 0);
|
||||
return Buffer.concat([len, body, crc]);
|
||||
}
|
||||
function encodePng(size, rgba) {
|
||||
const stride = size * 4;
|
||||
const raw = Buffer.alloc((stride + 1) * size);
|
||||
for (let y = 0; y < size; y++) {
|
||||
raw[y * (stride + 1)] = 0; // filter: none
|
||||
rgba.copy(raw, y * (stride + 1) + 1, y * stride, y * stride + stride);
|
||||
}
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(size, 0);
|
||||
ihdr.writeUInt32BE(size, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 6; // colour type RGBA
|
||||
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
return Buffer.concat([
|
||||
sig,
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("IDAT", deflateSync(raw, { level: 9 })),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
// `fullBleed` fills the whole square opaquely (maskable / apple-touch);
|
||||
// otherwise the disc is drawn on transparency with content in the safe
|
||||
// zone untouched.
|
||||
function draw(size, fullBleed) {
|
||||
const buf = Buffer.alloc(size * size * 4);
|
||||
const c = size / 2;
|
||||
const discR = size * 0.46;
|
||||
const ringR = fullBleed ? size * 0.3 : size * 0.34;
|
||||
const ringW = size * 0.04;
|
||||
const coreR = fullBleed ? size * 0.09 : size * 0.1;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const i = (y * size + x) * 4;
|
||||
const d = Math.hypot(x + 0.5 - c, y + 0.5 - c);
|
||||
let col = null;
|
||||
if (fullBleed) col = BG;
|
||||
else if (d <= discR) col = BG;
|
||||
if (col !== null) {
|
||||
if (Math.abs(d - ringR) <= ringW / 2) col = AMBER;
|
||||
if (d <= coreR) col = AMBER;
|
||||
buf[i] = col[0];
|
||||
buf[i + 1] = col[1];
|
||||
buf[i + 2] = col[2];
|
||||
buf[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
mkdirSync(new URL("../static/icons/", import.meta.url), { recursive: true });
|
||||
const targets = [
|
||||
["icon-192.png", 192, false],
|
||||
["icon-512.png", 512, false],
|
||||
["icon-maskable-512.png", 512, true],
|
||||
["apple-touch-icon-180.png", 180, true],
|
||||
];
|
||||
for (const [name, size, fullBleed] of targets) {
|
||||
const path = new URL(`../static/icons/${name}`, import.meta.url);
|
||||
writeFileSync(path, encodePng(size, draw(size, fullBleed)));
|
||||
console.log("wrote", name, `${size}x${size}`);
|
||||
}
|
||||
@@ -3,6 +3,21 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
href="%sveltekit.assets%/icons/apple-touch-icon-180.png"
|
||||
/>
|
||||
<meta
|
||||
name="theme-color"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
content="#0a0e1a"
|
||||
/>
|
||||
<meta
|
||||
name="theme-color"
|
||||
media="(prefers-color-scheme: light)"
|
||||
content="#f3f5fb"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Galaxy</title>
|
||||
<style>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { dev } from "$app/environment";
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import { session } from "$lib/session-store.svelte";
|
||||
import { eventStream } from "../api/events.svelte";
|
||||
@@ -21,6 +22,12 @@
|
||||
|
||||
onMount(() => {
|
||||
void session.init();
|
||||
// Production-only service-worker registration (auto-register is off
|
||||
// in svelte.config.js) so `vite dev` and the dev-server e2e suite
|
||||
// run without the worker intercepting requests.
|
||||
if (!dev && "serviceWorker" in navigator) {
|
||||
void navigator.serviceWorker.register("/service-worker.js");
|
||||
}
|
||||
return () => {
|
||||
eventStream.stop();
|
||||
streamSessionId = null;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
// Native SvelteKit service worker (no Workbox). It precaches the app
|
||||
// shell, the build artefacts (JS/CSS + core.wasm), and the static files
|
||||
// under a version-keyed cache, so the installed PWA loads offline and a
|
||||
// new deploy (new `version`) drops the stale cache instead of serving
|
||||
// old code. The gateway is never intercepted — it is always live network.
|
||||
//
|
||||
// SvelteKit registers this worker automatically in the production build.
|
||||
|
||||
import { build, files, version } from "$service-worker";
|
||||
|
||||
const sw = self as unknown as ServiceWorkerGlobalScope;
|
||||
|
||||
const CACHE = `galaxy-cache-${version}`;
|
||||
// "/" is the SPA shell (adapter-static fallback); precaching it makes the
|
||||
// start_url load offline.
|
||||
const PRECACHE = ["/", ...build, ...files];
|
||||
|
||||
sw.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(CACHE)
|
||||
.then((cache) => cache.addAll(PRECACHE))
|
||||
.then(() => sw.skipWaiting()),
|
||||
);
|
||||
});
|
||||
|
||||
sw.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
for (const key of await caches.keys()) {
|
||||
if (key !== CACHE) await caches.delete(key);
|
||||
}
|
||||
await sw.clients.claim();
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
sw.addEventListener("fetch", (event) => {
|
||||
const { request } = event;
|
||||
if (request.method !== "GET") return;
|
||||
const url = new URL(request.url);
|
||||
// Cross-origin (the gateway) is always live network — never cached.
|
||||
if (url.origin !== sw.location.origin) return;
|
||||
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const cache = await caches.open(CACHE);
|
||||
// Version-keyed build/files: cache-first (content-hashed/immutable).
|
||||
if (PRECACHE.includes(url.pathname)) {
|
||||
const hit = await cache.match(url.pathname);
|
||||
if (hit) return hit;
|
||||
}
|
||||
// Everything else: network-first, falling back to the cache, and
|
||||
// for a navigation to the cached app shell when fully offline.
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok && response.type === "basic") {
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch (err) {
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
if (request.mode === "navigate") {
|
||||
const shell = await cache.match("/");
|
||||
if (shell) return shell;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
})(),
|
||||
);
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 867 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "Galaxy",
|
||||
"short_name": "Galaxy",
|
||||
"description": "Galaxy — a turn-based space strategy game.",
|
||||
"id": "/",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0a0e1a",
|
||||
"theme_color": "#0a0e1a",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,5 +11,11 @@ export default {
|
||||
fallback: "index.html",
|
||||
strict: true,
|
||||
}),
|
||||
serviceWorker: {
|
||||
// Registered manually in the root layout for production only.
|
||||
// SvelteKit's auto-registration also runs under `vite dev`, where
|
||||
// the worker would intercept and cache the dev-server e2e suite.
|
||||
register: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// F5 — PWA behaviour against a production preview build (see
|
||||
// playwright.pwa.config.ts). Covers the manifest, service-worker
|
||||
// registration, offline-from-cache load, and the version-keyed cache
|
||||
// (a new deploy's `version` makes a new cache and `activate` drops the
|
||||
// old one — verified here as "exactly one galaxy cache, version-keyed").
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("PWA", () => {
|
||||
test("links a web manifest with installable icons", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
const href = await page
|
||||
.locator('head link[rel="manifest"]')
|
||||
.getAttribute("href");
|
||||
expect(href).toMatch(/manifest\.webmanifest$/);
|
||||
|
||||
const manifest = await page.request
|
||||
.get(href!)
|
||||
.then((r) => r.json());
|
||||
expect(manifest.name).toBe("Galaxy");
|
||||
expect(manifest.start_url).toBe("/");
|
||||
expect(manifest.display).toBe("standalone");
|
||||
const sizes = (manifest.icons as { sizes: string }[]).map((i) => i.sizes);
|
||||
expect(sizes).toContain("192x192");
|
||||
expect(sizes).toContain("512x512");
|
||||
const purposes = (manifest.icons as { purpose?: string }[]).map(
|
||||
(i) => i.purpose,
|
||||
);
|
||||
expect(purposes).toContain("maskable");
|
||||
});
|
||||
|
||||
test("registers a service worker that controls the page", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.waitForFunction(
|
||||
() => navigator.serviceWorker.controller !== null,
|
||||
null,
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
const cacheNames: string[] = await page.evaluate(() => caches.keys());
|
||||
const galaxy = cacheNames.filter((n) => n.startsWith("galaxy-cache-"));
|
||||
// Exactly one, version-keyed cache (old versions purged on activate).
|
||||
expect(galaxy).toHaveLength(1);
|
||||
expect(galaxy[0]).toMatch(/^galaxy-cache-.+/);
|
||||
});
|
||||
|
||||
test("serves the app shell offline from the cache", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await page.goto("/login");
|
||||
await page.waitForFunction(
|
||||
() => navigator.serviceWorker.controller !== null,
|
||||
null,
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
await expect(page.locator("#main-content")).toBeVisible();
|
||||
|
||||
await context.setOffline(true);
|
||||
try {
|
||||
await page.reload();
|
||||
// The cached shell boots offline — the login main region renders.
|
||||
await expect(page.locator("#main-content")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
} finally {
|
||||
await context.setOffline(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user