Files
multica/packages/views/editor/readonly-content.test.tsx
Jiayuan Zhang d4ae220cc1 feat(rich-content): render bare in-app project/issue URLs as chips (MUL-5499) (#6141)
* feat(rich-content): render bare in-app project/issue URLs as chips (MUL-5499)

A project has no `MUL-123`-style identifier — only a UUID and a free-text
title — so there is nothing for the bare-identifier autolink preprocessor to
detect, and the link copied out of the app is how people actually reference
one. It rendered as a raw URL.

RichLink now unfurls a bare in-app entity URL into the same chip the
`mention://project/<uuid>` form already produces (issue URLs go through the
same path for symmetry). Render-only: stored markdown is untouched, and the
editable Tiptap path is deliberately unaffected.

Three guards, each load-bearing: the link must be bare (an authored label is
never discarded), same-workspace (a chip resolves its title in the current
workspace only), and address exactly one entity page by UUID with no query or
fragment.

Also:
- mobile: tapping a `mention://project/` link navigated nowhere despite the
  `project/[id]` route existing — it now pushes the project detail.
- agents had no documented way to emit a clickable project reference: add the
  link form to the runtime brief's Mentions section and to the projects skill,
  and record in the mentioning skill why `project` sits outside `MentionRe`
  (render-only, enqueues nothing).

Co-authored-by: multica-agent <github@multica.ai>

* fix(rich-content): unfurl issue URLs in identifier form

The unfurl required a UUID id, on the stated grounds that "every link the
app itself produces carries a UUID". That holds for a project but not for
an issue: `copyLink` and `openInNewTab` both build
`paths.issueDetail(issueIdentifier || issueId)`, and the issue route
rewrites a UUID URL back to the identifier — so `MUL-123` is the shape a
user actually copies, out of the app or out of the address bar. The issue
half of the feature could not fire on the links people paste, while bare
`MUL-123` prose did become a chip: the fuller reference lost to the
shorter one.

`parseWorkspaceEntityLink` now accepts an issue identifier as well as a
UUID. A project still requires a UUID — it has no shorthand, so an
identifier-shaped id under /projects/ addresses nothing.

An identifier needs a lookup, which means it can miss, and the miss has to
differ by entry point. `AutolinkedIssueMentionLink` degraded to plain text,
which is right for autolinked prose and wrong for a URL: the author wrote a
link, and an issue this workspace cannot see must not cost them the only
pointer to it. The fallback is now a prop — plain text for the autolink
path, the original anchor for a URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(editor): stop drawing link chrome over mention chips

A mention chip already carries its own affordance — border, icon, hover
background — so the generic `.rich-text-editor a` color and underline draw a
second, competing one straight through the card. `.issue-mention` reset it;
`.project-mention` never did, so project chips shipped with a brand-coloured
underline through them. The rule belongs to the chip shape rather than to one
entity, so both selectors now share it and a future chip is one line.

The hover card had the same gap: it skipped `.issue-mention` only, so hovering
a project chip opened a URL card offering to copy `/{slug}/projects/{uuid}` —
an in-app path, not the shareable link that wording implies.

Both are pre-existing, but a bare project URL now renders as a chip, so what
used to surface on hand-written mentions alone shows up on ordinary pasted
links.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(rich-content): decide in-app by resolving the URL, not by its prefix

`href.startsWith("/")` was standing in for "this deployment". It is not: a
browser reads `//other.example/x` and `/\other.example/x` as another host and
goes there, and both start with a slash. The parser skipped the origin check
for exactly the hrefs that most needed it.

Nothing shipped from this: an unfurled chip links to
`paths.projectDetail(uuid)`, so the href it was parsed from is discarded and a
misparse could not send anyone anywhere. The prefix test was still the wrong
instrument. Adding `&& !startsWith("//")` would have looked like a fix while
leaving the backslash spelling through — the gap is the technique, not the
case, so this resolves the href against the app origin with `URL` and compares
`origin`, which is one comparison for every spelling and for the schemes
(`javascript:`, `data:`) whose opaque origin can never match.

Relative and absolute now take the same path, so the slugless legacy form
parses identically whether or not it carries the origin — previously the
absolute spelling was rejected by a reserved-slug test meant for workspace
slugs, and the two disagreed.

`openLink` still tests the prefix, and its result IS navigated. That is a live
issue, older than this feature and wider than it; it needs its own change
rather than a quiet ride here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(skills): state what mobile actually does with a project mention

The projects skill told agents a `mention://project/<uuid>` link "renders as a
navigable project chip on web, desktop, and mobile", and that a pasted project
URL is unfurled into that same chip by "the reader's client". Neither holds on
mobile: `apps/mobile/lib/markdown/markdown.tsx` renders the default enriched
link and only routes the tap, and a bare URL still goes to `Linking.openURL`,
which leaves the app.

These files enter agent context and read as product contract, so an agent
choosing between a mention link and a pasted URL was choosing on false
information — and the URL is the option that strands a mobile reader in a
browser. Both skills and both source maps now say chip on web/desktop, ordinary
link that opens the project on tap on mobile, and unfurling as web/desktop only.

The projects skill also now states the preference outright rather than
presenting the two forms as equivalent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(views): give a project mention the component an issue mention has

`IssueMentionCard` owns "chip inside a link" for issues; the project equivalent
lived inline in the readonly renderer, so nothing named the pairing and nothing
held the rules that come with being a link.

That cost was not hypothetical. Both gaps fixed a commit ago landed on project
mentions alone: `.project-mention` never got the CSS rule cancelling generic
link chrome, and the hover card never learned to skip it. Each was written for
`.issue-mention` at the component that owns it, and project had no such place
for the second half to be written. `ProjectMentionCard` is that place.

No behaviour change: same anchor, same href, same hover affordance, same
accessibility contract that project-mention-a11y.test.tsx pins. The "open in
new tab" preference stays out — it is scoped to issue links, and inheriting it
by symmetry would be inventing product.

Also drops `not-prose` from both cards. It has no definition anywhere in the
repo — Tailwind's typography plugin is not installed, and the class does not
appear in built CSS — so it read as protection that was not there.

The editor's `MentionView` keeps its hand-rolled anchors: it needs a
modifier-click intent hook `AppLink` does not expose, and it does the same for
issues, so the two stay symmetric there too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 16:18:13 +08:00

810 lines
30 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, waitFor } from "@testing-library/react";
import type { ReactElement } from "react";
import { readFileSync } from "node:fs";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const { getAttachmentTextContentMock, resolveIssueIdentifierMock } = vi.hoisted(
() => ({
getAttachmentTextContentMock: vi.fn(),
resolveIssueIdentifierMock: vi.fn(),
}),
);
vi.mock("../issues/hooks", () => ({
useResolveIssueIdentifier: (identifier: string) =>
resolveIssueIdentifierMock(identifier),
}));
// i18next is not initialized in this suite, so `t()` would resolve every label
// to "". Resolve against the real EN bundle instead — the editor tree only ever
// uses the `editor` namespace — so tests can select controls by accessible name.
vi.mock("../i18n", async () => {
const editor = (await import("../locales/en/editor.json")).default;
return {
useT: () => ({ t: (select: (bundle: typeof editor) => string) => select(editor) }),
useTimeAgo: () => "just now",
};
});
vi.mock("@multica/core/api", () => ({
api: { getAttachmentTextContent: getAttachmentTextContentMock },
PreviewTooLargeError: class extends Error {},
PreviewUnsupportedError: class extends Error {},
}));
vi.mock("@multica/core/paths", () => ({
useWorkspacePaths: () => ({
issueDetail: (id: string) => `/test/issues/${id}`,
}),
useWorkspaceSlug: () => "test",
}));
vi.mock("../navigation", () => ({
useNavigation: () => ({ push: vi.fn(), openInNewTab: vi.fn() }),
useAppOrigin: () => null,
}));
vi.mock("../issues/components/issue-mention-card", () => ({
IssueMentionCard: ({ issueId, fallbackLabel }: { issueId: string; fallbackLabel?: string }) => (
<span data-testid="issue-mention-card">{fallbackLabel ?? issueId}</span>
),
}));
vi.mock("./extensions/image-view", () => ({
ImageLightbox: () => null,
}));
vi.mock("./link-hover-card", () => ({
useLinkHover: () => ({}),
LinkHoverCard: () => null,
}));
// Partial: only navigation is stubbed. The pure URL predicates stay real so
// these autolink fixtures assert the renderer's actual link/chip dispatch.
vi.mock("./utils/link-handler", async (importOriginal) => ({
...(await importOriginal<typeof import("./utils/link-handler")>()),
openLink: vi.fn(),
isMentionHref: (href?: string) => Boolean(href?.startsWith("mention://")),
}));
vi.mock("mermaid", () => ({
default: {
initialize: vi.fn(),
render: vi.fn().mockResolvedValue({
svg: '<svg viewBox="0 0 123 45"><g><text>mock diagram</text></g></svg>',
}),
},
}));
Object.defineProperty(HTMLCanvasElement.prototype, "getContext", {
value: () => ({
fillStyle: "#000",
fillRect: vi.fn(),
getImageData: () => ({ data: new Uint8ClampedArray([12, 34, 56, 255]) }),
}),
});
import mermaid from "mermaid";
import { ReadonlyContent } from "./readonly-content";
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("ReadonlyContent memoization", () => {
// Long-timeline issues (Inbox + IssueDetail with thousands of comments)
// freeze the tab when each comment re-runs the full react-markdown pipeline
// on every parent re-render. Wrapping the component in React.memo is the
// mitigation; this test guards against a future revert that would silently
// reintroduce the perf regression.
it("is wrapped in React.memo", () => {
const memoTypeSymbol = Symbol.for("react.memo");
expect((ReadonlyContent as unknown as { $$typeof: symbol }).$$typeof).toBe(
memoTypeSymbol,
);
});
});
describe("ReadonlyContent math rendering", () => {
it("renders inline and block LaTeX with KaTeX markup", () => {
const { container } = render(
<ReadonlyContent
content={[
"Inline math: $$E = mc^2$$",
"",
"$$",
"\\int_0^1 x^2 \\, dx",
"$$",
].join("\n")}
/>,
);
const text = container.textContent?.replace(/\s+/g, " ") ?? "";
expect(container.querySelectorAll(".katex").length).toBeGreaterThanOrEqual(2);
expect(container.querySelector(".katex-display")).not.toBeNull();
expect(text).toContain("E = mc^2");
expect(text).toContain("\\int_0^1 x^2 \\, dx");
});
});
describe("ReadonlyContent line breaks", () => {
// Issue panel comments are the primary user-visible surface for agent
// output. CommonMark's default soft-break behavior collapses single
// newlines into spaces; agent text often relies on a single newline as a
// visible break. remark-breaks must remain wired into ReadonlyContent's
// remark plugin chain or comments lose their formatting again.
it("converts a single newline into a <br>", () => {
const { container } = render(<ReadonlyContent content={"line one\nline two"} />);
expect(container.querySelector("br")).not.toBeNull();
});
it("renders a blank-line gap as separate paragraphs", () => {
const { container } = render(<ReadonlyContent content={"para one\n\npara two"} />);
expect(container.querySelectorAll("p").length).toBeGreaterThanOrEqual(2);
});
});
describe("ReadonlyContent autolink policy", () => {
it("keeps historical bare filenames and domains as plain text", () => {
const { container } = render(
<ReadonlyContent content="plan.md 4399.com ai.md" />,
);
expect(container.textContent).toContain("plan.md 4399.com ai.md");
expect(container.querySelector("a")).toBeNull();
});
it("still links explicit web URLs, www URLs, and email addresses", () => {
const { container } = render(
<ReadonlyContent
content="https://4399.com www.4399.com contact@example.com"
/>,
);
const hrefs = Array.from(container.querySelectorAll("a"), (anchor) =>
anchor.getAttribute("href"),
);
expect(hrefs).toEqual([
"https://4399.com",
"https://www.4399.com",
"mailto:contact@example.com",
]);
});
});
describe("ReadonlyContent task lists", () => {
it("renders `- [ ]` / `- [x]` as checkboxes and preserves the checked state", () => {
const { container } = render(
<ReadonlyContent content={"- [ ] todo\n- [x] done"} />,
);
const boxes = container.querySelectorAll<HTMLInputElement>(
'input[type="checkbox"]',
);
expect(boxes).toHaveLength(2);
// The completed item must render checked, not just present.
expect(boxes[0]!.checked).toBe(false);
expect(boxes[1]!.checked).toBe(true);
// Checkboxes are display-only in readonly mode.
expect(boxes[1]!.disabled).toBe(true);
});
it("nests a child task list inside its parent item (not as a sibling)", () => {
const { container } = render(
<ReadonlyContent content={"- [ ] parent\n - [x] child\n - [ ] child2"} />,
);
// One top-level list with a single parent item.
const root = container.querySelector("ul.contains-task-list");
expect(root).not.toBeNull();
const topItems = root!.querySelectorAll(":scope > li.task-list-item");
expect(topItems).toHaveLength(1);
// The child list lives INSIDE the parent <li> — this is the structural
// assumption the readonly CSS depends on (no <div> body wrapper, so the
// parent item must stay a block, not flex, or the nested <ul> shares the
// parent's row). If remark-gfm ever wrapped the body, this fails loudly.
const parent = topItems[0]!;
const nested = parent.querySelector(":scope > ul.contains-task-list");
expect(nested).not.toBeNull();
const childItems = nested!.querySelectorAll(":scope > li.task-list-item");
expect(childItems).toHaveLength(2);
const childBoxes = nested!.querySelectorAll<HTMLInputElement>(
'input[type="checkbox"]',
);
expect(childBoxes[0]!.checked).toBe(true);
expect(childBoxes[1]!.checked).toBe(false);
});
});
describe("ReadonlyContent highlight Markdown", () => {
// `==text==` is lowered to a raw <mark> by highlightToHtml; rehype-raw turns
// it into an element and the sanitize schema must whitelist <mark> or it gets
// stripped. These guard both halves of that contract.
it("renders ==text== as a <mark> element", () => {
const { container } = render(<ReadonlyContent content={"a ==hi== b"} />);
const mark = container.querySelector("mark");
expect(mark).not.toBeNull();
expect(mark?.textContent).toBe("hi");
});
it("keeps inner Markdown formatting inside a highlight", () => {
const { container } = render(<ReadonlyContent content={"==**bold**=="} />);
expect(container.querySelector("mark strong")).not.toBeNull();
});
it("does not highlight == inside inline code", () => {
const { container } = render(<ReadonlyContent content={"`a ==b== c`"} />);
expect(container.querySelector("mark")).toBeNull();
expect(container.querySelector("code")?.textContent).toBe("a ==b== c");
});
// Boundary regressions (Emacs review, PR #3661).
it("wraps the whole span when an inner == lives in inline code", () => {
const { container } = render(<ReadonlyContent content={"==a `b==c` d=="} />);
const mark = container.querySelector("mark");
expect(mark).not.toBeNull();
// inner `==` stays inside the code, not consumed as the closing fence
expect(mark?.querySelector("code")?.textContent).toBe("b==c");
expect(mark?.textContent).toBe("a b==c d");
});
it("does not highlight across a blank line", () => {
const { container } = render(<ReadonlyContent content={"==a\n\nb=="} />);
expect(container.querySelector("mark")).toBeNull();
});
});
describe("ReadonlyContent issue mention Markdown", () => {
it("renders an issue mention inside a task list as an issue mention card", () => {
const { container, getByTestId } = render(
<ReadonlyContent content="- [ ] [MUL-123](mention://issue/issue-123)" />,
);
expect(container.querySelector('input[type="checkbox"]')).not.toBeNull();
expect(getByTestId("issue-mention-card").textContent).toBe("MUL-123");
});
it("autolinks a resolved bare identifier as an issue mention card", () => {
resolveIssueIdentifierMock.mockImplementation((id: string) =>
id === "MUL-7" ? { id: "issue-7", identifier: "MUL-7" } : null,
);
const { getByTestId } = render(
<ReadonlyContent content="See MUL-7 for context" />,
);
expect(getByTestId("issue-mention-card").textContent).toBe("MUL-7");
expect(resolveIssueIdentifierMock).toHaveBeenCalledWith("MUL-7");
});
it("leaves an unresolved bare identifier as plain text", () => {
resolveIssueIdentifierMock.mockReturnValue(null);
const { container, queryByTestId } = render(
<ReadonlyContent content="See MUL-999 for context" />,
);
expect(queryByTestId("issue-mention-card")).toBeNull();
expect(container.textContent).toContain("MUL-999");
});
it("does not autolink a bare identifier inside inline code", () => {
resolveIssueIdentifierMock.mockReturnValue(null);
const { queryByTestId } = render(
<ReadonlyContent content={"use `MUL-7` here"} />,
);
expect(resolveIssueIdentifierMock).not.toHaveBeenCalled();
expect(queryByTestId("issue-mention-card")).toBeNull();
});
it("documents the CommonMark quoted-emphasis edge case before Korean particles", () => {
const unsafe = render(
<ReadonlyContent content={'**"무엇을 먼저 정해두고 시작할지"**가'} />,
);
expect(unsafe.container.querySelector("strong")).toBeNull();
expect(unsafe.container.textContent).toContain(
'**"무엇을 먼저 정해두고 시작할지"**가',
);
const safe = render(
<ReadonlyContent content={'"**무엇을 먼저 정해두고 시작할지**"가'} />,
);
expect(safe.container.querySelector("strong")?.textContent).toBe(
"무엇을 먼저 정해두고 시작할지",
);
expect(safe.container.textContent).toContain('"무엇을 먼저 정해두고 시작할지"가');
});
});
describe("ReadonlyContent code styling", () => {
const literalCode = "uv run --extra dev pytest -q";
it("renders inline and fenced code through rich-text-editor code selectors", () => {
const { container } = render(
<ReadonlyContent
content={[
`<code>${literalCode}</code>`,
"",
"```",
literalCode,
"```",
].join("\n")}
/>,
);
const inlineCode = Array.from(container.querySelectorAll("code")).find(
(code) => !code.closest("pre"),
);
const blockCode = container.querySelector("pre code");
expect(inlineCode?.textContent).toBe(literalCode);
expect(blockCode?.textContent).toBe(literalCode);
});
it("renders code blocks without a language tag as plaintext", () => {
const token = "const answer = 42;";
const { container } = render(
<ReadonlyContent content={["```", token, "```"].join("\n")} />,
);
const blockCode = container.querySelector("pre code");
expect(blockCode?.textContent?.trim()).toBe(token);
expect(blockCode?.querySelector("span")).toBeNull();
});
it("copies the whole fenced code block from the readonly toolbar", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
const source = ["pnpm install", "pnpm test"].join("\n");
const { getByRole } = render(
<ReadonlyContent content={["```bash", source, "```"].join("\n")} />,
);
fireEvent.click(getByRole("button", { name: "Copy code" }));
await waitFor(() => {
expect(writeText).toHaveBeenCalledWith(source);
});
});
it("keeps editor code literal by disabling font ligatures", () => {
const codeCss = readFileSync("editor/styles/code.css", "utf8");
expect(codeCss).toContain(".rich-text-editor code");
expect(codeCss).toContain(".rich-text-editor pre");
expect(codeCss).toContain(".rich-text-editor pre code");
expect(codeCss).toContain("font-variant-ligatures: none;");
expect(codeCss).toContain('font-feature-settings: "liga" 0;');
});
});
describe("ReadonlyContent Mermaid rendering", () => {
it("renders mermaid code fences in a sized sandbox iframe with legacy rgb colors", async () => {
const originalGetComputedStyle = window.getComputedStyle;
vi.spyOn(window, "getComputedStyle").mockImplementation((element, pseudoElt) => {
if (element instanceof HTMLElement && element.style.color.startsWith("var(")) {
return { color: "oklch(60% 0.2 120)" } as CSSStyleDeclaration;
}
return originalGetComputedStyle.call(window, element, pseudoElt);
});
const { container } = render(
<ReadonlyContent
content={["```mermaid", "graph LR", " A[Start] --> B[Done]", "```"].join("\n")}
/>,
);
expect(container.querySelector(".mermaid-diagram")).not.toBeNull();
expect(container.querySelector("pre code.language-mermaid")).toBeNull();
await waitFor(() => {
const iframe = container.querySelector<HTMLIFrameElement>(".mermaid-diagram-frame");
expect(iframe).not.toBeNull();
expect(iframe?.getAttribute("sandbox")).toBe("");
expect(iframe?.srcdoc).toContain("mock diagram");
expect(iframe?.style.width).toBe("123px");
expect(iframe?.style.height).toBe("45px");
});
expect(mermaid.initialize).toHaveBeenCalledWith(
expect.objectContaining({
themeVariables: expect.objectContaining({
lineColor: "rgb(12, 34, 56)",
primaryBorderColor: "rgb(12, 34, 56)",
primaryColor: "rgb(12, 34, 56)",
primaryTextColor: "rgb(12, 34, 56)",
}),
}),
);
});
it("does not regress Mermaid unwrap after the HtmlBlockPreview branch was added", async () => {
// Both Mermaid and HtmlBlockPreview rely on react-markdown's `code`
// renderer returning a non-<code> React element, and on the `pre`
// renderer recognizing the element by reference and unwrapping it. If
// someone tightens the `pre` check to a single component, the other
// one quietly regresses into a `<pre>`-wrapped DOM. This test pins the
// contract.
const { container } = render(
<ReadonlyContent
content={["```mermaid", "graph LR", " A --> B", "```"].join("\n")}
/>,
);
expect(container.querySelector(".mermaid-diagram")).not.toBeNull();
// No outer <pre> envelope.
expect(container.querySelector("pre")).toBeNull();
});
it("opens the fullscreen viewer from the toolbar and closes it with Escape", async () => {
const { container } = render(
<ReadonlyContent
content={["```mermaid", "graph LR", " A[Start] --> B[Done]", "```"].join("\n")}
/>,
);
const expandButton = await waitFor(() => {
const found = container.querySelector<HTMLButtonElement>(
'.mermaid-diagram-toolbar button[aria-label="Open diagram viewer"]',
);
expect(found).not.toBeNull();
return found!;
});
expect(document.querySelector(".zoom-canvas")).toBeNull();
fireEvent.click(expandButton);
const viewerFrame = await waitFor(() => {
const found = document.querySelector<HTMLIFrameElement>(".mermaid-viewer-frame");
expect(found).not.toBeNull();
return found!;
});
expect(viewerFrame.getAttribute("sandbox")).toBe("");
expect(viewerFrame.srcdoc).toContain("mock diagram");
// The viewer draws at natural size and lets the host transform handle zoom;
// an inline-style max-width clamp here would cap how far it can scale.
expect(viewerFrame.srcdoc).toContain("width: 123px");
expect(viewerFrame.srcdoc).toContain("max-width: none");
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => {
expect(document.querySelector(".zoom-canvas")).toBeNull();
});
});
it("keeps the inline toolbar outside the scroll container so wide diagrams stay openable", async () => {
const { container } = render(
<ReadonlyContent
content={["```mermaid", "graph LR", " A[Start] --> B[Done]", "```"].join("\n")}
/>,
);
await waitFor(() => {
expect(container.querySelector(".mermaid-diagram-toolbar")).not.toBeNull();
});
// Previously the toolbar was an absolutely-positioned child of the
// horizontally-scrolling element, so on a wide diagram it scrolled out of
// view along with the content and left no way to open or copy it.
const scroller = container.querySelector(".mermaid-diagram-scroll");
expect(scroller).not.toBeNull();
expect(scroller?.querySelector(".mermaid-diagram-toolbar")).toBeNull();
});
it("shows the compact error state instead of embedding Mermaid's parser error SVG", async () => {
// With suppressErrorRendering enabled, invalid syntax makes render() reject
// instead of emitting Mermaid's built-in error graphic.
vi.mocked(mermaid.render).mockRejectedValueOnce(
new Error("Parse error on line 3"),
);
const chart = "graph LR\n A -->";
const { container } = render(
<ReadonlyContent content={["```mermaid", chart, "```"].join("\n")} />,
);
await waitFor(() => {
expect(container.querySelector(".mermaid-diagram-error")).not.toBeNull();
});
expect(container.querySelector(".mermaid-diagram-frame")).toBeNull();
expect(container.querySelector(".mermaid-diagram-error code")?.textContent).toBe(
chart,
);
});
});
describe("ReadonlyContent HTML block rendering", () => {
// `language=html` fenced blocks should default to a preview iframe with
// sandbox="allow-scripts" (chart JS executes in an opaque origin) and
// must NOT be wrapped by react-markdown's default <pre>, which would
// clamp the iframe with monospace / overflow styles. The two-layer
// code+pre unwrap mirror's Mermaid's pattern.
it("renders an iframe with sandbox='allow-scripts' for ```html and skips the outer <pre>", () => {
const { container } = render(
<ReadonlyContent
content={["```html", '<h1 id="x">hi</h1>', "```"].join("\n")}
/>,
);
const frame = container.querySelector<HTMLIFrameElement>("iframe");
expect(frame).not.toBeNull();
expect(frame?.getAttribute("sandbox")).toBe("allow-scripts");
expect(frame?.getAttribute("srcdoc")).toContain('<h1 id="x">hi</h1>');
expect(container.querySelector("pre")).toBeNull();
});
it("keeps the <pre><code> wrapper for adjacent languages like htmlbars / mermaidx", () => {
// Regression: the previous `className.includes("language-html")` check
// matched `language-htmlbars` too, so an htmlbars fence lost its outer
// <pre> envelope and rendered as bare lowlight-highlighted spans. The
// unwrap rule must match the exact class token, not a prefix.
const { container } = render(
<ReadonlyContent
content={[
"```htmlbars",
"<div>{{name}}</div>",
"```",
"",
"```mermaidx",
"not a real lang",
"```",
].join("\n")}
/>,
);
const pres = container.querySelectorAll("pre");
// Both fences keep their <pre> wrapper.
expect(pres.length).toBe(2);
// And the inner <code> still carries the original language class.
expect(
container.querySelector("pre code.language-htmlbars"),
).not.toBeNull();
expect(
container.querySelector("pre code.language-mermaidx"),
).not.toBeNull();
});
});
describe("ReadonlyContent file-card → AttachmentBlock HTML routing", () => {
// Regression pin for readonly-content.tsx:279. The `div data-type=fileCard`
// branch must render through <AttachmentBlock>, not the older
// <AttachmentCard>. Reverting that line would skip the html+attachmentId
// dispatcher branch and surface the bare file-card chrome (filename row)
// instead of the rendered iframe — the exact regression MUL-2330 fixed.
function renderWithQuery(ui: ReactElement) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
it("renders the !file[](url) HTML attachment as an iframe (no file-card chrome)", async () => {
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>chart</p>",
originalContentType: "text/html",
});
const attachment = {
id: "att-1",
url: "/uploads/report.html",
filename: "report.html",
content_type: "text/html",
size_bytes: 0,
} as any;
const { container, queryByText } = renderWithQuery(
<ReadonlyContent
content="!file[report.html](/uploads/report.html)"
attachments={[attachment]}
/>,
);
const frame = await waitFor(() => {
const f = container.querySelector<HTMLIFrameElement>("iframe");
expect(f).not.toBeNull();
return f!;
});
expect(frame.getAttribute("sandbox")).toBe("allow-scripts");
expect(frame.getAttribute("srcdoc")).toContain("<p>chart</p>");
// AttachmentCard chrome surfaces the filename as visible text in a
// <p class="truncate"> row. HtmlAttachmentPreview replaces it entirely.
expect(queryByText("report.html")).toBeNull();
});
it("renders a stable attachment download URL as file-card chrome", () => {
const id = "11111111-2222-3333-4444-555555555555";
const href = `/api/attachments/${id}/download`;
const attachment = {
id,
url: "/uploads/report.pdf",
filename: "report.pdf",
content_type: "application/pdf",
size_bytes: 1024,
markdown_url: href,
download_url: href,
} as any;
const { container, getByText } = renderWithQuery(
<ReadonlyContent
content={`!file[report.pdf](${href})`}
attachments={[attachment]}
/>,
);
expect(getByText("report.pdf")).toBeTruthy();
expect(container.querySelector("iframe")).toBeNull();
expect(container.querySelector("img")).toBeNull();
});
it("resolves a markdown image whose src is the response download_url", () => {
const href = "https://cdn.example.test/shot.png?Signature=stale";
const fresh = "https://cdn.example.test/shot.png?Signature=fresh";
const attachment = {
id: "11111111-2222-3333-4444-555555555555",
url: "https://cdn.example.test/shot.png",
download_url: fresh,
markdown_url: "/api/attachments/11111111-2222-3333-4444-555555555555/download",
filename: "shot.png",
content_type: "image/png",
size_bytes: 1024,
} as any;
const { container } = renderWithQuery(
<ReadonlyContent
content={`![](${href})`}
attachments={[attachment]}
/>,
);
const img = container.querySelector("img");
expect(img?.getAttribute("src")).toBe(fresh);
expect(img?.getAttribute("alt")).toBe("shot.png");
});
});
describe("ReadonlyContent inline data-URI images", () => {
// Issue comments render through ReadonlyContent, which has its own sanitize
// schema + urlTransform separate from the base Markdown component. Agents
// inline auth QR codes as `![](data:image/png;base64,...)`; both gates used
// to strip the src and surface a broken image (MUL-3961).
const PNG_1X1 =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
function renderWithQuery(ui: ReactElement) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
it("preserves the src of an inline data:image/png image", () => {
const { container } = renderWithQuery(
<ReadonlyContent content={`![QR Code](${PNG_1X1})`} />,
);
expect(container.querySelector("img")?.getAttribute("src")).toBe(PNG_1X1);
});
it("strips non-image data URIs (data:text/html)", () => {
const { container } = renderWithQuery(
<ReadonlyContent content={"![x](data:text/html,<script>alert(1)</script>)"} />,
);
// The value allow-list rejects non-image data URIs, so no usable src reaches
// the <img>. AttachmentRenderer still mounts an <img>, but with an empty src.
expect(container.querySelector("img")?.getAttribute("src") ?? "").toBe("");
});
});
describe("ReadonlyContent slash command rendering", () => {
it("renders slash skill links as slash command pills", () => {
const { container } = render(
<ReadonlyContent content="[/deploy](slash://skill/abc-123)" />,
);
const pill = container.querySelector(".slash-command");
expect(pill).not.toBeNull();
expect(pill?.textContent).toBe("/deploy");
});
it("does not affect regular links", () => {
const { container } = render(
<ReadonlyContent content="[docs](https://example.com)" />,
);
expect(container.querySelector(".slash-command")).toBeNull();
expect(container.querySelector("a")).not.toBeNull();
});
});
describe("ReadonlyContent bare URL autolinking (MUL-4242)", () => {
// A bare URL wrapped in bold used to be linkified into [url**](url**), which
// swallowed the closing `**`: the bold never closed (leading `**` showed as
// literal asterisks) and the href was corrupted with a trailing `**`. The
// shared linkify now drops a trailing markdown-delimiter run from the URL, so
// the closing `**` stays as emphasis outside a clean [url](url).
it("renders a bold-wrapped bare URL as bold plus a clean link", () => {
const url = "https://github.com/multica-ai/multica/pull/5081";
const { container } = render(<ReadonlyContent content={`**PR${url}**`} />);
const strong = container.querySelector("strong");
expect(strong).not.toBeNull();
const anchor = strong!.querySelector("a");
expect(anchor?.getAttribute("href")).toBe(url);
// No literal asterisks leak into the text, no trailing `**` in the href.
expect(container.textContent).not.toContain("**");
expect(anchor?.getAttribute("href")).not.toContain("*");
});
it("bolds a bare URL even when a CJK punctuation immediately follows (variant B)", () => {
// `**url**MUL` — the closing `**` is glued to a fullwidth paren. gfm
// autolink swallowed the `**` here; the shared string linkify does not.
const url = "https://github.com/multica-ai/multica/pull/5133";
const { container } = render(
<ReadonlyContent content={`PR**${url}**MUL-4277`} />,
);
const strong = container.querySelector("strong");
expect(strong).not.toBeNull();
expect(strong!.querySelector("a")?.getAttribute("href")).toBe(url);
expect(container.textContent).not.toContain("**");
expect(container.textContent).toContain("MUL-4277");
});
it("still autolinks a plain bare URL", () => {
const { container } = render(
<ReadonlyContent content={"see https://example.com/foo here"} />,
);
expect(container.querySelector('a[href="https://example.com/foo"]')).not.toBeNull();
});
it("stops an autolinked URL at CJK punctuation instead of swallowing it", () => {
const { container } = render(
<ReadonlyContent content={"见 https://example.com/foo。后面还有字"} />,
);
const anchor = container.querySelector("a");
expect(anchor?.getAttribute("href")).toBe("https://example.com/foo");
expect(anchor?.textContent).toBe("https://example.com/foo");
// The CJK tail stays outside the link.
expect(container.textContent).toContain("。后面还有字");
});
it("keeps every URL in a CJK-separated run linked, not just the first", () => {
// `url1、url2` — linkify-it merges both across the CJK comma; collectLinkify
// truncates at 、 and rescans the tail so both URLs become their own link.
const { container } = render(
<ReadonlyContent content={"两个地址 https://a.com/x、https://b.com/y"} />,
);
const hrefs = Array.from(container.querySelectorAll("a")).map((a) =>
a.getAttribute("href"),
);
expect(hrefs).toContain("https://a.com/x");
expect(hrefs).toContain("https://b.com/y");
// The 、 separator stays as text between the two links.
expect(container.textContent).toContain("、");
});
it("leaves an explicit link's destination untouched even when it ends in CJK", () => {
const { container } = render(
<ReadonlyContent content={"[看](https://example.com/x。)后文"} />,
);
const anchor = container.querySelector("a");
// react-markdown percent-encodes the CJK char; the point is it is NOT
// trimmed off the way an autolink literal would be.
expect(decodeURIComponent(anchor?.getAttribute("href") ?? "")).toBe(
"https://example.com/x。",
);
expect(anchor?.textContent).toBe("看");
});
});