test: rework the anomaly detection e2e suite (#14247)

This commit is contained in:
Neha P
2026-09-08 10:25:16 +00:00
committed by GitHub
parent 48bb9ab996
commit 22ed066463
13 changed files with 1727 additions and 2771 deletions
@@ -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 };
-2
View File
@@ -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>
+1
View File
@@ -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>