"""End-to-end checks of the web viewer's octree LOD/picking and delta recolour. Drives the real stack — ``toaster-web`` served from THIS checkout, headless Chromium via Playwright — against a synthetic cloud big enough to build the octree and trip the motion LOD (> 1M points). Skips cleanly when Playwright or its browser is unavailable. Invariants covered: - the worker-built octree is a permutation of the cloud with well-formed, disjoint node slices; - camera motion renders the octree cut (a strict subset), idle refines back to the full cloud, and the rAF loop parks afterwards (the QtWebEngine leak guard); - octree pick/pickBox return exactly what the brute-force scans return; - after delta-applied label edits (assign, undo), the client's colour/alpha buffers are indistinguishable from a from-scratch full-state re-render. """ from __future__ import annotations import os import socket import subprocess import sys import time from pathlib import Path import numpy as np import pytest pytest.importorskip("playwright.sync_api") from playwright.sync_api import sync_playwright # noqa: E402 REPO = Path(__file__).resolve().parent.parent N_POINTS = 1_200_000 # > LOD_BUDGET (1M) so the motion cut engages def _free_port() -> int: with socket.socket() as s: s.bind(("127.0.0.1", 0)) return s.getsockname()[1] @pytest.fixture(scope="module") def server(tmp_path_factory): """toaster-web on a random port, serving a 1.2M-point synthetic terrain.""" cloud = tmp_path_factory.mktemp("cloud") / "big.bin" rng = np.random.default_rng(7) xy = rng.uniform(-50, 50, (N_POINTS, 2)).astype(np.float32) z = (np.sin(xy[:, 0] * 0.2) * np.cos(xy[:, 1] * 0.15) + rng.normal(0, 0.1, N_POINTS)).astype( np.float32 ) i = rng.uniform(0, 1, N_POINTS).astype(np.float32) np.column_stack([xy, z, i]).astype(np.float32).tofile(cloud) port = _free_port() env = os.environ | {"PYTHONPATH": str(REPO)} # serve THIS checkout, not the installed copy proc = subprocess.Popen( [sys.executable, "-m", "toaster.api.server", str(cloud), "--port", str(port)], env=env, cwd=REPO, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, ) import urllib.request deadline = time.time() + 30 while True: try: urllib.request.urlopen(f"http://127.0.0.1:{port}/api/meta", timeout=1) break except Exception: if proc.poll() is not None: pytest.skip(f"server died: {proc.stderr.read().decode()[-500:]}") if time.time() > deadline: proc.kill() pytest.skip("server did not come up in 30s") time.sleep(0.3) yield f"http://127.0.0.1:{port}" proc.terminate() proc.wait(timeout=10) @pytest.fixture(scope="module") def browser(): with sync_playwright() as p: try: browser = p.chromium.launch() except Exception as exc: # no browser installed pytest.skip(f"chromium unavailable: {exc}") yield browser browser.close() @pytest.fixture(scope="module") def page(server, browser): """One loaded page (cloud + octree ready) shared by the module's tests.""" page = browser.new_page(viewport={"width": 1280, "height": 800}) errors: list[str] = [] page.on("pageerror", lambda e: errors.append(f"pageerror: {e}")) # Headless SwiftShader emits a spurious "VALIDATE_STATUS false" with an # EMPTY info log under multi-context pressure; a real shader break always # carries a compile log, so only the empty-log noise is filtered. page.on( "console", lambda m: errors.append(f"console: {m.text}") if m.type == "error" and not ("VALIDATE_STATUS false" in m.text and "ERROR:" not in m.text) else None, ) page.goto(server) page.wait_for_function( f"window.__toaster && window.__toaster.viewer.geom && " f"window.__toaster.viewer.geom.getAttribute('position').count === {N_POINTS}", timeout=90000, ) page.wait_for_function("window.__toaster.viewer._octree !== null", timeout=90000) yield page assert not errors, f"console/page errors: {errors}" page.close() def test_octree_is_a_permutation_with_wellformed_slices(page): ok = page.evaluate( f"""(() => {{ const o = window.__toaster.viewer._octree.data; if (o.order.length !== {N_POINTS}) return "bad order length"; const seen = new Uint8Array({N_POINTS}); for (let k = 0; k < o.order.length; k++) {{ if (seen[o.order[k]]) return "duplicate index"; seen[o.order[k]] = 1; }} for (let i = 0; i < seen.length; i++) if (!seen[i]) return "missing index"; for (let ni = 0; ni < o.start.length; ni++) {{ if (o.start[ni] + o.count[ni] > o.order.length) return "slice out of bounds"; for (let c = 0; c < 8; c++) {{ const ch = o.children[ni * 8 + c]; if (ch >= 0 && o.start[ch] < o.start[ni] + o.count[ni]) return "child overlaps parent sample"; }} }} return "ok"; }})()""" ) assert ok == "ok" def test_motion_draws_octree_cut_then_idle_refines_full(page): # A held fly key keeps the view "in motion" for as long as we need to # observe it (a drag's 150 ms settle window is shorter than one headless # software-GL frame, so the refine would race the assertion). page.click("canvas", position={"x": 10, "y": 10}) page.keyboard.down("w") time.sleep(0.4) during = page.evaluate( """(() => { const v = window.__toaster.viewer; return { cut: v.geom.index === v._drawAttr, n: v.geom.drawRange.count }; })()""" ) page.keyboard.up("w") assert during["cut"], "motion frames must render the octree cut" assert 0 < during["n"] < N_POINTS, "the cut must be a strict subset" time.sleep(1.5) # settle + slow software-GL frames after = page.evaluate( "(() => { const v = window.__toaster.viewer;" " return v.geom.index === null && !v._lodOn; })()" ) assert after, "idle must refine back to the full cloud" # The rAF loop must park at idle (the QtWebEngine leak guard). for _ in range(4): assert page.evaluate("window.__toaster.viewer._rafPending") is not True time.sleep(0.4) def test_orbit_crosshair_shows_during_motion_and_fades(page): # The rerun-style pivot crosshair: hidden at rest, visible while orbiting, # lingers briefly after release, then fades back out. assert page.evaluate("window.__toaster.viewer.orbitIndicator.lines.visible") is False page.mouse.move(640, 400) page.mouse.down() for i in range(6): page.mouse.move(640 + i * 10, 400 + i * 5, steps=2) time.sleep(0.05) state = page.evaluate( """(() => { const v = window.__toaster.viewer; const pos = v.orbitIndicator.lines.geometry.attributes.position.array; const t = v.controls.target; return { visible: v.orbitIndicator.lines.visible, centred: Math.abs(pos[0] - t.x) < 1e-3 && Math.abs(pos[1] - t.y) < 1e-3, }; })()""" ) page.mouse.up() assert state["visible"], "crosshair must show while orbiting" assert state["centred"], "crosshair must sit exactly on the orbit pivot" time.sleep(1.0) # > linger (350ms) + fade (100ms), slow-frame margin assert page.evaluate("window.__toaster.viewer.orbitIndicator.lines.visible") is False def test_octree_picking_matches_brute_force(page): out = page.evaluate( """(() => { const v = window.__toaster.viewer; const pick1 = v.pick(640, 400); const box1 = v.pickBox(610, 370, 670, 430); const oct = v._octree; v._octree = null; // brute-force path const pick2 = v.pick(640, 400); const box2 = v.pickBox(610, 370, 670, 430); v._octree = oct; box1.sort((a, b) => a - b); box2.sort((a, b) => a - b); return { pickSame: pick1 === pick2, picked: pick1, boxSame: box1.length === box2.length && box1.every((x, i) => x === box2[i]), boxN: box1.length, }; })()""" ) assert out["pickSame"], "octree pick must match the exact scan" assert out["picked"] >= 0 assert out["boxSame"], "octree pickBox must match the exact scan" assert out["boxN"] > 0 def test_delta_recolor_matches_full_rerender(page): # Box-select a screen region, label it, and compare the delta-patched # colour/alpha buffers against a from-scratch full-state re-render — # byte-identical or the delta path has drifted from the reference rules. # Drive the real UI: box mode, rubber-band drag, double-click to label. page.click("button[data-mode-pick=box]") # idempotent — asserts the mode, not a toggle page.mouse.move(560, 320) page.mouse.down() page.mouse.move(720, 480, steps=5) page.mouse.up() page.wait_for_function( "window.__toaster.debug.getState().selection.length > 0", timeout=15000 ) page.mouse.dblclick(640, 440) # viewport coords, inside the drawn box — delta path page.wait_for_function( "window.__toaster.debug.getState().selection.length === 0", timeout=15000 ) def buffers_equal_after_full_refresh() -> dict: return page.evaluate( """(async () => { const { viewer, debug } = window.__toaster; const live = viewer.colorArrays(); const colors = live.colors.slice(); const alpha = live.alpha.slice(); await debug.refresh(); // from-scratch reference render const ref = viewer.colorArrays(); let colorDiff = 0, alphaDiff = 0; for (let i = 0; i < colors.length; i++) if (colors[i] !== ref.colors[i]) colorDiff++; for (let i = 0; i < alpha.length; i++) if (alpha[i] !== ref.alpha[i]) alphaDiff++; const labeled = debug.getState().labels.reduce((a, v) => a + (v !== 0 ? 1 : 0), 0); return { colorDiff, alphaDiff, labeled }; })()""" ) out = buffers_equal_after_full_refresh() assert out["labeled"] > 0, "the double-click must have labelled the box selection" assert out["colorDiff"] == 0, f"{out['colorDiff']} colour slots diverged from the reference" assert out["alphaDiff"] == 0, f"{out['alphaDiff']} alpha slots diverged from the reference" # Same equivalence with the visibility mask active (hide-labelled points): # the delta must now patch alphas too, and keep the hidden-count pill exact. page.check("#hide-labeled") page.mouse.move(400, 250) page.mouse.down() page.mouse.move(560, 400, steps=5) page.mouse.up() page.wait_for_function( "window.__toaster.debug.getState().selection.length > 0", timeout=15000 ) page.mouse.dblclick(480, 330) # viewport coords, inside the second box page.wait_for_function( "window.__toaster.debug.getState().selection.length === 0", timeout=15000 ) out = buffers_equal_after_full_refresh() assert out["colorDiff"] == 0, "colours diverged with the visibility mask on" assert out["alphaDiff"] == 0, "alphas diverged with the visibility mask on" # Undo (a delta of the same edit, reversed) must also match the reference. page.keyboard.press("Control+z") time.sleep(0.5) out = buffers_equal_after_full_refresh() assert out["colorDiff"] == 0, "colours diverged after an undo delta" assert out["alphaDiff"] == 0, "alphas diverged after an undo delta" page.uncheck("#hide-labeled") def test_delta_upload_matches_full_upload_on_screen(page): # The CPU-array equality above can't see a bad commitColors update range — # a wrong range leaves stale bytes GPU-side only. Render both ways and # compare pixels: delta-patched frame vs the same state fully re-uploaded. page.click("button[data-mode-pick=box]") page.mouse.move(700, 500) page.mouse.down() page.mouse.move(860, 620, steps=5) page.mouse.up() page.wait_for_function( "window.__toaster.debug.getState().selection.length > 0", timeout=15000 ) page.mouse.dblclick(780, 560) # delta path page.wait_for_function( "window.__toaster.debug.getState().selection.length === 0", timeout=15000 ) time.sleep(1.5) # settle: full-res idle frame with the delta-patched buffers # Clip to the cloud area: the toolbar's neon "buzz" animation (triggered by # the assign) is still pulsing in frame A, and side panels are irrelevant. clip = {"x": 300, "y": 150, "width": 700, "height": 550} a = np.frombuffer(page.screenshot(clip=clip), dtype=np.uint8) page.evaluate("window.__toaster.debug.refresh()") time.sleep(1.5) # full recompute + full GPU upload, then idle again b = np.frombuffer(page.screenshot(clip=clip), dtype=np.uint8) if a.shape == b.shape and (a == b).all(): return # byte-identical PNGs — perfect # Compare decoded pixels with a tiny tolerance for encoder nondeterminism. import io from PIL import Image pa = np.asarray(Image.open(io.BytesIO(a.tobytes())).convert("RGB"), dtype=np.int16) pb = np.asarray(Image.open(io.BytesIO(b.tobytes())).convert("RGB"), dtype=np.int16) diff_ratio = float((np.abs(pa - pb).max(axis=2) > 2).mean()) assert diff_ratio < 0.001, f"{diff_ratio:.4%} of pixels differ between delta and full upload" def test_isolate_mode_falls_back_to_full_recompute(page): # Isolate couples every point's alpha to the selection — assigning under # isolate must go through the full recompute and still match the reference. page.click("button[data-mode-pick=box]") page.mouse.move(300, 500) page.mouse.down() page.mouse.move(440, 620, steps=5) page.mouse.up() page.wait_for_function( "window.__toaster.debug.getState().selection.length > 0", timeout=15000 ) page.keyboard.press("i") # isolate the selection page.mouse.dblclick(370, 560) # label it (assign clears the selection → isolate disarms) page.wait_for_function( "window.__toaster.debug.getState().selection.length === 0", timeout=15000 ) out = page.evaluate( """(async () => { const { viewer, debug } = window.__toaster; const live = viewer.colorArrays(); const colors = live.colors.slice(); const alpha = live.alpha.slice(); await debug.refresh(); const ref = viewer.colorArrays(); let colorDiff = 0, alphaDiff = 0; for (let i = 0; i < colors.length; i++) if (colors[i] !== ref.colors[i]) colorDiff++; for (let i = 0; i < alpha.length; i++) if (alpha[i] !== ref.alpha[i]) alphaDiff++; return { colorDiff, alphaDiff }; })()""" ) assert out["colorDiff"] == 0, "colours diverged after assigning under isolate" assert out["alphaDiff"] == 0, "alphas diverged after assigning under isolate" def test_page_idle_pauses_decorative_css_animations(page): # Regression: two `infinite` CSS animations (the bottom-edge "ember" glow, # the active theme's logo) ran unconditionally — independent of the 3D # canvas's own render-on-demand loop, they alone kept QtWebEngine's Vulkan # fallback compositing forever and OOM'd a session left open. app.js's # watchPageIdle() adds body.idle after PAGE_IDLE_MS (8s) of no input # anywhere on the page; style.css pauses those animations off it. page.mouse.move(50, 50) # establish an "active" baseline idle_before = page.evaluate("document.body.classList.contains('idle')") assert not idle_before, "must not be idle right after activity" time.sleep(8.5) # > PAGE_IDLE_MS with zero synthesized input in between idle_after = page.evaluate("document.body.classList.contains('idle')") assert idle_after, "must go idle after a stretch with no input anywhere" paused = page.evaluate( """(() => { const after = getComputedStyle(document.body, '::after').animationPlayState; // Target the ACTIVE theme's mark specifically — the other two themes' // logo-art blocks sit at display:none and never animate regardless. const marks = { toaster: '.toast', cafe: '.steam', arcade: '.invader' }; const cls = marks[document.body.dataset.theme]; const logo = document.querySelector(`.logo-art ${cls}`); const logoState = logo ? getComputedStyle(logo).animationPlayState : 'n/a'; return { emberState: after, logoState }; })()""" ) assert paused["emberState"] == "paused", f"ember glow must pause when idle: {paused}" assert paused["logoState"] == "paused", f"active theme's logo must pause when idle: {paused}" # Any input wakes it back up immediately — no lingering "still idle" frame. page.mouse.move(60, 60) idle_woken = page.evaluate("document.body.classList.contains('idle')") assert not idle_woken, "must wake up on the very next interaction" def test_camera_control_style_defaults_to_trackball_and_persists(page): # Regression: toaster briefly defaulted to Orbit-style controls (matching # most 3D viewers), but OrbitControls walls off at the poles — "blocked # at a plane" — which is exactly why this codebase used TrackballControls # exclusively in the first place (free tumble in any direction, needed to # turn a scan that isn't gravity-aligned upright). Reverted: trackball is # the default again; Orbit stays available as an opt-in toggle. default_style = page.evaluate("window.__toaster.viewer._controlStyle") assert default_style == "trackball", "toaster must default to free-tumble trackball" assert page.evaluate("document.getElementById('controls-style').value") == "trackball" canvas = page.query_selector("canvas") box = canvas.bounding_box() cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2 # Trackball: a drag must still move the camera (basic sanity — its whole # point is that it does NOT constrain the horizon the way Orbit does, so # unlike the Orbit case there is no "up never changes" assertion here). # Retried: a synthetic drag landing right after another test's real 8.5s # sleep (the idle-CSS test just before this one) occasionally gets # coalesced away by a loaded headless Chromium — a pointer-event timing # artifact, not a product behaviour to pin down with more raw sleeps. pos_before_drag = page.evaluate("window.__toaster.viewer.camera.position.toArray()") pos_after_drag = pos_before_drag for attempt in range(3): page.mouse.move(cx, cy) page.mouse.down() page.mouse.move(cx + 150 + attempt, cy + 20, steps=8) page.mouse.up() time.sleep(0.3) pos_after_drag = page.evaluate("window.__toaster.viewer.camera.position.toArray()") if pos_after_drag != pos_before_drag: break assert pos_after_drag != pos_before_drag, "trackball drag must move the camera" # Switching to Orbit (for a gravity-aligned scan where a level horizon is # wanted) persists and actually swaps the underlying controls; a drag # then never rolls the horizon — the defining Orbit behaviour. page.evaluate("document.getElementById('win-display').style.display = 'flex'") page.select_option("#controls-style", "orbit") assert page.evaluate("window.__toaster.viewer._controlStyle") == "orbit" assert page.evaluate("localStorage.getItem('toaster-controls')") == "orbit" up_before = page.evaluate("window.__toaster.viewer.camera.up.toArray()") page.mouse.move(cx, cy) page.mouse.down() page.mouse.move(cx - 100, cy + 60, steps=8) page.mouse.up() time.sleep(0.3) up_after = page.evaluate("window.__toaster.viewer.camera.up.toArray()") assert up_after == up_before, "an orbit drag must never roll the horizon" # Reset for any later test on this shared page. page.select_option("#controls-style", "trackball") page.evaluate("document.getElementById('win-display').style.display = 'none'") assert page.evaluate("window.__toaster.viewer._controlStyle") == "trackball" def test_windows_minimize_resize_and_persist(page): # Every floating window (Segmenter, Segments, etc.) gets a minimize # toggle and a resize grip, added uniformly by setupWindows() — no # per-window HTML wiring. Segmenter is the case that motivated this: # its params list (or the Segments window's segment list) can run long. counts = page.evaluate( """(() => ({ windows: document.querySelectorAll('.window').length, minBtns: document.querySelectorAll('.window .titlebar .min').length, grips: document.querySelectorAll('.window .resize-handle').length, }))()""" ) assert counts["windows"] > 0 assert counts["minBtns"] == counts["windows"] assert counts["grips"] == counts["windows"] seg = page.query_selector("#win-segmenter") min_btn = seg.query_selector(".titlebar .min") # Minimize collapses the body and persists; restoring un-collapses it. assert seg.query_selector(".body").is_visible() min_btn.click() time.sleep(0.1) assert page.evaluate("document.getElementById('win-segmenter').classList.contains('minimized')") assert not seg.query_selector(".body").is_visible() assert page.evaluate( "JSON.parse(localStorage.getItem('toaster-windows'))['win-segmenter'].minimized" ) is True min_btn.click() time.sleep(0.1) assert not page.evaluate( "document.getElementById('win-segmenter').classList.contains('minimized')" ) # Dragging the corner grip resizes both dimensions, and the body actually # fills the extra height (base .body is flex:1 now, not just the old # .grow modifier) instead of leaving dead space below static content. box_before = seg.bounding_box() grip = seg.query_selector(".resize-handle") gbox = grip.bounding_box() gx, gy = gbox["x"] + gbox["width"] / 2, gbox["y"] + gbox["height"] / 2 page.mouse.move(gx, gy) page.mouse.down() page.mouse.move(gx + 120, gy + 200, steps=10) page.mouse.up() time.sleep(0.2) box_after = seg.bounding_box() assert box_after["width"] > box_before["width"] + 50 assert box_after["height"] > box_before["height"] + 50 body_height = page.evaluate( "document.querySelector('#win-segmenter .body').getBoundingClientRect().height" ) assert body_height > 100, "a taller window must give its body the extra space" # Shrinking past the floor clamps to WIN_MIN_WIDTH/HEIGHT, not zero. gbox2 = grip.bounding_box() gx2, gy2 = gbox2["x"] + gbox2["width"] / 2, gbox2["y"] + gbox2["height"] / 2 page.mouse.move(gx2, gy2) page.mouse.down() page.mouse.move(gx2 - 2000, gy2 - 2000, steps=10) page.mouse.up() time.sleep(0.2) tiny = seg.bounding_box() assert tiny["width"] >= 150 and tiny["height"] >= 80 # The titlebar drag (raise/move) still works after adding the min/resize # controls — they must not have hijacked its pointer events. bar = seg.query_selector(".titlebar") bbox = bar.bounding_box() bx, by = bbox["x"] + 40, bbox["y"] + bbox["height"] / 2 x_before = seg.bounding_box()["x"] page.mouse.move(bx, by) page.mouse.down() page.mouse.move(bx + 60, by + 40, steps=8) page.mouse.up() time.sleep(0.2) assert seg.bounding_box()["x"] != x_before # Geometry survives a reload (localStorage-backed, restored in setupWindows()). page.reload() page.wait_for_function("window.__toaster.viewer._octree !== null", timeout=90000) restored = page.evaluate( "(() => { const w = document.getElementById('win-segmenter');" " return { width: w.style.width, height: w.style.height }; })()" ) assert restored["width"] and restored["height"] def test_engine_options_then_dispose(server, browser): # dispose() is for hosts that embed the engine and tear viewers down — # exercise it on its own page so the shared page fixture stays alive. page = browser.new_page(viewport={"width": 800, "height": 600}) errors: list[str] = [] page.on("pageerror", lambda e: errors.append(str(e))) page.goto(server) page.wait_for_function( "window.__toaster && window.__toaster.viewer._octree !== null", timeout=90000 ) # Exercise the embedding-oriented options on this throwaway page first: # orbit control style, world-size attenuation, streaming setCloud. opts = page.evaluate( """(() => { const v = window.__toaster.viewer; v.setControlStyle("orbit"); const orbitOk = v._controlStyle === "orbit" && v.controls.enableDamping === false; v.setSizeAttenuation(true); const attenOk = v.material.uniforms.uAttenuate.value === 1 && v.material.uniforms.uProjScalePx.value > 0; v.setSizeAttenuation(false); v.setControlStyle("trackball"); const backOk = v._controlStyle === "trackball" && !!v.controls.staticMoving; // Streaming: a smaller frame, no octree churn, camera untouched. const camBefore = v.camera.position.toArray(); const xyz = new Float32Array(3000); for (let i = 0; i < 1000; i++) { xyz[i * 3] = Math.random() * 10; xyz[i * 3 + 1] = Math.random() * 10; xyz[i * 3 + 2] = Math.random(); } v.setCloud(xyz, { octree: false, frame: false }); const camAfter = v.camera.position.toArray(); const streamOk = v._octree === null && v.geom.getAttribute("position").count === 1000 && camBefore.every((x, i) => x === camAfter[i]); return { orbitOk, attenOk, backOk, streamOk }; })()""" ) assert opts["orbitOk"], "setControlStyle('orbit') must install OrbitControls without damping" assert opts["attenOk"], "setSizeAttenuation must arm the world-size shader path" assert opts["backOk"], "switching back to trackball must restore the free-tumble controls" assert opts["streamOk"], "streaming setCloud must skip the octree and leave the camera alone" # Camera-feel guards (rerun-style): fly speed follows the camera-to-pivot # distance, and the orbit radius can never collapse through the pivot. feel = page.evaluate( """(() => { const v = window.__toaster.viewer; const minDistOk = v.controls.minDistance > 0; // Fly one tick from far, then from close: the step must shrink. const target = v.controls.target.clone(); const step = (dist) => { v.camera.position.set(target.x + dist, target.y, target.z); v.controls.target.copy(target); const before = v.camera.position.clone(); v._flyKeys.add("KeyW"); v._fly(0.05); v._flyKeys.clear(); return v.camera.position.distanceTo(before); }; const farStep = step(100); const nearStep = step(0.5); return { minDistOk, farStep, nearStep }; })()""" ) assert feel["minDistOk"], "controls.minDistance must clamp the orbit radius" assert feel["nearStep"] < feel["farStep"] / 20, ( f"fly speed must scale with pivot distance (far {feel['farStep']}, near {feel['nearStep']})" ) gone = page.evaluate( """(() => { window.__toaster.viewer.dispose(); return document.querySelector('#viewport canvas') === null; })()""" ) assert gone, "dispose must remove the canvas" # Events that used to hit the viewer's window listeners must be harmless now. page.keyboard.down("w") page.keyboard.up("w") page.mouse.move(400, 300) time.sleep(0.5) assert not errors, f"errors after dispose: {errors}" page.close()