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`.
487 lines
22 KiB
Python
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)
|