Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011h6NB8JQtfPc6eRCefEzBB
176 lines
6.6 KiB
JavaScript
176 lines
6.6 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
const ttsUrl = new URL("./tts.mjs", import.meta.url).href;
|
|
const stub = `#!${process.execPath}
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const args = process.argv.slice(2);
|
|
if (args[0] === "--version") {
|
|
console.log("Python 3.12.0");
|
|
process.exit(0);
|
|
}
|
|
const input = args[2];
|
|
const output = args[0] === "-c" ? args[4] : args[args.indexOf("--output") + 1];
|
|
const text = fs.readFileSync(input, "utf8");
|
|
fs.writeFileSync(output + ".started", input);
|
|
const finish = () => {
|
|
if (text === "slow" && !fs.existsSync(process.env.RELEASE_FILE)) {
|
|
setTimeout(finish, 5);
|
|
return;
|
|
}
|
|
const consumed = fs.readFileSync(input, "utf8");
|
|
fs.writeFileSync(output + ".json", JSON.stringify({ input, text, consumed, args }));
|
|
if (process.env.CASE_MODE === "nonzero") process.exit(7);
|
|
if (process.env.CASE_MODE !== "no-output") fs.writeFileSync(output, consumed);
|
|
};
|
|
setTimeout(finish, 10);
|
|
`;
|
|
|
|
const exercise = `
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import childProcess from "node:child_process";
|
|
import { syncBuiltinESMExports } from "node:module";
|
|
import { join, dirname } from "node:path";
|
|
const { CASE_ROOT: root, CASE_MODE: mode, CASE_PROVIDER: provider } = process.env;
|
|
const temp = join(root, "tmp");
|
|
const preexisting = join(temp, "hf-txt-preexisting");
|
|
fs.mkdirSync(preexisting);
|
|
fs.writeFileSync(join(preexisting, "marker"), "keep");
|
|
const injected = Object.assign(new Error("synthetic " + mode), { code: "EACCES" });
|
|
const originalMkdtemp = fs.mkdtempSync;
|
|
const originalWrite = fs.writeFileSync;
|
|
const originalRm = fs.rmSync;
|
|
if (mode === "allocation") fs.mkdtempSync = (prefix, ...args) => {
|
|
if (prefix.startsWith(join(temp, "hf-txt-"))) throw injected;
|
|
return originalMkdtemp(prefix, ...args);
|
|
};
|
|
if (mode === "write") fs.writeFileSync = (file, ...args) => {
|
|
if (String(file).endsWith("line.txt")) {
|
|
originalWrite(file, "partial");
|
|
throw injected;
|
|
}
|
|
return originalWrite(file, ...args);
|
|
};
|
|
if (mode === "spawn-throw") childProcess.spawn = () => { throw injected; };
|
|
if (mode === "cleanup") fs.rmSync = (dir, ...args) => {
|
|
if (String(dir).startsWith(join(temp, "hf-txt-"))) throw injected;
|
|
return originalRm(dir, ...args);
|
|
};
|
|
syncBuiltinESMExports();
|
|
const { synthesizeOne } = await import(${JSON.stringify(ttsUrl)});
|
|
const project = join(root, "project");
|
|
const wavAbs = join(project, "line.wav");
|
|
const request = (chosen = provider, text = "synthetic café", output = wavAbs) =>
|
|
synthesizeOne({ provider: chosen, text, voiceId: "synthetic-voice", lang: "es",
|
|
speed: 0.8, wavAbs: output, hyperframesDir: project });
|
|
const owned = () => fs.readdirSync(temp).filter(name => name !== "hf-txt-preexisting");
|
|
if (["allocation", "write", "spawn-throw"].includes(mode)) {
|
|
await assert.rejects(request(), error => error === injected);
|
|
assert.equal(fs.existsSync(wavAbs), false);
|
|
} else if (mode === "concurrent") {
|
|
const slowWav = join(project, "slow.wav");
|
|
const slow = request("elevenlabs", "slow", slowWav);
|
|
try {
|
|
const deadline = Date.now() + 5000;
|
|
while (!fs.existsSync(slowWav + ".started")) {
|
|
assert.ok(Date.now() < deadline, "slow subprocess must start");
|
|
await new Promise(resolve => setTimeout(resolve, 5));
|
|
}
|
|
const slowInput = fs.readFileSync(slowWav + ".started", "utf8");
|
|
assert.deepEqual(await request("kokoro"), { ok: true, words: null });
|
|
assert.deepEqual(owned(), [dirname(slowInput).split(/[\\\\/]/).pop()]);
|
|
assert.equal(fs.readFileSync(slowInput, "utf8"), "slow");
|
|
} finally {
|
|
fs.writeFileSync(process.env.RELEASE_FILE, "release");
|
|
await slow;
|
|
}
|
|
assert.equal(fs.readFileSync(slowWav, "utf8"), "slow");
|
|
} else {
|
|
const result = await request();
|
|
const label = provider === "elevenlabs" ? "elevenlabs (python)" : "kokoro (npx hyperframes tts)";
|
|
if (["nonzero", "enoent", "no-output"].includes(mode)) {
|
|
const error = mode === "no-output" ? label + " produced no wav file" :
|
|
label + " exited with status " + (mode === "enoent" ? -1 : 7);
|
|
assert.deepEqual(result, { ok: false, words: null, error });
|
|
assert.equal(fs.existsSync(wavAbs), false);
|
|
} else {
|
|
assert.deepEqual(result, { ok: true, words: null });
|
|
assert.equal(fs.readFileSync(wavAbs, "utf8"), "synthetic café");
|
|
}
|
|
if (mode !== "enoent") {
|
|
const log = JSON.parse(fs.readFileSync(wavAbs + ".json", "utf8"));
|
|
assert.equal(log.text, "synthetic café");
|
|
assert.equal(log.consumed, log.text);
|
|
if (provider === "kokoro") assert.deepEqual(log.args.slice(-4), ["--lang", "es", "--speed", "0.8"]);
|
|
}
|
|
}
|
|
if (mode === "cleanup") assert.equal(owned().length, 1);
|
|
else assert.deepEqual(owned(), [], "owned text directories must be removed");
|
|
assert.equal(fs.readFileSync(join(preexisting, "marker"), "utf8"), "keep");
|
|
`;
|
|
|
|
function runCase(provider, mode) {
|
|
const root = mkdtempSync(join(tmpdir(), "tts-cleanup-test-"));
|
|
try {
|
|
for (const dir of ["home", "tmp", "bin", "project"]) mkdirSync(join(root, dir));
|
|
if (mode !== "enoent") {
|
|
for (const name of ["npx", "python3"]) {
|
|
const executable = join(root, "bin", name);
|
|
writeFileSync(executable, stub);
|
|
chmodSync(executable, 0o755);
|
|
}
|
|
}
|
|
const result = spawnSync(process.execPath, ["--input-type=module", "-e", exercise], {
|
|
encoding: "utf8",
|
|
timeout: 15000,
|
|
env: {
|
|
HOME: join(root, "home"),
|
|
USERPROFILE: join(root, "home"),
|
|
TMPDIR: join(root, "tmp"),
|
|
TMP: join(root, "tmp"),
|
|
TEMP: join(root, "tmp"),
|
|
PATH: join(root, "bin"),
|
|
CASE_ROOT: root,
|
|
CASE_PROVIDER: provider,
|
|
CASE_MODE: mode,
|
|
RELEASE_FILE: join(root, "release"),
|
|
},
|
|
});
|
|
assert.equal(result.error, undefined);
|
|
assert.equal(result.status, 0, result.stdout + result.stderr);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
for (const provider of ["elevenlabs", "kokoro"]) {
|
|
for (const mode of [
|
|
"success",
|
|
"nonzero",
|
|
"no-output",
|
|
"enoent",
|
|
"spawn-throw",
|
|
"allocation",
|
|
"write",
|
|
"cleanup",
|
|
]) {
|
|
test(
|
|
`synthesizeOne(${provider}) preserves ${mode} behavior and cleans only its text directory`,
|
|
{ skip: process.platform === "win32" },
|
|
() => runCase(provider, mode),
|
|
);
|
|
}
|
|
}
|
|
|
|
test(
|
|
"concurrent providers retain each other's text until their subprocess finishes",
|
|
{ skip: process.platform === "win32" },
|
|
() => runCase("kokoro", "concurrent"),
|
|
);
|