nexus/ui/src/components/ChatMarkdownMessage.test.tsx
Mikkel Georgsen c7e0d9361f feat(21-02): add ChatMarkdownMessage component with syntax highlighting and copy button
- Renders full GFM markdown (headings, bold, italic, links, lists, tables, images)
- Code blocks use rehype-highlight for syntax highlighting
- CodeBlock sub-component shows language label and copy-to-clipboard button
- Inline code renders without copy button
- 10 tests green (jsdom environment)
2026-04-01 13:01:41 +02:00

164 lines
5 KiB
TypeScript

// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { ThemeProvider } from "../context/ThemeContext";
import { ChatMarkdownMessage } from "./ChatMarkdownMessage";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
// Mock clipboard API (not available in jsdom by default)
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
writable: true,
configurable: true,
});
describe("ChatMarkdownMessage", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
writeText.mockClear();
});
afterEach(() => {
container.remove();
});
it("renders markdown headings", () => {
act(() => {
createRoot(container).render(
<ThemeProvider>
<ChatMarkdownMessage content="# Hello World" />
</ThemeProvider>,
);
});
const heading = container.querySelector("h1");
expect(heading).not.toBeNull();
expect(heading?.textContent).toBe("Hello World");
});
it("renders markdown bold and italic", () => {
act(() => {
createRoot(container).render(
<ThemeProvider>
<ChatMarkdownMessage content="**bold** and _italic_" />
</ThemeProvider>,
);
});
expect(container.querySelector("strong")).not.toBeNull();
expect(container.querySelector("em")).not.toBeNull();
});
it("renders markdown lists", () => {
act(() => {
createRoot(container).render(
<ThemeProvider>
<ChatMarkdownMessage content={"- item one\n- item two"} />
</ThemeProvider>,
);
});
expect(container.querySelector("ul")).not.toBeNull();
const items = container.querySelectorAll("li");
expect(items.length).toBe(2);
});
it("renders markdown links", () => {
act(() => {
createRoot(container).render(
<ThemeProvider>
<ChatMarkdownMessage content="[Click here](https://example.com)" />
</ThemeProvider>,
);
});
const link = container.querySelector("a");
expect(link).not.toBeNull();
expect(link?.getAttribute("href")).toBe("https://example.com");
});
it("renders markdown tables (GFM)", () => {
const tableContent = "| A | B |\n|---|---|\n| 1 | 2 |";
act(() => {
createRoot(container).render(
<ThemeProvider>
<ChatMarkdownMessage content={tableContent} />
</ThemeProvider>,
);
});
expect(container.querySelector("table")).not.toBeNull();
expect(container.querySelector("th")).not.toBeNull();
});
it("renders code block with copy button", () => {
act(() => {
createRoot(container).render(
<ThemeProvider>
<ChatMarkdownMessage content={"```typescript\nconst x = 1;\n```"} />
</ThemeProvider>,
);
});
const copyBtn = container.querySelector('[aria-label="Copy code"]');
expect(copyBtn).not.toBeNull();
});
it("renders code block with language label when language is specified", () => {
act(() => {
createRoot(container).render(
<ThemeProvider>
<ChatMarkdownMessage content={"```typescript\nconst x = 1;\n```"} />
</ThemeProvider>,
);
});
expect(container.textContent).toContain("typescript");
});
it("clicking copy button calls clipboard.writeText with code content", async () => {
act(() => {
createRoot(container).render(
<ThemeProvider>
<ChatMarkdownMessage content={"```\nconst y = 2;\n```"} />
</ThemeProvider>,
);
});
const copyBtn = container.querySelector('[aria-label="Copy code"]') as HTMLButtonElement | null;
expect(copyBtn).not.toBeNull();
await act(async () => {
copyBtn?.click();
});
expect(writeText).toHaveBeenCalledTimes(1);
expect(writeText).toHaveBeenCalledWith(expect.stringContaining("const y = 2;"));
});
it("inline code renders without copy button", () => {
act(() => {
createRoot(container).render(
<ThemeProvider>
<ChatMarkdownMessage content="Here is `inline code` text" />
</ThemeProvider>,
);
});
const copyBtn = container.querySelector('[aria-label="Copy code"]');
expect(copyBtn).toBeNull();
expect(container.querySelector("code")).not.toBeNull();
});
it("renders inline images with img tag", () => {
act(() => {
createRoot(container).render(
<ThemeProvider>
<ChatMarkdownMessage content="![alt text](https://example.com/img.png)" />
</ThemeProvider>,
);
});
const img = container.querySelector("img");
expect(img).not.toBeNull();
expect(img?.getAttribute("src")).toBe("https://example.com/img.png");
expect(img?.getAttribute("alt")).toBe("alt text");
});
});