Lumen/tests/unit/sw.test.ts

250 lines
9.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @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,
watchForWaitingUpdate,
} 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);
const mockUnregister = vi.fn().mockResolvedValue(true);
Object.defineProperty(globalThis, "navigator", {
value: {
serviceWorker: {
register: mockRegister,
getRegistrations: vi.fn().mockResolvedValue([{ unregister: mockUnregister }]),
},
},
writable: true,
configurable: true,
});
const res = await registerSW();
// clean-update: any pre-existing worker is unregistered before re-registering
expect(mockUnregister).toHaveBeenCalled();
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,
getRegistrations: vi.fn().mockResolvedValue([]),
},
},
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("watchForWaitingUpdate — restart-to-update discovery", () => {
function withController(controller: unknown): void {
Object.defineProperty(globalThis, "navigator", {
value: { serviceWorker: { controller } },
writable: true,
configurable: true,
});
}
it("no controller (first install) → never fires", () => {
withController(null);
const onWaiting = vi.fn();
const listeners: Record<string, () => void> = {};
const reg = {
waiting: { postMessage: vi.fn() },
addEventListener: (ev: string, cb: () => void) => {
listeners[ev] = cb;
},
} as unknown as ServiceWorkerRegistration;
watchForWaitingUpdate(reg, onWaiting);
expect(onWaiting).not.toHaveBeenCalled();
expect(listeners.updatefound).toBeUndefined();
});
it("already-waiting worker fires immediately", () => {
withController({ scriptURL: "/sw.js" });
const onWaiting = vi.fn();
const reg = {
waiting: { postMessage: vi.fn() },
addEventListener: vi.fn(),
} as unknown as ServiceWorkerRegistration;
watchForWaitingUpdate(reg, onWaiting);
expect(onWaiting).toHaveBeenCalledWith(reg);
});
it("updatefound → installed waiting worker fires", () => {
withController({ scriptURL: "/sw.js" });
const onWaiting = vi.fn();
let foundCb: (() => void) | undefined;
const stateCbs: (() => void)[] = [];
const installed = {
state: "installed",
addEventListener: (_ev: string, cb: () => void) => {
stateCbs.push(cb);
},
};
const reg = {
waiting: null as unknown,
installing: installed,
addEventListener: (ev: string, cb: () => void) => {
if (ev === "updatefound") foundCb = cb;
},
} as unknown as ServiceWorkerRegistration;
watchForWaitingUpdate(reg, onWaiting);
expect(onWaiting).not.toHaveBeenCalled();
foundCb?.();
(reg as { waiting: unknown }).waiting = { postMessage: vi.fn() };
for (const cb of stateCbs) cb();
expect(onWaiting).toHaveBeenCalledTimes(1);
});
});
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\(\)/);
});
});