diff --git a/superset-frontend/src/features/home/ChartTable.test.tsx b/superset-frontend/src/features/home/ChartTable.test.tsx index 35283721f25..18e8867cc3d 100644 --- a/superset-frontend/src/features/home/ChartTable.test.tsx +++ b/superset-frontend/src/features/home/ChartTable.test.tsx @@ -25,8 +25,19 @@ import { import { VizType } from '@superset-ui/core'; import fetchMock from 'fetch-mock'; import { act } from 'react-dom/test-utils'; +import handleResourceExport from 'src/utils/export'; import ChartTable from './ChartTable'; +// Mock the export module +jest.mock('src/utils/export', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const mockExport = handleResourceExport as jest.MockedFunction< + typeof handleResourceExport +>; + const chartsEndpoint = 'glob:*/api/v1/chart/?*'; const chartsInfoEndpoint = 'glob:*/api/v1/chart/_info*'; const chartFavoriteStatusEndpoint = 'glob:*/api/v1/chart/favorite_status*'; @@ -47,7 +58,7 @@ fetchMock.get(chartsEndpoint, { }); fetchMock.get(chartsInfoEndpoint, { - permissions: ['can_add', 'can_edit', 'can_delete'], + permissions: ['can_add', 'can_edit', 'can_delete', 'can_export'], }); fetchMock.get(chartFavoriteStatusEndpoint, { @@ -115,3 +126,53 @@ test('renders mine tab on click', async () => { expect(screen.getAllByText(/cool chart/i)).toHaveLength(3); }); }); + +test('handles chart export with correct ID and shows spinner', async () => { + // Mock export to take some time before calling the done callback + mockExport.mockImplementation( + (resource: string, ids: number[], done: () => void) => + new Promise(resolve => { + setTimeout(() => { + done(); + resolve(); + }, 100); + }), + ); + + await renderChartTable(mineTabProps); + + // Click Mine tab to see charts + userEvent.click(screen.getByText(/mine/i)); + + await waitFor(() => { + expect(screen.getAllByText(/cool chart/i)).toHaveLength(3); + }); + + // Find and click the more options button for the first chart + const moreButtons = screen.getAllByRole('img', { name: /more/i }); + await userEvent.click(moreButtons[0]); + + // Wait for dropdown menu + await waitFor(() => { + expect(screen.getByText('Export')).toBeInTheDocument(); + }); + + const exportOption = screen.getByText('Export'); + await userEvent.click(exportOption); + + // Verify spinner appears during export + await waitFor(() => { + expect(screen.getByRole('status')).toBeInTheDocument(); + }); + + // Verify the export was called with correct parameters + expect(mockExport).toHaveBeenCalledWith('chart', [0], expect.any(Function)); + + // Wait for export to complete and spinner to disappear + await waitFor( + () => { + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }, + { timeout: 3000 }, + ); +}); diff --git a/superset-frontend/src/features/home/ChartTable.tsx b/superset-frontend/src/features/home/ChartTable.tsx index 3f85e49f9af..e86b2a8f67c 100644 --- a/superset-frontend/src/features/home/ChartTable.tsx +++ b/superset-frontend/src/features/home/ChartTable.tsx @@ -132,12 +132,17 @@ function ChartTable({ setLoaded(true); }, [activeTab]); - const handleBulkChartExport = (chartsToExport: Chart[]) => { + const handleBulkChartExport = async (chartsToExport: Chart[]) => { const ids = chartsToExport.map(({ id }) => id); - handleResourceExport('chart', ids, () => { - setPreparingExport(false); - }); setPreparingExport(true); + try { + await handleResourceExport('chart', ids, () => { + setPreparingExport(false); + }); + } catch (error) { + setPreparingExport(false); + addDangerToast(t('There was an issue exporting the selected charts')); + } }; const menuTabs = [ diff --git a/superset-frontend/src/features/home/DashboardTable.test.tsx b/superset-frontend/src/features/home/DashboardTable.test.tsx index 692623fed72..f33673539ad 100644 --- a/superset-frontend/src/features/home/DashboardTable.test.tsx +++ b/superset-frontend/src/features/home/DashboardTable.test.tsx @@ -28,8 +28,19 @@ import { Router } from 'react-router-dom'; import { configureStore } from '@reduxjs/toolkit'; import fetchMock from 'fetch-mock'; import * as hooks from 'src/views/CRUD/hooks'; +import handleResourceExport from 'src/utils/export'; import DashboardTable from './DashboardTable'; +// Mock the export module +jest.mock('src/utils/export', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const mockExport = handleResourceExport as jest.MockedFunction< + typeof handleResourceExport +>; + jest.mock('src/views/CRUD/utils', () => ({ ...jest.requireActual('src/views/CRUD/utils'), handleDashboardDelete: jest @@ -254,12 +265,38 @@ describe('DashboardTable', () => { expect(otherTab).toHaveClass('active'); }); - test('handles bulk dashboard export', async () => { + test('handles bulk dashboard export with correct ID and shows spinner', async () => { + // Mock export to take some time before calling the done callback + mockExport.mockImplementation( + (resource: string, ids: number[], done: () => void) => + new Promise(resolve => { + setTimeout(() => { + done(); + resolve(); + }, 100); + }), + ); + const props = { ...defaultProps, mine: mockDashboards, }; + jest.spyOn(hooks, 'useListViewResource').mockImplementation(() => ({ + state: { + loading: false, + resourceCollection: mockDashboards, + resourceCount: mockDashboards.length, + bulkSelectEnabled: false, + lastFetched: new Date().toISOString(), + }, + setResourceCollection: jest.fn(), + hasPerm: jest.fn().mockReturnValue(true), + refreshData: jest.fn(), + fetchData: jest.fn(), + toggleBulkSelect: jest.fn(), + })); + render( @@ -280,7 +317,25 @@ describe('DashboardTable', () => { const exportOption = screen.getByText('Export'); await userEvent.click(exportOption); - expect(screen.getByRole('status')).toBeInTheDocument(); + // Verify spinner shows up during export + await waitFor(() => { + expect(screen.getByRole('status')).toBeInTheDocument(); + }); + + // Verify the export was called with correct parameters + expect(mockExport).toHaveBeenCalledWith( + 'dashboard', + [1], + expect.any(Function), + ); + + // Wait for export to complete and spinner to disappear + await waitFor( + () => { + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }, + { timeout: 3000 }, + ); }); test('handles dashboard deletion confirmation', async () => { diff --git a/superset-frontend/src/features/home/DashboardTable.tsx b/superset-frontend/src/features/home/DashboardTable.tsx index bd23d455480..efa2256465e 100644 --- a/superset-frontend/src/features/home/DashboardTable.tsx +++ b/superset-frontend/src/features/home/DashboardTable.tsx @@ -118,12 +118,17 @@ function DashboardTable({ setLoaded(true); }, [activeTab]); - const handleBulkDashboardExport = (dashboardsToExport: Dashboard[]) => { + const handleBulkDashboardExport = async (dashboardsToExport: Dashboard[]) => { const ids = dashboardsToExport.map(({ id }) => id); - handleResourceExport('dashboard', ids, () => { - setPreparingExport(false); - }); setPreparingExport(true); + try { + await handleResourceExport('dashboard', ids, () => { + setPreparingExport(false); + }); + } catch (error) { + setPreparingExport(false); + addDangerToast(t('There was an issue exporting the selected dashboards')); + } }; const handleDashboardEdit = (edits: Dashboard) => diff --git a/superset-frontend/src/pages/ChartList/index.tsx b/superset-frontend/src/pages/ChartList/index.tsx index 6f2bfa29af4..2fa2a0c4fc8 100644 --- a/superset-frontend/src/pages/ChartList/index.tsx +++ b/superset-frontend/src/pages/ChartList/index.tsx @@ -241,12 +241,17 @@ function ChartList(props: ChartListProps) { const canDelete = hasPerm('can_write'); const canExport = hasPerm('can_export'); const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }]; - const handleBulkChartExport = (chartsToExport: Chart[]) => { + const handleBulkChartExport = async (chartsToExport: Chart[]) => { const ids = chartsToExport.map(({ id }) => id); - handleResourceExport('chart', ids, () => { - setPreparingExport(false); - }); setPreparingExport(true); + try { + await handleResourceExport('chart', ids, () => { + setPreparingExport(false); + }); + } catch (error) { + setPreparingExport(false); + addDangerToast(t('There was an issue exporting the selected charts')); + } }; function handleBulkChartDelete(chartsToDelete: Chart[]) { diff --git a/superset-frontend/src/pages/DashboardList/index.tsx b/superset-frontend/src/pages/DashboardList/index.tsx index e141bd31f5f..04881e72206 100644 --- a/superset-frontend/src/pages/DashboardList/index.tsx +++ b/superset-frontend/src/pages/DashboardList/index.tsx @@ -274,12 +274,17 @@ function DashboardList(props: DashboardListProps) { ); } - const handleBulkDashboardExport = (dashboardsToExport: Dashboard[]) => { + const handleBulkDashboardExport = async (dashboardsToExport: Dashboard[]) => { const ids = dashboardsToExport.map(({ id }) => id); - handleResourceExport('dashboard', ids, () => { - setPreparingExport(false); - }); setPreparingExport(true); + try { + await handleResourceExport('dashboard', ids, () => { + setPreparingExport(false); + }); + } catch (error) { + setPreparingExport(false); + addDangerToast(t('There was an issue exporting the selected dashboards')); + } }; function handleBulkDashboardDelete(dashboardsToDelete: Dashboard[]) { diff --git a/superset-frontend/src/pages/DatabaseList/index.tsx b/superset-frontend/src/pages/DatabaseList/index.tsx index e3f55a06f23..012b899b3a6 100644 --- a/superset-frontend/src/pages/DatabaseList/index.tsx +++ b/superset-frontend/src/pages/DatabaseList/index.tsx @@ -331,15 +331,20 @@ function DatabaseList({ ]; } - function handleDatabaseExport(database: DatabaseObject) { + async function handleDatabaseExport(database: DatabaseObject) { if (database.id === undefined) { return; } - handleResourceExport('database', [database.id], () => { - setPreparingExport(false); - }); setPreparingExport(true); + try { + await handleResourceExport('database', [database.id], () => { + setPreparingExport(false); + }); + } catch (error) { + setPreparingExport(false); + addDangerToast(t('There was an issue exporting the database')); + } } function handleDatabasePermSync(database: DatabaseObject) { diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx index 9ccf65be4ba..0be5b690374 100644 --- a/superset-frontend/src/pages/DatasetList/index.tsx +++ b/superset-frontend/src/pages/DatasetList/index.tsx @@ -275,12 +275,17 @@ const DatasetList: FunctionComponent = ({ setDatasetCurrentlyDuplicating(dataset); }; - const handleBulkDatasetExport = (datasetsToExport: Dataset[]) => { + const handleBulkDatasetExport = async (datasetsToExport: Dataset[]) => { const ids = datasetsToExport.map(({ id }) => id); - handleResourceExport('dataset', ids, () => { - setPreparingExport(false); - }); setPreparingExport(true); + try { + await handleResourceExport('dataset', ids, () => { + setPreparingExport(false); + }); + } catch (error) { + setPreparingExport(false); + addDangerToast(t('There was an issue exporting the selected datasets')); + } }; const columns = useMemo( diff --git a/superset-frontend/src/pages/SavedQueryList/index.tsx b/superset-frontend/src/pages/SavedQueryList/index.tsx index 33c65aa079b..26c5971e723 100644 --- a/superset-frontend/src/pages/SavedQueryList/index.tsx +++ b/superset-frontend/src/pages/SavedQueryList/index.tsx @@ -291,14 +291,19 @@ function SavedQueryList({ ); }; - const handleBulkSavedQueryExport = ( + const handleBulkSavedQueryExport = async ( savedQueriesToExport: SavedQueryObject[], ) => { const ids = savedQueriesToExport.map(({ id }) => id); - handleResourceExport('saved_query', ids, () => { - setPreparingExport(false); - }); setPreparingExport(true); + try { + await handleResourceExport('saved_query', ids, () => { + setPreparingExport(false); + }); + } catch (error) { + setPreparingExport(false); + addDangerToast(t('There was an issue exporting the selected queries')); + } }; const handleBulkQueryDelete = (queriesToDelete: SavedQueryObject[]) => { diff --git a/superset-frontend/src/pages/ThemeList/index.tsx b/superset-frontend/src/pages/ThemeList/index.tsx index dd32f5f1930..75755170b9d 100644 --- a/superset-frontend/src/pages/ThemeList/index.tsx +++ b/superset-frontend/src/pages/ThemeList/index.tsx @@ -221,14 +221,19 @@ function ThemesList({ setAppliedThemeId(null); } - const handleBulkThemeExport = (themesToExport: ThemeObject[]) => { + const handleBulkThemeExport = async (themesToExport: ThemeObject[]) => { const ids = themesToExport .map(({ id }) => id) .filter((id): id is number => id !== undefined); - handleResourceExport('theme', ids, () => { - setPreparingExport(false); - }); setPreparingExport(true); + try { + await handleResourceExport('theme', ids, () => { + setPreparingExport(false); + }); + } catch (error) { + setPreparingExport(false); + addDangerToast(t('There was an issue exporting the selected themes')); + } }; const openThemeImportModal = () => { diff --git a/superset-frontend/src/utils/export.test.ts b/superset-frontend/src/utils/export.test.ts new file mode 100644 index 00000000000..bfe936bdcd5 --- /dev/null +++ b/superset-frontend/src/utils/export.test.ts @@ -0,0 +1,278 @@ +/** + * 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 { SupersetClient, logging } from '@superset-ui/core'; +import contentDisposition from 'content-disposition'; +import handleResourceExport from './export'; + +// Mock dependencies +jest.mock('@superset-ui/core', () => ({ + SupersetClient: { + get: jest.fn(), + }, + logging: { + warn: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('content-disposition'); + +jest.mock('./pathUtils', () => ({ + ensureAppRoot: jest.fn((path: string) => path), +})); + +let mockBlob: Blob; +let mockResponse: Response; +let createElementSpy: jest.SpyInstance; +let createObjectURLSpy: jest.SpyInstance; +let revokeObjectURLSpy: jest.SpyInstance; + +beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + + // Mock Blob + mockBlob = new Blob(['test data'], { type: 'application/zip' }); + + // Mock Response with Headers + mockResponse = { + headers: new Headers({ + 'Content-Disposition': 'attachment; filename="dashboard_export.zip"', + }), + blob: jest.fn().mockResolvedValue(mockBlob), + } as unknown as Response; + + // Mock SupersetClient.get + (SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse); + + // Mock DOM APIs + const mockAnchor = document.createElement('a'); + mockAnchor.click = jest.fn(); + createElementSpy = jest + .spyOn(document, 'createElement') + .mockReturnValue(mockAnchor); + jest.spyOn(document.body, 'appendChild').mockImplementation(() => mockAnchor); + jest.spyOn(document.body, 'removeChild').mockImplementation(() => mockAnchor); + + // Mock URL.createObjectURL and revokeObjectURL + createObjectURLSpy = jest + .spyOn(window.URL, 'createObjectURL') + .mockReturnValue('blob:mock-url'); + + // Create revokeObjectURL if it doesn't exist + if (!window.URL.revokeObjectURL) { + window.URL.revokeObjectURL = jest.fn(); + } + revokeObjectURLSpy = jest.spyOn(window.URL, 'revokeObjectURL'); +}); + +afterEach(() => { + createElementSpy.mockRestore(); + createObjectURLSpy.mockRestore(); + if (revokeObjectURLSpy) { + revokeObjectURLSpy.mockRestore(); + } +}); + +test('exports resource with correct endpoint and headers', async () => { + const doneMock = jest.fn(); + await handleResourceExport('dashboard', [1, 2, 3], doneMock); + + expect(SupersetClient.get).toHaveBeenCalledWith({ + endpoint: '/api/v1/dashboard/export/?q=!(1,2,3)', + headers: { + Accept: 'application/zip, application/x-zip-compressed, text/plain', + }, + parseMethod: 'raw', + }); +}); + +test('creates blob and triggers download', async () => { + const doneMock = jest.fn(); + await handleResourceExport('dashboard', [1], doneMock); + + // Check that blob was created + expect(mockResponse.blob).toHaveBeenCalled(); + + // Check that object URL was created + expect(window.URL.createObjectURL).toHaveBeenCalledWith(mockBlob); + + // Check that anchor element was created and configured + expect(document.createElement).toHaveBeenCalledWith('a'); + const anchor = document.createElement('a'); + expect(anchor.href).toBe('blob:mock-url'); + expect(anchor.download).toBe('dashboard_export.zip'); + // eslint-disable-next-line jest-dom/prefer-to-have-style -- toHaveStyle not available without React Testing Library + expect(anchor.style.display).toBe('none'); + + // Check that click was triggered + expect(anchor.click).toHaveBeenCalled(); + + // Check cleanup + expect(document.body.removeChild).toHaveBeenCalled(); + expect(window.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url'); +}); + +test('calls done callback on success', async () => { + const doneMock = jest.fn(); + await handleResourceExport('dashboard', [1], doneMock); + + expect(doneMock).toHaveBeenCalled(); +}); + +test('uses default filename when Content-Disposition is missing', async () => { + mockResponse = { + headers: new Headers(), + blob: jest.fn().mockResolvedValue(mockBlob), + } as unknown as Response; + (SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse); + + const doneMock = jest.fn(); + await handleResourceExport('chart', [42], doneMock); + + const anchor = document.createElement('a'); + expect(anchor.download).toBe('chart_export.zip'); +}); + +test('handles Content-Disposition parsing errors gracefully', async () => { + (contentDisposition.parse as jest.Mock).mockImplementationOnce(() => { + throw new Error('Invalid header'); + }); + + const doneMock = jest.fn(); + await handleResourceExport('dashboard', [1], doneMock); + + // Should fall back to default filename + const anchor = document.createElement('a'); + expect(anchor.download).toBe('dashboard_export.zip'); + expect(doneMock).toHaveBeenCalled(); +}); + +test('handles API errors and calls done callback', async () => { + const apiError = new Error('API Error'); + (SupersetClient.get as jest.Mock).mockRejectedValue(apiError); + + const doneMock = jest.fn(); + + await expect( + handleResourceExport('dashboard', [1], doneMock), + ).rejects.toThrow('API Error'); + + expect(doneMock).toHaveBeenCalled(); +}); + +test('handles blob conversion errors', async () => { + const blobError = new Error('Blob conversion failed'); + mockResponse.blob = jest.fn().mockRejectedValue(blobError); + (SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse); + + const doneMock = jest.fn(); + + await expect( + handleResourceExport('dashboard', [1], doneMock), + ).rejects.toThrow('Blob conversion failed'); + + expect(doneMock).toHaveBeenCalled(); +}); + +test('exports multiple resources with correct IDs', async () => { + const doneMock = jest.fn(); + await handleResourceExport('dataset', [10, 20, 30, 40], doneMock); + + expect(SupersetClient.get).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: '/api/v1/dataset/export/?q=!(10,20,30,40)', + }), + ); +}); + +test('parses filename from Content-Disposition with quotes', async () => { + (contentDisposition.parse as jest.Mock).mockReturnValueOnce({ + type: 'attachment', + parameters: { filename: 'my_custom_export.zip' }, + }); + + const doneMock = jest.fn(); + await handleResourceExport('dashboard', [1], doneMock); + + const anchor = document.createElement('a'); + expect(anchor.download).toBe('my_custom_export.zip'); +}); + +test('warns when export exceeds maximum blob size', async () => { + const largeFileSize = 150 * 1024 * 1024; // 150MB + + mockResponse = { + headers: new Headers({ + 'Content-Length': largeFileSize.toString(), + 'Content-Disposition': 'attachment; filename="large_export.zip"', + }), + blob: jest.fn().mockResolvedValue(mockBlob), + } as unknown as Response; + (SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse); + + const doneMock = jest.fn(); + await handleResourceExport('dashboard', [1], doneMock); + + expect(logging.warn).toHaveBeenCalledWith( + expect.stringContaining('exceeds maximum blob size'), + ); + expect(doneMock).toHaveBeenCalled(); +}); + +test('handles various resource types', async () => { + const doneMock = jest.fn(); + + await handleResourceExport('dashboard', [1], doneMock); + expect(SupersetClient.get).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: '/api/v1/dashboard/export/?q=!(1)', + }), + ); + + await handleResourceExport('chart', [1], doneMock); + expect(SupersetClient.get).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: '/api/v1/chart/export/?q=!(1)', + }), + ); + + await handleResourceExport('dataset', [1], doneMock); + expect(SupersetClient.get).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: '/api/v1/dataset/export/?q=!(1)', + }), + ); + + await handleResourceExport('database', [1], doneMock); + expect(SupersetClient.get).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: '/api/v1/database/export/?q=!(1)', + }), + ); + + await handleResourceExport('query', [1], doneMock); + expect(SupersetClient.get).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: '/api/v1/query/export/?q=!(1)', + }), + ); + + expect(doneMock).toHaveBeenCalledTimes(5); +}); diff --git a/superset-frontend/src/utils/export.ts b/superset-frontend/src/utils/export.ts index 2f2550824dc..748afd9f1f4 100644 --- a/superset-frontend/src/utils/export.ts +++ b/superset-frontend/src/utils/export.ts @@ -16,34 +16,84 @@ * specific language governing permissions and limitations * under the License. */ -import parseCookie from 'src/utils/parseCookie'; +import { SupersetClient, logging } from '@superset-ui/core'; import rison from 'rison'; -import { nanoid } from 'nanoid'; +import contentDisposition from 'content-disposition'; import { ensureAppRoot } from './pathUtils'; -export default function handleResourceExport( +// Maximum blob size for in-memory downloads (100MB) +const MAX_BLOB_SIZE = 100 * 1024 * 1024; + +/** + * Downloads a blob as a file using a temporary anchor element + * @param blob - The blob to download + * @param fileName - The filename to use for the download + */ +function downloadBlob(blob: Blob, fileName: string): void { + const url = window.URL.createObjectURL(blob); + try { + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } finally { + window.URL.revokeObjectURL(url); + } +} + +export default async function handleResourceExport( resource: string, ids: number[], done: () => void, - interval = 200, -): void { - const token = nanoid(); - const url = ensureAppRoot( - `/api/v1/${resource}/export/?q=${rison.encode(ids)}&token=${token}`, +): Promise { + const endpoint = ensureAppRoot( + `/api/v1/${resource}/export/?q=${rison.encode(ids)}`, ); - // create new iframe for export - const iframe = document.createElement('iframe'); - iframe.style.display = 'none'; - iframe.src = url; - document.body.appendChild(iframe); + try { + // Use fetch with blob response instead of iframe to avoid CSP frame-src violations + const response = await SupersetClient.get({ + endpoint, + headers: { + Accept: 'application/zip, application/x-zip-compressed, text/plain', + }, + parseMethod: 'raw', + }); - const timer = window.setInterval(() => { - const cookie: { [cookieId: string]: string } = parseCookie(); - if (cookie[token] === 'done') { - window.clearInterval(timer); - document.body.removeChild(iframe); - done(); + // Check content length to prevent memory issues with large exports + const contentLength = response.headers.get('Content-Length'); + if (contentLength && parseInt(contentLength, 10) > MAX_BLOB_SIZE) { + logging.warn( + `Export file size (${contentLength} bytes) exceeds maximum blob size (${MAX_BLOB_SIZE} bytes). Large exports may cause memory issues.`, + ); } - }, interval); + + // Parse filename from Content-Disposition header + const disposition = response.headers.get('Content-Disposition'); + let fileName = `${resource}_export.zip`; + + if (disposition) { + try { + const parsed = contentDisposition.parse(disposition); + if (parsed?.parameters?.filename) { + fileName = parsed.parameters.filename; + } + } catch (error) { + logging.warn('Failed to parse Content-Disposition header:', error); + } + } + + // Convert response to blob and trigger download + const blob = await response.blob(); + downloadBlob(blob, fileName); + + done(); + } catch (error) { + logging.error('Resource export failed:', error); + done(); + throw error; + } }