Mirrors the cucucaracha (lacucarachanews) toolkit pattern adapted for
the voxel game:
bridges.rs adds TestCommand + Telemetry plumbing:
- thread_local TEST_COMMANDS queue + TELEMETRY snapshot.
- drain_test_commands() called by App::tick at frame start.
- publish_telemetry(t) called at frame end.
- wasm_api exports: set_scene_time, teleport, look_at, plus
getters get_scene_time / get_position / get_camera_angles.
app.rs:
- drain_test_commands() applies SetSceneTime / Teleport / LookAt
before physics integrates. Teleport zeroes velocity and syncs the
camera to feet+EYE_HEIGHT.
- publish_telemetry() at end of tick exposes scene state to JS.
test/:
launch.py Open Chromium with persistent profile + CDP:9222.
Navigates to https://voxel.mxvs.art by default;
--url for local dev.
peek.py Attach via CDP, screenshot canvas, dump telemetry
(scene_time, position, camera angles, hp). Read-only.
run.py Execute a YAML scenario:
wait_for, wait, eval, key, mouse_move, mouse,
screenshot, assert
Key allowlist prevents stray scenarios from sending
arbitrary input.
requirements.txt playwright + PyYAML.
README.md Setup, grammar, available bindings, why this
exists.
scenarios/
lighting-times-of-day.yaml Screenshots at noon / afternoon /
sunset / civil twilight / midnight
/ sunrise. Verifies the Round A
sunset fixes by visual diff.
god-rays-look-at-sun.yaml Pointed at the sun at four altitudes
to inspect the Round B shafts.
voxel-construction-darkness.yaml Visual baseline for the sky_vis
bake from Round D.
.gitignore Excludes the browser profile +
screenshots directory.
Visual regression workflow:
1. python3 launch.py
2. (separate terminal) python3 run.py scenarios/lighting-times-of-day.yaml
3. Compare screenshots/lighting-times-of-day_*.png against baseline.
Tests still 63 passing. Native + wasm release clean.
196 lines
6.1 KiB
Python
196 lines
6.1 KiB
Python
"""
|
|
Run a declarative scenario YAML against the running launch.py browser.
|
|
|
|
A scenario is a YAML mapping with a `name`, `description`, and a list
|
|
of `steps`. Each step is one mapping like `{eval: "..."}`. See
|
|
test/README.md for the full grammar.
|
|
|
|
Usage:
|
|
python3 run.py scenarios/lighting-times-of-day.yaml
|
|
python3 run.py scenarios/sunset.yaml --halt-on-fail
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
SCREENSHOT_DIR = HERE / "screenshots"
|
|
CDP_URL = "http://localhost:9222"
|
|
|
|
# All keys we'll let scenarios send via page.keyboard.press. Mirrors
|
|
# cucucaracha's whitelist + the keys our game listens for.
|
|
KEY_ALLOWLIST = {
|
|
"Enter", "Escape", "Tab", "Space",
|
|
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
|
|
"KeyW", "KeyA", "KeyS", "KeyD",
|
|
"ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight",
|
|
"Digit0", "Digit1", "Digit2", "Digit3", "Digit4",
|
|
"Digit5", "Digit6", "Digit7", "Digit8", "Digit9",
|
|
}
|
|
|
|
|
|
def attach_page():
|
|
p = sync_playwright().start()
|
|
try:
|
|
browser = p.chromium.connect_over_cdp(CDP_URL)
|
|
except Exception as e: # noqa: BLE001
|
|
p.stop()
|
|
sys.exit(f"could not attach to launch.py via {CDP_URL}: {e}")
|
|
pages = [pg for c in browser.contexts for pg in c.pages]
|
|
if not pages:
|
|
browser.close()
|
|
p.stop()
|
|
sys.exit("no open pages")
|
|
return p, browser, pages[-1]
|
|
|
|
|
|
def step_wait_for(page, expr: str, timeout_ms: int = 30_000):
|
|
"""Block until `expr` evaluates truthy in the page."""
|
|
start = time.time()
|
|
while True:
|
|
try:
|
|
ok = page.evaluate(f"() => Boolean({expr})")
|
|
except Exception:
|
|
ok = False
|
|
if ok:
|
|
return
|
|
if (time.time() - start) * 1000 > timeout_ms:
|
|
raise TimeoutError(f"wait_for {expr!r} timed out after {timeout_ms} ms")
|
|
time.sleep(0.1)
|
|
|
|
|
|
def run_step(page, step: dict, scenario_name: str, halt_on_fail: bool) -> bool:
|
|
"""Execute one step. Returns False on assertion failure."""
|
|
if "wait_for" in step:
|
|
print(f" wait_for: {step['wait_for']}")
|
|
step_wait_for(page, step["wait_for"], step.get("timeout_ms", 30_000))
|
|
return True
|
|
|
|
if "wait" in step:
|
|
ms = int(step["wait"])
|
|
print(f" wait: {ms} ms")
|
|
time.sleep(ms / 1000.0)
|
|
return True
|
|
|
|
if "eval" in step:
|
|
print(f" eval: {step['eval']}")
|
|
# Wrap so a bare expression like `set_scene_time(0)` works too.
|
|
js = step["eval"]
|
|
page.evaluate(f"() => {{ {js}; return null; }}")
|
|
return True
|
|
|
|
if "key" in step:
|
|
key = step["key"]
|
|
if key not in KEY_ALLOWLIST:
|
|
raise ValueError(f"key {key!r} not in allowlist {sorted(KEY_ALLOWLIST)}")
|
|
hold = step.get("hold")
|
|
if hold:
|
|
print(f" key: {key} hold={hold}ms")
|
|
page.keyboard.down(key)
|
|
time.sleep(int(hold) / 1000.0)
|
|
page.keyboard.up(key)
|
|
else:
|
|
print(f" key: {key}")
|
|
page.keyboard.press(key)
|
|
return True
|
|
|
|
if "mouse_move" in step:
|
|
dx, dy = step["mouse_move"]
|
|
print(f" mouse_move: ({dx}, {dy})")
|
|
# Relative motion via DeviceEvent — Playwright doesn't expose
|
|
# raw relative deltas, so we move from center by (dx, dy).
|
|
# The game uses pointer lock which means we'd need a different
|
|
# path for real-relative; this still works for many scenarios.
|
|
page.mouse.move(640 + dx, 400 + dy)
|
|
return True
|
|
|
|
if "mouse" in step:
|
|
action = step["mouse"]
|
|
print(f" mouse: {action}")
|
|
if action == "down":
|
|
page.mouse.down()
|
|
elif action == "up":
|
|
page.mouse.up()
|
|
elif action == "click":
|
|
page.mouse.click(640, 400)
|
|
else:
|
|
raise ValueError(f"unknown mouse action: {action}")
|
|
return True
|
|
|
|
if "screenshot" in step:
|
|
SCREENSHOT_DIR.mkdir(exist_ok=True)
|
|
name = step["screenshot"]
|
|
out = SCREENSHOT_DIR / f"{scenario_name}_{name}"
|
|
page.screenshot(path=str(out), full_page=False)
|
|
print(f" screenshot → {out}")
|
|
return True
|
|
|
|
if "assert" in step:
|
|
expr = step["assert"]
|
|
ok = page.evaluate(f"() => Boolean({expr})")
|
|
symbol = "✓" if ok else "✗"
|
|
print(f" assert: {expr} {symbol}")
|
|
if not ok and halt_on_fail:
|
|
return False
|
|
return ok
|
|
|
|
raise ValueError(f"unknown step type in {step!r}")
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("scenario", help="path to scenario YAML")
|
|
ap.add_argument(
|
|
"--halt-on-fail",
|
|
action="store_true",
|
|
help="stop on first failed assert (default: continue)",
|
|
)
|
|
args = ap.parse_args()
|
|
|
|
scenario_path = Path(args.scenario)
|
|
if not scenario_path.exists():
|
|
sys.exit(f"scenario not found: {scenario_path}")
|
|
scenario = yaml.safe_load(scenario_path.read_text())
|
|
name = scenario.get("name") or scenario_path.stem
|
|
print(f"# scenario: {name}")
|
|
if scenario.get("description"):
|
|
print(f"# {scenario['description']}")
|
|
print()
|
|
|
|
p, browser, page = attach_page()
|
|
failures = 0
|
|
try:
|
|
for i, step in enumerate(scenario.get("steps", [])):
|
|
print(f"step {i}:")
|
|
try:
|
|
ok = run_step(page, step, name, args.halt_on_fail)
|
|
if not ok:
|
|
failures += 1
|
|
if args.halt_on_fail:
|
|
break
|
|
except Exception as e: # noqa: BLE001
|
|
print(f" [!] step error: {e}", file=sys.stderr)
|
|
failures += 1
|
|
if args.halt_on_fail:
|
|
break
|
|
print()
|
|
if failures == 0:
|
|
print(f"# {name}: OK ({len(scenario.get('steps', []))} steps)")
|
|
else:
|
|
print(f"# {name}: FAILED ({failures} step(s))", file=sys.stderr)
|
|
finally:
|
|
browser.close()
|
|
p.stop()
|
|
|
|
return 0 if failures == 0 else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|