phase: 08_kiosk_page
**Phase 08 final verification pass — complete, all green.**
**Verified (all 5 tasks already implemented):**
- `static/wall.js` (`createWall` + `installWallKeys`), `kiosk.html`/`kiosk.js`, `GET /kiosk` route — guards: no `$("#…")` in wall.js, one keydown listener, kiosk ids no-collision across 3 pages, no audio field in `arrange`, no header/share link in markup, address gate covers `/kiosk`
- Cross-page e2e: arrival-arrangement, keypress-on-one-display moves the other (asserted on the second display's decoded moving pixels), dead mirror → `data-layout="wall"` on both, `followers` count, pointer-free meta-test
**Gates (exact outcomes):**
- `uv run ruff check . && uv run ruff format --check .` — passed
- `uv run pyright` — 0 errors
- `uv run pytest` — 341 passed, 5 skipped (realdisplay-gated), 96s
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — 4 passed, 1 skipped (documented: host has no audio source for real capture)
- Coverage 96% (>90%); only warning is a starlette/httpx third-party deprecation
**Completion criteria:** all 9 ✅ — e2e green incl. `test_kiosk_layout.py` (the one e2e diff is the phase-mandated address-gate extension to `/kiosk`; assertion logic untouched), full suite 100% passing, README documents `/kiosk`, PLAN.md untouched.
**Documentation:**
- README: fixed the Kiosk-client section that implied the display box is permanently pointed at `/watch` — now states the display page is `/kiosk` with the runbook's one-line re-point (installer default still `/watch` this phase, per docs). Rest of README verified against code (routes, config table, CLI flag, presets, key table) — consistent.
- AGENTS.md: fixed 2 stale statements — CDN test moved to `TestPages` (was `TestClientScripts`), suite runtime ~50s → ~95s. No content added.
**Notable:** PLAN.md §6 says "per-leg `maxBitrate` cap (default 12 Mbit/s)" but code/README default is 25 Mbit/s — pre-existing, left untouched (locked file; flagged for a user decision).
**Next pending phase:** `09_wall_remote_page`.
This commit is contained in:
@@ -1,106 +0,0 @@
|
||||
# Phase 08 — The `/kiosk` page: a wall that arranges itself
|
||||
|
||||
**Source:** `TODO.md` L3 — "Add a /kiosk endpoint … so I can just navigate to that endpoint on the kiosk and it will automatically arrange itself to the client's preferences." (the display-client half of that item)
|
||||
**Story:** n/a
|
||||
**Context:** `.agents/PLAN.md` §2 A9 (hand-written ES modules, no framework/bundler), A10 (nothing from outside the origin), A13 (Playwright, `channel="chromium"`); `AGENTS.md` §6 (reuse `net.js` rather than duplicating socket logic; ids must not collide across pages); phase 01 (`01_keyboard_kiosk_control` — the keyboard router, the stage mechanics, the mouseless rule); phase `07_kiosk_layout_state` (the `arrange`/`layout` contract, `hello.kiosk`, `GET /api/kiosk`); `src/mirror/static/watch.js`, `watch.html`, `app.css`, `net.js`; `docs/kiosk-silverblue.md` (the target box: Firefox `--kiosk`, **no keyboard, no mouse, no console**)
|
||||
|
||||
## Objective
|
||||
|
||||
`GET /kiosk`: a chrome-free wall for an unattended display that subscribes to every
|
||||
mirror like `/watch` does and then **does what the server state says** — stages the
|
||||
mirror the state names, fullscreen or not — on arrival and on every change, with no
|
||||
input from anyone standing at the box.
|
||||
|
||||
The phase is deliberately two halves:
|
||||
|
||||
1. **Make the wall reusable** (tasks 01–02). `/kiosk` must not fork 400 lines of
|
||||
tile/WebRTC/stage code (`AGENTS.md` §6: reuse, don't duplicate), so the wall's
|
||||
core moves out of `watch.js` into `static/wall.js` and both pages consume it.
|
||||
Those two tasks change **no behaviour** — phases 01–06's suites are the gate.
|
||||
2. **Build the page on it** (tasks 03–05): the route, the markup, the layout
|
||||
applier, and the browser proof.
|
||||
|
||||
## Rules this page lives by
|
||||
|
||||
* **Arrival is arrangement.** `welcome.layout` is applied before the first roster
|
||||
tile finishes painting. A person who points a kiosk at `/kiosk` must not have to
|
||||
touch it.
|
||||
* **The state is the boss, locally too.** A keypress on the kiosk performs the
|
||||
action *and* publishes it (`arrange`), so the controller and the display cannot
|
||||
disagree — same as every other shared state: one writer path, everyone re-reads.
|
||||
* **Fullscreen is best-effort, the stage is the guarantee.** A remote command
|
||||
carries no user activation, so `requestFullscreen()` may be refused; the in-page
|
||||
stage fills the viewport anyway and the Silverblue window manager already forces
|
||||
the window fullscreen (`docs/kiosk-silverblue/window-config.ini`). The page asks
|
||||
and never blocks on the answer — exactly the stance phase 01 documented for `f`.
|
||||
* **Never invent state.** No auto-staging of the newest mirror, no remember-last
|
||||
across a server restart. An empty state means the wall.
|
||||
* **No audio commands.** The layout carries no sound field (phase 07,
|
||||
`SECURITY.md` §4); a display never becomes audible because a stranger asked.
|
||||
* **The chrome stays off the screen.** No header bar, no "Share a screen" button,
|
||||
no counts in a title bar — a display shows mirrors. The three shared ids
|
||||
(`net`, `notice`, `toast`) are the only elements `/kiosk` may share with another
|
||||
page, and `#net` stays because the connection state is the one thing that must be
|
||||
legible in a screenshot of a wall nobody is looking at.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `07_kiosk_layout_state` (todo) — the `arrange`/`layout` frames, `hello.kiosk`,
|
||||
`welcome.layout`, the `followers` count, `GET /api/kiosk`. Task 01 of this phase
|
||||
has no dependency on it and may run first, but the phase is not complete until
|
||||
07 exists.
|
||||
- `01_keyboard_kiosk_control` (complete) — the router, `stage()`/`unstage()`, the
|
||||
mouseless rule and the pointer-free suite this phase extracts and extends.
|
||||
- `06_sharer_quality_presets` (complete) — the current behaviour of the wall.
|
||||
|
||||
## Tasks
|
||||
|
||||
1. `01_extract_wall_tiles_and_stage.md` — the tile/peer/stage/mute core of
|
||||
`watch.js` becomes `static/wall.js` (`createWall()`); `app.css` re-keys the
|
||||
stage rules from `#watch-stage` to a `.stage` class. No behaviour change.
|
||||
2. `02_extract_wall_key_router.md` — the single `keydown` switch becomes
|
||||
`installWallKeys()` in `wall.js`, dispatching named commands to the page;
|
||||
`/watch`'s key map is byte-identical in effect.
|
||||
3. `03_kiosk_page_and_route.md` — `static/kiosk.html`, `GET /kiosk` in `app.py`
|
||||
(and in `PAGES` for `no-store`), and every structural guard in `test_app.py`
|
||||
extended to a third page.
|
||||
4. `04_kiosk_layout_applier.md` — `static/kiosk.js`: `hello{kiosk:true}`, the wall
|
||||
from `createWall()`, `applyLayout()` on `welcome`/`layout`, and local keys
|
||||
published back with `arrange`.
|
||||
5. `05_kiosk_layout_e2e_and_docs.md` — `tests/e2e/test_kiosk_layout.py`: a real
|
||||
sharer, two kiosk displays (isolated contexts, hence separate sockets and peer
|
||||
ids), and the assertion that one display's keypress re-arranges the other,
|
||||
proved on the second display's decoded pixels; plus the `/kiosk` docs.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- **Refactor gate (tasks 01–02)**: `tests/e2e/test_keyboard_wall.py`,
|
||||
`test_live_mirror.py`, `test_audio_share.py`, `test_stage_autofade.py`,
|
||||
`test_share_layout.py` pass **unedited**. A changed assertion in one of them
|
||||
during a "behaviour-preserving" extraction is a bug report, not a fix.
|
||||
- **Structure gate (task 03)**: `test_app.py`'s guards extended — page renders,
|
||||
`no-store`, `node --check`, every `$("#id")` exists, ids do not collide across
|
||||
*three* pages, `<video>` is `muted playsinline`, nothing from a CDN, the address
|
||||
gate refuses `/kiosk` like the rest of the app. A new guard: `static/wall.js`
|
||||
contains **no** `$("#…")` — one module serving two pages takes its elements as
|
||||
arguments or it will look them up in the wrong document.
|
||||
- **Behaviour gate (tasks 04–05)**: a dedicated Playwright suite, pointer-free like
|
||||
phase 01's (a mouse on the kiosk would defeat the point), asserting on the
|
||||
*second* display's `<video>` (`videoWidth`, distinct frames via the `conftest`
|
||||
`frames_matching()` helper) and on `data-layout`/`data-fullscreen` — never on
|
||||
whether a handler ran. Fullscreen is asserted on the stage class, not on
|
||||
`document.fullscreenElement`, which the browser may legitimately refuse.
|
||||
- Coverage: **>90%** on new/modified code. The Python surface added here is one
|
||||
route; the JS is covered by browser assertions, per `AGENTS.md` §4.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `uv run pytest -m e2e` green, including the new `test_kiosk_layout.py`, with **no existing e2e assertion edited**
|
||||
- [ ] pointing a browser at `/kiosk` and sharing a mirror from a second browser process shows that mirror on the wall with no interaction on the first
|
||||
- [ ] a keypress on one `/kiosk` page re-arranges a second `/kiosk` page (the shared-state property, asserted cross-page, not in one page's DOM)
|
||||
- [ ] the staged mirror going away returns both displays to the wall (`data-layout="wall"`)
|
||||
- [ ] `/kiosk` renders no header, no share link, and refuses a blocked address with the same explanation page as `/watch`
|
||||
- [ ] `uv run pytest tests/test_app.py` green with the guards extended to three pages, and `wall.js` proven free of document-scoped lookups
|
||||
- [ ] all four gates from `AGENTS.md` §4 green, `MIRROR_REALDISPLAY=1 -m realdisplay` included
|
||||
- [ ] full test suite **100% passing**, coverage >90% on new code
|
||||
- [ ] README.md documents `/kiosk`; `PLAN.md` untouched
|
||||
@@ -1,125 +0,0 @@
|
||||
# Task 01 — Extract the wall (tiles, peers, stage) into `static/wall.js`
|
||||
|
||||
**Phase:** `08_kiosk_page` · **Source:** `TODO.md:3` — "a /kiosk endpoint which stores 'state' (fullscreen, which mirror is being viewed) … [that] will automatically arrange itself" (the wall has to exist twice before a page can arrange it)
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
Move the wall's core out of `watch.js` into a reusable `static/wall.js` module that
|
||||
takes its elements and its socket from the page, and have `watch.js` consume it.
|
||||
**Pure refactor: `/watch` must behave and look exactly as it did.** The kiosk page is
|
||||
impossible without this, and forking the tile/WebRTC code instead would violate
|
||||
`AGENTS.md` §6.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/mirror/static/wall.js` (new) — a factory, no document queries, no socket of
|
||||
its own, no top-level side effects beyond the export:
|
||||
|
||||
```js
|
||||
/**
|
||||
* The wall: one tile per live mirror, one RTCPeerConnection per tile, and a
|
||||
* stage the staged tile's own element moves into (so its <video> and its
|
||||
* connection never restart — AGENTS.md §6).
|
||||
*
|
||||
* Nothing here queries the document: the page hands over the elements it owns,
|
||||
* because one module now serves /watch, /kiosk and (phase 09) /remote, whose
|
||||
* ids must not collide (test_the_two_pages_share_no_element_ids…).
|
||||
*
|
||||
* @param {{grid: Element, stageEl: Element, template: HTMLTemplateElement,
|
||||
* send: (payload: object) => boolean,
|
||||
* onChange?: (summary: {mirrors: number, live: number}) => void,
|
||||
* stats?: boolean}} options
|
||||
*/
|
||||
export function createWall(options) { … }
|
||||
```
|
||||
|
||||
Return the surface the pages need — at minimum `absorb(stream)`,
|
||||
`drop(streamId)`, `onSignal(message)`, `stage(streamId, options)`,
|
||||
`unstage(options)`, `setMuted(tile, muted)`, `ordered()`, `selectedId()`,
|
||||
`setSelected(streamId)`, `moveSelection(delta)`, `tile(id)`, `closePeers()`, and
|
||||
a `stagedId()` accessor. Keep the existing function bodies as the implementation;
|
||||
the change is *which object owns the state*, not what the code does.
|
||||
2. `src/mirror/static/wall.js` — move, verbatim except for the `ui.`/`state.` reads
|
||||
the factory replaces, from `watch.js`: `tiles`, `makeTile`, `paintTile`,
|
||||
`dropTile`, `closePeer`, `ensurePeer` (with its `ontrack`/`onicecandidate`/
|
||||
`onconnectionstatechange` handlers and the retry-with-backoff), `onSignal`,
|
||||
`setMuted`, `subscribe`, `absorb`, `orderedTiles`, `stage`, `unstage`,
|
||||
`revealStageControls` + `stageRevealTimer` + `STAGE_REVEAL_IDLE_MS`,
|
||||
`fullscreenExitInFlight`, and the stats ticker. Every comment that explains *why*
|
||||
(the single-MediaStream-per-tile story, the audio-only-on-stage policy, the
|
||||
fullscreen-exit race, "never move `srcObject`, move the element") travels with
|
||||
the code — those comments are why phases 01–06 do not get re-broken.
|
||||
`renderCounts()` does **not** move: it writes `#counts`/`#empty`, which are
|
||||
`/watch` elements. The wall calls `onChange({mirrors, live})` wherever
|
||||
`renderCounts()` is called today, and `watch.js` keeps its own `renderCounts()`
|
||||
as that callback.
|
||||
3. `src/mirror/static/wall.js` — the socket is the page's. Take `send(payload)` and
|
||||
use it wherever the moved code used `state.ws.send(...)`
|
||||
(`subscribe`, ICE candidates, the retry's re-`subscribe`), and guard on its
|
||||
truthiness the way the code guards on `state.ws` today. This is what lets a page
|
||||
whose socket is not open yet build a wall, and what keeps `net.js` as the only
|
||||
place a socket exists (`AGENTS.md` §6).
|
||||
4. `src/mirror/static/watch.js` — delete the moved code, import
|
||||
`{ createWall }` from `./wall.js`, build the wall after `loadConfig()` with the
|
||||
elements already in its `ui` map (`grid`, `stage`, `template`) and
|
||||
`send: (payload) => state.ws.send(payload)`, and call `wall.absorb/drop/onSignal`
|
||||
from the `onMessage` switch, `wall.closePeers()` from `onClose`,
|
||||
`wall.stage/unstage/…` from the key router (task 02 moves the router itself).
|
||||
Keep in `watch.js`: the config load, the socket, `renderCounts`, `setNet`, the
|
||||
help overlay, the `focusin` handler (via `wall.setSelected`), the refusal notice,
|
||||
the share link.
|
||||
5. `src/mirror/static/watch.js` — keep the `fullscreenchange` listener **in the
|
||||
page** (it is a page policy: `/watch` unstages when the browser leaves
|
||||
fullscreen; the kiosk will do something different, phase 08 task 04), calling
|
||||
`wall.unstage()`. The `fullscreenExitInFlight` flag it depends on moves into
|
||||
`wall.js`, so expose the flag's two behaviours through `unstage()`/a small
|
||||
`handleFullscreenChange(hasFullscreen)` hook — the shape is yours, but the race
|
||||
documented at watch.js L345–352 must stay closed.
|
||||
6. `src/mirror/static/app.css` — the stage rules are keyed to the `/watch` id and
|
||||
would not style a second page's stage. Add `class="stage"` to
|
||||
`<div id="watch-stage">` in `watch.html` (keep the id — the e2e suites and
|
||||
`watch.js` both select it) and re-key these selectors from `#watch-stage` to
|
||||
`.stage`, changing nothing else in the blocks: `#watch-stage { … }` (L565),
|
||||
`body.staged #watch-stage` (L574), `#watch-stage .tile` (L577),
|
||||
`#watch-stage .tile video`, `#watch-stage .tile .cap`, and the
|
||||
`#watch-stage .tile[data-audio="on"|"off"]::after` pair (L471, L477). No new
|
||||
`display` declarations, no `[hidden]` rules — `TestStylesheet` guards those.
|
||||
7. `src/mirror/static/watch.js` — the dead `if (ui.unstage) …` branch at L473: it is
|
||||
unreachable today (`ui` has no `unstage` key) and moving it would launder dead
|
||||
code into the new module. Delete it and leave a one-line comment saying the exit
|
||||
control is the per-tile `.exit` button, so nobody re-adds a second exit.
|
||||
8. Do **not** add `/kiosk`, `kiosk.html` or `kiosk.js` in this task, and do not
|
||||
change any key binding, DOM structure of the tile, or visual rule beyond the
|
||||
`#watch-stage` → `.stage` re-key.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- **The gate is the existing suites, unedited** (`AGENTS.md` §4/§5):
|
||||
`uv run pytest -m e2e -k "keyboard or live_mirror or audio or stage or share_layout"`,
|
||||
then the full `-m e2e`. These cover tile rendering, the stage transitions, audio
|
||||
policy, the autofade timing, and the peer-connection probes through
|
||||
`window.__probe` — i.e. exactly the machinery being moved.
|
||||
- `tests/test_app.py` — `test_scripts_parse_as_es_modules` gains `"wall.js"`,
|
||||
`test_static_assets_are_served` gains `"wall.js"`.
|
||||
`test_every_element_the_script_reaches_for_exists` must still pass for
|
||||
`watch.js` (the ids it still reaches for exist) — and add the new module rule:
|
||||
*`wall.js` must contain no `$("#…")`*, because a module shared by pages with
|
||||
different ids may never look an element up in the document it happens to be
|
||||
loaded in. Assert it as a source-level guard next to the others, with a comment
|
||||
naming `test_the_two_pages_share_no_element_ids_but_the_shared_chrome` as the
|
||||
reason it exists.
|
||||
- Visual check while working: `/watch` at 1280×800 and 390×844, staged and
|
||||
unstaged — the stage must be pixel-equivalent (the re-key must not change
|
||||
specificity in a way that reorders a rule).
|
||||
- Coverage: **>90%** on new/modified code — the moved code is covered by the e2e
|
||||
suites that already exercise it; the module has no new logic in this task.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `uv run pytest -m e2e` green with **zero assertions changed** in any existing file
|
||||
- [ ] `uv run pytest tests/test_app.py` green, including `wall.js` in the parse/asset lists and the new "no `$("#…")` in `wall.js`" guard
|
||||
- [ ] `/watch` looks and behaves as before: grid, click-to-stage, `Esc`/`g`, the autofading overlay, one audible mirror
|
||||
- [ ] `git diff --stat` shows the moved lines as moves, not rewrites (a reviewer should be able to read the diff as "cut, paste, re-point at `options`")
|
||||
- [ ] full test suite **100% passing**
|
||||
- [ ] no behavior change in completed work
|
||||
@@ -1,106 +0,0 @@
|
||||
# Task 02 — The key router becomes `installWallKeys()` in `wall.js`
|
||||
|
||||
**Phase:** `08_kiosk_page` · **Source:** `TODO.md:3` — "the kiosk doesn't have a keyboard/mouse plugged in most of the time so I can't full screen/switch views" (the display must accept the same keys *and* report them)
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
Move `/watch`'s single `keydown` switch into `wall.js` as `installWallKeys()`, so
|
||||
`/kiosk` gets the identical key map for free and — this is the point — can publish
|
||||
what a key did instead of only performing it. Again **no behaviour change on
|
||||
`/watch`**: phase 01's pointer-free suite is the gate.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/mirror/static/wall.js` — export the router, keeping the invariant phase 01
|
||||
wrote down (one listener, one switch; every command is a case here, never a new
|
||||
listener):
|
||||
|
||||
```js
|
||||
/**
|
||||
* The one keydown listener for a wall page. It maps keys to named commands and
|
||||
* hands them to the page; it never decides what a command does to shared state.
|
||||
*
|
||||
* A page returns `true` only when the key actually changed something — the
|
||||
* router calls `preventDefault()` on exactly those, which is today's discipline
|
||||
* on /watch (`g` unstaged and `Esc` with nothing open are not "handled" and keep
|
||||
* their browser default).
|
||||
*
|
||||
* Never intercepted: any chord with Ctrl/Cmd/Alt (back, reload, copy belong to
|
||||
* the browser) and every key inside an <input>/<textarea>/<select>/contenteditable.
|
||||
*
|
||||
* @param {{wall: object, onCommand: (command: string, detail: {key: string, index?: number, event: KeyboardEvent}) => boolean, root?: Document}} options
|
||||
*/
|
||||
export function installWallKeys(options) { … }
|
||||
```
|
||||
|
||||
2. `src/mirror/static/wall.js` — move `onWallKey`'s switch body into the module,
|
||||
translating each case into a command name, in this order and with these names
|
||||
(they are the contract `/kiosk` publishes against):
|
||||
|
||||
| key(s) | command | detail |
|
||||
|---|---|---|
|
||||
| `j` `arrowdown` | `next` | — (module performs `wall.moveSelection(1)` itself, then reports handled) |
|
||||
| `k` `arrowup` | `previous` | — |
|
||||
| `enter` | `stage` | — |
|
||||
| `f` | `fullscreen` | — |
|
||||
| `g` | `wall` | — |
|
||||
| `n` / `p` | `step_next` / `step_previous` | — |
|
||||
| `m` | `mute` | — |
|
||||
| `?` | `help` | — |
|
||||
| `escape` | `escape` | — |
|
||||
| `1`–`9` | `jump` | `index` (0-based) |
|
||||
|
||||
Keys are matched case-insensitively exactly as today (a caps-locked kiosk, a
|
||||
remote hand holding Shift). `next`/`previous` stay module-side because moving the
|
||||
selection is wall state, not page policy — the page still sees the command, so a
|
||||
page that wants to publish the new cursor can.
|
||||
3. `src/mirror/static/wall.js` — the "any keypress while staged reveals the
|
||||
overlay" behaviour (today `if (state.staged) revealStageControls();` before the
|
||||
switch) moves with it, *before* dispatch, and must not `preventDefault` —
|
||||
an unhandled key keeps flashing the caption, which is the debuggable outcome
|
||||
phase 01 documented.
|
||||
4. `src/mirror/static/wall.js` — commands that are pure wall operations
|
||||
(`stage`, `wall`, `step_*`, `mute`, `jump`) get a **default implementation in the
|
||||
module** used when the page returns nothing for them, so `onCommand` can be a
|
||||
thin observer. `stage`/`jump` resolve their target with today's rule (the
|
||||
selected tile, else the first; `toast` when the wall is empty — `toast` comes
|
||||
from `net.js`, which `wall.js` may import). `help` and `escape` have **no**
|
||||
default: they are page policy (`/watch` closes the `?` overlay before leaving the
|
||||
stage), and a page that ignores them must not break the router.
|
||||
5. `src/mirror/static/watch.js` — delete `onWallKey`, `moveSelection`,
|
||||
`stageSelectedOrFirst`, `stepStage`, `toggleHelp`'s key wiring and the
|
||||
`document.addEventListener("keydown", …)` line; call
|
||||
`installWallKeys({ wall, onCommand })` instead, where `onCommand` keeps today's
|
||||
exact behaviour for `help` (toggle `#wall-help`, return whether it changed) and
|
||||
`escape` (overlay first, then `wall.unstage()`, return whether either happened),
|
||||
and returns `false`/nothing for the commands the module already performs.
|
||||
`?`'s "focus is deliberately never touched" comment moves with the behaviour.
|
||||
6. `src/mirror/static/watch.js` — keep `focusTile`, `setSelected` and the grid's
|
||||
`focusin` handler as they are now that they call into `wall` (task 01); Tab must
|
||||
still agree with `j`/`k`.
|
||||
7. No new page, no route, no kiosk markup in this task.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- **The gate**: `uv run pytest tests/e2e/test_keyboard_wall.py` **unedited** — the
|
||||
file drives every binding with `page.keyboard.press()` and asserts the DOM result,
|
||||
and its meta-test forbids the pointer in itself. If a case fails, the router moved
|
||||
wrong; do not touch the test.
|
||||
- `tests/test_app.py` — add one source-level guard in the spirit of the existing
|
||||
ones: `installWallKeys` is defined in `wall.js` and `watch.js` contains **no**
|
||||
`addEventListener("keydown"` — the "one listener per wall page" rule should fail
|
||||
the build when someone adds a second one, which was phase 01's whole point.
|
||||
- `uv run pytest -m e2e -k "keyboard or live_mirror or audio or stage"` green, then
|
||||
the whole `-m e2e`.
|
||||
- Coverage: **>90%** on new/modified code (the router is exercised by the browser
|
||||
suite on every binding).
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] every key in phase 01's key map does on `/watch` exactly what it did before, asserted by `tests/e2e/test_keyboard_wall.py` with no edit
|
||||
- [ ] `grep -n 'addEventListener("keydown"' src/mirror/static/*.js` shows exactly one hit, inside `wall.js`
|
||||
- [ ] `g` when already unstaged and `Esc` with nothing open still do not `preventDefault` (browser default preserved)
|
||||
- [ ] `uv run pytest tests/test_app.py` green including the new single-listener guard
|
||||
- [ ] full test suite **100% passing**
|
||||
- [ ] no behavior change in completed work
|
||||
@@ -1,118 +0,0 @@
|
||||
# Task 03 — `GET /kiosk` and the page that hosts the wall
|
||||
|
||||
**Phase:** `08_kiosk_page` · **Source:** `TODO.md:3` — "Add a /kiosk endpoint … so I can just navigate to that endpoint on the kiosk"
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
The endpoint and the markup: a chrome-free page whose only job is to hold a wall and
|
||||
expose the state a test (and a screenshot) can read. No layout behaviour yet —
|
||||
task 04 writes `kiosk.js` against this skeleton.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/mirror/app.py` — the route, beside `/watch`, in the same shape:
|
||||
|
||||
```python
|
||||
@app.get("/kiosk", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def kiosk_page() -> FileResponse:
|
||||
"""A wall for an unattended display: subscribes like /watch and arranges
|
||||
itself from the shared layout (phase 07/08)."""
|
||||
return _page("kiosk.html")
|
||||
```
|
||||
|
||||
Add `"/kiosk"` to the `PAGES` tuple so the middleware keeps sending
|
||||
`Cache-Control: no-store` on it, like the other pages.
|
||||
2. `src/mirror/static/kiosk.html` (new) — model it on `watch.html` (same `<head>`,
|
||||
`/static/app.css`, the same inline `data:` favicon — **no** external asset, A10),
|
||||
`<title>Wall · mirror</title>` — `docs/kiosk-silverblue/check-kiosk.sh` proves a
|
||||
client is showing what it should by grepping the served page's `<title>`, so this
|
||||
string is a contract, not a label (phase 09 task 03 widens that check to accept
|
||||
it) — and these elements and nothing else:
|
||||
|
||||
* `<body class="kiosk" data-layout="wall" data-fullscreen="false">` — the two
|
||||
layout knobs, mirrored onto the body so a screenshot and a test can read the
|
||||
state without reaching into JS (`docs/kiosk-silverblue/dim-probe.py` judges a
|
||||
wall from a screenshot; `data-audio` on a tile exists for the same reason).
|
||||
* `<main class="grid" id="kiosk-grid" hidden>` and `<div class="empty" id="kiosk-empty">`
|
||||
— the classes are what the stylesheet and `wall.js` select; the ids are page-unique.
|
||||
The empty state reads "No mirrors live" plus
|
||||
`Share from <code id="kiosk-share-url">…</code>` — `kiosk.js` fills that with
|
||||
`location.origin + "/mirror"`, because nobody is standing at the box to click
|
||||
anything and the person reading the screen needs a URL to type elsewhere.
|
||||
* `<div class="stage" id="kiosk-stage" aria-live="polite">` — **`class="stage"`
|
||||
is mandatory**: task 01 re-keyed the staging rules off `#watch-stage`, and
|
||||
without the class the kiosk stage would be an unstyled `div`.
|
||||
* the shared chrome ids and only these: `<span class="net" id="net"
|
||||
data-state="connecting"><i></i><span>connecting…</span></span>`,
|
||||
`<div class="notice" id="notice" hidden>`, `<div class="toast" id="toast" hidden>`.
|
||||
`#net` is not decoration — it is how a wall reports "the server is gone" in a
|
||||
screenshot, and every e2e `device()` helper waits on it.
|
||||
* `<template id="kiosk-tile-template">` — a copy of `watch.html`'s
|
||||
`#tile-template` tile (`article.tile[tabindex=0][data-state=connecting]`,
|
||||
`<video autoplay playsinline muted>`, `.badge`, `.actions` with `.audio` and
|
||||
`.expand`, `.cap` with `.title`/`.sub`/`.exit`). Name it
|
||||
`kiosk-tile-template`, never `tile-template`: the id-collision guard is the
|
||||
reason two pages may not share an id, and relaxing it for convenience is how
|
||||
the `#stage` bug happened.
|
||||
* `<div class="hud" id="kiosk-hud" aria-live="polite">` — one line the script
|
||||
owns: `N mirrors · wall` / `… · <label> · fullscreen`. It is the wall's
|
||||
self-description for a room with no keyboard.
|
||||
* **No** `<header class="bar">`, no counts in a title bar, no "Share a screen"
|
||||
link: a display shows mirrors.
|
||||
* `<script type="module" src="/static/kiosk.js">` — task 04 writes it; create a
|
||||
placeholder `kiosk.js` in this task that only imports `loadConfig` and
|
||||
`refusalNotice` from `./net.js` so the page parses, serves and shows a refusal
|
||||
notice, and nothing more.
|
||||
3. `src/mirror/static/app.css` — the new page's chrome only: `.kiosk` (no header
|
||||
space, full-bleed grid), `.kiosk .net` (fixed top-right, the same pill), `.hud`
|
||||
(fixed bottom-left, `--mono`, `--fg-dim`, small, `pointer-events: none`). Do not
|
||||
duplicate any stage/tile rule — those are `.stage`/`.tile` now. Add no rule whose
|
||||
selector mentions `[hidden]` and no `display` declaration on a component the
|
||||
stylesheet already owns (`TestStylesheet` guards both).
|
||||
4. `src/mirror/static/index.html` — a third `<a class="card" href="/kiosk">`
|
||||
("GET /kiosk — An unattended wall") so the endpoint is discoverable from `/`.
|
||||
Cards carry no ids, so the id guard is unaffected.
|
||||
5. Do **not** touch `/remote` (phase 09), `watch.html`, or the layout applier.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- `tests/test_app.py` — extend the structural guards to the third page; each of
|
||||
these is an existing test gaining a case, not a new invention:
|
||||
* `TestPages::test_pages_render` → `("/kiosk", "kiosk-tile-template")`;
|
||||
* `test_pages_are_not_cacheable` → `client.get("/kiosk")` is `no-store`;
|
||||
* `test_static_assets_are_served` → `kiosk.js`;
|
||||
* `test_scripts_parse_as_es_modules` → `"kiosk.js"` (`node --check`);
|
||||
* `test_every_element_the_script_reaches_for_exists` → `("kiosk.js", "kiosk.html")`;
|
||||
* `test_both_pages_load_the_stylesheet_and_their_module` → the `kiosk.html` /
|
||||
`kiosk.js` pair (rename it if the name now lies — the guard matters more than
|
||||
the name);
|
||||
* `test_videos_are_muted_and_inline_so_autoplay_is_not_blocked` → `kiosk.html`
|
||||
(both autoplay rules apply to a kiosk: iOS/Safari blocks the wall otherwise);
|
||||
* `test_the_two_pages_share_no_element_ids_but_the_shared_chrome` → add
|
||||
`kiosk.html` to the ids map and assert `kiosk ∩ watch`, `kiosk ∩ mirror`,
|
||||
`kiosk ∩ index` are all exactly `{"net","notice","toast"}`. Keep the existing
|
||||
assertions verbatim; `#kiosk-*` prefixes are what make these pass.
|
||||
* add `#kiosk-grid`, `#kiosk-stage`, `#kiosk-hud` to the "elements a page must
|
||||
have" style check if the file has one, and assert `class="stage"` appears on
|
||||
`#kiosk-stage` **and** `#watch-stage` — a page whose stage lost the class
|
||||
renders a full-size unstyled video, which is exactly the class of bug the
|
||||
stylesheet guards exist for.
|
||||
- `tests/test_app.py::TestAddressGate` — `/kiosk` joins the parametrised "a refused
|
||||
address gets no page, no roster, no assets" list, and one test that an admitted
|
||||
address gets `200`.
|
||||
- `tests/e2e/test_live_mirror.py` — the refused-address parametr list
|
||||
`("/mirror", "/watch")` gains `"/kiosk"`, and its
|
||||
`expect(page.locator("#start, #grid, #notice")).to_have_count(0)` selector list
|
||||
gains `#kiosk-grid` / `#kiosk-stage` so the assertion actually covers the new page.
|
||||
- Coverage: **>90%** on new/modified Python (one route — covered by the page tests).
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `curl -s localhost:8055/kiosk | head -20` serves the page and `curl -sI` shows `cache-control: no-store`
|
||||
- [ ] opening `/kiosk` in a browser shows an empty-state wall with the share URL in it, no header bar, the net pill top-right, and no console errors
|
||||
- [ ] `uv run pytest tests/test_app.py` green with every guard above extended to three pages
|
||||
- [ ] `uv run pytest -m e2e` green (existing suites unedited except the two parametrisations named here)
|
||||
- [ ] `grep -c 'id="' src/mirror/static/kiosk.html` ids appear in no other page except `net`/`notice`/`toast` — which the extended guard now proves
|
||||
- [ ] full test suite **100% passing**
|
||||
- [ ] no behavior change in completed work
|
||||
@@ -1,122 +0,0 @@
|
||||
# Task 04 — `kiosk.js`: obey the layout, publish the keystrokes
|
||||
|
||||
**Phase:** `08_kiosk_page` · **Source:** `TODO.md:3` — "state (fullscreen, which mirror is being viewed) that any client can manipulate and will update immediately so I can just navigate to that endpoint on the kiosk and it will automatically arrange itself to the client's preferences"
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
The behaviour: `/kiosk` opens, subscribes to every mirror, and arranges itself from
|
||||
`welcome.layout` without being touched; every `layout` frame that follows re-arranges
|
||||
it; and a keypress on the box — rare, but it happens when a keyboard is plugged in to
|
||||
diagnose — performs the change **and** publishes it, so the controller and the display
|
||||
converge instead of disagreeing.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/mirror/static/kiosk.js` — replace task 03's placeholder. Module shape, in
|
||||
the order `watch.js` uses it: `loadConfig()` → `rtcConfig()` → `ui` map →
|
||||
`createWall()` → `connectSignaling()` → `installWallKeys()`. The `ui` map is
|
||||
`grid`, `stage`, `template`, `empty`, `shareUrl`, `hud`, `net`, `toast`, `notice`
|
||||
— every one of them exists in `kiosk.html` (the element guard proves it).
|
||||
2. `src/mirror/static/kiosk.js` — announce as a display, not a controller:
|
||||
`state.ws.send({type:"hello", role:"viewer", kiosk:true, name:"", device: deviceLabel()})`.
|
||||
`deviceLabel()` comes from `net.js`. `kiosk:true` is what feeds `followers`, and a
|
||||
`/kiosk` page must be counted while a `/remote` page must not.
|
||||
3. `src/mirror/static/kiosk.js` — the applier, the one function that touches the
|
||||
stage:
|
||||
|
||||
```js
|
||||
/**
|
||||
* Make the page match the server's layout. Idempotent: a frame that repeats
|
||||
* what is already applied changes nothing, so a controller that re-sends (or a
|
||||
* follower join that re-broadcasts) cannot re-request fullscreen forever.
|
||||
*
|
||||
* The caller is never trusted to have waited: `staged` names a live mirror when
|
||||
* the server accepts it, and may have died on the way here, so an unknown id
|
||||
* keeps the wall and is retried by the next roster frame.
|
||||
*/
|
||||
function applyLayout(next) { … }
|
||||
```
|
||||
|
||||
* `next.staged` is a tile the wall has → `wall.stage(id)`; is `null` →
|
||||
`wall.unstage()`; names something unknown → unstage and remember the intent,
|
||||
re-applying whenever `absorb()` gains that tile.
|
||||
* `next.fullscreen` → request on the same target `/watch` uses (the stage element
|
||||
when staged, `document.documentElement` for the wall), `.catch(() => {})` and
|
||||
never awaited. A remote command carries no user activation: the request is a
|
||||
courtesy, the `.stage` CSS is the guarantee, and the Silverblue window is
|
||||
already fullscreen (`docs/kiosk-silverblue/window-config.ini`). Say exactly that
|
||||
in a comment — the next reader will otherwise "fix" it by awaiting.
|
||||
* Mirror both knobs onto the body — `document.body.dataset.layout =
|
||||
staged ? "stage" : "wall"` and `document.body.dataset.fullscreen = "true"|"false"`,
|
||||
and set `document.title` to the staged mirror's label (`"mirror — <label>"`, else
|
||||
`"mirror — wall"`), because a window list and a screenshot are the only views a
|
||||
headless box offers.
|
||||
4. `src/mirror/static/kiosk.js` — apply on arrival and on every change:
|
||||
`welcome` → `applyLayout(message.layout)` (before `renderCounts()`-equivalent work,
|
||||
so the wall never flashes between the roster and the stage), `layout` →
|
||||
`applyLayout(message)`. `stream_added`/`stream_updated` → `wall.absorb()` then
|
||||
`applyLayout(state.layout)` so a remembered intent lands the moment its tile does.
|
||||
`stream_removed` → `wall.drop()` (the server pushes the cleared layout behind it —
|
||||
do not "helpfully" unstage locally, or two frames will fight).
|
||||
5. `src/mirror/static/kiosk.js` — the keys, via `installWallKeys({wall, onCommand})`,
|
||||
each entry doing the local change **and** publishing the same intent:
|
||||
|
||||
| command | local effect | published `arrange` |
|
||||
|---|---|---|
|
||||
| `next` / `previous` | (module moves the selection) | none — the cursor is local to this page |
|
||||
| `stage` (`Enter`) | `wall.stage(cursor ?? first)` | `{staged: <id>}` |
|
||||
| `wall` (`g`), `escape` | `wall.unstage()` | `{staged: null}` |
|
||||
| `step_next` / `step_previous` | stage the neighbour | `{staged: <id>}` |
|
||||
| `jump` (`1`–`9`) | stage the Nth | `{staged: <id>}` |
|
||||
| `fullscreen` (`f`) | re-request fullscreen | `{fullscreen: true}` |
|
||||
| `mute` (`m`) | `wall.setMuted(tile, …)` | **none** |
|
||||
|
||||
`mute` is never published, and the code must say why in one comment line:
|
||||
`SECURITY.md` §4 — sound only ever starts from a gesture on the device that plays
|
||||
it, so a display must not become audible because another machine asked. `help`
|
||||
(`?`) has no target on this page (no overlay markup) and returns not-handled.
|
||||
6. `src/mirror/static/kiosk.js` — what the page does **not** publish: the browser's
|
||||
own fullscreen exits. Register no `fullscreenchange` policy that unstages (the WM
|
||||
dropping fullscreen is not a person asking to leave the stage — on `/watch` that
|
||||
is a deliberate choice, on a wall it would fight the state) and do not echo
|
||||
`fullscreen:false` because the browser refused a request; that would make a
|
||||
controller's "fullscreen" button look broken and would be reporting the browser's
|
||||
refusal as a user preference.
|
||||
ASSUMPTION: publish what a person asked for, not what the browser conceded.
|
||||
7. `src/mirror/static/kiosk.js` — the honest-but-quiet details:
|
||||
* `onChange({mirrors, live})` → `#kiosk-hud` text (`3 mirrors · wall`,
|
||||
`3 mirrors · Alice's laptop · fullscreen`) and the `#kiosk-grid`/`#kiosk-empty`
|
||||
`hidden` toggles `/watch` does in `renderCounts()`;
|
||||
* `#kiosk-share-url` gets `location.origin + "/mirror"` on load;
|
||||
* `refusalNotice(config)` → `showNotice(ui.notice, …)` and never open a socket,
|
||||
exactly as `/watch` does;
|
||||
* `onClose` → `wall.closePeers()` and mark the tiles stale; the next `welcome`
|
||||
re-applies the layout, which is what makes a server restart recover on its own.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- `tests/test_app.py` — the element guard from task 03 already forces every
|
||||
`$("#…")` in `kiosk.js` to exist; add one source-level guard that `kiosk.js`
|
||||
**never** sends a `mute`/audio field in an `arrange` frame (assert no `arrange`
|
||||
object literal in the file contains `muted`/`audio`) — the security rule should
|
||||
fail the build, not live in a comment.
|
||||
- `uv run pytest -m e2e` — `/watch`'s behaviour must be untouched by this task; the
|
||||
new page's behaviour is covered in task 05.
|
||||
- Manual, before task 05 exists: `uv run mirror serve`, two browser windows, one on
|
||||
`/kiosk`; drive the state with `websocat` (`{"type":"arrange","staged":"<id>"}`) and
|
||||
watch the page stage it — AGENTS.md §8's "reproduce at the lowest layer" ladder
|
||||
reversed: prove the layer works before automating it.
|
||||
- Coverage: **>90%** on new/modified code — the applier is exercised by task 05's
|
||||
suite; the guards above cover the security rules statically.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] a `/kiosk` tab opened **after** an `arrange` is already showing that mirror when it finishes loading (no flash of the wall)
|
||||
- [ ] an `arrange` sent from `websocat` re-arranges an open `/kiosk` tab within one frame
|
||||
- [ ] a keypress on `/kiosk` (`Enter`, `g`, `n`, `p`, `1`–`9`, `f`) changes `GET /api/kiosk` — the display is a first-class controller
|
||||
- [ ] `m` on `/kiosk` mutes locally and leaves `/api/kiosk` byte-identical
|
||||
- [ ] `document.body.dataset.layout` / `dataset.fullscreen` agree with what is on screen at every point a test can observe
|
||||
- [ ] `uv run pytest tests/test_app.py` and the full `-m e2e` suite green, existing assertions unedited
|
||||
- [ ] full test suite **100% passing**
|
||||
- [ ] no behavior change in completed work
|
||||
@@ -1,92 +0,0 @@
|
||||
# Task 05 — `tests/e2e/test_kiosk_layout.py` and the kiosk docs
|
||||
|
||||
**Phase:** `08_kiosk_page` · **Source:** `TODO.md:3` — "will update immediately so I can just navigate to that endpoint on the kiosk and it will automatically arrange itself to the client's preferences"
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
Prove the two properties the TODO item actually claims — **it arranges itself on
|
||||
arrival** and **one client's action moves another client's display** — in a browser,
|
||||
on pixels, with no pointer anywhere near the display. Then write down how to point a
|
||||
real kiosk box at it.
|
||||
|
||||
## Work
|
||||
|
||||
1. `tests/e2e/test_kiosk_layout.py` (new) — `pytestmark = [pytest.mark.e2e,
|
||||
pytest.mark.timeout(180)]`, and the file's own `device()` fixture copied in the
|
||||
suite's established shape (isolated context per device, `page.on("pageerror")` and
|
||||
`console` errors collected from the first line of each test — `AGENTS.md` §8),
|
||||
waiting on `#net` `data-state=online`.
|
||||
Isolated **contexts** are the right unit here: each is its own socket and peer id,
|
||||
which is what "another client" means to the server. The media path itself is
|
||||
already proven by `test_live_mirror.py`; this suite proves the *state* path and
|
||||
still asserts on real decoded frames so nothing is taken on trust.
|
||||
2. `tests/e2e/test_kiosk_layout.py` — the cases:
|
||||
* **arrival is arrangement**: sharer on `/mirror` + `start_sharing()`; drive the
|
||||
state from a driver page (`page.evaluate` over a plain `WebSocket` to `/ws`,
|
||||
`hello` then `{"type":"arrange","staged":<id>,"fullscreen":true}`); *then* open
|
||||
`/kiosk`; assert it settles staged — `body[data-layout="stage"]`,
|
||||
`#kiosk-stage .tile[data-stream="<id>"]`, and
|
||||
`frames_matching(kiosk, "#kiosk-stage video")` showing `width > 0` and
|
||||
`distinct >= 2`. The frame assertion is what proves media crossed to the
|
||||
display, not just a CSS class (`AGENTS.md` §5).
|
||||
* **immediate update across clients**: two `/kiosk` pages, no input on the second;
|
||||
press `Enter` on the first (a keypress, so the display stays pointer-free);
|
||||
assert the second page's `data-layout` flips to `stage`, its staged tile id
|
||||
matches, and `GET /api/kiosk` reports the same id — one action, two displays,
|
||||
one source of truth.
|
||||
* **stepping**: `n` then `p` on page A move page B through the mirrors (start two
|
||||
sharers; assert the staged `data-stream` on B changes and wraps).
|
||||
* **teardown is a state change**: `stop_sharing()` on the sharer → both kiosk pages
|
||||
return to `body[data-layout="wall"]` and `/api/kiosk` reports
|
||||
`{"staged": null, …}` — no display may sit on a dead tile.
|
||||
* **followers**: with one `/kiosk` open, `/api/kiosk` says `followers: 1`; open a
|
||||
`/watch` page (a plain viewer, not a display) and it still says `1`; close the
|
||||
kiosk and it says `0`.
|
||||
* **nothing lies**: every case asserts `errors == []`.
|
||||
3. `tests/e2e/test_kiosk_layout.py` — keep it pointer-free and say so in the module
|
||||
docstring, in phase 01's words ("a test that reaches for the mouse proves nothing
|
||||
about a kiosk"); add the same style of self-guard the keyboard suite uses if it
|
||||
fits (scan this file for `.click(` and fail on it) — the driver's raw `WebSocket`
|
||||
calls in `page.evaluate` are not a pointer and must be allowed explicitly rather
|
||||
than by loosening the rule.
|
||||
4. Do **not** assert on `document.fullscreenElement` anywhere: headless Chromium may
|
||||
refuse an element fullscreen without user activation and the real kiosk's window
|
||||
manager forces fullscreen anyway (`docs/kiosk-silverblue/window-config.ini`).
|
||||
Assert `body[data-fullscreen]` and the `.stage` state, which are what the app
|
||||
controls. A comment must say this, or the next reader will "strengthen" the test
|
||||
into a flake.
|
||||
5. `docs/kiosk-silverblue.md` — a section, *"Two endpoints: `/watch` and `/kiosk`"*:
|
||||
`/watch` is a person operating a wall, `/kiosk` is a display obeying a wall, the
|
||||
keys still work on `/kiosk` for the moment a keyboard is plugged in, and the
|
||||
controller side arrives in phase 09 (`/remote`). Point out the migration rule
|
||||
honestly: existing clients keep their own `MIRROR_KIOSK_URL`, so nothing changes
|
||||
under a box until its `kiosk.conf` is re-pointed and `gdm` restarted. Do **not**
|
||||
change `install-kiosk.sh`, `kiosk.conf`, `gnome-kiosk-script` or `check-kiosk.sh`
|
||||
in this phase — the default URL moves in phase 09, when there is a way to drive it.
|
||||
6. `README.md` — the page table gains the `/kiosk` row; the "Keys" section gains one
|
||||
sentence: the wall keys work on `/kiosk` too, and a change made there is what every
|
||||
other display follows.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- `uv run pytest tests/e2e/test_kiosk_layout.py -x` green on its own, then the whole
|
||||
`-m e2e` (the suites share one session-scoped server, so a leaked peer in the new
|
||||
file will show up as a tile count in someone else's test — clean up contexts via
|
||||
the fixture's finalizers, as the existing suites do).
|
||||
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` stays green: `/kiosk` must not
|
||||
have changed what a real capture looks like to a viewer.
|
||||
- Coverage: this task adds no product code; it is the **behavioural** coverage the
|
||||
phase's JS relies on (the project's rule — media and page behaviour are asserted in
|
||||
a browser, not simulated in Python).
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `uv run pytest tests/e2e/test_kiosk_layout.py` green, including the arrival case and the two-display case
|
||||
- [ ] the two-display test passes with the second display never receiving a `click()`, `focus()` or `hover()` from the test
|
||||
- [ ] `stop_sharing()` returns both kiosk pages to `data-layout="wall"` within the assertion timeout (no stale tile, no dead `<video>`)
|
||||
- [ ] `/api/kiosk`'s `followers` counts displays only, asserted over a real socket lifecycle
|
||||
- [ ] `uv run pytest -m e2e` and `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` green
|
||||
- [ ] full test suite **100% passing**
|
||||
- [ ] README.md and `docs/kiosk-silverblue.md` describe what exists after this phase, and nothing that arrives in phase 09
|
||||
- [ ] no behavior change in completed work
|
||||
Reference in New Issue
Block a user