"""End-to-end: the keyboard is a complete control surface for mirror. Phase 01, tasks 01-04: one focus-aware keydown router plus a keyboard cursor over the tiles (j/k/Up/Down, Esc), the wall commands (Enter/f, g, n/p, 1-9, m), the ``?`` help overlay, the focus-visible affordances — no control is reachable *only* by hover — and the /mirror keys (s, x, Esc). Every command here is driven with ``page.keyboard.press()`` and asserted on the DOM state the key changes — ``data-selected``, focus, the ``staged`` class, the ``muted`` flag, computed styles — never on whether a handler "ran". The suite is pointer-free: a test that reaches for the pointer proves nothing about a kiosk. The meta-test at the bottom enforces that on this file itself, so the guarantee cannot rot. """ from __future__ import annotations import os import re import socket import subprocess import sys import time from pathlib import Path import pytest from playwright.sync_api import expect from .conftest import start_sharing pytestmark = [pytest.mark.e2e, pytest.mark.timeout(180)] @pytest.fixture def device(base_url, browser, request): """Open a page as its own device (isolated context, fresh peer id). Uncaught page errors and console errors are captured from the first line of the test and surfaced through the ``errors == []`` assertion (AGENTS.md §8: print pageerror/console from the first line of the test). """ opened = [] def _open(path: str = "/", errors: list | None = None): context = browser.new_context(viewport={"width": 1100, "height": 720}) opened.append(context) request.addfinalizer(context.close) page = context.new_page() if errors is not None: page.on("pageerror", lambda error: errors.append(f"pageerror: {error}")) page.on( "console", lambda message: ( errors.append(f"console: {message.text}") if message.type == "error" else None ), ) page.goto(f"{base_url}{path}", wait_until="domcontentloaded") expect(page.locator("#net")).to_have_attribute("data-state", "online", timeout=10_000) return page return _open def _non_loopback_ipv4() -> str | None: """The address a *neighbour* would use to reach this machine, or None. A UDP connect sends no packet; it only resolves the default route, which is exactly the interface a LAN device would see. """ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe: try: probe.connect(("8.8.8.8", 80)) except OSError: return None address = probe.getsockname()[0] return address if address != "127.0.0.1" else None @pytest.fixture def insecure_base_url(): """A `mirror serve` on a plain-HTTP non-localhost address. That is a non-secure context: the browser hides `navigator.mediaDevices` entirely — the exact trap /mirror documents for a bare `mirror serve` on a LAN IP (README, docs/kiosk-silverblue.md "One trap worth knowing"). """ host = _non_loopback_ipv4() if host is None: pytest.skip("no non-loopback interface: the non-secure-context trap is untestable here") import httpx from .conftest import _free_port port = _free_port() process = subprocess.Popen( [ sys.executable, "-m", "mirror", "serve", "--host", "0.0.0.0", "--port", str(port), "--log-level", "warning", ], cwd=Path(__file__).resolve().parents[2], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env={ **os.environ, "MIRROR_ICE_SERVERS": "", "PYTHONUNBUFFERED": "1", "DEBUGPY": "0", }, ) url = f"http://{host}:{port}" last_error: Exception | None = None deadline = time.time() + 25 while time.time() < deadline: if process.poll() is not None: output = process.stdout.read() if process.stdout else "" raise RuntimeError(f"mirror serve exited early ({process.returncode}):\n{output}") try: if httpx.get(f"{url}/healthz", timeout=1.0).status_code == 200: break except Exception as exc: # still booting last_error = exc time.sleep(0.15) else: process.terminate() raise RuntimeError(f"mirror serve never came up: {last_error}") yield url process.terminate() try: process.wait(timeout=5) except subprocess.TimeoutExpired: # pragma: no cover process.kill() def tile_ids(page) -> list[str]: return page.locator(".tile").evaluate_all("els => els.map(e => e.dataset.stream)") def selected_id(page) -> str | None: return page.evaluate( "() => { const el = document.querySelector('.tile[data-selected]');" " return el ? el.dataset.stream : null; }" ) def focus_follows_selection(page) -> bool: return page.evaluate( "() => { const el = document.querySelector('.tile[data-selected]');" " return el !== null && document.activeElement === el; }" ) class TestKeyboardCursor: def _two_mirror_wall(self, device, errors: list) -> tuple: for _ in range(2): start_sharing(device("/mirror", errors)) viewer = device("/watch", errors) expect(viewer.locator(".tile")).to_have_count(2, timeout=15_000) return viewer, tile_ids(viewer) def test_j_selects_the_first_tile_and_focus_follows(self, device): errors: list = [] viewer, ids = self._two_mirror_wall(device, errors) viewer.keyboard.press("j") assert selected_id(viewer) == ids[0] assert focus_follows_selection(viewer), "focus must follow the selection" assert errors == [], f"uncaught browser errors: {errors}" def test_j_and_k_wrap_at_the_ends(self, device): errors: list = [] viewer, ids = self._two_mirror_wall(device, errors) viewer.keyboard.press("j") assert selected_id(viewer) == ids[0] # k from the first wraps to the last. viewer.keyboard.press("k") assert selected_id(viewer) == ids[1] # j from the last wraps back to the first. viewer.keyboard.press("j") assert selected_id(viewer) == ids[0] assert errors == [], f"uncaught browser errors: {errors}" def test_arrow_keys_drive_the_same_cursor(self, device): errors: list = [] viewer, ids = self._two_mirror_wall(device, errors) viewer.keyboard.press("ArrowDown") assert selected_id(viewer) == ids[0] viewer.keyboard.press("ArrowDown") assert selected_id(viewer) == ids[1] viewer.keyboard.press("ArrowUp") assert selected_id(viewer) == ids[0] # The cursor must not fight real scrolling: the router suppresses the # arrow's default while it handles the key. viewer.keyboard.press("ArrowUp") assert selected_id(viewer) == ids[1] assert errors == [], f"uncaught browser errors: {errors}" def test_ctrl_chords_never_run_a_command(self, device): errors: list = [] viewer, ids = self._two_mirror_wall(device, errors) viewer.keyboard.press("j") assert selected_id(viewer) == ids[0] # Synthesised ctrlKey: true — the browser's chords are never ours. viewer.keyboard.press("Control+j") assert selected_id(viewer) == ids[0] viewer.keyboard.press("Control+k") assert selected_id(viewer) == ids[0] viewer.keyboard.press("Control+ArrowDown") assert selected_id(viewer) == ids[0] assert errors == [], f"uncaught browser errors: {errors}" def test_shifted_letters_drive_the_same_commands(self, device): errors: list = [] viewer, ids = self._two_mirror_wall(device, errors) # Keys are matched case-insensitively: a caps-locked kiosk keyboard, or # a remote hand holding Shift, must work exactly the same. viewer.keyboard.press("Shift+J") assert selected_id(viewer) == ids[0] # Shift+F stages through the same command as f. viewer.keyboard.press("Shift+F") expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000) assert viewer.locator("#watch-stage .tile").get_attribute("data-stream") == ids[0] assert errors == [], f"uncaught browser errors: {errors}" class TestEscape: def test_escape_leaves_the_stage(self, device): errors: list = [] start_sharing(device("/mirror", errors)) viewer = device("/watch", errors) expect(viewer.locator(".tile")).to_have_count(1, timeout=15_000) # Staged the way the kiosk stages — select, then Enter — because this # suite is pointer-free; Esc is what the kiosk uses to leave the stage. viewer.keyboard.press("j") viewer.keyboard.press("Enter") expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000) viewer.keyboard.press("Escape") expect(viewer.locator("body")).not_to_have_class(re.compile("staged"), timeout=5_000) # Leaving the stage must not have disturbed the stream. expect(viewer.locator(".tile[data-state='live']")).to_have_count(1, timeout=20_000) assert errors == [], f"uncaught browser errors: {errors}" def test_escape_without_a_stage_is_a_noop(self, device): errors: list = [] start_sharing(device("/mirror", errors)) viewer = device("/watch", errors) expect(viewer.locator(".tile")).to_have_count(1, timeout=15_000) viewer.keyboard.press("Escape") expect(viewer.locator("body")).not_to_have_class(re.compile("staged")) expect(viewer.locator(".tile")).to_have_count(1) expect(viewer.locator(".tile[data-state='live']")).to_have_count(1, timeout=20_000) assert errors == [], f"uncaught browser errors: {errors}" class TestWallCommands: """Task 02: Enter/f, g, n/p, 1-9, m on /watch. Every command is driven with ``page.keyboard.press()`` and asserted on the DOM state the key changes — the staged tile id, the ``staged`` class, the video's ``muted`` flag, the ``#toast`` — never on whether a handler "ran". """ def _two_mirror_wall(self, device, errors: list) -> tuple: for _ in range(2): start_sharing(device("/mirror", errors)) viewer = device("/watch", errors) expect(viewer.locator(".tile")).to_have_count(2, timeout=15_000) return viewer, tile_ids(viewer) def test_enter_stages_the_selected_mirror(self, device): errors: list = [] viewer, ids = self._two_mirror_wall(device, errors) viewer.keyboard.press("j") viewer.keyboard.press("Enter") expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000) expect(viewer.locator("#watch-stage .tile")).to_have_count(1) assert viewer.locator("#watch-stage .tile").get_attribute("data-stream") == ids[0] assert errors == [], f"uncaught browser errors: {errors}" def test_enter_with_nothing_selected_stages_the_first_tile(self, device): errors: list = [] viewer, ids = self._two_mirror_wall(device, errors) viewer.keyboard.press("Enter") expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000) expect(viewer.locator("#watch-stage .tile")).to_have_count(1) assert viewer.locator("#watch-stage .tile").get_attribute("data-stream") == ids[0] assert errors == [], f"uncaught browser errors: {errors}" def test_f_stages_when_unstaged_and_holds_the_stage_when_staged(self, device): errors: list = [] viewer, ids = self._two_mirror_wall(device, errors) # Unstaged: `f` stages like Enter (first tile, nothing selected). viewer.keyboard.press("f") expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000) assert viewer.locator("#watch-stage .tile").get_attribute("data-stream") == ids[0] # Staged: `f` re-requests fullscreen. A kiosk's window manager can drop # the window out of fullscreen without the page noticing, so the # command must never tear the stage down — headless or not. viewer.keyboard.press("f") expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000) expect(viewer.locator("#watch-stage .tile")).to_have_count(1) assert viewer.locator("#watch-stage .tile").get_attribute("data-stream") == ids[0] assert errors == [], f"uncaught browser errors: {errors}" def test_g_returns_to_the_wall(self, device): errors: list = [] viewer, _ = self._two_mirror_wall(device, errors) viewer.keyboard.press("j") viewer.keyboard.press("Enter") expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000) viewer.keyboard.press("g") expect(viewer.locator("body")).not_to_have_class(re.compile("staged"), timeout=5_000) expect(viewer.locator("#watch-stage .tile")).to_have_count(0) expect(viewer.locator(".tile")).to_have_count(2) # g on an already-unstaged wall is a silent no-op: the wall *is* the # visible result "back to the wall". viewer.keyboard.press("g") expect(viewer.locator(".tile")).to_have_count(2) expect(viewer.locator("#toast")).not_to_be_visible() # Leaving the stage must not have disturbed the streams. expect(viewer.locator(".tile[data-state='live']")).to_have_count(2, timeout=20_000) assert errors == [], f"uncaught browser errors: {errors}" def test_n_and_p_step_between_mirrors_without_killing_the_peer(self, device): errors: list = [] viewer, ids = self._two_mirror_wall(device, errors) a, b = ids[0], ids[1] expect(viewer.locator(".tile[data-state='live']")).to_have_count(2, timeout=30_000) # Stage A by keyboard. viewer.keyboard.press("j") viewer.keyboard.press("Enter") expect(viewer.locator("#watch-stage .tile")).to_have_count(1, timeout=5_000) assert viewer.locator("#watch-stage .tile").get_attribute("data-stream") == a # Mark A's