#!/usr/bin/env bats # Seam: frank-tts-engine — the arm A TTS engine (qwentts.cpp `tts-server`) that # sits behind frank-tts. What is pinned here is the shape the fleet depends on, # observed through the tools that act on it: the Quadlet generator's own output, # the image entrypoint driven with a stub binary, the boot warmer driven against # a stub engine over a real socket, and the fetcher's own verifier. # # Nothing in this file synthesizes audio, builds an image, starts a container or # touches a fleet host. The engine's real behaviour (TTFA, RTF, the cold-voice # cost) was measured on Dankclaw on 2026-10-18 and is documented in # serverAppConfigs-docs/Dankclaw/Frank TTS Engine.md; a test that re-derived it # would need the weights, the CPU and the time the endpoint is meant to serve. # # What this seam refuses to assert, on purpose: any comparison between streamed # (`pcm`) and buffered (`wav`) audio. At the same seed those two paths differ in # 92.2 % of samples (-41.6 dB RMS), so a fixture that pinned one against the # other would be a bug waiting to be "fixed". load helpers UNIT="$REPO_ROOT/dankclaw/voiceservice/quadlet/frank-tts-engine.container" NETF="$REPO_ROOT/dankclaw/voiceservice/quadlet/frank-tts-engine.network" # The façade this engine serves: its wiring is tested here because the bridge # both units attach to is what makes the pair one deployable shape. FACADE="$REPO_ROOT/dankclaw/voiceservice/quadlet/frank-tts.container" FACADE_ENV="$REPO_ROOT/scripts/frank-tts.env" CF="$REPO_ROOT/dankclaw/voiceservice/Containerfile.engine" START="$REPO_ROOT/dankclaw/voiceservice/tts-engine-start.sh" ENVF="$REPO_ROOT/scripts/frank-tts-engine.env" WARM="$REPO_ROOT/dankclaw/voiceservice/tts-engine-warm.sh" FETCH="$REPO_ROOT/scripts/download-frank-tts-engine-qwentts.sh" PLANNER="$REPO_ROOT/scripts/deploy-plan.py" REMOTE="$REPO_ROOT/scripts/deploy-remote.sh" # The decided artifacts, exactly as the 2026-10-18 Hub census recorded them. TALKER="qwen-talker-1.7b-base-Q4_K_M.gguf" TALKER_BYTES=1219245248 TALKER_SHA="ea393ebaf2167ea23ce9fc18b093822851358a950d7075cd47ab4f6ce23e887d" CODEC="qwen-tokenizer-12hz-Q8_0.gguf" CODEC_BYTES=291150624 CODEC_SHA="1883beeed99348fc35e23dd225e9082f93f6f8c109330a33d935baa8acdbfd94" setup() { TEST_TMP="$(mktemp -d)" PIDS="$TEST_TMP/pids" : > "$PIDS" : > "$TEST_TMP/journal.jsonl" # One real pack, and one directory that LOOKS like a pack but has no # reference.wav so must never be offered as a voice. A third case — a pack # whose name the two halves of the voice stack would resolve differently — # is created only by the test that asserts on it, because every other run # of the warmer has to be able to report success. VOICES="$TEST_TMP/voices" mkdir -p "$VOICES/narrator1" "$VOICES/justamanifest" python3 -c 'import sys; sys.stdout.buffer.write(b"RIFF" + bytes(range(128)) * 2)' \ > "$VOICES/narrator1/reference.wav" printf 'a manifest is not a voice\n' > "$VOICES/justamanifest/manifest.json" } teardown() { while IFS= read -r pid; do [ -n "$pid" ] && kill "$pid" 2>/dev/null || true done < "$PIDS" wait 2>/dev/null || true rm -rf "$TEST_TMP" } # write_engine_stub — a stand-in for tts-server's HTTP surface, route for # route: GET /health, GET /v1/models, POST /v1/audio/voices, POST # /v1/audio/speech, and a bare 404 for anything else (that last part is the # point of the stub: a warmer dialing a route the engine does not have must # fail here, not in production). Every request is appended to $STUB_JOURNAL in # the order it arrived, with the registration reduced to its key set, its name # and the sha256 of the decoded wav — so "exactly which bytes were registered" # is observable without shipping a 1 MB fixture around. write_engine_stub() { cat > "$TEST_TMP/stub_engine.py" <<'PY' import base64, hashlib, json, os, time from http.server import BaseHTTPRequestHandler, HTTPServer JOURNAL = os.environ["STUB_JOURNAL"] HANG = float(os.environ.get("STUB_HANG_SPEECH") or 0) class Stub(BaseHTTPRequestHandler): def _record(self, entry): with open(JOURNAL, "a") as fh: fh.write(json.dumps(entry) + "\n") def _reply(self, status, body, ctype="application/json"): self.send_response(status) self.send_header("content-type", ctype) self.send_header("content-length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self): self._record({"method": "GET", "path": self.path}) if self.path == "/health": self._reply(200, b'{"status":"ok"}') elif self.path == "/v1/models": self._reply(200, b'{"object":"list","data":[{"id":"frank-tts-engine"}]}') else: self._reply(404, b'{"detail":"not found"}') def do_POST(self): length = int(self.headers.get("content-length") or 0) raw = self.rfile.read(length) or b"{}" try: body = json.loads(raw) except ValueError: self._record({"method": "POST", "path": self.path, "invalid_json": True}) self._reply(400, b'{"error":{"message":"request body is not valid JSON"}}') return if self.path == "/v1/audio/voices": wav = body.get("wav_b64") or "" try: digest = hashlib.sha256(base64.b64decode(wav)).hexdigest() except Exception: digest = "undecodable" self._record({"method": "POST", "path": self.path, "keys": sorted(body), "name": body.get("name"), "wav_sha256": digest, "wav_b64_len": len(wav), "has_ref_text": "ref_text" in body}) self._reply(200, json.dumps({"name": body.get("name"), "status": "registered"}).encode()) elif self.path == "/v1/audio/speech": self._record({"method": "POST", "path": self.path, "voice": body.get("voice"), "response_format": body.get("response_format"), "input": body.get("input")}) # STUB_HANG_SPEECH stands in for an engine that takes the request # and never answers it: the branch the warmer's ceiling guards. if HANG: time.sleep(HANG) audio = b"RIFF" + b"\x00" * 64 self._reply(200, audio, "audio/wav") else: self._record({"method": "POST", "path": self.path}) self._reply(404, b'{"detail":"not found"}') def log_message(self, *args): pass server = HTTPServer(("127.0.0.1", int(os.environ.get("STUB_BOUND_PORT") or 0)), Stub) print(server.server_address[1], flush=True) server.serve_forever() PY } start_engine_stub() { # start_engine_stub [port] — with a port the stub binds that exact one, # which is what lets a test have a listener appear on an address the warmer # is already dialing. write_engine_stub : > "$TEST_TMP/stub.log" STUB_JOURNAL="$TEST_TMP/journal.jsonl" STUB_BOUND_PORT="${1:-}" \ STUB_HANG_SPEECH="${STUB_HANG_SPEECH:-}" \ python3 "$TEST_TMP/stub_engine.py" > "$TEST_TMP/stub.log" 2>&1 & echo "$!" >> "$PIDS" local waited=0 while [ "$waited" -lt 200 ]; do STUB_PORT="$(head -n1 "$TEST_TMP/stub.log")" [ -n "$STUB_PORT" ] && return 0 sleep 0.05 waited=$((waited + 1)) done echo "the stub engine never bound a port:" cat "$TEST_TMP/stub.log" return 1 } # quadlet_generator — locate the Quadlet generator the target host also has. # podman moved it over the releases the fleet runs, so try every known home and # skip the test if this box has none: the assertions below are only meaningful # against a real generator, not a guess at one. quadlet_generator() { local candidate for candidate in \ /usr/lib/systemd/system-generators/podman-system-generator \ /usr/libexec/podman/quadlet-generator \ /usr/lib/podman/quadlet-generator \ /usr/lib/podman/quadlet; do [ -x "$candidate" ] && { printf '%s' "$candidate"; return 0; } done return 1 } # render_units — run the generator in a throwaway HOME over a copy of the voice # stack's source files and print everything it emits. Both units are rendered # together on purpose: the façade references the engine's .network, and a # referenced file that is not in the directory makes the generator drop that # container unit outright — which is the failure this seam must be able to see. # --dryrun prints instead of writing, so this neither contacts the podman daemon # nor creates a network. render_units() { local gen home gen="$(quadlet_generator)" || { echo "no Quadlet generator on this box"; return 1; } home="$TEST_TMP/fakehome" mkdir -p "$home/.config/containers/systemd" cp "$NETF" "$UNIT" "$FACADE" "$home/.config/containers/systemd/" HOME="$home" "$gen" --user --dryrun > "$TEST_TMP/generated.txt" 2> "$TEST_TMP/generated.err" cat "$TEST_TMP/generated.txt" } # unit_block — one unit's block from the last render_units output. The # end of the block is the next `---` marker: two units are rendered now, and a # slice that ran to the end of the output would read the façade's RestartSec # into the engine's assertions, which is exactly how a generator test starts # asserting the wrong container. unit_block() { awk -v u="$1" 'index($0, "---" u "---") == 1 { f = 1; next } /^---/ { f = 0 } f' "$TEST_TMP/generated.txt" } # free_port — an address with nothing bound to it, for the tests that need the # warmer to be dialing before a listener exists. free_port() { python3 -c 'import socket s = socket.socket() s.bind(("127.0.0.1", 0)) print(s.getsockname()[1]) s.close()' } @test "the voice bridge is the next free pinned subnet and no two committed networks share one" { # Two networks claiming one subnet is a boot-time collision that shows up as # the later container failing to start, on the host, weeks from now. The # registry lives in the network files' comments, so the subnets themselves # are the only honest thing to compare. mine="$(sed -nE 's/^Subnet=([0-9./]+)$/\1/p' "$NETF")" [ "$mine" = "10.89.5.0/24" ] || { echo "expected 10.89.5.0/24, the next free /24, got: $mine"; false; } run grep -hoE '^Subnet=[0-9./]+' "$REPO_ROOT"/dankclaw/*/quadlet/*.network "$REPO_ROOT"/dankserver/*/quadlet/*.network dupes="$(printf '%s\n' "$output" | sort | uniq -d)" [ -z "$dupes" ] || { echo "subnets claimed by more than one network file: $dupes"; false; } printf '%s\n' "$output" | grep -qxF "Subnet=$mine" } @test "the Quadlet generator renders the engine on its private bridge, publishing nothing" { rendered="$(render_units)" || { echo "$rendered"; skip "no Quadlet generator here"; } line="$(printf '%s\n' "$rendered" | grep -E '^ExecStart=/usr/bin/podman run .*--name frank-tts-engine ')" [ -n "$line" ] || { printf '%s\n' "$rendered"; false; } # The data path: container DNS on the network the engine owns. One network # and nothing else attached — the façade joins this one rather than the # engine moving onto the fleet-wide bridge. printf '%s' "$line" | grep -qE -- '--network systemd-frank-tts-engine( |$)' [ "$(printf '%s' "$line" | grep -oE -- '--network [^ ]+' | wc -l)" = "1" ] # NO publish of any kind, not even 127.0.0.1:8880. netavark DNATs published # ports only, so a host-loopback publish is not a path to a peer container — # the façade could never have used it (the trap that 502'd hermes), and it # would be the one LAN-reachable surface of an unauthenticated synthesiser. ! printf '%s' "$line" | grep -qE -- '--publish' # Repo-owned config: the tracked env file, the read-only weights mount, the # read-only voice packs the in-container warmer reads, and a health probe # that survives the generator's CMD-SHELL wrapping because the image ships # curl. printf '%s' "$line" | grep -qF -- '--env-file /home/dank/serverAppConfigs/scripts/frank-tts-engine.env' printf '%s' "$line" | grep -qF -- '-v /home/dank/models/Qwen3-TTS-GGUF:/models:ro' printf '%s' "$line" | grep -qF -- '-v /home/dank/voices:/voices:ro' printf '%s' "$line" | grep -qF -- '--health-cmd' printf '%s' "$line" | grep -qF 'curl' # Boot persistence and self-healing, as the generator actually emits them — # and the warm hook: ExecStartPost must carry the `-` prefix, because # Restart=always plus a failing warmer is a crash loop on the endpoint. unit="$(unit_block frank-tts-engine.service)" printf '%s' "$unit" | grep -qE '^Restart=always$' restart_sec="$(printf '%s' "$unit" | sed -nE 's/^RestartSec=([0-9]+)s?$/\1/p')" [ -n "$restart_sec" ] && [ "$restart_sec" -le 30 ] printf '%s' "$unit" | grep -qE '^WantedBy=default\.target$' # The warm hook is an exec, because there is no host-visible address to run # a host script against (see the publish assertions above). printf '%s' "$unit" | grep -qE '^ExecStartPost=-/usr/bin/podman exec frank-tts-engine /app/warm-voices\.sh$' # Warming a library is serialised: it must not fit inside the default 90 s. start_deadline="$(printf '%s' "$unit" | sed -nE 's/^TimeoutStartSec=([0-9]+)$/\1/p')" [ -n "$start_deadline" ] && [ "$start_deadline" -gt 90 ] } @test "the generator renders the bridge creation command with the pinned subnet" { rendered="$(render_units)" || { echo "$rendered"; skip "no Quadlet generator here"; } net="$(unit_block frank-tts-engine-network.service)" printf '%s' "$net" | grep -qE '^ExecStart=/usr/bin/podman network create --ignore --driver bridge --subnet 10\.89\.5\.0/24 --gateway 10\.89\.5\.1 systemd-frank-tts-engine$' # The container unit must be tied to the network unit, not merely mention it: # a referenced .network that the generator cannot find kills the container # unit outright, and an untied one starts the container before the bridge. unit="$(unit_block frank-tts-engine.service)" printf '%s' "$unit" | grep -qE '^Requires=frank-tts-engine-network\.service$' printf '%s' "$unit" | grep -qE '^After=frank-tts-engine-network\.service$' } @test "the entrypoint turns the tracked env file into the engine's own flags" { # Not a grep of the env file: source it the way podman's --env-file feeds # it to the container, then require the stub binary to be handed the flags # those values mean. A key the entrypoint forgot to translate shows up here # as a missing argument, which is what "the knob does nothing" looks like. cat > "$TEST_TMP/fake-tts-server" <<'FAKE' #!/usr/bin/env bash printf '%s\n' "$@" > "$ARGS_FILE" FAKE chmod +x "$TEST_TMP/fake-tts-server" mkdir -p "$TEST_TMP/models" : > "$TEST_TMP/models/$TALKER" : > "$TEST_TMP/models/$CODEC" set -a # shellcheck disable=SC1090 source "$ENVF" set +a # The env file names CONTAINER paths; the stub stands in for the mount. export QWENTTS_MODEL="$TEST_TMP/models/$TALKER" export QWENTTS_CODEC="$TEST_TMP/models/$CODEC" export QWENTTS_BIN="$TEST_TMP/fake-tts-server" export ARGS_FILE="$TEST_TMP/argv.txt" run bash "$START" [ "$status" -eq 0 ] || { echo "$output"; false; } argv() { awk -v flag="$1" 'p { print; exit } $0 == flag { p = 1 }' "$ARGS_FILE"; } [ "$(argv --model)" = "$TEST_TMP/models/$TALKER" ] [ "$(argv --codec)" = "$TEST_TMP/models/$CODEC" ] [ "$(argv --alias)" = "frank-tts-engine" ] # 0.0.0.0 is the one value that makes the container reachable at all from # the bridge; tts-server's own default (127.0.0.1) is unreachable in a netns. [ "$(argv --host)" = "0.0.0.0" ] [ "$(argv --port)" = "8880" ] [ "$(argv --lang)" = "auto" ] [ "$(argv --max-batch)" = "1" ] # The two flags that must never appear: there is no thread count to set (it # comes from hardware_concurrency()/2), and naming a device on an image with # every GPU backend compiled out is only a new way to be wrong. ! grep -qx -- '--device' "$ARGS_FILE" ! grep -qE -- '^--?t$|^--threads$' "$ARGS_FILE" } @test "missing weights are a refusal that names the fetcher, before the port is bound" { cat > "$TEST_TMP/fake-tts-server" <<'FAKE' #!/usr/bin/env bash echo "the engine binary must not run when its weights are absent" > "$RAN_FILE" FAKE chmod +x "$TEST_TMP/fake-tts-server" export RAN_FILE="$TEST_TMP/ran.txt" export QWENTTS_MODEL="$TEST_TMP/nope/$TALKER" export QWENTTS_CODEC="$TEST_TMP/nope/$CODEC" export QWENTTS_BIN="$TEST_TMP/fake-tts-server" run bash "$START" [ "$status" -eq 1 ] || { echo "$output"; false; } [ ! -e "$RAN_FILE" ] || { echo "the engine was started without its weights"; false; } [[ "$output" == *"$TEST_TMP/nope/$TALKER"* ]] || { echo "$output"; false; } [[ "$output" == *"download-frank-tts-engine-qwentts.sh"* ]] } @test "the boot warmer registers each pack from its reference.wav, embedding-only" { start_engine_stub run env ENGINE_URL="http://127.0.0.1:$STUB_PORT" VOICES_DIR="$VOICES" \ bash "$WARM" [ "$status" -eq 0 ] || { echo "$output"; false; } [[ "$output" == *"voice narrator1 cold="*"ms warm="*"ms"* ]] || { echo "$output"; false; } # The registration is the whole product: name + wav_b64, and NOTHING else. # ref_text present would flip the voice into in-context-learning mode (the # slower voice) — so its absence is the shipped-default assertion. python3 - "$TEST_TMP/journal.jsonl" "$VOICES/narrator1/reference.wav" <<'PY' import hashlib, json, sys entries = [json.loads(l) for l in open(sys.argv[1])] regs = [e for e in entries if e["path"] == "/v1/audio/voices"] assert len(regs) == 1, entries reg = regs[0] assert reg["name"] == "narrator1", reg assert reg["keys"] == ["name", "wav_b64"], reg assert reg["has_ref_text"] is False, reg want = hashlib.sha256(open(sys.argv[2], "rb").read()).hexdigest() assert reg["wav_sha256"] == want, (reg, want) PY # Two speech calls for the one voice: the cold pass that creates the codec # snapshot, then the warm pass whose number is the fleet's real experience. # Both ask for `wav`, which is what frank-tts asks for too. python3 - "$TEST_TMP/journal.jsonl" <<'PY' import json, sys entries = [json.loads(l) for l in open(sys.argv[1])] paths = [e["path"] for e in entries] assert paths[0] == "/health", paths assert paths[1] == "/v1/audio/voices", paths speech = [e for e in entries if e["path"] == "/v1/audio/speech"] assert len(speech) == 2, entries assert {s["voice"] for s in speech} == {"narrator1"}, speech assert {s["response_format"] for s in speech} == {"wav"}, speech PY } @test "a reference clip too big for one argument still registers" { # The kernel caps a SINGLE command-line string at 32 pages = 131,072 bytes # (MAX_ARG_STRLEN), and a shipped reference clip base64's to about 1.27 MB — # so a warmer that hands the registration body to curl as an argument dies # before curl runs. That is exactly what the first real boot of 2026-10-18 # hit with the real packs: `/usr/bin/curl: Argument list too long`, then the # misleading "registration failed (HTTP no response)" for all three voices. # The stub engine is a real listener and curl is the real binary, so this # test fails for the same kernel reason the host did. start_engine_stub local big="$TEST_TMP/bigvoices/bigvoice" mkdir -p "$big" head -c 300000 /dev/zero | tr '\0' 'A' > "$big/reference.wav" run env ENGINE_URL="http://127.0.0.1:$STUB_PORT" VOICES_DIR="$TEST_TMP/bigvoices" \ bash "$WARM" [ "$status" -eq 0 ] || { echo "$output"; false; } [[ "$output" == *"voice bigvoice cold="*"ms warm="*"ms"* ]] || { echo "$output"; false; } # And the audio that arrived is the audio that was on disk, whole. python3 - "$TEST_TMP/journal.jsonl" "$big/reference.wav" <<'PY' import hashlib, json, sys entries = [json.loads(l) for l in open(sys.argv[1])] regs = [e for e in entries if e["path"] == "/v1/audio/voices"] assert len(regs) == 1, entries assert regs[0]["name"] == "bigvoice", regs[0] want = hashlib.sha256(open(sys.argv[2], "rb").read()).hexdigest() assert regs[0]["wav_sha256"] == want, (len(regs[0]["wav_sha256"]), want) PY } @test "the warm sentence survives both readers of the env file intact" { # scripts/frank-tts-engine.env is read two ways and they disagree about # quotes. bash (`set -a; source`, what the seams and a human do) strips the # quotes around a value with spaces. podman's --env-file — how the unit really # reads it — does NOT: measured 2026-10-18 against the image's pinned base, # the value lands in the container WITH its quotes. Interpolated raw into the # hook's JSON body that is `""Warm up…"",` — the engine answers 400, no voice # warms, and the boot still reads healthy. So the hook unwraps one # shell-style layer and then escapes, and BOTH shapes must arrive as the # sentence the operator wrote. start_engine_stub # Shape A: what podman delivers for `WARM_TEXT="Warm up, one two three."`. run env ENGINE_URL="http://127.0.0.1:$STUB_PORT" VOICES_DIR="$VOICES" \ WARM_TEXT='"Warm up, one two three."' bash "$WARM" [ "$status" -eq 0 ] || { echo "$output"; false; } [[ "$output" == *"voice narrator1 cold="*"ms warm="*"ms"* ]] || { echo "$output"; false; } python3 - "$TEST_TMP/journal.jsonl" <<'PY' import json, sys entries = [json.loads(l) for l in open(sys.argv[1])] assert not [e for e in entries if e.get("invalid_json")], entries speech = [e for e in entries if e["path"] == "/v1/audio/speech"] assert len(speech) == 2, entries assert {s["input"] for s in speech} == {"Warm up, one two three."}, speech PY # Shape B: a value that starts AND ends with a quote but carries more quotes # INSIDE — exactly the shape an unwrap rule must refuse, because peeling the # outer pair would delete characters the operator wrote. It must arrive # character for character (trailing quote included), which also proves the # escaping does not mangle what it is protecting. : > "$TEST_TMP/journal.jsonl" run env ENGINE_URL="http://127.0.0.1:$STUB_PORT" VOICES_DIR="$VOICES" \ WARM_TEXT='"He said "hi"\ once"' bash "$WARM" [ "$status" -eq 0 ] || { echo "$output"; false; } python3 - "$TEST_TMP/journal.jsonl" <<'PY' import json, sys entries = [json.loads(l) for l in open(sys.argv[1])] assert not [e for e in entries if e.get("invalid_json")], entries speech = [e for e in entries if e["path"] == "/v1/audio/speech"] assert len(speech) == 2, entries assert {s["input"] for s in speech} == {'"He said "hi"\\ once"'}, speech PY } @test "a directory without reference.wav is not a voice, and a mixed-case name is refused" { # `justamanifest` must be invisible; `Narrator2` must be loud. The engine # lowercases every voice name (tts_voice_name) while frank-tts matches the # directory name exactly, so a mixed-case pack is a voice that works on one # side of the pair and not the other. Silence here is how a wrong-voice pack # survives for weeks. mkdir -p "$VOICES/Narrator2" printf 'RIFFwrongcase' > "$VOICES/Narrator2/reference.wav" start_engine_stub run env ENGINE_URL="http://127.0.0.1:$STUB_PORT" VOICES_DIR="$VOICES" \ bash "$WARM" [ "$status" -eq 1 ] || { echo "$output"; false; } [[ "$output" == *"Narrator2"* ]] [[ "$output" == *"lowercase"* ]] run cat "$TEST_TMP/journal.jsonl" [[ "$output" != *"justamanifest"* ]] [[ "$output" != *"Narrator2"* ]] # The refusal is a journal line plus a non-zero exit — not a crash: the # narrator1 pack still got registered and warmed above the failure. grep -q 'narrator1' "$TEST_TMP/journal.jsonl" } @test "an engine that never answers /health is reported, and nothing is registered" { # HEALTH_WAIT is a deadline, not a retry-forever: an engine that dies on # boot must produce one clear journal line and a non-zero exit (which the # unit's `-` prefix turns into "nothing happened", not a crash loop). port="$(free_port)" run env ENGINE_URL="http://127.0.0.1:$port" VOICES_DIR="$VOICES" HEALTH_WAIT=1 \ bash "$WARM" [ "$status" -eq 1 ] || { echo "$output"; false; } [[ "$output" == *"/health"* ]] || { echo "$output"; false; } [[ "$output" == *"did not answer"* ]] [ ! -s "$TEST_TMP/journal.jsonl" ] } @test "the warmer waits for a late engine instead of racing model load" { # Boot order: ExecStartPost fires the moment the container starts, while the # two GGUFs (1.2 GiB + 291 MB) are still being read and the HTTP thread has # not bound yet. The warmer is launched against an address with nothing on # it, the listener appears a beat later, and the requirement is that the # warmer notices and does the work — an early exit leaves every voice # unregistered, and the façade then answers 400 for all of them. port="$(free_port)" ( ENGINE_URL="http://127.0.0.1:$port" VOICES_DIR="$VOICES" HEALTH_WAIT=60 \ bash "$WARM" > "$TEST_TMP/race.log" 2>&1; echo "$?" > "$TEST_TMP/race.rc" ) & echo "$!" >> "$PIDS" sleep 0.7 start_engine_stub "$port" local waited=0 while [ ! -f "$TEST_TMP/race.rc" ] && [ "$waited" -lt 200 ]; do sleep 0.05 waited=$((waited + 1)) done [ -f "$TEST_TMP/race.rc" ] || { echo "warmer never finished:"; cat "$TEST_TMP/race.log"; false; } [ "$(cat "$TEST_TMP/race.rc")" = "0" ] || { cat "$TEST_TMP/race.log"; false; } grep -q '/v1/audio/voices' "$TEST_TMP/journal.jsonl" grep -q '/v1/audio/speech' "$TEST_TMP/journal.jsonl" } @test "a synthesis that never returns is abandoned at REQUEST_TIMEOUT, not waited out" { # systemd counts the WHOLE warm pass against TimeoutStartSec, so the price # of a hung engine is paid by the unit: the endpoint stays "activating" and # never reaches started. The warmer therefore puts a ceiling on every # request. Here the engine registers the voice and then never answers the # synthesis — the failure mode a wedged GEMM loop actually produces. STUB_HANG_SPEECH=30 export STUB_HANG_SPEECH start_engine_stub run env ENGINE_URL="http://127.0.0.1:$STUB_PORT" VOICES_DIR="$VOICES" \ REQUEST_TIMEOUT=1 bash "$WARM" [ "$status" -eq 1 ] || { echo "$output"; false; } [[ "$output" == *"warm synthesis failed"* ]] || { echo "$output"; false; } # It got as far as registering, so the stall — not a missing route — is # what ended the pass. grep -q '/v1/audio/voices' "$TEST_TMP/journal.jsonl" } @test "an empty voice root is an explanation, not a failure" { # No packs yet is the state right after this unit is deployed and before the # pack builder runs. Failing there would mark the engine deploy incomplete # and, worse, read as a broken warmer to whoever is debugging a real one. start_engine_stub run env ENGINE_URL="http://127.0.0.1:$STUB_PORT" VOICES_DIR="$TEST_TMP/nodir" \ bash "$WARM" [ "$status" -eq 0 ] || { echo "$output"; false; } [[ "$output" == *"no voice directory"* ]] # No registration, no synthesis. Health polls are expected — the stub # journals them, and this is about what the warmer must NOT send. run grep -c '/v1/audio' "$TEST_TMP/journal.jsonl" [ "$output" = "0" ] } @test "the warmer's knobs come from the same env file the engine reads" { # The warm hook is exec-ed into the container, so systemd hands it the unit's # EnvironmentFile — one file for both. Sourcing it here is how that is # reproduced without a container: if a key were renamed on one side only, the # hook would silently fall back to its built-in default and the knob would do # nothing, which no grep of either file alone can catch. start_engine_stub set -a # shellcheck disable=SC1090 source "$ENVF" set +a [ -n "${WARM_TEXT:-}" ] || { echo "scripts/frank-tts-engine.env defines no WARM_TEXT"; false; } run env ENGINE_URL="http://127.0.0.1:$STUB_PORT" VOICES_DIR="$VOICES" \ bash "$WARM" [ "$status" -eq 0 ] || { echo "$output"; false; } python3 - "$TEST_TMP/journal.jsonl" "$WARM_TEXT" <<'PY' import json, sys texts = {json.loads(l).get("input") for l in open(sys.argv[1]) if json.loads(l)["path"] == "/v1/audio/speech"} assert texts == {sys.argv[2]}, (texts, sys.argv[2]) PY # The engine-side keys are in the same file and must not collide with the # warmer's: QWENTTS_PORT is what the server binds, ENGINE_URL is where the # hook dials, and they are different values by design (container bind address # versus in-namespace loopback). grep -qE '^QWENTTS_PORT=8880$' "$ENVF" grep -qE '^VOICES_DIR=/voices$' "$ENVF" } @test "the fetcher pins the decided weights and puts no credential on the wire" { # The byte counts and the Hub pointer oids ARE the contract for the payload, # the same way an image digest is: they came off the Hub on 2026-10-18 and a # silent change means someone moved a file upstream. run grep -h '^ "Serveurperso/Qwen3-TTS-GGUF|' "$FETCH" [ "$(printf '%s\n' "$output" | grep -c .)" = "2" ] printf '%s\n' "$output" | grep -qF "|$TALKER|$TALKER|$TALKER_BYTES|$TALKER_SHA" printf '%s\n' "$output" | grep -qF "|$CODEC|$CODEC|$CODEC_BYTES|$CODEC_SHA" # sha256 oids, not the 40-char git blob ids the same API also returns. run grep -oE '\|[0-9a-f]{64}"$' "$FETCH" [ "$(printf '%s\n' "$output" | grep -c .)" = "2" ] # Public repo: no auth header, and no token in the invocation. ! grep -qE 'HF_TOKEN|Authorization|--header' "$FETCH" grep -qE 'curl -fsSL --fail-with-body' "$FETCH" } @test "the fetcher reports absent and wrong-sized weights as MISSING, non-zero" { run bash "$FETCH" "$TEST_TMP/payload" --verify-only [ "$status" -eq 1 ] || { echo "$output"; false; } [[ "$output" == *"MISSING $TALKER"* ]] [[ "$output" == *"MISSING $CODEC"* ]] [[ "$output" == *"DOWNLOAD_FAILED"* ]] # A partial download of the right NAME is still missing: the byte count is # what gates, and a truncated GGUF would mmap fine and then produce noise. mkdir -p "$TEST_TMP/payload" head -c 4096 /dev/zero > "$TEST_TMP/payload/$TALKER" : > "$TEST_TMP/payload/$CODEC" run bash "$FETCH" "$TEST_TMP/payload" --verify-only [ "$status" -eq 1 ] [[ "$output" == *"MISSING $TALKER"* ]] [[ "$output" == *"MISSING $CODEC"* ]] } @test "the image build is the pinned commit with the proven cmake line" { # GGML_BLAS=OFF is forced by the host (libopenblas.so.0 present, no dev # headers) and upstream's own Dockerfile passes it ON, so this file is not a # copy of upstream on purpose. -j 8 and the OFF-for-every-GPU-backend list # are what the 2026-10-18 trial ran. grep -qE '^ARG QWENTTS_COMMIT=[0-9a-f]{40}$' "$CF" grep -qF '5916bf7c79dbb4e66fc059cdc507a79a23a28bb4' "$CF" grep -qE 'git -C /src checkout --detach' "$CF" grep -qE 'submodule update --init' "$CF" # ggml is a submodule; without it the build cannot configure for flag in -DGGML_CCACHE=OFF -DGGML_BACKEND_DL=OFF -DGGML_CPU_ALL_VARIANTS=OFF \ -DGGML_OPENMP=ON -DGGML_BLAS=OFF -DGGML_CUDA=OFF -DGGML_HIP=OFF \ -DGGML_VULKAN=OFF -DGGML_METAL=OFF -DGGML_SYCL=OFF -DGGML_RPC=OFF; do grep -qF -- "$flag" "$CF" || { echo "build line lost $flag"; false; } done grep -qE 'cmake --build build -j 8' "$CF" # OpenMP on means the runtime stage needs the OpenMP runtime, and the probe # runs as /bin/sh -c, so the runtime stage needs curl. grep -qE 'libgomp1' "$CF" grep -qE '^\s+ca-certificates curl libgomp1' "$CF" # Both scripts ship in the image: the server's entrypoint, and the warmer the # unit execs. A warmer that is not in the image makes the unit's # ExecStartPost fail on every boot, silently (`-` prefix by design). grep -qF 'COPY dankclaw/voiceservice/tts-engine-start.sh /app/start.sh' "$CF" grep -qF 'COPY dankclaw/voiceservice/tts-engine-warm.sh /app/warm-voices.sh' "$CF" grep -qE 'chmod 755 /app/start.sh /app/warm-voices.sh' "$CF" ! grep -qE '^HEALTHCHECK' "$CF" # the probe lives in the Quadlet unit grep -qE '^USER tts$' "$CF" # Base pinned by digest, both stages from one image. grep -qE '^ARG BASE_IMAGE=docker\.io/library/ubuntu:24\.04@sha256:[0-9a-f]{64}$' "$CF" [ "$(grep -cE '^FROM \$\{BASE_IMAGE\}' "$CF")" = "2" ] } @test "the planner maps every engine path to frank-tts-engine, and nothing to dankserver" { # Everything named after the engine, EXCEPT the bridge file: both units # attach to it, so a subnet change is a change to two deployables and that # pairing is asserted in the façade's planner test below. run python3 "$PLANNER" --target dankclaw --paths \ dankclaw/voiceservice/quadlet/frank-tts-engine.container \ dankclaw/voiceservice/Containerfile.engine \ dankclaw/voiceservice/tts-engine-start.sh \ scripts/frank-tts-engine.env \ dankclaw/voiceservice/tts-engine-warm.sh [ "$status" -eq 0 ] [ "$output" = "frank-tts-engine" ] # Host-side payload and repo tooling restart nothing: the weights are host # state, exactly like every other download script. run python3 "$PLANNER" --target dankclaw --paths \ scripts/download-frank-tts-engine-qwentts.sh [ "$status" -eq 0 ] [ -z "$output" ] # The engine is a Dankclaw container. On Dankserver these paths must plan # nothing rather than queue a service that does not exist there. run python3 "$PLANNER" --target dankserver --paths \ dankclaw/voiceservice/quadlet/frank-tts-engine.container \ scripts/frank-tts-engine.env [ "$status" -eq 0 ] [ -z "$output" ] } @test "changed-path routing selects this seam" { # The regression this guards is real and was hit next door: the generic # dankclaw/*.container case matches across `/`, so a quadlet path placed # below it never reaches its own seam. run "$REPO_ROOT/scripts/test-changed.sh" --list --paths \ dankclaw/voiceservice/quadlet/frank-tts-engine.container [ "$status" -eq 0 ] printf '%s\n' "$output" | grep -qx "tests/frank_tts_engine.bats" run "$REPO_ROOT/scripts/test-changed.sh" --list --paths \ dankclaw/voiceservice/quadlet/frank-tts-engine.network [ "$status" -eq 0 ] printf '%s\n' "$output" | grep -qx "tests/frank_tts_engine.bats" run "$REPO_ROOT/scripts/test-changed.sh" --list --paths scripts/frank-tts-engine.env [ "$status" -eq 0 ] printf '%s\n' "$output" | grep -qx "tests/frank_tts_engine.bats" printf '%s\n' "$output" | grep -qx "tests/scripts_syntax.bats" } @test "both deployer entry points know the engine and its handler gates on health, then warm" { grep -qF 'dankclaw:frank-tts-engine|' "$REMOTE" grep -qF 'dankclaw:frank-tts-engine|' "$REPO_ROOT/scripts/deploy.sh" grep -qF 'frank-tts-engine) deploy_frank_tts_engine ;;' "$REMOTE" grep -qF 'containerfile=dankclaw/voiceservice/Containerfile.engine' "$REMOTE" handler="$(sed -n '/^deploy_frank_tts_engine()/,/^}/p' "$REMOTE")" [ -n "$handler" ] || { echo "no deploy_frank_tts_engine handler"; false; } # Weights before build, build before start, health before warm: in that # order, because each step's failure message is only true in that order. local weights build quadlet start health warm pos_prev=-1 pos weights="$(printf '%s' "$handler" | grep -n 'download-frank-tts-engine-qwentts.sh --verify-only' | head -1 | cut -d: -f1)" build="$(printf '%s' "$handler" | grep -n 'build_compose_image frank-tts-engine' | head -1 | cut -d: -f1)" quadlet="$(printf '%s' "$handler" | grep -n 'install_quadlet frank-tts-engine' | head -1 | cut -d: -f1)" start="$(printf '%s' "$handler" | grep -n 'systemctl --user restart frank-tts-engine.service' | head -1 | cut -d: -f1)" health="$(printf '%s' "$handler" | grep -n 'wait_container_healthy frank-tts-engine' | head -1 | cut -d: -f1)" warm="$(printf '%s' "$handler" | grep -n 'podman exec frank-tts-engine /app/warm-voices.sh' | head -1 | cut -d: -f1)" for pos in "$weights" "$build" "$quadlet" "$start" "$health" "$warm"; do [ -n "$pos" ] || { echo "handler is missing a step (weights/build/quadlet/start/health/warm)"; false; } [ "$pos" -gt "$pos_prev" ] || { echo "handler steps are out of order"; false; } pos_prev="$pos" done # The gate is podman's recorded health, not a URL: this container publishes # nothing, so the host has no address to dial. printf '%s' "$handler" | grep -qE 'wait_container_healthy frank-tts-engine [0-9]+' ! printf '%s' "$handler" | grep -qE 'wait_for_health frank-tts-engine' # Generated Quadlet units cannot be enabled on this host: the handler must # not try, and the unit carries [Install] instead. ! printf '%s' "$handler" | grep -qE '^[[:space:]]*systemctl --user enable' } @test "the two scripts that ship in the image pass the repo's shell gates" { # scripts_syntax.bats walks scripts/ only, and these two no longer live # there: the entrypoint is the container's only way to read the env file, # and the warmer is exec-ed by the unit. A syntax error in either is a # container that never starts, or a deploy that reports an engine nobody # told it to talk to, so they get the same gates as scripts/*.sh. local s for s in "$START" "$WARM"; do bash -n "$s" || { echo "syntax error in $s"; false; } [ -x "$s" ] || { echo "$s is not executable — git must record the mode"; false; } if command -v shellcheck >/dev/null 2>&1; then shellcheck -S error "$s" || { echo "shellcheck errors in $s"; false; } fi done } @test "the generator puts the façade on both bridges and behind the engine" { rendered="$(render_units)" || { echo "$rendered"; skip "no Quadlet generator here"; } line="$(printf '%s\n' "$rendered" | grep -E '^ExecStart=/usr/bin/podman run .*--name frank-tts ')" [ -n "$line" ] || { printf '%s\n' "$rendered"; false; } # Two attachments, and the fleet-wide bridge FIRST: that is the network # consumers address (open-notebook dials `http://frank-tts:7772/v1` by # container name), so it owns the default route. The engine's private bridge # is second and exists so the shim can resolve the engine at all. [ "$(printf '%s' "$line" | grep -oE -- '--network [^ ]+' | head -1)" = "--network dankclaw_default" ] printf '%s' "$line" | grep -qE -- '--network systemd-frank-tts-engine( |$)' # Published exactly once, on the fleet port — and this is the only publish in # the voice stack. [ "$(printf '%s' "$line" | grep -oE -- '--publish [^ ]+' | wc -l)" = "1" ] printf '%s' "$line" | grep -qE -- '--publish (\[::\]:)?7772:7772( |$)' unit="$(unit_block frank-tts.service)" printf '%s' "$unit" | grep -qE '^Wants=frank-tts-engine\.service$' printf '%s' "$unit" | grep -qE '^After=.*frank-tts-engine\.service' # Wants, never Requires: the CLI rollback backend needs no upstream, and an # engine that will not start must not take this endpoint's unit down. ! printf '%s' "$unit" | grep -qE '^Requires=frank-tts-engine\.service$' # And it is tied to the bridge as well, or the container would be started # before netavark had created the network it dials over. printf '%s' "$unit" | grep -qE '^Requires=frank-tts-engine-network\.service$' } @test "the upstream the env file names is only resolvable on a network the unit joins" { # The pair's one real wiring contract, checked as a join rather than as a # string: take the hostname out of TTS_UPSTREAM the way the container will # get it, and require the façade unit to attach to the Quadlet network file # named after that hostname. A revert to host.containers.internal, or a unit # that drops the second Network= line, breaks here — both mistakes leave the # endpoint answering and every synthesis failing. set -a # shellcheck disable=SC1090 source "$FACADE_ENV" set +a host="${TTS_UPSTREAM#*://}" host="${host%%:*}" host="${host%%/*}" [ -n "$host" ] || { echo "TTS_UPSTREAM names no host: $TTS_UPSTREAM"; false; } [ "$host" != localhost ] || { echo "localhost is not reachable across netns"; false; } if ! grep -qE "^Network=${host}\.network$" "$FACADE"; then echo "frank-tts.container does not attach to ${host}.network" false fi if ! [ -f "$REPO_ROOT/dankclaw/voiceservice/quadlet/${host}.network" ]; then echo "no Quadlet network file for $host — the generator would drop the unit" false fi } @test "the planner keeps the two voice deployables separate and ordered" { # `--only` on the façade must not drag the engine's rebuild along, and a # bridge change must restart both: the network file is named after the engine # unit only because install_quadlet copies `$unit.network` beside the unit. run python3 "$PLANNER" --target dankclaw --paths dankclaw/voiceservice/app.py [ "$output" = "frank-tts" ] run python3 "$PLANNER" --target dankclaw --paths dankclaw/voiceservice/Containerfile [ "$output" = "frank-tts" ] run python3 "$PLANNER" --target dankclaw --paths \ dankclaw/voiceservice/quadlet/frank-tts.container [ "$output" = "frank-tts" ] run python3 "$PLANNER" --target dankclaw --paths \ dankclaw/voiceservice/quadlet/frank-tts-engine.container [ "$output" = "frank-tts-engine" ] run python3 "$PLANNER" --target dankclaw --paths \ dankclaw/voiceservice/quadlet/frank-tts-engine.network printf '%s\n' "$output" | grep -qx frank-tts printf '%s\n' "$output" | grep -qx frank-tts-engine # Order: dependency before consumer, so a full dankclaw deploy starts a warm # engine and then the endpoint that forwards into it. run python3 "$PLANNER" --target dankclaw --paths \ dankclaw/voiceservice/quadlet/frank-tts.container \ dankclaw/voiceservice/quadlet/frank-tts-engine.container local e f e="$(printf '%s\n' "$output" | grep -n '^frank-tts-engine$' | cut -d: -f1)" f="$(printf '%s\n' "$output" | grep -n '^frank-tts$' | cut -d: -f1)" [ -n "$e" ] && [ -n "$f" ] && [ "$e" -lt "$f" ] } @test "the façade handler builds its image, installs the bridge file, and gates on recorded health" { grep -qF 'dankclaw:frank-tts|' "$REMOTE" grep -qF 'dankclaw:frank-tts|' "$REPO_ROOT/scripts/deploy.sh" grep -qF 'frank-tts) deploy_frank_tts ;;' "$REMOTE" # build_compose_image's default arm returns 0 without building: an unlisted # service means a unit whose Pull=never image was never made. grep -qE '^\s+containerfile=dankclaw/voiceservice/Containerfile$' "$REMOTE" handler="$(sed -n '/^deploy_frank_tts()/,/^}/p' "$REMOTE")" [ -n "$handler" ] || { echo "no deploy_frank_tts handler"; false; } printf '%s' "$handler" | grep -qF 'build_compose_image frank-tts' printf '%s' "$handler" | grep -qF 'install_quadlet frank-tts dankclaw/voiceservice/quadlet frank-tts' # The engine's quadlet pair is installed by this handler too: with the # referenced .network file missing the generator produces no façade unit at # all, and `--only frank-tts` has no other way to get it onto the host. printf '%s' "$handler" | grep -qF 'install_quadlet frank-tts-engine dankclaw/voiceservice/quadlet frank-tts-engine' printf '%s' "$handler" | grep -qE 'mkdir -p /home/dank/voices' printf '%s' "$handler" | grep -qE 'systemctl --user restart frank-tts\.service' printf '%s' "$handler" | grep -qE 'wait_container_healthy frank-tts [0-9]+' ! printf '%s' "$handler" | grep -qE '^[[:space:]]*systemctl --user enable' } @test "an image rebuild is triggered by every file the image bakes in" { # build_compose_image skips `podman build` when the image's cfhash label # matches a hash — the thing that keeps the engine's 10-minute source clone # from running on every re-deploy. If that hash covers only the Containerfile, # then editing app.py (COPY'd into the façade) or the warm hook (COPY'd into # the engine) rebuilds NOTHING: the deploy reports success, /health answers, # and the container is still running last week's code. Observable output, not # a grep of the handler: did the deploy ask podman to build, or did it skip? awk '/^build_compose_image\(\) \{/,/^\}/' "$REMOTE" > "$TEST_TMP/bci.sh" [ -s "$TEST_TMP/bci.sh" ] || { echo "build_compose_image not extractable from $REMOTE"; false; } repo="$TEST_TMP/repo" mkdir -p "$repo/dankclaw/voiceservice" : > "$repo/dankclaw/voiceservice/Containerfile" : > "$repo/dankclaw/voiceservice/Containerfile.engine" : > "$repo/dankclaw/voiceservice/app.py" : > "$repo/dankclaw/voiceservice/tts-engine-start.sh" : > "$repo/dankclaw/voiceservice/tts-engine-warm.sh" # Stub podman: `image inspect` replays the label this stub stored on the last # build of THAT image (nothing the first time), `build` records the tag and # writes the label it was given. One state file per image, so the pair's two # images cannot borrow each other's freshness. mkdir -p "$TEST_TMP/bin" "$TEST_TMP/labels" cat > "$TEST_TMP/bin/podman" <<'STUB' #!/usr/bin/env bash # Image tags contain a slash (`franksdank/foo:latest`), so the state file name # flattens it — otherwise the write lands in a directory that does not exist and # every image looks permanently unbuilt. case "$1" in image) # invoked as `podman image inspect --format …`, so $3 is the tag f="$LABEL_DIR/${3//\//_}" [[ -s "$f" ]] && { cat "$f"; exit 0; } exit 1 ;; build) # Two passes: `podman build` puts `--label cfhash=…` BEFORE `-t `, so # the tag is not known when the label argument goes by. tag="" label="" for a in "$@"; do case "$a" in *:*) tag="$a" ;; cfhash=*) label="${a#cfhash=}" ;; esac done [[ -n "$label" ]] && printf '%s' "$label" > "$LABEL_DIR/${tag//\//_}" printf 'BUILD %s\n' "$tag" >> "$BUILD_LOG" ;; esac STUB chmod +x "$TEST_TMP/bin/podman" cat > "$TEST_TMP/drive.sh" <<'DRV' #!/usr/bin/env bash set -euo pipefail source "$BCI" build_compose_image "$1" DRV : > "$TEST_TMP/builds" builds() { grep -cF "BUILD $1" "$TEST_TMP/builds"; } drive() { ( cd "$repo" && BCI="$TEST_TMP/bci.sh" PATH="$TEST_TMP/bin:$PATH" \ LABEL_DIR="$TEST_TMP/labels" BUILD_LOG="$TEST_TMP/builds" \ bash "$TEST_TMP/drive.sh" "$1" ) } drive frank-tts-engine [ "$(builds franksdank/frank-tts-engine:latest)" -eq 1 ] \ || { echo "the first deploy of an unbuilt image must build"; false; } drive frank-tts-engine [ "$(builds franksdank/frank-tts-engine:latest)" -eq 1 ] \ || { echo "an unchanged tree must not rebuild — that is the 10-minute clone"; false; } printf '# the boot warmer grew a line\n' >> "$repo/dankclaw/voiceservice/tts-engine-warm.sh" drive frank-tts-engine [ "$(builds franksdank/frank-tts-engine:latest)" -eq 2 ] \ || { echo "editing a script the image COPYs must rebuild it"; false; } drive frank-tts [ "$(builds franksdank/frank-tts:latest)" -eq 1 ] || { echo "the façade must build once"; false; } printf '# the shim grew a route\n' >> "$repo/dankclaw/voiceservice/app.py" drive frank-tts [ "$(builds franksdank/frank-tts:latest)" -eq 2 ] \ || { echo "editing app.py must rebuild the façade image"; false; } } @test "the voice stack's homepage state: one probed card for the pair, and no link" { # The engine is not on the homepage and must not be: it publishes no port, so # there is no address the checker could dial, and a card that can never be # green is worse than no card. The façade carries the pair's signal — and # since the 2026-10-18 deploy that signal is a real probe (`wip: false`), not # a placeholder. It stays `showLink: false` and admin-only: an HTTP API has no # page to open, and the checker counts ANY HTTP response as up, so the card is # honest without a link and without a `/health` path in the probe. python3 - "$REPO_ROOT" <<'PY' import json, os, sys root = sys.argv[1] cards = {c["name"]: c for c in json.load(open(f"{root}/shared/homepage/services.json"))} assert "frank-tts-engine" not in cards, \ "the engine publishes no port: a card for it could never be probed green" fts = cards["frank-tts"] assert fts["probe"] == "127.0.0.1:7772", fts["probe"] assert fts["wip"] is False, \ "the endpoint is deployed; a wip card would hide its real state: " + repr(fts.get("wip")) assert fts["showLink"] is False and fts["url"] == "", \ "an API endpoint has no UI to link to — it is a status card, not a tile" assert fts["category"] == "admin", \ "consumers dial this; it is not an app the user homepage should advertise" assert os.path.exists(f"{root}/shared/homepage{fts['art']}"), f"missing art {fts['art']}" PY } @test "either deployable's paths select both voice seams" { # A change to the bridge or the env file can break the other half of the # pair, and the seams are cheap; routing them together is what keeps a # façade-only commit from landing without the engine's wiring tests. run "$REPO_ROOT/scripts/test-changed.sh" --list --paths \ dankclaw/voiceservice/quadlet/frank-tts.container [ "$status" -eq 0 ] printf '%s\n' "$output" | grep -qx "tests/frank_tts_unit.bats" printf '%s\n' "$output" | grep -qx "tests/frank_tts_engine.bats" run "$REPO_ROOT/scripts/test-changed.sh" --list --paths dankclaw/voiceservice/app.py [ "$status" -eq 0 ] printf '%s\n' "$output" | grep -qx "tests/frank_tts_unit.bats" }