/** * 深色主题手工验收。 * * 需要共享 Chromium(CDP 9222)。结构性检查已在 test/theme.test.mjs 里, * 这里验的是**真实渲染出来的对比度** —— 那是唯一能发现白底白字的判据。 * * 用法: * ADMIN_USER=jianf ADMIN_PW=... AGENTMAIL_URL=http://127.0.0.1:8180 \ * node client/electron/test/manual/theme-verify.mjs */ import { openApp, WIDE } from './narrow-probe-helper.mjs'; let pass = 0, fail = 0; const check = (name, ok, detail = '') => { if (ok) { pass++; console.log(` 通过 ${name}`); } else { fail++; console.log(` 失败 ${name}${detail ? ' — ' + detail : ''}`); } }; /** 相对亮度(WCAG)。用于判断 body 底色是否真的变暗。 */ function luminance([r, g, b]) { const f = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); } const parseRgb = s => { const m = String(s).match(/(\d+),\s*(\d+),\s*(\d+)/); return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null; }; const { browser, page, issues } = await openApp(WIDE); try { for (const mode of ['light', 'dark']) { console.log(`\n── ${mode} ──`); await page.evaluate(m => { localStorage.setItem('agentmail.theme', m); document.documentElement.classList.toggle('dark', m === 'dark'); document.documentElement.style.colorScheme = m; }, mode); await page.waitForTimeout(400); const body = parseRgb(await page.evaluate(() => getComputedStyle(document.body).backgroundColor)); check(`${mode}: body 底色可读取`, body !== null, String(body)); if (mode === 'dark') { // 深色下 body 必须是暗的。这一条挂掉说明变量没生效 check('dark: body 底色确实是暗的', luminance(body) < 0.2, `亮度 ${luminance(body).toFixed(3)}`); } // 遍历可见文本节点,算每个的前景/背景对比度。 // 4.5:1 是 WCAG AA 的正文标准;大字放宽到 3:1。 const bad = await page.evaluate(() => { const out = []; const els = document.querySelectorAll('button, a, h1, h2, h3, p, span, div, label, li'); const lum = ([r, g, b]) => { const f = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); }; const parse = s => { const m = String(s).match(/(\d+),\s*(\d+),\s*(\d+)/); return m ? [+m[1], +m[2], +m[3]] : null; }; /** 往上找第一个不透明的背景。 */ const bgOf = el => { let cur = el; while (cur && cur !== document.documentElement) { const cs = getComputedStyle(cur); const c = parse(cs.backgroundColor); const alpha = String(cs.backgroundColor).match(/rgba?\([^)]*,\s*([\d.]+)\)/); if (c && (!alpha || Number(alpha[1]) > 0.85)) return c; cur = cur.parentElement; } return parse(getComputedStyle(document.body).backgroundColor); }; for (const el of els) { // 只看直接含文本的元素 const text = [...el.childNodes] .filter(n => n.nodeType === 3) .map(n => n.textContent.trim()) .join(''); if (!text) continue; const r = el.getBoundingClientRect(); if (r.width < 4 || r.height < 4) continue; const cs = getComputedStyle(el); if (cs.visibility === 'hidden' || Number(cs.opacity) < 0.75) continue; if (el.matches(':disabled, [aria-disabled="true"]')) continue; // 方向箭头、分隔点与关闭符号是装饰/图标,不按正文文字审计。 if (/^[→·×↑↓]+$/.test(text)) continue; const fg = parse(cs.color); const bg = bgOf(el); if (!fg || !bg) continue; const la = lum(fg), lb = lum(bg); const [hi, lo] = la > lb ? [la, lb] : [lb, la]; const ratio = (hi + 0.05) / (lo + 0.05); const size = parseFloat(cs.fontSize); const bold = Number(cs.fontWeight) >= 700; const large = size >= 24 || (size >= 18.66 && bold); const need = large ? 3 : 4.5; if (ratio < need) { out.push({ text: text.slice(0, 24), ratio: Number(ratio.toFixed(2)), need, fg: cs.color, bg: `rgb(${bg.join(',')})` }); } } return out; }); // 禁用态与纯图标已在页面端过滤,其余可见文字都应达到自身 AA 阈值。 check(`${mode}: 可见文字全部达到 WCAG AA`, bad.length === 0, bad.slice(0, 4).map(x => `"${x.text}" ${x.ratio}/${x.need}`).join(' | ')); if (bad.length) { console.log(` (${bad.length} 处低于 AA 阈值,最差 ${Math.min(...bad.map(x => x.ratio))}:1)`); // 逐条列出来而不只报个数:不知道是哪一处就没法修 for (const x of bad.slice(0, 8)) { console.log(` ${x.ratio}:1 (需 ${x.need}) "${x.text}" ${x.fg} on ${x.bg}`); } } await page.screenshot({ path: `/tmp/theme-${mode}.png`, fullPage: false }); } // 刷新后主题必须保持(localStorage + 内联脚本) await page.evaluate(() => localStorage.setItem('agentmail.theme', 'dark')); await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForTimeout(600); const stillDark = await page.evaluate(() => document.documentElement.classList.contains('dark')); check('刷新后深色保持(内联脚本生效)', stillDark); check('无 JS 运行时错误', issues.length === 0, issues.slice(0, 3).join(' | ')); } finally { await page.close(); await browser.close(); } console.log(`\n主题验收:${pass} 通过${fail ? `,${fail} 失败` : ''}`); console.log('截图:/tmp/theme-light.png /tmp/theme-dark.png'); process.exit(fail ? 1 : 0);