339 lines
12 KiB
JavaScript
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(() => {});
|
|
}
|
|
}
|