phase: 02_sharp_capture
**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`.
This commit is contained in:
@@ -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`.
|
||||
@@ -0,0 +1 @@
|
||||
ok
|
||||
@@ -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
|
||||
+17
@@ -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`
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+32
@@ -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
|
||||
@@ -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).
|
||||
@@ -0,0 +1 @@
|
||||
ok
|
||||
@@ -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
|
||||
+26
@@ -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`
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+32
@@ -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
|
||||
@@ -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.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+33
@@ -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
|
||||
+3
-2
@@ -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
|
||||
|
||||
@@ -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:<port>`**. 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+126
-9
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 <video> is the same element moved onto
|
||||
# the stage, so the peer connection — and its stats — survive.
|
||||
page.locator(".tile").click()
|
||||
expect(page.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
|
||||
expect(page.locator("#watch-stage video")).to_have_count(1)
|
||||
|
||||
read_bytes = """async () => {
|
||||
let total = 0;
|
||||
for (const pc of window.__probe.pcs) {
|
||||
try {
|
||||
const stats = await pc.getStats();
|
||||
stats.forEach((entry) => {
|
||||
if (entry.type === 'inbound-rtp' && entry.kind === 'video') {
|
||||
total += entry.bytesReceived || 0;
|
||||
}
|
||||
});
|
||||
} catch { /* a closed pc has no stats; the live one does */ }
|
||||
}
|
||||
return total;
|
||||
}"""
|
||||
|
||||
# Wait for the leg to actually be connected, then sample twice.
|
||||
deadline = time.time() + 30
|
||||
while time.time() < deadline:
|
||||
states = page.evaluate("() => window.__probe.pcs.map(pc => pc.connectionState)")
|
||||
if "connected" in states:
|
||||
break
|
||||
time.sleep(0.3)
|
||||
first = page.evaluate(read_bytes)
|
||||
assert first > 0, f"no bytes had arrived by the first sample: {first}"
|
||||
time.sleep(2.0)
|
||||
second = page.evaluate(read_bytes)
|
||||
assert second > first, (
|
||||
f"the viewer's inbound byte count did not grow while the share was "
|
||||
f"live: {first} -> {second}"
|
||||
)
|
||||
|
||||
def test_clicking_a_tile_takes_it_on_stage(self, device):
|
||||
sharer = device("/mirror")
|
||||
start_sharing(sharer)
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""End-to-end: the sharer's per-leg encoder settings, read back from the page.
|
||||
|
||||
The wall is a wall of *text*: the encoder must never buy frame rate with
|
||||
resolution. `degradationPreference: "maintain-resolution"` is that trade, and
|
||||
the per-leg `maxBitrate` cap must survive every renegotiation — a re-offer
|
||||
(a surface switch, a viewer that re-joined) resets the encodings, and the
|
||||
cap is re-applied after each negotiation and when a leg reaches `connected`.
|
||||
|
||||
The assertions read the parameters back from the sharer's real
|
||||
RTCPeerConnection senders, through the page's `window.__probe` test handle
|
||||
(AGENTS.md §6) — on the full Chromium channel this is a real read, not a
|
||||
stub. The media rules still govern: pixel assertions are made on the
|
||||
*viewer's* `<video>`, never on the sharer's preview.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from .conftest import frames_matching, 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)."""
|
||||
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.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
|
||||
|
||||
|
||||
# Reads the video sender's committed parameters back from the sharer's live
|
||||
# legs. `state` is the leg's connectionState; `maxBitrate` is the cap the
|
||||
# leg is actually encoding under (0 or undefined would mean "uncapped").
|
||||
SENDER_PROBE = """() => {
|
||||
const probe = window.__probe;
|
||||
if (!probe) return null;
|
||||
return [...probe.peers.values()].map((peer) => {
|
||||
const sender = peer.pc.getSenders().find(
|
||||
(s) => s.track && s.track.kind === "video"
|
||||
);
|
||||
if (!sender) return null;
|
||||
const params = sender.getParameters();
|
||||
const encodings = params.encodings || [];
|
||||
return {
|
||||
state: peer.pc.connectionState,
|
||||
degradationPreference: params.degradationPreference,
|
||||
maxBitrate: encodings.length ? encodings[0].maxBitrate : null,
|
||||
};
|
||||
});
|
||||
}"""
|
||||
|
||||
|
||||
def _wait_for(sharer, want: int, pred, timeout: float = 45.0) -> list:
|
||||
"""Poll the sharer's legs until `want` of them satisfy `pred` (or give up)."""
|
||||
legs: list = []
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
legs = sharer.evaluate(SENDER_PROBE) or []
|
||||
if len(legs) == want and all(leg and pred(leg) for leg in legs):
|
||||
return legs
|
||||
time.sleep(0.5)
|
||||
return legs
|
||||
|
||||
|
||||
class TestSenderFidelity:
|
||||
def test_two_live_legs_report_maintain_resolution_and_the_cap(self, device, base_url):
|
||||
import httpx
|
||||
|
||||
cap = httpx.get(f"{base_url}/api/config", timeout=5).json()["maxBitrateBps"]
|
||||
assert cap > 0
|
||||
|
||||
errors: list = []
|
||||
sharer = device("/mirror", errors)
|
||||
start_sharing(sharer)
|
||||
|
||||
viewers = [device("/watch", errors) for _ in range(2)]
|
||||
for viewer in viewers:
|
||||
expect(viewer.locator(".tile[data-state='live']")).to_have_count(1, timeout=25_000)
|
||||
|
||||
legs = _wait_for(sharer, 2, lambda leg: leg["state"] == "connected")
|
||||
assert len(legs) == 2, f"the sharer has {len(legs)} legs, wanted 2: {legs}"
|
||||
for leg in legs:
|
||||
assert leg["state"] == "connected", legs
|
||||
assert leg["degradationPreference"] == "maintain-resolution", leg
|
||||
assert leg["maxBitrate"] == cap, leg
|
||||
|
||||
# The parameters are not a facade: the pixels actually arrive, moving.
|
||||
for viewer in viewers:
|
||||
frames = frames_matching(viewer, ".tile video")
|
||||
assert frames["distinct"] >= 2, f"a viewer froze: {frames}"
|
||||
|
||||
expect(sharer.locator("#viewer-count")).to_have_text("2", timeout=10_000)
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
|
||||
def test_the_quality_survives_a_renegotiation(self, device, base_url):
|
||||
"""The bug the post-negotiation re-apply exists for.
|
||||
|
||||
A live leg is renegotiated in place — a second offer on the same
|
||||
RTCPeerConnection, exactly what a surface switch or a viewer that
|
||||
re-joined does. The re-offer resets the encodings; the parameters
|
||||
must be back by the time the renegotiation completes, and the wall
|
||||
must still be moving on the viewer.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
cap = httpx.get(f"{base_url}/api/config", timeout=5).json()["maxBitrateBps"]
|
||||
|
||||
errors: list = []
|
||||
sharer = device("/mirror", errors)
|
||||
start_sharing(sharer)
|
||||
|
||||
viewer = device("/watch", errors)
|
||||
expect(viewer.locator(".tile[data-state='live']")).to_have_count(1, timeout=25_000)
|
||||
legs = _wait_for(sharer, 1, lambda leg: leg["state"] == "connected")
|
||||
assert legs and legs[0]["state"] == "connected", legs
|
||||
assert legs[0]["degradationPreference"] == "maintain-resolution", legs
|
||||
assert legs[0]["maxBitrate"] == cap, legs
|
||||
|
||||
immediate = sharer.evaluate(
|
||||
"""async () => {
|
||||
const [id, peer] = [...window.__probe.peers.entries()][0];
|
||||
await window.__probe.renegotiate(id);
|
||||
const sender = peer.pc.getSenders().find(
|
||||
(s) => s.track && s.track.kind === "video"
|
||||
);
|
||||
const params = sender.getParameters();
|
||||
const encodings = params.encodings || [];
|
||||
return {
|
||||
state: peer.pc.connectionState,
|
||||
degradationPreference: params.degradationPreference,
|
||||
maxBitrate: encodings.length ? encodings[0].maxBitrate : null,
|
||||
};
|
||||
}"""
|
||||
)
|
||||
# renegotiate() awaits setLocalDescription *and* the re-apply before
|
||||
# it resolves, so the parameters must already be back — whatever the
|
||||
# leg's connection state is at that instant.
|
||||
assert immediate["degradationPreference"] == "maintain-resolution", immediate
|
||||
assert immediate["maxBitrate"] == cap, immediate
|
||||
|
||||
# ...and once the leg reports `connected` again, the connected-state
|
||||
# re-apply confirms them and the picture is still moving.
|
||||
legs = _wait_for(sharer, 1, lambda leg: leg["state"] == "connected")
|
||||
assert legs and legs[0]["state"] == "connected", legs
|
||||
assert legs[0]["degradationPreference"] == "maintain-resolution", legs
|
||||
assert legs[0]["maxBitrate"] == cap, legs
|
||||
|
||||
frames = frames_matching(viewer, ".tile video")
|
||||
assert frames["distinct"] >= 2, f"the leg froze across the renegotiation: {frames}"
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
@@ -10,6 +10,28 @@ Skipped unless ``MIRROR_REALDISPLAY=1``, because it needs an X server binary and
|
||||
a headed browser::
|
||||
|
||||
MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay
|
||||
|
||||
Measured on the reference Linux host (Xvfb 1280x800x24, headed Chromium,
|
||||
phase 02, 2026-11-01) — requested vs delivered, per sharer window size:
|
||||
|
||||
=================== ============ ============ =====================
|
||||
sharer window requested delivered viewer decodes
|
||||
=================== ============ ============ =====================
|
||||
1200x760 1200x760 1200x750 1200x750 (93.75% of desktop)
|
||||
1024x700 1024x700 1024x640 1024x640 (80% -> fails the floor)
|
||||
800x600 800x600 800x500 800x500 (62.5% -> fails the floor)
|
||||
=================== ============ ============ =====================
|
||||
|
||||
* ``requested`` is ``screen x devicePixelRatio``; under Playwright ``screen``
|
||||
tracks the browser window, so the request can never outgrow the window here.
|
||||
* Chromium *still* clamps a monitor capture to the capturing window even with
|
||||
an explicit ``ideal`` size named — which agrees with PLAN §5 ("Chromium
|
||||
scales a monitor capture to the capturing window"). Removing the phase 02
|
||||
size constraints (frameRate only) delivered the identical 1200x750 for a
|
||||
1200x760 window, so on this host the floor discriminates by *window* size,
|
||||
not by the constraints. On real hardware ``screen`` is the true display
|
||||
resolution, so a small window on a large display is the case the 90% floor
|
||||
and the share page's 75% toast are for.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -35,11 +57,18 @@ pytestmark = [
|
||||
),
|
||||
]
|
||||
|
||||
# Deliberately not 16:9: Chromium's fake device reports 1280x720 no matter what,
|
||||
# so an aspect ratio of exactly 1280/800 is a fingerprint of the real screen.
|
||||
# Deliberately not 16:9: an aspect ratio of exactly 1280/800 is a fingerprint
|
||||
# of the real screen. (The fake device, where it is used, defaults to 1280x720
|
||||
# and, once a size is requested, clamps to the capturing window — PLAN §5.)
|
||||
XVFB_GEOMETRY = "1280x800x24"
|
||||
XVFB_ASPECT = 1280 / 800
|
||||
FAKE_DEVICE_SIZE = (1280, 720)
|
||||
# The sharpness floor: 90% of the desktop width (1152 of 1280 px). An exact
|
||||
# 100% match is environment-specific (compositor, fractional DPI, window
|
||||
# chrome), but a window-clamped capture of a small share page lands at 62-93%
|
||||
# of the desktop — that is where the fuzziness a person sees lives.
|
||||
XVFB_WIDTH = 1280
|
||||
SHARP_FLOOR_PX = int(XVFB_WIDTH * 0.9)
|
||||
|
||||
# Flags learned the hard way, and worth keeping written down:
|
||||
#
|
||||
@@ -240,8 +269,11 @@ class TestRealX11Capture:
|
||||
f"got exactly {size}: that is Chromium's fake capture device, not the X11 screen"
|
||||
)
|
||||
assert settings["width"] >= 640, f"implausibly small capture: {settings}"
|
||||
# Chromium scales a monitor capture to the capturing window, so compare
|
||||
# the aspect ratio: 1280x800 in, 1200x750 out is the real screen.
|
||||
# Measured after phase 02: the 1200x760 window requests 1200x760
|
||||
# (Playwright emulates screen == window) and Chromium still clamps the
|
||||
# monitor capture to the capturing window: 1280x800 desktop in, 1200x750
|
||||
# out, aspect 1.6000 preserved. So compare the aspect ratio, not
|
||||
# absolute pixels (PLAN §5).
|
||||
assert abs(settings["width"] / settings["height"] - XVFB_ASPECT) < 0.06, (
|
||||
f"{size} does not match the {XVFB_GEOMETRY} desktop aspect "
|
||||
f"({XVFB_ASPECT:.3f}); this is not our Xvfb screen"
|
||||
@@ -276,8 +308,10 @@ class TestRealX11Capture:
|
||||
time.sleep(0.28)
|
||||
result = watch_page.evaluate("() => window.__probe")
|
||||
|
||||
assert result["width"] >= 640 and result["height"] >= 400, (
|
||||
f"the watcher decoded no picture: {result}"
|
||||
assert result["width"] >= SHARP_FLOOR_PX, (
|
||||
f"the watcher decoded a {result['width']}px-wide picture: the floor is "
|
||||
f"{SHARP_FLOOR_PX}px (90% of the {XVFB_GEOMETRY} desktop); below it the "
|
||||
f"wall shows a window-clamped capture, not the screen: {result}"
|
||||
)
|
||||
assert abs(result["width"] / result["height"] - XVFB_ASPECT) < 0.06, result
|
||||
assert result["distinct"] >= 2, (
|
||||
@@ -294,3 +328,85 @@ class TestRealX11Capture:
|
||||
|
||||
watch_page.close()
|
||||
share_page.close()
|
||||
|
||||
def test_sharp_picture_reaches_the_wall(self, base_url, share_page, watch_page):
|
||||
"""The size floor that makes \"sharper\" an assertion.
|
||||
|
||||
The viewer's decoded width must be at least 90% of the Xvfb desktop
|
||||
(1152 of 1280 px), the picture must keep moving in the same probe (a
|
||||
large-but-frozen picture must not pass), and the roster must not lie
|
||||
about what is on the wire: the published ``/api/streams`` width/height
|
||||
sit within one rounding step of both the sharer's
|
||||
``track.getSettings()`` and the viewer's decoded size.
|
||||
|
||||
Measured on this host (phase 02, 2026-11-01): the 1200x760 share
|
||||
window requests 1200x760 (Playwright emulates ``screen`` == viewport)
|
||||
and Chromium clamps the monitor capture to the capturing window, so the
|
||||
viewer decodes 1200x750 — 93.75% of the desktop, over the floor.
|
||||
Removing the phase 02 size constraints delivered the identical 1200x750
|
||||
here (the request cannot outgrow the window under Playwright), while a
|
||||
1024x700 window delivered 1024x640 (80%) — under the floor. On real
|
||||
hardware ``screen`` is the true display resolution, so a small window
|
||||
on a large display is the case this floor and the share page's 75%
|
||||
toast are for.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
share_page.on("pageerror", lambda error: errors.append(str(error)))
|
||||
|
||||
share_page.locator("#start").click()
|
||||
expect(share_page.locator("body")).to_have_attribute("data-live", "on", timeout=40_000)
|
||||
|
||||
settings = share_page.evaluate(
|
||||
"""() => {
|
||||
const track = document.querySelector('#preview').srcObject.getVideoTracks()[0];
|
||||
const s = track.getSettings();
|
||||
return {width: s.width, height: s.height, surface: s.displaySurface};
|
||||
}"""
|
||||
)
|
||||
assert settings["surface"] == "monitor", f"not a monitor capture: {settings}"
|
||||
|
||||
expect(watch_page.locator(".tile[data-state='live']")).to_have_count(1, timeout=60_000)
|
||||
|
||||
# Start measuring on the watcher, then paint: cycling the captured
|
||||
# machine's background is what a real presenter's screen does constantly.
|
||||
assert watch_page.evaluate(PROBE) == "started"
|
||||
for colour in ("#c81e1e", "#0b0d10", "#1e6ec8", "#0b0d10", "#1ec85a"):
|
||||
share_page.evaluate("(colour) => { document.body.style.background = colour; }", colour)
|
||||
time.sleep(0.28)
|
||||
result = watch_page.evaluate("() => window.__probe")
|
||||
|
||||
assert result["width"] >= SHARP_FLOOR_PX, (
|
||||
f"the watcher decoded a {result['width']}px-wide picture: the floor is "
|
||||
f"{SHARP_FLOOR_PX}px (90% of the {XVFB_GEOMETRY} desktop); below it the "
|
||||
f"wall is a window-clamped capture, not the screen: {result}"
|
||||
)
|
||||
assert abs(result["width"] / result["height"] - XVFB_ASPECT) < 0.06, (
|
||||
f"{result} does not match the {XVFB_GEOMETRY} desktop aspect "
|
||||
f"({XVFB_ASPECT:.3f}); this is not our Xvfb screen"
|
||||
)
|
||||
assert result["distinct"] >= 2, (
|
||||
f"the watcher received a frozen frame: {result} — a large-but-still "
|
||||
f"picture must not pass a sharpness test"
|
||||
)
|
||||
|
||||
# The roster must not lie about what is on the wire: the published
|
||||
# size sits within one rounding step of the sharer's delivered
|
||||
# settings *and* the viewer's decoded size.
|
||||
time.sleep(0.6)
|
||||
mirror = httpx.get(f"{base_url}/api/streams", timeout=5).json()["mirrors"][0]
|
||||
for source, (w, h) in {
|
||||
"the sharer's track.getSettings()": (settings["width"], settings["height"]),
|
||||
"the viewer's decoded size": (result["width"], result["height"]),
|
||||
}.items():
|
||||
assert abs(mirror["width"] - w) <= 1, (
|
||||
f"roster width {mirror['width']} vs {w} from {source}: the roster must "
|
||||
f"not lie about what is on the wire"
|
||||
)
|
||||
assert abs(mirror["height"] - h) <= 1, (
|
||||
f"roster height {mirror['height']} vs {h} from {source}: the roster must "
|
||||
f"not lie about what is on the wire"
|
||||
)
|
||||
|
||||
# And the sharer agrees that someone is watching its desktop.
|
||||
expect(share_page.locator("#viewer-count")).to_have_text("1", timeout=20_000)
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
|
||||
@@ -130,6 +130,67 @@ class TestClientScripts:
|
||||
assert f'src="/static/{script}"' in html
|
||||
assert 'type="module"' in html
|
||||
|
||||
def test_share_never_asks_for_an_exact_capture_size(self):
|
||||
"""A hard size breaks pickers: `exact` fails or hides picker entries on
|
||||
displays/DPRs the browser cannot honour. The size request must stay a
|
||||
preference the browser can decline while still capturing."""
|
||||
source = (STATIC / "share.js").read_text()
|
||||
assert not re.search(r"\bexact\s*:", source), (
|
||||
"share.js must not use an `exact` constraint key"
|
||||
)
|
||||
|
||||
def test_server_and_client_agree_on_the_bitrate_default(self):
|
||||
"""`/api/config` advertises the server's per-leg cap; a browser that cannot
|
||||
reach it falls back to the hardcoded literal in `net.js`. If the two drift,
|
||||
offline clients silently get a different picture quality and nothing fails.
|
||||
One number, two places, one test that notices."""
|
||||
server = int(
|
||||
re.search(
|
||||
r"DEFAULT_MAX_BITRATE_BPS\s*=\s*(\d[\d_]*)",
|
||||
(STATIC.parent / "config.py").read_text(),
|
||||
)
|
||||
.group(1)
|
||||
.replace("_", "")
|
||||
)
|
||||
client = int(re.search(r"maxBitrateBps:\s*(\d+)", (STATIC / "net.js").read_text()).group(1))
|
||||
assert server == client, (
|
||||
f"the server default ({server}) and the net.js fallback ({client}) "
|
||||
"have drifted: a client that cannot reach /api/config would use "
|
||||
"a different bitrate silently"
|
||||
)
|
||||
|
||||
def test_sender_quality_is_reapplied_on_negotiation_and_connection(self):
|
||||
"""`setParameters` is per-negotiation: a re-offer (a surface switch,
|
||||
a viewer that re-joined) resets the encodings. The cap must therefore
|
||||
be re-applied after `setLocalDescription` *and* when the leg becomes
|
||||
`connected`. Both call sites are asserted so a future edit cannot drop
|
||||
one and silently soften the wall."""
|
||||
source = (STATIC / "share.js").read_text()
|
||||
assert source.count('"maintain-resolution"') == 1, (
|
||||
"the degradation preference must be a single, named decision"
|
||||
)
|
||||
assert "async function applySenderQuality" in source
|
||||
|
||||
def section(start: str, stop: str) -> str:
|
||||
begin = source.index(start)
|
||||
return source[begin : source.index(stop, begin)]
|
||||
|
||||
negotiate = section("async function negotiate(viewerId)", "async function onSignal")
|
||||
assert "applySenderQuality" in negotiate, (
|
||||
"the cap is not re-applied after setLocalDescription"
|
||||
)
|
||||
assert negotiate.index("setLocalDescription") < negotiate.index("applySenderQuality"), (
|
||||
"the cap is applied before the encodings exist"
|
||||
)
|
||||
|
||||
connected = section(
|
||||
"pc.onconnectionstatechange = () => {",
|
||||
"for (const track of state.stream.getTracks())",
|
||||
)
|
||||
assert re.search(
|
||||
r'connectionState\s*===\s*"connected".*applySenderQuality', connected, re.S
|
||||
), "the cap is not re-applied when the leg becomes connected"
|
||||
|
||||
def test_the_two_pages_share_no_element_ids_but_the_shared_chrome(self):
|
||||
"""Both pages consume one stylesheet, so a duplicated id means a colliding rule.
|
||||
|
||||
|
||||
@@ -6,7 +6,13 @@ import pytest
|
||||
|
||||
from mirror import __main__ as entry
|
||||
from mirror.access import ConfigError, parse_cidrs
|
||||
from mirror.config import DEFAULT_PORT, Settings, lan_addresses, parse_ice_servers
|
||||
from mirror.config import (
|
||||
DEFAULT_MAX_BITRATE_BPS,
|
||||
DEFAULT_PORT,
|
||||
Settings,
|
||||
lan_addresses,
|
||||
parse_ice_servers,
|
||||
)
|
||||
from mirror.debug import maybe_enable_debugpy
|
||||
|
||||
|
||||
@@ -39,6 +45,7 @@ class TestSettings:
|
||||
assert settings.port == DEFAULT_PORT
|
||||
assert settings.host == "0.0.0.0"
|
||||
assert settings.ice_servers == []
|
||||
assert settings.max_bitrate_bps == DEFAULT_MAX_BITRATE_BPS
|
||||
assert settings.max_streams == 0
|
||||
# Unrestricted until told otherwise, and only loopback may speak for a client.
|
||||
assert settings.allow_cidrs == ()
|
||||
|
||||
Reference in New Issue
Block a user