test(sqllab): add extension slot contract tests for all 7 host components (#38776)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Joe Li
2026-03-24 10:01:52 -07:00
committed by GitHub
parent c596df9294
commit ccaac306e5
9 changed files with 425 additions and 67 deletions

View File

@@ -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);
};

View File

@@ -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(<AppLayout {...defaultProps} />, {
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(<AppLayout {...defaultProps} />, {
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);
});

View File

@@ -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 = {}) => (
<QueryHistory {...mockedProps} {...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();
});

View File

@@ -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: () => <div data-test="error-message">Error</div>,
@@ -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();
});
});

View File

@@ -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(<SouthPane {...mockedProps} />, {
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(<SouthPane {...mockedProps} />, {
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(<SouthPane {...mockedProps} />, {
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();
});

View File

@@ -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[];
}) => (
<div
data-test="mock-panel-toolbar"
data-view-id={viewId}
data-default-secondary-count={defaultSecondaryActions?.length ?? 0}
>
{defaultPrimaryActions}
</div>
),
}));
afterEach(cleanupExtensions);
const defaultProps: SqlEditorTopBarProps = {
queryEditorId: 'test-query-editor-id',
@@ -55,24 +40,6 @@ const defaultProps: SqlEditorTopBarProps = {
const setup = (props?: Partial<SqlEditorTopBarProps>) =>
render(<SqlEditorTopBar {...defaultProps} {...props} />);
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();
});

View File

@@ -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 }) => (
<div data-test="mock-view-extension" data-view-id={viewId}>
ViewListExtension
</div>
),
}));
test('renders StatusBar component', () => {
render(<StatusBar />);
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(<StatusBar />);
expect(screen.getByText('Status Extension')).toBeInTheDocument();
});
test('does not render container when no extensions registered', () => {
const { container } = render(<StatusBar />);
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(<StatusBar />);
// 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(<StatusBar />);
expect(screen.getByText('Extension One')).toBeInTheDocument();
expect(screen.getByText('Extension Two')).toBeInTheDocument();
});

View File

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

View File

@@ -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(<PanelToolbar viewId="test.clickLocation" />);
await userEvent.click(screen.getByRole('button', { name: 'Click Me' }));
expect(callback).toHaveBeenCalledTimes(1);
});
test('renders nothing when no actions registered', () => {
const { container } = render(<PanelToolbar viewId="empty.location" />);
expect(container).toBeEmptyDOMElement();
});