import { afterEach, describe, expect, it } from "vitest"; import { createAssistantSelectionClipboardContent } from "./content.web"; const fixture = `
" },
])(
"copies a partial $tag range without expanding to its delimiters",
({ tag, range, expected, forbiddenHtml }) => {
const message = mountFixture();
const element = fixtureElement(message, `[data-paseo-markdown-tag="${tag}"]`);
const content = createAssistantSelectionClipboardContent(
selectText(element, range[0], range[1]),
);
expect(content?.plainText).toBe(expected);
expect(content?.html).not.toContain(forbiddenHtml);
},
);
it("retains strong delimiters when its complete contents are selected", () => {
const message = mountFixture();
const element = fixtureElement(message, '[data-paseo-markdown-tag="strong"]');
const content = createAssistantSelectionClipboardContent(
selectText(element, 0, textNode(element).length),
);
expect(content?.plainText).toBe("**bold text**");
expect(content?.html).toContain("");
});
it("copies complete inline code without delimiters when the selection stays inside", () => {
const message = mountFixture();
const element = fixtureElement(message, '[data-paseo-markdown-tag="code"]');
const content = createAssistantSelectionClipboardContent(
selectText(element, 0, textNode(element).length),
);
expect(content?.plainText).toBe("inline code");
expect(content?.html).not.toContain("");
});
it("keeps a complete inline node but drops formatting from a partial node at the other edge", () => {
const message = mountFixture();
const strong = fixtureElement(message, '[data-paseo-markdown-tag="strong"]');
const code = fixtureElement(message, '[data-paseo-markdown-tag="code"]');
expect(copiedMarkdown(selectRange(strong, 0, code, 6))).toBe("**bold text** and inline");
});
it("copies list-item text without inventing a bullet", () => {
const message = mountFixture();
const itemText = fixtureElement(message, '[data-paseo-markdown-tag="li"] div span');
const content = createAssistantSelectionClipboardContent(
selectText(itemText, 0, textNode(itemText).length),
);
expect(content?.plainText).toBe("First bullet text");
expect(content?.html).toContain("First bullet text
");
expect(content?.html).not.toContain("- ");
});
it("retains a bullet when the range includes the marker and the complete item", () => {
const message = mountFixture();
const item = fixtureElement(message, '[data-paseo-markdown-tag="li"]');
expect(copiedMarkdown(selectNodeContents(item))).toBe("- First bullet text");
});
it("preserves paragraph breaks when rich HTML flattens a loose list item", () => {
const message = mountFixture();
const item = fixtureElement(message, '[data-paseo-markdown-tag="li"]');
item.replaceChildren();
item.insertAdjacentHTML(
"beforeend",
'First paragraphSecond paragraph',
);
const content = createAssistantSelectionClipboardContent(selectNodeContents(item));
expect(content?.plainText).toBe("- First paragraph\n\n Second paragraph");
expect(content?.html).toMatch(/-\s*First paragraph\s*
Second paragraph\s*<\/div>/);
});
it("omits a partial leading bullet and retains the marker crossed before the trailing item", () => {
const message = mountFixture();
const selector = '[data-paseo-markdown-tag="li"] div span';
const first = fixtureElement(message, selector);
const second = fixtureElement(message, selector, 1);
expect(copiedMarkdown(selectRange(first, 6, second, textNode(second).length))).toBe(
"bullet text\n\n- Second bullet text",
);
});
it("copies part of a fenced code block without adding an inline or fenced delimiter", () => {
const message = mountFixture();
const blockCode = fixtureElement(
message,
'[data-paseo-markdown-tag="pre"] [data-paseo-markdown-tag="code"]',
);
const content = createAssistantSelectionClipboardContent(selectText(blockCode, 6, 12));
expect(content?.plainText).toBe("answer");
expect(content?.html).not.toContain("");
expect(content?.html).not.toContain("");
});
it("copies code without a fence when every character is selected inside the block", () => {
const message = mountFixture();
const blockCode = fixtureElement(
message,
'[data-paseo-markdown-tag="pre"] [data-paseo-markdown-tag="code"]',
);
expect(copiedMarkdown(selectText(blockCode, 0, textNode(blockCode).length))).toBe(
"const answer = true;",
);
});
});
/**
* The fixture above holds a code block in one text node. The real renderer splits a
* highlighted line across one text node per syntax token and emits each newline as a
* node of its own (`highlighted-code-block.tsx`), and it puts the hover Copy button
* inside the `pre`. Line breaks and indentation only survive if the code skips
* Turndown, so those cases need a fixture shaped like the real thing.
*/
function highlightedFixture(language: string | null): string {
const languageAttribute =
language === null ? "" : ` data-paseo-markdown-language="${escapeAttribute(language)}"`;
return [
'',
``,
'',
"const answer = 1;",
"\n",
" if (answer) {",
"\n",
" doThing();",
"",
'Copy',
"",
'After the block.',
"",
].join("");
}
function escapeAttribute(value: string): string {
const holder = document.createElement("div");
holder.setAttribute("data-value", value);
return holder.outerHTML.slice('".length);
}
function mountHighlighted(language: string | null = "typescript"): HTMLElement {
const host = document.createElement("div");
host.innerHTML = highlightedFixture(language);
document.body.append(host);
const message = host.querySelector('[data-testid="assistant-message"]');
if (!message) {
throw new Error("Expected assistant message fixture");
}
return message;
}
/** The single text node whose content is exactly `text`. */
function tokenText(root: Node, text: string): Text {
const matches = allTextNodes(root, text);
if (matches.length !== 1) {
throw new Error(`Expected exactly one text node "${text}", found ${matches.length}`);
}
return matches[0];
}
function allTextNodes(root: Node, text: string): Text[] {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const matches: Text[] = [];
while (walker.nextNode()) {
if (walker.currentNode.textContent === text) {
matches.push(walker.currentNode as Text);
}
}
return matches;
}
/** The standalone `\n` node that ends rendered code line `index`. */
function lineBreak(root: Node, index: number): Text {
const breaks = allTextNodes(root, "\n");
const node = breaks[index];
if (!node) {
throw new Error(`Expected a line break at index ${index}, found ${breaks.length}`);
}
return node;
}
function copyBetween(start: [Text, number], end: [Text, number]) {
const range = document.createRange();
range.setStart(start[0], start[1]);
range.setEnd(end[0], end[1]);
const selection = window.getSelection();
if (!selection) {
throw new Error("Expected browser selection");
}
selection.removeAllRanges();
selection.addRange(range);
return createAssistantSelectionClipboardContent(selection);
}
/** Select from the start of one text node to the end of another. */
function copyAcross(root: Node, startText: string, endText: string) {
return copyBetween([tokenText(root, startText), 0], [tokenText(root, endText), endText.length]);
}
describe("assistant selection copy inside highlighted code", () => {
it("keeps the line break and the indentation of a partial multi-line selection", () => {
const message = mountHighlighted();
const content = copyAcross(message, "const", " {");
expect(content?.plainText).toBe("const answer = 1;\n if (answer) {");
});
it("keeps multi-line code as a block in the html half so the lines survive", () => {
const message = mountHighlighted();
const content = copyAcross(message, "const", " {");
expect(content?.html).toBe(
'const answer = 1;\n if (answer) {
',
);
});
it("does not escape Markdown characters inside copied code", () => {
const message = mountHighlighted();
const content = copyBetween([tokenText(message, " = 1;"), 1], [tokenText(message, " = 1;"), 5]);
expect(content?.plainText).toBe("= 1;");
});
it("handles a common ancestor that is an element rather than a text node", () => {
const message = mountHighlighted();
// Spanning two token spans makes `commonAncestorContainer` the `code` element.
const content = copyBetween(
[tokenText(message, "const"), 1],
[tokenText(message, " answer"), 4],
);
expect(content?.plainText).toBe("onst ans");
});
it("drops a trailing line break swept in from the end of a line", () => {
const message = mountHighlighted();
const content = copyBetween([tokenText(message, " = 1;"), 1], [lineBreak(message, 0), 1]);
expect(content?.plainText).toBe("= 1;");
});
it("drops a trailing line break together with the indentation after it", () => {
const message = mountHighlighted();
const content = copyBetween([tokenText(message, "const"), 0], [tokenText(message, " if"), 2]);
expect(content?.plainText).toBe("const answer = 1;");
});
it("keeps line breaks that are not at the end of the selection", () => {
const message = mountHighlighted();
const content = copyBetween([tokenText(message, "const"), 0], [tokenText(message, " if"), 4]);
expect(content?.plainText).toBe("const answer = 1;\n if");
});
it("leaves the hover copy button out of the copied code", () => {
const message = mountHighlighted();
const content = copyAcross(message, " answer", "Copy");
expect(content?.plainText).toBe(" answer = 1;\n if (answer) {\n doThing();");
expect(content?.plainText).not.toContain("Copy");
});
it("does not treat a selection inside the copy button as code", () => {
const message = mountHighlighted();
expect(copyAcross(message, "Copy", "Copy")).toBeNull();
});
it("omits the language class when the fence carries no info string", () => {
const message = mountHighlighted(null);
const content = copyAcross(message, "const", " {");
expect(content?.html).toBe(
'const answer = 1;\n if (answer) {
',
);
});
it("escapes a fence info string that would break out of the class attribute", () => {
const message = mountHighlighted('ts" onload="x');
const content = copyAcross(message, "const", " {");
expect(content?.html).toContain('class="language-ts" onload="x"');
expect(content?.html).not.toContain('onload="x"');
});
it("copies a fully selected multi-line region as code when the selection stays inside", () => {
const message = mountHighlighted();
const blockCode = fixtureElement(
message,
'[data-paseo-markdown-tag="pre"] [data-paseo-markdown-tag="code"]',
);
expect(copiedMarkdown(selectNodeContents(blockCode))).toBe(
"const answer = 1;\n if (answer) {\n doThing();",
);
});
it("retains the fence when a complete code block is selected across its boundary", () => {
const message = mountHighlighted();
const content = copyAcross(message, "const", "After the block.");
expect(content?.plainText).toBe(
"```typescript\nconst answer = 1;\n if (answer) {\n doThing();\n```\n\nAfter the block.",
);
});
});