diff --git a/plugins/pi-mail-bridge/test/session-scan.test.mjs b/plugins/pi-mail-bridge/test/session-scan.test.mjs new file mode 100644 index 0000000..ed99ab8 --- /dev/null +++ b/plugins/pi-mail-bridge/test/session-scan.test.mjs @@ -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 { + // 用一个普通文件当 sessionsDir:readdir 会给 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 }); + } +}); diff --git a/web/test/manual/ui-sweep.mjs b/web/test/manual/ui-sweep.mjs new file mode 100644 index 0000000..4ac89a8 --- /dev/null +++ b/web/test/manual/ui-sweep.mjs @@ -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` 快捷按钮的文字是 `+ ` + 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);