This is an automated email from the ASF dual-hosted git repository. jli pushed a commit to branch fix-app-prefix-export in repository https://gitbox.apache.org/repos/asf/superset.git
commit 8f9dc880e2f1078c43b65004df5f9ccb32a58b1d Author: Joe Li <[email protected]> AuthorDate: Fri Dec 19 14:57:36 2025 -0800 test(streaming-export): add comprehensive behavior tests for useStreamingExport Add tests covering: - Stream error marker (__STREAM_ERROR__) handling - Happy-path CSV/XLSX export flows with callbacks - HTTP error responses (4xx/5xx) and missing body - cancelExport/abort behavior with state transition - Content-Disposition filename parsing and fallback - Progress updates during multi-chunk streaming - Double startExport prevention guard - Retry without prior export edge case - State reset after success/failure with blob cleanup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> --- .../useStreamingExport.test.ts | 568 +++++++++++++++++++++ 1 file changed, 568 insertions(+) diff --git a/superset-frontend/src/components/StreamingExportModal/useStreamingExport.test.ts b/superset-frontend/src/components/StreamingExportModal/useStreamingExport.test.ts index 3d4b631607..98f20cf14d 100644 --- a/superset-frontend/src/components/StreamingExportModal/useStreamingExport.test.ts +++ b/superset-frontend/src/components/StreamingExportModal/useStreamingExport.test.ts @@ -16,11 +16,16 @@ * specific language governing permissions and limitations * under the License. */ +import { TextEncoder, TextDecoder } from 'util'; import { renderHook, act } from '@testing-library/react-hooks'; import { waitFor } from '@testing-library/react'; import { useStreamingExport } from './useStreamingExport'; import { ExportStatus } from './StreamingExportModal'; +// Polyfill TextEncoder/TextDecoder for Jest environment +global.TextEncoder = TextEncoder; +global.TextDecoder = TextDecoder as typeof global.TextDecoder; + // Mock SupersetClient jest.mock('@superset-ui/core', () => ({ ...jest.requireActual('@superset-ui/core'), @@ -428,3 +433,566 @@ test('URL prefix guard correctly handles sibling paths (prefixes /app2 when appR expect.any(Object), ); }); + +// Streaming export behavior tests + +test('sets ERROR status and calls onError when stream contains __STREAM_ERROR__ marker', async () => { + const errorMessage = 'Database connection failed'; + const errorChunk = new TextEncoder().encode( + `__STREAM_ERROR__:${errorMessage}`, + ); + + let readCount = 0; + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ + 'Content-Disposition': 'attachment; filename="export.csv"', + }), + body: { + getReader: () => ({ + read: jest.fn().mockImplementation(() => { + readCount += 1; + if (readCount === 1) { + return Promise.resolve({ done: false, value: errorChunk }); + } + return Promise.resolve({ done: true, value: undefined }); + }), + }), + }, + }); + global.fetch = mockFetch; + + const onError = jest.fn(); + const { result } = renderHook(() => useStreamingExport({ onError })); + + act(() => { + result.current.startExport({ + url: '/api/v1/sqllab/export_streaming/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + }); + }); + + await waitFor(() => { + expect(result.current.progress.status).toBe(ExportStatus.ERROR); + }); + + expect(result.current.progress.error).toBe(errorMessage); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(errorMessage); +}); + +test('completes CSV export successfully with correct status and downloadUrl', async () => { + applicationRoot.mockReturnValue(''); + const csvData = new TextEncoder().encode('id,name\n1,Alice\n2,Bob\n'); + + let readCount = 0; + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ + 'Content-Disposition': 'attachment; filename="results.csv"', + }), + body: { + getReader: () => ({ + read: jest.fn().mockImplementation(() => { + readCount += 1; + if (readCount === 1) { + return Promise.resolve({ done: false, value: csvData }); + } + return Promise.resolve({ done: true, value: undefined }); + }), + }), + }, + }); + global.fetch = mockFetch; + + const onComplete = jest.fn(); + const { result } = renderHook(() => useStreamingExport({ onComplete })); + + act(() => { + result.current.startExport({ + url: '/api/v1/sqllab/export_streaming/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + }); + }); + + await waitFor(() => { + expect(result.current.progress.status).toBe(ExportStatus.COMPLETED); + }); + + expect(result.current.progress.downloadUrl).toBe('blob:mock-url'); + expect(result.current.progress.filename).toBe('results.csv'); + expect(onComplete).toHaveBeenCalledTimes(1); + expect(onComplete).toHaveBeenCalledWith('blob:mock-url', 'results.csv'); +}); + +test('completes XLSX export successfully with correct filename', async () => { + applicationRoot.mockReturnValue(''); + const xlsxData = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); // XLSX magic bytes + + let readCount = 0; + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ + 'Content-Disposition': 'attachment; filename="report.xlsx"', + }), + body: { + getReader: () => ({ + read: jest.fn().mockImplementation(() => { + readCount += 1; + if (readCount === 1) { + return Promise.resolve({ done: false, value: xlsxData }); + } + return Promise.resolve({ done: true, value: undefined }); + }), + }), + }, + }); + global.fetch = mockFetch; + + const onComplete = jest.fn(); + const { result } = renderHook(() => useStreamingExport({ onComplete })); + + act(() => { + result.current.startExport({ + url: '/api/v1/chart/data', + payload: { datasource: '1__table', viz_type: 'table' }, + exportType: 'xlsx', + }); + }); + + await waitFor(() => { + expect(result.current.progress.status).toBe(ExportStatus.COMPLETED); + }); + + expect(result.current.progress.filename).toBe('report.xlsx'); + expect(onComplete).toHaveBeenCalledWith('blob:mock-url', 'report.xlsx'); +}); + +test('sets ERROR status when response is not ok (4xx/5xx)', async () => { + applicationRoot.mockReturnValue(''); + const mockFetch = jest.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + headers: new Headers({}), + }); + global.fetch = mockFetch; + + const onError = jest.fn(); + const { result } = renderHook(() => useStreamingExport({ onError })); + + act(() => { + result.current.startExport({ + url: '/api/v1/sqllab/export_streaming/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + }); + }); + + await waitFor(() => { + expect(result.current.progress.status).toBe(ExportStatus.ERROR); + }); + + expect(result.current.progress.error).toBe( + 'Export failed: 500 Internal Server Error', + ); + expect(onError).toHaveBeenCalledWith( + 'Export failed: 500 Internal Server Error', + ); +}); + +test('sets ERROR status when response body is missing', async () => { + applicationRoot.mockReturnValue(''); + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + headers: new Headers({}), + body: null, + }); + global.fetch = mockFetch; + + const onError = jest.fn(); + const { result } = renderHook(() => useStreamingExport({ onError })); + + act(() => { + result.current.startExport({ + url: '/api/v1/sqllab/export_streaming/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + }); + }); + + await waitFor(() => { + expect(result.current.progress.status).toBe(ExportStatus.ERROR); + }); + + expect(result.current.progress.error).toBe( + 'Response body is not available for streaming', + ); + expect(onError).toHaveBeenCalledWith( + 'Response body is not available for streaming', + ); +}); + +test('cancelExport sets CANCELLED status and aborts the request', async () => { + applicationRoot.mockReturnValue(''); + let abortSignal: AbortSignal | undefined; + + // Create a reader that will hang until aborted + const mockFetch = jest.fn().mockImplementation((_url, options) => { + abortSignal = options?.signal; + return Promise.resolve({ + ok: true, + headers: new Headers({ + 'Content-Disposition': 'attachment; filename="export.csv"', + }), + body: { + getReader: () => ({ + read: jest.fn().mockImplementation( + () => + new Promise((resolve, reject) => { + // Simulate slow stream that can be aborted + const timeout = setTimeout(() => { + resolve({ + done: false, + value: new TextEncoder().encode('data'), + }); + }, 10000); + abortSignal?.addEventListener('abort', () => { + clearTimeout(timeout); + reject(new Error('Export cancelled by user')); + }); + }), + ), + }), + }, + }); + }); + global.fetch = mockFetch; + + const { result } = renderHook(() => useStreamingExport()); + + act(() => { + result.current.startExport({ + url: '/api/v1/sqllab/export_streaming/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + }); + }); + + // Wait for fetch to be called + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + // Cancel the export + act(() => { + result.current.cancelExport(); + }); + + await waitFor(() => { + expect(result.current.progress.status).toBe(ExportStatus.CANCELLED); + }); +}); + +test('parses filename from Content-Disposition header with quotes', async () => { + applicationRoot.mockReturnValue(''); + const csvData = new TextEncoder().encode('data\n'); + + let readCount = 0; + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ + 'Content-Disposition': 'attachment; filename="my export file.csv"', + }), + body: { + getReader: () => ({ + read: jest.fn().mockImplementation(() => { + readCount += 1; + if (readCount === 1) { + return Promise.resolve({ done: false, value: csvData }); + } + return Promise.resolve({ done: true, value: undefined }); + }), + }), + }, + }); + global.fetch = mockFetch; + + const { result } = renderHook(() => useStreamingExport()); + + act(() => { + result.current.startExport({ + url: '/api/v1/sqllab/export_streaming/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + }); + }); + + await waitFor(() => { + expect(result.current.progress.status).toBe(ExportStatus.COMPLETED); + }); + + expect(result.current.progress.filename).toBe('my export file.csv'); +}); + +test('uses default filename when Content-Disposition header is missing', async () => { + applicationRoot.mockReturnValue(''); + const csvData = new TextEncoder().encode('data\n'); + + let readCount = 0; + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + headers: new Headers({}), + body: { + getReader: () => ({ + read: jest.fn().mockImplementation(() => { + readCount += 1; + if (readCount === 1) { + return Promise.resolve({ done: false, value: csvData }); + } + return Promise.resolve({ done: true, value: undefined }); + }), + }), + }, + }); + global.fetch = mockFetch; + + const { result } = renderHook(() => useStreamingExport()); + + act(() => { + result.current.startExport({ + url: '/api/v1/sqllab/export_streaming/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + }); + }); + + await waitFor(() => { + expect(result.current.progress.status).toBe(ExportStatus.COMPLETED); + }); + + expect(result.current.progress.filename).toBe('export.csv'); +}); + +test('updates progress with rowsProcessed and totalSize during streaming', async () => { + applicationRoot.mockReturnValue(''); + const chunk1 = new TextEncoder().encode('id,name\n1,Alice\n'); + const chunk2 = new TextEncoder().encode('2,Bob\n3,Charlie\n'); + + let readCount = 0; + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ + 'Content-Disposition': 'attachment; filename="export.csv"', + }), + body: { + getReader: () => ({ + read: jest.fn().mockImplementation(() => { + readCount += 1; + if (readCount === 1) { + return Promise.resolve({ done: false, value: chunk1 }); + } + if (readCount === 2) { + return Promise.resolve({ done: false, value: chunk2 }); + } + return Promise.resolve({ done: true, value: undefined }); + }), + }), + }, + }); + global.fetch = mockFetch; + + const { result } = renderHook(() => useStreamingExport()); + + act(() => { + result.current.startExport({ + url: '/api/v1/sqllab/export_streaming/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + expectedRows: 100, + }); + }); + + await waitFor(() => { + expect(result.current.progress.status).toBe(ExportStatus.COMPLETED); + }); + + // Verify final progress reflects data received + expect(result.current.progress.totalSize).toBe(chunk1.length + chunk2.length); + // 4 newlines total (2 in chunk1, 2 in chunk2) + expect(result.current.progress.rowsProcessed).toBe(4); +}); + +test('prevents double startExport calls while export is in progress', async () => { + applicationRoot.mockReturnValue(''); + + // Create a slow reader that takes time to complete + let readCount = 0; + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ + 'Content-Disposition': 'attachment; filename="export.csv"', + }), + body: { + getReader: () => ({ + read: jest.fn().mockImplementation( + () => + new Promise(resolve => { + readCount += 1; + if (readCount === 1) { + // Delay first chunk to simulate in-progress export + setTimeout(() => { + resolve({ + done: false, + value: new TextEncoder().encode('data\n'), + }); + }, 50); + } else { + resolve({ done: true, value: undefined }); + } + }), + ), + }), + }, + }); + global.fetch = mockFetch; + + const { result } = renderHook(() => useStreamingExport()); + + // Start first export + act(() => { + result.current.startExport({ + url: '/api/v1/first/', + payload: { client_id: 'first' }, + exportType: 'csv', + }); + }); + + // Immediately try to start second export + act(() => { + result.current.startExport({ + url: '/api/v1/second/', + payload: { client_id: 'second' }, + exportType: 'csv', + }); + }); + + await waitFor(() => { + expect(result.current.progress.status).toBe(ExportStatus.COMPLETED); + }); + + // Only one fetch call should have been made (first export) + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith('/api/v1/first/', expect.any(Object)); +}); + +test('retryExport does nothing when no prior export exists', async () => { + applicationRoot.mockReturnValue(''); + const mockFetch = jest.fn(); + global.fetch = mockFetch; + + const { result } = renderHook(() => useStreamingExport()); + + // Call retry without ever calling startExport + act(() => { + result.current.retryExport(); + }); + + // Give it time to potentially make a call + await new Promise(resolve => { + setTimeout(resolve, 50); + }); + + // No fetch should have been made + expect(mockFetch).not.toHaveBeenCalled(); +}); + +test('state resets correctly after successful export and resetExport call', async () => { + applicationRoot.mockReturnValue(''); + const csvData = new TextEncoder().encode('data\n'); + + let readCount = 0; + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ + 'Content-Disposition': 'attachment; filename="export.csv"', + }), + body: { + getReader: () => ({ + read: jest.fn().mockImplementation(() => { + readCount += 1; + if (readCount === 1) { + return Promise.resolve({ done: false, value: csvData }); + } + return Promise.resolve({ done: true, value: undefined }); + }), + }), + }, + }); + global.fetch = mockFetch; + + const { result } = renderHook(() => useStreamingExport()); + + act(() => { + result.current.startExport({ + url: '/api/v1/sqllab/export_streaming/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + }); + }); + + await waitFor(() => { + expect(result.current.progress.status).toBe(ExportStatus.COMPLETED); + }); + + // Verify completed state + expect(result.current.progress.downloadUrl).toBe('blob:mock-url'); + + // Reset the export + act(() => { + result.current.resetExport(); + }); + + // Verify state is reset + expect(result.current.progress.status).toBe(ExportStatus.STREAMING); + expect(result.current.progress.rowsProcessed).toBe(0); + expect(result.current.progress.totalSize).toBe(0); + expect(result.current.progress.downloadUrl).toBeUndefined(); + expect(result.current.progress.error).toBeUndefined(); + expect(global.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url'); +}); + +test('state resets correctly after failed export and resetExport call', async () => { + applicationRoot.mockReturnValue(''); + const mockFetch = jest.fn().mockRejectedValue(new Error('Network error')); + global.fetch = mockFetch; + + const { result } = renderHook(() => useStreamingExport()); + + act(() => { + result.current.startExport({ + url: '/api/v1/sqllab/export_streaming/', + payload: { client_id: 'test-id' }, + exportType: 'csv', + }); + }); + + await waitFor(() => { + expect(result.current.progress.status).toBe(ExportStatus.ERROR); + }); + + // Verify error state + expect(result.current.progress.error).toBe('Network error'); + + // Reset the export + act(() => { + result.current.resetExport(); + }); + + // Verify state is reset + expect(result.current.progress.status).toBe(ExportStatus.STREAMING); + expect(result.current.progress.error).toBeUndefined(); + expect(result.current.progress.rowsProcessed).toBe(0); +});
