Files
deer-flow/frontend/tests/unit/core/streamdown/preprocess.test.ts
T
Eilen Shin 25fbd25b05 fix(frontend): cap deeply nested list indentation to prevent render crash (#3393) (#3570)
* fix(frontend): cap deeply nested list indentation to prevent render crash

Deeply nested lists make marked's recursive list tokenizer overflow the
call stack during Streamdown's lexing useMemo, throwing an uncaught
"RangeError: Maximum call stack size exceeded" that replaces the chat
route with an error page (issue #3393); on larger stacks the same input
exhausts the heap, which the render error boundary cannot catch.

Mirror the existing capBlockquoteNesting guard with capListNesting, which
clamps leading whitespace to 200 columns (~100 nesting levels) only when
pathologically deep indentation is present, leaving normal content and
fenced code untouched. Wire both through capMarkdownNesting.

* fix(frontend): satisfy prettier format check in preprocess test

* fix(frontend): exempt indented code from list-indent cap (PR #3570 review)

* fix(frontend): keep capping all deep indentation outside fenced code

Revert the indented-code exemption from the PR #3570 review nit. Taken
literally the suggested guard (insideFence || INDENTED_CODE_RE.test(line))
no-ops capListNesting, because INDENTED_CODE_RE matches every line with
4+ leading spaces — i.e. exactly the deep-indent lines the cap targets.
A context-aware exemption (only treat 4+-space lines as code after a
blank line) instead reopens the crash: blank-separated deeply nested list
items get exempted and still blow up marked (verified: OOM at depth ~1.5k).

Unlike blockquotes (markers take <=3 leading spaces, so deep-quote lines
never look like indented code), list vs. indented-code indentation is
ambiguous line-by-line, so any exemption is exploitable. Keep capping all
deep indentation outside fenced code; the only cost is mild corruption of
a >200-column indented-code line, which never occurs in real content and
is strictly preferable to a render crash. Add a regression test locking
the blank-line case.
2026-06-14 22:19:54 +08:00

107 lines
4.1 KiB
TypeScript

import { expect, test } from "vitest";
import {
capBlockquoteNesting,
capListNesting,
capMarkdownNesting,
preprocessStreamdownMarkdown,
} from "@/core/streamdown/preprocess";
test("capBlockquoteNesting returns normal content unchanged", () => {
const input = "# Title\n\n> a quote\n>> nested\n\nsome `code`";
expect(capBlockquoteNesting(input)).toBe(input);
});
test("capBlockquoteNesting keeps nesting at or below the cap untouched", () => {
const input = "> ".repeat(100) + "hi";
expect(capBlockquoteNesting(input)).toBe(input);
});
test("capBlockquoteNesting caps pathological nesting and preserves content", () => {
const result = capBlockquoteNesting("> ".repeat(5000) + "hi");
expect((result.match(/>/g) ?? []).length).toBe(100);
expect(result.endsWith("hi")).toBe(true);
});
test("capBlockquoteNesting handles markers without spaces", () => {
const result = capBlockquoteNesting(">".repeat(5000) + "hi");
expect((result.match(/>/g) ?? []).length).toBe(100);
expect(result.endsWith("hi")).toBe(true);
});
test("capBlockquoteNesting leaves fenced code content untouched", () => {
const literal = ">".repeat(150);
const input = `${"> ".repeat(3000)}hi\n\`\`\`text\n${literal}\n\`\`\``;
const result = capBlockquoteNesting(input);
expect(result.split("\n")[2]).toBe(literal);
});
test("capBlockquoteNesting leaves indented code blocks untouched", () => {
const literal = " " + ">".repeat(150);
const input = `${"> ".repeat(3000)}hi\n\n${literal}`;
const result = capBlockquoteNesting(input);
expect(result.split("\n")[2]).toBe(literal);
});
test("capBlockquoteNesting only rewrites pathological lines", () => {
const normal = "> normal quote";
const deep = "> ".repeat(3000) + "deep";
const result = capBlockquoteNesting(`${normal}\n${deep}\nplain`);
const lines = result.split("\n");
expect(lines[0]).toBe(normal);
expect((lines[1]?.match(/>/g) ?? []).length).toBe(100);
expect(lines[2]).toBe("plain");
});
test("capListNesting returns normally indented content unchanged", () => {
const input = "- a\n - b\n - c\n\n code continuation";
expect(capListNesting(input)).toBe(input);
});
test("capListNesting caps pathologically deep list indentation", () => {
const deep = " ".repeat(2000) + "- x";
const result = capListNesting(deep);
const indent = /^[ \t]*/.exec(result)![0];
expect(indent.length).toBe(200);
expect(result.endsWith("- x")).toBe(true);
});
test("capListNesting leaves fenced code content untouched", () => {
const literal = " ".repeat(400) + "deeply indented ascii art";
const input = `\`\`\`text\n${literal}\n\`\`\``;
expect(capListNesting(input).split("\n")[1]).toBe(literal);
});
// Outside a fence, deep indentation is capped regardless of blank-line context:
// we cannot tell an indented-code line from deeply nested list content (both can
// follow a blank line), and exempting either reopens the crash — blank-separated
// deep-indent lists otherwise blow up marked just like contiguous ones.
test("capListNesting caps deep indentation even after a blank line", () => {
const input = `- a\n\n${" ".repeat(500)}- deep`;
const lines = capListNesting(input).split("\n");
expect(/^[ \t]*/.exec(lines[2]!)![0].length).toBe(200);
});
test("capListNesting only rewrites pathological lines", () => {
const normal = " indented paragraph";
const deep = " ".repeat(500) + "- deep";
const result = capListNesting(`${normal}\n${deep}\nplain`);
const lines = result.split("\n");
expect(lines[0]).toBe(normal);
expect(/^[ \t]*/.exec(lines[1]!)![0].length).toBe(200);
expect(lines[2]).toBe("plain");
});
test("capMarkdownNesting caps both blockquote and list nesting", () => {
const input = `${"> ".repeat(3000)}quote\n${" ".repeat(500)}- item`;
const result = capMarkdownNesting(input);
const lines = result.split("\n");
expect((lines[0]?.match(/>/g) ?? []).length).toBe(100);
expect(/^[ \t]*/.exec(lines[1]!)![0].length).toBe(200);
});
test("preprocessStreamdownMarkdown leaves non-mermaid content unchanged", () => {
const input = "just some text";
expect(preprocessStreamdownMarkdown(input)).toBe(input);
});