145 lines
5.7 KiB
Python
145 lines
5.7 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 tests.conftest import session_client
|
|
from tests.e2e._pipeline_harness import (
|
|
Server,
|
|
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 to visit, and the element whose
|
|
# presence means the view has actually finished rendering.
|
|
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),
|
|
)
|
|
|
|
|
|
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)
|
|
|
|
|
|
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:
|
|
page.goto(f"{server.base}/app/{route}")
|
|
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
|
|
|
|
|
|
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_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}"
|