phase: 05_autohide_stage_controls
**Phase 05 final verification pass — all green.** - Verified implementation in tree: staged `.cap`/`.actions` hidden at rest with container+children `pointer-events: none` (`app.css`); `revealStageControls()` with 3 s idle timer, `pointermove`/`pointerdown` + wall-keypress wiring, `unstage()` timer/attribute cleanup (`watch.js`); `tests/e2e/test_stage_autofade.py` (5 tests) present; `test_keyboard_wall.py` rest-state assertion updated per task 01. - Gates: `uv run ruff check . && uv run ruff format --check .` — clean; `uv run pyright` — 0 errors; `uv run pytest` — **274 passed, 4 skipped** (realdisplay-gated), coverage **95%** (>90% ✓); `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — **3 passed, 1 skipped** (documented platform skip: no audio source on host). - Dedicated phase suite `uv run pytest tests/e2e/test_stage_autofade.py` — **5/5 in isolation**. - Criteria: idle opacity 0 + bottom-edge hit probe ✓; mouse reveal → 1 → idle fade → 0 ✓; keypress reveal + Exit visible/clickable/unstages ✓; grid rules byte-for-byte unchanged (CSS diff touches only the staged block; grid guard tests green) ✓; suite green, coverage >90% ✓; phases 01–04 suites all green (no regression) ✓. - Documentation: README.md reviewed in full — all commands, env vars (incl. `DEBUGPY_*`, 25 Mbit/s default), `--allow-cidr`, kiosk/share key tables, index-page and auto-hide descriptions match the code; the only stale "12 Mbit/s" default lives in locked PLAN.md §6, README is correct — no change made. AGENTS.md: all facts (gates, test names, starlette 1.10, e2e flags, ~95% coverage) current — verified, no changes needed. - Notable: no code changes were required this pass — the phase work was already complete and correct; nothing to fix. - Next pending phase: none — phase 05 is the last in `todo/`.
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
**Phase 05 final verification pass — all green.**
|
||||
|
||||
- Verified implementation in tree: staged `.cap`/`.actions` hidden at rest with container+children `pointer-events: none` (`app.css`); `revealStageControls()` with 3 s idle timer, `pointermove`/`pointerdown` + wall-keypress wiring, `unstage()` timer/attribute cleanup (`watch.js`); `tests/e2e/test_stage_autofade.py` (5 tests) present; `test_keyboard_wall.py` rest-state assertion updated per task 01.
|
||||
- Gates: `uv run ruff check . && uv run ruff format --check .` — clean; `uv run pyright` — 0 errors; `uv run pytest` — **274 passed, 4 skipped** (realdisplay-gated), coverage **95%** (>90% ✓); `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — **3 passed, 1 skipped** (documented platform skip: no audio source on host).
|
||||
- Dedicated phase suite `uv run pytest tests/e2e/test_stage_autofade.py` — **5/5 in isolation**.
|
||||
- Criteria: idle opacity 0 + bottom-edge hit probe ✓; mouse reveal → 1 → idle fade → 0 ✓; keypress reveal + Exit visible/clickable/unstages ✓; grid rules byte-for-byte unchanged (CSS diff touches only the staged block; grid guard tests green) ✓; suite green, coverage >90% ✓; phases 01–04 suites all green (no regression) ✓.
|
||||
- Documentation: README.md reviewed in full — all commands, env vars (incl. `DEBUGPY_*`, 25 Mbit/s default), `--allow-cidr`, kiosk/share key tables, index-page and auto-hide descriptions match the code; the only stale "12 Mbit/s" default lives in locked PLAN.md §6, README is correct — no change made. AGENTS.md: all facts (gates, test names, starlette 1.10, e2e flags, ~95% coverage) current — verified, no changes needed.
|
||||
- Notable: no code changes were required this pass — the phase work was already complete and correct; nothing to fix.
|
||||
- Next pending phase: none — phase 05 is the last in `todo/`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
........................................................................ [ 25%]
|
||||
......................ssss.............................................. [ 51%]
|
||||
........................................................................ [ 77%]
|
||||
.............................................................. [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:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
274 passed, 4 skipped, 1 warning in 96.65s (0:01:36)
|
||||
All checks passed!
|
||||
31 files already formatted
|
||||
0 errors, 0 warnings, 0 informations
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
All gates verified green after the final code changes. Done.
|
||||
|
||||
**Report — Task 01 (phase 05): hide the staged overlay at rest**
|
||||
|
||||
- `app.css`: staged `.cap` `opacity: 0.28 → 0`; deleted the three staged reveal rules and staged `.actions { opacity: 1 }`; added id-scoped `#watch-stage .tile .actions { opacity: 0 }` (outranks the grid `data-selected`/`focus-within`/`hover:none` rules — verified by the E2E computed-opacity assertion, not assumption); `pointer-events: none` scoped to both containers *and their children* (no dead zones); comment block replaced with the new contract (hidden at rest, task-02 `data-reveal` is the only staged reveal, `::after` audio mark untouched)
|
||||
- `test_keyboard_wall.py`: `test_focus_moves_to_the_stage_and_back_with_g` now waits (10 s) for staged `.cap` **and** `.actions` computed opacity `0`; grid guard test untouched
|
||||
- `test_live_mirror.py` ×2 + `test_audio_share.py` ×1 (deviation, forced by the task's own CSS): with the overlay pointer-transparent at rest and no reveal yet, the existing `#watch-stage .exit` / staged `.audio` clicks are impossible — replaced those unstage/mute actions with the `g`/`m` keys (comment notes the revealed-state Exit click is asserted by task 03's E2E); `to_be_visible` assertions kept
|
||||
- README: one accurate-now-and-still-true line — staged caption/controls are hidden at rest so the video is never covered. No AGENTS.md impact (no statement touched by this task went stale)
|
||||
|
||||
Gates (all green, in order):
|
||||
- `uv run ruff check . && uv run ruff format --check .` → passed / 30 files formatted
|
||||
- `uv run pyright` → 0 errors
|
||||
- `uv run pytest` → **269 passed, 4 skipped** (realdisplay marker)
|
||||
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → **3 passed, 1 skipped** (host has no audio source for real capture — documented platform limit)
|
||||
|
||||
Next pending task: `02_reveal_on_activity.md`
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
........................................................................ [ 26%]
|
||||
.................ssss................................................... [ 52%]
|
||||
........................................................................ [ 79%]
|
||||
......................................................... [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:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
269 passed, 4 skipped, 1 warning in 92.81s (0:01:32)
|
||||
All checks passed!
|
||||
30 files already formatted
|
||||
0 errors, 0 warnings, 0 informations
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
Everything is green and in order. Final report:
|
||||
|
||||
**Implemented (task 02 — reveal on activity, auto-hide when idle)**
|
||||
- `watch.js`: `STAGE_REVEAL_IDLE_MS = 3000` + `let stageRevealTimer = 0` + `revealStageControls()` — sets `data-reveal` on the staged tile, clears/re-arms the timer; callback deletes the attribute only if `state.staged === tile.id` (tile may be unstaged/replaced before it fires)
|
||||
- Triggers wired per spec: `pointermove` + `pointerdown` (touch tap, A5) on `#watch-stage`; `if (state.staged) revealStageControls()` in `onWallKey` after bail-outs, before the `switch`, never `preventDefault`
|
||||
- `unstage()`: clears the pending timer and deletes `data-reveal` from the departing tile — no stale reveal can leak into the grid (grid rules never read it; re-staging starts hidden)
|
||||
- `app.css`: id-scoped `[data-reveal]` reveal pair for `.cap`/`.actions` + children (`opacity: 1; pointer-events: auto`), outranking the task-01 hide rules; existing transitions give the fade, `prefers-reduced-motion` already degrades it to instant
|
||||
|
||||
**Verification (temporary E2E, deleted after — the dedicated suite is task 03's deliverable)**
|
||||
- 5 browser tests on a real staged tile (fake-media capture): mouse reveal → 3 s idle fade, `m`-keypress reveal → fade, unstage clears attribute+timer (survived 3.6 s timer window), n/p + 1-9 replacement leaves no stale `data-reveal`, Exit clickable while revealed and unstages — all passed
|
||||
- `uv run ruff check . && uv run ruff format --check .` — clean; `uv run pyright` — 0 errors
|
||||
- `uv run pytest` — 269 passed, 4 skipped (realdisplay-gated), coverage 95% (no Python code changed; JS/CSS verified in-browser per convention)
|
||||
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — 3 passed, 1 env skip (host has no audio source)
|
||||
|
||||
**Docs**: README.md — staged-overlay paragraph extended with the reveal/~3 s auto-hide behaviour; AGENTS.md — no impact (no statement became stale)
|
||||
|
||||
**Notable**: a keypress that *causes* staging (Enter) doesn't reveal — `state.staged` is null pre-switch, so the overlay stays hidden at rest, keeping task 01's 10 s rest-state test green.
|
||||
|
||||
**Next pending task**: `03_e2e_autofade_lifecycle.md`
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
........................................................................ [ 26%]
|
||||
.................ssss................................................... [ 52%]
|
||||
........................................................................ [ 79%]
|
||||
......................................................... [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:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
269 passed, 4 skipped, 1 warning in 95.80s (0:01:35)
|
||||
All checks passed!
|
||||
30 files already formatted
|
||||
0 errors, 0 warnings, 0 informations
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
All four gates green. Task complete.
|
||||
|
||||
**Report**
|
||||
|
||||
- **Implemented:** `tests/e2e/test_stage_autofade.py` — 5-test suite pinning the staged overlay lifecycle on a real capture: `rest` (opacity 0 for `.cap`/`.actions` + `elementFromPoint` bottom-centre hit is the `<video>` itself), `mouse reveal`, `idle fade` (3 s + transition), `key reveal + Exit` (`m` mute flip tolerated/asserted; Exit visible + clickable while revealed; click unstages; no stale `data-reveal` leaks to the grid), `grid guard` (unselected `.actions` 0 / `data-selected` 1). `device` fixture, `share_one_and_stage` helper, `staged_overlay` probe; `pageerror`+`console` asserted `[]` in every test; docstring names the bug (staged tile kept `data-selected`/focus → grid rules forced `.cap` opaque over a shared terminal's bottom line) and its repro probe.
|
||||
- **Gates:** `uv run ruff check . && uv run ruff format --check .` → clean (31 files); `uv run pyright` → 0 errors; `uv run pytest` → 274 passed, 4 skipped (realdisplay, env-gated); `uv run pytest -m e2e` → 94 passed, 4 skipped; `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 3 passed, 1 skipped (host audio, documented); suite in isolation: 5 passed.
|
||||
- **Regressions:** `test_keyboard_wall.py` 35/35 passed — phases 01–04 intact.
|
||||
- **Docs:** no doc impact — README already documented the auto-hide behaviour (updated in tasks 01/02); AGENTS.md has no stale statements.
|
||||
- **Decisions:** one Playwright API fix — `wait_for_function` takes `arg` keyword-only in this version. No Python behaviour changes, no new assets/dependencies.
|
||||
- **Next pending task:** none — this was the last task of phase 05; phase completion criteria are met.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
...F.................................................................... [ 25%]
|
||||
......................ssss.............................................. [ 51%]
|
||||
........................................................................ [ 77%]
|
||||
.............................................................. [100%]
|
||||
=================================== FAILURES ===================================
|
||||
_ TestStagedAudioPlayback.test_unstaging_re_mutes_and_re_staging_does_not_revive_audio _
|
||||
|
||||
self = <tests.e2e.test_audio_share.TestStagedAudioPlayback object at 0x7f1fdf890cd0>
|
||||
base_url = 'http://127.0.0.1:38615'
|
||||
browser = <Browser type=<BrowserType name=chromium executable_path=/home/ducoterra/.cache/ms-playwright/chromium-1248/chrome-linux64/chrome> version=156.0.8078.4>
|
||||
request = <FixtureRequest for <Function test_unstaging_re_mutes_and_re_staging_does_not_revive_audio>>
|
||||
device = <function device.<locals>._open at 0x7f1fdf2e9590>
|
||||
|
||||
def test_unstaging_re_mutes_and_re_staging_does_not_revive_audio(
|
||||
self, base_url, browser, request, device
|
||||
):
|
||||
"""`g` returns the wall to silence, and re-staging the same mirror does
|
||||
not revive the sound — every audible state must trace back to a
|
||||
gesture (SECURITY.md §4)."""
|
||||
errors: list = []
|
||||
sharer = device("/mirror", errors)
|
||||
start_sharing(sharer)
|
||||
viewer = open_viewer(browser, base_url, request, errors=errors)
|
||||
expect(viewer.locator(".tile")).to_have_count(1, timeout=15_000)
|
||||
|
||||
viewer.keyboard.press("Enter") # nothing selected yet: the first tile
|
||||
expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
|
||||
time.sleep(2.0)
|
||||
assert viewer.evaluate(f"() => {self.STAGE_MUTED}") is True
|
||||
viewer.keyboard.press("m")
|
||||
assert viewer.evaluate(f"() => {self.STAGE_MUTED}") is False
|
||||
|
||||
# Back to the wall: the tile the stage leaves behind is muted.
|
||||
viewer.keyboard.press("g")
|
||||
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"), timeout=5_000)
|
||||
assert viewer.evaluate(f"() => {self.GRID_MUTED}") is True
|
||||
|
||||
# Re-staging the same mirror does not bring the sound back.
|
||||
viewer.keyboard.press("Enter")
|
||||
> expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
|
||||
E AssertionError: Locator expected to have class 're.compile('staged')'
|
||||
E Actual value:
|
||||
E Call log:
|
||||
E - Expect "to_have_class" locator("body") with timeout 5000ms
|
||||
E - waiting for locator("body")
|
||||
E 14 × locator resolved to <body class="">…</body>
|
||||
E - unexpected value ""
|
||||
E
|
||||
E Aria snapshot:
|
||||
E - banner:
|
||||
E - text: mirror 1 mirror · 1 live online
|
||||
E - link "Share a screen":
|
||||
E - /url: http://127.0.0.1:38615/mirror
|
||||
E - main:
|
||||
E - article "Watch screen:-3:0"
|
||||
|
||||
tests/e2e/test_audio_share.py:448: AssertionError
|
||||
=============================== 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:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
FAILED tests/e2e/test_audio_share.py::TestStagedAudioPlayback::test_unstaging_re_mutes_and_re_staging_does_not_revive_audio
|
||||
1 failed, 273 passed, 4 skipped, 1 warning in 102.56s (0:01:42)
|
||||
All checks passed!
|
||||
31 files already formatted
|
||||
0 errors, 0 warnings, 0 informations
|
||||
validation FAILED (see output above)
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
All checks complete. Final verification of the full gate state:
|
||||
|
||||
- `validate.sh` (harness gate: full pytest + ruff + format + pyright + repo hygiene): **274 passed, 4 skipped** (realdisplay, env-gated), `All checks passed!`, `31 files already formatted`, `0 errors`, no hygiene findings
|
||||
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay`: **3 passed, 1 skipped** (host audio, documented in the module docstring)
|
||||
- Failing test `test_unstaging_re_mutes_and_re_staging_does_not_revive_audio`: 6/6 clean runs after the fix (plus 6/6 pre-fix runs were green when isolated — the failure is load/timing-dependent, exposed under the full-suite run)
|
||||
- README consistency review: verified against code — all routes (`/`, `/mirror`, `/share`, `/watch`, `/api/config`, `/api/streams`, `/healthz`, `/ws`), all 11 config variables with defaults (`0.0.0.0`, `8055`, `25000000`, `20`, `127.0.0.0/8, ::1/128`, …), both key tables, the audio states (`shared`/`silent`/`not offered` in `share.js`), and the staged-overlay auto-hide paragraph. No inconsistencies found — no README change needed.
|
||||
- AGENTS.md stale-fact check: every referenced file/test/command (`registry.py`, `access.py`, `test_nothing_is_loaded_from_a_cdn`, the gates, `DEBUGPY=1` on 5678, `--allow-cidr`) still holds; nothing stale — no AGENTS.md change.
|
||||
|
||||
**Report**
|
||||
|
||||
- **Fixed:** pre-existing race in `src/mirror/static/watch.js` — `unstage()`'s async `exitFullscreen()` settling after an immediate re-stage (`g` → `Enter`): the late `fullscreenchange` (element null, `state.staged` = the *new* tile) tore down the fresh stage, so `body` never kept `staged`. Added a `fullscreenExitInFlight` flag set in `unstage()`: the handler now ignores the exit of an exit we initiated, clears the flag when the document re-enters fullscreen (re-stage's request won) or the exit settles, and still unstages on a genuine external/WM exit; a rejected exit clears the stale flag when the document is still fullscreen. `restoreFocus` hand-back unchanged.
|
||||
- **Gates:** `.agents/validate.sh` (full suite + linters) → 274 passed, 4 skipped (realdisplay), ruff clean, 31 files formatted, pyright 0 errors, hygiene clean; `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 3 passed, 1 skipped (host audio, documented); failing audio test 6/6 green post-fix.
|
||||
- **Docs:** README fully cross-checked against code (routes, config defaults, keys, audio states, overlay auto-hide) — no inconsistencies, no change; AGENTS.md stale-fact check — no stale statements, no change.
|
||||
- **Decisions:** fixed the defect in app code (not the test) — the test's `g`→`Enter` sequence is legitimate user behaviour; the old-exit event must not kill a new stage.
|
||||
- **Next pending task:** none — phase 05 complete.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
........................................................................ [ 25%]
|
||||
......................ssss.............................................. [ 51%]
|
||||
........................................................................ [ 77%]
|
||||
.............................................................. [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:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
|
||||
274 passed, 4 skipped, 1 warning in 100.65s (0:01:40)
|
||||
All checks passed!
|
||||
31 files already formatted
|
||||
0 errors, 0 warnings, 0 informations
|
||||
@@ -169,6 +169,12 @@ in. On `/watch`:
|
||||
| `?` | Show / hide this list on screen. |
|
||||
| `Esc` | Close the key list, then leave the stage. |
|
||||
|
||||
A staged mirror's caption bar and control cluster are **hidden at rest**, so
|
||||
the shared video — a terminal's bottom line included — is never covered by
|
||||
them. A pointer move over the stage (a tap, for touch) or any key press
|
||||
while staged reveals them briefly; after ~3 s without activity they fade
|
||||
back, so a parked pointer or a one-time key press never pins them up.
|
||||
|
||||
A mirror's audio is only heard while it is on the stage: every mirror starts
|
||||
muted, unmuting one (the tile's audio button or `m`) mutes the others, and
|
||||
leaving the stage re-mutes it. Sound never starts without a click or a
|
||||
|
||||
@@ -599,17 +599,40 @@ body.staged #watch-stage { display: block; }
|
||||
background: linear-gradient(to top, rgba(4, 6, 10, 0.92), rgba(4, 6, 10, 0));
|
||||
border-top: 0;
|
||||
padding: 26px 16px 12px;
|
||||
opacity: 0.28;
|
||||
opacity: 0;
|
||||
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:focus-within .cap,
|
||||
#watch-stage .tile[data-selected] .cap { opacity: 1; }
|
||||
#watch-stage .tile .actions { opacity: 1; }
|
||||
/* The staged overlay is hidden at rest so the shared video — a terminal's
|
||||
bottom line included — is never covered. `data-reveal` (pointer activity
|
||||
or a wall keypress) is the only staged reveal: it shows the caption and
|
||||
the control cluster briefly and fades them back on idle. The small
|
||||
`::after` audio mark is a separate rule and is untouched. The id-scoped
|
||||
selectors outrank the grid rules (`.tile[data-selected] .actions`,
|
||||
`.tile:focus-within .actions`, the `hover: none` media rule) which also
|
||||
match the staged tile — it keeps `data-selected` and holds focus. */
|
||||
#watch-stage .tile .actions { opacity: 0; }
|
||||
|
||||
/* A `pointer-events: none` container does not shield children whose default
|
||||
is `auto`: scope it to the container *and its children* so the hidden
|
||||
Exit/mute buttons are not live dead zones over the video. */
|
||||
#watch-stage .tile .cap, #watch-stage .tile .cap *,
|
||||
#watch-stage .tile .actions, #watch-stage .tile .actions * { pointer-events: none; }
|
||||
|
||||
/* `data-reveal` (pointer activity on the stage, or a wall keypress while
|
||||
staged) is the only staged reveal: it restores opacity and makes the
|
||||
Exit/mute buttons clickable again the moment the overlay is up — the
|
||||
`pointer-events: auto` on the children, since a `none` container never
|
||||
shields an `auto` child (setting `opacity: 1` on the children themselves
|
||||
is harmless). The transitions on the base rules give the fade in and
|
||||
out; under `prefers-reduced-motion` the global transition-kill makes the
|
||||
reveal instant, which is the correct degraded behaviour. The id-scoped
|
||||
selectors outrank every grid rule that also matches the staged tile. */
|
||||
#watch-stage .tile[data-reveal] .cap, #watch-stage .tile[data-reveal] .cap *,
|
||||
#watch-stage .tile[data-reveal] .actions, #watch-stage .tile[data-reveal] .actions * {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { animation: none !important; transition: none !important; }
|
||||
|
||||
@@ -318,6 +318,41 @@ function setMuted(tile, muted) {
|
||||
|
||||
/* ------------------------------------------------------------------ stage */
|
||||
|
||||
/* The staged overlay (caption bar + control cluster) is hidden at rest so a
|
||||
shared terminal's bottom line is never covered. Pointer activity on the
|
||||
stage, and any wall keypress while staged (the kiosk has no mouse),
|
||||
reveal it via `data-reveal`; this timeout is what fades it back, so a
|
||||
parked pointer or a one-time keypress never pins it up. */
|
||||
const STAGE_REVEAL_IDLE_MS = 3000;
|
||||
let stageRevealTimer = 0;
|
||||
|
||||
function revealStageControls() {
|
||||
if (!state.staged) return;
|
||||
const tile = tiles.get(state.staged);
|
||||
if (!tile) return;
|
||||
tile.el.dataset.reveal = "";
|
||||
clearTimeout(stageRevealTimer);
|
||||
stageRevealTimer = setTimeout(() => {
|
||||
stageRevealTimer = 0;
|
||||
// The tile may have been unstaged or replaced (n/p, 1-9, a click)
|
||||
// before the timer fires; only the tile still on stage loses the
|
||||
// attribute. (unstage() clears the timer, so this guard also covers
|
||||
// a replacement whose keepFullscreen unstage ran in between.)
|
||||
if (state.staged === tile.id) delete tile.el.dataset.reveal;
|
||||
}, STAGE_REVEAL_IDLE_MS);
|
||||
}
|
||||
|
||||
/* A `document.exitFullscreen()` that `unstage()` started is in flight.
|
||||
The exit settles asynchronously, and a keypress in that window (g followed
|
||||
by an immediate Enter) stages a new tile before the exit's
|
||||
`fullscreenchange` event lands: `state.staged` is then the NEW tile, and
|
||||
the handler would tear the fresh stage down with the old exit. While the
|
||||
flag is set, an exit event belongs to the unstage that already tore the
|
||||
stage down synchronously, so it is ignored; the flag is cleared when the
|
||||
document becomes fullscreen again (a re-stage's request won over the exit)
|
||||
or when the exit settles. */
|
||||
let fullscreenExitInFlight = false;
|
||||
|
||||
export function stage(streamId, options = {}) {
|
||||
const tile = tiles.get(streamId);
|
||||
if (!tile) return;
|
||||
@@ -361,6 +396,14 @@ export function unstage(options = {}) {
|
||||
const tile = state.staged ? tiles.get(state.staged) : null;
|
||||
state.staged = null;
|
||||
document.body.classList.remove("staged");
|
||||
// The reveal belongs to the stage: every exit (Exit click, `g`, Esc, or a
|
||||
// replacement's keepFullscreen) clears the pending timeout and the
|
||||
// departing tile's `data-reveal`, so a stale reveal can never leak back
|
||||
// into the grid — grid tiles never read the attribute, and re-staging
|
||||
// must start hidden.
|
||||
clearTimeout(stageRevealTimer);
|
||||
stageRevealTimer = 0;
|
||||
if (tile) delete tile.el.dataset.reveal;
|
||||
if (tile) {
|
||||
tile.exitBtn.hidden = true;
|
||||
if (state.stageParent) {
|
||||
@@ -393,18 +436,49 @@ export function unstage(options = {}) {
|
||||
// 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);
|
||||
// The in-flight flag keeps the exit's late `fullscreenchange` from
|
||||
// tearing down a stage re-entered in the meantime (see the handler).
|
||||
fullscreenExitInFlight = true;
|
||||
document.exitFullscreen().then(restoreFocus).catch(() => {
|
||||
// A rejected exit never settles: if the document is still fullscreen
|
||||
// the exit was pre-empted (or lost user activation) and the flag is
|
||||
// stale — drop it so a genuine later exit still unstages.
|
||||
if (document.fullscreenElement) fullscreenExitInFlight = false;
|
||||
restoreFocus();
|
||||
});
|
||||
} else {
|
||||
restoreFocus();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("fullscreenchange", () => {
|
||||
if (!document.fullscreenElement && state.staged) unstage();
|
||||
if (document.fullscreenElement) {
|
||||
// (Re-)entered fullscreen — a re-stage's request won over our exit, so
|
||||
// the in-flight flag is stale from this point on.
|
||||
fullscreenExitInFlight = false;
|
||||
return;
|
||||
}
|
||||
if (fullscreenExitInFlight) {
|
||||
// Our own unstage's exit settling: the stage was already torn down
|
||||
// synchronously, and a keypress in the exit window may have re-staged —
|
||||
// the event must not take that new stage down with it.
|
||||
fullscreenExitInFlight = false;
|
||||
return;
|
||||
}
|
||||
// The browser (or the kiosk's window manager) left fullscreen on its own;
|
||||
// the in-page stage is what the operator asked to leave.
|
||||
if (state.staged) unstage();
|
||||
});
|
||||
|
||||
if (ui.unstage) ui.unstage.addEventListener("click", unstage);
|
||||
|
||||
// Pointer activity on the stage reveals the overlay. `pointerdown` — not
|
||||
// just `pointermove` — is a deliberate touch tap, so a phone watcher can
|
||||
// reach the controls without a hover; the idle timeout is what fades them
|
||||
// again, so a parked pointer never pins the overlay up.
|
||||
ui.stage.addEventListener("pointermove", revealStageControls);
|
||||
ui.stage.addEventListener("pointerdown", revealStageControls);
|
||||
|
||||
/* ------------------------------------------------------------------ socket */
|
||||
|
||||
function subscribe(streamId) {
|
||||
@@ -606,6 +680,12 @@ function onWallKey(event) {
|
||||
) {
|
||||
return; // a key typed in a text field is text, not a command
|
||||
}
|
||||
// Any wall keypress while staged reveals the overlay, handled or not: a
|
||||
// key in a mouseless kiosk is the only way to read the caption and reach
|
||||
// the controls (a silent no-op key flashing the caption is the
|
||||
// debuggable outcome). The idle timeout fades it again, and this call
|
||||
// never preventDefaults, so unhandled keys keep their defaults.
|
||||
if (state.staged) revealStageControls();
|
||||
// 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.
|
||||
|
||||
@@ -481,8 +481,10 @@ class TestStagedAudioPlayback:
|
||||
# Button and `m` cannot disagree: the glyph and aria-pressed come from
|
||||
# setMuted(), so the button shows the live state.
|
||||
expect(stage_tile.locator(".audio")).to_have_attribute("aria-pressed", "true")
|
||||
# A second click on the button mutes again (stayed on stage).
|
||||
stage_tile.locator(".audio").click()
|
||||
# A second toggle mutes again (stayed on stage). The staged button is
|
||||
# not clickable yet — phase 05 hides the overlay at rest — so `m`
|
||||
# drives the same `setMuted()` path the button would.
|
||||
viewer.keyboard.press("m")
|
||||
expect(stage_tile).to_have_attribute("data-audio", "off")
|
||||
|
||||
# Back to the wall: silent again.
|
||||
|
||||
@@ -618,12 +618,17 @@ class TestFocusVisibleControls:
|
||||
" && 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).
|
||||
# The staged overlay is hidden at rest — caption bar and control
|
||||
# cluster alike — so a shared terminal's bottom line is never
|
||||
# covered (phase 05). 10 s stays green under task 02 as well: an
|
||||
# Enter-press reveal lasts 3 s + 200 ms fade, so the settled `0`
|
||||
# is always reachable inside the window.
|
||||
viewer.wait_for_function(
|
||||
"() => document.activeElement !== null"
|
||||
" && getComputedStyle(document.activeElement.querySelector('.cap')).opacity === '1'",
|
||||
timeout=5_000,
|
||||
"() => { const tile = document.querySelector('#watch-stage .tile');"
|
||||
" if (!tile) return false;"
|
||||
" return getComputedStyle(tile.querySelector('.cap')).opacity === '0'"
|
||||
" && getComputedStyle(tile.querySelector('.actions')).opacity === '0'; }",
|
||||
timeout=10_000,
|
||||
)
|
||||
|
||||
# m still works with focus sitting on the stage.
|
||||
|
||||
@@ -390,9 +390,14 @@ class TestStageSizing:
|
||||
expect(viewer.locator(".tile .exit")).not_to_be_visible()
|
||||
|
||||
viewer.locator(".tile").click()
|
||||
# Phase 05: the Exit button still exists on the staged tile (and is
|
||||
# still absent from the grid), but the overlay is hidden at rest and
|
||||
# pointer-transparent until the phase's activity reveal lands, so the
|
||||
# button itself is not clickable yet. `g` leaves the stage the same
|
||||
# way; the revealed-state click is asserted by the phase 03 E2E.
|
||||
expect(viewer.locator("#watch-stage .exit")).to_be_visible()
|
||||
|
||||
viewer.locator("#watch-stage .exit").click()
|
||||
viewer.keyboard.press("g")
|
||||
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"), timeout=5_000)
|
||||
expect(viewer.locator(".tile .exit")).not_to_be_visible()
|
||||
# Leaving the stage must not have disturbed the stream.
|
||||
@@ -445,7 +450,9 @@ class TestManyToMany:
|
||||
staged = viewer.locator("#watch-stage .tile").get_attribute("data-stream")
|
||||
assert staged == ids[1]
|
||||
|
||||
viewer.locator("#watch-stage .exit").click()
|
||||
# The staged overlay is pointer-transparent at rest (phase 05), so
|
||||
# `g` leaves the stage in place of the Exit click for now.
|
||||
viewer.keyboard.press("g")
|
||||
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"), timeout=5_000)
|
||||
|
||||
viewer.locator(".tile").nth(0).click()
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
"""End-to-end: the staged overlay's auto-hide lifecycle.
|
||||
|
||||
Phase 05, the bug this pins: while a tile was on stage it kept
|
||||
``data-selected`` and held focus, so the *grid* reveal rules
|
||||
(``.tile[data-selected] .actions``, ``.tile:focus-within .actions``) matched
|
||||
the staged tile too and the caption bar (``.cap``: label; device · surface ·
|
||||
dims · bitrate · fps) and the control cluster (``.actions``: mute/expand,
|
||||
and the ``.cap``-side "Exit" button) sat over the shared video at all
|
||||
times. The motivating case (TODO.md L3): sharing a terminal window — the
|
||||
caption line and the word "Exit" covered the video, so the terminal's bottom
|
||||
line could not be read. The repro probe: the computed opacity of ``.cap`` on
|
||||
the staged tile while it is idle — it must be ``0``.
|
||||
|
||||
The fix contract this suite pins, per PLAN.md §5 asserted on the **viewer's**
|
||||
side (computed opacity, the ``data-reveal`` attribute, the ``staged`` class,
|
||||
the ``hidden`` flag, ``elementFromPoint``) — never on the sharer's preview,
|
||||
never on whether a handler "ran":
|
||||
|
||||
- ``rest`` — hidden at rest; the stage's bottom edge is the ``<video>``
|
||||
itself (the strongest form of the repro probe: nothing else answers a hit
|
||||
probe at the bottom centre of the stage).
|
||||
- ``mouse reveal`` — a pointer move inside ``#watch-stage`` brings both back
|
||||
(opacity ``1``).
|
||||
- ``idle fade`` — a parked pointer fades them again (3 s + transition).
|
||||
- ``key reveal + Exit`` — a wall keypress (``m``; its mute flip is tolerated
|
||||
and asserted, as the keyboard suite does) reveals the overlay, and the Exit
|
||||
button is visible and clickable while revealed — clicking it unstages.
|
||||
- ``grid guard`` — the grid's kiosk reveal rules survive the id-scoped
|
||||
staged overrides: an unselected tile stays quiet, the selected tile
|
||||
carries its controls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
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 share_one_and_stage(device, errors: list):
|
||||
"""Stand up one mirror and stage it the kiosk way (Enter)."""
|
||||
start_sharing(device("/mirror", errors))
|
||||
viewer = device("/watch", errors)
|
||||
expect(viewer.locator(".tile")).to_have_count(1, timeout=15_000)
|
||||
viewer.keyboard.press("Enter")
|
||||
expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
|
||||
return viewer
|
||||
|
||||
|
||||
STAGE_OVERLAY_PROBE = """
|
||||
() => {
|
||||
const tile = document.querySelector('#watch-stage .tile');
|
||||
if (!tile) return { missing: true };
|
||||
return {
|
||||
cap: getComputedStyle(tile.querySelector('.cap')).opacity,
|
||||
actions: getComputedStyle(tile.querySelector('.actions')).opacity,
|
||||
reveal: tile.hasAttribute('data-reveal'),
|
||||
};
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def staged_overlay(page) -> dict:
|
||||
"""The staged tile's overlay state: computed opacities + data-reveal."""
|
||||
return page.evaluate(STAGE_OVERLAY_PROBE)
|
||||
|
||||
|
||||
# Settled-state probes. The fade is the existing opacity transition (~200 ms
|
||||
# on .cap, ~150 ms on .actions), so "settled" is the exact 0 / 1 value, not a
|
||||
# range.
|
||||
OVERLAY_HIDDEN = (
|
||||
"() => { const tile = document.querySelector('#watch-stage .tile');"
|
||||
" if (!tile) return false;"
|
||||
" return getComputedStyle(tile.querySelector('.cap')).opacity === '0'"
|
||||
" && getComputedStyle(tile.querySelector('.actions')).opacity === '0'; }"
|
||||
)
|
||||
OVERLAY_REVEALED = (
|
||||
"() => { const tile = document.querySelector('#watch-stage .tile');"
|
||||
" if (!tile) return false;"
|
||||
" return tile.hasAttribute('data-reveal')"
|
||||
" && getComputedStyle(tile.querySelector('.cap')).opacity === '1'"
|
||||
" && getComputedStyle(tile.querySelector('.actions')).opacity === '1'; }"
|
||||
)
|
||||
OVERLAY_FADING = (
|
||||
"() => { const tile = document.querySelector('#watch-stage .tile');"
|
||||
" if (!tile) return false;"
|
||||
" return !tile.hasAttribute('data-reveal')"
|
||||
" && getComputedStyle(tile.querySelector('.cap')).opacity === '0'"
|
||||
" && getComputedStyle(tile.querySelector('.actions')).opacity === '0'; }"
|
||||
)
|
||||
|
||||
|
||||
class TestStageAutoHideLifecycle:
|
||||
"""The staged overlay's auto-hide lifecycle, on a real capture.
|
||||
|
||||
Hidden at rest → mouse reveal → idle fade → key reveal → Exit usable
|
||||
while revealed → the grid's kiosk rules unchanged. Every test ends with
|
||||
``assert errors == []``: the wall must stay free of uncaught page and
|
||||
console errors through the whole lifecycle.
|
||||
"""
|
||||
|
||||
def test_staged_overlay_is_hidden_at_rest(self, device):
|
||||
errors: list = []
|
||||
viewer = share_one_and_stage(device, errors)
|
||||
|
||||
# Settled rest state. An Enter stage does not reveal: the reveal
|
||||
# trigger runs before the key's command, when nothing is staged yet,
|
||||
# so the post-staging steady state is the one under test.
|
||||
viewer.wait_for_function(OVERLAY_HIDDEN, timeout=10_000)
|
||||
overlay = staged_overlay(viewer)
|
||||
assert overlay["reveal"] is False, "an Enter stage must start hidden"
|
||||
assert overlay["cap"] == "0"
|
||||
assert overlay["actions"] == "0"
|
||||
|
||||
# The motivating case: the shared video's bottom edge is unobstructed.
|
||||
# Nothing other than the <video> itself may answer a hit probe at the
|
||||
# bottom centre of the stage — the caption bar and the control
|
||||
# cluster must be pointer-transparent, not merely invisible.
|
||||
probe = viewer.evaluate(
|
||||
"""() => {
|
||||
const stage = document.querySelector('#watch-stage');
|
||||
const r = stage.getBoundingClientRect();
|
||||
const el = document.elementFromPoint(r.left + r.width / 2, r.bottom - 4);
|
||||
return el ? el.tagName : null;
|
||||
}"""
|
||||
)
|
||||
assert probe == "VIDEO", f"the stage's bottom edge must be the video itself (got {probe!r})"
|
||||
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
|
||||
def test_a_pointer_move_over_the_stage_reveals_the_overlay(self, device):
|
||||
errors: list = []
|
||||
viewer = share_one_and_stage(device, errors)
|
||||
viewer.wait_for_function(OVERLAY_HIDDEN, timeout=10_000)
|
||||
|
||||
# A pointer move inside #watch-stage: the stage covers the viewport,
|
||||
# so the centre is deep inside it. (The kiosk's own reveal is the
|
||||
# keypress; this is the path a human watcher with a mouse gets.)
|
||||
viewer.mouse.move(550, 360)
|
||||
viewer.wait_for_function(OVERLAY_REVEALED, timeout=5_000)
|
||||
|
||||
overlay = staged_overlay(viewer)
|
||||
assert overlay["reveal"] is True
|
||||
assert overlay["cap"] == "1"
|
||||
assert overlay["actions"] == "1"
|
||||
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
|
||||
def test_a_parked_pointer_fades_the_overlay_again(self, device):
|
||||
errors: list = []
|
||||
viewer = share_one_and_stage(device, errors)
|
||||
viewer.wait_for_function(OVERLAY_HIDDEN, timeout=10_000)
|
||||
|
||||
viewer.mouse.move(550, 360)
|
||||
viewer.wait_for_function(OVERLAY_REVEALED, timeout=5_000)
|
||||
|
||||
# Now sit idle: the 3 s timeout fires, the ~200 ms fade follows, and
|
||||
# the attribute is gone — a parked pointer must not pin the overlay up.
|
||||
viewer.wait_for_function(OVERLAY_FADING, timeout=8_000)
|
||||
|
||||
overlay = staged_overlay(viewer)
|
||||
assert overlay["reveal"] is False
|
||||
assert overlay["cap"] == "0"
|
||||
assert overlay["actions"] == "0"
|
||||
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
|
||||
def test_a_wall_keypress_reveals_the_overlay_and_exit_unstages(self, device):
|
||||
errors: list = []
|
||||
viewer = share_one_and_stage(device, errors)
|
||||
viewer.wait_for_function(OVERLAY_HIDDEN, timeout=10_000)
|
||||
|
||||
# `m` is a real wall command: tolerate and assert its mute flip, as
|
||||
# the keyboard suite does. The same keypress also reveals the
|
||||
# overlay — the kiosk's (mouseless) way to read the caption and
|
||||
# reach the controls.
|
||||
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})"
|
||||
viewer.wait_for_function(OVERLAY_REVEALED, timeout=5_000)
|
||||
|
||||
# While revealed, Exit is visible (its `hidden` flag was cleared by
|
||||
# stage()) and the reveal's pointer-events:auto is what makes it
|
||||
# clickable.
|
||||
assert (
|
||||
viewer.evaluate("() => document.querySelector('#watch-stage .exit').hidden") is False
|
||||
), "Exit must be visible while the overlay is revealed"
|
||||
|
||||
# Clicking it unstages — the grid comes back.
|
||||
viewer.click("#watch-stage .exit")
|
||||
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"), timeout=5_000)
|
||||
# The departing tile must not leak its reveal into the grid.
|
||||
assert viewer.evaluate("() => document.querySelector('.tile[data-reveal]') === null"), (
|
||||
"a stale data-reveal must not leak back into the grid"
|
||||
)
|
||||
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
|
||||
def test_grid_tile_reveal_rules_survive_the_staged_overrides(self, device):
|
||||
errors: list = []
|
||||
for _ in range(2):
|
||||
start_sharing(device("/mirror", errors))
|
||||
viewer = device("/watch", errors)
|
||||
expect(viewer.locator(".tile")).to_have_count(2, timeout=15_000)
|
||||
|
||||
# Nothing is staged here, so the id-scoped staged rules cannot be
|
||||
# what reveals anything: the kiosk screenshot rule must hold on its
|
||||
# own — the selected tile carries its controls, the rest stay quiet.
|
||||
viewer.keyboard.press("j")
|
||||
selected = viewer.evaluate(
|
||||
"() => document.querySelector('.tile[data-selected]').dataset.stream"
|
||||
)
|
||||
viewer.wait_for_function(
|
||||
"""(id) => {
|
||||
const el = document.querySelector(`.tile[data-stream="${id}"] .actions`);
|
||||
return el !== null && getComputedStyle(el).opacity === '1';
|
||||
}""",
|
||||
arg=selected,
|
||||
timeout=5_000,
|
||||
)
|
||||
|
||||
# The unselected tile: hover, focus and selection all absent → 0.
|
||||
other = viewer.evaluate(
|
||||
"""(id) => {
|
||||
const tile = [...document.querySelectorAll('.tile')]
|
||||
.find((e) => e.dataset.stream !== id);
|
||||
return tile ? getComputedStyle(tile.querySelector('.actions')).opacity : null;
|
||||
}""",
|
||||
selected,
|
||||
)
|
||||
assert other == "0", f"an unselected grid tile must stay quiet (got {other!r})"
|
||||
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
Reference in New Issue
Block a user