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 { + * 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 { + 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 { + 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 { + 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 { + 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 { 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 { + 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) + * 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 openDownloadMenu(): Promise { - // 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' }); - } + async selectDownloadOption(optionText: string): Promise { + await this.openHeaderActionsMenu(); - /** - * Click "Export YAML" in the download menu - * Returns a Promise that resolves when download starts - */ - async clickExportYaml(): Promise { + const menu = new Menu( + this.page, + DashboardPage.SELECTORS.HEADER_ACTIONS_MENU, + ); const downloadPromise = this.page.waitForEvent('download'); - await this.page.getByText('Export YAML').click(); - return downloadPromise; - } - - /** - * Click "Export as Example" in the download menu - * Returns a Promise that resolves when download starts - */ - async clickExportAsExample(): Promise { - 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 }[] = []; + +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(); + const toast = new Toast(page); + const download = await dashboardPage.selectDownloadOption('Export YAML'); + downloads.push(download); - // 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$/); + 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(); + const toast = new Toast(page); + const download = + await dashboardPage.selectDownloadOption('Export as Example'); + downloads.push(download); - // 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 }); + 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; + const createProps = () => ({ pdfMenuItemTitle: 'Export to PDF', imageMenuItemTitle: 'Download as Image', @@ -37,6 +65,18 @@ const MenuWrapper = () => { return ; }; +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(, { 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 = { + 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(, { 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(, { 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', },