"""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 ``&``
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 ``
``/```` 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 = ")"
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}:
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 ```` and
``#####`` renders ````, with no literal hash syntax left and no
``
`` 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 "
"
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
``
`` with a ``javascript:`` src, no live ``