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)
|
||||
@@ -48,6 +48,30 @@ site):
|
||||
innerHTML/outerHTML (the module's ONE innerHTML survives), and the
|
||||
``.doc-origin`` CSS mirroring the ``.doc-path`` ellipsis idiom with
|
||||
the ``.doc-link`` affordance (+ the ≤640px squeeze).
|
||||
|
||||
Phase 153 (task 03) — unit pins for the URL contract and the
|
||||
copy-path strings (additions beside the task-01/02 pins; nothing
|
||||
loosened):
|
||||
* the phase-13 back-resolution contract, guarded BOTH as a byte-for-byte
|
||||
source pin of the resolution block AND as a node behavioral run of
|
||||
that exact extracted block (missing / empty / garbage /
|
||||
protocol-relative / pseudo-protocol ``back`` → ``/sources.html`` +
|
||||
the "Sources" label; ``/`` → the "Chat" label; any other same-origin
|
||||
relative → the "Back" label — the phase-13 pins themselves are
|
||||
untouched);
|
||||
* the copy-path composition end-to-end under node — the modal's
|
||||
``wireCopyPath`` click handler and the page's ``render()`` wiring
|
||||
each send the EXACT ``source + "/" + path`` bytes to the (fake)
|
||||
clipboard for a source/path pair with spaces + slashes, and the
|
||||
rejected-clipboard failure prefills the fallback field with that
|
||||
same composed string (success / failure strings + the prefill,
|
||||
byte-for-byte);
|
||||
* the ``document.html`` Copy-path static nodes — the page's
|
||||
``header.doc-header`` (the ``.doc-copy-pop`` block's anchor —
|
||||
absent, the float would fall back to the meta row and grow the
|
||||
bar, the phase-12/34 breach) + the ``#doc-copy-announcer``
|
||||
(role=status) + the ``#doc-meta`` row the shared core builds the
|
||||
control into.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -673,7 +697,7 @@ def test_viewer_url_builder_present_in_chat_and_sources() -> None:
|
||||
assert 'chip.href = documentUrl(s.source, s.path, "/")' in app_js
|
||||
assert 'chip.addEventListener("click"' in app_js
|
||||
assert "e.preventDefault()" in app_js
|
||||
assert "openDocumentModal(s.source, s.path, chip)" in app_js
|
||||
assert "openDocumentModal(s.source, s.path, chip, \"/\")" in app_js
|
||||
|
||||
sources_js = _read(SOURCES_JS)
|
||||
# Sources: unchanged 2-arg builder — no back param in the URL.
|
||||
@@ -694,9 +718,8 @@ def _run_node(script: str) -> str:
|
||||
|
||||
|
||||
def _extract_function(js: str, name: str) -> str:
|
||||
match = re.search(
|
||||
rf"(?:export )?function {name}\(source, path(?:, back = \"/\")?\) \{{.*?\n\}}", js, re.S
|
||||
)
|
||||
pat = rf"(?:export )?function {name}\(source, path(?:, back = (?:\"/\"|null))?\) \{{.*?\n\}}"
|
||||
match = re.search(pat, js, re.S)
|
||||
assert match, f"{name}(source, path) not found"
|
||||
return match.group(0).replace("export ", "", 1)
|
||||
|
||||
@@ -878,14 +901,18 @@ def test_modal_close_contract_pins() -> None:
|
||||
# The shared renderer (not a copy), called with the modal's own
|
||||
# #doc-modal-title / #doc-modal-meta / #doc-modal-content elements.
|
||||
assert "renderDocument(doc, { titleEl, metaEl, contentEl })" in js
|
||||
assert 'openEl.href = fullPageUrl(source, path)' in js
|
||||
assert 'openEl.href = fullPageUrl(source, path, currentBack)' in js
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_modal_url_builders_encode_like_the_page() -> None:
|
||||
"""Behavioral check (node) of the modal's own URL builders: the
|
||||
content fetch and the "Full page" href must come out percent-encoded
|
||||
exactly like the page's builders (slashes/spaces in real paths)."""
|
||||
exactly like the page's builders (slashes/spaces in real paths).
|
||||
Phase 153 (task 01): the "Full page" builder's optional ``back``
|
||||
(default null) — a null/absent ``back`` keeps the URL byte-identical
|
||||
to pre-phase (no ``&back=`` at all — the Sources path), and a truthy
|
||||
``back`` is appended + percent-encoded (``"/"`` → ``%2F``)."""
|
||||
js = _read(MODAL_JS)
|
||||
content_fn = _extract_function(js, "contentUrl")
|
||||
full_fn = _extract_function(js, "fullPageUrl")
|
||||
@@ -893,11 +920,18 @@ def test_modal_url_builders_encode_like_the_page() -> None:
|
||||
content_fn
|
||||
+ full_fn
|
||||
+ "\nconsole.log(contentUrl('Homelab', 'notes/my file.yaml'));\n"
|
||||
+ "console.log(fullPageUrl('H omelab', 'a/b.md'));"
|
||||
+ "console.log(fullPageUrl('H omelab', 'a/b.md'));\n"
|
||||
+ "console.log(fullPageUrl('s', 'p'));\n"
|
||||
+ "console.log(fullPageUrl('s', 'p', '/'));\n"
|
||||
+ "console.log(fullPageUrl('H omelab', 'a/b.md', '/sources.html'));"
|
||||
)
|
||||
assert out.splitlines() == [
|
||||
"/api/documents/content?source=Homelab&path=notes%2Fmy%20file.yaml",
|
||||
# pre-phase byte-identical: no ``back`` arg → no ``&back=``
|
||||
"/document.html?source=H%20omelab&path=a%2Fb.md",
|
||||
"/document.html?source=s&path=p",
|
||||
"/document.html?source=s&path=p&back=%2F",
|
||||
"/document.html?source=H%20omelab&path=a%2Fb.md&back=%2Fsources.html",
|
||||
]
|
||||
|
||||
|
||||
@@ -920,3 +954,752 @@ def test_viewer_js_rendering_contracts() -> None:
|
||||
assert '"/sources.html"' in js # default target + no-JS fallback value
|
||||
assert '"Chat"' in js and '"Sources"' in js # labels for the two entry points
|
||||
assert "encodeURIComponent" in js # content fetch uses the same encoding
|
||||
|
||||
|
||||
def _stmt_block(js: str, start_marker: str, last_stmt: str) -> str:
|
||||
"""Extract the source of a statement group: from ``start_marker``
|
||||
to the balanced close of the ``{…}`` opened by ``last_stmt``
|
||||
(the brace walk is the house style — no third-party parsers)."""
|
||||
start = js.find(start_marker)
|
||||
assert start != -1, f"marker not found: {start_marker!r}"
|
||||
stmt_i = js.find(last_stmt, start)
|
||||
assert stmt_i != -1, f"statement not found: {last_stmt!r}"
|
||||
brace_i = js.find("{", stmt_i)
|
||||
depth = 0
|
||||
for j in range(brace_i, len(js)):
|
||||
if js[j] == "{":
|
||||
depth += 1
|
||||
elif js[j] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return js[start : j + 1]
|
||||
raise AssertionError(f"unbalanced braces from {last_stmt!r}")
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_phase13_back_resolution_guard() -> None:
|
||||
"""The phase-13 back-resolution contract, guarded in BOTH layers
|
||||
(task 03; the phase-13 pins in ``test_viewer_js_rendering_
|
||||
contracts`` are untouched — this runs beside them):
|
||||
|
||||
1. SOURCE — the resolution block itself, extracted from the live
|
||||
``document.js`` and pinned byte-for-byte (the exact statements,
|
||||
including the two-label ternary);
|
||||
2. BEHAVIOR — that exact extracted block (not a re-implementation)
|
||||
run under node over a fake ``params``/``backLink``:
|
||||
|
||||
* missing / empty / garbage (absolute cross-origin) /
|
||||
protocol-relative / pseudo-protocol ``back`` → the
|
||||
``/sources.html`` default + the "Sources" label;
|
||||
* ``/`` → the same-origin chat root WINS + the "Chat" label;
|
||||
* any other same-origin relative → wins verbatim + the
|
||||
"Back" label (and an explicit ``/sources.html`` keeps the
|
||||
"Sources" label).
|
||||
|
||||
A refactor that re-introduces a browser-history heuristic, an
|
||||
origin check, or a different label mapping fails the behavior run
|
||||
(and a reformat of the block fails the byte pin)."""
|
||||
js = _read(DOCUMENT_JS)
|
||||
block = _stmt_block(
|
||||
js,
|
||||
'const backParam = params.get("back") || "";',
|
||||
"if (backLabel) {",
|
||||
)
|
||||
# 1. the byte-for-byte source pin (the exact statements)
|
||||
assert 'const backParam = params.get("back") || "";' in block
|
||||
assert 'backParam.startsWith("/") && !backParam.startsWith("//")' in block
|
||||
assert "? backParam" in block and ': "/sources.html";' in block
|
||||
assert "backLink.href = backTarget;" in block
|
||||
assert 'backTarget === "/"' in block # the label ternary…
|
||||
assert '"Chat"' in block
|
||||
assert 'backTarget === "/sources.html"' in block
|
||||
assert '"Sources"' in block
|
||||
assert '"Back"' in block # …and the three labels
|
||||
|
||||
# 2. the behavior — the EXACT extracted block, run under node
|
||||
script = (
|
||||
"const block = " + json.dumps(block) + ";\n"
|
||||
"function run(v) {\n"
|
||||
" const params = { get: (k) => (k === 'back' ? v : null) };\n"
|
||||
" const label = { textContent: '' };\n"
|
||||
" const backLink = { href: '',"
|
||||
' querySelector: (s) => (s === "span" ? label : null) };\n'
|
||||
' const f = new Function("params", "backLink",'
|
||||
' block + "\\nreturn backTarget;");\n'
|
||||
" const target = f(params, backLink);\n"
|
||||
" return [target, label.textContent];\n"
|
||||
"}\n"
|
||||
"const cases = [null, '', 'https://evil.com/x', '//evil.com',"
|
||||
" 'javascript:alert(1)', '/', '/sources.html', '/chat?x=1'];\n"
|
||||
"for (const v of cases) console.log(JSON.stringify([v, ...run(v)]));\n"
|
||||
)
|
||||
out = [json.loads(line) for line in _run_node(script).splitlines()]
|
||||
assert out == [
|
||||
[None, "/sources.html", "Sources"], # missing
|
||||
["", "/sources.html", "Sources"], # empty
|
||||
["https://evil.com/x", "/sources.html", "Sources"], # garbage (cross-origin)
|
||||
["//evil.com", "/sources.html", "Sources"], # protocol-relative
|
||||
["javascript:alert(1)", "/sources.html", "Sources"], # pseudo-protocol
|
||||
["/", "/", "Chat"], # the chat root wins
|
||||
["/sources.html", "/sources.html", "Sources"], # explicit, same label
|
||||
["/chat?x=1", "/chat?x=1", "Back"], # other same-origin relative wins
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 153 (task 02) — "Copy path" (the standalone titlebar meta row +
|
||||
# the modal meta block — one shared-core control, per-surface wiring)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DOCUMENT_MODAL_JS = _read(MODAL_JS) # the modal owner (the live asset)
|
||||
|
||||
COPY_PATH_STR = 'doc.source + "/" + doc.path' # the copied string (ASSUMPTION A1)
|
||||
|
||||
STATUS_TERNARY = (
|
||||
'status.textContent = ok ? "Path copied." : "Path ready — copy it from the field."'
|
||||
) # the status wording pair (owner-locked fallback wording, path-adapted)
|
||||
|
||||
|
||||
def _fn_async(js: str, name: str) -> str:
|
||||
"""Like _fn, but includes a leading ``async `` when the function is
|
||||
async (the phase-153 helpers are async)."""
|
||||
start = js.find(f"function {name}(")
|
||||
assert start != -1, f"{name}() must exist"
|
||||
if js[max(0, start - 6) : start] == "async ":
|
||||
start -= 6
|
||||
i = js.find("(", start)
|
||||
depth = 0
|
||||
close = i
|
||||
while i < len(js):
|
||||
if js[i] == "(":
|
||||
depth += 1
|
||||
elif js[i] == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
close = i
|
||||
break
|
||||
i += 1
|
||||
brace = js.find("{", close)
|
||||
depth = 0
|
||||
for j in range(brace, len(js)):
|
||||
if js[j] == "{":
|
||||
depth += 1
|
||||
elif js[j] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return js[start : j + 1]
|
||||
raise AssertionError(f"unbalanced braces in {name}")
|
||||
|
||||
|
||||
def test_copy_path_button_built_in_the_shared_core() -> None:
|
||||
"""renderDocument (the ONE shared core — modal AND /document.html):
|
||||
the Copy-path control joins the meta row AFTER the path code and
|
||||
BEFORE the origin anchor (one build site — the surfaces can never
|
||||
drift). The button is a labeled icon+text control: type=button,
|
||||
class doc-copy-path, an aria-hidden SVG (createElementNS — the
|
||||
never-build-HTML rule stands: the module's ONE innerHTML stays the
|
||||
escape-first markdown render) and the VISIBLE "Copy path" text as
|
||||
the accessible name (no redundant aria-label)."""
|
||||
fn = _fn(_read(DOCUMENT_JS), "copyPathButton")
|
||||
assert 'document.createElement("button")' in fn
|
||||
assert "b.type = \"button\"" in fn
|
||||
assert 'b.className = "doc-copy-path"' in fn
|
||||
assert "document.createElementNS(SVG_NS, \"svg\")" in fn
|
||||
assert 'icon.setAttribute("aria-hidden", "true")' in fn
|
||||
assert 'label.textContent = "Copy path"' in fn
|
||||
assert "innerHTML" not in re.sub(r"//.*?$|/\*.*?\*/", "", fn, flags=re.S | re.M)
|
||||
rd = _fn(_read(DOCUMENT_JS), "renderDocument")
|
||||
assert "copyPathButton()" in rd
|
||||
# meta row order: path code → copy button → origin anchor (spread) →
|
||||
# created badge (the phase-145 pin's slot, unchanged).
|
||||
code_i = rd.find("pathCode,")
|
||||
btn_i = rd.find("copyBtn,")
|
||||
spread_i = rd.find("...originItems,")
|
||||
created_i = rd.find("metaBadge(\"doc-created\"")
|
||||
assert 0 <= code_i < btn_i < spread_i < created_i
|
||||
|
||||
|
||||
def test_page_copy_path_helper_pins() -> None:
|
||||
"""document.js's page-local helper (the per-page duplication house
|
||||
style — the modal module keeps its own copy; no shared module):
|
||||
* the copied string is `doc.source + "/" + doc.path` (the read-tool
|
||||
form — the same two values the meta row renders);
|
||||
* navigator.clipboard.writeText in a try (a non-secure http origin
|
||||
rejecting the clipboard is the fallback trigger);
|
||||
* the status ternary: "Path copied." (success, visual-only —
|
||||
aria-hidden, the toast idiom) / "Path ready — copy it from the
|
||||
field." (failure — the owner-locked fallback wording,
|
||||
path-adapted);
|
||||
* a re-click re-attempts (the previous .doc-copy-pop block is
|
||||
removed first — a failed copy never clears the previous state
|
||||
into nothing);
|
||||
* the success status auto-clears ~4s (the block is dropped if it
|
||||
is still connected);
|
||||
* the page's #doc-copy-announcer (role=status) reads the outcome
|
||||
(one a11y announcer — the visible node is aria-hidden);
|
||||
* the fallback field: input-like (type text, readOnly, class
|
||||
doc-path-fallback, prefilled with the path, selects its content
|
||||
on focus, auto-focused with preventScroll — the
|
||||
.share-link-fallback pattern);
|
||||
* the page's state floats in a .doc-copy-pop block anchored under
|
||||
the button (in the sticky header — the meta row never wraps or
|
||||
grows the bar, the phase-12/34 contract)."""
|
||||
js = _read(DOCUMENT_JS)
|
||||
assert "async function copyPathWithFallback(btn, path)" in js
|
||||
assert "function makePathFallbackField(path)" in js
|
||||
assert 'document.querySelectorAll(".doc-copy-pop").forEach((el) => el.remove());' in js
|
||||
assert "await navigator.clipboard.writeText(path);" in js
|
||||
try_i = js.find("try {\n await navigator.clipboard.writeText(path);\n } catch")
|
||||
assert try_i != -1, "the writeText call must sit in a try (the rejection fallback)"
|
||||
assert js.count(STATUS_TERNARY) == 1
|
||||
assert "status.setAttribute(\"aria-hidden\", \"true\")" in js # visual-only status
|
||||
assert 'document.querySelector("#doc-copy-announcer")' in js
|
||||
assert "if (block.isConnected) block.remove();" in js
|
||||
assert "}, 4000);" in js # the auto-clear beat
|
||||
assert js.count(COPY_PATH_STR) == 1 # the copied string, wired in render()
|
||||
assert 'metaEl.querySelector(".doc-copy-path")' in js # the render() wiring
|
||||
# the fallback field (makePathFallbackField)
|
||||
f = _fn(js, "makePathFallbackField")
|
||||
assert 'document.createElement("input")' in f
|
||||
assert "field.type = \"text\"" in f
|
||||
assert "field.readOnly = true" in f
|
||||
assert 'field.className = "doc-path-fallback"' in f
|
||||
assert "field.value = path" in f
|
||||
assert 'field.setAttribute("aria-label", "Document path")' in f
|
||||
assert "field.addEventListener(\"focus\", () => field.select())" in f
|
||||
# the floating block (page surface)
|
||||
p = _fn_async(js, "copyPathWithFallback")
|
||||
assert 'block.className = "doc-copy-pop"' in p
|
||||
assert 'document.querySelector("header.doc-header")' in p
|
||||
assert "br.bottom - hr.top + 6" in p # anchored under the button
|
||||
assert "field.focus({ preventScroll: true })" in p
|
||||
|
||||
|
||||
def test_modal_copy_path_helper_pins() -> None:
|
||||
"""document-modal.js's page-local helper (the per-page duplication
|
||||
house style):
|
||||
* the copied string is `doc.source + "/" + doc.path` (the read-tool
|
||||
form — the same two values the meta row renders);
|
||||
* writeText in a try; the status ternary (the same owner-locked
|
||||
wording as the page);
|
||||
* the nodes land right after the "Copy path" control
|
||||
(insertAdjacentElement("afterend") — the meta block wraps, so
|
||||
they may take a new row);
|
||||
* a re-click re-attempts (the previous .doc-copy-status /
|
||||
.doc-path-fallback state is removed first — a failed copy never
|
||||
clears the previous state into nothing);
|
||||
* success auto-clears ~4s; #doc-modal-desc (setDesc) reads the
|
||||
outcome (one a11y announcer — the visible node is aria-hidden);
|
||||
* the renderDocument call is followed by the wireCopyPath wiring
|
||||
(the control is live on every open)."""
|
||||
js = DOCUMENT_MODAL_JS
|
||||
assert "async function copyPathWithFallback(metaEl, path)" in js
|
||||
assert "function wireCopyPath(metaEl, doc)" in js
|
||||
assert "let modalCopyTimer = 0" in js
|
||||
assert "await navigator.clipboard.writeText(path);" in js
|
||||
assert js.count(STATUS_TERNARY) == 1
|
||||
assert 'status.setAttribute("aria-hidden", "true")' in js
|
||||
assert js.count('".doc-copy-status, .doc-path-fallback"') == 1 # the re-attempt cleanup
|
||||
assert js.count('insertAdjacentElement("afterend"') == 3 # 1 success + 2 failure
|
||||
assert 'setDesc("Path copied.")' in js
|
||||
assert 'setDesc("Path ready — copy it from the field.")' in js
|
||||
assert "if (status.isConnected) status.remove();" in js
|
||||
assert "}, 4000);" in js
|
||||
assert js.count(COPY_PATH_STR) == 1 # wired in wireCopyPath
|
||||
assert 'metaEl.querySelector(".doc-copy-path")' in js
|
||||
# renderDocument → wireCopyPath, in openDocumentModal's fetch .then
|
||||
# (the LAST occurrence — the docstring names the render earlier):
|
||||
rd_i = js.rfind("renderDocument(doc, { titleEl, metaEl, contentEl });")
|
||||
wire_i = js.rfind("wireCopyPath(metaEl, doc)")
|
||||
assert rd_i != -1 and wire_i != -1
|
||||
assert 0 < wire_i - rd_i < 300 # right after the render
|
||||
# the shared core builds the button for BOTH surfaces — the modal
|
||||
# module never builds one itself (no per-surface copy):
|
||||
assert "copyPathButton" not in js
|
||||
|
||||
|
||||
def test_document_html_carries_the_copy_announcer() -> None:
|
||||
"""The /document.html page's role=status live region for the copy
|
||||
outcome — visually hidden (the visible status line is the
|
||||
aria-hidden floating node; this node is the sole a11y announcer,
|
||||
the share pattern)."""
|
||||
html = (FRONTEND / "document.html").read_text(encoding="utf-8")
|
||||
i = html.find('id="doc-copy-announcer"')
|
||||
assert i != -1
|
||||
node = html[i - 200 : i + 200]
|
||||
assert "visually-hidden" in node
|
||||
assert "role=\"status\"" in node
|
||||
assert "aria-live" in node
|
||||
|
||||
|
||||
def test_document_html_carries_the_copy_path_static_nodes() -> None:
|
||||
"""The ``document.html`` Copy-path static nodes (task 03 — the
|
||||
Copy-path node on the full page): the floating ``.doc-copy-pop``
|
||||
block is anchored in the page's ``header.doc-header`` (absent,
|
||||
the page helper's ``if (header)`` guard would drop the block and
|
||||
the status would never float — the meta row is never grown, the
|
||||
phase-12/34 contract); the ``#doc-copy-announcer`` (role=status,
|
||||
the sole a11y announcer) and the ``#doc-meta`` row (where the
|
||||
shared core builds the control) must all be present for the
|
||||
page surface to work."""
|
||||
html = (FRONTEND / "document.html").read_text(encoding="utf-8")
|
||||
assert '<header class="doc-header">' in html, (
|
||||
"the page's sticky header is the copy block's anchor"
|
||||
)
|
||||
assert 'id="doc-copy-announcer"' in html, "the announcer node is missing"
|
||||
assert 'role="status"' in html, "the announcer must be a live region"
|
||||
assert 'id="doc-meta"' in html, "the meta row (the control's home) is missing"
|
||||
|
||||
|
||||
def test_copy_path_css() -> None:
|
||||
"""The Copy-path styles (phase 153, task 02):
|
||||
* .doc-copy-path — the row-action language (inline-flex icon +
|
||||
label), neutral secondary fill, brand-soft hover pair,
|
||||
focus-visible outline; the ≤640px block gives it the ≥44px
|
||||
touch-target floor (PLAN §7.1);
|
||||
* .doc-copy-status — the visible status (ink-soft on surface —
|
||||
the meta row's 5.1:1 pair), serving BOTH surfaces;
|
||||
* .doc-copy-pop — the page's floating state block (position
|
||||
absolute in the sticky header — never grows the bar), z-index
|
||||
30 inside the header's z-20 root context;
|
||||
* .doc-path-fallback — the .share-link-fallback pattern (input
|
||||
variant): mono, ellipsis, surface fill, --line border."""
|
||||
css = (FRONTEND / "assets" / "styles.css").read_text(encoding="utf-8")
|
||||
bp = css.find(".doc-copy-path {")
|
||||
assert bp != -1
|
||||
b = css[bp : css.find("}", bp)]
|
||||
assert "display: inline-flex" in b
|
||||
assert "gap: 0.3rem" in b
|
||||
assert "cursor: pointer" in b
|
||||
assert ".doc-copy-path svg" in css
|
||||
assert ".doc-copy-path:hover" in css
|
||||
assert ".doc-copy-path:focus-visible" in css
|
||||
assert ".doc-copy-path { min-height: 44px; min-width: 44px; }" in css # mobile floor
|
||||
sp = css.find(".doc-copy-status {")
|
||||
assert sp != -1
|
||||
s = css[sp : css.find("}", sp)]
|
||||
assert "color: var(--ink-soft)" in s
|
||||
pp = css.find(".doc-copy-pop {")
|
||||
assert pp != -1
|
||||
p = css[pp : css.find("}", pp)]
|
||||
assert "position: absolute" in p
|
||||
assert "z-index: 30" in p
|
||||
fp = css.find(".doc-path-fallback {")
|
||||
assert fp != -1
|
||||
f = css[fp : css.find("}", fp)]
|
||||
assert "font-family: var(--mono)" in f
|
||||
assert "text-overflow: ellipsis" in f
|
||||
assert ".doc-path-fallback:hover" in css
|
||||
assert ".doc-path-fallback:focus-visible" in css
|
||||
|
||||
|
||||
def _copy_path_page_fixture() -> str:
|
||||
"""The shared node fixture: a fake DOM (elements + query registry +
|
||||
tracked pop blocks) + a fake clipboard (resolves or rejects) + a
|
||||
fake timer (captures the fn + delay)."""
|
||||
return """
|
||||
function makeEl(tag) {
|
||||
const el = {
|
||||
tag, children: [], attrs: {}, style: {},
|
||||
className: "", type: "", readOnly: false, value: "", title: "",
|
||||
isConnected: true, focusCount: 0, selectCount: 0, listeners: {},
|
||||
setAttribute(k, v) { this.attrs[k] = v; },
|
||||
appendChild(c) { this.children.push(c); return c; },
|
||||
append(...cs) { this.children.push(...cs); },
|
||||
insertAdjacentElement(pos, node) {
|
||||
if (pos === "afterend") this.after.unshift(node);
|
||||
return node;
|
||||
},
|
||||
addEventListener(ev, fn) { (this.listeners[ev] = this.listeners[ev] || []).push(fn); },
|
||||
focus() { this.focusCount += 1; (this.listeners.focus || []).forEach((f) => f()); },
|
||||
select() { this.selectCount += 1; },
|
||||
remove() { this.isConnected = false; },
|
||||
getBoundingClientRect() {
|
||||
return { top: 10, left: 20, bottom: 44, right: 90, width: 70, height: 34 };
|
||||
},
|
||||
};
|
||||
el.after = [];
|
||||
return el;
|
||||
}
|
||||
const created = [];
|
||||
const registry = {};
|
||||
let written = [];
|
||||
let reject = false;
|
||||
let timerFn = null;
|
||||
let timerDelay = null;
|
||||
globalThis.setTimeout = (fn, delay) => { timerFn = fn; timerDelay = delay; return 1; };
|
||||
globalThis.clearTimeout = () => {};
|
||||
// Node 24's globalThis.navigator is GETTER-ONLY (a plain assignment
|
||||
// silently fails in sloppy mode) — defineProperty is the only way to
|
||||
// stand up the fake clipboard.
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: {
|
||||
clipboard: {
|
||||
writeText: async (s) => { written.push(s); if (reject) throw new Error("denied"); },
|
||||
},
|
||||
},
|
||||
});
|
||||
globalThis.document = {
|
||||
createElement: (t) => { const el = makeEl(t); created.push(el); return el; },
|
||||
createElementNS: (ns, t) => makeEl(t),
|
||||
querySelector: (sel) => {
|
||||
if (!registry[sel]) registry[sel] = makeEl("root:" + sel);
|
||||
return registry[sel];
|
||||
},
|
||||
querySelectorAll: (sel) =>
|
||||
sel === ".doc-copy-pop"
|
||||
? created.filter((c) => c.className === "doc-copy-pop" && c.isConnected)
|
||||
: [],
|
||||
};
|
||||
let copyStatusTimer = 0;
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_page_copy_path_behavior_under_node() -> None:
|
||||
"""The page's copyPathWithFallback under node (a fake DOM + a fake
|
||||
clipboard):
|
||||
* success — the clipboard receives the EXACT `source + "/" + path`
|
||||
string (spaces + slashes intact — the read-tool form,
|
||||
ASSUMPTION A1), the visible status is "Path copied." (the
|
||||
aria-hidden node), the announcer reads the same text, the block
|
||||
is anchored under the button in the header (top/left computed
|
||||
from the rects), and the auto-clear timer is scheduled at 4000
|
||||
ms and drops the block when it fires;
|
||||
* failure — no write, the pre-filled readOnly fallback field
|
||||
(doc-path-fallback, aria-labelled "Document path") is
|
||||
auto-focused and selects its content on focus, the status reads
|
||||
"Path ready — copy it from the field." (the owner-locked
|
||||
wording, path-adapted) and the announcer reads it too;
|
||||
* a re-click re-attempts (failure again → exactly one connected
|
||||
state block; then success → the field is replaced by the
|
||||
success status — never cleared into nothing)."""
|
||||
js = _copy_path_page_fixture()
|
||||
js += _fn(_read(DOCUMENT_JS), "makePathFallbackField")
|
||||
js += "\n" + _fn_async(_read(DOCUMENT_JS), "copyPathWithFallback")
|
||||
js += """
|
||||
async function main() {
|
||||
let out = {};
|
||||
const header = document.querySelector("header.doc-header");
|
||||
const btn = document.createElement("button");
|
||||
const ok1 = await copyPathWithFallback(btn, "H omelab/a/b c.md");
|
||||
const pops = () => created.filter((c) => c.className === "doc-copy-pop");
|
||||
const pop1 = pops()[pops().length - 1];
|
||||
out = {
|
||||
ok1,
|
||||
written: written.slice(),
|
||||
popClass: pop1.className,
|
||||
inHeader: header.children.includes(pop1),
|
||||
top: pop1.style.top,
|
||||
left: pop1.style.left,
|
||||
statusText: pop1.children.find((c) => c.className === "doc-copy-status").textContent,
|
||||
statusAria: pop1.children.find((c) => c.className === "doc-copy-status").attrs["aria-hidden"],
|
||||
announcer: registry["#doc-copy-announcer"].textContent,
|
||||
timerDelay,
|
||||
connected: pops().filter((p) => p.isConnected).length,
|
||||
};
|
||||
timerFn(); // the auto-clear fires
|
||||
out.cleared = !pop1.isConnected;
|
||||
reject = true;
|
||||
const ok2 = await copyPathWithFallback(btn, "H omelab/a/b c.md");
|
||||
const pop2 = pops().filter((p) => p.isConnected)[0];
|
||||
const field = pop2.children.find((c) => c.tag === "input");
|
||||
out.ok2 = ok2;
|
||||
out.fieldValue = field.value;
|
||||
out.fieldType = field.type;
|
||||
out.fieldReadOnly = field.readOnly;
|
||||
out.fieldClass = field.className;
|
||||
out.fieldAria = field.attrs["aria-label"];
|
||||
out.fieldTitle = field.title;
|
||||
out.fieldFocus = field.focusCount;
|
||||
out.fieldSelect = field.selectCount;
|
||||
out.failStatus = pop2.children.find((c) => c.className === "doc-copy-status").textContent;
|
||||
out.failAnnouncer = registry["#doc-copy-announcer"].textContent;
|
||||
out.connectedAfterFail = pops().filter((p) => p.isConnected).length;
|
||||
await copyPathWithFallback(btn, "H omelab/a/b c.md"); // re-attempt (fail again)
|
||||
out.connectedAfterRetry = pops().filter((p) => p.isConnected).length;
|
||||
reject = false;
|
||||
const ok3 = await copyPathWithFallback(btn, "x/y.md"); // re-attempt (success)
|
||||
out.ok3 = ok3;
|
||||
out.lastWritten = written[written.length - 1];
|
||||
const pop3 = pops().filter((p) => p.isConnected)[0];
|
||||
out.successReplacesField = !pop3.children.some((c) => c.tag === "input");
|
||||
console.log(JSON.stringify(out));
|
||||
}
|
||||
main().catch((e) => { console.error("FAIL " + e.message); process.exit(1); });
|
||||
"""
|
||||
r = _run_node(js)
|
||||
out = json.loads(r)
|
||||
assert out["ok1"] is True
|
||||
assert out["written"] == ["H omelab/a/b c.md"] # the exact source/path form
|
||||
assert out["popClass"] == "doc-copy-pop"
|
||||
assert out["inHeader"] is True
|
||||
assert out["top"] == "40px" and out["left"] == "0px" # anchored under the button
|
||||
assert out["statusText"] == "Path copied."
|
||||
assert out["statusAria"] == "true" # visual only
|
||||
assert out["announcer"] == "Path copied." # the a11y region
|
||||
assert out["timerDelay"] == 4000
|
||||
assert out["connected"] == 1
|
||||
assert out["cleared"] is True # the auto-clear dropped the block
|
||||
assert out["ok2"] is False
|
||||
assert out["fieldValue"] == "H omelab/a/b c.md" # prefilled with the path
|
||||
assert out["fieldType"] == "text"
|
||||
assert out["fieldReadOnly"] is True
|
||||
assert out["fieldClass"] == "doc-path-fallback"
|
||||
assert out["fieldAria"] == "Document path"
|
||||
assert out["fieldTitle"] == "Document path — click, then copy (Ctrl/Cmd+C)"
|
||||
assert out["fieldFocus"] == 1 # auto-focused (selects the path)
|
||||
assert out["fieldSelect"] == 1 # the focus listener selected the content
|
||||
assert out["failStatus"] == "Path ready — copy it from the field."
|
||||
assert out["failAnnouncer"] == "Path ready — copy it from the field."
|
||||
assert out["connectedAfterFail"] == 1
|
||||
assert out["connectedAfterRetry"] == 1 # the re-click replaced, never cleared
|
||||
assert out["ok3"] is True
|
||||
assert out["lastWritten"] == "x/y.md"
|
||||
assert out["successReplacesField"] is True # the field was swapped for the status
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_modal_copy_path_behavior_under_node() -> None:
|
||||
"""The modal's copyPathWithFallback under node (a fake DOM + a
|
||||
fake clipboard + the setDesc announcer):
|
||||
* success — the clipboard receives the EXACT `source + "/" + path`
|
||||
string, the "Path copied." status lands right after the control
|
||||
(insertAdjacentElement("afterend")), the visible node is
|
||||
aria-hidden, #doc-modal-desc reads the outcome, and the
|
||||
auto-clear timer (4000 ms) drops the status when it fires;
|
||||
* failure — the pre-filled readOnly fallback field + the
|
||||
"Path ready — copy it from the field." status land in DOM order
|
||||
[field, status] right after the control (the meta block wraps),
|
||||
the field is auto-focused and selects its content on focus, and
|
||||
#doc-modal-desc reads the outcome;
|
||||
* a re-click re-attempts (the previous state is removed first —
|
||||
exactly one connected state pair after the retry — a failed
|
||||
copy never clears the previous state into nothing)."""
|
||||
js = """
|
||||
""" + _copy_path_page_fixture()
|
||||
js += """
|
||||
const desc = [];
|
||||
function setDesc(t) { desc.push(t); }
|
||||
let modalCopyTimer = 0;
|
||||
"""
|
||||
js += "\n" + _fn_async(DOCUMENT_MODAL_JS, "copyPathWithFallback")
|
||||
js += """
|
||||
async function main() {
|
||||
let out = {};
|
||||
const inserted = [];
|
||||
const btn = makeEl("button");
|
||||
btn.className = "doc-copy-path";
|
||||
btn.insertAdjacentElement = function (pos, node) {
|
||||
if (pos === "afterend") { this.after.unshift(node); inserted.push(node); }
|
||||
return node;
|
||||
};
|
||||
const metaEl = {
|
||||
appendChild(c) { inserted.push(c); return c; },
|
||||
append(...cs) { inserted.push(...cs); },
|
||||
querySelector(sel) { return sel === ".doc-copy-path" ? btn : null; },
|
||||
querySelectorAll() { return inserted; },
|
||||
};
|
||||
const ok1 = await copyPathWithFallback(metaEl, "H omelab/a/b c.md");
|
||||
const live = () =>
|
||||
btn.after.filter(
|
||||
(n) =>
|
||||
(n.className === "doc-copy-status" || n.className === "doc-path-fallback") &&
|
||||
n.isConnected,
|
||||
);
|
||||
out = {
|
||||
ok1,
|
||||
written: written.slice(),
|
||||
after: live().map((n) => n.className),
|
||||
statusText: live().find((n) => n.className === "doc-copy-status").textContent,
|
||||
statusAria: live().find((n) => n.className === "doc-copy-status").attrs["aria-hidden"],
|
||||
desc: desc.slice(),
|
||||
timerDelay,
|
||||
};
|
||||
timerFn(); // the auto-clear fires
|
||||
out.cleared = !btn.after[0].isConnected;
|
||||
reject = true;
|
||||
const ok2 = await copyPathWithFallback(metaEl, "H omelab/a/b c.md");
|
||||
const lv = live();
|
||||
const field = lv.find((n) => n.className === "doc-path-fallback");
|
||||
out.ok2 = ok2;
|
||||
out.afterFail = lv.map((n) => n.className);
|
||||
out.fieldValue = field.value;
|
||||
out.fieldType = field.type;
|
||||
out.fieldReadOnly = field.readOnly;
|
||||
out.fieldAria = field.attrs["aria-label"];
|
||||
out.fieldFocus = field.focusCount;
|
||||
out.fieldSelect = field.selectCount;
|
||||
out.failStatus = lv.find((n) => n.className === "doc-copy-status").textContent;
|
||||
out.descFail = desc[desc.length - 1];
|
||||
out.connectedAfterFail = lv.length;
|
||||
await copyPathWithFallback(metaEl, "H omelab/a/b c.md"); // re-attempt (fail again)
|
||||
out.connectedAfterRetry = live().length;
|
||||
console.log(JSON.stringify(out));
|
||||
}
|
||||
main().catch((e) => { console.error("FAIL " + e.message); process.exit(1); });
|
||||
"""
|
||||
r = _run_node(js)
|
||||
out = json.loads(r)
|
||||
assert out["ok1"] is True
|
||||
assert out["written"] == ["H omelab/a/b c.md"] # the exact source/path form
|
||||
assert out["after"] == ["doc-copy-status"] # right after the control
|
||||
assert out["statusText"] == "Path copied."
|
||||
assert out["statusAria"] == "true" # visual only
|
||||
assert out["desc"] == ["Path copied."] # the a11y region (setDesc)
|
||||
assert out["timerDelay"] == 4000
|
||||
assert out["cleared"] is True # the auto-clear dropped the status
|
||||
assert out["ok2"] is False
|
||||
assert out["afterFail"] == ["doc-path-fallback", "doc-copy-status"] # field first, status after
|
||||
assert out["fieldValue"] == "H omelab/a/b c.md" # prefilled with the path
|
||||
assert out["fieldType"] == "text"
|
||||
assert out["fieldReadOnly"] is True
|
||||
assert out["fieldAria"] == "Document path"
|
||||
assert out["fieldFocus"] == 1 # auto-focused (selects the path)
|
||||
assert out["fieldSelect"] == 1 # the focus listener selected the content
|
||||
assert out["failStatus"] == "Path ready — copy it from the field."
|
||||
assert out["descFail"] == "Path ready — copy it from the field."
|
||||
assert out["connectedAfterFail"] == 2 # one field + one status
|
||||
assert out["connectedAfterRetry"] == 2 # the re-click replaced, never cleared
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 153 (task 03) — the copy-path composition, end-to-end under node
|
||||
# (the click handler + render() wiring → the EXACT composed bytes on the
|
||||
# (fake) clipboard; the pre-existing pins above call the helper with a
|
||||
# pre-composed string — these run the composition step itself)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PUMP = "async function pump() { for (let i = 0; i < 300; i++) await Promise.resolve(); }\n"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_wire_copy_path_click_composes_the_exact_source_path_string() -> None:
|
||||
"""The modal's ``wireCopyPath`` click handler, executed end-to-end
|
||||
under node (the REAL ``wireCopyPath`` + the REAL ``
|
||||
copyPathWithFallback`` from the live ``document-modal.js``, the
|
||||
DOM/clipboard/timers faked): for ``source = "H omelab"`` (space) +
|
||||
``path = "a/b c.md"`` (slashes + a space), a click sends the
|
||||
EXACT ``"H omelab/a/b c.md"`` bytes to the clipboard (byte-for-
|
||||
byte — a refactor that reorders, re-encodes, or trims the
|
||||
composition breaks the exact string). The success beat announces
|
||||
the exact success string; a REJECTED clipboard then prefills the
|
||||
fallback field with that SAME composed string (the exact prefill,
|
||||
readOnly, focused + selected — the "never clears the previous
|
||||
state" re-click stands: the second status replaces the first)."""
|
||||
modal_js = _read(MODAL_JS)
|
||||
wire = _fn(modal_js, "wireCopyPath")
|
||||
helper = _fn_async(modal_js, "copyPathWithFallback")
|
||||
script = (
|
||||
_copy_path_page_fixture()
|
||||
+ "\nlet modalCopyTimer = 0;\n"
|
||||
+ "const descCalls = [];\n"
|
||||
+ "function setDesc(t) { descCalls.push(t); }\n"
|
||||
+ "let createdInput = null;\n"
|
||||
+ "document.createElement = (t) => { const el = makeEl(t);"
|
||||
+ " if (t === 'input') createdInput = el; return el; };\n"
|
||||
+ "const btn = makeEl('button');\n"
|
||||
+ "const metaEl = { querySelector: (s) => (s === '.doc-copy-path' ? btn : null),"
|
||||
+ " querySelectorAll: () => [], replaceChildren() {} };\n"
|
||||
+ helper
|
||||
+ "\n" + wire
|
||||
+ "\n" + PUMP
|
||||
+ "wireCopyPath(metaEl, { source: 'H omelab', path: 'a/b c.md' });\n"
|
||||
+ "const newestStatus = () => (btn.after.find((e) => e.className === 'doc-copy-status')"
|
||||
+ " || { textContent: undefined }).textContent;\n"
|
||||
+ "const click = btn.listeners.click[0];\n"
|
||||
+ "click(); await pump();\n"
|
||||
+ "const okOut = { written: written.slice(), status: newestStatus(),"
|
||||
+ " descCalls: descCalls.slice() };\n"
|
||||
+ "reject = true; click(); await pump();\n"
|
||||
+ "const failOut = { input: createdInput && createdInput.value,"
|
||||
+ " readOnly: createdInput && createdInput.readOnly,"
|
||||
+ " focusCount: createdInput && createdInput.listeners.focus.length,"
|
||||
+ " selectCount: createdInput && createdInput.selectCount,"
|
||||
+ " status: newestStatus() };\n"
|
||||
+ "console.log(JSON.stringify({ okOut, failOut }));\n"
|
||||
)
|
||||
out = json.loads(_run_node(script))
|
||||
# the exact composed bytes (spaces + slashes) on the clipboard
|
||||
assert out["okOut"]["written"] == ["H omelab/a/b c.md"], (
|
||||
"the click handler must send the exact source + '/' + path bytes"
|
||||
)
|
||||
assert out["okOut"]["status"] == "Path copied."
|
||||
assert out["okOut"]["descCalls"] == ["Path copied."]
|
||||
# the rejected-clipboard failure: the prefill is the SAME composed string
|
||||
assert out["failOut"]["input"] == "H omelab/a/b c.md", (
|
||||
"the fallback field must prefill with the exact composed string"
|
||||
)
|
||||
assert out["failOut"]["readOnly"] is True
|
||||
assert out["failOut"]["focusCount"] == 1
|
||||
assert out["failOut"]["selectCount"] == 1
|
||||
assert out["failOut"]["status"] == "Path ready — copy it from the field."
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_page_render_wires_the_copy_path_with_the_composed_string() -> None:
|
||||
"""The page's ``render()`` wiring, executed end-to-end under node
|
||||
(the REAL ``render`` + the page-local ``copyPathWithFallback`` +
|
||||
``makePathFallbackField`` from the live ``document.js``, the
|
||||
DOM/clipboard/timers faked): a click on the shared core's
|
||||
``.doc-copy-path`` control sends the EXACT ``"H omelab/a/b c.md"``
|
||||
bytes to the clipboard (the ``doc.source + "/" + doc.path``
|
||||
composition, byte-for-byte). The success beat floats the status in
|
||||
the ``header.doc-header`` block (the meta row is never grown —
|
||||
the phase-12/34 contract); the REJECTED-clipboard failure floats
|
||||
the fallback field prefilled with that SAME composed string
|
||||
(readOnly, focused + selected) beside the exact failure status, and
|
||||
the role=status announcer mirror gets the same text."""
|
||||
js = _read(DOCUMENT_JS)
|
||||
render_fn = _fn(js, "render")
|
||||
helper = _fn_async(js, "copyPathWithFallback")
|
||||
field_fn = _fn(js, "makePathFallbackField")
|
||||
script = (
|
||||
_copy_path_page_fixture()
|
||||
+ "const brand = () => 'Brain of Reese';\n"
|
||||
+ "const rendered = [];\n"
|
||||
+ "function renderDocument(d, els) { rendered.push(d); }\n"
|
||||
+ "const btn = makeEl('button');\n"
|
||||
+ "const titleEl = {};\n"
|
||||
+ "const metaEl = { querySelector: (s) => (s === '.doc-copy-path' ? btn : null),"
|
||||
+ " replaceChildren() {} };\n"
|
||||
+ "const contentEl = {};\n"
|
||||
+ "const header = document.querySelector('header.doc-header');\n"
|
||||
+ field_fn
|
||||
+ "\n" + helper
|
||||
+ "\n" + render_fn
|
||||
+ "\n" + PUMP
|
||||
+ "render({ source: 'H omelab', path: 'a/b c.md', title: 'T' });\n"
|
||||
+ "const click = btn.listeners.click[0];\n"
|
||||
+ "click(); await pump();\n"
|
||||
+ "const okBlock = header.children[0];\n"
|
||||
+ "const okOut = { written: written.slice(), blockChildren: okBlock.children.length,"
|
||||
+ " status: okBlock.children[0].textContent,"
|
||||
+ " announcer: document.querySelector('#doc-copy-announcer').textContent };\n"
|
||||
+ "reject = true; click(); await pump();\n"
|
||||
+ "const failBlock = header.children[header.children.length - 1];\n"
|
||||
+ "const field = failBlock.children[0]; const failStatus = failBlock.children[1];\n"
|
||||
+ "const failOut = { value: field.value, readOnly: field.readOnly,"
|
||||
+ " focusCount: field.listeners.focus.length, selectCount: field.selectCount,"
|
||||
+ " status: failStatus.textContent, className: failBlock.className };\n"
|
||||
+ "console.log(JSON.stringify({ okOut, failOut }));\n"
|
||||
)
|
||||
out = json.loads(_run_node(script))
|
||||
# the exact composed bytes (spaces + slashes) on the clipboard
|
||||
assert out["okOut"]["written"] == ["H omelab/a/b c.md"], (
|
||||
"render() must wire the click to the exact source + '/' + path bytes"
|
||||
)
|
||||
# the float lives in the header (never the meta row): one status child
|
||||
assert out["okOut"]["blockChildren"] == 1
|
||||
assert out["okOut"]["status"] == "Path copied."
|
||||
assert out["okOut"]["announcer"] == "Path copied." # the a11y mirror
|
||||
# the rejected-clipboard failure: the prefill is the SAME composed string
|
||||
assert out["failOut"]["value"] == "H omelab/a/b c.md", (
|
||||
"the page fallback field must prefill with the exact composed string"
|
||||
)
|
||||
assert out["failOut"]["readOnly"] is True
|
||||
assert out["failOut"]["focusCount"] == 1
|
||||
assert out["failOut"]["selectCount"] == 1
|
||||
assert out["failOut"]["status"] == "Path ready — copy it from the field."
|
||||
assert out["failOut"]["className"] == "doc-copy-pop" # the floating block
|
||||
|
||||
@@ -32,15 +32,56 @@ source-level contract a silent regression would break:
|
||||
``closingViaBack``-wrapped close in the back branch);
|
||||
* a fallback-path push (the ``window.open`` no-skeleton fallback's
|
||||
early ``return`` precedes the first ``history.`` use in the open).
|
||||
|
||||
Phase 153 (task 01) — the "Full page" escape hatch carries the origin
|
||||
``back``; the module's added pins:
|
||||
|
||||
* a second ``currentBack`` declaration / a reset that does not land at
|
||||
the top of EVERY open (before the no-skeleton guard — the fallback
|
||||
path reads the stored value);
|
||||
* ``fullPageUrl`` appending ``&back=`` unconditionally (a null/absent
|
||||
``back`` must keep the URL byte-identical — the Sources path);
|
||||
* the ``openDocumentModal`` ``back`` default drifting from ``null``
|
||||
(the Sources table's 3-arg call must stay byte-identical);
|
||||
* a ``fullPageUrl`` call site dropping the stored ``back`` (the
|
||||
no-skeleton fallback and the anchor must both read ``currentBack``).
|
||||
|
||||
Phase 153 (task 03) — unit pins for the URL contract (additions on top
|
||||
of task 01's source pins; the pre-phase contract is the load-bearing
|
||||
byte-identity):
|
||||
|
||||
* ``fullPageUrl`` behavior under node — ``back = null`` and an ABSENT
|
||||
third arg are byte-identical to each other AND to the exact
|
||||
pre-phase string (no ``&back=`` anywhere — the Sources path);
|
||||
``back = "/"`` appends exactly ``&back=%2F`` to that same string;
|
||||
* ``openDocumentModal`` behavior under node — the no-skeleton
|
||||
``window.open`` fallback carries the stored ``back`` (a chat open's
|
||||
``"/"`` lands in the URL, an omitted ``back`` keeps it byte-
|
||||
identical) and the ``#doc-modal-open`` ("Full page") anchor carries
|
||||
it on a skeleton open (chat → ``&back=%2F``; a Sources-style open
|
||||
→ byte-identical, no param).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
MODAL_JS = FRONTEND / "assets" / "document-modal.js"
|
||||
|
||||
HAVE_NODE = shutil.which("node") is not None
|
||||
|
||||
|
||||
def _run_node(script: str) -> str:
|
||||
proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60)
|
||||
assert proc.returncode == 0, f"node failed: {proc.stderr}"
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def _js() -> str:
|
||||
return MODAL_JS.read_text(encoding="utf-8")
|
||||
@@ -168,7 +209,9 @@ def test_window_open_fallback_precedes_the_entry_logic() -> None:
|
||||
``return`` precedes the first ``history.`` use in the open."""
|
||||
open_body = _fn(_js(), "openDocumentModal")
|
||||
guard_i = open_body.find("if (!modalEl || !titleEl || !metaEl || !contentEl) {")
|
||||
open_i = open_body.find('window.open(fullPageUrl(source, path), "_blank", "noopener");')
|
||||
open_i = open_body.find(
|
||||
'window.open(fullPageUrl(source, path, currentBack), "_blank", "noopener");'
|
||||
)
|
||||
ret_i = open_body.find("return;", open_i)
|
||||
first_hist_i = open_body.find("history.")
|
||||
assert -1 < guard_i < open_i < ret_i < first_hist_i, (
|
||||
@@ -358,3 +401,219 @@ def test_pre_phase_contract_pins_still_hold() -> None:
|
||||
)
|
||||
assert "triggerEl.focus({ preventScroll: true });" in close_body
|
||||
assert "triggerEl = null;" in close_body
|
||||
|
||||
|
||||
# ---------- phase 153 (task 01): the "Full page" hatch carries the origin ----------
|
||||
|
||||
|
||||
def test_currentback_declared_exactly_once_and_reset_at_the_top_of_every_open() -> None:
|
||||
"""REGRESSION: a second ``currentBack`` writer, or a reset that
|
||||
does not land at the top of EVERY open. ``currentBack`` is
|
||||
declared by EXACTLY ONE ``let``, and the open resets it
|
||||
(``currentBack = back;``) BEFORE the no-skeleton guard — so even
|
||||
the ``window.open`` fallback path (which early-returns there)
|
||||
reads the stored value of THIS open."""
|
||||
js = _js()
|
||||
assert js.count("let currentBack = null;") == 1
|
||||
assert len(re.findall(r"let\s+currentBack\s*=", js)) == 1
|
||||
open_body = _fn(js, "openDocumentModal")
|
||||
reset_i = open_body.find("currentBack = back;")
|
||||
guard_i = open_body.find("if (!modalEl || !titleEl || !metaEl || !contentEl) {")
|
||||
assert -1 < reset_i < guard_i, (
|
||||
"the reset must land at the TOP of every open — before the "
|
||||
"no-skeleton guard (the fallback path reads the stored value)"
|
||||
)
|
||||
# Exactly one reset site (no second writer anywhere in the module).
|
||||
assert js.count("currentBack = back;") == 1
|
||||
|
||||
|
||||
def test_fullpageurl_appends_encoded_back_only_when_truthy() -> None:
|
||||
"""REGRESSION: the ``back`` param leaking into a null/absent open
|
||||
(the Sources path must stay byte-identical — the viewer's
|
||||
/sources.html default) or the encoding drifting from the
|
||||
app.js::documentUrl contract. The signature is
|
||||
``fullPageUrl(source, path, back = null)`` and the ONLY writer
|
||||
after the base URL is the guarded ``if (back)`` append —
|
||||
percent-encoded, exactly the way documentUrl documents it."""
|
||||
js = _js()
|
||||
assert "function fullPageUrl(source, path, back = null)" in js
|
||||
fn = _fn(js, "fullPageUrl")
|
||||
assert 'if (back) url += "&back=" + encodeURIComponent(back);' in fn
|
||||
# The base URL is built first; the guarded append is the only
|
||||
# ``&back=`` writer (a null back bakes no param into the URL).
|
||||
base_i = fn.find('"/document.html?source=" + encodeURIComponent(')
|
||||
back_i = fn.find('"&back=" + encodeURIComponent(back)', base_i)
|
||||
assert -1 < base_i < back_i, "the back append must follow the base URL"
|
||||
base_part = fn.split('"&back="')[0]
|
||||
assert "back=" not in base_part, (
|
||||
"no &back= may be baked into the base URL (null back = no param)"
|
||||
)
|
||||
|
||||
|
||||
def test_open_document_modal_defaults_back_to_null() -> None:
|
||||
"""REGRESSION: a non-null default for the ``back`` argument.
|
||||
``openDocumentModal(source, path, trigger = null, back = null)`` —
|
||||
a call WITHOUT ``back`` (the Sources table's
|
||||
``openDocumentModal(d.source, d.path, link)``) must leave the
|
||||
"Full page" URL byte-identical to pre-phase (the viewer's
|
||||
/sources.html default — the byte-identity is the contract)."""
|
||||
js = _js()
|
||||
assert (
|
||||
"export function openDocumentModal(source, path, trigger = null, back = null)"
|
||||
in js
|
||||
)
|
||||
|
||||
|
||||
def test_fallback_and_anchor_both_read_the_stored_back() -> None:
|
||||
"""REGRESSION: a call site passing ``back`` positionally from a
|
||||
caller, or dropping the third argument (a dropped arg loses the
|
||||
origin — the chat path would land on "/sources.html"). BOTH the
|
||||
no-skeleton ``window.open`` fallback and the ``#doc-modal-open``
|
||||
("Full page") anchor read ``currentBack``: exactly three
|
||||
``fullPageUrl(`` sites in the module (one definition + two call
|
||||
sites), and no call site without the stored variable."""
|
||||
js = _js()
|
||||
assert 'window.open(fullPageUrl(source, path, currentBack), "_blank", "noopener");' in js
|
||||
assert "openEl.href = fullPageUrl(source, path, currentBack);" in js
|
||||
assert "fullPageUrl(source, path)" not in js, "a dropped third argument loses the back"
|
||||
assert "fullPageUrl(source, path, back)" not in js, ("call sites must read the stored variable")
|
||||
assert js.count("fullPageUrl(") == 3, "1 definition + 2 call sites"
|
||||
|
||||
|
||||
# ---------- phase 153 (task 03): the URL contract, behavior-pinned under node ----------
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_fullpageurl_null_back_is_byte_identical_to_the_absent_and_pre_phase() -> None:
|
||||
"""The URL contract's load-bearing byte-identity, executed under
|
||||
node (the real function, not a re-implementation):
|
||||
|
||||
* ``back = null`` and an ABSENT third argument produce the same
|
||||
string, and that string is byte-identical to the EXACT pre-phase
|
||||
string (no ``&back=`` anywhere — the Sources path must never gain
|
||||
the param);
|
||||
* ``back = "/"`` (a chat open) appends exactly ``&back=%2F`` to
|
||||
that same pre-phase string (the encoding pinned byte-for-byte).
|
||||
|
||||
A refactor that bakes a ``back=`` into the base, reorders the
|
||||
param, or re-encodes the values breaks one of the exact strings."""
|
||||
fn = _fn(_js(), "fullPageUrl")
|
||||
out = _run_node(
|
||||
fn
|
||||
+ "\nconsole.log(fullPageUrl('H omelab', 'a/b.md'));"
|
||||
+ "\nconsole.log(fullPageUrl('H omelab', 'a/b.md', null));"
|
||||
+ "\nconsole.log(fullPageUrl('H omelab', 'a/b.md', '/'));"
|
||||
)
|
||||
absent, null_back, chat = out.splitlines()
|
||||
pre_phase = "/document.html?source=H%20omelab&path=a%2Fb.md"
|
||||
assert null_back == absent, "back = null must be byte-identical to an absent back"
|
||||
assert absent == pre_phase, (
|
||||
"a null/absent back must keep the URL byte-identical to pre-phase"
|
||||
)
|
||||
assert "back=" not in absent, "no &back= may be present for a null/absent back"
|
||||
assert chat == pre_phase + "&back=%2F", (
|
||||
"back = '/' must append exactly &back=%2F to the pre-phase string"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_open_document_modal_no_skeleton_fallback_carries_the_stored_back() -> None:
|
||||
"""The no-skeleton ``window.open`` fallback, executed under node
|
||||
(the real ``openDocumentModal`` with the module state stubbed):
|
||||
a chat open (``back = "/"``) lands in the URL exactly (``%2F``),
|
||||
and a Sources-style open (``back`` omitted — the 3-arg call)
|
||||
keeps the URL byte-identical (no ``&back=`` at all). The
|
||||
fallback is the ``window.open(url, "_blank", "noopener")`` shape,
|
||||
and the early ``return`` means neither open touches the
|
||||
history."""
|
||||
fn = _fn(_js(), "openDocumentModal")
|
||||
url_fn = _fn(_js(), "fullPageUrl")
|
||||
script = (
|
||||
"let currentBack = null;\n"
|
||||
"const modalEl = null, titleEl = null, metaEl = null, contentEl = null, openEl = null;\n"
|
||||
"const opened = [];\n"
|
||||
"globalThis.window = { open: (u, t, f) => opened.push([u, t, f]) };\n"
|
||||
+ url_fn
|
||||
+ "\n"
|
||||
+ fn
|
||||
+ "\nopenDocumentModal('H omelab', 'a/b c.md', null, '/');\n"
|
||||
+ "\nopenDocumentModal('S2', 'p2.md', null);\n"
|
||||
+ "\nconsole.log(JSON.stringify(opened));\n"
|
||||
)
|
||||
out = json.loads(_run_node(script))
|
||||
assert out == [
|
||||
[
|
||||
"/document.html?source=H%20omelab&path=a%2Fb%20c.md&back=%2F",
|
||||
"_blank",
|
||||
"noopener",
|
||||
],
|
||||
# the omitted back (the Sources table's 3-arg call) stays byte-identical
|
||||
["/document.html?source=S2&path=p2.md", "_blank", "noopener"],
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_open_document_modal_full_page_anchor_carries_the_stored_back() -> None:
|
||||
"""The ``#doc-modal-open`` ("Full page") anchor on a skeleton
|
||||
open, executed under node (the real ``openDocumentModal`` with the
|
||||
modal elements + history + fetch chain stubbed): a chat open
|
||||
(``back = "/"``) sets the anchor's ``href`` to the pre-phase string
|
||||
+ exactly ``&back=%2F``; a follow-up Sources-style open (``back``
|
||||
omitted — the defensive re-open-while-open branch, which updates
|
||||
the SAME anchor) resets it to the byte-identical no-param string.
|
||||
The open's first push is a single state-only entry (the phase-144
|
||||
contract stands); the defensive re-open replaces in place (no
|
||||
second push)."""
|
||||
fn = _fn(_js(), "openDocumentModal")
|
||||
url_fn = _fn(_js(), "fullPageUrl")
|
||||
script = (
|
||||
"let currentBack = null;\n"
|
||||
"let triggerEl = null;\n"
|
||||
"let fetchSeq = 0;\n"
|
||||
"let entry = null;\n"
|
||||
"let lastPrev = null;\n"
|
||||
"let closingViaBack = false;\n"
|
||||
"const modalEl = { hidden: true };\n"
|
||||
"const titleEl = {}, metaEl = { replaceChildren() {} };\n"
|
||||
"const contentEl = { replaceChildren() {}, focus() {} };\n"
|
||||
"const anchor = { href: '', hidden: true };\n"
|
||||
"const openEl = anchor;\n"
|
||||
"const hist = [];\n"
|
||||
"globalThis.history = { state: null,"
|
||||
" pushState(s, t) { hist.push(['push', s.modal === 'doc', s.source, s.path, t]); },"
|
||||
" replaceState(s, t) { hist.push(['replace', s.modal === 'doc', s.source, s.path]); } };\n"
|
||||
"globalThis.document = { addEventListener() {}, removeEventListener() {} };\n"
|
||||
"globalThis.fetch = () => Promise.resolve({ ok: true, json: async () => ({}) });\n"
|
||||
"function onModalKeydown() {}\n"
|
||||
"function showLoading() {}\n"
|
||||
"function setDesc() {}\n"
|
||||
"function renderNotFound() {}\n"
|
||||
"function wireCopyPath() {}\n"
|
||||
"function renderDocument() {}\n"
|
||||
'function contentUrl(s, p) { return ""; }\n'
|
||||
+ url_fn
|
||||
+ "\n"
|
||||
+ fn
|
||||
+ "\nopenDocumentModal('H omelab', 'a/b c.md', null, '/');\n"
|
||||
+ "\nconst chatHref = anchor.href;\n"
|
||||
+ "\nopenDocumentModal('S2', 'p2.md', null);\n"
|
||||
+ "\nconsole.log(JSON.stringify({ chatHref, sourcesHref: anchor.href,"
|
||||
+ " visible: !modalEl.hidden, hist }));\n"
|
||||
)
|
||||
out = json.loads(_run_node(script))
|
||||
pre_phase = "/document.html?source=H%20omelab&path=a%2Fb%20c.md"
|
||||
assert out["chatHref"] == pre_phase + "&back=%2F", (
|
||||
"a chat open must set the Full-page anchor to the pre-phase string + exactly &back=%2F"
|
||||
)
|
||||
assert out["sourcesHref"] == "/document.html?source=S2&path=p2.md", (
|
||||
"a Sources-style open (back omitted) must reset the anchor to the byte-"
|
||||
"identical no-param string"
|
||||
)
|
||||
assert out["visible"] is True
|
||||
# phase 144 stands: one state-only push, then the defensive
|
||||
# re-open replaces IN PLACE (no second push).
|
||||
assert out["hist"] == [
|
||||
["push", True, "H omelab", "a/b c.md", ""],
|
||||
["replace", True, "S2", "p2.md"],
|
||||
]
|
||||
|
||||
|
||||
@@ -842,7 +842,7 @@ def test_answer_image_helper_builds_the_lazy_capturing_figure() -> None:
|
||||
body = _function_body(_js(), "function appendAnswerImage(wrap, ref) {")
|
||||
assert 'fig.className = "answer-image";' in body
|
||||
assert 'fig.href = documentUrl(ref.source, ref.path, "/");' in body
|
||||
assert "openDocumentModal(ref.source, ref.path, fig);" in body
|
||||
assert "openDocumentModal(ref.source, ref.path, fig, \"/\")" in body
|
||||
assert "e.preventDefault();" in body # no new tab (phase 26)
|
||||
assert 'img.className = "answer-image-img";' in body
|
||||
assert "img.src = ref.image_url;" in body # the public bytes route
|
||||
|
||||
@@ -1390,7 +1390,7 @@ def test_chat_sources_figure_reads_image_url_sets_alt_and_falls_back_in_place()
|
||||
assert 'chip.href = documentUrl(s.source, s.path, "/");' in body, (
|
||||
"the fallback chip keeps the chip's /document.html href"
|
||||
)
|
||||
assert "openDocumentModal(s.source, s.path, chip);" in body, (
|
||||
assert "openDocumentModal(s.source, s.path, chip, \"/\")" in body, (
|
||||
"the fallback chip keeps the left-click → same-page modal"
|
||||
)
|
||||
assert "e.preventDefault();" in body # no new tab (phase 26)
|
||||
@@ -1406,7 +1406,7 @@ def test_chat_sources_figure_reads_image_url_sets_alt_and_falls_back_in_place()
|
||||
assert "innerHTML" not in code, "no innerHTML from document-derived data"
|
||||
# The figure reuses the chip's navigation (phase 26 contract):
|
||||
assert 'fig.href = documentUrl(s.source, s.path, "/");' in body
|
||||
assert "openDocumentModal(s.source, s.path, fig);" in body
|
||||
assert "openDocumentModal(s.source, s.path, fig, \"/\")" in body
|
||||
|
||||
|
||||
def test_chat_sources_splits_the_chips_row_from_the_images_row() -> None:
|
||||
|
||||
@@ -159,7 +159,7 @@ def test_append_related_exists_and_never_uses_source_chip() -> None:
|
||||
"each related link keeps the chip's /document.html href — the "
|
||||
"no-JS / context-menu escape hatch (back → the chat page)"
|
||||
)
|
||||
assert "openDocumentModal(s.source, s.path, link);" in body, (
|
||||
assert "openDocumentModal(s.source, s.path, link, \"/\")" in body, (
|
||||
"left-click opens the same-page document modal exactly like the "
|
||||
"chips (phase 26 contract)"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user