Spaces:
Running on T4
Running on T4
| """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()) | |