Files
paseo/packages/app/src/utils/rich-clipboard.test.ts
T
Mohamed BoudraandGitHub adf33930b3 Preserve assistant selection formatting when copying (#2808)
* fix(app): preserve formatting when copying assistant selections

Browser selection serialized React Native Web layout nodes instead of the assistant message semantics. Recover semantic markup from renderer-owned metadata and reuse the rich clipboard generator used by Copy turn.

* fix(app): preserve assistant copy semantics

Carry source-only Markdown metadata through the selection copy boundary, unwrap generated code links, and cover ordered starts, hard breaks, and fence languages in Playwright. Refresh the Nix dependency hash for the added clipboard dependency.

* fix(app): preserve partial selection context

Wrap selected fragments with their shared semantic ancestor chain and enable GFM table and strikethrough conversion. Cover partial nested selections and GFM output through the browser clipboard.

* fix(app): preserve copied list and autolink text

Adjust partial ordered-list starts for omitted siblings, strip parser-generated anchors before Markdown reconstruction, and refresh the Nix dependency hash computed by CI.

* fix(app): normalize partial selection context

Normalize cloned ordered lists across block boundaries, degrade incomplete table fragments to selected text, and preserve table alignment provenance through Markdown reconstruction.

* fix(app): escape copied table delimiters

Rendered table cells no longer retain source escape characters. Escape literal pipes during DOM-to-Markdown conversion so copied tables preserve their column structure.

* fix(app): preserve authored link semantics

The rendered link boundary conflated parser-generated links with authored autolinks and discarded authored titles. Carry title semantics into the anchor and unwrap only links with explicit generated provenance.

* fix(app): preserve nested copy structure

Blank-line splitting separated loose nested lists before Markdown rendering, leaving no nesting for clipboard serialization to preserve. Derive protected separators from the Markdown block structure and keep copy metadata independent from monospace surface styling.

* fix(app): preserve selected file links

Assistant messages and rich clipboard HTML must apply the same link validation policy. A shared parser keeps file links semantic in both MIME representations while retaining markdown-it's unsafe-scheme rejection.

* fix(app): preserve partial table content

A selected body row wider than its retained header cannot be represented as GFM and is truncated when rich HTML is regenerated. Treat only those over-wide fragments, plus headerless fragments, as incomplete while preserving valid short rows.
2026-08-03 19:02:31 +08:00

117 lines
3.6 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
createMarkdownClipboardContent,
type MarkdownClipboardEnvironment,
type RichClipboardWriter,
writeMarkdownToRichClipboard,
} from "./rich-clipboard";
interface RecordingClipboard {
environment: MarkdownClipboardEnvironment;
richWrites: Array<Record<"text/plain" | "text/html", Blob>>;
plainTexts: string[];
}
function createRecordingClipboard(
options: { supportsHtml?: boolean; richWriteFails?: boolean } = {},
): RecordingClipboard {
const { supportsHtml = true, richWriteFails = false } = options;
const richWrites: Array<Record<"text/plain" | "text/html", Blob>> = [];
const plainTexts: string[] = [];
const richWriter: RichClipboardWriter = {
supportsHtml: () => supportsHtml,
write: async (data) => {
if (richWriteFails) {
throw new Error("clipboard denied");
}
richWrites.push(data);
},
};
const environment: MarkdownClipboardEnvironment = {
richWriter,
writePlainText: async (text) => {
plainTexts.push(text);
},
};
return { environment, richWrites, plainTexts };
}
describe("createMarkdownClipboardContent", () => {
it("renders markdown structures to clipboard html", () => {
const markdown = [
"# Heading",
"",
"| Name | Value |",
"| --- | --- |",
"| One | Two |",
"",
"- Parent",
" - Child",
"",
"```ts",
"const value = 1;",
"```",
].join("\n");
const content = createMarkdownClipboardContent(markdown);
expect(content.plainText).toBe(markdown);
expect(content.html).toContain("<h1>Heading</h1>");
expect(content.html).toContain("<table>");
expect(content.html).toContain("<ul>");
expect(content.html).toContain('class="language-ts"');
});
it("escapes raw html instead of placing it on the rich clipboard", () => {
const content = createMarkdownClipboardContent(
'<script>alert("x")</script>\n\n[jump](javascript:alert("x"))',
);
expect(content.html).not.toContain("<script>");
expect(content.html).not.toContain('href="javascript:');
expect(content.html).toContain("&lt;script&gt;");
});
it("preserves assistant file links without allowing unsafe link schemes", () => {
const content = createMarkdownClipboardContent(
[
"[file](file:///tmp/paseo%20notes.md#L4)",
'[javascript](javascript:alert("x"))',
"[data](data:text/html,unsafe)",
"[vbscript](vbscript:msgbox(1))",
].join("\n\n"),
);
expect(content.html).toContain('<a href="file:///tmp/paseo%20notes.md#L4">file</a>');
expect(content.html).not.toMatch(/href="(?:javascript|data|vbscript):/);
});
});
describe("writeMarkdownToRichClipboard", () => {
it("writes plain text and html when a rich clipboard writer is available", async () => {
const clipboard = createRecordingClipboard();
await writeMarkdownToRichClipboard("- item", clipboard.environment);
const written = clipboard.richWrites[0];
if (!written) {
throw new Error("Expected rich clipboard data to be written");
}
await expect(written["text/plain"].text()).resolves.toBe("- item");
await expect(written["text/html"].text()).resolves.toContain("<li>item</li>");
expect(clipboard.plainTexts).toEqual([]);
});
it("falls back to plain text when rich clipboard writing fails", async () => {
const clipboard = createRecordingClipboard({ richWriteFails: true });
await writeMarkdownToRichClipboard("**bold**", clipboard.environment);
expect(clipboard.plainTexts).toEqual(["**bold**"]);
expect(clipboard.richWrites).toEqual([]);
});
});