diff --git a/tests/ui-testing/ci-matrix/ci_matrix.json b/tests/ui-testing/ci-matrix/ci_matrix.json index 27a3520cb0..d844a1d91f 100644 --- a/tests/ui-testing/ci-matrix/ci_matrix.json +++ b/tests/ui-testing/ci-matrix/ci_matrix.json @@ -107,6 +107,7 @@ "crossLinkMultiStream.spec.js", "dashboards-form-validation.spec.js", "dashboard-panel-unsaved-changes.spec.js", + "dashboard-panel-live-preview.spec.js", "dashboard-refresh-without-cache.spec.js", "dashboard-print-layout.spec.js", "dashboard-layout-edit-mode.spec.js", diff --git a/tests/ui-testing/pages/dashboardPages/dashboard-live-preview.js b/tests/ui-testing/pages/dashboardPages/dashboard-live-preview.js new file mode 100644 index 0000000000..7e8ce1c264 --- /dev/null +++ b/tests/ui-testing/pages/dashboardPages/dashboard-live-preview.js @@ -0,0 +1,212 @@ +// Backs dashboard-panel-live-preview.spec.js (openobserve#15214). +import { expect } from "@playwright/test"; +import { + ORG, + createDashboard, + pinAutoQuery, + trackPanelRuns, +} from "../../playwright-tests/utils/dashboard-auto-run-fixtures.js"; + +const AUTO_RUN_STORAGE_KEY = "oo_toggle_auto_run"; +// Longer than the 600ms live-preview debounce plus a panel request round trip. +const QUIET_MS = 3000; + +export default class DashboardLivePreview { + constructor(page) { + this.page = page; + this.applyBtn = page.locator('[data-test="dashboard-apply"]'); + this.cancelBtn = page.locator('[data-test="dashboard-cancel"]'); + this.applyOptionsBtn = page.locator('[data-test="dashboard-apply-options-btn"]'); + this.autoRunToggle = page.locator('[data-test="dashboard-panel-auto-run-toggle-btn"]'); + this.menuItems = page.getByRole("menuitem"); + this.tooltip = page.locator('[data-test="o-tooltip-content"]'); + this.streamSelect = page.locator('[data-test="index-dropdown-stream"]'); + this.chartRenderer = page.locator('[data-test="chart-renderer"]'); + this.dateTimeBtn = page.locator('[data-test="date-time-btn"]'); + this.relativeTab = page.locator('[data-test="date-time-relative-tab"]'); + this.pickerApplyBtn = page.locator('[data-test="date-time-apply-btn"]'); + this.panelRuns = null; + } + + relativeOption(value, unit) { + return this.page.locator(`[data-test="date-time-relative-${value}-${unit}-btn"]`); + } + + async pinAutoQuery(enabled) { + await pinAutoQuery(this.page, enabled); + } + + async setStoredAutoRun(value) { + await this.page.evaluate(([key, v]) => localStorage.setItem(key, v), [AUTO_RUN_STORAGE_KEY, value]); + } + + async getStoredAutoRun() { + return this.page.evaluate((key) => localStorage.getItem(key), AUTO_RUN_STORAGE_KEY); + } + + async rememberPanelStream(stream, streamType = "logs") { + await this.page.evaluate( + ([org, type, name]) => { + localStorage.setItem(`oo_dashboard_panel_stream_type_${org}`, type); + localStorage.setItem(`oo_dashboard_panel_stream_${type}_${org}`, name); + }, + [ORG(), streamType, stream] + ); + } + + async getRememberedPanelStream(streamType = "logs") { + return this.page.evaluate( + ([org, type]) => localStorage.getItem(`oo_dashboard_panel_stream_${type}_${org}`), + [ORG(), streamType] + ); + } + + async createEmptyDashboard(name) { + return createDashboard(`${name}_${Date.now()}_${Math.floor(Math.random() * 1e4)}`, { panels: [] }); + } + + // Run tracking starts before the editor mounts so the new panel's first run is counted. + async openNewPanel(dashboardId) { + this.panelRuns ??= trackPanelRuns(this.page); + await this.page.goto( + `/web/dashboards/add_panel?org_identifier=${ORG()}&dashboard=${dashboardId}&folder=default&tab=default`, + { waitUntil: "domcontentloaded" } + ); + await this.applyBtn.or(this.cancelBtn).first().waitFor({ state: "visible", timeout: 30000 }); + } + + // Auto Run and the remembered stream are seeded before the editor mounts, so the new panel opens on `stream`. + async openNewPanelOn(stream, { autoQueryEnabled = true, autoRun = "true" } = {}) { + await this.pinAutoQuery(autoQueryEnabled); + await this.setStoredAutoRun(autoRun); + await this.rememberPanelStream(stream); + const dashboardId = await this.createEmptyDashboard("live_preview"); + await this.openNewPanel(dashboardId); + await this.expectSelectedStream(stream); + return dashboardId; + } + + runsFrom(stream) { + return this.panelRuns.from(stream); + } + + async waitForRuns(stream, count) { + await expect.poll(() => this.runsFrom(stream).length, { timeout: 30000 }).toBe(count); + } + + // The run count is read once the editor stops querying, so in-flight runs are not missed. + async settledRunCount(stream, { atLeast = 0 } = {}) { + await expect.poll(() => this.runsFrom(stream).length, { timeout: 30000 }).toBeGreaterThanOrEqual(atLeast); + await this.expectApplyIdle(); + let last = -1; + await expect + .poll( + () => { + const now = this.runsFrom(stream).length; + const stable = now === last; + last = now; + return stable; + }, + { timeout: 30000, intervals: [1500] } + ) + .toBe(true); + return last; + } + + async expectNoNewRun(stream, count) { + // Absence has no event to wait on: a run, if any, starts after the debounce. + await this.page.waitForTimeout(QUIET_MS); + expect(this.runsFrom(stream)).toHaveLength(count); + } + + async expectLastRunContains(stream, text) { + const runs = this.runsFrom(stream); + expect(runs[runs.length - 1]).toContain(text); + } + + async expectApplyIdle() { + await expect(this.applyBtn).toBeVisible({ timeout: 30000 }); + await expect(this.applyBtn).toBeEnabled({ timeout: 30000 }); + } + + async expectApplyVariant(variant) { + await expect(this.applyBtn).toHaveAttribute("data-o2-variant", variant, { timeout: 15000 }); + } + + // While a run is in flight enterprise swaps Apply for Cancel, so either button is checked. + async expectApplyNotPending() { + await expect(this.applyBtn.or(this.cancelBtn).first()).not.toHaveAttribute("data-o2-variant", "warning"); + } + + // A hover that lands while the query editor still holds focus can miss the tooltip, so it is re-hovered. + async expectApplyTooltip(text) { + await expect(async () => { + await this.page.mouse.move(0, 0); + await this.applyBtn.hover(); + await expect(this.tooltip.filter({ hasText: text })).toBeVisible({ timeout: 2000 }); + }).toPass({ timeout: 15000 }); + await this.page.mouse.move(0, 0); + } + + async clickApply() { + await this.applyBtn.click(); + } + + async openApplyOptions() { + await this.applyOptionsBtn.click(); + await this.menuItems.first().waitFor({ state: "visible", timeout: 5000 }); + } + + async closeMenu() { + await this.page.keyboard.press("Escape"); + await expect(this.menuItems).toHaveCount(0, { timeout: 5000 }); + } + + async toggleAutoRun() { + await this.openApplyOptions(); + await this.autoRunToggle.click(); + await expect(this.autoRunToggle).toBeHidden({ timeout: 5000 }); + } + + async expectAutoRunToggleLabel(label) { + await this.openApplyOptions(); + await expect(this.autoRunToggle).toContainText(label); + await this.closeMenu(); + } + + // OSS hides the caret without Auto Run; enterprise keeps it for "refresh cache" only. + async expectNoAutoRunToggle() { + if ((await this.applyOptionsBtn.count()) === 0) return; + await this.openApplyOptions(); + await expect(this.autoRunToggle).toHaveCount(0); + await this.closeMenu(); + } + + async expectSelectedStream(stream) { + await expect(this.streamSelect).toContainText(stream, { timeout: 30000 }); + } + + async expectChartRendered() { + await expect(this.chartRenderer.first()).toBeVisible({ timeout: 30000 }); + } + + async pickRelativeTime(value, unit) { + await this.dateTimeBtn.click(); + await this.relativeTab.waitFor({ state: "visible", timeout: 10000 }); + await this.relativeTab.click(); + await this.relativeOption(value, unit).click(); + } + + async expectNoPickerApply() { + await expect(this.pickerApplyBtn).toHaveCount(0); + } + + async closeTimePicker() { + await this.page.keyboard.press("Escape"); + await expect(this.relativeTab).toBeHidden({ timeout: 5000 }); + } + + async expectTimePickerLabel(text) { + await expect(this.dateTimeBtn).toContainText(text, { timeout: 10000 }); + } +} diff --git a/tests/ui-testing/pages/page-manager.js b/tests/ui-testing/pages/page-manager.js index 4bf44d5511..e612aa04ce 100644 --- a/tests/ui-testing/pages/page-manager.js +++ b/tests/ui-testing/pages/page-manager.js @@ -10,6 +10,7 @@ import DashboardSetting from "./dashboardPages/dashboard-settings"; import DashboardVariables from "./dashboardPages/dashboard-variables"; import DashboardVariablesScoped from "./dashboardPages/dashboard-variables-scoped.js"; import DashboardAutoRun from "./dashboardPages/dashboard-auto-run.js"; +import DashboardLivePreview from "./dashboardPages/dashboard-live-preview.js"; import ChartTypeSelector from "./dashboardPages/dashboard-chart"; import DashboardSqlAutocomplete from "./dashboardPages/dashboard-sql-autocomplete"; import DashboardPromQLEditor from "./dashboardPages/dashboard-promql-editor"; @@ -177,6 +178,7 @@ class PageManager { this.dashboardVariables = new DashboardVariables(page); this.dashboardVariablesScoped = new DashboardVariablesScoped(page); this.dashboardAutoRun = new DashboardAutoRun(page); + this.dashboardLivePreview = new DashboardLivePreview(page); this.chartTypeSelector = new ChartTypeSelector(page); this.dashboardSqlAutocomplete = new DashboardSqlAutocomplete(page); this.dashboardPromQLEditor = new DashboardPromQLEditor(page); diff --git a/tests/ui-testing/playwright-tests/Dashboards/dashboard-panel-live-preview.spec.js b/tests/ui-testing/playwright-tests/Dashboards/dashboard-panel-live-preview.spec.js new file mode 100644 index 0000000000..b68ce9dcec --- /dev/null +++ b/tests/ui-testing/playwright-tests/Dashboards/dashboard-panel-live-preview.spec.js @@ -0,0 +1,208 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Live chart preview in the dashboard panel editor (openobserve#15214). +const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js'); +const testLogger = require('../utils/test-logger.js'); +const { + STREAM, + NAMESPACE_FIELD, + deleteCreatedDashboards, +} = require('../utils/dashboard-auto-run-fixtures.js'); +import PageManager from '../../pages/page-manager.js'; +import { ingestion } from './utils/dashIngestion.js'; + +// A second fixed stream, so a saved panel's stream differs from the one every test starts on. +const OTHER_STREAM = 'e2e_live_preview'; +const TOOLTIP = { + refresh: 'Run the query again', + pending: 'Changes not run yet', + typed: 'Run the edited query', + incomplete: 'Finish the builder fields', +}; + +// Same aliases as the builder's default chart, so the typed query keeps the panel's axes valid. +const TYPED_SQL = `SELECT histogram(_timestamp) as "x_axis_1", count(_timestamp) as "y_axis_1" FROM "${STREAM}" WHERE ${NAMESPACE_FIELD} IS NOT NULL GROUP BY x_axis_1 ORDER BY x_axis_1 ASC`; + +test.describe('Dashboard panel editor live preview', { tag: ['@dashboards', '@panelLivePreview', '@all'] }, () => { + test.describe.configure({ mode: 'parallel' }); + let pm; + let lp; + + test.beforeEach(async ({ page }, testInfo) => { + testLogger.testStart(testInfo.title, testInfo.file); + await navigateToBase(page); + await ingestion(page); + pm = new PageManager(page); + lp = pm.dashboardLivePreview; + }); + + test.afterAll(async () => { + await deleteCreatedDashboards(); + }); + + + test('Auto Run on: a new panel opens on the remembered stream and renders without Apply', { tag: ['@smoke', '@P0'] }, async ({ page }) => { + await lp.openNewPanelOn(STREAM); + + await lp.settledRunCount(STREAM, { atLeast: 1 }); + await lp.expectChartRendered(); + await lp.expectApplyVariant('primary'); + await lp.expectApplyTooltip(TOOLTIP.refresh); + }); + + test('Auto Run on: a builder edit re-runs the chart once and Apply never turns amber', { tag: ['@P0'] }, async ({ page }) => { + await lp.openNewPanelOn(STREAM); + const base = await lp.settledRunCount(STREAM, { atLeast: 1 }); + testLogger.info('Initial runs settled', { base }); + + await pm.chartTypeSelector.searchAndAddField(NAMESPACE_FIELD, 'b'); + await lp.expectApplyNotPending(); + + await lp.waitForRuns(STREAM, base + 1); + await lp.expectLastRunContains(STREAM, NAMESPACE_FIELD); + await lp.expectNoNewRun(STREAM, base + 1); + await lp.expectApplyVariant('primary'); + }); + + test('Auto Run off: the new panel and every builder edit wait for Apply', { tag: ['@P0'] }, async ({ page }) => { + await lp.openNewPanelOn(STREAM, { autoRun: 'false' }); + + await lp.expectNoNewRun(STREAM, 0); + await lp.expectApplyVariant('warning'); + await lp.expectApplyTooltip(TOOLTIP.pending); + + await lp.clickApply(); + await lp.waitForRuns(STREAM, 1); + await lp.expectApplyIdle(); + await lp.expectApplyVariant('primary'); + + await pm.chartTypeSelector.searchAndAddField(NAMESPACE_FIELD, 'b'); + await lp.expectApplyVariant('warning'); + await lp.expectNoNewRun(STREAM, 1); + + await lp.clickApply(); + await lp.waitForRuns(STREAM, 2); + await lp.expectLastRunContains(STREAM, NAMESPACE_FIELD); + await lp.expectApplyIdle(); + await lp.expectApplyVariant('primary'); + }); + + test('typed custom SQL never runs on its own and runs on Apply', { tag: ['@P1'] }, async ({ page }) => { + await lp.openNewPanelOn(STREAM); + const base = await lp.settledRunCount(STREAM, { atLeast: 1 }); + + await pm.chartTypeSelector.setCustomSQL(TYPED_SQL); + await lp.expectNoNewRun(STREAM, base); + await lp.expectApplyVariant('warning'); + await lp.expectApplyTooltip(TOOLTIP.typed); + + await lp.clickApply(); + await lp.waitForRuns(STREAM, base + 1); + await lp.expectLastRunContains(STREAM, 'IS NOT NULL'); + await lp.expectApplyIdle(); + await lp.expectApplyVariant('primary'); + }); + + test('a half-built filter holds the live run until it is removed', { tag: ['@P1'] }, async ({ page }) => { + await lp.openNewPanelOn(STREAM); + const base = await lp.settledRunCount(STREAM, { atLeast: 1 }); + + await pm.chartTypeSelector.searchAndAddField(NAMESPACE_FIELD, 'filter'); + await lp.expectNoNewRun(STREAM, base); + await lp.expectApplyVariant('warning'); + await lp.expectApplyTooltip(TOOLTIP.incomplete); + + await pm.dashboardFilter.clickRemoveCondition(); + await lp.expectApplyVariant('primary'); + await lp.expectNoNewRun(STREAM, base); + }); + + test('the Apply menu toggles Auto Run, and turning it back on runs the pending edit', { tag: ['@P1'] }, async ({ page }) => { + await lp.openNewPanelOn(STREAM); + const base = await lp.settledRunCount(STREAM, { atLeast: 1 }); + + await lp.expectAutoRunToggleLabel('Turn off Auto Run'); + await lp.toggleAutoRun(); + expect(await lp.getStoredAutoRun()).toBe('false'); + await lp.expectAutoRunToggleLabel('Turn on Auto Run'); + + await pm.chartTypeSelector.searchAndAddField(NAMESPACE_FIELD, 'b'); + await lp.expectApplyVariant('warning'); + await lp.expectNoNewRun(STREAM, base); + + await lp.toggleAutoRun(); + expect(await lp.getStoredAutoRun()).toBe('true'); + await lp.waitForRuns(STREAM, base + 1); + await lp.expectLastRunContains(STREAM, NAMESPACE_FIELD); + await lp.expectApplyIdle(); + await lp.expectApplyVariant('primary'); + }); + + test('with ZO_AUTO_QUERY_ENABLED off there is no Auto Run toggle and edits wait for Apply', { tag: ['@P1'] }, async ({ page }) => { + await lp.openNewPanelOn(STREAM, { autoQueryEnabled: false }); + + await lp.expectNoNewRun(STREAM, 0); + await lp.expectApplyVariant('warning'); + await lp.expectNoAutoRunToggle(); + + await lp.clickApply(); + await lp.waitForRuns(STREAM, 1); + await lp.expectApplyIdle(); + + await pm.chartTypeSelector.searchAndAddField(NAMESPACE_FIELD, 'b'); + await lp.expectApplyVariant('warning'); + await lp.expectNoNewRun(STREAM, 1); + }); + + test('saving a panel remembers its stream for the next new panel', { tag: ['@P1'] }, async ({ page }) => { + await ingestion(page, OTHER_STREAM); + const dashboardId = await lp.openNewPanelOn(STREAM); + await lp.settledRunCount(STREAM, { atLeast: 1 }); + + await pm.chartTypeSelector.selectStream(OTHER_STREAM); + await lp.expectSelectedStream(OTHER_STREAM); + await lp.settledRunCount(OTHER_STREAM, { atLeast: 1 }); + + await pm.dashboardPanelActions.addPanelName(`live_preview_${Date.now()}`); + await pm.dashboardPanelActions.savePanel(); + await expect(page).not.toHaveURL(/add_panel/, { timeout: 30000 }); + expect(await lp.getRememberedPanelStream()).toBe(OTHER_STREAM); + + const before = lp.runsFrom(OTHER_STREAM).length; + await lp.openNewPanel(dashboardId); + await lp.expectSelectedStream(OTHER_STREAM); + await expect.poll(() => lp.runsFrom(OTHER_STREAM).length, { timeout: 30000 }).toBeGreaterThan(before); + }); + + test('Auto Run off: the time picker has no Apply of its own and a new range waits for the panel Apply', { tag: ['@P2'] }, async ({ page }) => { + await lp.openNewPanelOn(STREAM, { autoRun: 'false' }); + await lp.clickApply(); + await lp.waitForRuns(STREAM, 1); + await lp.expectApplyIdle(); + await lp.expectApplyVariant('primary'); + + await lp.pickRelativeTime(30, 'm'); + await lp.expectNoPickerApply(); + await lp.closeTimePicker(); + await lp.expectApplyVariant('warning'); + await lp.expectNoNewRun(STREAM, 1); + + await lp.clickApply(); + await lp.waitForRuns(STREAM, 2); + await lp.expectApplyIdle(); + await lp.expectApplyVariant('primary'); + }); +}); diff --git a/tests/ui-testing/playwright-tests/utils/dashboard-auto-run-fixtures.js b/tests/ui-testing/playwright-tests/utils/dashboard-auto-run-fixtures.js index a419f61512..1325d8d7db 100644 --- a/tests/ui-testing/playwright-tests/utils/dashboard-auto-run-fixtures.js +++ b/tests/ui-testing/playwright-tests/utils/dashboard-auto-run-fixtures.js @@ -128,6 +128,7 @@ function trackPanelRuns(page) { }); return { of: (key) => runs.filter((sql) => sql.includes(`"${key}_cnt"`)), + from: (stream) => runs.filter((sql) => sql.includes(`"${stream}"`)), }; }