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 六项全过。
This commit is contained in:
2026-09-04 06:29:21 +08:00
parent 5b76ac5c57
commit e504eccf3a
9 changed files with 1572 additions and 65 deletions

View File

@ -0,0 +1,321 @@
import { describe, it, expect } from 'vitest';
import {
groupMailsBySession,
isFlatGroup,
isPendingPermission,
splitByPermission,
countPendingPermissions,
groupPermissions
} from '../../src/lib/mailGroups';
import type { Mail } from '../../src/types';
/**
* 收件箱会话分组 + 授权请求独立分组。
*
* 这些用例锁的是生产实测过的形状:一个会话独占 17 封权限邮件,
* 把另外两个会话的信挤出视野。修法有两层 ——
* 权限请求整体移出收件箱(各归各的导航项),剩下的普通邮件按会话折叠。
*/
let seq = 0;
function mail(over: Partial<Mail> = {}): Mail {
seq += 1;
return {
mail_id: `m${String(seq).padStart(3, '0')}`,
session_id: 's1',
parent_mail_id: null,
from_name: 'pi',
from_workspace: '/home',
to_name: 'jianf',
to_workspace: '',
cc_list: [],
subject: `主题 ${seq}`,
body: '正文',
mail_type: 'normal',
permission_options: null,
permission_result: null,
status: 'read',
created_at: `2026-09-03T10:${String(seq % 60).padStart(2, '0')}:00Z`,
...over
};
}
function perm(over: Partial<Mail> = {}): Mail {
return mail({ mail_type: 'permission_request', ...over });
}
describe('groupMailsBySession', () => {
it('把同一会话的多封邮件折成一组', () => {
const groups = groupMailsBySession([
mail({ session_id: 'a' }),
mail({ session_id: 'a' }),
mail({ session_id: 'a' }),
mail({ session_id: 'b' })
]);
expect(groups).toHaveLength(2);
expect(groups.find(g => g.sessionId === 'a')!.mails).toHaveLength(3);
});
it('组内按时间倒序,最新那封做组头', () => {
const old = mail({ session_id: 'a', created_at: '2026-09-03T08:00:00Z', subject: '旧' });
const mid = mail({ session_id: 'a', created_at: '2026-09-03T09:00:00Z', subject: '中' });
const now = mail({ session_id: 'a', created_at: '2026-09-03T10:00:00Z', subject: '新' });
const [g] = groupMailsBySession([mid, old, now]); // 故意乱序传入
expect(g.mails.map(m => m.subject)).toEqual(['新', '中', '旧']);
expect(g.latest.subject).toBe('新');
// 组标题取最新一封:会话主题随任务推进被改写,最新的最贴切
expect(g.subject).toBe('新');
});
it('组之间按最新邮件时间倒序', () => {
const groups = groupMailsBySession([
mail({ session_id: 'stale', created_at: '2026-09-01T10:00:00Z' }),
mail({ session_id: 'fresh', created_at: '2026-09-03T10:00:00Z' }),
mail({ session_id: 'mid', created_at: '2026-09-02T10:00:00Z' })
]);
expect(groups.map(g => g.sessionId)).toEqual(['fresh', 'mid', 'stale']);
});
it('同一时刻用 mail_id 兜底定序(SQLite 时间戳精度有限)', () => {
const ts = '2026-09-03T10:00:00Z';
const a = mail({ mail_id: 'aaa', session_id: 's', created_at: ts });
const z = mail({ mail_id: 'zzz', session_id: 's', created_at: ts });
const [g1] = groupMailsBySession([a, z]);
const [g2] = groupMailsBySession([z, a]);
// 两种输入顺序必须给出同一结果,否则「刷新一次顺序就变了」
expect(g1.mails.map(m => m.mail_id)).toEqual(g2.mails.map(m => m.mail_id));
expect(g1.mails[0].mail_id).toBe('zzz');
});
it('统计未读数', () => {
const [g] = groupMailsBySession([
mail({ session_id: 's', status: 'unread' }),
mail({ session_id: 's', status: 'unread' }),
mail({ session_id: 's', status: 'read' })
]);
expect(g.unreadCount).toBe(2);
});
it('别名取自最新一封;无别名给空串而不是 undefined', () => {
const [withAlias] = groupMailsBySession([
mail({ session_id: 's', session_alias: 'deploy-review' })
]);
expect(withAlias.alias).toBe('deploy-review');
const [without] = groupMailsBySession([mail({ session_id: 's' })]);
expect(without.alias).toBe('');
});
it('session_id 缺失的脏数据各自成组而不是挤成一堆', () => {
const groups = groupMailsBySession([
mail({ mail_id: 'x', session_id: '' }),
mail({ mail_id: 'y', session_id: '' })
]);
// 全归到 '' 这一组会把两封无关的信显示成一个会话
expect(groups).toHaveLength(2);
});
it('created_at 解析失败不会让顺序变得不确定', () => {
const bad = mail({ mail_id: 'bad', session_id: 's', created_at: '不是时间' });
const good = mail({ mail_id: 'good', session_id: 's', created_at: '2026-09-03T10:00:00Z' });
const [g] = groupMailsBySession([bad, good]);
// NaN 参与比较恒为 false,会让排序结果取决于原数组顺序
expect(g.mails.map(m => m.mail_id)).toEqual(['good', 'bad']);
});
it('不修改入参数组', () => {
const input = [
mail({ session_id: 's', created_at: '2026-09-03T08:00:00Z' }),
mail({ session_id: 's', created_at: '2026-09-03T10:00:00Z' })
];
const snapshot = input.map(m => m.mail_id);
groupMailsBySession(input);
expect(input.map(m => m.mail_id)).toEqual(snapshot);
});
it('空数组返回空数组', () => {
expect(groupMailsBySession([])).toEqual([]);
});
});
describe('isFlatGroup', () => {
it('单封邮件的组平铺显示,不套折叠头', () => {
const [g] = groupMailsBySession([mail({ session_id: 'solo' })]);
expect(isFlatGroup(g)).toBe(true);
});
it('两封以上才折叠', () => {
const [g] = groupMailsBySession([mail({ session_id: 's' }), mail({ session_id: 's' })]);
expect(isFlatGroup(g)).toBe(false);
});
});
describe('splitByPermission', () => {
it('权限请求与普通邮件分开', () => {
const { normal, permissions } = splitByPermission([
mail({ subject: '来信' }),
perm({ subject: '要跑 bash' }),
mail({ subject: '又一封' })
]);
expect(normal.map(m => m.subject)).toEqual(['来信', '又一封']);
expect(permissions.map(m => m.subject)).toEqual(['要跑 bash']);
});
it('保持原有顺序(调用方自己排序)', () => {
const a = mail({ mail_id: 'a' });
const b = mail({ mail_id: 'b' });
expect(splitByPermission([a, b]).normal.map(m => m.mail_id)).toEqual(['a', 'b']);
});
it('空输入给两个空数组而不是 undefined', () => {
const r = splitByPermission([]);
expect(r.normal).toEqual([]);
expect(r.permissions).toEqual([]);
});
});
describe('isPendingPermission', () => {
it('普通邮件不是权限请求', () => {
expect(isPendingPermission(mail())).toBe(false);
});
it('permission_result 为空串或 null 都算未决策', () => {
// 后端用 COALESCE(permission_result,'') 归一,两种都会出现
expect(isPendingPermission(perm({ permission_result: null }))).toBe(true);
expect(isPendingPermission(perm({ permission_result: '' }))).toBe(true);
});
it('有决策结果就不再是待决策', () => {
expect(isPendingPermission(perm({ permission_result: '拒绝' }))).toBe(false);
});
});
describe('countPendingPermissions', () => {
it('只数待决策的那些', () => {
const n = countPendingPermissions([
perm({ permission_result: null }),
perm({ permission_result: '' }),
perm({ permission_result: '同意' }),
mail()
]);
expect(n).toBe(2);
});
it('没有待决策时给 0', () => {
expect(countPendingPermissions([perm({ permission_result: '同意' })])).toBe(0);
});
});
describe('groupPermissions', () => {
it('只收权限请求,普通邮件不进来', () => {
const groups = groupPermissions([
mail({ session_id: 'a' }),
perm({ session_id: 'a' }),
perm({ session_id: 'a' })
]);
expect(groups).toHaveLength(1);
expect(groups[0].pending.length + groups[0].settled.length).toBe(2);
});
it('待决策与已决策分开装', () => {
const [g] = groupPermissions([
perm({ session_id: 's', permission_result: null, subject: '在等' }),
perm({ session_id: 's', permission_result: '同意', subject: '批过' }),
perm({ session_id: 's', permission_result: '拒绝', subject: '拒过' })
]);
expect(g.pending.map(m => m.subject)).toEqual(['在等']);
expect(g.settled.map(m => m.subject).sort()).toEqual(['批过', '拒过']);
});
it('有待决策的会话排在前面,哪怕它更旧', () => {
const groups = groupPermissions([
// 刚刚批完的
perm({ session_id: 'done', permission_result: '同意', created_at: '2026-09-03T12:00:00Z' }),
// 三天前就卡着的 —— 那条 Agent 会话正在那儿等
perm({ session_id: 'stuck', permission_result: null, created_at: '2026-08-31T09:00:00Z' })
]);
expect(groups.map(g => g.sessionId)).toEqual(['stuck', 'done']);
});
it('都有待决策时,卡住更多请求的会话更靠前', () => {
const groups = groupPermissions([
perm({ session_id: 'one', permission_result: null, created_at: '2026-09-03T12:00:00Z' }),
perm({ session_id: 'many', permission_result: null, created_at: '2026-09-03T08:00:00Z' }),
perm({ session_id: 'many', permission_result: null, created_at: '2026-09-03T08:01:00Z' }),
perm({ session_id: 'many', permission_result: null, created_at: '2026-09-03T08:02:00Z' })
]);
expect(groups[0].sessionId).toBe('many');
});
it('都无待决策时按最新时间倒序', () => {
const groups = groupPermissions([
perm({ session_id: 'old', permission_result: '同意', created_at: '2026-09-01T10:00:00Z' }),
perm({ session_id: 'new', permission_result: '同意', created_at: '2026-09-03T10:00:00Z' })
]);
expect(groups.map(g => g.sessionId)).toEqual(['new', 'old']);
});
it('组头带上发起请求的 Agent 与工作目录', () => {
const [g] = groupPermissions([
perm({ session_id: 's', from_name: 'dsh', from_workspace: '/home/program/llmsproxy' })
]);
// 同名 Agent 在不同目录是不同的活,光有名字判断不了
expect(g.agentName).toBe('dsh');
expect(g.path).toBe('/home/program/llmsproxy');
});
it('组内时间倒序', () => {
const [g] = groupPermissions([
perm({ session_id: 's', permission_result: null, created_at: '2026-09-03T08:00:00Z', subject: '早' }),
perm({ session_id: 's', permission_result: null, created_at: '2026-09-03T10:00:00Z', subject: '晚' })
]);
expect(g.pending.map(m => m.subject)).toEqual(['晚', '早']);
});
it('没有权限请求时返回空数组', () => {
expect(groupPermissions([mail(), mail()])).toEqual([]);
});
it('不修改入参数组', () => {
const input = [
perm({ session_id: 's', created_at: '2026-09-03T08:00:00Z' }),
perm({ session_id: 's', created_at: '2026-09-03T10:00:00Z' })
];
const snapshot = input.map(m => m.mail_id);
groupPermissions(input);
expect(input.map(m => m.mail_id)).toEqual(snapshot);
});
});
describe('生产实测形状:17 封权限邮件的会话', () => {
it('权限请求移出收件箱后,剩下的信按会话分组且不再被淹', () => {
const flood: Mail[] = [];
for (let i = 0; i < 17; i++) {
flood.push(
perm({
session_id: 'f3ce62b0',
permission_result: i === 16 ? null : '同意',
created_at: `2026-09-03T09:${String(i).padStart(2, '0')}:00Z`
})
);
}
const realMail = [
mail({ session_id: 'fa420c33', created_at: '2026-09-03T10:00:00Z', subject: '进展汇报' }),
mail({ session_id: 'c2db6b62', created_at: '2026-09-03T11:00:00Z', subject: '需要确认' })
];
const inbox = [...flood, ...realMail];
// 收件箱侧:权限请求整体不进来,只剩两封真信
const { normal, permissions } = splitByPermission(inbox);
expect(normal).toHaveLength(2);
expect(permissions).toHaveLength(17);
const inboxGroups = groupMailsBySession(normal);
expect(inboxGroups.map(g => g.subject)).toEqual(['需要确认', '进展汇报']);
// 授权侧:17 封折成 1 组,只有 1 个在等人
const permGroups = groupPermissions(permissions);
expect(permGroups).toHaveLength(1);
expect(permGroups[0].pending).toHaveLength(1);
expect(permGroups[0].settled).toHaveLength(16);
expect(countPendingPermissions(permissions)).toBe(1);
});
});

View File

@ -0,0 +1,241 @@
import { describe, it, expect } from 'vitest';
import {
formatAddress,
mailCounterpart,
sessionCounterpart,
sessionReplyTarget,
mailReplyTarget,
replyAllCC
} from '../../src/lib/replyTarget';
import type { Mail, Session } from '../../src/types';
/**
* 「这封回复该发给谁」。
*
* 锁的是一次生产事故:判据写死 `from_name === 'human'`(单用户时代的遗留),
* 多用户下登录名是 jianf,判据恒为假 → 对端取成 from_name(自己)→ 信发给自己。
* 数据库里的链条:
* pi → jianf 权限请求
* jianf → pi Re: 权限请求 (对的,锚点是 pi 发来的)
* pi → jianf 权限请求
* jianf → jianf Re: Re: 权限请求 (错的,锚点是自己发的)
*/
let seq = 0;
function mail(over: Partial<Mail> = {}): Mail {
seq += 1;
return {
mail_id: `m${String(seq).padStart(3, '0')}`,
session_id: 's1',
parent_mail_id: null,
from_name: 'pi',
from_workspace: '/home/program/llmsproxy',
to_name: 'jianf',
to_workspace: '',
cc_list: [],
subject: '主题',
body: '正文',
mail_type: 'normal',
permission_options: null,
permission_result: null,
status: 'read',
created_at: `2026-09-03T10:${String(seq % 60).padStart(2, '0')}:00Z`,
...over
};
}
const session = (alias: string | null): Session => ({
session_id: 's1',
session_alias: alias,
from_agent: 'jianf',
subject: '关于llmsproxy工程的联合审查',
status: 'active',
created_at: '2026-09-03T09:00:00Z',
updated_at: '2026-09-03T10:00:00Z'
});
describe('formatAddress', () => {
it('三段齐全', () => {
expect(formatAddress('pi', '/home', 'deploy')).toBe('pi@/home.deploy');
});
it('无会话段时省略', () => {
expect(formatAddress('pi', '/home')).toBe('pi@/home');
expect(formatAddress('pi', '/home', null)).toBe('pi@/home');
});
it('path 为空仍保留 @(人类没有工作目录)', () => {
expect(formatAddress('jianf', '')).toBe('jianf@');
});
it('名字为空给空串而不是拼出 @', () => {
expect(formatAddress('', '/home')).toBe('');
});
});
describe('mailCounterpart', () => {
it('别人发来的 → 回给发件人', () => {
const m = mail({ from_name: 'pi', to_name: 'jianf' });
expect(mailCounterpart(m, 'jianf')).toEqual({ name: 'pi', path: '/home/program/llmsproxy' });
});
it('我发出的 → 回给收件人(不是回给自己)', () => {
const m = mail({
from_name: 'jianf',
from_workspace: '',
to_name: 'pi',
to_workspace: '/home/program/llmsproxy'
});
// 这一条就是生产 bug:原代码在这里返回 jianf
expect(mailCounterpart(m, 'jianf')).toEqual({ name: 'pi', path: '/home/program/llmsproxy' });
});
it('登录名未知时退化为回给发件人,不会回给自己', () => {
const m = mail({ from_name: 'pi', to_name: 'jianf' });
expect(mailCounterpart(m, '').name).toBe('pi');
});
it('不把 human 当特殊值', () => {
// 老判据写死 human;现在它只是个普通名字
const m = mail({ from_name: 'human', to_name: 'pi' });
expect(mailCounterpart(m, 'jianf').name).toBe('human');
expect(mailCounterpart(m, 'human').name).toBe('pi');
});
});
describe('sessionCounterpart', () => {
it('按会话定对端,不受最后一封是谁发的影响', () => {
const mails = [
mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', created_at: '2026-09-03T09:00:00Z' }),
mail({ from_name: 'pi', to_name: 'jianf', created_at: '2026-09-03T09:30:00Z' }),
// 最后一封是我自己发的 —— 原代码在这里会把自己当对端
mail({ from_name: 'jianf', from_workspace: '', to_name: 'jianf', created_at: '2026-09-03T10:00:00Z' })
];
expect(sessionCounterpart(mails, 'jianf')?.name).toBe('pi');
});
it('取首个非我参与方:后来被抄送进来的第三方不抢位置', () => {
const mails = [
mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', created_at: '2026-09-03T09:00:00Z' }),
mail({ from_name: 'dsh', from_workspace: '/opt', to_name: 'jianf', created_at: '2026-09-03T09:30:00Z' })
];
expect(sessionCounterpart(mails, 'jianf')?.name).toBe('pi');
});
it('补上首次出现时缺失的 path', () => {
const mails = [
// 首封的 to_workspace 是空的
mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', to_workspace: '', created_at: '2026-09-03T09:00:00Z' }),
// 后一封才带上目录
mail({ from_name: 'pi', from_workspace: '/home/program/llmsproxy', to_name: 'jianf', created_at: '2026-09-03T09:30:00Z' })
];
// 同名 Agent 在不同目录是不同的活,path 不能丢
expect(sessionCounterpart(mails, 'jianf')).toEqual({
name: 'pi',
path: '/home/program/llmsproxy'
});
});
it('同刻邮件用 mail_id 定序,结果稳定', () => {
const ts = '2026-09-03T09:00:00Z';
const a = mail({ mail_id: 'aaa', from_name: 'jianf', from_workspace: '', to_name: 'pi', created_at: ts });
const z = mail({ mail_id: 'zzz', from_name: 'jianf', from_workspace: '', to_name: 'dsh', created_at: ts });
expect(sessionCounterpart([a, z], 'jianf')?.name).toBe(sessionCounterpart([z, a], 'jianf')?.name);
});
it('全是自己的会话返回 null 而不是自己', () => {
const mails = [mail({ from_name: 'jianf', from_workspace: '', to_name: 'jianf' })];
expect(sessionCounterpart(mails, 'jianf')).toBeNull();
});
it('空会话返回 null', () => {
expect(sessionCounterpart([], 'jianf')).toBeNull();
});
it('created_at 解析失败不影响确定性', () => {
const bad = mail({ mail_id: 'bad', from_name: 'jianf', from_workspace: '', to_name: 'pi', created_at: '不是时间' });
const good = mail({ mail_id: 'good', from_name: 'jianf', from_workspace: '', to_name: 'dsh', created_at: '2026-09-03T09:00:00Z' });
expect(sessionCounterpart([bad, good], 'jianf')?.name).toBe(
sessionCounterpart([good, bad], 'jianf')?.name
);
});
});
describe('sessionReplyTarget', () => {
it('带上会话别名:不带会落到该 Agent 的默认会话', () => {
const mails = [
mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', to_workspace: '/home/program/llmsproxy' })
];
expect(sessionReplyTarget(mails, session('pi-关于llmsproxy工程的联合审查'), 'jianf')).toBe(
'pi@/home/program/llmsproxy.pi-关于llmsproxy工程的联合审查'
);
});
it('会话未命名时省略会话段', () => {
const mails = [
mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', to_workspace: '/home' })
];
expect(sessionReplyTarget(mails, session(null), 'jianf')).toBe('pi@/home');
});
it('生产链条重现:回复自己发的那封仍指向 pi', () => {
const mails = [
mail({ from_name: 'pi', to_name: 'jianf', created_at: '2026-09-03T10:53:12Z' }),
mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', to_workspace: '/home/program/llmsproxy', created_at: '2026-09-03T10:53:39Z' }),
mail({ from_name: 'pi', to_name: 'jianf', created_at: '2026-09-03T10:55:13Z' }),
mail({ from_name: 'jianf', from_workspace: '', to_name: 'jianf', created_at: '2026-09-03T10:56:34Z' })
];
const target = sessionReplyTarget(mails, session('pi-关于llmsproxy工程的联合审查'), 'jianf');
expect(target.startsWith('pi@')).toBe(true);
expect(target.startsWith('jianf@')).toBe(false);
});
it('找不到对端时给空串(调用方据此禁用发送)', () => {
expect(sessionReplyTarget([], session('x'), 'jianf')).toBe('');
});
});
describe('mailReplyTarget', () => {
it('单封视图带上该封的会话别名', () => {
const m = mail({ from_name: 'pi', to_name: 'jianf', session_alias: 'deploy-review' });
expect(mailReplyTarget(m, 'jianf')).toBe('pi@/home/program/llmsproxy.deploy-review');
});
});
describe('replyAllCC', () => {
it('去掉自己与主收件人', () => {
const m = mail({
from_name: 'pi',
from_workspace: '/home',
to_name: 'jianf',
to_workspace: '',
cc_list: [{ name: 'dsh', path: '/opt', session: '', raw: 'dsh@/opt' }]
});
// 主收件人是 pi(回复对象),自己是 jianf —— 都不该出现在抄送里
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt']);
});
it('自己是 jianf 时不会把自己抄送进去(老判据只挡 human)', () => {
const m = mail({ from_name: 'pi', from_workspace: '/home', to_name: 'jianf', to_workspace: '' });
expect(replyAllCC(m, 'jianf', 'pi')).toEqual([]);
});
it('cc_list 取 raw 保留会话段', () => {
const m = mail({
from_name: 'pi',
from_workspace: '/home',
to_name: 'jianf',
cc_list: [{ name: 'dsh', path: '/opt', session: 'audit', raw: 'dsh@/opt.audit' }]
});
// 重新拼 name@path 会丢掉 .audit
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt.audit']);
});
it('同一个人既在 to 又在 cc 时只出现一次', () => {
const m = mail({
from_name: 'pi',
from_workspace: '/home',
to_name: 'dsh',
to_workspace: '/opt',
cc_list: [{ name: 'dsh', path: '/opt', session: '', raw: 'dsh@/opt' }]
});
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt']);
});
});

View File

@ -0,0 +1,168 @@
/**
* 收件箱分组 + 授权独立列表的实测验收。
*
* 起因:生产库里一个会话独占 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);