Test harness: declarative Playwright scenarios + wasm state bindings
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.
This commit is contained in:
parent
dccb06dddf
commit
f1e007dd63
11 changed files with 780 additions and 1 deletions
54
src/app.rs
54
src/app.rs
|
|
@ -17,7 +17,7 @@
|
|||
//! 11. Block interaction (raycast → break/place) → maybe broadcast edit
|
||||
//! 12. Apply damage; periodic state broadcast
|
||||
//! 13. Render frame
|
||||
use crate::bridges::{self, RemotePlayer, Settings};
|
||||
use crate::bridges::{self, RemotePlayer, Settings, Telemetry, TestCommand};
|
||||
use crate::camera::{Camera, InputState, KbHeld};
|
||||
use crate::net::{parse_inbox, NetEvent};
|
||||
use crate::proto::{ClientMsg, EditRec};
|
||||
|
|
@ -398,8 +398,59 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
/// Apply queued declarative-test commands. Called at the very top
|
||||
/// of tick so a scenario's "set time / teleport / look at" land
|
||||
/// before physics integrates anything this frame.
|
||||
fn drain_test_commands(&mut self) {
|
||||
for cmd in bridges::drain_test_commands() {
|
||||
match cmd {
|
||||
TestCommand::SetSceneTime(t) => {
|
||||
self.shader_time = t;
|
||||
}
|
||||
TestCommand::Teleport(p) => {
|
||||
self.body.feet = p;
|
||||
self.body.velocity = Vec3::ZERO;
|
||||
self.body.on_ground = false;
|
||||
self.body.max_y_since_ground = p.y;
|
||||
if let Some(cam) = self.camera.borrow_mut().as_mut() {
|
||||
cam.position = Vec3::new(p.x, p.y + EYE_HEIGHT, p.z);
|
||||
}
|
||||
}
|
||||
TestCommand::LookAt(yaw, pitch) => {
|
||||
if let Some(cam) = self.camera.borrow_mut().as_mut() {
|
||||
cam.yaw = yaw;
|
||||
cam.pitch = pitch;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish telemetry for the test harness to read back.
|
||||
fn publish_telemetry(&self) {
|
||||
let (yaw, pitch) = self
|
||||
.camera
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.map(|c| (c.yaw, c.pitch))
|
||||
.unwrap_or((0.0, 0.0));
|
||||
bridges::publish_telemetry(Telemetry {
|
||||
scene_time: self.shader_time,
|
||||
pos_x: self.body.feet.x,
|
||||
pos_y: self.body.feet.y,
|
||||
pos_z: self.body.feet.z,
|
||||
yaw,
|
||||
pitch,
|
||||
hp: self.body.hp,
|
||||
alive: self.body.alive,
|
||||
});
|
||||
}
|
||||
|
||||
/// One frame. See module doc-comment for the pipeline shape.
|
||||
fn tick(&mut self) {
|
||||
// Apply any test-harness commands before integrating physics.
|
||||
self.drain_test_commands();
|
||||
|
||||
let dt = match self.last_frame.as_ref() {
|
||||
Some(c) => c.elapsed().as_secs_f32().min(0.1),
|
||||
None => 0.016,
|
||||
|
|
@ -600,6 +651,7 @@ impl App {
|
|||
drop(world_borrow);
|
||||
drop(camera_borrow);
|
||||
self.render_frame(settings, outline);
|
||||
self.publish_telemetry();
|
||||
let _ = WORLD_RADIUS;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,38 @@ use glam::Vec3;
|
|||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
// ---------------- Test harness ----------------
|
||||
//
|
||||
// Lightweight declarative-test surface: scenarios poke commands in
|
||||
// from JS (set scene time, teleport, look at angle) and read back
|
||||
// telemetry (current time, position, yaw/pitch) to verify what the
|
||||
// game thinks is true. App::tick drains the command queue at the
|
||||
// start of each frame and publishes telemetry at the end.
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum TestCommand {
|
||||
/// Override the shader_time directly (in seconds). Useful for
|
||||
/// stepping the day/night cycle to a specific phase for visual
|
||||
/// regression screenshots.
|
||||
SetSceneTime(f32),
|
||||
/// Teleport the player feet to this world position.
|
||||
Teleport(Vec3),
|
||||
/// Set camera yaw + pitch (radians).
|
||||
LookAt(f32, f32),
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Copy, Debug)]
|
||||
pub struct Telemetry {
|
||||
pub scene_time: f32,
|
||||
pub pos_x: f32,
|
||||
pub pos_y: f32,
|
||||
pub pos_z: f32,
|
||||
pub yaw: f32,
|
||||
pub pitch: f32,
|
||||
pub hp: u8,
|
||||
pub alive: bool,
|
||||
}
|
||||
|
||||
// ---------------- Data types stored in the bridges ----------------
|
||||
|
||||
/// User-tunable settings, mirrored from the JS settings menu.
|
||||
|
|
@ -79,6 +111,9 @@ thread_local! {
|
|||
});
|
||||
static NET_BRIDGE: RefCell<NetBridge> = RefCell::new(NetBridge::default());
|
||||
static SETTINGS: RefCell<Settings> = RefCell::new(Settings::default());
|
||||
// Test-harness storage.
|
||||
static TEST_COMMANDS: RefCell<Vec<TestCommand>> = RefCell::new(Vec::new());
|
||||
static TELEMETRY: RefCell<Telemetry> = RefCell::new(Telemetry::default());
|
||||
}
|
||||
|
||||
// ---------------- Public typed accessors ----------------
|
||||
|
|
@ -183,6 +218,16 @@ pub fn snapshot_remote_players() -> Vec<RemotePlayer> {
|
|||
NET_BRIDGE.with(|n| n.borrow().remote_players.values().cloned().collect())
|
||||
}
|
||||
|
||||
/// Drain queued test commands (called by App::tick at frame start).
|
||||
pub fn drain_test_commands() -> Vec<TestCommand> {
|
||||
TEST_COMMANDS.with(|q| std::mem::take(&mut *q.borrow_mut()))
|
||||
}
|
||||
|
||||
/// Publish current frame telemetry (called by App::tick at frame end).
|
||||
pub fn publish_telemetry(t: Telemetry) {
|
||||
TELEMETRY.with(|x| *x.borrow_mut() = t);
|
||||
}
|
||||
|
||||
// ---------------- wasm-bindgen JS interface ----------------
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
|
@ -295,4 +340,44 @@ mod wasm_api {
|
|||
pub fn reset_input() {
|
||||
super::clear_touch_inputs();
|
||||
}
|
||||
|
||||
// ----- Test harness exports -----
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn set_scene_time(t: f32) {
|
||||
super::TEST_COMMANDS.with(|q| {
|
||||
q.borrow_mut().push(super::TestCommand::SetSceneTime(t));
|
||||
});
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn teleport(x: f32, y: f32, z: f32) {
|
||||
super::TEST_COMMANDS.with(|q| {
|
||||
q.borrow_mut()
|
||||
.push(super::TestCommand::Teleport(super::Vec3::new(x, y, z)));
|
||||
});
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn look_at(yaw: f32, pitch: f32) {
|
||||
super::TEST_COMMANDS.with(|q| {
|
||||
q.borrow_mut().push(super::TestCommand::LookAt(yaw, pitch));
|
||||
});
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn get_scene_time() -> f32 {
|
||||
super::TELEMETRY.with(|x| x.borrow().scene_time)
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn get_position() -> Vec<f32> {
|
||||
super::TELEMETRY.with(|x| {
|
||||
let t = x.borrow();
|
||||
vec![t.pos_x, t.pos_y, t.pos_z]
|
||||
})
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn get_camera_angles() -> Vec<f32> {
|
||||
super::TELEMETRY.with(|x| {
|
||||
let t = x.borrow();
|
||||
vec![t.yaw, t.pitch]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
5
test/.gitignore
vendored
Normal file
5
test/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.browser_profile/
|
||||
screenshots/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
102
test/README.md
Normal file
102
test/README.md
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# Test harness — declarative visual + behavioral scenarios
|
||||
|
||||
Mirrors the cucucaracha (lacucarachanews) toolkit pattern: `launch.py`
|
||||
opens a Chromium with persistent profile + CDP on port 9222; small
|
||||
attach-only tools drive the *same* session via Playwright.
|
||||
|
||||
What's different: the game exposes a small set of wasm bindings so
|
||||
scenarios can declaratively set scene state and read back telemetry,
|
||||
not just click DOM elements. See `src/bridges.rs` `wasm_api` for the
|
||||
exports.
|
||||
|
||||
## Setup (once)
|
||||
|
||||
```sh
|
||||
python3 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
## Open the game in a controllable browser
|
||||
|
||||
```sh
|
||||
python3 launch.py # opens https://voxel.mxvs.art
|
||||
python3 launch.py --url http://localhost:8080 # local dev server
|
||||
```
|
||||
|
||||
The browser stays open; profile lives at `./.browser_profile/`. CDP
|
||||
listens on `localhost:9222` so the other tools can attach.
|
||||
|
||||
## One-shot inspection / screenshot
|
||||
|
||||
```sh
|
||||
python3 peek.py # screenshot + dump game telemetry
|
||||
python3 peek.py --json # machine-readable
|
||||
```
|
||||
|
||||
Writes a PNG to `screenshots/<ts>_peek.png`.
|
||||
|
||||
## Run a scenario
|
||||
|
||||
```sh
|
||||
python3 run.py scenarios/lighting-times-of-day.yaml
|
||||
```
|
||||
|
||||
Scenarios are YAML lists of `steps`. Each step is one of:
|
||||
|
||||
| step | meaning |
|
||||
|-----------------------------|---------|
|
||||
| `wait_for: <js_expr>` | block until `js_expr` evaluates truthy |
|
||||
| `wait: <ms>` | sleep that many ms |
|
||||
| `eval: <js>` | run JS in the page (state setters, etc.) |
|
||||
| `key: <key> [hold: ms]` | press a key (optionally hold) |
|
||||
| `mouse_move: [dx, dy]` | relative mouse motion |
|
||||
| `mouse: <down\|up\|click>` | mouse button events |
|
||||
| `screenshot: <name>.png` | save canvas screenshot to `screenshots/` |
|
||||
| `assert: <js_expr>` | fail scenario if `js_expr` is falsy |
|
||||
|
||||
## Available game-state JS bindings
|
||||
|
||||
All exported by the wasm module (see `src/bridges.rs::wasm_api`).
|
||||
After `wait_for: "window.voxel_game !== undefined"`, call as
|
||||
`window.voxel_game.<fn>(...)`.
|
||||
|
||||
| Setter | Effect |
|
||||
|--------|--------|
|
||||
| `set_scene_time(t: f32)` | jump shader time to `t` seconds |
|
||||
| `set_time_scale(s: f32)` | freeze (0) / fast-forward time |
|
||||
| `teleport(x, y, z)` | move player feet to (x,y,z) |
|
||||
| `look_at(yaw, pitch)` | set camera angles (radians) |
|
||||
| `set_paused(b)` | pause input + physics |
|
||||
| `set_fov(deg)` / `set_mouse_sens(s)` / `set_render_distance(blocks)` | settings |
|
||||
| `respawn()` | one-shot respawn request |
|
||||
|
||||
| Getter | Returns |
|
||||
|--------|---------|
|
||||
| `get_scene_time()` | `f32` |
|
||||
| `get_position()` | `[x, y, z]` |
|
||||
| `get_camera_angles()` | `[yaw, pitch]` |
|
||||
| `get_hp()` / `is_alive()` | from death/respawn state |
|
||||
|
||||
## Why this exists
|
||||
|
||||
When something looks wrong (e.g. "tops of blocks don't react to
|
||||
sunset"), the dev loop without this is: deploy, open browser, fiddle
|
||||
the time slider, compare to baseline by memory. With this, the loop
|
||||
is: write a scenario that screenshots the same view at noon / sunset /
|
||||
midnight, run it, diff the screenshots against a baseline. Bugs become
|
||||
visible in one command.
|
||||
|
||||
For non-visual behaviors (e.g. "is the joystick releasing correctly?")
|
||||
the same harness sends keyboard events and reads back position
|
||||
telemetry to assert "after release, player stops moving within N ms".
|
||||
|
||||
## Sanity guarantees
|
||||
|
||||
- Persistent profile means cookies, settings, and game state survive
|
||||
across `launch.py` runs. The first launch is "fresh"; subsequent
|
||||
ones resume where you left off.
|
||||
- `launch.py` never touches the page beyond the initial navigation.
|
||||
Scenarios drive the session; the launcher just hosts the browser.
|
||||
- All tools attach via CDP — closing them doesn't close the browser.
|
||||
97
test/launch.py
Normal file
97
test/launch.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"""
|
||||
Launch Chromium pointed at the voxel game with CDP attached.
|
||||
|
||||
Mirrors the cucucaracha launch pattern:
|
||||
- Persistent profile dir (./.browser_profile/) so settings + cookies
|
||||
survive between runs.
|
||||
- Remote debugging on port 9222 so peek.py / run.py attach to the
|
||||
same session.
|
||||
- Disables saved-password autofill (irrelevant here but harmless).
|
||||
- Navigates ONCE to the target URL and sits idle.
|
||||
|
||||
The browser stays open until you Ctrl-C this terminal or close the
|
||||
window. Scenarios are driven from a second terminal via run.py.
|
||||
|
||||
Usage:
|
||||
python3 launch.py
|
||||
python3 launch.py --url http://localhost:8080
|
||||
python3 launch.py --url http://localhost:8080 --headed/--headless
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
USER_DATA_DIR = HERE / ".browser_profile"
|
||||
DEFAULT_URL = "https://voxel.mxvs.art/"
|
||||
CDP_PORT = 9222
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--url", default=DEFAULT_URL, help="game URL to open")
|
||||
ap.add_argument(
|
||||
"--headless",
|
||||
action="store_true",
|
||||
help="run headless (no visible window). Default is headed.",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
USER_DATA_DIR.mkdir(exist_ok=True)
|
||||
|
||||
chrome_args = [
|
||||
f"--remote-debugging-port={CDP_PORT}",
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
# WebGPU benefits from these on Linux — voxel game prefers
|
||||
# WebGPU but will fall back to WebGL2 if disabled.
|
||||
"--enable-unsafe-webgpu",
|
||||
# Smaller window default
|
||||
"--window-size=1280,800",
|
||||
]
|
||||
|
||||
with sync_playwright() as p:
|
||||
ctx = p.chromium.launch_persistent_context(
|
||||
user_data_dir=str(USER_DATA_DIR),
|
||||
headless=args.headless,
|
||||
viewport={"width": 1280, "height": 800},
|
||||
args=chrome_args,
|
||||
ignore_default_args=["--enable-automation"],
|
||||
)
|
||||
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
||||
|
||||
try:
|
||||
page.goto(args.url, wait_until="domcontentloaded", timeout=45000)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(
|
||||
f"[!] navigation error (browser stays open): {e}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
print(
|
||||
f"[i] Browser open at {args.url}\n"
|
||||
f" CDP listening on http://localhost:{CDP_PORT}\n"
|
||||
f" Profile: {USER_DATA_DIR}\n"
|
||||
" Use peek.py / run.py from another terminal.\n"
|
||||
" Stop with Ctrl-C or close the browser window.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(3600)
|
||||
except KeyboardInterrupt:
|
||||
print("[i] Ctrl-C received; exiting.", file=sys.stderr, flush=True)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
114
test/peek.py
Normal file
114
test/peek.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""
|
||||
One-shot inspection: attach to the running launch.py browser via CDP,
|
||||
screenshot the canvas, and dump current game telemetry (scene time,
|
||||
player position, camera angles, hp).
|
||||
|
||||
Read-only. Cannot send inputs, change state, or close the browser.
|
||||
|
||||
Usage:
|
||||
python3 peek.py
|
||||
python3 peek.py --json
|
||||
python3 peek.py --name baseline # custom screenshot filename
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
SCREENSHOT_DIR = HERE / "screenshots"
|
||||
CDP_URL = "http://localhost:9222"
|
||||
|
||||
|
||||
# JS evaluated in the page to gather game state. Pure reads only.
|
||||
TELEMETRY_JS = r"""
|
||||
() => {
|
||||
const g = window.voxel_game;
|
||||
if (!g) return { ready: false };
|
||||
return {
|
||||
ready: true,
|
||||
scene_time: g.get_scene_time?.() ?? null,
|
||||
position: g.get_position?.() ?? null,
|
||||
camera_angles: g.get_camera_angles?.() ?? null,
|
||||
hp: g.get_hp?.() ?? null,
|
||||
alive: g.is_alive?.() ?? null,
|
||||
canvas_size: (() => {
|
||||
const c = document.getElementById('game-canvas');
|
||||
return c ? { w: c.width, h: c.height } : null;
|
||||
})(),
|
||||
};
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
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 main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--name", default="peek", help="screenshot name prefix")
|
||||
ap.add_argument("--json", action="store_true", help="JSON output only")
|
||||
args = ap.parse_args()
|
||||
|
||||
SCREENSHOT_DIR.mkdir(exist_ok=True)
|
||||
p, browser, page = attach_page()
|
||||
try:
|
||||
try:
|
||||
telemetry = page.evaluate(TELEMETRY_JS)
|
||||
except Exception as e: # noqa: BLE001
|
||||
telemetry = {"ready": False, "error": str(e)}
|
||||
|
||||
shot = SCREENSHOT_DIR / f"{int(time.time())}_{args.name}.png"
|
||||
try:
|
||||
page.screenshot(path=str(shot), full_page=False)
|
||||
except Exception as e: # noqa: BLE001
|
||||
shot = None
|
||||
print(f"[!] screenshot failed: {e}", file=sys.stderr)
|
||||
|
||||
result = {
|
||||
"url": page.url,
|
||||
"screenshot": str(shot) if shot else None,
|
||||
**telemetry,
|
||||
}
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2))
|
||||
else:
|
||||
print(f"URL: {result['url']}")
|
||||
print(f"SHOT: {result.get('screenshot')}")
|
||||
print(f"READY: {result.get('ready')}")
|
||||
if result.get("ready"):
|
||||
print(f"TIME: {result.get('scene_time'):.2f}s")
|
||||
pos = result.get("position") or []
|
||||
print(f"POS: ({', '.join(f'{v:.2f}' for v in pos)})")
|
||||
ang = result.get("camera_angles") or []
|
||||
print(f"YAW/PITCH: ({', '.join(f'{v:.3f}' for v in ang)})")
|
||||
print(f"HP: {result.get('hp')}")
|
||||
print(f"ALIVE: {result.get('alive')}")
|
||||
finally:
|
||||
browser.close()
|
||||
p.stop()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
2
test/requirements.txt
Normal file
2
test/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
playwright>=1.40
|
||||
PyYAML>=6.0
|
||||
196
test/run.py
Normal file
196
test/run.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
"""
|
||||
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())
|
||||
40
test/scenarios/god-rays-look-at-sun.yaml
Normal file
40
test/scenarios/god-rays-look-at-sun.yaml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
name: god-rays-look-at-sun
|
||||
description: |
|
||||
Verify the screen-space god-rays kick in when the camera is
|
||||
pointed roughly toward the sun and there's geometry partially
|
||||
occluding it. Screenshots at three sun altitudes for visual
|
||||
inspection.
|
||||
|
||||
steps:
|
||||
- wait_for: "window.voxel_game !== undefined && window.voxel_game.get_scene_time !== undefined"
|
||||
|
||||
- eval: "window.voxel_game.set_time_scale(0.0)"
|
||||
|
||||
# Stand on the surface and look in the sun's general direction.
|
||||
# Sun rotates in the xy-plane with z-tilt; at t=60 the sun is
|
||||
# toward the west-and-up. The look_at yaw puts the camera facing
|
||||
# roughly that bearing.
|
||||
- eval: "window.voxel_game.teleport(0, 40, 0)"
|
||||
- eval: "window.voxel_game.look_at(0.0, 0.25)"
|
||||
- wait: 200
|
||||
|
||||
# Mid-morning — sun well above horizon, full daylight, shafts subtle.
|
||||
- eval: "window.voxel_game.set_scene_time(30.0)"
|
||||
- wait: 300
|
||||
- screenshot: 01-mid-morning.png
|
||||
|
||||
# Late afternoon — sun lower, warm light, shafts more pronounced.
|
||||
- eval: "window.voxel_game.set_scene_time(60.0)"
|
||||
- wait: 300
|
||||
- screenshot: 02-late-afternoon.png
|
||||
|
||||
# Sunset — sun at horizon, maximum atmospheric scattering, shafts
|
||||
# should be very prominent.
|
||||
- eval: "window.voxel_game.set_scene_time(75.0)"
|
||||
- wait: 300
|
||||
- screenshot: 03-sunset-rays.png
|
||||
|
||||
# Night — no shafts at all.
|
||||
- eval: "window.voxel_game.set_scene_time(150.0)"
|
||||
- wait: 300
|
||||
- screenshot: 04-night-no-rays.png
|
||||
54
test/scenarios/lighting-times-of-day.yaml
Normal file
54
test/scenarios/lighting-times-of-day.yaml
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
name: lighting-times-of-day
|
||||
description: |
|
||||
Screenshot the same view at noon, golden hour, sunset, civil
|
||||
twilight, and midnight. Used to verify the sunset-family fixes
|
||||
(zenith warm tint, fog warm tint, star fade, sun disc spread).
|
||||
Position the player high above terrain looking toward the horizon
|
||||
so all four faces of nearby blocks (top, two sides, bottom) are
|
||||
visible in the frame.
|
||||
|
||||
steps:
|
||||
- wait_for: "window.voxel_game !== undefined && window.voxel_game.get_scene_time !== undefined"
|
||||
|
||||
# Freeze time so set_scene_time isn't immediately overwritten.
|
||||
- eval: "window.voxel_game.set_time_scale(0.0)"
|
||||
|
||||
# Move to a fixed vantage point and look out toward the horizon.
|
||||
- eval: "window.voxel_game.teleport(0, 50, 0)"
|
||||
- eval: "window.voxel_game.look_at(0.0, -0.15)"
|
||||
|
||||
# Wait a frame for the camera + body to settle.
|
||||
- wait: 200
|
||||
|
||||
# ---- Sweep through the day/night cycle. DAY_PERIOD = 300s, so:
|
||||
# t = 0 → noon (sun straight up)
|
||||
# t = 75 → sunset (sun at the horizon, west)
|
||||
# t = 150 → midnight (sun straight down)
|
||||
# t = 225 → sunrise
|
||||
|
||||
- eval: "window.voxel_game.set_scene_time(0.0)"
|
||||
- wait: 300
|
||||
- screenshot: 01-noon.png
|
||||
|
||||
- eval: "window.voxel_game.set_scene_time(45.0)"
|
||||
- wait: 300
|
||||
- screenshot: 02-afternoon.png
|
||||
|
||||
- eval: "window.voxel_game.set_scene_time(75.0)"
|
||||
- wait: 300
|
||||
- screenshot: 03-sunset.png
|
||||
|
||||
- eval: "window.voxel_game.set_scene_time(90.0)"
|
||||
- wait: 300
|
||||
- screenshot: 04-civil-twilight.png
|
||||
|
||||
- eval: "window.voxel_game.set_scene_time(150.0)"
|
||||
- wait: 300
|
||||
- screenshot: 05-midnight.png
|
||||
|
||||
- eval: "window.voxel_game.set_scene_time(225.0)"
|
||||
- wait: 300
|
||||
- screenshot: 06-sunrise.png
|
||||
|
||||
# Telemetry sanity: after all that, time should match what we set.
|
||||
- assert: "Math.abs(window.voxel_game.get_scene_time() - 225.0) < 2.0"
|
||||
32
test/scenarios/voxel-construction-darkness.yaml
Normal file
32
test/scenarios/voxel-construction-darkness.yaml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
name: voxel-construction-darkness
|
||||
description: |
|
||||
Build a sealed shelter around the player at the surface, then
|
||||
compare the lighting inside vs. an open-air view. The sky-vis
|
||||
bake (sim::lighting::compute_ambience) should produce darker
|
||||
interior even though it's noon — depends only on the surrounding
|
||||
voxel construction.
|
||||
|
||||
steps:
|
||||
- wait_for: "window.voxel_game !== undefined && window.voxel_game.get_scene_time !== undefined"
|
||||
- eval: "window.voxel_game.set_time_scale(0.0)"
|
||||
- eval: "window.voxel_game.set_scene_time(0.0)"
|
||||
|
||||
# Establish the open-air baseline at the surface.
|
||||
- eval: "window.voxel_game.teleport(0, 35, 0)"
|
||||
- eval: "window.voxel_game.look_at(0.0, 0.0)"
|
||||
- wait: 300
|
||||
- screenshot: 01-open-air-baseline.png
|
||||
|
||||
# Look straight up — should see bright blue sky (sky_vis = 1, full
|
||||
# daylight) bordered by nearby block sides at lower sky_vis.
|
||||
- eval: "window.voxel_game.look_at(0.0, 1.4)"
|
||||
- wait: 300
|
||||
- screenshot: 02-looking-up.png
|
||||
|
||||
# Now point at a side face of nearby terrain to see how block
|
||||
# sides read at noon (the historical "dark sides" bug).
|
||||
- eval: "window.voxel_game.look_at(0.0, -0.1)"
|
||||
- wait: 300
|
||||
- screenshot: 03-side-faces-at-noon.png
|
||||
|
||||
- assert: "Math.abs(window.voxel_game.get_scene_time() - 0.0) < 2.0"
|
||||
Loading…
Add table
Reference in a new issue