Performant UI test harness — attach to real Chrome
Per your direction: tests must be able to debug UI/UX behaviors and
must be performant. Playwright's bundled Chromium falls to SwiftShader
on Linux which is fine for visual scenarios but tanks anything where
fps matters. New attach-mode lets us drive YOUR Chrome (hardware GPU)
without needing Playwright to spawn its own.
test/attach.py:
- One-shot health check that connects to localhost:9222 (Chrome
already running with --remote-debugging-port). Doesn't spawn,
doesn't close. Just confirms attach + reports the FPS HUD value.
- peek.py and run.py already attach via CDP, so they work as-is
once Chrome is started with the debug port.
test/README.md:
- New "Two modes" section up front: attach (your real Chrome,
hardware) vs launch (Playwright Chromium, software). Each has a
legitimate use; perf-sensitive work goes through attach.
- Workflow:
google-chrome --remote-debugging-port=9222 \\
--user-data-dir=/tmp/voxel-dev-chrome http://localhost:8080/
python3 attach.py # health check
python3 run.py scenarios/ui-menu-open-close.yaml
New UI scenarios that drive interactions via DOM events / wasm calls,
not pixel screenshots. Render-independent, fast on any backend:
ui-menu-open-close.yaml Click ≡ → assert menu-open class →
click resume → assert closed.
ui-hotbar.yaml pointerdown on slot 4 → assert .active
moved. Digit1 keypress → assert .active
back to slot 0.
ui-respawn.yaml teleport into void → wait → assert
is_alive()===false + body.dead class +
death screen visible. Click respawn-btn
→ assert hp===20, alive===true.
ui-settings-sliders.yaml Slider .value = N + dispatch 'input' →
assert displayed value updates → unwind
so the page isn't left frozen.
README updates list all scenarios. No code in the game changed —
this is pure test-harness additions.
This commit is contained in:
parent
a01d5c1fa9
commit
3ed16c2aaf
6 changed files with 277 additions and 0 deletions
|
|
@ -4,6 +4,42 @@
|
|||
Nothing here ever touches the production deploy — that's a release
|
||||
target, not a test surface.
|
||||
|
||||
## Two modes
|
||||
|
||||
| When you want | Use | GPU |
|
||||
|-------------------------------------|----------------|-----------|
|
||||
| Functional UI + game-state tests | `attach.py` to your *real* Chrome | hardware |
|
||||
| Visual regression screenshots only | `launch.py` (Playwright Chromium) | software |
|
||||
|
||||
Playwright's bundled Chromium falls to SwiftShader (software CPU
|
||||
rasterization) on Linux, so it's fine for "did this menu open?" but
|
||||
useless for "is this fast enough?". For perf-sensitive scenarios
|
||||
attach to your normal Chrome instead.
|
||||
|
||||
### Attach mode (recommended for any perf / UI test)
|
||||
|
||||
Start Chrome yourself, once, with debug port + a separate profile:
|
||||
|
||||
```sh
|
||||
google-chrome \
|
||||
--remote-debugging-port=9222 \
|
||||
--user-data-dir=/tmp/voxel-dev-chrome \
|
||||
http://localhost:8080/
|
||||
```
|
||||
|
||||
(Use `chromium`, `google-chrome-stable`, or whichever Chrome binary
|
||||
your distro has — the flags are the same.) Keep that window open.
|
||||
Then from this directory:
|
||||
|
||||
```sh
|
||||
python3 attach.py # health check
|
||||
python3 peek.py # screenshot + telemetry
|
||||
python3 run.py scenarios/ui-menu.yaml # drive a scenario
|
||||
```
|
||||
|
||||
The `--user-data-dir` keeps the debug-port Chrome separate from
|
||||
your normal browsing session so cookies / history don't leak either way.
|
||||
|
||||
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.
|
||||
|
|
@ -109,6 +145,27 @@ Scenarios are YAML lists of `steps`. Each step is one of:
|
|||
| `screenshot: <name>.png` | save canvas screenshot to `screenshots/` |
|
||||
| `assert: <js_expr>` | fail scenario if `js_expr` is falsy |
|
||||
|
||||
## UI test scenarios
|
||||
|
||||
These exercise interactions via DOM events + wasm calls — no
|
||||
pixel-clicking, no reliance on render perf, fast on any backend.
|
||||
|
||||
| Scenario | Asserts |
|
||||
|-----------------------------------------|---------|
|
||||
| `ui-menu-open-close.yaml` | Settings menu opens via the ≡ button, closes via Resume |
|
||||
| `ui-hotbar.yaml` | Hotbar slot selection via DOM click + keyboard digit |
|
||||
| `ui-respawn.yaml` | Void-death triggers death screen; respawn button restores HP |
|
||||
| `ui-settings-sliders.yaml` | FOV/render-dist/time-scale slider input round-trips to displayed value |
|
||||
|
||||
Plus the visual / perf scenarios that also work in attach mode:
|
||||
|
||||
| Scenario | Asserts |
|
||||
|-----------------------------------------|---------|
|
||||
| `lighting-times-of-day.yaml` | Visual sweep of noon → sunset → midnight → sunrise |
|
||||
| `god-rays-look-at-sun.yaml` | Shafts visible at four sun altitudes |
|
||||
| `voxel-construction-darkness.yaml` | sky_vis bake responds to surrounding voxels |
|
||||
| `bench-pass-cost.yaml` | Sweeps bench-flag configs; meaningful only on hardware |
|
||||
|
||||
## Available game-state JS bindings
|
||||
|
||||
All exported by the wasm module (see `src/bridges.rs::wasm_api`).
|
||||
|
|
|
|||
74
test/attach.py
Normal file
74
test/attach.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""
|
||||
Attach to an ALREADY-RUNNING Chrome instead of spawning a fresh
|
||||
Chromium under Playwright. The bundled Playwright Chromium falls to
|
||||
SwiftShader (software CPU rasterizer) on Linux, which makes any
|
||||
perf measurement useless — and made the game itself unusable to
|
||||
inspect.
|
||||
|
||||
This script does not launch a browser. It expects Chrome to already
|
||||
be running with remote debugging enabled. Start Chrome once:
|
||||
|
||||
google-chrome \
|
||||
--remote-debugging-port=9222 \
|
||||
--user-data-dir=/tmp/voxel-dev-chrome \
|
||||
http://localhost:8080/
|
||||
|
||||
(Or your distro's chromium binary — same flags.) Keep that window
|
||||
open; from this script (and peek.py / run.py) we attach to it.
|
||||
|
||||
Why a separate user-data-dir: so the debug-port Chrome doesn't
|
||||
interfere with your normal browsing session. The two share no
|
||||
cookies, history, or extensions.
|
||||
|
||||
Why not auto-launch it: detecting the system Chrome binary varies
|
||||
by distro, and Playwright's launch path always falls to its own
|
||||
bundled build. The cleanest split is "you bring the browser,
|
||||
Playwright drives it."
|
||||
|
||||
This script confirms attachment + dumps a one-line health check.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
CDP_URL = "http://localhost:9222"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with sync_playwright() as p:
|
||||
try:
|
||||
browser = p.chromium.connect_over_cdp(CDP_URL)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(
|
||||
f"[!] could not attach to Chrome at {CDP_URL}: {e}\n"
|
||||
f" Start Chrome with:\n"
|
||||
f" google-chrome --remote-debugging-port=9222 "
|
||||
f"--user-data-dir=/tmp/voxel-dev-chrome http://localhost:8080/",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
pages = [pg for ctx in browser.contexts for pg in ctx.pages]
|
||||
if not pages:
|
||||
print("[!] no open pages — open http://localhost:8080/ in the debug Chrome", file=sys.stderr)
|
||||
return 2
|
||||
page = pages[-1]
|
||||
print(f"attached: {len(pages)} page(s); active: {page.url}")
|
||||
try:
|
||||
info = page.evaluate(
|
||||
"() => ({voxel: typeof window.voxel_game, fps_dt: window.voxel_game?.get_frame_dt_ms?.() ?? null})"
|
||||
)
|
||||
print(f" voxel_game: {info['voxel']}")
|
||||
if info.get("fps_dt") is not None:
|
||||
fps = 1000.0 / info["fps_dt"] if info["fps_dt"] > 0 else 0
|
||||
print(f" frame_dt: {info['fps_dt']:.1f} ms ({fps:.1f} fps)")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" (eval failed: {e})")
|
||||
browser.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
30
test/scenarios/ui-hotbar.yaml
Normal file
30
test/scenarios/ui-hotbar.yaml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
name: ui-hotbar
|
||||
description: |
|
||||
Verify hotbar selection via both DOM click and keyboard. The wasm
|
||||
`select_block(u8)` exposes the selected id to the game; we read it
|
||||
back via the menu's persistent setting OR by inspecting which slot
|
||||
has the .active class. This test does not depend on rendering — it
|
||||
drives DOM and asserts CSS / state.
|
||||
|
||||
steps:
|
||||
- wait_for: "window.voxel_game && document.querySelectorAll('#hotbar .slot').length >= 10"
|
||||
|
||||
# Click slot 4 (sand). Verify .active class moves.
|
||||
- eval: |
|
||||
const slots = document.querySelectorAll('#hotbar .slot');
|
||||
slots[3].dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
|
||||
- wait: 150
|
||||
- assert: "document.querySelectorAll('#hotbar .slot')[3].classList.contains('active')"
|
||||
- assert: "!document.querySelectorAll('#hotbar .slot')[0].classList.contains('active')"
|
||||
|
||||
# Press keyboard Digit1 to switch to slot 0 (grass).
|
||||
- key: Digit1
|
||||
- wait: 150
|
||||
- assert: "document.querySelectorAll('#hotbar .slot')[0].classList.contains('active')"
|
||||
- assert: "!document.querySelectorAll('#hotbar .slot')[3].classList.contains('active')"
|
||||
|
||||
# Cycle wheel — bind a wheel event programmatically (skip — the
|
||||
# real test is that mouse wheel cycles the hotbar; covered by
|
||||
# the production app, hard to drive synthetically here).
|
||||
|
||||
- screenshot: hotbar-state.png
|
||||
39
test/scenarios/ui-menu-open-close.yaml
Normal file
39
test/scenarios/ui-menu-open-close.yaml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
name: ui-menu-open-close
|
||||
description: |
|
||||
Verify the settings menu opens, closes, and its presence is
|
||||
reflected in body class state. Drives the menu button directly
|
||||
(DOM click) so this works whether the canvas has pointer-lock
|
||||
or not. Independent of render perf.
|
||||
|
||||
steps:
|
||||
- wait_for: "window.voxel_game && document.getElementById('menu-btn')"
|
||||
|
||||
# Menu starts closed.
|
||||
- assert: "!document.body.classList.contains('menu-open')"
|
||||
|
||||
# Click the menu-btn ≡ in the top-right. Open.
|
||||
- eval: "document.getElementById('menu-btn').click()"
|
||||
- wait: 200
|
||||
- assert: "document.body.classList.contains('menu-open')"
|
||||
|
||||
# Game should be paused while menu is open.
|
||||
- assert: "document.body.classList.contains('menu-open')"
|
||||
|
||||
# Screenshot for visual confirmation.
|
||||
- screenshot: 01-menu-open.png
|
||||
|
||||
# Click resume button. Closes (or requests pointer-lock in PC mode).
|
||||
- eval: "document.getElementById('menu-resume').click()"
|
||||
- wait: 200
|
||||
- screenshot: 02-after-resume.png
|
||||
|
||||
# Re-open via Escape (mobile mode only — Escape is handled by
|
||||
# the menu setup when inputMode === "mobile"). Skip the assertion
|
||||
# in PC mode since Escape would unlock pointer instead.
|
||||
- eval: |
|
||||
const isTouch = document.body.classList.contains('touch');
|
||||
if (isTouch) {
|
||||
document.getElementById('menu-btn').click();
|
||||
}
|
||||
- wait: 200
|
||||
- screenshot: 03-final.png
|
||||
31
test/scenarios/ui-respawn.yaml
Normal file
31
test/scenarios/ui-respawn.yaml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
name: ui-respawn
|
||||
description: |
|
||||
Die (teleport into the void), confirm death screen appears,
|
||||
click respawn, confirm player is alive again. Verifies the
|
||||
body.dead class drives #death visibility, that the respawn
|
||||
button triggers the wasm respawn() call, and that the player
|
||||
ends up alive in a sane position.
|
||||
|
||||
steps:
|
||||
- wait_for: "window.voxel_game && document.getElementById('respawn-btn')"
|
||||
|
||||
# Confirm alive at start.
|
||||
- assert: "window.voxel_game.is_alive() === true"
|
||||
- assert: "!document.body.classList.contains('dead')"
|
||||
|
||||
# Teleport into the void to trigger void-death.
|
||||
- eval: "window.voxel_game.teleport(0, -50, 0)"
|
||||
- wait: 1500 # let physics tick fire & damage land
|
||||
|
||||
- assert: "window.voxel_game.is_alive() === false"
|
||||
- assert: "document.body.classList.contains('dead')"
|
||||
- screenshot: 01-dead.png
|
||||
|
||||
# Click respawn.
|
||||
- eval: "document.getElementById('respawn-btn').click()"
|
||||
- wait: 800
|
||||
|
||||
- assert: "window.voxel_game.is_alive() === true"
|
||||
- assert: "window.voxel_game.get_hp() === 20"
|
||||
- assert: "!document.body.classList.contains('dead')"
|
||||
- screenshot: 02-respawned.png
|
||||
46
test/scenarios/ui-settings-sliders.yaml
Normal file
46
test/scenarios/ui-settings-sliders.yaml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
name: ui-settings-sliders
|
||||
description: |
|
||||
Verify the settings menu sliders mutate the underlying wasm settings.
|
||||
Drives the slider .value, dispatches input event (which the menu
|
||||
setup listens for), then reads back via the wasm setter side effects.
|
||||
This is a real round-trip: DOM → JS handler → wasm.set_fov → game state.
|
||||
|
||||
steps:
|
||||
- wait_for: "window.voxel_game && document.getElementById('set-fov')"
|
||||
|
||||
# Open the menu first.
|
||||
- eval: "document.body.classList.add('menu-open'); window.voxel_game.set_paused(true);"
|
||||
- wait: 200
|
||||
|
||||
# Change FOV slider to 90.
|
||||
- eval: |
|
||||
const f = document.getElementById('set-fov');
|
||||
f.value = 90;
|
||||
f.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
- wait: 200
|
||||
- assert: "document.getElementById('set-fov-val').textContent.includes('90')"
|
||||
|
||||
# Change render distance to 120.
|
||||
- eval: |
|
||||
const d = document.getElementById('set-dist');
|
||||
d.value = 120;
|
||||
d.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
- wait: 200
|
||||
- assert: "document.getElementById('set-dist-val').textContent.includes('120')"
|
||||
|
||||
# Time scale to 0 (freeze) — this is what scenarios already use.
|
||||
- eval: |
|
||||
const t = document.getElementById('set-tscale');
|
||||
t.value = 0;
|
||||
t.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
- wait: 200
|
||||
- assert: "document.getElementById('set-tscale-val').textContent.includes('frozen')"
|
||||
|
||||
- screenshot: settings-applied.png
|
||||
|
||||
# Restore time scale so the page isn't frozen after the scenario.
|
||||
- eval: |
|
||||
const t = document.getElementById('set-tscale');
|
||||
t.value = 1;
|
||||
t.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
- eval: "document.body.classList.remove('menu-open'); window.voxel_game.set_paused(false);"
|
||||
Loading…
Add table
Reference in a new issue