#!/usr/bin/env node import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { spawn } from 'node:child_process'; const urls = [ 'https://me.bugabinga.net/', 'https://site.bugabinga.net/', 'https://pkg.bugabinga.net/', 'https://vcs.bugabinga.net/', 'https://ci.bugabinga.net/', 'https://home.bugabinga.net/', 'https://media.bugabinga.net/', 'https://docs.bugabinga.net/', 'https://vault.bugabinga.net/', 'https://fin.bugabinga.net/', 'https://tracks.bugabinga.net/', 'https://registry.bugabinga.net/home', 'https://genie.bugabinga.net/', 'https://quest.bugabinga.net/', ]; const outDir = process.argv[2] || '/tmp/nugu-real-chrome-current'; const chromeBin = process.env.CHROME_BIN || 'google-chrome-stable'; mkdirSync(outDir, { recursive: true }); class CDP { constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map(); this.handlers = new Map(); } static connect(url) { return new Promise((resolve, reject) => { const ws = new WebSocket(url); ws.onopen = () => { const cdp = new CDP(ws); cdp.pump(); resolve(cdp); }; ws.onerror = reject; }); } on(method, fn) { if (!this.handlers.has(method)) this.handlers.set(method, []); this.handlers.get(method).push(fn); } send(method, params = {}, sessionId) { const id = ++this.id; const msg = { id, method, params }; if (sessionId) msg.sessionId = sessionId; this.ws.send(JSON.stringify(msg)); return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`${method} timeout`)); }, 30000); this.pending.set(id, { resolve, reject, timer }); }); } pump() { this.ws.onmessage = event => { const msg = JSON.parse(event.data); if (msg.id && this.pending.has(msg.id)) { const pending = this.pending.get(msg.id); clearTimeout(pending.timer); this.pending.delete(msg.id); if (msg.error) pending.reject(new Error(`${msg.error.message}: ${msg.error.data || ''}`)); else pending.resolve(msg.result || {}); return; } for (const fn of this.handlers.get(msg.method) || []) fn(msg.params || {}, msg.sessionId); }; } close() { this.ws.close(); } } const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); const slug = url => new URL(url).hostname.split('.')[0]; async function chromeWs(portFile) { for (let i = 0; i < 100; i++) { try { const [port, browserPath] = readFileSync(portFile, 'utf8').trim().split('\n'); const version = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json(); return version.webSocketDebuggerUrl || `ws://127.0.0.1:${port}${browserPath}`; } catch { await sleep(100); } } throw new Error(`Chrome did not write ${portFile}`); } async function verifyOne(cdp, url, mode) { const target = await cdp.send('Target.createTarget', { url: 'about:blank' }); const { sessionId } = await cdp.send('Target.attachToTarget', { targetId: target.targetId, flatten: true }); const events = { logs: [], exceptions: [], failed: [] }; cdp.on('Log.entryAdded', params => events.logs.push({ level: params.entry.level, text: params.entry.text, url: params.entry.url || '' })); cdp.on('Runtime.exceptionThrown', params => events.exceptions.push({ text: params.exceptionDetails?.text || '', description: params.exceptionDetails?.exception?.description || '', url: params.exceptionDetails?.url || '', lineNumber: params.exceptionDetails?.lineNumber, columnNumber: params.exceptionDetails?.columnNumber, })); cdp.on('Network.loadingFailed', params => events.failed.push({ errorText: params.errorText, canceled: params.canceled, type: params.type })); await cdp.send('Log.enable', {}, sessionId); await cdp.send('Runtime.enable', {}, sessionId); await cdp.send('Network.enable', {}, sessionId); await cdp.send('Page.enable', {}, sessionId); const mobile = mode === 'mobile'; await cdp.send('Emulation.setDeviceMetricsOverride', { width: mobile ? 390 : 1440, height: mobile ? 844 : 1000, deviceScaleFactor: 1, mobile }, sessionId); await cdp.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: 'light' }] }, sessionId); await cdp.send('Page.navigate', { url }, sessionId).catch(error => events.logs.push({ level: 'error', text: `navigate failed: ${error.message}`, url })); await sleep(5500); const { result } = await cdp.send('Runtime.evaluate', { returnByValue: true, expression: `(() => { const s = getComputedStyle(document.documentElement); const b = document.body && getComputedStyle(document.body); const r = document.documentElement; const parseColor = value => { const m = String(value || '').match(/rgba?\\(([^)]+)\\)/); if (!m) return null; const parts = m[1].split(',').map(p => Number.parseFloat(p)); return { r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1 }; }; const luminance = c => { const channel = v => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; return 0.2126 * channel(c.r) + 0.7152 * channel(c.g) + 0.0722 * channel(c.b); }; const contrast = (fg, bg) => { const a = luminance(fg); const z = luminance(bg); return (Math.max(a, z) + 0.05) / (Math.min(a, z) + 0.05); }; const effectiveBackground = el => { for (let n = el; n; n = n.parentElement) { const c = parseColor(getComputedStyle(n).backgroundColor); if (c && c.a > 0.7) return c; } const bodyBg = parseColor(b && b.backgroundColor); return bodyBg && bodyBg.a > 0.7 ? bodyBg : { r: 255, g: 255, b: 255, a: 1 }; }; const hasBackgroundImageBehind = el => { for (let n = el; n; n = n.parentElement) { const style = getComputedStyle(n); if (style.backgroundImage && style.backgroundImage !== 'none') return true; } return false; }; const visibleTextElements = [...document.querySelectorAll('body *')] .filter(el => { const rect = el.getBoundingClientRect(); const style = getComputedStyle(el); const text = [...el.childNodes].filter(n => n.nodeType === Node.TEXT_NODE).map(n => n.textContent).join(' ').replace(/\\s+/g, ' ').trim(); const hiddenClass = typeof el.className === 'string' && /(^|\\s)(sr-only|visually-hidden)(\\s|$)/.test(el.className); return text && !hiddenClass && rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none' && Number(style.opacity) > 0.2; }); const contrastIssues = visibleTextElements .map(el => { const style = getComputedStyle(el); const fg = parseColor(style.color); const ownBg = parseColor(style.backgroundColor); if ((!ownBg || ownBg.a <= 0.7) && hasBackgroundImageBehind(el)) return null; const bg = effectiveBackground(el); if (!fg || !bg) return null; const ratio = contrast(fg, bg); const fontSize = Number.parseFloat(style.fontSize) || 16; const fontWeight = Number.parseFloat(style.fontWeight) || 400; const threshold = fontSize >= 24 || (fontSize >= 18.66 && fontWeight >= 700) ? 3 : 4.5; const rect = el.getBoundingClientRect(); return ratio < threshold ? { tag: el.tagName.toLowerCase(), className: typeof el.className === 'string' ? el.className.slice(0, 120) : '', text: [...el.childNodes].filter(n => n.nodeType === Node.TEXT_NODE).map(n => n.textContent).join(' ').replace(/\\s+/g, ' ').trim().slice(0, 120), ratio: Math.round(ratio * 100) / 100, threshold, color: style.color, background: getComputedStyle(el).backgroundColor, effectiveBackground: bg, fontSize, fontWeight, left: Math.round(rect.left), top: Math.round(rect.top), } : null; }) .filter(Boolean) .slice(0, 20); return { url: location.href, title: document.title, nuguNormal: s.getPropertyValue('--nugu-content-normal').trim(), nuguBackdrop: s.getPropertyValue('--nugu-content-backdrop').trim(), nuguAccent: s.getPropertyValue('--nugu-ui-accent').trim(), bodyBg: b && b.backgroundColor, scrollWidth: r.scrollWidth, clientWidth: r.clientWidth, overflowX: r.scrollWidth - r.clientWidth, ready: document.readyState, links: [...document.links].length, brokenImages: [...document.images].filter(i => i.complete && i.naturalWidth === 0).map(i => i.src).slice(0, 10), overflowElements: [...document.querySelectorAll('body *')] .map(el => { const rect = el.getBoundingClientRect(); const style = getComputedStyle(el); return { tag: el.tagName.toLowerCase(), id: el.id || '', className: typeof el.className === 'string' ? el.className.slice(0, 120) : '', text: (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 120), left: Math.round(rect.left), right: Math.round(rect.right), width: Math.round(rect.width), position: style.position, }; }) .filter(el => el.width > 0 && (el.left < -1 || el.right > r.clientWidth + 1 || el.width > r.clientWidth + 1)) .sort((a, b) => (b.right - r.clientWidth) - (a.right - r.clientWidth)) .slice(0, 10), contrastIssues, }; })()`, }, sessionId); const screenshot = join(outDir, `${slug(url)}-${mode}.png`); try { const png = (await cdp.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }, sessionId)).data; writeFileSync(screenshot, Buffer.from(png, 'base64')); } catch (error) { events.logs.push({ level: 'warning', text: `screenshot failed: ${error.message}`, url }); } await cdp.send('Target.closeTarget', { targetId: target.targetId }); return { input: url, mode, screenshot, ...result.value, exceptions: events.exceptions.slice(0, 10), failed: events.failed.filter(f => !f.canceled).slice(0, 10), severeLogs: events.logs.filter(l => ['error', 'warning'].includes(l.level)).slice(0, 10), }; } const profile = mkdtempSync(join(tmpdir(), 'nugu-real-chrome-')); const chrome = spawn(chromeBin, [ '--remote-debugging-port=0', `--user-data-dir=${profile}`, '--no-first-run', '--no-default-browser-check', 'about:blank', ], { stdio: ['ignore', 'ignore', 'pipe'] }); let stderr = ''; chrome.stderr.on('data', chunk => { stderr += chunk; }); try { const cdp = await CDP.connect(await chromeWs(join(profile, 'DevToolsActivePort'))); const results = []; for (const url of urls) { results.push(await verifyOne(cdp, url, 'desktop')); results.push(await verifyOne(cdp, url, 'mobile')); } cdp.close(); const summary = results.map(r => ({ input: r.input, mode: r.mode, title: r.title, nugu: Boolean(r.nuguNormal), overflowX: r.overflowX, exceptions: r.exceptions.length, failed: r.failed.length, logs: r.severeLogs.length, brokenImages: r.brokenImages.length, contrastIssues: r.contrastIssues.length, screenshot: r.screenshot, })); const problems = results.filter(r => r.overflowX > 0 || r.exceptions.length > 0 || r.failed.length > 0 || r.severeLogs.length > 0 || r.brokenImages.length > 0 || r.contrastIssues.length > 0); writeFileSync(join(outDir, 'results.json'), JSON.stringify(results, null, 2)); writeFileSync(join(outDir, 'summary.json'), JSON.stringify(summary, null, 2)); writeFileSync(join(outDir, 'problems.json'), JSON.stringify(problems, null, 2)); for (const r of summary) { console.log([r.input, r.mode, r.title, `nugu=${r.nugu}`, `overflow=${r.overflowX}`, `exc=${r.exceptions}`, `fail=${r.failed}`, `logs=${r.logs}`, `broken=${r.brokenImages}`, `contrast=${r.contrastIssues}`].join('\t')); } } finally { chrome.kill('SIGTERM'); await sleep(200); try { rmSync(profile, { recursive: true, force: true }); } catch {} } if (stderr.includes('DevToolsActivePort file')) process.stderr.write(stderr);