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