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-700"); expect(cls("approve")).toContain("bg-green-700"); expect(cls("拒绝")).toContain("text-red-700"); // 不在同意词表里的一律按「否」处理 —— 宁可让人多看一眼 expect(cls("算了")).toContain("text-red-700"); }); }); /** * 模型主动提问(permission_kind='question')。 * * 与审批型共用 permission_request 这个 mail_type,但语义完全不同: * 这里是「回答问题」而不是「批准执行」。断言集中在两件事: * - 不能把问题渲染成同意/拒绝(那会让人点出一个毫无意义的答案) * - 空回答不能提交(模型会拿到一个什么都没说的结果继续跑) */ describe("PermissionPanel 回答问题", () => { beforeEach(() => { vi.restoreAllMocks(); useMailStore.setState({ fetchInbox: vi.fn(async () => {}) } as any); useSessionStore.setState({ selectSession: vi.fn(async () => {}) } as any); }); const questionMail = (over: Partial = {}): Mail => permMail({ permission_kind: "question", ...over }); it("问题不带选项时:不渲染同意/拒绝,只给自由文本", () => { render( React.createElement(PermissionPanel, { mail: questionMail({ permission_options: [] }), }), ); expect(screen.queryByRole("button", { name: /同意/ })).toBeNull(); expect(screen.queryByRole("button", { name: /拒绝/ })).toBeNull(); expect(screen.getByPlaceholderText("你的回答(必填)")).toBeInTheDocument(); }); it("问题带选项时:渲染选项按钮(而不是同意/拒绝)", () => { render( React.createElement(PermissionPanel, { mail: questionMail({ permission_options: ["方案 A", "方案 B"] }), }), ); expect(screen.getByRole("button", { name: /方案 A/ })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /方案 B/ })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /^同意$/ })).toBeNull(); }); it("单选:再点已选项会取消,不会同时选中两个", async () => { render( React.createElement(PermissionPanel, { mail: questionMail({ permission_options: ["A", "B"] }), }), ); const a = screen.getByRole("button", { name: /^A$/ }); const b = screen.getByRole("button", { name: /^B$/ }); await userEvent.click(a); expect(a).toHaveAttribute("aria-pressed", "true"); await userEvent.click(b); expect(b).toHaveAttribute("aria-pressed", "true"); expect(a).toHaveAttribute("aria-pressed", "false"); }); it("多选:可同时选中多项,提交时用换行拼接", async () => { const spy = vi .spyOn(api, "decidePermission") .mockResolvedValue({ status: "decided" } as any); render( React.createElement(PermissionPanel, { mail: questionMail({ permission_options: ["A", "B"], permission_multi_select: true, }), }), ); await userEvent.click(screen.getByRole("button", { name: /^A$/ })); await userEvent.click(screen.getByRole("button", { name: /^B$/ })); await userEvent.click(screen.getByRole("button", { name: /提交回答/ })); // 服务端按换行拆分多选答案,不能拼接成 "AB" 或数组字符串 await waitFor(() => expect(spy).toHaveBeenCalledWith("m-1", "A\nB", undefined), ); }); it("空回答禁止提交(模型不能拿到一个什么都没说的结果)", async () => { const spy = vi .spyOn(api, "decidePermission") .mockResolvedValue({ status: "decided" } as any); render( React.createElement(PermissionPanel, { mail: questionMail({ permission_options: [] }), }), ); const submit = screen.getByRole("button", { name: /提交回答/ }); expect(submit).toBeDisabled(); expect(screen.getByText("请先选择或填写回答")).toBeInTheDocument(); await userEvent.type( screen.getByPlaceholderText("你的回答(必填)"), "配置在 /etc/foo.conf", ); expect(submit).toBeEnabled(); await userEvent.click(submit); await waitFor(() => expect(spy).toHaveBeenCalledWith("m-1", "", "配置在 /etc/foo.conf"), ); }); it("选了选项又写了备注:两者都发出去", async () => { const spy = vi .spyOn(api, "decidePermission") .mockResolvedValue({ status: "decided" } as any); render( React.createElement(PermissionPanel, { mail: questionMail({ permission_options: ["方案 A"] }), }), ); await userEvent.click(screen.getByRole("button", { name: /方案 A/ })); await userEvent.type( screen.getByPlaceholderText("补充说明(可选)"), "但要先备份", ); await userEvent.click(screen.getByRole("button", { name: /提交回答/ })); await waitFor(() => expect(spy).toHaveBeenCalledWith("m-1", "方案 A", "但要先备份"), ); }); it("问题已回答过:显示结论,不再显示任何输入控件", () => { render( React.createElement(PermissionPanel, { mail: questionMail({ permission_result: "方案 A" }), }), ); expect(screen.getByText(/已处理/)).toBeInTheDocument(); expect(screen.queryByRole("button")).toBeNull(); }); it("审批型(无 permission_kind)仍然走同意/拒绝路径", () => { render(React.createElement(PermissionPanel, { mail: permMail() })); // 回归防护:question 分支不能把普通审批也带走 expect(screen.getByRole("button", { name: /同意/ })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /提交回答/ })).toBeNull(); }); });