Files
agents-marketplace/tests/unit/supplier-piper.test.ts
jelisaveta-talbot 3c3e009a67 supplier: TTS capability scaffolding (audio.synthesize.piper.v1)
Path-A foundation: extend the supplier service so the same on-chain
escrow lifecycle can serve a PiperTTS supplier alongside the existing
Ollama-backed chat one. Capability dispatch is boot-time; one process
serves one capability via a single advert.

Receipt mapping for TTS is committed inline:
- prompt_hash  = sha256(canonical({text, voice, format, speed}))
- result_hash  = sha256(audio_bytes)
- prompt_tokens / completion_tokens = 0 (off-chain only; Piper has no
  token semantics, on-chain validator never reads these fields)

New / changed files

- supplier/src/piper.ts (new): mirror of ollama.ts. callPiper(...)
  POSTs OpenAI-shape /v1/audio/speech, returns {audio: Uint8Array,
  contentType, wallclock_ms}. Typed errors: piper_failure | piper_
  timeout | piper_malformed (with a 64-byte minimum to catch upstream
  "200 OK, body length 0" cold-start)
- supplier/src/config.ts: + CapabilityKind = "chat" | "tts", + piperUrl
  / piperTimeoutMs. CAPABILITY_KIND env defaults to "chat" so existing
  chat suppliers keep booting unchanged
- supplier/src/jobs.ts: JobResponsePayload becomes a discriminated
  union (ChatJobResponsePayload | TtsJobResponsePayload). TTS variant
  carries audio_b64 + content_type + byte_length so the in-memory
  store stays JSON-clean and the GET poll route returns audio without
  binary content-type negotiation
- supplier/src/jobRunner.ts: + runTtsJob (parallels runChatJob with
  same 7-step shape: setRunning -> upstream -> receipt -> buildSubmit
  -> awaitTx -> complete -> finally release)
- supplier/src/server.ts: + makeTtsHandler (POST /v1/audio/synthesize)
  + makeGetTtsJobHandler (poll). createApp dispatches by config.
  capabilityKind: TTS supplier exposes the audio routes only, chat
  supplier exposes the chat routes only. The on-chain capability_id
  check on every request is belt-and-braces

Tests
- tests/unit/supplier-piper.test.ts: 9 tests (request shape, success,
  network throw, AbortError -> piper_timeout, too-small body ->
  piper_malformed, missing content-type fallback)
- tests/unit/supplier-routes-tts.test.ts: 9 tests (capability-routed
  mounting, all 400 validation paths, sanity that the route is reachable)
- tests/fixtures/supplier-side/sample-config.ts: + buildSampleTtsConfig

243 supplier tests passing; chat path behaviourally untouched.
2026-05-06 19:01:35 +02:00

141 lines
5.4 KiB
TypeScript

/**
* supplier-piper.test.ts — coverage for supplier/src/piper.ts.
*
* Mirrors the ollama tests: mocks global.fetch (no real PiperTTS host
* required) and asserts request shape, success path, and the typed error
* reasons (`piper_failure`, `piper_timeout`, `piper_malformed`).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { callPiper, PiperError } from "../../supplier/src/piper.js";
const PIPER_URL = "http://piper.fake";
const TEXT = "Hello from the marketplace.";
const VOICE = "nova";
const FORMAT = "mp3";
const SPEED = 1.0;
const TIMEOUT_MS = 5_000;
function makeAudioBuffer(byteLength: number): ArrayBuffer {
const buf = new ArrayBuffer(byteLength);
// Fill with mp3-frame-header-ish bytes so the buffer isn't all-zeros.
const view = new Uint8Array(buf);
for (let i = 0; i < byteLength; i++) view[i] = (i * 37) & 0xff;
return buf;
}
function makeFetchOk(body: ArrayBuffer, contentType = "audio/mpeg") {
return vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: (k: string) => (k.toLowerCase() === "content-type" ? contentType : null) },
arrayBuffer: async () => body,
});
}
function makeFetchError(status: number) {
return vi.fn().mockResolvedValue({
ok: false,
status,
statusText: "Internal Server Error",
headers: { get: () => null },
text: async () => "upstream piper error",
});
}
describe("callPiper() — request shape", () => {
beforeEach(() => {
vi.stubGlobal("fetch", makeFetchOk(makeAudioBuffer(2048)));
});
afterEach(() => {
vi.restoreAllMocks();
});
it("POSTs to ${piperUrl}/v1/audio/speech with OpenAI-shape body", async () => {
await callPiper({ piperUrl: PIPER_URL, text: TEXT, voice: VOICE, format: FORMAT, speed: SPEED, timeoutMs: TIMEOUT_MS });
expect(fetch).toHaveBeenCalledTimes(1);
const [url, init] = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("http://piper.fake/v1/audio/speech");
expect(init.method).toBe("POST");
const body = JSON.parse(init.body);
expect(body).toEqual({
model: "tts-1",
input: TEXT,
voice: VOICE,
response_format: FORMAT,
speed: SPEED,
});
});
it("strips trailing slash from piperUrl", async () => {
await callPiper({ piperUrl: "http://piper.fake////", text: "hi", voice: "nova", format: "mp3", speed: 1.0, timeoutMs: TIMEOUT_MS });
const [url] = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("http://piper.fake/v1/audio/speech");
});
});
describe("callPiper() — happy path", () => {
afterEach(() => vi.restoreAllMocks());
it("returns audio bytes + content-type + measured wallclock_ms", async () => {
const buf = makeAudioBuffer(4096);
vi.stubGlobal("fetch", makeFetchOk(buf, "audio/mpeg"));
const result = await callPiper({ piperUrl: PIPER_URL, text: TEXT, voice: VOICE, format: FORMAT, speed: SPEED, timeoutMs: TIMEOUT_MS });
expect(result.audio).toBeInstanceOf(Uint8Array);
expect(result.audio.byteLength).toBe(4096);
expect(result.contentType).toBe("audio/mpeg");
expect(typeof result.wallclock_ms).toBe("number");
expect(result.wallclock_ms).toBeGreaterThanOrEqual(0);
});
it("falls back to audio/<format> when upstream omits content-type", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: () => null },
arrayBuffer: async () => makeAudioBuffer(128),
}));
const r = await callPiper({ piperUrl: PIPER_URL, text: "hi", voice: "nova", format: "wav", speed: 1.0, timeoutMs: TIMEOUT_MS });
expect(r.contentType).toBe("audio/wav");
});
});
describe("callPiper() — error reasons", () => {
afterEach(() => vi.restoreAllMocks());
it("non-2xx HTTP → PiperError piper_failure", async () => {
vi.stubGlobal("fetch", makeFetchError(500));
await expect(
callPiper({ piperUrl: PIPER_URL, text: TEXT, voice: VOICE, format: FORMAT, speed: SPEED, timeoutMs: TIMEOUT_MS }),
).rejects.toMatchObject({ name: "PiperError", reason: "piper_failure" });
});
it("network throw → PiperError piper_failure", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED")));
await expect(
callPiper({ piperUrl: PIPER_URL, text: TEXT, voice: VOICE, format: FORMAT, speed: SPEED, timeoutMs: TIMEOUT_MS }),
).rejects.toMatchObject({ name: "PiperError", reason: "piper_failure" });
});
it("AbortError → PiperError piper_timeout", async () => {
const abortErr = Object.assign(new Error("aborted"), { name: "AbortError" });
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(abortErr));
await expect(
callPiper({ piperUrl: PIPER_URL, text: TEXT, voice: VOICE, format: FORMAT, speed: SPEED, timeoutMs: TIMEOUT_MS }),
).rejects.toMatchObject({ name: "PiperError", reason: "piper_timeout" });
});
it("body smaller than MIN_AUDIO_BYTES → PiperError piper_malformed", async () => {
vi.stubGlobal("fetch", makeFetchOk(makeAudioBuffer(8)));
await expect(
callPiper({ piperUrl: PIPER_URL, text: TEXT, voice: VOICE, format: FORMAT, speed: SPEED, timeoutMs: TIMEOUT_MS }),
).rejects.toMatchObject({ name: "PiperError", reason: "piper_malformed" });
});
it("PiperError exports `reason` for switch dispatch", () => {
const err = new PiperError("piper_timeout", "x");
expect(err.reason).toBe("piper_timeout");
expect(err.name).toBe("PiperError");
});
});