#!/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. 字段(比如 `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 --file --widths [--height ]', ' style --file --selector --prop [--width ] [--height ]', ' console --file [--width ] [--height ]', ' contrast --fg --bg ', '', ].join('\n')); process.exit(cmd ? 1 : 0); } } main().catch((e) => fail(e.stack || e.message));