diff --git a/web/cypress/e2e/incidents/00.coo_incidents_e2e.cy.ts b/web/cypress/e2e/incidents/00.coo_incidents_e2e.cy.ts index e35802392..12ecb1da8 100644 --- a/web/cypress/e2e/incidents/00.coo_incidents_e2e.cy.ts +++ b/web/cypress/e2e/incidents/00.coo_incidents_e2e.cy.ts @@ -42,12 +42,15 @@ describe('BVT: Incidents - e2e', { tags: ['@smoke', '@slow', '@incidents', '@e2e incidentsPage.clearAllFilters(); const intervalMs = 60_000; - const maxMinutes = 30; cy.log('1.2 Wait for incident with custom alert to appear'); + // Poll via UI traversal with OOM-safe findIncidentWithAlert. + // The search loop has two layers of OOM protection: + // 1. _quietSearch — suppresses Cypress DOM snapshots + // 2. Hard timeout (35 min) — kills infinite loops cy.waitUntil(() => incidentsPage.findIncidentWithAlert(currentAlertName), { - interval: intervalMs, - timeout: maxMinutes * intervalMs, + interval: 2 * intervalMs, + timeout: 30 * intervalMs + 2 * intervalMs, }); cy.log('1.3 Verify custom alert appears in alerts table'); diff --git a/web/cypress/e2e/incidents/01.incidents.cy.ts b/web/cypress/e2e/incidents/01.incidents.cy.ts index 861c5aa21..0beb7108c 100644 --- a/web/cypress/e2e/incidents/01.incidents.cy.ts +++ b/web/cypress/e2e/incidents/01.incidents.cy.ts @@ -27,6 +27,7 @@ const MP = { describe('BVT: Incidents - UI', { tags: ['@smoke', '@incidents'] }, () => { before(() => { cy.beforeBlockCOO(MCP, MP, { dashboards: false, troubleshootingPanel: false }); + incidentsPage.warmUpForPlugin(); cy.mockIncidentFixture( 'incident-scenarios/1-single-incident-firing-critical-and-warning-alerts.yaml', ); @@ -88,6 +89,53 @@ describe('BVT: Incidents - UI', { tags: ['@smoke', '@incidents'] }, () => { cy.log('5.3 Verify traversing incident table works when the alert is present'); cy.mockIncidentFixture('incident-scenarios/6-multi-incident-target-alert-scenario.yaml'); + incidentsPage.clearAllFilters(); incidentsPage.findIncidentWithAlert('TargetAlert').should('be.true'); }); + + it('6. Admin perspective - Incidents page - Bar click selection walkthrough', () => { + cy.log('6.1 Load multi-incident fixture and verify chart bars are clickable'); + cy.mockIncidentFixture( + 'incident-scenarios/1-single-incident-firing-critical-and-warning-alerts.yaml', + ); + incidentsPage.goTo(); + incidentsPage.clearAllFilters(); + incidentsPage.setDays('7 days'); + incidentsPage.elements.incidentsChartContainer().should('be.visible'); + + cy.log('6.2 Select incident bar and verify table appears with expected alerts'); + incidentsPage.selectIncidentByBarIndex(0); + incidentsPage.elements.incidentsTable().should('be.visible'); + incidentsPage.elements.incidentsTableComponentCell(0).should('contain.text', 'monitoring'); + incidentsPage.expandRow(0); + incidentsPage.elements.incidentsDetailsTable().should('be.visible'); + incidentsPage.elements.incidentsDetailsAlertRuleCell(0).should('be.visible'); + incidentsPage.elements + .incidentsDetailsTable() + .should('contain.text', 'AlertmanagerReceiversNotConfigured'); + incidentsPage.elements + .incidentsDetailsTable() + .should('contain.text', 'KubeDeploymentReplicasMismatch'); + incidentsPage.elements.incidentsDetailsTable().should('contain.text', 'KubePodCrashLooping'); + + cy.log('6.3 Deselect incident bar and verify table disappears'); + incidentsPage.deselectIncidentByBar(0); + incidentsPage.elements.incidentsTable().should('not.exist'); + + cy.log('6.4 Select by incident ID and verify table appears with expected alerts'); + incidentsPage.selectIncidentById('monitoring-critical-001'); + incidentsPage.elements.incidentsTable().should('be.visible'); + incidentsPage.expandRow(0); + incidentsPage.elements + .incidentsDetailsTable() + .should('contain.text', 'AlertmanagerReceiversNotConfigured'); + incidentsPage.elements + .incidentsDetailsTable() + .should('contain.text', 'KubeDeploymentReplicasMismatch'); + incidentsPage.elements.incidentsDetailsTable().should('contain.text', 'KubePodCrashLooping'); + + cy.log('6.5 Deselect by incident ID and verify table disappears'); + incidentsPage.deselectIncidentById('monitoring-critical-001'); + incidentsPage.elements.incidentsTable().should('not.exist'); + }); }); diff --git a/web/cypress/e2e/incidents/regression/01.reg_filtering.cy.ts b/web/cypress/e2e/incidents/regression/01.reg_filtering.cy.ts index 4cf51626c..6fe52699a 100644 --- a/web/cypress/e2e/incidents/regression/01.reg_filtering.cy.ts +++ b/web/cypress/e2e/incidents/regression/01.reg_filtering.cy.ts @@ -110,7 +110,54 @@ describe('Regression: Incidents Filtering', { tags: ['@incidents'] }, () => { incidentsPage.clearAllFilters(); }); - it('2. Chart interaction with active filters', () => { + it('2. Incident ID filter - select, verify, and traverse', () => { + cy.log('2.1 Clear filters and verify all incidents are loaded'); + incidentsPage.clearAllFilters(); + incidentsPage.elements.incidentsChartBarsGroups().should('have.length', 12); + + cy.log('2.2 Discover all incident IDs from the filter dropdown'); + incidentsPage.getIncidentIds().then((ids) => { + expect(ids.length).to.equal(12); + cy.log(`Discovered ${ids.length} incident IDs`); + + cy.log('2.3 Select an incident by ID filter and verify table appears'); + incidentsPage.selectIncidentIdFilter(ids[0]); + incidentsPage.elements.incidentsTable().should('be.visible'); + incidentsPage.elements.incidentsChartBarsGroups().should('have.length', 1); + + cy.log('2.4 Clear the Incident ID filter and verify all incidents return'); + incidentsPage.clearAllFilters(); + incidentsPage.elements.incidentsTable().should('not.exist'); + incidentsPage.elements.incidentsChartBarsGroups().should('have.length', 12); + + cy.log('2.5 Filter-based traversal finds a known alert'); + cy.mockIncidentFixture('incident-scenarios/6-multi-incident-target-alert-scenario.yaml'); + incidentsPage.clearAllFilters(); + incidentsPage.setDays('7 days'); + incidentsPage.findIncidentWithAlert('TargetAlert').should('be.true'); + + cy.log('2.6 Filter-based traversal returns false for non-existent alert'); + incidentsPage.findIncidentWithAlert('NonExistentAlert').should('be.false'); + }); + + cy.log('2.7 Select incident by ID filter and verify matching alerts are present'); + cy.mockIncidentFixture('incident-scenarios/7-comprehensive-filtering-test-scenarios.yaml'); + incidentsPage.clearAllFilters(); + incidentsPage.selectIncidentIdFilter('etcd-critical-warning-001'); + incidentsPage.elements.incidentsTable().should('be.visible'); + incidentsPage.elements.incidentsTableComponentCell(0).should('contain.text', 'etcd'); + incidentsPage.expandRow(0); + incidentsPage.elements.incidentsDetailsTable().should('be.visible'); + incidentsPage.elements + .incidentsDetailsTable() + .should('contain.text', 'EtcdClusterUnavailable001'); + incidentsPage.elements.incidentsDetailsTable().should('contain.text', 'EtcdHighLatency001'); + incidentsPage.elements.incidentsDetailsTable().should('contain.text', 'EtcdBackupFailed001'); + + incidentsPage.clearAllFilters(); + }); + + it('3. Chart interaction with active filters', () => { cy.log('Setting up filters for chart interaction testing'); incidentsPage.clearAllFilters(); @@ -120,16 +167,16 @@ describe('Regression: Incidents Filtering', { tags: ['@incidents'] }, () => { incidentsPage.elements.incidentsChartBarsGroups().should('have.length', 2); cy.log('Setup complete: Informative + Firing filters active, 2 incidents shown'); - cy.log('2.1 Select incident bar while filters are active'); + cy.log('3.1 Select incident bar while filters are active'); incidentsPage.selectIncidentByBarIndex(0); incidentsPage.elements.incidentsTable().should('be.visible'); cy.log('Incident table displayed after bar selection'); - cy.log('2.2 Verify just selected incident is shown in the chart'); + cy.log('3.2 Verify just selected incident is shown in the chart'); incidentsPage.elements.incidentsChartBarsGroups().should('have.length', 1); cy.log('Verified: 1 incident shown'); - cy.log('2.3 Deselect incident bar and verify filter persistence'); + cy.log('3.3 Deselect incident bar and verify filter persistence'); incidentsPage.deselectIncidentByBar(); incidentsPage.elements.incidentsTable().should('not.exist'); incidentsPage.elements.incidentsChartBarsGroups().should('have.length', 2); diff --git a/web/cypress/e2e/incidents/regression/02.reg_ui_charts_comprehensive.cy.ts b/web/cypress/e2e/incidents/regression/02.reg_ui_charts_comprehensive.cy.ts index aa0fffbe5..3684ac52d 100644 --- a/web/cypress/e2e/incidents/regression/02.reg_ui_charts_comprehensive.cy.ts +++ b/web/cypress/e2e/incidents/regression/02.reg_ui_charts_comprehensive.cy.ts @@ -103,6 +103,7 @@ const MP = { describe('Regression: Charts UI - Comprehensive', { tags: ['@incidents'] }, () => { before(() => { cy.beforeBlockCOO(MCP, MP, { dashboards: false, troubleshootingPanel: false }); + incidentsPage.warmUpForPlugin(); }); beforeEach(() => { diff --git a/web/cypress/e2e/incidents/regression/02.reg_ui_tooltip_boundary_times.cy.ts b/web/cypress/e2e/incidents/regression/02.reg_ui_tooltip_boundary_times.cy.ts index 1ec15c0ce..d4474d98a 100644 --- a/web/cypress/e2e/incidents/regression/02.reg_ui_tooltip_boundary_times.cy.ts +++ b/web/cypress/e2e/incidents/regression/02.reg_ui_tooltip_boundary_times.cy.ts @@ -113,7 +113,6 @@ describe( incidentsPage.setDays('1 day'); incidentsPage.elements.incidentsChartContainer().should('be.visible'); incidentsPage.elements.incidentsChartBarsGroups().should('have.length', 1); - cy.pause(); cy.log( '2.2 Consecutive interval boundaries: End of segment 1 should equal Start of segment 2', @@ -141,7 +140,6 @@ describe( ).to.equal(firstEnd); }); }); - cy.pause(); cy.log('2.3 Incident tooltip Start vs alert tooltip Start vs alerts table Start'); incidentsPage.hoverOverIncidentBarSegment(0, 0); @@ -188,7 +186,6 @@ describe( }); }); }); - cy.pause(); cy.log('Expected failure: Incident tooltip Start times are 5 minutes off (OU-1221)'); }); diff --git a/web/cypress/e2e/incidents/regression/03-04.reg_e2e_firing_alerts.cy.ts b/web/cypress/e2e/incidents/regression/03-04.reg_e2e_firing_alerts.cy.ts index 923c3beed..d2bda3ea9 100644 --- a/web/cypress/e2e/incidents/regression/03-04.reg_e2e_firing_alerts.cy.ts +++ b/web/cypress/e2e/incidents/regression/03-04.reg_e2e_firing_alerts.cy.ts @@ -41,6 +41,10 @@ describe( before(() => { cy.beforeBlockCOO(MCP, MP, { dashboards: false, troubleshootingPanel: false }); + incidentsPage.warmUpForPlugin(); + + // Reset the search timeout so this spec gets a fresh 35-minute window + incidentsPage.resetSearchTimeout(); cy.log('Create firing alert for testing'); cy.cleanupIncidentPrometheusRules(); diff --git a/web/cypress/e2e/incidents/regression/03.reg_api_calls.cy.ts b/web/cypress/e2e/incidents/regression/03.reg_api_calls.cy.ts index f38dc713d..1a1f1d245 100644 --- a/web/cypress/e2e/incidents/regression/03.reg_api_calls.cy.ts +++ b/web/cypress/e2e/incidents/regression/03.reg_api_calls.cy.ts @@ -34,6 +34,7 @@ const MP = { describe('Regression: Silences Not Applied Correctly', { tags: ['@incidents'] }, () => { before(() => { cy.beforeBlockCOO(MCP, MP, { dashboards: false, troubleshootingPanel: false }); + incidentsPage.warmUpForPlugin(); }); beforeEach(() => { diff --git a/web/cypress/e2e/incidents/regression/04.reg_redux_effects.cy.ts b/web/cypress/e2e/incidents/regression/04.reg_redux_effects.cy.ts index 9cf510e59..e53be9aac 100644 --- a/web/cypress/e2e/incidents/regression/04.reg_redux_effects.cy.ts +++ b/web/cypress/e2e/incidents/regression/04.reg_redux_effects.cy.ts @@ -33,6 +33,7 @@ const MP = { describe('Regression: Redux State Management', { tags: ['@incidents', '@incidents-redux'] }, () => { before(() => { cy.beforeBlockCOO(MCP, MP, { dashboards: false, troubleshootingPanel: false }); + incidentsPage.warmUpForPlugin(); }); beforeEach(() => { diff --git a/web/cypress/e2e/incidents/regression/05.reg_stress_testing_ui.cy.ts b/web/cypress/e2e/incidents/regression/05.reg_stress_testing_ui.cy.ts index 7ca34bba9..b93103979 100644 --- a/web/cypress/e2e/incidents/regression/05.reg_stress_testing_ui.cy.ts +++ b/web/cypress/e2e/incidents/regression/05.reg_stress_testing_ui.cy.ts @@ -34,6 +34,7 @@ const MAX_GAP_RELAXED = 500; describe('Regression: Stress Testing UI', { tags: ['@incidents'] }, () => { before(() => { cy.beforeBlockCOO(MCP, MP, { dashboards: false, troubleshootingPanel: false }); + incidentsPage.warmUpForPlugin(); }); it('5.1 No excessive padding between chart top and alert bars for 100, 200, and 500 alerts', () => { diff --git a/web/cypress/fixtures/incident-scenarios/silenced-and-firing-mixed-severity.yaml b/web/cypress/fixtures/incident-scenarios/silenced-and-firing-mixed-severity.yaml new file mode 100644 index 000000000..d443123d8 --- /dev/null +++ b/web/cypress/fixtures/incident-scenarios/silenced-and-firing-mixed-severity.yaml @@ -0,0 +1,32 @@ +name: "Silenced and Firing Mixed Severity" +description: "One silenced critical incident (resolved) and one firing warning incident." +incidents: + - id: "silenced-critical-resolved-001" + component: "monitoring" + layer: "core" + timeline: + start: "4h" + end: "1h" + alerts: + - name: "SilencedCriticalAlert001" + namespace: "openshift-monitoring" + severity: "critical" + firing: false + silenced: true + timeline: + start: "3h" + end: "2h" + + - id: "firing-warning-unsilenced-001" + component: "network" + layer: "core" + timeline: + start: "2h" + alerts: + - name: "FiringWarningAlert001" + namespace: "openshift-network" + severity: "warning" + firing: true + silenced: false + timeline: + start: "1h" diff --git a/web/cypress/reports/test-stability.md b/web/cypress/reports/test-stability.md new file mode 100644 index 000000000..4ddaca0fc --- /dev/null +++ b/web/cypress/reports/test-stability.md @@ -0,0 +1,169 @@ +# Test Stability Ledger + +Tracks incident detection test stability across local and CI iteration runs. Updated automatically by `/cypress:test-iteration:iterate-incident-tests` and `/cypress:test-iteration:iterate-ci-flaky`. + +## How to Read + +- **Pass rate**: percentage across all recorded runs (local + CI combined) +- **Trend**: direction over last 3 runs +- **Last failure**: most recent failure reason and which run it occurred in +- **Fixed by**: commit that resolved the issue (if applicable) + +## Current Status + +| Test | Pass Rate | Trend | Runs | Last Failure | Fixed By | +|------|-----------|-------|------|-------------|----------| +| BVT: Incidents - 1.1 Toolbar and charts toggle functionality | 100% | stable | 7 | — | — | +| BVT: Incidents - 1.2 Incidents chart renders with bars | 100% | stable | 7 | — | — | +| BVT: Incidents - 1.3 Incidents table renders with rows | 100% | stable | 7 | — | — | +| BVT: Incidents - 1.4 Charts and alerts empty state | 100% | stable | 7 | — | — | +| BVT: Incidents - 1.5 Traverse Incident Table | 100% | stable | 7 | 2026-04-16: plugin tab timeout (80s) | 0cb566d (warmUpForPlugin in goTo + BVT before) | +| Regression: Filtering - 1. Severity filtering | 100% | stable | 7 | — | — | +| Regression: Filtering - 2. Chart interaction with active filters | 100% | stable | 7 | — | — | +| Regression: Charts UI - 2.1 Chart renders with correct bar count | 100% | stable | 7 | — | — | +| Regression: Charts UI - 2.2 Chart bars have correct severity colors | 100% | stable | 7 | — | — | +| Regression: Charts UI - 2.3 Toggle charts button hides/shows chart | 100% | stable | 7 | — | — | +| Regression: Charts UI - 2.4 Incident selection updates alert chart | 100% | stable | 7 | — | — | +| Regression: Silences - 3.1 Silenced alerts not shown as active | 100% | stable | 7 | — | — | +| Regression: Silences - 3.2 Mixed silenced and firing alerts | 100% | stable | 7 | — | — | +| Regression: Redux - 4.1 Redux state updates on filter change | 100% | stable | 7 | — | — | +| Regression: Redux - 4.2 Redux state persists across navigation | 100% | stable | 7 | — | — | +| Regression: Redux - 4.3 Days selector updates redux state | 100% | stable | 7 | — | — | +| Regression: Stress Testing - 5.1 No excessive padding | 100% | stable | 7 | — | — | + +## Run History + +### Run Log + +| # | Date | Type | Cluster | Tests | Passed | Failed | Flaky | Commit | +|---|------|------|---------|-------|--------|--------|-------|--------| +| 1 | 2026-04-16 | local | ci-ln-trfv3nt (cluster 1) | 17 | 17 | 0 | 0 | 567c2e7 | +| 2 | 2026-04-16 | local | ci-ln-trfv3nt (cluster 1) | 17 | 17 | 0 | 0 | 567c2e7 | +| 3 | 2026-04-16 | local | ci-ln-trfv3nt (cluster 1) | 17 | 17 | 0 | 0 | 567c2e7 | +| 4 | 2026-04-16 | e2e-real | ci-ln-trfv3nt (cluster 1) | 1 | 1 | 0 | 0 | 92bba27 | +| 5 | 2026-04-16 | local | ci-ln-zgwt0qt (cluster 2) | 17 | 17 | 0 | 0 | 580dc96 | +| 6 | 2026-04-17 | local | ci-ln-lg6ry1t (cluster 3) | 17 | 17 | 0 | 0 | 580dc96 | +| 7 | 2026-04-17 | local | ci-ln-lg6ry1t (cluster 3) | 17 | 17 | 0 | 0 | d9f37d2 | +| 8 | 2026-04-22 | local | ci-ln-y7v0t92 (cluster 4) | 17 | 17 | 0 | 0 | 0cb566d | + + diff --git a/web/cypress/views/incidents-page.ts b/web/cypress/views/incidents-page.ts index b47fb639a..1044de5cb 100644 --- a/web/cypress/views/incidents-page.ts +++ b/web/cypress/views/incidents-page.ts @@ -1,6 +1,36 @@ import { nav } from './nav'; import { DataTestIDs } from '../../src/components/data-test'; +// Hard timeout safety net for findIncidentWithAlert retry loops. +// Prevents infinite loops if cy.waitUntil's timeout mechanism fails. +let _findIncidentSearchStart: number | null = null; +const _FIND_INCIDENT_HARD_TIMEOUT_MS = 35 * 60 * 1000; // 35 minutes + +// When true, search methods suppress Cypress command logging to prevent +// DOM snapshot accumulation that causes OOM (exit 137) in CI containers. +// Toggled by findIncidentWithAlert during its retry loop. +let _quietSearch = false; +const _qLog = (): { log: false } | Record => (_quietSearch ? { log: false } : {}); + +const _resetSearchState = () => { + _findIncidentSearchStart = null; + _quietSearch = false; +}; + +// Selector for the Incidents tab rendered by the Console SDK's HorizontalNav. +// The Console assigns data-test-id="horizontal-link-" to each tab. +const _INCIDENTS_TAB_SELECTOR = '[data-test-id="horizontal-link-incidents"]'; + +// Selector for bar group containers in the incidents chart (one per incident). +const _BAR_GROUP_SELECTOR = 'g[role="presentation"][data-test*="incidents-chart-bar-"]'; + +// Filter predicate for visible path segments (fill-opacity > 0). +// Multi-severity incidents have placeholder paths with zero opacity. +const _isVisiblePath = (_: number, el: HTMLElement) => { + const opacity = Cypress.$(el).css('fill-opacity') || Cypress.$(el).attr('fill-opacity'); + return parseFloat(opacity || '0') > 0; +}; + export const incidentsPage = { // Centralized element selectors - all selectors defined in one place elements: { @@ -70,17 +100,16 @@ export const incidentsPage = { incidentsChartBar: (groupId: string) => cy.byTestID(`${DataTestIDs.IncidentsChart.ChartBar}-${groupId}`), incidentsChartBarsVisiblePaths: () => { - return cy.get('body').then(($body) => { + return cy.get('body', _qLog()).then(($body) => { // There is a delay between the element being rendered and the paths being visible. - // The case when no paths are visible is valid, so we can not use should or conditional - // testing semantics. - cy.wait(500); + // The case when no paths are visible is valid, so we can not use should + // or conditional testing semantics. + cy.wait(500, _qLog()); // We need to use the $body as both cases when the element is there or not are valid. - const exists = - $body.find('g[role="presentation"][data-test*="incidents-chart-bar-"]').length > 0; + const exists = $body.find(_BAR_GROUP_SELECTOR).length > 0; if (exists) { return cy - .get('g[role="presentation"][data-test*="incidents-chart-bar-"]') + .get(_BAR_GROUP_SELECTOR, _qLog()) .find('path[role="presentation"]') .filter((index, element) => { const fillOpacity = @@ -88,8 +117,8 @@ export const incidentsPage = { return parseFloat(fillOpacity || '0') > 0; }); } else { - cy.log('Chart bars were not found. Test continues.'); - return cy.wrap([]); + if (!_quietSearch) cy.log('Chart bars were not found. Test continues.'); + return cy.wrap([], _qLog()); } }); }, @@ -106,9 +135,7 @@ export const incidentsPage = { }); }, incidentsChartBarsGroups: () => - cy - .byTestID(DataTestIDs.IncidentsChart.ChartBars) - .find('g[role="presentation"][data-test*="incidents-chart-bar-"]'), + cy.byTestID(DataTestIDs.IncidentsChart.ChartBars).find(_BAR_GROUP_SELECTOR), incidentsChartSvg: () => incidentsPage.elements.incidentsChartCard().find('svg'), alertsChartTitle: () => cy.byTestID(DataTestIDs.AlertsChart.Title), @@ -195,12 +222,35 @@ export const incidentsPage = { }, goTo: () => { - cy.log('incidentsPage.goTo'); + if (!_quietSearch) cy.log('incidentsPage.goTo'); nav.sidenav.clickNavLink(['Observe', 'Alerting']); + incidentsPage.waitForIncidentsTab(); nav.tabs.switchTab('Incidents'); incidentsPage.elements.daysSelectToggle().should('be.visible'); }, + // Used in before() hooks as a warm-up to ensure the monitoring-console-plugin has fully + // registered the Incidents tab extension before beforeEach() runs. Uses a 3-minute timeout + // because plugin registration after session restoration can take 80-120 seconds. + warmUpForPlugin: () => { + cy.log('incidentsPage.warmUpForPlugin: waiting for monitoring-console-plugin Incidents tab'); + nav.sidenav.clickNavLink(['Observe', 'Alerting']); + incidentsPage.waitForIncidentsTab(); + nav.tabs.switchTab('Incidents'); + incidentsPage.elements.daysSelectToggle().should('be.visible'); + }, + + // Polls for the Incidents tab to appear in the horizontal nav using a + // synchronous jQuery check (no Cypress command overhead / DOM snapshots). + // Shared by goTo() and warmUpForPlugin(). + waitForIncidentsTab: () => { + cy.waitUntil(() => Cypress.$(_INCIDENTS_TAB_SELECTOR).length > 0, { + interval: 2000, + timeout: 180000, + errorMsg: 'Incidents tab not registered within 3 minutes', + }); + }, + setDays: (value: '1 day' | '3 days' | '7 days' | '15 days') => { cy.log('incidentsPage.setDays'); incidentsPage.elements.daysSelectToggle().scrollIntoView().click(); @@ -285,10 +335,7 @@ export const incidentsPage = { throw new Error(`Failed to deselect "${name}" via dropdown after ${attempt} attempts`); } - cy.log( - `Retrying deselection for "${name}" because option click was - visible but selection state did not change`, - ); + cy.log(`Retrying deselection for "${name}": clicked but selection state unchanged`); return attemptDeselect(attempt + 1); }); }; @@ -297,38 +344,101 @@ export const incidentsPage = { }); }, - toggleFilter: (name: 'Critical' | 'Warning' | 'Informative' | 'Firing' | 'Resolved') => { - cy.log('incidentsPage.toggleFilter'); - const isSeverityFilter = ['Critical', 'Warning', 'Informative'].includes(name); - const filterType = isSeverityFilter ? 'Severity' : 'State'; - const valueToggleSelector = isSeverityFilter - ? `[data-test="${DataTestIDs.IncidentsPage.FiltersSelectToggle}-severity"]` - : `[data-test="${DataTestIDs.IncidentsPage.FiltersSelectToggle}-state"]`; - - cy.wait(500); + // The first-level filter type option for Incident ID uses a hardcoded + // data-test suffix "incident-id" (hyphenated), unlike Severity/State which + // use the lowercased name directly. This map resolves the mismatch. + _filterTypeDataTestSuffix: { + Severity: 'severity', + State: 'state', + 'Incident ID': 'incident-id', + } as Record, + + // The second-level filter selectors (toggle, list, options) use the + // lowercased categoryName from ToolbarItemFilter — with a space for + // Incident ID. + _filterValueKey: { + Severity: 'severity', + State: 'state', + 'Incident ID': 'incident id', + } as Record, + + ensureFilterTypeSelected: (filterType: 'Severity' | 'State' | 'Incident ID') => { + const valueKey = incidentsPage._filterValueKey[filterType]; + const toggleId = DataTestIDs.IncidentsPage.FiltersSelectToggle; + const valueToggleSelector = `[data-test="${toggleId}-${valueKey}"]`; + + cy.wait(500, _qLog()); // If the value toggle is already visible (filter type already selected), skip // re-selecting the filter type. Re-selecting triggers a React re-render and can // leave PatternFly Select in a stale state where options render but ignore clicks. - cy.get('body').then(($body) => { + cy.get('body', _qLog()).then(($body) => { const isToggleVisible = $body.find(valueToggleSelector).filter(':visible').length > 0; - if (!isToggleVisible) { - incidentsPage.elements.filtersSelectToggle().click(); - incidentsPage.elements.filtersSelectOption(filterType).click(); - } - - if (isSeverityFilter) { - incidentsPage.toggleFilterValueFromDropdown('severity', name); - } else { - incidentsPage.toggleFilterValueFromDropdown('state', name); + incidentsPage.elements.filtersSelectToggle().scrollIntoView().click(); + const suffix = incidentsPage._filterTypeDataTestSuffix[filterType]; + cy.byTestID(`${DataTestIDs.IncidentsPage.FiltersSelectOption}-${suffix}`) + .scrollIntoView() + .click(); } }); }, + toggleFilter: (name: 'Critical' | 'Warning' | 'Informative' | 'Firing' | 'Resolved') => { + cy.log('incidentsPage.toggleFilter'); + + const isSeverityFilter = ['Critical', 'Warning', 'Informative'].includes(name); + const filterType = isSeverityFilter ? 'Severity' : 'State'; + + incidentsPage.ensureFilterTypeSelected(filterType); + + if (isSeverityFilter) { + incidentsPage.toggleFilterValueFromDropdown('severity', name); + } else { + incidentsPage.toggleFilterValueFromDropdown('state', name); + } + }, + + selectIncidentIdFilter: (incidentId: string) => { + if (!_quietSearch) cy.log(`incidentsPage.selectIncidentIdFilter: ${incidentId}`); + + incidentsPage.ensureFilterTypeSelected('Incident ID'); + incidentsPage.elements.incidentIdFilterToggle().scrollIntoView().click({ force: true }); + incidentsPage.elements.incidentIdFilterList().should('be.visible'); + cy.wait(250, _qLog()); + incidentsPage.elements.incidentIdFilterOption(incidentId).scrollIntoView().click(); + // Incident ID dropdown auto-closes on select (single-select behavior) + }, + + getIncidentIds: (): Cypress.Chainable => { + if (!_quietSearch) cy.log('incidentsPage.getIncidentIds'); + + incidentsPage.ensureFilterTypeSelected('Incident ID'); + incidentsPage.elements.incidentIdFilterToggle().scrollIntoView().click({ force: true }); + incidentsPage.elements.incidentIdFilterList().should('be.visible'); + + return incidentsPage.elements + .incidentIdFilterList() + .find('button[role="menuitem"] .pf-v6-c-menu__item-text') + .then(($texts) => { + const ids: string[] = []; + $texts.each((_, el) => { + const text = Cypress.$(el).text().trim(); + if (text) ids.push(text); + }); + // Close the dropdown without selecting + incidentsPage.elements.incidentIdFilterToggle().scrollIntoView().click({ force: true }); + return cy.wrap(ids, _qLog()); + }); + }, + clearAllFilters: () => { cy.log('incidentsPage.clearAllFilters'); - incidentsPage.elements.clearAllFiltersButton().should('be.visible').click({ force: true }); + incidentsPage.elements + .clearAllFiltersButton() + .scrollIntoView() + .should('be.visible') + .click({ force: true }); }, removeFilter: (category: 'Severity' | 'State' | 'Incident ID', value: string) => { @@ -359,41 +469,39 @@ export const incidentsPage = { }, /** - * Selects an incident from the chart by clicking on a bar at the specified index. - * BUG: Problems with multi-severity incidents (multiple paths in a single incident bar) + * Selects an incident from the chart by clicking on a bar group at the + * specified index. Uses bar groups (one per incident) instead of flattened + * paths to correctly handle multi-severity incidents. * * @param index - Zero-based index of the incident bar to click (default: 0) * @returns Promise that resolves when the incidents table is visible */ selectIncidentByBarIndex: (index = 0) => { - cy.log(`incidentsPage.selectIncidentByBarIndex: ${index} (clicking visible path elements)`); + if (!_quietSearch) cy.log(`incidentsPage.selectIncidentByBarIndex: ${index}`); return incidentsPage.elements - .incidentsChartBarsVisiblePaths() + .incidentsChartBarsGroups() .should('have.length.greaterThan', index) - .then(($paths) => { - if (index >= $paths.length) { - throw new Error(`Index ${index} exceeds available paths (${$paths.length})`); - } - - return cy.wrap($paths.eq(index)).click({ force: true }); - }) + .eq(index) + .find('path[role="presentation"]') + .filter(_isVisiblePath) + .first() + .click({ force: true, ..._qLog() }) .then(() => { - cy.wait(2000); + cy.wait(2000, _qLog()); return incidentsPage.elements.incidentsTable().scrollIntoView().should('exist'); }); }, - deselectIncidentByBar: () => { - cy.log('incidentsPage.deselectIncidentByBar'); + deselectIncidentByBar: (index = 0) => { + if (!_quietSearch) cy.log('incidentsPage.deselectIncidentByBar'); return incidentsPage.elements - .incidentsChartBarsVisiblePaths() - .then(($paths) => { - if ($paths.length === 0) { - throw new Error('No paths found in incidents chart'); - } - return cy.wrap($paths.eq(0)).click({ force: true }); - }) + .incidentsChartBarsGroups() + .eq(index) + .find('path[role="presentation"]') + .filter(_isVisiblePath) + .first() + .click({ force: true, ..._qLog() }) .then(() => { return incidentsPage.elements.incidentsTable().should('not.exist'); }); @@ -445,8 +553,8 @@ export const incidentsPage = { }, expandRow: (rowIndex = 0) => { - cy.log('incidentsPage.expandRow'); - incidentsPage.elements.incidentsTableExpandButton(rowIndex).click({ force: true }); + if (!_quietSearch) cy.log('incidentsPage.expandRow'); + incidentsPage.elements.incidentsTableExpandButton(rowIndex).click({ force: true, ..._qLog() }); }, waitForTooltip: () => { @@ -521,9 +629,9 @@ export const incidentsPage = { cy.log(`incidentsPage.hoverOverIncidentBarSegment: bar=${barIndex}, segment=${segmentIndex}`); incidentsPage.getIncidentBarVisibleSegments(barIndex).then((segments) => { if (segmentIndex >= segments.length) { + const visCount = segments.length; throw new Error( - `Segment ${segmentIndex} not found (only ${segments.length} - visible segments in bar ${barIndex})`, + `Segment ${segmentIndex} not found — only ${visCount} segments in bar ${barIndex}`, ); } const path = segments[segmentIndex]; @@ -600,6 +708,10 @@ export const incidentsPage = { return incidentsPage.waitForTooltip(); }, + resetSearchTimeout: () => { + _resetSearchState(); + }, + // Constants for search configuration SEARCH_CONFIG: { CHART_LOAD_WAIT: 1000, @@ -607,11 +719,11 @@ export const incidentsPage = { }, prepareIncidentsPageForSearch: () => { - cy.log('incidentsPage.prepareIncidentsPageForSearch: Setting up page for search'); + if (!_quietSearch) cy.log('incidentsPage.prepareIncidentsPageForSearch: Setting up page...'); incidentsPage.goTo(); incidentsPage.setDays(incidentsPage.SEARCH_CONFIG.DEFAULT_DAYS); incidentsPage.elements.incidentsChartContainer().should('be.visible'); - cy.wait(incidentsPage.SEARCH_CONFIG.CHART_LOAD_WAIT); + cy.wait(incidentsPage.SEARCH_CONFIG.CHART_LOAD_WAIT, _qLog()); }, /** @@ -631,10 +743,9 @@ export const incidentsPage = { .then((text) => { if (String(text).includes(alertName)) { cy.log(`Found alert "${alertName}" in incident ${incidentIndex + 1} table content`); - cy.log(text); - return cy.wrap(true); + return cy.wrap(true, _qLog()); } - return cy.wrap(false); + return cy.wrap(false, _qLog()); }); }, @@ -655,23 +766,21 @@ export const incidentsPage = { currentRowIndex: number = 0, ): Cypress.Chainable => { if (currentRowIndex >= totalRows) { - cy.log(`Checked all ${totalRows} rows in incident ${incidentIndex + 1}, alert not found`); - return cy.wrap(false); + if (!_quietSearch) + cy.log(`Checked all ${totalRows} rows in incident ${incidentIndex + 1}, alert not found`); + return cy.wrap(false, _qLog()); } - cy.log(`Expanding and checking row ${currentRowIndex} in incident ${incidentIndex + 1}`); + if (!_quietSearch) + cy.log(`Expanding and checking row ${currentRowIndex} in incident ${incidentIndex + 1}`); incidentsPage.expandRow(currentRowIndex); return incidentsPage .checkComponentRowInIncidentTableForAlert(alertName, incidentIndex) .then((found) => { if (found) { - cy.log( - `Found alert "${alertName}" in expanded row ${currentRowIndex} of incident ${ - incidentIndex + 1 - }`, - ); - return cy.wrap(true); + cy.log(`Found "${alertName}" in row ${currentRowIndex}, incident ${incidentIndex + 1}`); + return cy.wrap(true, _qLog()); } return incidentsPage.checkComponentInIncident( alertName, @@ -683,8 +792,8 @@ export const incidentsPage = { }, /** - * Searches for an alert in all components (usually connected with namespaces) of a single - * incident. + * Searches for an alert in all components (usually connected with namespaces) + * of a single incident. * First checks main table content, then recursively expands and checks each row. * * @param alertName - Name of the alert to search for @@ -695,17 +804,16 @@ export const incidentsPage = { alertName: string, incidentIndex: number, ): Cypress.Chainable => { - cy.log( - `incidentsPage.searchAllRowsInIncident: Checking all rows in incident ${ - incidentIndex + 1 - } for alert "${alertName}"`, - ); + if (!_quietSearch) + cy.log( + `incidentsPage.searchAllRowsInIncident: incident ${incidentIndex + 1} for "${alertName}"`, + ); return incidentsPage .checkComponentRowInIncidentTableForAlert(alertName, incidentIndex) .then((foundInMain) => { if (foundInMain) { - return cy.wrap(true); + return cy.wrap(true, _qLog()); } return incidentsPage.elements @@ -714,36 +822,33 @@ export const incidentsPage = { .then(($rows) => { const totalRows = $rows.length; if (totalRows === 0) { - cy.log(`No rows found in incident ${incidentIndex + 1}`); - return cy.wrap(false); + if (!_quietSearch) cy.log(`No rows found in incident ${incidentIndex + 1}`); + return cy.wrap(false, _qLog()); } - cy.log(`Found ${totalRows} incident rows to check in incident ${incidentIndex + 1}`); + if (!_quietSearch) + cy.log(`Found ${totalRows} incident rows to check in incident ${incidentIndex + 1}`); return incidentsPage.checkComponentInIncident(alertName, incidentIndex, totalRows); }); }); }, /** - * Searches for an alert within a specific incident by selecting it and checking all components. - * Combines incident selection with comprehensive component search. - * - * @param alertName - Name of the alert to search for - * @param incidentIndex - Zero-based index of the incident to search - * @returns Promise resolving to true if alert is found in the incident + * @deprecated Use findIncidentWithAlert (filter-based) instead. + * Searches for an alert within a specific incident by selecting it via bar click. */ - searchForAlertInIncident: ( + searchForAlertInIncident_legacy: ( alertName: string, incidentIndex: number, ): Cypress.Chainable => { - cy.log( - `incidentsPage.searchForAlertInIncident: Checking incident ${ - incidentIndex + 1 - } for alert "${alertName}"`, - ); + if (!_quietSearch) + cy.log( + `incidentsPage.searchForAlertInIncident_legacy: ` + + `incident ${incidentIndex + 1} for "${alertName}"`, + ); return cy - .wrap(null) + .wrap(null, _qLog()) .then(() => { incidentsPage.selectIncidentByBarIndex(incidentIndex); return null; @@ -752,69 +857,129 @@ export const incidentsPage = { }, /** - * Recursively traverses all incident bars in the chart, searching each one for a specific - * alert. - * Uses internal recursive function to systematically check each incident until found or - * exhausted. - * - * @param alertName - Name of the alert to search for - * @param totalIncidents - Total number of incidents to traverse - * @returns Promise resolving to true if alert is found in any incident + * @deprecated Use findIncidentWithAlert (filter-based) instead. + * Recursively traverses all incident bars in the chart via click-based selection. */ - traverseAllIncidentsBars: ( + traverseAllIncidentsBars_legacy: ( alertName: string, totalIncidents: number, ): Cypress.Chainable => { - cy.log( - `incidentsPage.searchAllIncidents: Searching ${totalIncidents} incidents for alert ` + - `"${alertName}"`, - ); + if (!_quietSearch) + cy.log( + `incidentsPage.traverseAllIncidentsBars_legacy: ` + + `Searching ${totalIncidents} for "${alertName}"`, + ); const searchNextIncidentBar = (currentIndex: number): Cypress.Chainable => { if (currentIndex >= totalIncidents) { - cy.log(`Checked all ${totalIncidents} incidents, alert "${alertName}" not found`); - return cy.wrap(false); + if (!_quietSearch) + cy.log(`Checked all ${totalIncidents} incidents, alert "${alertName}" not found`); + return cy.wrap(false, _qLog()); } - return incidentsPage.searchForAlertInIncident(alertName, currentIndex).then((found) => { - if (found) { - return cy.wrap(true); - } - incidentsPage.deselectIncidentByBar(); - // Wait for the incident to be deselected - // Quick workaround, could be improved by waiting for the number of paths to change, but - // it does not has to if 1 initially. The check for the alert table non existance is - // already implemented, - // but there seems to be a short delay between the alert table closing and new bars - // rendering. - cy.wait(500); - return searchNextIncidentBar(currentIndex + 1); - }); + return incidentsPage + .searchForAlertInIncident_legacy(alertName, currentIndex) + .then((found) => { + if (found) { + return cy.wrap(true, _qLog()); + } + incidentsPage.deselectIncidentByBar(currentIndex); + cy.wait(500, _qLog()); + return searchNextIncidentBar(currentIndex + 1); + }); }; return searchNextIncidentBar(0); }, + /** + * Selects an incident via the Incident ID filter, searches all its + * components for the given alert, then clears the filter. + */ + searchForAlertInIncidentByFilter: ( + alertName: string, + incidentId: string, + ): Cypress.Chainable => { + if (!_quietSearch) + cy.log(`incidentsPage.searchForAlertInIncidentByFilter: "${incidentId}" for "${alertName}"`); + + incidentsPage.selectIncidentIdFilter(incidentId); + cy.wait(2000, _qLog()); + + return incidentsPage.elements + .incidentsTable() + .should('exist') + .scrollIntoView() + .then(() => incidentsPage.searchAllComponentsInIncident(alertName, 0)) + .then((found: boolean) => { + incidentsPage.clearAllFilters(); + cy.wait(500, _qLog()); + return cy.wrap(found, _qLog()); + }); + }, + + /** + * Discovers all incident IDs from the filter dropdown, then searches + * each one for the given alert using filter-based selection. + */ + traverseAllIncidentsByFilter: (alertName: string): Cypress.Chainable => { + if (!_quietSearch) + cy.log(`incidentsPage.traverseAllIncidentsByFilter: discovering incident IDs`); + + return incidentsPage.getIncidentIds().then((incidentIds) => { + if (incidentIds.length === 0) { + return cy.wrap(false, _qLog()); + } + + let result = cy.wrap(false, _qLog()); + for (let i = 0; i < incidentIds.length; i++) { + result = result.then((found) => { + if (found) return cy.wrap(true, _qLog()); + return incidentsPage.searchForAlertInIncidentByFilter(alertName, incidentIds[i]); + }); + } + + return result.then((found) => { + if (!found && !_quietSearch) + cy.log(`Checked all ${incidentIds.length} incidents, alert "${alertName}" not found`); + return cy.wrap(found, _qLog()); + }); + }); + }, + /** * Main entry point for finding an alert across all incidents in the chart. - * Prepares the page, gets visible incident bars, and initiates comprehensive search. + * Prepares the page, discovers incidents via the Incident ID filter dropdown, + * and searches each one by selecting it through the filter UI. * * @param alertName - Name of the alert to search for across all incidents * @returns Promise resolving to true if alert is found in any incident */ findIncidentWithAlert: (alertName: string): Cypress.Chainable => { + if (_findIncidentSearchStart === null) { + _findIncidentSearchStart = Date.now(); + } + const elapsed = Date.now() - _findIncidentSearchStart; + if (elapsed > _FIND_INCIDENT_HARD_TIMEOUT_MS) { + _resetSearchState(); + const mins = Math.round(elapsed / 60000); + throw new Error(`findIncidentWithAlert: hard timeout after ${mins} min for "${alertName}"`); + } + cy.log(`incidentsPage.findIncidentWithAlert: Starting search for alert "${alertName}"`); + _quietSearch = true; + incidentsPage.prepareIncidentsPageForSearch(); + incidentsPage.clearAllFilters(); - return incidentsPage.elements.incidentsChartBarsVisiblePaths().then(($paths) => { - const totalPaths = $paths.length; - if (totalPaths === 0) { - cy.log('No visible incident bar paths found in chart'); - return cy.wrap(false); + return incidentsPage.traverseAllIncidentsByFilter(alertName).then((found: boolean) => { + if (found) { + _resetSearchState(); + } else { + _quietSearch = false; } - - return incidentsPage.traverseAllIncidentsBars(alertName, totalPaths); + return found; }); },