mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-15 07:14:08 +00:00
372 lines
14 KiB
TypeScript
372 lines
14 KiB
TypeScript
import { fireEvent, render, screen } from "@testing-library/react";
|
||
import { describe, expect, it, vi } from "vitest";
|
||
|
||
import MarkdownTextRenderer from "@/components/MarkdownTextRenderer";
|
||
|
||
describe("MarkdownTextRenderer", () => {
|
||
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/");
|
||
expect(link).toHaveClass("text-blue-500", "dark:text-blue-300");
|
||
});
|
||
|
||
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",
|
||
);
|
||
});
|
||
|
||
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");
|
||
});
|
||
|
||
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();
|
||
});
|
||
|
||
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();
|
||
expect(screen.getByText("text")).toBeInTheDocument();
|
||
expect(container.querySelectorAll("pre")).toHaveLength(1);
|
||
});
|
||
|
||
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();
|
||
});
|
||
|
||
it("renders markdown images as inline previews", () => {
|
||
render(<MarkdownTextRenderer></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",
|
||
);
|
||
});
|
||
|
||
it("renders markdown videos as inline players", () => {
|
||
render(<MarkdownTextRenderer></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();
|
||
});
|
||
|
||
it("renders markdown links with file-looking names as file attachments", () => {
|
||
render(<MarkdownTextRenderer></MarkdownTextRenderer>);
|
||
|
||
expect(screen.getByLabelText("File attachment")).toHaveTextContent("index.html");
|
||
expect(screen.queryByRole("img", { name: "index.html" })).not.toBeInTheDocument();
|
||
});
|
||
|
||
it("renders title plus url list items as compact link rows", () => {
|
||
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(
|
||
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",
|
||
);
|
||
expect(
|
||
screen.getByRole("link", {
|
||
name: "Open link: Polymarket — GPT-5.6 released by...?",
|
||
}),
|
||
).toHaveAttribute("href", "https://polymarket.com/event/gpt-5pt6-released-by");
|
||
expect(screen.queryByText("Polymarket · polymarket.com")).not.toBeInTheDocument();
|
||
});
|
||
|
||
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");
|
||
});
|
||
|
||
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");
|
||
});
|
||
|
||
it("renders media attachments without an extra preview/code wrapper", () => {
|
||
render(<MarkdownTextRenderer></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();
|
||
});
|
||
|
||
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();
|
||
});
|
||
|
||
it("still renders explicit math blocks", () => {
|
||
const { container } = render(
|
||
<MarkdownTextRenderer>{"$$x^2 + y^2 = z^2$$"}</MarkdownTextRenderer>,
|
||
);
|
||
|
||
expect(container.querySelector(".katex")).toBeInTheDocument();
|
||
});
|
||
});
|