Files
tutti/packages/agent/gui/shared/AgentMessageMermaid.spec.tsx
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

291 lines
8.7 KiB
TypeScript

import "@testing-library/jest-dom/vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi
} from "vitest";
import { AgentMessageMarkdown } from "./AgentMessageMarkdown";
import {
AgentMessageMermaid,
agentMessageMermaidLimits,
resolveDraggedMermaidView,
sanitizeMermaidSvg
} from "./AgentMessageMermaid";
const mermaidMocks = vi.hoisted(() => ({
initialize: vi.fn(),
render: vi.fn()
}));
vi.mock("mermaid", () => ({
default: mermaidMocks
}));
const mermaidSource = `flowchart TD
A["HTTP 已经取得正确状态<br/>曾探在线"] --> B{"响应时间戳是否<br/>大于缓存时间戳?"}
B -->|"是"| C["更新缓存"]`;
describe("AgentMessageMermaid", () => {
const nativePointerEvent = window.PointerEvent;
beforeAll(() => {
class TestPointerEvent extends MouseEvent {
pointerId: number;
constructor(
type: string,
init: PointerEventInit & { pointerId?: number } = {}
) {
super(type, init);
this.pointerId = init.pointerId ?? 0;
}
}
Object.defineProperty(window, "PointerEvent", {
configurable: true,
value: TestPointerEvent
});
Object.defineProperties(HTMLElement.prototype, {
hasPointerCapture: {
configurable: true,
value: () => false
},
releasePointerCapture: {
configurable: true,
value: () => undefined
},
setPointerCapture: {
configurable: true,
value: () => undefined
}
});
});
afterAll(() => {
Object.defineProperty(window, "PointerEvent", {
configurable: true,
value: nativePointerEvent
});
});
beforeEach(() => {
mermaidMocks.initialize.mockReset();
mermaidMocks.render.mockReset();
mermaidMocks.render.mockResolvedValue({
svg: '<svg viewBox="0 0 320 180"><text>Rendered diagram</text></svg>'
});
document.documentElement.dataset.theme = "light";
});
afterEach(() => {
document.documentElement.removeAttribute("data-theme");
vi.restoreAllMocks();
});
it("shows a placeholder during streaming and renders only after settlement", async () => {
const { rerender } = render(
<AgentMessageMermaid source={mermaidSource} streaming />
);
expect(screen.getByTestId("agent-mermaid-placeholder")).toBeInTheDocument();
expect(screen.queryByText("flowchart TD")).not.toBeInTheDocument();
expect(mermaidMocks.render).not.toHaveBeenCalled();
rerender(<AgentMessageMermaid source={mermaidSource} streaming={false} />);
await waitFor(() => {
expect(screen.getByText("Rendered diagram")).toBeInTheDocument();
});
expect(mermaidMocks.initialize).toHaveBeenCalledWith(
expect.objectContaining({
htmlLabels: false,
maxEdges: 500,
maxTextSize: 50_000,
secure: expect.arrayContaining(["htmlLabels", "securityLevel"]),
securityLevel: "strict",
startOnLoad: false,
suppressErrorRendering: true,
theme: "default"
})
);
expect(mermaidMocks.render).toHaveBeenCalledWith(
expect.stringMatching(/^agent-mermaid-/),
mermaidSource
);
});
it("hides Mermaid source while the streaming language marker is incomplete", () => {
render(
<AgentMessageMarkdown
content={`\`\`\`merm\n${mermaidSource}\n\`\`\``}
streaming
/>
);
expect(screen.getByTestId("agent-mermaid-placeholder")).toBeInTheDocument();
expect(screen.queryByText(/flowchart TD/)).not.toBeInTheDocument();
expect(mermaidMocks.render).not.toHaveBeenCalled();
});
it("keeps an unclosed Mermaid fence in the loading state", () => {
render(
<AgentMessageMarkdown
content={`\`\`\`mermaid\n${mermaidSource}\n P -->`}
/>
);
expect(screen.getByTestId("agent-mermaid-placeholder")).toBeInTheDocument();
expect(screen.queryByText(/flowchart TD/)).not.toBeInTheDocument();
expect(mermaidMocks.render).not.toHaveBeenCalled();
});
it("keeps inline markdown previews from turning Mermaid blocks into diagrams", () => {
render(
<AgentMessageMarkdown
content={`\`\`\`mermaid\n${mermaidSource}\n\`\`\``}
inline
/>
);
expect(screen.queryByTestId("agent-mermaid-placeholder")).toBeNull();
expect(screen.getByText(/flowchart TD/)).toBeInTheDocument();
expect(mermaidMocks.render).not.toHaveBeenCalled();
});
it("shows a compact failure state without exposing source code", async () => {
mermaidMocks.render.mockRejectedValueOnce(new Error("invalid diagram"));
render(
<AgentMessageMarkdown
content={`\`\`\`mermaid\n${mermaidSource}\n\`\`\``}
/>
);
await waitFor(() => {
expect(screen.getByTestId("agent-mermaid-failure")).toBeInTheDocument();
});
expect(screen.queryByText(/flowchart TD/)).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Copy code" })
).toBeInTheDocument();
});
it("keeps the current SVG visible while refreshing it for a new theme", async () => {
const content = `\`\`\`mermaid\n${mermaidSource}\n\`\`\``;
const { rerender } = render(<AgentMessageMarkdown content={content} />);
await screen.findByText("Rendered diagram");
document.documentElement.dataset.theme = "dark";
rerender(<AgentMessageMarkdown content={content} />);
expect(screen.getByText("Rendered diagram")).toBeInTheDocument();
await waitFor(() => {
expect(mermaidMocks.render).toHaveBeenCalledTimes(2);
});
expect(mermaidMocks.initialize).toHaveBeenLastCalledWith(
expect.objectContaining({
darkMode: true,
theme: "dark"
})
);
});
it("does not invoke Mermaid when the source exceeds the text limit", () => {
const source = "x".repeat(agentMessageMermaidLimits.maxTextSize + 1);
render(<AgentMessageMermaid source={source} streaming={false} />);
expect(screen.getByTestId("agent-mermaid-failure")).toBeInTheDocument();
expect(screen.queryByText(source)).not.toBeInTheDocument();
expect(mermaidMocks.render).not.toHaveBeenCalled();
});
it("opens a zoomable viewer and supports wheel zoom and Space-drag panning", async () => {
render(
<AgentMessageMarkdown
content={`\`\`\`mermaid\n${mermaidSource}\n\`\`\``}
/>
);
const preview = await screen.findByRole("button", {
name: "Expand Mermaid diagram"
});
fireEvent.click(preview);
const viewer = screen.getByRole("dialog", {
name: "Mermaid diagram viewer"
});
expect(viewer).toBeInTheDocument();
expect(viewer).toHaveFocus();
const stage = screen.getByTestId("agent-mermaid-viewer-stage");
expect(
fireEvent.wheel(stage, {
cancelable: true,
clientX: 300,
clientY: 200,
deltaY: -100
})
).toBe(false);
await waitFor(() => {
expect(screen.getByRole("status")).toHaveTextContent("115%");
});
fireEvent.keyDown(window, { code: "Space", key: " " });
expect(stage.closest(".agent-mermaid-viewer")).toHaveAttribute(
"data-agent-mermaid-space-pressed",
"true"
);
fireEvent.pointerDown(stage, {
button: 0,
clientX: 100,
clientY: 100,
pointerId: 1
});
expect(stage.closest(".agent-mermaid-viewer")).toHaveAttribute(
"data-agent-mermaid-dragging",
"true"
);
const draggedView = resolveDraggedMermaidView(
{ x: 0.39, y: 0.26, zoom: 1.149 },
{ originX: 0.39, originY: 0.26, startX: 100, startY: 100 },
145,
130
);
expect(draggedView.x).toBeCloseTo(45.39);
expect(draggedView.y).toBeCloseTo(30.26);
expect(draggedView.zoom).toBe(1.149);
fireEvent.keyUp(window, { code: "Space", key: " " });
fireEvent.keyDown(window, { key: "Escape" });
expect(screen.queryByRole("dialog")).toBeNull();
});
it("sanitizes Mermaid SVG output before inserting it into the transcript", () => {
const sanitized = sanitizeMermaidSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<script>alert("xss")</script>
<a href="javascript:alert('xss')">
<text onclick="alert('xss')">Safe label</text>
</a>
<foreignObject>
<div xmlns="http://www.w3.org/1999/xhtml">
<img src="invalid" onerror="alert('xss')" />
</div>
</foreignObject>
</svg>
`);
const container = document.createElement("div");
container.innerHTML = sanitized;
expect(container.querySelector("script")).toBeNull();
expect(container.querySelector("[onclick]")).toBeNull();
expect(container.querySelector("[onerror]")).toBeNull();
expect(container.querySelector('a[href^="javascript:"]')).toBeNull();
expect(container.textContent).toContain("Safe label");
});
});