"""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 = "![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}: 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 ``