visual-acceptance-check.mjs
8.12 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
#!/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));