tests/test_visual.py
Rama main · réplica de lectura del repositorio original.
"""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 = """<!doctype html><html><body style="margin:0">
<iframe id="f" src="%s" style="width:%dpx;height:900px;border:0"></iframe>
<script>
var frame = document.getElementById("f");
var tries = 0;
function collect() {
var title = "";
try { title = frame.contentDocument.title; } catch (e) { title = "BLOCKED " + e.message; }
if (title.indexOf("VC") === 0 || title.indexOf("BLOCKED") === 0 || ++tries > 60) {
document.title = title || "probe silent";
return;
}
setTimeout(collect, 50);
}
frame.addEventListener("load", collect);
</script></body></html>
"""
@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("</body>", f"<script>{PROBE}</script></body>"), 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("</title>", 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)}")