Files
MailUI4Agents/client/electron/test/manual/theme-verify.mjs

149 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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