feat(ui): installable offline PWA (F5) #31
@@ -98,6 +98,12 @@ jobs:
|
|||||||
working-directory: ui/frontend
|
working-directory: ui/frontend
|
||||||
run: pnpm exec playwright test
|
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
|
- name: Upload Playwright report on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v4
|
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
|
consistent empty/loading/error states; the selected planet is visually
|
||||||
marked.
|
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
|
Goal: the web build is installable and offline-tolerant. Depends on F6
|
||||||
(wasm in CI) so the service worker caches a freshly-built artefact.
|
(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.
|
bridge, with the live function surface and parity rules.
|
||||||
- [wasm-toolchain.md](wasm-toolchain.md) — building `ui/core` to
|
- [wasm-toolchain.md](wasm-toolchain.md) — building `ui/core` to
|
||||||
`core.wasm` with TinyGo.
|
`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).
|
- [testing.md](testing.md) — the UI test layers (Vitest + Playwright).
|
||||||
|
|
||||||
## Auth & lobby
|
## 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",
|
"preview": "vite preview",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test",
|
||||||
|
"test:pwa": "playwright test --config playwright.pwa.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"flatbuffers": "^25.9.23",
|
"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>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Galaxy</title>
|
<title>Galaxy</title>
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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