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..781d3913f 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,17 @@ 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 three layers of OOM protection: + // 1. cy.reload() — releases browser DOM each iteration + // 2. _quietSearch — suppresses Cypress DOM snapshots + // 3. Hard timeout (35 min) — kills infinite loops + // This makes it safe to poll for the full 30-minute window. 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..71590a40c 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', ); 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..aa614f8ec 100644 --- a/web/cypress/views/incidents-page.ts +++ b/web/cypress/views/incidents-page.ts @@ -1,6 +1,40 @@ 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 +// (e.g., due to cy.reload() interfering with the Cypress command queue). +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; +}; + +// jQuery selector for the Incidents tab — covers both PatternFly v6 and +// legacy Console horizontal nav markup. Used by goTo()/warmUpForPlugin() +// to poll for plugin registration without Cypress command overhead. +const _INCIDENTS_TAB_SELECTOR = + '.pf-v6-c-tabs__item:contains("Incidents"), ' + + '.co-m-horizontal-nav__menu-item:contains("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 +104,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 +121,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 +139,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 +226,41 @@ export const incidentsPage = { }, goTo: () => { - cy.log('incidentsPage.goTo'); + if (!_quietSearch) cy.log('incidentsPage.goTo'); nav.sidenav.clickNavLink(['Observe', 'Alerting']); + // Wait for the Incidents tab to be registered by the dynamic plugin. + // After session restore the plugin may need up to 2 min to re-register. + 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 + // instead of the default 80s, because plugin registration after session restoration can + // take 80-120 seconds when the console reloads its dynamic plugin manifest. + warmUpForPlugin: () => { + cy.log('incidentsPage.warmUpForPlugin: waiting for monitoring-console-plugin Incidents tab'); + nav.sidenav.clickNavLink(['Observe', 'Alerting']); + // Wait up to 3 minutes for the Incidents tab to appear. Uses synchronous jQuery check + // inside cy.waitUntil() to avoid the 80s default command timeout, then uses + // nav.tabs.switchTab() which correctly clicks the button element (not the li wrapper). + 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 +345,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); }); }; @@ -299,6 +356,7 @@ 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 @@ -359,41 +417,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 +501,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 +577,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 +656,10 @@ export const incidentsPage = { return incidentsPage.waitForTooltip(); }, + resetSearchTimeout: () => { + _resetSearchState(); + }, + // Constants for search configuration SEARCH_CONFIG: { CHART_LOAD_WAIT: 1000, @@ -607,11 +667,15 @@ export const incidentsPage = { }, prepareIncidentsPageForSearch: () => { - cy.log('incidentsPage.prepareIncidentsPageForSearch: Setting up page for search'); + if (!_quietSearch) cy.log('incidentsPage.prepareIncidentsPageForSearch: Setting up page...'); + // Use SPA navigation instead of cy.reload() — the Incidents component is a + // dynamic plugin chunk, and cy.reload() causes the Console to re-resolve all + // plugins from scratch, which silently fails in headless CI (blank page). + // OOM is handled by _quietSearch suppressing DOM snapshots, not by reload. 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 +695,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 +718,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 +744,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 +756,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,11 +774,12 @@ 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); }); }); @@ -736,14 +797,13 @@ export const incidentsPage = { alertName: string, incidentIndex: number, ): Cypress.Chainable => { - cy.log( - `incidentsPage.searchForAlertInIncident: Checking incident ${ - incidentIndex + 1 - } for alert "${alertName}"`, - ); + if (!_quietSearch) + cy.log( + `incidentsPage.searchForAlertInIncident: incident ${incidentIndex + 1} for "${alertName}"`, + ); return cy - .wrap(null) + .wrap(null, _qLog()) .then(() => { incidentsPage.selectIncidentByBarIndex(incidentIndex); return null; @@ -752,10 +812,9 @@ 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. + * 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 @@ -765,29 +824,22 @@ export const incidentsPage = { alertName: string, totalIncidents: number, ): Cypress.Chainable => { - cy.log( - `incidentsPage.searchAllIncidents: Searching ${totalIncidents} incidents for alert ` + - `"${alertName}"`, - ); + if (!_quietSearch) + cy.log(`incidentsPage.searchAllIncidents: 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); + return cy.wrap(true, _qLog()); } - 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); + incidentsPage.deselectIncidentByBar(currentIndex); + cy.wait(500, _qLog()); return searchNextIncidentBar(currentIndex + 1); }); }; @@ -803,19 +855,48 @@ export const incidentsPage = { * @returns Promise resolving to true if alert is found in any incident */ findIncidentWithAlert: (alertName: string): Cypress.Chainable => { + // Hard timeout safety net: if waitUntil's timeout fails to trigger + // (e.g., cy.reload() breaks the command queue), this prevents a 2h+ hang. + 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}"`); + // Enable quiet mode to suppress Cypress DOM snapshots during the search. + // Each snapshot stores a serialized copy of the DOM (~1-5 MB). Without this, + // ~40 snapshots per search iteration * 15+ iterations = OOM in CI containers. + _quietSearch = true; + incidentsPage.prepareIncidentsPageForSearch(); - 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); - } + // Check for bar groups without asserting existence — an empty chart is + // valid (e.g. when mocking empty incidents or before detection fires). + return cy + .get('body', _qLog()) + .then(($body) => { + const totalIncidents = $body.find(_BAR_GROUP_SELECTOR).length; + if (totalIncidents === 0) { + if (!_quietSearch) cy.log('No incident bar groups found in chart'); + return cy.wrap(false, { log: false }); + } - return incidentsPage.traverseAllIncidentsBars(alertName, totalPaths); - }); + return incidentsPage.traverseAllIncidentsBars(alertName, totalIncidents); + }) + .then((found: boolean) => { + if (found) { + _resetSearchState(); + } else { + _quietSearch = false; + } + return found; + }); }, /**