Prove dinner detail works offline once visited (Feature 15)
CI / ci (push) Successful in 5m6s
CI / ci (pull_request) Successful in 5m1s

Runs the detail page through the service worker's real network-first API
strategy against an in-memory cache: a visited grounded dinner reloaded
offline shows its photo, ingredients and steps with the offline indicator
(phone and Kitchen reading mode); a dinner never visited shows the error
with Retry; a saved pending copy stays "still finding the recipe".

Moves the worker test harness out of apiStrategy.test.ts into
src/test/serviceWorker.ts so both tests share it, and routes network-only
paths (settings) straight to the network as the worker does.

No product change: the existing route, strategy, apiRead and page already
satisfy the done-condition.

Maestro task caa384bc-2853-4915-9cb3-ccb80ad20cb3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 10:56:13 -04:00
co-authored by Claude Opus 5.5
parent 1028817193
commit 5867218661
3 changed files with 269 additions and 61 deletions
@@ -0,0 +1,184 @@
// A dinner's detail offline once visited: the page reads through the service
// worker's real API strategy (src/test/serviceWorker.ts), so what it shows
// offline is what the worker saved while it was online. jsdom has no service
// worker, so the strategy runs directly against an in-memory cache.
import { render, screen, within } from '@testing-library/react'
import { createMemoryRouter, RouterProvider } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { OfflinePill } from '../pwa/OfflinePill.tsx'
import { AppShell } from '../shell/AppShell.tsx'
import { cacheNames, routeFor } from '../sw/routes.ts'
import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { errorResponse, jsonResponse } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { origin, stubFetchThroughWorker, stubWorkerCaches, type FakeCache } from '../test/serviceWorker.ts'
import { DinnerDetailPage } from './DinnerDetailPage.tsx'
// grounded is a dinner as GET /api/v1/dinners/{id} sends it, its recipe found.
function grounded(id: string, name: string) {
return {
id,
name,
status: 'quest_log',
origin: 'suggested',
method: 'stovetop',
minutes_total: 30,
difficulty: 'easy',
cuisine: 'korean',
is_favorite: false,
photo_urls: { '400': `/media/photos/${id}-400.jpg`, '1200': `/media/photos/${id}-1200.jpg` },
ingredients: [
{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' },
{ name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' },
],
steps: ['Cook the rice.', 'Brown the beef with the sauce.'],
source: { site_name: 'Serious Eats' },
grounding_status: 'grounded',
grounding_error: null,
why_picked: null,
}
}
// server is what the server sends while it can be reached. Kitchen reading
// mode also reads the household settings, which the worker never saves, and
// the week's plans to find the dinner's day, of which there are none here.
const server: Record<string, unknown> = {
'/api/v1/settings/household': HOUSEHOLD_SETTINGS,
'/api/v1/dinners/d1': grounded('d1', 'Korean beef bowls'),
'/api/v1/dinners/d2': grounded('d2', 'Chicken tikka masala'),
'/api/v1/dinners/d3': {
...grounded('d3', 'Pork carnitas'),
photo_urls: null,
ingredients: [],
steps: [],
source: null,
grounding_status: 'pending',
},
}
let cache: FakeCache
let reachable: boolean
// networkReads is every path the network was asked for, reached or not.
let networkReads: string[]
beforeEach(() => {
cache = stubWorkerCaches()
reachable = true
networkReads = []
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => reachable)
stubFetchThroughWorker(async (request) => {
const path = new URL(request.url).pathname
networkReads.push(path)
if (!reachable) throw new TypeError('Failed to fetch')
if (path.startsWith('/api/v1/plans/')) return errorResponse(404, 'not_found')
if (!Object.hasOwn(server, path)) throw new Error(`unexpected network fetch: ${request.url}`)
return jsonResponse(200, server[path])
})
})
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
// open loads /dinner/{id} as a fresh page load would: a new API client,
// network status and query cache, with the offline indicator App shows.
function open(id: string) {
const router = createMemoryRouter(
[{ element: <AppShell />, children: [{ path: '/dinner/:id', element: <DinnerDetailPage /> }] }],
{ initialEntries: [`/dinner/${id}`] },
)
return render(
<ApiProviders>
<RouterProvider router={router} />
<OfflinePill />
</ApiProviders>,
)
}
// visit opens a dinner while online and leaves once its title shows.
async function visit(id: string, name: string) {
const page = open(id)
expect(await screen.findByRole('heading', { level: 1, name })).toBeInTheDocument()
expect(screen.queryByText('Offline — showing saved data')).not.toBeInTheDocument()
page.unmount()
}
describe('dinner detail offline', () => {
it('saves a visited dinner in the API cache the worker reads offline', async () => {
mockViewport(390)
expect(routeFor({ method: 'GET', url: new URL('/api/v1/dinners/d1', origin), mode: 'cors' }, origin)).toEqual({
strategy: 'network-first',
cacheName: cacheNames.api,
})
await visit('d1', 'Korean beef bowls')
expect([...cache.entries.keys()]).toEqual([`${origin}/api/v1/dinners/d1`])
})
it('shows a visited dinner offline from its saved copy: photo, ingredients and steps, with the offline indicator', async () => {
mockViewport(390)
await visit('d1', 'Korean beef bowls')
reachable = false
open('d1')
expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveAttribute('src', '/media/photos/d1-400.jpg')
const [ingredients, steps] = screen.getAllByRole('list')
expect(within(ingredients!).getAllByRole('listitem').map((row) => row.textContent)).toEqual([
'Ground beef1 lb',
'Jasmine rice2 cups',
])
expect(within(steps!).getAllByRole('listitem').map((row) => row.textContent)).toEqual([
'1Cook the rice.',
'2Brown the beef with the sauce.',
])
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.queryByLabelText('Dinner loading')).not.toBeInTheDocument()
})
it('shows a visited dinner offline in Kitchen reading mode on a tablet', async () => {
mockViewport(1180)
await visit('d1', 'Korean beef bowls')
reachable = false
open('d1')
expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument()
expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument()
expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveAttribute('src', '/media/photos/d1-400.jpg')
expect(within(screen.getByRole('region', { name: 'Ingredients' })).getAllByRole('checkbox')).toHaveLength(2)
expect(screen.getByRole('listitem', { current: 'step' })).toHaveTextContent('Cook the rice.')
})
it('shows an error, not a blank page or placeholder, for a dinner never visited', async () => {
mockViewport(390)
await visit('d1', 'Korean beef bowls')
reachable = false
open('d2')
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load this dinner.")
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
expect(screen.queryByRole('heading', { level: 1 })).not.toBeInTheDocument()
expect(screen.queryByText(/Korean beef bowls|Chicken tikka masala/)).not.toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument()
expect(screen.queryByLabelText('Dinner loading')).not.toBeInTheDocument()
expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument()
})
it('keeps a saved copy still finding its recipe as that, not a finished recipe', async () => {
mockViewport(390)
await visit('d3', 'Pork carnitas')
reachable = false
open('d3')
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument()
expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument()
// Read once online and tried once offline before the saved copy answered.
expect(networkReads.filter((path) => path.startsWith('/api/v1/dinners/'))).toEqual([
'/api/v1/dinners/d3',
'/api/v1/dinners/d3',
])
})
})
+6 -61
View File
@@ -1,51 +1,16 @@
// Runs the real Workbox network-first strategy against an in-memory cache
// 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'
const origin = 'https://mealquest.test'
// FakeCache keys entries by URL, which is all the API cache needs.
class FakeCache {
readonly entries = new Map<string, Response>()
async match(request: RequestInfo | URL): Promise<Response | undefined> {
return this.entries.get(new Request(request).url)?.clone()
}
async put(request: RequestInfo | URL, response: Response): Promise<void> {
this.entries.set(new Request(request).url, response)
}
}
// FakeExtendableEvent stands in for the worker-only ExtendableEvent,
// collecting the promises the strategy waits on.
class FakeExtendableEvent extends Event {
readonly pending: Promise<unknown>[] = []
waitUntil(promise: Promise<unknown>) {
this.pending.push(promise)
}
}
import { answer, origin, stubFetchThroughWorker, stubWorkerCaches, type FakeCache } from '../test/serviceWorker.ts'
import { cacheFallbackHeader } from './routes'
let cache: FakeCache
beforeEach(() => {
cache = new FakeCache()
// Workbox checks its arguments against these worker-only globals.
vi.stubGlobal('FetchEvent', class FetchEvent {})
vi.stubGlobal('ExtendableEvent', FakeExtendableEvent)
const only = (name: string | undefined) => {
if (name !== cacheNames.api) throw new Error(`unexpected cache ${name}`)
return cache
}
vi.stubGlobal('caches', {
open: async (name: string) => only(name),
match: async (request: RequestInfo | URL, options?: MultiCacheQueryOptions) =>
only(options?.cacheName).match(request),
})
cache = stubWorkerCaches()
})
afterEach(() => {
@@ -58,17 +23,6 @@ async function handle(path: string): Promise<Response> {
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,
})
const event = new FakeExtendableEvent('fetch')
const response = await createApiStrategy().handle({ request, event: event as unknown as RouteHandlerCallbackOptions['event'] })
await Promise.all(event.pending)
return response
}
function recipe(): Response {
return new Response(JSON.stringify({ id: '01J9ZR', name: 'Chicken tikka masala' }), {
status: 200,
@@ -107,22 +61,13 @@ describe('previously viewed plan and recipe reads', () => {
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 })
})
}
stubFetchThroughWorker(async (request) => {
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}`)
const body = server[new URL(request.url).pathname]
if (body === undefined) throw new Error(`unexpected network fetch: ${request.url}`)
return jsonResponse(200, body)
})
})
+79
View File
@@ -0,0 +1,79 @@
// Runs requests through the service worker's real API strategy against an
// in-memory cache storage: jsdom has neither a service worker nor caches.
import { expect, vi } from 'vitest'
import type { RouteHandlerCallbackOptions } from 'workbox-core/types'
import { createApiStrategy } from '../sw/apiStrategy.ts'
import { cacheNames, routeFor } from '../sw/routes.ts'
export const origin = 'https://mealquest.test'
// FakeCache keys entries by URL, which is all the API cache needs.
export class FakeCache {
readonly entries = new Map<string, Response>()
async match(request: RequestInfo | URL): Promise<Response | undefined> {
return this.entries.get(new Request(request).url)?.clone()
}
async put(request: RequestInfo | URL, response: Response): Promise<void> {
this.entries.set(new Request(request).url, response)
}
}
// FakeExtendableEvent stands in for the worker-only ExtendableEvent,
// collecting the promises the strategy waits on.
class FakeExtendableEvent extends Event {
readonly pending: Promise<unknown>[] = []
waitUntil(promise: Promise<unknown>) {
this.pending.push(promise)
}
}
// stubWorkerCaches stubs the worker globals Workbox checks and a cache
// storage holding only the API cache, which it returns. Undo with
// vi.unstubAllGlobals.
export function stubWorkerCaches(): FakeCache {
const cache = new FakeCache()
vi.stubGlobal('FetchEvent', class FetchEvent {})
vi.stubGlobal('ExtendableEvent', FakeExtendableEvent)
const only = (name: string | undefined) => {
if (name !== cacheNames.api) throw new Error(`unexpected cache ${name}`)
return cache
}
vi.stubGlobal('caches', {
open: async (name: string) => only(name),
match: async (request: RequestInfo | URL, options?: MultiCacheQueryOptions) =>
only(options?.cacheName).match(request),
})
return cache
}
// answer sends a GET through the API strategy, as src/sw.ts does for a
// network-first route; the strategy's own fetch is the network.
export 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,
})
const event = new FakeExtendableEvent('fetch')
const response = await createApiStrategy().handle({ request, event: event as unknown as RouteHandlerCallbackOptions['event'] })
await Promise.all(event.pending)
return response
}
// stubFetchThroughWorker replaces the global fetch with the page's view of
// it behind the worker, routed as src/sw.ts routes it: a network-first path
// from the API client goes through the API strategy, a network-only one
// straight to network; the strategy's own fetch, a Request, is network too.
// As in a browser, a worker that cannot answer is a network error to the
// page. Undo with vi.unstubAllGlobals.
export function stubFetchThroughWorker(network: (request: Request) => Promise<Response>) {
vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
if (input instanceof Request) return network(input)
const request = new Request(new URL(String(input), origin), init)
const route = routeFor({ method: request.method, url: new URL(request.url), mode: 'cors' }, origin)
if (route.strategy === 'network-only') return network(request)
if (route.strategy !== 'network-first') throw new Error(`unexpected ${route.strategy} fetch: ${request.url}`)
return answer(request).catch((error: unknown) => {
throw new TypeError('Failed to fetch', { cause: error })
})
})
}