Add restart-to-update banner: waiting SW detected via watchForWaitingUpdate, explicit user opt-in applies SKIP_WAITING + reload (no auto mid-session claim, ARCH §8)
Some checks failed
ci / check (push) Has been cancelled

This commit is contained in:
Lumen Stage1 2026-10-03 13:12:25 -05:00
commit e46b14ff07
6 changed files with 266 additions and 5 deletions

View file

@ -6,7 +6,11 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { readFileSync, existsSync } from "node:fs";
import { registerSW, skipWaitingForUpdate } from "../../src/platform/sw/register.js";
import {
registerSW,
skipWaitingForUpdate,
watchForWaitingUpdate,
} from "../../src/platform/sw/register.js";
const SW_PATH = "public/sw.js";
@ -148,6 +152,68 @@ describe("SW registration — page bridge", () => {
});
});
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");

View file

@ -0,0 +1,54 @@
/**
* @vitest-environment jsdom
* "Restart to update" banner — appears once, apply fires the opt-in,
* Later removes it. Pure DOM: no SW, no storage, no network.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { showUpdateBanner } from "../../src/ui/components/update-banner.js";
beforeEach(() => {
document.body.replaceChildren();
});
function root(): HTMLElement {
const el = document.createElement("div");
document.body.append(el);
return el;
}
describe("update banner", () => {
it("renders banner with apply and dismiss once (idempotent)", () => {
const host = root();
showUpdateBanner(host, () => undefined);
showUpdateBanner(host, () => undefined);
expect(document.querySelectorAll(".update-banner").length).toBe(1);
expect(host.textContent).toContain("new version");
expect(host.querySelector(".update-banner__apply")?.textContent).toBe("Restart to update");
});
it("apply invokes the opt-in callback exactly once per click", () => {
const host = root();
const onApply = vi.fn();
showUpdateBanner(host, onApply);
host.querySelector(".update-banner__apply")?.dispatchEvent(new MouseEvent("click"));
expect(onApply).toHaveBeenCalledTimes(1);
});
it("Later removes the banner without calling apply", () => {
const host = root();
const onApply = vi.fn();
showUpdateBanner(host, onApply);
host.querySelector(".update-banner__dismiss")?.dispatchEvent(new MouseEvent("click"));
expect(document.querySelector(".update-banner")).toBeNull();
expect(onApply).not.toHaveBeenCalled();
// Re-show after dismiss is allowed (a later update can re-offer).
showUpdateBanner(host, onApply);
expect(document.querySelectorAll(".update-banner").length).toBe(1);
});
it("renders no raw HTML from anywhere (textContent-only)", () => {
const host = root();
showUpdateBanner(host, () => undefined);
expect(host.querySelector(".update-banner")?.innerHTML).not.toMatch(/<img|<script/);
});
});