phase: 01_keyboard_kiosk_control
**Phase 01 final verification pass — complete, 100% green** - Verified all 4 tasks done (files in `complete/`); implementation review: key router in `watch.js`, share-page router, CSS focus/selection affordances, `?` overlay, pointer-free E2E suite — all sound. - Gates: `ruff check` + `ruff format --check` ✅ · `pyright` 0 errors ✅ · `uv run pytest` → 223 passed, 2 skipped (realdisplay), 1 pre-existing FastAPI deprecation warning · `MIRROR_REALDISPLAY=1 pytest -m realdisplay` → 2 passed ✅ · `pytest -m e2e` → 51 passed (incl. `test_keyboard_wall.py`, which a meta-test keeps pointer-free) · `node --check` on all static JS ✅. - Completion criteria: all 6 ✅ (j/k+Enter, f/g/n/p/1–9/m/?/Esc keyboard-only E2E; no hover-only controls via CSS guard + focus-ring E2E; `#name` text-safe; e2e/realdisplay green; key map documented in README "Kiosk client" and `docs/kiosk-silverblue.md`). - README fixed: added missing env vars `MIRROR_MAX_BITRATE`, `MIRROR_PING_INTERVAL`, `DEBUGPY_WAIT` to the config table; added the one-time `rpm-ostree` layering note before the kiosk install snippet. - AGENTS.md fixed: removed stale "roadmap is intentionally empty" parenthetical (3 phases pending); coverage ~94% → ~95%. No content added. - **Unresolved, needs your call:** README/SECURITY.md say `mirror.reeseapps.com`; `docs/kiosk-silverblue.md` (reference-client naming table with real IPs) says `mirror.reeselink.com`. I couldn't verify which is the live deployment domain, so I changed neither — say the word and I'll unify. - Next pending phase: `02_sharp_capture`.
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
**Phase 01 final verification pass — complete, 100% green**
|
||||||
|
|
||||||
|
- Verified all 4 tasks done (files in `complete/`); implementation review: key router in `watch.js`, share-page router, CSS focus/selection affordances, `?` overlay, pointer-free E2E suite — all sound.
|
||||||
|
- Gates: `ruff check` + `ruff format --check` ✅ · `pyright` 0 errors ✅ · `uv run pytest` → 223 passed, 2 skipped (realdisplay), 1 pre-existing FastAPI deprecation warning · `MIRROR_REALDISPLAY=1 pytest -m realdisplay` → 2 passed ✅ · `pytest -m e2e` → 51 passed (incl. `test_keyboard_wall.py`, which a meta-test keeps pointer-free) · `node --check` on all static JS ✅.
|
||||||
|
- Completion criteria: all 6 ✅ (j/k+Enter, f/g/n/p/1–9/m/?/Esc keyboard-only E2E; no hover-only controls via CSS guard + focus-ring E2E; `#name` text-safe; e2e/realdisplay green; key map documented in README "Kiosk client" and `docs/kiosk-silverblue.md`).
|
||||||
|
- README fixed: added missing env vars `MIRROR_MAX_BITRATE`, `MIRROR_PING_INTERVAL`, `DEBUGPY_WAIT` to the config table; added the one-time `rpm-ostree` layering note before the kiosk install snippet.
|
||||||
|
- AGENTS.md fixed: removed stale "roadmap is intentionally empty" parenthetical (3 phases pending); coverage ~94% → ~95%. No content added.
|
||||||
|
- **Unresolved, needs your call:** README/SECURITY.md say `mirror.reeseapps.com`; `docs/kiosk-silverblue.md` (reference-client naming table with real IPs) says `mirror.reeselink.com`. I couldn't verify which is the live deployment domain, so I changed neither — say the word and I'll unify.
|
||||||
|
- Next pending phase: `02_sharp_capture`.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
...................................................ss................... [ 32%]
|
||||||
|
........................................................................ [ 64%]
|
||||||
|
........................................................................ [ 96%]
|
||||||
|
......... [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
|
||||||
|
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/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.14.8-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 185 11 94% 108, 113, 117, 182-183, 212-216, 245
|
||||||
|
src/mirror/config.py 68 5 93% 110-111, 118-120
|
||||||
|
src/mirror/debug.py 15 10 33% 19-28
|
||||||
|
src/mirror/protocol.py 59 0 100%
|
||||||
|
src/mirror/registry.py 191 3 98% 166, 180, 355
|
||||||
|
------------------------------------------------------
|
||||||
|
TOTAL 653 32 95%
|
||||||
|
=========================== short test summary info ============================
|
||||||
|
SKIPPED [1] tests/e2e/test_x11_capture.py:220: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||||
|
SKIPPED [1] tests/e2e/test_x11_capture.py:257: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||||
|
223 passed, 2 skipped, 1 warning in 35.82s
|
||||||
|
All checks passed!
|
||||||
|
26 files already formatted
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
All green. Final report:
|
||||||
|
|
||||||
|
**Implemented (task 01 — keyboard router + cursor on `/watch`)**
|
||||||
|
- `watch.js`: removed the per-tile `Enter`/`Space` and global `Escape` listeners; added `state.cursor` (stream-id), `orderedTiles()`, `selectedTile()`, `setSelected()` (focus-follows-selection, `preventScroll`), `moveSelection(delta)` (wraps), and one `onWallKey` router (bails on `defaultPrevented`/Ctrl/Cmd/Alt chords/fields; `j`/`ArrowDown`, `k`/`ArrowUp`, `Escape`→`unstage`; `preventDefault` only for handled keys). Cursor re-seats on `dropTile` (nearest remaining tile, wraps) and tracks the staged tile.
|
||||||
|
- `app.css`: `.tile[data-selected]` border at live-tile weight; `.tile:focus-visible` accent ring.
|
||||||
|
- `tests/e2e/test_keyboard_wall.py`: 6 mouse-free tests (j selects first + focus follows, j/k wrap both ends, arrows, `Control+j/k/ArrowDown` no-ops, Esc unstages, Esc no-op).
|
||||||
|
|
||||||
|
**Gates** — `ruff check` + `ruff format --check` + `pyright`: clean; `uv run pytest`: 193 passed, 2 skipped (realdisplay off); `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay`: 2 passed; `grep -c 'addEventListener("keydown"' watch.js` = 1; `pytest -m e2e -k keyboard`: 6 passed; `pytest tests/test_app.py`: 61 passed. Coverage 95% (no new Python).
|
||||||
|
|
||||||
|
**Docs** — no README/AGENTS.md changes (keymap docs are task 04's scope; no statements went stale).
|
||||||
|
|
||||||
|
**Notable** — the committed phase-04 task file failed `ruff format` (ruff 0.17 now formats markdown code blocks); since I may not edit `todo/` files, I added `extend-exclude = [".agents"]` to `[tool.ruff]` in `pyproject.toml` with a comment explaining why the harness-owned planning tree is outside the code gate.
|
||||||
|
|
||||||
|
**Next pending task** — `02_wall_navigation_commands.md` (Enter/f, g, n/p, 1–9, m in the router).
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
ImportError while loading conftest '/home/ducoterra/Projects/Personal/mirror/tests/conftest.py'.
|
||||||
|
tests/conftest.py:8: in <module>
|
||||||
|
from mirror.app import create_app
|
||||||
|
E ModuleNotFoundError: No module named 'mirror'
|
||||||
|
All checks passed!
|
||||||
|
repo hygiene: TRACKED files look like log drops / key material (untrack with 'git rm --cached <file>' and add a .gitignore rule):
|
||||||
|
.env.example
|
||||||
|
validation FAILED (see output above)
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
100% green. Report:
|
||||||
|
|
||||||
|
**Fixed (validator failures)**
|
||||||
|
- `.agents/validate.sh` ran bare `python3 -m pytest`/`ruff` — in this uv-managed project the package and dev tools live in `.venv`, so `mirror` was unimportable. Adapted the script (as its own header instructs) to the project's real gate: `uv run pytest -q` + `uv run ruff check .` + `uv run ruff format --check .` + `uv run pyright`.
|
||||||
|
- Validator's generic key-material regex over-matched the tracked `.env.example` — a committed config *template* with default/empty values, deliberately re-allowed by `.gitignore` (`!.env.example`). Added a commented per-project allow-list in `validate.sh` (the script's stated customization point) instead of `git rm --cached`, which my rules reserve for files my task file explicitly names. A real `.env`/`.env.local` still fails the gate.
|
||||||
|
- No application code changes from the previous attempt (router, cursor, CSS, E2E suite) were touched — they were already green.
|
||||||
|
|
||||||
|
**Gates** — `bash .agents/validate.sh`: **exit 0** (193 passed / 2 realdisplay-skipped, `ruff check` + `ruff format --check` clean, `pyright` 0 errors, all hygiene checks clean); `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay`: 2 passed; `pytest -m e2e -k keyboard`: 6 passed; `pytest tests/test_app.py`: 61 passed; coverage 95% (no new Python).
|
||||||
|
|
||||||
|
**Docs** — no README/AGENTS.md changes needed (not the phase's final pass; no statements went stale).
|
||||||
|
|
||||||
|
**Notable** — `validate.sh` is untracked harness scaffolding; its two adaptations are the only changes this round.
|
||||||
|
|
||||||
|
**Next pending task** — `02_wall_navigation_commands.md` (Enter/f, g, n/p, 1–9, m in the router).
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
......................ss................................................ [ 36%]
|
||||||
|
........................................................................ [ 73%]
|
||||||
|
................................................... [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
|
||||||
|
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/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.14.8-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 185 12 94% 108, 113, 116-117, 182-183, 212-216, 245
|
||||||
|
src/mirror/config.py 68 5 93% 110-111, 118-120
|
||||||
|
src/mirror/debug.py 15 10 33% 19-28
|
||||||
|
src/mirror/protocol.py 59 0 100%
|
||||||
|
src/mirror/registry.py 191 3 98% 166, 180, 355
|
||||||
|
------------------------------------------------------
|
||||||
|
TOTAL 653 33 95%
|
||||||
|
=========================== short test summary info ============================
|
||||||
|
SKIPPED [1] tests/e2e/test_x11_capture.py:220: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||||
|
SKIPPED [1] tests/e2e/test_x11_capture.py:257: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||||
|
193 passed, 2 skipped, 1 warning in 25.27s
|
||||||
|
All checks passed!
|
||||||
|
26 files already formatted
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
All gates green. Task complete.
|
||||||
|
|
||||||
|
**Report — Task 02 (wall navigation commands)**
|
||||||
|
|
||||||
|
- `watch.js`: extracted `setMuted(tile, muted)` (audio button is now a two-liner calling it); added `Enter`/`f`, `g`, `n`/`p`, `1`–`9`, `m` to the single keydown router (case-insensitive matching; `?` left for task 04); no-op toasts (`no mirror N`, `no mirrors to switch to`, `no mirrors to stage`, `no mirror to mute`, `this mirror has no audio`); `f`-branch comment cites the `--kiosk`/`window-config.ini` reality; `n`/`p` **stage** the neighbour per the task assumption.
|
||||||
|
- **Defect fixed in previously completed work**: staging a *replacement* tile (`n`/`p`, digits, or clicking another tile) unstaged the replacement — `unstage()`'s `exitFullscreen()` fired `fullscreenchange` after `state.staged` was already the new tile, and the handler unstaged it. Reproduced deterministically (6/6) via a throwaway script; fixed with `unstage({ keepFullscreen })` on the replace path (the stage container itself stays fullscreen; no exit, no event).
|
||||||
|
- `tests/e2e/test_keyboard_wall.py`: +10 mouse-free tests — Enter (selected & unselected), `f` both states, `g` both states, `n`/`p` step with same-`<video>`-node + `srcObject` survival, digit jump, `m` flip + `aria-pressed`, missing-number toast, empty-wall toasts, `Shift+J`/`Shift+F`.
|
||||||
|
|
||||||
|
**Gates**: `ruff check` + `ruff format --check` clean; `pyright` 0 errors; `uv run pytest` 203 passed / 2 skipped; `uv run pytest -m e2e` 33 passed (three consecutive full runs); `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` 2 passed; coverage 95% (no new Python — JS covered by E2E per AGENTS.md §5).
|
||||||
|
|
||||||
|
**Docs**: no doc impact (keymap README/`docs/kiosk-silverblue.md` docs are task 04's scope; no README/AGENTS.md statement went stale).
|
||||||
|
|
||||||
|
**Next pending task**: `03_keyboard_reachable_tile_controls.md`.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
................................ss...................................... [ 35%]
|
||||||
|
........................................................................ [ 70%]
|
||||||
|
............................................................. [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
|
||||||
|
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/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.14.8-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 185 12 94% 108, 113, 116-117, 182-183, 212-216, 245
|
||||||
|
src/mirror/config.py 68 5 93% 110-111, 118-120
|
||||||
|
src/mirror/debug.py 15 10 33% 19-28
|
||||||
|
src/mirror/protocol.py 59 0 100%
|
||||||
|
src/mirror/registry.py 191 3 98% 166, 180, 355
|
||||||
|
------------------------------------------------------
|
||||||
|
TOTAL 653 33 95%
|
||||||
|
=========================== short test summary info ============================
|
||||||
|
SKIPPED [1] tests/e2e/test_x11_capture.py:220: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||||
|
SKIPPED [1] tests/e2e/test_x11_capture.py:257: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||||
|
203 passed, 2 skipped, 1 warning in 29.87s
|
||||||
|
All checks passed!
|
||||||
|
26 files already formatted
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
Everything is green. Final state of the work:
|
||||||
|
|
||||||
|
**Task 03 complete — keyboard-reachable tile controls**
|
||||||
|
|
||||||
|
- **`app.css`**: `.tile .actions` now revealed by `:focus-within` *and* `[data-selected]` alongside `:hover` (`@media (hover:none)` kept); staged caption legible via `:focus-within`/`[data-selected]` (quiet 0.28 when unfocused); explicit focus-ring block for `.tile`/`.btn`/`.icon-btn` `:focus-visible`, no `outline: none`
|
||||||
|
- **`watch.js`**: `focusin` handler on `ui.grid` moves selection with Tab; `focusTile()` guard prevents stealing focus off a focused inner button; `stage()` focuses the staged tile; `unstage()` restores focus to the grid — **deferred until the fullscreen exit settles** (Chromium blurs focus on exit, dropping it to `<body>`; found via focus-event tracing), with a race guard so a pending exit can't steal focus from a tile staged in the meantime
|
||||||
|
- **`watch.html`**: no new ids; kept the `<article>` (no `role="button"` — it wraps real buttons, which would be an ARIA violation); existing `aria-label` suffices
|
||||||
|
- **`test_app.py`**: new `TestStylesheet::test_a_hidden_control_is_not_revealed_only_by_hover` — a hidden `.actions` rule requires a non-hover reveal
|
||||||
|
- **`test_keyboard_wall.py`**: 6 new mouse-free tests — Tab reveals `.actions` (opacity 1, no `page.hover()`), visible 2px ring + screenshot artifact, Tab moves `data-selected`, Tab-to-inner-button not stolen, wall stays quiet at rest, focus moves to stage and back with `g`/`Esc`, staged caption opacity 1
|
||||||
|
|
||||||
|
**Gates (all after final code state):**
|
||||||
|
- `uv run ruff check . && uv run ruff format --check .` → clean
|
||||||
|
- `uv run pyright` → 0 errors
|
||||||
|
- `uv run pytest` → 211 passed, 2 skipped (realdisplay-gated)
|
||||||
|
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 2 passed
|
||||||
|
- `grep "opacity: 0" app.css` → the one hidden control has non-hover triggers; no `outline: none` anywhere
|
||||||
|
|
||||||
|
**Docs:** no doc impact — README's kiosk section makes no hover claim (key-map docs are task 04's scope); no AGENTS.md statement went stale.
|
||||||
|
|
||||||
|
**Notable:** fullscreen-exit focus blur required deferring focus restoration; kept the pre-existing `if (!state.staged) return` guard intact while restructuring `unstage()`.
|
||||||
|
|
||||||
|
**Next pending task:** `04_help_overlay_and_keyboard_e2e.md` (`?` overlay, `/mirror` `s`/`x`, kiosk/README key-map docs).
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
.......................................ss............................... [ 33%]
|
||||||
|
........................................................................ [ 67%]
|
||||||
|
..................................................................... [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
|
||||||
|
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/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.14.8-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 185 11 94% 108, 113, 117, 182-183, 212-216, 245
|
||||||
|
src/mirror/config.py 68 5 93% 110-111, 118-120
|
||||||
|
src/mirror/debug.py 15 10 33% 19-28
|
||||||
|
src/mirror/protocol.py 59 0 100%
|
||||||
|
src/mirror/registry.py 191 3 98% 166, 180, 355
|
||||||
|
------------------------------------------------------
|
||||||
|
TOTAL 653 32 95%
|
||||||
|
=========================== short test summary info ============================
|
||||||
|
SKIPPED [1] tests/e2e/test_x11_capture.py:220: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||||
|
SKIPPED [1] tests/e2e/test_x11_capture.py:257: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||||
|
211 passed, 2 skipped, 1 warning in 32.21s
|
||||||
|
All checks passed!
|
||||||
|
26 files already formatted
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
Both screenshots confirm the overlay (and the staged tile with legible caption) render correctly — the second shot shows the toggle closed it, exactly per the `?` semantics. All done; the full gate is green.
|
||||||
|
|
||||||
|
## Report
|
||||||
|
|
||||||
|
**Implemented (task 04):**
|
||||||
|
- `watch.html`: `#wall-help` overlay after `#watch-stage` — static `<dl>` of the locked key map + "Press `?` to close this list" hint
|
||||||
|
- `app.css`: `.help` centred `position: fixed` panel (z-index 70 > stage's 50), wall-readable font sizes, `kbd` styling; hidden purely via the global `[hidden]` rule
|
||||||
|
- `watch.js`: `ui.help` lookup, `toggleHelp()`, `?` case, `Esc` closes the overlay *before* unstaging; focus never touched (verified: `?`→`?`→`Esc` stays on the tile)
|
||||||
|
- `share.js`: small own router (`s`→start, `x`→stop, `Esc` kept), Ctrl/Cmd/Alt and `#name`-focus guards, user-activation comment (why `s` works), no-silent-failure toasts, `state.picking` guard so a second `s` can't open two pickers
|
||||||
|
- `tests/e2e/test_keyboard_wall.py`: 35 tests — `?` open/close/over-stage/Esc-ordering/focus, `/mirror` `s`/`x` real capture, name-field typing, Ctrl chords, and the **real** non-secure-context trap (server on `http://192.168.4.2`: `mediaDevices` undefined, `#start` disabled, `s` re-shows the notice) + pointer-free meta-test (built from string parts to avoid self-referencing the ban)
|
||||||
|
- Docs: README "Kiosk client" **Keys** table + kiosk-silverblue.md "Operating the wall: keys" section
|
||||||
|
|
||||||
|
**Gates (all green):**
|
||||||
|
- `uv run ruff check . && uv run ruff format --check .` — clean
|
||||||
|
- `uv run pyright` — 0 errors
|
||||||
|
- `uv run pytest` — 223 passed, 2 skipped (opt-in realdisplay)
|
||||||
|
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — 2 passed
|
||||||
|
- Coverage (fast suite, phase adds tests only): 95%
|
||||||
|
|
||||||
|
**Docs:** README + kiosk doc updated per step 9; AGENTS.md unchanged (no stale facts).
|
||||||
|
|
||||||
|
**Decisions:** meta-test constructs `"cli"+"ck("`/`"hover"+"("` so the ban doesn't trip on itself; `s`-while-live and `x`-while-idle toast (project's no-silent-no-op rule); insecure-context test uses a real LAN-IP origin (verified the trap reproduces) rather than stubbing.
|
||||||
|
|
||||||
|
**Next pending task:** none — task 04 is the last of phase 01; the phase can be validated/committed.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
...................................................ss................... [ 32%]
|
||||||
|
........................................................................ [ 64%]
|
||||||
|
........................................................................ [ 96%]
|
||||||
|
......... [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
|
||||||
|
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/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.14.8-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 185 11 94% 108, 113, 117, 182-183, 212-216, 245
|
||||||
|
src/mirror/config.py 68 5 93% 110-111, 118-120
|
||||||
|
src/mirror/debug.py 15 10 33% 19-28
|
||||||
|
src/mirror/protocol.py 59 0 100%
|
||||||
|
src/mirror/registry.py 191 3 98% 166, 180, 355
|
||||||
|
------------------------------------------------------
|
||||||
|
TOTAL 653 32 95%
|
||||||
|
=========================== short test summary info ============================
|
||||||
|
SKIPPED [1] tests/e2e/test_x11_capture.py:220: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||||
|
SKIPPED [1] tests/e2e/test_x11_capture.py:257: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||||
|
223 passed, 2 skipped, 1 warning in 35.43s
|
||||||
|
All checks passed!
|
||||||
|
26 files already formatted
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
@@ -36,9 +36,6 @@ is a roster and a signaling relay. Linux is the reference platform.
|
|||||||
decision, and record the reason in the same change.
|
decision, and record the reason in the same change.
|
||||||
* A phase is not done until every gate in §4 passes.
|
* A phase is not done until every gate in §4 passes.
|
||||||
|
|
||||||
*(The roadmap is intentionally empty at the prototype stage: a working prototype
|
|
||||||
came first, and the candidate phase list is §7 of `PLAN.md`.)*
|
|
||||||
|
|
||||||
## 3. Locked decisions that are easy to break by accident
|
## 3. Locked decisions that are easy to break by accident
|
||||||
|
|
||||||
| Rule | Why it exists |
|
| Rule | Why it exists |
|
||||||
@@ -64,7 +61,7 @@ uv run pytest # unit + API + browser E2E (~20s)
|
|||||||
MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay # Linux-native X11 capture
|
MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay # Linux-native X11 capture
|
||||||
```
|
```
|
||||||
|
|
||||||
Coverage is ~94%; the media path is covered by browser assertions, not by unit
|
Coverage is ~95%; the media path is covered by browser assertions, not by unit
|
||||||
tests pretending to be WebRTC.
|
tests pretending to be WebRTC.
|
||||||
|
|
||||||
## 5. Browser testing rules
|
## 5. Browser testing rules
|
||||||
|
|||||||
@@ -65,10 +65,13 @@ Environment variables (all optional; a `.env` file is loaded if present):
|
|||||||
| `MIRROR_PORT` | `8055` | Bind port. |
|
| `MIRROR_PORT` | `8055` | Bind port. |
|
||||||
| `MIRROR_ICE_SERVERS` | `[]` | JSON array of RTCIceServer objects, if peers cannot reach each other directly. |
|
| `MIRROR_ICE_SERVERS` | `[]` | JSON array of RTCIceServer objects, if peers cannot reach each other directly. |
|
||||||
| `MIRROR_MAX_MIRRORS` | `0` | Cap on concurrent mirrors (`0` = unlimited). |
|
| `MIRROR_MAX_MIRRORS` | `0` | Cap on concurrent mirrors (`0` = unlimited). |
|
||||||
|
| `MIRROR_MAX_BITRATE` | `12000000` | Per-leg WebRTC bitrate cap in bits/s — the mesh uploads N × bitrate per sharer, so this is the ceiling. |
|
||||||
|
| `MIRROR_PING_INTERVAL` | `20` | Seconds between socket liveness pings. |
|
||||||
| `MIRROR_ALLOW_CIDRS` | *(unset)* | Comma-separated source networks allowed to connect, e.g. `10.2.0.0/22,fd00::/64`. Unset means unrestricted. |
|
| `MIRROR_ALLOW_CIDRS` | *(unset)* | Comma-separated source networks allowed to connect, e.g. `10.2.0.0/22,fd00::/64`. Unset means unrestricted. |
|
||||||
| `MIRROR_TRUSTED_PROXIES` | `127.0.0.0/8, ::1/128` | Which peers may speak for a client via `X-Forwarded-For`. Only a proxy that overwrites the header belongs here. |
|
| `MIRROR_TRUSTED_PROXIES` | `127.0.0.0/8, ::1/128` | Which peers may speak for a client via `X-Forwarded-For`. Only a proxy that overwrites the header belongs here. |
|
||||||
| `DEBUGPY` | `0` | `1` opens a debugpy listener on `DEBUGPY_PORT`. |
|
| `DEBUGPY` | `0` | `1` opens a debugpy listener on `DEBUGPY_PORT`. |
|
||||||
| `DEBUGPY_PORT` | `5678` | debugpy port. |
|
| `DEBUGPY_PORT` | `5678` | debugpy port. |
|
||||||
|
| `DEBUGPY_WAIT` | `0` | `1` blocks the server at start until a debugger attaches. |
|
||||||
|
|
||||||
## Platform notes
|
## Platform notes
|
||||||
|
|
||||||
@@ -113,7 +116,9 @@ from this side:
|
|||||||
A wall display is a Fedora Silverblue box running the locked-down GNOME Kiosk
|
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
|
session, auto-logged in as `kiosk`, with Firefox in `--kiosk` mode pointed at
|
||||||
`/watch`. It is set up and repaired entirely over SSH — no keyboard, no console,
|
`/watch`. It is set up and repaired entirely over SSH — no keyboard, no console,
|
||||||
no monitor:
|
no monitor. 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:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scp -r docs/kiosk-silverblue client:/var/tmp/ && ssh client \
|
scp -r docs/kiosk-silverblue client:/var/tmp/ && ssh client \
|
||||||
@@ -128,6 +133,27 @@ peer state for the media, and a probe page for the rendered resolution. The
|
|||||||
runbook records why each file lives where it does on an
|
runbook records why each file lives where it does on an
|
||||||
otherwise read-only OS.
|
otherwise read-only OS.
|
||||||
|
|
||||||
|
**Keys.** The whole wall works from a keyboard — and the box needs no keyboard
|
||||||
|
attached to be operated *or* debugged; the keys are there when one is plugged
|
||||||
|
in. On `/watch`:
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| --- | --- |
|
||||||
|
| `j` / `↓` · `k` / `↑` | Select the next / previous mirror (wraps). |
|
||||||
|
| `Enter` / `f` | Stage the selected mirror fullscreen (`f` re-enters fullscreen when already staged). |
|
||||||
|
| `g` | Back to the wall. |
|
||||||
|
| `n` / `p` | Stage the next / previous mirror. |
|
||||||
|
| `1`–`9` | Stage the Nth mirror. |
|
||||||
|
| `m` | Mute / unmute the staged mirror. |
|
||||||
|
| `?` | Show / hide this list on screen. |
|
||||||
|
| `Esc` | Close the key list, then leave the stage. |
|
||||||
|
|
||||||
|
On `/mirror`: `s` starts a share, `x` stops one, `Esc` stops while live. Keys
|
||||||
|
with `Ctrl`/`Cmd`/`Alt` are never intercepted, and a key typed in the display-
|
||||||
|
name field is text, not a command. `?` is the only key that needs Shift, and it
|
||||||
|
is matched on the character itself — on a layout where `?` is a dead key the
|
||||||
|
list is not reachable, so keep this table to hand.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -91,6 +91,32 @@ sudo pkill -u kiosk firefox # loop notices within 5s and relaunches
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Operating the wall: keys
|
||||||
|
|
||||||
|
The whole wall works from a keyboard — and the box needs no keyboard attached
|
||||||
|
to be operated *or* debugged: everything about it is checkable over SSH (see
|
||||||
|
[Verifying a kiosk you cannot see](#verifying-a-kiosk-you-cannot-see)). The keys
|
||||||
|
are there when one is plugged in. On `/watch`:
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|---|---|
|
||||||
|
| `j` / `↓` · `k` / `↑` | select the next / previous mirror (wraps) |
|
||||||
|
| `Enter` / `f` | stage the selected mirror fullscreen (`f` re-enters fullscreen) |
|
||||||
|
| `g` | back to the wall |
|
||||||
|
| `n` / `p` | stage the next / previous mirror |
|
||||||
|
| `1`–`9` | stage the Nth mirror |
|
||||||
|
| `m` | mute / unmute the staged mirror |
|
||||||
|
| `?` | show / hide the key list on screen |
|
||||||
|
| `Esc` | close the key list, then leave the stage |
|
||||||
|
|
||||||
|
On `/mirror`: `s` starts a share, `x` stops one, `Esc` stops while live. Keys
|
||||||
|
with `Ctrl`/`Cmd`/`Alt` are never intercepted, and a key typed in the
|
||||||
|
display-name field is text, not a command. `?` is the only key that needs
|
||||||
|
Shift, and it is matched on the character itself, so on a layout where `?` is a
|
||||||
|
dead key the list is not reachable — keep this table to hand.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Why each piece is where it is
|
## Why each piece is where it is
|
||||||
|
|
||||||
**Auto-login — `/etc/gdm/custom.conf`**
|
**Auto-login — `/etc/gdm/custom.conf`**
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ packages = ["src/mirror"]
|
|||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = "py313"
|
target-version = "py313"
|
||||||
src = ["src", "tests"]
|
src = ["src", "tests"]
|
||||||
|
# The phase tree is harness-managed planning documents, not code: ruff 0.17+
|
||||||
|
# formats the code blocks inside them, and a phase author's snippet must not
|
||||||
|
# be able to fail the project gate (nor may an executor reformat a phase file
|
||||||
|
# to fix it — the harness owns those paths).
|
||||||
|
extend-exclude = [".agents"]
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "W", "I", "UP", "B", "C4", "SIM", "ARG", "RUF"]
|
select = ["E", "F", "W", "I", "UP", "B", "C4", "SIM", "ARG", "RUF"]
|
||||||
|
|||||||
+101
-2
@@ -319,6 +319,24 @@ ul.audience .empty { border: 1px dashed var(--line); color: var(--fg-dim); justi
|
|||||||
.tile:hover { border-color: #3a4753; }
|
.tile:hover { border-color: #3a4753; }
|
||||||
.tile[data-state="live"] { border-color: rgba(79, 209, 197, 0.55); }
|
.tile[data-state="live"] { border-color: rgba(79, 209, 197, 0.55); }
|
||||||
|
|
||||||
|
/* Keyboard cursor (phase 01): the selected tile carries the same border
|
||||||
|
weight as a live one, and keyboard focus draws a ring that is not the
|
||||||
|
hover ring. The kiosk has no mouse, so the ring is how a person sees
|
||||||
|
where the cursor is. */
|
||||||
|
.tile[data-selected] { border-color: rgba(79, 209, 197, 0.55); }
|
||||||
|
|
||||||
|
/* Focus ring on every element the wall's key router can land on — the tile
|
||||||
|
itself, and the buttons inside it. The blanket :focus-visible above already
|
||||||
|
draws these; the explicit block documents the contract and keeps it if the
|
||||||
|
blanket is ever deleted. Never a blanket `outline: none` here: on a
|
||||||
|
mouseless kiosk the ring is how a person sees where they are. */
|
||||||
|
.tile:focus-visible,
|
||||||
|
.btn:focus-visible,
|
||||||
|
.icon-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.tile video { width: 100%; aspect-ratio: 16 / 9; object-fit: contain; display: block; background: #05070a; }
|
.tile video { width: 100%; aspect-ratio: 16 / 9; object-fit: contain; display: block; background: #05070a; }
|
||||||
|
|
||||||
.tile .cap {
|
.tile .cap {
|
||||||
@@ -348,11 +366,88 @@ ul.audience .empty { border: 1px dashed var(--line); color: var(--fg-dim); justi
|
|||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The tile's controls start hidden so the wall stays quiet on a shared
|
||||||
|
display. A kiosk has no mouse, so a :hover-only reveal would make them
|
||||||
|
unusable: keyboard focus (Tab / j/k) and the selected tile reveal them too,
|
||||||
|
and the selected tile carries its controls in a screenshot. */
|
||||||
.tile .actions { position: absolute; top: 8px; right: 8px; display: flex; gap: 6px; opacity: 0; transition: opacity 0.15s ease; }
|
.tile .actions { position: absolute; top: 8px; right: 8px; display: flex; gap: 6px; opacity: 0; transition: opacity 0.15s ease; }
|
||||||
.tile:hover .actions, .tile:focus-within .actions { opacity: 1; }
|
.tile:hover .actions,
|
||||||
|
.tile:focus-within .actions,
|
||||||
|
.tile[data-selected] .actions { opacity: 1; }
|
||||||
|
|
||||||
|
/* Touch devices have no hover at all: keep the existing escape hatch. */
|
||||||
@media (hover: none) { .tile .actions { opacity: 1; } }
|
@media (hover: none) { .tile .actions { opacity: 1; } }
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ key list (?) */
|
||||||
|
|
||||||
|
/* The key list: centred over the wall — and above the stage (50) — sized to
|
||||||
|
be read from across the room on a wall display. It is shown and hidden by
|
||||||
|
the global `[hidden]` rule: .help deliberately sets no `display` of its own,
|
||||||
|
so it never fights that rule (TestStylesheet). */
|
||||||
|
.help {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 70;
|
||||||
|
width: min(760px, calc(100vw - 48px));
|
||||||
|
max-height: calc(100vh - 72px);
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 32px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help h2 {
|
||||||
|
margin: 0 0 22px;
|
||||||
|
font-size: 30px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help dl {
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 300px) 1fr;
|
||||||
|
gap: 14px 32px;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help dt {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 18px;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--fg-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 1.8em;
|
||||||
|
padding: 2px 9px;
|
||||||
|
margin: 0 2px;
|
||||||
|
text-align: center;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.82em;
|
||||||
|
color: var(--fg);
|
||||||
|
background: var(--bg-3);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help .hint {
|
||||||
|
margin: 26px 0 0;
|
||||||
|
font-size: 17px;
|
||||||
|
color: var(--fg-dim);
|
||||||
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
padding: 70px 20px;
|
padding: 70px 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -405,8 +500,12 @@ body.staged #watch-stage { display: block; }
|
|||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The staged caption is quiet (0.28) so the video is what is being watched;
|
||||||
|
a kiosk has no mouse, so keyboard focus and the selection state are the
|
||||||
|
way to read it back. */
|
||||||
#watch-stage .tile:hover .cap,
|
#watch-stage .tile:hover .cap,
|
||||||
#watch-stage .tile:focus-within .cap { opacity: 1; }
|
#watch-stage .tile:focus-within .cap,
|
||||||
|
#watch-stage .tile[data-selected] .cap { opacity: 1; }
|
||||||
#watch-stage .tile .actions { opacity: 1; }
|
#watch-stage .tile .actions { opacity: 1; }
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ const rtc = rtcConfig(config);
|
|||||||
const state = {
|
const state = {
|
||||||
ws: null,
|
ws: null,
|
||||||
stream: null,
|
stream: null,
|
||||||
|
/** True while the OS picker is up, so a second `s` (or a fast double
|
||||||
|
* start) cannot open two pickers. */
|
||||||
|
picking: false,
|
||||||
startedAt: 0,
|
startedAt: 0,
|
||||||
/** viewerId -> {pc, name, pending: [], state} */
|
/** viewerId -> {pc, name, pending: [], state} */
|
||||||
peers: new Map(),
|
peers: new Map(),
|
||||||
@@ -152,13 +155,19 @@ function publish() {
|
|||||||
async function start() {
|
async function start() {
|
||||||
const support = captureSupport();
|
const support = captureSupport();
|
||||||
if (!support.ok) {
|
if (!support.ok) {
|
||||||
|
// The one place a refused capture says why: a key press routes here too
|
||||||
|
// (the `s` key calls this same function), so a disabled #start never
|
||||||
|
// fails silently — the notice is (re)shown instead.
|
||||||
showNotice(ui.notice, support);
|
showNotice(ui.notice, support);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (state.picking) return; // a picker is already up; the second `s` must not open two
|
||||||
|
state.picking = true;
|
||||||
ui.start.disabled = true;
|
ui.start.disabled = true;
|
||||||
try {
|
try {
|
||||||
state.stream = await pickSurface();
|
state.stream = await pickSurface();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
state.picking = false;
|
||||||
ui.start.disabled = false;
|
ui.start.disabled = false;
|
||||||
if (err && err.name === "NotAllowedError") {
|
if (err && err.name === "NotAllowedError") {
|
||||||
toast("Sharing was cancelled or blocked.");
|
toast("Sharing was cancelled or blocked.");
|
||||||
@@ -167,6 +176,7 @@ async function start() {
|
|||||||
toast(`Could not start capture: ${err && err.name ? err.name : err}`);
|
toast(`Could not start capture: ${err && err.name ? err.name : err}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
state.picking = false;
|
||||||
ui.start.disabled = false;
|
ui.start.disabled = false;
|
||||||
|
|
||||||
const video = state.stream.getVideoTracks()[0];
|
const video = state.stream.getVideoTracks()[0];
|
||||||
@@ -419,8 +429,49 @@ window.addEventListener("beforeunload", () => {
|
|||||||
if (state.ws) state.ws.send({ type: "unpublish" });
|
if (state.ws) state.ws.send({ type: "unpublish" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* --------------------------------------------------------- keyboard (share) */
|
||||||
|
|
||||||
|
/* The share page's own small router: the two pages do not share a key
|
||||||
|
handler, and duplicating these lines is cheaper than a new abstraction over
|
||||||
|
net.js. Same rules as the wall: chords with Ctrl/Cmd/Alt belong to the
|
||||||
|
browser, a key in #name is text (it is the one real text box), and only
|
||||||
|
handled keys are preventDefault'ed. The commands: `s` → start, `x` → stop,
|
||||||
|
and the existing `Esc` → stop while live.
|
||||||
|
|
||||||
|
A keydown is a user activation: that is the only reason `s` can call
|
||||||
|
getDisplayMedia() at all — the permission policy allows the OS picker to
|
||||||
|
open without a pointer gesture, which is why `s` works on a mouseless
|
||||||
|
kiosk. A silent no-op is the one outcome a keyboard-only operator cannot
|
||||||
|
debug, so every handled key either changes state or toasts why it did not. */
|
||||||
document.addEventListener("keydown", (event) => {
|
document.addEventListener("keydown", (event) => {
|
||||||
if (event.key === "Escape" && state.stream) stop();
|
if (event.defaultPrevented) return;
|
||||||
|
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
||||||
|
const target = event.target;
|
||||||
|
if (
|
||||||
|
target instanceof HTMLInputElement ||
|
||||||
|
target instanceof HTMLTextAreaElement ||
|
||||||
|
target instanceof HTMLSelectElement ||
|
||||||
|
target.isContentEditable
|
||||||
|
) {
|
||||||
|
return; // a key typed in #name is text, not a command
|
||||||
|
}
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
if (key === "s") {
|
||||||
|
if (state.stream) {
|
||||||
|
toast("already sharing; x stops it");
|
||||||
|
} else {
|
||||||
|
// The button's own path: start() re-checks captureSupport() and shows
|
||||||
|
// the notice, so a disabled #start never fails silently.
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (key === "x") {
|
||||||
|
if (state.stream) stop();
|
||||||
|
else toast("not sharing");
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (key === "escape" && state.stream) {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// A refused address outranks every other diagnosis: capture would work and the
|
// A refused address outranks every other diagnosis: capture would work and the
|
||||||
|
|||||||
@@ -34,6 +34,35 @@
|
|||||||
<!-- A staged tile is moved into this container, so its video never restarts. -->
|
<!-- A staged tile is moved into this container, so its video never restarts. -->
|
||||||
<div id="watch-stage" aria-live="polite"></div>
|
<div id="watch-stage" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<!-- The key list: on a mouseless kiosk `?` is the only way to see the key
|
||||||
|
map, so it lives in the page (static markup) where a translator can
|
||||||
|
find the strings. /watch-only id: the only ids two pages may share are
|
||||||
|
net, notice, toast. -->
|
||||||
|
<div class="help" id="wall-help" hidden>
|
||||||
|
<h2>Keys</h2>
|
||||||
|
<dl>
|
||||||
|
<dt><kbd>j</kbd> / <kbd>↓</kbd></dt>
|
||||||
|
<dd>Select the next mirror (wraps)</dd>
|
||||||
|
<dt><kbd>k</kbd> / <kbd>↑</kbd></dt>
|
||||||
|
<dd>Select the previous mirror (wraps)</dd>
|
||||||
|
<dt><kbd>Enter</kbd> / <kbd>f</kbd></dt>
|
||||||
|
<dd>Stage the selected mirror fullscreen</dd>
|
||||||
|
<dt><kbd>g</kbd></dt>
|
||||||
|
<dd>Back to the wall</dd>
|
||||||
|
<dt><kbd>n</kbd> / <kbd>p</kbd></dt>
|
||||||
|
<dd>Stage the next / previous mirror</dd>
|
||||||
|
<dt><kbd>1</kbd>–<kbd>9</kbd></dt>
|
||||||
|
<dd>Stage the Nth mirror</dd>
|
||||||
|
<dt><kbd>m</kbd></dt>
|
||||||
|
<dd>Mute / unmute the staged mirror</dd>
|
||||||
|
<dt><kbd>?</kbd></dt>
|
||||||
|
<dd>Show / hide this list</dd>
|
||||||
|
<dt><kbd>Esc</kbd></dt>
|
||||||
|
<dd>Close this list, then leave the stage</dd>
|
||||||
|
</dl>
|
||||||
|
<p class="hint">Press <kbd>?</kbd> to close this list.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="toast" id="toast" hidden></div>
|
<div class="toast" id="toast" hidden></div>
|
||||||
|
|
||||||
<template id="tile-template">
|
<template id="tile-template">
|
||||||
|
|||||||
+284
-23
@@ -14,6 +14,9 @@ const ui = {
|
|||||||
notice: $("#notice"),
|
notice: $("#notice"),
|
||||||
template: $("#tile-template"),
|
template: $("#tile-template"),
|
||||||
share: $("#share-link"),
|
share: $("#share-link"),
|
||||||
|
// The element-existence guard (test_every_element_the_script_reaches_for_exists)
|
||||||
|
// enforces that #wall-help is in watch.html.
|
||||||
|
help: $("#wall-help"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = await loadConfig();
|
const config = await loadConfig();
|
||||||
@@ -28,6 +31,7 @@ const state = {
|
|||||||
stageParent: null,
|
stageParent: null,
|
||||||
stageNext: null,
|
stageNext: null,
|
||||||
ticker: 0,
|
ticker: 0,
|
||||||
|
cursor: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
/* -------------------------------------------------------------- rendering */
|
/* -------------------------------------------------------------- rendering */
|
||||||
@@ -78,22 +82,9 @@ function makeTile(stream) {
|
|||||||
if (state.staged === stream.id) return;
|
if (state.staged === stream.id) return;
|
||||||
stage(stream.id, { fullscreen: true });
|
stage(stream.id, { fullscreen: true });
|
||||||
});
|
});
|
||||||
node.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
|
||||||
event.preventDefault();
|
|
||||||
stage(stream.id, { fullscreen: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tile.audioBtn.addEventListener("click", (event) => {
|
tile.audioBtn.addEventListener("click", (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const unmuted = tile.video.muted;
|
setMuted(tile, !tile.video.muted);
|
||||||
tile.video.muted = !unmuted;
|
|
||||||
tile.video.volume = 1;
|
|
||||||
tile.audioBtn.textContent = unmuted ? "🔊" : "🔇";
|
|
||||||
tile.audioBtn.setAttribute("aria-pressed", String(unmuted));
|
|
||||||
tile.audioBtn.title = unmuted ? "Mute" : "Unmute";
|
|
||||||
if (unmuted) tile.video.play().catch(() => {});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
tile.exitBtn.hidden = true;
|
tile.exitBtn.hidden = true;
|
||||||
@@ -129,11 +120,23 @@ function paintTile(tile, stream) {
|
|||||||
function dropTile(streamId) {
|
function dropTile(streamId) {
|
||||||
const tile = tiles.get(streamId);
|
const tile = tiles.get(streamId);
|
||||||
if (!tile) return;
|
if (!tile) return;
|
||||||
|
const wasCursor = state.cursor === streamId;
|
||||||
|
const slot = wasCursor ? orderedTiles().findIndex((t) => t.id === streamId) : -1;
|
||||||
if (state.staged === streamId) unstage();
|
if (state.staged === streamId) unstage();
|
||||||
tiles.delete(streamId);
|
tiles.delete(streamId);
|
||||||
closePeer(tile);
|
closePeer(tile);
|
||||||
tile.el.remove();
|
tile.el.remove();
|
||||||
renderCounts();
|
renderCounts();
|
||||||
|
if (wasCursor) {
|
||||||
|
const remaining = orderedTiles();
|
||||||
|
if (remaining.length === 0) {
|
||||||
|
state.cursor = null;
|
||||||
|
} else {
|
||||||
|
// The tile that takes over the dropped slot is the nearest; if the
|
||||||
|
// dropped tile was last, wrap to the first (the cursor wraps too).
|
||||||
|
setSelected(remaining[slot % remaining.length].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePeer(tile) {
|
function closePeer(tile) {
|
||||||
@@ -225,19 +228,51 @@ async function onSignal(message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------- audio */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one place mute state changes: the tile's audio button, the `m` key,
|
||||||
|
* and the later audio policy (phase 03) all go through here, so the video's
|
||||||
|
* `muted`/`volume`, the button text, `aria-pressed` and `title` cannot drift
|
||||||
|
* apart. Kept as `(tile, muted)` on purpose.
|
||||||
|
*/
|
||||||
|
function setMuted(tile, muted) {
|
||||||
|
tile.video.muted = muted;
|
||||||
|
tile.video.volume = 1;
|
||||||
|
if (!muted) tile.video.play().catch(() => {}); // a muted play may be suspended
|
||||||
|
tile.audioBtn.textContent = muted ? "🔇" : "🔊";
|
||||||
|
tile.audioBtn.setAttribute("aria-pressed", String(!muted));
|
||||||
|
tile.audioBtn.title = muted ? "Unmute" : "Mute";
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ stage */
|
/* ------------------------------------------------------------------ stage */
|
||||||
|
|
||||||
export function stage(streamId, options = {}) {
|
export function stage(streamId, options = {}) {
|
||||||
const tile = tiles.get(streamId);
|
const tile = tiles.get(streamId);
|
||||||
if (!tile) return;
|
if (!tile) return;
|
||||||
if (state.staged && state.staged !== streamId) unstage();
|
if (state.staged && state.staged !== streamId) {
|
||||||
|
// Replacing the tile on stage: keep the stage's fullscreen. The fullscreen
|
||||||
|
// element is the stage container itself, so the replacement moves into a
|
||||||
|
// stage that is already fullscreen. Exiting first would deliver a
|
||||||
|
// fullscreenchange event after state.staged is already the new tile, and
|
||||||
|
// the handler would unstage the replacement — n/p stepping, 1-9 jumps and
|
||||||
|
// clicking another tile all go through this path on a kiosk.
|
||||||
|
unstage({ keepFullscreen: true });
|
||||||
|
}
|
||||||
|
|
||||||
state.stageParent = tile.el.parentNode;
|
state.stageParent = tile.el.parentNode;
|
||||||
state.stageNext = tile.el.nextSibling;
|
state.stageNext = tile.el.nextSibling;
|
||||||
ui.stage.appendChild(tile.el);
|
ui.stage.appendChild(tile.el);
|
||||||
document.body.classList.add("staged");
|
document.body.classList.add("staged");
|
||||||
state.staged = streamId;
|
state.staged = streamId;
|
||||||
|
// The cursor sits on whatever is on stage, so the selection stays coherent
|
||||||
|
// while the wall is hidden behind it.
|
||||||
|
state.cursor = streamId;
|
||||||
|
tile.el.dataset.selected = "";
|
||||||
tile.exitBtn.hidden = false;
|
tile.exitBtn.hidden = false;
|
||||||
|
// The cursor moves with the stage: focus the staged tile so Esc/m/g keep
|
||||||
|
// working no matter what held focus before (the grid is hidden behind it).
|
||||||
|
tile.el.focus({ preventScroll: true });
|
||||||
|
|
||||||
if (options.fullscreen !== false) {
|
if (options.fullscreen !== false) {
|
||||||
const target = ui.stage;
|
const target = ui.stage;
|
||||||
@@ -250,9 +285,9 @@ export function stage(streamId, options = {}) {
|
|||||||
tile.video.play().catch(() => {});
|
tile.video.play().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unstage() {
|
export function unstage(options = {}) {
|
||||||
if (!state.staged) return;
|
if (!state.staged) return;
|
||||||
const tile = tiles.get(state.staged);
|
const tile = state.staged ? tiles.get(state.staged) : null;
|
||||||
state.staged = null;
|
state.staged = null;
|
||||||
document.body.classList.remove("staged");
|
document.body.classList.remove("staged");
|
||||||
if (tile) {
|
if (tile) {
|
||||||
@@ -265,8 +300,24 @@ export function unstage() {
|
|||||||
}
|
}
|
||||||
state.stageParent = null;
|
state.stageParent = null;
|
||||||
state.stageNext = null;
|
state.stageNext = null;
|
||||||
if (document.fullscreenElement && document.exitFullscreen) {
|
const restoreFocus = () => {
|
||||||
document.exitFullscreen().catch(() => {});
|
// The tile is back in the grid at its old position; the cursor goes with
|
||||||
|
// it. The grid's focusin handler then agrees the selection with focus —
|
||||||
|
// focusTile() declines when focus is already on the tile, so this cannot
|
||||||
|
// loop. If a different tile was staged in the meantime (the exit is
|
||||||
|
// asynchronous), that tile already holds the cursor, so don't steal it.
|
||||||
|
if (tile && state.staged !== tile.id) tile.el.focus({ preventScroll: true });
|
||||||
|
};
|
||||||
|
// keepFullscreen: the caller is re-staging into the same stage container in
|
||||||
|
// the same tick, so its fullscreen (if granted) is left in place and its
|
||||||
|
// focus() lands the cursor on the new tile.
|
||||||
|
if (!options.keepFullscreen && document.fullscreenElement && document.exitFullscreen) {
|
||||||
|
// Exiting fullscreen makes the browser re-validate focus and drops it to
|
||||||
|
// <body>, which would cancel a focus() made earlier in this tick — so
|
||||||
|
// the cursor is handed back to the tile only after the exit has settled.
|
||||||
|
document.exitFullscreen().then(restoreFocus).catch(restoreFocus);
|
||||||
|
} else {
|
||||||
|
restoreFocus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,10 +325,6 @@ document.addEventListener("fullscreenchange", () => {
|
|||||||
if (!document.fullscreenElement && state.staged) unstage();
|
if (!document.fullscreenElement && state.staged) unstage();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key === "Escape" && state.staged) unstage();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (ui.unstage) ui.unstage.addEventListener("click", unstage);
|
if (ui.unstage) ui.unstage.addEventListener("click", unstage);
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ socket */
|
/* ------------------------------------------------------------------ socket */
|
||||||
@@ -376,6 +423,220 @@ state.ticker = setInterval(async () => {
|
|||||||
}
|
}
|
||||||
}, 1500);
|
}, 1500);
|
||||||
|
|
||||||
|
/* -------------------------------------------------------- keyboard router */
|
||||||
|
|
||||||
|
/* The kiosk rule this page lives by: no command may require a pointer, and no
|
||||||
|
pointer affordance may be the only way to see state. This is the ONE place
|
||||||
|
a keypress on /watch is read — one listener, one switch. Every command in the
|
||||||
|
key map (j/k/arrows, Enter/f, g, n/p, 1-9, m, ?, Esc) is a case here, never
|
||||||
|
a new listener. Chords with Ctrl/Cmd/Alt belong to the browser (back, reload,
|
||||||
|
copy), and a key in a text field is text, not a command, so both bail out
|
||||||
|
before the switch. Only keys this router actually handled are
|
||||||
|
preventDefault'ed. */
|
||||||
|
|
||||||
|
function orderedTiles() {
|
||||||
|
// DOM order == insertion order, which is the grid order renderCounts() uses.
|
||||||
|
return [...tiles.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedTile() {
|
||||||
|
return state.cursor ? tiles.get(state.cursor) || null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusTile(tile) {
|
||||||
|
// Focus follows selection (tiles are tabindex="0"). preventScroll keeps the
|
||||||
|
// grid from jumping while a remote hand walks it. If focus is already inside
|
||||||
|
// this tile — Tab reached one of its buttons — the selection already agrees,
|
||||||
|
// so don't steal focus back off the control the operator is on.
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (active === tile.el || (active && tile.el.contains(active))) return;
|
||||||
|
tile.el.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSelected(streamId) {
|
||||||
|
const tile = tiles.get(streamId);
|
||||||
|
if (!tile) return;
|
||||||
|
const previous = selectedTile();
|
||||||
|
if (previous && previous !== tile) delete previous.el.dataset.selected;
|
||||||
|
tile.el.dataset.selected = "";
|
||||||
|
state.cursor = streamId;
|
||||||
|
focusTile(tile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveSelection(delta) {
|
||||||
|
const ordered = orderedTiles();
|
||||||
|
const n = ordered.length;
|
||||||
|
if (n === 0) return;
|
||||||
|
const index = state.cursor ? ordered.findIndex((tile) => tile.id === state.cursor) : -1;
|
||||||
|
if (index === -1) {
|
||||||
|
// No live cursor yet: the first j lands on the first tile, the first k on
|
||||||
|
// the last. A wall with twenty mirrors is navigated in a loop from a
|
||||||
|
// remote, so the ends wrap.
|
||||||
|
setSelected(ordered[delta > 0 ? 0 : n - 1].id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelected(ordered[(index + delta + n) % n].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `?` shows and hides the key list. Focus is deliberately never touched: the
|
||||||
|
* selected tile holds it throughout, so `?` → `?` → `Esc` lands the operator
|
||||||
|
* exactly where they were — stage included.
|
||||||
|
*/
|
||||||
|
function toggleHelp() {
|
||||||
|
ui.help.hidden = !ui.help.hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageSelectedOrFirst() {
|
||||||
|
// Enter and `f` (when unstaged) share a target: the selected tile, or the
|
||||||
|
// first tile when nothing is selected yet. With nothing on the wall there
|
||||||
|
// is nothing to show, so say why instead of staying silent.
|
||||||
|
const tile = selectedTile() || orderedTiles()[0] || null;
|
||||||
|
if (!tile) {
|
||||||
|
toast("no mirrors to stage");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stage(tile.id, { fullscreen: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepStage(delta) {
|
||||||
|
// `n`/`p` *stage* the neighbour, not just move the selection: a remote
|
||||||
|
// operator cannot reach an Enter afterwards, so the step is the switch.
|
||||||
|
// The cursor sits on the staged tile while staged (stage() moves it), so
|
||||||
|
// stepping from the stage walks the wall; stage() unstages the previous
|
||||||
|
// tile, and unstaging moves — never recreates — the tile element.
|
||||||
|
const ordered = orderedTiles();
|
||||||
|
if (ordered.length === 0) {
|
||||||
|
toast("no mirrors to switch to");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let index = state.cursor ? ordered.findIndex((tile) => tile.id === state.cursor) : -1;
|
||||||
|
if (index === -1) index = delta > 0 ? -1 : 0; // no cursor: n lands first, p wraps to last
|
||||||
|
const next = ordered[(index + delta + ordered.length) % ordered.length];
|
||||||
|
stage(next.id, { fullscreen: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWallKey(event) {
|
||||||
|
if (event.defaultPrevented) return;
|
||||||
|
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
||||||
|
const target = event.target;
|
||||||
|
if (
|
||||||
|
target instanceof HTMLInputElement ||
|
||||||
|
target instanceof HTMLTextAreaElement ||
|
||||||
|
target instanceof HTMLSelectElement ||
|
||||||
|
target.isContentEditable
|
||||||
|
) {
|
||||||
|
return; // a key typed in a text field is text, not a command
|
||||||
|
}
|
||||||
|
// Match case-insensitively: a caps-locked kiosk keyboard, or a remote hand
|
||||||
|
// holding Shift, must drive the wall exactly the same. "?" is the only key
|
||||||
|
// that needs Shift; it lowercases to itself, so the same match covers it.
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
switch (key) {
|
||||||
|
case "j":
|
||||||
|
case "arrowdown":
|
||||||
|
moveSelection(1);
|
||||||
|
event.preventDefault();
|
||||||
|
break;
|
||||||
|
case "k":
|
||||||
|
case "arrowup":
|
||||||
|
moveSelection(-1);
|
||||||
|
event.preventDefault();
|
||||||
|
break;
|
||||||
|
case "enter":
|
||||||
|
stageSelectedOrFirst();
|
||||||
|
event.preventDefault(); // the default would click a focused button instead
|
||||||
|
break;
|
||||||
|
case "f":
|
||||||
|
if (state.staged) {
|
||||||
|
// The kiosk's window manager can drop the *window* out of fullscreen
|
||||||
|
// without mirror noticing (docs/kiosk-silverblue.md: window-config.ini
|
||||||
|
// forces the window fullscreen; an element requestFullscreen() on top
|
||||||
|
// of that is allowed and simply no-ops if the browser refuses). So
|
||||||
|
// `f` re-requests rather than tracking state. When unstaged it stages
|
||||||
|
// like Enter.
|
||||||
|
if (ui.stage.requestFullscreen) {
|
||||||
|
ui.stage.requestFullscreen({ navigationUI: "hide" }).catch(() => {});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stageSelectedOrFirst();
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
break;
|
||||||
|
case "g": {
|
||||||
|
const wasStaged = state.staged !== null;
|
||||||
|
unstage();
|
||||||
|
// When already unstaged the wall *is* the visible result "back to the
|
||||||
|
// wall", so there is nothing to toast; the key was only handled if it
|
||||||
|
// changed state.
|
||||||
|
if (wasStaged) event.preventDefault();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "n":
|
||||||
|
case "p":
|
||||||
|
stepStage(key === "n" ? 1 : -1);
|
||||||
|
event.preventDefault();
|
||||||
|
break;
|
||||||
|
case "m": {
|
||||||
|
const tile = (state.staged && tiles.get(state.staged)) || selectedTile();
|
||||||
|
if (!tile) {
|
||||||
|
toast("no mirror to mute");
|
||||||
|
} else {
|
||||||
|
setMuted(tile, !tile.video.muted);
|
||||||
|
// A stream with no audio track still toggles the element's `muted`,
|
||||||
|
// but "I muted a silent mirror" and "the keyboard is broken" look
|
||||||
|
// identical without a word.
|
||||||
|
if (tile.audioBtn.hidden) toast("this mirror has no audio");
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "?":
|
||||||
|
toggleHelp();
|
||||||
|
event.preventDefault();
|
||||||
|
break;
|
||||||
|
case "escape":
|
||||||
|
// The overlay sits above the stage (z-index 70 over 50), so Esc closes
|
||||||
|
// it FIRST and only leaves the stage when it was not open: ? over a
|
||||||
|
// staged mirror is two Escapes out of the wall, in that order.
|
||||||
|
if (!ui.help.hidden) {
|
||||||
|
ui.help.hidden = true;
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (state.staged) {
|
||||||
|
unstage();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// 1-9: jump to the Nth mirror in grid order. A no-op gets a toast — a
|
||||||
|
// silent no-op is the one outcome a keyboard-only operator cannot debug.
|
||||||
|
if (/^[1-9]$/.test(key)) {
|
||||||
|
const tile = orderedTiles()[Number(key) - 1];
|
||||||
|
if (tile) {
|
||||||
|
stage(tile.id, { fullscreen: true });
|
||||||
|
} else {
|
||||||
|
toast(`no mirror ${key}`);
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", onWallKey);
|
||||||
|
|
||||||
|
// Tab is the browser's own cursor, and it must agree with j/k: when a tile
|
||||||
|
// (or a control inside one) receives focus, the selection moves with it.
|
||||||
|
// focusin — not focus — because it bubbles from the focused element up to the
|
||||||
|
// grid. focusTile() declines when focus is already inside the tile, so Tabbing
|
||||||
|
// to a tile's own button is not stolen back to the tile.
|
||||||
|
ui.grid.addEventListener("focusin", (event) => {
|
||||||
|
const node = event.target instanceof Element ? event.target.closest(".tile") : null;
|
||||||
|
if (node && node.dataset.stream && tiles.has(node.dataset.stream)) {
|
||||||
|
setSelected(node.dataset.stream);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Same rule as the share page: if the server refused this address, say so
|
// Same rule as the share page: if the server refused this address, say so
|
||||||
// rather than showing an empty wall that will never fill.
|
// rather than showing an empty wall that will never fill.
|
||||||
const refusal = refusalNotice(config);
|
const refusal = refusalNotice(config);
|
||||||
|
|||||||
@@ -0,0 +1,902 @@
|
|||||||
|
"""End-to-end: the keyboard is a complete control surface for mirror.
|
||||||
|
|
||||||
|
Phase 01, tasks 01-04: one focus-aware keydown router plus a keyboard cursor
|
||||||
|
over the tiles (j/k/Up/Down, Esc), the wall commands (Enter/f, g, n/p, 1-9,
|
||||||
|
m), the ``?`` help overlay, the focus-visible affordances — no control is
|
||||||
|
reachable *only* by hover — and the /mirror keys (s, x, Esc). Every command
|
||||||
|
here is driven with ``page.keyboard.press()`` and asserted on the DOM state
|
||||||
|
the key changes — ``data-selected``, focus, the ``staged`` class, the
|
||||||
|
``muted`` flag, computed styles — never on whether a handler "ran".
|
||||||
|
|
||||||
|
The suite is pointer-free: a test that reaches for the pointer proves nothing
|
||||||
|
about a kiosk. The meta-test at the bottom enforces that on this file
|
||||||
|
itself, so the guarantee cannot rot.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from playwright.sync_api import expect
|
||||||
|
|
||||||
|
from .conftest import start_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: print pageerror/console from the first line of the test).
|
||||||
|
"""
|
||||||
|
opened = []
|
||||||
|
|
||||||
|
def _open(path: str = "/", errors: list | None = None):
|
||||||
|
context = browser.new_context(viewport={"width": 1100, "height": 720})
|
||||||
|
opened.append(context)
|
||||||
|
request.addfinalizer(context.close)
|
||||||
|
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 page
|
||||||
|
|
||||||
|
return _open
|
||||||
|
|
||||||
|
|
||||||
|
def _non_loopback_ipv4() -> str | None:
|
||||||
|
"""The address a *neighbour* would use to reach this machine, or None.
|
||||||
|
|
||||||
|
A UDP connect sends no packet; it only resolves the default route, which
|
||||||
|
is exactly the interface a LAN device would see.
|
||||||
|
"""
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
|
||||||
|
try:
|
||||||
|
probe.connect(("8.8.8.8", 80))
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
address = probe.getsockname()[0]
|
||||||
|
return address if address != "127.0.0.1" else None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def insecure_base_url():
|
||||||
|
"""A `mirror serve` on a plain-HTTP non-localhost address.
|
||||||
|
|
||||||
|
That is a non-secure context: the browser hides `navigator.mediaDevices`
|
||||||
|
entirely — the exact trap /mirror documents for a bare `mirror serve` on a
|
||||||
|
LAN IP (README, docs/kiosk-silverblue.md "One trap worth knowing").
|
||||||
|
"""
|
||||||
|
host = _non_loopback_ipv4()
|
||||||
|
if host is None:
|
||||||
|
pytest.skip("no non-loopback interface: the non-secure-context trap is untestable here")
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .conftest import _free_port
|
||||||
|
|
||||||
|
port = _free_port()
|
||||||
|
process = subprocess.Popen(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"mirror",
|
||||||
|
"serve",
|
||||||
|
"--host",
|
||||||
|
"0.0.0.0",
|
||||||
|
"--port",
|
||||||
|
str(port),
|
||||||
|
"--log-level",
|
||||||
|
"warning",
|
||||||
|
],
|
||||||
|
cwd=Path(__file__).resolve().parents[2],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
env={
|
||||||
|
**os.environ,
|
||||||
|
"MIRROR_ICE_SERVERS": "",
|
||||||
|
"PYTHONUNBUFFERED": "1",
|
||||||
|
"DEBUGPY": "0",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
url = f"http://{host}:{port}"
|
||||||
|
last_error: Exception | None = None
|
||||||
|
deadline = time.time() + 25
|
||||||
|
while time.time() < deadline:
|
||||||
|
if process.poll() is not None:
|
||||||
|
output = process.stdout.read() if process.stdout else ""
|
||||||
|
raise RuntimeError(f"mirror serve exited early ({process.returncode}):\n{output}")
|
||||||
|
try:
|
||||||
|
if httpx.get(f"{url}/healthz", timeout=1.0).status_code == 200:
|
||||||
|
break
|
||||||
|
except Exception as exc: # still booting
|
||||||
|
last_error = exc
|
||||||
|
time.sleep(0.15)
|
||||||
|
else:
|
||||||
|
process.terminate()
|
||||||
|
raise RuntimeError(f"mirror serve never came up: {last_error}")
|
||||||
|
yield url
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
process.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired: # pragma: no cover
|
||||||
|
process.kill()
|
||||||
|
|
||||||
|
|
||||||
|
def tile_ids(page) -> list[str]:
|
||||||
|
return page.locator(".tile").evaluate_all("els => els.map(e => e.dataset.stream)")
|
||||||
|
|
||||||
|
|
||||||
|
def selected_id(page) -> str | None:
|
||||||
|
return page.evaluate(
|
||||||
|
"() => { const el = document.querySelector('.tile[data-selected]');"
|
||||||
|
" return el ? el.dataset.stream : null; }"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def focus_follows_selection(page) -> bool:
|
||||||
|
return page.evaluate(
|
||||||
|
"() => { const el = document.querySelector('.tile[data-selected]');"
|
||||||
|
" return el !== null && document.activeElement === el; }"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestKeyboardCursor:
|
||||||
|
def _two_mirror_wall(self, device, errors: list) -> tuple:
|
||||||
|
for _ in range(2):
|
||||||
|
start_sharing(device("/mirror", errors))
|
||||||
|
viewer = device("/watch", errors)
|
||||||
|
expect(viewer.locator(".tile")).to_have_count(2, timeout=15_000)
|
||||||
|
return viewer, tile_ids(viewer)
|
||||||
|
|
||||||
|
def test_j_selects_the_first_tile_and_focus_follows(self, device):
|
||||||
|
errors: list = []
|
||||||
|
viewer, ids = self._two_mirror_wall(device, errors)
|
||||||
|
|
||||||
|
viewer.keyboard.press("j")
|
||||||
|
assert selected_id(viewer) == ids[0]
|
||||||
|
assert focus_follows_selection(viewer), "focus must follow the selection"
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_j_and_k_wrap_at_the_ends(self, device):
|
||||||
|
errors: list = []
|
||||||
|
viewer, ids = self._two_mirror_wall(device, errors)
|
||||||
|
|
||||||
|
viewer.keyboard.press("j")
|
||||||
|
assert selected_id(viewer) == ids[0]
|
||||||
|
# k from the first wraps to the last.
|
||||||
|
viewer.keyboard.press("k")
|
||||||
|
assert selected_id(viewer) == ids[1]
|
||||||
|
# j from the last wraps back to the first.
|
||||||
|
viewer.keyboard.press("j")
|
||||||
|
assert selected_id(viewer) == ids[0]
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_arrow_keys_drive_the_same_cursor(self, device):
|
||||||
|
errors: list = []
|
||||||
|
viewer, ids = self._two_mirror_wall(device, errors)
|
||||||
|
|
||||||
|
viewer.keyboard.press("ArrowDown")
|
||||||
|
assert selected_id(viewer) == ids[0]
|
||||||
|
viewer.keyboard.press("ArrowDown")
|
||||||
|
assert selected_id(viewer) == ids[1]
|
||||||
|
viewer.keyboard.press("ArrowUp")
|
||||||
|
assert selected_id(viewer) == ids[0]
|
||||||
|
# The cursor must not fight real scrolling: the router suppresses the
|
||||||
|
# arrow's default while it handles the key.
|
||||||
|
viewer.keyboard.press("ArrowUp")
|
||||||
|
assert selected_id(viewer) == ids[1]
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_ctrl_chords_never_run_a_command(self, device):
|
||||||
|
errors: list = []
|
||||||
|
viewer, ids = self._two_mirror_wall(device, errors)
|
||||||
|
|
||||||
|
viewer.keyboard.press("j")
|
||||||
|
assert selected_id(viewer) == ids[0]
|
||||||
|
# Synthesised ctrlKey: true — the browser's chords are never ours.
|
||||||
|
viewer.keyboard.press("Control+j")
|
||||||
|
assert selected_id(viewer) == ids[0]
|
||||||
|
viewer.keyboard.press("Control+k")
|
||||||
|
assert selected_id(viewer) == ids[0]
|
||||||
|
viewer.keyboard.press("Control+ArrowDown")
|
||||||
|
assert selected_id(viewer) == ids[0]
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_shifted_letters_drive_the_same_commands(self, device):
|
||||||
|
errors: list = []
|
||||||
|
viewer, ids = self._two_mirror_wall(device, errors)
|
||||||
|
|
||||||
|
# Keys are matched case-insensitively: a caps-locked kiosk keyboard, or
|
||||||
|
# a remote hand holding Shift, must work exactly the same.
|
||||||
|
viewer.keyboard.press("Shift+J")
|
||||||
|
assert selected_id(viewer) == ids[0]
|
||||||
|
# Shift+F stages through the same command as f.
|
||||||
|
viewer.keyboard.press("Shift+F")
|
||||||
|
expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
|
||||||
|
assert viewer.locator("#watch-stage .tile").get_attribute("data-stream") == ids[0]
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestEscape:
|
||||||
|
def test_escape_leaves_the_stage(self, device):
|
||||||
|
errors: list = []
|
||||||
|
start_sharing(device("/mirror", errors))
|
||||||
|
viewer = device("/watch", errors)
|
||||||
|
expect(viewer.locator(".tile")).to_have_count(1, timeout=15_000)
|
||||||
|
|
||||||
|
# Staged the way the kiosk stages — select, then Enter — because this
|
||||||
|
# suite is pointer-free; Esc is what the kiosk uses to leave the stage.
|
||||||
|
viewer.keyboard.press("j")
|
||||||
|
viewer.keyboard.press("Enter")
|
||||||
|
expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
|
||||||
|
|
||||||
|
viewer.keyboard.press("Escape")
|
||||||
|
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"), timeout=5_000)
|
||||||
|
# Leaving the stage must not have disturbed the stream.
|
||||||
|
expect(viewer.locator(".tile[data-state='live']")).to_have_count(1, timeout=20_000)
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_escape_without_a_stage_is_a_noop(self, device):
|
||||||
|
errors: list = []
|
||||||
|
start_sharing(device("/mirror", errors))
|
||||||
|
viewer = device("/watch", errors)
|
||||||
|
expect(viewer.locator(".tile")).to_have_count(1, timeout=15_000)
|
||||||
|
|
||||||
|
viewer.keyboard.press("Escape")
|
||||||
|
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"))
|
||||||
|
expect(viewer.locator(".tile")).to_have_count(1)
|
||||||
|
expect(viewer.locator(".tile[data-state='live']")).to_have_count(1, timeout=20_000)
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestWallCommands:
|
||||||
|
"""Task 02: Enter/f, g, n/p, 1-9, m on /watch.
|
||||||
|
|
||||||
|
Every command is driven with ``page.keyboard.press()`` and asserted on the
|
||||||
|
DOM state the key changes — the staged tile id, the ``staged`` class, the
|
||||||
|
video's ``muted`` flag, the ``#toast`` — never on whether a handler "ran".
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _two_mirror_wall(self, device, errors: list) -> tuple:
|
||||||
|
for _ in range(2):
|
||||||
|
start_sharing(device("/mirror", errors))
|
||||||
|
viewer = device("/watch", errors)
|
||||||
|
expect(viewer.locator(".tile")).to_have_count(2, timeout=15_000)
|
||||||
|
return viewer, tile_ids(viewer)
|
||||||
|
|
||||||
|
def test_enter_stages_the_selected_mirror(self, device):
|
||||||
|
errors: list = []
|
||||||
|
viewer, ids = self._two_mirror_wall(device, errors)
|
||||||
|
|
||||||
|
viewer.keyboard.press("j")
|
||||||
|
viewer.keyboard.press("Enter")
|
||||||
|
expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
|
||||||
|
expect(viewer.locator("#watch-stage .tile")).to_have_count(1)
|
||||||
|
assert viewer.locator("#watch-stage .tile").get_attribute("data-stream") == ids[0]
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_enter_with_nothing_selected_stages_the_first_tile(self, device):
|
||||||
|
errors: list = []
|
||||||
|
viewer, ids = self._two_mirror_wall(device, errors)
|
||||||
|
|
||||||
|
viewer.keyboard.press("Enter")
|
||||||
|
expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
|
||||||
|
expect(viewer.locator("#watch-stage .tile")).to_have_count(1)
|
||||||
|
assert viewer.locator("#watch-stage .tile").get_attribute("data-stream") == ids[0]
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_f_stages_when_unstaged_and_holds_the_stage_when_staged(self, device):
|
||||||
|
errors: list = []
|
||||||
|
viewer, ids = self._two_mirror_wall(device, errors)
|
||||||
|
|
||||||
|
# Unstaged: `f` stages like Enter (first tile, nothing selected).
|
||||||
|
viewer.keyboard.press("f")
|
||||||
|
expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
|
||||||
|
assert viewer.locator("#watch-stage .tile").get_attribute("data-stream") == ids[0]
|
||||||
|
|
||||||
|
# Staged: `f` re-requests fullscreen. A kiosk's window manager can drop
|
||||||
|
# the window out of fullscreen without the page noticing, so the
|
||||||
|
# command must never tear the stage down — headless or not.
|
||||||
|
viewer.keyboard.press("f")
|
||||||
|
expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
|
||||||
|
expect(viewer.locator("#watch-stage .tile")).to_have_count(1)
|
||||||
|
assert viewer.locator("#watch-stage .tile").get_attribute("data-stream") == ids[0]
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_g_returns_to_the_wall(self, device):
|
||||||
|
errors: list = []
|
||||||
|
viewer, _ = self._two_mirror_wall(device, errors)
|
||||||
|
|
||||||
|
viewer.keyboard.press("j")
|
||||||
|
viewer.keyboard.press("Enter")
|
||||||
|
expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
|
||||||
|
|
||||||
|
viewer.keyboard.press("g")
|
||||||
|
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"), timeout=5_000)
|
||||||
|
expect(viewer.locator("#watch-stage .tile")).to_have_count(0)
|
||||||
|
expect(viewer.locator(".tile")).to_have_count(2)
|
||||||
|
|
||||||
|
# g on an already-unstaged wall is a silent no-op: the wall *is* the
|
||||||
|
# visible result "back to the wall".
|
||||||
|
viewer.keyboard.press("g")
|
||||||
|
expect(viewer.locator(".tile")).to_have_count(2)
|
||||||
|
expect(viewer.locator("#toast")).not_to_be_visible()
|
||||||
|
|
||||||
|
# Leaving the stage must not have disturbed the streams.
|
||||||
|
expect(viewer.locator(".tile[data-state='live']")).to_have_count(2, timeout=20_000)
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_n_and_p_step_between_mirrors_without_killing_the_peer(self, device):
|
||||||
|
errors: list = []
|
||||||
|
viewer, ids = self._two_mirror_wall(device, errors)
|
||||||
|
a, b = ids[0], ids[1]
|
||||||
|
expect(viewer.locator(".tile[data-state='live']")).to_have_count(2, timeout=30_000)
|
||||||
|
|
||||||
|
# Stage A by keyboard.
|
||||||
|
viewer.keyboard.press("j")
|
||||||
|
viewer.keyboard.press("Enter")
|
||||||
|
expect(viewer.locator("#watch-stage .tile")).to_have_count(1, timeout=5_000)
|
||||||
|
assert viewer.locator("#watch-stage .tile").get_attribute("data-stream") == a
|
||||||
|
|
||||||
|
# Mark A's <video> node: staging B must *move* it back to the grid,
|
||||||
|
# never recreate it — the element moves, srcObject is never reassigned.
|
||||||
|
assert viewer.evaluate(
|
||||||
|
"""(id) => {
|
||||||
|
const el = document.querySelector('#watch-stage video');
|
||||||
|
el.dataset.e2e = "same-node";
|
||||||
|
return el.srcObject !== null;
|
||||||
|
}""",
|
||||||
|
a,
|
||||||
|
), "A's video must hold a live stream before the switch"
|
||||||
|
|
||||||
|
viewer.keyboard.press("n")
|
||||||
|
expect(viewer.locator("#watch-stage .tile")).to_have_count(1, timeout=5_000)
|
||||||
|
assert viewer.locator("#watch-stage .tile").get_attribute("data-stream") == b
|
||||||
|
assert viewer.evaluate(
|
||||||
|
"""(id) => {
|
||||||
|
const el = document.querySelector(`.tile[data-stream="${id}"] video`);
|
||||||
|
return el !== null && el.dataset.e2e === "same-node" && el.srcObject !== null;
|
||||||
|
}""",
|
||||||
|
a,
|
||||||
|
), "A's <video> must be the same node, still holding its stream"
|
||||||
|
|
||||||
|
# p wraps back to A — and the stepping must not have killed either mirror.
|
||||||
|
viewer.keyboard.press("p")
|
||||||
|
expect(viewer.locator("#watch-stage .tile")).to_have_count(1, timeout=5_000)
|
||||||
|
assert viewer.locator("#watch-stage .tile").get_attribute("data-stream") == a
|
||||||
|
|
||||||
|
expect(viewer.locator(".tile[data-state='live']")).to_have_count(2, timeout=20_000)
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_digit_jump_stages_the_nth_mirror(self, device):
|
||||||
|
errors: list = []
|
||||||
|
viewer, ids = self._two_mirror_wall(device, errors)
|
||||||
|
|
||||||
|
viewer.keyboard.press("2")
|
||||||
|
expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
|
||||||
|
expect(viewer.locator("#watch-stage .tile")).to_have_count(1)
|
||||||
|
assert viewer.locator("#watch-stage .tile").get_attribute("data-stream") == ids[1]
|
||||||
|
|
||||||
|
# 1 takes the first mirror back on stage; stage() unstages the other.
|
||||||
|
viewer.keyboard.press("1")
|
||||||
|
expect(viewer.locator("#watch-stage .tile")).to_have_count(1, timeout=5_000)
|
||||||
|
assert viewer.locator("#watch-stage .tile").get_attribute("data-stream") == ids[0]
|
||||||
|
|
||||||
|
# The wall survives the jump: both mirrors still live.
|
||||||
|
expect(viewer.locator(".tile[data-state='live']")).to_have_count(2, timeout=20_000)
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_m_flips_the_staged_videos_muted_flag(self, device):
|
||||||
|
errors: list = []
|
||||||
|
viewer, _ = self._two_mirror_wall(device, errors)
|
||||||
|
|
||||||
|
viewer.keyboard.press("j")
|
||||||
|
viewer.keyboard.press("Enter")
|
||||||
|
expect(viewer.locator("#watch-stage video")).to_have_count(1, timeout=5_000)
|
||||||
|
|
||||||
|
before = viewer.evaluate("() => document.querySelector('#watch-stage video').muted")
|
||||||
|
viewer.keyboard.press("m")
|
||||||
|
after = viewer.evaluate("() => document.querySelector('#watch-stage video').muted")
|
||||||
|
assert after == (not before), f"m must flip muted ({before} -> {after})"
|
||||||
|
|
||||||
|
# The tile's audio button tracks the same state through setMuted.
|
||||||
|
assert (
|
||||||
|
viewer.locator("#watch-stage .audio").get_attribute("aria-pressed")
|
||||||
|
== str(not after).lower()
|
||||||
|
), "aria-pressed must follow the muted flag"
|
||||||
|
|
||||||
|
# And back again.
|
||||||
|
viewer.keyboard.press("m")
|
||||||
|
again = viewer.evaluate("() => document.querySelector('#watch-stage video').muted")
|
||||||
|
assert again == before, f"m must flip back ({after} -> {again})"
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_missing_mirror_number_toasts_instead_of_silence(self, device):
|
||||||
|
errors: list = []
|
||||||
|
sharer = device("/mirror", errors)
|
||||||
|
start_sharing(sharer)
|
||||||
|
viewer = device("/watch", errors)
|
||||||
|
expect(viewer.locator(".tile")).to_have_count(1, timeout=15_000)
|
||||||
|
|
||||||
|
viewer.keyboard.press("5")
|
||||||
|
|
||||||
|
# The wall is unchanged…
|
||||||
|
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"))
|
||||||
|
expect(viewer.locator(".tile")).to_have_count(1)
|
||||||
|
# …and the no-op says why — a silent no-op is undiagnosable from a kiosk.
|
||||||
|
expect(viewer.locator("#toast")).to_be_visible()
|
||||||
|
expect(viewer.locator("#toast")).to_contain_text("no mirror 5")
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_commands_on_an_empty_wall_toast_the_reason(self, device):
|
||||||
|
errors: list = []
|
||||||
|
viewer = device("/watch", errors)
|
||||||
|
expect(viewer.locator("#empty")).to_be_visible()
|
||||||
|
|
||||||
|
viewer.keyboard.press("Enter")
|
||||||
|
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"))
|
||||||
|
expect(viewer.locator("#toast")).to_be_visible()
|
||||||
|
expect(viewer.locator("#toast")).to_contain_text("no mirrors to stage")
|
||||||
|
|
||||||
|
viewer.keyboard.press("n")
|
||||||
|
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"))
|
||||||
|
expect(viewer.locator("#toast")).to_contain_text("no mirrors to switch to")
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestFocusVisibleControls:
|
||||||
|
"""Task 03: no control is reachable *only* by hover.
|
||||||
|
|
||||||
|
Everything here is keyboard-only: ``Tab`` moves focus, and the assertions
|
||||||
|
are on what the kiosk *sees* — computed opacity, the focus ring,
|
||||||
|
``data-selected`` — never on whether a handler "ran". No test in this class
|
||||||
|
drives the pointer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _one_mirror_wall(self, device, errors: list):
|
||||||
|
start_sharing(device("/mirror", errors))
|
||||||
|
viewer = device("/watch", errors)
|
||||||
|
expect(viewer.locator(".tile")).to_have_count(1, timeout=15_000)
|
||||||
|
return viewer
|
||||||
|
|
||||||
|
def _two_mirror_wall(self, device, errors: list) -> tuple:
|
||||||
|
for _ in range(2):
|
||||||
|
start_sharing(device("/mirror", errors))
|
||||||
|
viewer = device("/watch", errors)
|
||||||
|
expect(viewer.locator(".tile")).to_have_count(2, timeout=15_000)
|
||||||
|
return viewer, tile_ids(viewer)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _tab_to_a_tile(viewer, attempts: int = 12) -> str | None:
|
||||||
|
"""Tab from the top of the document until focus lands on a tile."""
|
||||||
|
for _ in range(attempts):
|
||||||
|
viewer.keyboard.press("Tab")
|
||||||
|
tile = viewer.evaluate(
|
||||||
|
"() => { const el = document.activeElement;"
|
||||||
|
" return el && el.classList.contains('tile') ? el.dataset.stream : null; }"
|
||||||
|
)
|
||||||
|
if tile:
|
||||||
|
return tile
|
||||||
|
return None
|
||||||
|
|
||||||
|
def test_tab_focus_reveals_the_tile_controls(self, device):
|
||||||
|
errors: list = []
|
||||||
|
viewer = self._one_mirror_wall(device, errors)
|
||||||
|
|
||||||
|
assert self._tab_to_a_tile(viewer), "Tab must reach the tile without a pointer"
|
||||||
|
|
||||||
|
# The opacity transition is 150 ms; wait for the settled value.
|
||||||
|
viewer.wait_for_function(
|
||||||
|
"() => { const el = document.activeElement;"
|
||||||
|
" return el && el.classList.contains('tile')"
|
||||||
|
" && getComputedStyle(el.querySelector('.actions')).opacity === '1'; }",
|
||||||
|
timeout=5_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_keyboard_focus_draws_a_visible_ring(self, device, tmp_path):
|
||||||
|
errors: list = []
|
||||||
|
viewer = self._one_mirror_wall(device, errors)
|
||||||
|
|
||||||
|
assert self._tab_to_a_tile(viewer)
|
||||||
|
|
||||||
|
ring = viewer.evaluate(
|
||||||
|
"() => { const s = getComputedStyle(document.activeElement);"
|
||||||
|
" return { width: s.outlineWidth, style: s.outlineStyle, color: s.outlineColor }; }"
|
||||||
|
)
|
||||||
|
assert ring["style"] == "solid", f"the focus ring must be visible: {ring}"
|
||||||
|
assert ring["width"] == "2px", ring
|
||||||
|
assert ring["color"] != "rgba(0, 0, 0, 0)", f"the ring must not be transparent: {ring}"
|
||||||
|
|
||||||
|
# Screenshot artifact: a kiosk is debugged from another machine, so a
|
||||||
|
# focused tile must be legible in a still frame.
|
||||||
|
shot = tmp_path / "focused_tile.png"
|
||||||
|
viewer.screenshot(path=str(shot))
|
||||||
|
assert shot.stat().st_size > 0
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_tab_focus_moves_the_selection_with_it(self, device):
|
||||||
|
errors: list = []
|
||||||
|
viewer, ids = self._two_mirror_wall(device, errors)
|
||||||
|
|
||||||
|
seen: list[str] = []
|
||||||
|
for _ in range(16):
|
||||||
|
viewer.keyboard.press("Tab")
|
||||||
|
on_tile = viewer.evaluate(
|
||||||
|
"() => document.activeElement !== null"
|
||||||
|
" && document.activeElement.classList.contains('tile')"
|
||||||
|
)
|
||||||
|
if on_tile:
|
||||||
|
seen.append(selected_id(viewer))
|
||||||
|
# While a tile itself holds focus, data-selected must be that tile.
|
||||||
|
assert viewer.evaluate(
|
||||||
|
"() => document.activeElement === document.querySelector"
|
||||||
|
"('.tile[data-selected]')"
|
||||||
|
), "the selected tile must be the focused tile"
|
||||||
|
if len(seen) == 2:
|
||||||
|
break
|
||||||
|
|
||||||
|
assert seen == ids, f"Tab must walk the grid in order {ids}; walked {seen}"
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_tab_to_a_tile_button_is_not_stolen_back_to_the_tile(self, device):
|
||||||
|
errors: list = []
|
||||||
|
viewer = self._one_mirror_wall(device, errors)
|
||||||
|
|
||||||
|
assert self._tab_to_a_tile(viewer)
|
||||||
|
# The selected tile carries its controls, so the next Tab reaches a
|
||||||
|
# control *inside* the tile — and must stay there: the focusin
|
||||||
|
# handler must not pull focus back off the control the operator is on.
|
||||||
|
viewer.keyboard.press("Tab")
|
||||||
|
assert viewer.evaluate(
|
||||||
|
"() => document.activeElement !== null"
|
||||||
|
" && document.activeElement.closest('.actions') !== null"
|
||||||
|
), "Tab must be able to land on a control inside the tile"
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_the_wall_stays_quiet_until_something_is_focused(self, device):
|
||||||
|
errors: list = []
|
||||||
|
viewer = self._one_mirror_wall(device, errors)
|
||||||
|
|
||||||
|
# Nothing hovered, focused or selected: the controls stay hidden, so a
|
||||||
|
# shared display keeps its quiet look.
|
||||||
|
opacity = viewer.evaluate(
|
||||||
|
"() => getComputedStyle(document.querySelector('.tile .actions')).opacity"
|
||||||
|
)
|
||||||
|
assert opacity == "0", (
|
||||||
|
f"the wall must stay quiet until a tile is focused or selected (got {opacity})"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_focus_moves_to_the_stage_and_back_with_g(self, device):
|
||||||
|
errors: list = []
|
||||||
|
viewer = self._one_mirror_wall(device, errors)
|
||||||
|
|
||||||
|
viewer.keyboard.press("Enter")
|
||||||
|
expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
|
||||||
|
assert viewer.evaluate(
|
||||||
|
"() => document.activeElement !== null"
|
||||||
|
" && document.activeElement.parentElement.id === 'watch-stage'"
|
||||||
|
), "staging must hand focus to the staged tile"
|
||||||
|
|
||||||
|
# The staged caption is legible while the tile holds focus (the
|
||||||
|
# opacity transition is 200 ms; wait for the settled value).
|
||||||
|
viewer.wait_for_function(
|
||||||
|
"() => document.activeElement !== null"
|
||||||
|
" && getComputedStyle(document.activeElement.querySelector('.cap')).opacity === '1'",
|
||||||
|
timeout=5_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
# m still works with focus sitting on the stage.
|
||||||
|
before = viewer.evaluate("() => document.querySelector('#watch-stage video').muted")
|
||||||
|
viewer.keyboard.press("m")
|
||||||
|
after = viewer.evaluate("() => document.querySelector('#watch-stage video').muted")
|
||||||
|
assert after == (not before), f"m must flip muted on the staged tile ({before} -> {after})"
|
||||||
|
|
||||||
|
# g returns to the wall and hands focus back to the tile's grid slot.
|
||||||
|
# The hand-back is deferred until the fullscreen exit has settled
|
||||||
|
# (the exit would otherwise drop the focus back to <body>), so wait
|
||||||
|
# for it rather than asserting on the keypress alone.
|
||||||
|
viewer.keyboard.press("g")
|
||||||
|
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"), timeout=5_000)
|
||||||
|
viewer.wait_for_function(
|
||||||
|
"() => { const el = document.activeElement;"
|
||||||
|
" return el !== null && el.classList.contains('tile')"
|
||||||
|
" && el.parentElement.id === 'grid'"
|
||||||
|
" && el === document.querySelector('.tile[data-selected]'); }",
|
||||||
|
timeout=5_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_escape_after_staging_returns_focus_to_the_grid(self, device):
|
||||||
|
errors: list = []
|
||||||
|
viewer = self._one_mirror_wall(device, errors)
|
||||||
|
|
||||||
|
viewer.keyboard.press("Enter")
|
||||||
|
expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
|
||||||
|
|
||||||
|
viewer.keyboard.press("Escape")
|
||||||
|
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"), timeout=5_000)
|
||||||
|
viewer.wait_for_function(
|
||||||
|
"() => { const el = document.activeElement;"
|
||||||
|
" return el !== null && el.classList.contains('tile')"
|
||||||
|
" && el.parentElement.id === 'grid'; }",
|
||||||
|
timeout=5_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestHelpOverlay:
|
||||||
|
"""Task 04: `?` shows the key list; `?`/`Esc` close it; focus never moves.
|
||||||
|
|
||||||
|
The overlay is the element ``watch.js`` reaches for (``#wall-help``) and it
|
||||||
|
exists in ``watch.html`` (the element-existence guard enforces the pairing)
|
||||||
|
— asserted here on the DOM state, as always, never on whether a handler
|
||||||
|
"ran".
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _one_mirror_wall(self, device, errors: list):
|
||||||
|
start_sharing(device("/mirror", errors))
|
||||||
|
viewer = device("/watch", errors)
|
||||||
|
expect(viewer.locator(".tile")).to_have_count(1, timeout=15_000)
|
||||||
|
return viewer
|
||||||
|
|
||||||
|
def test_question_mark_opens_the_key_list(self, device):
|
||||||
|
errors: list = []
|
||||||
|
viewer = self._one_mirror_wall(device, errors)
|
||||||
|
expect(viewer.locator("#wall-help")).to_be_hidden()
|
||||||
|
|
||||||
|
viewer.keyboard.press("?")
|
||||||
|
expect(viewer.locator("#wall-help")).to_be_visible()
|
||||||
|
# The list is the locked key map from 00_phase.md — a kiosk operator
|
||||||
|
# can read back every command the wall accepts.
|
||||||
|
for text in (
|
||||||
|
"next mirror",
|
||||||
|
"previous mirror",
|
||||||
|
"Stage the selected mirror fullscreen",
|
||||||
|
"Back to the wall",
|
||||||
|
"Stage the next / previous mirror",
|
||||||
|
"Stage the Nth mirror",
|
||||||
|
"Mute / unmute the staged mirror",
|
||||||
|
"Close this list, then leave the stage",
|
||||||
|
):
|
||||||
|
expect(viewer.locator("#wall-help")).to_contain_text(text)
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_question_mark_closes_the_key_list(self, device):
|
||||||
|
errors: list = []
|
||||||
|
viewer = self._one_mirror_wall(device, errors)
|
||||||
|
|
||||||
|
viewer.keyboard.press("?")
|
||||||
|
expect(viewer.locator("#wall-help")).to_be_visible()
|
||||||
|
|
||||||
|
viewer.keyboard.press("?")
|
||||||
|
expect(viewer.locator("#wall-help")).to_be_hidden()
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_the_list_says_how_to_close_it(self, device):
|
||||||
|
errors: list = []
|
||||||
|
viewer = self._one_mirror_wall(device, errors)
|
||||||
|
|
||||||
|
viewer.keyboard.press("?")
|
||||||
|
expect(viewer.locator("#wall-help .hint")).to_contain_text("to close this list")
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_escape_closes_the_overlay_before_it_unstages(self, device):
|
||||||
|
errors: list = []
|
||||||
|
viewer = self._one_mirror_wall(device, errors)
|
||||||
|
|
||||||
|
viewer.keyboard.press("j")
|
||||||
|
viewer.keyboard.press("Enter")
|
||||||
|
expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
|
||||||
|
|
||||||
|
# ? over the stage: the overlay sits above the stage (z-index 70 over
|
||||||
|
# 50), so it is readable with a mirror on stage.
|
||||||
|
viewer.keyboard.press("?")
|
||||||
|
expect(viewer.locator("#wall-help")).to_be_visible()
|
||||||
|
|
||||||
|
# First Esc: the overlay closes, the stage stays.
|
||||||
|
viewer.keyboard.press("Escape")
|
||||||
|
expect(viewer.locator("#wall-help")).to_be_hidden()
|
||||||
|
expect(viewer.locator("body")).to_have_class(re.compile("staged"))
|
||||||
|
|
||||||
|
# Second Esc: the stage goes.
|
||||||
|
viewer.keyboard.press("Escape")
|
||||||
|
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"), timeout=5_000)
|
||||||
|
expect(viewer.locator("#watch-stage .tile")).to_have_count(0)
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_focus_stays_on_the_selected_tile_through_the_overlay(self, device):
|
||||||
|
errors: list = []
|
||||||
|
viewer = self._one_mirror_wall(device, errors)
|
||||||
|
|
||||||
|
viewer.keyboard.press("j")
|
||||||
|
assert viewer.evaluate(
|
||||||
|
"() => document.activeElement !== null"
|
||||||
|
" && document.activeElement.classList.contains('tile')"
|
||||||
|
), "the selected tile must hold focus before the overlay"
|
||||||
|
|
||||||
|
for key in ("?", "?", "Escape"):
|
||||||
|
viewer.keyboard.press(key)
|
||||||
|
assert viewer.evaluate(
|
||||||
|
"() => document.activeElement !== null"
|
||||||
|
" && document.activeElement.classList.contains('tile')"
|
||||||
|
), f"focus must stay on the tile after {key!r}"
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSharePageKeys:
|
||||||
|
"""Task 04: `s`/`x`/`Esc` on /mirror, and the no-silent-failure rule.
|
||||||
|
|
||||||
|
The sharer here is driven by the keyboard exactly as a kiosk operator would
|
||||||
|
drive it; ``start_sharing`` (a pointer helper on conftest) is used only to
|
||||||
|
stand up *wall* mirrors, never to start a share under test.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_s_starts_a_share_and_x_stops_it(self, device):
|
||||||
|
errors: list = []
|
||||||
|
page = device("/mirror", errors)
|
||||||
|
expect(page.locator("body")).to_have_attribute("data-live", "off")
|
||||||
|
|
||||||
|
# A keydown is a user activation, so the fake UI approves the OS picker
|
||||||
|
# and the real capture -> preview path runs.
|
||||||
|
page.keyboard.press("s")
|
||||||
|
expect(page.locator("body")).to_have_attribute("data-live", "on", timeout=15_000)
|
||||||
|
|
||||||
|
page.keyboard.press("x")
|
||||||
|
expect(page.locator("body")).to_have_attribute("data-live", "off", timeout=10_000)
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_s_while_sharing_toasts_instead_of_opening_a_second_picker(self, device):
|
||||||
|
errors: list = []
|
||||||
|
page = device("/mirror", errors)
|
||||||
|
|
||||||
|
page.keyboard.press("s")
|
||||||
|
expect(page.locator("body")).to_have_attribute("data-live", "on", timeout=15_000)
|
||||||
|
|
||||||
|
page.keyboard.press("s")
|
||||||
|
expect(page.locator("#toast")).to_be_visible()
|
||||||
|
expect(page.locator("#toast")).to_contain_text("already sharing")
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_x_while_idle_toasts_instead_of_silence(self, device):
|
||||||
|
errors: list = []
|
||||||
|
page = device("/mirror", errors)
|
||||||
|
|
||||||
|
page.keyboard.press("x")
|
||||||
|
expect(page.locator("#toast")).to_be_visible()
|
||||||
|
expect(page.locator("#toast")).to_contain_text("not sharing")
|
||||||
|
expect(page.locator("body")).to_have_attribute("data-live", "off")
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_s_and_x_type_text_in_the_name_field_instead(self, device):
|
||||||
|
errors: list = []
|
||||||
|
page = device("/mirror", errors)
|
||||||
|
|
||||||
|
page.locator("#name").focus()
|
||||||
|
page.keyboard.press("s")
|
||||||
|
expect(page.locator("#name")).to_have_value("s")
|
||||||
|
page.keyboard.press("x")
|
||||||
|
expect(page.locator("#name")).to_have_value("sx")
|
||||||
|
# A key in the display-name field is text, not a command: no share
|
||||||
|
# started, no share stopped, no picker.
|
||||||
|
expect(page.locator("body")).to_have_attribute("data-live", "off")
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_ctrl_chords_never_share(self, device):
|
||||||
|
errors: list = []
|
||||||
|
page = device("/mirror", errors)
|
||||||
|
|
||||||
|
page.keyboard.press("Control+s")
|
||||||
|
expect(page.locator("body")).to_have_attribute("data-live", "off")
|
||||||
|
expect(page.locator("#toast")).not_to_be_visible()
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_s_says_why_when_capturing_is_unavailable(self, browser, insecure_base_url):
|
||||||
|
"""The no-silent-failure path: `#start` disabled must not make `s` die quietly.
|
||||||
|
|
||||||
|
A plain-HTTP non-localhost origin is a non-secure context, so the
|
||||||
|
browser hides mediaDevices entirely and the page disables `#start` on
|
||||||
|
load. Pressing `s` routes through the button's own path (start() re-
|
||||||
|
checks captureSupport() and re-shows the notice) instead of failing
|
||||||
|
silently.
|
||||||
|
"""
|
||||||
|
errors: list = []
|
||||||
|
context = browser.new_context(viewport={"width": 1100, "height": 720})
|
||||||
|
page = context.new_page()
|
||||||
|
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"{insecure_base_url}/mirror", wait_until="domcontentloaded")
|
||||||
|
|
||||||
|
# The premise: this really is the trap — the browser has hidden the
|
||||||
|
# media API, exactly as it does on a bare-LAN-IP deployment.
|
||||||
|
assert page.evaluate("() => window.isSecureContext") is False
|
||||||
|
assert page.evaluate("() => navigator.mediaDevices === undefined")
|
||||||
|
expect(page.locator("#net")).to_have_attribute("data-state", "online", timeout=10_000)
|
||||||
|
|
||||||
|
# The page says so and disables the button on load…
|
||||||
|
expect(page.locator("#notice")).to_be_visible()
|
||||||
|
expect(page.locator("#notice")).to_contain_text("secure context")
|
||||||
|
expect(page.locator("#start")).to_be_disabled()
|
||||||
|
|
||||||
|
# …and `s` does not fail silently: the notice stays up, nothing starts.
|
||||||
|
page.keyboard.press("s")
|
||||||
|
expect(page.locator("#notice")).to_be_visible()
|
||||||
|
expect(page.locator("#notice")).to_contain_text("secure context")
|
||||||
|
expect(page.locator("body")).to_have_attribute("data-live", "off")
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
context.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSuiteIsPointerFree:
|
||||||
|
"""The kiosk guarantee, enforced on the file itself.
|
||||||
|
|
||||||
|
A test that reaches for the pointer proves nothing about a kiosk. The
|
||||||
|
guarantee lives in the code, not in reviewer attention, so this meta-test
|
||||||
|
bans the pointer calls on this file and fails the suite the moment one
|
||||||
|
slips back in.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_the_suite_never_reaches_for_the_pointer(self):
|
||||||
|
source = Path(__file__).read_text()
|
||||||
|
# Built from parts so this very assertion does not contain the banned
|
||||||
|
# literals (a plain string here would trip the check on itself).
|
||||||
|
assert ("cli" + "ck(") not in source, "a pointer call slipped into the keyboard suite"
|
||||||
|
assert ("hover" + "(") not in source, "a pointer call slipped into the keyboard suite"
|
||||||
@@ -179,6 +179,38 @@ class TestStylesheet:
|
|||||||
]
|
]
|
||||||
assert offenders == []
|
assert offenders == []
|
||||||
|
|
||||||
|
def test_a_hidden_control_is_not_revealed_only_by_hover(self):
|
||||||
|
"""Kiosk rule: a control hidden by default must be reachable without a pointer.
|
||||||
|
|
||||||
|
The tile's controls (`.actions`) start at `opacity: 0`. If the only rule
|
||||||
|
that raises them to full opacity were a `:hover` rule, a mouseless kiosk
|
||||||
|
would have no mute/expand controls at all. So while any `.actions` rule
|
||||||
|
hides them, the stylesheet must also contain a `.actions` reveal rule
|
||||||
|
with a non-hover trigger — keyboard focus or the selection state.
|
||||||
|
"""
|
||||||
|
css = re.sub(r"/\*.*?\*/", "", (STATIC / "app.css").read_text(), flags=re.S)
|
||||||
|
actions_rules = [
|
||||||
|
(selector.strip(), body)
|
||||||
|
for selector, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css)
|
||||||
|
if ".actions" in selector
|
||||||
|
]
|
||||||
|
hiding = [
|
||||||
|
selector
|
||||||
|
for selector, body in actions_rules
|
||||||
|
if re.search(r"opacity\s*:\s*0(?![0-9])", body)
|
||||||
|
]
|
||||||
|
non_hover_reveals = [
|
||||||
|
selector
|
||||||
|
for selector, body in actions_rules
|
||||||
|
if re.search(r"opacity\s*:\s*1(?![0-9])", body)
|
||||||
|
and re.search(r":focus-visible|:focus-within|data-selected", selector)
|
||||||
|
]
|
||||||
|
if hiding:
|
||||||
|
assert non_hover_reveals, (
|
||||||
|
f".actions starts hidden ({sorted(hiding)}) but no rule reveals it "
|
||||||
|
"without a pointer — a mouseless kiosk would have no tile controls"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestAddressGate:
|
class TestAddressGate:
|
||||||
"""mirror has no accounts; the address is the access control."""
|
"""mirror has no accounts; the address is the access control."""
|
||||||
|
|||||||
Reference in New Issue
Block a user