Files
2026-08-16 18:33:03 +07:00

325 lines
14 KiB
Python

#!/usr/bin/env python3
"""Headless Chromium smoke checks for the trellis2.cpp demo.
Uses only the Python standard library and Chromium's DevTools protocol so it can
run on the headless demo host without Playwright/Selenium. It intentionally
loads a persisted mesh and the showcase, exercising fetch, binary parsing,
WebGL2 buffer upload, and rendering rather than merely checking the HTML.
"""
import argparse
import base64
import json
import os
import secrets
import socket
import struct
import subprocess
import tempfile
import time
import urllib.request
from pathlib import Path
from urllib.parse import urlparse
class CDP:
def __init__(self, url):
parsed = urlparse(url)
self.sock = socket.create_connection((parsed.hostname, parsed.port), timeout=15)
key = base64.b64encode(secrets.token_bytes(16)).decode()
request = (
f"GET {parsed.path} HTTP/1.1\r\n"
f"Host: {parsed.hostname}:{parsed.port}\r\n"
"Upgrade: websocket\r\nConnection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n"
)
self.sock.sendall(request.encode())
response = b""
while b"\r\n\r\n" not in response:
response += self.sock.recv(4096)
if b" 101 " not in response.split(b"\r\n", 1)[0]:
raise RuntimeError(f"WebSocket upgrade failed: {response[:200]!r}")
self.next_id = 1
self.events = []
def _send_frame(self, payload):
data = payload.encode()
mask = secrets.token_bytes(4)
n = len(data)
header = bytearray([0x81])
if n < 126:
header.append(0x80 | n)
elif n < 65536:
header.append(0x80 | 126)
header.extend(struct.pack("!H", n))
else:
header.append(0x80 | 127)
header.extend(struct.pack("!Q", n))
header.extend(mask)
header.extend(bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
self.sock.sendall(header)
def _read_exact(self, n):
chunks = []
while n:
chunk = self.sock.recv(n)
if not chunk:
raise RuntimeError("DevTools WebSocket closed")
chunks.append(chunk)
n -= len(chunk)
return b"".join(chunks)
def _recv_frame(self):
b0, b1 = self._read_exact(2)
opcode, n = b0 & 0x0F, b1 & 0x7F
if n == 126:
n = struct.unpack("!H", self._read_exact(2))[0]
elif n == 127:
n = struct.unpack("!Q", self._read_exact(8))[0]
masked = bool(b1 & 0x80)
mask = self._read_exact(4) if masked else None
data = self._read_exact(n)
if mask:
data = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
if opcode == 0x9: # ping
self._send_frame(data.decode(errors="ignore"))
return self._recv_frame()
if opcode == 0x8:
raise RuntimeError("DevTools WebSocket closed")
return json.loads(data.decode())
def call(self, method, params=None, timeout=90):
ident = self.next_id
self.next_id += 1
self._send_frame(json.dumps({"id": ident, "method": method, "params": params or {}}))
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
self.sock.settimeout(max(0.1, deadline - time.monotonic()))
message = self._recv_frame()
if message.get("id") == ident:
if "error" in message:
raise RuntimeError(f"{method}: {message['error']}")
return message.get("result", {})
self.events.append(message)
raise TimeoutError(method)
def evaluate(self, expression, timeout=90):
result = self.call("Runtime.evaluate", {
"expression": expression,
"awaitPromise": True,
"returnByValue": True,
"userGesture": True,
}, timeout)
value = result.get("result", {})
if "exceptionDetails" in result:
raise RuntimeError(result["exceptionDetails"])
return value.get("value")
def wait_json(url, timeout=15):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(url, timeout=1) as response:
return json.load(response)
except Exception:
time.sleep(0.1)
raise TimeoutError(url)
def screenshot(cdp, path):
encoded = cdp.call("Page.captureScreenshot", {
"format": "png", "captureBeyondViewport": False,
}, timeout=30)["data"]
path.write_bytes(base64.b64decode(encoded))
def navigate(cdp, url, timeout=30):
cdp.call("Page.navigate", {"url": url}, timeout=timeout)
expected = urlparse(url).path or "/"
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
state = cdp.evaluate("({path: location.pathname, ready: document.readyState})", timeout=2)
if state and state["path"] == expected and state["ready"] == "complete":
return
except Exception:
pass
time.sleep(0.1)
raise TimeoutError(f"navigation to {url}")
def browser_errors(events):
errors = []
for event in events:
method, params = event.get("method"), event.get("params", {})
if method == "Runtime.exceptionThrown":
detail = params.get("exceptionDetails", {})
errors.append(detail.get("text", "JavaScript exception"))
elif method == "Log.entryAdded":
entry = params.get("entry", {})
if entry.get("level") in ("error", "warning"):
location = f" ({entry['url']})" if entry.get("url") else ""
errors.append(f"{entry.get('level')}: {entry.get('text', '')}{location}")
elif method == "Runtime.consoleAPICalled" and params.get("type") == "error":
args = params.get("args", [])
errors.append("console: " + " ".join(str(a.get("value", a.get("description", ""))) for a in args))
return errors
MAIN_CHECK = r"""
(async () => {
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
for (let i = 0; i < 100 && (!history || !history.length); i++) await sleep(100);
if (!history.length) throw new Error('no persisted generations in history');
// Prefer the oldest retained asset: it is stable across new generations and
// avoids coupling this reusable smoke test to a particular server/job ID.
const preferred = history[history.length - 1];
await viewGeneration(preferred.id);
await sleep(1200);
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const mesh = originalExportMesh;
const pbr = mesh && mesh.pbr;
const range = pbr ? {min: Array(6).fill(Infinity), max: Array(6).fill(-Infinity)} : null;
if (pbr) {
const stride = Math.max(6, Math.floor((pbr.length / 6) / 50000) * 6);
for (let i = 0; i < pbr.length; i += stride) {
for (let c = 0; c < 6; c++) {
range.min[c] = Math.min(range.min[c], pbr[i + c]);
range.max[c] = Math.max(range.max[c], pbr[i + c]);
}
}
}
const canvas = document.getElementById('gl');
const gl = canvas.getContext('webgl2');
const pixel = new Uint8Array(4);
gl.readPixels(canvas.width >> 1, canvas.height >> 1, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel);
return {
path: location.pathname, title: document.title,
historyCount: history.length, selected: preferred.id,
status: document.getElementById('status').textContent,
meshInfo: document.getElementById('meshinfo').textContent,
mesh: mesh ? {vertices: mesh.nv, triangles: mesh.nt, textured: mesh.textured} : null,
pbrRange: range, webgl2: !!gl, glError: gl.getError(), centerPixel: [...pixel],
canvas: {width: canvas.width, height: canvas.height,
cssWidth: canvas.clientWidth, cssHeight: canvas.clientHeight},
liveSteps: {text: document.getElementById('tsteps').textContent,
pressed: document.getElementById('tsteps').getAttribute('aria-pressed'),
keyframesDisabled: document.getElementById('tkf').disabled},
exportEnabled: !document.getElementById('dglb').disabled,
regenerateEnabled: !document.getElementById('regen').disabled,
};
})()
"""
SHOWCASE_CHECK = r"""
(async () => {
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
for (let i = 0; i < 600; i++) {
if (typeof showcaseAssets !== 'undefined' && history.length && showcaseAssets.length === history.length) break;
await sleep(100);
}
await sleep(1800);
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const canvas = document.getElementById('gl');
const gl = canvas.getContext('webgl2');
const display = selector => getComputedStyle(document.querySelector(selector)).display;
const pixel = new Uint8Array(4);
gl.readPixels(canvas.width >> 1, canvas.height >> 1, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel);
return {
path: location.pathname, title: document.title,
historyCount: history.length,
loadedAssets: typeof showcaseAssets === 'undefined' ? -1 : showcaseAssets.length,
running: typeof showcaseRunning === 'undefined' ? false : showcaseRunning,
label: document.getElementById('showcaselabel').textContent,
sourceClass: document.getElementById('showcasesource').className,
chrome: {header: display('header'), side: display('#side'), timeline: display('#timeline')},
webgl2: !!gl, glError: gl.getError(), centerPixel: [...pixel],
canvas: {width: canvas.width, height: canvas.height,
cssWidth: canvas.clientWidth, cssHeight: canvas.clientHeight},
};
})()
"""
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--url", default="http://127.0.0.1:8742")
parser.add_argument("--chromium", default="chromium")
parser.add_argument("--output", default="/tmp/trellis2-headless")
args = parser.parse_args()
output = Path(args.output)
output.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="trellis2-chrome-") as profile:
command = [
args.chromium, "--headless=new", "--no-sandbox", "--disable-dev-shm-usage",
"--enable-webgl", "--use-angle=swiftshader", "--enable-unsafe-swiftshader",
"--remote-debugging-port=0", "--remote-allow-origins=*",
f"--user-data-dir={profile}", "--window-size=1280,900", "about:blank",
]
browser = subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
try:
port_file = Path(profile) / "DevToolsActivePort"
deadline = time.monotonic() + 15
while not port_file.exists() and time.monotonic() < deadline:
if browser.poll() is not None:
raise RuntimeError(browser.stderr.read().decode(errors="replace"))
time.sleep(0.1)
port = int(port_file.read_text().splitlines()[0])
targets = wait_json(f"http://127.0.0.1:{port}/json/list")
page = next(t for t in targets if t["type"] == "page")
cdp = CDP(page["webSocketDebuggerUrl"])
for method in ("Page.enable", "Runtime.enable", "Log.enable"):
cdp.call(method)
results = {}
navigate(cdp, args.url.rstrip("/") + "/")
results["main"] = cdp.evaluate(MAIN_CHECK, timeout=120)
screenshot(cdp, output / "main.png")
navigate(cdp, args.url.rstrip("/") + "/showcase")
results["showcase"] = cdp.evaluate(SHOWCASE_CHECK, timeout=180)
screenshot(cdp, output / "showcase.png")
results["browserErrors"] = browser_errors(cdp.events)
results["screenshots"] = [str(output / "main.png"), str(output / "showcase.png")]
failures = []
main_result, showcase_result = results["main"], results["showcase"]
if not main_result["webgl2"] or main_result["glError"] != 0:
failures.append("main page WebGL2 failure")
if not main_result["mesh"]["textured"] or not main_result["pbrRange"]:
failures.append("main page did not load PBR data")
elif max(main_result["pbrRange"]["max"][c] - main_result["pbrRange"]["min"][c]
for c in range(3)) < 0.1:
failures.append("main page base colour is effectively constant")
if main_result["status"] != "done":
failures.append(f"main page status is {main_result['status']!r}")
if main_result["liveSteps"] != {
"text": "live steps: off", "pressed": "false", "keyframesDisabled": True}:
failures.append(f"live steps did not default clearly off: {main_result['liveSteps']!r}")
if showcase_result["loadedAssets"] != showcase_result["historyCount"]:
failures.append("showcase did not load every persisted asset")
if not showcase_result["webgl2"] or showcase_result["glError"] != 0:
failures.append("showcase WebGL2 failure")
if any(value != "none" for value in showcase_result["chrome"].values()):
failures.append("showcase page chrome is visible")
significant_errors = [e for e in results["browserErrors"] if "favicon.ico" not in e]
if significant_errors:
failures.extend(significant_errors)
results["failures"] = failures
results["passed"] = not failures
print(json.dumps(results, indent=2))
if failures:
raise SystemExit(1)
finally:
browser.terminate()
try:
browser.wait(timeout=5)
except subprocess.TimeoutExpired:
browser.kill()
if __name__ == "__main__":
main()