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

Reply via email to