**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`.
167 lines
6.9 KiB
Python
167 lines
6.9 KiB
Python
"""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}"
|