diff --git a/superset-frontend/src/explore/components/controls/DatasourceControl/DatasourceControl.test.tsx b/superset-frontend/src/explore/components/controls/DatasourceControl/DatasourceControl.test.tsx index b51e87fb2bc..a589ab83fc1 100644 --- a/superset-frontend/src/explore/components/controls/DatasourceControl/DatasourceControl.test.tsx +++ b/superset-frontend/src/explore/components/controls/DatasourceControl/DatasourceControl.test.tsx @@ -24,7 +24,6 @@ import { DatasourceType, JsonObject, SupersetClient } from '@superset-ui/core'; import { render, screen, - act, userEvent, waitFor, } from 'spec/helpers/testing-library'; @@ -32,7 +31,20 @@ import { fallbackExploreInitialData } from 'src/explore/fixtures'; import type { ColumnObject } from 'src/features/datasets/types'; import DatasourceControl from '.'; -const SupersetClientGet = jest.spyOn(SupersetClient, 'get'); +// Mock DatasourceEditor to avoid mounting the full 2,500+ line editor tree. +// The heavy editor (CollectionTable, FilterableTable, DatabaseSelector, etc.) +// causes OOM in CI when rendered repeatedly. These tests only need to verify +// DatasourceControl's callback wiring through the modal save flow. +// Editor internals are tested in DatasourceEditor.test.tsx. +jest.mock('src/components/Datasource/components/DatasourceEditor', () => ({ + __esModule: true, + default: () => + require('react').createElement( + 'div', + { 'data-test': 'mock-datasource-editor' }, + 'Mock Editor', + ), +})); let originalLocation: Location; @@ -42,8 +54,19 @@ beforeEach(() => { afterEach(() => { window.location = originalLocation; - fetchMock.clearHistory().removeRoutes(); - jest.clearAllMocks(); // Clears mock history but keeps spy in place + + try { + const unmatched = fetchMock.callHistory.calls('unmatched'); + if (unmatched.length > 0) { + const urls = unmatched.map(call => call.url).join(', '); + throw new Error( + `fetchMock: ${unmatched.length} unmatched call(s): ${urls}`, + ); + } + } finally { + fetchMock.clearHistory().removeRoutes(); + jest.restoreAllMocks(); + } }); interface TestDatasource { @@ -234,16 +257,16 @@ test('Should show SQL Lab for sql_lab role', async () => { test('Click on Swap dataset option', async () => { const props = createProps(); - SupersetClientGet.mockImplementationOnce( - async ({ endpoint }: { endpoint: string }) => { + jest + .spyOn(SupersetClient, 'get') + .mockImplementation(async ({ endpoint }: { endpoint: string }) => { if (endpoint.includes('_info')) { return { json: { permissions: ['can_read', 'can_write'] }, } as any; } return { json: { result: [] } } as any; - }, - ); + }); render(, { useRedux: true, @@ -251,9 +274,8 @@ test('Click on Swap dataset option', async () => { }); await userEvent.click(screen.getByTestId('datasource-menu-trigger')); - await act(async () => { - await userEvent.click(screen.getByText('Swap dataset')); - }); + await userEvent.click(screen.getByText('Swap dataset')); + expect( screen.getByText( 'Changing the dataset may break the chart if the chart relies on columns or metadata that does not exist in the target dataset', @@ -263,25 +285,18 @@ test('Click on Swap dataset option', async () => { test('Click on Edit dataset', async () => { const props = createProps(); - SupersetClientGet.mockImplementationOnce( - async () => ({ json: { result: [] } }) as any, - ); fetchMock.removeRoute(getDbWithQuery); fetchMock.get(getDbWithQuery, { result: [] }, { name: getDbWithQuery }); render(, { useRedux: true, useRouter: true, }); - userEvent.click(screen.getByTestId('datasource-menu-trigger')); + await userEvent.click(screen.getByTestId('datasource-menu-trigger')); - await act(async () => { - userEvent.click(screen.getByText('Edit dataset')); - }); + await userEvent.click(screen.getByText('Edit dataset')); expect( - screen.getByText( - 'Changing these settings will affect all charts using this dataset, including charts owned by other people.', - ), + await screen.findByTestId('mock-datasource-editor'), ).toBeInTheDocument(); }); @@ -289,9 +304,6 @@ test('Edit dataset should be disabled when user is not admin', async () => { const props = createProps(); props.user.roles = {}; props.datasource.owners = []; - SupersetClientGet.mockImplementationOnce( - async () => ({ json: { result: [] } }) as any, - ); render(, { useRedux: true, @@ -330,9 +342,7 @@ test('Click on View in SQL Lab', async () => { expect(queryByTestId('mock-sqllab-route')).not.toBeInTheDocument(); - await act(async () => { - await userEvent.click(screen.getByText('View in SQL Lab')); - }); + await userEvent.click(screen.getByText('View in SQL Lab')); expect(getByTestId('mock-sqllab-route')).toBeInTheDocument(); expect(JSON.parse(`${getByTestId('mock-sqllab-route').textContent}`)).toEqual( @@ -570,235 +580,87 @@ test('should show forbidden dataset state', () => { expect(screen.getByText(error.statusText)).toBeVisible(); }); -test('should allow creating new metrics in dataset editor', async () => { - const newMetricName = `test_metric_${Date.now()}`; - const mockDatasourceWithMetrics = { - ...mockDatasource, - metrics: [], - }; - +test('should fire onDatasourceSave when saving with new metrics', async () => { const props = createProps({ - datasource: mockDatasourceWithMetrics, + datasource: { ...mockDatasource, metrics: [] }, }); - // Mock API calls for dataset editor - fetchMock.get(getDbWithQuery, { response: { result: [] } }); - - fetchMock.get(getDatasetWithAll, { result: mockDatasourceWithMetrics }); - - fetchMock.put(putDatasetWithAll, { - result: { - ...mockDatasourceWithMetrics, - metrics: [{ id: 1, metric_name: newMetricName }], - }, - }); - - SupersetClientGet.mockImplementationOnce( - async () => ({ json: { result: [] } }) as any, - ); - render(, { useRedux: true, useRouter: true, }); - // Open datasource menu and click edit dataset - userEvent.click(screen.getByTestId('datasource-menu-trigger')); - userEvent.click(await screen.findByTestId('edit-dataset')); - - // Wait for modal to appear and navigate to Metrics tab - await waitFor(() => { - expect(screen.getByText('Metrics')).toBeInTheDocument(); + await openAndSaveChanges({ + ...mockDatasource, + metrics: [{ id: 1, metric_name: 'test_metric' }], }); - userEvent.click(screen.getByText('Metrics')); - - // Click add new metric button - const addButton = await screen.findByTestId('crud-add-table-item'); - userEvent.click(addButton); - - // Find and fill in the metric name - const nameInput = await screen.findByTestId('textarea-editable-title-input'); - userEvent.clear(nameInput); - userEvent.type(nameInput, newMetricName); - - // Save the modal - userEvent.click(screen.getByTestId('datasource-modal-save')); - - // Confirm the save - const okButton = await screen.findByText('OK'); - userEvent.click(okButton); - - // Verify the onDatasourceSave callback was called await waitFor(() => { - expect(props.onDatasourceSave).toHaveBeenCalled(); + expect(props.onDatasourceSave).toHaveBeenCalledWith( + expect.objectContaining({ + metrics: [{ id: 1, metric_name: 'test_metric' }], + }), + ); }); }); -test('should allow deleting metrics in dataset editor', async () => { - const existingMetricName = 'existing_metric'; - const mockDatasourceWithMetrics = { - ...mockDatasource, - metrics: [{ id: 1, metric_name: existingMetricName }], - }; - +test('should fire onDatasourceSave when saving with removed metrics', async () => { const props = createProps({ - datasource: mockDatasourceWithMetrics, + datasource: { + ...mockDatasource, + metrics: [{ id: 1, metric_name: 'existing_metric' }], + }, }); - // Mock API calls - fetchMock.get('glob:*/api/v1/database/?q=*', { result: [] }); - - fetchMock.get('glob:*/api/v1/dataset/*', { - result: mockDatasourceWithMetrics, - }); - - fetchMock.put('glob:*/api/v1/dataset/*', { - result: { ...mockDatasourceWithMetrics, metrics: [] }, - }); - - SupersetClientGet.mockImplementationOnce( - async () => ({ json: { result: [] } }) as any, - ); - render(, { useRedux: true, useRouter: true, }); - // Open edit dataset modal - await userEvent.click(screen.getByTestId('datasource-menu-trigger')); - await userEvent.click(await screen.findByTestId('edit-dataset')); + await openAndSaveChanges({ ...mockDatasource, metrics: [] }); - // Navigate to Metrics tab await waitFor(() => { - expect(screen.getByText('Metrics')).toBeInTheDocument(); - }); - await userEvent.click(screen.getByText('Metrics')); - - // Find existing metric and delete it - const metricRow = (await screen.findByText(existingMetricName)).closest('tr'); - expect(metricRow).toBeInTheDocument(); - - const deleteButton = metricRow?.querySelector( - '[data-test="crud-delete-icon"]', - ); - expect(deleteButton).toBeInTheDocument(); - await userEvent.click(deleteButton!); - - // Save the changes - await userEvent.click(screen.getByTestId('datasource-modal-save')); - - // Confirm the save - const okButton = await screen.findByText('OK'); - await userEvent.click(okButton); - - // Verify the onDatasourceSave callback was called - await waitFor(() => { - expect(props.onDatasourceSave).toHaveBeenCalled(); + expect(props.onDatasourceSave).toHaveBeenCalledWith( + expect.objectContaining({ metrics: [] }), + ); }); }); test('should handle metric save confirmation modal', async () => { const props = createProps(); - // Mock API calls for dataset editor - fetchMock.get('glob:*/api/v1/database/?q=*', { result: [] }); - - fetchMock.get('glob:*/api/v1/dataset/*', { result: mockDatasource }); - - fetchMock.put('glob:*/api/v1/dataset/*', { result: mockDatasource }); - - SupersetClientGet.mockImplementationOnce( - async () => ({ json: { result: [] } }) as any, - ); - render(, { useRedux: true, useRouter: true, }); - // Open edit dataset modal - await userEvent.click(screen.getByTestId('datasource-menu-trigger')); - await userEvent.click(await screen.findByTestId('edit-dataset')); + await openAndSaveChanges(mockDatasource); - // Save without making changes - const saveButton = await screen.findByTestId('datasource-modal-save'); - await userEvent.click(saveButton); - - // Verify confirmation modal appears - await waitFor(() => { - expect(screen.getByText('OK')).toBeInTheDocument(); - }); - - // Click OK to confirm - await userEvent.click(screen.getByText('OK')); - - // Verify the save was processed await waitFor(() => { expect(props.onDatasourceSave).toHaveBeenCalled(); }); }); -test('should verify real DatasourceControl callback fires on save', async () => { - // This test verifies that the REAL DatasourceControl component calls onDatasourceSave - // This is simpler than the full metric creation flow but tests the key integration - +test('should fire onDatasourceSave callback on save', async () => { const mockOnDatasourceSave = jest.fn(); const props = createProps({ datasource: mockDatasource, onDatasourceSave: mockOnDatasourceSave, }); - // Mock API calls with the same datasource (no changes needed for this test) - fetchMock.get('glob:*/api/v1/database/?q=*', { result: [] }); - - fetchMock.get('glob:*/api/v1/dataset/*', { result: mockDatasource }); - - fetchMock.put('glob:*/api/v1/dataset/*', { result: mockDatasource }); - - SupersetClientGet.mockImplementationOnce( - async () => ({ json: { result: [] } }) as any, - ); - - // Render the REAL DatasourceControl component render(, { useRedux: true, useRouter: true, }); - // Verify the real component rendered - expect(screen.getByTestId('datasource-control')).toBeInTheDocument(); + await openAndSaveChanges(mockDatasource); - // Open dataset editor - await userEvent.click(screen.getByTestId('datasource-menu-trigger')); - await userEvent.click(await screen.findByTestId('edit-dataset')); - - // Wait for modal to open await waitFor(() => { - expect(screen.getByText('Columns')).toBeInTheDocument(); + expect(mockOnDatasourceSave).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.any(Number), + name: expect.any(String), + }), + ); }); - - // Save without making changes (this should still trigger the callback) - await userEvent.click(screen.getByTestId('datasource-modal-save')); - const okButton = await screen.findByText('OK'); - await userEvent.click(okButton); - - // Verify the REAL component called the callback - // This tests that the integration point works (regardless of what data is passed) - await waitFor(() => { - expect(mockOnDatasourceSave).toHaveBeenCalled(); - }); - - // Verify it was called with a datasource object - expect(mockOnDatasourceSave).toHaveBeenCalledWith( - expect.objectContaining({ - id: expect.any(Number), - name: expect.any(String), - }), - ); }); - -// Note: Cross-component integration test removed due to complex Redux/user context setup -// The existing callback tests provide sufficient coverage for metric creation workflows -// Future enhancement could add MetricsControl integration when test infrastructure supports it diff --git a/superset-frontend/src/pages/FileHandler/index.test.tsx b/superset-frontend/src/pages/FileHandler/index.test.tsx index aa0c9d9bab1..3f7f15471f3 100644 --- a/superset-frontend/src/pages/FileHandler/index.test.tsx +++ b/superset-frontend/src/pages/FileHandler/index.test.tsx @@ -17,7 +17,12 @@ * under the License. */ import { ComponentType } from 'react'; -import { render, screen, waitFor } from 'spec/helpers/testing-library'; +import { + render, + screen, + userEvent, + waitFor, +} from 'spec/helpers/testing-library'; import { MemoryRouter, Route } from 'react-router-dom'; import FileHandler from './index'; @@ -108,6 +113,8 @@ type LaunchQueue = { ) => void; }; +const pendingTimerIds = new Set>(); + const setupLaunchQueue = (fileHandle: MockFileHandle | null = null) => { let savedConsumer: | ((params: { files?: MockFileHandle[] }) => void | Promise) @@ -116,11 +123,13 @@ const setupLaunchQueue = (fileHandle: MockFileHandle | null = null) => { setConsumer: (consumer: (params: { files?: MockFileHandle[] }) => void) => { savedConsumer = consumer; if (fileHandle) { - setTimeout(() => { + const id = setTimeout(() => { + pendingTimerIds.delete(id); consumer({ files: [fileHandle], }); }, 0); + pendingTimerIds.add(id); } }, }; @@ -136,6 +145,12 @@ beforeEach(() => { delete (window as any).launchQueue; }); +afterEach(() => { + pendingTimerIds.forEach(id => clearTimeout(id)); + pendingTimerIds.clear(); + delete (window as any).launchQueue; +}); + test('shows error when launchQueue is not supported', async () => { render( @@ -345,8 +360,7 @@ test('modal close redirects to welcome page', async () => { expect(modal).toBeInTheDocument(); // Click the close button in the mocked modal - const closeButton = screen.getByRole('button', { name: 'Close' }); - closeButton.click(); + await userEvent.click(screen.getByRole('button', { name: 'Close' })); await waitFor(() => { expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');