250 lines
9.1 KiB
TypeScript
250 lines
9.1 KiB
TypeScript
/**
|
||
* @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\(\)/);
|
||
});
|
||
});
|