**Phase 08 final verification pass — complete, all green.**
**Verified (all 5 tasks already implemented):**
- `static/wall.js` (`createWall` + `installWallKeys`), `kiosk.html`/`kiosk.js`, `GET /kiosk` route — guards: no `$("#…")` in wall.js, one keydown listener, kiosk ids no-collision across 3 pages, no audio field in `arrange`, no header/share link in markup, address gate covers `/kiosk`
- Cross-page e2e: arrival-arrangement, keypress-on-one-display moves the other (asserted on the second display's decoded moving pixels), dead mirror → `data-layout="wall"` on both, `followers` count, pointer-free meta-test
**Gates (exact outcomes):**
- `uv run ruff check . && uv run ruff format --check .` — passed
- `uv run pyright` — 0 errors
- `uv run pytest` — 341 passed, 5 skipped (realdisplay-gated), 96s
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — 4 passed, 1 skipped (documented: host has no audio source for real capture)
- Coverage 96% (>90%); only warning is a starlette/httpx third-party deprecation
**Completion criteria:** all 9 ✅ — e2e green incl. `test_kiosk_layout.py` (the one e2e diff is the phase-mandated address-gate extension to `/kiosk`; assertion logic untouched), full suite 100% passing, README documents `/kiosk`, PLAN.md untouched.
**Documentation:**
- README: fixed the Kiosk-client section that implied the display box is permanently pointed at `/watch` — now states the display page is `/kiosk` with the runbook's one-line re-point (installer default still `/watch` this phase, per docs). Rest of README verified against code (routes, config table, CLI flag, presets, key table) — consistent.
- AGENTS.md: fixed 2 stale statements — CDN test moved to `TestPages` (was `TestClientScripts`), suite runtime ~50s → ~95s. No content added.
**Notable:** PLAN.md §6 says "per-leg `maxBitrate` cap (default 12 Mbit/s)" but code/README default is 25 Mbit/s — pre-existing, left untouched (locked file; flagged for a user decision).
**Next pending phase:** `09_wall_remote_page`.
570 lines
25 KiB
Python
570 lines
25 KiB
Python
"""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
|
|
from typing import ClassVar
|
|
|
|
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_the_screen_sized_request_does_not_break_the_fake_device(self, device):
|
|
"""Asking for screen x DPR (capped 3840x2160) must not break the fake
|
|
device's path.
|
|
|
|
The old "always 1280x720" expectation held only while the request
|
|
carried no size. Once a size is *named*, the fake UI clamps the capture
|
|
to the fake browser window (1100x720 context -> 1100x620 with chrome)
|
|
— PLAN §5's window scaling, even for the synthetic device. So the
|
|
assertion here is: the share starts, the viewer decodes exactly what
|
|
the sharer's delivered settings are, moving, and publish() reports
|
|
those delivered settings, not the request.
|
|
"""
|
|
errors: list = []
|
|
sharer = device("/mirror", errors)
|
|
start_sharing(sharer)
|
|
|
|
# What the page asked for vs what the fake device delivered.
|
|
reported = sharer.evaluate(
|
|
"""() => {
|
|
const track = document.querySelector('#preview').srcObject.getVideoTracks()[0];
|
|
const s = track.getSettings();
|
|
const dpr = window.devicePixelRatio || 1;
|
|
return {
|
|
requested: {
|
|
width: Math.min(Math.round(screen.width * dpr), 3840),
|
|
height: Math.min(Math.round(screen.height * dpr), 2160),
|
|
},
|
|
delivered: {width: s.width, height: s.height},
|
|
stat: document.querySelector('#stat-resolution').textContent,
|
|
};
|
|
}"""
|
|
)
|
|
assert reported["requested"] == {"width": 1100, "height": 720}, reported
|
|
# The full requested width is honoured; the height is clamped to the
|
|
# fake window's visible area (the 100px of chrome).
|
|
assert reported["delivered"]["width"] == 1100, reported
|
|
assert 0 < reported["delivered"]["height"] < 720, reported
|
|
|
|
# publish() reports the delivered settings, not the request.
|
|
# The page renders the separator as U+00D7 (multiplication sign).
|
|
w, h = reported["delivered"]["width"], reported["delivered"]["height"]
|
|
assert reported["stat"] == f"{w} \u00d7 {h}", reported
|
|
|
|
viewer = device("/watch", errors)
|
|
expect(viewer.locator(".tile[data-state='live']")).to_have_count(1, timeout=25_000)
|
|
frames = frames_matching(viewer, ".tile video")
|
|
assert (frames["width"], frames["height"]) == (w, h), (
|
|
f"the viewer must decode what the sharer reported: {frames} vs {reported}"
|
|
)
|
|
assert frames["distinct"] >= 2, f"the video is frozen: {frames}"
|
|
assert errors == [], f"uncaught browser errors: {errors}"
|
|
|
|
def test_the_fake_device_keeps_exactly_1280x720(self, base_url, browser, request, device):
|
|
"""The fake-device counterpart of the realdisplay sharpness floor.
|
|
|
|
With the sharer's window filling the fake 1280x720 monitor (a 1280x820
|
|
viewport: the ~100px of window chrome clamps the 820 request back to
|
|
720), the deterministic device delivers exactly 1280x720 — the classic
|
|
"always 1280x720" synthetic path. The phase 02 ``ideal`` size
|
|
constraints (screen x DPR, capped 3840x2160) must not change what the
|
|
synthetic path delivers: the viewer decodes exactly 1280x720, moving.
|
|
|
|
Measured on the reference host (phase 02, 2026-11-01): a 1280x820
|
|
sharer window requests 1280x820 and the fake device delivers exactly
|
|
1280x720; the viewer decodes 1280x720. (A 1100x720 window instead
|
|
delivers 1100x620 — the same window clamp, covered by the sibling
|
|
test.)
|
|
"""
|
|
errors: list = []
|
|
context = browser.new_context(viewport={"width": 1280, "height": 820})
|
|
request.addfinalizer(context.close)
|
|
sharer = context.new_page()
|
|
sharer.on("pageerror", lambda error: errors.append(str(error)))
|
|
sharer.goto(f"{base_url}/mirror", wait_until="domcontentloaded")
|
|
expect(sharer.locator("#net")).to_have_attribute("data-state", "online", timeout=10_000)
|
|
start_sharing(sharer)
|
|
|
|
delivered = sharer.evaluate(
|
|
"""() => {
|
|
const track = document.querySelector('#preview').srcObject.getVideoTracks()[0];
|
|
const s = track.getSettings();
|
|
return {width: s.width, height: s.height};
|
|
}"""
|
|
)
|
|
assert delivered == {"width": 1280, "height": 720}, delivered
|
|
# The page's own stats agree with the delivered settings.
|
|
expect(sharer.locator("#stat-resolution")).to_have_text("1280 \u00d7 720")
|
|
|
|
viewer = device("/watch", errors)
|
|
expect(viewer.locator(".tile[data-state='live']")).to_have_count(1, timeout=25_000)
|
|
frames = frames_matching(viewer, ".tile video")
|
|
assert (frames["width"], frames["height"]) == (1280, 720), (
|
|
f"the fake device must keep delivering exactly 1280x720: {frames}"
|
|
)
|
|
assert frames["distinct"] >= 2, f"the video is frozen: {frames}"
|
|
assert errors == [], f"uncaught browser errors: {errors}"
|
|
|
|
def test_the_staged_tile_bytes_keep_growing(self, base_url, browser, request, device):
|
|
"""Inbound-bitrate sanity on a staged tile: an observation, not an equality.
|
|
|
|
The viewer's ``inbound-rtp.bytesReceived`` must grow while a share is
|
|
live. With the default 25 Mbit/s per-leg cap, a live 1280x720 fake
|
|
share delivered ~126 KB over a ~2 s sample (≈ 0.5 Mbit/s) on the
|
|
reference host (phase 02, 2026-11-01) — the figure is
|
|
environment-dependent (codec, motion, cap), so the assertion is growth
|
|
from a non-zero start, never a specific number.
|
|
"""
|
|
context = browser.new_context(viewport={"width": 1100, "height": 720})
|
|
request.addfinalizer(context.close)
|
|
# Test scaffolding (AGENTS.md §6): collect the RTCPeerConnection
|
|
# instances watch.js creates so the test can read the viewer's
|
|
# inbound-rtp stats through the one allowed global, ``window.__probe``.
|
|
context.add_init_script(
|
|
"""
|
|
window.__probe = {pcs: []};
|
|
const RealPC = window.RTCPeerConnection;
|
|
window.RTCPeerConnection = function (config) {
|
|
const pc = new RealPC(config);
|
|
window.__probe.pcs.push(pc);
|
|
return pc;
|
|
};
|
|
window.RTCPeerConnection.prototype = RealPC.prototype;
|
|
window.RTCPeerConnection.static = RealPC.static;
|
|
"""
|
|
)
|
|
page = context.new_page()
|
|
page.goto(f"{base_url}/watch", wait_until="domcontentloaded")
|
|
expect(page.locator("#net")).to_have_attribute("data-state", "online", timeout=10_000)
|
|
|
|
sharer = device("/mirror")
|
|
start_sharing(sharer)
|
|
|
|
expect(page.locator(".tile[data-state='live']")).to_have_count(1, timeout=25_000)
|
|
# Take it fullscreen: the staged <video> is the same element moved onto
|
|
# the stage, so the peer connection — and its stats — survive.
|
|
page.locator(".tile").click()
|
|
expect(page.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
|
|
expect(page.locator("#watch-stage video")).to_have_count(1)
|
|
|
|
read_bytes = """async () => {
|
|
let total = 0;
|
|
for (const pc of window.__probe.pcs) {
|
|
try {
|
|
const stats = await pc.getStats();
|
|
stats.forEach((entry) => {
|
|
if (entry.type === 'inbound-rtp' && entry.kind === 'video') {
|
|
total += entry.bytesReceived || 0;
|
|
}
|
|
});
|
|
} catch { /* a closed pc has no stats; the live one does */ }
|
|
}
|
|
return total;
|
|
}"""
|
|
|
|
# Wait for the leg to actually be connected, then sample twice.
|
|
deadline = time.time() + 30
|
|
while time.time() < deadline:
|
|
states = page.evaluate("() => window.__probe.pcs.map(pc => pc.connectionState)")
|
|
if "connected" in states:
|
|
break
|
|
time.sleep(0.3)
|
|
first = page.evaluate(read_bytes)
|
|
assert first > 0, f"no bytes had arrived by the first sample: {first}"
|
|
time.sleep(2.0)
|
|
second = page.evaluate(read_bytes)
|
|
assert second > first, (
|
|
f"the viewer's inbound byte count did not grow while the share was "
|
|
f"live: {first} -> {second}"
|
|
)
|
|
|
|
def test_clicking_a_tile_takes_it_on_stage(self, device):
|
|
sharer = device("/mirror")
|
|
start_sharing(sharer)
|
|
|
|
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()
|
|
|
|
|
|
class TestStageSizing:
|
|
"""The stage has a floor, a ceiling, and an escape hatch.
|
|
|
|
``aspect-ratio: 16 / 10`` alone made the stage 180 px tall at 320 px wide
|
|
(the idle placeholder needed ~240 px there) and 543 px tall in a 480 px
|
|
window (the Share button ended up below the fold). These tests pin the
|
|
six shapes measured in ``00_phase.md``: at each one ``#start`` must be
|
|
fully inside *both* the stage box and the viewport — inside the stage or
|
|
``overflow: hidden`` clips it, and inside the viewport or a finger can
|
|
never reach it. The 1080p case pins the ceiling: the Session panel must
|
|
be reachable without scrolling past a 964 px preview.
|
|
"""
|
|
|
|
# The three clip cases (button clipped out of the stage) and the two
|
|
# below-the-fold cases (stage taller than the viewport), plus 360x640,
|
|
# the fourth measured narrow shape.
|
|
FAILURE_VIEWPORTS: ClassVar[list[tuple[int, int]]] = [
|
|
(320, 568),
|
|
(360, 640),
|
|
(390, 844),
|
|
(480, 800),
|
|
(900, 480),
|
|
(1280, 450),
|
|
]
|
|
|
|
STAGE_PROBE = """() => {
|
|
const s = document.querySelector('#preview-stage').getBoundingClientRect();
|
|
const r = document.querySelector('#start').getBoundingClientRect();
|
|
return {
|
|
stage_h: s.height,
|
|
stage_bottom: s.bottom,
|
|
start: {top: r.top, bottom: r.bottom, right: r.right},
|
|
in_stage: r.bottom <= s.bottom + 1 && r.top >= s.top - 1,
|
|
in_viewport: r.top >= 0 && r.bottom <= innerHeight && r.right <= innerWidth,
|
|
stage_in_viewport: s.bottom <= innerHeight + 1,
|
|
};
|
|
}"""
|
|
|
|
@pytest.mark.parametrize("viewport", FAILURE_VIEWPORTS)
|
|
def test_the_share_button_is_inside_the_stage_and_the_viewport(
|
|
self, base_url, browser, request, viewport
|
|
):
|
|
context = browser.new_context(viewport={"width": viewport[0], "height": viewport[1]})
|
|
request.addfinalizer(context.close)
|
|
page = context.new_page()
|
|
page.goto(f"{base_url}/mirror", wait_until="domcontentloaded")
|
|
expect(page.locator("#start")).to_be_visible(timeout=10_000)
|
|
result = page.evaluate(self.STAGE_PROBE)
|
|
message = f"{viewport[0]}x{viewport[1]}: {result}"
|
|
# The floor (264px, the measured idle placeholder + slack) and the
|
|
# ceiling (min(70svh, 640px)) must both hold: the box is never so
|
|
# short that the CTA clips out, and never so tall that it leaves
|
|
# the viewport.
|
|
assert result["stage_h"] >= 263, f"floor failed: {message}"
|
|
assert result["in_stage"], f"#start is clipped by the stage: {message}"
|
|
assert result["in_viewport"], f"#start is below the fold: {message}"
|
|
# The stage itself must also fit: on the two short-window shapes the
|
|
# aspect-ratio box used to run 543/564 px tall in a 480/450 px window,
|
|
# leaving only a scroll to reach the bottom of the page.
|
|
assert result["stage_in_viewport"], f"the stage itself overflows the viewport: {message}"
|
|
|
|
def test_the_ceiling_keeps_the_session_panel_reachable_at_1080p(
|
|
self, base_url, browser, request
|
|
):
|
|
"""1920x1080: the aspect ratio alone wanted a 964 px stage, which
|
|
pushed the Session panel off-screen. The 640 px ceiling must keep
|
|
the panel on screen — reachable without scrolling."""
|
|
context = browser.new_context(viewport={"width": 1920, "height": 1080})
|
|
request.addfinalizer(context.close)
|
|
page = context.new_page()
|
|
page.goto(f"{base_url}/mirror", wait_until="domcontentloaded")
|
|
expect(page.locator("#start")).to_be_visible(timeout=10_000)
|
|
result = page.evaluate(
|
|
"""() => {
|
|
const s = document.querySelector('#preview-stage').getBoundingClientRect();
|
|
const p = document.querySelector('.panel').getBoundingClientRect();
|
|
return {stage_h: s.height, stage_w: s.width, panel_bottom: p.bottom};
|
|
}"""
|
|
)
|
|
assert result["stage_h"] <= 641, (
|
|
f"the 640px ceiling failed (aspect ratio alone wanted 964px): {result}"
|
|
)
|
|
assert result["panel_bottom"] <= 1080, (
|
|
f"the Session panel is not reachable without scrolling: {result}"
|
|
)
|
|
|
|
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()
|
|
# Phase 05: the Exit button still exists on the staged tile (and is
|
|
# still absent from the grid), but the overlay is hidden at rest and
|
|
# pointer-transparent until the phase's activity reveal lands, so the
|
|
# button itself is not clickable yet. `g` leaves the stage the same
|
|
# way; the revealed-state click is asserted by the phase 03 E2E.
|
|
expect(viewer.locator("#watch-stage .exit")).to_be_visible()
|
|
|
|
viewer.keyboard.press("g")
|
|
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]
|
|
|
|
# The staged overlay is pointer-transparent at rest (phase 05), so
|
|
# `g` leaves the stage in place of the Exit click for now.
|
|
viewer.keyboard.press("g")
|
|
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")
|
|
|
|
|
|
class TestAddressGate:
|
|
"""`--allow-cidr` in a real process, refused by a real browser.
|
|
|
|
``tests/test_app.py::TestAddressGate`` covers the JSON refusal the pages key
|
|
their own notice on, the proxy unwrapping, and the socket closing before it
|
|
accepts. This covers what a person actually gets when a server refuses them:
|
|
an explanation naming the address it judged, not an empty wall that looks
|
|
like a bug in the tool.
|
|
"""
|
|
|
|
@pytest.mark.parametrize("path", ["/mirror", "/watch", "/kiosk"])
|
|
def test_a_refused_address_sees_why_and_none_of_the_app(self, browser, restricted_url, path):
|
|
context = browser.new_context(viewport={"width": 1100, "height": 720})
|
|
page = context.new_page()
|
|
try:
|
|
response = page.goto(f"{restricted_url}{path}")
|
|
assert response.status == 403
|
|
expect(page.locator("body")).to_contain_text("not open to your address")
|
|
expect(page.locator("body")).to_contain_text("203.0.113.0/24")
|
|
# Which address it judged — without this, "you are not allowed" in
|
|
# front of a proxy is undiagnosable.
|
|
expect(page.locator("body")).to_contain_text("127.0.0.1")
|
|
# None of the app shell leaked past the gate.
|
|
expect(page.locator("#start, #grid, #notice, #kiosk-grid, #kiosk-stage")).to_have_count(
|
|
0
|
|
)
|
|
finally:
|
|
context.close()
|
|
|
|
def test_the_health_probe_is_not_gated(self, restricted_url):
|
|
import httpx
|
|
|
|
assert httpx.get(f"{restricted_url}/healthz", timeout=5).status_code == 200
|