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:
2026-09-03 12:09:12 +08:00
parent 22ddb1b89c
commit e6fd2fafdc
81 changed files with 11355 additions and 122 deletions

1240
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -8,9 +8,11 @@
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "node test/markdown-xss.test.mjs && node test/narrow-layout.test.mjs",
"test": "node test/markdown-xss.test.mjs && node test/narrow-layout.test.mjs && vitest run",
"test:narrow": "node test/manual/narrow-verify.mjs",
"test:wide": "node test/manual/wide-regression.mjs"
"test:wide": "node test/manual/wide-regression.mjs",
"test:components": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"react": "^18.3.1",
@ -20,13 +22,19 @@
"zustand": "^4.5.4"
},
"devDependencies": {
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"jsdom": "26.1.0",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.6",
"typescript": "^5.5.3",
"vite": "^5.3.4"
"vite": "^5.3.4",
"vitest": "3.2.4"
}
}

View File

@ -475,7 +475,14 @@ function ThreadCard({ mail, onForward }: { mail: Mail; onForward?: () => void })
);
}
function PermissionPanel({ mail }: { mail: Mail }) {
/**
* 权限请求的决策面板。
*
* 导出供测试单独渲染:通过整个 MailView 渲染它需要先把 mailStore 与
* sessionStore 摆到「当前正看着一封 permission_request 邮件」的状态,
* 那些铺垫与这个组件本身的行为无关。
*/
export function PermissionPanel({ mail }: { mail: Mail }) {
const [note, setNote] = useState('');
const [busy, setBusy] = useState(false);
const [decided, setDecided] = useState(mail.permission_result || '');

View File

@ -122,8 +122,10 @@ export function WorkCard({
* 0 = 不限,此时不显示 —— 一个「0/0」或「不限」的徽标对每张卡片都成立
* 等于纯噪声。只在真正设了上限时才占位置。
* 剩 1 个来回时转红:那是需要人介入的时刻(要么加预算,要么让它收尾)。
*
* 导出供测试单独渲染 —— 它是纯展示件,而通过 WorkCard 渲染要先造一整个 Contact。
*/
function BudgetChip({ max, used }: { max: number; used: number }) {
export function BudgetChip({ max, used }: { max: number; used: number }) {
if (!max || max <= 0) return null;
const remaining = Math.max(max - used, 0);

View 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=0dsh下移一格到 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@');
});
});

View 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');
});
});

View 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 落定并等状态更新走完,
// 否则组件在测试结束后才 setStateReact 会报 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');
});
});

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

View File

@ -14,7 +14,11 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
// vitest 的 globals: true 让 describe/it/expect 无需 import
// jest-dom 提供 toBeInTheDocument 等 matcher 的类型。
// 不加这两个的话 tsc --noEmit 会把整套测试报成「找不到名称」。
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src"]
"include": ["src", "test/components", "vitest.config.ts"]
}

27
web/vitest.config.ts Normal file
View File

@ -0,0 +1,27 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
/**
* 组件测试配置。
*
* 与 vite.config.ts 分开:那份是构建与开发服务器的配置,插件链和 test 段混在
* 一起时 `vite build` 也会解析 jsdom 这些只有测试才需要的依赖。
*
* 只跑 test/components/ 下的用例。test/ 顶层那几个markdown-xss、
* narrow-layout是 node:test / 手写断言脚本,由 `npm test` 直接用 node 跑 ——
* 它们不需要 DOM套一层 vitest 只是变慢。
*/
export default defineConfig({
plugins: [react()],
test: {
include: ['test/components/**/*.test.tsx'],
environment: 'jsdom',
globals: true,
setupFiles: ['test/components/setup.ts'],
// 每个文件跑完清掉 DOM 与 mock避免上一个用例的残留影响下一个
restoreMocks: true,
clearMocks: true,
unstubEnvs: true,
unstubGlobals: true
}
});