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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;");
}

/** 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);
});