ece8a51e22
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - This branch accumulated multiple already-tested control-plane, adapter runtime, invite, workspace, plugin, and UI quality fixes on the primary Paperclip checkout. > - `origin/master` advanced while those commits were still local, so the branch needed to be preserved and reconciled before review. > - Splitting the branch commit-by-commit against the new base produced overlapping conflicts with recently merged upstream PRs. > - This pull request keeps the remaining branch as one standalone PR because the final diff is 38 files after removing screenshot artifacts, under Greptile's 100-file cap, and can be merged independently after review. > - The benefit is that none of the local work is lost, the branch is now based on current `origin/master`, and reviewers can evaluate the reconciled changes in one place. ## What Changed - Merged the local accumulated branch with current `origin/master` and resolved the invite-flow overlaps from the newer upstream companies query helper. - Preserved the local fixes for invite existing-member behavior, invite link copy fallback, reusable workspace selection, worktree auth, static SPA fallback, markdown wrapping, plugin slot registration, cloud upstream UX/server polish, project sorting, and related tests. - Removed screenshot artifacts from the PR per review request. - Kept the PR under the requested file limit: 38 files changed, with no `pnpm-lock.yaml` or `.github/workflows/*` changes. ## Verification - `NODE_ENV=test pnpm exec vitest run ui/src/pages/CompanyInvites.test.tsx ui/src/pages/InviteLanding.test.tsx ui/src/pages/Projects.test.tsx ui/src/plugins/slots.test.ts ui/src/components/MarkdownBody.test.tsx server/src/__tests__/invite-accept-existing-member.test.ts server/src/__tests__/static-index-html.test.ts server/src/__tests__/execution-workspaces-service.test.ts server/src/__tests__/better-auth.test.ts server/src/__tests__/worktree-config.test.ts` - `NODE_ENV=test pnpm --filter @paperclipai/ui typecheck` - `NODE_ENV=test pnpm --filter @paperclipai/server typecheck` - Confirmed `git diff --name-only origin/master...HEAD | wc -l` is `38`. - Confirmed no PR diff entries match `pnpm-lock.yaml`, `.github/workflows/*`, or `screenshots/*`. ## Risks - Medium review risk because this is a bundled rescue PR rather than several narrow feature PRs. - Invite flow and company cache behavior overlapped with newer upstream changes; the merge resolution intentionally keeps the shared `companiesListQueryOptions` helper while preserving local existing-member invite behavior. - Visual review evidence is no longer attached in-repo because screenshots were removed from this PR per review request. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5-based coding agent, with repository tool access, terminal execution, and git/GitHub CLI operations. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] UI screenshots were intentionally removed from this PR per review request - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: CodexCoder <codexcoder@paperclip.local>
490 lines
19 KiB
TypeScript
490 lines
19 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import type { ComponentProps, ReactNode } from "react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { 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";
|
|
|
|
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>
|
|
),
|
|
}));
|
|
|
|
vi.mock("../api/issues", () => ({
|
|
issuesApi: mockIssuesApi,
|
|
}));
|
|
|
|
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 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("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("text-green-600");
|
|
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("text-red-600");
|
|
expect(html).toContain("text-green-600");
|
|
});
|
|
|
|
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("text-green-600");
|
|
expect(html).toContain("text-red-600");
|
|
});
|
|
|
|
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("text-green-600");
|
|
expect(html).toContain("paperclip-markdown-issue-ref");
|
|
});
|
|
|
|
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-[-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-[-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");
|
|
});
|
|
});
|