mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
fix: add tab error boundary (#7987)
This commit is contained in:
@@ -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 (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<IconAlertTriangle size={36} strokeWidth={1.5} style={{ color: theme?.status?.warning?.text }} />
|
||||
<h2 className="text-lg font-medium">Something went wrong</h2>
|
||||
{isClosable ? (
|
||||
<p className="text-sm opacity-70 max-w-md">
|
||||
This tab encountered an unexpected error. Close it and try reopening the request. If the
|
||||
error repeats, the request file may be corrupt.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm opacity-70 max-w-md">
|
||||
This panel encountered an unexpected error. Restart Bruno to recover.
|
||||
</p>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<p className="text-xs font-mono opacity-50 max-w-md break-all">{errorMessage}</p>
|
||||
)}
|
||||
{isClosable && (
|
||||
<Button size="md" data-testid="tab-panel-error-boundary-close-tab" color="primary" onClick={onClose}>
|
||||
Close Tab
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<TabPanelErrorBoundaryInner isClosable={isClosable} onClose={handleClose} theme={theme}>
|
||||
{children}
|
||||
</TabPanelErrorBoundaryInner>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabPanelErrorBoundary;
|
||||
@@ -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() {
|
||||
) : (
|
||||
<>
|
||||
<RequestTabs />
|
||||
<RequestTabPanel key={activeTabUid} />
|
||||
<TabPanelErrorBoundary key={activeTabUid} tabUid={activeTabUid}>
|
||||
<RequestTabPanel key={activeTabUid} />
|
||||
</TabPanelErrorBoundary>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "invalid-tags-bru",
|
||||
"type": "collection"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
meta {
|
||||
name: invalid-tags-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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
opencollection: "1.0.0"
|
||||
|
||||
info:
|
||||
name: invalid-tags-yml
|
||||
|
||||
bundled: false
|
||||
@@ -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
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"lastOpenedCollections": [
|
||||
"{{collectionPath}}/invalid-tags-bru",
|
||||
"{{collectionPath}}/invalid-tags-yml"
|
||||
],
|
||||
"preferences": {
|
||||
"onboarding": {
|
||||
"hasLaunchedBefore": true,
|
||||
"hasSeenWelcomeModal": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user