room-visualizer / verify_live_smoke.py
GitHub Actions
Deploy from GitHub commit feae62fa5b6d65b6bfe64ecd37fd85011bd7fb99
a4f876a
Raw
History Blame Contribute Delete
5.94 kB
"""R0-3 — post-deploy live smoke gate.
Verifies the ARTIFACT, not the pipeline: after a deploy, converts the kitchen
reference photo (extracted from the committed bundle, so no binary assets in
the repo) against the LIVE Space and asserts the bundle carries the metric
stack. Catches stale images, partial rsyncs, and dependency regressions that
deploy "successfully" while serving old behaviour — both deploy traps hit in
June 2026 (unpromoted Vercel build; rsync size+mtime skip) would have failed
this gate within minutes instead of costing a photo test round.
Pure stdlib (no pip installs in CI).
Usage:
python verify_live_smoke.py [base_url]
# default base_url: https://modventures-room-visualizer.hf.space
Exit 0 = live Space serves the expected stack; non-zero = block/alert.
"""
import base64
import io
import json
import os
import sys
import time
import urllib.error
import urllib.request
import uuid
HERE = os.path.dirname(os.path.abspath(__file__))
BUNDLE = os.path.join(HERE, "data", "ref_kitchen.vizbundle.json")
DEFAULT_BASE = "https://modventures-room-visualizer.hf.space"
SPACE_API = "https://huggingface.co/api/spaces/modventures/room-visualizer"
BUILD_WAIT_S = 20 * 60 # Space docker rebuild after a push
CONVERT_RETRY_S = 10 * 60 # wake-from-sleep + model loads on first request
JOB_WAIT_S = 6 * 60
def get_json(url, timeout=30):
with urllib.request.urlopen(url, timeout=timeout) as r:
return json.load(r)
def wait_for_space_ready():
"""Wait out a BUILDING stage; RUNNING/SLEEPING are both fine (a request
wakes a sleeping Space)."""
deadline = time.time() + BUILD_WAIT_S
last = None
while time.time() < deadline:
try:
stage = get_json(SPACE_API).get("runtime", {}).get("stage")
except Exception as exc:
stage = f"api-error: {exc}"
if stage != last:
print(f" space stage: {stage}", flush=True)
last = stage
if stage in ("RUNNING", "SLEEPING", "PAUSED"):
return stage
time.sleep(20)
raise SystemExit(f"FAIL: Space not ready within {BUILD_WAIT_S}s (stage={last})")
def post_convert(base, img_bytes):
boundary = uuid.uuid4().hex
body = (
f"--{boundary}\r\nContent-Disposition: form-data; "
f'name="file"; filename="smoke.jpg"\r\n'
f"Content-Type: image/jpeg\r\n\r\n"
).encode() + img_bytes + f"\r\n--{boundary}--\r\n".encode()
req = urllib.request.Request(
base + "/viz2d/convert",
data=body,
method="POST",
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
)
deadline = time.time() + CONVERT_RETRY_S
while True:
try:
with urllib.request.urlopen(req, timeout=120) as r:
return json.load(r)
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, OSError) as exc:
if time.time() > deadline:
raise SystemExit(f"FAIL: /viz2d/convert unreachable: {exc}")
print(f" convert not ready ({exc}), retrying...", flush=True)
time.sleep(30)
def main():
base = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_BASE
print(f"smoke target: {base}", flush=True)
img_bytes = base64.b64decode(json.load(open(BUNDLE))["pixels"])
print(f" reference photo: {len(img_bytes)} bytes (from committed kitchen bundle)")
wait_for_space_ready()
job = post_convert(base, img_bytes)
jid = job.get("id") or job.get("jobId")
if not jid:
raise SystemExit(f"FAIL: convert returned no job id: {job}")
print(f" job: {jid}", flush=True)
deadline = time.time() + JOB_WAIT_S
status = None
while time.time() < deadline:
status = get_json(f"{base}/viz2d/jobs/{jid}").get("status")
if status not in ("PENDING", "PROCESSING"):
break
time.sleep(5)
if status != "COMPLETED":
raise SystemExit(f"FAIL: job status {status}")
bundle = get_json(f"{base}/viz2d/jobs/{jid}/file", timeout=120)
segs = bundle.get("segments") or []
if not segs:
raise SystemExit("FAIL: no segments in live bundle")
seg = max(segs, key=lambda s: s.get("metadata", {}).get("surfacePixels", 0))
meta = seg.get("metadata", {})
plane = seg.get("plane") or {}
checks = [
("depthEnabled", meta.get("depthEnabled") is True),
# R1-5 — either metric plane source is correct: moge-plane when the
# point-map fit passes its gates, depth-plane otherwise.
("geometrySource in {depth-plane, moge-plane}",
plane.get("geometrySource") in ("depth-plane", "moge-plane")),
("metersPerUnit in [0.5, 2.0]",
isinstance(plane.get("metersPerUnit"), (int, float))
and 0.5 <= plane["metersPerUnit"] <= 2.0),
("plane size sane (0.5..20 m)",
0.5 <= plane.get("width", 0) <= 20 and 0.5 <= plane.get("height", 0) <= 20),
("shadeMap present", bool(seg.get("shadeMap"))),
("shadeRange present", bool(seg.get("shadeRange"))),
# R4-1 — the intrinsic model must actually engage; a silent fall back
# to the luminance heuristic (load failure, missing weights, trust
# prompt) is exactly the class of regression this gate exists for.
("shadingSource=intrinsic", meta.get("shadingSource") == "intrinsic"),
]
ok = True
for label, passed in checks:
print(f" [{'PASS' if passed else 'FAIL'}] {label}")
ok &= passed
if not ok:
print(f"\n plane: { {k: plane.get(k) for k in ('geometrySource', 'metersPerUnit', 'width', 'height')} }")
print(f" metadata: { {k: meta.get(k) for k in ('depthEnabled', 'segmenter')} }")
raise SystemExit("LIVE SMOKE FAILED — the deployed Space is not serving the expected stack")
print("\nLIVE SMOKE PASSED")
return 0
if __name__ == "__main__":
raise SystemExit(main())