Compare commits

..
Author SHA1 Message Date
dev_agent bc83f3cd3a Merge remote-tracking branch 'origin/feature/293a89c5-bd98-4de4-9afd-5ca371e5e679-recipe-detail-and-kitchen-reading-mode' into task/kitchen-wake-lock_c7da10dc-7f27-4dd0-8548-28d6587bdabf
CI / ci (pull_request) Successful in 5m30s
CI / ci (push) Successful in 4m58s
2026-10-09 10:11:15 -04:00
dev_agentandClaude Opus 5.5 94a3de309b Re-request the wake lock the browser lets go or a pending request misses
CI / ci (pull_request) Failing after 4m31s
CI / ci (push) Failing after 4m15s
Keep screen on now listens for each lock's release event: when the
browser lets the lock go while the switch is on, the lock is asked for
again at once if the page is visible (or on the next visibilitychange if
hidden), so the switch never claims a lock that is not held. When a
pending request is granted, the hook checks visibility and the lock
again, so a hide-and-show during the request is no longer lost.

Fixes review findings F-01 and F-02 on PR #151.

Maestro task c7da10dc-7f27-4dd0-8548-28d6587bdabf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:14:25 -04:00
dev_agentandClaude Opus 5.5 7b03d16ec0 Keep screen on via Screen Wake Lock in Kitchen reading mode
CI / ci (push) Successful in 5m45s
CI / ci (pull_request) Successful in 6m4s
The Keep screen on switch now holds a Screen Wake Lock while on: it asks
navigator.wakeLock.request('screen') when turned on, asks again when the
page is visible again while on, and releases the lock when turned off or
when the screen is left, including a lock granted after the screen was
left. A refused request turns the switch back off and is reported with a
Retry. Without the API the switch is disabled with an explanation.

Maestro task c7da10dc-7f27-4dd0-8548-28d6587bdabf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:45:42 -04:00
9 changed files with 98 additions and 565 deletions
+8 -72
View File
@@ -58,16 +58,15 @@ type Ingredient struct {
RawText string `json:"raw_text"`
}
// Source is the site a recipe was fetched from. SiteName is the name the
// page itself gives its site, and null when it gives none or the stored
// name could have come from a link (see pageSiteName), so a client shows
// "Recipe from {site}" only when SiteName is not null. URL is the page's
// link only when it is verifiably free of secrets (see safeSourceURL), and
// null otherwise: a link pasted or offered as a candidate may carry a
// personal token, so a client links the credit only when URL is not null.
// Source is the site a recipe was fetched from. It has no url: the page's
// link, pasted or offered as a candidate, may carry a personal token, and
// no stored link can be shown to be free of one, so like a dinner's
// recipe_url and a candidate's page it never leaves the server. SiteName
// is the name the page itself gives its site, and null when it gives none
// or the stored name could have come from a link (see pageSiteName), so a
// client shows "Recipe from {site}" only when SiteName is not null.
type Source struct {
SiteName *string `json:"site_name"`
URL *string `json:"url"`
}
// Get returns dinner id with its recipe and the reason it was suggested,
@@ -150,7 +149,7 @@ func readRecipe(ctx context.Context, q *db.Queries, recipeID string, d *Detail)
if err != nil {
return err
}
d.Source = &Source{SiteName: site, URL: safeSourceURL(rec.SourceUrl)}
d.Source = &Source{SiteName: site}
}
ings, err := q.ListRecipeIngredients(ctx, recipeID)
if err != nil {
@@ -210,69 +209,6 @@ func pageSiteName(stored sql.NullString, sourceURL string) (*string, error) {
return &name, nil
}
// safeSourceURL is sourceURL when it is verifiably free of secrets — an
// https link to a host, with no user or password, no query (not even a
// bare "?") and no fragment (not even a bare "#") — else nil. Those are
// the places a link carries a token; a link failing any check, or not
// parsing, is withheld rather than trimmed, since a trimmed link is not the
// page the recipe came from. The client (sourceUrl in
// web/src/dinners/detail.ts) parses the link with the browser's URL parser
// and refuses the whole recipe when it fails, so the host and port must
// also be ones it parses: see browserHost.
func safeSourceURL(sourceURL string) *string {
u, err := url.Parse(sourceURL)
if err != nil || u.Scheme != "https" || u.Opaque != "" || u.Host == "" || u.User != nil ||
u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(sourceURL, "#") {
return nil
}
// url.Parse decodes a percent-encoded host; the link must spell its
// host out as it is checked.
authority, _, _ := strings.Cut(sourceURL[len(u.Scheme)+len("://"):], "/")
if authority != u.Host || !browserHost(u) {
return nil
}
if port := u.Port(); port != "" {
if n, err := strconv.ParseUint(port, 10, 16); err != nil || n == 0 {
return nil
}
}
return &sourceURL
}
// browserHost says whether u's host is one the browser's URL parser (the
// WHATWG URL Standard) accepts as it stands, where url.Parse accepts more,
// such as "[bad]". The rule is narrower than the browser's: a bracketed
// host is an IPv6 address without a zone; any other is ASCII letters,
// digits and hyphens in dot-separated labels, none empty but a trailing
// one, none punycode ("xn--"), since the browser refuses invalid punycode
// and checking it is not worth a decoder; and a host whose last label is
// a number, which the browser reads as an IPv4 address, must be a dotted
// quad. A link to any other host is withheld.
func browserHost(u *url.URL) bool {
host := u.Hostname()
if strings.HasPrefix(u.Host, "[") {
ip := net.ParseIP(host)
return ip != nil && strings.Contains(host, ":")
}
labels := strings.Split(strings.TrimSuffix(host, "."), ".")
for _, label := range labels {
if label == "" || len(label) >= 4 && strings.EqualFold(label[:4], "xn--") {
return false
}
for _, c := range label {
if !('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' || c == '-') {
return false
}
}
}
last := labels[len(labels)-1]
if strings.Trim(last, "0123456789") == "" || len(last) >= 2 && strings.EqualFold(last[:2], "0x") {
ip := net.ParseIP(host)
return ip != nil && ip.To4() != nil && !strings.Contains(host, ":")
}
return true
}
// duplicatePosition is the error for two recipe items of kind at position.
// Position is the only record of recipe order, and the id that breaks the
// tie says nothing about it, so a recipe with a duplicate is refused rather
-57
View File
@@ -112,63 +112,6 @@ func TestPageSiteNameWithholdsHosts(t *testing.T) {
}
}
// TestSafeSourceURLOnlyWithoutSecrets: a recipe link is shown only when it
// is https to a host with no user, password, query or fragment, each of
// which can carry a token; any other link, or one that does not parse, is
// withheld whole.
func TestSafeSourceURLOnlyWithoutSecrets(t *testing.T) {
t.Parallel()
for _, link := range []string{
"https://www.example.com/recipes/chicken-curry",
"https://example.com",
"https://example.com:8443/stew/",
"https://[2001:db8::1]/curry",
"https://1.2.3.4/curry",
"https://Kitchen-2.Example.com./curry",
} {
if got := dinners.SafeSourceURL(link); got == nil || *got != link {
t.Errorf("SafeSourceURL(%q) = %v, want the link", link, deref(got))
}
}
for _, link := range []string{
"https://example.com/curry?access_token=query-token",
"https://example.com/curry?",
"https://user:password@example.com/curry",
"https://token@example.com/curry",
"https://@example.com/curry",
"http://example.com/curry",
"https://example.com/curry#token=fragment-token",
"https://example.com/curry#",
"ftp://example.com/curry",
"https:example.com/curry",
"https:///curry",
"https://example.com:99999/curry",
"https://example.com:0/curry",
"//example.com/curry",
"https://secret-token\x7f.example/%zz",
"",
// Hosts url.Parse accepts but the browser's URL parser refuses
// (or reads as another host), which would fail the whole recipe
// screen.
"https://[bad]/curry",
"https://[fe80::1%25en0]/curry",
"https://[1.2.3.4]/curry",
"https://1.2.3.999/curry",
"https://1.2.3.4.5/curry",
"https://999/curry",
"https://example.0x/curry",
"https://xn--a/curry",
"https://ex%61mple.com/curry",
"https://ex%2fample.com/curry",
"https://a..b/curry",
"https://café.example/curry",
} {
if got := dinners.SafeSourceURL(link); got != nil {
t.Errorf("SafeSourceURL(%q) = %q, want nil", link, *got)
}
}
}
func deref(s *string) any {
if s == nil {
return nil
-3
View File
@@ -74,6 +74,3 @@ func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error)
// PageSiteName is pageSiteName.
var PageSiteName = pageSiteName
// SafeSourceURL is safeSourceURL.
var SafeSourceURL = safeSourceURL
+29 -94
View File
@@ -5,7 +5,6 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"reflect"
"strings"
"testing"
@@ -112,7 +111,7 @@ func TestGetDinnerGroundedSideQuest(t *testing.T) {
{Name: str("onion"), Qty: str("1"), RawText: "1 onion"},
},
Steps: []string{"Fry with the garlic.", "Chop the onion."},
Source: &dinners.Source{SiteName: str("Curry House"), URL: str(curryPage)},
Source: &dinners.Source{SiteName: str("Curry House")},
GroundingStatus: "grounded",
WhyPicked: str(reason),
}
@@ -217,113 +216,51 @@ func TestGetDinnerNotFound(t *testing.T) {
}
}
// TestGetDinnerSourceURLOnlyWhenSafe: a recipe grounded in a clean https
// link shows it as source.url; one whose stored link carries a token in its
// query or userinfo, is http, or has a fragment shows its site name but
// source.url null and nothing of the link.
func TestGetDinnerSourceURLOnlyWhenSafe(t *testing.T) {
// TestGetDinnerSourceHidesTokenURL: a recipe grounded in a pasted link that
// carries a token in its path or query shows its site name, but never the
// link or any part of it.
func TestGetDinnerSourceHidesTokenURL(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
const (
clean = "https://kitchen.example/recipes/chicken-curry"
pathToken = "path-token-abc123"
queryToken = "query-token-def456"
pathPage = "https://secret-path.example/u/" + pathToken + "/chicken-curry"
queryPage = "https://secret-query.example/curry?access_token=" + queryToken
)
e.pages.Bodies[clean] = recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40)
e.pages.Bodies[pathPage] = recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40)
e.pages.Bodies[queryPage] = recipestest.RecipePage("Weeknight Curry", "B Kitchen", "https://b.example/curry.jpg", 25)
e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1)
e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2)
ground := func(dish, page string) (id string) {
t.Helper()
id = createDinner(t, e.h, `{"name":"`+dish+`"}`) // runner not started: stays pending
for _, tc := range []struct{ dish, page, site string }{
{"Chicken curry", pathPage, "Curry House"},
{"Weeknight curry", queryPage, "B Kitchen"},
} {
id := createDinner(t, e.h, `{"name":"`+tc.dish+`"}`) // runner not started: stays pending
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+page+`"}`); rec.Code != http.StatusOK {
t.Fatalf("POST ground %s = %d: %s", page, rec.Code, rec.Body)
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+tc.page+`"}`); rec.Code != http.StatusOK {
t.Fatalf("POST ground %s = %d: %s", tc.page, rec.Code, rec.Body)
}
var stored string
if err := e.st.Reader.QueryRow(`SELECT source_url FROM recipes WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, id).Scan(&stored); err != nil {
t.Fatal(err)
}
if stored != page {
t.Fatalf("stored source_url = %q, want %q", stored, page)
if stored != tc.page {
t.Fatalf("stored source_url = %q, want %q", stored, tc.page)
}
return id
}
source := func(what, id string) map[string]any {
t.Helper()
raw, _ := getDetail(t, e.h, id)
src, ok := raw["source"].(map[string]any)
if !ok {
t.Fatalf("source of %s = %v, want an object", what, raw["source"])
}
return src
}
cleanID := ground("Chicken curry", clean)
if src := source("a clean link", cleanID); !reflect.DeepEqual(src, map[string]any{"site_name": "Curry House", "url": clean}) {
t.Errorf("source of a clean link = %v, want site_name Curry House and url %s", src, clean)
}
queryID := ground("Weeknight curry", queryPage)
withheld := func(what, id, site string, secrets ...string) {
t.Helper()
rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "")
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d: %s", what, rec.Code, rec.Body)
t.Fatalf("GET dinner = %d: %s", rec.Code, rec.Body)
}
for _, secret := range secrets {
for _, secret := range []string{tc.page, "secret-path.example", "secret-query.example", pathToken, queryToken, "access_token", `"url"`} {
if strings.Contains(rec.Body.String(), secret) {
t.Errorf("detail of %s contains %q: %s", what, secret, rec.Body)
t.Errorf("detail of %s contains %q: %s", tc.dish, secret, rec.Body)
}
}
if src := source(what, id); !reflect.DeepEqual(src, map[string]any{"site_name": site, "url": nil}) {
t.Errorf("source of %s = %v, want site_name %q and url null", what, src, site)
}
}
withheld("a link with a query token", queryID, "B Kitchen", queryPage, "secret-query.example", queryToken, "access_token")
// The other unsafe shapes, stored as a link grounded elsewhere would
// leave them.
for _, tc := range []struct{ what, link string }{
{"a link with a userinfo token", "https://user-token-ghi789@kitchen.example/recipes/chicken-curry"},
{"a link with a userinfo password", "https://cook:password-jkl012@kitchen.example/recipes/chicken-curry"},
{"an http link", "http://kitchen.example/recipes/chicken-curry"},
{"a link with a fragment", "https://kitchen.example/recipes/chicken-curry#token=fragment-mno345"},
} {
if _, err := e.st.Writer.Exec(`UPDATE recipes SET source_url = ? WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, tc.link, cleanID); err != nil {
t.Fatal(err)
}
withheld(tc.what, cleanID, "Curry House", tc.link, "kitchen.example", "-token-", "password", "fragment")
}
// web/src/dinners/testdata/source_url_cases.json pins source as this
// handler sends it for each stored link; the recipe screen's tests
// render each one, so a link the server sends and the client refuses
// fails one side or the other.
fixture, err := os.ReadFile("../../web/src/dinners/testdata/source_url_cases.json")
if err != nil {
t.Fatal(err)
}
var shared struct {
Cases []struct {
Stored string
Source map[string]any
}
}
if err := json.Unmarshal(fixture, &shared); err != nil {
t.Fatal(err)
}
if len(shared.Cases) == 0 {
t.Fatal("source_url_cases.json has no cases")
}
for _, c := range shared.Cases {
if _, err := e.st.Writer.Exec(`UPDATE recipes SET source_url = ? WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, c.Stored, cleanID); err != nil {
t.Fatal(err)
}
if src := source(c.Stored, cleanID); !reflect.DeepEqual(src, c.Source) {
got, _ := json.Marshal(src)
want, _ := json.Marshal(c.Source)
t.Errorf("source of %s = %s, want %s", c.Stored, got, want)
raw, _ := getDetail(t, e.h, id)
if src, _ := raw["source"].(map[string]any); !reflect.DeepEqual(src, map[string]any{"site_name": tc.site}) {
t.Errorf("source of %s = %v, want only site_name %q", tc.dish, raw["source"], tc.site)
}
}
}
@@ -333,16 +270,14 @@ func TestGetDinnerSourceURLOnlyWhenSafe(t *testing.T) {
// token has site_name null and nothing from the link in its detail; a page
// that names its site keeps that name, even on a token-bearing host; and a
// stored name shaped like another host, as a redirect leaves, is withheld.
// The links carry a query, so source.url is withheld too and nothing of the
// link may appear.
func TestGetDinnerSiteNameNeverFromLink(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
const (
bareToken = "private-token-abc123"
namedToken = "named-token-def456"
bare = "https://" + bareToken + ".example.com/recipe?print=1"
named = "https://www." + namedToken + ".example.com/recipe?print=1"
bare = "https://" + bareToken + ".example.com/recipe"
named = "https://www." + namedToken + ".example.com/recipe"
)
e.pages.Bodies[bare] = recipestest.RecipePage("Chicken Curry", "", "https://a.example/curry.jpg", 40)
e.pages.Bodies[named] = recipestest.RecipePage("Weeknight Curry", "Curry House", "https://b.example/curry.jpg", 25)
@@ -380,8 +315,8 @@ func TestGetDinnerSiteNameNeverFromLink(t *testing.T) {
}
}
raw, _ := getDetail(t, e.h, id)
if src, _ := raw["source"].(map[string]any); !reflect.DeepEqual(src, map[string]any{"site_name": want, "url": nil}) {
t.Errorf("source of %s = %v, want site_name %v and url null", what, raw["source"], want)
if src, _ := raw["source"].(map[string]any); !reflect.DeepEqual(src, map[string]any{"site_name": want}) {
t.Errorf("source of %s = %v, want only site_name %v", what, raw["source"], want)
}
}
@@ -389,13 +324,13 @@ func TestGetDinnerSiteNameNeverFromLink(t *testing.T) {
if got := storedSite(bareRecipe); got != bareToken+".example.com" {
t.Fatalf("stored site_name = %q, want the link's host", got)
}
check("a page naming no site", bareID, []string{bare, bareToken, "example.com"}, nil)
check("a page naming no site", bareID, []string{bare, bareToken, "example.com", `"url"`}, nil)
namedID, namedRecipe := ground("Weeknight curry", named)
if got := storedSite(namedRecipe); got != "Curry House" {
t.Fatalf("stored site_name = %q, want the page's", got)
}
check("a page naming its site", namedID, []string{named, namedToken, "example.com"}, "Curry House")
check("a page naming its site", namedID, []string{named, namedToken, "example.com", `"url"`}, "Curry House")
const redirectHost = "redirect-token-ghi789.example.net"
if _, err := e.st.Writer.Exec(`UPDATE recipes SET site_name = ? WHERE id = ?`, redirectHost, namedRecipe); err != nil {
@@ -1,184 +0,0 @@
// A dinner's detail offline once visited: the page reads through the service
// worker's real API strategy (src/test/serviceWorker.ts), so what it shows
// offline is what the worker saved while it was online. jsdom has no service
// worker, so the strategy runs directly against an in-memory cache.
import { render, screen, within } from '@testing-library/react'
import { createMemoryRouter, RouterProvider } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { OfflinePill } from '../pwa/OfflinePill.tsx'
import { AppShell } from '../shell/AppShell.tsx'
import { cacheNames, routeFor } from '../sw/routes.ts'
import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { errorResponse, jsonResponse } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { origin, stubFetchThroughWorker, stubWorkerCaches, type FakeCache } from '../test/serviceWorker.ts'
import { DinnerDetailPage } from './DinnerDetailPage.tsx'
// grounded is a dinner as GET /api/v1/dinners/{id} sends it, its recipe found.
function grounded(id: string, name: string) {
return {
id,
name,
status: 'quest_log',
origin: 'suggested',
method: 'stovetop',
minutes_total: 30,
difficulty: 'easy',
cuisine: 'korean',
is_favorite: false,
photo_urls: { '400': `/media/photos/${id}-400.jpg`, '1200': `/media/photos/${id}-1200.jpg` },
ingredients: [
{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' },
{ name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' },
],
steps: ['Cook the rice.', 'Brown the beef with the sauce.'],
source: { site_name: 'Serious Eats' },
grounding_status: 'grounded',
grounding_error: null,
why_picked: null,
}
}
// server is what the server sends while it can be reached. Kitchen reading
// mode also reads the household settings, which the worker never saves, and
// the week's plans to find the dinner's day, of which there are none here.
const server: Record<string, unknown> = {
'/api/v1/settings/household': HOUSEHOLD_SETTINGS,
'/api/v1/dinners/d1': grounded('d1', 'Korean beef bowls'),
'/api/v1/dinners/d2': grounded('d2', 'Chicken tikka masala'),
'/api/v1/dinners/d3': {
...grounded('d3', 'Pork carnitas'),
photo_urls: null,
ingredients: [],
steps: [],
source: null,
grounding_status: 'pending',
},
}
let cache: FakeCache
let reachable: boolean
// networkReads is every path the network was asked for, reached or not.
let networkReads: string[]
beforeEach(() => {
cache = stubWorkerCaches()
reachable = true
networkReads = []
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => reachable)
stubFetchThroughWorker(async (request) => {
const path = new URL(request.url).pathname
networkReads.push(path)
if (!reachable) throw new TypeError('Failed to fetch')
if (path.startsWith('/api/v1/plans/')) return errorResponse(404, 'not_found')
if (!Object.hasOwn(server, path)) throw new Error(`unexpected network fetch: ${request.url}`)
return jsonResponse(200, server[path])
})
})
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
// open loads /dinner/{id} as a fresh page load would: a new API client,
// network status and query cache, with the offline indicator App shows.
function open(id: string) {
const router = createMemoryRouter(
[{ element: <AppShell />, children: [{ path: '/dinner/:id', element: <DinnerDetailPage /> }] }],
{ initialEntries: [`/dinner/${id}`] },
)
return render(
<ApiProviders>
<RouterProvider router={router} />
<OfflinePill />
</ApiProviders>,
)
}
// visit opens a dinner while online and leaves once its title shows.
async function visit(id: string, name: string) {
const page = open(id)
expect(await screen.findByRole('heading', { level: 1, name })).toBeInTheDocument()
expect(screen.queryByText('Offline — showing saved data')).not.toBeInTheDocument()
page.unmount()
}
describe('dinner detail offline', () => {
it('saves a visited dinner in the API cache the worker reads offline', async () => {
mockViewport(390)
expect(routeFor({ method: 'GET', url: new URL('/api/v1/dinners/d1', origin), mode: 'cors' }, origin)).toEqual({
strategy: 'network-first',
cacheName: cacheNames.api,
})
await visit('d1', 'Korean beef bowls')
expect([...cache.entries.keys()]).toEqual([`${origin}/api/v1/dinners/d1`])
})
it('shows a visited dinner offline from its saved copy: photo, ingredients and steps, with the offline indicator', async () => {
mockViewport(390)
await visit('d1', 'Korean beef bowls')
reachable = false
open('d1')
expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveAttribute('src', '/media/photos/d1-400.jpg')
const [ingredients, steps] = screen.getAllByRole('list')
expect(within(ingredients!).getAllByRole('listitem').map((row) => row.textContent)).toEqual([
'Ground beef1 lb',
'Jasmine rice2 cups',
])
expect(within(steps!).getAllByRole('listitem').map((row) => row.textContent)).toEqual([
'1Cook the rice.',
'2Brown the beef with the sauce.',
])
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.queryByLabelText('Dinner loading')).not.toBeInTheDocument()
})
it('shows a visited dinner offline in Kitchen reading mode on a tablet', async () => {
mockViewport(1180)
await visit('d1', 'Korean beef bowls')
reachable = false
open('d1')
expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument()
expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument()
expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveAttribute('src', '/media/photos/d1-400.jpg')
expect(within(screen.getByRole('region', { name: 'Ingredients' })).getAllByRole('checkbox')).toHaveLength(2)
expect(screen.getByRole('listitem', { current: 'step' })).toHaveTextContent('Cook the rice.')
})
it('shows an error, not a blank page or placeholder, for a dinner never visited', async () => {
mockViewport(390)
await visit('d1', 'Korean beef bowls')
reachable = false
open('d2')
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load this dinner.")
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
expect(screen.queryByRole('heading', { level: 1 })).not.toBeInTheDocument()
expect(screen.queryByText(/Korean beef bowls|Chicken tikka masala/)).not.toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument()
expect(screen.queryByLabelText('Dinner loading')).not.toBeInTheDocument()
expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument()
})
it('keeps a saved copy still finding its recipe as that, not a finished recipe', async () => {
mockViewport(390)
await visit('d3', 'Pork carnitas')
reachable = false
open('d3')
expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument()
expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument()
// Read once online and tried once offline before the saved copy answered.
expect(networkReads.filter((path) => path.startsWith('/api/v1/dinners/'))).toEqual([
'/api/v1/dinners/d3',
'/api/v1/dinners/d3',
])
})
})
-18
View File
@@ -9,7 +9,6 @@ import { errorResponse, jsonResponse, stubFetchByPath } from '../test/fetchStub.
import { mockViewport } from '../test/matchMedia.ts'
import { GROUNDING_POLL_MS } from './api.ts'
import { DinnerDetailPage, REGULAR_DINNERS } from './DinnerDetailPage.tsx'
import sourceCases from './testdata/source_url_cases.json'
const DINNER = '/api/v1/dinners/d1'
@@ -187,23 +186,6 @@ describe('DinnerDetailPage', () => {
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
})
// Each source exactly as the Go handler sends it for a stored link
// (internal/server's TestGetDinnerSourceURLOnlyWhenSafe checks the same
// file), so every url the server sends must parse here.
it.each(sourceCases.cases)('renders the source the server sends for $stored', async ({ source }) => {
serve(detail({ source }))
renderAt(['/dinner/d1'])
const credit = await screen.findByText('Recipe from Curry House')
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
if (source.url === null) {
expect(credit.closest('a')).toBeNull()
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
} else {
expect(screen.getByRole('link', { name: 'Recipe from Curry House' })).toHaveAttribute('href', new URL(source.url).href)
}
})
it('leaves the credit out when the server names no site', async () => {
serve(detail({ source: { site_name: null } }))
renderAt(['/dinner/d1'])
-52
View File
@@ -1,52 +0,0 @@
{
"cases": [
{
"stored": "https://kitchen.example/recipes/chicken-curry",
"source": { "site_name": "Curry House", "url": "https://kitchen.example/recipes/chicken-curry" }
},
{
"stored": "https://Kitchen-2.Example.com.:8443/recipes/chicken-curry",
"source": { "site_name": "Curry House", "url": "https://Kitchen-2.Example.com.:8443/recipes/chicken-curry" }
},
{
"stored": "https://[2001:db8::1]/recipes/chicken-curry",
"source": { "site_name": "Curry House", "url": "https://[2001:db8::1]/recipes/chicken-curry" }
},
{
"stored": "https://192.0.2.7/recipes/chicken-curry",
"source": { "site_name": "Curry House", "url": "https://192.0.2.7/recipes/chicken-curry" }
},
{
"stored": "https://[bad]/recipes/chicken-curry",
"source": { "site_name": "Curry House", "url": null }
},
{
"stored": "https://[fe80::1%25en0]/recipes/chicken-curry",
"source": { "site_name": "Curry House", "url": null }
},
{
"stored": "https://192.0.2.999/recipes/chicken-curry",
"source": { "site_name": "Curry House", "url": null }
},
{
"stored": "https://xn--a.example/recipes/chicken-curry",
"source": { "site_name": "Curry House", "url": null }
},
{
"stored": "https://kitchen.example/recipes/chicken-curry?access_token=query-token",
"source": { "site_name": "Curry House", "url": null }
},
{
"stored": "https://user-token@kitchen.example/recipes/chicken-curry",
"source": { "site_name": "Curry House", "url": null }
},
{
"stored": "http://kitchen.example/recipes/chicken-curry",
"source": { "site_name": "Curry House", "url": null }
},
{
"stored": "https://kitchen.example/recipes/chicken-curry#token=fragment-token",
"source": { "site_name": "Curry House", "url": null }
}
]
}
+61 -6
View File
@@ -1,16 +1,51 @@
// 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 { answer, origin, stubFetchThroughWorker, stubWorkerCaches, type FakeCache } from '../test/serviceWorker.ts'
import { cacheFallbackHeader } from './routes'
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 = stubWorkerCaches()
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(() => {
@@ -23,6 +58,17 @@ 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,
@@ -61,13 +107,22 @@ describe('previously viewed plan and recipe reads', () => {
const server: Record<string, unknown> = { '/api/v1/plans/2026-W40': plan, '/api/v1/recipes/01J9ZR': recipe }
let reachable: boolean
// The page's fetch goes through the worker's API strategy; the strategy's
// own fetch (always a Request) is the network. The API client fetches a
// path string, so the two are told apart by the argument's type. As in a
// browser, a worker that cannot answer is a network error to the page.
beforeEach(() => {
reachable = true
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => reachable)
stubFetchThroughWorker(async (request) => {
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(request.url).pathname]
if (body === undefined) throw new Error(`unexpected network fetch: ${request.url}`)
const body = server[new URL(input.url).pathname]
if (body === undefined) throw new Error(`unexpected network fetch: ${input.url}`)
return jsonResponse(200, body)
})
})
-79
View File
@@ -1,79 +0,0 @@
// Runs requests through the service worker's real API strategy against an
// in-memory cache storage: jsdom has neither a service worker nor caches.
import { expect, vi } from 'vitest'
import type { RouteHandlerCallbackOptions } from 'workbox-core/types'
import { createApiStrategy } from '../sw/apiStrategy.ts'
import { cacheNames, routeFor } from '../sw/routes.ts'
export const origin = 'https://mealquest.test'
// FakeCache keys entries by URL, which is all the API cache needs.
export class FakeCache {
readonly entries = new Map<string, Response>()
async match(request: RequestInfo | URL): Promise<Response | undefined> {
return this.entries.get(new Request(request).url)?.clone()
}
async put(request: RequestInfo | URL, response: Response): Promise<void> {
this.entries.set(new Request(request).url, response)
}
}
// FakeExtendableEvent stands in for the worker-only ExtendableEvent,
// collecting the promises the strategy waits on.
class FakeExtendableEvent extends Event {
readonly pending: Promise<unknown>[] = []
waitUntil(promise: Promise<unknown>) {
this.pending.push(promise)
}
}
// stubWorkerCaches stubs the worker globals Workbox checks and a cache
// storage holding only the API cache, which it returns. Undo with
// vi.unstubAllGlobals.
export function stubWorkerCaches(): FakeCache {
const cache = new FakeCache()
vi.stubGlobal('FetchEvent', class FetchEvent {})
vi.stubGlobal('ExtendableEvent', FakeExtendableEvent)
const only = (name: string | undefined) => {
if (name !== cacheNames.api) throw new Error(`unexpected cache ${name}`)
return cache
}
vi.stubGlobal('caches', {
open: async (name: string) => only(name),
match: async (request: RequestInfo | URL, options?: MultiCacheQueryOptions) =>
only(options?.cacheName).match(request),
})
return cache
}
// answer sends a GET through the API strategy, as src/sw.ts does for a
// network-first route; the strategy's own fetch is the network.
export async function answer(request: Request): Promise<Response> {
expect(routeFor({ method: request.method, url: new URL(request.url), mode: 'cors' }, origin)).toEqual({
strategy: 'network-first',
cacheName: cacheNames.api,
})
const event = new FakeExtendableEvent('fetch')
const response = await createApiStrategy().handle({ request, event: event as unknown as RouteHandlerCallbackOptions['event'] })
await Promise.all(event.pending)
return response
}
// stubFetchThroughWorker replaces the global fetch with the page's view of
// it behind the worker, routed as src/sw.ts routes it: a network-first path
// from the API client goes through the API strategy, a network-only one
// straight to network; the strategy's own fetch, a Request, is network too.
// As in a browser, a worker that cannot answer is a network error to the
// page. Undo with vi.unstubAllGlobals.
export function stubFetchThroughWorker(network: (request: Request) => Promise<Response>) {
vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
if (input instanceof Request) return network(input)
const request = new Request(new URL(String(input), origin), init)
const route = routeFor({ method: request.method, url: new URL(request.url), mode: 'cors' }, origin)
if (route.strategy === 'network-only') return network(request)
if (route.strategy !== 'network-first') throw new Error(`unexpected ${route.strategy} fetch: ${request.url}`)
return answer(request).catch((error: unknown) => {
throw new TypeError('Failed to fetch', { cause: error })
})
})
}