|
| 1 | +import { test, expect } from '@playwright/test' |
| 2 | + |
| 3 | +// Regression coverage for issue #9904: |
| 4 | +// - /api/operations was polled every 1s and *always* re-rendered the Chat |
| 5 | +// page, even when the response was unchanged. The reconciliation would |
| 6 | +// collapse any text selection inside an assistant message. |
| 7 | +// - The copy button next to each assistant message used navigator.clipboard |
| 8 | +// without any fallback, which is undefined when the page is served over |
| 9 | +// plain http (non-secure context) from a remote host. |
| 10 | + |
| 11 | +async function setupChatPage(page) { |
| 12 | + await page.route('**/api/models/capabilities', (route) => { |
| 13 | + route.fulfill({ |
| 14 | + contentType: 'application/json', |
| 15 | + body: JSON.stringify({ |
| 16 | + data: [{ id: 'test-model', capabilities: ['FLAG_CHAT'] }], |
| 17 | + }), |
| 18 | + }) |
| 19 | + }) |
| 20 | + |
| 21 | + // Poll-tracking mock: assert the hook is hammering /api/operations every |
| 22 | + // ~1s, and always return an empty list so its contents never change. |
| 23 | + let operationsHits = 0 |
| 24 | + await page.route('**/api/operations', (route) => { |
| 25 | + operationsHits++ |
| 26 | + route.fulfill({ |
| 27 | + contentType: 'application/json', |
| 28 | + body: JSON.stringify({ operations: [] }), |
| 29 | + }) |
| 30 | + }) |
| 31 | + |
| 32 | + await page.route('**/v1/chat/completions', (route) => { |
| 33 | + // One short SSE stream so the chat finishes streaming quickly and we |
| 34 | + // can interact with a stable assistant message. |
| 35 | + const body = [ |
| 36 | + 'data: {"choices":[{"delta":{"content":"Hello world this is a long assistant reply that we can try to select."},"index":0}]}\n\n', |
| 37 | + 'data: {"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}\n\n', |
| 38 | + 'data: [DONE]\n\n', |
| 39 | + ].join('') |
| 40 | + route.fulfill({ |
| 41 | + status: 200, |
| 42 | + headers: { 'Content-Type': 'text/event-stream' }, |
| 43 | + body, |
| 44 | + }) |
| 45 | + }) |
| 46 | + |
| 47 | + return { getOperationsHits: () => operationsHits } |
| 48 | +} |
| 49 | + |
| 50 | +test.describe('Chat - /api/operations polling (#9904)', () => { |
| 51 | + test('text selection inside an assistant message survives polling', async ({ page }) => { |
| 52 | + const { getOperationsHits } = await setupChatPage(page) |
| 53 | + |
| 54 | + await page.goto('/app/chat') |
| 55 | + await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 }) |
| 56 | + |
| 57 | + await page.locator('.chat-input').fill('Hi') |
| 58 | + await page.locator('.chat-send-btn').click() |
| 59 | + |
| 60 | + const assistantContent = page.locator('.chat-message-assistant .chat-message-content').first() |
| 61 | + await expect(assistantContent).toContainText('Hello world', { timeout: 10_000 }) |
| 62 | + |
| 63 | + // Sanity check: the polling we're regressing against is actually firing. |
| 64 | + await page.waitForTimeout(2_500) |
| 65 | + expect(getOperationsHits()).toBeGreaterThan(1) |
| 66 | + |
| 67 | + // Sanity check that the bug we're guarding against is structurally |
| 68 | + // possible: count how many times the assistant content node gets |
| 69 | + // *touched* by React (childList / characterData mutations) over a |
| 70 | + // 3-second window. Before the fix, every poll re-rendered Chat and |
| 71 | + // re-set dangerouslySetInnerHTML, triggering a mutation cascade that |
| 72 | + // collapsed the user's text selection. After the fix, polling with |
| 73 | + // identical contents must not mutate the DOM at all. |
| 74 | + const mutationCount = await assistantContent.evaluate((el) => new Promise((resolve) => { |
| 75 | + let count = 0 |
| 76 | + const obs = new MutationObserver((records) => { count += records.length }) |
| 77 | + obs.observe(el, { childList: true, subtree: true, characterData: true }) |
| 78 | + setTimeout(() => { obs.disconnect(); resolve(count) }, 3_000) |
| 79 | + })) |
| 80 | + expect(mutationCount).toBe(0) |
| 81 | + |
| 82 | + // Same sanity check translated to a user-observable property: a |
| 83 | + // programmatically created selection survives the polling window. |
| 84 | + await assistantContent.evaluate((el) => { |
| 85 | + const range = document.createRange() |
| 86 | + range.selectNodeContents(el) |
| 87 | + const sel = window.getSelection() |
| 88 | + sel.removeAllRanges() |
| 89 | + sel.addRange(range) |
| 90 | + }) |
| 91 | + |
| 92 | + const initialSelection = await page.evaluate(() => window.getSelection().toString()) |
| 93 | + expect(initialSelection).toContain('Hello world') |
| 94 | + |
| 95 | + await page.waitForTimeout(2_500) |
| 96 | + |
| 97 | + const selectionAfterPolling = await page.evaluate(() => window.getSelection().toString()) |
| 98 | + expect(selectionAfterPolling).toBe(initialSelection) |
| 99 | + }) |
| 100 | +}) |
| 101 | + |
| 102 | +test.describe('Chat - copy button (#9904)', () => { |
| 103 | + test('copy button works when navigator.clipboard is unavailable (plain http)', async ({ page }) => { |
| 104 | + await setupChatPage(page) |
| 105 | + |
| 106 | + // Simulate a non-secure context: hide navigator.clipboard before any of |
| 107 | + // our app code touches it. This mirrors what browsers do over plain |
| 108 | + // http from a remote host. |
| 109 | + await page.addInitScript(() => { |
| 110 | + Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true }) |
| 111 | + try { |
| 112 | + Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true }) |
| 113 | + } catch { /* some browsers refuse — the secure-context flag is enough */ } |
| 114 | + }) |
| 115 | + |
| 116 | + await page.goto('/app/chat') |
| 117 | + await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 }) |
| 118 | + |
| 119 | + await page.locator('.chat-input').fill('Hi') |
| 120 | + await page.locator('.chat-send-btn').click() |
| 121 | + |
| 122 | + const assistantBubble = page.locator('.chat-message-assistant .chat-message-bubble').first() |
| 123 | + await expect(assistantBubble).toContainText('Hello world', { timeout: 10_000 }) |
| 124 | + |
| 125 | + // Spy on document.execCommand so we can confirm the fallback path ran. |
| 126 | + await page.evaluate(() => { |
| 127 | + window.__execCommandCalls = [] |
| 128 | + const original = document.execCommand?.bind(document) |
| 129 | + document.execCommand = (cmd, ...rest) => { |
| 130 | + window.__execCommandCalls.push(cmd) |
| 131 | + // execCommand('copy') in a headless browser may return false because |
| 132 | + // there is no real clipboard, but the fact that we tried is what we |
| 133 | + // care about for this regression. |
| 134 | + return original ? original(cmd, ...rest) : false |
| 135 | + } |
| 136 | + }) |
| 137 | + |
| 138 | + await assistantBubble.locator('.chat-message-actions button').first().click() |
| 139 | + |
| 140 | + const execCommandCalls = await page.evaluate(() => window.__execCommandCalls) |
| 141 | + expect(execCommandCalls).toContain('copy') |
| 142 | + }) |
| 143 | +}) |
0 commit comments