import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { CloudKnowledgeAttachmentPicker } from '@/pages/CloudAgents/CloudKnowledgeAttachmentPicker'; import { CloudKnowledgePanel } from '@/pages/CloudAgents/CloudKnowledge'; import type { CloudAttachment, CloudKnowledgeFile, CloudThread } from '../../shared/cloud-agents'; const api = vi.hoisted(() => ({ call: vi.fn(), list: vi.fn() })); vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api })); const slug = 'ml-' + 'a'.repeat(32), otherSlug = 'ml-' + 'b'.repeat(32); const threads: CloudThread[] = [ { thread_id: 'spring', title: '春天的观察讨论', agent_slug: slug, client_thread_id: null, updated_at: '2026-10-13T00:00:00Z', mode: 'published', run_id: null, status: 'completed', unread: false }, { thread_id: 'reading', title: '图书阅读记录', agent_slug: otherSlug, client_thread_id: null, updated_at: '2026-10-12T00:00:00Z', mode: 'preview', run_id: null, status: 'completed', unread: false }, ]; function attachment(id: string, name: string, size = 1536): CloudAttachment { return { file_id: id, file_name: name, file_size: size, path: '/attachments/' + name, original_path: '/attachments/' + name }; } function knowledgeFile(id: string): CloudKnowledgeFile { return { file_id: 'knowledge-' + id, name: id + '.txt', size: 1536, status: 'uploaded', available: false, chunk_count: 0, error: null }; } let sourceFiles: Record; const imported = vi.fn<(file: CloudKnowledgeFile) => Promise>(); const pending = vi.fn(); const close = vi.fn(); const props = { slug, kbId: 'kb_own', kbName: '我的观察记录', busy: false, act: async (task: () => Promise) => task(), onImported: imported, onPendingChange: pending, onClose: close }; beforeEach(() => { vi.resetAllMocks(); imported.mockResolvedValue(); sourceFiles = { spring: [attachment('a', '观察报告.md'), attachment('b', '植物资料.pdf')], reading: [attachment('a', '阅读笔记.txt')] }; api.list.mockResolvedValue({ agents: [{ slug, name: '资料小助手' }], next_cursor: null }); api.call.mockImplementation(async (operation: string, input: { thread_id?: string; attachment_id?: string }) => { if (operation === 'threads') return { threads, next_offset: null }; if (operation === 'received') return { agents: [{ slug: otherSlug, name: '阅读搭档' }] }; if (operation === 'attachments') return { attachments: sourceFiles[input.thread_id!] }; if (operation === 'importKnowledgeAttachment') return knowledgeFile(input.attachment_id!); throw new Error('Unexpected operation ' + operation); }); }); afterEach(cleanup); async function chooseChat(title = threads[0].title) { fireEvent.click(await screen.findByRole('button', { name: '选择对话 ' + title })); } async function chooseFile(name: string) { fireEvent.click(await screen.findByRole('checkbox', { name: '选择文件 ' + name })); } function imports() { return api.call.mock.calls.filter(([operation]) => operation === 'importKnowledgeAttachment').map(([, input]) => input); } function deferred() { let resolve!: (value: T) => void; let reject!: (error: Error) => void; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; } it('searches named and dated chats, keeps selections across chats, and imports only explicitly selected attachments', async () => { const original = api.call.getMockImplementation()!; api.call.mockImplementation((operation, input) => operation === 'threads' ? Promise.resolve(input.offset ? { threads: [threads[1]], next_offset: null } : { threads: [threads[0]], next_offset: 50 }) : original(operation, input)); render(); expect(screen.getByRole('dialog', { name: '从聊天选择文件' })).toHaveTextContent('导入到「我的观察记录」'); await screen.findByText(/阅读搭档 · 2026/); fireEvent.change(screen.getByRole('textbox', { name: '搜索对话' }), { target: { value: '资料小助手' } }); expect(screen.queryByRole('button', { name: '选择对话 图书阅读记录' })).not.toBeInTheDocument(); await chooseChat(); expect(await screen.findByText('MD · 2 KB')).toBeVisible(); fireEvent.change(screen.getByRole('textbox', { name: '搜索文件' }), { target: { value: '观察' } }); expect(screen.queryByRole('checkbox', { name: '选择文件 植物资料.pdf' })).not.toBeInTheDocument(); await chooseFile('观察报告.md'); fireEvent.change(screen.getByRole('textbox', { name: '搜索对话' }), { target: { value: '阅读' } }); await chooseChat(threads[1].title); await chooseFile('阅读笔记.txt'); expect(within(screen.getByRole('list', { name: '已选聊天文件' })).getAllByRole('listitem')).toHaveLength(2); expect(imports()).toHaveLength(0); fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); await waitFor(() => expect(close).toHaveBeenCalledTimes(1)); expect(imports()).toEqual([ expect.objectContaining({ slug, kb_id: 'kb_own', thread_id: 'spring', attachment_id: 'a' }), expect.objectContaining({ slug, kb_id: 'kb_own', thread_id: 'reading', attachment_id: 'a' }), ]); expect(new Set(imports().map(input => input.operation_id)).size).toBe(2); expect(imported).toHaveBeenCalledTimes(2); expect(api.call.mock.calls.some(([operation]) => ['history', 'files', 'deleteAttachment'].includes(operation))).toBe(false); expect(pending).toHaveBeenLastCalledWith(false); }); it('retries only failed files with their original identity, retaining successful files and processing callbacks', async () => { const original = api.call.getMockImplementation()!; let failure = true; api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' && input.attachment_id === 'b' && failure ? Promise.reject(new Error('连接超时')) : original(operation, input)); render(); await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf'); fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); await screen.findByText(/导入结果尚未确认:连接超时/); expect(close).not.toHaveBeenCalled(); expect(imported).toHaveBeenCalledTimes(1); expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeDisabled(); expect(screen.getByRole('button', { name: '选择对话 图书阅读记录' })).toBeDisabled(); fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); expect(close).not.toHaveBeenCalled(); const first = imports(); failure = false; fireEvent.click(screen.getByRole('button', { name: '重试失败的 1 个文件' })); await waitFor(() => expect(close).toHaveBeenCalledTimes(1)); expect(imports()).toHaveLength(3); expect(imports()[2]).toEqual(first[1]); expect(imported).toHaveBeenCalledTimes(2); }); it('never reimports a confirmed file after a processing callback failure and permits a later independent batch', async () => { imported.mockRejectedValueOnce(new Error('处理失败')); render(); await chooseChat(); await chooseFile('观察报告.md'); fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' })); await screen.findByText(/文件已导入,处理未全部启动/); expect(imports()).toHaveLength(1); expect(close).not.toHaveBeenCalled(); expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeEnabled(); await chooseFile('植物资料.pdf'); fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' })); await waitFor(() => expect(close).toHaveBeenCalledTimes(1)); expect(imports().map(input => input.attachment_id)).toEqual(['a', 'b']); }); it('shows file types and sizes, disables unsupported legacy formats and oversize files, and accepts supported files at the size boundary', async () => { sourceFiles.spring = [attachment('legacy-doc', '旧文档.doc'), attachment('legacy-xls', '旧表格.xls'), attachment('markdown', '资料.markdown'), attachment('image', '图片.png'), attachment('large', '过大的资料.pdf', 5 * 1024 * 1024 + 1), attachment('boundary', '可导入.DOCX', 5 * 1024 * 1024)]; render(); await chooseChat(); for (const name of ['旧文档.doc', '旧表格.xls', '资料.markdown', '图片.png', '过大的资料.pdf']) expect(await screen.findByRole('checkbox', { name: '选择文件 ' + name })).toBeDisabled(); expect(screen.getAllByText('此格式暂不支持知识库导入')).toHaveLength(4); expect(screen.getByText('超过 5 MB,暂不能导入')).toBeVisible(); expect(screen.getByText('DOCX · 5.0 MB')).toBeVisible(); await chooseFile('可导入.DOCX'); fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' })); await waitFor(() => expect(close).toHaveBeenCalledOnce()); expect(imports()).toEqual([expect.objectContaining({ attachment_id: 'boundary' })]); }); it('distinguishes loading, empty chats, chat read failures and a successful retry', async () => { const loading = deferred<{ threads: CloudThread[]; next_offset: null }>(); const original = api.call.getMockImplementation()!; let mode = 'loading'; api.call.mockImplementation((operation, input) => { if (operation !== 'threads') return original(operation, input); return mode === 'loading' ? loading.promise : mode === 'error' ? Promise.reject(new Error('目录离线')) : Promise.resolve({ threads, next_offset: null }); }); const view = render(); expect(screen.getByText('正在读取对话…')).toBeVisible(); expect(screen.queryByText('还没有个人智能体对话。')).not.toBeInTheDocument(); await act(async () => loading.resolve({ threads: [], next_offset: null })); expect(screen.getByText('还没有个人智能体对话。')).toBeVisible(); view.unmount(); mode = 'error'; render(); await screen.findByText('对话读取失败:目录离线'); expect(screen.queryByText('还没有个人智能体对话。')).not.toBeInTheDocument(); mode = 'ready'; fireEvent.click(screen.getByRole('button', { name: '重试读取对话' })); await screen.findByRole('button', { name: '选择对话 春天的观察讨论' }); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); it('distinguishes file loading, failures, empty files and search results while ignoring a late response from a previous chat', async () => { const late = deferred<{ attachments: CloudAttachment[] }>(); const original = api.call.getMockImplementation()!; let readingFails = true; api.call.mockImplementation((operation, input) => operation === 'attachments' ? input.thread_id === 'spring' ? late.promise : readingFails ? Promise.reject(new Error('文件离线')) : Promise.resolve({ attachments: [] }) : original(operation, input)); render(); await chooseChat(); expect(screen.getByText('正在读取文件…')).toBeVisible(); expect(screen.queryByText('这段对话还没有上传文件。')).not.toBeInTheDocument(); await chooseChat(threads[1].title); await screen.findByText('文件读取失败:文件离线'); await act(async () => late.resolve({ attachments: sourceFiles.spring })); expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); readingFails = false; fireEvent.click(screen.getByRole('button', { name: '重试读取文件' })); await screen.findByText('这段对话还没有上传文件。'); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); fireEvent.change(screen.getByRole('textbox', { name: '搜索对话' }), { target: { value: '不存在' } }); expect(screen.getByText('没有匹配的对话。')).toBeVisible(); }); it('guards X, escape and selection during an in-flight import, then closes only after all files finish', async () => { const first = deferred(); const original = api.call.getMockImplementation()!; api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' && input.attachment_id === 'a' ? first.promise : original(operation, input)); render(); await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf'); fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); expect(imports()).toHaveLength(1); expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeDisabled(); expect(screen.getByRole('checkbox', { name: '选择文件 观察报告.md' })).toBeDisabled(); expect(screen.queryByRole('button', { name: '放弃重试', exact: true })).not.toBeInTheDocument(); fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); expect(close).not.toHaveBeenCalled(); await act(async () => first.resolve(knowledgeFile('a'))); await waitFor(() => expect(close).toHaveBeenCalledOnce()); expect(imports()).toHaveLength(2); expect(imported).toHaveBeenCalledTimes(2); }); it('does not read or import without management permission and resumes a confirmed receipt without importing it twice', async () => { const receipt = deferred(); const original = api.call.getMockImplementation()!; api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' ? receipt.promise : original(operation, input)); const view = render(); expect(api.call).not.toHaveBeenCalled(); expect(api.list).not.toHaveBeenCalled(); view.rerender(); await chooseChat(); await chooseFile('观察报告.md'); fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' })); view.rerender(); await act(async () => receipt.resolve(knowledgeFile('a'))); expect(imports()).toHaveLength(1); expect(imported).not.toHaveBeenCalled(); expect(close).not.toHaveBeenCalled(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(pending).toHaveBeenLastCalledWith(true); view.rerender(); fireEvent.click(screen.getByRole('button', { name: '继续导入 1 个文件' })); await waitFor(() => expect(close).toHaveBeenCalledOnce()); expect(imports()).toHaveLength(1); expect(imported).toHaveBeenCalledOnce(); }); it('ignores an old-target response and stops the old batch after a knowledge-base scope change', async () => { const response = deferred(); const original = api.call.getMockImplementation()!; api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' ? response.promise : original(operation, input)); const view = render(); await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf'); fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); view.rerender(); await act(async () => response.resolve(knowledgeFile('a'))); expect(imports()).toHaveLength(1); expect(imported).not.toHaveBeenCalled(); expect(close).not.toHaveBeenCalled(); expect(screen.getByRole('dialog')).toHaveTextContent('导入到「其他资料」'); expect(screen.queryByRole('list', { name: '文件导入结果' })).not.toBeInTheDocument(); }); it('does not continue a batch or notify the old owner after unmount', async () => { const response = deferred(); const original = api.call.getMockImplementation()!; api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' ? response.promise : original(operation, input)); const view = render(); await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf'); fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); view.unmount(); await act(async () => response.resolve(knowledgeFile('a'))); expect(imports()).toHaveLength(1); expect(imported).not.toHaveBeenCalled(); expect(close).not.toHaveBeenCalled(); expect(pending).toHaveBeenLastCalledWith(false); }); it('hides the portal after a confirmed loss of ownership so the parent can discard pending requests without importing again', async () => { const response = deferred(); const original = api.call.getMockImplementation()!; let owned = true; api.call.mockImplementation((operation, input) => { if (operation === 'knowledge') return Promise.resolve({ databases: owned ? [{ kb_id: 'kb_own', name: '我的观察记录' }] : [], models: [] }); if (operation === 'knowledgeFiles') return Promise.resolve({ files: [], next_offset: null }); if (operation === 'importKnowledgeAttachment') return response.promise; return original(operation, input); }); const created = vi.fn(); const view = render(); await screen.findByRole('option', { name: '我的观察记录' }); fireEvent.change(screen.getByRole('combobox', { name: '管理的知识库' }), { target: { value: 'kb_own' } }); fireEvent.click(await screen.findByRole('button', { name: '从聊天选择文件' })); await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf'); fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); owned = false; view.rerender(); await waitFor(() => expect(screen.queryByRole('dialog', { name: '从聊天选择文件' })).not.toBeInTheDocument()); await act(async () => response.resolve(knowledgeFile('a'))); const discard = await screen.findByRole('button', { name: '放弃未确认操作并重新选择' }); await waitFor(() => expect(discard).toBeEnabled()); expect(imports()).toHaveLength(1); expect(api.call.mock.calls.some(([operation]) => operation === 'processKnowledge')).toBe(false); fireEvent.click(discard); expect(screen.queryByRole('button', { name: '放弃未确认操作并重新选择' })).not.toBeInTheDocument(); expect(screen.getByRole('combobox', { name: '管理的知识库' })).toHaveValue(''); }); it('allows cancelling unsubmitted selections and does not mutate knowledge merely by choosing files', async () => { render(); await chooseChat(); await chooseFile('观察报告.md'); expect(pending).toHaveBeenLastCalledWith(true); fireEvent.click(screen.getByRole('button', { name: '关闭文件选择' })); expect(close).toHaveBeenCalledOnce(); expect(imports()).toHaveLength(0); expect(imported).not.toHaveBeenCalled(); }); it.each(['button', 'escape'])('allows %s dismissal during a catalog failure before any import has been sent', async control => { const view = render(); await chooseChat(); await chooseFile('观察报告.md'); view.rerender(); expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeEnabled(); if (control === 'button') fireEvent.click(screen.getByRole('button', { name: '关闭文件选择' })); else fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); await waitFor(() => expect(close).toHaveBeenCalledOnce()); expect(imports()).toHaveLength(0); }); it('offers a read-only catalog refresh while an uncertain import is blocked, then retries only the original operation', async () => { const original = api.call.getMockImplementation()!; let fails = true; api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' && fails ? Promise.reject(new Error('结果未确认')) : original(operation, input)); const refresh = vi.fn(); const view = render(); await chooseChat(); await chooseFile('观察报告.md'); fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' })); await screen.findByText(/导入结果尚未确认:结果未确认/); const request = imports()[0]; view.rerender(); expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeDisabled(); expect(screen.getByRole('button', { name: '重试失败的 1 个文件' })).toBeDisabled(); fireEvent.click(screen.getByRole('button', { name: '刷新知识库', exact: true })); expect(refresh).toHaveBeenCalledOnce(); expect(imports()).toHaveLength(1); fails = false; view.rerender(); fireEvent.click(screen.getByRole('button', { name: '重试失败的 1 个文件' })); await waitFor(() => expect(close).toHaveBeenCalledOnce()); expect(imports()).toEqual([request, request]); expect(imported).toHaveBeenCalledOnce(); }); it('requires explicit confirmation to abandon a retry during a catalog failure, while keeping the request through cancelling that confirmation', async () => { const original = api.call.getMockImplementation()!; api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' ? Promise.reject(new Error('附件已无法读取')) : original(operation, input)); const view = render(); await chooseChat(); await chooseFile('观察报告.md'); fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' })); await screen.findByText(/导入结果尚未确认:附件已无法读取/); view.rerender(); fireEvent.click(screen.getByRole('button', { name: '放弃重试', exact: true })); expect(screen.getByText('这只会结束当前页面的重试。已保存的文件会保留,可刷新知识库确认。')).toBeVisible(); expect(close).not.toHaveBeenCalled(); expect(pending).toHaveBeenLastCalledWith(true); fireEvent.click(screen.getByRole('button', { name: '继续保留' })); expect(screen.getByRole('button', { name: '重试失败的 1 个文件' })).toBeDisabled(); expect(screen.getByRole('list', { name: '文件导入结果' })).toHaveTextContent('观察报告.md'); fireEvent.click(screen.getByRole('button', { name: '放弃重试', exact: true })); fireEvent.click(screen.getByRole('button', { name: '确认放弃重试' })); expect(close).toHaveBeenCalledOnce(); expect(pending).toHaveBeenLastCalledWith(false); expect(imports()).toHaveLength(1); expect(imported).not.toHaveBeenCalled(); expect(screen.queryByRole('list', { name: '文件导入结果' })).not.toBeInTheDocument(); expect(screen.queryByRole('list', { name: '已选聊天文件' })).not.toBeInTheDocument(); }); it('exits a partly successful batch with a permanent source error without deleting or replaying successful imports', async () => { const original = api.call.getMockImplementation()!; api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' && input.attachment_id === 'b' ? Promise.reject(new Error('原附件已经删除')) : original(operation, input)); render(); await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf'); fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); await screen.findByText(/导入结果尚未确认:原附件已经删除/); expect(imported).toHaveBeenCalledOnce(); expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeDisabled(); expect(screen.queryByText(/可继续切换对话选择/)).not.toBeInTheDocument(); const before = api.call.mock.calls.length; fireEvent.click(screen.getByRole('button', { name: '放弃重试', exact: true })); fireEvent.click(screen.getByRole('button', { name: '确认放弃重试' })); expect(close).toHaveBeenCalledOnce(); expect(pending).toHaveBeenLastCalledWith(false); expect(imported).toHaveBeenCalledOnce(); expect(imports()).toHaveLength(2); expect(api.call).toHaveBeenCalledTimes(before); });