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

jli pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 86f690d17fe fix(dashboard): fix Export as Example with app prefix and 
enable Dashboard Export E2E tests (#37529)
86f690d17fe is described below

commit 86f690d17fe9a48a264ecdbb653ef0034d67ec0f
Author: Joe Li <[email protected]>
AuthorDate: Mon Feb 2 12:07:22 2026 -0800

    fix(dashboard): fix Export as Example with app prefix and enable Dashboard 
Export E2E tests (#37529)
    
    Co-authored-by: Claude Opus 4.5 <[email protected]>
---
 .../playwright/components/core/Menu.ts             | 217 +++++++++++++++++++++
 .../playwright/components/core/index.ts            |   2 +
 .../playwright/pages/DashboardPage.ts              |  68 ++++---
 .../tests/experimental/dashboard/export.spec.ts    |  81 +++-----
 .../DownloadMenuItems/DownloadMenuItems.test.tsx   |  88 ++++++++-
 .../components/menu/DownloadMenuItems/index.tsx    |   7 +-
 6 files changed, 383 insertions(+), 80 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..e312f0283c5
--- /dev/null
+++ b/superset-frontend/playwright/components/core/Menu.ts
@@ -0,0 +1,217 @@
+/**
+ * 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';
+import { TIMEOUT } from '../../utils/constants';
+
+/**
+ * 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 = {
+    SUBMENU: '.ant-dropdown-menu-submenu',
+    SUBMENU_POPUP: '.ant-dropdown-menu-submenu-popup',
+    SUBMENU_TITLE: '.ant-dropdown-menu-submenu-title',
+  } as const;
+
+  /**
+   * Ant Design animation delay - allows slide-in animation to complete.
+   * Without this, elements may be "not stable" and clicks can fail.
+   */
+  private static readonly ANIMATION_DELAY = 150;
+
+  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 ?? TIMEOUT.FORM_LOAD;
+
+    // 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.`,
+      );
+    }
+
+    // Use dispatchEvent instead of click to bypass viewport and pointer 
interception
+    // issues. Ant Design renders submenu popups in a portal that can be 
positioned
+    // outside the viewport or behind chart content (e.g., large tables with 
z-index).
+    await popup.getByText(itemText, { exact: true }).dispatchEvent('click');
+  }
+
+  /**
+   * 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(Menu.ANIMATION_DELAY);
+
+      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..f94695ad4fd 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,35 @@ export class DashboardPage {
     });
   }
 
+  /**
+   * Wait for all charts on the dashboard to finish loading.
+   * Waits until no loading indicators are visible on the page.
+   */
+  async waitForChartsToLoad(options?: { timeout?: number }): Promise<void> {
+    const timeout = options?.timeout ?? TIMEOUT.API_RESPONSE;
+
+    // Use browser-context evaluation to check visibility directly.
+    // Loading indicators ([aria-label="Loading"]) may persist in the DOM as 
hidden
+    // elements after charts finish loading. This checks that none are 
currently visible,
+    // returning immediately when charts are already loaded (no timeout 
penalty).
+    await this.page.waitForFunction(
+      () => {
+        const loaders = document.querySelectorAll('[aria-label="Loading"]');
+        if (loaders.length === 0) return true;
+        return Array.from(loaders).every(el => {
+          const style = getComputedStyle(el);
+          return (
+            style.display === 'none' ||
+            style.visibility === 'hidden' ||
+            style.opacity === '0'
+          );
+        });
+      },
+      undefined,
+      { timeout },
+    );
+  }
+
   /**
    * Open the dashboard header actions menu (three-dot menu)
    */
@@ -78,33 +108,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..992aaca5097 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,56 @@ 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('Dashboard Export', () => {
+  // Dashboard with multiple charts needs extra time for cold-cache CI runs:
+  // waitForLoad (10s) + waitForChartsToLoad (15s) + menu + download + toast
+  test.setTimeout(60_000);
 
-test.describe.skip('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();
   });
 
-  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,
+    });
   });
 });
diff --git 
a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx
 
b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx
index 2f3db006472..aa10717b64f 100644
--- 
a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx
+++ 
b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx
@@ -16,10 +16,38 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { render, screen } from 'spec/helpers/testing-library';
+import React from 'react';
+import {
+  render,
+  screen,
+  userEvent,
+  waitFor,
+} from 'spec/helpers/testing-library';
 import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
+import { SupersetClient } from '@superset-ui/core';
 import { useDownloadMenuItems } from '.';
 
+const mockAddSuccessToast = jest.fn();
+const mockAddDangerToast = jest.fn();
+
+jest.mock('src/components/MessageToasts/withToasts', () => ({
+  __esModule: true,
+  default: (Component: React.ComponentType) => Component,
+  useToasts: () => ({
+    addSuccessToast: mockAddSuccessToast,
+    addDangerToast: mockAddDangerToast,
+  }),
+}));
+
+jest.mock('@superset-ui/core', () => ({
+  ...jest.requireActual('@superset-ui/core'),
+  SupersetClient: {
+    get: jest.fn(),
+  },
+}));
+
+const mockSupersetClient = SupersetClient as jest.Mocked<typeof 
SupersetClient>;
+
 const createProps = () => ({
   pdfMenuItemTitle: 'Export to PDF',
   imageMenuItemTitle: 'Download as Image',
@@ -37,6 +65,18 @@ const MenuWrapper = () => {
   return <Menu forceSubMenuRender items={menuItems} />;
 };
 
+const originalCreateObjectURL = window.URL.createObjectURL;
+const originalRevokeObjectURL = window.URL.revokeObjectURL;
+
+beforeEach(() => {
+  jest.clearAllMocks();
+});
+
+afterEach(() => {
+  window.URL.createObjectURL = originalCreateObjectURL;
+  window.URL.revokeObjectURL = originalRevokeObjectURL;
+});
+
 test('Should render all menu items', () => {
   render(<MenuWrapper />, {
     useRedux: true,
@@ -50,3 +90,49 @@ test('Should render all menu items', () => {
   expect(screen.getByText('Export YAML')).toBeInTheDocument();
   expect(screen.getByText('Export as Example')).toBeInTheDocument();
 });
+
+test('Export as Example calls SupersetClient.get with correct endpoint', async 
() => {
+  const mockBlob = new Blob(['test'], { type: 'application/zip' });
+  const mockResponse: Pick<Response, 'blob' | 'headers'> = {
+    blob: jest.fn().mockResolvedValue(mockBlob),
+    headers: new Headers({
+      'Content-Disposition': 'attachment; 
filename="dashboard_123_example.zip"',
+    }),
+  };
+  mockSupersetClient.get.mockResolvedValue(mockResponse as unknown as 
Response);
+
+  // Mock URL.createObjectURL / revokeObjectURL since jsdom doesn't support 
them
+  const createObjectURL = jest.fn(() => 'blob:http://localhost/fake');
+  const revokeObjectURL = jest.fn();
+  window.URL.createObjectURL = createObjectURL;
+  window.URL.revokeObjectURL = revokeObjectURL;
+
+  render(<MenuWrapper />, { useRedux: true });
+
+  await userEvent.click(screen.getByText('Export as Example'));
+
+  await waitFor(() => {
+    expect(mockSupersetClient.get).toHaveBeenCalledWith({
+      endpoint: '/api/v1/dashboard/123/export_as_example/',
+      headers: { Accept: 'application/zip' },
+      parseMethod: 'raw',
+    });
+    expect(mockAddSuccessToast).toHaveBeenCalledWith(
+      'Dashboard exported as example successfully',
+    );
+  });
+});
+
+test('Export as Example shows error toast on failure', async () => {
+  mockSupersetClient.get.mockRejectedValue(new Error('Network error'));
+
+  render(<MenuWrapper />, { useRedux: true });
+
+  await userEvent.click(screen.getByText('Export as Example'));
+
+  await waitFor(() => {
+    expect(mockAddDangerToast).toHaveBeenCalledWith(
+      'Sorry, something went wrong. Try again later.',
+    );
+  });
+});
diff --git 
a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx 
b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx
index c4e646977e2..a3511e3cb83 100644
--- 
a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx
+++ 
b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx
@@ -35,7 +35,7 @@ import {
   LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_IMAGE,
 } from 'src/logger/LogUtils';
 import { useToasts } from 'src/components/MessageToasts/withToasts';
-import { ensureAppRoot } from 'src/utils/pathUtils';
+
 import { DownloadScreenshotFormat } from './types';
 
 export interface UseDownloadMenuItemsProps {
@@ -104,11 +104,8 @@ export const useDownloadMenuItems = (
 
   const onExportAsExample = async () => {
     try {
-      const endpoint = ensureAppRoot(
-        `/api/v1/dashboard/${dashboardId}/export_as_example/`,
-      );
       const response = await SupersetClient.get({
-        endpoint,
+        endpoint: `/api/v1/dashboard/${dashboardId}/export_as_example/`,
         headers: {
           Accept: 'application/zip',
         },

Reply via email to