153 lines
6.5 KiB
Python
153 lines
6.5 KiB
Python
"""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
|
|
|
|
from pathlib import Path
|
|
|
|
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):
|
|
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_every_diagram_in_the_architecture_overview_draws(page, server, quiet):
|
|
"""Five diagrams, three of them state machines (US09-03). A mermaid block with a
|
|
syntax error renders an error node instead of throwing, so a page that merely
|
|
loaded proves nothing — count the drawings and read the source for the states."""
|
|
source = (Path(__file__).resolve().parents[2] / "docs" / "architecture.md").read_text()
|
|
expected = source.count("```mermaid")
|
|
assert expected >= 5, "the overview lost its diagrams"
|
|
|
|
page.goto(f"{server.base}/app/#/docs?page=architecture")
|
|
page.get_by_test_id("diagram").first.wait_for()
|
|
page.wait_for_function(
|
|
"count => document.querySelectorAll('[data-testid=\"diagram\"] svg').length === count",
|
|
arg=expected,
|
|
)
|
|
|
|
drawn = page.locator('[data-testid="diagram"] svg')
|
|
assert drawn.count() == expected
|
|
for index in range(expected):
|
|
diagram = drawn.nth(index)
|
|
# Labels may be <text> or HTML inside a <foreignObject> depending on the
|
|
# diagram type, so ask for the rendered text rather than a specific element.
|
|
labels = diagram.evaluate("node => node.textContent.trim().length")
|
|
assert labels > 0, f"diagram {index} drew no labels"
|
|
assert diagram.locator(".error-icon, .error-text").count() == 0, f"diagram {index} errored"
|
|
assert page.locator("pre code.language-mermaid").count() == 0
|
|
assert quiet == []
|
|
|
|
|
|
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
|