phase: 138_markdown_blockquotes
**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:
@@ -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 ``>``-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 ``>`` 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 > 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*(?:>)+ ?`` (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*(?:>)+ ?"
|
||||
assert js.count(group_rule) >= 2, (
|
||||
"the group rule ^\\s*(?:>)+ ? 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*(?:>)+ ?/, "")' in js, (
|
||||
"the strip must remove the > 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 >
|
||||
(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 + "<script>alert(1)</script></blockquote>",
|
||||
),
|
||||
"fence": (
|
||||
"```\n> x\n```",
|
||||
[],
|
||||
["<blockquote"],
|
||||
"<p><pre><code>> x</code></pre></p>",
|
||||
),
|
||||
"cell": (
|
||||
"| a |\n|---|\n| > x |",
|
||||
["<td>> 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}"
|
||||
Reference in New Issue
Block a user