phase: 08_kiosk_page
**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:
+158
-20
@@ -80,6 +80,7 @@ class TestPages:
|
||||
("/mirror", "Share my screen"),
|
||||
("/share", "Share my screen"),
|
||||
("/watch", "tile-template"),
|
||||
("/kiosk", "kiosk-tile-template"),
|
||||
],
|
||||
)
|
||||
def test_pages_render(self, client, path, marker):
|
||||
@@ -88,14 +89,17 @@ class TestPages:
|
||||
assert response.headers["content-type"].startswith("text/html")
|
||||
assert marker in response.text
|
||||
|
||||
def test_pages_are_not_cacheable(self, client):
|
||||
assert client.get("/watch").headers["cache-control"] == "no-store"
|
||||
@pytest.mark.parametrize("path", ["/", "/mirror", "/share", "/watch", "/kiosk"])
|
||||
def test_pages_are_not_cacheable(self, client, path):
|
||||
assert client.get(path).headers["cache-control"] == "no-store"
|
||||
|
||||
def test_the_two_pages_use_the_media_apis_they_claim(self):
|
||||
# The watch page's media path lives in wall.js (phase 08): the page
|
||||
# builds the wall, and the wall owns the RTCPeerConnection.
|
||||
share = (STATIC / "share.js").read_text()
|
||||
watch = (STATIC / "watch.js").read_text()
|
||||
wall = (STATIC / "wall.js").read_text()
|
||||
assert "getDisplayMedia" in share
|
||||
assert "RTCPeerConnection" in share and "RTCPeerConnection" in watch
|
||||
assert "RTCPeerConnection" in share and "RTCPeerConnection" in wall
|
||||
assert "new WebSocket" in (STATIC / "net.js").read_text()
|
||||
|
||||
def test_nothing_is_loaded_from_a_cdn(self):
|
||||
@@ -109,7 +113,10 @@ class TestPages:
|
||||
offenders.append(f"{file.name} (@import)")
|
||||
assert offenders == []
|
||||
|
||||
@pytest.mark.parametrize("asset", ["app.css", "net.js", "share.js", "watch.js"])
|
||||
@pytest.mark.parametrize(
|
||||
"asset",
|
||||
["app.css", "net.js", "share.js", "watch.js", "wall.js", "kiosk.js"],
|
||||
)
|
||||
def test_static_assets_are_served(self, client, asset):
|
||||
assert client.get(f"/static/{asset}").status_code == 200
|
||||
|
||||
@@ -124,7 +131,7 @@ 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"])
|
||||
@pytest.mark.parametrize("script", ["net.js", "share.js", "watch.js", "wall.js", "kiosk.js"])
|
||||
def test_scripts_parse_as_es_modules(self, script, tmp_path):
|
||||
copy = tmp_path / "subject.mjs"
|
||||
copy.write_text((STATIC / script).read_text())
|
||||
@@ -133,7 +140,11 @@ class TestClientScripts:
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("script", "page"),
|
||||
[("share.js", "mirror.html"), ("watch.js", "watch.html")],
|
||||
[
|
||||
("share.js", "mirror.html"),
|
||||
("watch.js", "watch.html"),
|
||||
("kiosk.js", "kiosk.html"),
|
||||
],
|
||||
)
|
||||
def test_every_element_the_script_reaches_for_exists(self, script, page):
|
||||
html = (STATIC / page).read_text()
|
||||
@@ -141,8 +152,100 @@ class TestClientScripts:
|
||||
missing = {name for name in wanted if f'id="{name}"' not in html}
|
||||
assert not missing, f"{script} looks up {sorted(missing)} which {page} does not render"
|
||||
|
||||
def test_both_pages_load_the_stylesheet_and_their_module(self):
|
||||
for page, script in (("mirror.html", "share.js"), ("watch.html", "watch.js")):
|
||||
def test_wall_never_looks_up_elements_in_the_document(self):
|
||||
"""wall.js serves /watch and /kiosk (and phase 09's /remote) from one
|
||||
module; an element lookup would resolve in whichever page happens to
|
||||
load it. The elements come from the page instead, which is why the
|
||||
element ids must not collide across pages (see
|
||||
test_the_two_pages_share_no_element_ids_but_the_shared_chrome) — so
|
||||
the module is guarded at the source level, here, next to the other
|
||||
client-script guards."""
|
||||
source = (STATIC / "wall.js").read_text()
|
||||
offenders = re.findall(r"""\$\(['\"]#([A-Za-z0-9_-]+)['\"]\)""", source)
|
||||
assert not offenders, f"wall.js must not query the document; found {offenders}"
|
||||
|
||||
def test_the_wall_key_router_is_one_listener_in_wall_js(self):
|
||||
"""One listener, one switch, per wall page — phase 01's whole point.
|
||||
|
||||
`installWallKeys` (wall.js) is the single keydown listener a wall page
|
||||
gets (/watch, /kiosk, phase 09's /remote): a second listener on the
|
||||
same document would run the same key twice (j would move the cursor
|
||||
twice) or race the first. The share page is not a wall page — its
|
||||
s/x/Esc listener lives in share.js and is page policy — but a wall
|
||||
page module must never add a keydown listener of its own, so a second
|
||||
`addEventListener("keydown"` in a wall page module fails the build.
|
||||
"""
|
||||
wall = (STATIC / "wall.js").read_text()
|
||||
assert "export function installWallKeys" in wall, (
|
||||
"installWallKeys must be defined in wall.js (the one keydown listener)"
|
||||
)
|
||||
assert len(re.findall(r'addEventListener\(\s*["\']keydown["\']', wall)) == 1, (
|
||||
"wall.js must hold exactly one keydown listener (the router)"
|
||||
)
|
||||
offenders = [
|
||||
file.name
|
||||
for file in sorted(STATIC.glob("*.js"))
|
||||
if file.name not in ("share.js", "wall.js")
|
||||
and re.search(r'addEventListener\(\s*["\']keydown["\']', file.read_text())
|
||||
]
|
||||
assert offenders == [], (
|
||||
"a wall page module added its own keydown listener; the one listener "
|
||||
f"per wall page lives in wall.js: {offenders}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _object_literal_around(source, pos):
|
||||
"""The object literal containing ``source[pos:``.
|
||||
|
||||
``pos`` points at a ``type: "arrange"`` key. Walking back, the nearest
|
||||
``{`` that is not inside a deeper ``{...}`` span is the literal's start
|
||||
— the literal itself when ``type`` is its first member, the enclosing
|
||||
object when it is not. Forward brace matching then closes it.
|
||||
"""
|
||||
depth = 0
|
||||
start = None
|
||||
for i in range(pos - 1, -1, -1):
|
||||
if source[i] == "}":
|
||||
depth += 1
|
||||
elif source[i] == "{":
|
||||
if depth == 0:
|
||||
start = i
|
||||
break
|
||||
depth -= 1
|
||||
assert start is not None, "no object literal before the arrange key"
|
||||
depth = 0
|
||||
for i in range(start, len(source)):
|
||||
if source[i] == "{":
|
||||
depth += 1
|
||||
elif source[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[start : i + 1]
|
||||
raise AssertionError("unbalanced braces around the arrange frame")
|
||||
|
||||
def test_kiosk_never_publishes_an_audio_field(self):
|
||||
"""The layout has no audio field (SECURITY.md §4): a display must not
|
||||
become audible because another machine asked, so ``kiosk.js`` may never
|
||||
put a ``muted``/``audio`` field in an ``arrange`` frame. The rule is a
|
||||
security rule, so it fails the build here instead of living in a
|
||||
comment (phase 08 task 04). At least one ``arrange`` frame must exist:
|
||||
the display is a first-class controller, and this guard must not pass
|
||||
on a file that simply stopped publishing."""
|
||||
source = (STATIC / "kiosk.js").read_text()
|
||||
matches = list(re.finditer(r"type\s*:\s*[\"\']arrange[\"\']", source))
|
||||
assert matches, "kiosk.js publishes no arrange frames at all"
|
||||
for match in matches:
|
||||
literal = self._object_literal_around(source, match.start())
|
||||
assert not re.search(r"\b(muted|audio)\s*:", literal), (
|
||||
f"an arrange frame carries an audio field: {literal}"
|
||||
)
|
||||
|
||||
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"),
|
||||
):
|
||||
html = (STATIC / page).read_text()
|
||||
assert 'href="/static/app.css"' in html
|
||||
assert f'src="/static/{script}"' in html
|
||||
@@ -289,23 +392,55 @@ class TestClientScripts:
|
||||
)
|
||||
|
||||
def test_the_two_pages_share_no_element_ids_but_the_shared_chrome(self):
|
||||
"""Both pages consume one stylesheet, so a duplicated id means a colliding rule.
|
||||
"""The pages consume one stylesheet, so a duplicated id means a colliding rule.
|
||||
|
||||
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).
|
||||
"""
|
||||
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")
|
||||
for page in ("mirror.html", "watch.html", "index.html", "kiosk.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
|
||||
# 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
|
||||
# same shape as the index/mirror assertion above.
|
||||
assert ids["kiosk.html"] & ids["index.html"] <= shared
|
||||
for page, found in ids.items():
|
||||
assert len(found) == len(set(found)), f"{page} declares an id twice"
|
||||
|
||||
def test_the_wall_pages_render_their_stage_classed(self):
|
||||
"""The stage rules are class-keyed (`.stage`, phase 08 task 01): a page
|
||||
whose stage lost the class renders a full-size unstyled video, which is
|
||||
exactly the class of bug the stylesheet guards exist for. Both wall
|
||||
pages keep it, and the kiosk's structural elements (grid, stage, hud)
|
||||
exist for the layout applier to take them. (There was no
|
||||
elements-a-page-must-have check before; this is the one.)
|
||||
"""
|
||||
kiosk = (STATIC / "kiosk.html").read_text()
|
||||
for element_id in ("kiosk-grid", "kiosk-stage", "kiosk-hud"):
|
||||
assert f'id="{element_id}"' in kiosk, f"kiosk.html is missing #{element_id}"
|
||||
for page, stage_id in (("watch.html", "watch-stage"), ("kiosk.html", "kiosk-stage")):
|
||||
stage_tags = [
|
||||
tag
|
||||
for tag in re.findall(r"<div[^>]*>", (STATIC / page).read_text())
|
||||
if f'id="{stage_id}"' in tag
|
||||
]
|
||||
assert stage_tags, f'{page} is missing <div id="{stage_id}">'
|
||||
assert 'class="stage"' in stage_tags[0], (
|
||||
f'{page}#{stage_id} lost class="stage": the stage rules are '
|
||||
"class-keyed, so an unclassed stage renders a full-size unstyled video"
|
||||
)
|
||||
|
||||
def test_videos_are_muted_and_inline_so_autoplay_is_not_blocked(self):
|
||||
for page in ("mirror.html", "watch.html"):
|
||||
for page in ("mirror.html", "watch.html", "kiosk.html"):
|
||||
for tag in re.findall(r"<video[^>]*>", (STATIC / page).read_text()):
|
||||
assert "muted" in tag, f"{page}: {tag} would be blocked by autoplay policy"
|
||||
assert "playsinline" in tag, f"{page}: {tag} would hijack iOS into fullscreen"
|
||||
@@ -378,8 +513,9 @@ class TestStylesheet:
|
||||
The flat ``[^{}]+\{[^{}]*\}`` pass treats an ``@media`` header as a
|
||||
selector and the first child's header as its body; the child rules
|
||||
that follow still come back as their own pairs, which is all these
|
||||
guards need — every assertion is about a top-level block (``.stage``,
|
||||
``.stage > *``) or about properties that must sit in the *same* block
|
||||
guards need — every assertion is about a top-level block
|
||||
(``.preview-stage``, ``.preview-stage > *``) or about properties that
|
||||
must sit in the *same* block
|
||||
(``overflow``/``aspect-ratio``/``min-height`` together).
|
||||
"""
|
||||
css = re.sub(r"/\*.*?\*/", "", (STATIC / "app.css").read_text(), flags=re.S)
|
||||
@@ -396,8 +532,8 @@ class TestStylesheet:
|
||||
without the other stacks the rows back.
|
||||
"""
|
||||
rules = self._rules()
|
||||
stage_bodies = [body for sel, body in rules if sel == ".stage"]
|
||||
assert stage_bodies, ".stage rule missing from app.css"
|
||||
stage_bodies = [body for sel, body in rules if sel == ".preview-stage"]
|
||||
assert stage_bodies, ".preview-stage rule missing from app.css"
|
||||
assert any(
|
||||
re.search(r"grid-template\s*:\s*1fr\s*/\s*1fr", body)
|
||||
or (
|
||||
@@ -409,7 +545,7 @@ class TestStylesheet:
|
||||
".stage must declare an explicit single cell (grid-template: 1fr / 1fr, "
|
||||
"or grid-template-rows/columns of 1fr) — otherwise its children stack into rows"
|
||||
)
|
||||
child_bodies = [body for sel, body in rules if sel == ".stage > *"]
|
||||
child_bodies = [body for sel, body in rules if sel == ".preview-stage > *"]
|
||||
assert child_bodies and any(
|
||||
re.search(r"grid-area\s*:\s*1\s*/\s*1", body) for body in child_bodies
|
||||
), (
|
||||
@@ -431,8 +567,8 @@ class TestStylesheet:
|
||||
content again.
|
||||
"""
|
||||
rules = self._rules()
|
||||
stage_bodies = [body for sel, body in rules if sel == ".stage"]
|
||||
assert stage_bodies, ".stage rule missing from app.css"
|
||||
stage_bodies = [body for sel, body in rules if sel == ".preview-stage"]
|
||||
assert stage_bodies, ".preview-stage rule missing from app.css"
|
||||
assert any(re.search(r"min-height\s*:", body) for body in stage_bodies), (
|
||||
".stage must keep its floor (min-height)"
|
||||
)
|
||||
@@ -460,7 +596,8 @@ class TestAddressGate:
|
||||
assert client.get("/watch").status_code == 200
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path", ["/", "/mirror", "/watch", "/api/streams", "/api/kiosk", "/static/app.css"]
|
||||
"path",
|
||||
["/", "/mirror", "/watch", "/kiosk", "/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:
|
||||
@@ -527,6 +664,7 @@ class TestAddressGate:
|
||||
def test_the_real_address_is_in_and_connects(self):
|
||||
with gated(self.ALLOW, client_address="10.2.1.10") as client:
|
||||
assert client.get("/watch").status_code == 200
|
||||
assert client.get("/kiosk").status_code == 200
|
||||
with client.websocket_connect("/ws") as socket:
|
||||
assert handshake(socket, "viewer")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user