442 lines
24 KiB
TypeScript
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);
|
|
});
|