import type { BrowserContext } from "@playwright/test"; import { expect, test, type Page } from "../support/fixtures"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; const CROSS_STRUCTURE_MARKDOWN = [ "# P1 — High-value UI behavior", "", "5. Claude model and thinking preferences", "", " - Switch Claude models and confirm each model retains its own thinking choice.", " - Cover new-workspace drafts, queued launches, schedules, and editing existing schedules.", " - Confirm Opus 5 exposes the Fast toggle.", " - Smoke with an older saved Fabel/model alias and with daemon/client version drift.", "", "6. Chat, composer, and timeline stability", ].join("\n"); const ASSISTANT_MARKDOWN = [ CROSS_STRUCTURE_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( `${CROSS_STRUCTURE_MARKDOWN}\n\n`, "", ) .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 selectStructuredClipboardFixture(page: Page): Promise { await selectAssistantTextRange(page, "Direct matches:", "ready"); } 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 selectAssistantListItemFromMarker( page: Page, listTag: "ol" | "ul", itemText: string, endText: string, ): Promise { const assistantMessage = page.getByTestId("assistant-message").filter({ hasText: "Direct matches:", }); const item = assistantMessage .locator(`[data-paseo-markdown-tag="${listTag}"]`) .filter({ hasText: itemText }) .locator(':scope > [data-paseo-markdown-tag="li"]') .filter({ hasText: itemText }); await item.evaluate((element, selectedEndText) => { const marker = element.querySelector(':scope > [data-paseo-markdown-list-marker="true"]'); const markerText = marker?.firstChild; if (!(markerText instanceof Text)) { throw new Error("Expected rendered list marker text"); } const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); let endNode: Text | null = null; let endOffset = -1; while (walker.nextNode()) { const node = walker.currentNode; const offset = node.textContent?.indexOf(selectedEndText) ?? -1; if (node instanceof Text && offset >= 0) { endNode = node; endOffset = offset + selectedEndText.length; break; } } if (!endNode) { throw new Error(`Could not find list item selection end: ${selectedEndText}`); } const range = document.createRange(); range.setStart(markerText, 0); range.setEnd(endNode, endOffset); const selection = window.getSelection(); selection?.removeAllRanges(); selection?.addRange(range); }, endText); } async function doubleClickAssistantMarkdownText( page: Page, tag: "code" | "strong", text: string, ): Promise { const assistantMessage = page.getByTestId("assistant-message").filter({ hasText: "Direct matches:", }); await assistantMessage .locator(`[data-paseo-markdown-tag="${tag}"]`) .filter({ hasText: text }) .dblclick({ position: { x: 5, y: 5 } }); } 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 selectStructuredClipboardFixture(page); await copySelection(page); const clipboard = await readRichClipboard(page); expect(clipboard.plainText).toBe(EXPECTED_WHOLE_SELECTION_MARKDOWN); expect(clipboard.html).not.toContain("