mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 08:09:22 +00:00
## Summary Two independent improvements to the Playwright E2E test suite. --- ### Part 1 — Sentinel audit fixes (original PR scope) Fixes from the Sentinel audit review of PR #12358 (selector drift stabilization). - **Dead code removal**: delete `clickJobID()` from `logsPageEP.js` (unreachable code containing `console.log`); remove unused `chromium` import - **Critical fix**: replace `console.log`/`console.error` in `decryptLogSQL()` with `testLogger` equivalents - **POM compliance**: replace raw `page.locator()` call in `alerts-ui-operations.spec.js` `beforeEach` with `pm.alertsPage.isIncidentsFeatureEnabled()`; add that method to `alertsPage.js` - **POM compliance**: move per-test `const pm = new PageManager(page)` into `beforeEach` in `service-graph.spec.js`; replace raw `page.waitForLoadState()` with `pm.serviceGraphPage.waitForGraphReload()`; add `waitForGraphReload()` to `serviceGraphPage.js` - **Inline selector cleanup**: move metrics tab inline string literals in `serviceGraphPage.js` into constructor properties - **Tagging**: add `@cipherKeys/@enterprise/@P1/@P2/@negative/@validation` to all 6 tests in `cipherKeys.spec.js` (were untagged); add `@P1/@P2` to all 5 tests in `regexPatternManagement.spec.js` - **ESM fix**: correct `export const path = require('path')` → `import path from 'path'` in `logoManagementPage.js` - **Deduplication**: consolidate `createFirstFunction`/`createSecondFunction` into shared `createFunction(name, vrlCode)` in `pipelinesEP.js` --- ### Part 2 — Parallelize Metrics E2E shard **Problem**: The `e2e / Metrics` CI shard was consistently the slowest job at ~22 minutes per PR run, while all other shards complete in ~5-7 minutes. **Root cause**: 9 of 11 Metrics spec files used `test.describe.configure({ mode: 'serial' })` because they shared a describe-scope `let pm` variable reassigned in `beforeEach`. This forced every test in those files onto a single Playwright worker, eliminating all parallelism despite `fullyParallel: true` and 5 workers being configured. **Fix applied to all 9 affected files** (`metrics.spec.js`, `metrics-config.spec.js`, `metrics-config-tabs.spec.js`, `metrics-visualizations.spec.js`, `metrics-promql-query-persistence.spec.js`, `metrics-table-column-order.spec.js`, `metrics-queries.spec.js`, `metrics-aggregations.spec.js`, `metrics-advanced.spec.js`): - Remove `test.describe.configure({ mode: 'serial' })` - Replace shared `let pm` + `beforeEach` pattern with a `setupTest(page, testInfo)` helper that creates and returns a fresh `PageManager` per test — the same pattern already used in `metrics-promql-builder.spec.js` (the one file that was already parallel) - `metrics-table-column-order.spec.js`: pass `pm` explicitly into `setupTableChart(pm, page, mode)` since it can no longer close over a describe-scope variable - Split consolidated mega-tests (for-loops over multiple independent scenarios) in `metrics-queries`, `metrics-aggregations`, and `metrics-advanced` into individual `test()` blocks so Playwright can distribute them across workers **No test assertions, selectors, query logic, or validation changed.** | File | Tests before | Tests after | |---|---|---| | `metrics.spec.js` | 10 (serial) | 10 (parallel) | | `metrics-config.spec.js` | 2 (serial) | 2 (parallel) | | `metrics-config-tabs.spec.js` | 3 (serial) | 3 (parallel) | | `metrics-visualizations.spec.js` | 4 (serial) | 4 (parallel) | | `metrics-promql-query-persistence.spec.js` | 6 (serial) | 6 (parallel) | | `metrics-table-column-order.spec.js` | 20 (serial) | 20 (parallel) | | `metrics-queries.spec.js` | 4 mega-tests (serial) | 12 individual (parallel) | | `metrics-aggregations.spec.js` | 3 mega-tests (serial) | 10 individual (parallel) | | `metrics-advanced.spec.js` | 4 mega-tests (serial) | 12 individual (parallel) | **Expected impact**: ~1300s (~22 min) → ~300-400s (~5-7 min) wall-clock time. --- ## Test plan - [ ] All 40 tests in the 4 Sentinel-audit spec files enumerate cleanly via `npx playwright test --list` - [ ] Tag filters `@cipherKeys`, `@P2`, `@sdr` match expected tests - [ ] All Metrics spec files enumerate cleanly with no serial mode warnings - [ ] Metrics shard CI time drops to ~5-7 min on next PR run 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
446 lines
18 KiB
JavaScript
446 lines
18 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 =====
|
||
this.serviceGraphToggle = '[data-test="traces-service-graph-toggle"]';
|
||
|
||
// ===== 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 (SearchBar.vue — q-btn 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"]';
|
||
|
||
// 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"]';
|
||
|
||
// ===== 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"]';
|
||
this.metricsDashboard = '[data-test="service-graph-side-panel-metrics-dashboard"]';
|
||
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.serviceGraphToggle).click();
|
||
await this.page.waitForURL(/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')
|
||
*/
|
||
async navigateToServiceGraphUrl(streamName = 'default') {
|
||
// 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(/\/+$/, '');
|
||
const url = `${baseUrl}/web/traces?tab=service-graph&org_identifier=${org}`;
|
||
await this.page.goto(url);
|
||
await this.page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {});
|
||
}
|
||
|
||
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 });
|
||
}
|
||
|
||
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 getHealthStatus() {
|
||
// .health-badge is a plain CSS class on a <span> in the ODrawer #header-right slot
|
||
const badge = this.page.locator(`${this.sidePanel} .health-badge`);
|
||
const badgeExists = await badge.count() > 0;
|
||
if (!badgeExists) return 'unknown';
|
||
const classes = await badge.getAttribute('class') || '';
|
||
if (classes.includes('critical')) return 'critical';
|
||
if (classes.includes('degraded')) return 'degraded';
|
||
if (classes.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 correlation data to load.
|
||
* Returns true if metrics dashboard rendered, false if error/empty state shown.
|
||
*/
|
||
async clickMetricsTabAndWait() {
|
||
await this.page.locator(this.metricsTab).click();
|
||
|
||
// Wait for loading spinner to appear and disappear
|
||
const panel = this.page.locator(this.metricsPanel);
|
||
await panel.locator(this.metricsLoadingIndicator).waitFor({ state: 'visible', timeout: 5000 }).catch(() => {});
|
||
await panel.locator(this.metricsLoadingIndicator).waitFor({ state: 'hidden', timeout: 30000 }).catch(() => {});
|
||
|
||
// Check if the metrics dashboard rendered
|
||
return await this.page.locator(this.metricsDashboard)
|
||
.waitFor({ state: 'visible', timeout: 5000 })
|
||
.then(() => true)
|
||
.catch(() => false);
|
||
}
|
||
|
||
async expectMetricsDashboardVisible() {
|
||
await expect(this.page.locator(this.metricsDashboard)).toBeVisible({ timeout: 5000 });
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// ===== SCREENSHOTS (Visual Verification) =====
|
||
|
||
async takeGraphScreenshot(name = 'service-graph') {
|
||
await this.page.locator(this.chartContainer).screenshot({
|
||
path: `test-results/screenshots/${name}-${Date.now()}.png`,
|
||
});
|
||
}
|
||
}
|