"""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"),
],
)
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"])
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"],
)
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"])
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"),
],
)
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_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"),
):
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'', 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 for exactly this reason;
the only ids it may share with another page 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")
}
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
# index.html has no notice/toast (a signpost, not an app page), so its
# intersection with the kiosk is a subset of the shared chrome — the
# same shape as the index/mirror assertion above.
assert ids["kiosk.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"
]*>", (STATIC / page).read_text())
if f'id="{stage_id}"' in tag
]
assert stage_tags, f'{page} is missing
'
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"