diff --git a/.agents/phases/todo/02_sharp_capture/00_phase.md b/.agents/phases/complete/02_sharp_capture/00_phase.md similarity index 100% rename from .agents/phases/todo/02_sharp_capture/00_phase.md rename to .agents/phases/complete/02_sharp_capture/00_phase.md diff --git a/.agents/phases/todo/02_sharp_capture/01_capture_resolution_constraints.md b/.agents/phases/complete/02_sharp_capture/01_capture_resolution_constraints.md similarity index 100% rename from .agents/phases/todo/02_sharp_capture/01_capture_resolution_constraints.md rename to .agents/phases/complete/02_sharp_capture/01_capture_resolution_constraints.md diff --git a/.agents/phases/todo/02_sharp_capture/02_sender_fidelity.md b/.agents/phases/complete/02_sharp_capture/02_sender_fidelity.md similarity index 100% rename from .agents/phases/todo/02_sharp_capture/02_sender_fidelity.md rename to .agents/phases/complete/02_sharp_capture/02_sender_fidelity.md diff --git a/.agents/phases/todo/02_sharp_capture/03_bitrate_default_and_surface.md b/.agents/phases/complete/02_sharp_capture/03_bitrate_default_and_surface.md similarity index 100% rename from .agents/phases/todo/02_sharp_capture/03_bitrate_default_and_surface.md rename to .agents/phases/complete/02_sharp_capture/03_bitrate_default_and_surface.md diff --git a/.agents/phases/todo/02_sharp_capture/04_sharpness_assertion_e2e.md b/.agents/phases/complete/02_sharp_capture/04_sharpness_assertion_e2e.md similarity index 100% rename from .agents/phases/todo/02_sharp_capture/04_sharpness_assertion_e2e.md rename to .agents/phases/complete/02_sharp_capture/04_sharpness_assertion_e2e.md diff --git a/.agents/reports/02_sharp_capture/02_sharp_capture__00_phase.a1.err b/.agents/reports/02_sharp_capture/02_sharp_capture__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/02_sharp_capture/02_sharp_capture__00_phase.a1.md b/.agents/reports/02_sharp_capture/02_sharp_capture__00_phase.a1.md new file mode 100644 index 0000000..6e93f55 --- /dev/null +++ b/.agents/reports/02_sharp_capture/02_sharp_capture__00_phase.a1.md @@ -0,0 +1,21 @@ +**Phase 02 final verification pass — complete, 100% green.** + +**Verified (all 4 tasks in `complete/02_sharp_capture/`):** +- Task 1: `pickSurface()` requests `screen × devicePixelRatio` (capped 3840×2160) instead of window size +- Task 2: `degradationPreference: "maintain-resolution"` + per-leg `maxBitrate`, re-applied after `setLocalDescription` *and* on `connected` (both call sites asserted in `test_app.py`) +- Task 3: default cap raised to 25 Mbit/s — `config.py` constant, `net.js` offline fallback, `.env.example`, README, and the drift-guard test all agree +- Task 4: realdisplay sharpness floor (viewer decoded width ≥ 1152px = 90% of Xvfb 1280px) in `test_sharp_picture_reaches_the_wall` + +**Gates (all green):** +- `uv run ruff check . && uv run ruff format --check .` → clean +- `uv run pyright` → 0 errors +- `uv run pytest` → 231 passed, 3 skipped (realdisplay), coverage 95% +- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 3 passed on a real Xvfb + headed Chromium + +**Completion criteria:** (1) 90% width floor asserted on viewer's decoded pixels — PASS · (2) `/api/config` `maxBitrateBps: 25000000` default, net.js fallback agrees — PASS · (3) `#stat-resolution` matches viewer-decoded size incl. documented window downscale (1100×620, 1280×720 fake-device tests) — PASS · (4) both suites green, aspect-ratio guard (`1280/800` ± 0.06 + `≠ 1280×720`) still distinguishes real from fake capture — PASS · (5) README Configuration documents `25000000` — PASS; `PLAN.md` §6 "default 12 Mbit/s" left untouched as instructed — **flag for the user to amend**. + +**Docs:** README — fixed suite timing `~20s → ~50s` (measured 49s); all other claims verified (CLI flags, dev extra, config table, kiosk key table vs. `watch.js`/`share.js` routers, kiosk commands vs. `docs/kiosk-silverblue.md`). AGENTS.md — same stale timing fixed, nothing else stale (all referenced test names/files exist). + +**Notable:** `docs/kiosk-silverblue.md` + its scripts use `mirror.reeselink.com` while README/SECURITY.md/Containerfile use `mirror.reeseapps.com` — a pre-existing cross-file mismatch I could not resolve from the repo (both in same-day commits; live-infra fact). Left untouched; user should confirm the live kiosk URL. No live changes made. + +**Next pending phase:** `03_audio_share`. diff --git a/.agents/reports/02_sharp_capture/02_sharp_capture__00_phase.a1.md.status b/.agents/reports/02_sharp_capture/02_sharp_capture__00_phase.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/02_sharp_capture/02_sharp_capture__00_phase.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/02_sharp_capture/02_sharp_capture__00_phase.a1.validate b/.agents/reports/02_sharp_capture/02_sharp_capture__00_phase.a1.validate new file mode 100644 index 0000000..acb4145 --- /dev/null +++ b/.agents/reports/02_sharp_capture/02_sharp_capture__00_phase.a1.validate @@ -0,0 +1,33 @@ +........................................................sss............. [ 30%] +........................................................................ [ 61%] +........................................................................ [ 92%] +.................. [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:249: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +SKIPPED [1] tests/e2e/test_x11_capture.py:289: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +SKIPPED [1] tests/e2e/test_x11_capture.py:332: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +231 passed, 3 skipped, 1 warning in 48.27s +All checks passed! +27 files already formatted +0 errors, 0 warnings, 0 informations diff --git a/.agents/reports/02_sharp_capture/02_sharp_capture__01_capture_resolution_constraints.a1.err b/.agents/reports/02_sharp_capture/02_sharp_capture__01_capture_resolution_constraints.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/02_sharp_capture/02_sharp_capture__01_capture_resolution_constraints.a1.md b/.agents/reports/02_sharp_capture/02_sharp_capture__01_capture_resolution_constraints.a1.md new file mode 100644 index 0000000..ab37207 --- /dev/null +++ b/.agents/reports/02_sharp_capture/02_sharp_capture__01_capture_resolution_constraints.a1.md @@ -0,0 +1,17 @@ +All four gates green. Final report: + +**Implemented (task 01 — capture the screen's pixels, not the window's)** +- `share.js` `pickSurface()`: explicit `ideal` width/height = `screen × devicePixelRatio`, capped 3840×2160, one shared `video` object for both the audio and silent `getDisplayMedia` attempts (catch semantics unchanged); `contentHint = "detail"` untouched; kiosk-rule comment added +- Diagnostic toast once per share when delivered width < 75% of requested, with the PLAN §5 reason commented +- Tests: `test_share_never_asks_for_an_exact_capture_size` (regex guard in `TestClientScripts`), new E2E `test_the_screen_sized_request_does_not_break_the_fake_device`, `test_x11_capture.py` stale "no matter what" comment fixed + +**Results** +- `node --check share.js` OK; `uv run ruff check . && uv run ruff format --check .` clean; `uv run pyright` 0 errors +- `uv run pytest` → 225 passed, 2 skipped (realdisplay-gated); `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 2 passed +- Measured (Xvfb 1280×800, window 1200×760): **requested 1200×760 → delivered 1200×750**, `displaySurface: monitor`. **Chromium still clamps the capture to the window** — please amend PLAN.md §5 accordingly (not edited, per task rules) + +**Docs** — README "Development" and AGENTS.md §5 updated: fake-device stream is 1280×720 *by default*, clamped to the window when a size is requested (the E2E context now yields 1100×620, asserted as such) + +**Deviations** — the task's "viewer still decodes 1280×720" premise holds only when no size is requested; the fake UI clamps named `ideal` sizes to the fake window, so the E2E asserts the viewer decodes exactly the sharer's delivered settings (window-clamped), moving, with `publish()` reporting delivered, not requested + +**Next pending task** — `02_sharp_capture/02_sender_fidelity.md` diff --git a/.agents/reports/02_sharp_capture/02_sharp_capture__01_capture_resolution_constraints.a1.md.status b/.agents/reports/02_sharp_capture/02_sharp_capture__01_capture_resolution_constraints.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/02_sharp_capture/02_sharp_capture__01_capture_resolution_constraints.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/02_sharp_capture/02_sharp_capture__01_capture_resolution_constraints.a1.validate b/.agents/reports/02_sharp_capture/02_sharp_capture__01_capture_resolution_constraints.a1.validate new file mode 100644 index 0000000..793a33c --- /dev/null +++ b/.agents/reports/02_sharp_capture/02_sharp_capture__01_capture_resolution_constraints.a1.validate @@ -0,0 +1,32 @@ +....................................................ss.................. [ 31%] +........................................................................ [ 63%] +........................................................................ [ 95%] +........... [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:221: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +SKIPPED [1] tests/e2e/test_x11_capture.py:258: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +225 passed, 2 skipped, 1 warning in 36.96s +All checks passed! +26 files already formatted +0 errors, 0 warnings, 0 informations diff --git a/.agents/reports/02_sharp_capture/02_sharp_capture__02_sender_fidelity.a1.err b/.agents/reports/02_sharp_capture/02_sharp_capture__02_sender_fidelity.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/02_sharp_capture/02_sharp_capture__02_sender_fidelity.a1.md b/.agents/reports/02_sharp_capture/02_sharp_capture__02_sender_fidelity.a1.md new file mode 100644 index 0000000..e5db21d --- /dev/null +++ b/.agents/reports/02_sharp_capture/02_sharp_capture__02_sender_fidelity.a1.md @@ -0,0 +1,22 @@ +All gates green. Final verification of the complete gate sequence was done above. Done — here's the report: + +**Task 02 — Tell the encoder to keep the pixels — complete** + +**Implemented** +- `share.js`: `applySenderQuality(peer)` — finds the video sender, sets `degradationPreference: "maintain-resolution"`, `maxBitrate` from `config.maxBitrateBps` on first encoding only (skipped when 0, which means "uncapped" in the WebRTC API); named `NotAllowedError` catch (Firefox rejects `degradationPreference`), `typeof setParameters` guard +- Both `addTransceiver`/`addTrack` paths capture the returned sender into `peer.senders`; initial `sendEncodings` cap **kept** alongside the re-apply with a comment (it covers the pre-negotiation window the re-apply can't) +- Re-applied after `setLocalDescription` in `negotiate()` (never blocks the offer) and on `connectionState === "connected"` +- `window.__probe` test handle (the sanctioned global) exposing `peers` + `renegotiate()` for driving a real in-place renegotiation +- New `tests/e2e/test_sender_fidelity.py`: 2-viewer baseline (params read back per leg) + renegotiation survival test; new guard in `test_app.py::TestClientScripts` (single `"maintain-resolution"`, both call sites via regex) + +**Gates** +- `uv run ruff check . && uv run ruff format --check .` — clean; `uv run pyright` — 0 errors +- `uv run pytest -m e2e` — 54 passed (incl. both new tests, real Chromium, config value read from `/api/config`); `uv run pytest -m "not e2e…"` — 174 passed, 95% coverage +- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — 2 passed +- Full `uv run pytest` — 228 passed, 2 skipped (realdisplay without env) — 100% green + +**Docs** — no doc impact: no config/command/API change (default cap stays 12 Mbit/s; task 03 raises it); `AGENTS.md` states nothing now stale. + +**Notable decisions** — `maxBitrate` only set when truthy (0 = uncapped, matches `addPeer`); immediate post-renegotiation read asserted because `renegotiate()` awaits the re-apply, so it proves the parameters return with the re-offer regardless of connection-state timing. + +**Next pending task** — `02_sharp_capture/03_bitrate_default_and_surface.md` (raise default cap to 25 Mbit/s; server, `net.js` fallback, `.env.example`, README). diff --git a/.agents/reports/02_sharp_capture/02_sharp_capture__02_sender_fidelity.a1.md.status b/.agents/reports/02_sharp_capture/02_sharp_capture__02_sender_fidelity.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/02_sharp_capture/02_sharp_capture__02_sender_fidelity.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/02_sharp_capture/02_sharp_capture__02_sender_fidelity.a1.validate b/.agents/reports/02_sharp_capture/02_sharp_capture__02_sender_fidelity.a1.validate new file mode 100644 index 0000000..a59b9ff --- /dev/null +++ b/.agents/reports/02_sharp_capture/02_sharp_capture__02_sender_fidelity.a1.validate @@ -0,0 +1,32 @@ +......................................................ss................ [ 31%] +........................................................................ [ 62%] +........................................................................ [ 93%] +.............. [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:221: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +SKIPPED [1] tests/e2e/test_x11_capture.py:258: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +228 passed, 2 skipped, 1 warning in 40.94s +All checks passed! +27 files already formatted +0 errors, 0 warnings, 0 informations diff --git a/.agents/reports/02_sharp_capture/02_sharp_capture__03_bitrate_default_and_surface.a1.err b/.agents/reports/02_sharp_capture/02_sharp_capture__03_bitrate_default_and_surface.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/02_sharp_capture/02_sharp_capture__03_bitrate_default_and_surface.a1.md b/.agents/reports/02_sharp_capture/02_sharp_capture__03_bitrate_default_and_surface.a1.md new file mode 100644 index 0000000..59116a7 --- /dev/null +++ b/.agents/reports/02_sharp_capture/02_sharp_capture__03_bitrate_default_and_surface.a1.md @@ -0,0 +1,26 @@ +**Task 03 complete — 100% green.** + +**Implemented** +- `config.py`: `DEFAULT_MAX_BITRATE_BPS` 12_000_000 → 25_000_000 (flows to `client_config()` unchanged, no new field) +- `net.js`: `loadConfig()` offline fallback `maxBitrateBps` → 25000000 +- `test_config.py::test_defaults` now pins `max_bitrate_bps == DEFAULT_MAX_BITRATE_BPS` (constant imported); `test_environment_wins` (override `250000`) left as-is +- New cross-file guard `TestClientScripts::test_server_and_client_agree_on_the_bitrate_default` — regex-extracts the `config.py` constant and the `net.js` literal, asserts they are equal (one number, two places, one test that notices) +- `test_config_advertises_the_media_defaults` untouched (fixture `2_000_000` — still passes, proving the value is honoured end-to-end) + +**Gates** +- `uv run ruff check . && uv run ruff format --check .` — clean +- `uv run pyright` — 0 errors +- `uv run pytest` — 229 passed, 2 skipped (the realdisplay-gated pair), ~41s; coverage 95% overall, config.py 93% (only pre-existing `pragma: no cover` lines) +- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — 2 passed +- `uv run python -c "...Settings.from_env().max_bitrate_bps"` → `25000000`; live `curl /api/config` on bare serve → `maxBitrateBps: 25000000`; `grep "12000000\|12_000_000"` over `src/`, `.env.example`, `tests/` → nothing + +**Docs** +- README.md: `MIRROR_MAX_BITRATE` row → `25000000` + one-sentence trade-off (Wi-Fi sharer with many viewers may need less, not more) +- `.env.example`: `25000000` + one-line comment (wired-LAN 1440p; lower for Wi-Fi) +- AGENTS.md: no change (it never stated the 12 Mbit figure — only PLAN.md §6 does) + +**Decisions / notes** +- The stale `PLAN.md` §6 line ("default 12 Mbit/s") is **not** edited per protocol; the phase commit message should name it for the user to amend. +- Value is pinned via the constant + cross-file equality (as the task spec dictates), not as a magic number in a test. + +**Next pending task:** `.agents/phases/todo/02_sharp_capture/04_sharpness_assertion_e2e.md` diff --git a/.agents/reports/02_sharp_capture/02_sharp_capture__03_bitrate_default_and_surface.a1.md.status b/.agents/reports/02_sharp_capture/02_sharp_capture__03_bitrate_default_and_surface.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/02_sharp_capture/02_sharp_capture__03_bitrate_default_and_surface.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/02_sharp_capture/02_sharp_capture__03_bitrate_default_and_surface.a1.validate b/.agents/reports/02_sharp_capture/02_sharp_capture__03_bitrate_default_and_surface.a1.validate new file mode 100644 index 0000000..be45706 --- /dev/null +++ b/.agents/reports/02_sharp_capture/02_sharp_capture__03_bitrate_default_and_surface.a1.validate @@ -0,0 +1,32 @@ +......................................................ss................ [ 31%] +........................................................................ [ 62%] +........................................................................ [ 93%] +............... [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:221: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +SKIPPED [1] tests/e2e/test_x11_capture.py:258: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +229 passed, 2 skipped, 1 warning in 40.85s +All checks passed! +27 files already formatted +0 errors, 0 warnings, 0 informations diff --git a/.agents/reports/02_sharp_capture/02_sharp_capture__04_sharpness_assertion_e2e.a1.err b/.agents/reports/02_sharp_capture/02_sharp_capture__04_sharpness_assertion_e2e.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/02_sharp_capture/02_sharp_capture__04_sharpness_assertion_e2e.a1.md b/.agents/reports/02_sharp_capture/02_sharp_capture__04_sharpness_assertion_e2e.a1.md new file mode 100644 index 0000000..64b299e --- /dev/null +++ b/.agents/reports/02_sharp_capture/02_sharp_capture__04_sharpness_assertion_e2e.a1.md @@ -0,0 +1,18 @@ +All gates green. Final verification complete. + +## Report + +**Implemented** +- `tests/e2e/test_x11_capture.py`: `SHARP_FLOOR_PX = 1152` (90% of the 1280 Xvfb width); size floor added to the existing viewer probe (`test_a_real_desktop_reaches_a_second_machine_and_moves`); new `test_sharp_picture_reaches_the_wall` (matches `-k sharp`) asserting viewer-decoded width ≥ 1152, aspect guard + moving frames in the same probe, and `/api/streams` metadata within 1 px of both the sharer's `track.getSettings()` and the viewer's decoded size; module docstring now records the measured requested-vs-delivered table (1200×760 → 1200×750; 1024×700 → 1024×640 = 80%; 800×600 → 800×500 = 62.5%). +- `tests/e2e/test_live_mirror.py`: `test_the_fake_device_keeps_exactly_1280x720` (1280×820 sharer window → fake device delivers exactly 1280×720, viewer decodes exactly 1280×720, moving); `test_the_staged_tile_bytes_keep_growing` (inbound-rtp.bytesReceived on a staged tile, via a `window.__probe`-only init-script scaffold; observed ~126 KB / ~0.5 Mbit/s over ~2 s, recorded in the docstring, growth-only assertion). + +**Gates** — `uv run ruff check . && uv run ruff format --check .` clean; `uv run pyright` 0 errors; `uv run pytest` 231 passed, 3 skipped (opt-in), coverage 95% (unchanged); `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` 3 passed; `-m realdisplay -k sharp` 1 passed. + +**Docs** — no doc impact (README's 25 Mbit/s default already done by task 03; AGENTS.md statements all still accurate). + +**Notable findings (for the commit message / user)** +- Measured: Chromium **still clamps the monitor capture to the capturing window even with the explicit `ideal` size named** — agrees with PLAN §5. Under Playwright `screen` emulates the viewport, so the request can never outgrow the window here. +- Negative verification: with the size constraints removed, a 1200×760 window delivers the identical 1200×750 (93.75% of the desktop), so the 1152 floor passes on this host for both old and new code — it discriminates by *window* size here (a 1024 window lands at 80% and fails). On real hardware, where `screen` is the true display resolution, a small window on a large display is the discriminating case the floor and the share page's 75% toast guard. +- `PLAN.md` §6's "default 12 Mbit/s" is stale (since task 03) — flagged for the user; not edited per protocol. + +**Next pending task** — none; this was the last task of phase 02. diff --git a/.agents/reports/02_sharp_capture/02_sharp_capture__04_sharpness_assertion_e2e.a1.md.status b/.agents/reports/02_sharp_capture/02_sharp_capture__04_sharpness_assertion_e2e.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/02_sharp_capture/02_sharp_capture__04_sharpness_assertion_e2e.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/02_sharp_capture/02_sharp_capture__04_sharpness_assertion_e2e.a1.validate b/.agents/reports/02_sharp_capture/02_sharp_capture__04_sharpness_assertion_e2e.a1.validate new file mode 100644 index 0000000..c1f2b0a --- /dev/null +++ b/.agents/reports/02_sharp_capture/02_sharp_capture__04_sharpness_assertion_e2e.a1.validate @@ -0,0 +1,33 @@ +........................................................sss............. [ 30%] +........................................................................ [ 61%] +........................................................................ [ 92%] +.................. [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:249: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +SKIPPED [1] tests/e2e/test_x11_capture.py:289: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +SKIPPED [1] tests/e2e/test_x11_capture.py:332: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend +231 passed, 3 skipped, 1 warning in 49.94s +All checks passed! +27 files already formatted +0 errors, 0 warnings, 0 informations diff --git a/.env.example b/.env.example index 81ca9de..0cd79dd 100644 --- a/.env.example +++ b/.env.example @@ -4,8 +4,9 @@ MIRROR_HOST=0.0.0.0 MIRROR_PORT=8055 -# Per-viewer outbound cap, in bits/second. With N watchers the sharer uploads N x this. -MIRROR_MAX_BITRATE=12000000 +# Per-viewer-leg outbound cap, in bits/second. With N watchers the sharer uploads N x this. +# 25 Mbit/s suits a wired LAN at 1440p; lower it for Wi-Fi sharers. +MIRROR_MAX_BITRATE=25000000 # Cap on concurrent mirrors. 0 = unlimited. MIRROR_MAX_MIRRORS=0 diff --git a/AGENTS.md b/AGENTS.md index 8e5344b..9be6342 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,7 +57,7 @@ is a roster and a signaling relay. Linux is the reference platform. ```bash uv run ruff check . && uv run ruff format --check . uv run pyright # must be 0 errors -uv run pytest # unit + API + browser E2E (~20s) +uv run pytest # unit + API + browser E2E (~50s) MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay # Linux-native X11 capture ``` @@ -73,7 +73,9 @@ Non-negotiable, all learned the hard way (full notes in `PLAN.md` §5): * Serve pages over **`http://127.0.0.1:`**. On `about:blank` or a plain LAN IP, `mediaDevices` is undefined and the test proves nothing. * Deterministic capture: `--use-fake-ui-for-media-stream - --use-fake-device-for-media-stream` → a real, *moving* 1280×720 monitor stream. + --use-fake-device-for-media-stream` → a real, *moving* monitor stream + (1280×720 by default; clamped to the capturing window when a size is + requested). * Real capture (the `realdisplay` suite): private **Xvfb**, headed Chromium, `--auto-select-desktop-capture-source="Entire screen"`, `--ozone-platform=x11`, and **never** `--use-fake-ui-for-media-stream` (it approves the request without diff --git a/README.md b/README.md index 3b9545e..f3391ec 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Environment variables (all optional; a `.env` file is loaded if present): | `MIRROR_PORT` | `8055` | Bind port. | | `MIRROR_ICE_SERVERS` | `[]` | JSON array of RTCIceServer objects, if peers cannot reach each other directly. | | `MIRROR_MAX_MIRRORS` | `0` | Cap on concurrent mirrors (`0` = unlimited). | -| `MIRROR_MAX_BITRATE` | `12000000` | Per-leg WebRTC bitrate cap in bits/s — the mesh uploads N × bitrate per sharer, so this is the ceiling. | +| `MIRROR_MAX_BITRATE` | `25000000` | Per-leg WebRTC bitrate cap in bits/s — the mesh uploads N × bitrate per sharer, so this is the ceiling. 25 Mbit/s suits a wired LAN at 1440p; a Wi-Fi sharer with many viewers may need less, not more. | | `MIRROR_PING_INTERVAL` | `20` | Seconds between socket liveness pings. | | `MIRROR_ALLOW_CIDRS` | *(unset)* | Comma-separated source networks allowed to connect, e.g. `10.2.0.0/22,fd00::/64`. Unset means unrestricted. | | `MIRROR_TRUSTED_PROXIES` | `127.0.0.0/8, ::1/128` | Which peers may speak for a client via `X-Forwarded-For`. Only a proxy that overwrites the header belongs here. | @@ -157,14 +157,15 @@ list is not reachable, so keep this table to hand. ## Development ```bash -uv run pytest # unit + API + browser E2E (~20s) +uv run pytest # unit + API + browser E2E (~50s) uv run pytest -m "not e2e" # everything except the browser (~0.5s) uv run ruff check . && uv run ruff format --check . && uv run pyright ``` The browser suite drives **real capture**: Chromium's fake media device yields a -moving 1280×720 monitor stream, so the assertions are on decoded, changing pixels -at the watcher — not on log lines. +moving monitor stream (1280×720 by default; clamped to the capturing window when +a size is requested, as `/mirror` now asks), so the assertions are on decoded, +changing pixels at the watcher — not on log lines. There is a second, opt-in rung that exercises Chromium's **real X11 capture backend** on a private Xvfb display, isolated from your own session so it can diff --git a/src/mirror/config.py b/src/mirror/config.py index b71370d..722a67c 100644 --- a/src/mirror/config.py +++ b/src/mirror/config.py @@ -12,7 +12,7 @@ from .access import DEFAULT_TRUSTED_PROXIES, Gate, Network, parse_cidrs DEFAULT_HOST = "0.0.0.0" DEFAULT_PORT = 8055 -DEFAULT_MAX_BITRATE_BPS = 12_000_000 +DEFAULT_MAX_BITRATE_BPS = 25_000_000 def _env(name: str, default: str) -> str: diff --git a/src/mirror/static/net.js b/src/mirror/static/net.js index 9a7a856..4ce30c6 100644 --- a/src/mirror/static/net.js +++ b/src/mirror/static/net.js @@ -17,7 +17,7 @@ let cachedConfig = null; export async function loadConfig() { if (cachedConfig) return cachedConfig; - const fallback = { iceServers: [], maxBitrateBps: 12000000, pingIntervalMs: 20000, version: "?" }; + const fallback = { iceServers: [], maxBitrateBps: 25000000, pingIntervalMs: 20000, version: "?" }; try { const res = await fetch("/api/config", { cache: "no-store" }); if (res.status === 403) { diff --git a/src/mirror/static/share.js b/src/mirror/static/share.js index 71cadc3..da1b161 100644 --- a/src/mirror/static/share.js +++ b/src/mirror/static/share.js @@ -47,6 +47,8 @@ const state = { * start) cannot open two pickers. */ picking: false, startedAt: 0, + /** The size pickSurface() asked for, so a delivered shortfall can be named. */ + requested: null, /** viewerId -> {pc, name, pending: [], state} */ peers: new Map(), counters: { bytes: 0, at: 0 }, @@ -118,8 +120,30 @@ function renderAudience() { /* --------------------------------------------------------------- capture */ async function pickSurface() { + // The rule the kiosk wall depends on: a capture is only as sharp as the + // pixels the sharer's browser agreed to grab, and the request must name + // them. Without explicit dimensions Chromium rescales the monitor down to + // the *browser window* (PLAN §5: 1280×800 → 1200×750), which is the + // primary cause of a fuzzy wall. `ideal`, never `exact` — a hard size fails + // or hides picker entries on displays/DPRs the browser cannot honour, and + // would break sharing outright; `ideal` is a preference the browser can + // decline while still capturing. Capped at 3840×2160 so an 8K HiDPI sharer + // cannot starve a 1080p wall's decoder or its uplink (MIRROR_MAX_BITRATE + // is the tuning knob). + const dpr = window.devicePixelRatio || 1; + const requested = { + width: Math.min(Math.round(screen.width * dpr), 3840), + height: Math.min(Math.round(screen.height * dpr), 2160), + }; + // Built once and shared: the audio attempt and its silent retry must send + // identical video constraints; only the audio flag differs. + const video = { + width: { ideal: requested.width }, + height: { ideal: requested.height }, + frameRate: { ideal: 30, max: 60 }, + }; const shared = { - video: { frameRate: { ideal: 30, max: 60 } }, + video, // Let the OS picker offer whole screens *and* individual windows, and let // Chrome show this very tab as an option (handy for demoing the viewer). selfBrowserSurface: "include", @@ -127,11 +151,17 @@ async function pickSurface() { monitorTypeSurfaces: "include", }; try { - return await navigator.mediaDevices.getDisplayMedia({ ...shared, audio: true }); + return { + stream: await navigator.mediaDevices.getDisplayMedia({ ...shared, audio: true }), + requested, + }; } catch (err) { // A source without audio (most windows on Linux) must not kill the share. if (err && err.name === "NotAllowedError") throw err; - return await navigator.mediaDevices.getDisplayMedia({ ...shared, audio: false }); + return { + stream: await navigator.mediaDevices.getDisplayMedia({ ...shared, audio: false }), + requested, + }; } } @@ -164,8 +194,9 @@ async function start() { if (state.picking) return; // a picker is already up; the second `s` must not open two state.picking = true; ui.start.disabled = true; + let captured; try { - state.stream = await pickSurface(); + captured = await pickSurface(); } catch (err) { state.picking = false; ui.start.disabled = false; @@ -178,12 +209,24 @@ async function start() { } state.picking = false; ui.start.disabled = false; + state.stream = captured.stream; + state.requested = captured.requested; const video = state.stream.getVideoTracks()[0]; if (video) { // Screen content is text-first: prefer sharpness over frame rate when the // link gets tight. video.contentHint = "detail"; + // PLAN §5: Chromium rescales a monitor capture to the capturing window, + // so a delivered width below 75% of the request means the share page is + // smaller than the screen. Named once per share — start() runs once per + // share, and the mute/unmute republish must not repeat it. + const delivered = video.getSettings(); + if (state.requested && delivered.width && delivered.width < 0.75 * state.requested.width) { + toast( + `Chromium captured ${delivered.width}×${delivered.height}: the browser window limits a screen capture, so make the share page bigger for a sharper wall.`, + ); + } video.addEventListener("ended", () => stop("The source stopped being shared.")); const republish = () => { publish(); @@ -207,6 +250,7 @@ function stop(message) { for (const id of [...state.peers.keys()]) removePeer(id, "stopped"); if (state.stream) state.stream.getTracks().forEach((t) => t.stop()); state.stream = null; + state.requested = null; state.startedAt = 0; stopMetrics(); if (state.ws) state.ws.send({ type: "unpublish" }); @@ -237,11 +281,54 @@ function removePeer(viewerId, why) { if (why === "failed") toast("A viewer could not be reached and was dropped."); } +/* Screen content is text-first, so the encoder must never buy frame rate with + resolution — the `contentHint = "detail"` trade, made real at the sender. + `setParameters` is per-negotiation: a re-offer (a surface switch, a viewer + that re-joined) resets the encodings, and the `sendEncodings` passed to + addTransceiver is only the *initial* request. The call sites (negotiate() + after setLocalDescription, and the `connected` state change) are why the + cap survives every renegotiation. */ +async function applySenderQuality(peer) { + const sender = (peer.pc.getSenders() || []).find( + (s) => s && s.track && s.track.kind === "video", + ); + if (!sender || typeof sender.setParameters !== "function") return; + const params = sender.getParameters(); + params.degradationPreference = "maintain-resolution"; + params.encodings = params.encodings?.length ? params.encodings : [{}]; + if (config.maxBitrateBps) { + // 0 means "uncapped" in the WebRTC API; only a truthy value is a cap, and + // it lands on the first encoding only — the cap is per leg, not per + // simulcast layer. + params.encodings[0].maxBitrate = config.maxBitrateBps; + } + try { + await sender.setParameters(params); + } catch (err) { + // Swallow only what it names: some engines reject these parameters — + // Firefox has no `degradationPreference` and rejects setParameters for it. + // The leg still works at the browser defaults; a bare `catch {}` would + // read like a bug (AGENTS.md §7). + if (err && err.name === "NotAllowedError") { + console.warn("sender quality not honoured by this engine", err); + return; + } + throw err; + } +} + async function addPeer(viewerId, name) { if (!state.stream) return; removePeer(viewerId, "replace"); const pc = new RTCPeerConnection(rtc); - const peer = { pc, name: name || "Viewer", pending: [], offer: Promise.resolve() }; + const peer = { + pc, + name: name || "Viewer", + pending: [], + offer: Promise.resolve(), + /** The sender each addTransceiver/addTrack produced; one per track. */ + senders: [], + }; state.peers.set(viewerId, peer); pc.onicecandidate = (event) => { @@ -250,7 +337,12 @@ async function addPeer(viewerId, name) { pc.onnegotiationneeded = () => negotiate(viewerId); pc.onconnectionstatechange = () => { renderAudience(); - if (pc.connectionState === "failed") { + if (pc.connectionState === "connected") { + // The moment the leg is up is the last re-apply before it is measured + // as live; a renegotiation lands here too, so the cap is never more + // than one offer stale. + applySenderQuality(peer).catch((err) => console.warn("sender quality", err)); + } else if (pc.connectionState === "failed") { // Re-offer once before giving up; a viewer that roamed off the Wi-Fi // looks identical to one that walked away. if (!peer.retried) { @@ -267,14 +359,22 @@ async function addPeer(viewerId, name) { for (const track of state.stream.getTracks()) { const init = { direction: "sendonly" }; if (track.kind === "video" && config.maxBitrateBps) { - // Cap each leg: N viewers must not melt the sharer's encoder. + // Cap each leg: N viewers must not melt the sharer's encoder. This is + // kept *in addition to* the post-negotiation re-apply: `sendEncodings` + // here is only the initial request, and between addTransceiver and the + // first setLocalDescription it is the only thing capping the leg — + // applySenderQuality() cannot cover that window. init.sendEncodings = [{ maxBitrate: config.maxBitrateBps }]; } + // addTransceiver and its addTrack fallback both return the track's + // sender, so one code path owns the quality of both. + let sender; try { - pc.addTransceiver(track, init); + sender = pc.addTransceiver(track, init); } catch { - pc.addTrack(track, state.stream); + sender = pc.addTrack(track, state.stream); } + peer.senders.push(sender); } renderAudience(); } @@ -290,6 +390,10 @@ async function negotiate(viewerId) { try { const offer = await pc.createOffer(); await pc.setLocalDescription(offer); + // The local description set means the encodings exist: (re)apply the + // quality now. It must never hold the offer hostage, so it cannot + // throw into the negotiation. + await applySenderQuality(peer).catch((err) => console.warn("sender quality", err)); sendSignal(viewerId, { sdp: pc.localDescription }); } catch (err) { console.warn("negotiation failed", err); @@ -488,3 +592,16 @@ if (refusal) { if (ui.link) ui.link.href = `${location.origin}/watch`; renderAudience(); renderIdle(); + +/* Test scaffold: the one global this page may expose (AGENTS.md §6). The + e2e suite reads the live legs' sender parameters back through `peers` and + drives a real renegotiation through `renegotiate` to prove the quality + survives a re-offer. */ +window.__probe = { + get peers() { + return state.peers; + }, + renegotiate(viewerId) { + return negotiate(viewerId); + }, +}; diff --git a/tests/e2e/test_live_mirror.py b/tests/e2e/test_live_mirror.py index bd5b909..96243e4 100644 --- a/tests/e2e/test_live_mirror.py +++ b/tests/e2e/test_live_mirror.py @@ -65,6 +65,176 @@ class TestShareThenWatch: assert errors == [], f"uncaught browser errors: {errors}" + def test_the_screen_sized_request_does_not_break_the_fake_device(self, device): + """Asking for screen x DPR (capped 3840x2160) must not break the fake + device's path. + + The old "always 1280x720" expectation held only while the request + carried no size. Once a size is *named*, the fake UI clamps the capture + to the fake browser window (1100x720 context -> 1100x620 with chrome) + — PLAN §5's window scaling, even for the synthetic device. So the + assertion here is: the share starts, the viewer decodes exactly what + the sharer's delivered settings are, moving, and publish() reports + those delivered settings, not the request. + """ + errors: list = [] + sharer = device("/mirror", errors) + start_sharing(sharer) + + # What the page asked for vs what the fake device delivered. + reported = sharer.evaluate( + """() => { + const track = document.querySelector('#preview').srcObject.getVideoTracks()[0]; + const s = track.getSettings(); + const dpr = window.devicePixelRatio || 1; + return { + requested: { + width: Math.min(Math.round(screen.width * dpr), 3840), + height: Math.min(Math.round(screen.height * dpr), 2160), + }, + delivered: {width: s.width, height: s.height}, + stat: document.querySelector('#stat-resolution').textContent, + }; + }""" + ) + assert reported["requested"] == {"width": 1100, "height": 720}, reported + # The full requested width is honoured; the height is clamped to the + # fake window's visible area (the 100px of chrome). + assert reported["delivered"]["width"] == 1100, reported + assert 0 < reported["delivered"]["height"] < 720, reported + + # publish() reports the delivered settings, not the request. + # The page renders the separator as U+00D7 (multiplication sign). + w, h = reported["delivered"]["width"], reported["delivered"]["height"] + assert reported["stat"] == f"{w} \u00d7 {h}", reported + + viewer = device("/watch", errors) + expect(viewer.locator(".tile[data-state='live']")).to_have_count(1, timeout=25_000) + frames = frames_matching(viewer, ".tile video") + assert (frames["width"], frames["height"]) == (w, h), ( + f"the viewer must decode what the sharer reported: {frames} vs {reported}" + ) + assert frames["distinct"] >= 2, f"the video is frozen: {frames}" + assert errors == [], f"uncaught browser errors: {errors}" + + def test_the_fake_device_keeps_exactly_1280x720(self, base_url, browser, request, device): + """The fake-device counterpart of the realdisplay sharpness floor. + + With the sharer's window filling the fake 1280x720 monitor (a 1280x820 + viewport: the ~100px of window chrome clamps the 820 request back to + 720), the deterministic device delivers exactly 1280x720 — the classic + "always 1280x720" synthetic path. The phase 02 ``ideal`` size + constraints (screen x DPR, capped 3840x2160) must not change what the + synthetic path delivers: the viewer decodes exactly 1280x720, moving. + + Measured on the reference host (phase 02, 2026-11-01): a 1280x820 + sharer window requests 1280x820 and the fake device delivers exactly + 1280x720; the viewer decodes 1280x720. (A 1100x720 window instead + delivers 1100x620 — the same window clamp, covered by the sibling + test.) + """ + errors: list = [] + context = browser.new_context(viewport={"width": 1280, "height": 820}) + request.addfinalizer(context.close) + sharer = context.new_page() + sharer.on("pageerror", lambda error: errors.append(str(error))) + sharer.goto(f"{base_url}/mirror", wait_until="domcontentloaded") + expect(sharer.locator("#net")).to_have_attribute("data-state", "online", timeout=10_000) + start_sharing(sharer) + + delivered = sharer.evaluate( + """() => { + const track = document.querySelector('#preview').srcObject.getVideoTracks()[0]; + const s = track.getSettings(); + return {width: s.width, height: s.height}; + }""" + ) + assert delivered == {"width": 1280, "height": 720}, delivered + # The page's own stats agree with the delivered settings. + expect(sharer.locator("#stat-resolution")).to_have_text("1280 \u00d7 720") + + viewer = device("/watch", errors) + expect(viewer.locator(".tile[data-state='live']")).to_have_count(1, timeout=25_000) + frames = frames_matching(viewer, ".tile video") + assert (frames["width"], frames["height"]) == (1280, 720), ( + f"the fake device must keep delivering exactly 1280x720: {frames}" + ) + assert frames["distinct"] >= 2, f"the video is frozen: {frames}" + assert errors == [], f"uncaught browser errors: {errors}" + + def test_the_staged_tile_bytes_keep_growing(self, base_url, browser, request, device): + """Inbound-bitrate sanity on a staged tile: an observation, not an equality. + + The viewer's ``inbound-rtp.bytesReceived`` must grow while a share is + live. With the default 25 Mbit/s per-leg cap, a live 1280x720 fake + share delivered ~126 KB over a ~2 s sample (≈ 0.5 Mbit/s) on the + reference host (phase 02, 2026-11-01) — the figure is + environment-dependent (codec, motion, cap), so the assertion is growth + from a non-zero start, never a specific number. + """ + context = browser.new_context(viewport={"width": 1100, "height": 720}) + request.addfinalizer(context.close) + # Test scaffolding (AGENTS.md §6): collect the RTCPeerConnection + # instances watch.js creates so the test can read the viewer's + # inbound-rtp stats through the one allowed global, ``window.__probe``. + context.add_init_script( + """ + window.__probe = {pcs: []}; + const RealPC = window.RTCPeerConnection; + window.RTCPeerConnection = function (config) { + const pc = new RealPC(config); + window.__probe.pcs.push(pc); + return pc; + }; + window.RTCPeerConnection.prototype = RealPC.prototype; + window.RTCPeerConnection.static = RealPC.static; + """ + ) + page = context.new_page() + page.goto(f"{base_url}/watch", wait_until="domcontentloaded") + expect(page.locator("#net")).to_have_attribute("data-state", "online", timeout=10_000) + + sharer = device("/mirror") + start_sharing(sharer) + + expect(page.locator(".tile[data-state='live']")).to_have_count(1, timeout=25_000) + # Take it fullscreen: the staged