test: enhance logs visualization error handling and persistence tests (#13591)

## 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".
This commit is contained in:
ktx-akshay
2026-08-04 12:09:26 +00:00
committed by GitHub
parent 375a7360de
commit 7999fd33f3
6 changed files with 815 additions and 49 deletions
+7
View File
@@ -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 }));
+6
View File
@@ -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
+11
View File
@@ -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",
+287 -49
View File
@@ -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<string[]>}
*/
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<boolean>}
*/
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<object|null>} 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<string|null>} 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 <li>).
// 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 <li> via
// :data-test-selected="item.id" (the inner div carries data-selected="true|false",
// so reading data-selected off the <li> 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 <li>
* (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 <li> 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<string|null>} 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;
@@ -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');
});
});
@@ -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 <fields>). 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');
});
});