From 2c9dc9dcf8a429d2b330191e4fdd6eace671fa3d Mon Sep 17 00:00:00 2001 From: Sid Date: Wed, 13 May 2026 19:05:34 +0530 Subject: [PATCH] fix: correct the request type tabs in the snapshot (#7994) --- .../OpenAPISyncTab/hooks/useOpenAPISync.js | 2 +- .../middlewares/tasks/middleware.js | 2 + .../bruno-app/src/utils/snapshot/index.js | 20 +- .../src/utils/snapshot/index.spec.js | 108 ++++++++++ .../request-pane-interactivity.spec.ts | 198 ++++++++++++++++++ tests/utils/page/actions.ts | 90 +++++--- 6 files changed, 393 insertions(+), 27 deletions(-) diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js index 86779b229..082085246 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js @@ -93,7 +93,7 @@ const useOpenAPISync = (collection) => { uid: itemUid, collectionUid: collection.uid, requestPaneTab: item ? getDefaultRequestPaneTab(item) : undefined, - type: 'request' + type: item?.type ?? 'request' })); } }; 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 ac8c41fe1..e9708fab1 100644 --- a/packages/bruno-app/src/providers/ReduxStore/middlewares/tasks/middleware.js +++ b/packages/bruno-app/src/providers/ReduxStore/middlewares/tasks/middleware.js @@ -34,6 +34,8 @@ taskMiddleware.startListening({ addTab({ uid: item.uid, collectionUid: collection.uid, + type: item.type, + pathname: item.pathname, requestPaneTab: getDefaultRequestPaneTab(item), preview: task?.preview ?? true, ...(item.isTransient ? { isTransient: true } : {}) diff --git a/packages/bruno-app/src/utils/snapshot/index.js b/packages/bruno-app/src/utils/snapshot/index.js index 10d77c8ee..e09405dde 100644 --- a/packages/bruno-app/src/utils/snapshot/index.js +++ b/packages/bruno-app/src/utils/snapshot/index.js @@ -349,6 +349,18 @@ const getAccessor = (tab) => { return 'pathname'; }; +const getDefaultRequestPaneTabForType = (type) => { + if (type === 'grpc-request' || type === 'ws-request') { + return 'body'; + } + + if (type === 'graphql-request') { + return 'query'; + } + + return 'params'; +}; + export const serializeTab = (tab, collection) => { const accessor = getAccessor(tab); const serialized = { @@ -436,6 +448,7 @@ export const isActiveTab = (tab, activeTab, collection) => { export const deserializeTab = (snapshotTab, collection) => { const { accessor, pathname, exampleName, type } = snapshotTab; + const restoredRequestPaneTab = typeof snapshotTab.request?.tab === 'string' ? snapshotTab.request.tab : null; const tab = { collectionUid: collection.uid, @@ -443,7 +456,7 @@ export const deserializeTab = (snapshotTab, collection) => { preview: !snapshotTab.permanent, name: snapshotTab.name || null, pathname: pathname || null, - requestPaneTab: snapshotTab.request?.tab || 'params', + requestPaneTab: restoredRequestPaneTab || getDefaultRequestPaneTabForType(type), requestPaneWidth: snapshotTab.request?.width || null, requestPaneHeight: snapshotTab.request?.height || null, responsePaneTab: snapshotTab.response?.tab || 'response', @@ -461,6 +474,11 @@ export const deserializeTab = (snapshotTab, collection) => { if (accessor === 'pathname' && pathname) { const item = findItemInCollectionByPathname(collection, pathname); + const resolvedType = item?.type || type; + tab.type = resolvedType; + if (!restoredRequestPaneTab) { + tab.requestPaneTab = getDefaultRequestPaneTabForType(resolvedType); + } tab.uid = item?.uid || pathname; if (type === 'folder-settings') { tab.folderUid = item?.uid || pathname; diff --git a/packages/bruno-app/src/utils/snapshot/index.spec.js b/packages/bruno-app/src/utils/snapshot/index.spec.js index 7da307776..7a261764c 100644 --- a/packages/bruno-app/src/utils/snapshot/index.spec.js +++ b/packages/bruno-app/src/utils/snapshot/index.spec.js @@ -279,6 +279,114 @@ describe('deserializeTab', () => { const tab = deserializeTab(snapshotTab, collection); expect(tab.uid).toBe('collection-uid-preferences'); }); + + it('defaults grpc request pane to body when snapshot request tab is missing', () => { + const snapshotTab = { + type: 'grpc-request', + accessor: 'pathname', + pathname: '/collections/a/grpc-request.bru', + permanent: true + }; + + const tab = deserializeTab(snapshotTab, collection); + expect(tab.requestPaneTab).toBe('body'); + }); + + it('defaults websocket request pane to body when snapshot request tab is missing', () => { + const snapshotTab = { + type: 'ws-request', + accessor: 'pathname', + pathname: '/collections/a/ws-request.bru', + permanent: true + }; + + const tab = deserializeTab(snapshotTab, collection); + expect(tab.requestPaneTab).toBe('body'); + }); + + it('resolves generic request snapshot type to item type using pathname', () => { + const collectionWithGrpcItem = { + ...collection, + items: [ + { + uid: 'grpc-item-1', + pathname: '/collections/a/grpc-item.bru', + type: 'grpc-request' + } + ] + }; + + const snapshotTab = { + type: 'request', + accessor: 'pathname', + pathname: '/collections/a/grpc-item.bru', + permanent: true + }; + + const tab = deserializeTab(snapshotTab, collectionWithGrpcItem); + expect(tab.type).toBe('grpc-request'); + expect(tab.requestPaneTab).toBe('body'); + }); + + it('defaults to body for resolved websocket item type when generic snapshot request tab is missing', () => { + const collectionWithWsItem = { + ...collection, + items: [ + { + uid: 'ws-item-1', + pathname: '/collections/a/ws-item.bru', + type: 'ws-request' + } + ] + }; + + const snapshotTab = { + type: 'request', + accessor: 'pathname', + pathname: '/collections/a/ws-item.bru', + permanent: true + }; + + const tab = deserializeTab(snapshotTab, collectionWithWsItem); + expect(tab.type).toBe('ws-request'); + expect(tab.requestPaneTab).toBe('body'); + }); + + it('defaults graphql request pane to query when snapshot request tab is missing', () => { + const snapshotTab = { + type: 'graphql-request', + accessor: 'pathname', + pathname: '/collections/a/graphql-request.bru', + permanent: true + }; + + const tab = deserializeTab(snapshotTab, collection); + expect(tab.requestPaneTab).toBe('query'); + }); + + it('resolves generic request snapshot type to graphql-request item type using pathname', () => { + const collectionWithGraphqlItem = { + ...collection, + items: [ + { + uid: 'graphql-item-1', + pathname: '/collections/a/graphql-item.bru', + type: 'graphql-request' + } + ] + }; + + const snapshotTab = { + type: 'request', + accessor: 'pathname', + pathname: '/collections/a/graphql-item.bru', + permanent: true + }; + + const tab = deserializeTab(snapshotTab, collectionWithGraphqlItem); + expect(tab.type).toBe('graphql-request'); + expect(tab.requestPaneTab).toBe('query'); + }); }); describe('hydrateCollectionTabs', () => { diff --git a/tests/snapshots/request-pane-interactivity.spec.ts b/tests/snapshots/request-pane-interactivity.spec.ts index 0a41f02a9..60c00e24d 100644 --- a/tests/snapshots/request-pane-interactivity.spec.ts +++ b/tests/snapshots/request-pane-interactivity.spec.ts @@ -1,3 +1,5 @@ +import path from 'path'; +import fs from 'fs'; import { test, expect, closeElectronApp } from '../../playwright'; import { createCollection, @@ -6,6 +8,34 @@ import { } from '../utils/page'; import { buildCommonLocators } from '../utils/page/locators'; +const readSnapshot = (userDataPath: string) => { + const snapshotPath = path.join(userDataPath, 'ui-state-snapshot.json'); + if (!fs.existsSync(snapshotPath)) return null; + return JSON.parse(fs.readFileSync(snapshotPath, 'utf-8')); +}; + +const findSnapshotRequestTab = (snapshot: any, requestName: string) => { + if (!snapshot || !Array.isArray(snapshot.collections)) { + return null; + } + + for (const collection of snapshot.collections) { + if (!Array.isArray(collection?.tabs)) continue; + + const tab = collection.tabs.find((candidate) => ( + candidate?.accessor === 'pathname' + && typeof candidate?.pathname === 'string' + && candidate.pathname.includes(requestName) + )); + + if (tab) { + return tab; + } + } + + return null; +}; + test.describe('Snapshot: Request Pane Interactivity', () => { test('grpc request pane tab interactivity is restored after restart', async ({ launchElectronApp, createTmpDir }) => { const userDataPath = await createTmpDir('snap-grpc-interactivity'); @@ -105,4 +135,172 @@ test.describe('Snapshot: Request Pane Interactivity', () => { await closeElectronApp(app2); }); }); + + test('grpc snapshot stores concrete type and body tab key', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('snap-grpc-snapshot-type-tab-key'); + 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 collection and gRPC request', async () => { + await createCollection(page, 'TestCol', colPath); + + const locators = buildCommonLocators(page); + await locators.sidebar.collection('TestCol').hover(); + await locators.actions.collectionActions('TestCol').click(); + await locators.dropdown.item('New Request').click(); + + await page.getByTestId('grpc-request').click(); + await page.getByTestId('request-name').fill('ReqGrpcSnapshot'); + await page.getByTestId('new-request-url').locator('.CodeMirror').click(); + await page.keyboard.type('grpc://localhost:50051'); + await locators.modal.button('Create').click(); + + await openRequest(page, 'TestCol', 'ReqGrpcSnapshot', { persist: true }); + await selectRequestPaneTab(page, 'Message'); + }); + + await test.step('Close app and verify snapshot stores grpc-request/body', async () => { + await page.waitForTimeout(2000); + await closeElectronApp(app); + + const snapshotPath = path.join(userDataPath, 'ui-state-snapshot.json'); + await expect.poll(() => fs.existsSync(snapshotPath)).toBe(true); + + const snapshot = readSnapshot(userDataPath); + const tab = findSnapshotRequestTab(snapshot, 'ReqGrpcSnapshot'); + expect(tab).toBeTruthy(); + expect(tab.type).toBe('grpc-request'); + expect(tab.request?.tab).toBe('body'); + }); + + await test.step('Verify restore opens Message tab and avoids 404', 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.requestTab('ReqGrpcSnapshot')).toBeVisible({ timeout: 15000 }); + await locators.tabs.requestTab('ReqGrpcSnapshot').click({ force: true }); + + await expect(page2.getByTestId('responsive-tab-body')).toHaveAttribute('aria-selected', 'true'); + await expect(page2.locator('text=404 | Not found')).not.toBeVisible(); + + await closeElectronApp(app2); + }); + }); + + test('websocket snapshot stores concrete type and body tab key', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('snap-ws-snapshot-type-tab-key'); + 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 collection and WebSocket request', async () => { + await createCollection(page, 'TestCol', colPath); + + const locators = buildCommonLocators(page); + await locators.sidebar.collection('TestCol').hover(); + await locators.actions.collectionActions('TestCol').click(); + await locators.dropdown.item('New Request').click(); + + await page.getByTestId('ws-request').click(); + await page.getByTestId('request-name').fill('ReqWsSnapshot'); + await page.getByTestId('new-request-url').locator('.CodeMirror').click(); + await page.keyboard.type('ws://localhost:8080'); + await locators.modal.button('Create').click(); + + await openRequest(page, 'TestCol', 'ReqWsSnapshot', { persist: true }); + await selectRequestPaneTab(page, 'Message'); + }); + + await test.step('Close app and verify snapshot stores ws-request/body', async () => { + await page.waitForTimeout(2000); + await closeElectronApp(app); + + const snapshotPath = path.join(userDataPath, 'ui-state-snapshot.json'); + await expect.poll(() => fs.existsSync(snapshotPath)).toBe(true); + + const snapshot = readSnapshot(userDataPath); + const tab = findSnapshotRequestTab(snapshot, 'ReqWsSnapshot'); + expect(tab).toBeTruthy(); + expect(tab.type).toBe('ws-request'); + expect(tab.request?.tab).toBe('body'); + }); + + await test.step('Verify restore opens Message tab and avoids 404', 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.requestTab('ReqWsSnapshot')).toBeVisible({ timeout: 15000 }); + await locators.tabs.requestTab('ReqWsSnapshot').click({ force: true }); + + await expect(page2.getByTestId('responsive-tab-body')).toHaveAttribute('aria-selected', 'true'); + await expect(page2.locator('text=404 | Not found')).not.toBeVisible(); + + await closeElectronApp(app2); + }); + }); + + test('graphql snapshot stores concrete type and query tab key', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('snap-graphql-snapshot-type-tab-key'); + 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 collection and GraphQL request', async () => { + await createCollection(page, 'TestCol', colPath); + + const locators = buildCommonLocators(page); + await locators.sidebar.collection('TestCol').hover(); + await locators.actions.collectionActions('TestCol').click(); + await locators.dropdown.item('New Request').click(); + + await page.getByTestId('graphql-request').click(); + await page.getByTestId('request-name').fill('ReqGraphSnapshot'); + await page.getByTestId('new-request-url').locator('.CodeMirror').click(); + await page.keyboard.type('https://echo.usebruno.com/graphql'); + await locators.modal.button('Create').click(); + + await openRequest(page, 'TestCol', 'ReqGraphSnapshot', { persist: true }); + await selectRequestPaneTab(page, 'Headers'); + }); + + await test.step('Close app and verify snapshot stores graphql-request/headers', async () => { + await page.waitForTimeout(2000); + await closeElectronApp(app); + + const snapshotPath = path.join(userDataPath, 'ui-state-snapshot.json'); + await expect.poll(() => fs.existsSync(snapshotPath)).toBe(true); + + const snapshot = readSnapshot(userDataPath); + const tab = findSnapshotRequestTab(snapshot, 'ReqGraphSnapshot'); + expect(tab).toBeTruthy(); + expect(tab.type).toBe('graphql-request'); + expect(tab.request?.tab).toBe('headers'); + }); + + await test.step('Verify restore opens Headers tab and avoids 404', 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.requestTab('ReqGraphSnapshot')).toBeVisible({ timeout: 15000 }); + await locators.tabs.requestTab('ReqGraphSnapshot').click({ force: true }); + + await expect(page2.getByTestId('responsive-tab-headers')).toHaveAttribute('aria-selected', 'true'); + await expect(page2.locator('text=404 | Not found')).not.toBeVisible(); + + await closeElectronApp(app2); + }); + }); }); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index 5d4ade565..822ddc75c 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -812,37 +812,77 @@ const getResponseBody = async (page: Page): Promise => { return await page.locator('.response-pane').innerText(); }; +const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const trySelectPaneTabOnce = async (page: Page, paneSelector: string, tabName: string) => { + const pane = page.locator(paneSelector); + const visibleTab = pane.locator('.tabs').getByRole('tab', { name: tabName }); + + if (await visibleTab.isVisible().catch(() => false)) { + try { + await visibleTab.click({ timeout: 2000 }); + await expect(visibleTab).toContainClass('active', { timeout: 500 }); + return true; + } catch { + return false; + } + } + + const overflowButton = pane.locator('.tabs .more-tabs'); + if (!(await overflowButton.isVisible().catch(() => false))) { + return false; + } + + try { + await overflowButton.click({ force: true, timeout: 1000 }); + } catch { + return false; + } + + const dropdownItem = page + .getByRole('menuitem', { name: new RegExp(escapeRegExp(tabName), 'i') }) + .first(); + + if (await dropdownItem.isVisible({ timeout: 1500 }).catch(() => false)) { + try { + await dropdownItem.click({ force: true, timeout: 2000 }); + await expect(visibleTab).toContainClass('active', { timeout: 500 }); + return true; + } catch { + return false; + } + } + + const fallbackDropdownItem = page.locator('.tippy-box .dropdown-item').filter({ hasText: tabName }).first(); + if (await fallbackDropdownItem.isVisible({ timeout: 1500 }).catch(() => false)) { + try { + await fallbackDropdownItem.click({ force: true, timeout: 2000 }); + await expect(visibleTab).toContainClass('active', { timeout: 500 }); + return true; + } catch { + return false; + } + } + + return false; +}; + const selectPaneTab = async (page: Page, paneSelector: string, tabName: string) => { await test.step(`Select tab "${tabName}" in ${paneSelector}`, async () => { const pane = page.locator(paneSelector); await expect(pane).toBeVisible(); await expect(pane.locator('.tabs')).toBeVisible(); - const visibleTab = pane.locator('.tabs').getByRole('tab', { name: tabName }); - - // Check if tab is directly visible - if (await visibleTab.isVisible()) { - await visibleTab.click(); - await expect(visibleTab).toContainClass('active'); - return; - } - - const overflowButton = pane.locator('.tabs .more-tabs'); - // Check if there's an overflow dropdown - if (await overflowButton.isVisible()) { - await overflowButton.click(); - - // Wait for dropdown to appear and click the menu item - const dropdownItem = page.locator('.tippy-box .dropdown-item').filter({ hasText: tabName }); - await dropdownItem.waitFor({ state: 'visible' }); - - await page.waitForTimeout(50); - await dropdownItem.click({ force: true }); - await expect(visibleTab).toContainClass('active'); - return; - } - - throw new Error(`Tab "${tabName}" not found in visible tabs or overflow dropdown`); + await expect + .poll( + async () => trySelectPaneTabOnce(page, paneSelector, tabName), + { + message: `Tab "${tabName}" not found in visible tabs or overflow dropdown`, + timeout: 8000, + intervals: [100, 150, 200, 250] + } + ) + .toBe(true); }); };