"""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