From 7999fd33f3ac5577d3384dfd3e555989d71eead4 Mon Sep 17 00:00:00 2001 From: ktx-akshay Date: Tue, 4 Aug 2026 17:39:26 +0530 Subject: [PATCH] test: enhance logs visualization error handling and persistence tests (#13591) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds **10 E2E tests** covering the two defects fixed in #13244, plus the `SELECT *` behaviour change that shipped alongside them. Also introduces per-shard backend configuration so the `SELECT *` tests can actually run in CI. ## Test coverage ### `logsVisualizePersistence.spec.js` — 7 tests (quick mode **off**) | # | Test | Defect | Fix code exercised | |---|---|---|---| | 1 | Renders without the false required-fields error | A | `convertPanelData.ts` `!query?.customQuery` | | 2 | Restores chart type, query and chart after URL refresh (h-bar) | B | `setupLogsTab` re-run + `isInitialVisualizeRestore` | | 3 | Re-renders after reload with the default chart type | B | permanent No-Data half | | 4 | Restores a non-default chart type (area) after refresh | B | `restoreVisualizationFromUrlOnLoad()` | | 5 | Chart still renders after reload with a VRL function applied | B | `prepareVisualizeMode()` VRL copy | | 6 | Never shows required-fields error toggling Visualize → Logs → Visualize | A | manual-toggle rebuild path | | 7 | No `SELECT *` toast opening Visualize with no stream in the URL | B | page-load ordering guard | **Defect A** — false "Please select required fields to render the chart" on the custom-query Timechart panel. **Defect B** — URL refresh produced a `SELECT *` toast plus permanent No Data. ### `logsVisualizeSelectStar.spec.js` — 3 tests (quick mode **on**, dedicated shard) | # | Test | Behaviour verified | Call site | |---|---|---|---| | 8 | Table chart on `SELECT *` → toast shown **and** chart type reverts | Table is blocked | `Index.vue:2265` (watcher) | | 9 | Histogram chart on `SELECT *` → no toast, renders | Line/bar/area/scatter allowed | entry path | | 10 | `SELECT *` line chart survives browser refresh → no toast, state restored | Restore path doesn't re-emit | `Index.vue:2465` (restore) | ## Infrastructure changes | File | Change | Why | |---|---|---| | `ci_matrix.json` | New `Logs-SelectStar` shard with `quick_mode_enabled: "true"` | `isSelectStarForTable()` only exists when the server runs with quick mode on | | `playwright.yml` | Per-shard `ZO_QUICK_MODE_ENABLED` env on `Start OpenObserve` | The flag is read once at server start — a shard is the smallest unit that can vary it | | `build-ci-matrix.js` | Emit `quick_mode_enabled` (defaults `"false"`) | The field was being silently stripped by the emit whitelist | | `logsPage.js` | 13 assertion helpers + fixed chart-type detection | Detection read `data-selected` off the wrong element and matched retired CSS classes, so it always returned `null` | All 24 existing shards are unaffected — they omit the field and default to `false`. ## Notes on test quality - Every negative assertion ("no toast", "no error") is sequenced after a deterministic positive end-state, and gated on a precondition so it cannot pass vacuously. - Toast absence is proved with a MutationObserver recorder installed before the navigation under test, since toasts auto-dismiss. - The quick-mode precondition is a hard assertion, not `test.skip` — a misconfigured shard fails loudly instead of silently reporting "skipped". --- .github/scripts/build-ci-matrix.js | 7 + .github/workflows/playwright.yml | 6 + tests/ui-testing/ci-matrix/ci_matrix.json | 11 + tests/ui-testing/pages/logsPages/logsPage.js | 336 +++++++++++++++--- .../Logs/logsVisualizePersistence.spec.js | 293 +++++++++++++++ .../Logs/logsVisualizeSelectStar.spec.js | 211 +++++++++++ 6 files changed, 815 insertions(+), 49 deletions(-) create mode 100644 tests/ui-testing/playwright-tests/Logs/logsVisualizeSelectStar.spec.js diff --git a/.github/scripts/build-ci-matrix.js b/.github/scripts/build-ci-matrix.js index fbb1de82b7..dfabf2067b 100644 --- a/.github/scripts/build-ci-matrix.js +++ b/.github/scripts/build-ci-matrix.js @@ -104,11 +104,18 @@ for (const s of include) { // Emit ONLY the fields the CI matrix consumes — disabled/_comment/notes are stripped, // so turned-off specs never reach `npx playwright test`. +// +// quick_mode_enabled is always emitted (defaulting to "false") so every shard +// carries the key: the workflow feeds it to ZO_QUICK_MODE_ENABLED when starting +// that shard's OpenObserve process. The flag is read once at server start, so it +// cannot vary per test — a shard is the smallest unit that can change it. const matrix = include.map((s) => ({ testfolder: s.testfolder, actual_folder: s.actual_folder, browser: s.browser || "chrome", run_files: s.run_files, + quick_mode_enabled: + s.quick_mode_enabled === true || s.quick_mode_enabled === "true" ? "true" : "false", })); process.stdout.write(JSON.stringify({ include: matrix })); diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 774e75f901..fd503d7b77 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -247,6 +247,12 @@ jobs: path: /tmp/dist - name: Start OpenObserve + # Per-shard override of the workflow-level default. ZO_QUICK_MODE_ENABLED is + # read once at server start and is global to the process, so a shard is the + # smallest unit that can change it. Set via "quick_mode_enabled" in + # tests/ui-testing/ci-matrix/ci_matrix.json; shards that omit it get "false". + env: + ZO_QUICK_MODE_ENABLED: ${{ matrix.quick_mode_enabled || 'false' }} run: chmod +x ./release-ci-binary/openobserve && ./release-ci-binary/openobserve > o2.log 2>&1 & - name: Wait for start diff --git a/tests/ui-testing/ci-matrix/ci_matrix.json b/tests/ui-testing/ci-matrix/ci_matrix.json index 3a7d1e6547..472540e043 100644 --- a/tests/ui-testing/ci-matrix/ci_matrix.json +++ b/tests/ui-testing/ci-matrix/ci_matrix.json @@ -75,6 +75,17 @@ ], "disabled": [] }, + { + "_comment": "Own shard because isSelectStarForTable() only exists when the server runs with ZO_QUICK_MODE_ENABLED=true. That flag is read once at process start, so it cannot vary between tests sharing a backend — each shard gets its own OpenObserve process. Every other shard omits quick_mode_enabled and keeps the default (false).", + "testfolder": "Logs-SelectStar", + "actual_folder": "Logs", + "browser": "chrome", + "quick_mode_enabled": "true", + "run_files": [ + "logsVisualizeSelectStar.spec.js" + ], + "disabled": [] + }, { "testfolder": "Logs-Features", "actual_folder": "Logs", diff --git a/tests/ui-testing/pages/logsPages/logsPage.js b/tests/ui-testing/pages/logsPages/logsPage.js index f405dd7abf..37654c71ae 100644 --- a/tests/ui-testing/pages/logsPages/logsPage.js +++ b/tests/ui-testing/pages/logsPages/logsPage.js @@ -305,6 +305,20 @@ export class LogsPage { this.dashboardPanelTable = '[data-test="dashboard-panel-table"]'; // Composite: any of the three indicates the build/visualize tab finished initial render. this.buildInitIndicator = `${this.chartRenderer}, ${this.dashboardPanelTable}, ${this.noDataMessage}`; + // Composite: a *painted* chart — either an echarts canvas or a rendered table panel. + // Scoped with :visible because the logs histogram keeps its own (hidden) chart-renderer + // in the DOM while the Visualize tab is active. + this.renderedChartIndicator = `${this.chartRenderer} canvas:visible, ${this.dashboardPanelTable}:visible`; + + // ===== VISUALIZE ERROR PANEL (DashboardErrors.vue) ===== + // Rendered only when errorData.errors is non-empty (v-if), so count 0 == no errors. + this.dashboardError = '[data-test="dashboard-error"]'; + this.dashboardErrorsListItem = '[data-test="dashboard-errors-list-item"]'; + // Error text thrown by convertPanelData() for builder-mode panels with empty x/y + // (web/src/utils/dashboard/convertPanelData.ts). + this.requiredFieldsErrorText = 'Please select required fields to render the chart'; + // logs.index.selectStarNotSupportedForVisualization (en-US.json) + this.selectStarNotSupportedToastText = 'Select * query is not supported for visualization'; // ===== SHARE LINK SELECTORS (VERIFIED) ===== this.shareLinkButton = '[data-test="logs-search-bar-share-link-btn"]'; @@ -9435,6 +9449,250 @@ export class LogsPage { testLogger.info('Live mode refresh-interval button is visible'); } + // ===== LOGS VISUALIZE — CHART RENDER / ERROR ASSERTIONS (PR #13244, issue #12897) ===== + + /** + * Wait for the Visualize (Timechart) panel to actually PAINT a chart, then + * assert the "No Data" empty state is gone. + * + * This is the deterministic positive end-state that every negative assertion + * below must be sequenced after — never assert "no error" against a panel + * that has not finished rendering yet. + * @param {number} timeout - Max wait for the chart to paint (default 45000) + */ + async expectVisualizeChartRendered(timeout = 45000) { + await expect(this.page.locator(this.renderedChartIndicator).first()) + .toBeVisible({ timeout }); + // `no-data` is a v-if empty state, so it is removed from the DOM once the + // panel has rows. toHaveCount(0) auto-retries, so a brief no-data flash + // while the first chunk streams in does not fail the assertion. + await expect(this.page.locator(this.noDataMessage)).toHaveCount(0, { timeout: 15000 }); + testLogger.info('Visualize chart is rendered (not the No Data state)'); + } + + /** + * Assert the Visualize errors panel (DashboardErrors.vue) is absent. + * The container is rendered behind `v-if="errors.length"`, and errors are only + * cleared by an explicit resetErrors() — i.e. an error raised at any point in + * the current run is still present when this runs. Call AFTER + * expectVisualizeChartRendered() so the panel has settled. + */ + async expectNoDashboardErrors() { + await expect(this.page.locator(this.dashboardError)).toHaveCount(0, { timeout: 15000 }); + testLogger.info('No dashboard error panel is present'); + } + + /** + * Assert the false "Please select required fields to render the chart" error + * (convertPanelData's builder-mode guard) never landed in the errors panel. + * Targets the message specifically so an unrelated backend error still reports + * a meaningful failure via expectNoDashboardErrors(). + */ + async expectNoRequiredFieldsError() { + const errorItems = this.page.locator(this.dashboardErrorsListItem, { + hasText: this.requiredFieldsErrorText, + }); + await expect(errorItems).toHaveCount(0, { timeout: 15000 }); + testLogger.info('No "required fields" error is present in the errors panel'); + } + + // ===== LOGS VISUALIZE — TOAST RECORDER ===== + + /** + * Install a toast recorder that survives navigation. + * + * Toasts auto-dismiss, so polling for one after the page has settled is + * inherently racy — the toast we are asserting the ABSENCE of may have come + * and gone. Instead this attaches a MutationObserver that records every + * `[data-test="o-toast-message"]` text into `window.__o2RecordedToasts`, and + * registers it as an init script so it is re-installed at document-start on + * every subsequent navigation (including `page.reload()`). The assertion then + * inspects the recorded log after a deterministic positive end-state. + * + * Call this BEFORE the navigation/reload under test. + */ + async startToastRecorder() { + const installer = () => { + if (window.__o2ToastRecorderInstalled) return; + window.__o2ToastRecorderInstalled = true; + window.__o2RecordedToasts = []; + const SELECTOR = '[data-test="o-toast-message"]'; + const scan = () => { + document.querySelectorAll(SELECTOR).forEach((el) => { + const text = (el.textContent || '').trim(); + if (text && !window.__o2RecordedToasts.includes(text)) { + window.__o2RecordedToasts.push(text); + } + }); + }; + const start = () => { + scan(); + // characterData is required too: Vue inserts the toast node first and + // fills its text on a later tick, so childList alone can miss the text. + new MutationObserver(scan).observe(document.documentElement, { + childList: true, + subtree: true, + characterData: true, + }); + }; + if (document.documentElement) { + start(); + } else { + document.addEventListener('DOMContentLoaded', start); + } + }; + await this.page.addInitScript(installer); + // Also install on the CURRENT document — addInitScript only affects future loads. + await this.page.evaluate(installer).catch(() => {}); + testLogger.info('Toast recorder installed'); + } + + /** + * Read every toast message recorded since startToastRecorder(). + * @returns {Promise} + */ + async getRecordedToastMessages() { + return await this.page.evaluate(() => window.__o2RecordedToasts ?? []); + } + + /** + * Assert no recorded toast contains the given text. + * @param {string} text - Substring that must not appear in any recorded toast + */ + async expectNoToastContaining(text) { + const messages = await this.getRecordedToastMessages(); + const matches = messages.filter((m) => m.includes(text)); + expect( + matches, + `Expected no toast containing "${text}". Recorded toasts: ${JSON.stringify(messages)}` + ).toEqual([]); + testLogger.info(`No toast containing "${text}" was emitted`); + } + + /** + * Assert the "Select * query is not supported for visualization" toast was + * never emitted (the symptom of the `select * from "undefined"` page-load race). + */ + async expectNoSelectStarVisualizationToast() { + await this.expectNoToastContaining(this.selectStarNotSupportedToastText); + } + + /** + * Assert a toast containing the given text WAS emitted since startToastRecorder(). + * Polls because the toast arrives asynchronously after the triggering action. + * @param {string} text - Substring expected in one of the recorded toasts. + */ + async expectToastContaining(text, timeout = 15000) { + await expect + .poll( + async () => (await this.getRecordedToastMessages()).some((m) => m.includes(text)), + { + timeout, + message: `Expected a toast containing "${text}" but none was emitted`, + } + ) + .toBe(true); + testLogger.info(`Toast containing "${text}" was emitted`); + } + + /** + * Assert the "Select * query is not supported for visualization" toast WAS emitted. + */ + async expectSelectStarVisualizationToast() { + await this.expectToastContaining(this.selectStarNotSupportedToastText); + } + + /** + * Read `quick_mode_enabled` from the instance /config endpoint. + * isSelectStarForTable() short-circuits to false when this is off, so the + * SELECT * table guard simply does not apply on such an instance. + * @returns {Promise} + */ + async isQuickModeEnabledOnInstance() { + return await this.page + .evaluate(async () => { + const res = await fetch('/config'); + if (!res.ok) return false; + const cfg = await res.json(); + return cfg.quick_mode_enabled === true; + }) + .catch(() => false); + } + + // ===== LOGS VISUALIZE — URL STATE ===== + + /** + * Wait until the URL carries the Visualize tab state, so a subsequent + * page.reload() actually exercises URL restoration instead of reloading a URL + * that has not been synced yet (updateUrlQueryParams runs asynchronously after + * the chart renders). + * @param {{ requireVisualizationData?: boolean, requireFunctionContent?: boolean, timeout?: number }} options + */ + async waitForVisualizeUrlState({ + requireVisualizationData = true, + requireFunctionContent = false, + timeout = 20000, + } = {}) { + await expect + .poll( + () => { + const url = this.page.url(); + const hasToggle = url.includes('logs_visualize_toggle=visualize'); + const hasConfig = + !requireVisualizationData || url.includes('visualization_data='); + // functionContent is only written when transformType === "function" + // AND tempFunctionContent is non-empty, so requiring it proves a VRL + // body really is part of the restorable state before we reload. + const hasVrl = !requireFunctionContent || url.includes('functionContent='); + return hasToggle && hasConfig && hasVrl; + }, + { + timeout, + message: + 'URL never picked up logs_visualize_toggle=visualize / visualization_data' + + (requireFunctionContent ? ' / functionContent' : ''), + } + ) + .toBe(true); + testLogger.info('URL carries the visualize tab state'); + } + + /** + * Decode the `visualization_data` URL param (base64-encoded JSON written by + * updateUrlQueryParams from dashboardPanelData). After a reload the app + * regenerates this param from its restored in-memory state, so comparing the + * pre- and post-reload payloads proves the restore actually repopulated the + * panel — not merely that the query string survived. + * @returns {Promise} Decoded payload, or null if absent/unparsable. + */ + async getVisualizationDataFromUrl() { + const raw = new URL(this.page.url()).searchParams.get('visualization_data'); + if (!raw) return null; + try { + // URLSearchParams decodes a literal '+' as a space, and standard base64 + // uses '+', so put those back before decoding. + const normalized = raw.replace(/ /g, '+'); + return JSON.parse(Buffer.from(normalized, 'base64').toString('utf8')); + } catch (error) { + testLogger.warn(`Could not decode visualization_data: ${error.message}`); + return null; + } + } + + /** + * Assert the VRL (transform) editor currently contains the given text. + * @param {string} text - Substring expected in the editor. + */ + async expectVrlEditorContains(text) { + await expect + .poll(async () => await this.getVrlEditorContent(), { + timeout: 15000, + message: `VRL editor never contained "${text}"`, + }) + .toContain(text); + testLogger.info(`VRL editor contains "${text}"`); + } + /** * Expect Build tab toggle to be visible */ @@ -9942,70 +10200,50 @@ export class LogsPage { * @returns {Promise} The current chart type or null */ async getCurrentChartType() { - const chartTypes = ['bar', 'line', 'area', 'area-stacked', 'metric', 'table', 'scatter', 'pie', 'donut', 'h-bar', 'h-stacked', 'stacked', 'heatmap', 'gauge']; - - for (const chartType of chartTypes) { - const chartItem = this.page.locator(this.chartTypeItem(chartType)).first(); - const isVisible = await chartItem.isVisible().catch(() => false); - if (isVisible) { - const parent = chartItem.locator('..'); - // Prefer data-selected attribute (ChartSelection.vue exposes it on the
  • ). - // Fall back to legacy bg-grey-3/5 (framework) and bg-gray-200/400 (Tailwind). - const dataSelected = await parent.getAttribute('data-selected'); - if (dataSelected === 'true') { - testLogger.info(`Current chart type detected: ${chartType}`); - return chartType; - } - if (dataSelected === null) { - const parentClassList = (await parent.getAttribute('class')) || ''; - if ( - parentClassList.includes('bg-grey-3') || - parentClassList.includes('bg-grey-5') || - parentClassList.includes('bg-gray-200') || - parentClassList.includes('bg-gray-400') - ) { - testLogger.info(`Current chart type detected: ${chartType}`); - return chartType; - } + // ChartSelection.vue marks the selected chart on the
  • via + // :data-test-selected="item.id" (the inner div carries data-selected="true|false", + // so reading data-selected off the
  • always yields null). PanelEditor is + // mounted in both the Build and Visualize tabs via v-show, so filter to the + // visible instance with offsetParent — same approach as verifyChartTypeSelected. + const chartType = await this.page.evaluate(() => { + const items = document.querySelectorAll('[data-test-selected]'); + for (const item of items) { + if (item.offsetParent !== null) { + return item.getAttribute('data-test-selected'); } } + return null; + }).catch(() => null); + + if (chartType) { + testLogger.info(`Current chart type detected: ${chartType}`); + return chartType; } testLogger.warn('No chart type detected as selected'); return null; } /** - * Wait for any chart type to become selected (theme-aware). - * Uses page.waitForFunction for reliable detection of bg-grey-3/bg-grey-5 - * directly in the DOM, surviving reactive re-renders across tab switches. + * Wait for any chart type to become selected and return its id. + * ChartSelection.vue sets :data-test-selected="item.id" on the selected
  • + * (and only on that one), so the attribute value IS the chart type. The inner + * div's data-selected="true|false" lives on a different element, and the legacy + * bg-grey and bg-gray classes no longer exist (the selected
  • now uses the + * bg-label-chip-url-bg token), so neither is a usable signal here. * @param {number} timeout - Max wait time in ms (default 20000) * @returns {Promise} The selected chart type or null if timeout */ async waitForChartTypeStabilized(timeout = 20000) { try { - // Use waitForFunction to detect bg-grey-3 or bg-grey-5 on chart selection items - // This is more reliable than Playwright locator polling during reactive re-renders + // waitForFunction rather than locator polling: the panel re-renders + // reactively across tab switches and URL restoration. const result = await this.page.waitForFunction(() => { - const items = document.querySelectorAll('[data-test="dashboard-addpanel-chart-selection-item"]'); + const items = document.querySelectorAll('[data-test-selected]'); for (const item of items) { - const classes = item.className || ''; - // Prefer data-selected attribute (ChartSelection.vue), fall back to legacy classes - const dataSelected = item.getAttribute('data-selected'); - const matchesSelected = dataSelected === 'true' || - (dataSelected === null && ( - classes.includes('bg-grey-3') || - classes.includes('bg-grey-5') || - classes.includes('bg-gray-200') || - classes.includes('bg-gray-400') - )); - if (matchesSelected) { - // Found selected item - extract chart type from child data-test attribute - const section = item.querySelector('[data-test^="selected-chart-"][data-test$="-item"]'); - if (section) { - const attr = section.getAttribute('data-test'); - const match = attr.match(/^selected-chart-(.+)-item$/); - if (match) return match[1]; - } + // PanelEditor is mounted in both Build and Visualize tabs via + // v-show, so skip the hidden copy. + if (item.offsetParent !== null) { + return item.getAttribute('data-test-selected'); } } return null; diff --git a/tests/ui-testing/playwright-tests/Logs/logsVisualizePersistence.spec.js b/tests/ui-testing/playwright-tests/Logs/logsVisualizePersistence.spec.js index f51b3896cc..ebd0cf4f17 100644 --- a/tests/ui-testing/playwright-tests/Logs/logsVisualizePersistence.spec.js +++ b/tests/ui-testing/playwright-tests/Logs/logsVisualizePersistence.spec.js @@ -5,6 +5,12 @@ const logData = require("../../fixtures/log.json"); const { ingestTestData: _ingestData } = require('../utils/data-ingestion.js'); const { getOrgIdentifier } = require('../utils/cloud-auth.js'); +// VRL body the transform-editor test asserts on. clickVrlEditor() types this +// into Monaco itself (via keyboard.type + a model-stability wait), so the value +// here must match that helper rather than being typed separately — Monaco +// ignores direct .inputarea fills. +const VRL_FUNCTION = '.a=2'; + // ----- Helpers ----- async function ingestTestData(page) { @@ -293,4 +299,291 @@ test.describe("Logs Visualization State Persistence testcases", () => { testLogger.info('Toggle group rendering test completed'); }); + + // =========================================================================== + // PR #13244 / issue #12897 — Visualize render + URL-refresh regressions + // --------------------------------------------------------------------------- + // Defect A: convertPanelData() threw "Please select required fields to render + // the chart" for the logs Timechart's *custom-query* panel while its x/y axes + // were still being populated asynchronously from the result schema. + // Defect B: refreshing the URL on the Visualize tab produced a + // "Select * query is not supported for visualization" toast plus permanent + // No Data, because the toggle was restored ahead of the stream selection and + // nothing re-ran the visualization afterwards. + // + // Every "no error" check below is sequenced AFTER a deterministic positive + // end-state; see the assertion helpers in logsPage.js for why each is safe. + // =========================================================================== + + test("should render the Visualize chart without the false required-fields error", { + tag: ['@logs-visualize-persistence', '@all', '@logs', '@P0'] + }, async ({ page }) => { + testLogger.info('Testing Visualize renders with no required-fields error (defect A)'); + + // 1. Verify Logs table results visible (pre-condition). + await pm.logsPage.expectLogsSearchResultVisible(); + + // 2. Open the Visualize tab (default chart type — no manual chart selection). + await pm.logsPage.clickVisualizeToggle(); + await pm.logsPage.expectVisualizeTabContentVisible(); + + // 3. Positive end-state: the chart actually paints and No Data is gone. + await pm.logsPage.expectVisualizeChartRendered(); + + // 4. The custom-query panel's transient empty x/y must NOT surface the + // builder-mode required-fields error, and no error panel at all may show. + await pm.logsPage.expectNoRequiredFieldsError(); + await pm.logsPage.expectNoDashboardErrors(); + + testLogger.info('Visualize default chart rendered with zero errors'); + }); + + test("should restore chart type, query and chart after refreshing the URL on the Visualize tab", { + tag: ['@logs-visualize-persistence', '@all', '@logs', '@P0'] + }, async ({ page }) => { + testLogger.info("Testing PR repro: match_all('error') → h-bar → URL refresh"); + + // 1. Run the reported query. + await pm.logsPage.typeQuery("match_all('error')"); + await pm.logsPage.applyQueryAndWaitForSearchResponse(); + + // 2. Open the Visualize tab and let the first render complete. + await pm.logsPage.clickVisualizeToggle(); + await pm.logsPage.expectVisualizeTabContentVisible(); + await pm.logsPage.expectVisualizeChartRendered(); + + // 3. Switch the chart type to horizontal bar. + await pm.logsPage.selectChartType('h-bar'); + await pm.logsPage.verifyChartTypeSelected('h-bar'); + await pm.logsPage.expectVisualizeChartRendered(); + + // 4. Gate on the URL sync — updateUrlQueryParams() runs after the chart type + // watcher settles, so reloading earlier would not exercise URL restoration. + await pm.logsPage.waitForVisualizeUrlState(); + + // Capture the panel payload the app wrote for this configured state. + const configBeforeReload = await pm.logsPage.getVisualizationDataFromUrl(); + expect(configBeforeReload, 'visualization_data should be present before reload').toBeTruthy(); + expect(configBeforeReload.type).toBe('h-bar'); + + // 5. Start recording toasts, then refresh the URL. + await pm.logsPage.startToastRecorder(); + await page.reload(); + await page.waitForLoadState('domcontentloaded'); + + // 6. Positive end-state after restore: (b) chart type still h-bar and + // (c) the chart really renders (not the permanent No Data state). + await pm.logsPage.expectVisualizeTabContentVisible(); + await pm.logsPage.verifyChartTypeSelected('h-bar'); + await pm.logsPage.expectVisualizeChartRendered(); + + // 6b. Chart CONFIG (not just type) survived. After load the app rewrites + // visualization_data from its restored in-memory panel, so an equal payload + // proves restoreVisualizationFromUrlOnLoad() repopulated the config — a URL + // that merely survived untouched could not produce this. + await expect + .poll(async () => (await pm.logsPage.getVisualizationDataFromUrl())?.type, { + timeout: 20000, + message: 'visualization_data was not rewritten after reload', + }) + .toBe('h-bar'); + const configAfterReload = await pm.logsPage.getVisualizationDataFromUrl(); + expect(configAfterReload.config).toEqual(configBeforeReload.config); + + // 7. (a) no "Select * query is not supported for visualization" toast, and no + // spurious required-fields error / error panel. + await pm.logsPage.expectNoSelectStarVisualizationToast(); + await pm.logsPage.expectNoRequiredFieldsError(); + await pm.logsPage.expectNoDashboardErrors(); + + // 8. (d) the query survived the refresh. + await pm.logsPage.expectQueryEditorContainsText("match_all('error')"); + + testLogger.info('URL refresh restored chart type, query and chart with zero errors'); + }); + + test("should re-render the Visualize chart after reload with the default chart type", { + tag: ['@logs-visualize-persistence', '@all', '@logs', '@P0'] + }, async ({ page }) => { + testLogger.info('Testing reload on Visualize tab with the default chart type'); + + // 1. Open the Visualize tab and let the first render complete. + await pm.logsPage.clickVisualizeToggle(); + await pm.logsPage.expectVisualizeTabContentVisible(); + await pm.logsPage.expectVisualizeChartRendered(); + + // 2. Capture whatever chart type the Timechart auto-selected — the restore must + // bring back the same one without any manual chart-type interaction. + const defaultChartType = await pm.logsPage.waitForChartTypeStabilized(); + expect(defaultChartType).toBeTruthy(); + testLogger.info(`Default chart type before reload: ${defaultChartType}`); + + // 3. Gate on the URL sync, then reload with toast recording active. + await pm.logsPage.waitForVisualizeUrlState(); + await pm.logsPage.startToastRecorder(); + await page.reload(); + await page.waitForLoadState('domcontentloaded'); + + // 4. Positive end-state: same chart type, chart repainted, no No-Data state. + await pm.logsPage.expectVisualizeTabContentVisible(); + await pm.logsPage.verifyChartTypeSelected(defaultChartType); + await pm.logsPage.expectVisualizeChartRendered(); + + // 5. No error toast and no error panel after the restore. + await pm.logsPage.expectNoSelectStarVisualizationToast(); + await pm.logsPage.expectNoRequiredFieldsError(); + await pm.logsPage.expectNoDashboardErrors(); + + testLogger.info('Default chart type re-rendered after reload with zero errors'); + }); + + test("should restore a non-default chart type (area) after refreshing the URL", { + tag: ['@logs-visualize-persistence', '@all', '@logs', '@P1'] + }, async ({ page }) => { + testLogger.info('Testing chart-type restore is not h-bar specific (area)'); + + // 1. Open the Visualize tab and let the first render complete. + await pm.logsPage.clickVisualizeToggle(); + await pm.logsPage.expectVisualizeTabContentVisible(); + await pm.logsPage.expectVisualizeChartRendered(); + + // 2. Switch to the area chart ("area" is one of the chart types the fix's + // restoreVisualizationFromUrlOnLoad() whitelist accepts). + await pm.logsPage.selectChartType('area'); + await pm.logsPage.verifyChartTypeSelected('area'); + await pm.logsPage.expectVisualizeChartRendered(); + + // 3. Gate on the URL sync, capture the payload, then reload with toast recording. + await pm.logsPage.waitForVisualizeUrlState(); + const areaConfigBeforeReload = await pm.logsPage.getVisualizationDataFromUrl(); + expect(areaConfigBeforeReload, 'visualization_data should be present before reload').toBeTruthy(); + expect(areaConfigBeforeReload.type).toBe('area'); + + await pm.logsPage.startToastRecorder(); + await page.reload(); + await page.waitForLoadState('domcontentloaded'); + + // 4. Positive end-state: area restored and repainted. + await pm.logsPage.expectVisualizeTabContentVisible(); + await pm.logsPage.verifyChartTypeSelected('area'); + await pm.logsPage.expectVisualizeChartRendered(); + + // 4b. Config survived the restore, not just the chart type (see test above). + await expect + .poll(async () => (await pm.logsPage.getVisualizationDataFromUrl())?.type, { + timeout: 20000, + message: 'visualization_data was not rewritten after reload', + }) + .toBe('area'); + const areaConfigAfterReload = await pm.logsPage.getVisualizationDataFromUrl(); + expect(areaConfigAfterReload.config).toEqual(areaConfigBeforeReload.config); + + // 5. No error toast and no error panel after the restore. + await pm.logsPage.expectNoSelectStarVisualizationToast(); + await pm.logsPage.expectNoRequiredFieldsError(); + await pm.logsPage.expectNoDashboardErrors(); + + testLogger.info('Area chart type restored after reload with zero errors'); + }); + + test("should keep the Visualize chart rendering after reload with a VRL function applied", { + tag: ['@logs-visualize-persistence', '@all', '@logs', '@P1'] + }, async ({ page }) => { + testLogger.info('Testing VRL variant of the URL-refresh restore'); + + // 1. Enable the transform (VRL) editor. clickVrlEditor() focuses Monaco, clears + // it, types VRL_FUNCTION and waits for the model to settle past the editor's + // debounce, so tempFunctionContent is populated when it returns. Assert the + // body really landed rather than trusting the helper. + await pm.logsPage.toggleVrlEditor(); + await pm.logsPage.clickVrlEditor(); + await pm.logsPage.expectVrlEditorContains(VRL_FUNCTION); + + // 2. Re-run the search so the VRL function is part of the current query state. + await pm.logsPage.applyQueryAndWaitForSearchResponse(); + + // 3. Open the Visualize tab and let the first render complete. + await pm.logsPage.clickVisualizeToggle(); + await pm.logsPage.expectVisualizeTabContentVisible(); + await pm.logsPage.expectVisualizeChartRendered(); + + // 4. Gate on the URL sync INCLUDING functionContent — that param is only written + // when transformType === "function" and the body is non-empty, so reaching + // this line proves the reload will genuinely restore a VRL-carrying state. + await pm.logsPage.waitForVisualizeUrlState({ requireFunctionContent: true }); + await pm.logsPage.startToastRecorder(); + await page.reload(); + await page.waitForLoadState('domcontentloaded'); + + // 5. Positive end-state: chart repainted after the VRL-carrying restore, and the + // VRL body itself came back (useLogs restores tempFunctionContent from the URL). + await pm.logsPage.expectVisualizeTabContentVisible(); + await pm.logsPage.expectVisualizeChartRendered(); + await pm.logsPage.expectVrlEditorContains(VRL_FUNCTION); + + // 6. No error toast and no error panel after the restore. + await pm.logsPage.expectNoSelectStarVisualizationToast(); + await pm.logsPage.expectNoRequiredFieldsError(); + await pm.logsPage.expectNoDashboardErrors(); + + testLogger.info('VRL visualize state restored after reload with zero errors'); + }); + + test("should never show the required-fields error while toggling Visualize → Logs → Visualize", { + tag: ['@logs-visualize-persistence', '@all', '@logs', '@P1'] + }, async ({ page }) => { + testLogger.info('Testing manual toggle round-trip stays free of required-fields errors'); + + // 1. First entry into Visualize — chart paints, no errors. + await pm.logsPage.clickVisualizeToggle(); + await pm.logsPage.expectVisualizeTabContentVisible(); + await pm.logsPage.expectVisualizeChartRendered(); + await pm.logsPage.expectNoRequiredFieldsError(); + await pm.logsPage.expectNoDashboardErrors(); + + // 2. Back to Logs. + await pm.logsPage.clickLogsToggle(); + await pm.logsPage.expectLogsSearchResultVisible(); + + // 3. Second entry into Visualize — this rebuild resets the panel fields, which is + // exactly the transient empty x/y window that used to throw. + await pm.logsPage.clickVisualizeToggle(); + await pm.logsPage.expectVisualizeTabContentVisible(); + await pm.logsPage.expectVisualizeChartRendered(); + await pm.logsPage.expectNoRequiredFieldsError(); + await pm.logsPage.expectNoDashboardErrors(); + + testLogger.info('Toggle round-trip completed with zero required-fields errors'); + }); + + test("should not emit the SELECT * toast when opening the Visualize tab with no stream in the URL", { + tag: ['@logs-visualize-persistence', '@all', '@logs', '@P2'] + }, async ({ page }) => { + testLogger.info('Testing direct Visualize navigation without a stream in the URL'); + + // 1. Record toasts from document-start of the upcoming navigation. + await pm.logsPage.startToastRecorder(); + + // 2. Navigate directly with only the visualize toggle. Logs page state is + // Vuex-backed (not localStorage), so a URL without a `stream` param is a + // genuine "no stream selected yet" load — the ordering that used to make the + // toggle watcher build `select * from "undefined"`. + await page.goto( + `${logData.logsUrl}?org_identifier=${getOrgIdentifier()}&logs_visualize_toggle=visualize` + ); + await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {}); + + // 3. Positive end-state: the Visualize tab really is the active tab. A No Data + // placeholder is legitimate here (there is nothing to chart), so this uses the + // tab-content check rather than expectVisualizeChartRendered() — but it is + // paired with expectLogsSearchResultNotVisible() so a page that silently failed + // to restore the tab cannot pass on the toast-absence assertion alone. + await pm.logsPage.expectVisualizeTabContentVisible(); + await pm.logsPage.expectLogsSearchResultNotVisible(); + + // 4. The SELECT * toast must never have been emitted during that load. + await pm.logsPage.expectNoSelectStarVisualizationToast(); + + testLogger.info('Direct Visualize navigation emitted no SELECT * toast'); + }); }); diff --git a/tests/ui-testing/playwright-tests/Logs/logsVisualizeSelectStar.spec.js b/tests/ui-testing/playwright-tests/Logs/logsVisualizeSelectStar.spec.js new file mode 100644 index 0000000000..c420054163 --- /dev/null +++ b/tests/ui-testing/playwright-tests/Logs/logsVisualizeSelectStar.spec.js @@ -0,0 +1,211 @@ +const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js'); +const testLogger = require('../utils/test-logger.js'); +const PageManager = require('../../pages/page-manager.js'); +const logData = require("../../fixtures/log.json"); +const { ingestTestData: _ingestData } = require('../utils/data-ingestion.js'); +const { getOrgIdentifier } = require('../utils/cloud-auth.js'); + +// ============================================================================ +// SELECT * handling on the logs Visualize tab — PR #13244 / issue #12897. +// +// The old code blocked SELECT * for EVERY chart type, before the chart type was +// even finalised. The fix replaces that with isSelectStarForTable(): only the +// table chart is blocked (it renders the raw query columns), while histogram- +// based charts (line/bar/area/scatter) render histogram(_timestamp)/count(*) +// and legitimately ignore the SELECT columns. +// +// WHY THIS IS ITS OWN SPEC FILE: +// isSelectStarForTable() short-circuits to false unless the SERVER was started +// with ZO_QUICK_MODE_ENABLED=true. That flag is read once at process start, so +// it cannot vary between tests sharing a backend. CI gives each matrix shard its +// own OpenObserve process, so this file is pinned to its own shard in +// ci-matrix/ci_matrix.json with quick_mode_enabled: "true"; every other shard +// omits the field and keeps the default (false). Keep these tests here rather +// than folding them back into logsVisualizePersistence.spec.js, or they will +// silently skip. +// ============================================================================ + +// ----- Helpers ----- + +async function ingestTestData(page) { + await _ingestData(page); +} + +// ----- Test Suite ----- + +test.describe("Logs Visualize SELECT * handling testcases", () => { + test.describe.configure({ mode: 'parallel' }); + let pm; + + test.beforeEach(async ({ page }, testInfo) => { + testLogger.testStart(testInfo.title, testInfo.file); + await navigateToBase(page); + pm = new PageManager(page); + await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); + await ingestTestData(page); + await page.waitForLoadState('domcontentloaded'); + await page.goto( + `${logData.logsUrl}?org_identifier=${getOrgIdentifier()}` + ); + await pm.logsPage.selectStream("e2e_automate"); + await pm.logsPage.applyQueryAndWaitForSearchResponse(); + await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); + + // Fail loudly rather than skip: this shard exists precisely to run with quick + // mode on, so a false here means the shard is misconfigured (or the matrix + // entry was lost) and the coverage has silently evaporated. + expect( + await pm.logsPage.isQuickModeEnabledOnInstance(), + 'This spec requires the backend to run with ZO_QUICK_MODE_ENABLED=true — ' + + 'check the quick_mode_enabled flag on this shard in ci-matrix/ci_matrix.json' + ).toBe(true); + + testLogger.info('Test setup completed'); + }); + + test("should block a table chart on a SELECT * query and revert the chart type", { + tag: ['@logs-visualize-selectstar', '@all', '@logs', '@P0'] + }, async ({ page }) => { + testLogger.info('Testing SELECT * table guard (toast + chart type revert)'); + + // 1. SQL mode with an explicit SELECT * — in SQL mode + // getEffectiveVisualizeQuery() returns the raw query, so this is + // deterministically a select-all (quick mode cannot rewrite it). + await pm.logsPage.enableSQLMode(); + await pm.logsPage.typeQuery('SELECT * FROM "e2e_automate"'); + await pm.logsPage.applyQueryAndWaitForSearchResponse(); + + // Precondition gate: the editor really holds a select-all. Without this, a + // failure to enter SQL mode would surface as the confusing "expected a toast, + // none emitted" below instead of pointing at the actual cause. + await pm.logsPage.expectQueryEditorContainsText('SELECT * FROM'); + + // 2. Open Visualize — a histogram chart type is selected by default and must render. + await pm.logsPage.clickVisualizeToggle(); + await pm.logsPage.expectVisualizeTabContentVisible(); + await pm.logsPage.expectVisualizeChartRendered(); + + // 3. Record the chart type the panel settled on; the revert must return to it. + const typeBeforeTable = await pm.logsPage.waitForChartTypeStabilized(); + expect(typeBeforeTable, 'a chart type should be selected before switching').toBeTruthy(); + expect(typeBeforeTable).not.toBe('table'); + + // 4. Attempt to switch to the table chart with the recorder armed. + await pm.logsPage.startToastRecorder(); + await pm.logsPage.selectChartType('table'); + + // 5. The guard fires: toast shown AND the chart type reverts to the previous one. + await pm.logsPage.expectSelectStarVisualizationToast(); + await pm.logsPage.verifyChartTypeSelected('table', false); + await pm.logsPage.verifyChartTypeSelected(typeBeforeTable); + + // 6. The revert must leave a WORKING panel, not a blank one — the guard returns + // early after reverting, so this proves the previous chart survived intact. + await pm.logsPage.expectVisualizeChartRendered(); + await pm.logsPage.expectNoDashboardErrors(); + + testLogger.info(`SELECT * table guard fired and reverted to "${typeBeforeTable}"`); + }); + + test("should allow a histogram chart on a SELECT * query without the SELECT * toast", { + tag: ['@logs-visualize-selectstar', '@all', '@logs', '@P0'] + }, async ({ page }) => { + testLogger.info('Testing SELECT * is allowed for histogram-based charts'); + + // 1. Same SELECT * query that blocks the table chart above. + await pm.logsPage.enableSQLMode(); + await pm.logsPage.typeQuery('SELECT * FROM "e2e_automate"'); + await pm.logsPage.applyQueryAndWaitForSearchResponse(); + + // Precondition gate — ESSENTIAL here. This test's payload assertion is the + // ABSENCE of a toast, which passes trivially if the query was never a + // select-all in the first place (e.g. SQL mode failed to engage and quick mode + // rewrote it to SELECT ). Verifying the editor holds a select-all is + // what stops this from becoming a test that can never fail. + await pm.logsPage.expectQueryEditorContainsText('SELECT * FROM'); + + // 2. Arm the recorder BEFORE entering Visualize — the pre-fix code emitted the + // toast on entry, ahead of any chart-type choice, so recording from here is + // what makes this fail on the unfixed build. + await pm.logsPage.startToastRecorder(); + await pm.logsPage.clickVisualizeToggle(); + await pm.logsPage.expectVisualizeTabContentVisible(); + await pm.logsPage.expectVisualizeChartRendered(); + + // 3. Explicitly pick a histogram-based chart type and confirm it renders. + await pm.logsPage.selectChartType('line'); + await pm.logsPage.verifyChartTypeSelected('line'); + await pm.logsPage.expectVisualizeChartRendered(); + + // 4. No SELECT * toast at any point, and no error panel. + await pm.logsPage.expectNoSelectStarVisualizationToast(); + await pm.logsPage.expectNoDashboardErrors(); + + testLogger.info('SELECT * rendered on a line chart with no toast'); + }); + + test("should keep a SELECT * line chart rendered after a browser refresh without the SELECT * toast", { + tag: ['@logs-visualize-selectstar', '@all', '@logs', '@P0'] + }, async ({ page }) => { + testLogger.info('Testing SELECT * + histogram chart survives a URL refresh'); + + // 1. SELECT * in SQL mode, same as the tests above. + await pm.logsPage.enableSQLMode(); + await pm.logsPage.typeQuery('SELECT * FROM "e2e_automate"'); + await pm.logsPage.applyQueryAndWaitForSearchResponse(); + + // Precondition gate — the post-refresh "no toast" assertion below is a negative, + // so it would pass trivially if this were never a select-all query. + await pm.logsPage.expectQueryEditorContainsText('SELECT * FROM'); + + // 2. Render a histogram-based chart (allowed for SELECT * by the fix). + await pm.logsPage.clickVisualizeToggle(); + await pm.logsPage.expectVisualizeTabContentVisible(); + await pm.logsPage.expectVisualizeChartRendered(); + await pm.logsPage.selectChartType('line'); + await pm.logsPage.verifyChartTypeSelected('line'); + await pm.logsPage.expectVisualizeChartRendered(); + + // 3. Gate on the URL sync so the reload actually exercises URL restoration, + // and capture the payload to compare against the restored state. + await pm.logsPage.waitForVisualizeUrlState(); + const configBeforeRefresh = await pm.logsPage.getVisualizationDataFromUrl(); + expect(configBeforeRefresh, 'visualization_data should be present before refresh').toBeTruthy(); + expect(configBeforeRefresh.type).toBe('line'); + + // 4. Refresh with the recorder armed — the restore path re-runs the + // visualization, which is where a spurious SELECT * toast would surface. + await pm.logsPage.startToastRecorder(); + await page.reload(); + await page.waitForLoadState('domcontentloaded'); + + // 5. The chart comes back: same type, actually painted, not the No Data state. + await pm.logsPage.expectVisualizeTabContentVisible(); + await pm.logsPage.verifyChartTypeSelected('line'); + await pm.logsPage.expectVisualizeChartRendered(); + + // 6. The SELECT * query itself survived the refresh. + await pm.logsPage.expectQueryEditorContainsText('SELECT * FROM'); + + // 7. The core assertion: the visualization already rendered successfully, so the + // restore must NOT re-emit the SELECT * message. Only the table chart is + // guarded, and the restored type is line. + await pm.logsPage.expectNoSelectStarVisualizationToast(); + await pm.logsPage.expectNoRequiredFieldsError(); + await pm.logsPage.expectNoDashboardErrors(); + + // 8. Panel config survived too — after load the app rewrites visualization_data + // from its restored in-memory panel, so an equal payload proves the restore + // repopulated state rather than the query string merely surviving. + await expect + .poll(async () => (await pm.logsPage.getVisualizationDataFromUrl())?.type, { + timeout: 20000, + message: 'visualization_data was not rewritten after refresh', + }) + .toBe('line'); + const configAfterRefresh = await pm.logsPage.getVisualizationDataFromUrl(); + expect(configAfterRefresh.config).toEqual(configBeforeRefresh.config); + + testLogger.info('SELECT * line chart restored after refresh with no toast'); + }); +});