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',
},