Files
serverAppConfigs/tests/frank_tts_engine.bats
T
Frank 2dc31ff09c docs(frank-tts): retire the 5.5 s cold-voice claim everywhere it still lived
PR #15 corrected the engine's page, the research brief's law bullet and the
warmer's header, but the retired ladder had six more homes: both quadlet files
("6.2-8.0 s… 0.5 ms", "~6-8 s each", "paying the 5.5 s cold decode"),
`scripts/frank-tts-engine.env` (the whole law paragraph and the WARM_TEXT note),
`scripts/frank-tts.env`, the [[Frank TTS]] troubleshooting row that told an
operator to expect ~5.5 s, the research brief's consequences paragraph, and one
test comment.

All re-keyed to what was measured on the deployed container 2026-10-18: the
first synthesis of a freshly registered voice costs 1,883 ms against 1,226-1,283
ms afterwards, so the warm pass buys about 0.6 s — and the reason it exists at
all, which the pages never led with, is that the registry is memory-only: without
the hook every voice name is a 400 until somebody synthesizes. The trial's dated
worklog entry stays as written; the correction points at it.

No behavior change: comments, unit lore and docs only, so no cfhash move and no
image rebuild.
2026-10-18 18:52:50 -07:00

978 lines
49 KiB
Bash
Executable File

#!/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 <name> — 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 <image> --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 <tag>`, 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 card for the pair, and it is hidden while undeployed" {
# 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.
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"] == "down" and fts["showLink"] is False, \
"nothing listens on 7772 until the deploy, so a visible card reads as down forever"
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"
}