chore: directory migration - gateway→server, web→client/electron

This commit is contained in:
2026-09-08 19:16:35 +08:00
parent fd9f99a3f9
commit f9d757b5e5
243 changed files with 5095 additions and 228 deletions

View File

@ -0,0 +1,75 @@
# 手工浏览器实测脚本
不进 `npm test` —— 它们需要一个跑着的 Chromium 与一个活的 Gateway。
日常回归靠 `../narrow-layout.test.mjs`(读源码验形态,无外部依赖)。
## 为什么两套都要
结构性断言守住「代码写成了什么形态」,量不出「按钮实际多大、点下去命中谁」。
窄屏那轮修复里最严重的一个 bug 是抽屉式侧栏(`fixed ... z-50` 铺满视口高度)
把底部导航最左那一项盖住 —— 按钮在那里、尺寸也够、`md:hidden` 之类的规则也
没写错,**只有 `elementFromPoint` 才能发现它命中的是抽屉里的 SVG**。
## 用法
```bash
# 窄屏390pxiPhone 14 Pro+ 320pxiPhone SE
ADMIN_PW=<密码> npm run test:narrow
# 宽屏回归:窄屏修复不能把桌面改坏
ADMIN_PW=<密码> npm run test:wide
```
环境变量:
| 变量 | 默认 | 说明 |
|---|---|---|
| `ADMIN_PW` | 无(必填) | 管理员密码 |
| `ADMIN_USER` | `admin` | 登录用户名 |
| `AGENTMAIL_URL` | `https://mail.jianfgit.xyz` | 目标地址 |
| `CDP_URL` | `http://127.0.0.1:9222` | 浏览器 CDP 端点 |
| `PLAYWRIGHT` | `/usr/lib/node_modules/playwright/index.mjs` | playwright 入口 |
浏览器用的是本机 systemd 托管的共享 Chromium`homeagent-browser.service`
通过 CDP 连上去开自己的标签页,用完关掉。没有它时先
`systemctl start homeagent-browser`
## 文件
| 文件 | 作用 |
|---|---|
| `narrow-probe-helper.mjs` | 连浏览器、登录、量盒子/溢出/命中区/命中测试 |
| `narrow-verify.mjs` | 窄屏 13 项验收 |
| `wide-regression.mjs` | 宽屏 5 项回归 |
| `inbox-group-verify.mjs` | 收件箱按会话分组 |
| `theme-verify.mjs` | 深浅两色的 WCAG 对比度 |
| `accent-verify.mjs` | 强调色(红/绿/橙/黄/蓝17 组配色,两模式各一遍 |
`narrow-probe-helper.mjs` 里两个函数值得单独知道:
- `tapTargets(page, labels)` —— 量 `.tap` 按钮的**真实**命中区(`::after`
伪元素的尺寸)。`.tap` 刻意不改变视觉尺寸,所以只看 `boundingBox` 会误判成偏小
- `hitTest(page, selector)` —— 每个元素点下去是否命中自己。遮挡类 bug 只能这样查
`accent-verify.mjs` 存在的理由是一次真实事故:`tailwind.config.js``colors`
里同时写了固定 hex 与 `accent()` 两份 red/green/amber/orange/yellowJS 对象
字面量重复键**后者胜出**(不报错),而 `index.css` 当时没有对应的 `--c-red-*`
变量。`rgb(var(--c-red-600) / 1)` 里变量未定义 → 整条 `background-color` 声明
失效 → `bg-red-600` 退回透明、`text-white` 的白字落在白卡片上:
**按钮看不见但点得动**。所有静态检查都过,只有肉眼能发现。
因此这个脚本量的是**实际计算值**:它把类名注入真页面、读 `getComputedStyle`
把「背景透明」单独判为失败(那正是上述 bug 的指纹),再算 WCAG 对比度。
只以 `hover:` 变体出现的档(`bg-red-700` / `bg-blue-700`**不能**放进探针:
Tailwind 不生成未被使用的基础类,探它必然得到透明背景 —— 那是假阳性。
它们由 `../theme.test.mjs` 的档位断言覆盖。
## 已知限制
headless Chromium 报告 `hover: none`,因此 `.reveal`(只在支持悬停的设备上隐藏)
在这里永远是可见的 —— 脚本只能验「触摸设备上可见」这一半,
「鼠标设备上隐藏」那一半靠 `../narrow-layout.test.mjs` 检查 CSS 规则存在。
没有像素级视觉比对:字体差异下极脆,维护成本高于收益。

View File

@ -0,0 +1,84 @@
/**
* 强调色可见性验收:真实渲染 + 取实际计算色 + 算 WCAG 对比度。
*
* 结构性断言test/theme.test.mjs只能保证变量存在、档位达标
* 「按钮到底看得见吗」必须在真浏览器里量 —— 上一次那个 bug 正是
* 所有静态检查都过、只有肉眼能发现。
*/
import { openApp, WIDE } from './narrow-probe-helper.mjs';
const srgb = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
const L = ([r, g, b]) => 0.2126 * srgb(r) + 0.7152 * srgb(g) + 0.0722 * srgb(b);
const ratio = (a, b) => { const l1 = L(a), l2 = L(b); const [h, o] = l1 > l2 ? [l1, l2] : [l2, l1]; return (h + 0.05) / (o + 0.05); };
const parse = s => (s.match(/\d+/g) || []).slice(0, 3).map(Number);
// 把一批强调色类名注入页面,量它们的实际计算值
// hover 档bg-red-700 / bg-blue-700不在这里源码里它们只以 `hover:` 变体
// 出现Tailwind 因此不生成基础类 —— 探它会得到透明背景,那是探针的假阳性
// 而不是真 bug。它们由 theme.test.mjs 的档位断言覆盖(--s-* 两模式同值)。
const PROBES = [
// [类名组合, 说明, 期望最低对比度]
['bg-red-600 text-white', '危险实心按钮(确认归档 / 删除)', 3.0],
['bg-red-500 text-white', '未读徽标', 3.0],
['bg-green-600 text-white', '同意按钮', 3.0],
['bg-orange-700 text-white', '待决策徽标', 4.5],
['bg-blue-600 text-white', '主按钮', 4.5],
['bg-red-50 text-red-600', '错误提示条', 4.5],
['bg-red-50 text-red-700', '危险区块文字', 4.5],
['bg-green-50 text-green-700', '成功提示条', 4.5],
['bg-orange-100 text-orange-700', '橙色 chip', 4.5],
['bg-amber-100 text-amber-700', '警告 chip', 4.5],
['bg-amber-50 text-amber-800', '警告条', 4.5],
['bg-yellow-100 text-yellow-700', '黄色 chip', 4.5],
['bg-blue-50 text-blue-700', '信息条', 4.5],
['bg-red-100 text-red-700', '红色 chip', 4.5],
['bg-orange-200 text-orange-800', '深橙 chip', 4.5],
['bg-white text-gray-900', '卡片正文', 4.5],
['bg-white text-gray-500', '卡片次要文字', 4.5],
];
const { browser, page } = await openApp(WIDE);
let fail = 0;
for (const mode of ['light', 'dark']) {
await page.evaluate(m => {
document.documentElement.classList.toggle('dark', m === 'dark');
let host = document.getElementById('__probe');
if (host) host.remove();
host = document.createElement('div');
host.id = '__probe';
host.style.position = 'fixed';
host.style.top = '0';
host.style.left = '0';
host.style.zIndex = '99999';
document.body.appendChild(host);
}, mode);
console.log(`\n─── ${mode === 'dark' ? '深色' : '浅色'} ───`);
for (const [cls, label, min] of PROBES) {
const got = await page.evaluate(c => {
const host = document.getElementById('__probe');
host.innerHTML = `<span id="__p" class="${c}">测试</span>`;
const el = document.getElementById('__p');
const s = getComputedStyle(el);
return { bg: s.backgroundColor, fg: s.color };
}, cls);
// 透明背景 = 声明失效(正是上次那个 bug 的指纹)
const transparent = /rgba\(0,\s*0,\s*0,\s*0\)|transparent/.test(got.bg);
if (transparent) {
console.log(` 失败 ${label} — 背景透明(${cls} 的 background-color 声明失效)`);
fail++;
continue;
}
const r = ratio(parse(got.fg), parse(got.bg));
const ok = r >= min;
if (!ok) fail++;
console.log(` ${ok ? '通过' : '失败'} ${label.padEnd(22)} ${r.toFixed(2)}:1 (需 ${min}) ${got.bg} / ${got.fg}`);
}
}
await page.evaluate(() => document.getElementById('__probe')?.remove());
await browser.close();
console.log(fail ? `\n!! ${fail} 项不达标` : '\n全部达标');
process.exit(fail ? 1 : 0);

View File

@ -0,0 +1,98 @@
/**
* 邮件详情页地址行的真实渲染验收。
*
* 锁的是那次事故:发件行显示成 `jianf.<会话别名>` —— 别名拼给了发件人,
* 而且 path 为空时拼出了 ParseAddress 会整串当成名字的非法形态。
*/
import { openApp, WIDE } from './narrow-probe-helper.mjs';
const { browser, page } = await openApp(WIDE);
let fail = 0;
const check = (name, ok, detail = '') => {
if (ok) console.log(` 通过 ${name}`);
else { fail++; console.log(` 失败 ${name}${detail ? ' — ' + detail : ''}`); }
};
// 走**发件**箱:报这个 bug 的那封信是人发出去的jianf → pi抄送 pi@….new
// 收件箱里只有 Agent 的回信 —— 而回信没有抄送、也不带 `.new`
// 探不到要验的那三处。
await page.waitForSelector('button', { timeout: 20000 });
await page.click('button:has-text("发件")');
await page.waitForTimeout(2000);
// 收件箱/发件箱按会话分组:先点组头展开,再点里面的邮件行。
// 两者 className 都含 `w-full text-left`,靠「点完有没有出现含『发件』的 dl」区分。
let opened = false;
for (let round = 0; round < 3 && !opened; round++) {
const btns = await page.$$('button.w-full.text-left');
for (const b of btns) {
const t = await b.innerText().catch(() => '');
if (!t) continue;
await b.click().catch(() => {});
await page.waitForTimeout(350);
const hasMeta = await page.evaluate(() =>
[...document.querySelectorAll('dl')].some(dl => dl.textContent.includes('发件')));
if (hasMeta) { opened = true; break; }
}
}
check('打开了一封邮件', opened);
await page.waitForTimeout(800);
// 读元信息 dl
const meta = await page.evaluate(() => {
const dls = [...document.querySelectorAll('dl')];
for (const dl of dls) {
const pairs = [];
const kids = [...dl.children];
for (const div of kids) {
const dt = div.querySelector('dt'), dd = div.querySelector('dd');
if (dt && dd) pairs.push([dt.textContent.trim(), dd.textContent.trim()]);
}
if (pairs.some(([k]) => k === '发件')) return Object.fromEntries(pairs);
}
return null;
});
if (!meta) {
check('找到元信息区', false, '没有含「发件」的 dl');
} else {
console.log(' 元信息:', JSON.stringify(meta));
const from = meta['发件'] || '';
const to = meta['收件'] || '';
const cc = meta['抄送'] || '';
// 判「哪一方是人」:人的地址里既无 @ 也无 .
const isHuman = a => a && !a.includes('@') && !a.includes('.');
const human = isHuman(from) ? from : (isHuman(to) ? to : '');
const agent = isHuman(from) ? to : from;
check('有一方是人(裸名字,无 @ 无 .', !!human, `发件=${from} 收件=${to}`);
check('人的地址不含会话位', !human || !/[@.]/.test(human), `人=${human}`);
// Agent 必须三段齐全name@path.session
const threeSeg = /^[^@]+@\/[^@]*\.[^.@]+$/.test(agent);
check('Agent 带完整三段 name@path.session', threeSeg, `agent=${agent}`);
// path 不能是 Agent 名(`dsh@dsh` 那个 bug
if (agent.includes('@')) {
const [n, rest] = [agent.slice(0, agent.indexOf('@')), agent.slice(agent.indexOf('@') + 1)];
check('Agent 的 path 是真路径而不是 Agent 名',
rest.startsWith('/'), `${n}@${rest}`);
}
// 别名不能挂在人身上
check('会话别名跟着 Agent 而不是人',
!human || !agent || agent.includes('.'), `人=${human} agent=${agent}`);
// 抄送里不能残留 .new
check('抄送里没有 .new',
!cc.split('、').some(a => a.trim().endsWith('.new')), `抄送=${cc}`);
if (cc) console.log(` 抄送实际值: ${cc}`);
// 不该再有独立的「会话」行(别名已在 Agent 地址里)
check('没有多余的「会话」行', !('会话' in meta), `会话=${meta['会话']}`);
}
await browser.close();
console.log(fail ? `\n!! ${fail} 项不达标` : '\n全部达标');
process.exit(fail ? 1 : 0);

View File

@ -0,0 +1,168 @@
/**
* 收件箱分组 + 授权独立列表的实测验收。
*
* 起因:生产库里一个会话独占 17 封权限邮件,把另外两个会话的信挤出视野
* —— 收件箱失去了它唯一的作用(让人知道有哪几件事在等我)。
*
* 修法两层,这里各验一层:
* 1. 权限请求整体移出收件箱,进「授权」导航项(一级会话 / 二级请求)
* 2. 收件箱剩下的普通邮件按会话折叠
*
* 单元测试test/components/mailGroups.test.tsx守住分组函数的算术
* 量不出「组头真的只有一行」「折叠时组内邮件确实不在 DOM 里」这些
* 只有真实渲染才能验的事。
*
* 用法ADMIN_PW=<密码> ADMIN_USER=jianf AGENTMAIL_URL=http://127.0.0.1:8180 \
* node client/electron/test/manual/inbox-group-verify.mjs
*/
import { openApp, WIDE } from './narrow-probe-helper.mjs';
const { browser, page, issues } = await openApp(WIDE);
const failed = [];
async function check(name, fn) {
try {
const r = await fn();
console.log(` ${r.ok ? '通过' : '失败'} ${name}${r.note ? ' — ' + r.note : ''}`);
if (!r.ok) failed.push(name);
} catch (e) {
console.log(` 错误 ${name}${e.message.slice(0, 100)}`);
failed.push(name);
}
}
/** 中间栏里可点的条目数(组头 + 展开出来的行)。 */
const rowCount = () => page.locator('div.overflow-y-auto button').count();
async function goto(label) {
await page.click(`aside button[title*="${label}"], button[title*="${label}"]`);
await page.waitForTimeout(1200);
}
// ───────────────── 收件箱:权限已移出、其余按会话折叠 ─────────────────
console.log('\n收件箱');
await goto('收件箱');
await check('权限请求不再出现在收件箱', async () => {
const txt = await page.locator('div.overflow-y-auto').innerText();
// 「待决策」「等待你决策」都是授权列表的措辞;收件箱里不该有
const leaked = /待决策|等待你决策/.test(txt);
return { ok: !leaked, note: leaked ? '收件箱里出现了权限措辞' : '干净' };
});
await check('多封邮件的会话折叠成一行', async () => {
const header = await page.locator('h2:has-text("收件箱")').locator('..').innerText();
const m = header.match(/(\d+)\s*组\s*·\s*(\d+)\s*封/);
if (!m) {
// 每个会话都只有一封时不显示「N 组 · M 封」,这是设计(单封平铺)
return { ok: true, note: `无可折叠会话:${header.replace(/\n/g, ' ').trim()}` };
}
return { ok: Number(m[1]) < Number(m[2]), note: `${m[1]} 组 / ${m[2]}` };
});
// ───────────────── 授权:一级会话 / 二级请求 ─────────────────
console.log('\n授权列表');
await goto('授权');
await check('侧栏有独立的「授权」入口', async () => {
const n = await page.locator('button[title*="授权"]').count();
return { ok: n > 0, note: `${n} 个入口` };
});
await check('权限请求按会话分组(一级是会话)', async () => {
const heads = await page.locator('div.overflow-y-auto > div > button').count();
const txt = await page.locator('div.overflow-y-auto').innerText();
if (heads === 0 && /没有授权请求/.test(txt)) {
return { ok: true, note: '当前无授权请求(空态正常)' };
}
// 组头带会话别名(.alias 或「(未命名会话)」)
const hasAlias = /\.\S+|\(未命名会话\)/.test(txt);
return { ok: heads > 0 && hasAlias, note: `${heads} 个会话组头` };
});
await check('待决策的会话默认展开(在等人的不能藏)', async () => {
const txt = await page.locator('div.overflow-y-auto').innerText();
if (/没有授权请求/.test(txt)) return { ok: true, note: '无授权请求,跳过' };
const pendingBadge = await page.locator('span:has-text("待决策")').count();
if (pendingBadge === 0) {
return { ok: /已全部处理/.test(txt), note: '全部已处理,组头显示「已全部处理」' };
}
// 有待决策 → 组内应已展开,能看到「等待你决策」的行
const rows = await page.locator('span:has-text("等待你决策")').count();
return { ok: rows > 0, note: `${pendingBadge} 个待决策徽标,${rows} 行展开可见` };
});
await check('已决策的历史折进二级,不默认铺开', async () => {
const toggle = page.locator('button:has-text("已决策")');
const n = await toggle.count();
if (n === 0) return { ok: true, note: '无已决策历史,跳过' };
const label = await toggle.first().innerText();
return { ok: label.includes('展开'), note: label.trim() };
});
await check('二级折叠可展开', async () => {
const toggle = page.locator('button:has-text("已决策")');
if ((await toggle.count()) === 0) return { ok: true, note: '无二级折叠,跳过' };
const before = await rowCount();
await toggle.first().click();
await page.waitForTimeout(600);
const after = await rowCount();
return { ok: after > before, note: `${before}${after} 个条目` };
});
await check('点一条授权请求能打开决策面板', async () => {
// 授权行带「等待你决策」或决策结果;组头带「待决策 N」或「已全部处理」
const row = page
.locator('div.overflow-y-auto button')
.filter({ hasText: /等待你决策|同意|拒绝/ })
.filter({ hasNotText: /待决策 \d|已全部处理|展开已决策|收起已决策/ });
if ((await row.count()) === 0) return { ok: true, note: '无授权请求,跳过' };
await row.first().click();
await page.waitForTimeout(1500);
// 右栏出现权限决策按钮或已决策的结果说明
const panel =
(await page.locator('button:has-text("同意")').count()) > 0 ||
(await page.locator('text=/已(同意|拒绝|决策)/').count()) > 0;
return { ok: panel, note: panel ? '决策面板已渲染' : '右栏没有内容' };
});
await check('组头可收起', async () => {
const head = page.locator('div.overflow-y-auto > div > button').first();
if ((await head.count()) === 0) return { ok: true, note: '无组头,跳过' };
const before = await rowCount();
await head.click();
await page.waitForTimeout(600);
const after = await rowCount();
// 原本折叠的组点一下会展开;原本展开的会收起。两种都算「可切换」
return { ok: after !== before, note: `${before}${after}` };
});
// ───────────────── 徽标语义 ─────────────────
console.log('\n徽标');
await check('收件箱未读数不把权限请求算进来', async () => {
const badge = page.locator('button[title*="收件箱"] span').filter({ hasText: /^\d+$/ });
const inboxBadge = (await badge.count()) > 0 ? await badge.first().innerText() : '0';
const permBadge = page.locator('button[title*="授权"] span').filter({ hasText: /^\d+$/ });
const pBadge = (await permBadge.count()) > 0 ? await permBadge.first().innerText() : '0';
// 两个数字各自独立;一个待批的 bash 不该在两处都计数
return { ok: true, note: `收件箱未读 ${inboxBadge},授权待决策 ${pBadge}` };
});
await check('无 JS 运行时错误', async () => {
// 404 资源favicon 之类)不算 JS 错误
const real = issues.filter(i => !/404|Failed to load resource/.test(i));
return { ok: real.length === 0, note: real.length ? real.slice(0, 2).join(' | ') : '无' };
});
console.log(
failed.length === 0
? '\n收件箱分组 + 授权列表:全部通过'
: `\n收件箱分组 + 授权列表:${failed.length} 项失败 — ${failed.join('、')}`
);
await page.close();
await browser.close();
process.exit(failed.length === 0 ? 0 : 1);

View File

@ -0,0 +1,262 @@
/**
* 窄屏实测辅助:连本机共享 ChromiumCDP 127.0.0.1:9222量真实盒子。
*
* 这是**手工脚本**,不进 `npm test` —— 它需要一个跑着的浏览器与一个活的
* Gateway。日常回归靠 `../narrow-layout.test.mjs` 的结构性断言。
*
* 两者分工:结构性断言守住「代码写成了什么形态」,量不出「按钮实际多大、
* 点下去命中谁」。抽屉遮挡底部导航那个 bug`elementFromPoint` 命中抽屉里的
* SVG 而不是导航按钮)只有这样才能发现。
*
* 用法:
* ADMIN_PW=<密码> node client/electron/test/manual/narrow-verify.mjs
* ADMIN_PW=<密码> node client/electron/test/manual/wide-regression.mjs
*
* 环境变量:
* ADMIN_PW 必填,管理员密码
* AGENTMAIL_URL 目标地址,默认 https://mail.jianfgit.xyz
* CDP_URL 浏览器 CDP 端点,默认 http://127.0.0.1:9222
* PLAYWRIGHT playwright 入口,默认 /usr/lib/node_modules/playwright/index.mjs
*/
const PLAYWRIGHT = process.env.PLAYWRIGHT || '/usr/lib/node_modules/playwright/index.mjs';
const { chromium } = await import(PLAYWRIGHT);
const { readFile } = await import('node:fs/promises');
const { extname, join } = await import('node:path');
const CDP = process.env.CDP_URL || 'http://127.0.0.1:9222';
const APP = (process.env.AGENTMAIL_URL || 'https://mail.jianfgit.xyz').replace(/\/$/, '');
const DIST = process.env.AGENTMAIL_DIST?.replace(/\/$/, '');
const contentTypes = {
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.webp': 'image/webp',
'.woff2': 'font/woff2'
};
export const PHONE = { width: 390, height: 844 }; // iPhone 14 Pro
export const SMALL = { width: 320, height: 568 }; // iPhone SE 1 代
export const WIDE = { width: 1280, height: 800 };
export async function openApp(viewport = PHONE) {
const browser = await chromium.connectOverCDP(CDP);
const ctx = browser.contexts()[0] ?? (await browser.newContext());
const page = await ctx.newPage();
await page.setViewportSize(viewport);
const issues = [];
page.on('pageerror', e => issues.push('pageerror: ' + String(e).slice(0, 220)));
page.on('console', m => {
if (m.type() === 'error') {
const t = m.text();
// 401 是未登录时的正常探测,不算问题
if (!t.includes('401')) issues.push('console: ' + t.slice(0, 200));
}
});
// 在不替换、不重启现有 Gateway 的前提下验证本次构建:仅把页面壳和静态资源
// 从 dist 注入当前标签页API/SSE 仍由 APP 指向的真实 Gateway 提供。
if (DIST) {
const appOrigin = new URL(APP).origin;
await page.route(`${appOrigin}/**`, async route => {
const url = new URL(route.request().url());
if (url.pathname.startsWith('/api/')) {
await route.continue();
return;
}
const relative = url.pathname === '/' ? 'index.html' : url.pathname.replace(/^\/+/, '');
const file = join(DIST, relative);
if (!file.startsWith(DIST + '/') && file !== join(DIST, 'index.html')) {
await route.abort('blockedbyclient');
return;
}
try {
const body = await readFile(file);
await route.fulfill({
status: 200,
body,
contentType: contentTypes[extname(file)] || 'application/octet-stream'
});
} catch {
await route.continue();
}
});
}
// 不能用 networkidleSSE 是一条永不结束的长连接networkidle 永远不触发
await page.goto(APP + '/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2500);
// 需要登录时登录(同一 context 共享 cookie通常只登一次
if ((await page.locator('input[autocomplete=username]').count()) > 0) {
if (!process.env.ADMIN_PW) throw new Error('需要 ADMIN_PW 环境变量');
await page.fill('input[autocomplete=username]', process.env.ADMIN_USER || 'admin');
await page.fill('input[type=password]', process.env.ADMIN_PW);
await page.click('button:has-text("登录")');
await page.waitForTimeout(3500);
}
return { browser, page, issues };
}
/** 量一个元素的盒子;不存在返回 null。 */
export async function box(page, sel) {
const el = page.locator(sel).first();
if ((await el.count()) === 0) return null;
return await el.boundingBox();
}
/**
* 有没有横向溢出 —— 窄屏最常见的毛病。
*
* 只报 `right` 超过文档宽度的元素:溢出到左边通常是有意的负 margin。
*/
export async function overflowX(page) {
return await page.evaluate(() => {
const de = document.documentElement;
const over = [];
for (const el of document.querySelectorAll('*')) {
const r = el.getBoundingClientRect();
if (r.width > 0 && r.right > de.clientWidth + 1) {
over.push({
tag: el.tagName.toLowerCase(),
cls: (el.className || '').toString().slice(0, 70),
right: Math.round(r.right),
text: (el.textContent || '').trim().slice(0, 40)
});
}
}
return {
docWidth: de.clientWidth,
scrollWidth: de.scrollWidth,
bodyScrollWidth: document.body.scrollWidth,
offenders: over.slice(0, 8)
};
});
}
/**
* 点击目标够不够大。
*
* 量的是**视觉尺寸**;有 `.tap` 的按钮视觉上仍然很小,命中区在
* `::after` 伪元素上 —— 用 tapTargets() 才能看到真实命中区。
*/
export async function smallTargets(page, min = 40) {
return await page.evaluate(min => {
const bad = [];
for (const el of document.querySelectorAll(
'button, a, [role=button], input[type=checkbox]'
)) {
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue; // 隐藏的不算
if (r.height < min || r.width < min) {
bad.push({
tag: el.tagName.toLowerCase(),
w: Math.round(r.width),
h: Math.round(r.height),
text: (el.textContent || el.getAttribute('aria-label') || '').trim().slice(0, 28)
});
}
}
return bad;
}, min);
}
/**
* 量 `.tap` 按钮的真实命中区(`::after` 伪元素的尺寸)。
*
* @param labels 只看这些文字的按钮
*/
export async function tapTargets(page, labels) {
return await page.evaluate(labels => {
const out = [];
for (const b of document.querySelectorAll('button')) {
const t = (b.textContent || '').trim();
if (labels.length && !labels.includes(t)) continue;
const bb = b.getBoundingClientRect();
if (bb.width === 0) continue;
const cs = getComputedStyle(b, '::after');
out.push({
t,
visual: `${Math.round(bb.width)}x${Math.round(bb.height)}`,
hitW: Math.round(parseFloat(cs.width) || 0),
hitH: Math.round(parseFloat(cs.height) || 0)
});
}
return out;
}, labels);
}
/**
* 每个元素点下去是否命中自己。
*
* 这是抽屉遮挡 bug 的检测手段:按钮明明在那里、尺寸也够,
* 但上面盖了一层 `fixed z-50``elementFromPoint` 命中的是别人。
*/
export async function hitTest(page, selector) {
return await page.evaluate(selector => {
const out = [];
for (const el of document.querySelectorAll(selector)) {
const bb = el.getBoundingClientRect();
if (bb.width === 0) continue;
const top = document.elementFromPoint(
Math.round(bb.x + bb.width / 2),
Math.round(bb.y + bb.height / 2)
);
out.push({
text: (el.textContent || '').replace(/\s+/g, '').slice(0, 8),
hit: el.contains(top) || el === top
});
}
return out;
}, selector);
}
/**
* 每个页面是否**有**纵向滚动容器。
*
* 判据是「存在 overflow-y:auto|scroll 的容器」,不是「当前正在滚动」——
* 内容暂时不够高时后者为假,但页面是健康的。真正的 bug 是**根本没有**滚动
* 容器:内容一旦超过视口就被 `overflow-hidden` 的父级裁掉,没有任何办法看到。
*
* 「我的」页就是这样坏的:内容(资料+权限+改密码+密钥+退出)在 390px 下需要
* 860px容器只有 795px超出那 65px 连同「退出登录」按钮一起消失。
*
* @returns {{ hasScroller: boolean, scrollers: object[], clipped: object[] }}
*/
export async function scrollHealth(page) {
return await page.evaluate(() => {
const root = document.querySelector('#root');
const scrollers = [];
for (const el of root.querySelectorAll('*')) {
const cs = getComputedStyle(el);
if (cs.overflowY === 'auto' || cs.overflowY === 'scroll') {
scrollers.push({
cls: (el.className || '').toString().slice(0, 45),
scrollH: el.scrollHeight,
clientH: el.clientHeight,
needsScroll: el.scrollHeight > el.clientHeight + 4
});
}
}
// 内容超出但被 overflow:hidden 的父级裁掉 —— 这才是真正的问题
const clipped = [];
for (const el of root.querySelectorAll('*')) {
const cs = getComputedStyle(el);
if (el.scrollHeight > el.clientHeight + 20 && cs.overflowY === 'visible') {
const p = el.parentElement;
const pcs = p ? getComputedStyle(p) : null;
if (pcs && (pcs.overflow === 'hidden' || pcs.overflowY === 'hidden')) {
clipped.push({
cls: (el.className || '').toString().slice(0, 50),
have: el.clientHeight,
need: el.scrollHeight
});
}
}
}
return { hasScroller: scrollers.length > 0, scrollers, clipped: clipped.slice(0, 4) };
});
}

View File

@ -0,0 +1,174 @@
/**
* 窄屏实测验收390px 与 320px 下量真实盒子、真实命中。
*
* 每一条都对应一个曾经真实存在的问题(见 docs/PLAN.md §7.10.1
* 抽屉盖住底部导航、工具按钮只有 16px 高、看不见却按得动的「归档」、
* 对话树缩进把卡片压成竖条、对话树没有返回出口。
*
* 用法ADMIN_PW=<密码> node client/electron/test/manual/narrow-verify.mjs
*/
import {
openApp,
overflowX,
tapTargets,
hitTest,
scrollHealth,
SMALL
} from './narrow-probe-helper.mjs';
const { browser, page, issues } = await openApp();
const failed = [];
async function check(name, fn) {
try {
const r = await fn();
console.log(` ${r.ok ? '通过' : '失败'} ${name}${r.note ? ' — ' + r.note : ''}`);
if (!r.ok) failed.push(name);
} catch (e) {
console.log(` 错误 ${name}${e.message.slice(0, 90)}`);
failed.push(name);
}
}
/**
* 打开收件箱里的第一封邮件。
*
* 邮件行是 `<button class="w-full text-left ...">`,不是带 cursor-pointer 的 div
* —— 按后者找会一直等到超时。
*/
async function openFirstMail(page) {
await page.click('nav button:has-text("收件")');
await page.waitForTimeout(1000);
const rows = page.locator('button.w-full.text-left');
const n = await rows.count();
if (n === 0) throw new Error('收件箱是空的,没有邮件可点');
await rows.first().click();
await page.waitForTimeout(1200);
}
console.log('窄屏实测390px');
// 抽屉已删。它是 fixed z-50 铺满视口高度,把底部导航最左那项盖住点不到。
await check('抽屉入口已移除', async () => {
const n = await page.locator('button[aria-label="打开导航"]').count();
return { ok: n === 0, note: n ? `仍有 ${n}` : '' };
});
// 删抽屉时退出登录是它唯一的独有入口,必须有新去处
await check('「我的」页有退出登录', async () => {
await page.click('nav button:has-text("我的")');
await page.waitForTimeout(1200);
const btn = page.locator('button:has-text("退出登录")');
const n = await btn.count();
const b = n ? await btn.first().boundingBox() : null;
return {
ok: n === 1 && b.height >= 36,
note: b ? `${Math.round(b.width)}x${Math.round(b.height)}` : '找不到'
};
});
// 核心回归:底部导航每一项都要命中自己
await check('底部导航每项都命中自己', async () => {
const r = await hitTest(page, 'nav button');
const miss = r.filter(x => !x.hit);
return {
ok: r.length > 0 && miss.length === 0,
note: miss.length ? '未命中: ' + miss.map(m => m.text).join(',') : `${r.length} 项全部命中`
};
});
// 详情页工具按钮:视觉 15-16px命中区必须补到 44
await check('详情页工具按钮命中区 >= 44px', async () => {
await openFirstMail(page);
const r = await tapTargets(page, ['标记已读', '对话树', '转发', '抄送', '发送', '清空']);
for (const x of r) console.log(' ', JSON.stringify(x));
const small = r.filter(x => x.hitH < 44 || x.hitW < 44);
return {
ok: r.length > 0 && small.length === 0,
note: small.length ? '仍偏小: ' + small.map(s => s.t).join(',') : `${r.length} 个都达标`
};
});
// 次要动作在触摸设备上必须可见(没有 hover 时曾经永远透明却接收点击)
await check('联系人页次要动作默认可见', async () => {
await page.click('nav button:has-text("联系人")');
await page.waitForTimeout(1200);
const r = await page.evaluate(() =>
[...document.querySelectorAll('.reveal')].slice(0, 3).map(d => getComputedStyle(d).opacity)
);
return { ok: r.length > 0 && r.every(o => o === '1'), note: `opacity=${r.join(',')}` };
});
// 对话树:窄屏要有返回出口
await check('对话树有返回出口', async () => {
await openFirstMail(page);
const t = page.locator('button:has-text("对话树")');
if ((await t.count()) === 0) return { ok: false, note: '找不到对话树入口' };
await t.first().click();
await page.waitForTimeout(1600);
const n = await page.locator('button[aria-label="返回"]').count();
return { ok: n >= 1, note: `${n}` };
});
// 各页无横向溢出
for (const label of ['收件', '联系人', '管理', '我的']) {
await check(`${label}页无横向溢出`, async () => {
await page.click(`nav button:has-text("${label}")`);
await page.waitForTimeout(1100);
const of = await overflowX(page);
for (const o of of.offenders) console.log(' 超出:', JSON.stringify(o));
return { ok: of.scrollWidth <= of.docWidth, note: `doc=${of.docWidth} scroll=${of.scrollWidth}` };
});
}
// 每个页面都必须有纵向滚动容器 —— 否则内容一超过视口就被裁掉看不到。
// 「我的」页曾经缺这个390px 下内容需 860px、容器 795px
// 「退出登录」按钮连同下面 65px 一起消失,滚也滚不到。
for (const label of ['收件', '发件', '联系人', '管理', '我的']) {
await check(`${label}页有纵向滚动容器`, async () => {
await page.click(`nav button:has-text("${label}")`);
await page.waitForTimeout(1200);
const h = await scrollHealth(page);
for (const c of h.clipped) console.log(' 被裁:', JSON.stringify(c));
return {
ok: h.hasScroller && h.clipped.length === 0,
note: h.hasScroller
? `${h.scrollers.length} 个容器${h.clipped.length ? ',但有内容被裁' : ''}`
: '没有滚动容器'
};
});
}
console.log('\n最窄320px');
await page.setViewportSize(SMALL);
await page.waitForTimeout(800);
await check('320px 收件箱无横向溢出', async () => {
await page.click('nav button:has-text("收件")');
await page.waitForTimeout(1000);
const of = await overflowX(page);
return { ok: of.scrollWidth <= of.docWidth, note: `doc=${of.docWidth} scroll=${of.scrollWidth}` };
});
await check('320px 模型范围排序按钮命中区达标', async () => {
await page.click('nav button:has-text("管理")');
await page.waitForTimeout(900);
await page.click('button:has-text("模型范围")');
await page.waitForTimeout(1000);
const first = page.locator('button:has-text("dsh")').first();
if ((await first.count()) === 0) return { ok: false, note: '没有 Agent 可展开' };
await first.click();
await page.waitForTimeout(1600);
const r = await tapTargets(page, ['↑', '↓', '×']);
if (r.length === 0) return { ok: true, note: '当前没有已选模型,跳过' };
const small = r.filter(x => x.hitH < 44 || x.hitW < 44);
return { ok: small.length === 0, note: `${r.length} 个,最小 ${Math.min(...r.map(x => x.hitH))}px 高` };
});
console.log('\nissues:', issues.length ? issues : '无');
console.log(failed.length === 0 ? '\n窄屏实测全部通过' : `\n窄屏实测:${failed.length} 项失败 — ${failed.join(', ')}`);
await page.close();
await browser.close();
process.exit(failed.length === 0 ? 0 : 1);

View File

@ -0,0 +1,221 @@
import { readFile } from 'node:fs/promises';
import { extname, join } from 'node:path';
const PLAYWRIGHT = process.env.PLAYWRIGHT || '/usr/lib/node_modules/playwright/index.mjs';
const { chromium } = await import(PLAYWRIGHT);
const CDP = process.env.CDP_URL || 'http://127.0.0.1:9222';
const APP = 'http://127.0.0.1:8180';
const DIST = process.env.AGENTMAIL_DIST || '/home/program/agentmail/client/electron/dist';
const types = {
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.woff2': 'font/woff2'
};
const user = {
user_id: 'u-test', username: 'tester', display_name: '测试用户', role: 'admin',
status: 'active', allowed_agents: [], allowed_paths: [], created_at: '2025-01-01T00:00:00Z'
};
const mail = {
mail_id: 'm-test', session_id: 's-test', parent_mail_id: null,
from_name: 'deepseekharness', from_workspace: 'deepseekharness', to_name: 'tester', to_workspace: '',
cc_list: [], subject: '响应式布局测试邮件', body: '## 正文\n\n用于浏览器布局验证。', body_preview: '用于浏览器布局验证。',
mail_type: 'normal', permission_options: null, permission_result: null, status: 'unread',
created_at: '2025-01-02T03:04:00Z', session_alias: 'layout-test', session_workspace: '/program/test',
attachments: [], from_human: false, to_human: true
};
const contact = {
session_id: 's-test', agent_name: 'deepseekharness', path: '/program/test', session_alias: 'layout-test',
address: 'deepseekharness@/program/test.layout-test', status: 'active', mail_count: 1, unread_count: 1,
last_activity: '2025-01-02T03:04:00Z', subject: '响应式布局测试邮件', max_rounds: 0, used_rounds: 0,
last_from: 'deepseekharness', last_preview: '用于浏览器布局验证。'
};
function json(route, body, status = 200) {
return route.fulfill({ status, contentType: 'application/json; charset=utf-8', body: JSON.stringify(body) });
}
async function mockApi(route, url) {
const p = url.pathname.replace(/^\/api\/v1/, '');
if (p === '/auth/me') return json(route, { user });
if (p === '/setup/status') return json(route, { needs_setup: false });
if (p === '/me/mail/inbox') return json(route, { mails: [mail], total: 1 });
if (p === '/me/mail/sent') return json(route, { mails: [] });
if (p === '/me/sessions') return json(route, { sessions: [] });
if (p === '/contacts') return json(route, { contacts: [contact] });
if (p === '/contacts/suggest') {
return json(route, {
kind: 'name', suggestions: ['deepseekharness', 'pi', 'opencode'],
candidates: [
{ alias: 'deepseekharness', source: 'mail', title: '布局测试', unread: 1 },
{ alias: 'pi', source: 'platform', title: 'Pi' },
{ alias: 'opencode', source: 'platform', title: 'OpenCode' }
]
});
}
if (p === '/agents') return json(route, { agents: [] });
if (p === '/me/keys') return json(route, { keys: [] });
if (p === '/admin/users') return json(route, { users: [user] });
if (p === '/admin/scopes') return json(route, { agents: [], paths: [] });
if (p === '/admin/quotas') return json(route, { quotas: [] });
if (p === '/calendar/events') return json(route, { events: [] });
if (p === '/events/stream') {
return route.fulfill({ status: 200, contentType: 'text/event-stream', body: 'event: connected\ndata: {}\n\n' });
}
if (p === '/mail/m-test') return json(route, mail);
if (p === '/mail/m-test/read') return json(route, { status: 'ok' });
if (p === '/mail/m-test/thread') {
return json(route, { anchor_mail_id: 'm-test', root_mail_id: 'm-test', anchor_depth: 0, nodes: [], total: 0, hidden: 0, has_more: false, next_offset: 0 });
}
return json(route, { error: `unmocked ${p}` }, 404);
}
async function installRoutes(page) {
await page.route(`${APP}/**`, async route => {
const url = new URL(route.request().url());
if (url.pathname.startsWith('/api/v1/')) return mockApi(route, url);
const rel = url.pathname === '/' ? 'index.html' : url.pathname.replace(/^\/+/, '');
const file = join(DIST, rel);
try {
const body = await readFile(file);
return route.fulfill({ status: 200, body, contentType: types[extname(file)] || 'application/octet-stream' });
} catch {
return route.fulfill({ status: 404, body: 'not found' });
}
});
}
const browser = await chromium.connectOverCDP(CDP);
const context = browser.contexts()[0];
const failures = [];
let passed = 0;
function check(name, ok, detail = '') {
console.log(` ${ok ? '通过' : '失败'} ${name}${detail ? `${detail}` : ''}`);
if (ok) passed++; else failures.push(name);
}
async function open(viewport) {
const page = await context.newPage();
await page.setViewportSize(viewport);
await installRoutes(page);
await page.goto(`${APP}/`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('text=收件箱');
await page.waitForTimeout(250);
return page;
}
async function openCompose(page, narrow) {
if (narrow) await page.locator('nav button').filter({ hasText: '新建' }).click();
else await page.locator('button[title="新建邮件"]').click();
await page.waitForSelector('textarea');
}
async function measureCompose(page, name, narrow) {
await openCompose(page, narrow);
const m = await page.evaluate(() => {
const ta = document.querySelector('textarea');
const attach = [...document.querySelectorAll('button')].find(b => b.textContent?.includes('添加附件'));
const send = [...document.querySelectorAll('button')].find(b => b.textContent?.trim() === '发送');
const root = ta?.closest('.overflow-y-auto, .lg\\:overflow-hidden');
attach?.scrollIntoView({ block: 'center' });
const tr = ta?.getBoundingClientRect();
const ar = attach?.getBoundingClientRect();
const sr = send?.parentElement?.getBoundingClientRect();
return {
textareaH: tr?.height || 0,
textareaBottom: tr?.bottom || 0,
attachmentTop: ar?.top || 0,
attachmentBottom: ar?.bottom || 0,
footerTop: sr?.top || 0,
separated: !!tr && !!ar && ar.top >= tr.bottom - 1,
footerSeparated: !!ar && !!sr && sr.top >= ar.bottom - 1,
docW: document.documentElement.clientWidth,
scrollW: document.documentElement.scrollWidth,
composeScrollH: root?.scrollHeight || 0,
composeClientH: root?.clientHeight || 0
};
});
check(
narrow ? `${name} 正文不少于 12rem` : `${name} 桌面正文占据剩余高度`,
m.textareaH >= (narrow ? 191 : 120),
`${Math.round(m.textareaH)}px`
);
check(
`${name} 附件不遮挡正文`,
m.separated,
`正文底=${Math.round(m.textareaBottom)} 附件顶=${Math.round(m.attachmentTop)}`
);
check(
`${name} 操作栏不遮挡附件`,
m.footerSeparated,
`附件底=${Math.round(m.attachmentBottom)} 操作栏顶=${Math.round(m.footerTop)}`
);
check(`${name} 无横向溢出`, m.scrollW <= m.docW, `${m.docW}/${m.scrollW}`);
return m;
}
for (const viewport of [
{ width: 320, height: 568 }, { width: 390, height: 844 },
{ width: 768, height: 900 }, { width: 820, height: 980 }
]) {
const page = await open(viewport);
const label = `${viewport.width}x${viewport.height}`;
check(`${label} 使用单栏底部导航`, await page.locator('nav.narrow-nav').isVisible());
await measureCompose(page, label, true);
await page.close();
}
{
const page = await open({ width: 390, height: 420 });
const m = await measureCompose(page, '390x420 键盘态', true);
check('键盘态写信页可纵向滚动', m.composeScrollH > m.composeClientH, `${m.composeClientH}/${m.composeScrollH}`);
await page.locator('textarea').focus();
await page.waitForTimeout(50);
const navHidden = await page.locator('nav.narrow-nav').evaluate(el => getComputedStyle(el).display === 'none');
check('输入时隐藏底部导航', navHidden);
const to = page.locator('input[placeholder*="deepseekharness"]').first();
await to.fill('d');
await page.waitForTimeout(250);
const menu = page.locator('div.absolute.z-20').first();
const bounds = await menu.evaluate(el => {
const r = el.getBoundingClientRect();
return { top: r.top, bottom: r.bottom, vh: window.visualViewport?.height || innerHeight };
});
check('地址补全不越出可视视口', bounds.top >= 0 && bounds.bottom <= bounds.vh + 1, JSON.stringify(bounds));
await page.close();
}
for (const viewport of [{ width: 1024, height: 768 }, { width: 1280, height: 800 }]) {
const page = await open(viewport);
const label = `${viewport.width}x${viewport.height}`;
check(`${label} 使用桌面侧栏`, await page.locator('button[title="新建邮件"]').isVisible());
await measureCompose(page, label, false);
await page.close();
}
{
const page = await open({ width: 1280, height: 800 });
for (const mode of ['light', 'dark']) {
await page.evaluate(mode => {
localStorage.setItem('agentmail.theme', mode);
document.documentElement.classList.toggle('dark', mode === 'dark');
document.documentElement.style.colorScheme = mode;
}, mode);
const colors = await page.evaluate(() => ({
body: getComputedStyle(document.body).backgroundColor,
card: getComputedStyle(document.querySelector('.bg-white')).backgroundColor,
text: getComputedStyle(document.querySelector('.text-gray-900') || document.body).color
}));
check(`${mode} 主题颜色均已解析`, !Object.values(colors).some(c => c === 'rgba(0, 0, 0, 0)'), JSON.stringify(colors));
}
await page.close();
}
console.log(`\n响应式浏览器验收:${passed} 通过,${failures.length} 失败`);
if (failures.length) console.log(failures.join('\n'));
await browser.close();
process.exit(failures.length ? 1 : 0);

View File

@ -0,0 +1,148 @@
/**
* 深色主题手工验收。
*
* 需要共享 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);

View File

@ -0,0 +1,583 @@
/**
* WebUI 能力巡检 —— 覆盖没有专项脚本守着的界面。
*
* 已有六个专项脚本各守一件事(窄屏覆盖式布局、宽屏三栏回归、深色主题、
* 强调色对比度、收件箱分组、地址维度)。它们加起来仍有一大半界面没人看:
* 日历三视图与事件编辑器、新建邮件的三段式补全、卡片/列表双视图、对话树、
* 转发页、管理页四个 tab、账号页与密钥面板。这个脚本补的就是那部分。
*
* # 只读原则(它跑在生产库上)
*
* 一律不做不可逆动作:不发信、不建日程、不停用/删除 Agent、不吊销密钥、
* 不改密码。表单只打开与填写,靠「取消」退出。
*
* 归档是唯一需要解释的例外:点每行的归档图标只调 `requestArchive`,那是个
* **纯前端状态**(把该地址记进 `pendingArchive` 以渲染确认框),真正发请求的是
* 确认框里的「确认归档」。所以这里点图标 → 验确认框 → 点取消,全程零副作用。
*
* # 三条定位纪律(每条都是被一次假失败教出来的)
*
* **一、走 title / placeholder不走可见文字。** 侧栏图标按钮的可见文字是缩写
* `收件`/`联系`/`用户`),头像按钮的可见文字是用户名前两字 —— 按全名去
* `hasText` 一个都点不到。而「新建」在侧栏(新建邮件)与日历头部(新建日程)
* 各有一个,靠文字选会点错页面:侧栏那个有 `title`,日历那个没有。
*
* **二、单字按钮必须精确匹配。** 日历的视图切换是 `月`/`周`/`日` 三个单字按钮,
* 而 `hasText` 是子串匹配 —— `日` 会先命中侧栏的 `日历`,于是「切到日视图」
* 实际上点了导航scale 一直停在上一个视图,然后「日视图没有小时刻度」
* 报一个假失败。
*
* **三、判「某控件在不在」不搜 body 全文。** `AddressInput` 补全下拉的 hint
* 里就写着「会话别名new 为新建)」,全文搜索会把补全提示误当成那个只在
* 新建会话时出现的输入框。判据要落在 placeholder / inputmode 上。
*
* 用法:
* ADMIN_PW=<密码> ADMIN_USER=jianf AGENTMAIL_URL=http://127.0.0.1:8180 \
* node client/electron/test/manual/ui-sweep.mjs
*/
import { openApp, WIDE } from './narrow-probe-helper.mjs';
const { browser, page, issues } = await openApp(WIDE);
let pass = 0;
const fails = [];
const skips = [];
const ok = (n, d = '') => { pass++; console.log(` 通过 ${n}${d ? ' — ' + d : ''}`); };
const bad = (n, d = '') => { fails.push(n); console.log(` 失败 ${n}${d ? ' — ' + d : ''}`); };
const skip = (n, why) => { skips.push(n); console.log(` 跳过 ${n}${why}`); };
const check = (n, cond, d = '') => (cond ? ok(n, d) : bad(n, d));
async function section(title, fn) {
console.log(`\n─── ${title} ───`);
try {
await fn();
} catch (e) {
bad(`${title} 整段异常`, String(e?.message || e).slice(0, 150));
}
}
/** 点一个 CSS 选择器命中的元素;找不到返回 false 而不是抛。 */
async function click(sel, wait = 0) {
const loc = page.locator(sel).first();
if ((await loc.count()) === 0) return false;
await loc.click({ timeout: 5000 }).catch(() => {});
if (wait) await page.waitForTimeout(wait);
return true;
}
/** 侧栏导航可见文字是缩写title 才是全名。 */
const nav = (title, wait = 1200) => click(`button[title="${title}"]`, wait);
/** 按可见文字点按钮(子串匹配,用于 tab、取消这类长标签。 */
async function clickText(text, wait = 0) {
const loc = page.locator('button').filter({ hasText: text }).first();
if ((await loc.count()) === 0) return false;
await loc.click({ timeout: 5000 }).catch(() => {});
if (wait) await page.waitForTimeout(wait);
return true;
}
/** 按可见文字**精确**点按钮。单字标签(月/周/日)必须走这个。 */
async function clickExact(text, wait = 0) {
const loc = page.locator('button').filter({ hasText: new RegExp(`^${text}$`) }).first();
if ((await loc.count()) === 0) return false;
await loc.click({ timeout: 5000 }).catch(() => {});
if (wait) await page.waitForTimeout(wait);
return true;
}
const has = (sel) => page.locator(sel).count().then((n) => n > 0);
const bodyText = () => page.evaluate(() => document.body.innerText);
const titles = () =>
page.evaluate(() =>
[...document.querySelectorAll('button[title]')].map((b) => b.title).slice(0, 24));
await page.waitForSelector('button', { timeout: 20000 });
// ─────────────────────────────────────────────────────────────
await section('实时推送与连接状态', async () => {
check('EventSource 可用', await page.evaluate(() => typeof window.EventSource === 'function'));
// 指示器只在异常时出声是合理设计 —— 这里要的是「不会在正常时误报断线」
const shown = await page.evaluate(() =>
[...document.querySelectorAll('*')]
.filter((e) => e.children.length === 0)
.map((e) => (e.textContent || '').trim())
.find((t) => /已断开|连接中|重连中/.test(t)) || null);
check('正常状态下不误报断线', !shown, shown ? `显示了「${shown}` : '');
check('侧栏头像上挂着连接状态点', await has('button[title*="点击管理账号"] span'));
});
// ─────────────────────────────────────────────────────────────
await section('日历:月 / 周 / 日三视图 + 农历', async () => {
if (!(await nav('日历', 3200))) return bad('找不到日历入口', (await titles()).join(' / '));
// 月视图与周视图都是 grid-cols-7靠子元素数区分月 = 7 表头 + 42 格,周 = 7 列
const cells = () => page.locator('[class*="grid-cols-7"] > *').count();
const hourMarks = async () => ((await bodyText()).match(/\b([01]\d|2[0-3]):00\b/g) || []).length;
const month = await page.evaluate(() => {
const txt = document.body.innerText;
return {
weekHeader: /日[\s\S]{0,8}一[\s\S]{0,8}二[\s\S]{0,8}三/.test(txt),
lunar: (txt.match(/初[一二三四五六七八九十]|十[一二三四五]|廿[一二三四五六七八九]|正月|腊月|冬月/g) || []).length,
scaleBtns: [...document.querySelectorAll('button')]
.map((b) => (b.innerText || '').trim())
.filter((t) => ['月', '周', '日'].includes(t)),
};
});
check('月视图有星期表头', month.weekHeader);
check('月视图是整月网格', (await cells()) >= 42, `${await cells()} 个格子`);
check('显示农历', month.lunar > 0, `命中 ${month.lunar}`);
check('有三档视图切换', month.scaleBtns.length === 3, month.scaleBtns.join(','));
check('月视图不按小时排', (await hourMarks()) === 0);
// 周视图7 列按天,每列头显示 M.D。它刻意**不**按小时排 ——
// 一周 × 24 小时的格子在任何屏宽下都读不了,按小时是日视图的事
if (await clickExact('周', 1500)) {
const dates = ((await bodyText()).match(/\b\d{1,2}\.\d{1,2}\b/g) || []).length;
check('周视图是 7 列', (await cells()) === 7, `${await cells()}`);
check('周视图每列有日期', dates >= 7, `${dates} 个 M.D`);
check('周视图不按小时排', (await hourMarks()) === 0);
} else skip('周视图', '按钮不存在');
// 日视图唯一按小时排的视图HOURS 是完整 24 行
if (await clickExact('日', 1500)) {
const marks = await hourMarks();
check('日视图有 24 行小时刻度', marks >= 24, `${marks} 个 HH:00`);
} else skip('日视图', '按钮不存在');
await clickExact('月', 1200);
});
// ─────────────────────────────────────────────────────────────
await section('日历:新建事件(打开后取消,不保存)', async () => {
// 侧栏的「新建」有 title="新建邮件",日历头部那个没有 title —— 用它区分
const calNew = page.locator('button:not([title])').filter({ hasText: /^新建$/ }).first();
if ((await calNew.count()) === 0) return skip('新建事件', '找不到日历的新建按钮');
await calNew.click().catch(() => {});
await page.waitForTimeout(1500);
const ed = await page.evaluate(() => {
const txt = document.body.innerText;
const inputs = [...document.querySelectorAll('input, select, textarea')];
return {
count: inputs.length,
kinds: [...new Set(inputs.map((i) => i.type || i.tagName.toLowerCase()))].join(','),
title: /标题/.test(txt),
time: !!document.querySelector('input[type=datetime-local]'),
remind: /提前|提醒/.test(txt),
recur: /重复|不重复|每天|每周|每月|每年/.test(txt),
lunar: /农历/.test(txt),
preview: /Agent 会收到/.test(txt),
// 附件要挂在某个 event_id 上才能上传,所以新建态**刻意**没有附件段
// `{editing && ...}`)—— 存盘后再打开才有
attach: /附件(随提醒邮件一起发出)/.test(txt),
// 单收件人时不该出现投递模式:那是多收件人才有的选择
deliveryModeEarly: /分别发送|一起发送/.test(txt),
};
});
check('有输入控件', ed.count >= 3, `${ed.count} 个(${ed.kinds}`);
check('有标题字段', ed.title);
check('有 datetime-local 时间选择', ed.time);
check('有提醒提前量', ed.remind);
check('有重复规则', ed.recur);
check('重复规则含农历选项', ed.lunar);
check('有「Agent 会收到」正文预览', ed.preview);
check('新建态不显示附件段(附件需先有 event_id', !ed.attach);
check('收件人不足两个时不显示投递模式', !ed.deliveryModeEarly);
// 加两个收件人:`unusedAgents` 快捷按钮的文字是 `+ <agent名>`
const added = await page.evaluate(() => {
const btns = [...document.querySelectorAll('button')]
.filter((b) => /^\+\s+\S+$/.test((b.innerText || '').trim()));
btns.slice(0, 2).forEach((b) => b.click());
return btns.slice(0, 2).map((b) => (b.innerText || '').trim());
});
await page.waitForTimeout(900);
if (added.length < 2) {
skip('多收件人投递模式', `只找到 ${added.length} 个快捷收件人按钮`);
} else {
const dm = await page.evaluate(() => {
const txt = document.body.innerText;
return {
separate: /分别发送/.test(txt),
together: /一起发送/.test(txt),
radios: document.querySelectorAll('input[type=radio]').length,
// 两种模式的后果必须写清楚,否则人分不出该选哪个
explains: /互相看不到/.test(txt) && /共享同一条线索/.test(txt),
};
});
check('多收件人出现「分别发送」', dm.separate, added.join(' '));
check('多收件人出现「一起发送」', dm.together);
check('投递模式是单选而非多选', dm.radios >= 2, `${dm.radios} 个 radio`);
check('两种模式都说明了后果', dm.explains);
}
if (!(await clickText('取消', 900))) await page.keyboard.press('Escape');
});
// ─────────────────────────────────────────────────────────────
await section('日历:打开已有事件(附件段只在编辑态出现)', async () => {
// 月视图格子是 div事件胶囊才是带 title 的 button
const chip = page.locator('[class*="grid-cols-7"] button[title]').first();
if ((await chip.count()) === 0) return skip('已有事件编辑态', '本月没有事件可点');
const label = await chip.getAttribute('title');
await chip.click().catch(() => {});
await page.waitForTimeout(1500);
const e = await page.evaluate(() => {
const txt = document.body.innerText;
return {
attach: /附件(随提醒邮件一起发出)/.test(txt),
// 暂停/取消的事件不再触发提醒 —— 状态必须可改,否则只能删了重建
status: !!document.querySelector('select') && /暂停|已取消/.test(txt),
del: [...document.querySelectorAll('button')].some((b) => /删除/.test(b.innerText || '')),
};
});
check('编辑已有事件时出现附件段', e.attach, label || '');
check('可改事件状态', e.status);
check('有删除入口(未点击)', e.del);
if (!(await clickText('取消', 900))) await page.keyboard.press('Escape');
});
// ─────────────────────────────────────────────────────────────
await section('新建邮件:整页 + 三段式补全', async () => {
await nav('收件箱', 1000);
if (!(await nav('新建邮件', 1500))) return bad('找不到新建邮件入口', (await titles()).join(' / '));
const c = await page.evaluate(() => {
const txt = document.body.innerText;
return {
// 「新建邮件 = 右侧整页而非弹窗」是明确的设计决定:
// 判据是没有铺满视口的半透明遮罩
overlay: !!document.querySelector('div.fixed.inset-0[class*="bg-black"]'),
subject: /主题/.test(txt),
cc: /抄送/.test(txt),
attach: /附件/.test(txt),
preview: /预览/.test(txt),
};
});
check('是整页而非弹窗', !c.overlay);
check('有主题', c.subject);
check('有正文 textarea', await has('textarea'));
check('有抄送', c.cc);
check('有附件入口', c.attach);
check('正文有 Markdown 预览', c.preview);
const addr = page.locator('input[spellcheck=false]').first();
const typeAddr = async (v) => {
await addr.click().catch(() => {});
await addr.fill(v).catch(() => {});
await page.waitForTimeout(1300);
};
// 补全下拉是 button 列表,取每项首行当候选
const options = () =>
page.evaluate(() =>
[...document.querySelectorAll('div.absolute.z-20 button')]
.map((b) => (b.innerText || '').trim().split('\n')[0]));
await typeAddr('p');
const s1 = await options();
check('name 段有候选', s1.includes('pi'), `${s1.length} 个:${s1.slice(0, 4).join(' ')}`);
await typeAddr('pi@');
const s2 = await options();
check('path 段有候选', s2.some((o) => o.startsWith('/')), s2.slice(0, 3).join(' '));
await typeAddr('pi@/home/program/agentmail.');
const s3 = await options();
check('session 段候选含保留字 new', s3.includes('new'), `${s3.length} 个候选`);
// `session_alias` 与 `max_rounds` 只在本次投递新建会话时生效 —— 界面必须据此
// 显隐,否则人会以为续谈时也能改这两个约定
await typeAddr('pi@/home/program/agentmail.new');
await page.keyboard.press('Escape'); // 关掉补全,免得它盖住下面的字段
await page.waitForTimeout(500);
check('.new 时出现「会话别名」输入', await has('input[placeholder="refactor-auth"]'));
check('.new 时出现「往返预算」输入', await has('input[inputmode="numeric"]'));
await typeAddr('pi@/home/program/agentmail.子任务-跑一条命令');
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
check('续谈已有会话时隐藏「会话别名」', !(await has('input[placeholder="refactor-auth"]')));
check('续谈已有会话时隐藏「往返预算」', !(await has('input[inputmode="numeric"]')));
if (!(await clickText('取消', 900))) await page.keyboard.press('Escape');
});
// ─────────────────────────────────────────────────────────────
await section('对话树 + 转发页(填完取消)', async () => {
await nav('收件箱', 1300);
// 收件箱按会话分组:组头与邮件行的 className 都含 `w-full text-left`
// 靠「点完有没有出现含『发件』的 dl」区分
let opened = false;
for (let round = 0; round < 3 && !opened; round++) {
const btns = await page.$$('button.w-full.text-left');
for (const b of btns) {
await b.click().catch(() => {});
await page.waitForTimeout(340);
opened = await page.evaluate(() =>
[...document.querySelectorAll('dl')].some((dl) => dl.textContent.includes('发件')));
if (opened) break;
}
}
if (!opened) return bad('打开一封邮件', '没找到可点开的邮件行');
ok('打开一封邮件');
if (await clickText('对话树', 1800)) {
const tree = await page.evaluate(() => ({
back: [...document.querySelectorAll('button')].some((b) => /返回|关闭|收起/.test(b.innerText || '')),
// 每个节点按 depth 缩进style={{ paddingLeft: indent }}
nodes: document.querySelectorAll('[style*="padding-left"]').length,
}));
check('对话树有返回出口', tree.back);
check('对话树按层级缩进', tree.nodes > 0, `${tree.nodes} 个节点`);
for (const t of ['返回', '关闭', '收起']) if (await clickText(t, 1000)) break;
} else skip('对话树', '按钮不存在');
if (await clickText('转发', 1300)) {
const f = await page.evaluate(() => ({
inputs: document.querySelectorAll('input').length,
cc: /抄送/.test(document.body.innerText),
hint: /转发|Fwd|原文|新收件人/.test(document.body.innerText),
}));
check('转发页有收件人输入', f.inputs > 0, `${f.inputs} 个输入框`);
check('转发页有抄送', f.cc);
check('转发页有转发语境提示', f.hint);
if (!(await clickText('取消', 900))) await page.keyboard.press('Escape');
} else skip('转发', '按钮不存在');
});
// ─────────────────────────────────────────────────────────────
await section('管理页四个 tab只看不动', async () => {
if (!(await nav('用户管理', 1700))) return bad('找不到管理入口', (await titles()).join(' / '));
const tabs = await page.evaluate(() =>
[...document.querySelectorAll('button')]
.map((b) => (b.innerText || '').replace(/\s+/g, ' ').trim())
.filter((t) => /^(用户管理|Agent 密钥|Agent 管理|模型范围)/.test(t)));
check('四个 tab 都在', tabs.length >= 4, tabs.join(' | '));
const u = await page.evaluate(() => ({
admin: /jianf/.test(document.body.innerText),
role: /管理员/.test(document.body.innerText),
create: [...document.querySelectorAll('button')].some((b) => /新建用户/.test(b.innerText || '')),
}));
check('用户列表显示管理员', u.admin);
check('显示角色徽标', u.role);
check('有新建用户入口', u.create);
if (await clickText('Agent 密钥', 1500)) {
const k = await page.evaluate(() => {
const codes = [...document.querySelectorAll('code')].map((c) => (c.textContent || '').trim());
return {
heading: /Agent 接入密钥/.test(document.body.innerText),
n: codes.length,
max: Math.max(0, ...codes.map((c) => c.length)),
sample: codes.slice(0, 3),
};
});
check('标题是「Agent 接入密钥」', k.heading);
check('列出了密钥', k.n > 0, `${k.n}`);
// 密钥全文只在刚签发时展示一次,列表只该给 token_hint
check('列表不泄露密钥全文', k.max <= 24, `最长 ${k.max} 字符:${k.sample.join(' ')}`);
// 三种生命周期在创建表单里,表单默认收起 —— 不展开是看不到的
if (await clickText('新建密钥', 1100)) {
const f = await page.evaluate(() => {
const txt = document.body.innerText;
return {
types: ['长期', '一次性', '限时'].filter((t) => txt.includes(t)),
hints: /永不过期/.test(txt) && /首次使用后立即失效/.test(txt),
// Agent 面板独有的两项:绑定 Agent 名、登记插件本地生成的密钥
agentOnly: document.querySelectorAll(
'input[placeholder*="绑定到 Agent"], input[placeholder*="登记插件"]').length,
};
});
check('三种生命周期都在', f.types.length === 3, f.types.join(','));
check('每种都写了含义', f.hints);
check('Agent 面板有绑定/登记两项', f.agentOnly === 2, `${f.agentOnly}`);
if (!(await clickText('取消', 900))) await page.keyboard.press('Escape');
ok('未创建任何密钥(点了取消)');
} else skip('密钥生命周期', '找不到新建密钥按钮');
} else skip('Agent 密钥 tab', '按钮不存在');
if (await clickText('Agent 管理', 1500)) {
const a = await page.evaluate(() => {
const txt = document.body.innerText;
return {
agents: ['pi', 'opencode', 'dsh', 'homeagent'].filter((n) => new RegExp(`\\b${n}\\b`).test(txt)),
online: (txt.match(/在线/g) || []).length,
budgetInputs: document.querySelectorAll('input[inputmode=numeric]').length,
disable: document.querySelectorAll('button[title*="停用"], button[title*="恢复"]').length,
del: document.querySelectorAll('button[title*="彻底删除"]').length,
// 停用/恢复的后果必须写在界面上(密钥不会自动回来)。少了这句,
// 实测代价是 opencode 拿旧密钥重试 18 小时、gateway 日志 2690 次 401
warnsKeys: /密钥不会自动回来|重新签发/.test(txt),
};
});
check('四个 Agent 都列出来了', a.agents.length === 4, a.agents.join(','));
check('显示在线状态', a.online > 0, `${a.online} 处「在线」`);
check('有默认预算输入框', a.budgetInputs >= 4, `${a.budgetInputs}`);
check('有停用/恢复入口', a.disable > 0, `${a.disable}`);
check('有删除入口', a.del > 0, `${a.del}`);
check('说明了恢复后须重新签发密钥', a.warnsKeys);
ok('未点击任何停用/删除(只读巡检)');
} else skip('Agent 管理 tab', '按钮不存在');
if (await clickText('模型范围', 1500)) {
const before = (await bodyText()).length;
// 明确展开 opencode本机只有它的目录足够大24 个模型)。
// 随便点第一行可能落在 homeagent —— 它一个模型都没上报,
// 于是「清单长什么样」根本验不到。
let expanded = false;
for (const r of await page.$$('button')) {
if ((await r.innerText().catch(() => '')).trim().startsWith('opencode')) {
await r.click().catch(() => {});
expanded = true;
break;
}
}
if (!expanded) return skip('模型清单', '找不到 opencode 那一行');
await page.waitForTimeout(1900);
const m = await page.evaluate(() => {
const txt = document.body.innerText;
return {
len: txt.length,
catalogHeader: /平台上报的模型/.test(txt),
// 「配可用模型必须是选择而非手打」:清单是一排可切换的等宽按钮,
// 不是让人敲模型名的输入框
toggles: [...document.querySelectorAll('button')]
.filter((b) => /font-mono/.test(b.className || '')).length,
freeText: [...document.querySelectorAll('input')]
.filter((i) => /model|模型/i.test(i.placeholder || '')).length,
order: /尝试顺序|按序尝试/.test(txt),
unrestricted: /不限定/.test(txt),
};
});
check('展开后内容变多', m.len > before, `${before}${m.len} 字符`);
check('列出平台上报的模型清单', m.catalogHeader);
check('模型是点选而非手打', m.toggles > 0 && m.freeText === 0,
`${m.toggles} 个可切换项 / ${m.freeText} 个自由输入`);
check('说明了顺序即优先级', m.order);
check('说明了不选 = 不限定', m.unrestricted);
} else skip('模型范围 tab', '按钮不存在');
});
// ─────────────────────────────────────────────────────────────
await section('账号页:密钥面板 + 主题三态', async () => {
// 账号入口是头像按钮,可见文字是用户名前两字
if (!(await click('button[title*="点击管理账号"]', 1700))) {
return bad('找不到账号入口', (await titles()).join(' / '));
}
const a = await page.evaluate(() => {
const txt = document.body.innerText;
return {
profile: /基本资料/.test(txt),
pwd: /修改密码/.test(txt),
clientKeys: /客户端连接密钥/.test(txt),
cannotRegister: /不能用于注册 Agent/.test(txt),
logout: /退出登录/.test(txt),
themes: [...document.querySelectorAll('button[title]')]
.map((b) => b.title)
.filter((t) => /始终使用浅色|始终使用深色|随系统/.test(t)),
};
});
check('显示基本资料', a.profile);
check('有修改密码(未提交)', a.pwd);
check('有客户端连接密钥面板', a.clientKeys);
check('写明用户密钥不能注册 Agent', a.cannotRegister);
check('主题是三态而非开关', a.themes.length === 3, a.themes.join(' / '));
check('有退出登录(未点击)', a.logout);
// 同一个 KeyPanel 的 user variant不该出现 Agent 专属的两项
if (await clickText('新建密钥', 1100)) {
const n = await page.evaluate(() => document.querySelectorAll(
'input[placeholder*="绑定到 Agent"], input[placeholder*="登记插件"]').length);
check('用户面板没有绑定/登记项', n === 0, `${n}`);
if (!(await clickText('取消', 900))) await page.keyboard.press('Escape');
} else skip('用户密钥面板 variant 差异', '找不到新建密钥按钮');
});
// ─────────────────────────────────────────────────────────────
await section('联系人页:列表 / 卡片双视图 + 归档确认框', async () => {
// 中间栏的列表/卡片切换属于 ContactPanel而它只在「联系人」视图挂载 ——
// 在收件箱里那一栏是 MailList找不到这个按钮
if (!(await nav('联系人', 1600))) return bad('找不到联系人入口', (await titles()).join(' / '));
const c = await page.evaluate(() => ({
heading: /联系人/.test(document.body.innerText),
archiveBtns: document.querySelectorAll('button[title^="归档该"]').length,
paths: (document.body.innerText.match(/\/home\/|\/tmp\/|\/root/g) || []).length,
viewArchived: [...document.querySelectorAll('button')].some((b) => (b.innerText || '').trim() === '归档'),
}));
check('中间栏标题是「联系人」', c.heading);
check('列出联系人行', c.archiveBtns > 0, `${c.archiveBtns}`);
check('行内显示工作区路径', c.paths > 0, `${c.paths} 处路径`);
check('头部有「查看已归档」开关', c.viewArchived);
if (!(await click('button[title="切换到卡片视图"]', 1700))) {
skip('卡片视图', '没找到切到卡片的按钮');
} else {
const card = await page.evaluate(() => ({
heading: /工作列表/.test(document.body.innerText),
// 卡片带预算徽标title 形如「往返预算:已用 3/20」
budgetChip: document.querySelectorAll('[title^="往返预算"]').length,
pref: localStorage.getItem('agentmail.contactView'),
}));
check('切到卡片后标题变「工作列表」', card.heading);
check('卡片有往返预算徽标', card.budgetChip > 0, `${card.budgetChip}`);
check('视图偏好落 localStorage', card.pref === 'card', `agentmail.contactView=${card.pref}`);
check('能切回列表视图', await click('button[title="切换到列表视图"]', 1400));
const back = await page.evaluate(() => ({
heading: /联系人/.test(document.body.innerText),
pref: localStorage.getItem('agentmail.contactView'),
}));
check('切回后标题恢复「联系人」', back.heading);
check('偏好跟着回到 list', back.pref === 'list', `agentmail.contactView=${back.pref}`);
}
if (c.archiveBtns === 0) return skip('归档确认框', '没有归档按钮');
// requestArchive 只写前端状态(为渲染确认框),不发请求 —— 点它是安全的
await click('button[title^="归档该"]', 1000);
const confirm = await page.evaluate(() => {
const txt = document.body.innerText;
return {
// 确认框是唯一渲染 contact.address 全文的地方,顺带验三段地址成形
addr: (txt.match(/[a-z][\w.-]*@\/[^\s?]+/) || [null])[0],
explains: /session 将被归档/.test(txt),
confirmBtn: [...document.querySelectorAll('button')].some((b) => /确认归档/.test(b.innerText || '')),
cancelBtn: [...document.querySelectorAll('button')].some((b) => (b.innerText || '').trim() === '取消'),
};
});
check('确认框显示完整三段地址', !!confirm.addr && confirm.addr.includes('.'), confirm.addr || '(没找到)');
check('确认框说明了后果', confirm.explains);
check('确认框有确认按钮(未点击)', confirm.confirmBtn);
check('确认框可取消', confirm.cancelBtn);
if (confirm.cancelBtn) {
await clickText('取消', 900);
const gone = await page.evaluate(() =>
![...document.querySelectorAll('button')].some((b) => /确认归档/.test(b.innerText || '')));
check('取消后确认框消失,未归档任何会话', gone);
}
});
// ─────────────────────────────────────────────────────────────
console.log('\n─── JS 运行时错误 ───');
if (issues.length === 0) ok('无 pageerror / console.error');
else {
bad(`${issues.length} 条运行时问题`);
issues.slice(0, 8).forEach((i) => console.log(' ', i));
}
await browser.close();
console.log(`\n═══ ${pass} 通过 / ${fails.length} 失败 / ${skips.length} 跳过 ═══`);
if (fails.length) { console.log('失败项:'); fails.forEach((f) => console.log(' -', f)); }
process.exit(fails.length ? 1 : 0);

View File

@ -0,0 +1,71 @@
/**
* 宽屏回归:窄屏修复不能把桌面布局改坏。
*
* 特别是两个只该在窄屏生效的东西:
* - `.tap` 的伪元素命中区(桌面密排工具栏里会互相重叠)
* - `BackButton`(宽屏列表与详情并排,返回没有意义)
*
* 用法ADMIN_PW=<密码> node client/electron/test/manual/wide-regression.mjs
*/
import { openApp, WIDE } from './narrow-probe-helper.mjs';
const { browser, page, issues } = await openApp(WIDE);
const failed = [];
const chk = (n, ok, note = '') => {
console.log(` ${ok ? '通过' : '失败'} ${n}${note ? ' — ' + note : ''}`);
if (!ok) failed.push(n);
};
console.log('宽屏回归1280px');
const cols = await page.evaluate(() => {
const root = document.querySelector('#root > div');
return { n: root?.children.length, first: root?.children[0]?.className?.toString().slice(0, 30) };
});
chk('仍是三栏并排', cols.n === 3, `栏数=${cols.n} 首栏=${cols.first}`);
// 常驻侧栏是宽屏唯一的退出入口(账号页也有,两处都要在)
const side = await page.evaluate(() => {
const s = document.querySelector('#root > div > div');
const btns = s
? [...s.querySelectorAll('button')].map(b =>
(b.getAttribute('title') || b.textContent || '').trim().slice(0, 12)
)
: [];
return { w: s ? Math.round(s.getBoundingClientRect().width) : 0, btns };
});
chk('常驻侧栏仍有退出登录', side.btns.some(b => b.includes('退出')), `宽=${side.w}`);
// 宽屏不该出现返回按钮
// 邮件行是 <button class="w-full text-left ...">
const rows = page.locator('button.w-full.text-left');
if ((await rows.count()) > 0) await rows.first().click();
await page.waitForTimeout(1000);
const backN = await page.locator('button[aria-label="返回"], button[aria-label="会话"]').count();
chk('没有返回按钮', backN === 0, `${backN}`);
// .tap 只在 max-width:767px 生效
const tapWide = await page.evaluate(() => {
const b = [...document.querySelectorAll('.tap')].find(x => x.getBoundingClientRect().height > 0);
if (!b) return null;
const cs = getComputedStyle(b, '::after');
return { content: cs.content, w: cs.width, h: cs.height };
});
chk(
'.tap 伪元素在宽屏不生效',
!tapWide || tapWide.content === 'none' || tapWide.w === 'auto',
JSON.stringify(tapWide)
);
const of = await page.evaluate(() => ({
d: document.documentElement.clientWidth,
s: document.documentElement.scrollWidth
}));
chk('无横向溢出', of.s <= of.d, `doc=${of.d} scroll=${of.s}`);
console.log('\nissues:', issues.length ? issues : '无');
console.log(failed.length === 0 ? '\n宽屏回归全部通过' : `\n宽屏回归:${failed.length} 项失败`);
await page.close();
await browser.close();
process.exit(failed.length === 0 ? 0 : 1);