**Phase 138 final verification pass — all green, no defects found.** - Verified: blockquote pass in `frontend/assets/markdown.js` (step 2b, between escape+inline and paragraphs), guard extended to `|blockquote`, docstring updated; `.md-quote` CSS (left border + padding + `--ink-soft`, no fill, palette vars only, contrast comments, user-bubble override); unit pins + node behavior; dedicated E2E (chat / viewer / restore); mock `QUOTE_TRIGGER`/`QUOTE_ANSWER`; fixture `tests/fixtures/quote_docs/`. Thinking block confirmed on the same shared renderer (app.js L2025/L3095). - `uv run pytest tests/e2e/test_markdown_blockquotes.py -v --no-cov` → 3 passed (isolation) - `uv run pytest --cov=app --cov-report=term-missing` → 3268 passed, 9 skipped; TOTAL **99%** (>90%) - `uv run ruff check . && uv run pyright` → All checks passed; 0 errors - Criteria: quotes render in chat/viewer/restore ✅ (E2E) + thinking ✅ (shared renderer + unscoped AA CSS); `>>` collapses, fence/table `>` untouched ✅ (unit+e2e); non-quote byte-identical ✅ (exact fixture; phase-44/renderer suites pass **unedited**, confirmed by empty `git diff --stat`) - No deviations; commit + phase-dir move left to the harness per pipeline rules (working tree clean of stray changes — only phase-138 diffs present) - Next pending phase: **139_read_range_tool_lines**
272 lines
11 KiB
Python
272 lines
11 KiB
Python
"""Phase 138 E2E (Playwright): markdown blockquotes in the shared renderer.
|
|
|
|
Source: ``TODO.md`` L3 — "Markdown formatted ">" quotes aren't rendering"
|
|
Run in isolation (DB must be up: ``podman compose up -d db``):
|
|
|
|
uv run pytest tests/e2e/test_markdown_blockquotes.py -v --no-cov
|
|
|
|
Seeding reuses the real importer against the phase-138 fixture
|
|
directory ``tests/fixtures/quote_docs/`` with the deterministic mock
|
|
embeddings. A dedicated tree (like phase 68's ``search_docs``) — the
|
|
shared 13-doc fixture set's import count is pinned by many other
|
|
suites and must not move. The mock's ``QUOTE_TRIGGER``
|
|
(``show me the blockquote``, phase 138 task 03) returns the byte-stable
|
|
quote answer: a single-line quote, a 3-line group, a nested ``>>``
|
|
collapse, an ``<script>`` XSS probe, a ``>`` inside a fence, a ``>``
|
|
in a table cell, and a plain closing line. The fixture
|
|
``homelab/quotes.md`` (a 2-line quote block, a re-quoted line after a
|
|
blank line, and a ``>`` line inside a fence) 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_quote_renders`` — the brain bubble carries FOUR
|
|
``blockquote.md-quote`` (single line, 3-line group with <br>
|
|
joins, nested collapse, inert XSS line) with no literal ``>`` at
|
|
any block's start; the fenced ``>`` stays code and the table-cell
|
|
``>`` stays cell text (fences/tables keep their precedence); the
|
|
turn is NOT deflected (the honesty-gate interplay is part of the
|
|
contract).
|
|
2. ``test_viewer_quote_renders`` — the fixture's quote block opens
|
|
from the Sources table (admin) in the modal and renders
|
|
``blockquote.md-quote`` (shared renderer); the fenced ``>`` line
|
|
stays ``<pre><code>``.
|
|
3. ``test_restore_keeps_blockquote`` — a reload restores the
|
|
conversation (the localStorage restore path re-runs the shared
|
|
renderer, app.js ``addMessage``) and the restored brain bubble
|
|
keeps every ``blockquote.md-quote``.
|
|
"""
|
|
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" / "quote_docs"
|
|
|
|
#: Carries the mock's ``QUOTE_TRIGGER`` ("show me the blockquote") and
|
|
#: is on-topic (the fixture doc answers it — token-overlap cosine
|
|
#: ≈0.5 against the mock embeddings, so the turn is HIGH and the
|
|
#: suite can assert non-deflection).
|
|
QUESTION = "Show me the blockquote from my homelab backup notes?"
|
|
QUOTE_PATH = "homelab/quotes.md"
|
|
QUOTE_TITLE = "Homelab Backup Blockquote Notes"
|
|
#: The byte-stable answer's plain closing line (the stream-completion
|
|
#: marker — it lands last).
|
|
ANSWER_END = "a plain closing line"
|
|
#: The four rendered blockquote texts, in order (the XSS line renders
|
|
#: as its escaped tag TEXT — visible, inert).
|
|
QUOTED_TEXTS = [
|
|
"a short single-line quote",
|
|
"the first line\nthe second line\nthe third line",
|
|
"nested quotes collapse to one level",
|
|
"<script>alert(1)</script>",
|
|
]
|
|
FENCE_LINE = "> inside a fence stays fence content"
|
|
CELL_TEXT = "> x stays a cell"
|
|
|
|
|
|
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-138 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_quote_answer(page: Page, app_url: str) -> Any:
|
|
"""Drive the trigger question and return the brain bubble once the
|
|
whole byte-stable quote 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 quote contract (honesty gate
|
|
# interplay, like phase 44's table answer).
|
|
expect(page.locator(".msg.brain.is-deflected")).to_have_count(0)
|
|
return bubble
|
|
|
|
|
|
def _assert_blockquotes(bubble: Any, texts: list[str]) -> None:
|
|
"""The shared assertion: exactly len(texts) blockquote.md-quote
|
|
elements with those innerTexts, in order, and no literal quote
|
|
marker (the escaped >) at any block's start — the bug from
|
|
TODO.md L3."""
|
|
blocks = bubble.locator("blockquote.md-quote")
|
|
expect(blocks).to_have_count(len(texts))
|
|
for i, quoted in enumerate(texts):
|
|
inner = blocks.nth(i).inner_text()
|
|
assert inner == quoted, f"block {i}: {inner!r} != {quoted!r}"
|
|
assert not inner.startswith(">"), (
|
|
f"block {i} still starts with a literal quote marker: {inner!r}"
|
|
)
|
|
# no nested blockquote (locked A1: >> collapses to one level)
|
|
expect(bubble.locator("blockquote.md-quote blockquote")).to_have_count(0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Chat: the "> " lines render as semantic blockquotes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_chat_quote_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_quote_answer(page, app_url)
|
|
|
|
# The four quote shapes of the byte-stable answer — the single
|
|
# line, the 3-line group (<br> joins), the nested collapse, and the
|
|
# inert XSS line (its escaped tag is VISIBLE text).
|
|
_assert_blockquotes(bubble, QUOTED_TEXTS)
|
|
|
|
# The XSS probe stayed inert: no live <script> anywhere in the
|
|
# bubble, and the escaped form is what the DOM carries.
|
|
expect(bubble.locator("script")).to_have_count(0)
|
|
xss_html = bubble.locator("blockquote.md-quote").nth(3).inner_html()
|
|
assert "<script>alert(1)</script>" in xss_html, (
|
|
"the XSS line must carry the escaped form in the DOM"
|
|
)
|
|
|
|
# Fences keep their precedence: the > line inside the fence stays
|
|
# <pre><code> content (raw text), never a blockquote (the count
|
|
# above already excludes it).
|
|
pre = bubble.locator("pre code")
|
|
expect(pre).to_have_count(1)
|
|
assert FENCE_LINE in pre.inner_text(), "the fenced > line must stay raw fence content"
|
|
|
|
# Tables keep their precedence: the > in the cell is cell text.
|
|
table = bubble.locator("table.md-table")
|
|
expect(table).to_have_count(1)
|
|
expect(table.locator("td")).to_have_count(1)
|
|
assert table.locator("td").inner_text() == CELL_TEXT
|
|
expect(table.locator("blockquote")).to_have_count(0)
|
|
|
|
# Grounded (the answer is the mock's non-deflected quote branch)
|
|
# and ZERO citation chips: the turn read nothing (phase 119,
|
|
# LOCKED A1).
|
|
expect(page.locator(".msg.brain .source-chip")).to_have_count(0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Shared renderer: the viewer/modal renders the fixture's quotes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_viewer_quote_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 quotes.md row → same-page document modal.
|
|
# Phase 97: the catalog is the drill-down tree — the row lives at
|
|
# its folder level (``quote_docs`` → ``homelab``).
|
|
login(page, app_url) # phase 16: the Sources catalog is admin-only
|
|
for name in ("quote_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=QUOTE_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(QUOTE_TITLE)
|
|
|
|
# The same <blockquote class="md-quote"> shape the chat bubble
|
|
# gets — the shared renderer serves the viewer too: the 2-line
|
|
# block (<br> joins) and the re-quoted single line after the blank
|
|
# line (the group-termination rule, live in a real document).
|
|
content = page.locator("#doc-modal-content")
|
|
_assert_blockquotes(
|
|
content,
|
|
[
|
|
"Backups run nightly at 02:00 and the restore window is 30 minutes.\n"
|
|
"The offsite copy is the only copy that counts.",
|
|
"Short blockquotes stay on one line.",
|
|
],
|
|
)
|
|
|
|
# The fixture's fenced > line is code, never a blockquote (the
|
|
# count above already excludes it).
|
|
pre = content.locator("pre code")
|
|
expect(pre).to_have_count(1)
|
|
assert "the shell prompt is not a markdown quote" in pre.inner_text()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Restore: a reload re-renders the saved conversation through the
|
|
# same shared renderer and keeps the blockquotes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_restore_keeps_blockquote(
|
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
|
) -> None:
|
|
_reset_db(mock_llm)
|
|
page.set_default_timeout(30_000)
|
|
_ask_quote_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 the blockquotes 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 every blockquote (same texts, same
|
|
# order — the restore runs the SAME renderer).
|
|
_assert_blockquotes(bubble, QUOTED_TEXTS)
|
|
expect(bubble.locator("script")).to_have_count(0)
|
|
expect(bubble.locator("pre code")).to_have_count(1)
|
|
expect(bubble.locator("table.md-table")).to_have_count(1)
|