GET /rhubarb?path= -> rhubarb -f json -> mouthCues for Lane A's viseme lane; 503 when the binary is absent (not installed on ultra yet). Path-guarded. test_server.py: +503 gating assert (9 groups green). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
247 lines
11 KiB
Python
247 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""M1 smoke test for scenegod.server — stdlib only, real uvicorn on a test port.
|
|
|
|
python3 scripts/test_server.py # green = M1 code-done
|
|
|
|
Spins up the server against throwaway asset/scene dirs, then asserts tree
|
|
grouping, file streaming, path-traversal rejection, scene round-trip, and
|
|
validation failures.
|
|
"""
|
|
import base64
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
PY = ROOT / ".venv" / "bin" / "python"
|
|
PORT = 8099
|
|
BASE = f"http://127.0.0.1:{PORT}"
|
|
|
|
|
|
def req(method, path, body=None):
|
|
data = body.encode() if isinstance(body, str) else body
|
|
r = urllib.request.Request(BASE + path, data=data, method=method)
|
|
try:
|
|
with urllib.request.urlopen(r) as resp:
|
|
return resp.status, resp.read()
|
|
except urllib.error.HTTPError as e:
|
|
return e.code, e.read()
|
|
|
|
|
|
def get_json(path):
|
|
st, b = req("GET", path)
|
|
assert st == 200, f"{path} -> {st}"
|
|
return json.loads(b)
|
|
|
|
|
|
def main():
|
|
tmp = Path(tempfile.mkdtemp(prefix="scenegod_test_"))
|
|
assets, scenes = tmp / "assets", tmp / "scenes"
|
|
# lady.glb + lady.fbx + lady.jpg share a stem -> one entry, thumb split off
|
|
(assets / "characters" / "pack01").mkdir(parents=True)
|
|
for f in ("lady.glb", "lady.fbx", "lady.jpg"):
|
|
(assets / "characters" / "pack01" / f).write_bytes(b"x")
|
|
(assets / "backdrops").mkdir(parents=True)
|
|
(assets / "backdrops" / "street.jpg").write_bytes(b"img")
|
|
for d in ("animations", "props", "audio"):
|
|
(assets / d).mkdir()
|
|
(assets / "props" / "box.glb").write_bytes(b"b") # no image sidecar
|
|
|
|
renders = tmp / "renders"
|
|
env = {**os.environ, "SCENEGOD_ASSETS": str(assets), "SCENEGOD_SCENES": str(scenes),
|
|
"SCENEGOD_RENDERS": str(renders)}
|
|
proc = subprocess.Popen(
|
|
[str(PY), "-m", "uvicorn", "scenegod.server:app", "--port", str(PORT)],
|
|
cwd=ROOT, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
try:
|
|
for _ in range(50): # wait for boot
|
|
try:
|
|
req("GET", "/scenes")
|
|
break
|
|
except urllib.error.URLError:
|
|
time.sleep(0.2)
|
|
else:
|
|
raise SystemExit("server did not start")
|
|
|
|
n = 0
|
|
# --- tree grouping ---
|
|
tree = get_json("/assets/tree")
|
|
chars = tree["characters"]
|
|
assert len(chars) == 1 and chars[0]["name"] == "lady", chars
|
|
assert chars[0]["path"] == "characters/pack01/lady.glb", chars # glb preferred
|
|
assert set(chars[0]["formats"]) == {"glb", "fbx"}, chars # jpg not a format
|
|
assert chars[0]["thumb"] == "characters/pack01/lady.jpg", chars
|
|
assert tree["backdrops"][0]["path"] == "backdrops/street.jpg", tree["backdrops"]
|
|
n += 1
|
|
|
|
# --- file streaming + thumbnail sidecar ---
|
|
st, b = req("GET", "/assets/file?path=backdrops/street.jpg")
|
|
assert st == 200 and b == b"img", (st, b)
|
|
st, b = req("GET", "/assets/thumb?path=" + urllib.parse.quote("characters/pack01/lady.glb"))
|
|
assert st == 200 and b == b"x", (st, b) # resolves lady.jpg sidecar
|
|
assert req("GET", "/assets/thumb?path=props/box.glb")[0] == 404 # no sidecar
|
|
n += 1
|
|
|
|
# --- path traversal rejected ---
|
|
for bad in ("../../etc/hosts", "/etc/hosts", "..%2f..%2fetc%2fhosts",
|
|
"characters/../../../etc/hosts"):
|
|
st, _ = req("GET", "/assets/file?path=" + bad)
|
|
assert st >= 400, f"traversal {bad!r} not rejected: {st}"
|
|
n += 1
|
|
|
|
# --- scene round-trip ---
|
|
scene = {"version": 1, "name": "s1", "fps": 30, "duration": 5.0,
|
|
"entities": [{"id": "e1", "kind": "camera", "label": "cam",
|
|
"tracks": {"transform": [{"t": 0}, {"t": 1}]}}],
|
|
"cameraCuts": [{"t": 0, "camera": "e1"}]}
|
|
st, _ = req("POST", "/scenes/" + urllib.parse.quote("My Scene!"), json.dumps(scene))
|
|
assert st == 200, st
|
|
assert get_json("/scenes/my-scene")["duration"] == 5.0 # slugified
|
|
assert any(s["name"] == "my-scene" for s in get_json("/scenes"))
|
|
n += 1
|
|
|
|
# --- validation failures ---
|
|
bad_scenes = [
|
|
{"version": 2, "entities": []}, # bad version
|
|
{"version": 1, "entities": [{"id": "a"}, {"id": "a"}]}, # dup id
|
|
{"version": 1, "entities": [{"id": "a", "tracks": # unsorted
|
|
{"transform": [{"t": 1}, {"t": 0}]}}]},
|
|
{"version": 1, "entities": [], "cameraCuts": [{"camera": "nope"}]}, # bad cut
|
|
{"version": 1, "entities": [{"id": "a", "tracks": {"clips": [ # overlap
|
|
{"start": 0, "in": 0, "out": 2, "loop": 1, "fade": 0},
|
|
{"start": 1, "in": 0, "out": 2}]}}]},
|
|
]
|
|
for bs in bad_scenes:
|
|
st, b = req("POST", "/scenes/bad", json.dumps(bs))
|
|
assert st == 422, f"expected 422 for {bs}, got {st}"
|
|
assert json.loads(b)["errors"], b
|
|
# a fade-covered overlap is allowed
|
|
ok = {"version": 1, "entities": [{"id": "a", "tracks": {"clips": [
|
|
{"start": 0, "in": 0, "out": 2, "loop": 1, "fade": 0.5},
|
|
{"start": 1.6, "in": 0, "out": 2}]}}]}
|
|
st, _ = req("POST", "/scenes/ok-fade", json.dumps(ok))
|
|
assert st == 200, st
|
|
n += 1
|
|
|
|
# --- render pipeline: begin -> post 30 PNGs -> end -> poll -> mp4 ---
|
|
if shutil.which("ffmpeg") and shutil.which("ffprobe"):
|
|
fr = tmp / "genframes"
|
|
fr.mkdir()
|
|
subprocess.run(["ffmpeg", "-y", "-f", "lavfi", "-i", "color=c=red:s=64x64",
|
|
"-frames:v", "30", str(fr / "%06d.png")],
|
|
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
rid = json.loads(req("POST", "/render/begin",
|
|
json.dumps({"name": "t", "fps": 30}))[1])["renderId"]
|
|
for i, png in enumerate(sorted(fr.glob("*.png"))):
|
|
st, _ = req("POST", f"/render/{rid}/frame/{i}", png.read_bytes())
|
|
assert st == 200, st
|
|
# M4: mux a tone from assets/audio/test/ via the scene audio[] contract
|
|
(assets / "audio" / "test").mkdir(parents=True)
|
|
subprocess.run(["ffmpeg", "-y", "-f", "lavfi", "-i", "sine=frequency=440:duration=1",
|
|
str(assets / "audio" / "test" / "tone.wav")],
|
|
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
end_body = json.dumps({"audio": [{"path": "audio/test/tone.wav", "start": 0, "gain": 1.0}]})
|
|
assert req("POST", f"/render/{rid}/end", end_body)[0] == 200
|
|
for _ in range(100): # poll up to ~20s
|
|
state = json.loads(req("GET", f"/render/{rid}/status")[1])["state"]
|
|
if state in ("done", "error"):
|
|
break
|
|
time.sleep(0.2)
|
|
assert state == "done", state
|
|
st, mp4 = req("GET", f"/render/{rid}/out.mp4")
|
|
assert st == 200 and mp4[:4] != b"", st
|
|
out = str(renders / rid / "out.mp4")
|
|
dur = subprocess.check_output(
|
|
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
|
"-of", "default=nw=1:nokey=1", out], text=True).strip()
|
|
assert abs(float(dur) - 1.0) < 0.2, f"mp4 duration {dur}s, expected ~1s"
|
|
codecs = subprocess.check_output(
|
|
["ffprobe", "-v", "error", "-select_streams", "a", "-show_entries",
|
|
"stream=codec_type", "-of", "default=nw=1:nokey=1", out], text=True).strip()
|
|
assert "audio" in codecs, f"no audio stream muxed: {codecs!r}"
|
|
# bad audio path rejected before encode
|
|
assert req("POST", f"/render/{rid}/end",
|
|
json.dumps({"audio": [{"path": "audio/nope.wav"}]}))[0] == 400
|
|
n += 1
|
|
else:
|
|
print("(render test skipped — ffmpeg/ffprobe not found)")
|
|
|
|
# --- rhubarb: 503 when the binary is absent (happy path needs rhubarb) ---
|
|
if not shutil.which("rhubarb"):
|
|
assert req("GET", "/rhubarb?path=audio/x.wav")[0] == 503
|
|
n += 1
|
|
|
|
# --- MODELBEAST proxy: gating + two-step contract (mock MB, no farm jobs) ---
|
|
assert req("POST", "/mb/submit", json.dumps({"operator": "tts"}))[0] == 503 # gated off
|
|
n += 1
|
|
|
|
captured = []
|
|
|
|
class MB(BaseHTTPRequestHandler):
|
|
def do_POST(self):
|
|
data = self.rfile.read(int(self.headers.get("Content-Length", 0)))
|
|
captured.append({"path": self.path, "auth": self.headers.get("Authorization"),
|
|
"ctype": self.headers.get("Content-Type", ""), "body": data})
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.end_headers()
|
|
self.wfile.write(json.dumps(
|
|
{"id": "asset123" if self.path == "/api/assets" else "job456"}).encode())
|
|
|
|
def log_message(self, *a):
|
|
pass
|
|
|
|
mbport, port2 = PORT + 1, PORT + 2
|
|
srv = HTTPServer(("127.0.0.1", mbport), MB)
|
|
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
env2 = {**env, "SCENEGOD_MB": "1", "MB_HOST": f"http://127.0.0.1:{mbport}",
|
|
"MB_TOKEN": "secret"}
|
|
p2 = subprocess.Popen(
|
|
[str(PY), "-m", "uvicorn", "scenegod.server:app", "--port", str(port2)],
|
|
cwd=ROOT, env=env2, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
try:
|
|
for _ in range(50):
|
|
try:
|
|
urllib.request.urlopen(f"http://127.0.0.1:{port2}/scenes")
|
|
break
|
|
except urllib.error.URLError:
|
|
time.sleep(0.2)
|
|
sub = {"operator": "voice-clone", "params": {"text": "hi"},
|
|
"file_b64": base64.b64encode(b"WAVDATA").decode(), "file_name": "in.wav"}
|
|
r = urllib.request.urlopen(urllib.request.Request(
|
|
f"http://127.0.0.1:{port2}/mb/submit",
|
|
data=json.dumps(sub).encode(), method="POST",
|
|
headers={"Content-Type": "application/json"}))
|
|
out = json.loads(r.read())
|
|
assert out == {"job_id": "job456", "asset_id": "asset123"}, out
|
|
assert [c["path"] for c in captured] == ["/api/assets", "/api/jobs"], captured
|
|
assert all(c["auth"] == "Bearer secret" for c in captured), "token not forwarded"
|
|
assert captured[0]["ctype"].startswith("multipart/form-data"), captured[0]["ctype"]
|
|
assert b"WAVDATA" in captured[0]["body"], "file bytes not uploaded"
|
|
job = json.loads(captured[1]["body"])
|
|
assert job["asset_id"] == "asset123" and job["operator"] == "voice-clone", job
|
|
n += 1
|
|
finally:
|
|
p2.terminate()
|
|
p2.wait()
|
|
srv.shutdown()
|
|
|
|
print(f"OK: {n} test groups passed")
|
|
finally:
|
|
proc.terminate()
|
|
proc.wait()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|