diff --git a/tests/ui-testing/ci-matrix/ci_matrix.json b/tests/ui-testing/ci-matrix/ci_matrix.json index d844a1d91f..4e2e89a2fb 100644 --- a/tests/ui-testing/ci-matrix/ci_matrix.json +++ b/tests/ui-testing/ci-matrix/ci_matrix.json @@ -29,7 +29,8 @@ "alertDestinationsPagination.spec.js", "alerts-destinations-usage.spec.js", "alerts-save-query-mode.spec.js", - "alerts-no-destination.spec.js" + "alerts-no-destination.spec.js", + "alerts-from-panel.spec.js" ], "disabled": [ { @@ -561,7 +562,7 @@ }, { "testfolder": "Synthetics", - "_comment": "Own shard because the synthetics routes only exist when the server runs with ZO_SYNTHETICS_ENABLED=true (router registers /api/{org}/synthetics* conditionally). The flag is read once at process start, so it cannot vary between tests sharing a backend — each shard gets its own OpenObserve process; every other shard omits synthetics_enabled and keeps the default (false). Pinned to 3 workers: every create/list test writes a check and 5 concurrent writers trip SQLite's 'database is locked' (SLO shard precedent). Results/run-detail pages are fed by seeding the synthetics_results streams in beforeAll — no probe runs on CI. status-pages-enterprise-gating lives here because the status-page routes sit inside the same gate.", + "_comment": "Own shard because the synthetics routes only exist when the server runs with ZO_SYNTHETICS_ENABLED=true (router registers /api/{org}/synthetics* conditionally). The flag is read once at process start, so it cannot vary between tests sharing a backend \u2014 each shard gets its own OpenObserve process; every other shard omits synthetics_enabled and keeps the default (false). Pinned to 3 workers: every create/list test writes a check and 5 concurrent writers trip SQLite's 'database is locked' (SLO shard precedent). Results/run-detail pages are fed by seeding the synthetics_results streams in beforeAll \u2014 no probe runs on CI. status-pages-enterprise-gating lives here because the status-page routes sit inside the same gate.", "actual_folder": "Synthetics", "browser": "chrome", "synthetics_enabled": "true", diff --git a/tests/ui-testing/pages/alertsPages/alertsPage.js b/tests/ui-testing/pages/alertsPages/alertsPage.js index 5b817f812e..628864a986 100644 --- a/tests/ui-testing/pages/alertsPages/alertsPage.js +++ b/tests/ui-testing/pages/alertsPages/alertsPage.js @@ -3255,6 +3255,11 @@ export class AlertsPage { return ((await valueEl.textContent()) ?? '').trim(); } + /** Display-mode alert name value element (OFormInlineEdit span, not an ). */ + getAlertNameValueLocator() { + return this.page.locator('[data-test="add-alert-name-input-value"]').first(); + } + /** * Select stream type from dropdown * OSelect popover pattern: open trigger, wait for popover, click option keyed by data-test-value (§4) diff --git a/tests/ui-testing/pages/alertsPages/createAlertFromSourceDialogPage.js b/tests/ui-testing/pages/alertsPages/createAlertFromSourceDialogPage.js new file mode 100644 index 0000000000..70b51dde67 --- /dev/null +++ b/tests/ui-testing/pages/alertsPages/createAlertFromSourceDialogPage.js @@ -0,0 +1,94 @@ +// Create Alert From Source confirm-dialog page object. +// The shared "create an alert from here" confirmation step (app-root mounted via +// CreateAlertDialogProvider in App.vue). Every section renders off the prefill, +// so this owns the dialog shell + the conditional sections the specs assert on. +import { expect } from "@playwright/test"; + +export default class CreateAlertFromSourceDialogPage { + constructor(page) { + this.page = page; + + // Dialog shell + gating visibility (continue/cancel are ODialog buttons inside it). + this.dialog = page.locator('[data-test="create-alert-from-source-dialog"]'); + this.continueBtn = page.locator( + '[data-test="create-alert-from-source-dialog"] [data-test="o-dialog-primary-btn"]' + ); + this.cancelBtn = page.locator( + '[data-test="create-alert-from-source-dialog"] [data-test="o-dialog-secondary-btn"]' + ); + + // Query-picker mode (pickQueryOnly): "pick which query" + choice OSelect + picked text. + this.queryPickerIntro = page.locator('[data-test="create-alert-query-picker-intro"]'); + this.queryPicker = page.locator('[data-test="create-alert-query-picker"]'); + this.queryPickerTrigger = page.locator('[data-test="create-alert-query-picker-trigger"]'); + this.queryPickerOption = page.locator('[data-test="create-alert-query-picker-option"]'); + this.pickedQuery = page.locator('[data-test="create-alert-picked-query"]'); + this.pickedQueryText = page.locator('[data-test="create-alert-picked-query-text"]'); + + // Sections suppressed while pickQueryOnly is active (multi-query panel). + this.thresholdShape = page.locator('[data-test="create-alert-threshold-shape"]'); + this.window = page.locator('[data-test="create-alert-window"]'); + this.queryPreview = page.locator('[data-test="create-alert-query-preview"]'); + } + + async expectDialogVisible() { + await expect(this.dialog).toBeVisible({ timeout: 15000 }); + } + + async expectDialogHidden() { + await expect(this.dialog).toBeHidden({ timeout: 15000 }); + } + + async expectQueryPickerVisible() { + await expect(this.queryPicker).toBeVisible({ timeout: 10000 }); + } + + async expectPickedQueryTextVisible() { + await expect(this.pickedQueryText).toBeVisible({ timeout: 10000 }); + } + + /** The picked-query text (colorized SQL), trimmed; empty while unset. */ + async getPickedQueryText() { + await this.pickedQueryText.waitFor({ state: "visible", timeout: 10000 }); + return (await this.pickedQueryText.textContent())?.trim() ?? ""; + } + + async expectThresholdShapeHidden() { + await expect(this.thresholdShape).toHaveCount(0); + } + + async expectWindowHidden() { + await expect(this.window).toHaveCount(0); + } + + async expectQueryPreviewHidden() { + await expect(this.queryPreview).toHaveCount(0); + } + + /** Open the query picker and choose the query at the given (0-based) index. */ + async selectQuery(index) { + await this.queryPickerTrigger.click(); + await this.queryPickerOption + .nth(index) + .waitFor({ state: "visible", timeout: 10000 }); + await this.queryPickerOption.nth(index).click(); + } + + async clickContinue() { + await this.continueBtn.click(); + } + + async clickCancel() { + await this.cancelBtn.click(); + } + + async expectContinueDisabled() { + await expect(this.continueBtn).toBeDisabled(); + } + + async expectWarningVisible(key) { + await expect( + this.page.locator(`[data-test="create-alert-warning-${key}"]`) + ).toBeVisible({ timeout: 10000 }); + } +} diff --git a/tests/ui-testing/pages/dashboardPages/dashboard-panel-edit.js b/tests/ui-testing/pages/dashboardPages/dashboard-panel-edit.js index a7ac2f8c4f..ec55d25b35 100644 --- a/tests/ui-testing/pages/dashboardPages/dashboard-panel-edit.js +++ b/tests/ui-testing/pages/dashboardPages/dashboard-panel-edit.js @@ -325,6 +325,18 @@ export default class DashboardPanel { return this.page.locator('[data-test="dashboard-panel-container"]'); } + // Create Alert menu item (dropdown item) for disabled/state assertions. + getCreateAlertFromPanelItem() { + return this.createAlertFromPanel; + } + + // The disabled-reason description rendered under the item (ODropdownItem). + getCreateAlertFromPanelReason() { + return this.page.locator( + '[data-test="dashboard-create-alert-from-panel-reason"]' + ); + } + // Alert context menu options (shown on chart right-click). getAlertContextMenuAbove() { return this.alertContextMenuAbove; diff --git a/tests/ui-testing/pages/page-manager.js b/tests/ui-testing/pages/page-manager.js index e612aa04ce..fae284c414 100644 --- a/tests/ui-testing/pages/page-manager.js +++ b/tests/ui-testing/pages/page-manager.js @@ -31,6 +31,7 @@ import LogsVisualise from "./dashboardPages/visualise"; import { DashboardPage } from "./dashboardPages/dashboardPage.js"; import { ScheduledReportsDrawerPage } from "./dashboardPages/scheduledReportsDrawer.js"; import { AlertsPage } from "./alertsPages/alertsPage.js"; +import CreateAlertFromSourceDialogPage from "./alertsPages/createAlertFromSourceDialogPage.js"; import { AlertLibraryPage } from "./alertsPages/alertLibraryPage.js"; import { AlertHistoryPage } from "./alertsPages/alertHistoryPage.js"; import { AlertDetailPage } from "./alertsPages/alertDetailPage.js"; @@ -201,6 +202,7 @@ class PageManager { // ===== EXISTING ALERTS PAGE OBJECT ===== this.alertsPage = new AlertsPage(page); + this.createAlertFromSourceDialog = new CreateAlertFromSourceDialogPage(page); this.alertLibraryPage = new AlertLibraryPage(page); this.alertHistoryPage = new AlertHistoryPage(page); this.alertDetailPage = new AlertDetailPage(page); diff --git a/tests/ui-testing/playwright-tests/Alerts/alerts-from-panel.spec.js b/tests/ui-testing/playwright-tests/Alerts/alerts-from-panel.spec.js new file mode 100644 index 0000000000..c57b67da8a --- /dev/null +++ b/tests/ui-testing/playwright-tests/Alerts/alerts-from-panel.spec.js @@ -0,0 +1,363 @@ +// Create Alert from Dashboard Panel - E2E Tests +// Covers the shared "create an alert from here" confirm dialog when entered from +// a dashboard panel (source: "panel"). The headline path is a multi-query panel +// opening the dialog in pickQueryOnly mode (query picker + picked query, with +// threshold/window/preview suppressed), plus the disabled menu item and cancel. + +import { + test, + expect, + navigateToBase, +} from "../utils/enhanced-baseFixtures.js"; +import PageManager from "../../pages/page-manager"; +import { ingestion } from "../Dashboards/utils/dashIngestion.js"; +import { + waitForDashboardPage, + deleteDashboard, +} from "../Dashboards/utils/dashCreation.js"; +import { waitForStreamComplete } from "../utils/streaming-helpers.js"; +import testLogger from "../utils/test-logger.js"; + +test.describe("Create Alert from Dashboard Panel testcases", () => { + 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); + await ingestion(page); + + // navigateToBase() alone can land on the wrong org's page on cloud; force + // the correct org with an explicit navigation to a real feature page. + await page.goto( + `${process.env["ZO_BASE_URL"]}/web/dashboards?org_identifier=${process.env["ORGNAME"]}&folder=default` + ); + await page.waitForLoadState("domcontentloaded"); + testLogger.info("Test setup completed"); + }); + + test( + "should open the query-picker confirm dialog from a multi-query panel and land on the pre-filled alert form", + { tag: ["@create-alert-from-panel", "@alerts", "@all", "@P0"] }, + async ({ page }) => { + testLogger.info("Testing multi-query panel -> confirm dialog -> alert form"); + + const dashName = + "Dashboard_Alert_MQ_" + Math.random().toString(36).slice(2, 11); + const panelName = + pm.dashboardPanelActions.generateUniquePanelName("alert-mq"); + + await pm.dashboardList.menuItem("dashboards-item"); + await waitForDashboardPage(page); + + await pm.dashboardCreate.createDashboard(dashName); + + // Bar chart panel with two visible queries, each on e2e_automate. + await pm.dashboardCreate.addPanel(); + await pm.dashboardPanelActions.addPanelName(panelName); + + // Q1 + await pm.chartTypeSelector.selectChartType("bar"); + await pm.chartTypeSelector.selectStreamType("logs"); + await pm.chartTypeSelector.selectStream("e2e_automate"); + await pm.chartTypeSelector.searchAndAddField( + "kubernetes_container_name", + "y" + ); + + // Q2 — a new query tab becomes active; give it its own stream + measure. + await pm.dashboardMultiSQL.addQueryTab(1); + await pm.chartTypeSelector.selectStreamType("logs"); + await pm.chartTypeSelector.selectStream("e2e_automate"); + await pm.chartTypeSelector.searchAndAddField( + "kubernetes_namespace_name", + "y" + ); + + const streamPromise = waitForStreamComplete(page); + await pm.dashboardPanelActions.applyDashboardBtn(); + await streamPromise; + await pm.dashboardPanelActions.waitForChartToRender(); + + const dashboardStreamPromise = waitForStreamComplete(page, 30000); + await pm.dashboardPanelActions.savePanel(); + await dashboardStreamPromise; + await pm.dashboardPanelActions + .getChartRendererCanvasElement() + .first() + .waitFor({ state: "visible", timeout: 15000 }); + + await pm.dashboardPanelEdit.getPanelContainer().first().hover(); + await pm.dashboardPanelEdit.createAlertFromPanelMenu(panelName); + + // The confirm dialog appears only because there are >= 2 queries. + await pm.createAlertFromSourceDialog.expectDialogVisible(); + await pm.createAlertFromSourceDialog.expectQueryPickerVisible(); + + // pickQueryOnly shows the picked query; threshold/window/preview are suppressed. + await pm.createAlertFromSourceDialog.expectPickedQueryTextVisible(); + expect( + await pm.createAlertFromSourceDialog.getPickedQueryText() + ).not.toBe(""); + await pm.createAlertFromSourceDialog.expectThresholdShapeHidden(); + await pm.createAlertFromSourceDialog.expectWindowHidden(); + await pm.createAlertFromSourceDialog.expectQueryPreviewHidden(); + + await pm.createAlertFromSourceDialog.clickContinue(); + + await page.waitForURL(/.*alerts\/add.*prefill=panel.*/, { + timeout: 30000, + }); + const currentUrl = page.url(); + expect(currentUrl).toContain("prefill=panel"); + // The panel payload rides sessionStorage, not the URL. + expect(currentUrl).not.toContain("panelData="); + + // The alert name is an OFormInlineEdit title: in display mode the value + // is a span, not an , so read the committed value via the helper. + const alertNameValue = pm.alertsPage.getAlertNameValueLocator(); + await expect(alertNameValue).toBeVisible({ timeout: 30000 }); + expect((await alertNameValue.textContent()) ?? "").toContain("Alert_from_"); + testLogger.info("Landed on the alert form pre-filled from the panel"); + + // Navigate back to the dashboard folder and clean up. + await page.goto( + `${process.env["ZO_BASE_URL"]}/web/dashboards?org_identifier=${process.env["ORGNAME"]}&folder=default` + ); + await page.waitForLoadState("domcontentloaded"); + await waitForDashboardPage(page); + await pm.dashboardFolder + .getFolderCardByName("default") + .waitFor({ state: "visible", timeout: 30000 }); + await pm.dashboardFolder.openFolderByName("default"); + await deleteDashboard(page, dashName); + + testLogger.info("Test completed: multi-query panel confirm dialog"); + } + ); + + test( + "should update the picked-query text when a different query is selected in the dialog", + { tag: ["@create-alert-from-panel", "@alerts", "@all", "@P1"] }, + async ({ page }) => { + testLogger.info("Testing query picker re-parameterisation (rebuild)"); + + const dashName = + "Dashboard_Alert_RB_" + Math.random().toString(36).slice(2, 11); + const panelName = + pm.dashboardPanelActions.generateUniquePanelName("alert-rebuild"); + + await pm.dashboardList.menuItem("dashboards-item"); + await waitForDashboardPage(page); + + await pm.dashboardCreate.createDashboard(dashName); + + await pm.dashboardCreate.addPanel(); + await pm.dashboardPanelActions.addPanelName(panelName); + + await pm.chartTypeSelector.selectChartType("bar"); + await pm.chartTypeSelector.selectStreamType("logs"); + await pm.chartTypeSelector.selectStream("e2e_automate"); + await pm.chartTypeSelector.searchAndAddField( + "kubernetes_container_name", + "y" + ); + + await pm.dashboardMultiSQL.addQueryTab(1); + await pm.chartTypeSelector.selectStreamType("logs"); + await pm.chartTypeSelector.selectStream("e2e_automate"); + await pm.chartTypeSelector.searchAndAddField( + "kubernetes_namespace_name", + "y" + ); + + const streamPromise = waitForStreamComplete(page); + await pm.dashboardPanelActions.applyDashboardBtn(); + await streamPromise; + await pm.dashboardPanelActions.waitForChartToRender(); + + const dashboardStreamPromise = waitForStreamComplete(page, 30000); + await pm.dashboardPanelActions.savePanel(); + await dashboardStreamPromise; + await pm.dashboardPanelActions + .getChartRendererCanvasElement() + .first() + .waitFor({ state: "visible", timeout: 15000 }); + + await pm.dashboardPanelEdit.getPanelContainer().first().hover(); + await pm.dashboardPanelEdit.createAlertFromPanelMenu(panelName); + + await pm.createAlertFromSourceDialog.expectDialogVisible(); + const initialText = + await pm.createAlertFromSourceDialog.getPickedQueryText(); + expect(initialText).not.toBe(""); + + await pm.createAlertFromSourceDialog.selectQuery(1); + + // The picked-query display follows the newly chosen query. + await expect + .poll( + async () => + await pm.createAlertFromSourceDialog.getPickedQueryText(), + { timeout: 15000 } + ) + .not.toBe(initialText); + + await pm.createAlertFromSourceDialog.clickCancel(); + await pm.createAlertFromSourceDialog.expectDialogHidden(); + + await pm.dashboardCreate.backToDashboardList(); + await deleteDashboard(page, dashName); + + testLogger.info("Test completed: query picker rebuild"); + } + ); + + test( + "should close the confirm dialog on Cancel and stay on the dashboard", + { tag: ["@create-alert-from-panel", "@alerts", "@all", "@P1"] }, + async ({ page }) => { + testLogger.info("Testing Cancel on the confirm dialog"); + + const dashName = + "Dashboard_Alert_Cancel_" + Math.random().toString(36).slice(2, 11); + const panelName = + pm.dashboardPanelActions.generateUniquePanelName("alert-cancel"); + + await pm.dashboardList.menuItem("dashboards-item"); + await waitForDashboardPage(page); + + await pm.dashboardCreate.createDashboard(dashName); + + await pm.dashboardCreate.addPanel(); + await pm.dashboardPanelActions.addPanelName(panelName); + + await pm.chartTypeSelector.selectChartType("bar"); + await pm.chartTypeSelector.selectStreamType("logs"); + await pm.chartTypeSelector.selectStream("e2e_automate"); + await pm.chartTypeSelector.searchAndAddField( + "kubernetes_container_name", + "y" + ); + + await pm.dashboardMultiSQL.addQueryTab(1); + await pm.chartTypeSelector.selectStreamType("logs"); + await pm.chartTypeSelector.selectStream("e2e_automate"); + await pm.chartTypeSelector.searchAndAddField( + "kubernetes_namespace_name", + "y" + ); + + const streamPromise = waitForStreamComplete(page); + await pm.dashboardPanelActions.applyDashboardBtn(); + await streamPromise; + await pm.dashboardPanelActions.waitForChartToRender(); + + const dashboardStreamPromise = waitForStreamComplete(page, 30000); + await pm.dashboardPanelActions.savePanel(); + await dashboardStreamPromise; + await pm.dashboardPanelActions + .getChartRendererCanvasElement() + .first() + .waitFor({ state: "visible", timeout: 15000 }); + + await pm.dashboardPanelEdit.getPanelContainer().first().hover(); + await pm.dashboardPanelEdit.createAlertFromPanelMenu(panelName); + + await pm.createAlertFromSourceDialog.expectDialogVisible(); + await pm.createAlertFromSourceDialog.clickCancel(); + await pm.createAlertFromSourceDialog.expectDialogHidden(); + + // No route change: still on the dashboard, not the alert form. + expect(page.url()).not.toContain("alerts/add"); + + await pm.dashboardCreate.backToDashboardList(); + await deleteDashboard(page, dashName); + + testLogger.info("Test completed: Cancel closes the confirm dialog"); + } + ); + + test( + "should disable the Create Alert menu item with a reason when the panel has no visible query", + { tag: ["@create-alert-from-panel", "@alerts", "@all", "@P1"] }, + async ({ page }) => { + testLogger.info("Testing disabled Create Alert menu item (no visible query)"); + + const dashName = + "Dashboard_Alert_Dsb_" + Math.random().toString(36).slice(2, 11); + const panelName = + pm.dashboardPanelActions.generateUniquePanelName("alert-disabled"); + + await pm.dashboardList.menuItem("dashboards-item"); + await waitForDashboardPage(page); + + await pm.dashboardCreate.createDashboard(dashName); + + await pm.dashboardCreate.addPanel(); + await pm.dashboardPanelActions.addPanelName(panelName); + + await pm.chartTypeSelector.selectChartType("bar"); + await pm.chartTypeSelector.selectStreamType("logs"); + await pm.chartTypeSelector.selectStream("e2e_automate"); + await pm.chartTypeSelector.searchAndAddField( + "kubernetes_container_name", + "y" + ); + + // The visibility toggle only appears from the second query onwards, so + // add a second query and hide BOTH to leave the panel with no visible query. + await pm.dashboardMultiSQL.addQueryTab(1); + await pm.chartTypeSelector.selectStreamType("logs"); + await pm.chartTypeSelector.selectStream("e2e_automate"); + await pm.chartTypeSelector.searchAndAddField( + "kubernetes_namespace_name", + "y" + ); + + const streamPromise = waitForStreamComplete(page); + await pm.dashboardPanelActions.applyDashboardBtn(); + await streamPromise; + await pm.dashboardPanelActions.waitForChartToRender(); + + // Hide both queries so the saved panel has zero visible queries. + await pm.dashboardMultiSQL.toggleQueryVisibility(0); + await pm.dashboardMultiSQL.toggleQueryVisibility(1); + + const dashboardStreamPromise = waitForStreamComplete(page, 30000); + await pm.dashboardPanelActions.savePanel(); + await dashboardStreamPromise; + + await pm.dashboardPanelEdit.getPanelContainer().first().hover(); + await pm.dashboardPanelActions.getEditPanelDropdown(panelName).click(); + + const createAlertItem = pm.dashboardPanelEdit.getCreateAlertFromPanelItem(); + await expect(createAlertItem).toBeVisible({ timeout: 10000 }); + await expect(createAlertItem).toBeDisabled(); + await expect( + pm.dashboardPanelEdit.getCreateAlertFromPanelReason() + ).toHaveText("This panel has no queries to create an alert from"); + + await pm.dashboardCreate.backToDashboardList(); + await deleteDashboard(page, dashName); + + testLogger.info("Test completed: disabled Create Alert menu item"); + } + ); + + test.fixme( + "should block Continue in the confirm dialog when the prefill carries an unresolved-query warning — deferred: unresolved-variable panel setup unproven in CI (fromPanel.ts:293-295)", + { tag: ["@create-alert-from-panel", "@alerts", "@all", "@P2"] }, + async ({ page }) => { + // A panel query referencing an unresolved dashboard variable ($var) with + // no executedQuery produces a blocking `unresolvedQuery` warning, which + // disables Continue. Constructing that panel deterministically (a $var + // that does not resolve) has no proven helper in the suite yet. + testLogger.info("Testing blocking warning disables Continue (deferred)"); + + await pm.createAlertFromSourceDialog.expectWarningVisible("unresolvedQuery"); + await pm.createAlertFromSourceDialog.expectContinueDisabled(); + } + ); +});