Files
openobserve/tests/ui-testing/pages/generalPages/correlationSettingsPage.js
T
Shrinath Rao a28dcda276 test(correlation): standardize e2e suite onto framework harness (phase 0 — reference pattern) (#13775)
## What & why

Begins consolidating the **correlation (service-streams) e2e suite**
onto the standard `tests/ui-testing` framework. Today those specs live
in a self-contained parallel harness (own configs, own auth, own `O2_*`
env vars, per-test orgs with no teardown, non-standard names) and are
**not registered in any CI matrix** — so they never run automatically.

This is **Phase 0**: land the shared standard-shaped API helper and
convert the first spec as the **reference pattern** for review, before
batch-converting the rest.

## Changes
- **`utils/correlation-api-helpers.js`** (new) — single source of truth
for correlation API plumbing. Auth via `authedRequest`/`getAuthHeaders`
(framework session + 401/403 self-heal), per-test **fresh-org create +
teardown**. No `test`/`expect`.
- **`Correlation/correlation-discovery.spec.js`** (new) — converted from
`journeyA`: `enhanced-baseFixtures`, `@correlation`/`@P0` tags,
Copyright + JSDoc header, `testLogger.testStart`, standard `ZO_*` env
and naming. Assertions ported **1:1** (DISC-01/02/03).
- Remove `Correlation/journeyA-discovery.api.spec.js` (superseded).

## Deliberate deviation (documented)
Correlation discovery / identity config / `_reset` are **org-global and
destructive**, so each test provisions a **fresh org** and tears it down
in `afterEach`. This keeps the isolation the feature genuinely needs
while fixing the real defect in the old suite: **orgs were created but
never deleted** (leak).

## Validation
- `node --check` clean; requires resolve.
- Collects cleanly under `playwright.config.js` (`--list` → 3 tests,
tagged).
- ⚠️ **Green run pending an enterprise env** (service_streams 403s on
OSS). Not wired into CI yet, so `main` stays green. **Draft** until
validated.

## Follow-ups (later phases)
Batch-convert remaining 4 API + 3 UI specs · revive
`correlationSettings.spec.js` against the new UI · register a dedicated
**Correlation** shard in the ENT `ci_matrix.ent.json` · delete
`corr-*.config.js`.
2026-08-12 07:43:41 +00:00

1086 lines
45 KiB
JavaScript

import { expect } from '@playwright/test';
const testLogger = require('../../playwright-tests/utils/test-logger.js');
/**
* Page Object for Correlation Settings page
* Route: /web/settings/correlation?org_identifier={orgId}
*
* Tabs:
* - Service Identity (identity)
* - Discovered Services (services)
* - Alert Correlation (alert-correlation)
*/
export class CorrelationSettingsPage {
constructor(page) {
this.page = page;
// ==================== Main Page Selectors ====================
this.pageTitle = '.general-page-title';
this.pageSubtitle = '.general-page-subtitle';
// Tab selectors (OTabs - render as [role="tablist"] / [role="tab"])
// Note: tabs use name attribute: services, discovery, alert-correlation, field-aliases
this.tabsContainer = '[role="tablist"]';
// The Correlation Settings tabs wrapper (data-test container).
this.correlationSettingsTabs = '[data-test="correlation-settings-tabs"]';
// Save button on the Detection Rules ("Distinguish Services By") view.
this.saveConfigurationButtonName = 'Save Configuration';
// Placeholder text of the "+ Add field" OSelect trigger.
this.selectAFieldText = 'Select a field';
// Visible tab labels (post-revamp): the tab `name` attrs are
// services / discovery / alert-correlation / field-aliases, rendered as
// these i18n labels.
this.servicesTabName = 'Discovered Services';
this.serviceDiscoveryTabName = 'Detection Rules';
this.alertCorrelationTabName = 'Alert Grouping';
this.fieldAliasesTabName = 'Field Mappings';
// Detection Rules view (the "Distinguish Services By" configuration tab)
this.detectionRulesTabName = 'Detection Rules';
// "+ Add field" button that reveals the "Select a field" OSelect
this.addFieldButtonName = 'Add field';
// ==================== Service Identity Tab Selectors ====================
this.serviceIdentityBackwardBtn = '[data-test="correlation-service-identity-backward-btn"]';
this.serviceIdentityForwardBtn = '[data-test="correlation-service-identity-forward-btn"]';
this.serviceIdentitySearchInput = '[data-test="correlation-service-identity-available-search-input"]';
this.serviceIdentityResetBtn = '[data-test="correlation-service-identity-reset-btn"]';
this.serviceIdentitySaveBtn = '[data-test="correlation-service-identity-save-btn"]';
// ==================== Discovered Services Tab Selectors ====================
this.retryDiscoveredServicesBtn = '[data-test="retry-discovered-services-btn"]';
this.refreshDiscoveredServicesBtn = '[data-test="refresh-discovered-services-btn"]';
// ==================== Alert Correlation Tab Selectors ====================
this.dedupSettingsRefreshBtn = '[data-test="dedup-settings-refresh-btn"]';
this.organizationDedupEnableCheckbox = '[data-test="organization-deduplication-enable-checkbox"]';
this.crossAlertEnableCheckbox = '[data-test="organizationdeduplication-enable-cross-alert-checkbox"]';
this.defaultWindowInput = '[data-test="organizationdeduplication-default-window-input"]';
// ==================== Semantic Field Groups Selectors ====================
this.semanticGroupCategorySelect = '[data-test="semantic-group-category-select"]';
this.importJsonBtn = '[data-test="correlation-semanticfieldgroup-import-json-btn"]';
this.addCustomGroupBtn = '[data-test="correlation-semanticfieldgroup-add-custom-group-btn"]';
this.semanticFieldGroupSaveBtn = '[data-test="correlation-semanticfieldgroup-save-btn"]';
// ==================== Semantic Group Item Selectors ====================
this.semanticGroupDisplayInput = '[data-test="semantic-group-display-input"]';
// OInput forwards data-test to a non-interactive wrapper; the real <input>
// is the `-field` node and the inline validation message is `-error`.
this.semanticGroupDisplayInputField = '[data-test="semantic-group-display-input-field"]';
this.semanticGroupDisplayInputError = '[data-test="semantic-group-display-input-error"]';
this.semanticGroupScopeCheckbox = '[data-test="semantic-group-action-scope-chkbox"]';
this.semanticGroupStableCheckbox = '[data-test="semantic-group-action-stable-chkbox"]';
this.semanticGroupNormalizeCheckbox = '[data-test="semantic-group-action-normalize-chkbox"]';
this.semanticGroupRemoveBtn = '[data-test="semantic-group-remove-group-btn"]';
// ==================== Common Selectors ====================
this.loadingSpinner = '[data-test="discovered-services-loading-indicator"], [data-test="discovered-services-table-loading-indicator"]';
this.notification = '[role="alert"]';
}
// ==================== Test Data Setup ====================
/**
* Ensure semantic groups exist in the backend for testing fingerprint checkboxes
* Uses PUT /api/{org}/alerts/deduplication/semantic-groups to set semantic groups
* AND POST /api/{org}/alerts/deduplication/config to include them in the config
* The frontend reads semantic groups from the config endpoint first
* @param {string} orgId - Organization identifier
* @returns {Promise<boolean>} - True if successful
*/
async ensureSemanticGroupsExist(orgId) {
const baseUrl = process.env["ZO_BASE_URL"];
const username = process.env["ZO_ROOT_USER_EMAIL"];
const password = process.env["ZO_ROOT_USER_PASSWORD"];
const authHeader = 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64');
// Define test semantic groups that cover common use cases.
// Post-revamp shape: {id, display, group, fields} only — the per-group
// flags (is_scope / is_stable / normalize) were removed.
const testSemanticGroups = [
{
id: 'k8s-cluster',
display: 'K8s Cluster',
group: 'Kubernetes',
fields: ['k8s.cluster.name', 'k8s_cluster_name'],
},
{
id: 'k8s-namespace',
display: 'K8s Namespace',
group: 'Kubernetes',
fields: ['k8s.namespace.name', 'k8s_namespace_name'],
},
{
id: 'k8s-deployment',
display: 'K8s Deployment',
group: 'Kubernetes',
fields: ['k8s.deployment.name', 'k8s_deployment_name'],
},
{
id: 'service',
display: 'Service',
group: 'Common',
fields: ['service.name', 'service_name', 'service'],
}
];
testLogger.info('Setting up test semantic groups via API', { orgId, groupCount: testSemanticGroups.length });
try {
// Step 1: PUT semantic groups to the semantic-groups endpoint
const putUrl = `${baseUrl}/api/${orgId}/alerts/deduplication/semantic-groups`;
testLogger.info('PUT semantic groups', { url: putUrl });
const putResponse = await fetch(putUrl, {
method: 'PUT',
headers: {
'Authorization': authHeader,
'Content-Type': 'application/json'
},
body: JSON.stringify(testSemanticGroups)
});
const putText = await putResponse.text();
testLogger.info('PUT semantic-groups response', {
status: putResponse.status,
statusText: putResponse.statusText,
body: putText.substring(0, 500)
});
// Note: We intentionally do NOT POST to /config here.
// The config endpoint doesn't persist semantic_field_groups anyway (GET returns 0),
// and concurrent config POSTs in parallel tests cause race conditions where
// loadConfig() overwrites UI checkbox state with stale API values.
// The PUT above is the single source of truth for semantic groups.
return putResponse.ok;
} catch (error) {
testLogger.error('Failed to setup semantic groups', { error: error.message, stack: error.stack });
return false;
}
}
// ==================== Navigation ====================
async navigateToCorrelationSettings(orgId) {
const baseUrl = process.env.ZO_BASE_URL || 'http://localhost:5080';
await this.page.goto(`${baseUrl}/web/settings/correlation?org_identifier=${orgId}`);
await this.page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {});
}
// ==================== Page Assertions ====================
async expectPageLoaded() {
// The tabs container is the reliable "page loaded" signal — the old
// `.general-page-title` class was removed in the revamp (the title is
// now [data-test="correlation-settings-page-title"]).
await expect(this.page.locator(this.correlationSettingsTabs)).toBeVisible({ timeout: 15000 });
}
async expectAllTabsVisible() {
// Use getByRole for tabs or filter by text
const servicesTab = this.page.getByRole('tab', { name: this.servicesTabName });
const serviceDiscoveryTab = this.page.getByRole('tab', { name: this.serviceDiscoveryTabName });
const alertCorrelationTab = this.page.getByRole('tab', { name: this.alertCorrelationTabName });
const fieldAliasesTab = this.page.getByRole('tab', { name: this.fieldAliasesTabName });
await expect(servicesTab).toBeVisible({ timeout: 15000 });
await expect(serviceDiscoveryTab).toBeVisible();
await expect(alertCorrelationTab).toBeVisible();
await expect(fieldAliasesTab).toBeVisible();
}
// ==================== Tab Switching ====================
async clickServiceDiscoveryTab() {
const tab = this.page.getByRole('tab', { name: this.serviceDiscoveryTabName });
await tab.click();
await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
}
async clickServicesTab() {
const tab = this.page.getByRole('tab', { name: this.servicesTabName });
await tab.click();
await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
}
// The "services" tab IS the Discovered Services tab (post-revamp label).
async clickDiscoveredServicesTab() {
await this.clickServicesTab();
}
async clickAlertCorrelationTab() {
const tab = this.page.getByRole('tab', { name: this.alertCorrelationTabName });
// Wait for semantic-groups API response that loadConfig() triggers on component mount.
// This prevents a race condition where the test interacts with checkboxes before
// loadConfig() completes and overwrites localConfig with API values.
const semanticGroupsPromise = this.page.waitForResponse(
resp => resp.url().includes('/alerts/deduplication/semantic-groups') && resp.status() === 200,
{ timeout: 15000 }
).catch(() => {
testLogger.info('Semantic groups API response not intercepted (may have already fired)');
});
await tab.click();
await semanticGroupsPromise;
await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
}
async expectServiceDiscoveryTabActive() {
const tab = this.page.getByRole('tab', { name: this.serviceDiscoveryTabName });
await expect(tab).toHaveAttribute('aria-selected', 'true', { timeout: 5000 });
}
async expectServicesTabActive() {
const tab = this.page.getByRole('tab', { name: this.servicesTabName });
await expect(tab).toHaveAttribute('aria-selected', 'true', { timeout: 5000 });
}
async expectAlertCorrelationTabActive() {
const tab = this.page.getByRole('tab', { name: this.alertCorrelationTabName });
await expect(tab).toHaveAttribute('aria-selected', 'true', { timeout: 5000 });
}
// ==================== Tab / Button Getters (raw-selector relocation) ====================
/**
* Field Mappings tab locator (a.k.a. Field Aliases).
* @returns {import('@playwright/test').Locator}
*/
getFieldMappingsTab() {
return this.page.getByRole('tab', { name: this.fieldAliasesTabName });
}
/**
* Detection Rules tab locator (the "Distinguish Services By" configuration).
* @returns {import('@playwright/test').Locator}
*/
getDetectionRulesTab() {
return this.page.getByRole('tab', { name: this.detectionRulesTabName });
}
/**
* "+ Add field" button on the Detection Rules view.
* @returns {import('@playwright/test').Locator}
*/
getAddFieldButton() {
return this.page.getByRole('button', { name: this.addFieldButtonName }).first();
}
/**
* The Correlation Settings tabs container (data-test wrapper).
* @returns {import('@playwright/test').Locator}
*/
getCorrelationSettingsTabs() {
return this.page.locator(this.correlationSettingsTabs);
}
/**
* "+ Add custom group" button on the Field Mappings view.
* @returns {import('@playwright/test').Locator}
*/
getAddCustomGroupButton() {
return this.page.locator(this.addCustomGroupBtn);
}
/**
* Semantic-group display-input wrapper (data-test sits on the component
* wrapper; the editable element is inside).
* @returns {import('@playwright/test').Locator}
*/
getSemanticGroupDisplayWrap() {
return this.page.locator(this.semanticGroupDisplayInput);
}
/**
* Save button for a semantic field group.
* @returns {import('@playwright/test').Locator}
*/
getSemanticFieldGroupSaveButton() {
return this.page.locator(this.semanticFieldGroupSaveBtn);
}
/**
* "Save Configuration" button on the Detection Rules view.
* @returns {import('@playwright/test').Locator}
*/
getSaveConfigurationButton() {
return this.page.getByRole('button', { name: this.saveConfigurationButtonName });
}
/**
* "Select a field" OSelect trigger (placeholder text, non-exact).
* @returns {import('@playwright/test').Locator}
*/
getSelectAFieldTrigger() {
return this.page.getByText(this.selectAFieldText, { exact: false });
}
/**
* Role=option locator matched by name (string or RegExp).
* @param {string|RegExp} name - Option accessible name.
* @returns {import('@playwright/test').Locator}
*/
getOptionByName(name) {
return this.page.getByRole('option', { name });
}
/**
* Visible text locator matched by string or RegExp.
* @param {string|RegExp} text - Text (or pattern) to match.
* @returns {import('@playwright/test').Locator}
*/
getTextByPattern(text) {
return this.page.getByText(text);
}
async clickFieldAliasesTab() {
// Correlation Settings (Field Mappings / Field Aliases) is an ENTERPRISE-only
// feature — the tab only mounts on the enterprise build. Probe positively for
// the tab via its data-test (data-test-only policy) and return a boolean so
// callers can gate the suite to a clean SKIP on the OSS binary.
try {
const tab = this.page.locator('[data-test="correlation-settings-field-aliases-tab"]');
await tab.waitFor({ state: 'visible', timeout: 15000 });
await tab.click();
await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
return true;
} catch {
return false;
}
}
async expectFieldAliasesTabActive() {
const tab = this.page.getByRole('tab', { name: this.fieldAliasesTabName });
await expect(tab).toHaveAttribute('aria-selected', 'true', { timeout: 5000 });
}
// ==================== Tab Content Visibility Checks ====================
async expectServicesContentVisible() {
// Wait for the Services tab content to be visible
await expect(this.page.locator(this.refreshDiscoveredServicesBtn)).toBeVisible({ timeout: 15000 });
}
async expectServiceDiscoveryContentVisible() {
// Detection Rules view: the "Save Configuration" button anchors it
// (the old dual-list Service Identity save-btn was removed in the revamp).
await expect(
this.page.getByRole('button', { name: this.saveConfigurationButtonName }).first(),
).toBeVisible({ timeout: 15000 });
}
async expectFieldAliasesContentVisible() {
// Wait for the Field Aliases tab content to be visible
await expect(this.page.locator(this.importJsonBtn)).toBeVisible({ timeout: 15000 });
}
// ==================== Service Discovery (Configuration) Tab Actions ====================
async fillSearchAvailableDimensions(searchText) {
const searchInput = this.page.locator(this.serviceIdentitySearchInput);
await expect(searchInput).toBeVisible({ timeout: 10000 });
await searchInput.fill(searchText);
await this.page.waitForTimeout(500); // Wait for filtering
}
async clearSearchAvailableDimensions() {
const searchInput = this.page.locator(this.serviceIdentitySearchInput);
await searchInput.clear();
await this.page.waitForTimeout(500);
}
async clickAddDimensionButton() {
const btn = this.page.locator(this.serviceIdentityBackwardBtn);
// Wait for button to be enabled
await btn.waitFor({ state: 'visible', timeout: 5000 });
// Check if enabled, if not wait a bit
const isDisabled = await btn.getAttribute('disabled');
if (isDisabled !== null) {
await this.page.waitForTimeout(500);
}
await btn.click({ force: true });
}
async clickRemoveDimensionButton() {
const btn = this.page.locator(this.serviceIdentityForwardBtn);
// Wait for button to be visible
await btn.waitFor({ state: 'visible', timeout: 5000 });
// Check if enabled, if not wait a bit
const isDisabled = await btn.getAttribute('disabled');
if (isDisabled !== null) {
await this.page.waitForTimeout(500);
}
await btn.click({ force: true });
}
async clickResetButton() {
await this.page.locator(this.serviceIdentityResetBtn).click();
}
async clickSaveServiceIdentity() {
await this.page.locator(this.serviceIdentitySaveBtn).click();
await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
}
async expectAddDimensionButtonEnabled() {
const btn = this.page.locator(this.serviceIdentityBackwardBtn);
await expect(btn).not.toBeDisabled();
}
async expectAddDimensionButtonDisabled() {
const btn = this.page.locator(this.serviceIdentityBackwardBtn);
await expect(btn).toBeDisabled();
}
async expectSaveButtonVisible() {
await expect(this.page.locator(this.serviceIdentitySaveBtn)).toBeVisible({ timeout: 10000 });
}
async expectResetButtonVisible() {
await expect(this.page.locator(this.serviceIdentityResetBtn)).toBeVisible({ timeout: 10000 });
}
// ==================== Discovered Services Tab Actions ====================
async expectDiscoveredServicesContentVisible() {
// Wait for loading to finish
await this.page.locator(this.loadingSpinner).waitFor({ state: 'hidden', timeout: 30000 }).catch(() => {});
// Check for refresh button (always visible) or empty state
const refreshBtn = this.page.locator(this.refreshDiscoveredServicesBtn);
await expect(refreshBtn.first()).toBeVisible({ timeout: 15000 });
}
async clickRefreshDiscoveredServices() {
await this.page.locator(this.refreshDiscoveredServicesBtn).first().click();
await this.page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
}
async expectDiscoveredServicesLoading() {
await expect(this.page.locator(this.loadingSpinner)).toBeVisible({ timeout: 5000 });
}
async expectDiscoveredServicesLoaded() {
await this.page.locator(this.loadingSpinner).waitFor({ state: 'hidden', timeout: 30000 }).catch(() => {});
}
async expectRefreshDiscoveredServicesButtonVisible() {
const refreshBtn = this.page.locator(this.refreshDiscoveredServicesBtn).first();
await expect(refreshBtn).toBeVisible({ timeout: 15000 });
}
// ==================== Alert Correlation Tab Actions ====================
async expectAlertCorrelationContentVisible() {
await expect(this.page.locator(this.organizationDedupEnableCheckbox)).toBeVisible({ timeout: 15000 });
}
async clickEnableDeduplicationCheckbox() {
await this.page.locator(this.organizationDedupEnableCheckbox).click();
}
async clickEnableCrossAlertCheckbox() {
await this.page.locator(this.crossAlertEnableCheckbox).click();
}
async expectCrossAlertCheckboxVisible() {
await expect(this.page.locator(this.crossAlertEnableCheckbox)).toBeVisible({ timeout: 5000 });
}
async expectCrossAlertCheckboxHidden() {
await expect(this.page.locator(this.crossAlertEnableCheckbox)).not.toBeVisible({ timeout: 5000 });
}
/**
* Check if the cross-alert deduplication checkbox is currently checked
* @returns {Promise<boolean>} True if checkbox is checked
*/
async isCrossAlertCheckboxChecked() {
const checkbox = this.page.locator(this.crossAlertEnableCheckbox);
// The checkbox uses aria-checked attribute
const ariaChecked = await checkbox.getAttribute('aria-checked');
if (ariaChecked !== null) {
return ariaChecked === 'true';
}
// Fallback to checking the inner input if visible
const input = checkbox.locator('input[type="checkbox"]');
if (await input.count() > 0) {
return await input.isChecked();
}
// Last resort - check for checked class
const hasCheckedClass = await checkbox.evaluate(el => {
return el.classList.contains('checked') ||
el.closest('[role="checkbox"]')?.getAttribute('aria-checked') === 'true';
}).catch(() => false);
return hasCheckedClass;
}
async fillTimeWindowInput(minutes) {
// OFormInput forwards data-test to a wrapper <div>; the real control is
// the inner <input type="number">.
const wrap = this.page.locator(this.defaultWindowInput);
await expect(wrap).toBeVisible({ timeout: 5000 });
await wrap.locator('input').first().fill(String(minutes));
}
async getTimeWindowValue() {
return await this.page.locator(this.defaultWindowInput).locator('input').first().inputValue();
}
async clickRefreshDedupSettings() {
await this.page.locator(this.dedupSettingsRefreshBtn).click();
await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
}
async expectEnableDedupCheckboxVisible() {
await expect(this.page.locator(this.organizationDedupEnableCheckbox)).toBeVisible({ timeout: 10000 });
}
async expectTimeWindowInputVisible() {
await expect(this.page.locator(this.defaultWindowInput)).toBeVisible({ timeout: 10000 });
}
async expectDedupRefreshButtonVisible() {
await expect(this.page.locator(this.dedupSettingsRefreshBtn)).toBeVisible({ timeout: 10000 });
}
// ==================== Fingerprint Checkbox Actions ====================
async clickFingerprintCheckbox(groupId) {
const selector = `[data-test="organizationdeduplication-fingerprint-${groupId}-checkbox"]`;
await this.page.locator(selector).click();
}
async expectFingerprintCheckboxVisible(groupId) {
const selector = `[data-test="organizationdeduplication-fingerprint-${groupId}-checkbox"]`;
await expect(this.page.locator(selector)).toBeVisible({ timeout: 5000 });
}
// ==================== Semantic Field Groups Actions ====================
async clickCategorySelect() {
await this.page.locator(this.semanticGroupCategorySelect).click();
}
async selectCategory(categoryName) {
await this.clickCategorySelect();
await this.page.getByRole('option', { name: categoryName }).click();
}
async clickImportJsonButton() {
await this.page.locator(this.importJsonBtn).click();
}
async clickAddCustomGroupButton() {
await this.page.locator(this.addCustomGroupBtn).click();
await this.page.waitForTimeout(500);
}
// addGroup() unshifts the new group to the front of the list → use .first()
getDisplayNameInput() {
return this.page.locator(this.semanticGroupDisplayInputField).first();
}
getDisplayNameError() {
return this.page.locator(this.semanticGroupDisplayInputError).first();
}
async expectAddCustomGroupButtonVisible() {
await expect(this.page.locator(this.addCustomGroupBtn)).toBeVisible({ timeout: 10000 });
}
async expectCategorySelectVisible() {
await expect(this.page.locator(this.semanticGroupCategorySelect)).toBeVisible({ timeout: 10000 });
}
async expectImportJsonButtonVisible() {
await expect(this.page.locator(this.importJsonBtn)).toBeVisible({ timeout: 10000 });
}
// ==================== Service Identity Navigation Buttons ====================
async expectBackwardButtonVisible() {
await expect(this.page.locator(this.serviceIdentityBackwardBtn)).toBeVisible({ timeout: 10000 });
}
async expectForwardButtonVisible() {
await expect(this.page.locator(this.serviceIdentityForwardBtn)).toBeVisible({ timeout: 10000 });
}
// ==================== Semantic Group Item Actions ====================
async fillSemanticGroupDisplayName(name, index = 0) {
const input = this.page.locator(this.semanticGroupDisplayInput).nth(index);
await input.fill(name);
}
async toggleSemanticGroupScope(index = 0) {
await this.page.locator(this.semanticGroupScopeCheckbox).nth(index).click();
}
async toggleSemanticGroupStable(index = 0) {
await this.page.locator(this.semanticGroupStableCheckbox).nth(index).click();
}
async toggleSemanticGroupNormalize(index = 0) {
await this.page.locator(this.semanticGroupNormalizeCheckbox).nth(index).click();
}
async clickRemoveSemanticGroup(index = 0) {
await this.page.locator(this.semanticGroupRemoveBtn).nth(index).click();
}
async getSemanticGroupCount() {
return await this.page.locator(this.semanticGroupDisplayInput).count();
}
// ==================== Notification Helpers ====================
async expectNotificationVisible(expectedText = null, timeout = 5000) {
const notification = this.page.locator(this.notification);
await expect(notification).toBeVisible({ timeout });
if (expectedText) {
const text = await notification.textContent();
expect(text.toLowerCase()).toContain(expectedText.toLowerCase());
}
}
async expectSuccessNotification(timeout = 5000) {
await this.expectNotificationVisible(null, timeout);
}
// ==================== Functional Test Helpers ====================
async getAvailableDimensionsCount() {
// Count items in the available dimensions list (right side)
const list = this.page.locator('[role="listbox"]').nth(1); // TODO: verify list container selector post-Reka migration
await list.waitFor({ state: 'visible', timeout: 10000 });
const items = list.locator('[role="option"], [data-test$="-item"]'); // TODO: verify data-test for dimension list items
return await items.count();
}
async expectSearchFiltersResults(searchTerm, expectedToFilter = true) {
// Get initial count
const initialCount = await this.getAvailableDimensionsCount();
// Enter search term
await this.fillSearchAvailableDimensions(searchTerm);
await this.page.waitForTimeout(500);
// Get filtered count
const filteredCount = await this.getAvailableDimensionsCount();
if (expectedToFilter) {
// Should have fewer or equal items after filtering
return filteredCount <= initialCount;
}
return true;
}
async expectPositiveNotification(timeout = 5000) {
const notification = this.page.locator('[role="alert"][class*="bg-positive"], [role="alert"].bg-positive');
await expect(notification).toBeVisible({ timeout });
}
async waitForNotificationToDisappear(timeout = 10000) {
await this.page.locator(this.notification).waitFor({ state: 'hidden', timeout }).catch(() => {});
}
async refreshPage() {
await this.page.reload();
await this.page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {});
}
async isDedupCheckboxChecked() {
const checkbox = this.page.locator(this.organizationDedupEnableCheckbox);
// Checkboxes use aria-checked or have a specific class when checked
const isChecked = await checkbox.getAttribute('aria-checked');
if (isChecked !== null) {
return isChecked === 'true';
}
// Fallback: check for checked class
const classList = await checkbox.getAttribute('class');
return classList?.includes('checked');
}
// ==================== Alert Correlation Save Button ====================
async clickSaveAlertCorrelation() {
// The save button in Alert Correlation tab
const saveBtn = this.page.getByRole('button', { name: /save/i }).last();
await saveBtn.click();
await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
}
async expectAlertCorrelationSaveSuccess() {
// Wait for positive notification about saved settings
const notification = this.page.locator('[role="alert"]');
await expect(notification).toBeVisible({ timeout: 10000 });
const text = await notification.textContent();
return text?.toLowerCase().includes('saved') || text?.toLowerCase().includes('success');
}
// ==================== Phase 1 High Priority - Dimension Management ====================
/**
* Select an available dimension from the right list by name
* @param {string} dimensionName - Name of dimension to select
*/
async selectAvailableDimension(dimensionName) {
// Available dimensions are in the second list (right side) within service-identity-config
const serviceConfig = this.page.locator('.service-identity-config');
await serviceConfig.waitFor({ state: 'visible', timeout: 10000 });
// Bordered lists (from the bordered prop) with the h-80 height class
const lists = serviceConfig.locator('[role="listbox"]');
const rightList = lists.last();
await rightList.waitFor({ state: 'visible', timeout: 5000 });
const item = rightList.locator('[role="option"], [data-test$="-item"]').filter({ hasText: dimensionName }).first(); // TODO: verify data-test
await item.click();
// Wait for button to become enabled
await this.page.waitForTimeout(300);
}
/**
* Select a priority dimension from the left list by name
* @param {string} dimensionName - Name of dimension to select
*/
async selectPriorityDimension(dimensionName) {
// Priority dimensions are in the first list (left side) within service-identity-config
const serviceConfig = this.page.locator('.service-identity-config');
await serviceConfig.waitFor({ state: 'visible', timeout: 10000 });
// Bordered lists (from the bordered prop) with the h-80 height class
const lists = serviceConfig.locator('[role="listbox"]');
const leftList = lists.first();
await leftList.waitFor({ state: 'visible', timeout: 5000 });
const item = leftList.locator('[role="option"], [data-test$="-item"]').filter({ hasText: dimensionName }).first(); // TODO: verify data-test
await item.click();
// Wait for button to become enabled
await this.page.waitForTimeout(300);
}
/**
* Get count of dimensions in the priority list (left side)
* @returns {Promise<number>}
*/
async getPriorityDimensionsCount() {
const serviceConfig = this.page.locator('.service-identity-config');
await serviceConfig.waitFor({ state: 'visible', timeout: 10000 });
// Bordered lists (from the bordered prop) with the h-80 height class
const lists = serviceConfig.locator('[role="listbox"]');
const leftList = lists.first();
await leftList.waitFor({ state: 'visible', timeout: 5000 });
// Exclude empty state items (the "No dimensions configured" message)
const items = leftList.locator('[role="option"]:not(:has-text("No")), [data-test$="-item"]:not(:has-text("No"))'); // TODO: verify data-test
return await items.count();
}
/**
* Check if a dimension exists in the priority list
* @param {string} dimensionName - Name of dimension to check
*/
async expectPriorityDimensionExists(dimensionName) {
const serviceConfig = this.page.locator('.service-identity-config');
// Bordered lists (from the bordered prop) with the h-80 height class
const lists = serviceConfig.locator('[role="listbox"]');
const leftList = lists.first();
const item = leftList.locator('[role="option"], [data-test$="-item"]').filter({ hasText: dimensionName }).first(); // TODO: verify data-test
await expect(item).toBeVisible({ timeout: 5000 });
}
/**
* Check if a dimension does NOT exist in the priority list
* @param {string} dimensionName - Name of dimension to check
*/
async expectPriorityDimensionNotExists(dimensionName) {
const serviceConfig = this.page.locator('.service-identity-config');
// Bordered lists (from the bordered prop) with the h-80 height class
const lists = serviceConfig.locator('[role="listbox"]');
const leftList = lists.first();
const item = leftList.locator('[role="option"], [data-test$="-item"]').filter({ hasText: dimensionName }).first(); // TODO: verify data-test
await expect(item).not.toBeVisible({ timeout: 5000 });
}
// ==================== Phase 1 High Priority - Fingerprint Groups ====================
/**
* Get all fingerprint group checkboxes
* @returns {import('@playwright/test').Locator}
*/
getFingerprintGroupCheckboxes() {
return this.page.locator('[data-test^="organizationdeduplication-fingerprint-"]');
}
/**
* Expect fingerprint groups section to be visible
*/
async expectFingerprintGroupsVisible() {
const checkboxes = this.getFingerprintGroupCheckboxes();
await expect(checkboxes.first()).toBeVisible({ timeout: 10000 });
}
/**
* Expect fingerprint groups section to be hidden
*/
async expectFingerprintGroupsHidden() {
const checkboxes = this.page.locator('[data-test^="organizationdeduplication-fingerprint-"]');
await expect(checkboxes.first()).not.toBeVisible({ timeout: 5000 });
}
/**
* Get count of fingerprint group checkboxes
* @returns {Promise<number>}
*/
async getFingerprintGroupsCount() {
const checkboxes = this.getFingerprintGroupCheckboxes();
return await checkboxes.count();
}
/**
* Check if a specific fingerprint group is checked
* @param {string} groupId - The group ID
* @returns {Promise<boolean>}
*/
async isFingerprintGroupChecked(groupId) {
const selector = `[data-test="organizationdeduplication-fingerprint-${groupId}-checkbox"]`;
const checkbox = this.page.locator(selector);
const isChecked = await checkbox.getAttribute('aria-checked');
if (isChecked !== null) {
return isChecked === 'true';
}
const classList = await checkbox.getAttribute('class');
return classList?.includes('checked');
}
/**
* Expect validation error notification to be visible
* @param {string} expectedMessage - Expected error message (partial match)
*/
async expectValidationErrorVisible(expectedMessage = null) {
const notification = this.page.locator('[role="alert"][class*="bg-negative"], [role="alert"].bg-negative, [role="alert"]');
await expect(notification.first()).toBeVisible({ timeout: 10000 });
if (expectedMessage) {
const text = await notification.first().textContent();
expect(text.toLowerCase()).toContain(expectedMessage.toLowerCase());
}
}
/**
* Click the Cancel button in Alert Correlation tab
*/
async clickCancelAlertCorrelation() {
const cancelBtn = this.page.getByRole('button', { name: /cancel/i });
await cancelBtn.click();
}
// ==================== Phase 1 High Priority - Discovered Services ====================
/**
* Expect loading spinner to be visible (during data fetch)
*/
async expectLoadingSpinnerVisible() {
await expect(this.page.locator(this.loadingSpinner)).toBeVisible({ timeout: 5000 });
}
/**
* Expect discovered services data to be loaded (content visible)
*/
async expectServicesDataLoaded() {
await this.page.locator(this.loadingSpinner).waitFor({ state: 'hidden', timeout: 30000 }).catch(() => {});
// Check for either services content or empty state
const hasContent = await this.page.locator('table, [role="listbox"], .discovered-services-content').first().isVisible().catch(() => false);
const hasEmptyState = await this.page.locator('text=/no.*service|empty/i').first().isVisible().catch(() => false);
return hasContent || hasEmptyState;
}
/**
* Expect empty state message when no services discovered
*/
async expectEmptyStateVisible() {
const emptyState = this.page.locator('text=/no.*service|empty|no data/i').first();
await expect(emptyState).toBeVisible({ timeout: 10000 });
}
/**
* Click the Retry button when in error state
*/
async clickRetryButton() {
await this.page.locator(this.retryDiscoveredServicesBtn).click();
await this.page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
}
/**
* Expect retry button to be visible (error state)
*/
async expectRetryButtonVisible() {
await expect(this.page.locator(this.retryDiscoveredServicesBtn)).toBeVisible({ timeout: 10000 });
}
// ==================== Phase 1 High Priority - Error/Success Notifications ====================
/**
* Expect error notification to be visible
* @param {string} message - Expected message content (optional)
*/
async expectErrorNotificationVisible(message = null) {
const notification = this.page.locator('[role="alert"][class*="bg-negative"], [role="alert"].bg-negative');
await expect(notification.first()).toBeVisible({ timeout: 10000 });
if (message) {
const text = await notification.first().textContent();
expect(text.toLowerCase()).toContain(message.toLowerCase());
}
}
/**
* Expect success notification to be visible
* @param {string} message - Expected message content (optional)
*/
async expectSuccessNotificationVisible(message = null) {
const notification = this.page.locator('[role="alert"][class*="bg-positive"], [role="alert"].bg-positive, [role="alert"]');
await expect(notification.first()).toBeVisible({ timeout: 10000 });
if (message) {
const text = await notification.first().textContent();
expect(text.toLowerCase()).toContain(message.toLowerCase());
}
}
// ==================== Phase 1 High Priority - Reset Functionality ====================
/**
* Click Reset to Defaults button and wait for confirmation
*/
async clickResetToDefaults() {
await this.page.locator(this.serviceIdentityResetBtn).click();
// Wait for any confirmation dialog or immediate reset
await this.page.waitForTimeout(500);
}
/**
* Confirm reset dialog if present
*/
async confirmResetDialog() {
const confirmBtn = this.page.getByRole('button', { name: /confirm|yes|ok/i });
if (await confirmBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await confirmBtn.click();
}
}
/**
* Get the first available dimension name for testing
* @returns {Promise<string>}
*/
async getFirstAvailableDimensionName() {
const serviceConfig = this.page.locator('.service-identity-config');
await serviceConfig.waitFor({ state: 'visible', timeout: 10000 });
// Bordered lists (from the bordered prop) with the h-80 height class
const lists = serviceConfig.locator('[role="listbox"]');
const rightList = lists.last();
await rightList.waitFor({ state: 'visible', timeout: 5000 });
const firstItem = rightList.locator('[role="option"]:not(:has-text("No")), [data-test$="-item"]:not(:has-text("No"))').first(); // TODO: verify data-test
// Try to get just the label text
const label = firstItem.getByText(/.+/).first();
if (await label.isVisible().catch(() => false)) {
return await label.textContent();
}
// Fallback to full text
const fullText = await firstItem.textContent();
// Extract dimension name (usually formatted like "Name (id)")
const match = fullText.match(/([A-Za-z0-9\s\-_]+\s*\([^)]+\))/);
return match ? match[1].trim() : fullText.trim();
}
/**
* Get the first priority dimension name for testing
* @returns {Promise<string>}
*/
async getFirstPriorityDimensionName() {
const serviceConfig = this.page.locator('.service-identity-config');
await serviceConfig.waitFor({ state: 'visible', timeout: 10000 });
// Bordered lists (from the bordered prop) with the h-80 height class
const lists = serviceConfig.locator('[role="listbox"]');
const leftList = lists.first();
await leftList.waitFor({ state: 'visible', timeout: 5000 });
const firstItem = leftList.locator('[role="option"]:not(:has-text("No")), [data-test$="-item"]:not(:has-text("No"))').first(); // TODO: verify data-test
// Try to get just the label text
const label = firstItem.getByText(/.+/).first();
if (await label.isVisible().catch(() => false)) {
return await label.textContent();
}
// Fallback to full text
const fullText = await firstItem.textContent();
// Extract dimension name
const match = fullText.match(/([A-Za-z0-9\s\-_]+\s*\([^)]+\))/);
return match ? match[1].trim() : fullText.trim();
}
/**
* Check if backward (add) button is enabled
* @returns {Promise<boolean>}
*/
async isAddButtonEnabled() {
try {
const btn = this.page.locator(this.serviceIdentityBackwardBtn);
await btn.waitFor({ state: 'visible', timeout: 5000 });
const isDisabled = await btn.getAttribute('disabled', { timeout: 2000 });
return isDisabled === null;
} catch (e) {
return false;
}
}
/**
* Check if forward (remove) button is enabled
* @returns {Promise<boolean>}
*/
async isRemoveButtonEnabled() {
try {
const btn = this.page.locator(this.serviceIdentityForwardBtn);
await btn.waitFor({ state: 'visible', timeout: 5000 });
const isDisabled = await btn.getAttribute('disabled', { timeout: 2000 });
return isDisabled === null;
} catch (e) {
return false;
}
}
// ==================== Utility Helpers ====================
/**
* Wait for UI to stabilize after an action (e.g., selection, toggle)
* @param {number} ms - Milliseconds to wait (default: 500)
*/
async waitForUIStabilization(ms = 500) {
await this.page.waitForTimeout(ms);
}
/**
* Short wait for quick UI updates (e.g., button state changes)
* @param {number} ms - Milliseconds to wait (default: 300)
*/
async waitForQuickUpdate(ms = 300) {
await this.page.waitForTimeout(ms);
}
/**
* Opens the TimeRangeEditor dialog from the Correlation Settings page.
* Tries the known trigger button selectors for the time range editor.
* Returns true if the dialog was opened successfully, false otherwise.
*/
async openTimeRangeEditor() {
const trigger = this.page.locator(
'[data-test="correlation-time-range-edit-btn"], [data-test="time-range-editor-trigger"]'
);
const triggerVisible = await trigger.isVisible({ timeout: 5000 }).catch(() => false);
if (!triggerVisible) {
testLogger.warn('TimeRangeEditor trigger not found — component may not be available in this environment');
return false;
}
await trigger.click();
const dialogVisible = await this.page.locator('[data-test="time-range-editor-dialog"]')
.isVisible({ timeout: 5000 }).catch(() => false);
return dialogVisible;
}
}