u
This commit is contained in:
215
test/cdp.mjs
Normal file
215
test/cdp.mjs
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* 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(() => {});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user