import { test, expect, type Page } from '@playwright/test'; import { build } from 'vite'; import { createServer, type Server } from 'node:http'; import { mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve, sep } from 'node:path'; import { createRequire } from 'node:module'; import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CloudKnowledge, type CloudKnowledgeFile, type CloudThread, type CloudAttachment } from '../../shared/cloud-agents'; let server: Server; let baseUrl: string; let fixtureDir: string; const fixtureRequire = createRequire(resolve('tests/e2e/cloud-agent-knowledge.spec.ts')); const viteDependencies = createRequire(fixtureRequire.resolve('vite/package.json')); test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, video: 'off' }); // Render the real editor while replacing only the Main IPC boundary. No Electron, // native picker, real upload or cloud request is started by this suite. test.beforeAll(async () => { test.setTimeout(90_000); fixtureDir = await realpath(await mkdtemp(join(tmpdir(), 'makelore-knowledge-fixture-'))); await symlink(resolve('node_modules'), join(fixtureDir, 'node_modules'), 'dir'); const html = join(fixtureDir, 'index.html'); const output = join(fixtureDir, 'dist'); await writeFile(html, '
'); const entry = resolve('tests/e2e/__cloud-agent-knowledge-harness.tsx'); // Build the virtual entry once. A static fixture needs no Vite dev server, // background dependency discovery, file watcher, or HMR connection. await build({ configFile: false, root: fixtureDir, publicDir: false, logLevel: 'warn', resolve: { alias: { '@': resolve('src') } }, esbuild: { jsx: 'automatic' }, css: { postcss: resolve('.') }, build: { outDir: output, emptyOutDir: true, rollupOptions: { input: html } }, plugins: [{ name: 'isolated-cloud-agent-knowledge', enforce: 'pre', resolveId(id) { if (id === '/__cloud-agent-knowledge-harness.tsx') return entry; }, load(id) { if (id === entry) return ` import { createRoot } from 'react-dom/client'; import { createMemoryRouter, RouterProvider } from 'react-router-dom'; import CloudAgents from ${JSON.stringify(resolve('src/pages/CloudAgents/index.tsx'))}; import ${JSON.stringify(resolve('src/styles/globals.css'))}; const router = createMemoryRouter([{ path: '/cloud-agents', element: }], { initialEntries: ['/cloud-agents?view=mine'] }); createRoot(document.getElementById('root')).render(
); `; if (id.endsWith('/src/stores/auth.ts')) return 'export function useAuthStore(select) { return select({ user: { userId: "knowledge-fixture" } }); }'; }, }], }); const mime: Record = { html: 'text/html', js: 'text/javascript', css: 'text/css', svg: 'image/svg+xml', png: 'image/png', woff2: 'font/woff2', woff: 'font/woff' }; server = createServer(async (req, res) => { const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://127.0.0.1').pathname); const file = resolve(output, '.' + (pathname === '/' ? '/index.html' : pathname)); if (!file.startsWith(output + sep)) { res.writeHead(403); res.end(); return; } try { const bytes = await readFile(file); res.setHeader('Content-Type', mime[file.split('.').pop()!] ?? 'application/octet-stream'); res.end(bytes); } catch { res.writeHead(404); res.end(); } }); await new Promise((done, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', done); }); const address = server.address(); if (!address || typeof address === 'string') throw new Error('Fixture HTTP address is unavailable'); baseUrl = `http://127.0.0.1:${address.port}`; }); test.afterAll(async () => { try { if (server?.listening) { server.closeAllConnections(); await new Promise((done, reject) => server.close(error => error ? reject(error) : done())); } } finally { try { if (fixtureDir) await rm(fixtureDir, { recursive: true, force: true }); } finally { (viteDependencies('esbuild') as { stop(): void }).stop(); } } }); const agent: CloudAgentDraft = { slug: 'ml-' + 'a'.repeat(32), name: '资料小助手', purpose: '用我的资料回答问题。', system_prompt: '请先查资料,再用简单的中文回答。', draft_revision: 1, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'fixture-model' }, published_version: null, enabled: true, updated_at: '2026-10-12T00:00:00Z', }; // Existing facade wire format deliberately has no source/can_manage metadata. const libraries: CloudKnowledge[] = [ { kb_id: 'kb_fixturescience', name: '科学知识', description: '平台开放的科学资料', embedding_model: 'fixture-embedding' }, { kb_id: 'kb_fixturereading', name: '阅读知识', description: '平台开放的阅读资料', embedding_model: 'fixture-embedding' }, { kb_id: 'kb_fixturepersonal', name: '我的观察记录', description: '自己的资料', embedding_model: 'fixture-embedding' }, ]; const sourceThreads: CloudThread[] = [ { thread_id: 'thread-spring', client_thread_id: 'client-spring', agent_slug: agent.slug, title: '春天的观察讨论', mode: 'preview', updated_at: '2026-10-13T01:00:00Z', run_id: null, status: 'completed', unread: false }, { thread_id: 'thread-reading', client_thread_id: 'client-reading', agent_slug: agent.slug, title: '图书阅读记录', mode: 'preview', updated_at: '2026-10-13T00:00:00Z', run_id: null, status: 'completed', unread: false }, ]; const sourceAttachments: Record = { 'thread-spring': [ { file_id: 'attachment-observations', file_name: '春天观察报告.md', file_size: 1536, path: 'attachments/spring/report.md', original_path: 'attachments/spring/report.md' }, { file_id: 'attachment-plants', file_name: '植物清单.csv', file_size: 2048, path: 'attachments/spring/plants.csv', original_path: 'attachments/spring/plants.csv' }, ], 'thread-reading': [ { file_id: 'attachment-reading', file_name: '阅读笔记.pdf', file_size: 2048, path: 'attachments/reading/notes.pdf', original_path: 'attachments/reading/notes.pdf' }, ], }; type FixtureCall = { path: string; method: string; input: Record | null }; type KnowledgeFixture = { agent: CloudAgentDraft; libraries: CloudKnowledge[]; files: Record; calls: FixtureCall[]; ownedIds: string[]; upload: 'success' | 'cancel' | 'fail'; processFailure: boolean; threads: CloudThread[]; attachments: Record; importFailures: string[]; }; type FixtureWindow = Window & { __knowledgeFixture: KnowledgeFixture }; async function installFixture(page: Page) { page.on('pageerror', error => { throw error; }); await page.addInitScript(({ agent, libraries, sourceThreads, sourceAttachments }) => { const scope = window as unknown as FixtureWindow; const stored = sessionStorage.getItem('knowledge-fixture'); const state: KnowledgeFixture = stored ? JSON.parse(stored) : { agent, libraries, files: {}, calls: [], ownedIds: ['kb_fixturepersonal'], upload: 'success', processFailure: false, threads: sourceThreads, attachments: sourceAttachments, importFailures: [], }; scope.__knowledgeFixture = state; const persist = () => sessionStorage.setItem('knowledge-fixture', JSON.stringify(state)); const result = (json: unknown) => { persist(); return { ok: true, data: { status: 200, ok: true, json: structuredClone(json) } }; }; const failure = (message: string) => { persist(); return { ok: true, data: { status: 503, ok: false, json: { error: message } } }; }; const managed = (id: unknown) => state.ownedIds.includes(String(id)); Object.defineProperty(window, 'electron', { value: { ipcRenderer: { invoke: async (channel: string, request?: { path?: string; method?: string; body?: string }) => { if (channel === 'hostapi:token') return 'fixture'; if (channel === 'hostapi:base-url') return 'http://fixture.invalid'; if (channel !== 'hostapi:fetch') throw new Error('Unexpected fixture IPC ' + channel); const path = request?.path ?? ''; const method = request?.method ?? 'GET'; const input = request?.body ? JSON.parse(request.body) as Record : null; state.calls.push({ path, method, input }); if (path === '/api/cloud-agents/recovery') return result({ recent: null, pending: [] }); if (path === '/api/cloud-agents/recovery/recent') return result({ saved: true }); if (path.startsWith('/api/cloud-agents/agents')) { if (path === '/api/cloud-agents/agents') return result({ agents: [state.agent], next_cursor: null }); if (method === 'PATCH') Object.assign(state.agent, input, { draft_revision: state.agent.draft_revision + 1 }); return result(state.agent); } if (path === '/api/cloud-agents/knowledge/pick') { if (!managed(input?.kb_id)) throw new Error('Document upload reached a read-only library'); if (state.upload === 'cancel') return result(null); if (state.upload === 'fail') return failure('上传服务暂不可用'); const file: CloudKnowledgeFile = { file_id: 'observations-file', name: '春天观察记录.md', size: 1200, status: 'uploaded', error: null, chunk_count: 0, available: false }; state.files[String(input?.kb_id)] = [file]; return result(file); } if (path !== '/api/cloud-agents/actions') throw new Error('Unexpected fixture request ' + path); const args = input?.input as Record | undefined; switch (input?.operation) { case 'catalog': return result({ models: [{ id: 'fixture-model', name: '创作模型' }], resources: { knowledges: state.libraries.map(kb => ({ key: kb.kb_id, name: kb.name, description: kb.description })) }, pricing: null }); case 'knowledge': return result({ databases: state.libraries.filter(kb => managed(kb.kb_id)), models: [{ id: 'fixture-embedding', name: '平台向量模型', dimension: 768 }] }); case 'access': return result({ published_version: null, enabled: true, share_url: 'niancode://agents/' + agent.slug, api_url: 'http://fixture.invalid', grants: [], applications: [], versions: [] }); case 'createKnowledge': { const created: CloudKnowledge = { kb_id: 'kb_fixturecreated', name: String(args?.name), description: '', embedding_model: String(args?.embedding_model) }; state.libraries.push(created); state.ownedIds.push(created.kb_id); return result(created); } case 'knowledgeFiles': if (!managed(args?.kb_id)) throw new Error('Document management reached a read-only library'); return result({ files: state.files[String(args?.kb_id)] ?? [], next_offset: null }); case 'threads': return result({ threads: state.threads, next_offset: null }); case 'received': return result({ agents: [] }); case 'attachments': return result({ attachments: state.attachments[String(args?.thread_id)] ?? [] }); case 'importKnowledgeAttachment': { if (!managed(args?.kb_id)) throw new Error('Attachment import reached a read-only library'); const attachment = state.attachments[String(args?.thread_id)]?.find(file => file.file_id === args?.attachment_id); if (!attachment) throw new Error('Unknown fixture source attachment'); if (state.importFailures.includes(attachment.file_id)) return failure('植物清单导入暂时失败'); const file: CloudKnowledgeFile = { file_id: 'imported-' + attachment.file_id, name: attachment.file_name, size: attachment.file_size, status: 'uploaded', error: null, chunk_count: 0, available: false }; state.files[String(args?.kb_id)] = [file, ...(state.files[String(args?.kb_id)] ?? []).filter(item => item.file_id !== file.file_id)]; return result(file); } case 'processKnowledge': { if (!managed(args?.kb_id)) throw new Error('Processing reached a read-only library'); if (state.processFailure) return failure('处理请求暂不可用'); const file = state.files[String(args?.kb_id)]?.find(file => file.file_id === args?.file_id); if (!file) throw new Error('Unknown fixture document'); file.processing_task = { task_id: 'processing-task', status: 'pending', error: null }; return result({ task_id: 'processing-task' }); } default: throw new Error('Unexpected fixture operation ' + String(input?.operation)); } }, } } }); }, { agent, libraries, sourceThreads, sourceAttachments }); } async function openKnowledge(page: Page) { await page.getByRole('button', { name: '继续制作', exact: true }).click(); const editor = page.getByTestId('cloud-agent-editor'); await editor.getByRole('tab', { name: '高级设置', exact: true }).click(); const knowledge = editor.getByRole('group', { name: '知识库', exact: true }); await knowledge.scrollIntoViewIfNeeded(); await expect(knowledge.getByRole('combobox', { name: '选择知识库', exact: true })).toBeVisible(); await expect(editor.getByRole('combobox', { name: '模型', exact: true })).toBeEnabled(); return { editor, knowledge }; } async function calls(page: Page) { return page.evaluate(() => (window as unknown as FixtureWindow).__knowledgeFixture.calls); } async function expectNoClipping(page: Page) { expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); const clipped = await page.getByTestId('cloud-agent-editor').evaluate(editor => { const scroll = editor.querySelector('.agent-config-scroll')!; const edge = scroll.getBoundingClientRect(); return [...scroll.querySelectorAll('button, input, textarea, select, h2, summary, fieldset')] .filter(element => element.getClientRects().length > 0 && !element.classList.contains('sr-only')) .filter(element => { const bounds = element.getBoundingClientRect(); return bounds.left < edge.left - 1 || bounds.right > edge.right + 1; }) .map(element => element.getAttribute('aria-label') ?? element.textContent?.trim()); }); expect(clipped).toEqual([]); } async function expectKnowledgeLayout(page: Page, width: number) { const layout = page.locator('.agent-knowledge-workspace'); const upload = await layout.locator('.agent-knowledge-upload').boundingBox(); const selection = await layout.locator('.agent-knowledge-selection').boundingBox(); expect(upload).not.toBeNull(); expect(selection).not.toBeNull(); if (width >= 1024) { expect(upload!.x + upload!.width).toBeLessThanOrEqual(selection!.x + 1); expect(Math.abs(upload!.y - selection!.y)).toBeLessThanOrEqual(1); } else { expect(upload!.y + upload!.height).toBeLessThanOrEqual(selection!.y + 1); expect(Math.abs(upload!.x - selection!.x)).toBeLessThanOrEqual(1); } await expectNoClipping(page); } async function expectPickerBounds(page: Page) { expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); const outside = await page.getByRole('dialog', { name: '从聊天选择文件', exact: true }).evaluate(dialog => { const bounds = dialog.getBoundingClientRect(); return { bounds: { left: bounds.left, top: bounds.top, right: bounds.right, bottom: bounds.bottom }, viewport: { width: innerWidth, height: innerHeight }, dialogFits: bounds.left >= 0 && bounds.top >= 0 && bounds.right <= innerWidth && bounds.bottom <= innerHeight, clipped: [...dialog.querySelectorAll('button, input, label, h2, h4')].filter(element => element.getClientRects().length > 0) .filter(element => { const box = element.getBoundingClientRect(); return box.left < bounds.left - 1 || box.right > bounds.right + 1; }) .map(element => element.getAttribute('aria-label') ?? element.textContent?.trim()), }; }); expect(outside.dialogFits, JSON.stringify(outside)).toBe(true); expect(outside.clipped).toEqual([]); if (outside.viewport.width >= 1024) expect(outside.bounds.right - outside.bounds.left).toBeGreaterThanOrEqual(800); } for (const width of [375, 1440]) { test(`multiple shared/personal choices stay draft-only and survive save/reopen at ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: 850 }); await installFixture(page); await page.goto(baseUrl); const { editor, knowledge } = await openKnowledge(page); const choose = knowledge.getByRole('combobox', { name: '选择知识库', exact: true }); await expect(choose.locator('optgroup[label="MakeLore知识"] option')).toHaveCount(2); await expect(choose.locator('optgroup[label="我的知识库"] option')).toHaveCount(1); await choose.selectOption('kb_fixturescience'); await choose.selectOption('kb_fixturereading'); await choose.selectOption('kb_fixturepersonal'); const selected = knowledge.getByRole('list', { name: '已选知识库', exact: true }); await expect(selected.getByRole('listitem')).toHaveCount(3); await selected.getByRole('button', { name: '移除知识库 我的观察记录', exact: true }).click(); await expect(selected.getByRole('listitem')).toHaveCount(2); await choose.selectOption('kb_fixturepersonal'); await expect(selected.getByRole('listitem')).toHaveCount(3); await expect(selected.getByRole('button', { name: '移除知识库 科学知识', exact: true })).toBeVisible(); await expect(selected.getByRole('button', { name: /上传|删除|处理|替换/ })).toHaveCount(0); expect((await calls(page)).filter(call => call.method === 'PATCH' || call.path.endsWith('/knowledge/pick'))).toEqual([]); await expect(knowledge.getByRole('heading', { name: '上传自己的文件', exact: true })).toBeVisible(); const management = knowledge.getByRole('combobox', { name: '管理的知识库', exact: true }); await expect(management.locator('option[value="kb_fixturescience"], option[value="kb_fixturereading"]')).toHaveCount(0); await expect(knowledge.getByRole('button', { name: '从电脑上传', exact: true })).toHaveCount(0); await expectKnowledgeLayout(page, width); await page.screenshot({ path: test.info().outputPath(`knowledge-shared-${width}.png`), animations: 'disabled' }); await editor.getByRole('button', { name: '保存草稿', exact: true }).click(); await expect(editor.getByRole('button', { name: '已保存', exact: true })).toBeVisible(); expect((await calls(page)).filter(call => call.method === 'PATCH').map(call => (call.input?.configuration as { knowledges: string[] }).knowledges)) .toEqual([['kb_fixturescience', 'kb_fixturereading', 'kb_fixturepersonal']]); await page.reload(); const reopened = await openKnowledge(page); await expect(reopened.knowledge.getByRole('list', { name: '已选知识库', exact: true }).getByRole('listitem')).toHaveCount(3); await expectNoClipping(page); }); test(`own library creation and explicit upload process a document to ready at ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: 850 }); await installFixture(page); await page.goto(baseUrl); const { editor, knowledge } = await openKnowledge(page); await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience'); await expect(knowledge.getByRole('heading', { name: '上传自己的文件', exact: true })).toBeVisible(); expect((await calls(page)).filter(call => call.path.endsWith('/knowledge/pick') || ['createKnowledge', 'processKnowledge'].includes(String(call.input?.operation)))).toEqual([]); await knowledge.getByRole('button', { name: '新建知识库', exact: true }).click(); await knowledge.getByRole('textbox', { name: '知识库名称', exact: true }).fill('我的春天资料'); await knowledge.getByRole('combobox', { name: '向量模型', exact: true }).selectOption('fixture-embedding'); await knowledge.getByRole('button', { name: '创建并选用', exact: true }).click(); await expect(knowledge.getByRole('combobox', { name: '管理的知识库', exact: true })).toHaveValue('kb_fixturecreated'); await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('我的春天资料'); expect((await calls(page)).filter(call => call.path.endsWith('/knowledge/pick') || call.input?.operation === 'processKnowledge')).toEqual([]); await knowledge.getByRole('button', { name: '从电脑上传', exact: true }).click(); const document = knowledge.getByRole('article', { name: '春天观察记录.md', exact: true }); await expect(document.getByText('等待处理', { exact: true })).toBeVisible(); const mutations = (await calls(page)).filter(call => call.path.endsWith('/knowledge/pick') || call.input?.operation === 'processKnowledge'); expect(mutations).toHaveLength(2); expect(mutations[0].input).toMatchObject({ kb_id: 'kb_fixturecreated' }); expect(mutations[1].input?.input).toMatchObject({ kb_id: 'kb_fixturecreated', file_id: 'observations-file' }); await page.evaluate(() => { const file = (window as unknown as FixtureWindow).__knowledgeFixture.files['kb_fixturecreated'][0]; Object.assign(file, { status: 'indexed', chunk_count: 6, available: true, processing_task: { task_id: 'processing-task', status: 'completed', error: null } }); }); await expect(document.getByText('可用于回答', { exact: true })).toBeVisible({ timeout: 12_000 }); await expect(document.getByText('2 KB · 6 段可检索内容', { exact: true })).toBeVisible(); await document.scrollIntoViewIfNeeded(); await expectKnowledgeLayout(page, width); await page.screenshot({ path: test.info().outputPath(`knowledge-upload-ready-${width}.png`), animations: 'disabled' }); // Document mutations never implicitly save the Agent draft. expect((await calls(page)).filter(call => call.method === 'PATCH')).toEqual([]); await editor.getByRole('button', { name: '保存草稿', exact: true }).click(); await expect(editor.getByRole('button', { name: '已保存', exact: true })).toBeVisible(); expect(await page.evaluate(() => (window as unknown as FixtureWindow).__knowledgeFixture.agent.configuration.knowledges)) .toEqual(['kb_fixturescience', 'kb_fixturecreated']); }); test(`chat files show their source and metadata and import multiple choices into the named own library at ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: 850 }); await installFixture(page); await page.goto(baseUrl); const { knowledge } = await openKnowledge(page); await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience'); await knowledge.getByRole('combobox', { name: '管理的知识库', exact: true }).selectOption('kb_fixturepersonal'); await knowledge.getByRole('button', { name: '从聊天选择文件', exact: true }).click(); const picker = page.getByRole('dialog', { name: '从聊天选择文件', exact: true }); await expect(picker).toContainText('导入到「我的观察记录」'); await picker.getByRole('textbox', { name: '搜索对话', exact: true }).fill('春天'); await expect(picker.getByRole('button', { name: '选择对话 图书阅读记录', exact: true })).toHaveCount(0); await picker.getByRole('button', { name: '选择对话 春天的观察讨论', exact: true }).click(); await expect(picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true })).toBeVisible(); await expect(picker.locator('.agent-knowledge-import-file').filter({ has: page.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true }) })) .toContainText(/(?:MD|Markdown)\s*·\s*(?:1\.5|2)\s*KB/i); await picker.getByRole('textbox', { name: '搜索文件', exact: true }).fill('植物'); await expect(picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true })).toHaveCount(0); await picker.getByRole('checkbox', { name: '选择文件 植物清单.csv', exact: true }).check(); await expect(picker.locator('.agent-knowledge-import-file').filter({ has: page.getByRole('checkbox', { name: '选择文件 植物清单.csv', exact: true }) })) .toContainText(/CSV\s*·\s*2\s*KB/); await picker.getByRole('textbox', { name: '搜索文件', exact: true }).fill(''); await picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true }).check(); await picker.getByRole('textbox', { name: '搜索对话', exact: true }).fill('图书'); await picker.getByRole('button', { name: '选择对话 图书阅读记录', exact: true }).click(); await picker.getByRole('checkbox', { name: '选择文件 阅读笔记.pdf', exact: true }).check(); const confirm = picker.getByRole('button', { name: '导入 3 个文件', exact: true }); await expect(confirm).toBeEnabled(); await expect(confirm).toBeInViewport(); expect((await calls(page)).filter(call => ['importKnowledgeAttachment', 'processKnowledge'].includes(String(call.input?.operation)))).toEqual([]); await expectPickerBounds(page); await page.screenshot({ path: test.info().outputPath(`knowledge-chat-picker-${width}.png`), animations: 'disabled' }); await confirm.click(); await expect(picker).toHaveCount(0); for (const name of ['春天观察报告.md', '植物清单.csv', '阅读笔记.pdf']) { await expect(knowledge.getByRole('article', { name, exact: true })).toBeVisible(); } const imports = (await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment'); expect(imports).toHaveLength(3); expect(imports.map(call => (call.input?.input as { attachment_id: string }).attachment_id).sort()) .toEqual(['attachment-observations', 'attachment-plants', 'attachment-reading']); expect(imports.every(call => (call.input?.input as { kb_id: string }).kb_id === 'kb_fixturepersonal')).toBe(true); expect((await calls(page)).filter(call => call.input?.operation === 'processKnowledge')).toHaveLength(3); expect((await calls(page)).filter(call => call.method === 'PATCH')).toEqual([]); await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('科学知识'); await expectKnowledgeLayout(page, width); }); } test('cancelled and failed uploads preserve shared choices and the personal draft', async ({ page }) => { await page.setViewportSize({ width: 375, height: 850 }); await installFixture(page); await page.goto(baseUrl); const { editor, knowledge } = await openKnowledge(page); await editor.getByRole('tab', { name: '介绍与提示词', exact: true }).click(); await editor.getByRole('textbox', { name: '提示词', exact: true }).fill('这是我还没有保存的提示词。'); await editor.getByRole('tab', { name: '高级设置', exact: true }).click(); await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience'); await expect(knowledge.getByRole('heading', { name: '上传自己的文件', exact: true })).toBeVisible(); await knowledge.getByRole('combobox', { name: '管理的知识库', exact: true }).selectOption('kb_fixturepersonal'); await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.upload = 'cancel'; }); await knowledge.getByRole('button', { name: '从电脑上传', exact: true }).click(); await expect(knowledge.getByRole('button', { name: '从电脑上传', exact: true })).toBeEnabled(); await expect(knowledge.getByRole('article')).toHaveCount(0); expect((await calls(page)).filter(call => call.input?.operation === 'processKnowledge')).toEqual([]); await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.upload = 'fail'; }); await knowledge.getByRole('button', { name: '从电脑上传', exact: true }).click(); await expect(knowledge.getByRole('alert')).toContainText('上传服务暂不可用'); await expect(knowledge.getByRole('combobox', { name: '管理的知识库', exact: true })).toHaveValue('kb_fixturepersonal'); await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('科学知识'); await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('我的观察记录'); // An uncertain upload response keeps its retry attached to the same library. await expect(knowledge.getByRole('combobox', { name: '管理的知识库', exact: true })).toBeDisabled(); await expect(knowledge.getByRole('button', { name: '新建知识库', exact: true })).toBeDisabled(); const failedUpload = (await calls(page)).filter(call => call.path.endsWith('/knowledge/pick')).at(-1)!; await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.upload = 'cancel'; }); await knowledge.getByRole('button', { name: '重新选择同一文件并重试', exact: true }).click(); await expect(knowledge.getByRole('button', { name: '重新选择同一文件并重试', exact: true })).toBeEnabled(); await expect(knowledge.getByRole('combobox', { name: '管理的知识库', exact: true })).toBeDisabled(); const cancelledRetry = (await calls(page)).filter(call => call.path.endsWith('/knowledge/pick')).at(-1)!; expect(cancelledRetry.input?.operation_id).toBe(failedUpload.input?.operation_id); expect((await calls(page)).filter(call => call.method === 'PATCH' || call.input?.operation === 'processKnowledge')).toEqual([]); await expectNoClipping(page); await editor.getByRole('tab', { name: '介绍与提示词', exact: true }).click(); await expect(editor.getByRole('textbox', { name: '提示词', exact: true })).toHaveValue('这是我还没有保存的提示词。'); }); test('a partial chat-file import retries only the failed file with the same operation identity', async ({ page }) => { await page.setViewportSize({ width: 375, height: 850 }); await installFixture(page); await page.goto(baseUrl); const { knowledge } = await openKnowledge(page); await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience'); await knowledge.getByRole('combobox', { name: '管理的知识库', exact: true }).selectOption('kb_fixturepersonal'); await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.importFailures = ['attachment-plants']; }); await knowledge.getByRole('button', { name: '从聊天选择文件', exact: true }).click(); const picker = page.getByRole('dialog', { name: '从聊天选择文件', exact: true }); await picker.getByRole('button', { name: '选择对话 春天的观察讨论', exact: true }).click(); await picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true }).check(); await picker.getByRole('checkbox', { name: '选择文件 植物清单.csv', exact: true }).check(); const confirm = picker.getByRole('button', { name: '导入 2 个文件', exact: true }); await expect(confirm).toBeInViewport(); await confirm.click({ timeout: 15_000 }); await expect(picker).toContainText('植物清单导入暂时失败'); const retry = picker.getByRole('button', { name: '重试失败的 1 个文件', exact: true }); await expect(retry).toBeEnabled(); await expect(retry).toBeInViewport(); await expect(picker.getByRole('button', { name: '关闭文件选择', exact: true })).toBeDisabled(); await page.keyboard.press('Escape'); await expect(picker).toBeVisible(); const originalImports = (await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment'); expect(originalImports).toHaveLength(2); await expect(picker.getByRole('list', { name: '文件导入结果', exact: true }).getByRole('listitem').filter({ hasText: '春天观察报告.md' })) .toContainText('已导入,处理状态见知识库'); await expectPickerBounds(page); await page.screenshot({ path: test.info().outputPath('knowledge-chat-partial-failure-375.png'), animations: 'disabled' }); await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.importFailures = []; }); await retry.click(); await expect(picker).toHaveCount(0); await expect(knowledge.getByRole('article', { name: '春天观察报告.md', exact: true })).toBeVisible(); await expect(knowledge.getByRole('article', { name: '植物清单.csv', exact: true })).toBeVisible(); const imported = (await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment') .map(call => call.input?.input as { attachment_id: string; operation_id: string; kb_id: string }); expect(imported.filter(input => input.attachment_id === 'attachment-observations')).toHaveLength(1); const retried = imported.filter(input => input.attachment_id === 'attachment-plants'); expect(retried).toHaveLength(2); expect(new Set(retried.map(input => input.operation_id)).size).toBe(1); expect(imported.every(input => input.kb_id === 'kb_fixturepersonal')).toBe(true); const processed = (await calls(page)).filter(call => call.input?.operation === 'processKnowledge') .map(call => (call.input?.input as { file_id: string }).file_id); expect(processed.sort()).toEqual(['imported-attachment-observations', 'imported-attachment-plants']); expect((await calls(page)).filter(call => call.method === 'PATCH')).toEqual([]); await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('科学知识'); await expectNoClipping(page); }); test('abandoning a failed batch keeps saved files and allows a fresh picker operation without replaying successes', async ({ page }) => { await page.setViewportSize({ width: 375, height: 850 }); await installFixture(page); await page.goto(baseUrl); const { knowledge } = await openKnowledge(page); await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience'); await knowledge.getByRole('combobox', { name: '管理的知识库', exact: true }).selectOption('kb_fixturepersonal'); await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.importFailures = ['attachment-plants']; }); await knowledge.getByRole('button', { name: '从聊天选择文件', exact: true }).click(); const picker = page.getByRole('dialog', { name: '从聊天选择文件', exact: true }); await picker.getByRole('button', { name: '选择对话 春天的观察讨论', exact: true }).click(); await picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true }).check(); await picker.getByRole('checkbox', { name: '选择文件 植物清单.csv', exact: true }).check(); await expect(picker.getByRole('button', { name: '导入 2 个文件', exact: true })).toBeInViewport(); await picker.getByRole('button', { name: '导入 2 个文件', exact: true }).click({ timeout: 15_000 }); await expect(picker.getByRole('button', { name: '重试失败的 1 个文件', exact: true })).toBeEnabled(); const importsBefore = (await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment'); await picker.getByRole('button', { name: '放弃重试', exact: true }).click(); await expect(picker).toContainText('已保存的文件会保留'); const abandon = picker.getByRole('button', { name: '确认放弃重试', exact: true }); await abandon.scrollIntoViewIfNeeded(); await expect(abandon).toBeInViewport(); await expectPickerBounds(page); await page.screenshot({ path: test.info().outputPath('knowledge-chat-abandon-confirm-375.png'), animations: 'disabled' }); await abandon.click(); await expect(picker).toHaveCount(0); expect((await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment')).toEqual(importsBefore); expect((await calls(page)).filter(call => /delete/i.test(String(call.input?.operation)) || call.method === 'DELETE')).toEqual([]); await expect(knowledge.getByRole('article', { name: '春天观察报告.md', exact: true })).toBeVisible(); await expect(knowledge.getByRole('combobox', { name: '管理的知识库', exact: true })).toBeEnabled(); await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.importFailures = []; }); await knowledge.getByRole('button', { name: '从聊天选择文件', exact: true }).click(); await picker.getByRole('button', { name: '选择对话 春天的观察讨论', exact: true }).click(); await expect(picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true })).not.toBeChecked(); await picker.getByRole('checkbox', { name: '选择文件 植物清单.csv', exact: true }).check(); await picker.getByRole('button', { name: '导入 1 个文件', exact: true }).click(); await expect(picker).toHaveCount(0); const imports = (await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment') .map(call => call.input?.input as { attachment_id: string; operation_id: string }); expect(imports.filter(input => input.attachment_id === 'attachment-observations')).toHaveLength(1); const plants = imports.filter(input => input.attachment_id === 'attachment-plants'); expect(plants).toHaveLength(2); expect(new Set(plants.map(input => input.operation_id)).size).toBe(2); const processed = (await calls(page)).filter(call => call.input?.operation === 'processKnowledge') .map(call => (call.input?.input as { file_id: string }).file_id); expect(processed.sort()).toEqual(['imported-attachment-observations', 'imported-attachment-plants']); await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('科学知识'); await expectNoClipping(page); });