mirror of
https://github.com/apache/superset.git
synced 2026-07-20 05:36:00 +00:00
402 lines
13 KiB
TypeScript
402 lines
13 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 { testWithAssets, expect } from '../../helpers/fixtures';
|
||
import { DashboardListPage } from '../../pages/DashboardListPage';
|
||
import {
|
||
DeleteConfirmationModal,
|
||
ImportDatasetModal,
|
||
} from '../../components/modals';
|
||
import { Toast } from '../../components/core';
|
||
import {
|
||
apiDeleteDashboard,
|
||
apiExportDashboards,
|
||
getDashboardByName,
|
||
ENDPOINTS,
|
||
} from '../../helpers/api/dashboard';
|
||
import { createTestDashboard } from './dashboard-test-helpers';
|
||
import { waitForGet, waitForPost } from '../../helpers/api/intercepts';
|
||
import {
|
||
expectDeleted,
|
||
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();
|
||
|
||
// The list query is asynchronous; allow extra time on slow CI before the
|
||
// freshly-created dashboard appears.
|
||
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible({
|
||
timeout: TIMEOUT.API_RESPONSE,
|
||
});
|
||
|
||
// 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 (waits for it to become enabled)
|
||
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 (extended timeout for slow CI
|
||
// post-delete propagation — the default 8s expect.timeout intermittently
|
||
// expires before the listview re-fetch lands).
|
||
await expect(dashboardListPage.getDashboardRow(dashboardName)).toHaveCount(
|
||
0,
|
||
{
|
||
timeout: TIMEOUT.API_RESPONSE,
|
||
},
|
||
);
|
||
|
||
// Backend verification: API returns 404
|
||
await expectDeleted(page, ENDPOINTS.DASHBOARD, dashboardId, {
|
||
label: `Dashboard ${dashboardId}`,
|
||
});
|
||
});
|
||
|
||
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();
|
||
|
||
// The list query is asynchronous; allow extra time on slow CI before the
|
||
// freshly-created dashboard appears.
|
||
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible({
|
||
timeout: TIMEOUT.API_RESPONSE,
|
||
});
|
||
|
||
// 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(TIMEOUT.SLOW_TEST);
|
||
|
||
// 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();
|
||
|
||
// The list query is asynchronous; allow extra time on slow CI before the
|
||
// freshly-created dashboards appear.
|
||
await expect(dashboardListPage.getDashboardRow(dashboard1.name)).toBeVisible({
|
||
timeout: TIMEOUT.API_RESPONSE,
|
||
});
|
||
await expect(dashboardListPage.getDashboardRow(dashboard2.name)).toBeVisible({
|
||
timeout: TIMEOUT.API_RESPONSE,
|
||
});
|
||
|
||
// 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 (deleted rows are removed from the DOM, so assert count rather than visibility)
|
||
await expect(dashboardListPage.getDashboardRow(dashboard1.name)).toHaveCount(
|
||
0,
|
||
{ timeout: TIMEOUT.API_RESPONSE },
|
||
);
|
||
await expect(dashboardListPage.getDashboardRow(dashboard2.name)).toHaveCount(
|
||
0,
|
||
{ timeout: TIMEOUT.API_RESPONSE },
|
||
);
|
||
|
||
// Backend verification: Both return 404
|
||
for (const dashboard of [dashboard1, dashboard2]) {
|
||
await expectDeleted(page, ENDPOINTS.DASHBOARD, dashboard.id, {
|
||
label: `Dashboard ${dashboard.id}`,
|
||
});
|
||
}
|
||
});
|
||
|
||
test('should bulk export multiple dashboards', async ({
|
||
page,
|
||
dashboardListPage,
|
||
testAssets,
|
||
}) => {
|
||
// Chains create×2 → refresh → bulk select → export. Matches the
|
||
// sibling bulk-delete test's budget so the export response wait below
|
||
// can exceed the 30s default without hitting the test timeout.
|
||
test.setTimeout(TIMEOUT.SLOW_TEST);
|
||
|
||
// 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();
|
||
|
||
// The list query is asynchronous; allow extra time on slow CI before the
|
||
// freshly-created dashboards appear.
|
||
await expect(dashboardListPage.getDashboardRow(dashboard1.name)).toBeVisible({
|
||
timeout: TIMEOUT.API_RESPONSE,
|
||
});
|
||
await expect(dashboardListPage.getDashboardRow(dashboard2.name)).toBeVisible({
|
||
timeout: TIMEOUT.API_RESPONSE,
|
||
});
|
||
|
||
// Enable bulk select mode (waits for the checkbox column to render)
|
||
await dashboardListPage.clickBulkSelectButton();
|
||
|
||
// Select both dashboards (each call asserts the checkbox is checked)
|
||
await dashboardListPage.selectDashboardCheckbox(dashboard1.name);
|
||
await dashboardListPage.selectDashboardCheckbox(dashboard2.name);
|
||
|
||
// Set up API response intercept BEFORE the click that triggers it.
|
||
// Exports of multiple dashboards can take longer than 30s under load,
|
||
// so use SLOW_TEST instead of the default test-timeout-bound budget.
|
||
const exportResponsePromise = waitForGet(page, ENDPOINTS.DASHBOARD_EXPORT, {
|
||
timeout: TIMEOUT.SLOW_TEST,
|
||
});
|
||
|
||
// Click bulk export action (waits for the action button to render)
|
||
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', () => {
|
||
// `timeout` on describe.configure also bounds fixture setup, so the
|
||
// `dashboardListPage` navigation gets the SLOW_TEST budget too —
|
||
// inline `test.setTimeout()` only applies once the test body runs.
|
||
test.describe.configure({ mode: 'serial', timeout: TIMEOUT.SLOW_TEST });
|
||
test('should import a dashboard from a zip file', async ({
|
||
page,
|
||
dashboardListPage,
|
||
testAssets,
|
||
}) => {
|
||
// 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 expectDeleted(page, ENDPOINTS.DASHBOARD, dashboardId, {
|
||
label: `Dashboard ${dashboardId}`,
|
||
});
|
||
|
||
// Refresh to confirm dashboard is no longer in the list (deleted rows are removed from the DOM, so assert count rather than visibility)
|
||
await dashboardListPage.goto();
|
||
await dashboardListPage.waitForTableLoad();
|
||
await expect(dashboardListPage.getDashboardRow(dashboardName)).toHaveCount(
|
||
0,
|
||
{ timeout: TIMEOUT.API_RESPONSE },
|
||
);
|
||
|
||
// 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: TIMEOUT.CONFIRM_DIALOG })
|
||
.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: TIMEOUT.PAGE_LOAD,
|
||
});
|
||
|
||
// Refresh to see the imported dashboard
|
||
await dashboardListPage.goto();
|
||
await dashboardListPage.waitForTableLoad();
|
||
|
||
// The list query is asynchronous; allow extra time on slow CI before the
|
||
// freshly-imported dashboard appears.
|
||
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible({
|
||
timeout: TIMEOUT.API_RESPONSE,
|
||
});
|
||
|
||
// Track for cleanup: look up the reimported dashboard by title
|
||
const reimported = await getDashboardByName(page, dashboardName);
|
||
if (reimported) {
|
||
testAssets.trackDashboard(reimported.id);
|
||
}
|
||
});
|
||
});
|