diff --git a/superset-frontend/playwright/components/ListView/BulkSelect.ts b/superset-frontend/playwright/components/ListView/BulkSelect.ts index 3e4d2dbf87b..3286a67e1b5 100644 --- a/superset-frontend/playwright/components/ListView/BulkSelect.ts +++ b/superset-frontend/playwright/components/ListView/BulkSelect.ts @@ -17,12 +17,13 @@ * under the License. */ -import { Locator, Page } from '@playwright/test'; +import { Locator, Page, expect } from '@playwright/test'; import { Button, Checkbox, Table } from '../core'; const BULK_SELECT_SELECTORS = { CONTROLS: '[data-test="bulk-select-controls"]', ACTION: '[data-test="bulk-select-action"]', + HEADER_TOGGLE: '[data-test="header-toggle-all"]', } as const; /** @@ -56,10 +57,17 @@ export class BulkSelect { } /** - * Enables bulk selection mode by clicking the toggle button + * Enables bulk selection mode by clicking the toggle button. + * + * Waits for the bulk-select column header checkbox to render so the next + * row interaction does not race the table re-render that adds the + * checkbox column. */ async enable(): Promise { await this.getToggleButton().click(); + await this.page + .locator(BULK_SELECT_SELECTORS.HEADER_TOGGLE) + .waitFor({ state: 'visible' }); } /** @@ -72,11 +80,16 @@ export class BulkSelect { } /** - * Selects a row's checkbox in bulk select mode + * Selects a row's checkbox in bulk select mode. + * Asserts the checkbox is checked afterwards so any state-update race + * surfaces here rather than as a missing bulk-action button later. * @param rowName - The name/text identifying the row to select */ async selectRow(rowName: string): Promise { - await this.getRowCheckbox(rowName).check(); + const checkbox = this.getRowCheckbox(rowName); + await checkbox.element.waitFor({ state: 'visible' }); + await checkbox.check(); + await expect(checkbox.element).toBeChecked(); } /** @@ -107,10 +120,15 @@ export class BulkSelect { } /** - * Clicks a bulk action button by name (e.g., "Export", "Delete") + * Clicks a bulk action button by name (e.g., "Export", "Delete"). + * + * The action buttons only render once a row is selected; waiting for + * visibility makes that timing contract explicit before the click. * @param actionName - The name of the bulk action to click */ async clickAction(actionName: string): Promise { - await this.getActionButton(actionName).click(); + const button = this.getActionButton(actionName); + await button.element.waitFor({ state: 'visible' }); + await button.click(); } } diff --git a/superset-frontend/playwright/components/modals/DeleteConfirmationModal.ts b/superset-frontend/playwright/components/modals/DeleteConfirmationModal.ts index 44dca9e6b26..776b44838da 100644 --- a/superset-frontend/playwright/components/modals/DeleteConfirmationModal.ts +++ b/superset-frontend/playwright/components/modals/DeleteConfirmationModal.ts @@ -17,6 +17,7 @@ * under the License. */ +import { expect } from '@playwright/test'; import { Modal, Input } from '../core'; /** @@ -27,7 +28,8 @@ import { Modal, Input } from '../core'; */ export class DeleteConfirmationModal extends Modal { private static readonly SELECTORS = { - CONFIRMATION_INPUT: 'input[type="text"]', + CONFIRMATION_INPUT: '[data-test="delete-modal-input"]', + CONFIRM_BUTTON: '[data-test="modal-confirm-button"]', }; /** @@ -36,12 +38,16 @@ export class DeleteConfirmationModal extends Modal { private get confirmationInput(): Input { return new Input( this.page, - this.body.locator(DeleteConfirmationModal.SELECTORS.CONFIRMATION_INPUT), + this.element.locator( + DeleteConfirmationModal.SELECTORS.CONFIRMATION_INPUT, + ), ); } /** * Fills the confirmation input with the specified text. + * Waits for the input to be visible before filling so callers don't race + * with the modal's open animation / focus effect. * * @param confirmationText - The text to type * @param options - Optional fill options (timeout, force) @@ -57,11 +63,21 @@ export class DeleteConfirmationModal extends Modal { confirmationText: string, options?: { timeout?: number; force?: boolean }, ): Promise { + await this.confirmationInput.element.waitFor({ + state: 'visible', + timeout: options?.timeout, + }); await this.confirmationInput.fill(confirmationText, options); } /** - * Clicks the Delete button in the footer + * Clicks the Delete button in the footer. + * + * Waits for the confirm button to become enabled before clicking. The button + * is disabled until the confirmation text matches "DELETE", and React's state + * update from fillConfirmationInput is asynchronous; the explicit + * toBeEnabled() check makes the timing contract explicit and avoids racing + * the disabled→enabled transition. * * @param options - Optional click options (timeout, force, delay) */ @@ -70,6 +86,10 @@ export class DeleteConfirmationModal extends Modal { force?: boolean; delay?: number; }): Promise { - await this.clickFooterButton('Delete', options); + const confirmButton = this.element.locator( + DeleteConfirmationModal.SELECTORS.CONFIRM_BUTTON, + ); + await expect(confirmButton).toBeEnabled({ timeout: options?.timeout }); + await confirmButton.click(options); } } diff --git a/superset-frontend/playwright/tests/dashboard/dashboard-list.spec.ts b/superset-frontend/playwright/tests/dashboard/dashboard-list.spec.ts index 1def93acecb..6e6073c3f11 100644 --- a/superset-frontend/playwright/tests/dashboard/dashboard-list.spec.ts +++ b/superset-frontend/playwright/tests/dashboard/dashboard-list.spec.ts @@ -68,33 +68,35 @@ test('should delete a dashboard with confirmation', async ({ await dashboardListPage.goto(); await dashboardListPage.waitForTableLoad(); - // Verify dashboard is visible in list - await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible(); + // The list query is asynchronous; allow extra time on slow CI before the + // freshly-created dashboard appears. + await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible({ + timeout: TIMEOUT.API_RESPONSE, + }); // Click delete action button await dashboardListPage.clickDeleteAction(dashboardName); // Delete confirmation modal should appear const deleteModal = new DeleteConfirmationModal(page); - await deleteModal.waitForVisible(); + await deleteModal.waitForReady(); // Type "DELETE" to confirm await deleteModal.fillConfirmationInput('DELETE'); - // Click the Delete button + // Click the Delete button (waits for it to become enabled) await deleteModal.clickDelete(); // Modal should close await deleteModal.waitForHidden(); - // Verify success toast appears + // Verify success toast appears. Use waitFor instead of toBeVisible so we + // detect the toast even if it auto-dismisses on a fast machine. const toast = new Toast(page); - await expect(toast.getSuccess()).toBeVisible(); + await toast.getSuccess().waitFor({ state: 'visible' }); // Verify dashboard is removed from list - await expect( - dashboardListPage.getDashboardRow(dashboardName), - ).not.toBeVisible(); + await expect(dashboardListPage.getDashboardRow(dashboardName)).toHaveCount(0); // Backend verification: API returns 404 await expectDeleted(page, ENDPOINTS.DASHBOARD, dashboardId, { @@ -227,25 +229,30 @@ test('should bulk export multiple dashboards', async ({ await dashboardListPage.goto(); await dashboardListPage.waitForTableLoad(); - // Verify both dashboards are visible in list - await expect( - dashboardListPage.getDashboardRow(dashboard1.name), - ).toBeVisible(); - await expect( - dashboardListPage.getDashboardRow(dashboard2.name), - ).toBeVisible(); + // The list query is asynchronous; allow extra time on slow CI before the + // freshly-created dashboards appear. + await expect(dashboardListPage.getDashboardRow(dashboard1.name)).toBeVisible({ + timeout: TIMEOUT.API_RESPONSE, + }); + await expect(dashboardListPage.getDashboardRow(dashboard2.name)).toBeVisible({ + timeout: TIMEOUT.API_RESPONSE, + }); - // Enable bulk select mode + // Enable bulk select mode (waits for the checkbox column to render) await dashboardListPage.clickBulkSelectButton(); - // Select both dashboards + // Select both dashboards (each call asserts the checkbox is checked) await dashboardListPage.selectDashboardCheckbox(dashboard1.name); await dashboardListPage.selectDashboardCheckbox(dashboard2.name); - // Set up API response intercept for export endpoint - const exportResponsePromise = waitForGet(page, ENDPOINTS.DASHBOARD_EXPORT); + // Set up API response intercept BEFORE the click that triggers it. + // Use the configured API_RESPONSE timeout — exports of multiple dashboards + // can run longer than Playwright's default 30s timeout under load. + const exportResponsePromise = waitForGet(page, ENDPOINTS.DASHBOARD_EXPORT, { + timeout: TIMEOUT.API_RESPONSE, + }); - // Click bulk export action + // Click bulk export action (waits for the action button to render) await dashboardListPage.clickBulkAction('Export'); // Wait for export API response and validate zip contains both dashboards