Files
brain-of-reese/tests/e2e/test_markdown_inline_links_images.py
ducoterra 6880bd0866
Build and Push Containers / build-and-push-app (push) Successful in 1m51s
Build and Push Containers / build-and-push-db (push) Successful in 15s
phase: 180_markdown_inline_links_images
All verification complete. Final state summary:

**Report — phase 180 final verification pass**
- Verified (all already implemented in working tree): `inline()` chain = code→strong→em→linked-image→image→link→headings→lists in `frontend/assets/markdown.js`; `allowLinkUrl`/`allowImageUrl` allowlists (A5), shared `mdAnchorOpen` (target/rel only for absolute http(s)); `####`→`<h5>`, `#####`/`######`→`<h6>`, `#`/`##`→`<h3>`/`###`→`<h4>` unchanged; `.md-link`/`.md-img`/h5-h6 scale in `styles.css` (palette vars only). All surfaces (chat, thinking, restore, viewer, modal) route through the one renderer (pinned in `app.js`/`document.js`).
- Completion criteria:
  - `[text](url)`→`a.md-link`, `javascript:`/`data:` inert — ✅ (node probe + unit + E2E)
  - `![alt](src)`→`img.md-img loading="lazy"` with `alt`; bad src inert — ✅
  - `####`/`#####`/`######` ladder; shallow rungs unchanged — ✅
  - Fenced link/image/`####` stay code; cell-level link renders in `.md-table` — ✅ (node probe)
  - Byte-identity fixture + phase-44/138 suites pass unedited (git-verified untouched; 6 + 3 E2E pass in isolation) — ✅
  - `uv run pytest --cov=app --cov-report=term-missing`: **4198 passed, TOTAL 99%**; `uv run pytest tests/e2e/test_markdown_inline_links_images.py -v --no-cov`: **4 passed**; `uv run ruff check .`: clean; `uv run pyright`: **0 errors** — ✅
- Docs: full README/AGENTS.md review vs the whole project — env-example↔README table complete, page routes match `app/main.py`, AGENTS quick-ref artifacts exist. Fixed 1 inconsistency: README understated the image allowlist (impl per task 02 accepts first segments ≤2 chars, `^.{0,2}/`, not only `./`/`../`); phase-180 paragraphs otherwise accurate.
- Notable: image allowlist `.{0,2}/` is broader than the overview's `^\.{1,2}/` but is what task 02 explicitly specifies and the docstring documents — not a deviation from the tasks; SVG sizing deliberately deferred to phase 181.
- Next pending phase: `181_svg_document_preview`.
2026-10-14 16:03:36 -04:00

396 lines
18 KiB
Python

"""Phase 180 E2E (Playwright): markdown links, images and deep headings.
Source: ``TODO.md`` L4 ("Markdown [links](some-address) aren't being
render correctly and show up as plain text"), L5 ("Quad-level headers
(####) aren't being rendered and show up as plain text") and the
in-answer half of L3 ("Need SVG render support on the front end").
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_markdown_inline_links_images.py -v --no-cov
Seeding reuses the real importer against the phase-180 fixture directory
``tests/fixtures/links_docs/`` with the deterministic mock embeddings.
A dedicated tree (like phase 138's ``quote_docs``) — the shared 13-doc
fixture set's import count is pinned by many other suites and must not
move. The mock's ``INLINE_TRIGGER`` ("show me inline markdown", phase 180
task 05) returns the byte-stable inline-markdown answer: an absolute link
with a ``&`` query string, a ``/``-rooted link, a ``mailto:`` link and a
``#fragment`` link, an inert ``javascript:`` link probe, a
``data:image/svg+xml`` image, an inert ``javascript:`` image probe, a
LINKED image (locked A6), a ``####`` and a ``#####`` heading, a pipe
table with a link in a cell, and a plain closing line. The fixture
``homelab/inline-markdown.md`` (the same shapes in a real document) is
the viewer subject — the document viewer is database-only, so the
imported row is enough.
Test → story mapping (Playwright Mapping Rule):
1. ``test_chat_inline_markdown_renders`` — the brain bubble carries the
six ``a.md-link`` elements (the absolute one with
``target="_blank" rel="noopener noreferrer"`` and its ``&amp;``
preserved verbatim in the DOM, the relative / ``mailto:`` /
``#fragment`` ones with neither), NO live anchor for the
``javascript:`` probe (whose text stays visible), the
``img.md-img`` with a non-empty ``alt`` + ``loading="lazy"``, the
linked image as ``a.md-link > img.md-img`` with no stray bracket,
the ``<h5>``/``<h6>`` rungs and the cell-level link inside
``table.md-table``; the turn is NOT deflected.
2. ``test_viewer_inline_markdown_renders`` — the fixture document opens
from the Sources catalog (admin) in the document modal and renders
the same ``a.md-link`` / ``img.md-img`` / ``h5`` / ``h6`` shapes
with the same two inert probes (the shared-renderer contract across
surfaces).
3. ``test_restore_keeps_inline_markdown`` — a reload restores the
conversation (the localStorage restore path re-runs the shared
renderer, app.js ``addMessage``) and the restored brain bubble keeps
its links, its image and its deep headings.
4. ``test_no_horizontal_overflow_at_mobile`` — with the wide image
rendered at 375px, the document never scrolls horizontally (the
``.md-img`` ``max-width: 100%`` contract, task 04).
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from threading import Thread
from typing import Any
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" / "links_docs"
#: Carries the mock's ``INLINE_TRIGGER`` ("show me inline markdown") and
#: is on-topic (the fixture doc answers it — token-overlap cosine
#: ≈0.48 against the mock embeddings, so the turn is HIGH and the suite
#: can assert non-deflection).
QUESTION = (
"Show me inline markdown links, images and deep headers "
"from the homelab runbook notes?"
)
INLINE_PATH = "homelab/inline-markdown.md"
INLINE_TITLE = "Homelab Inline Markdown Notes"
#: The byte-stable answer's plain closing line (the stream-completion
#: marker — it lands last).
ANSWER_END = "a plain closing line"
#: Every allowlisted link the answer renders, as ``(href, is_external)``.
#: The external (absolute ``http(s)://``) ones carry the
#: ``target``/``rel`` pair; the others stay in the same tab (task 01).
ANSWER_LINKS = [
("https://example.com/a?x=1&y=2", True),
("/sources", False),
("mailto:reese@example.com", False),
("#homelab-inline-markdown-notes", False),
("https://example.com/runbook", True),
("https://example.com", True),
]
#: Every live image the answer renders, as ``alt`` text (task 02: ``alt``
#: is ALWAYS emitted, and the lazy hint is on every image).
ANSWER_IMAGE_ALTS = ["topology", "linked topology"]
#: The inert probes — the whole construct must stay visible TEXT (never a
#: live element, never dropped).
INERT_LINK = "[oops](javascript:alert(1))"
INERT_IMAGE = "![bad](javascript:alert(1))"
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 (the established house helper).
"""
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) -> ImportSummary:
"""Truncate the KB (+ the global prompt-state rows), then re-import
the phase-180 fixture (one document)."""
with SessionLocal() as db:
db.execute(
text("TRUNCATE chunks, documents, query_log, steering_notes, kb_overview")
)
db.commit()
summary = _run_in_thread(_import_fixtures(mock_port))
assert summary is not None and summary.added == 1, summary
return summary
def _ask_inline_answer(page: Page, app_url: str) -> Any:
"""Drive the trigger question and return the brain bubble once the
whole byte-stable inline answer has streamed in (the plain closing
line lands last)."""
login(page, app_url, next="/") # phase 79: chat is require_user-gated
page.fill("#message-input", QUESTION)
page.click("#send-btn")
bubble = page.locator(".msg.brain .bubble").first
bubble.wait_for(state="visible", timeout=30_000)
expect(bubble).to_contain_text(ANSWER_END, timeout=30_000)
# Non-deflection is part of the inline contract (honesty gate
# interplay, like phase 44's table and phase 138's quote answer).
expect(page.locator(".msg.brain.is-deflected")).to_have_count(0)
return bubble
def _assert_links(root: Any, links: list[tuple[str, bool]]) -> None:
"""The shared assertion (chat bubble, modal, restored bubble): exactly
``len(links)`` ``a.md-link`` elements with those hrefs, each external
one carrying ``target="_blank" rel="noopener noreferrer"`` and each
other one carrying NEITHER (a same-origin target in the same tab is
correct), and no live ``javascript:`` anchor anywhere."""
anchors = root.locator("a.md-link")
expect(anchors).to_have_count(len(links))
seen: list[tuple[str, bool]] = []
for i in range(anchors.count()):
a = anchors.nth(i)
href = a.get_attribute("href")
target = a.get_attribute("target")
rel = a.get_attribute("rel") or ""
# LOCKED A5: an anchor is never built for a scripting scheme.
assert href is not None and not href.lower().startswith("javascript"), (
f"link {i}: a live javascript: anchor exists ({href!r})"
)
expected_external = next((ext for h, ext in links if h == href), None)
assert expected_external is not None, f"link {i}: unexpected href {href!r}"
if expected_external:
assert target == "_blank", f"link {i} ({href!r}): target={target!r}"
assert "noopener" in rel and "noreferrer" in rel, (
f"link {i} ({href!r}): rel={rel!r} misses noopener/noreferrer"
)
else:
assert target is None, f"link {i} ({href!r}) must stay in the same tab"
assert rel == "", f"link {i} ({href!r}) must carry no rel"
seen.append((href, expected_external))
assert sorted(seen) == sorted(links), f"link set mismatch: {seen!r} != {links!r}"
def _assert_images(root: Any, alts: list[str]) -> None:
"""The shared assertion: exactly ``len(alts)`` ``img.md-img``
elements, each with its ``alt`` present and non-empty and
``loading="lazy"``, and no live ``javascript:`` src (LOCKED A5)."""
images = root.locator("img.md-img")
expect(images).to_have_count(len(alts))
got: list[str] = []
for i in range(images.count()):
img = images.nth(i)
alt = img.get_attribute("alt")
src = img.get_attribute("src") or ""
assert alt is not None, f"image {i}: <img> without an alt attribute (WCAG 1.1)"
assert alt != "", f"image {i}: expected a descriptive alt, got the empty one"
assert img.get_attribute("loading") == "lazy", f"image {i}: loading is not lazy"
assert not src.lower().startswith("javascript"), (
f"image {i}: a live javascript: src exists ({src!r})"
)
got.append(alt)
assert sorted(got) == sorted(alts), f"alt set mismatch: {got!r} != {alts!r}"
def _assert_headings(root: Any) -> None:
"""The shared assertion (locked A4): ``####`` renders ``<h5>`` and
``#####`` renders ``<h6>``, with no literal hash syntax left and no
``<p>`` wrapper around either heading."""
expect(root.locator("h5")).to_have_count(1)
expect(root.locator("h5")).to_have_text("Deep header")
expect(root.locator("h6")).to_have_count(1)
expect(root.locator("h6")).to_have_text("Deeper")
html = root.inner_html()
assert "####" not in html, "the hash syntax must not survive as text"
assert "<p><h5" not in html and "<p><h6" not in html, "a deep heading was wrapped in <p>"
def _assert_inert_probes(root: Any) -> None:
"""The two rejected constructs stay VISIBLE TEXT (never dropped,
never a live element): no anchor with a ``javascript:`` href, no
``<img>`` with a ``javascript:`` src, no live ``<script>`` — and the
escaped reference text is still on screen."""
expect(root.locator('a[href^="javascript" i]')).to_have_count(0)
expect(root.locator('img[src^="javascript" i]')).to_have_count(0)
expect(root.locator("script")).to_have_count(0)
expect(root).to_contain_text(INERT_LINK)
expect(root).to_contain_text(INERT_IMAGE)
# ---------------------------------------------------------------------------
# 1. Chat: links, images and the deep headings render in the answer
# ---------------------------------------------------------------------------
def test_chat_inline_markdown_renders(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(30_000)
bubble = _ask_inline_answer(page, app_url)
# TODO.md L4: the six allowlisted links are real anchors, and the
# target/rel pair rides ONLY the absolute http(s) ones.
_assert_links(bubble, ANSWER_LINKS)
# Escape-first, never decoded (design D2): the `&` of the query
# string reaches the DOM as the escaped `&amp;` form.
html = bubble.inner_html()
assert "https://example.com/a?x=1&amp;y=2" in html, (
"the query string must carry the escaped &amp; verbatim"
)
# TODO.md L3 (the in-answer half): the diagram paints instead of
# printing its reference, with its alt and its lazy hint.
_assert_images(bubble, ANSWER_IMAGE_ALTS)
assert "![topology](" not in bubble.inner_text(), (
"the image reference must render, not print"
)
# LOCKED A6: the linked image is ONE anchor wrapping ONE image, with
# no stray bracket left in the link's own text.
wrapped = bubble.locator("a.md-link > img.md-img")
expect(wrapped).to_have_count(1)
anchor_text = page.locator("a.md-link", has=page.locator("img.md-img")).first.inner_text()
assert anchor_text.strip() == "", f"stray text in the linked-image anchor: {anchor_text!r}"
for bracket in ("[", "]"):
assert bracket not in anchor_text, f"{bracket} left in the linked-image anchor"
# LOCKED A4 / TODO.md L5: the deep rungs of the ladder.
_assert_headings(bubble)
# LOCKED A5: the two rejected constructs stay inert visible text.
_assert_inert_probes(bubble)
# D1: the cell goes through the SAME inline() chain, so a link
# inside a table cell renders (and the table itself still renders).
table = bubble.locator("table.md-table")
expect(table).to_have_count(1)
cell_link = table.locator("a.md-link")
expect(cell_link).to_have_count(1)
expect(cell_link).to_have_text("cell link")
assert cell_link.get_attribute("target") == "_blank"
# ---------------------------------------------------------------------------
# 2. Shared renderer: the viewer/modal renders the fixture document
# ---------------------------------------------------------------------------
def test_viewer_inline_markdown_renders(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(30_000)
# Admin → Sources → the inline-markdown.md row → same-page document
# modal. Phase 97: the catalog is the drill-down tree — the row
# lives at its folder level (``links_docs`` → ``homelab``).
login(page, app_url) # phase 16: the Sources catalog is admin-only
for name in ("links_docs", "homelab"): # the fixture's source + folder
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
row = page.locator("#docs-tbody tr", has_text=INLINE_PATH)
expect(row).to_have_count(1)
row.locator("td:nth-child(2) a.doc-link").click()
expect(page.locator(".doc-modal")).to_be_visible()
expect(page.locator("#doc-modal-title")).to_have_text(INLINE_TITLE)
# The same shapes the chat bubble gets — the shared renderer serves
# the modal too: the allowlisted links with their target/rel rule,
# the data-URL images with alt + lazy, the deep headings, and the
# two inert probes.
content = page.locator("#doc-modal-content")
_assert_links(content, ANSWER_LINKS)
_assert_images(content, ANSWER_IMAGE_ALTS)
_assert_headings(content)
_assert_inert_probes(content)
# The cell-level transform in the document's own table (the cell
# goes through the same inline() chain as the whole-text pass).
table = content.locator("table.md-table")
expect(table).to_have_count(1)
expect(table.locator("a.md-link")).to_have_count(1)
# ---------------------------------------------------------------------------
# 3. Restore: a reload re-renders the saved conversation through the
# same shared renderer and keeps the links, the image, the headings
# ---------------------------------------------------------------------------
def test_restore_keeps_inline_markdown(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(30_000)
_ask_inline_answer(page, app_url)
# Reload — the same context keeps its localStorage; the phase-14
# restore path re-runs the shared renderer over the persisted RAW
# text (app.js addMessage), so everything must come back.
page.reload()
expect(page.locator("#empty-state")).to_be_hidden()
bubble = page.locator(".msg.brain .bubble").first
bubble.wait_for(state="visible", timeout=30_000)
expect(bubble).to_contain_text(ANSWER_END, timeout=30_000)
expect(page.locator(".msg.brain.is-deflected")).to_have_count(0)
# The restored bubble keeps the links (same hrefs, same target/rel
# rule), the image (alt + lazy), the deep headings and the inert
# probes — the restore runs the SAME renderer.
_assert_links(bubble, ANSWER_LINKS)
_assert_images(bubble, ANSWER_IMAGE_ALTS)
_assert_headings(bubble)
_assert_inert_probes(bubble)
expect(bubble.locator("a.md-link > img.md-img")).to_have_count(1)
expect(bubble.locator("table.md-table a.md-link")).to_have_count(1)
# ---------------------------------------------------------------------------
# 4. Task 04's image contract: the wide image never overflows the page
# horizontally at a phone viewport
# ---------------------------------------------------------------------------
def test_no_horizontal_overflow_at_mobile(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm)
page.set_viewport_size({"width": 375, "height": 720})
page.set_default_timeout(30_000)
bubble = _ask_inline_answer(page, app_url)
# The images are rendered (not text) at 375px — the max-width:100%
# contract only means anything once the replaced element is painted.
images = bubble.locator("img.md-img")
expect(images).to_have_count(len(ANSWER_IMAGE_ALTS))
for i in range(images.count()):
box = images.nth(i).bounding_box()
assert box is not None and box["width"] > 0, f"image {i} is not painted"
assert box["width"] <= 375, f"image {i} is {box['width']}px wide at a 375px viewport"
# The document itself never scrolls sideways (the phase-07 pin).
scroll_w, client_w = page.evaluate(
"() => [document.documentElement.scrollWidth, document.documentElement.clientWidth]"
)
assert scroll_w <= client_w, f"horizontal overflow at 375px: {scroll_w} > {client_w}"