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

Reply via email to