"""Every page, at a desktop AND a phone width, with the fault stated as a number. Every layout bug this project has had was found by a person looking at a page, and the ones that took longest only existed at one width — a calendar cropped to two days, a tab strip that pushed the document sideways, a popup you could read the page through. All of them rendered perfectly at 1400px, which was the only width anything was ever checked at. Marked `visual` and deselected by default (see `addopts` in pyproject.toml) because a full pass drives a real browser over every page twice and takes minutes, which is not what `pytest tests` should cost. CI runs them as their own job: uv run pytest tests -m visual The pages come from `tools/screenshot.py`, run as a subprocess rather than imported: it rewrites `os.environ` and reads `sys.argv` at import time, so importing it mid-session would hand it pytest's arguments and leak its configuration into every test that ran afterwards. """ from __future__ import annotations import json import subprocess import sys import tempfile from pathlib import Path import pytest ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) from tools.browser import find_chrome # noqa: E402 pytestmark = pytest.mark.visual CHROME = find_chrome() if CHROME is None: pytest.skip("no headless Chrome on this machine", allow_module_level=True) # 1400 is the layout's design width; 412 is a Pixel-class phone in CSS pixels. WIDTHS = {"desktop": 1400, "mobile": 412} # The page inventory, named rather than globbed. Parametrising over a static # list means the render can stay in a session fixture — and a page added to # screenshot.py without being added here fails `test_every_rendered_page_is_listed` # instead of silently never being checked. PAGES = [ "1-welcome", "2-database", "2b-database-error", "3-account", "4-recovery-codes", "5-2fa", "6-portfolio", "7-features", "7b-environment", "8-finish", "9-restarting", "10-dashboard", "11-profile", "11b-passkeys", "11c-profile-portfolios", "12-charts", "12b-chart-builder", "13-login", "14-recover", "15-instruments", "16-dashboard-holdings", "16a-trade-edit", "16b-action", "16c-holding", "16d-distribution", "16e-fy", "16f-simulate", "17-dashboard-sorted", "17b-holdings-blocked", "17c-dashboard-gain-pct", "18-imports", "18b-imports-templates", "18c-broker-designer", "18d-csv-preview", "19-plan-empty", "20-plan", "21-plan-editing", "22-admin", "22b-invite", "23-admin-settings", ] PROBE = (ROOT / "tests" / "visual_probe.js").read_text(encoding="utf-8") # The page under test goes in an IFRAME of the wanted width, not a window of it. # `--window-size=412,900` is silently clamped: headless Chrome will not go below # 500px in any headless mode, so a check that trusted the flag was measuring a # 500px viewport while claiming to measure a phone. An iframe gets its own # viewport, so media queries and `100vw` resolve to its width. # # Its document cannot write the outer title without # `--allow-file-access-from-files`: two `file://` documents are otherwise # different origins. WRAPPER = """
""" @pytest.fixture(scope="session") def shots() -> Path: """Every page rendered to standalone HTML, once for the whole session.""" out = Path(tempfile.mkdtemp(prefix="visual-")) result = subprocess.run( [sys.executable, str(ROOT / "tools" / "screenshot.py"), str(out)], capture_output=True, text=True, timeout=300, cwd=ROOT, ) if result.returncode != 0: pytest.fail(f"screenshot.py failed:\n{result.stdout}\n{result.stderr}") return out def probe(page: Path, width: int) -> dict: """Render one page at one width and return what the probe measured.""" with tempfile.TemporaryDirectory() as tmp: instrumented = Path(tmp) / "page.html" instrumented.write_text( page.read_text(encoding="utf-8").replace("