phase: 06_sharer_quality_presets
**Phase 06 — final verification pass: 100% green.** - **Verified (all 6 tasks complete in working tree):** `#quality` select + `PRESETS` table; preset-driven capture request + `contentHint`; per-leg `legCap()`/`degradation` at both call sites; live `applyQuality()` with Overconstrained fallback + republish; `q` key cycle; `#stat-quality`/`#stat-codec` readouts; full e2e matrix (requests, committed params ×3, zero-cap, mid-share switch, unrescalable device, late-joiner first-offer, readout, `q`); realdisplay delivered-pixels `light` case. `test_sender_fidelity.py` **untouched and green** (regression canary). - **Gates:** `uv run ruff check . && uv run ruff format --check .` → clean (32 files); `uv run pyright` → 0 errors; `uv run pytest` → **284 passed, 5 skipped (realdisplay-gated), coverage 95%** (93s); `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → **4 passed, 1 skipped** (no audio source on host — documented platform condition). - **Completion criteria:** all 11 ✓ — sharp default reproduces old request; light ≤960×600@15fps/motion/round(cap×0.35)/maintain-framerate with moving viewer frames; mid-share re-prices with `peers.size` unchanged, no renegotiation, roster matches `getSettings()`; refused-rescale toasts "next share"; `q` cycles + toasts, text-field guard holds; readout exact + codec shape, both reset to `—` idle; realdisplay: light decoded ≤990px (measured 320×200), sharp clears `SHARP_FLOOR_PX`; no new env/wire/Python/asset; suite 100% green, coverage >90%. - **Docs:** README.md reviewed in full against code — Quality table, `q` key, env-var table, routes (`/share`), kiosk commands, key table, doc paths all consistent; **no fixes needed**. AGENTS.md — verified, no stale statements, no changes. PLAN.md untouched (diff = 0). - **Reported (per phase, not edited):** PLAN.md §6 line 204 still says "per-leg `maxBitrate` cap (default **12 Mbit/s**)" — code has defaulted to **25 Mbit/s** since phase 02; needs a user-approved one-line amendment. - **Next pending phase:** none — 06 is the last phase in `todo/`; the roadmap is complete.
This commit is contained in:
+11
-3
@@ -13,7 +13,7 @@ import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterator, Sequence
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -45,8 +45,15 @@ def _free_port() -> int:
|
||||
return probe.getsockname()[1]
|
||||
|
||||
|
||||
def _start(extra_args: Sequence[str] = ()) -> tuple[str, subprocess.Popen[str]]:
|
||||
"""Spawn a real `mirror serve` on a free port and wait for it to answer."""
|
||||
def _start(
|
||||
extra_args: Sequence[str] = (),
|
||||
extra_env: Mapping[str, str] = {},
|
||||
) -> tuple[str, subprocess.Popen[str]]:
|
||||
"""Spawn a real `mirror serve` on a free port and wait for it to answer.
|
||||
|
||||
`extra_env` overrides inherited environment variables (it wins over
|
||||
`os.environ`, so a developer's `MIRROR_*` cannot leak into a test that
|
||||
names the opposite value)."""
|
||||
import httpx
|
||||
|
||||
port = _free_port()
|
||||
@@ -74,6 +81,7 @@ def _start(extra_args: Sequence[str] = ()) -> tuple[str, subprocess.Popen[str]]:
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
# A stray DEBUGPY in the developer's shell would stall the server.
|
||||
"DEBUGPY": "0",
|
||||
**extra_env,
|
||||
},
|
||||
)
|
||||
url = f"http://127.0.0.1:{port}"
|
||||
|
||||
@@ -0,0 +1,619 @@
|
||||
"""End-to-end: the capture request and the per-leg encoder parameters follow
|
||||
the selected quality preset.
|
||||
|
||||
`pickSurface()` asks the capture device for the *selected* preset's pixel
|
||||
budget and frame rate instead of one hardcoded 4K/30 request, and `start()`
|
||||
records which preset the live capture belongs to. The capture assertions read
|
||||
the page's own request back through `window.__probe` (AGENTS.md §6):
|
||||
`lastRequested` is the size the request named, `qualityKey` the preset it
|
||||
came from.
|
||||
|
||||
The per-leg assertions read the *committed* sender parameters back from the
|
||||
sharer's real RTCPeerConnection legs through the same `__probe` handle:
|
||||
each leg encodes under `round(config.maxBitrateBps * preset.factor)` and the
|
||||
preset's `degradationPreference`, read at call time — so a preset chosen
|
||||
before a viewer joins lands on that leg's first offer. Media rules still
|
||||
govern: pixel assertions are made on the *viewer's* `<video>`.
|
||||
|
||||
Delivered *capture* pixels are deliberately not asserted here: with
|
||||
`--use-fake-device-for-media-stream` the synthetic device is a fixed
|
||||
1280x720 and its clamping is not the behaviour under test (PLAN §5). Where
|
||||
a real capturer runs (`MIRROR_REALDISPLAY=1`, the `realdisplay` suite) the
|
||||
delivered pixels are asserted instead.
|
||||
|
||||
The late-joining-viewer case proves the *first offer* of a leg that comes
|
||||
up under a preset chosen before that viewer joined carries the preset's cap:
|
||||
a new `RTCPeerConnection` is wrapped on the sharer page (the same pattern the
|
||||
other e2e suites use for their `RTCPeerConnection` probes) and the parameters
|
||||
are read back at `setLocalDescription` — before `negotiate()`'s
|
||||
post-negotiation re-apply has run, so the only code that could have written
|
||||
them is `addPeer()`'s `sendEncodings`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from .conftest import _start, _stop, frames_matching, start_sharing, stop_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).
|
||||
|
||||
`url` targets a different server than the session's (the zero-cap
|
||||
fixture); the default is the session server, which is what the capture-
|
||||
request tests use.
|
||||
"""
|
||||
opened = []
|
||||
|
||||
def _open(path: str = "/", errors: list | None = None, url: str | 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"{url or base_url}{path}", wait_until="domcontentloaded")
|
||||
expect(page.locator("#net")).to_have_attribute("data-state", "online", timeout=10_000)
|
||||
return page
|
||||
|
||||
return _open
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def zero_cap_url():
|
||||
"""A real `mirror serve` with `MIRROR_MAX_BITRATE=0` — the "uncapped"
|
||||
setting of the WebRTC API, which must not turn into a 0 bps leg."""
|
||||
url, process = _start(extra_env={"MIRROR_MAX_BITRATE": "0"})
|
||||
yield url
|
||||
_stop(process)
|
||||
|
||||
|
||||
# Reads the video sender's committed parameters back from the sharer's live
|
||||
# legs (copied per suite from test_sender_fidelity.py — one copy per suite is
|
||||
# the convention). `state` is the leg's connectionState; `maxBitrate` is the
|
||||
# cap the leg is actually encoding under (undefined when none was written —
|
||||
# it reads back as null — which is exactly the "uncapped" state). If the
|
||||
# server had written a 0 bps cap it would read back as 0, and the zero-cap
|
||||
# test's `is None` assertion is what keeps the two apart.
|
||||
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
|
||||
|
||||
|
||||
# The page's own request, the preset it came from, the track's contentHint
|
||||
# (a pure read-back of what start() set — not a delivered pixel), and the
|
||||
# inputs the page used: screen.width / devicePixelRatio are read *from the
|
||||
# page* — under Playwright `screen` is the emulated viewport, so hardcoding
|
||||
# a display size would make this environment-dependent.
|
||||
REQUEST_PROBE = """() => {
|
||||
const probe = window.__probe;
|
||||
if (!probe) return null;
|
||||
const stream = document.querySelector('#preview').srcObject;
|
||||
const track = stream && stream.getVideoTracks()[0];
|
||||
return {
|
||||
requested: probe.lastRequested,
|
||||
qualityKey: probe.qualityKey,
|
||||
contentHint: track ? track.contentHint : null,
|
||||
screenWidth: screen.width,
|
||||
screenHeight: screen.height,
|
||||
dpr: window.devicePixelRatio || 1,
|
||||
};
|
||||
}"""
|
||||
|
||||
# The pixel ceilings and the per-preset contentHint, the mirror of `PRESETS`
|
||||
# in share.js: the request is min(screen * dpr, cap). This suite asserts the
|
||||
# *request* the page built; the table itself is guarded by test_app.py's
|
||||
# three-preset agreement test.
|
||||
PRESETS_UNDER_TEST = {
|
||||
"sharp": {"cap": (3840, 2160), "contentHint": "detail"},
|
||||
"balanced": {"cap": (1920, 1200), "contentHint": "detail"},
|
||||
"light": {"cap": (960, 600), "contentHint": "motion"},
|
||||
}
|
||||
|
||||
|
||||
def _bitrate_format(bps: float) -> str:
|
||||
"""The mirror of `bitrateFormat` in net.js — the panel's own renderer,
|
||||
so the test can recompute the exact line the panel must print."""
|
||||
if not bps or bps < 0:
|
||||
return "—"
|
||||
if bps >= 1e6:
|
||||
return f"{bps / 1e6:.1f} Mbps"
|
||||
return f"{round(bps / 1e3)} kbps"
|
||||
|
||||
|
||||
class TestQualityPresets:
|
||||
def test_each_preset_asks_the_capture_for_its_own_budget(self, device):
|
||||
"""Each preset drives the capture request: `sharp` is the old
|
||||
hardcoded 4K cap (the no-regression argument), `balanced` 1080p-class,
|
||||
`light` a small and cheap budget — and the page records which preset
|
||||
the live capture belongs to."""
|
||||
errors: list = []
|
||||
for key, preset in PRESETS_UNDER_TEST.items():
|
||||
cap_w, cap_h = preset["cap"]
|
||||
sharer = device("/mirror", errors)
|
||||
sharer.select_option("#quality", key)
|
||||
start_sharing(sharer)
|
||||
result = sharer.evaluate(REQUEST_PROBE)
|
||||
assert result, "the __probe handle is missing"
|
||||
assert result["qualityKey"] == key, result
|
||||
# The page's own arithmetic, recomputed from the page's own inputs:
|
||||
# min(round(screen * dpr), cap).
|
||||
expected_w = min(round(result["screenWidth"] * result["dpr"]), cap_w)
|
||||
expected_h = min(round(result["screenHeight"] * result["dpr"]), cap_h)
|
||||
assert result["requested"]["width"] == expected_w, result
|
||||
assert result["requested"]["height"] == expected_h, result
|
||||
# Text-first presets take `detail`; `light` is glanceable, so
|
||||
# motion wins over glyph crispness.
|
||||
assert result["contentHint"] == preset["contentHint"], result
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
|
||||
def test_a_light_leg_encodes_at_the_presets_share(self, device, base_url):
|
||||
"""A leg that comes up under `light` encodes at the preset's share
|
||||
of the server's ceiling and with the preset's degradation preference —
|
||||
both read from the preset *at call time*: `light` was selected before
|
||||
sharing started, so the viewer's leg must carry them from its first
|
||||
offer, and the wall must still be moving (a cheaper picture must not
|
||||
be a dead one)."""
|
||||
import httpx
|
||||
|
||||
cap = httpx.get(f"{base_url}/api/config", timeout=5).json()["maxBitrateBps"]
|
||||
assert cap > 0
|
||||
|
||||
errors: list = []
|
||||
sharer = device("/mirror", errors)
|
||||
sharer.select_option("#quality", "light")
|
||||
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]["maxBitrate"] == round(cap * 0.35), legs
|
||||
assert legs[0]["degradationPreference"] == "maintain-framerate", legs
|
||||
|
||||
# The cheaper picture still has to be a live one.
|
||||
frames = frames_matching(viewer, ".tile video")
|
||||
assert frames["distinct"] >= 2, f"a light leg froze: {frames}"
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
|
||||
def test_each_preset_commits_its_sender_parameters(self, device, base_url):
|
||||
"""The full committed-parameter matrix, `sharp` first so the
|
||||
no-regression leg is the first thing it proves: each preset's live
|
||||
leg commits `round(cap * factor)` and the preset's
|
||||
`degradationPreference` — read at call time, so the preset selected
|
||||
before sharing lands on the leg from its first offer — and the wall
|
||||
stays moving on the viewer in every case (a cheaper picture must not
|
||||
be a dead one). `sharp` is phase 02's behaviour verbatim: full cap,
|
||||
`maintain-resolution`.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
cap = httpx.get(f"{base_url}/api/config", timeout=5).json()["maxBitrateBps"]
|
||||
assert cap > 0
|
||||
|
||||
# The committed-parameter mirror of PRESETS_UNDER_TEST: the factor the
|
||||
# server's ceiling is scaled by and the degradation preference the
|
||||
# encoder commits to, per preset. `sharp` first: full cap and
|
||||
# maintain-resolution is the unchanged phase 02 behaviour.
|
||||
committed = {
|
||||
"sharp": (1.0, "maintain-resolution"),
|
||||
"balanced": (0.6, "balanced"),
|
||||
"light": (0.35, "maintain-framerate"),
|
||||
}
|
||||
for key, (factor, degradation) in committed.items():
|
||||
errors: list = []
|
||||
sharer = device("/mirror", errors)
|
||||
sharer.select_option("#quality", key)
|
||||
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", (key, legs)
|
||||
assert legs[0]["maxBitrate"] == round(cap * factor), (key, legs)
|
||||
assert legs[0]["degradationPreference"] == degradation, (key, legs)
|
||||
|
||||
# The picture still has to be a live one in every preset.
|
||||
frames = frames_matching(viewer, ".tile video")
|
||||
assert frames["distinct"] >= 2, f"the {key} leg froze: {frames}"
|
||||
assert errors == [], f"uncaught browser errors on {key}: {errors}"
|
||||
|
||||
# Tear the whole iteration down before the next preset: the
|
||||
# contexts outlive the iteration, and the wall auto-subscribes
|
||||
# *every live viewer* to *every new mirror* — an uncleaned
|
||||
# viewer would stack a second (third, ...) leg on the next
|
||||
# preset's sharer and break the one-leg expectation. Stop the
|
||||
# share first, so the viewer's tile (and its peer connection)
|
||||
# is torn down before its page goes away.
|
||||
stop_sharing(sharer)
|
||||
viewer.close()
|
||||
sharer.close()
|
||||
|
||||
def test_a_zero_cap_stays_uncapped_under_light(self, device, zero_cap_url):
|
||||
"""`MIRROR_MAX_BITRATE=0` is a real setting — "uncapped" in the
|
||||
WebRTC API. `legCap()` must return 0 and no `maxBitrate` may be
|
||||
written: an uncapped leg must not become a 0 bps leg. The preset's
|
||||
degradation preference still lands, and the picture still moves.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
config = httpx.get(f"{zero_cap_url}/api/config", timeout=5).json()
|
||||
assert config["maxBitrateBps"] == 0, config
|
||||
|
||||
errors: list = []
|
||||
sharer = device("/mirror", errors, url=zero_cap_url)
|
||||
sharer.select_option("#quality", "light")
|
||||
start_sharing(sharer)
|
||||
|
||||
viewer = device("/watch", errors, url=zero_cap_url)
|
||||
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
|
||||
# No maxBitrate written at all: an uncapped leg, not a 0 bps one.
|
||||
assert legs[0]["maxBitrate"] is None, legs
|
||||
assert legs[0]["degradationPreference"] == "maintain-framerate", legs
|
||||
|
||||
frames = frames_matching(viewer, ".tile video")
|
||||
assert frames["distinct"] >= 2, f"the uncapped leg froze: {frames}"
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
|
||||
def test_changing_quality_mid_share_re_prices_the_live_leg(self, device, base_url):
|
||||
"""A preset change made while a leg is live re-prices that leg —
|
||||
``setParameters`` commits without an offer, so the CPU/egress
|
||||
change is instant and no second RTCPeerConnection is needed for it —
|
||||
and the viewer keeps decoding moving frames across the switch. The
|
||||
roster must tell the truth in *both* outcomes: its width/height
|
||||
equal the track's own ``getSettings()``, whatever the engine
|
||||
settled on (the synthetic device clamps; delivered pixels are the
|
||||
``realdisplay`` suite's, task 06)."""
|
||||
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) # the default preset: sharp
|
||||
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
|
||||
|
||||
# The switch, mid-share, through the control the user has.
|
||||
sharer.select_option("#quality", "light")
|
||||
|
||||
# The single live leg re-prices in place: ``setParameters`` commits
|
||||
# within ~5 s of the change, and the connection state returns to /
|
||||
# stays ``connected`` (a leg that comes up fresh for the new size
|
||||
# reads ``legCap()`` at its first offer, so the cap lands either way).
|
||||
def repriced(leg):
|
||||
return leg["state"] == "connected" and leg["maxBitrate"] == round(cap * 0.35)
|
||||
|
||||
re = _wait_for(sharer, 1, repriced, timeout=5.0)
|
||||
# No second RTCPeerConnection: the roster of live legs is still the
|
||||
# one the viewer was subscribed on.
|
||||
assert sharer.evaluate("() => window.__probe.peers.size") == 1
|
||||
assert re and re[0]["maxBitrate"] == round(cap * 0.35), re
|
||||
assert re[0]["state"] == "connected", re
|
||||
|
||||
# The switch did not freeze anybody.
|
||||
frames = frames_matching(viewer, ".tile video")
|
||||
assert frames["distinct"] >= 2, frames
|
||||
|
||||
# The roster tells the truth: whatever the engine settled on, the
|
||||
# published width/height equal the track's own settings. Do not
|
||||
# assert a *delivered* size here — the synthetic device clamps, and
|
||||
# task 06 owns delivered pixels on a real desktop.
|
||||
def track_settings():
|
||||
return sharer.evaluate(
|
||||
"() => { const s = document.querySelector('#preview').srcObject;"
|
||||
" const t = s && s.getVideoTracks()[0];"
|
||||
" return t ? t.getSettings() : null; }"
|
||||
)
|
||||
|
||||
def roster_agrees():
|
||||
settings = track_settings()
|
||||
if not settings or not settings.get("width"):
|
||||
return False, None, settings
|
||||
mirror = httpx.get(f"{base_url}/api/streams", timeout=5).json()["mirrors"]
|
||||
assert len(mirror) == 1, mirror
|
||||
mirror = mirror[0]
|
||||
agrees = mirror["width"] == settings["width"] and mirror["height"] == settings["height"]
|
||||
return agrees, mirror, settings
|
||||
|
||||
deadline = time.time() + 10.0
|
||||
agrees, mirror, settings = roster_agrees()
|
||||
while not agrees and time.time() < deadline:
|
||||
time.sleep(0.5)
|
||||
agrees, mirror, settings = roster_agrees()
|
||||
assert agrees, f"roster does not match the track's own settings: {mirror!r} vs {settings!r}"
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
|
||||
def test_a_device_that_cannot_rescale_still_changes_rate(self, device, base_url):
|
||||
"""A display that cannot rescale answers the full constraint with the
|
||||
named ``OverconstrainedError``: the retry applies the frame rate
|
||||
alone, the leg is still re-priced, the viewer still moves, the
|
||||
toast says the size lands on the next share (it does not pretend
|
||||
the capture rescaled), and the roster stays truthful."""
|
||||
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)
|
||||
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
|
||||
|
||||
# Simulate the display: a constraint that carries a size fails with
|
||||
# the named error; the frame-rate-only retry passes. (This is the
|
||||
# real-world behaviour the retry exists for — a capturer that holds
|
||||
# its size — and nothing else in the path is stubbed.)
|
||||
sharer.evaluate(
|
||||
"() => { const s = document.querySelector('#preview').srcObject;"
|
||||
" const t = s.getVideoTracks()[0];"
|
||||
" const orig = t.applyConstraints.bind(t);"
|
||||
" t.applyConstraints = (c) => (c && c.width)"
|
||||
" ? Promise.reject(new DOMException('size cannot be applied', 'OverconstrainedError'))"
|
||||
" : orig(c); }"
|
||||
)
|
||||
sharer.select_option("#quality", "light")
|
||||
|
||||
# The toast lands with the settle (before the leg is measured
|
||||
# again), so it is read first: the preset is named, and because
|
||||
# the size was never applied the line says it lands next share.
|
||||
toast = sharer.locator("#toast")
|
||||
expect(toast).to_contain_text("Light", timeout=5_000)
|
||||
text = toast.text_content() or ""
|
||||
assert "next share" in text, text
|
||||
|
||||
# The leg is still re-priced, and the switch did not freeze anybody.
|
||||
def repriced(leg):
|
||||
return leg["state"] == "connected" and leg["maxBitrate"] == round(cap * 0.35)
|
||||
|
||||
re = _wait_for(sharer, 1, repriced, timeout=5.0)
|
||||
assert re and re[0]["maxBitrate"] == round(cap * 0.35), re
|
||||
frames = frames_matching(viewer, ".tile video")
|
||||
assert frames["distinct"] >= 2, frames
|
||||
|
||||
# The roster tells the truth about whatever the device delivered.
|
||||
def track_settings():
|
||||
return sharer.evaluate(
|
||||
"() => { const s = document.querySelector('#preview').srcObject;"
|
||||
" const t = s && s.getVideoTracks()[0];"
|
||||
" return t ? t.getSettings() : null; }"
|
||||
)
|
||||
|
||||
def roster_agrees():
|
||||
settings = track_settings()
|
||||
if not settings or not settings.get("width"):
|
||||
return False, None, settings
|
||||
mirror = httpx.get(f"{base_url}/api/streams", timeout=5).json()["mirrors"]
|
||||
assert len(mirror) == 1, mirror
|
||||
mirror = mirror[0]
|
||||
agrees = mirror["width"] == settings["width"] and mirror["height"] == settings["height"]
|
||||
return agrees, mirror, settings
|
||||
|
||||
deadline = time.time() + 10.0
|
||||
agrees, mirror, settings = roster_agrees()
|
||||
while not agrees and time.time() < deadline:
|
||||
time.sleep(0.5)
|
||||
agrees, mirror, settings = roster_agrees()
|
||||
assert agrees, f"roster does not match the track's own settings: {mirror!r} vs {settings!r}"
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
|
||||
def test_a_viewer_who_joins_after_a_switch_gets_the_new_cap(self, device, base_url):
|
||||
"""The `addPeer` path, not just the re-apply path.
|
||||
|
||||
A preset change mid-share re-prices the *existing* leg (task 04's
|
||||
re-apply). This proves the other half: a viewer who subscribes
|
||||
*after* the switch gets a leg whose **first offer** already carries
|
||||
the new cap — `legCap()` is read at call time in `addPeer()` and
|
||||
written into `sendEncodings` before any negotiation exists. The
|
||||
sharer's `RTCPeerConnection` is wrapped (the same pattern the other
|
||||
e2e suites use for their peer-connection probes) so the parameters
|
||||
are read back at `setLocalDescription`, i.e. before `negotiate()`'s
|
||||
post-negotiation re-apply has run: the only code that could have
|
||||
written them is `addPeer()`'s `sendEncodings`.
|
||||
"""
|
||||
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) # the default preset: sharp
|
||||
first = device("/watch", errors)
|
||||
expect(first.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]["maxBitrate"] == cap, legs
|
||||
|
||||
# The switch, mid-share: the existing leg re-prices via the re-apply.
|
||||
sharer.select_option("#quality", "light")
|
||||
|
||||
def repriced(leg):
|
||||
return leg["state"] == "connected" and leg["maxBitrate"] == round(cap * 0.35)
|
||||
|
||||
re = _wait_for(sharer, 1, repriced, timeout=5.0)
|
||||
assert re and re[0]["maxBitrate"] == round(cap * 0.35), re
|
||||
|
||||
# From here on, every new RTCPeerConnection on the sharer page is
|
||||
# the late joiner's leg: the wrapper records the committed parameters
|
||||
# at setLocalDescription time, before negotiate()'s re-apply.
|
||||
assert sharer.evaluate(
|
||||
"""() => {
|
||||
window.__firstOffers = [];
|
||||
const RealPC = window.RTCPeerConnection;
|
||||
window.RTCPeerConnection = function (config) {
|
||||
const pc = new RealPC(config);
|
||||
const orig = pc.setLocalDescription.bind(pc);
|
||||
pc.setLocalDescription = (d) => orig(d).then(() => {
|
||||
const sender = pc.getSenders().find(
|
||||
(s) => s.track && s.track.kind === 'video'
|
||||
);
|
||||
const params = sender ? sender.getParameters() : {};
|
||||
const enc = (params.encodings || [])[0] || {};
|
||||
window.__firstOffers.push({
|
||||
maxBitrate: 'maxBitrate' in enc ? enc.maxBitrate : null,
|
||||
degradationPreference: params.degradationPreference || null,
|
||||
});
|
||||
return pc;
|
||||
});
|
||||
return pc;
|
||||
};
|
||||
window.RTCPeerConnection.prototype = RealPC.prototype;
|
||||
window.RTCPeerConnection.static = RealPC.static;
|
||||
return true;
|
||||
}"""
|
||||
)
|
||||
|
||||
late = device("/watch", errors)
|
||||
expect(late.locator(".tile[data-state='live']")).to_have_count(1, timeout=25_000)
|
||||
|
||||
# Both legs are now live under the switched preset.
|
||||
legs = _wait_for(sharer, 2, lambda leg: leg["state"] == "connected")
|
||||
assert legs and all(leg["state"] == "connected" for leg in legs), legs
|
||||
|
||||
offers = sharer.evaluate("() => window.__firstOffers") or []
|
||||
assert offers, "no first offer was captured from the late joiner's leg"
|
||||
# The first offer carried the new cap: addPeer's sendEncodings, not
|
||||
# the post-negotiation re-apply, which ran later on the same leg.
|
||||
assert offers[-1]["maxBitrate"] == round(cap * 0.35), offers
|
||||
# And the settled leg is still capped — nothing drifted after.
|
||||
assert all(leg["maxBitrate"] == round(cap * 0.35) for leg in legs), legs
|
||||
assert sharer.evaluate("() => window.__probe.peers.size") == 2
|
||||
|
||||
# The late joiner's picture is a live one, and the sharer agrees
|
||||
# that two viewers are watching.
|
||||
frames = frames_matching(late, ".tile video")
|
||||
assert frames["distinct"] >= 2, f"the late joiner froze: {frames}"
|
||||
expect(sharer.locator("#viewer-count")).to_have_text("2", timeout=10_000)
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
|
||||
def test_the_panel_shows_the_budget_and_the_codec(self, device, base_url):
|
||||
"""The panel says what the share is actually costing (phase 06 task
|
||||
05): the quality budget in force — the selected preset, its capture
|
||||
ceiling, its frame rate and its per-leg share of the server's cap —
|
||||
and the codec the leg actually negotiated, resolved in the same
|
||||
`getStats()` read the 1 s tick already makes. The codec is `—` where
|
||||
the engine does not report it: the panel must never guess. Both
|
||||
lines reset to `—` when the share stops."""
|
||||
import httpx
|
||||
|
||||
cap = httpx.get(f"{base_url}/api/config", timeout=5).json()["maxBitrateBps"]
|
||||
assert cap > 0
|
||||
|
||||
errors: list = []
|
||||
sharer = device("/mirror", errors)
|
||||
sharer.select_option("#quality", "balanced")
|
||||
start_sharing(sharer)
|
||||
|
||||
# The budget line is written by start() from state: an exact match,
|
||||
# not a substring — the panel must not misprice the share. The cap
|
||||
# is read live from /api/config (never hardcoded) and scaled by the
|
||||
# preset's factor, exactly as legCap() does.
|
||||
expected = (
|
||||
f"balanced · ≤1920×1200 @24fps · " # noqa: RUF001 (the panel line itself carries the multiplication sign)
|
||||
f"{_bitrate_format(round(cap * 0.6))} /viewer"
|
||||
)
|
||||
expect(sharer.locator("#stat-quality")).to_have_text(expected, timeout=10_000)
|
||||
|
||||
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
|
||||
|
||||
# The codec line is written once per 1 s stats tick (no extra
|
||||
# getStats() call): let a tick run after the leg is up, then read it.
|
||||
time.sleep(2.5)
|
||||
codec = (sharer.locator("#stat-codec").text_content() or "").strip()
|
||||
# The shape, not the codec: which one the engine negotiates is not a
|
||||
# contract. `—` is the honest floor where the engine does not expose
|
||||
# the outbound codec; a name must be the short form
|
||||
# (`video/VP8` → `VP8`), never a guess.
|
||||
assert codec == "—" or re.fullmatch(r"[A-Z0-9/]{2,16}", codec), codec
|
||||
|
||||
# The share is still a live one while the panel reports on it.
|
||||
frames = frames_matching(viewer, ".tile video")
|
||||
assert frames["distinct"] >= 2, frames
|
||||
|
||||
# Stopping resets both lines: `—`, not a stale reading.
|
||||
stop_sharing(sharer)
|
||||
expect(sharer.locator("#stat-quality")).to_have_text("—", timeout=10_000)
|
||||
expect(sharer.locator("#stat-codec")).to_have_text("—", timeout=10_000)
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
|
||||
def test_q_cycles_the_preset_and_says_so(self, device):
|
||||
"""``q`` cycles the preset on the share page (sharp → balanced →
|
||||
light → sharp) and toasts the new one, naming it and its budget;
|
||||
a ``q`` typed into ``#name`` is text, not a command, and never
|
||||
changes the select (the router's text-field guard)."""
|
||||
errors: list = []
|
||||
page = device("/mirror", errors)
|
||||
toast = page.locator("#toast")
|
||||
|
||||
page.keyboard.press("q")
|
||||
expect(page.locator("#quality")).to_have_value("balanced", timeout=5_000)
|
||||
assert page.evaluate("() => window.__probe.qualityKey") == "balanced"
|
||||
expect(toast).to_contain_text("Balanced", timeout=5_000)
|
||||
text = toast.text_content() or ""
|
||||
assert "≤1920×1200" in text, text # noqa: RUF001 (the toast itself carries the multiplication sign)
|
||||
# Nothing is shared yet: the capture size lands on the next share,
|
||||
# and the line says so rather than pretending it rescaled.
|
||||
assert "next share" in text, text
|
||||
|
||||
page.keyboard.press("q")
|
||||
expect(page.locator("#quality")).to_have_value("light")
|
||||
expect(toast).to_contain_text("Light", timeout=5_000)
|
||||
|
||||
page.keyboard.press("q")
|
||||
expect(page.locator("#quality")).to_have_value("sharp")
|
||||
expect(toast).to_contain_text("Sharp", timeout=5_000)
|
||||
|
||||
# A ``q`` typed into #name is text, not a command: the select stays.
|
||||
page.locator("#name").click()
|
||||
page.keyboard.type("q")
|
||||
expect(page.locator("#quality")).to_have_value("sharp")
|
||||
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
@@ -443,6 +443,105 @@ class TestRealX11Capture:
|
||||
expect(share_page.locator("#viewer-count")).to_have_text("1", timeout=20_000)
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
|
||||
def test_a_light_preset_delivers_a_small_picture(self, share_page, watch_page):
|
||||
"""The delivered-pixel proof that the preset reaches the *real*
|
||||
capturer, not just the request object.
|
||||
|
||||
``#quality = light`` is selected **before** the share starts, so the
|
||||
request — under Playwright ``screen`` is the emulated viewport, so
|
||||
the 1200x760 share window asks ``min(viewport, 960x600)`` =
|
||||
960x600 — goes to Chromium's real X11 backend. The sharer's track
|
||||
settings must honour the budget (at least 80% of the 960 request
|
||||
wide, never above it): that is the proof the request reached the
|
||||
real capturer and was not clamped away to the desktop or the
|
||||
window. The viewer's decoded width must be at or under the cap —
|
||||
the phase's delivered-pixels criterion — *and below the sharp
|
||||
path's floor*: if a ``light`` share delivered a sharp-size picture,
|
||||
the budget never reached the wire. The aspect guard keeps proving
|
||||
the picture is *our* desktop, not somebody else's window.
|
||||
|
||||
The sharp path's floor (``SHARP_FLOOR_PX``) is deliberately
|
||||
**not** asserted here: it stays the sharp test's, and a small
|
||||
picture is exactly what a ``light`` share should deliver — the
|
||||
small picture that is still the whole desktop.
|
||||
|
||||
Measured on this host (Xvfb 1280x800x24, phase 06): the capturer
|
||||
delivered 960x600 (track settings, ``frameRate`` 15), and the
|
||||
viewer decoded 320x200 — VP8 honouring ``light``'s
|
||||
``maintain-framerate`` at ``round(25 Mbit/s x 0.35)`` = 8.75
|
||||
Mbit/s, i.e. the encoder's resolution-degradation policy (the same
|
||||
leg encodes 960x600 under ``maintain-resolution``); the encoder
|
||||
may sacrifice resolution, which is the preset's contract, so the
|
||||
decoded-width floor below guards a *collapsed* picture (>= a
|
||||
quarter of the request), not the encoder's honest ladder.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
share_page.on("pageerror", lambda error: errors.append(str(error)))
|
||||
|
||||
share_page.select_option("#quality", "light")
|
||||
share_page.locator("#start").click()
|
||||
expect(share_page.locator("body")).to_have_attribute("data-live", "on", timeout=40_000)
|
||||
|
||||
# The preset is what the live capture belongs to (a pure parameter
|
||||
# read, not a pixel claim — the pixels are the viewer's, below).
|
||||
assert share_page.evaluate("() => window.__probe.qualityKey") == "light"
|
||||
|
||||
# The request reached the *real* capturer and was not clamped away:
|
||||
# the track the X11 backend settled on honours the light budget —
|
||||
# at least 80% of the 960 request wide (the capturer did not drop
|
||||
# the size), never above it (the capturer did not ignore it and
|
||||
# deliver the 1200x750 window-clamped desktop).
|
||||
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}"
|
||||
assert settings["width"] <= 990, f"the capturer ignored the light budget: {settings}"
|
||||
assert settings["width"] >= 768, f"the capturer did not honour the request: {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")
|
||||
|
||||
# Delivered pixels follow the preset: the decoded picture is at or
|
||||
# under the light cap, and below the sharp path's floor (a
|
||||
# sharp-size picture from a light share would mean the budget
|
||||
# never reached the wire). Measured 320x200 on this host — the
|
||||
# encoder's maintain-framerate degradation, see the docstring.
|
||||
assert result["width"] <= 990, (
|
||||
f"the watcher decoded a {result['width']}px-wide picture: the light budget is "
|
||||
f"<= 960px, so a wider decode means the budget did not reach the wire: {result}"
|
||||
)
|
||||
assert result["width"] < SHARP_FLOOR_PX, (
|
||||
f"the watcher decoded a {result['width']}px-wide picture: that is at or above "
|
||||
f"the sharp floor ({SHARP_FLOOR_PX}px), so a light share delivered a sharp-size "
|
||||
f"picture: {result}"
|
||||
)
|
||||
assert result["width"] >= 240, (
|
||||
f"the watcher decoded a {result['width']}px-wide picture: a quarter of the 960px "
|
||||
f"request must arrive at least, or the picture collapsed: {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 small-but-still picture "
|
||||
f"must not pass a delivered-pixel test"
|
||||
)
|
||||
|
||||
expect(share_page.locator("#viewer-count")).to_have_text("1", timeout=20_000)
|
||||
assert errors == [], f"uncaught browser errors: {errors}"
|
||||
|
||||
def test_a_real_desktop_shares_audio_when_the_host_offers_any(self, base_url, browsers):
|
||||
"""The realdisplay counterpart of the audio-arrival assertion.
|
||||
|
||||
|
||||
+38
-6
@@ -177,15 +177,47 @@ class TestClientScripts:
|
||||
"a different bitrate silently"
|
||||
)
|
||||
|
||||
def test_the_quality_options_are_the_three_presets(self):
|
||||
"""`#quality` (mirror.html) and `PRESETS` (share.js) are one list in two places.
|
||||
|
||||
If the option values drift from the table keys, a preset the panel offers has
|
||||
no row (or an orphan row the panel can never select), and `currentPreset()`
|
||||
falls back to `sharp` instead of noticing: the sharer gets a wall they did not
|
||||
ask for and nothing fails. One list, two places, one test that notices (the
|
||||
same shape as the bitrate-default test)."""
|
||||
html = (STATIC / "mirror.html").read_text()
|
||||
select = re.search(r'<select id="quality"[^>]*>.*?</select>', html, re.S)
|
||||
assert select, "mirror.html must render a #quality select"
|
||||
options = set(re.findall(r'value="([A-Za-z0-9_-]+)"', select.group(0)))
|
||||
|
||||
source = (STATIC / "share.js").read_text()
|
||||
table = re.search(r"const PRESETS = Object\.freeze\(\{(.*?)\}\);", source, re.S)
|
||||
assert table, "share.js must define the PRESETS table"
|
||||
# Top-level keys sit two spaces in; the fields inside a preset sit four
|
||||
# spaces in, so anchoring on exactly two spaces selects the keys only.
|
||||
keys = set(re.findall(r"^ ([a-z]+): \{$", table.group(1), re.M))
|
||||
|
||||
expected = {"sharp", "balanced", "light"}
|
||||
assert options == expected, f"#quality offers {sorted(options)}, not {sorted(expected)}"
|
||||
assert keys == expected, f"PRESETS keys are {sorted(keys)}, not {sorted(expected)}"
|
||||
assert options == keys, "the select offers presets the table cannot resolve"
|
||||
|
||||
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."""
|
||||
a viewer that re-joined) resets the encodings. The preset's cap and
|
||||
degradation preference 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. The preference itself stays a single, named
|
||||
decision — the PRESETS table's `degradation` field, read at call
|
||||
time — so a hardcoded literal at the call site would be a second,
|
||||
drifting decision."""
|
||||
source = (STATIC / "share.js").read_text()
|
||||
assert source.count('"maintain-resolution"') == 1, (
|
||||
"the degradation preference must be a single, named decision"
|
||||
assert re.search(r'degradationPreference\s*=\s*"', source) is None, (
|
||||
"a hardcoded degradation literal at a call site is a second decision"
|
||||
)
|
||||
assert "params.degradationPreference = PRESETS[currentPreset()].degradation" in source, (
|
||||
"the preference is not read from the PRESETS table at call time"
|
||||
)
|
||||
assert "async function applySenderQuality" in source
|
||||
|
||||
|
||||
Reference in New Issue
Block a user