149 lines
5.8 KiB
JavaScript
149 lines
5.8 KiB
JavaScript
/**
|
||
* 深色主题手工验收。
|
||
*
|
||
* 需要共享 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);
|