Files
photoanalyzer/tests/e2e/test_user_manual_screenshots.py
domverse cdf4078123
Some checks failed
Test / suites (pull_request) Failing after 3m9s
Test / container (pull_request) Has been skipped
Test / documentation (pull_request) Failing after 1m55s
US09-05: Automate Documentation Acceptance
2026-08-24 00:22:00 +02:00

216 lines
9.4 KiB
Python

"""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}"