import type { BrowserContext, Locator } 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( "www.example.com stays", "[www.example.com](http://www.example.com) stays", ) .replace( "`https://example.com/generated` stays", "[`https://example.com/generated`](https://example.com/generated) stays", ) .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"]); } /** * Locate assistant rows by source-message identity. */ function assistantMessageBlocks(page: Page): Locator { return page.locator("[data-message-id]").filter({ has: page.getByTestId("assistant-message") }); } async function selectStructuredClipboardFixture(page: Page): Promise { await selectAssistantTextRange(page, "Direct matches:", "ready"); } async function selectAssistantElement(page: Page, selector: string): Promise { const assistantMessage = assistantMessageBlocks(page); await assistantMessage.locator(selector).evaluate((element) => { const selection = window.getSelection(); const range = document.createRange(); range.selectNodeContents(element); selection?.removeAllRanges(); selection?.addRange(range); }); } /** * Select an element from the end of the text before it. A drag that starts in the gap * above a block anchors there, so nothing outside the element is highlighted. */ async function selectAssistantElementFromEndOf( page: Page, startText: string, selector: string, ): Promise { await assistantMessageBlocks(page).evaluateAll( (blocks, selected) => { let startNode: Text | null = null; for (const block of blocks) { const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT); while (walker.nextNode()) { if (walker.currentNode.textContent?.endsWith(selected.startText)) { startNode = walker.currentNode as Text; } } } const end = blocks.map((block) => block.querySelector(selected.selector)).find(Boolean); if (!startNode || !end) { throw new Error(`Could not find ${selected.startText} — ${selected.selector}`); } const range = document.createRange(); range.setStart(startNode, startNode.length); range.setEnd(end, end.childNodes.length); const selection = window.getSelection(); selection?.removeAllRanges(); selection?.addRange(range); }, { startText, selector }, ); } /** A drag over an image alone selects it inside its rendered frame. */ async function selectAssistantImage(page: Page): Promise { await assistantMessageBlocks(page) .locator("img") .evaluate((element) => { const range = document.createRange(); range.selectNode(element); const selection = window.getSelection(); selection?.removeAllRanges(); selection?.addRange(range); }); } /** Press in the gap below the image and drag to the end of `text` in the block after it. */ async function dragFromBelowImageTo(page: Page, text: string): Promise { const blocks = assistantMessageBlocks(page); const target = blocks.getByText(text); await target.scrollIntoViewIfNeeded(); const frame = await blocks.locator("img").boundingBox(); const end = await target.evaluate((element, selectedText) => { const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); while (walker.nextNode()) { const node = walker.currentNode as Text; const offset = node.textContent?.indexOf(selectedText) ?? -1; if (offset >= 0) { const range = document.createRange(); range.setStart(node, offset); range.setEnd(node, offset + selectedText.length); const box = range.getBoundingClientRect(); return { x: box.right - 1, y: box.top + box.height / 2, left: box.left }; } } throw new Error(`Could not find ${selectedText}`); }, text); if (!frame) { throw new Error("Expected a rendered image"); } await page.evaluate(() => window.getSelection()?.removeAllRanges()); await page.mouse.move(end.left + 1, end.y - 14); await page.mouse.down(); await page.mouse.move(end.x, end.y, { steps: 10 }); await page.mouse.up(); } 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 = assistantMessageBlocks(page); const item = assistantMessage .locator(listTag) .filter({ hasText: itemText }) .locator(":scope > li") .filter({ hasText: itemText }); await item.evaluate((element, selectedEndText) => { 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(element, 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 = assistantMessageBlocks(page); await assistantMessage .locator(tag) .filter({ hasText: text }) .dblclick({ position: { x: 5, y: 5 } }); } async function selectAssistantTextRange( page: Page, startText: string, endText: string, ): Promise { await assistantMessageBlocks(page).evaluateAll( (blocks, selectedRange) => { // The message's text nodes in reading order, across every block row. const nodes: Text[] = []; for (const block of blocks) { const blockWalker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT); let found = blockWalker.nextNode(); while (found) { nodes.push(found as Text); found = blockWalker.nextNode(); } } const text = nodes.map((node) => node.data).join(""); const start = text.indexOf(selectedRange.startText); const end = text.indexOf(selectedRange.endText, start); if (start < 0 || end < 0) throw new Error( `Could not find assistant text range: ${selectedRange.startText} — ${selectedRange.endText}`, ); const boundary = (offset: number, isEnd = false): [Text, number] => { for (const node of nodes) { if (offset < node.length || (isEnd && offset === node.length)) return [node, offset]; offset -= node.length; } throw new Error("Selection extends beyond message"); }; const range = document.createRange(); range.setStart(...boundary(start)); range.setEnd(...boundary(end + selectedRange.endText.length, true)); 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 { await assistantMessageBlocks(page).evaluateAll( (blocks, selectedRange) => { // The message's text nodes in reading order, across every block row. const nodes: Text[] = []; for (const block of blocks) { const blockWalker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT); let found = blockWalker.nextNode(); while (found) { nodes.push(found as Text); found = blockWalker.nextNode(); } } let cursor = 0; const walker = { nextNode: () => nodes[cursor++] ?? null }; 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 = assistantMessageBlocks(page); for (const [tag, text] of [ ["strong", "strong prose"], ["em", "emphasized prose"], ["s", "struck prose"], ]) { const formattedProse = assistantMessage.locator(tag).filter({ hasText: text }); await expect(formattedProse).toHaveCSS("font-family", "serif"); await expect(formattedProse).not.toHaveAttribute("data-pmono"); } const inlineCode = assistantMessage.locator("code").filter({ hasText: "apply_patch" }); await expect(inlineCode).toHaveCSS("font-family", /monospace|Mono/); await selectStructuredClipboardFixture(page); await copySelection(page); const clipboard = await readRichClipboard(page); expect(clipboard.plainText).toBe(EXPECTED_WHOLE_SELECTION_MARKDOWN); expect(clipboard.html).not.toContain("