"""US09-04: the user manual's screenshots, produced from the running application. A screenshot nobody can regenerate is a screenshot that silently stops being true. So every image in the manual is captured here, from a real server and a real worker driving a temporary fixture library, and never pasted in by hand. Regenerate the committed images with one command: PHOTO_PIPELINE_WRITE_SCREENSHOTS=1 work_item/scripts/python -m pytest \ tests/e2e/test_user_manual_screenshots.py -q Without that variable the capture still runs on every suite — the generator has to keep working, and a view that stops rendering must fail here rather than in a reader's browser — but the images go to a temporary directory. Regenerating on every run would leave the working tree permanently dirty, because PNG output is not byte-stable. """ from __future__ import annotations import os import re from contextlib import closing from pathlib import Path import pytest from PIL import Image from playwright.sync_api import TimeoutError as PlaywrightTimeout from tests.conftest import session_client from tests.e2e._pipeline_harness import ( Server, approve_album, seed_album, start_worker, wait_until, ) REPO = Path(__file__).resolve().parents[2] DOCS = REPO / "docs" IMAGES = DOCS / "images" ALBUM = "rome" VIEWPORT = {"width": 1280, "height": 900} # One per stage page of the manual: the route, the heading that view renders, and the # element whose presence means it has finished. # # The heading matters more than it looks. Every route replaces the same container, so # waiting for "an h1" matches the *previous* view's heading and photographs the screen # you just left — which is how the statistics page first shipped a picture of the # archive view. SHOTS = ( ("workflow", "#/workflow", "Workflow", "stage-safety"), ("inventory", "#/inventory", "Inventory", "asset-row"), ("duplicates", "#/duplicates", "Duplicate clusters", None), ("safety", "#/safety", "Safety review", None), ("analysis", "#/analyze", "Analyze", "analyze-counts"), ("albums", f"#/albums?album={ALBUM}", "Albums", "suggested-name"), ("renames", "#/renames", "Renames", None), ("uploads", "#/uploads", "Upload", "upload-scope"), ("archive", "#/archive", "Archive", "archive-locations"), ("statistics", "#/stats", "Stats", None), ) # Every check here belongs to the documentation gate (US09-05). pytestmark = pytest.mark.phase_i def writing() -> bool: return os.environ.get("PHOTO_PIPELINE_WRITE_SCREENSHOTS") == "1" @pytest.fixture(scope="module") def stack(tmp_path_factory): """A library in the state the manual describes: one analysed album, a duplicate to review, an archive destination, and a worker that claims jobs.""" tmp_path = tmp_path_factory.mktemp("manual") seeded = seed_album(tmp_path, ALBUM, ("forum.jpg", "colosseum.jpg")) worker = start_worker(seeded, fake_vision_log=tmp_path / "vision.log") server = Server(seeded).start() try: _prepare(server.base, seeded) yield server finally: server.stop() worker.terminate() worker.wait(timeout=10) def _prepare(base: str, seeded) -> None: """Everything the views need, established over the public API — the same calls a person would make, so the screenshots show reachable states.""" with closing(session_client(base, timeout=30)) as client: client.post("/api/v1/duplicates/detect").raise_for_status() client.post("/api/v1/albums/proposals", json={}).raise_for_status() destination = seeded.data / "archive" destination.mkdir(exist_ok=True) client.post( "/api/v1/archive-locations", json={"name": "external disk", "root": str(destination)}, ).raise_for_status() wait_until(lambda: client.get("/api/v1/workflow").status_code == 200) # An approved name and a built — deliberately unapplied — plan, so the renames # screenshot shows the preview its page describes rather than an empty state. # Building a plan moves nothing; applying it is what would, and nothing here does. approve_album(base, album=ALBUM, name="2019 — Rome") with closing(session_client(base, timeout=30)) as client: client.post("/api/v1/rename-plans").raise_for_status() def _capture(page, server, target: Path) -> list[str]: target.mkdir(parents=True, exist_ok=True) page.set_viewport_size(VIEWPORT) written = [] for name, route, heading, ready in SHOTS: page.goto(f"{server.base}/app/{route}") page.locator("main h1", has_text=heading).first.wait_for(timeout=30_000) if ready: page.get_by_test_id(ready).first.wait_for(timeout=30_000) page.screenshot(path=str(target / f"{name}.png")) written.append(name) return written def test_the_generator_produces_every_screenshot_the_manual_references(page, stack, tmp_path): destination = IMAGES if writing() else tmp_path / "images" written = _capture(page, stack, destination) assert sorted(written) == sorted(name for name, *_ in SHOTS) for name in written: produced = destination / f"{name}.png" assert produced.stat().st_size > 5_000, f"{name}.png is too small to be a view" referenced = set(re.findall(r"images/([a-z-]+)\.png", "\n".join( path.read_text() for path in DOCS.rglob("*.md") ))) assert referenced <= set(written), f"the manual references images nobody generates: {referenced - set(written)}" def test_no_screenshot_shows_a_real_path_or_a_secret(page, stack, tmp_path): """The fixture library is synthetic and lives in a temporary directory, so the pixels cannot carry someone's photographs — but the view can still print a path, and that path must be the fixture's, never the operator's.""" with closing(session_client(stack.base, timeout=30)) as client: rendered = client.get("/api/v1/inventory/assets", params={"limit": 200}).json()["items"] paths = [item["current_path"] for item in rendered if item["current_path"]] assert paths, "nothing was rendered, so nothing was proven" assert all("/manual" in path or "pytest" in path or "/tmp" in path or "/var" in path for path in paths), paths assert all(ALBUM in path or "images" in path for path in paths) def test_the_committed_screenshots_still_show_what_the_application_shows(page, stack, tmp_path): """A UI change that invalidates the manual should be a red build, not a discovery months later by somebody following a picture of a screen that no longer exists. The tolerance is on *content*, not pixels. Comparing the committed PNGs with a fresh capture was tried first and rejected: PNG output is not reproducible, font rasterisation differs between the machine that generated an image and the machine running the gate, and several views legitimately print the fixture library's absolute path, which is a fresh temporary directory every run. A pixel or perceptual comparison therefore fails for reasons that have nothing to do with the manual being wrong — and a check that cries wolf gets deleted. What actually invalidates a screenshot is the view no longer showing what its page says it shows. That is what is compared here: the heading, the elements the page describes, and the fact that the image was captured at the same size. ponytail: content comparison, not pixels. A visual-diff service with per-platform baselines is the upgrade if cosmetic regressions ever need catching too. """ if not IMAGES.is_dir(): pytest.skip("no screenshots have been generated into the repository yet") fresh = tmp_path / "fresh" _capture(page, stack, fresh) drifted = [] for name, route, heading, ready in SHOTS: committed = IMAGES / f"{name}.png" if not committed.is_file(): drifted.append(f"{name}: never committed") continue with Image.open(committed) as image: if image.size != (VIEWPORT["width"], VIEWPORT["height"]): drifted.append(f"{name}: committed at {image.size}, captured at {VIEWPORT}") # The views render asynchronously, so each check waits rather than asking a # question the page has not finished answering. page.goto(f"{stack.base}/app/{route}") try: page.locator("main h1", has_text=heading).first.wait_for(timeout=15_000) except PlaywrightTimeout: drifted.append(f"{name}: the view no longer shows the heading '{heading}'") continue if ready: try: page.get_by_test_id(ready).first.wait_for(timeout=15_000) except PlaywrightTimeout: drifted.append(f"{name}: the view no longer renders '{ready}'") assert drifted == [], ( f"the manual's screenshots no longer match the application: {drifted}. " "Regenerate them with PHOTO_PIPELINE_WRITE_SCREENSHOTS=1 and review the result." ) def test_every_committed_screenshot_is_referenced_by_a_page(): """An image nobody shows is an image nobody updates.""" if not IMAGES.is_dir(): pytest.skip("no screenshots have been generated into the repository yet") text = "\n".join(path.read_text() for path in DOCS.rglob("*.md")) orphans = sorted( image.name for image in IMAGES.glob("*.png") if f"images/{image.name}" not in text ) assert orphans == [], f"committed but unreferenced: {orphans}"