clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/fix-sync-enrollment-startup
lofi / package / runtime / pwa_test.ts
pwa_test.ts 21.8K
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 prepareUpdateSwap?: () => Promise<void>;83 staleTabBehavior?: () => "reload" | "prompt";84 onStaleTab?: () => void;85} = {}): PwaController {86 const environment = options.install === "manual-ios"87 ? { platform: "iPhone", maxTouchPoints: 5 }88 : { platform: "Linux x86_64", maxTouchPoints: 0 };89 return createPwaController({90 eventTarget: () => options.target ?? new EventTarget(),91 visibilityTarget: () => options.visibilityTarget ?? new EventTarget(),92 serviceWorker: () => options.container as unknown as ServiceWorkerContainer,93 installEnvironment: () => ({94 displayModeStandalone: options.install === "installed",95 navigatorStandalone: false,96 secureContext: options.install !== "unsupported",97 serviceWorkerSupported: options.install !== "unsupported",98 ...environment,99 }),100 isVisible: options.isVisible,101 now: options.now,102 setTimeout: options.setTimeout,103 clearTimeout: options.clearTimeout,104 updateCheckIntervalMs: options.updateCheckIntervalMs,105 updateCheckTimeoutMs: options.updateCheckTimeoutMs,106 production: () => options.production ?? false,107 deploymentBaseUrl: () => options.deploymentBaseUrl ?? "http://127.0.0.1:4321/",108 reload: options.reload,109 prepareUpdateSwap: options.prepareUpdateSwap,110 staleTabBehavior: options.staleTabBehavior,111 onStaleTab: options.onStaleTab,112 });113}114115test("PWA resources resolve from the deployment base instead of the current route", () => {116 const resources = resolvePwaResources("https://example.com/field-notes");117 if (resources.workerUrl.href !== "https://example.com/field-notes/sw.js") {118 throw new Error(`unexpected worker URL ${resources.workerUrl.href}`);119 }120 if (resources.scope !== "/field-notes/") throw new Error(`unexpected scope ${resources.scope}`);121});122123test("nested direct visits register the worker at the configured non-root base", async () => {124 const container = new FakeContainer();125 container.registration.active = asServiceWorker(new FakeServiceWorker("activated"));126 const controller = testController({127 container,128 production: true,129 deploymentBaseUrl: "https://example.com/field-notes/",130 });131 controller.initialize();132 await waitForState(controller, (state) => state.worker === "ready");133 if (container.registeredUrl !== "https://example.com/field-notes/sw.js") {134 throw new Error(`registered the wrong worker URL: ${container.registeredUrl}`);135 }136 if (container.registeredScope !== "/field-notes/") {137 throw new Error(`registered the wrong scope: ${container.registeredScope}`);138 }139});140141function waitForState(controller: PwaController, predicate: (state: PwaState) => boolean) {142 return new Promise<PwaState>((resolve) => {143 const unsubscribe = controller.subscribe((state) => {144 if (!predicate(state)) return;145 unsubscribe();146 resolve(state);147 });148 });149}150151function installPrompt(152 outcome: "accepted" | "dismissed",153 prompted: () => void,154): InstallPromptEvent {155 return Object.assign(new Event("beforeinstallprompt", { cancelable: true }), {156 prompt() {157 prompted();158 return Promise.resolve();159 },160 userChoice: Promise.resolve({ outcome }),161 }) as InstallPromptEvent;162}163164test("service worker activation resolves for an active worker", async () => {165 await waitForActivation(asServiceWorker(new FakeServiceWorker("activated")));166});167168test("service worker activation waits for the activated state", async () => {169 const worker = new FakeServiceWorker("installing");170 const activation = waitForActivation(asServiceWorker(worker));171 worker.transition("activated");172 await activation;173});174175test("service worker activation rejects a redundant install", async () => {176 const worker = new FakeServiceWorker("installing");177 const activation = waitForActivation(asServiceWorker(worker));178 worker.transition("redundant");179 await activation.then(180 () => {181 throw new Error("expected redundant worker to reject");182 },183 (error) => {184 if (!(error instanceof Error) || !error.message.includes("installation failed")) throw error;185 },186 );187});188189test("install experience recognizes standalone display modes", () => {190 const state = classifyInstallExperience({191 displayModeStandalone: true,192 navigatorStandalone: false,193 platform: "Linux armv8l",194 maxTouchPoints: 5,195 secureContext: true,196 serviceWorkerSupported: true,197 });198 if (state !== "installed") throw new Error(`expected installed, received ${state}`);199});200201test("install experience gives iOS manual guidance in every region", () => {202 const iphone = classifyInstallExperience({203 displayModeStandalone: false,204 navigatorStandalone: false,205 platform: "iPhone",206 maxTouchPoints: 5,207 secureContext: true,208 serviceWorkerSupported: true,209 });210 const ipadDesktopMode = classifyInstallExperience({211 displayModeStandalone: false,212 navigatorStandalone: false,213 platform: "MacIntel",214 maxTouchPoints: 5,215 secureContext: true,216 serviceWorkerSupported: true,217 });218 if (iphone !== "manual-ios" || ipadDesktopMode !== "manual-ios") {219 throw new Error(`expected iOS guidance, received ${iphone} and ${ipadDesktopMode}`);220 }221});222223test("install experience distinguishes manual browser fallback from unsupported contexts", () => {224 const manual = classifyInstallExperience({225 displayModeStandalone: false,226 navigatorStandalone: false,227 platform: "Linux x86_64",228 maxTouchPoints: 0,229 secureContext: true,230 serviceWorkerSupported: true,231 });232 const unsupported = classifyInstallExperience({233 displayModeStandalone: false,234 navigatorStandalone: false,235 platform: "Linux x86_64",236 maxTouchPoints: 0,237 secureContext: false,238 serviceWorkerSupported: true,239 });240 if (manual !== "manual-browser" || unsupported !== "unsupported") {241 throw new Error(`unexpected fallback states ${manual} and ${unsupported}`);242 }243});244245test("install prompt availability and accepted outcome are observable", async () => {246 const target = new EventTarget();247 const controller = testController({ target });248 controller.initialize();249 let prompted = false;250 target.dispatchEvent(installPrompt("accepted", () => prompted = true));251 if (controller.getState().install !== "available") throw new Error("prompt was not exposed");252 if (await controller.requestInstall() !== "accepted" || !prompted) {253 throw new Error("accepted prompt outcome was not reflected");254 }255});256257test("dismissed install outcome and appinstalled are observable", async () => {258 const target = new EventTarget();259 const controller = testController({ target });260 controller.initialize();261 target.dispatchEvent(installPrompt("dismissed", () => undefined));262 if (await controller.requestInstall() !== "dismissed") {263 throw new Error("dismissed prompt outcome was not reflected");264 }265 target.dispatchEvent(new Event("appinstalled"));266 if (controller.getState().install !== "installed") throw new Error("appinstalled was ignored");267});268269test("waiting-worker update activation posts skip-waiting and reloads on controller change", async () => {270 const target = new EventTarget();271 const container = new FakeContainer();272 const waiting = new FakeServiceWorker("installed");273 container.controller = asServiceWorker(new FakeServiceWorker("activated"));274 container.registration.waiting = asServiceWorker(waiting);275 let reloaded = false;276 const controller = testController({277 target,278 container,279 production: true,280 reload: () => reloaded = true,281 });282 const available = waitForState(controller, (state) => state.update === "ready");283 controller.initialize();284 await available;285 if (await controller.checkForUpdate() || container.registration.updateCalls !== 0) {286 throw new Error("foreground check replaced an actionable waiting update");287 }288 if (!controller.applyUpdate()) throw new Error("update action was unavailable");289 if (controller.getState().update !== "applying") {290 throw new Error("update application state was not observable");291 }292 if (JSON.stringify(waiting.messages) !== JSON.stringify([{ type: "LOFI_SKIP_WAITING" }])) {293 throw new Error("update action did not request activation");294 }295 container.dispatchEvent(new Event("controllerchange"));296 if (!reloaded) throw new Error("controller change did not reload after update action");297 reloaded = false;298 container.dispatchEvent(new Event("controllerchange"));299 if (reloaded) throw new Error("one update action caused a reload loop");300});301302test("a coordinated update quiesces sibling writes before requesting activation", async () => {303 const container = new FakeContainer();304 const waiting = new FakeServiceWorker("installed");305 container.controller = asServiceWorker(new FakeServiceWorker("activated"));306 container.registration.waiting = asServiceWorker(waiting);307 let releaseQuiescence: () => void = () => undefined;308 const quiescence = new Promise<void>((resolve) => releaseQuiescence = resolve);309 const controller = testController({310 container,311 production: true,312 reload: () => undefined,313 prepareUpdateSwap: () => quiescence,314 });315 const available = waitForState(controller, (state) => state.update === "ready");316 controller.initialize();317 await available;318 if (!controller.applyUpdate()) throw new Error("update action was unavailable");319 if (controller.getState().update !== "applying") {320 throw new Error("coordinated update application state was not observable");321 }322 await Promise.resolve();323 if (waiting.messages.length !== 0) {324 throw new Error("activation was requested before sibling writes quiesced");325 }326 releaseQuiescence();327 await quiescence;328 for (let i = 0; i < 6; i += 1) await Promise.resolve();329 if (JSON.stringify(waiting.messages) !== JSON.stringify([{ type: "LOFI_SKIP_WAITING" }])) {330 throw new Error("quiescence did not hand off to the worker swap");331 }332});333334test("a rejected quiescence still proceeds to the worker swap", async () => {335 const container = new FakeContainer();336 const waiting = new FakeServiceWorker("installed");337 container.controller = asServiceWorker(new FakeServiceWorker("activated"));338 container.registration.waiting = asServiceWorker(waiting);339 const controller = testController({340 container,341 production: true,342 reload: () => undefined,343 prepareUpdateSwap: () => Promise.reject(new Error("locks unavailable")),344 });345 const available = waitForState(controller, (state) => state.update === "ready");346 controller.initialize();347 await available;348 if (!controller.applyUpdate()) throw new Error("update action was unavailable");349 for (let i = 0; i < 6; i += 1) await Promise.resolve();350 if (JSON.stringify(waiting.messages) !== JSON.stringify([{ type: "LOFI_SKIP_WAITING" }])) {351 throw new Error("a failed quiescence blocked the swap");352 }353});354355test("the prompt preference keeps a bystander tab and reports it stale", async () => {356 const container = new FakeContainer();357 container.controller = asServiceWorker(new FakeServiceWorker("activated"));358 container.registration.active = asServiceWorker(new FakeServiceWorker("activated"));359 let reloaded = 0;360 let staleReports = 0;361 const controller = testController({362 container,363 production: true,364 reload: () => reloaded += 1,365 staleTabBehavior: () => "prompt",366 onStaleTab: () => staleReports += 1,367 });368 controller.initialize();369 await waitForState(controller, (state) => state.worker === "ready");370 container.dispatchEvent(new Event("controllerchange"));371 if (reloaded !== 0) throw new Error("the prompt preference still reloaded the bystander tab");372 if (staleReports !== 1) throw new Error("the stale tab was not reported");373 if (controller.getState().update !== "idle") {374 throw new Error("the stale tab kept a stale update state");375 }376});377378test("a controlled tab reloads on an update another tab initiated", async () => {379 // The new worker prunes the old revision's caches, so every claimed tab380 // must leave the old HTML/module graph — not only the tab that clicked.381 const container = new FakeContainer();382 container.controller = asServiceWorker(new FakeServiceWorker("activated"));383 container.registration.active = asServiceWorker(new FakeServiceWorker("activated"));384 let reloaded = 0;385 const controller = testController({386 container,387 production: true,388 reload: () => reloaded += 1,389 });390 controller.initialize();391 await waitForState(controller, (state) => state.worker === "ready");392 container.dispatchEvent(new Event("controllerchange"));393 if (reloaded !== 1) throw new Error("a bystander tab kept the stale document after an update");394 container.dispatchEvent(new Event("controllerchange"));395 if (reloaded !== 1) throw new Error("a document must reload at most once per lifetime");396});397398test("the first claim of an uncontrolled page never reloads", async () => {399 const container = new FakeContainer();400 container.registration.active = asServiceWorker(new FakeServiceWorker("activated"));401 let reloaded = false;402 const controller = testController({403 container,404 production: true,405 reload: () => reloaded = true,406 });407 controller.initialize();408 await waitForState(controller, (state) => state.worker === "ready");409 container.dispatchEvent(new Event("controllerchange"));410 if (reloaded) throw new Error("clients.claim() on first visit must not reload the page");411});412413test("foreground update checks are single-flight and rate-limited", async () => {414 const target = new EventTarget();415 const visibility = new EventTarget();416 const container = new FakeContainer();417 const active = new FakeServiceWorker("activated");418 container.controller = asServiceWorker(active);419 container.registration.active = asServiceWorker(active);420 let currentTime = 0;421 let resolveUpdate: ((registration: ServiceWorkerRegistration) => void) | undefined;422 container.registration.updateImplementation = () =>423 new Promise((resolve) => resolveUpdate = resolve);424 const controller = testController({425 target,426 visibilityTarget: visibility,427 isVisible: () => true,428 container,429 production: true,430 now: () => currentTime,431 updateCheckIntervalMs: 100,432 });433 controller.initialize();434 await waitForState(controller, (state) => state.worker === "ready");435 visibility.dispatchEvent(new Event("visibilitychange"));436 target.dispatchEvent(new Event("online"));437 await Promise.resolve();438 if (container.registration.updateCalls !== 1 || controller.getState().update !== "checking") {439 throw new Error("overlapping foreground signals did not share one update check");440 }441 const idle = waitForState(controller, (state) => state.update === "idle");442 resolveUpdate?.(container.registration as unknown as ServiceWorkerRegistration);443 await idle;444 target.dispatchEvent(new Event("online"));445 await Promise.resolve();446 if (container.registration.updateCalls !== 1) {447 throw new Error("update check ignored the rate limit");448 }449 currentTime = 100;450 container.registration.updateImplementation = () =>451 Promise.resolve(container.registration as unknown as ServiceWorkerRegistration);452 target.dispatchEvent(new Event("online"));453 await Promise.resolve();454 await Promise.resolve();455 if (Number(container.registration.updateCalls) !== 2) {456 throw new Error("update check did not resume after the rate limit");457 }458});459460test("a bounded foreground update failure leaves the active worker ready", async () => {461 const target = new EventTarget();462 const container = new FakeContainer();463 const active = new FakeServiceWorker("activated");464 container.controller = asServiceWorker(active);465 container.registration.active = asServiceWorker(active);466 container.registration.updateImplementation = () => new Promise(() => undefined);467 let expire: (() => void) | undefined;468 const controller = testController({469 target,470 container,471 production: true,472 setTimeout: (callback) => {473 expire = callback;474 return 1;475 },476 clearTimeout: () => undefined,477 });478 controller.initialize();479 await waitForState(controller, (state) => state.worker === "ready");480 const failed = waitForState(controller, (state) => state.update === "failed");481 target.dispatchEvent(new Event("online"));482 await Promise.resolve();483 expire?.();484 const state = await failed;485 if (state.worker !== "ready" || state.failure?.code !== "update-check") {486 throw new Error("bounded update failure was reported as an active-worker failure");487 }488});489490test("an installing update becomes explicitly ready", async () => {491 const container = new FakeContainer();492 const active = new FakeServiceWorker("activated");493 container.controller = asServiceWorker(active);494 container.registration.active = asServiceWorker(active);495 const controller = testController({ container, production: true });496 controller.initialize();497 await waitForState(controller, (state) => state.worker === "ready");498 const installing = new FakeServiceWorker("installing");499 container.registration.installing = asServiceWorker(installing);500 container.registration.dispatchEvent(new Event("updatefound"));501 if (controller.getState().update !== "installing") {502 throw new Error("installing update state was hidden");503 }504 const ready = waitForState(controller, (state) => state.update === "ready");505 installing.transition("installed");506 await ready;507});508509test("registration failure is classified without leaking the browser error", async () => {510 const container = new FakeContainer();511 container.registerError = new Error("private registration detail");512 const controller = testController({ container, production: true });513 const failed = waitForState(controller, (state) => state.failure?.code === "registration");514 controller.initialize();515 const state = await failed;516 if (state.failure?.message.includes("private")) throw new Error("registration detail leaked");517 if (!state.failure?.message.includes("Reload")) {518 throw new Error("registration failure is not actionable");519 }520});521522test("installation failure is classified from a redundant update", async () => {523 const container = new FakeContainer();524 const active = new FakeServiceWorker("activated");525 container.registration.active = asServiceWorker(active);526 container.controller = asServiceWorker(active);527 const controller = testController({ container, production: true });528 controller.initialize();529 await waitForState(controller, (state) => state.worker === "ready");530 const installing = new FakeServiceWorker("installing");531 container.registration.installing = asServiceWorker(installing);532 container.registration.dispatchEvent(new Event("updatefound"));533 const failed = waitForState(controller, (state) => state.failure?.code === "installation");534 installing.transition("redundant");535 await failed;536});537538for (const code of ["precache", "runtime-cache"] as const) {539 test(`${code} worker failure is classified without exposing message payloads`, async () => {540 const container = new FakeContainer();541 container.registration.active = asServiceWorker(new FakeServiceWorker("activated"));542 const controller = testController({ container, production: true });543 controller.initialize();544 await waitForState(controller, (state) => state.worker === "ready");545 const failed = waitForState(controller, (state) => state.failure?.code === code);546 container.dispatchEvent(547 new MessageEvent("message", {548 data: { type: "LOFI_PWA_FAILURE", code, message: "private cache detail" },549 }),550 );551 const state = await failed;552 if (state.failure?.message.includes("private")) throw new Error(`${code} detail leaked`);553 if (!state.failure?.message.includes("Reconnect")) {554 throw new Error(`${code} failure is not actionable`);555 }556 const expectedWorker = code === "runtime-cache" ? "ready" : "failed";557 if (state.worker !== expectedWorker) {558 throw new Error(`${code} produced worker state ${state.worker}, expected ${expectedWorker}`);559 }560 });561}