mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-06 08:18:58 +00:00
Add InlineMarkdown component that tokenizes titles via marked's Lexer.lexInline and renders code, strong, and em. Block syntax, links, images, and raw HTML degrade to safe text — no dangerouslySetInnerHTML. Applied to the run detail h2 heading and the run list row titles. Title metadata, search, and the API/server data model are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
110 lines
4.1 KiB
TypeScript
110 lines
4.1 KiB
TypeScript
import { afterEach, describe, expect, test } from "bun:test";
|
|
import TestRenderer, { act } from "react-test-renderer";
|
|
|
|
import { InlineMarkdown } from "./inline-markdown";
|
|
|
|
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
|
|
|
|
async function render(content: string, className?: string) {
|
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
let renderer: TestRenderer.ReactTestRenderer | undefined;
|
|
await act(async () => {
|
|
renderer = TestRenderer.create(
|
|
<InlineMarkdown content={content} className={className} />,
|
|
);
|
|
});
|
|
mountedRenderers.push(renderer!);
|
|
return renderer!;
|
|
}
|
|
|
|
function textFromInstance(node: TestRenderer.ReactTestInstance): string {
|
|
return node.children
|
|
.map((child) => (typeof child === "string" ? child : textFromInstance(child)))
|
|
.join("");
|
|
}
|
|
|
|
describe("InlineMarkdown", () => {
|
|
afterEach(() => {
|
|
act(() => {
|
|
for (const renderer of mountedRenderers.splice(0)) {
|
|
renderer.unmount();
|
|
}
|
|
});
|
|
delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
|
|
});
|
|
|
|
test("renders backtick spans as <code> without visible backticks", async () => {
|
|
const renderer = await render(
|
|
"Move from `[server.integrations.github]` to `[run.integrations.github]`",
|
|
);
|
|
|
|
const codes = renderer.root.findAllByType("code");
|
|
expect(codes).toHaveLength(2);
|
|
expect(textFromInstance(codes[0]!)).toBe("[server.integrations.github]");
|
|
expect(textFromInstance(codes[1]!)).toBe("[run.integrations.github]");
|
|
|
|
const fullText = textFromInstance(renderer.root);
|
|
expect(fullText).not.toContain("`");
|
|
});
|
|
|
|
test("renders **bold** as <strong> and _italic_ as <em>", async () => {
|
|
const renderer = await render("**bold** and _italic_");
|
|
|
|
const strongs = renderer.root.findAllByType("strong");
|
|
expect(strongs).toHaveLength(1);
|
|
expect(textFromInstance(strongs[0]!)).toBe("bold");
|
|
|
|
const ems = renderer.root.findAllByType("em");
|
|
expect(ems).toHaveLength(1);
|
|
expect(textFromInstance(ems[0]!)).toBe("italic");
|
|
});
|
|
|
|
test("renders *italic* (asterisk form) as <em>", async () => {
|
|
const renderer = await render("*italic*");
|
|
const ems = renderer.root.findAllByType("em");
|
|
expect(ems).toHaveLength(1);
|
|
expect(textFromInstance(ems[0]!)).toBe("italic");
|
|
});
|
|
|
|
test("block markdown like headings, lists, blockquotes stays text", async () => {
|
|
const renderer = await render("# heading - item > quote");
|
|
|
|
expect(renderer.root.findAllByType("h1")).toHaveLength(0);
|
|
expect(renderer.root.findAllByType("h2")).toHaveLength(0);
|
|
expect(renderer.root.findAllByType("ul")).toHaveLength(0);
|
|
expect(renderer.root.findAllByType("li")).toHaveLength(0);
|
|
expect(renderer.root.findAllByType("blockquote")).toHaveLength(0);
|
|
|
|
expect(textFromInstance(renderer.root)).toContain("# heading");
|
|
expect(textFromInstance(renderer.root)).toContain("> quote");
|
|
});
|
|
|
|
test("link syntax renders the label as text without an <a>", async () => {
|
|
const renderer = await render("[label](javascript:alert(1))");
|
|
|
|
expect(renderer.root.findAllByType("a")).toHaveLength(0);
|
|
expect(textFromInstance(renderer.root)).toContain("label");
|
|
expect(textFromInstance(renderer.root)).not.toContain("javascript:");
|
|
});
|
|
|
|
test("image syntax renders the alt text without an <img>", async () => {
|
|
const renderer = await render("");
|
|
|
|
expect(renderer.root.findAllByType("img")).toHaveLength(0);
|
|
expect(textFromInstance(renderer.root)).toContain("alt");
|
|
});
|
|
|
|
test("raw HTML is shown literally as text, not interpreted", async () => {
|
|
const renderer = await render("<script>x</script>");
|
|
|
|
expect(renderer.root.findAllByType("script")).toHaveLength(0);
|
|
expect(textFromInstance(renderer.root)).toContain("<script>");
|
|
expect(textFromInstance(renderer.root)).toContain("</script>");
|
|
});
|
|
|
|
test("applies a className to the wrapper span", async () => {
|
|
const renderer = await render("hello", "text-fg-2");
|
|
const wrapper = renderer.root.findByType("span");
|
|
expect(wrapper.props.className).toBe("text-fg-2");
|
|
});
|
|
});
|