import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { createMemoryRouter, RouterProvider } from 'react-router-dom'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import CloudAgents from '@/pages/CloudAgents'; import { EMPTY_CLOUD_CONFIGURATION } from '../../shared/cloud-agents'; const api = vi.hoisted(() => ({ list: vi.fn(), create: vi.fn(), get: vi.fn(), save: vi.fn(), call: vi.fn(), events: vi.fn(), recovery: vi.fn(), remember: vi.fn(), resolvePending: vi.fn() })); const auth = vi.hoisted(() => ({ user: { userId: 'creator' } })); vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api })); vi.mock('@/stores/auth', () => ({ useAuthStore: (selector: (state: typeof auth) => unknown) => selector(auth) })); const draft = { slug: 'ml-' + 'a'.repeat(32), name: '写作搭档', purpose: '帮助写作', system_prompt: '原始指令', draft_revision: 1, updated_at: '2026-09-10T00:00:00Z', configuration: EMPTY_CLOUD_CONFIGURATION, published_version: null, enabled: true, }; function open() { const router = createMemoryRouter([ { path: '/cloud-agents', element: }, { path: '/module-select', element:
模块首页
}, ], { initialEntries: ['/cloud-agents'] }); const view = render(); return { router, ...view }; } beforeEach(() => { vi.resetAllMocks(); auth.user.userId = 'creator'; api.recovery.mockResolvedValue({ recent: null, pending: [] }); api.remember.mockResolvedValue({ saved: true }); api.list.mockResolvedValue({ agents: [draft], next_cursor: null }); api.call.mockImplementation(async (operation: string) => { if (operation === 'knowledge') return { databases: [], models: [] }; if (operation === 'resources') return { mcps: [], skills: [], subagents: [] }; if (operation === 'threads') return { threads: [], next_offset: null }; if (operation === 'budget') return { request_limit_points: null, daily_limit_points: null, daily_committed_points: '0.00' }; return { models: [], resources: {}, pricing: null }; }); }); afterEach(cleanup); it('restores the exact previous thread and presents an uncertain operation without automatically replaying it', async () => { const published = { ...draft, published_version: 1 }; const pending = { id: 'submit:pending', operation: 'submit', input: { slug: draft.slug, request_id: 'same-request', thread_id: 'older-thread', query: '上次未确认输入' }, created_at: '2026-09-10T01:00:00Z' }; api.recovery.mockResolvedValue({ recent: { slug: draft.slug, thread_id: 'older-thread', mode: 'published' }, pending: [pending] }); api.get.mockResolvedValue(published); api.call.mockImplementation(async (operation: string, input) => { if (operation === 'threads') return { threads: ['newer-thread', 'older-thread'].map(thread_id => ({ thread_id, client_thread_id: thread_id, mode: 'published', title: thread_id })), next_offset: null }; if (operation === 'history') return { thread_id: input.thread_id, messages: [{ id: 1, role: 'assistant', content: '恢复了旧对话' }], run: null, next_offset: null }; if (operation === 'attachments') return { attachments: [] }; if (operation === 'files') return { files: [] }; throw new Error(operation); }); open(); await screen.findByText('恢复了旧对话'); fireEvent.click(screen.getByRole('button', { name: '查看历史对话' })); expect(screen.getByLabelText('历史对话')).toHaveValue('older-thread'); expect(api.resolvePending).not.toHaveBeenCalled(); expect(api.call.mock.calls.some(call => call[0] === 'submit')).toBe(false); api.resolvePending.mockResolvedValue({ result: { request_id: 'same-request', status: 'queued' } }); api.recovery.mockResolvedValue({ recent: null, pending: [] }); fireEvent.click(screen.getByRole('button', { name: '重试并确认结果' })); await screen.findByText('发送消息的结果已确认。'); expect(api.resolvePending).toHaveBeenCalledWith('submit:pending', false); }); it('keeps creation input and its uncertain operation when an existing card is clicked', async () => { api.create.mockRejectedValueOnce(new Error('连接中断')).mockResolvedValueOnce(draft); open(); const existing = await screen.findByRole('button', { name: /写作搭档/ }); fireEvent.click(screen.getByRole('button', { name: '创建智能体' })); fireEvent.change(screen.getByLabelText('名称'), { target: { value: '新搭档' } }); fireEvent.change(screen.getByLabelText('用途'), { target: { value: '新用途' } }); fireEvent.click(existing); expect(screen.queryByTestId('cloud-agent-editor')).not.toBeInTheDocument(); expect(screen.getByLabelText('名称')).toHaveValue('新搭档'); fireEvent.click(screen.getByRole('button', { name: '创建草稿' })); await screen.findByRole('alert'); fireEvent.click(existing); expect(screen.queryByTestId('cloud-agent-editor')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '重试创建' })); await screen.findByTestId('cloud-agent-editor'); 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); open(); fireEvent.click(await screen.findByText('创建第一个智能体')); fireEvent.change(screen.getByLabelText('名称'), { target: { value: '写作搭档' } }); fireEvent.change(screen.getByLabelText('用途'), { target: { value: '帮助写作' } }); fireEvent.click(screen.getByRole('button', { name: '创建草稿' })); await screen.findByRole('alert'); fireEvent.click(screen.getByRole('button', { name: '重试创建' })); await screen.findByTestId('cloud-agent-editor'); expect(api.create).toHaveBeenCalledTimes(2); expect(api.create.mock.calls[1][0]).toEqual(api.create.mock.calls[0][0]); }); it('preserves local input on conflicts and requires an explicit choice before retry', async () => { api.save.mockRejectedValueOnce(new Error('草稿已更新')); api.get.mockResolvedValue({ ...draft, draft_revision: 2, system_prompt: '其他设备内容' }); open(); fireEvent.click(await screen.findByRole('button', { name: /写作搭档/ })); fireEvent.change(screen.getByLabelText('角色与指令'), { target: { value: '我的修改' } }); fireEvent.click(screen.getByRole('button', { name: '保存草稿' })); await screen.findByText('云端已有修订 2,本地输入仍保留'); expect(screen.getByLabelText('角色与指令')).toHaveValue('我的修改'); expect(screen.getByRole('button', { name: '保存草稿' })).toBeDisabled(); fireEvent.click(screen.getByText('保留我的内容,继续编辑')); api.save.mockResolvedValue({ ...draft, draft_revision: 3, system_prompt: '我的修改' }); fireEvent.click(screen.getByRole('button', { name: '保存草稿' })); await screen.findByRole('button', { name: '已保存' }); expect(api.save.mock.calls[1][1]).toMatchObject({ expected_revision: 2, system_prompt: '我的修改' }); }); it('blocks module navigation with unsaved edits, cancels leaving, then saves before proceeding', async () => { const { router } = open(); fireEvent.click(await screen.findByRole('button', { name: /写作搭档/ })); fireEvent.change(screen.getByLabelText('名称'), { target: { value: '新的名称' } }); await act(async () => { await router.navigate('/module-select'); }); expect(screen.getByRole('dialog', { name: '未保存的修改' })).toBeVisible(); expect(router.state.location.pathname).toBe('/cloud-agents'); fireEvent.click(screen.getByText('继续编辑')); expect(screen.getByLabelText('名称')).toHaveValue('新的名称'); await act(async () => { await router.navigate('/module-select'); }); api.save.mockResolvedValue({ ...draft, name: '新的名称', draft_revision: 2 }); fireEvent.click(screen.getByText('保存并离开')); await screen.findByText('模块首页'); expect(api.save).toHaveBeenCalledTimes(1); }); it('clears the previous account workspace and ignores its delayed list', async () => { let finish!: (value: unknown) => void; api.list.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })) .mockResolvedValue({ agents: [], next_cursor: null }); const view = open(); auth.user.userId = 'other'; view.rerender(); await screen.findByText('你的第一个智能体,从这里开始'); await act(async () => finish({ agents: [draft], next_cursor: null })); await waitFor(() => expect(screen.queryByText('写作搭档')).not.toBeInTheDocument()); }); it('warns before leaving an uncertain creation and keeps its retry intent', async () => { api.list.mockResolvedValue({ agents: [], next_cursor: null }); api.create.mockRejectedValue(new Error('连接中断')); const { router } = open(); fireEvent.click(await screen.findByText('创建第一个智能体')); fireEvent.change(screen.getByLabelText('名称'), { target: { value: '写作搭档' } }); fireEvent.change(screen.getByLabelText('用途'), { target: { value: '帮助写作' } }); fireEvent.click(screen.getByRole('button', { name: '创建草稿' })); await screen.findByRole('alert'); await act(async () => { await router.navigate('/module-select'); }); expect(screen.getByRole('dialog', { name: '离开创建' })).toBeVisible(); fireEvent.click(screen.getByRole('button', { name: '继续创建' })); fireEvent.click(screen.getByRole('button', { name: '重试创建' })); await waitFor(() => expect(api.create).toHaveBeenCalledTimes(2)); expect(api.create.mock.calls[1][0]).toEqual(api.create.mock.calls[0][0]); }); it('keeps unsent preview text when configuration changes and waits before switching saved revision', async () => { const configured = { ...draft, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'test-model' } }; api.list.mockResolvedValue({ agents: [configured], next_cursor: null }); api.save.mockResolvedValue({ ...configured, name: '新的名称', draft_revision: 2 }); open(); fireEvent.click(await screen.findByRole('button', { name: /写作搭档/ })); const prompt = await screen.findByPlaceholderText('输入消息或任务目标…'); fireEvent.change(prompt, { target: { value: '还未发送的问题' } }); fireEvent.change(screen.getByLabelText('名称'), { target: { value: '新的名称' } }); expect(prompt).toHaveValue('还未发送的问题'); expect(screen.getByText(/保存不会清空当前试用/)).toBeVisible(); await waitFor(() => expect(screen.getByRole('button', { name: '保存草稿' })).toBeEnabled()); fireEvent.click(screen.getByRole('button', { name: '保存草稿' })); await screen.findByRole('button', { name: '已保存' }); expect(api.save).toHaveBeenCalledTimes(1); expect(screen.getByLabelText('消息')).toBe(prompt); expect(prompt).toHaveValue('还未发送的问题'); expect(screen.getByText('草稿预览 · 修订 1')).toBeVisible(); expect(screen.getByRole('button', { name: '发送', exact: true })).toBeDisabled(); fireEvent.click(screen.getByRole('button', { name: '用最新草稿试用' })); expect(screen.getByText('草稿预览 · 修订 2')).toBeVisible(); expect(prompt).toHaveValue('还未发送的问题'); expect(screen.getByRole('button', { name: '发送', exact: true })).toBeEnabled(); }); it('keeps preview text across mode switches and saves a budget independently of the draft', async () => { const configured = { ...draft, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'test-model' } }; api.list.mockResolvedValue({ agents: [configured], next_cursor: null }); const base = api.call.getMockImplementation()!; api.call.mockImplementation(async (operation, input) => operation === 'saveBudget' ? { ...input, daily_committed_points: '0.00' } : base(operation, input)); open(); fireEvent.click(await screen.findByRole('button', { name: /写作搭档/ })); await waitFor(() => expect(screen.getByLabelText('消息')).toBeEnabled()); fireEvent.change(screen.getByLabelText('消息'), { target: { value: '待发送的问题' } }); fireEvent.change(screen.getByLabelText('角色与指令'), { target: { value: '未保存的指令' } }); fireEvent.click(screen.getByRole('button', { name: '对话', exact: true })); fireEvent.click(screen.getByRole('button', { name: '编辑', exact: true })); expect(screen.getByLabelText('消息')).toHaveValue('待发送的问题'); expect(screen.getByLabelText('角色与指令')).toHaveValue('未保存的指令'); fireEvent.click(screen.getByRole('button', { name: '限制', exact: true })); fireEvent.change(screen.getByLabelText('每日上限(词元点数)'), { target: { value: '12' } }); fireEvent.click(screen.getByRole('button', { name: '保存费用上限' })); await waitFor(() => expect(api.call).toHaveBeenCalledWith('saveBudget', expect.objectContaining({ daily_limit_points: '12' }))); expect(api.save).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: '指令', exact: true })); expect(screen.getByLabelText('角色与指令')).toHaveValue('未保存的指令'); expect(screen.getByLabelText('消息')).toHaveValue('待发送的问题'); }); it('keeps an existing preview when the saved draft clears its model selection', async () => { const configured = { ...draft, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'test-model' } }; api.list.mockResolvedValue({ agents: [configured], next_cursor: null }); api.save.mockResolvedValue({ ...draft, draft_revision: 2 }); open(); fireEvent.click(await screen.findByRole('button', { name: /写作搭档/ })); await waitFor(() => expect(screen.getByLabelText('消息')).toBeEnabled()); const prompt = screen.getByLabelText('消息'); fireEvent.change(prompt, { target: { value: '还没有发送' } }); fireEvent.click(screen.getByRole('button', { name: '能力', exact: true })); fireEvent.change(screen.getByLabelText('模型'), { target: { value: '' } }); fireEvent.click(screen.getByRole('button', { name: '保存草稿' })); await screen.findByText('云端已保存'); expect(screen.getByLabelText('消息')).toBe(prompt); expect(prompt).toHaveValue('还没有发送'); expect(screen.getByRole('button', { name: '用最新草稿试用' })).toBeDisabled(); }); it('searches capabilities while retaining existing selections outside the results', async () => { const configured = { ...draft, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'test-model', tools: ['missing-tool'] } }; api.list.mockResolvedValue({ agents: [configured], next_cursor: null }); const base = api.call.getMockImplementation()!; api.call.mockImplementation(async (operation, input) => operation === 'catalog' ? { models: [{ id: 'test-model', name: '写作模型' }], resources: { tools: [{ key: 'search', name: '资料搜索', description: '搜索素材' }, { key: 'clock', name: '当前时间' }] }, pricing: null, } : base(operation, input)); api.save.mockImplementation(async (_slug, input) => ({ ...configured, ...input, draft_revision: 2 })); open(); fireEvent.click(await screen.findByRole('button', { name: /写作搭档/ })); fireEvent.click(screen.getByRole('button', { name: '能力', exact: true })); expect(await screen.findByRole('checkbox', { name: /missing-tool/ })).toBeChecked(); fireEvent.click(screen.getByRole('button', { name: '选择工具', exact: true })); fireEvent.change(screen.getByLabelText('搜索工具'), { target: { value: '资料' } }); expect(screen.queryByRole('checkbox', { name: '当前时间' })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('checkbox', { name: /资料搜索/ })); fireEvent.click(screen.getByRole('button', { name: '完成选择' })); expect(screen.getByRole('checkbox', { name: /missing-tool/ })).toBeChecked(); fireEvent.click(screen.getByRole('button', { name: '保存草稿' })); await waitFor(() => expect(api.save).toHaveBeenCalledWith(draft.slug, expect.objectContaining({ configuration: expect.objectContaining({ tools: ['missing-tool', 'search'] }) }))); });