feat: agent 邮件寻址能力全面补齐 + .new 别名替换
## 别名替换(让 .new 邮件可寻址)
repo/autoalias.go: AutoAliasFor + EnsureSessionAlias
- .new 建完会话立刻给别名(形如 dsh-重构导入路径)
- 名字与主题都要:只用主题跨 Agent 撞名,只用名字看不出聊什么
- sanitizeAliasPart 只留 unicode.IsLetter/IsDigit,其余折 -
- 撞名追加 -2/-3,全占用退 session-<uuid前8位>
- 不复用 SyncSessionAlias:那个假定已存在且跳过 manual
- 条件写入 WHERE alias IS NULL OR '',并发安全
- resolveTarget 的 .new 与默认会话两条路径都调
notifyRecipients 加三个字段(每个收件方拿到自己那个地址的版本):
- session_alias / reply_address / self_address
- 别名为空时退回省略 session 位,绝不写 new
FormatAddress(name,path,session) 空 path 也必须留 @ 与 .
## Agent 侧寻址发现(五个只读端点)
handler/agent_discovery.go:
- /agent/contacts + /agent/contacts/suggest(三段式补全)
- /agent/mail/{id} + /agent/mail/{id}/thread
- /agent/sessions/{id}/participants
- 不复用人类路由:scope 不同、审计需求不同
- 一律只读:归档/改名/权限决策仍只有人能做
repo/participants.go: SessionParticipants 逐封扫 from/to/cc
- Roles 用集合、MailCount 只数发信(0=还没开口的人)
- 发件人 path 不取 from_workspace(那列存的是 Agent 名)
repo.SuggestPaths 重写:mails.to_workspace(按 MAX(created_at) 倒序)
+ agents.workspaces 并集。原只读 workspaces,官方插件传 [] 永远空
## 共用模块(三插件逐字节相同)
lib/addressing.js: formatAddress/roleOf/replyAddressFor/selfAddressFor/participantsOfMail
lib/discovery.js: renderNameSuggestions/renderPathSuggestions/renderSessionSuggestions/
renderParticipants/renderContacts/renderThread
lib/inbox-format.js: renderMail 新增收件人/身份/可投递地址三段
- selfName 参数(兼容旧调用不传的情况)
check-shared-libs.sh 纳入 addressing + discovery
## 插件侧
opencode: suggest_address + list_contacts + session_participants + read_thread + read_mail
dsh: 同上 + forward_mail(此前只有 opencode 有)+ upload_attachment 改真 multipart
pi: 同上(createMailTools 加 agentName 参数)
dsh: ctx.agents.create id collision 改为 readSession 探测后 resume
dsh: 关键路径日志改 console.error(ctx.logger 不进 journalctl)
## 测试
repo: autoalias_test.go 11 + participants_test.go 7 = 18 例
plugins: addressing.test 17 + discovery.test 23 + inbox-format.test 31 = 71 例
go test ./... + npm test(opencode 155 + dsh 173 + pi 199)全绿
端到端验证:admin 发 dsh@....new 抄送 opencode@....new
→ dsh 用 session_participants 取到地址 → send_mail 给 opencode
→ 地址取自工具返回值(.crisp-planet),未手工拼写
This commit is contained in:
264
web/test/components/AddressInput.test.tsx
Normal file
264
web/test/components/AddressInput.test.tsx
Normal file
@ -0,0 +1,264 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import AddressInput from '../../src/components/AddressInput';
|
||||
import * as api from '../../src/api/client';
|
||||
import type { SessionCandidate } from '../../src/types';
|
||||
|
||||
/**
|
||||
* 三段式地址补全。
|
||||
*
|
||||
* 每条断言都对应一个真实存在过或很容易犯的错:
|
||||
* - 问错层(写了 @ 还在问 name 的候选)
|
||||
* - 过滤后 suggestions 与 candidates 错位,标题挂到别的别名上
|
||||
* - 选中 name 后没自动补 `@`,人得自己敲
|
||||
* - session 段选完还开着下拉,挡住下面的输入框
|
||||
*/
|
||||
|
||||
/** 造一个 suggestAddress 的响应。candidates 与 suggestions 必须同序同长。 */
|
||||
function res(
|
||||
kind: 'name' | 'path' | 'session',
|
||||
suggestions: string[],
|
||||
candidates?: Partial<SessionCandidate>[]
|
||||
) {
|
||||
return {
|
||||
kind,
|
||||
suggestions,
|
||||
candidates: candidates?.map((c, i) => ({
|
||||
alias: suggestions[i],
|
||||
title: '',
|
||||
source: 'mail' as const,
|
||||
unread: 0,
|
||||
...c
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/** 渲染一个受控的 AddressInput,返回 input 与「当前值」读取器。 */
|
||||
function setup(initial = '') {
|
||||
let current = initial;
|
||||
const onChange = vi.fn((v: string) => {
|
||||
current = v;
|
||||
rerender();
|
||||
});
|
||||
const { rerender: rr } = render(
|
||||
React.createElement(AddressInput, { value: current, onChange })
|
||||
);
|
||||
function rerender() {
|
||||
rr(React.createElement(AddressInput, { value: current, onChange }));
|
||||
}
|
||||
return {
|
||||
onChange,
|
||||
value: () => current,
|
||||
input: () => screen.getByRole('textbox') as HTMLInputElement
|
||||
};
|
||||
}
|
||||
|
||||
describe('AddressInput 三段式补全', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('还没写 @ 时问 name 层', async () => {
|
||||
const spy = vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh', 'opencode']));
|
||||
const { input } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
await userEvent.type(input(), 'ds');
|
||||
|
||||
await waitFor(() => expect(spy).toHaveBeenCalled());
|
||||
// 问 name 层时不带任何参数
|
||||
expect(spy.mock.calls[spy.mock.calls.length - 1]).toEqual([]);
|
||||
});
|
||||
|
||||
it('写了 @ 没写 . 时带 name 去问 path 层', async () => {
|
||||
const spy = vi
|
||||
.spyOn(api, 'suggestAddress')
|
||||
.mockResolvedValue(res('path', ['/home/program/agentmail']));
|
||||
const { input } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
await userEvent.type(input(), 'dsh@/home');
|
||||
|
||||
// 问错层的后果:候选里全是 Agent 名,人以为这个工作区下没有会话
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith('dsh'));
|
||||
});
|
||||
|
||||
it('写了 . 时带 name 与 path 去问 session 层', async () => {
|
||||
const spy = vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('session', ['refactor']));
|
||||
const { input } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
await userEvent.type(input(), 'dsh@/home/x.ref');
|
||||
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith('dsh', '/home/x'));
|
||||
});
|
||||
|
||||
it('path 里的 . 不被当作 session 分隔符(按最后一个 . 切)', async () => {
|
||||
const spy = vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('session', ['a']));
|
||||
const { input } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
// path 自身含 .:a.b/proj
|
||||
await userEvent.type(input(), 'dsh@/home/a.b/proj.ref');
|
||||
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith('dsh', '/home/a.b/proj'));
|
||||
});
|
||||
|
||||
it('选中 name 候选后自动补 @', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh', 'opencode']));
|
||||
const { input, value } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
await waitFor(() => expect(screen.getByText('dsh')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByText('dsh'));
|
||||
|
||||
// 不补 @ 的话人得自己敲,而这是三段里唯一没有歧义的分隔符
|
||||
expect(value()).toBe('dsh@');
|
||||
});
|
||||
|
||||
it('选中 path 候选后自动补 .', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('path', ['/home/program/agentmail']));
|
||||
const { input, value } = setup('dsh@');
|
||||
|
||||
await userEvent.click(input());
|
||||
await waitFor(() => expect(screen.getByText('/home/program/agentmail')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByText('/home/program/agentmail'));
|
||||
|
||||
expect(value()).toBe('dsh@/home/program/agentmail.');
|
||||
});
|
||||
|
||||
it('选中 session 候选后拼出完整地址并关掉下拉', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('session', ['refactor', 'new']));
|
||||
const { input, value } = setup('dsh@/home/x.');
|
||||
|
||||
await userEvent.click(input());
|
||||
await waitFor(() => expect(screen.getByText('refactor')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByText('refactor'));
|
||||
|
||||
expect(value()).toBe('dsh@/home/x.refactor');
|
||||
// session 是最后一段,选完还开着下拉会挡住下面的输入框
|
||||
await waitFor(() => expect(screen.queryByText('会话别名(new 为新建)')).toBeNull());
|
||||
});
|
||||
|
||||
it('标题参与过滤,且过滤后标题不错位到别的别名上', async () => {
|
||||
// 三条候选,只有第二条的标题含「缓存」
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(
|
||||
res(
|
||||
'session',
|
||||
['witty-planet', 'brisk-harbor', 'calm-river'],
|
||||
[{ title: '重构导入路径' }, { title: '缓存选型讨论' }, { title: '修 CI' }]
|
||||
)
|
||||
);
|
||||
const { input } = setup('dsh@/home/x.');
|
||||
|
||||
await userEvent.click(input());
|
||||
await userEvent.type(input(), '缓存');
|
||||
|
||||
await waitFor(() => {
|
||||
// 命中的是 brisk-harbor,标题必须还是它自己的
|
||||
expect(screen.getByText('brisk-harbor')).toBeInTheDocument();
|
||||
expect(screen.getByText('缓存选型讨论')).toBeInTheDocument();
|
||||
});
|
||||
// 分别过滤两个数组会让 witty-planet 的标题挂到 brisk-harbor 上
|
||||
expect(screen.queryByText('重构导入路径')).toBeNull();
|
||||
expect(screen.queryByText('witty-planet')).toBeNull();
|
||||
});
|
||||
|
||||
it('platform 来源的候选标出「平台」,new 标出「新建会话」', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(
|
||||
res(
|
||||
'session',
|
||||
['ses-mirror', 'new'],
|
||||
[
|
||||
{ title: '平台侧在跑的会话', source: 'platform' },
|
||||
{ source: 'new' }
|
||||
]
|
||||
)
|
||||
);
|
||||
const { input } = setup('dsh@/home/x.');
|
||||
|
||||
await userEvent.click(input());
|
||||
|
||||
await waitFor(() => {
|
||||
// 不标的话人不知道这一封是「接入」一条已在跑的会话
|
||||
expect(screen.getByText('平台')).toBeInTheDocument();
|
||||
expect(screen.getByText('新建会话')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('未读数显示在候选上', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(
|
||||
res('session', ['busy-session'], [{ title: '有新消息', unread: 3 }])
|
||||
);
|
||||
const { input } = setup('dsh@/home/x.');
|
||||
|
||||
await userEvent.click(input());
|
||||
|
||||
await waitFor(() => expect(screen.getByText('3')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('键盘上下键移动选中项,Enter 采用', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh', 'opencode']));
|
||||
const { input, value } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
await waitFor(() => expect(screen.getByText('opencode')).toBeInTheDocument());
|
||||
|
||||
await userEvent.keyboard('{ArrowDown}{Enter}');
|
||||
|
||||
// 初始 active=0(dsh),下移一格到 opencode
|
||||
expect(value()).toBe('opencode@');
|
||||
});
|
||||
|
||||
it('Escape 关掉下拉但不清空已输入的内容', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh']));
|
||||
const { input, value } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
await userEvent.type(input(), 'ds');
|
||||
await waitFor(() => expect(screen.getByText('Agent 名')).toBeInTheDocument());
|
||||
|
||||
await userEvent.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Agent 名')).toBeNull());
|
||||
expect(value()).toBe('ds');
|
||||
});
|
||||
|
||||
it('接口失败时静默清空候选,不炸也不弹错', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockRejectedValue(new Error('network down'));
|
||||
const { input, value } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
await userEvent.type(input(), 'ds');
|
||||
|
||||
// 补全只是便利功能,失败不该阻止人手动输入完整地址
|
||||
await waitFor(() => expect(screen.queryByText('Agent 名')).toBeNull());
|
||||
expect(value()).toBe('ds');
|
||||
});
|
||||
|
||||
it('allowMultiple 时补全只作用于最后一段,前面的地址保留', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['opencode']));
|
||||
|
||||
let current = 'dsh@/home/x.a, ';
|
||||
const onChange = vi.fn((v: string) => {
|
||||
current = v;
|
||||
});
|
||||
render(
|
||||
React.createElement(AddressInput, {
|
||||
value: current,
|
||||
onChange,
|
||||
allowMultiple: true
|
||||
})
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('textbox'));
|
||||
await waitFor(() => expect(screen.getByText('opencode')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByText('opencode'));
|
||||
|
||||
// 抄送场景:前面已填好的地址不能被补全覆盖
|
||||
expect(current).toBe('dsh@/home/x.a, opencode@');
|
||||
});
|
||||
});
|
||||
80
web/test/components/BudgetChip.test.tsx
Normal file
80
web/test/components/BudgetChip.test.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { BudgetChip } from '../../src/components/WorkCard';
|
||||
|
||||
/**
|
||||
* 往返预算徽标。
|
||||
*
|
||||
* 它是「这个任务还剩几个来回」的唯一视觉提示,两件事必须对:
|
||||
* - 显示的是**剩余**而不是已用(人做决定看的是「还能问几次」)
|
||||
* - 0 = 不限时**不显示**,而不是显示「0/0」
|
||||
*/
|
||||
describe('BudgetChip 预算渲染', () => {
|
||||
it('显示剩余数而不是已用数', () => {
|
||||
render(React.createElement(BudgetChip, { max: 20, used: 3 }));
|
||||
|
||||
// 20 个上限、用了 3 个 → 剩 17。显示 3/20 会让人以为快用完了
|
||||
expect(screen.getByText('17/20')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('max 为 0(不限)时完全不渲染', () => {
|
||||
const { container } = render(React.createElement(BudgetChip, { max: 0, used: 0 }));
|
||||
|
||||
// 「0/0」或「不限」对每张卡片都成立,等于纯噪声
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('max 为负数时也不渲染(脏数据兜底)', () => {
|
||||
const { container } = render(React.createElement(BudgetChip, { max: -1, used: 0 }));
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('用超时剩余按 0 显示,不出现负数', () => {
|
||||
render(React.createElement(BudgetChip, { max: 5, used: 8 }));
|
||||
|
||||
// 「-3/5」看起来像 bug,而实际情形(免配额转发不计数、人手动下调过上限)是合法的
|
||||
expect(screen.getByText('0/5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('剩余为 0 时转红并标注「已用尽」', () => {
|
||||
render(React.createElement(BudgetChip, { max: 5, used: 5 }));
|
||||
|
||||
const chip = screen.getByText('0/5').closest('span')!;
|
||||
expect(chip.className).toContain('bg-red-100');
|
||||
expect(chip.getAttribute('title')).toContain('已用尽');
|
||||
});
|
||||
|
||||
it('剩余 1 个时转橙 —— 那是需要人介入的时刻', () => {
|
||||
render(React.createElement(BudgetChip, { max: 20, used: 19 }));
|
||||
|
||||
const chip = screen.getByText('1/20').closest('span')!;
|
||||
// 要么加预算,要么让它收尾;这一步不提示的话下一封信就被挡住了
|
||||
expect(chip.className).toContain('bg-orange-100');
|
||||
});
|
||||
|
||||
it('余量充足时用中性灰,不抢注意力', () => {
|
||||
render(React.createElement(BudgetChip, { max: 20, used: 2 }));
|
||||
|
||||
const chip = screen.getByText('18/20').closest('span')!;
|
||||
expect(chip.className).toContain('bg-gray-100');
|
||||
expect(chip.className).not.toContain('red');
|
||||
expect(chip.className).not.toContain('orange');
|
||||
});
|
||||
|
||||
it('title 里带已用/上限,供悬停查看细节', () => {
|
||||
render(React.createElement(BudgetChip, { max: 20, used: 7 }));
|
||||
|
||||
const chip = screen.getByText('13/20').closest('span')!;
|
||||
// 徽标上只有剩余,具体用了几个放在 title 里 —— 徽标要窄
|
||||
expect(chip.getAttribute('title')).toContain('已用 7/20');
|
||||
});
|
||||
|
||||
it('剩余 2 个时还不转橙(阈值是 <= 1)', () => {
|
||||
render(React.createElement(BudgetChip, { max: 10, used: 8 }));
|
||||
|
||||
const chip = screen.getByText('2/10').closest('span')!;
|
||||
expect(chip.className).toContain('bg-gray-100');
|
||||
});
|
||||
});
|
||||
203
web/test/components/PermissionPanel.test.tsx
Normal file
203
web/test/components/PermissionPanel.test.tsx
Normal file
@ -0,0 +1,203 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { PermissionPanel } from '../../src/components/MailView';
|
||||
import * as api from '../../src/api/client';
|
||||
import { useMailStore } from '../../src/stores/mailStore';
|
||||
import { useSessionStore } from '../../src/stores/sessionStore';
|
||||
import type { Mail } from '../../src/types';
|
||||
|
||||
/**
|
||||
* 权限决策面板。
|
||||
*
|
||||
* 这是全站唯一一处「人的一次点击直接放行 Agent 的危险操作」,
|
||||
* 因此断言集中在两件事:
|
||||
* - 点下去到底把什么发给了服务端(选项原文,不是归一化后的 allow/deny)
|
||||
* - 已决策的请求不能再点第二次
|
||||
*/
|
||||
|
||||
function permMail(over: Partial<Mail> = {}): Mail {
|
||||
return {
|
||||
mail_id: 'm-1',
|
||||
session_id: 's-1',
|
||||
parent_mail_id: null,
|
||||
from_name: 'dsh',
|
||||
from_workspace: '/home/program/agentmail',
|
||||
to_name: 'admin',
|
||||
to_workspace: '',
|
||||
cc_list: [],
|
||||
subject: '请求批准:删除 build/',
|
||||
body: '将执行 rm -rf build/',
|
||||
mail_type: 'permission_request',
|
||||
permission_options: undefined,
|
||||
permission_result: '',
|
||||
status: 'unread',
|
||||
created_at: '2026-09-03T00:00:00Z',
|
||||
hop_limit: 5,
|
||||
...over
|
||||
} as Mail;
|
||||
}
|
||||
|
||||
describe('PermissionPanel 决策', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
// fetchInbox / selectSession 会打网络,替换成空实现
|
||||
useMailStore.setState({ fetchInbox: vi.fn(async () => {}) } as any);
|
||||
useSessionStore.setState({ selectSession: vi.fn(async () => {}) } as any);
|
||||
});
|
||||
|
||||
it('没有 permission_options 时给默认的同意/拒绝两个选项', () => {
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
|
||||
expect(screen.getByRole('button', { name: /同意/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /拒绝/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('有 permission_options 时用它,且顺序保持', () => {
|
||||
render(
|
||||
React.createElement(PermissionPanel, {
|
||||
mail: permMail({ permission_options: ['只这一次', '总是允许', '拒绝'] })
|
||||
})
|
||||
);
|
||||
|
||||
const btns = screen.getAllByRole('button').map(b => b.textContent?.trim());
|
||||
// 顺序是 Agent 给的语义顺序,重排会让「拒绝」跑到人的手指默认位置上
|
||||
expect(btns).toEqual(['只这一次', '总是允许', '拒绝']);
|
||||
});
|
||||
|
||||
it('点选项时把【选项原文】发给服务端', async () => {
|
||||
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
|
||||
render(
|
||||
React.createElement(PermissionPanel, {
|
||||
mail: permMail({ permission_options: ['只这一次', '拒绝'] })
|
||||
})
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /只这一次/ }));
|
||||
|
||||
// 关键:不能归一化成 allow/deny —— 「只这一次」与「总是允许」的区别
|
||||
// 只有 Agent 侧的权限机制懂,服务端与前端都不该替它翻译
|
||||
await waitFor(() =>
|
||||
expect(spy).toHaveBeenCalledWith('m-1', '只这一次', undefined)
|
||||
);
|
||||
});
|
||||
|
||||
it('填了备注时一起发出去', async () => {
|
||||
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText('备注(可选)'), '只删 build,别动 dist');
|
||||
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(spy).toHaveBeenCalledWith('m-1', '同意', '只删 build,别动 dist')
|
||||
);
|
||||
});
|
||||
|
||||
it('备注为空时传 undefined 而不是空字符串', async () => {
|
||||
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||
|
||||
// 空串会在决策邮件里留一行空的「备注:」
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith('m-1', '同意', undefined));
|
||||
});
|
||||
|
||||
it('决策后变成「已处理」,不再显示按钮', async () => {
|
||||
vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('已处理:')).toBeInTheDocument());
|
||||
// 还能点第二次的话人会以为第一次没生效,而服务端那边早已决策
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
|
||||
it('已经有 permission_result 的邮件直接显示结论', () => {
|
||||
render(
|
||||
React.createElement(PermissionPanel, {
|
||||
mail: permMail({ permission_result: '拒绝' })
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByText('拒绝')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
|
||||
it('提交中禁用所有按钮,避免重复决策', async () => {
|
||||
let release: (v: any) => void = () => {};
|
||||
vi.spyOn(api, 'decidePermission').mockReturnValue(
|
||||
new Promise(res => {
|
||||
release = res;
|
||||
}) as any
|
||||
);
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||
|
||||
// 一次危险操作被批准两次,Agent 那边可能真的执行两遍
|
||||
await waitFor(() => {
|
||||
for (const b of screen.getAllByRole('button')) {
|
||||
expect(b).toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
// 收尾:让悬挂的 Promise 落定并等状态更新走完,
|
||||
// 否则组件在测试结束后才 setState,React 会报 act 警告
|
||||
await act(async () => {
|
||||
release({ status: 'decided' });
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText('已处理:')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('提交失败时恢复可点,不假装已决策', async () => {
|
||||
vi.spyOn(api, 'decidePermission').mockRejectedValue(new Error('500'));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||
|
||||
// 失败后显示「已处理」是最糟的结果:人以为批过了,Agent 还在等
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /同意/ })).toBeEnabled());
|
||||
expect(screen.queryByText('已处理:')).toBeNull();
|
||||
});
|
||||
|
||||
it('决策成功后刷新收件箱并选中该会话', async () => {
|
||||
vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
|
||||
const fetchInbox = vi.fn(async () => {});
|
||||
const selectSession = vi.fn(async () => {});
|
||||
useMailStore.setState({ fetchInbox } as any);
|
||||
useSessionStore.setState({ selectSession } as any);
|
||||
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||
|
||||
// 不刷新的话列表里那封还是「未读的权限请求」,人会以为没生效
|
||||
await waitFor(() => {
|
||||
expect(fetchInbox).toHaveBeenCalledWith('all');
|
||||
expect(selectSession).toHaveBeenCalledWith('s-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('同意类选项用绿色,其余用红色', () => {
|
||||
render(
|
||||
React.createElement(PermissionPanel, {
|
||||
mail: permMail({ permission_options: ['允许', 'approve', '拒绝', '算了'] })
|
||||
})
|
||||
);
|
||||
|
||||
const cls = (name: string) =>
|
||||
screen.getByRole('button', { name: new RegExp(name) }).className;
|
||||
|
||||
// 颜色是唯一的视觉提示:点错一次就放行了一个危险操作
|
||||
expect(cls('允许')).toContain('bg-green-600');
|
||||
expect(cls('approve')).toContain('bg-green-600');
|
||||
expect(cls('拒绝')).toContain('text-red-700');
|
||||
// 不在同意词表里的一律按「否」处理 —— 宁可让人多看一眼
|
||||
expect(cls('算了')).toContain('text-red-700');
|
||||
});
|
||||
});
|
||||
45
web/test/components/setup.ts
Normal file
45
web/test/components/setup.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { afterEach, expect } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import * as matchers from '@testing-library/jest-dom/matchers';
|
||||
|
||||
/**
|
||||
* 组件测试的全局准备。
|
||||
*
|
||||
* 三件事,每件都对应一类会串味的状态:
|
||||
* 1. 每个用例后卸载组件树(不卸载的话下一个用例的 getByText 会命中上一个的 DOM)
|
||||
* 2. 重置 zustand store(它是模块级单例,跨用例共享)
|
||||
* 3. 提供 matchMedia —— jsdom 没有实现它,而 useIsNarrow 直接调
|
||||
*/
|
||||
expect.extend(matchers);
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
/**
|
||||
* jsdom 不实现 matchMedia。useIsNarrow 会直接调它并挂 change 监听,
|
||||
* 缺了就抛 `matchMedia is not a function`,整个测试文件挂掉。
|
||||
*
|
||||
* 默认返回 false(宽屏)。要测窄屏的用例用 setViewport(true) 覆盖。
|
||||
*/
|
||||
function installMatchMedia(narrow: boolean) {
|
||||
const listeners = new Set<(e: MediaQueryListEvent) => void>();
|
||||
(window as any).matchMedia = (query: string) => ({
|
||||
matches: narrow,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: (_: string, cb: (e: MediaQueryListEvent) => void) => listeners.add(cb),
|
||||
removeEventListener: (_: string, cb: (e: MediaQueryListEvent) => void) => listeners.delete(cb),
|
||||
// 老式 API,某些库仍在用
|
||||
addListener: (cb: (e: MediaQueryListEvent) => void) => listeners.add(cb),
|
||||
removeListener: (cb: (e: MediaQueryListEvent) => void) => listeners.delete(cb),
|
||||
dispatchEvent: () => false
|
||||
});
|
||||
}
|
||||
|
||||
installMatchMedia(false);
|
||||
|
||||
/** 让当前测试文件里的组件按窄屏/宽屏渲染。在 render 之前调。 */
|
||||
export function setViewport(narrow: boolean) {
|
||||
installMatchMedia(narrow);
|
||||
}
|
||||
Reference in New Issue
Block a user