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`.
This commit is contained in:
@@ -341,10 +341,11 @@ def test_modal_focus_and_a11y(
|
||||
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).
|
||||
# URL — phase 153 (task 01): the chat-origin open carries its origin
|
||||
# through (back=/ → &back=%2F; a Sources-origin open omits it).
|
||||
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%2Fkubernetes.md"
|
||||
"href", "/document.html?source=docs&path=homelab%2Fkubernetes.md&back=%2F"
|
||||
)
|
||||
|
||||
# Closing returns focus to the triggering control.
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user