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,9 @@
|
|||||||
|
**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**
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
........................................................................ [ 2%]
|
||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 6%]
|
||||||
|
........................................................................ [ 8%]
|
||||||
|
........................................................................ [ 10%]
|
||||||
|
........................................................................ [ 13%]
|
||||||
|
........................................................................ [ 15%]
|
||||||
|
........................................................................ [ 17%]
|
||||||
|
........................................................................ [ 19%]
|
||||||
|
........................................................................ [ 21%]
|
||||||
|
........................................................................ [ 24%]
|
||||||
|
........................................................................ [ 26%]
|
||||||
|
........................................................................ [ 28%]
|
||||||
|
........................................................................ [ 30%]
|
||||||
|
........................................................................ [ 32%]
|
||||||
|
........................................................................ [ 35%]
|
||||||
|
........................................................................ [ 37%]
|
||||||
|
........................................................................ [ 39%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 43%]
|
||||||
|
........................................................................ [ 46%]
|
||||||
|
........................................................................ [ 48%]
|
||||||
|
........................................................................ [ 50%]
|
||||||
|
........................................................................ [ 52%]
|
||||||
|
...........................................................s.....sss...s [ 54%]
|
||||||
|
s....................................................................... [ 57%]
|
||||||
|
........................................................................ [ 59%]
|
||||||
|
........................................................................ [ 61%]
|
||||||
|
........................................................................ [ 63%]
|
||||||
|
........................................................................ [ 65%]
|
||||||
|
........................................................................ [ 68%]
|
||||||
|
........................................................................ [ 70%]
|
||||||
|
........................................................................ [ 72%]
|
||||||
|
........................................................................ [ 74%]
|
||||||
|
........................................................................ [ 76%]
|
||||||
|
........................................................................ [ 79%]
|
||||||
|
........................................................................ [ 81%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 85%]
|
||||||
|
........................................................................ [ 87%]
|
||||||
|
........................................................................ [ 90%]
|
||||||
|
........................................................................ [ 92%]
|
||||||
|
........................................................................ [ 94%]
|
||||||
|
....................................................................sss. [ 96%]
|
||||||
|
........................................................................ [ 98%]
|
||||||
|
..................................... [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/brain-of-reese/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.14.7-final-0 ________________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover
|
||||||
|
--------------------------------------------------
|
||||||
|
app/__init__.py 1 0 100%
|
||||||
|
app/api/__init__.py 0 0 100%
|
||||||
|
app/api/auth.py 52 0 100%
|
||||||
|
app/api/chat.py 268 1 99%
|
||||||
|
app/api/chat_images.py 50 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 13 0 100%
|
||||||
|
app/api/doc_drafts.py 99 0 100%
|
||||||
|
app/api/docs.py 179 1 99%
|
||||||
|
app/api/git_sources.py 259 0 100%
|
||||||
|
app/api/health.py 10 0 100%
|
||||||
|
app/api/steering.py 42 0 100%
|
||||||
|
app/api/suggestions.py 33 0 100%
|
||||||
|
app/api/sync.py 183 0 100%
|
||||||
|
app/api/tokens.py 40 0 100%
|
||||||
|
app/api/ui_settings.py 55 0 100%
|
||||||
|
app/config.py 256 0 100%
|
||||||
|
app/core/__init__.py 0 0 100%
|
||||||
|
app/core/auth.py 45 0 100%
|
||||||
|
app/core/caching.py 124 0 100%
|
||||||
|
app/core/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/errors.py 5 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/core/rate_limit.py 44 0 100%
|
||||||
|
app/core/security_headers.py 20 0 100%
|
||||||
|
app/core/theming.py 38 0 100%
|
||||||
|
app/core/tokens.py 44 0 100%
|
||||||
|
app/db.py 22 0 100%
|
||||||
|
app/main.py 68 0 100%
|
||||||
|
app/models.py 133 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 412 1 99%
|
||||||
|
app/rag/archive_upload.py 134 0 100%
|
||||||
|
app/rag/chunker.py 220 4 98%
|
||||||
|
app/rag/doc_dates.py 18 0 100%
|
||||||
|
app/rag/folder_summaries.py 134 0 100%
|
||||||
|
app/rag/git_sources.py 56 0 100%
|
||||||
|
app/rag/importer.py 671 9 99%
|
||||||
|
app/rag/llm.py 246 1 99%
|
||||||
|
app/rag/md_images.py 63 0 100%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/pdf.py 119 0 100%
|
||||||
|
app/rag/prompts.py 154 0 100%
|
||||||
|
app/rag/retriever.py 248 3 99%
|
||||||
|
app/rag/scaffolding.py 55 0 100%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 49 0 100%
|
||||||
|
app/schemas.py 422 0 100%
|
||||||
|
--------------------------------------------------
|
||||||
|
TOTAL 5430 22 99%
|
||||||
|
coverage gate: app/ 99% (>90%) OK
|
||||||
|
All checks passed!
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||||
|
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||||
|
|
||||||
|
validation OK
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
Task complete. All completion criteria verified.
|
||||||
|
|
||||||
|
**Report — phase 138, task 01 (blockquote pass)**
|
||||||
|
|
||||||
|
- `frontend/assets/markdown.js`: new step-2b pass between `inline(escapeHtml(text))` and the paragraph pass — maximal run of consecutive `^\s*(?:>)+ ?` lines → one `<blockquote class="md-quote">`, marker run + one optional space stripped per member, members joined with `<br>`, blank line terminates the group
|
||||||
|
- Step-3 paragraph guard extended to `/^<(h\d|ul|ol|pre|li|blockquote)/`; module docstring + step-2b comment document pass order (fences → tables → escape+inline → blockquotes → paragraphs → restore), the group rule, and the in-quote structure limit
|
||||||
|
- Node repro: `renderMarkdown("> a\n> b")` → `<blockquote class="md-quote">a<br>b</blockquote>` (exact); `> quoted` no longer emits literal `>`
|
||||||
|
- Byte-identity: 14-case non-quote corpus (paragraph, lists, headings, table, fence, XSS, blanks) renders byte-identical to pre-task baseline; fences/tables keep precedence (verified under node)
|
||||||
|
- `uv run pytest` — exit 0, no failures (unit + integration, renderer-dependent suites unedited)
|
||||||
|
- `uv run pytest --cov=app --cov-report=term-missing` — TOTAL **99%** (>90% gate)
|
||||||
|
- `uv run ruff check .` — All checks passed; `uv run pyright` — 0 errors, 0 warnings
|
||||||
|
- Notable: the task's regex shorthand `>+ ?` binds `+` to the semicolon and would not collapse nested `>>` (breaks locked A1) — implemented as `(?:>)+` to honor the intent; nested collapse, blank-termination, XSS-inert, and inline-in-quote all verified under node
|
||||||
|
- No test files added (unit pins live in task 03's `tests/unit/test_markdown_blockquotes.py` per the task); only `markdown.js` modified, left uncommitted
|
||||||
|
- Next pending task: `02_blockquote_css.md`
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
........................................................................ [ 2%]
|
||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 6%]
|
||||||
|
........................................................................ [ 8%]
|
||||||
|
........................................................................ [ 11%]
|
||||||
|
........................................................................ [ 13%]
|
||||||
|
........................................................................ [ 15%]
|
||||||
|
........................................................................ [ 17%]
|
||||||
|
........................................................................ [ 19%]
|
||||||
|
........................................................................ [ 22%]
|
||||||
|
........................................................................ [ 24%]
|
||||||
|
........................................................................ [ 26%]
|
||||||
|
........................................................................ [ 28%]
|
||||||
|
........................................................................ [ 30%]
|
||||||
|
........................................................................ [ 33%]
|
||||||
|
........................................................................ [ 35%]
|
||||||
|
........................................................................ [ 37%]
|
||||||
|
........................................................................ [ 39%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 44%]
|
||||||
|
........................................................................ [ 46%]
|
||||||
|
........................................................................ [ 48%]
|
||||||
|
........................................................................ [ 50%]
|
||||||
|
........................................................................ [ 52%]
|
||||||
|
......................................................s.....sss...ss.... [ 55%]
|
||||||
|
........................................................................ [ 57%]
|
||||||
|
........................................................................ [ 59%]
|
||||||
|
........................................................................ [ 61%]
|
||||||
|
........................................................................ [ 63%]
|
||||||
|
........................................................................ [ 66%]
|
||||||
|
........................................................................ [ 68%]
|
||||||
|
........................................................................ [ 70%]
|
||||||
|
........................................................................ [ 72%]
|
||||||
|
........................................................................ [ 74%]
|
||||||
|
........................................................................ [ 77%]
|
||||||
|
........................................................................ [ 79%]
|
||||||
|
........................................................................ [ 81%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 85%]
|
||||||
|
........................................................................ [ 88%]
|
||||||
|
........................................................................ [ 90%]
|
||||||
|
........................................................................ [ 92%]
|
||||||
|
........................................................................ [ 94%]
|
||||||
|
...............................................................sss...... [ 96%]
|
||||||
|
........................................................................ [ 99%]
|
||||||
|
................................ [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/brain-of-reese/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.14.7-final-0 ________________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover
|
||||||
|
--------------------------------------------------
|
||||||
|
app/__init__.py 1 0 100%
|
||||||
|
app/api/__init__.py 0 0 100%
|
||||||
|
app/api/auth.py 52 0 100%
|
||||||
|
app/api/chat.py 268 1 99%
|
||||||
|
app/api/chat_images.py 50 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 13 0 100%
|
||||||
|
app/api/doc_drafts.py 99 0 100%
|
||||||
|
app/api/docs.py 179 1 99%
|
||||||
|
app/api/git_sources.py 259 0 100%
|
||||||
|
app/api/health.py 10 0 100%
|
||||||
|
app/api/steering.py 42 0 100%
|
||||||
|
app/api/suggestions.py 33 0 100%
|
||||||
|
app/api/sync.py 183 0 100%
|
||||||
|
app/api/tokens.py 40 0 100%
|
||||||
|
app/api/ui_settings.py 55 0 100%
|
||||||
|
app/config.py 256 0 100%
|
||||||
|
app/core/__init__.py 0 0 100%
|
||||||
|
app/core/auth.py 45 0 100%
|
||||||
|
app/core/caching.py 124 0 100%
|
||||||
|
app/core/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/errors.py 5 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/core/rate_limit.py 44 0 100%
|
||||||
|
app/core/security_headers.py 20 0 100%
|
||||||
|
app/core/theming.py 38 0 100%
|
||||||
|
app/core/tokens.py 44 0 100%
|
||||||
|
app/db.py 22 0 100%
|
||||||
|
app/main.py 68 0 100%
|
||||||
|
app/models.py 133 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 412 1 99%
|
||||||
|
app/rag/archive_upload.py 134 0 100%
|
||||||
|
app/rag/chunker.py 220 4 98%
|
||||||
|
app/rag/doc_dates.py 18 0 100%
|
||||||
|
app/rag/folder_summaries.py 134 0 100%
|
||||||
|
app/rag/git_sources.py 56 0 100%
|
||||||
|
app/rag/importer.py 671 9 99%
|
||||||
|
app/rag/llm.py 246 1 99%
|
||||||
|
app/rag/md_images.py 63 0 100%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/pdf.py 119 0 100%
|
||||||
|
app/rag/prompts.py 154 0 100%
|
||||||
|
app/rag/retriever.py 248 3 99%
|
||||||
|
app/rag/scaffolding.py 55 0 100%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 49 0 100%
|
||||||
|
app/schemas.py 422 0 100%
|
||||||
|
--------------------------------------------------
|
||||||
|
TOTAL 5430 22 99%
|
||||||
|
coverage gate: app/ 99% (>90%) OK
|
||||||
|
All checks passed!
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||||
|
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||||
|
|
||||||
|
validation OK
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
All green. Task 02 complete.
|
||||||
|
|
||||||
|
## Report — Task 02: `.md-quote` styling (theme-safe, WCAG AA)
|
||||||
|
|
||||||
|
**Implemented**
|
||||||
|
- `.md-quote` rule in `frontend/assets/styles.css` at the phase-44 `.md-table` anchor: `border-left: 3px solid var(--brand-ink)` (9.9:1 on `--surface`), `padding: 0.25rem 0.75rem`, `margin: 0.5rem 0`, `color: var(--ink-soft)` with the 8.2:1-on-`--surface` contrast comment; no background fill (A2), palette vars only, no hex outside `:root` (verified programmatically)
|
||||||
|
- Scoped override `.msg.user .bubble .md-quote { color: var(--bg) }` (5.2:1 on `--brand`) — user questions run through `renderMarkdown` (app.js:2002) on the `--brand` fill where `--ink-soft` is 1.6:1 (AA failure)
|
||||||
|
|
||||||
|
**Verification (live browser, real `styles.css` + `markdown.js`)**
|
||||||
|
- Computed contrast in all four contexts: brain bubble 8.22:1, user bubble 5.35:1, thinking block 8.22:1, doc viewer 8.22:1 — all ≥4.5:1; screenshot confirms visually distinct quote in every context
|
||||||
|
- Deflected bubble (`--accent-bg`) also holds: 6.9:1
|
||||||
|
|
||||||
|
**Test / lint / coverage**
|
||||||
|
- `uv run pytest --cov=app --cov-report=term-missing` → 3263 passed, 9 skipped; TOTAL **99%** (>90% ✓)
|
||||||
|
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
|
||||||
|
- `uv run pytest tests/e2e/test_markdown_tables.py -v --no-cov` → 6 passed (phase-44 renderer-dependent E2E green unedited)
|
||||||
|
|
||||||
|
**Notable decision**
|
||||||
|
- User-bubble override added now (not awaiting task-03 E2E): the clash is provable by contrast math and WCAG 2.1 AA is a binding AGENTS.md rule 5 requirement
|
||||||
|
|
||||||
|
**Next pending task**
|
||||||
|
- `.agents/phases/todo/138_markdown_blockquotes/03_unit_and_e2e.md`
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
........................................................................ [ 2%]
|
||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 6%]
|
||||||
|
........................................................................ [ 8%]
|
||||||
|
........................................................................ [ 11%]
|
||||||
|
........................................................................ [ 13%]
|
||||||
|
........................................................................ [ 15%]
|
||||||
|
........................................................................ [ 17%]
|
||||||
|
........................................................................ [ 19%]
|
||||||
|
........................................................................ [ 22%]
|
||||||
|
........................................................................ [ 24%]
|
||||||
|
........................................................................ [ 26%]
|
||||||
|
........................................................................ [ 28%]
|
||||||
|
........................................................................ [ 30%]
|
||||||
|
........................................................................ [ 33%]
|
||||||
|
........................................................................ [ 35%]
|
||||||
|
........................................................................ [ 37%]
|
||||||
|
........................................................................ [ 39%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 44%]
|
||||||
|
........................................................................ [ 46%]
|
||||||
|
........................................................................ [ 48%]
|
||||||
|
........................................................................ [ 50%]
|
||||||
|
........................................................................ [ 52%]
|
||||||
|
......................................................s.....sss...ss.... [ 55%]
|
||||||
|
........................................................................ [ 57%]
|
||||||
|
........................................................................ [ 59%]
|
||||||
|
........................................................................ [ 61%]
|
||||||
|
........................................................................ [ 63%]
|
||||||
|
........................................................................ [ 66%]
|
||||||
|
........................................................................ [ 68%]
|
||||||
|
........................................................................ [ 70%]
|
||||||
|
........................................................................ [ 72%]
|
||||||
|
........................................................................ [ 74%]
|
||||||
|
........................................................................ [ 77%]
|
||||||
|
........................................................................ [ 79%]
|
||||||
|
........................................................................ [ 81%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 85%]
|
||||||
|
........................................................................ [ 88%]
|
||||||
|
........................................................................ [ 90%]
|
||||||
|
........................................................................ [ 92%]
|
||||||
|
........................................................................ [ 94%]
|
||||||
|
...............................................................sss...... [ 96%]
|
||||||
|
........................................................................ [ 99%]
|
||||||
|
................................ [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/brain-of-reese/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.14.7-final-0 ________________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover
|
||||||
|
--------------------------------------------------
|
||||||
|
app/__init__.py 1 0 100%
|
||||||
|
app/api/__init__.py 0 0 100%
|
||||||
|
app/api/auth.py 52 0 100%
|
||||||
|
app/api/chat.py 268 1 99%
|
||||||
|
app/api/chat_images.py 50 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 13 0 100%
|
||||||
|
app/api/doc_drafts.py 99 0 100%
|
||||||
|
app/api/docs.py 179 1 99%
|
||||||
|
app/api/git_sources.py 259 0 100%
|
||||||
|
app/api/health.py 10 0 100%
|
||||||
|
app/api/steering.py 42 0 100%
|
||||||
|
app/api/suggestions.py 33 0 100%
|
||||||
|
app/api/sync.py 183 0 100%
|
||||||
|
app/api/tokens.py 40 0 100%
|
||||||
|
app/api/ui_settings.py 55 0 100%
|
||||||
|
app/config.py 256 0 100%
|
||||||
|
app/core/__init__.py 0 0 100%
|
||||||
|
app/core/auth.py 45 0 100%
|
||||||
|
app/core/caching.py 124 0 100%
|
||||||
|
app/core/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/errors.py 5 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/core/rate_limit.py 44 0 100%
|
||||||
|
app/core/security_headers.py 20 0 100%
|
||||||
|
app/core/theming.py 38 0 100%
|
||||||
|
app/core/tokens.py 44 0 100%
|
||||||
|
app/db.py 22 0 100%
|
||||||
|
app/main.py 68 0 100%
|
||||||
|
app/models.py 133 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 412 1 99%
|
||||||
|
app/rag/archive_upload.py 134 0 100%
|
||||||
|
app/rag/chunker.py 220 4 98%
|
||||||
|
app/rag/doc_dates.py 18 0 100%
|
||||||
|
app/rag/folder_summaries.py 134 0 100%
|
||||||
|
app/rag/git_sources.py 56 0 100%
|
||||||
|
app/rag/importer.py 671 9 99%
|
||||||
|
app/rag/llm.py 246 1 99%
|
||||||
|
app/rag/md_images.py 63 0 100%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/pdf.py 119 0 100%
|
||||||
|
app/rag/prompts.py 154 0 100%
|
||||||
|
app/rag/retriever.py 248 3 99%
|
||||||
|
app/rag/scaffolding.py 55 0 100%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 49 0 100%
|
||||||
|
app/schemas.py 422 0 100%
|
||||||
|
--------------------------------------------------
|
||||||
|
TOTAL 5430 22 99%
|
||||||
|
coverage gate: app/ 99% (>90%) OK
|
||||||
|
All checks passed!
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||||
|
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||||
|
|
||||||
|
validation OK
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
Everything is green. Final report:
|
||||||
|
|
||||||
|
**Phase 138, task 03 — unit pins, node behavior, dedicated E2E — complete**
|
||||||
|
|
||||||
|
Implemented:
|
||||||
|
- `tests/unit/test_markdown_blockquotes.py` — source pins (pass order: after `inline(escapeHtml(text))`, before paragraph split; guard includes `blockquote`; exact `<blockquote class="md-quote">` marker; group rule test+strip literals; `.md-quote` CSS references `:root` palette vars only + contrast-ratio note) and a node behavior check: single line, 3-line `<br>` group, `>>`/`>>>` collapse, blank-line termination, inert `<script>` XSS, fence-wins, table-cell `>` untouched, inline-in-quote, and non-quote input byte-identical to the pre-phase renderer (fixture string generated from `git show HEAD` and checked into the test).
|
||||||
|
- `tests/e2e/test_markdown_blockquotes.py` — dedicated Playwright suite (mock-only, isolated): (a) chat — 4 `blockquote.md-quote` with no literal `>` at block start, inert XSS, fence/table precedence, non-deflected; (b) viewer — Sources drill-down opens `quote_docs/homelab/quotes.md`, modal renders `blockquote.md-quote`, fenced `>` stays code; (c) restore — `page.reload()` re-renders via the same renderer and keeps every blockquote.
|
||||||
|
- `tests/e2e/mock_llm.py` — `QUOTE_TRIGGER` ("show me the blockquote", verified disjoint from all triggers/questions/fixtures) + byte-stable `QUOTE_ANSWER` + docstring bullet (phase-44 pattern).
|
||||||
|
- `tests/fixtures/quote_docs/homelab/quotes.md` — dedicated fixture tree (like phase-68 `search_docs`) so the shared 13-doc fixture count pinned by ~30 other suites is untouched.
|
||||||
|
|
||||||
|
Results:
|
||||||
|
- `uv run pytest tests/unit/test_markdown_blockquotes.py -v` → 5 passed (node available, behavior check ran)
|
||||||
|
- `uv run pytest tests/e2e/test_markdown_blockquotes.py -v --no-cov` → 3 passed, in isolation
|
||||||
|
- `uv run pytest` → 3268 passed, 9 skipped; `--cov=app` TOTAL **99%** (>90%)
|
||||||
|
- `uv run ruff check . && uv run pyright` → clean (0 errors, 0 warnings)
|
||||||
|
|
||||||
|
Decisions: used the search-suite precedent (dedicated fixture dir) instead of adding to `tests/fixtures/docs/`, which would have broken the `added == 13` pins in ~30 other E2E suites. No defects found in prior work. No commits made (harness handles the atomic commit). Next pending: task files 04–05 in phase 139/140 (`todo/` — none within phase 138; its dir is complete pending the harness gate).
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
........................................................................ [ 2%]
|
||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 6%]
|
||||||
|
........................................................................ [ 8%]
|
||||||
|
........................................................................ [ 10%]
|
||||||
|
........................................................................ [ 13%]
|
||||||
|
........................................................................ [ 15%]
|
||||||
|
........................................................................ [ 17%]
|
||||||
|
........................................................................ [ 19%]
|
||||||
|
........................................................................ [ 21%]
|
||||||
|
........................................................................ [ 24%]
|
||||||
|
........................................................................ [ 26%]
|
||||||
|
........................................................................ [ 28%]
|
||||||
|
........................................................................ [ 30%]
|
||||||
|
........................................................................ [ 32%]
|
||||||
|
........................................................................ [ 35%]
|
||||||
|
........................................................................ [ 37%]
|
||||||
|
........................................................................ [ 39%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 43%]
|
||||||
|
........................................................................ [ 46%]
|
||||||
|
........................................................................ [ 48%]
|
||||||
|
........................................................................ [ 50%]
|
||||||
|
........................................................................ [ 52%]
|
||||||
|
...........................................................s.....sss...s [ 54%]
|
||||||
|
s....................................................................... [ 57%]
|
||||||
|
........................................................................ [ 59%]
|
||||||
|
........................................................................ [ 61%]
|
||||||
|
........................................................................ [ 63%]
|
||||||
|
........................................................................ [ 65%]
|
||||||
|
........................................................................ [ 68%]
|
||||||
|
........................................................................ [ 70%]
|
||||||
|
........................................................................ [ 72%]
|
||||||
|
........................................................................ [ 74%]
|
||||||
|
........................................................................ [ 76%]
|
||||||
|
........................................................................ [ 79%]
|
||||||
|
........................................................................ [ 81%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 85%]
|
||||||
|
........................................................................ [ 87%]
|
||||||
|
........................................................................ [ 90%]
|
||||||
|
........................................................................ [ 92%]
|
||||||
|
........................................................................ [ 94%]
|
||||||
|
....................................................................sss. [ 96%]
|
||||||
|
........................................................................ [ 98%]
|
||||||
|
..................................... [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/brain-of-reese/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.14.7-final-0 ________________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover
|
||||||
|
--------------------------------------------------
|
||||||
|
app/__init__.py 1 0 100%
|
||||||
|
app/api/__init__.py 0 0 100%
|
||||||
|
app/api/auth.py 52 0 100%
|
||||||
|
app/api/chat.py 268 1 99%
|
||||||
|
app/api/chat_images.py 50 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 13 0 100%
|
||||||
|
app/api/doc_drafts.py 99 0 100%
|
||||||
|
app/api/docs.py 179 1 99%
|
||||||
|
app/api/git_sources.py 259 0 100%
|
||||||
|
app/api/health.py 10 0 100%
|
||||||
|
app/api/steering.py 42 0 100%
|
||||||
|
app/api/suggestions.py 33 0 100%
|
||||||
|
app/api/sync.py 183 0 100%
|
||||||
|
app/api/tokens.py 40 0 100%
|
||||||
|
app/api/ui_settings.py 55 0 100%
|
||||||
|
app/config.py 256 0 100%
|
||||||
|
app/core/__init__.py 0 0 100%
|
||||||
|
app/core/auth.py 45 0 100%
|
||||||
|
app/core/caching.py 124 0 100%
|
||||||
|
app/core/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/errors.py 5 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/core/rate_limit.py 44 0 100%
|
||||||
|
app/core/security_headers.py 20 0 100%
|
||||||
|
app/core/theming.py 38 0 100%
|
||||||
|
app/core/tokens.py 44 0 100%
|
||||||
|
app/db.py 22 0 100%
|
||||||
|
app/main.py 68 0 100%
|
||||||
|
app/models.py 133 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 412 1 99%
|
||||||
|
app/rag/archive_upload.py 134 0 100%
|
||||||
|
app/rag/chunker.py 220 4 98%
|
||||||
|
app/rag/doc_dates.py 18 0 100%
|
||||||
|
app/rag/folder_summaries.py 134 0 100%
|
||||||
|
app/rag/git_sources.py 56 0 100%
|
||||||
|
app/rag/importer.py 671 9 99%
|
||||||
|
app/rag/llm.py 246 1 99%
|
||||||
|
app/rag/md_images.py 63 0 100%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/pdf.py 119 0 100%
|
||||||
|
app/rag/prompts.py 154 0 100%
|
||||||
|
app/rag/retriever.py 248 3 99%
|
||||||
|
app/rag/scaffolding.py 55 0 100%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 49 0 100%
|
||||||
|
app/schemas.py 422 0 100%
|
||||||
|
--------------------------------------------------
|
||||||
|
TOTAL 5430 22 99%
|
||||||
|
coverage gate: app/ 99% (>90%) OK
|
||||||
|
All checks passed!
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||||
|
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||||
|
|
||||||
|
validation OK
|
||||||
@@ -16,6 +16,22 @@
|
|||||||
* and restores it alongside the code blocks in the final step. Fences
|
* and restores it alongside the code blocks in the final step. Fences
|
||||||
* win over tables (they are protected first), and everything that is
|
* win over tables (they are protected first), and everything that is
|
||||||
* not a pipe table renders byte-identically to the original app.js copy.
|
* not a pipe table renders byte-identically to the original app.js copy.
|
||||||
|
*
|
||||||
|
* Phase 138 (2026-09-30, TODO.md L3) added the blockquote pass between
|
||||||
|
* step 2 (escape+inline) and step 3 (paragraphs): the maximal run of
|
||||||
|
* consecutive >-prefixed lines becomes ONE
|
||||||
|
* <blockquote class="md-quote"> with the members joined with <br>.
|
||||||
|
* The pass order is now fences → tables → escape+inline → blockquotes
|
||||||
|
* → paragraphs → restore. Group rule: a BLANK line terminates the
|
||||||
|
* group (a re-quoted line starts a new <blockquote>), and nested
|
||||||
|
* >> / >>> collapses to a single level (locked A1). Documented limit:
|
||||||
|
* a markdown STRUCTURE line inside a quote (a "- list" / "# heading")
|
||||||
|
* renders as plain text — the inline chain ran first, so its
|
||||||
|
* line-start transforms never see the post-strip text; inline
|
||||||
|
* code/strong/em still work. Fences and tables keep their precedence:
|
||||||
|
* a > inside a code fence never reaches the pass (step 1 placeholder),
|
||||||
|
* and a > in a table cell is restored in step 4. Everything without a
|
||||||
|
* quote line renders byte-identically to the pre-phase renderer.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
@@ -123,13 +139,46 @@ function renderMarkdown(md) {
|
|||||||
// 2. Escape everything else, then apply the inline + block transforms.
|
// 2. Escape everything else, then apply the inline + block transforms.
|
||||||
text = inline(escapeHtml(text));
|
text = inline(escapeHtml(text));
|
||||||
|
|
||||||
|
// 2b. Blockquotes (phase 138, 2026-09-30, TODO.md L3). Runs AFTER
|
||||||
|
// the escape+inline step — a quote line now starts with the escaped
|
||||||
|
// marker >, and inline code/strong/em already work inside quotes
|
||||||
|
// for free (this pass never re-transforms) — and BEFORE the
|
||||||
|
// paragraph pass, which the extended step-3 guard leaves alone. The
|
||||||
|
// maximal run of consecutive lines matching ^\s*(?:>)+ ? is ONE
|
||||||
|
// <blockquote class="md-quote">: the leading (?:>)+ run plus ONE
|
||||||
|
// optional space is stripped from each member (nested >> / >>>
|
||||||
|
// collapses to a single level — locked A1), members are joined with
|
||||||
|
// <br> (the paragraph convention), and a BLANK line terminates the
|
||||||
|
// group (a re-quoted line starts a new <blockquote>). Documented
|
||||||
|
// limit: a "- list" / "# heading" line INSIDE a quote renders as
|
||||||
|
// plain text — the inline chain ran first. Fences (step 1) and
|
||||||
|
// tables (step 1b) keep their precedence: a > inside a fence never
|
||||||
|
// reaches this pass, and a > in a table cell is restored in step 4.
|
||||||
|
const quoteLines = text.split("\n");
|
||||||
|
const quoted = [];
|
||||||
|
let qpos = 0;
|
||||||
|
while (qpos < quoteLines.length) {
|
||||||
|
if (!/^\s*(?:>)+ ?/.test(quoteLines[qpos])) {
|
||||||
|
quoted.push(quoteLines[qpos]);
|
||||||
|
qpos += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const members = [];
|
||||||
|
while (qpos < quoteLines.length && /^\s*(?:>)+ ?/.test(quoteLines[qpos])) {
|
||||||
|
members.push(quoteLines[qpos].replace(/^\s*(?:>)+ ?/, ""));
|
||||||
|
qpos += 1;
|
||||||
|
}
|
||||||
|
quoted.push(`<blockquote class="md-quote">${members.join("<br>")}</blockquote>`);
|
||||||
|
}
|
||||||
|
text = quoted.join("\n");
|
||||||
|
|
||||||
// 3. Paragraphs (double newline separated).
|
// 3. Paragraphs (double newline separated).
|
||||||
text = text
|
text = text
|
||||||
.split(/\n{2,}/)
|
.split(/\n{2,}/)
|
||||||
.map((block) => {
|
.map((block) => {
|
||||||
const b = block.trim();
|
const b = block.trim();
|
||||||
if (!b) return "";
|
if (!b) return "";
|
||||||
if (/^<(h\d|ul|ol|pre|li)/.test(b)) return b;
|
if (/^<(h\d|ul|ol|pre|li|blockquote)/.test(b)) return b;
|
||||||
return `<p>${b.replace(/\n/g, "<br>")}</p>`;
|
return `<p>${b.replace(/\n/g, "<br>")}</p>`;
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
|
|||||||
@@ -541,6 +541,37 @@ body::before {
|
|||||||
.md-table th, .md-table td { border: 1px solid var(--line); padding: 0.4rem 0.6rem; text-align: left; vertical-align: top; }
|
.md-table th, .md-table td { border: 1px solid var(--line); padding: 0.4rem 0.6rem; text-align: left; vertical-align: top; }
|
||||||
.md-table thead th { background: var(--surface); color: var(--ink); }
|
.md-table thead th { background: var(--surface); color: var(--ink); }
|
||||||
|
|
||||||
|
/* Markdown blockquotes (phase 138, 2026-09-30, TODO L3): the shared
|
||||||
|
renderer (markdown.js step 2b) turns a run of "> "-prefixed lines
|
||||||
|
into <blockquote class="md-quote"> — ONE rule set serves all three
|
||||||
|
rendering contexts (the chat .bubble, the details.thinking
|
||||||
|
scratchpad, the document viewer's .doc-md): every one sits on the
|
||||||
|
--surface canvas (the deflected bubble's --accent-bg holds too —
|
||||||
|
ink-soft there is 6.9:1), so a single AA pairing covers them all.
|
||||||
|
A visible-but-restrained left border + padding + a muted ink — NO
|
||||||
|
background fill (locked A2). Palette vars only (the :root-drift
|
||||||
|
test parses the built-ins from this file — no hex literal here):
|
||||||
|
border-left --brand-ink — 9.9:1 on --surface, the rose accent bar
|
||||||
|
(--line would be invisible: 1.14:1 on the surface;
|
||||||
|
plain --brand vanishes on the user bubble, 1:1);
|
||||||
|
color --ink-soft — 8.2:1 on --surface, the same AA pairing
|
||||||
|
the --ink-soft :root comment documents.
|
||||||
|
Static content — no animation (nothing for prefers-reduced-motion
|
||||||
|
to still). */
|
||||||
|
.md-quote {
|
||||||
|
border-left: 3px solid var(--brand-ink);
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
/* The USER bubble also runs the question through the shared renderer
|
||||||
|
(app.js addMessage), on the --brand fill with --bg text: --ink-soft
|
||||||
|
there is 1.6:1 — an AA failure — so the quote keeps the bubble's
|
||||||
|
own dark ink, 5.2:1 on --brand (the :root --bg pairing). The
|
||||||
|
--brand-ink bar holds 1.9:1 against the fill — still visible, the
|
||||||
|
quote stays distinct. */
|
||||||
|
.msg.user .bubble .md-quote { color: var(--bg); }
|
||||||
|
|
||||||
.msg.user { justify-content: flex-end; }
|
.msg.user { justify-content: flex-end; }
|
||||||
.msg.user .msg-body { align-items: flex-end; }
|
.msg.user .msg-body { align-items: flex-end; }
|
||||||
.msg.user .bubble {
|
.msg.user .bubble {
|
||||||
|
|||||||
@@ -382,6 +382,19 @@ Implements just enough of the aipi surface:
|
|||||||
``SUMMARY_MODE``), so a marker question always gets the table
|
``SUMMARY_MODE``), so a marker question always gets the table
|
||||||
answer; the E2E asks it against an on-topic fixture (HIGH gate) and
|
answer; the E2E asks it against an on-topic fixture (HIGH gate) and
|
||||||
asserts non-deflection.
|
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``
|
- user message containing ``echo my history``
|
||||||
(``HISTORY_TRIGGER``, phase 74, TODO L4 — chat history with prior
|
(``HISTORY_TRIGGER``, phase 74, TODO L4 — chat history with prior
|
||||||
thinking reaches the LLM) -> the deterministic HISTORY ECHO, derived
|
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 |"
|
"| 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
|
# 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
|
# against an on-topic fixture, where the gate is HIGH, and
|
||||||
# asserts non-deflection as part of the table test.
|
# asserts non-deflection as part of the table test.
|
||||||
answer = TABLE_ANSWER
|
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():
|
elif HISTORY_TRIGGER in user.lower():
|
||||||
# Phase 74 (TODO L4, chat history): the deterministic history
|
# Phase 74 (TODO L4, chat history): the deterministic history
|
||||||
# echo — proves on the wire that the client's prior turns (and
|
# echo — proves on the wire that the client's prior turns (and
|
||||||
|
|||||||
@@ -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 >) 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)
|
||||||
+17
@@ -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.
|
||||||
@@ -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