Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011h6NB8JQtfPc6eRCefEzBB
234 lines
8.3 KiB
JavaScript
234 lines
8.3 KiB
JavaScript
import { afterEach, beforeEach, test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { main } from "./heygen-voice.mjs";
|
|
|
|
const realFetch = globalThis.fetch;
|
|
const savedEnv = {
|
|
HEYGEN_ACCESS_TOKEN: process.env.HEYGEN_ACCESS_TOKEN,
|
|
HEYGEN_API_KEY: process.env.HEYGEN_API_KEY,
|
|
};
|
|
let dir;
|
|
let calls;
|
|
|
|
beforeEach(() => {
|
|
dir = mkdtempSync(join(tmpdir(), "heygen-voice-"));
|
|
delete process.env.HEYGEN_ACCESS_TOKEN;
|
|
process.env.HEYGEN_API_KEY = "hg_test";
|
|
calls = [];
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = realFetch;
|
|
for (const [k, v] of Object.entries(savedEnv)) {
|
|
if (v === undefined) delete process.env[k];
|
|
else process.env[k] = v;
|
|
}
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
// routes: "METHOD /path" → array of [status, body] answered in order (last one repeats).
|
|
function mockFetch(routes) {
|
|
globalThis.fetch = async (url, opts) => {
|
|
const key = `${opts.method} ${String(url).replace("https://api.heygen.com/v3", "")}`;
|
|
calls.push({ key, opts });
|
|
const queue = routes[key];
|
|
if (!queue) throw new Error(`unexpected request ${key}`);
|
|
const [status, body] = queue.length > 1 ? queue.shift() : queue[0];
|
|
return new Response(body === undefined ? null : JSON.stringify(body), { status });
|
|
};
|
|
}
|
|
|
|
async function run(argv, { sleep = async () => {}, now = () => 0 } = {}) {
|
|
const out = [];
|
|
const err = [];
|
|
const code = await main(argv, {
|
|
sleep,
|
|
now,
|
|
out: (s) => out.push(s),
|
|
err: (s) => err.push(s),
|
|
});
|
|
return { code, out: out.join("\n"), err: err.join("\n") };
|
|
}
|
|
|
|
function audioFile(name = "sample.mp3") {
|
|
const path = join(dir, name);
|
|
writeFileSync(path, Buffer.from("fake-audio"));
|
|
return path;
|
|
}
|
|
|
|
test("clone uploads base64 audio, polls until complete, prints the voice id", async () => {
|
|
mockFetch({
|
|
"POST /voices/clone": [[200, { data: { voice_clone_id: "vc1" } }]],
|
|
"GET /voices/vc1": [
|
|
[200, { data: { status: "processing", voice_id: "vc1" } }],
|
|
[200, { data: { status: "complete", voice_id: "vc1", name: "Me" } }],
|
|
],
|
|
});
|
|
const sleeps = [];
|
|
const r = await run(["clone", audioFile(), "--name", "Me"], {
|
|
sleep: async (ms) => sleeps.push(ms),
|
|
});
|
|
assert.deepEqual(r, { code: 0, out: '{"voice_id":"vc1"}', err: "" });
|
|
assert.deepEqual(sleeps, [2000]);
|
|
const post = calls[0];
|
|
assert.equal(post.opts.headers["X-Api-Key"], "hg_test");
|
|
assert.deepEqual(JSON.parse(post.opts.body), {
|
|
voice_name: "Me",
|
|
audio: {
|
|
type: "base64",
|
|
media_type: "audio/mpeg",
|
|
data: Buffer.from("fake-audio").toString("base64"),
|
|
},
|
|
});
|
|
});
|
|
|
|
test("clone sends audio/wav for a .wav file", async () => {
|
|
mockFetch({
|
|
"POST /voices/clone": [[200, { data: { voice_clone_id: "vc1" } }]],
|
|
"GET /voices/vc1": [[200, { data: { status: "complete", voice_id: "vc1" } }]],
|
|
});
|
|
const r = await run(["clone", audioFile("take.WAV"), "--name", "Me"]);
|
|
assert.equal(r.code, 0);
|
|
assert.equal(JSON.parse(calls[0].opts.body).audio.media_type, "audio/wav");
|
|
});
|
|
|
|
test("clone prints HeyGen's refusal verbatim and exits 1", async () => {
|
|
const message =
|
|
"Voice clone limit reached (10). Delete unused clones or contact support to increase your limit.";
|
|
mockFetch({
|
|
"POST /voices/clone": [[400, { error: { code: "resource_limit_reached", message } }]],
|
|
});
|
|
const r = await run(["clone", audioFile(), "--name", "Me"]);
|
|
assert.deepEqual(r, { code: 1, out: "", err: message });
|
|
});
|
|
|
|
test("clone surfaces a failed clone's failure_message", async () => {
|
|
mockFetch({
|
|
"POST /voices/clone": [[200, { data: { voice_clone_id: "vc1" } }]],
|
|
"GET /voices/vc1": [[200, { data: { status: "failed", failure_message: "Audio too noisy" } }]],
|
|
});
|
|
const r = await run(["clone", audioFile(), "--name", "Me"]);
|
|
assert.deepEqual(r, { code: 1, out: "", err: "Audio too noisy" });
|
|
});
|
|
|
|
test("clone gives up after the 120 s deadline", async () => {
|
|
mockFetch({
|
|
"POST /voices/clone": [[200, { data: { voice_clone_id: "vc1" } }]],
|
|
"GET /voices/vc1": [[200, { data: { status: "processing" } }]],
|
|
});
|
|
let clock = 0;
|
|
const r = await run(["clone", audioFile(), "--name", "Me"], {
|
|
now: () => clock,
|
|
sleep: async (ms) => {
|
|
clock += ms;
|
|
},
|
|
});
|
|
assert.deepEqual(r, { code: 1, out: "", err: "voice clone vc1 not ready after 120 s" });
|
|
assert.equal(calls.filter((c) => c.key === "GET /voices/vc1").length, 61);
|
|
});
|
|
|
|
test("clone rejects an audio type other than mp3 or wav without calling HeyGen", async () => {
|
|
mockFetch({});
|
|
const r = await run(["clone", audioFile("take.ogg"), "--name", "Me"]);
|
|
assert.equal(r.code, 1);
|
|
assert.match(r.err, /use \.mp3 or \.wav/);
|
|
assert.equal(calls.length, 0);
|
|
});
|
|
|
|
test("delete calls DELETE /voices/<id> and exits 0", async () => {
|
|
mockFetch({ "DELETE /voices/vc1": [[200, { data: {} }]] });
|
|
const r = await run(["delete", "vc1"]);
|
|
assert.deepEqual(r, { code: 0, out: "", err: "" });
|
|
});
|
|
|
|
test("delete exits 0 on a 204 with no body, and escapes the id in the path", async () => {
|
|
mockFetch({ "DELETE /voices/a%2Fb": [[204]] });
|
|
const r = await run(["delete", "a/b"]);
|
|
assert.deepEqual(r, { code: 0, out: "", err: "" });
|
|
});
|
|
|
|
test("clone falls back to the clone id when the finished voice has no voice_id", async () => {
|
|
mockFetch({
|
|
"POST /voices/clone": [[200, { data: { voice_clone_id: "vc9" } }]],
|
|
"GET /voices/vc9": [[200, { data: { status: "complete" } }]],
|
|
});
|
|
const r = await run(["clone", audioFile(), "--name", "Me"]);
|
|
assert.deepEqual(r, { code: 0, out: '{"voice_id":"vc9"}', err: "" });
|
|
});
|
|
|
|
test("list skips a nameless voice and stops on a page token HeyGen repeats", async () => {
|
|
mockFetch({
|
|
"GET /voices?type=private&limit=100": [
|
|
[200, { data: [{ voice_id: "n", name: null }], has_more: true, next_token: "t2" }],
|
|
],
|
|
"GET /voices?type=private&limit=100&token=t2": [
|
|
[200, { data: [], has_more: true, next_token: "t2" }],
|
|
],
|
|
});
|
|
// A fake answers at once, so a list that never stops would starve any timeout: cap the calls instead.
|
|
const answer = globalThis.fetch;
|
|
globalThis.fetch = (...args) =>
|
|
calls.length >= 10 ? Promise.reject(new Error("paged forever")) : answer(...args);
|
|
const r = await run(["list", "--prefix", "desk-"]);
|
|
assert.deepEqual(r, { code: 1, out: "", err: "HeyGen returned page token t2 twice" });
|
|
});
|
|
|
|
test("delete prints HeyGen's 404 message verbatim and exits 1", async () => {
|
|
mockFetch({
|
|
"DELETE /voices/gone": [
|
|
[404, { error: { code: "voice_not_found", message: "Voice not found: gone" } }],
|
|
],
|
|
});
|
|
const r = await run(["delete", "gone"]);
|
|
assert.deepEqual(r, { code: 1, out: "", err: "Voice not found: gone" });
|
|
});
|
|
|
|
test("list pages through private voices and returns prefix matches with created_at", async () => {
|
|
mockFetch({
|
|
"GET /voices?type=private&limit=100": [
|
|
[
|
|
200,
|
|
{
|
|
data: [
|
|
{ voice_id: "a", name: "desk-1" },
|
|
{ voice_id: "b", name: "other" },
|
|
],
|
|
has_more: true,
|
|
next_token: "t2",
|
|
},
|
|
],
|
|
],
|
|
"GET /voices?type=private&limit=100&token=t2": [
|
|
[200, { data: [{ voice_id: "c", name: "desk-2" }], has_more: false }],
|
|
],
|
|
"GET /voices/a": [[200, { data: { voice_id: "a", created_at: 1700000000 } }]],
|
|
"GET /voices/c": [[200, { data: { voice_id: "c", created_at: 1700000100 } }]],
|
|
});
|
|
const r = await run(["list", "--prefix", "desk-"]);
|
|
assert.equal(r.code, 0);
|
|
assert.deepEqual(JSON.parse(r.out), [
|
|
{ voice_id: "a", name: "desk-1", created_at: 1700000000 },
|
|
{ voice_id: "c", name: "desk-2", created_at: 1700000100 },
|
|
]);
|
|
});
|
|
|
|
test("list prints HeyGen's refusal verbatim and exits 1", async () => {
|
|
const message = "Invalid or expired API key. Verify your x-api-key header.";
|
|
mockFetch({
|
|
"GET /voices?type=private&limit=100": [[401, { error: { code: "unauthorized", message } }]],
|
|
});
|
|
const r = await run(["list", "--prefix", "desk-"]);
|
|
assert.deepEqual(r, { code: 1, out: "", err: message });
|
|
});
|
|
|
|
test("a host-injected HEYGEN_ACCESS_TOKEN is sent as Bearer", async () => {
|
|
process.env.HEYGEN_ACCESS_TOKEN = "at_host";
|
|
mockFetch({ "DELETE /voices/vc1": [[200, { data: {} }]] });
|
|
await run(["delete", "vc1"]);
|
|
assert.equal(calls[0].opts.headers.Authorization, "Bearer at_host");
|
|
assert.equal(calls[0].opts.headers["X-Api-Key"], undefined);
|
|
});
|