US09-05: Automate Documentation Acceptance
Some checks failed
Test / suites (pull_request) Failing after 3m9s
Test / container (pull_request) Has been skipped
Test / documentation (pull_request) Failing after 1m55s

This commit is contained in:
2026-08-24 00:22:00 +02:00
parent f1442527a2
commit cdf4078123
20 changed files with 427 additions and 20 deletions

View File

@@ -18,6 +18,9 @@ import pytest
from tests.e2e._pipeline_harness import Server, seed_library
# Every check here belongs to the documentation gate (US09-05).
pytestmark = pytest.mark.phase_i
@pytest.fixture(scope="module")
def server(tmp_path_factory):

View File

@@ -24,10 +24,13 @@ 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,
@@ -39,21 +42,29 @@ IMAGES = DOCS / "images"
ALBUM = "rome"
VIEWPORT = {"width": 1280, "height": 900}
# One per stage page of the manual: the route to visit, and the element whose
# presence means the view has actually finished rendering.
# 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", "stage-safety"),
("inventory", "#/inventory", "asset-row"),
("duplicates", "#/duplicates", None),
("safety", "#/safety", None),
("analysis", "#/analyze", "analyze-counts"),
("albums", f"#/albums?album={ALBUM}", "suggested-name"),
("renames", "#/renames", None),
("uploads", "#/uploads", "upload-scope"),
("archive", "#/archive", "archive-locations"),
("statistics", "#/stats", None),
("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"
@@ -90,17 +101,23 @@ def _prepare(base: str, seeded) -> None:
).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, ready in SHOTS:
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)
else:
page.locator("main h1").first.wait_for(timeout=30_000)
page.screenshot(path=str(target / f"{name}.png"))
written.append(name)
return written
@@ -110,7 +127,7 @@ def test_the_generator_produces_every_screenshot_the_manual_references(page, sta
destination = IMAGES if writing() else tmp_path / "images"
written = _capture(page, stack, destination)
assert sorted(written) == sorted(name for name, _, _ in SHOTS)
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"
@@ -133,6 +150,60 @@ def test_no_screenshot_shows_a_real_path_or_a_secret(page, stack, tmp_path):
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():