Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a5d0a7912 | ||
|
|
3e95514053 | ||
|
|
fe124f0d52 | ||
|
|
dd58865323 | ||
|
|
d80012a821 | ||
|
|
5758f198e4 | ||
|
|
048d5dd71a |
@@ -3,6 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#5a358c" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>MealQuest</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "MealQuest",
|
||||
"short_name": "MealQuest",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"theme_color": "#5a358c",
|
||||
"background_color": "#f9f6f1",
|
||||
"icons": [
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{
|
||||
"src": "/icons/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+5227
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -5,9 +5,9 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build && npm run check:dist-fonts",
|
||||
"build": "npm run lint:colors && npm run typecheck && vite build && npm run check:dist-fonts",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.sw.json",
|
||||
"test": "npm run lint:colors && vitest run",
|
||||
"lint:colors": "node scripts/lint-colors.mjs",
|
||||
"check:dist-fonts": "node scripts/check-dist-fonts.mjs"
|
||||
@@ -29,6 +29,7 @@
|
||||
"jsdom": "^30.1.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.2",
|
||||
"vite-plugin-pwa": "^2.0.0",
|
||||
"vitest": "^5.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 505 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 218 KiB |
@@ -0,0 +1,59 @@
|
||||
"""Generates the PWA icons in web/public from the MealQuest logo.
|
||||
|
||||
Run from the repository root with Pillow installed:
|
||||
|
||||
python3 web/scripts/generate-icons.py
|
||||
|
||||
The logo fills the regular icons edge to edge. The maskable icon shrinks it
|
||||
onto the colour of its own parchment, so the banner stays inside
|
||||
the circular safe zone (radius 40% of the icon) that launchers may crop to.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFilter, ImageStat
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SOURCE = ROOT / "design" / "logos" / "logo_v1_2Oct2026.jpg"
|
||||
PUBLIC = ROOT / "web" / "public"
|
||||
|
||||
MASKABLE_SCALE = 0.66
|
||||
|
||||
|
||||
def resized(logo: Image.Image, size: int) -> Image.Image:
|
||||
return logo.resize((size, size), Image.Resampling.LANCZOS)
|
||||
|
||||
|
||||
def maskable(logo: Image.Image, size: int) -> Image.Image:
|
||||
# The padding is the median colour of the logo's top edge, which is bare
|
||||
# parchment; a feathered edge hides the seam.
|
||||
top_edge = ImageStat.Stat(logo.crop((0, 0, logo.width, logo.height // 40)))
|
||||
canvas = Image.new("RGB", (size, size), tuple(round(c) for c in top_edge.median))
|
||||
inner = round(size * MASKABLE_SCALE)
|
||||
offset = (size - inner) // 2
|
||||
feather = Image.new("L", (inner, inner), 0)
|
||||
margin = inner // 24
|
||||
ImageDraw.Draw(feather).rectangle((margin, margin, inner - margin, inner - margin), fill=255)
|
||||
feather = feather.filter(ImageFilter.GaussianBlur(margin / 2))
|
||||
canvas.paste(resized(logo, inner), (offset, offset), feather)
|
||||
return canvas
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logo = Image.open(SOURCE).convert("RGB")
|
||||
if logo.width != logo.height:
|
||||
raise SystemExit(f"{SOURCE} is {logo.width}x{logo.height}; the logo must be square")
|
||||
outputs = {
|
||||
PUBLIC / "icons" / "icon-192.png": resized(logo, 192),
|
||||
PUBLIC / "icons" / "icon-512.png": resized(logo, 512),
|
||||
PUBLIC / "icons" / "icon-maskable-512.png": maskable(logo, 512),
|
||||
PUBLIC / "apple-touch-icon.png": resized(logo, 180),
|
||||
}
|
||||
for path, image in outputs.items():
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
image.save(path, optimize=True)
|
||||
print(f"wrote {path.relative_to(ROOT)} ({image.width}x{image.height})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+57
-5
@@ -1,19 +1,23 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApiClient } from './api/index.ts'
|
||||
import { mockViewport } from './test/matchMedia.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from './test/fetchStub.ts'
|
||||
import App, { routes } from './App.tsx'
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function renderAt(path: string) {
|
||||
function renderAt(path: string, location = fakeLocation(path)) {
|
||||
render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<App />
|
||||
<App api={createApiClient(location)} />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
return location
|
||||
}
|
||||
|
||||
describe('App routes', () => {
|
||||
@@ -39,11 +43,59 @@ describe('App routes', () => {
|
||||
|
||||
it.each(routes.map(({ path, title }) => [path.replace(':id', 'abc123'), title]))(
|
||||
'%s renders its placeholder page inside the shell',
|
||||
(path, title) => {
|
||||
async (path, title) => {
|
||||
mockViewport(390)
|
||||
stubFetch(jsonResponse(200, ME))
|
||||
renderAt(path)
|
||||
expect(screen.getByRole('main')).toContainElement(screen.getByRole('heading', { level: 1, name: title }))
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: title })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('App bootstrap', () => {
|
||||
it('shows the skeleton until /me loads, then the routes', async () => {
|
||||
mockViewport(390)
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME))
|
||||
renderAt('/plan')
|
||||
|
||||
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true')
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
|
||||
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' })
|
||||
})
|
||||
|
||||
it('sends a signed-out visitor to sign-in and never renders the routes', async () => {
|
||||
const fetchMock = stubFetch(errorResponse(401, 'unauthenticated'))
|
||||
const location = renderAt('/discover', fakeLocation('/discover', '?swipe=1'))
|
||||
|
||||
await vi.waitFor(() => expect(location.assign).toHaveBeenCalledWith('/auth/login?return=%2Fdiscover%3Fswipe%3D1'))
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the not-in-this-household screen on a 403', async () => {
|
||||
const fetchMock = stubFetch(errorResponse(403, 'forbidden'))
|
||||
const location = renderAt('/')
|
||||
|
||||
expect(await screen.findByText(/not in this household/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'Not in this household' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(location.assign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a /me failure with a retry instead of rendering the routes', async () => {
|
||||
mockViewport(390)
|
||||
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME))
|
||||
renderAt('/')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
||||
screen.getByRole('button', { name: 'Retry' }).click()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
+31
-14
@@ -1,6 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Route, Routes } from 'react-router'
|
||||
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
||||
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
||||
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
|
||||
import { AppShell } from './shell/AppShell.tsx'
|
||||
|
||||
@@ -23,19 +25,34 @@ export const routes = [
|
||||
{ path: '/settings/calendar', title: 'Calendar' },
|
||||
] as const
|
||||
|
||||
// App expects to be rendered inside a router (BrowserRouter in main.tsx,
|
||||
// MemoryRouter in tests).
|
||||
export default function App() {
|
||||
const [queryClient] = useState(() => new QueryClient())
|
||||
// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded.
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
{routes.map(({ path, title }) => (
|
||||
<Route key={path} path={path} element={<PlaceholderPage title={title} />} />
|
||||
))}
|
||||
</Route>
|
||||
</Routes>
|
||||
</QueryClientProvider>
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
{routes.map(({ path, title }) => (
|
||||
<Route key={path} path={path} element={<PlaceholderPage title={title} />} />
|
||||
))}
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export type AppProps = {
|
||||
api: ApiClient
|
||||
}
|
||||
|
||||
// App expects to be rendered inside a router (BrowserRouter in main.tsx,
|
||||
// MemoryRouter in tests).
|
||||
export default function App({ api }: AppProps) {
|
||||
const [queryClient] = useState(createQueryClient)
|
||||
return (
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthBootstrap>
|
||||
<AppRoutes />
|
||||
</AuthBootstrap>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useSyncExternalStore, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ApiError, ME_PATH, useApi } from './api/index.ts'
|
||||
import { NotInHouseholdPage } from './pages/NotInHouseholdPage.tsx'
|
||||
import { ErrorBanner } from './ui/ErrorBanner.tsx'
|
||||
import { Skeleton } from './ui/Skeleton.tsx'
|
||||
import styles from './pages/FullScreen.module.css'
|
||||
|
||||
export const meQueryKey = [ME_PATH] as const
|
||||
|
||||
// AuthBootstrap loads GET /api/v1/me before rendering children. A 401 is
|
||||
// already navigating to sign-in, so the skeleton stays up; an account outside
|
||||
// the household sees only the "not in this household" screen.
|
||||
export function AuthBootstrap({ children }: { children: ReactNode }) {
|
||||
const api = useApi()
|
||||
const forbidden = useSyncExternalStore(api.subscribe, api.forbidden)
|
||||
const me = useQuery({ queryKey: meQueryKey, queryFn: api.loadMe, staleTime: Infinity })
|
||||
|
||||
if (forbidden) return <NotInHouseholdPage />
|
||||
if (me.isSuccess) return children
|
||||
if (me.isError && !(me.error instanceof ApiError && me.error.status === 401)) {
|
||||
return (
|
||||
<main className={styles.screen}>
|
||||
<ErrorBanner onRetry={() => void me.refetch()}>MealQuest could not load: {me.error.message}</ErrorBanner>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<main className={styles.screen} aria-busy="true" aria-label="Loading MealQuest">
|
||||
<div className={styles.loading}>
|
||||
<Skeleton className={styles.title} />
|
||||
<Skeleton />
|
||||
<Skeleton />
|
||||
<Skeleton />
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react'
|
||||
import type { ApiClient } from './client.ts'
|
||||
|
||||
const ApiContext = createContext<ApiClient | null>(null)
|
||||
|
||||
export function ApiProvider({ client, children }: { client: ApiClient; children: ReactNode }) {
|
||||
return <ApiContext.Provider value={client}>{children}</ApiContext.Provider>
|
||||
}
|
||||
|
||||
// useApi returns the app's API client. It must be rendered inside ApiProvider.
|
||||
export function useApi(): ApiClient {
|
||||
const client = useContext(ApiContext)
|
||||
if (client === null) throw new Error('useApi: no ApiProvider above this component')
|
||||
return client
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from '../test/fetchStub.ts'
|
||||
import { ApiError, createApiClient, CSRF_HEADER } from './index.ts'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('apiFetch', () => {
|
||||
it('sends same-origin JSON and the cached CSRF token on non-GET requests only', async () => {
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, { ok: 1 }), jsonResponse(200, { ok: 2 }))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await api.loadMe()
|
||||
|
||||
await expect(api.apiFetch('/api/v1/settings/household')).resolves.toEqual({ ok: 1 })
|
||||
await expect(
|
||||
api.apiFetch('/api/v1/settings/household', { method: 'PATCH', body: { dinnerTime: '18:00' } }),
|
||||
).resolves.toEqual({ ok: 2 })
|
||||
|
||||
const get = request(fetchMock, 1)
|
||||
expect(get).toMatchObject({ path: '/api/v1/settings/household', method: 'GET', credentials: 'same-origin' })
|
||||
expect(get.headers.has(CSRF_HEADER)).toBe(false)
|
||||
|
||||
const patch = request(fetchMock, 2)
|
||||
expect(patch).toMatchObject({ method: 'PATCH', credentials: 'same-origin', body: '{"dinnerTime":"18:00"}' })
|
||||
expect(patch.headers.get(CSRF_HEADER)).toBe('token-1')
|
||||
expect(patch.headers.get('Content-Type')).toBe('application/json')
|
||||
})
|
||||
|
||||
it('refuses a non-GET request before /me has loaded', async () => {
|
||||
const fetchMock = stubFetch()
|
||||
const api = createApiClient(fakeLocation())
|
||||
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'https://evil.example/api/v1/me',
|
||||
'//evil.example/api/v1/me',
|
||||
// The placeholder origin an earlier guard compared against.
|
||||
'//same-origin.invalid/api/v1/me',
|
||||
'https://same-origin.invalid/api/v1/me',
|
||||
'/\\evil.example/api/v1/me',
|
||||
'/auth/logout',
|
||||
'/api/v1/../auth/logout',
|
||||
'/api/v1/%2e%2e/auth/logout',
|
||||
'api/v1/me',
|
||||
])('refuses %s without sending it or the CSRF token', async (path) => {
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await api.loadMe()
|
||||
|
||||
await expect(api.apiFetch(path, { method: 'POST', body: {} })).rejects.toThrow(
|
||||
`POST ${path}: not a same-origin /api/v1/ path`,
|
||||
)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET' })
|
||||
expect(request(fetchMock, 0).headers.has(CSRF_HEADER)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects with a typed ApiError from the error body', async () => {
|
||||
stubFetch(errorResponse(503, 'unavailable', true))
|
||||
const api = createApiClient(fakeLocation())
|
||||
const error = await api.apiFetch('/api/v1/jobs/j1').catch((e: unknown) => e)
|
||||
expect(error).toBeInstanceOf(ApiError)
|
||||
expect(error).toMatchObject({ status: 503, code: 'unavailable', message: 'unavailable message', retryable: true })
|
||||
})
|
||||
|
||||
it('rejects a response without the API error body', async () => {
|
||||
stubFetch(new Response('<html>bad gateway</html>', { status: 502 }))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await expect(api.apiFetch('/api/v1/jobs/j1')).rejects.toThrow('GET /api/v1/jobs/j1: 502 response is not JSON')
|
||||
})
|
||||
|
||||
it('on 401 navigates to sign-in with the current path and search to return to', async () => {
|
||||
stubFetch(errorResponse(401, 'unauthenticated'))
|
||||
const location = fakeLocation('/plan/shopping', '?week=2026-10-05')
|
||||
const api = createApiClient(location)
|
||||
|
||||
await expect(api.apiFetch('/api/v1/settings/household')).rejects.toMatchObject({ status: 401 })
|
||||
expect(location.assign).toHaveBeenCalledExactlyOnceWith(
|
||||
'/auth/login?return=%2Fplan%2Fshopping%3Fweek%3D2026-10-05',
|
||||
)
|
||||
})
|
||||
|
||||
it('on a csrf 403 refreshes /me once and retries with the new token', async () => {
|
||||
const fetchMock = stubFetch(
|
||||
jsonResponse(200, ME),
|
||||
errorResponse(403, 'csrf'),
|
||||
jsonResponse(200, { ...ME, csrfToken: 'token-2' }),
|
||||
jsonResponse(201, { id: 'j1' }),
|
||||
)
|
||||
const api = createApiClient(fakeLocation())
|
||||
await api.loadMe()
|
||||
|
||||
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: { kind: 'noop' } })).resolves.toEqual({
|
||||
id: 'j1',
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4)
|
||||
expect(request(fetchMock, 1).headers.get(CSRF_HEADER)).toBe('token-1')
|
||||
expect(request(fetchMock, 2)).toMatchObject({ path: '/api/v1/me', method: 'GET' })
|
||||
expect(request(fetchMock, 3)).toMatchObject({ path: '/api/v1/jobs', method: 'POST', body: '{"kind":"noop"}' })
|
||||
expect(request(fetchMock, 3).headers.get(CSRF_HEADER)).toBe('token-2')
|
||||
expect(api.forbidden()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not loop when the retry gets a csrf 403 too', async () => {
|
||||
const fetchMock = stubFetch(
|
||||
jsonResponse(200, ME),
|
||||
errorResponse(403, 'csrf'),
|
||||
jsonResponse(200, { ...ME, csrfToken: 'token-2' }),
|
||||
errorResponse(403, 'csrf'),
|
||||
)
|
||||
const api = createApiClient(fakeLocation())
|
||||
await api.loadMe()
|
||||
|
||||
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toMatchObject({
|
||||
status: 403,
|
||||
code: 'csrf',
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4)
|
||||
expect(api.forbidden()).toBe(false)
|
||||
})
|
||||
|
||||
it('marks any other 403 as not in this household and notifies subscribers', async () => {
|
||||
const fetchMock = stubFetch(errorResponse(403, 'forbidden'))
|
||||
const api = createApiClient(fakeLocation())
|
||||
const listener = vi.fn()
|
||||
api.subscribe(listener)
|
||||
|
||||
await expect(api.apiFetch('/api/v1/me')).rejects.toMatchObject({ status: 403, code: 'forbidden' })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(api.forbidden()).toBe(true)
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not take a 403 without the API error body as not in this household', async () => {
|
||||
stubFetch(new Response('<html>denied by proxy</html>', { status: 403 }))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await expect(api.apiFetch('/api/v1/me')).rejects.toThrow('GET /api/v1/me: 403 response is not JSON')
|
||||
expect(api.forbidden()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not refresh /me for a csrf 403 on a GET, which carries no token', async () => {
|
||||
const fetchMock = stubFetch(errorResponse(403, 'csrf'))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await expect(api.loadMe()).rejects.toMatchObject({ status: 403, code: 'csrf' })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadMe', () => {
|
||||
it('rejects a /me response without a CSRF token', async () => {
|
||||
stubFetch(jsonResponse(200, { ...ME, csrfToken: '' }))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await expect(api.loadMe()).rejects.toThrow('response is not a member with a CSRF token')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
import { ApiError, isRecord, parseApiError } from './errors.ts'
|
||||
|
||||
// CSRF_HEADER carries the session's CSRF token on every non-GET request.
|
||||
export const CSRF_HEADER = 'X-MealQuest-CSRF'
|
||||
|
||||
export const ME_PATH = '/api/v1/me'
|
||||
|
||||
// API_PREFIX is the only path apiFetch sends to.
|
||||
const API_PREFIX = '/api/v1/'
|
||||
|
||||
// apiURL resolves path against the page origin and returns it as the
|
||||
// path-absolute URL to fetch. It refuses any path that would not resolve to a
|
||||
// same-origin URL under /api/v1/, so the CSRF token is never sent anywhere
|
||||
// else: absolute and protocol-relative URLs on another origin, and dot
|
||||
// segments that climb out of /api/v1/.
|
||||
function apiURL(origin: string, method: string, path: string): string {
|
||||
const url = new URL(path, origin)
|
||||
if (!path.startsWith('/') || url.origin !== origin || !url.pathname.startsWith(API_PREFIX)) {
|
||||
throw new Error(`${method} ${path}: not a same-origin ${API_PREFIX} path`)
|
||||
}
|
||||
return url.pathname + url.search
|
||||
}
|
||||
|
||||
// Me is the GET /api/v1/me response: the signed-in member, the household and
|
||||
// the session's CSRF token.
|
||||
export type Me = {
|
||||
name: string
|
||||
email: string
|
||||
household: { label: string }
|
||||
csrfToken: string
|
||||
}
|
||||
|
||||
// ApiLocation is the part of window.location the client reads and navigates.
|
||||
// origin is the page origin every request must stay on.
|
||||
export type ApiLocation = Pick<Location, 'origin' | 'pathname' | 'search' | 'assign'>
|
||||
|
||||
export type ApiRequest = {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
// Sent as JSON.
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
export type ApiClient = {
|
||||
// apiFetch sends a same-origin JSON request to path, which must be under
|
||||
// /api/v1/, and resolves with the decoded response body, or rejects with an
|
||||
// ApiError.
|
||||
apiFetch<T>(path: string, request?: ApiRequest): Promise<T>
|
||||
// loadMe fetches GET /api/v1/me and caches its CSRF token.
|
||||
loadMe(): Promise<Me>
|
||||
// forbidden reports whether a request was refused because the signed-in
|
||||
// account is not in this household; subscribe is notified when it changes.
|
||||
forbidden(): boolean
|
||||
subscribe(listener: () => void): () => void
|
||||
}
|
||||
|
||||
// createApiClient builds the app's API client. location is window.location in
|
||||
// the app: a 401 sends it to /auth/login with the current path to return to.
|
||||
export function createApiClient(location: ApiLocation): ApiClient {
|
||||
let csrfToken: string | null = null
|
||||
let refreshing: Promise<Me> | null = null
|
||||
let forbidden = false
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
async function send(method: string, path: string, body: unknown): Promise<Response> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' }
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||||
if (method !== 'GET') {
|
||||
if (csrfToken === null) {
|
||||
throw new Error(`${method} ${path}: no CSRF token; ${ME_PATH} has not loaded`)
|
||||
}
|
||||
headers[CSRF_HEADER] = csrfToken
|
||||
}
|
||||
return fetch(path, {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
// refreshMe shares one in-flight /me request between every request that
|
||||
// got a csrf 403 at the same time.
|
||||
function refreshMe(): Promise<Me> {
|
||||
refreshing ??= loadMe().finally(() => {
|
||||
refreshing = null
|
||||
})
|
||||
return refreshing
|
||||
}
|
||||
|
||||
async function apiFetch<T>(path: string, request: ApiRequest = {}): Promise<T> {
|
||||
const method = request.method ?? 'GET'
|
||||
const url = apiURL(location.origin, method, path)
|
||||
let response = await send(method, url, request.body)
|
||||
if (response.status === 403) {
|
||||
const error = await parseForbidden(method, path, response)
|
||||
// Only a non-GET carries the token, so only it can be refused for it.
|
||||
if (error.code !== 'csrf' || method === 'GET') throw error
|
||||
// The session's token changed: refresh it once and retry once.
|
||||
await refreshMe()
|
||||
response = await send(method, url, request.body)
|
||||
if (response.status === 403) throw await parseForbidden(method, path, response)
|
||||
}
|
||||
return decode<T>(method, path, response)
|
||||
}
|
||||
|
||||
// parseForbidden reads a 403. Any API error other than csrf means the
|
||||
// account is not in this household.
|
||||
async function parseForbidden(method: string, path: string, response: Response): Promise<ApiError> {
|
||||
const error = await parseApiError(method, path, response)
|
||||
if (error.code !== 'csrf') markForbidden()
|
||||
return error
|
||||
}
|
||||
|
||||
function markForbidden() {
|
||||
if (forbidden) return
|
||||
forbidden = true
|
||||
listeners.forEach((listener) => listener())
|
||||
}
|
||||
|
||||
async function decode<T>(method: string, path: string, response: Response): Promise<T> {
|
||||
if (response.status === 401) {
|
||||
location.assign(`/auth/login?return=${encodeURIComponent(location.pathname + location.search)}`)
|
||||
}
|
||||
if (!response.ok) throw await parseApiError(method, path, response)
|
||||
if (response.status === 204) return undefined as T
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
async function loadMe(): Promise<Me> {
|
||||
const me = await apiFetch<unknown>(ME_PATH)
|
||||
if (
|
||||
!isRecord(me) ||
|
||||
typeof me.name !== 'string' ||
|
||||
typeof me.email !== 'string' ||
|
||||
!isRecord(me.household) ||
|
||||
typeof me.household.label !== 'string' ||
|
||||
typeof me.csrfToken !== 'string' ||
|
||||
me.csrfToken === ''
|
||||
) {
|
||||
throw new Error(`GET ${ME_PATH}: response is not a member with a CSRF token`)
|
||||
}
|
||||
csrfToken = me.csrfToken
|
||||
return me as Me
|
||||
}
|
||||
|
||||
return {
|
||||
apiFetch,
|
||||
loadMe,
|
||||
forbidden: () => forbidden,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// ApiError is a failed API response carrying the server's
|
||||
// {"error":{"code","message","retryable"}} body.
|
||||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
readonly code: string
|
||||
readonly retryable: boolean
|
||||
|
||||
constructor(status: number, code: string, message: string, retryable: boolean) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.code = code
|
||||
this.retryable = retryable
|
||||
}
|
||||
}
|
||||
|
||||
// parseApiError reads an error response body. A body that is not the API
|
||||
// error shape is itself an error: the server always sends one.
|
||||
export async function parseApiError(method: string, path: string, response: Response): Promise<ApiError> {
|
||||
const text = await response.text()
|
||||
let body: unknown
|
||||
try {
|
||||
body = JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error(`${method} ${path}: ${response.status} response is not JSON: ${text.slice(0, 200)}`)
|
||||
}
|
||||
const error = isRecord(body) ? body.error : undefined
|
||||
if (
|
||||
!isRecord(error) ||
|
||||
typeof error.code !== 'string' ||
|
||||
typeof error.message !== 'string' ||
|
||||
typeof error.retryable !== 'boolean'
|
||||
) {
|
||||
throw new Error(`${method} ${path}: ${response.status} response has no API error body`)
|
||||
}
|
||||
return new ApiError(response.status, error.code, error.message, error.retryable)
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { ApiProvider, useApi } from './ApiContext.tsx'
|
||||
export { createApiClient, CSRF_HEADER, ME_PATH, type ApiClient, type ApiLocation, type ApiRequest, type Me } from './client.ts'
|
||||
export { ApiError } from './errors.ts'
|
||||
export { createQueryClient, shouldRetry } from './queryClient.ts'
|
||||
export { useJob, type Job, type JobState, type JobStatus } from './useJob.ts'
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ApiError, createQueryClient, shouldRetry } from './index.ts'
|
||||
|
||||
describe('query retries', () => {
|
||||
it('retry only an ApiError the server marked retryable, a bounded number of times', () => {
|
||||
expect(shouldRetry(0, new ApiError(503, 'unavailable', 'try later', true))).toBe(true)
|
||||
expect(shouldRetry(3, new ApiError(503, 'unavailable', 'try later', true))).toBe(false)
|
||||
expect(shouldRetry(0, new ApiError(401, 'unauthenticated', 'sign in required', false))).toBe(false)
|
||||
expect(shouldRetry(0, new ApiError(403, 'csrf', 'bad token', false))).toBe(false)
|
||||
expect(shouldRetry(0, new Error('network down'))).toBe(false)
|
||||
})
|
||||
|
||||
it('are the QueryClient default for queries and mutations', () => {
|
||||
const options = createQueryClient().getDefaultOptions()
|
||||
expect(options.queries?.retry).toBe(shouldRetry)
|
||||
expect(options.mutations?.retry).toBe(shouldRetry)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { ApiError } from './errors.ts'
|
||||
|
||||
// MAX_RETRIES bounds retries of a failure the server marked retryable.
|
||||
export const MAX_RETRIES = 3
|
||||
|
||||
// shouldRetry retries only an ApiError the server marked retryable — never a
|
||||
// 401, a 403 or a malformed response.
|
||||
export function shouldRetry(failureCount: number, error: unknown): boolean {
|
||||
return error instanceof ApiError && error.retryable && failureCount < MAX_RETRIES
|
||||
}
|
||||
|
||||
export function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: shouldRetry, staleTime: 30_000 },
|
||||
mutations: { retry: shouldRetry },
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { errorResponse, fakeLocation, jsonResponse, request, stubFetch } from '../test/fetchStub.ts'
|
||||
import { ApiProvider, createApiClient, useJob } from './index.ts'
|
||||
|
||||
type Draft = { dinners: string[] }
|
||||
|
||||
function job(status: string, result: Draft | null, error: string | null = null) {
|
||||
return jsonResponse(200, { id: 'j1', kind: 'plan.draft', status, result, error })
|
||||
}
|
||||
|
||||
function renderJob(jobId: string | null) {
|
||||
const api = createApiClient(fakeLocation())
|
||||
const wrapper = ({ children }: { children: ReactNode }) => <ApiProvider client={api}>{children}</ApiProvider>
|
||||
return renderHook(({ id }) => useJob<Draft>(id), { wrapper, initialProps: { id: jobId } })
|
||||
}
|
||||
|
||||
// advance runs the timers due in ms and lets the polls they start answer.
|
||||
async function advance(ms: number) {
|
||||
await act(() => vi.advanceTimersByTimeAsync(ms))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('useJob', () => {
|
||||
it('exposes the partial result while running, then the terminal result, backing off 500 ms to 2 s', async () => {
|
||||
const fetchMock = stubFetch(
|
||||
job('queued', null),
|
||||
job('running', { dinners: ['Tacos'] }),
|
||||
job('running', { dinners: ['Tacos', 'Chili'] }),
|
||||
job('running', { dinners: ['Tacos', 'Chili'] }),
|
||||
job('succeeded', { dinners: ['Tacos', 'Chili', 'Soup'] }),
|
||||
)
|
||||
const { result } = renderJob('j1')
|
||||
expect(result.current).toEqual({ status: undefined, result: null, error: null })
|
||||
|
||||
await advance(0)
|
||||
expect(result.current.status).toBe('queued')
|
||||
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/jobs/j1', method: 'GET' })
|
||||
|
||||
await advance(499)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
await advance(1)
|
||||
expect(result.current).toEqual({ status: 'running', result: { dinners: ['Tacos'] }, error: null })
|
||||
|
||||
await advance(999)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
await advance(1)
|
||||
expect(result.current.result).toEqual({ dinners: ['Tacos', 'Chili'] })
|
||||
|
||||
await advance(2000)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4)
|
||||
await advance(2000)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5)
|
||||
expect(result.current).toEqual({
|
||||
status: 'succeeded',
|
||||
result: { dinners: ['Tacos', 'Chili', 'Soup'] },
|
||||
error: null,
|
||||
})
|
||||
|
||||
// Terminal: no timer left, no further polls.
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await advance(10_000)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('exposes a failed job as its error and stops', async () => {
|
||||
const fetchMock = stubFetch(job('running', { dinners: ['Tacos'] }), job('failed', null, 'model refused'))
|
||||
const { result } = renderJob('j1')
|
||||
|
||||
await advance(0)
|
||||
await advance(500)
|
||||
expect(result.current.status).toBe('failed')
|
||||
expect(result.current.error?.message).toBe('model refused')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('exposes a failed poll as the error and keeps polling to the terminal status', async () => {
|
||||
const fetchMock = stubFetch(
|
||||
job('running', { dinners: ['Tacos'] }),
|
||||
errorResponse(503, 'unavailable', true),
|
||||
job('succeeded', { dinners: ['Tacos', 'Chili'] }),
|
||||
)
|
||||
const { result } = renderJob('j1')
|
||||
|
||||
await advance(0)
|
||||
await advance(500)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] } })
|
||||
expect(result.current.error).toMatchObject({ status: 503, code: 'unavailable' })
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
// The backoff carries on from the failed poll: the next one is 1 s later.
|
||||
await advance(999)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
await advance(1)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3)
|
||||
expect(result.current).toEqual({ status: 'succeeded', result: { dinners: ['Tacos', 'Chili'] }, error: null })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('stops polling on unmount after a failed poll', async () => {
|
||||
const fetchMock = stubFetch(errorResponse(503, 'unavailable', true))
|
||||
const { result, unmount } = renderJob('j1')
|
||||
|
||||
await advance(0)
|
||||
expect(result.current).toMatchObject({ status: undefined, result: null })
|
||||
expect(result.current.error).toMatchObject({ status: 503 })
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await advance(10_000)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops polling the old job when the job id changes', async () => {
|
||||
const fetchMock = stubFetch(job('running', null), job('running', null))
|
||||
const { result, rerender } = renderJob('j1')
|
||||
|
||||
await advance(0)
|
||||
rerender({ id: 'j2' })
|
||||
expect(result.current).toEqual({ status: undefined, result: null, error: null })
|
||||
await advance(0)
|
||||
expect(request(fetchMock, 1).path).toBe('/api/v1/jobs/j2')
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('stops polling on unmount', async () => {
|
||||
const fetchMock = stubFetch(job('running', null))
|
||||
const { unmount } = renderJob('j1')
|
||||
|
||||
await advance(0)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await advance(10_000)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores a poll that answers after unmount', async () => {
|
||||
let answer!: (response: Response) => void
|
||||
const fetchMock = stubFetch()
|
||||
fetchMock.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)))
|
||||
const { result, unmount } = renderJob('j1')
|
||||
|
||||
unmount()
|
||||
await act(async () => answer(await Promise.resolve(job('running', null))))
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(result.current.status).toBeUndefined()
|
||||
})
|
||||
|
||||
it('polls nothing without a job id', async () => {
|
||||
const fetchMock = stubFetch()
|
||||
const { result } = renderJob(null)
|
||||
await advance(5000)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(result.current).toEqual({ status: undefined, result: null, error: null })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useApi } from './ApiContext.tsx'
|
||||
|
||||
export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed'
|
||||
|
||||
// Job is the GET /api/v1/jobs/{id} response. result may be set while the job
|
||||
// is running (a partial result); error is set only when it failed.
|
||||
export type Job<R> = {
|
||||
id: string
|
||||
kind: string
|
||||
status: JobStatus
|
||||
result: R | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export type JobState<R> = {
|
||||
// undefined until the first poll answers.
|
||||
status: JobStatus | undefined
|
||||
// The latest result, partial while the job runs.
|
||||
result: R | null
|
||||
// The job's failure, or the latest polling request's own failure (cleared
|
||||
// by the next poll that answers).
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
// Polling starts at FIRST_DELAY_MS and doubles up to MAX_DELAY_MS.
|
||||
export const FIRST_DELAY_MS = 500
|
||||
export const MAX_DELAY_MS = 2000
|
||||
|
||||
const idle = { status: undefined, result: null, error: null }
|
||||
|
||||
function isTerminal(status: JobStatus): boolean {
|
||||
return status === 'succeeded' || status === 'failed'
|
||||
}
|
||||
|
||||
// useJob polls GET /api/v1/jobs/{jobId} until the job succeeds or fails,
|
||||
// backing off from 500 ms to 2 s. A failed poll is exposed as the error and
|
||||
// polling continues. Polling stops only at a terminal status, when jobId
|
||||
// changes and on unmount. A null jobId polls nothing.
|
||||
export function useJob<R>(jobId: string | null): JobState<R> {
|
||||
const { apiFetch } = useApi()
|
||||
const [state, setState] = useState<{ jobId: string | null; job: JobState<R> }>({ jobId, job: idle })
|
||||
|
||||
useEffect(() => {
|
||||
if (jobId === null) return
|
||||
let active = true
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let delay = FIRST_DELAY_MS
|
||||
|
||||
function next() {
|
||||
timer = setTimeout(poll, delay)
|
||||
delay = Math.min(delay * 2, MAX_DELAY_MS)
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const job = await apiFetch<Job<R>>(`/api/v1/jobs/${encodeURIComponent(jobId!)}`)
|
||||
if (!active) return
|
||||
const error = job.status === 'failed' ? new Error(job.error ?? `job ${job.id} failed without an error message`) : null
|
||||
setState({ jobId, job: { status: job.status, result: job.result, error } })
|
||||
if (isTerminal(job.status)) return
|
||||
} catch (error) {
|
||||
if (!active) return
|
||||
// A failed poll is not the job's end: keep the last known status and
|
||||
// result, expose the request's error, and poll again.
|
||||
setState((prev) => ({
|
||||
jobId,
|
||||
job: { ...(prev.jobId === jobId ? prev.job : idle), error: error instanceof Error ? error : new Error(String(error)) },
|
||||
}))
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
void poll()
|
||||
return () => {
|
||||
active = false
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [apiFetch, jobId])
|
||||
|
||||
// State left from a previous jobId is not this job's.
|
||||
return state.jobId === jobId ? state.job : idle
|
||||
}
|
||||
+4
-1
@@ -4,16 +4,19 @@ import { BrowserRouter } from 'react-router'
|
||||
import './styles/tokens.css'
|
||||
import './styles/base.css'
|
||||
import App from './App.tsx'
|
||||
import { createApiClient } from './api/index.ts'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
if (!root) {
|
||||
throw new Error('MealQuest: #root element missing from index.html')
|
||||
}
|
||||
|
||||
const api = createApiClient(window.location)
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<App api={api} />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/* A full-viewport screen shown instead of the app shell. */
|
||||
.screen {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-8) var(--space-4);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.loading {
|
||||
width: min(100%, 480px);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.title {
|
||||
height: 32px;
|
||||
width: 60%;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||
import styles from './FullScreen.module.css'
|
||||
|
||||
// Shown when the server refuses a signed-in account that is not in this
|
||||
// household. Nothing else renders: the account can do nothing here.
|
||||
export function NotInHouseholdPage() {
|
||||
return (
|
||||
<main className={styles.screen}>
|
||||
<EmptyState title="Not in this household">
|
||||
You are signed in, but this account is not in this household. Ask someone in Our kitchen to add it.
|
||||
</EmptyState>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// @vitest-environment node
|
||||
// Builds the app with the production config and serves the result, so the
|
||||
// assertions cover what ships in dist: the web manifest, the service worker,
|
||||
// every icon, and the service worker registration that only builds include.
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { build, createServer, preview, type PreviewServer, type ViteDevServer } from 'vite'
|
||||
import tokens from './styles/tokens.css?raw'
|
||||
|
||||
const outDir = 'node_modules/.tmp/pwa-test-dist'
|
||||
|
||||
const icons = [
|
||||
{ path: '/icons/icon-192.png', size: 192 },
|
||||
{ path: '/icons/icon-512.png', size: 512 },
|
||||
{ path: '/icons/icon-maskable-512.png', size: 512 },
|
||||
{ path: '/apple-touch-icon.png', size: 180 },
|
||||
]
|
||||
|
||||
function token(name: string): string {
|
||||
const value = tokens.match(new RegExp(`^\\s*${name}:\\s*([^;\\s]+);`, 'm'))?.[1]
|
||||
if (!value) throw new Error(`${name} is not defined in tokens.css`)
|
||||
return value
|
||||
}
|
||||
|
||||
// Width and height from a PNG's IHDR chunk, which follows the 8-byte
|
||||
// signature, the chunk length and the chunk type.
|
||||
// The precache list vite-plugin-pwa injects into sw.js: a JSON array of
|
||||
// { revision, url } entries.
|
||||
function precacheEntries(sw: string): Array<{ revision: string | null; url: string }> {
|
||||
const start = sw.indexOf('[{"revision":')
|
||||
expect(start, 'injected precache list').toBeGreaterThanOrEqual(0)
|
||||
return JSON.parse(sw.slice(start, sw.indexOf('}]', start) + 2))
|
||||
}
|
||||
|
||||
function pngSize(bytes: Uint8Array): { width: number; height: number } {
|
||||
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
||||
expect(Array.from(bytes.subarray(0, 8))).toEqual(signature)
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
||||
return { width: view.getUint32(16), height: view.getUint32(20) }
|
||||
}
|
||||
|
||||
describe('production build', () => {
|
||||
let server: PreviewServer
|
||||
let origin: string
|
||||
|
||||
async function get(path: string): Promise<Response> {
|
||||
const response = await fetch(new URL(path, origin))
|
||||
expect(response.status, path).toBe(200)
|
||||
return response
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await build({ logLevel: 'error', build: { outDir, emptyOutDir: true } })
|
||||
server = await preview({
|
||||
logLevel: 'error',
|
||||
build: { outDir },
|
||||
preview: { port: 0, strictPort: false, host: '127.0.0.1' },
|
||||
})
|
||||
const url = server.resolvedUrls?.local[0]
|
||||
if (!url) throw new Error('preview server did not report a URL')
|
||||
origin = url
|
||||
}, 60_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.close()
|
||||
})
|
||||
|
||||
it('emits manifest.webmanifest with the MealQuest identity', async () => {
|
||||
const manifest = await (await get('/manifest.webmanifest')).json()
|
||||
expect(manifest).toMatchObject({
|
||||
name: 'MealQuest',
|
||||
short_name: 'MealQuest',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
// --color-accent and --color-bg hold the manifest colours; hex colours
|
||||
// may not be written outside tokens.css.
|
||||
theme_color: token('--color-accent'),
|
||||
background_color: token('--color-bg'),
|
||||
})
|
||||
expect(manifest.icons).toEqual([
|
||||
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{
|
||||
src: '/icons/icon-maskable-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it.each(icons)('ships $path as a $size px square PNG', async ({ path, size }) => {
|
||||
const bytes = new Uint8Array(await (await get(path)).arrayBuffer())
|
||||
expect(pngSize(bytes)).toEqual({ width: size, height: size })
|
||||
})
|
||||
|
||||
it('emits sw.js with the precache list injected', async () => {
|
||||
const sw = await (await get('/sw.js')).text()
|
||||
expect(sw).not.toContain('self.__WB_MANIFEST')
|
||||
expect(sw).toContain('"url":"index.html"')
|
||||
for (const { path } of icons.slice(0, 3)) {
|
||||
expect(sw).toContain(`"url":"${path.slice(1)}"`)
|
||||
}
|
||||
})
|
||||
|
||||
it('precaches the shell, JS, CSS and every self-hosted font', async () => {
|
||||
const entries = precacheEntries(await (await get('/sw.js')).text())
|
||||
const urls = entries.map((entry) => entry.url)
|
||||
// index.html keeps its URL across builds, so it carries a content
|
||||
// revision; the other assets have content hashes in their names.
|
||||
expect(entries.find((entry) => entry.url === 'index.html')?.revision).toMatch(/^[0-9a-f]{32}$/)
|
||||
expect(urls.some((url) => url.endsWith('.js'))).toBe(true)
|
||||
expect(urls.some((url) => url.endsWith('.css'))).toBe(true)
|
||||
for (const font of [
|
||||
'BagelFatOne-LatinExt',
|
||||
'BagelFatOne-Latin',
|
||||
'Figtree-Regular',
|
||||
'Figtree-SemiBold',
|
||||
'Figtree-Bold',
|
||||
]) {
|
||||
expect(urls.some((url) => url.startsWith(`assets/${font}`) && url.endsWith('.woff2'))).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('links the manifest, theme colour and apple-touch-icon from index.html', async () => {
|
||||
const html = await (await get('/')).text()
|
||||
expect(html.match(/<link rel="manifest" href="\/manifest\.webmanifest">/g)).toHaveLength(1)
|
||||
expect(html).toContain(`<meta name="theme-color" content="${token('--color-accent')}" />`)
|
||||
expect(html).toContain('<link rel="apple-touch-icon" href="/apple-touch-icon.png" />')
|
||||
})
|
||||
|
||||
it('registers /sw.js at scope / from index.html', async () => {
|
||||
const html = await (await get('/')).text()
|
||||
expect(html).toContain('<script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script>')
|
||||
const register = await (await get('/registerSW.js')).text()
|
||||
expect(register).toContain("navigator.serviceWorker.register('/sw.js', { scope: '/' })")
|
||||
})
|
||||
})
|
||||
|
||||
describe('dev server', () => {
|
||||
let server: ViteDevServer
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await createServer({ logLevel: 'error', server: { middlewareMode: true } })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.close()
|
||||
})
|
||||
|
||||
it('does not register a service worker', async () => {
|
||||
const html = await server.transformIndexHtml('/', '<html><head></head><body></body></html>')
|
||||
expect(html).not.toContain('registerSW')
|
||||
expect(html).not.toContain('serviceWorker')
|
||||
expect(html).not.toContain('rel="manifest"')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import App from '../App.tsx'
|
||||
import { AppRoutes } from '../App.tsx'
|
||||
import { declaredStyle, outerSize } from '../test/cssRules.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
@@ -22,7 +22,7 @@ function renderAt(path: string, width: number) {
|
||||
const resize = mockViewport(width)
|
||||
render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<App />
|
||||
<AppRoutes />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
return resize
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import App from '../App.tsx'
|
||||
import { AppRoutes } from '../App.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import styles from './BottomBar.module.css'
|
||||
@@ -71,7 +71,7 @@ function renderAt(path: string, reducedMotion = false) {
|
||||
mockViewport(MOBILE, { reducedMotion })
|
||||
render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<App />
|
||||
<AppRoutes />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// MealQuest service worker. vite-plugin-pwa replaces self.__WB_MANIFEST with
|
||||
// the build's precache list (shell, JS, CSS and fonts, each with a revision).
|
||||
// Every other request goes through the route src/sw/routes.ts decides.
|
||||
// Fonts are precached too, but their route is registered ahead of the
|
||||
// precache route, so a font fetch goes through the expiring CacheFirst
|
||||
// strategy and falls back to the precached copy when the network fails.
|
||||
|
||||
import { cacheNames as workboxCacheNames, setCacheNameDetails } from 'workbox-core'
|
||||
import type { RouteHandlerCallbackOptions } from 'workbox-core/types'
|
||||
import { ExpirationPlugin } from 'workbox-expiration'
|
||||
import { PrecacheFallbackPlugin, matchPrecache, precacheAndRoute } from 'workbox-precaching'
|
||||
import { registerRoute } from 'workbox-routing'
|
||||
import { CacheFirst, NetworkFirst, NetworkOnly, type Strategy } from 'workbox-strategies'
|
||||
import {
|
||||
cacheNames,
|
||||
cacheVersion,
|
||||
networkTimeoutSeconds,
|
||||
routeFor,
|
||||
staleCaches,
|
||||
type Route,
|
||||
} from './sw/routes'
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope & {
|
||||
__WB_MANIFEST: Array<{ url: string; revision: string | null }>
|
||||
}
|
||||
|
||||
// The precache becomes mealquest-precache-v1.
|
||||
setCacheNameDetails({ prefix: 'mealquest', precache: 'precache', suffix: cacheVersion })
|
||||
|
||||
const day = 24 * 60 * 60
|
||||
|
||||
const strategies = {
|
||||
[cacheNames.api]: new NetworkFirst({ cacheName: cacheNames.api, networkTimeoutSeconds }),
|
||||
[cacheNames.media]: new CacheFirst({
|
||||
cacheName: cacheNames.media,
|
||||
plugins: [
|
||||
new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 30 * day, purgeOnQuotaError: true }),
|
||||
],
|
||||
}),
|
||||
[cacheNames.fonts]: new CacheFirst({
|
||||
cacheName: cacheNames.fonts,
|
||||
plugins: [
|
||||
new ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 365 * day, purgeOnQuotaError: true }),
|
||||
// Offline with no runtime copy yet: the font from the precache.
|
||||
{ handlerDidError: async ({ request }) => matchPrecache(request.url) },
|
||||
],
|
||||
}),
|
||||
}
|
||||
const networkOnly = new NetworkOnly()
|
||||
const navigation = new NetworkOnly({
|
||||
plugins: [new PrecacheFallbackPlugin({ fallbackURL: 'index.html' })],
|
||||
})
|
||||
|
||||
function strategyFor(route: Route): Strategy {
|
||||
switch (route.strategy) {
|
||||
case 'network-first':
|
||||
case 'cache-first':
|
||||
return strategies[route.cacheName]
|
||||
case 'network-only':
|
||||
return networkOnly
|
||||
case 'navigation-fallback':
|
||||
return navigation
|
||||
}
|
||||
}
|
||||
|
||||
function routeOf({ request, url }: { request: Request; url: URL }): Route {
|
||||
return routeFor({ method: request.method, url, mode: request.mode }, self.location.origin)
|
||||
}
|
||||
|
||||
function handle({ request, url, event }: RouteHandlerCallbackOptions): Promise<Response> {
|
||||
return strategyFor(routeOf({ request, url })).handle({ request, event })
|
||||
}
|
||||
|
||||
function isFont(options: { request: Request; url: URL }): boolean {
|
||||
const route = routeOf(options)
|
||||
return route.strategy === 'cache-first' && route.cacheName === cacheNames.fonts
|
||||
}
|
||||
|
||||
// Workbox uses the first route that matches, so the font route comes before
|
||||
// the precache route, which would otherwise answer every precached font.
|
||||
registerRoute(isFont, handle, 'GET')
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
||||
|
||||
// Workbox routes are per method; every method goes through routeFor, which
|
||||
// sends every non-GET to the network only.
|
||||
for (const method of ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'] as const) {
|
||||
registerRoute(() => true, handle, method)
|
||||
}
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
const current = [workboxCacheNames.precache, ...Object.values(cacheNames)]
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((names) => Promise.all(staleCaches(names, current).map((name) => caches.delete(name)))),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { cacheNames, routeFor, staleCaches, type Route } from './routes'
|
||||
|
||||
const origin = 'https://mealquest.test'
|
||||
|
||||
function route(path: string, method = 'GET', mode: RequestMode = 'cors'): Route {
|
||||
return routeFor({ method, url: new URL(path, origin), mode }, origin)
|
||||
}
|
||||
|
||||
const apiCache: Route = { strategy: 'network-first', cacheName: cacheNames.api }
|
||||
const networkOnly: Route = { strategy: 'network-only' }
|
||||
|
||||
describe('routeFor', () => {
|
||||
it.each([
|
||||
'/api/v1/plans',
|
||||
'/api/v1/plans/2026-W40',
|
||||
'/api/v1/shopping/current',
|
||||
'/api/v1/dinners',
|
||||
'/api/v1/dinners/01J9ZK?include=recipe',
|
||||
'/api/v1/home',
|
||||
])('GET %s is network-first in the API cache', (path) => {
|
||||
expect(route(path)).toEqual(apiCache)
|
||||
})
|
||||
|
||||
it.each(['/media/photos/abc.jpg', '/media/thumbs/abc.webp'])(
|
||||
'GET %s is cache-first in the media cache',
|
||||
(path) => {
|
||||
expect(route(path)).toEqual({ strategy: 'cache-first', cacheName: cacheNames.media })
|
||||
},
|
||||
)
|
||||
|
||||
it('GET of a font file is cache-first in the fonts cache', () => {
|
||||
expect(route('/assets/Figtree-Bold-a1b2c3.woff2')).toEqual({
|
||||
strategy: 'cache-first',
|
||||
cacheName: cacheNames.fonts,
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
'/auth/login',
|
||||
'/auth/callback?code=x&state=y',
|
||||
'/auth/logout',
|
||||
'/auth/me',
|
||||
'/api/v1/settings',
|
||||
'/api/v1/settings/ai',
|
||||
'/api/v1/jobs',
|
||||
'/api/v1/jobs/01J9ZK',
|
||||
])('GET %s is network-only', (path) => {
|
||||
expect(route(path)).toEqual(networkOnly)
|
||||
})
|
||||
|
||||
it.each(['/auth/login', '/auth/callback?code=x&state=y', '/api/v1/settings/ai'])(
|
||||
'a navigation to %s is network-only with no offline fallback',
|
||||
(path) => {
|
||||
expect(route(path, 'GET', 'navigate')).toEqual(networkOnly)
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
'/%61uth/login',
|
||||
'/auth%2Fcallback?code=x&state=y',
|
||||
'/%61pi/v1/settings/household',
|
||||
'/api/v1/%73ettings/ai',
|
||||
'/api/v1/settings%2Fai',
|
||||
'/%61pi/v1/jobs/01J9ZK',
|
||||
'/api/v1/j%6Fbs',
|
||||
'/api/v1/plans%2F..%2Fsettings',
|
||||
'/plan%2F..%2Fauth%2Flogin',
|
||||
])('an encoded reserved path %s is network-only, navigated or not', (path) => {
|
||||
expect(route(path)).toEqual(networkOnly)
|
||||
expect(route(path, 'GET', 'navigate')).toEqual(networkOnly)
|
||||
})
|
||||
|
||||
it.each(['/%E0%A4%A', '/auth%', '/plan/%zz', '/api/v1/plans/%C3'])(
|
||||
'a malformed encoding in %s is network-only, navigated or not',
|
||||
(path) => {
|
||||
expect(route(path)).toEqual(networkOnly)
|
||||
expect(route(path, 'GET', 'navigate')).toEqual(networkOnly)
|
||||
},
|
||||
)
|
||||
|
||||
it('classifies other API paths by their decoded form', () => {
|
||||
expect(route('/%61pi/v1/plans/2026-W40')).toEqual(apiCache)
|
||||
expect(route('/%61pi/v1/recipes', 'GET', 'navigate')).toEqual(networkOnly)
|
||||
})
|
||||
|
||||
it.each(['POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'])(
|
||||
'%s is network-only on every URL class',
|
||||
(method) => {
|
||||
for (const path of [
|
||||
'/api/v1/plans/2026-W40',
|
||||
'/api/v1/shopping/current',
|
||||
'/api/v1/dinners/01J9ZK',
|
||||
'/api/v1/home',
|
||||
'/media/photos/abc.jpg',
|
||||
'/assets/Figtree-Bold-a1b2c3.woff2',
|
||||
'/auth/logout',
|
||||
'/plan',
|
||||
]) {
|
||||
expect(route(path, method), `${method} ${path}`).toEqual(networkOnly)
|
||||
expect(route(path, method, 'navigate'), `${method} ${path}`).toEqual(networkOnly)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it('matches whole path segments only', () => {
|
||||
expect(route('/api/v1/plansx')).toEqual(networkOnly)
|
||||
expect(route('/api/v1/homepage')).toEqual(networkOnly)
|
||||
expect(route('/mediax/a.jpg')).toEqual(networkOnly)
|
||||
expect(route('/authors')).toEqual(networkOnly)
|
||||
expect(route('/api/v1/settingsx')).toEqual(networkOnly)
|
||||
})
|
||||
|
||||
it('leaves other API calls network-only', () => {
|
||||
expect(route('/api/v1/recipes')).toEqual(networkOnly)
|
||||
expect(route('/api/v1/ideas', 'GET', 'navigate')).toEqual(networkOnly)
|
||||
})
|
||||
|
||||
it('leaves cross-origin requests network-only', () => {
|
||||
for (const path of ['/api/v1/plans', '/media/a.jpg', '/assets/a.woff2']) {
|
||||
const url = new URL(path, 'https://cdn.example')
|
||||
expect(routeFor({ method: 'GET', url, mode: 'no-cors' }, origin)).toEqual(networkOnly)
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['/', '/plan', '/supply-run', '/dinners/01J9ZK'])(
|
||||
'a navigation to the app at %s falls back to the precached index.html',
|
||||
(path) => {
|
||||
expect(route(path, 'GET', 'navigate')).toEqual({ strategy: 'navigation-fallback' })
|
||||
},
|
||||
)
|
||||
|
||||
it('leaves other same-origin GETs network-only', () => {
|
||||
expect(route('/health')).toEqual(networkOnly)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cache names', () => {
|
||||
it('all start with mealquest-', () => {
|
||||
for (const name of Object.values(cacheNames)) expect(name).toMatch(/^mealquest-/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('staleCaches', () => {
|
||||
it('deletes only MealQuest caches that are not current', () => {
|
||||
const current = ['mealquest-precache-v1', ...Object.values(cacheNames)]
|
||||
const names = [
|
||||
...current,
|
||||
'mealquest-precache',
|
||||
'mealquest-api-v0',
|
||||
'workbox-precache-v2-https://mealquest.test/',
|
||||
'other-app-cache',
|
||||
'my-mealquest-cache',
|
||||
]
|
||||
expect(staleCaches(names, current)).toEqual(['mealquest-precache', 'mealquest-api-v0'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
// Decides how the service worker handles each request. This module is pure —
|
||||
// no Workbox, no service worker globals — so the tests can assert every URL
|
||||
// class without a worker; src/sw.ts maps each route to a Workbox strategy.
|
||||
//
|
||||
// Security rule: /auth/*, /api/v1/settings/*, /api/v1/jobs/* and every
|
||||
// non-GET request are network-only and never reach a cache. Paths are matched
|
||||
// as the server sees them — percent-decoded, before and after dot segments are
|
||||
// resolved — so /%61uth/login is /auth/login; a malformed encoding is
|
||||
// network-only.
|
||||
|
||||
export type Route =
|
||||
// Network with a 3 s timeout, then the last cached response.
|
||||
| { 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.
|
||||
| { strategy: 'network-only' }
|
||||
// Network only, nothing cached; offline, the precached index.html.
|
||||
| { strategy: 'navigation-fallback' }
|
||||
|
||||
export interface RouteRequest {
|
||||
method: string
|
||||
url: URL
|
||||
mode: RequestMode
|
||||
}
|
||||
|
||||
// Bumping the version renames every cache; activate then deletes the old ones.
|
||||
export const cacheVersion = 'v1'
|
||||
export const cachePrefix = 'mealquest-'
|
||||
|
||||
export const cacheNames = {
|
||||
api: `${cachePrefix}api-${cacheVersion}`,
|
||||
media: `${cachePrefix}media-${cacheVersion}`,
|
||||
fonts: `${cachePrefix}fonts-${cacheVersion}`,
|
||||
} as const
|
||||
|
||||
export const networkTimeoutSeconds = 3
|
||||
|
||||
const neverCached = ['/auth', '/api/v1/settings', '/api/v1/jobs']
|
||||
const networkFirst = ['/api/v1/plans', '/api/v1/shopping', '/api/v1/dinners', '/api/v1/home']
|
||||
|
||||
// True when path is prefix itself or a path below it: /api/v1/plans matches
|
||||
// /api/v1/plans and /api/v1/plans/2026-W40, never /api/v1/plansx.
|
||||
function under(path: string, prefix: string): boolean {
|
||||
return path === prefix || path.startsWith(`${prefix}/`)
|
||||
}
|
||||
|
||||
// The pathname percent-decoded, as the server's router sees it, or undefined
|
||||
// when the encoding is malformed.
|
||||
function decodePath(pathname: string): string | undefined {
|
||||
try {
|
||||
return decodeURIComponent(pathname)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Resolves "." and ".." segments the way the server's path.Clean does, so a
|
||||
// decoded %2F cannot carry a request out of a reserved prefix:
|
||||
// /api/v1/plans/../settings cleans to /api/v1/settings.
|
||||
function cleanPath(path: string): string {
|
||||
const segments: string[] = []
|
||||
for (const segment of path.split('/')) {
|
||||
if (segment === '' || segment === '.') continue
|
||||
if (segment === '..') segments.pop()
|
||||
else segments.push(segment)
|
||||
}
|
||||
return `/${segments.join('/')}`
|
||||
}
|
||||
|
||||
const networkOnly: Route = { strategy: 'network-only' }
|
||||
|
||||
export function routeFor(request: RouteRequest, origin: string): Route {
|
||||
const { method, url, mode } = request
|
||||
if (method !== 'GET') return networkOnly
|
||||
if (url.origin !== origin) return networkOnly
|
||||
const path = decodePath(url.pathname)
|
||||
if (path === undefined) return networkOnly
|
||||
const cleaned = cleanPath(path)
|
||||
const reserved = (prefix: string) => under(path, prefix) || under(cleaned, prefix)
|
||||
if (neverCached.some(reserved)) return networkOnly
|
||||
if (networkFirst.some((prefix) => under(path, prefix))) {
|
||||
return { strategy: 'network-first', cacheName: cacheNames.api }
|
||||
}
|
||||
if (under(path, '/media')) return { strategy: 'cache-first', cacheName: cacheNames.media }
|
||||
if (path.endsWith('.woff2')) return { strategy: 'cache-first', cacheName: cacheNames.fonts }
|
||||
if (reserved('/api')) return networkOnly
|
||||
if (mode === 'navigate') return { strategy: 'navigation-fallback' }
|
||||
return networkOnly
|
||||
}
|
||||
|
||||
// The caches activate deletes: MealQuest caches that are no longer current.
|
||||
// Caches without the mealquest- prefix belong to someone else and are kept.
|
||||
export function staleCaches(names: readonly string[], current: readonly string[]): string[] {
|
||||
return names.filter((name) => name.startsWith(cachePrefix) && !current.includes(name))
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// The service worker itself needs worker globals, so these tests read its
|
||||
// source: they pin the parts routes.ts cannot express — the font strategy's
|
||||
// expiration and the font route's place ahead of the precache route.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import source from '../sw.ts?raw'
|
||||
|
||||
describe('service worker source', () => {
|
||||
it('gives the fonts cache a CacheFirst strategy with an expiration plugin', () => {
|
||||
expect(source).toMatch(
|
||||
/\[cacheNames\.fonts\]: new CacheFirst\(\{\s*cacheName: cacheNames\.fonts,\s*plugins: \[\s*new ExpirationPlugin\(\{ maxEntries: \d+, maxAgeSeconds: [^}]+\}\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the precached font when the network fails', () => {
|
||||
expect(source).toContain('handlerDidError: async ({ request }) => matchPrecache(request.url)')
|
||||
})
|
||||
|
||||
it('registers the font route before the precache route', () => {
|
||||
const fontRoute = source.indexOf("registerRoute(isFont, handle, 'GET')")
|
||||
const precache = source.indexOf('precacheAndRoute(self.__WB_MANIFEST)')
|
||||
expect(fontRoute).toBeGreaterThanOrEqual(0)
|
||||
expect(precache).toBeGreaterThan(fontRoute)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { vi, type Mock } from 'vitest'
|
||||
import type { ApiLocation, Me } from '../api/index.ts'
|
||||
|
||||
export const ME: Me = {
|
||||
name: 'Sam',
|
||||
email: 'sam@example.com',
|
||||
household: { label: 'Our kitchen' },
|
||||
csrfToken: 'token-1',
|
||||
}
|
||||
|
||||
export function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
|
||||
}
|
||||
|
||||
export function errorResponse(status: number, code: string, retryable = false): Response {
|
||||
return jsonResponse(status, { error: { code, message: `${code} message`, retryable } })
|
||||
}
|
||||
|
||||
// stubFetch replaces the global fetch with a mock answered from responses in
|
||||
// order; a request beyond them fails the test. Undo with vi.unstubAllGlobals.
|
||||
export function stubFetch(...responses: Response[]): Mock<typeof fetch> {
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
for (const response of responses) fetchMock.mockResolvedValueOnce(response)
|
||||
fetchMock.mockImplementation((input, init) => {
|
||||
throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${String(input)}`)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
// request returns the method, path and headers of the fetch mock's nth call.
|
||||
export function request(fetchMock: Mock<typeof fetch>, n: number) {
|
||||
const call = fetchMock.mock.calls[n]
|
||||
if (call === undefined) throw new Error(`fetch was called ${fetchMock.mock.calls.length} times, not ${n + 1}`)
|
||||
const [input, init] = call
|
||||
return {
|
||||
path: String(input),
|
||||
method: init?.method,
|
||||
credentials: init?.credentials,
|
||||
headers: new Headers(init?.headers),
|
||||
body: init?.body,
|
||||
}
|
||||
}
|
||||
|
||||
// fakeLocation stands in for window.location on https://mealquest.test,
|
||||
// recording navigations.
|
||||
export function fakeLocation(pathname = '/', search = ''): ApiLocation & { assign: Mock<(url: string | URL) => void> } {
|
||||
return { origin: 'https://mealquest.test', pathname, search, assign: vi.fn<(url: string | URL) => void>() }
|
||||
}
|
||||
+3
-1
@@ -13,9 +13,11 @@
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
"include": ["src", "vite.config.ts", "manifest.json"],
|
||||
"exclude": ["src/sw.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "WebWorker"],
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/sw.ts"],
|
||||
"exclude": []
|
||||
}
|
||||
+21
-1
@@ -1,10 +1,30 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA, type ManifestOptions } from 'vite-plugin-pwa'
|
||||
import manifest from './manifest.json' with { type: 'json' }
|
||||
|
||||
const backend = 'http://localhost:8080'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
react(),
|
||||
// The plugin writes manifest.webmanifest and sw.js, links the manifest
|
||||
// from index.html and adds the registerSW.js script. It does both only
|
||||
// in production builds: dev options stay off, so neither `vite` nor
|
||||
// Vitest registers a service worker.
|
||||
VitePWA({
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
filename: 'sw.ts',
|
||||
injectRegister: 'script',
|
||||
// Precache the shell, JS, CSS and the self-hosted fonts.
|
||||
injectManifest: { globPatterns: ['**/*.{html,js,css,woff2}'] },
|
||||
// The web manifest lives in manifest.json: its theme and background
|
||||
// colours are --color-accent and --color-bg from src/styles/tokens.css,
|
||||
// and hex colours may not be written in TypeScript.
|
||||
manifest: manifest as Partial<ManifestOptions>,
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': backend,
|
||||
|
||||
Reference in New Issue
Block a user