import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CloudChat } from '@/pages/CloudAgents/CloudChat'; import { CloudPendingContext } from '@/pages/CloudAgents/CloudPending'; import { useAuthStore } from '@/stores/auth'; import { WorksSquareApiError } from '@/lib/works-square'; import { StrictMode } from 'react'; import type { CloudThread } from '../../shared/cloud-agents'; const api = vi.hoisted(() => ({ call: vi.fn(), events: vi.fn(), upload: vi.fn(), uploadFile: vi.fn(), download: vi.fn(), remember: vi.fn(), recovery: vi.fn(), })); const voiceApi = vi.hoisted(() => ({ blobToBase64: vi.fn(), convertAudioBlobTo16kMonoWav: vi.fn(), transcribeWorksSpeech: vi.fn(), getValidAccessToken: vi.fn(), refreshSession: vi.fn(), })); const notifications = vi.hoisted(() => ({ error: vi.fn(), warning: vi.fn() })); vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api })); vi.mock('@/lib/voice-recording', () => ({ blobToBase64: (...args: unknown[]) => voiceApi.blobToBase64(...args), convertAudioBlobTo16kMonoWav: (...args: unknown[]) => voiceApi.convertAudioBlobTo16kMonoWav(...args), })); vi.mock('@/lib/works-square', async (importOriginal) => ({ ...await importOriginal(), transcribeWorksSpeech: (...args: unknown[]) => voiceApi.transcribeWorksSpeech(...args), })); vi.mock('sonner', () => ({ toast: notifications })); const slug = 'ml-' + 'a'.repeat(32); const otherSlug = 'ml-' + 'b'.repeat(32); const initialAuthState = useAuthStore.getState(); const initialMediaRecorder = window.MediaRecorder; const initialMediaDevices = navigator.mediaDevices; const stopTrack = vi.fn(); const getUserMedia = vi.fn(); class MockMediaRecorder { static latest: MockMediaRecorder | null = null; static isTypeSupported(): boolean { return true; } ondataavailable: ((event: { data: Blob }) => void) | null = null; onstop: (() => void) | null = null; state: RecordingState = 'inactive'; mimeType = 'audio/webm'; constructor() { MockMediaRecorder.latest = this; } start(): void { this.state = 'recording'; } stop(): void { this.state = 'inactive'; this.ondataavailable?.({ data: new Blob(['voice bytes'], { type: 'audio/webm' }) }); this.onstop?.(); } } function deferred() { let resolve!: (value: T) => void; const promise = new Promise(done => { resolve = done; }); return { promise, resolve }; } function storedThread(id: string, mode: 'published' | 'preview' = 'published'): CloudThread { return { thread_id: id, client_thread_id: 'client-' + id, agent_slug: slug, title: id, mode, draft_revision: mode === 'preview' ? 1 : undefined, status: 'idle', run_id: null, unread: false, updated_at: '2026-10-13T00:00:00Z', }; } function submissions() { return api.call.mock.calls.filter(([operation]) => operation === 'submit' || operation === 'preview'); } async function ready() { // Recovery can replace the lazy client-id subtree with its stored thread. await waitFor(() => expect(screen.getByRole('textbox', { name: '消息' })).toBeEnabled()); await waitFor(() => expect(screen.getByRole('button', { name: '语音输入', exact: true })).toBeEnabled()); return screen.getByRole('textbox', { name: '消息' }); } async function startRecording() { fireEvent.click(screen.getByRole('button', { name: '语音输入', exact: true })); await screen.findByRole('button', { name: '停止语音输入' }); } beforeEach(() => { vi.resetAllMocks(); MockMediaRecorder.latest = null; api.remember.mockResolvedValue({ saved: true }); api.recovery.mockResolvedValue({ recent: null, pending: [] }); api.events.mockResolvedValue({ close: vi.fn(), addEventListener: vi.fn() }); api.call.mockImplementation(async (operation: string, input: Record) => { if (operation === 'threads') return { threads: [], next_offset: null }; if (operation === 'history') return { thread_id: input.thread_id, messages: [], run: null, queued_requests: [], next_offset: null }; if (operation === 'attachments') return { attachments: [] }; if (operation === 'files') return { files: [] }; if (operation === 'submit' || operation === 'preview') return { request_id: input.request_id, thread_id: 'thread-1', run_id: null, version: '1', status: 'completed', }; throw new Error('Unexpected operation: ' + operation); }); voiceApi.convertAudioBlobTo16kMonoWav.mockResolvedValue(new Blob(['wav bytes'], { type: 'audio/wav' })); voiceApi.blobToBase64.mockResolvedValue('voice-base64'); voiceApi.transcribeWorksSpeech.mockResolvedValue({ text: '补充一些例子', model: 'test-transcribe' }); voiceApi.getValidAccessToken.mockResolvedValue('voice-access-token'); voiceApi.refreshSession.mockResolvedValue('voice-refreshed-token'); getUserMedia.mockResolvedValue({ getTracks: () => [{ stop: stopTrack }] }); Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: { getUserMedia } }); Object.defineProperty(window, 'MediaRecorder', { configurable: true, value: MockMediaRecorder }); useAuthStore.setState({ initialized: true, loading: false, error: null, accessToken: 'voice-access-token', tokenType: 'Bearer', expiresAt: Date.now() + 60_000, lastActiveAt: Date.now(), canRefresh: true, legacyRefreshToken: null, getValidAccessToken: voiceApi.getValidAccessToken, refreshSession: voiceApi.refreshSession, user: { username: 'agent-user', userId: 'agent-user-1', tenantId: null, deptId: null, authorities: [] }, }); }); afterEach(() => { cleanup(); useAuthStore.setState(initialAuthState); Object.defineProperty(window, 'MediaRecorder', { configurable: true, value: initialMediaRecorder }); Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: initialMediaDevices }); }); describe.each([ { mode: 'published', previewRevision: undefined }, { mode: 'preview', previewRevision: 1 }, ])('CloudChat $mode composer', ({ previewRevision }) => { it('appends speech to the latest typed draft without sending or creating an empty conversation', async () => { const transcription = deferred<{ text: string }>(); voiceApi.transcribeWorksSpeech.mockReturnValueOnce(transcription.promise); render(); const input = await ready(); expect(screen.getByTestId('cloud-chat-composer').querySelector('.agent-attachment-composer')).toBeInTheDocument(); expect(input).toHaveAttribute('rows', '1'); fireEvent.change(input, { target: { value: '原有草稿' } }); await startRecording(); fireEvent.change(input, { target: { value: '录音中继续编辑' } }); fireEvent.click(screen.getByRole('button', { name: '停止语音输入' })); await screen.findByRole('button', { name: '正在识别语音' }); fireEvent.change(input, { target: { value: '识别时的新草稿' } }); await act(async () => transcription.resolve({ text: ' 补充一些例子 ' })); await waitFor(() => expect(input).toHaveValue('识别时的新草稿\n补充一些例子')); expect(voiceApi.transcribeWorksSpeech).toHaveBeenCalledWith({ accessToken: 'voice-access-token', audioBase64: 'voice-base64', fileName: 'voice.wav', mimeType: 'audio/wav', language: 'zh', }); expect(getUserMedia).toHaveBeenCalledWith({ audio: true }); expect(stopTrack).toHaveBeenCalledOnce(); expect(submissions()).toHaveLength(0); expect(api.call.mock.calls.some(([operation]) => operation === 'createThread')).toBe(false); }); it('guards submit, new conversation and history during recording and transcription', async () => { const existing = storedThread('thread-existing', previewRevision === undefined ? 'published' : 'preview'); const base = api.call.getMockImplementation()!; api.call.mockImplementation(async (operation, input) => operation === 'threads' ? { threads: [existing], next_offset: null } : base(operation, input)); const transcription = deferred<{ text: string }>(); voiceApi.transcribeWorksSpeech.mockReturnValueOnce(transcription.promise); render(); const input = await ready(); if (previewRevision !== undefined) fireEvent.click(screen.getByRole('button', { name: '查看历史对话' })); fireEvent.change(input, { target: { value: '这条草稿还没有发' } }); await startRecording(); expect(screen.getByRole('button', { name: '发送', exact: true })).toBeDisabled(); expect(screen.getByRole('button', { name: '添加文件或图片', exact: true })).toBeDisabled(); if (previewRevision !== undefined) { expect(screen.getByRole('button', { name: '新对话' })).toBeDisabled(); expect(screen.getByLabelText('历史对话')).toBeDisabled(); } fireEvent.keyDown(input, { key: 'Enter' }); fireEvent.submit(screen.getByTestId('cloud-chat-composer')); expect(submissions()).toHaveLength(0); fireEvent.click(screen.getByRole('button', { name: '停止语音输入' })); await screen.findByRole('button', { name: '正在识别语音' }); expect(screen.getByRole('button', { name: '发送', exact: true })).toBeDisabled(); expect(screen.getByRole('button', { name: '添加文件或图片', exact: true })).toBeDisabled(); if (previewRevision !== undefined) { expect(screen.getByRole('button', { name: '新对话' })).toBeDisabled(); expect(screen.getByLabelText('历史对话')).toBeDisabled(); } fireEvent.keyDown(input, { key: 'Enter' }); fireEvent.submit(screen.getByTestId('cloud-chat-composer')); expect(submissions()).toHaveLength(0); await act(async () => transcription.resolve({ text: '补充一些例子' })); await waitFor(() => expect(screen.getByRole('button', { name: '发送', exact: true })).toBeEnabled()); }); it('preserves Shift+Enter and Chinese IME before explicit Enter submission', async () => { render(); const input = await ready(); fireEvent.change(input, { target: { value: '把想法写清楚' } }); fireEvent.keyDown(input, { key: 'Enter', shiftKey: true }); fireEvent.keyDown(input, { key: 'Enter', isComposing: true }); fireEvent.keyDown(input, { key: 'Enter', keyCode: 229 }); expect(submissions()).toHaveLength(0); fireEvent.keyDown(input, { key: 'Enter' }); await waitFor(() => expect(submissions()).toHaveLength(1)); expect(submissions()[0]).toEqual([ previewRevision === undefined ? 'submit' : 'preview', expect.objectContaining({ slug, query: '把想法写清楚', ...(previewRevision === undefined ? {} : { expected_revision: 1 }) }), ]); }); }); it('refreshes an expired speech token only once and uses the replacement token', async () => { voiceApi.transcribeWorksSpeech.mockRejectedValueOnce(new WorksSquareApiError('Unauthorized', 401)); render(); const input = await ready(); await startRecording(); fireEvent.click(screen.getByRole('button', { name: '停止语音输入' })); await waitFor(() => expect(input).toHaveValue('补充一些例子')); expect(voiceApi.refreshSession).toHaveBeenCalledOnce(); expect(voiceApi.transcribeWorksSpeech).toHaveBeenCalledTimes(2); expect(voiceApi.transcribeWorksSpeech.mock.calls[1][0]).toMatchObject({ accessToken: 'voice-refreshed-token' }); expect(submissions()).toHaveLength(0); }); it('records and transcribes after StrictMode replays mount lifecycle effects', async () => { render(); const input = await ready(); await startRecording(); fireEvent.click(screen.getByRole('button', { name: '停止语音输入' })); await waitFor(() => expect(input).toHaveValue('补充一些例子')); expect(getUserMedia).toHaveBeenCalledOnce(); expect(voiceApi.transcribeWorksSpeech).toHaveBeenCalledOnce(); expect(stopTrack).toHaveBeenCalledOnce(); expect(submissions()).toHaveLength(0); }); it('ends a second unauthorized speech response without a retry loop or draft loss', async () => { voiceApi.transcribeWorksSpeech.mockRejectedValue(new WorksSquareApiError('Unauthorized', 401)); render(); const input = await ready(); fireEvent.change(input, { target: { value: '保留我的文字' } }); await startRecording(); fireEvent.click(screen.getByRole('button', { name: '停止语音输入' })); await waitFor(() => expect(notifications.error).toHaveBeenCalledWith('语音识别失败', { description: 'Unauthorized' })); expect(input).toHaveValue('保留我的文字'); expect(voiceApi.refreshSession).toHaveBeenCalledOnce(); expect(voiceApi.transcribeWorksSpeech).toHaveBeenCalledTimes(2); expect(screen.getByRole('button', { name: '语音输入', exact: true })).toBeEnabled(); }); it('preserves the original draft when appending speech would exceed the message length limit', async () => { render(); const input = await ready(); const fullDraft = '字'.repeat(32000); fireEvent.change(input, { target: { value: fullDraft } }); await startRecording(); fireEvent.click(screen.getByRole('button', { name: '停止语音输入' })); await screen.findByText('语音内容加入后超过 32000 字,请先精简草稿再试。'); expect(input).toHaveValue(fullDraft); expect(submissions()).toHaveLength(0); }); describe.each(['account', 'slug', 'hidden', 'unmount'] as const)('voice isolation on %s change', change => { it('stops active recording and releases the microphone without transcription', async () => { const view = render(); await ready(); await startRecording(); if (change === 'account') act(() => useAuthStore.setState({ user: { ...useAuthStore.getState().user!, userId: 'agent-user-2' } })); if (change === 'slug') view.rerender(); if (change === 'hidden') view.rerender(); if (change === 'unmount') view.unmount(); await waitFor(() => expect(stopTrack).toHaveBeenCalledOnce()); expect(MockMediaRecorder.latest?.state).toBe('inactive'); expect(voiceApi.transcribeWorksSpeech).not.toHaveBeenCalled(); }); it('discards a transcription that arrives after the conversation context changes', async () => { const transcription = deferred<{ text: string }>(); voiceApi.transcribeWorksSpeech.mockReturnValueOnce(transcription.promise); const view = render(); const input = await ready(); fireEvent.change(input, { target: { value: '自己的草稿' } }); await startRecording(); fireEvent.click(screen.getByRole('button', { name: '停止语音输入' })); await waitFor(() => expect(voiceApi.transcribeWorksSpeech).toHaveBeenCalledOnce()); if (change === 'account') act(() => useAuthStore.setState({ user: { ...useAuthStore.getState().user!, userId: 'agent-user-2' } })); if (change === 'slug') view.rerender(); if (change === 'hidden') view.rerender(); if (change === 'unmount') view.unmount(); await act(async () => transcription.resolve({ text: '迟到的识别结果' })); if (change !== 'unmount') expect((screen.getByRole('textbox', { name: '消息' }) as HTMLTextAreaElement).value).not.toContain('迟到的识别结果'); expect(submissions()).toHaveLength(0); }); }); it('does not carry an old thread transcription into a newly mounted thread', async () => { const transcription = deferred<{ text: string }>(); voiceApi.transcribeWorksSpeech.mockReturnValueOnce(transcription.promise); const view = render(); await ready(); await startRecording(); fireEvent.click(screen.getByRole('button', { name: '停止语音输入' })); await waitFor(() => expect(voiceApi.transcribeWorksSpeech).toHaveBeenCalledOnce()); view.unmount(); render(); const input = await ready(); fireEvent.change(input, { target: { value: '新对话的草稿' } }); await act(async () => transcription.resolve({ text: '旧对话的语音' })); expect(input).toHaveValue('新对话的草稿'); expect(submissions()).toHaveLength(0); }); it('reports an empty recording as pending input until it ends', async () => { const report = vi.fn(); render(); await ready(); await startRecording(); await waitFor(() => expect(report).toHaveBeenCalledWith(expect.any(String), true)); voiceApi.transcribeWorksSpeech.mockResolvedValueOnce({ text: '' }); fireEvent.click(screen.getByRole('button', { name: '停止语音输入' })); await waitFor(() => expect(screen.getByRole('button', { name: '语音输入', exact: true })).toBeEnabled()); expect(report.mock.calls.at(-1)).toEqual([expect.any(String), false]); }); it('disables voice and submission while reading the initial conversation', async () => { const recovery = deferred<{ recent: null; pending: [] }>(); api.recovery.mockReturnValueOnce(recovery.promise); render(); expect(screen.getByRole('textbox', { name: '消息' })).toBeDisabled(); expect(screen.getByRole('button', { name: '语音输入', exact: true })).toBeDisabled(); fireEvent.submit(screen.getByTestId('cloud-chat-composer')); expect(submissions()).toHaveLength(0); expect(getUserMedia).not.toHaveBeenCalled(); await act(async () => recovery.resolve({ recent: null, pending: [] })); await ready(); }); it('does not allow microphone input in an archived conversation', async () => { render(); await screen.findByText('此对话已归档,恢复后可继续发送消息。'); expect(screen.getByRole('textbox', { name: '消息' })).toBeDisabled(); expect(screen.getByRole('button', { name: '语音输入', exact: true })).toBeDisabled(); fireEvent.click(screen.getByRole('button', { name: '语音输入', exact: true })); expect(getUserMedia).not.toHaveBeenCalled(); }); it('disables voice for a request awaiting acknowledgement and preserves its retry contract', async () => { const submission = deferred(); const base = api.call.getMockImplementation()!; api.call.mockImplementation(async (operation, input) => operation === 'submit' ? submission.promise : base(operation, input)); const view = render(); const input = await ready(); fireEvent.change(input, { target: { value: '等待确认的问题' } }); fireEvent.click(screen.getByRole('button', { name: '发送', exact: true })); expect(screen.getByRole('button', { name: '语音输入', exact: true })).toBeDisabled(); view.unmount(); }); it('keeps voice disabled after an uncertain submit result so its immutable retry cannot acquire new text', async () => { const base = api.call.getMockImplementation()!; let attempts = 0; api.call.mockImplementation(async (operation, input) => { if (operation === 'submit' && ++attempts === 1) throw new Error('提交响应丢失'); return base(operation, input); }); render(); const input = await ready(); fireEvent.change(input, { target: { value: '原来的请求' } }); fireEvent.click(screen.getByRole('button', { name: '发送', exact: true })); await screen.findByRole('button', { name: '重试本次发送' }); expect(screen.getByRole('button', { name: '语音输入', exact: true })).toBeDisabled(); expect(input).toBeDisabled(); fireEvent.click(screen.getByRole('button', { name: '语音输入', exact: true })); expect(getUserMedia).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: '重试本次发送' })); await waitFor(() => expect(submissions()).toHaveLength(2)); expect(submissions()[1][1]).toEqual(submissions()[0][1]); await waitFor(() => expect(input).toBeEnabled()); }); it('keeps preview restart disabled until the recording finishes', async () => { const save = vi.fn().mockResolvedValue(2); const view = render(); await ready(); await startRecording(); view.rerender(); expect(screen.getByRole('button', { name: '用最新草稿试用' })).toBeDisabled(); expect(save).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: '停止语音输入' })); await waitFor(() => expect(screen.getByRole('button', { name: '用最新草稿试用' })).toBeEnabled()); expect(submissions()).toHaveLength(0); }); it('keeps attachment permission and confirmation ahead of lazy thread creation', async () => { api.upload.mockResolvedValue({ object_name: 'temporary/notes.txt', file_name: 'notes.txt', file_type: 'txt', parse_supported: true, parse_methods: ['disable', 'rapid_ocr'] }); render(); await ready(); fireEvent.keyDown(screen.getByRole('button', { name: '添加文件或图片' }), { key: 'ArrowDown' }); fireEvent.click(await screen.findByRole('menuitem', { name: '添加并选择读取方式', exact: true })); await screen.findByRole('button', { name: '添加到消息' }); expect(api.upload).toHaveBeenCalledOnce(); expect(screen.getByRole('button', { name: '语音输入', exact: true })).toBeDisabled(); expect(api.call.mock.calls.some(([operation]) => operation === 'createThread')).toBe(false); fireEvent.click(screen.getByRole('button', { name: '取消添加', exact: true })); await waitFor(() => expect(screen.getByRole('button', { name: '语音输入', exact: true })).toBeEnabled()); }); it('routes files selected from the shared toolbar into confirmed message attachments', async () => { const base = api.call.getMockImplementation()!; api.uploadFile.mockResolvedValue({ object_name: 'temporary/image.png', file_name: 'image.png', file_type: 'png' }); api.call.mockImplementation(async (operation, input) => { if (operation === 'createThread') return { thread_id: 'file-thread', client_thread_id: input.thread_id }; if (operation === 'confirmAttachment') return { attachments: [{ file_id: 'image-1', file_name: 'image.png', file_size: 8192, path: '/attachments/image.png', original_path: '/original/image.png' }] }; return base(operation, input); }); render(); const input = await ready(); const file = new File(['image fixture'], 'image.png', { type: 'image/png' }); fireEvent.change(screen.getByTestId('cloud-file-attachment-input'), { target: { files: [file] } }); await screen.findByRole('button', { name: '移除附件 image.png' }); expect(api.uploadFile).toHaveBeenCalledWith(file, expect.any(Function)); expect(api.call).toHaveBeenCalledWith('confirmAttachment', { thread_id: 'file-thread', attachments: [{ object_name: 'temporary/image.png', file_type: 'png' }], }); expect(submissions()).toHaveLength(0); fireEvent.change(input, { target: { value: '看看这张图片' } }); fireEvent.click(screen.getByRole('button', { name: '发送', exact: true })); await waitFor(() => expect(submissions()).toHaveLength(1)); expect(submissions()[0][1]).toMatchObject({ thread_id: 'file-thread', query: '看看这张图片', attachment_file_ids: ['image-1'] }); }); it('keeps the round stop action bound to the active run without sending a new request', async () => { const existing = storedThread('running-thread'); const run = { agent_run_id: 'running-run', request_id: 'running-request', thread_id: existing.thread_id, agent_slug: slug, status: 'running', version: '1' }; const base = api.call.getMockImplementation()!; api.call.mockImplementation(async (operation, input) => { if (operation === 'history') return { thread_id: existing.thread_id, messages: [], run, queued_requests: [], next_offset: null }; if (operation === 'cancelRun') return { cancelled: true }; return base(operation, input); }); render(); const stop = await screen.findByRole('button', { name: '停止', exact: true }); await waitFor(() => expect(stop).toBeEnabled()); fireEvent.click(stop); await waitFor(() => expect(api.call).toHaveBeenCalledWith('cancelRun', { run_id: 'running-run' })); expect(submissions()).toHaveLength(0); });