diff --git a/tests/ui-testing/pages/logsPages/logsPage.js b/tests/ui-testing/pages/logsPages/logsPage.js index d1105b2bf5..9914faa88d 100644 --- a/tests/ui-testing/pages/logsPages/logsPage.js +++ b/tests/ui-testing/pages/logsPages/logsPage.js @@ -8259,19 +8259,26 @@ export class LogsPage { // Poll up to 2000ms for the Vue watcher chain to update the Monaco editor. // The watcher is async (reads buildDashboardPanelData, awaits onBuildQueryGenerated, // then Vue re-renders the editor prop) — a fixed 600ms was too short under CI load. - await this.page.waitForFunction((selector) => { - const host = document.querySelector(selector); - if (!host) return false; - const editors = window.monaco?.editor?.getEditors?.() ?? []; - for (const ed of editors) { - const node = ed.getDomNode?.(); - if (node && host.contains(node)) { - const val = (ed.getValue() || '').toLowerCase().trim(); - return val.includes('select') && val.includes('from'); + // Only the Build tab has this watcher; on the main logs tab it returns early and the + // editor never auto-populates, so waiting there just burns the full 2s before the + // fallback below runs anyway. Skip straight to the fallback in that case. + const onBuildTab = await this.page.locator(this.buildQueryPage) + .isVisible({ timeout: 500 }).catch(() => false); + if (onBuildTab) { + await this.page.waitForFunction((selector) => { + const host = document.querySelector(selector); + if (!host) return false; + const editors = window.monaco?.editor?.getEditors?.() ?? []; + for (const ed of editors) { + const node = ed.getDomNode?.(); + if (node && host.contains(node)) { + const val = (ed.getValue() || '').toLowerCase().trim(); + return val.includes('select') && val.includes('from'); + } } - } - return false; - }, this.queryEditor, { timeout: 2000, polling: 100 }).catch(() => {}); + return false; + }, this.queryEditor, { timeout: 2000, polling: 100 }).catch(() => {}); + } // Check if the editor was updated (build tab watcher fired). // In the main logs tab the watcher returns early, so the editor stays in FTS mode. @@ -8291,8 +8298,21 @@ export class LogsPage { // now falls back to model.setValue() which bypasses the readOnly restriction. await this.setQueryEditorContent(sql); } - // Allow Vue reactivity and CodeQueryEditor props.query watcher to propagate - await this.page.waitForTimeout(500); + // Wait for the CodeQueryEditor props.query watcher to actually apply the value + // to Monaco instead of sleeping a fixed 500ms — deterministic and usually <300ms. + await this.page.waitForFunction((selector) => { + const host = document.querySelector(selector); + if (!host) return false; + const editors = window.monaco?.editor?.getEditors?.() ?? []; + for (const ed of editors) { + const node = ed.getDomNode?.(); + if (node && host.contains(node)) { + const val = (ed.getValue() || '').toLowerCase().trim(); + return val.includes('select') && val.includes('from'); + } + } + return false; + }, this.queryEditor, { timeout: 3000, polling: 100 }).catch(() => {}); } testLogger.info('enableSqlModeIfNeeded: SQL mode enabled'); } @@ -8313,7 +8333,22 @@ export class LogsPage { // watcher which calls onBuildQueryGenerated() → extractWhereClause() (async) → // searchObj.data.query = whereClause → QueryEditor prop watcher → Monaco setValue. await this._setSqlModeViaVue(false); - await this.page.waitForTimeout(600); + // Wait for the watcher chain to strip the SELECT from the editor instead of a fixed + // 600ms sleep — resolves as soon as the editor updates, falls through on timeout to + // the isStillSQL fallback below (same recovery path as before). + await this.page.waitForFunction((selector) => { + const host = document.querySelector(selector); + if (!host) return true; + const editors = window.monaco?.editor?.getEditors?.() ?? []; + for (const ed of editors) { + const node = ed.getDomNode?.(); + if (node && host.contains(node)) { + const val = (ed.getValue() || '').toLowerCase().trim(); + return !(val.includes('select') && val.includes('from')); + } + } + return true; + }, this.queryEditor, { timeout: 2000, polling: 100 }).catch(() => {}); // Verify the editor was updated. The Vue watcher chain is async and involves an // SQL-parser dynamic import, so it can exceed 600ms on first load or under CI load. @@ -8335,7 +8370,21 @@ export class LogsPage { // silently on read-only editors in the build tab's auto mode). await this.setQueryEditorContent('').catch(() => {}); } - await this.page.waitForTimeout(500); + // Wait for the editor to actually reflect the cleared query instead of a + // fixed 500ms sleep — deterministic, resolves as soon as propagation lands. + await this.page.waitForFunction((selector) => { + const host = document.querySelector(selector); + if (!host) return true; + const editors = window.monaco?.editor?.getEditors?.() ?? []; + for (const ed of editors) { + const node = ed.getDomNode?.(); + if (node && host.contains(node)) { + const val = (ed.getValue() || '').toLowerCase().trim(); + return !(val.includes('select') && val.includes('from')); + } + } + return true; + }, this.queryEditor, { timeout: 2000, polling: 100 }).catch(() => {}); } testLogger.info('disableSqlModeIfNeeded: SQL mode disabled'); } @@ -9300,6 +9349,16 @@ export class LogsPage { // for the Monaco editor inside [data-test="logs-search-bar-query-editor"] to have // non-empty content — this confirms that onBuildQueryGenerated() has been called and // queries[0].query is ready for any Auto→Custom mode switch. + // + // With SQL mode OFF the editor holds only the WHERE clause and is often legitimately + // empty, so the wait below would always burn its full 10s timeout before the catch + // fires. Read sqlMode from Vue state and skip Phase 3 on an explicit `false`; if the + // state is unreachable (null) fall through to the wait as before. + const sqlModeOn = await this._mutateSearchObj((searchObj) => searchObj.meta.sqlMode === true); + if (sqlModeOn === false) { + testLogger.info('Build tab loaded (SQL mode off — query editor population not expected)'); + return true; + } try { await this.page.waitForFunction( (editorSelector) => { diff --git a/tests/ui-testing/pages/logsPages/unflattened.js b/tests/ui-testing/pages/logsPages/unflattened.js index 6b60371445..eb83ccd2fe 100644 --- a/tests/ui-testing/pages/logsPages/unflattened.js +++ b/tests/ui-testing/pages/logsPages/unflattened.js @@ -243,10 +243,15 @@ class UnflattenedPage { await this.page .locator('[data-test="logs-search-result-detail-dialog"][data-state="open"]') .waitFor({ state: 'visible', timeout: 10000 }); - // JSON content renders asynchronously after the drawer opens. - // Hard wait gives Vue time to populate the field list before callers - // probe for specific keys like _o2_id. - await this.page.waitForTimeout(3000); + // JSON content renders asynchronously after the drawer opens. Wait for the + // first detail key to actually render instead of a fixed 3s sleep — the keys + // appearing IS the signal callers need before probing for _o2_id, and the + // fixed sleep made findRowWithO2Id's 15-row scan cost ~1.5 minutes per + // attempt (the dominant share of the 5-minute-timeout flake on CI). + await this.allLogDetailKeys + .first() + .waitFor({ state: 'visible', timeout: 10000 }) + .catch(() => {}); } /** @@ -314,7 +319,11 @@ class UnflattenedPage { */ async waitForO2IdQueryable({ streamName = 'e2e_automate', timeout = 90000, pollInterval = 2000 } = {}) { const { getAuthHeaders, getOrgIdentifier } = require('../../playwright-tests/utils/cloud-auth.js'); - const baseUrl = (process.env.ZO_BASE_URL || '').replace(/\/$/, ''); + // Prefer INGESTION_URL like waitForStreamAvailable does: on CI both point at the + // same server, but in local setups ZO_BASE_URL can be a frontend dev server that + // does not serve /api/* (it returns an HTML hint page with HTTP 200, which made + // this gate silently poll uselessly for its entire budget). + const baseUrl = (process.env.INGESTION_URL || process.env.ZO_BASE_URL || '').replace(/\/$/, ''); const orgId = getOrgIdentifier(); const headers = getAuthHeaders(); const deadline = Date.now() + timeout; @@ -342,8 +351,14 @@ class UnflattenedPage { } ); if (resp.ok()) { - const body = await resp.json().catch(() => ({})); - const hits = Array.isArray(body.hits) ? body.hits : []; + const body = await resp.json().catch(() => null); + if (body === null) { + // HTTP 200 but not JSON — almost certainly the wrong endpoint + // (e.g. a frontend dev server). Surface it instead of silently + // burning the whole gate budget. + console.warn('waitForO2IdQueryable: response OK but not JSON — wrong endpoint? (will retry)'); + } + const hits = Array.isArray(body?.hits) ? body.hits : []; if (hits.some((h) => h && Object.prototype.hasOwnProperty.call(h, '_o2_id'))) { return true; } diff --git a/tests/ui-testing/playwright-tests/Logs/pagination.spec.js b/tests/ui-testing/playwright-tests/Logs/pagination.spec.js index 6708c7c1ee..0983c56344 100644 --- a/tests/ui-testing/playwright-tests/Logs/pagination.spec.js +++ b/tests/ui-testing/playwright-tests/Logs/pagination.spec.js @@ -15,20 +15,29 @@ function generateRandomStreamName() { test.describe("Pagination for logs", () => { let pageManager; + // One stream per worker process: every test asserts pagination counts against the + // record count of a single ingestion, so ingesting the same fixture once and reusing + // the stream across the worker's tests yields identical validations while removing + // a redundant ingest + index-wait from every test. Module state is per worker + // (workers are separate processes), so parallel workers stay isolated. let streamName; + let streamReady = false; const orgId = process.env.ORGNAME || "default"; test.beforeEach(async ({ page }) => { - streamName = generateRandomStreamName(); pageManager = new PageManager(page); await pageManager.loginPage.gotoLoginPage(); await pageManager.loginPage.loginAsInternalUser(); await pageManager.loginPage.login(); // Login as root user - await pageManager.ingestionPage.ingestionMultiOrgStream(orgId, streamName); - // Wait for stream to be indexed — cloud can take 30-60s - await pageManager.logsPage.waitForStreamAvailable(streamName, 120000, 3000); + if (!streamReady) { + streamName = generateRandomStreamName(); + await pageManager.ingestionPage.ingestionMultiOrgStream(orgId, streamName); + // Wait for stream to be indexed — cloud can take 30-60s + await pageManager.logsPage.waitForStreamAvailable(streamName, 120000, 1000); + streamReady = true; + } }); test("HTTP Pagination for running query to validate WHERE match_all('2022-12-27T14:11:27Z INFO zinc_enl')", { tag: ['@pagination', '@functional', '@P1'] }, async ({ page }) => { @@ -103,7 +112,6 @@ test.describe("Pagination for logs", () => { test("Enable Streaming for running query to validate WHERE match_all('2022-12-27T14:11:27Z INFO zinc_enl')", { tag: ['@pagination', '@streaming', '@functional', '@P1'] }, async ({ page }) => { // Streaming is enabled via ZO_STREAMING_ENABLED env var — no need to toggle - await page.waitForTimeout(2000); await pageManager.logsPage.navigateToLogs(); await pageManager.logsPage.selectIndexStream(streamName); testLogger.debug('Stream name generated', { streamName }); @@ -122,7 +130,6 @@ test.describe("Pagination for logs", () => { test("Enable Streaming for running query to validate WHERE match_all('zin*')", { tag: ['@pagination', '@streaming', '@functional', '@P1'] }, async ({ page }) => { // Streaming is enabled via ZO_STREAMING_ENABLED env var — no need to toggle - await page.waitForTimeout(2000); await pageManager.logsPage.navigateToLogs(); await pageManager.logsPage.selectIndexStream(streamName); testLogger.debug('Stream name generated', { streamName }); @@ -141,7 +148,6 @@ test.describe("Pagination for logs", () => { test("Enable Streaming for running query to validate WHERE match_all('2022-12-27T1*')", { tag: ['@pagination', '@streaming', '@functional', '@P1'] }, async ({ page }) => { // Streaming is enabled via ZO_STREAMING_ENABLED env var — no need to toggle - await page.waitForTimeout(2000); await pageManager.logsPage.navigateToLogs(); await pageManager.logsPage.selectIndexStream(streamName); testLogger.debug('Stream name generated', { streamName }); @@ -160,7 +166,6 @@ test.describe("Pagination for logs", () => { test("Enable Streaming for running query to validate WHERE match_all('2022-12-27T14:11:2*')", { tag: ['@pagination', '@streaming', '@functional', '@P1'] }, async ({ page }) => { // Streaming is enabled via ZO_STREAMING_ENABLED env var — no need to toggle - await page.waitForTimeout(2000); await pageManager.logsPage.navigateToLogs(); await pageManager.logsPage.selectIndexStream(streamName); testLogger.debug('Stream name generated', { streamName }); @@ -179,7 +184,6 @@ test.describe("Pagination for logs", () => { test("Enable Streaming for running query to validate pagination is not visible WHERE match_all('2022-12-27T14:11:27Z INFO zinc_enl') limit`", { tag: ['@pagination', '@streaming', '@functional', '@P1'] }, async ({ page }) => { // Streaming is enabled via ZO_STREAMING_ENABLED env var — no need to toggle - await page.waitForTimeout(2000); await pageManager.logsPage.navigateToLogs(); await pageManager.logsPage.selectIndexStream(streamName); testLogger.debug('Stream name generated', { streamName }); diff --git a/tests/ui-testing/playwright-tests/Logs/unflattened.spec.js b/tests/ui-testing/playwright-tests/Logs/unflattened.spec.js index 89b1722036..3943b8519b 100644 --- a/tests/ui-testing/playwright-tests/Logs/unflattened.spec.js +++ b/tests/ui-testing/playwright-tests/Logs/unflattened.spec.js @@ -16,23 +16,38 @@ test.describe("Unflattened testcases", () => { return text.replace(/[^\x00-\x7F]/g, " "); } async function applyQueryButton(page) { - // After stream selection, an auto-search may be in progress (button shows - // "Cancel"). Clicking refresh while a search is running just cancels it - // without starting a new one. We click twice: first to cancel any - // in-flight search, then again to start a fresh one. - await page.waitForTimeout(3000); - - // First click — cancels any in-progress auto-search. - await pageManager.logsPage.runQueryAndWaitForResults(); - - // Second click — reliably starts a new search now that no search is running. - await page.waitForTimeout(1000); + // runQueryAndWaitForResults already handles an in-flight auto-search internally: + // it waits for the button to exit the "Cancel" state before clicking, and if the + // state never clears it cancels and re-clicks Run itself. The old double-click + // wrapper (3s sleep + run + 1s sleep + run) duplicated that logic and added ~5s + // of fixed sleep to every call. await pageManager.logsPage.runQueryAndWaitForResults(); // 2-minute timeout: cloud re-indexing after Store Original Data changes takes longer than the default 30s await pageManager.logsPage.waitForSearchResults(120000); } + // Re-run the log search from a FRESH page load so the row scan below sees a + // complete, freshly-ordered result set. Re-clicking Run in place does not + // recover a stale/partial streaming result set (ZO_STREAMING_ENABLED delivers + // hits over a WebSocket that can lag the HTTP _search readiness gate) — the + // only reliable recovery is a new navigation + query, which is exactly what the + // whole-test Playwright retry does when it succeeds. This reproduces that here + // without re-ingesting, so it stays cheap. Pass sqlSelectAll for the quick-mode + // test, whose intent is specifically a `SELECT *` query (a plain reload would + // otherwise fall back to the quick-mode interesting-fields-only query). + async function runFreshLogSearch(page, { sqlSelectAll = false } = {}) { + await page.goto( + `${process.env.ZO_BASE_URL}/web/logs?org_identifier=${getOrgIdentifier()}&stream_type=logs&stream=e2e_automate` + ); + await page.waitForLoadState('domcontentloaded'); + if (sqlSelectAll) { + await pageManager.logsPage.typeQuery('SELECT * FROM "e2e_automate" ORDER BY _timestamp DESC'); + await pageManager.logsPage.waitForQueryEditorValue('ORDER BY _timestamp DESC'); + } + await applyQueryButton(page); + } + test.beforeEach(async ({ page }) => { pageManager = new PageManager(page); if (isCloudEnvironment()) { @@ -42,11 +57,12 @@ test.describe("Unflattened testcases", () => { await pageManager.loginPage.loginAsInternalUser(); await pageManager.loginPage.login(); } - await page.waitForTimeout(2000); - await page.waitForTimeout(500); + await page.waitForLoadState('domcontentloaded'); await ingestion(page); - // Wait for data to be indexed before navigating to logs - await page.waitForTimeout(2000); + // Wait for the stream to actually be queryable instead of a blind 2s sleep — + // deterministic (usually instant once the stream exists) and more tolerant on + // slow CI runners than a fixed pause. + await pageManager.logsPage.waitForStreamAvailable('e2e_automate', 30000, 1000); // Check and disable Store Original Data if it's enabled. // Navigate directly to the streams URL with the correct org — clicking the @@ -162,11 +178,12 @@ test.describe("Unflattened testcases", () => { await applyQueryButton(page); testLogger.info('Search query applied, logs should now contain _o2_id field'); - testLogger.info('Searching log rows for _o2_id field (iterates first 10 rows per attempt)'); + testLogger.info('Searching log rows for _o2_id field (iterates first 15 rows per attempt)'); let o2idFound = false; // The backend readiness gate above already guarantees _o2_id is queryable, so - // a miss here is only UI/render lag — re-run the query (cheap) rather than - // re-ingesting (the old loop's re-ingest + 5s sleep is what ballooned wall-time). + // a miss here is UI/streaming lag — recover with a FRESH log search (reload + + // re-run) rather than re-ingesting. Re-clicking Run in place does not recover a + // stale/partial streaming result set; a fresh navigation does (see runFreshLogSearch). for (let attempt = 1; attempt <= 3; attempt++) { const matchedRow = await pageManager.unflattenedPage.findRowWithO2Id(15); if (matchedRow !== -1) { @@ -175,10 +192,10 @@ test.describe("Unflattened testcases", () => { o2idFound = true; break; } - testLogger.warn(`_o2_id not found in first 10 rows on attempt ${attempt}, re-running query`); + testLogger.warn(`_o2_id not found in first 15 rows on attempt ${attempt}, re-running query from a fresh page`); await pageManager.unflattenedPage.closeLogDetailDrawerIfOpen(); if (attempt < 3) { - await applyQueryButton(page); + await runFreshLogSearch(page); } } if (!o2idFound) { @@ -322,14 +339,22 @@ test.describe("Unflattened testcases", () => { testLogger.info('Replacing query with SELECT * FROM "e2e_automate" ORDER BY _timestamp DESC'); await pageManager.logsPage.typeQuery('SELECT * FROM "e2e_automate" ORDER BY _timestamp DESC'); + // Wait for Monaco to commit the typed query to the Vue store before clicking Run. + // Without this the click can race the editor debounce and re-run the PREVIOUS + // (quick-mode fields-only) query, whose hits never contain _o2_id — dooming the + // row scan below and burning the whole 5-minute test budget (CI flake). + await pageManager.logsPage.waitForQueryEditorValue('ORDER BY _timestamp DESC'); + testLogger.info('Executing SELECT * query to fetch fresh data with _o2_id'); await applyQueryButton(page); - testLogger.info('Searching log rows for _o2_id field (iterates first 10 rows per attempt)'); - // With ORDER BY _timestamp DESC the newest rows come first. The backend - // readiness gate above already guarantees _o2_id is queryable, so a miss here - // is only UI/render lag — re-run the query (cheap) rather than re-ingesting - // (the old loop's re-ingest + 5s sleep is what ballooned wall-time on CI). + testLogger.info('Searching log rows for _o2_id field (iterates first 15 rows per attempt)'); + // With ORDER BY _timestamp DESC the newest rows come first (the fixture carries + // no _timestamp, so the Store-Original-Data re-ingest is genuinely newest). The + // backend readiness gate above guarantees _o2_id is queryable, so a miss here is + // UI/streaming lag — recover with a FRESH SELECT * search (reload + re-type + + // re-run) rather than re-ingesting. Re-clicking Run in place does not recover a + // stale/partial streaming result set; a fresh navigation does. let o2idFound = false; for (let attempt = 1; attempt <= 3; attempt++) { const matchedRow = await pageManager.unflattenedPage.findRowWithO2Id(15); @@ -339,7 +364,7 @@ test.describe("Unflattened testcases", () => { o2idFound = true; break; } - testLogger.warn(`_o2_id not found in first 10 rows on attempt ${attempt}, re-running query`); + testLogger.warn(`_o2_id not found in first 15 rows on attempt ${attempt}, re-running query from a fresh page`); if (attempt === 3) { try { const allKeys = await pageManager.unflattenedPage.allLogDetailKeys.allTextContents(); @@ -350,7 +375,7 @@ test.describe("Unflattened testcases", () => { break; } await pageManager.unflattenedPage.closeLogDetailDrawerIfOpen(); - await applyQueryButton(page); + await runFreshLogSearch(page, { sqlSelectAll: true }); } if (!o2idFound) { throw new Error('Failed to find _o2_id field in log details after 3 attempts'); diff --git a/tests/ui-testing/playwright-tests/utils/queryBuilder-helpers.js b/tests/ui-testing/playwright-tests/utils/queryBuilder-helpers.js index 15b0fb86d3..222b8b8d4c 100644 --- a/tests/ui-testing/playwright-tests/utils/queryBuilder-helpers.js +++ b/tests/ui-testing/playwright-tests/utils/queryBuilder-helpers.js @@ -1,5 +1,4 @@ const testLogger = require('./test-logger.js'); -const logData = require('../../fixtures/log.json'); const logsdata = require('../../../test-data/logs_data.json'); const { getOrgIdentifier, getAuthHeaders } = require('./cloud-auth.js'); @@ -25,6 +24,19 @@ async function ingestForQueryBuilderTest(request, streamName = "e2e_automate") { } async function applyQueryButton(pm) { + // Stream selection fires an auto-search that leaves the Run button disabled, + // aria-busy, or swapped to "Cancel" while in flight. Clicking blind during that + // window fails with "Element is not visible" — wait for the button to be ready + // first. The catch falls through to the click, which then reports loudly. + await pm.page.waitForFunction((selector) => { + const el = document.querySelector(selector); + if (!el) return false; + if (el.getAttribute('aria-busy') === 'true') return false; + if (el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true') return false; + if (((el.textContent || '').trim()).includes('Cancel')) return false; + return el.offsetParent !== null; + }, pm.logsPage.queryButton, { timeout: 60000, polling: 100 }).catch(() => {}); + const searchPromise = pm.page.waitForResponse( response => response.url().includes('/_search') && response.status() === 200, { timeout: 60000 } @@ -51,12 +63,19 @@ async function setupQueryAndSwitchToBuild(pm, page, query) { } /** - * Full init: navigate, select stream, run initial match_all query. + * Full init: select stream (selectStream navigates to the logs page itself, so no + * separate page.goto here — that would just load the same page twice), then run + * the initial match_all query. * Ingestion is handled by ingestForQueryBuilderTest in beforeAll. + * + * The applyQueryButton call is NOT optional setup: selecting a stream fires an + * auto-search, and waiting for its /_search response here absorbs it before the + * test body runs. Skipping it lets that response land mid-test and overwrite the + * test's query state (observed as table/metric chart-type tests flaking to the + * default bar chart under load). */ async function initQueryBuilderTest(page, pm) { await page.waitForLoadState('domcontentloaded'); - await page.goto(`${logData.logsUrl}?org_identifier=${getOrgIdentifier()}`); await pm.logsPage.selectStream("e2e_automate"); await applyQueryButton(pm); }