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:
@@ -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}"
|
||||
Reference in New Issue
Block a user