import type { BrowserContext } from "@playwright/test"; import { expect, test, type Page } from "../support/fixtures"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; const ASSISTANT_MARKDOWN = [ "Direct matches:", "", "Formatted **strong prose**, _emphasized prose_, and ~~struck prose~~.", "", "- **[First issue](https://example.com/issues/1)**: exact `apply_patch` failure.", "- [Second issue](https://example.com/issues/2): repeated sandbox setup.", "", "5. Fifth item", "6. Sixth item", "7. Seventh item", "8. Eighth item", "", "Nested list:", "", "3. Outer three", "", " 7. Inner seven", " 8. Inner eight", " 9. Inner nine", "", "A hard break ", "stays hard.", "", "www.example.com stays plain Markdown text.", "", '[docs](https://docs.example.com "Reference") and .', "", "[workspace file](file:///tmp/paseo%20notes.md#L4)", "", "`https://example.com/generated` stays code, not a generated link.", "", "```typescript", 'const answer = "yes";', " return answer;", "```", "", "After code.", "", "```bash", "echo trailing", "", "```", "", "````text", "before", "```", "after", "````", "", "| Left | Right | Center |", "| :-- | --: | :-: |", "| Current \\| active | ~~obsolete~~ | ready |", ].join("\n"); const EXPECTED_WHOLE_SELECTION_MARKDOWN = ASSISTANT_MARKDOWN.replace( "", "[https://autolink.example.com](https://autolink.example.com)", ) .replace( "3. Outer three\n\n 7. Inner seven\n 8. Inner eight\n 9. Inner nine", "3. Outer three\n 7. Inner seven\n 8. Inner eight\n 9. Inner nine", ) // Rendering drops the blank line before a closing fence, so copying cannot bring it // back. The fence is here to cover a body that ends in more than one newline. .replace("```bash\necho trailing\n\n```", "```bash\necho trailing\n```"); interface ClipboardContent { html: string; plainText: string; } async function allowRichClipboard(context: BrowserContext): Promise { await context.grantPermissions(["clipboard-read", "clipboard-write"]); } async function selectAssistantMessage(page: Page): Promise { const assistantMessage = page.getByTestId("assistant-message").filter({ hasText: "Direct matches:", }); await expect(assistantMessage).toBeVisible(); await assistantMessage.evaluate((element) => { const selection = window.getSelection(); const range = document.createRange(); range.selectNodeContents(element); selection?.removeAllRanges(); selection?.addRange(range); }); } async function selectAssistantElement(page: Page, selector: string): Promise { const assistantMessage = page.getByTestId("assistant-message").filter({ hasText: "Direct matches:", }); await assistantMessage.locator(selector).evaluate((element) => { const selection = window.getSelection(); const range = document.createRange(); range.selectNodeContents(element); selection?.removeAllRanges(); selection?.addRange(range); }); } async function selectAssistantText(page: Page, text: string): Promise { await selectAssistantTextRange(page, text, text); } async function selectAssistantTextRange( page: Page, startText: string, endText: string, ): Promise { const assistantMessage = page.getByTestId("assistant-message").filter({ hasText: "Direct matches:", }); await assistantMessage.evaluate( (element, selectedRange) => { const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); let startNode: Node | null = null; let startOffset = -1; let endNode: Node | null = null; let endOffset = -1; let textNode = walker.nextNode(); while (textNode) { if (!startNode) { const offset = textNode.textContent?.indexOf(selectedRange.startText) ?? -1; if (offset >= 0) { startNode = textNode; startOffset = offset; } } if (startNode) { const offset = textNode.textContent?.indexOf(selectedRange.endText) ?? -1; if (offset >= 0) { endNode = textNode; endOffset = offset + selectedRange.endText.length; break; } } textNode = walker.nextNode(); } if (!startNode || !endNode) { throw new Error( `Could not find assistant text range: ${selectedRange.startText} — ${selectedRange.endText}`, ); } const range = document.createRange(); range.setStart(startNode, startOffset); range.setEnd(endNode, endOffset); const selection = window.getSelection(); selection?.removeAllRanges(); selection?.addRange(range); }, { startText, endText }, ); } /** * Select from `startText` into the middle of the next rendered code line. * * Highlighted code splits a line across one text node per syntax token and puts the * newline in a node of its own, so this is a partial selection that crosses a line * break — the shape whose whitespace Turndown would collapse. */ async function selectAssistantAcrossCodeLines( page: Page, startText: string, endText: string, ): Promise { const assistantMessage = page.getByTestId("assistant-message").filter({ hasText: "Direct matches:", }); await assistantMessage.evaluate( (element, selectedRange) => { const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); let startNode: Node | null = null; let startOffset = -1; let textNode = walker.nextNode(); while (textNode) { if (!startNode) { const offset = textNode.textContent?.indexOf(selectedRange.startText) ?? -1; if (offset >= 0) { startNode = textNode; startOffset = offset; } textNode = walker.nextNode(); continue; } const endOffset = textNode.textContent?.indexOf(selectedRange.endText) ?? -1; if (endOffset >= 0) { const range = document.createRange(); range.setStart(startNode, startOffset); range.setEnd(textNode, endOffset + selectedRange.endText.length); const selection = window.getSelection(); selection?.removeAllRanges(); selection?.addRange(range); return; } textNode = walker.nextNode(); } throw new Error( `Could not find a code range: ${selectedRange.startText} — ${selectedRange.endText}`, ); }, { startText, endText }, ); } async function copySelection(page: Page): Promise { await page.keyboard.press("ControlOrMeta+c"); } /** The Copy button writes text/plain only, so the rich reader would throw on it. */ async function readPlainClipboard(page: Page): Promise { return page.evaluate(() => navigator.clipboard.readText()); } async function readRichClipboard(page: Page): Promise { return page.evaluate(async () => { const items = await navigator.clipboard.read(); const item = items[0]; if (!item) { throw new Error("Expected clipboard content"); } return { html: await (await item.getType("text/html")).text(), plainText: await (await item.getType("text/plain")).text(), }; }); } test("copying an assistant selection preserves Markdown structure and links", async ({ context, page, }) => { await page.addInitScript(() => { localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiFontFamily: "serif" })); }); const agent = await seedMockAgentWorkspace({ repoPrefix: "assistant-selection-copy-", title: "Assistant selection copy", initialPrompt: "Render the clipboard fixture.", featureValues: { mockAssistantResponse: ASSISTANT_MARKDOWN }, }); try { await allowRichClipboard(context); await agent.client.waitForAgentUpsert( agent.agentId, (snapshot) => snapshot.status === "idle", 30_000, ); await openAgentRoute(page, agent); const assistantMessage = page.getByTestId("assistant-message").filter({ hasText: "Direct matches:", }); for (const [tag, text] of [ ["strong", "strong prose"], ["em", "emphasized prose"], ["s", "struck prose"], ]) { const formattedProse = assistantMessage .locator(`[data-paseo-markdown-tag="${tag}"]`) .filter({ hasText: text }); await expect(formattedProse).toHaveCSS("font-family", "serif"); await expect(formattedProse).not.toHaveAttribute("data-pmono"); } const inlineCode = assistantMessage .locator('[data-paseo-markdown-tag="code"]') .filter({ hasText: "apply_patch" }); await expect(inlineCode).toHaveAttribute("data-pmono", ""); await selectAssistantMessage(page); await copySelection(page); const clipboard = await readRichClipboard(page); expect(clipboard.plainText).toBe(EXPECTED_WHOLE_SELECTION_MARKDOWN); expect(clipboard.html).not.toContain("
    "); expect(clipboard.html).not.toContain("
  • "); expect(clipboard.html).toContain("
    - "); expect(clipboard.html).toContain( 'First issue', ); expect(clipboard.html).toContain("apply_patch"); expect(clipboard.html).toContain("
    5. Fifth item
    "); expect(clipboard.html).toContain("A hard break
    "); expect(clipboard.html).toContain( 'www.example.com stays plain Markdown text.', ); expect(clipboard.html).toContain( 'docs', ); expect(clipboard.html).toContain( 'https://autolink.example.com', ); expect(clipboard.html).toContain( 'workspace file', ); expect(clipboard.html).toContain("https://example.com/generated"); expect(clipboard.html).not.toContain( 'https://example.com/generated', ); expect(clipboard.html).toContain(''); expect(clipboard.html).toContain( '
    before\n```\nafter\n
    ', ); expect(clipboard.html).toContain(""); expect(clipboard.html).toContain(''); expect(clipboard.html.match(/obsolete"); expect(clipboard.html).toContain(''); expect(clipboard.html).toContain(''); expect(clipboard.html).toContain(''); await selectAssistantText(page, "First"); await copySelection(page); const partialClipboard = await readRichClipboard(page); expect(partialClipboard.plainText).toBe("First"); expect(partialClipboard.html).toContain("

    First

    "); await selectAssistantTextRange(page, "Direct matches:", "repeated sandbox setup."); await copySelection(page); const paragraphAndListClipboard = await readRichClipboard(page); expect(paragraphAndListClipboard.plainText).toBe( [ "Direct matches:", "", "Formatted **strong prose**, _emphasized prose_, and ~~struck prose~~.", "", "- **[First issue](https://example.com/issues/1)**: exact `apply_patch` failure.", "- [Second issue](https://example.com/issues/2): repeated sandbox setup.", ].join("\n"), ); expect(paragraphAndListClipboard.html).not.toContain("
      "); expect(paragraphAndListClipboard.html).not.toContain("
    • "); expect(paragraphAndListClipboard.html).toContain( '
      - First issue', ); await selectAssistantText(page, "docs"); await copySelection(page); const titledLinkClipboard = await readRichClipboard(page); expect(titledLinkClipboard.plainText).toBe('[docs](https://docs.example.com "Reference")'); expect(titledLinkClipboard.html).toContain( 'docs', ); await selectAssistantText(page, "https://autolink.example.com"); await copySelection(page); const autolinkClipboard = await readRichClipboard(page); expect(autolinkClipboard.plainText).toBe( "[https://autolink.example.com](https://autolink.example.com)", ); expect(autolinkClipboard.html).toContain( 'https://autolink.example.com', ); await selectAssistantText(page, "workspace file"); await copySelection(page); const fileLinkClipboard = await readRichClipboard(page); expect(fileLinkClipboard.plainText).toBe("[workspace file](file:///tmp/paseo%20notes.md#L4)"); expect(fileLinkClipboard.html).toContain( 'workspace file', ); await selectAssistantTextRange(page, "Seventh item", "Eighth item"); await copySelection(page); const midListClipboard = await readRichClipboard(page); expect(midListClipboard.plainText).toBe("Seventh item\n\n8. Eighth item"); expect(midListClipboard.html).toContain("
      8. Eighth item
      "); await selectAssistantTextRange(page, "Inner eight", "Inner nine"); await copySelection(page); const nestedListClipboard = await readRichClipboard(page); expect(nestedListClipboard.plainText).toBe("Inner eight\n\n9. Inner nine"); expect(nestedListClipboard.html).toContain("
      9. Inner nine
      "); await selectAssistantTextRange(page, "Seventh item", "Nested list:"); await copySelection(page); const crossBlockClipboard = await readRichClipboard(page); expect(crossBlockClipboard.plainText).toBe("Seventh item\n\n8. Eighth item\n\nNested list:"); expect(crossBlockClipboard.html).toContain("
      8. Eighth item
      "); await selectAssistantText(page, "Current"); await copySelection(page); const partialTableClipboard = await readRichClipboard(page); expect(partialTableClipboard.plainText).toBe("Current"); expect(partialTableClipboard.html).toContain("

      Current

      "); expect(partialTableClipboard.html).not.toContain("<table"); await selectAssistantTextRange(page, "Right", "ready"); await copySelection(page); const columnSliceClipboard = await readRichClipboard(page); expect(columnSliceClipboard.plainText).toContain("ready"); expect(columnSliceClipboard.html).not.toContain("
    Current | activeLeftRightCenter
    "); expect(columnSliceClipboard.html).toContain("ready"); expect(columnSliceClipboard.html).toContain("obsolete"); // A partial selection loses its `pre` wrapper, so the code reaches Turndown as // prose unless it is diverted first: the line break and the indentation go, and // Markdown-significant characters get escaped. await selectAssistantAcrossCodeLines(page, "const", "return"); await copySelection(page); const codeLinesClipboard = await readRichClipboard(page); expect(codeLinesClipboard.plainText).toBe('const answer = "yes";\n return'); expect(codeLinesClipboard.html).toContain('
    ');
    
        // Selecting inside code expresses an intent to copy code, even when the selection
        // contains every character in the block.
        await selectAssistantElement(
          page,
          '[data-paseo-markdown-language="typescript"] [data-paseo-markdown-tag="code"]',
        );
        await copySelection(page);
    
        const completeCodeClipboard = await readRichClipboard(page);
        expect(completeCodeClipboard.plainText).toBe('const answer = "yes";\n  return answer;');
    
        // Crossing the block boundary expresses an intent to carry its Markdown structure.
        await selectAssistantAcrossCodeLines(page, "const", "After code.");
        await copySelection(page);
    
        const crossedCodeClipboard = await readRichClipboard(page);
        expect(crossedCodeClipboard.plainText).toBe(
          '```typescript\nconst answer = "yes";\n  return answer;\n```\n\nAfter code.',
        );
    
        // Selecting a whole line runs off its end into the newline node. A trailing
        // newline auto-executes the line when it is pasted into a terminal.
        await selectAssistantAcrossCodeLines(page, "const", "  ");
        await copySelection(page);
    
        expect((await readRichClipboard(page)).plainText).toBe('const answer = "yes";');
    
        // The block's own Copy button had the same hazard: a Markdown fence body ends in
        // a newline, and the button copied it raw.
        const typescriptFence = assistantMessage.locator('[data-paseo-markdown-language="typescript"]');
        // The button is opacity 0 / pointerEvents none until the fence is hovered.
        await typescriptFence.hover();
        await typescriptFence.locator("[data-paseo-markdown-ignore]").click();
    
        expect(await readPlainClipboard(page)).toBe('const answer = "yes";\n  return answer;');
    
        // A blank line before the closing fence leaves the body ending in two newlines,
        // so stripping only the terminal one still hands the terminal an executable line.
        const bashFence = assistantMessage.locator('[data-paseo-markdown-language="bash"]');
        await bashFence.hover();
        await bashFence.locator("[data-paseo-markdown-ignore]").click();
    
        expect(await readPlainClipboard(page)).toBe("echo trailing");
      } finally {
        await agent.cleanup();
      }
    });