**Phase 122 (image documents) — final verification pass: all green. No code changes were needed; defects found: none.**
**Verified (implementation already complete in working tree, reviewed end-to-end):**
- Toggle (`BOR_IMAGES`/`BOR_IMAGE_EXTENSIONS`/`BOR_IMAGE_DIR`, off by default) + `GET /api/config` `images` flag
- Ingest: bytes digest, `image_dir` persistent copy, `content = summary = vision description` (chat-model call; only text embedded), fail-soft skip + `images_failed` counter
- Serve/display: `/api/documents/{id}/image` route (404 matrix), viewer `<img>` + description, Sources 48px lazy thumbnails, chat inline source figure (alt = summary), agent `read` marker
- Prune guard: images-off syncs never prune `is_image` docs
**Test / lint / coverage (exact commands & outcomes):**
- `uv run pytest` → exit 0 (green; note: pytest 9.1.1 `-q` omits the final count line in output — exit code authoritative)
- `uv run pytest --cov=app --cov-report=term-missing` → **2715 passed, exit 0, TOTAL 99%** (>90% gate)
- `uv run ruff check . && uv run pyright` → "All checks passed!" / "0 errors, 0 warnings, 0 informations"
- `uv run pytest tests/e2e/test_image_documents.py -v --no-cov` → **4 passed, exit 0** (isolation)
**Completion criteria:** (1) images=true → described/embedded/displayed docs: ✅ (E2E + integration) · (2) images=false byte-identical + image docs survive sync: ✅ (E2E negative app + unit/integration) · (3) viewer + chat rendering with alt text; failed description skips + logs, sync completes: ✅ · (4) test/lint/coverage gates: ✅ · (5) commit + phase move: deferred to harness per this pass's rules (working tree left uncommitted).
**Notable deviation (pre-existing, documented in code):** image route uses `require_user` (phase-79 posture, same gate as the document content endpoint) rather than the phase text's "public" parenthetical — matches the endpoint it mirrors.
**Next pending phase:** `123_chat_image_questions`.
497 lines
22 KiB
Python
497 lines
22 KiB
Python
"""Unit: document viewer (phase 10).
|
|
|
|
Python side:
|
|
* ``doc_format`` — format from the path suffix (incl. ``.markdown`` and the
|
|
no-suffix fallback) + the phase-102 extensionless name-token rule
|
|
(``Dockerfile`` → ``dockerfile`` with a configured token, suffixes still
|
|
unconditional, no-arg calls byte-identical to the suffix-only rule);
|
|
* the content endpoint's 200/404 mapping — tested WITHOUT a database by
|
|
stubbing the session via FastAPI's dependency override (unknown pairs and
|
|
traversal-style paths map to 404 ``{detail: "document not found"}``;
|
|
known pairs map to the full ``DocContent`` shape).
|
|
|
|
Frontend side:
|
|
* the viewer URL builder — its real query-encoding behavior (paths with
|
|
spaces/slashes) executed under node when available, plus source pins that
|
|
run everywhere;
|
|
* the shared-renderer extraction — ``markdown.js`` holds the renderer,
|
|
loaded by the pages that use it via a relative ``<script src>`` before
|
|
the module scripts;
|
|
* phase 26 — the shared ``renderDocument`` export in ``document.js``, the
|
|
import-safe page guard, the modal skeleton on chat + Sources, and the
|
|
modal module's close/focus/URL contract.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.api.docs import doc_format
|
|
from app.db import get_db
|
|
from app.main import create_app
|
|
from app.models import Document
|
|
from tests.conftest import ADMIN_PASSWORD
|
|
|
|
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
|
APP_JS = FRONTEND / "assets" / "app.js"
|
|
SOURCES_JS = FRONTEND / "assets" / "sources.js"
|
|
DOCUMENT_JS = FRONTEND / "assets" / "document.js"
|
|
MARKDOWN_JS = FRONTEND / "assets" / "markdown.js"
|
|
MODAL_JS = FRONTEND / "assets" / "document-modal.js" # phase 26: the modal owner
|
|
INDEX_HTML = FRONTEND / "index.html" # the ONE-document shell (phase 76: sources.html folded in)
|
|
|
|
HAVE_NODE = shutil.which("node") is not None
|
|
|
|
|
|
def _read(path: Path) -> str:
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# doc_format — format-from-suffix (phase 10, PLAN §4)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("path", "expected"),
|
|
[
|
|
("kubernetes.md", "md"),
|
|
("notes/sub/deep.markdown", "markdown"),
|
|
("NOTES/ARCHIVE.MD", "md"),
|
|
("homelab/container_gitlab/gitlab-compose.yaml", "yaml"),
|
|
("homelab/networking/static-dns.json", "json"),
|
|
("homelab/scripts/uptime_probe.py", "py"),
|
|
("homelab/ssh/ssh_aliases.txt", "txt"),
|
|
("noext", "text"), # no suffix → fallback
|
|
("a/b", "text"), # no suffix → fallback
|
|
],
|
|
)
|
|
def test_doc_format_from_suffix(path: str, expected: str) -> None:
|
|
assert doc_format(path) == expected
|
|
|
|
|
|
def test_doc_format_no_args_extensionless_falls_back_to_text() -> None:
|
|
"""The no-arg contract: default ``extensions=frozenset()`` keeps the
|
|
pre-phase-102 result for every path — an extensionless name that LOOKS
|
|
like a configured token still badges ``text`` without the token set."""
|
|
assert doc_format("Dockerfile") == "text"
|
|
assert doc_format("README") == "text"
|
|
|
|
|
|
#: A token set in the dotted form ``import_extension_set`` passes it.
|
|
_TOKEN_EXTS = frozenset({".md", ".dev", ".dockerfile", ".containerfile"})
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("path", "extensions", "expected"),
|
|
[
|
|
# Name-token branch (phase 102): suffix-less name in the set.
|
|
("services/api/Dockerfile", _TOKEN_EXTS, "dockerfile"),
|
|
("DOCKERFILE", _TOKEN_EXTS, "dockerfile"), # case-insensitive name
|
|
("Containerfile", _TOKEN_EXTS, "containerfile"),
|
|
# Suffix precedence: display never depends on the import list.
|
|
("Dockerfile.dev", _TOKEN_EXTS, "dev"), # suffixed → its suffix rules
|
|
("readme.rst", _TOKEN_EXTS, "rst"), # out-of-scope suffix still badges
|
|
("notes/README.dev", _TOKEN_EXTS, "dev"),
|
|
("dockerfile.bak", _TOKEN_EXTS, "bak"), # lookalike → suffix, not name
|
|
("kubernetes.md", frozenset(), "md"), # empty set: suffix unconditional
|
|
# Extensionless names NOT in the set fall back to text — exact
|
|
# name only, no partial names.
|
|
("README", _TOKEN_EXTS, "text"),
|
|
("mydockerfile", _TOKEN_EXTS, "text"),
|
|
],
|
|
)
|
|
def test_doc_format_with_token_set(
|
|
path: str, extensions: frozenset[str], expected: str
|
|
) -> None:
|
|
assert doc_format(path, extensions) == expected
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Content endpoint mapping — stubbed session, no database required
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _FakeResult:
|
|
def __init__(self, row: object) -> None:
|
|
self._row = row
|
|
|
|
def first(self) -> object:
|
|
return self._row
|
|
|
|
|
|
class _FakeSession:
|
|
def __init__(self, row: object) -> None:
|
|
self._row = row
|
|
|
|
def execute(self, _stmt: object) -> _FakeResult:
|
|
return _FakeResult(self._row)
|
|
|
|
|
|
def _client_with_row(row: object) -> TestClient:
|
|
"""Fresh app whose ``get_db`` dependency is a stub returning ``row``
|
|
(``None`` → no matching document row). Phase 79: the endpoint is
|
|
user-gated, so the client signs in as the admin first — these tests
|
|
pin the CONTENT mapping (200/404/422), not the auth contract (which
|
|
``test_auth_api.py`` pins)."""
|
|
app = create_app()
|
|
app.dependency_overrides[get_db] = lambda: _FakeSession(row)
|
|
client = TestClient(app)
|
|
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
|
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
|
|
return client
|
|
|
|
|
|
def test_content_unknown_pair_maps_to_404() -> None:
|
|
with _client_with_row(None) as client:
|
|
r = client.get(
|
|
"/api/documents/content",
|
|
params={"source": "Homelab", "path": "nope.md"},
|
|
)
|
|
assert r.status_code == 404
|
|
assert r.json() == {"detail": "document not found"}
|
|
|
|
|
|
def test_content_traversal_style_path_maps_to_404() -> None:
|
|
"""``../``-style values are just non-existent rows → 404, never a
|
|
file read (DB-only endpoint, no filesystem access)."""
|
|
with _client_with_row(None) as client:
|
|
r = client.get(
|
|
"/api/documents/content",
|
|
params={"source": "Homelab", "path": "../../etc/passwd"},
|
|
)
|
|
assert r.status_code == 404
|
|
assert r.json() == {"detail": "document not found"}
|
|
assert "root:" not in r.text # nothing leaked
|
|
|
|
|
|
def test_content_known_pair_maps_to_doc_content() -> None:
|
|
doc = Document(
|
|
source="Homelab",
|
|
path="notes/deep mark.md",
|
|
full_path="/tmp/deep mark.md",
|
|
title="Deep Mark",
|
|
content="# Deep Mark\n\nbody",
|
|
content_hash="f" * 64,
|
|
is_image=False, # phase 122: the stub session never applies defaults
|
|
)
|
|
doc.indexed_at = datetime(2026, 8, 22, 1, 2, 3, tzinfo=UTC)
|
|
doc.created_at = datetime(2026, 8, 20, 9, 0, 0, tzinfo=UTC) # phase 106
|
|
with _client_with_row((doc, 3)) as client:
|
|
r = client.get(
|
|
"/api/documents/content",
|
|
params={"source": "Homelab", "path": "notes/deep mark.md"},
|
|
)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
# Wire-additive (phase 106, task 05): the pre-date keys are all
|
|
# still there, joined by ``created_at`` — and (phase 122, task 04)
|
|
# by ``is_image`` (ALWAYS present; text docs: false) while
|
|
# ``image_url`` is ABSENT (never null — the omission rule).
|
|
assert set(body) == {
|
|
"source", "path", "title", "format", "summary", "created_at",
|
|
"content", "indexed_at", "chunks", "is_image",
|
|
}
|
|
assert body["is_image"] is False
|
|
assert "image_url" not in body # absent — never null (text doc)
|
|
assert body["source"] == "Homelab"
|
|
assert body["path"] == "notes/deep mark.md"
|
|
assert body["title"] == "Deep Mark"
|
|
assert body["format"] == "md"
|
|
assert body["summary"] is None # markdown doc → no summary (phase 36)
|
|
assert body["content"] == "# Deep Mark\n\nbody"
|
|
assert body["created_at"] == "2026-08-20T09:00:00+00:00" # phase 106
|
|
assert body["indexed_at"] == "2026-08-22T01:02:03+00:00"
|
|
assert body["chunks"] == 3
|
|
|
|
|
|
def test_content_requires_both_params() -> None:
|
|
with _client_with_row(None) as client:
|
|
assert client.get("/api/documents/content").status_code == 422
|
|
assert client.get("/api/documents/content", params={"source": "Homelab"}).status_code == 422
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Viewer URL builder — encoded query (spaces/slashes in real paths)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_viewer_url_builder_present_in_chat_and_sources() -> None:
|
|
"""Both entry points (chat chips, Sources rows) build the same
|
|
encoded viewer URL — kept as each link's ``href`` (no-JS /
|
|
context-menu escape hatch to the dedicated viewer).
|
|
|
|
Phase 13: the chat builder additionally carries ``back=/`` (encoded
|
|
%2F) so the viewer's back button returns to the chat; Sources links
|
|
intentionally omit the param (the viewer's /sources.html default).
|
|
|
|
Phase 26: the left-click no longer opens a new tab — it is
|
|
intercepted (preventDefault) and routed to openDocumentModal from
|
|
the shared modal module; no ``target="_blank"" survives on either
|
|
entry point."""
|
|
for name, js in (("app.js", _read(APP_JS)), ("sources.js", _read(SOURCES_JS))):
|
|
assert '"/document.html?source=" + encodeURIComponent(' in js, name
|
|
assert '"&path=" + encodeURIComponent(' in js, name
|
|
assert 'target = "_blank"' not in js, f"{name}: phase 26 — no new tabs"
|
|
|
|
app_js = _read(APP_JS)
|
|
# Chat: 3-arg builder with back defaulting to the chat page.
|
|
assert 'function documentUrl(source, path, back = "/")' in app_js
|
|
assert '"&back=" + encodeURIComponent(back)' in app_js
|
|
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
|
|
|
|
sources_js = _read(SOURCES_JS)
|
|
# Sources: unchanged 2-arg builder — no back param in the URL.
|
|
assert "function documentUrl(source, path)" in sources_js
|
|
assert 'link.className = "doc-link"' in sources_js
|
|
assert "link.href = documentUrl(d.source, d.path)" in sources_js
|
|
assert 'link.addEventListener("click"' in sources_js
|
|
assert "openDocumentModal(d.source, d.path, link)" in sources_js
|
|
# The full path stays the hover name on the ellipsized cell AND the link.
|
|
assert "pathTd.title = d.path" in sources_js
|
|
assert "link.title = d.path" in sources_js
|
|
|
|
|
|
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 _extract_function(js: str, name: str) -> str:
|
|
match = re.search(
|
|
rf"(?:export )?function {name}\(source, path(?:, back = \"/\")?\) \{{.*?\n\}}", js, re.S
|
|
)
|
|
assert match, f"{name}(source, path) not found"
|
|
return match.group(0).replace("export ", "", 1)
|
|
|
|
|
|
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
|
def test_viewer_url_builder_encodes_spaces_and_slashes() -> None:
|
|
"""Behavioral check of the real builder (app.js) under node: slashes
|
|
and spaces in source/path values must come out percent-encoded, and
|
|
the back target is appended + encoded (phase 13)."""
|
|
fn = _extract_function(_read(APP_JS), "documentUrl")
|
|
out = _run_node(
|
|
f"{fn}\n"
|
|
"console.log(documentUrl('Homelab', 'kubernetes.md'));\n"
|
|
"console.log(documentUrl('Homelab', 'notes/my file.yaml'));\n"
|
|
"console.log(documentUrl('H omelab', 'a/b.md'));\n"
|
|
"console.log(documentUrl('Homelab', 'kubernetes.md', '/sources.html'));"
|
|
)
|
|
assert out.splitlines() == [
|
|
"/document.html?source=Homelab&path=kubernetes.md&back=%2F",
|
|
"/document.html?source=Homelab&path=notes%2Fmy%20file.yaml&back=%2F",
|
|
"/document.html?source=H%20omelab&path=a%2Fb.md&back=%2F",
|
|
"/document.html?source=Homelab&path=kubernetes.md&back=%2Fsources.html",
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared renderer extraction (phase 10 step 2)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_renderer_extracted_to_shared_markdown_js() -> None:
|
|
"""The renderer moved to assets/markdown.js (not duplicated in app.js)
|
|
and BOTH pages load it via a relative <script src> before their module
|
|
scripts — so the globals exist when app.js/document.js run."""
|
|
md = _read(MARKDOWN_JS)
|
|
assert "function renderMarkdown(md)" in md
|
|
assert "function escapeHtml(s)" in md
|
|
|
|
app_js = _read(APP_JS)
|
|
assert "function renderMarkdown" not in app_js, "renderer must live in markdown.js"
|
|
assert "function escapeHtml" not in app_js
|
|
|
|
for page in ("index.html", "document.html"):
|
|
html = _read(FRONTEND / page)
|
|
assert re.search(r'<script src="assets/markdown\.js"></script>', html), (
|
|
f"{page} must load markdown.js via a relative <script src>"
|
|
)
|
|
assert html.index('src="assets/markdown.js"') < html.index('type="module"'), (
|
|
f"{page}: markdown.js must load before the module script"
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
|
def test_markdown_renderer_stays_xss_safe_and_unchanged() -> None:
|
|
"""Behavioral check (node) that the extracted renderer still escapes
|
|
first: hostile content never becomes live HTML; basic transforms work."""
|
|
js = _read(MARKDOWN_JS)
|
|
out = _run_node(
|
|
js
|
|
+ "\nconsole.log(renderMarkdown('# Title\\n\\n<script>alert(1)</script>"
|
|
+ "\\n\\n**bold** and `code`'));"
|
|
)
|
|
html = out.strip()
|
|
assert "<script>" not in html # never live HTML
|
|
assert "<script>alert(1)</script>" in html
|
|
assert "<strong>bold</strong>" in html
|
|
assert "<code>code</code>" in html
|
|
assert "<h3>Title</h3>" in html
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 26 — shared renderDocument + the document modal wiring
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_render_document_exported_and_modal_imports_it() -> None:
|
|
"""Phase 26: document.js EXPORTS renderDocument(doc, { … }) — the
|
|
exact renderer the standalone page and the modal share (no drift).
|
|
The modal module imports it relatively, and BOTH view scripts
|
|
(app.js — the chat view at shell boot — and sources.js — the RAG
|
|
view module) import the modal module relatively — no direct
|
|
<script> tag (the header.js single-evaluation design: esbuild
|
|
inlines it into the bundle, one module instance per document).
|
|
"""
|
|
doc_js = _read(DOCUMENT_JS)
|
|
# Task 03 signature: the page passes its #doc-title / #doc-meta /
|
|
# #doc-content elements under exactly these names.
|
|
assert "export function renderDocument(doc, { titleEl, metaEl, contentEl })" in doc_js, (
|
|
"document.js must export renderDocument(doc, { titleEl, metaEl, contentEl })"
|
|
)
|
|
# The standalone page renders through the SAME shared function with its
|
|
# own page elements (no second renderer copy).
|
|
assert "renderDocument(doc, { titleEl, metaEl, contentEl })" in doc_js
|
|
modal_js = _read(MODAL_JS)
|
|
assert 'from "./document.js"' in modal_js
|
|
assert "export function openDocumentModal(" in modal_js
|
|
assert "export function closeDocumentModal(" in modal_js
|
|
for name, js in (("app.js", _read(APP_JS)), ("sources.js", _read(SOURCES_JS))):
|
|
assert 'from "./document-modal.js"' in js, (
|
|
f"{name}: must import the modal module relatively"
|
|
)
|
|
text = _read(INDEX_HTML)
|
|
assert not re.search(r"<script[^>]*document-modal\.js", text), (
|
|
"index.html: no direct document-modal.js <script> tag "
|
|
"(single-evaluation design — the view scripts import it)"
|
|
)
|
|
|
|
|
|
def test_document_js_page_init_is_import_safe() -> None:
|
|
"""Phase 26: the /document.html-specific init (back-link resolution,
|
|
whoami, content load) runs ONLY when #doc-title exists — the modal
|
|
module's `import { renderDocument } from "./document.js"` on the
|
|
chat/sources pages must have no side effects."""
|
|
js = _read(DOCUMENT_JS)
|
|
guard = js.find('querySelector("#doc-title")')
|
|
back_href = js.find("backLink.href = backTarget")
|
|
load_call = js.rfind("load();")
|
|
assert 0 < guard < back_href < load_call, (
|
|
"the viewer-page init must sit inside the #doc-title guard "
|
|
"(after it, and load() must be the guarded entry point)"
|
|
)
|
|
|
|
|
|
def test_shell_carries_the_single_modal_skeleton() -> None:
|
|
"""Phase 26 + phase 76 (task 02) dedup pin: the shell ships the
|
|
modal skeleton EXACTLY ONCE (the chat's, body level) — the RAG
|
|
view's second copy was dropped in the fold; BOTH view scripts
|
|
(app.js chat chips, sources.js RAG row links) open documents
|
|
through openDocumentModal(...) against that single instance
|
|
(document-modal.js resolves it by document-level querySelector at
|
|
import). The a11y frame is included: role=dialog + aria-modal, a
|
|
labelled close control, a focusable content target (tabindex=-1),
|
|
and a role=status announcer. Hidden by default — inert until JS
|
|
opens it."""
|
|
text = _read(INDEX_HTML)
|
|
assert text.count('id="doc-modal"') == 1, (
|
|
"the shell must carry exactly ONE modal skeleton (the fold dedup)"
|
|
)
|
|
assert '<div class="doc-modal" id="doc-modal" hidden>' in text
|
|
assert 'id="doc-modal-backdrop"' in text
|
|
assert 'id="doc-modal-panel"' in text
|
|
assert 'role="dialog"' in text and 'aria-modal="true"' in text
|
|
assert 'id="doc-modal-title"' in text
|
|
assert 'id="doc-modal-meta"' in text
|
|
assert 'id="doc-modal-desc"' in text
|
|
assert 'id="doc-modal-open"' in text
|
|
assert re.search(r'id="doc-modal-content"[^>]*tabindex="-1"', text)
|
|
assert re.search(r'id="doc-modal-close"[^>]*aria-label="Close document"', text)
|
|
|
|
|
|
def test_shell_loads_markdown_before_its_modules() -> None:
|
|
"""Phase 26 + phase 76 (task 02): the modal renders md documents
|
|
in the RAG view too — so the shell loads the classic markdown.js
|
|
(global renderMarkdown) via a relative <script src> BEFORE its
|
|
module scripts (app.js — the chat view — and the lazy view
|
|
modules' modal imports), exactly as the old sources.html did."""
|
|
html = _read(INDEX_HTML)
|
|
assert re.search(r'<script src="assets/markdown\.js"></script>', html)
|
|
assert html.index('src="assets/markdown.js"') < html.index('type="module"'), (
|
|
"index.html: markdown.js must load before the module scripts"
|
|
)
|
|
|
|
|
|
def test_modal_close_contract_pins() -> None:
|
|
"""Phase 26: the modal closes on the close button, on backdrop
|
|
click, and on Escape (captured document-level, so it works from any
|
|
focus position); focus returns to the triggering control
|
|
(best-effort); the fetch goes to the stateless content endpoint with
|
|
the same percent-encoding the page uses, and success renders through
|
|
the shared renderDocument; the "Full page" link is rebuilt on open."""
|
|
js = _read(MODAL_JS)
|
|
assert 'e.key === "Escape"' in js
|
|
assert 'addEventListener("keydown"' in js
|
|
assert "backdropEl.addEventListener(\"click\", closeDocumentModal)" in js
|
|
assert "closeEl.addEventListener(\"click\", closeDocumentModal)" in js
|
|
assert "triggerEl.focus" in js # best-effort focus restore
|
|
assert '"/api/documents/content?source=" + encodeURIComponent(' in js
|
|
assert '"&path=" + encodeURIComponent(' in js
|
|
# 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
|
|
|
|
|
|
@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)."""
|
|
js = _read(MODAL_JS)
|
|
content_fn = _extract_function(js, "contentUrl")
|
|
full_fn = _extract_function(js, "fullPageUrl")
|
|
out = _run_node(
|
|
content_fn
|
|
+ full_fn
|
|
+ "\nconsole.log(contentUrl('Homelab', 'notes/my file.yaml'));\n"
|
|
+ "console.log(fullPageUrl('H omelab', 'a/b.md'));"
|
|
)
|
|
assert out.splitlines() == [
|
|
"/api/documents/content?source=Homelab&path=notes%2Fmy%20file.yaml",
|
|
"/document.html?source=H%20omelab&path=a%2Fb.md",
|
|
]
|
|
|
|
|
|
def test_viewer_js_rendering_contracts() -> None:
|
|
"""document.js: raw formats go in via textContent (never parsed as
|
|
HTML), markdown via the shared renderer, 404 → designed not-found
|
|
state, and (phase 13) the back link resolves the ``back`` param —
|
|
same-origin relative URLs only, /sources.html default, no browser
|
|
history heuristics (both entry points are fresh tabs)."""
|
|
js = _read(DOCUMENT_JS)
|
|
assert "pre.textContent = doc.content" in js # raw formats: text node
|
|
assert "renderMarkdown(doc.content)" in js # md/markdown: shared renderer
|
|
assert "showNotFound" in js
|
|
# Phase 13: deterministic back-target resolution, no history heuristics.
|
|
assert "history.length" not in js
|
|
assert "window.history.back" not in js
|
|
assert 'backParam.startsWith("/")' in js # same-origin relative only…
|
|
assert 'backParam.startsWith("//")' in js # …and not protocol-relative
|
|
assert 'backLink.href = backTarget' in js # deterministic anchor navigation
|
|
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
|