import type { ReactNode } from "react"; import ReactMarkdown from "react-markdown"; import { renderToStaticMarkup } from "react-dom/server"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; const openFile = vi.hoisted(() => vi.fn()); const appState = vi.hoisted(() => ({ value: { tasks: { activeSessionId: "session-1" }, taskSessions: { items: { "session-1": { worktree_path: "/root/.kandev/tasks/example/kandev", }, } as Record, }, }, })); vi.mock("@/components/shared/mermaid-block", () => ({ MermaidBlock: ({ code, taskId }: { code: string; taskId?: string | null }) => (
{code}
), })); vi.mock("@/components/task/chat/messages/code-block", () => ({ CodeBlock: ({ children, className }: { children: ReactNode; className?: string }) => (
      {children}
    
), })); vi.mock("@/components/task/chat/messages/inline-code", () => ({ InlineCode: ({ children }: { children: ReactNode }) => {children}, })); vi.mock("@/hooks/use-panel-actions", () => ({ usePanelActions: () => ({ openFile }), })); vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (state: typeof appState.value) => unknown) => selector(appState.value), })); import { MarkdownFileLinkContext, MarkdownTaskContext, markdownComponents, normalizeMarkdown, remarkPlugins, type MarkdownFileLinkContextValue, } from "./markdown-components"; function renderMarkdown(source: string): string { return renderToStaticMarkup( {source} , ); } function renderTaskMarkdown(source: string, taskId: string): string { return renderToStaticMarkup( {source} , ); } function Markdown({ children }: { children: string }) { return ( {children} ); } function resetMarkdownComponentTestState() { cleanup(); openFile.mockClear(); appState.value.tasks.activeSessionId = "session-1"; appState.value.taskSessions.items = { "session-1": { worktree_path: "/root/.kandev/tasks/example/kandev", }, }; } describe("markdownComponents", () => { afterEach(resetMarkdownComponentTestState); it("keeps mermaid keywords in inline code as inline code", () => { const html = renderMarkdown("Metadata comes from `kanban`, `kanbanMulti`, repositories."); expect(html).toContain('data-kind="inline"'); expect(html).toContain("kanban"); expect(html).not.toContain('data-kind="mermaid"'); }); it("renders fenced mermaid code as a mermaid block", () => { const html = renderMarkdown("```mermaid\ngraph LR\nA-->B\n```"); expect(html).toContain('data-kind="mermaid"'); expect(html).toContain("graph LR"); }); it("passes the owning task to a fenced Mermaid block", () => { const html = renderTaskMarkdown("```mermaid\ngraph LR\nA-->B\n```", "task-a"); expect(html).toContain('data-task-id="task-a"'); }); it("renders non-mermaid fenced code as a code block", () => { const html = renderMarkdown("```ts\nconst source = 'kanban';\n```"); expect(html).toContain('data-kind="block"'); expect(html).toContain("language-ts"); expect(html).toContain("const source"); expect(html).not.toContain('data-kind="mermaid"'); }); it("opens absolute worktree file links in the editor", () => { render( {"[spec.md](/root/.kandev/tasks/example/kandev/docs/specs/native/spec.md)"} , ); fireEvent.click(screen.getByRole("link", { name: "spec.md" })); expect(openFile).toHaveBeenCalledWith("docs/specs/native/spec.md"); }); it("opens absolute worktree file links with line suffixes in the editor", () => { render( { "[workflow](/root/.kandev/tasks/example/kandev/.github/workflows/opencode-code-review.yml:1)" } , ); fireEvent.click(screen.getByRole("link", { name: "workflow" })); expect(openFile).toHaveBeenCalledWith(".github/workflows/opencode-code-review.yml"); }); it("opens relative file links in the editor", () => { render({"[plan.md](docs/specs/native/plan.md)"}); const link = screen.getByRole("link", { name: "plan.md" }); expect(link.getAttribute("target")).toBe("_self"); fireEvent.click(link); expect(openFile).toHaveBeenCalledWith("docs/specs/native/plan.md"); }); it("opens repo-root file links in the editor", () => { render({"[migration](/docs/nextjs-spa-migration.md)"}); const link = screen.getByRole("link", { name: "migration" }); fireEvent.click(link); expect(openFile).toHaveBeenCalledWith("docs/nextjs-spa-migration.md"); }); it("opens repo-root file links with line and column suffixes in the editor", () => { render({"[migration](/docs/nextjs-spa-migration.md:12:3)"}); fireEvent.click(screen.getByRole("link", { name: "migration" })); expect(openFile).toHaveBeenCalledWith("docs/nextjs-spa-migration.md"); }); it("opens repo-root file links when no worktree path is available", () => { appState.value.taskSessions.items = {}; render({"[migration](/docs/nextjs-spa-migration.md)"}); fireEvent.click(screen.getByRole("link", { name: "migration" })); expect(openFile).toHaveBeenCalledWith("docs/nextjs-spa-migration.md"); }); it("does not open host-absolute file links outside the worktree", () => { render({"[secret](/root/other-project/secret.md)"}); fireEvent.click(screen.getByRole("link", { name: "secret" })); expect(openFile).not.toHaveBeenCalled(); }); it("does not treat sibling workspace paths as repo-root file links", () => { appState.value.taskSessions.items["session-1"].worktree_path = "/workspace/current-project"; render({"[schema](/workspace/sibling-project/schema.prisma)"}); fireEvent.click(screen.getByRole("link", { name: "schema" })); expect(openFile).not.toHaveBeenCalled(); }); it("does not open Windows drive-letter absolute file links", () => { render({"[hosts](/C:/Windows/System32/drivers/etc/hosts)"}); const link = screen.getByRole("link", { name: "hosts" }); fireEvent.click(link); expect(openFile).not.toHaveBeenCalled(); expect(link.getAttribute("aria-disabled")).toBe("true"); }); it("does not treat bare domains as relative file links", () => { render({"[service](api.service.com)"}); const link = screen.getByRole("link", { name: "service" }); link.addEventListener("click", (event) => event.preventDefault()); fireEvent.click(link); expect(openFile).not.toHaveBeenCalled(); expect(link.getAttribute("target")).toBe("_blank"); }); }); describe("markdownComponents registered source links", () => { afterEach(resetMarkdownComponentTestState); it("opens registered repository paths through the active task worktree", () => { const fileLinkContext: MarkdownFileLinkContextValue = { worktreePath: "/root/.kandev/tasks/example", onOpenFile: openFile, fileRootAliases: [ { repositoryId: "repo-1", sourceRoot: "/home/jcfs/kandev-plugins/project", workspaceRelativeRoot: "kandev", }, ], }; render( {"[bundle](/home/jcfs/kandev-plugins/project/ui/bundle.js:61)"} , ); fireEvent.click(screen.getByRole("link", { name: "bundle" })); expect(openFile).toHaveBeenCalledWith("kandev/ui/bundle.js"); }); it("keeps an unmatched host path inert", () => { render({"[secret](/home/other-project/secret.md)"}); const link = screen.getByRole("link", { name: "secret" }); expect(fireEvent.click(link)).toBe(false); expect(openFile).not.toHaveBeenCalled(); expect(link.getAttribute("aria-disabled")).toBe("true"); }); }); describe("markdownComponents workspace roots", () => { afterEach(resetMarkdownComponentTestState); it("opens multi-repository absolute links relative to the task workspace root", () => { appState.value.taskSessions.items["session-1"].workspace_path = "/root/.kandev/tasks/example"; render( { "[primary](/root/.kandev/tasks/example/kandev/docs/specs/native/spec.md) [sibling](/root/.kandev/tasks/example/plugin/ui/bundle.js)" } , ); fireEvent.click(screen.getByRole("link", { name: "primary" })); fireEvent.click(screen.getByRole("link", { name: "sibling" })); expect(openFile).toHaveBeenNthCalledWith(1, "kandev/docs/specs/native/spec.md"); expect(openFile).toHaveBeenNthCalledWith(2, "plugin/ui/bundle.js"); }); }); describe("markdownComponents omp read selectors", () => { afterEach(() => { cleanup(); openFile.mockClear(); appState.value.tasks.activeSessionId = "session-1"; appState.value.taskSessions.items = { "session-1": { worktree_path: "/root/.kandev/tasks/example/kandev" }, }; }); it("opens file links with omp range selectors in the editor", () => { render({"[readme](docs/README.md:16-20)"}); fireEvent.click(screen.getByRole("link", { name: "readme" })); expect(openFile).toHaveBeenCalledWith("docs/README.md"); }); it("opens file links with omp multi-range and mode selectors in the editor", () => { render({"[readme](docs/README.md:5-16,960-973:raw)"}); fireEvent.click(screen.getByRole("link", { name: "readme" })); expect(openFile).toHaveBeenCalledWith("docs/README.md"); }); }); describe("normalizeMarkdown", () => { it("splits a glued 4-backtick close", () => { const input = "````go\nfunc f() {\n ...\n}````\nprose continues here"; const expected = "````go\nfunc f() {\n ...\n}\n````\nprose continues here"; expect(normalizeMarkdown(input)).toBe(expected); }); it("splits a glued 3-backtick close", () => { const input = "```go\nfunc f() {}```\nprose"; const expected = "```go\nfunc f() {}\n```\nprose"; expect(normalizeMarkdown(input)).toBe(expected); }); it("leaves a valid fence (close on its own line) unchanged", () => { const input = "```go\nfunc f() {}\n```\nprose"; expect(normalizeMarkdown(input)).toBe(input); }); it("leaves inline-code prose outside any fence unchanged", () => { const input = "Use `code` inline in a paragraph."; expect(normalizeMarkdown(input)).toBe(input); }); it("does not split when trailing run is shorter than opener", () => { // Opens with 4, line ends with 3 — those 3 backticks would not close, so // we must not split. The parser will keep gobbling, but at least we don't // invent a false close. const input = "````go\nfunc f() {}```\nstill code"; expect(normalizeMarkdown(input)).toBe(input); }); it("splits only the malformed fence when multiple fences appear", () => { const input = ["```go", "func f() {}```", "prose", "", "```ts", "const x = 1;", "```"].join( "\n", ); const expected = [ "```go", "func f() {}", "```", "prose", "", "```ts", "const x = 1;", "```", ].join("\n"); expect(normalizeMarkdown(input)).toBe(expected); }); it("handles up to 3 leading spaces on the opener", () => { const input = " ```go\nfunc f() {}```\nprose"; const expected = " ```go\nfunc f() {}\n```\nprose"; expect(normalizeMarkdown(input)).toBe(expected); }); it("returns pure prose unchanged", () => { const input = "Just some paragraph text with no fences.\nSecond line."; expect(normalizeMarkdown(input)).toBe(input); }); it("returns empty and single-line input unchanged", () => { expect(normalizeMarkdown("")).toBe(""); expect(normalizeMarkdown("single line")).toBe("single line"); }); it("preserves a trailing newline if present", () => { const input = "```go\nx```\nprose\n"; const expected = "```go\nx\n```\nprose\n"; expect(normalizeMarkdown(input)).toBe(expected); }); it("renders malformed fence + prose as two code blocks plus a paragraph", () => { const malformed = "```go\nfunc f() {}```\nprose continues here\n```ts\nconst x = 1;\n```"; const htmlRaw = renderMarkdown(malformed); const htmlFixed = renderMarkdown(normalizeMarkdown(malformed)); // Without normalization the parser swallows the prose into one code node. const rawBlockCount = (htmlRaw.match(/data-kind="block"/g) ?? []).length; const fixedBlockCount = (htmlFixed.match(/data-kind="block"/g) ?? []).length; expect(fixedBlockCount).toBeGreaterThan(rawBlockCount); expect(fixedBlockCount).toBe(2); // Prose paragraph is visible as its own node, not inside a code block. expect(htmlFixed).toContain("prose continues here"); expect(htmlFixed).toContain("language-go"); expect(htmlFixed).toContain("language-ts"); }); });