import { createElement, useState } from 'react'; import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CloudAgentsModule } from '@electron/services/cloud-agents'; import { clearWorksSquareSession, storeWorksSquareSession } from '@electron/services/works-square-session'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import { CloudComposerAttachments } from '@/pages/CloudAgents/CloudComposerAttachments'; import { useAuthStore } from '@/stores/auth'; const host = vi.hoisted(() => ({ call: vi.fn() })); vi.mock('@/lib/host-api', () => ({ hostApiFetch: host.call, createHostEventSource: vi.fn(), ensureHostApiToken: vi.fn() })); const modules: CloudAgentsModule[] = []; const login = (key = 'a'.repeat(64)) => storeWorksSquareSession({ accessToken: 'ws-secret', expiresAt: Date.now() + 600000, accountPartitionKey: key, }); const json = (body: unknown) => new Response(JSON.stringify(body)); const session = () => json({ access_token: 'agent-secret', token_type: 'bearer', scope: 'makelore-agents', expires_at: Math.floor(Date.now() / 1000) + 120, api_base_url: 'https://agents.example.test' }); const uploaded = { object_name: 'temporary/notes.txt', file_name: 'notes.txt', file_type: 'text/plain', parse_supported: false, parse_methods: [] }; const bytesInput = { bytesBase64: 'aGVsbG8=', fileName: 'notes.txt', mimeType: 'text/plain' }; const createModule = (transport: (url: string, init?: RequestInit) => Promise) => { const module = new CloudAgentsModule(transport); modules.push(module); return module; }; const readBlob = (blob: Blob) => new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = reject; reader.readAsDataURL(blob); }); const attachment = (id: string, name: string) => ({ file_id: id, file_name: name, file_size: 5, path: '/attachments/' + id, original_path: '/original/' + id }); function browserFiles({ scopeKey = 'same-thread', disabled = false, activeView = true, onBusy = vi.fn(), onSelected = vi.fn(), ensureThread = vi.fn(async () => 'thread-1') } = {}) { function Harness(props: { scopeKey: string; disabled: boolean; activeView: boolean; onBusy: (busy: boolean) => void }) { const [selected, setSelected] = useState(['prior']); return createElement(CloudComposerAttachments, { ...props, selected, ensureThread, onSelected: ids => { onSelected(ids); setSelected(ids); }, actions: createElement('button', { type: 'button', 'aria-label': '语音输入' }) }, createElement('textarea', { 'aria-label': '消息', defaultValue: '保留文字' })); } const view = render(createElement(Harness, { scopeKey, disabled, activeView, onBusy })); return { ...view, onBusy, onSelected, ensureThread, update: (next: { scopeKey: string; disabled?: boolean; activeView?: boolean; onBusy?: (busy: boolean) => void }) => view.rerender(createElement(Harness, { scopeKey: next.scopeKey, disabled: next.disabled ?? disabled, activeView: next.activeView ?? activeView, onBusy: next.onBusy ?? onBusy })) }; } function mockBrowserFiles() { const upload = vi.spyOn(cloudAgentsApi, 'uploadFile').mockImplementation(async file => ({ ...uploaded, object_name: 'temporary/' + file.name, file_name: file.name, file_type: file.type })); const call = vi.spyOn(cloudAgentsApi, 'call').mockImplementation(async operation => { if (operation === 'confirmAttachment') return { attachments: [attachment('image-1', 'photo.png'), attachment('notes-1', 'notes.txt')] } as never; return operation === 'attachments' ? { attachments: [] } as never : { files: [] } as never; }); return { upload, call }; } beforeEach(() => { vi.restoreAllMocks(); host.call.mockReset(); useAuthStore.setState({ user: null }); clearWorksSquareSession(); login(); }); afterEach(() => { cleanup(); modules.splice(0).forEach(module => module.dispose()); useAuthStore.setState({ user: null }); clearWorksSquareSession(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); describe('Main attachment bytes upload', () => { it('uploads only decoded file bytes using Main-owned credentials and projects the temporary upload', async () => { const transport = vi.fn(async (url: string, _init?: RequestInit) => url.endsWith('/session') ? session() : json({ ...uploaded, access_token: 'never-project' })); expect(await createModule(transport).uploadBytes(bytesInput)).toEqual(uploaded); const [url, init] = transport.mock.calls[1]; expect(url).toBe('https://agents.example.test/api/makelore/attachments/tmp'); expect(init?.method).toBe('POST'); expect(init?.redirect).toBe('error'); expect(init?.headers).toEqual({ Authorization: 'Bearer agent-secret' }); expect(init?.body).toBeInstanceOf(FormData); const file = (init!.body as FormData).get('file') as File; expect(file.name).toBe('notes.txt'); expect(file.type).toBe('text/plain'); expect(file.size).toBe(5); expect(await readBlob(file)).toBe('data:text/plain;base64,aGVsbG8='); }); it.each([ { ...bytesInput, bytesBase64: '' }, { ...bytesInput, bytesBase64: 'aGVsbG8' }, { ...bytesInput, bytesBase64: 'aGVs bG8=' }, { ...bytesInput, bytesBase64: 'aGVsbG8=\n' }, { ...bytesInput, bytesBase64: 'Zh==' }, { ...bytesInput, bytesBase64: 'data:text/plain;base64,aGVsbG8=' }, { ...bytesInput, bytesBase64: 'aG=VsbG8' }, { ...bytesInput, fileName: '../secret.txt' }, { ...bytesInput, fileName: 'C:\\secret.txt' }, { ...bytesInput, fileName: 'bad\u0000name' }, { ...bytesInput, fileName: '..' }, { ...bytesInput, fileName: ' ' }, { ...bytesInput, mimeType: 'text/plain\r\nX: secret' }, { ...bytesInput, path: '/tmp/private' }, ])('rejects malformed bytes, names and metadata before network access (%j)', async input => { const transport = vi.fn(); await expect(createModule(transport).uploadBytes(input)).rejects.toMatchObject({ status: 422, code: 'attachment_invalid' }); expect(transport).not.toHaveBeenCalled(); }); it('accepts exactly 5 MiB and rejects decoded or encoded bytes exceeding it', async () => { const transport = vi.fn(async (url: string) => url.endsWith('/session') ? session() : json(uploaded)); const module = createModule(transport); await expect(module.uploadBytes({ ...bytesInput, bytesBase64: Buffer.alloc(5 * 1024 * 1024).toString('base64') })).resolves.toEqual(uploaded); expect((transport.mock.calls[1] as [string, RequestInit])[1].body).toBeInstanceOf(FormData); transport.mockClear(); for (const extra of [1, 3]) { await expect(module.uploadBytes({ ...bytesInput, bytesBase64: Buffer.alloc(5 * 1024 * 1024 + extra).toString('base64') })) .rejects.toMatchObject({ status: 422, code: 'attachment_too_large' }); } expect(transport).not.toHaveBeenCalled(); }); it('requires a logged-in account before uploading', async () => { clearWorksSquareSession(); const transport = vi.fn(); await expect(createModule(transport).uploadBytes(bytesInput)).rejects.toMatchObject({ status: 401 }); expect(transport).not.toHaveBeenCalled(); }); it('rejects late upload results after the account changes', async () => { let finish!: (response: Response) => void; const transport = vi.fn().mockResolvedValueOnce(session()).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); const request = createModule(transport).uploadBytes(bytesInput); const rejection = expect(request).rejects.toMatchObject({ status: 409, code: 'account_changed' }); await waitFor(() => expect(transport).toHaveBeenCalledTimes(2)); login('b'.repeat(64)); finish(json(uploaded)); await rejection; }); }); describe('Renderer attachment input', () => { it('sends browser file bytes through the typed local Host API without any disk path', async () => { host.call.mockResolvedValue(uploaded); const file = new File(['hello'], 'notes.txt', { type: 'text/plain' }); Object.defineProperty(file, 'arrayBuffer', { value: async () => new TextEncoder().encode('hello').buffer }); expect(await cloudAgentsApi.uploadFile(file)).toEqual(uploaded); expect(host.call).toHaveBeenCalledWith('/api/cloud-agents/attachments/upload', { method: 'POST', body: JSON.stringify(bytesInput), }); }); it('never dispatches converted bytes after the originating operation becomes inactive', async () => { let finish!: (bytes: ArrayBuffer) => void; const file = new File(['hello'], 'notes.txt', { type: 'text/plain' }); Object.defineProperty(file, 'arrayBuffer', { value: () => new Promise(resolve => { finish = resolve; }) }); let active = true; const request = cloudAgentsApi.uploadFile(file, () => active); const rejection = expect(request).rejects.toThrow('对话已切换'); active = false; finish(new TextEncoder().encode('hello').buffer); await rejection; expect(host.call).not.toHaveBeenCalled(); }); it('does not begin reading file bytes for an already inactive operation', async () => { const file = new File(['hello'], 'notes.txt', { type: 'text/plain' }); const readBytes = vi.fn(); Object.defineProperty(file, 'arrayBuffer', { value: readBytes }); await expect(cloudAgentsApi.uploadFile(file, () => false)).rejects.toThrow('对话已切换'); expect(readBytes).not.toHaveBeenCalled(); expect(host.call).not.toHaveBeenCalled(); }); }); describe('Current trial composer browser attachments', () => { it('adds multiple files from the input, preserves the draft and native menu, and previews only confirmed browser images', async () => { const createPreview = vi.fn(() => 'blob:local-image'); const revokePreview = vi.fn(); vi.stubGlobal('URL', class extends URL { static createObjectURL = createPreview; static revokeObjectURL = revokePreview; }); const { upload, call } = mockBrowserFiles(); const view = browserFiles(); const files = [new File(['image'], 'photo.png', { type: 'image/png' }), new File(['notes'], 'notes.txt', { type: 'text/plain' })]; fireEvent.change(screen.getByTestId('cloud-file-attachment-input'), { target: { files } }); await screen.findByRole('img', { name: 'photo.png' }); expect(upload).toHaveBeenCalledTimes(2); expect(view.ensureThread).toHaveBeenCalledTimes(1); expect(call).toHaveBeenCalledWith('confirmAttachment', { thread_id: 'thread-1', attachments: [ { object_name: 'temporary/photo.png', file_type: 'image/png' }, { object_name: 'temporary/notes.txt', file_type: 'text/plain' }, ] }); expect(view.onSelected).toHaveBeenCalledWith(['prior', 'image-1', 'notes-1']); expect(screen.getByLabelText('消息')).toHaveValue('保留文字'); expect(screen.getByRole('button', { name: '语音输入' }).closest('.agent-attachment-actions')) .toContainElement(screen.getByRole('button', { name: '添加文件或图片' })); expect(call.mock.calls.some(([name]) => ['submit', 'preview', 'parseAttachment'].includes(name))).toBe(false); expect(view.onBusy.mock.calls).toEqual([[true], [false]]); fireEvent.keyDown(screen.getByRole('button', { name: '添加文件或图片' }), { key: 'ArrowDown' }); expect(await screen.findByRole('menuitem', { name: '添加并选择读取方式' })).toBeVisible(); expect(screen.getByRole('menuitem', { name: '从电脑选择多个文件' })).toBeVisible(); view.unmount(); expect(revokePreview).toHaveBeenCalledWith('blob:local-image'); }); it('uploads only clipboard images and does not submit the message', async () => { const { upload, call } = mockBrowserFiles(); browserFiles(); const image = new File(['image'], 'photo.png', { type: 'image/png' }); fireEvent.paste(screen.getByLabelText('消息'), { clipboardData: { files: [image, new File(['notes'], 'notes.txt', { type: 'text/plain' })] } }); await waitFor(() => expect(upload).toHaveBeenCalledTimes(1)); expect(upload.mock.calls[0][0]).toBe(image); await waitFor(() => expect(call).toHaveBeenCalledWith('confirmAttachment', expect.anything())); expect(call.mock.calls.some(([name]) => name === 'submit' || name === 'preview')).toBe(false); }); it('supports dropped files and blocks uploads while disabled', async () => { const { upload } = mockBrowserFiles(); const view = browserFiles({ disabled: true }); const file = new File(['notes'], 'notes.txt', { type: 'text/plain' }); fireEvent.drop(screen.getByLabelText('消息'), { dataTransfer: { files: [file] } }); expect(upload).not.toHaveBeenCalled(); view.update({ scopeKey: 'same-thread', disabled: false }); fireEvent.drop(screen.getByLabelText('消息'), { dataTransfer: { files: [file] } }); await waitFor(() => expect(upload).toHaveBeenCalledTimes(1)); expect(upload.mock.calls[0][0]).toBe(file); }); it('retries the same batch confirmation without uploading files or resolving the thread again', async () => { const { upload, call } = mockBrowserFiles(); const base = call.getMockImplementation()!; let attempts = 0; call.mockImplementation(async (operation, input) => { if (operation === 'confirmAttachment' && ++attempts === 1) throw new Error('确认响应丢失'); return base(operation, input); }); const view = browserFiles(); fireEvent.change(screen.getByTestId('cloud-file-attachment-input'), { target: { files: [new File(['image'], 'photo.png', { type: 'image/png' }), new File(['notes'], 'notes.txt', { type: 'text/plain' })] } }); await screen.findByText('确认响应丢失'); expect(view.onSelected).not.toHaveBeenCalled(); expect(screen.getByRole('button', { name: '添加文件或图片' })).toBeDisabled(); fireEvent.click(screen.getByRole('button', { name: '重试添加' })); await screen.findByRole('button', { name: '移除附件 photo.png' }); expect(upload).toHaveBeenCalledTimes(2); expect(view.ensureThread).toHaveBeenCalledTimes(1); const confirmations = call.mock.calls.filter(([name]) => name === 'confirmAttachment'); expect(confirmations).toHaveLength(2); expect(confirmations[1]).toEqual(confirmations[0]); }); it('keeps successfully uploaded temporary files when retrying a partly uploaded batch', async () => { const { upload } = mockBrowserFiles(); const implementation = upload.getMockImplementation()!; upload.mockResolvedValueOnce({ ...uploaded, object_name: 'temporary/photo.png', file_name: 'photo.png', file_type: 'image/png' }) .mockRejectedValueOnce(new Error('第二个文件上传失败')).mockImplementation(implementation); browserFiles(); const files = [new File(['image'], 'photo.png', { type: 'image/png' }), new File(['notes'], 'notes.txt', { type: 'text/plain' })]; fireEvent.change(screen.getByTestId('cloud-file-attachment-input'), { target: { files } }); await screen.findByText('第二个文件上传失败'); fireEvent.click(screen.getByRole('button', { name: '重试添加' })); await screen.findByRole('button', { name: '移除附件 notes.txt' }); expect(upload.mock.calls.map(([file]) => file.name)).toEqual(['photo.png', 'notes.txt', 'notes.txt']); }); it('ignores pending uploads after scope replacement and never clears a new busy owner', async () => { const { upload, call } = mockBrowserFiles(); let finish!: (result: typeof uploaded) => void; upload.mockImplementation(() => new Promise(resolve => { finish = resolve; })); const view = browserFiles({ scopeKey: 'old' }); const newBusy = vi.fn(); fireEvent.change(screen.getByTestId('cloud-file-attachment-input'), { target: { files: [new File(['notes'], 'notes.txt')] } }); view.update({ scopeKey: 'new', onBusy: newBusy }); await act(async () => { finish(uploaded); }); expect(view.ensureThread).not.toHaveBeenCalled(); expect(view.onSelected).not.toHaveBeenCalled(); expect(call.mock.calls.some(([name]) => name === 'confirmAttachment')).toBe(false); expect(view.onBusy.mock.calls).toEqual([[true], [false]]); expect(newBusy).not.toHaveBeenCalled(); }); it('rejects a pending upload as soon as the auth actor changes before React rerenders', async () => { useAuthStore.setState({ user: { userId: 'a', username: 'alice', tenantId: null, deptId: null, authorities: [] } }); const { upload, call } = mockBrowserFiles(); let finish!: (result: typeof uploaded) => void; upload.mockImplementation(() => new Promise(resolve => { finish = resolve; })); const view = browserFiles(); fireEvent.change(screen.getByTestId('cloud-file-attachment-input'), { target: { files: [new File(['notes'], 'notes.txt')] } }); const isActive = upload.mock.calls[0][1]!; expect(isActive()).toBe(true); useAuthStore.setState({ user: { userId: 'b', username: 'bob', tenantId: null, deptId: null, authorities: [] } }); expect(isActive()).toBe(false); await act(async () => { finish(uploaded); }); expect(view.ensureThread).not.toHaveBeenCalled(); expect(view.onSelected).not.toHaveBeenCalled(); expect(call.mock.calls.some(([name]) => name === 'confirmAttachment')).toBe(false); expect(view.onBusy.mock.calls).toEqual([[true], [false]]); expect(screen.getByRole('button', { name: '添加文件或图片' })).toBeEnabled(); }); it('releases the current lease when hidden and ignores late upload results while retaining an explicit retry', async () => { const { upload, call } = mockBrowserFiles(); let finish!: (result: typeof uploaded) => void; upload.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); const view = browserFiles(); fireEvent.change(screen.getByTestId('cloud-file-attachment-input'), { target: { files: [new File(['notes'], 'notes.txt', { type: 'text/plain' })] } }); view.update({ scopeKey: 'same-thread', activeView: false }); expect(view.onBusy.mock.calls).toEqual([[true], [false]]); await act(async () => { finish(uploaded); }); expect(view.ensureThread).not.toHaveBeenCalled(); expect(view.onSelected).not.toHaveBeenCalled(); expect(call.mock.calls.some(([name]) => name === 'confirmAttachment')).toBe(false); view.update({ scopeKey: 'same-thread', activeView: true }); fireEvent.click(screen.getByRole('button', { name: '重试添加' })); await screen.findByRole('button', { name: '移除附件 notes.txt' }); expect(view.ensureThread).toHaveBeenCalledTimes(1); }); it('does not let a hidden operation clear the busy lease of a newer retry', async () => { const { upload } = mockBrowserFiles(); let finishOld!: (result: typeof uploaded) => void; let finishNew!: (result: typeof uploaded) => void; upload.mockImplementationOnce(() => new Promise(resolve => { finishOld = resolve; })) .mockImplementationOnce(() => new Promise(resolve => { finishNew = resolve; })); const view = browserFiles(); fireEvent.change(screen.getByTestId('cloud-file-attachment-input'), { target: { files: [new File(['notes'], 'notes.txt', { type: 'text/plain' })] } }); view.update({ scopeKey: 'same-thread', activeView: false }); view.update({ scopeKey: 'same-thread', activeView: true }); fireEvent.click(screen.getByRole('button', { name: '重试添加' })); expect(view.onBusy.mock.calls).toEqual([[true], [false], [true]]); await act(async () => { finishOld(uploaded); }); expect(view.onBusy.mock.calls).toEqual([[true], [false], [true]]); expect(view.ensureThread).not.toHaveBeenCalled(); await act(async () => { finishNew(uploaded); }); await screen.findByRole('button', { name: '移除附件 notes.txt' }); expect(view.onBusy.mock.calls).toEqual([[true], [false], [true], [false]]); }); it('matches reordered confirmed thumbnails by identity and leaves unrelated files without a local preview', async () => { const createPreview = vi.fn((file: File) => 'blob:' + file.name); vi.stubGlobal('URL', class extends URL { static createObjectURL = createPreview; static revokeObjectURL = vi.fn(); }); const { call } = mockBrowserFiles(); const base = call.getMockImplementation()!; call.mockImplementation(async (operation, input) => operation === 'confirmAttachment' ? { attachments: [ attachment('second', 'second.png'), attachment('first', 'first.png'), attachment('unknown', 'unknown.png'), ] } as never : base(operation, input)); browserFiles(); const files = [new File(['first'], 'first.png', { type: 'image/png' }), new File(['second'], 'second.png', { type: 'image/png' })]; fireEvent.change(screen.getByTestId('cloud-file-attachment-input'), { target: { files } }); await screen.findByRole('img', { name: 'second.png' }); expect(screen.getByRole('img', { name: 'second.png' })).toHaveAttribute('src', 'blob:second.png'); expect(screen.getByRole('img', { name: 'first.png' })).toHaveAttribute('src', 'blob:first.png'); expect(screen.queryByRole('img', { name: 'unknown.png' })).not.toBeInTheDocument(); expect(createPreview).toHaveBeenNthCalledWith(1, files[1]); expect(createPreview).toHaveBeenNthCalledWith(2, files[0]); }); });