Files
MailUI4Agents/client/electron/test/manual/ui-sweep.mjs

584 lines
30 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.

/**
* 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);