mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 00:08:55 +00:00
test: e2e coverage for the dashboard panel editor live preview (#15407)
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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}"`)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user