diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ec059fb --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,32 @@ +"""Shared fixtures for the non-browser test suite.""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from mirror.app import create_app +from mirror.config import Settings +from mirror.registry import Registry + + +@pytest.fixture +def settings() -> Settings: + return Settings(host="127.0.0.1", port=0, ice_servers=[], max_bitrate_bps=2_000_000) + + +@pytest.fixture +def registry() -> Registry: + counter = iter(f"p{i}" for i in range(1, 10_000)) + return Registry(id_factory=lambda: next(counter)) + + +@pytest.fixture +def client(app) -> TestClient: + with TestClient(app) as test_client: + yield test_client + + +@pytest.fixture +def app(settings) -> object: + return create_app(settings) diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..c78ce1c --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,173 @@ +"""Fixtures for the browser end-to-end suite. + +The capture flags matter: with a fake UI and a fake device, Chromium answers +`getDisplayMedia()` with a real, *moving* 1280x720 monitor stream. That means +these tests exercise the genuine capture -> encode -> SRTP -> decode -> paint +path rather than a stubbed-out media source. +""" + +from __future__ import annotations + +import os +import socket +import subprocess +import sys +import time +from pathlib import Path + +import pytest +from playwright.sync_api import expect, sync_playwright + +pytestmark = pytest.mark.e2e + +REPO_ROOT = Path(__file__).resolve().parents[2] + +CHROMIUM_ARGS = [ + "--use-fake-ui-for-media-stream", + "--use-fake-device-for-media-stream", + "--use-fake-device-for-audio-capture", + "--autoplay-policy=no-user-gesture-required", + # Two Chromium instances must each get their own UDP ports; never proxy. + "--no-sandbox", + "--disable-dev-shm-usage", + "--no-proxy-server", +] + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.bind(("127.0.0.1", 0)) + return probe.getsockname()[1] + + +@pytest.fixture(scope="session") +def base_url() -> str: + """A real `mirror serve` process on a real port — not a test double.""" + import httpx + + port = _free_port() + process = subprocess.Popen( + [ + sys.executable, + "-m", + "mirror", + "serve", + "--host", + "127.0.0.1", + "--port", + str(port), + "--log-level", + "warning", + ], + cwd=REPO_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env={ + **os.environ, + "MIRROR_ICE_SERVERS": "", + "PYTHONUNBUFFERED": "1", + # A stray DEBUGPY in the developer's shell would stall the server. + "DEBUGPY": "0", + }, + ) + url = f"http://127.0.0.1:{port}" + deadline = time.time() + 25 + last_error: Exception | None = None + while time.time() < deadline: + if process.poll() is not None: + output = process.stdout.read() if process.stdout else "" + raise RuntimeError(f"mirror serve exited early ({process.returncode}):\n{output}") + try: + if httpx.get(f"{url}/healthz", timeout=1.0).status_code == 200: + break + except Exception as exc: # still booting + last_error = exc + time.sleep(0.15) + else: + process.terminate() + raise RuntimeError(f"mirror serve never came up: {last_error}") + + yield url + + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: # pragma: no cover + process.kill() + + +@pytest.fixture(scope="session") +def browser(): + with sync_playwright() as playwright: + # channel="chromium" = the full Chromium binary in new headless mode. + # The trimmed "headless shell" has no media stack at all and hides + # navigator.mediaDevices entirely. + instance = playwright.chromium.launch(channel="chromium", headless=True, args=CHROMIUM_ARGS) + yield instance + instance.close() + + +@pytest.fixture +def context(browser): + """One browser context == one device. Closed after every test.""" + created = browser.new_context(viewport={"width": 1100, "height": 720}) + yield created + created.close() + + +@pytest.fixture +def page(context): + return context.new_page() + + +def start_sharing(page) -> None: + page.locator("#start").click() + expect(page.locator("body")).to_have_attribute("data-live", "on", timeout=15_000) + + +def stop_sharing(page) -> None: + page.locator("#stop").click() + expect(page.locator("body")).to_have_attribute("data-live", "off", timeout=10_000) + + +# Returns {width, height, currentTime, distinct} for the first