Files
2026-07-13 13:20:22 +08:00

131 lines
4.3 KiB
TypeScript

/**
* @license
* Copyright 2025 AionUi (aionui.com)
* SPDX-License-Identifier: Apache-2.0
*
* Unit tests for renderer/services/SpeechToTextService.ts.
* Regression tests for voice input failing with 400: the backend /api/stt
* endpoint only accepts multipart with fields `file`, `fileName`, `mimeType`,
* `languageHint` — the previous code sent JSON (Electron) or a wrong
* `audio` multipart field (WebUI).
*
* @vitest-environment node
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { transcribeAudioBlob } from '@/renderer/services/SpeechToTextService';
type XhrListener = () => void;
class FakeXMLHttpRequest {
static instances: FakeXMLHttpRequest[] = [];
method = '';
url = '';
withCredentials = false;
status = 0;
statusText = '';
responseText = '';
sentBody: unknown;
private listeners: Record<string, XhrListener> = {};
open(method: string, url: string) {
this.method = method;
this.url = url;
}
addEventListener(name: string, listener: XhrListener) {
this.listeners[name] = listener;
}
send(body: unknown) {
this.sentBody = body;
FakeXMLHttpRequest.instances.push(this);
}
respond(status: number, responseText: string, statusText = '') {
this.status = status;
this.statusText = statusText;
this.responseText = responseText;
this.listeners.load?.();
}
}
const waitForRequest = async (): Promise<FakeXMLHttpRequest> => {
for (let i = 0; i < 20 && FakeXMLHttpRequest.instances.length === 0; i++) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
const xhr = FakeXMLHttpRequest.instances[0];
expect(xhr, 'expected an XHR request to /api/stt').toBeDefined();
return xhr;
};
describe('SpeechToTextService.transcribeAudioBlob', () => {
beforeEach(() => {
FakeXMLHttpRequest.instances = [];
vi.stubGlobal('XMLHttpRequest', FakeXMLHttpRequest);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('sends multipart fields matching the backend contract (file/fileName/mimeType/languageHint)', async () => {
const blob = new Blob(['fake-audio'], { type: 'audio/webm' });
const pending = transcribeAudioBlob(blob, 'zh-CN');
const xhr = await waitForRequest();
expect(xhr.method).toBe('POST');
expect(xhr.url).toContain('/api/stt');
// Credentialed cross-origin requests are rejected by the browser because
// the desktop backend responds with Access-Control-Allow-Origin: *
expect(xhr.withCredentials).toBe(false);
expect(xhr.sentBody).toBeInstanceOf(FormData);
const formData = xhr.sentBody as FormData;
const file = formData.get('file');
expect(file, "backend requires the audio under field 'file'").toBeInstanceOf(Blob);
expect((file as File).name).toBe('speech-input.webm');
expect(formData.get('fileName')).toBe('speech-input.webm');
expect(formData.get('mimeType')).toBe('audio/webm');
expect(formData.get('languageHint')).toBe('zh-CN');
xhr.respond(
200,
JSON.stringify({ success: true, data: { model: 'whisper-1', provider: 'openai', text: 'hello' } })
);
await expect(pending).resolves.toEqual({ model: 'whisper-1', provider: 'openai', text: 'hello' });
});
it('rejects with the backend error code so STT errors map correctly', async () => {
const blob = new Blob(['fake-audio'], { type: 'audio/webm' });
const pending = transcribeAudioBlob(blob);
const xhr = await waitForRequest();
xhr.respond(400, JSON.stringify({ success: false, error: 'STT is not enabled', code: 'STT_DISABLED' }));
await expect(pending).rejects.toThrow(/STT_DISABLED/);
});
it('rejects with STT_FILE_TOO_LARGE on 413', async () => {
const blob = new Blob(['fake-audio'], { type: 'audio/webm' });
const pending = transcribeAudioBlob(blob);
const xhr = await waitForRequest();
xhr.respond(413, '');
await expect(pending).rejects.toThrow('STT_FILE_TOO_LARGE');
});
it('falls back to STT_REQUEST_FAILED with status for non-JSON error bodies', async () => {
const blob = new Blob(['fake-audio'], { type: 'audio/webm' });
const pending = transcribeAudioBlob(blob);
const xhr = await waitForRequest();
xhr.respond(500, 'Internal Server Error', 'Internal Server Error');
await expect(pending).rejects.toThrow(/STT_REQUEST_FAILED:500/);
});
});