test: four-rung suite that asserts on decoded pixels, not log lines
130 tests, ~20s, 94% coverage. Four rungs, each proving something the one below it cannot: registry admission, fan-out, signal authorization and teardown, no I/O wire camelCase round-trip, malformed frames, WebSocket lifecycle e2e real capture -> encode -> SRTP -> decode -> painted frames realdisplay the actual X11 capture backend on a private Xvfb display The e2e rung drives Chromium's fake media device, which produces a genuine moving 1280x720 monitor stream, so the assertion is that a watcher's <video> has a size and that its pixels change over time -- a frozen or never-started peer connection fails the suite, which a "signal arrived" assertion would not have caught. Findings encoded as flags and comments, because each cost a debugging cycle: the headless shell has no media stack at all (use channel="chromium"); mediaDevices is undefined off a secure context (serve over 127.0.0.1); and --use-fake-ui-for-media-stream breaks real X11 capture by approving the request without ever selecting a source, so the realdisplay suite selects the source explicitly instead. The realdisplay fixture strips WAYLAND_DISPLAY and DBUS_SESSION_BUS_ADDRESS from the browser environment. Without that, a headed Chromium reaches xdg-desktop-portal, mirrors the developer's actual desktop and raises a permission dialog on their real screen -- which is exactly what happened before this was isolated. Skipped unless MIRROR_REALDISPLAY=1. Also guards the frontend's structure: scripts must parse as ES modules, every id a script looks up must exist in its page, ids must not collide across pages that share one stylesheet, and nothing may load from a CDN. LLM-Generated: true
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
"""Shared fixtures for the non-browser test suite."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from mirror.app import create_app
|
||||||
|
from mirror.config import Settings
|
||||||
|
from mirror.registry import Registry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def settings() -> Settings:
|
||||||
|
return Settings(host="127.0.0.1", port=0, ice_servers=[], max_bitrate_bps=2_000_000)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def registry() -> Registry:
|
||||||
|
counter = iter(f"p{i}" for i in range(1, 10_000))
|
||||||
|
return Registry(id_factory=lambda: next(counter))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app) -> TestClient:
|
||||||
|
with TestClient(app) as test_client:
|
||||||
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app(settings) -> object:
|
||||||
|
return create_app(settings)
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""Fixtures for the browser end-to-end suite.
|
||||||
|
|
||||||
|
The capture flags matter: with a fake UI and a fake device, Chromium answers
|
||||||
|
`getDisplayMedia()` with a real, *moving* 1280x720 monitor stream. That means
|
||||||
|
these tests exercise the genuine capture -> encode -> SRTP -> decode -> paint
|
||||||
|
path rather than a stubbed-out media source.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from playwright.sync_api import expect, sync_playwright
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.e2e
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
CHROMIUM_ARGS = [
|
||||||
|
"--use-fake-ui-for-media-stream",
|
||||||
|
"--use-fake-device-for-media-stream",
|
||||||
|
"--use-fake-device-for-audio-capture",
|
||||||
|
"--autoplay-policy=no-user-gesture-required",
|
||||||
|
# Two Chromium instances must each get their own UDP ports; never proxy.
|
||||||
|
"--no-sandbox",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
"--no-proxy-server",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _free_port() -> int:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
||||||
|
probe.bind(("127.0.0.1", 0))
|
||||||
|
return probe.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def base_url() -> str:
|
||||||
|
"""A real `mirror serve` process on a real port — not a test double."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
port = _free_port()
|
||||||
|
process = subprocess.Popen(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"mirror",
|
||||||
|
"serve",
|
||||||
|
"--host",
|
||||||
|
"127.0.0.1",
|
||||||
|
"--port",
|
||||||
|
str(port),
|
||||||
|
"--log-level",
|
||||||
|
"warning",
|
||||||
|
],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
env={
|
||||||
|
**os.environ,
|
||||||
|
"MIRROR_ICE_SERVERS": "",
|
||||||
|
"PYTHONUNBUFFERED": "1",
|
||||||
|
# A stray DEBUGPY in the developer's shell would stall the server.
|
||||||
|
"DEBUGPY": "0",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
url = f"http://127.0.0.1:{port}"
|
||||||
|
deadline = time.time() + 25
|
||||||
|
last_error: Exception | None = None
|
||||||
|
while time.time() < deadline:
|
||||||
|
if process.poll() is not None:
|
||||||
|
output = process.stdout.read() if process.stdout else ""
|
||||||
|
raise RuntimeError(f"mirror serve exited early ({process.returncode}):\n{output}")
|
||||||
|
try:
|
||||||
|
if httpx.get(f"{url}/healthz", timeout=1.0).status_code == 200:
|
||||||
|
break
|
||||||
|
except Exception as exc: # still booting
|
||||||
|
last_error = exc
|
||||||
|
time.sleep(0.15)
|
||||||
|
else:
|
||||||
|
process.terminate()
|
||||||
|
raise RuntimeError(f"mirror serve never came up: {last_error}")
|
||||||
|
|
||||||
|
yield url
|
||||||
|
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
process.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired: # pragma: no cover
|
||||||
|
process.kill()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def browser():
|
||||||
|
with sync_playwright() as playwright:
|
||||||
|
# channel="chromium" = the full Chromium binary in new headless mode.
|
||||||
|
# The trimmed "headless shell" has no media stack at all and hides
|
||||||
|
# navigator.mediaDevices entirely.
|
||||||
|
instance = playwright.chromium.launch(channel="chromium", headless=True, args=CHROMIUM_ARGS)
|
||||||
|
yield instance
|
||||||
|
instance.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def context(browser):
|
||||||
|
"""One browser context == one device. Closed after every test."""
|
||||||
|
created = browser.new_context(viewport={"width": 1100, "height": 720})
|
||||||
|
yield created
|
||||||
|
created.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def page(context):
|
||||||
|
return context.new_page()
|
||||||
|
|
||||||
|
|
||||||
|
def start_sharing(page) -> None:
|
||||||
|
page.locator("#start").click()
|
||||||
|
expect(page.locator("body")).to_have_attribute("data-live", "on", timeout=15_000)
|
||||||
|
|
||||||
|
|
||||||
|
def stop_sharing(page) -> None:
|
||||||
|
page.locator("#stop").click()
|
||||||
|
expect(page.locator("body")).to_have_attribute("data-live", "off", timeout=10_000)
|
||||||
|
|
||||||
|
|
||||||
|
# Returns {width, height, currentTime, distinct} for the first <video> matching.
|
||||||
|
FRAME_PROBE = """
|
||||||
|
async (selector) => {
|
||||||
|
const video = document.querySelector(selector);
|
||||||
|
if (!video) return {missing: true};
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 48;
|
||||||
|
canvas.height = 32;
|
||||||
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
const seen = new Set();
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||||
|
try { ctx.drawImage(video, 0, 0, 48, 32); } catch { /* not ready yet */ }
|
||||||
|
const data = ctx.getImageData(0, 0, 48, 32).data;
|
||||||
|
let hash = 0;
|
||||||
|
for (let j = 0; j < data.length; j += 4) {
|
||||||
|
hash = ((hash * 31) + data[j] + data[j + 1] * 3 + data[j + 2] * 7) >>> 0;
|
||||||
|
}
|
||||||
|
seen.add(hash);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
width: video.videoWidth,
|
||||||
|
height: video.videoHeight,
|
||||||
|
currentTime: video.currentTime,
|
||||||
|
paused: video.paused,
|
||||||
|
distinct: seen.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def frames_matching(page, selector: str, attempts: int = 10) -> dict:
|
||||||
|
"""Poll until real, changing pixels show up (or give up and return last)."""
|
||||||
|
result: dict = {}
|
||||||
|
for _ in range(attempts):
|
||||||
|
result = page.evaluate(FRAME_PROBE, selector)
|
||||||
|
if result.get("distinct", 0) >= 2 and result.get("width", 0) > 0:
|
||||||
|
return result
|
||||||
|
time.sleep(0.4)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
"""End-to-end: a real `mirror serve`, real Chromium, real captured frames.
|
||||||
|
|
||||||
|
Every assertion about "it works" is made on pixels that reached the viewer's
|
||||||
|
<video>, not on log lines. If the media path breaks, these tests break.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from playwright.sync_api import expect
|
||||||
|
|
||||||
|
from .conftest import 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)."""
|
||||||
|
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(str(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
|
||||||
|
|
||||||
|
|
||||||
|
def tile_ids(page) -> list[str]:
|
||||||
|
return page.locator(".tile").evaluate_all("els => els.map(e => e.dataset.stream)")
|
||||||
|
|
||||||
|
|
||||||
|
class TestShareThenWatch:
|
||||||
|
def test_a_shared_window_reaches_a_viewer_as_moving_pixels(self, device):
|
||||||
|
errors: list = []
|
||||||
|
sharer = device("/mirror", errors)
|
||||||
|
start_sharing(sharer)
|
||||||
|
|
||||||
|
expect(sharer.locator("#stat-resolution")).to_have_text(
|
||||||
|
re.compile(r"\d{3,4}\s.\s\d{3,4}"), timeout=10_000
|
||||||
|
)
|
||||||
|
expect(sharer.locator("#stat-surface")).to_contain_text("Screen", timeout=5_000)
|
||||||
|
|
||||||
|
viewer = device("/watch", errors)
|
||||||
|
expect(viewer.locator(".tile")).to_have_count(1, timeout=15_000)
|
||||||
|
expect(viewer.locator(".tile[data-state='live']")).to_have_count(1, timeout=25_000)
|
||||||
|
|
||||||
|
frames = frames_matching(viewer, ".tile video")
|
||||||
|
assert not frames.get("missing"), "no <video> element in the tile"
|
||||||
|
assert frames["width"] > 0 and frames["height"] > 0, f"no decoded frame size: {frames}"
|
||||||
|
assert frames["distinct"] >= 2, f"video is frozen: {frames}"
|
||||||
|
|
||||||
|
# The sharer learns that someone is watching.
|
||||||
|
expect(sharer.locator("#viewer-count")).to_have_text("1", timeout=10_000)
|
||||||
|
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_clicking_a_tile_takes_it_on_stage(self, device):
|
||||||
|
sharer = device("/mirror")
|
||||||
|
start_sharing(sharer)
|
||||||
|
|
||||||
|
viewer = device("/watch")
|
||||||
|
expect(viewer.locator(".tile[data-state='live']")).to_have_count(1, timeout=25_000)
|
||||||
|
|
||||||
|
viewer.locator(".tile").click()
|
||||||
|
expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
|
||||||
|
expect(viewer.locator("#watch-stage .tile")).to_have_count(1)
|
||||||
|
expect(viewer.locator("#watch-stage video")).to_have_count(1)
|
||||||
|
|
||||||
|
# Still moving while staged — staging must not restart the peer connection.
|
||||||
|
frames = frames_matching(viewer, "#watch-stage video")
|
||||||
|
assert frames["distinct"] >= 2, f"staged video froze: {frames}"
|
||||||
|
|
||||||
|
# And the mirror survives the stage: the sharer still has one viewer.
|
||||||
|
expect(sharer.locator("#viewer-count")).to_have_text("1", timeout=10_000)
|
||||||
|
|
||||||
|
def test_stopping_the_share_clears_the_wall(self, device):
|
||||||
|
sharer = device("/mirror")
|
||||||
|
start_sharing(sharer)
|
||||||
|
viewer = device("/watch")
|
||||||
|
expect(viewer.locator(".tile")).to_have_count(1, timeout=15_000)
|
||||||
|
|
||||||
|
stop_sharing(sharer)
|
||||||
|
|
||||||
|
expect(viewer.locator(".tile")).to_have_count(0, timeout=10_000)
|
||||||
|
expect(viewer.locator("#empty")).to_be_visible()
|
||||||
|
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"))
|
||||||
|
|
||||||
|
def test_closing_the_sharer_tab_clears_the_wall(self, device):
|
||||||
|
sharer = device("/mirror")
|
||||||
|
start_sharing(sharer)
|
||||||
|
viewer = device("/watch")
|
||||||
|
expect(viewer.locator(".tile")).to_have_count(1, timeout=15_000)
|
||||||
|
|
||||||
|
sharer.close()
|
||||||
|
|
||||||
|
expect(viewer.locator(".tile")).to_have_count(0, timeout=10_000)
|
||||||
|
|
||||||
|
|
||||||
|
class TestChromeVisibility:
|
||||||
|
"""The page must never advertise a control that does not apply to its state."""
|
||||||
|
|
||||||
|
def test_the_idle_share_page_offers_only_a_start(self, device):
|
||||||
|
sharer = device("/mirror")
|
||||||
|
expect(sharer.locator("#start")).to_be_visible()
|
||||||
|
expect(sharer.locator("#placeholder")).to_be_visible()
|
||||||
|
expect(sharer.locator("#stop")).not_to_be_visible()
|
||||||
|
expect(sharer.locator("#livetag")).not_to_be_visible()
|
||||||
|
|
||||||
|
start_sharing(sharer)
|
||||||
|
|
||||||
|
expect(sharer.locator("#stop")).to_be_visible()
|
||||||
|
expect(sharer.locator("#livetag")).to_be_visible()
|
||||||
|
expect(sharer.locator("#start")).not_to_be_visible()
|
||||||
|
expect(sharer.locator("#placeholder")).not_to_be_visible()
|
||||||
|
|
||||||
|
def test_exit_only_appears_on_the_tile_that_is_on_stage(self, device):
|
||||||
|
sharer = device("/mirror")
|
||||||
|
start_sharing(sharer)
|
||||||
|
viewer = device("/watch")
|
||||||
|
expect(viewer.locator(".tile")).to_have_count(1, timeout=15_000)
|
||||||
|
expect(viewer.locator(".tile .exit")).not_to_be_visible()
|
||||||
|
|
||||||
|
viewer.locator(".tile").click()
|
||||||
|
expect(viewer.locator("#watch-stage .exit")).to_be_visible()
|
||||||
|
|
||||||
|
viewer.locator("#watch-stage .exit").click()
|
||||||
|
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"), timeout=5_000)
|
||||||
|
expect(viewer.locator(".tile .exit")).not_to_be_visible()
|
||||||
|
# Leaving the stage must not have disturbed the stream.
|
||||||
|
expect(viewer.locator(".tile[data-state='live']")).to_have_count(1, timeout=20_000)
|
||||||
|
|
||||||
|
|
||||||
|
class TestManyToMany:
|
||||||
|
def test_one_mirror_fans_out_to_several_viewers(self, device):
|
||||||
|
errors: list = []
|
||||||
|
sharer = device("/mirror", errors)
|
||||||
|
start_sharing(sharer)
|
||||||
|
|
||||||
|
viewers = [device("/watch", errors) for _ in range(3)]
|
||||||
|
for viewer in viewers:
|
||||||
|
expect(viewer.locator(".tile[data-state='live']")).to_have_count(1, timeout=25_000)
|
||||||
|
|
||||||
|
expect(sharer.locator("#viewer-count")).to_have_text("3", timeout=15_000)
|
||||||
|
expect(sharer.locator("#audience li")).to_have_count(3, timeout=10_000)
|
||||||
|
|
||||||
|
for viewer in viewers:
|
||||||
|
frames = frames_matching(viewer, ".tile video")
|
||||||
|
assert frames["distinct"] >= 2, f"a viewer froze: {frames}"
|
||||||
|
|
||||||
|
# One viewer leaving must not disturb the others.
|
||||||
|
viewers[0].close()
|
||||||
|
expect(sharer.locator("#viewer-count")).to_have_text("2", timeout=15_000)
|
||||||
|
for viewer in viewers[1:]:
|
||||||
|
frames = frames_matching(viewer, ".tile video")
|
||||||
|
assert frames["distinct"] >= 2, f"collateral damage: {frames}"
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_several_mirrors_land_on_one_wall(self, device):
|
||||||
|
errors: list = []
|
||||||
|
sharers = []
|
||||||
|
for _ in range(2):
|
||||||
|
page = device("/mirror", errors)
|
||||||
|
start_sharing(page)
|
||||||
|
sharers.append(page)
|
||||||
|
|
||||||
|
viewer = device("/watch", errors)
|
||||||
|
expect(viewer.locator(".tile")).to_have_count(2, timeout=15_000)
|
||||||
|
expect(viewer.locator(".tile[data-state='live']")).to_have_count(2, timeout=30_000)
|
||||||
|
|
||||||
|
ids = tile_ids(viewer)
|
||||||
|
assert len(ids) == 2 and len(set(ids)) == 2, "tiles must be distinct mirrors"
|
||||||
|
|
||||||
|
# Stage the second mirror, then jump to the first.
|
||||||
|
viewer.locator(".tile").nth(1).click()
|
||||||
|
expect(viewer.locator("#watch-stage .tile")).to_have_count(1)
|
||||||
|
staged = viewer.locator("#watch-stage .tile").get_attribute("data-stream")
|
||||||
|
assert staged == ids[1]
|
||||||
|
|
||||||
|
viewer.locator("#watch-stage .exit").click()
|
||||||
|
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"), timeout=5_000)
|
||||||
|
|
||||||
|
viewer.locator(".tile").nth(0).click()
|
||||||
|
expect(viewer.locator("#watch-stage .tile")).to_have_count(1)
|
||||||
|
assert viewer.locator("#watch-stage .tile").get_attribute("data-stream") == ids[0]
|
||||||
|
|
||||||
|
# Both mirrors are still alive and untouched by the staging gymnastics.
|
||||||
|
expect(viewer.locator(".tile[data-state='live']")).to_have_count(2, timeout=20_000)
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_stopping_one_mirror_leaves_the_other_running(self, device):
|
||||||
|
first = device("/mirror")
|
||||||
|
start_sharing(first)
|
||||||
|
second = device("/mirror")
|
||||||
|
start_sharing(second)
|
||||||
|
|
||||||
|
viewer = device("/watch")
|
||||||
|
expect(viewer.locator(".tile[data-state='live']")).to_have_count(2, timeout=30_000)
|
||||||
|
|
||||||
|
stop_sharing(first)
|
||||||
|
|
||||||
|
expect(viewer.locator(".tile")).to_have_count(1, timeout=10_000)
|
||||||
|
expect(viewer.locator(".tile[data-state='live']")).to_have_count(1, timeout=20_000)
|
||||||
|
frames = frames_matching(viewer, ".tile video")
|
||||||
|
assert frames["distinct"] >= 2, f"the surviving mirror froze: {frames}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTimingAndRecovery:
|
||||||
|
def test_a_viewer_already_on_the_wall_sees_a_late_mirror(self, device):
|
||||||
|
viewer = device("/watch")
|
||||||
|
expect(viewer.locator("#empty")).to_be_visible()
|
||||||
|
|
||||||
|
sharer = device("/mirror")
|
||||||
|
start_sharing(sharer)
|
||||||
|
|
||||||
|
expect(viewer.locator(".tile[data-state='live']")).to_have_count(1, timeout=25_000)
|
||||||
|
frames = frames_matching(viewer, ".tile video")
|
||||||
|
assert frames["distinct"] >= 2, f"late mirror did not animate: {frames}"
|
||||||
|
|
||||||
|
def test_a_reloaded_viewer_recovers_the_live_mirror(self, device):
|
||||||
|
sharer = device("/mirror")
|
||||||
|
start_sharing(sharer)
|
||||||
|
viewer = device("/watch")
|
||||||
|
expect(viewer.locator(".tile[data-state='live']")).to_have_count(1, timeout=25_000)
|
||||||
|
|
||||||
|
viewer.reload(wait_until="domcontentloaded")
|
||||||
|
expect(viewer.locator("#net")).to_have_attribute("data-state", "online", timeout=10_000)
|
||||||
|
expect(viewer.locator(".tile[data-state='live']")).to_have_count(1, timeout=25_000)
|
||||||
|
frames = frames_matching(viewer, ".tile video")
|
||||||
|
assert frames["distinct"] >= 2, f"reload did not restore motion: {frames}"
|
||||||
|
expect(sharer.locator("#viewer-count")).to_have_text("1", timeout=15_000)
|
||||||
|
|
||||||
|
def test_the_index_page_counts_what_is_live(self, device):
|
||||||
|
index = device("/")
|
||||||
|
expect(index.locator("#livebar")).to_contain_text("No mirrors live", timeout=10_000)
|
||||||
|
|
||||||
|
sharer = device("/mirror")
|
||||||
|
start_sharing(sharer)
|
||||||
|
|
||||||
|
expect(index.locator("#livebar")).to_contain_text("1 mirror", timeout=10_000)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRosterTruthfulness:
|
||||||
|
def test_the_api_agrees_with_what_the_browser_shows(self, device, base_url):
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
sharer = device("/mirror")
|
||||||
|
start_sharing(sharer)
|
||||||
|
viewer = device("/watch")
|
||||||
|
expect(viewer.locator(".tile[data-state='live']")).to_have_count(1, timeout=25_000)
|
||||||
|
|
||||||
|
# Give the stats ticker a beat, then compare server truth to pixels.
|
||||||
|
time.sleep(1.0)
|
||||||
|
payload = httpx.get(f"{base_url}/api/streams", timeout=5).json()
|
||||||
|
assert payload["stats"]["mirrors"] == 1
|
||||||
|
assert payload["stats"]["watching"] == 1
|
||||||
|
mirror = payload["mirrors"][0]
|
||||||
|
assert mirror["width"] and mirror["height"]
|
||||||
|
assert mirror["displaySurface"] in ("monitor", "window", "browser")
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
"""The Linux-native capture path: a real X11 screen, no fake media devices.
|
||||||
|
|
||||||
|
The rest of the suite drives Chromium's fake capture device, which is
|
||||||
|
deterministic but synthetic. This module is the counterweight: it starts its own
|
||||||
|
Xvfb server, runs a *headed* Chromium on it, and asks that browser to mirror the
|
||||||
|
whole monitor through Chromium's real X11 capture backend. A second Chromium
|
||||||
|
process — a different machine as far as WebRTC cares — watches it.
|
||||||
|
|
||||||
|
Skipped unless ``MIRROR_REALDISPLAY=1``, because it needs an X server binary and
|
||||||
|
a headed browser::
|
||||||
|
|
||||||
|
MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from playwright.sync_api import expect, sync_playwright
|
||||||
|
|
||||||
|
pytestmark = [
|
||||||
|
pytest.mark.e2e,
|
||||||
|
pytest.mark.realdisplay,
|
||||||
|
pytest.mark.timeout(240),
|
||||||
|
pytest.mark.skipif(shutil.which("Xvfb") is None, reason="Xvfb is not installed"),
|
||||||
|
pytest.mark.skipif(
|
||||||
|
os.environ.get("MIRROR_REALDISPLAY", "") not in {"1", "true", "yes"},
|
||||||
|
reason="set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
XVFB_GEOMETRY = "1280x800x24"
|
||||||
|
XVFB_ASPECT = 1280 / 800
|
||||||
|
FAKE_DEVICE_SIZE = (1280, 720)
|
||||||
|
|
||||||
|
# Flags learned the hard way, and worth keeping written down:
|
||||||
|
#
|
||||||
|
# * No `--use-fake-device-for-media-stream`: that is exactly what we are trying
|
||||||
|
# not to use here.
|
||||||
|
# * No `--use-fake-ui-for-media-stream` either. It short-circuits the picker
|
||||||
|
# *before* the X11 backend selects a source, and every capture then dies with
|
||||||
|
# `NotReadableError: Could not start video source` in ~55ms. Choosing the
|
||||||
|
# source with `--auto-select-desktop-capture-source` grants and selects in one
|
||||||
|
# step, which is what makes unattended desktop capture work.
|
||||||
|
# * `--ozone-platform=x11` keeps Chromium on its native X11 capturer instead of
|
||||||
|
# xdg-desktop-portal/PipeWire. Combined with `browser_env()` this makes a
|
||||||
|
# permission dialog on the developer's own session impossible.
|
||||||
|
REAL_CAPTURE_ARGS = [
|
||||||
|
"--auto-select-desktop-capture-source=Entire screen",
|
||||||
|
"--ozone-platform=x11",
|
||||||
|
"--autoplay-policy=no-user-gesture-required",
|
||||||
|
"--no-sandbox",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
"--no-proxy-server",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Never reachable: without a session bus and without a Wayland socket there is no
|
||||||
|
# portal to ask, so these tests cannot interrupt a human.
|
||||||
|
UNREACHABLE_SESSION_VARS = (
|
||||||
|
"WAYLAND_DISPLAY",
|
||||||
|
"DBUS_SESSION_BUS_ADDRESS",
|
||||||
|
"XDG_CURRENT_DESKTOP",
|
||||||
|
"XDG_BACKEND",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def browser_env(display: str | None = None) -> dict[str, str]:
|
||||||
|
"""An environment that can only reach the display this test owns."""
|
||||||
|
env = {key: value for key, value in os.environ.items() if key not in UNREACHABLE_SESSION_VARS}
|
||||||
|
env["XDG_SESSION_TYPE"] = "x11"
|
||||||
|
env["MIRROR_ICE_SERVERS"] = ""
|
||||||
|
if display is None:
|
||||||
|
env.pop("DISPLAY", None)
|
||||||
|
else:
|
||||||
|
env["DISPLAY"] = display
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
# Starts a pixel-sampling loop without blocking, so the test can drive motion on
|
||||||
|
# the captured screen while the watcher is measuring it.
|
||||||
|
PROBE = """() => {
|
||||||
|
const video = document.querySelector('.tile video');
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 96;
|
||||||
|
canvas.height = 60;
|
||||||
|
const ctx = canvas.getContext('2d', {willReadFrequently: true});
|
||||||
|
window.__probe = (async () => {
|
||||||
|
const seen = new Set();
|
||||||
|
for (let i = 0; i < 14; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
|
try { ctx.drawImage(video, 0, 0, 96, 60); } catch {}
|
||||||
|
const d = ctx.getImageData(0, 0, 96, 60).data;
|
||||||
|
let h = 0;
|
||||||
|
for (let j = 0; j < d.length; j += 4) {
|
||||||
|
h = ((h * 31) + d[j] + d[j + 1] * 3 + d[j + 2] * 7) >>> 0;
|
||||||
|
}
|
||||||
|
seen.add(h);
|
||||||
|
}
|
||||||
|
return {distinct: seen.size, width: video.videoWidth, height: video.videoHeight};
|
||||||
|
})();
|
||||||
|
return 'started';
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
def _free_display() -> str:
|
||||||
|
for number in range(90, 120):
|
||||||
|
path = f"/tmp/.X11-unix/X{number}"
|
||||||
|
if os.path.exists(path):
|
||||||
|
continue
|
||||||
|
with socket.socket(socket.AF_UNIX) as probe:
|
||||||
|
try:
|
||||||
|
probe.connect(path)
|
||||||
|
except OSError:
|
||||||
|
return f":{number}"
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
raise RuntimeError("no free X display in :90..:119")
|
||||||
|
|
||||||
|
|
||||||
|
def _socket_path(display: str) -> str:
|
||||||
|
"""`:90` -> `/tmp/.X11-unix/X90`, the file whose existence means "ready"."""
|
||||||
|
return f"/tmp/.X11-unix/X{display.lstrip(':')}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def x_display():
|
||||||
|
display = _free_display()
|
||||||
|
ready = _socket_path(display)
|
||||||
|
server = subprocess.Popen(
|
||||||
|
["Xvfb", display, "-screen", "0", XVFB_GEOMETRY, "-nolisten", "tcp"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
deadline = time.time() + 20
|
||||||
|
while time.time() < deadline:
|
||||||
|
if os.path.exists(ready):
|
||||||
|
break
|
||||||
|
if server.poll() is not None: # pragma: no cover
|
||||||
|
raise RuntimeError(f"Xvfb exited immediately with code {server.returncode}")
|
||||||
|
time.sleep(0.15)
|
||||||
|
else: # pragma: no cover
|
||||||
|
server.terminate()
|
||||||
|
raise RuntimeError(f"Xvfb never created {ready}")
|
||||||
|
yield display
|
||||||
|
server.terminate()
|
||||||
|
try:
|
||||||
|
server.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired: # pragma: no cover
|
||||||
|
server.kill()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def browsers(x_display):
|
||||||
|
"""Two Chromium *processes* from one Playwright driver.
|
||||||
|
|
||||||
|
The sharer is headed and lives on the Xvfb display, so what it captures is a
|
||||||
|
genuine desktop. The watcher is headless: it never touches the display, which
|
||||||
|
is exactly why it can be a separate process.
|
||||||
|
"""
|
||||||
|
previous = os.environ.get("DISPLAY")
|
||||||
|
os.environ["DISPLAY"] = x_display
|
||||||
|
try:
|
||||||
|
with sync_playwright() as playwright:
|
||||||
|
sharer = playwright.chromium.launch(
|
||||||
|
channel="chromium",
|
||||||
|
headless=False,
|
||||||
|
args=REAL_CAPTURE_ARGS,
|
||||||
|
env=browser_env(x_display),
|
||||||
|
)
|
||||||
|
watcher = playwright.chromium.launch(
|
||||||
|
channel="chromium",
|
||||||
|
headless=True,
|
||||||
|
args=[
|
||||||
|
"--no-sandbox",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
"--no-proxy-server",
|
||||||
|
"--ozone-platform=x11",
|
||||||
|
],
|
||||||
|
env=browser_env(),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
yield {"sharer": sharer, "watcher": watcher}
|
||||||
|
finally:
|
||||||
|
sharer.close()
|
||||||
|
watcher.close()
|
||||||
|
finally:
|
||||||
|
if previous is not None:
|
||||||
|
os.environ["DISPLAY"] = previous
|
||||||
|
else: # pragma: no cover
|
||||||
|
os.environ.pop("DISPLAY", None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def share_page(browsers, base_url):
|
||||||
|
page = browsers["sharer"].new_context(viewport={"width": 1200, "height": 760}).new_page()
|
||||||
|
page.goto(f"{base_url}/mirror", wait_until="domcontentloaded")
|
||||||
|
expect(page.locator("#net")).to_have_attribute("data-state", "online", timeout=20_000)
|
||||||
|
yield page
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def watch_page(browsers, base_url):
|
||||||
|
page = browsers["watcher"].new_context().new_page()
|
||||||
|
page.goto(f"{base_url}/watch", wait_until="domcontentloaded")
|
||||||
|
expect(page.locator("#net")).to_have_attribute("data-state", "online", timeout=20_000)
|
||||||
|
yield page
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestRealX11Capture:
|
||||||
|
def test_the_real_desktop_is_captured_not_a_synthetic_one(self, base_url, share_page):
|
||||||
|
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];
|
||||||
|
return {label: track.label, ...track.getSettings()};
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
assert settings["displaySurface"] == "monitor", f"not a monitor capture: {settings}"
|
||||||
|
assert settings["label"], "a real capture always names its source"
|
||||||
|
assert not settings["label"].startswith("screen:-"), (
|
||||||
|
f"{settings['label']} is Chromium's synthetic capture device"
|
||||||
|
)
|
||||||
|
size = (settings["width"], settings["height"])
|
||||||
|
assert size != FAKE_DEVICE_SIZE, (
|
||||||
|
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.
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The roster must carry the real geometry, not a made-up one.
|
||||||
|
time.sleep(0.6)
|
||||||
|
mirror = httpx.get(f"{base_url}/api/streams", timeout=5).json()["mirrors"][0]
|
||||||
|
assert (mirror["width"], mirror["height"]) == size
|
||||||
|
assert mirror["displaySurface"] == "monitor"
|
||||||
|
assert errors == [], f"uncaught browser errors: {errors}"
|
||||||
|
|
||||||
|
def test_a_real_desktop_reaches_a_second_machine_and_moves(self, base_url, browsers):
|
||||||
|
share_page = (
|
||||||
|
browsers["sharer"].new_context(viewport={"width": 1200, "height": 760}).new_page()
|
||||||
|
)
|
||||||
|
share_page.goto(f"{base_url}/mirror", wait_until="domcontentloaded")
|
||||||
|
expect(share_page.locator("#net")).to_have_attribute("data-state", "online", timeout=20_000)
|
||||||
|
share_page.locator("#start").click()
|
||||||
|
expect(share_page.locator("body")).to_have_attribute("data-live", "on", timeout=40_000)
|
||||||
|
|
||||||
|
watch_page = browsers["watcher"].new_context().new_page()
|
||||||
|
watch_page.goto(f"{base_url}/watch", wait_until="domcontentloaded")
|
||||||
|
expect(watch_page.locator("#net")).to_have_attribute("data-state", "online", timeout=20_000)
|
||||||
|
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"] >= 640 and result["height"] >= 400, (
|
||||||
|
f"the watcher decoded no picture: {result}"
|
||||||
|
)
|
||||||
|
assert abs(result["width"] / result["height"] - XVFB_ASPECT) < 0.06, result
|
||||||
|
assert result["distinct"] >= 2, (
|
||||||
|
f"the watcher received a frozen frame: {result} — real desktop motion "
|
||||||
|
f"never made it across the peer connection"
|
||||||
|
)
|
||||||
|
|
||||||
|
# And the sharer agrees that someone is watching its desktop.
|
||||||
|
expect(share_page.locator("#viewer-count")).to_have_text("1", timeout=20_000)
|
||||||
|
|
||||||
|
# Stopping the share tears the mirror away for the watcher as well.
|
||||||
|
share_page.locator("#stop").click()
|
||||||
|
expect(watch_page.locator(".tile")).to_have_count(0, timeout=20_000)
|
||||||
|
|
||||||
|
watch_page.close()
|
||||||
|
share_page.close()
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"""HTTP surface + signaling socket tests. No browser required."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
STATIC = Path(__file__).resolve().parent.parent / "src/mirror/static"
|
||||||
|
EXTERNAL_REF = re.compile(r"""(?:src|href)\s*=\s*["']https?://""", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def handshake(socket, role, name=""):
|
||||||
|
socket.send_json({"type": "hello", "role": role, "name": name})
|
||||||
|
message = socket.receive_json()
|
||||||
|
assert message["type"] == "welcome", message
|
||||||
|
return message["peerId"]
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for(socket, kind):
|
||||||
|
"""Read frames until one has ``type == kind``.
|
||||||
|
|
||||||
|
The roster legitimately pushes ``stream_updated`` frames whenever the
|
||||||
|
audience changes, so tests must not assume a one-message-per-action cadence.
|
||||||
|
Any ``error`` frame seen on the way is a failure worth surfacing.
|
||||||
|
"""
|
||||||
|
seen = []
|
||||||
|
while True:
|
||||||
|
message = socket.receive_json()
|
||||||
|
if message.get("type") == kind:
|
||||||
|
return message
|
||||||
|
assert message.get("type") != "error", (
|
||||||
|
f"unexpected error while waiting for {kind}: {message}"
|
||||||
|
)
|
||||||
|
seen.append(message)
|
||||||
|
assert len(seen) < 25, f"never saw {kind!r}; got {seen}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPages:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("path", "marker"),
|
||||||
|
[
|
||||||
|
("/", "Watch everything"),
|
||||||
|
("/mirror", "Share my screen"),
|
||||||
|
("/share", "Share my screen"),
|
||||||
|
("/watch", "tile-template"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_pages_render(self, client, path, marker):
|
||||||
|
response = client.get(path)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["content-type"].startswith("text/html")
|
||||||
|
assert marker in response.text
|
||||||
|
|
||||||
|
def test_pages_are_not_cacheable(self, client):
|
||||||
|
assert client.get("/watch").headers["cache-control"] == "no-store"
|
||||||
|
|
||||||
|
def test_the_two_pages_use_the_media_apis_they_claim(self):
|
||||||
|
share = (STATIC / "share.js").read_text()
|
||||||
|
watch = (STATIC / "watch.js").read_text()
|
||||||
|
assert "getDisplayMedia" in share
|
||||||
|
assert "RTCPeerConnection" in share and "RTCPeerConnection" in watch
|
||||||
|
assert "new WebSocket" in (STATIC / "net.js").read_text()
|
||||||
|
|
||||||
|
def test_nothing_is_loaded_from_a_cdn(self):
|
||||||
|
"""The no-CDN rule is a hard product requirement, so assert it."""
|
||||||
|
offenders = []
|
||||||
|
for file in sorted(STATIC.iterdir()):
|
||||||
|
text = file.read_text()
|
||||||
|
if EXTERNAL_REF.search(text):
|
||||||
|
offenders.append(file.name)
|
||||||
|
if "@import" in text or "fonts.googleapis" in text:
|
||||||
|
offenders.append(f"{file.name} (@import)")
|
||||||
|
assert offenders == []
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("asset", ["app.css", "net.js", "share.js", "watch.js"])
|
||||||
|
def test_static_assets_are_served(self, client, asset):
|
||||||
|
assert client.get(f"/static/{asset}").status_code == 200
|
||||||
|
|
||||||
|
def test_unknown_page_is_a_404(self, client):
|
||||||
|
assert client.get("/nope").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestClientScripts:
|
||||||
|
"""Guards for the hand-written ES modules: they must parse, and the ids they
|
||||||
|
reach for must exist. Both of these have already bitten once."""
|
||||||
|
|
||||||
|
NODE = shutil.which("node")
|
||||||
|
|
||||||
|
@pytest.mark.skipif(NODE is None, reason="node is not installed")
|
||||||
|
@pytest.mark.parametrize("script", ["net.js", "share.js", "watch.js"])
|
||||||
|
def test_scripts_parse_as_es_modules(self, script, tmp_path):
|
||||||
|
copy = tmp_path / "subject.mjs"
|
||||||
|
copy.write_text((STATIC / script).read_text())
|
||||||
|
result = subprocess.run([self.NODE, "--check", str(copy)], capture_output=True, text=True)
|
||||||
|
assert result.returncode == 0, f"{script}: {result.stderr}"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("script", "page"),
|
||||||
|
[("share.js", "mirror.html"), ("watch.js", "watch.html")],
|
||||||
|
)
|
||||||
|
def test_every_element_the_script_reaches_for_exists(self, script, page):
|
||||||
|
html = (STATIC / page).read_text()
|
||||||
|
wanted = set(re.findall(r"""\$\(\"#([A-Za-z0-9_-]+)\"\)""", (STATIC / script).read_text()))
|
||||||
|
missing = {name for name in wanted if f'id="{name}"' not in html}
|
||||||
|
assert not missing, f"{script} looks up {sorted(missing)} which {page} does not render"
|
||||||
|
|
||||||
|
def test_both_pages_load_the_stylesheet_and_their_module(self):
|
||||||
|
for page, script in (("mirror.html", "share.js"), ("watch.html", "watch.js")):
|
||||||
|
html = (STATIC / page).read_text()
|
||||||
|
assert 'href="/static/app.css"' in html
|
||||||
|
assert f'src="/static/{script}"' in html
|
||||||
|
assert 'type="module"' in html
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
This is not hypothetical: a `#stage` on both pages once made the share
|
||||||
|
page's preview inherit `display: none` from the watch page's overlay.
|
||||||
|
"""
|
||||||
|
shared = {"net", "notice", "toast"}
|
||||||
|
ids = {
|
||||||
|
page: set(re.findall(r'id="([A-Za-z0-9_-]+)"', (STATIC / page).read_text()))
|
||||||
|
for page in ("mirror.html", "watch.html", "index.html")
|
||||||
|
}
|
||||||
|
assert ids["mirror.html"] & ids["watch.html"] == shared
|
||||||
|
assert ids["index.html"] & ids["mirror.html"] <= shared | {"livebar"}
|
||||||
|
for page, found in ids.items():
|
||||||
|
assert len(found) == len(set(found)), f"{page} declares an id twice"
|
||||||
|
|
||||||
|
def test_videos_are_muted_and_inline_so_autoplay_is_not_blocked(self):
|
||||||
|
for page in ("mirror.html", "watch.html"):
|
||||||
|
for tag in re.findall(r"<video[^>]*>", (STATIC / page).read_text()):
|
||||||
|
assert "muted" in tag, f"{page}: {tag} would be blocked by autoplay policy"
|
||||||
|
assert "playsinline" in tag, f"{page}: {tag} would hijack iOS into fullscreen"
|
||||||
|
|
||||||
|
|
||||||
|
class TestStylesheet:
|
||||||
|
def test_hidden_always_means_gone(self):
|
||||||
|
"""Every component that sets its own `display` outranks the UA's `[hidden]`.
|
||||||
|
|
||||||
|
Without this rule the share page shows the LIVE badge and the Stop button
|
||||||
|
while nothing is being shared, and every wall tile offers an Exit button
|
||||||
|
it is not on stage for.
|
||||||
|
"""
|
||||||
|
css = (STATIC / "app.css").read_text()
|
||||||
|
assert re.search(r"\[hidden\]\s*\{[^}]*display:\s*none\s*!important", css), (
|
||||||
|
"app.css must declare a global `[hidden] { display: none !important }`"
|
||||||
|
)
|
||||||
|
# Components that set `display` and are toggled with `hidden` in JS.
|
||||||
|
for selector in (".btn", ".livetag", ".icon-btn", ".toast", ".net"):
|
||||||
|
assert selector in css, f"{selector} is toggled by `hidden`; it lives in app.css"
|
||||||
|
|
||||||
|
def test_no_rule_reintroduces_a_conflicting_display_for_hidden(self):
|
||||||
|
"""Every rule whose selector mentions `[hidden]` must actually hide."""
|
||||||
|
css = re.sub(r"/\*.*?\*/", "", (STATIC / "app.css").read_text(), flags=re.S)
|
||||||
|
offenders = [
|
||||||
|
selector.strip()
|
||||||
|
for selector, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css)
|
||||||
|
if "[hidden]" in selector and not re.search(r"display\s*:\s*none", body)
|
||||||
|
]
|
||||||
|
assert offenders == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestJsonApi:
|
||||||
|
def test_config_advertises_the_media_defaults(self, client):
|
||||||
|
body = client.get("/api/config").json()
|
||||||
|
assert body["iceServers"] == []
|
||||||
|
assert body["maxBitrateBps"] == 2_000_000
|
||||||
|
assert "version" in body
|
||||||
|
|
||||||
|
def test_config_is_a_whitelist(self, client):
|
||||||
|
body = client.get("/api/config").json()
|
||||||
|
assert set(body) == {"version", "iceServers", "maxBitrateBps", "pingIntervalMs"}
|
||||||
|
|
||||||
|
def test_streams_starts_empty(self, client):
|
||||||
|
body = client.get("/api/streams").json()
|
||||||
|
assert body["mirrors"] == []
|
||||||
|
assert body["stats"]["mirrors"] == 0
|
||||||
|
|
||||||
|
def test_healthz(self, client):
|
||||||
|
assert client.get("/healthz").json()["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSignalingSocket:
|
||||||
|
def test_hello_must_come_first(self, client):
|
||||||
|
with client.websocket_connect("/ws") as socket:
|
||||||
|
socket.send_json({"type": "publish", "label": "sneaky"})
|
||||||
|
message = socket.receive_json()
|
||||||
|
assert message["type"] == "error"
|
||||||
|
assert message["code"] == "hello_required"
|
||||||
|
assert message["fatal"] is True
|
||||||
|
|
||||||
|
def test_malformed_json_is_rejected(self, client):
|
||||||
|
with client.websocket_connect("/ws") as socket:
|
||||||
|
socket.send_text("{not json")
|
||||||
|
assert socket.receive_json()["code"] == "bad_json"
|
||||||
|
|
||||||
|
def test_a_non_object_frame_is_rejected(self, client):
|
||||||
|
with client.websocket_connect("/ws") as socket:
|
||||||
|
socket.send_text("[1, 2, 3]")
|
||||||
|
assert socket.receive_json()["code"] == "bad_json"
|
||||||
|
|
||||||
|
def test_an_unknown_role_is_refused(self, client):
|
||||||
|
with client.websocket_connect("/ws") as socket:
|
||||||
|
socket.send_json({"type": "hello", "role": "spectator"})
|
||||||
|
assert socket.receive_json()["code"] == "bad_role"
|
||||||
|
|
||||||
|
def test_unknown_message_type_is_reported_but_survivable(self, client):
|
||||||
|
with client.websocket_connect("/ws") as socket:
|
||||||
|
handshake(socket, "viewer", "V")
|
||||||
|
socket.send_json({"type": "telepathy"})
|
||||||
|
assert socket.receive_json()["code"] == "bad_message"
|
||||||
|
socket.send_json({"type": "ping"})
|
||||||
|
assert socket.receive_json()["type"] == "pong"
|
||||||
|
|
||||||
|
def test_ping_roundtrips(self, client):
|
||||||
|
with client.websocket_connect("/ws") as socket:
|
||||||
|
handshake(socket, "viewer")
|
||||||
|
socket.send_json({"type": "ping"})
|
||||||
|
assert socket.receive_json() == {"type": "pong"}
|
||||||
|
|
||||||
|
def test_an_oversized_frame_is_refused(self, client):
|
||||||
|
with client.websocket_connect("/ws") as socket:
|
||||||
|
handshake(socket, "viewer")
|
||||||
|
socket.send_text(json.dumps({"type": "ping", "padding": "x" * 70_000}))
|
||||||
|
assert socket.receive_json()["code"] == "too_large"
|
||||||
|
|
||||||
|
def test_roster_flows_from_sharer_to_viewer(self, client):
|
||||||
|
with client.websocket_connect("/ws") as sharer, client.websocket_connect("/ws") as viewer:
|
||||||
|
sharer_id = handshake(sharer, "sharer", "Linux Box")
|
||||||
|
viewer_id = handshake(viewer, "viewer", "Phone")
|
||||||
|
|
||||||
|
sharer.send_json({"type": "publish", "label": "Terminal", "width": 1280, "height": 720})
|
||||||
|
added = wait_for(viewer, "stream_added")["stream"]
|
||||||
|
assert added["id"] == sharer_id
|
||||||
|
assert added["label"] == "Terminal"
|
||||||
|
assert added["name"] == "Linux Box"
|
||||||
|
assert added["width"] == 1280
|
||||||
|
|
||||||
|
viewer.send_json({"type": "subscribe", "streamId": sharer_id})
|
||||||
|
joined = wait_for(sharer, "peer_joined")
|
||||||
|
assert joined["viewerId"] == viewer_id
|
||||||
|
assert joined["name"] == "Phone"
|
||||||
|
|
||||||
|
sharer.send_json({"type": "signal", "to": viewer_id, "payload": {"sdp": {"s": "o"}}})
|
||||||
|
relayed = wait_for(viewer, "signal")
|
||||||
|
assert relayed["from"] == sharer_id
|
||||||
|
assert relayed["payload"] == {"sdp": {"s": "o"}}
|
||||||
|
|
||||||
|
viewer.send_json({"type": "signal", "to": sharer_id, "payload": {"c": "ice"}})
|
||||||
|
assert wait_for(sharer, "signal")["payload"] == {"c": "ice"}
|
||||||
|
|
||||||
|
def test_capture_metadata_survives_the_wire(self, client):
|
||||||
|
"""Regression: `displaySurface` once arrived as "" because the model expected
|
||||||
|
snake_case and unknown keys were being ignored."""
|
||||||
|
with client.websocket_connect("/ws") as sharer, client.websocket_connect("/ws") as viewer:
|
||||||
|
handshake(sharer, "sharer", "A")
|
||||||
|
handshake(viewer, "viewer")
|
||||||
|
sharer.send_json(
|
||||||
|
{
|
||||||
|
"type": "publish",
|
||||||
|
"label": "proj.gl — zsh",
|
||||||
|
"displaySurface": "window",
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080,
|
||||||
|
"audio": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
stream = wait_for(viewer, "stream_added")["stream"]
|
||||||
|
assert stream["displaySurface"] == "window"
|
||||||
|
assert stream["audio"] is True
|
||||||
|
assert (stream["width"], stream["height"]) == (1920, 1080)
|
||||||
|
assert client.get("/api/streams").json()["mirrors"][0]["displaySurface"] == "window"
|
||||||
|
|
||||||
|
def test_both_viewers_of_one_mirror_see_their_own_leg(self, client):
|
||||||
|
with (
|
||||||
|
client.websocket_connect("/ws") as sharer,
|
||||||
|
client.websocket_connect("/ws") as one,
|
||||||
|
client.websocket_connect("/ws") as two,
|
||||||
|
):
|
||||||
|
sharer_id = handshake(sharer, "sharer", "A")
|
||||||
|
first = handshake(one, "viewer", "one")
|
||||||
|
second = handshake(two, "viewer", "two")
|
||||||
|
sharer.send_json({"type": "publish", "label": "Screen"})
|
||||||
|
wait_for(one, "stream_added")
|
||||||
|
wait_for(two, "stream_added")
|
||||||
|
|
||||||
|
one.send_json({"type": "subscribe", "streamId": sharer_id})
|
||||||
|
two.send_json({"type": "subscribe", "streamId": sharer_id})
|
||||||
|
assert wait_for(sharer, "peer_joined")["viewerId"] == first
|
||||||
|
assert wait_for(sharer, "peer_joined")["viewerId"] == second
|
||||||
|
|
||||||
|
sharer.send_json({"type": "signal", "to": first, "payload": {"n": 1}})
|
||||||
|
sharer.send_json({"type": "signal", "to": second, "payload": {"n": 2}})
|
||||||
|
assert wait_for(one, "signal")["payload"] == {"n": 1}
|
||||||
|
assert wait_for(two, "signal")["payload"] == {"n": 2}
|
||||||
|
|
||||||
|
def test_a_mirror_disappears_for_viewers_when_the_sharer_leaves(self, client):
|
||||||
|
with client.websocket_connect("/ws") as sharer, client.websocket_connect("/ws") as viewer:
|
||||||
|
sharer_id = handshake(sharer, "sharer", "A")
|
||||||
|
handshake(viewer, "viewer", "V")
|
||||||
|
sharer.send_json({"type": "publish", "label": "Screen"})
|
||||||
|
wait_for(viewer, "stream_added")
|
||||||
|
viewer.send_json({"type": "subscribe", "streamId": sharer_id})
|
||||||
|
wait_for(sharer, "peer_joined")
|
||||||
|
|
||||||
|
sharer.close()
|
||||||
|
assert wait_for(viewer, "stream_removed")["streamId"] == sharer_id
|
||||||
|
|
||||||
|
def test_unpublish_removes_the_mirror(self, client):
|
||||||
|
with client.websocket_connect("/ws") as sharer, client.websocket_connect("/ws") as viewer:
|
||||||
|
sharer_id = handshake(sharer, "sharer")
|
||||||
|
handshake(viewer, "viewer")
|
||||||
|
sharer.send_json({"type": "publish", "label": "Screen"})
|
||||||
|
wait_for(viewer, "stream_added")
|
||||||
|
sharer.send_json({"type": "unpublish"})
|
||||||
|
assert wait_for(viewer, "stream_removed")["streamId"] == sharer_id
|
||||||
|
assert client.get("/api/streams").json()["mirrors"] == []
|
||||||
|
|
||||||
|
def test_signalling_a_peer_that_never_subscribed_is_refused(self, client):
|
||||||
|
with client.websocket_connect("/ws") as sharer, client.websocket_connect("/ws") as viewer:
|
||||||
|
sharer_id = handshake(sharer, "sharer")
|
||||||
|
viewer_id = handshake(viewer, "viewer")
|
||||||
|
sharer.send_json({"type": "publish", "label": "Screen"})
|
||||||
|
assert wait_for(viewer, "stream_added")["stream"]["sharerId"] == sharer_id
|
||||||
|
# The viewer knows the mirror exists but has not subscribed to it.
|
||||||
|
sharer.send_json({"type": "signal", "to": viewer_id, "payload": {}})
|
||||||
|
assert wait_for(sharer, "error")["code"] == "not_subscribed"
|
||||||
|
|
||||||
|
def test_a_viewer_cannot_reach_a_sharer_it_never_subscribed_to(self, client):
|
||||||
|
with client.websocket_connect("/ws") as sharer, client.websocket_connect("/ws") as viewer:
|
||||||
|
sharer_id = handshake(sharer, "sharer", "A")
|
||||||
|
handshake(viewer, "viewer")
|
||||||
|
sharer.send_json({"type": "publish", "label": "Screen"})
|
||||||
|
wait_for(viewer, "stream_added")
|
||||||
|
viewer.send_json({"type": "signal", "to": sharer_id, "payload": {}})
|
||||||
|
assert wait_for(viewer, "error")["code"] == "not_subscribed"
|
||||||
|
|
||||||
|
def test_subscribing_to_a_mirror_that_is_not_there_reports_cleanly(self, client):
|
||||||
|
with client.websocket_connect("/ws") as viewer:
|
||||||
|
handshake(viewer, "viewer")
|
||||||
|
viewer.send_json({"type": "subscribe", "streamId": "p404"})
|
||||||
|
message = wait_for(viewer, "error")
|
||||||
|
assert message["code"] == "unknown_stream"
|
||||||
|
assert message["fatal"] is False
|
||||||
|
|
||||||
|
def test_editing_the_display_name_reaches_viewers(self, client):
|
||||||
|
with client.websocket_connect("/ws") as sharer, client.websocket_connect("/ws") as viewer:
|
||||||
|
sharer_id = handshake(sharer, "sharer", "Old name")
|
||||||
|
handshake(viewer, "viewer")
|
||||||
|
sharer.send_json({"type": "publish", "label": "Screen"})
|
||||||
|
wait_for(viewer, "stream_added")
|
||||||
|
sharer.send_json({"type": "hello", "role": "sharer", "name": "Design Laptop"})
|
||||||
|
assert wait_for(viewer, "stream_updated")["stream"]["name"] == "Design Laptop"
|
||||||
|
assert client.get("/api/streams").json()["mirrors"][0]["sharerId"] == sharer_id
|
||||||
|
|
||||||
|
def test_stats_track_the_fan_out(self, client):
|
||||||
|
with client.websocket_connect("/ws") as sharer, client.websocket_connect("/ws") as viewer:
|
||||||
|
sharer_id = handshake(sharer, "sharer", "A")
|
||||||
|
handshake(viewer, "viewer", "V")
|
||||||
|
sharer.send_json({"type": "publish", "label": "Screen"})
|
||||||
|
wait_for(viewer, "stream_added")
|
||||||
|
viewer.send_json({"type": "subscribe", "streamId": sharer_id})
|
||||||
|
wait_for(sharer, "peer_joined")
|
||||||
|
stats = client.get("/api/streams").json()["stats"]
|
||||||
|
assert stats["mirrors"] == 1
|
||||||
|
assert stats["watching"] == 1
|
||||||
|
assert stats["connections"] == 2
|
||||||
|
|
||||||
|
def test_one_peer_cannot_impersonate_another(self, client):
|
||||||
|
"""`to` is addressed by peer id; a viewer must not be able to spoof a sharer."""
|
||||||
|
with client.websocket_connect("/ws") as sharer, client.websocket_connect("/ws") as viewer:
|
||||||
|
sharer_id = handshake(sharer, "sharer", "A")
|
||||||
|
handshake(viewer, "viewer", "V")
|
||||||
|
sharer.send_json({"type": "publish", "label": "Screen"})
|
||||||
|
wait_for(viewer, "stream_added")
|
||||||
|
viewer.send_json({"type": "publish", "label": "forged"})
|
||||||
|
assert wait_for(viewer, "error")["code"] == "not_sharer"
|
||||||
|
assert client.get("/api/streams").json()["stats"]["mirrors"] == 1
|
||||||
|
assert sharer_id and viewer
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""Configuration parsing, the CLI surface, and the debugpy hook."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mirror import __main__ as entry
|
||||||
|
from mirror.config import DEFAULT_PORT, Settings, lan_addresses, parse_ice_servers
|
||||||
|
from mirror.debug import maybe_enable_debugpy
|
||||||
|
|
||||||
|
|
||||||
|
class TestIceServers:
|
||||||
|
@pytest.mark.parametrize("raw", ["", " ", "[]", "none", "null", "not json", "{}", "[1,2]"])
|
||||||
|
def test_unusable_input_means_no_turn_stun(self, raw):
|
||||||
|
assert parse_ice_servers(raw) == []
|
||||||
|
|
||||||
|
def test_valid_servers_survive_and_junk_is_dropped(self):
|
||||||
|
raw = '[{"urls": "stun:router:3478"}, "junk", {"urls": ["turn:h:3478"]}]'
|
||||||
|
assert parse_ice_servers(raw) == [
|
||||||
|
{"urls": "stun:router:3478"},
|
||||||
|
{"urls": ["turn:h:3478"]},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestSettings:
|
||||||
|
def test_defaults(self, monkeypatch):
|
||||||
|
for name in (
|
||||||
|
"MIRROR_HOST",
|
||||||
|
"MIRROR_PORT",
|
||||||
|
"MIRROR_ICE_SERVERS",
|
||||||
|
"MIRROR_MAX_BITRATE",
|
||||||
|
"MIRROR_MAX_MIRRORS",
|
||||||
|
):
|
||||||
|
monkeypatch.delenv(name, raising=False)
|
||||||
|
settings = Settings.from_env()
|
||||||
|
assert settings.port == DEFAULT_PORT
|
||||||
|
assert settings.host == "0.0.0.0"
|
||||||
|
assert settings.ice_servers == []
|
||||||
|
assert settings.max_streams == 0
|
||||||
|
|
||||||
|
def test_environment_wins(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("MIRROR_HOST", "127.0.0.1")
|
||||||
|
monkeypatch.setenv("MIRROR_PORT", "9123")
|
||||||
|
monkeypatch.setenv("MIRROR_MAX_BITRATE", "250000")
|
||||||
|
monkeypatch.setenv("MIRROR_MAX_MIRRORS", "3")
|
||||||
|
monkeypatch.setenv("MIRROR_ICE_SERVERS", '[{"urls": "stun:box:3478"}]')
|
||||||
|
settings = Settings.from_env()
|
||||||
|
assert (settings.host, settings.port) == ("127.0.0.1", 9123)
|
||||||
|
assert settings.max_bitrate_bps == 250_000
|
||||||
|
assert settings.max_streams == 3
|
||||||
|
assert settings.ice_servers == [{"urls": "stun:box:3478"}]
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["abc", "", "80x"])
|
||||||
|
def test_a_bogus_number_falls_back_instead_of_crashing(self, monkeypatch, value):
|
||||||
|
monkeypatch.setenv("MIRROR_PORT", value)
|
||||||
|
assert Settings.from_env().port == DEFAULT_PORT
|
||||||
|
|
||||||
|
def test_client_config_is_a_whitelist(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("MIRROR_ICE_SERVERS", '[{"urls": "stun:box:3478"}]')
|
||||||
|
config = Settings.from_env().client_config()
|
||||||
|
assert set(config) == {"iceServers", "maxBitrateBps", "pingIntervalMs"}
|
||||||
|
|
||||||
|
def test_lan_addresses_are_real_addresses(self):
|
||||||
|
for address in lan_addresses():
|
||||||
|
assert not address.startswith("127.")
|
||||||
|
assert address.count(".") == 3 or ":" in address
|
||||||
|
|
||||||
|
|
||||||
|
class TestCli:
|
||||||
|
def test_bare_invocation_means_serve(self):
|
||||||
|
parser = entry.build_parser()
|
||||||
|
argv = ["serve", "--port", "8123", "--host", "127.0.0.1"]
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
assert args.command == "serve"
|
||||||
|
assert args.port == 8123
|
||||||
|
|
||||||
|
def test_port_validation_rejects_nonsense(self):
|
||||||
|
parser = entry.build_parser()
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
parser.parse_args(["serve", "--port", "nope"])
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
parser.parse_args(["serve", "--port", "70000"])
|
||||||
|
|
||||||
|
def test_version_exits_cleanly(self, capsys):
|
||||||
|
with pytest.raises(SystemExit) as excinfo:
|
||||||
|
entry.build_parser().parse_args(["--version"])
|
||||||
|
assert excinfo.value.code == 0
|
||||||
|
assert "mirror" in capsys.readouterr().out
|
||||||
|
|
||||||
|
def test_flags_without_a_subcommand_still_serve(self, monkeypatch):
|
||||||
|
"""`mirror --port 8901` must work as well as `mirror serve --port 8901`."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
class StubUvicorn:
|
||||||
|
@staticmethod
|
||||||
|
def run(_app, **kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
|
for name in ("MIRROR_HOST", "MIRROR_PORT", "MIRROR_ICE_SERVERS", "DEBUGPY"):
|
||||||
|
monkeypatch.delenv(name, raising=False)
|
||||||
|
monkeypatch.setitem(sys.modules, "uvicorn", StubUvicorn)
|
||||||
|
with pytest.raises(KeyboardInterrupt):
|
||||||
|
entry.main(["--port", "8901", "--host", "127.0.0.1"])
|
||||||
|
assert captured["port"] == 8901
|
||||||
|
assert captured["host"] == "127.0.0.1"
|
||||||
|
assert captured["access_log"] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestDebugpy:
|
||||||
|
@pytest.mark.parametrize("value", ["", "0", "off", "no", "FALSE"])
|
||||||
|
def test_stays_asleep_unless_explicitly_asked(self, monkeypatch, value):
|
||||||
|
monkeypatch.setenv("DEBUGPY", value)
|
||||||
|
assert maybe_enable_debugpy() is False
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""The wire contract, in both directions.
|
||||||
|
|
||||||
|
These exist because the client speaks camelCase and Python speaks snake_case,
|
||||||
|
and a silently-dropped field is the kind of bug that only shows up as a
|
||||||
|
"cosmetic" column being blank three features later.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from mirror import protocol
|
||||||
|
|
||||||
|
|
||||||
|
class TestInbound:
|
||||||
|
def test_publish_accepts_the_camel_case_wire_format(self):
|
||||||
|
message = protocol.parse_client_message(
|
||||||
|
{
|
||||||
|
"type": "publish",
|
||||||
|
"label": "proj.gl — zsh",
|
||||||
|
"displaySurface": "window",
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080,
|
||||||
|
"audio": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert message.display_surface == "window"
|
||||||
|
assert (message.width, message.height) == (1920, 1080)
|
||||||
|
assert message.audio is True
|
||||||
|
|
||||||
|
def test_snake_case_is_accepted_too_so_server_side_tools_can_replay_frames(self):
|
||||||
|
message = protocol.parse_client_message(
|
||||||
|
{"type": "publish", "display_surface": "monitor", "width": 800, "height": 600}
|
||||||
|
)
|
||||||
|
assert message.display_surface == "monitor"
|
||||||
|
|
||||||
|
def test_subscribe_carries_a_camel_case_stream_id(self):
|
||||||
|
assert (
|
||||||
|
protocol.parse_client_message({"type": "subscribe", "streamId": "p1"}).stream_id == "p1"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
protocol.parse_client_message({"type": "unsubscribe", "streamId": "p1"}).stream_id
|
||||||
|
== "p1"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_missing_stream_id_is_a_validation_error_not_a_none(self):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
protocol.parse_client_message({"type": "subscribe"})
|
||||||
|
|
||||||
|
def test_dimensions_are_bounded(self):
|
||||||
|
for bad in (-1, 16385, "wide"):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
protocol.parse_client_message({"type": "publish", "width": bad})
|
||||||
|
|
||||||
|
def test_unknown_message_types_are_rejected(self):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
protocol.parse_client_message({"type": "teleport"})
|
||||||
|
|
||||||
|
def test_extra_keys_are_tolerated_for_forward_compatibility(self):
|
||||||
|
message = protocol.parse_client_message({"type": "ping", "someDayWeAddThis": True})
|
||||||
|
assert message.type == "ping"
|
||||||
|
|
||||||
|
def test_a_signal_payload_is_carried_verbatim(self):
|
||||||
|
payload = {"sdp": {"type": "offer", "sdp": "v=0\r\n"}}
|
||||||
|
message = protocol.parse_client_message({"type": "signal", "to": "p2", "payload": payload})
|
||||||
|
assert message.payload == payload
|
||||||
|
|
||||||
|
|
||||||
|
class TestOutbound:
|
||||||
|
def test_the_stream_dto_is_camel_case(self):
|
||||||
|
dto = protocol.stream_dto(
|
||||||
|
stream_id="p1",
|
||||||
|
sharer_id="p1",
|
||||||
|
name="Tower",
|
||||||
|
label="Terminal",
|
||||||
|
display_surface="window",
|
||||||
|
width=1280,
|
||||||
|
height=720,
|
||||||
|
audio=False,
|
||||||
|
viewers=2,
|
||||||
|
)
|
||||||
|
assert set(dto) == {
|
||||||
|
"id",
|
||||||
|
"sharerId",
|
||||||
|
"name",
|
||||||
|
"label",
|
||||||
|
"displaySurface",
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"audio",
|
||||||
|
"viewers",
|
||||||
|
}
|
||||||
|
assert dto["displaySurface"] == "window"
|
||||||
|
|
||||||
|
def test_every_server_message_advertises_its_type(self):
|
||||||
|
messages = [
|
||||||
|
protocol.welcome(peer_id="p1", role="viewer", streams=[]),
|
||||||
|
protocol.stream_added(stream={"id": "p1"}),
|
||||||
|
protocol.stream_updated(stream={"id": "p1"}),
|
||||||
|
protocol.stream_removed(stream_id="p1"),
|
||||||
|
protocol.peer_joined(stream_id="p1", viewer_id="p2", name="V"),
|
||||||
|
protocol.peer_left(stream_id="p1", viewer_id="p2"),
|
||||||
|
protocol.relayed_signal(frm="p1", to="p2", payload={}),
|
||||||
|
protocol.error(code="x", message="y"),
|
||||||
|
protocol.pong(),
|
||||||
|
]
|
||||||
|
assert [m["type"] for m in messages] == [
|
||||||
|
"welcome",
|
||||||
|
"stream_added",
|
||||||
|
"stream_updated",
|
||||||
|
"stream_removed",
|
||||||
|
"peer_joined",
|
||||||
|
"peer_left",
|
||||||
|
"signal",
|
||||||
|
"error",
|
||||||
|
"pong",
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_an_error_says_whether_the_connection_survives_it(self):
|
||||||
|
assert protocol.error(code="x", message="y")["fatal"] is False
|
||||||
|
assert protocol.error(code="x", message="y", fatal=True)["fatal"] is True
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
"""Unit tests for the roster. No sockets, no async — pure policy."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mirror.registry import Registry, RegistryError
|
||||||
|
|
||||||
|
|
||||||
|
def by_target(outbox):
|
||||||
|
"""Group emitted envelopes by their destination peer id."""
|
||||||
|
grouped: dict[str, list[dict]] = {}
|
||||||
|
for peer_id, message in outbox:
|
||||||
|
grouped.setdefault(peer_id, []).append(message)
|
||||||
|
return grouped
|
||||||
|
|
||||||
|
|
||||||
|
def types_for(outbox, peer_id):
|
||||||
|
return [m["type"] for m in by_target(outbox).get(peer_id, [])]
|
||||||
|
|
||||||
|
|
||||||
|
def message_for(outbox, peer_id, kind):
|
||||||
|
for message in by_target(outbox).get(peer_id, []):
|
||||||
|
if message["type"] == kind:
|
||||||
|
return message
|
||||||
|
raise AssertionError(f"no {kind!r} for {peer_id!r} in {by_target(outbox)}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mesh(registry):
|
||||||
|
"""One sharer, two viewers, and the sharer already mirroring."""
|
||||||
|
sharer, _ = registry.join(role="sharer", name="Linux Box")
|
||||||
|
v1, _ = registry.join(role="viewer", name="Phone")
|
||||||
|
v2, _ = registry.join(role="viewer", name="Tablet")
|
||||||
|
registry.publish(sharer, label="Terminal", width=1280, height=720)
|
||||||
|
return sharer, v1, v2, registry
|
||||||
|
|
||||||
|
|
||||||
|
class TestJoin:
|
||||||
|
def test_viewer_gets_an_empty_roster(self, registry):
|
||||||
|
peer_id, outbox = registry.join(role="viewer", name="Phone")
|
||||||
|
welcome = message_for(outbox, peer_id, "welcome")
|
||||||
|
assert welcome["peerId"] == peer_id
|
||||||
|
assert welcome["role"] == "viewer"
|
||||||
|
assert welcome["streams"] == []
|
||||||
|
|
||||||
|
def test_ids_are_unique_and_sequential_when_injected(self, registry):
|
||||||
|
first, _ = registry.join(role="viewer")
|
||||||
|
second, _ = registry.join(role="viewer")
|
||||||
|
assert (first, second) == ("p1", "p2")
|
||||||
|
|
||||||
|
def test_generated_ids_do_not_collide(self):
|
||||||
|
registry = Registry(id_factory=lambda: "same")
|
||||||
|
registry.join(role="viewer")
|
||||||
|
with pytest.raises(RegistryError, match="out of peer ids"):
|
||||||
|
registry.join(role="viewer")
|
||||||
|
|
||||||
|
def test_default_names_are_useful(self, registry):
|
||||||
|
sharer, _ = registry.join(role="sharer")
|
||||||
|
viewer, _ = registry.join(role="viewer")
|
||||||
|
assert registry.peers[sharer].name == "This device"
|
||||||
|
assert registry.peers[viewer].name == "Viewer"
|
||||||
|
|
||||||
|
def test_names_are_sanitised_and_capped(self, registry):
|
||||||
|
peer_id, _ = registry.join(role="sharer", name=" a\n\t b " + "x" * 80)
|
||||||
|
name = registry.peers[peer_id].name
|
||||||
|
assert name.startswith("a b ")
|
||||||
|
assert len(name) == 48
|
||||||
|
assert "\n" not in name and " " not in name
|
||||||
|
|
||||||
|
def test_unknown_role_is_fatal(self, registry):
|
||||||
|
with pytest.raises(RegistryError) as excinfo:
|
||||||
|
registry.join(role="spectator")
|
||||||
|
assert excinfo.value.code == "bad_role"
|
||||||
|
assert excinfo.value.fatal is True
|
||||||
|
|
||||||
|
def test_a_late_viewer_sees_existing_mirrors(self, mesh):
|
||||||
|
sharer, _v1, _v2, registry = mesh
|
||||||
|
late, outbox = registry.join(role="viewer", name="Late")
|
||||||
|
roster = message_for(outbox, late, "welcome")["streams"]
|
||||||
|
assert [s["id"] for s in roster] == [sharer]
|
||||||
|
assert roster[0]["label"] == "Terminal"
|
||||||
|
assert roster[0]["viewers"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestPublish:
|
||||||
|
def test_viewers_are_told_and_other_sharers_are_not(self, registry):
|
||||||
|
sharer, _ = registry.join(role="sharer", name="A")
|
||||||
|
other_sharer, _ = registry.join(role="sharer", name="B")
|
||||||
|
viewer, _ = registry.join(role="viewer", name="V")
|
||||||
|
outbox = registry.publish(sharer, label="Terminal", width=800, height=600)
|
||||||
|
assert types_for(outbox, viewer) == ["stream_added"]
|
||||||
|
assert by_target(outbox).get(other_sharer) is None
|
||||||
|
added = message_for(outbox, viewer, "stream_added")["stream"]
|
||||||
|
assert added == {
|
||||||
|
"id": sharer,
|
||||||
|
"sharerId": sharer,
|
||||||
|
"name": "A",
|
||||||
|
"label": "Terminal",
|
||||||
|
"displaySurface": "",
|
||||||
|
"width": 800,
|
||||||
|
"height": 600,
|
||||||
|
"audio": False,
|
||||||
|
"viewers": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_a_viewer_cannot_publish(self, registry):
|
||||||
|
viewer, _ = registry.join(role="viewer")
|
||||||
|
with pytest.raises(RegistryError) as excinfo:
|
||||||
|
registry.publish(viewer)
|
||||||
|
assert excinfo.value.code == "not_sharer"
|
||||||
|
assert excinfo.value.fatal is True
|
||||||
|
|
||||||
|
def test_republish_updates_in_place(self, registry):
|
||||||
|
sharer, _ = registry.join(role="sharer", name="A")
|
||||||
|
registry.join(role="viewer")
|
||||||
|
registry.publish(sharer, label="First", width=800, height=600)
|
||||||
|
registry.publish(sharer, label="Second", width=1920, height=1080, audio=True)
|
||||||
|
assert len(registry.streams) == 1
|
||||||
|
assert registry.roster()[0]["label"] == "Second"
|
||||||
|
assert registry.roster()[0]["audio"] is True
|
||||||
|
|
||||||
|
def test_unchanged_republish_does_not_re_offer_to_viewers(self, mesh):
|
||||||
|
sharer, v1, _v2, registry = mesh
|
||||||
|
registry.subscribe(v1, sharer)
|
||||||
|
outbox = registry.publish(sharer, label="Terminal", width=1280, height=720)
|
||||||
|
# The viewer already has a peer connection; nothing to renegotiate.
|
||||||
|
assert types_for(outbox, v1) == ["stream_updated"]
|
||||||
|
assert types_for(outbox, sharer) == ["stream_updated"]
|
||||||
|
|
||||||
|
def test_changed_republish_asks_for_a_new_offer_per_viewer(self, mesh):
|
||||||
|
sharer, v1, v2, registry = mesh
|
||||||
|
registry.subscribe(v1, sharer)
|
||||||
|
registry.subscribe(v2, sharer)
|
||||||
|
outbox = registry.publish(sharer, label="Terminal", width=1920, height=1080)
|
||||||
|
assert types_for(outbox, v1).count("peer_joined") == 1
|
||||||
|
assert types_for(outbox, v2).count("peer_joined") == 1
|
||||||
|
assert message_for(outbox, v1, "peer_joined")["streamId"] == sharer
|
||||||
|
|
||||||
|
def test_unpublish_clears_the_tile_for_every_viewer(self, mesh):
|
||||||
|
"""A tile is drawn on `stream_added`, so every viewer must be told it is gone —
|
||||||
|
including one that had not subscribed yet."""
|
||||||
|
sharer, v1, v2, registry = mesh
|
||||||
|
registry.subscribe(v1, sharer)
|
||||||
|
outbox = registry.unpublish(sharer)
|
||||||
|
assert types_for(outbox, v1) == ["stream_removed"]
|
||||||
|
assert types_for(outbox, v2) == ["stream_removed"]
|
||||||
|
assert registry.streams == {}
|
||||||
|
assert registry.watching(v1) == set()
|
||||||
|
|
||||||
|
def test_dropping_a_mirror_leaves_other_mirrors_alone(self, registry):
|
||||||
|
first, _ = registry.join(role="sharer", name="A")
|
||||||
|
second, _ = registry.join(role="sharer", name="B")
|
||||||
|
viewer, _ = registry.join(role="viewer")
|
||||||
|
registry.publish(first, label="one")
|
||||||
|
registry.publish(second, label="two")
|
||||||
|
registry.subscribe(viewer, second)
|
||||||
|
outbox = registry.unpublish(first)
|
||||||
|
assert [m["streamId"] for m in by_target(outbox)[viewer]] == [first]
|
||||||
|
assert [s["label"] for s in registry.roster()] == ["two"]
|
||||||
|
assert registry.watching(viewer) == {second}
|
||||||
|
|
||||||
|
def test_unpublish_without_a_stream_is_a_no_op(self, registry):
|
||||||
|
sharer, _ = registry.join(role="sharer")
|
||||||
|
assert registry.unpublish(sharer) == []
|
||||||
|
|
||||||
|
def test_capacity_blocks_a_new_mirror_but_not_an_existing_one(self):
|
||||||
|
registry = Registry(max_streams=1)
|
||||||
|
first, _ = registry.join(role="sharer", name="A")
|
||||||
|
second, _ = registry.join(role="sharer", name="B")
|
||||||
|
viewer, _ = registry.join(role="viewer")
|
||||||
|
registry.publish(first, label="one")
|
||||||
|
with pytest.raises(RegistryError) as excinfo:
|
||||||
|
registry.publish(second, label="two")
|
||||||
|
assert excinfo.value.code == "capacity"
|
||||||
|
assert excinfo.value.fatal is True
|
||||||
|
# The incumbent may keep refreshing its own mirror.
|
||||||
|
registry.publish(first, label="one", width=1920, height=1080)
|
||||||
|
assert [s["label"] for s in registry.roster()] == ["one"]
|
||||||
|
# Capacity counts mirrors; an audience never uses it up.
|
||||||
|
assert registry.stats()["viewers"] == 1
|
||||||
|
assert registry.watching(viewer) == set()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSubscribe:
|
||||||
|
def test_sharer_is_told_to_negotiate(self, mesh):
|
||||||
|
sharer, v1, _v2, registry = mesh
|
||||||
|
outbox = registry.subscribe(v1, sharer)
|
||||||
|
joined = message_for(outbox, sharer, "peer_joined")
|
||||||
|
assert joined["viewerId"] == v1
|
||||||
|
assert joined["name"] == "Phone"
|
||||||
|
assert message_for(outbox, v1, "stream_updated")["stream"]["viewers"] == 1
|
||||||
|
|
||||||
|
def test_subscribing_twice_is_idempotent(self, mesh):
|
||||||
|
sharer, v1, _v2, registry = mesh
|
||||||
|
registry.subscribe(v1, sharer)
|
||||||
|
assert registry.subscribe(v1, sharer) == []
|
||||||
|
assert registry.streams[sharer].viewers == {v1}
|
||||||
|
|
||||||
|
def test_unknown_stream(self, registry):
|
||||||
|
viewer, _ = registry.join(role="viewer")
|
||||||
|
with pytest.raises(RegistryError) as excinfo:
|
||||||
|
registry.subscribe(viewer, "nope")
|
||||||
|
assert excinfo.value.code == "unknown_stream"
|
||||||
|
|
||||||
|
def test_a_sharer_cannot_even_watch_itself(self, mesh):
|
||||||
|
sharer, _v1, _v2, registry = mesh
|
||||||
|
with pytest.raises(RegistryError) as excinfo:
|
||||||
|
registry.subscribe(sharer, sharer)
|
||||||
|
assert excinfo.value.code == "not_viewer"
|
||||||
|
|
||||||
|
def test_a_sharer_cannot_subscribe_like_a_viewer(self, mesh):
|
||||||
|
sharer, _v1, _v2, registry = mesh
|
||||||
|
other, _ = registry.join(role="sharer", name="B")
|
||||||
|
registry.publish(other, label="B's screen")
|
||||||
|
with pytest.raises(RegistryError) as excinfo:
|
||||||
|
registry.subscribe(sharer, other)
|
||||||
|
assert excinfo.value.code == "not_viewer"
|
||||||
|
|
||||||
|
def test_unsubscribe_closes_the_leg(self, mesh):
|
||||||
|
sharer, v1, v2, registry = mesh
|
||||||
|
registry.subscribe(v1, sharer)
|
||||||
|
registry.subscribe(v2, sharer)
|
||||||
|
outbox = registry.unsubscribe(v1, sharer)
|
||||||
|
assert message_for(outbox, sharer, "peer_left")["viewerId"] == v1
|
||||||
|
assert message_for(outbox, v2, "stream_updated")["stream"]["viewers"] == 1
|
||||||
|
assert registry.unsubscribe(v1, sharer) == []
|
||||||
|
|
||||||
|
def test_unsubscribing_from_an_unknown_stream_is_quiet(self, registry):
|
||||||
|
viewer, _ = registry.join(role="viewer")
|
||||||
|
assert registry.unsubscribe(viewer, "ghost") == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestSignalAuthorization:
|
||||||
|
def test_sharer_may_reach_a_subscribed_viewer(self, mesh):
|
||||||
|
sharer, v1, _v2, registry = mesh
|
||||||
|
registry.subscribe(v1, sharer)
|
||||||
|
outbox = registry.signal(sharer, v1, {"sdp": {"type": "offer"}})
|
||||||
|
relay = message_for(outbox, v1, "signal")
|
||||||
|
assert relay["from"] == sharer
|
||||||
|
assert relay["payload"] == {"sdp": {"type": "offer"}}
|
||||||
|
|
||||||
|
def test_viewer_may_answer_the_sharer_it_watches(self, mesh):
|
||||||
|
sharer, v1, _v2, registry = mesh
|
||||||
|
registry.subscribe(v1, sharer)
|
||||||
|
assert registry.signal(v1, sharer, {"candidate": {"candidate": "x"}}) == [
|
||||||
|
(
|
||||||
|
sharer,
|
||||||
|
{
|
||||||
|
"type": "signal",
|
||||||
|
"from": v1,
|
||||||
|
"to": sharer,
|
||||||
|
"payload": {"candidate": {"candidate": "x"}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_sharer_may_not_blindly_push_to_a_stranger(self, mesh):
|
||||||
|
sharer, _v1, v2, registry = mesh
|
||||||
|
with pytest.raises(RegistryError) as excinfo:
|
||||||
|
registry.signal(sharer, v2, {"sdp": {"type": "offer"}})
|
||||||
|
assert excinfo.value.code == "not_subscribed"
|
||||||
|
|
||||||
|
def test_viewer_may_not_reach_a_sharer_it_never_subscribed_to(self, mesh):
|
||||||
|
_sharer, v1, _v2, registry = mesh
|
||||||
|
other, _ = registry.join(role="sharer", name="C")
|
||||||
|
registry.publish(other, label="private")
|
||||||
|
with pytest.raises(RegistryError) as excinfo:
|
||||||
|
registry.signal(v1, other, {})
|
||||||
|
assert excinfo.value.code == "not_subscribed"
|
||||||
|
|
||||||
|
def test_peers_may_not_talk_to_each_other_directly(self, mesh):
|
||||||
|
_sharer, v1, v2, registry = mesh
|
||||||
|
with pytest.raises(RegistryError) as excinfo:
|
||||||
|
registry.signal(v1, v2, {})
|
||||||
|
assert excinfo.value.code == "peer_to_peer"
|
||||||
|
|
||||||
|
def test_unknown_endpoints_are_refused(self, mesh):
|
||||||
|
sharer, _v1, _v2, registry = mesh
|
||||||
|
with pytest.raises(RegistryError) as excinfo:
|
||||||
|
registry.signal(sharer, "ghost", {})
|
||||||
|
assert excinfo.value.code == "unknown_peer"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTeardown:
|
||||||
|
def test_sharer_disconnect_removes_the_mirror(self, mesh):
|
||||||
|
sharer, v1, v2, registry = mesh
|
||||||
|
registry.subscribe(v1, sharer)
|
||||||
|
registry.subscribe(v2, sharer)
|
||||||
|
outbox = registry.leave(sharer)
|
||||||
|
assert types_for(outbox, v1) == ["stream_removed"]
|
||||||
|
assert types_for(outbox, v2) == ["stream_removed"]
|
||||||
|
assert registry.streams == {}
|
||||||
|
assert registry.watching(v1) == set()
|
||||||
|
|
||||||
|
def test_viewer_disconnect_releases_the_sharer(self, mesh):
|
||||||
|
sharer, v1, v2, registry = mesh
|
||||||
|
registry.subscribe(v1, sharer)
|
||||||
|
registry.subscribe(v2, sharer)
|
||||||
|
outbox = registry.leave(v1)
|
||||||
|
assert message_for(outbox, sharer, "peer_left")["viewerId"] == v1
|
||||||
|
assert registry.streams[sharer].viewers == {v2}
|
||||||
|
|
||||||
|
def test_leaving_twice_is_quiet(self, mesh):
|
||||||
|
_sharer, v1, _v2, registry = mesh
|
||||||
|
registry.leave(v1)
|
||||||
|
assert registry.leave(v1) == []
|
||||||
|
|
||||||
|
def test_sharer_disconnect_after_a_crash_still_cleans_subscriptions(self, mesh):
|
||||||
|
sharer, v1, _v2, registry = mesh
|
||||||
|
registry.subscribe(v1, sharer)
|
||||||
|
registry.leave(sharer)
|
||||||
|
# A stale viewer must not be able to signal the dead sharer.
|
||||||
|
with pytest.raises(RegistryError):
|
||||||
|
registry.signal(v1, sharer, {})
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenameAndStats:
|
||||||
|
def test_rename_refreshes_the_mirrors_that_show_it(self, mesh):
|
||||||
|
sharer, v1, _v2, registry = mesh
|
||||||
|
outbox = registry.rename(sharer, name="Meeting Room")
|
||||||
|
assert message_for(outbox, v1, "stream_updated")["stream"]["name"] == "Meeting Room"
|
||||||
|
assert registry.roster()[0]["name"] == "Meeting Room"
|
||||||
|
|
||||||
|
def test_rename_to_the_same_name_is_quiet(self, mesh):
|
||||||
|
sharer, _v1, _v2, registry = mesh
|
||||||
|
assert registry.rename(sharer, name="Linux Box") == []
|
||||||
|
|
||||||
|
def test_stats_count_every_role(self, mesh):
|
||||||
|
sharer, v1, _v2, registry = mesh
|
||||||
|
registry.subscribe(v1, sharer)
|
||||||
|
assert registry.stats() == {
|
||||||
|
"mirrors": 1,
|
||||||
|
"sharers": 1,
|
||||||
|
"viewers": 2,
|
||||||
|
"connections": 3,
|
||||||
|
"watching": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_roster_is_oldest_first(self, registry):
|
||||||
|
first, _ = registry.join(role="sharer", name="first")
|
||||||
|
registry.publish(first, label="first")
|
||||||
|
second, _ = registry.join(role="sharer", name="second")
|
||||||
|
registry.publish(second, label="second")
|
||||||
|
assert [s["label"] for s in registry.roster()] == ["first", "second"]
|
||||||
Reference in New Issue
Block a user