Files
makelore/tests/unit/cloud-knowledge-import.test.tsx
T

346 lines
24 KiB
TypeScript

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<string, CloudAttachment[]>;
const imported = vi.fn<(file: CloudKnowledgeFile) => Promise<void>>();
const pending = vi.fn();
const close = vi.fn();
const props = { slug, kbId: 'kb_own', kbName: '我的观察记录', busy: false, act: async (task: () => Promise<void>) => 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<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((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(<CloudKnowledgeAttachmentPicker {...props} />);
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(<CloudKnowledgeAttachmentPicker {...props} />);
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(<CloudKnowledgeAttachmentPicker {...props} />);
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(<CloudKnowledgeAttachmentPicker {...props} />); 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(<CloudKnowledgeAttachmentPicker {...props} />);
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(<CloudKnowledgeAttachmentPicker {...props} />);
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(<CloudKnowledgeAttachmentPicker {...props} />); 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<CloudKnowledgeFile>();
const original = api.call.getMockImplementation()!;
api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' && input.attachment_id === 'a' ? first.promise : original(operation, input));
render(<CloudKnowledgeAttachmentPicker {...props} />); 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<CloudKnowledgeFile>();
const original = api.call.getMockImplementation()!;
api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' ? receipt.promise : original(operation, input));
const view = render(<CloudKnowledgeAttachmentPicker {...props} canRead={false} />);
expect(api.call).not.toHaveBeenCalled(); expect(api.list).not.toHaveBeenCalled();
view.rerender(<CloudKnowledgeAttachmentPicker {...props} />); await chooseChat(); await chooseFile('观察报告.md');
fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' }));
view.rerender(<CloudKnowledgeAttachmentPicker {...props} canRead={false} />);
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(<CloudKnowledgeAttachmentPicker {...props} />);
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<CloudKnowledgeFile>();
const original = api.call.getMockImplementation()!;
api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' ? response.promise : original(operation, input));
const view = render(<CloudKnowledgeAttachmentPicker {...props} />);
await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf');
fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' }));
view.rerender(<CloudKnowledgeAttachmentPicker {...props} kbId="kb_other" kbName="其他资料" />);
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<CloudKnowledgeFile>();
const original = api.call.getMockImplementation()!;
api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' ? response.promise : original(operation, input));
const view = render(<CloudKnowledgeAttachmentPicker {...props} />);
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<CloudKnowledgeFile>();
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(<CloudKnowledgePanel slug={slug} onCreated={created} />);
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(<CloudKnowledgePanel slug={slug} onCreated={created} refreshToken={1} />);
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(<CloudKnowledgeAttachmentPicker {...props} />); 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(<CloudKnowledgeAttachmentPicker {...props} />);
await chooseChat(); await chooseFile('观察报告.md');
view.rerender(<CloudKnowledgeAttachmentPicker {...props} busy />);
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(<CloudKnowledgeAttachmentPicker {...props} onRefreshKnowledge={refresh} />);
await chooseChat(); await chooseFile('观察报告.md');
fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' }));
await screen.findByText(/导入结果尚未确认:结果未确认/);
const request = imports()[0];
view.rerender(<CloudKnowledgeAttachmentPicker {...props} busy onRefreshKnowledge={refresh} />);
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(<CloudKnowledgeAttachmentPicker {...props} onRefreshKnowledge={refresh} />);
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(<CloudKnowledgeAttachmentPicker {...props} />);
await chooseChat(); await chooseFile('观察报告.md');
fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' }));
await screen.findByText(/导入结果尚未确认:附件已无法读取/);
view.rerender(<CloudKnowledgeAttachmentPicker {...props} busy />);
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(<CloudKnowledgeAttachmentPicker {...props} />);
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);
});