"""Phase 153 E2E (Playwright): the in-page modal's "Full page" escape hatch carries its ORIGIN to the dedicated viewer, and the Copy path affordance works on BOTH surfaces (the standalone titlebar and the modal's meta block). Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_document_viewer_context.py -v --no-cov The two user-facing contracts (the phase's completion criteria): 1. **Origin back.** A document opened through the modal reaches the standalone viewer with the ``back`` context of the page it was opened FROM — a chat-origin open (the app.js call sites pass ``"/"``) lands on the back link ``href="/"`` labeled "Chat"; a Sources-origin open (the sources.js call site omits the param — the URL stays byte-identical to pre-phase) lands on ``href="/sources.html"`` labeled "Sources" (the phase-13 resolution, finally fed on every path). "Full page" (``#doc-modal-open``, ``target="_blank"`` by design) opens the dedicated viewer in a NEW TAB — the Playwright context's second page (the ``expect_popup`` idiom, the phase-36 story-suite precedent). 2. **Copy path.** The "Copy path" control (``.doc-copy-path`` — the shared renderDocument core builds it on both surfaces) copies ``source + "/" + path`` (ASSUMPTION A1 — the read-tool form, the byte-for-byte pin) with one click, with the established clipboard contract: a granted clipboard (the ``grant_permissions`` idiom — ``http://127.0.0.1`` is a secure context, but headless Chromium requires the permission grant before ``writeText`` resolves) renders the visible "Path copied." status + the surface's ``role="status"`` announcer reading the same text; a rejected clipboard (no grant — the established pattern: the non-secure/unguarded origin's ``writeText`` rejects, and the ungranted secure-context API rejects too) renders the inline fallback field (``.doc-path-fallback`` — input-like, prefilled with the exact path, SELECTS its full content on focus — the ``.share-link-fallback`` pattern) + the "Path ready — copy it from the field." status (the owner-locked fallback wording, path-adapted). Both tests are parameterized over the surface (standalone / modal — the per-page-duplicated helpers behave identically by contract). Regression guards: the DIRECT chip link (the ``documentUrl`` escape hatch — what a context-menu "open in new tab" reaches; a middle-click is not a reliable trigger in headless Chromium, so the contract is asserted on the exact href and verified by navigating to it — the phase-13 idiom) still carries ``&back=%2F``; and a direct ``/document.html?source=…&path=…`` open without the ``back`` param still defaults to ``/sources.html`` + "Sources" (the phase-13 fallback). Determinism: mock-LLM answers only (no ``E2E_REAL_LLM`` dependency), one fixture document (``docs/homelab/kubernetes.md`` — the copied string is a byte-for-byte pin), per-module DB truncate/re-import (the house fixture idiom, the phase-13/26 suites). Test → contract mapping (Playwright Mapping Rule): 1. ``test_chat_origin_full_page_backs_to_chat`` — the mock's scripted summary-read flow (the house scripted-turn convention, phase 119 LOCKED A1 — the read doc is the turn's ONLY chip) → the chat source chip → the phase-26 same-page modal → "Full page" (new tab) → the standalone ``#doc-back`` is ``href="/"`` with the "Chat" label (the phase-13 label-match contract, now actually fed on the modal path). 2. ``test_sources_origin_full_page_backs_to_sources`` — the RAG folder table's path link (drilled to its level — phase 97) → the modal → "Full page" (new tab) → the URL carries NO ``back`` param (asserted by absence) and the standalone ``#doc-back`` is ``href="/sources.html"`` with the "Sources" label (the default). 3. ``test_direct_chip_link_still_carries_back`` — the chat chip's ``href`` (the ``documentUrl`` escape hatch) still carries ``&back=%2F`` byte-for-byte; navigating to that exact href lands on ``href="/"`` + "Chat" (the pre-phase path is untouched). 4. ``test_copy_path_clipboard_success[standalone|modal]`` — the granted clipboard: the visible "Path copied." status + the surface's live region read it; the clipboard content is exactly ``docs/homelab/kubernetes.md``. 5. ``test_copy_path_fallback[standalone|modal]`` — the rejected clipboard: the fallback field appears, prefilled with the exact path, focused with its full content selected; the status reads "Path ready — copy it from the field." (+ the live region). 6. ``test_direct_open_without_back_defaults`` — a direct open without the ``back`` param resolves to ``/sources.html`` + "Sources" (the phase-13 fallback, regression guard). """ from __future__ import annotations import asyncio from pathlib import Path from threading import Thread from typing import Any import pytest from playwright.sync_api import Page, expect from sqlalchemy import text from app.config import Settings from app.db import SessionLocal from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient from e2e.auth_helpers import login REPO = Path(__file__).resolve().parents[2] FIXTURES = REPO / "tests" / "fixtures" / "docs" # The house scripted-turn convention (phase 119, LOCKED A1): the # scripted summary-read flow reads the kubernetes fixture, so the # turn's ONLY source chip is the read doc — the citation-bearing # bubble the chat-origin tests drive. QUESTION = ( "Read the suggested document: read docs/homelab/kubernetes.md — " "how is my Kubernetes cluster set up?" ) # The fixture document every test in this file uses (source=docs) — # one document, the copied-string pin is byte-for-byte on it. DOC_SOURCE = "docs" DOC_PATH = "homelab%2Fkubernetes.md" # percent-encoded (slashes) DOC_PATH_RAW = "homelab/kubernetes.md" DOC_TITLE = "Kubernetes Homelab Cluster" #: The copied string (ASSUMPTION A1 — the read-tool form): the byte #: for byte pin, pinned against the fixture document. COPIED_PATH = f"{DOC_SOURCE}/{DOC_PATH_RAW}" #: The phase-13 label-match contract pins (byte-for-byte). CHAT_BACK_HREF = "/" CHAT_BACK_LABEL = "Chat" SOURCES_BACK_HREF = "/sources.html" SOURCES_BACK_LABEL = "Sources" #: The owner-locked copy-path status strings (byte-for-byte — the #: em dash is U+2014, the share-fallback wording adapted for the #: path; pinned by the phase-153 unit suite as well). COPY_SUCCESS = "Path copied." COPY_FALLBACK = "Path ready — copy it from the field." #: The chip's escape-hatch href (the documentUrl contract, pre- #: phase): byte-for-byte, carries back=/ (encoded %2F). CHIP_HREF = f"/document.html?source={DOC_SOURCE}&path={DOC_PATH}&back=%2F" #: The Sources row link's escape-hatch href (no back param — the #: Sources call site omits it; byte-identical to pre-phase). SOURCES_LINK_HREF = f"/document.html?source={DOC_SOURCE}&path={DOC_PATH}" #: The modal's "Full page" escape-hatch hrefs (fullPageUrl — task #: 01): the chat open carries back=/ through; the Sources open #: omits it (byte-identical to pre-phase). MODAL_CHAT_FULLPAGE_HREF = CHIP_HREF # the same contract, now true MODAL_SOURCES_FULLPAGE_HREF = SOURCES_LINK_HREF async def _import_fixtures(mock_port: int) -> ImportSummary: kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"} settings = Settings(**kwargs) # pyright: ignore[reportCallIssue] return await import_sources([FIXTURES], LLMClient(settings)) def _run_in_thread(coro: Any) -> Any: """Run a coroutine on a worker thread. Playwright's sync API keeps an asyncio loop running on the test thread, so ``asyncio.run`` cannot be called directly from a test body. """ box: dict[str, Any] = {} def runner() -> None: try: box["value"] = asyncio.run(coro) except BaseException as e: # noqa: BLE001 — re-raised on the test thread box["error"] = e t = Thread(target=runner) t.start() t.join() if "error" in box: raise box["error"] return box["value"] def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None: """Truncate the KB (and query log), then optionally re-import fixtures.""" with SessionLocal() as db: db.execute(text("TRUNCATE chunks, documents, query_log")) db.commit() if not seed: return None return _run_in_thread(_import_fixtures(mock_port)) def _grant_clipboard(page: Page, app_url: str) -> None: """Grant the async-clipboard permissions on the context. ``http://127.0.0.1`` is a secure context, so ``navigator.clipboard`` exists — but headless Chromium still requires the permission grant before ``writeText`` resolves (without it the fallback branch fires — the share-chat suite's idiom). A test that does NOT grant exercises the rejected-clipboard branch deterministically.""" page.context.grant_permissions( ["clipboard-read", "clipboard-write"], origin=app_url ) def _ask_for_chip(page: Page, app_url: str) -> Any: """Drive one scripted chat turn and return the kubernetes.md source chip (the turn's READ doc — phase 119, LOCKED A1: chips cite read docs only, so the scripted summary-read flow is what makes the citation-bearing bubble exist).""" login(page, app_url, next="/") # phase 79: chat is require_user-gated 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) # Phase 119 A1: the read doc is the turn's ONLY chip. expect(page.locator(".msg.brain .source-chip")).to_have_count(1) return chip def _drill(page: Page, *names: str) -> None: """Phase 97: the catalog is the drill-down tree — click through the source/folder rows (exact name match) to the level that holds the asserted file.""" for name in names: page.click(f'#folders-tbody a.folder-link:text-is("{name}")') def _open_sources_modal(page: Page, app_url: str) -> None: """Open the fixture document in the same-page modal through the Sources path (the sources.js call site — the back param is omitted, so the "Full page" URL stays byte-identical to pre-phase).""" login(page, app_url) # phase 16: the Sources catalog is admin-only _drill(page, "docs", "homelab") row = page.locator("#docs-tbody tr", has_text="kubernetes.md") expect(row).to_have_count(1) row.locator("td:nth-child(2) a.doc-link").click() expect(page.locator("#doc-modal")).not_to_have_attribute("hidden") expect(page.locator("#doc-modal-title")).to_have_text(DOC_TITLE) # --------------------------------------------------------------------------- # 1. Chat → chip → modal → "Full page" (new tab) → back returns to Chat # --------------------------------------------------------------------------- def test_chat_origin_full_page_backs_to_chat( 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) # The pre-phase escape-hatch href is byte-identical (regression # guard #3 pins the full contract; here it is the contract the # chat call sites pass `back = "/"` through). expect(chip.first).to_have_attribute("href", CHIP_HREF) expect(chip.first).not_to_have_attribute("target") # phase 26: modal, not a new tab # Phase 26: the left click opens the same-page modal (no new tab). before = len(page.context.pages) chip.first.click() assert len(page.context.pages) == before, "clicking a chip must not open a new tab" expect(page.locator("#doc-modal-title")).to_have_text(DOC_TITLE) # Phase 153 (task 01): the "Full page" escape hatch carries the # open's back (the chat call site passed "/" → &back=%2F). expect(page.locator("#doc-modal-open")).to_be_visible() expect(page.locator("#doc-modal-open")).to_have_attribute("href", MODAL_CHAT_FULLPAGE_HREF) # "Full page" (target=_blank by design) opens the dedicated # viewer in a NEW TAB — the context's second page. with page.expect_popup() as popup_info: page.click("#doc-modal-open") viewer = popup_info.value viewer.set_default_timeout(30_000) # The origin param rode through (encoded %2F — the documentUrl # contract the modal now shares): the new tab's URL is exactly # the escape-hatch href, byte-for-byte. assert viewer.url == app_url + CHIP_HREF, ( f"the new tab's URL is not the escape-hatch href: {viewer.url}" ) # The standalone viewer rendered the document (not an error state). expect(viewer.locator("#doc-title")).to_have_text(DOC_TITLE) # The phase-13 label-match contract, now actually fed on the modal # path: the back link resolved to the chat page, labeled "Chat". back = viewer.locator("#doc-back") expect(back).to_have_attribute("href", CHAT_BACK_HREF) expect(back).to_have_text(CHAT_BACK_LABEL) # --------------------------------------------------------------------------- # 2. Sources → row link → modal → "Full page" (new tab) → back stays on # Sources (no back param — the default, asserted by ABSENCE) # --------------------------------------------------------------------------- def test_sources_origin_full_page_backs_to_sources( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) _open_sources_modal(page, app_url) # The row link's href carries NO back param (the sources.js call # site is unchanged — the byte-identity contract). expect(page.locator("#doc-modal-open")).to_be_visible() expect(page.locator("#doc-modal-open")).to_have_attribute( "href", MODAL_SOURCES_FULLPAGE_HREF ) assert "back=" not in MODAL_SOURCES_FULLPAGE_HREF # the omitted param # "Full page" opens the dedicated viewer in a NEW TAB. with page.expect_popup() as popup_info: page.click("#doc-modal-open") viewer = popup_info.value viewer.set_default_timeout(30_000) # The URL carries NO back param (asserted by absence — the Sources # path is byte-identical to pre-phase). assert "back=" not in viewer.url, f"unexpected back param: {viewer.url}" # The standalone viewer rendered the document (not an error state). expect(viewer.locator("#doc-title")).to_have_text(DOC_TITLE) # The back link kept the default target, labeled "Sources". back = viewer.locator("#doc-back") expect(back).to_have_attribute("href", SOURCES_BACK_HREF) expect(back).to_have_text(SOURCES_BACK_LABEL) # --------------------------------------------------------------------------- # 3. The direct chip link (the documentUrl escape hatch — what a # context-menu "open in new tab" reaches) still carries &back=%2F # --------------------------------------------------------------------------- def test_direct_chip_link_still_carries_back( 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) # The escape hatch is the chip's href itself (phase 26: the left # click is intercepted, but the encoded viewer URL survives as the # no-JS / context-menu "open in new tab" target) — byte-for-byte, # still carrying back=/ (encoded %2F). expect(chip.first).to_have_attribute("href", CHIP_HREF) expect(chip.first).not_to_have_attribute("target") # A middle-click is not a reliable trigger in headless Chromium, # so the contract is verified by navigating to the exact href # (the phase-13 idiom — "the back contract is asserted on that # exact href and verified by navigating to it directly"). page.goto(app_url + CHIP_HREF) expect(page.locator("#doc-title")).to_have_text(DOC_TITLE) back = page.locator("#doc-back") expect(back).to_have_attribute("href", CHAT_BACK_HREF) expect(back).to_have_text(CHAT_BACK_LABEL) # --------------------------------------------------------------------------- # 4. Copy path — the clipboard SUCCESS branch (parameterized over the # surface: the standalone titlebar / the modal's meta block) # --------------------------------------------------------------------------- def _open_copy_surface(page: Page, app_url: str, surface: str) -> None: """Reach the "Copy path" control on the requested surface with the fixture document rendered.""" if surface == "standalone": login(page, app_url, next="/") # phase 79: the viewer content is gated page.goto( f"{app_url}/document.html?source={DOC_SOURCE}&path={DOC_PATH}" ) expect(page.locator("#doc-title")).to_have_text(DOC_TITLE) return # surface == "modal": the Sources path (the modal renders through # the ONE shared renderDocument core — the control is the same # shape; the Sources open keeps the URL byte-identical to # pre-phase, so this also exercises the default-back surface). _open_sources_modal(page, app_url) @pytest.mark.parametrize("surface", ["standalone", "modal"]) def test_copy_path_clipboard_success( page: Page, app_url: str, mock_llm: int, db_ready: None, surface: str ) -> None: _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) _open_copy_surface(page, app_url, surface) _grant_clipboard(page, app_url) # the granted-clipboard idiom copy_btn = page.locator(".doc-copy-path") expect(copy_btn).to_have_count(1) copy_btn.click() if surface == "standalone": # The visible status (the .doc-copy-pop block — aria-hidden, # visual only) + the page's live region (the sole announcer). expect(page.locator(".doc-copy-status")).to_have_text(COPY_SUCCESS, timeout=15_000) announcer = page.locator("#doc-copy-announcer") else: # The modal's nodes sit inline in the wrapping #doc-modal-meta # block; #doc-modal-desc is the one a11y announcer. expect(page.locator("#doc-modal-meta .doc-copy-status")).to_have_text( COPY_SUCCESS, timeout=15_000 ) announcer = page.locator("#doc-modal-desc") expect(announcer).to_have_attribute("role", "status") expect(announcer).to_have_text(COPY_SUCCESS) # No fallback field on the success branch. expect(page.locator(".doc-path-fallback")).to_have_count(0) # The clipboard content is EXACTLY source/path (the byte-for-byte # pin — ASSUMPTION A1, the read-tool form). copied = page.evaluate("() => navigator.clipboard.readText().then(t => t)") assert copied == COPIED_PATH, f"clipboard {copied!r} != the pinned {COPIED_PATH!r}" # --------------------------------------------------------------------------- # 5. Copy path — the REJECTED-clipboard branch (parameterized over the # surface): the inline fallback field + the status line # --------------------------------------------------------------------------- @pytest.mark.parametrize("surface", ["standalone", "modal"]) def test_copy_path_fallback( page: Page, app_url: str, mock_llm: int, db_ready: None, surface: str ) -> None: _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) # NO clipboard grant: the established pattern — the ungranted # origin's writeText rejects, so the failure path renders the # inline fallback field (deterministic on every origin). _open_copy_surface(page, app_url, surface) copy_btn = page.locator(".doc-copy-path") expect(copy_btn).to_have_count(1) copy_btn.click() # The status line (the owner-locked fallback wording, path- # adapted) is visible + the live region reads it. if surface == "standalone": expect(page.locator(".doc-copy-status")).to_have_text(COPY_FALLBACK, timeout=15_000) expect(page.locator("#doc-copy-announcer")).to_have_text(COPY_FALLBACK) else: expect(page.locator("#doc-modal-meta .doc-copy-status")).to_have_text( COPY_FALLBACK, timeout=15_000 ) expect(page.locator("#doc-modal-desc")).to_have_text(COPY_FALLBACK) # The inline fallback field (the .share-link-fallback pattern, # input variant): it appears, is prefilled with the EXACT path # (the byte-for-byte pin), and is focused with its FULL content # selected (the select-on-focus contract — the owner can # Ctrl/Cmd+C directly). field = page.locator(".doc-path-fallback") expect(field).to_have_count(1) expect(field).to_be_visible() expect(field).to_have_attribute("readonly", "") expect(field).to_have_value(COPIED_PATH) state = field.evaluate( """(el) => ({ active: el === document.activeElement, start: el.selectionStart, end: el.selectionEnd, length: el.value.length, })""" ) assert state["length"] == len(COPIED_PATH), "the field is not prefilled with the path" assert state["active"], "the field is not focused on the rejected copy" assert state["start"] == 0 and state["end"] == state["length"], ( f"the field's content is not fully selected: {state}" ) # A failed copy never claims success: no success status anywhere. assert COPY_SUCCESS not in page.locator(".doc-copy-status").all_inner_texts() # --------------------------------------------------------------------------- # 6. A direct open WITHOUT the back param still defaults to Sources # (the phase-13 fallback — regression guard) # --------------------------------------------------------------------------- def test_direct_open_without_back_defaults( page: Page, app_url: str, mock_llm: int, db_ready: None ) -> None: _reset_db(mock_llm, seed=True) page.set_default_timeout(30_000) login(page, app_url, next="/") # phase 79: the viewer content is gated page.goto(f"{app_url}/document.html?source={DOC_SOURCE}&path={DOC_PATH}") assert "back=" not in page.url, f"unexpected back param: {page.url}" expect(page.locator("#doc-title")).to_have_text(DOC_TITLE) back = page.locator("#doc-back") expect(back).to_have_attribute("href", SOURCES_BACK_HREF) expect(back).to_have_text(SOURCES_BACK_LABEL)