phase: 09_wall_remote_page
**Phase 09 — final verification pass (all 3 tasks already in `complete/`; phase work was in the working tree, verified, nothing needed fixing)**
**Gates** — `uv run ruff check . && uv run ruff format --check .` → clean; `uv run pyright` → 0 errors; `uv run pytest` → **360 passed, 5 skipped** (only the realdisplay rung), coverage 96% total / 95% app.py; `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 4 passed, 1 skipped (documented host limitation: no audio source on the real X11 path); `uv run pytest tests/e2e/test_remote_control.py` → 8/8 passed; `node --check` on every `static/*.js` → all pass.
**Completion criteria** — all met: (1) "Show on wall" stages on the display, asserted on display DOM + `/api/kiosk` ✓; (2) Back/Fullscreen + untouched second controller converge (TestConvergence) ✓; (3) `/api/streams` reports `viewers: 0` / `watching: 0` with only `/remote` open (TestACostsNothing) ✓; (4) followers count displays only — 0/1/1 with a second controller (TestFollowers) ✓; (5) controller works with no display: `followers: 0`, state stored (protocol test + e2e) ✓; (6) e2e + full `-m e2e` + realdisplay all green ✓; (7) README + SECURITY.md updated, Silverblue default now `/kiosk` (installer, conf, gnome script, checker) ✓; (8) suite 100% green, coverage >90% on new code ✓; (9) PLAN.md untouched; AGENTS.md changed only for a stale statement ✓.
**Documentation** — README.md reviewed in full against code (page table, quickstart, env-var table vs `config.py`/`debug.py`, presets, keys, deployment, kiosk section): no inconsistencies found. AGENTS.md: fixed the one stale statement introduced by this phase ("Four pages" → "Five pages"); no other staleness, nothing added.
**Decisions** — no repo changes were required; the one realdisplay skip is a pre-documented environment fact, not a failure.
**Next pending phase** — none; `todo/` held only phase 09.
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
"""End-to-end: a hand on another machine drives the /kiosk display.
|
||||
|
||||
Phase 09, task 03: the whole loop the TODO item claims — a mirror on the wall,
|
||||
a box across the room with no keyboard, and a person on a laptop or phone who
|
||||
can put a mirror on the stage, send it fullscreen, or put the wall back. The
|
||||
controller is asserted *on the display's DOM and on the server's numbers*,
|
||||
never on the controller's optimism (a page that paints a result and hopes is
|
||||
the regression this suite exists to catch), and the media is asserted as
|
||||
decoded, changing pixels on the display's staged ``<video>`` (AGENTS.md §5).
|
||||
|
||||
Unlike the kiosk suite, this one **clicks**: a person with a mouse driving a
|
||||
machine that has none is the entire feature. Isolated contexts are the unit
|
||||
of "a device": each is its own socket and its own peer id, which is exactly
|
||||
what "another client" means to the server. Uncaught page errors and console
|
||||
errors are captured from the first line of every test and surfaced through
|
||||
the ``errors == []`` assertion.
|
||||
|
||||
Two invariants are asserted here because they are the product's, not the
|
||||
browser's:
|
||||
|
||||
* **A controller costs a sharer nothing** — with only ``/remote`` open, the
|
||||
stream reports ``viewers: 0`` and the server's ``watching`` count is 0: the
|
||||
controller never subscribes and never opens a media leg (PLAN.md §6's mesh
|
||||
cost). Opening a real ``/watch`` is what moves those numbers.
|
||||
* **``followers`` counts displays, never controllers** — a second ``/remote``
|
||||
does not raise the count, so "no display following" keeps meaning what it
|
||||
says.
|
||||
|
||||
Fullscreen is asserted on the page's own knobs — ``body[data-fullscreen]``
|
||||
and the server's 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``). Asserting on the
|
||||
browser's concession would turn this suite into a flake.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
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 streams_api(base_url) -> dict:
|
||||
return httpx.get(f"{base_url}/api/streams", timeout=5).json()
|
||||
|
||||
|
||||
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}")
|
||||
|
||||
|
||||
def wait_for_streams(base_url, predicate, what: str, timeout: float = 15.0) -> dict:
|
||||
deadline = time.time() + timeout
|
||||
last: dict = {}
|
||||
while time.time() < deadline:
|
||||
last = streams_api(base_url)
|
||||
if predicate(last):
|
||||
return last
|
||||
time.sleep(0.2)
|
||||
raise AssertionError(f"{what}; last /api/streams was {last}")
|
||||
|
||||
|
||||
def wait_row(remote, timeout: int = 15_000) -> None:
|
||||
"""The controller's list holds one row: `welcome` has landed."""
|
||||
expect(remote.locator("#remote-list .mirror-row")).to_have_count(1, timeout=timeout)
|
||||
|
||||
|
||||
class TestTheLoop:
|
||||
def test_show_on_wall_and_back_to_the_wall(self, device, base_url):
|
||||
"""The whole loop: a click on the controller arranges the display, and
|
||||
"back to the wall" undoes it — asserted on the display, not the click."""
|
||||
errors: list = []
|
||||
sharer = device("/mirror", errors)
|
||||
start_sharing(sharer)
|
||||
(stream_id,) = mirror_ids(base_url, 1)
|
||||
|
||||
display = device("/kiosk", errors)
|
||||
expect(display.locator("body")).to_have_attribute("data-layout", "wall")
|
||||
controller = device("/remote", errors)
|
||||
wait_row(controller)
|
||||
# The row the click lands on names the live mirror.
|
||||
assert controller.locator(".mirror-row").get_attribute("data-stream") == stream_id
|
||||
|
||||
# One click: "Show on wall". The display must obey, on the server's
|
||||
# numbers as well as its own DOM.
|
||||
controller.locator(".mirror-row .show").click()
|
||||
expect(display.locator("body")).to_have_attribute("data-layout", "stage", timeout=15_000)
|
||||
expect(display.locator(f"#kiosk-stage .tile[data-stream='{stream_id}']")).to_have_count(
|
||||
1, timeout=15_000
|
||||
)
|
||||
state = wait_for_kiosk(base_url, lambda s: s["staged"] == stream_id, "the display staged")
|
||||
assert state["staged"] == stream_id, state
|
||||
|
||||
# The controller reflects the server's state, not its own click: the
|
||||
# row says the mirror is on the wall.
|
||||
expect(controller.locator(".mirror-row[data-on]")).to_have_count(1, timeout=15_000)
|
||||
|
||||
controller.locator("#remote-back").click()
|
||||
expect(display.locator("body")).to_have_attribute("data-layout", "wall", timeout=15_000)
|
||||
expect(display.locator("#kiosk-stage .tile")).to_have_count(0)
|
||||
wait_for_kiosk(base_url, lambda s: s["staged"] is None, "the display unstaged")
|
||||
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
|
||||
def test_the_controller_moves_real_media_to_the_display(self, device, base_url):
|
||||
"""Pixels, not classes: the assertion is decoded, *changing* frames on
|
||||
the display's staged <video> (AGENTS.md §5) — a CSS class would prove
|
||||
nothing crossed the wire."""
|
||||
errors: list = []
|
||||
sharer = device("/mirror", errors)
|
||||
start_sharing(sharer)
|
||||
(stream_id,) = mirror_ids(base_url, 1)
|
||||
|
||||
display = device("/kiosk", errors)
|
||||
controller = device("/remote", errors)
|
||||
wait_row(controller)
|
||||
controller.locator(".mirror-row .show").click()
|
||||
expect(display.locator("body")).to_have_attribute("data-layout", "stage", timeout=15_000)
|
||||
expect(
|
||||
display.locator(f"#kiosk-stage .tile[data-stream='{stream_id}'][data-state='live']")
|
||||
).to_have_count(1, timeout=25_000)
|
||||
|
||||
frames = frames_matching(display, "#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}"
|
||||
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
|
||||
|
||||
class TestFullscreen:
|
||||
def test_fullscreen_is_reported_not_promised(self, device, base_url):
|
||||
"""The controller requests fullscreen; the display obeys in its state.
|
||||
Never assert ``document.fullscreenElement`` here: headless Chromium
|
||||
may refuse an element ``requestFullscreen()`` without user activation,
|
||||
and the real box's window manager forces the window fullscreen anyway
|
||||
(``docs/kiosk-silverblue/window-config.ini``). The page's own knob and
|
||||
the server's state are what the app controls; the browser's
|
||||
concession is a bonus, not the guarantee."""
|
||||
errors: list = []
|
||||
sharer = device("/mirror", errors)
|
||||
start_sharing(sharer)
|
||||
mirror_ids(base_url, 1)
|
||||
|
||||
display = device("/kiosk", errors)
|
||||
controller = device("/remote", errors)
|
||||
wait_row(controller)
|
||||
# Normalize the shared server state: the suites share one session
|
||||
# server, and the kiosk suite's first test leaves `fullscreen: true`
|
||||
# behind (a teardown resets `staged`, never `fullscreen`). The button
|
||||
# toggles from the server's value, so a leaked true would make this
|
||||
# click flip it to false — reset it through the product, the way a
|
||||
# controller would.
|
||||
if kiosk_state(base_url)["fullscreen"]:
|
||||
controller.locator("#remote-fullscreen").click()
|
||||
wait_for_kiosk(base_url, lambda s: s["fullscreen"] is False, "the state normalized")
|
||||
controller.locator(".mirror-row .show").click()
|
||||
expect(display.locator("body")).to_have_attribute("data-layout", "stage", timeout=15_000)
|
||||
|
||||
controller.locator("#remote-fullscreen").click()
|
||||
state = wait_for_kiosk(
|
||||
base_url, lambda s: s["fullscreen"] is True, "the server's state went fullscreen"
|
||||
)
|
||||
assert state["fullscreen"] is True, state
|
||||
expect(display.locator("body")).to_have_attribute("data-fullscreen", "true", timeout=15_000)
|
||||
# The controller's button reads the server's value, not its own click.
|
||||
expect(controller.locator("#remote-fullscreen")).to_have_attribute("aria-pressed", "true")
|
||||
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
|
||||
|
||||
class TestConvergence:
|
||||
def test_two_controllers_converge_without_touching_the_second(self, device, base_url):
|
||||
"""One acts, the other was never touched: its readout and the
|
||||
``data-on`` row update from the server's frame, so two open
|
||||
controllers can never disagree (task 02's no-optimistic-paint rule,
|
||||
proven from the outside)."""
|
||||
errors: list = []
|
||||
sharer = device("/mirror", errors)
|
||||
start_sharing(sharer)
|
||||
(stream_id,) = mirror_ids(base_url, 1)
|
||||
|
||||
display = device("/kiosk", errors)
|
||||
first = device("/remote", errors)
|
||||
second = device("/remote", errors) # nothing is ever clicked here
|
||||
wait_row(first)
|
||||
wait_row(second)
|
||||
|
||||
first.locator(".mirror-row .show").click()
|
||||
expect(display.locator("body")).to_have_attribute("data-layout", "stage", timeout=15_000)
|
||||
|
||||
# The untouched controller: its row carries the marker and the button
|
||||
# reads the server's state.
|
||||
row = second.locator(".mirror-row[data-on]")
|
||||
expect(row).to_have_count(1, timeout=15_000)
|
||||
assert row.get_attribute("data-stream") == stream_id
|
||||
expect(row.locator(".show")).to_have_text("On the wall")
|
||||
# Its readout agrees too: the staged mirror's title is the row's title.
|
||||
title = row.locator(".title").inner_text()
|
||||
expect(second.locator("#remote-staged")).to_have_text(title, timeout=15_000)
|
||||
|
||||
# And the server, which the display obeys, names the same mirror.
|
||||
state = kiosk_state(base_url)
|
||||
assert state["staged"] == stream_id, state
|
||||
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
|
||||
|
||||
class TestACostsNothing:
|
||||
def test_a_controller_costs_the_sharer_nothing(self, device, base_url):
|
||||
"""The guard that keeps /remote from becoming a stealth watcher:
|
||||
the controller never subscribes, so the sharer uploads nothing for
|
||||
it — the per-stream ``viewers`` and the server's ``watching`` count
|
||||
stay at zero until a real ``/watch`` joins (PLAN.md §6)."""
|
||||
errors: list = []
|
||||
sharer = device("/mirror", errors)
|
||||
start_sharing(sharer)
|
||||
(stream_id,) = mirror_ids(base_url, 1)
|
||||
|
||||
def only(stream_id):
|
||||
def check(payload):
|
||||
mirror = next(m for m in payload["mirrors"] if m["id"] == stream_id)
|
||||
return mirror["viewers"] == 0 and payload["stats"]["watching"] == 0
|
||||
|
||||
return check
|
||||
|
||||
# The baseline: nothing is watching before anyone opens a page.
|
||||
wait_for_streams(base_url, only(stream_id), "the idle baseline")
|
||||
|
||||
controller = device("/remote", errors)
|
||||
wait_row(controller) # the page is connected and rendered…
|
||||
# …and still consumes nothing: no subscription, no leg.
|
||||
wait_for_streams(base_url, only(stream_id), "the controller not counted")
|
||||
|
||||
device("/watch", errors)
|
||||
wait_for_streams(
|
||||
base_url,
|
||||
lambda payload: (
|
||||
next(m for m in payload["mirrors"] if m["id"] == stream_id)["viewers"] == 1
|
||||
and payload["stats"]["watching"] == 1
|
||||
),
|
||||
"the real watcher counted",
|
||||
)
|
||||
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
|
||||
|
||||
class TestFollowers:
|
||||
def test_followers_and_the_readout_agree_and_a_controller_is_not_a_display(
|
||||
self, device, base_url
|
||||
):
|
||||
"""`/api/kiosk` and the controller's `#remote-followers` read the same
|
||||
number at 0, at 1 with a display open, and still at 1 with a second
|
||||
/remote added — a controller is not a display, so "1 display
|
||||
following" keeps meaning what it says."""
|
||||
errors: list = []
|
||||
sharer = device("/mirror", errors)
|
||||
start_sharing(sharer)
|
||||
|
||||
assert kiosk_state(base_url)["followers"] == 0, "the suite must start with no displays"
|
||||
|
||||
controller = device("/remote", errors)
|
||||
wait_row(controller) # welcome has landed: the readout is the server's
|
||||
assert kiosk_state(base_url)["followers"] == 0
|
||||
expect(controller.locator("#remote-followers")).to_contain_text("no display following")
|
||||
|
||||
device("/kiosk", errors)
|
||||
wait_for_kiosk(base_url, lambda s: s["followers"] == 1, "the display counted")
|
||||
expect(controller.locator("#remote-followers")).to_contain_text(
|
||||
"1 display following", timeout=15_000
|
||||
)
|
||||
|
||||
second = device("/remote", errors)
|
||||
wait_row(second)
|
||||
assert kiosk_state(base_url)["followers"] == 1, "a controller is not a display"
|
||||
expect(second.locator("#remote-followers")).to_contain_text(
|
||||
"1 display following", timeout=15_000
|
||||
)
|
||||
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
|
||||
|
||||
class TestTheDisplayIsAControllerToo:
|
||||
def test_a_keypress_on_the_display_moves_the_remote_readout(self, device, base_url):
|
||||
"""The TODO's "any client can manipulate", in the direction that would
|
||||
otherwise be forgotten: with a keyboard plugged in to the box, `n`
|
||||
stages a mirror *and* publishes the intent, so the controller — which
|
||||
receives no keypress — follows it."""
|
||||
errors: list = []
|
||||
sharer = device("/mirror", errors)
|
||||
start_sharing(sharer)
|
||||
(stream_id,) = mirror_ids(base_url, 1)
|
||||
|
||||
display = device("/kiosk", errors)
|
||||
controller = device("/remote", errors)
|
||||
wait_row(controller)
|
||||
expect(controller.locator("#remote-staged")).to_have_text("no mirror")
|
||||
|
||||
# The one input in this test: a keypress on the display.
|
||||
display.keyboard.press("n")
|
||||
expect(display.locator("body")).to_have_attribute("data-layout", "stage", timeout=15_000)
|
||||
|
||||
# The untouched controller follows: its readout names the staged
|
||||
# mirror and its row carries the marker.
|
||||
row = controller.locator(".mirror-row")
|
||||
title = row.locator(".title").inner_text()
|
||||
expect(controller.locator("#remote-staged")).to_have_text(title, timeout=15_000)
|
||||
expect(row).to_have_count(1)
|
||||
expect(controller.locator(".mirror-row[data-on]")).to_have_count(1, timeout=15_000)
|
||||
assert row.get_attribute("data-stream") == stream_id
|
||||
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
|
||||
|
||||
class TestGoneIsAState:
|
||||
def test_an_ended_mirror_unstages_the_display_and_clears_the_controller(self, device, base_url):
|
||||
"""Gone is a state: when the mirror ends, the display falls back to
|
||||
the wall and the controller's row and readout clear — no stale tile,
|
||||
no stale readout, no stale server state."""
|
||||
errors: list = []
|
||||
sharer = device("/mirror", errors)
|
||||
start_sharing(sharer)
|
||||
(stream_id,) = mirror_ids(base_url, 1)
|
||||
|
||||
display = device("/kiosk", errors)
|
||||
controller = device("/remote", errors)
|
||||
wait_row(controller)
|
||||
controller.locator(".mirror-row .show").click()
|
||||
expect(display.locator("body")).to_have_attribute("data-layout", "stage", timeout=15_000)
|
||||
expect(display.locator(f"#kiosk-stage .tile[data-stream='{stream_id}']")).to_have_count(1)
|
||||
|
||||
stop_sharing(sharer)
|
||||
|
||||
# No display sits on a dead tile.
|
||||
expect(display.locator("body")).to_have_attribute("data-layout", "wall", timeout=10_000)
|
||||
expect(display.locator("#kiosk-stage .tile")).to_have_count(0)
|
||||
# The controller: the row is gone and the readout says no mirror.
|
||||
expect(controller.locator("#remote-list .mirror-row")).to_have_count(0, timeout=10_000)
|
||||
expect(controller.locator("#remote-staged")).to_have_text("no mirror", timeout=10_000)
|
||||
# And the server cleared the stage.
|
||||
wait_for_kiosk(base_url, lambda s: s["staged"] is None, "the layout cleared")
|
||||
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
+167
-9
@@ -81,6 +81,7 @@ class TestPages:
|
||||
("/share", "Share my screen"),
|
||||
("/watch", "tile-template"),
|
||||
("/kiosk", "kiosk-tile-template"),
|
||||
("/remote", "remote-row-template"),
|
||||
],
|
||||
)
|
||||
def test_pages_render(self, client, path, marker):
|
||||
@@ -89,7 +90,7 @@ class TestPages:
|
||||
assert response.headers["content-type"].startswith("text/html")
|
||||
assert marker in response.text
|
||||
|
||||
@pytest.mark.parametrize("path", ["/", "/mirror", "/share", "/watch", "/kiosk"])
|
||||
@pytest.mark.parametrize("path", ["/", "/mirror", "/share", "/watch", "/kiosk", "/remote"])
|
||||
def test_pages_are_not_cacheable(self, client, path):
|
||||
assert client.get(path).headers["cache-control"] == "no-store"
|
||||
|
||||
@@ -115,7 +116,7 @@ class TestPages:
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"asset",
|
||||
["app.css", "net.js", "share.js", "watch.js", "wall.js", "kiosk.js"],
|
||||
["app.css", "net.js", "share.js", "watch.js", "wall.js", "kiosk.js", "remote.js"],
|
||||
)
|
||||
def test_static_assets_are_served(self, client, asset):
|
||||
assert client.get(f"/static/{asset}").status_code == 200
|
||||
@@ -131,7 +132,9 @@ class TestClientScripts:
|
||||
NODE = shutil.which("node")
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node is not installed")
|
||||
@pytest.mark.parametrize("script", ["net.js", "share.js", "watch.js", "wall.js", "kiosk.js"])
|
||||
@pytest.mark.parametrize(
|
||||
"script", ["net.js", "share.js", "watch.js", "wall.js", "kiosk.js", "remote.js"]
|
||||
)
|
||||
def test_scripts_parse_as_es_modules(self, script, tmp_path):
|
||||
copy = tmp_path / "subject.mjs"
|
||||
copy.write_text((STATIC / script).read_text())
|
||||
@@ -144,6 +147,7 @@ class TestClientScripts:
|
||||
("share.js", "mirror.html"),
|
||||
("watch.js", "watch.html"),
|
||||
("kiosk.js", "kiosk.html"),
|
||||
("remote.js", "remote.html"),
|
||||
],
|
||||
)
|
||||
def test_every_element_the_script_reaches_for_exists(self, script, page):
|
||||
@@ -240,11 +244,79 @@ class TestClientScripts:
|
||||
f"an arrange frame carries an audio field: {literal}"
|
||||
)
|
||||
|
||||
def test_a_controller_is_media_free(self):
|
||||
"""The /remote page shows no video and subscribes to nothing (PLAN.md §6).
|
||||
|
||||
The mesh gives every sharer one upload per connected leg, so a controller
|
||||
that consumed a leg just to know what to point at would add an upload to
|
||||
every sharer for every controller open. The promise is enforced at the
|
||||
source level so a well-meant future "preview thumbnail" fails the build
|
||||
instead of being caught in review: `remote.js` may never construct a media
|
||||
connection or send a subscribe frame, may never hold a media stream or
|
||||
read the screen-capture APIs, and `remote.html` may never embed a
|
||||
`<video>`.
|
||||
"""
|
||||
source = (STATIC / "remote.js").read_text()
|
||||
assert "RTCPeerConnection" not in source, (
|
||||
"remote.js must not open a media connection: a controller that streams "
|
||||
"costs every sharer a leg (PLAN.md §6)"
|
||||
)
|
||||
assert "MediaStream" not in source, (
|
||||
"remote.js must not hold a media stream: the controller keeps a list, "
|
||||
"not tiles — a preview is the regression this guard exists for"
|
||||
)
|
||||
assert "navigator.mediaDevices" not in source, (
|
||||
"remote.js must not read the screen-capture APIs: the controller never "
|
||||
"asks the browser for a surface"
|
||||
)
|
||||
assert 'type: "subscribe"' not in source and "type:'subscribe'" not in source, (
|
||||
"remote.js must not send a subscribe frame: a controller subscribes to nothing"
|
||||
)
|
||||
html = (STATIC / "remote.html").read_text()
|
||||
assert "<video" not in html, (
|
||||
"remote.html must not embed a <video>: the controller shows no media"
|
||||
)
|
||||
|
||||
def test_a_controller_sends_exactly_one_frame_type_besides_hello(self):
|
||||
"""The controller's whole job is the `arrange` frame (plus the hello that
|
||||
introduces it): the file must actually send one, and must not send any
|
||||
other roster frame. The presence half matters: a guard that only bans
|
||||
strings would pass on a controller whose buttons had gone dead."""
|
||||
source = (STATIC / "remote.js").read_text()
|
||||
assert 'type: "arrange"' in source, (
|
||||
"remote.js sends no arrange frame: the controller's buttons are dead"
|
||||
)
|
||||
assert 'type: "subscribe"' not in source and "type:'subscribe'" not in source, (
|
||||
"remote.js must not send a subscribe frame: a controller subscribes to nothing"
|
||||
)
|
||||
|
||||
def test_the_remote_page_declares_its_control_hooks(self):
|
||||
"""The readout, the buttons and the list are the contract task 02 and the
|
||||
e2e suite code against; assert them on the markup so task 01's stub and
|
||||
task 02's page cannot drift (the element guard covers the script→page
|
||||
direction once remote.js looks them up)."""
|
||||
html = (STATIC / "remote.html").read_text()
|
||||
for element_id in (
|
||||
"remote-state",
|
||||
"remote-staged",
|
||||
"remote-fullscreen-mark",
|
||||
"remote-followers",
|
||||
"remote-back",
|
||||
"remote-fullscreen",
|
||||
"remote-list",
|
||||
"remote-row-template",
|
||||
"remote-empty",
|
||||
"remote-share-url",
|
||||
"remote-hint",
|
||||
):
|
||||
assert f'id="{element_id}"' in html, f"remote.html is missing #{element_id}"
|
||||
|
||||
def test_the_pages_load_the_stylesheet_and_their_module(self):
|
||||
for page, script in (
|
||||
("mirror.html", "share.js"),
|
||||
("watch.html", "watch.js"),
|
||||
("kiosk.html", "kiosk.js"),
|
||||
("remote.html", "remote.js"),
|
||||
):
|
||||
html = (STATIC / page).read_text()
|
||||
assert 'href="/static/app.css"' in html
|
||||
@@ -396,23 +468,27 @@ class TestClientScripts:
|
||||
|
||||
This is not hypothetical: a `#stage` on both pages once made the share
|
||||
page's preview inherit `display: none` from the watch page's overlay.
|
||||
The kiosk page's ids carry the `kiosk-` prefix for exactly this reason;
|
||||
the only ids it may share with another page are the shared chrome
|
||||
(net, notice, toast).
|
||||
The kiosk page's ids carry the `kiosk-` prefix and the remote page's the
|
||||
`remote-` prefix for exactly this reason; the only ids a page may share
|
||||
with another are the shared chrome (net, notice, toast).
|
||||
"""
|
||||
shared = {"net", "notice", "toast"}
|
||||
ids = {
|
||||
page: set(re.findall(r'id="([A-Za-z0-9_-]+)"', (STATIC / page).read_text()))
|
||||
for page in ("mirror.html", "watch.html", "index.html", "kiosk.html")
|
||||
for page in ("mirror.html", "watch.html", "index.html", "kiosk.html", "remote.html")
|
||||
}
|
||||
assert ids["mirror.html"] & ids["watch.html"] == shared
|
||||
assert ids["index.html"] & ids["mirror.html"] <= shared | {"livebar"}
|
||||
assert ids["kiosk.html"] & ids["watch.html"] == shared
|
||||
assert ids["kiosk.html"] & ids["mirror.html"] == shared
|
||||
assert ids["remote.html"] & ids["watch.html"] == shared
|
||||
assert ids["remote.html"] & ids["mirror.html"] == shared
|
||||
assert ids["remote.html"] & ids["kiosk.html"] == shared
|
||||
# index.html has no notice/toast (a signpost, not an app page), so its
|
||||
# intersection with the kiosk is a subset of the shared chrome — the
|
||||
# intersection with an app page is a subset of the shared chrome — the
|
||||
# same shape as the index/mirror assertion above.
|
||||
assert ids["kiosk.html"] & ids["index.html"] <= shared
|
||||
assert ids["remote.html"] & ids["index.html"] <= shared
|
||||
for page, found in ids.items():
|
||||
assert len(found) == len(set(found)), f"{page} declares an id twice"
|
||||
|
||||
@@ -597,7 +673,16 @@ class TestAddressGate:
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
["/", "/mirror", "/watch", "/kiosk", "/api/streams", "/api/kiosk", "/static/app.css"],
|
||||
[
|
||||
"/",
|
||||
"/mirror",
|
||||
"/watch",
|
||||
"/kiosk",
|
||||
"/remote",
|
||||
"/api/streams",
|
||||
"/api/kiosk",
|
||||
"/static/app.css",
|
||||
],
|
||||
)
|
||||
def test_a_refused_address_gets_no_page_no_roster_no_assets(self, path):
|
||||
with gated(self.ALLOW) as client:
|
||||
@@ -1140,3 +1225,76 @@ class TestSignalingSocket:
|
||||
assert wait_for(viewer1, "layout")["staged"] is None
|
||||
assert wait_for(viewer2, "layout")["staged"] is None
|
||||
assert client.get("/api/kiosk").json()["staged"] is None
|
||||
|
||||
def test_a_controller_peer_drives_the_layout_without_counting_in_followers(self, client):
|
||||
"""A /remote-shaped peer — `hello{role:"viewer"}` with no `kiosk` flag —
|
||||
may drive the wall, but must not be counted in it: `followers` is the
|
||||
display count, and the phone that holds the wall is not a display. The
|
||||
read endpoint and the frames must agree on the same number."""
|
||||
with (
|
||||
client.websocket_connect("/ws") as sharer,
|
||||
client.websocket_connect("/ws") as controller,
|
||||
client.websocket_connect("/ws") as display,
|
||||
):
|
||||
sharer_id = handshake(sharer, "sharer", "A")
|
||||
handshake(controller, "viewer", "phone") # no `kiosk` flag
|
||||
display.send_json({"type": "hello", "role": "viewer", "name": "K", "kiosk": True})
|
||||
assert display.receive_json()["type"] == "welcome"
|
||||
# The display's join is reported to the other viewers; the controller
|
||||
# is one of them and hears the count of one.
|
||||
assert wait_for(controller, "layout")["followers"] == 1
|
||||
assert client.get("/api/kiosk").json()["followers"] == 1
|
||||
|
||||
sharer.send_json({"type": "publish", "label": "Screen"})
|
||||
wait_for(controller, "stream_added")
|
||||
|
||||
# The controller drives the wall...
|
||||
controller.send_json({"type": "arrange", "staged": sharer_id, "fullscreen": True})
|
||||
sent = wait_for(controller, "layout")
|
||||
assert sent["staged"] == sharer_id
|
||||
assert sent["fullscreen"] is True
|
||||
# ...the display obeys it...
|
||||
assert wait_for(display, "layout")["staged"] == sharer_id
|
||||
# ...and the read endpoint agrees with both — the controller still
|
||||
# does not count: the wall is driven by one, obeyed by one, and the
|
||||
# count says one.
|
||||
body = client.get("/api/kiosk").json()
|
||||
assert (body["staged"], body["fullscreen"], body["followers"]) == (sharer_id, True, 1)
|
||||
assert sent["followers"] == 1
|
||||
|
||||
def test_arranging_a_mirror_a_viewer_is_watching_changes_no_subscriptions(self, client):
|
||||
"""Driving the wall must not disturb the people watching it: an
|
||||
`arrange` for a mirror a second viewer is receiving touches no
|
||||
subscription — /api/streams reports the same audience before and after,
|
||||
and the controller's exit removes nothing, because it never watched."""
|
||||
with (
|
||||
client.websocket_connect("/ws") as sharer,
|
||||
client.websocket_connect("/ws") as viewer,
|
||||
client.websocket_connect("/ws") as controller,
|
||||
):
|
||||
sharer_id = handshake(sharer, "sharer", "A")
|
||||
handshake(viewer, "viewer", "V")
|
||||
handshake(controller, "viewer", "phone") # no `kiosk` flag
|
||||
sharer.send_json({"type": "publish", "label": "Screen"})
|
||||
wait_for(viewer, "stream_added")
|
||||
|
||||
viewer.send_json({"type": "subscribe", "streamId": sharer_id})
|
||||
wait_for(sharer, "peer_joined")
|
||||
before = client.get("/api/streams").json()
|
||||
assert before["stats"]["watching"] == 1
|
||||
assert before["mirrors"][0]["viewers"] == 1
|
||||
|
||||
controller.send_json({"type": "arrange", "staged": sharer_id, "fullscreen": True})
|
||||
sent = wait_for(controller, "layout")
|
||||
assert sent["staged"] == sharer_id
|
||||
|
||||
after = client.get("/api/streams").json()
|
||||
assert after["stats"]["watching"] == 1
|
||||
assert after["mirrors"][0]["viewers"] == 1
|
||||
|
||||
# The controller leaving removes no subscription: it never watched,
|
||||
# so its exit touches the audience not at all.
|
||||
controller.close()
|
||||
final = client.get("/api/streams").json()
|
||||
assert final["stats"]["watching"] == 1
|
||||
assert final["mirrors"][0]["viewers"] == 1
|
||||
|
||||
Reference in New Issue
Block a user