@@ -1,25 +1,32 @@
""" Phase 10 E2E (Playwright): the clickable document viewer.
""" Phase 26 E2E (Playwright): documents open in the almost-fullscreen
modal — not in a new page.
Story: ``.agent/user_stories/document-viewer .md``
Story: ``.agent/user_stories/document-modal .md``
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_document_viewer.py -v --no-cov
Seeding reuses the real importer against ``tests/fixtures/docs/`` with the
deterministic mock embeddings (same pattern as the earlier story suites).
deterministic mock embeddings (same harness as the phase-10 suite — only
the assertions changed: chips/row links now open the SAME-PAGE modal,
no ``expect_popup``).
Test → story mapping (Playwright Mapping Rule):
1. ``test_source_chip_opens_document`` — chip → NEW TAB → viewer with
title + known content string + format badge .
2. ``test_sources_row_links_to_viewer`` — Sources path link ( yaml
fixture) → viewer with raw content in a ``pre`` .
3. ``test_markdown_renders_and_stays_xss_safe`` — md fixture containing
``<script>alert(1)</script>`` renders as visible escaped text (no
execution).
4. ``test_missing_doc_shows_not_found`` — unknown doc → not-found
state + Sources link; no console crash.
5. ``test_viewer_theme_and_no_cdn`` — dark theme + every
``script[src]`` / ``link[href]`` local or ``data:`` + a11y frame.
1. ``test_source_chip_opens_modal`` — chat chip → modal opens in-page
(NO new tab, URL unchanged), title + ``.doc-md`` content + meta row .
2. ``test_sources_row_opens_modal`` — Sources path link → modal, yaml in
``<pre.doc-raw>``, mono font, URL unchanged .
3. ``test_modal_closes_on_button_escape_and_backdrop`` — close via
``#doc-modal-close``, via backdrop click, via ``Escape``.
4. ``test_modal_focus_and_a11y`` — ``role= " dialog " `` + ``aria-modal``,
focus inside the panel on open, close button has an ``aria-label``.
5. ``test_modal_xss_safe`` — hostile md document opened through the modal
renders as escaped text; no dialog fires.
6. ``test_standalone_page_still_works`` — the dedicated ``/document.html``
page keeps its phase-10 contract (title/content/badges, not-found,
dark theme, no-CDN, a11y frame, ≤736px md column).
7. ``test_modal_theme_and_no_cdn`` — dark page background, the panel on
the Phase-08 surface colour, every asset same-origin or ``data:``.
"""
from __future__ import annotations
@@ -83,60 +90,84 @@ def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
return _run_in_thread ( _import_fixtures ( mock_port ) )
def _ask_for_chip ( page : Page , app_url : str ) - > Any :
""" Drive one chat turn and return the kubernetes.md source chip. """
page . goto ( app_url )
page . fill ( " #message-input " , QUESTION )
page . click ( " #send-btn " )
chip = page . locator ( " .msg.brain .source-chip " , has_text = " kubernetes.md " )
expect ( chip ) . to_have_count ( 1 , timeout = 30_000 )
return chip
def _assert_closed ( page : Page ) - > None :
""" The modal is fully closed: the hidden attribute is back and the
overlay is gone from view. """
expect ( page . locator ( " #doc-modal " ) ) . to_have_attribute ( " hidden " , " " )
expect ( page . locator ( " .doc-modal " ) ) . not_to_be_visible ( )
# ---------------------------------------------------------------------------
# 1. Chat source chip → new tab → full document
# 1. Chat source chip → SAME-PAGE modal (no new tab)
# ---------------------------------------------------------------------------
def test_source_chip_opens_document (
def test_source_chip_opens_modal (
page : Page , app_url : str , mock_llm : int , db_ready : None
) - > None :
_reset_db ( mock_llm , seed = True )
page . set_default_timeout ( 30_000 )
page . goto ( app_url )
page . fill ( " #message-input " , QUESTION )
page . click ( " #send-btn " )
chip = page . locator ( " .msg.brain .source-chip " , has_text = " kubernetes.md " )
expect ( chip ) . to_have_count ( 1 , timeout = 30_000 )
# New-tab contract: same-origin viewer URL, all query values encoded
# (the path's slashes come out as %2F — exactly why encoding matters),
# plus back=/ (phase 13) so the viewer's back button returns to chat.
chip = _ask_for_chip ( page , app_url )
# The encoded viewer URL stays as the no-JS / context-menu escape
# hatch — but phase 26 removed target=_blank: the left click is
# intercepted and opens the modal in place.
expect ( chip . first ) . to_have_attribute (
" href " , " /document.html?source=docs&path=homelab %2F kubernetes.md&back= %2F "
)
expect ( chip . first ) . to_have_attribute ( " target " , " _blank " )
expect ( chip . first ) . to_have_attribute ( " rel " , " noopener " )
expect ( chip . first ) . not_to_have_attribute ( " target " )
with page . expect_popup ( ) as popup_info :
chip . first . click ( )
viewer = popup_info . value
expect ( viewer ) . to_have_url (
re . compile (
re . escape (
f " { app_url } /document.html?source=docs&path=homelab%2Fkubernetes.md&back=%2F "
)
)
before = len ( page . context . pages )
chip . first . click ( )
# No new tab: the click must not have spawned a page.
assert len ( page . context . pages ) == before , " clicking a chip must not open a new tab "
# The almost-fullscreen modal becomes visible (hidden attribute gone).
expect ( page . locator ( " #doc-modal " ) ) . not_to_have_attribute ( " hidden " )
expect ( page . locator ( " .doc-modal " ) ) . to_be_visible ( )
# "Almost-fullscreen": the panel is min(1100px, 96vw) × 92vh, centered
# (at a 1280px viewport the 1100px cap wins over 96vw = 1228.8px).
box = page . locator ( " #doc-modal-panel " ) . bounding_box ( )
assert box is not None , " modal panel not rendered "
expected_w = min ( 1100 , 0.96 * 1280 )
expected_h = 0.92 * 800
assert abs ( box [ " width " ] - expected_w ) < 2 , f " panel width { box [ ' width ' ] } (want ~ { expected_w } ) "
assert abs ( box [ " height " ] - expected_h ) < 2 , f " panel height { box [ ' height ' ] } (want ~ { expected_h } ) "
# Same content the /document.html page renders: title, markdown in
# the centered .doc-md column (the modal's content target is
# #doc-modal-content — the modal variant of the page's #doc-content).
expect ( page . locator ( " #doc-modal-title " ) ) . to_have_text ( " Kubernetes Homelab Cluster " )
expect ( page . locator ( " #doc-modal-content .doc-md " ) ) . to_have_count ( 1 )
expect ( page . locator ( " #doc-modal-content " ) ) . to_contain_text ( " Talos Linux on three nodes " )
# Meta row mirrors the viewer: source · format · mono path · indexed · chunks.
expect ( page . locator ( " #doc-modal-meta .doc-source-badge " ) ) . to_have_text ( " docs " )
expect ( page . locator ( " #doc-modal-meta .format-badge " ) ) . to_have_text ( " md " )
expect ( page . locator ( " #doc-modal-meta .doc-path " ) ) . to_have_text ( " homelab/kubernetes.md " )
expect ( page . locator ( " #doc-modal-meta .doc-indexed " ) ) . to_contain_text ( " Indexed " )
assert re . fullmatch (
r " \ d+ chunks? " , page . locator ( " #doc-modal-meta .doc-chunks " ) . inner_text ( )
)
expect ( viewer . locator ( " #doc-title " ) ) . to_have_text ( " Kubernetes Homelab Cluster " )
# Meta row: source badge · format badge · mono path · indexed · chunks .
expect ( viewer . locator ( " #doc-meta .doc-source-badge " ) ) . to_have_text ( " docs " )
expect ( viewer . locator ( " #doc-meta .format-badge " ) ) . to_have_text ( " md " )
expect ( viewer . locator ( " #doc-meta .doc-path " ) ) . to_have_text ( " homelab/kubernetes.md " )
expect ( viewer . locator ( " #doc-meta .doc-indexed " ) ) . to_contain_text ( " Indexed " )
assert re . fullmatch ( r " \ d+ chunks? " , viewer . locator ( " #doc-meta .doc-chunks " ) . inner_text ( ) )
# Full document, rendered markdown in the centered column (not a pre).
expect ( viewer . locator ( " #doc-content .doc-md " ) ) . to_have_count ( 1 )
expect ( viewer . locator ( " #doc-content " ) ) . to_contain_text ( " Talos Linux on three nodes " )
# Still the chat page: no navigation happened .
assert page . url == app_url + " / " , f " navigated away: { page . url } "
# ---------------------------------------------------------------------------
# 2. Sources table path link → viewer (yaml → raw pre)
# 2. Sources table path link → same-page modal (yaml → raw pre)
# ---------------------------------------------------------------------------
def test_sources_row_links_to_viewer (
def test_sources_row_opens_modal (
page : Page , app_url : str , mock_llm : int , db_ready : None
) - > None :
_reset_db ( mock_llm , seed = True )
@@ -146,34 +177,119 @@ def test_sources_row_links_to_viewer(
expect ( row ) . to_have_count ( 1 )
link = row . locator ( " td:nth-child(2) a.doc-link " )
expect ( link ) . to_have_count ( 1 )
# Encoded URL: the slashes in the p ath value come out as %2F.
# Encoded URL kept as the escape h atc h (slashes come out as %2F);
# no target=_blank any more.
expect ( link ) . to_have_attribute (
" href " ,
" /document.html?source=docs&path=homelab %2F container_gitlab %2F gitlab-compose.yaml " ,
)
expect ( link ) . to_have_attribute ( " target " , " _blank " )
expect ( link ) . to_have_attribute ( " rel " , " noopener " )
expect ( link ) . not_to_have_attribute ( " target " )
expect ( link ) . to_have_attribute ( " title " , " homelab/container_gitlab/gitlab-compose.yaml " )
with page . expect_popup ( ) as popup_info :
link . click ( )
viewer = popup_info . value
expect ( viewer . locator ( " #doc-title " ) ) . to_have_text ( " gitlab-compose " )
expect ( viewer . locator ( " #doc-meta .format-badge " ) ) . to_have_text ( " yaml " )
before = len ( page . context . pages )
link . click ( )
assert len ( page . context . pages ) == before , " clicking a row link must not open a new tab "
expect ( page . locator ( " .doc-modal " ) ) . to_be_visible ( )
expect ( page . locator ( " #doc-modal-title " ) ) . to_have_text ( " gitlab-compose " )
expect ( page . locator ( " #doc-modal-meta .format-badge " ) ) . to_have_text ( " yaml " )
# Non-markdown formats render as escaped monospace text in a pre.
pre = viewer . locator ( " #doc-content pre.doc-raw " )
pre = page . locator ( " #doc-modal- content pre.doc-raw " )
expect ( pre ) . to_have_count ( 1 )
expect ( pre ) . to_contain_text ( " gitlab/gitlab-ce:17.2.1-ce.0 " )
font = pre . evaluate ( " el => getComputedStyle(el).fontFamily " )
assert " mono " in font
# Still on the Sources page: no navigation happened.
assert page . url == app_url + " /sources.html " , f " navigated away: { page . url } "
# ---------------------------------------------------------------------------
# 3. Markdown renders through the shared renderer and stays XSS-saf e
# 3. Close on button, backdrop, and Escap e
# ---------------------------------------------------------------------------
def test_markdown_renders_and_stays_xss_safe (
def test_modal_closes_on_button_escape_and_backdrop (
page : Page , app_url : str , mock_llm : int , db_ready : None
) - > None :
_reset_db ( mock_llm , seed = True )
page . set_default_timeout ( 30_000 )
chip = _ask_for_chip ( page , app_url )
def open_and_loaded ( ) - > None :
chip . first . click ( )
expect ( page . locator ( " #doc-modal-title " ) ) . to_have_text ( " Kubernetes Homelab Cluster " )
# 1) The close button.
open_and_loaded ( )
page . click ( " #doc-modal-close " )
_assert_closed ( page )
# 2) The backdrop — a point outside the centered 96vw × 92vh panel
# (panel starts at 4vh from the top / 2vw from the edge).
open_and_loaded ( )
page . locator ( " #doc-modal-backdrop " ) . click ( position = { " x " : 5 , " y " : 5 } )
_assert_closed ( page )
# 3) Escape — the capture is document-level, so it works from any
# focus position inside (or outside) the panel.
open_and_loaded ( )
page . keyboard . press ( " Escape " )
_assert_closed ( page )
# After closing, the page behind is untouched: chat is still there.
assert page . url == app_url + " / "
expect ( page . locator ( " #composer " ) ) . to_be_visible ( )
# ---------------------------------------------------------------------------
# 4. Focus management + dialog a11y frame
# ---------------------------------------------------------------------------
def test_modal_focus_and_a11y (
page : Page , app_url : str , mock_llm : int , db_ready : None
) - > None :
_reset_db ( mock_llm , seed = True )
page . set_default_timeout ( 30_000 )
chip = _ask_for_chip ( page , app_url )
chip . first . click ( )
expect ( page . locator ( " #doc-modal-title " ) ) . to_have_text ( " Kubernetes Homelab Cluster " )
# The panel is a proper modal dialog, labelled by its title.
panel = page . locator ( " #doc-modal-panel " )
expect ( panel ) . to_have_attribute ( " role " , " dialog " )
expect ( panel ) . to_have_attribute ( " aria-modal " , " true " )
expect ( panel ) . to_have_attribute ( " aria-labelledby " , " doc-modal-title " )
# On open, focus moves into the dialog's content target.
focus_id = page . evaluate ( " () => document.activeElement && document.activeElement.id " )
assert focus_id == " doc-modal-content " , f " focus { focus_id !r} did not move into the modal "
# The close control carries an accessible name (icon-only button).
expect ( page . locator ( " #doc-modal-close " ) ) . to_have_attribute ( " aria-label " , " Close document " )
# The "Full page" escape hatch is rebuilt to the same encoded viewer
# URL (no back param — the dedicated page's own default applies).
expect ( page . locator ( " #doc-modal-open " ) ) . to_be_visible ( )
expect ( page . locator ( " #doc-modal-open " ) ) . to_have_attribute (
" href " , " /document.html?source=docs&path=homelab %2F kubernetes.md "
)
# Closing returns focus to the triggering control.
page . keyboard . press ( " Escape " )
_assert_closed ( page )
focus_id = page . evaluate ( " () => document.activeElement && document.activeElement.className " )
assert " source-chip " in ( focus_id or " " ) , f " focus { focus_id !r} did not return to the chip "
# ---------------------------------------------------------------------------
# 5. Modal rendering stays XSS-safe (hostile md, opened via the modal)
# ---------------------------------------------------------------------------
def test_modal_xss_safe (
page : Page , app_url : str , mock_llm : int , db_ready : None
) - > None :
_reset_db ( mock_llm , seed = True )
@@ -200,28 +316,46 @@ def test_markdown_renders_and_stays_xss_safe(
d . dismiss ( )
page . on ( " dialog " , _catch_dialog )
page . goto ( f " { app_url } /document.html?source=docs&path=notes%2Fxss-fixture.md " )
expect ( page . locator ( " #doc-title " ) ) . to_have_text ( " Xss Fixture " )
# The Sources table lists every indexed document — the admin entry
# point into the modal for a doc the chat never cited.
login ( page , app_url )
row = page . locator ( " #docs-tbody tr " , has_text = " xss-fixture.md " )
expect ( row ) . to_have_count ( 1 )
row . locator ( " td:nth-child(2) a.doc-link " ) . click ( )
expect ( page . locator ( " .doc-modal " ) ) . to_be_visible ( )
expect ( page . locator ( " #doc-modal-title " ) ) . to_have_text ( " Xss Fixture " )
# The tag shows up as VISIBLE, ESCAPED text — rendered, never executed.
expect ( page . locator ( " #doc-content " ) ) . to_contain_text ( " <script>alert(1)</script> " )
expect ( page . locator ( " #doc-content " ) ) . to_contain_text ( " XSS-FIXTURE-MARKER " )
assert page . locator ( " #doc-content script " ) . count ( ) == 0 , " hostile script became live HTML "
expect ( page . locator ( " #doc-modal- content " ) ) . to_contain_text ( " <script>alert(1)</script> " )
expect ( page . locator ( " #doc-modal- content " ) ) . to_contain_text ( " XSS-FIXTURE-MARKER " )
assert page . locator ( " #doc-modal- content script " ) . count ( ) == 0 , " hostile script became live HTML "
assert dialogs == [ ] , f " dialog fired — script executed: { dialogs } "
# ---------------------------------------------------------------------------
# 4 . Missing document → designed not-found state, no console crash
# 6 . The dedicated /document.html page keeps its phase-10 contract
# ---------------------------------------------------------------------------
def test_missing_doc_shows_not_found (
def test_standalone_page_still_works (
page : Page , app_url : str , mock_llm : int , db_ready : None
) - > None :
_reset_db ( mock_llm , seed = True )
errors : list [ str ] = [ ]
page . on ( " pageerror " , lambda e : errors . append ( str ( e ) ) )
# Direct link renders exactly as before (phase 10): title, meta row,
# markdown in the centered column.
page . goto ( f " { app_url } /document.html?source=docs&path=homelab%2Fkubernetes.md " )
expect ( page . locator ( " #doc-title " ) ) . to_have_text ( " Kubernetes Homelab Cluster " )
expect ( page . locator ( " #doc-meta .doc-source-badge " ) ) . to_have_text ( " docs " )
expect ( page . locator ( " #doc-meta .format-badge " ) ) . to_have_text ( " md " )
expect ( page . locator ( " #doc-content .doc-md " ) ) . to_have_count ( 1 )
expect ( page . locator ( " #doc-content " ) ) . to_contain_text ( " Talos Linux on three nodes " )
# Not-found state: an unknown pair AND missing params — no console
# crash, the designed card with the Sources link.
page . goto ( f " { app_url } /document.html?source=docs&path=definitely/not/here.md " )
expect ( page . locator ( " #doc-title " ) ) . to_have_text ( " Document not found " )
card = page . locator ( " #doc-not-found " )
@@ -229,29 +363,15 @@ def test_missing_doc_shows_not_found(
expect ( card ) . to_contain_text ( " Document not found " )
expect ( card . locator ( " a.doc-open-sources " ) ) . to_have_attribute ( " href " , " /sources.html " )
expect ( page . locator ( " #doc-content " ) ) . to_be_empty ( )
# Missing params → the same designed state (no fetch, no crash).
page . goto ( f " { app_url } /document.html " )
expect ( page . locator ( " #doc-not-found " ) ) . to_be_visible ( )
assert errors == [ ] , f " console crashes: { errors } "
# ---------------------------------------------------------------------------
# 5. Dark theme + all assets local + a11y frame
# ---------------------------------------------------------------------------
def test_viewer_theme_and_no_cdn ( page : Page , app_url : str , mock_llm : int , db_ready : None ) - > None :
_reset_db ( mock_llm , seed = True )
# Dark theme + all assets local + a11y frame + capped md column.
page . goto ( f " { app_url } /document.html?source=docs&path=homelab%2Fkubernetes.md " )
expect ( page . locator ( " #doc-content .doc-md " ) ) . not_to_be_empty ( )
# Dark theme inherited from phase 08 (same sampling as that story).
bg = page . evaluate ( " () => getComputedStyle(document.documentElement).backgroundColor " )
assert bg == " rgb(10, 14, 23) "
# No-CDN: every script/link reference is same-origin or a data: URI.
refs = page . evaluate (
""" () => [...document.querySelectorAll( " script[src], link[href] " )]
.map((el) => el.src || el.href) """
@@ -260,8 +380,6 @@ def test_viewer_theme_and_no_cdn(page: Page, app_url: str, mock_llm: int, db_rea
for ref in refs :
assert ref . startswith ( app_url ) or ref . startswith ( " data: " ) , f " non-local: { ref } "
# A11y frame: landmarks, skip link, aria-live around the load→content
# swap, and focus moved to main on load.
expect ( page . locator ( " header.doc-header " ) ) . to_have_count ( 1 )
expect ( page . locator ( " main#main " ) ) . to_have_count ( 1 )
expect ( page . locator ( " footer.app-footer " ) ) . to_have_count ( 1 )
@@ -272,3 +390,40 @@ def test_viewer_theme_and_no_cdn(page: Page, app_url: str, mock_llm: int, db_rea
# Markdown column centered and capped at 46rem (736px at 16px root).
box = page . locator ( " #doc-content .doc-md " ) . bounding_box ( )
assert box is not None and box [ " width " ] < = 736 + 1
assert errors == [ ] , f " console crashes: { errors } "
# ---------------------------------------------------------------------------
# 7. Modal theme + no CDN on the touched page
# ---------------------------------------------------------------------------
def test_modal_theme_and_no_cdn (
page : Page , app_url : str , mock_llm : int , db_ready : None
) - > None :
_reset_db ( mock_llm , seed = True )
page . set_default_timeout ( 30_000 )
chip = _ask_for_chip ( page , app_url )
chip . first . click ( )
expect ( page . locator ( " #doc-modal-title " ) ) . to_have_text ( " Kubernetes Homelab Cluster " )
expect ( page . locator ( " #doc-modal-content .doc-md " ) ) . not_to_be_empty ( )
# Dark theme (phase 08): the page background is untouched, and the
# modal panel sits on the Phase-08 surface colour (#121a2e).
bg = page . evaluate ( " () => getComputedStyle(document.documentElement).backgroundColor " )
assert bg == " rgb(10, 14, 23) "
surface = page . evaluate (
" () => getComputedStyle(document.querySelector( ' .doc-modal-panel ' )).backgroundColor "
)
assert surface == " rgb(18, 26, 46) " , f " panel not on the Phase-08 surface: { surface } "
# No-CDN: every script/link reference on the chat page (the touched
# page) is same-origin or a data: URI — the modal adds no assets.
refs = page . evaluate (
""" () => [...document.querySelectorAll( " script[src], link[href] " )]
.map((el) => el.src || el.href) """
)
assert refs , " expected local asset references on the chat page "
for ref in refs :
assert ref . startswith ( app_url ) or ref . startswith ( " data: " ) , f " non-local: { ref } "