Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 6 min read

Five things that break when you drive a real Chrome over CDP (and how to verify your writes actually landed)

Five things that break when you drive a real Chrome over CDP (and how to verify your writes actually landed) Most "browser automation" advice assumes a fresh, disposable browser. The interesting problems start when the

Five things that break when you drive a real Chrome over CDP (and how to verify your writes actually landed)

Most "browser automation" advice assumes a fresh, disposable browser. The interesting problems start when the browser is a real one: your normal Chrome, with your normal profile, already logged into the sites you need. That is the setup I run β€” Chrome started with --remote-debugging-port=9243 on a persistent --user-data-dir, driven from Python over raw Chrome DevTools Protocol.

No Selenium, no Playwright, no driver binaries. Just websocket-client and the protocol: hit http://127.0.0.1:9243/json/list for the targets, connect to a page's webSocketDebuggerUrl, then send Runtime.evaluate, Input.*, DOM.*, Network.* and friends.

It works well. It also fails in ways that never show up as exceptions, which is the part worth writing down. Everything below is a failure I actually hit, in the order I hit it.

1. The OAuth popup that never opens

The first login I automated was a Google sign-in that opens a popup. The click fired, and nothing happened: Chrome blocked the popup because it wasn't tied to a user gesture it recognised.

The fix is one flag at launch time:

google-chrome \
  --remote-debugging-port=9243 \
  --user-data-dir=/home/amr/.config/google-chrome-cdp \
  --disable-popup-blocking

With popups allowed, the flow becomes: click the provider button, wait for a new target whose URL starts with accounts.google.com, attach to that target's own websocket, and drive it like any other page:

def find_popup():
    for t in targets():                      # GET /json/list
        if t.get("type") == "page" and "accounts.google.com" in (t.get("url") or ""):
            return t
    return None

popup = find_popup()
ws = websocket.create_connection(popup["webSocketDebuggerUrl"], timeout=30)
# choose the account row, then the consent button, in a loop until the URL leaves Google

Two details that bit me later:

Not every OAuth is a popup. When I signed up on dev.to, the "Continue with Google" button navigated the current tab to accounts.google.com and back to a /users/auth/google_oauth2/callback URL. If your code only watches for a new target, it will sit there forever. Handle both: after clicking, poll for either a new popup target or your own tab's URL changing to the provider.

On a shared browser, do not grab "any" Google tab. The browser I automate usually has several tabs doing different jobs. On one attempt I connected to the first accounts.google.com target I found β€” it belonged to a different tab's Luma OAuth flow β€” and happily clicked through its consent screen. Take a snapshot of target IDs before you click, then pick the target that wasn't there before, and sanity-check its openerId against your own tab.

2. Input events into a background tab are silently dropped

This one cost me fifteen identical clicks and a lot of confusion: I dispatched a mouse click at the exact centre of the right button, the coordinates were correct, elementFromPoint at those coordinates returned the button, and the page did absolutely nothing.

Headed Chrome only routes Input.* events to the foreground tab. If your tab is in the background β€” because another part of your automation switched tabs, or the user did β€” clicks, keys and insertText all go nowhere, with no error.

The fix is to claim the foreground before every interaction, and to re-claim it between steps, because something else can steal it back:

browser.call("Target.activateTarget", {"targetId": my_tab_id})
tab.call("Page.bringToFront")
# now the click lands

After adding activation, the same click sequence completed the whole OAuth flow in two steps.

3. File inputs: success with zero files

Uploading the product zip to a Gumroad listing looked like the easy part. DOM.setFileInputFiles reported success β€” and the page still showed nothing. The reason:

document.querySelector('input[type=file]').files.length   // 0

The call is a no-op unless the backend node actually resolves to a real file input. Worse, the fallback path (clicking the input and driving the native dialog) doesn't exist on a bare X display with no window manager: no dialog opens, so there is nothing for xdotool to type into.

React drop-zones make this worse, because they don't read the file input at all. They listen for drag events β€” dragenter, dragover, drop β€” and they read event.dataTransfer.files. A synthetic change event on a hidden input is not a drop. What works is dispatching an actual drag sequence with the file attached to the drag payload:

data = {
    "items": [{
        "mimeType": "application/zip",
        "data": base64.b64encode(open(ZIP_PATH, "rb").read()).decode(),
    }],
    "dragOperationsMask": 1,
}
for kind in ("dragEnter", "dragOver", "drop"):
    tab.call("Input.dispatchDragEvent",
             {"type": kind, "x": x, "y": y, "data": data})
    time.sleep(0.2)

The coordinates must sit over the drop zone (same coordinates you would use to click it), dragOperationsMask: 1 is "copy", and binary payloads go in as base64. Then β€” and this is the whole point β€” re-read the persisted state instead of trusting the drop. In this case the listing's server-rendered model finally showed the file with its real byte size. Before the drag-event route, it showed nothing for an hour while my own logs said the upload had "succeeded".

4. Rich-text editors: the DOM is not the model

Gumroad's description editor is tiptap, a ProseMirror wrapper, and ProseMirror keeps its own document model. You can make its contenteditable DOM show your text and still save nothing, because the model never changed.

What failed: dispatching a synthetic paste (ClipboardEvent) with the text in clipboardData, and typing into the DOM's innerText directly. Both left the editor's model empty; the editor itself still reported an empty document (len=1).

What worked was treating it like a human at a keyboard, with the tab foregrounded (see Β§2):

tab.call("Page.bringToFront")
tab.call("Input.dispatchMouseEvent", {"type": "mousePressed",  "x": x, "y": y, "button": "left", "clickCount": 1})
tab.call("Input.dispatchMouseEvent", {"type": "mouseReleased", "x": x, "y": y, "button": "left", "clickCount": 1})
assert tab.js("document.activeElement === editor")
tab.call("Input.insertText", {"text": description_markdown})

Input.insertText goes in through the browser's real input pipeline, so ProseMirror sees it as a genuine text insertion. Paragraph breaks become real paragraphs, and the editor's empty-state class disappears β€” but that's still editor evidence, not persistence evidence.

5. Verifying a write: three checks, in order

The lesson from all four failures above is that the UI will happily show you a lie: a "success" file API call with zero files, an editor full of text that saves as empty, a click that lands on the right pixel and does nothing.

So the rule I now follow is: a write is only real if you can read it back from somewhere that doesn't share state with the thing that wrote it.

  1. Capture the network payload. Enable Network on the tab, click the real Save button with a trusted click, and inspect Network.requestWillBeSent events for the request your form submits.
tab.call("Network.enable")
# ... trusted click on Save ...
evs = drain_events(8)
for e in evs:
    if e["method"] == "Network.requestWillBeSent":
        post = e["params"]["request"].get("postData") or ""
        print("description in payload:", "my first line" in post)

The first time I trusted the editor, that check is what proved the text never left the page.

  1. Hard-reload and re-read the persisted model. For Gumroad the server renders the stored product into #app[data-page] as JSON β€” description, files, price β€” the same object the public storefront reads. If the reloaded page's model doesn't have your text, nothing persisted, no matter what the editor said a second ago.

  2. Fetch the public URL and grep the HTML. curl -s https://the-public-page | grep -F "first line of my text". This is the only check your users can reproduce, so it's the one that counts.

That loop β€” dispatch real input, capture the request, reload, curl the public page β€” has caught every one of the silent failures above. It is also how I confirmed that the article you are reading was actually published, rather than sitting in a draft tab that looked fine.

If any of this is the kind of thing you fight

I packaged the working pieces of this setup β€” the CDP client, a page watcher, a declarative form filler, a table scraper and a change diff β€” as a small toolkit: Browser Automation Toolkit.

That's a paid bundle ($14), and I'd rather say what that means: the core source is MIT-licensed and public on GitHub. The paid zip is for people who want the packaged version in one file β€” quickstart, changelog, examples and a working test suite, plus updates and email answers from me. If you'd rather read the source first, the repo is right there and it's the same code.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.