test: session-scan 内存泄漏测试 + ui-sweep 界面验收脚本

This commit is contained in:
2026-09-06 15:18:30 +08:00
parent 79c4171c9d
commit 47fe9a5e74
2 changed files with 879 additions and 0 deletions

View File

@ -0,0 +1,296 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createSessionScanner } from '../src/session-scan.mjs';
/** 造一个会话目录树。返回根目录,用完由调用方删。 */
function makeRoot() {
return mkdtempSync(join(tmpdir(), 'pi-scan-'));
}
/** 写一条会话文件。lines 是 header 之后的行(对象,会被 JSON 化)。 */
function writeSession(root, cwdSlug, fileName, header, lines = []) {
const dir = join(root, cwdSlug);
mkdirSync(dir, { recursive: true });
const file = join(dir, fileName);
const body = [JSON.stringify({ type: 'session', version: 3, ...header })]
.concat(lines.map((l) => JSON.stringify(l)))
.join('\n');
writeFileSync(file, `${body}\n`);
return file;
}
const msg = (text) => ({
type: 'message',
id: Math.random().toString(36).slice(2, 10),
message: { role: 'user', content: [{ type: 'text', text }] },
});
const info = (name) => ({ type: 'session_info', id: 'aa', parentId: 'bb', name });
test('取到 header 的 id 与 cwd以及 session_info 的 name', async () => {
const root = makeRoot();
try {
writeSession(root, '--tmp-proj--', 'a.jsonl',
{ id: 'sess-1', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/tmp/proj' },
[msg('你好'), info('重构导入路径'), msg('继续')]);
const { scan } = createSessionScanner({ sessionsDir: root });
const out = await scan();
assert.equal(out.length, 1);
assert.equal(out[0].id, 'sess-1');
assert.equal(out[0].cwd, '/tmp/proj');
assert.equal(out[0].name, '重构导入路径');
assert.ok(out[0].modified instanceof Date);
assert.ok(out[0].path.endsWith('a.jsonl'));
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('多次改名时取最后一个 session_info与 SDK 语义一致)', async () => {
const root = makeRoot();
try {
writeSession(root, '--x--', 'a.jsonl',
{ id: 's', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' },
[info('旧名'), msg('干活'), info('新名')]);
const { scan } = createSessionScanner({ sessionsDir: root });
assert.equal((await scan())[0].name, '新名');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('显式清名session_info 不带 name→ name 为 undefined', async () => {
const root = makeRoot();
try {
writeSession(root, '--x--', 'a.jsonl',
{ id: 's', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' },
[info('有名字'), { type: 'session_info', id: 'cc', parentId: 'dd' }]);
const { scan } = createSessionScanner({ sessionsDir: root });
assert.equal((await scan())[0].name, undefined);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('老会话的 cwd 是空串时照实返回,不冒充', async () => {
// snapshotPiSessions 会按空 workspace 上报。拿桥自己的 cwd 顶上去会让
// 那条会话在错误的工作目录下出现在补全候选里。
const root = makeRoot();
try {
writeSession(root, '--x--', 'a.jsonl',
{ id: 's', timestamp: '2026-09-01T00:00:00.000Z' },
[info('无 cwd 的老会话')]);
const { scan } = createSessionScanner({ sessionsDir: root });
assert.equal((await scan())[0].cwd, '');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('第二次扫描不重读未变化的文件(这就是省下来的那 282MB', async () => {
const root = makeRoot();
try {
writeSession(root, '--x--', 'a.jsonl',
{ id: 's1', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' },
[info('甲'), msg('内容')]);
writeSession(root, '--y--', 'b.jsonl',
{ id: 's2', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/y' },
[info('乙')]);
const sc = createSessionScanner({ sessionsDir: root });
await sc.scan();
const after1 = sc.stats();
assert.equal(after1.fullScans, 2);
await sc.scan();
const after2 = sc.stats();
assert.equal(after2.fullScans, 2, '没变化的文件不该被重扫');
assert.equal(after2.tailScans, 0);
assert.equal(after2.tailBytes, after1.tailBytes, '一个字节都不该多读');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('文件追加后只扫尾部,且能看到新名字', async () => {
const root = makeRoot();
try {
const file = writeSession(root, '--x--', 'a.jsonl',
{ id: 's', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' },
[info('第一版')]);
const sc = createSessionScanner({ sessionsDir: root });
assert.equal((await sc.scan())[0].name, '第一版');
const bytes1 = sc.stats().tailBytes;
appendFileSync(file, `${JSON.stringify(msg('新一轮'))}\n${JSON.stringify(info('第二版'))}\n`);
const out = await sc.scan();
assert.equal(out[0].name, '第二版');
assert.equal(sc.stats().tailScans, 1);
const delta = sc.stats().tailBytes - bytes1;
assert.ok(delta > 0 && delta < 400, `只该读新增的那一小段,实际 ${delta} 字节`);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('★尾部没有 session_info 时保留旧 name不能清空', async () => {
// 写错成 `cached.name = name` 会让每次有新消息的会话都丢掉名字,
// 而没有 name 的会话不上报S-1—— 于是**活跃**会话反而从补全里消失。
const root = makeRoot();
try {
const file = writeSession(root, '--x--', 'a.jsonl',
{ id: 's', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' },
[info('要保住的名字')]);
const sc = createSessionScanner({ sessionsDir: root });
assert.equal((await sc.scan())[0].name, '要保住的名字');
appendFileSync(file, `${JSON.stringify(msg('只是普通消息'))}\n`);
assert.equal((await sc.scan())[0].name, '要保住的名字');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('★巨大的 message 行不进内存也不影响解析', async () => {
// 本机实测单行最长 2.63MB。listAll 会把它整行读进来并 parse
// 这里只要求 name 仍能取到,且扫描不抛错。
const root = makeRoot();
try {
const huge = 'x'.repeat(3 * 1024 * 1024);
writeSession(root, '--x--', 'a.jsonl',
{ id: 's', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' },
[msg(huge), info('巨行之后的名字'), msg(huge)]);
const sc = createSessionScanner({ sessionsDir: root });
const out = await sc.scan();
assert.equal(out.length, 1);
assert.equal(out[0].name, '巨行之后的名字');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('首行不是 session 的文件被忽略,且不会每拍重读', async () => {
const root = makeRoot();
try {
const dir = join(root, '--x--');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'junk.jsonl'), `${JSON.stringify({ type: 'message' })}\n`);
writeFileSync(join(dir, 'empty.jsonl'), '');
writeFileSync(join(dir, 'broken.jsonl'), '这不是 json\n');
const sc = createSessionScanner({ sessionsDir: root });
assert.deepEqual(await sc.scan(), []);
const n = sc.stats().fullScans;
await sc.scan();
assert.equal(sc.stats().fullScans, n, '空壳条目不该被反复重读');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('★会话文件被删后缓存条目跟着走(缓存自身不是下一个泄露源)', async () => {
const root = makeRoot();
try {
writeSession(root, '--x--', 'a.jsonl',
{ id: 's1', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' }, [info('甲')]);
writeSession(root, '--x--', 'b.jsonl',
{ id: 's2', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' }, [info('乙')]);
const sc = createSessionScanner({ sessionsDir: root });
await sc.scan();
assert.equal(sc.stats().tracked, 2);
rmSync(join(root, '--x--', 'a.jsonl'));
const out = await sc.scan();
assert.equal(out.length, 1);
assert.equal(sc.stats().tracked, 1, '磁盘上没了的文件必须从缓存里消失');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('文件变小(被截断/重写)时整份重扫而不是从越界偏移读', async () => {
const root = makeRoot();
try {
const file = writeSession(root, '--x--', 'a.jsonl',
{ id: 's', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' },
[msg('很多内容'.repeat(200)), info('旧')]);
const sc = createSessionScanner({ sessionsDir: root });
await sc.scan();
const fulls = sc.stats().fullScans;
// 重写成更短的内容,且换了名字
writeSession(root, '--x--', 'a.jsonl',
{ id: 's', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' }, [info('新')]);
const out = await sc.scan();
assert.equal(out[0].name, '新');
assert.equal(sc.stats().fullScans, fulls + 1, '变小必须触发整份重扫');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('目录不存在 → 空列表pi 从没跑过是正常状态)', async () => {
const sc = createSessionScanner({ sessionsDir: join(tmpdir(), `nope-${Date.now()}`) });
assert.deepEqual(await sc.scan(), []);
});
test('★读目录遇到非 ENOENT 错误必须抛出,不能返回空数组', async () => {
// 返回空数组的语义是「平台确实没有会话」会把服务端镜像抹掉W-3 / N-7
// 一次 EACCES 就能清空别人的补全候选 —— 必须让调用方看到失败并省略字段。
const root = makeRoot();
try {
// 用一个普通文件当 sessionsDirreaddir 会给 ENOTDIR而不是 ENOENT
const notADir = join(root, 'file');
writeFileSync(notADir, 'x');
const sc = createSessionScanner({ sessionsDir: notADir });
await assert.rejects(() => sc.scan(), (e) => e?.code === 'ENOTDIR');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('缺 sessionsDir 时当场抛错', () => {
assert.throws(() => createSessionScanner(), /sessionsDir/);
assert.throws(() => createSessionScanner({}), /sessionsDir/);
});
test('跨多个 cwd 子目录汇总', async () => {
const root = makeRoot();
try {
writeSession(root, '--a--', '1.jsonl', { id: 'a1', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/a' }, [info('甲')]);
writeSession(root, '--b--', '2.jsonl', { id: 'b1', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/b' }, [info('乙')]);
writeSession(root, '--b--', '3.jsonl', { id: 'b2', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/b' }, [info('丙')]);
const sc = createSessionScanner({ sessionsDir: root });
const out = await sc.scan();
assert.equal(out.length, 3);
assert.deepEqual([...out.map((s) => s.id)].sort(), ['a1', 'b1', 'b2']);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('非 .jsonl 文件被跳过', async () => {
const root = makeRoot();
try {
const dir = join(root, '--x--');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'notes.txt'), 'hello');
writeSession(root, '--x--', 'a.jsonl', { id: 's', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' }, [info('甲')]);
const sc = createSessionScanner({ sessionsDir: root });
assert.equal((await sc.scan()).length, 1);
} finally {
rmSync(root, { recursive: true, force: true });
}
});

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