426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
587 lines
18 KiB
TypeScript
587 lines
18 KiB
TypeScript
/**
|
|
* Functional streaming test for the ElevenLabs TTS plugin.
|
|
*
|
|
* The plugin's `TEXT_TO_SPEECH` model handler internally calls
|
|
* `client.textToSpeech.stream()` and drains the resulting
|
|
* `ReadableStream<Uint8Array>` via `readStreamToUint8Array`. This test
|
|
* mocks the ElevenLabs SDK so we can:
|
|
* 1. Confirm the plugin actually awaits the SDK's streaming API.
|
|
* 2. Confirm a multi-chunk stream is fully drained (truly streaming —
|
|
* the reader pulls chunk N+1 before the producer writes the last
|
|
* chunk).
|
|
* 3. Confirm the resolved bytes preserve chunk order and total size.
|
|
*/
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
const streamMock = vi.fn();
|
|
const convertMock = vi.fn();
|
|
const clientConfigMock = vi.fn();
|
|
|
|
// Mock the SDK before the plugin module is loaded.
|
|
vi.mock("@elevenlabs/elevenlabs-js", () => {
|
|
return {
|
|
ElevenLabsClient: class {
|
|
constructor(config: unknown) {
|
|
clientConfigMock(config);
|
|
}
|
|
|
|
textToSpeech = {
|
|
stream: streamMock,
|
|
};
|
|
speechToText = {
|
|
convert: convertMock,
|
|
};
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock("@elevenlabs/elevenlabs-js/api", () => {
|
|
return {
|
|
SpeechToTextConvertRequestModelId: { ScribeV1: "scribe_v1" },
|
|
SpeechToTextConvertRequestTimestampsGranularity: {
|
|
None: "none",
|
|
Word: "word",
|
|
},
|
|
TextToSpeechStreamRequestOutputFormat: {
|
|
Mp3_44100_128: "mp3_44100_128",
|
|
Pcm16000: "pcm_16000",
|
|
},
|
|
};
|
|
});
|
|
|
|
interface TestRuntime {
|
|
agentId: string;
|
|
getSetting: (key: string) => string | undefined;
|
|
character: { settings: Record<string, unknown> };
|
|
}
|
|
|
|
function createTestRuntime(
|
|
settings: Record<string, string | undefined> = {},
|
|
): TestRuntime {
|
|
const merged: Record<string, string | undefined> = {
|
|
ELEVENLABS_API_KEY: "sk-test-key",
|
|
ELEVENLABS_VOICE_ID: "voice-123",
|
|
ELEVENLABS_MODEL_ID: "eleven_monolingual_v1",
|
|
ELEVENLABS_OUTPUT_FORMAT: "mp3_44100_128",
|
|
...settings,
|
|
};
|
|
return {
|
|
agentId: "test-agent",
|
|
getSetting: (key: string) => merged[key],
|
|
character: { settings: {} },
|
|
};
|
|
}
|
|
|
|
function setGlobalValue(key: string, value: unknown): () => void {
|
|
const hadValue = Object.hasOwn(globalThis, key);
|
|
const previous = (globalThis as Record<string, unknown>)[key];
|
|
Object.defineProperty(globalThis, key, {
|
|
configurable: true,
|
|
writable: true,
|
|
value,
|
|
});
|
|
return () => {
|
|
if (hadValue) {
|
|
Object.defineProperty(globalThis, key, {
|
|
configurable: true,
|
|
writable: true,
|
|
value: previous,
|
|
});
|
|
} else {
|
|
delete (globalThis as Record<string, unknown>)[key];
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build a ReadableStream that yields `chunkCount` Uint8Array chunks at
|
|
* `intervalMs` apart. Each chunk is filled with a distinct byte value so
|
|
* we can verify ordering on the consumer side.
|
|
*/
|
|
function makeChunkedStream(
|
|
chunkSizes: number[],
|
|
intervalMs: number,
|
|
): {
|
|
stream: ReadableStream<Uint8Array>;
|
|
chunksEnqueued: { time: number; size: number }[];
|
|
} {
|
|
const chunksEnqueued: { time: number; size: number }[] = [];
|
|
const start = Date.now();
|
|
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
async start(controller) {
|
|
for (let i = 0; i < chunkSizes.length; i += 1) {
|
|
if (i > 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
}
|
|
const size = chunkSizes[i];
|
|
const chunk = new Uint8Array(size);
|
|
chunk.fill(i + 1);
|
|
chunksEnqueued.push({ time: Date.now() - start, size });
|
|
controller.enqueue(chunk);
|
|
}
|
|
controller.close();
|
|
},
|
|
});
|
|
|
|
return { stream, chunksEnqueued };
|
|
}
|
|
|
|
describe("plugin-elevenlabs TTS streaming", () => {
|
|
it("drains a multi-chunk stream and concatenates bytes in order", async () => {
|
|
const chunkSizes = [16, 32, 24, 8];
|
|
const { stream } = makeChunkedStream(chunkSizes, 5);
|
|
streamMock.mockResolvedValueOnce(stream);
|
|
|
|
const { elevenLabsPlugin } = await import("../src/index.js");
|
|
const ttsHandler = elevenLabsPlugin.models?.TEXT_TO_SPEECH;
|
|
expect(ttsHandler).toBeDefined();
|
|
|
|
const runtime = createTestRuntime();
|
|
// The plugin handler signature accepts (runtime, input).
|
|
const result = (await ttsHandler?.(
|
|
runtime as unknown as Parameters<NonNullable<typeof ttsHandler>>[0],
|
|
"hello world",
|
|
)) as Uint8Array;
|
|
|
|
expect(result).toBeInstanceOf(Uint8Array);
|
|
const expectedTotal = chunkSizes.reduce((a, b) => a + b, 0);
|
|
expect(result.byteLength).toBe(expectedTotal);
|
|
|
|
// Verify chunk ordering: bytes from chunk 0 (filled with 1) come first,
|
|
// then chunk 1 (filled with 2), etc.
|
|
let cursor = 0;
|
|
for (let i = 0; i < chunkSizes.length; i += 1) {
|
|
const expected = i + 1;
|
|
for (let b = 0; b < chunkSizes[i]; b += 1) {
|
|
expect(result[cursor + b]).toBe(expected);
|
|
}
|
|
cursor += chunkSizes[i];
|
|
}
|
|
});
|
|
|
|
it("invokes the SDK with the configured voice + format params", async () => {
|
|
streamMock.mockReset();
|
|
const { stream } = makeChunkedStream([8], 0);
|
|
streamMock.mockResolvedValueOnce(stream);
|
|
|
|
const { elevenLabsPlugin } = await import("../src/index.js");
|
|
const ttsHandler = elevenLabsPlugin.models?.TEXT_TO_SPEECH;
|
|
const runtime = createTestRuntime({
|
|
ELEVENLABS_VOICE_ID: "voice-XYZ",
|
|
ELEVENLABS_OUTPUT_FORMAT: "pcm_16000",
|
|
});
|
|
|
|
await ttsHandler?.(
|
|
runtime as unknown as Parameters<NonNullable<typeof ttsHandler>>[0],
|
|
{ text: "stream me" },
|
|
);
|
|
|
|
expect(streamMock).toHaveBeenCalledTimes(1);
|
|
const [voiceArg, optsArg] = streamMock.mock.calls[0] as [
|
|
string,
|
|
Record<string, unknown>,
|
|
];
|
|
expect(voiceArg).toBe("voice-XYZ");
|
|
expect(optsArg.text).toBe("stream me");
|
|
expect(optsArg.outputFormat).toBe("pcm_16000");
|
|
});
|
|
|
|
it("respects an explicit per-call format override", async () => {
|
|
streamMock.mockReset();
|
|
const { stream } = makeChunkedStream([4], 0);
|
|
streamMock.mockResolvedValueOnce(stream);
|
|
|
|
const { elevenLabsPlugin } = await import("../src/index.js");
|
|
const ttsHandler = elevenLabsPlugin.models?.TEXT_TO_SPEECH;
|
|
const runtime = createTestRuntime();
|
|
|
|
await ttsHandler?.(
|
|
runtime as unknown as Parameters<NonNullable<typeof ttsHandler>>[0],
|
|
{ text: "override format", format: "pcm_16000" },
|
|
);
|
|
|
|
const [, optsArg] = streamMock.mock.calls[0] as [
|
|
string,
|
|
Record<string, unknown>,
|
|
];
|
|
expect(optsArg.outputFormat).toBe("pcm_16000");
|
|
});
|
|
|
|
it("propagates SDK errors instead of returning empty bytes", async () => {
|
|
streamMock.mockReset();
|
|
streamMock.mockRejectedValueOnce(new Error("upstream 503"));
|
|
|
|
const { elevenLabsPlugin } = await import("../src/index.js");
|
|
const ttsHandler = elevenLabsPlugin.models?.TEXT_TO_SPEECH;
|
|
const runtime = createTestRuntime();
|
|
|
|
await expect(
|
|
ttsHandler?.(
|
|
runtime as unknown as Parameters<NonNullable<typeof ttsHandler>>[0],
|
|
"fail me",
|
|
),
|
|
).rejects.toThrow(/upstream 503/);
|
|
});
|
|
|
|
// Malformed provider response: the SDK resolves with no stream. The handler
|
|
// must throw rather than fabricate an empty audio buffer (issue #12797).
|
|
it.each([
|
|
null,
|
|
undefined,
|
|
])("throws on an empty TTS stream body (%s) instead of returning fake audio", async (emptyBody) => {
|
|
streamMock.mockReset();
|
|
streamMock.mockResolvedValueOnce(emptyBody);
|
|
|
|
const { elevenLabsPlugin } = await import("../src/index.js");
|
|
const ttsHandler = elevenLabsPlugin.models?.TEXT_TO_SPEECH;
|
|
const runtime = createTestRuntime();
|
|
|
|
await expect(
|
|
ttsHandler?.(
|
|
runtime as unknown as Parameters<NonNullable<typeof ttsHandler>>[0],
|
|
"no stream",
|
|
),
|
|
).rejects.toThrow("Empty response body from ElevenLabs SDK");
|
|
});
|
|
|
|
it.each([
|
|
"",
|
|
" \n\t ",
|
|
null,
|
|
{ text: "" },
|
|
{ text: "hello", voiceId: " " },
|
|
{ text: "hello", model: "" },
|
|
{ text: "hello", format: "\t" },
|
|
])("rejects hostile TTS input before streaming %#", async (input) => {
|
|
streamMock.mockReset();
|
|
|
|
const { elevenLabsPlugin } = await import("../src/index.js");
|
|
const ttsHandler = elevenLabsPlugin.models?.TEXT_TO_SPEECH;
|
|
const runtime = createTestRuntime();
|
|
|
|
await expect(
|
|
ttsHandler?.(
|
|
runtime as unknown as Parameters<NonNullable<typeof ttsHandler>>[0],
|
|
input as never,
|
|
),
|
|
).rejects.toThrow(/ElevenLabs TTS .*required|must be a non-empty string/);
|
|
expect(streamMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses a browser proxy without sending synthetic API credentials", async () => {
|
|
streamMock.mockReset();
|
|
clientConfigMock.mockReset();
|
|
const { stream } = makeChunkedStream([4], 0);
|
|
streamMock.mockResolvedValueOnce(stream);
|
|
const restoreDocument = setGlobalValue("document", {});
|
|
|
|
const { elevenLabsPlugin } = await import("../src/index.js");
|
|
const ttsHandler = elevenLabsPlugin.models?.TEXT_TO_SPEECH;
|
|
const runtime = createTestRuntime({
|
|
ELEVENLABS_API_KEY: undefined,
|
|
ELEVENLABS_BROWSER_URL: "https://elevenlabs-proxy.example/v1",
|
|
});
|
|
|
|
await ttsHandler?.(
|
|
runtime as unknown as Parameters<NonNullable<typeof ttsHandler>>[0],
|
|
"browser proxy",
|
|
);
|
|
|
|
expect(clientConfigMock).toHaveBeenCalledWith({
|
|
baseUrl: "https://elevenlabs-proxy.example/v1",
|
|
});
|
|
expect(streamMock).toHaveBeenCalledTimes(1);
|
|
|
|
restoreDocument();
|
|
});
|
|
|
|
it("rejects browser TTS without an API key or browser proxy", async () => {
|
|
streamMock.mockReset();
|
|
clientConfigMock.mockReset();
|
|
const restoreDocument = setGlobalValue("document", {});
|
|
|
|
const { elevenLabsPlugin } = await import("../src/index.js");
|
|
const ttsHandler = elevenLabsPlugin.models?.TEXT_TO_SPEECH;
|
|
const runtime = createTestRuntime({
|
|
ELEVENLABS_API_KEY: undefined,
|
|
ELEVENLABS_BROWSER_URL: undefined,
|
|
});
|
|
|
|
await expect(
|
|
ttsHandler?.(
|
|
runtime as unknown as Parameters<NonNullable<typeof ttsHandler>>[0],
|
|
"missing proxy",
|
|
),
|
|
).rejects.toThrow("ELEVENLABS_API_KEY is required");
|
|
expect(clientConfigMock).not.toHaveBeenCalled();
|
|
expect(streamMock).not.toHaveBeenCalled();
|
|
|
|
restoreDocument();
|
|
});
|
|
});
|
|
|
|
describe("plugin-elevenlabs STT transcription", () => {
|
|
it("sends Buffer input with configured STT options", async () => {
|
|
convertMock.mockReset();
|
|
convertMock.mockResolvedValueOnce({ text: "transcribed buffer" });
|
|
|
|
const { elevenLabsPlugin } = await import("../src/index.js");
|
|
const transcriptionHandler = elevenLabsPlugin.models?.TRANSCRIPTION;
|
|
const runtime = createTestRuntime({
|
|
ELEVENLABS_STT_MODEL_ID: "scribe_v1",
|
|
ELEVENLABS_STT_LANGUAGE_CODE: "en",
|
|
ELEVENLABS_STT_TIMESTAMPS_GRANULARITY: "word",
|
|
ELEVENLABS_STT_DIARIZE: "true",
|
|
ELEVENLABS_STT_NUM_SPEAKERS: "2",
|
|
ELEVENLABS_STT_TAG_AUDIO_EVENTS: "true",
|
|
});
|
|
const audio = Buffer.from([1, 2, 3, 4]);
|
|
|
|
await expect(
|
|
transcriptionHandler?.(
|
|
runtime as unknown as Parameters<
|
|
NonNullable<typeof transcriptionHandler>
|
|
>[0],
|
|
audio,
|
|
),
|
|
).resolves.toBe("transcribed buffer");
|
|
|
|
expect(convertMock).toHaveBeenCalledWith({
|
|
modelId: "scribe_v1",
|
|
file: audio,
|
|
languageCode: "en",
|
|
timestampsGranularity: "word",
|
|
diarize: true,
|
|
numSpeakers: 2,
|
|
tagAudioEvents: true,
|
|
});
|
|
});
|
|
|
|
it("fetches audioUrl input and sends explicit none timestamp granularity", async () => {
|
|
convertMock.mockReset();
|
|
convertMock.mockResolvedValueOnce({
|
|
transcripts: [{ text: "left" }, { text: "right" }],
|
|
});
|
|
const fetchMock = vi.fn(async () => ({
|
|
ok: true,
|
|
arrayBuffer: async () => new Uint8Array([9, 8, 7]).buffer,
|
|
}));
|
|
const restoreFetch = setGlobalValue("fetch", fetchMock);
|
|
|
|
const { elevenLabsPlugin } = await import("../src/index.js");
|
|
const transcriptionHandler = elevenLabsPlugin.models?.TRANSCRIPTION;
|
|
const runtime = createTestRuntime({
|
|
ELEVENLABS_STT_TIMESTAMPS_GRANULARITY: "none",
|
|
ELEVENLABS_STT_DIARIZE: "false",
|
|
ELEVENLABS_STT_TAG_AUDIO_EVENTS: "false",
|
|
});
|
|
|
|
await expect(
|
|
transcriptionHandler?.(
|
|
runtime as unknown as Parameters<
|
|
NonNullable<typeof transcriptionHandler>
|
|
>[0],
|
|
{ audioUrl: "https://audio.example/file.wav" },
|
|
),
|
|
).resolves.toBe("left\nright");
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith("https://audio.example/file.wav");
|
|
expect(convertMock).toHaveBeenCalledWith({
|
|
modelId: "scribe_v1",
|
|
file: Buffer.from([9, 8, 7]),
|
|
timestampsGranularity: "none",
|
|
});
|
|
|
|
restoreFetch();
|
|
});
|
|
|
|
it("throws when URL audio fetch fails before calling the SDK", async () => {
|
|
convertMock.mockReset();
|
|
const restoreFetch = setGlobalValue(
|
|
"fetch",
|
|
vi.fn(async () => ({
|
|
ok: false,
|
|
arrayBuffer: async () => new ArrayBuffer(0),
|
|
})),
|
|
);
|
|
|
|
const { elevenLabsPlugin } = await import("../src/index.js");
|
|
const transcriptionHandler = elevenLabsPlugin.models?.TRANSCRIPTION;
|
|
const runtime = createTestRuntime();
|
|
|
|
await expect(
|
|
transcriptionHandler?.(
|
|
runtime as unknown as Parameters<
|
|
NonNullable<typeof transcriptionHandler>
|
|
>[0],
|
|
"https://audio.example/missing.wav",
|
|
),
|
|
).rejects.toThrow(
|
|
"Failed to fetch audio from URL: https://audio.example/missing.wav",
|
|
);
|
|
expect(convertMock).not.toHaveBeenCalled();
|
|
|
|
restoreFetch();
|
|
});
|
|
|
|
it.each([
|
|
"",
|
|
"not a url",
|
|
"file:///etc/passwd",
|
|
"data:audio/wav;base64,AAAA",
|
|
{ audioUrl: "javascript:alert(1)" },
|
|
{ audioUrl: " " },
|
|
null,
|
|
{},
|
|
])("rejects hostile transcription URL input before fetch %#", async (input) => {
|
|
convertMock.mockReset();
|
|
const fetchMock = vi.fn();
|
|
const restoreFetch = setGlobalValue("fetch", fetchMock);
|
|
|
|
const { elevenLabsPlugin } = await import("../src/index.js");
|
|
const transcriptionHandler = elevenLabsPlugin.models?.TRANSCRIPTION;
|
|
const runtime = createTestRuntime();
|
|
|
|
await expect(
|
|
transcriptionHandler?.(
|
|
runtime as unknown as Parameters<
|
|
NonNullable<typeof transcriptionHandler>
|
|
>[0],
|
|
input as never,
|
|
),
|
|
).rejects.toThrow(/audioUrl|Invalid input type/);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(convertMock).not.toHaveBeenCalled();
|
|
|
|
restoreFetch();
|
|
});
|
|
|
|
it("rejects unsupported STT enum values", async () => {
|
|
convertMock.mockReset();
|
|
|
|
const { elevenLabsPlugin } = await import("../src/index.js");
|
|
const transcriptionHandler = elevenLabsPlugin.models?.TRANSCRIPTION;
|
|
const runtime = createTestRuntime({
|
|
ELEVENLABS_STT_MODEL_ID: "bad_model",
|
|
});
|
|
|
|
await expect(
|
|
transcriptionHandler?.(
|
|
runtime as unknown as Parameters<
|
|
NonNullable<typeof transcriptionHandler>
|
|
>[0],
|
|
Buffer.from([1]),
|
|
),
|
|
).rejects.toThrow("Unsupported ElevenLabs STT model: bad_model");
|
|
expect(convertMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects invalid numSpeakers before calling the SDK", async () => {
|
|
convertMock.mockReset();
|
|
|
|
const { elevenLabsPlugin } = await import("../src/index.js");
|
|
const transcriptionHandler = elevenLabsPlugin.models?.TRANSCRIPTION;
|
|
const runtime = createTestRuntime({
|
|
ELEVENLABS_STT_DIARIZE: "true",
|
|
ELEVENLABS_STT_NUM_SPEAKERS: "abc",
|
|
});
|
|
|
|
await expect(
|
|
transcriptionHandler?.(
|
|
runtime as unknown as Parameters<
|
|
NonNullable<typeof transcriptionHandler>
|
|
>[0],
|
|
Buffer.from([1]),
|
|
),
|
|
).rejects.toThrow(
|
|
"ELEVENLABS_STT_NUM_SPEAKERS must be an integer between 1 and 32",
|
|
);
|
|
expect(convertMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// Provider rejection: the SDK throws. The handler must surface the failure to
|
|
// the caller rather than returning a fabricated/empty transcript (#12797).
|
|
it("propagates STT SDK errors instead of returning an empty transcript", async () => {
|
|
convertMock.mockReset();
|
|
convertMock.mockRejectedValueOnce(new Error("stt upstream 500"));
|
|
|
|
const { elevenLabsPlugin } = await import("../src/index.js");
|
|
const transcriptionHandler = elevenLabsPlugin.models?.TRANSCRIPTION;
|
|
const runtime = createTestRuntime();
|
|
|
|
await expect(
|
|
transcriptionHandler?.(
|
|
runtime as unknown as Parameters<
|
|
NonNullable<typeof transcriptionHandler>
|
|
>[0],
|
|
Buffer.from([1, 2, 3]),
|
|
),
|
|
).rejects.toThrow(/stt upstream 500/);
|
|
});
|
|
|
|
// Malformed provider response: the SDK resolves with no payload. The handler
|
|
// must throw rather than return an empty-string transcript (#12797).
|
|
it.each([
|
|
null,
|
|
undefined,
|
|
])("throws on an empty STT response (%s) instead of returning fake text", async (emptyResponse) => {
|
|
convertMock.mockReset();
|
|
convertMock.mockResolvedValueOnce(emptyResponse);
|
|
|
|
const { elevenLabsPlugin } = await import("../src/index.js");
|
|
const transcriptionHandler = elevenLabsPlugin.models?.TRANSCRIPTION;
|
|
const runtime = createTestRuntime();
|
|
|
|
await expect(
|
|
transcriptionHandler?.(
|
|
runtime as unknown as Parameters<
|
|
NonNullable<typeof transcriptionHandler>
|
|
>[0],
|
|
Buffer.from([1, 2, 3]),
|
|
),
|
|
).rejects.toThrow("Empty response from ElevenLabs STT API");
|
|
});
|
|
|
|
it("supports browser object URL input without a global Buffer", async () => {
|
|
convertMock.mockReset();
|
|
convertMock.mockResolvedValueOnce({ text: "browser transcript" });
|
|
const blob = new Blob([new Uint8Array([5, 4, 3])], {
|
|
type: "audio/wav",
|
|
});
|
|
const restoreDocument = setGlobalValue("document", {});
|
|
const restoreBuffer = setGlobalValue("Buffer", undefined);
|
|
const restoreFetch = setGlobalValue(
|
|
"fetch",
|
|
vi.fn(async () => ({
|
|
ok: true,
|
|
blob: async () => blob,
|
|
arrayBuffer: async () => new Uint8Array([5, 4, 3]).buffer,
|
|
})),
|
|
);
|
|
|
|
const { elevenLabsPlugin } = await import("../src/index.js");
|
|
const transcriptionHandler = elevenLabsPlugin.models?.TRANSCRIPTION;
|
|
const runtime = createTestRuntime();
|
|
|
|
await expect(
|
|
transcriptionHandler?.(
|
|
runtime as unknown as Parameters<
|
|
NonNullable<typeof transcriptionHandler>
|
|
>[0],
|
|
{ audioUrl: "https://audio.example/browser.wav" },
|
|
),
|
|
).resolves.toBe("browser transcript");
|
|
|
|
expect(convertMock).toHaveBeenCalledWith({
|
|
modelId: "scribe_v1",
|
|
file: blob,
|
|
timestampsGranularity: "word",
|
|
});
|
|
|
|
restoreFetch();
|
|
restoreBuffer();
|
|
restoreDocument();
|
|
});
|
|
});
|