test: e2e coverage for the dashboard panel editor live preview (#15407)

This commit is contained in:
ktx-akshay
2026-10-16 06:58:36 +00:00
committed by GitHub
parent b721385f6a
commit a56bc4c0ae
5 changed files with 424 additions and 0 deletions
@@ -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",
@@ -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 });
}
}
+2
View File
@@ -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);
@@ -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 <http://www.gnu.org/licenses/>.
// 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');
});
});
@@ -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}"`)),
};
}