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) - ``→`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`.
396 lines
18 KiB
Python
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 ``&``
|
|
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 = ")"
|
|
|
|
|
|
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 `&` form.
|
|
html = bubble.inner_html()
|
|
assert "https://example.com/a?x=1&y=2" in html, (
|
|
"the query string must carry the escaped & 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 ", (
|
|
"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}"
|