/** * 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` 快捷按钮的文字是 `+ ` 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);