Files
superset2/superset-frontend/playwright/tests/dashboard/dashboard-list.spec.ts
2026-06-04 11:41:36 -07:00

402 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}
});
});