import { afterEach, describe, expect, it } from "vitest"; import { createAssistantSelectionClipboardContent } from "./content.web"; const fixture = `
Prefix bold text and inline code suffix.
•
First bullet text
•
Second bullet text
const answer = true;
`; function mountFixture(): HTMLElement { const host = document.createElement("div"); host.innerHTML = fixture; document.body.append(host); const message = host.querySelector('[data-testid="assistant-message"]'); if (!message) { throw new Error("Expected assistant message fixture"); } return message; } function fixtureElement( root: ParentNode, selector: string, index = 0, ): T { const element = root.querySelectorAll(selector).item(index); if (!element) { throw new Error(`Expected fixture element ${selector} at index ${index}`); } return element; } function textNode(element: Element): Text { const node = element.firstChild; if (!(node instanceof Text)) { throw new Error("Expected text node"); } return node; } function selectText(element: Element, start: number, end: number): Selection { const node = textNode(element); const range = document.createRange(); range.setStart(node, start); range.setEnd(node, end); const selection = window.getSelection(); if (!selection) { throw new Error("Expected browser selection"); } selection.removeAllRanges(); selection.addRange(range); return selection; } function selectRange( startElement: Element, startOffset: number, endElement: Element, endOffset: number, ): Selection { const range = document.createRange(); range.setStart(textNode(startElement), startOffset); range.setEnd(textNode(endElement), endOffset); const selection = window.getSelection(); if (!selection) { throw new Error("Expected browser selection"); } selection.removeAllRanges(); selection.addRange(range); return selection; } function selectNodeContents(element: Element): Selection { const range = document.createRange(); range.selectNodeContents(element); const selection = window.getSelection(); if (!selection) { throw new Error("Expected browser selection"); } selection.removeAllRanges(); selection.addRange(range); return selection; } function copiedMarkdown(selection: Selection): string | null { return createAssistantSelectionClipboardContent(selection)?.plainText ?? null; } afterEach(() => { window.getSelection()?.removeAllRanges(); document.body.replaceChildren(); }); describe("assistant selection copy ranges", () => { it("does not handle absent, collapsed, or non-assistant selections", () => { expect(createAssistantSelectionClipboardContent(null)).toBeNull(); const message = mountFixture(); const strong = fixtureElement(message, '[data-paseo-markdown-tag="strong"]'); expect(copiedMarkdown(selectText(strong, 2, 2))).toBeNull(); const outside = document.createElement("span"); outside.textContent = "outside"; document.body.append(outside); expect(copiedMarkdown(selectText(outside, 0, 7))).toBeNull(); }); it("does not replace the browser clipboard for a range spanning assistant messages", () => { const firstMessage = mountFixture(); const secondMessage = mountFixture(); const firstText = fixtureElement(firstMessage, '[data-paseo-markdown-tag="strong"]'); const secondText = fixtureElement(secondMessage, '[data-paseo-markdown-tag="strong"]'); expect(copiedMarkdown(selectRange(firstText, 0, secondText, 4))).toBeNull(); }); it("preserves all structure when the complete assistant message is selected", () => { const message = mountFixture(); const content = createAssistantSelectionClipboardContent(selectNodeContents(message)); expect(content?.plainText).toBe( [ "Prefix **bold text** and `inline code` suffix.", "", "- First bullet text", "- Second bullet text", "", "```ts", "const answer = true;", "```", ].join("\n"), ); expect(content?.html).not.toContain("
    "); expect(content?.html).not.toContain("
  • "); expect(content?.html).toContain("
    - First bullet text
    "); expect(content?.html).toContain("
    - Second bullet text
    "); }); it.each([ { tag: "strong", range: [1, 5], expected: "old", forbiddenHtml: "" }, { tag: "code", range: [2, 8], expected: "line c", forbiddenHtml: "" }, ])( "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 paragraph
    Second 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.", ); }); });