mirror of
https://github.com/apache/superset.git
synced 2026-04-07 10:31:50 +00:00
404 lines
12 KiB
TypeScript
404 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 {
|
|
test as testWithAssets,
|
|
expect,
|
|
} from '../../../helpers/fixtures/testAssets';
|
|
import { DashboardListPage } from '../../../pages/DashboardListPage';
|
|
import { DeleteConfirmationModal } from '../../../components/modals/DeleteConfirmationModal';
|
|
import { ImportDatasetModal } from '../../../components/modals/ImportDatasetModal';
|
|
import { Toast } from '../../../components/core/Toast';
|
|
import {
|
|
apiGetDashboard,
|
|
apiDeleteDashboard,
|
|
apiExportDashboards,
|
|
getDashboardByName,
|
|
ENDPOINTS,
|
|
} from '../../../helpers/api/dashboard';
|
|
import { createTestDashboard } from './dashboard-test-helpers';
|
|
import { waitForGet, waitForPost } from '../../../helpers/api/intercepts';
|
|
import {
|
|
expectStatusOneOf,
|
|
expectValidExportZip,
|
|
} from '../../../helpers/api/assertions';
|
|
import { TIMEOUT } from '../../../utils/constants';
|
|
|
|
/**
|
|
* Extend testWithAssets with dashboardListPage navigation (beforeEach equivalent).
|
|
*/
|
|
const test = testWithAssets.extend<{ dashboardListPage: DashboardListPage }>({
|
|
dashboardListPage: async ({ page }, use) => {
|
|
const dashboardListPage = new DashboardListPage(page);
|
|
await dashboardListPage.goto();
|
|
await dashboardListPage.waitForTableLoad();
|
|
await use(dashboardListPage);
|
|
},
|
|
});
|
|
|
|
test('should delete a dashboard with confirmation', async ({
|
|
page,
|
|
dashboardListPage,
|
|
testAssets,
|
|
}) => {
|
|
// Create throwaway dashboard for deletion
|
|
const { id: dashboardId, name: dashboardName } = await createTestDashboard(
|
|
page,
|
|
testAssets,
|
|
test.info(),
|
|
{ prefix: 'test_delete' },
|
|
);
|
|
|
|
// Refresh to see the new dashboard (created via API)
|
|
await dashboardListPage.goto();
|
|
await dashboardListPage.waitForTableLoad();
|
|
|
|
// Verify dashboard is visible in list
|
|
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible();
|
|
|
|
// Click delete action button
|
|
await dashboardListPage.clickDeleteAction(dashboardName);
|
|
|
|
// Delete confirmation modal should appear
|
|
const deleteModal = new DeleteConfirmationModal(page);
|
|
await deleteModal.waitForVisible();
|
|
|
|
// Type "DELETE" to confirm
|
|
await deleteModal.fillConfirmationInput('DELETE');
|
|
|
|
// Click the Delete button
|
|
await deleteModal.clickDelete();
|
|
|
|
// Modal should close
|
|
await deleteModal.waitForHidden();
|
|
|
|
// Verify success toast appears
|
|
const toast = new Toast(page);
|
|
await expect(toast.getSuccess()).toBeVisible();
|
|
|
|
// Verify dashboard is removed from list
|
|
await expect(
|
|
dashboardListPage.getDashboardRow(dashboardName),
|
|
).not.toBeVisible();
|
|
|
|
// Backend verification: API returns 404
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const response = await apiGetDashboard(page, dashboardId, {
|
|
failOnStatusCode: false,
|
|
});
|
|
return response.status();
|
|
},
|
|
{ timeout: 10000, message: `Dashboard ${dashboardId} should return 404` },
|
|
)
|
|
.toBe(404);
|
|
});
|
|
|
|
test('should export a dashboard as a zip file', async ({
|
|
page,
|
|
dashboardListPage,
|
|
testAssets,
|
|
}) => {
|
|
// Create throwaway dashboard for export
|
|
const { name: dashboardName } = await createTestDashboard(
|
|
page,
|
|
testAssets,
|
|
test.info(),
|
|
{ prefix: 'test_export' },
|
|
);
|
|
|
|
// Refresh to see the new dashboard
|
|
await dashboardListPage.goto();
|
|
await dashboardListPage.waitForTableLoad();
|
|
|
|
// Verify dashboard is visible in list
|
|
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible();
|
|
|
|
// Set up API response intercept for export endpoint
|
|
const exportResponsePromise = waitForGet(page, ENDPOINTS.DASHBOARD_EXPORT);
|
|
|
|
// Click export action button
|
|
await dashboardListPage.clickExportAction(dashboardName);
|
|
|
|
// Wait for export API response and validate zip contents
|
|
const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]);
|
|
await expectValidExportZip(exportResponse, {
|
|
resourceDir: 'dashboards',
|
|
expectedNames: [dashboardName],
|
|
});
|
|
});
|
|
|
|
test('should bulk delete multiple dashboards', async ({
|
|
page,
|
|
dashboardListPage,
|
|
testAssets,
|
|
}) => {
|
|
test.setTimeout(60_000);
|
|
|
|
// Create 2 throwaway dashboards for bulk delete
|
|
const [dashboard1, dashboard2] = await Promise.all([
|
|
createTestDashboard(page, testAssets, test.info(), {
|
|
prefix: 'bulk_delete_1',
|
|
}),
|
|
createTestDashboard(page, testAssets, test.info(), {
|
|
prefix: 'bulk_delete_2',
|
|
}),
|
|
]);
|
|
|
|
// Refresh to see new dashboards
|
|
await dashboardListPage.goto();
|
|
await dashboardListPage.waitForTableLoad();
|
|
|
|
// Verify both dashboards are visible in list
|
|
await expect(
|
|
dashboardListPage.getDashboardRow(dashboard1.name),
|
|
).toBeVisible();
|
|
await expect(
|
|
dashboardListPage.getDashboardRow(dashboard2.name),
|
|
).toBeVisible();
|
|
|
|
// Enable bulk select mode
|
|
await dashboardListPage.clickBulkSelectButton();
|
|
|
|
// Select both dashboards
|
|
await dashboardListPage.selectDashboardCheckbox(dashboard1.name);
|
|
await dashboardListPage.selectDashboardCheckbox(dashboard2.name);
|
|
|
|
// Click bulk delete action
|
|
await dashboardListPage.clickBulkAction('Delete');
|
|
|
|
// Delete confirmation modal should appear
|
|
const deleteModal = new DeleteConfirmationModal(page);
|
|
await deleteModal.waitForVisible();
|
|
|
|
// Type "DELETE" to confirm
|
|
await deleteModal.fillConfirmationInput('DELETE');
|
|
|
|
// Click the Delete button
|
|
await deleteModal.clickDelete();
|
|
|
|
// Modal should close
|
|
await deleteModal.waitForHidden();
|
|
|
|
// Verify success toast appears
|
|
const toast = new Toast(page);
|
|
await expect(toast.getSuccess()).toBeVisible();
|
|
|
|
// Verify both dashboards are removed from list
|
|
await expect(
|
|
dashboardListPage.getDashboardRow(dashboard1.name),
|
|
).not.toBeVisible();
|
|
await expect(
|
|
dashboardListPage.getDashboardRow(dashboard2.name),
|
|
).not.toBeVisible();
|
|
|
|
// Backend verification: Both return 404
|
|
for (const dashboard of [dashboard1, dashboard2]) {
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const response = await apiGetDashboard(page, dashboard.id, {
|
|
failOnStatusCode: false,
|
|
});
|
|
return response.status();
|
|
},
|
|
{
|
|
timeout: 10000,
|
|
message: `Dashboard ${dashboard.id} should return 404`,
|
|
},
|
|
)
|
|
.toBe(404);
|
|
}
|
|
});
|
|
|
|
test('should bulk export multiple dashboards', async ({
|
|
page,
|
|
dashboardListPage,
|
|
testAssets,
|
|
}) => {
|
|
// Create 2 throwaway dashboards for bulk export
|
|
const [dashboard1, dashboard2] = await Promise.all([
|
|
createTestDashboard(page, testAssets, test.info(), {
|
|
prefix: 'bulk_export_1',
|
|
}),
|
|
createTestDashboard(page, testAssets, test.info(), {
|
|
prefix: 'bulk_export_2',
|
|
}),
|
|
]);
|
|
|
|
// Refresh to see new dashboards
|
|
await dashboardListPage.goto();
|
|
await dashboardListPage.waitForTableLoad();
|
|
|
|
// Verify both dashboards are visible in list
|
|
await expect(
|
|
dashboardListPage.getDashboardRow(dashboard1.name),
|
|
).toBeVisible();
|
|
await expect(
|
|
dashboardListPage.getDashboardRow(dashboard2.name),
|
|
).toBeVisible();
|
|
|
|
// Enable bulk select mode
|
|
await dashboardListPage.clickBulkSelectButton();
|
|
|
|
// Select both dashboards
|
|
await dashboardListPage.selectDashboardCheckbox(dashboard1.name);
|
|
await dashboardListPage.selectDashboardCheckbox(dashboard2.name);
|
|
|
|
// Set up API response intercept for export endpoint
|
|
const exportResponsePromise = waitForGet(page, ENDPOINTS.DASHBOARD_EXPORT);
|
|
|
|
// Click bulk export action
|
|
await dashboardListPage.clickBulkAction('Export');
|
|
|
|
// Wait for export API response and validate zip contains both dashboards
|
|
const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]);
|
|
await expectValidExportZip(exportResponse, {
|
|
resourceDir: 'dashboards',
|
|
minCount: 2,
|
|
expectedNames: [dashboard1.name, dashboard2.name],
|
|
});
|
|
});
|
|
|
|
// Import test uses export-then-reimport approach (no static fixture needed).
|
|
// Uses test.describe only because Playwright's serial mode API requires it -
|
|
// this prevents race conditions when parallel workers import the same dashboard.
|
|
// (Deviation from "avoid describe" guideline is necessary for functional reasons)
|
|
test.describe('import dashboard', () => {
|
|
test.describe.configure({ mode: 'serial' });
|
|
test('should import a dashboard from a zip file', async ({
|
|
page,
|
|
dashboardListPage,
|
|
testAssets,
|
|
}) => {
|
|
test.setTimeout(60_000);
|
|
|
|
// Create a dashboard, export it via API, then delete it, then reimport via UI
|
|
const { id: dashboardId, name: dashboardName } = await createTestDashboard(
|
|
page,
|
|
testAssets,
|
|
test.info(),
|
|
{
|
|
prefix: 'test_import',
|
|
},
|
|
);
|
|
|
|
// Export the dashboard via API to get a zip buffer
|
|
const exportResponse = await apiExportDashboards(page, [dashboardId]);
|
|
expect(exportResponse.ok()).toBe(true);
|
|
const exportBuffer = await exportResponse.body();
|
|
|
|
// Delete the dashboard so reimport creates it fresh
|
|
await apiDeleteDashboard(page, dashboardId);
|
|
|
|
// Verify it's gone
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const response = await apiGetDashboard(page, dashboardId, {
|
|
failOnStatusCode: false,
|
|
});
|
|
return response.status();
|
|
},
|
|
{
|
|
timeout: 10000,
|
|
message: `Dashboard ${dashboardId} should return 404 after delete`,
|
|
},
|
|
)
|
|
.toBe(404);
|
|
|
|
// Refresh to confirm dashboard is no longer in the list
|
|
await dashboardListPage.goto();
|
|
await dashboardListPage.waitForTableLoad();
|
|
await expect(
|
|
dashboardListPage.getDashboardRow(dashboardName),
|
|
).not.toBeVisible();
|
|
|
|
// Click the import button
|
|
await dashboardListPage.clickImportButton();
|
|
|
|
// Reuse ImportDatasetModal (same shared ImportModelsModal UI)
|
|
const importModal = new ImportDatasetModal(page);
|
|
await importModal.waitForReady();
|
|
|
|
// Upload the exported zip via buffer (no temp file needed)
|
|
await page.locator('[data-test="model-file-input"]').setInputFiles({
|
|
name: 'dashboard_export.zip',
|
|
mimeType: 'application/zip',
|
|
buffer: exportBuffer,
|
|
});
|
|
|
|
// Set up response intercept for the import POST
|
|
let importResponsePromise = waitForPost(page, ENDPOINTS.DASHBOARD_IMPORT, {
|
|
pathMatch: true,
|
|
});
|
|
|
|
// Click Import button
|
|
await importModal.clickImport();
|
|
|
|
// Wait for first import response
|
|
let importResponse = await importResponsePromise;
|
|
|
|
// Handle overwrite confirmation if dashboard already exists
|
|
const overwriteInput = importModal.getOverwriteInput();
|
|
await overwriteInput
|
|
.waitFor({ state: 'visible', timeout: 3000 })
|
|
.catch(error => {
|
|
if (!(error instanceof Error) || error.name !== 'TimeoutError') {
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
if (await overwriteInput.isVisible()) {
|
|
importResponsePromise = waitForPost(page, ENDPOINTS.DASHBOARD_IMPORT, {
|
|
pathMatch: true,
|
|
});
|
|
await importModal.fillOverwriteConfirmation();
|
|
await importModal.clickImport();
|
|
importResponse = await importResponsePromise;
|
|
}
|
|
|
|
// Verify import succeeded
|
|
expectStatusOneOf(importResponse, [200]);
|
|
|
|
// Modal should close on success
|
|
await importModal.waitForHidden({ timeout: TIMEOUT.FILE_IMPORT });
|
|
|
|
// Verify success toast appears
|
|
const toast = new Toast(page);
|
|
await expect(toast.getSuccess()).toBeVisible({ timeout: 10000 });
|
|
|
|
// Refresh to see the imported dashboard
|
|
await dashboardListPage.goto();
|
|
await dashboardListPage.waitForTableLoad();
|
|
|
|
// Verify dashboard appears in list
|
|
await expect(
|
|
dashboardListPage.getDashboardRow(dashboardName),
|
|
).toBeVisible();
|
|
|
|
// Track for cleanup: look up the reimported dashboard by title
|
|
const reimported = await getDashboardByName(page, dashboardName);
|
|
if (reimported) {
|
|
testAssets.trackDashboard(reimported.id);
|
|
}
|
|
});
|
|
});
|