**Phase 09 — final verification pass (all 3 tasks already in `complete/`; phase work was in the working tree, verified, nothing needed fixing)**
**Gates** — `uv run ruff check . && uv run ruff format --check .` → clean; `uv run pyright` → 0 errors; `uv run pytest` → **360 passed, 5 skipped** (only the realdisplay rung), coverage 96% total / 95% app.py; `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 4 passed, 1 skipped (documented host limitation: no audio source on the real X11 path); `uv run pytest tests/e2e/test_remote_control.py` → 8/8 passed; `node --check` on every `static/*.js` → all pass.
**Completion criteria** — all met: (1) "Show on wall" stages on the display, asserted on display DOM + `/api/kiosk` ✓; (2) Back/Fullscreen + untouched second controller converge (TestConvergence) ✓; (3) `/api/streams` reports `viewers: 0` / `watching: 0` with only `/remote` open (TestACostsNothing) ✓; (4) followers count displays only — 0/1/1 with a second controller (TestFollowers) ✓; (5) controller works with no display: `followers: 0`, state stored (protocol test + e2e) ✓; (6) e2e + full `-m e2e` + realdisplay all green ✓; (7) README + SECURITY.md updated, Silverblue default now `/kiosk` (installer, conf, gnome script, checker) ✓; (8) suite 100% green, coverage >90% on new code ✓; (9) PLAN.md untouched; AGENTS.md changed only for a stale statement ✓.
**Documentation** — README.md reviewed in full against code (page table, quickstart, env-var table vs `config.py`/`debug.py`, presets, keys, deployment, kiosk section): no inconsistencies found. AGENTS.md: fixed the one stale statement introduced by this phase ("Four pages" → "Five pages"); no other staleness, nothing added.
**Decisions** — no repo changes were required; the one realdisplay skip is a pre-documented environment fact, not a failure.
**Next pending phase** — none; `todo/` held only phase 09.
1301 lines
63 KiB
Python
1301 lines
63 KiB
Python
"""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
|
|
from fastapi.testclient import TestClient
|
|
from starlette.websockets import WebSocketDisconnect
|
|
|
|
from mirror.access import parse_cidrs
|
|
from mirror.app import create_app
|
|
from mirror.config import Settings
|
|
|
|
STATIC = Path(__file__).resolve().parent.parent / "src/mirror/static"
|
|
|
|
|
|
def gated(*allow, client_address="203.0.113.9", **kwargs):
|
|
"""A client speaking as ``client_address`` to a server restricted to ``allow``."""
|
|
settings = Settings(allow_cidrs=parse_cidrs(",".join(allow)), **kwargs)
|
|
return TestClient(create_app(settings), client=(client_address, 44444))
|
|
|
|
|
|
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}"
|
|
|
|
|
|
def wait_for_update_with(socket, **stream_fields):
|
|
"""`wait_for` for `stream_updated` frames, matched on the stream payload.
|
|
|
|
`stream_updated` is pushed for audience changes too, so the test must
|
|
keep reading until the payload itself says what is being awaited (e.g.
|
|
the audio flag actually flipped)."""
|
|
seen = []
|
|
while True:
|
|
message = socket.receive_json()
|
|
if message.get("type") == "stream_updated" and all(
|
|
message["stream"].get(key) == value for key, value in stream_fields.items()
|
|
):
|
|
return message["stream"]
|
|
assert message.get("type") != "error", f"unexpected error: {message}"
|
|
seen.append(message)
|
|
assert len(seen) < 25, f"never saw stream_updated with {stream_fields}; got {seen}"
|
|
|
|
|
|
class TestPages:
|
|
@pytest.mark.parametrize(
|
|
("path", "marker"),
|
|
[
|
|
("/", "Watch everything"),
|
|
("/mirror", "Share my screen"),
|
|
("/share", "Share my screen"),
|
|
("/watch", "tile-template"),
|
|
("/kiosk", "kiosk-tile-template"),
|
|
("/remote", "remote-row-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
|
|
|
|
@pytest.mark.parametrize("path", ["/", "/mirror", "/share", "/watch", "/kiosk", "/remote"])
|
|
def test_pages_are_not_cacheable(self, client, path):
|
|
assert client.get(path).headers["cache-control"] == "no-store"
|
|
|
|
def test_the_two_pages_use_the_media_apis_they_claim(self):
|
|
# The watch page's media path lives in wall.js (phase 08): the page
|
|
# builds the wall, and the wall owns the RTCPeerConnection.
|
|
share = (STATIC / "share.js").read_text()
|
|
wall = (STATIC / "wall.js").read_text()
|
|
assert "getDisplayMedia" in share
|
|
assert "RTCPeerConnection" in share and "RTCPeerConnection" in wall
|
|
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", "wall.js", "kiosk.js", "remote.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", "wall.js", "kiosk.js", "remote.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"),
|
|
("kiosk.js", "kiosk.html"),
|
|
("remote.js", "remote.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_wall_never_looks_up_elements_in_the_document(self):
|
|
"""wall.js serves /watch and /kiosk (and phase 09's /remote) from one
|
|
module; an element lookup would resolve in whichever page happens to
|
|
load it. The elements come from the page instead, which is why the
|
|
element ids must not collide across pages (see
|
|
test_the_two_pages_share_no_element_ids_but_the_shared_chrome) — so
|
|
the module is guarded at the source level, here, next to the other
|
|
client-script guards."""
|
|
source = (STATIC / "wall.js").read_text()
|
|
offenders = re.findall(r"""\$\(['\"]#([A-Za-z0-9_-]+)['\"]\)""", source)
|
|
assert not offenders, f"wall.js must not query the document; found {offenders}"
|
|
|
|
def test_the_wall_key_router_is_one_listener_in_wall_js(self):
|
|
"""One listener, one switch, per wall page — phase 01's whole point.
|
|
|
|
`installWallKeys` (wall.js) is the single keydown listener a wall page
|
|
gets (/watch, /kiosk, phase 09's /remote): a second listener on the
|
|
same document would run the same key twice (j would move the cursor
|
|
twice) or race the first. The share page is not a wall page — its
|
|
s/x/Esc listener lives in share.js and is page policy — but a wall
|
|
page module must never add a keydown listener of its own, so a second
|
|
`addEventListener("keydown"` in a wall page module fails the build.
|
|
"""
|
|
wall = (STATIC / "wall.js").read_text()
|
|
assert "export function installWallKeys" in wall, (
|
|
"installWallKeys must be defined in wall.js (the one keydown listener)"
|
|
)
|
|
assert len(re.findall(r'addEventListener\(\s*["\']keydown["\']', wall)) == 1, (
|
|
"wall.js must hold exactly one keydown listener (the router)"
|
|
)
|
|
offenders = [
|
|
file.name
|
|
for file in sorted(STATIC.glob("*.js"))
|
|
if file.name not in ("share.js", "wall.js")
|
|
and re.search(r'addEventListener\(\s*["\']keydown["\']', file.read_text())
|
|
]
|
|
assert offenders == [], (
|
|
"a wall page module added its own keydown listener; the one listener "
|
|
f"per wall page lives in wall.js: {offenders}"
|
|
)
|
|
|
|
@staticmethod
|
|
def _object_literal_around(source, pos):
|
|
"""The object literal containing ``source[pos:``.
|
|
|
|
``pos`` points at a ``type: "arrange"`` key. Walking back, the nearest
|
|
``{`` that is not inside a deeper ``{...}`` span is the literal's start
|
|
— the literal itself when ``type`` is its first member, the enclosing
|
|
object when it is not. Forward brace matching then closes it.
|
|
"""
|
|
depth = 0
|
|
start = None
|
|
for i in range(pos - 1, -1, -1):
|
|
if source[i] == "}":
|
|
depth += 1
|
|
elif source[i] == "{":
|
|
if depth == 0:
|
|
start = i
|
|
break
|
|
depth -= 1
|
|
assert start is not None, "no object literal before the arrange key"
|
|
depth = 0
|
|
for i in range(start, len(source)):
|
|
if source[i] == "{":
|
|
depth += 1
|
|
elif source[i] == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return source[start : i + 1]
|
|
raise AssertionError("unbalanced braces around the arrange frame")
|
|
|
|
def test_kiosk_never_publishes_an_audio_field(self):
|
|
"""The layout has no audio field (SECURITY.md §4): a display must not
|
|
become audible because another machine asked, so ``kiosk.js`` may never
|
|
put a ``muted``/``audio`` field in an ``arrange`` frame. The rule is a
|
|
security rule, so it fails the build here instead of living in a
|
|
comment (phase 08 task 04). At least one ``arrange`` frame must exist:
|
|
the display is a first-class controller, and this guard must not pass
|
|
on a file that simply stopped publishing."""
|
|
source = (STATIC / "kiosk.js").read_text()
|
|
matches = list(re.finditer(r"type\s*:\s*[\"\']arrange[\"\']", source))
|
|
assert matches, "kiosk.js publishes no arrange frames at all"
|
|
for match in matches:
|
|
literal = self._object_literal_around(source, match.start())
|
|
assert not re.search(r"\b(muted|audio)\s*:", literal), (
|
|
f"an arrange frame carries an audio field: {literal}"
|
|
)
|
|
|
|
def test_a_controller_is_media_free(self):
|
|
"""The /remote page shows no video and subscribes to nothing (PLAN.md §6).
|
|
|
|
The mesh gives every sharer one upload per connected leg, so a controller
|
|
that consumed a leg just to know what to point at would add an upload to
|
|
every sharer for every controller open. The promise is enforced at the
|
|
source level so a well-meant future "preview thumbnail" fails the build
|
|
instead of being caught in review: `remote.js` may never construct a media
|
|
connection or send a subscribe frame, may never hold a media stream or
|
|
read the screen-capture APIs, and `remote.html` may never embed a
|
|
`<video>`.
|
|
"""
|
|
source = (STATIC / "remote.js").read_text()
|
|
assert "RTCPeerConnection" not in source, (
|
|
"remote.js must not open a media connection: a controller that streams "
|
|
"costs every sharer a leg (PLAN.md §6)"
|
|
)
|
|
assert "MediaStream" not in source, (
|
|
"remote.js must not hold a media stream: the controller keeps a list, "
|
|
"not tiles — a preview is the regression this guard exists for"
|
|
)
|
|
assert "navigator.mediaDevices" not in source, (
|
|
"remote.js must not read the screen-capture APIs: the controller never "
|
|
"asks the browser for a surface"
|
|
)
|
|
assert 'type: "subscribe"' not in source and "type:'subscribe'" not in source, (
|
|
"remote.js must not send a subscribe frame: a controller subscribes to nothing"
|
|
)
|
|
html = (STATIC / "remote.html").read_text()
|
|
assert "<video" not in html, (
|
|
"remote.html must not embed a <video>: the controller shows no media"
|
|
)
|
|
|
|
def test_a_controller_sends_exactly_one_frame_type_besides_hello(self):
|
|
"""The controller's whole job is the `arrange` frame (plus the hello that
|
|
introduces it): the file must actually send one, and must not send any
|
|
other roster frame. The presence half matters: a guard that only bans
|
|
strings would pass on a controller whose buttons had gone dead."""
|
|
source = (STATIC / "remote.js").read_text()
|
|
assert 'type: "arrange"' in source, (
|
|
"remote.js sends no arrange frame: the controller's buttons are dead"
|
|
)
|
|
assert 'type: "subscribe"' not in source and "type:'subscribe'" not in source, (
|
|
"remote.js must not send a subscribe frame: a controller subscribes to nothing"
|
|
)
|
|
|
|
def test_the_remote_page_declares_its_control_hooks(self):
|
|
"""The readout, the buttons and the list are the contract task 02 and the
|
|
e2e suite code against; assert them on the markup so task 01's stub and
|
|
task 02's page cannot drift (the element guard covers the script→page
|
|
direction once remote.js looks them up)."""
|
|
html = (STATIC / "remote.html").read_text()
|
|
for element_id in (
|
|
"remote-state",
|
|
"remote-staged",
|
|
"remote-fullscreen-mark",
|
|
"remote-followers",
|
|
"remote-back",
|
|
"remote-fullscreen",
|
|
"remote-list",
|
|
"remote-row-template",
|
|
"remote-empty",
|
|
"remote-share-url",
|
|
"remote-hint",
|
|
):
|
|
assert f'id="{element_id}"' in html, f"remote.html is missing #{element_id}"
|
|
|
|
def test_the_pages_load_the_stylesheet_and_their_module(self):
|
|
for page, script in (
|
|
("mirror.html", "share.js"),
|
|
("watch.html", "watch.js"),
|
|
("kiosk.html", "kiosk.js"),
|
|
("remote.html", "remote.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_share_never_asks_for_an_exact_capture_size(self):
|
|
"""A hard size breaks pickers: `exact` fails or hides picker entries on
|
|
displays/DPRs the browser cannot honour. The size request must stay a
|
|
preference the browser can decline while still capturing."""
|
|
source = (STATIC / "share.js").read_text()
|
|
assert not re.search(r"\bexact\s*:", source), (
|
|
"share.js must not use an `exact` constraint key"
|
|
)
|
|
|
|
def test_server_and_client_agree_on_the_bitrate_default(self):
|
|
"""`/api/config` advertises the server's per-leg cap; a browser that cannot
|
|
reach it falls back to the hardcoded literal in `net.js`. If the two drift,
|
|
offline clients silently get a different picture quality and nothing fails.
|
|
One number, two places, one test that notices."""
|
|
server = int(
|
|
re.search(
|
|
r"DEFAULT_MAX_BITRATE_BPS\s*=\s*(\d[\d_]*)",
|
|
(STATIC.parent / "config.py").read_text(),
|
|
)
|
|
.group(1)
|
|
.replace("_", "")
|
|
)
|
|
client = int(re.search(r"maxBitrateBps:\s*(\d+)", (STATIC / "net.js").read_text()).group(1))
|
|
assert server == client, (
|
|
f"the server default ({server}) and the net.js fallback ({client}) "
|
|
"have drifted: a client that cannot reach /api/config would use "
|
|
"a different bitrate silently"
|
|
)
|
|
|
|
def test_the_quality_options_are_the_three_presets(self):
|
|
"""`#quality` (mirror.html) and `PRESETS` (share.js) are one list in two places.
|
|
|
|
If the option values drift from the table keys, a preset the panel offers has
|
|
no row (or an orphan row the panel can never select), and `currentPreset()`
|
|
falls back to `sharp` instead of noticing: the sharer gets a wall they did not
|
|
ask for and nothing fails. One list, two places, one test that notices (the
|
|
same shape as the bitrate-default test)."""
|
|
html = (STATIC / "mirror.html").read_text()
|
|
select = re.search(r'<select id="quality"[^>]*>.*?</select>', html, re.S)
|
|
assert select, "mirror.html must render a #quality select"
|
|
options = set(re.findall(r'value="([A-Za-z0-9_-]+)"', select.group(0)))
|
|
|
|
source = (STATIC / "share.js").read_text()
|
|
table = re.search(r"const PRESETS = Object\.freeze\(\{(.*?)\}\);", source, re.S)
|
|
assert table, "share.js must define the PRESETS table"
|
|
# Top-level keys sit two spaces in; the fields inside a preset sit four
|
|
# spaces in, so anchoring on exactly two spaces selects the keys only.
|
|
keys = set(re.findall(r"^ ([a-z]+): \{$", table.group(1), re.M))
|
|
|
|
expected = {"sharp", "balanced", "light"}
|
|
assert options == expected, f"#quality offers {sorted(options)}, not {sorted(expected)}"
|
|
assert keys == expected, f"PRESETS keys are {sorted(keys)}, not {sorted(expected)}"
|
|
assert options == keys, "the select offers presets the table cannot resolve"
|
|
|
|
def test_sender_quality_is_reapplied_on_negotiation_and_connection(self):
|
|
"""`setParameters` is per-negotiation: a re-offer (a surface switch,
|
|
a viewer that re-joined) resets the encodings. The preset's cap and
|
|
degradation preference must therefore be re-applied after
|
|
`setLocalDescription` *and* when the leg becomes `connected`. Both
|
|
call sites are asserted so a future edit cannot drop one and silently
|
|
soften the wall. The preference itself stays a single, named
|
|
decision — the PRESETS table's `degradation` field, read at call
|
|
time — so a hardcoded literal at the call site would be a second,
|
|
drifting decision."""
|
|
source = (STATIC / "share.js").read_text()
|
|
assert re.search(r'degradationPreference\s*=\s*"', source) is None, (
|
|
"a hardcoded degradation literal at a call site is a second decision"
|
|
)
|
|
assert "params.degradationPreference = PRESETS[currentPreset()].degradation" in source, (
|
|
"the preference is not read from the PRESETS table at call time"
|
|
)
|
|
assert "async function applySenderQuality" in source
|
|
|
|
def section(start: str, stop: str) -> str:
|
|
begin = source.index(start)
|
|
return source[begin : source.index(stop, begin)]
|
|
|
|
negotiate = section("async function negotiate(viewerId)", "async function onSignal")
|
|
assert "applySenderQuality" in negotiate, (
|
|
"the cap is not re-applied after setLocalDescription"
|
|
)
|
|
assert negotiate.index("setLocalDescription") < negotiate.index("applySenderQuality"), (
|
|
"the cap is applied before the encodings exist"
|
|
)
|
|
|
|
connected = section(
|
|
"pc.onconnectionstatechange = () => {",
|
|
"for (const track of state.stream.getTracks())",
|
|
)
|
|
assert re.search(
|
|
r'connectionState\s*===\s*"connected".*applySenderQuality', connected, re.S
|
|
), "the cap is not re-applied when the leg becomes connected"
|
|
|
|
def test_pick_surface_rethrows_cancellation_and_retries_only_no_audio_errors(self):
|
|
"""The audio-off retry must be precise. A cancelled picker
|
|
(NotAllowedError) is a refusal — in Chrome it is also what unticking
|
|
"Share tab audio" produces — and must be *rethrown*: retrying would
|
|
silently re-open the picker and share a surface the person meant not
|
|
to. Only the errors that genuinely mean "this source has no audio"
|
|
(a failed audio *constraint*, not a failed capture) may drop the
|
|
audio requirement; a bare retry in a catch-all would hide real
|
|
capture failures and turn refusals into silent shares."""
|
|
source = (STATIC / "share.js").read_text()
|
|
# pickSurface and its named-error set live between these markers; the
|
|
# first `catch` in that span is pickSurface's own.
|
|
begin = source.index("async function pickSurface")
|
|
section = source[begin : source.index("function publish", begin)]
|
|
catch = section[section.index("catch") :]
|
|
assert "NotAllowedError" in catch, "the catch block must name the refusal error"
|
|
assert re.search(r"NotAllowedError[\s\S]*?throw\s+err", catch), (
|
|
"a cancelled picker must be rethrown, not retried"
|
|
)
|
|
for name in (
|
|
"NotSupportedError",
|
|
"OverconstrainedError",
|
|
"NotFoundError",
|
|
"TypeError",
|
|
):
|
|
assert name in section, f"the audio-off retry must name {name}"
|
|
assert "return await navigator.mediaDevices.getDisplayMedia" not in catch, (
|
|
"the audio-off retry must be conditional on the named errors, "
|
|
"not a bare retry in a catch-all"
|
|
)
|
|
assert re.search(r"audiolessError\(err\)\s*\)\s*throw\s+err", section), (
|
|
"an error the named set does not cover must surface, not be retried"
|
|
)
|
|
|
|
def test_no_bare_publish_frame_wipes_the_roster(self):
|
|
"""The wire's `publish` is a full-metadata frame. A bare
|
|
`{ type: "publish" }` frame parses with the model's defaults and
|
|
would wipe width/height/audio off the roster on every surface switch
|
|
(the mute/unmute republish path) — the panel and the wall would
|
|
disagree from a single event. The full frame is the only publish
|
|
share.js may send."""
|
|
source = (STATIC / "share.js").read_text()
|
|
assert source.count('type: "publish"') == 1, (
|
|
"share.js must send exactly one publish frame (the full one); "
|
|
"a bare { type: 'publish' } frame would reset the metadata"
|
|
)
|
|
|
|
def test_the_two_pages_share_no_element_ids_but_the_shared_chrome(self):
|
|
"""The 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.
|
|
The kiosk page's ids carry the `kiosk-` prefix and the remote page's the
|
|
`remote-` prefix for exactly this reason; the only ids a page may share
|
|
with another are the shared chrome (net, notice, toast).
|
|
"""
|
|
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", "kiosk.html", "remote.html")
|
|
}
|
|
assert ids["mirror.html"] & ids["watch.html"] == shared
|
|
assert ids["index.html"] & ids["mirror.html"] <= shared | {"livebar"}
|
|
assert ids["kiosk.html"] & ids["watch.html"] == shared
|
|
assert ids["kiosk.html"] & ids["mirror.html"] == shared
|
|
assert ids["remote.html"] & ids["watch.html"] == shared
|
|
assert ids["remote.html"] & ids["mirror.html"] == shared
|
|
assert ids["remote.html"] & ids["kiosk.html"] == shared
|
|
# index.html has no notice/toast (a signpost, not an app page), so its
|
|
# intersection with an app page is a subset of the shared chrome — the
|
|
# same shape as the index/mirror assertion above.
|
|
assert ids["kiosk.html"] & ids["index.html"] <= shared
|
|
assert ids["remote.html"] & ids["index.html"] <= shared
|
|
for page, found in ids.items():
|
|
assert len(found) == len(set(found)), f"{page} declares an id twice"
|
|
|
|
def test_the_wall_pages_render_their_stage_classed(self):
|
|
"""The stage rules are class-keyed (`.stage`, phase 08 task 01): a page
|
|
whose stage lost the class renders a full-size unstyled video, which is
|
|
exactly the class of bug the stylesheet guards exist for. Both wall
|
|
pages keep it, and the kiosk's structural elements (grid, stage, hud)
|
|
exist for the layout applier to take them. (There was no
|
|
elements-a-page-must-have check before; this is the one.)
|
|
"""
|
|
kiosk = (STATIC / "kiosk.html").read_text()
|
|
for element_id in ("kiosk-grid", "kiosk-stage", "kiosk-hud"):
|
|
assert f'id="{element_id}"' in kiosk, f"kiosk.html is missing #{element_id}"
|
|
for page, stage_id in (("watch.html", "watch-stage"), ("kiosk.html", "kiosk-stage")):
|
|
stage_tags = [
|
|
tag
|
|
for tag in re.findall(r"<div[^>]*>", (STATIC / page).read_text())
|
|
if f'id="{stage_id}"' in tag
|
|
]
|
|
assert stage_tags, f'{page} is missing <div id="{stage_id}">'
|
|
assert 'class="stage"' in stage_tags[0], (
|
|
f'{page}#{stage_id} lost class="stage": the stage rules are '
|
|
"class-keyed, so an unclassed stage renders a full-size unstyled video"
|
|
)
|
|
|
|
def test_videos_are_muted_and_inline_so_autoplay_is_not_blocked(self):
|
|
for page in ("mirror.html", "watch.html", "kiosk.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 toggled with `hidden` in JS. All but `.placeholder` set
|
|
# their own `display`, which would outrank the UA's `[hidden]` without
|
|
# the global rule; `.placeholder` is guarded because its block is the
|
|
# escape hatch that keeps its content inside the stage.
|
|
for selector in (".btn", ".livetag", ".icon-btn", ".toast", ".net", ".placeholder"):
|
|
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 == []
|
|
|
|
def test_a_hidden_control_is_not_revealed_only_by_hover(self):
|
|
"""Kiosk rule: a control hidden by default must be reachable without a pointer.
|
|
|
|
The tile's controls (`.actions`) start at `opacity: 0`. If the only rule
|
|
that raises them to full opacity were a `:hover` rule, a mouseless kiosk
|
|
would have no mute/expand controls at all. So while any `.actions` rule
|
|
hides them, the stylesheet must also contain a `.actions` reveal rule
|
|
with a non-hover trigger — keyboard focus or the selection state.
|
|
"""
|
|
css = re.sub(r"/\*.*?\*/", "", (STATIC / "app.css").read_text(), flags=re.S)
|
|
actions_rules = [
|
|
(selector.strip(), body)
|
|
for selector, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css)
|
|
if ".actions" in selector
|
|
]
|
|
hiding = [
|
|
selector
|
|
for selector, body in actions_rules
|
|
if re.search(r"opacity\s*:\s*0(?![0-9])", body)
|
|
]
|
|
non_hover_reveals = [
|
|
selector
|
|
for selector, body in actions_rules
|
|
if re.search(r"opacity\s*:\s*1(?![0-9])", body)
|
|
and re.search(r":focus-visible|:focus-within|data-selected", selector)
|
|
]
|
|
if hiding:
|
|
assert non_hover_reveals, (
|
|
f".actions starts hidden ({sorted(hiding)}) but no rule reveals it "
|
|
"without a pointer — a mouseless kiosk would have no tile controls"
|
|
)
|
|
|
|
def _rules(self) -> list[tuple[str, str]]:
|
|
r"""app.css as ``(selector, body)`` pairs, comments stripped.
|
|
|
|
The flat ``[^{}]+\{[^{}]*\}`` pass treats an ``@media`` header as a
|
|
selector and the first child's header as its body; the child rules
|
|
that follow still come back as their own pairs, which is all these
|
|
guards need — every assertion is about a top-level block
|
|
(``.preview-stage``, ``.preview-stage > *``) or about properties that
|
|
must sit in the *same* block
|
|
(``overflow``/``aspect-ratio``/``min-height`` together).
|
|
"""
|
|
css = re.sub(r"/\*.*?\*/", "", (STATIC / "app.css").read_text(), flags=re.S)
|
|
return [(sel.strip(), body) for sel, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css)]
|
|
|
|
def test_the_share_stage_is_one_cell(self):
|
|
"""The stage's children overlap on purpose.
|
|
|
|
In plain flow an idle ``<video>`` is intrinsically 300x150, so the
|
|
placeholder (and the Share button in it) starts 151px below the video
|
|
at *every* width and runs out of the ``overflow: hidden`` box.
|
|
``grid-template: 1fr / 1fr`` makes the stage one cell; ``grid-area:
|
|
1 / 1`` on every child is what puts them all in it. Either half
|
|
without the other stacks the rows back.
|
|
"""
|
|
rules = self._rules()
|
|
stage_bodies = [body for sel, body in rules if sel == ".preview-stage"]
|
|
assert stage_bodies, ".preview-stage rule missing from app.css"
|
|
assert any(
|
|
re.search(r"grid-template\s*:\s*1fr\s*/\s*1fr", body)
|
|
or (
|
|
re.search(r"grid-template-rows\s*:\s*1fr", body)
|
|
and re.search(r"grid-template-columns\s*:\s*1fr", body)
|
|
)
|
|
for body in stage_bodies
|
|
), (
|
|
".stage must declare an explicit single cell (grid-template: 1fr / 1fr, "
|
|
"or grid-template-rows/columns of 1fr) — otherwise its children stack into rows"
|
|
)
|
|
child_bodies = [body for sel, body in rules if sel == ".preview-stage > *"]
|
|
assert child_bodies and any(
|
|
re.search(r"grid-area\s*:\s*1\s*/\s*1", body) for body in child_bodies
|
|
), (
|
|
"app.css must put every .stage child in that one cell (grid-area: 1 / 1) — "
|
|
"without it the placeholder stacks below the 300x150 video and the Share "
|
|
"button is clipped by overflow: hidden"
|
|
)
|
|
|
|
def test_the_stage_has_a_floor_and_a_ceiling(self):
|
|
"""An aspect-derived box with no floor is a clip.
|
|
|
|
``aspect-ratio`` alone made the stage 180px tall at 320px wide (the
|
|
idle placeholder needs 240px there) and 543px tall in a 480px-tall
|
|
window (the Share button below the fold). The floor and the ceiling
|
|
make the ratio the *preferred* shape instead of a hard derivation, and
|
|
the second check is the general statement of the bug: any rule that
|
|
keeps ``overflow: hidden`` on an aspect-ratio box must carry a
|
|
``min-height`` in the same block, or the hidden clip silently swallows
|
|
content again.
|
|
"""
|
|
rules = self._rules()
|
|
stage_bodies = [body for sel, body in rules if sel == ".preview-stage"]
|
|
assert stage_bodies, ".preview-stage rule missing from app.css"
|
|
assert any(re.search(r"min-height\s*:", body) for body in stage_bodies), (
|
|
".stage must keep its floor (min-height)"
|
|
)
|
|
assert any(re.search(r"max-height\s*:", body) for body in stage_bodies), (
|
|
".stage must keep its ceiling (max-height)"
|
|
)
|
|
for sel, body in rules:
|
|
if re.search(r"overflow[^:]*:\s*[^;]*hidden", body) and re.search(
|
|
r"aspect-ratio\s*:", body
|
|
):
|
|
assert re.search(r"min-height\s*:", body), (
|
|
f"{sel} combines `overflow: hidden` with `aspect-ratio` but has no "
|
|
"`min-height` in the same block — an aspect-derived box with no "
|
|
"floor clips its content silently"
|
|
)
|
|
|
|
|
|
class TestAddressGate:
|
|
"""mirror has no accounts; the address is the access control."""
|
|
|
|
ALLOW = "10.2.0.0/22"
|
|
|
|
def test_unset_allows_everyone_which_is_the_development_default(self):
|
|
with gated(client_address="203.0.113.9") as client:
|
|
assert client.get("/watch").status_code == 200
|
|
|
|
@pytest.mark.parametrize(
|
|
"path",
|
|
[
|
|
"/",
|
|
"/mirror",
|
|
"/watch",
|
|
"/kiosk",
|
|
"/remote",
|
|
"/api/streams",
|
|
"/api/kiosk",
|
|
"/static/app.css",
|
|
],
|
|
)
|
|
def test_a_refused_address_gets_no_page_no_roster_no_assets(self, path):
|
|
with gated(self.ALLOW) as client:
|
|
assert client.get(path).status_code == 403
|
|
|
|
def test_the_probe_stays_answerable_from_anywhere(self):
|
|
"""The container health check comes from loopback, which the list excludes."""
|
|
with gated(self.ALLOW) as client:
|
|
response = client.get("/healthz")
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "ok"
|
|
|
|
def test_a_browser_gets_an_explanation_rather_than_json(self):
|
|
with gated(self.ALLOW) as client:
|
|
response = client.get("/mirror", headers={"accept": "text/html"})
|
|
assert response.status_code == 403
|
|
assert "text/html" in response.headers["content-type"]
|
|
body = response.text
|
|
assert self.ALLOW in body
|
|
assert "203.0.113.9" in body # which address was judged, or this is undiagnosable
|
|
|
|
def test_the_refusal_page_cannot_depend_on_assets_the_client_cannot_fetch(self):
|
|
with gated(self.ALLOW) as client:
|
|
body = client.get("/", headers={"accept": "text/html"}).text
|
|
assert not EXTERNAL_REF.search(body)
|
|
assert "/static/" not in body
|
|
|
|
def test_a_fetch_gets_the_machine_readable_refusal(self):
|
|
with gated(self.ALLOW) as client:
|
|
response = client.get("/api/config")
|
|
assert response.status_code == 403
|
|
assert response.json() == {
|
|
"error": "forbidden",
|
|
"message": "This mirror only accepts connections from 10.2.0.0/22.",
|
|
"you": "203.0.113.9",
|
|
}
|
|
|
|
def test_a_refusal_is_never_cacheable(self):
|
|
with gated(self.ALLOW) as client:
|
|
for headers in ({"accept": "text/html"}, {}):
|
|
response = client.get("/api/config", headers=headers)
|
|
assert response.headers["cache-control"] == "no-store"
|
|
|
|
def test_a_refused_address_never_learns_the_layout(self):
|
|
with gated(self.ALLOW) as client:
|
|
response = client.get("/api/kiosk")
|
|
assert response.status_code == 403
|
|
assert "staged" not in response.text
|
|
|
|
def test_the_real_address_gets_the_layout_read_with_no_store(self):
|
|
with gated(self.ALLOW, client_address="10.2.1.10") as client:
|
|
response = client.get("/api/kiosk")
|
|
assert response.status_code == 200
|
|
assert response.headers["cache-control"] == "no-store"
|
|
|
|
def test_the_socket_closes_before_it_accepts(self):
|
|
with (
|
|
gated(self.ALLOW) as client,
|
|
pytest.raises(WebSocketDisconnect),
|
|
client.websocket_connect("/ws") as socket,
|
|
):
|
|
socket.send_json({"type": "hello", "role": "viewer"})
|
|
|
|
def test_the_real_address_is_in_and_connects(self):
|
|
with gated(self.ALLOW, client_address="10.2.1.10") as client:
|
|
assert client.get("/watch").status_code == 200
|
|
assert client.get("/kiosk").status_code == 200
|
|
with client.websocket_connect("/ws") as socket:
|
|
assert handshake(socket, "viewer")
|
|
|
|
def test_a_trusted_local_proxy_carries_the_client_through(self):
|
|
"""The fleet shape: nginx on the same host, X-Forwarded-For set by nginx."""
|
|
with gated(self.ALLOW, client_address="127.0.0.1") as client:
|
|
assert client.get("/watch", headers={"x-forwarded-for": "10.2.1.5"}).status_code == 200
|
|
with client.websocket_connect("/ws", headers={"x-forwarded-for": "10.2.1.5"}) as socket:
|
|
assert handshake(socket, "viewer")
|
|
|
|
def test_a_trusted_proxy_carrying_a_foreign_client_is_still_refused(self):
|
|
with gated(self.ALLOW, client_address="127.0.0.1") as client:
|
|
assert client.get("/watch", headers={"x-forwarded-for": "8.8.8.8"}).status_code == 403
|
|
with (
|
|
pytest.raises(WebSocketDisconnect),
|
|
client.websocket_connect("/ws", headers={"x-forwarded-for": "8.8.8.8"}),
|
|
):
|
|
pass
|
|
|
|
def test_an_untrusted_peer_cannot_write_its_own_pass(self):
|
|
with gated(self.ALLOW, client_address="203.0.113.9") as client:
|
|
response = client.get("/watch", headers={"x-forwarded-for": "10.2.1.5"})
|
|
assert response.status_code == 403
|
|
assert response.json()["you"] == "203.0.113.9"
|
|
|
|
def test_both_address_families_can_be_admitted_side_by_side(self):
|
|
with gated(f"{self.ALLOW}, fd00::/64", client_address="fd00::1234") as client:
|
|
assert client.get("/watch").status_code == 200
|
|
|
|
|
|
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"
|
|
|
|
def test_kiosk_answers_exactly_the_layout_dto(self, client):
|
|
"""Shape guard: the read endpoint is the socket's `layout` DTO, nothing more.
|
|
|
|
It answers the same object the socket pushes (task 02's single-DTO
|
|
invariant), so the key set — including the `type` tag — is pinned: a new
|
|
key here is a decision (00_phase.md), not a leak.
|
|
"""
|
|
body = client.get("/api/kiosk").json()
|
|
assert set(body) == {"type", "staged", "fullscreen", "followers"}
|
|
assert body == {"type": "layout", "staged": None, "fullscreen": False, "followers": 0}
|
|
|
|
|
|
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_the_audio_flag_survives_the_wire_and_a_republish_flips_it(self, client):
|
|
"""Regression for the audio work: `audio` is the one bit that tells a
|
|
viewer whether to expect sound. A publish with audio on must arrive
|
|
with the flag set (and the REST roster must agree); a republish that
|
|
drops it must flip it via `stream_updated` for **every** subscribed
|
|
viewer; and an `unpublish` must leave the wall empty — a viewer that
|
|
joins afterwards sees an empty roster, so the flip cannot leak into a
|
|
stream that no longer exists. The policy half is the same one the
|
|
capture-metadata test pins; this records it for the audio phase so it
|
|
cannot regress."""
|
|
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")
|
|
handshake(one, "viewer", "one")
|
|
handshake(two, "viewer", "two")
|
|
sharer.send_json({"type": "publish", "label": "Screen", "audio": True})
|
|
assert wait_for(one, "stream_added")["stream"]["audio"] is True
|
|
assert wait_for(two, "stream_added")["stream"]["audio"] is True
|
|
assert client.get("/api/streams").json()["mirrors"][0]["audio"] is True
|
|
|
|
one.send_json({"type": "subscribe", "streamId": sharer_id})
|
|
two.send_json({"type": "subscribe", "streamId": sharer_id})
|
|
wait_for(sharer, "peer_joined")
|
|
wait_for(sharer, "peer_joined")
|
|
|
|
# A shape change (audio off) is a republish: each subscribed
|
|
# viewer is re-offered and the flag arrives flipped in
|
|
# `stream_updated`. The subscribes already pushed one (audience
|
|
# change), so keep reading until the republish's own update
|
|
# lands on *each* viewer.
|
|
sharer.send_json({"type": "publish", "label": "Screen", "audio": False})
|
|
for viewer in (one, two):
|
|
assert wait_for_update_with(viewer, audio=False)["audio"] is False
|
|
assert client.get("/api/streams").json()["mirrors"][0]["audio"] is False
|
|
|
|
# Teardown: every viewer is told, and the wall is empty
|
|
# afterwards — no stray update may keep advertising the gone
|
|
# stream's audio.
|
|
sharer.send_json({"type": "unpublish"})
|
|
wait_for(one, "stream_removed")
|
|
wait_for(two, "stream_removed")
|
|
body = client.get("/api/streams").json()
|
|
assert body["mirrors"] == []
|
|
assert body["stats"]["mirrors"] == 0
|
|
|
|
# The empty wall is unaffected: a viewer that joins after the
|
|
# teardown sees an empty roster, with no audio bit on anything.
|
|
with client.websocket_connect("/ws") as late:
|
|
late.send_json({"type": "hello", "role": "viewer", "name": "late"})
|
|
welcome = late.receive_json()
|
|
assert welcome["type"] == "welcome", welcome
|
|
assert welcome["streams"] == []
|
|
|
|
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
|
|
|
|
def test_arrange_roundtrips_to_viewers_the_sender_and_the_read_endpoint(self, client):
|
|
"""The whole path: publish, arrange, and every observer — the sender, the
|
|
other viewers, and `GET /api/kiosk` — says the same thing."""
|
|
with (
|
|
client.websocket_connect("/ws") as sharer,
|
|
client.websocket_connect("/ws") as viewer,
|
|
client.websocket_connect("/ws") as second,
|
|
):
|
|
sharer_id = handshake(sharer, "sharer", "A")
|
|
handshake(viewer, "viewer", "V1")
|
|
handshake(second, "viewer", "V2")
|
|
sharer.send_json({"type": "publish", "label": "Screen"})
|
|
wait_for(viewer, "stream_added")
|
|
wait_for(second, "stream_added")
|
|
|
|
viewer.send_json({"type": "arrange", "staged": sharer_id, "fullscreen": True})
|
|
sent = wait_for(viewer, "layout")
|
|
assert sent["staged"] == sharer_id
|
|
assert sent["fullscreen"] is True
|
|
assert wait_for(second, "layout") == sent
|
|
body = client.get("/api/kiosk").json()
|
|
assert (body["staged"], body["fullscreen"], body["followers"]) == (sharer_id, True, 0)
|
|
|
|
# A peer that joins after the arrange is already arranged: its
|
|
# welcome carries the state, so a page can navigate and obey
|
|
# without asking. (Joins while the wall still exists — closing the
|
|
# sharer's socket would legitimately reset the stage.)
|
|
with client.websocket_connect("/ws") as late:
|
|
late.send_json({"type": "hello", "role": "viewer", "name": "late"})
|
|
welcome = late.receive_json()
|
|
assert welcome["layout"]["staged"] == sharer_id
|
|
assert welcome["layout"]["fullscreen"] is True
|
|
|
|
def test_kiosk_followers_are_counted_and_recounted_on_leave(self, client):
|
|
with (
|
|
client.websocket_connect("/ws") as plain,
|
|
client.websocket_connect("/ws") as kiosk1,
|
|
client.websocket_connect("/ws") as kiosk2,
|
|
):
|
|
handshake(plain, "viewer", "plain")
|
|
kiosk1.send_json({"type": "hello", "role": "viewer", "name": "K1", "kiosk": True})
|
|
assert kiosk1.receive_json()["type"] == "welcome"
|
|
kiosk2.send_json({"type": "hello", "role": "viewer", "name": "K2", "kiosk": True})
|
|
# The joiner counts itself: it is connected and it declared kiosk.
|
|
assert kiosk2.receive_json()["layout"]["followers"] == 2
|
|
|
|
# The plain viewer was told by every kiosk join, in order.
|
|
assert wait_for(plain, "layout")["followers"] == 1
|
|
assert wait_for(plain, "layout")["followers"] == 2
|
|
assert wait_for(kiosk1, "layout")["followers"] == 2
|
|
|
|
kiosk1.send_json({"type": "arrange", "fullscreen": True})
|
|
sent = wait_for(kiosk1, "layout")
|
|
assert sent["fullscreen"] is True
|
|
assert sent["followers"] == 2
|
|
assert wait_for(plain, "layout")["followers"] == 2
|
|
assert wait_for(kiosk2, "layout")["followers"] == 2
|
|
|
|
kiosk1.close()
|
|
# A follower leaving re-counts: the remaining viewers hear `1`.
|
|
assert wait_for(kiosk2, "layout")["followers"] == 1
|
|
assert wait_for(plain, "layout")["followers"] == 1
|
|
assert client.get("/api/kiosk").json()["followers"] == 1
|
|
|
|
def test_arranging_a_mirror_that_is_not_there_is_refused_without_closing(self, client):
|
|
with client.websocket_connect("/ws") as viewer:
|
|
handshake(viewer, "viewer")
|
|
viewer.send_json({"type": "arrange", "staged": "p404"})
|
|
error = wait_for(viewer, "error")
|
|
assert error["code"] == "unknown_stream"
|
|
assert error["fatal"] is False
|
|
# The socket is still up, and nothing changed.
|
|
viewer.send_json({"type": "ping"})
|
|
assert viewer.receive_json() == {"type": "pong"}
|
|
assert client.get("/api/kiosk").json()["staged"] is None
|
|
|
|
def test_a_fullscreen_only_arrange_leaves_the_stage_and_null_returns_the_wall(self, client):
|
|
"""End to end: absent ≠ null. A `fullscreen`-only frame keeps the staged
|
|
mirror; an explicit `staged: null` is the only way back to the wall."""
|
|
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")
|
|
|
|
viewer.send_json({"type": "arrange", "staged": sharer_id})
|
|
wait_for(viewer, "layout")
|
|
|
|
viewer.send_json({"type": "arrange", "fullscreen": True})
|
|
frame = wait_for(viewer, "layout")
|
|
assert frame["staged"] == sharer_id # absent field left alone
|
|
assert frame["fullscreen"] is True
|
|
|
|
viewer.send_json({"type": "arrange", "staged": None})
|
|
frame = wait_for(viewer, "layout")
|
|
assert frame["staged"] is None # explicit null returns the wall
|
|
assert frame["fullscreen"] is True # and touches nothing else
|
|
body = client.get("/api/kiosk").json()
|
|
assert (body["staged"], body["fullscreen"]) == (None, True)
|
|
|
|
def test_a_sharer_may_arrange_but_only_viewers_and_the_sender_are_told(self, client):
|
|
with (
|
|
client.websocket_connect("/ws") as sharer_a,
|
|
client.websocket_connect("/ws") as sharer_b,
|
|
client.websocket_connect("/ws") as viewer,
|
|
):
|
|
a_id = handshake(sharer_a, "sharer", "A")
|
|
b_id = handshake(sharer_b, "sharer", "B")
|
|
handshake(viewer, "viewer")
|
|
sharer_a.send_json({"type": "publish", "label": "A"})
|
|
wait_for(viewer, "stream_added")
|
|
sharer_b.send_json({"type": "publish", "label": "B"})
|
|
wait_for(viewer, "stream_added")
|
|
viewer.send_json({"type": "subscribe", "streamId": a_id})
|
|
wait_for(sharer_a, "peer_joined")
|
|
wait_for(sharer_a, "stream_updated")
|
|
viewer.send_json({"type": "subscribe", "streamId": b_id})
|
|
wait_for(sharer_b, "peer_joined")
|
|
wait_for(sharer_b, "stream_updated")
|
|
for _ in range(2):
|
|
wait_for(viewer, "stream_updated")
|
|
|
|
sharer_a.send_json({"type": "arrange", "staged": a_id, "fullscreen": True})
|
|
sent = wait_for(sharer_a, "layout") # a sharer-sender gets its ack
|
|
assert sent["staged"] == a_id
|
|
assert wait_for(viewer, "layout")["fullscreen"] is True
|
|
|
|
# sharer_b is neither the sender nor a viewer: its next frame must be
|
|
# the unsubscribe below, not a layout — a fanned-out layout would
|
|
# have arrived first and broken this.
|
|
viewer.send_json({"type": "unsubscribe", "streamId": b_id})
|
|
message = sharer_b.receive_json()
|
|
assert message["type"] == "peer_left", (
|
|
f"a non-viewing sharer received {message!r}: layout may not fan out to sharers"
|
|
)
|
|
|
|
def test_the_staged_mirror_going_away_resets_the_stage_for_every_viewer(self, client):
|
|
with (
|
|
client.websocket_connect("/ws") as sharer,
|
|
client.websocket_connect("/ws") as viewer1,
|
|
client.websocket_connect("/ws") as viewer2,
|
|
):
|
|
sharer_id = handshake(sharer, "sharer")
|
|
handshake(viewer1, "viewer")
|
|
handshake(viewer2, "viewer")
|
|
sharer.send_json({"type": "publish", "label": "Screen"})
|
|
wait_for(viewer1, "stream_added")
|
|
wait_for(viewer2, "stream_added")
|
|
|
|
viewer1.send_json({"type": "arrange", "staged": sharer_id, "fullscreen": True})
|
|
wait_for(viewer1, "layout")
|
|
wait_for(viewer2, "layout")
|
|
|
|
sharer.close()
|
|
assert wait_for(viewer1, "layout")["staged"] is None
|
|
assert wait_for(viewer2, "layout")["staged"] is None
|
|
assert client.get("/api/kiosk").json()["staged"] is None
|
|
|
|
def test_a_controller_peer_drives_the_layout_without_counting_in_followers(self, client):
|
|
"""A /remote-shaped peer — `hello{role:"viewer"}` with no `kiosk` flag —
|
|
may drive the wall, but must not be counted in it: `followers` is the
|
|
display count, and the phone that holds the wall is not a display. The
|
|
read endpoint and the frames must agree on the same number."""
|
|
with (
|
|
client.websocket_connect("/ws") as sharer,
|
|
client.websocket_connect("/ws") as controller,
|
|
client.websocket_connect("/ws") as display,
|
|
):
|
|
sharer_id = handshake(sharer, "sharer", "A")
|
|
handshake(controller, "viewer", "phone") # no `kiosk` flag
|
|
display.send_json({"type": "hello", "role": "viewer", "name": "K", "kiosk": True})
|
|
assert display.receive_json()["type"] == "welcome"
|
|
# The display's join is reported to the other viewers; the controller
|
|
# is one of them and hears the count of one.
|
|
assert wait_for(controller, "layout")["followers"] == 1
|
|
assert client.get("/api/kiosk").json()["followers"] == 1
|
|
|
|
sharer.send_json({"type": "publish", "label": "Screen"})
|
|
wait_for(controller, "stream_added")
|
|
|
|
# The controller drives the wall...
|
|
controller.send_json({"type": "arrange", "staged": sharer_id, "fullscreen": True})
|
|
sent = wait_for(controller, "layout")
|
|
assert sent["staged"] == sharer_id
|
|
assert sent["fullscreen"] is True
|
|
# ...the display obeys it...
|
|
assert wait_for(display, "layout")["staged"] == sharer_id
|
|
# ...and the read endpoint agrees with both — the controller still
|
|
# does not count: the wall is driven by one, obeyed by one, and the
|
|
# count says one.
|
|
body = client.get("/api/kiosk").json()
|
|
assert (body["staged"], body["fullscreen"], body["followers"]) == (sharer_id, True, 1)
|
|
assert sent["followers"] == 1
|
|
|
|
def test_arranging_a_mirror_a_viewer_is_watching_changes_no_subscriptions(self, client):
|
|
"""Driving the wall must not disturb the people watching it: an
|
|
`arrange` for a mirror a second viewer is receiving touches no
|
|
subscription — /api/streams reports the same audience before and after,
|
|
and the controller's exit removes nothing, because it never watched."""
|
|
with (
|
|
client.websocket_connect("/ws") as sharer,
|
|
client.websocket_connect("/ws") as viewer,
|
|
client.websocket_connect("/ws") as controller,
|
|
):
|
|
sharer_id = handshake(sharer, "sharer", "A")
|
|
handshake(viewer, "viewer", "V")
|
|
handshake(controller, "viewer", "phone") # no `kiosk` flag
|
|
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")
|
|
before = client.get("/api/streams").json()
|
|
assert before["stats"]["watching"] == 1
|
|
assert before["mirrors"][0]["viewers"] == 1
|
|
|
|
controller.send_json({"type": "arrange", "staged": sharer_id, "fullscreen": True})
|
|
sent = wait_for(controller, "layout")
|
|
assert sent["staged"] == sharer_id
|
|
|
|
after = client.get("/api/streams").json()
|
|
assert after["stats"]["watching"] == 1
|
|
assert after["mirrors"][0]["viewers"] == 1
|
|
|
|
# The controller leaving removes no subscription: it never watched,
|
|
# so its exit touches the audience not at all.
|
|
controller.close()
|
|
final = client.get("/api/streams").json()
|
|
assert final["stats"]["watching"] == 1
|
|
assert final["mirrors"][0]["viewers"] == 1
|