diff --git a/superset-frontend/spec/helpers/extensionTestHelpers.ts b/superset-frontend/spec/helpers/extensionTestHelpers.ts
new file mode 100644
index 00000000000..84f449b387a
--- /dev/null
+++ b/superset-frontend/spec/helpers/extensionTestHelpers.ts
@@ -0,0 +1,70 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import type { ReactElement } from 'react';
+import { views, menus, commands } from 'src/core';
+import { resetContributions } from 'src/core/commands';
+
+const disposables: Array<{ dispose: () => void }> = [];
+
+/**
+ * Cleans up all tracked extension registrations (views, menus, commands).
+ * Call in afterEach to prevent state leaks between tests.
+ */
+export const cleanupExtensions = () => {
+ disposables.forEach(d => d.dispose());
+ disposables.length = 0;
+ resetContributions();
+};
+
+/**
+ * Registers a test view at a given location and tracks it for cleanup.
+ */
+export const registerTestView = (
+ location: string,
+ id: string,
+ name: string,
+ provider: () => ReactElement,
+) => {
+ const disposable = views.registerView({ id, name }, location, provider);
+ disposables.push(disposable);
+ return disposable;
+};
+
+/**
+ * Registers a toolbar action (command + menu item) and tracks both for cleanup.
+ * Primary actions require an icon to render in PanelToolbar.
+ */
+export const registerToolbarAction = (
+ viewId: string,
+ commandId: string,
+ title: string,
+ callback: () => void,
+ group: 'primary' | 'secondary' = 'primary',
+) => {
+ const cmdDisposable = commands.registerCommand(
+ { id: commandId, title, icon: 'FileOutlined' },
+ callback,
+ );
+ const menuDisposable = menus.registerMenuItem(
+ { command: commandId, view: `test-view-${commandId}` },
+ viewId,
+ group,
+ );
+ disposables.push(cmdDisposable, menuDisposable);
+};
diff --git a/superset-frontend/src/SqlLab/components/AppLayout/AppLayout.test.tsx b/superset-frontend/src/SqlLab/components/AppLayout/AppLayout.test.tsx
index 88ee6941765..a25738303a3 100644
--- a/superset-frontend/src/SqlLab/components/AppLayout/AppLayout.test.tsx
+++ b/superset-frontend/src/SqlLab/components/AppLayout/AppLayout.test.tsx
@@ -20,8 +20,11 @@ import React from 'react';
import { render, userEvent, waitFor } from 'spec/helpers/testing-library';
import { initialState } from 'src/SqlLab/fixtures';
import useStoredSidebarWidth from 'src/components/ResizableSidebar/useStoredSidebarWidth';
-import { views } from 'src/core';
import { ViewLocations } from 'src/SqlLab/contributions';
+import {
+ registerTestView,
+ cleanupExtensions,
+} from 'spec/helpers/extensionTestHelpers';
import AppLayout from './index';
jest.mock('src/components/ResizableSidebar/useStoredSidebarWidth');
@@ -63,6 +66,8 @@ beforeEach(() => {
(useStoredSidebarWidth as jest.Mock).mockReturnValue([250, jest.fn()]);
});
+afterEach(cleanupExtensions);
+
test('renders two panels', () => {
const { getAllByTestId } = render(, {
useRedux: true,
@@ -94,10 +99,20 @@ test('calls setWidth on sidebar resize when not hidden', async () => {
await waitFor(() => expect(setWidth).toHaveBeenCalled());
});
+test('right sidebar is hidden when no extensions registered', () => {
+ const { queryByText } = render(, {
+ useRedux: true,
+ initialState,
+ });
+ // No right sidebar content — the third Splitter.Panel is conditionally omitted
+ expect(queryByText('Right Sidebar Content')).not.toBeInTheDocument();
+});
+
test('renders right sidebar when view is contributed at rightSidebar location', () => {
- views.registerView(
- { id: 'test-right-sidebar-view', name: 'Test Right Sidebar View' },
+ registerTestView(
ViewLocations.sqllab.rightSidebar,
+ 'test-right-sidebar-view',
+ 'Test Right Sidebar View',
() => React.createElement('div', null, 'Right Sidebar Content'),
);
@@ -110,5 +125,6 @@ test('renders right sidebar when view is contributed at rightSidebar location',
);
expect(getByText('Child')).toBeInTheDocument();
+ expect(getByText('Right Sidebar Content')).toBeInTheDocument();
expect(getAllByTestId('mock-panel')).toHaveLength(3);
});
diff --git a/superset-frontend/src/SqlLab/components/QueryHistory/QueryHistory.test.tsx b/superset-frontend/src/SqlLab/components/QueryHistory/QueryHistory.test.tsx
index a1d2185f3c5..cea251cb9cc 100644
--- a/superset-frontend/src/SqlLab/components/QueryHistory/QueryHistory.test.tsx
+++ b/superset-frontend/src/SqlLab/components/QueryHistory/QueryHistory.test.tsx
@@ -25,6 +25,11 @@ import {
defaultQueryEditor,
extraQueryEditor3,
} from 'src/SqlLab/fixtures';
+import { ViewLocations } from 'src/SqlLab/contributions';
+import {
+ registerToolbarAction,
+ cleanupExtensions,
+} from 'spec/helpers/extensionTestHelpers';
const mockedProps = {
queryEditorId: defaultQueryEditor.id,
@@ -81,7 +86,11 @@ const setup = (overrides = {}) => (
);
-afterEach(() => fetchMock.clearHistory().removeRoutes());
+afterEach(() => {
+ fetchMock.clearHistory().removeRoutes();
+ cleanupExtensions();
+ mockedIsFeatureEnabled.mockReset();
+});
test('Renders an empty state for query history', () => {
render(setup(), { useRedux: true, initialState });
@@ -242,3 +251,44 @@ test('displays multiple queries with newest query first', async () => {
isFeatureEnabledMock.mockClear();
});
+
+test('renders contributed toolbar action in queryHistory slot', () => {
+ registerToolbarAction(
+ ViewLocations.sqllab.queryHistory,
+ 'test-history-action',
+ 'History Action',
+ jest.fn(),
+ );
+
+ const stateWithQueries = {
+ ...initialState,
+ sqlLab: {
+ ...initialState.sqlLab,
+ queries: {
+ testQuery: {
+ id: 'testQuery',
+ sqlEditorId: defaultQueryEditor.id,
+ sql: 'SELECT 1',
+ state: QueryState.Success,
+ startDttm: Date.now(),
+ endDttm: Date.now() + 100,
+ progress: 100,
+ rows: 1,
+ cached: false,
+ changed_on: new Date().toISOString(),
+ db: 'main',
+ dbId: 1,
+ },
+ },
+ },
+ };
+
+ render(setup(), {
+ useRedux: true,
+ initialState: stateWithQueries,
+ });
+
+ expect(
+ screen.getByRole('button', { name: 'History Action' }),
+ ).toBeInTheDocument();
+});
diff --git a/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx b/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx
index 20871fc029f..64daa73cf5b 100644
--- a/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx
+++ b/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx
@@ -41,6 +41,11 @@ import {
queryWithNoQueryLimit,
failedQueryWithFrontendTimeoutErrors,
} from 'src/SqlLab/fixtures';
+import { ViewLocations } from 'src/SqlLab/contributions';
+import {
+ registerToolbarAction,
+ cleanupExtensions,
+} from 'spec/helpers/extensionTestHelpers';
jest.mock('src/components/ErrorMessage', () => ({
ErrorMessageWithStackTrace: () =>
Error
,
@@ -152,6 +157,7 @@ describe('ResultSet', () => {
// Add cleanup after each test
afterEach(async () => {
fetchMock.clearHistory();
+ cleanupExtensions();
// Wait for any pending effects to complete
await new Promise(resolve => setTimeout(resolve, 0));
});
@@ -753,4 +759,35 @@ describe('ResultSet', () => {
);
},
);
+
+ test('renders contributed toolbar action in results slot', async () => {
+ registerToolbarAction(
+ ViewLocations.sqllab.results,
+ 'test-results-action',
+ 'Results Action',
+ jest.fn(),
+ );
+
+ const { getByTestId } = setup(
+ mockedProps,
+ mockStore({
+ ...initialState,
+ user,
+ sqlLab: {
+ ...initialState.sqlLab,
+ queries: {
+ [queries[0].id]: queries[0],
+ },
+ },
+ }),
+ );
+
+ await waitFor(() => {
+ expect(getByTestId('table-container')).toBeInTheDocument();
+ });
+
+ expect(
+ screen.getByRole('button', { name: 'Results Action' }),
+ ).toBeInTheDocument();
+ });
});
diff --git a/superset-frontend/src/SqlLab/components/SouthPane/SouthPane.test.tsx b/superset-frontend/src/SqlLab/components/SouthPane/SouthPane.test.tsx
index 98b2ff45f54..02de9e44b82 100644
--- a/superset-frontend/src/SqlLab/components/SouthPane/SouthPane.test.tsx
+++ b/superset-frontend/src/SqlLab/components/SouthPane/SouthPane.test.tsx
@@ -16,12 +16,21 @@
* specific language governing permissions and limitations
* under the License.
*/
+import React from 'react';
import { render, waitFor, screen } from 'spec/helpers/testing-library';
+import userEvent from '@testing-library/user-event';
import SouthPane from 'src/SqlLab/components/SouthPane';
import { STATUS_OPTIONS } from 'src/SqlLab/constants';
import { initialState, table, defaultQueryEditor } from 'src/SqlLab/fixtures';
import { denormalizeTimestamp } from '@superset-ui/core';
-import userEvent from '@testing-library/user-event';
+import { ViewLocations } from 'src/SqlLab/contributions';
+import {
+ registerTestView,
+ registerToolbarAction,
+ cleanupExtensions,
+} from 'spec/helpers/extensionTestHelpers';
+
+afterEach(cleanupExtensions);
const mockedProps = {
queryEditorId: defaultQueryEditor.id,
@@ -181,3 +190,69 @@ test('should remove tab', async () => {
expect(tabs).toHaveLength(totalTabs - 1);
});
});
+
+test('renders contributed tab content via ViewListExtension', () => {
+ registerTestView(
+ ViewLocations.sqllab.panels,
+ 'test-panel',
+ 'Test Panel',
+ () => React.createElement('div', null, 'Contributed Panel Content'),
+ );
+
+ const { container } = render(, {
+ useRedux: true,
+ initialState: mockState,
+ });
+
+ const tabs = Array.from(container.querySelectorAll('[role="tab"]')).filter(
+ tab => !tab.classList.contains('ant-tabs-tab-remove'),
+ );
+ // Base tabs (Results + Query history) + 2 table previews + 1 extension
+ expect(tabs).toHaveLength(mockState.sqlLab.tables.length + 3);
+ expect(tabs.find(tab => tab.textContent === 'Test Panel')).toBeTruthy();
+ expect(screen.getByText('Contributed Panel Content')).toBeInTheDocument();
+});
+
+test('renders slot-wide toolbar actions via PanelToolbar', () => {
+ registerToolbarAction(
+ ViewLocations.sqllab.panels,
+ 'test-panels-action',
+ 'Panels Action',
+ jest.fn(),
+ );
+
+ render(, {
+ useRedux: true,
+ initialState: mockState,
+ });
+
+ expect(
+ screen.getByRole('button', { name: 'Panels Action' }),
+ ).toBeInTheDocument();
+});
+
+test('renders per-view toolbar actions for contributed tab', () => {
+ registerTestView(
+ ViewLocations.sqllab.panels,
+ 'test-per-view-panel',
+ 'Per-View Panel',
+ () => React.createElement('div', null, 'Per-View Content'),
+ );
+ registerToolbarAction(
+ 'test-per-view-panel',
+ 'test-per-view-action',
+ 'Per-View Action',
+ jest.fn(),
+ );
+
+ render(, {
+ useRedux: true,
+ initialState: mockState,
+ });
+
+ // Content is rendered via forceRender: true even when tab is not active.
+ // Use { hidden: true } to find button in non-active tab pane.
+ expect(
+ screen.getByRole('button', { name: 'Per-View Action', hidden: true }),
+ ).toBeInTheDocument();
+});
diff --git a/superset-frontend/src/SqlLab/components/SqlEditorTopBar/SqlEditorTopBar.test.tsx b/superset-frontend/src/SqlLab/components/SqlEditorTopBar/SqlEditorTopBar.test.tsx
index 224627b0701..515118c3900 100644
--- a/superset-frontend/src/SqlLab/components/SqlEditorTopBar/SqlEditorTopBar.test.tsx
+++ b/superset-frontend/src/SqlLab/components/SqlEditorTopBar/SqlEditorTopBar.test.tsx
@@ -17,31 +17,16 @@
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
-import { MenuItemType } from '@superset-ui/core/components/Menu';
import SqlEditorTopBar, {
SqlEditorTopBarProps,
} from 'src/SqlLab/components/SqlEditorTopBar';
+import { ViewLocations } from 'src/SqlLab/contributions';
+import {
+ registerToolbarAction,
+ cleanupExtensions,
+} from 'spec/helpers/extensionTestHelpers';
-jest.mock('src/components/PanelToolbar', () => ({
- __esModule: true,
- default: ({
- viewId,
- defaultPrimaryActions,
- defaultSecondaryActions,
- }: {
- viewId: string;
- defaultPrimaryActions?: React.ReactNode;
- defaultSecondaryActions?: MenuItemType[];
- }) => (
-
- {defaultPrimaryActions}
-
- ),
-}));
+afterEach(cleanupExtensions);
const defaultProps: SqlEditorTopBarProps = {
queryEditorId: 'test-query-editor-id',
@@ -55,24 +40,6 @@ const defaultProps: SqlEditorTopBarProps = {
const setup = (props?: Partial) =>
render();
-test('renders SqlEditorTopBar component', () => {
- setup();
- const panelToolbar = screen.getByTestId('mock-panel-toolbar');
- expect(panelToolbar).toBeInTheDocument();
-});
-
-test('renders PanelToolbar with correct viewId', () => {
- setup();
- const panelToolbar = screen.getByTestId('mock-panel-toolbar');
- expect(panelToolbar).toHaveAttribute('data-view-id', 'sqllab.editor');
-});
-
-test('renders PanelToolbar with correct secondary actions count', () => {
- setup();
- const panelToolbar = screen.getByTestId('mock-panel-toolbar');
- expect(panelToolbar).toHaveAttribute('data-default-secondary-count', '2');
-});
-
test('renders defaultPrimaryActions', () => {
setup();
expect(
@@ -98,9 +65,24 @@ test('renders with custom primary actions', () => {
).toBeInTheDocument();
});
-test('renders with empty secondary actions', () => {
- setup({ defaultSecondaryActions: [] });
-
- const panelToolbar = screen.getByTestId('mock-panel-toolbar');
- expect(panelToolbar).toHaveAttribute('data-default-secondary-count', '0');
+test('renders contributed toolbar action in editor slot', () => {
+ registerToolbarAction(
+ ViewLocations.sqllab.editor,
+ 'test-editor-action',
+ 'Test Editor Action',
+ jest.fn(),
+ );
+ setup();
+ expect(
+ screen.getByRole('button', { name: 'Test Editor Action' }),
+ ).toBeInTheDocument();
+});
+
+test('renders nothing when no toolbar actions registered and no default actions', () => {
+ setup({
+ defaultPrimaryActions: undefined,
+ defaultSecondaryActions: [],
+ });
+ // PanelToolbar returns null when there are no actions at all
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
diff --git a/superset-frontend/src/SqlLab/components/StatusBar/StatusBar.test.tsx b/superset-frontend/src/SqlLab/components/StatusBar/StatusBar.test.tsx
index 60c0642e64a..54944b70ef7 100644
--- a/superset-frontend/src/SqlLab/components/StatusBar/StatusBar.test.tsx
+++ b/superset-frontend/src/SqlLab/components/StatusBar/StatusBar.test.tsx
@@ -16,26 +16,82 @@
* specific language governing permissions and limitations
* under the License.
*/
+import React from 'react';
import { render, screen } from 'spec/helpers/testing-library';
import StatusBar from 'src/SqlLab/components/StatusBar';
+import { ViewLocations } from 'src/SqlLab/contributions';
+import {
+ registerTestView,
+ cleanupExtensions,
+} from 'spec/helpers/extensionTestHelpers';
-jest.mock('src/core/views', () => ({
- views: {
- getViews: jest.fn().mockReturnValue([{ id: 'test-status-bar' }]),
- registerView: jest.fn(),
- },
-}));
+let consoleErrorSpy: jest.SpyInstance;
-jest.mock('src/components/ViewListExtension', () => ({
- __esModule: true,
- default: ({ viewId }: { viewId: string }) => (
-
- ViewListExtension
-
- ),
-}));
-
-test('renders StatusBar component', () => {
- render();
- expect(screen.getByTestId('mock-view-extension')).toBeInTheDocument();
+afterEach(() => {
+ cleanupExtensions();
+ if (consoleErrorSpy) {
+ consoleErrorSpy.mockRestore();
+ }
+});
+
+test('renders extension content when registered at statusBar slot', () => {
+ registerTestView(
+ ViewLocations.sqllab.statusBar,
+ 'test-status',
+ 'Test Status',
+ () => React.createElement('div', null, 'Status Extension'),
+ );
+ render();
+ expect(screen.getByText('Status Extension')).toBeInTheDocument();
+});
+
+test('does not render container when no extensions registered', () => {
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+});
+
+test('extension throwing during render does not crash host', () => {
+ consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+
+ const ThrowingComponent = () => {
+ throw new Error('Extension error');
+ };
+
+ registerTestView(
+ ViewLocations.sqllab.statusBar,
+ 'throwing-ext',
+ 'Throwing',
+ () => React.createElement(ThrowingComponent),
+ );
+ registerTestView(
+ ViewLocations.sqllab.statusBar,
+ 'healthy-ext',
+ 'Healthy',
+ () => React.createElement('div', null, 'Healthy Content'),
+ );
+
+ render();
+
+ // Healthy extension still renders despite the throwing extension
+ expect(screen.getByText('Healthy Content')).toBeInTheDocument();
+ // Verify the error boundary caught the throwing extension
+ expect(consoleErrorSpy).toHaveBeenCalled();
+});
+
+test('renders multiple extensions in status bar', () => {
+ registerTestView(
+ ViewLocations.sqllab.statusBar,
+ 'test-status-1',
+ 'Status One',
+ () => React.createElement('div', null, 'Extension One'),
+ );
+ registerTestView(
+ ViewLocations.sqllab.statusBar,
+ 'test-status-2',
+ 'Status Two',
+ () => React.createElement('div', null, 'Extension Two'),
+ );
+ render();
+ expect(screen.getByText('Extension One')).toBeInTheDocument();
+ expect(screen.getByText('Extension Two')).toBeInTheDocument();
});
diff --git a/superset-frontend/src/SqlLab/components/TableExploreTree/TableExploreTree.test.tsx b/superset-frontend/src/SqlLab/components/TableExploreTree/TableExploreTree.test.tsx
index 91334b6884e..58e9193c649 100644
--- a/superset-frontend/src/SqlLab/components/TableExploreTree/TableExploreTree.test.tsx
+++ b/superset-frontend/src/SqlLab/components/TableExploreTree/TableExploreTree.test.tsx
@@ -22,6 +22,11 @@ import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { initialState, defaultQueryEditor } from 'src/SqlLab/fixtures';
+import { ViewLocations } from 'src/SqlLab/contributions';
+import {
+ registerToolbarAction,
+ cleanupExtensions,
+} from 'spec/helpers/extensionTestHelpers';
import TableExploreTree from '.';
jest.mock(
@@ -74,6 +79,7 @@ beforeEach(() => {
afterEach(() => {
jest.clearAllMocks();
fetchMock.clearHistory();
+ cleanupExtensions();
});
const getInitialState = (overrides = {}) => ({
@@ -239,3 +245,22 @@ test('renders refresh button for schema list', async () => {
const refreshButton = screen.getByRole('button', { name: /reload/i });
expect(refreshButton).toBeInTheDocument();
});
+
+test('renders contributed toolbar action in leftSidebar slot', async () => {
+ registerToolbarAction(
+ ViewLocations.sqllab.leftSidebar,
+ 'test-left-action',
+ 'Left Sidebar Action',
+ jest.fn(),
+ );
+
+ renderComponent();
+
+ await waitFor(() => {
+ expect(screen.getByText('public')).toBeInTheDocument();
+ });
+
+ expect(
+ screen.getByRole('button', { name: 'Left Sidebar Action' }),
+ ).toBeInTheDocument();
+});
diff --git a/superset-frontend/src/components/PanelToolbar/PanelToolbar.test.tsx b/superset-frontend/src/components/PanelToolbar/PanelToolbar.test.tsx
new file mode 100644
index 00000000000..7b32a30f9d9
--- /dev/null
+++ b/superset-frontend/src/components/PanelToolbar/PanelToolbar.test.tsx
@@ -0,0 +1,47 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { render, screen } from 'spec/helpers/testing-library';
+import userEvent from '@testing-library/user-event';
+import PanelToolbar from 'src/components/PanelToolbar';
+import {
+ registerToolbarAction,
+ cleanupExtensions,
+} from 'spec/helpers/extensionTestHelpers';
+
+afterEach(cleanupExtensions);
+
+test('click executes registered command callback', async () => {
+ const callback = jest.fn();
+ registerToolbarAction(
+ 'test.clickLocation',
+ 'test-click-cmd',
+ 'Click Me',
+ callback,
+ );
+
+ render();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Click Me' }));
+ expect(callback).toHaveBeenCalledTimes(1);
+});
+
+test('renders nothing when no actions registered', () => {
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+});