An agent that checks the page itself
Giving an agent a real browser removes the slowest loop in web debugging. The first thing that breaks is not your selectors — it is that a headless browser is refused at the edge.
The slowest loop in web debugging is not the build. It is the round trip where an agent asks a human to look at something, the human describes it, and the agent reasons about the description.
Give the agent the browser and the loop collapses. Then you find out what actually stops it, and it is not the part anyone plans for.
The case this post is about
One delete, driven by an agent in a real browser, reported as done:
await deleteButton.click();
await page.goto(itemUrl);
const body = await page.evaluate(() => document.body.innerText);
if (!/deleted|not found/i.test(body)) {
bail("clicked Delete but the item still renders — treat as NOT deleted");
}
The first line is what the agent originally did, and it returned without throwing. The three lines under it are what had to be added, because the click succeeded and the item was still there.
That is the case this post follows. Driving the browser turned out to be the easy half — the hard half is that every read from a live page can succeed and be wrong, and a click that does nothing looks exactly like a click that worked.
The symptom
The old loop looks helpful and costs an hour:
Can you open DevTools and tell me what the console says? Can you check whether the cookie is set? Can you paste the network tab?
Every step is a human transcribing machine-readable state into prose, and every transcription drops detail. The agent then reasons about a lossy summary of a thing it could have read exactly.
The fix is obvious in principle: drive Playwright, read the console, read the network log, screenshot on failure. What is not obvious is the first thing that breaks.
Why the obvious fix failed
I wrote the automation, gave it a valid authenticated session, and ran it. The page came back:
Sorry, you have been blocked — You are unable to access the site. Cloudflare Ray ID:
a420e509ed2f8007
Not a selector problem. Not an auth problem — the session cookie was correct and current. The request never reached the application. A headless browser is refused at the edge, before authentication is considered, because the browser's identity is what gets rejected.
This is worth internalising, because the instinct is to debug the wrong layer. A stored session is not the thing being refused. Adding cookies, changing user agents, and reissuing tokens are all work on a request that is never inspected.
The second failure was self-inflicted and equally instructive. Driving a fresh chromium.launch()
at a login page produces a bot wall — the login simply never completes, with no error that says
why. So the naive approach fails at the very first step of anything auth-walled.
The actual mechanism
The working shape is to attach to a real browser the human already logged into, rather than launching one:
# real Chrome, remote debugging, dedicated profile
bash web-debug-bootstrap.sh real-launch "https://site/login"
# → human logs in once
export WEB_DEBUG_CDP=http://127.0.0.1:9222 # loopback debug port preflight-ignore
const browser = await chromium.connectOverCDP(process.env.WEB_DEBUG_CDP!);
const ctx = browser.contexts()[0];
const page = ctx.pages().find((p) => p.url().startsWith("http")) ?? await ctx.newPage();
// … drive it …
await browser.close(); // DISCONNECTS only — never kills a browser you did not launch
process.exit(0); // an open CDP socket keeps the process alive forever
Three details that are not optional:
- A dedicated profile, not the default one. Recent Chrome refuses remote debugging on the
default profile outright. A separate
--user-data-diris the only way it works, and it has the side benefit that the agent never touches the human's real session. close()disconnects. Playwright does not terminate a browser it did not start, so the human's window and login survive. Worth knowing before you avoid calling it.process.exit()explicitly. The CDP websocket is a live handle; without this the script finishes its work and hangs.
The human's only job is the credential entry. The agent resumes the instant the browser is logged in.
The fix
Driving the browser is the easy half. Reading it honestly is the half that bites.
Never trust a click. The failure mode is not "the click threw"; it is "the click succeeded and did nothing". A delete reported success while the item was still listed. The repair is to re-read the state afterwards and assert on the outcome:
await deleteButton.click();
await page.goto(itemUrl);
const body = await page.evaluate(() => document.body.innerText);
if (!/deleted|not found/i.test(body)) {
bail("clicked Delete but the item still renders — treat as NOT deleted");
}
Fail loudly, with evidence. A selector that stops matching should dump a screenshot and the page HTML and exit non-zero — never return an empty list. An empty list is indistinguishable from "there is nothing here", and downstream code will act on it. One truncated read reported 66 live items as deleted.
Prefer navigation to interaction. Tabs in a single-page app are often unlabelled controls where several locators match. Clicking them gave me 25 rows one run and 17 the next; the same state
reached by URL gave the same number every time.
Those three rules are one helper, and the reason they belong together is that each covers a way the other two read as success:
type Outcome<T> = { ok: true; value: T } | { ok: false; reason: string };
async function readState<T>(
page: Page,
url: string,
extract: (page: Page) => Promise<T>,
expect: { nonEmpty: boolean; fingerprint?: RegExp },
): Promise<Outcome<T>> {
// NAVIGATE, do not click. A URL is one locator that cannot match three controls.
await page.goto(url, { waitUntil: "networkidle" });
// FINGERPRINT the page before reading it. Direct navigation in some apps mounts
// the PREVIOUS record under the correct URL, and the read then succeeds — on the
// wrong object. This is the check that was missing when SEO landed on four
// unrelated posts.
if (expect.fingerprint) {
const heading = await page.locator("h1").first().innerText().catch(() => "");
if (!expect.fingerprint.test(heading)) {
await dump(page, "fingerprint-mismatch");
return { ok: false, reason: `expected ${expect.fingerprint}, page shows "${heading}"` };
}
}
const value = await extract(page);
// An EMPTY read is not a result. It is indistinguishable from "nothing here",
// and the caller will act on it — 66 live items were once reported as deleted.
if (expect.nonEmpty && Array.isArray(value) && value.length === 0) {
await dump(page, "empty-read");
return { ok: false, reason: "extracted 0 rows where at least 1 was required" };
}
return { ok: true, value };
}
async function mutateAndVerify(
page: Page, act: () => Promise<void>, verifyUrl: string, gone: RegExp,
): Promise<Outcome<true>> {
await act();
// NEVER trust the click. Re-read and assert on the OUTCOME, not the call.
await page.goto(verifyUrl, { waitUntil: "networkidle" });
const body = await page.evaluate(() => document.body.innerText);
return gone.test(body)
? { ok: true, value: true }
: { ok: false, reason: "action reported success but the item still renders" };
}
Three real runs against the same two helpers:
readState(list, "/me/stories/public", rows, { nonEmpty: true })
-> { ok: false, reason: "extracted 0 rows where at least 1 was required" }
(dumped empty-read.png + .html; 66 items were live)
readState(settings, "/p/9f2c1b05/settings", fields, { fingerprint: /Three phases/ })
-> { ok: false, reason: 'expected /Three phases/, page shows "Reading the 405"' }
mutateAndVerify(page, clickDelete, "/p/4e81aa3c", /deleted|not found/i)
-> { ok: false, reason: "action reported success but the item still renders" }
Every one of those three had previously been a success. The empty list was a truncated read reported
as "nothing to do"; the settings page was the wrong story mounted under the right URL, which wrote one
post's metadata onto another's; and the delete returned without throwing while the item stayed
exactly where it was.
reached by URL (?tab=posts-published) reproduced exactly. If the app pushes a URL, use it.
Anchor on content, not position. .last() grabbed an offscreen empty editor and timed out for
45 seconds against an element that was genuinely "visible, enabled and stable" — just permanently
outside the viewport. Selecting the element with the most text found the real one immediately.
How we proved it
Determinism is the property that matters, so it is the one to measure. Two independent reads of the same list, after switching from clicking tabs to navigating:
counts: {"published":{"declared":100,"scraped":100},"draft":{"declared":17,"scraped":17}}
counts: {"published":{"declared":100,"scraped":100},"draft":{"declared":17,"scraped":17}}
Before the change, the same operation returned 25 and then 17.
The cross-check in that output is doing real work. declared is the count the page itself prints;
scraped is what the loop collected. Requiring them to agree is what catches a scroll that stopped
early — a lazily-loaded list looks complete at every page boundary.
Being honest about that guard: the printed count is not always present, and when it is absent nothing validates the read. So there is a second check that needs nothing from the page — if a large share of items you have already recorded are missing from a fresh read, refuse the read. Things do not vanish in bulk; a short list is a short read.
What it costs
Two costs are worth knowing before you build on this, because neither is obvious from a working demo.
It is interactive, not autonomous. Attaching to a real browser means a real browser has to be running, launched by a human who logged in. That rules out cron, CI, and anything unattended. I kept the headless path in the code for the day the edge rules change, but today every one of these operations needs a person to have opened a window first. A capability that cannot run on a schedule is a different kind of tool than one that can, and it is better to design around that than to discover it when you try to automate.
Selectors are a maintenance stream, not a one-time cost. Class names in a modern app are
obfuscated (div.ao.df), so anything structural is a guess with an expiry date. The stable anchors
are the ones tied to meaning: an aria-label, a URL the app itself pushes, a link whose href
contains the id you already know. Everything else will break, quietly, on a deploy you do not
control.
The corollary is that failures must be loud and self-documenting. Every bail in mine dumps a screenshot and the page HTML to a temp directory and exits non-zero. That turned four separate selector breakages into four ten-minute fixes instead of four debugging sessions, because the evidence was already sitting there when I went to look.
What this generalizes to
An agent with a browser removes a human from the observation loop, and the value shows up immediately. The cost shows up later and lands in one place: everything the browser tells you is now an assertion you own. Clicks that quietly do nothing, lists that are quietly short, elements that are quietly offscreen — none of these raise errors. Drive the browser, then verify the outcome independently of the action, because the action will tell you it worked.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".