From b40af908a976fc522d48a2ac496ff7cc51875312 Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Fri, 9 Oct 2026 12:24:14 -0400 Subject: [PATCH] Recipe detail works offline once visited (Feature 15) (#159) Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> --- web/src/dinners/DinnerDetailOffline.test.tsx | 184 +++++++++++++++++++ web/src/sw/apiStrategy.test.ts | 67 +------ web/src/test/serviceWorker.ts | 79 ++++++++ 3 files changed, 269 insertions(+), 61 deletions(-) create mode 100644 web/src/dinners/DinnerDetailOffline.test.tsx create mode 100644 web/src/test/serviceWorker.ts diff --git a/web/src/dinners/DinnerDetailOffline.test.tsx b/web/src/dinners/DinnerDetailOffline.test.tsx new file mode 100644 index 0000000..386a1ec --- /dev/null +++ b/web/src/dinners/DinnerDetailOffline.test.tsx @@ -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 = { + '/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: , children: [{ path: '/dinner/:id', element: }] }], + { initialEntries: [`/dinner/${id}`] }, + ) + return render( + + + + , + ) +} + +// 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', + ]) + }) +}) diff --git a/web/src/sw/apiStrategy.test.ts b/web/src/sw/apiStrategy.test.ts index 46a7f5e..0aaab31 100644 --- a/web/src/sw/apiStrategy.test.ts +++ b/web/src/sw/apiStrategy.test.ts @@ -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() - async match(request: RequestInfo | URL): Promise { - return this.entries.get(new Request(request).url)?.clone() - } - async put(request: RequestInfo | URL, response: Response): Promise { - 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[] = [] - waitUntil(promise: Promise) { - 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 { return answer(new Request(new URL(path, origin))) } -async function answer(request: Request): Promise { - 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 = { '/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) }) }) diff --git a/web/src/test/serviceWorker.ts b/web/src/test/serviceWorker.ts new file mode 100644 index 0000000..606a12a --- /dev/null +++ b/web/src/test/serviceWorker.ts @@ -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() + async match(request: RequestInfo | URL): Promise { + return this.entries.get(new Request(request).url)?.clone() + } + async put(request: RequestInfo | URL, response: Response): Promise { + 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[] = [] + waitUntil(promise: Promise) { + 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 { + 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) { + 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 }) + }) + }) +}