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) - ``→`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`.
This commit is contained in:
@@ -98,6 +98,7 @@ def login(page: Page, app_url: str, password: str | None = None, next: str | Non
|
||||
expect(page.locator("#login-password")).to_be_visible(timeout=2_000)
|
||||
except AssertionError:
|
||||
expect(page).to_have_url(app_url + (next or DEFAULT_NEXT), timeout=30_000)
|
||||
_wait_for_app_boot(page)
|
||||
return
|
||||
else:
|
||||
expect(page.locator("#login-password")).to_be_visible()
|
||||
@@ -108,3 +109,22 @@ def login(page: Page, app_url: str, password: str | None = None, next: str | Non
|
||||
expect(page).to_have_url(url) # no redirect on failure
|
||||
return
|
||||
expect(page).to_have_url(app_url + (next or DEFAULT_NEXT), timeout=30_000)
|
||||
_wait_for_app_boot(page)
|
||||
|
||||
|
||||
def _wait_for_app_boot(page: Page) -> None:
|
||||
"""Wait until the landed page's deferred module graph has EXECUTED.
|
||||
|
||||
``to_have_url`` passes the moment the redirect navigation COMMITS, while
|
||||
``document.readyState`` is still ``interactive`` — the page's
|
||||
``<script type="module">`` (app.js plus everything it imports) can still
|
||||
be in flight, so a handler the page wires up in JS does not exist yet.
|
||||
A caller that submits straight away (``#composer.requestSubmit()`` in the
|
||||
thinking suites) then fires the form's NATIVE GET submit: the browser
|
||||
navigates to ``/?message=…``, the conversation is never sent, and the
|
||||
story's first assertion fails against an empty chat. ``load`` fires after
|
||||
every deferred module has run, so this makes an interactive page a real
|
||||
precondition of ``login`` for every suite — and it is a no-op once the
|
||||
page has settled, so no suite pays a wait twice.
|
||||
"""
|
||||
page.wait_for_load_state("load")
|
||||
|
||||
@@ -528,6 +528,21 @@ Implements just enough of the aipi surface:
|
||||
marker, same reasoning as ``SUMMARY_MODE``), so a marker question
|
||||
always gets the quote answer; the E2E asks it against an on-topic
|
||||
fixture (HIGH gate) and asserts non-deflection.
|
||||
- user message containing ``show me inline markdown`` (phase 180,
|
||||
markdown links / images / the deep heading ladder, TODO.md L3-L5) ->
|
||||
the fixed inline answer (``INLINE_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 carrying a link in a cell, and a plain closing line —
|
||||
byte-stable, so the story E2E can assert the rendered
|
||||
``<a class="md-link">`` / ``<img class="md-img">`` / ``<h5>`` /
|
||||
``<h6>`` shapes, the inert probes, the cell-level transform and the
|
||||
restore path's re-render. Checked BEFORE the ``DEFLECT_MODE`` branch
|
||||
(like ``TABLE_TRIGGER`` — the marker lives in the user message), so a
|
||||
marker question always gets the inline answer; the E2E asks it against
|
||||
an on-topic fixture (HIGH gate) and asserts non-deflection.
|
||||
- user message containing ``echo my history``
|
||||
(``HISTORY_TRIGGER``, phase 74, TODO L4 — chat history with prior
|
||||
thinking reaches the LLM) -> the deterministic HISTORY ECHO, derived
|
||||
@@ -1491,6 +1506,60 @@ QUOTE_ANSWER = (
|
||||
"And the quote ends here — a plain closing line."
|
||||
)
|
||||
|
||||
#: Phase 180 (markdown links / images / the deep heading ladder, TODO.md
|
||||
#: L3-L5): a user message containing this substring (case-insensitive)
|
||||
#: gets the fixed inline answer (``INLINE_ANSWER`` below) — the renderer's
|
||||
#: whole phase-180 contract in one byte-stable answer (see the module
|
||||
#: docstring). The two ``data:image/svg+xml;base64,`` payloads are tiny
|
||||
#: valid SVGs (no spaces, no parentheses — the URL capture is ``[^)\s]+``)
|
||||
#: so the CSP's existing ``img-src 'self' data:`` paints them. Existing
|
||||
#: E2E questions do not contain the phrase, so every other suite is
|
||||
#: unaffected.
|
||||
INLINE_TRIGGER = "show me inline markdown"
|
||||
|
||||
#: The fixed inline answer (phase 180) — byte-stable on purpose: the story
|
||||
#: E2E asserts the rendered ``<a class="md-link">`` shapes (the absolute
|
||||
#: link with its ``target``/``rel`` pair and its ``&`` kept verbatim,
|
||||
#: the relative / ``mailto:`` / ``#fragment`` links without them), the two
|
||||
#: inert probes (no live anchor, no live image, the reference still
|
||||
#: VISIBLE), the ``<img class="md-img">`` with its alt and lazy hint, the
|
||||
#: linked image wrapped with no stray bracket (locked A6), the ``<h5>`` /
|
||||
#: ``<h6>`` rungs (locked A4), the link inside the ``.md-table`` cell, and
|
||||
#: the plain closing line (the stream-completion marker — it lands last).
|
||||
INLINE_ANSWER = (
|
||||
"Here's the shape, inline:\n"
|
||||
"\n"
|
||||
"[the absolute link](https://example.com/a?x=1&y=2) opens the runbook.\n"
|
||||
"\n"
|
||||
"[the relative link](/sources) opens the catalog, "
|
||||
"[the mail link](mailto:reese@example.com) writes to the owner and "
|
||||
"[the anchor link](#homelab-inline-markdown-notes) stays on this page.\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"An unsafe reference stays plain text: [oops](javascript:alert(1)).\n"
|
||||
"\n"
|
||||
")\n"
|
||||
"\n"
|
||||
"["
|
||||
"](https://example.com/runbook)\n"
|
||||
"\n"
|
||||
"#### Deep header\n"
|
||||
"\n"
|
||||
"##### Deeper\n"
|
||||
"\n"
|
||||
"| Note | Link |\n"
|
||||
"|---|---|\n"
|
||||
"| topology | [cell link](https://example.com) |\n"
|
||||
"\n"
|
||||
"And the inline markdown ends here — a plain closing line."
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 67 (LLM retry, TODO.md L3): deterministic failure injection
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -4085,6 +4154,17 @@ def compose_answer(body: dict[str, Any]) -> str:
|
||||
# the marker lives in the user message, so a marker question
|
||||
# always gets the quote answer whatever the gate says.
|
||||
answer = QUOTE_ANSWER
|
||||
elif INLINE_TRIGGER in user.lower():
|
||||
# Markdown links, inline images and the deep heading ladder
|
||||
# (phase 180, TODO.md L3-L5): the story E2E's deterministic
|
||||
# inline answer — the allowlisted link/image shapes, the two
|
||||
# inert ``javascript:`` probes, the linked image (locked A6),
|
||||
# the ``####``/``#####`` rungs (locked A4) and the table-cell
|
||||
# link, in one byte-stable stream. Checked BEFORE the
|
||||
# DEFLECT_MODE branch, like TABLE_TRIGGER: the marker lives in
|
||||
# the user message, so a marker question always gets the inline
|
||||
# answer whatever the gate says.
|
||||
answer = INLINE_ANSWER
|
||||
elif HISTORY_TRIGGER in user.lower():
|
||||
# Phase 74 (TODO L4, chat history): the deterministic history
|
||||
# echo — proves on the wire that the client's prior turns (and
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
"""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}"
|
||||
@@ -0,0 +1,30 @@
|
||||
# Homelab Inline Markdown Notes
|
||||
|
||||
The homelab runbook keeps its links and its topology diagram inline in
|
||||
every markdown note, so the links and the image render in place.
|
||||
|
||||
The backup job publishes an [absolute link](https://example.com/a?x=1&y=2),
|
||||
a [relative link](/sources), a [mail link](mailto:reese@example.com) and
|
||||
an [anchor link](#homelab-inline-markdown-notes) in the same note.
|
||||
|
||||

|
||||
|
||||
An unsafe reference stays plain text in the note:
|
||||
[oops](javascript:alert(1)) for a link and
|
||||
) for an image.
|
||||
|
||||
The diagram also ships as a linked image, so clicking the topology image
|
||||
opens the runbook:
|
||||
[](https://example.com/runbook)
|
||||
|
||||
#### Deep header
|
||||
|
||||
The runbook keeps one deep header and one deeper header per section.
|
||||
|
||||
##### Deeper
|
||||
|
||||
| Note | Link |
|
||||
|---|---|
|
||||
| topology | [cell link](https://example.com) |
|
||||
|
||||
The end of the homelab inline markdown notes.
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user