phase: 08_kiosk_page
Build and Push Containers / test (push) Successful in 42s
Build and Push Containers / build-and-push-app (push) Successful in 9s

**Phase 08 final verification pass — complete, all green.**

**Verified (all 5 tasks already implemented):**
- `static/wall.js` (`createWall` + `installWallKeys`), `kiosk.html`/`kiosk.js`, `GET /kiosk` route — guards: no `$("#…")` in wall.js, one keydown listener, kiosk ids no-collision across 3 pages, no audio field in `arrange`, no header/share link in markup, address gate covers `/kiosk`
- Cross-page e2e: arrival-arrangement, keypress-on-one-display moves the other (asserted on the second display's decoded moving pixels), dead mirror → `data-layout="wall"` on both, `followers` count, pointer-free meta-test

**Gates (exact outcomes):**
- `uv run ruff check . && uv run ruff format --check .` — passed
- `uv run pyright` — 0 errors
- `uv run pytest` — 341 passed, 5 skipped (realdisplay-gated), 96s
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — 4 passed, 1 skipped (documented: host has no audio source for real capture)
- Coverage 96% (>90%); only warning is a starlette/httpx third-party deprecation

**Completion criteria:** all 9 ✅ — e2e green incl. `test_kiosk_layout.py` (the one e2e diff is the phase-mandated address-gate extension to `/kiosk`; assertion logic untouched), full suite 100% passing, README documents `/kiosk`, PLAN.md untouched.

**Documentation:**
- README: fixed the Kiosk-client section that implied the display box is permanently pointed at `/watch` — now states the display page is `/kiosk` with the runbook's one-line re-point (installer default still `/watch` this phase, per docs). Rest of README verified against code (routes, config table, CLI flag, presets, key table) — consistent.
- AGENTS.md: fixed 2 stale statements — CDN test moved to `TestPages` (was `TestClientScripts`), suite runtime ~50s → ~95s. No content added.

**Notable:** PLAN.md §6 says "per-leg `maxBitrate` cap (default 12 Mbit/s)" but code/README default is 25 Mbit/s — pre-existing, left untouched (locked file; flagged for a user decision).

**Next pending phase:** `09_wall_remote_page`.
This commit is contained in:
2026-11-03 22:38:22 -05:00
parent 736da1c1ef
commit cd29156a7d
45 changed files with 2389 additions and 750 deletions
+360
View File
@@ -0,0 +1,360 @@
"""End-to-end: the /kiosk display arranges itself, and obeys one client over another.
Phase 08, task 05: the two properties the TODO item claims — **it arranges
itself on arrival** (point a display at /kiosk and it does what the shared
layout already says, with nobody standing at the box) and **one client's
action moves another client's display** (a keypress on one kiosk re-arranges
the other, asserted on the second display's decoded pixels) — plus the
``followers`` count (displays only, not plain viewers) and the teardown
fallback (a dead mirror returns every display to the wall in the same frame
set; no display may sit on a dead tile).
Isolated contexts are the unit of "a client": each is its own socket and its
own peer id, which is exactly what "another client" means to the server. The
media path itself is proven by ``test_live_mirror.py``; this suite proves the
*state* path and still asserts on real decoded frames, so nothing is taken on
trust.
The suite is pointer-free: a test that reaches for the mouse proves nothing
about a kiosk. The only input a display receives here is a keypress; the
server state is driven by raw ``WebSocket`` frames in ``page.evaluate`` — a
wire-level client, not a pointer. That is what the meta-test at the bottom
permits: it bans pointer calls, not the driver's socket.
Fullscreen is asserted on the page's own knobs — ``body[data-fullscreen]``
and the staged state — and **never on ``document.fullscreenElement``**:
headless Chromium may refuse an element ``requestFullscreen()`` without user
activation, and the real kiosk's window manager forces the window fullscreen
anyway (``docs/kiosk-silverblue/window-config.ini``). The knobs are what the
app controls (the same stance ``kiosk.js`` documents for its best-effort
request); a "strengthened" assertion on ``fullscreenElement`` turns this
suite into a flake.
"""
from __future__ import annotations
import time
from pathlib import Path
import httpx
import pytest
from playwright.sync_api import expect
from .conftest import frames_matching, start_sharing, stop_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). Contexts are closed by finalizers, so a leaked peer here
cannot surface as a tile count in another suite's test (they share one
session-scoped server).
"""
opened = []
def _open(path: str = "/", errors: list | None = None):
context, page = open_device(base_url, browser, request, path, errors)
opened.append(context)
return page
return _open
def open_device(base_url, browser, request, path: str, errors: list | None = None):
"""Open one device; return ``(context, page)``.
The context is closed exactly once — by a finalizer at test end, or by
the test itself (the guard flag is what makes a finalizer after an
explicit close a no-op).
"""
context = browser.new_context(viewport={"width": 1100, "height": 720})
closed = [False]
def _finalize() -> None:
if not closed[0]:
closed[0] = True
context.close()
request.addfinalizer(_finalize)
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 context, page
def mirror_ids(base_url, count: int, timeout: float = 15.0) -> list[str]:
"""The server's truth about what is live (AGENTS.md §8, rung 1)."""
deadline = time.time() + timeout
last: list[str] = []
while time.time() < deadline:
payload = httpx.get(f"{base_url}/api/streams", timeout=5).json()
last = [mirror["id"] for mirror in payload["mirrors"]]
if len(last) == count:
return last
time.sleep(0.2)
raise AssertionError(f"expected {count} mirror(s) on /api/streams, saw {last}")
def kiosk_state(base_url) -> dict:
return httpx.get(f"{base_url}/api/kiosk", timeout=5).json()
def wait_for_kiosk(base_url, predicate, what: str, timeout: float = 15.0) -> dict:
deadline = time.time() + timeout
last: dict = {}
while time.time() < deadline:
last = kiosk_state(base_url)
if predicate(last):
return last
time.sleep(0.2)
raise AssertionError(f"{what}; last /api/kiosk was {last}")
# The driver: a plain WebSocket client, opened from inside a page so the
# handshake runs in a real browser. It is wire-level state, not a pointer —
# the meta-test below allows it on exactly that ground. It closes the socket
# once the server has acknowledged the change: the layout is process state,
# not the driver's, so the kiosk's `welcome` (which carries it) cannot race
# the driver's exit.
DRIVER = """
async ([url, staged, fullscreen]) => {
const ws = new WebSocket(url);
await new Promise((resolve, reject) => {
ws.onopen = () => resolve();
ws.onerror = () => reject(new Error('driver socket did not open'));
});
const nextFrame = (type) => new Promise((resolve, reject) => {
const timer = setTimeout(
() => { ws.close(); reject(new Error(`driver timed out waiting for ${type}`)); },
10_000);
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === type) { clearTimeout(timer); resolve(message); }
};
});
ws.send(JSON.stringify({ type: 'hello', role: 'viewer', name: '', device: 'e2e layout driver' }));
const welcome = await nextFrame('welcome');
ws.send(JSON.stringify({ type: 'arrange', staged, fullscreen }));
const layout = await nextFrame('layout');
ws.close();
return { peerId: welcome.peerId, layout };
}
"""
def drive_layout(page, base_url, staged: str | None, fullscreen: bool) -> dict:
"""Send one `arrange` frame over a raw socket; return the server's ack."""
ws_url = base_url.replace("http://", "ws://") + "/ws"
return page.evaluate(DRIVER, [ws_url, staged, fullscreen])
def expect_staged(page, stream_id: str, timeout: int = 15_000) -> None:
"""The display's stage holds exactly the tile the layout names."""
expect(page.locator("#kiosk-stage .tile")).to_have_count(1, timeout=timeout)
assert page.locator("#kiosk-stage .tile").get_attribute("data-stream") == stream_id
class TestArrivalIsArrangement:
def test_a_display_opened_after_an_arrange_shows_it_without_input(self, device, base_url):
"""Arrival is arrangement: the state is applied before the first tile
paints, so the person pointing a kiosk at /kiosk touches nothing."""
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
(stream_id,) = mirror_ids(base_url, 1)
# The controller acts first, over a raw socket: stage + fullscreen, and
# the server's own ack says it took.
driver = device("/watch", errors)
ack = drive_layout(driver, base_url, stream_id, True)
assert ack["layout"]["staged"] == stream_id
assert ack["layout"]["fullscreen"] is True
# The display opens *after* the arrange. No key, no pointer: it must
# settle on the stage.
kiosk = device("/kiosk", errors)
expect(kiosk.locator("body")).to_have_attribute("data-layout", "stage", timeout=15_000)
# The page's own knob, not document.fullscreenElement: headless may
# refuse the request and the window manager's window-fullscreen is the
# guarantee, so the knob (what the app controls) is the assertion.
expect(kiosk.locator("body")).to_have_attribute("data-fullscreen", "true", timeout=15_000)
expect_staged(kiosk, stream_id)
# The frame assertion is what proves media crossed to the display, not
# just a CSS class (AGENTS.md §5): decoded, *changing* pixels on the
# staged <video>.
expect(kiosk.locator("#kiosk-stage .tile[data-state='live']")).to_have_count(
1, timeout=25_000
)
frames = frames_matching(kiosk, "#kiosk-stage video")
assert frames["width"] > 0, f"no decoded frame size on the display: {frames}"
assert frames["height"] > 0, f"no decoded frame size on the display: {frames}"
assert frames["distinct"] >= 2, f"the staged video is frozen: {frames}"
# One source of truth: the server says what the pixels show.
state = kiosk_state(base_url)
assert state["staged"] == stream_id
assert state["fullscreen"] is True
# The display is counted; the driver (a plain viewer) is not.
assert state["followers"] == 1, state
assert errors == [], f"uncaught browser errors: {errors}"
class TestOneClientMovesAnother:
def test_a_keypress_on_one_display_rearranges_the_other(self, device, base_url):
"""One action, two displays, one source of truth — and the second
display never receives a pointer call (or any input at all)."""
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
(stream_id,) = mirror_ids(base_url, 1)
first = device("/kiosk", errors)
second = device("/kiosk", errors) # nothing is ever typed or pointed here
expect(first.locator("body")).to_have_attribute("data-layout", "wall")
expect(second.locator("body")).to_have_attribute("data-layout", "wall")
# The only input in this test: one keypress on the first display.
first.keyboard.press("Enter")
expect(first.locator("body")).to_have_attribute("data-layout", "stage", timeout=5_000)
expect(second.locator("body")).to_have_attribute("data-layout", "stage", timeout=15_000)
expect_staged(second, stream_id)
# The display is showing it, not merely labelled: a live, moving leg.
expect(second.locator("#kiosk-stage .tile[data-state='live']")).to_have_count(
1, timeout=25_000
)
frames = frames_matching(second, "#kiosk-stage video")
assert frames["width"] > 0, f"no decoded frame on the second display: {frames}"
assert frames["distinct"] >= 2, f"the second display's video is frozen: {frames}"
# The server agrees with both displays.
state = kiosk_state(base_url)
assert state["staged"] == stream_id, state
assert errors == [], f"uncaught browser errors: {errors}"
def test_n_and_p_on_one_display_walk_the_other_through_the_mirrors(self, device, base_url):
errors: list = []
for _ in range(2):
start_sharing(device("/mirror", errors))
first_id, second_id = mirror_ids(base_url, 2)
first = device("/kiosk", errors)
second = device("/kiosk", errors)
expect(second.locator("body")).to_have_attribute("data-layout", "wall")
expect(second.locator(".tile")).to_have_count(2, timeout=15_000)
# n: no cursor yet, so it lands on the first mirror…
first.keyboard.press("n")
expect_staged(second, first_id)
# …p walks to the second…
first.keyboard.press("p")
expect_staged(second, second_id)
# …and wraps back to the first.
first.keyboard.press("p")
expect_staged(second, first_id)
# The server walked the same way (checked at the wrap, where a stale
# broadcast would still show the second mirror).
state = kiosk_state(base_url)
assert state["staged"] == first_id, state
# Both mirrors survived the walking: the display's wall holds both.
expect(second.locator(".tile")).to_have_count(2)
expect(second.locator(".tile[data-state='live']")).to_have_count(2, timeout=20_000)
assert errors == [], f"uncaught browser errors: {errors}"
def test_a_dead_mirror_returns_every_display_to_the_wall(self, device, base_url):
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
(stream_id,) = mirror_ids(base_url, 1)
first = device("/kiosk", errors)
second = device("/kiosk", errors)
first.keyboard.press("Enter")
expect(first.locator("body")).to_have_attribute("data-layout", "stage", timeout=5_000)
expect_staged(second, stream_id)
stop_sharing(sharer)
# No display may sit on a dead tile: both back on the wall, in time.
expect(first.locator("body")).to_have_attribute("data-layout", "wall", timeout=10_000)
expect(second.locator("body")).to_have_attribute("data-layout", "wall", timeout=10_000)
expect(first.locator("#kiosk-stage .tile")).to_have_count(0)
expect(second.locator("#kiosk-stage .tile")).to_have_count(0)
# And the wall is actually empty, not just unstaged: the dead tile is
# gone, so there is no stale <video> to clear.
expect(first.locator(".tile")).to_have_count(0, timeout=10_000)
expect(second.locator(".tile")).to_have_count(0, timeout=10_000)
state = wait_for_kiosk(base_url, lambda s: s["staged"] is None, "the layout cleared")
assert state["followers"] == 2, state
assert errors == [], f"uncaught browser errors: {errors}"
class TestFollowers:
def test_followers_counts_displays_only_over_a_real_socket_lifecycle(
self, base_url, browser, request
):
"""`followers` is how many *displays* are obeying the wall — a plain
viewer (a /watch tab) is not one, and a display leaving is noticed
without any state to poll: the count is derived from the roster."""
errors: list = []
assert kiosk_state(base_url)["followers"] == 0, "the suite must start with no displays"
context, _kiosk = open_device(base_url, browser, request, "/kiosk", errors)
wait_for_kiosk(base_url, lambda s: s["followers"] == 1, "the display counted")
# A plain viewer joins: the wall follows the same state, but the count
# is about displays, not about who is reading the state.
open_device(base_url, browser, request, "/watch", errors)
wait_for_kiosk(base_url, lambda s: s["followers"] == 1, "the viewer not counted")
# The display leaves (its socket closes through a real context close)
# and the count falls back with it.
context.close()
wait_for_kiosk(base_url, lambda s: s["followers"] == 0, "the display counted out")
assert errors == [], f"uncaught browser errors: {errors}"
class TestSuiteIsPointerFree:
"""The kiosk guarantee, enforced on this file itself.
A test that reaches for the mouse proves nothing about a kiosk. The
guarantee lives in the code, not in reviewer attention, so this meta-test
bans pointer calls on this file and fails the suite the moment one slips
back in. The driver's raw ``WebSocket`` frames (``page.evaluate``) are
wire-level, not a pointer, and are allowed *explicitly* on that ground —
this guard bans the two pointer calls (click and hover) anywhere in this
file, nothing else, and does not loosen to skip the driver's block: a
pointer call anywhere in this file is a failure.
"""
def test_the_suite_never_reaches_for_the_pointer(self):
source = Path(__file__).read_text()
# Built from parts so this very assertion does not contain the banned
# literals (a plain string here would trip the check on itself).
assert ("cli" + "ck(") not in source, "a pointer call slipped into the kiosk suite"
assert ("hover" + "(") not in source, "a pointer call slipped into the kiosk suite"