The endpoint deployed on 2026-10-18, so the admin card must carry its state rather than a placeholder: wip becomes false (the registry schema wants the key on every service; false is what the live cards use) and the description drops the "Hidden until deployed" line. showLink stays false and the card stays admin-only: an HTTP API has no page to open, and homepage-status.py counts ANY HTTP response as up, so the card is honest with no link and no /health path in the probe. The voice seam that pinned wip == down is re-keyed to the deployed contract and now also refuses a link or a user-category placement.
986 lines
49 KiB
Bash
Executable File
986 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 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"
|
|
}
|