repositories / bugabinga.net
bugabinga.net
personal infrastructure for bugabinga!
owned by admin
scripts/local/verify-sites-chrome.mjs
Raw#!/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);