800 lines
37 KiB
TypeScript
800 lines
37 KiB
TypeScript
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||
import { ChannelAccounts } from '@/pages/CloudAgents/ChannelAccounts';
|
||
import {
|
||
EMPTY_CLOUD_CONFIGURATION,
|
||
type CloudAgentDraft,
|
||
type CloudPendingOperation,
|
||
type CloudUserChannelAccount,
|
||
type CloudWechatBindState,
|
||
} from '../../shared/cloud-agents';
|
||
|
||
const api = vi.hoisted(() => ({
|
||
call: vi.fn(),
|
||
list: vi.fn(),
|
||
recovery: vi.fn(),
|
||
resolvePending: vi.fn(),
|
||
}));
|
||
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
|
||
vi.mock('@/pages/CloudAgents/CloudWechatQr', () => ({
|
||
CloudWechatQr: () => <img alt="个人微信登录二维码" src="data:image/png;base64,test" />,
|
||
}));
|
||
const agentA: CloudAgentDraft = {
|
||
slug: 'ml-' + 'a'.repeat(32),
|
||
name: '写作助手',
|
||
purpose: '',
|
||
system_prompt: '',
|
||
published_version: 2,
|
||
draft_revision: 3,
|
||
updated_at: '',
|
||
configuration: EMPTY_CLOUD_CONFIGURATION,
|
||
enabled: true,
|
||
};
|
||
const agentB: CloudAgentDraft = {
|
||
...agentA,
|
||
slug: 'ml-' + 'b'.repeat(32),
|
||
name: '资料助手',
|
||
published_version: 4,
|
||
};
|
||
const legacy: CloudUserChannelAccount = {
|
||
id: 'wechat-legacy',
|
||
display_name: '原有微信',
|
||
binding_id: 'binding-legacy',
|
||
revision: 7,
|
||
enabled: true,
|
||
status: 'active',
|
||
health: 'connected',
|
||
blockers: [],
|
||
target_agent_slug: agentA.slug,
|
||
target_agent_name: agentA.name,
|
||
target_agent_published_version: 2,
|
||
sync_state: 'ready',
|
||
};
|
||
const spare: CloudUserChannelAccount = {
|
||
...legacy,
|
||
id: 'wechat-spare',
|
||
display_name: '备用微信',
|
||
revision: 2,
|
||
enabled: false,
|
||
status: 'paused',
|
||
health: 'login_required',
|
||
blockers: ['target_required'],
|
||
target_agent_slug: null,
|
||
target_agent_name: null,
|
||
target_agent_published_version: null,
|
||
};
|
||
let accounts: CloudUserChannelAccount[];
|
||
let agents: CloudAgentDraft[];
|
||
let pending: CloudPendingOperation[];
|
||
let qrStatus: CloudWechatBindState['status'];
|
||
let verifyStatus: CloudWechatBindState['status'];
|
||
const calls = (operation: string) => api.call.mock.calls.filter(([name]) => name === operation);
|
||
const writes = () =>
|
||
api.call.mock.calls.filter(
|
||
([name]) => !['channelAccounts', 'channelAccountWechatBindStatus'].includes(name)
|
||
);
|
||
function LocationProbe() {
|
||
const location = useLocation();
|
||
return <output data-testid="location-path">{location.pathname + location.search}</output>;
|
||
}
|
||
function renderPage(entry = '/cloud-agents/channels') {
|
||
return render(
|
||
<MemoryRouter initialEntries={[entry]}>
|
||
<Routes>
|
||
<Route path="/cloud-agents/channels" element={<ChannelAccounts />} />
|
||
<Route path="/cloud-agents" element={<div>智能体列表</div>} />
|
||
</Routes>
|
||
<LocationProbe />
|
||
</MemoryRouter>
|
||
);
|
||
}
|
||
async function card(name = legacy.display_name) {
|
||
return screen.findByRole('article', { name: name + '微信', exact: true });
|
||
}
|
||
async function chooseExisting(name = legacy.display_name) {
|
||
const current = await card(name);
|
||
fireEvent.click(within(current).getByRole('button', { name: /更换智能体|绑定智能体/ }));
|
||
return screen.getByRole('dialog', { name: '更换智能体', exact: true });
|
||
}
|
||
async function more(name = legacy.display_name) {
|
||
const current = await card(name);
|
||
fireEvent.click(within(current).getByRole('button', { name: '更多操作', exact: true }));
|
||
return current;
|
||
}
|
||
async function adding() {
|
||
fireEvent.click(await screen.findByRole('button', { name: '添加微信', exact: true }));
|
||
const dialog = screen.getByRole('dialog', { name: '添加微信', exact: true });
|
||
fireEvent.click(within(dialog).getByRole('radio', { name: agentA.name }));
|
||
await act(async () =>
|
||
fireEvent.click(within(dialog).getByRole('button', { name: '下一步:扫码连接', exact: true }))
|
||
);
|
||
return dialog;
|
||
}
|
||
function installMock() {
|
||
api.call.mockImplementation(async (operation: string, input: Record<string, unknown>) => {
|
||
if (operation === 'channelAccounts') return { items: accounts.map((item) => ({ ...item })) };
|
||
if (operation === 'createChannelAccount') {
|
||
const channel = {
|
||
...spare,
|
||
id: 'wechat-created',
|
||
display_name: String(input.display_name),
|
||
revision: 1,
|
||
};
|
||
accounts.push(channel);
|
||
return { channel: { ...channel } };
|
||
}
|
||
const account = accounts.find((item) => item.id === input.channel_account_id);
|
||
if (!account) throw new Error('Unknown fixture account');
|
||
if (operation === 'routeChannelAccount') {
|
||
const target = agents.find((item) => item.slug === input.target_agent_slug);
|
||
Object.assign(account, {
|
||
revision: account.revision + 1,
|
||
target_agent_slug: target?.slug ?? null,
|
||
target_agent_name: target?.name ?? null,
|
||
enabled: input.enabled,
|
||
blockers: input.enabled ? [] : ['binding_paused'],
|
||
});
|
||
return { channel: { ...account } };
|
||
}
|
||
if (operation === 'enableChannelAccount' || operation === 'pauseChannelAccount') {
|
||
const enabled = operation === 'enableChannelAccount';
|
||
Object.assign(account, {
|
||
revision: account.revision + 1,
|
||
enabled,
|
||
blockers: enabled ? [] : ['binding_paused'],
|
||
});
|
||
return { channel: { ...account } };
|
||
}
|
||
if (operation === 'unbindChannelAccount') {
|
||
Object.assign(account, {
|
||
revision: account.revision + 1,
|
||
health: 'login_required',
|
||
enabled: false,
|
||
target_agent_slug: null,
|
||
target_agent_name: null,
|
||
blockers: ['target_required'],
|
||
});
|
||
return { channel: { ...account }, unbound: true, disconnected: true };
|
||
}
|
||
if (
|
||
[
|
||
'channelAccountWechatBindStart',
|
||
'channelAccountWechatBindStatus',
|
||
'channelAccountWechatBindVerification',
|
||
].includes(operation)
|
||
) {
|
||
const status = operation === 'channelAccountWechatBindVerification' ? verifyStatus : qrStatus;
|
||
if (status === 'confirmed') account.health = 'connected';
|
||
return { session_key: 'qr-' + account.id, status, qrcode_url: 'https://login.example.test/qr' };
|
||
}
|
||
throw new Error('Unexpected fixture operation ' + operation);
|
||
});
|
||
}
|
||
beforeEach(() => {
|
||
vi.resetAllMocks();
|
||
accounts = [{ ...legacy }, { ...spare }];
|
||
agents = [agentA, agentB];
|
||
pending = [];
|
||
qrStatus = 'verification_required';
|
||
verifyStatus = 'pending';
|
||
api.list.mockImplementation(async () => ({ agents, next_cursor: null }));
|
||
api.recovery.mockImplementation(async () => ({ recent: null, pending }));
|
||
installMock();
|
||
});
|
||
afterEach(() => {
|
||
cleanup();
|
||
vi.useRealTimers();
|
||
});
|
||
|
||
it.each([
|
||
['none', []],
|
||
['drafts', [{ ...agentA, published_version: null }]],
|
||
['disabled', [{ ...agentA, enabled: false }]],
|
||
] satisfies [string, CloudAgentDraft[]][])(
|
||
'retains no-Agent guidance and makes no binding requests for %s Agents',
|
||
async (_name, unavailable) => {
|
||
agents = unavailable;
|
||
accounts = [];
|
||
renderPage();
|
||
expect(await screen.findByRole('heading', { name: '还没有可连接的智能体' })).toBeVisible();
|
||
expect(writes()).toEqual([]);
|
||
fireEvent.click(screen.getByRole('button', { name: '去我的智能体' }));
|
||
expect(await screen.findByText('智能体列表')).toBeVisible();
|
||
expect(screen.getByTestId('location-path')).toHaveTextContent('/cloud-agents?view=mine');
|
||
}
|
||
);
|
||
it('keeps one main action on each card and places pause/unbind in More', async () => {
|
||
renderPage();
|
||
const current = await card();
|
||
expect(within(current).getByText('使用中')).toBeVisible();
|
||
expect(within(current).getByText(agentA.name)).toBeVisible();
|
||
expect(within(current).getByRole('button', { name: '更换智能体' })).toBeVisible();
|
||
expect(within(current).queryByRole('button', { name: '解除微信绑定' })).not.toBeInTheDocument();
|
||
const menu = await more();
|
||
expect(within(menu).getByRole('button', { name: '暂停自动回复' })).toBeVisible();
|
||
expect(within(menu).getByRole('button', { name: '解除微信绑定' })).toBeVisible();
|
||
expect(screen.queryByText('断开微信连接')).not.toBeInTheDocument();
|
||
expect(screen.queryByText('活动记录')).not.toBeInTheDocument();
|
||
expect(writes()).toEqual([]);
|
||
});
|
||
it('uses one Agent-choice window and confirms a switch without a second dialog', async () => {
|
||
renderPage();
|
||
const dialog = await chooseExisting();
|
||
expect(within(dialog).getByRole('button', { name: '确认更换' })).toBeDisabled();
|
||
fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
|
||
expect(calls('routeChannelAccount')).toHaveLength(0);
|
||
fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' }));
|
||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||
expect(calls('routeChannelAccount')).toHaveLength(1);
|
||
expect(calls('routeChannelAccount')[0][1]).toMatchObject({
|
||
channel_account_id: legacy.id,
|
||
target_agent_slug: agentB.slug,
|
||
enabled: true,
|
||
expected_revision: 7,
|
||
});
|
||
expect(await screen.findByText('已更换智能体,微信连接和历史保留。')).toBeVisible();
|
||
expect(calls('channelAccountWechatBindStart')).toHaveLength(0);
|
||
});
|
||
it('switches a paused binding without enabling it', async () => {
|
||
accounts[0] = { ...legacy, enabled: false, blockers: ['binding_paused'] };
|
||
renderPage();
|
||
expect(await screen.findByText('已暂停')).toBeVisible();
|
||
const dialog = await chooseExisting();
|
||
fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
|
||
fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' }));
|
||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||
expect(accounts[0].enabled).toBe(false);
|
||
expect(calls('routeChannelAccount')[0][1]).toMatchObject({ enabled: false });
|
||
expect(calls('enableChannelAccount')).toHaveLength(0);
|
||
});
|
||
it('shows a rejected switch inside the still-open choice dialog and preserves the old binding', async () => {
|
||
const base = api.call.getMockImplementation()!;
|
||
api.call.mockImplementation(async (op, input) => {
|
||
if (op === 'routeChannelAccount') throw new Error('渠道状态已变化');
|
||
return base(op, input);
|
||
});
|
||
renderPage();
|
||
const dialog = await chooseExisting();
|
||
fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
|
||
fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' }));
|
||
expect(await within(dialog).findByRole('alert')).toHaveTextContent('渠道状态已变化');
|
||
expect(within(dialog).getByRole('radio', { name: agentB.name })).toBeChecked();
|
||
expect(accounts[0].target_agent_slug).toBe(agentA.slug);
|
||
expect(screen.getAllByRole('dialog')).toHaveLength(1);
|
||
});
|
||
it('never overwrites a final read with a stale switch receipt', async () => {
|
||
const base = api.call.getMockImplementation()!;
|
||
api.call.mockImplementation(async (op, input) => {
|
||
if (op === 'routeChannelAccount') {
|
||
await base(op, input);
|
||
return { channel: { ...legacy } };
|
||
}
|
||
return base(op, input);
|
||
});
|
||
renderPage();
|
||
const dialog = await chooseExisting();
|
||
fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
|
||
fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' }));
|
||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||
expect(within(await card()).getByText(agentB.name)).toBeVisible();
|
||
expect(within(await card()).getByText('使用中')).toBeVisible();
|
||
});
|
||
it('pauses and resumes with authoritative state, ignoring a stale receipt', async () => {
|
||
const base = api.call.getMockImplementation()!;
|
||
api.call.mockImplementation(async (op, input) => {
|
||
if (op === 'pauseChannelAccount') {
|
||
await base(op, input);
|
||
return { channel: { ...legacy } };
|
||
}
|
||
return base(op, input);
|
||
});
|
||
renderPage();
|
||
fireEvent.click(within(await more()).getByRole('button', { name: '暂停自动回复' }));
|
||
expect(await screen.findByText('已暂停')).toBeVisible();
|
||
const menu = await more();
|
||
expect(within(menu).getByRole('button', { name: '恢复自动回复' })).toBeEnabled();
|
||
fireEvent.click(within(menu).getByRole('button', { name: '恢复自动回复' }));
|
||
expect(await screen.findByText('使用中')).toBeVisible();
|
||
expect(calls('enableChannelAccount')[0][1]).toMatchObject({ expected_revision: 8 });
|
||
expect(calls('routeChannelAccount')).toHaveLength(0);
|
||
});
|
||
it('uses unified WeChat unbind after one explicit confirmation and preserves the account card', async () => {
|
||
renderPage();
|
||
fireEvent.click(within(await more()).getByRole('button', { name: '解除微信绑定' }));
|
||
const dialog = screen.getByRole('dialog', { name: '解除微信绑定' });
|
||
expect(within(dialog).getByText(/历史仍保留/)).toBeVisible();
|
||
expect(calls('unbindChannelAccount')).toHaveLength(0);
|
||
fireEvent.click(within(dialog).getByRole('button', { name: '确认解除微信绑定' }));
|
||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||
expect(within(await card()).getByText('未绑定')).toBeVisible();
|
||
expect(within(await card()).getByRole('button', { name: '绑定智能体' })).toBeVisible();
|
||
expect(calls('unbindChannelAccount')[0][1]).toMatchObject({
|
||
channel_account_id: legacy.id,
|
||
expected_revision: 7,
|
||
});
|
||
expect(calls('disconnectChannelAccount')).toHaveLength(0);
|
||
expect(calls('routeChannelAccount')).toHaveLength(0);
|
||
});
|
||
it('shows unbind errors in the current dialog and never reports success from an incomplete receipt', async () => {
|
||
const base = api.call.getMockImplementation()!;
|
||
api.call.mockImplementation(async (op, input) =>
|
||
op === 'unbindChannelAccount'
|
||
? { channel: { ...legacy }, unbound: false, disconnected: false }
|
||
: base(op, input)
|
||
);
|
||
renderPage();
|
||
fireEvent.click(within(await more()).getByRole('button', { name: '解除微信绑定' }));
|
||
const dialog = screen.getByRole('dialog', { name: '解除微信绑定' });
|
||
fireEvent.click(within(dialog).getByRole('button', { name: '确认解除微信绑定' }));
|
||
expect(await within(dialog).findByRole('alert')).toHaveTextContent('解除结果还没有确认');
|
||
expect(within(dialog).getByRole('button', { name: '确认解除微信绑定' })).toBeDisabled();
|
||
expect(screen.queryByText('微信绑定已解除,历史仍保留。')).not.toBeInTheDocument();
|
||
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
|
||
expect(within(await card()).getByText('正在确认')).toBeVisible();
|
||
expect(within(await card()).getByRole('button', { name: '更换智能体' })).toBeDisabled();
|
||
});
|
||
it('does not enable a pending or verification-required QR', async () => {
|
||
renderPage();
|
||
const dialog = await adding();
|
||
expect(await within(dialog).findByRole('img', { name: '个人微信登录二维码' })).toBeVisible();
|
||
expect(calls('createChannelAccount')).toHaveLength(1);
|
||
expect(calls('routeChannelAccount')[0][1]).toMatchObject({
|
||
target_agent_slug: agentA.slug,
|
||
enabled: false,
|
||
});
|
||
expect(calls('enableChannelAccount')).toHaveLength(0);
|
||
expect(within(dialog).queryByRole('button', { name: '完成绑定并启用' })).not.toBeInTheDocument();
|
||
fireEvent.change(within(dialog).getByLabelText('微信验证码'), { target: { value: '123456' } });
|
||
await act(async () => fireEvent.click(within(dialog).getByRole('button', { name: '提交验证码' })));
|
||
expect(await within(dialog).findByText('等待微信确认')).toBeVisible();
|
||
expect(calls('enableChannelAccount')).toHaveLength(0);
|
||
});
|
||
it('automatically enables once after genuine QR confirmation and final readback', async () => {
|
||
qrStatus = 'confirmed';
|
||
renderPage();
|
||
const dialog = await adding();
|
||
expect(await within(dialog).findByRole('heading', { name: '微信绑定成功' })).toBeVisible();
|
||
expect(calls('enableChannelAccount')).toHaveLength(1);
|
||
expect(calls('enableChannelAccount')[0][1]).toMatchObject({ expected_revision: 2 });
|
||
expect(within(dialog).queryByRole('button', { name: '完成绑定并启用' })).not.toBeInTheDocument();
|
||
expect(calls('channelAccounts').length).toBeGreaterThanOrEqual(5);
|
||
});
|
||
it('a confirmed status without authoritative login cannot enable or claim success', async () => {
|
||
const base = api.call.getMockImplementation()!;
|
||
api.call.mockImplementation(async (op, input) =>
|
||
op === 'channelAccountWechatBindStart' ? { session_key: 'qr', status: 'confirmed' } : base(op, input)
|
||
);
|
||
renderPage();
|
||
const dialog = await adding();
|
||
expect(await within(dialog).findByRole('alert')).toHaveTextContent('微信还没有准备好');
|
||
expect(within(dialog).queryByRole('heading', { name: '微信绑定成功' })).not.toBeInTheDocument();
|
||
expect(calls('enableChannelAccount')).toHaveLength(0);
|
||
expect(within(dialog).getByRole('button', { name: '重试完成绑定' })).toBeEnabled();
|
||
});
|
||
it('a failed automatic enable offers explicit retry without an automatic loop', async () => {
|
||
qrStatus = 'confirmed';
|
||
const base = api.call.getMockImplementation()!;
|
||
let attempts = 0;
|
||
api.call.mockImplementation(async (op, input) => {
|
||
if (op === 'enableChannelAccount' && ++attempts === 1) throw new Error('启用暂时失败');
|
||
return base(op, input);
|
||
});
|
||
renderPage();
|
||
const dialog = await adding();
|
||
expect(await within(dialog).findByRole('alert')).toHaveTextContent('启用暂时失败');
|
||
expect(calls('enableChannelAccount')).toHaveLength(1);
|
||
fireEvent.click(within(dialog).getByRole('button', { name: '重试完成绑定' }));
|
||
expect(await within(dialog).findByRole('heading', { name: '微信绑定成功' })).toBeVisible();
|
||
expect(calls('enableChannelAccount')).toHaveLength(2);
|
||
expect(calls('enableChannelAccount')[0][1]).toEqual(calls('enableChannelAccount')[1][1]);
|
||
});
|
||
it('stale enable receipts cannot conceal a still-disabled authoritative account', async () => {
|
||
qrStatus = 'confirmed';
|
||
const base = api.call.getMockImplementation()!;
|
||
api.call.mockImplementation(async (op, input) =>
|
||
op === 'enableChannelAccount'
|
||
? { channel: { ...accounts.find((item) => item.id === input.channel_account_id), enabled: true } }
|
||
: base(op, input)
|
||
);
|
||
renderPage();
|
||
const dialog = await adding();
|
||
expect(await within(dialog).findByRole('alert')).toHaveTextContent('操作结果还没有确认');
|
||
expect(within(dialog).queryByRole('heading', { name: '微信绑定成功' })).not.toBeInTheDocument();
|
||
fireEvent.click(within(dialog).getByRole('button', { name: '稍后继续' }));
|
||
const current = await card('微信 3');
|
||
expect(within(current).getByText('正在确认')).toBeVisible();
|
||
expect(within(current).queryByText('使用中')).not.toBeInTheDocument();
|
||
});
|
||
it('does not enable a different Agent or an offline account after QR confirmation', async () => {
|
||
qrStatus = 'confirmed';
|
||
const base = api.call.getMockImplementation()!;
|
||
api.call.mockImplementation(async (op, input) => {
|
||
const result = await base(op, input);
|
||
if (op === 'channelAccountWechatBindStart')
|
||
accounts.find((item) => item.id === input.channel_account_id)!.target_agent_slug = agentB.slug;
|
||
return result;
|
||
});
|
||
renderPage();
|
||
const dialog = await adding();
|
||
expect(await within(dialog).findByRole('alert')).toHaveTextContent('绑定的智能体已发生变化');
|
||
expect(calls('enableChannelAccount')).toHaveLength(0);
|
||
});
|
||
it('never auto-enables or replays old paused records on page entry or refresh', async () => {
|
||
accounts[0] = { ...legacy, enabled: false, blockers: ['binding_paused'] };
|
||
renderPage();
|
||
await screen.findByText('已暂停');
|
||
fireEvent.click(screen.getByRole('button', { name: '刷新微信列表' }));
|
||
await waitFor(() => expect(calls('channelAccounts')).toHaveLength(2));
|
||
expect(writes()).toEqual([]);
|
||
});
|
||
it('reconnects an old paused binding while keeping automatic replies paused', async () => {
|
||
accounts[0] = { ...legacy, health: 'login_required', enabled: false, blockers: ['binding_paused'] };
|
||
qrStatus = 'confirmed';
|
||
renderPage();
|
||
const current = await card();
|
||
fireEvent.click(within(current).getByRole('button', { name: '重新扫码' }));
|
||
const dialog = screen.getByRole('dialog', { name: '重新连接微信' });
|
||
expect(await within(dialog).findByRole('heading', { name: '微信重新连接成功' })).toBeVisible();
|
||
expect(within(dialog).getByText('微信已连接,自动回复仍保持暂停。')).toBeVisible();
|
||
expect(calls('enableChannelAccount')).toHaveLength(0);
|
||
expect(calls('routeChannelAccount')).toHaveLength(0);
|
||
});
|
||
it('stops polling after a QR confirmation and deduplicates activation', async () => {
|
||
renderPage();
|
||
await screen.findByRole('button', { name: '添加微信' });
|
||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||
const dialog = await adding();
|
||
await within(dialog).findByRole('img', { name: '个人微信登录二维码' });
|
||
qrStatus = 'confirmed';
|
||
await act(async () => {
|
||
await vi.advanceTimersByTimeAsync(2000);
|
||
});
|
||
expect(within(dialog).getByRole('heading', { name: '微信绑定成功' })).toBeVisible();
|
||
await act(async () => {
|
||
await vi.advanceTimersByTimeAsync(6000);
|
||
});
|
||
expect(calls('enableChannelAccount')).toHaveLength(1);
|
||
expect(calls('channelAccountWechatBindStatus')).toHaveLength(1);
|
||
});
|
||
it('does not create an enable request after unmount during the authoritative QR read', async () => {
|
||
qrStatus = 'confirmed';
|
||
const base = api.call.getMockImplementation()!;
|
||
let release: (() => void) | undefined;
|
||
api.call.mockImplementation(async (op, input) => {
|
||
if (op === 'channelAccounts' && calls('channelAccountWechatBindStart').length)
|
||
await new Promise<void>((resolve) => {
|
||
release = resolve;
|
||
});
|
||
return base(op, input);
|
||
});
|
||
const view = renderPage();
|
||
await adding();
|
||
await waitFor(() => expect(release).toBeTypeOf('function'));
|
||
view.unmount();
|
||
await act(async () => release!());
|
||
expect(calls('enableChannelAccount')).toHaveLength(0);
|
||
});
|
||
it('closing a pending QR does not later enable it or leave polling alive', async () => {
|
||
renderPage();
|
||
const dialog = await adding();
|
||
await within(dialog).findByRole('img', { name: '个人微信登录二维码' });
|
||
vi.useFakeTimers();
|
||
fireEvent.click(within(dialog).getByRole('button', { name: '稍后继续' }));
|
||
qrStatus = 'confirmed';
|
||
await act(async () => {
|
||
await vi.advanceTimersByTimeAsync(6000);
|
||
});
|
||
expect(calls('channelAccountWechatBindStatus')).toHaveLength(0);
|
||
expect(calls('enableChannelAccount')).toHaveLength(0);
|
||
});
|
||
it('retries an unsuccessful new route with the same operation, without creating another account', async () => {
|
||
const base = api.call.getMockImplementation()!;
|
||
let attempts = 0;
|
||
api.call.mockImplementation(async (op, input) => {
|
||
if (op === 'routeChannelAccount' && ++attempts === 1) throw new Error('绑定未完成');
|
||
return base(op, input);
|
||
});
|
||
renderPage();
|
||
const dialog = await adding();
|
||
expect(await within(dialog).findByRole('alert')).toHaveTextContent('绑定未完成');
|
||
fireEvent.click(within(dialog).getByRole('button', { name: '下一步:扫码连接' }));
|
||
await within(dialog).findByRole('img', { name: '个人微信登录二维码' });
|
||
expect(calls('createChannelAccount')).toHaveLength(1);
|
||
expect(calls('routeChannelAccount')[0][1]).toEqual(calls('routeChannelAccount')[1][1]);
|
||
});
|
||
it('represents a pending operation honestly and prevents conflicting changes until recovery', async () => {
|
||
pending = [
|
||
{
|
||
id: 'unknown-pause',
|
||
operation: 'pauseChannelAccount',
|
||
input: { channel_account_id: legacy.id, expected_revision: 7, operation_id: 'original' },
|
||
created_at: '',
|
||
},
|
||
];
|
||
renderPage();
|
||
const current = await card();
|
||
expect(within(current).getByText('正在确认')).toBeVisible();
|
||
expect(within(current).getByRole('button', { name: '更换智能体' })).toBeDisabled();
|
||
expect(screen.getByRole('button', { name: '添加微信' })).toBeDisabled();
|
||
expect(writes()).toEqual([]);
|
||
pending = [];
|
||
accounts[0] = { ...legacy, enabled: false, blockers: ['binding_paused'] };
|
||
fireEvent.click(within(current).getByRole('button', { name: '刷新确认' }));
|
||
expect(await screen.findByText('已暂停')).toBeVisible();
|
||
expect(within(current).getByRole('button', { name: '更换智能体' })).toBeEnabled();
|
||
expect(writes()).toEqual([]);
|
||
});
|
||
it('does not paint local paused or degraded synchronization as usable', async () => {
|
||
accounts = [
|
||
{ ...legacy, enabled: false, desired_state: 'paused', blockers: ['binding_paused'] },
|
||
{
|
||
...spare,
|
||
target_agent_slug: agentA.slug,
|
||
health: 'connected',
|
||
enabled: true,
|
||
sync_state: 'degraded',
|
||
blockers: ['channel_sync_degraded'],
|
||
},
|
||
];
|
||
renderPage();
|
||
expect(await screen.findByText('已暂停')).toBeVisible();
|
||
expect(within(await card(spare.display_name)).getByText('正在确认')).toBeVisible();
|
||
expect(screen.queryByText('使用中')).not.toBeInTheDocument();
|
||
});
|
||
it('deep links open the single choice dialog and refresh preserves user choice', async () => {
|
||
renderPage('/cloud-agents/channels?account=' + legacy.id + '&target=' + agentB.slug);
|
||
const dialog = await screen.findByRole('dialog', { name: '更换智能体' });
|
||
expect(within(dialog).getByRole('radio', { name: agentB.name })).toBeChecked();
|
||
expect(screen.getByTestId('location-path')).not.toHaveTextContent('target=');
|
||
expect(writes()).toEqual([]);
|
||
});
|
||
it.each(['accounts', 'agents'])(
|
||
'keeps failed %s loading distinct from an empty list',
|
||
async (source) => {
|
||
if (source === 'accounts') api.call.mockRejectedValue(new Error('微信列表暂时不可用'));
|
||
else api.list.mockRejectedValue(new Error('智能体列表暂时不可用'));
|
||
renderPage();
|
||
expect(await screen.findByRole('alert')).toHaveTextContent('暂时不可用');
|
||
expect(screen.getByRole('button', { name: '添加微信' })).toBeDisabled();
|
||
expect(screen.queryByRole('heading', { name: '把你的智能体带到微信吧' })).not.toBeInTheDocument();
|
||
}
|
||
);
|
||
|
||
it('locks changing an inconsistent local paused route without preventing safe unbind', async () => {
|
||
accounts = [{ ...legacy, desired_state: 'paused', enabled: true }];
|
||
renderPage();
|
||
const current = await card();
|
||
expect(within(current).getByText('正在确认')).toBeVisible();
|
||
expect(within(current).queryByText('使用中')).not.toBeInTheDocument();
|
||
expect(within(current).getByRole('button', { name: '更换智能体' })).toBeDisabled();
|
||
fireEvent.click(within(current).getByRole('button', { name: '更多操作' }));
|
||
expect(within(current).getByRole('button', { name: '解除微信绑定' })).toBeEnabled();
|
||
});
|
||
|
||
it('loads all Agent pages and offers only published enabled targets', async () => {
|
||
api.list.mockImplementation(async (cursor) =>
|
||
cursor
|
||
? {
|
||
agents: [
|
||
agentB,
|
||
{ ...agentA, slug: 'ml-' + 'c'.repeat(32), name: '未发布', published_version: null },
|
||
],
|
||
next_cursor: null,
|
||
}
|
||
: { agents: [agentA], next_cursor: 'page-2' }
|
||
);
|
||
renderPage();
|
||
const dialog = await chooseExisting();
|
||
expect(within(dialog).getByRole('radio', { name: agentA.name })).toBeVisible();
|
||
expect(within(dialog).getByRole('radio', { name: agentB.name })).toBeVisible();
|
||
expect(within(dialog).queryByRole('radio', { name: '未发布' })).not.toBeInTheDocument();
|
||
expect(api.list).toHaveBeenNthCalledWith(2, 'page-2');
|
||
});
|
||
|
||
it('keeps existing cards removable when no available Agent remains', async () => {
|
||
agents = [];
|
||
renderPage();
|
||
const current = await more();
|
||
fireEvent.click(within(current).getByRole('button', { name: '解除微信绑定' }));
|
||
const dialog = screen.getByRole('dialog', { name: '解除微信绑定' });
|
||
expect(within(dialog).getByRole('button', { name: '确认解除微信绑定' })).toBeEnabled();
|
||
expect(writes()).toEqual([]);
|
||
});
|
||
|
||
it.each(['expired', 'failed'] as const)(
|
||
'offers an explicit QR refresh after %s without claiming success',
|
||
async (status) => {
|
||
qrStatus = status;
|
||
renderPage();
|
||
const dialog = await adding();
|
||
expect(await within(dialog).findByRole('button', { name: '刷新二维码' })).toBeEnabled();
|
||
expect(calls('enableChannelAccount')).toHaveLength(0);
|
||
qrStatus = 'confirmed';
|
||
fireEvent.click(within(dialog).getByRole('button', { name: '刷新二维码' }));
|
||
await within(dialog).findByRole('heading', { name: '微信绑定成功' });
|
||
expect(calls('channelAccountWechatBindStart')[1][1]).toMatchObject({ force: true });
|
||
expect(calls('enableChannelAccount')).toHaveLength(1);
|
||
}
|
||
);
|
||
|
||
it('continues an existing unfinished account without creating another account slot', async () => {
|
||
accounts = [{ ...spare, health: 'connected' }];
|
||
renderPage();
|
||
const dialog = await chooseExisting(spare.display_name);
|
||
fireEvent.click(within(dialog).getByRole('radio', { name: agentA.name }));
|
||
fireEvent.click(within(dialog).getByRole('button', { name: '下一步:扫码连接' }));
|
||
await within(dialog).findByRole('heading', { name: '微信绑定成功' });
|
||
expect(calls('createChannelAccount')).toHaveLength(0);
|
||
expect(calls('channelAccountWechatBindStart')).toHaveLength(0);
|
||
expect(calls('enableChannelAccount')).toHaveLength(1);
|
||
});
|
||
|
||
it('does not enable an offline Worker after confirmed QR login', async () => {
|
||
qrStatus = 'confirmed';
|
||
const base = api.call.getMockImplementation()!;
|
||
api.call.mockImplementation(async (op, input) => {
|
||
const result = await base(op, input);
|
||
if (op === 'channelAccountWechatBindStart')
|
||
accounts.find((item) => item.id === input.channel_account_id)!.blockers = ['worker_offline'];
|
||
return result;
|
||
});
|
||
renderPage();
|
||
const dialog = await adding();
|
||
expect(await within(dialog).findByRole('alert')).toHaveTextContent('微信还没有准备好');
|
||
expect(calls('enableChannelAccount')).toHaveLength(0);
|
||
});
|
||
|
||
it('recovers the original uncertain operation through the existing recovery control', async () => {
|
||
pending = [
|
||
{
|
||
id: 'original-unbind-record',
|
||
operation: 'unbindChannelAccount',
|
||
input: { channel_account_id: legacy.id, operation_id: 'original-op', expected_revision: 7 },
|
||
created_at: '2026-10-13T00:00:00Z',
|
||
},
|
||
];
|
||
api.resolvePending.mockImplementation(async (id) => {
|
||
expect(id).toBe('original-unbind-record');
|
||
Object.assign(accounts[0], { target_agent_slug: null, enabled: false, health: 'login_required' });
|
||
pending = [];
|
||
return { result: { unbound: true, disconnected: true }, operation: 'unbindChannelAccount' };
|
||
});
|
||
renderPage();
|
||
await card();
|
||
fireEvent.click(screen.getByRole('button', { name: '待处理记录(1)' }));
|
||
const recovery = screen.getByRole('dialog', { name: '待处理记录' });
|
||
fireEvent.click(within(recovery).getByRole('button', { name: '再试一次' }));
|
||
await within(recovery).findByText('解除微信绑定的结果已确认。');
|
||
fireEvent.click(within(recovery).getByRole('button', { name: '返回列表' }));
|
||
expect(within(await card()).getByText('未绑定')).toBeVisible();
|
||
expect(api.resolvePending).toHaveBeenCalledWith('original-unbind-record', false);
|
||
expect(writes()).toEqual([]);
|
||
});
|
||
|
||
it('retains an uncertain badge when final route synchronization is degraded', async () => {
|
||
const base = api.call.getMockImplementation()!;
|
||
api.call.mockImplementation(async (op, input) => {
|
||
const result = await base(op, input);
|
||
if (op === 'routeChannelAccount') accounts[0].sync_state = 'degraded';
|
||
return result;
|
||
});
|
||
renderPage();
|
||
const dialog = await chooseExisting();
|
||
fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
|
||
fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' }));
|
||
expect(await within(dialog).findByRole('alert')).toHaveTextContent('操作结果还没有确认');
|
||
expect(screen.queryByText('已更换智能体,微信连接和历史保留。')).not.toBeInTheDocument();
|
||
fireEvent.click(within(dialog).getByRole('button', { name: '关闭' }));
|
||
expect(within(await card()).getByText('正在确认')).toBeVisible();
|
||
});
|
||
|
||
it('ignores an older manual refresh that arrives after the final switch read', async () => {
|
||
const base = api.call.getMockImplementation()!;
|
||
const original = accounts.map((account) => ({ ...account }));
|
||
let releaseRefresh: (() => void) | undefined;
|
||
api.call.mockImplementation(async (op, input) => {
|
||
if (op === 'channelAccounts' && calls('channelAccounts').length === 2) {
|
||
await new Promise<void>((resolve) => {
|
||
releaseRefresh = resolve;
|
||
});
|
||
return { items: original };
|
||
}
|
||
return base(op, input);
|
||
});
|
||
renderPage();
|
||
await card();
|
||
fireEvent.click(screen.getByRole('button', { name: '刷新微信列表' }));
|
||
await waitFor(() => expect(releaseRefresh).toBeTypeOf('function'));
|
||
const dialog = await chooseExisting();
|
||
fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
|
||
fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' }));
|
||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||
expect(within(await card()).getByText(agentB.name)).toBeVisible();
|
||
await act(async () => releaseRefresh!());
|
||
expect(within(await card()).getByText(agentB.name)).toBeVisible();
|
||
expect(within(await card()).queryByText(agentA.name)).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('preserves owned cards and safe pause/unbind when the initial Agent listing fails', async () => {
|
||
api.list.mockRejectedValue(new Error('智能体列表暂时不可用'));
|
||
renderPage();
|
||
const current = await card();
|
||
expect(within(current).getByText(agentA.name)).toBeVisible();
|
||
expect(within(current).getByRole('button', { name: '更换智能体' })).toBeDisabled();
|
||
const menu = await more();
|
||
expect(within(menu).getByRole('button', { name: '暂停自动回复' })).toBeEnabled();
|
||
expect(within(menu).getByRole('button', { name: '解除微信绑定' })).toBeEnabled();
|
||
fireEvent.click(within(menu).getByRole('button', { name: '解除微信绑定' }));
|
||
const dialog = screen.getByRole('dialog', { name: '解除微信绑定' });
|
||
fireEvent.click(within(dialog).getByRole('button', { name: '确认解除微信绑定' }));
|
||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||
expect(within(await card()).getByText('未绑定')).toBeVisible();
|
||
expect(calls('unbindChannelAccount')).toHaveLength(1);
|
||
expect(calls('routeChannelAccount')).toHaveLength(0);
|
||
});
|
||
|
||
it('does not let an older refresh erase a new uncertain operation and unlock the card', async () => {
|
||
const base = api.call.getMockImplementation()!;
|
||
const original = accounts.map((account) => ({ ...account }));
|
||
let releaseRefresh: (() => void) | undefined;
|
||
api.recovery.mockImplementation(async () => ({ recent: null, pending: [...pending] }));
|
||
api.call.mockImplementation(async (op, input) => {
|
||
if (op === 'channelAccounts' && calls('channelAccounts').length === 2) {
|
||
await new Promise<void>((resolve) => {
|
||
releaseRefresh = resolve;
|
||
});
|
||
return { items: original };
|
||
}
|
||
if (op === 'routeChannelAccount') {
|
||
pending = [
|
||
{
|
||
id: 'new-uncertain-route',
|
||
operation: 'routeChannelAccount',
|
||
input: { ...input },
|
||
created_at: '2026-10-13T00:00:00Z',
|
||
},
|
||
];
|
||
throw new Error('还没有确认切换结果');
|
||
}
|
||
return base(op, input);
|
||
});
|
||
renderPage();
|
||
await card();
|
||
fireEvent.click(screen.getByRole('button', { name: '刷新微信列表' }));
|
||
await waitFor(() => expect(releaseRefresh).toBeTypeOf('function'));
|
||
const dialog = await chooseExisting();
|
||
fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
|
||
fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' }));
|
||
expect(await within(dialog).findByRole('alert')).toHaveTextContent('还没有确认切换结果');
|
||
await within(dialog).findByText('上次操作的结果还需要确认。请关闭窗口,处理页面上的恢复提醒。');
|
||
fireEvent.click(within(dialog).getByRole('button', { name: '关闭' }));
|
||
expect(within(await card()).getByText('正在确认')).toBeVisible();
|
||
await act(async () => releaseRefresh!());
|
||
expect(within(await card()).getByText('正在确认')).toBeVisible();
|
||
expect(within(await card()).getByRole('button', { name: '更换智能体' })).toBeDisabled();
|
||
expect(screen.getByRole('button', { name: '待处理记录(1)' })).toBeVisible();
|
||
expect(calls('routeChannelAccount')).toHaveLength(1);
|
||
});
|
||
|
||
it('keeps an uncertain operation locked when its recovery read is also unavailable', async () => {
|
||
const base = api.call.getMockImplementation()!;
|
||
api.recovery
|
||
.mockResolvedValueOnce({ recent: null, pending: [] })
|
||
.mockRejectedValue(new Error('恢复状态暂时不可用'));
|
||
api.call.mockImplementation(async (op, input) => {
|
||
if (op === 'routeChannelAccount') throw new Error('服务器响应还没有收到');
|
||
return base(op, input);
|
||
});
|
||
renderPage();
|
||
const dialog = await chooseExisting();
|
||
fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
|
||
fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' }));
|
||
expect(await within(dialog).findByRole('alert')).toHaveTextContent('服务器响应还没有收到');
|
||
await waitFor(() => expect(within(dialog).getByRole('button', { name: '确认更换' })).toBeDisabled());
|
||
fireEvent.click(within(dialog).getByRole('button', { name: '关闭' }));
|
||
expect(within(await card()).getByText('正在确认')).toBeVisible();
|
||
expect(within(await card()).getByRole('button', { name: '更换智能体' })).toBeDisabled();
|
||
expect(calls('routeChannelAccount')).toHaveLength(1);
|
||
});
|