Spaces:
Sleeping
Sleeping
File size: 11,013 Bytes
ef99e21 3c5f882 ef99e21 3c5f882 ef99e21 3c5f882 e0198de 3c5f882 ef99e21 e0198de 3c5f882 ef99e21 e0198de ef99e21 3c5f882 e0198de 3c5f882 e0198de 3c5f882 e0198de 3c5f882 e0198de ef99e21 3c5f882 ef99e21 3c5f882 e0198de ef99e21 3c5f882 e0198de ef99e21 3c5f882 ef99e21 3c5f882 ef99e21 3c5f882 ef99e21 3c5f882 ef99e21 3c5f882 ef99e21 3c5f882 ef99e21 e0198de ef99e21 e0198de ef99e21 e0198de ef99e21 3c5f882 ef99e21 3c5f882 ef99e21 3c5f882 ef99e21 3c5f882 ef99e21 3c5f882 ef99e21 3c5f882 ef99e21 e0198de 3c5f882 ef99e21 3c5f882 ef99e21 3c5f882 ef99e21 e0198de ef99e21 e0198de ef99e21 3c5f882 ef99e21 3c5f882 ef99e21 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 | /**
* Regression tests for progressive (streaming) markdown rendering.
*
* Run with: npm test (node --test, no extra dev deps)
*
* The invariant under test: at EVERY prefix of the stream, the visible output
* must contain no raw markdown syntax — no `|---|` separators, no `**`, no
* `#` heading markers, no ``` fences — and the final frame must contain the
* same structure as the completed-message render.
*
* Frames are rendered through the REAL production parser (react-markdown +
* remark-gfm, via react-dom/server), so these tests exercise exactly what the
* browser shows, not a simulation.
*/
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import {
extractOpenCodeFence,
repairMarkdownTables,
splitStreaming,
withoutOpenCodeFence,
withoutOpenMathBlock,
withoutNascentTable,
isTableSeparatorLine,
cleanStreamTail,
} from "./streamRender.js";
// ---------- helpers ----------
/** Render markdown exactly like Message.jsx's <Markdown> does. */
function renderMd(md) {
return renderToStaticMarkup(
React.createElement(ReactMarkdown, {
remarkPlugins: [remarkGfm, [remarkMath, { singleDollarTextMath: false }]],
rehypePlugins: [[rehypeKatex, { strict: false, throwOnError: false }]],
}, repairMarkdownTables(md)),
);
}
/** One streaming frame as HTML: committed markdown + sanitized tail. */
function renderFrame(buf) {
const { thinking, committed, tail, liveCode } = splitStreaming(buf);
if (thinking) return "<thinking/>";
return (
(committed ? renderMd(committed) : "") +
(liveCode ? `<pre class="stream-code"><code>${escapeHtml(liveCode.code)}</code></pre>` : "") +
(tail ? `<div class="stream-tail">${tail}</div>` : "")
);
}
function escapeHtml(value) {
return String(value || "")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
}
/** Visible text of a rendered frame: strip all HTML tags. */
function visibleText(html) {
return html.replace(/<[^>]+>/g, "");
}
/** Assert no raw markdown syntax is visible at ANY prefix of `answer`.
* Rendered code blocks are exempt: characters like ** or | inside
* <pre><code> are literal code content, not leaked markdown syntax.
* (The old regex renderer even bolded text inside code blocks — a bug
* react-markdown fixes, which is why the exemption is needed now.) */
function assertNoLeaksAtEveryPrefix(answer) {
for (let i = 1; i <= answer.length; i++) {
const frame = renderFrame(answer.slice(0, i))
.replace(/<pre(?:\s[^>]*)?>[\s\S]*?<\/pre>/g, "");
const v = visibleText(frame);
assert.ok(!/\|\s*:?-{3,}/.test(v), `frame ${i}: leaked table separator:\n${v}`);
assert.ok(!v.includes("**"), `frame ${i}: leaked ** stars:\n${v}`);
assert.ok(!/(^|\n)\s*#{1,6}\s/.test(v), `frame ${i}: leaked # heading marker:\n${v}`);
assert.ok(!v.includes("```"), `frame ${i}: leaked \`\`\` fence:\n${v}`);
assert.ok(!v.includes("$$"), `frame ${i}: leaked $$ math fence:\n${v}`);
}
}
/** The completed-message render must contain the expected structural tags. */
function assertFinalHas(answer, tags) {
const done = renderMd(answer);
for (const t of tags) {
assert.ok(done.includes(t), `completed render missing ${t}:\n${done}`);
}
}
// ---------- tables ----------
const TABLE = [
"Here is the comparison.",
"",
"| Aspect | Roth IRA | Traditional IRA |",
"|--------|----------|-----------------|",
"| **Tax now** | After-tax | Pre-tax |",
"| Withdrawals | Tax-free | Taxed |",
].join("\n");
test("table: no raw pipes/stars at any prefix", () => {
assertNoLeaksAtEveryPrefix(TABLE);
for (let i = 1; i <= TABLE.length; i++) {
const v = visibleText(renderFrame(TABLE.slice(0, i)));
assert.ok(!v.includes("Aspect | Roth"), `frame ${i}: raw header row visible`);
}
});
test("table: renders as <table> once separator lands, then grows row by row", () => {
const sepEnd = TABLE.indexOf("\n", TABLE.indexOf("|----"));
const after = renderFrame(TABLE.slice(0, sepEnd + 2));
assert.ok(after.includes("<table>"), "table absent right after separator");
const before = renderFrame(TABLE.slice(0, TABLE.indexOf("|----") - 1));
assert.ok(!before.includes("<table>"));
assert.ok(!visibleText(before).includes("|"));
});
test("table: completed render intact", () => {
assertFinalHas(TABLE, ["<table>", "<thead>", "<strong>"]);
});
// ---------- code fences ----------
const CODE = [
"Use this snippet:",
"",
"```python",
"x = compute(1) # comment with **stars** and |pipes|",
"print(x)",
"```",
"",
"Done.",
].join("\n");
test("code fence: body streams inside a live code block without raw fences", () => {
assertNoLeaksAtEveryPrefix(CODE);
const openIdx = CODE.indexOf("print(x)") + 4; // mid-body
const html = renderFrame(CODE.slice(0, openIdx));
const v = visibleText(html);
assert.ok(html.includes('class="stream-code"'), "live code block absent while fence open");
assert.ok(v.includes("compute"), "code body did not stream inside live code block");
assert.ok(!v.includes("```"), "raw code fence visible while streaming");
const closeIdx = CODE.indexOf("```", CODE.indexOf("python")) + 4;
const closed = renderFrame(CODE.slice(0, closeIdx));
assert.ok(closed.includes("<pre>"), "closed fence did not render");
});
test("code fence: completed render intact", () => {
assertFinalHas(CODE, ["<pre>", "<code"]);
});
// ---------- lists ----------
const LISTS = [
"Two options:",
"",
"- **Roth**: after-tax money",
"- **Traditional**: pre-tax money",
"",
"Steps:",
"",
"1. Open the account",
"2. Fund it with `cash`",
"3. Invest",
].join("\n");
test("lists: no raw markers at any prefix; items appear as lines complete", () => {
assertNoLeaksAtEveryPrefix(LISTS);
const afterFirstItem = LISTS.indexOf("money") + 6;
const html = renderFrame(LISTS.slice(0, afterFirstItem));
assert.ok(html.includes("<ul>"), "first completed bullet did not render");
});
test("lists: completed render intact", () => {
assertFinalHas(LISTS, ["<ul>", "<ol>", "<strong>", "<code>"]);
});
// ---------- headings ----------
const HEADINGS = [
"# Title",
"",
"## Section **one**",
"",
"Body text here.",
].join("\n");
test("headings: marker never visible; renders as <h*> when line completes", () => {
assertNoLeaksAtEveryPrefix(HEADINGS);
const afterH1 = HEADINGS.indexOf("\n") + 2;
assert.ok(renderFrame(HEADINGS.slice(0, afterH1)).includes("<h1>"));
});
test("headings: completed render intact", () => {
assertFinalHas(HEADINGS, ["<h1>", "<h2>"]);
});
// ---------- previously-unsupported syntax, now real ----------
test("links render as real <a> anchors", () => {
assertFinalHas("See [the docs](https://example.com) for more.", ['<a href="https://example.com"']);
});
test("blockquotes render as <blockquote>", () => {
assertFinalHas("> Price is what you pay.\n\nWisdom.", ["<blockquote>"]);
});
test("horizontal rules render as <hr>", () => {
assertFinalHas("Part one.\n\n---\n\nPart two.", ["<hr"]);
});
test("strikethrough renders as <del> (GFM)", () => {
assertFinalHas("This is ~~wrong~~ right.", ["<del>"]);
});
test("task lists render as checkboxes (GFM)", () => {
assertFinalHas("- [ ] Choose a broker\n- [x] Open account", ['type="checkbox"']);
});
// ---------- math ----------
const MATH = [
"The score is:",
"",
"$$",
"UCB = \\bar{x} + c \\sqrt{\\frac{2\\ln N}{n}}",
"$$",
"",
"Then pick the largest score.",
].join("\n");
test("math block: no raw $$ while streaming; completed render uses KaTeX", () => {
assertNoLeaksAtEveryPrefix(MATH);
const mid = MATH.indexOf("\\frac") + 5;
const during = renderFrame(MATH.slice(0, mid));
assert.ok(!visibleText(during).includes("$$"), "raw math fence visible while open");
const done = renderMd(MATH);
assert.ok(done.includes("katex-display"), "completed math block did not render with KaTeX");
});
test("inline code: backticks stripped mid-line, rendered when complete", () => {
const mid = visibleText(renderFrame("Use `npm ru"));
assert.ok(!mid.includes("`"));
assertFinalHas("Use `npm run build` to compile.", ["<code>npm run build</code>"]);
});
test("single-star italic: no lone star flash on the in-progress line", () => {
const v = visibleText(renderFrame("emphasis on *ke"));
assert.ok(!v.includes("*"), `lone star visible: ${v}`);
assertFinalHas("emphasis on *key terms* here", ["<em>"]);
});
// ---------- unit tests for the helpers ----------
test("withoutOpenCodeFence: hides only an unterminated fence", () => {
assert.equal(withoutOpenCodeFence("a\n```js\ncode"), "a");
assert.equal(withoutOpenCodeFence("a\n```js\ncode\n```"), "a\n```js\ncode\n```");
assert.equal(withoutOpenCodeFence("no fences"), "no fences");
});
test("extractOpenCodeFence: returns live code body and stable prefix", () => {
assert.deepEqual(extractOpenCodeFence("a\n```js\ncode", "tail"), {
before: "a",
language: "js",
code: "code\ntail",
});
assert.equal(extractOpenCodeFence("a\n```js\ncode\n```"), null);
});
test("withoutOpenMathBlock: hides only an unterminated display math block", () => {
assert.equal(withoutOpenMathBlock("a\n$$\nx"), "a");
assert.equal(withoutOpenMathBlock("a\n$$\nx\n$$"), "a\n$$\nx\n$$");
});
test("withoutNascentTable: hides header-only, keeps valid table", () => {
assert.equal(withoutNascentTable("x\n| a | b |"), "x");
const valid = "x\n| a | b |\n|---|---|";
assert.equal(withoutNascentTable(valid), valid);
assert.equal(withoutNascentTable("plain"), "plain");
});
test("repairMarkdownTables: converts malformed pipe blocks into GFM tables", () => {
const broken = "x\n| a | b |\n| 1 | 2 |\ny";
const repaired = repairMarkdownTables(broken);
assert.equal(repaired, "x\n| a | b |\n|---|---|\n| 1 | 2 |\ny");
assert.ok(renderMd(broken).includes("<table>"));
});
test("isTableSeparatorLine", () => {
assert.ok(isTableSeparatorLine("|---|---|"));
assert.ok(isTableSeparatorLine("| :--- | ---: |"));
assert.ok(!isTableSeparatorLine("| a | b |"));
assert.ok(!isTableSeparatorLine("---"));
});
test("cleanStreamTail: hides table rows, strips inline markers", () => {
assert.equal(cleanStreamTail("| a | b"), "");
assert.equal(cleanStreamTail("## Head"), "Head");
assert.equal(cleanStreamTail("some **bo"), "some bo");
assert.equal(cleanStreamTail("a *wor"), "a wor");
assert.equal(cleanStreamTail("> quo"), "quo");
assert.equal(cleanStreamTail("run `np"), "run np");
});
// ---------- spinner ----------
test("empty buffer reports thinking state", () => {
assert.equal(splitStreaming("").thinking, true);
assert.equal(splitStreaming(" ").thinking, true);
assert.equal(splitStreaming("hi").thinking, false);
});
|