visual-acceptance-check.mjs 8.12 KB
#!/usr/bin/env node
/**
 * visual-acceptance-check.mjs — 可复用的真实浏览器验收检查(35 号文档提案 C)
 *
 * 起因:真实回归任务里,goal-benchmark.json 的 evidence_hint 常常点名"打开浏览器 /
 * 依次设置视口宽度 / 开发者工具 Console+Network 面板"这类检查,但系统里没有一份
 * 标准化的工具——每次都靠 agent 自己临时想(这次撞见的两种极端:一次真的写了一份
 * Playwright 脚本认真测,一次只写了正则解析文本)。这份脚本把最常见的几类检查
 * 封装成一个通用工具,agent 通过 bash 直接调用,不用每次重新发明。
 *
 * 用法:
 *   node scripts/visual-acceptance-check.mjs overflow --file index.html --widths 375,768,1440
 *   node scripts/visual-acceptance-check.mjs style --file index.html --selector .hero-title --prop font-size
 *   node scripts/visual-acceptance-check.mjs contrast --fg "#1d1d1f" --bg "#ffffff"
 *   node scripts/visual-acceptance-check.mjs console --file index.html
 *
 * 每个子命令把结果打到 stdout(一行 JSON),可以直接摘录进 attempt-N.scores.json 的
 * evidence.<dimension> 字段(比如 `node ... | node -e "console.log(JSON.parse(require('fs').readFileSync(0)).ratio)"`,
 * 或者更常见的做法:把这行 JSON 原样贴进 evidence 文本)。
 *
 * 退出码:0 = 检查本身跑完了(不代表"通过",通不通过看输出里的字段);
 *        1 = 参数错 / 文件不存在 / 浏览器跑不起来,检查没能真正执行。
 * 这个区分很重要:agent 应该只在退出码 0 时把输出当"真实证据"贴进 evidence,
 * 退出码 1 时说明检查本身没跑成,不能拿它的输出(如果有)冒充证据。
 */

import fs from 'fs';
import path from 'path';
import { pathToFileURL } from 'url';

function parseArgs(argv) {
  const out = { _: [] };
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    if (a.startsWith('--')) {
      const key = a.slice(2);
      const next = argv[i + 1];
      if (next === undefined || next.startsWith('--')) { out[key] = true; }
      else { out[key] = next; i += 1; }
    } else out._.push(a);
  }
  return out;
}

function fail(msg) {
  process.stderr.write(`ERROR ${msg}\n`);
  process.exit(1);
}

function requireFile(p) {
  const abs = path.resolve(p);
  if (!fs.existsSync(abs)) fail(`文件不存在: ${abs}`);
  return abs;
}

function parseWidths(s) {
  const widths = String(s || '').split(',').map((x) => Number(x.trim())).filter((n) => Number.isFinite(n) && n > 0);
  if (!widths.length) fail('--widths 需要至少一个正整数,用逗号分隔,如 375,768,1440');
  return widths;
}

async function loadChromium() {
  try {
    const { chromium } = await import('playwright');
    return chromium;
  } catch (e) {
    fail(`加载 playwright 失败(${e.message})——确认在仓库根目录下跑这份脚本,node_modules/playwright 才找得到`);
  }
}

// ── overflow:多档视口宽度下有没有横向溢出 ──────────────────────────
async function cmdOverflow(args) {
  const file = requireFile(args.file || fail('--file 必填'));
  const widths = parseWidths(args.widths);
  const height = Number(args.height) || 900;
  const chromium = await loadChromium();
  const browser = await chromium.launch();
  try {
    const page = await browser.newPage();
    const results = [];
    for (const width of widths) {
      await page.setViewportSize({ width, height });
      await page.goto(pathToFileURL(file).href, { waitUntil: 'load' });
      const m = await page.evaluate(() => ({
        scrollWidth: document.documentElement.scrollWidth,
        clientWidth: document.documentElement.clientWidth,
      }));
      results.push({ width, ...m, overflow: m.scrollWidth > m.clientWidth });
    }
    print({ file, widths: results, overflow_any: results.some((r) => r.overflow) });
  } finally {
    await browser.close();
  }
}

// ── style:某个选择器在指定视口下的某个 computedStyle 属性 ──────────
async function cmdStyle(args) {
  const file = requireFile(args.file || fail('--file 必填'));
  const selector = args.selector || fail('--selector 必填');
  const prop = args.prop || fail('--prop 必填');
  const width = Number(args.width) || 1440;
  const height = Number(args.height) || 900;
  const chromium = await loadChromium();
  const browser = await chromium.launch();
  try {
    const page = await browser.newPage();
    await page.setViewportSize({ width, height });
    await page.goto(pathToFileURL(file).href, { waitUntil: 'load' });
    const value = await page.evaluate(({ selector, prop }) => {
      const el = document.querySelector(selector);
      if (!el) return null;
      return getComputedStyle(el)[prop] ?? null;
    }, { selector, prop });
    print({ file, selector, prop, width, height, value, found: value !== null });
  } finally {
    await browser.close();
  }
}

// ── console:加载页面时的 console 错误 + 失败的网络请求 ──────────────
async function cmdConsole(args) {
  const file = requireFile(args.file || fail('--file 必填'));
  const width = Number(args.width) || 1440;
  const height = Number(args.height) || 900;
  const chromium = await loadChromium();
  const browser = await chromium.launch();
  try {
    const page = await browser.newPage();
    await page.setViewportSize({ width, height });
    const consoleErrors = [];
    const failedRequests = [];
    page.on('console', (msg) => { if (msg.type() === 'error') consoleErrors.push(msg.text()); });
    page.on('requestfailed', (req) => failedRequests.push({ url: req.url(), failure: req.failure()?.errorText || null }));
    await page.goto(pathToFileURL(file).href, { waitUntil: 'load' });
    print({
      file, console_errors: consoleErrors, failed_requests: failedRequests,
      clean: consoleErrors.length === 0 && failedRequests.length === 0,
    });
  } finally {
    await browser.close();
  }
}

// ── contrast:WCAG 相对亮度对比度,纯计算,不需要浏览器 ─────────────
function srgbToLinear(c) {
  const v = c / 255;
  return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
}
function relativeLuminance(hex) {
  const h = hex.replace('#', '');
  const r = parseInt(h.slice(0, 2), 16);
  const g = parseInt(h.slice(2, 4), 16);
  const b = parseInt(h.slice(4, 6), 16);
  if ([r, g, b].some((n) => Number.isNaN(n))) return null;
  return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b);
}
function cmdContrast(args) {
  const fg = args.fg || fail('--fg 必填(如 "#1d1d1f")');
  const bg = args.bg || fail('--bg 必填(如 "#ffffff")');
  const lFg = relativeLuminance(fg);
  const lBg = relativeLuminance(bg);
  if (lFg === null || lBg === null) fail(`颜色解析失败,需要 #rrggbb 形式:fg=${fg} bg=${bg}`);
  const [lighter, darker] = lFg > lBg ? [lFg, lBg] : [lBg, lFg];
  const ratio = (lighter + 0.05) / (darker + 0.05);
  print({
    fg, bg, ratio: Number(ratio.toFixed(2)),
    wcag_aa_normal_text: ratio >= 4.5,   // WCAG 2.1 AA:正文 ≥4.5:1
    wcag_aa_large_text: ratio >= 3,      // WCAG 2.1 AA:大字号(≥18pt 或 14pt 加粗)≥3:1
  });
}

function print(obj) {
  process.stdout.write(`${JSON.stringify(obj)}\n`);
}

async function main() {
  const [cmd, ...rest] = process.argv.slice(2);
  const args = parseArgs(rest);
  switch (cmd) {
    case 'overflow': return cmdOverflow(args);
    case 'style': return cmdStyle(args);
    case 'console': return cmdConsole(args);
    case 'contrast': return cmdContrast(args);
    default:
      process.stderr.write([
        '用法: node scripts/visual-acceptance-check.mjs <overflow|style|console|contrast> [选项]',
        '  overflow --file <path> --widths <w1,w2,...> [--height <h>]',
        '  style    --file <path> --selector <sel> --prop <cssProp> [--width <w>] [--height <h>]',
        '  console  --file <path> [--width <w>] [--height <h>]',
        '  contrast --fg <hex> --bg <hex>',
        '',
      ].join('\n'));
      process.exit(cmd ? 1 : 0);
  }
}

main().catch((e) => fail(e.stack || e.message));