/** * 自定义背景 + 外观现代化的手工验收。 * * 需要共享 Chromium(CDP 9222)与一个活着的 Gateway。与 theme.test.mjs / * background.test.mjs 的分工:那两个守「源码写成了什么形态」,这里量的是 * **真实渲染结果** —— 背景层有没有真的露出来、玻璃化有没有生效、 * 正文在背景之上还能不能读。这些只有真实渲染能回答。 * * 用法: * AGENTMAIL_DIST=$PWD/client/electron/dist \ * AGENTMAIL_URL=http://127.0.0.1:8180 \ * ADMIN_USER=gui-lab ADMIN_PW=... \ * node test/manual/background-verify.mjs * * 设 AGENTMAIL_DIST 时,页面壳与静态资源从本地 dist 注入,API/SSE 仍走真实 * Gateway —— 因此可以在不部署、不重启的前提下验收本次构建。 */ import { openApp, WIDE } from './narrow-probe-helper.mjs'; import { writeFile, mkdir } from 'node:fs/promises'; const OUT = process.env.SHOT_DIR || '/tmp/appearance-shots'; let pass = 0; let fail = 0; const check = (name, ok, detail = '') => { if (ok) { pass++; console.log(` 通过 ${name}`); } else { fail++; console.log(` 失败 ${name}${detail ? ' — ' + detail : ''}`); } }; /** WCAG 相对亮度与对比度。 */ const srgb = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }; const luminance = ([r, g, b]) => 0.2126 * srgb(r) + 0.7152 * srgb(g) + 0.0722 * srgb(b); const contrast = (a, b) => { const l1 = luminance(a); const l2 = luminance(b); const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1]; return (hi + 0.05) / (lo + 0.05); }; const parseRgb = s => { const m = String(s).match(/(\d+(?:\.\d+)?)[,\s]+(\d+(?:\.\d+)?)[,\s]+(\d+(?:\.\d+)?)/); return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null; }; const root = () => process.env.SHOT_DIR; void root; await mkdir(OUT, { recursive: true }); const { browser, page, issues } = await openApp(WIDE); /** 切到某个主题 + 某个背景,并等一帧让过渡稳定。 */ async function configure(theme, kind, presetId) { await page.evaluate( ([t, k, p]) => { localStorage.setItem('agentmail.theme', t); localStorage.setItem( 'agentmail.background', JSON.stringify({ kind: k, presetId: p, imageDataUrl: '', dim: 24, blur: 8 }) ); }, [theme, kind, presetId] ); await page.reload({ waitUntil: 'domcontentloaded' }); // reload 后要重新登录态?不需要:cookie 仍在。等应用壳渲染出来。 await page.waitForTimeout(700); } try { // 确认已登录(helper 内部在需要时会填表;这里兜底判断一次) const loggedIn = await page.evaluate(() => !!document.querySelector('nav, aside, main')); if (!loggedIn) { console.log(' 提示:未进入主界面,可能登录失败'); } // ── 1. 关闭背景时不应有任何背景层可见 ── console.log('\n── 背景:无 ──'); await configure('light', 'none', 'aurora'); const noneState = await page.evaluate(() => ({ dataBg: document.documentElement.dataset.bg, opacity: getComputedStyle(document.querySelector('.app-backdrop')).opacity, bodyBg: getComputedStyle(document.body).backgroundColor })); check('data-bg 为 off', noneState.dataBg === 'off'); check('背景层透明(opacity 0)', Number(noneState.opacity) === 0, noneState.opacity); check('页面底仍是不透明的灰底', parseRgb(noneState.bodyBg) !== null && !/transparent/.test(noneState.bodyBg), noneState.bodyBg); await page.screenshot({ path: `${OUT}/01-none-light.png` }); // ── 2. 预设背景在浅色下真的露出来 ── console.log('\n── 背景:预设(极光) 浅色 ──'); await configure('light', 'preset', 'aurora'); const lightPreset = await page.evaluate(() => ({ dataBg: document.documentElement.dataset.bg, opacity: getComputedStyle(document.querySelector('.app-backdrop')).opacity, bodyBg: getComputedStyle(document.body).backgroundColor, image: getComputedStyle(document.querySelector('.app-backdrop')).backgroundImage, preset: document.documentElement.className.includes('bg-preset-aurora'), dim: getComputedStyle(document.documentElement).getPropertyValue('--bg-dim').trim() })); check('data-bg 为 on', lightPreset.dataBg === 'on'); check('背景层可见', Number(lightPreset.opacity) === 1, lightPreset.opacity); check('预设类已挂到 html', lightPreset.preset); check('渐变已解析成真实背景图', /gradient/.test(lightPreset.image), lightPreset.image.slice(0, 60)); check('页面底已让出(透明)', /transparent|rgba\(0, 0, 0, 0\)/.test(lightPreset.bodyBg), lightPreset.bodyBg); check('压暗变量已写入', lightPreset.dim === '24%', lightPreset.dim); await page.screenshot({ path: `${OUT}/02-preset-aurora-light.png` }); // ── 3. 玻璃化:面板必须半透明且有背景模糊 ── // // 选择器必须排除**完全透明**的元素:背景开启后大量 .bg-gray-50 会变成 // rgba(0, 0, 0, 0),它们是「让出背景」的空白容器,不是玻璃面板。 // 上一版取「第一个 alpha<1 的元素」正好命中它们,得出「玻璃化没生效」的 // 错误结论(实测面板其实是 rgba(255,255,255,0.82) + blur(16px))。 const glass = await page.evaluate(() => { const alphaOf = s => { const parts = String(s).split(','); return parts.length > 3 ? parseFloat(parts[3]) : 1; }; const candidates = [...document.querySelectorAll('div,aside,nav')].filter(el => { const a = alphaOf(getComputedStyle(el).backgroundColor); return a > 0.05 && a < 1; // 排除全透明容器 }); if (!candidates.length) return { found: false }; // 取面积最大的那块:它才是真正承载内容的玻璃面板 const el = candidates.sort((a, b) => { const ra = a.getBoundingClientRect(); const rb = b.getBoundingClientRect(); return rb.width * rb.height - ra.width * ra.height; })[0]; const cs = getComputedStyle(el); return { found: true, bg: cs.backgroundColor, filter: cs.backdropFilter || cs.webkitBackdropFilter, cls: el.className.toString().slice(0, 80) }; }); check('存在半透明面板', glass.found, JSON.stringify(glass)); if (glass.found) { // 用 parseFloat 而不是 Number:getComputedStyle 给的是 `rgba(255, 255, 255, 0.82)`, // 最后一段带右括号,Number(' 0.82)') 是 NaN(上一版就踩了这个)。 const alpha = parseFloat(String(glass.bg).split(',')[3] ?? '1'); check('面板不透明度在 (0,1) 之间', alpha > 0 && alpha < 1, String(alpha)); check('面板启用了背景模糊', /blur/.test(glass.filter || ''), String(glass.filter)); } // ── 4. 深色 + 背景:遮罩必须跟着反转(否则照片会压不住) ── console.log('\n── 背景:预设(极光) 深色 ──'); await configure('dark', 'preset', 'aurora'); const darkState = await page.evaluate(() => { const backdrop = document.querySelector('.app-backdrop'); const scrim = getComputedStyle(backdrop, '::after'); return { isDark: document.documentElement.classList.contains('dark'), dataBg: document.documentElement.dataset.bg, scrim: scrim.backgroundColor, colorScheme: getComputedStyle(document.documentElement).colorScheme }; }); const scrimRgb = parseRgb(darkState.scrim); check('深色类已生效', darkState.isDark); check('深色下遮罩是暗色', scrimRgb !== null && luminance(scrimRgb) < 0.2, darkState.scrim); check('color-scheme 随主题声明', /dark/.test(darkState.colorScheme), darkState.colorScheme); await page.screenshot({ path: `${OUT}/03-preset-aurora-dark.png` }); // ── 5. 正文在背景之上仍然可读 ── // 逐元素算真实对比度:把最近的不透明祖先底色当作背景(与 theme-verify 同法)。 const contrastReport = await page.evaluate(() => { const parse = s => { const m = String(s).match(/(\d+(?:\.\d+)?)[,\s]+(\d+(?:\.\d+)?)[,\s]+(\d+(?:\.\d+)?)/); return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null; }; const srgb = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }; const lum = ([r, g, b]) => 0.2126 * srgb(r) + 0.7152 * srgb(g) + 0.0722 * srgb(b); const contrast = (a, b) => { const l1 = lum(a); const l2 = lum(b); const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1]; return (hi + 0.05) / (lo + 0.05); }; const effBg = el => { let cur = el; while (cur && cur !== document.documentElement) { const cs = getComputedStyle(cur); const bg = parse(cs.backgroundColor); const alpha = cs.backgroundColor.split(',').length > 3 ? parseFloat(cs.backgroundColor.split(',')[3]) : 1; if (bg && alpha > 0.85) return bg; cur = cur.parentElement; } return parse(getComputedStyle(document.body).backgroundColor) || [255, 255, 255]; }; const out = []; for (const el of document.querySelectorAll('p, span, h1, h2, h3, a, button, li, label')) { const text = (el.textContent || '').trim(); if (!text || text.length < 2) continue; const r = el.getBoundingClientRect(); if (r.width < 8 || r.height < 6) continue; const cs = getComputedStyle(el); if (cs.visibility === 'hidden' || cs.display === 'none' || Number(cs.opacity) < 0.5) continue; const fg = parse(cs.color); if (!fg) continue; const size = parseFloat(cs.fontSize); const bold = Number(cs.fontWeight) >= 700; const need = size >= 24 || (size >= 18.66 && bold) ? 3 : 4.5; const ratio = contrast(fg, effBg(el)); if (ratio < need) { out.push({ text: text.slice(0, 24), ratio: Number(ratio.toFixed(2)), need, size }); } } return out; }); check( '深色 + 背景:无低对比正文(WCAG AA)', contrastReport.length === 0, contrastReport.slice(0, 4).map(x => `${x.text}:${x.ratio}<${x.need}`).join(' | ') ); for (const miss of contrastReport.slice(0, 6)) { console.log(` · "${miss.text}" ${miss.ratio} < ${miss.need}(${miss.size}px)`); } // ── 6. 自动模式:跟随系统深浅色 ── console.log('\n── 主题:跟随系统 ──'); await page.evaluate(() => localStorage.setItem('agentmail.theme', 'system')); await page.emulateMedia({ colorScheme: 'dark' }); await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForTimeout(600); const autoDark = await page.evaluate(() => ({ dark: document.documentElement.classList.contains('dark'), colorScheme: document.documentElement.style.colorScheme, pref: localStorage.getItem('agentmail.theme') })); check('系统深色 → 应用深色', autoDark.dark === true, JSON.stringify(autoDark)); await page.emulateMedia({ colorScheme: 'light' }); // 真实用户改系统设置时不会刷新页面 —— 这里也必须**不刷新**地跟随 await page.waitForTimeout(600); const autoLight = await page.evaluate(() => ({ dark: document.documentElement.classList.contains('dark'), colorScheme: document.documentElement.style.colorScheme })); check('系统切浅色 → 应用实时跟随(无需刷新)', autoLight.dark === false, JSON.stringify(autoLight)); await page.screenshot({ path: `${OUT}/04-auto-light.png` }); // ── 7. 显式选择时不得被系统覆盖 ── await page.evaluate(() => localStorage.setItem('agentmail.theme', 'light')); await page.emulateMedia({ colorScheme: 'dark' }); await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForTimeout(600); const pinned = await page.evaluate(() => document.documentElement.classList.contains('dark')); check('显式选浅色时不被系统深色覆盖', pinned === false); console.log('\n── 现代化打磨 ──'); const polish = await page.evaluate(() => { const cs = getComputedStyle(document.documentElement); const btn = document.querySelector('button'); return { focusRuleExists: true, radiusCard: cs.getPropertyValue('--radius-card').trim(), radiusControl: cs.getPropertyValue('--radius-control').trim(), buttonRadius: btn ? getComputedStyle(btn).borderRadius : '', reducedMotionRule: [...document.styleSheets].some(s => { try { return [...s.cssRules].some(r => r.conditionText?.includes('prefers-reduced-motion')); } catch { return false; } }) }; }); check('圆角令牌已定义', !!polish.radiusCard && !!polish.radiusControl, JSON.stringify(polish)); check('按钮圆角变大(0.5rem 系而非 0.25rem)', /(8|10|12|16)px/.test(polish.buttonRadius), polish.buttonRadius); check('减弱动效媒体查询已生效', polish.reducedMotionRule); if (issues.length) { console.log('\n页面错误:'); for (const i of issues.slice(0, 8)) console.log(' ' + i); } check('无页面级错误', issues.length === 0, issues.slice(0, 3).join(' | ')); } finally { await page.close(); await browser.close(); } console.log(`\n外观:${pass} 通过${fail ? `,${fail} 失败` : ''}`); console.log(`截图:${OUT}`); process.exit(fail ? 1 : 0);