with one click
electron-devtools-testing
// Test the Electron app interactively using Chrome DevTools Protocol. Use when the user asks to test, verify, or interact with the running app via browser automation.
// Test the Electron app interactively using Chrome DevTools Protocol. Use when the user asks to test, verify, or interact with the running app via browser automation.
Clear extension caches, analysis data, and drafts from the database. Use when testing sender lookups, analysis, or draft generation from a clean state.
Run GitHub Actions CI workflows locally using nektos/act in Docker. Use when testing CI before pushing or debugging workflow failures.
Pre-commit code review for production-critical issues. Use when reviewing staged changes, before committing, or when asked to review code for bugs and consistency issues.
Iteratively improves a PR until all review bots (Greptile, Devin, and others) are satisfied with zero unresolved comments, then fixes any CI failures. Triggers reviews, fixes all actionable comments, pushes, re-triggers, and repeats. Use when the user wants to fully optimize a PR against all automated code review feedback.
Capture screenshots of the Exo Electron app workflows using Playwright in demo mode. Use when the user asks for screenshots, workflow documentation, or visual captures of the app.
| name | electron-devtools-testing |
| description | Test the Electron app interactively using Chrome DevTools Protocol. Use when the user asks to test, verify, or interact with the running app via browser automation. |
Test the Exo Electron app interactively using Chrome DevTools Protocol (CDP) via the chrome-devtools MCP.
chrome-devtools MCP must be configured — add it to your MCP config:
claude mcp add chrome-devtools -- npx -y chrome-devtools-mcp@latest --browser-url=http://127.0.0.1:9223
Port 9223 (not the chrome-devtools-mcp default of 9222) avoids conflicting
with the user's main Chrome browser, which on this machine already runs with
--remote-debugging-port=9222 for the browser-harness setup. The MCP is hard-wired
to this port — it reads --browser-url once at subprocess startup and never re-reads
it. If you have to launch Electron on a different port (see step 2), the MCP cannot
reach it; use direct CDP instead (see Fallback below).
Pick a free debug port, then launch the app headless on it:
PORT=9223
while lsof -ti:$PORT >/dev/null 2>&1; do PORT=$((PORT+1)); done
echo "Using CDP port $PORT"
EXO_DEMO_MODE=true EXO_HEADLESS=true npm run dev -- --remote-debugging-port=$PORT
EXO_HEADLESS=true makes createWindow skip mainWindow.show() (see
src/main/window.ts) and hides the macOS dock icon, so the app does not
pop visibly or steal focus — the renderer is still fully alive and CDP-attachable.
Always pass it; there is no reason to run this skill non-headlessly.npm run dev either fails to bind or, worse, your MCP attaches to the sibling's
Electron — you'd be inspecting and "fixing" their renderer, not yours.npm run dev is preferred over npx electron-vite dev directly because the
dev script also runs npm run build:worker first — without that, the agent
sidebar fails to start with "Agent worker failed to start" since the bundled
utility-process worker file is missing.http://127.0.0.1:<PORT> when launched with --remote-debugging-port=<PORT><PORT> is 9223, the chrome-devtools MCP connects and you can use its tools<PORT> is anything else, the MCP can't reach it — use direct CDP from a small node script (see Fallback)Start the app headless on a free port (run in background so the terminal is free):
PORT=9223
while lsof -ti:$PORT >/dev/null 2>&1; do PORT=$((PORT+1)); done
EXO_DEMO_MODE=true EXO_HEADLESS=true npm run dev -- --remote-debugging-port=$PORT
Wait for CDP to come up before doing anything else (the dev-server log is unreliable — wait on the CDP endpoint itself):
until curl -sf http://127.0.0.1:$PORT/json/version >/dev/null 2>&1; do sleep 1; done
The renderer process runs normally and is reachable over CDP; only the visible window and dock icon are suppressed.
List available pages:
Use mcp__chrome-devtools__list_pages to see Electron's renderer windows.
Select the main window:
Use mcp__chrome-devtools__select_page with the page ID of the main app window (not DevTools or blank pages).
Take a snapshot to see the current UI state:
Use mcp__chrome-devtools__take_snapshot to get an accessibility tree of the page.
Interact with the app:
mcp__chrome-devtools__click — click buttons, links, tabsmcp__chrome-devtools__fill — type into inputs and textareasmcp__chrome-devtools__take_screenshot — capture visual statemcp__chrome-devtools__evaluate_script — run JS in the renderer contextStop the app when done by killing the background process.
| Action | How |
|---|---|
| Open Settings | Click the gear icon in the top bar |
| Switch to Prompts tab | Click "Prompts" tab inside Settings |
| Edit a prompt | Click into the textarea and modify text |
| Save prompts | Click the "Save" button |
| Close Settings | Click "X" or press Escape |
| Switch accounts | Click account selector in the sidebar |
The MCP only attaches to whatever port it was configured with at startup (9223 here).
If the port probe picked something else (9224, 9225, …) because a sibling worktree
already had 9223, the MCP tools won't work for this run. Drive CDP directly from a
small node script — no install needed, Node 22+ ships a global WebSocket:
// /tmp/cdp.mjs — run with `CDP_PORT=<port> node /tmp/cdp.mjs`
import { writeFileSync } from "fs";
const PORT = process.env.CDP_PORT;
const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json();
const page = list.find(p => p.type === "page");
if (!page) throw new Error(`No page target on port ${PORT} yet — /json/version can succeed before the renderer registers; retry in a moment`);
const ws = new WebSocket(page.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener("message", e => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
const send = (method, params = {}) => new Promise(r => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method, params })); });
await new Promise(r => ws.addEventListener("open", r, { once: true }));
await send("Runtime.enable");
// CDP comes up before React has painted. Poll for a known-rendered marker
// before doing anything — otherwise your first screenshot is a blank canvas.
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
const r = await send("Runtime.evaluate", {
expression: `document.body && document.body.innerText.includes("Compose")`,
returnByValue: true,
});
if (r.result?.result?.value === true) break;
await new Promise(x => setTimeout(x, 250));
}
// example: click a button by visible text, then screenshot the result
await send("Runtime.evaluate", {
expression: `Array.from(document.querySelectorAll('button')).find(b => /compose/i.test(b.textContent || ""))?.click()`,
});
await new Promise(r => setTimeout(r, 500));
const shot = await send("Page.captureScreenshot", { format: "png" });
writeFileSync(".context/shot.png", Buffer.from(shot.result.data, "base64"));
ws.close();
Runtime.evaluate, Page.captureScreenshot, Input.dispatchKeyEvent, and
Input.dispatchMouseEvent cover everything the MCP would have given you.
Do NOT import { WebSocket } from "ws" — the ws package isn't a project
dependency, and only resolves accidentally from random parent node_modules.
EXO_DEMO_MODE=true, the app uses mock data and makes no real Gmail API calls. Useful for testing UI without credentials.lsof -ti:$PORT gives the PID; ps -p <pid> -o command= should show a path under
this worktree (e.g. .../casablanca/node_modules/electron/...). If it points at a
sibling worktree, something raced past the port probe — pick the next port and relaunch.pkill -f "electron-vite dev" doesn't always kill all
child Electron processes from prior dev sessions. If you attach to a stale Electron
whose source code is out of date with the disk, code changes will appear to silently
no-op. Scope the kill to this worktree's path and your chosen port so you don't
nuke sibling agents:
WORKTREE=$(basename "$PWD")
pkill -9 -f "$WORKTREE.*Electron.app" 2>/dev/null
pkill -9 -f "$WORKTREE.*electron-vite dev.*--remote-debugging-port=$PORT" 2>/dev/null
Then verify with lsof -ti:$PORT returning empty.type === "page", not "background_page" or "other") before interacting.electron-vite dev supports HMR for the renderer. After main-process code changes, you must restart the app — main-process code does NOT hot-reload.src/main/agents/agent-worker.ts and code it imports get bundled into out/worker/agent-worker.cjs by npm run build:worker. After changes to the agent worker, the bundle must rebuild — npm run dev does this automatically; npx electron-vite dev does NOT.To trigger an agent task without simulating Cmd+J + typing in the palette:
mcp__chrome-devtools__evaluate_script({
function: `async () => {
const taskId = "test-" + Date.now();
return await window.api.agent.run(
taskId,
["claude"], // provider id is "claude" (not "claude-agent")
"Reply with OK",
{ accountId: "default", userEmail: "me@example.com" }
);
}`,
})
Then poll the trace:
mcp__chrome-devtools__evaluate_script({
function: `async () => await window.api.agent.getTrace("<taskId>")`,
})
The trace returns { events: [...] } with state, tool_call_start, tool_call_end, text_delta, and done events.