import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { CloudChat } from '@/pages/CloudAgents/CloudChat'; import { CloudAccessPanel } from '@/pages/CloudAgents/CloudAccess'; import { CloudSchedules } from '@/pages/CloudAgents/CloudSchedules'; const api = vi.hoisted(() => ({ call: vi.fn(), events: vi.fn(), upload: vi.fn(), download: vi.fn() })); vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api })); class Stream extends EventTarget { close = vi.fn(); onopen = null; onerror = null; } const stream = new Stream(); const slug = 'ml-' + 'a'.repeat(32); const run = { agent_run_id: 'run-1', request_id: 'request-1', thread_id: 'thread-1', agent_slug: slug, status: 'running', version: '2', output: '' }; const history = { thread_id: 'thread-1', messages: [], run, next_offset: null }; beforeEach(() => { vi.resetAllMocks(); api.events.mockResolvedValue(stream); api.call.mockImplementation(async (operation: string) => { if (operation === 'threads') return { threads: [], next_offset: null }; if (operation === 'history') return history; if (operation === 'attachments') return { attachments: [] }; if (operation === 'files') return { files: [] }; if (operation === 'run') return run; throw new Error('Unexpected operation: ' + operation); }); }); afterEach(cleanup); it('retries the same request after a lost response and renders batched native SSE', async () => { let attempts = 0; const base = api.call.getMockImplementation()!; api.call.mockImplementation(async (operation, input) => { if (operation !== 'submit') return base(operation, input); if (++attempts === 1) throw new Error('响应丢失'); return { request_id: 'request-1', run_id: 'run-1', thread_id: 'thread-1', status: 'dispatched', version: '2' }; }); render(); await waitFor(() => expect(screen.getByLabelText('消息')).not.toBeDisabled()); fireEvent.change(screen.getByLabelText('消息'), { target: { value: '生成文章' } }); fireEvent.click(screen.getByRole('button', { name: '发送', exact: true })); await screen.findByRole('button', { name: '重试本次发送' }); expect(screen.getByLabelText('消息')).toBeDisabled(); fireEvent.click(screen.getByRole('button', { name: '重试本次发送' })); await waitFor(() => expect(api.events).toHaveBeenCalledWith('run-1')); const submissions = api.call.mock.calls.filter(call => call[0] === 'submit'); expect(submissions[0][1]).toEqual(submissions[1][1]); await act(async () => stream.dispatchEvent(new MessageEvent('messages', { data: JSON.stringify({ thread_id: 'thread-1', payload: { items: [ { stream_event: { type: 'message_delta', content: '第一段' } }, { stream_event: { type: 'message_delta', content: '文字' } }, ] } }), }))); expect(screen.getByText('第一段文字')).toBeVisible(); expect(screen.getByText(/版本 2/)).toBeVisible(); }); it('restores a durable question and follows the new resume run', async () => { const base = api.call.getMockImplementation()!; api.call.mockImplementation(async (operation, input) => { if (operation === 'threads') return { threads: [{ thread_id: 'thread-1', client_thread_id: 'client', mode: 'published' }], next_offset: null }; if (operation === 'history') return { ...history, run: { ...run, status: 'interrupted', interrupt: { questions: [{ question_id: 'direction', question: '选择方向', options: ['海洋', '森林'] }], } } }; if (operation === 'resume') return { run_id: 'run-2', status: 'pending' }; if (operation === 'run' && input.run_id === 'run-2') return { ...run, agent_run_id: 'run-2', status: 'pending' }; return base(operation, input); }); render(); fireEvent.click(await screen.findByRole('radio', { name: '海洋' })); fireEvent.click(screen.getByRole('button', { name: '新对话' })); expect(screen.getByRole('radio', { name: '海洋' })).toBeChecked(); fireEvent.click(screen.getByRole('button', { name: '继续编辑' })); fireEvent.click(screen.getByRole('button', { name: '提交回答' })); await waitFor(() => expect(api.events).toHaveBeenCalledWith('run-2')); expect(api.call.mock.calls.find(call => call[0] === 'resume')?.[1].decision).toEqual({ direction: '海洋' }); }); it('keeps publication and access controls usable when cost history is unavailable', async () => { 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('费用暂不可用'); throw new Error(operation); }); render( undefined} />); expect(await screen.findByText('当前发布版本 1')).toBeVisible(); expect(screen.getByRole('button', { name: '停用智能体' })).toBeEnabled(); expect(await screen.findByRole('alert')).toHaveTextContent('费用暂不可用'); }); it('an uncertain schedule save retries the same operation and explicit enabled state', async () => { let attempt = 0; api.call.mockImplementation(async (operation) => { if (operation === 'schedules') return { jobs: [] }; if (operation === 'createSchedule') { if (++attempt === 1) throw new Error('超时'); return { id: 'job' }; } throw new Error(operation); }); render( undefined} />); fireEvent.click(await screen.findByRole('button', { name: '添加任务' })); fireEvent.change(screen.getByLabelText('任务名称'), { target: { value: '早间选题' } }); fireEvent.change(screen.getByLabelText('目标与要求'), { target: { value: '给出三个选题' } }); 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].enabled).toBe(true); }); it('returns to a newly created conversation from history without remounting the page', async () => { const base = api.call.getMockImplementation()!; api.call.mockImplementation(async (operation, input) => { if (operation === 'submit') return { request_id: 'new-request', run_id: 'run-1', thread_id: 'thread-1', status: 'dispatched', version: '2' }; if (operation === 'history') return { ...history, run: { ...run, status: 'completed' } }; return base(operation, input); }); render(); await waitFor(() => expect(screen.getByLabelText('消息')).not.toBeDisabled()); fireEvent.change(screen.getByLabelText('消息'), { target: { value: '新对话主题' } }); fireEvent.click(screen.getByRole('button', { name: '发送', exact: true })); await waitFor(() => expect(screen.getByLabelText('历史对话')).toBeEnabled()); expect(screen.getByRole('option', { name: '新对话主题 · 已完成' })).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '新对话' })); expect(screen.getByLabelText('历史对话')).toHaveValue(''); fireEvent.change(screen.getByLabelText('历史对话'), { target: { value: 'thread-1' } }); await waitFor(() => expect(screen.getByLabelText('历史对话')).toHaveValue('thread-1')); expect(api.call.mock.calls.filter(call => call[0] === 'history' && call[1].thread_id === 'thread-1')).toHaveLength(2); }); it('creates an attachment conversation only when the selected file is confirmed', async () => { const base = api.call.getMockImplementation()!; api.call.mockImplementation(async (operation, input) => { if (operation === 'createThread') return { thread_id: 'thread-1', client_thread_id: input.thread_id }; if (operation === 'confirmAttachment') return { attachments: [{ file_id: 'attachment-1' }] }; return base(operation, input); }); api.upload.mockResolvedValueOnce(null).mockResolvedValueOnce({ object_name: 'temporary/notes.txt', file_name: 'notes.txt', file_type: 'txt', parse_supported: false, parse_methods: [], }); render(); fireEvent.click(screen.getByText('附件与产物')); const add = screen.getByRole('button', { name: '添加附件' }); await waitFor(() => expect(add).toBeEnabled()); fireEvent.click(add); await waitFor(() => expect(api.upload).toHaveBeenCalledTimes(1)); await waitFor(() => expect(add).toBeEnabled()); expect(api.call.mock.calls.some(call => call[0] === 'createThread')).toBe(false); expect(screen.queryByLabelText('历史对话')).not.toBeInTheDocument(); fireEvent.click(add); const confirm = await screen.findByRole('button', { name: '加入此对话' }); expect(api.call.mock.calls.some(call => call[0] === 'createThread')).toBe(false); fireEvent.click(confirm); await waitFor(() => expect(api.call).toHaveBeenCalledWith('confirmAttachment', { thread_id: 'thread-1', attachments: [{ object_name: 'temporary/notes.txt', file_type: 'txt' }], })); expect(api.call.mock.calls.filter(call => call[0] === 'createThread')).toHaveLength(1); await waitFor(() => expect(screen.getByText('附件与产物 · 本条消息附带 1 个文件')).toBeVisible()); });