test: session-scan 内存泄漏测试 + ui-sweep 界面验收脚本
This commit is contained in:
583
web/test/manual/ui-sweep.mjs
Normal file
583
web/test/manual/ui-sweep.mjs
Normal 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 web/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);
|
||||
Reference in New Issue
Block a user