From 2ab9d091d239e07c4d5854edfa5d3eeaa01422e9 Mon Sep 17 00:00:00 2001 From: Shrinath Rao Date: Tue, 20 Oct 2026 05:12:14 +0000 Subject: [PATCH] test: fix root causes of the flakiest UI tests (#15535) --- .../dashboardPages/dashboard-panel-actions.js | 11 + .../pages/generalPages/enrichmentPage.js | 57 ++--- tests/ui-testing/pages/iamPages/iamPage.js | 2 + tests/ui-testing/pages/logsPages/logsPage.js | 104 ++++++++- .../pages/metricsPages/metricsBuilderPage.js | 17 +- .../pages/metricsPages/metricsPage.js | 17 ++ .../pipelineImportExportPage.js | 19 +- .../pages/pipelinesPages/pipelinesPage.js | 6 + .../pages/tracesPages/tracesPage.js | 14 ++ .../Alerts/alerts-history.spec.js | 7 +- .../dashboard-table-pagination.spec.js | 34 +-- .../GeneralTests/dateTime.spec.js | 13 +- .../logs-autocomplete-suggestions.spec.js | 204 ++++++------------ .../Logs/logs-jk-navigation.spec.js | 7 + .../Logs/logsActionReasons.spec.js | 9 +- .../Logs/logsFreeText.spec.js | 3 + .../Logs/logsNoFtsRecovery.spec.js | 6 +- .../Logs/logsQueryBuilder-editor.spec.js | 2 + .../Logs/logsVisualizePersistence.spec.js | 3 +- .../playwright-tests/Logs/logstable.spec.js | 16 +- .../Metrics/metrics-promql-builder.spec.js | 55 ++--- .../playwright-tests/Metrics/metrics.spec.js | 65 +----- .../Pipelines/pipeline-traces.spec.js | 25 +-- .../Traces/traces-jk-navigation.spec.js | 5 + 24 files changed, 345 insertions(+), 356 deletions(-) diff --git a/tests/ui-testing/pages/dashboardPages/dashboard-panel-actions.js b/tests/ui-testing/pages/dashboardPages/dashboard-panel-actions.js index 191af62cb9..de68c4f2bd 100644 --- a/tests/ui-testing/pages/dashboardPages/dashboard-panel-actions.js +++ b/tests/ui-testing/pages/dashboardPages/dashboard-panel-actions.js @@ -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) diff --git a/tests/ui-testing/pages/generalPages/enrichmentPage.js b/tests/ui-testing/pages/generalPages/enrichmentPage.js index f2997d1b5a..be647cd93a 100644 --- a/tests/ui-testing/pages/generalPages/enrichmentPage.js +++ b/tests/ui-testing/pages/generalPages/enrichmentPage.js @@ -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(() => {}), diff --git a/tests/ui-testing/pages/iamPages/iamPage.js b/tests/ui-testing/pages/iamPages/iamPage.js index 2fe1427dc8..0f312621c2 100644 --- a/tests/ui-testing/pages/iamPages/iamPage.js +++ b/tests/ui-testing/pages/iamPages/iamPage.js @@ -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 }); } diff --git a/tests/ui-testing/pages/logsPages/logsPage.js b/tests/ui-testing/pages/logsPages/logsPage.js index 617ecad8b4..5653713810 100644 --- a/tests/ui-testing/pages/logsPages/logsPage.js +++ b/tests/ui-testing/pages/logsPages/logsPage.js @@ -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- 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 }); diff --git a/tests/ui-testing/pages/metricsPages/metricsBuilderPage.js b/tests/ui-testing/pages/metricsPages/metricsBuilderPage.js index 502ad7fb02..1425e8acdb 100644 --- a/tests/ui-testing/pages/metricsPages/metricsBuilderPage.js +++ b/tests/ui-testing/pages/metricsPages/metricsBuilderPage.js @@ -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 ===== /** diff --git a/tests/ui-testing/pages/metricsPages/metricsPage.js b/tests/ui-testing/pages/metricsPages/metricsPage.js index ebaf8167c9..452117ab84 100644 --- a/tests/ui-testing/pages/metricsPages/metricsPage.js +++ b/tests/ui-testing/pages/metricsPages/metricsPage.js @@ -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 diff --git a/tests/ui-testing/pages/pipelinesPages/pipelineImportExportPage.js b/tests/ui-testing/pages/pipelinesPages/pipelineImportExportPage.js index 53fc612a17..9556a46f50 100644 --- a/tests/ui-testing/pages/pipelinesPages/pipelineImportExportPage.js +++ b/tests/ui-testing/pages/pipelinesPages/pipelineImportExportPage.js @@ -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. */ diff --git a/tests/ui-testing/pages/pipelinesPages/pipelinesPage.js b/tests/ui-testing/pages/pipelinesPages/pipelinesPage.js index fecd61b431..cdb28338d9 100644 --- a/tests/ui-testing/pages/pipelinesPages/pipelinesPage.js +++ b/tests/ui-testing/pages/pipelinesPages/pipelinesPage.js @@ -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 */ diff --git a/tests/ui-testing/pages/tracesPages/tracesPage.js b/tests/ui-testing/pages/tracesPages/tracesPage.js index bdc95272d8..cf5ad06971 100644 --- a/tests/ui-testing/pages/tracesPages/tracesPage.js +++ b/tests/ui-testing/pages/tracesPages/tracesPage.js @@ -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'. diff --git a/tests/ui-testing/playwright-tests/Alerts/alerts-history.spec.js b/tests/ui-testing/playwright-tests/Alerts/alerts-history.spec.js index aea80d3d86..2d3ada365b 100644 --- a/tests/ui-testing/playwright-tests/Alerts/alerts-history.spec.js +++ b/tests/ui-testing/playwright-tests/Alerts/alerts-history.spec.js @@ -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)}`); } diff --git a/tests/ui-testing/playwright-tests/Dashboards/dashboard-table-pagination.spec.js b/tests/ui-testing/playwright-tests/Dashboards/dashboard-table-pagination.spec.js index d885411518..d320e488a4 100644 --- a/tests/ui-testing/playwright-tests/Dashboards/dashboard-table-pagination.spec.js +++ b/tests/ui-testing/playwright-tests/Dashboards/dashboard-table-pagination.spec.js @@ -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-" + // Rows render directly in tbody, keyed data-test="o2-table-row-"; 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+$/); diff --git a/tests/ui-testing/playwright-tests/GeneralTests/dateTime.spec.js b/tests/ui-testing/playwright-tests/GeneralTests/dateTime.spec.js index 5278707f8c..4739408358 100644 --- a/tests/ui-testing/playwright-tests/GeneralTests/dateTime.spec.js +++ b/tests/ui-testing/playwright-tests/GeneralTests/dateTime.spec.js @@ -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 diff --git a/tests/ui-testing/playwright-tests/Logs/logs-autocomplete-suggestions.spec.js b/tests/ui-testing/playwright-tests/Logs/logs-autocomplete-suggestions.spec.js index f5572ec9b1..847a86feb7 100644 --- a/tests/ui-testing/playwright-tests/Logs/logs-autocomplete-suggestions.spec.js +++ b/tests/ui-testing/playwright-tests/Logs/logs-autocomplete-suggestions.spec.js @@ -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) { diff --git a/tests/ui-testing/playwright-tests/Logs/logs-jk-navigation.spec.js b/tests/ui-testing/playwright-tests/Logs/logs-jk-navigation.spec.js index 8d18b9524b..d3a76b137d 100644 --- a/tests/ui-testing/playwright-tests/Logs/logs-jk-navigation.spec.js +++ b/tests/ui-testing/playwright-tests/Logs/logs-jk-navigation.spec.js @@ -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 && diff --git a/tests/ui-testing/playwright-tests/Logs/logsActionReasons.spec.js b/tests/ui-testing/playwright-tests/Logs/logsActionReasons.spec.js index 7056f9f533..a0400a2eee 100644 --- a/tests/ui-testing/playwright-tests/Logs/logsActionReasons.spec.js +++ b/tests/ui-testing/playwright-tests/Logs/logsActionReasons.spec.js @@ -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' }); diff --git a/tests/ui-testing/playwright-tests/Logs/logsFreeText.spec.js b/tests/ui-testing/playwright-tests/Logs/logsFreeText.spec.js index f80f206941..71f675f6ae 100644 --- a/tests/ui-testing/playwright-tests/Logs/logsFreeText.spec.js +++ b/tests/ui-testing/playwright-tests/Logs/logsFreeText.spec.js @@ -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); diff --git a/tests/ui-testing/playwright-tests/Logs/logsNoFtsRecovery.spec.js b/tests/ui-testing/playwright-tests/Logs/logsNoFtsRecovery.spec.js index ced1649f9b..1c6031b506 100644 --- a/tests/ui-testing/playwright-tests/Logs/logsNoFtsRecovery.spec.js +++ b/tests/ui-testing/playwright-tests/Logs/logsNoFtsRecovery.spec.js @@ -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.`); diff --git a/tests/ui-testing/playwright-tests/Logs/logsQueryBuilder-editor.spec.js b/tests/ui-testing/playwright-tests/Logs/logsQueryBuilder-editor.spec.js index ae9b3c3738..e4f53ac059 100644 --- a/tests/ui-testing/playwright-tests/Logs/logsQueryBuilder-editor.spec.js +++ b/tests/ui-testing/playwright-tests/Logs/logsQueryBuilder-editor.spec.js @@ -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(); diff --git a/tests/ui-testing/playwright-tests/Logs/logsVisualizePersistence.spec.js b/tests/ui-testing/playwright-tests/Logs/logsVisualizePersistence.spec.js index ebd0cf4f17..18062c632e 100644 --- a/tests/ui-testing/playwright-tests/Logs/logsVisualizePersistence.spec.js +++ b/tests/ui-testing/playwright-tests/Logs/logsVisualizePersistence.spec.js @@ -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(() => {}); diff --git a/tests/ui-testing/playwright-tests/Logs/logstable.spec.js b/tests/ui-testing/playwright-tests/Logs/logstable.spec.js index 529c77e9dd..522e77ee42 100644 --- a/tests/ui-testing/playwright-tests/Logs/logstable.spec.js +++ b/tests/ui-testing/playwright-tests/Logs/logstable.spec.js @@ -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(); }); diff --git a/tests/ui-testing/playwright-tests/Metrics/metrics-promql-builder.spec.js b/tests/ui-testing/playwright-tests/Metrics/metrics-promql-builder.spec.js index c3ac678b67..30b14035ad 100644 --- a/tests/ui-testing/playwright-tests/Metrics/metrics-promql-builder.spec.js +++ b/tests/ui-testing/playwright-tests/Metrics/metrics-promql-builder.spec.js @@ -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 diff --git a/tests/ui-testing/playwright-tests/Metrics/metrics.spec.js b/tests/ui-testing/playwright-tests/Metrics/metrics.spec.js index aad27571fc..684cf6f1c8 100644 --- a/tests/ui-testing/playwright-tests/Metrics/metrics.spec.js +++ b/tests/ui-testing/playwright-tests/Metrics/metrics.spec.js @@ -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", { diff --git a/tests/ui-testing/playwright-tests/Pipelines/pipeline-traces.spec.js b/tests/ui-testing/playwright-tests/Pipelines/pipeline-traces.spec.js index 954332498f..5c87339b08 100644 --- a/tests/ui-testing/playwright-tests/Pipelines/pipeline-traces.spec.js +++ b/tests/ui-testing/playwright-tests/Pipelines/pipeline-traces.spec.js @@ -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'); }); diff --git a/tests/ui-testing/playwright-tests/Traces/traces-jk-navigation.spec.js b/tests/ui-testing/playwright-tests/Traces/traces-jk-navigation.spec.js index 11c2408132..260a828cd0 100644 --- a/tests/ui-testing/playwright-tests/Traces/traces-jk-navigation.spec.js +++ b/tests/ui-testing/playwright-tests/Traces/traces-jk-navigation.spec.js @@ -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( () =>