216 lines
6.6 KiB
JavaScript
216 lines
6.6 KiB
JavaScript
/**
|
|
* CDP helpers for the visual testing rig (no npm deps).
|
|
* Connects to a Chrome remote-debugging port, opens a fresh page target per
|
|
* shot, waits for the app to settle, captures a full-page PNG.
|
|
*/
|
|
import net from 'node:net';
|
|
import fs from 'node:fs';
|
|
|
|
function wait(ms) {
|
|
return new Promise((r) => setTimeout(r, ms));
|
|
}
|
|
|
|
class CdpConnection {
|
|
constructor(socket) {
|
|
this.socket = socket;
|
|
this.id = 0;
|
|
this.pending = new Map();
|
|
this.buf = Buffer.alloc(0);
|
|
this.handlers = new Map();
|
|
socket.on('data', (chunk) => this.onData(chunk));
|
|
}
|
|
|
|
onData(chunk) {
|
|
this.buf = Buffer.concat([this.buf, chunk]);
|
|
for (;;) {
|
|
if (this.buf.length < 2) return;
|
|
const opcode = this.buf[0] & 0x0f;
|
|
let len = this.buf[1] & 0x7f;
|
|
let off = 2;
|
|
if (len === 126) {
|
|
if (this.buf.length < 4) return;
|
|
len = this.buf.readUInt16BE(2);
|
|
off = 4;
|
|
} else if (len === 127) {
|
|
if (this.buf.length < 10) return;
|
|
len = Number(this.buf.readBigUInt64BE(2));
|
|
off = 10;
|
|
}
|
|
if ((this.buf[1] & 0x80) !== 0) off += 4;
|
|
if (this.buf.length < off + len) return;
|
|
const payload = this.buf.subarray(off, off + len);
|
|
this.buf = this.buf.subarray(off + len);
|
|
if (opcode === 0x8) {
|
|
this.socket.end();
|
|
return;
|
|
}
|
|
if (opcode !== 0x1) continue;
|
|
let msg;
|
|
try {
|
|
msg = JSON.parse(payload.toString('utf8'));
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (msg.id != null && this.pending.has(msg.id)) {
|
|
const { resolve, reject } = this.pending.get(msg.id);
|
|
this.pending.delete(msg.id);
|
|
if (msg.error) reject(new Error(`${msg.error.code}: ${msg.error.message}`));
|
|
else resolve(msg.result);
|
|
} else if (msg.method && this.handlers.has(msg.method)) {
|
|
for (const fn of this.handlers.get(msg.method)) {
|
|
try {
|
|
fn(msg.params);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
sendFrame(opcode, payload) {
|
|
const len = payload.length;
|
|
let header;
|
|
if (len < 126) header = Buffer.from([0x80 | opcode, 0x80 | len, 0, 0, 0, 0]);
|
|
else if (len < 65536) {
|
|
header = Buffer.alloc(8);
|
|
header[0] = 0x80 | opcode;
|
|
header[1] = 0x80 | 126;
|
|
header.writeUInt16BE(len, 2);
|
|
} else {
|
|
header = Buffer.alloc(14);
|
|
header[0] = 0x80 | opcode;
|
|
header[1] = 0x80 | 127;
|
|
header.writeBigUInt64BE(BigInt(len), 2);
|
|
}
|
|
this.socket.write(Buffer.concat([header, payload]));
|
|
}
|
|
|
|
send(method, params = {}, timeoutMs = 30000) {
|
|
const callId = ++this.id;
|
|
return new Promise((resolve, reject) => {
|
|
this.pending.set(callId, { resolve, reject });
|
|
this.sendFrame(0x1, Buffer.from(JSON.stringify({ id: callId, method, params })));
|
|
setTimeout(() => {
|
|
if (this.pending.has(callId)) {
|
|
this.pending.delete(callId);
|
|
reject(new Error(`CDP timeout: ${method}`));
|
|
}
|
|
}, timeoutMs);
|
|
});
|
|
}
|
|
|
|
on(method, fn) {
|
|
if (!this.handlers.has(method)) this.handlers.set(method, new Set());
|
|
this.handlers.get(method).add(fn);
|
|
return () => this.handlers.get(method)?.delete(fn);
|
|
}
|
|
|
|
close() {
|
|
try {
|
|
this.sendFrame(0x8, Buffer.alloc(0));
|
|
} catch {
|
|
// ignore
|
|
}
|
|
this.socket.end();
|
|
}
|
|
}
|
|
|
|
export async function cdpConnect(wsUrl) {
|
|
const url = new URL(wsUrl);
|
|
const key = Buffer.from(`rig-${Date.now()}`).toString('base64');
|
|
const socket = net.connect(Number(url.port), url.hostname);
|
|
await new Promise((resolve, reject) => {
|
|
socket.on('connect', () => {
|
|
socket.write(
|
|
`GET ${url.pathname}${url.search} HTTP/1.1\r\nHost: ${url.hostname}:${url.port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n\r\n`,
|
|
);
|
|
});
|
|
socket.on('error', reject);
|
|
let head = '';
|
|
const onData = (chunk) => {
|
|
head += chunk.toString('latin1');
|
|
if (head.includes('\r\n\r\n')) {
|
|
socket.off('data', onData);
|
|
resolve();
|
|
}
|
|
};
|
|
socket.on('data', onData);
|
|
});
|
|
return new CdpConnection(socket);
|
|
}
|
|
|
|
async function httpJson(url, options = {}) {
|
|
const res = await fetch(url, options);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* Opens a new page target on the browser, navigates, waits for the app root
|
|
* to contain content + network to quiet down, captures a PNG.
|
|
*/
|
|
export async function capturePage(debugPort, url, file, { width, height, settleMs = 6000 }) {
|
|
// Chrome ≥136 requires PUT for /json/new.
|
|
const target = await httpJson(`http://127.0.0.1:${debugPort}/json/new?${encodeURIComponent(url)}`, {
|
|
method: 'PUT',
|
|
});
|
|
const session = await cdpConnect(target.webSocketDebuggerUrl);
|
|
try {
|
|
await session.send('Page.enable');
|
|
await session.send('Runtime.enable');
|
|
await session.send('Emulation.setDeviceMetricsOverride', {
|
|
width,
|
|
height,
|
|
deviceScaleFactor: 1,
|
|
mobile: false,
|
|
});
|
|
await session.send('Page.navigate', { url });
|
|
|
|
// Wait until #root has substantial content (React hydrated + data loaded).
|
|
const deadline = Date.now() + 45000;
|
|
for (;;) {
|
|
const res = await session.send(
|
|
'Runtime.evaluate',
|
|
{
|
|
expression: `(() => {
|
|
const root = document.getElementById('root');
|
|
const len = root ? root.innerHTML.length : 0;
|
|
const spinners = document.querySelectorAll('[role="progressbar"]').length;
|
|
return { len, spinners };
|
|
})()`,
|
|
returnByValue: true,
|
|
},
|
|
15000,
|
|
);
|
|
const { len, spinners } = res.result?.value ?? { len: 0, spinners: 99 };
|
|
if (len > 20000 && spinners === 0) break;
|
|
if (Date.now() > deadline) {
|
|
console.warn(`[capture] settle timeout for ${url} (root=${len}, spinners=${spinners})`);
|
|
break;
|
|
}
|
|
await wait(500);
|
|
}
|
|
// Extra quiet period for images / thumbhash decode / layout.
|
|
await wait(settleMs);
|
|
|
|
// Determinism: hide carets and scrollbars.
|
|
await session.send('Runtime.evaluate', {
|
|
expression: `(() => { const s = document.createElement('style');
|
|
s.textContent = '*{caret-color:transparent !important} ::-webkit-scrollbar{display:none !important}';
|
|
document.head.appendChild(s); })()`,
|
|
});
|
|
await wait(400);
|
|
|
|
const { data } = await session.send(
|
|
'Page.captureScreenshot',
|
|
{ format: 'png', captureBeyondViewport: true, fromSurface: true },
|
|
30000,
|
|
);
|
|
fs.writeFileSync(file, Buffer.from(data, 'base64'));
|
|
} finally {
|
|
session.close();
|
|
await fetch(`http://127.0.0.1:${debugPort}/json/close/${target.id}`).catch(() => {});
|
|
}
|
|
}
|