216 lines
9.4 KiB
Python
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}"
|