Files
openobserve/tests/ui-testing/pages/alertsPages/alertBulkOperations.js
T
Shrinath Rao e1a265d7f0 test(alerts): heal Move-across-folders selection-wipe flake (#13672)
## What

Heals the recurring flake in `alerts-e2e-flow.spec.js:160` — **E2E:
Alert Folder Management - Move, Search, Verify** — which fails with
*"Move drawer failed to open after retries"* (the
`alert-list-move-across-folders-btn` never becomes visible). Also
hardens the sibling caller in `alerts-ui-operations.spec.js`.

**Test-only change. No `web/` product code is touched.**

## Root cause

`AlertList.vue`'s `getAlertsFn()` runs `selectedAlerts.value = []` at
the **start of every folder fetch**. The move / export / pause buttons
are `v-if="selectedAlerts.length > 0"`.

- Switching to a freshly-created (**uncached**) folder kicks off an
async `listByFolderId` fetch.
- `navigateToFolder` returns as soon as the folder tab is active and a
table is on screen — so `select-all` can fire **before** that fetch
resolves.
- When it resolves it **wipes the selection** → the move button
disappears → the drawer never opens.
- **Cached** folders serve synchronously with no wipe.

This precisely explains the observed signature:
- **Always failed attempt 1** (folder uncached), **passed attempt 2**
(retry warmed the cache).
- **Failed all 3 attempts under parallel load** — the slower API
guarantees the wipe lands after `select-all` every time.

`networkidle` doesn't catch it because streaming/websocket
(`ZO_STREAMING_ENABLED=true`) keeps connections open.

## The fix (`pages/alertsPages/alertBulkOperations.js`)

1. Before selecting, wait for the source folder's **own** row to render
(`expectAlertName`) — this proves the fetch resolved (wipe already
happened) and that we're on the right folder, not a stale table. Falls
back to "any row" when no name is passed (backward compatible).
2. Retry **select-all → click Move → assert drawer open** as **one
atomic `expect(...).toPass()`**. A fixed stability window isn't enough
(a second fetch can resolve later and wipe). The drawer is driven by its
own `showMoveAlertDialog` ref and captures the selection **at click
time**, so once open a later wipe cannot close it — the click just needs
to land while the selection is valid, which it always eventually does
once the folder is cached.

Both spec callers now pass `{ expectAlertName: alertName }`.

## Validation

Ran against the alpha cloud env on the exact failing scenario
(freshly-created uncached source folder, `--workers=1 --retries=0`):

- 1 initial run → **passed**
- `--repeat-each=3` → **3/3 passed**

`Move drawer opened with a valid selection` → `Moved all alerts to
target folder` on every run.

## Note

Not related to the concurrent Traces service-graph failures on alpha —
those are a separate main-vs-RC route skew (#13659 not in the deployed
RC build) and resolve on deploying latest `main`.
2026-08-06 13:33:22 +00:00

366 lines
16 KiB
JavaScript

/**
* AlertBulkOperations - Handles bulk alert operations
*
* This module contains methods for bulk operations on alerts:
* - Select multiple alerts
* - Bulk pause/unpause
* - Move alerts across folders
* - Delete all alerts in a folder
*/
import { expect } from '@playwright/test';
const testLogger = require('../../playwright-tests/utils/test-logger.js');
export class AlertBulkOperations {
constructor(page, commonActions, locators) {
this.page = page;
this.commonActions = commonActions;
this.locators = locators;
}
/**
* Select multiple alerts by name
* @param {string[]} alertNames - Array of alert names to select
*/
async selectMultipleAlerts(alertNames) {
// Wait for page to stabilize
await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
await this.page.waitForTimeout(1000);
for (const alertName of alertNames) {
const alertRow = this.page.locator(`tr:has-text("${alertName}")`).first();
const checkbox = alertRow.locator(this.locators.tableCheckbox);
// Wait for alert to be visible with a longer timeout
try {
await checkbox.waitFor({ state: 'visible', timeout: 10000 });
} catch (e) {
// If alert not visible, try to search for it
testLogger.info('Alert checkbox not immediately visible, searching...', { alertName });
await this.page.locator(this.locators.alertSearchInput).click();
await this.page.locator(this.locators.alertSearchInput).fill(alertName);
await this.page.waitForTimeout(2000);
await checkbox.waitFor({ state: 'visible', timeout: 10000 });
}
await checkbox.click();
await this.page.waitForTimeout(300);
testLogger.debug('Selected alert', { alertName });
}
testLogger.info('Selected multiple alerts', { count: alertNames.length });
}
/**
* Bulk pause all selected alerts
*/
async bulkPauseAlerts() {
const pauseBtn = this.page.locator('[data-test="alert-list-pause-alerts-btn"]');
await pauseBtn.waitFor({ state: 'visible', timeout: 5000 });
await pauseBtn.click();
await this.page.waitForTimeout(1000);
await expect(this.page.getByText(/Alerts? paused successfully/i).first()).toBeVisible({ timeout: 10000 });
testLogger.info('Bulk paused selected alerts');
}
/**
* Bulk unpause all selected alerts
*/
async bulkUnpauseAlerts() {
const unpauseBtn = this.page.locator('[data-test="alert-list-unpause-alerts-btn"]');
await unpauseBtn.waitFor({ state: 'visible', timeout: 5000 });
await unpauseBtn.click();
await this.page.waitForTimeout(1000);
await expect(this.page.getByText(/Alerts? resumed successfully/i).first()).toBeVisible({ timeout: 10000 });
testLogger.info('Bulk unpaused selected alerts');
}
/**
* Verify bulk pause button is visible when alerts are selected
*/
async verifyBulkPauseButtonVisible() {
const pauseBtn = this.page.locator('[data-test="alert-list-pause-alerts-btn"]');
await expect(pauseBtn).toBeVisible({ timeout: 5000 });
testLogger.info('Bulk pause button is visible');
}
/**
* Verify bulk unpause button is visible when alerts are selected
*/
async verifyBulkUnpauseButtonVisible() {
const unpauseBtn = this.page.locator('[data-test="alert-list-unpause-alerts-btn"]');
await expect(unpauseBtn).toBeVisible({ timeout: 5000 });
testLogger.info('Bulk unpause button is visible');
}
/**
* Get count of selected alerts from UI
* @returns {Promise<number>} Number of selected alerts
*/
async getSelectedAlertsCount() {
try {
const selectedText = await this.page.locator('text=/\\d+ selected/i').textContent({ timeout: 3000 });
const count = parseInt(selectedText.match(/(\d+)/)[1], 10);
return count;
} catch (e) {
return 0;
}
}
/**
* Select all alerts using the header checkbox
*/
async selectAllAlerts() {
const selectAllCheckbox = this.page.getByRole('row', { name: this.locators.selectAllCheckboxRowName }).getByRole('checkbox');
await selectAllCheckbox.waitFor({ state: 'visible', timeout: 5000 });
await selectAllCheckbox.click();
testLogger.debug('Selected all alerts');
}
/**
* Pause all selected alerts using the bulk pause button
*/
async pauseAllSelectedAlerts() {
const pauseButton = this.page.locator('[data-test="alert-list-pause-alerts-btn"]');
await pauseButton.waitFor({ state: 'visible', timeout: 5000 });
await pauseButton.click();
await this.page.waitForTimeout(1000);
testLogger.debug('Paused all selected alerts');
}
/**
* Move all alerts in current folder to another folder
* @param {string} targetFolderName - Target folder name
* @param {object} [options]
* @param {string} [options.expectAlertName] - Name of an alert expected in the SOURCE folder.
* When provided, we wait for THAT row to render before selecting — this proves the source
* folder's own data has loaded (see race note below) and that we are not looking at a stale
* table left over from the previous folder.
*/
async moveAllAlertsToFolder(targetFolderName, options = {}) {
const { expectAlertName } = options;
const headerCheckbox = this.page.locator(this.locators.headerCheckbox).first();
const moveBtn = this.page.locator(this.locators.moveAcrossFoldersButton);
await headerCheckbox.waitFor({ state: 'visible', timeout: 10000 });
// ROOT CAUSE (alerts-e2e-flow:160 "Move drawer failed to open"):
// AlertList.vue's getAlertsFn() runs `selectedAlerts.value = []` at the START of every
// folder fetch. Switching to a freshly-created (uncached) folder kicks off that async
// fetch, but navigateToFolder returns as soon as the tab is active + a table is on screen
// — so clicking select-all can fire BEFORE the fetch resolves, and when it resolves it
// WIPES the selection. The move-across-folders button is `v-if="selectedAlerts.length > 0"`
// so it vanishes ("Move button disappeared before click attempt"). A CACHED folder serves
// synchronously with no wipe — which is exactly why this historically failed attempt 1 and
// passed attempt 2 (retry warmed the cache), and became permanent under parallel load
// (slow listByFolderId => the wipe always lands after select-all). networkidle does not
// catch it because streaming/websocket keeps connections open.
// Fix: wait until the source folder's OWN data has settled (loading gone + the expected row
// rendered) so the wipe has already happened, THEN select and confirm the selection sticks.
// 1) Let the folder's load finish (getAlertsFn resolved) — best-effort, non-blocking.
await this.page.locator('[data-test="alert-list-loading-alert"]')
.waitFor({ state: 'hidden', timeout: 20000 }).catch(() => {});
// 2) Wait for the source folder's own rows to render. Prefer the specific expected alert
// (proves right folder + settled data); fall back to any row when no name is given.
if (expectAlertName) {
await this.page.locator('[data-test^="o2-table-row-"]')
.filter({ hasText: expectAlertName }).first()
.waitFor({ state: 'visible', timeout: 20000 });
} else {
await this.page.locator('[data-test^="o2-table-row-"]').first()
.waitFor({ state: 'visible', timeout: 20000 });
}
const toastDismissBtn = this.page.locator('button[aria-label="Dismiss notification"]');
const moveDrawer = this.page.locator('[data-test="dashboard-move-to-another-folder-dialog"]');
const scopedFolderDropdown = moveDrawer.locator(this.locators.folderDropdown);
// 3) ATOMIC open: (re)select-all → click move → confirm the drawer opened — as ONE retried
// block. A late getAlertsFn resolve can still wipe selectedAlerts AFTER select-all
// (hiding the move button, which is v-if="selectedAlerts.length > 0"), so a fixed
// stability window is not enough. Instead we retry the WHOLE open until the drawer is
// up: the drawer is driven by its own `showMoveAlertDialog` ref and captures the
// selection at click time, so once it opens a later wipe cannot close it. Each
// iteration re-selects if the button vanished, so the operation only needs ONE click
// to land while the selection is valid — which it eventually does once the folder is
// cached and no further fetch fires. Also dismisses toasts that intercept the click.
await expect(async () => {
// Already open (from a prior iteration whose success check hadn't settled)? Done.
if (await moveDrawer.isVisible().catch(() => false)
&& await scopedFolderDropdown.isVisible().catch(() => false)) {
return;
}
// Toasts (folder-added, clone-validation errors) render over the button and eat clicks.
let dc = await toastDismissBtn.count().catch(() => 0);
while (dc > 0) {
await toastDismissBtn.first().click({ force: true }).catch(() => {});
await this.page.waitForTimeout(150);
dc = await toastDismissBtn.count().catch(() => 0);
}
// Ensure the selection is present (re-tick if a fetch wiped it).
if (!(await headerCheckbox.isChecked().catch(() => false))) {
await headerCheckbox.click({ force: true });
}
await expect(moveBtn).toBeVisible({ timeout: 3000 });
await moveBtn.click({ force: true, timeout: 5000 });
await expect(moveDrawer).toBeVisible({ timeout: 5000 });
await expect(scopedFolderDropdown).toBeVisible({ timeout: 5000 });
}).toPass({ timeout: 60000 });
testLogger.info('Move drawer opened with a valid selection');
await scopedFolderDropdown.click();
await this.page.waitForTimeout(2000);
await this.commonActions.scrollAndFindOption(targetFolderName, 'folder');
// Click move button and verify success message.
// The toast auto-dismisses after only 2s, so do NOT wait for networkidle
// between the click and the toast check — it would burn the toast's lifetime.
await this.page.locator(this.locators.moveButton).click();
await expect(this.page.locator('[data-test-variant="success"] [data-test="o-toast-message"]').filter({ hasText: this.locators.alertsMovedMessage })).toBeVisible({ timeout: 15000 });
testLogger.info('Move operation confirmed via success message');
// Wait for UI to update
await this.page.waitForTimeout(2000);
// Verify the source folder is now empty by checking for "No data available"
// This is the expected state after moving ALL alerts from a folder
await expect(this.page.locator('[data-test="o2-empty-state"]')).toBeVisible({ timeout: 15000 });
testLogger.info('Successfully moved alerts to folder', { targetFolderName });
}
/**
* Delete all alerts in the current folder one by one
*/
async deleteAllAlertsInFolder() {
testLogger.info('Starting to delete all alerts in current folder');
await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
await this.page.waitForTimeout(2000);
let totalDeleted = 0;
while (true) {
const hasAny = await this.hasAlerts();
if (!hasAny) {
testLogger.info('No alerts found to delete', { totalDeleted });
break;
}
let alertNames = await this.getAllAlertNames();
if (alertNames.length === 0) {
testLogger.info('No alert names found, stopping');
break;
}
testLogger.info('Found alerts to delete in this batch', { count: alertNames.length, alertNames });
for (const alertName of alertNames) {
testLogger.debug('Deleting alert', { alertName });
await this.deleteAlertByRow(alertName);
totalDeleted++;
}
await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
await this.page.waitForTimeout(1000);
}
// Each deletion is already verified individually via alertDeletedMessage assertion
// No additional verification needed - the loop ensures each alert was deleted
testLogger.info('All alerts in folder deleted', { totalDeleted });
}
/**
* Check if alerts exist in current view
* @returns {Promise<boolean>}
*/
async hasAlerts() {
try {
const count = await this.getAlertCount();
return count > 0;
} catch (e) {
return false;
}
}
/**
* Get the current alert count from the pagination text
* @returns {Promise<number>}
*/
async getAlertCount() {
try {
const paginationText = await this.page.locator('text=/Showing \\d+ - \\d+ of/').textContent({ timeout: 3000 });
const match = paginationText.match(/of (\d+)/);
if (match) {
const count = parseInt(match[1], 10);
testLogger.debug('Current alert count', { count });
return count;
}
} catch (e) {
testLogger.debug('No pagination text found, checking for "No data available"');
}
try {
const noData = await this.page.locator('[data-test="o2-empty-state"]').isVisible({ timeout: 1000 });
if (noData) {
testLogger.debug('No alerts found in folder');
return 0;
}
} catch (e) {
// Neither pagination nor empty state found
}
return 0;
}
/**
* Get all alert names from the current page
* @returns {Promise<string[]>}
*/
async getAllAlertNames() {
const alertNames = [];
const moreOptionsButtons = await this.page.locator('[data-test*="alert-list-"][data-test*="-more-options"]').all();
for (const button of moreOptionsButtons) {
const dataTest = await button.getAttribute('data-test');
const match = dataTest.match(/alert-list-(.+)-more-options/);
if (match && match[1]) {
alertNames.push(match[1]);
}
}
testLogger.debug('Found alert names', { alertNames, count: alertNames.length });
return alertNames;
}
/**
* Delete alert by name (helper method for bulk operations)
* @param {string} alertName - Name of the alert to delete
*/
async deleteAlertByRow(alertName) {
const kebabButton = this.page.locator(`[data-test="alert-list-${alertName}-more-options"]`).first();
await kebabButton.waitFor({ state: 'visible', timeout: 5000 });
await kebabButton.click();
const deleteOption = this.page.locator(`[data-test="alert-list-${alertName}-delete-alert"]`);
try {
await deleteOption.waitFor({ state: 'visible', timeout: 3000 });
} catch (e) {
testLogger.warn('Delete option not visible after first kebab click, retrying', { alertName });
await kebabButton.click();
await deleteOption.waitFor({ state: 'visible', timeout: 5000 });
}
await deleteOption.click();
await this.page.locator(this.locators.confirmButton).click();
await expect(this.page.locator('[data-test-variant="success"] [data-test="o-toast-message"]').filter({ hasText: this.locators.alertDeletedMessage })).toBeVisible();
await this.page.waitForTimeout(1000);
}
}