This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new e2ebc135e4 test(playwright): add dashboard list E2E tests (#38377)
e2ebc135e4 is described below
commit e2ebc135e4aedd0269d4b8279140a261c8e0d381
Author: Joe Li <[email protected]>
AuthorDate: Wed Mar 4 11:15:16 2026 -0800
test(playwright): add dashboard list E2E tests (#38377)
Co-authored-by: Claude Opus 4.6 <[email protected]>
---
.../playwright/helpers/api/dashboard.ts | 170 +++++++++
.../playwright/helpers/fixtures/testAssets.ts | 24 +-
.../playwright/pages/DashboardListPage.ts | 139 +++++++
.../experimental/dashboard/dashboard-list.spec.ts | 403 +++++++++++++++++++++
.../dashboard/dashboard-test-helpers.ts | 74 ++++
5 files changed, 809 insertions(+), 1 deletion(-)
diff --git a/superset-frontend/playwright/helpers/api/dashboard.ts
b/superset-frontend/playwright/helpers/api/dashboard.ts
new file mode 100644
index 0000000000..52314a31c9
--- /dev/null
+++ b/superset-frontend/playwright/helpers/api/dashboard.ts
@@ -0,0 +1,170 @@
+/**
+ * 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 { Page, APIResponse } from '@playwright/test';
+import rison from 'rison';
+import {
+ apiGet,
+ apiPost,
+ apiDelete,
+ apiPut,
+ ApiRequestOptions,
+} from './requests';
+
+export const ENDPOINTS = {
+ DASHBOARD: 'api/v1/dashboard/',
+ DASHBOARD_EXPORT: 'api/v1/dashboard/export/',
+ DASHBOARD_IMPORT: 'api/v1/dashboard/import/',
+} as const;
+
+/**
+ * TypeScript interface for dashboard creation API payload.
+ * Only dashboard_title is required (DashboardPostSchema).
+ */
+export interface DashboardCreatePayload {
+ dashboard_title: string;
+ slug?: string;
+ position_json?: string;
+ css?: string;
+ json_metadata?: string;
+ published?: boolean;
+}
+
+/**
+ * POST request to create a dashboard
+ * @param page - Playwright page instance (provides authentication context)
+ * @param requestBody - Dashboard configuration object
+ * @returns API response from dashboard creation
+ */
+export async function apiPostDashboard(
+ page: Page,
+ requestBody: DashboardCreatePayload,
+): Promise<APIResponse> {
+ return apiPost(page, ENDPOINTS.DASHBOARD, requestBody);
+}
+
+/**
+ * GET request to fetch a dashboard's details
+ * @param page - Playwright page instance (provides authentication context)
+ * @param dashboardId - ID of the dashboard to fetch
+ * @param options - Optional request options
+ * @returns API response with dashboard details
+ */
+export async function apiGetDashboard(
+ page: Page,
+ dashboardId: number,
+ options?: ApiRequestOptions,
+): Promise<APIResponse> {
+ return apiGet(page, `${ENDPOINTS.DASHBOARD}${dashboardId}`, options);
+}
+
+/**
+ * DELETE request to remove a dashboard
+ * @param page - Playwright page instance (provides authentication context)
+ * @param dashboardId - ID of the dashboard to delete
+ * @param options - Optional request options
+ * @returns API response from dashboard deletion
+ */
+export async function apiDeleteDashboard(
+ page: Page,
+ dashboardId: number,
+ options?: ApiRequestOptions,
+): Promise<APIResponse> {
+ return apiDelete(page, `${ENDPOINTS.DASHBOARD}${dashboardId}`, options);
+}
+
+/**
+ * PUT request to update a dashboard
+ * @param page - Playwright page instance (provides authentication context)
+ * @param dashboardId - ID of the dashboard to update
+ * @param data - Partial dashboard payload (Marshmallow allows optional fields)
+ * @param options - Optional request options
+ * @returns API response from dashboard update
+ */
+export async function apiPutDashboard(
+ page: Page,
+ dashboardId: number,
+ data: Record<string, unknown>,
+ options?: ApiRequestOptions,
+): Promise<APIResponse> {
+ return apiPut(page, `${ENDPOINTS.DASHBOARD}${dashboardId}`, data, options);
+}
+
+/**
+ * Export dashboards as a zip file via the API.
+ * Uses Rison encoding for the query parameter (required by the endpoint).
+ * @param page - Playwright page instance (provides authentication context)
+ * @param dashboardIds - Array of dashboard IDs to export
+ * @returns API response containing the zip file
+ */
+export async function apiExportDashboards(
+ page: Page,
+ dashboardIds: number[],
+): Promise<APIResponse> {
+ const query = rison.encode(dashboardIds);
+ return apiGet(page, `${ENDPOINTS.DASHBOARD_EXPORT}?q=${query}`);
+}
+
+/**
+ * TypeScript interface for dashboard search result
+ */
+export interface DashboardResult {
+ id: number;
+ dashboard_title: string;
+ slug?: string;
+ published?: boolean;
+}
+
+/**
+ * Get a dashboard by its title
+ * @param page - Playwright page instance (provides authentication context)
+ * @param title - The dashboard_title to search for
+ * @returns Dashboard object if found, null if not found
+ */
+export async function getDashboardByName(
+ page: Page,
+ title: string,
+): Promise<DashboardResult | null> {
+ const filter = {
+ filters: [
+ {
+ col: 'dashboard_title',
+ opr: 'eq',
+ value: title,
+ },
+ ],
+ };
+ const queryParam = rison.encode(filter);
+ const response = await apiGet(
+ page,
+ `${ENDPOINTS.DASHBOARD}?q=${queryParam}`,
+ { failOnStatusCode: false },
+ );
+
+ if (!response.ok()) {
+ return null;
+ }
+
+ const body = await response.json();
+ if (body.result && body.result.length > 0) {
+ return body.result[0] as DashboardResult;
+ }
+
+ return null;
+}
diff --git a/superset-frontend/playwright/helpers/fixtures/testAssets.ts
b/superset-frontend/playwright/helpers/fixtures/testAssets.ts
index c9ee84771a..ada44e5ae5 100644
--- a/superset-frontend/playwright/helpers/fixtures/testAssets.ts
+++ b/superset-frontend/playwright/helpers/fixtures/testAssets.ts
@@ -19,6 +19,7 @@
import { test as base } from '@playwright/test';
import { apiDeleteChart } from '../api/chart';
+import { apiDeleteDashboard } from '../api/dashboard';
import { apiDeleteDataset } from '../api/dataset';
import { apiDeleteDatabase } from '../api/database';
@@ -27,6 +28,7 @@ import { apiDeleteDatabase } from '../api/database';
* Inspired by Cypress's cleanDashboards/cleanCharts pattern.
*/
export interface TestAssets {
+ trackDashboard(id: number): void;
trackChart(id: number): void;
trackDataset(id: number): void;
trackDatabase(id: number): void;
@@ -37,19 +39,39 @@ const EXPECTED_CLEANUP_STATUSES = new Set([200, 202, 204,
404]);
export const test = base.extend<{ testAssets: TestAssets }>({
testAssets: async ({ page }, use) => {
// Use Set to de-dupe IDs (same resource may be tracked multiple times)
+ const dashboardIds = new Set<number>();
const chartIds = new Set<number>();
const datasetIds = new Set<number>();
const databaseIds = new Set<number>();
await use({
+ trackDashboard: id => dashboardIds.add(id),
trackChart: id => chartIds.add(id),
trackDataset: id => datasetIds.add(id),
trackDatabase: id => databaseIds.add(id),
});
- // Cleanup order: charts → datasets → databases (respects FK dependencies)
+ // Cleanup order: dashboards → charts → datasets → databases (respects FK
dependencies)
// Use failOnStatusCode: false to avoid throwing on 404 (resource already
deleted by test)
// Warn on unexpected status codes (401/403/500) that may indicate leaked
state
+ await Promise.all(
+ [...dashboardIds].map(id =>
+ apiDeleteDashboard(page, id, { failOnStatusCode: false })
+ .then(response => {
+ if (!EXPECTED_CLEANUP_STATUSES.has(response.status())) {
+ console.warn(
+ `[testAssets] Unexpected status ${response.status()} cleaning
up dashboard ${id}`,
+ );
+ }
+ })
+ .catch(error => {
+ console.warn(
+ `[testAssets] Failed to cleanup dashboard ${id}:`,
+ error,
+ );
+ }),
+ ),
+ );
await Promise.all(
[...chartIds].map(id =>
apiDeleteChart(page, id, { failOnStatusCode: false })
diff --git a/superset-frontend/playwright/pages/DashboardListPage.ts
b/superset-frontend/playwright/pages/DashboardListPage.ts
new file mode 100644
index 0000000000..8c8472f022
--- /dev/null
+++ b/superset-frontend/playwright/pages/DashboardListPage.ts
@@ -0,0 +1,139 @@
+/**
+ * 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 { Page, Locator } from '@playwright/test';
+import { Button, Table } from '../components/core';
+import { BulkSelect } from '../components/ListView';
+import { URL } from '../utils/urls';
+
+/**
+ * Dashboard List Page object.
+ */
+export class DashboardListPage {
+ private readonly page: Page;
+ private readonly table: Table;
+ readonly bulkSelect: BulkSelect;
+
+ /**
+ * Action button names for getByRole('button', { name })
+ * DashboardList uses Icons.DeleteOutlined, Icons.UploadOutlined,
Icons.EditOutlined
+ */
+ private static readonly ACTION_BUTTONS = {
+ DELETE: 'delete',
+ EDIT: 'edit',
+ EXPORT: 'upload',
+ } as const;
+
+ constructor(page: Page) {
+ this.page = page;
+ this.table = new Table(page);
+ this.bulkSelect = new BulkSelect(page, this.table);
+ }
+
+ /**
+ * Navigate to the dashboard list page.
+ * Forces table view via URL parameter to avoid card view default
+ * (ListviewsDefaultCardView feature flag may enable card view).
+ */
+ async goto(): Promise<void> {
+ await this.page.goto(`${URL.DASHBOARD_LIST}?viewMode=table`);
+ }
+
+ /**
+ * Wait for the table to load
+ * @param options - Optional wait options
+ */
+ async waitForTableLoad(options?: { timeout?: number }): Promise<void> {
+ await this.table.waitForVisible(options);
+ }
+
+ /**
+ * Gets a dashboard row locator by name.
+ * Returns a Locator that tests can use with expect().toBeVisible(), etc.
+ *
+ * @param dashboardName - The name of the dashboard
+ * @returns Locator for the dashboard row
+ */
+ getDashboardRow(dashboardName: string): Locator {
+ return this.table.getRow(dashboardName);
+ }
+
+ /**
+ * Clicks the delete action button for a dashboard
+ * @param dashboardName - The name of the dashboard to delete
+ */
+ async clickDeleteAction(dashboardName: string): Promise<void> {
+ const row = this.table.getRow(dashboardName);
+ await row
+ .getByRole('button', { name: DashboardListPage.ACTION_BUTTONS.DELETE })
+ .click();
+ }
+
+ /**
+ * Clicks the edit action button for a dashboard
+ * @param dashboardName - The name of the dashboard to edit
+ */
+ async clickEditAction(dashboardName: string): Promise<void> {
+ const row = this.table.getRow(dashboardName);
+ await row
+ .getByRole('button', { name: DashboardListPage.ACTION_BUTTONS.EDIT })
+ .click();
+ }
+
+ /**
+ * Clicks the export action button for a dashboard
+ * @param dashboardName - The name of the dashboard to export
+ */
+ async clickExportAction(dashboardName: string): Promise<void> {
+ const row = this.table.getRow(dashboardName);
+ await row
+ .getByRole('button', { name: DashboardListPage.ACTION_BUTTONS.EXPORT })
+ .click();
+ }
+
+ /**
+ * Clicks the "Bulk select" button to enable bulk selection mode
+ */
+ async clickBulkSelectButton(): Promise<void> {
+ await this.bulkSelect.enable();
+ }
+
+ /**
+ * Selects a dashboard's checkbox in bulk select mode
+ * @param dashboardName - The name of the dashboard to select
+ */
+ async selectDashboardCheckbox(dashboardName: string): Promise<void> {
+ await this.bulkSelect.selectRow(dashboardName);
+ }
+
+ /**
+ * Clicks a bulk action button by name (e.g., "Export", "Delete")
+ * @param actionName - The name of the bulk action to click
+ */
+ async clickBulkAction(actionName: string): Promise<void> {
+ await this.bulkSelect.clickAction(actionName);
+ }
+
+ /**
+ * Clicks the import button on the dashboard list page
+ */
+ async clickImportButton(): Promise<void> {
+ await new Button(this.page,
this.page.getByTestId('import-button')).click();
+ }
+}
diff --git
a/superset-frontend/playwright/tests/experimental/dashboard/dashboard-list.spec.ts
b/superset-frontend/playwright/tests/experimental/dashboard/dashboard-list.spec.ts
new file mode 100644
index 0000000000..0ed0c49769
--- /dev/null
+++
b/superset-frontend/playwright/tests/experimental/dashboard/dashboard-list.spec.ts
@@ -0,0 +1,403 @@
+/**
+ * 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);
+ }
+ });
+});
diff --git
a/superset-frontend/playwright/tests/experimental/dashboard/dashboard-test-helpers.ts
b/superset-frontend/playwright/tests/experimental/dashboard/dashboard-test-helpers.ts
new file mode 100644
index 0000000000..fcbb07601e
--- /dev/null
+++
b/superset-frontend/playwright/tests/experimental/dashboard/dashboard-test-helpers.ts
@@ -0,0 +1,74 @@
+/**
+ * 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 type { Page, TestInfo } from '@playwright/test';
+import type { TestAssets } from '../../../helpers/fixtures/testAssets';
+import { apiPostDashboard } from '../../../helpers/api/dashboard';
+
+interface TestDashboardResult {
+ id: number;
+ name: string;
+}
+
+interface CreateTestDashboardOptions {
+ /** Prefix for generated name (default: 'test_dashboard') */
+ prefix?: string;
+}
+
+/**
+ * Creates a test dashboard via the API for E2E testing.
+ *
+ * @example
+ * const { id, name } = await createTestDashboard(page, testAssets,
test.info());
+ *
+ * @example
+ * const { id, name } = await createTestDashboard(page, testAssets,
test.info(), {
+ * prefix: 'test_delete',
+ * });
+ */
+export async function createTestDashboard(
+ page: Page,
+ testAssets: TestAssets,
+ testInfo: TestInfo,
+ options?: CreateTestDashboardOptions,
+): Promise<TestDashboardResult> {
+ const prefix = options?.prefix ?? 'test_dashboard';
+ const name = `${prefix}_${Date.now()}_${testInfo.parallelIndex}`;
+
+ const response = await apiPostDashboard(page, {
+ dashboard_title: name,
+ });
+
+ if (!response.ok()) {
+ throw new Error(`Failed to create test dashboard: ${response.status()}`);
+ }
+
+ const body = await response.json();
+ // Handle both response shapes: { id } or { result: { id } }
+ const id = body.result?.id ?? body.id;
+ if (!id) {
+ throw new Error(
+ `Dashboard creation returned no id. Response: ${JSON.stringify(body)}`,
+ );
+ }
+
+ testAssets.trackDashboard(id);
+
+ return { id, name };
+}