Files
AgentHub/client/src/components/conversation/__tests__/MarkdownContent.test.tsx
T
nntrivi2001 58be04a4e7 fix(chat): render a markdown table that follows prose with no blank line (#83)
The chat renderer's paragraph collector never stopped at a table header,
so a table right after a line of text was shown as raw pipes. A table now
interrupts a paragraph as in GFM, and the divider accepts single dashes.

Closes #83

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:51:30 +07:00

171 lines
7.2 KiB
TypeScript

/**
* @file MarkdownContent.test.tsx
* @description Tests for the lightweight markdown renderer used by the conversation viewer.
* Focuses on the block parser since the inline parser is well-exercised by snapshot-style
* DOM assertions.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { MarkdownContent, OpenFileContext } from "../MarkdownContent";
import { resolveIn } from "../../../lib/resolvePath";
function withOpen(text: string) {
const open = vi.fn();
render(
<OpenFileContext.Provider value={open}>
<MarkdownContent text={text} />
</OpenFileContext.Provider>
);
return open;
}
describe("file links", () => {
it("opens a relative path with its line suffix stripped", () => {
const open = withOpen("See server/lib/x.js:42 for it.");
fireEvent.click(screen.getByRole("button", { name: "server/lib/x.js:42" }));
expect(open).toHaveBeenCalledWith("server/lib/x.js");
});
it("opens an absolute path with a line suffix and one in inline code", () => {
const open = withOpen("At /a/b/c.ts:9 and `docs/API.md`.");
fireEvent.click(screen.getByRole("button", { name: "/a/b/c.ts:9" }));
fireEvent.click(screen.getByRole("button", { name: "docs/API.md" }));
expect(open.mock.calls).toEqual([["/a/b/c.ts"], ["docs/API.md"]]);
});
it("opens a markdown link to a file instead of navigating", () => {
const open = withOpen("[the panel](client/src/A%20B.tsx#L12) and [web](https://x.dev)");
fireEvent.click(screen.getByRole("button", { name: "the panel" }));
expect(open).toHaveBeenCalledWith("client/src/A B.tsx");
expect(screen.getByRole("link", { name: "web" })).toHaveAttribute("href", "https://x.dev");
});
it("leaves prose with slashes as text", () => {
withOpen("and/or, 3/4.5, github.com/x.js, TCP/IP.");
expect(screen.queryAllByRole("button")).toHaveLength(0);
});
it("resolves a relative path against the pane's folder", () => {
expect(resolveIn("/w/repo/", "./server/../docs/API.md")).toBe("/w/repo/docs/API.md");
expect(resolveIn("/w/repo", "/etc/x.conf")).toBe("/etc/x.conf");
});
});
describe("<MarkdownContent />", () => {
it("renders fenced code blocks with the language label", () => {
render(<MarkdownContent text={"Here is some code:\n```js\nconst x = 1;\n```"} />);
// The CodeBlock header shows the language
expect(screen.getByText(/javascript/i)).toBeInTheDocument();
// The code text is present (split across syntax-highlighted spans, so use a substring)
expect(screen.getByText(/const/)).toBeInTheDocument();
});
it("renders headings as semantic-looking elements", () => {
render(<MarkdownContent text={"# Title\n\nbody"} />);
expect(screen.getByText("Title")).toBeInTheDocument();
expect(screen.getByText("body")).toBeInTheDocument();
});
it("renders unordered and ordered lists", () => {
const { container } = render(<MarkdownContent text={"- one\n- two\n\n1. first\n2. second"} />);
expect(container.querySelectorAll("ul li")).toHaveLength(2);
expect(container.querySelectorAll("ol li")).toHaveLength(2);
});
it("renders blockquotes", () => {
const { container } = render(<MarkdownContent text={"> a quote"} />);
expect(container.querySelector("blockquote")).not.toBeNull();
expect(screen.getByText("a quote")).toBeInTheDocument();
});
it("renders inline code, bold, and italic", () => {
const { container } = render(
<MarkdownContent text={"This has `code`, **bold**, and *italic*."} />
);
expect(container.querySelector("code")).not.toBeNull();
expect(container.querySelector("strong")).not.toBeNull();
expect(container.querySelector("em")).not.toBeNull();
});
it("auto-links bare URLs and renders explicit markdown links", () => {
const { container } = render(
<MarkdownContent text={"See https://example.com or [docs](https://example.com/docs)."} />
);
const links = container.querySelectorAll("a");
expect(links.length).toBe(2);
expect(links[0]!.getAttribute("href")).toBe("https://example.com");
expect(links[1]!.getAttribute("href")).toBe("https://example.com/docs");
// Both should open in a new tab safely
for (const a of links) {
expect(a.getAttribute("target")).toBe("_blank");
expect(a.getAttribute("rel")).toContain("noopener");
}
});
it("renders plain text without any markdown features", () => {
render(<MarkdownContent text={"just a normal sentence."} />);
expect(screen.getByText("just a normal sentence.")).toBeInTheDocument();
});
it("highlights standalone numbers, never ones inside a word, sha or issue ref", () => {
const { container } = render(
<MarkdownContent
text={"Took 3.5s, 87% done, $1.20, v2.7.1 on 2026-10-12 — abc123 file.2 #67 2fd0c903 x9"}
/>
);
const nums = [...container.querySelectorAll("span.tabular-nums")].map((n) => n.textContent);
expect(nums).toEqual(["3.5s", "87%", "$1.20", "v2.7.1", "2026-10-12"]);
});
it("highlights an ISO date-time as one number", () => {
const { container } = render(
<MarkdownContent
text={"At 2026-10-12T08:00, 2026-10-12T08:00:00Z and 2026-10-12T08:00:00.123+07:00."}
/>
);
const nums = [...container.querySelectorAll("span.tabular-nums")].map((n) => n.textContent);
expect(nums).toEqual([
"2026-10-12T08:00",
"2026-10-12T08:00:00Z",
"2026-10-12T08:00:00.123+07:00",
]);
});
it("does not highlight numbers inside inline code", () => {
const { container } = render(<MarkdownContent text={"Run `sleep 5` now"} />);
expect(container.querySelector("span.tabular-nums")).toBeNull();
});
it("colours a flag label by how serious it is", () => {
render(<MarkdownContent text={"Lưu ý: a. Warning: b. Lỗi: c. Note that x"} />);
expect(screen.getByText("Lưu ý:").className).toContain("text-accent");
expect(screen.getByText("Warning:").className).toContain("text-status-warning");
expect(screen.getByText("Lỗi:").className).toContain("text-status-danger");
expect(screen.queryByText("Note")).toBeNull();
});
it("handles empty input safely", () => {
const { container } = render(<MarkdownContent text="" />);
// Wrapper exists but no block elements
expect(container.firstChild).not.toBeNull();
expect(container.querySelectorAll("p, ul, ol, blockquote, pre, hr").length).toBe(0);
});
it("renders a table that follows prose with no blank line (issue #83)", () => {
const text = 'Cách kiểm tra:\n| Token xoá | Số chỗ |\n|---|---|\n| `width="N"` | 29 |';
const { container } = render(<MarkdownContent text={text} />);
expect(container.querySelector("p")?.textContent).toBe("Cách kiểm tra:");
expect(container.querySelectorAll("th").length).toBe(2);
expect(container.querySelectorAll("tbody tr").length).toBe(1);
expect(container.textContent).not.toContain("|---|");
});
it("accepts a GFM divider with single dashes", () => {
const { container } = render(<MarkdownContent text={"| a | b |\n|-|:-:|\n| 1 | 2 |"} />);
expect(container.querySelectorAll("th").length).toBe(2);
});
});