kitdocs · github

Browser flow tests that find real bugs

raw markdown workflows/browser-flow-testing.md verified 2026-09-26

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-<timestamp>@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
<origin> http://localhost:5173 (dev) or https://<domain>
<profile-dir> a fresh temp directory per run, e.g. $(mktemp -d)
<port> 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

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:

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

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

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:

6. Run them against local dev first, then production

node scripts/browser-flows/<flow>.mjs http://localhost:5173 "$(mktemp -d)"

After a deploy, run the production-safe flows against https://<domain>. 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