Clear offline data on logout, test offline plan and recipe reads, drop Pillow
CI / ci (push) Successful in 1m35s
CI / ci (pull_request) Successful in 1m38s

- POST /auth/logout answers with Clear-Site-Data: "storage", "cache", which
  removes the verified-session marker, the service worker and its caches, so
  an offline visit after logout cannot reopen household data. The 401/403
  clearing path is unchanged.
- The service worker no longer answers network-only requests; it leaves them
  to the browser. Chrome stalls a Clear-Site-Data navigation that the worker
  answered, so the logout POST must bypass it.
- New tests send plan and recipe reads through the real API client and the
  real network-first strategy online, then serve them from the cache offline.
- The icon generator is now Go on golang.org/x/image (approved) instead of
  Python on Pillow; the icons are regenerated with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 09:36:45 -04:00
co-authored by Claude Opus 5.5
parent 7f8ae90978
commit 8f5ab773fc
15 changed files with 306 additions and 71 deletions
+71 -2
View File
@@ -2,6 +2,9 @@
// storage: jsdom has none, and the worker itself needs service worker globals.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RouteHandlerCallbackOptions } from 'workbox-core/types'
import { createApiClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { fakeLocation, jsonResponse } from '../test/fetchStub.ts'
import { createApiStrategy } from './apiStrategy'
import { cacheFallbackHeader, cacheNames, routeFor } from './routes'
@@ -52,8 +55,11 @@ afterEach(() => {
// handle sends a GET through the API strategy, as src/sw.ts does for a
// network-first route.
async function handle(path: string): Promise<Response> {
const request = new Request(new URL(path, origin))
expect(routeFor({ method: 'GET', url: new URL(request.url), mode: 'cors' }, origin)).toEqual({
return answer(new Request(new URL(path, origin)))
}
async function answer(request: Request): Promise<Response> {
expect(routeFor({ method: request.method, url: new URL(request.url), mode: 'cors' }, origin)).toEqual({
strategy: 'network-first',
cacheName: cacheNames.api,
})
@@ -94,3 +100,66 @@ describe('API strategy', () => {
await expect(handle('/api/v1/recipes/01J9ZS')).rejects.toThrow()
})
})
describe('previously viewed plan and recipe reads', () => {
const plan = { week: '2026-W40', days: [{ date: '2026-10-05', dinner: 'Chicken tikka masala' }] }
const recipe = { id: '01J9ZR', name: 'Chicken tikka masala', steps: ['Marinate', 'Simmer'] }
const server: Record<string, unknown> = { '/api/v1/plans/2026-W40': plan, '/api/v1/recipes/01J9ZR': recipe }
let reachable: boolean
// The page's fetch goes through the worker's API strategy; the strategy's
// own fetch (always a Request) is the network. The API client fetches a
// path string, so the two are told apart by the argument's type. As in a
// browser, a worker that cannot answer is a network error to the page.
beforeEach(() => {
reachable = true
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => reachable)
vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
if (!(input instanceof Request)) {
return answer(new Request(new URL(String(input), origin), init)).catch((error: unknown) => {
throw new TypeError('Failed to fetch', { cause: error })
})
}
if (!reachable) throw new TypeError('Failed to fetch')
const body = server[new URL(input.url).pathname]
if (body === undefined) throw new Error(`unexpected network fetch: ${input.url}`)
return jsonResponse(200, body)
})
})
afterEach(() => {
vi.restoreAllMocks()
})
function client() {
return createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
}
it('serves the plan and recipe fetched online once the network is gone', async () => {
const online = client()
expect(await online.apiFetch('/api/v1/plans/2026-W40')).toEqual(plan)
expect(await online.apiFetch('/api/v1/recipes/01J9ZR')).toEqual(recipe)
expect(online.network.offline()).toBe(false)
reachable = false
const offline = client()
expect(await offline.apiFetch('/api/v1/plans/2026-W40')).toEqual(plan)
expect(await offline.apiFetch('/api/v1/recipes/01J9ZR')).toEqual(recipe)
expect(offline.network.offline()).toBe(true)
})
it('shows the offline indicator when the cached copy answers while the browser reports online', async () => {
await client().apiFetch('/api/v1/plans/2026-W40')
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
reachable = false
const api = client()
expect(await api.apiFetch('/api/v1/plans/2026-W40')).toEqual(plan)
expect(api.network.offline()).toBe(true)
})
it('fails offline for a plan never fetched', async () => {
reachable = false
await expect(client().apiFetch('/api/v1/plans/2026-W41')).rejects.toThrow(TypeError)
})
})
+1 -1
View File
@@ -13,7 +13,7 @@ export type Route =
| { strategy: 'network-first'; cacheName: typeof cacheNames.api }
// Cached response when there is one, else the network; entries expire.
| { strategy: 'cache-first'; cacheName: typeof cacheNames.media | typeof cacheNames.fonts }
// Network only, nothing cached.
// Network only, nothing cached: the worker leaves the request to the browser.
| { strategy: 'network-only' }
// Network only, nothing cached; offline, the precached index.html.
| { strategy: 'navigation-fallback' }
+12 -2
View File
@@ -1,7 +1,7 @@
// 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 and the font route's place ahead of
// the precache route.
// strategy, the font strategy's expiration, the font route's place ahead of
// the precache route and network-only requests left to the browser.
import { describe, expect, it } from 'vitest'
import source from '../sw.ts?raw'
@@ -27,6 +27,16 @@ describe('service worker source', () => {
expect(precache).toBeGreaterThan(fontRoute)
})
it('leaves network-only requests to the browser instead of answering them', () => {
// Chrome stalls a navigation whose Clear-Site-Data response (POST
// /auth/logout) the worker answered, so the worker must not match it.
expect(source).toMatch(
/function answered\(options: \{ request: Request; url: URL \}\): boolean \{\s*return routeOf\(options\)\.strategy !== 'network-only'/,
)
expect(source).toContain('registerRoute(answered, handle, method)')
expect(source).not.toContain('registerRoute(() => true')
})
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\(\)/,