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 { useAuthStore } from '@/stores/auth';
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: () =>
,
}));
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',
worker_online: true,
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 ;
}
function renderPage(entry = '/cloud-agents/channels') {
return render(
} />
智能体列表} />
);
}
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() {
const addButton = await screen.findByRole('button', { name: '添加微信', exact: true });
await waitFor(() => expect(addButton).toBeEnabled());
fireEvent.click(addButton);
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) => {
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,
desired_state: input.enabled ? 'enabled' : 'disabled',
blockers: input.enabled ? [] : ['binding_paused'],
});
return { channel: { ...account } };
}
if (operation === 'enableChannelAccount') {
const enabled = true;
Object.assign(account, {
revision: account.revision + 1,
enabled,
desired_state: 'enabled',
blockers: enabled ? [] : ['binding_paused'],
});
return { channel: { ...account } };
}
if (operation === 'unbindChannelAccount') {
Object.assign(account, {
revision: account.revision + 1,
health: 'login_required',
enabled: false,
desired_state: 'disabled',
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();
localStorage.clear();
useAuthStore.setState({ user: { username: 'creator', userId: 'creator', tenantId: null, deptId: null, authorities: [] } });
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.restoreAllMocks();
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 only unbind in the healthy account menu', 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).queryByRole('button', { name: '暂停自动回复' })).not.toBeInTheDocument();
expect(within(menu).queryByRole('button', { name: '恢复自动回复' })).not.toBeInTheDocument();
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 an old paused binding with replies enabled after choosing a healthy target', async () => {
accounts[0] = { ...legacy, enabled: false, desired_state: 'paused', blockers: ['binding_paused'] };
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(calls('enableChannelAccount')).toHaveLength(0);
const confirm = await within(dialog).findByRole('button', { name: '确认更换' });
await waitFor(() => expect(confirm).toBeEnabled());
fireEvent.click(confirm);
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(accounts[0].enabled).toBe(true);
expect(calls('routeChannelAccount')[0][1]).toMatchObject({ enabled: true, expected_revision: 7 });
expect(calls('enableChannelAccount')).toHaveLength(0);
expect(calls('pauseChannelAccount')).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('restores a healthy old paused binding once and trusts authoritative readback over a stale receipt', async () => {
accounts[0] = { ...legacy, enabled: false, desired_state: 'paused', blockers: ['binding_paused'] };
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (op, input) => {
if (op === 'enableChannelAccount') {
await base(op, input);
return { channel: { ...legacy, enabled: false } };
}
return base(op, input);
});
renderPage();
await waitFor(() => expect(calls('enableChannelAccount')).toHaveLength(1));
expect(await screen.findByText('使用中')).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: '刷新微信列表' }));
await waitFor(() => expect(api.list.mock.calls.length).toBeGreaterThanOrEqual(3));
expect(calls('enableChannelAccount')).toHaveLength(1);
expect(calls('enableChannelAccount')[0][1]).toMatchObject({ expected_revision: 7 });
expect(calls('routeChannelAccount')).toHaveLength(0);
expect(calls('pauseChannelAccount')).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(await within(dialog).findByText('扫码已确认')).toBeVisible();
expect(within(dialog).queryByText('微信登录已确认')).not.toBeInTheDocument();
expect(within(dialog).queryByText(/微信登录和智能体选择已保留/)).not.toBeInTheDocument();
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('does not restore an old paused binding while its Worker is offline on entry or refresh', async () => {
accounts[0] = { ...legacy, enabled: false, worker_online: false, blockers: ['binding_paused', 'worker_offline'] };
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 and enables replies without changing its Agent', 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(1);
expect(calls('enableChannelAccount')[0][1]).toMatchObject({ channel_account_id: legacy.id, expected_revision: 7 });
expect(calls('routeChannelAccount')).toHaveLength(0);
expect(accounts[0].target_agent_slug).toBe(agentA.slug);
});
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((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 () => {
accounts[0].blockers = ['worker_offline'];
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).queryByText('微信服务离线')).not.toBeInTheDocument();
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(calls('enableChannelAccount')).toHaveLength(1);
expect(calls('pauseChannelAccount')).toHaveLength(0);
});
it('does not paint local paused or degraded synchronization as usable', async () => {
accounts = [
{ ...legacy, enabled: false, worker_online: false, desired_state: 'paused', blockers: ['binding_paused'] },
{
...spare,
target_agent_slug: agentA.slug,
health: 'connected',
enabled: true,
sync_state: 'degraded',
blockers: ['channel_sync_degraded', 'worker_offline'],
},
];
renderPage();
expect(await screen.findByText('绑定待完成')).toBeVisible();
const degraded = await card(spare.display_name);
expect(within(degraded).getByText('状态待确认')).toBeVisible();
expect(within(degraded).queryByText('微信服务离线')).not.toBeInTheDocument();
expect(within(degraded).getByRole('button', { name: '更换智能体' })).toBeDisabled();
expect(screen.queryByText('使用中')).not.toBeInTheDocument();
expect(writes()).toEqual([]);
});
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();
await waitFor(() => 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(0);
expect(calls('routeChannelAccount')[0][1]).toMatchObject({ enabled: true });
});
it('explains an offline Worker after confirmed login and preserves the chosen Agent without enabling', 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(await within(dialog).findByText('微信登录已确认')).toBeVisible();
expect(within(dialog).queryByRole('heading', { name: '微信绑定成功' })).not.toBeInTheDocument();
expect(within(dialog).getByRole('button', { name: '检查连接并完成绑定' })).toBeEnabled();
expect(calls('enableChannelAccount')).toHaveLength(0);
expect(accounts.find((item) => item.id === 'wechat-created')).toMatchObject({
target_agent_slug: agentA.slug,
health: 'connected',
enabled: false,
});
fireEvent.click(within(dialog).getByRole('button', { name: '稍后继续' }));
const current = await card('微信 3');
expect(within(current).getByText('微信服务离线')).toBeVisible();
expect(within(current).queryByText(/智能体的微信服务暂时离线/)).not.toBeInTheDocument();
expect(within(current).getByText(agentA.name)).toBeVisible();
});
it('finishes once on explicit retry after Worker recovery without scanning again or changing the target', 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();
await within(dialog).findByRole('alert');
const created = accounts.find((item) => item.id === 'wechat-created')!;
created.blockers = ['binding_paused'];
expect(calls('enableChannelAccount')).toHaveLength(0);
fireEvent.click(within(dialog).getByRole('button', { name: '检查连接并完成绑定' }));
expect(await within(dialog).findByRole('heading', { name: '微信绑定成功' })).toBeVisible();
expect(calls('enableChannelAccount')).toHaveLength(1);
expect(calls('enableChannelAccount')[0][1]).toMatchObject({
channel_account_id: created.id,
expected_revision: 2,
});
expect(calls('channelAccountWechatBindStart')).toHaveLength(1);
expect(calls('routeChannelAccount')).toHaveLength(1);
expect(created.target_agent_slug).toBe(agentA.slug);
});
it('reports offline readiness after the final enable read without claiming binding success', async () => {
qrStatus = 'confirmed';
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (op, input) => {
const result = await base(op, input);
if (op === 'enableChannelAccount')
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(within(dialog).queryByRole('heading', { name: '微信绑定成功' })).not.toBeInTheDocument();
expect(calls('enableChannelAccount')).toHaveLength(1);
});
it.each(['login_required', 'unknown'] as const)(
'does not claim retained login for a %s account with an offline Worker',
async (health) => {
accounts[0] = { ...legacy, health, blockers: ['worker_offline'] };
renderPage();
const current = await card();
expect(within(current).queryByText('智能体的微信服务暂时离线,暂时无法自动回复。')).not.toBeInTheDocument();
expect(within(current).queryByText(/微信登录和智能体选择已保留/)).not.toBeInTheDocument();
expect(within(current).queryByText('微信服务离线')).not.toBeInTheDocument();
expect(writes()).toEqual([]);
}
);
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((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 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).queryByRole('button', { name: '暂停自动回复' })).not.toBeInTheDocument();
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((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);
});
it('blocks every new owner management write when server logout is pending without a local journal', async () => {
accounts = [
{ ...legacy, health: 'connected', sync_state: 'ready', blockers: ['wechat_disconnect_pending'] },
{
...spare,
target_agent_slug: agentB.slug,
target_agent_name: agentB.name,
health: 'login_required',
blockers: [],
},
];
renderPage();
const first = await card();
const second = await card(spare.display_name);
expect(within(first).getByText('状态待确认')).toBeVisible();
expect(within(first).queryByText('使用中')).not.toBeInTheDocument();
expect(
screen.getByText('请回到发起操作的设备确认原来的操作,再刷新这里;无需再次扫码或解除绑定。')
).toBeVisible();
expect(screen.getByRole('button', { name: '添加微信', exact: true })).toBeDisabled();
expect(within(first).getByRole('button', { name: '更换智能体' })).toBeDisabled();
expect(within(first).getByRole('button', { name: '更多操作' })).toBeDisabled();
expect(within(second).getByRole('button', { name: '重新扫码' })).toBeDisabled();
expect(within(second).getByRole('button', { name: '更多操作' })).toBeDisabled();
expect(writes()).toEqual([]);
accounts[0].blockers = [];
fireEvent.click(screen.getByRole('button', { name: '刷新确认', exact: true }));
await waitFor(() => expect(within(first).getByRole('button', { name: '更换智能体' })).toBeEnabled());
expect(writes()).toEqual([]);
});
it.each(['switch', 'unbind'] as const)(
'blocks a %s dialog opened before another account logout becomes pending',
async (mode) => {
renderPage();
let dialog: HTMLElement;
if (mode === 'switch') {
dialog = await chooseExisting();
fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
} else {
fireEvent.click(within(await more()).getByRole('button', { name: '解除微信绑定' }));
dialog = screen.getByRole('dialog', { name: '解除微信绑定' });
}
accounts[1].blockers = ['wechat_disconnect_pending'];
fireEvent.click(
within(dialog).getByRole('button', { name: mode === 'switch' ? '确认更换' : '确认解除微信绑定' })
);
expect(await within(dialog).findByRole('alert')).toHaveTextContent('微信退出还在确认中');
expect(
within(dialog).getByRole('button', { name: mode === 'switch' ? '确认更换' : '确认解除微信绑定' })
).toBeDisabled();
expect(within(dialog).getByText(/请回到发起操作的设备确认原来的操作/)).toBeVisible();
expect(writes()).toEqual([]);
}
);
it('does not create an account when an already-open add dialog first reads server pending', async () => {
renderPage();
const addButton = await screen.findByRole('button', { name: '添加微信', exact: true });
await waitFor(() => expect(addButton).toBeEnabled());
fireEvent.click(addButton);
const dialog = screen.getByRole('dialog', { name: '添加微信' });
fireEvent.click(within(dialog).getByRole('radio', { name: agentA.name }));
accounts[0].blockers = ['wechat_disconnect_pending'];
fireEvent.click(within(dialog).getByRole('button', { name: '下一步:扫码连接' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('微信退出还在确认中');
expect(calls('createChannelAccount')).toHaveLength(0);
expect(writes()).toEqual([]);
});
it('does not start a QR on another account after the preflight sees owner logout pending', async () => {
accounts[1] = {
...spare,
target_agent_slug: agentB.slug,
target_agent_name: agentB.name,
blockers: [],
};
renderPage();
const second = await card(spare.display_name);
accounts[0].blockers = ['wechat_disconnect_pending'];
fireEvent.click(within(second).getByRole('button', { name: '重新扫码' }));
const dialog = screen.getByRole('dialog', { name: '重新连接微信' });
expect(await within(dialog).findByRole('alert')).toHaveTextContent('微信退出还在确认中');
expect(calls('channelAccountWechatBindStart')).toHaveLength(0);
});
it('does not auto-enable after QR confirmation if another account logout becomes pending', async () => {
const base = api.call.getMockImplementation()!;
qrStatus = 'confirmed';
api.call.mockImplementation(async (op, input) => {
const result = await base(op, input);
if (op === 'channelAccountWechatBindStart') accounts[0].blockers = ['wechat_disconnect_pending'];
return result;
});
renderPage();
const dialog = await adding();
expect(await within(dialog).findByRole('alert')).toHaveTextContent('微信退出还在确认中');
expect(calls('enableChannelAccount')).toHaveLength(0);
expect(within(dialog).queryByRole('heading', { name: '微信绑定成功' })).not.toBeInTheDocument();
});
it('keeps original-operation recovery available while server logout is pending', async () => {
accounts[0].blockers = ['wechat_disconnect_pending'];
pending = [
{
id: 'original-logout',
operation: 'unbindChannelAccount',
input: { channel_account_id: legacy.id, operation_id: 'original-id', expected_revision: 7 },
created_at: '2026-10-13T00:00:00Z',
},
];
api.resolvePending.mockImplementation(async (id) => {
expect(id).toBe('original-logout');
pending = [];
Object.assign(accounts[0], {
enabled: false,
target_agent_slug: null,
target_agent_name: null,
health: 'login_required',
blockers: ['target_required'],
});
return { operation: 'unbindChannelAccount', result: { unbound: true, disconnected: true } };
});
renderPage();
await card();
fireEvent.click(screen.getByRole('button', { name: '待处理记录(1)' }));
const dialog = screen.getByRole('dialog', { name: '待处理记录' });
fireEvent.click(within(dialog).getByRole('button', { name: '再试一次' }));
await within(dialog).findByText('解除微信绑定的结果已确认。');
fireEvent.click(within(dialog).getByRole('button', { name: '返回列表' }));
expect(within(await card()).getByText('未绑定')).toBeVisible();
expect(api.resolvePending).toHaveBeenCalledWith('original-logout', false);
expect(writes()).toEqual([]);
});
it.each(['worker_offline', 'channel_sync_degraded'])(
'preserves safe unbind for ordinary %s',
async (blocker) => {
accounts[0].blockers = [blocker];
if (blocker === 'channel_sync_degraded') accounts[0].sync_state = 'degraded';
renderPage();
const current = await more();
expect(within(current).queryByRole('button', { name: '暂停自动回复' })).not.toBeInTheDocument();
expect(within(current).getByRole('button', { name: '解除微信绑定' })).toBeEnabled();
}
);
it('uses known disabled-target metadata without blocking replacement or safe stopping', async () => {
agents[0] = { ...agentA, enabled: false };
renderPage();
const current = await card();
expect(within(current).getByText('智能体已停用')).toBeVisible();
expect(within(current).queryByText('使用中')).not.toBeInTheDocument();
expect(within(current).getByText('当前智能体已停用,可以更换智能体。')).toBeVisible();
const menu = await more();
expect(within(menu).queryByRole('button', { name: '暂停自动回复' })).not.toBeInTheDocument();
expect(within(menu).getByRole('button', { name: '解除微信绑定' })).toBeEnabled();
const dialog = await chooseExisting();
expect(within(dialog).queryByRole('radio', { name: agentA.name })).not.toBeInTheDocument();
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('使用中')).toBeVisible();
});
it('does not restore an old paused binding with a known disabled target', async () => {
agents[0] = { ...agentA, enabled: false };
accounts[0] = { ...legacy, enabled: false, blockers: ['binding_paused'] };
renderPage();
const current = await more();
expect(within(current).getByText('智能体已停用')).toBeVisible();
expect(within(current).queryByRole('button', { name: '恢复自动回复' })).not.toBeInTheDocument();
expect(within(current).getByRole('button', { name: '解除微信绑定' })).toBeEnabled();
expect(calls('enableChannelAccount')).toHaveLength(0);
});
it('sends no mutation when the management preflight cannot read authoritative accounts', async () => {
const base = api.call.getMockImplementation()!;
renderPage();
const dialog = await chooseExisting();
fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
api.call.mockImplementation(async (op, input) => {
if (op === 'channelAccounts') throw new Error('微信状态暂时无法读取');
return base(op, input);
});
fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('微信状态暂时无法读取');
expect(writes()).toEqual([]);
});
it('sends no write when a superseded preflight observes pending after the newer account read fails', async () => {
renderPage();
await card();
const refreshButton = screen.getByRole('button', { name: '刷新微信列表' });
const dialog = await chooseExisting();
fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
const base = api.call.getMockImplementation()!;
let accountReads = 0;
let finishPreflight: ((value: { items: CloudUserChannelAccount[] }) => void) | undefined;
api.call.mockImplementation(async (op, input) => {
if (op !== 'channelAccounts') return base(op, input);
accountReads += 1;
if (accountReads === 1)
return new Promise<{ items: CloudUserChannelAccount[] }>((resolve) => {
finishPreflight = resolve;
});
throw new Error('较新的微信状态读取失败');
});
act(() => {
fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' }));
fireEvent.click(refreshButton);
});
await waitFor(() => expect(accountReads).toBe(2));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('较新的微信状态读取失败');
await act(async () =>
finishPreflight!({
items: [{ ...legacy }, { ...spare, blockers: ['wechat_disconnect_pending'] }],
})
);
expect(within(dialog).getByRole('alert')).toHaveTextContent('微信退出还在确认中');
expect(writes()).toEqual([]);
});
const restorable = (changes: Partial = {}): CloudUserChannelAccount => ({
...legacy,
enabled: false,
desired_state: 'paused',
blockers: ['binding_paused'],
...changes,
});
it('restores multiple valid old bindings serially with separate operation identities', async () => {
accounts = [
restorable(),
restorable({ id: spare.id, display_name: spare.display_name, revision: 2, target_agent_slug: agentB.slug, target_agent_name: agentB.name, target_agent_published_version: 4 }),
];
const base = api.call.getMockImplementation()!;
let releaseFirst: (() => void) | undefined;
api.call.mockImplementation(async (op, input) => {
if (op === 'enableChannelAccount' && input.channel_account_id === legacy.id)
await new Promise((resolve) => { releaseFirst = resolve; });
return base(op, input);
});
renderPage();
await waitFor(() => expect(calls('enableChannelAccount')).toHaveLength(1));
expect(accounts.every((account) => !account.enabled)).toBe(true);
expect(calls('enableChannelAccount')[0][1]).toMatchObject({ channel_account_id: legacy.id, expected_revision: 7 });
await act(async () => releaseFirst!());
await waitFor(() => expect(calls('enableChannelAccount')).toHaveLength(2));
await waitFor(() => expect(accounts.every((account) => account.enabled)).toBe(true));
expect(calls('enableChannelAccount')[1][1]).toMatchObject({ channel_account_id: spare.id, expected_revision: 2 });
expect(calls('enableChannelAccount')[0][1].operation_id).not.toBe(calls('enableChannelAccount')[1][1].operation_id);
expect(calls('routeChannelAccount')).toHaveLength(0);
expect(calls('pauseChannelAccount')).toHaveLength(0);
});
it.each([
['offline Worker', { worker_online: false, blockers: ['worker_offline', 'binding_paused'] }],
['missing Worker evidence', { worker_online: undefined }],
['login required', { health: 'login_required' }],
['unknown login', { health: 'unknown' }],
['degraded synchronization', { sync_state: 'degraded', blockers: ['channel_sync_degraded'] }],
['missing synchronization evidence', { sync_state: undefined }],
['server logout pending', { blockers: ['wechat_disconnect_pending'] }],
['unknown blocker', { blockers: ['unrecognized_fixture_blocker'] }],
['missing target', { target_agent_slug: null }],
] satisfies [string, Partial][])(
'never restores a disabled binding with %s',
async (_reason, changes) => {
accounts = [restorable(changes)];
await act(async () => renderPage());
expect(writes()).toEqual([]);
expect(accounts[0].enabled).toBe(false);
}
);
it.each(['disabled', 'unpublished', 'missing', 'archived'] as const)(
'never restores a binding whose authoritative target is %s',
async (state) => {
accounts = [restorable()];
agents = state === 'missing' ? [agentB] : [{ ...agentA, enabled: state !== 'disabled', archived: state === 'archived', published_version: state === 'unpublished' ? null : 2 }];
await act(async () => renderPage());
expect(writes()).toEqual([]);
}
);
it.each(['accounts', 'catalog', 'recovery'] as const)(
'sends no restoration write when the fresh %s read fails',
async (source) => {
accounts = [restorable()];
const base = api.call.getMockImplementation()!;
if (source === 'accounts') api.call.mockImplementation(async (op, input) => {
if (op === 'channelAccounts' && calls(op).length > 1) throw new Error('恢复前微信状态读取失败');
return base(op, input);
});
if (source === 'catalog') api.list.mockResolvedValueOnce({ agents, next_cursor: null }).mockRejectedValue(new Error('恢复前智能体列表读取失败'));
if (source === 'recovery') api.recovery.mockResolvedValueOnce({ recent: null, pending: [] }).mockRejectedValue(new Error('恢复前原操作读取失败'));
renderPage();
expect(await screen.findByRole('alert')).toHaveTextContent('读取失败');
expect(calls('enableChannelAccount')).toHaveLength(0);
expect(calls('routeChannelAccount')).toHaveLength(0);
}
);
it('keeps a global original pause journal untouched and blocks restoration of every account', async () => {
accounts = [restorable(), restorable({ id: spare.id, display_name: spare.display_name })];
const original = {
id: 'original-pause', operation: 'pauseChannelAccount',
input: { channel_account_id: spare.id, expected_revision: 2, operation_id: 'original-pause-id' },
created_at: '',
};
pending = [original];
await act(async () => renderPage());
expect(writes()).toEqual([]);
expect(pending).toEqual([original]);
expect(api.resolvePending).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: '待处理记录(1)' })).toBeVisible();
});
it.each(['journal', 'target', 'logout', 'disabled target'] as const)(
'denies restoration when fresh preflight discovers %s changes',
async (race) => {
accounts = [restorable(), { ...spare }];
const base = api.call.getMockImplementation()!;
if (race === 'journal') api.recovery.mockResolvedValueOnce({ recent: null, pending: [] }).mockResolvedValue({ recent: null, pending: [{ id: 'new-original-pause', operation: 'pauseChannelAccount', input: { channel_account_id: spare.id, operation_id: 'fresh-pause-id', expected_revision: 2 }, created_at: '' }] });
if (race === 'disabled target') api.list.mockResolvedValueOnce({ agents, next_cursor: null }).mockResolvedValue({ agents: [{ ...agentA, enabled: false }, agentB], next_cursor: null });
if (race === 'target' || race === 'logout') api.call.mockImplementation(async (op, input) => {
if (op === 'channelAccounts' && calls(op).length === 3) {
if (race === 'target') accounts[0].target_agent_slug = agentB.slug;
else accounts[1].blockers = ['wechat_disconnect_pending'];
}
return base(op, input);
});
await act(async () => renderPage());
expect(calls('enableChannelAccount')).toHaveLength(0);
expect(calls('routeChannelAccount')).toHaveLength(0);
expect(api.resolvePending).not.toHaveBeenCalled();
}
);
it('stops after an ambiguous restore and resolves only its original identity through recovery', async () => {
accounts = [restorable(), restorable({ id: spare.id, display_name: spare.display_name, revision: 2 })];
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (op, input) => {
if (op === 'enableChannelAccount' && input.channel_account_id === legacy.id) {
pending = [{ id: 'ambiguous-enable', operation: op, input: { ...input }, created_at: '' }];
throw new Error('启用结果尚未确认');
}
return base(op, input);
});
api.resolvePending.mockImplementation(async (id) => {
expect(id).toBe('ambiguous-enable');
expect(pending[0].input).toEqual(calls('enableChannelAccount')[0][1]);
Object.assign(accounts[0], { enabled: true, desired_state: 'enabled', blockers: [] });
pending = [];
return { operation: 'enableChannelAccount', result: { channel: { ...accounts[0] } } };
});
renderPage();
expect(await screen.findByRole('alert')).toHaveTextContent('启用结果尚未确认');
expect(calls('enableChannelAccount')).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: '刷新微信列表' }));
await waitFor(() => expect(screen.getByRole('button', { name: '待处理记录(1)' })).toBeVisible());
expect(calls('enableChannelAccount')).toHaveLength(1);
expect(accounts[1].enabled).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '待处理记录(1)' }));
const recovery = screen.getByRole('dialog', { name: '待处理记录' });
fireEvent.click(within(recovery).getByRole('button', { name: '再试一次' }));
await waitFor(() => expect(api.resolvePending).toHaveBeenCalledWith('ambiguous-enable', false));
expect(calls('enableChannelAccount').filter(([, input]) => input.channel_account_id === legacy.id)).toHaveLength(1);
expect(calls('pauseChannelAccount')).toHaveLength(0);
});
it('does not create a restore write after unmount during the fresh catalog read', async () => {
accounts = [restorable()];
let releaseCatalog: (() => void) | undefined;
api.list.mockResolvedValueOnce({ agents, next_cursor: null }).mockImplementation(async () => {
await new Promise((resolve) => { releaseCatalog = resolve; });
return { agents, next_cursor: null };
});
const view = renderPage();
await waitFor(() => expect(releaseCatalog).toBeTypeOf('function'));
view.unmount();
await act(async () => releaseCatalog!());
expect(calls('enableChannelAccount')).toHaveLength(0);
});
it('does not restore while a deep-linked target choice is open', async () => {
accounts = [restorable()];
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(calls('enableChannelAccount')).toHaveLength(0);
expect(calls('routeChannelAccount')).toHaveLength(0);
});
it('detects Worker recovery on the visible page and restores replies once without a QR scan', async () => {
accounts = [restorable({ worker_online: false, blockers: ['worker_offline', 'binding_paused'] })];
vi.useFakeTimers({ shouldAdvanceTime: true });
renderPage();
await screen.findByText('微信服务离线');
accounts[0].worker_online = true;
accounts[0].blockers = ['binding_paused'];
await act(async () => { await vi.advanceTimersByTimeAsync(10000); });
expect(calls('enableChannelAccount')).toHaveLength(1);
expect(await screen.findByText('使用中')).toBeVisible();
await act(async () => { await vi.advanceTimersByTimeAsync(20000); });
expect(calls('enableChannelAccount')).toHaveLength(1);
expect(calls('channelAccountWechatBindStart')).toHaveLength(0);
});
it.each(['pauseChannelBinding', 'wechatUnbind'] as const)(
'blocks legacy restoration while the original %s journal is pending',
async (operation) => {
accounts = [restorable()];
pending = [{ id: 'legacy-original', operation, input: { channel_account_id: legacy.id, expected_revision: 7, operation_id: 'legacy-original-id' }, created_at: '' }];
await act(async () => renderPage());
expect(writes()).toEqual([]);
expect(api.resolvePending).not.toHaveBeenCalled();
expect(pending[0].input.operation_id).toBe('legacy-original-id');
expect(screen.getByRole('button', { name: '待处理记录(1)' })).toBeVisible();
}
);
it('rechecks the same revision after preflight briefly finds an offline Worker and restores on later recovery', async () => {
accounts = [restorable()];
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (op, input) => {
if (op === 'channelAccounts' && calls(op).length === 3)
Object.assign(accounts[0], { worker_online: false, blockers: ['worker_offline', 'binding_paused'] });
return base(op, input);
});
vi.useFakeTimers({ shouldAdvanceTime: true });
renderPage();
await screen.findByText('微信服务离线');
expect(calls('enableChannelAccount')).toHaveLength(0);
expect(accounts[0].revision).toBe(7);
accounts[0].worker_online = true;
accounts[0].blockers = ['binding_paused'];
await act(async () => { await vi.advanceTimersByTimeAsync(10000); });
expect(calls('enableChannelAccount')).toHaveLength(1);
expect(calls('enableChannelAccount')[0][1]).toMatchObject({ expected_revision: 7 });
expect(await screen.findByText('使用中')).toBeVisible();
});
it('keeps a failed restoration stopped until explicit refresh and reuses its enable operation identity', async () => {
accounts = [restorable()];
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);
});
vi.useFakeTimers({ shouldAdvanceTime: true });
renderPage();
expect(await screen.findByRole('alert')).toHaveTextContent('启用暂时失败');
await act(async () => { await vi.advanceTimersByTimeAsync(20000); });
expect(calls('enableChannelAccount')).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: '刷新微信列表' }));
expect(await screen.findByText('使用中')).toBeVisible();
expect(calls('enableChannelAccount')).toHaveLength(2);
expect(calls('enableChannelAccount')[0][1]).toEqual(calls('enableChannelAccount')[1][1]);
});
it('keeps a preflight target change stopped through polling and restores only after explicit refresh', async () => {
accounts = [restorable()];
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (op, input) => {
if (op === 'channelAccounts' && calls(op).length === 3)
Object.assign(accounts[0], { target_agent_slug: agentB.slug, target_agent_name: agentB.name, target_agent_published_version: 4, revision: 8 });
return base(op, input);
});
vi.useFakeTimers({ shouldAdvanceTime: true });
await act(async () => renderPage());
expect(calls('enableChannelAccount')).toHaveLength(0);
await act(async () => { await vi.advanceTimersByTimeAsync(20000); });
expect(calls('enableChannelAccount')).toHaveLength(0);
fireEvent.click(screen.getByRole('button', { name: '刷新微信列表' }));
expect(await screen.findByText('使用中')).toBeVisible();
expect(calls('enableChannelAccount')).toHaveLength(1);
expect(calls('enableChannelAccount')[0][1]).toMatchObject({ expected_revision: 8 });
expect(accounts[0].target_agent_slug).toBe(agentB.slug);
expect(calls('routeChannelAccount')).toHaveLength(0);
});
const removedCardKey = (userId = 'creator', tenantId: string | null = null) =>
'makelore.wechat-removed-cards:' + JSON.stringify([tenantId, userId]);
async function deletingUnboundCard() {
const current = await more(spare.display_name);
const remove = within(current).getByRole('button', { name: '删除微信卡片' });
await waitFor(() => expect(remove).toBeEnabled());
fireEvent.click(remove);
return screen.getByRole('dialog', { name: '删除微信卡片' });
}
it('offers device-only deletion for an unbound card and cancelling changes neither storage nor server state', async () => {
renderPage();
const dialog = await deletingUnboundCard();
expect(within(dialog).getByText(/其他设备的记录和聊天历史保留/)).toBeVisible();
expect(within(dialog).getByText(/以后重新绑定时会再次显示/)).toBeVisible();
expect(localStorage.getItem(removedCardKey())).toBeNull();
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
expect(await card(spare.display_name)).toBeVisible();
expect(writes()).toEqual([]);
expect(localStorage.getItem(removedCardKey())).toBeNull();
});
it('removes only the confirmed unbound card on this device and persists through refresh and remount', async () => {
const view = renderPage();
const dialog = await deletingUnboundCard();
fireEvent.click(within(dialog).getByRole('button', { name: '确认删除卡片' }));
expect(await screen.findByText('微信卡片已从这台设备移除,聊天历史保留。')).toBeVisible();
expect(screen.queryByRole('article', { name: spare.display_name + '微信' })).not.toBeInTheDocument();
expect(await card()).toBeVisible();
expect(JSON.parse(localStorage.getItem(removedCardKey())!)).toEqual({ [spare.id]: spare.revision });
expect(accounts.find((account) => account.id === spare.id)).toEqual(spare);
fireEvent.click(screen.getByRole('button', { name: '刷新微信列表' }));
await waitFor(() => expect(calls('channelAccounts').length).toBeGreaterThanOrEqual(4));
expect(screen.queryByRole('article', { name: spare.display_name + '微信' })).not.toBeInTheDocument();
view.unmount();
renderPage();
await card();
expect(screen.queryByRole('article', { name: spare.display_name + '微信' })).not.toBeInTheDocument();
expect(writes()).toEqual([]);
expect(api.resolvePending).not.toHaveBeenCalled();
});
it.each([
['another account', 'another-creator', null],
['another tenant', 'creator', 'another-tenant'],
] as const)('isolates removed cards from %s on the same device', async (_scope, userId, tenantId) => {
localStorage.setItem(removedCardKey(), JSON.stringify({ [spare.id]: spare.revision }));
useAuthStore.setState({ user: { username: userId, userId, tenantId, deptId: null, authorities: [] } });
renderPage();
expect(await card(spare.display_name)).toBeVisible();
expect(writes()).toEqual([]);
});
it.each(['rebound', 'new revision'] as const)(
'shows a locally removed card again when authoritative state is %s',
async (change) => {
localStorage.setItem(removedCardKey(), JSON.stringify({ [spare.id]: spare.revision }));
renderPage();
await card();
expect(screen.queryByRole('article', { name: spare.display_name + '微信' })).not.toBeInTheDocument();
if (change === 'rebound') Object.assign(accounts[1], { target_agent_slug: agentB.slug, target_agent_name: agentB.name, health: 'connected', enabled: true, desired_state: 'enabled', blockers: [] });
else accounts[1].revision += 1;
fireEvent.click(screen.getByRole('button', { name: '刷新微信列表' }));
const current = await card(spare.display_name);
expect(within(current).getByText(change === 'rebound' ? agentB.name : '未绑定')).toBeVisible();
expect(writes()).toEqual([]);
}
);
it('keeps the card and shows a visible error when device storage rejects deletion', async () => {
renderPage();
const dialog = await deletingUnboundCard();
vi.spyOn(localStorage, 'setItem').mockImplementation(() => { throw new Error('fixture storage unavailable'); });
fireEvent.click(within(dialog).getByRole('button', { name: '确认删除卡片' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('卡片没有删除:暂时无法保存到这台设备,请重试。');
expect(localStorage.getItem(removedCardKey())).toBeNull();
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
expect(await card(spare.display_name)).toBeVisible();
expect(writes()).toEqual([]);
});
it.each(['rebound', 'new revision', 'logout pending', 'unknown login'] as const)(
'denies device deletion when a fresh account read discovers %s',
async (race) => {
const base = api.call.getMockImplementation()!;
renderPage();
const dialog = await deletingUnboundCard();
api.call.mockImplementation(async (op, input) => {
if (op === 'channelAccounts') {
if (race === 'rebound') Object.assign(accounts[1], { target_agent_slug: agentB.slug, health: 'connected', enabled: true, desired_state: 'enabled', blockers: [] });
if (race === 'new revision') accounts[1].revision += 1;
if (race === 'logout pending') accounts[0].blockers = ['wechat_disconnect_pending'];
if (race === 'unknown login') accounts[1].health = 'unknown';
}
return base(op, input);
});
fireEvent.click(within(dialog).getByRole('button', { name: '确认删除卡片' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent(race === 'logout pending' ? '微信退出还在确认中' : '这个微信的状态已变化');
expect(localStorage.getItem(removedCardKey())).toBeNull();
expect(writes()).toEqual([]);
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
expect(await card(spare.display_name)).toBeVisible();
}
);
it('denies device deletion if fresh recovery discovers an original legacy WeChat operation', async () => {
renderPage();
const dialog = await deletingUnboundCard();
pending = [{ id: 'delete-racing-old-operation', operation: 'wechatUnbind', input: { channel_account_id: spare.id, operation_id: 'original-delete-race', expected_revision: spare.revision }, created_at: '' }];
fireEvent.click(within(dialog).getByRole('button', { name: '确认删除卡片' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('上次微信操作还没有确认');
expect(localStorage.getItem(removedCardKey())).toBeNull();
expect(writes()).toEqual([]);
expect(api.resolvePending).not.toHaveBeenCalled();
});
it('denies device deletion if the authoritative account read becomes unavailable', async () => {
const base = api.call.getMockImplementation()!;
renderPage();
const dialog = await deletingUnboundCard();
api.call.mockImplementation(async (op, input) => {
if (op === 'channelAccounts') throw new Error('删除前微信状态无法读取');
return base(op, input);
});
fireEvent.click(within(dialog).getByRole('button', { name: '确认删除卡片' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('删除前微信状态无法读取');
expect(localStorage.getItem(removedCardKey())).toBeNull();
expect(writes()).toEqual([]);
});
it.each([
['account', 'another-creator', null],
['tenant', 'creator', 'another-tenant'],
] as const)('loads the changed %s removal preference without remounting the page', async (_scope, userId, tenantId) => {
localStorage.setItem(removedCardKey(userId, tenantId), JSON.stringify({ [spare.id]: spare.revision }));
renderPage();
expect(await card(spare.display_name)).toBeVisible();
await act(async () => useAuthStore.setState({ user: { username: userId, userId, tenantId, deptId: null, authorities: [] } }));
expect(screen.queryByRole('article', { name: spare.display_name + '微信' })).not.toBeInTheDocument();
await act(async () => useAuthStore.setState({ user: { username: 'creator', userId: 'creator', tenantId: null, deptId: null, authorities: [] } }));
expect(await card(spare.display_name)).toBeVisible();
expect(writes()).toEqual([]);
});
it.each(['connected', 'unknown'] as const)(
'keeps safe logout available before deleting an unbound card whose login is %s',
async (health) => {
accounts[1] = { ...spare, health };
renderPage();
const current = await more(spare.display_name);
expect(within(current).getByRole('button', { name: '解除微信绑定' })).toBeEnabled();
expect(within(current).getByRole('button', { name: '删除微信卡片' })).toBeDisabled();
expect(writes()).toEqual([]);
}
);
it('denies device deletion when fresh original-operation recovery cannot be read', async () => {
renderPage();
const dialog = await deletingUnboundCard();
api.recovery.mockRejectedValue(new Error('删除前原操作无法确认'));
fireEvent.click(within(dialog).getByRole('button', { name: '确认删除卡片' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('删除前原操作无法确认');
expect(localStorage.getItem(removedCardKey())).toBeNull();
expect(writes()).toEqual([]);
});
it('does not store deletion under the old owner when the account changes during its fresh read', async () => {
const base = api.call.getMockImplementation()!;
renderPage();
const dialog = await deletingUnboundCard();
api.call.mockImplementation(async (op, input) => {
if (op === 'channelAccounts' && calls(op).length === 3)
useAuthStore.setState({ user: { username: 'another-creator', userId: 'another-creator', tenantId: null, deptId: null, authorities: [] } });
return base(op, input);
});
await act(async () => fireEvent.click(within(dialog).getByRole('button', { name: '确认删除卡片' })));
expect(localStorage.getItem(removedCardKey())).toBeNull();
expect(localStorage.getItem(removedCardKey('another-creator'))).toBeNull();
expect(writes()).toEqual([]);
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
expect(await card(spare.display_name)).toBeVisible();
});