feat(agents): 支持个人微信发布与渠道会话
This commit is contained in:
108
tests/e2e/cloud-agent-channels.spec.ts
Normal file
108
tests/e2e/cloud-agent-channels.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
|
||||
|
||||
test('own WeChat conversation approval and generated PPT delivery in the desktop workspace', async ({ launchElectronApp }, testInfo) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
try {
|
||||
const page = await getStableWindow(app);
|
||||
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
|
||||
const applicationUrl = page.url();
|
||||
await page.goto('about:blank');
|
||||
await app.evaluate(({ ipcMain, BrowserWindow }) => {
|
||||
BrowserWindow.getAllWindows()[0].setSize(1366, 900);
|
||||
const calls: { operation: string; input: unknown }[] = [];
|
||||
Object.assign(globalThis, { cloudChannelFixtureCalls: calls });
|
||||
let approved = false;
|
||||
let verified = false;
|
||||
const slug = 'ml-' + 'a'.repeat(32);
|
||||
const configuration = { model: 'test-model', tools: [], knowledges: [], mcps: [], skills: [], preload_skills: [], subagents: [], tool_approval_mode: 'default', max_execution_steps: 40, max_output_tokens: 4096, max_run_seconds: 600 };
|
||||
const draft = { slug, name: '我的创作助手', purpose: '整理选题与制作演示文稿', system_prompt: '使用简洁自然的中文', draft_revision: 2, updated_at: '2026-09-13T00:00:00Z', configuration, published_version: 1, enabled: true, archived: false };
|
||||
const session = { session_id: 'self-session', caller_id: 'self-caller', agent_slug: slug, agent_name: draft.name, binding_id: 'binding-a', provider_generation: 'generation-a', channel_account_id: 'wechat-a', access_mode: 'self_only', grant_state: 'authorized', session_state: 'active', sequence: 1, thread_id: 'channel-thread', created_at: '2026-09-13T01:00:00Z' };
|
||||
const channel = { id: 'wechat-a', address: 'wechat:personal', display_name: '创作微信', target_agent_address: 'yuxi:makelore:' + slug, route_revision: 3, provider_generation: 'generation-a', binding_id: 'binding-a', enabled: true, status: 'active', worker_online: true, agent_slug: slug, published_version: 1, desired_state: 'enabled', sync_state: 'ready', health: 'connected', last_confirmed_at: '2026-09-13T01:00:00Z', revision: 3, blockers: [], access_mode: 'self_only', policy_revision: 0 };
|
||||
const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } });
|
||||
ipcMain.removeHandler('hostapi:fetch');
|
||||
ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string; body?: string }) => {
|
||||
const path = request.path ?? '';
|
||||
if (path === '/api/auth/session/sync') return result({ success: true, session: { accessToken: 'ui-fixture', tokenType: 'Bearer', expiresAt: Date.now() + 3600000, lastActiveAt: Date.now(), canRefresh: false } });
|
||||
if (path === '/api/auth/me') return result({ success: true, user: { username: 'creator', userId: 'creator', tenantId: null, deptId: null, authorities: [] }, moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } });
|
||||
if (path === '/api/works/user/agent-profile') return result({ success: true, profile: { display_name: '创作者', age: null, gender: null, avatar_url: null, share_age_with_agents: false, share_gender_with_agents: false, analysis_enabled: true, completed: true, version: 1, updated_at: '2026-09-13T00:00:00Z' } });
|
||||
if (!path.startsWith('/api/cloud-agents/')) return result({ success: true });
|
||||
const body = typeof request.body === 'string' ? JSON.parse(request.body) : request.body ?? {};
|
||||
if (path.endsWith('/recovery')) return result({ recent: null, pending: [] });
|
||||
if (path.endsWith('/recovery/recent')) return result({ saved: true });
|
||||
if (path.endsWith('/files/save-channel')) { calls.push({ operation: 'downloadChannelArtifact', input: body }); return result({ saved: true }); }
|
||||
if (!path.endsWith('/actions')) return result(path.endsWith('/agents') || path.endsWith('/bootstrap') ? { agents: [draft], next_cursor: null } : draft);
|
||||
const { operation, input = {} } = body;
|
||||
calls.push({ operation, input });
|
||||
if (operation === 'threads') return result({ threads: [], next_offset: null });
|
||||
if (operation === 'history') return result({ thread_id: 'desktop-thread', messages: [], run: null, next_offset: null });
|
||||
if (operation === 'attachments' || operation === 'files') return result({ attachments: [], files: [] });
|
||||
if (operation === 'catalog') return result({ models: [{ id: 'test-model', name: '创作模型' }], resources: {}, pricing: null });
|
||||
if (operation === 'access') return result({ published_version: 1, enabled: true, versions: [], grants: [], applications: [], share_url: 'niancode://agents/' + slug });
|
||||
if (operation === 'budget') return result({ request_limit_points: '2.50', daily_limit_points: '10.00', daily_committed_points: '0.20' });
|
||||
if (operation === 'costs') return result({ items: [], next_offset: null, summary: { count: 0, settled_points: '0', pending_points: '0' } });
|
||||
if (operation === 'channelBindings') return result({ items: [channel], available_accounts: [] });
|
||||
if (operation === 'channelConversations') return result({ items: [session], next_offset: null });
|
||||
if (operation === 'channelCallers' || operation === 'channelSelfCallers') return result({ items: [{ ...session, paired_ws_account_id: 'creator', revoked_at: null }] });
|
||||
if (operation === 'channelActivity') return result({ items: [], next_offset: null });
|
||||
if (operation === 'wechatBindStart' || operation === 'wechatBindStatus') return result({ session_key: 'qr-session', status: verified ? 'confirmed' : 'verification_required', qrcode_url: 'https://login.example.test/confirm?token=fixture', message: '请确认微信验证码' });
|
||||
if (operation === 'wechatBindVerification') { verified = true; return result({ session_key: 'qr-session', status: 'confirmed', message: '连接已确认' }); }
|
||||
if (operation === 'createChannelPairing') return result({ invitation_id: 'pairing-a', binding_id: 'binding-a', provider_generation: 'generation-a', agent_slug: slug, kind: input.kind, expires_at: '2026-09-13T23:00:00Z', consumed: false, code: 'TEST1234' });
|
||||
if (operation === 'channelConversation') return result({ ...session, messages: [{ id: 1, role: 'user', content: '把今天的创作建议整理成演示文稿。' }, ...(approved ? [{ id: 2, role: 'assistant', content: '## 演示文稿已完成\n共 7 页,文件已保存在本次会话中。' }] : [])], queued_requests: [], next_offset: null,
|
||||
run: { agent_run_id: 'channel-run', request_id: 'channel-request', thread_id: 'channel-thread', agent_slug: slug, status: approved ? 'completed' : 'interrupted', output: '', version: '1',
|
||||
...(approved ? {} : { interrupt: { approval: { action_requests: [{ name: 'execute', args: { command: 'python create_presentation.py' } }] } } }) } });
|
||||
if (operation === 'channelConversationControl') { approved = true; return result({ session_id: session.session_id, operation_id: input.operation_id, action: input.action, status: 'accepted', run_id: 'channel-run' }); }
|
||||
if (operation === 'channelFiles') return result({ session_id: session.session_id, thread_id: session.thread_id, files: !approved ? [] : input.path === '/'
|
||||
? [{ name: 'outputs', path: '/outputs', directory_path: '/outputs', is_dir: true, size: 0 }]
|
||||
: [{ name: '创作建议.pptx', path: '/outputs/创作建议.pptx', directory_path: '/outputs', is_dir: false, size: 40960 }] });
|
||||
if (operation === 'schedules') return result({ jobs: [] });
|
||||
if (operation === 'scheduleContext') return result({ version: 1, enabled: true, configuration, result_destination: '任务历史', payer: 'creator' });
|
||||
throw new Error('Unexpected channel UI fixture operation ' + operation);
|
||||
});
|
||||
});
|
||||
await page.goto(applicationUrl);
|
||||
await page.getByTestId('ai-module-option-cloud_agents').click();
|
||||
await page.getByRole('button', { name: /我的创作助手/ }).click();
|
||||
await page.getByRole('button', { name: '本人微信对话', exact: true }).click();
|
||||
const own = page.getByRole('region', { name: '本人微信会话', exact: true });
|
||||
await own.getByRole('button', { name: '允许执行', exact: true }).click();
|
||||
await expect(own.getByRole('heading', { name: '演示文稿已完成' })).toBeVisible();
|
||||
await own.getByRole('button', { name: '保存 创作建议.pptx', exact: true }).click();
|
||||
await expect(own.getByText('已保存 创作建议.pptx', { exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath('wechat-own-deliverables.png') });
|
||||
const calls = await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string; input: Record<string, unknown> }[] }).cloudChannelFixtureCalls);
|
||||
expect(calls.find(call => call.operation === 'channelConversationControl')?.input).toMatchObject({ session_id: 'self-session', run_id: 'channel-run', action: 'resume', decision: { decisions: [{ type: 'approve' }] } });
|
||||
expect(calls.find(call => call.operation === 'downloadChannelArtifact')?.input).toEqual({ session_id: 'self-session', path: '/outputs/创作建议.pptx' });
|
||||
expect(calls.some(call => call.operation === 'resume' || call.operation === 'submit')).toBe(false);
|
||||
await page.getByRole('button', { name: '发布与访问', exact: true }).click();
|
||||
await expect(page.getByText('创作微信', { exact: true }).first()).toBeVisible();
|
||||
await page.getByRole('region', { name: '个人微信渠道', exact: true }).scrollIntoViewIfNeeded();
|
||||
await page.getByRole('button', { name: '重新连接', exact: true }).click();
|
||||
await expect(page.getByRole('img', { name: '个人微信登录二维码', exact: true })).toHaveAttribute('src', /^data:image\//);
|
||||
await page.getByLabel('微信验证码', { exact: true }).scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: testInfo.outputPath('wechat-qr-verification.png') });
|
||||
await page.getByLabel('微信验证码', { exact: true }).fill('123456');
|
||||
await page.getByRole('button', { name: '提交验证码', exact: true }).click();
|
||||
await expect.poll(async () => app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string; input: unknown }[] }).cloudChannelFixtureCalls.some(call => call.operation === 'wechatBindVerification'))).toBe(true);
|
||||
if (await page.getByRole('button', { name: '关闭二维码', exact: true }).count()) await page.getByRole('button', { name: '关闭二维码', exact: true }).click();
|
||||
await page.getByRole('button', { name: '使用权限', exact: true }).click();
|
||||
await page.getByRole('button', { name: '生成本人配对码', exact: true }).click();
|
||||
await expect(page.getByText('TEST1234', { exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: '隐藏', exact: true }).click();
|
||||
await page.getByRole('button', { name: '连接微信', exact: true }).click();
|
||||
await page.screenshot({ path: testInfo.outputPath('wechat-publication.png') });
|
||||
await app.evaluate(({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0]; win.setMinimumSize(800, 600); win.setSize(980, 680); });
|
||||
await expect.poll(() => page.getByTestId('main-content').evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
|
||||
await page.getByRole('heading', { name: '发布到个人微信', exact: true }).scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: testInfo.outputPath('wechat-publication-compact.png') });
|
||||
await page.getByRole('button', { name: '自动任务', exact: true }).click();
|
||||
await page.getByRole('button', { name: '添加任务', exact: true }).click();
|
||||
await expect(page.getByLabel('结果发送到')).toHaveValue('');
|
||||
await expect(page.getByRole('option', { name: '创作微信 · 本人对话 1' })).toBeAttached();
|
||||
await page.getByLabel('结果发送到').selectOption('self-caller');
|
||||
await page.getByLabel('结果发送到').scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: testInfo.outputPath('wechat-schedule-target.png') });
|
||||
} finally {
|
||||
await app.evaluate(({ BrowserWindow }) => { for (const window of BrowserWindow.getAllWindows()) window.destroy(); }).catch(() => undefined);
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
@@ -45,6 +45,8 @@ test('personal cloud Agent creation, draft save and leave protection in Electron
|
||||
return result({ agent_slug: draft?.slug, ...limits, daily_committed_points: '0.00', timezone: 'Asia/Shanghai', unit: '词元点数', resets_at: '2026-09-11T00:00:00+08:00' });
|
||||
}
|
||||
if (body.operation === 'scheduleContext') return result({ version: 1, enabled: true, configuration, result_destination: '任务历史对话与活动', payer: 'creator' });
|
||||
if (body.operation === 'channelSelfCallers') return result({ items: [] });
|
||||
if (body.operation === 'channelBindings') return result({ items: [], available_accounts: [] });
|
||||
if (body.operation === 'threads') return result({ threads: [], next_offset: null });
|
||||
if (body.operation === 'publish') { if (draft) draft.published_version = 1; return result({ version: 1, draft_revision: 2 }); }
|
||||
if (body.operation === 'access') return result({ published_version: draft?.published_version, enabled: true, share_url: 'niancode://agents/' + draft?.slug, versions: [], grants: [], applications: [] });
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { CloudAgentsModule } from '@electron/services/cloud-agents';
|
||||
import { CloudAgentJournal } from '@electron/services/cloud-agent-journal';
|
||||
import { operationPlan } from '@electron/services/cloud-agent-operations';
|
||||
import type { CloudRecoveryState } from '../../shared/cloud-agents';
|
||||
import { EMPTY_CLOUD_CONFIGURATION } from '../../shared/cloud-agents';
|
||||
import { clearWorksSquareSession, storeWorksSquareSession } from '@electron/services/works-square-session';
|
||||
@@ -171,6 +172,89 @@ describe('Main cloud Agents boundary', () => {
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the fixed self-only channel query and slug query aliases', () => {
|
||||
const selfCallers = operationPlan({ operation: 'channelSelfCallers', input: { slug: draft.slug } });
|
||||
expect(selfCallers.path).toBe(`/api/makelore/agents/${draft.slug}/channel-callers?access_mode=self_only`);
|
||||
const conversations = operationPlan({ operation: 'channelConversations', input: { slug: draft.slug, offset: 12 } });
|
||||
expect(conversations.path).toBe(`/api/makelore/channel-conversations?agent_slug=${draft.slug}&offset=12`);
|
||||
const conversation = operationPlan({ operation: 'channelConversation', input: { session_id: 'session-1', offset: 100 } });
|
||||
expect(conversation.path).toBe('/api/makelore/channel-conversations/session-1?offset=100');
|
||||
const receipt = operationPlan({ operation: 'channelOperation', input: { slug: draft.slug, operation_id: 'op-123' } });
|
||||
expect(receipt.path).toBe(`/api/makelore/channel-operations/op-123?agent_slug=${draft.slug}`);
|
||||
expect(receipt.project({ operation_id: 'op-123', status: 'completed', result: { id: 'channel-1', account_key: 'private' } })).toEqual({
|
||||
operation_id: 'op-123', status: 'completed', result: { id: 'channel-1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('projects channel output without provider credentials or arbitrary QR URLs', () => {
|
||||
const plan = operationPlan({ operation: 'channelBindings', input: { slug: draft.slug } });
|
||||
expect(plan.project({ items: [{ id: 'channel-1', display_name: '本人微信', account_key: 'private', health: 'connected', blockers: ['publish_required', { secret: true }] }],
|
||||
available_accounts: [{ id: 'channel-2', adoption_state: 'available', managed_agent_slug: null, access_token: 'private' }] })).toEqual({
|
||||
items: [{ id: 'channel-1', display_name: '本人微信', health: 'connected', blockers: ['publish_required'] }],
|
||||
available_accounts: [{ id: 'channel-2', adoption_state: 'available', managed_agent_slug: null }],
|
||||
});
|
||||
const qr = operationPlan({ operation: 'wechatBindStart', input: { slug: draft.slug, channel_account_id: 'channel-1', operation_id: input.operation_id, force: false } });
|
||||
expect(qr.project({ session_key: 'session-1', status: 'pending', qrcode_url: 'http://provider.invalid/qr', access_token: 'private' })).toEqual({ session_key: 'session-1', status: 'pending' });
|
||||
const activity = operationPlan({ operation: 'channelActivity', input: { slug: draft.slug, channel_account_id: 'channel-1', offset: 0, limit: 50 } });
|
||||
expect(activity.project({ items: [{ status: 'completed', delivery: { logical_message_id: 'inbound-id', result: { message_id: 'result-id', provider_url: 'private' }, core: { parts: [{ part_id: 'file-1', type: 'file', adapter_status: 'failed', url: 'private' }] } }, private_prompt: 'hidden' }], next_offset: null })).toEqual({
|
||||
items: [{ status: 'completed', delivery: { logical_message_id: 'result-id', result: { message_id: 'result-id' }, core: { parts: [{ part_id: 'file-1', type: 'file', adapter_status: 'failed' }] }, parts: [{ part_id: 'file-1', kind: 'file', status: 'failed' }] } }], next_offset: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('recovers a completed WeChat verification from its receipt without replaying the code', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response'))
|
||||
.mockResolvedValueOnce(json({ operation_id: input.operation_id, status: 'completed', result: { channel: { id: 'channel-1' } } }));
|
||||
const module = moduleFor(fetchImpl);
|
||||
const verification = { slug: draft.slug, channel_account_id: 'channel-1', operation_id: input.operation_id, session_key: 'session-1', verify_code: 'secret-code-123' };
|
||||
await expect(module.execute({ operation: 'wechatBindVerification', input: verification })).rejects.toMatchObject({ code: 'cloud_service_unavailable' });
|
||||
const raw = JSON.stringify(Array.from(journalRecords.values()));
|
||||
expect(raw).not.toContain('secret-code-123');
|
||||
const pending = (await module.recovery()).pending[0];
|
||||
expect(pending.input).not.toHaveProperty('verify_code');
|
||||
expect(await module.resolvePending({ id: pending.id })).toMatchObject({ operation: 'wechatBindVerification', result: { status: 'completed' } });
|
||||
expect((await module.recovery()).pending).toEqual([]);
|
||||
expect(fetchImpl.mock.calls[2][0]).toContain(`/api/makelore/channel-operations/${input.operation_id}?agent_slug=${draft.slug}`);
|
||||
});
|
||||
|
||||
it('asks for a new WeChat verification code only when its receipt is absent', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response'))
|
||||
.mockResolvedValueOnce(json({ detail: { code: 'operation_not_found' } }, 404));
|
||||
const module = moduleFor(fetchImpl);
|
||||
const verification = { slug: draft.slug, channel_account_id: 'channel-1', operation_id: input.operation_id, session_key: 'session-1', verify_code: 'secret-code-123' };
|
||||
await expect(module.execute({ operation: 'wechatBindVerification', input: verification })).rejects.toMatchObject({ code: 'cloud_service_unavailable' });
|
||||
const pending = (await module.recovery()).pending[0];
|
||||
expect(await module.resolvePending({ id: pending.id })).toMatchObject({ requires_input: true, operation: 'wechatBindVerification' });
|
||||
});
|
||||
|
||||
it('keeps delivery-part recovery records distinct for different messages and parts', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockRejectedValue(new Error('lost response'));
|
||||
const module = moduleFor(fetchImpl);
|
||||
const first = { slug: draft.slug, channel_account_id: 'channel-a', logical_message_id: 'message-a', part_id: 'part-a' };
|
||||
const second = { slug: draft.slug, channel_account_id: 'channel-b', logical_message_id: 'message-b', part_id: 'part-b' };
|
||||
await expect(module.execute({ operation: 'retryChannelDeliveryPart', input: first })).rejects.toMatchObject({ code: 'cloud_service_unavailable' });
|
||||
await expect(module.execute({ operation: 'retryChannelDeliveryPart', input: second })).rejects.toMatchObject({ code: 'cloud_service_unavailable' });
|
||||
const pending = (await module.recovery()).pending;
|
||||
expect(pending).toHaveLength(2);
|
||||
expect(pending.map(item => item.input)).toEqual(expect.arrayContaining([first, second]));
|
||||
expect(new Set(pending.map(item => item.id)).size).toBe(2);
|
||||
expect(pending.map(item => item.id).join('\n')).toContain('channel-a:message-a:part-a');
|
||||
expect(pending.map(item => item.id).join('\n')).toContain('channel-b:message-b:part-b');
|
||||
});
|
||||
|
||||
it('downloads personal channel artifacts through the dedicated session route', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'makelore-channel-files-'));
|
||||
try {
|
||||
const destination = join(directory, 'channel-result.txt');
|
||||
desktop.save.mockResolvedValue({ canceled: false, filePath: destination });
|
||||
const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(new Response('channel result'));
|
||||
const module = moduleFor(transport);
|
||||
await expect(module.downloadChannelArtifact({ session_id: 'session-1', path: '/outputs/channel-result.txt' })).resolves.toEqual({ saved: true });
|
||||
expect(await readFile(destination, 'utf8')).toBe('channel result');
|
||||
expect(transport.mock.calls[1][0]).toContain('/api/makelore/channel-conversations/session-1/artifacts/outputs/channel-result.txt?download=true');
|
||||
expect(transport.mock.calls[1][0]).not.toContain('/threads/');
|
||||
} finally { await rm(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it('passes user validation failures as 422 without returning upstream text', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ detail: [{ msg: 'raw credential' }] }, 422));
|
||||
await expect(moduleFor(fetchImpl).execute({ operation: 'createSchedule', input: { slug: draft.slug } }))
|
||||
|
||||
@@ -80,6 +80,17 @@ it('keeps creation input and its uncertain operation when an existing card is cl
|
||||
expect(api.create.mock.calls[1][0]).toEqual(api.create.mock.calls[0][0]);
|
||||
});
|
||||
|
||||
it('guides an unconfirmed WeChat verification back to its code entry without claiming completion', async () => {
|
||||
api.recovery.mockResolvedValue({ recent: null, pending: [{ id: 'wechat-code', operation: 'wechatBindVerification',
|
||||
input: { slug: draft.slug, session_key: 'qr-session', operation_id: 'verify-operation' }, created_at: '2026-09-13T01:00:00Z' }] });
|
||||
api.resolvePending.mockResolvedValue({ requires_input: true, operation: 'wechatBindVerification' });
|
||||
open();
|
||||
fireEvent.click(await screen.findByRole('button', { name: '重试并确认结果' }));
|
||||
await screen.findByText(/这次微信验证还需要验证码/);
|
||||
expect(screen.queryByText('微信连接验证的结果已确认。')).not.toBeInTheDocument();
|
||||
expect(api.resolvePending).toHaveBeenCalledWith('wechat-code', false);
|
||||
});
|
||||
|
||||
it('keeps the same creation operation after a lost response', async () => {
|
||||
api.list.mockResolvedValue({ agents: [], next_cursor: null });
|
||||
api.create.mockRejectedValueOnce(new Error('连接中断')).mockResolvedValueOnce(draft);
|
||||
|
||||
@@ -23,6 +23,8 @@ beforeEach(() => {
|
||||
api.call.mockImplementation(async (operation: string) => {
|
||||
if (operation === 'budget') return budget;
|
||||
if (operation === 'scheduleContext') return scheduleContext;
|
||||
if (operation === 'channelSelfCallers') return { items: [] };
|
||||
if (operation === 'channelBindings') return { items: [], available_accounts: [] };
|
||||
if (operation === 'threads') return { threads: [], next_offset: null };
|
||||
if (operation === 'history') return history;
|
||||
if (operation === 'attachments') return { attachments: [] };
|
||||
@@ -180,13 +182,14 @@ it('keeps publication and access controls usable when cost history is unavailabl
|
||||
api.call.mockImplementation(async (operation) => {
|
||||
if (operation === 'access') return { published_version: 1, enabled: true, share_url: 'niancode://agents/' + slug, versions: [], grants: [], applications: [] };
|
||||
if (operation === 'costs') throw new Error('费用暂不可用');
|
||||
if (operation === 'channelBindings') return { items: [], available_accounts: [] };
|
||||
if (operation === 'budget') return budget;
|
||||
throw new Error(operation);
|
||||
});
|
||||
render(<CloudAccessPanel slug={slug} revision={2} onPublished={() => undefined} />);
|
||||
expect(await screen.findByText('当前发布版本 1')).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: '停用智能体' })).toBeEnabled();
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('费用暂不可用');
|
||||
expect(await screen.findByText('费用暂不可用')).toBeVisible();
|
||||
});
|
||||
|
||||
it('saves a manually created schedule disabled unless the creator selects enable', async () => {
|
||||
@@ -211,6 +214,8 @@ it('an uncertain schedule save retries the same operation and explicit enabled s
|
||||
if (operation === 'schedules') return { jobs: [] };
|
||||
if (operation === 'budget') return budget;
|
||||
if (operation === 'scheduleContext') return scheduleContext;
|
||||
if (operation === 'channelSelfCallers') return { items: [] };
|
||||
if (operation === 'channelBindings') return { items: [], available_accounts: [] };
|
||||
if (operation === 'createSchedule') { if (++attempt === 1) throw new Error('超时'); return { id: 'job' }; }
|
||||
throw new Error(operation);
|
||||
});
|
||||
@@ -230,6 +235,73 @@ it('an uncertain schedule save retries the same operation and explicit enabled s
|
||||
expect(calls[0][1].enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('sends scheduled results only to an explicitly selected own pairing and preserves that intent on retry', async () => {
|
||||
const base = api.call.getMockImplementation()!;
|
||||
let saves = 0;
|
||||
api.call.mockImplementation(async (operation, input) => {
|
||||
if (operation === 'schedules') return { jobs: [] };
|
||||
if (operation === 'channelSelfCallers') return { items: [{ caller_id: 'self-caller', channel_account_id: 'wechat-a' }] };
|
||||
if (operation === 'channelBindings') return { items: [{ id: 'wechat-a', display_name: '我的创作助手' }], available_accounts: [] };
|
||||
if (operation === 'createSchedule') { if (++saves === 1) throw new Error('连接超时'); return { id: 'job', ...input }; }
|
||||
return base(operation, input);
|
||||
});
|
||||
render(<CloudSchedules slug={slug} onOpen={() => undefined} />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '添加任务' }));
|
||||
await screen.findByRole('option', { name: '我的创作助手 · 本人对话 1' });
|
||||
expect(screen.getByLabelText('结果发送到')).toHaveValue('');
|
||||
fireEvent.change(screen.getByLabelText('任务名称'), { target: { value: '微信选题' } });
|
||||
fireEvent.change(screen.getByLabelText('目标与要求'), { target: { value: '整理三个选题' } });
|
||||
fireEvent.change(screen.getByLabelText('结果发送到'), { target: { value: 'self-caller' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存为停用任务' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '重试保存' }));
|
||||
await screen.findByRole('button', { name: '添加任务' });
|
||||
const calls = api.call.mock.calls.filter(call => call[0] === 'createSchedule');
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0][1]).toEqual(calls[1][1]);
|
||||
expect(calls[0][1].result_notification).toEqual({ enabled: true, channel_account_id: 'wechat-a', caller_id: 'self-caller' });
|
||||
expect(calls[0][1].enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('pauses a schedule without dropping its own-WeChat notification target', async () => {
|
||||
const notification = { enabled: true, channel_account_id: 'wechat-a', caller_id: 'self-caller' };
|
||||
const job = { id: 'job', name: '选题', prompt: '整理选题', cron_expression: '0 9 * * *', timezone: 'Asia/Shanghai', enabled: true, result_notification: notification };
|
||||
api.call.mockImplementation(async (operation, input) => {
|
||||
if (operation === 'schedules') return { jobs: [job] };
|
||||
if (operation === 'updateSchedule') return { ...job, ...input };
|
||||
throw new Error(operation);
|
||||
});
|
||||
render(<CloudSchedules slug={slug} onOpen={() => undefined} />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '暂停' }));
|
||||
await waitFor(() => expect(api.call).toHaveBeenCalledWith('updateSchedule', expect.objectContaining({ enabled: false })));
|
||||
// Yuxi retains the notification when the key is absent; resending a revoked
|
||||
// target would revalidate it and incorrectly block the pause.
|
||||
expect(api.call.mock.calls.find(call => call[0] === 'updateSchedule')![1]).not.toHaveProperty('result_notification');
|
||||
});
|
||||
|
||||
it('keeps an unavailable existing notification target until the creator explicitly clears it', async () => {
|
||||
const base = api.call.getMockImplementation()!;
|
||||
const job = { id: 'job', name: '选题', prompt: '整理选题', cron_expression: '0 9 * * *', timezone: 'Asia/Shanghai', enabled: false,
|
||||
result_notification: { enabled: true, channel_account_id: 'old-wechat', caller_id: 'revoked-caller' } };
|
||||
api.call.mockImplementation(async (operation, input) => {
|
||||
if (operation === 'schedules') return { jobs: [job] };
|
||||
if (operation === 'updateSchedule') return { ...job, ...input };
|
||||
return base(operation, input);
|
||||
});
|
||||
render(<CloudSchedules slug={slug} onOpen={() => undefined} />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '编辑' }));
|
||||
await screen.findByRole('option', { name: '原微信对话暂不可用(保留配置)' });
|
||||
expect(screen.getByLabelText('结果发送到')).toHaveValue('revoked-caller');
|
||||
expect(api.call.mock.calls.some(call => call[0] === 'updateSchedule')).toBe(false);
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存为停用任务' }));
|
||||
await screen.findByRole('button', { name: '添加任务' });
|
||||
expect(api.call.mock.calls.find(call => call[0] === 'updateSchedule')![1]).not.toHaveProperty('result_notification');
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑' }));
|
||||
await screen.findByRole('option', { name: '原微信对话暂不可用(保留配置)' });
|
||||
fireEvent.change(screen.getByLabelText('结果发送到'), { target: { value: '' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存为停用任务' }));
|
||||
await waitFor(() => expect(api.call).toHaveBeenCalledWith('updateSchedule', expect.objectContaining({ result_notification: null })));
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('returns to a newly created conversation from history without remounting the page', async () => {
|
||||
|
||||
144
tests/unit/cloud-channel-conversations.test.tsx
Normal file
144
tests/unit/cloud-channel-conversations.test.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { CloudChannelConversations } from '@/pages/CloudAgents/CloudChannelConversations';
|
||||
|
||||
const api = vi.hoisted(() => ({ call: vi.fn(), recovery: vi.fn(), resolvePending: vi.fn(), downloadChannelArtifact: vi.fn() }));
|
||||
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
|
||||
const session = { session_id: 'self-session', caller_id: 'self-caller', agent_slug: 'ml-' + 'a'.repeat(32), agent_name: '创作助手',
|
||||
session_state: 'active', grant_state: 'authorized', access_mode: 'self_only', thread_id: 'private-channel-thread', sequence: 1, created_at: '2026-09-13T01:00:00Z' };
|
||||
const run = { agent_run_id: 'native-run', status: 'interrupted', interrupt: { approval: { action_requests: [{ name: 'execute', args: { command: 'create slides' } }, { name: 'ls', args: { path: 'outputs' } }] } } };
|
||||
const output = { name: '创作建议.pptx', path: '/outputs/创作建议.pptx', directory_path: '/outputs', is_dir: false, size: 40960 };
|
||||
const history = { ...session, run, messages: [{ id: 1, role: 'user', content: '请制作演示文稿' }], queued_requests: [], next_offset: null };
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
api.recovery.mockResolvedValue({ recent: null, pending: [] });
|
||||
api.downloadChannelArtifact.mockResolvedValue({ saved: true });
|
||||
api.call.mockImplementation(async (operation, input) => {
|
||||
if (operation === 'channelConversations') return { items: [session], next_offset: null };
|
||||
if (operation === 'channelConversation') return history;
|
||||
if (operation === 'channelFiles') return { session_id: session.session_id, thread_id: session.thread_id,
|
||||
files: input.path === '/' ? [{ name: 'outputs', path: '/outputs', directory_path: '/outputs', is_dir: true, size: 0 }] : [output] };
|
||||
throw new Error('Unexpected operation ' + operation);
|
||||
});
|
||||
});
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); });
|
||||
|
||||
it('approves the actual action batch through the self-session endpoint and automatically exposes the delivered file', async () => {
|
||||
const base = api.call.getMockImplementation()!;
|
||||
let approved = false;
|
||||
api.call.mockImplementation(async (operation, input) => {
|
||||
if (operation === 'channelConversationControl') { approved = true; return { session_id: session.session_id, status: 'completed' }; }
|
||||
if (operation === 'channelConversation') return approved ? { ...history, run: { ...run, status: 'completed', interrupt: undefined }, messages: [{ id: 2, role: 'assistant', content: '演示文稿已完成' }] } : history;
|
||||
if (operation === 'channelFiles' && !approved) return { files: [] };
|
||||
return base(operation, input);
|
||||
});
|
||||
render(<CloudChannelConversations slug={session.agent_slug} />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '允许执行' }));
|
||||
await screen.findByText('演示文稿已完成');
|
||||
expect(api.call).toHaveBeenCalledWith('channelConversationControl', expect.objectContaining({ session_id: session.session_id, action: 'resume', run_id: 'native-run',
|
||||
decision: { decisions: [{ type: 'approve' }, { type: 'approve' }] } }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '保存 创作建议.pptx' }));
|
||||
await screen.findByText('已保存 创作建议.pptx');
|
||||
expect(api.downloadChannelArtifact).toHaveBeenCalledWith('self-session', '/outputs/创作建议.pptx');
|
||||
expect(api.call.mock.calls.some(call => ['resume', 'run', 'files', 'submit'].includes(call[0]))).toBe(false);
|
||||
});
|
||||
|
||||
it('retries an uncertain stop with the identical operation ID and keeps other controls disabled', async () => {
|
||||
const base = api.call.getMockImplementation()!;
|
||||
let original: unknown;
|
||||
api.call.mockImplementation(async (operation, input) => {
|
||||
if (operation === 'channelConversationControl') {
|
||||
original = input;
|
||||
api.recovery.mockResolvedValue({ pending: [{ id: 'pending-stop', operation, input }] });
|
||||
throw new Error('操作超时');
|
||||
}
|
||||
return base(operation, input);
|
||||
});
|
||||
api.resolvePending.mockImplementation(async () => {
|
||||
api.recovery.mockResolvedValue({ pending: [] });
|
||||
return { result: { session_id: session.session_id, status: 'cancelled' } };
|
||||
});
|
||||
render(<CloudChannelConversations slug={session.agent_slug} />);
|
||||
const stop = await screen.findByRole('button', { name: '停止本次任务' });
|
||||
await waitFor(() => expect(stop).toBeEnabled());
|
||||
fireEvent.click(stop);
|
||||
expect(await screen.findByRole('button', { name: '恢复上次操作' })).toBeEnabled();
|
||||
expect(stop).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '恢复上次操作' }));
|
||||
await waitFor(() => expect(api.resolvePending).toHaveBeenCalledWith('pending-stop', false));
|
||||
expect(api.call.mock.calls.filter(call => call[0] === 'channelConversationControl')).toEqual([['channelConversationControl', original]]);
|
||||
});
|
||||
|
||||
it('allows ignoring a rejected new-session operation before stopping an intake not yet exposed as a Run', async () => {
|
||||
const base = api.call.getMockImplementation()!;
|
||||
api.call.mockImplementation(async (operation, input) => {
|
||||
if (operation === 'channelConversation') return { ...history, run: null };
|
||||
if (operation === 'channelConversationControl') {
|
||||
if (input.action === 'new-session') {
|
||||
api.recovery.mockResolvedValue({ pending: [{ id: 'rejected-new', operation, input }] });
|
||||
throw new Error('已有任务正在准备');
|
||||
}
|
||||
return { session_id: session.session_id, status: 'cancelled' };
|
||||
}
|
||||
return base(operation, input);
|
||||
});
|
||||
api.resolvePending.mockImplementation(async () => {
|
||||
api.recovery.mockResolvedValue({ pending: [] }); return { discarded: true };
|
||||
});
|
||||
render(<CloudChannelConversations slug={session.agent_slug} />);
|
||||
const start = await screen.findByRole('button', { name: '开始新对话' });
|
||||
await waitFor(() => expect(start).toBeEnabled());
|
||||
fireEvent.click(start);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '忽略本机记录' }));
|
||||
const stop = screen.getByRole('button', { name: '停止本次任务' });
|
||||
await waitFor(() => expect(stop).toBeEnabled());
|
||||
fireEvent.click(stop);
|
||||
await waitFor(() => expect(api.call).toHaveBeenCalledWith('channelConversationControl', expect.objectContaining({ action: 'stop' })));
|
||||
expect(api.resolvePending).toHaveBeenCalledWith('rejected-new', true);
|
||||
});
|
||||
|
||||
it('recovers a completed new-session receipt only after an explicit click and selects the returned session', async () => {
|
||||
const pending = { id: 'pending-new', operation: 'channelConversationControl', input: { session_id: session.session_id, operation_id: 'old-operation', action: 'new-session' } };
|
||||
api.recovery.mockResolvedValueOnce({ pending: [pending] }).mockResolvedValue({ pending: [] });
|
||||
api.resolvePending.mockResolvedValue({ result: { session_id: 'next-self-session', previous_session_id: session.session_id } });
|
||||
render(<CloudChannelConversations slug={session.agent_slug} />);
|
||||
const restore = await screen.findByRole('button', { name: '恢复上次操作' });
|
||||
expect(api.resolvePending).not.toHaveBeenCalled();
|
||||
expect(api.call.mock.calls.some(call => call[0] === 'channelConversationControl')).toBe(false);
|
||||
fireEvent.click(restore);
|
||||
await waitFor(() => expect(api.call).toHaveBeenCalledWith('channelConversation', { session_id: 'next-self-session' }));
|
||||
expect(api.resolvePending).toHaveBeenCalledWith('pending-new', false);
|
||||
});
|
||||
|
||||
it('preserves historical self files while hiding controls for a revoked closed session', async () => {
|
||||
const base = api.call.getMockImplementation()!;
|
||||
api.call.mockImplementation(async (operation, input) => operation === 'channelConversation'
|
||||
? { ...history, session_state: 'closed', grant_state: 'revoked' } : base(operation, input));
|
||||
render(<CloudChannelConversations slug={session.agent_slug} />);
|
||||
await screen.findByRole('button', { name: '保存 创作建议.pptx' });
|
||||
expect(screen.queryByRole('button', { name: '允许执行' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '停止本次任务' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stops history polling when the desktop pane is hidden', async () => {
|
||||
const view = render(<CloudChannelConversations slug={session.agent_slug} />);
|
||||
await screen.findByText('请制作演示文稿');
|
||||
view.rerender(<CloudChannelConversations slug={session.agent_slug} activeView={false} />);
|
||||
const count = api.call.mock.calls.filter(call => call[0] === 'channelConversation').length;
|
||||
vi.useFakeTimers();
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(20000); });
|
||||
expect(api.call.mock.calls.filter(call => call[0] === 'channelConversation')).toHaveLength(count);
|
||||
});
|
||||
|
||||
it('reads subsequent native history pages so new results remain visible beyond the first 100 messages', async () => {
|
||||
const base = api.call.getMockImplementation()!;
|
||||
api.call.mockImplementation(async (operation, input) => {
|
||||
if (operation !== 'channelConversation') return base(operation, input);
|
||||
if (input.offset === 100) return { ...history, messages: [{ id: 101, role: 'assistant', content: '第 101 条消息的交付结果' }], next_offset: null };
|
||||
return { ...history, next_offset: 100 };
|
||||
});
|
||||
render(<CloudChannelConversations slug={session.agent_slug} />);
|
||||
await screen.findByText('第 101 条消息的交付结果');
|
||||
expect(screen.getByText('请制作演示文稿')).toBeVisible();
|
||||
expect(api.call).toHaveBeenCalledWith('channelConversation', { session_id: 'self-session', offset: 100 });
|
||||
});
|
||||
31
tests/unit/cloud-channel-enable.test.tsx
Normal file
31
tests/unit/cloud-channel-enable.test.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import { CloudChannelEnableConfirmation } from '@/pages/CloudAgents/CloudChannelEnableConfirmation';
|
||||
|
||||
const api = vi.hoisted(() => ({ call: vi.fn() }));
|
||||
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
|
||||
afterEach(() => { cleanup(); vi.resetAllMocks(); });
|
||||
const binding = { id: 'wechat', address: 'wechat:creator', display_name: '创作微信', target_agent_address: 'yuxi:makelore:agent',
|
||||
route_revision: 1, provider_generation: 'generation', binding_id: 'binding', enabled: false, status: 'bound', worker_online: true, access_mode: 'self_only' };
|
||||
|
||||
it('shows actual budget values and published audience before enabling, including a zero limit', async () => {
|
||||
api.call.mockResolvedValue({ request_limit_points: '0.00', daily_limit_points: null });
|
||||
const enable = vi.fn();
|
||||
render(<CloudChannelEnableConfirmation slug="agent" binding={binding} publishedVersion={2} busy={false} onEnable={enable} onCancel={() => undefined} />);
|
||||
expect(screen.getByRole('button', { name: '确认启用', exact: true })).toBeDisabled();
|
||||
await screen.findByText('每次任务上限:0.00 词元点数');
|
||||
expect(screen.getByText('每日上限:不设额外上限')).toBeVisible();
|
||||
expect(screen.getByRole('dialog')).toHaveTextContent('当前发布版本 v2,允许仅本人');
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认启用', exact: true }));
|
||||
expect(enable).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps enable unavailable until a failed budget read can be retried', async () => {
|
||||
api.call.mockRejectedValueOnce(new Error('费用服务暂不可用')).mockResolvedValueOnce({ request_limit_points: '2.00', daily_limit_points: '10.00' });
|
||||
render(<CloudChannelEnableConfirmation slug="agent" binding={binding} publishedVersion={2} busy={false} onEnable={() => undefined} onCancel={() => undefined} />);
|
||||
await screen.findByRole('alert');
|
||||
expect(screen.getByRole('button', { name: '确认启用', exact: true })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新读取上限' }));
|
||||
await screen.findByText('每日上限:10.00 词元点数');
|
||||
expect(screen.getByRole('button', { name: '确认启用', exact: true })).toBeEnabled();
|
||||
});
|
||||
302
tests/unit/cloud-channel-panel.test.tsx
Normal file
302
tests/unit/cloud-channel-panel.test.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
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 pairing code command and does not mutate when the access tab is opened', async () => {
|
||||
render(<CloudChannelPanel slug={slug} publishedVersion={2} />);
|
||||
await screen.findByRole('button', { name: '使用权限' });
|
||||
fireEvent.click(screen.getByRole('button', { name: '使用权限' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '生成本人配对码' }));
|
||||
await screen.findByText('ABC123');
|
||||
expect(api.call.mock.calls.some(call => call[0] === 'channelPolicy')).toBe(false);
|
||||
expect(api.call.mock.calls.find(call => call[0] === 'createChannelPairing')?.[1]).toEqual(expect.objectContaining({ kind: 'self', 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, 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, 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: 'self', 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: '个人微信登录二维码' });
|
||||
});
|
||||
30
tests/unit/cloud-wechat-qr.test.tsx
Normal file
30
tests/unit/cloud-wechat-qr.test.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { CloudWechatQr } from '@/pages/CloudAgents/CloudWechatQr';
|
||||
|
||||
describe('CloudWechatQr', () => {
|
||||
it('generates an SVG QR locally from the provider login URL', async () => {
|
||||
render(<CloudWechatQr value="https://wechat.example.test/login?ticket=abc" />);
|
||||
|
||||
const qr = await screen.findByAltText('个人微信登录二维码');
|
||||
expect(qr.tagName).toBe('IMG');
|
||||
expect(qr.getAttribute('src')).toMatch(/^data:image\/svg\+xml;charset=utf-8,/);
|
||||
expect(qr.getAttribute('width')).toBe('192');
|
||||
expect(qr.getAttribute('height')).toBe('192');
|
||||
});
|
||||
|
||||
it('does not show the previous QR while the value changes', async () => {
|
||||
const view = render(<CloudWechatQr value="https://wechat.example.test/login?ticket=old" />);
|
||||
await screen.findByAltText('个人微信登录二维码');
|
||||
|
||||
view.rerender(<CloudWechatQr value="https://wechat.example.test/login?ticket=new" />);
|
||||
expect(screen.queryByAltText('个人微信登录二维码')).toBeNull();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('正在生成二维码');
|
||||
await waitFor(() => expect(screen.getByAltText('个人微信登录二维码')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows a Chinese error when QR generation fails', async () => {
|
||||
render(<CloudWechatQr value={'x'.repeat(5000)} />);
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('二维码生成失败,请重试');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user