120 lines
4.9 KiB
Python
120 lines
4.9 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
|
|
|
|
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
|