This is an automated email from the ASF dual-hosted git repository.

jli pushed a commit to branch test-dashboard-list-playwright
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 1df7a7d7a2cf0c5dd20b3066fd85c2dced9963ec
Author: Joe Li <[email protected]>
AuthorDate: Tue Mar 3 17:34:36 2026 -0800

    test(playwright): add dashboard list E2E tests
    
    Add Playwright E2E tests for dashboard list operations (delete, export,
    bulk delete, bulk export, import), mirroring the existing chart list
    test patterns.
    
    New files:
    - helpers/api/dashboard.ts: CRUD + export/search API helpers with rison
    - pages/DashboardListPage.ts: page object composing Table + BulkSelect
    - tests/experimental/dashboard/dashboard-test-helpers.ts: factory
    - tests/experimental/dashboard/dashboard-list.spec.ts: 5 test cases
    
    Modified:
    - helpers/fixtures/testAssets.ts: add trackDashboard() with FK-ordered
      cleanup (dashboards before charts)
    
    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 00000000000..52314a31c99
--- /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 c9ee84771a6..ada44e5ae55 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 00000000000..8c8472f0224
--- /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 00000000000..0ed0c49769d
--- /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 00000000000..fcbb07601eb
--- /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 };
+}

Reply via email to