phase: 153_document_viewer_context
Build and Push Containers / build-and-push-app (push) Successful in 2m45s
Build and Push Containers / build-and-push-db (push) Successful in 19s

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:
2026-10-04 09:23:54 -04:00
parent 12fa69b7ea
commit 966c4b0540
59 changed files with 4304 additions and 34 deletions
+3 -2
View File
@@ -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.
+486
View File
@@ -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)
+790 -7
View File
@@ -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
+260 -1
View File
@@ -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"],
]
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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:
+1 -1
View File
@@ -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)"
)