Electron CDP Automation

Drive and test a packaged Electron desktop app from the outside — launch the .exe/.app, attach over the Chrome DevTools Protocol (CDP), and manipulate its DOM from a plain Node script. No source access, no rebuild, no test harness inside the app.

Electron embeds Chromium, so any packaged Electron app built with remote debugging left enabled can be started with --remote-debugging-port and then controlled much like a headless browser page.

Why this exists

The naive CDP script for an Electron app looks correct and fails anyway. Four failure modes account for most of the lost time, and none of them produce a useful error message:

Symptom Cause Fix
await hangs forever, no error, no timeout Page.loadEventFired already fired before Page.enable() ran Poll for URL + target element instead
Execution context was destroyed A real navigation tore down the JS context Reconnect-and-retry inside evaluate
Failed to execute 'querySelector' Playwright's :has-text() is not valid CSS Native CSS plus JS text filtering
Field shows the right text, app submits the old value Vue/React listen for events, not the value property Dispatch input/change/blur, then assert readback

SKILL.md documents each one with the broken code, the working code, and the reasoning. template.js is a runnable skeleton with all four already handled.

Requirements

  • Node.js 16 or newer
  • npm install chrome-remote-interface
  • A packaged Electron app whose debug port actually opens — verify that first

Step 0: verify the debug port before writing any code

If the target app has remote debugging disabled at build time, the entire CDP approach is impossible and no amount of flag-tweaking will change that. Find out in thirty seconds:

# Terminal 1 — launch with any free port in 9000-65535
./YourApp.exe --remote-debugging-port=9222 --remote-allow-origins=*

# Terminal 2 — verify
curl http://localhost:9222/json/version

A working port returns JSON containing webSocketDebuggerUrl:

{"Browser":"Chrome/120.0.6099.109","Protocol-Version":"1.3",
 "webSocketDebuggerUrl":"ws://localhost:9222/devtools/browser/..."}

A refused connection, a timeout, an "unsupported parameter" complaint, or an app that launches normally while nothing listens on the port all mean the same thing: debugging is off. Typical causes are a build-time flag, enterprise security policy, or ASAR hardening. Switching ports, adding --inspect, or injecting DLLs will not work around it. Fall back to OS-level automation (Windows UI Automation, pyautogui, AutoHotkey), the app's own CLI or HTTP API, or — with source access — a debug-only build.

Quick start

npm install chrome-remote-interface
cp template.js myflow.js

Edit the two config blocks at the top of myflow.js:

const CONFIG = {
  appPath: path.join(__dirname, '../your-app.exe'),  // the packaged binary
  port: 9222,
  readyUrlPart: 'main',      // substring of location.hash / pathname once the main UI is up
  readySelector: 'button',   // an element that only exists on the ready main UI
  readyTimeout: 120000,      // cold start of a real app is often 15-25s; be generous
};

const SEL = {
  targetButton: 'button.submit',
  nameInput: 'input#username',
};

Then write the flow in run() using the provided helpers, and go:

node myflow.js

The script launches the app, attaches, waits for the UI, runs your steps, writes numbered screenshots next to the script, and always tears down the CDP client and the process in a finally block. On failure it captures an error screenshot and exits non-zero, which makes it usable directly in CI.

What template.js gives you

ElectronAutomation wraps the whole lifecycle:

  • launch() — spawns the binary with the debug flags.
  • connect() — enumerates targets, keeps only type === 'page' (an Electron app also exposes hidden windows and workers), retries for up to 60 seconds, then enables Page and Runtime.
  • evaluate(expr) — runs an expression in the page, surfaces exceptionDetails as real errors, and transparently reconnects once if the execution context was destroyed by a navigation.
  • waitReady() — polls URL and element visibility until both match, logging each state change. Checking both matters: after a login redirect the URL can be right while the DOM is still empty.
  • waitVisible(sel) — waits for non-zero size and an attached offsetParent, not merely presence in the DOM.
  • click(sel) — uses native el.click(), which ignores overlays, pointer-events, and scroll position, and reports DISABLED distinctly from NOT_FOUND.
  • fill(sel, value) — clears the field, dispatches the input/change/blur chain frameworks actually listen to, then asserts the value read back. Without that assertion a silent fill failure only shows up after submission.
  • screenshot(name) — auto-numbered PNGs (01-main.png, 02-filled.png, …) for a visual trace of the run.
  • cleanup() — closes the client and kills the process.

Don't guess selectors

Guessed class names and button labels are almost always wrong, and text matching is fragile — localized buttons frequently contain stray whitespace (确 定). Dump the real DOM first, then write selectors against stable id/class values. SKILL.md describes the intended probe usage:

node probe.js ./YourApp.exe            # dump visible buttons/inputs with class, id, attributes
node probe.js ./YourApp.exe --watch    # print URL changes every second to find when the UI is ready

Red flags

Stop and reconsider if a script contains any of these:

  • Page.loadEventFired or domContentEventFired used for readiness detection
  • Selectors containing :has-text(, text=, or >>
  • An evaluate with no context-reconnect branch
  • A fill with no readback assertion
  • A fixed sleep(3000) standing in for a polling wait
  • Any automation logic written before curl localhost:9222/json/version was confirmed
  • Repeatedly trying new ports or flags against a port that won't connect, instead of concluding debugging is disabled

Security

Never ship or run a production app with a debug port open. Any local process that can reach that port gets complete control of the application — arbitrary JS in the renderer, full DOM access, and whatever the app's context exposes. Keep debug-enabled builds to test environments, and prefer binding to loopback only.

Cleanup

Electron can leave child processes behind. After a run, confirm nothing lingers:

# Windows
tasklist | findstr YourApp

# macOS / Linux
pgrep -fl YourApp

Repository layout

Path Purpose
SKILL.md The full guide: port verification, the four traps, connection handling, red flags. Carries YAML frontmatter so it can be loaded as an agent skill.
template.js Ready-to-copy automation skeleton implementing every pattern in the guide.
LICENSE MIT.

SKILL.md also references probe.js (selector discovery) and overlay.md (an "under external control" page banner — needs pointer-events: none on the full-screen layer, Shadow DOM style isolation, and Page.addScriptToEvaluateOnNewDocument to survive navigations). Neither file is in the repository yet.

License

MIT — see LICENSE.