phase: 180_markdown_inline_links_images
Build and Push Containers / build-and-push-app (push) Successful in 1m51s
Build and Push Containers / build-and-push-db (push) Successful in 15s

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`.
This commit is contained in:
2026-10-14 16:03:36 -04:00
parent 3d82a72b6f
commit 6880bd0866
39 changed files with 2952 additions and 1 deletions
+20
View File
@@ -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")
+80
View File
@@ -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 ``&amp;`` 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"
"![topology](data:image/svg+xml;base64,"
"PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0OCAzMiI+"
"PHJlY3Qgd2lkdGg9IjQ4IiBoZWlnaHQ9IjMyIiBmaWxsPSIjMzg2YjdhIi8+"
"PGNpcmNsZSBjeD0iMjQiY3k9IjE2IiByPSI4IiBmaWxsPSIjZTVlN2ViIi8+PC9zdmc+)\n"
"\n"
"An unsafe reference stays plain text: [oops](javascript:alert(1)).\n"
"\n"
"![bad](javascript:alert(1))\n"
"\n"
"[![linked topology](data:image/svg+xml;base64,"
"PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+"
"PHJlY3Qgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiBmaWxsPSIjN2EzODY4Ii8+PC9zdmc+)"
"](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 ``&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}"
+30
View File
@@ -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.
![topology](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0OCAzMiI+PHJlY3Qgd2lkdGg9IjQ4IiBoZWlnaHQ9IjMyIiBmaWxsPSIjMzg2YjdhIi8+PGNpcmNsZSBjeD0iMjQiIGN5PSIxNiIgcj0iOCIgZmlsbD0iI2U1ZTdlYiIvPjwvc3ZnPg==)
An unsafe reference stays plain text in the note:
[oops](javascript:alert(1)) for a link and
![bad](javascript:alert(1)) for an image.
The diagram also ships as a linked image, so clicking the topology image
opens the runbook:
[![linked topology](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHJlY3Qgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiBmaWxsPSIjN2EzODY4Ii8+PC9zdmc+)](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