clone

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

feat/141-demo-review-fixes
lofi / package / runtime / sw_test.ts
sw_test.ts 16.9K
1const serviceWorkerUrl = new URL("./sw.js", import.meta.url);2const revision = "__LOFI_BUILD_REVISION__";3// The harness registers at scope https://example.com/app/, so cache names4// carry the "app" scope key and never collide with sibling base paths.5const shellCacheName = `lofi-scope-app-shell-${revision}`;6const runtimeCacheName = `lofi-scope-app-runtime-${revision}`;78function assert(condition: unknown, message: string): asserts condition {9 if (!condition) throw new Error(message);10}1112function requestUrl(input: RequestInfo | URL | Record<string, unknown>): string {13 if (input instanceof Request) return input.url;14 if (typeof input === "object" && "url" in input) return String(input.url);15 return String(input);16}1718class MemoryCache {19 readonly entries = new Map<string, Response>();20 putGate: Promise<void> | undefined;21 failPut = false;2223 match(24 input: RequestInfo | URL,25 options: CacheQueryOptions = {},26 ): Promise<Response | undefined> {27 const requested = new URL(requestUrl(input));28 if (options.ignoreSearch) requested.search = "";29 for (const [key, response] of this.entries) {30 const candidate = new URL(key);31 if (options.ignoreSearch) candidate.search = "";32 if (candidate.href === requested.href) return Promise.resolve(response.clone());33 }34 return Promise.resolve(undefined);35 }3637 async put(input: RequestInfo | URL, response: Response): Promise<void> {38 if (this.putGate) await this.putGate;39 if (this.failPut) throw new Error("simulated cache write failure");40 this.entries.set(requestUrl(input), response.clone());41 }4243 delete(input: RequestInfo | URL): Promise<boolean> {44 return Promise.resolve(this.entries.delete(requestUrl(input)));45 }4647 keys(): Promise<Request[]> {48 return Promise.resolve([...this.entries.keys()].map((url) => new Request(url)));49 }5051 async addAll(inputs: readonly (RequestInfo | URL)[]): Promise<void> {52 const responses = await Promise.all(inputs.map(async (input) => {53 const response = await fetch(input);54 if (!response.ok) throw new Error(`cache add failed: ${response.status}`);55 return { input, response };56 }));57 for (const { input, response } of responses) await this.put(input, response);58 }59}6061class MemoryCacheStorage {62 readonly stores = new Map<string, MemoryCache>();6364 open(name: string): Promise<MemoryCache> {65 let cache = this.stores.get(name);66 if (!cache) {67 cache = new MemoryCache();68 this.stores.set(name, cache);69 }70 return Promise.resolve(cache);71 }7273 async match(74 input: RequestInfo | URL,75 options?: MultiCacheQueryOptions,76 ): Promise<Response | undefined> {77 if (options?.cacheName) return await (await this.open(options.cacheName)).match(input, options);78 for (const cache of this.stores.values()) {79 const response = await cache.match(input, options);80 if (response) return response;81 }82 }8384 keys(): Promise<string[]> {85 return Promise.resolve([...this.stores.keys()]);86 }8788 delete(name: string): Promise<boolean> {89 return Promise.resolve(this.stores.delete(name));90 }91}9293type ServiceWorkerHandler = (event: Record<string, unknown>) => void;94type FetchResult = {95 response?: Promise<Response>;96 lifetimes: Promise<unknown>[];97 waitUntilCalls: number;98};99100async function serviceWorkerHarness(101 fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,102) {103 const handlers = new Map<string, ServiceWorkerHandler>();104 const messages: unknown[] = [];105 const caches = new MemoryCacheStorage();106 let claimed = 0;107 const fakeSelf = {108 location: { origin: "https://example.com" },109 registration: { scope: "https://example.com/app/" },110 clients: {111 claim() {112 claimed += 1;113 return Promise.resolve();114 },115 matchAll() {116 return Promise.resolve([{ postMessage: (message: unknown) => messages.push(message) }]);117 },118 },119 skipWaiting: () => Promise.resolve(),120 addEventListener(type: string, handler: ServiceWorkerHandler) {121 handlers.set(type, handler);122 },123 };124 const originals = new Map<string, PropertyDescriptor | undefined>();125 const replacements: Array<[string, unknown]> = [126 ["self", fakeSelf],127 ["caches", caches],128 ["fetch", fetchImpl],129 ];130 for (const [name, value] of replacements) {131 originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));132 Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });133 }134 await import(`${serviceWorkerUrl.href}?test=${crypto.randomUUID()}`);135136 const dispatchLifecycle = async (type: "install" | "activate") => {137 const lifetimes: Promise<unknown>[] = [];138 const handler = handlers.get(type);139 assert(handler, `service worker omitted ${type} handler`);140 handler({ waitUntil: (promise: Promise<unknown>) => lifetimes.push(Promise.resolve(promise)) });141 await Promise.all(lifetimes);142 };143 const dispatchFetch = (request: Record<string, unknown>): FetchResult => {144 const lifetimes: Promise<unknown>[] = [];145 let response: Promise<Response> | undefined;146 let waitUntilCalls = 0;147 const handler = handlers.get("fetch");148 assert(handler, "service worker omitted fetch handler");149 handler({150 request,151 respondWith: (promise: Promise<Response>) => response = Promise.resolve(promise),152 waitUntil: (promise: Promise<unknown>) => {153 waitUntilCalls += 1;154 lifetimes.push(Promise.resolve(promise));155 },156 });157 return { response, lifetimes, waitUntilCalls };158 };159 const close = () => {160 for (const [name, descriptor] of originals) {161 if (descriptor) Object.defineProperty(globalThis, name, descriptor);162 else delete (globalThis as Record<string, unknown>)[name];163 }164 };165 return { caches, claimed: () => claimed, close, dispatchFetch, dispatchLifecycle, messages };166}167168function fakeRequest(169 url: string,170 options: { destination?: string; mode?: string; method?: string } = {},171): Record<string, unknown> {172 return {173 url,174 destination: options.destination ?? "",175 mode: options.mode ?? "same-origin",176 method: options.method ?? "GET",177 };178}179180Deno.test("precache installation is atomic and reports an incomplete required shell", async () => {181 const harness = await serviceWorkerHarness((input) => {182 const url = requestUrl(input);183 if (url.endsWith("lofi-precache.json")) {184 return Promise.resolve(Response.json(["./", "./missing.js"]));185 }186 return Promise.resolve(187 new Response(url.endsWith("missing.js") ? "missing" : "shell", {188 status: url.endsWith("missing.js") ? 404 : 200,189 }),190 );191 });192 try {193 let rejected = false;194 try {195 await harness.dispatchLifecycle("install");196 } catch {197 rejected = true;198 }199 assert(rejected, "partial required shell did not reject installation");200 assert(201 JSON.stringify(harness.messages).includes('"code":"precache"'),202 "precache failure was not reported",203 );204 } finally {205 harness.close();206 }207});208209Deno.test("offline nested navigation uses its precached prerendered page", async () => {210 let offline = false;211 const harness = await serviceWorkerHarness((input) => {212 if (offline) return Promise.reject(new Error("offline"));213 const url = requestUrl(input);214 if (url.endsWith("lofi-precache.json")) {215 return Promise.resolve(Response.json(["./", "./settings/index.html"]));216 }217 return Promise.resolve(218 new Response(url.endsWith("settings/index.html") ? "nested settings" : "root shell"),219 );220 });221 try {222 await harness.dispatchLifecycle("install");223 offline = true;224 const event = harness.dispatchFetch(fakeRequest("https://example.com/app/settings/", {225 mode: "navigate",226 }));227 assert(event.response, "navigation was not handled");228 assert(await (await event.response).text() === "nested settings", "nested shell was not used");229 await Promise.all(event.lifetimes);230 } finally {231 harness.close();232 }233});234235Deno.test("runtime cache writes remain attached to the fetch event lifetime", async () => {236 const harness = await serviceWorkerHarness(() => Promise.resolve(new Response("image bytes")));237 let releasePut: () => void = () => undefined;238 const putGate = new Promise<void>((resolve) => releasePut = resolve);239 try {240 const runtime = await harness.caches.open(runtimeCacheName);241 runtime.putGate = putGate;242 const request = fakeRequest("https://example.com/app/photo.png", { destination: "image" });243 const event = harness.dispatchFetch(request);244 assert(event.waitUntilCalls === 1, "runtime lifecycle was not attached synchronously");245 assert(event.response, "eligible request was not handled");246 assert(await (await event.response).text() === "image bytes", "network response was delayed");247 let settled = false;248 void Promise.all(event.lifetimes).then(() => settled = true);249 await Promise.resolve();250 assert(!settled, "fetch lifetime settled before the cache write");251 releasePut();252 await Promise.all(event.lifetimes);253 assert(254 await runtime.match("https://example.com/app/photo.png"),255 "runtime response was not cached",256 );257 } finally {258 harness.close();259 }260});261262Deno.test("runtime cache rejects disallowed destinations and private responses", async () => {263 const harness = await serviceWorkerHarness((input) => {264 const privateResponse = requestUrl(input).endsWith("private.png");265 return Promise.resolve(266 new Response("network", {267 headers: privateResponse ? { "cache-control": "private" } : undefined,268 }),269 );270 });271 try {272 const documentEvent = harness.dispatchFetch(273 fakeRequest("https://example.com/app/data.json", { destination: "" }),274 );275 assert(documentEvent.response, "same-origin request was not answered");276 await documentEvent.response;277 await Promise.all(documentEvent.lifetimes);278279 const privateEvent = harness.dispatchFetch(280 fakeRequest("https://example.com/app/private.png", { destination: "image" }),281 );282 assert(privateEvent.response, "private image request was not answered");283 await privateEvent.response;284 await Promise.all(privateEvent.lifetimes);285286 const outsideScope = harness.dispatchFetch(287 fakeRequest("https://example.com/outside/photo.png", { destination: "image" }),288 );289 assert(outsideScope.response, "same-origin outside-scope request was not answered");290 await outsideScope.response;291 await Promise.all(outsideScope.lifetimes);292 const runtime = await harness.caches.open(runtimeCacheName);293 assert((await runtime.keys()).length === 0, "unsuitable responses entered runtime cache");294295 const crossOrigin = harness.dispatchFetch(296 fakeRequest("https://cdn.example.net/photo.png", { destination: "image" }),297 );298 assert(!crossOrigin.response, "cross-origin request was intercepted");299 } finally {300 harness.close();301 }302});303304Deno.test("runtime cache evicts its oldest entry at the fixed size bound", async () => {305 const harness = await serviceWorkerHarness(() => Promise.resolve(new Response("new")));306 try {307 const runtime = await harness.caches.open(runtimeCacheName);308 for (let index = 0; index < 64; index += 1) {309 await runtime.put(`https://example.com/app/${index}.png`, new Response(String(index)));310 }311 const event = harness.dispatchFetch(312 fakeRequest("https://example.com/app/new.png", { destination: "image" }),313 );314 assert(event.response, "bounded cache request was not answered");315 await event.response;316 await Promise.all(event.lifetimes);317 assert((await runtime.keys()).length === 64, "runtime cache exceeded its size bound");318 assert(!(await runtime.match("https://example.com/app/0.png")), "oldest entry was not evicted");319 assert(await runtime.match("https://example.com/app/new.png"), "newest entry was not retained");320 } finally {321 harness.close();322 }323});324325Deno.test("activation prunes only this scope's stale revisions", async () => {326 const harness = await serviceWorkerHarness(() => Promise.resolve(new Response("unused")));327 try {328 await harness.caches.open(`lofi-scope-app-shell-old`);329 await harness.caches.open(`lofi-scope-app-runtime-old`);330 await harness.caches.open(shellCacheName);331 await harness.caches.open(runtimeCacheName);332 // A sibling lofi app at another base path and a legacy unscoped cache —333 // neither provably ours, neither may be deleted.334 await harness.caches.open("lofi-scope-other-shell-old");335 await harness.caches.open("lofi-shell-legacy");336 await harness.caches.open("lofi-runtime-legacy");337 await harness.caches.open("product-cache");338 await harness.dispatchLifecycle("activate");339 const names = await harness.caches.keys();340 assert(!names.includes("lofi-scope-app-shell-old"), "stale shell revision survived activation");341 assert(342 !names.includes("lofi-scope-app-runtime-old"),343 "stale runtime revision survived activation",344 );345 assert(346 names.includes(shellCacheName) && names.includes(runtimeCacheName),347 "current caches removed",348 );349 assert(350 names.includes("lofi-scope-other-shell-old"),351 "a sibling app's scoped cache was deleted",352 );353 assert(354 names.includes("lofi-shell-legacy") && names.includes("lofi-runtime-legacy"),355 "ambiguous legacy caches were deleted",356 );357 assert(names.includes("product-cache"), "unrelated cache was removed");358 assert(harness.claimed() === 1, "activation did not claim clients");359 } finally {360 harness.close();361 }362});363364Deno.test("lookups never read another cache on the origin", async () => {365 const harness = await serviceWorkerHarness(() => Promise.resolve(new Response("network")));366 try {367 const product = await harness.caches.open("product-cache");368 await product.put("https://example.com/app/photo.png", new Response("product shadow"));369 const event = harness.dispatchFetch(370 fakeRequest("https://example.com/app/photo.png", { destination: "image" }),371 );372 assert(event.response, "request was not handled");373 assert(374 await (await event.response).text() === "network",375 "a foreign cache shadowed the worker's own caches",376 );377 await Promise.all(event.lifetimes);378 } finally {379 harness.close();380 }381});382383Deno.test("shell precache bypasses the HTTP cache only for mutable assets", async () => {384 const precacheInputs: Array<RequestInfo | URL> = [];385 const harness = await serviceWorkerHarness((input) => {386 const url = requestUrl(input);387 if (url.endsWith("lofi-precache.json")) {388 return Promise.resolve(389 Response.json([390 "./",391 "./_astro/runtime.AAAA1111.js",392 "./_astro/jazz_wasm_bg.BBBB2222.wasm",393 ]),394 );395 }396 precacheInputs.push(input);397 return Promise.resolve(new Response("shell"));398 });399 try {400 await harness.dispatchLifecycle("install");401 assert(precacheInputs.length === 3, "expected three precached shell assets");402 const modes = new Map<string, string>();403 for (const input of precacheInputs) {404 assert(input instanceof Request, "precache must construct explicit requests");405 modes.set(new URL(input.url).pathname, input.cache);406 }407 assert(408 modes.get("/app/") === "reload",409 "mutable shell assets must bypass the HTTP cache, matching the manifest's no-store fetch",410 );411 assert(412 modes.get("/app/_astro/runtime.AAAA1111.js") === "default" &&413 modes.get("/app/_astro/jazz_wasm_bg.BBBB2222.wasm") === "default",414 "content-hashed assets must admit the HTTP cache so a first visit is not downloaded twice",415 );416 } finally {417 harness.close();418 }419});420421Deno.test("range requests are left to the network", async () => {422 const harness = await serviceWorkerHarness(() => Promise.resolve(new Response("full body")));423 try {424 const shell = await harness.caches.open(shellCacheName);425 await shell.put("https://example.com/app/audio.mp3", new Response("cached full"));426 const event = harness.dispatchFetch({427 ...fakeRequest("https://example.com/app/audio.mp3", { destination: "" }),428 headers: { get: (name: string) => name === "range" ? "bytes=0-1023" : null },429 });430 assert(431 event.response === undefined,432 "a Range request must not be answered with a cached 200",433 );434 } finally {435 harness.close();436 }437});438439Deno.test("runtime cache failure is reported without failing the network response", async () => {440 const harness = await serviceWorkerHarness(() => Promise.resolve(new Response("network image")));441 try {442 const runtime = await harness.caches.open(runtimeCacheName);443 runtime.failPut = true;444 const event = harness.dispatchFetch(445 fakeRequest("https://example.com/app/photo.png", { destination: "image" }),446 );447 assert(event.response, "runtime failure request was not answered");448 assert(await (await event.response).text() === "network image", "cache failure lost response");449 await Promise.all(event.lifetimes);450 assert(451 JSON.stringify(harness.messages).includes('"code":"runtime-cache"'),452 "runtime cache failure was not reported",453 );454 } finally {455 harness.close();456 }457});