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 }; +}
