feat(chat): render markdown tables in answers, viewer, and thinking
GFM pipe tables in the shared renderer (TODO.md L6): a table-protection
pass in frontend/assets/markdown.js (fences -> tables -> escape order)
pulls each header+separator+body block out as a placeholder, renders
cells escape-first with the same inline transforms, and reinserts a
semantic <table class="md-table"> inside a horizontal-overflow
.md-table-wrap — so a pipe table in a chat answer, the document
viewer/modal, and the thinking block all render the same semantic
table. Fences win over tables; lone pipes stay text.
- styles.css: .md-table palette rules (PLAN §7.2 tokens, no motion);
min-width: max-content so a WIDE table keeps its natural width and
the wrapper is the real scroller (width:100% alone wrapped the wide
table's cells — proven by the new E2E).
- mock_llm.py: TABLE_TRIGGER ("show me a table") -> byte-stable
TABLE_ANSWER (3-column table, <img onerror> XSS probe line, wide
5-column table), checked before DEFLECT_MODE like SUMMARY_MODE.
- tests/fixtures/docs/homelab/tables.md: 3x3 pipe table + pipe-heavy
fenced block (viewer/fence subject); the shared fixture set grows
8 -> 9 docs, so every suite pinning the count (added/formats/
stat-docs/EXPECTED_ROWS) is updated accordingly.
- tests/e2e/test_markdown_tables.py (new, story suite): chat table
shape + non-deflection, wide-table wrapper scroll (no page
overflow), XSS probe inert, viewer modal table, fence-not-a-table,
lone pipe stays text.
- tests/e2e/test_agent_document_tools.py: fix a pre-existing flake —
the "Calling tool…" label window is ~0.4 s at the mock's 0.1 s
tool-frame pacing, and a polling expect could stride over it
(failed 3 of 5 runs on the committed baseline). The pre-submit
MutationObserver record is the deterministic source of truth; the
racy to_have_text gate is gone.
uv run pytest: 738 passed, app/ coverage 99% (TOTAL unchanged);
ruff + pyright clean; story E2E 6/6 in isolation; regression E2E
suites (chat_rag, document_viewer, document_summaries, smoke) green.
This commit is contained in:
+104
-14
@@ -6,7 +6,16 @@
|
||||
* content can never inject live HTML/XSS. Classic script on purpose:
|
||||
* index.html and document.html load it via a plain relative <script src>
|
||||
* and both module scripts (app.js / document.js) call the globals it
|
||||
* defines. Rendering behavior is unchanged from the original app.js copy.
|
||||
* defines.
|
||||
*
|
||||
* Phase 44 (2026-08-27, TODO.md L6) extended the ~60-line renderer with
|
||||
* one more pass — GFM pipe tables: a table-protection pass (order:
|
||||
* fences → tables → escape) pulls each pipe-table block out as a
|
||||
* \u0000TABLEn\u0000 placeholder, renders it as a semantic, escape-first
|
||||
* <table class="md-table"> inside a horizontal-overflow .md-table-wrap,
|
||||
* and restores it alongside the code blocks in the final step. Fences
|
||||
* win over tables (they are protected first), and everything that is
|
||||
* not a pipe table renders byte-identically to the original app.js copy.
|
||||
*/
|
||||
|
||||
function escapeHtml(s) {
|
||||
@@ -23,17 +32,96 @@ function renderMarkdown(md) {
|
||||
return `\u0000CODE${codeBlocks.length - 1}\u0000`;
|
||||
});
|
||||
|
||||
// 2. Escape everything else, then apply inline + block transforms.
|
||||
text = escapeHtml(text)
|
||||
.replace(/`([^`\n]+)`/g, "<code>$1</code>")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/(^|[\s(])\*([^*\n]+)\*/g, "$1<em>$2</em>")
|
||||
.replace(/^### (.*)$/gm, "<h4>$1</h4>")
|
||||
.replace(/^## (.*)$/gm, "<h3>$1</h3>")
|
||||
.replace(/^# (.*)$/gm, "<h3>$1</h3>")
|
||||
.replace(/^\s*[-*] (.*)$/gm, "<li>$1</li>")
|
||||
.replace(/(<li>[\s\S]*?<\/li>)(?!\s*<li>)/g, "<ul>$1</ul>")
|
||||
.replace(/^\d+\. (.*)$/gm, "<li>$1</li>");
|
||||
// Inline + line-block transforms — the EXACT .replace chain of the
|
||||
// whole-text pass, factored so a table cell gets the identical
|
||||
// pipeline. Escape-first is the caller's job (both paths pass
|
||||
// already-escaped input), so the chain only ever sees inert text.
|
||||
const inline = (s) =>
|
||||
s.replace(/`([^`\n]+)`/g, "<code>$1</code>")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/(^|[\s(])\*([^*\n]+)\*/g, "$1<em>$2</em>")
|
||||
.replace(/^### (.*)$/gm, "<h4>$1</h4>")
|
||||
.replace(/^## (.*)$/gm, "<h3>$1</h3>")
|
||||
.replace(/^# (.*)$/gm, "<h3>$1</h3>")
|
||||
.replace(/^\s*[-*] (.*)$/gm, "<li>$1</li>")
|
||||
.replace(/(<li>[\s\S]*?<\/li>)(?!\s*<li>)/g, "<ul>$1</ul>")
|
||||
.replace(/^\d+\. (.*)$/gm, "<li>$1</li>");
|
||||
|
||||
// 1b. Protect GFM pipe tables (phase 44, 2026-08-27, TODO.md L6).
|
||||
// A table block starts at a line containing "|" whose NEXT line is a
|
||||
// separator (>=1 pipe-separated cells, each ^\s*:?-+:?\s*$; leading/
|
||||
// trailing pipes optional) and extends over every following line that
|
||||
// still contains "|" (body rows — zero is valid: a header-only table).
|
||||
// The maximal run is one table; anything else (a lone "|" in prose, a
|
||||
// separator without a "|" header above it, a 1-line "table") is left
|
||||
// untouched. Runs AFTER the fence pass (fences win over tables) and
|
||||
// BEFORE the escape pass: only the \u0000TABLEn\u0000 placeholder enters
|
||||
// the pipeline, each cell is escaped + inline-transformed here, and
|
||||
// the final HTML is restored in step 4 (it never re-enters the
|
||||
// paragraph pass). Alignment colons in the separator are parsed but
|
||||
// ignored — every cell renders left-aligned (owner decision 2026-08-27).
|
||||
const tables = [];
|
||||
const splitRow = (line) => {
|
||||
const cells = line.split("|");
|
||||
if (line.trimStart().startsWith("|")) cells.shift(); // leading pipe
|
||||
if (line.trimEnd().endsWith("|")) cells.pop(); // trailing pipe
|
||||
return cells.map((c) => c.trim());
|
||||
};
|
||||
const isSeparator = (line) =>
|
||||
line.includes("|") &&
|
||||
splitRow(line).length >= 1 &&
|
||||
splitRow(line).every((c) => /^\s*:?-+:?\s*$/.test(c));
|
||||
const tableLines = text.split("\n");
|
||||
const kept = [];
|
||||
let pos = 0;
|
||||
while (pos < tableLines.length) {
|
||||
const line = tableLines[pos];
|
||||
const startsTable =
|
||||
line.includes("|") &&
|
||||
pos + 1 < tableLines.length &&
|
||||
isSeparator(tableLines[pos + 1]);
|
||||
if (!startsTable) {
|
||||
kept.push(line);
|
||||
pos += 1;
|
||||
continue;
|
||||
}
|
||||
// Consume the maximal block: header + separator + "|" body lines.
|
||||
const block = [line, tableLines[pos + 1]];
|
||||
let end = pos + 2;
|
||||
while (end < tableLines.length && tableLines[end].includes("|")) {
|
||||
block.push(tableLines[end]);
|
||||
end += 1;
|
||||
}
|
||||
const header = splitRow(block[0]);
|
||||
const width = header.length;
|
||||
const body = block
|
||||
.slice(2)
|
||||
.map((row) => {
|
||||
const cells = splitRow(row);
|
||||
for (let k = cells.length; k < width; k += 1) cells.push(""); // pad
|
||||
// truncate past the header width, escape first, inline transforms
|
||||
return `<tr>${cells
|
||||
.slice(0, width)
|
||||
.map((c) => `<td>${inline(escapeHtml(c))}</td>`)
|
||||
.join("")}</tr>`;
|
||||
})
|
||||
.join("");
|
||||
tables.push(
|
||||
'<div class="md-table-wrap">\n' +
|
||||
'<table class="md-table">\n' +
|
||||
`<thead><tr>${header
|
||||
.map((c) => `<th scope="col">${inline(escapeHtml(c))}</th>`)
|
||||
.join("")}</tr></thead>\n` +
|
||||
(body ? `<tbody>${body}</tbody>\n` : "") +
|
||||
"</table>\n</div>",
|
||||
);
|
||||
kept.push(`\u0000TABLE${tables.length - 1}\u0000`);
|
||||
pos = end;
|
||||
}
|
||||
text = kept.join("\n");
|
||||
|
||||
// 2. Escape everything else, then apply the inline + block transforms.
|
||||
text = inline(escapeHtml(text));
|
||||
|
||||
// 3. Paragraphs (double newline separated).
|
||||
text = text
|
||||
@@ -46,6 +134,8 @@ function renderMarkdown(md) {
|
||||
})
|
||||
.join("");
|
||||
|
||||
// 4. Restore code blocks.
|
||||
return text.replace(/\u0000CODE(\d+)\u0000/g, (_m, i) => codeBlocks[Number(i)]);
|
||||
// 4. Restore protected spans: tables (already final HTML) and code.
|
||||
return text
|
||||
.replace(/\u0000TABLE(\d+)\u0000/g, (_m, i) => tables[Number(i)])
|
||||
.replace(/\u0000CODE(\d+)\u0000/g, (_m, i) => codeBlocks[Number(i)]);
|
||||
}
|
||||
|
||||
@@ -576,6 +576,25 @@ html::after {
|
||||
.bubble code { font-family: var(--mono); font-size: 0.88em; background: var(--brand-soft); padding: 0.08em 0.35em; border-radius: 5px; }
|
||||
.bubble pre code { background: none; padding: 0; }
|
||||
|
||||
/* GFM pipe tables (phase 44, 2026-08-27, TODO.md L6): the shared
|
||||
renderer wraps every table in .md-table-wrap — the horizontal
|
||||
scroller, so a wide table scrolls inside the bubble instead of
|
||||
breaking the 46rem column — around a semantic <table class="md-table">
|
||||
(escape-first cells; alignment colons render left, owner decision).
|
||||
Phase-08 tokens only: --line hairline borders and the thead tinted
|
||||
from the plain surface family — --ink on --surface is 14.5:1 (PLAN
|
||||
§7.2), never the brand. The rules are unscoped on purpose: the same
|
||||
renderer serves the chat bubble, the thinking scratchpad, and the
|
||||
document viewer (.doc-md). width:100% stretches narrow tables to the
|
||||
column; min-width:max-content lets a WIDE table keep its natural width
|
||||
so the wrapper is the real scroller (phase 44 task 03: width:100% alone
|
||||
wrapped the wide table's cells and it never overflowed). Static content
|
||||
— no animation (nothing for prefers-reduced-motion to still). */
|
||||
.md-table-wrap { overflow-x: auto; }
|
||||
.md-table { border-collapse: collapse; width: 100%; min-width: max-content; font-size: 0.9rem; }
|
||||
.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); }
|
||||
|
||||
.msg.user { justify-content: flex-end; }
|
||||
.msg.user .msg-body { align-items: flex-end; }
|
||||
.msg.user .bubble {
|
||||
|
||||
@@ -65,6 +65,17 @@ Implements just enough of the aipi surface:
|
||||
section, or with the tool conversation not yet started and no tools
|
||||
offered — e.g. budgets 0/0) behave exactly as today. ``E2E_REAL_LLM=1``
|
||||
ignores the mock entirely (the real model does what it does).
|
||||
- user message containing ``show me a table`` (phase 44, markdown
|
||||
tables, TODO.md L6) -> the fixed table answer (``TABLE_ANSWER``):
|
||||
a 3-column service table, an ``<img onerror>`` XSS probe line, and
|
||||
a deliberately wide 5-column table — byte-stable, so the story E2E
|
||||
can assert the rendered ``<table class="md-table">`` shape, the
|
||||
escaped XSS line, and the wrapper's horizontal scroll inside the
|
||||
46rem column. 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 table
|
||||
answer; the E2E asks it against an on-topic fixture (HIGH gate) and
|
||||
asserts non-deflection.
|
||||
|
||||
``max_tokens`` is honored deterministically (token ≈ whitespace word),
|
||||
like a real endpoint: an answer longer than the cap is truncated. This
|
||||
@@ -163,6 +174,37 @@ _DOCUMENTS_BLOCK_RE = re.compile(r"<documents>.*?</documents>", re.S)
|
||||
#: contain the phrase, so every other suite is unaffected.
|
||||
TOOLS_TRIGGER = "use your tools"
|
||||
|
||||
#: Phase 44 (markdown-tables story, TODO.md L6): a user message
|
||||
#: containing this substring (case-insensitive) gets the fixed table
|
||||
#: answer (``TABLE_ANSWER`` below) — a 3-column table, an XSS probe
|
||||
#: line, and a deliberately wide table (see the module docstring).
|
||||
#: Existing E2E questions do not contain the phrase, so every other
|
||||
#: suite is unaffected.
|
||||
TABLE_TRIGGER = "show me a table"
|
||||
|
||||
#: The fixed table answer (phase 44) — byte-stable on purpose: the story
|
||||
#: E2E asserts the rendered table shape, the escaped ``<img onerror>``
|
||||
#: line (the XSS payload must survive the mock byte-for-byte), and the
|
||||
#: wide table's ``scrollWidth > clientWidth`` inside the 46rem column.
|
||||
TABLE_ANSWER = (
|
||||
"Here's the shape, in a table:\n"
|
||||
"\n"
|
||||
"| Service | Port | Host |\n"
|
||||
"|---|---|---|\n"
|
||||
"| Caddy | 80 | homelab-gw |\n"
|
||||
"| GitLab | 8929 | homelab-git |\n"
|
||||
"| ntfy | 2087 | homelab-ntfy |\n"
|
||||
"\n"
|
||||
"<img src=x onerror=alert(1)>\n"
|
||||
"\n"
|
||||
"And the wide one:\n"
|
||||
"\n"
|
||||
"| A very long column header to force overflow | Second column with "
|
||||
"some padding text | Third column | Fourth | Fifth |\n"
|
||||
"|---|---|---|---|---|\n"
|
||||
"| value-one | value-two | value-three | value-four | value-five |"
|
||||
)
|
||||
|
||||
|
||||
#: The agent's ``read_document`` tool-result prefix (app.rag.agent
|
||||
#: ``_execute_tool``): ``"Document <source/path>:\n<content>"``.
|
||||
@@ -298,6 +340,20 @@ def compose_answer(body: dict[str, Any]) -> str:
|
||||
answer = "Knowledge base outline:\n- " + " ".join(
|
||||
TOKEN_RE.findall(user.lower())[:8]
|
||||
)
|
||||
elif TABLE_TRIGGER in user.lower():
|
||||
# Markdown tables (phase 44, TODO.md L6): the story E2E's
|
||||
# deterministic table answer — a 3-column table, the
|
||||
# <img onerror> XSS probe line (it must survive the mock
|
||||
# byte-for-byte so the E2E can prove the renderer neutralizes
|
||||
# it), and a wide 5-column table (guarantees scrollWidth >
|
||||
# clientWidth inside the 46rem column). Byte-stable. Checked
|
||||
# BEFORE the DEFLECT_MODE branch: a deflection prompt never
|
||||
# carries the marker (it lives in the user message, same
|
||||
# reasoning as SUMMARY_MODE), so a marker question always gets
|
||||
# the table answer, whatever the gate says; the E2E asks it
|
||||
# against an on-topic fixture, where the gate is HIGH, and
|
||||
# asserts non-deflection as part of the table test.
|
||||
answer = TABLE_ANSWER
|
||||
elif "DEFLECT_MODE" in system:
|
||||
answer = (
|
||||
"Ah — I haven't done anything like that, so I don't want to make stuff up! "
|
||||
|
||||
@@ -208,10 +208,10 @@ def test_admin_login_unlocks_sources_and_tuning(
|
||||
login(page, app_url)
|
||||
expect(page).to_have_url(app_url + "/sources.html")
|
||||
expect(page.locator("#sources-gate")).to_be_hidden()
|
||||
expect(page.locator("#stat-docs")).to_have_text("8")
|
||||
expect(page.locator("#stat-docs")).to_have_text("9") # phase 44: +tables.md
|
||||
expect(page.locator("#stat-chunks")).not_to_have_text("–")
|
||||
expect(page.locator("#docs-table")).to_be_visible()
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(8)
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(9)
|
||||
|
||||
# Chat: the tuning UI is back — header toggle with count badge,
|
||||
# Sign out instead of Sign in, Tune under the answer.
|
||||
|
||||
@@ -327,14 +327,16 @@ def test_marker_question_lists_reads_and_quotes(
|
||||
_install_page_hooks(page)
|
||||
|
||||
_submit(page, MARKER_QUESTION)
|
||||
# While a tool runs the button carries the "calling tool" label: the
|
||||
# first `tool` frame sets it and it holds until the FIRST answer
|
||||
# delta (the agent loop completes before the answer stream) — so the
|
||||
# poll issued right after the click must catch it inside that window.
|
||||
expect(page.locator("#send-label")).to_have_text("Calling tool…", timeout=20_000)
|
||||
# The "calling tool" label window is transient: the first `tool`
|
||||
# frame sets it and it holds until the FIRST answer delta (the agent
|
||||
# loop completes before the answer stream) — ~0.4 s at the mock's
|
||||
# 0.1 s tool-frame pacing. A polling expect can stride straight over
|
||||
# that window (observed flake, fixed in phase 44 task 03), so the
|
||||
# pre-submit MutationObserver record below is the deterministic
|
||||
# source of truth for the label transition.
|
||||
_wait_settled(page)
|
||||
|
||||
# The label transition is also recorded deterministically (no race):
|
||||
# The label transition, recorded deterministically (no race):
|
||||
# Thinking… → Calling tool… → … → Send.
|
||||
labels = page.evaluate("() => window.__labels")
|
||||
assert "Calling tool…" in labels, labels
|
||||
|
||||
@@ -128,7 +128,7 @@ def test_conversation_survives_reload(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
summary = _reset_db(mock_llm, seed=True)
|
||||
assert summary is not None and summary.added == 8 # A9 formats
|
||||
assert summary is not None and summary.added == 9 # A9 formats (phase 44 added tables.md)
|
||||
page.set_default_timeout(30_000)
|
||||
page.goto(app_url)
|
||||
_ask(page, QUESTION)
|
||||
|
||||
@@ -76,7 +76,7 @@ def test_on_topic_question_streams_grounded_answer(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
summary = _reset_db(mock_llm, seed=True)
|
||||
assert summary is not None and summary.added == 8 # A9 formats
|
||||
assert summary is not None and summary.added == 9 # A9 formats (phase 44 added tables.md)
|
||||
page.set_default_timeout(30_000)
|
||||
page.goto(app_url)
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ def _seed_kb(mock_port: int) -> ImportSummary:
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||
db.commit()
|
||||
summary = _run_in_thread(_import_fixtures(mock_port))
|
||||
assert summary is not None and summary.added == 8 # A9 formats
|
||||
assert summary is not None and summary.added == 9 # A9 formats (phase 44 added tables.md)
|
||||
return summary
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
The fixture KB is a story-dedicated directory
|
||||
(``tests/fixtures/summary_kb/`` — the shared ``tests/fixtures/docs/``
|
||||
stays at its 8 pinned files) with two documents:
|
||||
stays at its 9 pinned files) with two documents:
|
||||
|
||||
* ``quadlet/qwen-llamacpp.yaml`` — a non-markdown A9 doc. At import the
|
||||
mock ``lite`` model (``SUMMARY_MODE`` marker, ``tests/e2e/mock_llm.py``)
|
||||
|
||||
@@ -336,7 +336,7 @@ def test_edit_note_steers_answer(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
summary = _reset_db(mock_llm, seed=True)
|
||||
assert summary is not None and summary.added == 8 # A9 formats
|
||||
assert summary is not None and summary.added == 9 # A9 formats (phase 44 added tables.md)
|
||||
page.set_default_timeout(30_000)
|
||||
_open_tuning(page, app_url)
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ def test_off_topic_question_deflects_honestly(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
summary = _reset_db(mock_llm, seed=True)
|
||||
assert summary is not None and summary.added == 8 # A9 formats
|
||||
assert summary is not None and summary.added == 9 # A9 formats (phase 44 added tables.md)
|
||||
page.set_default_timeout(30_000)
|
||||
page.goto(app_url)
|
||||
expect(page.locator("#kb-banner")).to_be_hidden()
|
||||
|
||||
@@ -40,6 +40,7 @@ EXPECTED_ROWS = (
|
||||
"homelab/networking/static-dns.json",
|
||||
"homelab/scripts/uptime_probe.py",
|
||||
"homelab/ssh/ssh_aliases.txt",
|
||||
"homelab/tables.md", # phase 44: the markdown-tables fixture
|
||||
)
|
||||
|
||||
|
||||
@@ -85,13 +86,14 @@ def test_sources_page_lists_indexed_docs(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
summary = _reset_db(mock_llm, seed=True)
|
||||
# Eight A9-format files are imported; .hidden/junk.md is out of scope
|
||||
# (A9 revised — hidden path components are never walked).
|
||||
assert summary is not None and summary.added == 8
|
||||
assert summary.formats == {"md": 4, "yaml": 1, "json": 1, "py": 1, "txt": 1}
|
||||
# Nine A9-format files are imported (phase 44 added homelab/tables.md);
|
||||
# .hidden/junk.md is out of scope (A9 revised — hidden path components
|
||||
# are never walked).
|
||||
assert summary is not None and summary.added == 9
|
||||
assert summary.formats == {"md": 5, "yaml": 1, "json": 1, "py": 1, "txt": 1}
|
||||
|
||||
login(page, app_url) # phase 16: the catalog is admin-only
|
||||
expect(page.locator("#stat-docs")).to_have_text("8")
|
||||
expect(page.locator("#stat-docs")).to_have_text("9")
|
||||
# Phase 30: non-markdown fixtures each gained one ``is_summary`` chunk,
|
||||
# so the Sources total is content chunks + summary chunks.
|
||||
expect(page.locator("#stat-chunks")).to_have_text(
|
||||
|
||||
@@ -170,7 +170,7 @@ def test_on_topic_answer_echoes_kb_overview(
|
||||
the mock's echo of the ``<knowledge_base>`` section's first bullet —
|
||||
only possible if the section reached the LLM prompt."""
|
||||
summary = _reset_db(mock_llm, seed=True, overview=OVERVIEW)
|
||||
assert summary is not None and summary.added == 8 # A9 formats
|
||||
assert summary is not None and summary.added == 9 # phase 44 added tables.md
|
||||
assert _overview_row() is not None # the row the turn must inject
|
||||
|
||||
bubble = _ask(page, app_url, QUESTION, KB_ECHO)
|
||||
@@ -250,7 +250,7 @@ def test_mock_generated_outline_is_stored_and_echoed(
|
||||
stores the byte-stable 8-token digest of the generator's document
|
||||
list, and a chat turn echoes its first bullet."""
|
||||
summary = _reset_db(mock_llm, seed=True, overview=None)
|
||||
assert summary is not None and summary.added == 8
|
||||
assert summary is not None and summary.added == 9 # phase 44 added tables.md
|
||||
assert _overview_row() is None # direct import never regenerates
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
|
||||
@@ -176,7 +176,7 @@ def test_typing_indicator_during_slow_think(
|
||||
"""AC1/AC5: the 3s mock warm-up must show the typing indicator for
|
||||
>=2s before any text appears, then it is gone once the answer lands."""
|
||||
summary = _reset_db(mock_llm, seed=True)
|
||||
assert summary is not None and summary.added == 8 # A9 formats
|
||||
assert summary is not None and summary.added == 9 # A9 formats (phase 44 added tables.md)
|
||||
page.set_default_timeout(30_000)
|
||||
page.goto(app_url)
|
||||
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
"""Phase 44 E2E (Playwright): GFM pipe tables in the shared renderer.
|
||||
|
||||
Story: ``.agent/user_stories/markdown-tables.md``
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_markdown_tables.py -v --no-cov
|
||||
|
||||
Seeding reuses the real importer against ``tests/fixtures/docs/`` with
|
||||
the deterministic mock embeddings (same pattern as ``test_chat_rag.py``).
|
||||
The mock's ``TABLE_TRIGGER`` (``show me a table``, phase 44 task 02)
|
||||
returns the byte-stable table answer: a 3-column service table, an
|
||||
``<img onerror>`` XSS probe line, and a deliberately wide 5-column
|
||||
table. The phase-44 fixture ``homelab/tables.md`` (a 3×3 pipe table
|
||||
plus a pipe-heavy fenced block) is the viewer/fence subject — the
|
||||
document viewer is database-only, so the imported row is enough.
|
||||
|
||||
Test → story mapping (Playwright Mapping Rule):
|
||||
1. ``test_chat_table_renders`` — the brain bubble carries
|
||||
``<div class="md-table-wrap"><table class="md-table">`` with a
|
||||
``<thead>`` of three ``<th scope="col">`` (Service/Port/Host), the
|
||||
expected body cells, no raw ``|---|`` separator text, and the turn is
|
||||
NOT deflected (the honesty-gate interplay is part of the contract).
|
||||
2. ``test_wide_table_scrolls`` — the wide table's wrapper has
|
||||
``scrollWidth > clientWidth`` and horizontal scroll moves it; the
|
||||
page itself has no horizontal overflow (the 46rem column holds).
|
||||
3. ``test_table_xss_safe`` — the ``<img onerror>`` line renders as
|
||||
visible, escaped text: zero injected ``<img>`` nodes, no dialog.
|
||||
4. ``test_viewer_table_renders`` — the fixture's pipe table opens from
|
||||
the Sources table (admin) in the modal and renders the same
|
||||
``<table class="md-table">`` (shared renderer, story AC6).
|
||||
5. ``test_fence_not_a_table`` — the fixture's pipe-heavy fenced block
|
||||
renders ``<pre><code>``; the only ``<table>`` in the document is the
|
||||
real pipe table (fences win, story AC3).
|
||||
6. ``test_plain_pipe_stays_text`` — a grounded prose answer with a lone
|
||||
``|`` (the mock echoes the question) renders as text, no
|
||||
``<table>`` (story AC4).
|
||||
"""
|
||||
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" / "docs"
|
||||
|
||||
#: Carries the mock's ``TABLE_TRIGGER`` ("show me a table") and is
|
||||
#: on-topic (the fixture set answers it — FTS-OR grounds it, so the
|
||||
#: turn is HIGH and the suite can assert non-deflection).
|
||||
QUESTION = "Show me a table of my homelab services?"
|
||||
#: Grounded kubernetes question with a single ``|`` in the prose — the
|
||||
#: mock's default branch echoes the question (first 80 chars), so the
|
||||
#: lone pipe lands in the rendered answer.
|
||||
PLAIN_QUESTION = "How is my Kubernetes cluster set up? A lone | in prose stays text."
|
||||
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
|
||||
TABLES_PATH = "homelab/tables.md"
|
||||
WIDE_HEADER = "A very long column header to force overflow"
|
||||
XSS_LINE = "<img src=x onerror=alert(1)>"
|
||||
|
||||
EXPECTED_HEADER = ["Service", "Port", "Host"]
|
||||
EXPECTED_ROWS = [
|
||||
["Caddy", "80", "homelab-gw"],
|
||||
["GitLab", "8929", "homelab-git"],
|
||||
["ntfy", "2087", "homelab-ntfy"],
|
||||
]
|
||||
|
||||
|
||||
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, seed: bool) -> ImportSummary | None:
|
||||
"""Truncate the KB (+ the global prompt-state rows), then optionally
|
||||
re-import the fixtures (9 docs since phase 44 added tables.md)."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text("TRUNCATE chunks, documents, query_log, steering_notes, kb_overview")
|
||||
)
|
||||
db.commit()
|
||||
if not seed:
|
||||
return None
|
||||
return _run_in_thread(_import_fixtures(mock_port))
|
||||
|
||||
|
||||
def _ask_table_answer(page: Page, app_url: str) -> Any:
|
||||
"""Drive the trigger question and return the brain bubble once the
|
||||
whole byte-stable table answer has streamed in (the wide table's
|
||||
last cell lands last)."""
|
||||
page.goto(app_url)
|
||||
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("value-five", timeout=30_000)
|
||||
# Non-deflection is part of the table contract (honesty gate interplay).
|
||||
expect(page.locator(".msg.brain.is-deflected")).to_have_count(0)
|
||||
return bubble
|
||||
|
||||
|
||||
def _open_tables_doc_modal(page: Page, app_url: str) -> None:
|
||||
"""Admin → Sources → the tables.md row → same-page document modal."""
|
||||
login(page, app_url) # phase 16: the Sources catalog is admin-only
|
||||
row = page.locator("#docs-tbody tr", has_text=TABLES_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("Service Port Table")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Chat: the pipe table renders as a semantic table
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_chat_table_renders(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
summary = _reset_db(mock_llm, seed=True)
|
||||
assert summary is not None and summary.added == 9 # phase 44: +tables.md
|
||||
page.set_default_timeout(30_000)
|
||||
bubble = _ask_table_answer(page, app_url)
|
||||
|
||||
# Both tables of the answer rendered: the 3-column service table and
|
||||
# the wide one — each in its horizontal-overflow wrapper.
|
||||
tables = bubble.locator("table.md-table")
|
||||
expect(tables).to_have_count(2)
|
||||
expect(bubble.locator(".md-table-wrap")).to_have_count(2)
|
||||
|
||||
# The 3×3 table: <thead> of three <th scope="col"> + the body cells
|
||||
# (the whole answer has already streamed in — the DOM is settled).
|
||||
first = tables.nth(0)
|
||||
headers = first.locator("thead th[scope='col']")
|
||||
expect(headers).to_have_count(3)
|
||||
assert headers.all_inner_texts() == EXPECTED_HEADER
|
||||
rows = first.locator("tbody tr")
|
||||
expect(rows).to_have_count(3)
|
||||
for i, cells in enumerate(EXPECTED_ROWS):
|
||||
assert rows.nth(i).locator("td").all_inner_texts() == cells
|
||||
|
||||
# The raw markdown must not survive: no separator row, no raw header
|
||||
# row as text anywhere in the bubble.
|
||||
bubble_text = bubble.inner_text()
|
||||
assert "|---|" not in bubble_text, "the |---| separator leaked into the bubble"
|
||||
assert "| Service | Port | Host |" not in bubble_text, "the raw header row leaked"
|
||||
|
||||
# Grounded retrieval: the table fixture is the top source chip.
|
||||
chip = page.locator(".msg.brain .source-chip", has_text="homelab/tables.md")
|
||||
expect(chip).to_have_count(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Wide table: the wrapper scrolls, the page does not
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_wide_table_scrolls(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=True)
|
||||
page.set_default_timeout(30_000)
|
||||
bubble = _ask_table_answer(page, app_url)
|
||||
|
||||
# The wide table (5 columns, one deliberately long header) sits in
|
||||
# ITS wrapper — the 3-column table's wrapper is not the scroller.
|
||||
wrap = bubble.locator(".md-table-wrap", has=page.locator("th", has_text=WIDE_HEADER))
|
||||
expect(wrap).to_have_count(1)
|
||||
scroll_width, client_width = wrap.evaluate(
|
||||
"el => [el.scrollWidth, el.clientWidth]"
|
||||
)
|
||||
assert scroll_width > client_width, (
|
||||
f"the wide table must overflow its wrapper "
|
||||
f"(scrollWidth {scroll_width} <= clientWidth {client_width})"
|
||||
)
|
||||
|
||||
# Horizontal scrolling (scrollLeft) moves the wrapper's content.
|
||||
before = wrap.evaluate("el => el.scrollLeft")
|
||||
wrap.evaluate("el => { el.scrollLeft = 120; }")
|
||||
after = wrap.evaluate("el => el.scrollLeft")
|
||||
assert after > before, "the wrapper must scroll horizontally"
|
||||
|
||||
# The 46rem chat column must not break the page: no horizontal
|
||||
# document overflow (PLAN §7.1).
|
||||
page_scroll, page_client = page.evaluate(
|
||||
"() => [document.documentElement.scrollWidth, document.documentElement.clientWidth]"
|
||||
)
|
||||
assert page_scroll <= page_client, (
|
||||
f"the page overflowed horizontally ({page_scroll} > {page_client})"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. XSS-safe: the <img onerror> probe renders inert text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_table_xss_safe(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=True)
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
dialogs: list[str] = []
|
||||
|
||||
def _catch(d) -> None: # a fired dialog == the probe executed
|
||||
dialogs.append(d.message)
|
||||
d.dismiss()
|
||||
|
||||
page.on("dialog", _catch)
|
||||
_ask_table_answer(page, app_url)
|
||||
|
||||
state = page.evaluate(
|
||||
"""() => {
|
||||
const el = document.querySelector('.msg.brain .bubble');
|
||||
return {
|
||||
imgs: el.querySelectorAll('img').length,
|
||||
onerror: el.querySelectorAll('[onerror]').length,
|
||||
text: el.innerText,
|
||||
html: el.innerHTML,
|
||||
};
|
||||
}"""
|
||||
)
|
||||
assert state["imgs"] == 0, "the XSS probe became a live <img> element"
|
||||
assert state["onerror"] == 0, "an onerror attribute survived into the DOM"
|
||||
# The escaped tag renders as VISIBLE text (the escape-first contract).
|
||||
assert XSS_LINE in state["text"], "the probe line must be visible text"
|
||||
assert "<img src=x onerror=alert(1)>" in state["html"]
|
||||
assert dialogs == [], f"dialog fired — the probe executed: {dialogs}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Shared renderer: the viewer/modal renders the fixture's table
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_viewer_table_renders(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=True)
|
||||
page.set_default_timeout(30_000)
|
||||
_open_tables_doc_modal(page, app_url)
|
||||
|
||||
# The same <table class="md-table"> shape the chat bubble gets — the
|
||||
# shared renderer (story AC6) serves the viewer too.
|
||||
table = page.locator("#doc-modal-content table.md-table")
|
||||
expect(table).to_have_count(1)
|
||||
headers = table.locator("thead th[scope='col']")
|
||||
expect(headers).to_have_count(3)
|
||||
assert headers.all_inner_texts() == EXPECTED_HEADER
|
||||
rows = table.locator("tbody tr")
|
||||
expect(rows).to_have_count(3)
|
||||
for i, cells in enumerate(EXPECTED_ROWS):
|
||||
assert rows.nth(i).locator("td").all_inner_texts() == cells
|
||||
assert (
|
||||
"|---|" not in page.locator("#doc-modal-content").inner_text()
|
||||
), "the separator row leaked into the viewer"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Fences win: the pipe-heavy fenced block is code, never a table
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fence_not_a_table(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=True)
|
||||
page.set_default_timeout(30_000)
|
||||
_open_tables_doc_modal(page, app_url)
|
||||
|
||||
# The fixture's ``` block (pipe table inside) renders as code —
|
||||
# fence protection runs before the table pass (story AC3).
|
||||
pre = page.locator("#doc-modal-content pre code")
|
||||
expect(pre).to_have_count(1)
|
||||
expect(pre).to_contain_text("caddy", timeout=30_000)
|
||||
code_text = pre.inner_text()
|
||||
assert "| Service | Port |" in code_text, "the fenced header line must stay raw"
|
||||
assert "|----------|------|" in code_text, "the fenced separator must stay raw"
|
||||
assert "| caddy | 80 |" in code_text
|
||||
assert "| gitlab | 8929 |" in code_text
|
||||
|
||||
# Exactly ONE table in the whole document — the real pipe table. The
|
||||
# fenced rows (lowercase "caddy"/"gitlab") must not become cells.
|
||||
table = page.locator("#doc-modal-content table.md-table")
|
||||
expect(table).to_have_count(1)
|
||||
cells = table.locator("th, td").all_inner_texts()
|
||||
assert "caddy" not in cells and "gitlab" not in cells, (
|
||||
"the fenced pipe block was parsed as a table"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Non-tables stay put: a lone pipe in grounded prose renders as text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_plain_pipe_stays_text(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=True)
|
||||
page.set_default_timeout(30_000)
|
||||
page.goto(app_url)
|
||||
page.fill("#message-input", PLAIN_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(MOCK_ANSWER_MARKER, timeout=30_000)
|
||||
# Grounded (the kubernetes FTS hit), not deflected — this is the
|
||||
# default-answer path, so the echoed question is what we assert on.
|
||||
expect(page.locator(".msg.brain.is-deflected")).to_have_count(0)
|
||||
|
||||
# A single "|" in prose is not a table (no header + separator pair).
|
||||
expect(bubble.locator("table")).to_have_count(0)
|
||||
expect(bubble.locator(".md-table-wrap")).to_have_count(0)
|
||||
assert "A lone | in prose stays text" in bubble.inner_text()
|
||||
@@ -123,11 +123,11 @@ def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
|
||||
|
||||
@pytest.fixture()
|
||||
def seeded_kb(mock_llm: int, db_ready: None) -> Iterator[None]:
|
||||
"""A fresh KB seeded from ``tests/fixtures/docs`` (8 docs, A9 formats),
|
||||
truncated again on teardown. ``db_ready`` (conftest) skips with clear
|
||||
instructions when Postgres is down."""
|
||||
"""A fresh KB seeded from ``tests/fixtures/docs`` (9 docs since phase
|
||||
44, A9 formats), truncated again on teardown. ``db_ready`` (conftest)
|
||||
skips with clear instructions when Postgres is down."""
|
||||
summary = _reset_db(mock_llm, seed=True)
|
||||
assert summary is not None and summary.added == 8
|
||||
assert summary is not None and summary.added == 9
|
||||
yield
|
||||
_reset_db(mock_llm, seed=False)
|
||||
|
||||
|
||||
@@ -87,9 +87,10 @@ def test_multi_format_import_hidden_doc_excluded(
|
||||
"""A9 (revised): all seven formats import; hidden (dot) paths never do."""
|
||||
summary = _reset_db(mock_llm, seed=True)
|
||||
assert summary is not None
|
||||
# Eight A9-format fixture files; .hidden/junk.md must never be walked.
|
||||
assert summary.added == 8
|
||||
assert summary.formats == {"md": 4, "yaml": 1, "json": 1, "py": 1, "txt": 1}
|
||||
# Nine A9-format fixture files (phase 44 added homelab/tables.md);
|
||||
# .hidden/junk.md must never be walked.
|
||||
assert summary.added == 9
|
||||
assert summary.formats == {"md": 5, "yaml": 1, "json": 1, "py": 1, "txt": 1}
|
||||
|
||||
# Phase 16: the catalog is admin-only — perform the real form login,
|
||||
# then call the API with the signed cookie the browser now holds.
|
||||
@@ -102,7 +103,7 @@ def test_multi_format_import_hidden_doc_excluded(
|
||||
r = httpx.get(f"{app_url}/api/docs", timeout=10, cookies=cookies)
|
||||
assert r.status_code == 200
|
||||
docs = r.json()["documents"]
|
||||
assert len(docs) == 8
|
||||
assert len(docs) == 9
|
||||
assert all(".hidden" not in d["path"] for d in docs)
|
||||
assert {d["path"] for d in docs} >= {
|
||||
"homelab/container_gitlab/gitlab.md",
|
||||
@@ -113,7 +114,7 @@ def test_multi_format_import_hidden_doc_excluded(
|
||||
}
|
||||
|
||||
# The Sources page (we're already on it, signed in) reflects the set.
|
||||
expect(page.locator("#stat-docs")).to_have_text("8")
|
||||
expect(page.locator("#stat-docs")).to_have_text("9")
|
||||
expect(page.locator("#docs-tbody tr", has_text=".hidden")).to_have_count(0)
|
||||
|
||||
|
||||
|
||||
@@ -154,11 +154,11 @@ def _no_error_banner(page: Page) -> None:
|
||||
|
||||
@pytest.fixture()
|
||||
def seeded_kb(mock_llm: int, db_ready: None) -> Iterator[None]:
|
||||
"""A fresh KB seeded from ``tests/fixtures/docs`` (8 docs, A9 formats),
|
||||
truncated again on teardown. ``db_ready`` (conftest) skips with clear
|
||||
instructions when Postgres is down."""
|
||||
"""A fresh KB seeded from ``tests/fixtures/docs`` (9 docs since phase
|
||||
44, A9 formats), truncated again on teardown. ``db_ready`` (conftest)
|
||||
skips with clear instructions when Postgres is down."""
|
||||
summary = _reset_db(mock_llm, seed=True)
|
||||
assert summary is not None and summary.added == 8
|
||||
assert summary is not None and summary.added == 9
|
||||
yield
|
||||
_reset_db(mock_llm, seed=False)
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ def test_tune_under_answer_persists_and_steers(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
summary = _reset_db(mock_llm, seed=True)
|
||||
assert summary is not None and summary.added == 8 # A9 formats
|
||||
assert summary is not None and summary.added == 9 # A9 formats (phase 44 added tables.md)
|
||||
page.set_default_timeout(30_000)
|
||||
page.goto(app_url)
|
||||
login(page, app_url, next="/") # phase 16: tuning is admin-only
|
||||
|
||||
@@ -63,7 +63,7 @@ def _seed_kb(mock_port: int) -> ImportSummary:
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||
db.commit()
|
||||
summary = _run_in_thread(_import_fixtures(mock_port))
|
||||
assert summary is not None and summary.added == 8 # A9 formats
|
||||
assert summary is not None and summary.added == 9 # A9 formats (phase 44 added tables.md)
|
||||
return summary
|
||||
|
||||
|
||||
|
||||
@@ -99,11 +99,11 @@ def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
|
||||
|
||||
@pytest.fixture()
|
||||
def seeded_kb(mock_llm: int, db_ready: None) -> Iterator[None]:
|
||||
"""A fresh KB seeded from ``tests/fixtures/docs`` (8 docs, A9 formats),
|
||||
truncated again on teardown. ``db_ready`` (conftest) skips with clear
|
||||
instructions when Postgres is down."""
|
||||
"""A fresh KB seeded from ``tests/fixtures/docs`` (9 docs since phase
|
||||
44, A9 formats), truncated again on teardown. ``db_ready`` (conftest)
|
||||
skips with clear instructions when Postgres is down."""
|
||||
summary = _reset_db(mock_llm, seed=True)
|
||||
assert summary is not None and summary.added == 8
|
||||
assert summary is not None and summary.added == 9
|
||||
yield
|
||||
_reset_db(mock_llm, seed=False)
|
||||
|
||||
|
||||
@@ -148,11 +148,11 @@ def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
|
||||
|
||||
@pytest.fixture()
|
||||
def seeded_kb(mock_llm: int, db_ready: None) -> Iterator[None]:
|
||||
"""A fresh KB seeded from ``tests/fixtures/docs`` (8 docs, A9 formats),
|
||||
truncated again on teardown (same fixture shape as the phase-17/21
|
||||
suites)."""
|
||||
"""A fresh KB seeded from ``tests/fixtures/docs`` (9 docs since phase
|
||||
44, A9 formats), truncated again on teardown (same fixture shape as
|
||||
the phase-17/21 suites)."""
|
||||
summary = _reset_db(mock_llm, seed=True)
|
||||
assert summary is not None and summary.added == 8
|
||||
assert summary is not None and summary.added == 9
|
||||
yield
|
||||
_reset_db(mock_llm, seed=False)
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ The oversized documents are seeded directly via SQLAlchemy (a
|
||||
``documents`` row + 2–3 ``chunks`` rows whose embeddings are the mock's
|
||||
own deterministic bag-of-words vectors, so the question's live mock
|
||||
embedding genuinely overlaps — no fixture files added:
|
||||
``tests/fixtures/docs/`` stays at its 8 files, other suites pin
|
||||
``summary.added == 8``).
|
||||
``tests/fixtures/docs/`` stays at its 9 files (phase 44), other suites
|
||||
pin ``summary.added == 9``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -301,7 +301,7 @@ def test_small_document_path_unchanged(
|
||||
path, byte-identical to before — no marker, kubernetes.md cited."""
|
||||
_reset_db(None)
|
||||
summary = _run_in_thread(_import_fixtures(mock_llm))
|
||||
assert summary.added == 8 # A9 formats (fixture set unchanged)
|
||||
assert summary.added == 9 # A9 formats (phase 44 added tables.md)
|
||||
|
||||
bubble = _ask(page, app_url, SMALL_QUESTION)
|
||||
expect(bubble).to_contain_text(SMALL_QUESTION, timeout=30_000)
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Service Port Table
|
||||
|
||||
The table below lists the services that answer on the homelab gateway:
|
||||
the service, the port it binds, and the host that runs it.
|
||||
|
||||
| Service | Port | Host |
|
||||
|---|---|---|
|
||||
| Caddy | 80 | homelab-gw |
|
||||
| GitLab | 8929 | homelab-git |
|
||||
| ntfy | 2087 | homelab-ntfy |
|
||||
|
||||
## Pipe characters inside fences
|
||||
|
||||
Fenced blocks keep their raw text — the pipe lines below are part of a
|
||||
config example, not a table:
|
||||
|
||||
```text
|
||||
| Service | Port |
|
||||
|----------|------|
|
||||
| caddy | 80 |
|
||||
| gitlab | 8929 |
|
||||
```
|
||||
@@ -47,7 +47,7 @@ def seeded_kb(db) -> Iterator[FakeRagLLM]:
|
||||
db.commit()
|
||||
llm = FakeRagLLM()
|
||||
summary = asyncio.run(import_sources([FIXTURES], llm, session=db))
|
||||
assert summary.added == 8 # A9 formats; .hidden/ skipped
|
||||
assert summary.added == 9 # A9 formats (phase 44 added tables.md); .hidden/ skipped
|
||||
yield llm
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||
db.commit()
|
||||
|
||||
@@ -138,7 +138,7 @@ def seeded_kb(db) -> Iterator[FakeRagLLM]:
|
||||
db.commit()
|
||||
llm = FakeRagLLM()
|
||||
summary = asyncio.run(import_sources([FIXTURES], llm, session=db))
|
||||
assert summary.added == 8 # A9 formats; .hidden/ skipped
|
||||
assert summary.added == 9 # A9 formats (phase 44 added tables.md); .hidden/ skipped
|
||||
yield llm
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||
db.commit()
|
||||
|
||||
@@ -27,6 +27,7 @@ EXPECTED_DOCS = {
|
||||
("docs", "homelab/networking/static-dns.json"),
|
||||
("docs", "homelab/scripts/uptime_probe.py"),
|
||||
("docs", "homelab/ssh/ssh_aliases.txt"),
|
||||
("docs", "homelab/tables.md"), # phase 44: the markdown-tables fixture
|
||||
}
|
||||
|
||||
|
||||
@@ -36,12 +37,13 @@ def test_import_fixtures_end_to_end(admin_client, db) -> None:
|
||||
llm = FakeEmbedder()
|
||||
|
||||
summary = asyncio.run(import_sources([FIXTURES], llm, session=db))
|
||||
# Eight A9-format files; .hidden/junk.md is out of scope (A9 revised).
|
||||
assert (summary.files, summary.added, summary.unchanged) == (8, 8, 0)
|
||||
assert summary.chunks >= 8
|
||||
assert summary.formats == {"md": 4, "yaml": 1, "json": 1, "py": 1, "txt": 1}
|
||||
# Nine A9-format files (phase 44 added homelab/tables.md);
|
||||
# .hidden/junk.md is out of scope (A9 revised).
|
||||
assert (summary.files, summary.added, summary.unchanged) == (9, 9, 0)
|
||||
assert summary.chunks >= 9
|
||||
assert summary.formats == {"md": 5, "yaml": 1, "json": 1, "py": 1, "txt": 1}
|
||||
# PLAN §9 per-format summary line: highest count first, then alpha.
|
||||
assert summary.format_counts() == "md:4,json:1,py:1,txt:1,yaml:1"
|
||||
assert summary.format_counts() == "md:5,json:1,py:1,txt:1,yaml:1"
|
||||
|
||||
docs = db.scalars(select(Document)).all()
|
||||
assert {(d.source, d.path) for d in docs} == EXPECTED_DOCS
|
||||
@@ -90,13 +92,13 @@ def test_import_fixtures_end_to_end(admin_client, db) -> None:
|
||||
r = admin_client.get("/api/docs") # phase 16: the catalog is admin-only
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert len(body["documents"]) == 8
|
||||
assert len(body["documents"]) == 9
|
||||
assert all(d["chunks"] >= 1 for d in body["documents"])
|
||||
|
||||
# Idempotent re-run: nothing re-embedded.
|
||||
calls_before = len(llm.calls)
|
||||
s2 = asyncio.run(import_sources([FIXTURES], llm, session=db))
|
||||
assert s2.unchanged == 8 and s2.added == 0
|
||||
assert s2.unchanged == 9 and s2.added == 0
|
||||
assert len(llm.calls) == calls_before # unchanged → no embedding requests
|
||||
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||
|
||||
@@ -67,7 +67,7 @@ def seeded_kb(db) -> Iterator[FakeRagLLM]:
|
||||
db.commit()
|
||||
llm = FakeRagLLM()
|
||||
summary = asyncio.run(import_sources([FIXTURES], llm, session=db))
|
||||
assert summary.added == 8 # A9 formats; .hidden/ skipped
|
||||
assert summary.added == 9 # A9 formats (phase 44 added tables.md); .hidden/ skipped
|
||||
yield llm
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log, kb_overview"))
|
||||
db.commit()
|
||||
|
||||
@@ -51,7 +51,7 @@ def seeded_kb(db) -> Iterator[FakeRagLLM]:
|
||||
db.commit()
|
||||
llm = FakeRagLLM()
|
||||
summary = asyncio.run(import_sources([FIXTURES], llm, session=db))
|
||||
assert summary.added == 8 # A9 formats; .hidden/ skipped
|
||||
assert summary.added == 9 # A9 formats (phase 44 added tables.md); .hidden/ skipped
|
||||
yield llm
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
|
||||
db.commit()
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
"""Unit: the shared renderer's GFM pipe-table pass (phase 44, task 01).
|
||||
|
||||
Browser behavior is E2E-covered by ``tests/e2e/test_markdown_tables.py``
|
||||
(task 03); here we pin the source-level contract — the pass ordering
|
||||
(fences → tables → escape), the escape-first cell pipeline, the exact
|
||||
output markers, the placeholder restore, and the ``.md-table`` /
|
||||
``.md-table-wrap`` CSS rules — 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-10
|
||||
renderer check in ``test_document_viewer.py``) proves the four story
|
||||
acceptance shapes: table, XSS cell, fence-wins, non-tables stay text.
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
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 table-protection pass ----------
|
||||
|
||||
|
||||
def test_table_pass_runs_after_fences_and_before_escape() -> None:
|
||||
"""The renderer's pass order is fences → tables → escape (story AC3:
|
||||
a |-heavy fenced block is protected before the table pass ever sees
|
||||
it, so fences win). Pinned by the placeholder-emission order in the
|
||||
source: the \\u0000CODEn\\u0000 push (step 1) precedes the
|
||||
\\u0000TABLEn\\u0000 push (step 1b), which precedes step 2's
|
||||
escape of the whole text."""
|
||||
js = _text(MARKDOWN_JS)
|
||||
fence_emit = r"\u0000CODE${codeBlocks.length - 1}\u0000"
|
||||
table_emit = r"\u0000TABLE${tables.length - 1}\u0000"
|
||||
assert fence_emit in js, "the fence pass keeps its CODE placeholder"
|
||||
assert table_emit in js, "the table pass emits a TABLE placeholder"
|
||||
assert js.find(fence_emit) < js.find(table_emit), (
|
||||
"the fence pass must run BEFORE the table pass (fences win)"
|
||||
)
|
||||
assert "escapeHtml(text)" in js, "step 2 still escapes the whole text"
|
||||
assert js.find(table_emit) < js.find("escapeHtml(text)"), (
|
||||
"the table pass must run BEFORE the escape pass (cells are escaped per-cell)"
|
||||
)
|
||||
|
||||
|
||||
def test_table_detection_requires_pipe_header_then_separator() -> None:
|
||||
"""Detection (per the phase spec): a table block starts at a line
|
||||
containing '|' whose NEXT line is a separator — >=1 pipe-separated
|
||||
cells, each ^\\s*:?-+:?\\s*$ (leading/trailing pipes allowed) — and
|
||||
extends over every following line that still contains '|'; the
|
||||
maximal run is one table. Everything else is left untouched."""
|
||||
js = _text(MARKDOWN_JS)
|
||||
# the separator cell rule (alignment colons accepted, then ignored)
|
||||
assert r"^\s*:?-+:?\s*$" in js, "the separator cell rule ^\\s*:?-+:?\\s*$ is missing"
|
||||
# a table starts at a '|' line whose NEXT line is the separator
|
||||
assert 'line.includes("|")' in js, "the header line must contain '|'"
|
||||
assert "isSeparator(tableLines[pos + 1])" in js, (
|
||||
"the NEXT line after the '|' line must be the separator"
|
||||
)
|
||||
# the body extends over every following line that still contains '|'
|
||||
assert 'tableLines[end].includes("|")' in js, (
|
||||
"the table body must extend over every following line containing '|'"
|
||||
)
|
||||
# zero body rows is valid — body = the lines after header + separator
|
||||
assert "slice(2)" in js, "body rows are the lines after header + separator"
|
||||
|
||||
|
||||
def test_cells_are_escaped_then_inline_transformed() -> None:
|
||||
"""Escape-first is the renderer invariant: each cell (header AND
|
||||
body) goes through escapeHtml() BEFORE the inline transforms, and
|
||||
the inline chain is the SAME factored helper the whole-text path
|
||||
uses — so cells keep `code` / **bold** / *em* exactly like prose."""
|
||||
js = _text(MARKDOWN_JS)
|
||||
assert js.count("inline(escapeHtml(c))") >= 2, (
|
||||
"header and body cells must both run inline(escapeHtml(cell))"
|
||||
)
|
||||
# the whole-text path runs the same helper over the escaped text
|
||||
assert "text = inline(escapeHtml(text));" in js, (
|
||||
"step 2 must reuse the SAME factored inline chain"
|
||||
)
|
||||
# the helper carries the exact original .replace chain
|
||||
helper_start = js.find("const inline = (s) =>")
|
||||
assert helper_start != -1, "the inline chain must be factored into a local helper"
|
||||
helper = js[helper_start : js.find(";", js.find("gm", helper_start)) + 1]
|
||||
for marker in (
|
||||
r"/`([^`\n]+)`/g",
|
||||
r"\*\*([^*]+)\*\*",
|
||||
r"(^|[\s(])\*([^*\n]+)\*",
|
||||
r"/^### (.*)$/gm",
|
||||
r"/^## (.*)$/gm",
|
||||
r"/^# (.*)$/gm",
|
||||
r"/^\s*[-*] (.*)$/gm",
|
||||
r"(<li>[\s\S]*?<\/li>)(?!\s*<li>)",
|
||||
r"/^\d+\. (.*)$/gm",
|
||||
):
|
||||
assert marker in helper, f"the factored helper lost the {marker!r} step"
|
||||
assert js.count("replace(/`([^`\\n]+)`/g") == 1, (
|
||||
"the code-span replace must live ONLY in the factored helper"
|
||||
)
|
||||
|
||||
|
||||
def test_table_output_markers_and_wrap() -> None:
|
||||
"""The exact output shape the story pins: a semantic table inside a
|
||||
horizontal-overflow wrapper — <div class=\"md-table-wrap\"> /
|
||||
<table class=\"md-table\"> / <thead> with th scope=\"col\" headers /
|
||||
<tbody> body rows (a header-only table omits the empty <tbody>)."""
|
||||
js = _text(MARKDOWN_JS)
|
||||
assert '<div class="md-table-wrap">' in js
|
||||
assert '<table class="md-table">' in js
|
||||
assert "<thead><tr>" in js
|
||||
assert '<th scope="col">' in js, "headers must carry scope=\"col\""
|
||||
assert "</thead>" in js and "</table>" in js and "</div>" in js
|
||||
assert "<tbody>" in js and "</tbody>" in js
|
||||
assert "<td>" in js and "</tr>" in js
|
||||
# header-only tables: the tbody is conditional on having body rows
|
||||
assert re.search(r"body\s*\?", js), "a header-only table must skip the <tbody>"
|
||||
# the assembly order inside the push template: wrap, table, thead,
|
||||
# (tbody), close — pinned on the quoted source literals (the header
|
||||
# comment mentions the shapes out of order on purpose)
|
||||
wrap_lit = "'<div class=\"md-table-wrap\">\\n'"
|
||||
table_lit = "'<table class=\"md-table\">\\n'"
|
||||
assert js.find(wrap_lit) != -1, "the template must open the .md-table-wrap div"
|
||||
assert js.find(table_lit) != -1, "the template must open the table inside the wrap"
|
||||
assert js.find(wrap_lit) < js.find(table_lit), "the wrap opens before the table"
|
||||
assert '</table>\\n</div>' in js, "the template closes the table, then the wrap"
|
||||
|
||||
|
||||
def test_header_and_body_rows_pad_and_truncate_to_header_width() -> None:
|
||||
"""Defensive ragged rows: short rows are padded with empty <td>s,
|
||||
long rows are truncated to the header width (mock and real answers
|
||||
are well-formed; this only guards the parser)."""
|
||||
js = _text(MARKDOWN_JS)
|
||||
assert re.search(r"cells\.push\(\"\"\)", js), "short rows must be padded"
|
||||
assert re.search(r"slice\(0,\s*width\)", js), "long rows must be truncated"
|
||||
assert "const width = header.length;" in js, "the header row sets the column count"
|
||||
|
||||
|
||||
def test_placeholder_restored_alongside_code_blocks() -> None:
|
||||
"""Step 4 is the ONLY place placeholders re-expand — the TABLE
|
||||
placeholders are restored there, next to the CODE ones, so the
|
||||
final table HTML never re-enters the escape/paragraph passes."""
|
||||
js = _text(MARKDOWN_JS)
|
||||
fn = js.find("function renderMarkdown")
|
||||
assert fn != -1
|
||||
body = js[fn:]
|
||||
# step 4 is the ONLY restore point and it restores BOTH placeholder
|
||||
# kinds, indexing their arrays
|
||||
assert r"\u0000TABLE(\d+)\u0000/g" in body, (
|
||||
"step 4 must restore the \\u0000TABLEn\\u0000 placeholders"
|
||||
)
|
||||
assert "tables[Number(i)]" in body, "restoration must index the tables array"
|
||||
assert r"\u0000CODE(\d+)\u0000/g" in body, "the CODE restore stays in step 4"
|
||||
# the TABLE restore is AFTER the paragraph pass — the restored final
|
||||
# HTML never re-enters it
|
||||
assert body.find(r"\u0000TABLE(\d+)\u0000/g") > body.find("split(/\\n{2,}/)"), (
|
||||
"placeholders are restored AFTER the paragraph pass"
|
||||
)
|
||||
|
||||
|
||||
def test_alignment_colons_are_parsed_but_ignored() -> None:
|
||||
"""Owner decision (2026-08-27): the separator's alignment colons
|
||||
match the cell rule (so ':---:' is a valid separator) but are never
|
||||
turned into CSS alignment — the CSS pins every cell to left."""
|
||||
js = _text(MARKDOWN_JS)
|
||||
assert r"^\s*:?-+:?\s*$" in js, "colons must be accepted in separators"
|
||||
css = _text(STYLES_CSS)
|
||||
th_td = re.search(r"\.md-table th, .md-table td \{([^}]*)\}", css)
|
||||
assert th_td, "styles.css must style .md-table th/td"
|
||||
assert "text-align: left" in th_td.group(1), "all cells render left-aligned"
|
||||
|
||||
|
||||
def test_header_comment_notes_the_table_pass() -> None:
|
||||
"""The file's header comment records the extension (2026-08-27,
|
||||
TODO.md L6) — the ~60-line no-CDN renderer now also does tables."""
|
||||
header = _text(MARKDOWN_JS).split("*/", 1)[0]
|
||||
assert "Phase 44" in header
|
||||
assert "2026-08-27" in header
|
||||
assert "TODO.md L6" in header
|
||||
assert re.search(r"table", header, re.I), "the header comment must mention tables"
|
||||
assert "no CDN" in header, "the no-CDN contract stays stated (A11)"
|
||||
|
||||
|
||||
# ---------- styles.css: the .md-table / .md-table-wrap rules ----------
|
||||
|
||||
|
||||
def test_table_wrap_is_the_horizontal_scroller() -> None:
|
||||
""".md-table-wrap { overflow-x: auto } — the wrapper (not the
|
||||
table) is the scroller, so a wide table scrolls inside the bubble
|
||||
instead of breaking the 46rem column (story AC5)."""
|
||||
css = _text(STYLES_CSS)
|
||||
block = re.search(r"\.md-table-wrap\s*\{([^}]*)\}", css)
|
||||
assert block, "styles.css must define .md-table-wrap"
|
||||
assert re.search(r"overflow-x:\s*auto", block.group(1)), (
|
||||
"the wrapper must scroll horizontally"
|
||||
)
|
||||
|
||||
|
||||
def test_table_rule_and_cell_borders_use_the_palette() -> None:
|
||||
""".md-table is border-collapse, full-width (min-width: max-content
|
||||
keeps a wide table at its natural width so the wrap scrolls — phase
|
||||
44 task 03), 0.9rem; th/td carry the --line 1px borders, 0.4rem
|
||||
0.6rem padding, left alignment, top vertical alignment; the thead is
|
||||
tinted from the PLAIN surface family (PLAN §7.2: --ink on --surface
|
||||
is 14.5:1) — never the brand tokens."""
|
||||
css = _text(STYLES_CSS)
|
||||
table = re.search(r"(?<![-\w])\.md-table\s*\{([^}]*)\}", css)
|
||||
assert table, "styles.css must define .md-table"
|
||||
body = table.group(1)
|
||||
assert re.search(r"border-collapse:\s*collapse", body)
|
||||
assert re.search(r"width:\s*100%", body), "narrow tables stretch to the column"
|
||||
assert re.search(r"min-width:\s*max-content", body), (
|
||||
"wide tables must keep their natural width so .md-table-wrap scrolls"
|
||||
)
|
||||
assert re.search(r"font-size:\s*0\.9rem", body)
|
||||
cells = re.search(r"\.md-table th, .md-table td\s*\{([^}]*)\}", css)
|
||||
assert cells, "styles.css must style .md-table th and td"
|
||||
cell_body = cells.group(1)
|
||||
assert re.search(r"border:\s*1px solid var\(--line\)", cell_body), (
|
||||
"cells must carry the quiet --line hairline borders"
|
||||
)
|
||||
assert re.search(r"padding:\s*0\.4rem 0\.6rem", cell_body)
|
||||
assert re.search(r"text-align:\s*left", cell_body)
|
||||
assert re.search(r"vertical-align:\s*top", cell_body)
|
||||
thead = re.search(r"\.md-table thead th\s*\{([^}]*)\}", css)
|
||||
assert thead, "styles.css must tint the thead cells"
|
||||
thead_body = thead.group(1)
|
||||
assert "var(--surface)" in thead_body, "thead tint is the plain surface family"
|
||||
assert "var(--ink)" in thead_body, "--ink on --surface is 14.5:1 (>= 4.5:1)"
|
||||
assert "--brand" not in thead_body, "never the brand tokens on the thead"
|
||||
|
||||
|
||||
def test_table_rules_carry_no_motion() -> None:
|
||||
"""Reduced-motion consistency: the table rules are static content —
|
||||
no animation/transition for prefers-reduced-motion to still (the
|
||||
phase-44 comment states this, like the phase-08/22/36 static
|
||||
components)."""
|
||||
css = _text(STYLES_CSS)
|
||||
rules = (
|
||||
r"\.md-table-wrap",
|
||||
r"(?<![-\w])\.md-table",
|
||||
r"\.md-table th, .md-table td",
|
||||
r"\.md-table thead th",
|
||||
)
|
||||
for name in rules:
|
||||
block = re.search(name + r"\s*\{([^}]*)\}", css)
|
||||
assert block, f"styles.css must define {name}"
|
||||
assert "animation" not in block.group(1), f"{name} must not animate"
|
||||
assert "transition" not in block.group(1), f"{name} must not transition"
|
||||
assert re.search(r"nothing for\s+prefers-reduced-motion to still", css), (
|
||||
"the table block comment must note there is nothing to still"
|
||||
)
|
||||
|
||||
|
||||
def test_table_css_lives_with_the_bubble_markdown_rules() -> None:
|
||||
"""Placement (task 01 step 2): the table rules sit with the
|
||||
markdown/content styling — right after the .bubble code rules,
|
||||
before the .msg.user block — and are unscoped so the shared
|
||||
renderer's tables in the thinking scratchpad and the document
|
||||
viewer (.doc-md) get the same treatment."""
|
||||
css = _text(STYLES_CSS)
|
||||
assert css.find('.bubble pre code {') < css.find(".md-table-wrap"), (
|
||||
"the table rules follow the bubble markdown content rules"
|
||||
)
|
||||
assert css.find(".md-table thead th") < css.find(".msg.user {"), (
|
||||
"the table rules stay in the Messages section"
|
||||
)
|
||||
# no .bubble-scoped table variant: one rule set serves all consumers
|
||||
assert re.search(r"\.bubble\s+\.md-table", css) is None, (
|
||||
"the table rules must be unscoped (shared renderer consumers)"
|
||||
)
|
||||
|
||||
|
||||
# ---------- behavior (node): the story's acceptance shapes ----------
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_table_renders_semantic_xss_safe_and_fences_win() -> None:
|
||||
"""The four story shapes executed under node: (1) a pipe table with
|
||||
leading/trailing pipes + inline markdown in cells renders the
|
||||
<div class=\"md-table-wrap\"><table class=\"md-table\"> shape with
|
||||
<th scope=\"col\"> headers and padded ragged rows, and NO raw
|
||||
separator line survives; (2) a cell with an HTML tag is escaped;
|
||||
(3) a |-heavy fenced block stays <pre><code>; (4) a lone pipe in
|
||||
prose, a separator without a header, and a 1-line 'table' stay
|
||||
text."""
|
||||
js = _text(MARKDOWN_JS)
|
||||
cases = {
|
||||
"table": (
|
||||
"Here:\n\n| Service | Port |\n|:---|---:|\n| **api** | 8000 |\n| web |",
|
||||
[
|
||||
'<div class="md-table-wrap">',
|
||||
'<table class="md-table">',
|
||||
"<thead><tr><th scope=\"col\">Service</th><th scope=\"col\">Port</th></tr></thead>",
|
||||
"<tbody><tr><td><strong>api</strong></td><td>8000</td></tr>"
|
||||
"<tr><td>web</td><td></td></tr></tbody>",
|
||||
"</table>\n</div>",
|
||||
],
|
||||
["|:---|---:|", "| **api** |"],
|
||||
),
|
||||
"xss": (
|
||||
"| c |\n|---|\n| <img src=x onerror=alert(1)> & co |",
|
||||
["<img src=x onerror=alert(1)> & co"],
|
||||
["<img"],
|
||||
),
|
||||
"fence": (
|
||||
"```\n| a | b |\n|---|---|\n| 1 | 2 |\n```",
|
||||
["<pre><code>| a | b |"],
|
||||
["<table"],
|
||||
),
|
||||
"not_tables": (
|
||||
"a | b prose\n\n---\n\n| only a line\n\nend |---|",
|
||||
["<p>a | b prose</p>", "<p>---</p>", "<p>| only a line</p>", "<p>end |---|</p>"],
|
||||
["<table"],
|
||||
),
|
||||
}
|
||||
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) in cases.items():
|
||||
html = results[name]
|
||||
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