This is an automated email from the ASF dual-hosted git repository. jli pushed a commit to branch fix-dashboard-playwright-tests in repository https://gitbox.apache.org/repos/asf/superset.git
commit 31db274caf1b8c3cef2dd359359edd12c0e086e8 Author: Joe Li <[email protected]> AuthorDate: Tue Jan 27 19:29:21 2026 -0800 test(playwright): fix Dashboard Export tests with reliable Menu component - Create Menu core component for Ant Design dropdown menu interactions - Use hover-first approach with keyboard/dispatchEvent fallbacks - Scope popup selector by filtering for expected item text (avoid wrong popup) - Remove unused Menu methods (YAGNI): getItem, getSubmenu, selectItem, waitFor* - Remove magic waitForTimeout(100) - rely on popup visibility wait - Fix waitForChartsToLoad to wait for all indicators (count=0), not just first - Keep waitForLoad and waitForChartsToLoad as separate methods - Use existing Toast component for success toast assertions - Add toast assertions to both Export YAML and Export as Example tests - Add afterEach cleanup of downloaded files - Export Toast from core components index Co-Authored-By: Claude Opus 4.5 <[email protected]> --- .../playwright/components/core/Menu.ts | 210 +++++++++++++++++++++ .../playwright/components/core/index.ts | 2 + .../playwright/pages/DashboardPage.ts | 67 ++++--- .../tests/experimental/dashboard/export.spec.ts | 77 +++----- 4 files changed, 282 insertions(+), 74 deletions(-) diff --git a/superset-frontend/playwright/components/core/Menu.ts b/superset-frontend/playwright/components/core/Menu.ts new file mode 100644 index 00000000000..d3fb9eb7456 --- /dev/null +++ b/superset-frontend/playwright/components/core/Menu.ts @@ -0,0 +1,210 @@ +/** + * 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 { Locator, Page } from '@playwright/test'; + +/** + * Menu component for Ant Design dropdown menus. + * Uses hover as primary approach (most natural user interaction). + * Falls back to keyboard navigation, then dispatchEvent if hover fails. + * + * This component handles menu content only - not the trigger that opens the menu. + * The calling page object should open the menu first, then use this component. + * + * @example + * // In a page object + * async selectDownloadOption(optionText: string): Promise<void> { + * await this.openHeaderActionsMenu(); + * const menu = new Menu(this.page, '[data-test="header-actions-menu"]'); + * await menu.selectSubmenuItem('Download', optionText); + * } + */ +export class Menu { + private readonly page: Page; + private readonly locator: Locator; + + private static readonly SELECTORS = { + MENU_ITEM: '[role="menuitem"]', + // Dropdown menus use ant-dropdown-menu-submenu-* classes + SUBMENU: '.ant-dropdown-menu-submenu', + SUBMENU_POPUP: '.ant-dropdown-menu-submenu-popup', + SUBMENU_TITLE: '.ant-dropdown-menu-submenu-title', + } as const; + + private static readonly TIMEOUTS = { + SUBMENU_OPEN: 5000, + } as const; + + constructor(page: Page, selector: string); + constructor(page: Page, locator: Locator); + constructor(page: Page, selectorOrLocator: string | Locator) { + this.page = page; + if (typeof selectorOrLocator === 'string') { + this.locator = page.locator(selectorOrLocator); + } else { + this.locator = selectorOrLocator; + } + } + + /** + * Opens a submenu and selects an item within it. + * Uses hover as primary approach, falls back to keyboard then dispatchEvent. + * + * @param submenuText - The text of the submenu to open (e.g., "Download") + * @param itemText - The text of the item to select (e.g., "Export YAML") + * @param options - Optional timeout settings + */ + async selectSubmenuItem( + submenuText: string, + itemText: string, + options?: { timeout?: number }, + ): Promise<void> { + const timeout = options?.timeout ?? Menu.TIMEOUTS.SUBMENU_OPEN; + + // Try hover first (most natural user interaction) + let popup = await this.openSubmenuWithHover(submenuText, itemText, timeout); + + // Fallback to keyboard navigation + if (!popup) { + popup = await this.openSubmenuWithKeyboard(submenuText, itemText, timeout); + } + + // Last resort: dispatchEvent + if (!popup) { + popup = await this.openSubmenuWithDispatchEvent( + submenuText, + itemText, + timeout, + ); + } + + if (!popup) { + throw new Error( + `Failed to open submenu "${submenuText}". Tried hover, keyboard, and dispatchEvent.`, + ); + } + + // Click the item within the verified popup + await popup.getByText(itemText, { exact: true }).click({ timeout }); + } + + /** + * Opens a submenu using native Playwright hover. + * Returns the popup locator if successful, null otherwise. + */ + private async openSubmenuWithHover( + submenuText: string, + itemText: string, + timeout: number, + ): Promise<Locator | null> { + try { + const submenuTitle = this.getSubmenuTitle(submenuText); + await submenuTitle.hover(); + + // Find the popup that contains the expected item (scopes to correct popup) + const popup = this.page + .locator(Menu.SELECTORS.SUBMENU_POPUP) + .filter({ hasText: itemText }); + await popup.waitFor({ state: 'visible', timeout }); + + // Allow Ant Design's slide-in animation to complete before clicking. + // Without this, the element may be "not stable" and clicks can fail. + await this.page.waitForTimeout(150); + + return popup; + } catch { + return null; + } + } + + /** + * Opens a submenu using keyboard navigation. + * Returns the popup locator if successful, null otherwise. + */ + private async openSubmenuWithKeyboard( + submenuText: string, + itemText: string, + timeout: number, + ): Promise<Locator | null> { + try { + const submenuTitle = this.getSubmenuTitle(submenuText); + await submenuTitle.focus(); + await this.page.keyboard.press('ArrowRight'); + + const popup = this.page + .locator(Menu.SELECTORS.SUBMENU_POPUP) + .filter({ hasText: itemText }); + await popup.waitFor({ state: 'visible', timeout }); + + return popup; + } catch { + return null; + } + } + + /** + * Opens a submenu using dispatchEvent to trigger mouseover/mouseenter. + * Returns the popup locator if successful, null otherwise. + */ + private async openSubmenuWithDispatchEvent( + submenuText: string, + itemText: string, + timeout: number, + ): Promise<Locator | null> { + try { + const submenuTitle = this.getSubmenuTitle(submenuText); + + await submenuTitle.evaluate(el => { + el.dispatchEvent( + new MouseEvent('mouseover', { + bubbles: true, + cancelable: true, + view: window, + }), + ); + el.dispatchEvent( + new MouseEvent('mouseenter', { + bubbles: true, + cancelable: true, + view: window, + }), + ); + }); + + const popup = this.page + .locator(Menu.SELECTORS.SUBMENU_POPUP) + .filter({ hasText: itemText }); + await popup.waitFor({ state: 'visible', timeout }); + + return popup; + } catch { + return null; + } + } + + /** + * Gets the submenu title element for a submenu containing the given text. + */ + private getSubmenuTitle(submenuText: string): Locator { + return this.locator + .locator(Menu.SELECTORS.SUBMENU) + .filter({ hasText: submenuText }) + .locator(Menu.SELECTORS.SUBMENU_TITLE); + } +} diff --git a/superset-frontend/playwright/components/core/index.ts b/superset-frontend/playwright/components/core/index.ts index 82a26c2b695..8cbac12d54c 100644 --- a/superset-frontend/playwright/components/core/index.ts +++ b/superset-frontend/playwright/components/core/index.ts @@ -21,5 +21,7 @@ export { Button } from './Button'; export { Form } from './Form'; export { Input } from './Input'; +export { Menu } from './Menu'; export { Modal } from './Modal'; export { Table } from './Table'; +export { Toast } from './Toast'; diff --git a/superset-frontend/playwright/pages/DashboardPage.ts b/superset-frontend/playwright/pages/DashboardPage.ts index 47d8f82194d..788909a8c11 100644 --- a/superset-frontend/playwright/pages/DashboardPage.ts +++ b/superset-frontend/playwright/pages/DashboardPage.ts @@ -18,6 +18,7 @@ */ import { Page, Download } from '@playwright/test'; +import { Menu } from '../components/core'; import { TIMEOUT } from '../utils/constants'; /** @@ -54,7 +55,7 @@ export class DashboardPage { } /** - * Wait for the dashboard to load + * Wait for the dashboard header to be visible. */ async waitForLoad(options?: { timeout?: number }): Promise<void> { const timeout = options?.timeout ?? TIMEOUT.PAGE_LOAD; @@ -63,6 +64,34 @@ export class DashboardPage { }); } + /** + * Wait for all charts on the dashboard to finish loading. + * Waits until no loading indicators are visible. + */ + async waitForChartsToLoad(options?: { timeout?: number }): Promise<void> { + const timeout = options?.timeout ?? TIMEOUT.PAGE_LOAD; + const loadingIndicators = this.page.locator('[aria-label="Loading"]'); + + // Wait until all loading indicators are gone (count reaches 0) + await loadingIndicators + .first() + .waitFor({ state: 'hidden', timeout }) + .catch(() => { + // No loading indicators found - charts already loaded + }); + + // Double-check no loading indicators remain + await this.page + .waitForFunction( + selector => document.querySelectorAll(selector).length === 0, + '[aria-label="Loading"]', + { timeout }, + ) + .catch(() => { + // Timeout is acceptable - proceed if indicators are gone or minimal + }); + } + /** * Open the dashboard header actions menu (three-dot menu) */ @@ -78,33 +107,21 @@ export class DashboardPage { } /** - * Hover over the Download submenu to open it (Ant Design submenus open on hover) - */ - async openDownloadMenu(): Promise<void> { - // Find the Download menu item within the header actions menu and hover - const menu = this.page.locator(DashboardPage.SELECTORS.HEADER_ACTIONS_MENU); - await menu.getByText('Download', { exact: true }).hover(); - // Wait for Export YAML to become visible (indicates submenu opened) - await this.page.getByText('Export YAML').waitFor({ state: 'visible' }); - } - - /** - * Click "Export YAML" in the download menu - * Returns a Promise that resolves when download starts + * Selects an option from the Download submenu. + * Opens the header actions menu, navigates to Download submenu, + * and clicks the specified option. + * + * @param optionText - The download option to select (e.g., "Export YAML") */ - async clickExportYaml(): Promise<Download> { - const downloadPromise = this.page.waitForEvent('download'); - await this.page.getByText('Export YAML').click(); - return downloadPromise; - } + async selectDownloadOption(optionText: string): Promise<Download> { + await this.openHeaderActionsMenu(); - /** - * Click "Export as Example" in the download menu - * Returns a Promise that resolves when download starts - */ - async clickExportAsExample(): Promise<Download> { + const menu = new Menu( + this.page, + DashboardPage.SELECTORS.HEADER_ACTIONS_MENU, + ); const downloadPromise = this.page.waitForEvent('download'); - await this.page.getByText('Export as Example').click(); + await menu.selectSubmenuItem('Download', optionText); return downloadPromise; } } diff --git a/superset-frontend/playwright/tests/experimental/dashboard/export.spec.ts b/superset-frontend/playwright/tests/experimental/dashboard/export.spec.ts index 7b1340a98d2..122c6f1942b 100644 --- a/superset-frontend/playwright/tests/experimental/dashboard/export.spec.ts +++ b/superset-frontend/playwright/tests/experimental/dashboard/export.spec.ts @@ -19,6 +19,7 @@ import { test, expect } from '@playwright/test'; import { DashboardPage } from '../../../pages/DashboardPage'; +import { Toast } from '../../../components/core'; import { TIMEOUT } from '../../../utils/constants'; /** @@ -31,74 +32,52 @@ import { TIMEOUT } from '../../../utils/constants'; * Prerequisites: * - Superset running with example dashboards loaded * - Admin user authenticated (via global-setup) - * - * SKIP REASON: Ant Design Menu submenu hover behavior is not reliably - * triggered by Playwright. The submenu popup doesn't appear consistently - * when hovering over the Download menu item. This functionality is - * covered by unit tests in DownloadMenuItems.test.tsx. - * - * TODO: Investigate Ant Design Menu triggerSubMenuAction or alternative - * approaches for E2E testing of nested menus. */ let dashboardPage: DashboardPage; +const downloads: { delete: () => Promise<void> }[] = []; -test.describe.skip('Dashboard Export', () => { +test.describe('Dashboard Export', () => { test.beforeEach(async ({ page }) => { dashboardPage = new DashboardPage(page); // Navigate to World Health dashboard (standard example) await dashboardPage.gotoBySlug('world_health'); await dashboardPage.waitForLoad({ timeout: TIMEOUT.PAGE_LOAD }); + // Wait for charts to finish loading - Download menu may be disabled while loading + await dashboardPage.waitForChartsToLoad({ timeout: TIMEOUT.PAGE_LOAD }); }); - test('should download ZIP when clicking Export YAML', async ({ page }) => { - // Open the header actions menu (three-dot menu) - await dashboardPage.openHeaderActionsMenu(); - - // Open the Download submenu - await dashboardPage.openDownloadMenu(); - - // Click Export YAML and wait for download - const download = await dashboardPage.clickExportYaml(); - - // Verify the download - const filename = download.suggestedFilename(); - expect(filename).toMatch(/\.zip$/); + test.afterEach(async () => { + // Clean up downloaded files + await Promise.all(downloads.map(d => d.delete().catch(() => {}))); + downloads.length = 0; }); - test('should download example bundle when clicking Export as Example', async ({ + test('should download ZIP and show success toast when clicking Export YAML', async ({ page, }) => { - // Open the header actions menu - await dashboardPage.openHeaderActionsMenu(); - - // Open the Download submenu - await dashboardPage.openDownloadMenu(); - - // Click Export as Example and wait for download - const download = await dashboardPage.clickExportAsExample(); - - // Verify the download - const filename = download.suggestedFilename(); - expect(filename).toMatch(/_example\.zip$/); + const toast = new Toast(page); + const download = await dashboardPage.selectDownloadOption('Export YAML'); + downloads.push(download); + + expect(download.suggestedFilename()).toMatch(/\.zip$/); + await expect(toast.getSuccess()).toBeVisible({ + timeout: TIMEOUT.API_RESPONSE, + }); }); - test('should show success toast after Export as Example', async ({ + test('should download example bundle and show success toast when clicking Export as Example', async ({ page, }) => { - // Open the header actions menu - await dashboardPage.openHeaderActionsMenu(); - - // Open the Download submenu - await dashboardPage.openDownloadMenu(); - - // Click Export as Example - await dashboardPage.clickExportAsExample(); - - // Verify success toast appears - await expect( - page.locator('.ant-message-success, [data-test="toast-success"]'), - ).toBeVisible({ timeout: TIMEOUT.API_RESPONSE }); + const toast = new Toast(page); + const download = + await dashboardPage.selectDownloadOption('Export as Example'); + downloads.push(download); + + expect(download.suggestedFilename()).toMatch(/_example\.zip$/); + await expect(toast.getSuccess()).toBeVisible({ + timeout: TIMEOUT.API_RESPONSE, + }); }); });
