diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 1194847104..a4dc3ce957 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -245,6 +245,7 @@ jobs: "alerts-metrics-notification.spec.js", "alerts-destinations-prebuilt.spec.js", "alerts-vrl-encoding.spec.js", + "alerts-history.spec.js", "alerts-template-prebuilt-guard.spec.js", "alerts-form-validation.spec.js", "anomaly-detection-form-validation.spec.js", @@ -336,6 +337,7 @@ jobs: "reportsScheduleNow.spec.js", "reportsScheduleLater.spec.js", "reportFolders.spec.js", + "reports-bulk-operations.spec.js", "reports-form-validation.spec.js", ] - testfolder: "Streams" diff --git a/tests/ui-testing/pages/alertsPages/alertHistoryPage.js b/tests/ui-testing/pages/alertsPages/alertHistoryPage.js new file mode 100644 index 0000000000..4f9145249e --- /dev/null +++ b/tests/ui-testing/pages/alertsPages/alertHistoryPage.js @@ -0,0 +1,176 @@ +import { expect } from '@playwright/test'; + +export class AlertHistoryPage { + constructor(page) { + this.page = page; + + // Page container + this.pageContainer = '[data-test="alert-history-page"]'; + this.pageTitle = '[data-test="alerts-history-title"]'; + + // Controls + this.backBtn = '[data-test="alert-history-back-btn"]'; + this.datePicker = '[data-test="alert-history-date-picker"]'; + this.searchSelect = '[data-test="alert-history-search-select"]'; + this.manualSearchBtn = '[data-test="alert-history-manual-search-btn"]'; + this.refreshBtn = '[data-test="alert-history-refresh-btn"]'; + + // Results + this.table = '[data-test="alert-history-table"]'; + this.viewDetailsBtn = '[data-test="alert-history-view-details"]'; + // ODialog forwards data-test to DialogContent via parentDataTest computed; + // [role="dialog"] is the ARIA fallback but the forwarded attr is more specific. + this.alertDetailsDialog = '[data-test="alert-history-details-dialog"]'; + this.emptyState = '[data-test="o2-table-empty"]'; + } + + async navigate() { + await this.page.goto( + `${process.env["ZO_BASE_URL"]}/web/alerts/history?org_identifier=${process.env["ORGNAME"]}`, + { waitUntil: 'domcontentloaded' } + ); + await this.page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {}); + await expect(this.page.locator(this.pageContainer)).toBeVisible({ timeout: 15000 }); + } + + async expectPageTitleVisible() { + await expect(this.page.locator(this.pageTitle)).toContainText('Alert History'); + } + + async selectAlert(alertName) { + // alert-history-search-select is OSelect in listbox mode (searchable defaults to true). + // The ListboxFilter input is rendered in a PopoverPortal — it is NOT a DOM child of + // the OSelect wrapper, so `[data-test="...search-select"] input` never matches. + // Use the forwarded data-test sub-keys instead: -trigger, -popover, -search, -option. + const trigger = this.page.locator('[data-test="alert-history-search-select-trigger"]'); + await trigger.waitFor({ state: 'visible', timeout: 10000 }); + await trigger.click(); + + const popover = this.page.locator('[data-test="alert-history-search-select-popover"]'); + await popover.waitFor({ state: 'visible', timeout: 5000 }); + + // Type the alert name to filter — avoids virtualiser rendering only visible rows + const searchInput = this.page.locator('[data-test="alert-history-search-select-search"]'); + await searchInput.waitFor({ state: 'visible', timeout: 5000 }); + await searchInput.fill(alertName); + await this.page.waitForTimeout(300); + + const option = this.page.locator(`[data-test="alert-history-search-select-option"][data-test-label="${alertName}"]`); + await option.waitFor({ state: 'visible', timeout: 5000 }); + await option.click(); + + await popover.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}); + } + + async clickManualSearch() { + await this.page.locator(this.manualSearchBtn).click(); + await this.page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {}); + } + + async clickRefresh() { + await this.page.locator(this.refreshBtn).click(); + await this.page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {}); + } + + async clickBack() { + await this.page.locator(this.backBtn).click(); + await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); + } + + async expectTableVisible() { + await expect(this.page.locator(this.table)).toBeVisible({ timeout: 10000 }); + } + + async expectTableHasRows() { + // Exclude OTableLoading skeleton tbody to avoid false positives before real data loads + const rows = this.page.locator(`${this.table} tbody:not([data-test="o2-table-skeleton-body"]) tr`); + await expect(rows.first()).toBeVisible({ timeout: 10000 }); + } + + async getTableRowCount() { + // Exclude OTableLoading skeleton tbody rows + const rows = this.page.locator(`${this.table} tbody:not([data-test="o2-table-skeleton-body"]) tr`); + return await rows.count(); + } + + async clickViewDetails(index = 0) { + // Wait for the skeleton to finish and OTable to complete loading + await this.page.locator('[data-test="o2-table-skeleton-body"]') + .waitFor({ state: 'hidden', timeout: 15000 }) + .catch(() => {}); + await this.page.locator('[data-test="o2-table"][data-test-loading="false"]') + .waitFor({ state: 'visible', timeout: 10000 }) + .catch(() => {}); + + // If the details dialog already opened (from a prior click attempt), skip re-clicking. + const alreadyOpen = await this.page.locator(this.alertDetailsDialog) + .isVisible({ timeout: 300 }).catch(() => false); + if (alreadyOpen) return; + + // Dismiss any stale ODialog overlay blocking pointer events before clicking. + const overlay = this.page.locator('[data-test="o-dialog-overlay"]'); + if (await overlay.isVisible({ timeout: 500 }).catch(() => false)) { + await this.page.keyboard.press('Escape'); + await overlay.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}); + } + + const byDataTest = this.page.locator(this.viewDetailsBtn); + const byCell = this.page.locator('[data-test="o2-table-cell-actions"]').nth(index).locator('button').first(); + const btn = (await byDataTest.count() > 0) ? byDataTest.nth(index) : byCell; + await btn.waitFor({ state: 'visible', timeout: 10000 }); + await btn.scrollIntoViewIfNeeded(); + await btn.click({ force: true }); + } + + async expectViewDetailsBtnVisible() { + await expect(this.page.locator(this.viewDetailsBtn).first()).toBeVisible({ timeout: 5000 }); + } + + async expectBackBtnVisible() { + await expect(this.page.locator(this.backBtn)).toBeVisible({ timeout: 5000 }); + } + + async expectSearchSelectVisible() { + await expect(this.page.locator(this.searchSelect)).toBeVisible({ timeout: 5000 }); + } + + async expectManualSearchBtnVisible() { + await expect(this.page.locator(this.manualSearchBtn)).toBeVisible({ timeout: 5000 }); + } + + async expectRefreshBtnVisible() { + await expect(this.page.locator(this.refreshBtn)).toBeVisible({ timeout: 5000 }); + } + + async expectDatePickerVisible() { + await expect(this.page.locator(this.datePicker)).toBeVisible({ timeout: 5000 }); + } + + async expectDetailsDialogVisible() { + // Primary: forwarded data-test on ODialog's DialogContent. + // Fallback: data-o2-dialog is a static attribute always present on DialogContent, + // used when $attrs["data-test"] forwarding hasn't propagated yet in CI. + const byDataTest = this.page.locator(this.alertDetailsDialog); + const byStaticAttr = this.page.locator('[data-o2-dialog]'); + const found = await byDataTest.isVisible({ timeout: 20000 }).catch(() => false) + || await byStaticAttr.isVisible({ timeout: 2000 }).catch(() => false); + if (!found) { + await expect(byDataTest).toBeVisible({ timeout: 1000 }); + } + } + + async expectTableOrEmptyStateVisible() { + // Check for data rows OR the OTable empty-state panel + const [rowsVisible, emptyVisible] = await Promise.all([ + this.page.locator(`${this.table} tbody tr`).first().isVisible({ timeout: 10000 }).catch(() => false), + this.page.locator(this.emptyState).isVisible({ timeout: 5000 }).catch(() => false), + ]); + if (!rowsVisible && !emptyVisible) { + throw new Error('Expected either history table rows or the empty-state to be visible after search'); + } + } + + async expectEmptyStateVisible() { + await expect(this.page.locator(this.emptyState)).toBeVisible({ timeout: 5000 }); + } +} diff --git a/tests/ui-testing/pages/alertsPages/alertsPage.js b/tests/ui-testing/pages/alertsPages/alertsPage.js index ece78cc661..3ffb047c7d 100644 --- a/tests/ui-testing/pages/alertsPages/alertsPage.js +++ b/tests/ui-testing/pages/alertsPages/alertsPage.js @@ -1393,6 +1393,10 @@ export class AlertsPage { /** * Verify alert list table is visible */ + async expectAlertListPageVisible() { + await expect(this.page.locator(this.locators.alertListPage)).toBeVisible({ timeout: 15000 }); + } + async expectAlertListTableVisible() { await expect(this.page.locator(this.locators.alertListTable)).toBeVisible({ timeout: 10000 }); } diff --git a/tests/ui-testing/pages/apiCleanup.js b/tests/ui-testing/pages/apiCleanup.js index 844e246a2a..4468c35598 100644 --- a/tests/ui-testing/pages/apiCleanup.js +++ b/tests/ui-testing/pages/apiCleanup.js @@ -270,6 +270,37 @@ class APICleanup { } } + /** + * Create a minimal dashboard for use as a report dependency. + * Returns { dashboardId, folderId } or throws on failure. + */ + async createMinimalDashboard(title = 'E2E Setup Dashboard', folderId = 'default') { + const payload = { + title, + description: '', + role: '', + owner: this.email, + tabs: [{ tabId: 'default', name: 'Default', panels: [] }], + variables: {} + }; + const response = await this._fetch( + `${this.baseUrl}/api/${this.org}/dashboards?folder=${encodeURIComponent(folderId)}`, + { + method: 'POST', + headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + } + ); + if (!response.ok) { + const body = await response.text(); + throw new Error(`createMinimalDashboard: HTTP ${response.status} — ${body}`); + } + const result = await response.json(); + const dashboardId = result.dashboard_id || result.dashboardId || result.id; + testLogger.info('Created minimal dashboard', { dashboardId, folderId }); + return { dashboardId, folderId }; + } + /** * Delete a single dashboard * @param {string} dashboardId - The dashboard ID diff --git a/tests/ui-testing/pages/page-manager.js b/tests/ui-testing/pages/page-manager.js index ce466fd41f..9b2125f6ef 100644 --- a/tests/ui-testing/pages/page-manager.js +++ b/tests/ui-testing/pages/page-manager.js @@ -24,6 +24,7 @@ import DashboardPanelTime from "./dashboardPages/dashboard-panel-time"; import LogsVisualise from "./dashboardPages/visualise"; import { DashboardPage } from "./dashboardPages/dashboardPage.js"; import { AlertsPage } from "./alertsPages/alertsPage.js"; +import { AlertHistoryPage } from "./alertsPages/alertHistoryPage.js"; // ===== SANITY SPEC ADDITIONAL PAGE OBJECTS ===== import { LogsPage } from "./logsPages/logsPage.js"; @@ -135,6 +136,7 @@ class PageManager { // ===== EXISTING ALERTS PAGE OBJECT ===== this.alertsPage = new AlertsPage(page); + this.alertHistoryPage = new AlertHistoryPage(page); // ===== API CLEANUP ===== this.apiCleanup = new APICleanup(page); diff --git a/tests/ui-testing/pages/reportsPages/reportCreation.js b/tests/ui-testing/pages/reportsPages/reportCreation.js index 66e309648f..f5af6d4180 100644 --- a/tests/ui-testing/pages/reportsPages/reportCreation.js +++ b/tests/ui-testing/pages/reportsPages/reportCreation.js @@ -12,10 +12,14 @@ export async function createReportViaApi(api, reportName, folderId = 'default') testLogger.info('Creating report via API', { reportName, folderId }); try { - const dashboards = await api.fetchDashboardsInFolder(folderId); + let dashboards = await api.fetchDashboardsInFolder(folderId); if (dashboards.length === 0) { - testLogger.error('No dashboards found in folder, cannot create report', { folderId }); - return { success: false, error: 'No dashboards available' }; + testLogger.info('No dashboards in folder — creating minimal setup dashboard', { folderId }); + await api.createMinimalDashboard(`e2e_setup_dashboard_${Date.now()}`, folderId); + dashboards = await api.fetchDashboardsInFolder(folderId); + if (dashboards.length === 0) { + return { success: false, error: 'Could not create a setup dashboard for report' }; + } } const dashboard = dashboards[0]; diff --git a/tests/ui-testing/pages/reportsPages/reportFoldersPage.js b/tests/ui-testing/pages/reportsPages/reportFoldersPage.js index ed3cfdf1a4..82cb63c2a7 100644 --- a/tests/ui-testing/pages/reportsPages/reportFoldersPage.js +++ b/tests/ui-testing/pages/reportsPages/reportFoldersPage.js @@ -140,13 +140,9 @@ export class ReportFoldersPage { `${process.env["ZO_BASE_URL"]}/web/reports?org_identifier=${process.env["ORGNAME"]}`, { waitUntil: 'domcontentloaded' } ); - await this.page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => { - console.warn('navigateToReports: networkidle timed out, continuing'); - }); + await this.page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {}); await expect(this.pageTitle).toContainText('Reports'); - await this.folderTabsContainer.waitFor({ state: 'visible', timeout: 15000 }).catch(() => { - console.warn('navigateToReports: folderTabsContainer not visible, continuing'); - }); + await this.folderTabsContainer.waitFor({ state: 'visible', timeout: 15000 }).catch(() => {}); } async clickAddFolder() { @@ -223,8 +219,9 @@ export class ReportFoldersPage { async clickDeleteFolder(folderName) { await this.clickMoreIcon(folderName); - await this.deleteFolderIcon.click({ force: true }); - await expect(this.confirmDeleteDialog).toBeVisible({ timeout: 5000 }); + await this.deleteFolderIcon.waitFor({ state: 'visible', timeout: 5000 }); + await this.deleteFolderIcon.click(); + await expect(this.confirmDeleteDialog).toBeVisible({ timeout: 10000 }); } async confirmDelete() { @@ -308,11 +305,14 @@ export class ReportFoldersPage { async clickMove() { await this.moveSubmitBtn.click(); await this.moveDialog.waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {}); + // Wait for ODialog close animation to finish before next interaction + await this.page.locator('[data-test="o-dialog-overlay"]').waitFor({ state: 'hidden', timeout: 3000 }).catch(() => {}); } async cancelMove() { await this.moveCancelBtn.first().click(); await this.moveDialog.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}); + await this.page.locator('[data-test="o-dialog-overlay"]').waitFor({ state: 'hidden', timeout: 3000 }).catch(() => {}); } async expectDefaultFolderExists() { @@ -351,6 +351,10 @@ export class ReportFoldersPage { await expect(this.reportPauseStartBtn(reportName)).toBeVisible({ timeout: 10000 }); } + async expectReportNotVisibleInTable(reportName) { + await expect(this.reportPauseStartBtn(reportName)).not.toBeVisible({ timeout: 5000 }); + } + async getFolderCount() { // Every OTab in the sidebar carries a `data-test="dashboard-folder-tab-"`. return await this.page @@ -386,4 +390,58 @@ export class ReportFoldersPage { } return false; } + + // ===== BULK OPERATION METHODS ===== + // Selectors for bulk report operations (move/delete) in main branch. + // Note: bulk pause/resume are not available in main's ReportList.vue. + // Bulk delete confirmation uses ConfirmDialog (data-test="confirm-dialog") + // with ODialog's standard primary/secondary button pattern. + + async selectAllReports() { + // OTableHeader passes row-id="all" to OTableSelectCheckbox, making the + // header checkbox data-test "o2-table-select-all" (not "header"). + const headerCheckbox = this.page.locator('[data-test="o2-table-select-all"]'); + await headerCheckbox.waitFor({ state: 'visible', timeout: 10000 }); + await headerCheckbox.click(); + await this.page.waitForTimeout(300); + } + + async expectBulkButtonsVisible() { + await expect(this.page.locator('[data-test="report-list-move-reports-btn"]')).toBeVisible({ timeout: 5000 }); + await expect(this.page.locator('[data-test="report-list-delete-reports-btn"]')).toBeVisible({ timeout: 5000 }); + } + + async expectBulkButtonsHidden() { + await expect(this.page.locator('[data-test="report-list-move-reports-btn"]')).not.toBeVisible({ timeout: 3000 }); + } + + async clickBulkMove() { + await this.page.locator('[data-test="report-list-move-reports-btn"]').click(); + await expect(this.moveDialog).toBeVisible({ timeout: 5000 }); + } + + async clickBulkDelete() { + await this.page.locator('[data-test="report-list-delete-reports-btn"]').click(); + await expect(this.page.locator('[data-test="confirm-dialog"]')).toBeVisible({ timeout: 5000 }); + } + + async confirmBulkDelete() { + const confirmBtn = this.page.locator('[data-test="confirm-dialog"] [data-test="o-dialog-primary-btn"]'); + await confirmBtn.waitFor({ state: 'visible', timeout: 5000 }); + await confirmBtn.click({ force: true }); + await this.page.locator('[data-test="confirm-dialog"]').waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {}); + await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); + } + + async cancelBulkDelete() { + const cancelBtn = this.page.locator('[data-test="confirm-dialog"] [data-test="o-dialog-secondary-btn"]'); + await cancelBtn.click(); + await this.page.locator('[data-test="confirm-dialog"]').waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}); + } + + async expectBulkDeleteSuccessVisible() { + await expect( + this.page.locator('[data-test="o-toast-message"]').filter({ hasText: /deleted successfully/i }) + ).toBeVisible({ timeout: 10000 }); + } } diff --git a/tests/ui-testing/pages/reportsPages/reportsFormValidationPage.js b/tests/ui-testing/pages/reportsPages/reportsFormValidationPage.js index d48fbd4839..1d745aa316 100644 --- a/tests/ui-testing/pages/reportsPages/reportsFormValidationPage.js +++ b/tests/ui-testing/pages/reportsPages/reportsFormValidationPage.js @@ -139,25 +139,29 @@ export class ReportsFormValidationPage { } async selectFirstDashboardFolder() { - // Wait for the folder fetch to complete before opening the dropdown. - // getDashboaordFolders() runs in onBeforeMount (async, non-blocking). The trigger's - // loading spinner disappears once the fetch completes and options are populated. const folderTrigger = this.page.locator('[data-test="add-report-dashboard-folder-select-trigger"]'); await folderTrigger.waitFor({ state: 'visible', timeout: 10000 }); - // Wait for the folders API call to complete before opening the dropdown. - // getDashboaordFolders() fires in onBeforeMount async — networkidle ensures it has resolved. await this.page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {}); - await folderTrigger.click(); - await this.page.locator(this.dashboardFolderPopover).waitFor({ state: 'visible', timeout: 10000 }); - // Wait for any option to appear (virtualizer renders items after measuring - // the container height) — this attached-state wait is the deterministic - // readiness signal that replaces a fixed settle delay. + const anyOpt = this.page.locator('[data-test^="add-report-dashboard-folder-select-option"]').first(); - await anyOpt.waitFor({ state: 'attached', timeout: 10000 }); - // Prefer the "default" folder, but fall back to the first rendered option - // when the virtualizer has not materialised "default" into the DOM window. + let optionsReady = false; + + // folderOptions is fetched async in onBeforeMount — if the API hasn't resolved by the + // time we click, OSelect renders 0 items. Close and reopen to let data load. + for (let attempt = 0; attempt < 3; attempt++) { + await folderTrigger.click(); + await this.page.locator(this.dashboardFolderPopover).waitFor({ state: 'visible', timeout: 10000 }); + optionsReady = await anyOpt.waitFor({ state: 'attached', timeout: 8000 }).then(() => true).catch(() => false); + if (optionsReady) break; + await this.page.keyboard.press('Escape').catch(() => {}); + await this.page.locator(this.dashboardFolderPopover).waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}); + await this.page.waitForTimeout(2000); + } + + if (!optionsReady) throw new Error('Dashboard folder options failed to appear after 3 attempts'); + const defaultOpt = this.page.locator(`[data-test="add-report-dashboard-folder-select-option"][data-test-value="default"]`); - const hasDefault = await defaultOpt.waitFor({ state: 'attached', timeout: 5000 }).then(() => true).catch(() => false); + const hasDefault = await defaultOpt.waitFor({ state: 'attached', timeout: 3000 }).then(() => true).catch(() => false); await (hasDefault ? defaultOpt : anyOpt).click({ force: true }); await this.page.locator(this.dashboardFolderPopover).waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}); } diff --git a/tests/ui-testing/playwright-tests/Alerts/alerts-history.spec.js b/tests/ui-testing/playwright-tests/Alerts/alerts-history.spec.js new file mode 100644 index 0000000000..aea80d3d86 --- /dev/null +++ b/tests/ui-testing/playwright-tests/Alerts/alerts-history.spec.js @@ -0,0 +1,295 @@ +const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js'); +const testLogger = require('../utils/test-logger.js'); +const PageManager = require('../../pages/page-manager.js'); +const { getAuthHeaders, getOrgIdentifier } = require('../utils/cloud-auth.js'); + +const STREAM_NAME = 'e2e_automate'; + +// ============================================================================ +// API HELPERS +// ============================================================================ + +async function apiCall(page, method, path, body = null) { + const baseUrl = process.env.ZO_BASE_URL || 'http://localhost:5080'; + const headers = getAuthHeaders(); + return page.evaluate(async ({ url, method, headers, body }) => { + const opts = { method, headers }; + if (body) opts.body = JSON.stringify(body); + const resp = await fetch(url, opts); + const data = await resp.json().catch(() => ({})); + return { status: resp.status, data }; + }, { url: `${baseUrl}${path}`, method, headers, body }); +} + +async function ensureTemplate(page, templateName) { + const org = getOrgIdentifier(); + const resp = await apiCall(page, 'POST', `/api/${org}/alerts/templates`, { + name: templateName, + body: JSON.stringify({ text: 'Alert triggered: {alert_name}' }), + isDefault: false + }); + testLogger.info('Created alert template', { templateName, status: resp.status }); + if (resp.status !== 200 && resp.status !== 409) { + throw new Error(`ensureTemplate: unexpected status ${resp.status} for "${templateName}" — ${JSON.stringify(resp.data)}`); + } + return true; +} + +async function ensureDestination(page, destinationName, templateName) { + const org = getOrgIdentifier(); + const resp = await apiCall(page, 'POST', `/api/${org}/alerts/destinations`, { + name: destinationName, + url: 'https://httpbin.org/post', + method: 'post', + skip_tls_verify: true, + template: templateName, + headers: {} + }); + testLogger.info('Created alert destination', { destinationName, status: resp.status }); + if (resp.status !== 200 && resp.status !== 409) { + throw new Error(`ensureDestination: unexpected status ${resp.status} for "${destinationName}" — ${JSON.stringify(resp.data)}`); + } + return true; +} + +async function createHistoryTestAlert(page, alertName, destinationName) { + const org = getOrgIdentifier(); + const payload = { + name: alertName, + stream_type: 'logs', + stream_name: STREAM_NAME, + is_real_time: false, + query_condition: { + conditions: null, + sql: `SELECT COUNT(*) as cnt FROM "${STREAM_NAME}"`, + promql: null, + type: 'sql', + aggregation: null, + vrl_function: null + }, + trigger_condition: { + threshold: 1, + operator: '>=', + frequency: 1, + silence: 0, + period: 5, + frequency_type: 'minutes' + }, + destinations: [destinationName], + enabled: true, + description: 'Alert history E2E test', + context_attributes: {} + }; + const resp = await apiCall(page, 'POST', `/api/v2/${org}/alerts?folder=default`, payload); + testLogger.info('Created alert via API', { alertName, status: resp.status }); + return resp; +} + +async function getAlertId(page, alertName) { + const org = getOrgIdentifier(); + for (let attempt = 0; attempt < 3; attempt++) { + const resp = await apiCall(page, 'GET', `/api/v2/${org}/alerts?folder=default`); + if (resp.status === 200) { + const alerts = resp.data?.list || []; + const alert = alerts.find(a => a.name === alertName); + if (alert) return alert.alert_id || alert.id || null; + } + if (attempt < 2) await page.waitForTimeout(2000); + } + return null; +} + +async function deleteTestAlert(page, alertName) { + const alertId = await getAlertId(page, alertName); + if (alertId) { + const org = getOrgIdentifier(); + await apiCall(page, 'DELETE', `/api/v2/${org}/alerts/${alertId}?folder=default`); + testLogger.info('Deleted test alert via API', { alertName, alertId }); + } +} + +// Poll until either last_triggered_at or last_satisfied_at advances past the +// pre-trigger snapshot — manual triggers update one or the other depending on backend path. +async function pollForAlertFired(page, alertId, beforeTriggerTs, beforeSatisfiedTs, timeoutMs = 90000, intervalMs = 5000) { + const org = getOrgIdentifier(); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const resp = await apiCall(page, 'GET', `/api/v2/${org}/alerts/${alertId}`); + if (resp.status === 200) { + const triggeredAt = resp.data?.last_triggered_at || 0; + const satisfiedAt = resp.data?.last_satisfied_at || 0; + if (triggeredAt > beforeTriggerTs || satisfiedAt > beforeSatisfiedTs) { + testLogger.info('Alert fire confirmed via API', { last_triggered_at: triggeredAt, last_satisfied_at: satisfiedAt }); + return true; + } + } + await page.waitForTimeout(intervalMs); + } + testLogger.warn('Alert fire not confirmed within timeout — proceeding anyway'); + return false; +} + +// ============================================================================ +// TESTS +// ============================================================================ + +test.describe("Alert History Page", () => { + test.describe.configure({ mode: 'serial' }); + let pm; + + test.beforeEach(async ({ page }, testInfo) => { + testLogger.testStart(testInfo.title, testInfo.file); + await navigateToBase(page); + pm = new PageManager(page); + await pm.alertHistoryPage.navigate(); + testLogger.info('Test setup completed'); + }); + + // ===== P0: SMOKE TESTS ===== + + test("P0: Page loads with title and all controls visible", { + tag: ['@alertHistory', '@smoke', '@P0'] + }, async ({ page }) => { + testLogger.info('Verifying alert history page title and all controls are visible'); + await pm.alertHistoryPage.expectPageTitleVisible(); + await pm.alertHistoryPage.expectBackBtnVisible(); + await pm.alertHistoryPage.expectDatePickerVisible(); + await pm.alertHistoryPage.expectSearchSelectVisible(); + await pm.alertHistoryPage.expectManualSearchBtnVisible(); + await pm.alertHistoryPage.expectRefreshBtnVisible(); + testLogger.info('All controls are visible on the alert history page'); + }); + + test("P0: Back button returns to alerts list", { + tag: ['@alertHistory', '@smoke', '@P0'] + }, async ({ page }) => { + testLogger.info('Clicking back button from alert history page'); + await pm.alertHistoryPage.clickBack(); + testLogger.info('Verifying navigation returned to the alerts list page'); + await pm.alertsPage.expectAlertListPageVisible(); + testLogger.info('Successfully returned to alerts list'); + }); + + // ===== P1: FUNCTIONAL TESTS ===== + + test("P1: Table renders after manual search", { + tag: ['@alertHistory', '@functional', '@P1'] + }, async ({ page }) => { + testLogger.info('Triggering manual search on alert history page'); + await pm.alertHistoryPage.clickManualSearch(); + testLogger.info('Verifying table or empty-state is displayed after search'); + await pm.alertHistoryPage.expectTableOrEmptyStateVisible(); + testLogger.info('Table or empty-state rendered correctly after manual search'); + }); + + test("P1: Refresh button reloads data without error", { + tag: ['@alertHistory', '@functional', '@P1'] + }, async ({ page }) => { + testLogger.info('Clicking refresh button on alert history page'); + await pm.alertHistoryPage.clickRefresh(); + testLogger.info('Verifying page remains on alert history after refresh'); + await pm.alertHistoryPage.expectPageTitleVisible(); + await pm.alertHistoryPage.expectManualSearchBtnVisible(); + testLogger.info('Page remained stable after refresh'); + }); + + test.skip("P1: Alert history populates after triggering alert and view details works", { + tag: ['@alertHistory', '@functional', '@P1'] + }, async ({ page }) => { + const ts = Date.now(); + const alertName = `e2e_hist_${ts}`; + const templateName = `e2e_hist_tmpl_${ts}`; + const destinationName = `e2e_hist_dest_${ts}`; + const org = getOrgIdentifier(); + + testLogger.info('Creating template, destination and alert', { alertName, templateName, destinationName }); + await ensureTemplate(page, templateName); + await ensureDestination(page, destinationName, templateName); + const createResp = await createHistoryTestAlert(page, alertName, destinationName); + if (createResp.status !== 200) { + throw new Error(`Failed to create alert: ${createResp.status} — ${JSON.stringify(createResp.data)}`); + } + + // Snapshot pre-trigger state — capture both timestamp fields to detect either update path + const alertId = await getAlertId(page, alertName); + const preState = alertId + ? await apiCall(page, 'GET', `/api/v2/${org}/alerts/${alertId}`) + : null; + const beforeTriggerTs = preState?.data?.last_triggered_at || 0; + const beforeSatisfiedTs = preState?.data?.last_satisfied_at || 0; + + testLogger.info('Triggering alert manually via UI', { alertName }); + await pm.alertsPage.triggerAlertManually(alertName); + + // Wait for the backend to confirm it processed the trigger — avoids racing the history page + if (alertId) { + await pollForAlertFired(page, alertId, beforeTriggerTs, beforeSatisfiedTs, 90000, 5000); + } + + testLogger.info('Navigating to alert history page'); + await pm.alertHistoryPage.navigate(); + + testLogger.info('Selecting alert and running search', { alertName }); + await pm.alertHistoryPage.selectAlert(alertName); + await pm.alertHistoryPage.clickManualSearch(); + + // UI fallback: if API poll timed out or history hasn't appeared yet, retry the search + testLogger.info('Asserting history rows are present'); + let rowsFound = (await pm.alertHistoryPage.getTableRowCount()) > 0; + for (let attempt = 0; attempt < 5 && !rowsFound; attempt++) { + testLogger.info(`No history rows yet, retrying search (attempt ${attempt + 1}/5)...`); + await page.waitForTimeout(5000); + await pm.alertHistoryPage.clickManualSearch(); + rowsFound = (await pm.alertHistoryPage.getTableRowCount()) > 0; + } + await pm.alertHistoryPage.expectTableHasRows(); + + testLogger.info('Clicking view details on first row'); + await pm.alertHistoryPage.clickViewDetails(0); + + testLogger.info('Verifying details dialog is visible'); + await pm.alertHistoryPage.expectDetailsDialogVisible(); + + testLogger.info('Alert history populated and view details works'); + + // Cleanup + await deleteTestAlert(page, alertName); + await apiCall(page, 'DELETE', `/api/${org}/alerts/destinations/${destinationName}`); + await apiCall(page, 'DELETE', `/api/${org}/alerts/templates/${templateName}`); + testLogger.info('Cleaned up alert, destination, and template'); + }); + + // ===== P2: EDGE CASE TESTS ===== + + test("P2: Table shows empty state when alert has no history", { + tag: ['@alertHistory', '@edge', '@P2'] + }, async ({ page }) => { + const ts = Date.now(); + const alertName = `e2e_hist_empty_${ts}`; + const templateName = `e2e_empty_tmpl_${ts}`; + const destinationName = `e2e_empty_dest_${ts}`; + const org = getOrgIdentifier(); + + testLogger.info('Creating alert with no history to guarantee empty results', { alertName }); + await ensureTemplate(page, templateName); + await ensureDestination(page, destinationName, templateName); + const createResp = await createHistoryTestAlert(page, alertName, destinationName); + if (createResp.status !== 200) { + throw new Error(`Setup: failed to create alert: status ${createResp.status} — ${JSON.stringify(createResp.data)}`); + } + + testLogger.info('Navigating to alert history and searching for the untriggered alert'); + await pm.alertHistoryPage.navigate(); + await pm.alertHistoryPage.selectAlert(alertName); + await pm.alertHistoryPage.clickManualSearch(); + + testLogger.info('Verifying empty state is shown for alert with no history'); + await pm.alertHistoryPage.expectEmptyStateVisible(); + + // Cleanup + await deleteTestAlert(page, alertName); + await apiCall(page, 'DELETE', `/api/${org}/alerts/destinations/${destinationName}`); + await apiCall(page, 'DELETE', `/api/${org}/alerts/templates/${templateName}`); + testLogger.info('Cleaned up alert, destination, and template'); + }); +}); diff --git a/tests/ui-testing/playwright-tests/Reports/reportFolders.spec.js b/tests/ui-testing/playwright-tests/Reports/reportFolders.spec.js index a6c70dc238..908c1dee7e 100644 --- a/tests/ui-testing/playwright-tests/Reports/reportFolders.spec.js +++ b/tests/ui-testing/playwright-tests/Reports/reportFolders.spec.js @@ -89,6 +89,10 @@ test.describe("Report Folders", () => { testLogger.info(`Moving report "${REPORT_A}" to folder "${FOLDER_B}"`); + // Search to ensure REPORT_A is visible (table may be paginated with many reports) + await pm.reportFoldersPage.searchReports(REPORT_A); + await pm.reportFoldersPage.expectReportVisibleInTable(REPORT_A); + // Open move dialog and move to destination folder await pm.reportFoldersPage.openMoveDialog(REPORT_A); await pm.reportFoldersPage.selectMoveDestination(FOLDER_B); @@ -100,6 +104,8 @@ test.describe("Report Folders", () => { await pm.reportFoldersPage.expectReportVisibleInTable(REPORT_A); // Move it back to default + await pm.reportFoldersPage.searchReports(REPORT_A); + await pm.reportFoldersPage.expectReportVisibleInTable(REPORT_A); await pm.reportFoldersPage.openMoveDialog(REPORT_A); await pm.reportFoldersPage.selectMoveDestination('default'); await pm.reportFoldersPage.clickMove(); @@ -148,6 +154,8 @@ test.describe("Report Folders", () => { await pm.reportFoldersPage.clickFolderTab('default'); + await pm.reportFoldersPage.searchReports(REPORT_A); + await pm.reportFoldersPage.expectReportVisibleInTable(REPORT_A); await pm.reportFoldersPage.openMoveDialog(REPORT_A); await pm.reportFoldersPage.expectMoveButtonDisabled(); await pm.reportFoldersPage.cancelMove(); @@ -223,8 +231,11 @@ test.describe("Report Folders", () => { const testReports = reports.filter(r => r.name && r.name.startsWith('test_report_')); for (const report of testReports) { const result = await pm.apiCleanup.deleteReport(report.name); - expect(result.code).toBe(200); - testLogger.info(`Deleted test report: ${report.name}`); + // Accept 200 (deleted) or 404 (already gone from a prior partial cleanup) + if (result.code !== 200 && result.code !== 404) { + throw new Error(`Unexpected status deleting report ${report.name}: ${result.code}`); + } + testLogger.info(`Deleted/already gone test report: ${report.name} (${result.code})`); } // Delete all test folders diff --git a/tests/ui-testing/playwright-tests/Reports/reports-bulk-operations.spec.js b/tests/ui-testing/playwright-tests/Reports/reports-bulk-operations.spec.js new file mode 100644 index 0000000000..5fca3590c6 --- /dev/null +++ b/tests/ui-testing/playwright-tests/Reports/reports-bulk-operations.spec.js @@ -0,0 +1,179 @@ +const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js'); +const testLogger = require('../utils/test-logger.js'); +const PageManager = require('../../pages/page-manager.js'); +const { createReportViaApi } = require('../../pages/reportsPages/reportCreation.js'); + +const timestamp = Date.now(); +const BULK_FOLDER = `bulk_test_folder_${timestamp}`; +const REPORT_1 = `bulk_report_1_${timestamp}`; +const REPORT_2 = `bulk_report_2_${timestamp}`; +const REPORT_3 = `bulk_report_3_${timestamp}`; + +test.describe("Report Bulk Operations", () => { + test.describe.configure({ mode: 'serial' }); + let pm; + + test.beforeEach(async ({ page }, testInfo) => { + testLogger.testStart(testInfo.title, testInfo.file); + await navigateToBase(page); + pm = new PageManager(page); + await pm.reportFoldersPage.navigateToReports(); + testLogger.info('Test setup completed'); + }); + + // ===== P0: SMOKE TESTS ===== + + test("P0: Bulk action buttons hidden when no reports selected", { + tag: ['@reportBulkOps', '@smoke', '@P0'] + }, async ({ page }) => { + testLogger.info('Verifying bulk buttons are hidden with no selection'); + await pm.reportFoldersPage.expectBulkButtonsHidden(); + testLogger.info('Bulk buttons are correctly hidden when nothing is selected'); + }); + + test("P0: Selecting all reports reveals bulk action buttons", { + tag: ['@reportBulkOps', '@smoke', '@P0'] + }, async ({ page }) => { + testLogger.info('Creating a report via API to ensure table is non-empty'); + const result = await createReportViaApi(pm.apiCleanup, REPORT_1); + if (!result.success) { + throw new Error(`Setup: failed to create report: ${result.error}`); + } + + await pm.reportFoldersPage.navigateToReports(); + await pm.reportFoldersPage.expectReportVisibleInTable(REPORT_1); + + testLogger.info('Selecting all reports via header checkbox'); + await pm.reportFoldersPage.selectAllReports(); + await pm.reportFoldersPage.expectBulkButtonsVisible(); + + testLogger.info('Bulk action buttons appeared after selecting reports'); + + // Cleanup: delete created report via API + await pm.apiCleanup.deleteReport(REPORT_1); + }); + + // ===== P1: FUNCTIONAL TESTS ===== + + test("P1: Bulk move selected reports to another folder", { + tag: ['@reportBulkOps', '@functional', '@P1'] + }, async ({ page }) => { + testLogger.info('Setting up: creating folder and two reports'); + await pm.reportFoldersPage.createFolder(BULK_FOLDER); + await pm.reportFoldersPage.expectFolderTabVisible(BULK_FOLDER); + + const r1 = await createReportViaApi(pm.apiCleanup, `${REPORT_1}_move`); + const r2 = await createReportViaApi(pm.apiCleanup, `${REPORT_2}_move`); + if (!r1.success || !r2.success) { + throw new Error('Setup: failed to create reports for bulk move test'); + } + + await pm.reportFoldersPage.navigateToReports(); + await pm.reportFoldersPage.clickFolderTab('default'); + + testLogger.info('Selecting all reports and initiating bulk move'); + await pm.reportFoldersPage.selectAllReports(); + await pm.reportFoldersPage.clickBulkMove(); + + testLogger.info(`Selecting destination folder: ${BULK_FOLDER}`); + await pm.reportFoldersPage.selectMoveDestination(BULK_FOLDER); + await pm.reportFoldersPage.expectMoveButtonEnabled(); + await pm.reportFoldersPage.clickMove(); + + testLogger.info('Verifying reports appear in destination folder'); + await pm.reportFoldersPage.clickFolderTab(BULK_FOLDER); + await pm.reportFoldersPage.expectReportVisibleInTable(`${REPORT_1}_move`); + + testLogger.info('Verifying source folder no longer contains the moved reports'); + await pm.reportFoldersPage.clickFolderTab('default'); + await pm.reportFoldersPage.expectReportNotVisibleInTable(`${REPORT_1}_move`); + + // Cleanup + await pm.apiCleanup.deleteReport(`${REPORT_1}_move`); + await pm.apiCleanup.deleteReport(`${REPORT_2}_move`); + await pm.reportFoldersPage.deleteFolderIfExists(BULK_FOLDER); + }); + + test("P1: Bulk delete selected reports with confirmation", { + tag: ['@reportBulkOps', '@functional', '@P1'] + }, async ({ page }) => { + testLogger.info('Creating reports for bulk delete test'); + const r1 = await createReportViaApi(pm.apiCleanup, `${REPORT_2}_del`); + const r2 = await createReportViaApi(pm.apiCleanup, `${REPORT_3}_del`); + if (!r1.success || !r2.success) { + throw new Error('Setup: failed to create reports for bulk delete test'); + } + + await pm.reportFoldersPage.navigateToReports(); + await pm.reportFoldersPage.expectReportVisibleInTable(`${REPORT_2}_del`); + + testLogger.info('Selecting all reports and clicking bulk delete'); + await pm.reportFoldersPage.selectAllReports(); + await pm.reportFoldersPage.clickBulkDelete(); + + testLogger.info('Confirming bulk delete'); + await pm.reportFoldersPage.confirmBulkDelete(); + + testLogger.info('Verifying success notification after bulk delete'); + await pm.reportFoldersPage.expectBulkDeleteSuccessVisible(); + + testLogger.info('Verifying deleted reports are no longer in the table'); + await pm.reportFoldersPage.expectReportNotVisibleInTable(`${REPORT_2}_del`); + }); + + // ===== P2: EDGE CASE TESTS ===== + + test("P2: Cancel bulk delete does not remove reports", { + tag: ['@reportBulkOps', '@edge', '@P2'] + }, async ({ page }) => { + testLogger.info('Creating a report to verify cancel behaviour'); + const r1 = await createReportViaApi(pm.apiCleanup, `${REPORT_3}_cancel`); + if (!r1.success) { + throw new Error('Setup: failed to create report for cancel test'); + } + + await pm.reportFoldersPage.navigateToReports(); + await pm.reportFoldersPage.expectReportVisibleInTable(`${REPORT_3}_cancel`); + + testLogger.info('Selecting reports and clicking bulk delete then cancelling'); + await pm.reportFoldersPage.selectAllReports(); + await pm.reportFoldersPage.clickBulkDelete(); + await pm.reportFoldersPage.cancelBulkDelete(); + + testLogger.info('Verifying report still exists after cancel'); + await pm.reportFoldersPage.expectReportVisibleInTable(`${REPORT_3}_cancel`); + + // Cleanup + await pm.apiCleanup.deleteReport(`${REPORT_3}_cancel`); + }); + + test("P2: Cancel bulk move closes dialog without moving reports", { + tag: ['@reportBulkOps', '@edge', '@P2'] + }, async ({ page }) => { + testLogger.info('Creating test folder and report'); + const cancelFolder = `cancel_folder_${timestamp}`; + await pm.reportFoldersPage.createFolder(cancelFolder); + await pm.reportFoldersPage.expectFolderTabVisible(cancelFolder); + + const r1 = await createReportViaApi(pm.apiCleanup, `${REPORT_1}_cancel_move`); + if (!r1.success) { + throw new Error('Setup: failed to create report'); + } + + await pm.reportFoldersPage.navigateToReports(); + await pm.reportFoldersPage.clickFolderTab('default'); + + testLogger.info('Opening bulk move dialog and cancelling'); + await pm.reportFoldersPage.selectAllReports(); + await pm.reportFoldersPage.clickBulkMove(); + await pm.reportFoldersPage.cancelMove(); + + testLogger.info('Verifying report stays in original folder after cancel'); + await pm.reportFoldersPage.clickFolderTab('default'); + await pm.reportFoldersPage.expectReportVisibleInTable(`${REPORT_1}_cancel_move`); + + // Cleanup + await pm.apiCleanup.deleteReport(`${REPORT_1}_cancel_move`); + await pm.reportFoldersPage.deleteFolderIfExists(cancelFolder); + }); +});