Stage 3: offline/PWA foundation — SW registration + minimal shell SW
- Normalize branch to main - public/sw.js: hand-written, tiny (install precache + activate cleanup + fetch navigation/assets cache-first + package passthrough /latest.json /editions/** C-21 + message SKIP_WAITING). Safe population: open+addAll atomically, delete partial on failure; versioned cache lumen-shell-<version>-<gitHash> deterministic per build (injected via Vite plugin, not static); no skipWaiting in install (next full start per ARCH §8), no IDB/sync/dataset/B-5 - public/fallback.html: Ring-0 last-resort, no IDB, preserves future emergency baseline (src/emergency-baseline remains empty until Stage 10) - src/platform/sw/register.ts: offline-safe bridge, never blocks boot, scope /, handles unsupported/failed, skipWaitingForUpdate - src/app/main.ts: import + void registerSW().catch (non-blocking) - vite.config.ts: lumenShellPlugin closeBundle injects CACHE_NAME + PRECACHE_URLS (core /, index.html, fallback.html, manifest, icons + hashed assets/*) + precache-manifest.json; hashed immutable assets via rollup output - Build: dist/index.html 0.92kB, style 3.06kB/1.18gz, js 8.15kB/2.74gz (shell <1MB/150kB budgets) - Tests: tests/unit/sw.test.ts (16 tests: file minimal, B-5, install/activate/fetch/message, package passthrough, fallback last-resort, registration unsupported/registered/failed, skipWaiting, versioning) - CI: typecheck + lint + format + test (34 tests) + build — desktop automation; iOS lifecycle/persistence/eviction/jetsam/install still physical-device validation per ARCHITECTURE-VALIDATION.md §4
This commit is contained in:
parent
902a1ffc22
commit
615ae2565c
7 changed files with 418 additions and 10 deletions
171
tests/unit/sw.test.ts
Normal file
171
tests/unit/sw.test.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
/**
|
||||
* @vitest-environment node
|
||||
* Stage 3 PWA foundation tests — shell SW only.
|
||||
* Desktop-automation verifiable; iOS lifecycle remains physical-device validation.
|
||||
* Trace: IMPLEMENTATION-CONTRACT.md §16–§18, ARCHITECTURE-DESIGN.md §8
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { registerSW, skipWaitingForUpdate } from "../../src/platform/sw/register.js";
|
||||
|
||||
const SW_PATH = "public/sw.js";
|
||||
|
||||
describe("SW — file exists and is minimal", () => {
|
||||
it("public/sw.js exists and is hand-written (no Workbox, no framework)", () => {
|
||||
expect(existsSync(SW_PATH)).toBe(true);
|
||||
const src = readFileSync(SW_PATH, "utf8");
|
||||
expect(src).not.toMatch(/workbox/i);
|
||||
});
|
||||
|
||||
it("does NOT implement data-layer responsibilities (B-5, §17)", () => {
|
||||
const src = readFileSync(SW_PATH, "utf8");
|
||||
expect(src).not.toMatch(/indexedDB/i);
|
||||
expect(src).not.toMatch(/DatasetStore|UserStore/i);
|
||||
expect(src).not.toMatch(/Ed25519/i);
|
||||
});
|
||||
|
||||
it("has required SW responsibilities only", () => {
|
||||
const src = readFileSync(SW_PATH, "utf8");
|
||||
expect(src).toMatch(/addEventListener\("install"/);
|
||||
expect(src).toMatch(/addEventListener\("activate"/);
|
||||
expect(src).toMatch(/addEventListener\("fetch"/);
|
||||
expect(src).toMatch(/addEventListener\("message"/);
|
||||
expect(src).toMatch(/SKIP_WAITING/);
|
||||
expect(src).toMatch(/CACHE_NAME\s*=\s*"lumen-shell-/);
|
||||
expect(src).toMatch(/PRECACHE_URLS/);
|
||||
});
|
||||
|
||||
it("install: safe population — caches.open + addAll + failure deletes partial", () => {
|
||||
const src = readFileSync(SW_PATH, "utf8");
|
||||
expect(src).toMatch(/caches\.open\(CACHE_NAME\)/);
|
||||
expect(src).toMatch(/cache\.addAll\(PRECACHE_URLS\)/);
|
||||
expect(src).toMatch(/caches\.delete\(CACHE_NAME\)/);
|
||||
// install must not call skipWaiting(); only message handler may (per ARCH §8: no mid-session clients.claim)
|
||||
const installSection =
|
||||
src.split('addEventListener("install"')[1]?.split('addEventListener("activate"')[0] ?? "";
|
||||
expect(installSection).not.toMatch(/skipWaiting\(\)/);
|
||||
expect(src).toMatch(/addEventListener\("message"/);
|
||||
});
|
||||
|
||||
it("activate: deletes old shell caches only", () => {
|
||||
const src = readFileSync(SW_PATH, "utf8");
|
||||
expect(src).toMatch(/caches/);
|
||||
expect(src).toMatch(/keys\(\)/);
|
||||
expect(src).toMatch(/lumen-shell-/);
|
||||
expect(src).toMatch(/CACHE_NAME/);
|
||||
});
|
||||
|
||||
it("fetch: package endpoints are NOT intercepted (C-21)", () => {
|
||||
const src = readFileSync(SW_PATH, "utf8");
|
||||
expect(src).toMatch(/\/latest\.json/);
|
||||
expect(src).toMatch(/\/editions\//);
|
||||
expect(src).toMatch(/if \(url\.pathname === "\/latest\.json"/);
|
||||
});
|
||||
|
||||
it("fetch: navigation is cache-first with fallback.html, assets cache-first", () => {
|
||||
const src = readFileSync(SW_PATH, "utf8");
|
||||
expect(src).toMatch(/isNavigation/);
|
||||
expect(src).toMatch(/caches\.match/);
|
||||
expect(src).toMatch(/fallback\.html/);
|
||||
expect(src).toMatch(/url\.pathname\.startsWith\("\/assets\/"\)/);
|
||||
});
|
||||
|
||||
it("precache list contains offline-safe shell assets", () => {
|
||||
const src = readFileSync(SW_PATH, "utf8");
|
||||
expect(src).toMatch(/"\/"/);
|
||||
expect(src).toMatch(/"\/index\.html"/);
|
||||
expect(src).toMatch(/"\/fallback\.html"/);
|
||||
expect(src).toMatch(/"\/manifest\.webmanifest"/);
|
||||
});
|
||||
|
||||
it("fallback.html is last-resort and does not depend on emergency baseline", () => {
|
||||
const fallback = readFileSync("public/fallback.html", "utf8");
|
||||
expect(fallback).toMatch(/offline/);
|
||||
expect(fallback).not.toMatch(/indexedDB/i);
|
||||
expect(existsSync("src/emergency-baseline/README.md")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SW registration — page bridge", () => {
|
||||
let originalNavigator: unknown;
|
||||
|
||||
beforeEach(() => {
|
||||
originalNavigator = (globalThis as unknown as { navigator: unknown }).navigator;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: originalNavigator,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns unsupported when serviceWorker not in navigator", async () => {
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: {},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
const res = await registerSW();
|
||||
expect(res.status).toBe("unsupported");
|
||||
});
|
||||
|
||||
it("registers /sw.js with scope / when supported", async () => {
|
||||
const mockReg = { scope: "/", waiting: null } as unknown as ServiceWorkerRegistration;
|
||||
const mockRegister = vi.fn().mockResolvedValue(mockReg);
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: { serviceWorker: { register: mockRegister } },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
const res = await registerSW();
|
||||
expect(mockRegister).toHaveBeenCalledWith("/sw.js", { scope: "/" });
|
||||
expect(res.status).toBe("registered");
|
||||
});
|
||||
|
||||
it("returns failed when register throws (offline or HTTPS issue)", async () => {
|
||||
const mockRegister = vi.fn().mockRejectedValue(new Error("HTTPS required"));
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: { serviceWorker: { register: mockRegister } },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
const res = await registerSW();
|
||||
expect(res.status).toBe("failed");
|
||||
});
|
||||
|
||||
it("skipWaitingForUpdate posts SKIP_WAITING only to waiting worker", () => {
|
||||
const postMessage = vi.fn();
|
||||
const waiting = { postMessage } as unknown as ServiceWorker;
|
||||
const reg = { waiting } as unknown as ServiceWorkerRegistration;
|
||||
skipWaitingForUpdate(reg);
|
||||
expect(postMessage).toHaveBeenCalledWith({ type: "SKIP_WAITING" });
|
||||
// no waiting → no throw
|
||||
skipWaitingForUpdate(undefined);
|
||||
skipWaitingForUpdate({ waiting: null } as unknown as ServiceWorkerRegistration);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cache safety — versioning and shell assets", () => {
|
||||
it("vite build injects hashed assets into precache manifest", () => {
|
||||
const viteConfig = readFileSync("vite.config.ts", "utf8");
|
||||
expect(viteConfig).toMatch(/lumenShellPlugin/);
|
||||
expect(viteConfig).toMatch(/CACHE_NAME/);
|
||||
expect(viteConfig).toMatch(/PRECACHE_URLS/);
|
||||
expect(viteConfig).toMatch(/precache-manifest\.json/);
|
||||
expect(viteConfig).toMatch(/assets\/\[name\]-\[hash\]/);
|
||||
});
|
||||
|
||||
it("deterministic cache naming — prefix lumen-shell- per ARCH §9.1", () => {
|
||||
const sw = readFileSync(SW_PATH, "utf8");
|
||||
expect(sw).toMatch(/lumen-shell-/);
|
||||
});
|
||||
|
||||
it("does not claim to prove iOS lifecycle (must remain physical-device validation)", () => {
|
||||
const sw = readFileSync(SW_PATH, "utf8");
|
||||
expect(sw).not.toMatch(/BackgroundSync|PeriodicSync/i);
|
||||
expect(sw).not.toMatch(/clients\.claim\(\)/);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue