+
{!item?.response ? (
focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? (
k.startsWith(prefix))
.forEach((k) => localStorage.removeItem(k));
-}
+}
\ No newline at end of file
diff --git a/packages/bruno-app/src/hooks/usePersistedState/index.ts b/packages/bruno-app/src/hooks/usePersistedState/index.ts
index 652210701..45729a046 100644
--- a/packages/bruno-app/src/hooks/usePersistedState/index.ts
+++ b/packages/bruno-app/src/hooks/usePersistedState/index.ts
@@ -1,5 +1,5 @@
import type { Dispatch, SetStateAction } from 'react';
-import { useCallback, useState, useEffect } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import { usePersistenceScope } from './PersistedScopeProvider';
type Options = {
@@ -13,17 +13,33 @@ export function usePersistedState(options: Options): [T, Dispatch(options.default ?? undefined);
+ const [state, setState] = useState(() => {
+ try {
+ const raw = localStorage.getItem(storageKey);
+ if (raw !== null) {
+ const parsed = JSON.parse(raw);
+ if (parsed !== undefined) return parsed;
+ }
+ } catch {}
+ return options.default ?? undefined;
+ });
+ // Re-read from localStorage when storageKey changes (e.g. React reuses component instance with different data)
+ const prevKeyRef = useRef(storageKey);
useEffect(() => {
- const raw = localStorage.getItem(storageKey);
- const existingState = JSON.parse(raw);
-
- if (existingState !== undefined) {
- setState(existingState);
- }
-
- return;
+ if (prevKeyRef.current === storageKey) return;
+ prevKeyRef.current = storageKey;
+ try {
+ const raw = localStorage.getItem(storageKey);
+ if (raw !== null) {
+ const parsed = JSON.parse(raw);
+ if (parsed !== undefined) {
+ setState(parsed);
+ return;
+ }
+ }
+ } catch {}
+ setState(options.default ?? undefined);
}, [storageKey]);
const onSet = useCallback(
diff --git a/packages/bruno-app/src/hooks/useTrackScroll/index.ts b/packages/bruno-app/src/hooks/useTrackScroll/index.ts
new file mode 100644
index 000000000..e5e8cad04
--- /dev/null
+++ b/packages/bruno-app/src/hooks/useTrackScroll/index.ts
@@ -0,0 +1,61 @@
+import type { RefObject } from 'react';
+import { useEffect, useRef } from 'react';
+
+const SAVE_DEBOUNCE_MS = 200;
+
+export type UseTrackScrollOptions = {
+ /** Called with the current scrollTop on every debounced scroll and on unmount. */
+ onChange: (value: number) => void;
+ /** Scroll position to restore on mount (typically from usePersistedState). */
+ initialValue?: number;
+ /** Ref to an element inside (or equal to) the scroll container. */
+ ref?: RefObject;
+ /** CSS selector used with `closest()` to find the scrollable ancestor. Null/undefined = use `ref` directly. */
+ selector?: string | null;
+ /** Set false to pause tracking (e.g. edit mode in Docs where CodeEditor handles its own scroll). */
+ enabled?: boolean;
+};
+
+/**
+ * Tracks scroll position on a DOM scroll container. Debounces saves at 200ms and flushes on unmount.
+ *
+ * Compose with usePersistedState for localStorage persistence:
+ * const [scroll, setScroll] = usePersistedState({ key: 'my-key', default: 0 });
+ * useTrackScroll({ ref: wrapperRef, selector: '.flex-boundary', onChange: setScroll, initialValue: scroll });
+ *
+ * For CodeMirror editors, use CodeEditor's built-in onScroll/initialScroll props instead:
+ * const [scroll, setScroll] = usePersistedState({ key: 'my-key', default: 0 });
+ *
+ */
+export function useTrackScroll(options: UseTrackScrollOptions): void {
+ const { onChange, initialValue, ref, selector, enabled = true } = options;
+
+ const saveTimeout = useRef | null>(null);
+ const scrollPosRef = useRef(initialValue ?? 0);
+ const onChangeRef = useRef(onChange);
+ onChangeRef.current = onChange;
+
+ useEffect(() => {
+ if (!enabled || !ref) return;
+
+ const el: HTMLElement | null = selector
+ ? (ref.current?.closest(selector) as HTMLElement | null) ?? null
+ : ref.current;
+ if (!el) return;
+
+ el.scrollTop = scrollPosRef.current;
+
+ const handleScroll = () => {
+ scrollPosRef.current = el.scrollTop;
+ if (saveTimeout.current) clearTimeout(saveTimeout.current);
+ saveTimeout.current = setTimeout(() => onChangeRef.current(scrollPosRef.current), SAVE_DEBOUNCE_MS);
+ };
+
+ el.addEventListener('scroll', handleScroll);
+ return () => {
+ el.removeEventListener('scroll', handleScroll);
+ if (saveTimeout.current) clearTimeout(saveTimeout.current);
+ onChangeRef.current(scrollPosRef.current);
+ };
+ }, [ref, selector, enabled]);
+}
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js
index bb8684e1d..ad5f7d294 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/app.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/app.js
@@ -2,6 +2,7 @@ import { createSlice } from '@reduxjs/toolkit';
import filter from 'lodash/filter';
import brunoClipboard from 'utils/bruno-clipboard';
import { addTab, focusTab } from './tabs';
+import { clearPersistedScope } from 'hooks/usePersistedState/PersistedScopeProvider';
const initialState = {
isDragging: false,
@@ -283,6 +284,8 @@ export const createCookieString = (cookieObj) => () => {
export const completeQuitFlow = () => (dispatch, getState) => {
const { ipcRenderer } = window;
+ // Wipe all `persisted::*` keys from localStorage before quitting
+ clearPersistedScope();
return ipcRenderer.invoke('main:complete-quit-flow');
};
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
index d07a38231..197464eb8 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
@@ -65,7 +65,7 @@ import {
} from './index';
import { each } from 'lodash';
-import { closeAllCollectionTabs, closeTabs as _closeTabs, focusTab, updateResponsePaneScrollPosition, reopenLastClosedTab } from 'providers/ReduxStore/slices/tabs';
+import { closeAllCollectionTabs, closeTabs as _closeTabs, focusTab, reopenLastClosedTab } from 'providers/ReduxStore/slices/tabs';
import { removeCollectionFromWorkspace } from 'providers/ReduxStore/slices/workspaces';
import { resolveRequestFilename } from 'utils/common/platform';
import { interpolateUrl, parsePathParams, splitOnFirst } from 'utils/url/index';
@@ -558,13 +558,6 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
return reject(error);
}
- await dispatch(
- updateResponsePaneScrollPosition({
- uid: state.tabs.activeTabUid,
- scrollY: 0
- })
- );
-
await dispatch(
initRunRequestEvent({
requestUid,
@@ -3187,7 +3180,6 @@ export const closeTabs = ({ tabUids }) => async (dispatch, getState) => {
// Find transient items and group by temp directory before closing tabs
const transientByTempDir = {};
each(tabUids, (tabUid) => {
- clearPersistedScope(tabUid);
for (const collection of collections) {
const item = findItemInCollection(collection, tabUid);
if (item?.isTransient && item.pathname) {
@@ -3206,6 +3198,10 @@ export const closeTabs = ({ tabUids }) => async (dispatch, getState) => {
// Close the tabs first
await dispatch(_closeTabs({ tabUids }));
+ // Clear persisted scope AFTER unmount — otherwise useTrackScroll's cleanup flush
+ // would rewrite scroll position to localStorage right after we cleared it.
+ each(tabUids, (tabUid) => clearPersistedScope(tabUid));
+
// After close, the reducer may have set active tab to one from another workspace. Ensure it belongs to this workspace: prefer any open in-workspace tab, then workspace overview if none.
// Dispatch is synchronous; state is already updated by _closeTabs above.
await dispatch(ensureActiveTabInCurrentWorkspace());
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js
index 77ce9beb9..57d65b016 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js
@@ -89,7 +89,6 @@ export const tabsSlice = createSlice({
requestPaneWidth: null,
requestPaneTab: requestPaneTab || defaultRequestPaneTab,
responsePaneTab: 'response',
- responsePaneScrollPosition: null,
responseFormat: null,
responseViewTab: null,
responseFilter: null,
@@ -164,20 +163,6 @@ export const tabsSlice = createSlice({
tab.responsePaneTab = action.payload.responsePaneTab;
}
},
- updateResponsePaneScrollPosition: (state, action) => {
- const tab = find(state.tabs, (t) => t.uid === action.payload.uid);
-
- if (tab) {
- tab.responsePaneScrollPosition = action.payload.scrollY;
- }
- },
- updateRequestBodyScrollPosition: (state, action) => {
- const tab = find(state.tabs, (t) => t.uid === action.payload.uid);
-
- if (tab) {
- tab.requestBodyScrollPosition = action.payload.scrollY;
- }
- },
updateResponseFormat: (state, action) => {
const tab = find(state.tabs, (t) => t.uid === action.payload.uid);
@@ -387,8 +372,6 @@ export const {
updateRequestPaneTabHeight,
updateRequestPaneTab,
updateResponsePaneTab,
- updateResponsePaneScrollPosition,
- updateRequestBodyScrollPosition,
updateResponseFormat,
updateResponseViewTab,
updateResponseFilter,
diff --git a/tests/request/body-scroll/scroll-persistent.spec.ts b/tests/request/body-scroll/scroll-persistent.spec.ts
new file mode 100644
index 000000000..23b2bed47
--- /dev/null
+++ b/tests/request/body-scroll/scroll-persistent.spec.ts
@@ -0,0 +1,1149 @@
+import { test, expect, Page } from '../../../playwright';
+import {
+ closeAllCollections,
+ createCollection,
+ createRequest,
+ createFolder,
+ selectRequestPaneTab,
+ selectResponsePaneTab,
+ selectScriptSubTab,
+ openRequest,
+ sendRequest
+} from '../../utils/page';
+import { buildCommonLocators } from '../../utils/page/locators';
+
+// ---------------------------------------------------------------------------
+// Content generators - produce enough content to make each area scrollable
+// ---------------------------------------------------------------------------
+
+const generateLargeJson = () => JSON.stringify(
+ { users: Array.from({ length: 50 }, (_, i) => ({ id: i + 1, name: `User ${i + 1}`, email: `user${i + 1}@example.com` })) },
+ null, 2
+);
+
+const generateLargeScript = () =>
+ Array.from({ length: 80 }, (_, i) => `// Line ${i + 1}\nconsole.log('step ${i + 1}');`).join('\n');
+
+const generateLargeXml = () =>
+ `\n\n${Array.from({ length: 150 }, (_, i) => ` - Item ${i + 1}
`).join('\n')}\n`;
+
+// ---------------------------------------------------------------------------
+// CodeMirror helpers - interact with CM5 instances by CSS selector
+// ---------------------------------------------------------------------------
+
+const getEditorScroll = async (page: Page, selector: string): Promise => {
+ const editor = page.locator(selector).first();
+ return editor.evaluate((el) => {
+ const cm = (el as any).CodeMirror;
+ if (!cm) return 0;
+ const info = cm.getScrollInfo();
+ return info?.top ?? 0;
+ });
+};
+
+const setEditorScroll = async (page: Page, selector: string, scrollTop: number) => {
+ const editor = page.locator(selector).first();
+ // Ensure content is laid out
+ await editor.evaluate((el) => {
+ const cm = (el as any).CodeMirror;
+ cm?.refresh();
+ });
+ // Use mouse wheel to simulate real user scrolling
+ await editor.hover();
+ const box = await editor.boundingBox();
+ if (box) {
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ const scrollStep = 200;
+ const steps = Math.ceil(scrollTop / scrollStep);
+ for (let i = 0; i < steps; i++) {
+ await page.mouse.wheel(0, scrollStep);
+ await page.waitForTimeout(50);
+ }
+ }
+ await page.waitForTimeout(300);
+
+ // In Playwright's Electron environment, CM5's internal 'scroll' event may not
+ // fire reliably from mouse.wheel. Emit it manually so the persistence hook's
+ // onScroll handler fires and updates scrollPosRef + debounced localStorage save.
+ await editor.evaluate((el) => {
+ const cm = (el as any).CodeMirror;
+ if (cm && cm.constructor?.signal) {
+ cm.constructor.signal(cm, 'scroll', cm);
+ }
+ });
+ // Wait for debounced save (200ms) to complete
+ await page.waitForTimeout(400);
+};
+
+const setEditorContent = async (page: Page, selector: string, content: string) => {
+ const editor = page.locator(selector).first();
+ await editor.evaluate((el, value) => {
+ const cm = (el as any).CodeMirror;
+ if (!cm) return;
+ cm.setValue(value);
+ cm.refresh();
+ }, content);
+ // Wait for CodeMirror to calculate scroll height for new content
+ await page.waitForTimeout(200);
+};
+
+// ---------------------------------------------------------------------------
+// Body mode helper
+// ---------------------------------------------------------------------------
+
+const selectBodyMode = async (page: Page, mode: string) => {
+ await page.locator('.body-mode-selector').click();
+ await page.locator('.dropdown-item').filter({ hasText: mode }).click();
+ await page.waitForTimeout(100);
+};
+
+// ---------------------------------------------------------------------------
+// Common assertion: scroll position is approximately restored
+// ---------------------------------------------------------------------------
+
+// CodeMirror layout sub-pixel rounding, virtualised list buffers, and remount
+// timing can shift the restored scroll by a small amount even when persistence
+// is working correctly — assert "close to" rather than exact.
+const expectScrollRestored = (restored: number, original: number) => {
+ expect(restored).toBeGreaterThan(0);
+ // 10% tolerance, with a 50px floor so small captured values still pass
+ const tolerance = Math.max(50, original * 0.1);
+ expect(restored).toBeGreaterThan(original - tolerance);
+ expect(restored).toBeLessThan(original + tolerance);
+};
+
+// For virtualised tables: assert the first-visible row index is near the expected
+// row, tolerating TableVirtuoso's buffer drift (±a few rows).
+const expectRowNear = (actual: number, expected: number, tolerance: number = 5) => {
+ expect(actual).toBeGreaterThan(expected - tolerance);
+ expect(actual).toBeLessThan(expected + tolerance);
+};
+
+// ===========================================================================
+// REQUEST PANE - scroll persistence
+// ===========================================================================
+
+test.describe('Scroll Position Persistence', () => {
+ test.beforeEach(async ({ page }) => {
+ await closeAllCollections(page);
+ });
+
+ test.afterAll(async ({ page }) => {
+ await closeAllCollections(page);
+ });
+ // -------------------------------------------------------------------------
+ // Request Pane
+ // -------------------------------------------------------------------------
+
+ test.describe('Request Pane', () => {
+ test.beforeEach(async ({ page }) => {
+ await closeAllCollections(page);
+ });
+
+ test.afterAll(async ({ page }) => {
+ await closeAllCollections(page);
+ });
+
+ test('Body (JSON) - scroll persists across tab switches', async ({ page, createTmpDir }) => {
+ const tmpDir = await createTmpDir('scroll-body-json');
+
+ await test.step('Setup', async () => {
+ await createCollection(page, 'scroll-body-json', tmpDir);
+ await createRequest(page, 'req-1', 'scroll-body-json', { url: 'https://echo.usebruno.com' });
+ await selectRequestPaneTab(page, 'Body');
+ await selectBodyMode(page, 'JSON');
+ await setEditorContent(page, '.request-pane .CodeMirror', generateLargeJson());
+ });
+
+ await test.step('Verify initial scroll is 0', async () => {
+ const initial = await getEditorScroll(page, '.request-pane .CodeMirror');
+ expect(initial).toBe(0);
+ });
+
+ let saved: number;
+
+ await test.step('Switch to Headers then back to Body', async () => {
+ await selectRequestPaneTab(page, 'Headers');
+ await selectRequestPaneTab(page, 'Body');
+ await setEditorScroll(page, '.request-pane .CodeMirror', 1500);
+ });
+
+ await test.step('Scroll down and capture position', async () => {
+ saved = await getEditorScroll(page, '.request-pane .CodeMirror');
+ expectScrollRestored(saved, 1500);
+ });
+
+ await test.step('Switch to Headers then back to Body', async () => {
+ await selectRequestPaneTab(page, 'Headers');
+
+ await selectRequestPaneTab(page, 'Body');
+ });
+
+ await test.step('Verify scroll restored', async () => {
+ const checkNewPosition = await getEditorScroll(page, '.request-pane .CodeMirror');
+ expectScrollRestored(checkNewPosition, saved);
+ });
+ });
+
+ test('Body (XML) - scroll persists across tab switches', async ({ page, createTmpDir }) => {
+ const tmpDir = await createTmpDir('scroll-body-xml');
+
+ await test.step('Setup', async () => {
+ await createCollection(page, 'scroll-body-xml', tmpDir);
+ await createRequest(page, 'req-xml', 'scroll-body-xml', { url: 'https://echo.usebruno.com' });
+ await selectRequestPaneTab(page, 'Body');
+ await selectBodyMode(page, 'XML');
+ await setEditorContent(page, '.request-pane .CodeMirror', generateLargeXml());
+ });
+
+ await test.step('Verify initial scroll is 0', async () => {
+ const initial = await getEditorScroll(page, '.request-pane .CodeMirror');
+ expect(initial).toBe(0);
+ });
+
+ let saved: number;
+
+ await test.step('Initialize hook via tab switch, then scroll', async () => {
+ await selectRequestPaneTab(page, 'Params');
+
+ await selectRequestPaneTab(page, 'Body');
+
+ await setEditorScroll(page, '.request-pane .CodeMirror', 1500);
+ });
+
+ await test.step('Capture scroll position', async () => {
+ saved = await getEditorScroll(page, '.request-pane .CodeMirror');
+ expectScrollRestored(saved, 1500);
+ });
+
+ await test.step('Switch to Params then back to Body', async () => {
+ await selectRequestPaneTab(page, 'Params');
+
+ await selectRequestPaneTab(page, 'Body');
+ });
+
+ await test.step('Verify scroll restored', async () => {
+ const restored = await getEditorScroll(page, '.request-pane .CodeMirror');
+ expectScrollRestored(restored, saved);
+ });
+ });
+
+ test('Script - pre-request and post-response scroll persists across sub-tab switches', async ({ page, createTmpDir }) => {
+ const tmpDir = await createTmpDir('scroll-script');
+ const PRE_SELECTOR = '[data-testid="pre-request-script-editor"] .CodeMirror';
+ const POST_SELECTOR = '[data-testid="post-response-script-editor"] .CodeMirror';
+
+ let preReqSaved: number;
+ let postResSaved: number;
+
+ // --- Pre-request: add content, init hook, scroll, verify ---
+
+ await test.step('Switch to pre-request and add content', async () => {
+ await createCollection(page, 'scroll-script', tmpDir);
+ await createRequest(page, 'req-script', 'scroll-script', { url: 'https://echo.usebruno.com' });
+ await selectScriptSubTab(page, 'pre-request');
+ await setEditorContent(page, PRE_SELECTOR, generateLargeScript());
+ });
+
+ await test.step('Verify initial scroll is 0', async () => {
+ const initial = await getEditorScroll(page, PRE_SELECTOR);
+ expect(initial).toBe(0);
+ });
+
+ await test.step('Init pre-request hook: switch to Headers and back', async () => {
+ await selectRequestPaneTab(page, 'Headers');
+
+ await selectRequestPaneTab(page, 'Script');
+ });
+
+ await test.step('Scroll pre-request editor', async () => {
+ await selectScriptSubTab(page, 'pre-request');
+
+ await setEditorScroll(page, PRE_SELECTOR, 1500);
+ preReqSaved = await getEditorScroll(page, PRE_SELECTOR);
+ expectScrollRestored(preReqSaved, 1500);
+ });
+
+ await test.step('Verify pre-request: switch to Headers and back', async () => {
+ await selectRequestPaneTab(page, 'Headers');
+
+ await selectScriptSubTab(page, 'post-response');
+
+ await selectScriptSubTab(page, 'pre-request');
+
+ const restored = await getEditorScroll(page, PRE_SELECTOR);
+ expectScrollRestored(restored, preReqSaved);
+ });
+
+ // --- Post-response: add content, init hook, scroll, verify ---
+
+ await test.step('Switch to post-response and add content', async () => {
+ await selectScriptSubTab(page, 'post-response');
+
+ await setEditorContent(page, POST_SELECTOR, generateLargeScript());
+ });
+
+ await test.step('Verify initial scroll is 0', async () => {
+ const initial = await getEditorScroll(page, POST_SELECTOR);
+ expect(initial).toBe(0);
+ });
+
+ await test.step('Init post-response hook: switch to Headers and back', async () => {
+ await selectRequestPaneTab(page, 'Headers');
+
+ await selectRequestPaneTab(page, 'Script');
+
+ await selectScriptSubTab(page, 'post-response');
+ });
+
+ await test.step('Scroll post-response editor', async () => {
+ await setEditorScroll(page, POST_SELECTOR, 1500);
+ postResSaved = await getEditorScroll(page, POST_SELECTOR);
+ expectScrollRestored(postResSaved, 1500);
+ });
+
+ await test.step('Verify post-response: switch to Headers and back', async () => {
+ await selectRequestPaneTab(page, 'Headers');
+
+ await selectRequestPaneTab(page, 'Script');
+
+ await selectScriptSubTab(page, 'post-response');
+
+ const restored = await getEditorScroll(page, POST_SELECTOR);
+ expectScrollRestored(restored, postResSaved);
+ });
+
+ // --- Final check: both persist across pre/post sub-tab switch ---
+
+ await test.step('Verify pre-request still persisted after post-response check', async () => {
+ await selectScriptSubTab(page, 'pre-request');
+
+ const restored = await getEditorScroll(page, PRE_SELECTOR);
+ expectScrollRestored(restored, preReqSaved);
+ });
+
+ await test.step('Verify post-response still persisted after pre-request check', async () => {
+ await selectScriptSubTab(page, 'post-response');
+
+ const restored = await getEditorScroll(page, POST_SELECTOR);
+ expectScrollRestored(restored, postResSaved);
+ });
+ });
+
+ test('Tests editor - scroll persists across tab switches', async ({ page, createTmpDir }) => {
+ const tmpDir = await createTmpDir('scroll-tests');
+
+ await test.step('Setup', async () => {
+ await createCollection(page, 'scroll-tests', tmpDir);
+ await createRequest(page, 'req-tests', 'scroll-tests', { url: 'https://echo.usebruno.com' });
+ await selectRequestPaneTab(page, 'Tests');
+ await setEditorContent(page, '[data-testid="test-script-editor"] .CodeMirror', generateLargeScript());
+ });
+
+ await test.step('Verify initial scroll is 0', async () => {
+ const initial = await getEditorScroll(page, '[data-testid="test-script-editor"] .CodeMirror');
+ expect(initial).toBe(0);
+ });
+
+ let saved: number;
+
+ await test.step('Initialize hook via tab switch, then scroll', async () => {
+ await selectRequestPaneTab(page, 'Vars');
+
+ await selectRequestPaneTab(page, 'Tests');
+
+ await setEditorScroll(page, '[data-testid="test-script-editor"] .CodeMirror', 1500);
+ });
+
+ await test.step('Capture scroll position', async () => {
+ saved = await getEditorScroll(page, '[data-testid="test-script-editor"] .CodeMirror');
+ expectScrollRestored(saved, 1500);
+ });
+
+ await test.step('Switch to Body then back to Tests', async () => {
+ await selectRequestPaneTab(page, 'Vars');
+ await selectRequestPaneTab(page, 'Tests');
+ });
+
+ await test.step('Verify scroll restored', async () => {
+ const restored = await getEditorScroll(page, '[data-testid="test-script-editor"] .CodeMirror');
+ expectScrollRestored(restored, saved);
+ });
+ });
+
+ test('Scroll positions are independent per request', async ({ page, createTmpDir }) => {
+ const tmpDir = await createTmpDir('scroll-per-request');
+
+ await test.step('Setup two requests with JSON bodies', async () => {
+ await createCollection(page, 'scroll-per-request', tmpDir);
+ await createRequest(page, 'req-a', 'scroll-per-request', { url: 'https://echo.usebruno.com' });
+ await createRequest(page, 'req-b', 'scroll-per-request', { url: 'https://echo.usebruno.com' });
+ });
+
+ let scrollA: number;
+
+ await test.step('Open req-a, set body, initialize hook via tab switch, then scroll', async () => {
+ await openRequest(page, 'scroll-per-request', 'req-a');
+ await selectRequestPaneTab(page, 'Body');
+ await selectBodyMode(page, 'JSON');
+ await setEditorContent(page, '.request-pane .CodeMirror', generateLargeJson());
+ });
+
+ await test.step('Verify initial scroll is 0', async () => {
+ const initial = await getEditorScroll(page, '.request-pane .CodeMirror');
+ expect(initial).toBe(0);
+ });
+
+ await test.step('Initialize hook via tab switch, then scroll', async () => {
+ // Initialize hook
+ await selectRequestPaneTab(page, 'Headers');
+
+ await selectRequestPaneTab(page, 'Body');
+
+ await setEditorScroll(page, '.request-pane .CodeMirror', 1500);
+ });
+
+ await test.step('Capture scroll position', async () => {
+ scrollA = await getEditorScroll(page, '.request-pane .CodeMirror');
+ expectScrollRestored(scrollA, 1500);
+ });
+
+ await test.step('Switch to req-b', async () => {
+ await openRequest(page, 'scroll-per-request', 'req-b');
+ });
+
+ await test.step('Switch back to req-a and verify scroll', async () => {
+ await openRequest(page, 'scroll-per-request', 'req-a');
+ await selectRequestPaneTab(page, 'Body');
+
+ const restored = await getEditorScroll(page, '.request-pane .CodeMirror');
+ expectScrollRestored(restored, scrollA);
+ });
+ });
+
+ test('Request Headers - scroll persists with many headers across tab switches', async ({ page, createTmpDir }) => {
+ const tmpDir = await createTmpDir('scroll-req-headers');
+ const scrollContainer = '.flex-boundary';
+ const firstVisibleRowLocator = () => page.getByTestId('editable-table').locator('table > tbody > tr:nth-child(2)');
+
+ await test.step('Setup request and navigate to Headers tab', async () => {
+ await createCollection(page, 'scroll-req-headers', tmpDir);
+ await createRequest(page, 'req-headers', 'scroll-req-headers', { url: 'https://echo.usebruno.com' });
+ await selectRequestPaneTab(page, 'Headers');
+ });
+
+ await test.step('Add 100 headers via Bulk Edit', async () => {
+ const bulkEditBtn = page.getByTestId('bulk-edit-toggle');
+ await bulkEditBtn.scrollIntoViewIfNeeded();
+ await bulkEditBtn.click();
+
+ const bulkHeaders = Array.from({ length: 100 }, (_, i) =>
+ `X-Custom-Header-${i + 1}:value-${i + 1}`
+ ).join('\n');
+
+ // The bulk editor CodeMirror should now be visible in the request pane
+ const bulkEditor = page.locator('[data-testid="request-pane"] .CodeMirror').first();
+ await bulkEditor.evaluate((el, content) => {
+ const cm = (el as any).CodeMirror;
+ cm?.setValue(content);
+ }, bulkHeaders);
+
+ await page.getByTestId('key-value-edit-toggle').click();
+ });
+
+ await test.step('Verify initial scroll is 0', async () => {
+ const container = page.locator(scrollContainer).first();
+ const initial = await container.evaluate((el) => el.scrollTop);
+ expect(initial).toBe(0);
+ });
+
+ await test.step('Scroll to ~middle of table (~row 50)', async () => {
+ const container = page.locator(scrollContainer).first();
+ // Scroll halfway through the virtualised list so ~row 50 becomes the first visible row
+ await container.evaluate((el) => { el.scrollTop = el.scrollHeight / 2; });
+
+ // Auto-retry: wait for TableVirtuoso to land on a row in [45, 55]
+ // (matches the ~row 50 ± 5 range that expectRowNear asserts)
+ const element = firstVisibleRowLocator();
+ await expect(element).toHaveAttribute('data-index', /^(4[5-9]|5[0-5])$/, { timeout: 2000 });
+ });
+
+ await test.step('Switch to Body tab and back to Headers', async () => {
+ await selectRequestPaneTab(page, 'Body');
+ await selectRequestPaneTab(page, 'Headers');
+ const tableRow = page.getByRole('row', { name: 'Name Value' }).getByRole('cell').first();
+ await expect(tableRow).toBeVisible({ timeout: 2000 });
+ });
+
+ await test.step('Verify scroll restored to ~row 50', async () => {
+ const element = firstVisibleRowLocator();
+ const current = parseInt(await element.getAttribute('data-index') as string);
+ expectRowNear(current, 50);
+ });
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Response Pane
+ // -------------------------------------------------------------------------
+
+ test.describe('Response Pane', () => {
+ test.beforeEach(async ({ page }) => {
+ await closeAllCollections(page);
+ });
+
+ test.afterAll(async ({ page }) => {
+ await closeAllCollections(page);
+ });
+
+ test('Response body - scroll persists across response tab switches', async ({ page, createTmpDir }) => {
+ const tmpDir = await createTmpDir('scroll-response');
+ const responseEditor = '.response-pane .CodeMirror';
+
+ await test.step('Create collection, request, set JSON body and send', async () => {
+ await createCollection(page, 'scroll-response', tmpDir);
+ await createRequest(page, 'req-resp', 'scroll-response', { url: 'https://jsonplaceholder.typicode.com/todos' });
+ await selectRequestPaneTab(page, 'Body');
+ await selectBodyMode(page, 'JSON');
+ await setEditorContent(page, '.request-pane .CodeMirror', generateLargeJson());
+ await sendRequest(page, 200);
+ });
+
+ let saved: number;
+
+ await test.step('Initialize hook: switch response tabs', async () => {
+ await selectResponsePaneTab(page, 'Response');
+ await selectResponsePaneTab(page, 'Headers');
+ await selectResponsePaneTab(page, 'Response');
+ });
+
+ await test.step('Verify initial scroll is 0', async () => {
+ const initial = await getEditorScroll(page, responseEditor);
+ expect(initial).toBe(0);
+ });
+
+ await test.step('Scroll response editor and capture position', async () => {
+ await setEditorScroll(page, responseEditor, 1500);
+ saved = await getEditorScroll(page, responseEditor);
+ expectScrollRestored(saved, 1500);
+ });
+
+ await test.step('Switch to Headers tab and back', async () => {
+ await selectResponsePaneTab(page, 'Headers');
+
+ await selectResponsePaneTab(page, 'Response');
+ });
+
+ await test.step('Verify scroll restored', async () => {
+ const restored = await getEditorScroll(page, responseEditor);
+ expectScrollRestored(restored, saved);
+ });
+ });
+
+ test('Response headers - scroll persists across response tab switches', async ({ page, createTmpDir }) => {
+ const tmpDir = await createTmpDir('scroll-response-headers');
+ const headersContainer = '.response-tab-content';
+
+ await test.step('Create collection, request and send to get response headers', async () => {
+ await createCollection(page, 'scroll-response-headers', tmpDir);
+ await createRequest(page, 'req-resp-headers', 'scroll-response-headers', { url: 'https://jsonplaceholder.typicode.com/todos' });
+ await sendRequest(page, 200);
+ });
+
+ let saved: number;
+
+ await test.step('Initialize hook: switch response tabs', async () => {
+ await selectResponsePaneTab(page, 'Headers');
+
+ await selectResponsePaneTab(page, 'Response');
+
+ await selectResponsePaneTab(page, 'Headers');
+ });
+
+ await test.step('Verify initial scroll is 0', async () => {
+ const container = page.locator(headersContainer).first();
+ const initial = await container.evaluate((el) => el.scrollTop);
+ expect(initial).toBe(0);
+ });
+
+ await test.step('Scroll response headers and capture position', async () => {
+ const container = page.locator(headersContainer).first();
+ await container.evaluate((el) => { el.scrollTop = 200; });
+
+ saved = await container.evaluate((el) => el.scrollTop);
+ expectScrollRestored(saved, 200);
+ });
+
+ await test.step('Switch to Response tab and back to Headers', async () => {
+ await selectResponsePaneTab(page, 'Response');
+
+ await selectResponsePaneTab(page, 'Headers');
+ });
+
+ await test.step('Verify scroll restored', async () => {
+ const container = page.locator(headersContainer).first();
+ const restored = await container.evaluate((el) => el.scrollTop);
+ expectScrollRestored(restored, saved);
+ });
+ });
+
+ test('Response timeline - scroll persists across response tab switches', async ({ page, createTmpDir }) => {
+ const tmpDir = await createTmpDir('scroll-response-timeline');
+ const timelineScroller = '.timeline-container';
+
+ await test.step('Create collection and request', async () => {
+ await createCollection(page, 'scroll-response-timeline', tmpDir);
+ await createRequest(page, 'req-timeline', 'scroll-response-timeline', { url: 'http://localhost:8081' });
+ });
+
+ await test.step('Send and cancel requests to generate timeline entries', async () => {
+ const sendBtn = page.getByTestId('send-arrow-icon');
+ for (let i = 0; i < 25; i++) {
+ await sendBtn.click({ timeout: 2000 });
+ // Immediately cancel - we just need the timeline entry, not the response
+ await sendBtn.click({ timeout: 2000 });
+ }
+ });
+
+ let saved: number;
+
+ await test.step('Switch to Timeline tab', async () => {
+ await selectResponsePaneTab(page, 'Timeline');
+ });
+
+ await test.step('Initialize hook: switch tabs', async () => {
+ await selectResponsePaneTab(page, 'Response');
+
+ await selectResponsePaneTab(page, 'Timeline');
+ });
+
+ await test.step('Verify initial scroll is 0', async () => {
+ const container = page.locator(timelineScroller).first().locator('..');
+ const initial = await container.evaluate((el) => el.scrollTop);
+ expect(initial).toBe(0);
+ });
+
+ await test.step('Scroll timeline and capture position', async () => {
+ // Timeline StyledWrapper is the parent of .timeline-container
+ const container = page.locator(timelineScroller).first();
+ const scrollParent = container.locator('..');
+ await scrollParent.evaluate((el) => { el.scrollTop = 500; });
+
+ saved = await scrollParent.evaluate((el) => el.scrollTop);
+ expectScrollRestored(saved, 500);
+ });
+
+ await test.step('Switch to Response tab and back to Timeline', async () => {
+ await selectResponsePaneTab(page, 'Response');
+
+ await selectResponsePaneTab(page, 'Timeline');
+ });
+
+ await test.step('Verify scroll restored', async () => {
+ const container = page.locator(timelineScroller).first();
+ const scrollParent = container.locator('..');
+ const restored = await scrollParent.evaluate((el) => el.scrollTop);
+ expectScrollRestored(restored, saved);
+ });
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Folder Settings
+ // -------------------------------------------------------------------------
+
+ test.describe('Folder Settings', () => {
+ test.beforeEach(async ({ page }) => {
+ await closeAllCollections(page);
+ });
+
+ test.afterAll(async ({ page }) => {
+ await closeAllCollections(page);
+ });
+
+ test('Folder Script - scroll persists across sub-tab switches', async ({ page, createTmpDir }) => {
+ const tmpDir = await createTmpDir('scroll-folder-script');
+ const locators = buildCommonLocators(page);
+
+ await test.step('Setup folder', async () => {
+ await createCollection(page, 'scroll-folder-script', tmpDir);
+ await createFolder(page, 'test-folder', 'scroll-folder-script');
+ await locators.sidebar.folder('test-folder').click({ timeout: 2000 });
+ });
+
+ await test.step('Navigate to Script tab and fill pre-request', async () => {
+ await locators.paneTabs.folderSettingsTab('script').click({ timeout: 2000 });
+ await page.getByTestId('tab-trigger-pre-request').click({ timeout: 2000 });
+ await setEditorContent(page, '.CodeMirror', generateLargeScript());
+ });
+
+ await test.step('Verify initial scroll is 0', async () => {
+ const initial = await getEditorScroll(page, '.CodeMirror');
+ expect(initial).toBe(0);
+ });
+
+ let saved: number;
+
+ await test.step('Initialize hook via tab switch, then scroll', async () => {
+ await locators.paneTabs.folderSettingsTab('headers').click({ timeout: 2000 });
+ await locators.paneTabs.folderSettingsTab('script').click({ timeout: 2000 });
+ await page.getByTestId('tab-trigger-pre-request').click({ timeout: 2000 });
+ await setEditorScroll(page, '.CodeMirror', 400);
+ });
+
+ await test.step('Capture scroll position', async () => {
+ saved = await getEditorScroll(page, '.CodeMirror');
+ expectScrollRestored(saved, 400);
+ });
+
+ await test.step('Switch to post-response and back', async () => {
+ await page.getByTestId('tab-trigger-post-response').click({ timeout: 2000 });
+ await page.getByTestId('tab-trigger-pre-request').click({ timeout: 2000 });
+ });
+
+ await test.step('Verify scroll restored', async () => {
+ const restored = await getEditorScroll(page, '.CodeMirror');
+ expectScrollRestored(restored, saved);
+ });
+ });
+
+ test('Folder Tests - scroll persists across tab switches', async ({ page, createTmpDir }) => {
+ const tmpDir = await createTmpDir('scroll-folder-tests');
+ const locators = buildCommonLocators(page);
+
+ await test.step('Setup folder and add test content', async () => {
+ await createCollection(page, 'scroll-folder-tests', tmpDir);
+ await createFolder(page, 'test-folder', 'scroll-folder-tests');
+ await locators.sidebar.folder('test-folder').click({ timeout: 2000 });
+ await locators.paneTabs.folderSettingsTab('test').click({ timeout: 2000 });
+ await setEditorContent(page, '.CodeMirror', generateLargeScript());
+ });
+
+ await test.step('Verify initial scroll is 0', async () => {
+ const initial = await getEditorScroll(page, '.CodeMirror');
+ expect(initial).toBe(0);
+ });
+
+ let saved: number;
+
+ await test.step('Initialize hook via tab switch, then scroll', async () => {
+ await locators.paneTabs.folderSettingsTab('headers').click({ timeout: 2000 });
+ await locators.paneTabs.folderSettingsTab('test').click({ timeout: 2000 });
+ await setEditorScroll(page, '.CodeMirror', 1500);
+ });
+
+ await test.step('Capture scroll position', async () => {
+ saved = await getEditorScroll(page, '.CodeMirror');
+ expectScrollRestored(saved, 1500);
+ });
+
+ await test.step('Switch to headers and back', async () => {
+ await locators.paneTabs.folderSettingsTab('headers').click({ timeout: 2000 });
+ await locators.paneTabs.folderSettingsTab('test').click({ timeout: 2000 });
+ });
+
+ await test.step('Verify scroll restored', async () => {
+ const restored = await getEditorScroll(page, '.CodeMirror');
+ expectScrollRestored(restored, saved);
+ });
+ });
+
+ test('Folder Docs - scroll persists in edit mode across tab switches', async ({ page, createTmpDir }) => {
+ const tmpDir = await createTmpDir('scroll-folder-docs');
+ const locators = buildCommonLocators(page);
+ const largeDocContent = Array.from({ length: 80 }, (_, i) => `## Section ${i + 1}\nLorem ipsum dolor sit amet for section ${i + 1}.`).join('\n\n');
+
+ await test.step('Setup folder and navigate to Docs tab', async () => {
+ await createCollection(page, 'scroll-folder-docs', tmpDir);
+ await createFolder(page, 'test-folder', 'scroll-folder-docs');
+ await locators.sidebar.folder('test-folder').click({ timeout: 2000 });
+ await locators.paneTabs.folderSettingsTab('docs').click({ timeout: 2000 });
+ });
+
+ await test.step('Click Edit and add large doc content', async () => {
+ const editToggle = page.locator('.editing-mode');
+ await editToggle.click({ timeout: 2000 });
+ await setEditorContent(page, '.CodeMirror', largeDocContent);
+ });
+
+ await test.step('Verify initial scroll is 0', async () => {
+ const initial = await getEditorScroll(page, '.CodeMirror');
+ expect(initial).toBe(0);
+ });
+
+ let saved: number;
+
+ await test.step('Initialize hook via tab switch, then scroll', async () => {
+ await locators.paneTabs.folderSettingsTab('headers').click({ timeout: 2000 });
+ await locators.paneTabs.folderSettingsTab('docs').click({ timeout: 2000 });
+ await setEditorScroll(page, '.CodeMirror', 1500);
+ });
+
+ await test.step('Capture scroll position', async () => {
+ saved = await getEditorScroll(page, '.CodeMirror');
+ expectScrollRestored(saved, 1500);
+ });
+
+ await test.step('Switch to headers and back to docs edit mode', async () => {
+ await locators.paneTabs.folderSettingsTab('headers').click({ timeout: 2000 });
+ await locators.paneTabs.folderSettingsTab('docs').click({ timeout: 2000 });
+ });
+
+ await test.step('Verify scroll restored', async () => {
+ const restored = await getEditorScroll(page, '.CodeMirror');
+ expectScrollRestored(restored, saved);
+ });
+ });
+
+ test('Folder Script pre-request - scroll persists across tab switches', async ({ page, createTmpDir }) => {
+ const tmpDir = await createTmpDir('scroll-folder-pre-req');
+ const locators = buildCommonLocators(page);
+ const PRE_SELECTOR = '[data-testid="folder-pre-request-script-editor"] .CodeMirror';
+
+ let saved: number;
+
+ await test.step('Setup folder and add pre-request content', async () => {
+ await createCollection(page, 'scroll-folder-pre-req', tmpDir);
+ await createFolder(page, 'test-folder', 'scroll-folder-pre-req');
+ await locators.sidebar.folder('test-folder').click({ timeout: 2000 });
+ await locators.paneTabs.folderSettingsTab('script').click({ timeout: 2000 });
+ await page.getByTestId('tab-trigger-pre-request').click({ timeout: 2000 });
+ await setEditorContent(page, PRE_SELECTOR, generateLargeScript());
+ });
+
+ await test.step('Verify initial scroll is 0', async () => {
+ const initial = await getEditorScroll(page, PRE_SELECTOR);
+ expect(initial).toBe(0);
+ });
+
+ await test.step('Init hook: switch tabs', async () => {
+ await locators.paneTabs.folderSettingsTab('headers').click({ timeout: 2000 });
+ await locators.paneTabs.folderSettingsTab('script').click({ timeout: 2000 });
+ });
+
+ await test.step('Scroll pre-request editor', async () => {
+ await page.getByTestId('tab-trigger-pre-request').click({ timeout: 2000 });
+ await setEditorScroll(page, PRE_SELECTOR, 1500);
+ saved = await getEditorScroll(page, PRE_SELECTOR);
+ expectScrollRestored(saved, 1500);
+ });
+
+ await test.step('Switch to headers and back', async () => {
+ await locators.paneTabs.folderSettingsTab('headers').click({ timeout: 2000 });
+ await locators.paneTabs.folderSettingsTab('script').click({ timeout: 2000 });
+ await page.getByTestId('tab-trigger-pre-request').click({ timeout: 2000 });
+ });
+
+ await test.step('Verify scroll restored', async () => {
+ const restored = await getEditorScroll(page, PRE_SELECTOR);
+ expectScrollRestored(restored, saved);
+ });
+ });
+
+ test('Folder Script post-response - scroll persists across tab switches', async ({ page, createTmpDir }) => {
+ const tmpDir = await createTmpDir('scroll-folder-post-res');
+ const locators = buildCommonLocators(page);
+ const POST_SELECTOR = '[data-testid="folder-post-response-script-editor"] .CodeMirror';
+
+ let saved: number;
+
+ await test.step('Setup folder and add post-response content', async () => {
+ await createCollection(page, 'scroll-folder-post-res', tmpDir);
+ await createFolder(page, 'test-folder', 'scroll-folder-post-res');
+ await locators.sidebar.folder('test-folder').click({ timeout: 2000 });
+ await locators.paneTabs.folderSettingsTab('script').click({ timeout: 2000 });
+ await page.getByTestId('tab-trigger-post-response').click({ timeout: 2000 });
+ await setEditorContent(page, POST_SELECTOR, generateLargeScript());
+ });
+
+ await test.step('Verify initial scroll is 0', async () => {
+ const initial = await getEditorScroll(page, POST_SELECTOR);
+ expect(initial).toBe(0);
+ });
+
+ await test.step('Init hook: switch tabs', async () => {
+ await locators.paneTabs.folderSettingsTab('headers').click({ timeout: 2000 });
+ await locators.paneTabs.folderSettingsTab('script').click({ timeout: 2000 });
+ await page.getByTestId('tab-trigger-post-response').click({ timeout: 2000 });
+ });
+
+ await test.step('Scroll post-response editor', async () => {
+ await setEditorScroll(page, POST_SELECTOR, 1500);
+ saved = await getEditorScroll(page, POST_SELECTOR);
+ expectScrollRestored(saved, 1500);
+ });
+
+ await test.step('Switch to headers and back', async () => {
+ await locators.paneTabs.folderSettingsTab('headers').click({ timeout: 2000 });
+ await locators.paneTabs.folderSettingsTab('script').click({ timeout: 2000 });
+ await page.getByTestId('tab-trigger-post-response').click({ timeout: 2000 });
+ });
+
+ await test.step('Verify scroll restored', async () => {
+ const restored = await getEditorScroll(page, POST_SELECTOR);
+ expectScrollRestored(restored, saved);
+ });
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Collection Settings
+ // -------------------------------------------------------------------------
+
+ test.describe('Collection Settings', () => {
+ test.beforeEach(async ({ page }) => {
+ await closeAllCollections(page);
+ });
+
+ test.afterAll(async ({ page }) => {
+ await closeAllCollections(page);
+ });
+
+ // Helper to open collection settings
+ const openCollectionSettings = async (page: Page, collName: string) => {
+ const locators = buildCommonLocators(page);
+ await locators.sidebar.collection(collName).hover();
+ await locators.actions.collectionActions(collName).click({ timeout: 2000 });
+ await locators.dropdown.item('Settings').click({ timeout: 2000 });
+ };
+
+ test('Collection Script - pre-request and post-response scroll persists', async ({ page, createTmpDir }) => {
+ const tmpDir = await createTmpDir('scroll-coll-script');
+ const locators = buildCommonLocators(page);
+ const PRE_SELECTOR = '[data-testid="collection-pre-request-script-editor"] .CodeMirror';
+ const POST_SELECTOR = '[data-testid="collection-post-response-script-editor"] .CodeMirror';
+
+ let preReqSaved: number;
+ let postResSaved: number;
+
+ // --- Pre-request ---
+
+ await test.step('Setup collection and add pre-request content', async () => {
+ await createCollection(page, 'scroll-coll-script', tmpDir);
+ await openCollectionSettings(page, 'scroll-coll-script');
+ await locators.paneTabs.collectionSettingsTab('script').click({ timeout: 2000 });
+ await page.getByTestId('tab-trigger-pre-request').click({ timeout: 2000 });
+ await setEditorContent(page, PRE_SELECTOR, generateLargeScript());
+ });
+
+ await test.step('Verify initial scroll is 0', async () => {
+ const initial = await getEditorScroll(page, PRE_SELECTOR);
+ expect(initial).toBe(0);
+ });
+
+ await test.step('Init pre-request hook: switch tabs', async () => {
+ await locators.paneTabs.collectionSettingsTab('headers').click({ timeout: 2000 });
+ await locators.paneTabs.collectionSettingsTab('script').click({ timeout: 2000 });
+ });
+
+ await test.step('Scroll pre-request editor', async () => {
+ await page.getByTestId('tab-trigger-pre-request').click({ timeout: 2000 });
+ await setEditorScroll(page, PRE_SELECTOR, 1500);
+ preReqSaved = await getEditorScroll(page, PRE_SELECTOR);
+ expectScrollRestored(preReqSaved, 1500);
+ });
+
+ await test.step('Verify pre-request: switch to headers and back', async () => {
+ await locators.paneTabs.collectionSettingsTab('headers').click({ timeout: 2000 });
+ await locators.paneTabs.collectionSettingsTab('script').click({ timeout: 2000 });
+ await page.getByTestId('tab-trigger-pre-request').click({ timeout: 2000 });
+ const restored = await getEditorScroll(page, PRE_SELECTOR);
+ expectScrollRestored(restored, preReqSaved);
+ });
+
+ // --- Post-response ---
+
+ await test.step('Switch to post-response and add content', async () => {
+ await page.getByTestId('tab-trigger-post-response').click({ timeout: 2000 });
+ await setEditorContent(page, POST_SELECTOR, generateLargeScript());
+ });
+
+ await test.step('Verify initial scroll is 0', async () => {
+ const initial = await getEditorScroll(page, POST_SELECTOR);
+ expect(initial).toBe(0);
+ });
+
+ await test.step('Init post-response hook: switch tabs', async () => {
+ await locators.paneTabs.collectionSettingsTab('headers').click({ timeout: 2000 });
+ await locators.paneTabs.collectionSettingsTab('script').click({ timeout: 2000 });
+ await page.getByTestId('tab-trigger-post-response').click({ timeout: 2000 });
+ });
+
+ await test.step('Scroll post-response editor', async () => {
+ await setEditorScroll(page, POST_SELECTOR, 1500);
+ postResSaved = await getEditorScroll(page, POST_SELECTOR);
+ expectScrollRestored(postResSaved, 1500);
+ });
+
+ await test.step('Verify post-response: switch to headers and back', async () => {
+ await locators.paneTabs.collectionSettingsTab('headers').click({ timeout: 2000 });
+ await locators.paneTabs.collectionSettingsTab('script').click({ timeout: 2000 });
+ await page.getByTestId('tab-trigger-post-response').click({ timeout: 2000 });
+ const restored = await getEditorScroll(page, POST_SELECTOR);
+ expectScrollRestored(restored, postResSaved);
+ });
+
+ // --- Final cross-check ---
+
+ await test.step('Verify pre-request still persisted', async () => {
+ await page.getByTestId('tab-trigger-pre-request').click({ timeout: 2000 });
+ const restored = await getEditorScroll(page, PRE_SELECTOR);
+ expectScrollRestored(restored, preReqSaved);
+ });
+
+ await test.step('Verify post-response still persisted', async () => {
+ await page.getByTestId('tab-trigger-post-response').click({ timeout: 2000 });
+ const restored = await getEditorScroll(page, POST_SELECTOR);
+ expectScrollRestored(restored, postResSaved);
+ });
+ });
+
+ test('Collection Tests - scroll persists across tab switches', async ({ page, createTmpDir }) => {
+ const tmpDir = await createTmpDir('scroll-coll-tests');
+ const locators = buildCommonLocators(page);
+
+ await test.step('Setup and add test content', async () => {
+ await createCollection(page, 'scroll-coll-tests', tmpDir);
+ await openCollectionSettings(page, 'scroll-coll-tests');
+ await locators.paneTabs.collectionSettingsTab('tests').click({ timeout: 2000 });
+ await setEditorContent(page, '.CodeMirror', generateLargeScript());
+ });
+
+ await test.step('Verify initial scroll is 0', async () => {
+ const initial = await getEditorScroll(page, '.CodeMirror');
+ expect(initial).toBe(0);
+ });
+
+ let saved: number;
+
+ await test.step('Init hook via tab switch, then scroll', async () => {
+ await locators.paneTabs.collectionSettingsTab('headers').click({ timeout: 2000 });
+ await locators.paneTabs.collectionSettingsTab('tests').click({ timeout: 2000 });
+ await setEditorScroll(page, '.CodeMirror', 1500);
+ });
+
+ await test.step('Capture scroll position', async () => {
+ saved = await getEditorScroll(page, '.CodeMirror');
+ expectScrollRestored(saved, 1500);
+ });
+
+ await test.step('Switch to headers and back', async () => {
+ await locators.paneTabs.collectionSettingsTab('headers').click({ timeout: 2000 });
+ await locators.paneTabs.collectionSettingsTab('tests').click({ timeout: 2000 });
+ });
+
+ await test.step('Verify scroll restored', async () => {
+ const restored = await getEditorScroll(page, '.CodeMirror');
+ expectScrollRestored(restored, saved);
+ });
+ });
+
+ test('Collection Docs - scroll persists in edit mode across tab switches', async ({ page, createTmpDir }) => {
+ const tmpDir = await createTmpDir('scroll-coll-docs');
+ const locators = buildCommonLocators(page);
+ const largeDocContent = Array.from({ length: 80 }, (_, i) => `## Section ${i + 1}\nLorem ipsum dolor sit amet for section ${i + 1}.`).join('\n\n');
+
+ await test.step('Setup and navigate to Docs tab', async () => {
+ await createCollection(page, 'scroll-coll-docs', tmpDir);
+ await openCollectionSettings(page, 'scroll-coll-docs');
+ await locators.paneTabs.collectionSettingsTab('overview').click({ timeout: 2000 });
+ });
+
+ await test.step('Click Edit and add large doc content', async () => {
+ // Collection docs has an edit icon button
+ const editBtn = page.locator('.editing-mode');
+ await editBtn.click({ timeout: 2000 });
+ await setEditorContent(page, '.CodeMirror', largeDocContent);
+ });
+
+ await test.step('Verify initial scroll is 0', async () => {
+ const initial = await getEditorScroll(page, '.CodeMirror');
+ expect(initial).toBe(0);
+ });
+
+ let saved: number;
+
+ await test.step('Init hook via tab switch, then scroll', async () => {
+ await locators.paneTabs.collectionSettingsTab('headers').click({ timeout: 2000 });
+ await locators.paneTabs.collectionSettingsTab('overview').click({ timeout: 2000 });
+ await setEditorScroll(page, '.CodeMirror', 1500);
+ });
+
+ await test.step('Capture scroll position', async () => {
+ saved = await getEditorScroll(page, '.CodeMirror');
+ expectScrollRestored(saved, 1500);
+ });
+
+ await test.step('Switch to headers and back to docs edit mode', async () => {
+ await locators.paneTabs.collectionSettingsTab('headers').click({ timeout: 2000 });
+ await locators.paneTabs.collectionSettingsTab('overview').click({ timeout: 2000 });
+ });
+
+ await test.step('Verify scroll restored', async () => {
+ const restored = await getEditorScroll(page, '.CodeMirror');
+ expectScrollRestored(restored, saved);
+ });
+ });
+
+ test('Collection Headers - scroll persists with many headers across tab switches', async ({ page, createTmpDir }) => {
+ const tmpDir = await createTmpDir('scroll-coll-headers');
+ const locators = buildCommonLocators(page);
+ const scrollContainer = '.collection-settings-content';
+ const firstVisibleRowLocator = () => page.getByTestId('editable-table').locator('table > tbody > tr:nth-child(2)');
+
+ await test.step('Setup and navigate to Headers tab', async () => {
+ await createCollection(page, 'scroll-coll-headers', tmpDir);
+ await openCollectionSettings(page, 'scroll-coll-headers');
+ await locators.paneTabs.collectionSettingsTab('headers').click({ timeout: 2000 });
+ });
+
+ await test.step('Add 100 headers via Bulk Edit', async () => {
+ const bulkEditBtn = page.getByTestId('bulk-edit-toggle');
+ await bulkEditBtn.scrollIntoViewIfNeeded();
+ await bulkEditBtn.click({ timeout: 2000 });
+
+ const bulkHeaders = Array.from({ length: 100 }, (_, i) =>
+ `X-Custom-Header-${i + 1}:value-${i + 1}`
+ ).join('\n');
+
+ const bulkEditor = page.locator('.CodeMirror').first();
+ await bulkEditor.evaluate((el, content) => {
+ const cm = (el as any).CodeMirror;
+ cm?.setValue(content);
+ }, bulkHeaders);
+
+ await page.getByTestId('key-value-edit-toggle').click({ timeout: 2000 });
+ });
+
+ await test.step('Verify initial scroll is 0', async () => {
+ const container = page.locator(scrollContainer).first();
+ const initial = await container.evaluate((el) => el.scrollTop);
+ expect(initial).toBe(0);
+ });
+
+ await test.step('Scroll to ~middle of table (~row 50)', async () => {
+ const container = page.locator(scrollContainer).first();
+ // Scroll halfway through the virtualised list so ~row 50 becomes the first visible row
+ await container.evaluate((el) => { el.scrollTop = el.scrollHeight / 2; });
+
+ // Auto-retry: wait for TableVirtuoso to land on a row in [45, 55]
+ // (matches the ~row 50 ± 5 range that expectRowNear asserts)
+ const element = firstVisibleRowLocator();
+ await expect(element).toHaveAttribute('data-index', /^(4[5-9]|5[0-5])$/, { timeout: 2000 });
+ });
+
+ await test.step('Switch to script tab and back to headers', async () => {
+ await locators.paneTabs.collectionSettingsTab('script').click({ timeout: 2000 });
+ await locators.paneTabs.collectionSettingsTab('headers').click({ timeout: 2000 });
+ const tableRow = page.getByRole('row', { name: 'Name Value' }).getByRole('cell').first();
+ await expect(tableRow).toBeVisible({ timeout: 2000 });
+ });
+
+ await test.step('Verify scroll restored to ~row 50', async () => {
+ const element = firstVisibleRowLocator();
+ const current = parseInt(await element.getAttribute('data-index') as string);
+ expectRowNear(current, 50);
+ });
+ });
+ });
+});
diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts
index 8347d64b0..5d4ade565 100644
--- a/tests/utils/page/actions.ts
+++ b/tests/utils/page/actions.ts
@@ -77,10 +77,10 @@ const createCollection = async (page, collectionName: string, collectionLocation
const createCollectionModal = page.locator('.bruno-modal-card').filter({ hasText: 'Create Collection' });
await createCollectionModal.waitFor({ state: 'visible', timeout: 5000 });
- await createCollectionModal.getByLabel('Name').fill(collectionName);
+ // Fill location FIRST — some modals auto-derive the name from the path,
+ // so filling name after location ensures it isn't overwritten.
const locationInput = createCollectionModal.getByLabel('Location');
if (await locationInput.isVisible()) {
- // Location input can be read-only; drop the attribute so fill can type
await locationInput.evaluate((el) => {
const input = el as HTMLInputElement;
input.removeAttribute('readonly');
@@ -88,10 +88,16 @@ const createCollection = async (page, collectionName: string, collectionLocation
});
await locationInput.fill(collectionLocation);
}
+ const nameInput = createCollectionModal.getByLabel('Name');
+ await nameInput.clear();
+ await nameInput.fill(collectionName);
+ // Verify the name is correct before creating
+ await expect(nameInput).toHaveValue(collectionName, { timeout: 2000 });
await createCollectionModal.getByRole('button', { name: 'Create', exact: true }).click();
await createCollectionModal.waitFor({ state: 'detached', timeout: 15000 });
- await page.waitForTimeout(200);
+ // Wait for the collection name to appear in the sidebar before proceeding
+ await page.locator('#sidebar-collection-name').filter({ hasText: collectionName }).waitFor({ state: 'visible', timeout: 5000 });
await openCollection(page, collectionName);
});
};