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,11 @@
|
||||
**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.
|
||||
@@ -0,0 +1 @@
|
||||
ok
|
||||
@@ -0,0 +1,37 @@
|
||||
........................................................................ [ 19%]
|
||||
..............................................sssss..................... [ 39%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 98%]
|
||||
..... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1
|
||||
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.15.0-final-0 ________________
|
||||
|
||||
Name Stmts Miss Cover Missing
|
||||
------------------------------------------------------
|
||||
src/mirror/__init__.py 3 0 100%
|
||||
src/mirror/__main__.py 72 3 96% 129, 145, 156
|
||||
src/mirror/access.py 60 0 100%
|
||||
src/mirror/app.py 197 10 95% 108, 113, 117, 182-183, 212-216
|
||||
src/mirror/config.py 68 5 93% 110-111, 118-120
|
||||
src/mirror/debug.py 15 10 33% 19-28
|
||||
src/mirror/protocol.py 66 0 100%
|
||||
src/mirror/registry.py 228 3 99% 208, 226, 460
|
||||
------------------------------------------------------
|
||||
TOTAL 709 31 96%
|
||||
=========================== short test summary info ============================
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
360 passed, 5 skipped, 1 warning in 102.18s (0:01:42)
|
||||
All checks passed!
|
||||
34 files already formatted
|
||||
0 errors, 0 warnings, 0 informations
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
All gates green. Task complete.
|
||||
|
||||
**Report**
|
||||
|
||||
- **Implemented:** `GET /remote` route in `src/mirror/app.py` (+ `PAGES` no-store); `static/remote.html` (bar + net pill, readout `#remote-state` with stable hooks, `#remote-back`/`#remote-fullscreen` controls, `#remote-list` + row template, empty state with share URL); `static/remote.js` stub (config, refusal notice, socket hello as plain `role:"viewer"` with no `kiosk` flag, net pill to online); `app.css` `.remote` styles (mono readout strip, wrapping control row, 320 px-safe rows, `#remote-fullscreen[aria-pressed="true"]` accent = tile audio button treatment); entry links in `watch.html` (`#remote-link`) and `index.html` (4th card); `/kiosk` untouched; `test_app.py` guards extended to the fourth page (render/no-store/asset/node-check/element-exists/stylesheet-module/id-collision over 5 pages with `{"net","notice","toast"}` as the only overlap, address gate, plus new `test_a_controller_is_media_free` and `test_the_remote_page_declares_its_control_hooks`).
|
||||
- **Results:** `uv run ruff check . && uv run ruff format --check .` — clean; `uv run pyright` — 0 errors; `uv run pytest` — 349 passed, 5 skipped (realdisplay opt-in), 100% green; `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — 4 passed, 1 documented platform skip; `uv run pytest tests/test_app.py` — 104 passed; coverage 95% overall, new route fully covered. Live checks: `curl -s/-I localhost:8055/remote` serves the page with `cache-control: no-store`; browser at 320 px: net pill online, empty state + share URL, zero console errors, both buttons reachable, no horizontal scroll.
|
||||
- **Docs:** README — `/remote` row in the page table + quickstart URL line (SECURITY.md/full controller docs left to task 03 per the phase plan). AGENTS.md — one stale fact only: "Four pages" → "Five pages".
|
||||
- **Deviation:** the task text names both the readout marker span and the Full screen button `remote-fullscreen` (a duplicate id that would fail the page's own uniqueness guard). The button keeps `#remote-fullscreen` (the `aria-pressed` CSS rule and the task's guard target it); the readout marker is `#remote-fullscreen-mark`, noted in the markup and asserted by the new hook guard.
|
||||
- **Next pending task:** `.agents/phases/todo/09_wall_remote_page/02_remote_controller_logic.md`
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
........................................................................ [ 20%]
|
||||
......................................sssss............................. [ 40%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 81%]
|
||||
.................................................................. [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1
|
||||
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.15.0-final-0 ________________
|
||||
|
||||
Name Stmts Miss Cover Missing
|
||||
------------------------------------------------------
|
||||
src/mirror/__init__.py 3 0 100%
|
||||
src/mirror/__main__.py 72 3 96% 129, 145, 156
|
||||
src/mirror/access.py 60 0 100%
|
||||
src/mirror/app.py 197 11 94% 108, 113, 116-117, 182-183, 212-216
|
||||
src/mirror/config.py 68 5 93% 110-111, 118-120
|
||||
src/mirror/debug.py 15 10 33% 19-28
|
||||
src/mirror/protocol.py 66 0 100%
|
||||
src/mirror/registry.py 228 3 99% 208, 226, 460
|
||||
------------------------------------------------------
|
||||
TOTAL 709 32 95%
|
||||
=========================== short test summary info ============================
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
349 passed, 5 skipped, 1 warning in 95.96s (0:01:35)
|
||||
All checks passed!
|
||||
33 files already formatted
|
||||
0 errors, 0 warnings, 0 informations
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
All green. Final report:
|
||||
|
||||
**Implemented (task 02 — `remote.js` controller logic):**
|
||||
- `src/mirror/static/remote.js`: `mirrors` Map + `layout` state; roster frames (`welcome` seeds, added/updated upsert, removed deletes, `signal` ignored); `renderMirrors()` (template rows, `data-on`/`aria-current`/`On the wall`, empty state); `renderState()` (staged title/`(gone)`/`no mirror`, fullscreen on/off on marker + button `aria-pressed`/label, followers plural handling); one-line hint (`no display following` / `offline`); `arrange()` writers with event delegation on `#remote-list`; one-time "…ended — back to the wall" toast; stale readout on socket close, re-sync on `welcome`.
|
||||
- `remote.html`: added `#remote-hint`; `app.css`: `.remote-hint` + dimmed `[data-stale]` readout.
|
||||
- `tests/test_app.py`: media-free guard extended (`MediaStream`, `navigator.mediaDevices`), new `test_a_controller_sends_exactly_one_frame_type_besides_hello`, hook list gains `remote-hint`, 2 protocol tests (controller drives layout, not counted in `followers`; `arrange` changes no subscriptions/audience).
|
||||
|
||||
**Gates (all 4, exact commands):** ruff check+format clean · pyright 0 errors · `uv run pytest` **352 passed, 5 skipped** (documented realdisplay skips) · `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` **4 passed, 1 documented skip** · coverage 95%, byte-identical to verified baseline (no `src/` Python touched).
|
||||
|
||||
**Docs:** no impact — README already describes `/remote` accurately (task 01); deep docs are task 03's scope; no stale AGENTS.md statements.
|
||||
|
||||
**Notable:** spec's "three spans" named `#remote-fullscreen`, but task 01's built HTML (locked contract, asserted in tests) uses `#remote-fullscreen-mark` for the readout span and `#remote-fullscreen` for the button — followed the built HTML (marker carries on/off, button carries label/aria). Pre-existing, verified in a reconstructed baseline: the non-gate combined run `MIRROR_REALDISPLAY=1 uv run pytest` errors 5 x11 fixture setups (Playwright sync-API double context); both official invocations are green, left untouched.
|
||||
|
||||
**Next pending task:** `03_remote_e2e_docs_and_kiosk_url.md`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
........................................................................ [ 20%]
|
||||
......................................sssss............................. [ 40%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 80%]
|
||||
..................................................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1
|
||||
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.15.0-final-0 ________________
|
||||
|
||||
Name Stmts Miss Cover Missing
|
||||
------------------------------------------------------
|
||||
src/mirror/__init__.py 3 0 100%
|
||||
src/mirror/__main__.py 72 3 96% 129, 145, 156
|
||||
src/mirror/access.py 60 0 100%
|
||||
src/mirror/app.py 197 11 94% 108, 113, 116-117, 182-183, 212-216
|
||||
src/mirror/config.py 68 5 93% 110-111, 118-120
|
||||
src/mirror/debug.py 15 10 33% 19-28
|
||||
src/mirror/protocol.py 66 0 100%
|
||||
src/mirror/registry.py 228 3 99% 208, 226, 460
|
||||
------------------------------------------------------
|
||||
TOTAL 709 32 95%
|
||||
=========================== short test summary info ============================
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
352 passed, 5 skipped, 1 warning in 96.76s (0:01:36)
|
||||
All checks passed!
|
||||
33 files already formatted
|
||||
0 errors, 0 warnings, 0 informations
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
All changes verified. The AGENTS.md diff ("Five pages") was from task 01 — I left AGENTS.md untouched (no stale statement in it). Everything is complete and green.
|
||||
|
||||
**Report**
|
||||
|
||||
- **New `tests/e2e/test_remote_control.py`** (8 tests, `e2e` + `timeout(180)`, same `device()`/`open_device` setup as the kiosk suite): the click loop (`.show` → display `data-layout="stage"` + staged tile + `/api/kiosk`, `#remote-back` → wall), pixels-not-classes (`frames_matching` width>0, distinct≥2 on the display), fullscreen (server state + `body[data-fullscreen]`, `document.fullscreenElement` never asserted, comment says why), two-controller convergence, controller-costs-nothing (stream `viewers: 0` / `stats.watching: 0` with only `/remote`, moves when `/watch` joins), followers agreement (0/1/1 — a controller is not a display), kiosk `n` keypress moving the remote readout, teardown (display unstages, row/readout clear), `errors == []` in every case.
|
||||
- **Fix found while testing**: the kiosk suite leaves `fullscreen: true` on the shared session server (teardown resets `staged` only), which broke the toggle-based fullscreen test; the test now normalizes the shared state through the product at start (documented in the test).
|
||||
- **SECURITY.md**: §2 gains the "rearrange anybody's wall display" bullet (names `/remote`, same trust decision as watching, bounded by `MIRROR_ALLOW_CIDRS`) and the not-possible audio bullet; §3 gains the "No remote audio" row naming the real enforcing tests.
|
||||
- **README**: new "Driving an unattended wall" section (keys on the box visible to controllers, fullscreen requested-not-promised, in-memory state); Kiosk-client paragraph updated for the new default. Page table/Quickstart already had both endpoints.
|
||||
- **Silverblue tree → `/kiosk` in one change**: `install-kiosk.sh` default + comment, `kiosk.conf` URL + comment, `gnome-kiosk-script` fallback, `check-kiosk.sh` (accepts both walls, warning kept for the third case), `kiosk-silverblue.md` (top description, `KIOSK_URL` example, controller/migration/title paragraphs de-staled). `sh -n` clean on all scripts; checker logic verified via harness: `/kiosk` and `/watch` → ok, other → warn.
|
||||
- **Gates**: `uv run pytest tests/e2e/test_remote_control.py` → 8 passed; `uv run pytest -m e2e` → 118 passed; `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 4 passed (1 documented platform skip); full `uv run pytest` → **360 passed, 5 skipped, 0 failed**; `ruff check`/`ruff format --check`/`pyright` all clean.
|
||||
- **Note**: one one-off `test_kiosk_layout.py` tile-id flake in a single full run (not reproduced in two subsequent full runs; my suite runs after it in file order, so it cannot be the cause). No doc impact on AGENTS.md.
|
||||
- **Next pending task**: none — task 03 is the last task of phase 09.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
........................................................................ [ 19%]
|
||||
..............................................sssss..................... [ 39%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 98%]
|
||||
..... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1
|
||||
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.15.0-final-0 ________________
|
||||
|
||||
Name Stmts Miss Cover Missing
|
||||
------------------------------------------------------
|
||||
src/mirror/__init__.py 3 0 100%
|
||||
src/mirror/__main__.py 72 3 96% 129, 145, 156
|
||||
src/mirror/access.py 60 0 100%
|
||||
src/mirror/app.py 197 10 95% 108, 113, 117, 182-183, 212-216
|
||||
src/mirror/config.py 68 5 93% 110-111, 118-120
|
||||
src/mirror/debug.py 15 10 33% 19-28
|
||||
src/mirror/protocol.py 66 0 100%
|
||||
src/mirror/registry.py 228 3 99% 208, 226, 460
|
||||
------------------------------------------------------
|
||||
TOTAL 709 31 96%
|
||||
=========================== short test summary info ============================
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
360 passed, 5 skipped, 1 warning in 102.09s (0:01:42)
|
||||
All checks passed!
|
||||
34 files already formatted
|
||||
0 errors, 0 warnings, 0 informations
|
||||
@@ -41,7 +41,7 @@ is a roster and a signaling relay. Linux is the reference platform.
|
||||
| Rule | Why it exists |
|
||||
|---|---|
|
||||
| **No external assets.** No CDN, remote font, remote icon, `@import`, or analytics | LAN/offline correctness. `tests/test_app.py::TestPages::test_nothing_is_loaded_from_a_cdn` fails the build if you add one |
|
||||
| **No framework, no bundler** in `src/mirror/static/` | Four pages and one WebSocket. `node --check` is the entire frontend toolchain |
|
||||
| **No framework, no bundler** in `src/mirror/static/` | Five pages and one WebSocket. `node --check` is the entire frontend toolchain |
|
||||
| **Media never passes through the server** | The server relays JSON only. Anything that puts frames in the Python process violates A3 |
|
||||
| **`Registry` stays pure** — no sockets, no FastAPI, no `await` | It returns `list[Envelope]`. Transport lives in `Hub`. This is why the policy has 100%-ish unit coverage |
|
||||
| **camelCase on the wire, snake_case in Python**, via `alias_generator=to_camel` | A silently-dropped `displaySurface` was a real bug we shipped. Any new multi-word field needs an alias test |
|
||||
|
||||
@@ -10,6 +10,7 @@ your network:
|
||||
| **Share** | `/mirror` (also `/share`) | Picks a window, screen, or tab via the OS-native picker and mirrors it. |
|
||||
| **Watch** | `/watch` | Shows every screen currently being mirrored; click a tile to take the stage. |
|
||||
| **Kiosk** | `/kiosk` | A chrome-free wall for an unattended display: no header, no counts — the net pill is the only chrome, and the shared layout state (below) arranges it. |
|
||||
| **Remote** | `/remote` | A controller for the shared wall, from a laptop or a phone: lists the live mirrors and the wall's state and sends the decisions (show this, back to the wall, full screen) — no video, no bandwidth. |
|
||||
| **Index** | `/` | Signpost + live count of mirrors. |
|
||||
|
||||
Any number of people can mirror at the same time and any number of people can
|
||||
@@ -32,6 +33,7 @@ Then, from another machine on the LAN:
|
||||
http://<this-machine>:8055/mirror # to share
|
||||
http://<this-machine>:8055/watch # to watch
|
||||
http://<this-machine>:8055/kiosk # the unattended wall (kiosk display)
|
||||
http://<this-machine>:8055/remote # drive the wall from this device
|
||||
```
|
||||
|
||||
Linux is the primary target. On Linux the native picker is X11/Wayland's own
|
||||
@@ -78,6 +80,20 @@ same `arrange` intent, so a controller and a display converge on one state
|
||||
(`m` is local-only: a display never becomes audible because another machine
|
||||
asked).
|
||||
|
||||
### Driving an unattended wall
|
||||
|
||||
The shared state exists for a box with no keyboard: point the box at `/kiosk`,
|
||||
open `/remote` on a phone, and press **Show on wall** — the box stages that
|
||||
mirror, and **Back to the wall** and **Full screen** do the same for their
|
||||
decisions. When a keyboard is plugged into the box, the wall keys still work
|
||||
there, and a keypress performs the change *and* publishes the same `arrange`
|
||||
intent, so a key on the box is seen by every controller — a controller and a
|
||||
display converge on one state. Fullscreen is **requested, not promised**: a
|
||||
remote command carries no user activation, so the browser may refuse the
|
||||
request; the in-page stage fills the viewport anyway (and the kiosk box's
|
||||
window manager forces the window fullscreen). The state lives in the server's
|
||||
memory only: a restart returns the wall to a wall.
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables (all optional; a `.env` file is loaded if present):
|
||||
@@ -180,9 +196,9 @@ from this side:
|
||||
A wall display is a Fedora Silverblue box running the locked-down GNOME Kiosk
|
||||
session, auto-logged in as `kiosk`, with Firefox in `--kiosk` mode pointed at
|
||||
the wall. It is set up and repaired entirely over SSH — no keyboard, no console,
|
||||
no monitor. The display page is `/kiosk`; the installer default still opens
|
||||
`/watch` and an existing `kiosk.conf` URL wins, so moving a box is the
|
||||
one-line re-point in [the runbook](docs/kiosk-silverblue.md#two-endpoints-watch-and-kiosk). A brand-new box layers the kiosk packages once first
|
||||
no monitor. The display page is `/kiosk`, and it is the installer default
|
||||
too; an existing `kiosk.conf` URL wins over the default, so moving a box is
|
||||
the one-line re-point in [the runbook](docs/kiosk-silverblue.md#two-endpoints-watch-and-kiosk). A brand-new box layers the kiosk packages once first
|
||||
(`rpm-ostree install gnome-kiosk gnome-kiosk-script-session`, then a reboot);
|
||||
after that these two commands are both the setup and the repair path:
|
||||
|
||||
|
||||
+11
-5
@@ -31,11 +31,16 @@ limiting, no accounts, and no audit trail.
|
||||
`MIRROR_MAX_BITRATE`; the sharer's quality preset scales that bound only
|
||||
*down* — 1.0× / 0.6× / 0.35× — and can never raise it above the server's
|
||||
ceiling).
|
||||
* Point the shared wall at any live mirror and toggle its fullscreen state
|
||||
(the `arrange` frame on `/ws`), which every kiosk display follows. The
|
||||
state has no audio field: a remote hand can stage and scale, but it can
|
||||
never make a display *sound* — that still needs a gesture on the device
|
||||
playing it (see §4 “Audio”).
|
||||
* **Rearrange anybody's wall display**: choose which mirror is on stage and
|
||||
whether it is fullscreen, on every `/kiosk` client, immediately — open
|
||||
`/remote` on a phone, or send one `arrange` frame on `/ws`. This is the
|
||||
same trust decision as "watch anything anyone shares": it is visible, it is
|
||||
presentation-only (no sound, see below), and `MIRROR_ALLOW_CIDRS` is what
|
||||
bounds it.
|
||||
* What a visitor **cannot** do: make a display audible. The layout carries no
|
||||
sound field, and every audible state traces to a gesture on the device that
|
||||
plays it (the §4 “Audio” gesture rule) — a remote hand can stage and scale,
|
||||
but a stranger's display never sounds because they asked.
|
||||
|
||||
## 3. What they cannot do (enforced and tested)
|
||||
|
||||
@@ -46,6 +51,7 @@ limiting, no accounts, and no audit trail.
|
||||
| **No identity spoofing of the roster.** A viewer cannot publish a mirror; a peer cannot claim another's stream | role checks (`not_sharer`, `not_viewer`), stream ids are server-assigned | `test_one_peer_cannot_impersonate_another`, `test_a_viewer_cannot_publish` |
|
||||
| **No oversized signaling.** Frames above 64 KiB are dropped before parsing | `MAX_MESSAGE_BYTES` | `test_an_oversized_frame_is_refused` |
|
||||
| **No protocol fuzzing into a crash.** Bad JSON, non-object frames, unknown types and unknown roles produce an error frame; only fatal ones close the socket | `Hub.serve`/`_dispatch` | `TestSignalingSocket` |
|
||||
| **No remote audio, no remote mute override.** The layout carries no sound field, and every audible state traces to a gesture on the device that plays it | the layout model has no audio field, plus the `kiosk.js`/`remote.js` source guards | `test_kiosk_never_publishes_an_audio_field`, `test_a_controller_is_media_free`, `test_m_flips_the_staged_videos_muted_flag`, `test_only_the_staged_mirror_can_be_unmuted` |
|
||||
| **No leaked peer connections.** Every teardown path notifies every viewer | `Registry._drop_stream`, `leave()` | `test_sharer_disconnect_removes_the_mirror`, `test_unpublish_clears_the_tile_for_every_viewer` |
|
||||
| **Nothing leaves the network.** No CDN, no remote font, no analytics, no STUN by default | A10/A4 + a build-failing test | `test_nothing_is_loaded_from_a_cdn`, `test_config_does_not_leak_server_secrets` |
|
||||
| **The server cannot see your screen.** Media is SRTP between peers; the server relays JSON only | A3 | Architectural: no media code exists in Python |
|
||||
|
||||
+19
-16
@@ -1,7 +1,7 @@
|
||||
# Kiosk client — Fedora Silverblue
|
||||
|
||||
The target client for a wall display is **Fedora Silverblue running GNOME Kiosk,
|
||||
auto-logged in as `kiosk`, showing `https://mirror.reeseapps.com/watch`
|
||||
auto-logged in as `kiosk`, showing `https://mirror.reeseapps.com/kiosk`
|
||||
fullscreen in Firefox.** One box takes about five minutes over SSH and needs no
|
||||
keyboard, no monitor access, and no console.
|
||||
|
||||
@@ -56,7 +56,7 @@ be skipped when the packages are already layered. To point a client somewhere
|
||||
other than the production wall, pass the URL:
|
||||
|
||||
```bash
|
||||
sudo KIOSK_URL=https://mirror.reeseapps.com/watch sh install-kiosk.sh
|
||||
sudo KIOSK_URL=https://mirror.reeseapps.com/kiosk sh install-kiosk.sh
|
||||
```
|
||||
|
||||
Only `kiosk.conf` differs between clients; every other file stays byte-identical
|
||||
@@ -87,17 +87,20 @@ for the moment a keyboard is plugged in (see [the key table](#operating-the-wall
|
||||
and a keypress there performs the change *and* publishes the same `arrange`
|
||||
intent, so a controller and a display converge on one state instead of
|
||||
disagreeing (`m` is local-only — a display never becomes audible because
|
||||
another machine asked). The **controller side** — a page a person on another
|
||||
machine drives the wall from — arrives in phase 09 (`/remote`); until then a
|
||||
controller is any client that can send an `arrange` frame on `/ws`, `websocat`
|
||||
included. `GET /api/kiosk` is the state's read surface: what is on stage,
|
||||
whether it is fullscreen, and how many displays (`followers`) are obeying it.
|
||||
another machine asked). The **controller side** is `/remote`: a page a person
|
||||
on another machine (a phone) opens to list the live mirrors, read the wall's
|
||||
state, and send the decisions — show this, back to the wall, full screen. Any
|
||||
client that can send an `arrange` frame on `/ws` is a controller too,
|
||||
`websocat` included. `GET /api/kiosk` is the state's read surface: what is on
|
||||
stage, whether it is fullscreen, and how many displays (`followers`) are
|
||||
obeying it.
|
||||
|
||||
**The migration rule, stated honestly.** Existing clients keep their own
|
||||
`MIRROR_KIOSK_URL`: an existing `kiosk.conf` URL wins over the built-in
|
||||
default, and the installer's default still points at `/watch` in this phase.
|
||||
So **nothing changes under a box until its `kiosk.conf` is re-pointed and
|
||||
`gdm` restarted**:
|
||||
`MIRROR_KIOSK_URL` until an operator re-points it and restarts `gdm`, because
|
||||
the installer deliberately preserves a client's URL: an existing `kiosk.conf`
|
||||
URL wins over the built-in default (which now points at `/kiosk`). So
|
||||
**nothing changes under a box until its `kiosk.conf` is re-pointed and `gdm`
|
||||
restarted**:
|
||||
|
||||
```bash
|
||||
sudo sed -i 's|^MIRROR_KIOSK_URL=.*|MIRROR_KIOSK_URL="https://…/kiosk"|' \
|
||||
@@ -107,10 +110,10 @@ sudo systemctl restart gdm
|
||||
|
||||
(`pkill -u kiosk firefox` is the shorter path — the loop re-sources
|
||||
`kiosk.conf` on every relaunch, see [the section below](#the-two-facts-that-make-this-work) —
|
||||
`gdm` is the clean restart when you want the whole session back.) The kiosk
|
||||
page serves `<title>Wall · mirror`; `check-kiosk.sh`, untouched in this
|
||||
phase, still greps for `<title>Watch · mirror`, and the checker is widened to
|
||||
accept the kiosk title in phase 09, when the default URL moves with it.
|
||||
`gdm` is the clean restart when you want the whole session back.) Both walls
|
||||
are served by the same tree; `check-kiosk.sh` accepts both titles
|
||||
(`<title>Watch · mirror</title>` and `<title>Wall · mirror</title>`) and
|
||||
warns for anything else.
|
||||
|
||||
---
|
||||
|
||||
@@ -347,7 +350,7 @@ The console is not an interface. Every claim below is checkable over SSH.
|
||||
| The page loaded | the **server's** `GET /api/streams` shows the kiosk in `viewers` and its socket in `ss -tn` |
|
||||
| Media really flows | the sharer's own page renders each viewer's `RTCPeerConnection.connectionState`; `connected` = ICE+DTLS+SRTP up |
|
||||
| It is truly fullscreen | `dim-probe.py`: `viewport == window == screen == available` |
|
||||
| The URL is serving *mirror* | `check-kiosk.sh` greps the page for `<title>Watch · mirror` |
|
||||
| The URL is serving *mirror* | `check-kiosk.sh` greps the page for the wall title — `<title>Watch · mirror` or `<title>Wall · mirror` |
|
||||
| The kiosk holds the screen | `/sys/class/tty/tty0/active` equals the kiosk session's TTY and the session is `Active` — the one check that catches a covered wall |
|
||||
| No greeter is fighting it | `journalctl -b \| grep -c "New session.*gdm-greeter"` is `0`; any non-zero number is a login screen on your wall |
|
||||
| It survives a power cut | `sudo systemctl reboot`, then re-run `check-kiosk.sh` |
|
||||
|
||||
@@ -64,9 +64,9 @@ if [ -n "$HOME_DIR" ]; then
|
||||
"$HOME_DIR/.config/mirror/kiosk.conf" 2>/dev/null | head -1)
|
||||
ok "kiosk URL: ${URL:-<empty>}"
|
||||
case "$URL" in
|
||||
*/watch) : ;;
|
||||
*/watch|*/kiosk) : ;;
|
||||
"") bad "kiosk URL is empty" ;;
|
||||
*) warn "kiosk URL is not the /watch wall: $URL" ;;
|
||||
*) warn "kiosk URL is neither wall (/watch or /kiosk): $URL — check DNS and the nginx vhost" ;;
|
||||
esac
|
||||
else
|
||||
bad "kiosk config $HOME_DIR/.config/mirror/kiosk.conf is missing"
|
||||
@@ -213,8 +213,10 @@ if [ -n "$URL" ]; then
|
||||
CODE=$(curl -s -o /dev/null -w '%{http_code}' -m 8 "$URL" 2>/dev/null)
|
||||
if printf '%s' "$BODY" | grep -q "Watch · mirror"; then
|
||||
ok "$URL serves the mirror watch page (HTTP $CODE)"
|
||||
elif printf '%s' "$BODY" | grep -q "Wall · mirror"; then
|
||||
ok "$URL serves the mirror kiosk page (HTTP $CODE)"
|
||||
else
|
||||
warn "$URL answered HTTP $CODE but it is NOT the mirror watch page — the kiosk"
|
||||
warn "$URL answered HTTP $CODE but it is NOT a mirror wall — the kiosk"
|
||||
warn "will happily display whatever this is. Check DNS and the nginx vhost."
|
||||
info "title: $(printf '%s' "$BODY" | sed -n 's/.*<title>\(.*\)<\/title>.*/\1/p' | head -1)"
|
||||
fi
|
||||
|
||||
@@ -25,7 +25,7 @@ if [ -r "$CONF" ]; then
|
||||
. "$CONF"
|
||||
fi
|
||||
|
||||
URL="${MIRROR_KIOSK_URL:-https://mirror.reeseapps.com/watch}"
|
||||
URL="${MIRROR_KIOSK_URL:-https://mirror.reeseapps.com/kiosk}"
|
||||
PROFILE="${MIRROR_KIOSK_PROFILE:-$HOME/.mirror-kiosk}"
|
||||
LOG="${MIRROR_KIOSK_LOG:-${XDG_STATE_HOME:-$HOME/.local/state}/mirror-kiosk.log}"
|
||||
POLL="${MIRROR_KIOSK_POLL:-5}"
|
||||
|
||||
@@ -17,7 +17,10 @@ set -eu
|
||||
KIOSK_USER="${KIOSK_USER:-kiosk}"
|
||||
# The default is for a NEW client only. An existing client keeps its own URL
|
||||
# unless the operator names one — see "the URL is the one per-client value" below.
|
||||
KIOSK_URL_DEFAULT="https://mirror.reeseapps.com/watch"
|
||||
# The default is the DISPLAY endpoint: /kiosk obeys the shared wall state, so
|
||||
# a device with a keyboard (the /remote page) can arrange a box that has none.
|
||||
# /watch is the operator's page — a wall a person stands at.
|
||||
KIOSK_URL_DEFAULT="https://mirror.reeseapps.com/kiosk"
|
||||
KIOSK_URL="${KIOSK_URL:-}"
|
||||
KIOSK_SESSION="gnome-kiosk-script-wayland"
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
|
||||
@@ -7,9 +7,12 @@
|
||||
# After editing: `sudo systemctl restart gdm` (or log the kiosk user out) to
|
||||
# re-read it. The browser is restarted by the kiosk script's own loop.
|
||||
|
||||
# The wall of every live mirror. `/` is the Share/Watch chooser and needs a
|
||||
# tap to get anywhere, which is wrong for a display nobody is standing at.
|
||||
MIRROR_KIOSK_URL="https://mirror.reeseapps.com/watch"
|
||||
# The controllable wall. `/kiosk` is the display endpoint: it obeys the shared
|
||||
# layout state, so a device with a keyboard (the /remote page, or any
|
||||
# `arrange` frame on /ws) decides what is on stage and whether it is
|
||||
# fullscreen — a box that has no keyboard can still be arranged. `/watch` is
|
||||
# the operator's page: a wall a person stands at.
|
||||
MIRROR_KIOSK_URL="https://mirror.reeseapps.com/kiosk"
|
||||
|
||||
# Optional overrides (uncomment to change):
|
||||
|
||||
|
||||
+8
-1
@@ -316,6 +316,13 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
itself from the shared layout (phase 07/08)."""
|
||||
return _page("kiosk.html")
|
||||
|
||||
@app.get("/remote", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def remote_page() -> FileResponse:
|
||||
"""The controller: lists mirrors and drives the shared layout. Shows no
|
||||
media and subscribes to nothing — a controller must not cost a sharer
|
||||
bandwidth (PLAN.md §6)."""
|
||||
return _page("remote.html")
|
||||
|
||||
@app.get("/api/config")
|
||||
async def client_config() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
@@ -356,7 +363,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
return
|
||||
await hub.serve(websocket)
|
||||
|
||||
PAGES = ("/", "/mirror", "/share", "/watch", "/kiosk")
|
||||
PAGES = ("/", "/mirror", "/share", "/watch", "/kiosk", "/remote")
|
||||
|
||||
@app.middleware("http")
|
||||
async def admission_and_cache_headers(request: Request, call_next): # type: ignore[no-untyped-def]
|
||||
|
||||
@@ -683,6 +683,102 @@ body.staged .stage { display: block; }
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- remote */
|
||||
|
||||
/* /remote is a controller: a readout, the decision buttons and a list of
|
||||
rows — no video, no tiles (PLAN.md §6). It is the phone page of the app,
|
||||
so it must stay usable down to 320 px, where the share page's narrow-
|
||||
viewport rules are the model: wrap, ellipsis, never run off the edge. */
|
||||
|
||||
/* The readout: one strip under the bar in the monospace face — what the
|
||||
server says the wall is doing, verbatim. `aria-live` on the section
|
||||
(markup) is what makes it speak to a screen reader; the CSS only styles. */
|
||||
.wall-state {
|
||||
margin: 14px 16px 0;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-2);
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
|
||||
.wall-state #remote-staged { color: var(--fg); }
|
||||
|
||||
/* The decision buttons: one row that wraps, so at 320 px they stack and
|
||||
stay reachable instead of running off the edge. */
|
||||
.remote-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 14px 16px 0;
|
||||
}
|
||||
|
||||
/* The "on" state of the Full screen button: the same accent treatment the
|
||||
tile's audio button gets when its aria-pressed is true, so "on" looks
|
||||
the same everywhere in this app. */
|
||||
#remote-fullscreen[aria-pressed="true"] {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* The one-line hint under the controls: "no display following yet" (the
|
||||
state is stored — a display pointed at /kiosk picks it up) and
|
||||
"offline". The global `[hidden]` rule is what hides it; no rule of its
|
||||
own may fight that. */
|
||||
.remote-hint {
|
||||
margin: 10px 16px 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
|
||||
/* A readout that is a snapshot, not a statement: the socket dropped, so the
|
||||
last staged label is shown dimmed until the next welcome re-syncs it. */
|
||||
.wall-state #remote-staged[data-stale] {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* One row per mirror: title + sub + the decision. `flex-wrap` is the 320 px
|
||||
escape hatch (the button drops under the title rather than clipping), and
|
||||
`min-width: 0` on the grow is what lets the title ellipsis instead of
|
||||
pushing the button out. */
|
||||
.mirror-list {
|
||||
list-style: none;
|
||||
margin: 14px 16px 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mirror-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.mirror-row .grow { flex: 1; min-width: 0; }
|
||||
.mirror-row .title {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mirror-row .sub {
|
||||
color: var(--fg-dim);
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { animation: none !important; transition: none !important; }
|
||||
}
|
||||
|
||||
@@ -51,6 +51,14 @@
|
||||
itself from the shared layout. No pointer, no keyboard, nothing to set up at the box.
|
||||
</p>
|
||||
</a>
|
||||
<a class="card" href="/remote">
|
||||
<div class="kicker">GET /remote</div>
|
||||
<h2>Point the wall from another device</h2>
|
||||
<p>
|
||||
Open it on a laptop or a phone and drive the shared wall: list the live mirrors,
|
||||
show one on the display, back to the wall, full screen — no video, no bandwidth.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="livebar" id="livebar">Checking for mirrors…</div>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Remote · mirror</title>
|
||||
<link rel="stylesheet" href="/static/app.css" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='12' fill='%234fd1c5'/%3E%3C/svg%3E"
|
||||
/>
|
||||
</head>
|
||||
<!-- A controller, not a viewer: this page shows no media and subscribes to
|
||||
nothing (PLAN.md §6 — the mesh uploads N × bitrate per sharer, so a
|
||||
controller that streamed would cost every sharer a leg). It lists the
|
||||
live mirrors and the wall's state, and sends the decisions as
|
||||
`arrange` frames. On the wire it is a plain viewer — no `kiosk`
|
||||
flag — so `followers` counts displays only, which is what makes
|
||||
"no display following" mean what it says. -->
|
||||
<body class="remote">
|
||||
<header class="bar">
|
||||
<div class="brand"><span class="glyph"></span> mirror</div>
|
||||
<div class="spacer"></div>
|
||||
<span class="net" id="net" data-state="connecting"><i></i><span>connecting…</span></span>
|
||||
</header>
|
||||
|
||||
<div class="notice" id="notice" hidden></div>
|
||||
|
||||
<!-- The readout: what the server says the wall is doing, verbatim —
|
||||
"Wall: <label>" (or "Wall: no mirror"), a fullscreen marker, and the
|
||||
follower clause. Each piece has a stable hook so the script and the
|
||||
tests never have to parse a sentence. The marker span is
|
||||
`remote-fullscreen-mark` (not `remote-fullscreen`): the Full screen
|
||||
*button* below owns that id, and an id declared twice on one page
|
||||
fails the uniqueness guard. -->
|
||||
<section class="wall-state" id="remote-state" aria-live="polite">
|
||||
Wall: <span id="remote-staged">no mirror</span
|
||||
><span id="remote-fullscreen-mark" hidden> · fullscreen</span
|
||||
><span id="remote-followers"> · no display following</span>
|
||||
</section>
|
||||
|
||||
<!-- The three decisions. Task 02 makes them send `arrange` frames and
|
||||
reflects the server's state; they never paint a result locally. -->
|
||||
<div class="remote-controls">
|
||||
<button class="btn" id="remote-back">Back to the wall</button>
|
||||
<button class="btn ghost" id="remote-fullscreen" aria-pressed="false">Full screen</button>
|
||||
</div>
|
||||
|
||||
<!-- The one-line hint under the controls. It appears when the readout
|
||||
says "no display following" (the state is still stored, so a display
|
||||
pointed at /kiosk picks it up immediately) and when the socket is
|
||||
offline. It never disables the controls: the layout is server-side.
|
||||
The hint is hidden by the global `[hidden]` rule, as everything else. -->
|
||||
<p class="remote-hint" id="remote-hint" hidden></p>
|
||||
|
||||
<ul class="mirror-list" id="remote-list" hidden></ul>
|
||||
|
||||
<div class="empty" id="remote-empty">
|
||||
<h2>No mirrors live</h2>
|
||||
<p>
|
||||
Share from <code id="remote-share-url">…</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- One row per mirror: title + sub + the decision. No media. -->
|
||||
<template id="remote-row-template">
|
||||
<li class="mirror-row" data-stream="">
|
||||
<div class="grow">
|
||||
<div class="title">Shared screen</div>
|
||||
<div class="sub"></div>
|
||||
</div>
|
||||
<button class="btn ghost show">Show on wall</button>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<div class="toast" id="toast" hidden></div>
|
||||
|
||||
<script type="module" src="/static/remote.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,308 @@
|
||||
/* The remote page: the hand that drives the wall.
|
||||
|
||||
It lists the live mirrors and the wall's current state, and offers the
|
||||
three decisions (show this, back to the wall, full screen) as `arrange`
|
||||
frames the display obeys. The page's correctness rests on one rule: the
|
||||
only thing it displays about the wall is what the server last told it.
|
||||
A control never paints a result locally and hopes the server agrees —
|
||||
with two controllers open, an optimistic paint is a lie the moment the
|
||||
other one acts, and a lie in the readout is worse than a 40 ms delay.
|
||||
|
||||
What this page never does: show media, or ask for a stream's track —
|
||||
the mesh uploads N × bitrate per sharer, so a controller that consumed
|
||||
a leg just to know what to point at would cost everyone (PLAN.md §6).
|
||||
It keeps a list of mirrors, not tiles: no <video> element, no media
|
||||
connection, no read of the screen-capture APIs — the "no media in a
|
||||
controller" guards in test_app.py enforce it. On the wire it is a plain
|
||||
viewer: no `kiosk` flag in its hello, which is what makes `followers`
|
||||
count displays only, and "no display following" mean what it says.
|
||||
|
||||
There is no key map on this page (AGENTS.md §6): the buttons are native,
|
||||
focusable and phone-tappable, and a second copy of the wall's key map
|
||||
would be a second thing to keep in sync. */
|
||||
|
||||
import {
|
||||
$,
|
||||
connectSignaling,
|
||||
deviceLabel,
|
||||
loadConfig,
|
||||
refusalNotice,
|
||||
showNotice,
|
||||
toast,
|
||||
} from "./net.js";
|
||||
|
||||
const config = await loadConfig();
|
||||
|
||||
// The element-existence guard (test_every_element_the_script_reaches_for_exists)
|
||||
// enforces that every one of these lives in remote.html; the control hooks
|
||||
// are additionally asserted on the markup (test_the_remote_page_declares_its_control_hooks)
|
||||
// so this page and its markup cannot drift.
|
||||
const ui = {
|
||||
state: $("#remote-state"),
|
||||
staged: $("#remote-staged"),
|
||||
fullscreenMark: $("#remote-fullscreen-mark"),
|
||||
followers: $("#remote-followers"),
|
||||
back: $("#remote-back"),
|
||||
fullscreen: $("#remote-fullscreen"),
|
||||
list: $("#remote-list"),
|
||||
template: $("#remote-row-template"),
|
||||
empty: $("#remote-empty"),
|
||||
shareUrl: $("#remote-share-url"),
|
||||
hint: $("#remote-hint"),
|
||||
net: $("#net"),
|
||||
notice: $("#notice"),
|
||||
};
|
||||
|
||||
// The controller is usually the device that goes and asks somebody to share.
|
||||
if (ui.shareUrl) ui.shareUrl.textContent = `${location.origin}/mirror`;
|
||||
|
||||
function setNet(next, detail) {
|
||||
if (!ui.net) return;
|
||||
ui.net.dataset.state = next;
|
||||
const label = ui.net.querySelector("span");
|
||||
if (label) label.textContent = detail || next;
|
||||
}
|
||||
|
||||
const state = {
|
||||
ws: null,
|
||||
// The socket dropped: the readout is a snapshot of the last layout frame,
|
||||
// not a statement about the wall, until the next welcome re-syncs it.
|
||||
offline: false,
|
||||
// A layout frame has landed: the follower clause may only be reported as
|
||||
// fact after the server has actually said it.
|
||||
synced: false,
|
||||
// The mirror that was on the wall died: its label, held so the layout
|
||||
// frame that clears the stage can say "<label> ended — back to the wall"
|
||||
// exactly once.
|
||||
endedStagedLabel: null,
|
||||
};
|
||||
|
||||
/** id -> {id, name, label, displaySurface, width, height, audio}, in server
|
||||
* roster order (welcome.streams is oldest-first, stream_added appends). The
|
||||
* controller keeps a list, not tiles: no <video>, no media stream, no
|
||||
* media connection — see the "no media in a controller" guard in
|
||||
* test_app.py. */
|
||||
const mirrors = new Map();
|
||||
let layout = { staged: null, fullscreen: false, followers: 0 };
|
||||
|
||||
/* -------------------------------------------------------------- rendering */
|
||||
|
||||
function titleOf(stream) {
|
||||
return stream.label || stream.name || "Shared screen";
|
||||
}
|
||||
|
||||
function surfaceShort(setting) {
|
||||
return { window: "window", monitor: "screen", browser: "tab" }[setting] || setting || "";
|
||||
}
|
||||
|
||||
// The same words wall.js's paintTile() prints, so the two pages describe a
|
||||
// mirror identically: `name · surface · W×H · audio|silent`.
|
||||
function subOf(stream) {
|
||||
const dims = stream.width && stream.height ? `${stream.width}×${stream.height}` : "";
|
||||
const parts = [surfaceShort(stream.displaySurface), dims, stream.audio ? "audio" : "silent"].filter(
|
||||
Boolean,
|
||||
);
|
||||
return `${stream.name || "Unknown device"}${parts.length ? ` · ${parts.join(" · ")}` : ""}`;
|
||||
}
|
||||
|
||||
function renderMirrors() {
|
||||
const list = ui.list;
|
||||
list.innerHTML = "";
|
||||
for (const [id, stream] of mirrors) {
|
||||
const row = ui.template.content.firstElementChild.cloneNode(true);
|
||||
row.dataset.stream = id;
|
||||
row.querySelector(".title").textContent = titleOf(stream);
|
||||
row.querySelector(".sub").textContent = subOf(stream);
|
||||
const show = row.querySelector(".show");
|
||||
if (layout.staged === id) {
|
||||
row.dataset.on = "";
|
||||
show.textContent = "On the wall";
|
||||
show.setAttribute("aria-current", "true");
|
||||
} else {
|
||||
show.textContent = "Show on wall";
|
||||
}
|
||||
list.appendChild(row);
|
||||
}
|
||||
const any = mirrors.size > 0;
|
||||
ui.empty.hidden = any;
|
||||
list.hidden = !any;
|
||||
}
|
||||
|
||||
function renderHint() {
|
||||
// The controls stay enabled in every state: the layout is stored
|
||||
// server-side, so a display pointed at /kiosk picks it up the moment it
|
||||
// connects — the hint says that, it never disables anything.
|
||||
if (state.offline) {
|
||||
ui.hint.textContent = "offline — the wall is not following you";
|
||||
ui.hint.hidden = false;
|
||||
return;
|
||||
}
|
||||
if (state.synced && (layout.followers || 0) === 0) {
|
||||
ui.hint.textContent =
|
||||
"no display following yet — the state is stored, so a display pointed at /kiosk picks it up";
|
||||
ui.hint.hidden = false;
|
||||
} else {
|
||||
ui.hint.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function renderState() {
|
||||
// The readout is filled from the last layout frame and nothing else.
|
||||
const stagedId = layout.staged;
|
||||
if (stagedId === null) {
|
||||
ui.staged.textContent = "no mirror";
|
||||
} else if (mirrors.has(stagedId)) {
|
||||
ui.staged.textContent = titleOf(mirrors.get(stagedId));
|
||||
} else {
|
||||
// The frame in transit: the mirror is gone and the server has not
|
||||
// cleared the stage yet.
|
||||
ui.staged.textContent = "(gone)";
|
||||
}
|
||||
|
||||
// Fullscreen is reported, not promised: the marker and the button both
|
||||
// say what the state asks for, and only the state.
|
||||
const full = layout.fullscreen === true;
|
||||
ui.fullscreenMark.hidden = false;
|
||||
ui.fullscreenMark.textContent = full ? " · fullscreen: on" : " · fullscreen: off";
|
||||
ui.fullscreen.setAttribute("aria-pressed", String(full));
|
||||
ui.fullscreen.textContent = full ? "Full screen: on" : "Full screen: off";
|
||||
|
||||
const n = layout.followers || 0;
|
||||
ui.followers.textContent =
|
||||
n === 0 ? " · no display following" : ` · ${n} display${n === 1 ? "" : "s"} following`;
|
||||
|
||||
renderHint();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ intent */
|
||||
|
||||
/**
|
||||
* Send an intent and let the server's `layout` frame paint it. Never update
|
||||
* `layout` here: with two controllers open, an optimistic local paint is a
|
||||
* lie the moment the other one acts, and a lie in the readout is worse
|
||||
* than a 40 ms delay.
|
||||
*/
|
||||
function arrange(patch) {
|
||||
if (!state.ws.send({ type: "arrange", ...patch })) {
|
||||
toast("not connected — nothing sent");
|
||||
}
|
||||
}
|
||||
|
||||
// One listener on the list container (event delegation, the way watch.js
|
||||
// handles per-tile buttons): the rows are re-created on every roster frame,
|
||||
// so a per-row listener would be orphaned by the very render it served.
|
||||
ui.list.addEventListener("click", (event) => {
|
||||
const button = event.target instanceof Element ? event.target.closest(".show") : null;
|
||||
const row = button && button.closest(".mirror-row");
|
||||
if (!row || !row.dataset.stream) return;
|
||||
arrange({ staged: row.dataset.stream });
|
||||
});
|
||||
|
||||
// The two page-level buttons live for the page's whole life: one listener
|
||||
// each, wired once.
|
||||
ui.back.addEventListener("click", () => arrange({ staged: null }));
|
||||
ui.fullscreen.addEventListener("click", () => {
|
||||
// Toggle from the server-reported value, never from the button's own
|
||||
// aria-pressed: with two controllers open, the button may be mid-paint.
|
||||
arrange({ fullscreen: !layout.fullscreen });
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ socket */
|
||||
|
||||
state.ws = connectSignaling({
|
||||
// A refused address never opens a socket (net.js says so via the stub);
|
||||
// the notice is the one thing the page owes the person holding the phone.
|
||||
refused: !!config.refused,
|
||||
refusedReason: config.refusedMessage,
|
||||
onOpen() {
|
||||
// A plain viewer on the wire: role "viewer" and NO `kiosk` flag, so the
|
||||
// layout's `followers` count (displays only) never includes this page.
|
||||
state.ws.send({ type: "hello", role: "viewer", name: "", device: deviceLabel() });
|
||||
},
|
||||
onStatus(next, detail) {
|
||||
setNet(next, detail);
|
||||
},
|
||||
onClose() {
|
||||
// The readout is a snapshot now, not a statement: mark it stale and say
|
||||
// the wall is no longer following. A fresh welcome after reconnect
|
||||
// re-syncs everything and clears the mark.
|
||||
state.offline = true;
|
||||
ui.staged.setAttribute("data-stale", "");
|
||||
renderHint();
|
||||
},
|
||||
onMessage(message) {
|
||||
switch (message.type) {
|
||||
case "welcome": {
|
||||
// Arrival is a full re-sync: the roster and the layout arrive in
|
||||
// one frame, so a reload (or a reconnect) lands already arranged.
|
||||
mirrors.clear();
|
||||
for (const stream of message.streams || []) mirrors.set(stream.id, stream);
|
||||
layout = message.layout || { staged: null, fullscreen: false, followers: 0 };
|
||||
state.offline = false;
|
||||
state.synced = true;
|
||||
state.endedStagedLabel = null;
|
||||
ui.staged.removeAttribute("data-stale");
|
||||
renderMirrors();
|
||||
renderState();
|
||||
break;
|
||||
}
|
||||
case "stream_added":
|
||||
case "stream_updated": {
|
||||
const stream = message.stream;
|
||||
if (stream && stream.id) mirrors.set(stream.id, stream);
|
||||
// An update can change the readout without changing the list: the
|
||||
// staged mirror's label sits in it.
|
||||
renderMirrors();
|
||||
renderState();
|
||||
break;
|
||||
}
|
||||
case "stream_removed": {
|
||||
const id = message.streamId;
|
||||
const stream = mirrors.get(id);
|
||||
if (stream && layout.staged === id) {
|
||||
// The mirror that was on the wall died: hold its label so the
|
||||
// layout frame that clears the stage can say so.
|
||||
state.endedStagedLabel = titleOf(stream);
|
||||
}
|
||||
mirrors.delete(id);
|
||||
renderMirrors();
|
||||
renderState();
|
||||
break;
|
||||
}
|
||||
case "layout": {
|
||||
layout = message;
|
||||
state.synced = true;
|
||||
if (layout.staged === null && state.endedStagedLabel) {
|
||||
// On a controller the change is invisible otherwise: the row is
|
||||
// already gone, and nothing is "on the wall" now.
|
||||
toast(`${state.endedStagedLabel} ended — back to the wall`);
|
||||
state.endedStagedLabel = null;
|
||||
}
|
||||
renderState();
|
||||
renderMirrors();
|
||||
break;
|
||||
}
|
||||
case "signal":
|
||||
// This page never negotiates: a signal is somebody else's
|
||||
// conversation, and it stays ignored even when a sharer misbehaves.
|
||||
break;
|
||||
case "error":
|
||||
// An `unknown_stream` from a stale row is exactly the case where
|
||||
// silence would look like a broken button.
|
||||
toast(message.message || message.code);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Same rule as the other pages: if the server refused this address, say so
|
||||
// rather than showing a controller that will never reach the wall.
|
||||
const refusal = refusalNotice(config);
|
||||
if (refusal) showNotice(ui.notice, refusal);
|
||||
|
||||
// The settled starting line: the empty roster and the idle layout, so the
|
||||
// first paint agrees with the markup before any frame has landed.
|
||||
renderMirrors();
|
||||
renderState();
|
||||
@@ -17,6 +17,7 @@
|
||||
<span class="meta" id="counts">no mirrors</span>
|
||||
<div class="spacer"></div>
|
||||
<span class="net" id="net" data-state="connecting"><i></i><span>connecting…</span></span>
|
||||
<a class="btn ghost" id="remote-link" href="/remote">Drive the wall</a>
|
||||
<a class="btn ghost" id="share-link" href="/mirror">Share a screen</a>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -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