Merge pull request 'Promote dev to main: Feature 06 (PWA shell and offline)' (#71) from release/2026-10-05b into main
CI / ci (push) Successful in 1m40s

Reviewed-on: #71
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
This commit was merged in pull request #71.
This commit is contained in:
2026-10-05 16:02:03 -04:00
44 changed files with 7359 additions and 42 deletions
+3
View File
@@ -260,6 +260,9 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
hard-coded hex. Self-hosted Bagel Fat One and Figtree.
- Tests: Vitest, Testing Library, jsdom.
- Offline cache for the current plan, shopping list and recipes.
- Logout (`POST /auth/logout`) answers with `Clear-Site-Data: "storage",
"cache"`, wiping the offline marker, the service worker and its caches; the
worker leaves that request to the browser, as it does every network-only one.
| Route | Screen |
| --- | --- |
+1
View File
@@ -6,6 +6,7 @@ require (
github.com/coreos/go-oidc/v3 v3.21.0
github.com/go-chi/chi/v5 v5.3.1
github.com/oklog/ulid/v2 v2.1.2
golang.org/x/image v0.45.0
golang.org/x/oauth2 v0.36.0
modernc.org/sqlite v1.59.0
)
+2
View File
@@ -21,6 +21,8 @@ github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+5
View File
@@ -327,8 +327,13 @@ func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
// logout ends the session, clears the cookie and sends the browser to the
// provider's end_session_endpoint to end the provider session as well.
// Clear-Site-Data wipes this origin's storage and caches — the PWA's
// verified-session marker in localStorage and the service worker's cached API
// and media responses — so an offline visit after logout cannot reopen
// household data.
func (a *Auth) logout(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Clear-Site-Data", `"storage", "cache"`)
if err := a.sessions.EndSession(w, r); err != nil {
a.logger.ErrorContext(r.Context(), "end session", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
+6
View File
@@ -440,6 +440,9 @@ func TestLogout(t *testing.T) {
if !strings.HasPrefix(raw, SessionCookieName+"=;") || !strings.Contains(raw, "Max-Age=0") {
t.Errorf("logout Set-Cookie = %q, want the cookie cleared", raw)
}
if got := resp.Header.Get("Clear-Site-Data"); got != `"storage", "cache"` {
t.Errorf("logout Clear-Site-Data = %q, want the PWA's storage and caches cleared", got)
}
rows := h.rows(t)
if len(rows) != 1 || rows[0].idHash != sha256Hex(other.Value) {
@@ -461,6 +464,9 @@ func TestLogoutWithoutSession(t *testing.T) {
t.Errorf("logout = %d to %q, want 303 to the end session endpoint", resp.StatusCode, resp.Header.Get("Location"))
}
assertSessionCookieAttributes(t, sessionSetCookie(t, resp))
if got := resp.Header.Get("Clear-Site-Data"); got != `"storage", "cache"` {
t.Errorf("logout Clear-Site-Data = %q, want the PWA's storage and caches cleared", got)
}
}
func TestLogoutIsPostOnly(t *testing.T) {
+2
View File
@@ -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>
+19
View File
@@ -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"
}
]
}
+5227
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -5,9 +5,9 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "npm run lint:colors && 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: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

+186
View File
@@ -0,0 +1,186 @@
// Command generate-icons writes the PWA icons in web/public from the
// MealQuest logo. Run it from the repository root:
//
// go run ./web/scripts/generate-icons
//
// 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.
package main
import (
"fmt"
"image"
"image/color"
"image/png"
"math"
"os"
"path/filepath"
"slices"
"golang.org/x/image/draw"
)
const (
source = "design/logos/logo_v1_2Oct2026.jpg"
public = "web/public"
maskableScale = 0.66
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "generate-icons:", err)
os.Exit(1)
}
}
func run() error {
logo, err := load(source)
if err != nil {
return err
}
if b := logo.Bounds(); b.Dx() != b.Dy() {
return fmt.Errorf("%s is %dx%d; the logo must be square", source, b.Dx(), b.Dy())
}
outputs := []struct {
path string
image *image.RGBA
}{
{"icons/icon-192.png", resized(logo, 192)},
{"icons/icon-512.png", resized(logo, 512)},
{"icons/icon-maskable-512.png", maskable(logo, 512)},
{"apple-touch-icon.png", resized(logo, 180)},
}
for _, out := range outputs {
path := filepath.Join(public, out.path)
if err := save(path, out.image); err != nil {
return err
}
fmt.Printf("wrote %s (%dx%d)\n", path, out.image.Rect.Dx(), out.image.Rect.Dy())
}
return nil
}
// load decodes the logo. The file is a PNG despite its .jpg name, so the
// format is sniffed rather than taken from the extension.
func load(path string) (image.Image, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return nil, fmt.Errorf("decode %s: %w", path, err)
}
return img, nil
}
func save(path string, img image.Image) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
f, err := os.Create(path)
if err != nil {
return err
}
enc := png.Encoder{CompressionLevel: png.BestCompression}
if err := enc.Encode(f, img); err != nil {
f.Close()
return fmt.Errorf("encode %s: %w", path, err)
}
return f.Close()
}
func resized(logo image.Image, size int) *image.RGBA {
dst := image.NewRGBA(image.Rect(0, 0, size, size))
draw.CatmullRom.Scale(dst, dst.Rect, logo, logo.Bounds(), draw.Src, nil)
return dst
}
func maskable(logo image.Image, size int) *image.RGBA {
// The padding is the median colour of the logo's top edge, which is bare
// parchment; a feathered edge hides the seam.
b := logo.Bounds()
canvas := image.NewRGBA(image.Rect(0, 0, size, size))
draw.Draw(canvas, canvas.Rect, image.NewUniform(medianColour(logo, image.Rect(b.Min.X, b.Min.Y, b.Max.X, b.Min.Y+b.Dy()/40))), image.Point{}, draw.Src)
inner := int(math.Round(float64(size) * maskableScale))
offset := (size - inner) / 2
margin := inner / 24
at := image.Rect(offset, offset, offset+inner, offset+inner)
draw.DrawMask(canvas, at, resized(logo, inner), image.Point{}, feather(inner, margin), image.Point{}, draw.Over)
return canvas
}
// medianColour is the per-channel median of the pixels in r.
func medianColour(img image.Image, r image.Rectangle) color.RGBA {
var channels [3][]uint8
for y := r.Min.Y; y < r.Max.Y; y++ {
for x := r.Min.X; x < r.Max.X; x++ {
c := color.RGBAModel.Convert(img.At(x, y)).(color.RGBA)
channels[0] = append(channels[0], c.R)
channels[1] = append(channels[1], c.G)
channels[2] = append(channels[2], c.B)
}
}
var median [3]uint8
for i, values := range channels {
slices.Sort(values)
median[i] = values[len(values)/2]
}
return color.RGBA{R: median[0], G: median[1], B: median[2], A: 0xff}
}
// feather is an opaque square inset by margin on a size×size mask, its edge
// softened by a Gaussian blur with a standard deviation of margin/2.
func feather(size, margin int) *image.Alpha {
values := make([]float64, size*size)
for y := margin; y <= size-margin && y < size; y++ {
for x := margin; x <= size-margin && x < size; x++ {
values[y*size+x] = 255
}
}
kernel := gaussian(float64(margin) / 2)
values = blur(values, size, kernel, 1, size)
values = blur(values, size, kernel, size, 1)
mask := image.NewAlpha(image.Rect(0, 0, size, size))
for i, v := range values {
mask.Pix[i] = uint8(math.Round(min(max(v, 0), 255)))
}
return mask
}
// gaussian is a normalised kernel reaching three standard deviations.
func gaussian(sigma float64) []float64 {
radius := int(math.Ceil(3 * sigma))
kernel := make([]float64, 2*radius+1)
var sum float64
for i := range kernel {
d := float64(i - radius)
kernel[i] = math.Exp(-d * d / (2 * sigma * sigma))
sum += kernel[i]
}
for i := range kernel {
kernel[i] /= sum
}
return kernel
}
// blur convolves each line of a size×size grid with kernel: step is the
// distance between neighbours along a line and stride between lines. Samples
// beyond the edge repeat the edge.
func blur(values []float64, size int, kernel []float64, step, stride int) []float64 {
radius := len(kernel) / 2
out := make([]float64, len(values))
for line := range size {
for i := range size {
var v float64
for k, w := range kernel {
j := min(max(i+k-radius, 0), size-1)
v += w * values[line*stride+j*step]
}
out[line*stride+i*step] = v
}
}
return out
}
+103 -8
View File
@@ -1,23 +1,28 @@
import { render, screen } from '@testing-library/react'
import { act, render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApiClient } from './api/index.ts'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApiClient, createVerifiedSession } from './api/index.ts'
import { createNetworkStatus } from './pwa/networkStatus.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.restoreAllMocks()
vi.unstubAllGlobals()
})
function renderAt(path: string, location = fakeLocation(path)) {
render(
return { location, ...renderAppAt(path, location) }
}
function renderAppAt(path: string, location = fakeLocation(path)) {
return render(
<MemoryRouter initialEntries={[path]}>
<App api={createApiClient(location)} />
<App api={createApiClient(location, createNetworkStatus(window), createVerifiedSession(localStorage))} serviceWorker={undefined} />
</MemoryRouter>,
)
return location
}
describe('App routes', () => {
@@ -70,7 +75,7 @@ describe('App bootstrap', () => {
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'))
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)
@@ -80,7 +85,7 @@ describe('App bootstrap', () => {
it('shows the not-in-this-household screen on a 403', async () => {
const fetchMock = stubFetch(errorResponse(403, 'forbidden'))
const location = renderAt('/')
const { location } = renderAt('/')
expect(await screen.findByText(/not in this household/)).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Not in this household' })).toBeInTheDocument()
@@ -99,3 +104,93 @@ describe('App bootstrap', () => {
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
})
})
describe('App offline bootstrap', () => {
let onLine: boolean
function goOffline() {
onLine = false
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
}
// visitOnline is an earlier online visit on this device: /me answers with a
// member, then the app is closed.
async function visitOnline() {
stubFetch(jsonResponse(200, ME))
const { unmount } = renderAt('/')
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
unmount()
}
beforeEach(() => {
mockViewport(390)
onLine = true
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine)
})
it.each([
['/plan', 'Plan'],
['/dinner/01J9ZK', 'Dinner'],
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
await visitOnline()
goOffline()
renderAt(path)
expect(await screen.findByRole('heading', { level: 1, name: title })).toBeInTheDocument()
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('opens the saved pages when the server is unreachable but the browser reports online', async () => {
await visitOnline()
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
renderAt('/plan')
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
})
it('reports the failure offline when this device never verified a session', async () => {
goOffline()
renderAt('/plan')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
})
it('reports the failure offline after the session was refused', async () => {
await visitOnline()
stubFetch(errorResponse(401, 'unauthenticated'))
const { location, unmount } = renderAt('/plan')
await vi.waitFor(() => expect(location.assign).toHaveBeenCalled())
unmount()
goOffline()
renderAt('/plan')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
})
it('loads /me again on reconnect and refuses writes until it has', async () => {
await visitOnline()
goOffline()
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
render(
<MemoryRouter initialEntries={['/plan']}>
<App api={api} serviceWorker={undefined} />
</MemoryRouter>,
)
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
const fetchMock = stubFetch(jsonResponse(200, ME))
onLine = true
act(() => {
window.dispatchEvent(new Event('online'))
})
await vi.waitFor(() => expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET' }))
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
expect(screen.getByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
})
})
+11 -1
View File
@@ -4,6 +4,8 @@ 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 { OfflinePill } from './pwa/OfflinePill.tsx'
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
import { AppShell } from './shell/AppShell.tsx'
// Every route the app serves, with the heading its placeholder page shows.
@@ -40,11 +42,17 @@ export function AppRoutes() {
export type AppProps = {
api: ApiClient
// navigator.serviceWorker, or undefined where there is none (and in tests).
serviceWorker: UpdateContainer | undefined
}
function reloadPage() {
window.location.reload()
}
// App expects to be rendered inside a router (BrowserRouter in main.tsx,
// MemoryRouter in tests).
export default function App({ api }: AppProps) {
export default function App({ api, serviceWorker }: AppProps) {
const [queryClient] = useState(createQueryClient)
return (
<ApiProvider client={api}>
@@ -52,6 +60,8 @@ export default function App({ api }: AppProps) {
<AuthBootstrap>
<AppRoutes />
</AuthBootstrap>
<OfflinePill />
<UpdatePrompt container={serviceWorker} reload={reloadPage} />
</QueryClientProvider>
</ApiProvider>
)
+19 -3
View File
@@ -1,4 +1,4 @@
import { useSyncExternalStore, type ReactNode } from 'react'
import { useEffect, 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'
@@ -11,13 +11,29 @@ 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.
//
// When /me cannot reach the server and it last answered with a member on this
// device, the saved pages open read-only: the service worker answers their
// reads from its cache, and every write is refused for want of a CSRF token.
// /me runs even while the browser reports offline, so that case fails fast
// instead of waiting, and it is fetched again when the browser is back online.
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 })
const me = useQuery({ queryKey: meQueryKey, queryFn: api.loadMe, staleTime: Infinity, networkMode: 'always' })
// fetch rejects with a TypeError only when the network fails.
const savedPages = me.isError && me.error instanceof TypeError && api.session.verified()
const { refetch } = me
useEffect(() => {
if (!savedPages) return
return api.network.subscribe(() => {
if (!api.network.offline()) void refetch()
})
}, [savedPages, api, refetch])
if (forbidden) return <NotInHouseholdPage />
if (me.isSuccess) return children
if (me.isSuccess || savedPages) return children
if (me.isError && !(me.error instanceof ApiError && me.error.status === 401)) {
return (
<main className={styles.screen}>
+83 -13
View File
@@ -1,15 +1,18 @@
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'
import { ApiError, createApiClient, createVerifiedSession, CSRF_HEADER, VERIFIED_SESSION_KEY } from './index.ts'
import { cacheFallbackHeader } from '../sw/routes.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
afterEach(() => {
vi.restoreAllMocks()
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())
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
await expect(api.apiFetch('/api/v1/settings/household')).resolves.toEqual({ ok: 1 })
@@ -29,7 +32,7 @@ describe('apiFetch', () => {
it('refuses a non-GET request before /me has loaded', async () => {
const fetchMock = stubFetch()
const api = createApiClient(fakeLocation())
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
expect(fetchMock).not.toHaveBeenCalled()
})
@@ -47,7 +50,7 @@ describe('apiFetch', () => {
'api/v1/me',
])('refuses %s without sending it or the CSRF token', async (path) => {
const fetchMock = stubFetch(jsonResponse(200, ME))
const api = createApiClient(fakeLocation())
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
await expect(api.apiFetch(path, { method: 'POST', body: {} })).rejects.toThrow(
@@ -60,7 +63,7 @@ describe('apiFetch', () => {
it('rejects with a typed ApiError from the error body', async () => {
stubFetch(errorResponse(503, 'unavailable', true))
const api = createApiClient(fakeLocation())
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
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 })
@@ -68,14 +71,14 @@ describe('apiFetch', () => {
it('rejects a response without the API error body', async () => {
stubFetch(new Response('<html>bad gateway</html>', { status: 502 }))
const api = createApiClient(fakeLocation())
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
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)
const api = createApiClient(location, createNetworkStatus(window), createVerifiedSession(localStorage))
await expect(api.apiFetch('/api/v1/settings/household')).rejects.toMatchObject({ status: 401 })
expect(location.assign).toHaveBeenCalledExactlyOnceWith(
@@ -90,7 +93,7 @@ describe('apiFetch', () => {
jsonResponse(200, { ...ME, csrfToken: 'token-2' }),
jsonResponse(201, { id: 'j1' }),
)
const api = createApiClient(fakeLocation())
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: { kind: 'noop' } })).resolves.toEqual({
@@ -111,7 +114,7 @@ describe('apiFetch', () => {
jsonResponse(200, { ...ME, csrfToken: 'token-2' }),
errorResponse(403, 'csrf'),
)
const api = createApiClient(fakeLocation())
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toMatchObject({
@@ -124,7 +127,7 @@ describe('apiFetch', () => {
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 api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
const listener = vi.fn()
api.subscribe(listener)
@@ -136,14 +139,14 @@ describe('apiFetch', () => {
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())
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
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())
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await expect(api.loadMe()).rejects.toMatchObject({ status: 403, code: 'csrf' })
expect(fetchMock).toHaveBeenCalledTimes(1)
})
@@ -152,7 +155,74 @@ describe('apiFetch', () => {
describe('loadMe', () => {
it('rejects a /me response without a CSRF token', async () => {
stubFetch(jsonResponse(200, { ...ME, csrfToken: '' }))
const api = createApiClient(fakeLocation())
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await expect(api.loadMe()).rejects.toThrow('response is not a member with a CSRF token')
})
})
describe('verified session', () => {
it('is remembered when /me answers with a member', async () => {
stubFetch(jsonResponse(200, ME))
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
expect(api.session.verified()).toBe(false)
await api.loadMe()
expect(api.session.verified()).toBe(true)
expect(localStorage.getItem(VERIFIED_SESSION_KEY)).toBe('1')
})
it('stores nothing from the /me response', async () => {
stubFetch(jsonResponse(200, ME))
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
expect(Object.keys(localStorage)).toEqual([VERIFIED_SESSION_KEY])
expect(JSON.stringify({ ...localStorage })).not.toContain(ME.csrfToken)
})
it('is not remembered for a /me response that is not a member', async () => {
stubFetch(jsonResponse(200, { ...ME, csrfToken: '' }))
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await expect(api.loadMe()).rejects.toThrow()
expect(api.session.verified()).toBe(false)
})
it('is forgotten on a 401', async () => {
stubFetch(jsonResponse(200, ME), errorResponse(401, 'unauthenticated'))
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
await expect(api.apiFetch('/api/v1/plans')).rejects.toMatchObject({ status: 401 })
expect(api.session.verified()).toBe(false)
})
it('is forgotten when the account is not in this household', async () => {
stubFetch(jsonResponse(200, ME), errorResponse(403, 'forbidden'))
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
await expect(api.apiFetch('/api/v1/plans')).rejects.toMatchObject({ status: 403 })
expect(api.session.verified()).toBe(false)
})
it('is kept when a request fails with a network error', async () => {
stubFetch(jsonResponse(200, ME))
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
await expect(api.loadMe()).rejects.toThrow(TypeError)
expect(api.session.verified()).toBe(true)
})
})
describe('network status', () => {
it('counts a response the service worker answered from its cache as offline', async () => {
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
const cached = jsonResponse(200, { id: '01J9ZR' })
cached.headers.set(cacheFallbackHeader, '1')
stubFetch(cached, jsonResponse(200, { id: '01J9ZR' }))
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await expect(api.apiFetch('/api/v1/recipes/01J9ZR')).resolves.toEqual({ id: '01J9ZR' })
expect(api.network.offline()).toBe(true)
await expect(api.apiFetch('/api/v1/recipes/01J9ZR')).resolves.toEqual({ id: '01J9ZR' })
expect(api.network.offline()).toBe(false)
})
})
+44 -8
View File
@@ -1,4 +1,7 @@
import type { NetworkStatus } from '../pwa/networkStatus.ts'
import { cacheFallbackHeader } from '../sw/routes.ts'
import { ApiError, isRecord, parseApiError } from './errors.ts'
import type { VerifiedSession } from './verifiedSession.ts'
// CSRF_HEADER carries the session's CSRF token on every non-GET request.
export const CSRF_HEADER = 'X-MealQuest-CSRF'
@@ -36,7 +39,7 @@ export type ApiLocation = Pick<Location, 'origin' | 'pathname' | 'search' | 'ass
export type ApiRequest = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
// Sent as JSON.
// Sent as JSON. A GET must not have one.
body?: unknown
}
@@ -51,11 +54,18 @@ export type ApiClient = {
// account is not in this household; subscribe is notified when it changes.
forbidden(): boolean
subscribe(listener: () => void): () => void
// network learns of every request that failed with a network error or was
// answered from the service worker's cache, and of every request the
// server answered.
network: NetworkStatus
// session remembers, on this device, whether /me last answered with a
// member; offline, AuthBootstrap opens the saved pages only if it did.
session: VerifiedSession
}
// 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 {
export function createApiClient(location: ApiLocation, network: NetworkStatus, session: VerifiedSession): ApiClient {
let csrfToken: string | null = null
let refreshing: Promise<Me> | null = null
let forbidden = false
@@ -70,12 +80,28 @@ export function createApiClient(location: ApiLocation): ApiClient {
}
headers[CSRF_HEADER] = csrfToken
}
return fetch(path, {
method,
headers,
credentials: 'same-origin',
body: body === undefined ? undefined : JSON.stringify(body),
})
// Serialize before fetching so a body that cannot be sent is its own error
// and never counts as a network failure.
let json: string | undefined
try {
json = body === undefined ? undefined : JSON.stringify(body)
} catch (error) {
throw new Error(`${method} ${path}: request body is not serializable as JSON`, { cause: error })
}
let response: Response
try {
response = await fetch(path, { method, headers, credentials: 'same-origin', body: json })
} catch (error) {
// fetch rejects with a TypeError when the network fails; an abort is a
// DOMException and says nothing about the network.
if (error instanceof TypeError) network.requestFailed()
throw error
}
// The service worker marks a response it answered from its cache because
// the server could not be reached in time.
if (response.headers.has(cacheFallbackHeader)) network.requestFailed()
else network.requestSucceeded()
return response
}
// refreshMe shares one in-flight /me request between every request that
@@ -90,6 +116,11 @@ export function createApiClient(location: ApiLocation): ApiClient {
async function apiFetch<T>(path: string, request: ApiRequest = {}): Promise<T> {
const method = request.method ?? 'GET'
const url = apiURL(location.origin, method, path)
// fetch refuses a GET with a body by rejecting with a TypeError, the same
// error as a network failure, so refuse it here before anything is sent.
if (method === 'GET' && request.body !== undefined) {
throw new Error(`GET ${path}: a GET request cannot carry a body`)
}
let response = await send(method, url, request.body)
if (response.status === 403) {
const error = await parseForbidden(method, path, response)
@@ -113,12 +144,14 @@ export function createApiClient(location: ApiLocation): ApiClient {
function markForbidden() {
if (forbidden) return
session.forget()
forbidden = true
listeners.forEach((listener) => listener())
}
async function decode<T>(method: string, path: string, response: Response): Promise<T> {
if (response.status === 401) {
session.forget()
location.assign(`/auth/login?return=${encodeURIComponent(location.pathname + location.search)}`)
}
if (!response.ok) throw await parseApiError(method, path, response)
@@ -140,6 +173,7 @@ export function createApiClient(location: ApiLocation): ApiClient {
throw new Error(`GET ${ME_PATH}: response is not a member with a CSRF token`)
}
csrfToken = me.csrfToken
session.remember()
return me as Me
}
@@ -151,5 +185,7 @@ export function createApiClient(location: ApiLocation): ApiClient {
listeners.add(listener)
return () => listeners.delete(listener)
},
network,
session,
}
}
+1
View File
@@ -2,4 +2,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 { createVerifiedSession, VERIFIED_SESSION_KEY, type VerifiedSession } from './verifiedSession.ts'
export { useJob, type Job, type JobState, type JobStatus } from './useJob.ts'
+3 -2
View File
@@ -2,7 +2,8 @@ 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'
import { ApiProvider, createApiClient, createVerifiedSession, useJob } from './index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
type Draft = { dinners: string[] }
@@ -11,7 +12,7 @@ function job(status: string, result: Draft | null, error: string | null = null)
}
function renderJob(jobId: string | null) {
const api = createApiClient(fakeLocation())
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
const wrapper = ({ children }: { children: ReactNode }) => <ApiProvider client={api}>{children}</ApiProvider>
return renderHook(({ id }) => useJob<Draft>(id), { wrapper, initialProps: { id: jobId } })
}
+24
View File
@@ -0,0 +1,24 @@
// VERIFIED_SESSION_KEY is the localStorage key marking that GET /api/v1/me
// last answered with a household member on this device. It holds only "1" —
// never the /me response, its CSRF token or anything from /auth.
export const VERIFIED_SESSION_KEY = 'mealquest.verified-session'
export type VerifiedSession = {
// verified reports whether /me last answered with a member on this device.
verified(): boolean
// remember records a /me that answered with a member.
remember(): void
// forget records that the server refused the session (401) or the account
// (not in this household).
forget(): void
}
// createVerifiedSession keeps the marker in storage (window.localStorage in
// the app). A storage failure is thrown to the caller.
export function createVerifiedSession(storage: Storage): VerifiedSession {
return {
verified: () => storage.getItem(VERIFIED_SESSION_KEY) === '1',
remember: () => storage.setItem(VERIFIED_SESSION_KEY, '1'),
forget: () => storage.removeItem(VERIFIED_SESSION_KEY),
}
}
+4 -3
View File
@@ -4,19 +4,20 @@ import { BrowserRouter } from 'react-router'
import './styles/tokens.css'
import './styles/base.css'
import App from './App.tsx'
import { createApiClient } from './api/index.ts'
import { createApiClient, createVerifiedSession } from './api/index.ts'
import { createNetworkStatus } from './pwa/networkStatus.ts'
const root = document.getElementById('root')
if (!root) {
throw new Error('MealQuest: #root element missing from index.html')
}
const api = createApiClient(window.location)
const api = createApiClient(window.location, createNetworkStatus(window), createVerifiedSession(window.localStorage))
createRoot(root).render(
<StrictMode>
<BrowserRouter>
<App api={api} />
<App api={api} serviceWorker={'serviceWorker' in navigator ? navigator.serviceWorker : undefined} />
</BrowserRouter>
</StrictMode>,
)
+157
View File
@@ -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"')
})
})
+18
View File
@@ -0,0 +1,18 @@
.hint {
display: flex;
align-items: center;
gap: var(--space-3);
}
.icon {
color: var(--color-accent);
}
.message {
flex: 1;
margin: 0;
font-family: var(--font-body);
font-size: 15px;
font-weight: 600;
line-height: 1.35;
}
+146
View File
@@ -0,0 +1,146 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DISMISSED_KEY, InstallHint } from './InstallHint.tsx'
const IPHONE_SAFARI =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1'
const IPAD_SAFARI =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15'
const MAC_SAFARI = IPAD_SAFARI
const IPHONE_CHROME =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/138.0.7204.119 Mobile/15E148 Safari/604.1'
const IPHONE_FIREFOX =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/141.0 Mobile/15E148 Safari/605.1.15'
const IPHONE_EDGE =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 EdgiOS/138.3351.83 Mobile/15E148 Safari/605.1.15'
const IPHONE_OPERA =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1 OPT/5.4.0'
const IPHONE_MQQBROWSER =
'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 MQQBrowser/11.3.5 Mobile/15E148 Safari/604.1'
const ANDROID_CHROME =
'Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36'
const DESKTOP_CHROME =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'
const copy = 'Tap Share, then Add to Home Screen to get notifications'
type Device = {
userAgent: string
maxTouchPoints?: number
// display-mode: standalone
displayStandalone?: boolean
// navigator.standalone (Safari only; undefined elsewhere)
navigatorStandalone?: boolean
}
// mockDevice pretends to be a browser with the given user agent and
// display mode; jsdom has no matchMedia, navigator.maxTouchPoints or
// navigator.standalone.
function mockDevice({ userAgent, maxTouchPoints = 0, displayStandalone = false, navigatorStandalone }: Device) {
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(userAgent)
Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: maxTouchPoints })
Object.defineProperty(navigator, 'standalone', { configurable: true, value: navigatorStandalone })
vi.stubGlobal('matchMedia', (query: string) => {
if (query !== '(display-mode: standalone)') throw new Error(`unexpected media query ${query}`)
return { matches: displayStandalone, media: query } as MediaQueryList
})
}
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
Reflect.deleteProperty(navigator, 'maxTouchPoints')
Reflect.deleteProperty(navigator, 'standalone')
localStorage.clear()
})
describe('InstallHint', () => {
it('shows on iPhone Safari in a browser tab', () => {
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false })
render(<InstallHint />)
expect(screen.getByRole('note')).toHaveTextContent(copy)
})
it('shows on iPadOS Safari, which reports itself as a Mac with a touch screen', () => {
mockDevice({ userAgent: IPAD_SAFARI, maxTouchPoints: 5, navigatorStandalone: false })
render(<InstallHint />)
expect(screen.getByRole('note')).toHaveTextContent(copy)
})
it('is hidden when navigator.standalone is true', () => {
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: true })
render(<InstallHint />)
expect(screen.queryByRole('note')).toBeNull()
})
it('is hidden when display-mode is standalone', () => {
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, displayStandalone: true })
render(<InstallHint />)
expect(screen.queryByRole('note')).toBeNull()
})
it.each([
['Android Chrome', { userAgent: ANDROID_CHROME, maxTouchPoints: 5 }],
['desktop Chrome', { userAgent: DESKTOP_CHROME }],
['desktop Safari on a Mac', { userAgent: MAC_SAFARI }],
['Chrome on iPhone', { userAgent: IPHONE_CHROME, maxTouchPoints: 5 }],
['Firefox on iPhone', { userAgent: IPHONE_FIREFOX, maxTouchPoints: 5 }],
['Edge on iPhone', { userAgent: IPHONE_EDGE, maxTouchPoints: 5 }],
['Opera on iPhone', { userAgent: IPHONE_OPERA, maxTouchPoints: 5 }],
['MQQBrowser on iPhone', { userAgent: IPHONE_MQQBROWSER, maxTouchPoints: 5 }],
])('is hidden on %s', (_name, device: Device) => {
mockDevice(device)
render(<InstallHint />)
expect(screen.queryByRole('note')).toBeNull()
})
it('stays dismissed on this device', () => {
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false })
const first = render(<InstallHint />)
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
expect(screen.queryByRole('note')).toBeNull()
expect(localStorage.getItem(DISMISSED_KEY)).toBe('1')
first.unmount()
render(<InstallHint />)
expect(screen.queryByRole('note')).toBeNull()
})
it('stays up and reports the error when the dismissal cannot be saved', () => {
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false })
const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('quota exceeded', 'QuotaExceededError')
})
render(<InstallHint />)
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
expect(screen.getByRole('note')).toHaveTextContent(copy)
expect(screen.getByRole('alert')).toHaveTextContent(
'Could not remember on this device that this hint was dismissed: QuotaExceededError: quota exceeded',
)
setItem.mockRestore()
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(screen.queryByRole('note')).toBeNull()
expect(screen.queryByRole('alert')).toBeNull()
expect(localStorage.getItem(DISMISSED_KEY)).toBe('1')
})
it('shows and reports the error when storage cannot be read', () => {
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false })
const getItem = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new DOMException('storage blocked', 'SecurityError')
})
render(<InstallHint />)
expect(screen.getByRole('note')).toHaveTextContent(copy)
expect(screen.getByRole('alert')).toHaveTextContent(
'Could not check whether this hint was dismissed on this device: SecurityError: storage blocked',
)
getItem.mockRestore()
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(screen.getByRole('note')).toHaveTextContent(copy)
expect(screen.queryByRole('alert')).toBeNull()
})
})
+97
View File
@@ -0,0 +1,97 @@
import { useState } from 'react'
import { Share, X } from 'lucide-react'
import { Card } from '../ui/Card.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import styles from './InstallHint.module.css'
// DISMISSED_KEY is the localStorage key remembering, on this device, that the
// household dismissed the hint.
export const DISMISSED_KEY = 'mealquest.installHint.dismissed'
// Safari's user agent ends "(KHTML, like Gecko) Version/<v> Mobile/<build>
// Safari/<v>" (iPadOS leaves out Mobile/). Every other iOS browser and in-app
// web view adds its own token (CriOS, FxiOS, EdgiOS, OPiOS, MQQBrowser, GSA,
// ...) or drops Version/ or Safari/, so only this exact tail is Safari.
const SAFARI_TAIL = /\(KHTML, like Gecko\) Version\/[\d.]+ (?:Mobile\/\w+ )?Safari\/[\d.]+$/
// isIosSafari reports whether the page is open in Safari on iPhone, iPod or
// iPad. iPadOS 13+ asks for the desktop site and reports itself as a Mac, so
// a "Macintosh" with a touch screen is an iPad.
export function isIosSafari(nav: Pick<Navigator, 'userAgent' | 'maxTouchPoints'>): boolean {
const ua = nav.userAgent
const ios = /iPhone|iPod|iPad/.test(ua) || (/Macintosh/.test(ua) && nav.maxTouchPoints > 1)
return ios && SAFARI_TAIL.test(ua)
}
// isStandalone reports whether the app is running from the Home Screen.
// Safari sets navigator.standalone; other browsers answer the display-mode
// media query.
export function isStandalone(): boolean {
const legacy = (navigator as Navigator & { standalone?: boolean }).standalone
return legacy === true || window.matchMedia('(display-mode: standalone)').matches
}
// HintState is whether the household dismissed the hint on this device, and
// the storage failure, if any, that stopped us reading or saving that.
type HintState = {
dismissed: boolean
failure: { action: 'read' | 'write'; message: string } | null
}
// Storage can be unavailable (blocked by the browser or privacy settings), in
// which case using it throws. The failure is reported, never treated as a
// remembered dismissal.
function readDismissed(): HintState {
try {
return { dismissed: window.localStorage.getItem(DISMISSED_KEY) === '1', failure: null }
} catch (err) {
return { dismissed: false, failure: { action: 'read', message: String(err) } }
}
}
function writeDismissed(): HintState {
try {
window.localStorage.setItem(DISMISSED_KEY, '1')
return { dismissed: true, failure: null }
} catch (err) {
return { dismissed: false, failure: { action: 'write', message: String(err) } }
}
}
const failureText = {
read: 'Could not check whether this hint was dismissed on this device',
write: 'Could not remember on this device that this hint was dismissed',
}
// InstallHint tells iOS and iPadOS Safari users how to install the app, which
// iOS requires before it delivers Web Push. It shows only in a Safari browser
// tab, never once installed, and stays dismissed on this device. If storage
// fails, the hint stays up with an error saying so. Used by onboarding step 3
// and the Notifications screen.
export function InstallHint() {
const [eligible] = useState(() => isIosSafari(navigator) && !isStandalone())
const [state, setState] = useState<HintState>(() =>
eligible ? readDismissed() : { dismissed: false, failure: null },
)
if (!eligible || state.dismissed) return null
const { failure } = state
return (
<>
<Card tone="muted" role="note" className={styles.hint}>
<Icon icon={Share} className={styles.icon} />
<p className={styles.message}>Tap Share, then Add to Home Screen to get notifications</p>
<IconButton label="Dismiss" onClick={() => setState(writeDismissed())}>
<Icon icon={X} />
</IconButton>
</Card>
{failure && (
<ErrorBanner onRetry={() => setState(failure.action === 'read' ? readDismissed() : writeDismissed())}>
{failureText[failure.action]}: {failure.message}
</ErrorBanner>
)}
</>
)
}
+22
View File
@@ -0,0 +1,22 @@
.pill {
position: fixed;
top: calc(var(--space-3) + env(safe-area-inset-top));
left: 50%;
z-index: 20;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: var(--space-2);
margin: 0;
padding: 6px 14px;
border-radius: var(--radius-pill);
background: var(--color-neutral-800);
color: var(--on-accent);
box-shadow: var(--shadow-md);
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
pointer-events: none;
}
+150
View File
@@ -0,0 +1,150 @@
import { act, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, createVerifiedSession, type ApiClient } from '../api/index.ts'
import { fakeLocation, jsonResponse, ME, stubFetch } from '../test/fetchStub.ts'
import { cacheFallbackHeader } from '../sw/routes.ts'
import { createNetworkStatus } from './networkStatus.ts'
import { OfflinePill } from './OfflinePill.tsx'
const copy = 'Offline — showing saved data'
let onLine: boolean
beforeEach(() => {
onLine = true
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine)
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
function goOffline() {
onLine = false
window.dispatchEvent(new Event('offline'))
}
function goOnline() {
onLine = true
window.dispatchEvent(new Event('online'))
}
function renderPill(): ApiClient {
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
render(
<ApiProvider client={api}>
<OfflinePill />
</ApiProvider>,
)
return api
}
describe('OfflinePill', () => {
it('is hidden while online', () => {
renderPill()
expect(screen.queryByRole('status')).toBeNull()
})
it('shows while the browser is offline when it renders', () => {
onLine = false
renderPill()
expect(screen.getByRole('status')).toHaveTextContent(copy)
})
it('appears on the offline event and disappears on the online event', () => {
renderPill()
act(goOffline)
expect(screen.getByRole('status')).toHaveTextContent(copy)
act(goOnline)
expect(screen.queryByRole('status')).toBeNull()
})
it('appears when a request fails with a network error', async () => {
const api = renderPill()
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
await act(() => expect(api.apiFetch('/api/v1/plan')).rejects.toThrow(TypeError))
expect(screen.getByRole('status')).toHaveTextContent(copy)
act(goOnline)
expect(screen.queryByRole('status')).toBeNull()
})
it('disappears when a later request gets a response', async () => {
const api = renderPill()
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
await act(() => expect(api.apiFetch('/api/v1/plan')).rejects.toThrow(TypeError))
expect(screen.getByRole('status')).toBeInTheDocument()
stubFetch(jsonResponse(200, {}))
await act(() => api.apiFetch('/api/v1/plan'))
expect(screen.queryByRole('status')).toBeNull()
})
it('shows while online when the service worker answers from its cache', async () => {
const api = renderPill()
const cached = jsonResponse(200, { id: '01J9ZR' })
cached.headers.set(cacheFallbackHeader, '1')
stubFetch(cached, jsonResponse(200, { id: '01J9ZR' }))
await act(() => api.apiFetch('/api/v1/recipes/01J9ZR'))
expect(navigator.onLine).toBe(true)
expect(screen.getByRole('status')).toHaveTextContent(copy)
// The server answers again.
await act(() => api.apiFetch('/api/v1/recipes/01J9ZR'))
expect(screen.queryByRole('status')).toBeNull()
})
it('ignores a request body that cannot be serialized', async () => {
const api = renderPill()
const fetchMock = stubFetch(jsonResponse(200, ME))
await act(() => api.loadMe())
const body: Record<string, unknown> = {}
body.self = body
await act(() =>
expect(api.apiFetch('/api/v1/plan', { method: 'PUT', body })).rejects.toThrow(
'PUT /api/v1/plan: request body is not serializable as JSON',
),
)
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('status')).toBeNull()
})
it('ignores a GET request with a body, which fetch would refuse to send', async () => {
const api = renderPill()
const fetchMock = stubFetch()
await act(() =>
expect(api.apiFetch('/api/v1/plan', { body: { week: 1 } })).rejects.toThrow(
'GET /api/v1/plan: a GET request cannot carry a body',
),
)
expect(fetchMock).not.toHaveBeenCalled()
expect(screen.queryByRole('status')).toBeNull()
})
it('ignores an aborted request', async () => {
const api = renderPill()
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new DOMException('aborted', 'AbortError')))
await act(() => expect(api.apiFetch('/api/v1/plan')).rejects.toThrow('aborted'))
expect(screen.queryByRole('status')).toBeNull()
})
it('removes its online and offline listeners on unmount', () => {
const add = vi.spyOn(window, 'addEventListener')
const remove = vi.spyOn(window, 'removeEventListener')
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
const { unmount } = render(
<ApiProvider client={api}>
<OfflinePill />
</ApiProvider>,
)
const added = add.mock.calls.filter(([type]) => type === 'online' || type === 'offline')
expect(added.map(([type]) => type).sort()).toEqual(['offline', 'online'])
unmount()
for (const [type, listener] of added) expect(remove).toHaveBeenCalledWith(type, listener)
})
})
+19
View File
@@ -0,0 +1,19 @@
import { useSyncExternalStore } from 'react'
import { WifiOff } from 'lucide-react'
import { useApi } from '../api/index.ts'
import { Icon } from '../ui/Icon.tsx'
import styles from './OfflinePill.module.css'
// OfflinePill tells the household that what they see came from the cache
// while the browser is offline or the server cannot be reached.
export function OfflinePill() {
const { network } = useApi()
const offline = useSyncExternalStore(network.subscribe, network.offline)
if (!offline) return null
return (
<p role="status" className={styles.pill}>
<Icon icon={WifiOff} />
Offline — showing saved data
</p>
)
}
+22
View File
@@ -0,0 +1,22 @@
.toast {
position: fixed;
left: 50%;
bottom: calc(110px + env(safe-area-inset-bottom));
z-index: 20;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: var(--space-3);
width: max-content;
max-width: calc(100vw - 2 * var(--space-4));
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4);
box-shadow: var(--shadow-lg);
}
.message {
margin: 0;
font-family: var(--font-body);
font-size: 15px;
font-weight: 600;
line-height: 1.35;
}
+165
View File
@@ -0,0 +1,165 @@
import { act, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { SKIP_WAITING, UpdatePrompt, type UpdateContainer } from './UpdatePrompt.tsx'
// FakeWorker is a ServiceWorker whose state the test moves along.
class FakeWorker extends EventTarget {
state: ServiceWorkerState = 'installing'
postMessage = vi.fn()
setState(state: ServiceWorkerState) {
this.state = state
this.dispatchEvent(new Event('statechange'))
}
}
class FakeRegistration extends EventTarget {
installing: FakeWorker | null = null
waiting: FakeWorker | null = null
// install starts a new worker, as the browser does on finding an update.
install(): FakeWorker {
const worker = new FakeWorker()
this.installing = worker
this.dispatchEvent(new Event('updatefound'))
return worker
}
// finishInstall moves the installing worker to waiting.
finishInstall(worker: FakeWorker) {
this.installing = null
this.waiting = worker
worker.setState('installed')
}
}
class FakeContainer extends EventTarget {
controller: object | null = {}
registration = new FakeRegistration()
ready = Promise.resolve(this.registration)
// takeOver makes the waiting worker the controller.
takeOver() {
const worker = this.registration.waiting
this.registration.waiting = null
this.controller = worker
worker?.setState('activated')
this.dispatchEvent(new Event('controllerchange'))
}
}
async function renderPrompt(container: FakeContainer | undefined) {
const reload = vi.fn()
const view = render(<UpdatePrompt container={container as unknown as UpdateContainer} reload={reload} />)
await act(async () => {
await container?.ready
})
return { reload, ...view }
}
const copy = 'New version ready · Reload'
describe('UpdatePrompt', () => {
it('shows nothing without a service worker', async () => {
await renderPrompt(undefined)
expect(screen.queryByRole('status')).toBeNull()
})
it('shows nothing while no worker is waiting', async () => {
const container = new FakeContainer()
await renderPrompt(container)
act(() => {
container.registration.install()
})
expect(screen.queryByRole('status')).toBeNull()
})
it('shows nothing for a waiting worker when the page has no controller', async () => {
const container = new FakeContainer()
container.controller = null
container.registration.waiting = new FakeWorker()
await renderPrompt(container)
expect(screen.queryByRole('status')).toBeNull()
})
it('shows the toast for a worker already waiting when the page loads', async () => {
const container = new FakeContainer()
container.registration.waiting = new FakeWorker()
await renderPrompt(container)
expect(screen.getByRole('status')).toHaveTextContent(copy)
})
it('shows the toast when an update finishes installing', async () => {
const container = new FakeContainer()
await renderPrompt(container)
act(() => container.registration.finishInstall(container.registration.install()))
expect(screen.getByRole('status')).toHaveTextContent(copy)
})
it('sends SKIP_WAITING on Reload and reloads once the new worker controls the page', async () => {
const container = new FakeContainer()
const { reload } = await renderPrompt(container)
const worker = container.registration.install()
act(() => container.registration.finishInstall(worker))
act(() => screen.getByRole('button', { name: 'Reload' }).click())
expect(worker.postMessage).toHaveBeenCalledExactlyOnceWith(SKIP_WAITING)
expect(screen.queryByRole('status')).toBeNull()
expect(reload).not.toHaveBeenCalled()
act(() => container.takeOver())
expect(reload).toHaveBeenCalledOnce()
// A later controller change (another tab's update) does not reload again.
act(() => container.dispatchEvent(new Event('controllerchange')))
expect(reload).toHaveBeenCalledOnce()
expect(screen.queryByRole('status')).toBeNull()
})
it('does not reappear for the worker the household chose to reload into', async () => {
const container = new FakeContainer()
container.registration.waiting = new FakeWorker()
await renderPrompt(container)
act(() => screen.getByRole('button', { name: 'Reload' }).click())
// The worker reports another state change before it takes over.
act(() => container.registration.waiting?.setState('installed'))
expect(screen.queryByRole('status')).toBeNull()
})
it('hides when dismissed until the next update is waiting', async () => {
const container = new FakeContainer()
const { reload } = await renderPrompt(container)
const first = container.registration.install()
act(() => container.registration.finishInstall(first))
act(() => screen.getByRole('button', { name: 'Dismiss' }).click())
expect(screen.queryByRole('status')).toBeNull()
expect(first.postMessage).not.toHaveBeenCalled()
act(() => first.setState('installed'))
expect(screen.queryByRole('status')).toBeNull()
act(() => container.registration.finishInstall(container.registration.install()))
expect(screen.getByRole('status')).toHaveTextContent(copy)
expect(reload).not.toHaveBeenCalled()
})
it('hides when another tab activates the waiting worker', async () => {
const container = new FakeContainer()
container.registration.waiting = new FakeWorker()
const { reload } = await renderPrompt(container)
act(() => container.takeOver())
expect(screen.queryByRole('status')).toBeNull()
expect(reload).not.toHaveBeenCalled()
})
it('removes its listeners on unmount', async () => {
const container = new FakeContainer()
const worker = new FakeWorker()
container.registration.waiting = worker
const { reload, unmount } = await renderPrompt(container)
const removeRegistration = vi.spyOn(container.registration, 'removeEventListener')
const removeWorker = vi.spyOn(worker, 'removeEventListener')
act(() => screen.getByRole('button', { name: 'Reload' }).click())
unmount()
expect(removeRegistration).toHaveBeenCalledWith('updatefound', expect.any(Function))
expect(removeWorker).toHaveBeenCalledWith('statechange', expect.any(Function))
container.takeOver()
expect(reload).not.toHaveBeenCalled()
})
})
+101
View File
@@ -0,0 +1,101 @@
import { useEffect, useRef, useState } from 'react'
import { X } from 'lucide-react'
import { Button } from '../ui/Button.tsx'
import { Card } from '../ui/Card.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import styles from './UpdatePrompt.module.css'
// UpdateContainer is the part of navigator.serviceWorker the prompt uses.
export type UpdateContainer = Pick<ServiceWorkerContainer, 'ready' | 'controller' | 'addEventListener' | 'removeEventListener'>
export type UpdatePromptProps = {
// navigator.serviceWorker, or undefined where the browser has none.
container: UpdateContainer | undefined
// Reloads the page once the new worker controls it.
reload: () => void
}
// SKIP_WAITING is the message src/sw.ts answers by activating the waiting
// worker.
export const SKIP_WAITING = { type: 'SKIP_WAITING' } as const
// UpdatePrompt offers to reload when a new service worker is installed and
// waiting to replace the one controlling the page. Reload asks the waiting
// worker to activate and reloads once it controls the page; dismissing hides
// the prompt until a different worker is waiting.
export function UpdatePrompt({ container, reload }: UpdatePromptProps) {
const [waiting, setWaiting] = useState<ServiceWorker | null>(null)
const [dismissed, setDismissed] = useState<ServiceWorker | null>(null)
const [accepted, setAccepted] = useState<ServiceWorker | null>(null)
const reloadListener = useRef<(() => void) | null>(null)
useEffect(() => {
if (container === undefined) return
const sw = container
let registration: ServiceWorkerRegistration | null = null
let unmounted = false
const watched = new Set<ServiceWorker>()
// A waiting worker only replaces something when the page has a
// controller; without one there is no controllerchange to reload on.
function check() {
if (registration === null) return
setWaiting(sw.controller === null ? null : registration.waiting)
}
function watch(worker: ServiceWorker | null) {
if (worker === null || watched.has(worker)) return
watched.add(worker)
worker.addEventListener('statechange', check)
}
function updateFound() {
watch(registration?.installing ?? null)
}
void sw.ready.then((ready) => {
if (unmounted) return
registration = ready
ready.addEventListener('updatefound', updateFound)
watch(ready.installing)
watch(ready.waiting)
check()
})
return () => {
unmounted = true
registration?.removeEventListener('updatefound', updateFound)
watched.forEach((worker) => worker.removeEventListener('statechange', check))
if (reloadListener.current !== null) {
sw.removeEventListener('controllerchange', reloadListener.current)
reloadListener.current = null
}
}
}, [container])
if (container === undefined || waiting === null || waiting === dismissed || waiting === accepted) return null
function update() {
if (container === undefined || waiting === null || reloadListener.current !== null) return
const listener = () => {
reloadListener.current = null
reload()
}
reloadListener.current = listener
container.addEventListener('controllerchange', listener, { once: true })
setAccepted(waiting)
waiting.postMessage(SKIP_WAITING)
}
return (
<Card role="status" className={styles.toast}>
<p className={styles.message}>
New version ready <span aria-hidden="true">·</span>
</p>
{/* The space keeps the copy "New version ready · Reload" in the text;
a flex container does not render it. */}{' '}
<Button onClick={update}>Reload</Button>
<IconButton label="Dismiss" onClick={() => setDismissed(waiting)}>
<Icon icon={X} />
</IconButton>
</Card>
)
}
+51
View File
@@ -0,0 +1,51 @@
// NetworkWindow is the part of window the network status reads: the browser's
// online flag and its online/offline events.
export type NetworkWindow = Pick<Window, 'navigator' | 'addEventListener' | 'removeEventListener'>
export type NetworkStatus = {
// offline reports whether the browser says it is offline or the last API
// request failed with a network error or was answered from the cache.
offline(): boolean
// subscribe is notified when offline may have changed. It listens for the
// window's online/offline events until the returned function is called.
subscribe(listener: () => void): () => void
// requestFailed records a fetch that rejected with a network error or that
// the service worker answered from its cache.
requestFailed(): void
// requestSucceeded records a fetch the server answered.
requestSucceeded(): void
}
// createNetworkStatus tracks whether MealQuest can reach the server. A failed
// request counts as offline until a later request gets a response or the
// browser reports that it is back online.
export function createNetworkStatus(win: NetworkWindow): NetworkStatus {
let failed = false
const listeners = new Set<() => void>()
function setFailed(value: boolean) {
if (failed === value) return
failed = value
listeners.forEach((listener) => listener())
}
return {
offline: () => !win.navigator.onLine || failed,
subscribe(listener) {
const online = () => {
failed = false
listener()
}
listeners.add(listener)
win.addEventListener('online', online)
win.addEventListener('offline', listener)
return () => {
listeners.delete(listener)
win.removeEventListener('online', online)
win.removeEventListener('offline', listener)
}
},
requestFailed: () => setFailed(true),
requestSucceeded: () => setFailed(false),
}
}
+115
View File
@@ -0,0 +1,115 @@
// 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.
// A network-only request is left to the browser: the worker never answers it,
// so it reaches the network exactly as the page sent it. That matters for
// POST /auth/logout, whose Clear-Site-Data header wipes the worker and its
// caches — Chrome stalls that navigation when the worker answers it.
// 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, NetworkOnly, type Strategy } from 'workbox-strategies'
import { createApiStrategy } from './sw/apiStrategy'
import {
cacheNames,
cacheVersion,
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]: createApiStrategy(),
[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 navigation = new NetworkOnly({
plugins: [new PrecacheFallbackPlugin({ fallbackURL: 'index.html' })],
})
type AnsweredRoute = Exclude<Route, { strategy: 'network-only' }>
function strategyFor(route: AnsweredRoute): Strategy {
switch (route.strategy) {
case 'network-first':
case 'cache-first':
return strategies[route.cacheName]
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)
}
// answered matches every request the worker answers: all but network-only.
function answered(options: { request: Request; url: URL }): boolean {
return routeOf(options).strategy !== 'network-only'
}
function handle({ request, url, event }: RouteHandlerCallbackOptions): Promise<Response> {
const route = routeOf({ request, url })
if (route.strategy === 'network-only') {
throw new Error(`the worker never answers network-only ${request.method} ${url.href}`)
}
return strategyFor(route).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, so only GETs are ever answered.
for (const method of ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'] as const) {
registerRoute(answered, 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)))),
)
})
// The update prompt (src/pwa/UpdatePrompt.tsx) asks a waiting worker to take
// over when the household chooses Reload.
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') void self.skipWaiting()
})
+165
View File
@@ -0,0 +1,165 @@
// 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)
}
}
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),
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
// handle sends a GET through the API strategy, as src/sw.ts does for a
// network-first route.
async function handle(path: string): Promise<Response> {
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,
headers: { 'Content-Type': 'application/json' },
})
}
describe('API strategy', () => {
it('answers from the network while the server is reachable, unmarked', async () => {
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockResolvedValue(recipe()))
const response = await handle('/api/v1/recipes/01J9ZR')
expect(response.headers.has(cacheFallbackHeader)).toBe(false)
expect(await response.json()).toMatchObject({ id: '01J9ZR' })
expect(cache.entries.has(`${origin}/api/v1/recipes/01J9ZR`)).toBe(true)
})
it('opens a previously viewed recipe offline, marked as a cache fallback', async () => {
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockResolvedValue(recipe()))
await handle('/api/v1/recipes/01J9ZR')
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
const response = await handle('/api/v1/recipes/01J9ZR')
expect(response.headers.get(cacheFallbackHeader)).toBe('1')
expect(await response.json()).toEqual({ id: '01J9ZR', name: 'Chicken tikka masala' })
})
it('fails offline for a recipe never viewed', async () => {
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
await expect(handle('/api/v1/recipes/01J9ZS')).rejects.toThrow()
})
})
describe('previously viewed plan and recipe reads', () => {
const plan = { week: '2026-W40', days: [{ date: '2026-10-05', dinner: 'Chicken tikka masala' }] }
const recipe = { id: '01J9ZR', name: 'Chicken tikka masala', steps: ['Marinate', 'Simmer'] }
const server: Record<string, unknown> = { '/api/v1/plans/2026-W40': plan, '/api/v1/recipes/01J9ZR': recipe }
let reachable: boolean
// The page's fetch goes through the worker's API strategy; the strategy's
// own fetch (always a Request) is the network. The API client fetches a
// path string, so the two are told apart by the argument's type. As in a
// browser, a worker that cannot answer is a network error to the page.
beforeEach(() => {
reachable = true
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => reachable)
vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
if (!(input instanceof Request)) {
return answer(new Request(new URL(String(input), origin), init)).catch((error: unknown) => {
throw new TypeError('Failed to fetch', { cause: error })
})
}
if (!reachable) throw new TypeError('Failed to fetch')
const body = server[new URL(input.url).pathname]
if (body === undefined) throw new Error(`unexpected network fetch: ${input.url}`)
return jsonResponse(200, body)
})
})
afterEach(() => {
vi.restoreAllMocks()
})
function client() {
return createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
}
it('serves the plan and recipe fetched online once the network is gone', async () => {
const online = client()
expect(await online.apiFetch('/api/v1/plans/2026-W40')).toEqual(plan)
expect(await online.apiFetch('/api/v1/recipes/01J9ZR')).toEqual(recipe)
expect(online.network.offline()).toBe(false)
reachable = false
const offline = client()
expect(await offline.apiFetch('/api/v1/plans/2026-W40')).toEqual(plan)
expect(await offline.apiFetch('/api/v1/recipes/01J9ZR')).toEqual(recipe)
expect(offline.network.offline()).toBe(true)
})
it('shows the offline indicator when the cached copy answers while the browser reports online', async () => {
await client().apiFetch('/api/v1/plans/2026-W40')
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
reachable = false
const api = client()
expect(await api.apiFetch('/api/v1/plans/2026-W40')).toEqual(plan)
expect(api.network.offline()).toBe(true)
})
it('fails offline for a plan never fetched', async () => {
reachable = false
await expect(client().apiFetch('/api/v1/plans/2026-W41')).rejects.toThrow(TypeError)
})
})
+16
View File
@@ -0,0 +1,16 @@
// The network-first strategy for API reads, kept apart from src/sw.ts so the
// tests can run it without a service worker.
import type { WorkboxPlugin } from 'workbox-core/types'
import { NetworkFirst } from 'workbox-strategies'
import { cacheNames, markCacheFallback, networkTimeoutSeconds } from './routes'
// NetworkFirst reads its cache only when the network fails or times out, so
// every cached response it uses is a fallback.
const markFallback: WorkboxPlugin = {
cachedResponseWillBeUsed: async ({ cachedResponse }) =>
cachedResponse === undefined ? undefined : markCacheFallback(cachedResponse),
}
export function createApiStrategy(): NetworkFirst {
return new NetworkFirst({ cacheName: cacheNames.api, networkTimeoutSeconds, plugins: [markFallback] })
}
+180
View File
@@ -0,0 +1,180 @@
import { describe, expect, it } from 'vitest'
import { cacheFallbackHeader, cacheNames, markCacheFallback, 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/recipes',
'/api/v1/recipes/01J9ZR',
'/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',
'/api/v1/recipes%2F..%2Fsettings%2Fai',
'/api/v1/recipes/..%2F..%2F..%2Fauth%2Flogin',
'/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/01J9ZR')).toEqual(apiCache)
expect(route('/%61pi/v1/ideas', '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/recipes/01J9ZR',
'/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('/api/v1/recipesx')).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/me')).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'])
})
})
describe('markCacheFallback', () => {
it('copies the cached response with the cache fallback header set', async () => {
const cached = new Response('{"id":"01J9ZR"}', {
status: 200,
statusText: 'OK',
headers: { 'Content-Type': 'application/json' },
})
const marked = markCacheFallback(cached)
expect(marked.status).toBe(200)
expect(marked.statusText).toBe('OK')
expect(marked.headers.get('Content-Type')).toBe('application/json')
expect(marked.headers.get(cacheFallbackHeader)).toBe('1')
expect(await marked.json()).toEqual({ id: '01J9ZR' })
})
})
+108
View File
@@ -0,0 +1,108 @@
// 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: the worker leaves the request to the browser.
| { 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
// cacheFallbackHeader marks a network-first response answered from the cache
// because the server could not be reached in time, so the app can show the
// offline indicator even while the browser reports that it is online.
export const cacheFallbackHeader = 'X-MealQuest-Cache-Fallback'
// markCacheFallback copies a cached response with cacheFallbackHeader set.
export function markCacheFallback(cached: Response): Response {
const headers = new Headers(cached.headers)
headers.set(cacheFallbackHeader, '1')
return new Response(cached.body, { status: cached.status, statusText: cached.statusText, headers })
}
const neverCached = ['/auth', '/api/v1/settings', '/api/v1/jobs']
const networkFirst = ['/api/v1/plans', '/api/v1/shopping', '/api/v1/dinners', '/api/v1/recipes', '/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))
}
+45
View File
@@ -0,0 +1,45 @@
// The service worker itself needs worker globals, so these tests read its
// source: they pin the parts routes.ts cannot express — the API cache's
// strategy, the font strategy's expiration, the font route's place ahead of
// the precache route and network-only requests left to the browser.
import { describe, expect, it } from 'vitest'
import source from '../sw.ts?raw'
describe('service worker source', () => {
it('serves the API cache with the strategy that marks cache fallbacks', () => {
expect(source).toContain('[cacheNames.api]: createApiStrategy(),')
})
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)
})
it('leaves network-only requests to the browser instead of answering them', () => {
// Chrome stalls a navigation whose Clear-Site-Data response (POST
// /auth/logout) the worker answered, so the worker must not match it.
expect(source).toMatch(
/function answered\(options: \{ request: Request; url: URL \}\): boolean \{\s*return routeOf\(options\)\.strategy !== 'network-only'/,
)
expect(source).toContain('registerRoute(answered, handle, method)')
expect(source).not.toContain('registerRoute(() => true')
})
it('activates a waiting worker when the update prompt sends SKIP_WAITING', () => {
expect(source).toMatch(
/self\.addEventListener\('message', \(event\) => \{\s*if \(event\.data\?\.type === 'SKIP_WAITING'\) void self\.skipWaiting\(\)/,
)
})
})
+3
View File
@@ -4,4 +4,7 @@ import { afterEach } from 'vitest'
afterEach(() => {
cleanup()
// The verified-session marker and other device state must not leak between
// tests. Tests in the node environment (src/pwa.test.ts) have no storage.
if (typeof localStorage !== 'undefined') localStorage.clear()
})
+3 -1
View File
@@ -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"]
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"lib": ["ES2022", "WebWorker"],
"types": []
},
"include": ["src/sw.ts"],
"exclude": []
}
+21 -1
View File
@@ -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,