mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-06 00:09:22 +00:00
test: rework the anomaly detection e2e suite (#14247)
This commit is contained in:
@@ -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",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 <name>-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 };
|
||||
@@ -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 =====
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>} 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,
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
size="sm"
|
||||
:data-test="`alerts-anomalydetectionchart-range-${option.value}`"
|
||||
>
|
||||
{{ option.label }}
|
||||
</OToggleGroupItem>
|
||||
|
||||
@@ -130,6 +130,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
size="sm"
|
||||
class="ms-1"
|
||||
:title="t('alerts.alertSettings.refreshDestinations')"
|
||||
data-test="anomaly-refresh-destinations"
|
||||
@click="$emit('refresh:destinations')"
|
||||
icon-left="refresh"
|
||||
/>
|
||||
|
||||
@@ -28,6 +28,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:key="tab.value"
|
||||
:value="tab.value"
|
||||
size="sm"
|
||||
:data-test="`anomaly-query-tab-${tab.value}`"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</OToggleGroupItem>
|
||||
@@ -50,9 +51,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
v-for="(filter, idx) in filterRows"
|
||||
:key="idx"
|
||||
class="mb-2 flex items-center gap-2"
|
||||
data-test="anomaly-filter-row"
|
||||
>
|
||||
<OFormSelect
|
||||
:name="`filters[${idx}].field`"
|
||||
:data-test="`anomaly-filter-field-${idx}`"
|
||||
:options="filteredStreamFields"
|
||||
:placeholder="filter.field ? raw('') : t('alerts.anomaly.fieldPlaceholder')"
|
||||
class="alert-v3-select filter-field-select"
|
||||
@@ -71,6 +74,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</OFormSelect>
|
||||
<OFormSelect
|
||||
:name="`filters[${idx}].operator`"
|
||||
:data-test="`anomaly-filter-operator-${idx}`"
|
||||
:options="filterOperators"
|
||||
class="alert-v3-select"
|
||||
style="width: 6.875rem"
|
||||
@@ -78,6 +82,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<OFormInput
|
||||
v-if="operatorNeedsValue(filter.operator)"
|
||||
:name="`filters[${idx}].value`"
|
||||
:data-test="`anomaly-filter-value-${idx}`"
|
||||
:placeholder="t('alerts.placeholders.value')"
|
||||
class="alert-v3-input"
|
||||
style="max-width: 10rem"
|
||||
@@ -85,11 +90,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<OButton
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
data-test="anomaly-filter-row-remove"
|
||||
@click="removeFilter(idx)"
|
||||
icon-left="close"
|
||||
/>
|
||||
</div>
|
||||
<OButton variant="outline" size="sm-action" class="mt-2" @click="addFilter">
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="sm-action"
|
||||
class="mt-2"
|
||||
data-test="anomaly-filter-add"
|
||||
@click="addFilter"
|
||||
>
|
||||
{{ t("alerts.anomaly.addFilter") }}
|
||||
</OButton>
|
||||
</div>
|
||||
|
||||
@@ -283,6 +283,7 @@ onUnmounted(() => {
|
||||
title ? 'self-start' : 'self-center',
|
||||
]"
|
||||
:aria-label="t('components.toast.dismissNotification')"
|
||||
data-test="o-toast-dismiss"
|
||||
>
|
||||
<OIcon name="close" size="sm" class="size-4" aria-hidden="true" />
|
||||
</ToastClose>
|
||||
|
||||
Reference in New Issue
Block a user