TLDR: When browser automation silently does nothing on a widget, ask yourself one question first — is this a trusted input boundary? If yes, two failed synthetic strategies is your signal to stop iterating and escalate to real CDP Input events.

The Setup

I was automating the Cloudflare DNS "Add record" modal using Arc CDP (my browser, Arc, driven over Chrome DevTools Protocol).

The task: open the modal, set the record type to CNAME, fill the name and target, save.

Simple enough.

The Type field is a base-ui combobox (Radix UI's lower-level headless component library). I've done this a hundred times. Click the button, pick the option.

Except this time… nothing happened.

The Wall

arc-cdp eval is Runtime.evaluate under the hood — it runs script inside the page. That means every event it synthesizes has isTrusted: false.

I tried everything:

  • element.click()
  • Full pointerdown → mousedown → pointerup → mouseup → click sequence
  • dispatchEvent(new MouseEvent(...)) with clientX/clientY/view filled in
  • Synthetic KeyboardEvent typeahead — C, N, Enter
  • ArrowDown + Enter via dispatched KeyboardEvent

Type stayed A. Every time.

Here's the thing: base-ui ignores these for the exact same reason a browser ignores an XSS payload trying to forge a click. Script-dispatched events from inside the page are forgeable. A malicious script could dispatch a click() on a "Delete Account" button and your browser wouldn't know the difference from you. So browsers refuse to treat them as user gestures.

That's not a bug. That's the security model working perfectly. I just happened to be on the wrong side of it.

The lesson I should have absorbed earlier: once TWO synthetic strategies fail on the same widget, stop. The rest will fail too. They're all equally untrustworthy — varying the flavour doesn't change the fundamental.

The Fix

You need to speak at the debugger layer, not the page layer.

The raw CDP Input domain — accessed over the page target's webSocketDebuggerUrl from http://localhost:9222/json — dispatches events at the OS/hardware level. The page can't forge that. So isTrusted comes back true.

Recipe:

  1. Input.dispatchMouseEvent (mousePressed + mouseReleased) on the Type button → opens the combobox
  2. Input.dispatchKeyEvent (rawKeyDown + keyUp) — ArrowDown × 4 → Enter

The order is A → AAAA → CAA → CERT → CNAME. Four downs, one enter. Confirm via the hidden input: input[name$="-hidden-input"].

Text fields still work fine via eval + execCommand("insertText", false, value). Only trust-gating pickers need the real CDP path.

The 19-Records Scare

While I was at it, I needed to toggle the proxy switch. I grabbed the first switch-like element: querySelector("[role=switch],input[type=checkbox]").

That matched a table-row checkbox sitting right next to "Delete 19 records."

I caught it before clicking. But the lesson is: SCOPE your selectors to the modal. modal.querySelector(...), not document.querySelector(...). One missing scope and you're deleting production records.

Why This Matters

isTrusted isn't an annoyance. It's a boundary that keeps injected scripts from impersonating users on sensitive widgets. When automation gets silently ignored, the first question is now: am I dispatching from inside the page (forgeable) or from the debugger (trusted)?

Two failed synthetic clicks is your diagnostic. Escalate to Input.dispatchKeyEvent over the raw websocket, and the wall disappears.