Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
484
extensions/inworld/tts.test.ts
Normal file
484
extensions/inworld/tts.test.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
// Inworld tests cover tts plugin behavior.
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
|
||||
fetchWithSsrFGuardMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
|
||||
};
|
||||
});
|
||||
|
||||
import { inworldTTS, listInworldVoices } from "./tts.js";
|
||||
|
||||
type GuardRequest = {
|
||||
url: string;
|
||||
init?: RequestInit;
|
||||
auditContext?: string;
|
||||
policy?: unknown;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
function queueGuardedResponse(response: Response): { release: ReturnType<typeof vi.fn> } {
|
||||
const release = vi.fn(async () => {});
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({ response, release });
|
||||
return { release };
|
||||
}
|
||||
|
||||
function lastGuardRequest(): GuardRequest {
|
||||
const calls = fetchWithSsrFGuardMock.mock.calls;
|
||||
const call = calls[calls.length - 1];
|
||||
if (!call) {
|
||||
throw new Error("fetchWithSsrFGuard was not called");
|
||||
}
|
||||
return call[0] as GuardRequest;
|
||||
}
|
||||
|
||||
function readRequestBody(request: GuardRequest): string {
|
||||
const body = request.init?.body;
|
||||
if (typeof body !== "string") {
|
||||
throw new Error("expected request body to be a string");
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
const guardedSuccessReleaseCases = [
|
||||
{
|
||||
name: "listInworldVoices",
|
||||
run: async () => {
|
||||
const { release } = queueGuardedResponse(
|
||||
new Response(JSON.stringify({ voices: [] }), { status: 200 }),
|
||||
);
|
||||
|
||||
await listInworldVoices({ apiKey: "test-key" });
|
||||
return release;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "inworldTTS",
|
||||
run: async () => {
|
||||
const chunk = Buffer.from("audio").toString("base64");
|
||||
const { release } = queueGuardedResponse(
|
||||
new Response(JSON.stringify({ result: { audioContent: chunk } }), { status: 200 }),
|
||||
);
|
||||
|
||||
await inworldTTS({ text: "test", apiKey: "test-key" });
|
||||
return release;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("Inworld guarded dispatcher lifecycle", () => {
|
||||
afterEach(() => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each(guardedSuccessReleaseCases)(
|
||||
"$name releases the guarded dispatcher after success",
|
||||
async ({ run }) => {
|
||||
const release = await run();
|
||||
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("listInworldVoices", () => {
|
||||
afterEach(() => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("maps Inworld voice metadata into speech voice options", async () => {
|
||||
queueGuardedResponse(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
voices: [
|
||||
{
|
||||
voiceId: "Dennis",
|
||||
displayName: "Dennis",
|
||||
description: "Middle-aged man with a smooth, calm and friendly voice",
|
||||
langCode: "EN_US",
|
||||
tags: ["male", "middle-aged", "smooth", "calm", "friendly"],
|
||||
source: "SYSTEM",
|
||||
},
|
||||
{
|
||||
voiceId: "Ashley",
|
||||
displayName: "Ashley",
|
||||
description: "A warm, natural female voice",
|
||||
langCode: "EN_US",
|
||||
tags: ["female", "warm", "natural"],
|
||||
source: "SYSTEM",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const voices = await listInworldVoices({ apiKey: "test-key" });
|
||||
|
||||
expect(voices).toEqual([
|
||||
{
|
||||
id: "Dennis",
|
||||
name: "Dennis",
|
||||
description: "Middle-aged man with a smooth, calm and friendly voice",
|
||||
locale: "EN_US",
|
||||
gender: "male",
|
||||
},
|
||||
{
|
||||
id: "Ashley",
|
||||
name: "Ashley",
|
||||
description: "A warm, natural female voice",
|
||||
locale: "EN_US",
|
||||
gender: "female",
|
||||
},
|
||||
]);
|
||||
const request = lastGuardRequest();
|
||||
expect(request.url).toBe("https://api.inworld.ai/voices/v1/voices");
|
||||
expect(request.auditContext).toBe("inworld-voices");
|
||||
expect(request.policy).toEqual({ hostnameAllowlist: ["api.inworld.ai"] });
|
||||
const headers = new Headers(request.init?.headers);
|
||||
expect(headers.get("authorization")).toBe("Basic test-key");
|
||||
});
|
||||
|
||||
it("throws on API errors with response body", async () => {
|
||||
queueGuardedResponse(new Response("service unavailable", { status: 503 }));
|
||||
|
||||
await expect(listInworldVoices({ apiKey: "test-key" })).rejects.toThrow(
|
||||
"Inworld voices API error (503): service unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
it("filters out voices with empty voiceId", async () => {
|
||||
queueGuardedResponse(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
voices: [
|
||||
{ voiceId: "", displayName: "Empty" },
|
||||
{ voiceId: "Dennis", displayName: "Dennis" },
|
||||
],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const voices = await listInworldVoices({ apiKey: "test-key" });
|
||||
expect(voices).toHaveLength(1);
|
||||
expect(voices[0].id).toBe("Dennis");
|
||||
});
|
||||
|
||||
it("returns empty array when no voices present", async () => {
|
||||
queueGuardedResponse(new Response(JSON.stringify({}), { status: 200 }));
|
||||
|
||||
const voices = await listInworldVoices({ apiKey: "test-key" });
|
||||
expect(voices).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("passes language filter as query parameter", async () => {
|
||||
queueGuardedResponse(new Response(JSON.stringify({ voices: [] }), { status: 200 }));
|
||||
|
||||
await listInworldVoices({ apiKey: "test-key", language: "EN_US" });
|
||||
|
||||
expect(lastGuardRequest().url).toBe("https://api.inworld.ai/voices/v1/voices?languages=EN_US");
|
||||
});
|
||||
});
|
||||
|
||||
describe("inworldTTS", () => {
|
||||
afterEach(() => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("concatenates base64 audio chunks from streaming response", async () => {
|
||||
const chunk1 = Buffer.from("audio-chunk-1").toString("base64");
|
||||
const chunk2 = Buffer.from("audio-chunk-2").toString("base64");
|
||||
const body = [
|
||||
JSON.stringify({ result: { audioContent: chunk1 } }),
|
||||
JSON.stringify({ result: { audioContent: chunk2 } }),
|
||||
].join("\n");
|
||||
|
||||
queueGuardedResponse(new Response(body, { status: 200 }));
|
||||
|
||||
const buffer = await inworldTTS({
|
||||
text: "Hello world",
|
||||
apiKey: "test-key",
|
||||
});
|
||||
|
||||
expect(buffer).toEqual(
|
||||
Buffer.concat([Buffer.from("audio-chunk-1"), Buffer.from("audio-chunk-2")]),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on HTTP errors with response body", async () => {
|
||||
queueGuardedResponse(new Response("bad request body", { status: 400 }));
|
||||
|
||||
await expect(inworldTTS({ text: "test", apiKey: "test-key" })).rejects.toThrow(
|
||||
"Inworld TTS API error (400): bad request body",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on in-stream errors", async () => {
|
||||
const body = JSON.stringify({
|
||||
error: { code: 3, message: "Invalid voice ID" },
|
||||
});
|
||||
queueGuardedResponse(new Response(body, { status: 200 }));
|
||||
|
||||
await expect(inworldTTS({ text: "test", apiKey: "test-key" })).rejects.toThrow(
|
||||
"Inworld TTS stream error (3): Invalid voice ID",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on empty audio response", async () => {
|
||||
const body = JSON.stringify({ result: { audioContent: "" } });
|
||||
queueGuardedResponse(new Response(body, { status: 200 }));
|
||||
|
||||
await expect(inworldTTS({ text: "test", apiKey: "test-key" })).rejects.toThrow(
|
||||
"Inworld TTS returned no audio data",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws descriptive error on non-JSON line in stream", async () => {
|
||||
queueGuardedResponse(new Response("<html>Rate limited</html>", { status: 200 }));
|
||||
|
||||
await expect(inworldTTS({ text: "test", apiKey: "test-key" })).rejects.toThrow(
|
||||
"Inworld TTS stream parse error: unexpected non-JSON line:",
|
||||
);
|
||||
});
|
||||
|
||||
it("sends correct request body with defaults", async () => {
|
||||
const chunk = Buffer.from("audio").toString("base64");
|
||||
queueGuardedResponse(
|
||||
new Response(JSON.stringify({ result: { audioContent: chunk } }), { status: 200 }),
|
||||
);
|
||||
|
||||
await inworldTTS({ text: "Hello", apiKey: "test-key" });
|
||||
|
||||
const request = lastGuardRequest();
|
||||
expect(request.url).toBe("https://api.inworld.ai/tts/v1/voice:stream");
|
||||
expect(request.auditContext).toBe("inworld-tts");
|
||||
expect(request.policy).toEqual({ hostnameAllowlist: ["api.inworld.ai"] });
|
||||
if (!request.init) {
|
||||
throw new Error("expected Inworld TTS request init");
|
||||
}
|
||||
expect(request.init.method).toBe("POST");
|
||||
const headers = new Headers(request.init.headers);
|
||||
expect(headers.get("authorization")).toBe("Basic test-key");
|
||||
expect(headers.get("content-type")).toBe("application/json");
|
||||
expect(JSON.parse(readRequestBody(request))).toEqual({
|
||||
text: "Hello",
|
||||
voiceId: "Sarah",
|
||||
modelId: "inworld-tts-1.5-max",
|
||||
audioConfig: { audioEncoding: "MP3" },
|
||||
});
|
||||
});
|
||||
|
||||
it("includes temperature and sampleRateHertz when provided", async () => {
|
||||
const chunk = Buffer.from("audio").toString("base64");
|
||||
queueGuardedResponse(
|
||||
new Response(JSON.stringify({ result: { audioContent: chunk } }), { status: 200 }),
|
||||
);
|
||||
|
||||
await inworldTTS({
|
||||
text: "Hello",
|
||||
apiKey: "test-key",
|
||||
voiceId: "Ashley",
|
||||
modelId: "inworld-tts-1.5-mini",
|
||||
audioEncoding: "PCM",
|
||||
sampleRateHertz: 22_050,
|
||||
temperature: 0.8,
|
||||
});
|
||||
|
||||
const callBody = JSON.parse(readRequestBody(lastGuardRequest()));
|
||||
expect(callBody.voiceId).toBe("Ashley");
|
||||
expect(callBody.modelId).toBe("inworld-tts-1.5-mini");
|
||||
expect(callBody.audioConfig.audioEncoding).toBe("PCM");
|
||||
expect(callBody.audioConfig.sampleRateHertz).toBe(22_050);
|
||||
expect(callBody.temperature).toBe(0.8);
|
||||
});
|
||||
|
||||
it("uses custom base URL", async () => {
|
||||
const chunk = Buffer.from("audio").toString("base64");
|
||||
queueGuardedResponse(
|
||||
new Response(JSON.stringify({ result: { audioContent: chunk } }), { status: 200 }),
|
||||
);
|
||||
|
||||
await inworldTTS({
|
||||
text: "Hello",
|
||||
apiKey: "test-key",
|
||||
baseUrl: "https://custom.inworld.example.com/",
|
||||
});
|
||||
|
||||
expect(lastGuardRequest().url).toBe("https://custom.inworld.example.com/tts/v1/voice:stream");
|
||||
expect(lastGuardRequest().policy).toEqual({
|
||||
hostnameAllowlist: ["custom.inworld.example.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("skips empty lines in streaming response", async () => {
|
||||
const chunk = Buffer.from("audio").toString("base64");
|
||||
const body = `\n${JSON.stringify({ result: { audioContent: chunk } })}\n\n`;
|
||||
queueGuardedResponse(new Response(body, { status: 200 }));
|
||||
|
||||
const buffer = await inworldTTS({ text: "test", apiKey: "test-key" });
|
||||
expect(buffer).toEqual(Buffer.from("audio"));
|
||||
});
|
||||
|
||||
it("releases the guarded dispatcher after failure", async () => {
|
||||
const { release } = queueGuardedResponse(new Response("fail", { status: 500 }));
|
||||
|
||||
await expect(inworldTTS({ text: "test", apiKey: "test-key" })).rejects.toThrow(
|
||||
"Inworld TTS API error (500): fail",
|
||||
);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Inworld response read bounding", () => {
|
||||
const MiB = 1024 * 1024;
|
||||
|
||||
// A never-ending stream that enqueues one fixed-size chunk per pull. An
|
||||
// unbounded reader (the previous `await response.text()` / `response.json()`)
|
||||
// would buffer this forever and OOM; the bounded reader must stop at the cap
|
||||
// and cancel the stream.
|
||||
function infiniteByteStream(chunkBytes: number): {
|
||||
stream: ReadableStream<Uint8Array>;
|
||||
state: { enqueued: number; cancelled: boolean };
|
||||
} {
|
||||
const state = { enqueued: 0, cancelled: false };
|
||||
const chunk = new Uint8Array(chunkBytes).fill(0x61); // "a"
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
state.enqueued += 1;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { stream, state };
|
||||
}
|
||||
|
||||
it("fail-closed: rejects and cancels an oversized TTS audio stream instead of buffering it (32 MiB cap)", async () => {
|
||||
const { stream, state } = infiniteByteStream(8 * MiB);
|
||||
queueGuardedResponse(new Response(stream, { status: 200 }));
|
||||
|
||||
await expect(inworldTTS({ text: "test", apiKey: "test-key" })).rejects.toThrow(
|
||||
/Inworld TTS audio stream too large: \d+ bytes \(limit: 33554432 bytes\)/,
|
||||
);
|
||||
// Enforced after a bounded number of 8 MiB chunks, never the full unbounded
|
||||
// stream, and the stream is cancelled so the socket/buffers are released.
|
||||
expect(state.enqueued).toBeLessThanOrEqual(8);
|
||||
expect(state.cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it("happy-path: a normal multi-line NDJSON audio payload still decodes unchanged", async () => {
|
||||
const part1 = Buffer.from("hello-").toString("base64");
|
||||
const part2 = Buffer.from("world").toString("base64");
|
||||
const body = [
|
||||
JSON.stringify({ result: { audioContent: part1 } }),
|
||||
JSON.stringify({ result: { audioContent: part2 } }),
|
||||
].join("\n");
|
||||
queueGuardedResponse(new Response(body, { status: 200 }));
|
||||
|
||||
const audio = await inworldTTS({ text: "test", apiKey: "test-key" });
|
||||
expect(audio.toString("utf8")).toBe("hello-world");
|
||||
});
|
||||
|
||||
it("edge: an under-cap ~1 MiB audio payload is read intact, not truncated", async () => {
|
||||
const payload = "x".repeat(MiB);
|
||||
const encoded = Buffer.from(payload).toString("base64");
|
||||
const body = JSON.stringify({ result: { audioContent: encoded } });
|
||||
queueGuardedResponse(new Response(body, { status: 200 }));
|
||||
|
||||
const audio = await inworldTTS({ text: "test", apiKey: "test-key" });
|
||||
expect(audio.length).toBe(payload.length);
|
||||
expect(audio.toString("utf8")).toBe(payload);
|
||||
});
|
||||
|
||||
it("fail-closed: rejects decoded audio that exceeds the shared audio cap", async () => {
|
||||
const decodedPayload = Buffer.alloc(16 * MiB + 1, 0x61);
|
||||
const body = JSON.stringify({
|
||||
result: { audioContent: decodedPayload.toString("base64") },
|
||||
});
|
||||
queueGuardedResponse(new Response(body, { status: 200 }));
|
||||
|
||||
await expect(inworldTTS({ text: "test", apiKey: "test-key" })).rejects.toThrow(
|
||||
/Inworld TTS decoded audio too large: 16777217 bytes \(limit: 16777216 bytes\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("regression: a malformed NDJSON line under the cap still throws a bounded parse error", async () => {
|
||||
queueGuardedResponse(new Response("this-is-not-json", { status: 200 }));
|
||||
await expect(inworldTTS({ text: "test", apiKey: "test-key" })).rejects.toThrow(
|
||||
/Inworld TTS stream parse error/,
|
||||
);
|
||||
});
|
||||
|
||||
it("fail-closed: truncates an oversized HTTP error body to a bounded marker", async () => {
|
||||
queueGuardedResponse(new Response("E".repeat(64 * 1024), { status: 500 }));
|
||||
|
||||
let captured: unknown;
|
||||
await inworldTTS({ text: "test", apiKey: "test-key" }).catch((error: unknown) => {
|
||||
captured = error;
|
||||
});
|
||||
|
||||
expect(captured).toBeInstanceOf(Error);
|
||||
const message = (captured as Error).message;
|
||||
expect(message.startsWith("Inworld TTS API error (500): ")).toBe(true);
|
||||
// Never the full 64 KiB hostile body: it collapses to a fixed marker.
|
||||
expect(message).toContain("(error body exceeded diagnostic limit; truncated)");
|
||||
expect(message.length).toBeLessThan(512);
|
||||
});
|
||||
|
||||
it("edge: a small error body is preserved verbatim in the thrown message", async () => {
|
||||
queueGuardedResponse(new Response("invalid api key", { status: 401 }));
|
||||
await expect(inworldTTS({ text: "test", apiKey: "test-key" })).rejects.toThrow(
|
||||
"Inworld TTS API error (401): invalid api key",
|
||||
);
|
||||
});
|
||||
|
||||
it("fail-closed: rejects and cancels an oversized voices JSON stream (16 MiB cap)", async () => {
|
||||
const { stream, state } = infiniteByteStream(8 * MiB);
|
||||
queueGuardedResponse(new Response(stream, { status: 200 }));
|
||||
|
||||
await expect(listInworldVoices({ apiKey: "test-key" })).rejects.toThrow(
|
||||
/Inworld voices response too large: \d+ bytes \(limit: 16777216 bytes\)/,
|
||||
);
|
||||
expect(state.enqueued).toBeLessThanOrEqual(4);
|
||||
expect(state.cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it("happy-path: a normal voices JSON list still parses unchanged", async () => {
|
||||
queueGuardedResponse(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
voices: [{ voiceId: "Sarah", displayName: "Sarah", langCode: "en-US", tags: ["female"] }],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const voices = await listInworldVoices({ apiKey: "test-key" });
|
||||
expect(voices).toEqual([
|
||||
{ id: "Sarah", name: "Sarah", description: undefined, locale: "en-US", gender: "female" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("regression: malformed voices JSON under the cap throws descriptive error", async () => {
|
||||
queueGuardedResponse(new Response("{not-json", { status: 200 }));
|
||||
await expect(listInworldVoices({ apiKey: "test-key" })).rejects.toThrow(
|
||||
"Inworld voices API returned malformed JSON",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user