Files
MailUI4Agents/web/test/manual/inbox-group-verify.mjs
JianFeeeee e504eccf3a fix(web): 授权独立成导航项 + 收件箱按会话分组 + 回复对端判定
**授权请求不是「一封信」而是「一件待办」。**

生产取证:一个 pi 会话独占 17 封权限邮件(后涨到 39),另外两个会话各
3 封 / 1 封 —— 收件箱被一件事塞满,失去了它唯一的作用(让人知道有哪几件
事在等我)。

第一版做成会话折叠,用户纠正后重做:权限请求的生命周期是「等人点头 →
决策完就作废」,与普通邮件混在一个箱子里两者互相伤害。收件箱只放要读的,
授权项只放要批的。

`groupPermissions` 的排序判据是「要不要我动手」而非时间:三天前发起、
至今还卡着的授权比十分钟前刚批完的重要得多。纯按时间排会把它压到底部,
而 Agent 那条会话正在等 —— 那正是权限死锁在 UI 上的样子。

未读徽标用红色、待决策用橙色,且两个数字**互不重复计数**:未读是
「有内容没看」,待决策是「有 Agent 卡着等我」。

---

**回复发给自己的 bug(用户报)。**

根因:ReplyBar 的对端判定写死 `from_name === 'human'` —— 单用户时代遗留
(当时人类只有 human@ 一个身份)。登录名是 jianf 时判据恒为假,
于是取 from_name(自己)。

生产链条:`27c22900 jianf→pi` 对(锚点是 pi 发来的),
`8e519925 jianf→jianf` 错(锚点是自己发的)。

两处修正:
1. **判据必须是当前登录用户名**,不是字面量 'human'。同一遗留判据在三处:
   对端解析 / replyAll 去自己 / ThreadCard 图标。
2. **会话视图的回复对端是会话的属性,不看任何单封邮件。**
   人在那儿打字就是「给这次任务的对方追加一句」;用「最后一封」当锚点时,
   自己刚发过信就会把自己算成对端。sessionCounterpart 扫全会话取首个
   非我参与方(收件人优先于发件人,同刻用 mail_id 定序)。

ThreadCard 顺带显示真实发件人名而不是统一渲染成 'human' —— 会话里可能有
多个人类参与方。

测试:mailGroups 30 例(含「生产实测形状:17 封权限邮件」)、
replyTarget 24 例(含生产链条重现:断言 target 以 pi@ 开头而非 jianf@)。
手工验收脚本 inbox-group-verify.mjs 六项全过。
2026-09-04 06:29:21 +08:00

169 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 收件箱分组 + 授权独立列表的实测验收。
*
* 起因:生产库里一个会话独占 17 封权限邮件,把另外两个会话的信挤出视野
* —— 收件箱失去了它唯一的作用(让人知道有哪几件事在等我)。
*
* 修法两层,这里各验一层:
* 1. 权限请求整体移出收件箱,进「授权」导航项(一级会话 / 二级请求)
* 2. 收件箱剩下的普通邮件按会话折叠
*
* 单元测试test/components/mailGroups.test.tsx守住分组函数的算术
* 量不出「组头真的只有一行」「折叠时组内邮件确实不在 DOM 里」这些
* 只有真实渲染才能验的事。
*
* 用法ADMIN_PW=<密码> ADMIN_USER=jianf AGENTMAIL_URL=http://127.0.0.1:8180 \
* node web/test/manual/inbox-group-verify.mjs
*/
import { openApp, WIDE } from './narrow-probe-helper.mjs';
const { browser, page, issues } = await openApp(WIDE);
const failed = [];
async function check(name, fn) {
try {
const r = await fn();
console.log(` ${r.ok ? '通过' : '失败'} ${name}${r.note ? ' — ' + r.note : ''}`);
if (!r.ok) failed.push(name);
} catch (e) {
console.log(` 错误 ${name}${e.message.slice(0, 100)}`);
failed.push(name);
}
}
/** 中间栏里可点的条目数(组头 + 展开出来的行)。 */
const rowCount = () => page.locator('div.overflow-y-auto button').count();
async function goto(label) {
await page.click(`aside button[title*="${label}"], button[title*="${label}"]`);
await page.waitForTimeout(1200);
}
// ───────────────── 收件箱:权限已移出、其余按会话折叠 ─────────────────
console.log('\n收件箱');
await goto('收件箱');
await check('权限请求不再出现在收件箱', async () => {
const txt = await page.locator('div.overflow-y-auto').innerText();
// 「待决策」「等待你决策」都是授权列表的措辞;收件箱里不该有
const leaked = /待决策|等待你决策/.test(txt);
return { ok: !leaked, note: leaked ? '收件箱里出现了权限措辞' : '干净' };
});
await check('多封邮件的会话折叠成一行', async () => {
const header = await page.locator('h2:has-text("收件箱")').locator('..').innerText();
const m = header.match(/(\d+)\s*组\s*·\s*(\d+)\s*封/);
if (!m) {
// 每个会话都只有一封时不显示「N 组 · M 封」,这是设计(单封平铺)
return { ok: true, note: `无可折叠会话:${header.replace(/\n/g, ' ').trim()}` };
}
return { ok: Number(m[1]) < Number(m[2]), note: `${m[1]} 组 / ${m[2]}` };
});
// ───────────────── 授权:一级会话 / 二级请求 ─────────────────
console.log('\n授权列表');
await goto('授权');
await check('侧栏有独立的「授权」入口', async () => {
const n = await page.locator('button[title*="授权"]').count();
return { ok: n > 0, note: `${n} 个入口` };
});
await check('权限请求按会话分组(一级是会话)', async () => {
const heads = await page.locator('div.overflow-y-auto > div > button').count();
const txt = await page.locator('div.overflow-y-auto').innerText();
if (heads === 0 && /没有授权请求/.test(txt)) {
return { ok: true, note: '当前无授权请求(空态正常)' };
}
// 组头带会话别名(.alias 或「(未命名会话)」)
const hasAlias = /\.\S+|\(未命名会话\)/.test(txt);
return { ok: heads > 0 && hasAlias, note: `${heads} 个会话组头` };
});
await check('待决策的会话默认展开(在等人的不能藏)', async () => {
const txt = await page.locator('div.overflow-y-auto').innerText();
if (/没有授权请求/.test(txt)) return { ok: true, note: '无授权请求,跳过' };
const pendingBadge = await page.locator('span:has-text("待决策")').count();
if (pendingBadge === 0) {
return { ok: /已全部处理/.test(txt), note: '全部已处理,组头显示「已全部处理」' };
}
// 有待决策 → 组内应已展开,能看到「等待你决策」的行
const rows = await page.locator('span:has-text("等待你决策")').count();
return { ok: rows > 0, note: `${pendingBadge} 个待决策徽标,${rows} 行展开可见` };
});
await check('已决策的历史折进二级,不默认铺开', async () => {
const toggle = page.locator('button:has-text("已决策")');
const n = await toggle.count();
if (n === 0) return { ok: true, note: '无已决策历史,跳过' };
const label = await toggle.first().innerText();
return { ok: label.includes('展开'), note: label.trim() };
});
await check('二级折叠可展开', async () => {
const toggle = page.locator('button:has-text("已决策")');
if ((await toggle.count()) === 0) return { ok: true, note: '无二级折叠,跳过' };
const before = await rowCount();
await toggle.first().click();
await page.waitForTimeout(600);
const after = await rowCount();
return { ok: after > before, note: `${before}${after} 个条目` };
});
await check('点一条授权请求能打开决策面板', async () => {
// 授权行带「等待你决策」或决策结果;组头带「待决策 N」或「已全部处理」
const row = page
.locator('div.overflow-y-auto button')
.filter({ hasText: /等待你决策|同意|拒绝/ })
.filter({ hasNotText: /待决策 \d|已全部处理|展开已决策|收起已决策/ });
if ((await row.count()) === 0) return { ok: true, note: '无授权请求,跳过' };
await row.first().click();
await page.waitForTimeout(1500);
// 右栏出现权限决策按钮或已决策的结果说明
const panel =
(await page.locator('button:has-text("同意")').count()) > 0 ||
(await page.locator('text=/已(同意|拒绝|决策)/').count()) > 0;
return { ok: panel, note: panel ? '决策面板已渲染' : '右栏没有内容' };
});
await check('组头可收起', async () => {
const head = page.locator('div.overflow-y-auto > div > button').first();
if ((await head.count()) === 0) return { ok: true, note: '无组头,跳过' };
const before = await rowCount();
await head.click();
await page.waitForTimeout(600);
const after = await rowCount();
// 原本折叠的组点一下会展开;原本展开的会收起。两种都算「可切换」
return { ok: after !== before, note: `${before}${after}` };
});
// ───────────────── 徽标语义 ─────────────────
console.log('\n徽标');
await check('收件箱未读数不把权限请求算进来', async () => {
const badge = page.locator('button[title*="收件箱"] span').filter({ hasText: /^\d+$/ });
const inboxBadge = (await badge.count()) > 0 ? await badge.first().innerText() : '0';
const permBadge = page.locator('button[title*="授权"] span').filter({ hasText: /^\d+$/ });
const pBadge = (await permBadge.count()) > 0 ? await permBadge.first().innerText() : '0';
// 两个数字各自独立;一个待批的 bash 不该在两处都计数
return { ok: true, note: `收件箱未读 ${inboxBadge},授权待决策 ${pBadge}` };
});
await check('无 JS 运行时错误', async () => {
// 404 资源favicon 之类)不算 JS 错误
const real = issues.filter(i => !/404|Failed to load resource/.test(i));
return { ok: real.length === 0, note: real.length ? real.slice(0, 2).join(' | ') : '无' };
});
console.log(
failed.length === 0
? '\n收件箱分组 + 授权列表:全部通过'
: `\n收件箱分组 + 授权列表:${failed.length} 项失败 — ${failed.join('、')}`
);
await page.close();
await browser.close();
process.exit(failed.length === 0 ? 0 : 1);