Files
dashfront/test/cdp.mjs
2026-10-11 14:06:13 +02:00

339 lines
12 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) => {
const timer = setTimeout(() => {
if (this.pending.has(callId)) {
this.pending.delete(callId);
reject(new Error(`CDP timeout: ${method}`));
}
}, timeoutMs);
// Don't let a settled call's timer hold the process open.
timer.unref?.();
this.pending.set(callId, {
resolve: (v) => {
clearTimeout(timer);
resolve(v);
},
reject: (e) => {
clearTimeout(timer);
reject(e);
},
});
this.sendFrame(0x1, Buffer.from(JSON.stringify({ id: callId, method, params })));
});
}
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, dialog, icons }) {
// 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);
// If a dialog needs to be opened before capturing:
if (dialog) {
if (dialog.name === 'settings') {
// Open user avatar menu
await session.send('Runtime.evaluate', {
expression: `(() => {
const btn = document.querySelector('nav .MuiAvatar-root')?.closest('button');
if (btn) btn.click();
})()`,
});
await wait(300);
// Click "Einstellungen" menu item
await session.send('Runtime.evaluate', {
expression: `(() => {
const item = Array.from(document.querySelectorAll('.MuiMenuItem-root'))
.find(el => el.textContent.includes('Einstellungen'));
if (item) item.click();
})()`,
});
await wait(400);
// Click specific tab if requested
if (dialog.tab) {
await session.send('Runtime.evaluate', {
expression: `(() => {
const target = ${JSON.stringify(dialog.tab.toLowerCase())};
const tab = Array.from(document.querySelectorAll('[role="dialog"] [role="tab"]'))
.find(el => el.textContent.trim().toLowerCase() === target || el.textContent.toLowerCase().includes(target));
if (tab) tab.click();
})()`,
});
await wait(300);
}
// Wait for any dialog-internal loading spinners to settle
const dialogDeadline = Date.now() + 15000;
while (Date.now() < dialogDeadline) {
const res = await session.send('Runtime.evaluate', {
expression: `(() => {
const spinners = document.querySelectorAll('[role="dialog"] [role="progressbar"]').length;
return { spinners };
})()`,
returnByValue: true,
});
if ((res.result?.value?.spinners ?? 0) === 0) break;
await wait(300);
}
await wait(800);
}
}
// Icon sheet: replace the page by a white sheet with every distinct icon
// (inline SVG) found on it, drawn in black.
if (icons) {
await session.send('Runtime.evaluate', {
expression: `(() => {
const seen = new Map();
for (const svg of document.querySelectorAll('svg')) {
const r = svg.getBoundingClientRect();
if (r.width > 64 || r.height > 64) continue; // skip illustrations / spinners
if (svg.closest('[role="progressbar"]')) continue;
const key = svg.getAttribute('data-testid') || svg.innerHTML;
if (!svg.innerHTML || seen.has(key)) continue;
seen.set(key, svg);
}
const style = document.createElement('style');
style.textContent = \`
#icon-sheet{position:absolute;inset:0;background:#fff;color:#000;padding:32px;
display:grid;grid-template-columns:repeat(7,144px);gap:36px 32px;align-content:flex-start}
#icon-sheet .cell{width:144px;display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center}
#icon-sheet svg{width:144px;height:144px;color:#000;fill:#000}
#icon-sheet svg *{fill:#000;color:#000}
#icon-sheet svg [stroke]:not([stroke=none]){stroke:#000}
#icon-sheet .label{font:16px/1.2 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;font-weight:600;color:#000}
\`;
document.head.appendChild(style);
document.getElementById('root').style.display = 'none';
const sheet = document.createElement('div');
sheet.id = 'icon-sheet';
const LABELS = [
'Brand', 'Stack', 'Attributes', 'Info', 'History', 'Edit', 'Customer',
'Save', 'Label', 'Order', 'Settings', 'Article', 'Chat', 'Delivery',
'Tree', 'Warehouse', 'Options', 'List', 'Details', 'Default', 'Tiles List',
];
const sorted = [...seen.entries()].sort((a, b) => String(a[0]).localeCompare(String(b[0])));
let index = 0;
for (const [key, svg] of sorted) {
const cell = document.createElement('div');
cell.className = 'cell';
const clone = svg.cloneNode(true);
clone.removeAttribute('class');
clone.removeAttribute('style');
clone.removeAttribute('width');
clone.removeAttribute('height');
cell.appendChild(clone);
const label = document.createElement('span');
label.className = 'label';
label.textContent = LABELS[index] || svg.getAttribute('data-testid') || '';
index++;
cell.appendChild(label);
sheet.appendChild(cell);
}
document.body.style.background = '#fff';
document.body.appendChild(sheet);
})()`,
});
await wait(300);
}
// 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,
);
const buf = Buffer.from(data, 'base64');
if (file) fs.writeFileSync(file, buf);
return buf;
} finally {
session.close();
await fetch(`http://127.0.0.1:${debugPort}/json/close/${target.id}`).catch(() => {});
}
}