diff --git a/tests/ui-testing/ci-matrix/ci_matrix.json b/tests/ui-testing/ci-matrix/ci_matrix.json index 1343831b76..920a7d1544 100644 --- a/tests/ui-testing/ci-matrix/ci_matrix.json +++ b/tests/ui-testing/ci-matrix/ci_matrix.json @@ -13,7 +13,6 @@ "alerts-history.spec.js", "alerts-template-prebuilt-guard.spec.js", "alerts-form-validation.spec.js", - "anomaly-detection-form-validation.spec.js", "alerts-priority-tags.spec.js", "alerts-multialert-ui.spec.js", "alert-library.spec.js", diff --git a/tests/ui-testing/pages/anomalyPages/anomalyDetectionPage.js b/tests/ui-testing/pages/anomalyPages/anomalyDetectionPage.js index a8e38c5f90..1902722f3e 100644 --- a/tests/ui-testing/pages/anomalyPages/anomalyDetectionPage.js +++ b/tests/ui-testing/pages/anomalyPages/anomalyDetectionPage.js @@ -1,1618 +1,799 @@ -/** - * AnomalyDetectionPage - Handles anomaly detection operations - * - * This module contains methods for managing anomaly detection configurations: - * - Navigate to anomaly detection tab - * - Create anomaly detection configs - * - Verify anomaly configs - * - Pause/Resume anomaly detection - * - Retrain models - * - Delete anomaly configs - * - * NOTE: Anomaly detection is an enterprise/cloud feature only - */ +// Copyright 2026 OpenObserve Inc. + +/** Anomaly detection is an alert TYPE, not a module: list actions reuse the shared `alert-list-*` hooks and only the wizard body carries `anomaly-*` ones. */ const { expect } = require('@playwright/test'); const testLogger = require('../../playwright-tests/utils/test-logger.js'); +const MonacoEditorHelper = require('../../playwright-tests/utils/MonacoEditorHelper.js'); const { cleanupTestAnomalies: apiCleanupTestAnomalies } = require('../../playwright-tests/utils/api-helper.js'); class AnomalyDetectionPage { + /** + * @param {import('@playwright/test').Page} page + * @param {object} [commonActions] + */ constructor(page, commonActions) { this.page = page; this.commonActions = commonActions; + this.monaco = new MonacoEditorHelper(page); - // Create selectors object for compatibility with test expectations this.selectors = { - // Tables - alertsTable: '[data-test="alert-list-table"]', - anomalyTable: '[data-test="anomaly-detection-list-table"]', + // Navigation + alertsMenuLink: '[data-test="menu-link-\\/alerts-item"]', + // OToggleGroupItem in AlertList.vue renders `alert-list-tab-`. + // The tab is only in `alertTabs` when zoConfig.anomaly_detection_enabled + // is true, so its presence IS the feature check — do not probe the + // ?tab= query param, which AlertList never rewrites on fallback. + anomalyTab: '[data-test="alert-list-tab-anomalyDetection"]', + listPage: '[data-test="alert-list-page"]', + // Anomalies share the alert table; there is no anomaly-specific one. + listTable: '[data-test="alert-list-table"]', + addBtn: '[data-test="alert-list-add-alert-btn"]', + searchInput: '[data-test="alert-list-search-input"]', - // Anomaly config form - anomalyNameInput: '.alert-v3-field.topbar-name-input', + // Wizard topbar + // OFormInlineEdit: click -trigger to reveal -input; -error carries + // the validation message. + nameTrigger: '[data-test="add-anomaly-name-input-trigger"]', + nameInput: '[data-test="add-anomaly-name-input-input"]', + nameValue: '[data-test="add-anomaly-name-input-value"]', + nameError: '[data-test="add-anomaly-name-input-error"]', streamTypeSelect: '[data-test="add-alert-stream-type-select-dropdown"]', streamNameSelect: '[data-test="add-alert-stream-name-select-dropdown"]', - // Detection config - detectionFunctionSelect: '[data-test="anomaly-detection-function"]', + // Wizard chrome + saveBtn: '[data-test="add-alert-submit-btn"]', + cancelBtn: '[data-test="add-alert-cancel-btn"]', + configTab: '[data-test="add-alert-tab-anomaly-config"]', + alertingTab: '[data-test="add-alert-tab-anomaly-alerting"]', + + // Detection config step + queryTabs: '[data-test="anomaly-query-tabs"]', + filterRow: '[data-test="anomaly-filter-row"]', + filterRowRemove: '[data-test="anomaly-filter-row-remove"]', + filterAdd: '[data-test="anomaly-filter-add"]', + customSql: '[data-test="anomaly-custom-sql"]', + customSqlRequiredError: '[data-test="anomaly-custom-sql-required-error"]', + customSqlTimestampError: '[data-test="anomaly-custom-sql-timestamp-alias-error"]', + detectionFunction: '[data-test="anomaly-detection-function"]', detectionFunctionField: '[data-test="anomaly-detection-function-field"]', - histogramIntervalValue: '[data-test="anomaly-histogram-interval-value-field"]', + histogramIntervalValue: '[data-test="anomaly-histogram-interval-value"]', histogramIntervalUnit: '[data-test="anomaly-histogram-interval-unit"]', - scheduleIntervalValue: '[data-test="anomaly-schedule-interval-value-field"]', + histogramIntervalError: '[data-test="anomaly-histogram-interval-error"]', + scheduleIntervalValue: '[data-test="anomaly-schedule-interval-value"]', scheduleIntervalUnit: '[data-test="anomaly-schedule-interval-unit"]', - detectionWindowValue: '[data-test="anomaly-detection-window-value-field"]', + scheduleIntervalError: '[data-test="anomaly-schedule-interval-error"]', + detectionWindowValue: '[data-test="anomaly-detection-window-value"]', detectionWindowUnit: '[data-test="anomaly-detection-window-unit"]', - trainingWindow: '[data-test="anomaly-training-window-field"]', + detectionWindowError: '[data-test="anomaly-detection-window-error"]', + trainingWindow: '[data-test="anomaly-training-window"]', retrainInterval: '[data-test="anomaly-retrain-interval"]', sensitivityTier: '[data-test="anomaly-sensitivity-tier"]', - sensitivityPercentile: '[data-test="anomaly-sensitivity-percentile"] input', - sensitivityHint: '[data-test="anomaly-sensitivity-hint"]', + sensitivityPercentile: '[data-test="anomaly-sensitivity-percentile"]', sensitivityError: '[data-test="anomaly-sensitivity-error"]', - customSql: '[data-test="anomaly-custom-sql"]', - customSqlTimestampError: '[data-test="anomaly-sql-timestamp-error"]', - filterRow: '[data-test="anomaly-filter-row"]', - detectionWindowError: '[data-test="anomaly-detection-window-error"]', - summaryText: '.anomaly-summary .summary-text', + sensitivityHint: '[data-test="anomaly-sensitivity-hint"]', + sqlPreview: '[data-test="anomaly-sql-preview"]', - // Data preview — the right-hand Preview card, not the config step - dataPreviewChart: '[data-test="anomaly-data-preview-chart"]', - dataPreviewEmpty: '[data-test="anomaly-data-preview-empty"]', - dataPreviewError: '[data-test="panel-schema-renderer-error-message"]', - - // Alerting + // Alerting step + prioritySelect: '[data-test="anomaly-priority-select"]', + tagsInput: '[data-test="anomaly-tags-input"]', alertEnabled: '[data-test="anomaly-alert-enabled"]', - alertToggleLabel: '[role="switch"]', destination: '[data-test="anomaly-destination"]', destinationError: '[data-test="anomaly-destination-error"]', - refreshDestinationsBtn: '[data-test="anomaly-refresh-destinations"]', + refreshDestinations: '[data-test="anomaly-refresh-destinations"]', - // Action buttons - backButton: '[data-test="add-alert-back-btn"]', - saveButton: '[data-test="add-alert-submit-btn"]', - cancelButton: '[data-test="add-alert-cancel-btn"]', + // Right rail + dataPreviewChart: '[data-test="anomaly-data-preview-chart"]', + dataPreviewEmpty: '[data-test="anomaly-data-preview-empty"]', + summaryScrollBtn: '[data-test="anomaly-summary-scroll-btn"]', - // Generic framework components - qToggle: '[role="switch"]', - qMenuItem: '[data-test$="-popover"] [data-test$="-option"]', - qMenu: '[data-test$="-popover"]', - qDialog: '[data-test$="-dialog"]', + // Detection charts (AlertDetail) + detectionCharts: '[data-test="alerts-anomalydetectionchart"]', + detectionChartsRange: '[data-test="alerts-anomalydetectionchart-range"]', - // Dynamic selectors (functions) - editButton: (name) => `[data-test="alert-list-${name}-update-alert"]`, - pauseButton: (name) => `[data-test="alert-list-${name}-pause-start-alert"]`, - resumeButton: (name) => `[data-test="alert-list-${name}-pause-start-alert"]`, // Same button toggles - deleteButton: (name) => `[data-test="alert-list-${name}-delete-alert"]`, - cloneButton: (name) => `[data-test="alert-list-${name}-clone-alert"]`, - moreOptionsButton: (name) => `[data-test="alert-list-${name}-more-options"]`, - triggerDetectionButton: (name) => `[data-test="alert-list-${name}-trigger-detection"]`, - sensitivityTierButton: (percentile) => `[data-test="anomaly-sensitivity-tier-${percentile}"]`, + toast: '[data-test="o-toast-message"]', + toastDismiss: '[data-test="o-toast-dismiss"]', + + // Row-scoped hooks (shared alert-list family) + rowName: (name) => `[data-test="alert-list-${name}-name-cell"]`, + rowEdit: (name) => `[data-test="alert-list-${name}-update-alert"]`, + rowDelete: (name) => `[data-test="alert-list-${name}-delete-alert"]`, + rowClone: (name) => `[data-test="alert-list-${name}-clone-alert"]`, + rowPause: (name) => `[data-test="alert-list-${name}-pause-start-alert"]`, + rowMoreOptions: (name) => `[data-test="alert-list-${name}-more-options"]`, + rowTriggerDetection: (name) => `[data-test="alert-list-${name}-trigger-detection"]`, + rowRetrain: (name) => `[data-test="alert-list-${name}-retrain-anomaly"]`, + + sensitivityTierItem: (pct) => `[data-test="anomaly-sensitivity-tier-${pct}"]`, + queryTab: (mode) => `[data-test="anomaly-query-tab-${mode}"]`, + filterField: (idx) => `[data-test="anomaly-filter-field-${idx}"]`, + filterOperator: (idx) => `[data-test="anomaly-filter-operator-${idx}"]`, + filterValue: (idx) => `[data-test="anomaly-filter-value-${idx}"]`, + chartRangeItem: (v) => `[data-test="alerts-anomalydetectionchart-range-${v}"]`, + chartPanel: (key) => `[data-test="alerts-anomalydetectionchart-${key}"]`, + chartPanelBody: (key) => `[data-test="alerts-anomalydetectionchart-${key}-panel"]`, + chartPanelEmpty: (key) => `[data-test="alerts-anomalydetectionchart-${key}-empty"]`, }; - - // Also keep direct references for backward compatibility - this.addAnomalyButton = '[data-test="alert-list-add-alert-btn"]'; - this.anomalyTable = this.selectors.anomalyTable; - this.alertTable = this.selectors.alertsTable; - this.anomalyDetectionTab = 'anomalyDetection'; - this.anomalyNameInput = this.selectors.anomalyNameInput; - this.streamTypeSelect = this.selectors.streamTypeSelect; - this.streamNameSelect = this.selectors.streamNameSelect; - this.detectionFunctionSelect = this.selectors.detectionFunctionSelect; - this.detectionFunctionField = this.selectors.detectionFunctionField; - this.histogramIntervalValue = this.selectors.histogramIntervalValue; - this.histogramIntervalUnit = this.selectors.histogramIntervalUnit; - this.scheduleIntervalValue = this.selectors.scheduleIntervalValue; - this.scheduleIntervalUnit = this.selectors.scheduleIntervalUnit; - this.detectionWindowValue = this.selectors.detectionWindowValue; - this.detectionWindowUnit = this.selectors.detectionWindowUnit; - this.trainingWindow = this.selectors.trainingWindow; - this.retrainInterval = this.selectors.retrainInterval; - this.dataPreviewChart = this.selectors.dataPreviewChart; - this.alertEnabledToggle = this.selectors.alertEnabled; - this.destinationSelect = this.selectors.destination; - this.backButton = this.selectors.backButton; - this.saveButton = this.selectors.saveButton; - this.cancelButton = this.selectors.cancelButton; } - // ==================== HELPER METHODS FOR TESTS ==================== + // Navigation & feature availability /** - * Wait for alerts table to be visible - * @param {number} timeout - Timeout in ms + * True when the build/backend exposes anomaly detection. + * + * AlertList silently coerces activeTab back to "all" when the feature is + * off but LEAVES ?tab=anomalyDetection in the URL, so reading the query + * param back is a tautology. The rendered tab is the only honest signal. */ - async waitForAlertsTable(timeout = 10000) { - try { - await this.page.locator(this.selectors.alertsTable).first().waitFor({ state: 'visible', timeout }); - testLogger.info('Alerts table is visible'); - } catch (e) { - testLogger.warn('Alerts table not visible within timeout', { timeout, error: e.message }); + async isAnomalyDetectionAvailable() { + await this.page.locator(this.selectors.alertsMenuLink).click(); + await this.page.locator(this.selectors.listPage).waitFor({ state: 'visible', timeout: 30000 }); + const visible = await this.page + .locator(this.selectors.anomalyTab) + .waitFor({ state: 'visible', timeout: 5000 }) + .then(() => true) + .catch(() => false); + testLogger.info('Anomaly detection availability', { visible }); + return visible; + } + + async navigateToAnomalyTab() { + await this.page.locator(this.selectors.alertsMenuLink).click(); + await this.page.locator(this.selectors.listPage).waitFor({ state: 'visible', timeout: 30000 }); + const tab = this.page.locator(this.selectors.anomalyTab); + await tab.waitFor({ state: 'visible', timeout: 15000 }); + await tab.click(); + await expect(tab).toHaveAttribute('data-state', 'on', { timeout: 10000 }); + await this.page.locator(this.selectors.listTable).waitFor({ state: 'visible', timeout: 30000 }); + testLogger.info('Anomaly Detection tab loaded'); + } + + /** + * Open the Add wizard. + * + * The Add button renders disabled until the org's destinations have loaded, + * so clicking on sight is a race: the click lands on a dead button and the + * wizard never opens. + */ + async openAddAnomalyWizard() { + const addBtn = this.page.locator(this.selectors.addBtn); + await expect(addBtn).toBeEnabled({ timeout: 30000 }); + await addBtn.click(); + await this.page.locator(this.selectors.streamTypeSelect).waitFor({ state: 'visible', timeout: 15000 }); + testLogger.info('Add Anomaly wizard opened'); + } + + // Shared O2 control drivers + + /** + * Narrow a just-opened OSelect popover to `term`. + * + * Searchable OSelects render their options through a virtual list, so an + * option outside the rendered window is absent from the DOM entirely and no + * amount of waiting will surface it. Typing into the filter is the only way + * to reach one reliably once an org has accumulated rows. + */ + async _filterOpenSelect(dataTest, term) { + const search = this.page.locator(`[data-test="${dataTest}-search"]`); + if (await search.count()) { + await search.fill(String(term)); } } /** - * Wait for Add Alert button to be enabled - * @param {number} timeout - Timeout in ms - * @returns {Promise} + * Pick an OSelect option by its underlying value. + * + * OSelect stamps data-test-value on each option, so matching on the value + * survives translation of the visible label. */ - async waitForAddAlertButtonEnabled(timeout = 15000) { - try { - const button = this.page.locator(this.addAnomalyButton); - await button.waitFor({ state: 'visible', timeout }); - // Wait for button to not be disabled - const startTime = Date.now(); - while (Date.now() - startTime < timeout) { - const isDisabled = await button.isDisabled(); - if (!isDisabled) { - testLogger.info('Add Alert button is enabled'); - return true; - } - await this.page.waitForTimeout(500); - } - testLogger.warn('Add Alert button is still disabled after timeout'); - return false; - } catch (e) { - testLogger.warn('Error waiting for Add Alert button', { error: e.message }); - return false; + async selectOptionByValue(selectSelector, value) { + const dataTest = this._dataTestOf(selectSelector); + await this.page.locator(`${selectSelector} [data-test$="-trigger"]`).click(); + await this._filterOpenSelect(dataTest, value); + const option = this.page.locator( + `[data-test="${dataTest}-option"][data-test-value="${value}"]`, + ); + await option.waitFor({ state: 'visible', timeout: 10000 }); + await option.click(); + } + + /** Pick an OSelect option by its rendered label (use when the value is opaque). */ + async selectOptionByLabel(selectSelector, label) { + const dataTest = this._dataTestOf(selectSelector); + await this.page.locator(`${selectSelector} [data-test$="-trigger"]`).click(); + await this._filterOpenSelect(dataTest, label); + const option = this.page + .locator(`[data-test="${dataTest}-option"]`) + .filter({ hasText: label }) + .first(); + await option.waitFor({ state: 'visible', timeout: 10000 }); + await option.click(); + } + + /** Extract the bare data-test value out of a `[data-test="x"]` selector. */ + _dataTestOf(selector) { + const match = selector.match(/\[data-test="([^"]+)"\]/); + if (!match) throw new Error(`Not a data-test selector: ${selector}`); + return match[1]; + } + + /** Fill the inner input of an OFormInput (OInput suffixes it with -field). */ + async fillFormInput(selector, value) { + const input = this.page.locator(`${selector} [data-test$="-field"]`).first(); + await input.waitFor({ state: 'visible', timeout: 10000 }); + await input.fill(String(value)); + await input.blur(); + } + + async getFormInputValue(selector) { + return this.page.locator(`${selector} [data-test$="-field"]`).first().inputValue(); + } + + // Wizard topbar + + /** OFormInlineEdit renders as text until its trigger is clicked. */ + async fillAnomalyName(name) { + const trigger = this.page.locator(this.selectors.nameTrigger); + if (await trigger.isVisible({ timeout: 3000 }).catch(() => false)) { + await trigger.click(); } + const input = this.page.locator(this.selectors.nameInput); + await input.waitFor({ state: 'visible', timeout: 10000 }); + await input.fill(name); + // Blur, never Enter: OInlineEdit commits on both, but Enter also SUBMITS + // the owning form, which runs a premature save and leaves a 30s error + // toast covering the wizard footer for every step that follows. + await input.blur(); + await expect(this.page.locator(this.selectors.nameValue)).toContainText(name, { + timeout: 10000, + }); + testLogger.info('Filled anomaly name', { name }); + } + + async selectStreamType(streamType = 'logs') { + await this.selectOptionByValue(this.selectors.streamTypeSelect, streamType); + } + + async selectStreamName(streamName) { + await this.selectOptionByValue(this.selectors.streamNameSelect, streamName); } - /** - * Fill basic setup - name, stream type, stream name - * @param {string} name - Anomaly name - * @param {string} streamType - Stream type - * @param {string} streamName - Stream name - */ async fillBasicSetup(name, streamType, streamName) { - // Fill stream type and name FIRST, then name LAST - // This prevents stream selection from clearing the name field await this.selectStreamType(streamType); await this.selectStreamName(streamName); await this.fillAnomalyName(name); - testLogger.info('Filled basic setup', { name, streamType, streamName }); } - /** - * Click on a tab (Basic Setup, Detection Config, Alerting, Summary) - * @param {string} tabName - Tab name - */ - async clickTab(tabName) { - // Try multiple base selectors with filter pattern to avoid injection - const baseSelectors = [ - '[class*="cursor-pointer"]', - '[role="group"] button', - 'button', - '[role="tab"]' - ]; + // Wizard tabs - let tabFound = false; - for (const baseSelector of baseSelectors) { - const tab = this.page.locator(baseSelector).filter({ hasText: tabName }).first(); - if (await tab.isVisible({ timeout: 2000 }).catch(() => false)) { - await tab.click(); - await this.page.waitForTimeout(1000); // Wait for tab content to load - testLogger.info('Clicked tab', { tabName, baseSelector }); - tabFound = true; - break; - } - } - - // Fallback: try text selector - if (!tabFound) { - const textTab = this.page.getByText(tabName, { exact: true }).first(); - if (await textTab.isVisible({ timeout: 2000 }).catch(() => false)) { - await textTab.click(); - await this.page.waitForTimeout(1000); - testLogger.info('Clicked tab via text selector', { tabName }); - tabFound = true; - } - } - - if (!tabFound) { - testLogger.warn('Tab not found with any selector', { tabName }); - } + async openConfigTab() { + await this.page.locator(this.selectors.configTab).click(); + await this.page.locator(this.selectors.queryTabs).waitFor({ state: 'visible', timeout: 10000 }); } - /** - * Get anomaly row by name - * @param {string} name - Anomaly name - * @returns {import('@playwright/test').Locator} - */ - getAnomalyRow(name) { - return this.page.locator('tr').filter({ hasText: name }); + async openAlertingTab() { + await this.page.locator(this.selectors.alertingTab).click(); + await this.page.locator(this.selectors.alertEnabled).waitFor({ state: 'visible', timeout: 10000 }); } + // Detection config + /** - * Select query mode (builder or sql) - * @param {string} mode - Query mode (builder, sql) + * Select the query mode. + * + * Per-item data-tests landed with the anomaly revamp; on builds without + * them the toggle items are reachable only by their label, so fall back to + * that rather than failing on an older deployment. + * + * @param {'filters'|'custom_sql'} mode */ async selectQueryMode(mode) { - const tabLabel = mode === 'sql' ? 'SQL' : 'Builder'; - - // The tabs are buttons within the query-mode-tabs container - const tabsContainer = this.page.locator('[data-test="anomaly-query-tabs"]'); - await expect(tabsContainer).toBeVisible({ timeout: 5000 }); - - const modeTab = tabsContainer.locator(`button.query-mode-tab:has-text("${tabLabel}")`).first(); - await expect(modeTab).toBeVisible({ timeout: 3000 }); - await modeTab.click(); - - // Wait longer for SQL mode because Monaco editor takes time to initialize - const waitTime = mode === 'sql' ? 3000 : 1000; - await this.page.waitForTimeout(waitTime); - - testLogger.info('Selected query mode', { mode, tabLabel }); + const tab = this.page.locator(this.selectors.queryTab(mode)); + if (await tab.count()) { + await tab.click(); + await expect(tab).toHaveAttribute('data-state', 'on', { timeout: 5000 }); + return; + } + const label = mode === 'custom_sql' ? 'SQL' : 'Builder'; + const legacyTab = this.page + .locator(`${this.selectors.queryTabs} button`) + .filter({ hasText: label }) + .first(); + await legacyTab.click(); + await expect(legacyTab).toHaveAttribute('data-state', 'on', { timeout: 5000 }); } /** - * Set SQL query - * @param {string} sql - SQL query + * Replace the SQL editor's contents. + * + * Monaco's select-all only lands once the editor has focus, and that is + * racy right after the pane renders — a missed clear types the new query in + * FRONT of the seeded default and the save dies on a parser error. So clear + * until the editor is verifiably empty, then type. */ - async setSqlQuery(sql) { - // Monaco editor takes time to initialize, wait up to 10 seconds - const sqlEditorContainer = this.page.locator(this.selectors.customSql); - await expect(sqlEditorContainer).toBeVisible({ timeout: 10000 }); - - // Monaco editor uses a textarea for input - find it within the editor container - const monacoTextarea = sqlEditorContainer.locator('textarea').first(); - await expect(monacoTextarea).toBeVisible({ timeout: 5000 }); - - // Click the editor to focus it - await sqlEditorContainer.click(); - await this.page.waitForTimeout(500); - - // Select all and replace with new SQL using keyboard shortcuts - await monacoTextarea.focus(); - await this.page.waitForTimeout(300); - - // Cmd+A (Mac) or Ctrl+A (Win/Linux) to select all - const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'; - await this.page.keyboard.press(`${modifier}+KeyA`); - await this.page.waitForTimeout(200); - - // Type the new SQL - await this.page.keyboard.type(sql, { delay: 10 }); - await this.page.waitForTimeout(500); - - testLogger.info('Set SQL query', { sqlLength: sql.length }); + async setCustomSql(sql) { + const container = this.page.locator(this.selectors.customSql); + await container.waitFor({ state: 'visible', timeout: 15000 }); + await this.clearCustomSql(); + await this.monaco.type(container, sql); } - /** - * Set detection resolution (histogram interval) - */ - async setDetectionResolution(value, unit) { - await this.setHistogramInterval(value, unit === 'm' ? 'minutes' : unit === 'h' ? 'hours' : unit); + /** Empty the SQL editor, confirming it actually emptied. */ + async clearCustomSql() { + const container = this.page.locator(this.selectors.customSql); + await container.waitFor({ state: 'visible', timeout: 15000 }); + for (let attempt = 1; attempt <= 5; attempt++) { + // Click the editor body first: select-all is a no-op until Monaco + // actually holds focus, and under parallel load that lags the + // element being visible. + await container.locator('.monaco-editor').first().click({ force: true }); + await this.page.waitForTimeout(200 * attempt); + await this.monaco.clear(container); + const left = ((await this.monaco.getContent(container)) || '').replace(/\s|\u00a0/g, ''); + if (left === '') return; + testLogger.warn('Monaco clear did not take; retrying', { attempt, left }); + } + throw new Error('Could not clear the SQL editor after 5 attempts'); } - /** - * Set check every (schedule interval) - */ - async setCheckEvery(value, unit) { - await this.setScheduleInterval(value, unit === 'm' ? 'minutes' : unit === 'h' ? 'hours' : unit); + + async getSqlPreviewText() { + return this.monaco.getContent(this.page.locator(this.selectors.sqlPreview)); } - /** - * Set look back window (detection window) - */ - async setLookBackWindow(value, unit) { - await this.setDetectionWindow(value, unit === 'm' ? 'minutes' : unit === 'h' ? 'hours' : unit); + async selectDetectionFunction(fn) { + await this.selectOptionByValue(this.selectors.detectionFunction, fn); + } + + async selectDetectionFunctionField(field) { + await this.selectOptionByValue(this.selectors.detectionFunctionField, field); + } + + /** @param {'m'|'h'} unit */ + async setHistogramInterval(value, unit = 'm') { + await this.fillFormInput(this.selectors.histogramIntervalValue, value); + await this.selectOptionByValue(this.selectors.histogramIntervalUnit, unit); + } + + /** @param {'m'|'h'} unit */ + async setScheduleInterval(value, unit = 'm') { + await this.fillFormInput(this.selectors.scheduleIntervalValue, value); + await this.selectOptionByValue(this.selectors.scheduleIntervalUnit, unit); + } + + /** @param {'m'|'h'} unit */ + async setDetectionWindow(value, unit = 'h') { + await this.fillFormInput(this.selectors.detectionWindowValue, value); + await this.selectOptionByValue(this.selectors.detectionWindowUnit, unit); + } + + /** Blank the resolution field, to exercise a mid-edit invalid interval. */ + async clearHistogramInterval() { + await this.fillFormInput(this.selectors.histogramIntervalValue, ''); } - /** - * Set training window days - * @param {number} days - Training window in days - */ async setTrainingWindow(days) { - const input = this.page.locator(this.selectors.trainingWindow); - if (await input.isVisible({ timeout: 3000 }).catch(() => false)) { - await input.fill(String(days)); - testLogger.info('Set training window', { days }); - } + await this.fillFormInput(this.selectors.trainingWindow, days); + } + + /** @param {0|1|7|14} days 0 means "Never". */ + async selectRetrainInterval(days) { + await this.selectOptionByValue(this.selectors.retrainInterval, days); + } + + // Filters + + getFilterRows() { + return this.page.locator(this.selectors.filterRow); } /** - * Set retrain interval - * @param {string} interval - Retrain interval (Never, 1 day, 7 days, etc.) + * Append one filter row. Value is skipped for Is Null / Is Not Null, whose + * value input is not rendered at all. */ - async setRetrainInterval(interval) { - const select = this.page.locator(this.selectors.retrainInterval); - if (await select.isVisible({ timeout: 3000 }).catch(() => false)) { - await select.click(); - await this.page.waitForTimeout(500); - await this.page.getByRole('option', { name: interval }).click(); - await this.page.waitForTimeout(300); - testLogger.info('Set retrain interval', { interval }); + async addFilter(field, operator = '=', value = null) { + const before = await this.getFilterRows().count(); + await this.page.locator(this.selectors.filterAdd).click(); + await expect(this.getFilterRows()).toHaveCount(before + 1, { timeout: 10000 }); + + const idx = before; + await this.selectOptionByValue(this.selectors.filterField(idx), field); + if (operator !== '=') { + await this.selectOptionByValue(this.selectors.filterOperator(idx), operator); } - } - - /** - * Disable alerting - */ - async disableAlerting() { - testLogger.info('Disabling alerting to allow save'); - - // Try multiple ways to find and click the Alerting tab - const tabSelectors = [ - '[class*="cursor-pointer"]:has-text("Alerting")', - 'text=Alerting', - 'button:has-text("Alerting")', - '[role="group"] button:has-text("Alerting")' - ]; - - let alertingTab = null; - for (const selector of tabSelectors) { - const tab = this.page.locator(selector).first(); - if (await tab.isVisible({ timeout: 2000 }).catch(() => false)) { - alertingTab = tab; - testLogger.info('Found Alerting tab', { selector }); - break; - } + if (value !== null) { + await this.fillFormInput(this.selectors.filterValue(idx), value); } - - if (!alertingTab) { - testLogger.warn('Alerting tab not found'); - return; - } - - await alertingTab.click(); - testLogger.info('Clicked Alerting tab'); - await this.page.waitForTimeout(2000); // Wait longer for tab content to load - - // Try multiple selectors for the toggle - const toggleSelectors = [ - this.selectors.alertEnabled, - '[data-test="anomaly-alert-enabled"]', - '[role="switch"]' - ]; - - let toggle = null; - for (const selector of toggleSelectors) { - const loc = this.page.locator(selector).first(); - if (await loc.isVisible({ timeout: 3000 }).catch(() => false)) { - toggle = loc; - testLogger.info('Found alerting toggle', { selector }); - break; - } - } - - if (!toggle) { - testLogger.warn('Alerting toggle not found'); - return; - } - - // Get toggle label to determine state - const toggleLabel = await toggle.locator('[role="switch"]').textContent().catch(() => ''); - testLogger.info('Alert toggle state', { toggleLabel }); - - if (toggleLabel.toLowerCase().includes('enabled')) { - testLogger.info('Alerting is enabled, clicking to disable'); - await toggle.click(); - await this.page.waitForTimeout(1000); - - // Verify it was disabled - const newLabel = await toggle.locator('[role="switch"]').textContent().catch(() => ''); - testLogger.info('After clicking toggle', { toggleLabel: newLabel }); - testLogger.info('Disabled alerting'); - } else { - testLogger.info('Alerting already disabled'); - } - - // Navigate back to Config tab - testLogger.info('Navigating back to Config tab'); - await this.clickTab('Detection Config'); - await this.page.waitForTimeout(1000); - testLogger.info('Clicked Config tab'); - } - - /** - * Toggle notifications on/off - * @param {boolean} enabled - Enable or disable - */ - async toggleNotifications(enabled) { - const toggle = this.page.locator(this.selectors.alertEnabled); - if (await toggle.isVisible({ timeout: 3000 }).catch(() => false)) { - const label = await toggle.locator(this.selectors.alertToggleLabel).textContent().catch(() => ''); - const isEnabled = label.toLowerCase().includes('enabled'); - if (enabled !== isEnabled) { - await toggle.click(); - await this.page.waitForTimeout(500); - } - } - } - - /** - * Click save button - */ - async clickSave() { - const saveBtn = this.page.locator(this.selectors.saveButton); - await expect(saveBtn).toBeVisible({ timeout: 5000 }); - await expect(saveBtn).toBeEnabled({ timeout: 5000 }); - await saveBtn.click(); - await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); - testLogger.info('Clicked save button'); - } - - /** - * Expect anomaly to appear in list - * @param {string} name - Anomaly name - */ - async expectAnomalyInList(name) { - const row = this.getAnomalyRow(name); - await expect(row).toBeVisible({ timeout: 15000 }); - testLogger.info('Anomaly found in list', { name }); - } - - /** - * Configure builder mode - * @param {Object} config - Builder mode config - */ - async configureBuilderMode(config) { - await this.configureDetectionFunction(config.function, config.field); - } - - /** - * Add a filter in builder mode - * @param {string} field - Field name - * @param {string} operator - Operator (=, Contains, etc.) - * @param {string} value - Value - */ - async addFilter(field, operator, value) { - // Click add filter button to create a new empty filter row - const addFilterBtn = this.page.locator('button:has-text("Add filter"), button:has-text("Add Filter")').first(); - await expect(addFilterBtn).toBeVisible({ timeout: 5000 }); - await addFilterBtn.click(); - await this.page.waitForTimeout(1000); - - // Get the last filter row (the newly added one) - // Filter rows are flex containers with field, operator, value, and close button - const filterRows = this.page.locator('.tw\\:flex.tw\\:items-center.tw\\:gap-2.tw\\:mb-2'); - const filterCount = await filterRows.count(); - const lastFilterRow = filterRows.nth(filterCount - 1); - - // 1. Fill the field select (first select in the row) - const fieldSelect = lastFilterRow.locator('.filter-field-select, .alert-v3-select').first(); - await expect(fieldSelect).toBeVisible({ timeout: 3000 }); - await fieldSelect.click(); - await this.page.waitForTimeout(500); - - // Type to search for the field - await this.page.keyboard.type(field, { delay: 50 }); - await this.page.waitForTimeout(1000); - - // Click the option that matches - const fieldOption = this.page.getByRole('option', { name: field }).first(); - if (await fieldOption.isVisible({ timeout: 3000 }).catch(() => false)) { - await fieldOption.click(); - } else { - // Fallback: try clicking by text - await this.page.locator(`[data-test$="-option"]:has-text("${field}")`).first().click().catch(() => {}); - } - await this.page.waitForTimeout(500); - - // 2. Fill the operator select (second select in the row) - const operatorSelect = lastFilterRow.locator('.alert-v3-select').nth(1); - if (await operatorSelect.isVisible({ timeout: 2000 }).catch(() => false)) { - await operatorSelect.click(); - await this.page.waitForTimeout(500); - - const operatorOption = this.page.getByRole('option', { name: operator }).first(); - if (await operatorOption.isVisible({ timeout: 2000 }).catch(() => false)) { - await operatorOption.click(); - } else { - await this.page.locator(`[data-test$="-option"]:has-text("${operator}")`).first().click().catch(() => {}); - } - await this.page.waitForTimeout(500); - } - - // 3. Fill the value input (input in the row) - const valueInput = lastFilterRow.locator('.alert-v3-input input, input').first(); - if (await valueInput.isVisible({ timeout: 2000 }).catch(() => false)) { - await valueInput.click(); - await valueInput.fill(value); - await this.page.waitForTimeout(300); - } - testLogger.info('Added filter', { field, operator, value }); } - /** - * Refresh destinations list - */ - async refreshDestinations() { - const btn = this.page.locator(this.selectors.refreshDestinationsBtn).first(); - if (await btn.isVisible({ timeout: 2000 }).catch(() => false)) { - await btn.click(); - await this.page.waitForTimeout(2000); - testLogger.info('Refreshed destinations'); - } + async removeFilter(index = 0) { + await this.page.locator(this.selectors.filterRowRemove).nth(index).click(); } + // Sensitivity + /** - * Select a sensitivity tier - * @param {number} percentile - Tier percentile (99, 97 or 95) + * Pick a sensitivity tier. + * + * The tier toggle arrived with the anomaly revamp; builds before it expose a + * plain threshold slider instead. Left at its default there so the flows + * that merely pass through this step still run. + * + * @param {95|97|99} percentile + * @returns {Promise} whether the tier control was present */ async selectSensitivityTier(percentile) { - const tier = this.page.locator(this.selectors.sensitivityTierButton(percentile)); + const tier = this.page.locator(this.selectors.sensitivityTierItem(percentile)); + if (!(await tier.count())) { + testLogger.info('Sensitivity tiers absent on this build; keeping the default threshold'); + return false; + } await tier.click(); - await this.page.waitForTimeout(300); - testLogger.info('Selected sensitivity tier', { percentile }); + return true; } - /** - * Set the exact sensitivity percentile - * @param {number} percentile - Percentile between 50 and 99 - */ + /** True when the build exposes the revamped tier/percentile sensitivity controls. */ + async hasSensitivityTiers() { + return (await this.page.locator(this.selectors.sensitivityTier).count()) > 0; + } + + /** Tier toggle and percentile input share the `threshold` field. */ async setSensitivityPercentile(percentile) { - const input = this.page.locator(this.selectors.sensitivityPercentile); - await input.fill(String(percentile)); - await this.page.waitForTimeout(300); - testLogger.info('Set sensitivity percentile', { percentile }); + await this.fillFormInput(this.selectors.sensitivityPercentile, percentile); } - /** - * Expect a sensitivity tier to be the selected one - * @param {number} percentile - Tier percentile (99, 97 or 95) - */ - async expectSensitivityTierActive(percentile) { - const tier = this.page.locator(this.selectors.sensitivityTierButton(percentile)); - await expect(tier).toHaveAttribute('data-state', 'on', { timeout: 5000 }); - } - - /** - * Expect validation error to be visible - * @param {string} selector - Error selector - */ - async expectValidationError(selector) { - const error = this.page.locator(selector); - await expect(error).toBeVisible({ timeout: 5000 }); - } - - /** - * Click pause anomaly button - * @param {string} name - Anomaly name - */ - async clickPauseAnomaly(name) { - await this.pauseAnomaly(name); - } - - /** - * Click resume anomaly button - * @param {string} name - Anomaly name - */ - async clickResumeAnomaly(name) { - await this.resumeAnomaly(name); - } - - /** - * Expect anomaly to have a specific status - * @param {string} name - Anomaly name - * @param {string} status - Expected status - */ - async expectAnomalyStatus(name, status) { - const row = this.getAnomalyRow(name); - const badge = row.locator('[data-test*="badge"], [data-test*="chip"], .status-badge'); - await expect(badge).toContainText(status, { ignoreCase: true, timeout: 10000 }); - } - - /** - * Expect edit button to be visible for anomaly - * @param {string} name - Anomaly name - */ - async expectEditButtonVisible(name) { - const editButton = this.page.locator(this.selectors.editButton(name)); - await expect(editButton).toBeVisible({ timeout: 5000 }); - } - - /** - * Expect pause button to be visible for anomaly - * @param {string} name - Anomaly name - */ - async expectPauseButtonVisible(name) { - const pauseButton = this.page.locator(this.selectors.pauseButton(name)); - await expect(pauseButton).toBeVisible({ timeout: 5000 }); - } - - /** - * Expect detection function field to be visible - */ - async expectDetectionFieldVisible() { - const field = this.page.locator(this.selectors.detectionFunctionField); - await expect(field).toBeVisible({ timeout: 5000 }); - } - - /** - * Expect filter count - * @param {number} count - Expected filter count - */ - async expectFilterCount(count) { - const filterRows = this.page.locator('.tw\\:flex.tw\\:items-center.tw\\:gap-2.tw\\:mb-2'); - await expect(filterRows).toHaveCount(count, { timeout: 5000 }); - } - - /** - * Expect custom SQL editor to be visible - */ - async expectSqlEditorVisible() { - const sqlEditor = this.page.locator(this.selectors.customSql); - await expect(sqlEditor).toBeVisible({ timeout: 10000 }); - } - - /** - * Expect summary text to be visible - */ - async expectSummaryVisible() { - // Wait for the summary container to be visible first - const summaryContainer = this.page.locator('.anomaly-summary'); - await expect(summaryContainer).toBeVisible({ timeout: 10000 }); - - // Then check for summary text - const summary = this.page.locator(this.selectors.summaryText); - await expect(summary).toBeVisible({ timeout: 10000 }); - testLogger.info('Summary text is visible'); - } - - /** - * Expect summary to contain text - * @param {string} text - Text to check - */ - async expectSummaryContains(text) { - const summary = this.page.locator(this.selectors.summaryText); - await expect(summary).toContainText(text); - } - - /** - * Expect filter row with specific field - * @param {string} field - Field name - */ - async expectFilterRowWithField(field) { - const filterRow = this.page.locator(this.selectors.filterRow).filter({ hasText: field }); - await expect(filterRow).toBeVisible({ timeout: 5000 }); - } - - /** - * Expect data preview empty state to be visible - */ - async expectDataPreviewEmptyState() { - const emptyState = this.page.locator(this.selectors.dataPreviewEmpty); - await expect(emptyState).toBeVisible({ timeout: 5000 }); - } - - /** - * Expect destination selector to be visible - */ - async expectDestinationSelectorVisible() { - const dest = this.page.locator(this.selectors.destination); - await expect(dest).toBeVisible({ timeout: 5000 }); - } - - /** - * Scroll to sensitivity section - */ - async scrollToSensitivitySection() { - const tierGroup = this.page.locator(this.selectors.sensitivityTier); - await tierGroup.scrollIntoViewIfNeeded(); - } - - /** - * Get the exact sensitivity percentile - * @returns {Promise} - */ async getSensitivityPercentile() { - const input = this.page.locator(this.selectors.sensitivityPercentile); - return await input.inputValue(); + return this.getFormInputValue(this.selectors.sensitivityPercentile); + } + + async getActiveSensitivityTier() { + const active = this.page.locator( + `${this.selectors.sensitivityTier} [data-state="on"]`, + ); + if ((await active.count()) === 0) return null; + const dataTest = await active.first().getAttribute('data-test'); + return dataTest ? Number(dataTest.replace('anomaly-sensitivity-tier-', '')) : null; + } + + getSensitivityHintLocator() { + return this.page.locator(this.selectors.sensitivityHint); + } + + getSensitivityErrorLocator() { + return this.page.locator(this.selectors.sensitivityError); + } + + // Alerting step + + /** @param {1|2|3|4|5} priority */ + async selectPriority(priority) { + await this.selectOptionByValue(this.selectors.prioritySelect, priority); + } + + async addTags(tags) { + const input = this.page.locator(`${this.selectors.tagsInput} input`).first(); + await input.waitFor({ state: 'visible', timeout: 10000 }); + for (const tag of tags) { + await input.fill(tag); + await input.press('Enter'); + } } /** - * Get sensitivity hint text - * @returns {Promise} + * Set the notifications switch to `enabled`. + * + * OSwitch toggles from a click handler on its WRAPPER, not on the inner + * role=switch button, so a click on the button is not self-evidently a + * state change. Assert the end state — silently leaving this on makes the + * save fail much later with "At least one destination is required". */ - async getSensitivityHint() { - const hint = this.page.locator(this.selectors.sensitivityHint); - return await hint.textContent(); + async toggleNotifications(enabled) { + const root = this.page.locator(this.selectors.alertEnabled); + const sw = root.locator('[role="switch"]'); + await sw.waitFor({ state: 'visible', timeout: 10000 }); + if (((await sw.getAttribute('aria-checked')) === 'true') === enabled) return; + await root.click(); + await expect(sw).toHaveAttribute('aria-checked', String(enabled), { timeout: 5000 }); } /** - * Expect save button to be disabled + * Destination is a searchable multi-select, so the popover stays open + * between picks and each name has to be filtered for in turn — the list is + * virtualized and an org accumulates far more destinations than it renders. */ - async expectSaveButtonDisabled() { - const saveBtn = this.page.locator(this.selectors.saveButton); - await expect(saveBtn).toBeDisabled({ timeout: 5000 }); + async selectDestinations(names) { + await this.page.locator(`${this.selectors.destination} [data-test$="-trigger"]`).click(); + for (const name of names) { + await this._filterOpenSelect('anomaly-destination', name); + const option = this.page.locator( + `[data-test="anomaly-destination-option"][data-test-value="${name}"]`, + ); + await option.waitFor({ state: 'visible', timeout: 10000 }); + await option.click(); + } + await this.page.keyboard.press('Escape'); } /** - * Expect destination error to contain text - * @param {string} text - Text to check + * Re-pull the org's destinations. + * + * The wizard fetches the list once, in the view's onBeforeMount, so a + * destination created after the wizard opened is invisible to the picker + * until this runs. Await the list response rather than the click, or the + * next open of the select races the refetch. + * + * Falls back to the button's title on builds predating its data-test. */ - async expectDestinationErrorContains(text) { - const error = this.page.locator(this.selectors.destinationError); - await expect(error).toContainText(text); + async refreshDestinations() { + const btn = this.page.locator(this.selectors.refreshDestinations); + const target = (await btn.count()) + ? btn + : this.page + .locator('.step-anomaly-alerting button[title="Refresh latest Destinations"]') + .first(); + await Promise.all([ + this.page.waitForResponse( + (res) => + res.url().includes('/alerts/destinations') && + res.request().method() === 'GET' && + res.ok(), + { timeout: 15000 }, + ), + target.click(), + ]); + } + + getDestinationErrorLocator() { + return this.page.locator(this.selectors.destinationError); + } + + // Save / cancel + + async save() { + await this.page.locator(this.selectors.saveBtn).click(); } /** - * Expect timestamp error to contain text - * @param {string} text - Text to check + * Save and prove it landed. + * + * Without this the create tests navigate away regardless and a rejected + * save shows up much later as a missing table row, which says nothing about + * why. Failing here surfaces the actual validation toast instead. */ - async expectTimestampErrorContains(text) { - const error = this.page.locator(this.selectors.customSqlTimestampError); - await expect(error).toContainText(text); + async saveAndExpectSuccess() { + await this.save(); + const failure = this.getToastLocator(/error|fail|fix the highlighted fields|required/i); + const saveBtn = this.page.locator(this.selectors.saveBtn); + // Race the two outcomes so a rejection is reported in the toast's own + // words rather than as a later, meaningless "row not found". + const outcome = await Promise.race([ + failure + .first() + .waitFor({ state: 'visible', timeout: 30000 }) + .then(() => 'rejected') + .catch(() => null), + saveBtn + .waitFor({ state: 'hidden', timeout: 30000 }) + .then(() => 'saved') + .catch(() => null), + ]); + if (outcome === 'rejected') { + throw new Error(`Save was rejected: ${(await failure.first().innerText()).trim()}`); + } + if (outcome !== 'saved') { + throw new Error('Save neither completed nor reported an error'); + } } /** - * Expect to be in edit mode (Detection Config tab visible) + * Error toasts live 30s and stack over the wizard footer, where they + * intercept the click meant for Cancel. Best-effort: the dismiss hook is + * absent on older builds, so callers must not depend on this clearing them. */ - async expectEditModeOpen() { - // Detection Config tab should be visible when in edit mode - const tabSelectors = [ - '[class*="cursor-pointer"]:has-text("Detection Config")', - 'text=Detection Config', - 'button:has-text("Detection Config")', - '[role="group"] button:has-text("Detection Config")' - ]; - - let found = false; - for (const selector of tabSelectors) { - const tab = this.page.locator(selector).first(); - if (await tab.isVisible({ timeout: 2000 }).catch(() => false)) { - found = true; - break; + async dismissToasts() { + const closers = this.page.locator(this.selectors.toastDismiss); + try { + for (let i = await closers.count(); i > 0; i = await closers.count()) { + await closers.first().click({ timeout: 3000 }); + await expect(closers).toHaveCount(i - 1, { timeout: 3000 }); } + } catch { + testLogger.info('Toast dismiss unavailable; falling back to a forced click'); } - - if (!found) { - throw new Error('Edit mode not open - Detection Config tab not visible'); - } - testLogger.info('Edit mode verified - Detection Config tab visible'); } /** - * Expect seasonality text to be visible - * @param {string} text - Expected seasonality text (e.g., 'hour + day-of-week') + * Cancel is teardown, not an assertion: a toast still covering the footer + * must not fail a test that already proved its point, so the click is + * forced past any overlay once the dismiss attempt is done. */ - async expectSeasonalityText(text) { - const seasonalityElement = this.page.getByText(text).first(); - await expect(seasonalityElement).toBeVisible({ timeout: 5000 }); - testLogger.info('Seasonality text verified', { text }); + async cancel() { + await this.dismissToasts(); + const btn = this.page.locator(this.selectors.cancelBtn); + await btn.waitFor({ state: 'visible', timeout: 15000 }); + await btn.click({ force: true }); + } + + getStreamTypeSelectLocator() { return this.page.locator(this.selectors.streamTypeSelect); } + getListTableLocator() { return this.page.locator(this.selectors.listTable); } + getCustomSqlTimestampErrorLocator() { return this.page.locator(this.selectors.customSqlTimestampError); } + getCustomSqlRequiredErrorLocator() { return this.page.locator(this.selectors.customSqlRequiredError); } + getDetectionFunctionFieldLocator() { return this.page.locator(this.selectors.detectionFunctionField); } + getSqlPreviewLocator() { return this.page.locator(this.selectors.sqlPreview); } + getTagsInputLocator() { return this.page.locator(this.selectors.tagsInput); } + getDestinationLocator() { return this.page.locator(this.selectors.destination); } + getNameValueLocator() { return this.page.locator(this.selectors.nameValue); } + + /** The OSelect trigger, which carries the selected label. */ + getPriorityTriggerLocator() { + return this.page.locator(`${this.selectors.prioritySelect} [data-test$="-trigger"]`); + } + + /** @param {'1h'|'6h'|'24h'} range */ + getChartRangeItemLocator(range) { + return this.page.locator(this.selectors.chartRangeItem(range)); + } + + getSaveBtnLocator() { + return this.page.locator(this.selectors.saveBtn); + } + + getNameErrorLocator() { + return this.page.locator(this.selectors.nameError); } /** - * Select a destination from the dropdown - * @param {string} destinationName - Name of the destination to select - * @returns {Promise} - True if destination was selected + * Toast node narrowed by text. + * + * Toasts stack and error toasts live 30s, so an unrelated earlier toast is + * very likely still on screen; the bare selector would trip strict mode. */ - async selectDestination(destinationName) { - testLogger.info('Selecting destination', { destinationName }); - - const destSelect = this.page.locator(this.selectors.destination); - await destSelect.click(); - await this.page.waitForTimeout(1000); - - // Type to filter - await this.page.keyboard.type(destinationName); - await this.page.waitForTimeout(1000); - - // Try multiple selectors to find and click the option - let destFound = false; - - // Try via menu item - const destOption = this.page.locator(this.selectors.qMenuItem).filter({ hasText: destinationName }).first(); - if (await destOption.isVisible({ timeout: 3000 }).catch(() => false)) { - await destOption.click(); - await this.page.waitForTimeout(500); - destFound = true; - testLogger.info('Destination selected via menu item'); - } - - if (!destFound) { - // Try selecting via checkbox in dropdown - const checkboxItem = this.page.locator(this.selectors.qMenuItem).filter({ hasText: new RegExp(destinationName.split('_')[0]) }).first(); - if (await checkboxItem.isVisible({ timeout: 2000 }).catch(() => false)) { - await checkboxItem.click(); - await this.page.waitForTimeout(500); - destFound = true; - testLogger.info('Destination selected via checkbox'); - } - } - - // Close dropdown - await this.page.locator('body').click({ position: { x: 10, y: 10 } }); - await this.page.waitForTimeout(300); - - if (!destFound) { - testLogger.warn('Could not find destination in dropdown', { destinationName }); - } - - return destFound; + getToastLocator(hasText) { + return this.page.locator(this.selectors.toast).filter({ hasText }); } + // Right rail — data preview & summary + + getDataPreviewChartLocator() { + return this.page.locator(this.selectors.dataPreviewChart); + } + + getDataPreviewEmptyLocator() { + return this.page.locator(this.selectors.dataPreviewEmpty); + } + + /** The preview debounces edits by 600ms, so allow for that plus the query. */ + async waitForDataPreview(timeout = 30000) { + await this.getDataPreviewChartLocator().waitFor({ state: 'visible', timeout }); + } + + // List row actions + + getRow(name) { + return this.page.locator(this.selectors.rowName(name)); + } + + /** OInput puts the real behind a -field suffix; the wrapper is a div. */ /** - * Check if destination error is visible - * @returns {Promise} + * Filter the list. The search debounces, so callers must not act on a row + * until it has settled — awaitRow below is what every row action uses. */ - async isDestinationErrorVisible() { - const destError = this.page.locator(this.selectors.destinationError); - return await destError.isVisible({ timeout: 2000 }).catch(() => false); + async searchAnomaly(name) { + await this.fillFormInput(this.selectors.searchInput, name); + await this.page.locator(this.selectors.listTable).waitFor({ state: 'visible', timeout: 15000 }); } /** - * Open more options menu for an anomaly - * @param {string} anomalyName - Anomaly name + * Wait for a row to actually be on screen before acting on it. + * + * The row actions are per-row data-tests, so clicking one straight after a + * debounced search races the filter: the locator resolves to nothing and + * the click times out somewhere unrelated to the real cause. */ - async openMoreOptionsMenu(anomalyName) { - const moreOptionsButton = this.page.locator(this.selectors.moreOptionsButton(anomalyName)); - await expect(moreOptionsButton).toBeVisible({ timeout: 5000 }); - await moreOptionsButton.click(); - await this.page.waitForTimeout(500); - testLogger.info('Opened more options menu', { anomalyName }); + async awaitRow(name, timeout = 20000) { + await this.getRow(name).waitFor({ state: 'visible', timeout }); } - /** - * Click trigger detection from more options menu - * @param {string} anomalyName - Anomaly name - * @returns {Promise} - True if trigger detection was clicked - */ - async clickTriggerDetection(anomalyName) { - // First open the more options menu - await this.openMoreOptionsMenu(anomalyName); - - // Look for "Trigger Detection" menu item - const triggerMenuItem = this.page.locator(this.selectors.triggerDetectionButton(anomalyName)); - - if (await triggerMenuItem.isVisible({ timeout: 2000 }).catch(() => false)) { - testLogger.info('Found Trigger Detection menu item'); - await triggerMenuItem.click(); - await this.page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}); - testLogger.info('Anomaly detection triggered via UI'); - return true; - } - - // Alternative: try clicking by text in menu - const triggerByText = this.page.locator(this.selectors.qMenu).getByText(/trigger.*detection/i).first(); - if (await triggerByText.isVisible({ timeout: 2000 }).catch(() => false)) { - await triggerByText.click(); - await this.page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}); - testLogger.info('Anomaly detection triggered via text selector'); - return true; - } - - testLogger.info('Trigger Detection menu item not available - anomaly may need to be trained first'); - // Close menu - await this.page.locator('body').click({ position: { x: 10, y: 10 } }); - return false; + async openEdit(name) { + await this.awaitRow(name); + await this.page.locator(this.selectors.rowEdit(name)).click(); + await this.page.locator(this.selectors.saveBtn).waitFor({ state: 'visible', timeout: 15000 }); } - /** - * Clear detection window value (for validation testing) - */ - async clearDetectionWindowValue() { - const input = this.page.locator(this.selectors.detectionWindowValue); - await input.fill(''); + async openDetail(name) { + await this.awaitRow(name); + await this.page.locator(this.selectors.rowName(name)).click(); } - /** - * Click save button without waiting for success (for validation testing) - */ - async clickSaveForValidation() { - const saveBtn = this.page.locator(this.selectors.saveButton); - await saveBtn.click(); + async togglePause(name) { + await this.awaitRow(name); + await this.page.locator(this.selectors.rowPause(name)).click(); } + async triggerDetection(name) { + await this.awaitRow(name); + await this.page.locator(this.selectors.rowMoreOptions(name)).click(); + await this.page.locator(this.selectors.rowTriggerDetection(name)).click(); + } + + async retrain(name) { + await this.awaitRow(name); + await this.page.locator(this.selectors.rowMoreOptions(name)).click(); + await this.page.locator(this.selectors.rowRetrain(name)).click(); + } + + /** Delete lives in the row's overflow menu, not on the row itself. */ + async deleteAnomaly(name) { + await this.awaitRow(name); + await this.page.locator(this.selectors.rowMoreOptions(name)).click(); + await this.page.locator(this.selectors.rowDelete(name)).click(); + const confirm = this.page.locator( + '[data-test="confirm-dialog"] [data-test="o-dialog-primary-btn"]', + ); + await confirm.waitFor({ state: 'visible', timeout: 10000 }); + await confirm.click(); + } + + // Detection charts (AlertDetail) + + getDetectionChartsLocator() { + return this.page.locator(this.selectors.detectionCharts); + } + + /** @param {'metric'|'score'|'deviation'} key */ + getChartPanelLocator(key) { + return this.page.locator(this.selectors.chartPanel(key)); + } + + /** @param {'metric'|'score'|'deviation'} key */ + getChartBodyLocator(key) { + return this.page.locator(this.selectors.chartPanelBody(key)); + } + + /** @param {'metric'|'score'|'deviation'} key */ + getChartEmptyLocator(key) { + return this.page.locator(this.selectors.chartPanelEmpty(key)); + } + + async selectChartRange(value) { + const range = this.page.locator(this.selectors.detectionChartsRange); + await range.waitFor({ state: 'visible', timeout: 15000 }); + await this.page.locator(this.selectors.chartRangeItem(value)).click(); + } + + // Cleanup + /** - * Clean up test anomalies - * @param {string} pattern - Name pattern to match + * Delete every anomaly whose name contains `pattern`, via the API. + * + * Used from afterAll where there is no signed-in UI session to drive; the + * helper authenticates from env vars. */ async cleanupTestAnomalies(pattern) { - testLogger.info('Cleaning up test anomalies', { pattern }); - try { - const deletedCount = await apiCleanupTestAnomalies(this.page, pattern); - testLogger.info('Test anomalies cleanup completed', { pattern, deletedCount }); - return deletedCount; - } catch (error) { - testLogger.warn('Failed to cleanup test anomalies via API', { pattern, error: error.message }); + const deleted = await apiCleanupTestAnomalies(this.page, pattern); + testLogger.info('Cleaned up test anomalies', { pattern, deleted }); + return deleted; + } catch (e) { + testLogger.warn('API cleanup failed', { pattern, error: e.message }); return 0; } } - // ==================== NAVIGATION METHODS ==================== - - /** - * Navigate to anomaly detection tab from alerts page - */ - async navigateToAnomalyTab() { - // The tab is controlled by URL query parameter - const currentUrl = this.page.url(); - const url = new URL(currentUrl); - url.searchParams.set('tab', 'anomalyDetection'); - - await this.page.goto(url.toString()); - await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); - await this.page.waitForTimeout(1000); - - testLogger.info('Navigated to anomaly detection tab'); - } - - /** - * Check if anomaly detection feature is available (enterprise/cloud only) - * @returns {Promise} - */ - async isAnomalyDetectionAvailable() { + /** Best-effort teardown — a missing row is not a failure. */ + async cleanupAnomaly(name) { try { await this.navigateToAnomalyTab(); - // If we're redirected back to 'all' tab, feature is not available - const currentUrl = this.page.url(); - const url = new URL(currentUrl); - const tab = url.searchParams.get('tab'); - - const isAvailable = tab === 'anomalyDetection'; - testLogger.info('Anomaly detection availability check', { isAvailable }); - return isAvailable; + await this.searchAnomaly(name); + if (await this.getRow(name).isVisible({ timeout: 3000 }).catch(() => false)) { + await this.deleteAnomaly(name); + testLogger.info('Cleaned up anomaly', { name }); + } } catch (e) { - testLogger.warn('Error checking anomaly detection availability', { error: e.message }); - return false; + testLogger.warn('Cleanup failed', { name, error: e.message }); } } - - /** - * Click Add button to create new anomaly detection config - */ - async clickAddAnomaly() { - await expect(this.page.locator(this.addAnomalyButton)).toBeVisible({ timeout: 10000 }); - await this.page.locator(this.addAnomalyButton).click(); - await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); - await this.page.waitForTimeout(1000); - - testLogger.info('Clicked Add Anomaly button'); - } - - /** - * Fill anomaly detection name - * @param {string} name - Anomaly config name - */ - async fillAnomalyName(name) { - const nameInput = this.page.locator(this.anomalyNameInput).first(); - - // Wait for input to be visible and editable - await expect(nameInput).toBeVisible({ timeout: 10000 }); - await expect(nameInput).toBeEditable({ timeout: 5000 }); - - // Click to focus - await nameInput.click(); - await this.page.waitForTimeout(300); - - // Clear any existing value - await nameInput.clear(); - await this.page.waitForTimeout(300); - - // Type the value using pressSequentially for better reliability - await nameInput.pressSequentially(name, { delay: 50 }); - await this.page.waitForTimeout(500); - - // Verify the value was set - const actualValue = await nameInput.inputValue(); - testLogger.info('Filled anomaly name', { name, actualValue, match: actualValue === name }); - - if (actualValue !== name) { - testLogger.warn('Name value mismatch, retrying with fill()'); - await nameInput.fill(name); - await this.page.waitForTimeout(500); - } - } - - /** - * Select stream type for anomaly detection - * @param {string} streamType - Stream type (logs, metrics, traces) - */ - async selectStreamType(streamType = 'logs') { - await this.page.locator(this.streamTypeSelect).click(); - await this.page.waitForTimeout(500); - await this.page.getByRole('option', { name: streamType }).locator('div').nth(2).click(); - await this.page.waitForTimeout(1000); - testLogger.info('Selected stream type', { streamType }); - } - - /** - * Select stream name for anomaly detection - * @param {string} streamName - Stream name - */ - async selectStreamName(streamName) { - await this.page.locator(this.streamNameSelect).click(); - await this.page.waitForTimeout(500); - - // Type stream name to filter - await this.page.keyboard.type(streamName, { delay: 30 }); - await this.page.waitForTimeout(1000); - - await this.page.getByText(streamName, { exact: true }).click(); - await this.page.waitForTimeout(500); - testLogger.info('Selected stream name', { streamName }); - } - - /** - * Configure detection function - * @param {string} functionName - Detection function (e.g., 'count', 'avg', 'sum') - * @param {string} field - Field to apply function on (optional) - */ - async configureDetectionFunction(functionName = 'count', field = null) { - await this.page.locator(this.detectionFunctionSelect).click(); - await this.page.waitForTimeout(500); - await this.page.getByRole('option', { name: functionName }).click(); - await this.page.waitForTimeout(500); - - if (field && functionName !== 'count') { - await this.page.locator(this.detectionFunctionField).click(); - await this.page.waitForTimeout(500); - await this.page.getByText(field, { exact: true }).click(); - await this.page.waitForTimeout(500); - } - - testLogger.info('Configured detection function', { functionName, field }); - } - - /** - * Set histogram interval - * @param {number} value - Interval value - * @param {string} unit - Time unit (minutes, hours, days) - */ - async setHistogramInterval(value = 5, unit = 'minutes') { - await this.page.locator(this.histogramIntervalValue).fill(String(value)); - await this.page.waitForTimeout(300); - - await this.page.locator(this.histogramIntervalUnit).click(); - await this.page.waitForTimeout(500); - await this.page.getByRole('option', { name: unit }).click(); - await this.page.waitForTimeout(300); - - testLogger.info('Set histogram interval', { value, unit }); - } - - /** - * Set schedule interval (how often to run detection) - * For tests, use 1-2 minutes for faster validation (production typically uses 15-60 minutes) - * @param {number} value - Interval value (default: 1 for tests) - * @param {string} unit - Time unit (minutes, hours, days) - */ - async setScheduleInterval(value = 1, unit = 'minutes') { - await this.page.locator(this.scheduleIntervalValue).fill(String(value)); - await this.page.waitForTimeout(300); - - await this.page.locator(this.scheduleIntervalUnit).click(); - await this.page.waitForTimeout(500); - await this.page.getByRole('option', { name: unit }).click(); - await this.page.waitForTimeout(300); - - testLogger.info('Set schedule interval', { value, unit }); - } - - /** - * Set detection window (how much historical data to analyze) - * @param {number} value - Window value - * @param {string} unit - Time unit (hours, days) - */ - async setDetectionWindow(value = 1, unit = 'hours') { - await this.page.locator(this.detectionWindowValue).fill(String(value)); - await this.page.waitForTimeout(300); - - await this.page.locator(this.detectionWindowUnit).click(); - await this.page.waitForTimeout(500); - await this.page.getByRole('option', { name: unit }).click(); - await this.page.waitForTimeout(300); - - testLogger.info('Set detection window', { value, unit }); - } - - /** - * Wait for the auto-populated data preview chart - */ - async waitForDataPreview() { - await this.page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {}); - await expect(this.page.locator(this.dataPreviewChart)).toBeVisible({ timeout: 15000 }); - // The panel root renders for errors too, so a visible chart alone proves nothing - const panel = this.page.locator(this.dataPreviewChart); - await expect(panel.locator(this.selectors.dataPreviewError)).toHaveCount(0); - await expect(panel.locator('canvas').first()).toBeVisible({ timeout: 15000 }); - testLogger.info('Data preview chart rendered'); - } - - /** - * Enable alerting for anomaly detection - * @param {string} destinationName - Destination name for alerts - */ - async enableAlerting(destinationName) { - // Enable alert toggle - const alertToggle = this.page.locator(this.alertEnabledToggle); - const isChecked = await alertToggle.isChecked().catch(() => false); - - if (!isChecked) { - await alertToggle.click(); - await this.page.waitForTimeout(500); - testLogger.info('Enabled alerting'); - } - - // Select destination - await this.page.locator(this.destinationSelect).click(); - await this.page.waitForTimeout(1000); - - const visibleMenu = this.page.locator('[data-test$="-popover"]'); - await expect(visibleMenu.locator('[data-test$="-option"]').first()).toBeVisible({ timeout: 5000 }); - - const destOption = visibleMenu.locator('[data-test$="-option"]').filter({ hasText: destinationName }).first(); - if (await destOption.isVisible({ timeout: 3000 }).catch(() => false)) { - await destOption.click(); - } else { - // Fallback to first option - await visibleMenu.locator('[data-test$="-option"]').first().click(); - testLogger.warn('Selected first available destination (fallback)'); - } - - await this.page.waitForTimeout(500); - testLogger.info('Configured alerting destination', { destinationName }); - } - - /** - * Save anomaly detection configuration - */ - async saveAnomalyConfig() { - await this.page.locator(this.saveButton).click(); - await this.page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {}); - await this.page.waitForTimeout(2000); - - testLogger.info('Saved anomaly configuration'); - } - - /** - * Click back button to return to anomaly list - */ - async clickBack() { - await this.page.locator(this.backButton).click(); - await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); - await this.page.waitForTimeout(1000); - testLogger.info('Clicked back button'); - } - - /** - * Create a complete anomaly detection configuration - * @param {Object} config - Anomaly configuration - * @param {string} config.name - Anomaly name - * @param {string} config.streamName - Stream name - * @param {string} config.streamType - Stream type (default: 'logs') - * @param {string} config.destinationName - Destination for alerts - */ - async createAnomalyDetection(config) { - const { - name, - streamName, - streamType = 'logs', - destinationName - } = config; - - testLogger.info('Creating anomaly detection config', config); - - await this.clickAddAnomaly(); - // Fill fields in correct order (matching fillBasicSetup pattern): - // 1. Stream type first (triggers UI loading for stream names) - await this.selectStreamType(streamType); - // 2. Stream name second - await this.selectStreamName(streamName); - // 3. Anomaly name last (after stream selection) - await this.fillAnomalyName(name); - - // Configure detection (using test-optimized defaults) - // Use short intervals for faster test execution (1-2 minute detection cycles) - await this.configureDetectionFunction('count'); - await this.setHistogramInterval(1, 'minutes'); // 1-minute buckets - await this.setScheduleInterval(1, 'minutes'); // Run detection every 1 minute (faster tests) - await this.setDetectionWindow(1, 'hours'); // Look back 1 hour for anomalies - - // Note: Skip data preview loading as it's optional and not required for anomaly creation - // The data preview can be loaded separately if needed - // await this.waitForDataPreview(); - - // Enable alerting if destination provided, otherwise disable alerting - if (destinationName) { - await this.enableAlerting(destinationName); - } else { - // Disable alerting to enable save button (alerting is enabled by default) - await this.disableAlerting(); - } - - await this.saveAnomalyConfig(); - - testLogger.info('Successfully created anomaly detection', { name }); - return name; - } - - /** - * Verify anomaly config appears in the list - * @param {string} name - Anomaly config name - */ - async verifyAnomalyConfigExists(name) { - await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); - await this.page.waitForTimeout(1000); - - // Check if anomaly appears in alert list with anomaly icon - const anomalyRow = this.page.locator('tr').filter({ hasText: name }).first(); - await expect(anomalyRow).toBeVisible({ timeout: 10000 }); - - // Verify it has anomaly icon (query_stats) - const anomalyIcon = anomalyRow.locator('i[class*="query_stats"]'); - await expect(anomalyIcon).toBeVisible({ timeout: 5000 }); - - testLogger.info('Verified anomaly config exists', { name }); - } - - /** - * Get status badge for anomaly config - * @param {string} name - Anomaly config name - * @returns {Promise} Status (training, ready, failed, etc.) - */ - async getAnomalyStatus(name) { - const row = this.page.locator('tr').filter({ hasText: name }).first(); - const badge = row.locator('[data-test*="badge"], [data-test*="chip"], .status-badge'); - - if (await badge.isVisible({ timeout: 3000 })) { - const status = await badge.textContent(); - testLogger.info('Anomaly status', { name, status }); - return status.trim().toLowerCase(); - } - - return 'unknown'; - } - - /** - * Retrain anomaly detection model - * @param {string} name - Anomaly config name - */ - async retrainAnomaly(name) { - const retrainBtn = this.page.locator(`[data-test="alert-list-${name}-retrain-anomaly"]`); - await expect(retrainBtn).toBeVisible({ timeout: 10000 }); - await retrainBtn.click(); - await this.page.waitForTimeout(1000); - - testLogger.info('Triggered anomaly retraining', { name }); - } - - /** - * Pause anomaly detection - * @param {string} name - Anomaly config name - */ - async pauseAnomaly(name) { - const pauseBtn = this.page.locator(`[data-test="alert-list-${name}-pause-start-alert"]`); - await expect(pauseBtn).toBeVisible({ timeout: 10000 }); - - // Check if it's currently running (has text-negative class = red = running) - const classList = await pauseBtn.getAttribute('class'); - testLogger.info('Pause button class', { classList }); - - if (classList && classList.includes('text-negative')) { - // Wait for the API response after clicking - API uses /enable endpoint - const responsePromise = this.page.waitForResponse( - response => response.url().includes('/enable') && response.status() === 200, - { timeout: 10000 } - ).catch((e) => { - testLogger.info('Failed to wait for enable response', { error: e.message }); - return null; - }); - - await pauseBtn.click(); - const response = await responsePromise; - if (response) { - testLogger.info('Pause API response received', { url: response.url(), status: response.status() }); - } - - // Wait for UI to update - await this.page.waitForTimeout(2000); - - // Navigate away and back to refresh the list data - await this.page.reload({ waitUntil: 'networkidle' }); - await this.page.waitForTimeout(1000); - - // Navigate back to anomaly tab after reload - await this.navigateToAnomalyTab(); - await this.page.waitForTimeout(1000); - - testLogger.info('Paused anomaly detection', { name }); - } else { - testLogger.info('Anomaly already paused or button class unexpected', { classList }); - } - } - - /** - * Resume anomaly detection - * @param {string} name - Anomaly config name - */ - async resumeAnomaly(name) { - const resumeBtn = this.page.locator(`[data-test="alert-list-${name}-pause-start-alert"]`); - await expect(resumeBtn).toBeVisible({ timeout: 10000 }); - - // Check if it's currently paused (has text-positive class) - const classList = await resumeBtn.getAttribute('class'); - if (classList && classList.includes('text-positive')) { - // Wait for the API response after clicking - const responsePromise = this.page.waitForResponse( - response => response.url().includes('/toggle_state') && response.status() === 200, - { timeout: 10000 } - ).catch(() => null); - - await resumeBtn.click(); - await responsePromise; - - // Wait for UI to update and refresh the list by navigating away and back - await this.page.waitForTimeout(1000); - - // Click on a different tab and back to refresh the list - const alertsTab = this.page.locator('[data-test="menu-link-\\/alerts-item"]').first(); - if (await alertsTab.isVisible({ timeout: 2000 }).catch(() => false)) { - await alertsTab.click(); - await this.page.waitForTimeout(500); - } - - // Navigate back to anomaly tab - await this.navigateToAnomalyTab(); - await this.page.waitForTimeout(1000); - - testLogger.info('Resumed anomaly detection', { name }); - } else { - testLogger.info('Anomaly already running', { name }); - } - } - - /** - * Delete anomaly detection config - * @param {string} name - Anomaly config name - */ - async deleteAnomaly(name) { - const moreBtn = this.page.locator(`[data-test="alert-list-${name}-more-options"]`); - await expect(moreBtn).toBeVisible({ timeout: 10000 }); - await moreBtn.click(); - await this.page.waitForTimeout(500); - - await this.page.getByText('Delete', { exact: true }).click(); - await this.page.waitForTimeout(500); - - // Confirm deletion - const confirmBtn = this.page.locator('button:has-text("OK")').or( - this.page.locator('button:has-text("Delete")') - ); - await confirmBtn.click(); - await this.page.waitForTimeout(1000); - - testLogger.info('Deleted anomaly config', { name }); - } - - /** - * Edit anomaly detection config - * @param {string} name - Anomaly config name - */ - async editAnomaly(name) { - const editBtn = this.page.locator(`[data-test="alert-list-${name}-update-alert"]`); - await expect(editBtn).toBeVisible({ timeout: 10000 }); - await editBtn.click(); - await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); - await this.page.waitForTimeout(1000); - - testLogger.info('Opened anomaly for editing', { name }); - } - - // ==================== ANOMALY DETECTION TRIGGER VERIFICATION ==================== - - /** - * Wait for anomaly model to finish training - * @param {string} name - Anomaly config name - * @param {number} maxWaitSeconds - Maximum time to wait for training (default: 300 = 5 minutes) - * @returns {Promise<{success: boolean, status: string}>} - */ - async waitForModelTraining(name, maxWaitSeconds = 300) { - testLogger.info('Waiting for anomaly model to complete training', { name, maxWaitSeconds }); - - const startTime = Date.now(); - const maxWaitMs = maxWaitSeconds * 1000; - - while (Date.now() - startTime < maxWaitMs) { - const status = await this.getAnomalyStatus(name); - - if (status === 'ready' || status === 'active') { - testLogger.info('Anomaly model training completed', { name, status, waitedSeconds: Math.floor((Date.now() - startTime) / 1000) }); - return { success: true, status }; - } - - if (status === 'failed') { - testLogger.error('Anomaly model training failed', { name, status }); - return { success: false, status }; - } - - // Still training, wait and check again - testLogger.debug('Model still training', { name, status, elapsedSeconds: Math.floor((Date.now() - startTime) / 1000) }); - await this.page.waitForTimeout(10000); // Check every 10 seconds - - // Refresh the page to get latest status - await this.page.reload(); - await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); - await this.page.waitForTimeout(2000); - } - - testLogger.warn('Model training timeout', { name, maxWaitSeconds }); - return { success: false, status: 'timeout' }; - } - - /** - * Ingest data pattern to establish baseline for anomaly detection - * @param {Object} pm - PageManager instance - * @param {string} streamName - Stream name to ingest to - * @param {Object} pattern - Data pattern to establish baseline - * @param {number} count - Number of data points to ingest (default: 50) - */ - async ingestBaselineData(pm, streamName, pattern = {}, count = 50) { - testLogger.info('Ingesting baseline data for anomaly detection', { streamName, count, pattern }); - - for (let i = 0; i < count; i++) { - await pm.commonActions.ingestTestDataWithUniqueId( - streamName, - `baseline_${Date.now()}_${i}`, - 'value', - pattern.baselineValue || 100 // Normal value - ); - - // Small delay between ingestions - if (i % 10 === 0) { - await this.page.waitForTimeout(1000); - } - } - - testLogger.info('Baseline data ingested', { count }); - } - - /** - * Ingest anomalous data (data that deviates from normal pattern) - * @param {Object} pm - PageManager instance - * @param {string} streamName - Stream name to ingest to - * @param {Object} anomalyPattern - Anomalous data pattern - */ - async ingestAnomalousData(pm, streamName, anomalyPattern = {}) { - testLogger.info('Ingesting anomalous data', { streamName, anomalyPattern }); - - const uniqueTestId = `anomaly_${Date.now()}_${Math.random().toString(36).substring(2, 6)}`; - - await pm.commonActions.ingestTestDataWithUniqueId( - streamName, - uniqueTestId, - 'value', - anomalyPattern.anomalousValue || 1000 // Anomalous value (10x normal) - ); - - testLogger.info('Anomalous data ingested', { uniqueTestId }); - return uniqueTestId; - } - - /** - * Verify anomaly detection trigger via validation stream - * Similar to alert trigger verification but for anomaly detection - * - * @param {Object} pm - PageManager instance - * @param {string} anomalyName - Name of the anomaly config - * @param {string} sourceStreamName - Stream where anomaly should be detected - * @param {string} validationStreamName - Validation stream for anomaly alerts - * @param {Object} anomalyPattern - Pattern for anomalous data - * @param {number} waitTimeMs - Time to wait for detection (default: 150000 = 2.5 minutes) - * Should be ~2x the schedule_interval to ensure detection runs - * @returns {Promise<{detected: boolean, logText: string|null}>} - */ - async verifyAnomalyDetection(pm, anomalyName, sourceStreamName, validationStreamName, anomalyPattern = {}, waitTimeMs = 150000) { - testLogger.info('Starting anomaly detection verification', { - anomalyName, - sourceStreamName, - validationStreamName, - waitTimeMs - }); - - // Step 1: Wait for model to be ready - const trainingResult = await this.waitForModelTraining(anomalyName, 300); // 5 minutes max - if (!trainingResult.success) { - testLogger.error('Model training did not complete', trainingResult); - return { detected: false, logText: 'Model training failed or timed out' }; - } - - // Step 2: Ingest baseline data if needed - testLogger.info('Baseline data should already exist from stream history'); - - // Step 3: Ingest anomalous data - const uniqueTestId = await this.ingestAnomalousData(pm, sourceStreamName, anomalyPattern); - - testLogger.info('Ingested anomalous data, waiting for detection...', { uniqueTestId }); - - // Step 4: Wait for anomaly detection to run - await this.page.waitForTimeout(waitTimeMs); - - // Step 5: Check validation stream for anomaly alert - testLogger.info('Checking validation stream for anomaly alert'); - - await this.page.locator('[data-test="menu-link-\\/logs-item"]').click(); - await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); - await this.page.waitForTimeout(2000); - - try { - await pm.logsPage.selectStream(validationStreamName, 2, 5000); - await this.page.waitForTimeout(1000); - } catch (streamError) { - testLogger.warn('Validation stream not found - anomaly may not have been detected', { - anomalyName, - validationStreamName, - error: streamError.message - }); - return { detected: false, logText: null }; - } - - await pm.logsPage.clickRefreshButton(); - await this.page.waitForTimeout(3000); - - // Search for anomaly alert in validation stream - let logTableCell = this.page.locator('[data-test="o2-table-row-0"] [data-test="o2-table-cell-source"]'); - let logCount = await logTableCell.count(); - - if (logCount === 0) { - logTableCell = this.page.locator('tbody tr'); - logCount = await logTableCell.count(); - } - - testLogger.info(`Found ${logCount} log entries in validation stream`); - - let anomalyDetected = false; - let foundLogText = null; - - // Look for anomaly name or "anomaly" keyword in logs - for (let i = 0; i < logCount && !anomalyDetected; i++) { - const logText = await logTableCell.nth(i).textContent(); - if (logText && (logText.includes(anomalyName) || logText.toLowerCase().includes('anomaly detected'))) { - anomalyDetected = true; - foundLogText = logText; - testLogger.info('SUCCESS: Anomaly detection alert found in validation stream!', { - anomalyName, - logIndex: i, - logTextPreview: logText.substring(0, 400) - }); - } - } - - if (!anomalyDetected) { - testLogger.warn('Anomaly detection alert not found in validation stream', { - anomalyName, - validationStreamName, - logsChecked: logCount - }); - } - - return { detected: anomalyDetected, logText: foundLogText }; - } - - /** - * Create anomaly detection and verify it detects anomalies (full E2E flow) - * @param {Object} pm - PageManager instance - * @param {Object} config - Anomaly configuration with validation settings - * @param {Object} anomalyPattern - Pattern for generating anomalous data - * @returns {Promise<{name: string, detected: boolean, logText: string|null}>} - */ - async createAndVerifyAnomalyDetection(pm, config, anomalyPattern = {}) { - testLogger.info('Creating and verifying anomaly detection E2E', config); - - // Create anomaly detection - const anomalyName = await this.createAnomalyDetection(config); - - // Navigate back to anomaly tab - await pm.commonActions.navigateToAlerts(); - await this.navigateToAnomalyTab(); - - // Verify anomaly detection - const detectionResult = await this.verifyAnomalyDetection( - pm, - anomalyName, - config.streamName, - config.validationStreamName, - anomalyPattern, - config.detectionWaitTimeMs || 120000 - ); - - return { - name: anomalyName, - ...detectionResult - }; - } } module.exports = { AnomalyDetectionPage }; diff --git a/tests/ui-testing/pages/anomalyPages/anomalyFormValidationPage.js b/tests/ui-testing/pages/anomalyPages/anomalyFormValidationPage.js deleted file mode 100644 index 4ea9821768..0000000000 --- a/tests/ui-testing/pages/anomalyPages/anomalyFormValidationPage.js +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright 2026 OpenObserve Inc. - -const testLogger = require('../../playwright-tests/utils/test-logger.js'); - -class AnomalyFormValidationPage { - /** - * @param {import('@playwright/test').Page} page - */ - constructor(page) { - this.page = page; - - // ── Navigation ───────────────────────────────────────────────────── - this.alertsMenuLink = '[data-test="menu-link-\\/alerts-item"]'; - this.anomalyDetectionTab = '[data-test="tab-anomalyDetection"]'; - - // ── Anomaly list ─────────────────────────────────────────────────── - this.addAnomalyBtn = '[data-test="alert-list-add-alert-btn"]'; - // AlertList.vue uses alert-list-table for all tabs including anomalyDetection - this.anomalyTable = '[data-test="alert-list-table"]'; - - // ── Step 1 — stream selection ────────────────────────────────────── - // OSelect: stream type dropdown - this.streamTypeSelect = '[data-test="add-alert-stream-type-select-dropdown"]'; - // OSelect: stream name dropdown - this.streamNameSelect = '[data-test="add-alert-stream-name-select-dropdown"]'; - // Save / Cancel buttons shared across the wizard. There is no Next button: - // the V3 layout is a single pane navigated by tabs, not a stepper. - this.saveBtn = '[data-test="add-alert-submit-btn"]'; - this.cancelBtn = '[data-test="add-alert-cancel-btn"]'; - - // ── Validation feedback ──────────────────────────────────────────── - // OInput derives its error node's data-test from the consumer's own - // data-test, so the topbar OFormInput yields -error. - this.anomalyNameError = '[data-test="add-anomaly-name-input-error"]'; - this.toastMessage = '[data-test="o-toast-message"]'; - - // ── Step 2 — AnomalyDetectionConfig fields ───────────────────────── - this.queryTabsToggle = '[data-test="anomaly-query-tabs"]'; - this.detectionFunction = '[data-test="anomaly-detection-function"]'; - this.detectionFunctionField = '[data-test="anomaly-detection-function-field"]'; - this.histogramIntervalValue = '[data-test="anomaly-histogram-interval-value"]'; - this.histogramIntervalUnit = '[data-test="anomaly-histogram-interval-unit"]'; - this.detectionWindowValue = '[data-test="anomaly-detection-window-value"]'; - this.detectionWindowUnit = '[data-test="anomaly-detection-window-unit"]'; - this.detectionWindowError = '[data-test="anomaly-detection-window-error"]'; - this.trainingWindow = '[data-test="anomaly-training-window"]'; - this.customSqlEditor = '[data-test="anomaly-custom-sql"]'; - this.customSqlTimestampError = '[data-test="anomaly-custom-sql-timestamp-alias-error"]'; - } - - // ── Navigation ───────────────────────────────────────────────────────── - - /** - * Navigate to Anomaly Detection tab. - * Returns false on OSS builds where the tab is hidden by enterprise feature flag. - */ - async navigateToAnomalyDetection() { - testLogger.info('Navigating to Alerts > Anomaly Detection tab'); - await this.page.locator(this.alertsMenuLink).click(); - // Tab is only rendered when build_type !== "opensource" && isEnterprise === "true" - const tab = this.page.locator(this.anomalyDetectionTab); - const tabVisible = await tab.waitFor({ state: 'visible', timeout: 5000 }).then(() => true).catch(() => false); - if (!tabVisible) { - testLogger.info('Anomaly Detection tab not available (OSS build) — skipping'); - return false; - } - await tab.click(); - await this.page.locator(this.anomalyTable).waitFor({ state: 'visible', timeout: 10000 }); - testLogger.info('Anomaly Detection tab loaded'); - return true; - } - - async openAddAnomalyWizard() { - testLogger.info('Opening Add Anomaly wizard'); - await this.page.locator(this.addAnomalyBtn).click(); - // Wait for step 1 (stream selection) to appear - await this.page.locator(this.streamTypeSelect).waitFor({ state: 'visible', timeout: 10000 }); - testLogger.info('Add Anomaly wizard opened'); - } - - async clickSave() { - testLogger.info('Clicking Save in anomaly wizard'); - await this.page.locator(this.saveBtn).click(); - } - - async clickCancel() { - testLogger.info('Clicking Cancel in anomaly wizard'); - await this.page.locator(this.cancelBtn).click(); - } - - // ── Locator getters ──────────────────────────────────────────────────── - - getStreamTypeSelectLocator() { return this.page.locator(this.streamTypeSelect); } - getStreamNameSelectLocator() { return this.page.locator(this.streamNameSelect); } - getSaveBtnLocator() { return this.page.locator(this.saveBtn); } - getAnomalyNameErrorLocator() { return this.page.locator(this.anomalyNameError); } - - /** - * Toast message node, narrowed to the one we care about. - * - * Toasts STACK (OToastProvider v-for's over every open record) and error - * toasts live 30s, so an unrelated earlier error is very likely still on - * screen. Matching the bare selector would resolve to several nodes and trip - * Playwright strict mode, so always filter by the expected text. - * - * @param {string|RegExp} hasText text the target toast must contain - */ - getToastMessageLocator(hasText) { - return this.page.locator(this.toastMessage).filter({ hasText }); - } - getCancelBtnLocator() { return this.page.locator(this.cancelBtn); } - getDetectionWindowErrorLocator() { return this.page.locator(this.detectionWindowError); } - getCustomSqlTimestampErrorLocator() { return this.page.locator(this.customSqlTimestampError); } - getQueryTabsLocator() { return this.page.locator(this.queryTabsToggle); } - getDetectionFunctionLocator() { return this.page.locator(this.detectionFunction); } - getAnomalyTableLocator() { return this.page.locator(this.anomalyTable); } -} - -module.exports = { AnomalyFormValidationPage }; diff --git a/tests/ui-testing/pages/page-manager.js b/tests/ui-testing/pages/page-manager.js index 475ad8c692..30db7598ce 100644 --- a/tests/ui-testing/pages/page-manager.js +++ b/tests/ui-testing/pages/page-manager.js @@ -120,7 +120,6 @@ const FunctionsFormValidationPage = require("./functionsPages/functionsFormValid // ===== ANOMALY DETECTION PAGE OBJECTS ===== const { AnomalyDetectionPage } = require("./anomalyPages/anomalyDetectionPage.js"); -const { AnomalyFormValidationPage } = require("./anomalyPages/anomalyFormValidationPage.js"); class PageManager { /** @@ -253,7 +252,6 @@ class PageManager { // ===== ANOMALY DETECTION PAGE OBJECTS ===== this.anomalyDetectionPage = new AnomalyDetectionPage(page, this.commonActions); - this.anomalyFormValidation = new AnomalyFormValidationPage(page); this.aiToolsetsFormValidation = new AiToolsetsFormValidationPage(page); // ===== RUM PAGE OBJECTS ===== diff --git a/tests/ui-testing/playwright-tests/Alerts/alerts-anomaly-detection.spec.js b/tests/ui-testing/playwright-tests/Alerts/alerts-anomaly-detection.spec.js index e89453186b..5cf3ff2361 100644 --- a/tests/ui-testing/playwright-tests/Alerts/alerts-anomaly-detection.spec.js +++ b/tests/ui-testing/playwright-tests/Alerts/alerts-anomaly-detection.spec.js @@ -1,822 +1,888 @@ +// Copyright 2026 OpenObserve Inc. +// +// Anomaly Detection — end-to-end suite. +// +// Anomaly detection is an alert *type*, not a module: it lives behind the +// `anomalyDetection` tab on the Alerts list and is edited in the AddAlert +// wizard's anomaly-config / anomaly-alerting tabs. This file is the single +// anomaly suite — it replaces the former Anomaly/ folder and the separate +// anomaly-detection-form-validation spec. +// +// ENTERPRISE-ONLY. `anomaly_detection_enabled` is false unless the binary is +// built with --features enterprise, and OSS CI is not, so every test here skips +// there. Deliberately absent from the OSS ci_matrix.json — build-ci-matrix.js +// rejects a spec listed in both the base and the overlay — and registered in +// o2-enterprise's ci_matrix.ent.json under `append.Alerts` instead. + +const http = require('http'); const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js'); const testLogger = require('../utils/test-logger.js'); const PageManager = require('../../pages/page-manager.js'); -const logData = require('../../fixtures/log.json'); -const { getOrgName, listAnomalyDetections, triggerAnomalyDetection, getAnomalyHistory } = require('../utils/api-helper.js'); - -test.describe("Anomaly Detection Alerts", () => { - test.describe.configure({ mode: 'serial' }); +const { listAnomalyDetections, triggerAnomalyDetection, getAnomalyHistory, deleteDestination, deleteTemplate, createAnomalyViaApi, waitForAnomalyListed, createMockDestination, destinationExists, searchSql, seedAnomalyStream, waitForStream, + triggerAnomalyTraining, waitForAnomalyTrained } = require('../utils/api-helper.js'); +test.describe('Anomaly Detection', () => { let pm; - // Generate randomValue ONCE for all serial tests so they can share the same anomaly name - const randomValue = Date.now().toString().slice(-6); - const testStreamName = 'e2e_automate'; // Stream created by global setup - const testAnomalyName = (suffix) => `E2E_Anomaly_${suffix}_${randomValue}`; + // One value per run so the lifecycle chain can hand names between tests. + // Timestamp ALONE is not unique per worker: workers start within the same + // millisecond window and Date.now().slice(-6) then collides, so two of them + // share template/destination/anomaly names — the create 500s on a duplicate + // key and one worker's cleanup deletes the other's fixtures. The random + // suffix is what actually makes the run id unique. + const randomValue = `${Date.now().toString().slice(-6)}${Math.random().toString(36).slice(2, 8)}`; + const testStreamName = 'e2e_automate'; + // Unique per call: tests own their own records now, and two parallel tests + // asking for the same suffix must not collide on one name. + let seq = 0; + const anomalyName = (suffix) => `E2E_Anomaly_${suffix}${++seq}_${randomValue}`; - // Prerequisite template and destination names - ensures Add Alert button is enabled + // The Add button is disabled while the org has no destination, so both must + // exist before any wizard test can open. const prerequisiteTemplateName = `e2e_anomaly_template_${randomValue}`; const prerequisiteDestinationName = `e2e_anomaly_dest_${randomValue}`; + test.beforeEach(async ({ page }, testInfo) => { testLogger.testStart(testInfo.title, testInfo.file); await navigateToBase(page); pm = new PageManager(page); - // Ensure template and destination exist (same pattern as alerts-e2e-flow.spec.js) - // This enables the Add Alert button which is disabled when no destinations exist await pm.alertTemplatesPage.ensureTemplateExists(prerequisiteTemplateName); - await pm.alertDestinationsPage.ensureDestinationExists(prerequisiteDestinationName, 'DEMO', prerequisiteTemplateName); + // Created through the API, like the template above, rather than driven + // through the destinations form. The URL must be routable and absolute: + // the SSRF guard rejects bare placeholders ("relative URL without a base") + // and private IPs, and a silently failed create leaves every test without + // the destination it later selects. + const dest = await createMockDestination( + page, + prerequisiteDestinationName, + prerequisiteTemplateName, + ); + // beforeEach runs before every test, so only the first call in a worker + // actually creates it; the rest come back 400 "already exists" (not 409). + // Assert the destination EXISTS rather than pinning a status code — that + // is what the tests depend on, and it survives the API changing its mind. + if (dest.status !== 200) { + expect( + await destinationExists(page, prerequisiteDestinationName), + `destination ${prerequisiteDestinationName} missing; create returned ${dest.status}: ${JSON.stringify(dest.data)}`, + ).toBe(true); + } - // Navigate to alerts page and then to anomaly detection tab - await pm.commonActions.navigateToAlerts(); - await page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {}); + // Presence of the tab is the only honest feature check — AlertList coerces + // activeTab back to "all" without rewriting ?tab=, so the URL always lies. + const available = await pm.anomalyDetectionPage.isAnomalyDetectionAvailable(); + test.skip(!available, 'Anomaly Detection is disabled on this build (OSS, or ZO_ANOMALY_DETECTION_DISABLED)'); - // Navigate to anomaly detection tab await pm.anomalyDetectionPage.navigateToAnomalyTab(); - - // Wait for alerts table to be ready - await pm.anomalyDetectionPage.waitForAlertsTable(); - - testLogger.info('Test setup completed', { randomValue }); - }); - - // ======================================================================== - // P0 TESTS - SMOKE / CRITICAL PATH - // ======================================================================== - - test.describe("Create Anomaly Detection - Builder Mode", () => { - - test("Create basic anomaly detection with builder mode", { - tag: ['@smoke', '@anomaly', '@P0', '@all'] - }, async ({ page }) => { - testLogger.info('Creating basic anomaly detection'); - - const anomalyName = testAnomalyName('Basic'); - - // Start anomaly creation - await pm.anomalyDetectionPage.clickAddAnomaly(); - - // Fill basic setup - await pm.anomalyDetectionPage.fillBasicSetup( - anomalyName, - 'logs', - testStreamName - ); - - // Wait for the anomaly configuration UI to be ready - await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}); - - testLogger.info('Checking if anomaly configuration UI is visible'); - - // Click Detection Config tab using POM method - await pm.anomalyDetectionPage.clickTab('Detection Config'); - - // Configure detection settings using POM methods - testLogger.info('Setting detection configuration'); - await pm.anomalyDetectionPage.setDetectionResolution(5, 'm'); - await pm.anomalyDetectionPage.setCheckEvery(10, 'm'); - await pm.anomalyDetectionPage.setLookBackWindow(30, 'm'); - await pm.anomalyDetectionPage.setTrainingWindow(1); - - // Set retrain interval - it should already be set to "Never" by default, skip if not critical - testLogger.info('Skipping retrain interval (default is Never)'); - - // Disable alerting using POM method (alerting is enabled by default with no destination) - // Tab navigation inside disableAlerting() dismisses any open menus deterministically - await pm.anomalyDetectionPage.disableAlerting(); - - // Wait for Vue reactivity and validation to complete - // clickSave() waits for the save button to be enabled deterministically - await pm.anomalyDetectionPage.clickSave(); - - // Verify we're back on the list and the anomaly appears - const anomalyRow = pm.anomalyDetectionPage.getAnomalyRow(anomalyName); - await expect(anomalyRow).toBeVisible({ timeout: 15000 }); - - testLogger.info('Anomaly created successfully and visible in list', { anomalyName }); - }); - - test("View anomaly detection in list", { - tag: ['@smoke', '@anomaly', '@P0', '@all'] - }, async ({ page }) => { - testLogger.info('Viewing anomaly in list'); - - const anomalyName = testAnomalyName('Basic'); - - // Verify anomaly appears in the alerts list - const anomalyRow = pm.anomalyDetectionPage.getAnomalyRow(anomalyName); - await expect(anomalyRow).toBeVisible({ timeout: 10000 }); - - // Verify action buttons using POM methods - await pm.anomalyDetectionPage.expectEditButtonVisible(anomalyName); - await pm.anomalyDetectionPage.expectPauseButtonVisible(anomalyName); - - testLogger.info('Anomaly visible in list with action buttons'); - }); - - test("Edit existing anomaly detection", { - tag: ['@smoke', '@anomaly', '@P0', '@all'] - }, async ({ page }) => { - testLogger.info('Editing anomaly detection'); - - const anomalyName = testAnomalyName('Basic'); - - // Click edit button using POM method - await pm.anomalyDetectionPage.editAnomaly(anomalyName); - - // Verify wizard opens in edit mode - await pm.anomalyDetectionPage.expectEditModeOpen(); - - // Change training window to 7 days - await pm.anomalyDetectionPage.setTrainingWindow(7); - - // Verify seasonality text changes - await pm.anomalyDetectionPage.expectSeasonalityText('hour + day-of-week'); - - // Save changes using POM method - await pm.anomalyDetectionPage.clickSave(); - - // Verify still in list - const anomalyRow = pm.anomalyDetectionPage.getAnomalyRow(anomalyName); - await expect(anomalyRow).toBeVisible({ timeout: 10000 }); - - testLogger.info('Anomaly edited successfully'); - }); - - test("Delete anomaly detection", { - tag: ['@smoke', '@anomaly', '@P0', '@all'] - }, async ({ page }) => { - testLogger.info('Deleting anomaly detection'); - - const anomalyName = testAnomalyName('Basic'); - - // Delete anomaly using POM method - await pm.anomalyDetectionPage.deleteAnomaly(anomalyName); - - // Verify removed from list - const anomalyRow = pm.anomalyDetectionPage.getAnomalyRow(anomalyName); - await expect(anomalyRow).not.toBeVisible({ timeout: 10000 }); - - testLogger.info('Anomaly deleted successfully'); - }); - }); - - // ======================================================================== - // P1 TESTS - FUNCTIONAL / MAIN WORKFLOWS - // ======================================================================== - - test.describe("Create Anomaly Detection - SQL Mode", () => { - - test("Create anomaly with SQL mode", { - tag: ['@functional', '@anomaly', '@P1', '@all'] - }, async ({ page }) => { - testLogger.info('Creating anomaly with SQL mode'); - - const anomalyName = testAnomalyName('SQL'); - - // Start creation - await pm.anomalyDetectionPage.clickAddAnomaly(); - - await pm.anomalyDetectionPage.fillBasicSetup(anomalyName, 'logs', testStreamName); - - // Configure with SQL - await pm.anomalyDetectionPage.clickTab('Detection Config'); - await pm.anomalyDetectionPage.selectQueryMode('sql'); - - // Verify SQL editor appears (Monaco editor takes time to initialize) - await pm.anomalyDetectionPage.expectSqlEditorVisible(); - - // Set custom SQL - use simple count without invalid field references - const customSql = `SELECT histogram(_timestamp, '10m') AS time_bucket, count(*) AS value FROM "${testStreamName}" GROUP BY time_bucket ORDER BY time_bucket`; - await pm.anomalyDetectionPage.setSqlQuery(customSql); - - // Set intervals - await pm.anomalyDetectionPage.setDetectionResolution(10, 'm'); - await pm.anomalyDetectionPage.setCheckEvery(30, 'm'); - await pm.anomalyDetectionPage.setLookBackWindow(1, 'h'); - await pm.anomalyDetectionPage.setTrainingWindow(3); - await pm.anomalyDetectionPage.setRetrainInterval('Never'); - - // Disable alerting (required - alerting is enabled by default with no destination) - await pm.anomalyDetectionPage.disableAlerting(); - - // Save - await pm.anomalyDetectionPage.clickSave(); - - // Verify created - await pm.anomalyDetectionPage.expectAnomalyInList(anomalyName); - - testLogger.info('SQL mode anomaly created successfully'); - }); - }); - - test.describe("Anomaly Configuration - Builder Mode", () => { - - test("Create anomaly with detection function (avg)", { - tag: ['@functional', '@anomaly', '@P1', '@all'] - }, async ({ page }) => { - testLogger.info('Creating anomaly with avg function'); - - const anomalyName = testAnomalyName('Avg'); - - await pm.anomalyDetectionPage.clickAddAnomaly(); - - await pm.anomalyDetectionPage.fillBasicSetup(anomalyName, 'logs', testStreamName); - - await pm.anomalyDetectionPage.clickTab('Detection Config'); - await pm.anomalyDetectionPage.selectQueryMode('builder'); - - // Configure with avg function - use 'code' field which exists in test data - await pm.anomalyDetectionPage.configureBuilderMode({ - function: 'avg', - field: 'code' - }); - - // Verify field selector appeared - await pm.anomalyDetectionPage.expectDetectionFieldVisible(); - - // Set other config - await pm.anomalyDetectionPage.setDetectionResolution(5, 'm'); - await pm.anomalyDetectionPage.setCheckEvery(15, 'm'); - await pm.anomalyDetectionPage.setLookBackWindow(30, 'm'); - await pm.anomalyDetectionPage.setTrainingWindow(1); - await pm.anomalyDetectionPage.setRetrainInterval('1 day'); - - // Disable alerting (required - alerting is enabled by default with no destination) - await pm.anomalyDetectionPage.disableAlerting(); - - await pm.anomalyDetectionPage.clickSave(); - - await pm.anomalyDetectionPage.expectAnomalyInList(anomalyName); - - testLogger.info('Anomaly with avg function created successfully'); - }); - - test("Add filters in builder mode", { - tag: ['@functional', '@anomaly', '@P1', '@all'] - }, async ({ page }) => { - testLogger.info('Creating anomaly with filters'); - - const anomalyName = testAnomalyName('Filtered'); - - await pm.anomalyDetectionPage.clickAddAnomaly(); - - await pm.anomalyDetectionPage.fillBasicSetup(anomalyName, 'logs', testStreamName); - - await pm.anomalyDetectionPage.clickTab('Detection Config'); - await pm.anomalyDetectionPage.selectQueryMode('builder'); - - // Add filters - use fields that exist in the test stream - await pm.anomalyDetectionPage.addFilter('code', '=', '200'); - await pm.anomalyDetectionPage.addFilter('stream', 'Contains', 'stdout'); - - // Verify filters added using POM method - await pm.anomalyDetectionPage.expectFilterCount(2); - - // Complete config - await pm.anomalyDetectionPage.setDetectionResolution(5, 'm'); - await pm.anomalyDetectionPage.setCheckEvery(10, 'm'); - await pm.anomalyDetectionPage.setLookBackWindow(30, 'm'); - await pm.anomalyDetectionPage.setTrainingWindow(1); - - // Disable alerting (required - alerting is enabled by default with no destination) - await pm.anomalyDetectionPage.disableAlerting(); - - await pm.anomalyDetectionPage.clickSave(); - await pm.anomalyDetectionPage.expectAnomalyInList(anomalyName); - - testLogger.info('Anomaly with filters created successfully'); - }); - }); - - test.describe("Anomaly Alerting Configuration", () => { - - test("Enable alerting with destinations (CI/CD compatible)", { - tag: ['@functional', '@anomaly', '@P1', '@all', '@cicd'] - }, async ({ page }) => { - testLogger.info('Creating anomaly with alerting enabled'); - - // Use the prerequisite destination created in beforeEach - const testDestinationName = prerequisiteDestinationName; - - const anomalyName = testAnomalyName('WithAlerts'); - - await pm.anomalyDetectionPage.clickAddAnomaly(); - - await pm.anomalyDetectionPage.fillBasicSetup(anomalyName, 'logs', testStreamName); - - // Config tab - await pm.anomalyDetectionPage.clickTab('Detection Config'); - await pm.anomalyDetectionPage.setDetectionResolution(5, 'm'); - await pm.anomalyDetectionPage.setCheckEvery(10, 'm'); - await pm.anomalyDetectionPage.setLookBackWindow(30, 'm'); - await pm.anomalyDetectionPage.setTrainingWindow(1); - - // Alerting tab - await pm.anomalyDetectionPage.clickTab('Alerting'); - - // Alerting should already be enabled by default, but ensure it's on - await pm.anomalyDetectionPage.toggleNotifications(true); - - // Verify destination selector appears - await pm.anomalyDetectionPage.expectDestinationSelectorVisible(); - - // Select the prerequisite destination created in beforeEach using POM method - const destFound = await pm.anomalyDetectionPage.selectDestination(testDestinationName); - - // Verify destination error is gone (destination selected) - const errorVisible = await pm.anomalyDetectionPage.isDestinationErrorVisible(); - if (errorVisible || !destFound) { - testLogger.warn('Destination error still visible - destination may not be selected'); - // Cancel and skip test - await pm.anomalyDetectionPage.clickBack(); - test.skip(true, 'Could not select destination in dropdown'); - return; - } - - // Save anomaly - await pm.anomalyDetectionPage.clickSave(); - await pm.anomalyDetectionPage.expectAnomalyInList(anomalyName); - - testLogger.info('Anomaly with alerting created successfully'); - }); - - test("Refresh destinations list", { - tag: ['@functional', '@anomaly', '@P1', '@all'] - }, async ({ page }) => { - testLogger.info('Testing refresh destinations'); - - const anomalyName = testAnomalyName('RefreshDest'); - - await pm.anomalyDetectionPage.clickAddAnomaly(); - - await pm.anomalyDetectionPage.fillBasicSetup(anomalyName, 'logs', testStreamName); - - await pm.anomalyDetectionPage.clickTab('Detection Config'); - await pm.anomalyDetectionPage.setDetectionResolution(5, 'm'); - await pm.anomalyDetectionPage.setCheckEvery(10, 'm'); - await pm.anomalyDetectionPage.setLookBackWindow(30, 'm'); - await pm.anomalyDetectionPage.setTrainingWindow(1); - - await pm.anomalyDetectionPage.clickTab('Alerting'); - await pm.anomalyDetectionPage.toggleNotifications(true); - - // Click refresh button - await pm.anomalyDetectionPage.refreshDestinations(); - - // Verify no errors (destinations reloaded) - await pm.anomalyDetectionPage.expectDestinationSelectorVisible(); - - // Cancel creation - await pm.anomalyDetectionPage.clickBack(); - - testLogger.info('Destinations refresh works'); - }); - }); - - test.describe("Anomaly Management Operations", () => { - - // TODO: Status badge doesn't update to 'disabled' after pause - needs backend investigation - test.skip("Pause and resume anomaly detection", { - tag: ['@functional', '@anomaly', '@P1', '@all'] - }, async ({ page }) => { - testLogger.info('Testing pause and resume'); - - // Create an anomaly first - const anomalyName = testAnomalyName('PauseResume'); - - await pm.anomalyDetectionPage.createAnomalyDetection({ - name: anomalyName, - streamType: 'logs', - streamName: testStreamName, - detection: { - mode: 'builder', - function: 'count' - }, - schedule: { - resolution: { value: 5, unit: 'm' }, - checkEvery: { value: 10, unit: 'm' }, - lookBack: { value: 30, unit: 'm' } - }, - training: { - window: 1, - retrain: 'Never' - } - }); - - // Wait for anomaly to be ready (may take time for training) - // In real scenario, we'd poll for status or use a fixture with pre-trained anomaly - // Use deterministic wait for the pause button to be ready instead of arbitrary delay - const pauseRow = pm.anomalyDetectionPage.getAnomalyRow(anomalyName); - await expect(pauseRow).toBeVisible({ timeout: 15000 }); - - // Pause - await pm.anomalyDetectionPage.clickPauseAnomaly(anomalyName); - - // Verify status changes to disabled - await pm.anomalyDetectionPage.expectAnomalyStatus(anomalyName, 'disabled'); - - // Resume - await pm.anomalyDetectionPage.clickResumeAnomaly(anomalyName); - - // Verify status changes back - // Note: Status may be "waiting" or "ready" depending on training state - // We just verify it's no longer "disabled" - const row = pm.anomalyDetectionPage.getAnomalyRow(anomalyName); - // OBadge migration: use data-test selector instead of the legacy badge class - await expect(row.locator('[data-test="anomaly-detection-status-badge"]')).not.toContainText('disabled', { ignoreCase: true, timeout: 5000 }); - - testLogger.info('Pause/resume works correctly'); - }); - - test("Trigger anomaly detection via UI", { - tag: ['@functional', '@anomaly', '@P1', '@all', '@cicd'] - }, async ({ page }) => { - testLogger.info('Testing trigger anomaly detection'); - - // This test verifies that anomaly detection can be triggered manually - // Similar to how regular alerts can be triggered in CI/CD pipelines - - // First, we need an existing anomaly in the list - // The P0 tests should have left some anomalies or we create one - const anomalyName = testAnomalyName('Trigger'); - - // Create anomaly first using POM methods - await pm.anomalyDetectionPage.clickAddAnomaly(); - await pm.anomalyDetectionPage.fillBasicSetup(anomalyName, 'logs', testStreamName); - - // Set minimal config - await pm.anomalyDetectionPage.clickTab('Detection Config'); - await pm.anomalyDetectionPage.setDetectionResolution(5, 'm'); - await pm.anomalyDetectionPage.setCheckEvery(10, 'm'); - await pm.anomalyDetectionPage.setLookBackWindow(30, 'm'); - await pm.anomalyDetectionPage.setTrainingWindow(1); - - // Disable alerting to allow save - await pm.anomalyDetectionPage.disableAlerting(); - - // Save - await pm.anomalyDetectionPage.clickSave(); - - // Verify created - await pm.anomalyDetectionPage.expectAnomalyInList(anomalyName); - - // Now trigger detection via the UI menu using POM method - testLogger.info('Triggering detection via UI'); - await pm.anomalyDetectionPage.clickTriggerDetection(anomalyName); - - testLogger.info('Trigger anomaly detection test completed'); - }); - - test("Trigger anomaly detection via API", { - tag: ['@functional', '@anomaly', '@P1', '@all', '@cicd', '@api'] - }, async ({ page }) => { - testLogger.info('Testing trigger anomaly detection via API'); - - // List anomaly detections to find one we can trigger - testLogger.info('Listing anomaly detections via API'); - const anomalies = await listAnomalyDetections(page); - - testLogger.info('Anomaly list response', { count: anomalies.length }); - - if (anomalies.length > 0) { - // Try to trigger the first anomaly that's enabled and trained - const targetAnomaly = anomalies.find(a => a.enabled && (a.is_trained || a.status === 'ready')) || anomalies[0]; - const anomalyId = targetAnomaly.anomaly_id || targetAnomaly.id; - const anomalyName = targetAnomaly.name; - - testLogger.info('Triggering detection for anomaly', { anomalyId, anomalyName, status: targetAnomaly.status }); - - // Trigger detection via API - const triggerResp = await triggerAnomalyDetection(page, anomalyId); - - testLogger.info('Trigger detection response', { status: triggerResp.status, data: triggerResp.data }); - - // 200 = success, 400/404 = anomaly not ready/found - if (triggerResp.status === 200) { - testLogger.info('Anomaly detection triggered successfully via API'); - } else if (triggerResp.status === 400 || triggerResp.status === 404) { - testLogger.info('Anomaly not ready for detection trigger', { reason: triggerResp.data?.message || 'Unknown' }); - } else { - testLogger.warn('Unexpected response from trigger API', { status: triggerResp.status, data: triggerResp.data }); - } - - // Optional: Check detection history to verify trigger was recorded - const historyResp = await getAnomalyHistory(page, anomalyId); - testLogger.info('Detection history', { status: historyResp.status, entries: historyResp.data?.length || 0 }); - - } else { - testLogger.info('No anomaly detections found to trigger - test will be skipped'); - } - - testLogger.info('API trigger test completed'); - }); - }); - - test.describe("Sensitivity and Preview", () => { - - test("Data preview chart populates automatically", { - tag: ['@functional', '@anomaly', '@P1', '@all'] - }, async ({ page }) => { - testLogger.info('Testing data preview'); - - const anomalyName = testAnomalyName('Preview'); - - await pm.anomalyDetectionPage.clickAddAnomaly(); - - // Nothing to query yet, so the Preview card sits in its empty state - await pm.anomalyDetectionPage.expectDataPreviewEmptyState(); - - await pm.anomalyDetectionPage.fillBasicSetup(anomalyName, 'logs', testStreamName); - - await pm.anomalyDetectionPage.clickTab('Detection Config'); - await pm.anomalyDetectionPage.selectQueryMode('builder'); - - // Set config to enable preview - await pm.anomalyDetectionPage.setDetectionResolution(5, 'm'); - await pm.anomalyDetectionPage.setCheckEvery(10, 'm'); - await pm.anomalyDetectionPage.setLookBackWindow(30, 'm'); - await pm.anomalyDetectionPage.setTrainingWindow(1); - - // The chart populates itself once a stream is selected — no Load data button - await pm.anomalyDetectionPage.waitForDataPreview(); - - // Cancel without saving - await pm.anomalyDetectionPage.clickBack(); - - testLogger.info('Preview chart loaded successfully'); - }); - - test("Adjust sensitivity tier and percentile", { - tag: ['@functional', '@anomaly', '@P1', '@all'] - }, async ({ page }) => { - testLogger.info('Testing sensitivity adjustment'); - - const anomalyName = testAnomalyName('Sensitivity'); - - await pm.anomalyDetectionPage.clickAddAnomaly(); - - await pm.anomalyDetectionPage.fillBasicSetup(anomalyName, 'logs', testStreamName); - - await pm.anomalyDetectionPage.clickTab('Detection Config'); - - // Resolution feeds the hint below the control - await pm.anomalyDetectionPage.setDetectionResolution(5, 'm'); - - await pm.anomalyDetectionPage.scrollToSensitivitySection(); - - // Picking a tier sets the percentile it stands for - await pm.anomalyDetectionPage.selectSensitivityTier(95); - await pm.anomalyDetectionPage.expectSensitivityTierActive(95); - - const tierPercentile = await pm.anomalyDetectionPage.getSensitivityPercentile(); - expect(tierPercentile).toBe('95'); - - // Typing a percentile re-highlights the tier that matches it - await pm.anomalyDetectionPage.setSensitivityPercentile(99); - await pm.anomalyDetectionPage.expectSensitivityTierActive(99); - - // Hint restates the percentile as an anomaly rate - const hint = await pm.anomalyDetectionPage.getSensitivityHint(); - testLogger.info('Sensitivity updated', { hint }); - expect(hint).toContain('1%'); - - await pm.anomalyDetectionPage.clickBack(); - - testLogger.info('Sensitivity adjustment works'); - }); - }); - - test.describe("Summary Tab", () => { - - test("View anomaly summary", { - tag: ['@functional', '@anomaly', '@P1', '@all'] - }, async ({ page }) => { - testLogger.info('Testing summary view'); - - const anomalyName = testAnomalyName('Summary'); - - await pm.anomalyDetectionPage.clickAddAnomaly(); - - await pm.anomalyDetectionPage.fillBasicSetup(anomalyName, 'logs', testStreamName); - - // Fill config - await pm.anomalyDetectionPage.clickTab('Detection Config'); - await pm.anomalyDetectionPage.setDetectionResolution(5, 'm'); - await pm.anomalyDetectionPage.setCheckEvery(10, 'm'); - await pm.anomalyDetectionPage.setLookBackWindow(30, 'm'); - await pm.anomalyDetectionPage.setTrainingWindow(7); - - // Go to summary - await pm.anomalyDetectionPage.clickTab('Summary'); - - // Verify summary text appears using POM methods - // expectSummaryVisible() waits for the summary container deterministically - await pm.anomalyDetectionPage.expectSummaryVisible(); - - // Verify summary contains key information - await pm.anomalyDetectionPage.expectSummaryContains(testStreamName); - await pm.anomalyDetectionPage.expectSummaryContains('7'); // training window days - - await pm.anomalyDetectionPage.clickBack(); - - testLogger.info('Summary displayed correctly'); - }); - }); - - test.describe("Tab Switching", () => { - - test.skip("Switch between Builder and SQL tabs", { - tag: ['@functional', '@anomaly', '@P1', '@all'] - }, async ({ page }) => { - testLogger.info('Testing tab switching'); - - const anomalyName = testAnomalyName('TabSwitch'); - - await pm.anomalyDetectionPage.clickAddAnomaly(); - - await pm.anomalyDetectionPage.fillBasicSetup(anomalyName, 'logs', testStreamName); - - await pm.anomalyDetectionPage.clickTab('Detection Config'); - - // Start in Builder mode - await pm.anomalyDetectionPage.selectQueryMode('builder'); - - // Add a filter - await pm.anomalyDetectionPage.addFilter('status', '=', 'error'); - - // Switch to SQL - await pm.anomalyDetectionPage.selectQueryMode('sql'); - - // Verify SQL editor appears and query was generated (should contain the filter) - await pm.anomalyDetectionPage.expectSqlEditorVisible(); - // SQL should contain the WHERE clause for the filter - // (This is implementation-dependent, may need adjustment) - - // Switch back to Builder - await pm.anomalyDetectionPage.selectQueryMode('builder'); - - // Verify filter still there using POM method - await pm.anomalyDetectionPage.expectFilterRowWithField('status'); - - await pm.anomalyDetectionPage.clickBack(); - - testLogger.info('Tab switching preserves data'); - }); - }); - - // ======================================================================== - // P2 TESTS - EDGE CASES - // ======================================================================== - - test.describe("Validation Tests", () => { - - test.skip("Validation: Missing required fields", { - tag: ['@edge-case', '@anomaly', '@P2', '@all'] - }, async ({ page }) => { - testLogger.info('Testing validation errors'); - - await pm.anomalyDetectionPage.clickAddAnomaly(); - - // Try to proceed without filling required fields - const anomalyName = testAnomalyName('Validation'); - - await pm.anomalyDetectionPage.fillBasicSetup(anomalyName, 'logs', testStreamName); - - await pm.anomalyDetectionPage.clickTab('Detection Config'); - - // Clear detection window to trigger validation - await pm.anomalyDetectionPage.clearDetectionWindowValue(); - - // Try to save - should show validation error - await pm.anomalyDetectionPage.clickSaveForValidation(); - - // Verify error appears - await pm.anomalyDetectionPage.expectValidationError(pm.anomalyDetectionPage.selectors.detectionWindowError); - - // Fix the error - await pm.anomalyDetectionPage.setDetectionResolution(5, 'm'); - await pm.anomalyDetectionPage.setCheckEvery(10, 'm'); - await pm.anomalyDetectionPage.setLookBackWindow(30, 'm'); - await pm.anomalyDetectionPage.setTrainingWindow(1); - - // Disable alerting (required - alerting is enabled by default with no destination) - await pm.anomalyDetectionPage.disableAlerting(); - - // Now save should work - await pm.anomalyDetectionPage.clickSave(); - - await pm.anomalyDetectionPage.expectAnomalyInList(anomalyName); - - testLogger.info('Validation works correctly'); - }); - - test("Validation: Destination required when alerting enabled", { - tag: ['@edge-case', '@anomaly', '@P2', '@all'] - }, async ({ page }) => { - testLogger.info('Testing destination validation'); - - const anomalyName = testAnomalyName('DestValidation'); - - await pm.anomalyDetectionPage.clickAddAnomaly(); - - await pm.anomalyDetectionPage.fillBasicSetup(anomalyName, 'logs', testStreamName); - - await pm.anomalyDetectionPage.clickTab('Detection Config'); - await pm.anomalyDetectionPage.setDetectionResolution(5, 'm'); - await pm.anomalyDetectionPage.setCheckEvery(10, 'm'); - await pm.anomalyDetectionPage.setLookBackWindow(30, 'm'); - await pm.anomalyDetectionPage.setTrainingWindow(1); - - // Enable alerting without selecting destinations - await pm.anomalyDetectionPage.clickTab('Alerting'); - await pm.anomalyDetectionPage.toggleNotifications(true); - - // Don't select any destinations - - // Try to save - await pm.anomalyDetectionPage.clickSaveForValidation(); - - // Verify error message - await pm.anomalyDetectionPage.expectValidationError(pm.anomalyDetectionPage.selectors.destinationError); - - // Verify error text - await pm.anomalyDetectionPage.expectDestinationErrorContains('At least one destination is required'); - - // Cancel - await pm.anomalyDetectionPage.clickBack(); - - testLogger.info('Destination validation works'); - }); - - test("Validation: SQL mode timestamp alias error", { - tag: ['@edge-case', '@anomaly', '@P2', '@all'] - }, async ({ page }) => { - testLogger.info('Testing timestamp alias validation'); - - const anomalyName = testAnomalyName('TimestampError'); - - await pm.anomalyDetectionPage.clickAddAnomaly(); - - await pm.anomalyDetectionPage.fillBasicSetup(anomalyName, 'logs', testStreamName); - - await pm.anomalyDetectionPage.clickTab('Detection Config'); - await pm.anomalyDetectionPage.selectQueryMode('sql'); - - // Set invalid SQL with _timestamp as alias - const invalidSql = `SELECT _timestamp AS _timestamp, count(*) AS value FROM "${testStreamName}" GROUP BY _timestamp ORDER BY _timestamp`; - await pm.anomalyDetectionPage.setSqlQuery(invalidSql); - - // Set other required fields - await pm.anomalyDetectionPage.setDetectionResolution(5, 'm'); - await pm.anomalyDetectionPage.setCheckEvery(10, 'm'); - await pm.anomalyDetectionPage.setLookBackWindow(30, 'm'); - await pm.anomalyDetectionPage.setTrainingWindow(1); - - // Verify error appears - await pm.anomalyDetectionPage.expectValidationError(pm.anomalyDetectionPage.selectors.customSqlTimestampError); - - await pm.anomalyDetectionPage.expectTimestampErrorContains('_timestamp cannot be used as a column alias'); - - // Save should be blocked - await pm.anomalyDetectionPage.expectSaveButtonDisabled(); - - await pm.anomalyDetectionPage.clickBack(); - - testLogger.info('Timestamp alias validation works'); - }); }); test.afterEach(async ({}, testInfo) => { testLogger.testEnd(testInfo.title, testInfo.status); }); - // Cleanup: Delete all test anomalies - test.afterAll(async ({ browser }) => { - testLogger.info('Running test cleanup - deleting test anomalies'); + // ════════════════════════════════════════════════════════════════════════ + // Form validation — the wizard never disables Save; it validates on click + // ════════════════════════════════════════════════════════════════════════ - // Verify required env vars are set (auth is handled by api-helper.js) - if (!process.env.ZO_BASE_URL || !process.env.ZO_ROOT_USER_EMAIL || !process.env.ZO_ROOT_USER_PASSWORD || !process.env.ORGNAME) { + test.describe('Form validation', () => { + test('blank name blocks save and paints the name field', { + tag: ['@anomaly', '@P0', '@smoke', '@all'], + }, async () => { + await pm.anomalyDetectionPage.openAddAnomalyWizard(); + + // OForm keeps submit enabled and validates on click, so the offending + // field gets painted rather than the user guessing why a grey button + // does nothing. + await expect(pm.anomalyDetectionPage.getSaveBtnLocator()).toBeEnabled(); + await pm.anomalyDetectionPage.save(); + + await expect(pm.anomalyDetectionPage.getNameErrorLocator()).toHaveText('Anomaly name is required.'); + await expect( + pm.anomalyDetectionPage.getToastLocator(/fix the highlighted fields/i), + ).toBeVisible(); + + await pm.anomalyDetectionPage.cancel(); + }); + + test('stream type select is present when the wizard opens', { + tag: ['@anomaly', '@P0', '@smoke', '@all'], + }, async ({ page }) => { + await pm.anomalyDetectionPage.openAddAnomalyWizard(); + await expect(pm.anomalyDetectionPage.getStreamTypeSelectLocator()).toBeVisible(); + await pm.anomalyDetectionPage.cancel(); + }); + + test('cancel closes the wizard and returns to the list', { + tag: ['@anomaly', '@P2', '@functional', '@all'], + }, async ({ page }) => { + await pm.anomalyDetectionPage.openAddAnomalyWizard(); + await pm.anomalyDetectionPage.cancel(); + await expect(pm.anomalyDetectionPage.getListTableLocator()).toBeVisible(); + }); + + test('destination is required once notifications are enabled', { + tag: ['@anomaly', '@P1', '@functional', '@all'], + }, async () => { + await pm.anomalyDetectionPage.openAddAnomalyWizard(); + await pm.anomalyDetectionPage.fillBasicSetup(anomalyName('destreq'), 'logs', testStreamName); + await pm.anomalyDetectionPage.openAlertingTab(); + await pm.anomalyDetectionPage.toggleNotifications(true); + await pm.anomalyDetectionPage.save(); + + await expect(pm.anomalyDetectionPage.getDestinationErrorLocator()).toBeVisible(); + await pm.anomalyDetectionPage.cancel(); + }); + + test('SQL mode rejects aliasing the timestamp column', { + tag: ['@anomaly', '@P1', '@functional', '@all'], + }, async ({ page }) => { + await pm.anomalyDetectionPage.openAddAnomalyWizard(); + await pm.anomalyDetectionPage.fillBasicSetup(anomalyName('alias'), 'logs', testStreamName); + await pm.anomalyDetectionPage.openConfigTab(); + await pm.anomalyDetectionPage.selectQueryMode('custom_sql'); + await pm.anomalyDetectionPage.setCustomSql( + `SELECT histogram(_timestamp) AS _timestamp, count(*) AS value FROM "${testStreamName}"`, + ); + await pm.anomalyDetectionPage.save(); + + // Bare-Monaco errors only render after the first submit attempt. + await expect( + pm.anomalyDetectionPage.getCustomSqlTimestampErrorLocator(), + ).toBeVisible(); + await pm.anomalyDetectionPage.cancel(); + }); + + test('empty SQL in custom_sql mode blocks save', { + tag: ['@anomaly', '@P1', '@functional', '@all'], + }, async ({ page }) => { + await pm.anomalyDetectionPage.openAddAnomalyWizard(); + await pm.anomalyDetectionPage.fillBasicSetup(anomalyName('emptysql'), 'logs', testStreamName); + await pm.anomalyDetectionPage.openConfigTab(); + await pm.anomalyDetectionPage.selectQueryMode('custom_sql'); + // The mode switch seeds a default query, so the editor has to be emptied + // to reach the required-SQL state at all. + await pm.anomalyDetectionPage.clearCustomSql(); + await pm.anomalyDetectionPage.save(); + + await expect( + pm.anomalyDetectionPage.getCustomSqlRequiredErrorLocator(), + ).toBeVisible(); + await pm.anomalyDetectionPage.cancel(); + }); + }); + + // ════════════════════════════════════════════════════════════════════════ + // Sensitivity — tier toggle and percentile input share the `threshold` field + // ════════════════════════════════════════════════════════════════════════ + + test.describe('Sensitivity', () => { + test.beforeEach(async () => { + await pm.anomalyDetectionPage.openAddAnomalyWizard(); + await pm.anomalyDetectionPage.fillBasicSetup(anomalyName('sens'), 'logs', testStreamName); + await pm.anomalyDetectionPage.openConfigTab(); + }); + + test.afterEach(async () => { + await pm.anomalyDetectionPage.cancel(); + }); + + test('each tier writes its percentile into the shared threshold field', { + tag: ['@anomaly', '@P0', '@smoke', '@all'], + }, async () => { + for (const [tier, percentile] of [[99, '99'], [97, '97'], [95, '95']]) { + await pm.anomalyDetectionPage.selectSensitivityTier(tier); + expect(await pm.anomalyDetectionPage.getSensitivityPercentile()).toBe(percentile); + expect(await pm.anomalyDetectionPage.getActiveSensitivityTier()).toBe(tier); + } + }); + + test('typing a tier percentile lights that tier up', { + tag: ['@anomaly', '@P1', '@functional', '@all'], + }, async () => { + await pm.anomalyDetectionPage.setSensitivityPercentile(95); + expect(await pm.anomalyDetectionPage.getActiveSensitivityTier()).toBe(95); + }); + + test('an off-tier percentile is accepted with no tier selected', { + tag: ['@anomaly', '@P1', '@functional', '@all'], + }, async () => { + await pm.anomalyDetectionPage.setSensitivityPercentile(88); + await expect(pm.anomalyDetectionPage.getSensitivityErrorLocator()).toBeHidden(); + expect(await pm.anomalyDetectionPage.getActiveSensitivityTier()).toBeNull(); + }); + + test('the hint restates the flag rate at the current resolution', { + tag: ['@anomaly', '@P1', '@functional', '@all'], + }, async () => { + await pm.anomalyDetectionPage.setHistogramInterval(5, 'm'); + await pm.anomalyDetectionPage.selectSensitivityTier(97); + + // 97 flags the most unusual 3% of buckets; at 5m resolution that is + // 288 buckets/day * 0.03 ≈ 9 per day. + const hint = pm.anomalyDetectionPage.getSensitivityHintLocator(); + await expect(hint).toBeVisible(); + await expect(hint).toContainText('3%'); + await expect(hint).toContainText('5m'); + }); + + test('the hint recomputes when the resolution changes', { + tag: ['@anomaly', '@P1', '@functional', '@all'], + }, async () => { + await pm.anomalyDetectionPage.selectSensitivityTier(97); + await pm.anomalyDetectionPage.setHistogramInterval(5, 'm'); + const atFiveMinutes = await pm.anomalyDetectionPage.getSensitivityHintLocator().textContent(); + + await pm.anomalyDetectionPage.setHistogramInterval(1, 'h'); + await expect(pm.anomalyDetectionPage.getSensitivityHintLocator()).not.toHaveText(atFiveMinutes); + await expect(pm.anomalyDetectionPage.getSensitivityHintLocator()).toContainText('1h'); + }); + + for (const [label, value] of [['below the floor', 49], ['above the ceiling', 100], ['fractional', 97.5]]) { + test(`rejects a ${label} percentile`, { + tag: ['@anomaly', '@P1', '@functional', '@all'], + }, async () => { + await pm.anomalyDetectionPage.setSensitivityPercentile(value); + await pm.anomalyDetectionPage.save(); + await expect(pm.anomalyDetectionPage.getSensitivityErrorLocator()).toHaveText( + 'Enter a whole number between 50 and 99', + ); + }); + } + + test('the hint is suppressed while the percentile is invalid', { + tag: ['@anomaly', '@P2', '@functional', '@all'], + }, async () => { + await pm.anomalyDetectionPage.setSensitivityPercentile(49); + await expect(pm.anomalyDetectionPage.getSensitivityHintLocator()).toBeHidden(); + }); + }); + + // ════════════════════════════════════════════════════════════════════════ + // Builder mode — filters and the read-only SQL preview + // ════════════════════════════════════════════════════════════════════════ + + test.describe('Builder mode', () => { + test.beforeEach(async () => { + await pm.anomalyDetectionPage.openAddAnomalyWizard(); + await pm.anomalyDetectionPage.fillBasicSetup(anomalyName('builderui'), 'logs', testStreamName); + await pm.anomalyDetectionPage.openConfigTab(); + }); + + test.afterEach(async () => { + await pm.anomalyDetectionPage.cancel(); + }); + + test('the detection-function field appears only for non-count functions', { + tag: ['@anomaly', '@P1', '@functional', '@all'], + }, async ({ page }) => { + const fieldSelect = pm.anomalyDetectionPage.getDetectionFunctionFieldLocator(); + await expect(fieldSelect).toBeHidden(); + + await pm.anomalyDetectionPage.selectDetectionFunction('avg'); + await expect(fieldSelect).toBeVisible(); + + await pm.anomalyDetectionPage.selectDetectionFunction('count'); + await expect(fieldSelect).toBeHidden(); + }); + + test('filters can be added and removed', { + tag: ['@anomaly', '@P1', '@functional', '@all'], + }, async () => { + const rows = pm.anomalyDetectionPage.getFilterRows(); + const initial = await rows.count(); + + await pm.anomalyDetectionPage.addFilter('kubernetes_namespace_name', '=', 'ziox'); + await expect(rows).toHaveCount(initial + 1); + + await pm.anomalyDetectionPage.removeFilter(initial); + await expect(rows).toHaveCount(initial); + }); + + test('the SQL preview reflects the builder configuration', { + tag: ['@anomaly', '@P0', '@smoke', '@all'], + }, async () => { + await pm.anomalyDetectionPage.selectDetectionFunction('count'); + await pm.anomalyDetectionPage.setHistogramInterval(5, 'm'); + + const sql = await pm.anomalyDetectionPage.getSqlPreviewText(); + // The anomaly query must alias its time column as time_bucket and its + // measure as value — that contract is what the preview proves. + expect(sql).toContain('time_bucket'); + expect(sql).toContain(testStreamName); + }); + + test('the SQL preview is hidden in custom_sql mode', { + tag: ['@anomaly', '@P2', '@functional', '@all'], + }, async ({ page }) => { + await expect(pm.anomalyDetectionPage.getSqlPreviewLocator()).toBeVisible(); + await pm.anomalyDetectionPage.selectQueryMode('custom_sql'); + await expect(pm.anomalyDetectionPage.getSqlPreviewLocator()).toBeHidden(); + }); + }); + + // ════════════════════════════════════════════════════════════════════════ + // Data preview — auto-loads, no "Load preview" button any more + // ════════════════════════════════════════════════════════════════════════ + + test.describe('Data preview', () => { + test('empty until a stream is chosen, then populates on its own', { + tag: ['@anomaly', '@P0', '@smoke', '@all'], + }, async () => { + await pm.anomalyDetectionPage.openAddAnomalyWizard(); + await expect(pm.anomalyDetectionPage.getDataPreviewEmptyLocator()).toBeVisible(); + + await pm.anomalyDetectionPage.fillBasicSetup(anomalyName('preview'), 'logs', testStreamName); + + // Edits are debounced 600ms before the query fires; nothing is clicked. + await pm.anomalyDetectionPage.waitForDataPreview(); + await expect(pm.anomalyDetectionPage.getDataPreviewEmptyLocator()).toBeHidden(); + + await pm.anomalyDetectionPage.cancel(); + }); + + test('a mid-edit blank interval keeps the last chart instead of blanking', { + tag: ['@anomaly', '@P2', '@functional', '@all'], + }, async () => { + await pm.anomalyDetectionPage.openAddAnomalyWizard(); + await pm.anomalyDetectionPage.fillBasicSetup(anomalyName('previewedit'), 'logs', testStreamName); + await pm.anomalyDetectionPage.waitForDataPreview(); + + await pm.anomalyDetectionPage.openConfigTab(); + await pm.anomalyDetectionPage.clearHistogramInterval(); + + // canPreview goes false, which blocks the query but must not tear the + // chart down — the empty state means "nothing to preview", not "invalid". + await expect(pm.anomalyDetectionPage.getDataPreviewChartLocator()).toBeVisible(); + + await pm.anomalyDetectionPage.cancel(); + }); + }); + + // ════════════════════════════════════════════════════════════════════════ + // Alerting step — priority, tags, destinations + // ════════════════════════════════════════════════════════════════════════ + + test.describe('Alerting configuration', () => { + test.beforeEach(async () => { + await pm.anomalyDetectionPage.openAddAnomalyWizard(); + await pm.anomalyDetectionPage.fillBasicSetup(anomalyName('alerting'), 'logs', testStreamName); + await pm.anomalyDetectionPage.openAlertingTab(); + }); + + test.afterEach(async () => { + await pm.anomalyDetectionPage.cancel(); + }); + + test('priority and tags are settable', { + tag: ['@anomaly', '@P1', '@functional', '@all'], + }, async ({ page }) => { + await pm.anomalyDetectionPage.selectPriority(2); + // Assert the rendered label, not data-test-selected-value: OSelect emits + // that attribute only from its searchable trigger, and priority is a + // non-searchable select rendered by the reka SelectTrigger branch. + await expect( + pm.anomalyDetectionPage.getPriorityTriggerLocator(), + ).toContainText('P2'); + + await pm.anomalyDetectionPage.addTags(['team-platform']); + await expect(pm.anomalyDetectionPage.getTagsInputLocator()).toContainText('team-platform'); + }); + + test('the destination picker appears only when notifications are on', { + tag: ['@anomaly', '@P1', '@functional', '@all'], + }, async ({ page }) => { + const destination = pm.anomalyDetectionPage.getDestinationLocator(); + await pm.anomalyDetectionPage.toggleNotifications(false); + await expect(destination).toBeHidden(); + + await pm.anomalyDetectionPage.toggleNotifications(true); + await expect(destination).toBeVisible(); + }); + + test('the destination list can be refreshed', { + tag: ['@anomaly', '@P2', '@functional', '@all'], + }, async ({ page }) => { + await pm.anomalyDetectionPage.toggleNotifications(true); + await pm.anomalyDetectionPage.refreshDestinations(); + await expect(pm.anomalyDetectionPage.getDestinationLocator()).toBeVisible(); + }); + }); + + // ════════════════════════════════════════════════════════════════════════ + // Lifecycle and detection charts + // + // Serial: these share one anomaly, created by the first test and deleted by + // the last, and the charts read that same record. They sit under one parent + // so the ordering is declared rather than depending across describes — and so + // a failure here cannot abort the independent describes above. + // ════════════════════════════════════════════════════════════════════════ + + test.describe('Lifecycle and detection charts', () => { + // Parallel, not serial. Only the two "creates ..." tests exercise the + // wizard's creation path; the rest need a config to ACT on, so each makes + // its own through the API (~1s versus ~15s of wizard) and owns it outright. + // Sharing one record forced serial ordering, which made a single flake + // retry the whole block — 11 tests retried three times on one CI run. + test.describe.configure({ mode: 'parallel' }); + + /** + * A config for this test alone, so nothing it does can disturb another. + * + * Reloads after creating: the list is fetched when the tab mounts, and + * beforeEach has already mounted it, so a record created through the API + * afterwards is simply absent until the page refetches. + */ + const ownAnomaly = async (page, suffix, opts) => { + const name = anomalyName(suffix); + await createAnomalyViaApi(page, name, opts); + // Settle the backend before touching the UI: creation returns before the + // record is queryable, so a reload can still render a list without it. + await waitForAnomalyListed(page, name); + await page.reload(); + await page.waitForLoadState('domcontentloaded', { timeout: 30000 }).catch(() => {}); + await pm.anomalyDetectionPage.navigateToAnomalyTab(); + await pm.anomalyDetectionPage.searchAnomaly(name); + await expect( + pm.anomalyDetectionPage.getRow(name), + `${name} was created via the API but never appeared in the list`, + ).toBeVisible({ timeout: 20000 }); + return name; + }; + + test.describe('Lifecycle', () => { + test('creates an anomaly in builder mode', { + tag: ['@anomaly', '@P0', '@smoke', '@all'], + }, async () => { + const name = anomalyName('builder'); + await pm.anomalyDetectionPage.openAddAnomalyWizard(); + await pm.anomalyDetectionPage.fillBasicSetup(name, 'logs', testStreamName); + + await pm.anomalyDetectionPage.openConfigTab(); + await pm.anomalyDetectionPage.selectDetectionFunction('count'); + await pm.anomalyDetectionPage.setHistogramInterval(5, 'm'); + await pm.anomalyDetectionPage.setScheduleInterval(10, 'm'); + await pm.anomalyDetectionPage.setDetectionWindow(1, 'h'); + await pm.anomalyDetectionPage.setTrainingWindow(7); + await pm.anomalyDetectionPage.selectRetrainInterval(7); + await pm.anomalyDetectionPage.selectSensitivityTier(97); + + await pm.anomalyDetectionPage.openAlertingTab(); + await pm.anomalyDetectionPage.toggleNotifications(true); + await pm.anomalyDetectionPage.selectDestinations([prerequisiteDestinationName]); + + await pm.anomalyDetectionPage.saveAndExpectSuccess(); + + await pm.anomalyDetectionPage.navigateToAnomalyTab(); + await pm.anomalyDetectionPage.searchAnomaly(name); + await expect(pm.anomalyDetectionPage.getRow(name)).toBeVisible(); + }); + + test('creates an anomaly in SQL mode', { + tag: ['@anomaly', '@P1', '@functional', '@all'], + }, async () => { + const name = anomalyName('sql'); + await pm.anomalyDetectionPage.openAddAnomalyWizard(); + await pm.anomalyDetectionPage.fillBasicSetup(name, 'logs', testStreamName); + + await pm.anomalyDetectionPage.openConfigTab(); + await pm.anomalyDetectionPage.selectQueryMode('custom_sql'); + await pm.anomalyDetectionPage.setCustomSql( + `SELECT histogram(_timestamp, '5 minute') AS time_bucket, count(*) AS value FROM "${testStreamName}" GROUP BY time_bucket`, + ); + await pm.anomalyDetectionPage.setHistogramInterval(5, 'm'); + + await pm.anomalyDetectionPage.openAlertingTab(); + await pm.anomalyDetectionPage.toggleNotifications(false); + + await pm.anomalyDetectionPage.saveAndExpectSuccess(); + + await pm.anomalyDetectionPage.navigateToAnomalyTab(); + await pm.anomalyDetectionPage.searchAnomaly(name); + await expect(pm.anomalyDetectionPage.getRow(name)).toBeVisible(); + }); + + // The only non-creation test that still builds its fixture through the + // wizard. Its whole point is that a config SAVED BY THE FORM round-trips + // back into the form; an API-created record would only prove "API write → + // UI read", which is not the regression worth guarding. + test('edit mode loads the saved configuration', { + tag: ['@anomaly', '@P1', '@functional', '@all'], + }, async ({ page }) => { + const name = anomalyName('edit'); + await pm.anomalyDetectionPage.openAddAnomalyWizard(); + await pm.anomalyDetectionPage.fillBasicSetup(name, 'logs', testStreamName); + await pm.anomalyDetectionPage.openConfigTab(); + await pm.anomalyDetectionPage.selectSensitivityTier(97); + await pm.anomalyDetectionPage.openAlertingTab(); + await pm.anomalyDetectionPage.toggleNotifications(false); + await pm.anomalyDetectionPage.saveAndExpectSuccess(); + + await pm.anomalyDetectionPage.navigateToAnomalyTab(); + await pm.anomalyDetectionPage.searchAnomaly(name); + await pm.anomalyDetectionPage.openEdit(name); + + // The name is readonly in edit mode, so it renders as plain text. + await expect(pm.anomalyDetectionPage.getNameValueLocator()).toContainText(name); + + await pm.anomalyDetectionPage.openConfigTab(); + // The percentile readback only exists where the tier controls do; on + // older builds the edit-load path is still covered by the name above. + if (await pm.anomalyDetectionPage.hasSensitivityTiers()) { + expect(await pm.anomalyDetectionPage.getSensitivityPercentile()).toBe('97'); + } + + await pm.anomalyDetectionPage.cancel(); + }); + + test('pause and resume toggle the anomaly', { + tag: ['@anomaly', '@P1', '@functional', '@all'], + }, async ({ page }) => { + const name = await ownAnomaly(page, 'pause'); + await pm.anomalyDetectionPage.togglePause(name); + await expect(pm.anomalyDetectionPage.getToastLocator(/success/i)).toBeVisible(); + + await pm.anomalyDetectionPage.togglePause(name); + await expect(pm.anomalyDetectionPage.getRow(name)).toBeVisible(); + }); + + test('detection can be triggered from the row menu', { + tag: ['@anomaly', '@P2', '@functional', '@all'], + }, async ({ page }) => { + const name = await ownAnomaly(page, 'trigger'); + await pm.anomalyDetectionPage.triggerDetection(name); + await expect(pm.anomalyDetectionPage.getToastLocator(/detection|triggered/i)).toBeVisible(); + }); + + test('detection can be triggered via the API and lands in history', { + tag: ['@anomaly', '@P2', '@api', '@all'], + }, async ({ page }) => { + const name = anomalyName('apitrigger'); + const id = await createAnomalyViaApi(page, name); + + const triggered = await triggerAnomalyDetection(page, id); + + // A config created seconds ago has no trained model yet, so a detection + // run is expected to be refused. What matters is that the refusal says + // why — the handler funnels every non-"not found" error into a 500, so + // an empty body here would leave the caller with nothing to act on. + if (![200, 202].includes(triggered.status)) { + const body = JSON.stringify(triggered.data ?? triggered.body ?? {}); + expect( + body.length > 2, + `detect returned ${triggered.status} with no explanation: ${body}`, + ).toBe(true); + testLogger.info('Detection refused on an untrained config', { + status: triggered.status, + body, + }); + } + + // getAnomalyHistory returns the raw {status, data} envelope and the + // endpoint serialises a bare array, verified against a live response. + // (The DetectionHistoryResponse {history: [...]} utoipa annotation on + // the handler does NOT match what it actually returns.) + const history = await getAnomalyHistory(page, id); + expect(history.status).toBe(200); + expect(Array.isArray(history.data)).toBe(true); + }); + + test('deletes an anomaly', { + tag: ['@anomaly', '@P0', '@smoke', '@all'], + }, async ({ page }) => { + const name = await ownAnomaly(page, 'delete'); + await pm.anomalyDetectionPage.deleteAnomaly(name); + await expect(pm.anomalyDetectionPage.getRow(name)).toBeHidden(); + }); + }); + + // ══════════════════════════════════════════════════════════════════════ + // Detection charts — three readings of the `_anomalies` stream + // ══════════════════════════════════════════════════════════════════════ + + test.describe('Detection charts', () => { + test('the detail page renders all three panels behind one range picker', { + tag: ['@anomaly', '@P1', '@functional', '@all'], + }, async ({ page }) => { + const name = await ownAnomaly(page, 'charts'); + await pm.anomalyDetectionPage.openDetail(name); + + await expect(pm.anomalyDetectionPage.getDetectionChartsLocator()).toBeVisible(); + for (const key of ['metric', 'score', 'deviation']) { + await expect(pm.anomalyDetectionPage.getChartPanelLocator(key)).toBeVisible(); + } + }); + + test('the shared range picker drives every panel', { + tag: ['@anomaly', '@P2', '@functional', '@all'], + }, async ({ page }) => { + const name = await ownAnomaly(page, 'range'); + await pm.anomalyDetectionPage.openDetail(name); + await expect(pm.anomalyDetectionPage.getDetectionChartsLocator()).toBeVisible(); + + // One picker for all three: separate pickers would let the panels + // silently disagree about which window they are showing. + for (const range of ['1h', '6h', '24h']) { + await pm.anomalyDetectionPage.selectChartRange(range); + await expect( + pm.anomalyDetectionPage.getChartRangeItemLocator(range), + ).toHaveAttribute('data-state', 'on'); + } + }); + + test('a panel with no model data shows its unavailable state, not a broken chart', { + tag: ['@anomaly', '@P2', '@functional', '@all'], + }, async ({ page }) => { + const name = await ownAnomaly(page, 'emptychart'); + await pm.anomalyDetectionPage.openDetail(name); + + // A freshly created anomaly has not trained, so each panel is either a + // rendered chart or the explicit unavailable state — never neither. + // Poll rather than count once: the panels mount asynchronously, and a + // bare count() races the render instead of waiting for it. + for (const key of ['metric', 'score', 'deviation']) { + const body = pm.anomalyDetectionPage.getChartBodyLocator(key); + const empty = pm.anomalyDetectionPage.getChartEmptyLocator(key); + await expect + .poll(async () => (await body.count()) + (await empty.count()), { + timeout: 30000, + message: `panel ${key} rendered neither a chart nor its empty state`, + }) + .toBeGreaterThan(0); + } + }); + }); + }); + + // ════════════════════════════════════════════════════════════════════════ + // End to end: a seeded spike is trained on, detected, alerted and charted. + // + // The only test that exercises the feature's actual purpose. Everything else + // stops at "the config saved"; this one proves the model fires. Training + // needs history, so the stream is seeded with backdated buckets — the shared + // e2e_automate stream is all stamped "now" and cannot train a model. + // ════════════════════════════════════════════════════════════════════════ + + test.describe('End to end detection', () => { + // A receiver the test owns, so delivery is proved from the wire rather than + // inferred. Mirrors alerts-content-templates.spec.js. Needs the backend to + // reach 127.0.0.1 of this runner AND loopback allowlisted + // (ZO_SSRF_ALLOW_LOOPBACK) — true in CI, false against a remote env, where + // the destination create is refused and the delivery check stands down. + let receiver; + let receiverPort; + let received; + + test.beforeAll(async () => { + received = []; + receiver = http.createServer((req, res) => { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + received.push({ url: req.url, body }); + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('ok'); + }); + }); + await new Promise((resolve) => receiver.listen(0, '127.0.0.1', resolve)); + receiverPort = receiver.address().port; + testLogger.info('Started in-test HTTP receiver', { receiverPort }); + }); + + test.afterAll(async () => { + if (receiver) await new Promise((resolve) => receiver.close(resolve)); + }); + + + const firingName = anomalyName('fire'); + const seededStream = `anomaly_e2e_${randomValue}`; + + test('a seeded spike is detected, alerted on, and charted', { + tag: ['@anomaly', '@P0', '@smoke', '@e2e', '@all'], + }, async ({ page }) => { + test.slow(); + + // 4 hours of 1-minute buckets at a flat baseline, with a 120-count spike + // held back from the newest buckets so it stays inside the 1h detection + // window. Four hours because CI drops anything backdated further + // (ZO_INGEST_ALLOWED_UPTO), 1-minute because the model needs 100+ points. + const seed = await seedAnomalyStream(page, seededStream, { + hours: 4, + bucketSeconds: 60, + baseline: 10, + spikeValue: 120, + spikeBuckets: 4, + }); + expect(seed.status, `seeding ${seededStream} failed: ${JSON.stringify(seed.data)}`).toBe(200); + await waitForStream(page, seededStream); + + // Aim a destination at the in-test receiver BEFORE the wizard mounts: it + // fetches destinations once, on mount, so one created later is missing + // from the picker and refreshing it is a race. The SSRF guard refuses a + // private ip unless loopback is allowlisted, so where it is not, fall + // back to the shared destination and leave delivery unasserted. + received.length = 0; + const sinkName = `e2e_anomaly_sink_${randomValue}`; + const sink = await createMockDestination(page, sinkName, prerequisiteTemplateName, { + url: `http://127.0.0.1:${receiverPort}/anomaly`, + }); + const deliveryAssertable = sink.status === 200; + if (!deliveryAssertable) { + testLogger.warn( + 'DELIVERY NOT ASSERTED — the loopback destination was refused, so the only ' + + 'proof an alert left the process is skipped for this run', + { status: sink.status, body: JSON.stringify(sink.data) }, + ); + } + // The app caches its stream list from page load, which happened before + // the seed — without a reload the new stream is absent from the picker + // even though the API already lists it. + await page.reload(); + await page.waitForLoadState('domcontentloaded', { timeout: 30000 }).catch(() => {}); + + await pm.anomalyDetectionPage.navigateToAnomalyTab(); + await pm.anomalyDetectionPage.openAddAnomalyWizard(); + await pm.anomalyDetectionPage.fillBasicSetup(firingName, 'logs', seededStream); + + await pm.anomalyDetectionPage.openConfigTab(); + await pm.anomalyDetectionPage.selectDetectionFunction('count'); + // Resolution matches the seeded bucket width — at 5m the 1m buckets + // collapse and the model sees a fifth of the points. + await pm.anomalyDetectionPage.setHistogramInterval(1, 'm'); + await pm.anomalyDetectionPage.setScheduleInterval(10, 'm'); + await pm.anomalyDetectionPage.setDetectionWindow(1, 'h'); + await pm.anomalyDetectionPage.setTrainingWindow(1); + await pm.anomalyDetectionPage.selectSensitivityTier(95); + + await pm.anomalyDetectionPage.openAlertingTab(); + await pm.anomalyDetectionPage.toggleNotifications(true); + await pm.anomalyDetectionPage.selectDestinations([ + deliveryAssertable ? sinkName : prerequisiteDestinationName, + ]); + await pm.anomalyDetectionPage.saveAndExpectSuccess(); + + const configs = await listAnomalyDetections(page); + const created = configs.find((c) => c.name === firingName); + expect(created, `${firingName} should be listed by the API`).toBeTruthy(); + const id = created.anomaly_id || created.id; + + // The notification is sent inline by the detection run, gated on + // `anomaly_count > 0 && alert_enabled`. If the wizard failed to persist + // either of these, detection still "succeeds" and no alert is ever sent — + // a silent hole this suite would otherwise not notice. + expect(created.alert_enabled, 'alert_enabled must persist from the wizard').toBe(true); + expect(created.alert_destinations).toContain( + deliveryAssertable ? sinkName : prerequisiteDestinationName, + ); + + // Training and detection have no UI trigger that reports completion, so + // they are driven through the API; the assertions below are on the result. + const trainStarted = await triggerAnomalyTraining(page, id); + expect([200, 202]).toContain(trainStarted.status); + const trained = await waitForAnomalyTrained(page, id); + expect(trained.is_trained).toBe(true); + + const detected = await triggerAnomalyDetection(page, id); + expect( + detected.status, + `detection failed: ${JSON.stringify(detected.data)}`, + ).toBe(200); + + // The spike must be found, and found as the worst point — a run that + // flags only baseline noise would otherwise look like a pass. + expect(detected.data.anomalies_found).toBeGreaterThan(0); + const worst = Math.max(...detected.data.anomalies.map((a) => a.actual_value)); + expect(worst).toBe(120); + const spike = detected.data.anomalies.find((a) => a.actual_value === 120); + expect(spike.is_anomaly).toBe(true); + expect(spike.score).toBeGreaterThan(spike.threshold_value); + + // Each detection run writes one row per scored bucket to _anomalies, + // which is the only source the detail-page charts read. + await pm.anomalyDetectionPage.navigateToAnomalyTab(); + await pm.anomalyDetectionPage.searchAnomaly(firingName); + await pm.anomalyDetectionPage.openDetail(firingName); + await expect(pm.anomalyDetectionPage.getDetectionChartsLocator()).toBeVisible(); + for (const key of ['metric', 'score', 'deviation']) { + await expect(pm.anomalyDetectionPage.getChartPanelLocator(key)).toBeVisible(); + } + + // The flagged overlay must sit exactly ON the metric it marks. The + // renderer colours a series, not a segment, so the flagged buckets are a + // second null-gapped line — and taking max(actual_value) over the flagged + // ROWS instead of the bucket reads a different row wherever a bucket holds + // both a flagged and an unflagged point, drawing the overlay BELOW the + // metric. The SQL shape is unit-tested; this checks the values it returns. + const chartRows = await searchSql( + page, + `SELECT histogram(_timestamp, '5m') AS zo_sql_key, ` + + `max(actual_value) AS zo_sql_num, ` + + `CASE WHEN max(CASE WHEN is_anomaly THEN 1 ELSE 0 END) = 1 ` + + `THEN max(actual_value) END AS anomaly_value ` + + `FROM "_anomalies" WHERE anomaly_id = '${id}' ` + + `GROUP BY zo_sql_key ORDER BY zo_sql_key`, + ); + const flagged = chartRows.filter((r) => r.anomaly_value !== null && r.anomaly_value !== undefined); + expect(flagged.length, 'the charted series must flag at least one bucket').toBeGreaterThan(0); + for (const row of flagged) { + expect( + row.anomaly_value, + `overlay ${row.anomaly_value} must equal the metric ${row.zo_sql_num} at ${row.zo_sql_key}`, + ).toBe(row.zo_sql_num); + } + expect(Math.max(...flagged.map((r) => r.anomaly_value))).toBe(120); + + // Delivery. The notification is sent inline by the detection run, so a + // regression that stops it would pass every assertion above. Asserting it + // from the wire is the only way to know it actually left the process. + if (deliveryAssertable) { + await expect + .poll(() => received.length, { + timeout: 60000, + message: `no webhook delivered to the in-test receiver for ${firingName}`, + }) + .toBeGreaterThan(0); + const delivered = received.map((r) => r.body).join('\n'); + expect(delivered, 'the payload must name the anomaly that fired').toContain(firingName); + } else { + testLogger.warn( + 'DELIVERY NOT ASSERTED — the backend cannot reach this runner; detection and ' + + 'charts are proven but the notification path is not', + ); + } + }); + }); + + test.afterAll(async ({ browser }) => { + if ( + !process.env.ZO_BASE_URL || + !process.env.ZO_ROOT_USER_EMAIL || + !process.env.ZO_ROOT_USER_PASSWORD || + !process.env.ORGNAME + ) { testLogger.warn('Skipping cleanup - missing environment variables'); return; } const context = await browser.newContext(); const page = await context.newPage(); - try { - // Navigate to base URL first - required for page.evaluate() to make fetch calls + // page.evaluate() can only fetch once the page is on the app origin. await page.goto(process.env.ZO_BASE_URL); await page.waitForLoadState('domcontentloaded', { timeout: 10000 }).catch(() => {}); - - const pm = new PageManager(page); - // cleanupTestAnomalies uses env vars for auth via shared api-helper.js - await pm.anomalyDetectionPage.cleanupTestAnomalies(`E2E_Anomaly`); - testLogger.info('Test anomalies cleanup completed'); - - // Note: Template and destination created via UI in beforeEach are not cleaned up here - // They may be reused by subsequent test runs or cleaned up by a separate cleanup job + const cleanupPm = new PageManager(page); + // Scope to THIS worker's run id. afterAll fires once per worker, and the + // config runs 5 of them, so deleting by the shared "E2E_Anomaly" prefix + // lets a worker finishing early wipe another worker's fixtures mid-test. + await cleanupPm.anomalyDetectionPage.cleanupTestAnomalies(randomValue); + // Destination last: it cannot be removed while an anomaly still points at + // it. Left uncleaned these accumulate every run, and a long destination + // list is what makes the virtualized picker drop options out of the DOM. + await deleteDestination(page, prerequisiteDestinationName); + // Template after the destination that references it. Both are named per + // worker, so this only ever removes what this run created. + await deleteTemplate(page, prerequisiteTemplateName); } catch (error) { testLogger.warn('Cleanup failed', { error: error.message }); } finally { diff --git a/tests/ui-testing/playwright-tests/Alerts/anomaly-detection-form-validation.spec.js b/tests/ui-testing/playwright-tests/Alerts/anomaly-detection-form-validation.spec.js deleted file mode 100644 index d14a4fde93..0000000000 --- a/tests/ui-testing/playwright-tests/Alerts/anomaly-detection-form-validation.spec.js +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright 2026 OpenObserve Inc. -// -// Anomaly Detection domain — form validation E2E tests -// -// Covers: -// 1. Add Anomaly wizard — blank name blocks save and highlights the field -// 2. Query mode toggle — switching between filter and custom SQL modes -// 3. Detection window error — save button disabled or window error shown for invalid window - -const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js'); -const testLogger = require('../utils/test-logger.js'); -const PageManager = require('../../pages/page-manager.js'); - -test.describe('Anomaly Detection form validation', { tag: ['@anomaly-form-validation', '@P0', '@smoke'] }, () => { - test.describe.configure({ mode: 'parallel' }); - let pm; - - test.beforeEach(async ({ page }, testInfo) => { - testLogger.testStart(testInfo.title, testInfo.file); - await navigateToBase(page); - pm = new PageManager(page); - const available = await pm.anomalyFormValidation.navigateToAnomalyDetection(); - if (!available) { - test.skip(true, 'Anomaly Detection tab not available on this build (enterprise feature)'); - } - testLogger.info('Navigated to Anomaly Detection tab'); - }); - - // ── TC001: blank name blocks save and highlights the name field ─────────── - // - // The wizard does NOT gate save by disabling the button: OForm keeps submit - // enabled and validates on click, so the offending field gets painted rather - // than the user being left guessing why a gray button won't respond. The - // blank-name rule lives in AddAlert.schema.ts's anomaly branch. - - test('should block save and highlight the name when it is blank', { - tag: ['@anomaly-form-validation', '@P0', '@smoke'] - }, async ({ page }) => { - testLogger.info('TC001: Verify blank anomaly name blocks save and paints the field'); - - await pm.anomalyFormValidation.openAddAnomalyWizard(); - - // Wizard is open on step 1; name is blank and nothing is selected yet - await expect(pm.anomalyFormValidation.getStreamTypeSelectLocator()).toBeVisible(); - - // Save stays enabled — validation runs on submit, not via a disabled button - const saveBtn = pm.anomalyFormValidation.getSaveBtnLocator(); - await expect(saveBtn).toBeEnabled(); - - await pm.anomalyFormValidation.clickSave(); - - // Blank name must paint the topbar field and toast, and must not save - await expect(pm.anomalyFormValidation.getAnomalyNameErrorLocator()) - .toHaveText('Anomaly name is required.'); - await expect(pm.anomalyFormValidation.getToastMessageLocator(/fix the highlighted fields/i)) - .toBeVisible(); - testLogger.info('Blank name correctly blocked save and highlighted the field'); - - await pm.anomalyFormValidation.clickCancel(); - }); - - // ── TC002: Stream type select is visible and interactive ───────────────── - - test('should show stream type select when Add Anomaly wizard opens', { - tag: ['@anomaly-form-validation', '@P0', '@smoke'] - }, async ({ page }) => { - testLogger.info('TC002: Verify stream type select is present on wizard open'); - - await pm.anomalyFormValidation.openAddAnomalyWizard(); - - await expect(pm.anomalyFormValidation.getStreamTypeSelectLocator()).toBeVisible(); - await expect(pm.anomalyFormValidation.getStreamNameSelectLocator()).toBeVisible(); - - testLogger.info('Stream type and name selects are visible'); - - await pm.anomalyFormValidation.clickCancel(); - }); - - // ── TC003: Cancel closes the wizard ────────────────────────────────────── - - test('should close wizard when Cancel is clicked', { - tag: ['@anomaly-form-validation', '@functional', '@P2'] - }, async ({ page }) => { - testLogger.info('TC003: Verify Cancel closes the anomaly wizard'); - - await pm.anomalyFormValidation.openAddAnomalyWizard(); - - // Stream type select should be visible (wizard is open) - await expect(pm.anomalyFormValidation.getStreamTypeSelectLocator()).toBeVisible(); - - await pm.anomalyFormValidation.clickCancel(); - - // After cancel, anomaly table should still be visible (back on list page) - await expect(pm.anomalyFormValidation.getAnomalyTableLocator()).toBeVisible({ timeout: 10000 }); - testLogger.info('Wizard closed correctly after Cancel'); - }); -}); diff --git a/tests/ui-testing/playwright-tests/Anomaly/anomaly-detection.spec.js b/tests/ui-testing/playwright-tests/Anomaly/anomaly-detection.spec.js deleted file mode 100644 index bd63cc4b6d..0000000000 --- a/tests/ui-testing/playwright-tests/Anomaly/anomaly-detection.spec.js +++ /dev/null @@ -1,294 +0,0 @@ -const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js'); -const testLogger = require('../utils/test-logger.js'); -const PageManager = require('../../pages/page-manager.js'); -const logData = require("../../fixtures/log.json"); -const { getOrgIdentifier } = require('../utils/cloud-auth.js'); - -/** - * Anomaly Detection E2E Tests - * - * Tests for anomaly detection feature (Enterprise/Cloud only) - * - * Features tested: - * - Navigate to anomaly detection tab - * - Create anomaly detection configuration - * - Verify anomaly config appears in list - * - Pause/Resume anomaly detection - * - Delete anomaly configuration - * - * Note: These tests require enterprise or cloud build - */ - -test.describe("Anomaly Detection testcases", () => { - test.describe.configure({ mode: 'serial' }); - - let pm; - let sharedRandomValue; - let testStreamName; - let createdDestinationName; - - test.beforeEach(async ({ page }, testInfo) => { - testLogger.testStart(testInfo.title, testInfo.file); - - await navigateToBase(page); - pm = new PageManager(page); - - // Generate shared random value if not already generated - if (!sharedRandomValue) { - sharedRandomValue = pm.alertsPage.generateRandomString().toLowerCase(); - testLogger.info('Generated shared random value', { sharedRandomValue }); - } - - // Use existing test stream - testStreamName = `e2e_automate`; - - // Navigate to alerts page - await page.goto( - `${logData.alertUrl}?org_identifier=${getOrgIdentifier()}` - ); - await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); - await page.waitForTimeout(1000); - - testLogger.info('Test setup completed'); - }); - - /** - * Test: Check if anomaly detection feature is available - * This test verifies if the anomaly detection tab is accessible (enterprise/cloud feature) - */ - test('Verify anomaly detection tab is available', { - tag: ['@anomaly', '@all', '@smoke'] - }, async () => { - testLogger.info('Testing anomaly detection availability'); - - const isAvailable = await pm.anomalyDetectionPage.isAnomalyDetectionAvailable(); - - if (!isAvailable) { - testLogger.warn('Anomaly detection is not available - skipping tests (requires enterprise/cloud build)'); - test.skip(); - } - - expect(isAvailable).toBe(true); - testLogger.info('Anomaly detection feature is available'); - }); - - /** - * Test: Navigate to anomaly detection tab - */ - test('Navigate to anomaly detection tab', { - tag: ['@anomaly', '@all', '@navigation'] - }, async ({ page }) => { - testLogger.info('Testing navigation to anomaly detection tab'); - - await pm.anomalyDetectionPage.navigateToAnomalyTab(); - - // Verify URL contains tab parameter - const currentUrl = page.url(); - expect(currentUrl).toContain('tab=anomalyDetection'); - - testLogger.info('Successfully navigated to anomaly detection tab'); - }); - - /** - * Test: Create anomaly detection configuration - */ - test('Create anomaly detection configuration', { - tag: ['@anomaly', '@all', '@create'] - }, async ({ page }) => { - testLogger.info('Testing anomaly detection creation'); - - // Check if feature is available - const isAvailable = await pm.anomalyDetectionPage.isAnomalyDetectionAvailable(); - if (!isAvailable) { - testLogger.warn('Anomaly detection not available - skipping test'); - test.skip(); - } - - // Ensure destination exists - createdDestinationName = 'auto_playwright_destination_' + sharedRandomValue; - const templateName = 'auto_playwright_template_' + sharedRandomValue; - const slackUrl = "DEMO"; - - await pm.alertTemplatesPage.ensureTemplateExists(templateName); - await pm.alertDestinationsPage.ensureDestinationExists( - createdDestinationName, - slackUrl, - templateName - ); - - // Navigate back to alerts - await pm.commonActions.navigateToAlerts(); - await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); - - await pm.anomalyDetectionPage.navigateToAnomalyTab(); - - const anomalyName = 'auto_anomaly_' + sharedRandomValue; - - await pm.anomalyDetectionPage.createAnomalyDetection({ - name: anomalyName, - streamName: testStreamName, - streamType: 'logs', - destinationName: createdDestinationName - }); - - // Verify anomaly config exists in list - await pm.anomalyDetectionPage.navigateToAnomalyTab(); - await pm.anomalyDetectionPage.verifyAnomalyConfigExists(anomalyName); - - testLogger.info('Successfully created and verified anomaly detection config'); - }); - - /** - * Test: Verify anomaly config has status badge - */ - test('Verify anomaly config shows status', { - tag: ['@anomaly', '@all', '@status'] - }, async () => { - testLogger.info('Testing anomaly status display'); - - // Check if feature is available - const isAvailable = await pm.anomalyDetectionPage.isAnomalyDetectionAvailable(); - if (!isAvailable) { - testLogger.warn('Anomaly detection not available - skipping test'); - test.skip(); - } - - await pm.anomalyDetectionPage.navigateToAnomalyTab(); - - const anomalyName = 'auto_anomaly_' + sharedRandomValue; - - // Get status - const status = await pm.anomalyDetectionPage.getAnomalyStatus(anomalyName); - - // Status should be one of: training, ready, failed, etc. - expect(['training', 'ready', 'failed', 'active', 'paused', 'unknown']).toContain(status); - - testLogger.info('Anomaly config has status', { status }); - }); - - /** - * Test: Pause and resume anomaly detection - */ - test('Pause and resume anomaly detection', { - tag: ['@anomaly', '@all', '@pause-resume'] - }, async ({ page }) => { - testLogger.info('Testing pause and resume functionality'); - - // Check if feature is available - const isAvailable = await pm.anomalyDetectionPage.isAnomalyDetectionAvailable(); - if (!isAvailable) { - testLogger.warn('Anomaly detection not available - skipping test'); - test.skip(); - } - - await pm.anomalyDetectionPage.navigateToAnomalyTab(); - - const anomalyName = 'auto_anomaly_' + sharedRandomValue; - - // Pause anomaly detection - await pm.anomalyDetectionPage.pauseAnomaly(anomalyName); - await page.waitForTimeout(2000); - - // Resume anomaly detection - await pm.anomalyDetectionPage.resumeAnomaly(anomalyName); - await page.waitForTimeout(2000); - - testLogger.info('Successfully paused and resumed anomaly detection'); - }); - - /** - * Test: Verify anomaly detection actually detects anomalies (E2E validation) - * This test creates an anomaly config, waits for training, ingests anomalous data, - * and verifies the anomaly is detected and alert is triggered - * - * NOTE: This test may take several minutes due to model training and detection wait times - */ - test('Verify anomaly detection E2E - model training and anomaly detection', { - tag: ['@anomaly', '@all', '@e2e', '@detection'], - timeout: 600000 // 10 minutes - training and detection can take time - }, async () => { - testLogger.info('Testing full E2E anomaly detection with trigger validation'); - - // Check if feature is available - const isAvailable = await pm.anomalyDetectionPage.isAnomalyDetectionAvailable(); - if (!isAvailable) { - testLogger.warn('Anomaly detection not available - skipping test'); - test.skip(); - } - - // Mark this test as slow since it involves training and detection - test.slow(); - - await pm.anomalyDetectionPage.navigateToAnomalyTab(); - - const anomalyName = 'auto_anomaly_' + sharedRandomValue; - - // Ensure validation infrastructure exists (destination sends alerts to validation stream) - const validationInfra = await pm.alertsPage.ensureValidationInfrastructure(pm, sharedRandomValue); - testLogger.info('Validation infrastructure ready', validationInfra); - - await pm.commonActions.navigateToAlerts(); - await pm.anomalyDetectionPage.navigateToAnomalyTab(); - - testLogger.info('Verifying anomaly detection with actual anomaly trigger'); - - // Use the comprehensive E2E verification method - // With 1-minute schedule_interval, wait ~2.5 minutes to ensure detection runs - const result = await pm.anomalyDetectionPage.verifyAnomalyDetection( - pm, - anomalyName, - testStreamName, - validationInfra.streamName, // Use validation stream from infrastructure - { - baselineValue: 100, // Normal value - anomalousValue: 1000 // 10x spike (clear anomaly) - }, - 150000 // Wait 2.5 minutes (schedule is 1 min, so 2.5min ensures at least 2 cycles) - ); - - // Verify anomaly was detected - expect(result.detected, `Anomaly ${anomalyName} should be detected and trigger alert`).toBe(true); - - if (result.detected) { - testLogger.info('SUCCESS: Anomaly detection E2E validation passed!', { - anomalyName, - logTextPreview: result.logText?.substring(0, 200) - }); - } else { - testLogger.error('FAILED: Anomaly was not detected', { - anomalyName, - result - }); - } - }); - - /** - * Test: Delete anomaly detection configuration - */ - test('Delete anomaly detection configuration', { - tag: ['@anomaly', '@all', '@delete'] - }, async ({ page }) => { - testLogger.info('Testing anomaly deletion'); - - // Check if feature is available - const isAvailable = await pm.anomalyDetectionPage.isAnomalyDetectionAvailable(); - if (!isAvailable) { - testLogger.warn('Anomaly detection not available - skipping test'); - test.skip(); - } - - await pm.anomalyDetectionPage.navigateToAnomalyTab(); - - const anomalyName = 'auto_anomaly_' + sharedRandomValue; - - // Delete anomaly config - await pm.anomalyDetectionPage.deleteAnomaly(anomalyName); - await page.waitForTimeout(2000); - - // Verify it's removed from list (should not be visible) - const row = pm.anomalyDetectionPage.getAnomalyRow(anomalyName); - await expect(row).not.toBeVisible({ timeout: 5000 }); - - testLogger.info('Successfully deleted anomaly detection config'); - }); -}); diff --git a/tests/ui-testing/playwright-tests/utils/MonacoEditorHelper.js b/tests/ui-testing/playwright-tests/utils/MonacoEditorHelper.js index a2cc0ff996..61c4df5385 100644 --- a/tests/ui-testing/playwright-tests/utils/MonacoEditorHelper.js +++ b/tests/ui-testing/playwright-tests/utils/MonacoEditorHelper.js @@ -68,7 +68,10 @@ class MonacoEditorHelper { */ async clear(container) { await this.focus(container); - await this.page.keyboard.press('Control+a'); + // ControlOrMeta, not Control: on macOS Control+a is "move to line start", + // so a plain Control+a leaves the editor untouched and the next type() + // appends to the existing query instead of replacing it. + await this.page.keyboard.press('ControlOrMeta+a'); await this.page.keyboard.press('Backspace'); } diff --git a/tests/ui-testing/playwright-tests/utils/api-helper.js b/tests/ui-testing/playwright-tests/utils/api-helper.js index 275d2e7b7e..b67057b144 100644 --- a/tests/ui-testing/playwright-tests/utils/api-helper.js +++ b/tests/ui-testing/playwright-tests/utils/api-helper.js @@ -16,8 +16,10 @@ const testLogger = require('./test-logger.js'); * @returns {Promise<{status: number, data: any}>} - Response object * @throws {Error} - If required environment variables are missing */ -async function apiCall(page, method, path, body = null) { - const baseUrl = process.env.ZO_BASE_URL; +async function apiCall(page, method, path, body = null, baseUrlOverride = null) { + // Ingestion can live on a different host from the UI (INGESTION_URL), so the + // caller may override the base rather than assume ZO_BASE_URL serves both. + const baseUrl = baseUrlOverride || process.env.ZO_BASE_URL; const email = process.env.ZO_ROOT_USER_EMAIL; const password = process.env.ZO_ROOT_USER_PASSWORD; @@ -56,18 +58,36 @@ function getOrgName() { /** * Create a test destination via API (useful for CI/CD testing) * - * Uses MOCK_WEBHOOK_URL env var if set, otherwise falls back to httpbin.org/post - * which requires outbound internet connectivity. + * Uses opts.url if given, else MOCK_WEBHOOK_URL, else example.com/webhook — a + * public, reserved host that satisfies the SSRF guard without any third-party + * dependency. + * + * @param {import('@playwright/test').Page} page + * @param {string} name destination name + * @param {string} [template] template to bind + * @param {object} [opts] + * @param {string} [opts.url] explicit webhook URL, overriding MOCK_WEBHOOK_URL + * and the fallback. Pointing it at a receiver the test owns is what makes + * delivery assertable rather than merely configured. * * @param {import('@playwright/test').Page} page - Playwright page instance * @param {string} name - Destination name * @param {string} [template='Slack'] - Template type * @returns {Promise<{status: number, data: any}>} */ -async function createMockDestination(page, name, template = 'Slack') { +async function createMockDestination(page, name, template = 'Slack', opts = {}) { const org = getOrgName(); - // Use MOCK_WEBHOOK_URL if set, otherwise fall back to httpbin.org - const finalUrl = process.env.MOCK_WEBHOOK_URL || 'https://httpbin.org/post'; + // Defaults to example.com, matching the convention documented in + // alerts-advanced.spec.js: it is IANA-reserved, resolves to a PUBLIC ip so + // the SSRF guard accepts it, and is never actually delivered to — no + // third-party dependency and no rate limit. Pointing at this instance's own + // ingest instead fails on cloud/alpha, where the base URL is a private IP the + // guard rejects, leaving the destination form open forever. + // + // MOCK_WEBHOOK_URL overrides it where loopback is allowlisted + // (ZO_SSRF_ALLOW_LOOPBACK in CI); a self-ingest URL there turns a delivered + // alert into a queryable receipt, which is what makes delivery assertable. + const finalUrl = opts.url || process.env.MOCK_WEBHOOK_URL || 'https://example.com/webhook'; const payload = { name, template, @@ -81,6 +101,181 @@ async function createMockDestination(page, name, template = 'Slack') { return apiCall(page, 'POST', `/api/${org}/alerts/destinations`, payload); } +/** + * Run a SQL search and return the hits. + * @param {import('@playwright/test').Page} page + * @param {string} sql + * @param {number} sinceSeconds how far back to search + */ +async function searchSql(page, sql, sinceSeconds = 259200) { + const org = getOrgName(); + const end = Date.now() * 1000; + const start = end - sinceSeconds * 1_000_000; + const res = await apiCall(page, 'POST', `/api/${org}/_search?type=logs`, { + query: { sql, start_time: start, end_time: end, size: 1000 }, + }); + return res.data?.hits || []; +} + +/** True when a destination with this name exists. */ +async function destinationExists(page, name) { + const org = getOrgName(); + const res = await apiCall(page, 'GET', `/api/${org}/alerts/destinations`); + const rows = Array.isArray(res.data) ? res.data : res.data?.list || []; + return rows.some((r) => r.name === name); +} + +/** + * Seed a stream with a flat baseline plus a spike, backdated across `hours`. + * + * Anomaly training needs history: a model cannot be built from rows all stamped + * "now", which is why the shared e2e_automate stream is unusable for this. The + * spike sits in the most recent buckets so it falls inside the detection window. + */ +async function seedAnomalyStream(page, streamName, opts = {}) { + const { + // Stay inside ZO_INGEST_ALLOWED_UPTO (5 hours in CI) — rows backdated + // further are silently dropped, and a 2-day seed at 5m buckets lands only + // ~59 points, under the 100 the model needs to train. + hours = 4, + // 1-minute buckets so 4 hours still yields ~240 points. + bucketSeconds = 60, + baseline = 10, + spikeValue = 120, + // Wide enough that the model cannot dismiss it, and held back from the + // newest buckets: the test spends ~30s in the wizard before detecting, so + // a spike hard against the window edge drifts and can fall outside it. + spikeBuckets = 15, + spikeOffset = 10, + } = opts; + const org = getOrgName(); + const now = Math.floor(Date.now() / 1000); + const total = Math.floor((hours * 3600) / bucketSeconds); + const rows = []; + for (let i = total; i > 0; i--) { + const ts = (now - i * bucketSeconds) * 1_000_000; + const inSpike = i > spikeOffset && i <= spikeOffset + spikeBuckets; + const count = inSpike ? spikeValue : baseline; + for (let n = 0; n < count; n++) { + rows.push({ _timestamp: ts, level: 'info', job: 'anomaly_e2e', log: 'seeded event' }); + } + } + testLogger.info('Seeding anomaly stream', { streamName, rows: rows.length, hours, buckets: total }); + const base = process.env.INGESTION_URL || process.env.ZO_BASE_URL; + return apiCall(page, 'POST', `/api/${org}/${streamName}/_json`, rows, base); +} + +/** + * Wait until a freshly ingested stream is listed. + * + * Ingestion returns before the stream shows up in the schema list, so a wizard + * opened immediately after seeding finds no such option in the stream picker. + */ +async function waitForStream(page, streamName, timeoutMs = 60000) { + const org = getOrgName(); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const res = await apiCall(page, 'GET', `/api/${org}/streams?type=logs`); + const names = (res.data?.list || []).map((x) => x.name); + if (names.includes(streamName)) return true; + await page.waitForTimeout(3000); + } + throw new Error(`Stream ${streamName} did not appear within ${timeoutMs}ms`); +} + +/** + * Create an anomaly config directly, for tests that need one to ACT on rather + * than to create. Driving the wizard costs ~15s and couples every test to the + * creation path; this is ~1s and lets them run in parallel, each owning its own + * record instead of sharing one through a serial chain. + * + * @returns {Promise} the new anomaly_id + */ +async function createAnomalyViaApi(page, name, opts = {}) { + const org = getOrgName(); + const { + streamName = 'e2e_automate', + destinations = [], + threshold = 97, + histogramInterval = '5m', + } = opts; + const res = await apiCall(page, 'POST', `/api/v2/${org}/alerts?folder=default`, { + alert_type: 'anomaly_detection', + name, + stream_name: streamName, + stream_type: 'logs', + enabled: true, + folder_id: 'default', + alert_destinations: destinations, + tags: [], + anomaly_config: { + query_mode: 'filters', + filters: [], + custom_sql: null, + detection_function: 'count(*)', + histogram_interval: histogramInterval, + schedule_interval: '10m', + detection_window_seconds: 3600, + training_window_days: 1, + retrain_interval_days: 0, + threshold, + alert_enabled: destinations.length > 0, + }, + }); + if (res.status !== 200) { + throw new Error(`createAnomalyViaApi(${name}) failed ${res.status}: ${JSON.stringify(res.data)}`); + } + testLogger.info('Created anomaly via API', { name }); + return res.data.anomaly_id || res.data.id; +} + +/** + * Wait until a config is visible to the LIST endpoint. + * + * Creation returns before the record is queryable, so reloading the UI straight + * after it can still render a list that does not contain it. Settling on the + * API first separates backend consistency from the page's own caching. + */ +async function waitForAnomalyListed(page, name, timeoutMs = 30000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const rows = await listAnomalyDetections(page); + if (rows.some((r) => r.name === name)) return true; + await page.waitForTimeout(1000); + } + throw new Error(`Anomaly ${name} was not listed by the API within ${timeoutMs}ms`); +} + +/** Kick off model training for an anomaly config. */ +async function triggerAnomalyTraining(page, anomalyId) { + const org = getOrgName(); + return apiCall(page, 'POST', `/api/${org}/anomaly_detection/${anomalyId}/train`, {}); +} + +/** Poll until the model reports trained. Training is normally seconds. */ +async function waitForAnomalyTrained(page, anomalyId, timeoutMs = 180000) { + const org = getOrgName(); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const res = await apiCall(page, 'GET', `/api/${org}/anomaly_detection/${anomalyId}`); + if (res.data?.is_trained) return res.data; + if (res.data?.last_error) throw new Error(`Training failed: ${res.data.last_error}`); + await page.waitForTimeout(5000); + } + throw new Error(`Model not trained within ${timeoutMs}ms`); +} + +/** + * Delete an alert template by name. + * @param {import('@playwright/test').Page} page + * @param {string} name + */ +async function deleteTemplate(page, name) { + const org = getOrgName(); + testLogger.info('Deleting template via API', { name }); + return apiCall(page, 'DELETE', `/api/${org}/alerts/templates/${name}`); +} + /** * Delete a destination via API * @param {import('@playwright/test').Page} page - Playwright page instance @@ -173,6 +368,15 @@ module.exports = { getOrgName, createMockDestination, deleteDestination, + deleteTemplate, + destinationExists, + searchSql, + createAnomalyViaApi, + waitForAnomalyListed, + seedAnomalyStream, + waitForStream, + triggerAnomalyTraining, + waitForAnomalyTrained, listAnomalyDetections, triggerAnomalyDetection, getAnomalyHistory, diff --git a/web/src/components/alerts/AnomalyDetectionChart.vue b/web/src/components/alerts/AnomalyDetectionChart.vue index 2418a1cb95..7fe4f5ba23 100644 --- a/web/src/components/alerts/AnomalyDetectionChart.vue +++ b/web/src/components/alerts/AnomalyDetectionChart.vue @@ -46,6 +46,7 @@ :key="option.value" :value="option.value" size="sm" + :data-test="`alerts-anomalydetectionchart-range-${option.value}`" > {{ option.label }} diff --git a/web/src/components/anomaly_detection/steps/AnomalyAlerting.vue b/web/src/components/anomaly_detection/steps/AnomalyAlerting.vue index 3055021ce5..52a4168765 100644 --- a/web/src/components/anomaly_detection/steps/AnomalyAlerting.vue +++ b/web/src/components/anomaly_detection/steps/AnomalyAlerting.vue @@ -130,6 +130,7 @@ along with this program. If not, see . size="sm" class="ms-1" :title="t('alerts.alertSettings.refreshDestinations')" + data-test="anomaly-refresh-destinations" @click="$emit('refresh:destinations')" icon-left="refresh" /> diff --git a/web/src/components/anomaly_detection/steps/AnomalyDetectionConfig.vue b/web/src/components/anomaly_detection/steps/AnomalyDetectionConfig.vue index 364a33aa4e..90fde70696 100644 --- a/web/src/components/anomaly_detection/steps/AnomalyDetectionConfig.vue +++ b/web/src/components/anomaly_detection/steps/AnomalyDetectionConfig.vue @@ -28,6 +28,7 @@ along with this program. If not, see . :key="tab.value" :value="tab.value" size="sm" + :data-test="`anomaly-query-tab-${tab.value}`" > {{ tab.label }} @@ -50,9 +51,11 @@ along with this program. If not, see . v-for="(filter, idx) in filterRows" :key="idx" class="mb-2 flex items-center gap-2" + data-test="anomaly-filter-row" > . . . - + {{ t("alerts.anomaly.addFilter") }} diff --git a/web/src/lib/feedback/Toast/OToast.vue b/web/src/lib/feedback/Toast/OToast.vue index 1b53f2b5ee..8edeed7814 100644 --- a/web/src/lib/feedback/Toast/OToast.vue +++ b/web/src/lib/feedback/Toast/OToast.vue @@ -283,6 +283,7 @@ onUnmounted(() => { title ? 'self-start' : 'self-center', ]" :aria-label="t('components.toast.dismissNotification')" + data-test="o-toast-dismiss" >