--- title: Browser flow tests that find real bugs summary: Drive headless Chrome over the DevTools Protocol at phone width as a throwaway user, and after every action compare the screen with the API, then reload and compare again. when_to_use: Before and after every deploy of a web app; whenever someone asks to "test the website" or "find bugs"; when a bug report says "I did X and the screen didn't change". prerequisites: Node 22+ (built-in fetch and WebSocket), Google Chrome installed, an app you can run locally, a signup or a disposable test account. No Puppeteer/Playwright needed — skills/browser-flows/scripts/cdp.mjs is a 70-line driver. related: [skills/browser-flows/SKILL.md, workflows/pwa-and-caching.md, workflows/password-reset.md] last_verified: 2026-09-26 --- # Browser flow tests that find real bugs ## Goal A folder of small scripts, one per user journey, each of which: 1. launches **headless** Chrome with a fresh, isolated profile at 390 × 844, 2. signs up its own `flow-@example.com` user (or logs in as a disposable test account), 3. performs real taps and typing, 4. after **every** action checks the DOM **and** asks the API what was stored, 5. reloads the page and checks again, 6. prints `PASS`/`FAIL` per step plus any console errors, exceptions and HTTP ≥ 400. ## Why not just click around and look Reading rendered pages cannot exercise actions, and it cannot see "the click worked but the screen did not change" — that only shows up when you compare the page with the API and then reload. In one app a read-only walk of every page passed while two bugs sat in plain sight: *Remove* on a finished item left it on screen, and a list↔grid toggle broke every card. The owner found both in minutes. Unit tests miss these too: they are bugs *between* components, the cache, the router and the server. ## Inputs (placeholders used) | Placeholder | Meaning | |---|---| | `` | `http://localhost:5173` (dev) or `https://` | | `` | a fresh temp directory per run, e.g. `$(mktemp -d)` | | `` | a free CDP port; distinct per parallel run | ## Steps ### 1. Copy the driver Copy `skills/browser-flows/scripts/cdp.mjs` into `scripts/browser-flows/` in the repo. It launches Chrome headless, attaches to the page **and to its workers**, and exposes `goto`, `eval`, `waitFor`, `clickText`, `type`, `screenshot`, `block`, `requests`, `problems`, `close`. **Check:** `node -e "import('./scripts/browser-flows/cdp.mjs').then(async m=>{const b=await m.launch(process.argv[1]);await b.goto('https://example.com');console.log(await b.eval('document.title'));b.close()})" "$(mktemp -d)"` prints `Example Domain`. ### 2. Write one flow per journey with a `step()` wrapper ```js import { launch } from "./cdp.mjs"; const ORIGIN = process.argv[2] ?? "http://localhost:5173"; const b = await launch(process.argv[3], Number(process.env.CDP_PORT ?? 9350)); const results = []; async function step(name, fn) { try { const info = await fn(); results.push("PASS"); console.log("PASS", name, info ?? ""); } catch (e) { results.push("FAIL"); console.log("FAIL", name, "->", e.message); } const p = b.problems(); // console errors, exceptions, HTTP >= 400 if (p.length) console.log(" problems:", p.join(" | ")); } const assert = (c, m) => { if (!c) throw new Error(m); }; // Call the API from inside the page so the session cookie comes along. const api = (method, path, body) => b.eval(`fetch("/api${path}",{method:"${method}",credentials:"include", headers:{"content-type":"application/json"}${body ? ",body:" + JSON.stringify(JSON.stringify(body)) : ""}}) .then(async r=>({status:r.status,body:await r.json().catch(()=>null)}))`); ``` End every flow with a summary and a non-zero exit on failure: ```js const failed = results.filter((r) => r === "FAIL").length; console.log(`\n${results.length - failed}/${results.length} passed`); b.close(); process.exit(failed ? 1 : 0); ``` ### 3. Use a fresh user per run ```js const EMAIL = `flow-${Date.now()}@example.com`; ``` Never reuse the owner's account. If the flow must run on production, use one dedicated test account and make the script **refuse any login that is not `@example.com`**. Flows that change passwords, delete accounts or need dev-only API fields must **refuse the production origin** outright. ### 4. After each action: DOM, API, reload, DOM ```js await step("mark item done", async () => { await b.clickText("button", "Done"); // wait for the ROW's state, not for toast text (see Traps) assert(await b.waitFor(`document.querySelector('[data-item="42"]')?.dataset.state==="done"`), "row did not update"); const r = await api("GET", "/items/42"); assert(r.body.done === true, "API did not store it"); await b.goto(location.href); // or b.goto(`${ORIGIN}/items`) assert(await b.waitFor(`document.querySelector('[data-item="42"]')?.dataset.state==="done"`), "lost on reload"); }); ``` ### 5. Cover the states that break in real use For every screen that saves something: - **Phone width** — the driver emulates 390 × 844. Also assert no sideways scroll (`document.scrollingElement.scrollWidth <= innerWidth + 1`) and tap targets ≥ 40 px. - **Network cut** — `await b.send("Network.emulateNetworkConditions", { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1 })`, act, restore, and check the queued write reaches the API. - **Remount** — navigate away and back without saving; unsaved input should survive if users expect it to. - **Logout/login as another user** — nothing from the first user may remain. - **View toggles and search** on every list page. - **Light/dark and any accent/theme setting.** ### 6. Run them against local dev first, then production ```bash node scripts/browser-flows/.mjs http://localhost:5173 "$(mktemp -d)" ``` After a deploy, run the production-safe flows against `https://`. Run flows ~1 minute apart if they sign up users (auth rate limits). **Check:** every flow ends `N/N passed` with no `problems:` lines you can't explain. ### 7. Prove each new flow can fail When a flow covers a bug fix, run it once on the code **before** the fix (or temporarily revert the fix). A flow that passes on the broken code tests nothing. ## Traps | Symptom | Cause | Fix | |---|---|---| | `waitFor(text)` passes but the list hasn't refetched | the success toast repeats the row's text | wait on the row's element/attribute, e.g. `aria-label^="Edit …"` | | An animation or lazy image reads 0×0 forever | headless Chrome paints only when asked; lazy images load on paint | take a screenshot first, then wait on `naturalWidth > 0` | | Code waiting on `requestAnimationFrame` never runs | rAF fires only when a frame is drawn | capture a screenshot to force a frame, then wait | | A tab/menu doesn't open on `click()` | Radix-style components switch on `pointerdown`/`mousedown` | dispatch `pointerdown` + `mousedown` before `click` | | Every flow fails between midnight and ~3 am local | test data dated with `toISOString()` (UTC) while the app uses the device's calendar | use `new Date().toLocaleDateString("en-CA")` for "today" | | The third flow in a row fails at signup | auth rate limit (e.g. 10/min per IP) | space flows or raise the limit on the dev server only | | A download the app makes never shows in `requests()` | it happens inside a Web Worker, a separate CDP target | the driver auto-attaches to workers; make sure yours does too | | Production looks "still broken" right after a deploy | the service worker served the previous build | unregister the SW / use a fresh profile; see workflows/pwa-and-caching.md | | A logged-out user can fetch another user's export | CDN cached a URL with a file extension (`.csv`) | `curl -sI` API URLs without cookies; send `Cache-Control: no-store` on every API response | | Flow fails once on a fresh dev server, passes on rerun | cold start (a CDN dataset, a first build) | rerun before blaming the change; fix the flow if it is flaky twice | | Clock-based assertions off by ~15 s | the test machine's clock drifts from the server's | measure skew from the response `Date` header | | Flow passes against a stale API | another process is running the dev API from old source | restart the API after migrations before trusting a run | | Two parallel runs collide | same CDP port or profile dir | `CDP_PORT` per run, `mktemp -d` profile per run | ## Done when - [ ] `scripts/browser-flows/` holds `cdp.mjs`, a README, and one flow per core journey - [ ] Every flow uses a fresh profile and its own user (or a guarded test account) - [ ] Every write is checked in DOM, API, and after a reload - [ ] Destructive or dev-only flows refuse the production origin - [ ] All flows pass locally; production-safe ones pass after each deploy - [ ] The repo's agent/contributor notes say which flows to run after which kind of change