/** * Dev-only CDP screenshot harness (any Chromium-CDP browser: Chrome, * Brave, Edge). Unlike `--headless --screenshot` (which fires at first * paint), this waits for the page to signal readiness before capturing. * * node dev/cdp-shot.mjs [readyExpr] [timeoutMs] [beforeCaptureExpr] * * readyExpr is a JS expression evaluated repeatedly in the page; the shot * is taken when it returns a truthy value (default: none → capture * shortly after load). The expression's truthy value is printed. * beforeCaptureExpr (optional) runs once right before the capture (e.g. * to hide dev overlays). * * Requires a headless browser exposing CDP on 127.0.0.1:9333: * /opt/brave.com/brave/brave --headless --disable-gpu \ * --remote-debugging-port=9333 about:blank * (or point CDP_PORT at another one.) */ const [url, out, readyExpr, timeoutMs, beforeCaptureExpr] = process.argv.slice(2); if (!url || !out) { console.error('usage: node dev/cdp-shot.mjs [readyExpr] [timeoutMs]'); process.exit(2); } const CDP = process.env.CDP_PORT ? `127.0.0.1:${process.env.CDP_PORT}` : '127.0.0.1:9333'; const deadline = Date.now() + Number(timeoutMs ?? 60000); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function main() { // Pick the first page target (or open one). let target = await (await fetch(`http://${CDP}/json/list`)).json() .then((ts) => ts.find((t) => t.type === 'page')); if (!target) { target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' })).json(); } const ws = new WebSocket(target.webSocketDebuggerUrl); let id = 0; const pending = new Map(); const send = (method, params = {}) => new Promise((res, rej) => { const mid = ++id; pending.set(mid, { res, rej }); ws.send(JSON.stringify({ id: mid, method, params })); }); const events = []; await new Promise((res, rej) => { ws.onopen = res; ws.onerror = (e) => rej(new Error('ws error: ' + (e?.message ?? '?'))); }); ws.onmessage = (m) => { const msg = JSON.parse(m.data); if (msg.id && pending.has(msg.id)) { const { res, rej } = pending.get(msg.id); pending.delete(msg.id); msg.error ? rej(new Error(msg.error.message)) : res(msg.result); } else if (msg.method) { events.push(msg); } }; await send('Page.enable'); await send('Runtime.enable'); await send('Page.navigate', { url }); const loaded = new Promise((res) => { const t = setInterval(() => { if (events.some((e) => e.method === 'Page.loadEventFired')) { clearInterval(t); res(); } }, 50); setTimeout(() => { clearInterval(t); res(); }, 20000); }); await loaded; const evaluate = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error('page: ' + JSON.stringify(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text)); return r.result?.value; }; let ready = null; if (readyExpr) { while (Date.now() < deadline) { ready = await evaluate(readyExpr).catch(() => null); if (ready) break; await sleep(200); } } else { await sleep(1000); } if (beforeCaptureExpr) await evaluate(beforeCaptureExpr).catch(() => null); // One more beat so any final paint lands. await sleep(250); const shot = await send('Page.captureScreenshot', { format: 'png' }); const buf = Buffer.from(shot.data, 'base64'); await (await import('node:fs/promises')).writeFile(out, buf); ws.close(); console.log(`screenshot: ${out} (${buf.length} bytes)` + (ready ? ` · ready=${JSON.stringify(ready).slice(0, 400)}` : '')); } main().catch((err) => { console.error('cdp-shot failed:', err.message); process.exit(1); });