"""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 `