US09-01: Serve the Documentation Inside the Application
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
|
||||
Reference in New Issue
Block a user