Files
makelore/tests/unit/channel-accounts-page.test.tsx
T

800 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});