How to E2E test the MealLoop production app (mealloop.zalize.com) — DNS workaround, magic-code login via Mail.tm, KV fallback for codes, share-link sync testing.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
A direct command skips the review prompt. Inspect the source before running it.
How to E2E test the MealLoop production app (mealloop.zalize.com) — DNS workaround, magic-code login via Mail.tm, KV fallback for codes, share-link sync testing.
Testing MealLoop (mealloop.zalize.com)
Reaching production
The box's DNS resolver may not have the record. Add 104.21.0.1 mealloop.zalize.com to /etc/hosts (works for both curl and Chrome). Alternatively curl --resolve mealloop.zalize.com:443:104.21.0.1 ....
Local dev alternative: cd /home/ubuntu/repos/mealloop && npx wrangler dev (remote D1/KV bindings).
Login (email magic code)
Create a throwaway mailbox via Mail.tm API: GET https://api.mail.tm/domains, POST /accounts {address,password}, POST /token, then GET /messages with Bearer token. Resend emails from mealloop@zalize.com usually arrive within ~10s.
Fallback if email doesn't arrive: read the code from KV:
CLOUDFLARE_API_TOKEN=$CLOUDFLARE_GLOBAL_API_TOKEN CLOUDFLARE_ACCOUNT_ID=ddff52d24ee44e21a021c15eaffcc86d npx wrangler kv key get "code:<email>" --namespace-id a02f5b9e979e4f9fb8dbe95a0cd4f983 --remote
Rate limits: max 3 code sends per email per 10 min; 5 wrong verify attempts invalidate the code. Once send-limited, /login won't render the code form again — use one fresh Mail.tm mailbox per limit you're testing.
Key flows / gotchas
Recipe import: Allrecipes/Dotdash Meredith block ALL Cloudflare-egress fetches, including Browser Rendering (they serve a ~651-byte empty document). Expect the friendly-error/manual-entry fallback there; test import with BBC Good Food URLs (e.g. https://www.bbcgoodfood.com/recipes/classic-lasagne-0) instead.
Import error banners distinguish the failing stage: "blocks automated access" = HTTP 403/429 on fetch; "no recipe data was found" = page fetched/rendered but no schema.org JSON-LD (likely a challenge page).
Debugging imports: run CLOUDFLARE_API_TOKEN=$CLOUDFLARE_GLOBAL_API_TOKEN CLOUDFLARE_ACCOUNT_ID=ddff52d24ee44e21a021c15eaffcc86d npx wrangler tail mealloop --format pretty while triggering; the browserExtract <url>: html=N title=... log line shows what the headless render received. POST→redirect timing is a quick proxy for whether a real render happened.
Grocery categories are stored at insert time — categorizer changes only affect newly added items.
Share sync test: get link from /app/share, open /s/ in incognito, check an item there, and watch the logged-in /app/list tab — it polls /app/list/version every 5s and reloads on version change.
List check-off is optimistic (inline JS fetch POST to .../toggle with X-Requested-With: fetch).
Household/share token is auto-created on first /app visit.
Quantity merging keys on ingredient name+unit — to test merge math exactly, plan on an empty future week (/app?week=YYYY-MM-DD) so previously planned recipes don't add into the totals.
"Copy last week's plan" renders only when the viewed week has zero entries.
All client JS is in static /app.js (data-copy buttons, form[data-confirm] dialogs, .toggle-form check-off, 5s version poll) — after CSP changes check the browser console for violations.
Tags are normalized to slugs (lowercase, spaces→dashes, max 10); tag filter is /app/recipes?tag=<slug>; favorites sort first via ORDER BY favorite DESC.
Confirm-dialog cancel tests should verify state via a reload (token/recipe unchanged).
Staples (/app/staples) are appended by name (case-insensitive) on every "Add week's ingredients" click; menu forms on /app render conditionally — Save input only on weeks WITH entries, Apply select only on EMPTY weeks (Delete select whenever any menu exists).
The nav "Planner" link always lands on the CURRENT week — use /app?week=YYYY-MM-DD explicitly to avoid polluting the grocery list with the current week's ingredients.
Category selects on /app/list use data-autosubmit + a __custom prompt in /app.js — Cancel must revert the select with no reload; the share page has no category selects.
Snacks toggle is per-household (households.snacks) and hides-but-keeps snack plan_entries when turned off.
Grocery scaling multiplies ingredient quantities before merge and uses MAX(scale) per recipe per week; existing-item dedupe is by exact lowercase label, so scaled lines coexist with unscaled ones (e.g. "3 cups flour" + "4 cups flour").
For scaling/pluralization tests, always include one imported recipe (descriptive multi-word ingredient names, ranges like "2-3") — clean manual test names hide formatIngredient bugs. Range quantities ("2-3 sprigs") are deliberately never parsed/scaled/merged (parseIngredient returns qty=null) — expect them verbatim at any scale. Pluralization only applies to names of ≤2 words. Old garbled/scaled lines persist via exact-label dedupe, so assert on exact new-line text, not section contents.
Since Round 3, grocery dedupe is by normalized ingredientKey (name+unit, plural/qty-insensitive): re-adding after a scale change UPDATES the existing unchecked item's label in place (checked items are never relabeled) and ?added=N counts only true inserts — old exact-label-dedupe expectations no longer apply. Share page accepts /s/<token>?week=YYYY-MM-DD with Prev/This/Next week links; invalid week values fall back to the current week.
Console-clean checks via the JS hook miss CSP violation reports — open the devtools Console panel once per round. (Cloudflare zone RUM auto-injection was disabled in Round 5, so the beacon.min.js CSP error should be gone.) To verify Copy-list clipboard content, paste into the manual-recipe Ingredients textarea and delete without saving; incognito pages can't programmatically read the clipboard back.
R156: the ai=err "Try again" form carries hidden retry=1 → a second failure redirects to ?ai=err&retried=1 with "We retried and the AI service is still unavailable…" and a "Try once more" button. When the AI relay is hard-down, failures return near-instantly (<1.5 s) — capture the overlay screenshot in the same action batch as the retry click (no wait). /ops/cleanup-qa (Bearer ADMIN_STATS_KEY) bulk-deletes QA accounts matching delivered+qa%@resend.dev / qa+%@example.com — do not call unless asked; Mail.tm addresses don't match, so still delete disposables via the in-app flow.
At 375px, compare scrollWidth to viewport (not just eyeball) — nowrap buttons in a non-wrapping flex container overflow silently. This test browser profile injects a local Content-Security-Policy-Report-Only ("[Report Only] Refused to load … app.js") — verify with curl -sI that the server sends no Report-Only header before attributing it to the app.
For 375px viewport measurements, type into the visible DevTools Console of the tab under test (the browser_console tool attaches to the most recent CDP target, often another window); assert document.documentElement.scrollWidth <= clientWidth and find culprits by filtering querySelectorAll('*') on scrollWidth.
Since Round 8, plan-generated items carry sources (recipe titles per normalized ingredientKey, sorted alphabetically since Round 9) rendered as a nested "for ..." subtext span inside the label span — Copy list strips it via cloneNode, so clipboard assertions should expect labels only. Re-adding updates sources in place on unchecked items; staples/manual adds have empty sources. Test Soup + Test Stew share all three ingredient keys (onions/potatoes/flour); add Test Onion Salad for a single-recipe case.
Since Round 10, "Add week's ingredients" also CLEARS sources on any existing UNCHECKED item whose ingredientKey is absent from the current run's generated set (stale attribution cleanup after unplanning a recipe); checked items keep stale sources until unchecked + re-added. Labels of shared items also shrink back to the remaining recipes' merged quantities.
Since Round 11, households have a display-only units preference ("Units: …" select in the /app/list action row, data-autosubmit → POST /app/settings/units). convertUnits() applies on list labels, recipe detail, and share pages (which follow the household setting, no viewer toggle, and auto-update via the sync poll after bumpVersion). Imperial: g→oz (lb when ≥454g), ml→fl oz; metric: oz→g, lb→g/kg; cups/tbsp/counts pass through. Since Round 13, composite "N x amount" labels ARE converted (inner amount only, prefix/trailing text kept: "2 x 400g cans …" → "2 x 14.11 oz cans …"). Stored labels never mutate — switching back to "as written" must restore originals exactly.
Since Round 9, non-empty searches on /app/recipes log into remote D1 search_terms(day, term, count) (term lowercased/trimmed, max 60 chars, no user/household columns, inserted via waitUntil ON CONFLICT increment). Verify with: CLOUDFLARE_ACCOUNT_ID=ddff52d24ee44e21a021c15eaffcc86d CLOUDFLARE_API_TOKEN=$CLOUDFLARE_GLOBAL_API_TOKEN npx wrangler d1 execute mealloop-db --remote --command "SELECT * FROM search_terms". Snapshot the table BEFORE searching to assert exact deltas; rows use the production server date, which may differ from expectations.
Since Round 14, recipe detail's "Add to your week plan" links to /app?recipe=, which shows a green preselect banner and preselects that recipe in every day/meal "+ add" select; unknown ids silently fall back to the normal planner.
Since Round 15, / has a 6-question FAQ (<details> accordions) after the email-signup block plus an inline FAQPage JSON-LD script. Inline type=application/ld+json is a non-executable data block, so it does NOT violate the strict script-src 'self' CSP — verify via an empty Console on fresh load; validate the JSON-LD from public page source with curl + json.loads.
Since Round 16, /app shows a "Fill dinners from recipe box" button (next to "Copy last week's plan") only when the shown week has zero entries and ≥1 recipe exists; POST /app/plan/fill-week inserts one dinner/day Mon–Sun (scale 1, cycling if <7 recipes). To prove the rotation branch, plan 2–3 recipes in the week immediately before an empty target week, then fill — exclusion is deterministic (with a broken filter every recipe would appear at least once across 7 days); remaining fresh recipes must be ≥4 or the handler falls back to the full pool. Cleanup: × each entry restores the empty week and the button.
Since Round 17, each /app/list row has a ✎ details toggle (stone-300; amber-600 when a note exists) opening an absolute right-aligned w-64 popup (POST /app/list/note, max 140 chars; empty save clears). Notes render as an amber "✎ " sub-span inside the label span — the share page shows them read-only via the same renderer and syncs via polling; deleting an item deletes its note. Copy list strips ALL nested sub-spans (sources + notes), so clipboard assertions expect plain labels. "Clear checked" (like menu delete / recipe delete / share rotate) uses data-confirm native confirm() — test both Cancel and OK paths. When testing the popup at 375px, measure scrollWidth while the popup is open.
Since Round 18, recipe detail (both /app/recipes/:id and /s//r/:id — same recipeBody) shows a "Cook mode" button next to the Steps heading only when the recipe has steps. Toggling adds .cook-mode to the article (steps 1.25rem, ingredients 1.05rem) and makes step
clicks toggle .done (dim + strikethrough) — client-only state, inert outside cook mode. Wake Lock failures are silently caught, so the console-clean check is the intended verification. Quick stepless fixture: "Or add a recipe manually" with steps blank; delete via the recipe-page data-confirm afterwards.
For content/pSEO rounds, guides live in src/guides.js (slug/title/excerpt/body) and are auto-included in /guides and sitemap.xml — verify sitemap loc count and canonical/og:url/description via curl on the public pages (no cookies needed); canonical/og meta come from page() in src/layout.js keyed on the path arg. As of Round 19 the sitemap has 16 locs.
Since Round 20, the anonymous share page /s/ renders the same Add item form as /app/list (listBody with editable:false, canAdd:true, suggestions=COMMON_ITEMS only — no staples), POSTing to /s//add (≤200 chars, categorize(), bumpVersion, silent no-op at ≥500 household items). Share page remains otherwise read-only (no Clear checked/Units/✎/category controls). The add form action is ${base}/add on both pages. To prove poll sync, keep /app/list open before the anonymous add and assert the row appears without reload.
Since Round 21, /app/list rows have a store select (Any store / / New store… via the same __custom prompt as categories, POST /app/list/store); when ≥1 store exists, pill tabs (All stores + per store, ?store=<name>) render above the Add form on both /app/list and the share page (share rows stay control-free). Filtered tabs show assigned + unassigned items; invalid ?store falls back to All. Store/category POSTs carry a hidden back input and redirect back to the active ?store= filter; the 375px row with three controls measures exactly 375/375 (store select max-w-20, category max-w-24, form pr-1).
Since Round 22, /app/list shows an "Edit stores…" <details> toggle at the end of the store pill row (only when ≥1 store exists, never on the share page). Its popup lists each store as a data-confirm form POSTing to /app/stores/delete, which drops the name from households.stores AND resets assigned shopping_items.store='' (assert the item's select reads "Any store" afterward, not just tab disappearance). Removing the last store hides the whole tab row on both /app/list and /s/. Production's household should normally have ZERO stores — create/remove stores within a round and always delete them via this UI at the end. The popup is anchored right-0 w-56 (as of Round 22b; left-0 overflowed to 425px) and measures exactly 375/375 with the popup open at 375px — assert that, not just tab behavior.
Since Round 23, each planner entry row has a Move… select (aria-label 'Move to another day', data-autosubmit, POST /app/plan/move with hidden week=days[0]; regex-validated date; redirect keeps ?week=). Test moves on an empty future week (e.g. 2026-12-14) so week preservation is deterministic. As of Round 23c the entry row is flex flex-wrap with label min-w-[4rem] break-words and controls shrink-0 — on narrow xl 7-col cards the Move…/✕ controls wrap to a second line under the label; assert labels read horizontally (not one char per line) and verify ✕ clickability with elementFromPoint at its center, not just visually.
Since Round 24 all email inputs have autocomplete='email', the /login code input 'one-time-code', and both add-item/staple inputs 'off' — the DevTools Issues-panel "element doesn't have an autocomplete attribute" hint should stay absent; check the Issues tab (not just Console) each round since new form fields can reintroduce it. For login regressions, one fresh Mail.tm mailbox per round keeps the main household untouched (each new email creates a new empty household — expected residue).
Since Round 25, recipe (not note) planner entries' Move… select has a final '+ Leftovers next day' option (value __leftovers) which INSERTs a note 'Leftovers: ' next day/same meal/scale 1 via POST /app/plan/move, original entry unchanged; Sunday leftovers land on next week's Monday (check via Next →). As of Round 25b every user-facing input carries an explicit autocomplete attribute (email/one-time-code/off) — when asserting 'Issues panel clean', do it on a week WITH entries so the Save-menu form renders, since state-dependent forms are the ones that reintroduce the hint; expand the issue's 'Violating node' link to identify the element instead of guessing.
Since Round 26, recipe detail (owner only) has an 'Edit recipe' link → GET/POST /app/recipes/:id/edit (title required maxlength 200 autocomplete=off; ingredients/steps newline textareas, rows 6–20; POST trims/filters blank lines, bumps sync version; empty title redirects back to /edit). Share view /s/<token>/r/:id renders recipeBody(r, false) — assert zero edit links AND zero delete forms in the DOM. Unit conversion is display-only (stored '500 g flour' shows as '1.1 lb flour' in Imperial), so edits persist raw text. Manual recipe creation lives on /app/recipes under 'Or add a recipe manually' — good for deterministic fixtures with bad-data ingredients like '2 olive oil'.
Since Round 27 every grocery-list mutation form (add, toggle non-JS fallback, note, store, category) carries a hidden back input computed once in listBody (base + qs(storeFilter), with extraQuery week=<date> on the share page), and all POST handlers validate it with startsWith('/app/list') or startsWith('/s/') before redirecting — assert filter/week preservation after each mutation AND the plain-page fallback with a tampered back value. Store tab links on the share page also carry the week param. Tooling note: the browser_console tool attaches only to the main (non-incognito) window — for incognito assertions use visible URL/screenshot checks, or run DOM checks from a main-window tab when the action is cookie-independent (e.g. anonymous /s/:token POSTs).
Since Round 28, checked list items render in one bottom 'Checked off (N)' section (stone-50, print:hidden, same row markup so store/note/category controls stay functional there); category sections render only unchecked items, so a fully-checked category's header disappears — a custom category with a single fixture item is the cleanest way to assert that. The JS toggle styles in place and the row moves sections on the next version-poll (~5s, often faster). To verify Copy list contents, don't use navigator.clipboard.readText() via CDP (fails with 'Document is not focused') — read the clipboard with xclip -o -selection clipboard in the shell. Careful clicking near list rows after a poll re-render: rows shift position and a stale-coordinate click can check the wrong (pre-existing) item — reload and verify state before Clear checked, which permanently deletes all checked items.
Since Round 29, owner recipe detail (only when the recipe has ingredients) has an 'Add ingredients to list' button → POST /app/recipes/:id/to-list: dedupes by ingredientKey, INSERTs new keys with sources=recipe title, unions the title into sources of existing UNCHECKED matches (alphabetical), leaves checked items untouched, redirects /app/list?added=N&src=recipe ('Added N new item(s) from that recipe.' / 'Everything from that recipe is already on the list.'). To test the union without touching real data, pre-add your own list item with the exact overlap label. Pitfall: running the WEEKLY 'Add week's ingredients' route clears sources on any unchecked item not in that week's merged set — expect pre-existing 'for ' sub-lines to vanish if you run it as a regression. Tooling: browser_console can attach to a stale/background tab (check location.href first); fall back to typing in the visible DevTools console of the right tab.
Since Round 30 the planner has a data-print Print button (first control in the week-nav row); print CSS (src/input.css @media print) forces .planner-grid to 4 columns with break-inside avoid per day card, and the nav row, action-button rows, Save/Apply/Delete menu row, '+ add' details, preselect banner, and onboarding card are all print:hidden (Move…/✕ already were). Assert print behavior in Chrome's actual print preview (Save as PDF destination), not just DOM classes, and zoom the preview to confirm entries are readable and controls absent. The onboarding card only renders for zero-recipe households — treat its print-hiding as source-verified unless using a throwaway account. Deploy-propagation pitfall: within ~60s of a deploy, alternating responses can serve old/new markup (e.g. a new button vanishing after a POST redirect) — reload before concluding a regression.
Since Round 31 /app/share is 'Share & account' with a bottom Account card (Signed in as , data-confirm form → POST /app/account/delete). Sole-member deletion batch-removes menu_entries/menus/staples/plan_entries/shopping_items/recipes/household_members/households + email_intents + users, kills the KV session and redirects to /. To test destructively: fresh Mail.tm mailbox in an incognito window (log out any stale incognito session first — incognito windows share cookies until all are closed); prove deletion via old share-token 404, /app→/login, and same-email re-signup showing a NEW share token and 0 recipes; delete the recreated account too for zero residue. Never confirm the dialog on the main QA account. Mail.tm tokens persist — saved mailbox creds (e.g. /tmp/mailtm.env pattern) let you log back into an old throwaway account later to self-delete it via Share & account, keeping QA residue at zero.
Since Round 32, /app/recipes has an 'Or paste a whole recipe' details block (above the manual one) → POST /app/recipes/paste using parseRecipeText (src/recipes.js): first non-empty line before an ING heading (ingredients|what you'll need|you'll need|shopping list, optional trailing :/.) = title; lines to a STEP heading (method|steps|directions|instructions|preparation|to make|how to make it) = ingredients; rest = steps; leading -,*,•,‣,▪ bullets and 1./2) numbering stripped. Failure redirects ?err=…&paste=<text≤1500> which shows the amber notice and re-opens the details prefilled — assert both the notice AND the preserved textarea text. Good adversarial fixture: distinct r-prefixed ingredient labels so grocery-list attribution ('for ') is unambiguous.
Since Round 33 all informational grey text is text-stone-500 (grocery 'for ' sub-line, 'Checked off (N)' heading, staple category label + ✕, store ✕, Move…/store/category selects, /app/share deletion hint) — assert with document.querySelectorAll('[class*="text-stone-400"]').length === 0; the grocery green notice has role=status and the recipes amber error notice role=alert. To measure contrast objectively: paint getComputedStyle(el).color into a 1×1 canvas, read rgba, apply the WCAG luminance formula (stone-500 ≈ 4.79:1 vs white, 4.58:1 vs stone-50). Typing pitfall: long JS strings typed into the visible DevTools console can drop characters — type short statements and reuse vars; and never ctrl+L while console has focus (the URL gets evaluated as JS, polluting the console).
Guides live in src/guides.js (GUIDES array) and are auto-included in /guides and /sitemap.xml (4 static locs + one per guide; assert the total <loc> count via curl). Landing FAQ + FAQPage JSON-LD are generated from LANDING_FAQ (src/index.js); validate by curling / and parsing the ld+json script. Logged-in / redirects to the app, so landing checks need an incognito window. URL-import errors have two branches — fetch-blocked vs no-recipe-found; example.com URLs hit the fetch-fail branch, so triggering the no-recipe branch needs a fetchable page without recipe data. Since Round 34 both branches direct users to the paste box.
Since Round 35 the grocery ✎ popover is 'Edit item' (src/index.js listBody row): required label input (prefilled, maxlength 200) above the note input (maxlength 140), both → POST /app/list/note; non-empty label renames the item (identity/dedupe keys derive from label at runtime), and back-param filter preservation applies. Test rename+note, note-only, and rename-only (clear note → ✎ returns to grey); the empty-label server branch is unreachable via UI (required attr). Pitfall: /app/list add-form submissions can appear to do nothing until the ~5s version-poll re-render, and repeated retries insert duplicates (no insert dedupe) — wait/reload before retrying an 'unresponsive' Add.
Since Round 36 manual list adds dedupe via addListItem (src/index.js), used by both POST /app/list/add and the anonymous POST /s/:token/add (500-item cap): match by ingredientKey — checked hit is unchecked + label REPLACED with the new text ('buy again'); unchecked hit gets labels merged via mergeIngredients (quantity arithmetic like '2 x' + '1 x' → '3 x', but merging an unquantified existing label with a quantified add keeps the existing label — expect no visible change); no hit inserts normally. The R35 pitfall about retry-duplicates from slow Add responses no longer applies (dupes now merge), but poll-render lag still makes adds look unresponsive for a few seconds.
Since Round 37 public/app.js closes any open details.relative[open] popover on a document click outside it, and on Escape (which also focuses its summary). Floating popovers affected: grocery ✎ 'Edit item' and 'Edit stores…'. Planner '+ add' and recipes paste/manual details are NOT .relative and must stay open on outside clicks — assert both directions. Testing pitfall: Escape while DevTools has focus won't reach the page — press Escape with the page focused; verify refocus objectively via document.activeElement.getAttribute('aria-label').
Since Round 38 ingredient section headers (isIngredientHeading, src/util.js — trimmed line ends ':', ≤60 chars, no digits) render as <li class='pt-2 font-semibold'> (colon stripped, no bullet, no convertUnits) on both app and share recipe pages and are skipped by recipe→list and weekly add. Good fixture: paste-import with 'For the sauce:'/'For the topping:' sections. Units-toggle pitfall: convertUnits only converts g/ml↔oz/lb — tbsp/tsp/cup never convert, so use a g or oz ingredient to prove conversion, and the Units select lives on /app/list (household setting), not the recipe page.
Since Round 39 recipe pages have a Print button (data-print, next to Cook mode; app.js → window.print()) and the photo/action-row/tags/Edit-Delete/button-group are print:hidden; since 39b the share page's '← Back to …'s week' link is too. Test prints via UI click → Chrome Save-as-PDF preview and zoom the page thumbnail; Chrome's own date/URL margins are not app chrome. Pitfall: anything a route prepends OUTSIDE recipeBody needs its own print:hidden — always check share pages separately from app pages.
Since Round 41 grocery sections render in store-walk order via sortCategories(h.category_order) (src/util.js; saved order → default → customs alphabetically), with the 'Aisle order…' details.relative popover (per-row ↑/↓ forms → POST /app/list/aisles, edge arrows disabled, back carries ?aisles=1 so the popover reopens after every move). Share page uses the same order but editable=false (no popover). Custom categories join the popover only while an item uses them, but stay inside the saved category_order JSON afterwards (harmless). Reorder pitfall: after each ↑/↓ the page reloads and popover rows shift one slot — recompute the target row's y per click. Cleanup pitfall: 'click outside to close popover' near the list can toggle an item checkbox — click in empty margin (e.g. far left), and re-check the Checked-off section afterwards.
Since Round 42 planner helpers work on partially-filled weeks: 'Copy last week's plan' always shown; 'Fill empty dinners from recipe box' shown iff recipes exist AND ≥1 day lacks a dinner entry; Apply/Delete menu shown iff menus exist; Save-menu form shown iff the week has entries. Occupancy = same date|meal slot (copy-week and menus/apply skip occupied slots; fill-week fills only dinner-less days — other meals don't block). Test with far-future empty weeks via /app?week=YYYY-MM-DD (Mondays); prove no-dupe by counting entries per slot before/after and idempotency by a second Apply. Cleanup: delete plan entries one ✕ at a time — the grid re-renders and coordinates shift after each delete.
Since Round 44 the planner has a 'Clear week' button (shown only when the week has entries) → POST /app/plan/clear-week (deletes only that week's plan_entries, bumps version); its data-confirm dialog includes the live entry count with singular/plural wording — assert the exact text for both N=1 and N>1. It's also the fastest fixture-cleanup tool for planner rounds (replaces per-entry ✕ deletion). Share-poll testing tip: the share planner accepts ?week=YYYY-MM-DD, so open /s/?week=… in a second tab, mutate on the app side, and the open share tab self-reloads within ~10s via the version poll — no manual reload needed as proof.
Since Round 46 recipes have household-shared notes: edit-form Notes textarea (rows=3, maxlength=2000, below Steps), POST edit trims/slices + bumpVersion, amber callout rendered by recipeBody (whitespace-pre-line + esc) on both app and share recipe pages; the note also prints. Since 46b the version poll (public/app.js, 5s interval → location.reload on change) runs on any page with #list OR a [data-poll data-version data-base] element — the share recipe page /s/:token/r/:id has the latter on its back-link
. To prove a poll runs, watch DevTools Network for version fetches every ~5s; to prove self-update, keep the tab open and wait 10–15s after a version-bumping app-side mutation (a recipe-note save is the easiest one).
Since Round 47 recipe cards are outer
s with an inner link (image+title+meta+tags) plus a '+ Plan this week' link → /app?recipe= (R14 preselect banner; combine with ?week=YYYY-MM-DD to keep fixtures on far-future weeks). '★ Favourites' chip renders only when the household has ≥1 favourite and goes solid amber when fav=1 (favourites-only, created_at DESC); '✕ Clear filter' covers tag AND fav. Favourite toggling is only on the recipe detail page (☆ Favourite form). QA household pitfall: bolognese and Test Soup are normally favourited — if a test needs the zero-favourite state, unfavourite them temporarily and RESTORE both afterwards.
Since Round 48 every page head has <link rel=manifest> + apple-touch-icon + theme-color #059669; /manifest.webmanifest (id+start_url /app), /icon-192.png, /icon-512.png are static in public/. Verify via curl (content-type application/manifest+json; icon dims from PNG bytes — file is not installed, use python struct) and DevTools Application → Manifest. Pitfalls: incognito adds a 'loaded in an incognito window' installability warning — use a normal window for the clean check; there is intentionally NO service worker; don't actually install the PWA on the test box.
Since Round 43 src/layout.js emits og:type/site_name/title/description/url, og:image=https://mealloop.zalize.com/og-card.png (+1200/630) and twitter:card=summary_large_image on EVERY page, while noindex pages (share, /login, app) keep <meta name=robots content=noindex>. Verify head tags with curl on the raw HTML (server-rendered, no JS needed) and count occurrences (grep -o 'name="robots"' | wc -l) to catch duplicates. Pitfall: logged-in / 302s to /app/list — check the marketing homepage anonymously.
Sitemap loc counting: curl -s .../sitemap.xml | grep -o '<loc>' | wc -l — plain grep -c counts LINES and undercounts when multiple locs share a line; as of Round 53 the total is 21 locs (4 static + 17 guides in src/guides.js); right after a deploy the sitemap may be served from a stale CDN cache with the old loc count — if the count looks off, re-fetch (optionally with a ?cache-buster) before reporting a failure; guide cards on /guides follow the ARRAY ORDER in src/guides.js — new guides are usually near the bottom but may be inserted mid-array, so grep src/guides.js for the slug's position rather than assuming the last card is newest. Tooling pitfall: the headless browser-console tool can attach to a stale background tab (check the returned location.href) — for incognito checks use the visible DevTools console in the active tab instead.
Since Round 50 every guide page has a 'More guides' nav after the CTA (relatedGuides in src/index.js): deterministic picks = the next 3 guides in src/guides.js array order with wrap-around. To verify picks for any guide, find its index via grep -n "slug:" src/guides.js and take the next 3 (wrapping). The /guides LISTING page has no More guides section — only individual guide pages do.
Since Round 51 the recipe edit form has an optional 'Photo URL' input between Steps and Notes (type=url maxlength=500); POST edit sanitizes via sanitizeImageUrl (src/util.js — only http/https kept, else NULL) and bumps version. Photo renders on the card grid (else 🍽 placeholder), detail page and share recipe page (print:hidden). Stable test image: https://mealloop.zalize.com/og-card.png. Note: Chrome's type=url input ACCEPTS javascript: URLs, so the server-side rejection can be tested straight through the UI form — no direct POST needed.
Since Round 52 the recipe edit form has a 3-col Prep (min)/Cook (min)/Servings row between Steps and Photo URL. Pitfalls: the Servings input has placeholder='Serves 4' — an empty field LOOKS filled, so verify emptiness via the rendered meta line, not the form; the number inputs (min=0 max=6000) block negatives/letters client-side, so to exercise clampMinutes' NULL path (src/util.js) through the UI, save the value 0 (allowed by min=0 but >0 fails server-side). Meta strings differ per surface: card grid 'Prep 10m · Cook 25m · …' vs detail/share 'Prep 10 min · Cook 25 min · …'.
Since Round 54 the logged-in recipe detail page shows a plan-stats line: 'Planned once/N times · last on Ddd D Mmm' from COUNT/MAX(date) of plan_entries with date <= date('now') UTC — future entries don't count, and the share recipe page never shows it (no stats passed); it's also print:hidden. Deterministic test: plan Test Stew on TODAY via the planner (?recipe= preselect flow), expect 'Planned once · last on ', then remove the entry to restore. The QA household's current week may contain real entries — never clear the current week; remove only your own entries via ✕.
Since Round 55 every guide page emits a JSON-LD @graph (Article + BreadcrumbList, absolute https://mealloop.zalize.com URLs, og-card image, icon-512 publisher logo) before the , plus a visible breadcrumb nav 'Guides › ' above the h1 (Guides links to /guides; title uses esc() so it must equal the h1 text). Validate JSON-LD via curl + regex-extract the application/ld+json script + json.loads — note the landing page ALSO has an FAQPage ld+json script, so scope extraction to the guide URL's HTML. The ld+json script is data, not executed — strict CSP logs nothing for it.
Since Round 56 the ✎ Edit-item popup on /app/list is a div (note/rename form nested inside) with a bottom '↑ Move up'/'↓ Move down' row POSTing to /app/list/move: swaps only among same-category same-checked items and writes normalized sort_index (migration 0012) for the WHOLE category — boundary moves are silent no-ops with no DB write, so testing ↑ on a pre-existing first item is safe. /app/list and the share page both ORDER BY category, COALESCE(sort_index,1000000), created_at. Pitfalls: 'Add item' auto-categorizes by name (QA-Apple/Banana/Carrot land in Produce), so plan fixtures around the target category's pre-existing items; before using 'Clear checked' for cleanup, confirm the Checked off section is empty of pre-existing items.
As of Round 63 there are 19 guides (sitemap 23 locs); meal-planning-on-a-budget is the LAST array entry in src/guides.js, so its More guides links wrap to the first 3 guides, and the R60 /guides ItemList has 19 items with it at position 19. Pitfall: an already-open incognito tab may show a stale pre-deploy /guides render — F5 before asserting card counts. Quick shell cross-check for the listing count: curl -s https://mealloop.zalize.com/guides | grep -o 'href="/guides/[a-z-]*"' | sort -u | wc -l.
Since Round 58 cook mode has tap-to-dim ingredients: public/app.js binds '.steps-list li, .ingredients-list li.flex' and toggles .done only while the article has .cook-mode; CSS (src/input.css) is fully scoped to .cook-mode so exiting hides any lingering .done styling. Ingredient section headings ('For the sauce:' style) render as li.pt-2.font-semibold (not .flex) and must stay non-clickable. To test heading behavior on Test Stew, temporarily append 'For the garnish:' + '1 lemon wedge' via the edit form and remove after. Cook mode button only renders when the recipe has steps; the share recipe page uses the same recipeBody + app.js so cook mode works there anonymously.
Since Round 59 the /app/list/move swap logic lives in the pure helper swapAdjacent(arr, value, dir) (src/util.js, returns null for boundary/missing → no DB write, no version bump). UI and behavior identical to R56. Fixture tip: names like 'QA59-One' auto-categorize into Other (not Produce like QA-Apple), landing at the bottom of that category — handy for boundary Move-down tests. Pitfall: after checking one item, the list re-renders — a rapid second checkbox click may not register; re-click after the reload.
Since Round 66 /app/recipes has a Newest|A–Z sort control: ?sort=title = favorite DESC, title COLLATE NOCASE ASC; active sort is a non-link span (aria-current), inactive is a link preserving q/tag/fav; the search form carries a hidden sort=title input in A–Z mode; unknown ?sort falls back to newest. QA household order facts for assertions: favourites are Test Soup + bolognese; in A–Z 'Test Soup' sorts before 'The best spaghetti bolognese recipe' ('Test'<'The') and Easy classic lasagne moves from last (newest) to first non-fav (A–Z) — a good broken-vs-working discriminator. All titles are Title-case, so COLLATE NOCASE needs a lowercase fixture to prove.
Chrome min window width (~532px) blocks a real 375px window; use devtools device toolbar (F12 then Ctrl+Shift+M). URL-bar autocomplete may hijack "/" to "/login" — press Delete before Enter.
R132–133 AI planner + Pantry (verified in prod)
AI flow: /app "✨ Plan my week with AI" button appears only when a dinner slot is empty (src/index.js:566); POST /app/ai/generate takes 15–60s and CAN fail transiently → redirected to /app?ai=err with an amber notice — retry once before reporting failure. Draft lives in KV aidraft:<household_id> (1h TTL); /app/ai redirects to /app when no draft. Apply saves invented recipes tagged ai-suggested and fills only empty dinner days; the AI button disappears once the week is full.
Busy label: buttons with data-busy-label swap text on submit (public/app.js:59) — screenshot immediately after clicking.
Pantry: /app/pantry linked from list header. Since R133b (commit f84dd79) all pantry matching uses pantryKey (src/util.js:223, name-only, quantity/unit-agnostic) — stocked "basmati rice" DOES skip "300g basmati rice", "peanut butter" does not falsely match "butter", and pantry→list reuses/unchecks an existing quantified list row instead of duplicating. (The earlier ingredientKey unit-mismatch bug found in R132 QA is fixed; keep the "300g basmati rice" fixture as the adversarial regression check.)
axe on authenticated pages: browser_console attaches to the main profile while incognito exists; instead run copy(document.documentElement.outerHTML) in the incognito DevTools console, xclip -selection clipboard -o > file.html, then axe-core+jsdom locally (disable color-contrast rule — jsdom has no layout).
POST /ops/migrate is gated like /ops/stats (Bearer ADMIN_STATS_KEY); no auth → 404.
wrangler d1 remote access may return API error 7403 (account not authorized) — verify GDPR wipes via share-token 404 instead.
Devin Secrets Needed
CLOUDFLARE_GLOBAL_API_TOKEN (only for the KV code fallback / wrangler remote).
Since Round 78 the feed's escaping lives in src/util.js as exported icsEscape (used for SUMMARY and X-WR-CALNAME in src/index.js; unit-tested in test/util.test.js). Live-proven feed edges: a comma in a note renders as \, in raw SUMMARY bytes and a ×2-scaled recipe entry renders Lunch: Title ×2. Fixture convention for feed tests: the December future-week convention is OUTSIDE the today−7..today+28 feed window — pick an empty weekday ~3 weeks out instead and compare the feed's SUMMARY list byte-for-byte against a pre-fixture curl baseline after cleanup. The QA household has standing plan entries on some future weeks (e.g. Test Soup/Test Stew on 2026-08-24/25) — don't assume future weeks are empty; verify the chosen fixture day is empty first.
Since Round 79 public/app.js adds swipe week navigation on any page with a[data-swipe-prev]/a[data-swipe-next] (planner Prev/Next, share page Previous/Next week): touchend navigates when |dx|≥70 CSS px and |dy|≤|dx|/2; touchstart on input/select/textarea/button/a/summary cancels. Testing pitfalls: (a) start emulated swipes on the day-HEADING area — most of a day card is covered by + add elements which the guard cancels; (b) in device emulation the viewport is scaled, so screen-pixel drags map to larger CSS-pixel deltas; (c) if browser_console/read_dom report a stale URL, CDP may be attached to a hidden background tab — list targets via the remote-debugging port and close stray pages; (d) holding a touch too long in device mode triggers a long-press context menu.
Round 80 closed the R75 edges: copyName (src/util.js) is live-proven — a 60-char menu name duplicates to exactly 60 chars ('Copy of ' + first 52); the /app/menus/duplicate household guard is a silent no-op redirect for foreign menu_ids (prove via unchanged menu counts in BOTH households, not the 200 response). Adversarial-POST pattern: log a disposable Mail.tm account into an incognito window and run the fetch from ITS page context. R80b fixed the /app/menus long-name overflow: the card h2 has break-words min-w-0 max-w-full, so unbroken 60-char names wrap at 375px. When verifying CSS-utility fixes, also curl the deployed styles.css for the expected rule — the Tailwind build only emits classes actually used, so a missing rule means the rebuild/deploy didn't pick up the class.
Since Round 81 the 'Checked off (N)' header on /app/list has an app-only 'Uncheck all' button (editable-only → POST /app/list/uncheck, household-scoped checked=0 + bumpVersion); the share page (editable:false) must never show it. QA-state note: the standing QA list normally sits at '35 to buy · 0 checked' — if a round needs checked items, create them by checking standing items (milk, 3 cups flour) and always restore to 0 checked. The 'Clear checked' confirm text is 'Remove all checked items? This can't be undone.' — cancel it, never confirm.
R137–141 (brand/marketing): /about and /press are public pages (footer links between Guides and Privacy on all pages); press assets /favicon.svg /icon-512.png /og-card.png; sitemap 36 locs. R140: /subscribe/confirm sends a ONE-TIME welcome email via waitUntil only when the row was previously unconfirmed (sendWelcome in src/auth.js) — test idempotency by reloading the confirm link and asserting the Mail.tm message count stays at 2; welcome arrives <60s with List-Unsubscribe + one-click headers. Omnibox pitfall: pasting long URLs via type can drop the ':' after https — if you land on DNS_PROBE_FINISHED_NXDOMAIN, ctrl+a and retype.
R147–151: Duplicate is a POST form button on recipe detail (redirects to the new id, title + ' (copy)') — always delete the copy to preserve the baseline recipe count (7). ?sort=planned counts only past plan entries (date <= now). /app/list week chips render only when the current week has plan entries (weekRecipes.length); verify print-hidden via DevTools Rendering → Emulate CSS media type: print (reset via the same dropdown). R151b fixed the recipe-card heading-order (h3→h2).
R152–154: assets are served as /styles.css?v= and /app.js?v= with immutable 1-year caching (hash generated into src/assetv.js by scripts/asset-version.mjs) — when verifying a deploy, compare the ?v= hash in served HTML against src/assetv.js at the deployed commit to confirm the right build is live; a mismatch means stale deploy. /faq is public with FAQPage JSON-LD (9 questions) and is counted in sitemap (37 locs as of this batch).
R155: /app/ai/generate with <3 recipes redirects instantly to ?ai=fewbox; POST /app/recipes/starters adds 8 dedup-by-title starter recipes (?ai=starters). Submitting the AI form shows [data-ai-overlay]; success → /app/ai within ~40 s (20 s timeout ×2 attempts); failure → ?ai=err alert with Try again + fill-week fallback — the AI backend genuinely flakes sometimes, so plan for one retry. List toolbar: only "+ Add staples" / "Share with family" / "⋯ More" (Copy list, Print, Edit staples, Pantry, Aisle order…, Clear checked, Units); Aisle order is an inline card via ?aisles=1 (not a popover).
R174–175: the login code page has a button[data-resend] with a 60 s JS cooldown ("Didn't get it? Resend in N s", disabled/muted) that promotes to a solid emerald primary button at 0 — plan ~65 s on the code page to capture both states. Past-day planner cards use bg-stone-100 (not opacity-60); today keeps the emerald border+ring. Design tokens are overridden in src/input.css (emerald-600 #047857, emerald-700 #065f46, stone-400 #736b5a, stone-500 #635b4b) — verify via served styles.css?v= matching src/assetv.js.
R178 AI daily caps + login gate copy: POST /app/ai/generate is capped via KV — per-household 10/day (rl:ai:<YYYY-MM-DD>:<hid>) and per-IP 20/day (rl:ai:<day>:ip:<ip>); at cap it redirects instantly to /app?ai=limit (amber alert "You've reached today's AI drafting limit (10 drafts a day)." + "Fill from recipe box (no AI)" button; AI button title ends "Up to 10 AI drafts a day."). Simulate the cap by wrangler kv key put "rl:ai:$(date -u +%F):<hid>" 10 (household id via the D1 email join query). Login gate: per-email 3 sends/10 min (sends:<email>, incremented even when the send is blocked) and per-IP 10 sends/hour (mailip:<ip>) both now return the copy "Too many login codes were requested from this address or network. Please wait before trying again — the limit clears within an hour." QA pitfall: repeated rounds from the same box can leave mailip:<ip> at 10, blocking the FIRST send for a fresh address — check curl api.ipify.org, then wrangler kv key get/delete "mailip:<ip>" to reset your own QA counter. /login redirects to /app when logged in, so run 4-send gate tests after GDPR delete (or in a separate profile). browser_console CDP may bind to a stale chrome://new-tab-page target when multiple windows are open — verify location.href before trusting cookie writes.
R179 edge cache: public marketing pages (sitePaths() + robots.txt/sitemap.xml) are served from caches.default with an X-Edge-Cache: HIT/MISS response header (key path?edge=ASSET_V, s-maxage 3600). Bypass conditions (no header at all): any query string, an ml_session= cookie, or non-GET — note curl -I sends HEAD so it never shows the header; use curl -s -D- -o /dev/null (GET) instead. Logged-in checks: read headers via same-origin fetch(path,{credentials:'include'}).headers.get('x-edge-cache') from the authenticated page (expect null on / and /app). Rate limits as of R179: email per-IP 30/h, AI per-IP 100/day, global AI breaker rl:ai:<day>:all 200/day, per-household 10/day unchanged.
R177 QA-traffic marking (org-wide convention): first-party analytics (analytics_daily/referrers_daily) skips requests marked as QA — UA containing DevinQA, header x-qa-traffic: 1, or cookie ml_qa=1. ALWAYS mark your traffic when testing: for curl add -H 'x-qa-traffic: 1'; for browser sessions set the cookie once per origin via DevTools console document.cookie='ml_qa=1;path=/;max-age=31536000' (or CDP Network.setUserAgentOverride with a ' DevinQA' UA suffix) before walking pages. IndexNow full-sitemap push runs on a weekly cron (Mon 09:00 UTC, runIndexNow in src/index.js).
This SKILL.md is very large, so SkillsMP previews the first section here.View on GitHub
Since Round 60 the /guides listing emits a single ItemList ld+json script (18 ListItems, position 1..18, name = guide title, url = absolute guide URL) before the listing div — so /guides has 1 ld+json script and each guide page has 1 (@graph Article+BreadcrumbList); the landing page separately has FAQPage. Validate by extracting scripts with re.findall(r'<script type="application/ld\+json">(.*?)</script>', html, re.S) and cross-checking names against the visible card
s in the same HTML. When regex-matching titles out of src/guides.js, use a quote-aware pattern (titles contain apostrophes — a naive '(.+?)' capture silently mis-splits).
Since Round 61 the planner gives today's card id='today' + scroll-mt-20 and the controls-row 'Today' link points to /app#today; past-day cards (date < today UTC) get opacity-60 print:opacity-100. Testing tips: the Today link lives in the Print/Prev/Today/Next controls row (not the sticky brand header) — at 375px scroll back up to it to click; verify the anchor by the resulting /app#today URL + today's card heading fully visible below the sticky header; verify print behavior via Ctrl+P preview where past days must match future days' contrast.
Since Round 62 POST /app/staples/add has a case-insensitive duplicate guard (SELECT lower(label) match → silent no-insert, redirect to /app/staples). The staples page has a single Add form (Enter submits) and a ✕ delete form per row. The QA household keeps one standing staple 'milk · Dairy & Eggs' — never remove it; use QA62-style prefixed labels for fixtures and ✕ them at the end. Duplicate rejection shows no message — assert by unchanged row count after the redirect.
Since Round 64 the share page (/s/:token) mirrors R61's day-card cues: today (UTC) gets border-emerald-500 + ring-1 ring-emerald-200 + text-emerald-700 heading; past days get opacity-60 print:opacity-100; entries inside dimmed cards inherit the dimming. No #today anchor/Today link on the share page (compact 4-col grid, by design) — its week nav is '← Previous week' / 'This week' (only shown on non-current weeks) / 'Next week →'. Quick adversarial states: ?week= must show zero ring/dim; a fully past week must show all 7 cards dimmed with no ring; print preview must reset opacity but keeps the ring.
Since Round 65 the grocery h1 shows N to buy[ · M checked] / all done 🎉 · M checked / no span when empty, counted from the SHOWN items (store filter applies; Any-store items appear in every filter, so a filter can never isolate a subset that excludes them). Critical testing pitfall: grocery checkboxes are optimistic AJAX — the page only re-renders via the 5s version poll, so clicking the same screen position repeatedly toggles the SAME item on/off; space check clicks ~7s apart, or click distinct rows. To bulk-uncheck the Checked-off section, press End and repeatedly click the LAST row's checkbox (its position is stable relative to the page bottom), waiting ~7s per click. Stores are created via any item's store select → 'New store…' JS prompt and removed via 'Edit stores…' (items revert to Any store).
Since Round 71 each /app/staples row has a data-autosubmit category select (POST /app/staples/category, household-scoped UPDATE, redirect back) and 'Add week's ingredients' inserts staples with the staple's STORED category (stapleCats.get(key) || categorize(label)) — recipe ingredients still use categorize(). Testing pitfalls: (a) with few staples a category change may not visibly re-sort (ORDER BY category, created_at) — prove the UPDATE by the select value persisting after F5; (b) clicking 'Add week's ingredients' for a propagation test also inserts the week's missing recipe ingredients AND re-attaches 'for ' source sub-labels to pre-existing unchecked items — use the 'Added N' notice to identify exactly the new rows, check them ~8s apart, and Clear checked to restore the count; source sub-labels can't be reverted but are recomputed next run; (c) the button lives on the /app planner page, not /app/list.
Since Round 72 there is a saved-menus viewer: planner shows a 'View menus' link ONLY when the household has ≥1 menu; GET /app/menus renders newest-first cards with day columns only for days that have entries (Monday..Sunday), each li ': <recipe title || note>' plus ×N badge only when scale≠1; Rename form → POST /app/menus/rename (household-scoped, redirect back), ✕ → POST /app/menus/delete with hidden back=/app/menus (data-confirm 'Delete this saved menu?'), header Print button (data-print) with all controls print:hidden; empty state 'No saved menus yet…'. Safe fixture recipe for future-week plan entries: 'Test Stew'; save via 'Save this week as menu…' which only renders once the week has entries. The QA household normally has zero saved menus — leave it that way after testing.
Since Round 75 each /app/menus card has a Duplicate button between Rename and ✕ (POST /app/menus/duplicate): household-scoped SELECT, inserts 'Copy of '.slice(0,60) and copies all menu_entries (dow/meal/recipe_id/note/scale); the copy renders FIRST (newest-first). The ×N scale badge is proven working: add a recipe planner entry with the 'Scale ingredients ×2' option in the day add form to get 'meal: Title ×2' in the menu preview. Untested edges to date: 60-char copy-name truncation and cross-household menu_id guard (needs a second account). Fixture convention: build menus from an empty future week (e.g. /app?week=2026-12-14) and always restore the household to zero menus.
Since Round 76 /app/share has a 'Meal plan in your calendar' card (input id=cal-url, data-copy) and GET /s/:token/calendar.ics serves a share-token-scoped iCal feed: window today−7..today+28, all-day VEVENTs UID <entryId>@mealloop.zalize.com / DTSTART;VALUE=DATE / SUMMARY 'Meal: Title[ ×N]' (note text for note-only entries) / TRANSP:TRANSPARENT, CRLF, Content-Type text/calendar; invalid token → 404; resetting the share link also changes this URL. Clipboard proof pattern for data-copy buttons: click Copy ('Copied!' feedback), then Ctrl+T + Ctrl+V into the new tab's URL bar and screenshot the pasted value. Pitfall: typing a share URL in the omnibox may autocomplete stale ?week=/&store= params from history — append '#' or re-enter the clean URL.
Since Round 73 the public pages /guides, /guides/:slug, /privacy and /terms pass user: await getUser(c) into page(), so a logged-in visitor sees the app header (Planner/Recipes/List/Log out) instead of Log in/Get started free; the guide-detail CTA box is user ? 'Open your planner' → /app : 'Start planning' → /login. Testing pattern: use the standing logged-in session for the new state and an incognito window for the logged-out contrast (never log out the standing session); cache-bust with ?cb= since the CDN can serve stale HTML ~1 min after deploy. SEO invariants to regress: guide = 1 ld+json (Article+BreadcrumbList) + og:type=article; /guides = ItemList (20 items); landing = 1 FAQPage ld+json.
Since Round 70 the landing page (/) has a 'From the guides' section between the FAQ and the FAQPage ld+json: 3 whole-card links from FEATURED_SLUGS (src/index.js — picky-eaters, batch-cooking, budget), h3 title + excerpt from src/guides.js, plus an 'All guides →' link. To prove the whole-card link, click the excerpt text, not the title. The landing page still has exactly 1 ld+json (FAQPage, 6 Questions) — the featured section adds none; assert the section HTML sits between the FAQ markup and the FAQPage script by string offsets.
As of Round 77 there are 22 guides (sitemap 26 locs); meal-plan-in-your-family-calendar is the LAST array entry (cross-promotes the R76 iCal feed), so its More guides wraps to the first 3 guides and the /guides ItemList has 22 items with it at position 22. New-guide verification recipe: browser render check (breadcrumb/h1/h2s/bullets/CTA/More guides) + cache-busted curl for the single @graph [Article, BreadcrumbList] ld+json, og:type=article, canonical, and sitemap loc count. Pitfall when cross-checking ItemList names against visible card h2s: card markup HTML-escapes apostrophes (') while JSON-LD names don't — html.unescape() the card titles before comparing or you'll get a false mismatch.
Round 68 closed the R65/R66 gaps: COLLATE NOCASE is proven (a lowercase title sorts first among non-favs in A–Z, not last), and the empty-list no-span state + 1-item transitions work on a fresh household. Disposable-account lifecycle: fresh email login auto-creates a household; self-serve GDPR deletion lives on /app/share ('Delete account & all data', data-confirm, POST /app/account/delete) and verifiably kills the session (/app→/login) AND the household share link (404) — use that 404 as the 'data gone' proof. Manual recipe fixtures ('Or add a recipe manually', POST /app/recipes/new) + the recipe page's Delete are the safe add/remove pair for sort tests.
Since Round 67 page() takes ogType (src/layout.js; og:type meta emits article only when ogType==='article'); only the guide detail route passes it — every other route should emit og:type=website. Quick check: curl -s 'https://mealloop.zalize.com/<path>?cb=<ts>' | grep -o '<meta property="og:[^>]*>'; guide og:description must equal the guide's excerpt in src/guides.js.
Since Round 82 list adds are comma-split via splitListInput (src/util.js, /,(?!\d)/ so decimal commas like '1,5 kg' stay one item; cap 20 parts) in both POST /app/list/add and POST /s/:token/add (500-item cap); placeholder is 'Add items (e.g. milk, eggs, 2 lemons)' on both views. Testing notes: single-word QA labels usually categorize to 'Other' unless they contain a known keyword (e.g. 'apples' → Produce) — don't assert category placement when testing the split. There is NO per-row delete on the grocery list: delete fixtures by checking them and using Clear checked (verify all standing items are unchecked first). Rows re-sort after each toggle, so reload between checkbox clicks when checking several items.
Round 83 expanded CATEGORY_RULES (src/util.js): jam/jelly/marmalade/peanut butter hit an early Oils & Condiments rule BEFORE Produce (which now matches [a-z]*berr(y|ies), pears, plums, etc.), Dairy butter has a (?<!peanut ) lookbehind, Meat gained cod/haddock/tofu, Bakery gained buns/bagel/rolls/etc. Live-proven: pears→Produce, buns→Bakery, strawberry jam + peanut butter→Condiments, tofu→Meat, plain butter→Dairy. Note categorize() runs only on ADD — existing rows keep their stored category, so standing items in 'Other' (spaghetti, red wine…) are expected and shouldn't be flagged. Multi-add (R82) is the fastest way to build category fixtures in one line.
As of Round 84 there are 23 guides (sitemap 27 locs); why-meal-plans-fall-apart is the LAST src/guides.js entry, so its More guides wraps to the first 3 guides and the /guides ItemList has it at position 23. The More-guides link mechanism is now click-proven (R84), so future guide rounds can verify the 3 links by exact visible titles only. New-guide verification recipe otherwise unchanged (browser render + cache-busted curl for single @graph [Article, BreadcrumbList], og:type=article, og:description==excerpt, canonical, sitemap loc count; html.unescape card text before comparing).
Since Round 85 recipe-backed planner rows have an autosubmit scale select (options from SCALES, aria-label 'Servings scale', emerald/semibold when ≠1) posting to /app/plan/scale (household-scoped + recipe_id IS NOT NULL, redirects preserving ?week); the old static ×N badge is print-only (print:inline hidden) and the whole action span is print:hidden, so print preview (Ctrl+P) is the way to prove badge-vs-controls. Note-only entries render no scale form. Scale changes do NOT touch shopping_items, so the standing 35-to-buy list is safe during scale tests — but 'Add ingredients' at a non-1 scale WILL add scaled items, so avoid it unless the round budgets a list cleanup.
Round 86 live-proved the full scale pipeline: POST /app/plan/to-list applies scaleIngredient(label, MAX(scale)) per recipe — a '1 cup milk'/'2 onions' fixture at ×2 yields exactly '2 cups milk'/'4 onions' and back at ×1 '1 cup milk'/'2 onions'. /app/plan/scale guards live-proven: invalid scale values (e.g. 7) and foreign-household entry ids both silently no-op behind the unconditional redirect — always prove by reloading and reading the select state in the OWNING session. Disposable-account adversarial pattern: read the target entry id from the QA session via document.querySelector('form[action="/app/plan/scale"] input[name=id]').value, then run the fetch from the disposable session's visible DevTools console. Reminder: grocery checkbox toggles are optimistic — reload between checking multiple items or the second click may not register.
Since Round 87 the grocery ✎ Edit-item popup (app view only) ends with a red 'Delete item' form (data-confirm 'Remove "" from the list?') posting to POST /app/list/remove (household-scoped DELETE + bumpVersion, redirect to back iff it startsWith('/app/list') — store-filtered ?store= views are preserved). This is now the fastest way to remove single fixtures (no more check + Clear checked needed for one-off items). Store pills: create via an item's store select → 'New store…' prompt; delete via the pill's ✕ (confirm 'Items assigned to it go back to Any store.'). The share page rows remain checkbox-only — any ✎/Delete appearing there is a regression.
As of Round 88 there are 24 guides (sitemap 28 locs); meal-plan-in-20-minutes is the LAST src/guides.js entry, so /guides ItemList item 24 and its More-guides wrap to the first 3 guides. New-guide verification recipe unchanged and fully scriptable curl-side (single @graph [Article, BreadcrumbList], headline==title, description==og:description==excerpt, canonical==mainEntityOfPage, og:type=article, sitemap loc count, ItemList positions) — browser needed only for the visual render, CTA, and 375px.
Since Round 89 note-only planner rows have a ✎ details popup (summary aria-label 'Edit note', w-56 popup, input name=note required maxlength=120 prefilled + Save) posting to POST /app/plan/note (trims/slices to 120, empty → no UPDATE, household-scoped WHERE recipe_id IS NULL, bumpVersion, redirect preserving ?week). Recipe rows keep the scale select and get no ✎. The whole action span is print:hidden so print preview shows note text only. The R37 Esc/click-outside close interceptor applies to this popup. Fastest live proof of the version bump: edit the note, reload the anonymous share tab and read the Wed snacks text.
Round 90 closed the R89 note-route branches: POST /app/plan/note is trim()-then-slice(0,120), so a 150-char POST stores exactly the first 120 chars; a whitespace-only note is a pure no-op; a foreign-household entry id and a recipe-backed entry id both silently no-op behind the 200 redirect — always prove by reloading in the OWNING session and reading the ✎ popup input value (document.querySelector('form[action="/app/plan/note"] input[name=note]').value) for exact length. Read entry ids via form[action="/app/plan/note"] input[name=id] (note entries) and form[action="/app/plan/scale"] input[name=id] (recipe entries). Console pitfall: DevTools auto-pairs quotes, so build fetch bodies with var p=new URLSearchParams(); p.set(...) across statements. Also: a new incognito window inherits device emulation from the main window — Ctrl+Shift+M to leave 375px mode before console work.
Since Round 91 all user-typed text fields go through clip(s,n) (src/util.js — slice to n UTF-16 units, drop a trailing lone high surrogate). Boundary proof pattern: build 'a'.repeat(n-1)+String.fromCodePoint(0x1F355) (🍕 = 2 units) in the visible console, POST via URLSearchParams fetch, reload, then measure len===n-1, no '\ufffd', maxCharCode 97.
R91c resolved the grocery-row wrap: the fix needed min-w-0 on the toggle form AND button plus [overflow-wrap:anywhere] on the label span — break-words alone never triggers because it doesn't reduce min-content. Wrap regression check: add an ~85-char unbroken label via UI, at 375px assert document.documentElement.scrollWidth === 375 and label-span height > 20; find the label with [...li.querySelectorAll('span')] filter — li.querySelector('span') returns the checkbox span. The ✎ sits between the store and category selects; a click slightly left opens the store select (Escape recovers).
Since Round 92 the /app/list header has a '+ Add staples' form button (App view only — absent on /s/token) posting to POST /app/list/staples: per staple, ingredientKey dedupe vs shopping_items — unchecked match skipped, checked match set checked=0 and counted as added ('buy again'), no match inserted with the staple's stored category (fallback categorize()); bumpVersion only if added>0; redirect /app/list?added=N&src=staples with notices 'Added N new item(s) from your staples.' / 'Everything from your staples is already on the list.'. Staples category is set via the per-row select on /app/staples; new staples get an inferred category on add, so set the select explicitly when a specific category matters. Note: the share list is at /s/ itself — /s//list 404s.
As of Round 93 there are 25 guides (sitemap 29 locs); household-staples-list is the LAST src/guides.js entry, so /guides ItemList item 25 and its More-guides wrap to the first 3 guides.
R94b closed the R94 paste-title gap: src/recipes.js now uses clip() everywhere it truncates text — parseRecipeText returns clip(title,200) and normalize() clips title/description/servings. Boundary re-check recipe: fill the paste textarea via console (ta.value='a'.repeat(199)+String.fromCodePoint(0x1F355)+'\nIngredients\n1 cup oats\nMethod\nMix well.'), submit with the native Parse & save button, then measure document.querySelector('h1').textContent.trim() — expect [199, last '61', no lone surrogate, no \ufffd]. A broken store shows length 202 ending 'fffd' (renders ���). POST /app/list/staples takes household from session only, so its cross-household check = mutate from a disposable account and reload the standing list natively. The ✎ Edit-item route is POST /app/list/note with fields id/label/note/back — empty label = note-only update (cap 140), non-empty label = rename (cap 200). When auditing clip() coverage, grep for .slice( in parse/import paths too, not just route handlers.
Since Round 95 recipe-box cards show a plan-status badge: recipes on the CURRENT real week's plan (weekDates(today()), recipe-backed entries only) get a stone '✓ On this week's plan' link → /app with aria-label ' is on this week's plan'; others keep the emerald '+ Plan this week' → /app?recipe=<id>. Badge round-trip: click '+ Plan this week', add via a day's '+ add' picker, reload /app/recipes to see ✓, then remove the entry with its ✕ and reload to revert. Caution: the badge keys off today()'s week, not ?week=. The standing household has 7 recipes (6 unplanned + lasagne), so no fixture is needed for an 'unplanned' card. Ctrl+Shift+M only toggles device mode when DevTools has focus — click inside the console first, or it opens Chrome's profile menu.
Since Round 96 the grocery list has a 'Jump to aisle' chip nav: rendered above #list when ≥3 open (unchecked) categories, one pill '<Category> <count>' → #cat-<idx>, sections get id=cat-<idx> class=scroll-mt-4; print:hidden; shared listBody so it appears on /s/<token> too. Verification recipe: cross-check chips vs sections (name/href/count/order), sum chip counts against the '<N> to buy' heading, click a late chip natively and confirm URL fragment + section in view. Store filters are applied upstream of listBody so filtered views recompute chips — the standing household has NO stores configured, so store-filter behavior needs a disposable household with stores if it must be runtime-proven. DevTools console pitfall: chained arrow functions can get mangled by autocomplete — prefer function(){} style and split long expressions.
R97 patterns: (a) a controllable JSON-LD import fixture lives at test/fixtures/qa97-recipe.html (servable via raw.githubusercontent.com on any branch) — extractRecipe regexes ld+json out of the body regardless of content-type, so raw text/plain URLs import fine; boundary-measure title via h1 and description/servings via longest char-runs in main.textContent. (b) Store-filter testing: the filter keeps !i.store || i.store===storeFilter, so a filter test is only distinguishing if some item is assigned to a DIFFERENT store. Create stores via the item row's store select → 'New store…' native prompt. (c) GDPR delete is on /app/share (Account card → red 'Delete account & all data').
As of Round 98 there are 26 guides (sitemap 30 locs); meal-planning-as-a-team is the LAST src/guides.js entry, so ItemList item 26 and its More-guides wrap to the first 3 guides. New-guide verification recipe unchanged and fully scriptable curl-side; browser needed only for visual render, CTA, and 375px.
Since Round 99 the recipe DETAIL page mirrors R95's plan-status: /app/recipes/:id computes plannedThisWeek (recipe-backed plan_entries in weekDates(today())) and the canEdit action row shows stone '✓ On this week's plan' → /app plus underlined emerald 'Plan again' → /app?recipe= when planned, else the emerald 'Add to your week plan'. Share recipe route passes canEdit=false so no action row. Testing pitfall: opening Chrome's print preview on a recipe with an external hero image triggers third-party CORB warnings in the Issues panel — harmless and print-only; do a fresh reload before asserting 'Issues clean'.
R100 golden-path recipe (reusable for future full sweeps): run the disposable household entirely in an incognito window so the main profile keeps the standing session; the share route renders anonymously regardless of session, so a separate logged-out browser is unnecessary for share checks; planner note entries appear in calendar.ics as Dinner: <note> events alongside recipe events; 'Add week's ingredients' reports 'Added N new items' and auto-adds staples; ×2-scaled entries emit doubled quantities. GDPR delete logs the incognito session out to the marketing page and 404s both /s/ and its calendar.ics.
Since R101–102 MealLoop is positioned as a paid product in open beta: /pricing (src/index.js, PRICING_PLANS) shows Free $0 / Household $3/mo · $24/yr ('Most popular') / Supporter $29/yr with CTAs = user ? /app : /login — there is NO payment flow, nothing should ever ask for a card. Logged-out header nav gained a Pricing link + 'Start free trial' CTA (src/layout.js), footer gained Pricing; landing badge is 'OPEN BETA · ALL FEATURES FREE DURING BETA · NO ADS', hero CTA 'Start your free beta trial', FAQ #1 'How much does MealLoop cost?' contains a rendered /pricing link; terms opens with the open-beta clause; sitemap is now 31 locs (26 guides + / /pricing /guides /privacy /terms). Pricing checks are fully read-only — verify logged-out in incognito and the logged-in CTA variant with the standing session.
R103–105 features & test recipes: (a) landing has a 'How it works' Plan/Shop/Cook section (src/index.js) with a /pricing teaser link, between the feature grid and the emerald email band. (b) Cook mode (public/app.js): first not-done step gets class current (opacity 1), other unfinished steps dim to 0.55, done 0.4 + strikethrough — verify by dumping document.querySelectorAll('.steps-list li') classNames. (c) Tap timers: only the FIRST duration phrase per step becomes a .timer-btn (ranges use the lower bound; steps without phrases get none); click stopPropagation means timer taps never toggle done; states: label → running '⏱ m:ss' → finished '⏰ Time's up — tap to reset' → tap resets. The standing lasagne has two '1 min' phrases, so the finished state is testable in ~60s; timers work logged-out on /s//r/ too. (d) /app/share has a 'Your data' card above Account → GET /app/export.json (302 → /login logged out); standing export is recipeCount 7, schema.org Recipe/HowToStep shaped; the file lands in ~/Downloads as mealloop-recipes.json.
R106–108 features & recipes: (a) grocery items can carry a photo_url (migration 0013): the ✎ popup has a type=url Photo URL input, POST /app/list/note runs it through sanitizeImageUrl (http/https else NULL) — to prove the server guard, POST via console fetch from the authenticated page (the url input blocks bad schemes natively); thumbnail is a 32px img between checkbox and label, rendered on both /app/list and /s/ (print:hidden). (b) /app/month is reached via the planner's Month button; day cells link to /app?week=; invalid ?month regex-falls-back to the current month; on 375px empty other-month days are hidden. (c) /guides is a 4-section topic hub (GUIDE_TOPICS in src/index.js, chips → #topic-0..3, cards now h3); ItemList JSON-LD is reordered to the flat grouped order — verify by comparing ld+json names to the h3 card order via curl. CDP pitfall: browser_console attaches to the incognito window while it exists — close incognito before running console scripts against the main profile.
R109–110: /app/recipes has a 4th intake <details> "Or import a JSON backup (moving from another app)" → multipart POST /app/recipes/import-json: accepts array | {recipes:[…]} | single Recipe; success redirect ?imported=N (green role=status), bad JSON → ?err (amber role=alert); images run through sanitizeImageUrl (javascript: → NULL), HowToSection.itemListElement is flattened, PT…H…M converted to minutes (displayed as e.g. "Cook 65m"); caps 5 MB / 200 recipes (code-read only, not runtime-exercised). In incognito, downloads prompt a save dialog (save to ~/Downloads; export becomes "mealloop-recipes (1).json"). File-picker tip: in the GTK dialog use Ctrl+L and type the absolute path. Sitemap is now 32 locs (27 guides + 5 static). CDP pitfall reconfirmed: browser_console binds to one window; when both main and incognito windows are open it may attach to either — verify with document.title before trusting results, and prefer visual/hover verification for hrefs.
R111–113: planner "+ add" recipe select groups ★ Favourites / All recipes via optgroups (query ORDER BY favorite DESC) — inspect read-only by expanding a details and opening the select. Landing has a [data-demo] tablist section (JS in public/app.js); demo checkboxes are client-only — prove no network with performance.getEntriesByType('resource').length before/after (they now have id="demo-item-N"). Cook-mode button is emerald "▶ Start cooking" ↔ "Exit cook mode". Omnibox pitfall: Chrome autocompletes typed URLs to visited paths and Enter accepts the suggestion — after typing the URL press Delete to strip the inline autocomplete before Return.
R114–118 brand baseline: headings h1–h4 use self-hosted Nunito (@font-face in src/input.css, font-display swap, /fonts/nunito-latin.woff2) and the stone palette is re-tokened warm cream (stone-50 #fbf8f3 → body bg rgb(251,248,243)) — assert via getComputedStyle fontFamily/backgroundColor and document.fonts.check. Micro-interactions (check-pop, fade-up, button:active scale .96, .celebrate) live only inside @media (prefers-reduced-motion: no-preference) — test both states via DevTools Rendering → emulate prefers-reduced-motion, then re-check computed animationName (expect 'none') and transitionDuration ('0s'). Empty-state illustrations: fresh household /app/recipes shows a plate+steam SVG, /app/list a grocery-bag SVG. Brand mark is the plate+loop (favicon.svg viewBox 64, header logo in src/layout.js).
R119–122: /subscribe is double opt-in (src/index.js): landing footer POST → "Check your inbox 📬" always (no enumeration); confirm/unsub tokens in email_intents (migration 0014); rate limit is KV subconfirm:<email> (2 sends/hour). QA cleanup is a D1 DELETE from email_intents (subscribing never creates a user account). Confirmation emails arrive at Mail.tm in ~30–60s (slower than magic codes); read List-Unsubscribe/one-click headers from raw source. After unsubscribe the confirm token shows "Link not valid" but the unsub token stays valid. Static asset caching comes from public/_headers (fonts immutable 1y, icons/og 86400) — use /styles.css as negative control. /guides appends any GUIDES entry missing from GUIDE_TOPICS to the LAST topic section (fallback) — check GUIDE_TOPICS membership for new guides. UI-typing pitfall: click+type into the footer email input can drop leading characters if focus lands late — verify the DOM value before submitting.
R123: global security headers (Permissions-Policy, COOP same-origin, CORP same-origin) are set in the src/index.js middleware (~lines 14-22) on every response — verify via curl -sI grep for permissions-policy|cross-origin; CORP on our responses does not affect cross-origin <img> loads (BBC recipe photos), and COOP same-origin doesn't break window.print()/clipboard — sanity-check via share-page Print + Copy list. R124: /pricing embeds SoftwareApplication JSON-LD with 3 Offers (0/3/29 USD).
R127: GET /ops/stats (src/index.js) is gated by Authorization: Bearer $ADMIN_STATS_KEY (key on QA box at /home/ubuntu/.mealloop-ops-stats-key, org secret MEALLOOP_ADMIN_STATS_KEY); wrong/no key → 404 HTML, correct → JSON aggregates {days, paths, search_terms, email_intents}; supports ?days=1..90 (default 7). R128: recipe search ORDER BY (title LIKE ?) DESC, <sort> — adversarial check: search a term whose title match is a NON-favourite (e.g. 'onion' → Test Onion Salad) so the old order would show a ★ favourite first. CWV spot-checks: npx lighthouse <url> --only-categories=performance --output=json --chrome-flags='--headless=new --no-sandbox' works on this box; / and guide pages baseline ~LCP 1.1s / CLS 0.
R132–133 (AI + pantry): "✨ Plan my week with AI" renders only when the shown week has an empty dinner slot; generation takes 30–60s and fails ~1/3 of the time (amber degrade notice → ?ai=err) — budget retries. /app/pantry levels stocked/low/out; stocked skip matches by pantryKey (name-only, quantity/unit-agnostic since 133b) — always include a quantified ingredient ("300g basmati rice") vs bare pantry name in skip tests.
R134–136 (onboarding): planner setup card is server-rendered hidden with data-dismiss-box="setup" only while setupLeft>0 (recipes/plan_entries/shopping_items existence checks in the /app handler); app.js reveals it unless localStorage ml-hide-setup. "New" badges use data-new="ai-week"/"pantry" + ml-new-<key> set on host click — cheap persistence test is the Pantry link (no AI cost). Reset onboarding UI state via localStorage.removeItem in the tab's DevTools console. Isolate 375px overflow culprits by hiding suspect elements and re-measuring scrollWidth before attributing to new changes (the week-nav toolbar lacked flex-wrap from R106 until 136b).
Wrangler D1 remote: the default CLOUDFLARE_API_TOKEN gets API error 7403, but CLOUDFLARE_API_TOKEN=$CLOUDFLARE_GLOBAL_API_TOKEN npx wrangler d1 execute mealloop-db --remote --command '…' works (CLOUDFLARE_WORKERS_API_TOKEN also works) — use this for email_intents inspection/cleanup instead of the share-token-404 fallback.
R142–146 design system: breakpoint sweeps are fastest via DevTools device toolbar Responsive mode — set the width field, then measure innerWidth+'|'+scrollWidth+'/'+clientWidth via CDP console; main is max-w-5xl but xl:max-w-6xl (1152px at ≥1280) while header/footer stay 5xl. Motion checks: .stagger > * delays 0.07s steps, hover lift = computed transform matrix(1,0,0,1,0,-2) while :hover (query with li.matches(':hover')), popover = details[open] > div.absolute pop-in 0.16s; emulate reduced motion via Ctrl+Shift+P → 'Emulate CSS prefers-reduced-motion' and assert animationName === 'none'. Lighthouse headless needs --chrome-flags='--headless=new --no-sandbox --disable-gpu' (without --disable-gpu it dies with NO_NAVSTART). The old axe heading-order moderates (h1→h3) on landing and /app were fixed in R146b (cards/day headings are h2 now).