2026-07-15 10:45:40 +08:00
import { act , fireEvent , render , screen , waitFor } from "@testing-library/react" ;
2026-06-06 19:49:33 +08:00
import { describe , expect , it , vi } from "vitest" ;
2026-05-23 01:39:46 +08:00
2026-07-15 10:45:40 +08:00
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext" ;
2026-05-23 01:39:46 +08:00
import MarkdownTextRenderer from "@/components/MarkdownTextRenderer" ;
describe ( "MarkdownTextRenderer" , () => {
2026-06-02 15:08:34 +08:00
it ( "renders clickable markdown links in blue" , () => {
render (< MarkdownTextRenderer >[ local server ]( http : //127.0.0.1:7891/)</MarkdownTextRenderer>);
const link = screen . getByRole ( "link" , { name : "local server" });
expect ( link ). toHaveAttribute ( "href" , "http://127.0.0.1:7891/" );
2026-06-02 15:19:10 +08:00
expect ( link ). toHaveClass ( "text-blue-500" , "dark:text-blue-300" );
2026-06-02 15:08:34 +08:00
});
2026-06-06 19:49:33 +08:00
it ( "renders local file links as previewable file references" , () => {
const onOpenFilePreview = vi . fn ();
render (
< MarkdownTextRenderer onOpenFilePreview = { onOpenFilePreview }>
{ "Edited [hook.py](/Users/test/project/nanobot/agent/hook.py:12)" }
</ MarkdownTextRenderer >,
);
const reference = screen . getByTestId ( "inline-file-path" );
expect ( reference ). toHaveTextContent ( "hook.py" );
expect ( reference ). toHaveAttribute (
"aria-label" ,
"/Users/test/project/nanobot/agent/hook.py" ,
);
fireEvent . click ( reference );
expect ( onOpenFilePreview ). toHaveBeenCalledWith (
"/Users/test/project/nanobot/agent/hook.py" ,
);
});
2026-07-15 10:45:40 +08:00
it ( "keeps unavailable inferred inline file paths non-interactive" , async () => {
const onOpenFilePreview = vi . fn ();
const resolve = vi . fn (). mockResolvedValue ( false );
render (
< FilePreviewAvailabilityProvider resolve = { resolve }>
< MarkdownTextRenderer onOpenFilePreview = { onOpenFilePreview }>
{ "Future file: `notes/missing.md`" }
</ MarkdownTextRenderer >
</ FilePreviewAvailabilityProvider >,
);
const reference = screen . getByTestId ( "inline-file-path" );
expect ( reference ). toHaveTextContent ( "missing.md" );
await waitFor (() => expect ( resolve ). toHaveBeenCalledWith ( "notes/missing.md" ));
expect ( reference ). not . toHaveAttribute ( "role" );
expect ( reference ). not . toHaveAttribute ( "tabindex" );
fireEvent . click ( reference );
expect ( onOpenFilePreview ). not . toHaveBeenCalled ();
});
it ( "keeps inferred inline file paths non-interactive when availability lookup fails" , async () => {
const onOpenFilePreview = vi . fn ();
let rejectAvailability !: ( reason? : unknown ) => void ;
const resolve = vi . fn (() => new Promise < boolean >(( _resolve , reject ) => {
rejectAvailability = reject ;
}));
render (
< FilePreviewAvailabilityProvider resolve = { resolve }>
< MarkdownTextRenderer onOpenFilePreview = { onOpenFilePreview }>
{ "Unreadable file: `notes/locked.md`" }
</ MarkdownTextRenderer >
</ FilePreviewAvailabilityProvider >,
);
const reference = screen . getByTestId ( "inline-file-path" );
await waitFor (() => expect ( resolve ). toHaveBeenCalledWith ( "notes/locked.md" ));
await act ( async () => {
rejectAvailability ( new Error ( "probe failed" ));
await Promise . resolve ();
});
expect ( reference ). not . toHaveAttribute ( "role" );
expect ( reference ). not . toHaveAttribute ( "tabindex" );
fireEvent . click ( reference );
expect ( onOpenFilePreview ). not . toHaveBeenCalled ();
});
it ( "makes available inferred inline file paths previewable" , async () => {
const onOpenFilePreview = vi . fn ();
const resolve = vi . fn (). mockResolvedValue ( true );
render (
< FilePreviewAvailabilityProvider resolve = { resolve }>
< MarkdownTextRenderer onOpenFilePreview = { onOpenFilePreview }>
{ "Existing file: `notes/ready.md`" }
</ MarkdownTextRenderer >
</ FilePreviewAvailabilityProvider >,
);
const reference = screen . getByTestId ( "inline-file-path" );
await waitFor (() => expect ( reference ). toHaveAttribute ( "role" , "button" ));
expect ( reference ). toHaveAttribute ( "tabindex" , "0" );
fireEvent . click ( reference );
expect ( onOpenFilePreview ). toHaveBeenCalledWith ( "notes/ready.md" );
});
2026-06-06 19:49:33 +08:00
it ( "does not treat non-file hrefs as previews just because the label looks like a file" , () => {
const onOpenFilePreview = vi . fn ();
render (
< MarkdownTextRenderer onOpenFilePreview = { onOpenFilePreview }>
{ "Download [index.html](/api/media/sig/html)" }
</ MarkdownTextRenderer >,
);
expect ( screen . queryByTestId ( "inline-file-path" )). not . toBeInTheDocument ();
expect ( screen . getByRole ( "link" , { name : "index.html" })). toHaveAttribute (
"href" ,
"/api/media/sig/html" ,
);
});
it ( "renders glob file links as plain text instead of preview targets" , () => {
const onOpenFilePreview = vi . fn ();
const { container } = render (
< MarkdownTextRenderer onOpenFilePreview = { onOpenFilePreview }>
{ "原始对话通常还在 [*.json](*.json)。" }
</ MarkdownTextRenderer >,
);
expect ( screen . queryByTestId ( "inline-file-path" )). not . toBeInTheDocument ();
expect ( screen . queryByRole ( "link" , { name : "*.json" })). not . toBeInTheDocument ();
expect ( container ). toHaveTextContent ( "*.json" );
});
it ( "keeps glob inline code as code instead of a file preview chip" , () => {
render (
< MarkdownTextRenderer >
{ "检查 `src/**/*.json`。" }
</ MarkdownTextRenderer >,
);
expect ( screen . queryByTestId ( "inline-file-path" )). not . toBeInTheDocument ();
expect ( screen . getByText ( "src/**/*.json" ). tagName ). toBe ( "CODE" );
});
2026-05-30 23:45:26 +08:00
it ( "does not wrap complete fenced code blocks in an extra pre" , () => {
const { container } = render (
< MarkdownTextRenderer highlightCode = { false }>
{ "当前目录:\n\n```text\n/Users/renxubin/.nanobot/workspace\n```" }
</ MarkdownTextRenderer >,
);
expect ( screen . getByText ( "/Users/renxubin/.nanobot/workspace" )). toBeInTheDocument ();
expect ( container . querySelectorAll ( "pre" )). toHaveLength ( 1 );
expect ( container . querySelector ( "pre div" )). toBeNull ();
});
2026-05-31 13:25:00 +08:00
it ( "renders bare fenced code blocks without crashing" , () => {
const { container } = render (
< MarkdownTextRenderer highlightCode = { false }>
{ "Some text\n\n```\ncode without language\n```" }
</ MarkdownTextRenderer >,
);
expect ( screen . getByText ( "code without language" )). toBeInTheDocument ();
2026-05-31 15:15:40 +08:00
expect ( screen . getByText ( "text" )). toBeInTheDocument ();
2026-05-31 13:25:00 +08:00
expect ( container . querySelectorAll ( "pre" )). toHaveLength ( 1 );
});
2026-05-30 23:45:26 +08:00
it ( "keeps streaming unfinished fenced code blocks to a single shell" , () => {
const { container } = render (
< MarkdownTextRenderer highlightCode = { false }>
{ "当前目录:\n\n```text\n/Users/renxubin/.nanobot/workspace" }
</ MarkdownTextRenderer >,
);
expect ( screen . getByText ( "/Users/renxubin/.nanobot/workspace" )). toBeInTheDocument ();
expect ( container . querySelectorAll ( "pre" )). toHaveLength ( 1 );
expect ( container . querySelector ( "pre div" )). toBeNull ();
});
2026-05-23 01:39:46 +08:00
it ( "renders markdown images as inline previews" , () => {
render (< MarkdownTextRenderer > ! [ Diagram ]( /api/media/sig/payload)</MarkdownTextRenderer>);
const image = screen . getByRole ( "img" , { name : "Diagram" });
expect ( image ). toHaveAttribute ( "src" , "/api/media/sig/payload" );
expect ( screen . getByRole ( "link" , { name : "Open Diagram" })). toHaveAttribute (
"href" ,
"/api/media/sig/payload" ,
);
});
2026-05-29 14:54:46 +08:00
it ( "renders markdown videos as inline players" , () => {
render (< MarkdownTextRenderer > ! [ nanobot - intro . mp4 ]( /api/media/sig/video)</MarkdownTextRenderer>);
const video = screen . getByLabelText ( "Video attachment: nanobot-intro.mp4" );
expect ( video . tagName ). toBe ( "VIDEO" );
expect ( video ). toHaveAttribute ( "src" , "/api/media/sig/video" );
expect ( video ). toHaveAttribute ( "controls" );
expect ( screen . queryByRole ( "img" , { name : "nanobot-intro.mp4" })). not . toBeInTheDocument ();
});
2026-05-30 23:45:26 +08:00
it ( "renders markdown links with file-looking names as file attachments" , () => {
render (< MarkdownTextRenderer > ! [ index . html ]( /api/media/sig/html)</MarkdownTextRenderer>);
expect ( screen . getByLabelText ( "File attachment" )). toHaveTextContent ( "index.html" );
expect ( screen . queryByRole ( "img" , { name : "index.html" })). not . toBeInTheDocument ();
});
2026-05-31 23:02:43 +08:00
it ( "renders title plus url list items as compact link rows" , () => {
2026-05-31 21:43:12 +08:00
render (
< MarkdownTextRenderer >
{
"Sources:\n\n- Polymarket — “When will GPT-5.6 be released?”\n https://polymarket.com/event/when-will-gpt-5pt6-be-released\n- Polymarket — “GPT-5.6 released by...?”\n https://polymarket.com/event/gpt-5pt6-released-by"
}
</ MarkdownTextRenderer >,
);
expect (
2026-05-31 23:02:43 +08:00
screen . getByRole ( "link" , {
name : "Open link: Polymarket — When will GPT-5.6 be released?" ,
}),
2026-05-31 21:43:12 +08:00
). toHaveAttribute (
"href" ,
"https://polymarket.com/event/when-will-gpt-5pt6-be-released" ,
);
expect (
2026-05-31 23:02:43 +08:00
screen . getByRole ( "link" , {
name : "Open link: Polymarket — GPT-5.6 released by...?" ,
}),
2026-05-31 21:43:12 +08:00
). toHaveAttribute ( "href" , "https://polymarket.com/event/gpt-5pt6-released-by" );
expect ( screen . queryByText ( "Polymarket · polymarket.com" )). not . toBeInTheDocument ();
});
2026-05-31 23:02:43 +08:00
it ( "does not require a source heading for compact link rows" , () => {
render (
< MarkdownTextRenderer >
{
"Useful links:\n\n- Polymarket — “When will GPT-5.6 be released?”\n https://polymarket.com/event/when-will-gpt-5pt6-be-released"
}
</ MarkdownTextRenderer >,
);
expect (
screen . getByRole ( "link" , {
name : "Open link: Polymarket — When will GPT-5.6 be released?" ,
}),
). toHaveAttribute ( "href" , "https://polymarket.com/event/when-will-gpt-5pt6-be-released" );
});
2026-06-06 19:49:33 +08:00
it ( "falls back through favicon sources before showing a globe for compact link rows" , () => {
const { container } = render (
< MarkdownTextRenderer >
{
"Useful links:\n\n- Savills Hong Kong Corporate Relocation — Corporate relocation services\n https://www.savills.com.hk/services/corporate-relocation.aspx"
}
</ MarkdownTextRenderer >,
);
const link = screen . getByRole ( "link" , {
name : "Open link: Savills Hong Kong Corporate Relocation — Corporate relocation services" ,
});
const favicon = () => link . querySelector ( "img" );
expect ( favicon ()). toHaveAttribute (
"src" ,
"https://www.savills.com.hk/favicon.ico" ,
);
fireEvent . error ( favicon () ! );
expect ( favicon ()). toHaveAttribute (
"src" ,
"https://icons.duckduckgo.com/ip3/www.savills.com.hk.ico" ,
);
fireEvent . error ( favicon () ! );
expect ( favicon ()). toHaveAttribute (
"src" ,
"https://www.google.com/s2/favicons?domain=www.savills.com.hk&sz=64" ,
);
fireEvent . error ( favicon () ! );
expect ( favicon ()). not . toBeInTheDocument ();
expect ( link . querySelector ( "svg" )). toBeInTheDocument ();
expect ( container ). not . toHaveTextContent ( "SC" );
});
2026-05-30 23:45:26 +08:00
it ( "renders media attachments without an extra preview/code wrapper" , () => {
render (< MarkdownTextRenderer > ! [ Diagram ]( /api/media/sig/payload)</MarkdownTextRenderer>);
expect ( screen . getByRole ( "img" , { name : "Diagram" })). toHaveAttribute (
"src" ,
"/api/media/sig/payload" ,
);
expect ( screen . getByRole ( "link" , { name : "Open Diagram" })). toHaveAttribute (
"href" ,
"/api/media/sig/payload" ,
);
expect ( screen . queryByRole ( "button" , { name : "Code" })). not . toBeInTheDocument ();
});
it ( "renders a safe subset of inline HTML" , () => {
const { container } = render (
< MarkdownTextRenderer >
{ "<mark>高亮文本</mark>\n\n上标:x<sup>2</sup>\n下标:H<sub>2</sub>O" }
</ MarkdownTextRenderer >,
);
expect ( container . querySelector ( "mark" )). toHaveTextContent ( "高亮文本" );
expect ( container . querySelector ( "sup" )). toHaveTextContent ( "2" );
expect ( container . querySelector ( "sub" )). toHaveTextContent ( "2" );
});
it ( "keeps unsafe HTML as text" , () => {
const { container } = render (
< MarkdownTextRenderer >
{ "<script>alert(1)</script>\n\n<mark onclick=\"alert(1)\">bad</mark>" }
</ MarkdownTextRenderer >,
);
expect ( container . querySelector ( "script" )). toBeNull ();
expect ( container . querySelector ( "mark" )). toBeNull ();
expect ( container ). toHaveTextContent ( "<script>alert(1)</script>" );
expect ( container ). toHaveTextContent ( "<mark onclick=\"alert(1)\">bad</mark>" );
});
it ( "renders safe details blocks" , () => {
const { container } = render (
< MarkdownTextRenderer >
{
"<details><summary>点击展开更多内容</summary>\n\n这里是被折叠的内容。\n\n- 可以放列表\n\n</details>"
}
</ MarkdownTextRenderer >,
);
expect ( container . querySelector ( "details" )). toBeInTheDocument ();
expect ( container . querySelector ( "summary" )). toHaveTextContent ( "点击展开更多内容" );
expect ( screen . getByText ( "这里是被折叠的内容。" )). toBeInTheDocument ();
expect ( screen . getByText ( "可以放列表" )). toBeInTheDocument ();
expect ( container ). not . toHaveTextContent ( "</details>" );
});
it ( "renders task list checkboxes as quiet status marks" , () => {
const { container } = render (
< MarkdownTextRenderer >
{ "- [x] 写 Markdown 示例\n- [x] 加点 emoji\n- [ ] 测试渲染效果" }
</ MarkdownTextRenderer >,
);
expect ( container . querySelectorAll ( "input[type='checkbox']" )). toHaveLength ( 0 );
expect ( screen . getAllByTestId ( "markdown-task-checkbox" )). toHaveLength ( 3 );
expect ( container . querySelectorAll ( ".task-list-item" )). toHaveLength ( 3 );
});
it ( "keeps dollar amounts from being parsed as inline math" , () => {
const { container } = render (
< MarkdownTextRenderer >
{
"VBeats mentions $24 million, while Globe states a total of $130.6 million since founding."
}
</ MarkdownTextRenderer >,
);
expect ( container ). toHaveTextContent (
"VBeats mentions $24 million, while Globe states a total of $130.6 million since founding." ,
);
expect ( container . querySelector ( ".katex" )). toBeNull ();
});
2026-06-08 17:31:10 +08:00
it ( "renders guarded single-dollar inline math" , () => {
const { container } = render (
< MarkdownTextRenderer >
{
"Variables $x$ and powers $2^n$ render inline, while a price range $10-20$ stays literal."
}
</ MarkdownTextRenderer >,
);
expect ( container . querySelectorAll ( ".katex" )). toHaveLength ( 2 );
expect ( container ). not . toHaveTextContent ( "$x$" );
expect ( container ). not . toHaveTextContent ( "$2^n$" );
expect ( container ). toHaveTextContent ( "$10-20$" );
});
it ( "renders model-style single-dollar formula lists" , () => {
const { container } = render (
< MarkdownTextRenderer >
{[
"- Fourier transform: $\\hat{f}(\\xi) = \\int_{-\\infty}^{+\\infty} f(x)e^{-2\\pi i x \\xi}\\, dx$" ,
"- Taylor expansion: $e^x = \\sum_{n=0}^{\\infty} \\frac{x^n}{n!}$" ,
"- KL divergence: $D_\\text{KL}(P || Q) = \\sum_x P(x) \\log \\frac{P(x)}{Q(x)}$" ,
"- Quantum state: $\\psi = \\alpha|0\\rangle + \\beta|1\\rangle$" ,
]. join ( "\n" )}
</ MarkdownTextRenderer >,
);
expect ( container . querySelectorAll ( ".katex" )). toHaveLength ( 4 );
expect ( container ). not . toHaveTextContent ( "$\\hat{f}" );
expect ( container ). not . toHaveTextContent ( "$D_\\text" );
});
it ( "renders TeX inline math delimiters" , () => {
const { container } = render (
< MarkdownTextRenderer >{ "Einstein wrote \\(E = mc^2\\) for mass-energy equivalence." }</ MarkdownTextRenderer >,
);
expect ( container . querySelector ( ".katex" )). toBeInTheDocument ();
expect ( container . querySelector ( ".katex-display" )). toBeNull ();
expect ( container ). not . toHaveTextContent ( "\\(" );
expect ( container ). not . toHaveTextContent ( "\\)" );
});
it ( "renders TeX display math delimiters" , () => {
const { container } = render (
< MarkdownTextRenderer >{ "\\[x^2 + y^2 = z^2\\]" }</ MarkdownTextRenderer >,
);
expect ( container . querySelector ( ".katex-display" )). toBeInTheDocument ();
expect ( container ). not . toHaveTextContent ( "\\[" );
expect ( container ). not . toHaveTextContent ( "\\]" );
});
it ( "keeps TeX delimiters inside code literal" , () => {
const { container } = render (
< MarkdownTextRenderer highlightCode = { false }>
{ "Inline `\\(x\\)` stays literal.\n\n```text\n\\[x^2\\]\n```" }
</ MarkdownTextRenderer >,
);
expect ( container . querySelector ( ".katex" )). toBeNull ();
expect ( screen . getByText ( "\\(x\\)" ). tagName ). toBe ( "CODE" );
expect ( screen . getByText ( "\\[x^2\\]" )). toBeInTheDocument ();
});
2026-05-30 23:45:26 +08:00
it ( "still renders explicit math blocks" , () => {
const { container } = render (
< MarkdownTextRenderer >{ "$$x^2 + y^2 = z^2$$" }</ MarkdownTextRenderer >,
);
expect ( container . querySelector ( ".katex" )). toBeInTheDocument ();
});
2026-05-23 01:39:46 +08:00
});