165 lines
7.2 KiB
TypeScript
165 lines
7.2 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
|
|
import {
|
|
renderHighlightedMarkdownHtml,
|
|
renderMarkdownHtml,
|
|
type MarkdownPresentation,
|
|
} from "../src/components/markdown/markdown-primitive";
|
|
import { getArtifactType, isMarkdownPreviewSupported } from "../src/lib/artifacts";
|
|
import {
|
|
guardMermaidSource,
|
|
isSafeMermaidSvgReference,
|
|
MERMAID_LIMITS,
|
|
mermaidConfigForTheme,
|
|
normalizeCssEscapes,
|
|
renderMermaidSource,
|
|
type MermaidRuntime,
|
|
} from "../src/components/markdown/mermaid";
|
|
|
|
const SOURCE = "flowchart LR\n Start[Start] --> Finish[Finish]";
|
|
const MARKDOWN = `\`\`\`mermaid\n${SOURCE}\n\`\`\``;
|
|
|
|
describe("Mermaid Markdown placeholders", () => {
|
|
test("parses Mermaid fences into inert source fallbacks for chat and artifact surfaces", async () => {
|
|
const presentations: MarkdownPresentation[] = ["chat", "surface"];
|
|
for (const presentation of presentations) {
|
|
const fallback = renderMarkdownHtml(MARKDOWN, presentation);
|
|
const highlighted = await renderHighlightedMarkdownHtml(MARKDOWN, presentation);
|
|
|
|
for (const html of [fallback, highlighted]) {
|
|
expect(html).toContain("data-openwork-mermaid");
|
|
expect(html).toContain("data-openwork-mermaid-source");
|
|
expect(html).toContain("data-openwork-mermaid-view=\"rendered\"");
|
|
expect(html).toContain("data-openwork-mermaid-download");
|
|
expect(html).toContain("Start[Start] --> Finish[Finish]");
|
|
expect(html).not.toContain("<svg");
|
|
}
|
|
}
|
|
});
|
|
|
|
test("leaves non-Mermaid fences on the existing code path", () => {
|
|
const html = renderMarkdownHtml("```ts\nconst diagram = false;\n```");
|
|
expect(html).toContain("data-openwork-code-block");
|
|
expect(html).not.toContain("data-openwork-mermaid");
|
|
});
|
|
|
|
test("classifies standalone Mermaid source as a Markdown artifact", () => {
|
|
expect(getArtifactType("diagrams/architecture.mmd")).toBe("markdown");
|
|
expect(isMarkdownPreviewSupported("mmd")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("Mermaid rendering boundaries", () => {
|
|
test("guards source bytes, nodes, edges, and semicolon-separated statements before loading Mermaid", async () => {
|
|
expect(guardMermaidSource("x".repeat(MERMAID_LIMITS.maxSourceBytes + 1))).toMatchObject({ ok: false, reason: "size" });
|
|
const complex = [
|
|
"flowchart TD",
|
|
...Array.from({ length: MERMAID_LIMITS.maxNodes + 1 }, (_, index) => `N${index}[Node ${index}]`),
|
|
].join("\n");
|
|
expect(guardMermaidSource(complex)).toMatchObject({ ok: false, reason: "complexity" });
|
|
const statementHeavy = [
|
|
"flowchart TD",
|
|
...Array.from({ length: MERMAID_LIMITS.maxStatements }, (_, index) => `statement${index}`),
|
|
].join(";");
|
|
expect(guardMermaidSource(statementHeavy)).toMatchObject({ ok: false, reason: "complexity" });
|
|
|
|
let loads = 0;
|
|
const guarded = await renderMermaidSource(complex, "light", {
|
|
loadRuntime: async () => {
|
|
loads += 1;
|
|
throw new Error("must not load");
|
|
},
|
|
});
|
|
expect(guarded).toEqual({ status: "source", reason: "complexity" });
|
|
expect(await renderMermaidSource(statementHeavy, "light", {
|
|
loadRuntime: async () => {
|
|
loads += 1;
|
|
throw new Error("must not load");
|
|
},
|
|
})).toEqual({ status: "source", reason: "complexity" });
|
|
expect(loads).toBe(0);
|
|
});
|
|
|
|
test("rejects directives and resource syntax before loading Mermaid", async () => {
|
|
const unsafeSources = [
|
|
"%%{init: {'theme': 'dark'}}%%\nflowchart LR\nA --> B",
|
|
"%%{config: {'htmlLabels': true}}%%\nflowchart LR\nA --> B",
|
|
"---\nconfig:\n theme: dark\n---\nflowchart LR\nA --> B",
|
|
"flowchart LR; A --> B; click A callback",
|
|
"flowchart LR\nA[https://example.com/image.svg]",
|
|
"flowchart LR\nA[data:image/svg+xml;base64,AAAA]",
|
|
"flowchart LR\nA[blob:payload]",
|
|
"flowchart LR\nA[file:///tmp/payload]",
|
|
"flowchart LR\nA[javascript:alert(1)]",
|
|
"flowchart LR\nA[ftp://example.com/payload]",
|
|
"flowchart LR\nA[//example.com/payload]",
|
|
String.raw`flowchart LR; A[u\72l(https://example.com/payload)]`,
|
|
String.raw`flowchart LR; A[u\72l(#local-resource)]`,
|
|
String.raw`flowchart LR; A[\68 ttps://example.com/payload]`,
|
|
String.raw`flowchart LR; cl\69 ck A callback`,
|
|
String.raw`%%{\69 nit: {'theme': 'dark'}}%%; flowchart LR; A --> B`,
|
|
"flowchart LR; A[@import 'theme.css']",
|
|
"flowchart LR; A[src=local.svg]",
|
|
"flowchart LR; A[h/**/ttps://example.com/payload]",
|
|
];
|
|
let loads = 0;
|
|
|
|
for (const source of unsafeSources) {
|
|
expect(guardMermaidSource(source)).toMatchObject({ ok: false, reason: "unsafe" });
|
|
expect(await renderMermaidSource(source, "light", {
|
|
loadRuntime: async () => {
|
|
loads += 1;
|
|
throw new Error("must not load");
|
|
},
|
|
})).toEqual({ status: "source", reason: "unsafe" });
|
|
}
|
|
expect(loads).toBe(0);
|
|
});
|
|
|
|
test("keeps source for malformed, unavailable, and timed-out renders", async () => {
|
|
const malformedRuntime: MermaidRuntime = {
|
|
initialize: () => undefined,
|
|
render: async () => { throw new Error("bad syntax"); },
|
|
};
|
|
await expect(renderMermaidSource("not-a-diagram", "light", {
|
|
loadRuntime: async () => malformedRuntime,
|
|
})).resolves.toEqual({ status: "source", reason: "invalid" });
|
|
|
|
await expect(renderMermaidSource(SOURCE, "light", {
|
|
loadRuntime: async () => { throw new Error("chunk unavailable"); },
|
|
})).resolves.toEqual({ status: "source", reason: "unavailable" });
|
|
|
|
const stalledRuntime: MermaidRuntime = {
|
|
initialize: () => undefined,
|
|
render: () => new Promise<{ svg: string }>(() => undefined),
|
|
};
|
|
await expect(renderMermaidSource(SOURCE, "light", {
|
|
loadRuntime: async () => stalledRuntime,
|
|
timeoutMs: 1,
|
|
})).resolves.toEqual({ status: "source", reason: "timeout" });
|
|
});
|
|
|
|
test("uses strict theme-specific config with HTML labels disabled", () => {
|
|
const light = mermaidConfigForTheme("light");
|
|
const dark = mermaidConfigForTheme("dark");
|
|
|
|
expect(light).toMatchObject({ securityLevel: "strict", htmlLabels: false, startOnLoad: false, theme: "default" });
|
|
expect(light.flowchart).toMatchObject({ htmlLabels: false });
|
|
expect(light.secure).toContain("securityLevel");
|
|
expect(light.secure).toContain("htmlLabels");
|
|
expect(dark.theme).toBe("dark");
|
|
});
|
|
|
|
test("allows local SVG fragments but rejects remote-resource and redirect vectors", () => {
|
|
expect(normalizeCssEscapes(String.raw`u\72l(\68 ttps://example.com)`)).toBe("url(https://example.com)");
|
|
expect(isSafeMermaidSvgReference("url(#arrowhead)")).toBe(true);
|
|
expect(isSafeMermaidSvgReference("#local-gradient")).toBe(true);
|
|
expect(isSafeMermaidSvgReference("url(https://example.com/tracker.svg)")).toBe(false);
|
|
expect(isSafeMermaidSvgReference("@import url('//example.com/diagram.css')")).toBe(false);
|
|
expect(isSafeMermaidSvgReference("javascript:location='https://example.com'")).toBe(false);
|
|
expect(isSafeMermaidSvgReference("data:image/svg+xml;base64,AAAA")).toBe(false);
|
|
expect(isSafeMermaidSvgReference(String.raw`background:u\72l(\68 ttps://example.com/tracker.svg)`)).toBe(false);
|
|
expect(isSafeMermaidSvgReference("background:u/**/rl(https://example.com/tracker.svg)")).toBe(false);
|
|
});
|
|
});
|