diff --git a/web/src/sw.test.ts b/web/src/sw.test.ts new file mode 100644 index 0000000..75e6d20 --- /dev/null +++ b/web/src/sw.test.ts @@ -0,0 +1,47 @@ +// Runs the service worker's activate handler against mocked Cache Storage, +// with the Workbox precache and router stubbed out. tsconfig.sw.json checks +// this file with the worker's types, like src/sw.ts itself. +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('workbox-precaching', () => ({ + PrecacheFallbackPlugin: class {}, + matchPrecache: vi.fn(), + precacheAndRoute: vi.fn(), +})) +vi.mock('workbox-routing', () => ({ registerRoute: vi.fn() })) + +describe('service worker activation', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('deletes the v1 API cache and keeps the precache, photo and font caches', async () => { + // A household on the v1 worker: its API cache held dinners with the + // pasted recipe_url; its photos and fonts are not in the precache. + const names = [ + 'mealquest-precache-v1', + 'mealquest-api-v1', + 'mealquest-media-v1', + 'mealquest-fonts-v1', + 'other-app-cache', + ] + const cacheStorage = { + keys: vi.fn(async () => names), + delete: vi.fn(async () => true), + } + vi.stubGlobal('caches', cacheStorage) + vi.stubGlobal('__WB_MANIFEST', []) + await import('./sw') + + const pending: Promise[] = [] + const activate = Object.assign(new Event('activate'), { + waitUntil: (promise: Promise) => pending.push(promise), + }) + self.dispatchEvent(activate) + await Promise.all(pending) + + expect(pending).toHaveLength(1) + expect(cacheStorage.delete).toHaveBeenCalledWith('mealquest-api-v1') + expect(cacheStorage.delete).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/src/sw.ts b/web/src/sw.ts index 8e797c9..3983585 100644 --- a/web/src/sw.ts +++ b/web/src/sw.ts @@ -18,7 +18,7 @@ import { CacheFirst, NetworkOnly, type Strategy } from 'workbox-strategies' import { createApiStrategy } from './sw/apiStrategy' import { cacheNames, - cacheVersion, + cacheVersions, routeFor, staleCaches, type Route, @@ -28,8 +28,8 @@ declare const self: ServiceWorkerGlobalScope & { __WB_MANIFEST: Array<{ url: string; revision: string | null }> } -// The precache becomes mealquest-precache-v2. -setCacheNameDetails({ prefix: 'mealquest', precache: 'precache', suffix: cacheVersion }) +// The precache becomes mealquest-precache-v1. +setCacheNameDetails({ prefix: 'mealquest', precache: 'precache', suffix: cacheVersions.precache }) const day = 24 * 60 * 60 diff --git a/web/src/sw/routes.test.ts b/web/src/sw/routes.test.ts index 273df82..62685b3 100644 --- a/web/src/sw/routes.test.ts +++ b/web/src/sw/routes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { cacheFallbackHeader, cacheNames, markCacheFallback, routeFor, staleCaches, type Route } from './routes' +import { cacheFallbackHeader, cacheNames, cacheVersions, markCacheFallback, routeFor, staleCaches, type Route } from './routes' const origin = 'https://mealquest.test' @@ -147,19 +147,22 @@ describe('cache names', () => { for (const name of Object.values(cacheNames)) expect(name).toMatch(/^mealquest-/) }) - it('are v2, so activate deletes the v1 caches', () => { + it('retire only the v1 API cache', () => { // v1 API responses held dinners with the pasted recipe_url, which can // carry a personal token; offline, the worker would still serve them. - expect(cacheNames.api).toBe('mealquest-api-v2') - const current = ['mealquest-precache-v2', ...Object.values(cacheNames)] - const v1 = ['mealquest-precache-v1', 'mealquest-api-v1', 'mealquest-media-v1', 'mealquest-fonts-v1'] - expect(staleCaches([...v1, ...current], current)).toEqual(v1) + // The precache, photos and fonts keep their v1 caches. + expect(cacheNames).toEqual({ + api: 'mealquest-api-v2', + media: 'mealquest-media-v1', + fonts: 'mealquest-fonts-v1', + }) + expect(cacheVersions.precache).toBe('v1') }) }) describe('staleCaches', () => { it('deletes only MealQuest caches that are not current', () => { - const current = ['mealquest-precache-v2', ...Object.values(cacheNames)] + const current = ['mealquest-precache-v1', ...Object.values(cacheNames)] const names = [ ...current, 'mealquest-precache', diff --git a/web/src/sw/routes.ts b/web/src/sw/routes.ts index f865452..e1f0ea3 100644 --- a/web/src/sw/routes.ts +++ b/web/src/sw/routes.ts @@ -24,16 +24,23 @@ export interface RouteRequest { mode: RequestMode } -// Bumping the version renames every cache; activate then deletes the old ones. -// v2 drops v1 API responses, whose dinners still carried the pasted -// recipe_url. -export const cacheVersion = 'v2' +// Each cache carries its own version. Bumping one renames only that cache; +// activate then deletes its old name and keeps every other cache, so saved +// photos and fonts stay available offline. +export const cacheVersions = { + precache: 'v1', + // v2 retires the v1 API cache, whose dinner responses still carried the + // pasted recipe_url. + api: 'v2', + media: 'v1', + fonts: 'v1', +} as const export const cachePrefix = 'mealquest-' export const cacheNames = { - api: `${cachePrefix}api-${cacheVersion}`, - media: `${cachePrefix}media-${cacheVersion}`, - fonts: `${cachePrefix}fonts-${cacheVersion}`, + api: `${cachePrefix}api-${cacheVersions.api}`, + media: `${cachePrefix}media-${cacheVersions.media}`, + fonts: `${cachePrefix}fonts-${cacheVersions.fonts}`, } as const export const networkTimeoutSeconds = 3 diff --git a/web/src/sw/worker.test.ts b/web/src/sw/worker.test.ts index 4f223c4..9c858c5 100644 --- a/web/src/sw/worker.test.ts +++ b/web/src/sw/worker.test.ts @@ -1,8 +1,8 @@ // The service worker itself needs worker globals, so these tests read its // source: they pin the parts routes.ts cannot express — the API cache's // strategy, the font strategy's expiration, the font route's place ahead of -// the precache route, network-only requests left to the browser and stale -// caches deleted on activate. +// the precache route and network-only requests left to the browser. +// src/sw.test.ts runs the worker's activation against mocked Cache Storage. import { describe, expect, it } from 'vitest' import source from '../sw.ts?raw' @@ -38,12 +38,6 @@ describe('service worker source', () => { expect(source).not.toContain('registerRoute(() => true') }) - it('deletes every stale cache on activate', () => { - expect(source).toMatch( - /self\.addEventListener\('activate', \(event\) => \{\s*const current = \[workboxCacheNames\.precache, \.\.\.Object\.values\(cacheNames\)\][\s\S]*?staleCaches\(names, current\)\.map\(\(name\) => caches\.delete\(name\)\)/, - ) - }) - it('activates a waiting worker when the update prompt sends SKIP_WAITING', () => { expect(source).toMatch( /self\.addEventListener\('message', \(event\) => \{\s*if \(event\.data\?\.type === 'SKIP_WAITING'\) void self\.skipWaiting\(\)/, diff --git a/web/tsconfig.json b/web/tsconfig.json index 95c28ed..a11d80b 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -19,5 +19,5 @@ "noEmit": true }, "include": ["src", "vite.config.ts", "manifest.json"], - "exclude": ["src/sw.ts"] + "exclude": ["src/sw.ts", "src/sw.test.ts"] } diff --git a/web/tsconfig.sw.json b/web/tsconfig.sw.json index a71dd06..ced4bf6 100644 --- a/web/tsconfig.sw.json +++ b/web/tsconfig.sw.json @@ -4,6 +4,6 @@ "lib": ["ES2022", "WebWorker"], "types": [] }, - "include": ["src/sw.ts"], + "include": ["src/sw.ts", "src/sw.test.ts"], "exclude": [] }