From 958ca49156d4eee955b579a79f869ecff326f1d4 Mon Sep 17 00:00:00 2001 From: Pooja Date: Tue, 7 Jul 2026 20:42:34 +0530 Subject: [PATCH] fix(ws): prevent message list from scrolling on request pane tab switch (#8448) --- .../WsBody/SingleWSMessage/index.js | 28 +- .../components/RequestPane/WsBody/index.js | 87 +++- .../fixtures/collection/ws-long-msg.bru | 452 ++++++++++++++++++ .../fixtures/collection/ws-scroll-top.bru | 324 +++++++++++++ .../websockets/message-editor-scroll.spec.ts | 42 ++ tests/websockets/message-scroll.spec.ts | 79 +++ .../websockets/message-typing-scroll.spec.ts | 49 ++ 7 files changed, 1044 insertions(+), 17 deletions(-) create mode 100644 tests/websockets/fixtures/collection/ws-long-msg.bru create mode 100644 tests/websockets/fixtures/collection/ws-scroll-top.bru create mode 100644 tests/websockets/message-editor-scroll.spec.ts create mode 100644 tests/websockets/message-scroll.spec.ts create mode 100644 tests/websockets/message-typing-scroll.spec.ts diff --git a/packages/bruno-app/src/components/RequestPane/WsBody/SingleWSMessage/index.js b/packages/bruno-app/src/components/RequestPane/WsBody/SingleWSMessage/index.js index 944e14047..00ce48e11 100644 --- a/packages/bruno-app/src/components/RequestPane/WsBody/SingleWSMessage/index.js +++ b/packages/bruno-app/src/components/RequestPane/WsBody/SingleWSMessage/index.js @@ -39,7 +39,8 @@ export const SingleWSMessage = ({ isNew, onNewRendered, isSelected, - onSelect + onSelect, + paneHeight }) => { const dispatch = useDispatch(); const { displayedTheme } = useTheme(); @@ -105,11 +106,16 @@ export const SingleWSMessage = ({ const fontSize = get(preferences, 'font.codeFontSize', 14); const lineHeight = fontSize * 1.5; + const HEADER_ALLOWANCE = 44; + const maxEditorHeight = paneHeight + ? Math.max(160, paneHeight - HEADER_ALLOWANCE) + : Math.round((typeof window !== 'undefined' ? window.innerHeight : 900) * 0.6); const editorHeight = useMemo(() => { const lineCount = (content || '').split('\n').length; const lines = lineCount + 1; - return `${lines * lineHeight + 10}px`; - }, [content, lineHeight]); + const contentHeight = lines * lineHeight + 10; + return `${Math.min(contentHeight, maxEditorHeight)}px`; + }, [content, lineHeight, maxEditorHeight]); const onUpdateMessageType = (newMode) => { const currentMessages = [...(body.ws || [])]; @@ -173,8 +179,8 @@ export const SingleWSMessage = ({ return ( { - if (!isSelected) setTimeout(onSelect, 0); + onMouseUpCapture={() => { + if (!isSelected) onSelect(); }} >
{ - e.preventDefault(); - onToggle(); - }} + onClick={(e) => e.stopPropagation()} onDoubleClick={handleNameClick} > {displayName} @@ -251,7 +254,11 @@ export const SingleWSMessage = ({
{isExpanded && ( -
+
)} diff --git a/packages/bruno-app/src/components/RequestPane/WsBody/index.js b/packages/bruno-app/src/components/RequestPane/WsBody/index.js index 479ed05d4..02ad48595 100644 --- a/packages/bruno-app/src/components/RequestPane/WsBody/index.js +++ b/packages/bruno-app/src/components/RequestPane/WsBody/index.js @@ -3,6 +3,7 @@ import { updateRequestBody } from 'providers/ReduxStore/slices/collections'; import { IconPlus } from '@tabler/icons'; import React, { useState, useRef, useEffect, useCallback } from 'react'; import { useDispatch } from 'react-redux'; +import { usePersistedState } from 'hooks/usePersistedState'; import StyledWrapper from './StyledWrapper'; import { SingleWSMessage } from './SingleWSMessage/index'; @@ -14,6 +15,10 @@ const getSelectedIndex = (messages) => { const WSBody = ({ item, collection, handleRun, onAddMessage }) => { const dispatch = useDispatch(); const messagesContainerRef = useRef(null); + const [listScrollTop, setListScrollTop] = usePersistedState({ + key: `ws-list-scroll-${item.uid}`, + default: 0 + }); const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body'); const messages = body?.ws || []; @@ -27,6 +32,19 @@ const WSBody = ({ item, collection, handleRun, onAddMessage }) => { const [newMessageUid, setNewMessageUid] = useState(null); const prevMessagesLengthRef = useRef(messages.length); + // Track the message pane's height so an expanded editor can be capped to fit + // inside it. A taller editor would overflow the pane and produce a second + // scrollbar (the list) on top of the editor's own scroll. + const [paneHeight, setPaneHeight] = useState(0); + useEffect(() => { + const el = messagesContainerRef.current; + if (!el || typeof ResizeObserver === 'undefined') return; + const ro = new ResizeObserver(() => setPaneHeight(el.clientHeight)); + ro.observe(el); + setPaneHeight(el.clientHeight); + return () => ro.disconnect(); + }, []); + const setSelectedIndex = useCallback((index) => { const currentMessages = [...(body?.ws || [])]; const updated = currentMessages.map((msg, i) => ({ @@ -68,6 +86,11 @@ const WSBody = ({ item, collection, handleRun, onAddMessage }) => { setNewMessageUid(newMsg.uid); setSelectedIndex(messages.length - 1); } + + if (messagesContainerRef.current) { + const container = messagesContainerRef.current; + container.scrollTop = container.scrollHeight; + } } prevMessagesLengthRef.current = messages.length; }, [messages.length]); @@ -76,13 +99,54 @@ const WSBody = ({ item, collection, handleRun, onAddMessage }) => { setNewMessageUid(null); }, []); - // Auto-scroll to bottom when new message is added + // Restore the last scroll position on mount (component remounts on tab switch, + // so listScrollTop is read synchronously for this request). useEffect(() => { - if (messagesContainerRef.current && messages.length > 0) { - const container = messagesContainerRef.current; - container.scrollTop = container.scrollHeight; + const container = messagesContainerRef.current; + if (container) { + container.scrollTop = listScrollTop; } - }, [messages.length]); + }, [item.uid]); + + const handleScroll = useCallback(() => { + const container = messagesContainerRef.current; + if (container) { + setListScrollTop(container.scrollTop); + } + }, [setListScrollTop]); + + // Clicking or typing in an editor makes the browser scroll the list to reveal + // CodeMirror's cursor, flinging the whole panel. Pin the list's scrollTop for a + // few frames so focus/keystrokes can't move it (the editor still scrolls + // internally); a real user scroll (wheel/touch) releases the pin. + const pinScrollRef = useRef(null); + const pinListScroll = useCallback(() => { + const container = messagesContainerRef.current; + if (!container) return; + const top = container.scrollTop; + let frames = 0; + const pin = () => { + const el = messagesContainerRef.current; + if (!el || pinScrollRef.current === null) return; + if (el.scrollTop !== top) el.scrollTop = top; + if (++frames < 8) { + pinScrollRef.current = requestAnimationFrame(pin); + } else { + pinScrollRef.current = null; + } + }; + // Cancel any in-flight pin so a fresh gesture re-snapshots the current top. + if (pinScrollRef.current !== null) cancelAnimationFrame(pinScrollRef.current); + pinScrollRef.current = requestAnimationFrame(pin); + }, []); + + // A real user scroll (wheel/touch) releases the pin immediately. + const releasePin = useCallback(() => { + if (pinScrollRef.current !== null) { + cancelAnimationFrame(pinScrollRef.current); + pinScrollRef.current = null; + } + }, []); if (!messages.length) { return ( @@ -100,11 +164,19 @@ const WSBody = ({ item, collection, handleRun, onAddMessage }) => { return ( -
+
{messages.map((message, index) => ( { onNewRendered={handleNewMessageRendered} isSelected={selectedIndex === index} onSelect={() => handleSelect(index)} + paneHeight={paneHeight} /> ))}
diff --git a/tests/websockets/fixtures/collection/ws-long-msg.bru b/tests/websockets/fixtures/collection/ws-long-msg.bru new file mode 100644 index 000000000..bc69e6854 --- /dev/null +++ b/tests/websockets/fixtures/collection/ws-long-msg.bru @@ -0,0 +1,452 @@ +meta { + name: ws-long-msg + type: ws + seq: 4 +} + +ws { + url: ws://localhost:8081/ws/echo + body: ws + auth: inherit +} + +body:ws { + name: long message + type: json + content: ''' + { + "type": "bulk", + "count": 60, + "items": [ + { + "id": 0, + "name": "item-0", + "active": true + }, + { + "id": 1, + "name": "item-1", + "active": false + }, + { + "id": 2, + "name": "item-2", + "active": true + }, + { + "id": 3, + "name": "item-3", + "active": false + }, + { + "id": 4, + "name": "item-4", + "active": true + }, + { + "id": 5, + "name": "item-5", + "active": false + }, + { + "id": 6, + "name": "item-6", + "active": true + }, + { + "id": 7, + "name": "item-7", + "active": false + }, + { + "id": 8, + "name": "item-8", + "active": true + }, + { + "id": 9, + "name": "item-9", + "active": false + }, + { + "id": 10, + "name": "item-10", + "active": true + }, + { + "id": 11, + "name": "item-11", + "active": false + }, + { + "id": 12, + "name": "item-12", + "active": true + }, + { + "id": 13, + "name": "item-13", + "active": false + }, + { + "id": 14, + "name": "item-14", + "active": true + }, + { + "id": 15, + "name": "item-15", + "active": false + }, + { + "id": 16, + "name": "item-16", + "active": true + }, + { + "id": 17, + "name": "item-17", + "active": false + }, + { + "id": 18, + "name": "item-18", + "active": true + }, + { + "id": 19, + "name": "item-19", + "active": false + }, + { + "id": 20, + "name": "item-20", + "active": true + }, + { + "id": 21, + "name": "item-21", + "active": false + }, + { + "id": 22, + "name": "item-22", + "active": true + }, + { + "id": 23, + "name": "item-23", + "active": false + }, + { + "id": 24, + "name": "item-24", + "active": true + }, + { + "id": 25, + "name": "item-25", + "active": false + }, + { + "id": 26, + "name": "item-26", + "active": true + }, + { + "id": 27, + "name": "item-27", + "active": false + }, + { + "id": 28, + "name": "item-28", + "active": true + }, + { + "id": 29, + "name": "item-29", + "active": false + }, + { + "id": 30, + "name": "item-30", + "active": true + }, + { + "id": 31, + "name": "item-31", + "active": false + }, + { + "id": 32, + "name": "item-32", + "active": true + }, + { + "id": 33, + "name": "item-33", + "active": false + }, + { + "id": 34, + "name": "item-34", + "active": true + }, + { + "id": 35, + "name": "item-35", + "active": false + }, + { + "id": 36, + "name": "item-36", + "active": true + }, + { + "id": 37, + "name": "item-37", + "active": false + }, + { + "id": 38, + "name": "item-38", + "active": true + }, + { + "id": 39, + "name": "item-39", + "active": false + }, + { + "id": 40, + "name": "item-40", + "active": true + }, + { + "id": 41, + "name": "item-41", + "active": false + }, + { + "id": 42, + "name": "item-42", + "active": true + }, + { + "id": 43, + "name": "item-43", + "active": false + }, + { + "id": 44, + "name": "item-44", + "active": true + }, + { + "id": 45, + "name": "item-45", + "active": false + }, + { + "id": 46, + "name": "item-46", + "active": true + }, + { + "id": 47, + "name": "item-47", + "active": false + }, + { + "id": 48, + "name": "item-48", + "active": true + }, + { + "id": 49, + "name": "item-49", + "active": false + }, + { + "id": 50, + "name": "item-50", + "active": true + }, + { + "id": 51, + "name": "item-51", + "active": false + }, + { + "id": 52, + "name": "item-52", + "active": true + }, + { + "id": 53, + "name": "item-53", + "active": false + }, + { + "id": 54, + "name": "item-54", + "active": true + }, + { + "id": 55, + "name": "item-55", + "active": false + }, + { + "id": 56, + "name": "item-56", + "active": true + }, + { + "id": 57, + "name": "item-57", + "active": false + }, + { + "id": 58, + "name": "item-58", + "active": true + }, + { + "id": 59, + "name": "item-59", + "active": false + } + ] + } + ''' +} + +body:ws { + name: short message + type: text + content: ''' + hello world + ''' +} + +body:ws { + name: filler message 1 + type: text + content: ''' + filler 1 + ''' +} + +body:ws { + name: filler message 2 + type: text + content: ''' + filler 2 + ''' +} + +body:ws { + name: filler message 3 + type: text + content: ''' + filler 3 + ''' +} + +body:ws { + name: filler message 4 + type: text + content: ''' + filler 4 + ''' +} + +body:ws { + name: filler message 5 + type: text + content: ''' + filler 5 + ''' +} + +body:ws { + name: filler message 6 + type: text + content: ''' + filler 6 + ''' +} + +body:ws { + name: filler message 7 + type: text + content: ''' + filler 7 + ''' +} + +body:ws { + name: filler message 8 + type: text + content: ''' + filler 8 + ''' +} + +body:ws { + name: filler message 9 + type: text + content: ''' + filler 9 + ''' +} + +body:ws { + name: filler message 10 + type: text + content: ''' + filler 10 + ''' +} + +body:ws { + name: filler message 11 + type: text + content: ''' + filler 11 + ''' +} + +body:ws { + name: filler message 12 + type: text + content: ''' + filler 12 + ''' +} + +body:ws { + name: filler message 13 + type: text + content: ''' + filler 13 + ''' +} + +body:ws { + name: filler message 14 + type: text + content: ''' + filler 14 + ''' +} + +body:ws { + name: filler message 15 + type: text + content: ''' + filler 15 + ''' +} diff --git a/tests/websockets/fixtures/collection/ws-scroll-top.bru b/tests/websockets/fixtures/collection/ws-scroll-top.bru new file mode 100644 index 000000000..8fd3f2a23 --- /dev/null +++ b/tests/websockets/fixtures/collection/ws-scroll-top.bru @@ -0,0 +1,324 @@ +meta { + name: ws-scroll-top + type: ws + seq: 5 +} + +ws { + url: ws://localhost:8081/ws/echo + body: ws + auth: inherit +} + +body:ws { + name: message 1 + type: json + content: ''' + { + "type": "bulk", + "count": 60, + "items": [ + { + "id": 0, + "name": "item-0", + "active": true + }, + { + "id": 1, + "name": "item-1", + "active": false + }, + { + "id": 2, + "name": "item-2", + "active": true + }, + { + "id": 3, + "name": "item-3", + "active": false + }, + { + "id": 4, + "name": "item-4", + "active": true + }, + { + "id": 5, + "name": "item-5", + "active": false + }, + { + "id": 6, + "name": "item-6", + "active": true + }, + { + "id": 7, + "name": "item-7", + "active": false + }, + { + "id": 8, + "name": "item-8", + "active": true + }, + { + "id": 9, + "name": "item-9", + "active": false + }, + { + "id": 10, + "name": "item-10", + "active": true + }, + { + "id": 11, + "name": "item-11", + "active": false + }, + { + "id": 12, + "name": "item-12", + "active": true + }, + { + "id": 13, + "name": "item-13", + "active": false + }, + { + "id": 14, + "name": "item-14", + "active": true + }, + { + "id": 15, + "name": "item-15", + "active": false + }, + { + "id": 16, + "name": "item-16", + "active": true + }, + { + "id": 17, + "name": "item-17", + "active": false + }, + { + "id": 18, + "name": "item-18", + "active": true + }, + { + "id": 19, + "name": "item-19", + "active": false + }, + { + "id": 20, + "name": "item-20", + "active": true + }, + { + "id": 21, + "name": "item-21", + "active": false + }, + { + "id": 22, + "name": "item-22", + "active": true + }, + { + "id": 23, + "name": "item-23", + "active": false + }, + { + "id": 24, + "name": "item-24", + "active": true + }, + { + "id": 25, + "name": "item-25", + "active": false + }, + { + "id": 26, + "name": "item-26", + "active": true + }, + { + "id": 27, + "name": "item-27", + "active": false + }, + { + "id": 28, + "name": "item-28", + "active": true + }, + { + "id": 29, + "name": "item-29", + "active": false + }, + { + "id": 30, + "name": "item-30", + "active": true + }, + { + "id": 31, + "name": "item-31", + "active": false + }, + { + "id": 32, + "name": "item-32", + "active": true + }, + { + "id": 33, + "name": "item-33", + "active": false + }, + { + "id": 34, + "name": "item-34", + "active": true + }, + { + "id": 35, + "name": "item-35", + "active": false + }, + { + "id": 36, + "name": "item-36", + "active": true + }, + { + "id": 37, + "name": "item-37", + "active": false + }, + { + "id": 38, + "name": "item-38", + "active": true + }, + { + "id": 39, + "name": "item-39", + "active": false + }, + { + "id": 40, + "name": "item-40", + "active": true + }, + { + "id": 41, + "name": "item-41", + "active": false + }, + { + "id": 42, + "name": "item-42", + "active": true + }, + { + "id": 43, + "name": "item-43", + "active": false + }, + { + "id": 44, + "name": "item-44", + "active": true + }, + { + "id": 45, + "name": "item-45", + "active": false + }, + { + "id": 46, + "name": "item-46", + "active": true + }, + { + "id": 47, + "name": "item-47", + "active": false + }, + { + "id": 48, + "name": "item-48", + "active": true + }, + { + "id": 49, + "name": "item-49", + "active": false + }, + { + "id": 50, + "name": "item-50", + "active": true + }, + { + "id": 51, + "name": "item-51", + "active": false + }, + { + "id": 52, + "name": "item-52", + "active": true + }, + { + "id": 53, + "name": "item-53", + "active": false + }, + { + "id": 54, + "name": "item-54", + "active": true + }, + { + "id": 55, + "name": "item-55", + "active": false + }, + { + "id": 56, + "name": "item-56", + "active": true + }, + { + "id": 57, + "name": "item-57", + "active": false + }, + { + "id": 58, + "name": "item-58", + "active": true + }, + { + "id": 59, + "name": "item-59", + "active": false + } + ] + } + ''' +} diff --git a/tests/websockets/message-editor-scroll.spec.ts b/tests/websockets/message-editor-scroll.spec.ts new file mode 100644 index 000000000..07de91a7f --- /dev/null +++ b/tests/websockets/message-editor-scroll.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from '../../playwright'; +import { openRequest, closeAllCollections } from '../utils/page/actions'; + +const COLLECTION_NAME = 'collection'; +const SCROLL_REQ = 'ws-scroll-top'; + +test.describe('websocket message editor scroll behaviour', () => { + test.afterEach(async ({ pageWithUserData: page }) => { + await closeAllCollections(page); + }); + + test('reopening a message restores the scroll position where we left it', async ({ pageWithUserData: page }) => { + await openRequest(page, COLLECTION_NAME, SCROLL_REQ); + + const header = page.getByTestId('ws-message-header-0'); + const body = page.getByTestId('ws-message-body-0'); + await expect(body).toBeVisible(); + + const cmScroll = body.locator('.CodeMirror-scroll'); + await expect + .poll(() => cmScroll.evaluate((el) => el.scrollHeight - el.clientHeight)) + .toBeGreaterThan(0); + + // Scroll down into the body. + const target = await cmScroll.evaluate((el) => { + el.scrollTop = Math.floor((el.scrollHeight - el.clientHeight) * 0.6); + return el.scrollTop; + }); + expect(target).toBeGreaterThan(0); + + // Collapse then reopen. + await header.click({ position: { x: 8, y: 12 } }); + await expect(body).toBeHidden(); + await header.click({ position: { x: 8, y: 12 } }); + await expect(body).toBeVisible(); + + // Editor should return to where we left it, not to the top. + await expect + .poll(() => page.getByTestId('ws-message-body-0').locator('.CodeMirror-scroll').evaluate((el) => el.scrollTop)) + .toBeGreaterThan(target - 60); + }); +}); diff --git a/tests/websockets/message-scroll.spec.ts b/tests/websockets/message-scroll.spec.ts new file mode 100644 index 000000000..c4c037702 --- /dev/null +++ b/tests/websockets/message-scroll.spec.ts @@ -0,0 +1,79 @@ +import { expect, test } from '../../playwright'; +import { openRequest, selectRequestPaneTab, closeAllCollections } from '../utils/page/actions'; + +const COLLECTION_NAME = 'collection'; +const LONG_MSG_REQ = 'ws-long-msg'; + +test.describe('websocket message list scroll on tab switch', () => { + test.afterEach(async ({ pageWithUserData: page }) => { + await closeAllCollections(page); + }); + + test('does not scroll the expanded message list when switching request pane tabs', async ({ + pageWithUserData: page + }) => { + await openRequest(page, COLLECTION_NAME, LONG_MSG_REQ); + + const container = page.getByTestId('ws-messages-container'); + await expect(container).toBeVisible(); + + await expect(page.getByTestId('ws-message-body-0')).toBeVisible(); + + // the container must actually be scrollable. + await expect + .poll(() => container.evaluate((el) => el.scrollHeight - el.clientHeight)) + .toBeGreaterThan(0); + + // Start from the top of the list. + await container.evaluate((el) => { + el.scrollTop = 0; + }); + await expect.poll(() => container.evaluate((el) => el.scrollTop)).toBe(0); + + // Switch away from the Message tab and back — this remounts WsBody. + await selectRequestPaneTab(page, 'Headers'); + await expect(container).toBeHidden(); + await selectRequestPaneTab(page, 'Message'); + + await expect(container).toBeVisible(); + await expect(page.getByTestId('ws-message-body-0')).toBeVisible(); + + // The scroll position must not have jumped to the bottom on remount. + await expect.poll(() => container.evaluate((el) => el.scrollTop)).toBe(0); + }); + + test('restores the scroll position when switching back to the Message tab', async ({ + pageWithUserData: page + }) => { + await openRequest(page, COLLECTION_NAME, LONG_MSG_REQ); + + const container = page.getByTestId('ws-messages-container'); + await expect(container).toBeVisible(); + await expect(page.getByTestId('ws-message-body-0')).toBeVisible(); + + // The container must actually be scrollable. + await expect + .poll(() => container.evaluate((el) => el.scrollHeight - el.clientHeight)) + .toBeGreaterThan(0); + + // Scroll to a specific position partway down the list. + const targetScroll = await container.evaluate((el) => { + const target = Math.floor((el.scrollHeight - el.clientHeight) / 2); + el.scrollTop = target; + return el.scrollTop; + }); + expect(targetScroll).toBeGreaterThan(0); + await expect.poll(() => container.evaluate((el) => el.scrollTop)).toBe(targetScroll); + + // Switch away from the Message tab and back — this remounts WsBody. + await selectRequestPaneTab(page, 'Headers'); + await expect(container).toBeHidden(); + await selectRequestPaneTab(page, 'Message'); + + await expect(container).toBeVisible(); + await expect(page.getByTestId('ws-message-body-0')).toBeVisible(); + + // The scroll position must be restored to where we left off. + await expect.poll(() => container.evaluate((el) => el.scrollTop)).toBe(targetScroll); + }); +}); diff --git a/tests/websockets/message-typing-scroll.spec.ts b/tests/websockets/message-typing-scroll.spec.ts new file mode 100644 index 000000000..a8acbcafa --- /dev/null +++ b/tests/websockets/message-typing-scroll.spec.ts @@ -0,0 +1,49 @@ +import { expect, test } from '../../playwright'; +import { openRequest, closeAllCollections } from '../utils/page/actions'; + +const COLLECTION_NAME = 'collection'; +const SCROLL_REQ = 'ws-scroll-top'; +const MULTI_REQ = 'ws-long-msg'; + +test.describe('websocket message editor typing scroll', () => { + test.afterEach(async ({ pageWithUserData: page }) => { + await closeAllCollections(page); + }); + + test('typing does not jump the list to the end', async ({ pageWithUserData: page }) => { + await openRequest(page, COLLECTION_NAME, SCROLL_REQ); + + const container = page.getByTestId('ws-messages-container'); + const body = page.getByTestId('ws-message-body-0'); + await expect(container).toBeVisible(); + await expect(body).toBeVisible(); + + const cmScroll = body.locator('.CodeMirror-scroll'); + await expect + .poll(() => cmScroll.evaluate((el) => el.scrollHeight - el.clientHeight)) + .toBeGreaterThan(0); + + // Scroll into the middle of the message body and place the cursor there. + await cmScroll.evaluate((el) => { + el.scrollTop = Math.floor((el.scrollHeight - el.clientHeight) / 2); + }); + await body.locator('.CodeMirror').click(); + + const containerBefore = await container.evaluate((el) => el.scrollTop); + const cmBefore = await cmScroll.evaluate((el) => el.scrollTop); + + await page.keyboard.type('x'); + + const containerAfter = await container.evaluate((el) => el.scrollTop); + const cmAfter = await cmScroll.evaluate((el) => el.scrollTop); + + const listMoved = Math.abs(containerAfter - containerBefore); + const editorMoved = Math.abs(cmAfter - cmBefore); + + // Typing on the same, already-visible line must not scroll anything. + // The bug flung the list (and would fling the editor) to the very end; + // healthy behaviour is zero movement (≤ 1px allows for fractional scrollTop). + expect(listMoved).toBeLessThanOrEqual(1); + expect(editorMoved).toBeLessThanOrEqual(1); + }); +});