**主进程此前没有任何守卫**(实测 grep 0 命中:setWindowOpenHandler、
will-navigate、requestSingleInstanceLock 全无)。
在 `loadFile(dist/index.html)` 下,邮件正文里的链接(react-markdown 会渲染
`<a href>`)点下去会**在应用窗口里导航走** —— 一个 `href="https://…"` 就足以
把整个应用窗口变成浏览器,而窗口标题与 preload 注入的 API base/token
全部暴露在那个站点上。
★ **本条防的不是 XSS**:`markdown-xss` 守的是渲染层(无 rehype-raw、
defaultUrlTransform 中和 javascript:),**目前没有 XSS 面**。
这里防的是**导航逃逸** —— 让外部站点**借用**这个窗口与 preload 上下文。
两者失效方式不同,所以分开钉。
**① setWindowOpenHandler** ⇒ 一律 `deny`,地址交系统浏览器。
`allow` 会给那个站点一个**带 preload 的窗口**。
**② will-navigate** ⇒ 拦下并 preventDefault。
★ 但必须**放行本应用自己的加载**(prod 的 `file://` / dev 的 `DEV_URL`)
—— 只会 preventDefault 的实现会把应用自己锁死,首屏进不去。
这是「过严的守卫同样是缺陷」,判据专门为它加了一格。
**③ 外跳只放行 http/https**:file: / javascript: / 自定义协议交给
`shell.openExternal` 意图不可控;`new URL()` 对畸形输入会抛,必须 catch。
**④ requestSingleInstanceLock**:双击图标此前会起**两个进程** ——
两条 SSE 连接、两套 `accounts.json` 并发写入(那个文件是 tmp+rename 原子写,
并发即「后写的赢」),而用户以为只有一扇窗。
**同时修的两处双提交**(形状与 ④ 同源,都是「busy/state 要到提交后才为真」):
· PermissionPanel.submit:审批是本工程**唯一带副作用且不可撤销**的动作
⇒ 同帧两次激活会发出**两条** decidePermission(服务端记两次账)。
照同文件 ForwardBar 的 `inFlight` 形状改。
· Attachments.handleFiles:`uploading` 只加在按钮的 disabled 上,
`<input type=file>` 本身无闸门 ⇒ 上传期间重入会拿到**上一次的 items 闭包**
⇒ onChange 把上一次结果整批覆盖,表现为「附件少了」且**无任何提示**。
★ 清 `input.value` 必须与闸门**成对**提前:只提前清而不加闸门,
会亲手制造「上传中重选同一文件 ⇒ value 已空 ⇒ change 照触发 ⇒ 二次上传」。
**判据(新建 main-process-security.test.mjs,14 格,已接线)**
按括号配对取函数体/实参,**不用** `\{[\s\S]{0,80}` 窗口(§1 第三次露头)。
变异测试 **10 个全部抓住**:去掉 deny / 去掉 preventDefault / 守卫写死不放行自己 /
放开所有 scheme / 拿不到锁不退出 / sandbox:false / contextIsolation:false /
second-instance 删 show() / 删整个 if / 删 restore()。
★ PermissionPanel 那条新判据第一版是**假绿**:`fireEvent.click` 连发两次
(不在 act 里)**删掉闸门也照样绿** —— 两次 fireEvent 之间 React 提交了一次,
第二次点到的是已 disabled 的按钮。必须放进**同一个 act**(同批次、不提交)
才复现。`userEvent.click` 每次都 await 一轮 ⇒ 它测不到同帧。
这条已写进判据注释,免得下一个人再写一次。
**边界 / 未做**
· 这些是**静态**断言,证明守卫被写下来了,**不证明运行时生效**(那要真起窗口点链接)。
· 没有把 webPreferences 的值当"够不够安全"来评审 —— 那属于安全评审,不属可机检;
本条只钉「不许被放松」。
· X-2(index.html 无 CSP)没做:首帧防闪屏那段内联脚本要求 'unsafe-inline',
加 CSP 是在**降低**强度的前提下加一层,值得单独一轮 + 真机验闪屏,不夹在本次。
429 lines
18 KiB
TypeScript
429 lines
18 KiB
TypeScript
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||
import { act, fireEvent, 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());
|
||
});
|
||
|
||
/*
|
||
* ★ 上一格测的是**渲染之后**的 disabled —— 它**抓不到本条要拦的形状**。
|
||
* 那个形状发生在 React 提交**之前**:`busy` 还没变成 true,
|
||
* 两次激活(双击 / Enter 连按 / 鼠标+键盘同时)都读到 `busy === false`。
|
||
* 而审批是本工程里**唯一带副作用且不可撤销**的动作
|
||
* ⇒ 服务端记两次账、Agent 侧收两条通知,且没有撤销入口。
|
||
*
|
||
* ★★ 这里踩过一次**假绿**,值得把形状记准(2026-10-03 实测):
|
||
* 我第一版写 `fireEvent.click(b); fireEvent.click(b);`(不在 `act` 里),
|
||
* 断言 `toHaveBeenCalledTimes(1)` —— **删掉闸门它也照样绿**。
|
||
* 真因:两次 `fireEvent` 之间 React 会提交一次(自动批处理),
|
||
* 于是第二次点到的按钮**已经 disabled** ⇒ 只发一条,与闸门无关。
|
||
* ⇒ 必须把两次点击放进**同一个 `act`**(同一批次、中间不提交)才复现。
|
||
* 实测对照(闸门已删):`A calls = 2`(同 act 内两次 click)。
|
||
* `userEvent.click` 每次都 await 一轮 ⇒ **它测不到同帧**,别用。
|
||
*/
|
||
it('★ 同一帧内的两次激活只发出一条 decidePermission(busy 要到提交后才为真)', async () => {
|
||
let release: (v: any) => void = () => {};
|
||
const spy = vi.spyOn(api, 'decidePermission').mockReturnValue(
|
||
new Promise(res => {
|
||
release = res;
|
||
}) as any
|
||
);
|
||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||
|
||
const approve = screen.getByRole('button', { name: /同意/ });
|
||
// ★ 同**一个** act:两次激活落在同一批次,中间没有 React 提交
|
||
await act(async () => {
|
||
fireEvent.click(approve);
|
||
fireEvent.click(approve);
|
||
});
|
||
|
||
await act(async () => {
|
||
release({ status: 'decided' });
|
||
});
|
||
await waitFor(() => expect(screen.getByText('已处理:')).toBeInTheDocument());
|
||
|
||
expect(spy).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
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> = {}): 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();
|
||
});
|
||
});
|
||
|
||
/**
|
||
* 越窗的待办必须可见。
|
||
*
|
||
* 实测(2026-09-11):pi 桥等不到决策时会在回合超时(默认 10 分钟)拆掉 worker
|
||
* 与决策路由表,此后的批准只会作为通知投递给 Agent。而界面当时照样显示「等待你
|
||
* 决策」、点下去也照样回「已处理」—— 人以为事情办了,实际什么都没发生。
|
||
*
|
||
* 判据分两处:决策**前**要能提前看见(人正要按下去),决策**后**要能知道这次
|
||
* 批准没恢复原调用(服务端回 warning)。
|
||
*/
|
||
describe('PermissionPanel 越窗(可能已失效)', () => {
|
||
beforeEach(() => {
|
||
vi.restoreAllMocks();
|
||
useMailStore.setState({ fetchInbox: vi.fn(async () => {}) } as any);
|
||
useSessionStore.setState({ selectSession: vi.fn(async () => {}) } as any);
|
||
});
|
||
|
||
it('失效时刻已过:决策前就提示「不会恢复原调用」', () => {
|
||
const past = new Date(Date.now() - 60_000).toISOString();
|
||
render(React.createElement(PermissionPanel, { mail: permMail({ permission_expires_at: past }) }));
|
||
|
||
// 仍可决策(决策本身有效,桥会当通知投递),但必须讲清会发生什么
|
||
expect(screen.getByRole('button', { name: /同意/ })).toBeInTheDocument();
|
||
expect(screen.getByText(/不会恢复当时那次工具调用/)).toBeInTheDocument();
|
||
});
|
||
|
||
it('失效时刻未到:不提示,避免把还在等的待办说成过期', () => {
|
||
const future = new Date(Date.now() + 60 * 60_000).toISOString();
|
||
render(React.createElement(PermissionPanel, { mail: permMail({ permission_expires_at: future }) }));
|
||
|
||
expect(screen.queryByText(/不会恢复当时那次工具调用/)).toBeNull();
|
||
});
|
||
|
||
it('没有失效时刻(旧数据):不提示', () => {
|
||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||
expect(screen.queryByText(/不会恢复当时那次工具调用/)).toBeNull();
|
||
});
|
||
|
||
it('决策后服务端回 warning:把它显示在「已处理」旁边', async () => {
|
||
const warning = '该请求已超过等待窗口(10 分钟),发起它的 Agent 很可能已不再阻塞等待。';
|
||
vi.spyOn(api, 'decidePermission').mockResolvedValue({
|
||
status: 'decided',
|
||
decision_mail_id: 'd-1',
|
||
expired: true,
|
||
warning
|
||
} as any);
|
||
|
||
render(
|
||
React.createElement(PermissionPanel, {
|
||
mail: permMail({ permission_options: ['同意', '拒绝'] })
|
||
})
|
||
);
|
||
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText(/已处理/)).toBeInTheDocument();
|
||
});
|
||
// 关键:不能只显示「已处理:同意」就完事 —— 那正是静默成功
|
||
expect(screen.getByText(warning)).toBeInTheDocument();
|
||
});
|
||
});
|