mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-06 00:09:22 +00:00
test: fix root causes of the flakiest UI tests (#15535)
This commit is contained in:
@@ -331,6 +331,17 @@ export default class DashboardactionPage {
|
||||
}, { timeout: 30000 });
|
||||
}
|
||||
|
||||
// The apply button stays enabled for ~100ms after the click, so waitForChartToRender alone can pass before the query starts and the table later resets to empty.
|
||||
async applyAndWaitForPromQLQuery() {
|
||||
const promqlResponse = this.page.waitForResponse(
|
||||
(res) => res.url().includes("/prometheus/api/v1/query_range"),
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
await this.applyDashboardBtn();
|
||||
await (await promqlResponse).finished();
|
||||
await this.waitForChartToRender();
|
||||
}
|
||||
|
||||
//Dashboard panel actions(Edit, Layout, Duplicate, Inspector, Move, Delete)
|
||||
|
||||
// Returns the per-panel edit dropdown locator (data-test keyed by panel name)
|
||||
|
||||
@@ -76,6 +76,8 @@ class EnrichmentPage {
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
this.vrlEditor = '[data-test="logs-vrl-function-editor"]';
|
||||
this.refreshButton = '[data-test="logs-search-bar-refresh-btn"]';
|
||||
// While a search runs the same data-test is the Cancel button, so idle is keyed on the Run title.
|
||||
this.idleRunQueryButton = page.locator('[data-test="logs-search-bar-refresh-btn"][title="Run query"]');
|
||||
this.showQueryToggleBtn = '[data-test="logs-search-bar-show-query-toggle-btn"]';
|
||||
this.functionsBtn = '[data-test="logs-search-functions-btn"]';
|
||||
|
||||
@@ -562,28 +564,17 @@ abc, err = get_enrichment_table_record("${fileName}", {
|
||||
await this.page.waitForLoadState('domcontentloaded');
|
||||
}
|
||||
|
||||
async applyQueryMultipleClicks() {
|
||||
// Click run query button multiple times to ensure it registers (VRL test specific).
|
||||
// NOTE: Do NOT use `{ force: true }` — OButton's @click handler does not
|
||||
// fire on a forced click (Reka Primitive expects a real bubble path).
|
||||
// Key the final click to a _search response so we don't sit in a
|
||||
// 10s networkidle wait for cross-domain background requests.
|
||||
const refreshButton = this.page.locator(this.refreshButton);
|
||||
await refreshButton.waitFor({ state: 'visible' });
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (i === 3) {
|
||||
const searchResp = this.page.waitForResponse(
|
||||
(resp) => /\/api\/.*\/_search/.test(resp.url()),
|
||||
{ timeout: 30000 }
|
||||
).catch(() => null);
|
||||
await refreshButton.click();
|
||||
await searchResp;
|
||||
} else {
|
||||
await refreshButton.click();
|
||||
await this.page.waitForLoadState('domcontentloaded');
|
||||
}
|
||||
}
|
||||
async runQueryWhenIdle() {
|
||||
// Do NOT use `{ force: true }`: OButton's @click handler does not fire on a forced click.
|
||||
await expect(this.idleRunQueryButton).toBeVisible({ timeout: 30000 });
|
||||
await expect(this.idleRunQueryButton).toBeEnabled({ timeout: 15000 });
|
||||
const searchResp = this.page.waitForResponse(
|
||||
(resp) => /\/api\/.*\/_search/.test(resp.url()),
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
await this.idleRunQueryButton.click();
|
||||
await searchResp;
|
||||
await expect(this.idleRunQueryButton).toBeVisible({ timeout: 30000 });
|
||||
}
|
||||
|
||||
async verifyNoQueryWarning() {
|
||||
@@ -595,21 +586,8 @@ abc, err = get_enrichment_table_record("${fileName}", {
|
||||
}
|
||||
|
||||
async expandFirstLogRow() {
|
||||
// Additional run query clicks to ensure VRL enrichment is fully processed.
|
||||
// NOTE: Do NOT use `{ force: true }` — see applyQueryMultipleClicks().
|
||||
// Key each click to a _search response so we don't burn 10s networkidle
|
||||
// timeouts per iteration.
|
||||
const refreshButton = this.page.locator(this.refreshButton);
|
||||
if (await refreshButton.isVisible()) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const searchResp = this.page.waitForResponse(
|
||||
(resp) => /\/api\/.*\/_search/.test(resp.url()),
|
||||
{ timeout: 15000 }
|
||||
).catch(() => null);
|
||||
await refreshButton.click();
|
||||
await searchResp;
|
||||
}
|
||||
}
|
||||
// Re-run once the previous search settles; clicking mid-search hits Cancel and empties the grid.
|
||||
await this.runQueryWhenIdle();
|
||||
|
||||
// Wait for timestamp column to be visible and stable
|
||||
await this.timestampColumn.first().waitFor({ state: 'visible', timeout: 30000 });
|
||||
@@ -824,8 +802,7 @@ abc, err = get_enrichment_table_record("${fileName}", {
|
||||
// Wait for VRL editor to be fully ready for processing
|
||||
await this.waitForVRLEditorReady();
|
||||
|
||||
// Apply query with multiple clicks for VRL test reliability
|
||||
await this.applyQueryMultipleClicks();
|
||||
await this.runQueryWhenIdle();
|
||||
|
||||
// Wait for query results to load — either the timestamp column appears
|
||||
// (success) or the error-details button appears (failure).
|
||||
@@ -842,7 +819,7 @@ abc, err = get_enrichment_table_record("${fileName}", {
|
||||
testLogger.warn('Error detected after first VRL query attempt, waiting 5s and retrying');
|
||||
|
||||
// Retry the query — the backend may need a moment to index the new table
|
||||
await this.applyQueryMultipleClicks();
|
||||
await this.runQueryWhenIdle();
|
||||
await Promise.race([
|
||||
timestampCol.waitFor({ state: 'visible', timeout: 30000 }).catch(() => {}),
|
||||
errorDetailsBtn.waitFor({ state: 'visible', timeout: 30000 }).catch(() => {}),
|
||||
|
||||
@@ -117,6 +117,8 @@ export class IamPage {
|
||||
|
||||
async gotoIamPage() {
|
||||
await this.iamPageMenu.click();
|
||||
// An org switch issued before this navigation commits reuses the old path and cancels it.
|
||||
await expect(this.page).toHaveURL(/\/iam/, { timeout: 15000 });
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -510,6 +510,8 @@ export class LogsPage {
|
||||
this.resultsSkeleton = '[data-test="logs-search-result-logs-table"] [data-test="o2-table-skeleton-body"]';
|
||||
this.resultsLoadingBanner = '[data-test="logs-search-result-logs-table"] [data-test="o2-table-loading-banner"]';
|
||||
this.noResultsFoundText = '[data-test="logs-search-no-events-found-text"]';
|
||||
// States that only render once a search has run (or the auto-run guard held it); the pre-search "apply search" hero is excluded on purpose.
|
||||
this.searchSettledStates = `${this.logsSearchResultTableRows}, ${this.noResultsFoundText}, [data-test="logs-search-error-state"], [data-test="logs-search-filter-error-message"], [data-test="logs-search-cancelled-state"], [data-test="logs-auto-run-guard"]`;
|
||||
this.resultsProgressBar = '[data-test="logs-results-progress"]';
|
||||
this.queryPlanDialog = '[data-test="query-plan-dialog"]';
|
||||
this.queryEditorSplitter = '[role="separator"][aria-orientation="horizontal"]';
|
||||
@@ -1097,6 +1099,88 @@ export class LogsPage {
|
||||
throw new Error(`selectStream: Failed to find stream "${stream}" after ${maxRetries} attempts`);
|
||||
}
|
||||
|
||||
/** Wait until the Logs search bar's completion lists (what it feeds the query editor) know the stream's fields and optionally a server function. */
|
||||
async waitForStreamFieldsLoaded(fieldName = 'level', functionName = null, timeout = 30000) {
|
||||
testLogger.info(`waitForStreamFieldsLoaded: waiting for field "${fieldName}"${functionName ? ` and function "${functionName}"` : ''} in the query editor`);
|
||||
// Each check walks the whole vnode tree, so poll on an interval rather than every animation frame.
|
||||
await this.page.waitForFunction(({ field, fn }) => {
|
||||
const app = document.querySelector('#app');
|
||||
if (!app?._vnode) return false;
|
||||
const visited = new Set();
|
||||
let searchBar = null;
|
||||
const walk = (node, depth) => {
|
||||
if (!node || depth > 80 || visited.has(node) || searchBar) return;
|
||||
visited.add(node);
|
||||
// The editor's own component props read back empty in prod builds; SearchBar's setupState holds the same lists.
|
||||
if (node.component?.setupState && 'effectiveKeywords' in node.component.setupState) {
|
||||
searchBar = node.component.setupState;
|
||||
return;
|
||||
}
|
||||
if (node.component?.subTree) walk(node.component.subTree, depth + 1);
|
||||
if (Array.isArray(node.children)) {
|
||||
for (const c of node.children) {
|
||||
if (c && typeof c === 'object') walk(c, depth + 1);
|
||||
if (searchBar) return;
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(app._vnode, 0);
|
||||
if (!searchBar) return false;
|
||||
const keywords = Array.isArray(searchBar.effectiveKeywords) ? searchBar.effectiveKeywords : [];
|
||||
const hasField = keywords.some((k) => k?.kind === 'Field' && k?.name === field);
|
||||
if (!fn) return hasField;
|
||||
const suggestions = Array.isArray(searchBar.effectiveSuggestions) ? searchBar.effectiveSuggestions : [];
|
||||
return hasField && suggestions.some((s) => (s?.name ?? s?.label) === fn);
|
||||
}, { field: fieldName, fn: functionName }, { timeout, polling: 250 });
|
||||
}
|
||||
|
||||
/** Read one o2FieldValues record by its "org|streamType|streamName|fieldName" key; null until the app has written it. */
|
||||
async readFieldValueRecord(key) {
|
||||
return await this.page.evaluate((searchKey) => new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open('o2FieldValues', 1);
|
||||
let aborted = false;
|
||||
// A reader that creates the DB leaves it without the app's store, and the app never writes again.
|
||||
req.onupgradeneeded = () => { aborted = true; req.transaction.abort(); resolve(null); };
|
||||
req.onerror = () => (aborted ? resolve(null) : reject(req.error));
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains('fieldValues')) { db.close(); resolve(null); return; }
|
||||
const getReq = db.transaction('fieldValues', 'readonly').objectStore('fieldValues').get(searchKey);
|
||||
getReq.onsuccess = () => { db.close(); resolve(getReq.result || null); };
|
||||
getReq.onerror = () => { db.close(); reject(getReq.error); };
|
||||
};
|
||||
}), key);
|
||||
}
|
||||
|
||||
/** Read every o2FieldValues record; [] until the app has created the DB. */
|
||||
async readAllFieldValueRecords() {
|
||||
return await this.page.evaluate(() => new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open('o2FieldValues', 1);
|
||||
let aborted = false;
|
||||
// A reader that creates the DB leaves it without the app's store, and the app never writes again.
|
||||
req.onupgradeneeded = () => { aborted = true; req.transaction.abort(); resolve([]); };
|
||||
req.onerror = () => (aborted ? resolve([]) : reject(req.error));
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains('fieldValues')) { db.close(); resolve([]); return; }
|
||||
const getAllReq = db.transaction('fieldValues', 'readonly').objectStore('fieldValues').getAll();
|
||||
getAllReq.onsuccess = () => { db.close(); resolve(getAllReq.result || []); };
|
||||
getAllReq.onerror = () => { db.close(); reject(getAllReq.error); };
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
/** Delete the o2FieldValues DB and resolve once the delete has completed. */
|
||||
async clearFieldValueDB() {
|
||||
await this.page.evaluate(() => new Promise((resolve, reject) => {
|
||||
const req = indexedDB.deleteDatabase('o2FieldValues');
|
||||
// onblocked is not completion: the app closes its connection on versionchange, then onsuccess fires.
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
}));
|
||||
testLogger.info('Cleared IndexedDB o2FieldValues database');
|
||||
}
|
||||
|
||||
async deselectStream(streamName) {
|
||||
testLogger.info(`Deselecting stream: ${streamName}`);
|
||||
// Legacy select used `log-search-index-list-stream-toggle-<name> div`;
|
||||
@@ -13215,7 +13299,23 @@ export class LogsPage {
|
||||
|
||||
/** Refreshes and waits for the results table to render. */
|
||||
async runSearchAndWaitForResults(timeout = 30000) {
|
||||
await this.page.locator(this.searchBarRefreshButton).click();
|
||||
// The button reads Run before the load-time search starts, so first wait for that search to have produced a state.
|
||||
await expect(this.page.locator(this.searchSettledStates).first()).toBeVisible({ timeout });
|
||||
// A click while the load-time search is in flight hits the Cancel variant of this button.
|
||||
await this.waitForRunQueryButton(timeout);
|
||||
// The table is already visible from the load-time search, so gate on this click's own hits response.
|
||||
const [response] = await Promise.all([
|
||||
this.page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'POST' &&
|
||||
/\/_search(_stream)?\?/.test(r.url()) &&
|
||||
!r.url().includes('is_ui_histogram'),
|
||||
{ timeout }
|
||||
),
|
||||
this.page.locator(this.searchBarRefreshButton).click(),
|
||||
]);
|
||||
await response.finished();
|
||||
await this.waitForRunQueryButton(timeout);
|
||||
await expect(this.resultsTable()).toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
@@ -13294,6 +13394,8 @@ export class LogsPage {
|
||||
/** Opens the first row's detail and returns the stream name that row came from. */
|
||||
async openFirstHitDetail(timeout = 20000) {
|
||||
const streamCell = this.streamNameCells().first();
|
||||
// The grid is rebuilt per streamed chunk; a one-shot read can land on a cell not yet filled.
|
||||
await expect(streamCell).toHaveText(/\S/, { timeout });
|
||||
const hitStream = (await streamCell.innerText()).trim();
|
||||
await streamCell.click();
|
||||
await expect(this.page.locator(this.logDetailDialog)).toBeVisible({ timeout });
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// Page Object Model for PromQL Builder Mode UI interactions
|
||||
// Covers: Label Filters, Operations, Options, Query Mode tabs, Add to Dashboard
|
||||
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
// Keyed by page so every PageManager built on the same page shares one set of listeners instead of stacking more.
|
||||
const promqlTrackers = new WeakMap();
|
||||
|
||||
@@ -1433,16 +1435,14 @@ export class MetricsBuilderPage {
|
||||
// ===== Chart Type Switching =====
|
||||
|
||||
/**
|
||||
* Select a chart type by clicking its icon
|
||||
* Select a chart type and assert it became the selected one
|
||||
* @param {string} chartType - e.g., 'line', 'bar', 'table', 'area', 'scatter', 'h-bar', 'stacked', 'h-stacked', 'pie', 'donut', 'heatmap', 'gauge'
|
||||
*/
|
||||
async selectChartType(chartType) {
|
||||
const chartItem = this.page.locator(`[data-test="selected-chart-${chartType}-item"]`);
|
||||
if (await chartItem.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await chartItem.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
await chartItem.waitFor({ state: 'visible' });
|
||||
await chartItem.click();
|
||||
await expect(chartItem).toHaveAttribute('data-selected', 'true');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1463,6 +1463,11 @@ export class MetricsBuilderPage {
|
||||
return chartVisible || tableVisible;
|
||||
}
|
||||
|
||||
/** Assert the table chart renders; the table component mounts lazily after the first PromQL load settles. */
|
||||
async expectTableRendered(timeout = 30000) {
|
||||
await expect(this.dashboardPanelTable).toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
// ===== Add to Dashboard - Full Save Flow =====
|
||||
|
||||
/**
|
||||
|
||||
@@ -126,6 +126,13 @@ export class MetricsPage {
|
||||
this.configToggleAllSectionsBtnExpanded = page.locator(
|
||||
'[data-test="dashboard-config-toggle-all-sections-btn"][data-test-all-expanded="true"]'
|
||||
);
|
||||
this.configToggleAllSectionsTooltip = page
|
||||
.locator('[data-test="o-tooltip-content"]')
|
||||
.filter({ hasText: /(Collapse|Expand) all sections/ });
|
||||
this.panelSidebarExpandedTitle = page
|
||||
.locator('[data-test="panel-sidebar-root"]')
|
||||
.filter({ has: this.configToggleAllSectionsBtn })
|
||||
.locator('[data-test="panel-sidebar-expanded-title"]');
|
||||
// All config-panel controls (used by getConfigSectionKeys to enumerate).
|
||||
this.allDashboardConfigControls = page.locator('[data-test^="dashboard-config-"]');
|
||||
// Metrics page does not yet surface a chart-type picker; this defensive
|
||||
@@ -189,6 +196,13 @@ export class MetricsPage {
|
||||
return this.inlineError;
|
||||
}
|
||||
|
||||
/** Assert the panel surfaces a query error (chart-area or inline) and return its text. */
|
||||
async expectQueryErrorVisible(timeout = 15000) {
|
||||
const error = this.chartErrorMessage.or(this.inlineError).first();
|
||||
await expect(error).toBeVisible({ timeout });
|
||||
return (await error.textContent()) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an element's aria-expanded attribute to differ from a previous value.
|
||||
* @param {import('@playwright/test').Locator} toggle
|
||||
@@ -1151,6 +1165,9 @@ export class MetricsPage {
|
||||
await this.configToggleAllSectionsBtnExpanded
|
||||
.waitFor({ state: 'visible', timeout: 5000 })
|
||||
.catch(() => {});
|
||||
// The toggle's tooltip stays open while the pointer rests on it and covers the sidebar collapse button.
|
||||
await this.panelSidebarExpandedTitle.hover();
|
||||
await expect(this.configToggleAllSectionsTooltip).toBeHidden({ timeout: 5000 });
|
||||
// Wait for the sections' open (height) animations to finish — clicking
|
||||
// an OSelect while sections are still animating dismisses the dropdown.
|
||||
await this.page
|
||||
|
||||
@@ -37,6 +37,7 @@ class PipelineImportExportPage {
|
||||
// here rather than inside the methods.
|
||||
this.rowMoreOptionsSelector = (name) => `[data-test="pipeline-list-${name}-more-options"]`;
|
||||
this.rowExportActionSelector = (name) => `[data-test="pipeline-list-${name}-export-action"]`;
|
||||
this.anyRowMoreOptionsSelector = '[data-test^="pipeline-list-"][data-test$="-more-options"]';
|
||||
this.errorAtSelector = (itemIndex, errorIndex) =>
|
||||
`[data-test="pipeline-import-error-${itemIndex}-${errorIndex}"]`;
|
||||
this.creationLineSelector = '[data-test^="pipeline-import-creation-"]';
|
||||
@@ -75,6 +76,8 @@ class PipelineImportExportPage {
|
||||
* hover shortcut; the menu item is the one a user actually clicks.
|
||||
*/
|
||||
async exportPipelineByRow(name) {
|
||||
// The list pages at 20, so in a busy org the row can sit on page two and never render.
|
||||
await this.searchPipelines(name);
|
||||
await this.page.locator(this.rowMoreOptionsSelector(name)).click();
|
||||
const downloadPromise = this.page.waitForEvent('download', { timeout: 30000 });
|
||||
await this.page.locator(this.rowExportActionSelector(name)).click();
|
||||
@@ -89,12 +92,18 @@ class PipelineImportExportPage {
|
||||
*/
|
||||
async searchPipelines(term) {
|
||||
await this.listSearchInput.fill(term);
|
||||
// Gate on the filter having applied: rows exist and every row matches the term.
|
||||
await expect
|
||||
.poll(async () => await this.page.locator('[data-test^="pipeline-list-"]').count(), {
|
||||
timeout: 15000,
|
||||
intervals: [250, 500],
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
.poll(
|
||||
async () => {
|
||||
const rows = await this.page.locator(this.anyRowMoreOptionsSelector).evaluateAll((els) =>
|
||||
els.map((el) => el.getAttribute('data-test')),
|
||||
);
|
||||
return rows.length > 0 && rows.every((dataTest) => dataTest.includes(term));
|
||||
},
|
||||
{ timeout: 15000, intervals: [250, 500] },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
/** Tick the header select-all box and wait for the bulk action to appear. */
|
||||
|
||||
@@ -2854,6 +2854,12 @@ export class PipelinesPage {
|
||||
await this.dashboardsMenuLink.click();
|
||||
}
|
||||
|
||||
async acceptUnsavedChangesPromptAndExpectDashboards() {
|
||||
await expect(this.discardChangesOkBtn, 'Leaving an unsaved pipeline must show the confirm dialog').toBeVisible({ timeout: 10000 });
|
||||
await this.discardChangesOkBtn.click();
|
||||
await expect(this.page).toHaveURL(/dashboards/, { timeout: 15000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify connection error is displayed
|
||||
*/
|
||||
|
||||
@@ -3028,6 +3028,20 @@ export class TracesPage {
|
||||
await this.page.getByRole('option', { name: String(size), exact: true }).click();
|
||||
}
|
||||
|
||||
// J/K is ignored while the list's loading prop is true (TracesSearchResultList stepTraceRow).
|
||||
async expectTraceListIdle(timeout = 30000) {
|
||||
await expect(this.page.locator(`${this.searchResultList} [data-test="o2-table"]`))
|
||||
.toHaveAttribute('data-test-loading', 'false', { timeout });
|
||||
}
|
||||
|
||||
async expectTraceListPageLoaded(prevLastIndex = 24, timeout = 30000) {
|
||||
await expect(this.page.locator(`${this.searchResultList} [data-test="o2-table-row-${prevLastIndex}"]`))
|
||||
.toHaveCount(0, { timeout });
|
||||
await this.expectTraceListIdle(timeout);
|
||||
await expect(this.page.locator(`${this.searchResultList} [data-test="o2-table-row-0"]`))
|
||||
.toBeAttached({ timeout });
|
||||
}
|
||||
|
||||
// ----- Search windows, saved views and the shared error state (#15130) -----
|
||||
|
||||
// Records every traces search the page sends; `panel` is the RED chart name or 'results'.
|
||||
|
||||
@@ -52,7 +52,7 @@ async function ensureDestination(page, destinationName, templateName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function createHistoryTestAlert(page, alertName, destinationName) {
|
||||
async function createHistoryTestAlert(page, alertName, destinationName, { enabled = true } = {}) {
|
||||
const org = getOrgIdentifier();
|
||||
const payload = {
|
||||
name: alertName,
|
||||
@@ -76,7 +76,7 @@ async function createHistoryTestAlert(page, alertName, destinationName) {
|
||||
frequency_type: 'minutes'
|
||||
},
|
||||
destinations: [destinationName],
|
||||
enabled: true,
|
||||
enabled,
|
||||
description: 'Alert history E2E test',
|
||||
context_attributes: {}
|
||||
};
|
||||
@@ -273,7 +273,8 @@ test.describe("Alert History Page", () => {
|
||||
testLogger.info('Creating alert with no history to guarantee empty results', { alertName });
|
||||
await ensureTemplate(page, templateName);
|
||||
await ensureDestination(page, destinationName, templateName);
|
||||
const createResp = await createHistoryTestAlert(page, alertName, destinationName);
|
||||
// Disabled so the scheduler never evaluates it: COUNT(*) >= 1 on the shared stream would fire within a minute.
|
||||
const createResp = await createHistoryTestAlert(page, alertName, destinationName, { enabled: false });
|
||||
if (createResp.status !== 200) {
|
||||
throw new Error(`Setup: failed to create alert: status ${createResp.status} — ${JSON.stringify(createResp.data)}`);
|
||||
}
|
||||
|
||||
@@ -1066,7 +1066,7 @@ test.describe("Dashboard Table Chart Pagination Feature - PromQL Tables", () =>
|
||||
await deleteDashboard(page, dashboardName);
|
||||
});
|
||||
|
||||
test("should display pagination controls in PromQL table with legend filter", async ({
|
||||
test("should render the pagination footer in PromQL table with legend filter", async ({
|
||||
page,
|
||||
}) => {
|
||||
const dashboardName = generateUniqueDashboardName();
|
||||
@@ -1122,8 +1122,7 @@ test.describe("Dashboard Table Chart Pagination Feature - PromQL Tables", () =>
|
||||
await page.keyboard.press('Escape'); // Dismiss any Monaco autocomplete suggestions
|
||||
|
||||
// Apply
|
||||
await pm.dashboardPanelActions.applyDashboardBtn();
|
||||
await pm.dashboardPanelActions.waitForChartToRender();
|
||||
await pm.dashboardPanelActions.applyAndWaitForPromQLQuery();
|
||||
|
||||
// Open config panel and enable pagination
|
||||
await pm.dashboardPanelConfigs.openConfigPanel();
|
||||
@@ -1135,28 +1134,16 @@ test.describe("Dashboard Table Chart Pagination Feature - PromQL Tables", () =>
|
||||
await pm.dashboardPanelConfigs.setRowsPerPage("10");
|
||||
|
||||
// Apply changes and wait for table data to load
|
||||
await pm.dashboardPanelActions.applyDashboardBtn();
|
||||
await pm.dashboardPanelActions.waitForChartToRender();
|
||||
await pm.dashboardPanelActions.applyAndWaitForPromQLQuery();
|
||||
|
||||
// Wait for the table to render with data rows
|
||||
// TenstackTable does NOT render the pagination container when rows are empty
|
||||
const tablePanel = pm.dashboardPanelActions.dashboardTable;
|
||||
await tablePanel.waitFor({ state: "visible", timeout: 15000 });
|
||||
|
||||
// Wait for table rows - if first attempt returns no data, re-apply to retry the query
|
||||
// Rows render directly in tbody, keyed data-test="o2-table-row-<index>"
|
||||
// Rows render directly in tbody, keyed data-test="o2-table-row-<index>"; data is required for pagination to render
|
||||
const tableRows = tablePanel.locator('tbody tr[data-test^="o2-table-row-"]');
|
||||
let hasRows = await tableRows.first().waitFor({ state: "visible", timeout: 15000 }).then(() => true).catch(() => false);
|
||||
|
||||
if (!hasRows) {
|
||||
testLogger.warn('No table rows on first attempt - re-applying query to retry');
|
||||
await pm.dashboardPanelActions.applyDashboardBtn();
|
||||
await pm.dashboardPanelActions.waitForChartToRender();
|
||||
hasRows = await tableRows.first().waitFor({ state: "visible", timeout: 15000 }).then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
// Data is required for pagination to render - fail clearly if still no data
|
||||
expect(hasRows, 'PromQL query "cpu_usage{}" must return data rows for pagination test - check metrics ingestion').toBe(true);
|
||||
await expect(tableRows.first(), 'PromQL query "cpu_usage{}" must return data rows for pagination test - check metrics ingestion').toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Wait for the table bottom container which holds all pagination controls
|
||||
// (PromQL with legend uses a custom bottom slot that also has this data-test)
|
||||
@@ -1167,17 +1154,10 @@ test.describe("Dashboard Table Chart Pagination Feature - PromQL Tables", () =>
|
||||
const rowsPerPageText = pm.dashboardPanelConfigs.tableRowsPerPageLabel;
|
||||
await expect(rowsPerPageText).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Wait for row count to show actual data in format "X-Y of Z"
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const el = document.querySelector('[data-test="dashboard-table-row-count"]');
|
||||
return el && /\d+-\d+\s+of\s+\d+/.test(el.textContent ?? "");
|
||||
},
|
||||
{ timeout: 15000 }
|
||||
);
|
||||
const paginationInfo = pm.dashboardPanelConfigs.tableRowCount;
|
||||
await expect(paginationInfo).toHaveText(/^\s*\d+-\d+\s+of\s+\d+\s*$/, { timeout: 15000 });
|
||||
|
||||
// Verify pagination shows correct format (e.g., "1-10 of X" or "1-N of N" if fewer records)
|
||||
// Only the footer's presence and range shape are checked; the page-size count is wrong today (#15544) and its fix adds the exact assertion.
|
||||
const paginationText = await paginationInfo.textContent();
|
||||
expect(paginationText.trim()).toMatch(/^\d+-\d+\s+of\s+\d+$/);
|
||||
|
||||
|
||||
@@ -143,18 +143,15 @@ test.describe("Date-Time Picker Forward Shift Cap testcases", () => {
|
||||
});
|
||||
|
||||
// TC-05
|
||||
test("forward shift is disabled when the window end is at or past the present", {
|
||||
test("forward shift is disabled when the window end is past the present", {
|
||||
tag: ['@date-time-next-shift-cap', '@all', '@P1']
|
||||
}, async ({ page }) => {
|
||||
testLogger.info('Verifying Next is disabled for a window already ending at now');
|
||||
testLogger.info('Verifying Next is disabled for a window ending past the present');
|
||||
|
||||
// A window whose end is at the present must not offer a forward shift, since
|
||||
// there is nothing left to shift into. This is the second gating branch of
|
||||
// isNextShiftDisabled() (endUTC + 1s > now). Time-sensitive by nature: the
|
||||
// end is derived from the page clock at paste time.
|
||||
// An end exactly at now re-enables Next ~1s later (endUTC + 1s > now), so end in the future.
|
||||
const { startMicros, endMicros } = await page.evaluate(() => {
|
||||
const now = Date.now() * 1_000;
|
||||
return { startMicros: now - 30_000_000, endMicros: now };
|
||||
return { startMicros: now - 30_000_000, endMicros: now + 300_000_000 };
|
||||
});
|
||||
|
||||
await pm.dateTimePickerPage.seedClipboard(`${startMicros} - ${endMicros}`);
|
||||
@@ -164,7 +161,7 @@ test.describe("Date-Time Picker Forward Shift Cap testcases", () => {
|
||||
|
||||
await pm.dateTimePickerPage.expectNextDisabled();
|
||||
|
||||
testLogger.info('Next disabled for a window ending at the present');
|
||||
testLogger.info('Next disabled for a window ending past the present');
|
||||
});
|
||||
|
||||
// TC-06
|
||||
|
||||
@@ -31,96 +31,15 @@ const MonacoEditorHelper = require('../utils/MonacoEditorHelper.js');
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Clear IndexedDB o2FieldValues database to ensure clean test state
|
||||
*/
|
||||
async function clearIndexedDB(page) {
|
||||
await page.evaluate(async () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.deleteDatabase('o2FieldValues');
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onblocked = () => {
|
||||
// Database is blocked - close any open connections first
|
||||
resolve(); // Continue anyway, test isolation is secondary
|
||||
};
|
||||
});
|
||||
});
|
||||
testLogger.info('Cleared IndexedDB o2FieldValues database');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all records from IndexedDB o2FieldValues database
|
||||
*/
|
||||
async function getIndexedDBRecords(page) {
|
||||
return await page.evaluate(async () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open('o2FieldValues', 1);
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onsuccess = (event) => {
|
||||
const db = event.target.result;
|
||||
if (!db.objectStoreNames.contains('fieldValues')) {
|
||||
db.close();
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
const tx = db.transaction('fieldValues', 'readonly');
|
||||
const store = tx.objectStore('fieldValues');
|
||||
const getAllReq = store.getAll();
|
||||
getAllReq.onsuccess = () => {
|
||||
db.close();
|
||||
resolve(getAllReq.result || []);
|
||||
};
|
||||
getAllReq.onerror = () => {
|
||||
db.close();
|
||||
reject(getAllReq.error);
|
||||
};
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific field record from IndexedDB
|
||||
*/
|
||||
async function getFieldRecord(page, org, streamType, streamName, fieldName) {
|
||||
const key = `${org}|${streamType}|${streamName}|${fieldName}`;
|
||||
return await page.evaluate(async (searchKey) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open('o2FieldValues', 1);
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onsuccess = (event) => {
|
||||
const db = event.target.result;
|
||||
if (!db.objectStoreNames.contains('fieldValues')) {
|
||||
db.close();
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const tx = db.transaction('fieldValues', 'readonly');
|
||||
const store = tx.objectStore('fieldValues');
|
||||
const getReq = store.get(searchKey);
|
||||
getReq.onsuccess = () => {
|
||||
db.close();
|
||||
resolve(getReq.result || null);
|
||||
};
|
||||
getReq.onerror = () => {
|
||||
db.close();
|
||||
reject(getReq.error);
|
||||
};
|
||||
};
|
||||
});
|
||||
}, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for IndexedDB to have at least one record for a given field.
|
||||
* Uses expect.poll keyed on the actual record contents — deterministic, no setTimeout.
|
||||
*/
|
||||
async function waitForFieldInIndexedDB(page, org, streamType, streamName, fieldName, timeout = 10000) {
|
||||
async function waitForFieldInIndexedDB(pm, org, streamType, streamName, fieldName, timeout = 10000) {
|
||||
let record = null;
|
||||
try {
|
||||
await expect.poll(async () => {
|
||||
record = await getFieldRecord(page, org, streamType, streamName, fieldName);
|
||||
record = await pm.logsPage.readFieldValueRecord(`${org}|${streamType}|${streamName}|${fieldName}`);
|
||||
return !!(record && record.values && record.values.length > 0);
|
||||
}, { timeout, intervals: [200, 500, 1000] }).toBe(true);
|
||||
} catch (_) {
|
||||
@@ -148,7 +67,7 @@ async function waitForFieldInIndexedDB(page, org, streamType, streamName, fieldN
|
||||
*
|
||||
* @returns the IndexedDB record ({ values, ... }) or null if none was captured.
|
||||
*/
|
||||
async function expandFieldAndCaptureValues(page, button, orgName, streamName, fieldName) {
|
||||
async function expandFieldAndCaptureValues(page, pm, button, orgName, streamName, fieldName) {
|
||||
const waitValues = () => page.waitForResponse(
|
||||
(r) => r.url().includes('_values_stream'),
|
||||
{ timeout: 20000 }
|
||||
@@ -170,7 +89,7 @@ async function expandFieldAndCaptureValues(page, button, orgName, streamName, fi
|
||||
|
||||
// The IDB write happens within a beat of the response landing, so an 8s
|
||||
// window post-response is ample (vs. the old 10s racing from click time).
|
||||
return await waitForFieldInIndexedDB(page, orgName, 'logs', streamName, fieldName, 8000);
|
||||
return await waitForFieldInIndexedDB(pm, orgName, 'logs', streamName, fieldName, 8000);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -404,7 +323,7 @@ async function findFieldWithStringValues(page, pm, orgName, streamName, maxAttem
|
||||
for (const preferredField of preferredStringFields) {
|
||||
const preferredButton = pm.logsPage.getFieldExpandButton(preferredField);
|
||||
if (await preferredButton.count() > 0) {
|
||||
const record = await expandFieldAndCaptureValues(page, preferredButton, orgName, streamName, preferredField);
|
||||
const record = await expandFieldAndCaptureValues(page, pm, preferredButton, orgName, streamName, preferredField);
|
||||
const hit = pickStringValue(preferredField, record);
|
||||
if (hit) return hit;
|
||||
}
|
||||
@@ -419,7 +338,7 @@ async function findFieldWithStringValues(page, pm, orgName, streamName, maxAttem
|
||||
// Skip if already expanded (part of preferred fields)
|
||||
if (preferredStringFields.includes(fieldName)) continue;
|
||||
|
||||
const record = await expandFieldAndCaptureValues(page, button, orgName, streamName, fieldName);
|
||||
const record = await expandFieldAndCaptureValues(page, pm, button, orgName, streamName, fieldName);
|
||||
const hit = pickStringValue(fieldName, record);
|
||||
if (hit) return hit;
|
||||
}
|
||||
@@ -494,7 +413,7 @@ test.describe("Autocomplete Value Suggestions", () => {
|
||||
testLogger.info('Testing value capture from field expansion');
|
||||
|
||||
// Clear IndexedDB to ensure clean state
|
||||
await clearIndexedDB(page);
|
||||
await pm.logsPage.clearFieldValueDB();
|
||||
|
||||
// Select stream and run query to get results
|
||||
await pm.logsPage.selectStream(streamName);
|
||||
@@ -634,7 +553,7 @@ test.describe("Autocomplete Value Suggestions", () => {
|
||||
testLogger.info('Testing persistence across page refresh');
|
||||
|
||||
// Clear IndexedDB first
|
||||
await clearIndexedDB(page);
|
||||
await pm.logsPage.clearFieldValueDB();
|
||||
|
||||
// Capture values
|
||||
await pm.logsPage.selectStream(streamName);
|
||||
@@ -654,7 +573,7 @@ test.describe("Autocomplete Value Suggestions", () => {
|
||||
// After reload the page may auto-search and add new values — the
|
||||
// persistence guarantee is that ALL values captured before the refresh
|
||||
// are still present (subset check, not exact equality).
|
||||
const recordAfter = await getFieldRecord(page, orgName, 'logs', streamName, fieldName);
|
||||
const recordAfter = await pm.logsPage.readFieldValueRecord(`${orgName}|logs|${streamName}|${fieldName}`);
|
||||
expect(recordAfter).not.toBeNull();
|
||||
expect(recordAfter.values.length).toBeGreaterThanOrEqual(valuesBefore.length);
|
||||
testLogger.info(`Values after refresh: ${recordAfter.values.length}`);
|
||||
@@ -678,7 +597,7 @@ test.describe("Autocomplete Value Suggestions", () => {
|
||||
testLogger.info('Testing stream isolation');
|
||||
|
||||
// Clear IndexedDB first
|
||||
await clearIndexedDB(page);
|
||||
await pm.logsPage.clearFieldValueDB();
|
||||
|
||||
// Capture values from first stream
|
||||
await pm.logsPage.selectStream(streamName);
|
||||
@@ -743,7 +662,7 @@ test.describe("Autocomplete Value Suggestions", () => {
|
||||
testLogger.info('Testing excluded fields are not stored');
|
||||
|
||||
// Clear IndexedDB first
|
||||
await clearIndexedDB(page);
|
||||
await pm.logsPage.clearFieldValueDB();
|
||||
|
||||
// Capture values from search
|
||||
await pm.logsPage.selectStream(streamName);
|
||||
@@ -752,7 +671,7 @@ test.describe("Autocomplete Value Suggestions", () => {
|
||||
// Wait for background capture
|
||||
|
||||
// Get all records
|
||||
const records = await getIndexedDBRecords(page);
|
||||
const records = await pm.logsPage.readAllFieldValueRecords();
|
||||
|
||||
// Check that excluded fields are not present
|
||||
// These fields should NEVER be stored (they're explicitly excluded in the feature)
|
||||
@@ -796,7 +715,7 @@ test.describe("Autocomplete Value Suggestions", () => {
|
||||
// Wait for background capture
|
||||
|
||||
// Get all records
|
||||
const records = await getIndexedDBRecords(page);
|
||||
const records = await pm.logsPage.readAllFieldValueRecords();
|
||||
|
||||
// Check that no field has more than 50 values
|
||||
const maxValues = 50;
|
||||
@@ -1098,7 +1017,7 @@ test.describe("Autocomplete Value Suggestions - Quoting Behavior", () => {
|
||||
// Response-anchored capture (not a click-time IDB race): waits for the
|
||||
// /_values_stream fetch before confirming the IndexedDB write, so a slow
|
||||
// runner can't make this null even though the data exists.
|
||||
const record = await expandFieldAndCaptureValues(page, codeFieldBtn, orgName, streamName, 'code');
|
||||
const record = await expandFieldAndCaptureValues(page, pm, codeFieldBtn, orgName, streamName, 'code');
|
||||
expect(record, 'Expected IndexedDB record for code field').not.toBeNull();
|
||||
expect(record.values.length, 'Expected captured values for code field').toBeGreaterThan(0);
|
||||
|
||||
@@ -1212,7 +1131,7 @@ test.describe("Autocomplete Value Suggestions - Cold Start & TTL", () => {
|
||||
testLogger.info('Testing cold start behavior');
|
||||
|
||||
// Clear IndexedDB completely
|
||||
await clearIndexedDB(page);
|
||||
await pm.logsPage.clearFieldValueDB();
|
||||
|
||||
// Navigate and select stream WITHOUT expanding any fields or running search
|
||||
await pm.logsPage.selectStream(streamName);
|
||||
@@ -1347,6 +1266,8 @@ test.describe("Autocomplete key contract — Enter never inserts from a menu it
|
||||
await page.goto(`${logData.logsUrl}?org_identifier=${orgName}`);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await pm.logsPage.selectStream(streamName);
|
||||
// The editor only knows the stream once the schema fetch lands, well after the option click.
|
||||
await pm.logsPage.waitForStreamFieldsLoaded();
|
||||
mode = await getSuggestMode(page);
|
||||
testLogger.info(`ZO_EDITOR_SUGGEST_MODE in the browser: ${mode}`);
|
||||
});
|
||||
@@ -1457,9 +1378,13 @@ test.describe("Autocomplete key contract — Enter never inserts from a menu it
|
||||
}, async ({ page }) => {
|
||||
await seedEditor(page, `SELECT * FROM "${streamName}" WHERE kubernetes_labels_app_kubernetes_io_version = `);
|
||||
await page.keyboard.press('Control+Space');
|
||||
const menu = await waitForMenu(page).catch(() => ({ visible: false, rows: [] }));
|
||||
await waitForMenu(page);
|
||||
// The label shows the inserted form, so a quoted Utf8 value reads '1.2' in the menu.
|
||||
test.skip(!menu.rows.some((r) => /^'?[0-9.]+'?$/.test(r)), 'no numeric-looking value captured for this field');
|
||||
await expect.poll(async () => (await menuState(page)).rows.some((r) => /^'?[0-9.]+'?$/.test(r)), {
|
||||
message: 'the version value menu should offer a numeric-looking value',
|
||||
timeout: 10000,
|
||||
}).toBe(true);
|
||||
const menu = await menuState(page);
|
||||
const row = menu.rows.findIndex((r) => /^'?[0-9.]+'?$/.test(r));
|
||||
for (let i = 0; i < row; i++) await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('Enter');
|
||||
@@ -1492,7 +1417,7 @@ test.describe("Autocomplete key contract — Enter never inserts from a menu it
|
||||
const escapeRe = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const NOT_VALUES = ['match_all', 'count', 'cast_to_arr'];
|
||||
|
||||
/** The first two quoted values the level column offers, or null when fewer were captured. */
|
||||
/** The first two quoted values the level column offers, once at least two are listed. */
|
||||
async function levelValues(page) {
|
||||
await seedEditor(page, `SELECT * FROM "${streamName}" WHERE level IN (`);
|
||||
await page.keyboard.type("'");
|
||||
@@ -1501,15 +1426,14 @@ test.describe("Autocomplete key contract — Enter never inserts from a menu it
|
||||
await expect.poll(async () => {
|
||||
quoted = (await menuState(page)).rows.filter((r) => /^'.+'$/.test(r));
|
||||
return quoted.length;
|
||||
}, { timeout: 10000 }).toBeGreaterThanOrEqual(2).catch(() => {});
|
||||
return quoted.length >= 2 ? quoted : null;
|
||||
}, { message: 'the level IN ( menu should list at least two quoted values', timeout: 10000 }).toBeGreaterThanOrEqual(2);
|
||||
return quoted;
|
||||
}
|
||||
|
||||
test("a comma inside IN opens values for the IN field, unhighlighted, without the ones chosen", {
|
||||
tag: ['@all', '@autosuggestions', '@logs', '@keycontract', '@values', '@inlist']
|
||||
}, async ({ page }) => {
|
||||
const values = await levelValues(page);
|
||||
test.skip(!values, 'fewer than two level values captured');
|
||||
await seedEditor(page, `SELECT * FROM "${streamName}" WHERE level IN (${values[0]}`);
|
||||
await page.keyboard.type(',');
|
||||
const menu = await waitForMenu(page, 10000);
|
||||
@@ -1522,7 +1446,6 @@ test.describe("Autocomplete key contract — Enter never inserts from a menu it
|
||||
tag: ['@all', '@autosuggestions', '@logs', '@keycontract', '@values', '@inlist']
|
||||
}, async ({ page }) => {
|
||||
const values = await levelValues(page);
|
||||
test.skip(!values, 'fewer than two level values captured');
|
||||
const prefix = `SELECT * FROM "${streamName}" WHERE level IN (${values[0]},`;
|
||||
await seedEditor(page, prefix);
|
||||
await page.keyboard.type(values[1][1]);
|
||||
@@ -1536,7 +1459,6 @@ test.describe("Autocomplete key contract — Enter never inserts from a menu it
|
||||
tag: ['@all', '@autosuggestions', '@logs', '@keycontract', '@values', '@inlist']
|
||||
}, async ({ page }) => {
|
||||
const values = await levelValues(page);
|
||||
test.skip(!values, 'fewer than two level values captured');
|
||||
const prefix = `SELECT * FROM "${streamName}" WHERE level NOT IN (${values[0]},`;
|
||||
await seedEditor(page, prefix);
|
||||
await page.keyboard.type(values[1][1]);
|
||||
@@ -1550,9 +1472,12 @@ test.describe("Autocomplete key contract — Enter never inserts from a menu it
|
||||
}, async ({ page }) => {
|
||||
await seedEditor(page, `SELECT * FROM "${streamName}" WHERE code IN (`);
|
||||
await page.keyboard.type(' ');
|
||||
const first = await waitForMenu(page, 10000).catch(() => ({ rows: [] }));
|
||||
const numbers = first.rows.filter((r) => /^\d+$/.test(r));
|
||||
test.skip(numbers.length < 2, 'fewer than two code values captured');
|
||||
await waitForMenu(page, 10000);
|
||||
let numbers = [];
|
||||
await expect.poll(async () => {
|
||||
numbers = (await menuState(page)).rows.filter((r) => /^\d+$/.test(r));
|
||||
return numbers.length;
|
||||
}, { message: 'the code IN ( menu should list at least two numeric values', timeout: 10000 }).toBeGreaterThanOrEqual(2);
|
||||
const prefix = `SELECT * FROM "${streamName}" WHERE code IN (${numbers[0]}, `;
|
||||
await seedEditor(page, prefix);
|
||||
await page.keyboard.press('Control+Space');
|
||||
@@ -1630,6 +1555,7 @@ test.describe("Autocomplete key contract — chained menus (Automatic)", () => {
|
||||
await page.goto(`${logData.logsUrl}?org_identifier=${orgName}`);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await pm.logsPage.selectStream(streamName);
|
||||
await pm.logsPage.waitForStreamFieldsLoaded();
|
||||
mode = await getSuggestMode(page);
|
||||
});
|
||||
|
||||
@@ -1777,7 +1703,10 @@ test.describe("Autocomplete key contract — chained menus (Automatic)", () => {
|
||||
await expect.poll(() => editorText(page)).toBe(`${WHERE}level = `);
|
||||
const values = await waitForMenu(page, 10000);
|
||||
expect(values.focused, 'the chained value menu opens unhighlighted').toBeNull();
|
||||
test.skip(!values.rows.some((r) => /e/i.test(r) && !/values/i.test(r)), 'no level value with an e captured');
|
||||
await expect.poll(async () => (await menuState(page)).rows.some((r) => /e/i.test(r) && !/values/i.test(r)), {
|
||||
message: 'the chained level value menu should list a value containing e',
|
||||
timeout: 10000,
|
||||
}).toBe(true);
|
||||
await page.keyboard.type('e');
|
||||
await waitForMenu(page);
|
||||
await page.keyboard.press('Tab');
|
||||
@@ -1802,8 +1731,12 @@ test.describe("Autocomplete key contract — chained menus (Automatic)", () => {
|
||||
test.skip(mode === 'off', 'off is the editor before the fix');
|
||||
await seedEditor(page, `${WHERE}level = `);
|
||||
await page.keyboard.type("'");
|
||||
const menu = await waitForMenu(page, 10000).catch(() => null);
|
||||
test.skip(!menu, 'no level values captured');
|
||||
await waitForMenu(page, 10000);
|
||||
await expect.poll(async () => (await menuState(page)).rows.some((r) => /^'/.test(r)), {
|
||||
message: 'the opening-quote menu should list a level value',
|
||||
timeout: 10000,
|
||||
}).toBe(true);
|
||||
const menu = await menuState(page);
|
||||
await expect.poll(async () => (await menuState(page)).focused, { timeout: 2000 }).toBe(menu.rows[0]);
|
||||
await page.keyboard.press('Enter');
|
||||
expect(await editorText(page)).toBe(`${WHERE}level = ${menu.rows[0]}`);
|
||||
@@ -1823,7 +1756,7 @@ test.describe("Autocomplete key contract — chained menus (Automatic)", () => {
|
||||
test("a value fetch slower than the budget shows one row that inserts nothing", {
|
||||
tag: ['@all', '@autosuggestions', '@logs', '@keycontract', '@values']
|
||||
}, async ({ page }) => {
|
||||
await clearIndexedDB(page);
|
||||
await pm.logsPage.clearFieldValueDB();
|
||||
await page.route((url) => url.toString().includes('/_values?'), async (route) => {
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
await route.continue();
|
||||
@@ -1848,7 +1781,7 @@ test.describe("Autocomplete key contract — chained menus (Automatic)", () => {
|
||||
test("Esc during a slow value fetch: the values never pop up", {
|
||||
tag: ['@all', '@autosuggestions', '@logs', '@keycontract', '@values']
|
||||
}, async ({ page }) => {
|
||||
await clearIndexedDB(page);
|
||||
await pm.logsPage.clearFieldValueDB();
|
||||
await page.route((url) => url.toString().includes('/_values?'), async (route) => {
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
await route.continue();
|
||||
@@ -1865,7 +1798,7 @@ test.describe("Autocomplete key contract — chained menus (Automatic)", () => {
|
||||
test("← during a slow value fetch drops the answer", {
|
||||
tag: ['@all', '@autosuggestions', '@logs', '@keycontract', '@values']
|
||||
}, async ({ page }) => {
|
||||
await clearIndexedDB(page);
|
||||
await pm.logsPage.clearFieldValueDB();
|
||||
await page.route((url) => url.toString().includes('/_values?'), async (route) => {
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
await route.continue();
|
||||
@@ -1882,7 +1815,7 @@ test.describe("Autocomplete key contract — chained menus (Automatic)", () => {
|
||||
test("typing forward during a slow value fetch keeps the values", {
|
||||
tag: ['@all', '@autosuggestions', '@logs', '@keycontract', '@values']
|
||||
}, async ({ page }) => {
|
||||
await clearIndexedDB(page);
|
||||
await pm.logsPage.clearFieldValueDB();
|
||||
await page.route((url) => url.toString().includes('/_values?'), async (route) => {
|
||||
// Well under the 1000 ms budget, so the answer is pending, not past it.
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
@@ -1901,7 +1834,7 @@ test.describe("Autocomplete key contract — chained menus (Automatic)", () => {
|
||||
tag: ['@all', '@autosuggestions', '@logs', '@keycontract', '@values']
|
||||
}, async ({ page }) => {
|
||||
test.skip(mode === 'off', 'off is the editor before the fix');
|
||||
await clearIndexedDB(page);
|
||||
await pm.logsPage.clearFieldValueDB();
|
||||
await page.route((url) => url.toString().includes('/_values?'), async (route) => {
|
||||
await new Promise((r) => setTimeout(r, 4000));
|
||||
await route.continue();
|
||||
@@ -1944,27 +1877,33 @@ test.describe("Autocomplete group headers — never a target", () => {
|
||||
const orgName = process.env["ORGNAME"] || 'default';
|
||||
const WHERE = `SELECT * FROM "${streamName}" WHERE `;
|
||||
let mode;
|
||||
let pm;
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
testLogger.testStart(testInfo.title, testInfo.file);
|
||||
await navigateToBase(page);
|
||||
const pm = new PageManager(page);
|
||||
pm = new PageManager(page);
|
||||
await ingestTestData(page);
|
||||
await waitForStreamData(page, streamName, 1).catch(() => {});
|
||||
await page.goto(`${logData.logsUrl}?org_identifier=${orgName}`);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await pm.logsPage.selectStream(streamName);
|
||||
await pm.logsPage.waitForStreamFieldsLoaded('level', 'now');
|
||||
mode = await getSuggestMode(page);
|
||||
test.skip(mode === 'off', 'off keeps the flat menus');
|
||||
});
|
||||
|
||||
/** The value menu for level, opened by a typed space; null when fewer than two level values were captured. */
|
||||
/** The value menu for level, opened by a typed space, once at least two level values are listed. */
|
||||
async function valueMenu(page) {
|
||||
await seedEditor(page, `${WHERE}level =`);
|
||||
await page.keyboard.type(' ');
|
||||
const menu = await waitForMenu(page, 10000).catch(() => null);
|
||||
// Only the captured data decides a skip, so a menu that lost its headers fails the header assertions.
|
||||
return menu && menu.rows.filter((r) => /^'/.test(r)).length >= 2 ? menu : null;
|
||||
await waitForMenu(page, 10000);
|
||||
// The menu can open on a still-loading row, so wait for the values themselves.
|
||||
await expect.poll(async () => (await menuState(page)).rows.filter((r) => /^'/.test(r)).length, {
|
||||
message: 'the level value menu should list at least two quoted values',
|
||||
timeout: 10000,
|
||||
}).toBeGreaterThanOrEqual(2);
|
||||
return await menuState(page);
|
||||
}
|
||||
const undoKey = (page) => page.evaluate(() => (/Macintosh/.test(navigator.userAgent) ? 'Meta' : 'Control'));
|
||||
|
||||
@@ -1972,7 +1911,6 @@ test.describe("Autocomplete group headers — never a target", () => {
|
||||
tag: ['@all', '@autosuggestions', '@logs', '@headers']
|
||||
}, async ({ page }) => {
|
||||
const menu = await valueMenu(page);
|
||||
test.skip(!menu, 'fewer than two level values captured');
|
||||
// Monaco renders only the rows in view, so the Functions header below the fields may not be in the DOM yet.
|
||||
expect(menu.headers.slice(0, 2)).toEqual(['Values · recent', 'Fields']);
|
||||
expect(menu.focused).toBeNull();
|
||||
@@ -1990,8 +1928,12 @@ test.describe("Autocomplete group headers — never a target", () => {
|
||||
await waitForMenu(page);
|
||||
await page.keyboard.press('Tab');
|
||||
await expect.poll(() => editorText(page)).toBe(`${WHERE}level = `);
|
||||
const values = await waitForMenu(page, 10000);
|
||||
test.skip(!values.rows.some((r) => /^'/.test(r)), 'no level value captured');
|
||||
await waitForMenu(page, 10000);
|
||||
await expect.poll(async () => (await menuState(page)).rows.some((r) => /^'/.test(r)), {
|
||||
message: 'the chained value menu should list a level value',
|
||||
timeout: 10000,
|
||||
}).toBe(true);
|
||||
const values = await menuState(page);
|
||||
await page.keyboard.press('Tab');
|
||||
await expect.poll(() => editorText(page)).toBe(`${WHERE}level = ${values.rows[0]}`);
|
||||
});
|
||||
@@ -2000,7 +1942,6 @@ test.describe("Autocomplete group headers — never a target", () => {
|
||||
tag: ['@all', '@autosuggestions', '@logs', '@headers']
|
||||
}, async ({ page }) => {
|
||||
const menu = await valueMenu(page);
|
||||
test.skip(!menu, 'fewer than two level values captured');
|
||||
await page.keyboard.press('Tab');
|
||||
await expect.poll(() => editorText(page)).toBe(`${WHERE}level = ${menu.rows[0]}`);
|
||||
});
|
||||
@@ -2009,7 +1950,6 @@ test.describe("Autocomplete group headers — never a target", () => {
|
||||
tag: ['@all', '@autosuggestions', '@logs', '@headers']
|
||||
}, async ({ page }) => {
|
||||
const menu = await valueMenu(page);
|
||||
test.skip(!menu, 'fewer than two level values captured');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await expect.poll(async () => (await menuState(page)).focused).toBe(menu.rows[0]);
|
||||
await page.keyboard.press('ArrowDown');
|
||||
@@ -2022,7 +1962,6 @@ test.describe("Autocomplete group headers — never a target", () => {
|
||||
tag: ['@all', '@autosuggestions', '@logs', '@headers']
|
||||
}, async ({ page }) => {
|
||||
const menu = await valueMenu(page);
|
||||
test.skip(!menu, 'fewer than two level values captured');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('ArrowUp');
|
||||
await expect.poll(async () => (await menuState(page)).focused).toBe(menu.rows[0]);
|
||||
@@ -2037,7 +1976,6 @@ test.describe("Autocomplete group headers — never a target", () => {
|
||||
tag: ['@all', '@autosuggestions', '@logs', '@headers']
|
||||
}, async ({ page }) => {
|
||||
const menu = await valueMenu(page);
|
||||
test.skip(!menu, 'fewer than two level values captured');
|
||||
await page.keyboard.press('Control+Space');
|
||||
await expect.poll(async () => (await menuState(page)).focused).toBe(menu.rows[0]);
|
||||
await page.keyboard.press('Enter');
|
||||
@@ -2048,7 +1986,6 @@ test.describe("Autocomplete group headers — never a target", () => {
|
||||
tag: ['@all', '@autosuggestions', '@logs', '@headers']
|
||||
}, async ({ page }) => {
|
||||
const menu = await valueMenu(page);
|
||||
test.skip(!menu, 'fewer than two level values captured');
|
||||
for (const header of menu.headers) {
|
||||
await page.locator('.monaco-editor .suggest-widget.visible .monaco-list-row', { has: page.locator('.codicon-symbol-folder') })
|
||||
.filter({ hasText: header }).first().click({ force: true });
|
||||
@@ -2064,7 +2001,6 @@ test.describe("Autocomplete group headers — never a target", () => {
|
||||
tag: ['@all', '@autosuggestions', '@logs', '@headers']
|
||||
}, async ({ page }) => {
|
||||
const menu = await valueMenu(page);
|
||||
test.skip(!menu, 'fewer than two level values captured');
|
||||
await page.keyboard.type('k8s');
|
||||
await expect.poll(async () => (await menuState(page)).headers).toEqual(['Fields']);
|
||||
const after = await menuState(page);
|
||||
@@ -2076,12 +2012,10 @@ test.describe("Autocomplete group headers — never a target", () => {
|
||||
tag: ['@all', '@autosuggestions', '@logs', '@headers']
|
||||
}, async ({ page }) => {
|
||||
const menu = await valueMenu(page);
|
||||
test.skip(!menu, 'fewer than two level values captured');
|
||||
const whole = menu.rows[0].replace(/'/g, '');
|
||||
await page.keyboard.type(whole);
|
||||
const after = await waitForMenu(page, 5000);
|
||||
test.skip(after.headers.includes('Fields'), `a field also starts with ${whole}`);
|
||||
expect(after.headers).toEqual(['Values · recent']);
|
||||
expect(after.headers, `only values should match "${whole}"; a Fields header means the test data has a field with that prefix`).toEqual(['Values · recent']);
|
||||
expect(after.focused).toBe(menu.rows[0]);
|
||||
});
|
||||
|
||||
@@ -2147,7 +2081,7 @@ test.describe("Autocomplete group headers — never a target", () => {
|
||||
test("Tab on a menu that holds only a header and a still-loading row inserts nothing and closes it", {
|
||||
tag: ['@all', '@autosuggestions', '@logs', '@headers', '@values']
|
||||
}, async ({ page }) => {
|
||||
await clearIndexedDB(page);
|
||||
await pm.logsPage.clearFieldValueDB();
|
||||
await page.route((url) => url.toString().includes('/_values?'), async (route) => {
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
await route.continue();
|
||||
@@ -2181,7 +2115,6 @@ test.describe("Autocomplete group headers — never a target", () => {
|
||||
tag: ['@all', '@autosuggestions', '@logs', '@headers']
|
||||
}, async ({ page }) => {
|
||||
const menu = await valueMenu(page);
|
||||
test.skip(!menu, 'fewer than two level values captured');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect.poll(async () => (await menuState(page)).visible).toBe(false);
|
||||
expect(await editorText(page)).toBe(`${WHERE}level = `);
|
||||
@@ -2191,7 +2124,6 @@ test.describe("Autocomplete group headers — never a target", () => {
|
||||
tag: ['@all', '@autosuggestions', '@logs', '@headers']
|
||||
}, async ({ page }) => {
|
||||
const menu = await valueMenu(page);
|
||||
test.skip(!menu, 'fewer than two level values captured');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('Enter');
|
||||
await expect.poll(() => editorText(page)).toBe(`${WHERE}level = ${menu.rows[0]}`);
|
||||
@@ -2219,6 +2151,7 @@ test.describe("Autocomplete — after a complete predicate, AND and OR lead", ()
|
||||
await page.goto(`${logData.logsUrl}?org_identifier=${orgName}`);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await pm.logsPage.selectStream(streamName);
|
||||
await pm.logsPage.waitForStreamFieldsLoaded();
|
||||
mode = await getSuggestMode(page);
|
||||
});
|
||||
|
||||
@@ -2387,6 +2320,7 @@ test.describe("Autocomplete zero-setup row — never pressing Tab gives today's
|
||||
await waitForStreamData(page, streamName, 1).catch(() => {});
|
||||
await page.goto(`${logData.logsUrl}?org_identifier=${orgName}`);
|
||||
await pm.logsPage.selectStream(streamName);
|
||||
await pm.logsPage.waitForStreamFieldsLoaded();
|
||||
const mode = await getSuggestMode(page);
|
||||
const results = {};
|
||||
for (const sequence of ZERO_SETUP_SEQUENCES) {
|
||||
|
||||
@@ -87,6 +87,11 @@ async function openResults(page) {
|
||||
const searches = trackSearches(page);
|
||||
await page.goto(urlFor());
|
||||
await page.locator(editor).first().waitFor({ timeout: 60000 });
|
||||
// The stream URL runs its own search on entry; clicking the shared run/cancel button mid-flight cancels it.
|
||||
await expect
|
||||
.poll(() => searches.hits().length, { timeout: 30000 })
|
||||
.toBeGreaterThan(0);
|
||||
await new PageManager(page).logsPage.waitForRunQueryButton();
|
||||
const before = searches.hits().length;
|
||||
await page.locator(runBtn).click();
|
||||
await expect
|
||||
@@ -543,6 +548,8 @@ test.describe("Logs J/K navigation (item 4a)", () => {
|
||||
).toHaveCount(0, {
|
||||
timeout: 60000,
|
||||
});
|
||||
// J is ignored until the refreshed hits settle, and the progress bar can be absent before they start.
|
||||
await logs.waitForRunQueryButton();
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.activeElement instanceof HTMLElement &&
|
||||
|
||||
@@ -36,14 +36,15 @@ async function open(page) {
|
||||
await navigateToBase(page);
|
||||
await page.goto(`/web/logs?org_identifier=${ORG}&stream=${STREAM}&stream_type=logs&period=15m&refresh=0&sql_mode=false&quick_mode=false&show_histogram=false`);
|
||||
await page.locator('[data-test="logs-search-bar-query-editor"]').waitFor();
|
||||
await page.locator(run).click();
|
||||
// The stream URL runs its own search on entry; clicking the shared run/cancel button mid-flight cancels it.
|
||||
await expect(page.locator(`${table} tbody tr[data-test^="o2-table-row-"]`)).toHaveCount(2);
|
||||
await expect(page.locator(run)).toHaveAttribute('title', 'Run query');
|
||||
await expect(page.locator(run)).toContainText('Run query');
|
||||
}
|
||||
async function settleMenu(page) {
|
||||
await page.locator('[role="menu"]').first().evaluate(async (element) => {
|
||||
await Promise.all(element.getAnimations({ subtree: true }).map((animation) => animation.finished));
|
||||
});
|
||||
// A closing menu stays mounted for its exit animation, and a cancelled animation rejects `finished`.
|
||||
const menu = page.locator('[role="menu"][data-state="open"]').first();
|
||||
await expect.poll(() => menu.evaluate((element) => element.getAnimations({ subtree: true }).filter((animation) => animation.playState === 'running').length)).toBe(0);
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
@@ -329,12 +329,15 @@ test.describe('Logs bare-word search (item 1)', () => {
|
||||
}, async ({ page }) => {
|
||||
const logsPage = new PageManager(page).logsPage;
|
||||
await open(page, FTS, 'timeout');
|
||||
await logsPage.expectFieldListItemVisible('body');
|
||||
await logsPage._setSqlModeViaVue(true);
|
||||
await expect
|
||||
.poll(() => editorText(page), { timeout: 15000 })
|
||||
.toBe(`SELECT * FROM "${FTS}" WHERE match_all('timeout')`);
|
||||
|
||||
await open(page, NOFTS, 'timeout');
|
||||
// Before the stream schema loads the planner cannot tell a no-FTS stream, so toggling then renders raw SQL.
|
||||
await logsPage.expectFieldListItemVisible('msg_text');
|
||||
await logsPage._setSqlModeViaVue(true);
|
||||
await expect(page.locator(panel)).toBeVisible({ timeout: 15000 });
|
||||
await expect.poll(() => logsPage._isSqlModeEnabledViaVue()).toBe(false);
|
||||
|
||||
@@ -22,7 +22,9 @@ async function open(page, streams = NOFTS) {
|
||||
const requests = trackSearches(page);
|
||||
await page.goto(url(streams));
|
||||
await page.locator('[data-test="logs-search-bar-query-editor"]').waitFor();
|
||||
await page.locator(run).click();
|
||||
// The stream URL runs its own search on entry; clicking the shared run/cancel button mid-flight cancels it.
|
||||
await expect(page.locator(panel).or(page.locator('[data-test="logs-missing-stream-banner"]')).first()).toBeVisible();
|
||||
await expect(page.locator(run)).toHaveAttribute('title', 'Run query');
|
||||
return requests;
|
||||
}
|
||||
async function total(request, stream) {
|
||||
@@ -121,6 +123,8 @@ test.describe('No-FTS recovery C1', () => {
|
||||
const banner = page.locator('[data-test="logs-missing-stream-banner"]');
|
||||
await expect(banner).toContainText(NOFTS);
|
||||
await expect(page.locator('[data-test="logs-search-result-logs-table"] tbody tr[data-test^="o2-table-row-"]')).toHaveCount(1);
|
||||
// The page-count search follows the hits search, so count only after it has gone out.
|
||||
await expect.poll(() => requests.all().some((entry) => entry.type === 'pageCount')).toBe(true);
|
||||
const before = requests.all().length;
|
||||
await banner.locator('[data-test="logs-no-fts-search-fields-btn"]').click();
|
||||
await expect(banner).toContainText(`Search ${NOFTS} only; ${FTS} will be deselected.`);
|
||||
|
||||
@@ -252,7 +252,9 @@ test.describe("Logs Query Builder - Query Mode Toggle", () => {
|
||||
const histogramQuery = 'SELECT histogram(_timestamp) as "x_axis_1", count(_timestamp) as "y_axis_1" FROM "e2e_automate" GROUP BY x_axis_1';
|
||||
await setupQueryAndSwitchToBuild(pm, page, histogramQuery);
|
||||
|
||||
// Custom mode copies the builder's generated SQL once, so it must exist before the switch.
|
||||
await pm.logsPage.expectBuilderModeActive();
|
||||
await pm.logsPage.expectQueryEditorPopulated();
|
||||
await pm.logsPage.clickCustomQueryType();
|
||||
await pm.logsPage.expectCustomModeActive();
|
||||
|
||||
|
||||
@@ -156,7 +156,8 @@ test.describe("Logs Visualization State Persistence testcases", () => {
|
||||
|
||||
// 1. Verify stream is selected and results visible (done in beforeEach).
|
||||
|
||||
// 2. Reload the page.
|
||||
// 2. Reload the page once the selection is in the URL; the logs page writes it there after the search settles.
|
||||
await expect(page).toHaveURL(/[?&]stream=e2e_automate(&|$)/);
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@ const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures
|
||||
const PageManager = require('../../pages/page-manager.js');
|
||||
const testLogger = require('../utils/test-logger.js');
|
||||
const logData = require("../../fixtures/log.json");
|
||||
const { ingestTestData: ingestion } = require('../utils/data-ingestion.js');
|
||||
const {
|
||||
ingestTestData: ingestion,
|
||||
waitForStreamData,
|
||||
waitForStreamListed,
|
||||
} = require('../utils/data-ingestion.js');
|
||||
|
||||
test.describe("Logs Table Field Management - Complete Test Suite", () => {
|
||||
let pageManager;
|
||||
@@ -712,7 +716,15 @@ test.describe("Severity inference journeys (4c Part A)", () => {
|
||||
const url = `${process.env.INGESTION_URL}/api/${getOrgIdentifier()}/${journeyStream}/_json`;
|
||||
const response = await page.request.post(url, { headers: getAuthHeaders(), data: JOURNEY_ROWS });
|
||||
expect(response.ok()).toBe(true);
|
||||
await page.waitForTimeout(3000);
|
||||
// A single search runs in beforeEach, so every journey row must already be queryable.
|
||||
expect(
|
||||
await waitForStreamData(page, journeyStream, JOURNEY_ROWS.length, 60000),
|
||||
`stream ${journeyStream} never returned all ${JOURNEY_ROWS.length} rows within 60s`,
|
||||
).toBe(true);
|
||||
expect(
|
||||
await waitForStreamListed(page, journeyStream, 'logs'),
|
||||
`stream ${journeyStream} never appeared in the streams list`,
|
||||
).toBe(true);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -546,44 +546,25 @@ test.describe("Metrics PromQL Builder Mode testcases", () => {
|
||||
testLogger.info('Default line chart query executed');
|
||||
|
||||
// 2. Switch to table chart type
|
||||
const tableSelected = await builder.selectChartType('table');
|
||||
if (tableSelected) {
|
||||
testLogger.info('Switched to table chart type');
|
||||
await builder.selectChartType('table');
|
||||
testLogger.info('Switched to table chart type');
|
||||
|
||||
// 3. Run query again with table chart
|
||||
const respPromise2 = builder.waitForQueryResponse(15000);
|
||||
await builder.clickRunQuery();
|
||||
await respPromise2;
|
||||
// 3. Run query again with table chart; the chart switch fires its own run, so settle before and after
|
||||
await builder.waitForPromqlIdle();
|
||||
await builder.clickRunQuery();
|
||||
await builder.waitForPromqlIdle();
|
||||
|
||||
// 4. Verify no errors
|
||||
expect(await pm.metricsPage.isErrorNotificationVisible()).toBe(false);
|
||||
testLogger.info('Table chart query executed without errors');
|
||||
// 4. Verify no errors
|
||||
expect(await pm.metricsPage.isErrorNotificationVisible()).toBe(false);
|
||||
testLogger.info('Table chart query executed without errors');
|
||||
|
||||
// 5. Verify table or chart rendered
|
||||
const rendered = await builder.isChartRendered();
|
||||
expect(rendered).toBe(true);
|
||||
testLogger.info('Table/chart rendered successfully');
|
||||
// 5. Verify the table rendered
|
||||
await builder.expectTableRendered();
|
||||
testLogger.info('Table rendered successfully');
|
||||
|
||||
// 6. Switch back to line chart
|
||||
await builder.selectChartType('line');
|
||||
testLogger.info('Switched back to line chart');
|
||||
} else {
|
||||
// Chart type selector might not be visible on metrics page
|
||||
// (it may only appear in dashboard panel editor context)
|
||||
testLogger.info('Table chart type not available on metrics page - checking area chart');
|
||||
|
||||
// Try area chart instead
|
||||
const areaSelected = await builder.selectChartType('area');
|
||||
if (areaSelected) {
|
||||
const respPromise3 = builder.waitForQueryResponse(15000);
|
||||
await builder.clickRunQuery();
|
||||
await respPromise3;
|
||||
expect(await pm.metricsPage.isErrorNotificationVisible()).toBe(false);
|
||||
testLogger.info('Area chart query executed without errors');
|
||||
} else {
|
||||
testLogger.info('Chart type selector not available on metrics page (expected in dashboard context)');
|
||||
}
|
||||
}
|
||||
// 6. Switch back to line chart
|
||||
await builder.selectChartType('line');
|
||||
testLogger.info('Switched back to line chart');
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
@@ -903,8 +884,7 @@ test.describe("Metrics PromQL Builder Mode testcases", () => {
|
||||
testLogger.info('Builder state verified: metric, filter, operation, options all correct');
|
||||
|
||||
// 8. Switch to Table chart type
|
||||
const tableSelected = await builder.selectChartType('table');
|
||||
expect(tableSelected).toBe(true);
|
||||
await builder.selectChartType('table');
|
||||
testLogger.info('Switched to table chart type');
|
||||
|
||||
// 9. Run query and intercept API request to verify PromQL query string
|
||||
@@ -1083,8 +1063,7 @@ test.describe("Metrics PromQL Builder Mode testcases", () => {
|
||||
testLogger.info('Builder state verified');
|
||||
|
||||
// 7. Switch to table chart
|
||||
const tableSelected = await builder.selectChartType('table');
|
||||
expect(tableSelected).toBe(true);
|
||||
await builder.selectChartType('table');
|
||||
testLogger.info('Table chart selected');
|
||||
|
||||
// 8. Run query and capture API request
|
||||
|
||||
@@ -462,70 +462,11 @@ test.describe("Metrics testcases", () => {
|
||||
// Enter invalid PromQL query with actual syntax error (unclosed parenthesis)
|
||||
await pm.metricsPage.enterMetricsQuery('sum(rate(');
|
||||
|
||||
// Wait for the API error response after clicking Apply
|
||||
const apiResponsePromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/_search') || resp.url().includes('/api/'),
|
||||
{ timeout: 15000 }
|
||||
).catch(() => null);
|
||||
await pm.metricsPage.clickApplyButton();
|
||||
await apiResponsePromise;
|
||||
|
||||
// Wait for the panel to settle into a graceful state after the invalid query.
|
||||
// The metrics panel handles invalid PromQL in one of three ways, all valid:
|
||||
// 1. an explicit error -> [data-test="dashboard-error"] (DashboardErrors) or
|
||||
// [data-test="panel-schema-renderer-error-message"] (chart error), OR
|
||||
// 2. a no-data placeholder -> rendered via OEmptyState (own data-test="o2-empty-state",
|
||||
// scoped under the panel-editor container; [data-test="no-data"] as dev fallback), OR
|
||||
// 3. the chart container still renders without crashing -> [data-test="chart-renderer"].
|
||||
// Case 3 is what production hits: the query fails without setting an error and noData is
|
||||
// empty (so the OEmptyState overlay is intentionally suppressed), yet the panel mounts the
|
||||
// chart path cleanly. A genuinely broken/blank panel would match none of these and still
|
||||
// time out, so this stays a meaningful "handled without crashing" gate.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const dashError = document.querySelector('[data-test="dashboard-error"]');
|
||||
const chartError = document.querySelector('[data-test="panel-schema-renderer-error-message"]');
|
||||
const noData = document.querySelector(
|
||||
'[data-test="no-data"], [data-test="panel-editor-container"] [data-test="o2-empty-state"]'
|
||||
);
|
||||
const chartRendered = document.querySelector('[data-test="chart-renderer"]');
|
||||
return !!dashError || !!chartError || !!noData || !!chartRendered;
|
||||
},
|
||||
null,
|
||||
{ timeout: 15000 }
|
||||
);
|
||||
|
||||
const inlineErrorLocator = pm.metricsPage.getDashboardError();
|
||||
const chartErrorLocator = pm.metricsPage.getChartErrorMessage();
|
||||
const noDataLocator = await pm.metricsPage.getNoDataMessage();
|
||||
const chartRendererLocator = pm.metricsPage.getChartRenderer();
|
||||
|
||||
const hasInlineError = await inlineErrorLocator.isVisible().catch(() => false);
|
||||
const hasChartError = await chartErrorLocator.isVisible().catch(() => false);
|
||||
const hasNoData = await noDataLocator.isVisible().catch(() => false);
|
||||
// Also check DOM presence (no-data can render with empty text and 0px height)
|
||||
const noDataInDom = await noDataLocator.count() > 0;
|
||||
// Chart container still attached => panel handled the invalid query without crashing.
|
||||
const chartRendered = await chartRendererLocator.count() > 0;
|
||||
|
||||
testLogger.info(`Invalid query state: hasInlineError=${hasInlineError}, hasChartError=${hasChartError}, hasNoData=${hasNoData}, noDataInDom=${noDataInDom}, chartRendered=${chartRendered}`);
|
||||
|
||||
// System must handle invalid syntax gracefully - show error, no data, or keep the
|
||||
// panel rendered (no crash / blank screen).
|
||||
const handledGracefully = hasInlineError || hasChartError || hasNoData || noDataInDom || chartRendered;
|
||||
expect(handledGracefully).toBe(true);
|
||||
|
||||
if (hasInlineError) {
|
||||
const errorText = await inlineErrorLocator.first().textContent().catch(() => '');
|
||||
testLogger.info(`Dashboard error displayed: ${errorText.substring(0, 100)}`);
|
||||
} else if (hasChartError) {
|
||||
const errorText = await chartErrorLocator.first().textContent().catch(() => '');
|
||||
testLogger.info(`Chart error displayed: ${errorText.substring(0, 100)}`);
|
||||
} else if (hasNoData || noDataInDom) {
|
||||
testLogger.info('Invalid query handled gracefully - no-data state (no crash, loading completed)');
|
||||
} else {
|
||||
testLogger.info('Invalid query handled gracefully - panel rendered without crashing (no error/no-data indicator shown)');
|
||||
}
|
||||
// The setup query leaves a chart mounted, so only a surfaced error proves this query was handled.
|
||||
const errorText = await pm.metricsPage.expectQueryErrorVisible();
|
||||
testLogger.info(`Invalid query error displayed: ${errorText.substring(0, 100)}`);
|
||||
});
|
||||
|
||||
test("Query for non-existent metric", {
|
||||
|
||||
@@ -502,30 +502,9 @@ test.describe("Traces Pipeline Tests", { tag: ['@all', '@pipelines', '@traces',
|
||||
await pageManager.pipelinesPage.selectStreamOptionByName(TRACES_STREAM);
|
||||
await pageManager.pipelinesPage.saveInputNodeStream();
|
||||
|
||||
// Store current URL before navigation attempt
|
||||
const beforeUrl = page.url();
|
||||
|
||||
// Try to navigate to dashboards — onBeforeRouteLeave fires and shows a Vue
|
||||
// ConfirmDialog (data-test="confirm-dialog") instead of window.confirm.
|
||||
// onBeforeRouteLeave must hold the navigation behind the confirm dialog; accepting it completes the navigation.
|
||||
await pageManager.pipelinesPage.clickDashboardsMenu();
|
||||
|
||||
// Check if the Vue confirm dialog appeared (unsaved changes prompt).
|
||||
const dialogVisible = await pageManager.pipelinesPage.discardChangesOkBtn
|
||||
.isVisible({ timeout: 5000 }).catch(() => false);
|
||||
|
||||
if (dialogVisible) {
|
||||
testLogger.debug('Vue confirm dialog appeared — accepting to proceed');
|
||||
await pageManager.pipelinesPage.discardChangesOkBtn.click();
|
||||
}
|
||||
|
||||
// Wait to see if navigation happens after accepting (or if it happened directly)
|
||||
await page.waitForURL(/dashboards/, { timeout: 5000 }).catch(() => {
|
||||
testLogger.info('Navigation blocked or no dialog appeared');
|
||||
});
|
||||
|
||||
// Either the Vue confirm dialog appeared or navigation went straight through
|
||||
const urlChanged = page.url() !== beforeUrl;
|
||||
expect(dialogVisible || urlChanged).toBe(true);
|
||||
await pageManager.pipelinesPage.acceptUnsavedChangesPromptAndExpectDashboards();
|
||||
|
||||
testLogger.info('Test completed: Unsaved changes behavior verified');
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ const {
|
||||
navigateToBase,
|
||||
} = require("../utils/enhanced-baseFixtures.js");
|
||||
const { getAuthHeaders } = require("../utils/cloud-auth.js");
|
||||
const PageManager = require("../../pages/page-manager.js");
|
||||
|
||||
const ORG = `jktraces${Date.now()}`;
|
||||
const STREAM = "jk_traces";
|
||||
@@ -130,6 +131,8 @@ async function openList(page) {
|
||||
`/web/traces?org_identifier=${ORG}&stream=${STREAM}&period=1h`,
|
||||
);
|
||||
await expect(page.locator(listRow(24))).toBeAttached({ timeout: 60000 });
|
||||
// Rows render before the search settles; J pressed while loading is dropped.
|
||||
await new PageManager(page).tracesPage.expectTraceListIdle();
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.activeElement instanceof HTMLElement &&
|
||||
@@ -279,6 +282,8 @@ test.describe("Traces J/K navigation (item 4a)", () => {
|
||||
"2",
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
// The pager flips to 2 before page 2's rows arrive; J is ignored until then.
|
||||
await new PageManager(page).tracesPage.expectTraceListPageLoaded();
|
||||
await expect(activeListRows(page)).toHaveCount(0);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
|
||||
Reference in New Issue
Block a user