US09-01: Serve the Documentation Inside the Application (#107)
This commit was merged in pull request #107.
This commit is contained in:
119
tests/e2e/test_docs_ui.py
Normal file
119
tests/e2e/test_docs_ui.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""US09-01: the manuals, in the browser, under the application's real policy.
|
||||
|
||||
Everything here runs against a real ``photo_pipeline serve`` process serving the
|
||||
committed ``docs/`` tree and the vendored renderer — no fixture markdown, no stubbed
|
||||
fetch. What that buys is the assertion the story actually cares about: the pages
|
||||
render, the links between them work, a diagram becomes a diagram, and the console
|
||||
stays empty, including of CSP violations.
|
||||
|
||||
The offline contract — reachability, dead links, pinned checksums — is
|
||||
``tests/integration/test_documentation.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.e2e._pipeline_harness import Server, seed_library
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def server(tmp_path_factory):
|
||||
seeded = seed_library(tmp_path_factory.mktemp("docs"), {"a": 1}, {})
|
||||
running = Server(seeded).start()
|
||||
try:
|
||||
yield running
|
||||
finally:
|
||||
running.stop()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def quiet(page):
|
||||
"""Any console error, page error, or failed request fails the test that caused
|
||||
it. A CSP violation arrives as a console error, which is the point."""
|
||||
problems: list[str] = []
|
||||
page.on("console", lambda m: problems.append(m.text) if m.type == "error" else None)
|
||||
page.on("pageerror", lambda error: problems.append(str(error)))
|
||||
page.on("requestfailed", lambda request: problems.append(f"failed request: {request.url}"))
|
||||
yield problems
|
||||
|
||||
|
||||
def test_the_documentation_opens_from_the_navigation(page, server, quiet):
|
||||
page.goto(f"{server.base}/app/#/workflow")
|
||||
page.locator('nav a[data-nav="docs"]').click()
|
||||
|
||||
page.get_by_test_id("doc").wait_for()
|
||||
assert page.get_by_test_id("doc").get_attribute("data-page") == "index"
|
||||
# The sidebar is the index's own reading order, not a second list to maintain.
|
||||
assert page.get_by_test_id("doc-pages").get_by_role("link", name="Overview").is_visible()
|
||||
assert quiet == []
|
||||
|
||||
|
||||
def test_a_link_between_documents_stays_inside_the_application(page, server, quiet):
|
||||
page.goto(f"{server.base}/app/#/docs")
|
||||
page.get_by_test_id("doc").wait_for()
|
||||
page.get_by_test_id("doc").get_by_role("link", name="Overview").click()
|
||||
|
||||
page.wait_for_selector('[data-testid="doc"][data-page="overview"]')
|
||||
assert "#/docs?page=overview" in page.url, "a relative .md link must not leave the app"
|
||||
# And back again, by the link the document itself carries.
|
||||
page.get_by_test_id("doc").get_by_role("link", name="← Documentation index").click()
|
||||
page.wait_for_selector('[data-testid="doc"][data-page="index"]')
|
||||
assert quiet == []
|
||||
|
||||
|
||||
def test_a_deep_link_to_a_heading_lands_on_that_heading(page, server, quiet):
|
||||
page.goto(f"{server.base}/app/#/docs?page=overview&anchor=the-workflow")
|
||||
heading = page.locator("#the-workflow")
|
||||
heading.wait_for()
|
||||
|
||||
assert heading.inner_text().strip() == "The workflow"
|
||||
assert heading.evaluate("node => node.getBoundingClientRect().top < window.innerHeight")
|
||||
assert quiet == []
|
||||
|
||||
|
||||
def test_a_mermaid_block_becomes_a_diagram_under_the_unchanged_script_policy(page, server, quiet):
|
||||
page.goto(f"{server.base}/app/#/docs?page=overview")
|
||||
diagram = page.get_by_test_id("diagram").first
|
||||
diagram.wait_for()
|
||||
|
||||
# A real drawing, not the source text and not an error node.
|
||||
assert diagram.locator("svg").count() == 1
|
||||
assert diagram.evaluate("node => node.querySelector('svg').getBBox().width") > 100
|
||||
assert page.locator("pre code.language-mermaid").count() == 0
|
||||
assert quiet == [], "rendering a diagram must not violate the policy"
|
||||
|
||||
policy = page.evaluate(
|
||||
"async () => (await fetch('/app/')).headers.get('content-security-policy')"
|
||||
)
|
||||
assert "script-src 'self';" in policy
|
||||
assert "unsafe-eval" not in policy
|
||||
|
||||
|
||||
def test_an_unknown_page_says_so_without_naming_a_path(page, server, quiet):
|
||||
page.goto(f"{server.base}/app/#/docs?page=no-such-manual")
|
||||
message = page.get_by_test_id("doc-not-found")
|
||||
message.wait_for()
|
||||
|
||||
text = message.inner_text()
|
||||
assert "does not exist" in text
|
||||
assert "/" not in text.replace("Back to the documentation index", ""), text
|
||||
message.get_by_role("link").click()
|
||||
page.wait_for_selector('[data-testid="doc"][data-page="index"]')
|
||||
# The missing page is a 404 and the browser says so; nothing else may go wrong,
|
||||
# and in particular the view must not throw on the way to its own message.
|
||||
assert all("404" in problem for problem in quiet), quiet
|
||||
|
||||
|
||||
def test_the_documentation_is_readable_without_a_session(page, server, quiet):
|
||||
"""The troubleshooting page is needed most by whoever is locked out."""
|
||||
page.goto(f"{server.base}/app/#/docs")
|
||||
page.get_by_test_id("doc").wait_for()
|
||||
unauthenticated = page.evaluate(
|
||||
"""async () => {
|
||||
const response = await fetch('/docs/index.md', { credentials: 'omit' });
|
||||
return { status: response.status, length: (await response.text()).length };
|
||||
}"""
|
||||
)
|
||||
assert unauthenticated["status"] == 200
|
||||
assert unauthenticated["length"] > 100
|
||||
179
tests/integration/test_documentation.py
Normal file
179
tests/integration/test_documentation.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""US09-01: the documentation the application serves, checked without a browser.
|
||||
|
||||
What is checkable offline is everything that makes the manuals *navigable* rather
|
||||
than merely present: every page reachable, every internal link and anchor landing
|
||||
somewhere real, the vendored renderer being the file that was pinned, and the
|
||||
application actually serving `docs/` in both the working copy and the image.
|
||||
|
||||
The rendering itself — marked, mermaid, anchors, link rewriting — is proven in a
|
||||
real browser by ``tests/e2e/test_docs_ui.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from photo_pipeline.api.app import create_app
|
||||
from photo_pipeline.api.security import DEFAULT_HEADERS
|
||||
from photo_pipeline.config import Config
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
DOCS = REPO / "docs"
|
||||
VENDOR = REPO / "frontend" / "js" / "vendor"
|
||||
INDEX = DOCS / "index.md"
|
||||
|
||||
MARKDOWN_LINK = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)")
|
||||
HEADING = re.compile(r"^#{1,6}\s+(.+?)\s*$", re.MULTILINE)
|
||||
FENCE = re.compile(r"^```.*?^```", re.MULTILINE | re.DOTALL)
|
||||
|
||||
|
||||
def pages() -> list[Path]:
|
||||
return sorted(DOCS.rglob("*.md"))
|
||||
|
||||
|
||||
def body(path: Path) -> str:
|
||||
"""A document without its fenced code, so an example link in a shell snippet is
|
||||
not mistaken for a link the reader can follow."""
|
||||
return FENCE.sub("", path.read_text())
|
||||
|
||||
|
||||
def slug(text: str) -> str:
|
||||
"""The heading-anchor rule, mirroring ``frontend/js/docs.js``.
|
||||
|
||||
Deliberately duplicated: this check has to run without a browser. The browser
|
||||
test is the authority on the real behaviour, and it asserts the same anchors.
|
||||
"""
|
||||
cleaned = re.sub(r"[^\w\s-]", "", text.lower(), flags=re.UNICODE).strip()
|
||||
return re.sub(r"[\s-]+", "-", cleaned).strip("-") or "section"
|
||||
|
||||
|
||||
def anchors(path: Path) -> set[str]:
|
||||
found: dict[str, int] = {}
|
||||
for heading in HEADING.findall(body(path)):
|
||||
# Markdown emphasis and inline code are not part of the rendered text.
|
||||
plain = re.sub(r"[*`_]", "", heading)
|
||||
base = slug(plain)
|
||||
found[base] = found.get(base, 0) + 1
|
||||
return {name if index == 1 else f"{name}-{index}" for name, count in found.items() for index in range(1, count + 1)}
|
||||
|
||||
|
||||
def internal_links(path: Path) -> list[str]:
|
||||
return [
|
||||
href
|
||||
for href in MARKDOWN_LINK.findall(body(path))
|
||||
if not re.match(r"^[a-z][a-z\d+.-]*:", href, re.IGNORECASE) and not href.startswith("//")
|
||||
]
|
||||
|
||||
|
||||
# ── the documentation tree ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_the_index_exists_and_names_every_page():
|
||||
"""A page nobody links to is a page nobody reads."""
|
||||
assert INDEX.is_file()
|
||||
listed = {
|
||||
(INDEX.parent / href.split("#")[0]).resolve()
|
||||
for href in internal_links(INDEX)
|
||||
if href.split("#")[0].endswith(".md")
|
||||
}
|
||||
unreachable = [page.name for page in pages() if page != INDEX and page.resolve() not in listed]
|
||||
assert unreachable == [], f"not linked from the index: {unreachable}"
|
||||
|
||||
|
||||
def test_every_internal_link_and_anchor_resolves():
|
||||
broken: list[str] = []
|
||||
for page in pages():
|
||||
for href in internal_links(page):
|
||||
target, _, anchor = href.partition("#")
|
||||
destination = page if not target else (page.parent / target).resolve()
|
||||
if not destination.is_file():
|
||||
broken.append(f"{page.name} → {href} (no such file)")
|
||||
continue
|
||||
if anchor and anchor not in anchors(destination):
|
||||
broken.append(f"{page.name} → {href} (no such heading)")
|
||||
assert broken == [], broken
|
||||
|
||||
|
||||
def test_every_page_starts_with_one_title():
|
||||
for page in pages():
|
||||
titles = [line for line in page.read_text().splitlines() if line.startswith("# ")]
|
||||
assert len(titles) == 1, f"{page.name} has {len(titles)} top-level titles"
|
||||
|
||||
|
||||
# ── the vendored renderer ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_the_vendored_libraries_are_the_files_that_were_pinned():
|
||||
"""A vendored dependency without a recorded checksum is a dependency nobody is
|
||||
reviewing. Replacing one has to be a visible change to this manifest."""
|
||||
manifest = json.loads((VENDOR / "VERSIONS.json").read_text())
|
||||
recorded = {entry["file"]: entry for entry in manifest["libraries"]}
|
||||
|
||||
on_disk = {path.name for path in VENDOR.iterdir() if path.suffix in (".js", ".mjs")}
|
||||
assert on_disk == set(recorded), f"unrecorded vendored files: {on_disk ^ set(recorded)}"
|
||||
|
||||
for name, entry in recorded.items():
|
||||
digest = hashlib.sha256((VENDOR / name).read_bytes()).hexdigest()
|
||||
assert digest == entry["sha256"], f"{name} does not match the pinned checksum"
|
||||
assert entry["version"] in entry["url"], f"{name}: the pinned URL and version disagree"
|
||||
assert entry["license"], f"{name}: no license recorded"
|
||||
|
||||
|
||||
def test_the_documentation_view_is_wired_into_the_shell():
|
||||
shell = (REPO / "frontend" / "index.html").read_text()
|
||||
assert 'data-nav="docs"' in shell, "the manuals are unreachable from the navigation"
|
||||
assert '"/docs"' in (REPO / "frontend" / "js" / "app.js").read_text()
|
||||
|
||||
|
||||
# ── the policy the view runs under ───────────────────────────────────────────
|
||||
|
||||
|
||||
def test_the_script_boundary_is_unchanged_and_only_style_was_relaxed():
|
||||
"""The measured cost of rendering diagrams in the browser, held to that cost.
|
||||
|
||||
mermaid needs to style the SVG it builds, which `style-src 'self'` refuses. It
|
||||
does not need to execute generated code, so `script-src` must never acquire the
|
||||
escape hatch that would let injected markup run.
|
||||
"""
|
||||
policy = DEFAULT_HEADERS["content-security-policy"]
|
||||
directives = {
|
||||
part.split(" ")[0]: part.split(" ")[1:]
|
||||
for part in (piece.strip() for piece in policy.split(";"))
|
||||
if part
|
||||
}
|
||||
assert directives["script-src"] == ["'self'"]
|
||||
assert "'unsafe-inline'" in directives["style-src"]
|
||||
for directive in ("default-src", "img-src", "connect-src", "font-src"):
|
||||
assert "'self'" in directives[directive]
|
||||
assert "'unsafe-inline'" not in directives[directive]
|
||||
assert directives["frame-ancestors"] == ["'none'"]
|
||||
|
||||
|
||||
# ── serving it ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_the_application_serves_the_markdown_without_a_session(tmp_path):
|
||||
"""Whoever cannot get past the access secret is exactly who needs these pages."""
|
||||
config = Config.from_env({"PHOTO_PIPELINE_DATA_DIR": str(tmp_path / "data")})
|
||||
(tmp_path / "data").mkdir()
|
||||
with TestClient(create_app(config), raise_server_exceptions=False) as client:
|
||||
response = client.get("/docs/index.md", headers={"cookie": ""})
|
||||
assert response.status_code == 200
|
||||
assert "Photo Pipeline documentation" in response.text
|
||||
assert client.get("/docs/overview.md").status_code == 200
|
||||
assert client.get("/docs/nothing-here.md").status_code == 404
|
||||
# The mount is a directory, not a path parameter: nothing above it is reachable.
|
||||
assert client.get("/docs/../pyproject.toml").status_code in (307, 404)
|
||||
assert client.get("/docs/%2e%2e/pyproject.toml").status_code == 404
|
||||
|
||||
|
||||
def test_the_image_ships_the_documentation_it_serves():
|
||||
"""An image without `docs/` serves an empty manual — and the build context is
|
||||
deny-by-default, so a new directory is excluded until it is named."""
|
||||
assert "\n!docs\n" in (REPO / ".dockerignore").read_text()
|
||||
assert "COPY docs ./docs" in (REPO / "Dockerfile").read_text()
|
||||
@@ -193,10 +193,13 @@
|
||||
"US08-05": [
|
||||
"tests/integration/test_container_gate.py",
|
||||
"tests/e2e/test_phase_h_container.py"
|
||||
],
|
||||
"US09-01": [
|
||||
"tests/integration/test_documentation.py",
|
||||
"tests/e2e/test_docs_ui.py"
|
||||
]
|
||||
},
|
||||
"planned": [
|
||||
"US09-01",
|
||||
"US09-02",
|
||||
"US09-03",
|
||||
"US09-04",
|
||||
|
||||
Reference in New Issue
Block a user