Puppeteer CDP relay: "No page targets available" and 30s timeouts driving user's Chrome on macOS
Driving the user's own Chrome through the omp browser relay (app.relay: true / app.cdp_url: http://127.0.0.1:9224) fails in two ways that both look like a broken relay but are not:
- The
browsertool'sopenreturnsNo page targets available on the attached browser, even thoughcurl localhost:9224/json/versionreturns 200 with a real Chrome UA (Chrome/151.0.0.0).curl localhost:9224/json/listreturns[]. - After attaching, every puppeteer high-level call fails identically at 30 s:
tab.evaluate,page.evaluate,tab.click('eN'),page.bringToFront()->TimeoutError: Timed out after waiting 30000ms. RawRuntime.evaluateon the same target works fine.
The documented readiness check only distinguishes 200 = ready from 503 = extension not attached, so 200-with-zero-targets reads as "ready" and sends you hunting the wrong problem.
Two independent causes
(1) Chrome running with zero tabs. On macOS Chrome stays alive as a process after every window is closed. The relay extension builds its hello payload from chrome.tabs.query({}), so it reports a valid browserVersion (hence HTTP 200 on /json/version) with tabs: []. There is nothing for the relay to adopt. 200 + empty /json/list is a third readiness state. Always check /json/list, not just /json/version.
(2) The relay proxies CDP commands but not lifecycle events. It forwards chrome.debugger.sendCommand, so commands succeed, but it does not forward the frame-lifecycle / execution-context events puppeteer's high-level API awaits — those promises never resolve. Browser.* domain commands are also unavailable (tab-scoped debugger): Browser.getVersion -> -32601 wasn't found.
Fix 1 — create a tab with LaunchServices, NOT AppleScript
open -g -a "Google Chrome" "https://example.com/" # -g = don't steal focus
sleep 6
curl -s localhost:9224/json/list | jq length # now 1The trap: osascript -e 'tell application "Google Chrome" to ...' cost 240 s of AppleEvent timeouts (-1712) across two attempts and produced nothing. Chrome's browser process does not reliably service AppleEvents in this state, and a pending Automation-permission consent dialog presents as the same timeout. open -g needs no permission and worked in 6 s.
Fix 2 — drive raw CDP instead of puppeteer
const s = await page.target().createCDPSession();
const ev = async (expression) => {
const r = await s.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error(JSON.stringify(r.exceptionDetails).slice(0, 400));
return r.result.value;
};
const click = async (x, y) => {
await s.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, button: 'none', buttons: 0, clickCount: 0 });
await s.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1 });
await s.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1 });
};
await s.send('Page.navigate', { url }); // instead of page.gotoPoll readiness yourself: loop on ev('document.readyState') plus a stable document.body.innerText.length.
Bonus: Input.dispatchMouseEvent is trusted input, which also defeats the separate problem that Google's consoles (Search Console menus, the More -> Compare date dialog) ignore synthetic el.click() and PointerEvent dispatch. Take x/y from getBoundingClientRect() inside Runtime.evaluate.
Fix 3 — widen the viewport instead of scrolling
Coordinates outside innerWidth are silently dropped. The real window was 827 px wide and the target column header sat at x=1003. Rather than horizontal-scrolling:
await s.send('Emulation.setDeviceMetricsOverride', { width: 1680, height: 1300, deviceScaleFactor: 1, mobile: false });Re-apply per CDP session; a fresh session drops the override.
Verified
Ran a full three-console sweep on raw CDP after open -g -a: Google Search Console (including the More -> Compare -> Apply date dialog, column-header sorting, and the EXPORT -> Download CSV menu) and Ahrefs Site Audit's data explorer. Every puppeteer high-level call had failed at 30 s beforehand against the same target.
Two DOM gotchas that cost extra retries
- Material Symbols ligatures put a private-use codepoint at the start of
innerText, so an EXPORT trigger reads"\nEXPORT"and a menu item reads"\nDownload CSV".innerText.trim() === 'EXPORT'fails while looking correct — match withincludes()oraria-label. [role=menuitem]and[role=dialog]elements persist in the DOM while closed. Always filtergetBoundingClientRect().width > 0or you will "find" a menu you never opened.