Files
makelore/tests/unit/cloud-chat-voice.test.tsx
T

442 lines
24 KiB
TypeScript

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<typeof import('@/lib/works-square')>(),
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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(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<string, unknown>) => {
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(<CloudChat slug={slug} previewRevision={previewRevision} />);
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(<CloudChat slug={slug} previewRevision={previewRevision} />);
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(<CloudChat slug={slug} previewRevision={previewRevision} />);
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(<CloudChat slug={slug} />);
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(<StrictMode><CloudChat slug={slug} /></StrictMode>);
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(<CloudChat slug={slug} />);
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(<CloudChat slug={slug} />);
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(<CloudChat slug={slug} />);
await ready();
await startRecording();
if (change === 'account') act(() => useAuthStore.setState({ user: { ...useAuthStore.getState().user!, userId: 'agent-user-2' } }));
if (change === 'slug') view.rerender(<CloudChat slug={otherSlug} />);
if (change === 'hidden') view.rerender(<CloudChat slug={slug} activeView={false} />);
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(<CloudChat slug={slug} />);
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(<CloudChat slug={otherSlug} />);
if (change === 'hidden') view.rerender(<CloudChat slug={slug} activeView={false} />);
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(<CloudChat slug={slug} initialThread={storedThread('first')} />);
await ready();
await startRecording();
fireEvent.click(screen.getByRole('button', { name: '停止语音输入' }));
await waitFor(() => expect(voiceApi.transcribeWorksSpeech).toHaveBeenCalledOnce());
view.unmount();
render(<CloudChat slug={slug} initialThread={storedThread('second')} />);
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(<CloudPendingContext.Provider value={report}><CloudChat slug={slug} /></CloudPendingContext.Provider>);
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(<CloudChat slug={slug} />);
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(<CloudChat slug={slug} initialThread={{ ...storedThread('archived'), archived: true }} />);
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<never>();
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (operation, input) => operation === 'submit' ? submission.promise : base(operation, input));
const view = render(<CloudChat slug={slug} />);
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(<CloudChat slug={slug} />);
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(<CloudChat slug={slug} previewRevision={1} onSaveForPreview={save} />);
await ready();
await startRecording();
view.rerender(<CloudChat slug={slug} previewRevision={2} onSaveForPreview={save} />);
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(<CloudChat slug={slug} />);
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(<CloudChat slug={slug} />);
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(<CloudChat slug={slug} initialThread={existing} />);
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);
});