mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
Add shared rich ACP transport, durable questions and permissions, verified provider packaging, and bounded activity and plan presentation. Keep Cursor, Copilot, and Pi pending their separate provider qualification. Persist interaction settlement before publication, fence failed writes until fresh recovery, and preserve owned-process cleanup. Incorporate reviewed mainline integration with extended harness coverage. Co-Authored-By: Paperclip <noreply@paperclip.ing>
698 lines
28 KiB
TypeScript
698 lines
28 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import type { ComponentProps, ReactNode } from "react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import {
|
|
buildAgentMentionHref,
|
|
buildIssueReferenceHref,
|
|
buildProjectMentionHref,
|
|
buildRoutineMentionHref,
|
|
buildSkillMentionHref,
|
|
buildUserMentionHref,
|
|
} from "@paperclipai/shared";
|
|
import { ThemeProvider } from "../context/ThemeContext";
|
|
import { MarkdownBody } from "./MarkdownBody";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import type { WorkspaceFileAvailabilityTarget } from "../lib/workspace-file-availability";
|
|
|
|
/** Stands in for a server-confirmed openable reference in the issue's workspace. */
|
|
const OPENABLE_AUTO_TARGET: WorkspaceFileAvailabilityTarget = {
|
|
workspace: "auto",
|
|
projectId: null,
|
|
workspaceId: null,
|
|
projectName: null,
|
|
};
|
|
|
|
const mockIssuesApi = vi.hoisted(() => ({
|
|
get: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/lib/router", () => ({
|
|
Link: ({
|
|
children,
|
|
to,
|
|
...props
|
|
}: { children: ReactNode; to: string } & React.ComponentProps<"a">) => (
|
|
<a href={to} {...props}>{children}</a>
|
|
),
|
|
useLocation: () => ({
|
|
pathname: "/PAP/issues/PAP-10306",
|
|
search: "",
|
|
hash: "",
|
|
state: null,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../api/issues", () => ({
|
|
issuesApi: mockIssuesApi,
|
|
}));
|
|
|
|
// Defaults to null (no provider) so the existing suite exercises the permissive
|
|
// path unchanged. Gating tests override the return value per-case.
|
|
const mockUseOptionalCompany = vi.hoisted(() => vi.fn<() => { companies: Array<{ issuePrefix: string }> } | null>(() => null));
|
|
|
|
vi.mock("../context/CompanyContext", () => ({
|
|
useOptionalCompany: mockUseOptionalCompany,
|
|
}));
|
|
|
|
afterEach(() => {
|
|
mockUseOptionalCompany.mockReturnValue(null);
|
|
});
|
|
|
|
function renderMarkdown(
|
|
children: string,
|
|
seededIssues: Array<{ identifier: string; status: string; title?: string }> = [],
|
|
props: Partial<ComponentProps<typeof MarkdownBody>> = {},
|
|
) {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
retry: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
for (const issue of seededIssues) {
|
|
queryClient.setQueryData(queryKeys.issues.detail(issue.identifier), {
|
|
id: issue.identifier,
|
|
identifier: issue.identifier,
|
|
status: issue.status,
|
|
title: issue.title,
|
|
});
|
|
}
|
|
|
|
return renderToStaticMarkup(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ThemeProvider>
|
|
<MarkdownBody {...props}>{children}</MarkdownBody>
|
|
</ThemeProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
describe("MarkdownBody", () => {
|
|
it("renders markdown images without a resolver", () => {
|
|
const html = renderToStaticMarkup(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<ThemeProvider>
|
|
<MarkdownBody>{""}</MarkdownBody>
|
|
</ThemeProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
|
|
expect(html).toContain('<img src="/api/attachments/test/content" alt=""/>');
|
|
});
|
|
|
|
it("resolves relative image paths when a resolver is provided", () => {
|
|
const html = renderToStaticMarkup(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<ThemeProvider>
|
|
<MarkdownBody resolveImageSrc={(src) => `/resolved/${src}`}>
|
|
{""}
|
|
</MarkdownBody>
|
|
</ThemeProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
|
|
expect(html).toContain('src="/resolved/images/org-chart.png"');
|
|
expect(html).toContain('alt="Org chart"');
|
|
});
|
|
|
|
it("renders decision images as inert references without resolving or preloading them", () => {
|
|
const resolveImageSrc = vi.fn((src: string) => `https://resolver.invalid/${src}`);
|
|
const html = renderMarkdown(
|
|
'# Plan\n\n**Keep this step.**\n\n\n\n\n\n',
|
|
[], { mediaMode: "reference", resolveImageSrc },
|
|
);
|
|
|
|
expect(html).toContain("<h1>Plan</h1>");
|
|
expect(html).toContain("<strong>Keep this step.</strong>");
|
|
expect(html).toContain("Image: Evidence (https://provider.invalid/track.png)");
|
|
expect(html).toContain('title="Provider image"');
|
|
expect(html).toContain("Image: Relative (images/proof.png)");
|
|
expect(html).toContain("Image: Protocol relative (//provider.invalid/other.png)");
|
|
expect(html).not.toMatch(/<(?:img|link|iframe|video|audio)\b/);
|
|
expect(resolveImageSrc).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps diagram source inert in decision media mode", () => {
|
|
const source = 'flowchart LR\n A@{ img: "https://provider.invalid/diagram.png" }';
|
|
const html = renderMarkdown(`\`\`\`mermaid\n${source}\n\`\`\``, [], { mediaMode: "reference" });
|
|
|
|
expect(html).toContain('class="language-mermaid"');
|
|
expect(html).toContain("https://provider.invalid/diagram.png");
|
|
expect(html).not.toContain('class="paperclip-mermaid"');
|
|
expect(html).not.toContain("Rendering Mermaid diagram");
|
|
});
|
|
|
|
it("renders user, agent, project, skill, and routine mentions as chips", () => {
|
|
const html = renderToStaticMarkup(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<ThemeProvider>
|
|
<MarkdownBody>
|
|
{`[@Taylor](${buildUserMentionHref("user-123")}) [@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")}) [/routine:Weekly review](${buildRoutineMentionHref("routine-123")})`}
|
|
</MarkdownBody>
|
|
</ThemeProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
|
|
expect(html).toContain('href="/company/settings/access"');
|
|
expect(html).toContain('data-mention-kind="user"');
|
|
expect(html).toContain('href="/agents/agent-123"');
|
|
expect(html).toContain('data-mention-kind="agent"');
|
|
expect(html).toContain("--paperclip-mention-icon-mask");
|
|
expect(html).toContain('href="/projects/project-456"');
|
|
expect(html).toContain('data-mention-kind="project"');
|
|
expect(html).toContain("--paperclip-mention-project-color:#336699");
|
|
expect(html).toContain('href="/skills/skill-789"');
|
|
expect(html).toContain('data-mention-kind="skill"');
|
|
expect(html).toContain('href="/routines/routine-123"');
|
|
expect(html).toContain('data-mention-kind="routine"');
|
|
});
|
|
|
|
it("sanitizes unsafe javascript markdown links", () => {
|
|
const html = renderMarkdown("[click me](javascript:alert(document.cookie))");
|
|
|
|
expect(html).toContain('<a href="" rel="noreferrer"');
|
|
expect(html).toContain(">click me</a>");
|
|
expect(html).not.toContain("javascript:");
|
|
});
|
|
|
|
it("renders raw HTML tags as escaped text", () => {
|
|
const html = renderMarkdown(
|
|
'<script>fetch("/api/secrets")</script>\n<iframe src="https://example.com"></iframe>\n<p onclick="steal()">Plain text</p>',
|
|
);
|
|
|
|
expect(html).not.toContain("<script>");
|
|
expect(html).not.toContain("<iframe");
|
|
expect(html).not.toContain("<p onclick");
|
|
expect(html).not.toContain('onclick="steal()"');
|
|
expect(html).toContain("<script>");
|
|
expect(html).toContain("onclick="steal()"");
|
|
expect(html).toContain("Plain text");
|
|
});
|
|
|
|
it("hides markdown HTML comments instead of rendering placeholder text", () => {
|
|
const html = renderMarkdown("Before\n\n<!-- -->\n\nAfter");
|
|
|
|
expect(html).toContain("Before");
|
|
expect(html).toContain("After");
|
|
expect(html).not.toContain("<!--");
|
|
expect(html).not.toContain("-->");
|
|
});
|
|
|
|
it("hides escaped HTML comment placeholders before attachment images", () => {
|
|
const html = renderMarkdown("\\<!-- --> ");
|
|
|
|
expect(html).toContain('<img src="/api/attachments/57d0805a-1b95-4fa5-abb4-d0c33e2e649c/content" alt=""/>');
|
|
expect(html).not.toContain("<!--");
|
|
expect(html).not.toContain("-->");
|
|
});
|
|
|
|
it("hides incomplete streamed HTML comment placeholders before attachment images", () => {
|
|
const html = renderMarkdown("\\<!-- ");
|
|
|
|
expect(html).toContain('<img src="/api/attachments/57d0805a-1b95-4fa5-abb4-d0c33e2e649c/content" alt=""/>');
|
|
expect(html).not.toContain("<!--");
|
|
});
|
|
|
|
it("hides incomplete encoded HTML comment placeholders", () => {
|
|
const html = renderMarkdown("<!-- -- ");
|
|
|
|
expect(html).toContain('<img src="/api/attachments/57d0805a-1b95-4fa5-abb4-d0c33e2e649c/content" alt=""/>');
|
|
expect(html).not.toContain("<!--");
|
|
expect(html).not.toContain("&lt;!--");
|
|
});
|
|
|
|
it("keeps HTML comment markers when they are literal code content", () => {
|
|
const inlineHtml = renderMarkdown("Use `<!-- -->` as a literal.");
|
|
const blockHtml = renderMarkdown("```html\n<!-- keep this example -->\n```");
|
|
|
|
expect(inlineHtml).toContain("<!-- -->");
|
|
expect(blockHtml).toContain("<!-- keep this example -->");
|
|
});
|
|
|
|
it("uses soft-break styling by default", () => {
|
|
const html = renderMarkdown("First line\nSecond line");
|
|
|
|
expect(html).toContain("First line<br/>");
|
|
expect(html).toContain("Second line");
|
|
});
|
|
|
|
it("can opt out of soft-break styling", () => {
|
|
const html = renderToStaticMarkup(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<ThemeProvider>
|
|
<MarkdownBody softBreaks={false}>
|
|
{"First line\nSecond line"}
|
|
</MarkdownBody>
|
|
</ThemeProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
|
|
expect(html).not.toContain("<br/>");
|
|
});
|
|
|
|
it("does not inject extra line-break nodes into nested lists", () => {
|
|
const html = renderMarkdown("1. Parent item\n - child a\n - child b\n\n2. Second item");
|
|
|
|
expect(html).not.toContain("[&_p]:whitespace-pre-line");
|
|
expect(html).not.toContain("Parent item<br/>");
|
|
expect(html).toContain("<ol>");
|
|
expect(html).toContain("<ul>");
|
|
});
|
|
|
|
it("linkifies bare issue identifiers in markdown text", () => {
|
|
const html = renderMarkdown("Depends on PAP-1271 for the hover state.", [
|
|
{ identifier: "PAP-1271", status: "done" },
|
|
]);
|
|
|
|
expect(html).toContain('href="/issues/PAP-1271"');
|
|
expect(html).toContain("var(--status-task-icon-done)");
|
|
expect(html).toContain(">PAP-1271<");
|
|
expect(html).toContain('data-mention-kind="issue"');
|
|
expect(html).toContain("paperclip-markdown-issue-ref");
|
|
expect(html).not.toContain("paperclip-mention-chip--issue");
|
|
});
|
|
|
|
it("uses concise issue aria labels until a distinct title is available", () => {
|
|
const html = renderMarkdown("Depends on PAP-1271 and PAP-1272.", [
|
|
{ identifier: "PAP-1271", status: "done" },
|
|
{ identifier: "PAP-1272", status: "blocked", title: "Fix hover state" },
|
|
]);
|
|
|
|
expect(html).toContain('aria-label="Issue PAP-1271"');
|
|
expect(html).toContain('aria-label="Issue PAP-1272: Fix hover state"');
|
|
expect(html).not.toContain('aria-label="Issue PAP-1271: PAP-1271"');
|
|
});
|
|
|
|
it("preserves absolute issue URLs as external links", () => {
|
|
const url = "http://remote.example.test:3103/PAPA/issues/PAPA-115#comment-850083f3-24de-43e7-a8cd-bc01f7cc9f0d";
|
|
const html = renderMarkdown(`See ${url}.`, [
|
|
{ identifier: "PAPA-115", status: "blocked" },
|
|
]);
|
|
|
|
expect(html).toContain(`href="${url}"`);
|
|
expect(html).toContain('target="_blank"');
|
|
expect(html).toContain("lucide-external-link");
|
|
expect(html).not.toContain('href="/issues/PAPA-115"');
|
|
expect(html).not.toContain("paperclip-markdown-issue-ref");
|
|
});
|
|
|
|
it("linkifies plain internal issue paths in markdown text", () => {
|
|
const html = renderMarkdown("See /issues/PAP-1179 and /PAP/issues/pap-1180 for context.", [
|
|
{ identifier: "PAP-1179", status: "blocked" },
|
|
{ identifier: "PAP-1180", status: "done" },
|
|
]);
|
|
|
|
expect(html).toContain('href="/issues/PAP-1179"');
|
|
expect(html).toContain('href="/issues/PAP-1180"');
|
|
expect(html).toContain(">/issues/PAP-1179<");
|
|
expect(html).toContain(">/PAP/issues/pap-1180<");
|
|
expect(html).toContain("var(--status-task-icon-blocked)");
|
|
expect(html).toContain("var(--status-task-icon-done)");
|
|
});
|
|
|
|
it("does not auto-link non-issue internal route paths", () => {
|
|
const html = renderMarkdown("Use /issues/new for the creation form, /issues/PAP-42extra as text, and /api/issues for data.");
|
|
|
|
expect(html).toContain("Use /issues/new for the creation form, /issues/PAP-42extra as text, and /api/issues for data.");
|
|
expect(html).not.toContain('href="/issues/new"');
|
|
expect(html).not.toContain('href="/issues/PAP-42"');
|
|
expect(html).not.toContain('data-mention-kind="issue"');
|
|
});
|
|
|
|
it("rewrites issue scheme links to internal issue links", () => {
|
|
const html = renderMarkdown("See issue://PAP-1310 and issue://:PAP-1311.", [
|
|
{ identifier: "PAP-1310", status: "done" },
|
|
{ identifier: "PAP-1311", status: "blocked" },
|
|
]);
|
|
|
|
expect(html).toContain('href="/issues/PAP-1310"');
|
|
expect(html).toContain('href="/issues/PAP-1311"');
|
|
expect(html).toContain(">issue://PAP-1310<");
|
|
expect(html).toContain(">issue://:PAP-1311<");
|
|
expect(html).toContain("var(--status-task-icon-done)");
|
|
expect(html).toContain("var(--status-task-icon-blocked)");
|
|
});
|
|
|
|
it("linkifies issue identifiers inside inline code spans", () => {
|
|
const html = renderMarkdown("Reference `PAP-1271` here.", [
|
|
{ identifier: "PAP-1271", status: "done" },
|
|
]);
|
|
|
|
expect(html).toContain('href="/issues/PAP-1271"');
|
|
expect(html).toContain('<code style="overflow-wrap:anywhere;word-break:break-word">PAP-1271</code>');
|
|
expect(html).toContain("var(--status-task-icon-done)");
|
|
expect(html).toContain("paperclip-markdown-issue-ref");
|
|
});
|
|
|
|
it("renders linked inline-code workspace paths as file viewer links before issue links", () => {
|
|
const html = renderMarkdown(
|
|
"- **MP4**: [`videos/90-days-paperclip/out/90-days-paperclip-1x1.mp4`](/PAP/issues/PAP-10306 \"Publish handoff\")",
|
|
[{ identifier: "PAP-10306", status: "in_review", title: "Publish handoff" }],
|
|
{ resolveWorkspaceFileRef: () => OPENABLE_AUTO_TARGET },
|
|
);
|
|
|
|
expect(html).toContain('data-workspace-file-link="true"');
|
|
expect(html).toContain('data-workspace-file-path="videos/90-days-paperclip/out/90-days-paperclip-1x1.mp4"');
|
|
expect(html).toContain("videos/90-days-paperclip/out/90-days-paperclip-1x1.mp4");
|
|
expect(html).not.toContain("max-w-(--sz-38ch)");
|
|
expect(html).not.toContain("paperclip-markdown-issue-ref");
|
|
expect(html).not.toContain('href="/issues/PAP-10306"');
|
|
});
|
|
|
|
it("renders auto-detected workspace paths as plain code without an availability resolver", () => {
|
|
const html = renderMarkdown("Check `ui/src/pages/IssueDetail.tsx:42` please.");
|
|
|
|
expect(html).not.toContain("data-workspace-file-link");
|
|
expect(html).not.toContain("paperclip-workspace-file-link");
|
|
expect(html).toContain("ui/src/pages/IssueDetail.tsx:42");
|
|
});
|
|
|
|
it("keeps a non-openable auto-detected path as plain code with no chip affordances", () => {
|
|
const html = renderMarkdown(
|
|
"Check `ui/src/pages/IssueDetail.tsx:42` please.",
|
|
[],
|
|
{ resolveWorkspaceFileRef: () => null },
|
|
);
|
|
|
|
expect(html).not.toContain("data-workspace-file-link");
|
|
expect(html).not.toContain('role="button"');
|
|
expect(html).not.toContain("paperclip-workspace-file-link");
|
|
expect(html).toContain("<code");
|
|
});
|
|
|
|
it("keeps an explicit markdown link ordinary when its path is not openable", () => {
|
|
const html = renderMarkdown(
|
|
"See [`ui/src/a.ts:1`](/PAP/issues/PAP-10306)",
|
|
[{ identifier: "PAP-10306", status: "todo" }],
|
|
{ resolveWorkspaceFileRef: () => null },
|
|
);
|
|
|
|
expect(html).not.toContain("data-workspace-file-link");
|
|
expect(html).toContain('href="/issues/PAP-10306"');
|
|
});
|
|
|
|
it("promotes an openable auto-detected path to a workspace file chip", () => {
|
|
const html = renderMarkdown(
|
|
"Check `ui/src/pages/IssueDetail.tsx:42` please.",
|
|
[],
|
|
{ resolveWorkspaceFileRef: () => OPENABLE_AUTO_TARGET },
|
|
);
|
|
|
|
expect(html).toContain('data-workspace-file-link="true"');
|
|
expect(html).toContain('data-workspace-file-path="ui/src/pages/IssueDetail.tsx"');
|
|
});
|
|
|
|
it("keeps trailing punctuation outside auto-linked issue references", () => {
|
|
const html = renderMarkdown("See PAP-1271: /issues/PAP-1272] and issue://PAP-1273.", [
|
|
{ identifier: "PAP-1271", status: "done" },
|
|
{ identifier: "PAP-1272", status: "blocked" },
|
|
{ identifier: "PAP-1273", status: "todo" },
|
|
]);
|
|
|
|
expect(html).toContain('<a href="/issues/PAP-1271"');
|
|
expect(html).toContain('>PAP-1271</a>:');
|
|
expect(html).toContain('<a href="/issues/PAP-1272"');
|
|
expect(html).toContain('>/issues/PAP-1272</a>]');
|
|
expect(html).toContain('<a href="/issues/PAP-1273"');
|
|
expect(html).toContain('>issue://PAP-1273</a>.');
|
|
});
|
|
|
|
it("can opt out of issue reference linkification for offline previews", () => {
|
|
const html = renderToStaticMarkup(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<ThemeProvider>
|
|
<MarkdownBody linkIssueReferences={false}>
|
|
{"Depends on PAP-1271 and [manual link](PAP-1271)."}
|
|
</MarkdownBody>
|
|
</ThemeProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
|
|
expect(html).not.toContain('href="/issues/PAP-1271"');
|
|
expect(html).toContain("Depends on PAP-1271");
|
|
expect(html).toContain('href="PAP-1271"');
|
|
});
|
|
|
|
it("leaves wiki links as text unless explicitly enabled", () => {
|
|
const html = renderMarkdown("See [[wiki/entities/paperclip]].");
|
|
|
|
expect(html).toContain("[[wiki/entities/paperclip]]");
|
|
expect(html).not.toContain('href="/wiki/page/wiki/entities/paperclip.md"');
|
|
});
|
|
|
|
it("renders wiki links with a custom resolver when enabled", () => {
|
|
const html = renderMarkdown(
|
|
"See [[wiki/entities/paperclip|Paperclip]] and [[wiki/entities/dotta-b]].",
|
|
[],
|
|
{
|
|
enableWikiLinks: true,
|
|
resolveWikiLinkHref: (target) => `/wiki/page/${target.endsWith(".md") ? target : `${target}.md`}`,
|
|
},
|
|
);
|
|
|
|
expect(html).toContain('href="/wiki/page/wiki/entities/paperclip.md"');
|
|
expect(html).toContain('data-paperclip-wiki-link="true"');
|
|
expect(html).toContain('data-paperclip-wiki-target="wiki/entities/paperclip"');
|
|
expect(html).toContain(">Paperclip</a>");
|
|
expect(html).toContain('href="/wiki/page/wiki/entities/dotta-b.md"');
|
|
expect(html).toContain(">wiki/entities/dotta-b</a>");
|
|
expect(html).not.toContain("[[wiki/entities/paperclip");
|
|
});
|
|
|
|
it("keeps wiki links as text when the custom resolver rejects them", () => {
|
|
const html = renderMarkdown(
|
|
"See [[wiki/entities/paperclip]].",
|
|
[],
|
|
{
|
|
enableWikiLinks: true,
|
|
wikiLinkRoot: "/wiki/page",
|
|
resolveWikiLinkHref: () => null,
|
|
},
|
|
);
|
|
|
|
expect(html).toContain("[[wiki/entities/paperclip]]");
|
|
expect(html).not.toContain('data-paperclip-wiki-link="true"');
|
|
expect(html).not.toContain('href="/wiki/page/wiki/entities/paperclip"');
|
|
});
|
|
|
|
it("does not render wiki links inside code spans or code blocks", () => {
|
|
const html = renderMarkdown(
|
|
"Inline `[[wiki/entities/paperclip]]`.\n\n```md\n[[wiki/entities/dotta-b]]\n```",
|
|
[],
|
|
{
|
|
enableWikiLinks: true,
|
|
wikiLinkRoot: "/wiki/page",
|
|
},
|
|
);
|
|
|
|
expect(html).toContain("[[wiki/entities/paperclip]]");
|
|
expect(html).toContain("[[wiki/entities/dotta-b]]");
|
|
expect(html).not.toContain('href="/wiki/page/wiki/entities/paperclip"');
|
|
expect(html).not.toContain('href="/wiki/page/wiki/entities/dotta-b"');
|
|
});
|
|
|
|
it("applies wrap-friendly styles to long inline content", () => {
|
|
const html = renderMarkdown("averyveryveryveryveryveryveryveryveryverylongtoken");
|
|
|
|
expect(html).toContain('class="paperclip-markdown prose prose-sm min-w-0 max-w-full break-words overflow-hidden');
|
|
expect(html).toContain('style="overflow-wrap:anywhere;word-break:break-word"');
|
|
expect(html).toContain("<p");
|
|
});
|
|
|
|
it("applies wrap-friendly styles to long links", () => {
|
|
const html = renderMarkdown("[link](https://example.com/reallyreallyreallyreallyreallyreallyreallyreallylong)");
|
|
|
|
expect(html).toContain('<a href="https://example.com/reallyreallyreallyreallyreallyreallyreallyreallylong"');
|
|
expect(html).toContain('style="overflow-wrap:anywhere;word-break:break-word"');
|
|
});
|
|
|
|
it("renders markdown tables in a horizontally scrollable region", () => {
|
|
const html = renderMarkdown([
|
|
"| Time UTC | Source | Finding | Stalled leaf | Escalation |",
|
|
"| --- | --- | --- | --- | --- |",
|
|
"| 2026-04-30T14:31:35Z | PAP-2505 | in_review_without_action_path | PAP-2779 | PAP-2910 |",
|
|
].join("\n"));
|
|
|
|
expect(html).toContain('class="paperclip-markdown-table-scroll"');
|
|
expect(html).toContain('aria-label="Scrollable table"');
|
|
expect(html).toContain('tabindex="0"');
|
|
expect(html).toContain("<table>");
|
|
expect(html).toContain('style="overflow-wrap:anywhere;word-break:normal"');
|
|
});
|
|
|
|
it("opens external links in a new tab with safe rel attributes", () => {
|
|
const html = renderMarkdown("[docs](https://example.com/docs)");
|
|
|
|
expect(html).toContain('href="https://example.com/docs"');
|
|
expect(html).toContain('target="_blank"');
|
|
expect(html).toContain('rel="noopener noreferrer"');
|
|
});
|
|
|
|
it("opens GitHub links in a new tab", () => {
|
|
const html = renderMarkdown("[pr](https://github.com/paperclipai/paperclip/pull/4099)");
|
|
|
|
expect(html).toContain('target="_blank"');
|
|
expect(html).toContain('rel="noopener noreferrer"');
|
|
});
|
|
|
|
it("does not set target on relative internal links", () => {
|
|
const html = renderMarkdown("[settings](/company/settings)");
|
|
|
|
expect(html).toContain('href="/company/settings"');
|
|
expect(html).not.toContain('target="_blank"');
|
|
expect(html).toContain('rel="noreferrer"');
|
|
});
|
|
|
|
it("prefixes GitHub markdown links with the GitHub icon glued to the first character", () => {
|
|
const html = renderMarkdown("[https://github.com/paperclipai/paperclip/pull/4099](https://github.com/paperclipai/paperclip/pull/4099)");
|
|
|
|
expect(html).toContain('<a href="https://github.com/paperclipai/paperclip/pull/4099"');
|
|
expect(html).toContain('class="lucide lucide-github mr-1 inline h-3.5 w-3.5 align-(--va-0_125em)"');
|
|
// The icon and first character "h" must sit in a no-wrap span so the
|
|
// icon can never be orphaned on the previous line from the URL text.
|
|
expect(html).toMatch(/<span style="white-space:nowrap">.*lucide-github.*?<\/svg>h<\/span>/);
|
|
expect(html).toContain("ttps://github.com/paperclipai/paperclip/pull/4099");
|
|
expect(html).not.toContain("lucide-external-link");
|
|
});
|
|
|
|
it("prefixes GitHub autolinks with the GitHub icon", () => {
|
|
const html = renderMarkdown("See https://github.com/paperclipai/paperclip/issues/1778");
|
|
|
|
expect(html).toContain('<a href="https://github.com/paperclipai/paperclip/issues/1778"');
|
|
expect(html).toContain('class="lucide lucide-github mr-1 inline h-3.5 w-3.5 align-(--va-0_125em)"');
|
|
});
|
|
|
|
it("does not prefix non-GitHub markdown links with the GitHub icon", () => {
|
|
const html = renderMarkdown("[docs](https://example.com/docs)");
|
|
|
|
expect(html).toContain('<a href="https://example.com/docs"');
|
|
expect(html).not.toContain("lucide-github");
|
|
});
|
|
|
|
it("suffixes external links with a new-tab icon glued to the last character", () => {
|
|
const html = renderMarkdown("[docs](https://example.com/docs)");
|
|
|
|
expect(html).toContain('target="_blank"');
|
|
expect(html).toContain("lucide-external-link");
|
|
// Last character "s" must sit in a no-wrap span with the icon so the
|
|
// indicator never wraps away from the link text.
|
|
expect(html).toMatch(/<span style="white-space:nowrap">s<svg[^>]*lucide-external-link/);
|
|
});
|
|
|
|
it("does not render the new-tab icon on internal links", () => {
|
|
const html = renderMarkdown("[settings](/company/settings)");
|
|
|
|
expect(html).not.toContain("lucide-external-link");
|
|
});
|
|
|
|
it("keeps fenced code blocks width-bounded and horizontally scrollable", () => {
|
|
const html = renderMarkdown("```text\nGET /heartbeat-runs/ca5d23fc-c15b-4826-8ff1-2b6dd11be096/log?offset=2062357&limitBytes=256000\n```");
|
|
|
|
expect(html).toContain("<pre");
|
|
expect(html).toContain('style="max-width:100%;overflow-x:auto"');
|
|
});
|
|
|
|
it("renders a copy button alongside fenced code blocks", () => {
|
|
const html = renderMarkdown("```ts\nconst a = 1;\n```");
|
|
|
|
expect(html).toContain("paperclip-markdown-codeblock");
|
|
expect(html).toContain("paperclip-markdown-codeblock-actions");
|
|
expect(html).toContain("position:absolute;top:0.4rem;right:0.4rem;display:inline-flex");
|
|
expect(html).toContain("paperclip-markdown-codeblock-wrap");
|
|
expect(html).toContain('aria-label="Wrap lines"');
|
|
expect(html).toContain("position:static;opacity:1;display:inline-flex");
|
|
expect(html).toContain("paperclip-markdown-codeblock-copy");
|
|
expect(html).toContain('aria-label="Copy code"');
|
|
expect(html).toContain("lucide-copy");
|
|
});
|
|
|
|
it("renders code block actions for indented preformatted markdown blocks", () => {
|
|
const html = renderMarkdown("Plan:\n\n source fetch/sync -> signal inbox");
|
|
|
|
expect(html).toContain("paperclip-markdown-codeblock");
|
|
expect(html).toContain("paperclip-markdown-codeblock-wrap");
|
|
expect(html).toContain('aria-label="Wrap lines"');
|
|
expect(html).toContain("paperclip-markdown-codeblock-copy");
|
|
});
|
|
|
|
it("does not render a copy button on inline code", () => {
|
|
const html = renderMarkdown("Reference `inline-code` here.");
|
|
|
|
expect(html).not.toContain("paperclip-markdown-codeblock-copy");
|
|
});
|
|
|
|
it("renders internal issue links and bare identifiers as inline issue refs", () => {
|
|
const html = renderMarkdown(`See PAP-42 and [linked task](${buildIssueReferenceHref("PAP-77")}) for follow-up.`, [
|
|
{ identifier: "PAP-42", status: "done" },
|
|
{ identifier: "PAP-77", status: "blocked" },
|
|
]);
|
|
|
|
expect(html).toContain('href="/issues/PAP-42"');
|
|
expect(html).toContain('href="/issues/PAP-77"');
|
|
expect(html).toContain('data-mention-kind="issue"');
|
|
expect(html).toContain("paperclip-markdown-issue-ref");
|
|
expect(html).not.toContain("paperclip-mention-chip--issue");
|
|
});
|
|
|
|
it("gates bare-identifier auto-linking to known company prefixes", () => {
|
|
mockUseOptionalCompany.mockReturnValue({ companies: [{ issuePrefix: "PAP" }] });
|
|
|
|
const html = renderMarkdown("Depends on PAP-1271 and blocked by JIRA-2.", [
|
|
{ identifier: "PAP-1271", status: "done" },
|
|
{ identifier: "JIRA-2", status: "done" },
|
|
]);
|
|
|
|
// Known prefix links; foreign tracker key stays as plain text.
|
|
expect(html).toContain('href="/issues/PAP-1271"');
|
|
expect(html).not.toContain('href="/issues/JIRA-2"');
|
|
expect(html).toContain("blocked by JIRA-2.");
|
|
});
|
|
|
|
it("stays permissive when companies are loaded but the list is empty", () => {
|
|
mockUseOptionalCompany.mockReturnValue({ companies: [] });
|
|
|
|
const html = renderMarkdown("See JIRA-2 for context.", [
|
|
{ identifier: "JIRA-2", status: "done" },
|
|
]);
|
|
|
|
expect(html).toContain('href="/issues/JIRA-2"');
|
|
});
|
|
|
|
it("renders the inline mention status glyph at md (16px / h-4 w-4)", () => {
|
|
const html = renderMarkdown("See PAP-1271 for context.", [
|
|
{ identifier: "PAP-1271", status: "in_progress" },
|
|
]);
|
|
|
|
// Unified glyph at 16px (PAP-349 round 4: stepped down from lg), with the
|
|
// h-4 w-4 class override so the Tailwind sizing matches the intrinsic SVG
|
|
// size.
|
|
expect(html).toContain('viewBox="0 0 24 24"');
|
|
expect(html).toContain('width="16"');
|
|
expect(html).toContain('height="16"');
|
|
expect(html).toContain("h-4");
|
|
expect(html).toContain("w-4");
|
|
// PAP-243b: the glyph is optically centered to the body text
|
|
// (vertical-align: middle + a 1px lift), not floating off the baseline.
|
|
expect(html).toContain("align-middle");
|
|
expect(html).not.toContain("align-(--va-0_125em)");
|
|
// Legacy h-3 w-3 sizing is gone.
|
|
expect(html).not.toContain("mr-1 h-3 w-3");
|
|
});
|
|
|
|
it("never gates explicit internal issue paths, even for unknown prefixes", () => {
|
|
mockUseOptionalCompany.mockReturnValue({ companies: [{ issuePrefix: "PAP" }] });
|
|
|
|
const html = renderMarkdown("See /ACME/issues/ACME-1 for the writeup.", [
|
|
{ identifier: "ACME-1", status: "done" },
|
|
]);
|
|
|
|
expect(html).toContain('href="/issues/ACME-1"');
|
|
});
|
|
|
|
});
|