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(); +});