test: fix root causes of the flakiest UI tests (#15535)

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