// Minimal CDP driver: node cdp_inspect.mjs [scriptFile] // Launches headless Chromium, connects over the DevTools WebSocket (Node's // built-in WebSocket — no dependencies), evaluates one async script, prints JSON. import { spawn } from 'node:child_process' import { mkdtempSync, readFileSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' const [chromeBin, targetUrl, scriptFile] = process.argv.slice(2) const userDataDir = mkdtempSync(join(tmpdir(), 'cdp-')) const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) // Optional --window-size=WxH, so responsive behaviour can be checked at real sizes. const windowSizeArg = process.argv.find((arg) => arg.startsWith('--window-size=')) const windowSize = windowSizeArg ? windowSizeArg.split('=')[1] : null const chrome = spawn(chromeBin, [ '--headless=new', '--no-sandbox', '--disable-dev-shm-usage', // Headless Chrome wants `--window-size=W,H`; a `WxH` string is accepted here too. ...(windowSize ? [`--window-size=${windowSize.replace('x', ',')}`] : []), // Software WebGL2: headless has no GPU, and SwiftShader is the only way to // exercise the renderer path in CI-like runs. '--use-angle=swiftshader', '--use-gl=angle', '--enable-unsafe-swiftshader', // Never route a localhost check through a proxy. gLinux sets the GNOME proxy // mode to `auto`, and Chrome honours it — PAC and all — even for `127.0.0.1`. // This is a precaution, not a diagnosed fix: it did not by itself revive the // harness in a sandbox where Chrome had no network at all. See the README. '--no-proxy-server', `--user-data-dir=${userDataDir}`, '--remote-debugging-port=0', targetUrl, ], { stdio: ['ignore', 'ignore', 'pipe'] }) let stderr = '' chrome.stderr.on('data', (d) => { stderr += d }) let port = null for (let i = 0; i < 100; i++) { await sleep(200) try { port = readFileSync(join(userDataDir, 'DevToolsActivePort'), 'utf8').trim().split('\n')[0] if (port) break } catch {} } if (!port) { console.error('NO PORT', stderr.slice(-500)); process.exit(1) } /** * The page target actually showing `targetUrl`. * * Attaching to "the first target of type `page`" looks right and is wrong. * Chrome creates a transient startup tab *before* it opens the URL from the * command line, so a driver that polls every 200 ms wins that race more often * than not, attaches to the blank tab, and then evaluates against a document * that has no scripts and no state. The symptom is indistinguishable from a * page that failed to boot — the check just reports "no state", and you go * looking for the bug in the application. * * So: wait for a page whose URL is the one asked for. Only if that never * appears do we fall back, and then we say so on stderr. */ let page for (let i = 0; i < 150; i++) { await sleep(200) try { const list = await (await fetch(`http://127.0.0.1:${port}/json`)).json() const pages = list.filter((x) => x.type === 'page') const matched = pages.find((x) => x.url === targetUrl) if (matched) { page = matched; break } // Keep the best non-blank candidate around in case the URL is normalised // (a trailing slash, a fragment) and never compares equal. page = pages.find((x) => x.url !== 'about:blank' && x.url !== '') ?? page } catch {} } if (!page) { console.error('NO PAGE', stderr.slice(-500)); process.exit(1) } if (page.url !== targetUrl) console.error(`WARNING attached to ${page.url}, wanted ${targetUrl}`) const ws = new WebSocket(page.webSocketDebuggerUrl) await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject }) let msgId = 0 const pending = new Map() ws.onmessage = (event) => { const message = JSON.parse(event.data) if (message.id && pending.has(message.id)) { pending.get(message.id)(message); pending.delete(message.id) } } const send = (method, params = {}) => new Promise((resolve) => { const id = ++msgId pending.set(id, resolve) ws.send(JSON.stringify({ id, method, params })) }) await send('Runtime.enable') await send('Page.enable') await sleep(3000) const expression = scriptFile ? await readFile(scriptFile, 'utf8') : `({ title: document.title, text: document.body.innerText.slice(0, 2000) })` const result = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }) const shotIndex = process.argv.indexOf('--screenshot') if (shotIndex !== -1) { const shotPath = process.argv[shotIndex + 1] const shot = await send('Page.captureScreenshot', { format: 'png' }) const data = shot.result?.data if (typeof data === 'string') { const { writeFileSync } = await import('node:fs') writeFileSync(shotPath, Buffer.from(data, 'base64')) console.error(`screenshot written: ${shotPath}`) } } if (result.result?.exceptionDetails) { console.error('EVAL ERROR', JSON.stringify(result.result.exceptionDetails).slice(0, 1500)) } else { console.log(JSON.stringify(result.result?.result?.value, null, 2)) } ws.close() chrome.kill() process.exit(0)