Files
brain-of-reese/tests/e2e/test_document_viewer_context.py
T
ducoterra 966c4b0540
Build and Push Containers / build-and-push-app (push) Successful in 2m45s
Build and Push Containers / build-and-push-db (push) Successful in 19s
phase: 153_document_viewer_context
That last `--co` (collect-only) run was my probe mistake — it shows 34% because no tests execute under collect-only. The valid coverage run was the real gate command executed earlier: `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (exit 0, all 3544 tests executed). All gates are verified.

## Phase 153 — final verification pass report

**Verified (all tasks 01–04 complete in `complete/`):**
- Modal `fullPageUrl(source, path, back = null)` + `openDocumentModal(…, back = null)` with module-level `currentBack`; 5 chat call sites pass `"/"`, sources.js call site unchanged (byte-identical Sources path); `documentUrl` comment now true
- Copy-path control (shared `renderDocument` core, icon+text, text = accessible name) on both surfaces; per-page clipboard helpers with the fallback-field + status-line contract; `#doc-copy-announcer` live region; styles + 44px touch floor
- Unit pins: `&back=%2F` / no-`back` byte-identity, `currentBack` single-writer guard, copy strings byte-pinned, phase-13 regression pins untouched

**Test / lint / coverage results:**
- `uv run pytest` → **3544 passed**, 0 failed (full unit + integration)
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90% gate)
- `uv run pytest tests/e2e/test_document_viewer_context.py -v --no-cov` → **8 passed** in 47.33s, in isolation (DB up, mock LLM)
- `uv run ruff check . && uv run pyright` → clean (0 errors/warnings)

**Completion criteria:** all met — (1) chat→modal→Full page: `href="/"` + "Chat"; Sources→modal→Full page: `href="/sources.html"` + "Sources" with `back=` asserted absent; (2) no-`back` URLs byte-identical to pre-phase; (3) Copy path: "Path copied." on success (clipboard readback), prefilled select-on-focus fallback field + "Path ready — copy it from the field." on rejected clipboard, both surfaces parameterized; (4) copied string exactly `docs/homelab/kubernetes.md`; (5) full suite green, E2E isolated green, lint clean, coverage 99%; (6) commit by harness.

**Documentation:** README.md reviewed in full — phase-153 feature already named in the Chat section; routes, env reference, workflows, deployment all consistent — no changes needed. AGENTS.md reviewed in full — rules, gates, and quick-reference commands verified against the repo (incl. `test_smoke.py`, `.env.example`, gitignore entries) — consistent, no changes needed.

**Notable:** pytest 9.1.1 quirk — a doubled `-q` (pyproject `addopts` + explicit `-q`) swallows the final "N passed" summary line while still exiting 0; the plain `uv run pytest` gate command prints it. No defects found in prior work.

**Next pending phase:** `154_sync_status_freshness`.
2026-10-04 09:23:54 -04:00

487 lines
22 KiB
Python

"""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)