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:
Maximus Gorog 2026-05-24 17:41:05 -06:00
parent a01d5c1fa9
commit 3ed16c2aaf
6 changed files with 277 additions and 0 deletions

View file

@ -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
View 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())

View 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

View 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

View 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

View 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);"