1import {2 classifyInstallExperience,3 createPwaController,4 type InstallPromptEvent,5 type PwaController,6 type PwaInstallState,7 type PwaState,8 resolvePwaResources,9 waitForActivation,10} from "./pwa.ts";1112const test = (globalThis as unknown as {13 Deno: { test(name: string, body: () => void | Promise<void>): void };14}).Deno.test;1516class FakeServiceWorker extends EventTarget {17 state: ServiceWorkerState;18 readonly messages: unknown[] = [];1920 constructor(state: ServiceWorkerState) {21 super();22 this.state = state;23 }2425 transition(state: ServiceWorkerState) {26 this.state = state;27 this.dispatchEvent(new Event("statechange"));28 }2930 postMessage(message: unknown) {31 this.messages.push(message);32 }33}3435class FakeRegistration extends EventTarget {36 installing: ServiceWorker | null = null;37 waiting: ServiceWorker | null = null;38 active: ServiceWorker | null = null;39 updateCalls = 0;40 updateImplementation: () => Promise<ServiceWorkerRegistration> = () =>41 Promise.resolve(this as unknown as ServiceWorkerRegistration);4243 update(): Promise<ServiceWorkerRegistration> {44 this.updateCalls += 1;45 return this.updateImplementation();46 }47}4849class FakeContainer extends EventTarget {50 controller: ServiceWorker | null = null;51 registration = new FakeRegistration();52 registerError: Error | undefined;53 registeredUrl: string | undefined;54 registeredScope: string | undefined;5556 register(url: string | URL, options?: RegistrationOptions): Promise<ServiceWorkerRegistration> {57 this.registeredUrl = String(url);58 this.registeredScope = options?.scope;59 if (this.registerError) return Promise.reject(this.registerError);60 return Promise.resolve(this.registration as unknown as ServiceWorkerRegistration);61 }62}6364function asServiceWorker(worker: FakeServiceWorker): ServiceWorker {65 return worker as unknown as ServiceWorker;66}6768function testController(options: {69 production?: boolean;70 container?: FakeContainer;71 target?: EventTarget;72 install?: PwaInstallState;73 reload?: () => void;74 deploymentBaseUrl?: string;75 visibilityTarget?: EventTarget;76 isVisible?: () => boolean;77 now?: () => number;78 setTimeout?: (callback: () => void, milliseconds: number) => unknown;79 clearTimeout?: (handle: unknown) => void;80 updateCheckIntervalMs?: number;81 updateCheckTimeoutMs?: number;82} = {}): PwaController {83 const environment = options.install === "manual-ios"84 ? { platform: "iPhone", maxTouchPoints: 5 }85 : { platform: "Linux x86_64", maxTouchPoints: 0 };86 return createPwaController({87 eventTarget: () => options.target ?? new EventTarget(),88 visibilityTarget: () => options.visibilityTarget ?? new EventTarget(),89 serviceWorker: () => options.container as unknown as ServiceWorkerContainer,90 installEnvironment: () => ({91 displayModeStandalone: options.install === "installed",92 navigatorStandalone: false,93 secureContext: options.install !== "unsupported",94 serviceWorkerSupported: options.install !== "unsupported",95 ...environment,96 }),97 isVisible: options.isVisible,98 now: options.now,99 setTimeout: options.setTimeout,100 clearTimeout: options.clearTimeout,101 updateCheckIntervalMs: options.updateCheckIntervalMs,102 updateCheckTimeoutMs: options.updateCheckTimeoutMs,103 production: () => options.production ?? false,104 deploymentBaseUrl: () => options.deploymentBaseUrl ?? "http://127.0.0.1:4321/",105 reload: options.reload,106 });107}108109test("PWA resources resolve from the deployment base instead of the current route", () => {110 const resources = resolvePwaResources("https://example.com/field-notes");111 if (resources.workerUrl.href !== "https://example.com/field-notes/sw.js") {112 throw new Error(`unexpected worker URL ${resources.workerUrl.href}`);113 }114 if (resources.scope !== "/field-notes/") throw new Error(`unexpected scope ${resources.scope}`);115});116117test("nested direct visits register the worker at the configured non-root base", async () => {118 const container = new FakeContainer();119 container.registration.active = asServiceWorker(new FakeServiceWorker("activated"));120 const controller = testController({121 container,122 production: true,123 deploymentBaseUrl: "https://example.com/field-notes/",124 });125 controller.initialize();126 await waitForState(controller, (state) => state.worker === "ready");127 if (container.registeredUrl !== "https://example.com/field-notes/sw.js") {128 throw new Error(`registered the wrong worker URL: ${container.registeredUrl}`);129 }130 if (container.registeredScope !== "/field-notes/") {131 throw new Error(`registered the wrong scope: ${container.registeredScope}`);132 }133});134135function waitForState(controller: PwaController, predicate: (state: PwaState) => boolean) {136 return new Promise<PwaState>((resolve) => {137 const unsubscribe = controller.subscribe((state) => {138 if (!predicate(state)) return;139 unsubscribe();140 resolve(state);141 });142 });143}144145function installPrompt(146 outcome: "accepted" | "dismissed",147 prompted: () => void,148): InstallPromptEvent {149 return Object.assign(new Event("beforeinstallprompt", { cancelable: true }), {150 prompt() {151 prompted();152 return Promise.resolve();153 },154 userChoice: Promise.resolve({ outcome }),155 }) as InstallPromptEvent;156}157158test("service worker activation resolves for an active worker", async () => {159 await waitForActivation(asServiceWorker(new FakeServiceWorker("activated")));160});161162test("service worker activation waits for the activated state", async () => {163 const worker = new FakeServiceWorker("installing");164 const activation = waitForActivation(asServiceWorker(worker));165 worker.transition("activated");166 await activation;167});168169test("service worker activation rejects a redundant install", async () => {170 const worker = new FakeServiceWorker("installing");171 const activation = waitForActivation(asServiceWorker(worker));172 worker.transition("redundant");173 await activation.then(174 () => {175 throw new Error("expected redundant worker to reject");176 },177 (error) => {178 if (!(error instanceof Error) || !error.message.includes("installation failed")) throw error;179 },180 );181});182183test("install experience recognizes standalone display modes", () => {184 const state = classifyInstallExperience({185 displayModeStandalone: true,186 navigatorStandalone: false,187 platform: "Linux armv8l",188 maxTouchPoints: 5,189 secureContext: true,190 serviceWorkerSupported: true,191 });192 if (state !== "installed") throw new Error(`expected installed, received ${state}`);193});194195test("install experience gives iOS manual guidance in every region", () => {196 const iphone = classifyInstallExperience({197 displayModeStandalone: false,198 navigatorStandalone: false,199 platform: "iPhone",200 maxTouchPoints: 5,201 secureContext: true,202 serviceWorkerSupported: true,203 });204 const ipadDesktopMode = classifyInstallExperience({205 displayModeStandalone: false,206 navigatorStandalone: false,207 platform: "MacIntel",208 maxTouchPoints: 5,209 secureContext: true,210 serviceWorkerSupported: true,211 });212 if (iphone !== "manual-ios" || ipadDesktopMode !== "manual-ios") {213 throw new Error(`expected iOS guidance, received ${iphone} and ${ipadDesktopMode}`);214 }215});216217test("install experience distinguishes manual browser fallback from unsupported contexts", () => {218 const manual = classifyInstallExperience({219 displayModeStandalone: false,220 navigatorStandalone: false,221 platform: "Linux x86_64",222 maxTouchPoints: 0,223 secureContext: true,224 serviceWorkerSupported: true,225 });226 const unsupported = classifyInstallExperience({227 displayModeStandalone: false,228 navigatorStandalone: false,229 platform: "Linux x86_64",230 maxTouchPoints: 0,231 secureContext: false,232 serviceWorkerSupported: true,233 });234 if (manual !== "manual-browser" || unsupported !== "unsupported") {235 throw new Error(`unexpected fallback states ${manual} and ${unsupported}`);236 }237});238239test("install prompt availability and accepted outcome are observable", async () => {240 const target = new EventTarget();241 const controller = testController({ target });242 controller.initialize();243 let prompted = false;244 target.dispatchEvent(installPrompt("accepted", () => prompted = true));245 if (controller.getState().install !== "available") throw new Error("prompt was not exposed");246 if (await controller.requestInstall() !== "accepted" || !prompted) {247 throw new Error("accepted prompt outcome was not reflected");248 }249});250251test("dismissed install outcome and appinstalled are observable", async () => {252 const target = new EventTarget();253 const controller = testController({ target });254 controller.initialize();255 target.dispatchEvent(installPrompt("dismissed", () => undefined));256 if (await controller.requestInstall() !== "dismissed") {257 throw new Error("dismissed prompt outcome was not reflected");258 }259 target.dispatchEvent(new Event("appinstalled"));260 if (controller.getState().install !== "installed") throw new Error("appinstalled was ignored");261});262263test("waiting-worker update activation posts skip-waiting and reloads on controller change", async () => {264 const target = new EventTarget();265 const container = new FakeContainer();266 const waiting = new FakeServiceWorker("installed");267 container.controller = asServiceWorker(new FakeServiceWorker("activated"));268 container.registration.waiting = asServiceWorker(waiting);269 let reloaded = false;270 const controller = testController({271 target,272 container,273 production: true,274 reload: () => reloaded = true,275 });276 const available = waitForState(controller, (state) => state.update === "ready");277 controller.initialize();278 await available;279 if (await controller.checkForUpdate() || container.registration.updateCalls !== 0) {280 throw new Error("foreground check replaced an actionable waiting update");281 }282 if (!controller.applyUpdate()) throw new Error("update action was unavailable");283 if (controller.getState().update !== "applying") {284 throw new Error("update application state was not observable");285 }286 if (JSON.stringify(waiting.messages) !== JSON.stringify([{ type: "LOFI_SKIP_WAITING" }])) {287 throw new Error("update action did not request activation");288 }289 container.dispatchEvent(new Event("controllerchange"));290 if (!reloaded) throw new Error("controller change did not reload after update action");291 reloaded = false;292 container.dispatchEvent(new Event("controllerchange"));293 if (reloaded) throw new Error("one update action caused a reload loop");294});295296test("a controlled tab reloads on an update another tab initiated", async () => {297 // The new worker prunes the old revision's caches, so every claimed tab298 // must leave the old HTML/module graph — not only the tab that clicked.299 const container = new FakeContainer();300 container.controller = asServiceWorker(new FakeServiceWorker("activated"));301 container.registration.active = asServiceWorker(new FakeServiceWorker("activated"));302 let reloaded = 0;303 const controller = testController({304 container,305 production: true,306 reload: () => reloaded += 1,307 });308 controller.initialize();309 await waitForState(controller, (state) => state.worker === "ready");310 container.dispatchEvent(new Event("controllerchange"));311 if (reloaded !== 1) throw new Error("a bystander tab kept the stale document after an update");312 container.dispatchEvent(new Event("controllerchange"));313 if (reloaded !== 1) throw new Error("a document must reload at most once per lifetime");314});315316test("the first claim of an uncontrolled page never reloads", async () => {317 const container = new FakeContainer();318 container.registration.active = asServiceWorker(new FakeServiceWorker("activated"));319 let reloaded = false;320 const controller = testController({321 container,322 production: true,323 reload: () => reloaded = true,324 });325 controller.initialize();326 await waitForState(controller, (state) => state.worker === "ready");327 container.dispatchEvent(new Event("controllerchange"));328 if (reloaded) throw new Error("clients.claim() on first visit must not reload the page");329});330331test("foreground update checks are single-flight and rate-limited", async () => {332 const target = new EventTarget();333 const visibility = new EventTarget();334 const container = new FakeContainer();335 const active = new FakeServiceWorker("activated");336 container.controller = asServiceWorker(active);337 container.registration.active = asServiceWorker(active);338 let currentTime = 0;339 let resolveUpdate: ((registration: ServiceWorkerRegistration) => void) | undefined;340 container.registration.updateImplementation = () =>341 new Promise((resolve) => resolveUpdate = resolve);342 const controller = testController({343 target,344 visibilityTarget: visibility,345 isVisible: () => true,346 container,347 production: true,348 now: () => currentTime,349 updateCheckIntervalMs: 100,350 });351 controller.initialize();352 await waitForState(controller, (state) => state.worker === "ready");353 visibility.dispatchEvent(new Event("visibilitychange"));354 target.dispatchEvent(new Event("online"));355 await Promise.resolve();356 if (container.registration.updateCalls !== 1 || controller.getState().update !== "checking") {357 throw new Error("overlapping foreground signals did not share one update check");358 }359 const idle = waitForState(controller, (state) => state.update === "idle");360 resolveUpdate?.(container.registration as unknown as ServiceWorkerRegistration);361 await idle;362 target.dispatchEvent(new Event("online"));363 await Promise.resolve();364 if (container.registration.updateCalls !== 1) {365 throw new Error("update check ignored the rate limit");366 }367 currentTime = 100;368 container.registration.updateImplementation = () =>369 Promise.resolve(container.registration as unknown as ServiceWorkerRegistration);370 target.dispatchEvent(new Event("online"));371 await Promise.resolve();372 await Promise.resolve();373 if (Number(container.registration.updateCalls) !== 2) {374 throw new Error("update check did not resume after the rate limit");375 }376});377378test("a bounded foreground update failure leaves the active worker ready", async () => {379 const target = new EventTarget();380 const container = new FakeContainer();381 const active = new FakeServiceWorker("activated");382 container.controller = asServiceWorker(active);383 container.registration.active = asServiceWorker(active);384 container.registration.updateImplementation = () => new Promise(() => undefined);385 let expire: (() => void) | undefined;386 const controller = testController({387 target,388 container,389 production: true,390 setTimeout: (callback) => {391 expire = callback;392 return 1;393 },394 clearTimeout: () => undefined,395 });396 controller.initialize();397 await waitForState(controller, (state) => state.worker === "ready");398 const failed = waitForState(controller, (state) => state.update === "failed");399 target.dispatchEvent(new Event("online"));400 await Promise.resolve();401 expire?.();402 const state = await failed;403 if (state.worker !== "ready" || state.failure?.code !== "update-check") {404 throw new Error("bounded update failure was reported as an active-worker failure");405 }406});407408test("an installing update becomes explicitly ready", async () => {409 const container = new FakeContainer();410 const active = new FakeServiceWorker("activated");411 container.controller = asServiceWorker(active);412 container.registration.active = asServiceWorker(active);413 const controller = testController({ container, production: true });414 controller.initialize();415 await waitForState(controller, (state) => state.worker === "ready");416 const installing = new FakeServiceWorker("installing");417 container.registration.installing = asServiceWorker(installing);418 container.registration.dispatchEvent(new Event("updatefound"));419 if (controller.getState().update !== "installing") {420 throw new Error("installing update state was hidden");421 }422 const ready = waitForState(controller, (state) => state.update === "ready");423 installing.transition("installed");424 await ready;425});426427test("registration failure is classified without leaking the browser error", async () => {428 const container = new FakeContainer();429 container.registerError = new Error("private registration detail");430 const controller = testController({ container, production: true });431 const failed = waitForState(controller, (state) => state.failure?.code === "registration");432 controller.initialize();433 const state = await failed;434 if (state.failure?.message.includes("private")) throw new Error("registration detail leaked");435 if (!state.failure?.message.includes("Reload")) {436 throw new Error("registration failure is not actionable");437 }438});439440test("installation failure is classified from a redundant update", async () => {441 const container = new FakeContainer();442 const active = new FakeServiceWorker("activated");443 container.registration.active = asServiceWorker(active);444 container.controller = asServiceWorker(active);445 const controller = testController({ container, production: true });446 controller.initialize();447 await waitForState(controller, (state) => state.worker === "ready");448 const installing = new FakeServiceWorker("installing");449 container.registration.installing = asServiceWorker(installing);450 container.registration.dispatchEvent(new Event("updatefound"));451 const failed = waitForState(controller, (state) => state.failure?.code === "installation");452 installing.transition("redundant");453 await failed;454});455456for (const code of ["precache", "runtime-cache"] as const) {457 test(`${code} worker failure is classified without exposing message payloads`, async () => {458 const container = new FakeContainer();459 container.registration.active = asServiceWorker(new FakeServiceWorker("activated"));460 const controller = testController({ container, production: true });461 controller.initialize();462 await waitForState(controller, (state) => state.worker === "ready");463 const failed = waitForState(controller, (state) => state.failure?.code === code);464 container.dispatchEvent(465 new MessageEvent("message", {466 data: { type: "LOFI_PWA_FAILURE", code, message: "private cache detail" },467 }),468 );469 const state = await failed;470 if (state.failure?.message.includes("private")) throw new Error(`${code} detail leaked`);471 if (!state.failure?.message.includes("Reconnect")) {472 throw new Error(`${code} failure is not actionable`);473 }474 const expectedWorker = code === "runtime-cache" ? "ready" : "failed";475 if (state.worker !== expectedWorker) {476 throw new Error(`${code} produced worker state ${state.worker}, expected ${expectedWorker}`);477 }478 });479}