#!/usr/bin/env bats # tests/dankvoice.bats — black-box tests for agent/extensions/dankvoice.ts # # The extension's contract, from outside: it registers a `say` command and an # `agent_end` listener; what is observable is (a) the JSON it POSTs to the # speech route, (b) the file it hands to the player, and (c) the notifications # and select-lists it renders. A fixture HTTP server stands in for frank-tts # (it answers the speech route with a fixed WAV, the roster route with a JSON # array, and everything else with 404), a stub player stands in for pw-play # (it records its full argv and copies the .wav it was given), and a fixture # `wpctl` in $WORK/bin stands in for wireplumber's sink enumeration. # # Driven by env: DV_EXT (extension under test), DV_STUB / DV_STUB_LOG / DV_COPY # (player fixture), DV_PWPLAY (stub aliased as pw-play), DV_WPCTL (the sink # name the fixture wpctl reports), DV_MODELS (models.yml fixture), # DANKVOICE_CONFIG (settings file), DV_PLAYER_BIN (player command override), # DV_SELECT (pipe-separated answers fed to ctx.ui.select in order), DV_MODE # (the harness mode reported to the extension). setup() { REPO="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" WORK="$(mktemp -d)" export DV_EXT="$REPO/agent/extensions/dankvoice.ts" export DV_STUB="$WORK/player.sh" export DV_STUB_LOG="$WORK/player.log" export DV_COPY="$WORK/played.wav" export DV_EMPTY_BIN="$WORK/emptybin" mkdir -p "$DV_EMPTY_BIN" cat > "$DV_STUB" <<'SH' #!/usr/bin/env bash printf '%s\n' "$*" >> "$DV_STUB_LOG" for arg in "$@"; do case "$arg" in *.wav) cp "$arg" "$DV_COPY" ;; esac; done sleep 0.1 SH chmod +x "$DV_STUB" # the same stub reachable under the canonical player names, plus a fixture # wpctl so output-device enumeration is deterministic on any machine mkdir -p "$WORK/bin" ln -s "$DV_STUB" "$WORK/bin/pw-play" export DV_PWPLAY="$WORK/bin/pw-play" export DV_WPCTL="alsa_output.usb-KT.0" cat > "$WORK/bin/wpctl" <<'WPC' #!/usr/bin/env bash printf '57\talsa_output.pci-0000_07_00.6.HiFi__Speaker__sink\taudio/sink\t\n116\t%s\taudio/sink\t*\n' "$DV_WPCTL" WPC chmod +x "$WORK/bin/wpctl" # settings live in an isolated file so menu choices never leak between tests export DANKVOICE_CONFIG="$WORK/settings.json" # models.yml fixture: a chat provider first, the speech provider second export DV_MODELS="$WORK/models.yml" cat > "$DV_MODELS" <<'YML' providers: "chat (box)": baseUrl: http://10.0.0.1:9999/v1 models: - id: chatmodel api: openai-completions "speech (box)": baseUrl: http://127.0.0.1:1/v1 auth: none models: - id: speechmodel api: openai-speech YML # omp config.yml fixture: the @tiny role points at the chat provider above export DV_OMPCONFIG="$WORK/ompconfig.yml" cat > "$DV_OMPCONFIG" <<'CFY' modelRoles: tiny: chat (box)/chatmodel:off CFY cat > "$WORK/driver.ts" <<'TS' // Black-box driver: stand up the frank-tts fixture, load the extension against // it, invoke one scenario, print what the endpoint received and what got played. import { createServer } from "node:http"; import { existsSync, readFileSync, statSync } from "node:fs"; import { dirname } from "node:path"; const scenario = process.argv[2] ?? "speak"; const AUDIO = Buffer.from([0x52, 0x49, 0x46, 0x46, 8, 0, 0, 0, 0x57, 0x41, 0x56, 0x45, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); const received: Array<{ url: string | undefined; body: string }> = []; const server = createServer((req, res) => { const chunks: Buffer[] = []; req.on("data", (chunk: Buffer) => chunks.push(chunk)); req.on("end", () => { const body = Buffer.concat(chunks).toString("utf8"); received.push({ url: req.url, body }); if (req.url?.endsWith("/audio/voices")) { res.setHeader("content-type", "application/json"); res.end(JSON.stringify(["narrator1", "narrator2"])); return; } if (req.url?.endsWith("/audio/speech")) { res.setHeader("content-type", "audio/wav"); res.end(AUDIO); return; } if (process.env.DV_REWRITE && req.url?.endsWith("/chat/completions")) { res.setHeader("content-type", "application/json"); const spoken = process.env.DV_REWRITE === "echo" ? String(JSON.parse(body).messages?.[1]?.content ?? "") : "Spoken, humanized words."; res.end(JSON.stringify({ choices: [{ message: { content: spoken } }] })); return; } res.statusCode = 404; res.end("{}"); }); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const port = (server.address() as { port: number }).port; if (scenario === "endpoint") { process.env.DANKVOICE_MODELS = process.env.DV_MODELS; } else { process.env.DANKVOICE_URL = `http://127.0.0.1:${port}/v1`; } process.env.DANKVOICE_OMPCONFIG = process.env.DV_OMPCONFIG; if (scenario !== "endpoint") process.env.DANKVOICE_REWRITE_URL = `http://127.0.0.1:${port}/v1`; if (scenario === "noplayer") process.env.PATH = process.env.DV_EMPTY_BIN ?? "/nonexistent-bin"; else process.env.PATH = `${dirname(process.env.DV_STUB ?? ".")}/bin:${process.env.PATH ?? ""}`; process.env.DANKVOICE_PLAYER = scenario === "noplayer" ? "" : process.env.DV_PLAYER_BIN ?? process.env.DV_STUB; const extension = await import(process.env.DV_EXT as string); const commands = new Map Promise }>(); const events = new Map Promise | void>(); extension.default({ registerCommand: (name: string, options: { handler: (args: string, ctx: unknown) => Promise }) => commands.set(name, options), on: (event: string, handler: (event: unknown, ctx: unknown) => Promise | void) => events.set(event, handler), }); const answers = (process.env.DV_SELECT ?? "").split("|").filter((answer) => answer !== ""); let answerIndex = 0; const ctx = { mode: process.env.DV_MODE ?? "tui", ui: { notify: (message: string, level?: string) => console.log(`NOTIFY ${level ?? "info"} ${message}`), setStatus: (key: string, text?: string) => console.log(`STATUS ${key} ${text ?? ""}`), select: (title: string, options: string[]) => { console.log(`SELECT ${title} ${options.join("|")}`); const answer = answerIndex < answers.length ? answers[answerIndex] : undefined; answerIndex += 1; return Promise.resolve(answer ?? options[0]); }, }, }; const say = commands.get("say"); const agentEnd = events.get("agent_end"); const answer = (text: string) => ({ messages: [{ role: "assistant", content: [{ type: "text", text }] }] }); const settle = () => new Promise((resolve) => setTimeout(resolve, 800)); if (!say) { console.log("COMMANDS none"); } else { console.log(`COMMANDS ${[...commands.keys()].join(",")} EVENTS ${[...events.keys()].join(",")}`); switch (scenario) { case "speak": await say.handler("hello world", ctx); break; case "voice": await say.handler("voice narrator2", ctx); await say.handler("good morning", ctx); break; case "menu": await say.handler("menu", ctx); break; case "empty": await say.handler("", ctx); break; case "devicemenu": await say.handler("menu", ctx); await say.handler("hello world", ctx); break; case "auto": await say.handler("on", ctx); await agentEnd?.(answer("the answer"), ctx); await settle(); await say.handler("off", ctx); await agentEnd?.(answer("the second answer"), ctx); break; case "continuation": await say.handler("on", ctx); await agentEnd?.({ ...answer("not yet"), willContinue: true }, ctx); await settle(); await agentEnd?.(answer("the settled answer"), ctx); break; case "printmode": await say.handler("on", ctx); await agentEnd?.(answer("quiet"), ctx); break; case "voices": await say.handler("voices", ctx); break; case "endpoint": await say.handler("status", ctx); break; case "markdown": await say.handler("## Title\n\nUse `pw-play` here.\n\n```bash\nnpm i x\n```\n\nsee [docs](http://example.test) and **bold**\n- a\n- b", ctx); break; case "truncate": await say.handler("Sentence one is here. ".repeat(40), ctx); break; case "sentences": await say.handler("First stop here. Second one goes!!! Really? Third ... done", ctx); break; case "decimals": await say.handler("Model 3.5 ships today. Version 1,5 too.", ctx); break; case "rewrite": await say.handler("The deploy failed because a secret was missing.", ctx); break; case "thinking": await say.handler("on", ctx); await agentEnd?.({ messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "Considering the wiring." }, { type: "text", text: "The answer is nine." }] }] }, ctx); break; case "narration": await say.handler("on", ctx); await agentEnd?.({ messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "Here is the result.", narration: true }] }] }, ctx); break; case "noplayer": await say.handler("speak anyway", ctx); break; } await settle(); } for (const call of received) { const parsed: unknown = JSON.parse(call.body === "" ? "{}" : call.body); const field = (key: string): string => { if (parsed && typeof parsed === "object" && key in parsed) { const value = parsed[key]; if (typeof value === "string") return value; } return "-"; }; console.log(`CALL ${call.url} model=${field("model")} format=${field("response_format")} voice=${field("voice")} input=${field("input")}`); } console.log(`CALLS ${received.filter((call) => call.url?.endsWith("/audio/speech")).length}`); const log = process.env.DV_STUB_LOG && existsSync(process.env.DV_STUB_LOG) ? readFileSync(process.env.DV_STUB_LOG, "utf8").trim() : ""; console.log(`PLAYS ${log === "" ? 0 : log.split("\n").length}`); if (log !== "") for (const line of log.split("\n")) console.log(`PLAYER ${line}`); if (process.env.DV_COPY && existsSync(process.env.DV_COPY)) console.log(`COPIED ${statSync(process.env.DV_COPY).size}`); server.close(); TS export DV_DRIVER="$WORK/driver.ts" } teardown() { rm -rf "$WORK" } say_run() { run env DV_SELECT="${2:-}" bun "$DV_DRIVER" "$1" } @test "the extension registers the say command and an agent_end listener" { say_run speak [ "$status" -eq 0 ] [[ "$output" == *"COMMANDS say EVENTS agent_end"* ]] } @test "speaking posts the text to the speech route" { say_run speak [ "$status" -eq 0 ] [[ "$output" == *"CALL /v1/audio/speech model=frank-tts format=wav voice=- input=hello world"* ]] } @test "one utterance is played once, as exactly the bytes the endpoint sent" { say_run speak [[ "$output" == *"PLAYS 1"* ]] [[ "$output" == *"COPIED 24"* ]] } @test "the voice is named in the request only when the user chose one" { say_run speak [[ "$output" == *"voice=- input=hello world"* ]] say_run voice [[ "$output" == *"voice=narrator2 input=good morning"* ]] [[ "$output" == *"NOTIFY info dankvoice: voice = narrator2"* ]] } @test "auto-speak narrates the final answer once and stays silent after it is disarmed" { say_run auto [[ "$output" == *"PLAYS 1"* ]] [[ "$output" == *"input=the answer"* ]] [[ "$output" != *"the second answer"* ]] [[ "$output" == *"STATUS dankvoice 🔊"* ]] } @test "auto-speak stays silent outside the interactive modes" { DV_MODE=print say_run printmode [[ "$output" == *"PLAYS 0"* ]] [[ "$output" != *"input=quiet"* ]] } @test "the voice list comes from the endpoint roster route" { say_run voices [[ "$output" == *"CALL /v1/audio/voices"* ]] [[ "$output" == *"dankvoice voices: narrator1, narrator2"* ]] } @test "the endpoint is taken from the speech row of the models file, never the chat provider" { say_run endpoint [[ "$output" == *"http://127.0.0.1:1/v1/audio/speech"* ]] [[ "$output" == *"humanize chatmodel"* ]] [[ "$output" != *"10.0.0.1"* ]] } @test "markdown never reaches the speech engine" { say_run markdown [[ "$output" == *"input=Title"* ]] [[ "$output" == *"input=Use pw-play here."* ]] [[ "$output" == *"input=see docs and bold"* ]] [[ "$output" != *"npm i x"* ]] [[ "$output" != *"example.test"* ]] } @test "the spoken budget keeps only the leading whole segments" { DANKVOICE_MAX=60 say_run truncate [[ "$output" == *"CALLS 1"* ]] [[ "$output" == *"PLAYS 1"* ]] [[ "$output" == *"input=Sentence one is here."* ]] } @test "a long answer is spoken as sequential sentence-sized requests" { say_run sentences [[ "$output" == *"CALLS 3"* ]] [[ "$output" == *"PLAYS 3"* ]] [[ "$output" == *"input=First stop here."* ]] [[ "$output" == *"input=Second one goes!!! Really?"* ]] [[ "$output" == *"input=Third ... done"* ]] } @test "decimals and comma-decimals never split a sentence" { say_run decimals [[ "$output" == *"CALLS 2"* ]] [[ "$output" == *"input=Model 3.5 ships today."* ]] [[ "$output" == *"input=Version 1,5 too."* ]] } @test "an answer is rewritten into spoken prose before synthesis" { DV_REWRITE=prose say_run rewrite [[ "$output" == *"CALL /v1/chat/completions"* ]] [[ "$output" == *"input=Spoken, humanized words."* ]] [[ "$output" == *"CALLS 1"* ]] } @test "a failed rewrite speaks the mechanical text unchanged" { say_run speak [[ "$output" == *"input=hello world"* ]] } @test "thinking is narrated before the answer as its own segment" { DV_REWRITE=echo say_run thinking [[ "$output" == *"input=Considering the wiring."* ]] [[ "$output" == *"input=The answer is nine."* ]] [[ "$output" == *"CALLS 2"* ]] local first second first=$(grep -n "input=Considering the wiring\." <<<"$output" | head -1 | cut -d: -f1) second=$(grep -n "input=The answer is nine\." <<<"$output" | head -1 | cut -d: -f1) [ -n "$first" ] && [ -n "$second" ] && [ "$first" -lt "$second" ] } @test "thinking narration can be switched off" { DANKVOICE_THINKING=0 DV_REWRITE=echo say_run thinking [[ "$output" == *"input=The answer is nine."* ]] [[ "$output" != *"input=Considering the wiring."* ]] [[ "$output" == *"CALLS 1"* ]] } @test "narration-flagged thinking is spoken once as the answer" { DV_REWRITE=echo say_run narration [[ "$output" == *"input=Here is the result."* ]] [[ "$output" == *"CALLS 1"* ]] } @test "a missing player is reported, not swallowed" { say_run noplayer [[ "$output" == *"NOTIFY error dankvoice: no audio player found"* ]] [[ "$output" == *"PLAYS 0"* ]] } @test "a scheduled continuation is never narrated as the final answer" { say_run continuation [[ "$output" == *"PLAYS 1"* ]] [[ "$output" == *"input=the settled answer"* ]] [[ "$output" != *"input=not yet"* ]] } @test "the menu lists every knob and a plain /say opens it" { say_run menu [[ "$output" == *"SELECT dankvoice — options Auto-speak: off (toggle)|Voice: server default (change)|Output device: system default (change)|Player: $DV_STUB (change)|Spoken cap: 700 chars (change)|Thinking narration: on (toggle)|Humanize answers: on (tiny) (toggle)|Speak a test phrase"* ]] [[ "$output" == *"NOTIFY info dankvoice: auto-speak armed (server default @ http://127.0.0.1:"* ]] say_run empty [[ "$output" == *"SELECT dankvoice — options"* ]] } @test "a voice picked from the menu is spoken by every later request" { say_run menu "Voice: server default (change)|narrator2" [[ "$output" == *"SELECT dankvoice — voice server default|narrator1|narrator2"* ]] [[ "$output" == *"NOTIFY info dankvoice: voice = narrator2"* ]] say_run speak [[ "$output" == *"voice=narrator2 input=hello world"* ]] } @test "a device chosen from the menu becomes the player's output target" { DV_PLAYER_BIN="$DV_PWPLAY" say_run menu "Output device: system default (change)|$DV_WPCTL" [[ "$output" == *"SELECT dankvoice — output device system default|alsa_output.pci-0000_07_00.6.HiFi__Speaker__sink|$DV_WPCTL"* ]] [[ "$output" == *"NOTIFY info dankvoice: output device = $DV_WPCTL"* ]] DV_PLAYER_BIN="$DV_PWPLAY" say_run speak [[ "$output" == *"PLAYER --target $DV_WPCTL"* ]] }