From 9df06e152a9274222a4ab602f3736415a53fa436 Mon Sep 17 00:00:00 2001 From: Sid Date: Wed, 13 May 2026 18:48:57 +0530 Subject: [PATCH] fix: add tab error boundary (#7987) --- .../RequestTabPanel/TabPanelErrorBoundary.js | 79 +++++++++++++++++++ packages/bruno-app/src/pages/Bruno/index.js | 5 +- .../src/providers/ReduxStore/slices/tabs.js | 8 +- .../collections/invalid-tags-bru/bruno.json | 5 ++ .../invalid-tags-bru/collection.bru | 3 + .../invalid-tags-bru-request.bru | 12 +++ .../invalid-tags-yml/control-yml-request.yml | 13 +++ .../invalid-tags-yml-request.yml | 15 ++++ .../invalid-tags-yml/opencollection.yml | 6 ++ .../valid-tags-yml-request.yml | 19 +++++ .../init-user-data/preferences.json | 12 +++ .../tab-panel-error-boundary.spec.ts | 75 ++++++++++++++++++ 12 files changed, 247 insertions(+), 5 deletions(-) create mode 100644 packages/bruno-app/src/components/RequestTabPanel/TabPanelErrorBoundary.js create mode 100644 tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-bru/bruno.json create mode 100644 tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-bru/collection.bru create mode 100644 tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-bru/invalid-tags-bru-request.bru create mode 100644 tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-yml/control-yml-request.yml create mode 100644 tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-yml/invalid-tags-yml-request.yml create mode 100644 tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-yml/opencollection.yml create mode 100644 tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-yml/valid-tags-yml-request.yml create mode 100644 tests/request/tab-panel-error-boundary/init-user-data/preferences.json create mode 100644 tests/request/tab-panel-error-boundary/tab-panel-error-boundary.spec.ts diff --git a/packages/bruno-app/src/components/RequestTabPanel/TabPanelErrorBoundary.js b/packages/bruno-app/src/components/RequestTabPanel/TabPanelErrorBoundary.js new file mode 100644 index 000000000..c5e512300 --- /dev/null +++ b/packages/bruno-app/src/components/RequestTabPanel/TabPanelErrorBoundary.js @@ -0,0 +1,79 @@ +import React from 'react'; +import { IconAlertTriangle } from '@tabler/icons'; +import { useDispatch, useSelector } from 'react-redux'; +import find from 'lodash/find'; +import { closeTabs } from 'providers/ReduxStore/slices/collections/actions'; +import { NON_CLOSABLE_TAB_TYPES } from 'providers/ReduxStore/slices/tabs'; +import Button from 'ui/Button'; +import { useTheme } from 'providers/Theme'; + +class TabPanelErrorBoundaryInner extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + + componentDidCatch(error, errorInfo) { + console.error('[TabPanelErrorBoundary] Unexpected render error:', error, errorInfo); + } + + render() { + const { theme } = this.props; + + if (this.state.hasError) { + const { isClosable, onClose } = this.props; + const errorMessage = this.state.error?.message; + + return ( +
+ +

Something went wrong

+ {isClosable ? ( +

+ This tab encountered an unexpected error. Close it and try reopening the request. If the + error repeats, the request file may be corrupt. +

+ ) : ( +

+ This panel encountered an unexpected error. Restart Bruno to recover. +

+ )} + {errorMessage && ( +

{errorMessage}

+ )} + {isClosable && ( + + )} +
+ ); + } + + return this.props.children; + } +} + +const TabPanelErrorBoundary = ({ tabUid, children }) => { + const dispatch = useDispatch(); + const tabs = useSelector((state) => state.tabs.tabs); + const focusedTab = find(tabs, (t) => t.uid === tabUid); + const isClosable = !focusedTab || !NON_CLOSABLE_TAB_TYPES.includes(focusedTab.type); + const { theme } = useTheme(); + + const handleClose = () => { + dispatch(closeTabs({ tabUids: [tabUid] })); + }; + + return ( + + {children} + + ); +}; + +export default TabPanelErrorBoundary; diff --git a/packages/bruno-app/src/pages/Bruno/index.js b/packages/bruno-app/src/pages/Bruno/index.js index 35275bb29..8c637f88f 100644 --- a/packages/bruno-app/src/pages/Bruno/index.js +++ b/packages/bruno-app/src/pages/Bruno/index.js @@ -7,6 +7,7 @@ import Sidebar from 'components/Sidebar'; import StatusBar from 'components/StatusBar'; import AppTitleBar from 'components/AppTitleBar'; import ApiSpecPanel from 'components/ApiSpecPanel'; +import TabPanelErrorBoundary from 'components/RequestTabPanel/TabPanelErrorBoundary'; // import ErrorCapture from 'components/ErrorCapture'; import { useSelector } from 'react-redux'; import { isElectron } from 'utils/common/platform'; @@ -145,7 +146,9 @@ export default function Main() { ) : ( <> - + + + )} diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js index 281bd98fe..a862affdf 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js @@ -8,6 +8,8 @@ import { isActiveTab as checkIsActiveTab, deserializeTab } from 'utils/snapshot' const MAX_RECENTLY_CLOSED_TABS = 50; +export const NON_CLOSABLE_TAB_TYPES = ['workspaceOverview', 'workspaceEnvironments']; + const initialState = { tabs: [], activeTabUid: null, @@ -304,12 +306,10 @@ export const tabsSlice = createSlice({ const activeTab = find(state.tabs, (t) => t.uid === state.activeTabUid); const tabUids = action.payload.tabUids || []; - const nonClosableTypes = ['workspaceOverview', 'workspaceEnvironments']; - // Push closed tabs onto the recently closed stack (LIFO) // Exclude transient requests — they have no persisted file and can't be reopened const closedTabs = state.tabs.filter((t) => - tabUids.includes(t.uid) && !nonClosableTypes.includes(t.type) && !t.isTransient + tabUids.includes(t.uid) && !NON_CLOSABLE_TAB_TYPES.includes(t.type) && !t.isTransient ); if (closedTabs.length > 0) { state.recentlyClosedTabs.push(...closedTabs); @@ -320,7 +320,7 @@ export const tabsSlice = createSlice({ } state.tabs = filter(state.tabs, (t) => - !tabUids.includes(t.uid) || nonClosableTypes.includes(t.type) + !tabUids.includes(t.uid) || NON_CLOSABLE_TAB_TYPES.includes(t.type) ); if (activeTab && state.tabs.length) { diff --git a/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-bru/bruno.json b/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-bru/bruno.json new file mode 100644 index 000000000..a3bf1e183 --- /dev/null +++ b/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-bru/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "invalid-tags-bru", + "type": "collection" +} diff --git a/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-bru/collection.bru b/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-bru/collection.bru new file mode 100644 index 000000000..6d411094c --- /dev/null +++ b/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-bru/collection.bru @@ -0,0 +1,3 @@ +meta { + name: invalid-tags-bru +} diff --git a/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-bru/invalid-tags-bru-request.bru b/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-bru/invalid-tags-bru-request.bru new file mode 100644 index 000000000..d170bc5ba --- /dev/null +++ b/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-bru/invalid-tags-bru-request.bru @@ -0,0 +1,12 @@ +meta { + name: invalid-tags-bru-request + type: http + seq: 1 + tags: smoke +} + +get { + url: https://httpbin.org/get + body: none + auth: none +} diff --git a/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-yml/control-yml-request.yml b/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-yml/control-yml-request.yml new file mode 100644 index 000000000..568bb136c --- /dev/null +++ b/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-yml/control-yml-request.yml @@ -0,0 +1,13 @@ +meta: + name: control-yml-request + type: http + seq: 3 + +info: + name: control-yml-request + type: http + seq: 3 + +http: + method: GET + url: https://httpbin.org/get diff --git a/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-yml/invalid-tags-yml-request.yml b/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-yml/invalid-tags-yml-request.yml new file mode 100644 index 000000000..3a69b9c10 --- /dev/null +++ b/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-yml/invalid-tags-yml-request.yml @@ -0,0 +1,15 @@ +meta: + name: invalid-tags-yml-request + type: http + seq: 1 + tags: smoke + +info: + name: invalid-tags-yml-request + type: http + seq: 1 + tags: smoke + +http: + method: GET + url: https://httpbin.org/get diff --git a/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-yml/opencollection.yml b/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-yml/opencollection.yml new file mode 100644 index 000000000..4d7d0a368 --- /dev/null +++ b/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-yml/opencollection.yml @@ -0,0 +1,6 @@ +opencollection: "1.0.0" + +info: + name: invalid-tags-yml + +bundled: false diff --git a/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-yml/valid-tags-yml-request.yml b/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-yml/valid-tags-yml-request.yml new file mode 100644 index 000000000..db3a729ff --- /dev/null +++ b/tests/request/tab-panel-error-boundary/fixtures/collections/invalid-tags-yml/valid-tags-yml-request.yml @@ -0,0 +1,19 @@ +meta: + name: valid-tags-yml-request + type: http + seq: 2 + tags: + - smoke + - sanity + +info: + name: valid-tags-yml-request + type: http + seq: 2 + tags: + - smoke + - sanity + +http: + method: GET + url: https://httpbin.org/get diff --git a/tests/request/tab-panel-error-boundary/init-user-data/preferences.json b/tests/request/tab-panel-error-boundary/init-user-data/preferences.json new file mode 100644 index 000000000..dc873c76f --- /dev/null +++ b/tests/request/tab-panel-error-boundary/init-user-data/preferences.json @@ -0,0 +1,12 @@ +{ + "lastOpenedCollections": [ + "{{collectionPath}}/invalid-tags-bru", + "{{collectionPath}}/invalid-tags-yml" + ], + "preferences": { + "onboarding": { + "hasLaunchedBefore": true, + "hasSeenWelcomeModal": true + } + } +} diff --git a/tests/request/tab-panel-error-boundary/tab-panel-error-boundary.spec.ts b/tests/request/tab-panel-error-boundary/tab-panel-error-boundary.spec.ts new file mode 100644 index 000000000..4eaa135f1 --- /dev/null +++ b/tests/request/tab-panel-error-boundary/tab-panel-error-boundary.spec.ts @@ -0,0 +1,75 @@ +import { expect, Page, test } from '../../../playwright'; +import { openRequest } from '../../utils/page'; +import { buildCommonLocators } from '../../utils/page/locators'; + +// NOTE: Rewritten instead of `selectRequestPaneTab` from actions.ts because we want to avoid assertion +// of tab active state +const openRequestSettingsTab = async (page: Page) => { + const requestPane = page.locator('[data-testid="request-pane"] > .px-4'); + await expect(requestPane).toBeVisible(); + + const settingsTab = requestPane.locator('.tabs').getByRole('tab', { name: 'Settings' }); + if (await settingsTab.isVisible().catch(() => false)) { + await settingsTab.click(); + return; + } + + const moreTabs = requestPane.locator('.tabs .more-tabs'); + await expect(moreTabs).toBeVisible(); + await moreTabs.click(); + await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Settings' }).click({ force: true }); +}; + +const assertTabPanelErrorBoundary = async (page: Page, collectionName: string, requestName: string) => { + const commonLocators = buildCommonLocators(page); + + await test.step(`Open ${requestName} and trigger render error`, async () => { + await openRequest(page, collectionName, requestName); + await expect(commonLocators.tabs.activeRequestTab()).toContainText(requestName); + await openRequestSettingsTab(page); + }); + + await test.step('Verify fallback UI', async () => { + await expect(page.getByText('Something went wrong')).toBeVisible(); + await expect( + page.getByText( + 'This tab encountered an unexpected error. Close it and try reopening the request. If the error repeats, the request file may be corrupt.' + ) + ).toBeVisible(); + await expect(page.getByTestId('tab-panel-error-boundary-close-tab')).toBeVisible(); + }); + + await test.step('Close errored tab via boundary action', async () => { + await page.getByTestId('tab-panel-error-boundary-close-tab').click(); + await expect(commonLocators.tabs.requestTab(requestName)).not.toBeVisible(); + }); +}; + +const assertBoundaryVisible = async (page: Page) => { + await expect(page.getByText('Something went wrong')).toBeVisible(); + await expect(page.getByTestId('tab-panel-error-boundary-close-tab')).toBeVisible(); +}; + +test.describe.serial('Tab Panel Error Boundary', () => { + test('handles invalid tags type in YAML request metadata', async ({ pageWithUserData: page }) => { + await assertTabPanelErrorBoundary(page, 'invalid-tags-yml', 'invalid-tags-yml-request'); + }); + + test('shows error only on failed tab and allows switching to valid tab', async ({ pageWithUserData: page }) => { + const commonLocators = buildCommonLocators(page); + + await openRequest(page, 'invalid-tags-yml', 'invalid-tags-yml-request', { persist: true }); + await openRequestSettingsTab(page); + await assertBoundaryVisible(page); + + await openRequest(page, 'invalid-tags-yml', 'control-yml-request'); + await expect(commonLocators.tabs.activeRequestTab()).toContainText('control-yml-request'); + await expect(page.getByText('Something went wrong')).toHaveCount(0); + await expect(page.getByTestId('tab-panel-error-boundary-close-tab')).toHaveCount(0); + await expect(page.locator('[data-testid="request-pane"]')).toBeVisible(); + + await commonLocators.tabs.requestTab('invalid-tags-yml-request').click(); + await expect(commonLocators.tabs.activeRequestTab()).toContainText('invalid-tags-yml-request'); + await assertBoundaryVisible(page); + }); +});