phase: 09_wall_remote_page
Build and Push Containers / test (push) Successful in 45s
Build and Push Containers / build-and-push-app (push) Successful in 13s

**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:
2026-11-03 23:50:43 -05:00
parent cd29156a7d
commit d0077e0db4
36 changed files with 1354 additions and 43 deletions
+167 -9
View File
@@ -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