mirror of
https://github.com/apache/superset.git
synced 2026-07-19 05:05:39 +00:00
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: sadpandajoe <jcli38@gmail.com>
424 lines
12 KiB
TypeScript
424 lines
12 KiB
TypeScript
/**
|
|
* 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 { ComponentType } from 'react';
|
|
import {
|
|
render,
|
|
screen,
|
|
userEvent,
|
|
waitFor,
|
|
} from 'spec/helpers/testing-library';
|
|
import { MemoryRouter, Route } from 'react-router-dom';
|
|
import FileHandler from './index';
|
|
|
|
const mockAddDangerToast = jest.fn();
|
|
const mockAddSuccessToast = jest.fn();
|
|
const mockHistoryPush = jest.fn();
|
|
|
|
jest.setTimeout(60000);
|
|
|
|
type ToastInjectedProps = {
|
|
addDangerToast: (msg: string) => void;
|
|
addSuccessToast: (msg: string) => void;
|
|
};
|
|
|
|
// Mock the withToasts HOC
|
|
jest.mock('src/components/MessageToasts/withToasts', () => ({
|
|
__esModule: true,
|
|
default: (Component: ComponentType<ToastInjectedProps>) =>
|
|
function MockedWithToasts(props: Record<string, unknown>) {
|
|
return (
|
|
<Component
|
|
{...props}
|
|
addDangerToast={mockAddDangerToast}
|
|
addSuccessToast={mockAddSuccessToast}
|
|
/>
|
|
);
|
|
},
|
|
}));
|
|
|
|
interface UploadDataModalProps {
|
|
show: boolean;
|
|
onHide: () => void;
|
|
type: string;
|
|
allowedExtensions: string[];
|
|
fileListOverride?: File[];
|
|
}
|
|
|
|
// Mock the UploadDataModal
|
|
jest.mock('src/features/databases/UploadDataModel', () => ({
|
|
__esModule: true,
|
|
default: ({
|
|
show,
|
|
onHide,
|
|
type,
|
|
allowedExtensions,
|
|
fileListOverride,
|
|
}: UploadDataModalProps) => (
|
|
<div data-test="upload-modal">
|
|
<div data-test="modal-show">{show.toString()}</div>
|
|
<div data-test="modal-type">{type}</div>
|
|
<div data-test="modal-extensions">{allowedExtensions.join(',')}</div>
|
|
<div data-test="modal-file">{fileListOverride?.[0]?.name ?? ''}</div>
|
|
<button onClick={onHide} type="button">
|
|
Close
|
|
</button>
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
// Mock react-router-dom's useHistory
|
|
jest.mock('react-router-dom', () => ({
|
|
...jest.requireActual('react-router-dom'),
|
|
useHistory: () => ({
|
|
push: mockHistoryPush,
|
|
}),
|
|
}));
|
|
|
|
// Mock the File API
|
|
type MockFileHandle = {
|
|
kind: 'file';
|
|
name: string;
|
|
getFile: () => Promise<File>;
|
|
isSameEntry: () => Promise<boolean>;
|
|
queryPermission: () => Promise<PermissionState>;
|
|
requestPermission: () => Promise<PermissionState>;
|
|
};
|
|
|
|
const createMockFileHandle = (fileName: string): MockFileHandle => ({
|
|
kind: 'file',
|
|
name: fileName,
|
|
getFile: async () => new File(['test'], fileName),
|
|
isSameEntry: async () => false,
|
|
queryPermission: async () => 'granted',
|
|
requestPermission: async () => 'granted',
|
|
});
|
|
|
|
type LaunchQueue = {
|
|
setConsumer: (
|
|
consumer: (params: { files?: MockFileHandle[] }) => void,
|
|
) => void;
|
|
};
|
|
|
|
const pendingTimerIds = new Set<ReturnType<typeof setTimeout>>();
|
|
const MAX_CONSUMER_POLL_ATTEMPTS = 50;
|
|
const consumerPromises: Promise<void>[] = [];
|
|
|
|
// Defer the consumer call to a macrotask so it doesn't fire synchronously inside
|
|
// the component's useEffect — calling it inline deadlocks Jest because the
|
|
// MessageChannel mock in jsDomWithFetchAPI forces React to schedule via setTimeout.
|
|
const setupLaunchQueue = (fileHandle: MockFileHandle | null = null) => {
|
|
let savedConsumer:
|
|
| ((params: { files?: MockFileHandle[] }) => void | Promise<void>)
|
|
| null = null;
|
|
(window as unknown as Window & { launchQueue: LaunchQueue }).launchQueue = {
|
|
setConsumer: (consumer: (params: { files?: MockFileHandle[] }) => void) => {
|
|
savedConsumer = consumer;
|
|
if (fileHandle) {
|
|
const id = setTimeout(() => {
|
|
pendingTimerIds.delete(id);
|
|
consumerPromises.push(
|
|
Promise.resolve(consumer({ files: [fileHandle] })).then(
|
|
() => undefined,
|
|
),
|
|
);
|
|
}, 0);
|
|
pendingTimerIds.add(id);
|
|
}
|
|
},
|
|
};
|
|
return {
|
|
triggerConsumer: async (params: { files?: MockFileHandle[] }) => {
|
|
// In slower CI runners, useEffect may not have registered the consumer yet.
|
|
// Wait briefly for it before triggering.
|
|
let attempts = 0;
|
|
while (!savedConsumer && attempts < MAX_CONSUMER_POLL_ATTEMPTS) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await new Promise(resolve => {
|
|
setTimeout(resolve, 0);
|
|
});
|
|
attempts += 1;
|
|
}
|
|
if (!savedConsumer) {
|
|
throw new Error(
|
|
`LaunchQueue consumer was never registered after ${MAX_CONSUMER_POLL_ATTEMPTS} polling attempts`,
|
|
);
|
|
}
|
|
await savedConsumer(params);
|
|
},
|
|
};
|
|
};
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
delete (window as unknown as Window & { launchQueue?: LaunchQueue })
|
|
.launchQueue;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
pendingTimerIds.forEach(id => clearTimeout(id));
|
|
pendingTimerIds.clear();
|
|
if (consumerPromises.length > 0) {
|
|
const results = await Promise.allSettled(consumerPromises);
|
|
results.forEach(r => {
|
|
if (r.status === 'rejected') {
|
|
// eslint-disable-next-line no-console
|
|
console.warn('LaunchQueue consumer rejected:', r.reason);
|
|
}
|
|
});
|
|
consumerPromises.length = 0;
|
|
}
|
|
delete (window as unknown as Window & { launchQueue?: LaunchQueue })
|
|
.launchQueue;
|
|
});
|
|
|
|
test('shows error when launchQueue is not supported', async () => {
|
|
render(
|
|
<MemoryRouter initialEntries={['/superset/file-handler']}>
|
|
<Route path="/superset/file-handler">
|
|
<FileHandler />
|
|
</Route>
|
|
</MemoryRouter>,
|
|
{ useRedux: true },
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(mockAddDangerToast).toHaveBeenCalledWith(
|
|
'File handling is not supported in this browser. Please use a modern browser like Chrome or Edge.',
|
|
);
|
|
expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
|
|
});
|
|
});
|
|
|
|
test('redirects when no files are provided', async () => {
|
|
const { triggerConsumer } = setupLaunchQueue();
|
|
|
|
render(
|
|
<MemoryRouter initialEntries={['/superset/file-handler']}>
|
|
<Route path="/superset/file-handler">
|
|
<FileHandler />
|
|
</Route>
|
|
</MemoryRouter>,
|
|
{ useRedux: true },
|
|
);
|
|
|
|
// Trigger the consumer with no files
|
|
await triggerConsumer({ files: [] });
|
|
|
|
await waitFor(() => {
|
|
expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
|
|
});
|
|
});
|
|
|
|
// eslint-disable-next-line jest/no-disabled-tests
|
|
test.skip('handles CSV file correctly', async () => {
|
|
const fileHandle = createMockFileHandle('test.csv');
|
|
setupLaunchQueue(fileHandle);
|
|
|
|
render(
|
|
<MemoryRouter initialEntries={['/superset/file-handler']}>
|
|
<Route path="/superset/file-handler">
|
|
<FileHandler />
|
|
</Route>
|
|
</MemoryRouter>,
|
|
{ useRedux: true },
|
|
);
|
|
|
|
const modal = await screen.findByTestId('upload-modal');
|
|
expect(modal).toBeInTheDocument();
|
|
expect(screen.getByTestId('modal-show')).toHaveTextContent('true');
|
|
expect(screen.getByTestId('modal-type')).toHaveTextContent('csv');
|
|
expect(screen.getByTestId('modal-extensions')).toHaveTextContent('csv');
|
|
expect(screen.getByTestId('modal-file')).toHaveTextContent('test.csv');
|
|
});
|
|
|
|
test('handles Excel (.xls) file correctly', async () => {
|
|
const fileHandle = createMockFileHandle('test.xls');
|
|
setupLaunchQueue(fileHandle);
|
|
|
|
render(
|
|
<MemoryRouter initialEntries={['/superset/file-handler']}>
|
|
<Route path="/superset/file-handler">
|
|
<FileHandler />
|
|
</Route>
|
|
</MemoryRouter>,
|
|
{ useRedux: true },
|
|
);
|
|
|
|
const modal = await screen.findByTestId('upload-modal');
|
|
expect(modal).toBeInTheDocument();
|
|
expect(screen.getByTestId('modal-type')).toHaveTextContent('excel');
|
|
expect(screen.getByTestId('modal-extensions')).toHaveTextContent('xls,xlsx');
|
|
});
|
|
|
|
test('handles Excel (.xlsx) file correctly', async () => {
|
|
const fileHandle = createMockFileHandle('test.xlsx');
|
|
const { triggerConsumer } = setupLaunchQueue();
|
|
|
|
render(
|
|
<MemoryRouter initialEntries={['/superset/file-handler']}>
|
|
<Route path="/superset/file-handler">
|
|
<FileHandler />
|
|
</Route>
|
|
</MemoryRouter>,
|
|
{ useRedux: true },
|
|
);
|
|
|
|
await triggerConsumer({ files: [fileHandle] });
|
|
|
|
const modal = await screen.findByTestId('upload-modal');
|
|
expect(modal).toBeInTheDocument();
|
|
expect(screen.getByTestId('modal-type')).toHaveTextContent('excel');
|
|
expect(screen.getByTestId('modal-extensions')).toHaveTextContent('xls,xlsx');
|
|
}, 60000);
|
|
|
|
test('handles Parquet file correctly', async () => {
|
|
const fileHandle = createMockFileHandle('test.parquet');
|
|
setupLaunchQueue(fileHandle);
|
|
|
|
render(
|
|
<MemoryRouter initialEntries={['/superset/file-handler']}>
|
|
<Route path="/superset/file-handler">
|
|
<FileHandler />
|
|
</Route>
|
|
</MemoryRouter>,
|
|
{ useRedux: true },
|
|
);
|
|
|
|
const modal = await screen.findByTestId('upload-modal');
|
|
expect(modal).toBeInTheDocument();
|
|
expect(screen.getByTestId('modal-type')).toHaveTextContent('columnar');
|
|
expect(screen.getByTestId('modal-extensions')).toHaveTextContent('parquet');
|
|
});
|
|
|
|
test('shows error for unsupported file type', async () => {
|
|
const { triggerConsumer } = setupLaunchQueue();
|
|
|
|
render(
|
|
<MemoryRouter initialEntries={['/superset/file-handler']}>
|
|
<Route path="/superset/file-handler">
|
|
<FileHandler />
|
|
</Route>
|
|
</MemoryRouter>,
|
|
{ useRedux: true },
|
|
);
|
|
|
|
// Trigger with unsupported file
|
|
const fileHandle = createMockFileHandle('test.pdf');
|
|
await triggerConsumer({ files: [fileHandle] });
|
|
|
|
await waitFor(() => {
|
|
expect(mockAddDangerToast).toHaveBeenCalledWith(
|
|
'Unsupported file type. Please use CSV, Excel, or Columnar files.',
|
|
);
|
|
expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
|
|
});
|
|
});
|
|
|
|
test('handles file with uppercase extension', async () => {
|
|
const fileHandle = createMockFileHandle('test.CSV');
|
|
setupLaunchQueue(fileHandle);
|
|
|
|
render(
|
|
<MemoryRouter initialEntries={['/superset/file-handler']}>
|
|
<Route path="/superset/file-handler">
|
|
<FileHandler />
|
|
</Route>
|
|
</MemoryRouter>,
|
|
{ useRedux: true },
|
|
);
|
|
|
|
const modal = await screen.findByTestId('upload-modal');
|
|
expect(modal).toBeInTheDocument();
|
|
expect(screen.getByTestId('modal-type')).toHaveTextContent('csv');
|
|
});
|
|
|
|
test('handles errors during file processing', async () => {
|
|
const { triggerConsumer } = setupLaunchQueue();
|
|
|
|
render(
|
|
<MemoryRouter initialEntries={['/superset/file-handler']}>
|
|
<Route path="/superset/file-handler">
|
|
<FileHandler />
|
|
</Route>
|
|
</MemoryRouter>,
|
|
{ useRedux: true },
|
|
);
|
|
|
|
// Trigger with a file handle that throws an error
|
|
const errorFileHandle: MockFileHandle = {
|
|
kind: 'file',
|
|
name: 'error.csv',
|
|
getFile: async () => {
|
|
throw new Error('File access denied');
|
|
},
|
|
isSameEntry: async () => false,
|
|
queryPermission: async () => 'granted',
|
|
requestPermission: async () => 'granted',
|
|
};
|
|
|
|
await triggerConsumer({ files: [errorFileHandle] });
|
|
|
|
await waitFor(() => {
|
|
expect(mockAddDangerToast).toHaveBeenCalledWith(
|
|
'Failed to open file. Please try again.',
|
|
);
|
|
expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
|
|
});
|
|
});
|
|
|
|
test('modal close redirects to welcome page', async () => {
|
|
const fileHandle = createMockFileHandle('test.csv');
|
|
setupLaunchQueue(fileHandle);
|
|
|
|
render(
|
|
<MemoryRouter initialEntries={['/superset/file-handler']}>
|
|
<Route path="/superset/file-handler">
|
|
<FileHandler />
|
|
</Route>
|
|
</MemoryRouter>,
|
|
{ useRedux: true },
|
|
);
|
|
|
|
const modal = await screen.findByTestId('upload-modal');
|
|
expect(modal).toBeInTheDocument();
|
|
|
|
// Click the close button in the mocked modal
|
|
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
|
|
});
|
|
});
|
|
|
|
test('shows loading state while waiting for file', () => {
|
|
setupLaunchQueue();
|
|
|
|
render(
|
|
<MemoryRouter initialEntries={['/superset/file-handler']}>
|
|
<Route path="/superset/file-handler">
|
|
<FileHandler />
|
|
</Route>
|
|
</MemoryRouter>,
|
|
{ useRedux: true },
|
|
);
|
|
|
|
// Should show loading initially before file is processed
|
|
expect(screen.getByRole('status')).toBeInTheDocument();
|
|
});
|