mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
Co-authored-by: Shrinath-O2 <214651540+Shrinath-O2@users.noreply.github.com> Co-authored-by: Vamsi Karthik R <vkarthik@openobserve.ai>
646 lines
28 KiB
JavaScript
646 lines
28 KiB
JavaScript
// serviceGraphPage.js
|
||
// Page object for Service Graph feature in Traces module
|
||
// Selectors verified against: ServiceGraph.vue, ServiceGraphNodeSidePanel.vue
|
||
|
||
import { expect } from '@playwright/test';
|
||
|
||
export class ServiceGraphPage {
|
||
constructor(page) {
|
||
this.page = page;
|
||
|
||
// ===== NAVIGATION =====
|
||
// Service Graph is reached from the Traces rail tile's hover flyout; since #13852 the
|
||
// flyout item lands on the Traces page's in-page `?tab=service-graph` view (the old
|
||
// /traces/service-graph route now only exists as a legacy redirect).
|
||
this.tracesRailTile = '[data-test="nav-group-traces"]';
|
||
// #13852 rerouted the flyout children through the Traces page's ?tab= views, so the
|
||
// Service Graph flyout item is now `nav-group-item-traces-service-graph`
|
||
// (childDataTest = `nav-group-item-${child.name}-${child.tab}`), not the old
|
||
// standalone `nav-group-item-serviceGraph`.
|
||
this.serviceGraphFlyoutItem = '[data-test="nav-group-item-traces-service-graph"]';
|
||
|
||
// ===== MAIN COMPONENT (ServiceGraph.vue) =====
|
||
this.dateTimePicker = '[data-test="service-graph-date-time-picker"]';
|
||
this.refreshButton = '[data-test="service-graph-refresh-btn"]';
|
||
this.chartContainer = '[data-test="service-graph-chart"]';
|
||
|
||
// ===== VIEW TOGGLE BUTTONS (page header #subnav — button with .selected class) =====
|
||
this.graphViewTab = '[data-test="service-graph-graph-view-btn"]';
|
||
this.treeViewTab = '[data-test="service-graph-tree-view-btn"]';
|
||
|
||
// ===== SEARCH =====
|
||
this.searchInput = 'input[placeholder="Search Services"]';
|
||
|
||
// ===== NODE DETAIL PANEL (ServiceGraphNodeSidePanel.vue) =====
|
||
// ODrawer forwards data-test to <DialogContent> — panel root is [data-test="service-graph-side-panel"].
|
||
// Close button: ODrawer renders it as [data-test="o-drawer-close-btn"] inside the panel.
|
||
// Title: Reka UI DialogTitle renders as an <h2> (sr-only) — accessible via locator('h2').
|
||
this.sidePanel = '[data-test="service-graph-side-panel"]';
|
||
this.sidePanelHeader = '[data-test="service-graph-side-panel"]';
|
||
this.sidePanelViewRelatedBtn = '[data-test="service-graph-node-panel-view-related-btn"]';
|
||
this.sidePanelViewRelatedLogsBtn = '[data-test="service-graph-node-panel-view-related-logs-btn"]';
|
||
this.sidePanelViewRelatedTracesBtn = '[data-test="service-graph-node-panel-view-related-traces-btn"]';
|
||
this.sidePanelCloseBtn = '[data-test="service-graph-side-panel"] [data-test="o-drawer-close-btn"]';
|
||
// reka-ui renders this scrim only for a modal drawer, so a non-modal panel has none.
|
||
this.overlay = '[data-test="o-drawer-overlay"]';
|
||
|
||
// RED charts section (Rate/Errors/Duration dashboards)
|
||
this.sidePanelRedCharts = '[data-test="service-graph-side-panel-red-charts"]';
|
||
this.sidePanelFilterChips = '[data-test="service-graph-side-panel-filter-chips"]';
|
||
this.sidePanelViewInTracesBtn = '[data-test="service-graph-side-panel-view-in-traces-btn"]';
|
||
|
||
// Tabs section (Operations / Nodes / Pods)
|
||
this.nodePanelTabs = '[data-test="service-graph-node-panel-tabs"]';
|
||
this.operationsTab = '[data-test="service-graph-node-panel-tab-operations"]';
|
||
this.nodesTab = '[data-test="service-graph-node-panel-tab-nodes"]';
|
||
this.podsTab = '[data-test="service-graph-node-panel-tab-pods"]';
|
||
// data-test on OTabPanel is swallowed by <Transition> — target slot content instead
|
||
this.recentOperations = '[data-test="service-graph-side-panel-operations-table"]';
|
||
this.operationsTable = '[data-test="service-graph-side-panel-operations-table"]';
|
||
this.nodesPanel = '[data-test="service-graph-side-panel-nodes"]';
|
||
this.nodesTable = '[data-test="service-graph-side-panel-nodes-table"]';
|
||
this.podsPanel = '[data-test="service-graph-side-panel-pods"]';
|
||
this.podsTable = '[data-test="service-graph-side-panel-pods-table"]';
|
||
// OTable marks headers o2-table-th-<id> and body cells o2-table-cell-<id>.
|
||
this.tableHeaderPrefix = 'thead th[data-test^="o2-table-th-"]';
|
||
this.tableHeader = (columnId) => `thead th[data-test="o2-table-th-${columnId}"]`;
|
||
this.tableCell = (columnId) => `tbody td[data-test="o2-table-cell-${columnId}"]`;
|
||
this.nodePanelTabPrefix = '[data-test^="service-graph-node-panel-tab-"]';
|
||
// Resource tabs and their tables are generated per detected OTEL field, so both
|
||
// data-tests carry a dynamic id (k8s-pod-name, k8s-node-name, ...), never 'pods'/'nodes'.
|
||
this.resourceTab = (tabId) => `[data-test="service-graph-node-panel-tab-${tabId}"]`;
|
||
this.resourceTable = (tabId) => `[data-test="service-graph-side-panel-${tabId}-table"]`;
|
||
this.fixedPanelTabs = ['operations', 'behavior', 'metrics'];
|
||
|
||
// ===== TELEMETRY CORRELATION (Metrics tab) =====
|
||
this.metricsTab = '[data-test="service-graph-node-panel-tab-metrics"]';
|
||
this.metricsPanel = '[data-test="service-graph-side-panel-metrics"]';
|
||
this.metricsLoadingIndicator = '[data-test="service-graph-side-panel-metrics-loading"]';
|
||
// The metrics-tab CONTENT is what we wait on — NOT the panel wrapper. Two hooks that look
|
||
// usable are not:
|
||
// • service-graph-side-panel-metrics is on an OTabPanel whose data-test is swallowed by
|
||
// <Transition>, so it never reaches the DOM;
|
||
// • service-graph-side-panel-metrics-dashboard is passed to <TelemetryCorrelationDashboard>,
|
||
// which has multiple top-level roots (a Vue FRAGMENT) so Vue 3 drops the fallthrough attr.
|
||
// The reliable, currently-shipping "correlation view rendered" signal is the correlation event
|
||
// header (renders whenever the metrics tab resolves to a correlation object, with or without
|
||
// metric streams — this env seeds only traces, so zero-stream is the common case).
|
||
this.metricsCorrelationHeader = '[data-test^="correlation-event-header-"]';
|
||
// "Rendered WITH metric data" = real per-metric-stream rows (data-dependent; used only for the
|
||
// happy-path assertion, never required for the tab to count as resolved).
|
||
this.metricsStreamItem = '[data-test="telemetry-correlation-metric-stream-item"]';
|
||
this.metricsError = '[data-test="service-graph-side-panel-metrics-error"]';
|
||
this.metricsEmpty = '[data-test="service-graph-side-panel-metrics-empty"]';
|
||
|
||
// ===== TELEMETRY CORRELATION DIALOG =====
|
||
this.correlationDashboardClose = '[data-test="correlation-dashboard-close"]';
|
||
this.correlationDashboardCard = '[data-test*="correlation-dashboard"]';
|
||
this.correlationDialogTabs = '[data-test*="dialog"] [role="tab"]';
|
||
}
|
||
|
||
// ===== NAVIGATION =====
|
||
|
||
async navigateToServiceGraph() {
|
||
await this.page.locator(this.tracesRailTile).hover();
|
||
// The flyout is teleported to <body> and opens after ONavGroup's 120ms OPEN_DELAY, so the
|
||
// item is not in the DOM the instant hover() resolves — wait for it before clicking, else
|
||
// the click races the debounce and times out.
|
||
const flyoutItem = this.page.locator(this.serviceGraphFlyoutItem);
|
||
await flyoutItem.waitFor({ state: 'visible', timeout: 10000 });
|
||
await flyoutItem.click();
|
||
// #13852 lands Service Graph on the Traces page as `?tab=service-graph`; the old
|
||
// /traces/service-graph path now only exists as a legacy redirect.
|
||
await this.page.waitForURL(/\/traces\?.*tab=service-graph/, { timeout: 10000 });
|
||
}
|
||
|
||
/**
|
||
* Navigate directly to service graph via URL (more reliable than click-based navigation).
|
||
* Sets the stream filter in localStorage before navigating to ensure the correct stream is shown.
|
||
* @param {string} streamName - Stream to filter by (default: 'default')
|
||
* @param {string} [period='6h'] - Relative time window passed via the `period` URL param.
|
||
* Defaults to a WIDE 6h window (not the page default "Past 15 Minutes") so the
|
||
* daemon-processed topology reliably falls inside the query window and the chart renders.
|
||
*/
|
||
async navigateToServiceGraphUrl(streamName = 'default', period = '6h') {
|
||
// Set stream filter in localStorage before navigation — the Vue component reads from here
|
||
await this.page.evaluate((stream) => {
|
||
localStorage.setItem('serviceGraph_streamFilter', stream);
|
||
}, streamName);
|
||
|
||
const org = process.env['ORGNAME'] || 'default';
|
||
const baseUrl = (process.env['ZO_BASE_URL'] || '').replace(/\/+$/, '');
|
||
// Query a WIDE window (default 6h), not the page default of "Past 15 Minutes". The service
|
||
// graph is derived by a backend daemon that runs on a delay after trace ingestion, so with
|
||
// the 15m default the just-ingested topology often falls outside the window and the chart
|
||
// renders "No service graph data" (no chart element) — the alpha1 failure mode. The route
|
||
// honours the `period` URL param (verified: label shows "Past 6 Hours").
|
||
const url = `${baseUrl}/web/traces/service-graph?org_identifier=${org}&period=${period}`;
|
||
await this.page.goto(url);
|
||
await this.page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {});
|
||
}
|
||
|
||
/**
|
||
* Navigate to the service graph and wait until the chart actually renders, tolerating
|
||
* service-graph-daemon lag: if the page shows the empty "No service graph data" state,
|
||
* refresh and retry within the budget. Returns once the chart is visible; if it never
|
||
* renders the caller's own assertion still fails (non-masking).
|
||
* @param {string} streamName
|
||
* @param {object} [opts] { period, timeout }
|
||
*/
|
||
async navigateToServiceGraphAndWaitForChart(streamName = 'default', opts = {}) {
|
||
const period = opts.period || '6h';
|
||
const timeout = opts.timeout || 90000;
|
||
await this.navigateToServiceGraphUrl(streamName, period);
|
||
|
||
const chart = this.page.locator(this.chartContainer);
|
||
const deadline = Date.now() + timeout;
|
||
// Date.now() is allowed here (test/page-object runtime, not a workflow script).
|
||
while (Date.now() < deadline) {
|
||
if (await chart.isVisible().catch(() => false)) return true;
|
||
// Empty-state → nudge the daemon result by refreshing, then wait a cycle.
|
||
await this.page.locator(this.refreshButton).click({ timeout: 5000 }).catch(() => {});
|
||
await this.page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
|
||
await chart.waitFor({ state: 'visible', timeout: 10000 }).catch(() => {});
|
||
}
|
||
return await chart.isVisible().catch(() => false);
|
||
}
|
||
|
||
async expectServiceGraphPageVisible() {
|
||
await expect(this.page.locator(this.chartContainer)).toBeVisible({ timeout: 15000 });
|
||
}
|
||
|
||
async isServiceGraphVisible() {
|
||
return await this.page.locator(this.chartContainer)
|
||
.waitFor({ state: 'visible', timeout: 10000 })
|
||
.then(() => true)
|
||
.catch(() => false);
|
||
}
|
||
|
||
// ===== CONTROLS =====
|
||
|
||
async clickRefresh() {
|
||
await this.page.locator(this.refreshButton).click();
|
||
}
|
||
|
||
async waitForGraphReload() {
|
||
await this.page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
|
||
}
|
||
|
||
async switchToGraphView() {
|
||
await this.page.locator(this.graphViewTab).click();
|
||
// OToggleGroupItem uses inheritAttrs:false + v-bind="$attrs" on the inner Reka UI <button>,
|
||
// so data-test and data-state land on the SAME element. Use compound selector (no space).
|
||
await expect(this.page.locator(`${this.graphViewTab}[data-state="on"]`)).toBeVisible({ timeout: 5000 });
|
||
}
|
||
|
||
async switchToTreeView() {
|
||
await this.page.locator(this.treeViewTab).click();
|
||
// Same OToggleGroupItem pattern — compound selector, no space between data-test and data-state.
|
||
await expect(this.page.locator(`${this.treeViewTab}[data-state="on"]`)).toBeVisible({ timeout: 5000 });
|
||
}
|
||
|
||
async getActiveViewTab() {
|
||
// OToggleGroupItem: data-test and data-state are on the same element — use compound selector.
|
||
const treeSelected = await this.page.locator(`${this.treeViewTab}[data-state="on"]`)
|
||
.isVisible({ timeout: 1000 }).catch(() => false);
|
||
if (treeSelected) return 'Tree View';
|
||
|
||
const graphSelected = await this.page.locator(`${this.graphViewTab}[data-state="on"]`)
|
||
.isVisible({ timeout: 1000 }).catch(() => false);
|
||
if (graphSelected) return 'Graph View';
|
||
|
||
return 'Unknown';
|
||
}
|
||
|
||
async typeInSearchBox(text) {
|
||
await this.page.locator(this.searchInput).fill(text);
|
||
await this.page.waitForTimeout(500); // debounce 300ms + buffer
|
||
}
|
||
|
||
async clearSearchBox() {
|
||
await this.page.locator(this.searchInput).fill('');
|
||
await this.page.waitForTimeout(500);
|
||
}
|
||
|
||
// ===== TOPOLOGY API =====
|
||
|
||
async getTopologyViaAPI() {
|
||
const orgId = process.env['ORGNAME'] || 'default';
|
||
const baseUrl = process.env['INGESTION_URL'] || process.env['ZO_BASE_URL'];
|
||
const response = await this.page.request.get(
|
||
`${baseUrl}/api/${orgId}/traces/service_graph/topology/current`,
|
||
{
|
||
headers: {
|
||
Authorization: 'Basic ' + Buffer.from(
|
||
`${process.env['ZO_ROOT_USER_EMAIL']}:${process.env['ZO_ROOT_USER_PASSWORD']}`
|
||
).toString('base64'),
|
||
'Content-Type': 'application/json',
|
||
},
|
||
}
|
||
);
|
||
if (!response.ok()) {
|
||
const text = await response.text();
|
||
return { status: response.status(), data: null, error: text };
|
||
}
|
||
const data = await response.json();
|
||
return { status: response.status(), data };
|
||
}
|
||
|
||
async getNodeCount() {
|
||
const result = await this.getTopologyViaAPI();
|
||
return result.data?.nodes?.length || result.data?.data?.nodes?.length || 0;
|
||
}
|
||
|
||
async getEdgeCount() {
|
||
const result = await this.getTopologyViaAPI();
|
||
return result.data?.edges?.length || result.data?.data?.edges?.length || 0;
|
||
}
|
||
|
||
async findNodeByLabel(label) {
|
||
const result = await this.getTopologyViaAPI();
|
||
const nodes = result.data?.nodes || result.data?.data?.nodes || [];
|
||
return nodes.find(n => n.label === label || n.id === label);
|
||
}
|
||
|
||
async findEdge(fromService, toService) {
|
||
const result = await this.getTopologyViaAPI();
|
||
const edges = result.data?.edges || result.data?.data?.edges || [];
|
||
return edges.find(e => e.from === fromService && e.to === toService);
|
||
}
|
||
|
||
// ===== NODE/EDGE INTERACTION =====
|
||
|
||
/**
|
||
* Trigger ServiceGraph's handleNodeClick programmatically.
|
||
*
|
||
* WHY: ECharts renders the graph on an HTML <canvas>. Canvas content is NOT
|
||
* part of the DOM or accessibility tree, so standard Playwright selectors
|
||
* (click, locator, etc.) cannot target individual nodes or edges. The only
|
||
* reliable way to open a node/edge detail panel in tests is to invoke the
|
||
* Vue component's click handler directly.
|
||
*
|
||
* HOW: On the first call the helper walks the Vue 3 vnode tree
|
||
* (app._vnode → component.subTree) to locate the ServiceGraph instance by
|
||
* checking for `setupState.handleNodeClick`. The found reference is cached
|
||
* on `window.__sgTestHelper` so subsequent calls skip the walk entirely.
|
||
*
|
||
* STABILITY: The vnode/component/subTree shape has been stable across all
|
||
* Vue 3.x releases (3.0 – 3.5+). The cache is validated before reuse and
|
||
* rebuilt automatically if the component re-mounts.
|
||
*
|
||
* @param {object} clickParams - Params forwarded to handleNodeClick
|
||
* @returns {Promise<{ success: true } | { error: string }>}
|
||
*/
|
||
async _callHandleNodeClick(clickParams) {
|
||
return await this.page.evaluate((params) => {
|
||
// Fast path: reuse cached component instance if still valid
|
||
const cached = window.__sgTestHelper;
|
||
if (cached?.setupState?.handleNodeClick && cached.setupState.graphData) {
|
||
cached.setupState.handleNodeClick(params);
|
||
return { success: true };
|
||
}
|
||
|
||
// Slow path: walk the vnode tree to find the ServiceGraph component
|
||
function findServiceGraph(vnode, depth) {
|
||
if (!vnode || depth > 60) return null;
|
||
if (vnode.component) {
|
||
const inst = vnode.component;
|
||
const ss = inst.setupState;
|
||
if (ss && typeof ss.handleNodeClick === 'function' && ss.graphData) return inst;
|
||
if (inst.subTree) { const r = findServiceGraph(inst.subTree, depth + 1); if (r) return r; }
|
||
}
|
||
if (Array.isArray(vnode.children)) {
|
||
for (const child of vnode.children) {
|
||
if (child && typeof child === 'object') { const r = findServiceGraph(child, depth + 1); if (r) return r; }
|
||
}
|
||
}
|
||
if (vnode.dynamicChildren) {
|
||
for (const child of vnode.dynamicChildren) { const r = findServiceGraph(child, depth + 1); if (r) return r; }
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const appEl = document.querySelector('#app');
|
||
if (!appEl?._vnode) return { error: 'no-vue-app-vnode' };
|
||
const inst = findServiceGraph(appEl._vnode, 0);
|
||
if (!inst) return { error: 'service-graph-component-not-found' };
|
||
|
||
window.__sgTestHelper = inst; // cache for subsequent calls
|
||
inst.setupState.handleNodeClick(params);
|
||
return { success: true };
|
||
}, clickParams);
|
||
}
|
||
|
||
/**
|
||
* Click a node in the service graph by name to open the side panel.
|
||
*/
|
||
async clickNodeByName(nodeName) {
|
||
const result = await this._callHandleNodeClick({
|
||
componentType: 'series',
|
||
data: { name: nodeName },
|
||
});
|
||
|
||
if (result?.error) {
|
||
throw new Error(`clickNodeByName("${nodeName}") failed: ${result.error}`);
|
||
}
|
||
|
||
await this.page.locator(this.sidePanel)
|
||
.waitFor({ state: 'visible', timeout: 5000 });
|
||
}
|
||
|
||
// ===== SIDE PANEL (Node Detail) =====
|
||
|
||
async expectSidePanelVisible() {
|
||
await expect(this.page.locator(this.sidePanel)).toBeVisible({ timeout: 10000 });
|
||
}
|
||
|
||
async isSidePanelVisible() {
|
||
return await this.page.locator(this.sidePanel)
|
||
.waitFor({ state: 'visible', timeout: 5000 })
|
||
.then(() => true)
|
||
.catch(() => false);
|
||
}
|
||
|
||
async expectSidePanelNotVisible() {
|
||
await expect(this.page.locator(this.sidePanel)).not.toBeVisible({ timeout: 5000 });
|
||
}
|
||
|
||
/** Modal mode renders a scrim and sets `pointer-events: none` on <body>; a non-modal drawer does neither. */
|
||
async expectSidePanelNonModal() {
|
||
await expect(this.page.locator(this.overlay)).toHaveCount(0);
|
||
await expect(this.page.locator('body')).not.toHaveCSS('pointer-events', 'none');
|
||
}
|
||
|
||
async expectRedChartsSectionVisible() {
|
||
await expect(this.page.locator(this.sidePanelRedCharts)).toBeVisible({ timeout: 10000 });
|
||
}
|
||
|
||
async isRedChartsSectionVisible() {
|
||
return await this.page.locator(this.sidePanelRedCharts)
|
||
.waitFor({ state: 'visible', timeout: 5000 })
|
||
.then(() => true)
|
||
.catch(() => false);
|
||
}
|
||
|
||
async getSidePanelServiceName() {
|
||
// ODrawer renders the :title prop via Reka UI's <DialogTitle> (an <h2>) —
|
||
// also visible as a styled <span> in the header. The <h2> is sr-only but
|
||
// still has textContent. Scope to panel to avoid picking up other headings.
|
||
return await this.page.locator(this.sidePanel).locator('h2').first().textContent();
|
||
}
|
||
|
||
async expectSidePanelServiceName(name) {
|
||
await expect
|
||
.poll(async () => ((await this.getSidePanelServiceName()) || '').trim(), { timeout: 10000 })
|
||
.toBe(name);
|
||
}
|
||
|
||
/** Tags the open panel node, so a close-and-reopen (which mounts a fresh node) can be told apart from an in-place update. */
|
||
async markSidePanelNode() {
|
||
await this.page.locator(this.sidePanel).evaluate((el) => {
|
||
el.dataset.e2eOriginalNode = 'true';
|
||
});
|
||
}
|
||
|
||
async expectSidePanelNodeUnchanged() {
|
||
await expect(this.page.locator(`${this.sidePanel}[data-e2e-original-node="true"]`)).toBeVisible();
|
||
}
|
||
|
||
async getHealthStatus() {
|
||
// The health badge is an OTag with data-test="service-health-badge" whose visible
|
||
// text is the status label (Healthy / Degraded / Critical). The old `.health-badge`
|
||
// class + status modifier classes were dropped when it moved to the OTag component.
|
||
const badge = this.page.locator(`${this.sidePanel} [data-test="service-health-badge"]`);
|
||
if (await badge.count() === 0) return 'unknown';
|
||
const label = ((await badge.first().textContent()) || '').trim().toLowerCase();
|
||
if (label.includes('critical')) return 'critical';
|
||
if (label.includes('degraded')) return 'degraded';
|
||
if (label.includes('healthy')) return 'healthy';
|
||
return 'unknown';
|
||
}
|
||
|
||
// ===== TABS (Operations / Nodes / Pods) =====
|
||
|
||
async switchToOperationsTab() {
|
||
await this.page.locator(this.operationsTab).click();
|
||
}
|
||
|
||
async switchToNodesTab() {
|
||
await this.page.locator(this.nodesTab).click();
|
||
}
|
||
|
||
async switchToPodsTab() {
|
||
await this.page.locator(this.podsTab).click();
|
||
}
|
||
|
||
async expectOperationsSectionVisible() {
|
||
await expect(this.page.locator(this.recentOperations)).toBeVisible({ timeout: 10000 });
|
||
}
|
||
|
||
async isOperationsSectionVisible() {
|
||
return await this.page.locator(this.recentOperations)
|
||
.waitFor({ state: 'visible', timeout: 5000 })
|
||
.then(() => true)
|
||
.catch(() => false);
|
||
}
|
||
|
||
async getOperationsTableRowCount() {
|
||
const table = this.page.locator(this.operationsTable);
|
||
await table.locator('[data-test="service-graph-operations-loading-indicator"]').waitFor({ state: 'hidden', timeout: 15000 }).catch(() => {});
|
||
return await table.locator('tbody tr').count();
|
||
}
|
||
|
||
async closeSidePanel() {
|
||
await this.page.locator(this.sidePanelCloseBtn).click();
|
||
await this.page.locator(this.sidePanel)
|
||
.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
|
||
}
|
||
|
||
// ===== TELEMETRY CORRELATION =====
|
||
|
||
/**
|
||
* Click the Metrics tab in the side panel and wait for the correlation view to RESOLVE.
|
||
* Deploy-independent: gates only on real, currently-shipping elements (the metrics panel and its
|
||
* loading spinner) — NOT on the dashboard's data-test, which the fragment-root component drops
|
||
* (see this.metricsStreamItem). Throws if the panel never renders (a genuinely broken tab).
|
||
* @returns {Promise<boolean>} true if real metric-stream rows rendered (data present); false for
|
||
* a resolved-but-streamless view (zero-stream dashboard / empty / error) — all acceptable.
|
||
*/
|
||
async clickMetricsTabAndWait() {
|
||
await this.page.locator(this.metricsTab).click();
|
||
|
||
// Wait until the metrics tab RESOLVES into a rendered content state — the correlation view
|
||
// (header) OR a terminal empty/error state. We race real content elements (never the swallowed
|
||
// panel wrapper); if none appears within the window the race rejects and the caller fails,
|
||
// which is the correct outcome for a genuinely broken/hung tab.
|
||
await Promise.race([
|
||
this.page.locator(this.metricsCorrelationHeader).first().waitFor({ state: 'visible', timeout: 30000 }),
|
||
this.page.locator(this.metricsError).first().waitFor({ state: 'visible', timeout: 30000 }),
|
||
this.page.locator(this.metricsEmpty).first().waitFor({ state: 'visible', timeout: 30000 }),
|
||
]);
|
||
|
||
// Report whether real metric-stream rows rendered (.first() — the selector matches every row,
|
||
// so an unscoped locator would trip Playwright strict mode).
|
||
return await this.page.locator(this.metricsStreamItem).first()
|
||
.isVisible({ timeout: 2000 }).catch(() => false);
|
||
}
|
||
|
||
async expectMetricsStreamsVisible() {
|
||
await expect(this.page.locator(this.metricsStreamItem).first()).toBeVisible({ timeout: 8000 });
|
||
}
|
||
|
||
async isMetricsErrorVisible() {
|
||
return await this.page.locator(this.metricsError)
|
||
.isVisible({ timeout: 3000 }).catch(() => false);
|
||
}
|
||
|
||
async isMetricsEmptyVisible() {
|
||
return await this.page.locator(this.metricsEmpty)
|
||
.isVisible({ timeout: 3000 }).catch(() => false);
|
||
}
|
||
|
||
/** Terminal state of the Metrics tab: 'correlation', 'error', 'empty', or 'unknown' when none rendered. */
|
||
async getMetricsTerminalState() {
|
||
if (await this.page.locator(this.metricsCorrelationHeader).first().isVisible().catch(() => false)) {
|
||
return 'correlation';
|
||
}
|
||
if (await this.page.locator(this.metricsError).first().isVisible().catch(() => false)) {
|
||
return 'error';
|
||
}
|
||
if (await this.page.locator(this.metricsEmpty).first().isVisible().catch(() => false)) {
|
||
return 'empty';
|
||
}
|
||
return 'unknown';
|
||
}
|
||
|
||
async getSearchInputValue() {
|
||
return await this.page.locator(this.searchInput).inputValue();
|
||
}
|
||
|
||
/**
|
||
* Click "View Related" dropdown and select an option (Logs or Traces).
|
||
* @param {'logs'|'traces'} type - Which related telemetry to view
|
||
*/
|
||
async clickViewRelated(type = 'traces') {
|
||
await this.page.locator(this.sidePanelViewRelatedBtn).click();
|
||
await this.page.waitForTimeout(300);
|
||
const selector = type === 'logs' ? this.sidePanelViewRelatedLogsBtn : this.sidePanelViewRelatedTracesBtn;
|
||
await this.page.locator(selector).click();
|
||
}
|
||
|
||
/** The traces page keeps its filter base64-encoded in ?query=, with '.' standing in for '=' padding. */
|
||
async getTracesUrlQuery() {
|
||
const encoded = new URL(this.page.url()).searchParams.get('query') || '';
|
||
return Buffer.from(encoded.replace(/\./g, '='), 'base64').toString('utf8');
|
||
}
|
||
|
||
async expectCorrelationDialogVisible() {
|
||
await expect(this.page.locator(this.correlationDashboardCard)).toBeVisible({ timeout: 15000 });
|
||
}
|
||
|
||
async isCorrelationDialogVisible() {
|
||
return await this.page.locator(this.correlationDashboardCard)
|
||
.waitFor({ state: 'visible', timeout: 5000 })
|
||
.then(() => true)
|
||
.catch(() => false);
|
||
}
|
||
|
||
async closeCorrelationDialog() {
|
||
await this.page.locator(this.correlationDashboardClose).click();
|
||
await this.page.locator(this.correlationDashboardCard)
|
||
.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
|
||
}
|
||
|
||
async getCorrelationTabs() {
|
||
const tabs = this.page.locator(this.correlationDialogTabs);
|
||
const count = await tabs.count();
|
||
const tabNames = [];
|
||
for (let i = 0; i < count; i++) {
|
||
tabNames.push((await tabs.nth(i).textContent()).trim());
|
||
}
|
||
return tabNames;
|
||
}
|
||
|
||
// ===== SIDE PANEL TABLES (Operations / resource tabs) =====
|
||
|
||
async getOperationsColumnIds() {
|
||
return await this.getResourceTableColumnIds(this.operationsTable);
|
||
}
|
||
|
||
async sortOperationsByColumn(columnId) {
|
||
const previousOrder = (await this.getOperationsColumnText('operation')).join('|');
|
||
await this.page.locator(`${this.operationsTable} ${this.tableHeader(columnId)}`).click();
|
||
// The re-sort is a client-side computed, so wait for the rendered order to
|
||
// change rather than guessing a settle time. Data whose keys are all equal
|
||
// legitimately keeps its order, and the caller's assertions still hold.
|
||
await this.page
|
||
.waitForFunction(
|
||
([selector, previous]) => {
|
||
const rows = [...document.querySelectorAll(selector)].map((c) => c.innerText.trim());
|
||
return rows.length > 0 && rows.join('|') !== previous;
|
||
},
|
||
[`${this.operationsTable} ${this.tableCell('operation')}`, previousOrder],
|
||
{ timeout: 10000 },
|
||
)
|
||
.catch(() => {});
|
||
}
|
||
|
||
/** Text of one operations column, top to bottom, for comparing row order. */
|
||
async getOperationsColumnText(columnId) {
|
||
return await this.page
|
||
.locator(`${this.operationsTable} ${this.tableCell(columnId)}`)
|
||
.evaluateAll((cells) => cells.map((c) => c.innerText.trim()));
|
||
}
|
||
|
||
/**
|
||
* Latency cells render a ServiceCatalogBarCell whose progress bar carries
|
||
* aria-valuenow = round(value / columnMax * 100), monotonic in the raw duration.
|
||
* Reading it avoids parsing formatted latencies across us/ms/s units.
|
||
*/
|
||
async getOperationsDurationRatios(columnId) {
|
||
return await this.page
|
||
.locator(`${this.operationsTable} ${this.tableCell(columnId)} [role="progressbar"]`)
|
||
.evaluateAll((bars) => bars.map((b) => Number(b.getAttribute('aria-valuenow'))));
|
||
}
|
||
|
||
/** Resource tabs are generated per OTEL workload, so the set varies by data. */
|
||
async getSidePanelTabIds() {
|
||
return await this.page
|
||
.locator(this.nodePanelTabPrefix)
|
||
.evaluateAll((nodes) =>
|
||
nodes.map((n) => (n.getAttribute('data-test') || '').replace('service-graph-node-panel-tab-', '')),
|
||
);
|
||
}
|
||
|
||
/** Tab ids for the generated resource tabs, with the fixed tabs removed. */
|
||
async getResourceTabIds() {
|
||
const tabIds = await this.getSidePanelTabIds();
|
||
return tabIds.filter((id) => id && !this.fixedPanelTabs.includes(id));
|
||
}
|
||
|
||
async switchToResourceTab(tabId) {
|
||
await this.page.locator(this.resourceTab(tabId)).click();
|
||
}
|
||
|
||
async expectResourceTableVisible(tabId) {
|
||
await expect(this.page.locator(this.resourceTable(tabId))).toBeVisible({ timeout: 15000 });
|
||
}
|
||
|
||
async getResourceTableColumnIds(tableSelector) {
|
||
const ids = await this.page
|
||
.locator(`${tableSelector} ${this.tableHeaderPrefix}`)
|
||
.evaluateAll((nodes) =>
|
||
nodes.map((n) => (n.getAttribute('data-test') || '').replace('o2-table-th-', '')),
|
||
);
|
||
return ids.filter((id) => id && id !== '__spacer__');
|
||
}
|
||
|
||
// ===== SCREENSHOTS (Visual Verification) =====
|
||
|
||
async takeGraphScreenshot(name = 'service-graph') {
|
||
await this.page.locator(this.chartContainer).screenshot({
|
||
path: `test-results/screenshots/${name}-${Date.now()}.png`,
|
||
});
|
||
}
|
||
}
|