Files
paperclip/ui/src/components/MarkdownBody.test.tsx
T
Dotta 468edd8b22 Add workspace file viewer and artifact links (#7681)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent work is issue-centered, and reviewers often need to inspect
files, artifacts, and path references produced during that work.
> - Before this branch, workspace-relative paths and artifact file
references were not first-class inspectable objects in the board UI.
> - Safe file viewing needs shared resource contracts, server-side
workspace boundary checks, and UI that opens files without exposing
arbitrary host paths.
> - The workspace file viewer branch needed to stay as one active PR and
be rebased onto current `paperclipai/paperclip:master` for review.
> - This pull request adds the workspace file resource API, issue-page
file viewer and browser, markdown file-reference links, and artifact
file chips.
> - The benefit is that board users can inspect relevant files from
issue context while preserving workspace boundaries and auditability.

## Linked Issues or Issue Description

No public GitHub issue exists for this branch. Internal Paperclip
issues: `PAP-1953`, `PAP-10539`, `PAP-10733`.

Problem / motivation:
- Board users need to open workspace-relative files mentioned by agents
or attached as work-product metadata without switching to a terminal.
- The UI needs to support both direct file-path opening and workspace
browsing/searching from an issue page.
- The server must enforce company access, workspace boundaries, size
limits, rate limits, and safe audit logging.

Related PR:
- Prior closed attempt: #4442
- Single active PR for this branch: #7681

## What Changed

- Added shared workspace file resource types, validators, and
workspace-file `resourceRef` metadata validation for work products.
- Added server routes/services for resolving, listing, and previewing
workspace-relative files with access checks, scan caps, list-specific
limits, and audit logging.
- Added the issue file viewer provider, sheet, workspace browser,
command-palette action, markdown workspace-file autolinks, and artifact
file chips.
- Updated issue workspace UI and stories/tests for file browsing and
workspace file opening.
- Rebased the branch onto current `paperclipai/paperclip:master` and
updated the existing single PR branch.
- Addressed current-head Greptile follow-ups by applying `offset`
consistently across search/recent/changed file listings, restoring
stopped-service port ownership checks before auto-port reuse, and
stabilizing the workspace browser pagination test.

## Verification

Current local verification after rebase to `public/master`:
- `pnpm exec vitest run packages/shared/src/work-product.test.ts
server/src/__tests__/file-resources.test.ts
server/src/__tests__/instance-settings-routes.test.ts
server/src/__tests__/instance-settings-service.test.ts
server/src/__tests__/workspace-runtime.test.ts
ui/src/components/FileViewerSheet.test.tsx
ui/src/components/FileViewerSheet.copy.test.tsx
ui/src/components/WorkspaceFileBrowser.test.tsx
ui/src/components/WorkspaceFileMarkdownBody.test.tsx
ui/src/context/FileViewerContext.test.ts
ui/src/lib/remark-workspace-file-refs.test.ts
ui/src/lib/workspace-file-parser.test.ts
ui/src/components/IssueWorkspaceCard.test.tsx` - 13 files passed, 197
tests passed.
- `pnpm -r --filter @paperclipai/shared --filter @paperclipai/server
--filter @paperclipai/ui typecheck` - passed.
- `pnpm exec vitest run ui/src/components/WorkspaceFileBrowser.test.tsx`
- 1 file passed, 25 tests passed.
- `pnpm exec vitest run server/src/__tests__/file-resources.test.ts
server/src/__tests__/workspace-runtime.test.ts` - 2 files passed, 90
tests passed.
- `pnpm -r --filter @paperclipai/server typecheck` - passed.
- Confirmed branch is `0` behind and `46` ahead of current
`public/master` after rebase and follow-up commits.
- Confirmed the PR diff does not include `pnpm-lock.yaml`.
- Confirmed the PR diff does not include `.github/workflows` changes.
- Searched GitHub for duplicate or related workspace file viewer
PRs/issues; #4442 is the prior closed attempt and this PR is the single
active PR for the branch.
- No screenshots were committed; the task explicitly asked not to add
design screenshots or images unless they were part of the work.

Current remote verification on head
`a698a7bc10137baf7d25bd5722e1d6e0343387c1`:
- Greptile Review - success, 64 files reviewed, 0 comments added, no
unresolved Greptile review threads.
- PR workflow `verify` - success.
- Typecheck + Release Registry, General tests, workspace test shards,
serialized server suites, Build, Canary Dry Run, e2e, Socket, and Snyk -
success.
- `security-review` - neutral, with output saying a draft advisory was
filed for maintainer review and is not a merge block.
- `commitperclip PR Review / review` - cancelled after the security gate
detected flags and timed out while creating/reviewing the advisory. I
reran it once and it cancelled the same way; no actionable code/test
failure was exposed in the job logs.

## Risks

- This is a broad UI/server feature PR, so review needs to pay attention
to route authorization, workspace boundary handling, and markdown
autolink false positives.
- Workspace browsing intentionally caps list results and scan depth;
very large workspaces may require users to refine search terms.
- Remote workspace preview remains unavailable until remote file-access
support is implemented.
- The neutral commitperclip security-review advisory needs maintainer
review, but the check output says it is not a merge block.

> 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 coding agent in a Paperclip/Codex local tool-use
environment, medium reasoning, with shell/GitHub CLI tool use for branch
inspection, verification, rebase, PR update, Greptile review, and CI
inspection.

## 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 searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [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>
2026-06-09 17:17:43 -05:00

558 lines
22 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";
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>{"![](/api/attachments/test/content)"}</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}`}>
{"![Org chart](images/org-chart.png)"}
</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("&lt;script&gt;");
expect(html).toContain("onclick=&quot;steal()&quot;");
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("[&amp;_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("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" }],
{ linkWorkspaceFileRefs: true },
);
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-[38ch]");
expect(html).not.toContain("paperclip-markdown-issue-ref");
expect(html).not.toContain('href="/issues/PAP-10306"');
});
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");
});
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("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"');
});
});