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

902 lines
36 KiB
JavaScript

// pages/chartTypeSelector.js
// Methods : selectChartType, selectStreamType, searchAndAddField, selectStream
const { expect } = require("@playwright/test");
const testLogger = require("../../playwright-tests/utils/test-logger.js");
export default class ChartTypeSelector {
constructor(page) {
this.page = page;
// Raw query / DynamicFunctionPopUp selectors
this.rawQueryTextarea = page.locator('[data-test="dashboard-raw-query-textarea-field"]');
this.popupTabs = page.locator('[data-test="dynamic-function-popup-tabs"]');
this.buildTab = page.locator('[data-test="dynamic-function-popup-tab-build"]');
this.rawTab = page.locator('[data-test="dynamic-function-popup-tab-raw"]');
// Field property checkboxes (visible on table chart type)
this.treatAsNonTimestampCheckbox = page.locator('[data-test="dynamic-function-popup-treat-as-non-timestamp"]');
this.showFieldAsJsonCheckbox = page.locator('[data-test="dynamic-function-popup-show-field-as-json"]');
// Query type selectors
this.sqlQueryTypeBtn = page.locator('[data-test="dashboard-sql-query-type"]');
this.customQueryTypeBtn = page.locator('[data-test="dashboard-custom-query-type"]');
this.builderQueryTypeBtn = page.locator('[data-test="dashboard-builder-query-type"]');
this.promqlQueryTypeBtn = page.locator('[data-test="dashboard-promql-query-type"]');
// Custom query editor
this.queryEditor = page.locator('[data-test="dashboard-panel-query-editor"]');
// JSON renderer locators (source-defined data-tests in JsonFieldRenderer.vue)
this.jsonFieldRenderer = page.locator('[data-test="json-field-renderer"]');
this.jsonKey = page.locator('[data-test="json-key"]');
this.jsonValue = page.locator('[data-test="json-value"]');
// VRL function editor (toggle + editor) shown in the panel query bar
this.vrlToggleBtn = page.locator('[data-test="logs-search-bar-show-query-toggle-btn"]');
this.vrlFunctionEditor = page.locator('[data-test="dashboard-vrl-function-editor"]');
// Field layout containers (X / Y / Breakdown) and the +P (add-to-pivot) button
this.xLayout = page.locator('[data-test="dashboard-x-layout"]');
this.yLayout = page.locator('[data-test="dashboard-y-layout"]');
this.breakdownLayout = page.locator('[data-test="dashboard-b-layout"]');
this.pivotAddBtn = page.locator('[data-test="dashboard-add-p-data"]');
// Geomap chart canvas (rendered inside #chart-map)
this.mapCanvas = page.locator("#chart-map canvas");
// HTML chart editor + rendered output
this.htmlEditor = page.locator('[data-test="dashboard-html-editor"]');
this.htmlRenderer = page.locator('[data-test="html-renderer"]');
// Markdown chart editor + rendered output
this.markdownEditor = page.locator('[data-test="dashboard-markdown-editor"]');
this.markdownRenderer = page.locator('[data-test="markdown-renderer"]');
}
async fillMarkdownEditor(markdown) {
await this.markdownEditor.locator(".monaco-editor").click();
await this.markdownEditor.locator(".inputarea").fill(markdown);
}
getHtmlRendererElement(selector) {
return this.htmlRenderer.locator(selector);
}
// Click the geomap chart canvas at a given pixel position
async clickMapCanvas(position) {
await this.mapCanvas.click({ position });
}
// Enter HTML code into the HTML chart Monaco editor
async fillHtmlEditor(html) {
await this.htmlEditor.locator(".monaco-editor").click();
await this.htmlEditor.locator(".inputarea").fill(html);
}
// Rendered HTML output: heading by accessible name
getHtmlHeading(name) {
return this.page.getByRole("heading", { name });
}
// Rendered HTML output: element containing given text
getHtmlRendererText(text) {
return this.htmlRenderer.getByText(text);
}
// Rendered HTML output: any <script> tags (should be sanitized away)
getHtmlRendererScripts() {
return this.htmlRenderer.locator("script");
}
getHtmlRendererElement(selector) {
return this.htmlRenderer.locator(selector);
}
// Page-wide, since an escaped element would land outside the renderer
getInjectedOnErrorImages() {
return this.page.locator("img[onerror]");
}
async getHtmlPanelSentinel() {
return this.page.evaluate(() => window.__o2HtmlPanelSentinel);
}
async addHtmlPanelContent(html) {
await this.selectChartType("html");
await this.fillHtmlEditor(html);
}
// A null target asserts the sanitizer dropped it and added no rel
async expectHtmlLinkTarget(selector, target) {
const element = this.getHtmlRendererElement(selector);
if (target === null) {
await expect(element).toHaveCount(1);
await expect(element).not.toHaveAttribute("target");
await expect(element).not.toHaveAttribute("rel");
return;
}
await expect(element).toHaveAttribute("target", target);
await expect(element).toHaveAttribute("rel", "noopener noreferrer");
}
async clickHtmlRendererElementForPopup(selector) {
const [popup] = await Promise.all([
this.page.context().waitForEvent("page"),
this.getHtmlRendererElement(selector).click(),
]);
await popup.waitForLoadState("domcontentloaded");
return popup;
}
async isPopupOpenerNull(popup) {
return popup.evaluate(() => window.opener === null);
}
// Returns the field-list row for a given field name
getFieldListRow(fieldName) {
return this.page.locator(`[data-test="o-field-list-row-${fieldName}"]`);
}
// Field-list search input (the OInput field itself)
getFieldSearchInput() {
return this.page.locator('[data-test="o-field-list-search-field"]');
}
// Returns the "selected chart type" indicator item (e.g. pie, donut)
getSelectedChartItem(chartType) {
return this.page.locator(`[data-test="selected-chart-${chartType}-item"]`);
}
// Sankey builder layout areas
getSankeySourceLayout() {
return this.page.locator('[data-test="dashboard-source-layout"]');
}
getSankeyTargetLayout() {
return this.page.locator('[data-test="dashboard-target-layout"]');
}
getSankeyValueLayout() {
return this.page.locator('[data-test="dashboard-value-layout"]');
}
// Returns the Y-axis remove button for a specific alias
getYAxisRemoveButton(alias) {
return this.page.locator(`[data-test="dashboard-y-item-${alias}-remove"]`);
}
// Returns all Y-axis remove buttons (any alias)
getAnyYAxisRemoveButton() {
return this.page.locator('[data-test^="dashboard-y-item-"][data-test$="-remove"]');
}
// Returns the nth breakdown (pivot) field chip (1-based index in the data-test)
getBreakdownItem(index) {
return this.page.locator(`[data-test="dashboard-b-item-breakdown_${index}"]`);
}
// Returns a field-section label by its visible text (e.g. "First Column",
// "Row Fields", "Add 0 or 1 field here"), used to assert layout-mode labels.
getFieldSectionLabel(text) {
return this.page.getByText(text);
}
// Chart Type select
async selectChartType(chartType) {
// Wait for panel editor to be ready - could be full page or dialog
// Look for either the Apply button or any chart type selector
const panelEditorIndicator = this.page.locator('[data-test="dashboard-apply"]').or(
this.page.locator('[data-test^="selected-chart-"]').first()
);
// Wait for panel editor to be visible with retry
try {
await panelEditorIndicator.first().waitFor({ state: "visible", timeout: 15000 });
} catch (e) {
// Panel editor may take extra time to load, wait for network idle and retry
await this.page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {});
await panelEditorIndicator.first().waitFor({ state: "visible", timeout: 10000 });
}
const chartOption = this.page.locator(
`[data-test="selected-chart-${chartType}-item"]`
);
await chartOption.waitFor({ state: "visible", timeout: 15000 });
await chartOption.scrollIntoViewIfNeeded();
await chartOption.click();
}
// Stream Type select - waits for stream list to load after selection
async selectStreamType(type) {
// Click the dropdown trigger
await this.page.locator('[data-test="index-dropdown-stream_type"]').click();
// Wait for popover, then pick option by text (OSelect uses o-select-option data-test)
const streamTypePopover = this.page.locator('[data-test="index-dropdown-stream_type-popover"]');
await streamTypePopover.waitFor({ state: "visible", timeout: 10000 });
await streamTypePopover.getByText(type, { exact: true }).first().click();
// CRITICAL: Wait for stream list API call to complete after changing type
await this.page.waitForLoadState("networkidle", { timeout: 10000 }).catch(() => {});
await this.page.locator('[data-test="index-dropdown-stream"]').waitFor({ state: "visible", timeout: 10000 });
}
// Stream select with retry mechanism (no page reload to preserve context)
async selectStream(streamName, maxRetries = 3) {
const streamInput = this.page.locator(
'[data-test="index-dropdown-stream"]'
);
// An already-selected stream can be missing from the open option list (seen on alpha1), so there is nothing to click.
const trigger = this.page.locator('[data-test="index-dropdown-stream-trigger"]').first();
await streamInput.waitFor({ state: "visible", timeout: 10000 });
if ((await trigger.getAttribute("data-test-selected-value", { timeout: 5000 })) === streamName) {
testLogger.info(`selectStream: "${streamName}" is already the selected stream`);
return;
}
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
// Close any open dropdown first — click outside (ODropdown closes on outside click)
await this.page.locator('body').click({ position: { x: 10, y: 10 } }).catch(() => {});
await streamInput.waitFor({ state: "visible", timeout: 5000 });
await streamInput.click();
// Wait for popover to open, then type via keyboard (OSelect wrapper div doesn't accept fill directly)
const popover = this.page.locator('[data-test="index-dropdown-stream-popover"]');
await popover.waitFor({ state: "visible", timeout: 10000 });
await this.page.keyboard.press("Control+a");
await this.page.keyboard.type(streamName);
// Log available options after filtering
const allOptions = await this.page
.locator('[data-test="index-dropdown-stream-popover"] [data-test="index-dropdown-stream-option"]')
.allTextContents();
testLogger.debug(`Attempt ${attempt}: Looking for "${streamName}". Available options (${allOptions.length}): ${allOptions.slice(0, 10).join(', ')}`);
// Exact value match: hasText is a substring match, so "e2e_automate" also hit "e2e_automate1_w0".
const streamOption = this.page
.locator(`[data-test="index-dropdown-stream-option"][data-test-value="${streamName}"]`)
.first();
await streamOption.waitFor({ state: "visible", timeout: 15000 });
await streamOption.click();
return; // Success
} catch (error) {
if (attempt === maxRetries) {
// Final attempt: log full diagnostic info
const finalOptions = await this.page
.locator('[data-test="index-dropdown-stream-popover"] [data-test="index-dropdown-stream-option"]')
.allTextContents()
.catch(() => []);
testLogger.error(`FAILED after ${maxRetries} attempts. Final options: ${finalOptions.join(', ')}`);
throw error;
}
// Close dropdown and wait for network before retry (don't reload - loses context!)
await this.page.locator('body').click({ position: { x: 10, y: 10 } }).catch(() => {});
await this.page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {});
}
}
}
// Search field and added for X, Y,Breakdown etc.
async searchAndAddField(fieldName, target) {
// o-field-list-search is a div wrapper — scope fill to its inner input
const searchInput = this.page.locator('[data-test="o-field-list-search"] input');
await searchInput.click();
await searchInput.fill(fieldName);
const buttonSelectors = {
x: "dashboard-add-x-data",
y: "dashboard-add-y-data",
b: "dashboard-add-b-data",
p: "dashboard-add-p-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",
source: "dashboard-add-source-data",
target: "dashboard-add-target-data",
sankeyvalue: "dashboard-add-value-data",
};
const buttonTestId = buttonSelectors[target];
if (!buttonTestId) {
throw new Error(`Invalid target type: ${target}`);
}
// New data-test format: o-field-list-row-{fieldName}
// NOTE: no waitFor here — the retry loop below already waits for the row,
// and duplicating the wait outside the loop meant a slow schema/field-list
// load threw before the loop ever got its first attempt, collapsing the
// 3-attempt budget down to one.
const fieldItem = this.page.locator(`[data-test="o-field-list-row-${fieldName}"]`);
// Use .first() — in join panels the same field name can appear multiple times
// (once per joined stream), which would cause a strict mode violation.
const button = fieldItem.first().locator(`[data-test="${buttonTestId}"]`);
// The add-to-axis buttons are CSS-hidden until the row is hovered
// (OFieldRow .__actions { display: none }), and hover() auto-scrolls into view
// so scrollIntoViewIfNeeded() is not needed.
//
// Hovering ONCE and then waiting for the button is not enough. Vue re-renders the
// field list after search input changes (debounce / virtual scroll); a re-render
// landing between the hover and the button check either detaches the row or
// shifts it out from under the cursor, dropping :hover. The button then stays
// hidden and the wait burns its full timeout on an element that can no longer
// become visible — the "15 x locator resolved to hidden" failure signature.
// Re-hover on every attempt so a re-render costs one retry, not the whole call.
// The click stays INSIDE the loop for the same reason: a re-render between the check
// and the click drops :hover again, so the click burns its full timeout. Keeping
// hover -> check -> click together costs one retry instead of the call; break on
// success means the field is never added twice.
const maxAttempts = 3;
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await fieldItem.first().waitFor({ state: "visible", timeout: 10000 });
await fieldItem.first().hover({ timeout: 5000 });
await button.waitFor({ state: "visible", timeout: 5000 });
await button.click({ timeout: 5000 });
break;
} catch (e) {
lastError = e;
if (attempt === maxAttempts) throw lastError;
}
}
await searchInput.fill(""); // Clear the search input
}
/**
* Search for a field by name without adding it to any axis.
* Useful for checking +P / +X button visibility after searching.
* @param {string} fieldName
*/
async searchField(fieldName) {
const searchInput = this.page.locator('[data-test="o-field-list-search"] input');
await searchInput.click();
await searchInput.fill(fieldName);
}
/**
* Clear the field search input.
*/
async clearFieldSearch() {
const searchInput = this.page.locator('[data-test="o-field-list-search"] input');
await searchInput.click();
await searchInput.fill('');
}
//remove fields from the dashboard
// Remove field by type (x, y, breakdown, etc.)
// @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",
source: "dashboard-source-item",
target: "dashboard-target-item",
sankeyvalue: "dashboard-value-item",
};
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();
}
// Helper function to wait for table data to load completely
async waitForTableDataLoad() {
// Wait for table to be visible
await this.page.waitForSelector('[data-test="dashboard-panel-table"]', {
timeout: 10000,
});
// Wait for table to have data (non-empty tbody)
await this.page.waitForFunction(
() => {
const table = document.querySelector(
'[data-test="dashboard-panel-table"]'
);
const rows = table?.querySelectorAll("tbody tr");
return (
rows &&
rows.length > 0 &&
Array.from(rows).some((row) =>
Array.from(row.querySelectorAll("td")).some(
(cell) => cell.textContent.trim() !== ""
)
)
);
},
undefined,
{ timeout: 15000 }
);
// The check above is satisfied by a STALE table. Re-applying a query leaves the
// previous result set on screen while the new one loads, so "some row has text"
// is instantly true and callers go on to measure the OLD data — a filter that
// narrows 77 rows to fewer still reads 77. The same gap in reverse lets a caller
// sample during a transient re-render and read zero rows.
//
// Wait for the row count to stop changing so the new result set has actually
// rendered. State is parked on window and reset first so repeated calls within a
// test do not inherit a previous call's sample.
await this.page.evaluate(() => {
delete window.__o2TableSettle;
});
await this.page.waitForFunction(
(settleMs) => {
const table = document.querySelector(
'[data-test="dashboard-panel-table"]'
);
const count = table ? table.querySelectorAll("tbody tr").length : 0;
if (!window.__o2TableSettle) {
window.__o2TableSettle = { count: -1, since: 0 };
}
const state = window.__o2TableSettle;
if (state.count !== count) {
state.count = count;
state.since = Date.now();
return false;
}
return count > 0 && Date.now() - state.since >= settleMs;
},
750,
{ timeout: 20000 }
);
}
// ===== Y-AXIS FUNCTION CONFIGURATION METHODS =====
/**
* Open the Y-axis function configuration popup
* @param {string} alias - The Y-axis alias (e.g., "y_axis_1", "y_axis_2")
*/
async openYAxisFunctionPopup(alias) {
const yAxisItem = this.page.locator(`[data-test="dashboard-y-item-${alias}"]`);
await yAxisItem.waitFor({ state: "visible", timeout: 10000 });
await yAxisItem.click();
const menuLocator = this.page.locator(`[data-test="dashboard-y-item-${alias}-menu"]`);
await menuLocator.waitFor({ state: "visible", timeout: 10000 });
}
/**
* Select a function from the function dropdown
* @param {string} functionName - The function to select (e.g., "count", "sum", "avg", "min", "max", "Distinct")
*/
async selectFunction(functionName) {
const dropdown = this.page.locator('[data-test="dashboard-function-dropdown"]').first();
await dropdown.waitFor({ state: "visible", timeout: 10000 });
await dropdown.click();
await this.page.locator('[data-test="dashboard-function-dropdown-popover"]').waitFor({ state: "visible", timeout: 5000 });
await this.page.keyboard.type(functionName);
// Use case-insensitive contains match — OSelect forwards parent data-test to options.
const option = this.page
.locator('[data-test="dashboard-function-dropdown-option"]', { hasText: new RegExp(functionName, 'i') })
.first();
await option.waitFor({ state: "visible", timeout: 10000 });
await option.click();
}
/**
* Verify Y-axis label contains expected function name
* @param {string} alias - The Y-axis alias (e.g., "y_axis_1")
* @param {string} expectedFunction - The expected function name in the label
* @param {Function} expect - Playwright expect function
*/
async verifyYAxisLabel(alias, expectedFunction, expect) {
const yAxisLabel = this.page.locator(`[data-test="dashboard-y-item-${alias}"]`);
const labelText = await yAxisLabel.textContent();
expect(labelText.toLowerCase()).toContain(expectedFunction.toLowerCase());
return labelText;
}
/**
* Configure a Y-axis field with a function in one method
* @param {string} alias - The Y-axis alias (e.g., "y_axis_1")
* @param {string} functionName - The function to apply (e.g., "count", "sum")
*/
async configureYAxisFunction(alias, functionName) {
await this.openYAxisFunctionPopup(alias);
await this.selectFunction(functionName);
await this.page.locator('body').click({ position: { x: 10, y: 10 } });
const menuLocator = this.page.locator(`[data-test="dashboard-y-item-${alias}-menu"]`);
await menuLocator.waitFor({ state: "hidden", timeout: 5000 }).catch(() => {});
}
// ===== RAW QUERY CONFIGURATION METHODS =====
/**
* Switch to the Raw tab in the DynamicFunctionPopUp
* Requires the Y-axis popup to already be open (call openYAxisFunctionPopup first)
*/
async switchToRawTab() {
await this.rawTab.waitFor({ state: "visible", timeout: 10000 });
await this.rawTab.click();
await this.rawQueryTextarea.waitFor({ state: "visible", timeout: 10000 });
testLogger.debug('Switched to Raw tab');
}
/**
* Switch to the Build tab in the DynamicFunctionPopUp
* Requires the Y-axis popup to already be open
*/
async switchToBuildTab() {
await this.buildTab.waitFor({ state: "visible", timeout: 10000 });
await this.buildTab.click();
// Wait for Build tab content to render (function dropdown becomes visible)
await this.page.locator('[data-test="dashboard-function-dropdown"]').first().waitFor({ state: "visible", timeout: 10000 });
testLogger.debug('Switched to Build tab');
}
/**
* Enter a raw SQL query in the Raw tab textarea
* Requires the Raw tab to be active
* @param {string} query - The raw SQL query to enter
*/
async enterRawQuery(query) {
await this.rawQueryTextarea.waitFor({ state: "visible", timeout: 10000 });
await this.rawQueryTextarea.click();
await this.rawQueryTextarea.fill(query);
testLogger.debug('Entered raw query', { query });
}
/**
* Get the current value of the raw query textarea
* Requires the Raw tab to be active
* @returns {Promise<string>} The raw query text
*/
async getRawQueryValue() {
await this.rawQueryTextarea.waitFor({ state: "visible", timeout: 10000 });
return await this.rawQueryTextarea.inputValue();
}
/**
* Verify the raw query textarea is visible
* @param {Function} expect - Playwright expect function
*/
async verifyRawTextareaVisible(expect) {
await expect(this.rawQueryTextarea).toBeVisible({ timeout: 10000 });
}
/**
* Configure a Y-axis field with a raw query in one method
* Opens the popup, switches to Raw tab, enters query, and closes popup
* @param {string} alias - The Y-axis alias (e.g., "y_axis_1")
* @param {string} query - The raw SQL query to enter
*/
async configureYAxisRawQuery(alias, query) {
await this.openYAxisFunctionPopup(alias);
await this.switchToRawTab();
await this.enterRawQuery(query);
await this.page.locator('body').click({ position: { x: 10, y: 10 } });
// Wait for popup to close
const menuLocator = this.page.locator(`[data-test="dashboard-y-item-${alias}-menu"]`);
await menuLocator.waitFor({ state: "hidden", timeout: 10000 });
testLogger.info('Configured Y-axis raw query', { alias, query });
}
/**
* Verify the Build/Raw tabs are visible in the popup
* Requires the Y-axis popup to already be open
* @param {Function} expect - Playwright expect function
*/
async verifyBuildRawTabsVisible(expect) {
await expect(this.popupTabs).toBeVisible({ timeout: 10000 });
await expect(this.buildTab).toBeVisible();
await expect(this.rawTab).toBeVisible();
}
/**
* Verify the Build/Raw tabs are NOT visible (e.g., in custom query mode)
* @param {Function} expect - Playwright expect function
*/
async verifyBuildRawTabsNotVisible(expect) {
await expect(this.popupTabs).not.toBeVisible({ timeout: 5000 });
}
/**
* Open field property popup by clicking on an axis item
* @param {string} alias - The axis alias (e.g., "x_axis_1", "y_axis_1")
* @param {string} axis - The axis type ("x" or "y")
*/
async openFieldPropertyPopup(alias, axis = "x") {
const itemLocator = this.page.locator(`[data-test="dashboard-${axis}-item-${alias}"]`);
await itemLocator.waitFor({ state: "visible", timeout: 10000 });
// The popup is an ODropdown anchored to the axis item. When opened
// immediately after the panel's first render, a late re-render of the
// field list can re-mount the trigger and auto-dismiss the popup within
// a few hundred ms — so a single click + visibility check races against
// the popup vanishing. Open it, then confirm it STAYS open; re-open if it
// auto-closed. Click only when the popup isn't already open (the trigger
// toggles, so clicking an open popup would close it).
const popupRoot = this.page.locator('[data-test="dynamic-function-popup-root"]');
const maxAttempts = 5;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (!(await popupRoot.isVisible().catch(() => false))) {
await itemLocator.click();
}
const opened = await popupRoot
.waitFor({ state: "visible", timeout: 3000 })
.then(() => true)
.catch(() => false);
if (opened) {
// Confirm the popup is still open after the window in which the
// post-render re-render would have dismissed it.
await this.page.waitForTimeout(700);
if (await popupRoot.isVisible().catch(() => false)) {
testLogger.debug('Opened field property popup', { alias, axis, attempt });
return;
}
}
// Auto-closed (or never opened) — wait briefly for the panel to settle,
// then retry the open.
await this.page.waitForTimeout(500);
}
throw new Error(
`Field property popup for ${axis}-item-${alias} did not stay open after ${maxAttempts} attempts`,
);
}
/**
* Toggle "Mark this field as non-timestamp" checkbox
*/
async toggleTreatAsNonTimestamp() {
await this.treatAsNonTimestampCheckbox.waitFor({ state: "visible", timeout: 10000 });
await this.treatAsNonTimestampCheckbox.click();
testLogger.debug('Toggled treat as non-timestamp checkbox');
}
/**
* Toggle "Render Data as JSON / Array" checkbox.
* Waits for `isChecked()` to flip so the underlying field model
* (fields.showFieldAsJson) is guaranteed propagated before Apply.
* (`isChecked()` works on Radix-style OCheckbox via the role="checkbox"
* descendant, so this stays selector-policy compliant.)
*/
async toggleShowFieldAsJson() {
await this.showFieldAsJsonCheckbox.waitFor({
state: "visible",
timeout: 10000,
});
const beforeChecked = await this.showFieldAsJsonCheckbox.isChecked();
await this.showFieldAsJsonCheckbox.click();
// Poll isChecked() until it flips, confirming the v-model update
// propagated to the parent panel before any subsequent Apply.
await expect
.poll(
async () => await this.showFieldAsJsonCheckbox.isChecked(),
{ timeout: 5000 },
)
.toBe(!beforeChecked);
testLogger.debug("Toggled show field as JSON checkbox");
}
/**
* Switch to Custom query mode
*/
async switchToCustomQueryMode() {
await this.customQueryTypeBtn.waitFor({ state: "visible", timeout: 10000 });
await this.customQueryTypeBtn.click();
testLogger.debug('Switched to custom query mode');
}
/**
* Click the SQL query-type toggle button
*/
async clickSqlQueryType() {
await this.sqlQueryTypeBtn.click();
}
/**
* Click the Custom query-type toggle button
*/
async clickCustomQueryType() {
await this.customQueryTypeBtn.click();
}
/**
* Focus the panel query editor by clicking its Monaco code region
*/
async clickQueryEditorCode() {
await this.queryEditor.getByRole('code').click();
}
/**
* Fill the panel query editor's hidden input area with a query string
* @param {string} query - The SQL query to fill
*/
async fillQueryEditorInputArea(query) {
await this.queryEditor.locator(".inputarea").fill(query);
}
/**
* Wait for a field to appear in the field list (e.g. after SQL parsing).
* @param {string} fieldName - The field name to wait for
* @param {number} timeout - Max wait time in ms (default 10000)
*/
async waitForFieldListRow(fieldName, timeout = 10000) {
const fieldRow = this.page.locator(`[data-test="o-field-list-row-${fieldName}"]`).first();
await fieldRow.waitFor({ state: "visible", timeout });
}
/**
* Enter a custom SQL query in the Monaco editor.
* Clicks the editor wrapper (data-test) then types via the page keyboard,
* avoiding class/role selectors that violate the PO selector policy.
* @param {string} query - The SQL query to enter
*/
async enterCustomSQL(query) {
await this.queryEditor.waitFor({ state: "visible", timeout: 10000 });
await this.queryEditor.click();
await this.dismissSuggestionsIfOpen();
await this.page.keyboard.press("Control+a");
await this.page.keyboard.press("Delete");
// Use insertText (single input event) instead of type (per-character keydown events)
// to prevent Monaco's autocomplete from accepting suggestions mid-input and
// mangling the SQL (e.g. replacing table names with function calls).
await this.page.keyboard.insertText(query);
await this.dismissSuggestionsIfOpen();
testLogger.debug('Entered custom SQL query', { query });
}
// Escape with no suggest widget open moves focus out of the editor, so only press it when needed.
async dismissSuggestionsIfOpen() {
const open = await this.page.evaluate(() => {
const widget = document.querySelector(".monaco-editor .suggest-widget");
return !!widget && (widget.classList.contains("visible") || widget.classList.contains("focused"));
});
if (open) await this.page.keyboard.press("Escape");
}
/**
* Wait until the custom SQL has been parsed into these axis fields; Apply validates the panel state, which lags the editor.
* @param {string} xAlias
* @param {string} yAlias
*/
async waitForCustomQueryAxes(xAlias, yAlias) {
await this.page.locator(`[data-test="dashboard-x-item-${xAlias}"]`).first().waitFor({ state: "attached", timeout: 15000 });
await this.page.locator(`[data-test="dashboard-y-item-${yAlias}"]`).first().waitFor({ state: "attached", timeout: 15000 });
}
/**
* End-to-end helper: switch to SQL → Custom query mode and enter the query.
* @param {string} query - The SQL query to enter
*/
async setCustomSQL(query) {
await this.sqlQueryTypeBtn.waitFor({ state: "visible", timeout: 10000 });
await this.sqlQueryTypeBtn.click();
await this.customQueryTypeBtn.waitFor({ state: "visible", timeout: 10000 });
await this.customQueryTypeBtn.click();
await this.enterCustomSQL(query);
}
/**
* Dismiss the field property popup (ODropdown portal) by pressing Escape.
* Waits for the JSON-toggle checkbox (which only renders inside the open popup)
* to detach so the menu is guaranteed gone before subsequent actions.
*/
async dismissFieldPropertyPopup() {
await this.page.keyboard.press('Escape');
await this.showFieldAsJsonCheckbox
.waitFor({ state: 'hidden', timeout: 5000 })
.catch(() => {});
testLogger.debug('Dismissed field property popup');
}
/**
* Verify the JSON renderer element is visible after JSON-rendering is enabled.
* Waits up to 60s for the renderer to attach + become visible; the panel
* re-renders asynchronously after Apply, so we poll on attach first to
* ride out the column-meta hydration that triggers JsonFieldRenderer.
*/
async verifyJsonRendererVisible() {
// Wait for at least one renderer to attach (mount) — this rides out
// the panel-schema → TenstackTable column-meta → JsonFieldRenderer
// hydration cycle that happens just after Apply.
await expect
.poll(
async () => await this.jsonFieldRenderer.count(),
{ timeout: 60000 },
)
.toBeGreaterThan(0);
// TenstackTable virtualises rows — the FIRST renderer in DOM order may be
// a buffered (off-viewport) row reporting `hidden` per Playwright's
// visibility test even though it's mounted and laid out. Poll for ANY
// attached `json-field-renderer` to have a non-zero bounding box, which
// matches the spec's downstream `verifyJsonContainsKey/Value` calls that
// succeed when even one renderer has rendered content.
await expect
.poll(
async () =>
await this.page.evaluate(() => {
const nodes = document.querySelectorAll(
'[data-test="json-field-renderer"]',
);
return Array.from(nodes).some((n) => {
const r = n.getBoundingClientRect();
return r.width > 0 && r.height > 0;
});
}),
{ timeout: 30000 },
)
.toBe(true);
}
/**
* Get the count of attached json-field-renderer elements (may be in overflow)
* @returns {Promise<number>} count of JSON renderer elements
*/
async getJsonRendererCount() {
await this.jsonFieldRenderer.first().waitFor({ state: 'attached', timeout: 30000 });
return await this.jsonFieldRenderer.count();
}
/**
* Verify that at least one JSON key element contains the expected key text.
* Uses page.evaluate against data-test="json-key" elements (no text=/has-text selectors).
* @param {string} expectedKey - The JSON key to find (e.g., "domain")
*/
async verifyJsonContainsKey(expectedKey) {
await this.jsonKey.first().waitFor({ state: 'attached', timeout: 30000 });
const found = await this.page.evaluate((key) => {
const nodes = document.querySelectorAll('[data-test="json-key"]');
return Array.from(nodes).some((n) => (n.textContent || '').trim() === key);
}, expectedKey);
if (!found) {
throw new Error(`Expected JSON key "${expectedKey}" was not rendered`);
}
}
/**
* Verify that at least one JSON value element contains the expected value text.
* Uses page.evaluate against data-test="json-value" elements (no text=/has-text selectors).
* @param {string} expectedValue - The JSON value substring to find (e.g., "service.local")
*/
async verifyJsonContainsValue(expectedValue) {
await this.jsonValue.first().waitFor({ state: 'attached', timeout: 30000 });
const found = await this.page.evaluate((val) => {
const nodes = document.querySelectorAll('[data-test="json-value"]');
return Array.from(nodes).some((n) => (n.textContent || '').includes(val));
}, expectedValue);
if (!found) {
throw new Error(`Expected JSON value "${expectedValue}" was not rendered`);
}
}
}