Prove dinner detail works offline once visited (Feature 15)
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:
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user