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 onlytype === 'page'(an Electron app also exposes hidden windows and workers), retries for up to 60 seconds, then enablesPageandRuntime.evaluate(expr)— runs an expression in the page, surfacesexceptionDetailsas 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 attachedoffsetParent, not merely presence in the DOM.click(sel)— uses nativeel.click(), which ignores overlays,pointer-events, and scroll position, and reportsDISABLEDdistinctly fromNOT_FOUND.fill(sel, value)— clears the field, dispatches theinput/change/blurchain 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.loadEventFiredordomContentEventFiredused for readiness detection- Selectors containing
:has-text(,text=, or>> - An
evaluatewith 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/versionwas 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.
No comments yet
Be the first to share your take.