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: () => 个人微信登录二维码, })); 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 {location.pathname + location.search}; } 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() { 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) => { 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((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((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((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); });