Files
openobserve/tests/ui-testing/pages/dashboardPages/visualise.js
T

879 lines
33 KiB
JavaScript

//logs visualise page object
//Methods: openLogs, openVisualiseTab, logsApplyQueryButton, Visualize run query button, setRelative, searchAndAddField, showQueryToggle, enableSQLMode, streamIndexList, logsSelectStream, logsToggle, selectChartType, removeField, chartRender, backToLogs, openQueryEditor, fillQueryEditor
import { expect } from "@playwright/test";
import DateTimeHelper from "./dashboard-time.js";
import { selectLogsViewMode } from "../commonActions.js";
import { LogsPage } from "../logsPages/logsPage.js";
import testLogger from "../../playwright-tests/utils/test-logger.js";
// Long enough for the empty-state overlay to reappear if the panel is genuinely empty.
const quietPeriodProbeMs = 3000;
export default class LogsVisualise {
constructor(page) {
this.page = page;
this.timeTab = page.locator('[data-test="date-time-btn"]');
this.relativeTime = page.locator('[data-test="date-time-relative-tab"]');
this.indexListDropdown = page.locator(
'[data-test="log-search-index-list-select-stream"]'
);
this.functionEditor = page.locator(
'[data-test="logs-vrl-function-editor"]'
);
this.queryEditor = page
.locator('[data-test="logs-vrl-function-editor"]')
.locator(".inputarea")
.first();
// Dashboard locators
this.addToDashboardBtn = page.locator('[data-test="panel-editor-add-to-dashboard-btn"]');
this.newDashboardBtn = page.locator(
'[data-test="dashboard-dashboard-new-add"]'
);
this.dashboardNameInput = page.locator('[data-test="add-dashboard-name-field"]');
// Inner "New dashboard" ODrawer's Save action — the legacy
// `dashboard-add-submit` button was removed when AddDashboard.vue was
// migrated to ODrawer (SelectDashboardDropdown now hosts the form and
// the footer primary button calls addDashboardRef?.submit()).
this.dashboardSubmitBtn = page.locator(
'[data-test="dashboard-dashboard-add-dialog"] [data-test="o-dialog-primary-btn"]'
);
this.panelTitleInput = page.locator(
'[data-test="metrics-new-dashboard-panel-title-field"]'
);
// Outer "Add to Dashboard" ODrawer's Add action — the legacy
// `metrics-schema-update-settings-button` was removed when AddToDashboard.vue
// migrated to ODrawer (parent slug: add-to-dashboard-dialog).
this.updateSettingsBtn = page.locator(
'[data-test="add-to-dashboard-dialog"] [data-test="o-dialog-primary-btn"]'
);
// Query editor locators
this.logsQueryEditor = page
.locator('[data-test="logs-search-bar-query-editor"]');
//Functions
}
async openLogs() {
// Open Logs Page
await this.page.locator('[data-test="menu-link-\\/logs-item"]').click();
}
async openVisualiseTab() {
// Started before the toggle so no request is missed; a Run clicked while result_schema is still deciding the chart leaves the panel empty.
const pipelineIdle = this.waitForVisualizePipelineIdle();
await selectLogsViewMode(this.page, "visualize");
await pipelineIdle;
// Wait for the panel editor container to load (async component)
// It may be hidden if there's a query error, so wait for attached (in DOM)
await this.page.locator('[data-test="panel-editor-container"]').waitFor({
state: "attached",
timeout: 30000,
});
}
// Visualize tab's root element. Rendered under v-show (Index.vue), so it is
// always attached but only *visible* when the Visualize tab is actually active.
getPanelEditorContainer() {
return this.page.locator('[data-test="panel-editor-container"]');
}
// Open visualise tab and ensure table chart is selected when VRL is present
async openVisualiseTabWithVrl() {
await this.openVisualiseTab();
await this.ensureTableRendered();
}
// Opening the visualise tab decides the chart type asynchronously off the result_schema response (auto-selection first, then the VRL table override), so nothing may touch the panel until that traffic has finished and stayed quiet, or the chart type still changes under the test.
async waitForVisualizePipelineIdle(quietMs = 2500, timeout = 60000) {
const isPipelineUrl = (url) =>
url.includes("/result_schema") || url.includes("/_search");
let inFlight = 0;
let sawRequest = false;
let lastEvent = Date.now();
const onRequest = (request) => {
if (!isPipelineUrl(request.url())) return;
inFlight += 1;
sawRequest = true;
lastEvent = Date.now();
};
const onRequestSettled = (request) => {
if (!isPipelineUrl(request.url())) return;
inFlight = Math.max(inFlight - 1, 0);
lastEvent = Date.now();
};
this.page.on("request", onRequest);
this.page.on("requestfinished", onRequestSettled);
this.page.on("requestfailed", onRequestSettled);
try {
const deadline = Date.now() + timeout;
// A visualize tab that reuses cached results issues no request at all, so stop waiting for one.
const firstRequestDeadline = Date.now() + 15000;
while (Date.now() < deadline) {
const idle = inFlight === 0 && Date.now() - lastEvent >= quietMs;
if (idle && (sawRequest || Date.now() >= firstRequestDeadline)) return;
await this.page.waitForTimeout(250);
}
testLogger.warn(`Visualize pipeline still busy after ${timeout}ms; continuing`, { inFlight });
} finally {
this.page.off("request", onRequest);
this.page.off("requestfinished", onRequestSettled);
this.page.off("requestfailed", onRequestSettled);
}
}
// The chart-type auto-selection wins over a table click issued while it is still running, and it can land even after the request wait above gives up (a loaded CI run can delay the tab's first request past that cap) - so re-select until the table renderer is on screen and has stayed there. VRL only works with the table chart type, so every caller needs that end state.
async ensureTableRendered({ timeout = 60000, quietMs = 1500 } = {}) {
const tableChartItem = this.getChartTypeItem("table");
const tablePanel = this.getTablePanel();
await tableChartItem.waitFor({ state: "visible", timeout: 10000 });
const deadline = Date.now() + timeout;
let renderedSince = null;
while (Date.now() < deadline) {
const isSelected =
(await tableChartItem.getAttribute("data-selected").catch(() => null)) === "true";
const isRendered = await tablePanel.isVisible().catch(() => false);
if (!isSelected) {
await tableChartItem.click({ timeout: 5000 }).catch(() => {});
renderedSince = null;
} else if (!isRendered) {
renderedSince = null;
} else if (renderedSince === null) {
renderedSince = Date.now();
} else if (Date.now() - renderedSince >= quietMs) {
return;
}
await this.page.waitForTimeout(250);
}
throw new Error(
"Visualise tab never settled on a rendered table chart (VRL requires the table type)"
);
}
//Apply: Logs
async logsApplyQueryButton() {
await this.page
.locator('[data-test="logs-search-bar-refresh-btn"]')
.click();
}
// Wait for logs query to fully complete (similar to waitForChartToRender for dashboard)
async waitForLogsQueryToComplete(timeout = 30000) {
const runBtn = this.page.locator('[data-test="logs-search-bar-refresh-btn"]');
// Wait for button to be visible first
await runBtn.waitFor({ state: "visible", timeout: 10000 });
// Wait for query execution to complete by checking button state
// When query is running, the button may change to cancel or show loading state
// When complete, the Run query button should be enabled and visible again
await this.page.waitForFunction(
() => {
const btn = document.querySelector('[data-test="logs-search-bar-refresh-btn"]');
if (!btn) return false;
// Check if button is not disabled and contains "Run query" text
const isEnabled = !btn.disabled && !btn.classList.contains('disabled');
const hasRunQueryText = btn.textContent?.includes('Run query');
// Also check that no loading indicator is present
const hasLoadingClass = btn.hasAttribute('disabled') || btn.getAttribute('aria-busy') === 'true';
return isEnabled && hasRunQueryText && !hasLoadingClass;
},
undefined,
{ timeout }
);
}
// Apply logs query and wait for completion
async logsApplyQueryAndWait(timeout = 30000) {
await this.logsApplyQueryButton();
await this.waitForLogsQueryToComplete(timeout);
}
//set relative time selection
async setRelative(date, time) {
// Wait for the date-time button to be enabled before interacting
await this.page.waitForSelector(
'[data-test="date-time-btn"]:not([disabled])',
{ timeout: 15000 }
);
await this.timeTab.click();
await this.relativeTime.click();
const selector = `[data-test="date-time-relative-${date}-${time}-btn"]`;
// Wait for the relative time button to be enabled (not just visible)
await this.page.waitForSelector(`${selector}:not([disabled])`, {
timeout: 15000,
});
await this.page.locator(selector).click();
await new LogsPage(this.page).closeDateTimePickerIfOpen();
}
//search and add fields
async searchAndAddField(fieldName, target) {
const searchInput = this.page.locator(
'[data-test="o-field-list-search-field"]'
);
await searchInput.waitFor({ state: "visible", timeout: 5000 });
await searchInput.click();
await searchInput.fill(fieldName);
const buttonSelectors = {
x: "dashboard-add-x-data",
y: "dashboard-add-y-data",
b: "dashboard-add-b-data",
filter: "dashboard-add-filter-data",
latitude: "dashboard-add-latitude-data",
longitude: "dashboard-add-longitude-data",
weight: "dashboard-add-weight-data",
z: "dashboard-add-z-data",
name: "dashboard-name-layout",
value: "dashboard-value_for_maps-layout",
firstcolumn: "dashboard-x-layout",
othercolumn: "dashboard-y-layout",
};
const buttonTestId = buttonSelectors[target];
if (!buttonTestId) {
throw new Error(`Invalid target type: ${target}`);
}
const button = this.page.locator(`[data-test="${buttonTestId}"]`);
// Ensure the button is visible before clicking
await button.waitFor({ state: "visible", timeout: 5000 });
await button.click();
}
//show query toggle
async showQueryToggle() {
await this.page
.locator('[data-test="logs-search-bar-show-query-toggle-btn"] img')
.click();
}
//enable SQL Mode
async enableSQLMode() {
// SQL mode toggle was removed from the UI. SQL mode is now auto-detected
// from query content — entering a SELECT query enables it automatically.
// This method is intentionally a no-op.
}
//stream index list
async streamIndexList() {
await this.page
.locator('[data-test="panel-editor-field-list-collapsed-icon"]')
.click();
}
//logs: Select stream
async logsSelectStream(stream) {
await this.page
.locator('[data-test="log-search-index-list-select-stream"]')
.click({ force: true });
const popover = this.page.locator(
'[data-test="log-search-index-list-select-stream-popover"]'
);
await popover.waitFor({ state: "visible", timeout: 10000 });
// OSelect virtualises past 50 options, so a target stream further down the
// list is not in the DOM at all — filter it in through the popover search.
const popoverSearch = popover.locator("input").first();
await popoverSearch.click();
await popoverSearch.fill(stream);
// OSelect forwards parent data-test to ListboxItems (`*-option`).
await popover
.locator('[data-test="log-search-index-list-select-stream-option"]', { hasText: stream })
.first()
.click();
}
//logs toggle
async logsToggle() {
await this.page.locator('[data-test="logs-logs-toggle"]').click();
}
//select chart type
async selectChartType(chartType) {
const chartOption = this.page.locator(
`[data-test="selected-chart-${chartType}-item"]`
);
await chartOption.waitFor({ state: "visible" });
await chartOption.click({ force: true });
}
//remove field
// @param alias - The field alias (e.g., "x_axis_1", "y_axis_1", "breakdown_1")
// @param target - The target type (x, y, b, filter, etc.)
async removeField(alias, target) {
const removeSelectors = {
x: "dashboard-x-item",
y: "dashboard-y-item",
b: "dashboard-b-item",
filter: "dashboard-filter-item",
latitude: "dashboard-latitude-item",
longitude: "dashboard-longitude-item",
weight: "dashboard-weight-item",
z: "dashboard-z-item",
name: "dashboard-name-layout",
value: "dashboard-value_for_maps-layout",
firstcolumn: "dashboard-x-layout",
othercolumn: "dashboard-y-layout",
};
const baseTestId = removeSelectors[target];
if (!baseTestId) {
throw new Error(`Invalid target type: ${target}`);
}
const removeButton = this.page.locator(
`[data-test="${baseTestId}-${alias}-remove"]`
);
await removeButton.waitFor({ state: "visible", timeout: 10000 });
await removeButton.click();
}
//chart render
async chartRender(x, y) {
await this.page
.locator('[data-test="chart-renderer"] canvas')
.last()
.waitFor({ state: "visible" });
await this.page
.locator('[data-test="chart-renderer"] canvas')
.last()
.click({
position: {
x: x,
y: y,
},
});
}
//back to logs
async backToLogs() {
await this.page.locator('[data-test="logs-logs-toggle"]').click();
}
//open query editor
async openQueryEditor() {
await this.page.locator('[data-test="logs-search-bar-query-editor"]').getByRole('code').click();
}
//fill query editor
async fillQueryEditor(sqlQuery) {
const queryEditor = this.page.locator('[data-test="logs-search-bar-query-editor"]');
await queryEditor.waitFor({ state: "visible", timeout: 5000 });
await queryEditor.getByRole('code').click();
await queryEditor.locator('.inputarea').fill(sqlQuery);
}
//fill logs query editor with SQL query
async fillLogsQueryEditor(sqlQuery) {
await this.logsQueryEditor.waitFor({ state: "visible", timeout: 5000 });
await this.logsQueryEditor.getByRole('code').click();
await this.logsQueryEditor.locator('.inputarea').fill(sqlQuery);
// An edit made while the stream is still loading never reaches searchObj, so Visualize and Add-to-dashboard would use an empty query.
await new LogsPage(this.page).waitForSearchQueryCommitted(sqlQuery);
}
// Open the first VRL Function Editor
async vrlFunctionEditor(vrl) {
const editorLines = this.functionEditor.first().locator(".view-lines");
const firstLine = vrl.split("\n")[0].trim();
// A fill that lands before the lazily-mounted Monaco model binds is discarded, leaving tempFunctionContent empty.
await expect(async () => {
const shown = ((await editorLines.textContent().catch(() => "")) || "").replace(/\s+/g, "");
if (!shown.includes(firstLine.replace(/\s+/g, ""))) {
await this.functionEditor.first().click();
await this.queryEditor.fill(vrl);
}
await expect(editorLines).toContainText(firstLine, { timeout: 2000 });
}).toPass({ timeout: 20000, intervals: [500, 1000, 2000] });
await new LogsPage(this.page).waitForVrlFunctionCommitted(vrl);
}
async runQueryAndWaitForCompletion({ expectTable = false } = {}) {
const runBtn = this.page.locator(
'[data-test="logs-search-bar-visualize-refresh-btn"]'
);
const cancelBtn = this.page.locator(
'[data-test="logs-search-bar-visualize-cancel-btn"]'
);
await runBtn.waitFor({ state: "visible" });
await runBtn.click();
// Wait for query to start (cancel btn appears) then complete (cancel btn disappears)
await cancelBtn.waitFor({ state: "visible", timeout: 5000 }).catch(() => {});
await cancelBtn.waitFor({ state: "hidden", timeout: 30000 }).catch(() => {});
// The cancel button is enterprise-gated (SearchBar.vue config.isEnterprise), so on OSS the waits above are no-ops that don't gate completion — wait for the result to actually render (the table specifically when the caller expects one, so a transient chart-renderer can't satisfy the gate early).
const resultSelector = expectTable
? '[data-test="dashboard-panel-table"]'
: '[data-test="chart-renderer"], [data-test="dashboard-panel-table"]';
await this.page.locator(resultSelector).first().waitFor({ state: "visible", timeout: 30000 }).catch(() => {});
}
// Helper function to check for dashboard errors
async checkDashboardErrors(page, chartTypeName) {
const dashboardErrorContainer = page.locator(
'[data-test="dashboard-error"]'
);
const errorContainerExists = await dashboardErrorContainer.count();
if (errorContainerExists === 0) {
return { hasErrors: false, errors: [], errorTextCount: 0, errorListCount: 0 };
}
const isErrorVisible = await dashboardErrorContainer.first().isVisible();
if (!isErrorVisible) {
return { hasErrors: false, errors: [], errorTextCount: 0, errorListCount: 0 };
}
const errors = [];
// Check for error indicator text
const errorText = page
.locator('[data-test="dashboard-error"]')
.getByText(/Errors \(\d+\)/);
const errorTextCount = await errorText.count();
if (errorTextCount > 0) {
const errorTextContent = await errorText.first().textContent();
errors.push(`Error indicator: ${errorTextContent}`);
}
// Check for error list items
const errorListItems = page.locator('[data-test="dashboard-error"] ul li');
const errorListCount = await errorListItems.count();
if (errorListCount > 0) {
for (let i = 0; i < errorListCount; i++) {
const errorItem = errorListItems.nth(i);
const errorItemText = await errorItem.textContent();
if (errorItemText && errorItemText.trim().length > 0) {
errors.push(`Error ${i + 1}: ${errorItemText.trim()}`);
}
}
}
return {
hasErrors: errors.length > 0,
errors,
errorTextCount,
errorListCount,
};
}
async verifyChartRenders(page) {
const chartRenderer = page.locator(
'[data-test="chart-renderer"], [data-test="dashboard-panel-table"]'
);
try {
// Wait for chart renderer to be present and visible
await chartRenderer.first().waitFor({
state: "visible",
timeout: 15000,
});
// Additional check to ensure the chart is actually rendered
const chartExists = await chartRenderer.count();
if (chartExists > 0) {
// Double-check visibility after waiting
const isVisible = await chartRenderer.first().isVisible();
if (!isVisible) {
// Wait for chart content to be rendered intelligently
const chartContentReady = await this.waitForChartContent(page, 3000);
if (!chartContentReady) {
// Fallback: try waiting for chart renderer to be stable
try {
await chartRenderer.first().waitFor({
state: "visible",
timeout: 2000,
});
} catch (fallbackError) {
throw new Error(
`Chart renderer present but content failed to load after intelligent waiting. Error: ${fallbackError.message}`
);
}
}
// Final visibility check after intelligent waiting
const isVisibleRetry = await chartRenderer.first().isVisible();
if (!isVisibleRetry) {
throw new Error(
"Chart renderer is present but not visible after intelligent retry"
);
}
}
}
return chartExists > 0;
} catch (error) {
// If waiting times out, check if element exists but is not visible
const chartExists = await chartRenderer.count();
if (chartExists > 0) {
const isVisible = await chartRenderer.first().isVisible();
throw new Error(
`Chart renderer found (${chartExists} elements) but visibility check failed. Is visible: ${isVisible}. Original error: ${error.message}`
);
} else {
throw new Error(
`No chart renderer found. Original error: ${error.message}`
);
}
}
}
// RECOMMENDED: Native browser function (fastest & most reliable)
async waitForChartContent(page, timeout = 5000) {
return page
.waitForFunction(
() =>
document.querySelector(
'[data-test="chart-renderer"], [data-test="dashboard-panel-table"]'
)?.offsetParent !== null,
{},
{ timeout }
)
.then(() => true)
.catch(() => false);
}
// Get quick mode toggle state using the most reliable method
async getQuickModeToggleState() {
// Open utilities dropdown to access the quick mode toggle
const utilitiesBtn = this.page.locator(
'[data-test="logs-search-bar-utilities-menu-btn"]'
);
await utilitiesBtn.waitFor({ state: "visible", timeout: 10000 });
await utilitiesBtn.click();
const quickModeSwitch = this.page.locator(
'[data-test="logs-search-bar-quick-mode-switch"]'
);
await quickModeSwitch.waitFor({ state: "visible", timeout: 10000 });
// OSwitch inner button carries aria-checked
const switchBtn = quickModeSwitch.locator('[data-test$="-btn"]');
const ariaChecked = await switchBtn.getAttribute("aria-checked");
// Close dropdown
await this.page.keyboard.press("Escape");
return ariaChecked === "true";
}
// Verify quick mode toggle state
async verifyQuickModeToggle(expectedState = true) {
const isChecked = await this.getQuickModeToggleState();
// Validate the toggle state matches expectation
if (expectedState !== isChecked) {
throw new Error(
`Expected quick mode to be ${expectedState} but actual state is ${isChecked}`
);
}
return isChecked;
}
// Toggle quick mode to specific state (enable/disable)
async setQuickModeToggle(enableState) {
const quickModeToggle = this.page.locator(
'[data-test="logs-search-bar-quick-mode-toggle-btn"]'
);
await quickModeToggle.waitFor({ state: "visible", timeout: 10000 });
const currentState = await this.getQuickModeToggleState();
// Only click if we need to change the state
if (currentState !== enableState) {
await quickModeToggle.click();
// Wait for state change to complete
await this.page.waitForTimeout(500);
// Verify the state actually changed
const newState = await this.getQuickModeToggleState();
if (newState !== enableState) {
throw new Error(
`Failed to set quick mode to ${enableState}. Current state: ${newState}`
);
}
}
return enableState;
}
// Enable quick mode if it's currently disabled
async enableQuickModeIfDisabled() {
return await this.setQuickModeToggle(true);
}
// Disable quick mode if it's currently enabled
async disableQuickModeIfEnabled() {
return await this.setQuickModeToggle(false);
}
// Helper function to verify chart type selection
async verifyChartTypeSelected(page, chartType, shouldBeSelected = true) {
const locator = page.locator(`[data-test="selected-chart-${chartType}-item"]`);
if (shouldBeSelected) {
await expect(locator).toHaveAttribute("data-selected", "true");
} else {
await expect(locator).toHaveAttribute("data-selected", "false");
}
}
async addPanelToNewDashboard(randomDashboardName, panelName) {
// Add to dashboard and submit it
await this.addToDashboardBtn.waitFor({ state: "visible", timeout: 5000 });
await this.addToDashboardBtn.click();
// Wait for the "Add to Dashboard" side panel to fully load. The old
// `schema-title-text` element was removed when AddToDashboard.vue migrated
// to ODrawer — use the drawer's parent slug instead.
const sidePanelTitle = this.page.locator('[data-test="add-to-dashboard-dialog"]');
await sidePanelTitle.waitFor({ state: "visible", timeout: 10000 });
await sidePanelTitle.waitFor({ state: "attached", timeout: 5000 });
// Wait for the "new dashboard" button to be visible and stable
await this.newDashboardBtn.waitFor({ state: "visible", timeout: 10000 });
await this.newDashboardBtn.waitFor({ state: "attached", timeout: 5000 });
// await this.page.waitForTimeout(500);
// Click new dashboard option
await this.newDashboardBtn.click();
// Wait for the "New dashboard" dialog to open and stabilize
await this.dashboardNameInput.waitFor({ state: "visible", timeout: 10000 });
await this.dashboardNameInput.waitFor({ state: "attached", timeout: 5000 });
// await this.page.waitForTimeout(500);
// Fill dashboard name
await this.dashboardNameInput.click();
await this.dashboardNameInput.fill(randomDashboardName);
// Submit dashboard creation
await this.dashboardSubmitBtn.waitFor({ state: "visible", timeout: 5000 });
await this.dashboardSubmitBtn.click();
// Wait for panel title input to be available (replacing hardcoded timeout)
await this.panelTitleInput.waitFor({ state: "visible", timeout: 10000 });
await this.panelTitleInput.click();
await this.panelTitleInput.fill(panelName);
// Submit panel settings
await this.updateSettingsBtn.waitFor({ state: "visible", timeout: 5000 });
await this.updateSettingsBtn.click();
}
//wait for query inspector to be visible
async waitForQueryInspector(page) {
const queryInspectorCloseBtn = page.locator('[data-test="query-inspector-dialog"] [data-test="o-dialog-close-btn"]');
await queryInspectorCloseBtn.waitFor({ state: "visible", timeout: 10000 });
}
async closeQueryInspector() {
const closeBtn = this.page.locator('[data-test="query-inspector-dialog"] [data-test="o-dialog-close-btn"]');
await closeBtn.click();
await this.page.locator('[data-test="query-inspector-dialog"]').waitFor({ state: 'hidden', timeout: 5000 });
}
// Verify toast message is visible with expected text
async verifyToastMessage(expectedText, timeout = 10000) {
const toast = this.page.locator('[data-test="o-toast-message"]', { hasText: expectedText }).first();
await expect(toast).toBeVisible({ timeout });
return toast;
}
// Locator for a saved panel's dropdown menu trigger
getPanelDropdown(panelName) {
return this.page.locator(`[data-test="dashboard-edit-panel-${panelName}-dropdown"]`);
}
// ── VRL visualization locator getters (used by visualize-vrl spec) ──────────
// Utilities menu button in the logs/visualize search bar (hosts the VRL toggle)
getUtilitiesMenuBtn() {
return this.page.locator('[data-test="logs-search-bar-utilities-menu-btn"]');
}
// VRL show-query toggle button rendered inside the utilities dropdown menu
getVrlToggleMenuBtn() {
return this.page.locator('[data-test="logs-search-bar-show-query-toggle-btn-btn"]');
}
// Rendered dashboard table panel
getTablePanel() {
return this.page.locator('[data-test="dashboard-panel-table"]');
}
// Data rows inside the rendered dashboard table panel
getTableRows() {
return this.page.locator('[data-test="dashboard-panel-table"] tbody tr');
}
// Chart-type selector item by type (e.g. "table", "line", "bar", "h-bar")
getChartTypeItem(chartType) {
return this.page.locator(`[data-test="selected-chart-${chartType}-item"]`);
}
// "Edit panel" action button (from a panel dropdown menu)
getEditPanelBtn() {
return this.page.locator('[data-test="dashboard-edit-panel"]');
}
// VRL "only supported for table chart" warning banner
getVrlWarningBanner() {
return this.page.getByText("VRL function is only supported for table chart");
}
// VRL error notification (present only when VRL functions are active on non-table)
getVrlErrorNotification() {
return this.page.getByText(
"VRL functions are present. Only table chart is supported when using VRL functions."
);
}
// Toast message locator scoped to a given text
getToastMessageByText(text) {
return this.page
.locator('[data-test="o-toast-message"]')
.filter({ hasText: text });
}
// Dashboard back button locator (for waits; use clickDashboardBackBtn() to click)
getDashboardBackBtn() {
return this.page.locator('[data-test="dashboard-back-btn"]');
}
// Dialog primary (confirm) button locator
getDialogPrimaryBtn() {
return this.page.locator('[data-test="o-dialog-primary-btn"]');
}
// Sidebar/table field locator matched by Playwright text engine selector
getFieldByTextSelector(textSelector) {
return this.page.locator(textSelector);
}
// Open query inspector from a panel dropdown
// metaData gating the Query Inspector item populates only on a non-empty query, so the panel has to come back with rows. The dashboard opens on its own 15m default whatever range the test used in logs, and the fixture is ingested once per worker, so by a late test it can sit outside that window - the panel is then empty however often it is refreshed. Widen the window first, unconditionally, so every run takes the same path instead of a recovery branch only CI ever exercises; the refreshes that follow cover rows that are not searchable yet (WAL lag under load).
async waitForPanelToLoadData({ refreshAttempts = 3, attemptTimeout = 10000 } = {}) {
const noData = this.page.locator('[data-test="no-data"]');
const refreshBtn = this.page.locator('[data-test="dashboard-refresh-btn"]');
await new DateTimeHelper(this.page).setRelativeTimeRange("6-h");
// A refresh unmounts the empty-state overlay for as long as its query runs, so "absent
// right now" is not "the panel came back with rows" - it has to stay absent.
const hasData = async (timeout, quietMs = 1500) => {
const deadline = Date.now() + timeout;
let goneSince = null;
while (Date.now() < deadline) {
if (await noData.isVisible().catch(() => false)) {
goneSince = null;
} else if (goneSince === null) {
goneSince = Date.now();
} else if (Date.now() - goneSince >= quietMs) {
return true;
}
await this.page.waitForTimeout(250);
}
return false;
};
if (await hasData(quietPeriodProbeMs)) return;
for (let attempt = 0; attempt < refreshAttempts; attempt++) {
await refreshBtn.click({ timeout: 5000 }).catch(() => {});
if (await hasData(attemptTimeout)) return;
}
throw new Error(
"Dashboard panel still reports no data over a 6h window after refreshing"
);
}
async openPanelQueryInspector(panelName) {
// The Query Inspector item is v-if-gated on the panel's metaData, populated only after its query executes, so wait for the panel to render before opening the menu.
await this.verifyChartRenders(this.page);
await this.waitForPanelToLoadData();
const dropdown = this.getPanelDropdown(panelName);
await dropdown.waitFor({ state: "visible", timeout: 20000 });
const inspectorBtn = this.page.locator('[data-test="dashboard-query-inspector-panel"]');
// Opening the menu before the item mounts leaves it absent for that open, so re-open until it appears.
// Escape first each retry: the dropdown is a toggle, so a blind re-click on an already-open menu closes it — fighting itself while metaData populates.
await expect(async () => {
if (await inspectorBtn.isVisible().catch(() => false)) return;
await this.page.keyboard.press("Escape").catch(() => {});
await dropdown.click();
await expect(inspectorBtn).toBeVisible({ timeout: 3000 });
}).toPass({ timeout: 30000, intervals: [500, 1000, 2000] });
await inspectorBtn.click();
await this.waitForQueryInspector(this.page);
}
// Verify executed query in query inspector contains expected text
async verifyExecutedQueryContains(expectedTexts, queryIndex = 0) {
const executedQuery = this.page.locator(`[data-test="query-inspector-executed-query-${queryIndex}"]`);
await expect(executedQuery).toBeVisible({ timeout: 10000 });
for (const text of expectedTexts) {
await expect(executedQuery).toContainText(text);
}
}
// Click the dashboard back button
async clickDashboardBackBtn() {
await this.page.locator('[data-test="dashboard-back-btn"]').click();
// Both the panel-editor and dashboard-view page render an element with
// the same data-test="dashboard-back-btn" — a click right after saving
// can land on a stale instance mid page-transition and silently no-op.
// Verify the URL actually changed and retry once if not.
try {
await this.page.waitForURL(/\/dashboards(?:\?|$)/, { timeout: 8000 });
} catch (e) {
await this.page.locator('[data-test="dashboard-back-btn"]').click();
await this.page.waitForURL(/\/dashboards(?:\?|$)/, { timeout: 15000 });
}
}
// Get chart renderer canvas locator
getChartRendererCanvas() {
return this.page.locator('[data-test="chart-renderer"] canvas');
}
// Get dashboard error locator
getDashboardErrorLocator() {
return this.page.locator('[data-test="dashboard-error"]');
}
// Verify no dashboard errors are present
async verifyNoDashboardErrors() {
const errorLocator = this.getDashboardErrorLocator();
await expect(errorLocator).toHaveCount(0);
}
// Get connect null values toggle button locator
getConnectNullValuesToggle() {
return this.page.locator(
'[data-test="dashboard-config-connect-null-values"] [data-test$="-btn"]'
);
}
}