diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index caaeb6682..f86004ea5 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -335,6 +335,9 @@ const RequestTabPanel = () => { let example = null; if (item?.examples) { example = item.examples.find((ex) => ex.uid === focusedTab.uid); + if (!example && typeof focusedTab.exampleIndex === 'number' && focusedTab.exampleIndex >= 0) { + example = item.examples[focusedTab.exampleIndex] || null; + } if (!example && focusedTab.exampleName) { example = item.examples.find((ex) => ex.name === focusedTab.exampleName); } diff --git a/packages/bruno-app/src/components/RequestTabs/ExampleTab/index.js b/packages/bruno-app/src/components/RequestTabs/ExampleTab/index.js index fad3f9979..3b5e6445b 100644 --- a/packages/bruno-app/src/components/RequestTabs/ExampleTab/index.js +++ b/packages/bruno-app/src/components/RequestTabs/ExampleTab/index.js @@ -26,11 +26,15 @@ const ExampleTab = ({ tab, collection }) => { if (!item?.examples) return null; const byUid = item.examples.find((ex) => ex.uid === tab.uid); if (byUid) return byUid; + if (typeof tab.exampleIndex === 'number' && tab.exampleIndex >= 0) { + const byIndex = item.examples[tab.exampleIndex]; + if (byIndex) return byIndex; + } if (tab.exampleName) { return item.examples.find((ex) => ex.name === tab.exampleName); } return null; - }, [item?.examples, tab.uid, tab.exampleName]); + }, [item?.examples, tab.uid, tab.exampleIndex, tab.exampleName]); const hasChanges = useMemo(() => hasExampleChanges(item, example?.uid), [item, example?.uid]); diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/index.js index 996b2d35a..b1fea8b2d 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/index.js @@ -37,13 +37,16 @@ const ExampleItem = ({ example, item, collection }) => { const indents = range((item.depth || 0) + 1); const handleExampleClick = () => { + const exampleIndex = item?.examples?.findIndex((ex) => ex.uid === example.uid); + dispatch(addTab({ uid: example.uid, collectionUid: collection.uid, type: 'response-example', itemUid: item.uid, pathname: item.pathname, - exampleName: example.name + exampleName: example.name, + exampleIndex: typeof exampleIndex === 'number' && exampleIndex >= 0 ? exampleIndex : undefined })); }; diff --git a/packages/bruno-app/src/providers/ReduxStore/middlewares/tasks/middleware.js b/packages/bruno-app/src/providers/ReduxStore/middlewares/tasks/middleware.js index e9708fab1..034d31eba 100644 --- a/packages/bruno-app/src/providers/ReduxStore/middlewares/tasks/middleware.js +++ b/packages/bruno-app/src/providers/ReduxStore/middlewares/tasks/middleware.js @@ -82,7 +82,8 @@ taskMiddleware.startListening({ type: 'response-example', itemUid: item.uid, pathname: item.pathname, - exampleName: example.name + exampleName: example.name, + exampleIndex: task.exampleIndex })); } } diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js index a862affdf..c1b4aa7f9 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js @@ -20,7 +20,7 @@ const tabTypeAlreadyExists = (tabs, collectionUid, type) => { return find(tabs, (tab) => tab.collectionUid === collectionUid && tab.type === type); }; -const findTabByPathname = (tabs, { collectionUid, pathname, type, exampleName }) => { +const findTabByPathname = (tabs, { collectionUid, pathname, type, exampleName, exampleIndex }) => { if (!pathname || !collectionUid || !type) { return null; } @@ -35,6 +35,10 @@ const findTabByPathname = (tabs, { collectionUid, pathname, type, exampleName }) } if (type === 'response-example') { + if (typeof exampleIndex === 'number' && exampleIndex >= 0 && typeof tab.exampleIndex === 'number' && tab.exampleIndex >= 0) { + return tab.exampleIndex === exampleIndex; + } + return tab.exampleName === exampleName; } @@ -47,7 +51,7 @@ export const tabsSlice = createSlice({ initialState, reducers: { addTab: (state, action) => { - const { uid, collectionUid, type, requestPaneTab, preview, exampleUid, itemUid, pathname, exampleName, isTransient } = action.payload; + const { uid, collectionUid, type, requestPaneTab, preview, exampleUid, itemUid, pathname, exampleName, exampleIndex, isTransient } = action.payload; const nonReplaceableTabTypes = [ 'variables', @@ -67,7 +71,7 @@ export const tabsSlice = createSlice({ return; } - const existingPathnameTab = findTabByPathname(state.tabs, { collectionUid, pathname, type, exampleName }); + const existingPathnameTab = findTabByPathname(state.tabs, { collectionUid, pathname, type, exampleName, exampleIndex }); if (existingPathnameTab) { state.activeTabUid = existingPathnameTab.uid; return; @@ -114,6 +118,7 @@ export const tabsSlice = createSlice({ ...(exampleUid ? { exampleUid } : {}), ...(itemUid ? { itemUid } : {}), ...(exampleName ? { exampleName } : {}), + ...(typeof exampleIndex === 'number' ? { exampleIndex } : {}), ...(isTransient ? { isTransient: true } : {}) }; @@ -149,6 +154,7 @@ export const tabsSlice = createSlice({ ...(exampleUid ? { exampleUid } : {}), ...(itemUid ? { itemUid } : {}), ...(exampleName ? { exampleName } : {}), + ...(typeof exampleIndex === 'number' ? { exampleIndex } : {}), ...(isTransient ? { isTransient: true } : {}) }); state.activeTabUid = uid; diff --git a/packages/bruno-app/src/selectors/tab.js b/packages/bruno-app/src/selectors/tab.js index 0676966ac..55ff78308 100644 --- a/packages/bruno-app/src/selectors/tab.js +++ b/packages/bruno-app/src/selectors/tab.js @@ -12,7 +12,11 @@ export const getTabUidForItem = ({ itemUid, itemPathname, collectionUid }) => cr return null; } - const tabByPathname = tabs.find((tab) => tab.pathname === itemPathname && (!collectionUid || tab.collectionUid === collectionUid)); + const tabByPathname = tabs.find((tab) => ( + tab.type !== 'response-example' + && tab.pathname === itemPathname + && (!collectionUid || tab.collectionUid === collectionUid) + )); return tabByPathname?.uid || null; }); @@ -41,7 +45,7 @@ export const isTabForItemActive = ({ itemUid, itemPathname, collectionUid }) => return false; } - return activeTab.pathname === itemPathname; + return activeTab.type !== 'response-example' && activeTab.pathname === itemPathname; }); export const isTabForItemPresent = ({ itemUid, itemPathname, collectionUid }) => createSelector([ @@ -51,5 +55,5 @@ export const isTabForItemPresent = ({ itemUid, itemPathname, collectionUid }) => return false; } - return tab.uid === itemUid || (itemPathname && tab.pathname === itemPathname); + return tab.uid === itemUid || (itemPathname && tab.type !== 'response-example' && tab.pathname === itemPathname); })); diff --git a/packages/bruno-app/src/selectors/tab.spec.js b/packages/bruno-app/src/selectors/tab.spec.js new file mode 100644 index 000000000..cb642c55d --- /dev/null +++ b/packages/bruno-app/src/selectors/tab.spec.js @@ -0,0 +1,90 @@ +import { getTabUidForItem, isTabForItemActive, isTabForItemPresent } from './tab'; + +describe('tab selectors', () => { + const baseState = { + tabs: { + activeTabUid: null, + tabs: [] + } + }; + + it('does not resolve request tab uid from response-example pathname fallback', () => { + const state = { + ...baseState, + tabs: { + ...baseState.tabs, + tabs: [ + { + uid: 'example-1', + type: 'response-example', + pathname: '/c/req.bru', + collectionUid: 'c1' + } + ] + } + }; + + const selector = getTabUidForItem({ itemUid: 'request-1', itemPathname: '/c/req.bru', collectionUid: 'c1' }); + expect(selector(state)).toBeNull(); + }); + + it('does not mark request active when only response-example tab is active on same pathname', () => { + const state = { + ...baseState, + tabs: { + activeTabUid: 'example-1', + tabs: [ + { + uid: 'example-1', + type: 'response-example', + pathname: '/c/req.bru', + collectionUid: 'c1' + } + ] + } + }; + + const selector = isTabForItemActive({ itemUid: 'request-1', itemPathname: '/c/req.bru', collectionUid: 'c1' }); + expect(selector(state)).toBe(false); + }); + + it('does not mark request present when only response-example tab exists for same pathname', () => { + const state = { + ...baseState, + tabs: { + ...baseState.tabs, + tabs: [ + { + uid: 'example-1', + type: 'response-example', + pathname: '/c/req.bru', + collectionUid: 'c1' + } + ] + } + }; + + const selector = isTabForItemPresent({ itemUid: 'request-1', itemPathname: '/c/req.bru', collectionUid: 'c1' }); + expect(selector(state)).toBe(false); + }); + + it('still resolves regular request tab by pathname fallback', () => { + const state = { + ...baseState, + tabs: { + ...baseState.tabs, + tabs: [ + { + uid: 'request-1', + type: 'http-request', + pathname: '/c/req.bru', + collectionUid: 'c1' + } + ] + } + }; + + const selector = getTabUidForItem({ itemUid: 'missing-uid', itemPathname: '/c/req.bru', collectionUid: 'c1' }); + expect(selector(state)).toBe('request-1'); + }); +}); diff --git a/packages/bruno-app/src/utils/snapshot/index.js b/packages/bruno-app/src/utils/snapshot/index.js index e09405dde..9c7a17a2d 100644 --- a/packages/bruno-app/src/utils/snapshot/index.js +++ b/packages/bruno-app/src/utils/snapshot/index.js @@ -344,7 +344,7 @@ export const findCollectionEnvironmentFromSnapshot = (collection, snapshotData = }; const getAccessor = (tab) => { - if (tab.type === 'response-example') return 'pathname::exampleName'; + if (tab.type === 'response-example') return 'pathname::exampleIndex'; if (SINGLETON_TAB_TYPES.has(tab.type)) return 'type'; return 'pathname'; }; @@ -379,6 +379,23 @@ export const serializeTab = (tab, collection) => { const item = findItemInCollection(collection, tab.itemUid); serialized.pathname = item?.pathname || tab.pathname; serialized.exampleName = tab.exampleName; + const exampleIndex = item?.examples?.findIndex((example) => example.uid === tab.uid); + if (typeof exampleIndex === 'number' && exampleIndex >= 0) { + serialized.exampleIndex = exampleIndex; + } + serialized.exampleUid = tab.uid; + if (tab.name) { + serialized.name = tab.name; + } + } else if (accessor === 'pathname::exampleIndex') { + const item = findItemInCollection(collection, tab.itemUid); + serialized.pathname = item?.pathname || tab.pathname; + const exampleIndex = item?.examples?.findIndex((example) => example.uid === tab.uid); + if (typeof exampleIndex === 'number' && exampleIndex >= 0) { + serialized.exampleIndex = exampleIndex; + } + serialized.exampleName = tab.exampleName; + serialized.exampleUid = tab.uid; if (tab.name) { serialized.name = tab.name; } @@ -420,6 +437,22 @@ export const serializeActiveTab = (tab, collection) => { return { accessor, value: `${pathname}::${tab.exampleName}` }; } + if (accessor === 'pathname::exampleIndex') { + const item = findItemInCollection(collection, tab.itemUid); + const pathname = item?.pathname || tab.pathname; + const exampleIndex = item?.examples?.findIndex((example) => example.uid === tab.uid); + + if (typeof exampleIndex === 'number' && exampleIndex >= 0) { + return { accessor, value: `${pathname}::${exampleIndex}` }; + } + + if (tab.exampleName) { + return { accessor: 'pathname::exampleName', value: `${pathname}::${tab.exampleName}` }; + } + + return { accessor, value: `${pathname}::-1` }; + } + return { accessor: 'type', value: tab.type }; }; @@ -434,7 +467,7 @@ export const isActiveTab = (tab, activeTab, collection) => { if (accessor === 'pathname') { const item = findItemInCollection(collection, tab.uid); - return item?.pathname === value || tab.pathname === value; + return tab.type !== 'response-example' && (item?.pathname === value || tab.pathname === value); } if (accessor === 'pathname::exampleName') { @@ -443,11 +476,62 @@ export const isActiveTab = (tab, activeTab, collection) => { return `${pathname}::${tab.exampleName}` === value; } + if (accessor === 'pathname::exampleIndex') { + const item = findItemInCollection(collection, tab.itemUid); + const pathname = item?.pathname || tab.pathname; + const exampleIndex = item?.examples?.findIndex((example) => example.uid === tab.uid); + + return `${pathname}::${exampleIndex}` === value; + } + return false; }; +const resolveResponseExampleTabState = ({ item, pathname, exampleName, exampleIndex, exampleUid }) => { + const hasExamples = Array.isArray(item?.examples); + const hasProvidedExampleIndex = typeof exampleIndex === 'number' && exampleIndex >= 0; + const hasValidExampleIndex = hasExamples && hasProvidedExampleIndex && exampleIndex < item.examples.length; + + let resolvedExample = null; + if (hasExamples) { + if (hasValidExampleIndex) { + resolvedExample = item.examples[exampleIndex] || null; + } else { + if (typeof exampleUid === 'string' && exampleUid.length > 0) { + resolvedExample = item.examples.find((ex) => ex.uid === exampleUid) || null; + } + + if (!resolvedExample && exampleName) { + resolvedExample = item.examples.find((ex) => ex.name === exampleName) || null; + } + } + } + + const resolvedExampleIndex = hasExamples && resolvedExample?.uid + ? item.examples.findIndex((ex) => ex.uid === resolvedExample.uid) + : -1; + + const fallbackExampleIdentity = hasProvidedExampleIndex + ? `${pathname}::${exampleIndex}` + : `${pathname}::${exampleName}`; + + let normalizedExampleIndex = null; + if (resolvedExampleIndex >= 0) { + normalizedExampleIndex = resolvedExampleIndex; + } else if (hasProvidedExampleIndex) { + normalizedExampleIndex = exampleIndex; + } + + return { + uid: resolvedExample?.uid || fallbackExampleIdentity, + itemUid: item?.uid || pathname, + exampleName: resolvedExample?.name || exampleName, + exampleIndex: normalizedExampleIndex + }; +}; + export const deserializeTab = (snapshotTab, collection) => { - const { accessor, pathname, exampleName, type } = snapshotTab; + const { accessor, pathname, exampleName, exampleIndex, exampleUid, type } = snapshotTab; const restoredRequestPaneTab = typeof snapshotTab.request?.tab === 'string' ? snapshotTab.request.tab : null; const tab = { @@ -483,12 +567,13 @@ export const deserializeTab = (snapshotTab, collection) => { if (type === 'folder-settings') { tab.folderUid = item?.uid || pathname; } - } else if (accessor === 'pathname::exampleName' && pathname && exampleName) { + } else if ((accessor === 'pathname::exampleName' || accessor === 'pathname::exampleIndex') && pathname) { const item = findItemInCollectionByPathname(collection, pathname); - const example = item?.examples?.find((ex) => ex.name === exampleName); - tab.uid = example?.uid || `${pathname}::${exampleName}`; - tab.itemUid = item?.uid || pathname; - tab.exampleName = exampleName; + const resolvedTabState = resolveResponseExampleTabState({ item, pathname, exampleName, exampleIndex, exampleUid }); + tab.uid = resolvedTabState.uid; + tab.itemUid = resolvedTabState.itemUid; + tab.exampleName = resolvedTabState.exampleName; + tab.exampleIndex = resolvedTabState.exampleIndex; } else if (needsTypeBasedFallback) { const collectionUidFromSnapshot = typeof snapshotTab.collection === 'string' && snapshotTab.collection.length > 0 ? snapshotTab.collection @@ -573,9 +658,20 @@ export const getActiveTabFromSnapshot = async (collectionPathname, collection, s if (accessor === 'type') { snapshotTab = tabsSnapshot.tabs.find((t) => t.type === value); } else if (accessor === 'pathname') { - snapshotTab = tabsSnapshot.tabs.find((t) => t.pathname === value); + snapshotTab = tabsSnapshot.tabs.find((t) => t.pathname === value && t.type !== 'response-example'); } else if (accessor === 'pathname::exampleName') { snapshotTab = tabsSnapshot.tabs.find((t) => `${t.pathname}::${t.exampleName}` === value); + } else if (accessor === 'pathname::exampleIndex') { + snapshotTab = tabsSnapshot.tabs.find((t) => `${t.pathname}::${t.exampleIndex}` === value); + + if (!snapshotTab) { + const [pathname, rawIndex] = value.split('::'); + const exampleIndex = Number(rawIndex); + if (pathname && Number.isInteger(exampleIndex) && exampleIndex >= 0) { + const candidateTabs = tabsSnapshot.tabs.filter((t) => t.type === 'response-example' && t.pathname === pathname); + snapshotTab = candidateTabs[exampleIndex] || null; + } + } } if (!snapshotTab) return null; diff --git a/packages/bruno-app/src/utils/snapshot/index.spec.js b/packages/bruno-app/src/utils/snapshot/index.spec.js index 7a261764c..bf6b8508f 100644 --- a/packages/bruno-app/src/utils/snapshot/index.spec.js +++ b/packages/bruno-app/src/utils/snapshot/index.spec.js @@ -11,7 +11,13 @@ jest.mock('nanoid', () => { }; }); -const { deserializeTab, hydrateSnapshotLookups, hydrateCollectionTabs } = require('./index'); +const { + deserializeTab, + hydrateSnapshotLookups, + hydrateCollectionTabs, + isActiveTab, + getActiveTabFromSnapshot +} = require('./index'); describe('hydrateSnapshotLookups', () => { it('builds lookup maps from array-based snapshot schema', () => { @@ -280,6 +286,100 @@ describe('deserializeTab', () => { expect(tab.uid).toBe('collection-uid-preferences'); }); + it('restores response example by index when duplicate names exist', () => { + const collectionWithDuplicateExamples = { + uid: 'collection-uid', + pathname: '/collections/a', + items: [ + { + uid: 'request-1', + pathname: '/collections/a/request-1.bru', + examples: [ + { uid: 'example-1', name: 'dup' }, + { uid: 'example-2', name: 'dup' } + ] + } + ] + }; + + const snapshotTab = { + type: 'response-example', + accessor: 'pathname::exampleIndex', + pathname: '/collections/a/request-1.bru', + exampleName: 'dup', + exampleIndex: 1, + permanent: true + }; + + const tab = deserializeTab(snapshotTab, collectionWithDuplicateExamples); + expect(tab.uid).toBe('example-2'); + expect(tab.exampleName).toBe('dup'); + expect(tab.exampleIndex).toBe(1); + }); + + it('falls back to first matching name when example index is missing or invalid', () => { + const collectionWithDuplicateExamples = { + uid: 'collection-uid', + pathname: '/collections/a', + items: [ + { + uid: 'request-1', + pathname: '/collections/a/request-1.bru', + examples: [ + { uid: 'example-1', name: 'dup' }, + { uid: 'example-2', name: 'dup' } + ] + } + ] + }; + + const snapshotTab = { + type: 'response-example', + accessor: 'pathname::exampleIndex', + pathname: '/collections/a/request-1.bru', + exampleName: 'dup', + exampleIndex: 99, + permanent: true + }; + + const tab = deserializeTab(snapshotTab, collectionWithDuplicateExamples); + expect(tab.uid).toBe('example-1'); + expect(tab.exampleName).toBe('dup'); + expect(tab.exampleIndex).toBe(0); + }); + + it('keeps example uid and index consistent when uid fallback is used', () => { + const collectionWithDuplicateExamples = { + uid: 'collection-uid', + pathname: '/collections/a', + items: [ + { + uid: 'request-1', + pathname: '/collections/a/request-1.bru', + examples: [ + { uid: 'example-1', name: 'dup' }, + { uid: 'example-2', name: 'dup' } + ] + } + ] + }; + + const snapshotTab = { + type: 'response-example', + accessor: 'pathname::exampleIndex', + pathname: '/collections/a/request-1.bru', + exampleName: 'dup', + exampleUid: 'example-1', + exampleIndex: 99, + permanent: true + }; + + const tab = deserializeTab(snapshotTab, collectionWithDuplicateExamples); + expect(tab.uid).toBe('example-1'); + expect(tab.exampleName).toBe('dup'); + expect(tab.exampleIndex).toBe(0); + }); + it('defaults grpc request pane to body when snapshot request tab is missing', () => { const snapshotTab = { type: 'grpc-request', @@ -315,7 +415,6 @@ describe('deserializeTab', () => { } ] }; - const snapshotTab = { type: 'request', accessor: 'pathname', @@ -389,6 +488,96 @@ describe('deserializeTab', () => { }); }); +describe('active tab matching', () => { + it('does not mark response example tab as active for pathname accessor', () => { + const collection = { + uid: 'collection-uid', + pathname: '/collections/a', + items: [ + { + uid: 'request-1', + pathname: '/collections/a/request-1.bru', + examples: [{ uid: 'example-1', name: 'Sample' }] + } + ] + }; + + const tab = { + uid: 'example-1', + type: 'response-example', + itemUid: 'request-1', + pathname: '/collections/a/request-1.bru', + exampleName: 'Sample' + }; + + const activeTab = { + accessor: 'pathname', + value: '/collections/a/request-1.bru' + }; + + expect(isActiveTab(tab, activeTab, collection)).toBe(false); + }); +}); + +describe('getActiveTabFromSnapshot', () => { + beforeEach(() => { + global.window = global.window || {}; + global.window.ipcRenderer = { + invoke: jest.fn() + }; + }); + + afterEach(() => { + delete global.window.ipcRenderer; + }); + + it('resolves response example using index accessor when duplicate names exist', async () => { + const collection = { + uid: 'collection-uid', + pathname: '/collections/a', + items: [ + { + uid: 'request-1', + pathname: '/collections/a/request-1.bru', + examples: [ + { uid: 'example-1', name: 'dup' }, + { uid: 'example-2', name: 'dup' } + ] + } + ] + }; + + window.ipcRenderer.invoke.mockResolvedValue({ + activeTab: { + accessor: 'pathname::exampleIndex', + value: '/collections/a/request-1.bru::1' + }, + tabs: [ + { + type: 'response-example', + accessor: 'pathname::exampleIndex', + pathname: '/collections/a/request-1.bru', + exampleName: 'dup', + exampleIndex: 0, + permanent: true + }, + { + type: 'response-example', + accessor: 'pathname::exampleIndex', + pathname: '/collections/a/request-1.bru', + exampleName: 'dup', + exampleIndex: 1, + permanent: true + } + ] + }); + + const activeTab = await getActiveTabFromSnapshot('/collections/a', collection, null, null); + expect(activeTab.uid).toBe('example-2'); + expect(activeTab.exampleIndex).toBe(1); + }); +}); + describe('hydrateCollectionTabs', () => { beforeEach(() => { global.window = { diff --git a/packages/bruno-electron/src/services/snapshot/index.js b/packages/bruno-electron/src/services/snapshot/index.js index 9f305bba5..9e0e51c0d 100644 --- a/packages/bruno-electron/src/services/snapshot/index.js +++ b/packages/bruno-electron/src/services/snapshot/index.js @@ -28,11 +28,13 @@ const buildWorkspaceCollectionLookupKey = (workspacePathname, collectionPathname const tabSchema = yup.object({ type: yup.string().required(), - accessor: yup.string().oneOf(['pathname', 'pathname::exampleName', 'type']).required(), + accessor: yup.string().oneOf(['pathname', 'pathname::exampleName', 'pathname::exampleIndex', 'type']).required(), pathname: yup.string().nullable(), permanent: yup.boolean().required(), name: yup.string().optional(), exampleName: yup.string().optional(), + exampleIndex: yup.number().integer().min(0).optional(), + exampleUid: yup.string().optional(), request: yup.object({ tab: yup.string(), width: yup.number().nullable(), @@ -46,7 +48,7 @@ const tabSchema = yup.object({ }); const activeTabSchema = yup.object({ - accessor: yup.string().oneOf(['pathname', 'pathname::exampleName', 'type']).required(), + accessor: yup.string().oneOf(['pathname', 'pathname::exampleName', 'pathname::exampleIndex', 'type']).required(), value: yup.string().required() }); @@ -500,7 +502,7 @@ class SnapshotManager { return null; } - if (!['pathname', 'pathname::exampleName', 'type'].includes(activeTab.accessor) || typeof activeTab.value !== 'string') { + if (!['pathname', 'pathname::exampleName', 'pathname::exampleIndex', 'type'].includes(activeTab.accessor) || typeof activeTab.value !== 'string') { return null; } diff --git a/tests/snapshots/sidebar-state.spec.ts b/tests/snapshots/sidebar-state.spec.ts index 99d39f79e..bb7077a10 100644 --- a/tests/snapshots/sidebar-state.spec.ts +++ b/tests/snapshots/sidebar-state.spec.ts @@ -1,7 +1,9 @@ import { test, expect, closeElectronApp, type Page } from '../../playwright'; import { createCollection, + createExampleFromSidebar, createRequest, + openExampleFromSidebar, openRequest } from '../utils/page'; import { buildCommonLocators } from '../utils/page/locators'; @@ -77,4 +79,117 @@ test.describe('Snapshot: Sidebar-Tab Restoration', () => { await closeElectronApp(app2); }); }); + + test('when request and example are open, last active request restores as active after restart', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('snap-sidebar-request-active-over-example'); + const colPath = await createTmpDir('col'); + + const app = await launchElectronApp({ userDataPath }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Create request, create example, then make request last active', async () => { + await createCollection(page, 'TestCol', colPath); + await createRequest(page, 'ReqAlpha', 'TestCol', { url: 'https://echo.usebruno.com', method: 'GET' }); + await openRequest(page, 'TestCol', 'ReqAlpha', { persist: true }); + + await createExampleFromSidebar(page, 'ReqAlpha', 'Example One'); + await expect(page.getByTestId('response-example-title')).toHaveText('ReqAlpha / Example One'); + + await openRequest(page, 'TestCol', 'ReqAlpha', { persist: true }); + + const locators = buildCommonLocators(page); + await expect(locators.tabs.activeRequestTab()).toContainText('ReqAlpha'); + }); + + await test.step('Close and restart app', async () => { + await page.waitForTimeout(2000); + await closeElectronApp(app); + }); + + await test.step('Verify request restores as active and request click does not focus example', async () => { + const app2 = await launchElectronApp({ userDataPath }); + const page2 = await app2.firstWindow(); + await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + const locators = buildCommonLocators(page2); + await expect(locators.tabs.activeRequestTab()).toContainText('ReqAlpha', { timeout: 15000 }); + + await openRequest(page2, 'TestCol', 'ReqAlpha', { persist: true }); + await expect(locators.tabs.requestTab('ReqAlpha')).toHaveCount(1); + await expect(page2.getByTestId('response-example-title')).not.toBeVisible(); + + await closeElectronApp(app2); + }); + }); + + test('when last active tab is an example, it restores as active after restart', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('snap-sidebar-example-active-restore'); + const colPath = await createTmpDir('col'); + + const app = await launchElectronApp({ userDataPath }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Create request and example, then make example active', async () => { + await createCollection(page, 'TestCol', colPath); + await createRequest(page, 'ReqAlpha', 'TestCol', { url: 'https://echo.usebruno.com', method: 'GET' }); + await openRequest(page, 'TestCol', 'ReqAlpha', { persist: true }); + + await createExampleFromSidebar(page, 'ReqAlpha', 'Example One'); + await openExampleFromSidebar(page, 'ReqAlpha', 'Example One'); + await expect(page.getByTestId('response-example-title')).toHaveText('ReqAlpha / Example One'); + }); + + await test.step('Close and restart app', async () => { + await page.waitForTimeout(2000); + await closeElectronApp(app); + }); + + await test.step('Verify example restores as active', async () => { + const app2 = await launchElectronApp({ userDataPath }); + const page2 = await app2.firstWindow(); + await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await expect(page2.getByTestId('response-example-title')).toHaveText('ReqAlpha / Example One', { timeout: 15000 }); + + await closeElectronApp(app2); + }); + }); + + test('when duplicate example names exist, snapshot restores the same active example by index', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('snap-sidebar-duplicate-example-restore'); + const colPath = await createTmpDir('col'); + + const app = await launchElectronApp({ userDataPath }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Create request and two examples with duplicate names, then activate second', async () => { + await createCollection(page, 'TestCol', colPath); + await createRequest(page, 'ReqAlpha', 'TestCol', { url: 'https://echo.usebruno.com', method: 'GET' }); + await openRequest(page, 'TestCol', 'ReqAlpha', { persist: true }); + + await createExampleFromSidebar(page, 'ReqAlpha', 'DupExample', 'first-desc'); + await createExampleFromSidebar(page, 'ReqAlpha', 'DupExample', 'second-desc'); + await openExampleFromSidebar(page, 'ReqAlpha', 'DupExample', 1); + await expect(page.getByTestId('response-example-description')).toHaveText('second-desc'); + }); + + await test.step('Close and restart app', async () => { + await page.waitForTimeout(2000); + await closeElectronApp(app); + }); + + await test.step('Verify second duplicate example restores as active', async () => { + const app2 = await launchElectronApp({ userDataPath }); + const page2 = await app2.firstWindow(); + await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await expect(page2.getByTestId('response-example-title')).toHaveText('ReqAlpha / DupExample', { timeout: 15000 }); + await expect(page2.getByTestId('response-example-description')).toHaveText('second-desc'); + + await closeElectronApp(app2); + }); + }); }); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index 822ddc75c..c926bb91c 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -1238,6 +1238,41 @@ const sendAndWaitForResponse = async (page: Page) => { }); }; +const createExampleFromSidebar = async (page: Page, requestName: string, exampleName: string, description: string = '') => { + const requestRow = page.locator('.collection-item-name').filter({ hasText: requestName }).first(); + + await requestRow.hover(); + await requestRow.locator('..').locator('.menu-icon').click({ force: true }); + await page.locator('.dropdown-item').filter({ hasText: 'Create Example' }).click(); + + const exampleInput = page.getByTestId('create-example-name-input'); + await expect(exampleInput).toBeVisible(); + await exampleInput.clear(); + await exampleInput.fill(exampleName); + const descriptionInput = page.getByTestId('create-example-description-input'); + await descriptionInput.clear(); + await descriptionInput.fill(description); + await page.getByRole('button', { name: 'Create Example' }).click(); + await expect(page.locator('text=Create Response Example')).not.toBeAttached(); +}; + +const openExampleFromSidebar = async (page: Page, requestName: string, exampleName: string, index: number = 0) => { + const requestRow = page.locator('.collection-item-name').filter({ hasText: requestName }).first(); + const requestBranch = requestRow.locator('..'); + const exampleRow = requestBranch + .locator('.collection-item-name') + .filter({ has: page.locator('.example-icon') }) + .getByText(exampleName, { exact: true }) + .nth(index); + + if (!(await exampleRow.isVisible())) { + await requestRow.getByTestId('request-item-chevron').click(); + } + + await expect(exampleRow).toBeVisible(); + await exampleRow.click(); +}; + export { closeAllCollections, openCollection, @@ -1283,7 +1318,9 @@ export { addPostResponseScript, addTestScript, sendAndWaitForErrorCard, - sendAndWaitForResponse + sendAndWaitForResponse, + createExampleFromSidebar, + openExampleFromSidebar }; export type { SandboxMode, EnvironmentType, EnvironmentVariable, ImportCollectionOptions, CreateRequestOptions, CreateUntitledRequestOptions, CreateTransientRequestOptions, AssertionInput };