Files
makelore/tests/unit/cloud-channel-panel.test.tsx

320 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { act, cleanup, fireEvent, render, screen, waitFor } 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: '个人微信登录二维码' });
});