fix(playwright): de-flake dashboard-list delete and bulk-export tests

Harden the delete-confirmation modal and bulk-select page objects against
known Playwright race patterns: explicit data-test selectors, an
enabled-state wait before clicking the disabled-by-default Delete button,
a header-toggle wait after enabling bulk select, and visibility gates on
checkboxes and action buttons that mount only after a row is selected.
Also extend list-row visibility timeouts for slow CI and switch the
post-delete assertion to toHaveCount(0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joe Li
2026-05-06 22:00:13 -07:00
parent 5b5dd01028
commit 4c4f56cd8a
3 changed files with 76 additions and 31 deletions

View File

@@ -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<void> {
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<void> {
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<void> {
await this.getActionButton(actionName).click();
const button = this.getActionButton(actionName);
await button.element.waitFor({ state: 'visible' });
await button.click();
}
}

View File

@@ -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<void> {
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<void> {
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);
}
}

View File

@@ -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