Files
openobserve/tests/ui-testing/pages/generalPages/schemaLoadPage.js
T
6fb5eecdc5 fix: table migration to same components (#13451)
## What changed

Every table in the app now renders through the shared `OTable`
(`web/src/lib/core/Table`). Both legacy implementations are deleted —
`components/TenstackTable.vue` and `plugins/logs/TenstackTable.vue` —
for ~7,000 lines removed and one table behaviour contract instead of
three.

To get there, `OTable` gained: per-cell hover actions, per-column value
filter, opt-in column close "x", sticky pivot total row + right-pinned
total columns, pivot row-field cell merge, caller-owned pagination bar,
delegated scroll (shared scroller with the logs/traces histogram), and
variable-height virtual rows.

## Tables migrated

| Area | Tables |
| --- | --- |
| Logs | Search results grid, correlated logs |
| Traces | Search results grid, trace details sidebar (attributes /
events / links), service graph node panel |
| Dashboards | Table panel + pivot (incl. PromQL), panel pagination
controls, import error panel |
| RUM | Session player traces tab |
| Pipelines | Scheduled pipeline SQL preview |

## Tested

775 unit tests across the 12 affected suites, all passing.

Manually verified per table:

- **Columns** — resize, reorder, close "x", persistence across reload;
horizontal scroll on wide grids; wrap toggle with virtual scroll; client
+ 3-state server sort
- **Pagination** — custom records-per-page, enabling pagination after
mount, scroll-to-top on page change, no duplicate pager
- **Logs** — FTS highlighting, row expand → JSON preview and its
actions, hover actions + AI button, cell font / row density / status
spine, histogram pinned while scrolling sideways
- **Dashboards** — pivot multi-level headers, cell merge, sticky totals
staying aligned under horizontal scroll, cell colouring (auto palette /
value mapping / conditional rules / overrides), CSV + JSON export
- **Traces, RUM, Pipelines** — events table expansion, error-row border,
row click → trace navigation, SQL preview copy + send-to-AI

---------

Co-authored-by: ktx-vaidehi <134508096+ktx-vaidehi@users.noreply.github.com>
Co-authored-by: ktx-vaidehi <vaidehi.akhani@kiara.tech>
Co-authored-by: Shrinath Rao <shnath@openobserve.ai>
2026-07-30 12:18:23 +00:00

396 lines
19 KiB
JavaScript

const { expect } = require('@playwright/test');
const testLogger = require('../../playwright-tests/utils/test-logger.js');
class SchemaLoadPage {
constructor(page) {
this.page = page;
// =========================================================
// Schema load testing locators — strict data-test only
// =========================================================
// Sidebar / navigation menu items
this.menuHomeItem = page.locator('[data-test="menu-link-\\/-item"]');
this.menuLogsItem = page.locator('[data-test="menu-link-\\/logs-item"]');
this.menuStreamsItem = page.locator('[data-test="menu-link-\\/streams-item"]');
// Logs page — stream selection OSelect
// Wrapper carries `data-test="log-search-index-list-select-stream"`; clicking
// the wrapper hits the inner PopoverTrigger button.
this.logSearchIndexSelectStream = page.locator(
'[data-test="log-search-index-list-select-stream"]'
);
// OSelect popover renders with `${parent}-popover`; the inner ListboxFilter
// is data-tested via `${parent}-search` (forwarded from OSelect.vue).
this.logSearchStreamPopover = page.locator(
'[data-test="log-search-index-list-select-stream-popover"]'
);
this.logSearchStreamPopoverSearch = page.locator(
'[data-test="log-search-index-list-select-stream-search"]'
);
// Field search and interaction within the logs explorer
this.logSearchIndexFieldSearchInput = page.locator(
'[data-test="log-search-index-list-field-search-input"]'
);
this.logsSearchBarRefreshBtn = page.locator(
'[data-test="logs-search-bar-refresh-btn"]'
);
this.logsSearchResultTableBody = page.locator(
'[data-test="o2-table-body"]'
);
this.tableRowExpandMenu = page.locator(
'[data-test^="o2-table-expand-"]'
);
this.logTableColumnTimestamp = page.locator(
'[data-test="o2-table-row-0"] [data-test="o2-table-cell-_timestamp"]'
);
// Streams page — search input (OInput wrapper → fill `-field` variant)
this.streamsSearchInput = page.locator(
'[data-test="streams-search-stream-input-field"]'
);
// Schema drawer locators (ODrawer rendered from LogStream.vue)
this.logStreamSchemaBtn = page.locator(
'[data-test="log-stream-schema-btn"]'
);
this.logStreamExploreBtn = page.locator(
'[data-test="log-stream-explore-btn"]'
);
this.logStreamStoreOriginalDataToggle = page.locator(
'[data-test="log-stream-store-original-data-toggle-btn"]'
);
this.schemaDrawerCloseBtn = page.locator(
'[data-test="schema-drawer"] [data-test="o-drawer-close-btn"]'
);
// Log attributes verification — `logs-search-subfield-add-...` is per-field
this.logsSearchSubfieldAdd = page.locator(
'[data-test="logs-search-subfield-add-log_log_attribute10007-Lorem ipsum dolor sit amet\\, consectetur adipiscing elit\\. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua\\."]'
);
}
// ─────────────────────────────────────────────────────────────────────────
// Factory helpers — runtime-dynamic data-test values
// ─────────────────────────────────────────────────────────────────────────
// Get the deterministic per-name cell on the Streams page table
getStreamNameCell(streamName) {
return this.page.locator(`[data-test="log-stream-name-cell-${streamName}"]`);
}
// Get the OSelect option inside the stream selector popover keyed by value
getStreamSelectOption(streamName) {
return this.page.locator(
`[data-test="log-search-index-list-select-stream-popover"] [data-test="log-search-index-list-select-stream-option"][data-test-value="${streamName}"]`
);
}
// Get any OSelect option inside the stream selector popover
getStreamSelectOptions() {
return this.page.locator(
'[data-test="log-search-index-list-select-stream-popover"] [data-test="log-search-index-list-select-stream-option"]'
);
}
// Resolve the OTable row containing the named stream by walking up from the cell
getStreamRow(streamName) {
return this.getStreamNameCell(streamName)
.first()
.locator(`xpath=ancestor::*[starts-with(@data-test,'o2-table-row-')]`)
.first();
}
// Stream-row schema button — scoped to a single row
getStreamSchemaBtn(streamName) {
return this.getStreamRow(streamName).locator('[data-test="log-stream-schema-btn"]').first();
}
// Stream-row explore button — scoped to a single row
getStreamExploreBtn(streamName) {
return this.getStreamRow(streamName).locator('[data-test="log-stream-explore-btn"]').first();
}
// Navigation methods
async navigateToHome() {
testLogger.debug('Navigating to home page');
await this.menuHomeItem.click();
await this.page.waitForLoadState('domcontentloaded');
}
async navigateToLogs() {
testLogger.debug('Navigating to logs page');
await this.menuLogsItem.click();
await this.page.waitForLoadState('domcontentloaded');
}
async navigateToStreams() {
testLogger.debug('Navigating to streams page');
await this.menuStreamsItem.click();
await this.page.waitForLoadState('domcontentloaded');
}
// Stream search and selection methods
async searchAndSelectStream(streamName) {
testLogger.debug('Searching and selecting stream', { streamName });
// Open the OSelect popover
await this.logSearchIndexSelectStream.waitFor({ state: 'visible', timeout: 15000 });
await this.logSearchIndexSelectStream.click();
await this.logSearchStreamPopover.waitFor({ state: 'visible', timeout: 10000 });
// Type the stream name into the popover's ListboxFilter to narrow options.
// Use Ctrl+A → Backspace before fill to defensively clear reka-ui's
// ComboboxInput internal searchTerm state (which can survive a `.fill()`
// overwrite alone) before we re-filter.
await this.logSearchStreamPopoverSearch.waitFor({ state: 'visible', timeout: 10000 });
await this.logSearchStreamPopoverSearch.press('ControlOrMeta+a');
await this.logSearchStreamPopoverSearch.press('Backspace');
await this.logSearchStreamPopoverSearch.fill(streamName);
// After filtering, the popover narrows to just our stream — click the first option.
// (OSelect's virtualizer can have a stale `data-test-value` when reusing DOM nodes,
// so we rely on the filter-result count rather than the value-attribute match.)
await expect.poll(
async () => {
return await this.getStreamSelectOptions().count().catch(() => -1);
},
{ intervals: [500, 1000, 2000], timeout: 20000 }
).toBeGreaterThanOrEqual(1);
const option = this.getStreamSelectOptions().first();
await option.waitFor({ state: 'visible', timeout: 15000 });
await option.click();
await this.page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {});
testLogger.debug('Stream selected successfully', { streamName });
}
// Field search and verification methods
async searchLogField(fieldValue) {
testLogger.debug('Searching log field', { fieldValue });
await this.logSearchIndexFieldSearchInput.waitFor({ state: 'visible' });
await this.logSearchIndexFieldSearchInput.click();
await this.logSearchIndexFieldSearchInput.fill(fieldValue);
await this.page.waitForLoadState('domcontentloaded');
}
async refreshLogs() {
testLogger.debug('Refreshing logs search');
await this.logsSearchBarRefreshBtn.waitFor({ state: 'visible', timeout: 15000 });
// Wait for search response to complete
const searchResponsePromise = this.page.waitForResponse(
response => response.url().includes('/_search') && response.status() === 200,
{ timeout: 60000 }
);
await this.logsSearchBarRefreshBtn.click();
// Wait for the search to complete
await searchResponsePromise;
// Use non-blocking wait for networkidle as the page may have long-polling connections
await this.page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {
testLogger.debug('Network idle timeout after search - continuing with verification');
});
// Check if the results table body is attached — empty results are acceptable
// for schema verification because the stream existing + search succeeding is enough.
try {
await this.logsSearchResultTableBody.waitFor({ state: 'visible', timeout: 10000 });
testLogger.debug('Log search results loaded successfully');
} catch (error) {
testLogger.debug('No log results found, but search completed successfully');
// This is acceptable - the stream exists and search completes, even if no results match
}
}
async expandFirstLogRow() {
testLogger.debug('Expanding first log row');
await this.tableRowExpandMenu.first().waitFor({ state: 'visible' });
await this.tableRowExpandMenu.first().click();
await this.page.waitForLoadState('domcontentloaded');
}
async verifyLogFieldVisible() {
testLogger.debug('Verifying log field visibility');
// The subfield-add data-test contains the literal Lorem ipsum text, so the
// single locator is enough to assert visibility — no nested getByText needed.
await expect(this.logsSearchSubfieldAdd).toBeVisible();
testLogger.debug('Log field verified as visible');
}
async verifyTimestampColumn() {
testLogger.debug('Verifying timestamp column visibility');
await this.logTableColumnTimestamp.waitFor({ state: 'visible' });
await expect(this.logTableColumnTimestamp).toBeVisible();
testLogger.debug('Timestamp column verified as visible');
}
// Stream management methods
async searchStreamInStreamsPage(streamName) {
testLogger.debug('Searching stream in streams page', { streamName });
// OInput inner `-field` variant — always fill the inner input
await this.streamsSearchInput.waitFor({ state: 'visible', timeout: 15000 });
await this.streamsSearchInput.click();
await this.streamsSearchInput.fill(streamName);
// Deterministically wait until the per-name cell shows up — replaces blanket timeout
await this.getStreamNameCell(streamName).first().waitFor({ state: 'visible', timeout: 20000 });
await this.page.waitForLoadState('domcontentloaded');
}
async openStreamDetails(streamName) {
testLogger.debug('Opening stream details', { streamName });
// Walk up from the per-name cell to the OTable row, then click the schema button
await this.getStreamNameCell(streamName).first().waitFor({ state: 'visible', timeout: 15000 });
await this.getStreamRow(streamName).hover();
await this.getStreamSchemaBtn(streamName).click({ force: true, timeout: 15000 });
await this.page.waitForLoadState('domcontentloaded');
}
async toggleStoreOriginalData() {
testLogger.debug('Toggling store original data setting');
await this.logStreamStoreOriginalDataToggle.first().waitFor({ state: 'visible' });
await this.logStreamStoreOriginalDataToggle.first().click();
await this.page.waitForLoadState('domcontentloaded');
}
async closeDialog() {
testLogger.debug('Closing dialog');
await this.schemaDrawerCloseBtn.waitFor({ state: 'visible', timeout: 15000 });
await this.schemaDrawerCloseBtn.click();
await this.page.waitForLoadState('domcontentloaded');
}
async exploreStream(streamName) {
testLogger.debug('Exploring stream', { streamName });
// Resolve the row containing the named stream via the per-name cell
await this.getStreamNameCell(streamName).first().waitFor({ state: 'visible', timeout: 15000 });
await this.getStreamRow(streamName).hover();
await this.getStreamExploreBtn(streamName).click({ force: true, timeout: 15000 });
// Extended timeout for large schema exploration
await this.page.waitForLoadState('networkidle', { timeout: 60000 }).catch(() => {});
await this.page.waitForLoadState('domcontentloaded');
}
// Complete workflow methods
async completeSchemaLoadVerificationWorkflow(streamName) {
testLogger.debug('Starting schema load verification workflow with stream check', { streamName });
try {
// Step 1: Navigate to streams page first to verify stream exists
await this.navigateToStreams();
await this.searchStreamInStreamsPage(streamName);
// Wait for stream cell to be visible in streams page
try {
const streamCell = this.getStreamNameCell(streamName).first();
await streamCell.scrollIntoViewIfNeeded({ timeout: 20000 });
await streamCell.waitFor({ state: 'visible', timeout: 20000 });
testLogger.debug('Stream verified in streams page', { streamName });
} catch (error) {
testLogger.error('Stream not found in streams page', { streamName, error: error.message });
throw new Error(`Stream ${streamName} not found in streams page after ingestion`);
}
// Step 2: Navigate to logs and verify stream is available in log search.
// Use the sidebar Logs link (in-app navigation) and then a hard reload so the
// Vuex streams cache is dropped — without the reload, the OSelect dropdown
// pulls from the stale list that was fetched on the first /web/logs visit
// (before this test's stream existed). The reload triggers a fresh
// streams-list API call.
await this.navigateToLogs();
// Gate the stream-selector readiness on the REAL signal: the fresh
// streams-list API call the logs page fires on (re)load. The OSelect
// wrapper only mounts once the SearchBar has this list, so on a heavy
// (large-schema) stream under load the wrapper can lag well past a bare
// 30s wait while the SPA is still bootstrapping/fetching. Arm the listener
// BEFORE reload() so we capture the post-reload request (not the stale one
// from the first /web/logs visit). If it never arrives, fall through — the
// OSelect wait below still guards correctness (and surfaces genuine
// streams-API slowness rather than masking it).
const streamsListLoaded = this.page.waitForResponse(
(resp) => resp.url().includes('/streams?type=logs') && resp.status() === 200,
{ timeout: 60000 }
).catch(() => {
testLogger.debug('streams?type=logs response not observed within timeout - continuing to OSelect wait');
return null;
});
await this.page.reload();
await this.page.waitForLoadState('domcontentloaded');
await streamsListLoaded;
// Wait for the OSelect to mount now that the streams list has been fetched
await this.logSearchIndexSelectStream.waitFor({ state: 'visible', timeout: 30000 });
// Open the stream dropdown
await this.logSearchIndexSelectStream.click();
await this.logSearchStreamPopover.waitFor({ state: 'visible', timeout: 15000 });
// Type the stream name into the popover's ListboxFilter to narrow options.
// Defensive clear via Ctrl+A → Backspace handles reka-ui's
// ComboboxInput internal searchTerm state surviving a `.fill()` overwrite.
await this.logSearchStreamPopoverSearch.waitFor({ state: 'visible', timeout: 10000 });
await this.logSearchStreamPopoverSearch.press('ControlOrMeta+a');
await this.logSearchStreamPopoverSearch.press('Backspace');
await this.logSearchStreamPopoverSearch.fill(streamName);
// After filtering by stream name, the popover's filteredOptions should narrow to
// just our stream. Wait until exactly one option remains, then click it.
// (Note: OSelect's virtualizer can have a stale `data-test-value` attribute
// when reusing DOM nodes, so we cannot reliably target by value here; the
// filter result count + first-option approach is deterministic.)
await expect.poll(
async () => {
return await this.getStreamSelectOptions().count().catch(() => -1);
},
{ intervals: [500, 1000, 2000], timeout: 30000 }
).toBeGreaterThanOrEqual(1);
const option = this.getStreamSelectOptions().first();
await option.waitFor({ state: 'visible', timeout: 30000 });
await option.scrollIntoViewIfNeeded({ timeout: 20000 });
await option.click();
// Use a non-blocking wait for networkidle as the page may have long-polling connections
await this.page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {
testLogger.debug('Network idle timeout after stream click - continuing with verification');
});
// Step 3: Basic verification - refresh and verify search functionality works
await this.refreshLogs();
// Simplified verification - just check that the search completed successfully
// The refreshLogs method already validates that search API responded successfully
testLogger.debug('Schema load verification: Search functionality verified');
// Step 4: Basic structural check - verify search interface is working
await expect(this.logsSearchResultTableBody).toBeAttached();
testLogger.debug('Schema load verification: Search results container verified');
testLogger.debug('Schema load verification workflow completed successfully');
} catch (error) {
testLogger.error('Error in schema load verification workflow', { error: error.message, streamName });
throw error;
}
}
}
module.exports = SchemaLoadPage;