- 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)
164 lines
5 KiB
TypeScript
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="" />
|
|
</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");
|
|
});
|
|
});
|