Luigit
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);