320 lines
22 KiB
TypeScript
320 lines
22 KiB
TypeScript
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||
import { CloudChannelPanel } from '@/pages/CloudAgents/CloudChannelPanel';
|
||
|
||
const api = vi.hoisted(() => ({ call: vi.fn(), recovery: vi.fn(), resolvePending: vi.fn(), list: vi.fn() }));
|
||
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
|
||
vi.mock('@/pages/CloudAgents/CloudChannelConversations', () => ({
|
||
CloudChannelConversations: () => <div>本人微信对话</div>,
|
||
}));
|
||
|
||
const slug = 'ml-' + 'a'.repeat(32);
|
||
const binding = {
|
||
id: 'wechat-1', address: 'wechat://1', display_name: '我的微信', target_agent_address: 'agent://1',
|
||
route_revision: 3, provider_generation: 'provider-1', binding_id: 'binding-1', enabled: false,
|
||
status: 'bound', worker_online: true, agent_slug: slug, published_version: 2, desired_state: 'paused',
|
||
sync_state: 'ready', health: 'connected', revision: 3, blockers: [], access_mode: 'self_only', policy_revision: 1,
|
||
};
|
||
const bindingB = {
|
||
...binding, id: 'wechat-2', address: 'wechat://2', display_name: '备用微信', target_agent_address: 'agent://2',
|
||
binding_id: 'binding-2', provider_generation: 'provider-2', agent_slug: slug,
|
||
};
|
||
|
||
beforeEach(() => {
|
||
vi.resetAllMocks();
|
||
api.recovery.mockResolvedValue({ recent: null, pending: [] });
|
||
api.call.mockImplementation(async (operation: string) => {
|
||
if (operation === 'channelBindings') return { items: [binding], available_accounts: [] };
|
||
if (operation === 'budget') return { request_limit_points: '2.00', daily_limit_points: '10.00' };
|
||
if (operation === 'channelCallers') return { items: [] };
|
||
if (operation === 'channelActivity') return { items: [], next_offset: null };
|
||
if (operation === 'channelOperation') return { operation_id: 'receipt', status: 'failed' };
|
||
if (operation === 'enableChannelBinding' || operation === 'pauseChannelBinding' || operation === 'disconnectChannelBinding') return { channel: { ...binding, enabled: true, desired_state: 'enabled' } };
|
||
if (operation === 'channelPolicy') return { binding_id: 'binding-1', channel_account_id: 'wechat-1', access_mode: 'invited', policy_revision: 2, revoked_caller_ids: [], revoked_invitation_ids: [] };
|
||
if (operation === 'createChannelPairing') return { binding_id: 'binding-1', provider_generation: 'provider-1', kind: 'self', consumed: false, code: 'ABC123' };
|
||
throw new Error(`Unexpected operation ${operation}`);
|
||
});
|
||
});
|
||
afterEach(cleanup);
|
||
|
||
it('keeps the connection action available when recovery is still loading at the first click', async () => {
|
||
let finishRecovery!: (value: { recent: null; pending: [] }) => void;
|
||
api.recovery.mockReturnValue(new Promise(resolve => { finishRecovery = resolve; }));
|
||
const base = api.call.getMockImplementation()!;
|
||
api.call.mockImplementation(async (operation: string, input) => operation === 'wechatBindStart' || operation === 'wechatBindStatus'
|
||
? { session_key: 'new-qr', status: 'pending', qrcode_url: 'https://wechat.example.test/login' } : base(operation, input));
|
||
render(<CloudChannelPanel slug={slug} publishedVersion={2} />);
|
||
fireEvent.click(await screen.findByRole('button', { name: '重新连接', exact: true }));
|
||
expect(api.call.mock.calls.some(call => call[0] === 'wechatBindStart')).toBe(false);
|
||
expect(screen.getByRole('button', { name: '重新连接', exact: true })).toBeEnabled();
|
||
await act(async () => { finishRecovery({ recent: null, pending: [] }); });
|
||
fireEvent.click(screen.getByRole('button', { name: '重新连接', exact: true }));
|
||
await screen.findByRole('img', { name: '个人微信登录二维码' });
|
||
expect(api.call.mock.calls.filter(call => call[0] === 'wechatBindStart')).toHaveLength(1);
|
||
});
|
||
|
||
it('requires an explicit enable confirmation and sends the typed binding operation once', async () => {
|
||
render(<CloudChannelPanel slug={slug} publishedVersion={2} />);
|
||
const enable = await screen.findByRole('button', { name: '启用渠道' });
|
||
expect(api.call.mock.calls.some(call => call[0] === 'enableChannelBinding')).toBe(false);
|
||
fireEvent.click(enable);
|
||
expect(screen.getByRole('dialog')).toHaveTextContent('确认启用');
|
||
await screen.findByText('每次任务上限:2.00 词元点数');
|
||
fireEvent.click(screen.getByRole('button', { name: '确认启用' }));
|
||
await waitFor(() => expect(api.call.mock.calls.some(call => call[0] === 'enableChannelBinding')).toBe(true));
|
||
const input = api.call.mock.calls.find(call => call[0] === 'enableChannelBinding')?.[1];
|
||
expect(input).toEqual(expect.objectContaining({ slug, channel_account_id: 'wechat-1', expected_revision: 3 }));
|
||
expect(typeof input.operation_id).toBe('string');
|
||
});
|
||
|
||
it('uses the bound WeChat identity without offering or requesting a self pairing code', async () => {
|
||
render(<CloudChannelPanel slug={slug} publishedVersion={2} />);
|
||
fireEvent.click(await screen.findByRole('button', { name: '使用权限' }));
|
||
await screen.findByText('使用绑定时扫码的微信,直接发送消息即可。');
|
||
expect(screen.queryByRole('button', { name: '生成本人配对码' })).toBeNull();
|
||
expect(screen.queryByRole('button', { name: '生成邀请码' })).toBeNull();
|
||
expect(api.call.mock.calls.some(call => ['createChannelPairing', 'channelPolicy'].includes(call[0]))).toBe(false);
|
||
});
|
||
|
||
it('still requires an explicit invitation for other contacts and copies the complete command', async () => {
|
||
const base = api.call.getMockImplementation()!;
|
||
api.call.mockImplementation(async (operation: string, input) => {
|
||
if (operation === 'channelBindings') return { items: [{ ...binding, access_mode: 'invited' }], available_accounts: [] };
|
||
if (operation === 'createChannelPairing') return { invitation_id: 'invite-a', binding_id: 'binding-1', provider_generation: 'provider-1', kind: 'invite', consumed: false, code: 'INVITE123' };
|
||
return base(operation, input);
|
||
});
|
||
const copy = vi.fn().mockResolvedValue(undefined);
|
||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: copy } });
|
||
render(<CloudChannelPanel slug={slug} publishedVersion={2} />);
|
||
fireEvent.click(await screen.findByRole('button', { name: '使用权限' }));
|
||
fireEvent.click(await screen.findByRole('button', { name: '生成邀请码' }));
|
||
await screen.findByText('INVITE123');
|
||
fireEvent.click(screen.getByRole('button', { name: '复制邀请命令' }));
|
||
expect(copy).toHaveBeenCalledWith('邀请 INVITE123');
|
||
expect(api.call.mock.calls.find(call => call[0] === 'createChannelPairing')?.[1]).toEqual(expect.objectContaining({ kind: 'invite', channel_account_id: 'wechat-1' }));
|
||
});
|
||
|
||
it('looks up delivery by the result message ID before offering file retry', async () => {
|
||
const activity = {
|
||
run_id: 'run-1', request_id: 'request-1', status: 'completed', created_at: '2026-09-13T00:00:00Z',
|
||
delivery: { state: 'known', result: { message_id: 'result-message-1' } },
|
||
};
|
||
api.call.mockImplementation(async (operation: string) => {
|
||
if (operation === 'channelBindings') return { items: [binding], available_accounts: [] };
|
||
if (operation === 'channelActivity') return { items: [activity], next_offset: null };
|
||
if (operation === 'channelDelivery') return { message_id: 'result-message-1', status: 'partial', parts: [{ part_id: 'part-1', type: 'file', status: 'failed' }] };
|
||
if (operation === 'retryChannelDeliveryPart') return { message_id: 'result-message-1', status: 'queued', parts: [] };
|
||
if (operation === 'channelCallers') return { items: [] };
|
||
throw new Error(`Unexpected operation ${operation}`);
|
||
});
|
||
render(<CloudChannelPanel slug={slug} publishedVersion={2} />);
|
||
fireEvent.click(await screen.findByRole('button', { name: '活动记录' }));
|
||
fireEvent.click(await screen.findByRole('button', { name: '重新补发文件' }));
|
||
await waitFor(() => expect(api.call).toHaveBeenCalledWith('retryChannelDeliveryPart', {
|
||
slug, channel_account_id: 'wechat-1', logical_message_id: 'result-message-1', part_id: 'part-1',
|
||
}));
|
||
});
|
||
|
||
it('requires an explicit recovery click and reuses the durable original channel operation', async () => {
|
||
api.recovery.mockResolvedValue({ recent: null, pending: [{
|
||
id: 'channelPolicy:pending', operation: 'channelPolicy', created_at: '2026-09-13T00:00:00Z',
|
||
input: { slug, channel_account_id: 'wechat-1', operation_id: 'original-operation', access_mode: 'invited', expected_policy_revision: 1 },
|
||
}] });
|
||
api.resolvePending.mockResolvedValue({ operation: 'channelPolicy', result: { binding_id: 'binding-1', channel_account_id: 'wechat-1', access_mode: 'invited', policy_revision: 2, revoked_caller_ids: [], revoked_invitation_ids: [] } });
|
||
render(<CloudChannelPanel slug={slug} publishedVersion={2} />);
|
||
const recover = await screen.findByRole('button', { name: '恢复上次操作' });
|
||
expect(api.resolvePending).not.toHaveBeenCalled();
|
||
fireEvent.click(recover);
|
||
await waitFor(() => expect(api.resolvePending).toHaveBeenCalledWith('channelPolicy:pending', false));
|
||
expect(api.call.mock.calls.some(call => call[0] === 'channelPolicy')).toBe(false);
|
||
});
|
||
|
||
it('can explicitly dismiss a timed-out channel operation without starting a mutation', async () => {
|
||
api.recovery.mockResolvedValue({ recent: null, pending: [{
|
||
id: 'retryChannelDeliveryPart:pending', operation: 'retryChannelDeliveryPart', created_at: '2026-09-13T00:00:00Z',
|
||
input: { slug, channel_account_id: 'wechat-1', logical_message_id: 'result-1', part_id: 'part-1' },
|
||
}] });
|
||
render(<CloudChannelPanel slug={slug} publishedVersion={2} />);
|
||
const dismiss = await screen.findByRole('button', { name: '移除提醒' });
|
||
fireEvent.click(dismiss);
|
||
await waitFor(() => expect(api.resolvePending).toHaveBeenCalledWith('retryChannelDeliveryPart:pending', true));
|
||
expect(api.call.mock.calls.some(call => call[0] === 'retryChannelDeliveryPart')).toBe(false);
|
||
});
|
||
|
||
it('opens the current verification input when recovery requires a new code', async () => {
|
||
const pending = {
|
||
id: 'wechatBindVerification:pending', operation: 'wechatBindVerification', created_at: '2026-09-13T00:00:00Z',
|
||
input: { slug, channel_account_id: 'wechat-1', session_key: 'session-1', operation_id: 'operation-1' },
|
||
};
|
||
api.recovery.mockResolvedValue({ recent: null, pending: [pending] });
|
||
api.resolvePending.mockResolvedValue({ operation: 'wechatBindVerification', requires_input: true, input: pending.input });
|
||
api.call.mockImplementation(async (operation: string) => {
|
||
if (operation === 'channelBindings') return { items: [binding], available_accounts: [] };
|
||
if (operation === 'channelCallers') return { items: [] };
|
||
if (operation === 'channelActivity') return { items: [], next_offset: null };
|
||
if (operation === 'wechatBindStatus') return { session_key: 'session-1', status: 'verification_required', message: '等待验证码' };
|
||
throw new Error(`Unexpected operation ${operation}`);
|
||
});
|
||
render(<CloudChannelPanel slug={slug} publishedVersion={2} />);
|
||
fireEvent.click(await screen.findByRole('button', { name: '恢复上次操作' }));
|
||
await waitFor(() => expect(screen.getByLabelText('微信验证码')).toBeInTheDocument());
|
||
expect(screen.getByText('上次扫码需要新的验证码,请输入后重新提交')).toBeInTheDocument();
|
||
expect(screen.queryByText('已恢复上次渠道操作,状态已经刷新')).toBeNull();
|
||
expect(api.call.mock.calls.some(call => call[0] === 'wechatBindVerification')).toBe(false);
|
||
});
|
||
|
||
it('drops an old QR session when switching bindings and ignores its late status', async () => {
|
||
let releaseOldStatus!: (value: unknown) => void;
|
||
const oldStatus = new Promise(resolve => { releaseOldStatus = resolve; });
|
||
let statusCalls = 0;
|
||
api.call.mockImplementation(async (operation: string, input: Record<string, unknown>) => {
|
||
if (operation === 'channelBindings') return { items: [{ ...binding, access_mode: 'invited' }, bindingB], available_accounts: [] };
|
||
if (operation === 'channelCallers') return { items: [] };
|
||
if (operation === 'channelActivity') return { items: [], next_offset: null };
|
||
if (operation === 'wechatBindStart') return { session_key: 'session-a', status: 'waiting', qrcode_url: 'https://provider.example/qr-a' };
|
||
if (operation === 'wechatBindStatus' && input.session_key === 'session-a') {
|
||
statusCalls += 1;
|
||
return statusCalls === 1 ? oldStatus : { session_key: 'session-a', status: 'waiting', qrcode_url: 'https://provider.example/qr-a' };
|
||
}
|
||
throw new Error(`Unexpected operation ${operation}`);
|
||
});
|
||
render(<CloudChannelPanel slug={slug} publishedVersion={2} />);
|
||
fireEvent.click(await screen.findByRole('button', { name: '重新连接' }));
|
||
await screen.findByText('等待中');
|
||
fireEvent.change(await screen.findByLabelText('当前渠道'), { target: { value: 'wechat-2' } });
|
||
await waitFor(() => expect(screen.queryByText('等待中')).toBeNull());
|
||
await new Promise(resolve => setTimeout(resolve, 0));
|
||
const callsAfterSwitch = statusCalls;
|
||
releaseOldStatus({ session_key: 'session-a', status: 'waiting', qrcode_url: 'https://provider.example/qr-a' });
|
||
await waitFor(() => expect(screen.queryByText('等待中')).toBeNull());
|
||
expect(statusCalls).toBe(callsAfterSwitch);
|
||
expect(screen.queryByAltText('个人微信登录二维码')).toBeNull();
|
||
});
|
||
|
||
it('clears the previous binding pairing, callers, and activity when switching accounts', async () => {
|
||
const callerA = { caller_id: 'caller-a', access_mode: 'invited', grant_state: 'authorized', created_at: '2026-09-13T00:00:00Z' };
|
||
const activityA = {
|
||
run_id: 'run-a', request_id: 'request-a', status: 'completed', token_usage: { total_tokens: 7 },
|
||
delivery: { state: 'known' }, timing: { created_at: '2026-09-13T00:00:00Z' },
|
||
};
|
||
api.call.mockImplementation(async (operation: string, input: Record<string, unknown>) => {
|
||
if (operation === 'channelBindings') return { items: [{ ...binding, access_mode: 'invited' }, bindingB], available_accounts: [] };
|
||
if (operation === 'channelCallers') return { items: input.channel_account_id === 'wechat-1' ? [callerA] : [] };
|
||
if (operation === 'channelActivity') return { items: input.channel_account_id === 'wechat-1' ? [activityA] : [], next_offset: null };
|
||
if (operation === 'createChannelPairing') return { binding_id: 'binding-1', provider_generation: 'provider-1', kind: 'invite', consumed: false, code: 'PAIR-A' };
|
||
throw new Error(`Unexpected operation ${operation}`);
|
||
});
|
||
render(<CloudChannelPanel slug={slug} publishedVersion={2} />);
|
||
fireEvent.click(await screen.findByRole('button', { name: '使用权限' }));
|
||
await screen.findByText('受邀联系人', { exact: true });
|
||
fireEvent.click(screen.getByRole('button', { name: '生成邀请码' }));
|
||
await screen.findByText('PAIR-A');
|
||
fireEvent.click(screen.getByRole('button', { name: '活动记录' }));
|
||
await screen.findByText('词元 7');
|
||
fireEvent.click(screen.getByRole('button', { name: '连接微信' }));
|
||
fireEvent.change(await screen.findByLabelText('当前渠道'), { target: { value: 'wechat-2' } });
|
||
await waitFor(() => expect(screen.queryByText('PAIR-A')).toBeNull());
|
||
fireEvent.click(screen.getByRole('button', { name: '使用权限' }));
|
||
await screen.findByText('首次收到你的微信消息后,会在这里显示本人会话。');
|
||
expect(screen.queryByText('受邀联系人', { exact: true })).toBeNull();
|
||
fireEvent.click(screen.getByRole('button', { name: '活动记录' }));
|
||
await screen.findByText('还没有渠道活动。');
|
||
expect(screen.queryByText('词元 7')).toBeNull();
|
||
});
|
||
|
||
it('loads a later published target when the route agent list has a next cursor', async () => {
|
||
api.list.mockResolvedValueOnce({ agents: [{ slug: 'ml-' + 'b'.repeat(32), name: '未发布', published_version: null }], next_cursor: 'cursor-2' })
|
||
.mockResolvedValueOnce({ agents: [{ slug: 'ml-' + 'c'.repeat(32), name: '第二页目标', published_version: 1 }], next_cursor: null });
|
||
render(<CloudChannelPanel slug={slug} publishedVersion={2} />);
|
||
fireEvent.click(await screen.findByRole('button', { name: '选择目标智能体' }));
|
||
await waitFor(() => expect(screen.getByRole('option', { name: /第二页目标/ })).toBeInTheDocument());
|
||
expect(api.list.mock.calls.some(call => call[0] === 'cursor-2')).toBe(true);
|
||
});
|
||
|
||
it('removes file retry after queued delivery and keeps it hidden after a sent refresh', async () => {
|
||
const activity = {
|
||
run_id: 'run-2', request_id: 'request-2', status: 'completed', created_at: '2026-09-13T00:00:00Z',
|
||
delivery: { state: 'known', result: { message_id: 'result-message-2' } },
|
||
};
|
||
let deliveryState: 'failed' | 'queued' | 'sent' = 'failed';
|
||
let deliveryCalls = 0;
|
||
api.call.mockImplementation(async (operation: string) => {
|
||
if (operation === 'channelBindings') return { items: [binding], available_accounts: [] };
|
||
if (operation === 'channelCallers') return { items: [] };
|
||
if (operation === 'channelActivity') return { items: [activity], next_offset: null };
|
||
if (operation === 'channelDelivery') {
|
||
deliveryCalls += 1;
|
||
const status = deliveryCalls === 1 ? 'failed' : deliveryState;
|
||
return { message_id: 'result-message-2', status, parts: [{ part_id: 'part-2', type: 'file', status }] };
|
||
}
|
||
if (operation === 'retryChannelDeliveryPart') {
|
||
deliveryState = 'queued';
|
||
return { message_id: 'result-message-2', status: 'queued', parts: [] };
|
||
}
|
||
throw new Error(`Unexpected operation ${operation}`);
|
||
});
|
||
render(<CloudChannelPanel slug={slug} publishedVersion={2} />);
|
||
fireEvent.click(await screen.findByRole('button', { name: '活动记录' }));
|
||
const retry = await screen.findByRole('button', { name: '重新补发文件' });
|
||
expect(screen.getByText('微信投递:发送失败')).toBeVisible();
|
||
fireEvent.click(retry);
|
||
await waitFor(() => expect(api.call).toHaveBeenCalledWith('retryChannelDeliveryPart', {
|
||
slug, channel_account_id: 'wechat-1', logical_message_id: 'result-message-2', part_id: 'part-2',
|
||
}));
|
||
await waitFor(() => expect(screen.queryByRole('button', { name: '重新补发文件' })).toBeNull());
|
||
deliveryState = 'sent';
|
||
fireEvent.click(screen.getByRole('button', { name: '刷新', exact: true }));
|
||
await waitFor(() => expect(deliveryCalls).toBeGreaterThanOrEqual(3));
|
||
await screen.findByText('微信投递:已送达');
|
||
expect(screen.queryByRole('button', { name: '重新补发文件' })).toBeNull();
|
||
});
|
||
|
||
it('does not label a known execution receipt as a successful WeChat delivery', async () => {
|
||
const base = api.call.getMockImplementation()!;
|
||
api.call.mockImplementation(async (operation: string, input) => operation === 'channelActivity'
|
||
? { items: [{ run_id: 'run-unknown', status: 'completed', delivery: { state: 'known', core: { state: 'unknown' } } }], next_offset: null }
|
||
: base(operation, input));
|
||
render(<CloudChannelPanel slug={slug} publishedVersion={2} />);
|
||
fireEvent.click(await screen.findByRole('button', { name: '活动记录' }));
|
||
await screen.findByText('微信投递:状态未知');
|
||
expect(screen.queryByText('微信投递:已送达')).toBeNull();
|
||
});
|
||
|
||
it('retains verification recovery while the corresponding channel binding is still loading', async () => {
|
||
const pending = { id: 'waiting-bindings', operation: 'wechatBindVerification', created_at: '2026-09-13T00:00:00Z',
|
||
input: { slug, channel_account_id: 'wechat-1', session_key: 'original-session', operation_id: 'original-verify' } };
|
||
let bindingsReady!: (value: { items: typeof binding[]; available_accounts: [] }) => void;
|
||
const delayedBindings = new Promise(resolve => { bindingsReady = resolve; });
|
||
const base = api.call.getMockImplementation()!;
|
||
api.call.mockImplementation(async (operation: string, input) => operation === 'channelBindings' ? delayedBindings
|
||
: operation === 'wechatBindStatus' ? { session_key: 'original-session', status: 'verification_required' } : base(operation, input));
|
||
api.recovery.mockResolvedValue({ recent: null, pending: [pending] });
|
||
api.resolvePending.mockImplementation(async (_id, discard) => {
|
||
if (discard) { api.recovery.mockResolvedValue({ recent: null, pending: [] }); return { discarded: true }; }
|
||
return { requires_input: true, operation: pending.operation, input: pending.input };
|
||
});
|
||
render(<CloudChannelPanel slug={slug} publishedVersion={2} />);
|
||
fireEvent.click(await screen.findByRole('button', { name: '恢复上次操作' }));
|
||
await screen.findByText(/尚未读取到该操作的微信连接/);
|
||
expect(api.resolvePending).not.toHaveBeenCalled();
|
||
await act(async () => { bindingsReady({ items: [binding], available_accounts: [] }); });
|
||
fireEvent.click(screen.getByRole('button', { name: '恢复上次操作' }));
|
||
await screen.findByLabelText('微信验证码');
|
||
expect(api.resolvePending).toHaveBeenCalledWith('waiting-bindings', false);
|
||
expect(api.resolvePending).toHaveBeenCalledWith('waiting-bindings', true);
|
||
});
|
||
|
||
it('refreshes the recovery gate after a temporary local journal read failure', async () => {
|
||
api.recovery.mockRejectedValueOnce(new Error('本机记录暂时无法读取')).mockResolvedValue({ recent: null, pending: [] });
|
||
const base = api.call.getMockImplementation()!;
|
||
api.call.mockImplementation(async (operation: string, input) => operation === 'wechatBindStart' || operation === 'wechatBindStatus'
|
||
? { session_key: 'retry-read', status: 'pending', qrcode_url: 'https://wechat.example.test/login' } : base(operation, input));
|
||
render(<CloudChannelPanel slug={slug} publishedVersion={2} />);
|
||
await screen.findByText('本机记录暂时无法读取');
|
||
fireEvent.click(screen.getByRole('button', { name: '重试', exact: true }));
|
||
await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(2));
|
||
await waitFor(() => expect(screen.queryByText('本机记录暂时无法读取')).toBeNull());
|
||
fireEvent.click(await screen.findByRole('button', { name: '重新连接', exact: true }));
|
||
await screen.findByRole('img', { name: '个人微信登录二维码' });
|
||
});
|