phase: 138_markdown_blockquotes
Build and Push Containers / build-and-push-app (push) Successful in 2m36s
Build and Push Containers / build-and-push-db (push) Successful in 11s

**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**
This commit is contained in:
2026-09-30 10:49:40 -04:00
parent 0db6ab5df0
commit 1a657ce4ca
22 changed files with 1230 additions and 1 deletions
+60
View File
@@ -382,6 +382,19 @@ Implements just enough of the aipi surface:
``SUMMARY_MODE``), so a marker question always gets the table
answer; the E2E asks it against an on-topic fixture (HIGH gate) and
asserts non-deflection.
- user message containing ``show me the blockquote`` (phase 138,
markdown blockquotes, TODO.md L3) -> the fixed quote answer
(``QUOTE_ANSWER``): a single-line quote, a 3-line quote group, a
nested ``>>`` collapse line, an ``<script>`` XSS probe quote, a
``>`` inside a code fence, a ``>`` in a table cell, and a plain
closing line — byte-stable, so the story E2E can assert the four
rendered ``<blockquote class="md-quote">`` shapes, the inert XSS
line, the untouched fence and table-cell ``>``, and the restore
path's byte-identical re-render. Checked BEFORE the
``DEFLECT_MODE`` branch (a deflection prompt never carries the
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 ``echo my history``
(``HISTORY_TRIGGER``, phase 74, TODO L4 — chat history with prior
thinking reaches the LLM) -> the deterministic HISTORY ECHO, derived
@@ -955,6 +968,43 @@ TABLE_ANSWER = (
"| value-one | value-two | value-three | value-four | value-five |"
)
#: Phase 138 (markdown blockquotes, TODO.md L3): a user message
#: containing this substring (case-insensitive) gets the fixed quote
#: answer (``QUOTE_ANSWER`` below) — the renderer's whole blockquote
#: contract in one byte-stable answer (see the module docstring).
#: Existing E2E questions do not contain the phrase, so every other
#: suite is unaffected.
QUOTE_TRIGGER = "show me the blockquote"
#: The fixed quote answer (phase 138) — byte-stable on purpose: the
#: story E2E asserts the four rendered <blockquote class="md-quote">
#: shapes (single line, 3-line group, nested collapse, XSS probe),
#: the untouched fence line and table cell, and the plain closing
#: line (the stream-completion marker — it lands last).
QUOTE_ANSWER = (
"Here's the shape, in quotes:\n"
"\n"
"> a short single-line quote\n"
"\n"
"> the first line\n"
"> the second line\n"
"> the third line\n"
"\n"
">> nested quotes collapse to one level\n"
"\n"
"> <script>alert(1)</script>\n"
"\n"
"```\n"
"> inside a fence stays fence content\n"
"```\n"
"\n"
"| Cell |\n"
"|---|\n"
"| > x stays a cell |\n"
"\n"
"And the quote ends here — a plain closing line."
)
# ---------------------------------------------------------------------------
# Phase 67 (LLM retry, TODO.md L3): deterministic failure injection
# ---------------------------------------------------------------------------
@@ -2720,6 +2770,16 @@ def compose_answer(body: dict[str, Any]) -> str:
# against an on-topic fixture, where the gate is HIGH, and
# asserts non-deflection as part of the table test.
answer = TABLE_ANSWER
elif QUOTE_TRIGGER in user.lower():
# Markdown blockquotes (phase 138, TODO.md L3): the story E2E's
# deterministic quote answer — the renderer's blockquote
# contract in one byte-stable stream (single-line quote,
# 3-line group, nested collapse, the <script> XSS probe, the
# fence-protected >, the table-cell >, the plain close).
# Checked BEFORE the DEFLECT_MODE branch, like TABLE_TRIGGER:
# the marker lives in the user message, so a marker question
# always gets the quote answer whatever the gate says.
answer = QUOTE_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
+271
View File
@@ -0,0 +1,271 @@
"""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 &gt;) 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 "&lt;script&gt;alert(1)&lt;/script&gt;" 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)
+17
View File
@@ -0,0 +1,17 @@
# Homelab Backup Blockquote Notes
The homelab backup notes keep their most important lines as markdown
blockquotes, so the backup runbook quotes them verbatim.
> Backups run nightly at 02:00 and the restore window is 30 minutes.
> The offsite copy is the only copy that counts.
A style note for the backup team:
> Short blockquotes stay on one line.
```bash
> the shell prompt is not a markdown quote
```
The end of the backup blockquote notes.
+271
View File
@@ -0,0 +1,271 @@
"""Unit: the shared renderer's markdown blockquote pass (phase 138, task 01).
Browser behavior is E2E-covered by ``tests/e2e/test_markdown_blockquotes.py``
(task 03); here we pin the source-level contract — the pass ordering
(fences → tables → escape+inline → blockquotes → paragraphs → restore),
the group rule (maximal run of ``&gt;``-prefixed lines; blank line
terminates; nested collapses — locked A1), the exact output marker, the
extended paragraph guard, and the ``.md-quote`` CSS rule (palette vars
only — task 02's pin) — so a silent regression is caught without a
browser (the established frontend pattern). A small node-driven
behavioral check (skipped when node is unavailable, like the phase-44
table check) proves the story acceptance shapes: single-line quote,
multi-line group, nested collapse, XSS quote inert, fence-wins,
table-cell untouched, blank-line termination, and non-quote input
byte-identical to the pre-phase renderer (fixture string checked into
the test, per the phase-44 shape).
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ASSETS = FRONTEND / "assets"
MARKDOWN_JS = ASSETS / "markdown.js"
STYLES_CSS = ASSETS / "styles.css"
HAVE_NODE = shutil.which("node") is not None
MARKER = '<blockquote class="md-quote">'
#: The non-quote input (a paragraph + list + a pipe table) and its
#: byte-EXACT pre-phase renderer output (the fixture string checked
#: into the test, per the phase-44 shape) — the byte-identity contract
#: (phase 138 design): anything without a quote line renders exactly
#: as it did before the blockquote pass existed. (The blank line before
#: the list is absorbed by the pre-existing ``^\\s*[-*] `` list rule —
#: the fixture pins that too.)
NO_QUOTE_INPUT = (
"A plain paragraph about the homelab.\n"
"\n"
"- item one\n"
"- item two\n"
"\n"
"| Col A | Col B |\n"
"|---|---|\n"
"| 1 | 2 |"
)
NO_QUOTE_PRE_PHASE_OUTPUT = (
"<p>A plain paragraph about the homelab.<br>"
"<ul><li>item one</li><br><li>item two</li></ul></p>"
'<p><div class="md-table-wrap">\n'
'<table class="md-table">\n'
'<thead><tr><th scope="col">Col A</th><th scope="col">Col B</th></tr></thead>\n'
"<tbody><tr><td>1</td><td>2</td></tr></tbody>\n"
"</table>\n</div></p>"
)
def _text(path: Path) -> str:
assert path.is_file(), f"missing frontend file: {path}"
return path.read_text(encoding="utf-8")
def _run_node(script: str) -> str:
proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60)
assert proc.returncode == 0, f"node failed: {proc.stderr}"
return proc.stdout
# ---------- markdown.js: the blockquote pass (step 2b) ----------
def test_blockquote_pass_runs_after_inline_and_before_paragraphs() -> None:
"""The pass order is fences → tables → escape+inline → blockquotes
→ paragraphs → restore (phase 138 design). The blockquote pass sits
between step 2 (``inline(escapeHtml(text))`` — after it, a quote
line starts with the escaped ``&gt;`` marker) and step 3 (the
paragraph split), so inline code/strong/em already work inside
quotes for free and the paragraph guard leaves the block alone."""
js = _text(MARKDOWN_JS)
inline_step = "text = inline(escapeHtml(text));"
quote_pass = 'const quoteLines = text.split("\\n");'
para_split = "split(/\\n{2,}/)"
assert inline_step in js, "step 2 still escapes+inlines the whole text"
assert quote_pass in js, "the blockquote pass splits the text into lines"
assert js.find(inline_step) < js.find(quote_pass), (
"the blockquote pass must run AFTER the escape+inline step "
"(a quote line only starts with &gt; once escaped)"
)
assert js.find(quote_pass) < js.find(para_split), (
"the blockquote pass must run BEFORE the paragraph split"
)
def test_paragraph_guard_includes_blockquote() -> None:
"""The step-3 block-element guard extends to
``/^<(h\\d|ul|ol|pre|li|blockquote)/`` so the paragraph pass leaves
the emitted <blockquote> alone (no <p> wrap) — the pre-phase guard
(without blockquote) must be gone."""
js = _text(MARKDOWN_JS)
assert r"/^<(h\d|ul|ol|pre|li|blockquote)/" in js, (
"the step-3 guard must include blockquote"
)
assert r"/^<(h\d|ul|ol|pre|li)/" not in js, (
"the pre-phase guard (without blockquote) must be replaced"
)
def test_blockquote_marker_and_group_rule() -> None:
"""The exact output marker string and the group rule: the maximal
run of consecutive lines matching ``^\\s*(?:&gt;)+ ?`` (leading
whitespace allowed, the nested ``>>``/``>>>`` run allowed, ONE
optional space after the run) is ONE
``<blockquote class="md-quote">`` — the marker run + one optional
space is stripped from each member, members join with ``<br>``
(the paragraph convention)."""
js = _text(MARKDOWN_JS)
assert MARKER in js, "the exact marker string must be emitted"
group_rule = r"^\s*(?:&gt;)+ ?"
assert js.count(group_rule) >= 2, (
"the group rule ^\\s*(?:&gt;)+ ? must both TEST and STRIP "
"(one occurrence in the test, one in the strip replace)"
)
assert re.search(r'members\.join\("<br>"\)', js), (
"group members must be joined with <br>"
)
# the strip replaces the leading run (+ one optional space) — the
# nested >> collapses to one level (locked A1)
assert r'replace(/^\s*(?:&gt;)+ ?/, "")' in js, (
"the strip must remove the &gt; run plus ONE optional space"
)
def test_md_quote_css_references_palette_vars_only() -> None:
"""Task 02's pin: the ``.md-quote`` rule (and the user-bubble
override, if present) reference :root palette vars ONLY — no
hex/rgb/hsl color literal (the :root-drift unit test parses the
built-ins from this file, so a literal here would be a second
source of truth), and the rule block carries a >=4.5:1 contrast
note per the house comment convention."""
css = _text(STYLES_CSS)
root = re.search(r":root\s*\{", css)
assert root, "styles.css must define :root"
root_body = css[root.end() : css.find("}", root.end())]
palette = set(re.findall(r"(--[a-z0-9-]+)\s*:", root_body))
assert palette, "the :root palette must declare variables"
rule_bodies = re.findall(r"(?<![-\w])\.md-quote\s*\{([^}]*)\}", css)
assert rule_bodies, "styles.css must define .md-quote"
for body in rule_bodies:
refs = re.findall(r"var\((--[a-z0-9-]+)\)", body)
assert refs, ".md-quote must reference palette vars"
for ref in refs:
assert ref in palette, f"{ref} is not a :root palette var"
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", body), (
"no hex color literal in .md-quote"
)
assert "rgb(" not in body and "hsl(" not in body, (
"no rgb()/hsl() color literal in .md-quote"
)
# the contrast note (house convention, cf. --ink-soft's
# "8.2:1 on --surface"): the comment block immediately above the
# rule carries a ratio
rule_pos = css.find(".md-quote {")
assert rule_pos != -1
comment_start = css.rfind("/*", 0, rule_pos)
comment_block = css[comment_start:rule_pos]
assert re.search(r"\d+(?:\.\d+)?\s*:\s*1", comment_block), (
"the .md-quote rule must carry a contrast-ratio note"
)
# ---------- behavior (node): the story's acceptance shapes ----------
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_blockquote_renders_semantic_xss_safe_and_precedence_holds() -> None:
"""The story shapes executed under node: (1) a single-line quote
renders ONE <blockquote class="md-quote"> with no literal &gt;
(the bug, TODO.md L3); (2) a 3-line group is ONE blockquote with
<br> joins; (3) nested >> / >>> collapses to one level (locked A1);
(4) a blank line terminates the group (two blockquotes); (5) an
<script> in a quote is escaped — inert text, no live tag; (6) a >
inside a code fence stays fence content (step 1 protects first);
(7) a > in a table cell stays cell text (step 1b protects, step 4
restores after the pass); (8) a quote line still carries inline
code/strong/em (the inline chain ran first); (9) non-quote input
(paragraph + list + table) renders byte-identical to the
pre-phase renderer (the checked-in fixture string)."""
js = _text(MARKDOWN_JS)
cases: dict[str, tuple[str, list[str], list[str], str | None]] = {
# name: (input, must_contain, must_not, exact_output_or_None)
"single": (
"> quoted",
[],
[],
MARKER + "quoted</blockquote>",
),
"group": (
"> a\n> b\n> c",
[],
[],
MARKER + "a<br>b<br>c</blockquote>",
),
"nested": (
">> nested\n>>> deeper",
[],
["<blockquote class=\"md-quote\"><blockquote"],
MARKER + "nested<br>deeper</blockquote>",
),
"blank_terminates": (
"> one\n\n> two",
[],
[],
MARKER + "one</blockquote>" + MARKER + "two</blockquote>",
),
"xss": (
"> <script>alert(1)</script>",
[],
["<script>"],
MARKER + "&lt;script&gt;alert(1)&lt;/script&gt;</blockquote>",
),
"fence": (
"```\n> x\n```",
[],
["<blockquote"],
"<p><pre><code>&gt; x</code></pre></p>",
),
"cell": (
"| a |\n|---|\n| > x |",
["<td>&gt; x</td>"],
["<blockquote"],
None,
),
"inline": (
"> **bold** and `code`",
[],
[],
MARKER + "<strong>bold</strong> and <code>code</code></blockquote>",
),
"noquote": (
NO_QUOTE_INPUT,
[],
["<blockquote"],
NO_QUOTE_PRE_PHASE_OUTPUT,
),
}
driver = (
js
+ "\nconst out = {}; "
+ "".join(
f"out.{name} = renderMarkdown({value!r}); " for name, (value, _, _, _) in cases.items()
)
+ "console.log(JSON.stringify(out));"
)
results = json.loads(_run_node(driver))
for name, (_, must_contain, must_not, exact) in cases.items():
html = results[name]
if exact is not None:
assert html == exact, f"{name}: {html!r} != pre-phase/contract shape {exact!r}"
for marker in must_contain:
assert marker in html, f"{name}: missing {marker!r} in {html!r}"
for marker in must_not:
assert marker not in html, f"{name}: unexpected {marker!r} in {html!r}"