/** * 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(() => {}); } }