"""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 ``",
]
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 (
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