"""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("", f""), encoding="utf-8") wrapper = Path(tmp) / "wrapper.html" wrapper.write_text(WRAPPER % (instrumented.name, width), encoding="utf-8") out = subprocess.run( [CHROME, "--headless", "--disable-gpu", "--dump-dom", "--allow-file-access-from-files", # Chrome needs no sandbox where it cannot get one (containers, # some runners) and refuses to start otherwise. "--no-sandbox", # Room for the frame plus the browser's own minimum. f"--window-size={max(width + 80, 620)},960", "--virtual-time-budget=6000", wrapper.as_uri()], capture_output=True, text=True, timeout=90, ).stdout marker = out.find("VC{") if marker < 0: return {"error": "probe did not run"} return json.loads(out[marker + 2:out.find("", marker)]) def faults(report: dict) -> list[str]: """What the probe found wrong, in words; nothing when nothing is.""" found = [] if report["overflow"] > 0: found.append(f"the document scrolls sideways by {report['overflow']}px") if report["wide"]: found.append("past the edge: " + ", ".join(report["wide"][:4])) if report["spill"]: found.append("text outside its box: " + ", ".join(report["spill"][:4])) if report.get("panels"): found.append("popups off screen: " + ", ".join(report["panels"][:4])) return found @pytest.mark.parametrize("width_name", sorted(WIDTHS)) @pytest.mark.parametrize("page", PAGES) def test_the_page_has_no_layout_faults(shots: Path, page: str, width_name: str) -> None: html = shots / f"{page}.html" assert html.exists(), f"{page} was not rendered — is it still in screenshot.py?" report = probe(html, WIDTHS[width_name]) assert not report.get("error"), f"{page}: {report['error']}" found = faults(report) assert not found, f"{page} at {width_name} ({WIDTHS[width_name]}px):\n " + \ "\n ".join(found) def test_every_rendered_page_is_listed(shots: Path) -> None: """A page added to screenshot.py must be added to PAGES, or it goes unchecked. The failure this prevents is silent: a new page renders, nothing references it, and it is never measured at either width. """ rendered = {p.stem for p in shots.glob("*.html") if p.name[0].isdigit()} assert rendered == set(PAGES), ( f"only in screenshot.py: {sorted(rendered - set(PAGES))}\n" f"only in PAGES: {sorted(set(PAGES) - rendered)}")