A folder of small scripts, one per user journey, each of which:
flow-<timestamp>@example.com user (or logs in as a
disposable test account),PASS/FAIL per step plus any console errors, exceptions and HTTP ≥ 400.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.
| 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 |
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.
step() wrapperimport { 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);
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.
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");
});
For every screen that saves something:
document.scrollingElement.scrollWidth <= innerWidth + 1) and tap targets
≥ 40 px.await b.send("Network.emulateNetworkConditions", { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1 }),
act, restore, and check the queued write reaches the API.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.
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.
| 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 |
scripts/browser-flows/ holds cdp.mjs, a README, and one flow per core journey