diff --git a/docs/incident_detection/tests/3.api_calls_data_loading_flows.md b/docs/incident_detection/tests/3.api_calls_data_loading_flows.md index eacb53c00..11590e00f 100644 --- a/docs/incident_detection/tests/3.api_calls_data_loading_flows.md +++ b/docs/incident_detection/tests/3.api_calls_data_loading_flows.md @@ -1,11 +1,9 @@ ## 3. CRITICAL: Data Loading – API Call Bugs -**Automation Status**: PARTIALLY AUTOMATED (Sections 3.1 and 3.2) +**Automation Status**: PARTIALLY AUTOMATED (Sections 3.1, 3.2, and 3.4) ### Prerequisites: Test Data Setup for Data Loading Tests -**CSV file**: [`simulate_scenarios/data-loading-silences.csv`](../simulate_scenarios/data-loading-silences.csv) - **CSV Format** - These alerts test resolution, short duration, and silence logic (creates incidents F, G, I, J): ```csv @@ -86,26 +84,45 @@ start,end,alertname,namespace,severity,silenced,labels - Verify the latest query end time param is within the last 5 minutes -### 3.4 Many Alerts Break API Request (OU-632) -**BUG**: When an incident contains many alerts (100+), the Prometheus query for the Alerts endpoint becomes too large, resulting in a "Request Header Fields Too Large" (431) error. No alerts are rendered for that incident. -**Automation Status**: NOT AUTOMATED (requires live injected data on cluster) - -**Data Setup**: -Use the simulation script from the `cluster-health-analyzer` repository (`local/simulate.sh`) with the following CSVs from `docs/incident_detection/simulate_scenarios/`: -- `100-alerts-14-days.csv` — 100 alerts across 14 days (single incident, triggers the bug) -- `1000-alerts-15-min.csv` — 1000 alerts in 15 minutes (extreme stress scenario) - -- [ ] **100 Alerts Load Successfully**: Inject `100-alerts-14-days.csv` - - Navigate to Observe → Incidents - - Select the incident containing 100 alerts - - Verify alerts are rendered in the alerts chart (no blank view) - - Open browser console: verify no "Request Header Fields Too Large" error - -- [ ] **1000 Alerts Load Successfully**: Inject `1000-alerts-15-min.csv` - - Navigate to Observe → Incidents - - Select the incident containing 1000 alerts - - Verify alerts are rendered (may be slow but must not fail with a 431 error) - - Open browser console: verify no "Request Header Fields Too Large" error +### 3.4 15-Day Data Loading with "Last N Days" Filtering +**FEATURE**: UI always loads 15 days of data (one query_range call per day), then filters client-side based on "Last N Days" selection. +**Automation Status**: AUTOMATED +**Test file**: `web/cypress/e2e/incidents/regression/03.reg_15day_data_loading.cy.ts` +**Fixture**: `web/cypress/fixtures/incident-scenarios/19-15-day-data-loading.yaml` + +**Background**: +- Before: Data was downloaded only for "Last N Days", causing Start dates to be relative to N days +- After: Start displays an absolute date, even when "Last N Days" is shorter than the incident's actual start +- Limit: Start is capped at max 15 days (the maximum supported range) + +**Fix Implementation**: +The absolute start date of an incident/alert is always displayed, regardless of the selected "Last N Days" filter. + +Solution uses a new API call: +- Absolute timestamps are retrieved by performing an **instant query** call to Prometheus +- For incidents: `min_over_time(timestamp(cluster_health_components_map{}))` +- For alerts: `min_over_time(timestamp(ALERTS{}))` +- This returns the timestamp of the first datapoint for that metric +- The result is saved into Redux store and matched to related incident/alert to update the Start date displayed in the tooltip + +**Manual Testing Data**: +Use `docs/incident_detection/simulate_scenarios/long-incident-15-days.csv` which creates a 15-day spanning incident for testing absolute start date display. + +**Automated Coverage** (tests 1–4 in test file): +- [x] **Absolute Start Date Display** — AUTOMATED + - Tests 1–3 switch between 15-day, 7-day, and 3-day filters on a 14-day ongoing incident + - Collects start dates from four surfaces (incident table, alert table, incident tooltip, alert tooltip) + - Verifies all four start dates are identical and non-empty regardless of the selected filter (proves dates are absolute, not relative to the filter window) +- [x] **Escalating Severity Segment Stability** — AUTOMATED (test 4) + - Collects segment start dates at 15-day baseline, then switches to 7 days + - Verifies critical segment start date is unchanged when the info→warning boundary scrolls out of the visible window + - Verifies warning segment start date equals either the true segment start or the overall incident start when the info→warning boundary (10d ago) is outside the 7-day window + +**Remaining Manual Steps**: +- [ ] **API Call Pattern Verification**: Monitor network requests on initial page load (requires real Prometheus endpoint) + - Verify 15 query_range calls are made on initial page load (one per day) + - Verify instant query calls for `min_over_time(timestamp(cluster_health_components_map{}))` and `min_over_time(timestamp(ALERTS{}))` + - Verify the time ranges cover the full 15-day window regardless of "Last N Days" selection ### 3.5 Data Integrity **NEW, NOT AUTOMATED, TODO COO 1.4** @@ -114,13 +131,3 @@ Use the simulation script from the `cluster-health-analyzer` repository (`local/ - [ ] Component lists combined for same group_id - [ ] Watchdog alerts filtered out -### 3.6 Permission Denied Handling (OU-1213) -**BUG**: Page should gracefully handle 403 Forbidden responses from API endpoints. -**Automation Status**: AUTOMATED in `03.reg_api_calls.cy.ts` -- Uses mock: `cy.mockPermissionDenied({ rules: true, silences: true, prometheus: true })` -- Manual replication: Apply resources from [`docs/incident_detection/resources/`](../resources/) - -- [ ] **403 Forbidden Response**: Create user with limited permissions (testuser/password123) - - Apply: `htpasswd-secret.yaml`, `oauth-htpasswd.yaml`, `limited-permissions-user.yaml` - - Login as testuser, navigate to Observe → Incidents - - Expected: `` with "Restricted access" text 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..7d8e746e2 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 @@ -5,13 +5,25 @@ Test 1 (OU-1205): Verifies tooltip End times at severity interval boundaries are rounded to 5-minute precision. Without rounding, consecutive interval end times can land on non-5-minute values (e.g., "10:58" instead of "11:00"). -Test 2 (OU-1221, XFAIL): Verifies Start times shown in incident tooltips match -alert tooltips and alerts table, and that consecutive segment boundaries align -with no 5-minute gap between End of one segment and Start of the next. +Test 2 (OU-1221): Incident chart segment boundaries — start and end dates. +Collects all segment tooltip dates and asserts: + - Each segment has a distinct start date + - Consecutive boundary alignment: End[N] == Start[N+1] + +Test 3: Alert bar tooltip dates — start and end dates. +Collects all alert tooltip dates and asserts: + - Each alert has a distinct start date + - All start and end values are non-empty + +Test 4 (OU-1221): Cross-validation — incident chart segment Start dates match alert bar Start dates. +Test 5 (OU-1205): Cross-validation — incident chart segment End dates match alert bar End dates. +Test 6 (OU-1205): Cross-validation — alert bar tooltip Start and End dates match alerts table. + +Verifies: OU-1205, OU-1221 */ /* eslint-disable @typescript-eslint/no-unused-expressions */ -import { incidentsPage } from '../../../views/incidents-page'; +import { incidentsPage, SegmentDates, AlertDates } from '../../../views/incidents-page'; const MCP = { namespace: Cypress.env('COO_NAMESPACE'), @@ -28,169 +40,421 @@ const MP = { operatorName: 'Cluster Monitoring Operator', }; -describe( - 'Regression: Mixed Severity Interval Boundary Times', - { tags: ['@incidents', '@xfail'] }, - () => { - before(() => { - cy.beforeBlockCOO(MCP, MP, { dashboards: false, troubleshootingPanel: false }); +const ESCALATING_ID = 'ESCALATING-14d-monitoring-severity'; + +describe('Regression: Mixed Severity Interval Boundary Times', { tags: ['@incidents'] }, () => { + before(() => { + cy.beforeBlockCOO(MCP, MP, { dashboards: false, troubleshootingPanel: false }); + }); + + beforeEach(() => { + cy.log('Loading escalating severity fixture'); + cy.mockIncidentFixture('incident-scenarios/19-15-day-data-loading.yaml'); + }); + + const setupEscalatingIncident = () => { + incidentsPage.clearAllFilters(); + incidentsPage.setDays('15 days'); + incidentsPage.elements.incidentsChartContainer().should('be.visible'); + incidentsPage.elements.incidentsChartBarsGroups().should('have.length', 4); + }; + + const collectSegmentTooltip = (segmentIndex: number) => + incidentsPage.collectSegmentTooltip(ESCALATING_ID, segmentIndex); + + const collectAlertTooltip = (barIndex: number) => incidentsPage.collectAlertTooltip(barIndex); + + it('1. Tooltip End times at severity boundaries show 5-minute rounded values', () => { + const verifyEndTimeRounded = (label: string) => { + incidentsPage.elements + .tooltip() + .invoke('text') + .then((text) => { + cy.log(`${label} tooltip: "${text}"`); + + if (text.match(/End.*---/)) { + cy.log(`${label}: Firing, End shows --- (skipped)`); + return; + } + + const endPart = text.split('End')[1]; + expect(endPart, `${label}: should contain End time`).to.exist; + + const timeMatch = endPart.match(/(\d{1,2}):(\d{2})/); + expect(timeMatch, `${label}: End time should be parseable`).to.not.be.null; + + const minutes = parseInt(timeMatch[2], 10); + const remainder = minutes % 5; + expect( + remainder, + `${label}: End minutes (${minutes}) should be divisible by 5, remainder=${remainder}`, + ).to.equal(0); + cy.log(`${label}: End ${timeMatch[1]}:${timeMatch[2]} - minutes divisible by 5`); + }); + }; + + cy.log('1.1 Verify multi-severity incident loaded'); + setupEscalatingIncident(); + + cy.log('1.2 Verify escalating bar has multiple severity segments'); + incidentsPage.getIncidentBarVisibleSegmentCount(ESCALATING_ID).then((count) => { + expect(count, 'Should have at least 2 visible segments').to.be.greaterThan(1); + cy.log(`Found ${count} visible severity segments`); }); - beforeEach(() => { - cy.mockIncidentFixture('incident-scenarios/21-multi-severity-boundary-times.yaml'); + cy.log('1.3 Check first segment end time (Info -> Warning boundary)'); + incidentsPage.hoverOverIncidentBarById(ESCALATING_ID, 0); + verifyEndTimeRounded('First segment'); + + cy.log('1.4 Check second segment end time (Warning -> Critical boundary)'); + incidentsPage.hoverOverIncidentBarById(ESCALATING_ID, 1); + verifyEndTimeRounded('Second segment'); + + cy.log('1.5 Check third segment end time (Critical end)'); + incidentsPage.hoverOverIncidentBarById(ESCALATING_ID, 2); + verifyEndTimeRounded('Third segment'); + + cy.log( + 'Verified: All tooltip End times at severity boundaries are at 5-minute precision (OU-1205)', + ); + }); + + it('2. Incident chart segment boundaries - start and end dates align across severity transitions', () => { + let infoSegment: SegmentDates; + let warningSegment: SegmentDates; + let criticalSegment: SegmentDates; + + cy.log('2.1 Setup: verify escalating incident loaded with 3 segments'); + setupEscalatingIncident(); + incidentsPage.getIncidentBarVisibleSegmentCount(ESCALATING_ID).then((count) => { + expect(count).to.equal(3, 'Should have 3 visible severity segments'); }); - const extractTime = (tooltipText: string, field: 'Start' | 'End'): string => { - const afterField = tooltipText.split(field)[1] || ''; - const match = afterField.match(/(\d{1,2}:\d{2}(\s*[AP]M)?)/); - return match ? match[1].trim() : ''; - }; + cy.log('2.2 Collect incident segment tooltips (info, warning, critical)'); + collectSegmentTooltip(0).then((d) => { + infoSegment = d; + expect(d.severity).to.equal('Info'); + expect(d.start).to.not.be.empty; + expect(d.end).to.not.be.empty; + cy.log(`Info segment: Start=${d.start}, End=${d.end}`); + }); + collectSegmentTooltip(1).then((d) => { + warningSegment = d; + expect(d.severity).to.equal('Warning'); + expect(d.start).to.not.be.empty; + expect(d.end).to.not.be.empty; + cy.log(`Warning segment: Start=${d.start}, End=${d.end}`); + }); + collectSegmentTooltip(2).then((d) => { + criticalSegment = d; + expect(d.severity).to.equal('Critical'); + expect(d.start).to.not.be.empty; + cy.log(`Critical segment: Start=${d.start}, End=${d.end}`); + }); - it('1. Tooltip End times at severity boundaries show 5-minute rounded values', () => { - const verifyEndTimeRounded = (label: string) => { - incidentsPage.elements - .tooltip() - .invoke('text') - .then((text) => { - cy.log(`${label} tooltip: "${text}"`); - - if (text.match(/End.*---/)) { - cy.log(`${label}: Firing, End shows --- (skipped)`); - return; - } - - const endPart = text.split('End')[1]; - expect(endPart, `${label}: should contain End time`).to.exist; - - const timeMatch = endPart.match(/(\d{1,2}):(\d{2})/); - expect(timeMatch, `${label}: End time should be parseable`).to.not.be.null; - - const minutes = parseInt(timeMatch[2], 10); - const remainder = minutes % 5; - expect( - remainder, - `${label}: End minutes (${minutes}) should be divisible by 5, remainder=${remainder}`, - ).to.equal(0); - cy.log(`${label}: End ${timeMatch[1]}:${timeMatch[2]} - minutes divisible by 5`); - }); - }; - - cy.log('1.1 Verify multi-severity incident loaded'); - incidentsPage.clearAllFilters(); - incidentsPage.setDays('1 day'); - incidentsPage.elements.incidentsChartContainer().should('be.visible'); - incidentsPage.elements.incidentsChartBarsGroups().should('have.length', 1); - - cy.log('1.2 Verify bar has multiple severity segments'); - incidentsPage.getIncidentBarVisibleSegments(0).then((segments) => { - expect( - segments.length, - 'Multi-severity bar should have at least 2 visible segments', - ).to.be.greaterThan(1); - cy.log(`Found ${segments.length} visible severity segments`); - }); - - cy.log('1.3 Check first segment end time (Info -> Warning boundary)'); - incidentsPage.hoverOverIncidentBarSegment(0, 0); - verifyEndTimeRounded('First segment'); - - cy.log('1.4 Check second segment end time (Warning -> Critical boundary)'); - incidentsPage.hoverOverIncidentBarSegment(0, 1); - verifyEndTimeRounded('Second segment'); - - cy.log('1.5 Check third segment end time (Critical end)'); - incidentsPage.hoverOverIncidentBarSegment(0, 2); - verifyEndTimeRounded('Third segment'); + cy.log('2.3 Assert boundary alignment and distinct start dates'); + cy.then(() => { + cy.log('2.3.1 Each segment has a distinct start date'); + expect(infoSegment.start).to.not.equal( + warningSegment.start, + 'Info start should differ from Warning start', + ); + expect(warningSegment.start).to.not.equal( + criticalSegment.start, + 'Warning start should differ from Critical start', + ); - cy.log( - 'Verified: All tooltip End times at severity boundaries are at 5-minute precision (OU-1205)', + cy.log('2.3.2 Consecutive boundary alignment: End[N] == Start[N+1] (OU-1221)'); + expect(infoSegment.end).to.equal( + warningSegment.start, + `Info End (${infoSegment.end}) should equal Warning Start (${warningSegment.start})`, + ); + expect(warningSegment.end).to.equal( + criticalSegment.start, + `Warning End (${warningSegment.end}) should equal ` + + `Critical Start (${criticalSegment.start})`, + ); + + cy.log('Verified: Segment boundaries align and each segment has a distinct start date'); + }); + }); + + it('3. Alert bar tooltips - start and end dates are populated and distinct across severity levels', () => { + let alertInfo: AlertDates; + let alertWarning: AlertDates; + let alertCritical: AlertDates; + + cy.log('3.1 Setup: load incident, select escalating incident to reveal alert chart'); + setupEscalatingIncident(); + incidentsPage.selectIncidentById(ESCALATING_ID); + incidentsPage.elements.alertsChartContainer().should('be.visible'); + + cy.log('3.2 Collect alert bar tooltips (info, warning, critical)'); + collectAlertTooltip(0).then((d) => { + alertInfo = d; + expect(d.start).to.not.be.empty; + expect(d.end).to.not.be.empty; + cy.log(`Alert 0 (info): start=${d.start}, end=${d.end}`); + }); + collectAlertTooltip(1).then((d) => { + alertWarning = d; + expect(d.start).to.not.be.empty; + expect(d.end).to.not.be.empty; + cy.log(`Alert 1 (warning): start=${d.start}, end=${d.end}`); + }); + collectAlertTooltip(2).then((d) => { + alertCritical = d; + expect(d.start).to.not.be.empty; + cy.log(`Alert 2 (critical): start=${d.start}, end=${d.end}`); + }); + + cy.log('3.3 Assert alert start dates are distinct across severity levels'); + cy.then(() => { + expect(alertInfo.start).to.not.equal( + alertWarning.start, + 'Info alert start should differ from Warning alert start', ); + expect(alertWarning.start).to.not.equal( + alertCritical.start, + 'Warning alert start should differ from Critical alert start', + ); + + cy.log('Verified: Alert tooltip start and end dates populated and distinct'); }); + }); - it('2. Start times match between incident tooltip, alert tooltip, and table; consecutive boundaries align', () => { - cy.log('2.1 Setup: verify multi-severity incident loaded'); - incidentsPage.clearAllFilters(); - incidentsPage.setDays('1 day'); - incidentsPage.elements.incidentsChartContainer().should('be.visible'); - incidentsPage.elements.incidentsChartBarsGroups().should('have.length', 1); - cy.pause(); + it('4. Cross-validation: incident chart segment Start dates match alert bar Start dates', () => { + let infoSegment: SegmentDates; + let warningSegment: SegmentDates; + let criticalSegment: SegmentDates; + let alertInfo: AlertDates; + let alertWarning: AlertDates; + let alertCritical: AlertDates; + cy.log('4.1 Setup and collect incident segment tooltip Start dates'); + setupEscalatingIncident(); + collectSegmentTooltip(0).then((d) => { + infoSegment = d; + cy.log(`Info segment: Start=${d.start}`); + }); + collectSegmentTooltip(1).then((d) => { + warningSegment = d; + cy.log(`Warning segment: Start=${d.start}`); + }); + collectSegmentTooltip(2).then((d) => { + criticalSegment = d; + cy.log(`Critical segment: Start=${d.start}`); + }); + + cy.log('4.2 Select incident and collect alert bar tooltip Start dates'); + incidentsPage.selectIncidentById(ESCALATING_ID); + incidentsPage.elements.alertsChartContainer().should('be.visible'); + collectAlertTooltip(0).then((d) => { + alertInfo = d; + cy.log(`Alert 0 (info): start=${d.start}`); + }); + collectAlertTooltip(1).then((d) => { + alertWarning = d; + cy.log(`Alert 1 (warning): start=${d.start}`); + }); + collectAlertTooltip(2).then((d) => { + alertCritical = d; + cy.log(`Alert 2 (critical): start=${d.start}`); + }); + + cy.log('4.3 Assert incident tooltip Start matches alert tooltip Start per segment'); + cy.then(() => { + expect(infoSegment.start).to.equal( + alertInfo.start, + `Incident info Start (${infoSegment.start}) should match ` + + `alert info Start (${alertInfo.start})`, + ); + expect(warningSegment.start).to.equal( + alertWarning.start, + `Incident warning Start (${warningSegment.start}) ` + + `should match alert warning Start (${alertWarning.start})`, + ); + expect(criticalSegment.start).to.equal( + alertCritical.start, + `Incident critical Start (${criticalSegment.start}) ` + + `should match alert critical Start (${alertCritical.start})`, + ); + cy.log('Verified: Incident chart segment Start dates match alert bar Start dates'); + }); + + cy.log( + 'Expected failure: OU-1221 (start time 5-min offset between incident and alert tooltips)', + ); + }); + + it('5. Cross-validation: last incident chart segment End date matches last alert bar End date', () => { + // Info and warning alert bars overlap into the next severity period, so the incident + // chart trims their segment End to the severity change boundary — these won't match + // the raw alert End. Only the last (critical) segment has no trimming applied. + let criticalSegment: SegmentDates; + let alertCritical: AlertDates; + + cy.log('5.1 Setup and collect incident segment tooltip End dates'); + setupEscalatingIncident(); + collectSegmentTooltip(0).then((d) => { cy.log( - '2.2 Consecutive interval boundaries: End of segment 1 should equal Start of segment 2', + `Info segment: End=${d.end} (trimmed to severity boundary, not expected to match alert)`, ); - incidentsPage.hoverOverIncidentBarSegment(0, 0); - incidentsPage.elements - .tooltip() + }); + collectSegmentTooltip(1).then((d) => { + cy.log( + `Warning segment: End=${d.end} (trimmed to severity boundary, not expected to match alert)`, + ); + }); + collectSegmentTooltip(2).then((d) => { + criticalSegment = d; + cy.log(`Critical segment: End=${d.end}`); + }); + + cy.log('5.2 Select incident and collect alert bar tooltip End date for last alert'); + incidentsPage.selectIncidentById(ESCALATING_ID); + incidentsPage.elements.alertsChartContainer().should('be.visible'); + collectAlertTooltip(0).then((d) => { + cy.log(`Alert 0 (info): end=${d.end} (raw alert end, extends past info segment boundary)`); + }); + collectAlertTooltip(1).then((d) => { + cy.log( + `Alert 1 (warning): end=${d.end} (raw alert end, extends past warning segment boundary)`, + ); + }); + collectAlertTooltip(2).then((d) => { + alertCritical = d; + cy.log(`Alert 2 (critical): end=${d.end}`); + }); + + cy.log( + '5.3 Assert last incident segment End matches last alert End (no trimming on final segment)', + ); + cy.then(() => { + expect(criticalSegment.end).to.equal( + alertCritical.end, + `Incident critical End (${criticalSegment.end}) should ` + + `match alert critical End (${alertCritical.end})`, + ); + cy.log('Verified: Last incident chart segment End matches last alert bar End'); + }); + + cy.log( + 'Expected failure: OU-1205 (end time padding mismatch between incident and alert tooltips)', + ); + }); + + it('6. Cross-validation: alert bar tooltip Start and End dates match alerts table', () => { + let alertInfo: AlertDates; + let alertWarning: AlertDates; + let alertCritical: AlertDates; + let tableInfoStart: string; + let tableInfoEnd: string; + let tableWarningStart: string; + let tableWarningEnd: string; + let tableCriticalStart: string; + let tableCriticalEnd: string; + + cy.log('6.1 Setup, select incident and collect alert bar tooltip dates'); + setupEscalatingIncident(); + incidentsPage.selectIncidentById(ESCALATING_ID); + incidentsPage.elements.alertsChartContainer().should('be.visible'); + collectAlertTooltip(0).then((d) => { + alertInfo = d; + cy.log(`Alert 0 (info): start=${d.start}, end=${d.end}`); + }); + collectAlertTooltip(1).then((d) => { + alertWarning = d; + cy.log(`Alert 1 (warning): start=${d.start}, end=${d.end}`); + }); + collectAlertTooltip(2).then((d) => { + alertCritical = d; + cy.log(`Alert 2 (critical): start=${d.start}, end=${d.end}`); + }); + + cy.log( + '6.2 Collect alerts table Start and End dates (table sorted ascending by start: 0=info, 1=warning, 2=critical)', + ); + incidentsPage.getSelectedIncidentAlerts().then((alerts) => { + expect(alerts.length).to.equal(3, 'Should have 3 alert rows'); + alerts[0] + .getStartCell() .invoke('text') - .then((firstText) => { - const firstEnd = extractTime(firstText, 'End'); - cy.log(`First segment End: ${firstEnd}`); - expect(firstEnd, 'First segment End should be parseable').to.not.be.empty; - - incidentsPage.hoverOverIncidentBarSegment(0, 1); - incidentsPage.elements - .tooltip() - .invoke('text') - .then((secondText) => { - const secondStart = extractTime(secondText, 'Start'); - cy.log(`Second segment Start: ${secondStart}`); - expect(secondStart, 'Second segment Start should be parseable').to.not.be.empty; - expect( - secondStart, - `No 5-min gap: second Start (${secondStart}) should equal first End (${firstEnd})`, - ).to.equal(firstEnd); - }); + .then((t) => { + tableInfoStart = t.trim(); + cy.log(`Table info start: ${tableInfoStart}`); }); - cy.pause(); - - cy.log('2.3 Incident tooltip Start vs alert tooltip Start vs alerts table Start'); - incidentsPage.hoverOverIncidentBarSegment(0, 0); - incidentsPage.elements - .tooltip() + alerts[0] + .getEndCell() + .invoke('text') + .then((t) => { + tableInfoEnd = t.trim(); + cy.log(`Table info end: ${tableInfoEnd}`); + }); + alerts[1] + .getStartCell() .invoke('text') - .then((incidentText) => { - const incidentStart = extractTime(incidentText, 'Start'); - cy.log(`Incident tooltip Start: ${incidentStart}`); - expect(incidentStart, 'Incident Start should be parseable').to.not.be.empty; - - cy.log('2.4 Select incident and get alert tooltip Start'); - incidentsPage.selectIncidentById('monitoring-multi-severity-boundary-001'); - incidentsPage.elements.alertsChartCard().should('be.visible'); - - incidentsPage.hoverOverAlertBar(0); - incidentsPage.elements - .alertsChartTooltip() - .invoke('text') - .then((alertText) => { - const alertStart = extractTime(alertText, 'Start'); - cy.log(`Alert tooltip Start: ${alertStart}`); - expect( - incidentStart, - `Incident Start (${incidentStart}) should match alert Start (${alertStart})`, - ).to.equal(alertStart); - }); - - cy.log('2.5 Compare incident tooltip Start with alerts table Start'); - incidentsPage.getSelectedIncidentAlerts().then((alerts) => { - expect(alerts.length, 'Should have at least 1 alert row').to.be.greaterThan(0); - alerts[0] - .getStartCell() - .invoke('text') - .then((cellText) => { - const tableMatch = cellText.trim().match(/(\d{1,2}:\d{2}(\s*[AP]M)?)/); - expect(tableMatch, 'Table Start time should be parseable').to.not.be.null; - const tableStart = tableMatch[1].trim(); - cy.log(`Alerts table Start: ${tableStart}`); - expect( - incidentStart, - `Incident Start (${incidentStart}) should match table Start (${tableStart})`, - ).to.equal(tableStart); - }); - }); + .then((t) => { + tableWarningStart = t.trim(); + cy.log(`Table warning start: ${tableWarningStart}`); }); - cy.pause(); + alerts[1] + .getEndCell() + .invoke('text') + .then((t) => { + tableWarningEnd = t.trim(); + cy.log(`Table warning end: ${tableWarningEnd}`); + }); + alerts[2] + .getStartCell() + .invoke('text') + .then((t) => { + tableCriticalStart = t.trim(); + cy.log(`Table critical start: ${tableCriticalStart}`); + }); + alerts[2] + .getEndCell() + .invoke('text') + .then((t) => { + tableCriticalEnd = t.trim(); + cy.log(`Table critical end: ${tableCriticalEnd}`); + }); + }); - cy.log('Expected failure: Incident tooltip Start times are 5 minutes off (OU-1221)'); + cy.log('6.3 Assert alert tooltip Start and End match alerts table Start and End'); + cy.then(() => { + expect(tableInfoStart).to.include( + alertInfo.start, + `Table info Start (${tableInfoStart}) should include ` + + `alert tooltip Start (${alertInfo.start})`, + ); + expect(tableInfoEnd).to.include( + alertInfo.end, + `Table info End (${tableInfoEnd}) should include alert tooltip End (${alertInfo.end})`, + ); + expect(tableWarningStart).to.include( + alertWarning.start, + `Table warning Start (${tableWarningStart}) should ` + + `include alert tooltip Start (${alertWarning.start})`, + ); + expect(tableWarningEnd).to.include( + alertWarning.end, + `Table warning End (${tableWarningEnd}) should include ` + + `alert tooltip End (${alertWarning.end})`, + ); + expect(tableCriticalStart).to.include( + alertCritical.start, + `Table critical Start (${tableCriticalStart}) should ` + + `include alert tooltip Start (${alertCritical.start})`, + ); + expect(tableCriticalEnd).to.include( + alertCritical.end, + `Table critical End (${tableCriticalEnd}) should ` + + `include alert tooltip End (${alertCritical.end})`, + ); + cy.log('Verified: Alert tooltip Start and End dates match alerts table'); }); - }, -); + + cy.log('Expected failure: OU-1205 (end time padding mismatch between alert tooltip and table)'); + }); +}); diff --git a/web/cypress/e2e/incidents/regression/03.reg_15day_data_loading.cy.ts b/web/cypress/e2e/incidents/regression/03.reg_15day_data_loading.cy.ts new file mode 100644 index 000000000..be072e190 --- /dev/null +++ b/web/cypress/e2e/incidents/regression/03.reg_15day_data_loading.cy.ts @@ -0,0 +1,272 @@ +/* +Regression test for 15-Day Data Loading with "Last N Days" Filtering (Section 3.4) + +FEATURE: UI always loads 15 days of data (one query_range call per day), then filters +client-side based on "Last N Days" selection. Absolute start dates are retrieved via +instant queries (min_over_time(timestamp(...))) and always displayed regardless of the +selected time filter. + +Before fix: Start dates were relative to the "Last N Days" selection. +After fix: Start dates show absolute timestamps from instant query results. + +Note: API call pattern verification (exact count of 15 query_range calls and instant +queries) is partially covered by the mocking infrastructure. Full API call counting +requires integration tests with a real Prometheus endpoint. + +All start dates — table dates (incidents table "Start", alerts details "Start") AND +tooltip dates (incident bar tooltip, alert bar tooltip) — should remain identical +regardless of which "Last N Days" filter is selected. If tooltip dates drift when +switching filters, that indicates a bug in the chart rendering pipeline. + +Test 4: Escalating severity — verifies that segment start dates remain correct when the +time filter clips severity change boundaries out of the visible window. Boundary and +cross-validation tests for this incident are covered in 02.reg_ui_tooltip_boundary_times.cy.ts. + +Verifies: Section 3.4 +*/ + +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import { incidentsPage, SegmentDates } from '../../../views/incidents-page'; + +const MCP = { + namespace: Cypress.env('COO_NAMESPACE'), + packageName: 'cluster-observability-operator', + operatorName: 'Cluster Observability Operator', + config: { + kind: 'UIPlugin', + name: 'monitoring', + }, +}; + +const MP = { + namespace: 'openshift-monitoring', + operatorName: 'Cluster Monitoring Operator', +}; + +describe('Regression: 15-Day Data Loading', { tags: ['@incidents'] }, () => { + before(() => { + cy.beforeBlockCOO(MCP, MP, { dashboards: false, troubleshootingPanel: false }); + }); + + beforeEach(() => { + cy.log('Loading 15-day data loading test scenarios'); + cy.mockIncidentFixture('incident-scenarios/19-15-day-data-loading.yaml'); + }); + + const collectAllStartDates = ( + incidentId: string, + ): Cypress.Chainable<{ + incidentTable: string; + alertTable: string; + incidentTooltip: string; + alertTooltip: string; + }> => { + let iTable: string; + let iTooltip: string; + let aTooltip: string; + + incidentsPage.hoverOverIncidentBarById(incidentId); + incidentsPage.getTooltipStartDate().then((d) => { + iTooltip = d; + }); + + incidentsPage.selectIncidentById(incidentId); + incidentsPage.elements.alertsChartContainer().should('be.visible'); + incidentsPage.hoverOverAlertBar(0); + incidentsPage.getAlertsTooltipStartDate().then((d) => { + aTooltip = d; + }); + + incidentsPage.expandRow(0); + incidentsPage.elements.incidentsDetailsTable().should('be.visible'); + incidentsPage.elements + .incidentsDetailsStartCell(0) + .invoke('text') + .then((t) => { + iTable = t.trim(); + }); + + return incidentsPage.elements + .incidentsDetailsFiringStartCell(0) + .invoke('text') + .then((t) => { + return cy.wrap({ + incidentTable: iTable, + alertTable: t.trim(), + incidentTooltip: iTooltip, + alertTooltip: aTooltip, + }); + }); + }; + + it('1. 15-day filter - start dates are consistent across table and tooltip', () => { + cy.log('1.1 Switch to 15-day filter so all incident data is fully visible'); + incidentsPage.clearAllFilters(); + incidentsPage.setDays('15 days'); + incidentsPage.elements.incidentsChartContainer().should('be.visible'); + incidentsPage.elements.incidentsChartBarsGroups().should('have.length', 4); + + cy.log('1.2 Collect all start dates for 14-day ongoing incident'); + collectAllStartDates('LONG-14d-monitoring-ongoing').then((dates) => { + cy.log(`Incident table: ${dates.incidentTable}`); + cy.log(`Alert table: ${dates.alertTable}`); + cy.log(`Incident tooltip: ${dates.incidentTooltip}`); + cy.log(`Alert tooltip: ${dates.alertTooltip}`); + expect(dates.incidentTable).to.not.be.empty; + expect(dates.alertTable).to.not.be.empty; + expect(dates.incidentTooltip).to.not.be.empty; + expect(dates.alertTooltip).to.not.be.empty; + expect(dates.incidentTooltip).to.equal( + dates.incidentTable, + 'Incident tooltip and table match', + ); + expect(dates.alertTooltip).to.equal(dates.alertTable, 'Alert tooltip and table match'); + expect(dates.incidentTable).to.equal( + dates.alertTable, + 'Incident table and alert table match', + ); + cy.log('Verified: All start dates consistent within 15-day view'); + }); + }); + + it('2. 7-day filter - start dates are consistent across table and tooltip', () => { + cy.log('2.1 Switch to 7-day filter'); + incidentsPage.clearAllFilters(); + incidentsPage.setDays('7 days'); + incidentsPage.elements.incidentsChartContainer().should('be.visible'); + incidentsPage.elements.incidentsChartBarsGroups().should('have.length', 4); + + cy.log('2.2 Collect all start dates for 14-day ongoing incident'); + collectAllStartDates('LONG-14d-monitoring-ongoing').then((dates) => { + cy.log(`Incident table: ${dates.incidentTable}`); + cy.log(`Alert table: ${dates.alertTable}`); + cy.log(`Incident tooltip: ${dates.incidentTooltip}`); + cy.log(`Alert tooltip: ${dates.alertTooltip}`); + expect(dates.incidentTable).to.not.be.empty; + expect(dates.alertTable).to.not.be.empty; + expect(dates.incidentTooltip).to.not.be.empty; + expect(dates.alertTooltip).to.not.be.empty; + expect(dates.incidentTooltip).to.equal( + dates.incidentTable, + 'Incident tooltip and table match', + ); + expect(dates.alertTooltip).to.equal(dates.alertTable, 'Alert tooltip and table match'); + expect(dates.incidentTable).to.equal( + dates.alertTable, + 'Incident table and alert table match', + ); + cy.log('Verified: All start dates consistent within 7-day view'); + }); + }); + + it('3. 3-day filter - start dates are consistent across table and tooltip', () => { + cy.log('3.1 Switch to 3-day filter'); + incidentsPage.clearAllFilters(); + incidentsPage.setDays('3 days'); + incidentsPage.elements.incidentsChartContainer().should('be.visible'); + incidentsPage.elements.incidentsChartBarsGroups().should('have.length', 4); + + cy.log('3.2 Collect all start dates for 14-day ongoing incident'); + collectAllStartDates('LONG-14d-monitoring-ongoing').then((dates) => { + cy.log(`Incident table: ${dates.incidentTable}`); + cy.log(`Alert table: ${dates.alertTable}`); + cy.log(`Incident tooltip: ${dates.incidentTooltip}`); + cy.log(`Alert tooltip: ${dates.alertTooltip}`); + expect(dates.incidentTable).to.not.be.empty; + expect(dates.alertTable).to.not.be.empty; + expect(dates.incidentTooltip).to.not.be.empty; + expect(dates.alertTooltip).to.not.be.empty; + expect(dates.incidentTooltip).to.equal( + dates.incidentTable, + 'Incident tooltip and table match', + ); + expect(dates.alertTooltip).to.equal(dates.alertTable, 'Alert tooltip and table match'); + expect(dates.incidentTable).to.equal( + dates.alertTable, + 'Incident table and alert table match', + ); + cy.log('Verified: All start dates consistent within 3-day view'); + }); + }); + + it('4. Escalating severity - segment start dates stable when info boundary scrolls out of 7-day window', () => { + // Boundary and cross-validation tests for the escalating incident are covered in + // 02.reg_ui_tooltip_boundary_times.cy.ts. This test focuses solely on whether segment + // start dates remain correct when the time filter clips the info→warning boundary (10d ago) + // out of the visible window. + let infoSegment: SegmentDates; + let warningSegment: SegmentDates; + let criticalSegment: SegmentDates; + + cy.log('4.1 Collect segment start dates as baseline at 15-day filter'); + incidentsPage.clearAllFilters(); + incidentsPage.setDays('15 days'); + incidentsPage.elements.incidentsChartContainer().should('be.visible'); + incidentsPage.elements.incidentsChartBarsGroups().should('have.length', 4); + + incidentsPage.collectSegmentTooltip('ESCALATING-14d-monitoring-severity', 0).then((d) => { + infoSegment = d; + cy.log(`Info segment baseline: Start=${d.start}`); + }); + incidentsPage.collectSegmentTooltip('ESCALATING-14d-monitoring-severity', 1).then((d) => { + warningSegment = d; + cy.log(`Warning segment baseline: Start=${d.start}`); + }); + incidentsPage.collectSegmentTooltip('ESCALATING-14d-monitoring-severity', 2).then((d) => { + criticalSegment = d; + cy.log(`Critical segment baseline: Start=${d.start}`); + }); + + cy.log( + '4.2 Switch to 7-day filter - info boundary (10d ago) is now outside the visible window', + ); + incidentsPage.setDays('7 days'); + incidentsPage.elements.incidentsChartBarsGroups().should('have.length', 4); + + cy.log('4.3 Critical segment start should be unchanged from 15d baseline'); + incidentsPage + .getIncidentBarVisibleSegmentCount('ESCALATING-14d-monitoring-severity') + .then((count) => { + incidentsPage.hoverOverIncidentBarById('ESCALATING-14d-monitoring-severity', count - 1); + incidentsPage.getTooltipSeverity().should('equal', 'Critical'); + incidentsPage.getTooltipStartDate().then((d) => { + expect(d).to.equal( + criticalSegment.start, + `Critical start in 7d view (${d}) should match 15d baseline (${criticalSegment.start})`, + ); + cy.log(`Verified: Critical segment start unchanged at ${d}`); + }); + }); + + cy.log( + '4.4 Warning segment start may differ - info→warning boundary (10d ago) is outside the 7d window', + ); + incidentsPage + .getIncidentBarVisibleSegmentCount('ESCALATING-14d-monitoring-severity') + .then((count) => { + const warningIndex = count - 2; + if (warningIndex < 0) { + cy.log('Warning segment not visible in 7d view (entirely clipped), skipping'); + return; + } + + incidentsPage.hoverOverIncidentBarById('ESCALATING-14d-monitoring-severity', warningIndex); + incidentsPage.getTooltipSeverity().should('equal', 'Warning'); + incidentsPage.getTooltipStartDate().then((d) => { + expect(d).to.satisfy( + (v: string) => v === warningSegment.start || v === infoSegment.start, + `Warning start in 7d (${d}) should equal either the segment start ` + + `(${warningSegment.start}) or the incident start (${infoSegment.start})`, + ); + cy.log( + `Warning start in 7d: ${d} (was ${warningSegment.start}` + + ` in 15d, incident start: ${infoSegment.start})`, + ); + }); + }); + + cy.log( + 'Verified: Segment start dates behave correctly when time filter clips severity boundaries', + ); + }); +}); diff --git a/web/cypress/fixtures/coo/monitoring-ui-plugin.yaml b/web/cypress/fixtures/coo/monitoring-ui-plugin.yaml index 3d27d5649..1a3d91b06 100644 --- a/web/cypress/fixtures/coo/monitoring-ui-plugin.yaml +++ b/web/cypress/fixtures/coo/monitoring-ui-plugin.yaml @@ -12,6 +12,6 @@ spec: thanosQuerier: url: 'https://rbac-query-proxy.open-cluster-management-observability.svc:8443' perses: - enabled: true + enabled: false # TEMPORARILY DISABLED incidents: enabled: true \ No newline at end of file diff --git a/web/cypress/fixtures/incident-scenarios/19-15-day-data-loading.yaml b/web/cypress/fixtures/incident-scenarios/19-15-day-data-loading.yaml new file mode 100644 index 000000000..394d3c2d9 --- /dev/null +++ b/web/cypress/fixtures/incident-scenarios/19-15-day-data-loading.yaml @@ -0,0 +1,87 @@ +name: "14-Day Data Loading Test" +description: "Tests absolute start date display when incidents span beyond the selected 'Last N Days' filter window. Uses 14d durations to guarantee all data fits within the 15-day fetch window. Baseline is collected at 15d filter (widest), then narrowed to 7d and 3d." +incidents: + # 14-day ongoing incident: fully within 15d fetch window, extends beyond 7d filter + - id: "LONG-14d-monitoring-ongoing" + component: "monitoring" + layer: "core" + timeline: + start: "14d" + alerts: + - name: "AlertLong14DayCritical" + namespace: "openshift-monitoring" + severity: "critical" + firing: true + timeline: + start: "14d" + + # 10-day resolved incident: start beyond 7-day window, ended 2 days ago + - id: "MEDIUM-10d-storage-resolved" + component: "storage" + layer: "core" + timeline: + start: "10d" + end: "2d" + alerts: + - name: "AlertMedium10DayStorageWarning" + namespace: "openshift-storage" + severity: "warning" + firing: false + timeline: + start: "10d" + end: "2d" + + # Escalating severity incident: info (14d-10d2m) → warning (10d2m-5d3m) → critical (5d3m-now) + # Off-grid boundaries (2m, 3m offsets) ensure timestamps don't land exactly on the + # 5-minute Prometheus grid, making discrepancies between tooltip and table more visible. + # The severityChange at "14d" anchors the initial severity so that all 3 alerts + # produce identical health metric values (getSeverityAtTime never falls back to default). + # Alerts intentionally overlap: info extends ~2h past warning start, warning extends ~1h + # past critical start. The incident chart trims these to the severity change boundaries. + - id: "ESCALATING-14d-monitoring-severity" + component: "monitoring" + layer: "core" + timeline: + start: "14d" + severityChanges: + - time: "14d" + severity: "info" + - time: "10d2m" + severity: "warning" + - time: "5d3m" + severity: "critical" + alerts: + - name: "AlertEscalatingInfo" + namespace: "openshift-monitoring" + severity: "info" + firing: false + timeline: + start: "14d" + end: "9d22h" + - name: "AlertEscalatingWarning" + namespace: "openshift-monitoring" + severity: "warning" + firing: false + timeline: + start: "10d2m" + end: "4d23h" + - name: "AlertEscalatingCritical" + namespace: "openshift-monitoring" + severity: "critical" + firing: true + timeline: + start: "5d3m" + + # 2-day recent incident: fully within all filter windows + - id: "SHORT-2d-network-recent" + component: "network" + layer: "core" + timeline: + start: "2d" + alerts: + - name: "AlertShortRecentNetworkWarning" + namespace: "openshift-network" + severity: "warning" + firing: true + timeline: + start: "2d" diff --git a/web/cypress/fixtures/incident-scenarios/21-multi-severity-boundary-times.yaml b/web/cypress/fixtures/incident-scenarios/21-multi-severity-boundary-times.yaml deleted file mode 100644 index 7852e3c6c..000000000 --- a/web/cypress/fixtures/incident-scenarios/21-multi-severity-boundary-times.yaml +++ /dev/null @@ -1,38 +0,0 @@ -name: "Multi-Severity Interval Boundary Times" -description: "Single resolved multi-severity incident for verifying that tooltip end times at severity interval boundaries are 5-minute rounded. The incident escalates from info to warning to critical over 5 hours." -incidents: - - id: "monitoring-multi-severity-boundary-001" - component: "monitoring" - layer: "core" - timeline: - start: "6h" - end: "1h" - severityChanges: - - time: "6h" - severity: "info" - - time: "4h" - severity: "warning" - - time: "2h" - severity: "critical" - alerts: - - name: "MonitoringLatencyHigh001" - namespace: "openshift-monitoring" - severity: "info" - firing: false - timeline: - start: "6h" - end: "4h" - - name: "MonitoringDegraded001" - namespace: "openshift-monitoring" - severity: "warning" - firing: false - timeline: - start: "4h" - end: "2h" - - name: "MonitoringDown001" - namespace: "openshift-monitoring" - severity: "critical" - firing: false - timeline: - start: "2h" - end: "1h" diff --git a/web/cypress/support/e2e.js b/web/cypress/support/e2e.js index 06b7d843b..f19a84876 100644 --- a/web/cypress/support/e2e.js +++ b/web/cypress/support/e2e.js @@ -2,3 +2,12 @@ import './commands'; import registerCypressGrep from '@cypress/grep'; registerCypressGrep(); + +// Hide fetch/XHR request entries from the Cypress command log to reduce noise +const app = window.top; +if (app && !app.document.head.querySelector('[data-hide-command-log-request]')) { + const style = app.document.createElement('style'); + style.innerHTML = '.command-name-request, .command-name-xhr { display: none }'; + style.setAttribute('data-hide-command-log-request', ''); + app.document.head.appendChild(style); +} diff --git a/web/cypress/support/incidents_prometheus_query_mocks/mock-generators.ts b/web/cypress/support/incidents_prometheus_query_mocks/mock-generators.ts index e5a8d19a9..2823f93e9 100644 --- a/web/cypress/support/incidents_prometheus_query_mocks/mock-generators.ts +++ b/web/cypress/support/incidents_prometheus_query_mocks/mock-generators.ts @@ -10,26 +10,26 @@ import { NEW_METRIC_NAME, OLD_METRIC_NAME } from './prometheus-mocks'; * For longer durations, uses 5-minute intervals */ function generateIntervalTimestamps(startTime: number, endTime: number): number[] { - const duration = endTime - startTime; const fiveMinutes = 300; + + // Align to 5-minute grid (matches real Prometheus query_range step=300 behavior) + const alignedStart = Math.ceil(startTime / fiveMinutes) * fiveMinutes; + const alignedEnd = Math.floor(endTime / fiveMinutes) * fiveMinutes; + + const duration = alignedEnd - alignedStart; const timestamps: number[] = []; if (duration < fiveMinutes) { - timestamps.push(startTime); + timestamps.push(alignedStart); return timestamps; } - let currentTime = startTime; - while (currentTime <= endTime) { + let currentTime = alignedStart; + while (currentTime <= alignedEnd) { timestamps.push(currentTime); currentTime += fiveMinutes; } - // Ensure we have the end timestamp if it doesn't align with 5-minute intervals - if (timestamps[timestamps.length - 1] !== endTime) { - timestamps.push(endTime); - } - return timestamps; } diff --git a/web/cypress/support/incidents_prometheus_query_mocks/prometheus-mocks.ts b/web/cypress/support/incidents_prometheus_query_mocks/prometheus-mocks.ts index c74c914f7..88028d536 100644 --- a/web/cypress/support/incidents_prometheus_query_mocks/prometheus-mocks.ts +++ b/web/cypress/support/incidents_prometheus_query_mocks/prometheus-mocks.ts @@ -23,7 +23,7 @@ export interface PermissionDeniedEndpoints { export const NEW_METRIC_NAME = 'cluster_health_components_map'; export const OLD_METRIC_NAME = 'cluster:health:components:map'; -const MOCK_QUERY = '/api/prometheus/api/v1/query_range*'; +const MOCK_QUERY_RANGE = '/api/prometheus/api/v1/query_range*'; /** * Main mocking function - sets up cy.intercept for Prometheus query_range API @@ -34,7 +34,7 @@ const MOCK_QUERY = '/api/prometheus/api/v1/query_range*'; * @param incidents - Array of incident definitions to mock */ export function mockPrometheusQueryRange(incidents: IncidentDefinition[]): void { - cy.intercept('GET', MOCK_QUERY, (req) => { + cy.intercept('GET', MOCK_QUERY_RANGE, (req) => { const url = new URL(req.url, window.location.origin); const query = url.searchParams.get('query') || ''; const startTime = url.searchParams.get('start'); @@ -128,7 +128,7 @@ Cypress.Commands.add('transformMetrics', () => { cy.log('Transforming old metric queries to new format'); - cy.intercept('GET', MOCK_QUERY, (req) => { + cy.intercept('GET', MOCK_QUERY_RANGE, (req) => { const url = new URL(req.url, window.location.origin); const query = url.searchParams.get('query') || ''; const hasNewMetric = query.includes(NEW_METRIC_NAME); @@ -191,7 +191,7 @@ export function mockPermissionDeniedResponses(endpoints: PermissionDeniedEndpoin } if (prometheus) { - cy.intercept('GET', MOCK_QUERY, (req) => { + cy.intercept('GET', MOCK_QUERY_RANGE, (req) => { Cypress.log({ name: '403', message: `${req.method} ${req.url}` }); req.reply(forbiddenResponse); }).as('prometheusQueryRangePermissionDenied'); diff --git a/web/cypress/views/incidents-page.ts b/web/cypress/views/incidents-page.ts index b47fb639a..b78cfe021 100644 --- a/web/cypress/views/incidents-page.ts +++ b/web/cypress/views/incidents-page.ts @@ -1,6 +1,22 @@ import { nav } from './nav'; import { DataTestIDs } from '../../src/components/data-test'; +const isPathVisible = (el: Element): boolean => { + const opacity = Cypress.$(el).css('fill-opacity') || Cypress.$(el).attr('fill-opacity'); + return parseFloat(opacity || '0') > 0; +}; + +export interface SegmentDates { + severity: string; + start: string; + end: string; +} + +export interface AlertDates { + start: string; + end: string; +} + export const incidentsPage = { // Centralized element selectors - all selectors defined in one place elements: { @@ -82,11 +98,7 @@ export const incidentsPage = { return cy .get('g[role="presentation"][data-test*="incidents-chart-bar-"]') .find('path[role="presentation"]') - .filter((index, element) => { - const fillOpacity = - Cypress.$(element).css('fill-opacity') || Cypress.$(element).attr('fill-opacity'); - return parseFloat(fillOpacity || '0') > 0; - }); + .filter((_, element) => isPathVisible(element)); } else { cy.log('Chart bars were not found. Test continues.'); return cy.wrap([]); @@ -99,11 +111,7 @@ export const incidentsPage = { .should('exist') .find('path[role="presentation"]') .should('have.length.greaterThan', 0) - .filter((index, element) => { - const fillOpacity = - Cypress.$(element).css('fill-opacity') || Cypress.$(element).attr('fill-opacity'); - return parseFloat(fillOpacity || '0') > 0; - }); + .filter((_, element) => isPathVisible(element)); }, incidentsChartBarsGroups: () => cy @@ -127,11 +135,7 @@ export const incidentsPage = { return cy .get('g[role="presentation"][data-test*="alerts-chart-bar-"]') .find('path[role="presentation"]') - .filter((index, element) => { - const fillOpacity = - Cypress.$(element).css('fill-opacity') || Cypress.$(element).attr('fill-opacity'); - return parseFloat(fillOpacity || '0') > 0; - }); + .filter((_, element) => isPathVisible(element)); } else { cy.log('Alert chart bars were not found. Test continues.'); return cy.wrap([]); @@ -151,6 +155,18 @@ export const incidentsPage = { cy.byTestID(`${DataTestIDs.IncidentsTable.SeverityCell}-${index}`), incidentsTableStateCell: (index: number) => cy.byTestID(`${DataTestIDs.IncidentsTable.StateCell}-${index}`), + incidentsTableStartCell: (index: number) => + incidentsPage.elements + .incidentsTable() + .find('td[data-label="Start"]') + .eq(index) + .find('span[data-test="timestamp"]'), + incidentsTableEndCell: (index: number) => + incidentsPage.elements + .incidentsTable() + .find('td[data-label="End"]') + .eq(index) + .find('span[data-test="timestamp"]'), // Details table (expanded row) incidentsDetailsTable: () => cy.byTestID(DataTestIDs.IncidentsDetailsTable.Table), @@ -168,8 +184,26 @@ export const incidentsPage = { cy.byTestID(`${DataTestIDs.IncidentsDetailsTable.SeverityCell}-${index}`), incidentsDetailsStateCell: (index: number) => cy.byTestID(`${DataTestIDs.IncidentsDetailsTable.StateCell}-${index}`), + // TODO: DataTestIDs.IncidentsDetailsTable.StartCell is defined but not rendered + // in IncidentsDetailsRowTable.tsx — use dataLabel fallback until data-test is wired up incidentsDetailsStartCell: (index: number) => - cy.byTestID(`${DataTestIDs.IncidentsDetailsTable.StartCell}-${index}`), + incidentsPage.elements + .incidentsDetailsTable() + .find('td[data-label="expanded-details-firingstart"]') + .eq(index) + .find('span[data-test="timestamp"]'), + incidentsDetailsFiringStartCell: (index: number) => + incidentsPage.elements + .incidentsDetailsTable() + .find('td[data-label="expanded-details-firingstart"]') + .eq(index) + .find('span[data-test="timestamp"]'), + incidentsDetailsFiringEndCell: (index: number) => + incidentsPage.elements + .incidentsDetailsTable() + .find('td[data-label="expanded-details-firingend"]') + .eq(index) + .find('span[data-test="timestamp"]'), incidentsDetailsEndCell: (index: number) => cy.byTestID(`${DataTestIDs.IncidentsDetailsTable.EndCell}-${index}`), @@ -461,13 +495,7 @@ export const incidentsPage = { .eq(index) .find('path[role="presentation"]') .then(($paths) => { - const visiblePath = $paths - .filter((i, el) => { - const fillOpacity = - Cypress.$(el).css('fill-opacity') || Cypress.$(el).attr('fill-opacity'); - return parseFloat(fillOpacity || '0') > 0; - }) - .first(); + const visiblePath = $paths.filter((_, el) => isPathVisible(el)).first(); if (visiblePath.length > 0) { const rect = visiblePath[0].getBoundingClientRect(); @@ -502,10 +530,7 @@ export const incidentsPage = { .eq(barIndex) .find('path[role="presentation"]') .then(($paths) => { - return $paths.filter((_, el) => { - const opacity = Cypress.$(el).css('fill-opacity') || Cypress.$(el).attr('fill-opacity'); - return parseFloat(opacity || '0') > 0; - }); + return $paths.filter((_, el) => isPathVisible(el)); }); }, @@ -541,6 +566,45 @@ export const incidentsPage = { return incidentsPage.waitForTooltip(); }, + getIncidentBarVisibleSegmentCount: (incidentId: string): Cypress.Chainable => { + cy.log(`incidentsPage.getIncidentBarVisibleSegmentCount: ${incidentId}`); + return incidentsPage.elements + .incidentsChartBar(incidentId) + .find('path[role="presentation"]') + .then(($paths) => { + const count = $paths.filter((_, el) => isPathVisible(el)).length; + return cy.wrap(count); + }); + }, + + hoverOverIncidentBarById: (incidentId: string, segmentIndex = 0) => { + cy.log(`incidentsPage.hoverOverIncidentBarById: ${incidentId}, segment: ${segmentIndex}`); + incidentsPage.elements + .incidentsChartBar(incidentId) + .find('path[role="presentation"]') + .then(($paths) => { + const visiblePaths = $paths.filter((_, el) => isPathVisible(el)); + + if (segmentIndex >= visiblePaths.length) { + throw new Error( + `Segment ${segmentIndex} not found for incident ` + + `${incidentId} (${visiblePaths.length} visible)`, + ); + } + + const rect = visiblePaths[segmentIndex].getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; + + incidentsPage.elements + .incidentsChartSvg() + .first() + .trigger('mousemove', { clientX: x, clientY: y, force: true }) + .wait(100); + }); + return incidentsPage.waitForTooltip(); + }, + getIncidentBarRect: (index: number) => { cy.log(`incidentsPage.getIncidentBarRect: ${index}`); return incidentsPage.elements @@ -583,23 +647,118 @@ export const incidentsPage = { hoverOverAlertBar: (index: number) => { cy.log(`incidentsPage.hoverOverAlertBar: ${index}`); - incidentsPage.elements - .alertsChartBarsPaths() - .eq(index) - .then(($bar) => { - const rect = $bar[0].getBoundingClientRect(); - const x = rect.left + rect.width / 2; - const y = rect.top + rect.height / 2; + incidentsPage.elements.alertsChartBarsPaths().then(($paths) => { + const visiblePaths = $paths.filter((_, el) => isPathVisible(el)); - incidentsPage.elements - .alertsChartSvg() - .first() - .trigger('mousemove', { clientX: x, clientY: y, force: true }) - .wait(100); - }); + if (index >= visiblePaths.length) { + throw new Error(`Alert bar ${index} not found (${visiblePaths.length} visible)`); + } + + const rect = visiblePaths[index].getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; + + incidentsPage.elements + .alertsChartSvg() + .first() + .trigger('mousemove', { clientX: x, clientY: y, force: true }) + .wait(100); + }); return incidentsPage.waitForTooltip(); }, + getTooltipStartDate: (): Cypress.Chainable => { + cy.log('incidentsPage.getTooltipStartDate'); + return incidentsPage.elements + .tooltip() + .find('p') + .contains('Start:') + .invoke('text') + .then((text) => { + const match = text.match(/Start\s*:\s*(.*)/); + return cy.wrap(match ? match[1].trim() : ''); + }); + }, + + getTooltipSeverity: (): Cypress.Chainable => { + cy.log('incidentsPage.getTooltipSeverity'); + return incidentsPage.elements + .tooltip() + .find('p') + .contains('Severity:') + .invoke('text') + .then((text) => { + const match = text.match(/Severity\s*:\s*(.*)/); + return cy.wrap(match ? match[1].trim() : ''); + }); + }, + + getTooltipEndDate: (): Cypress.Chainable => { + cy.log('incidentsPage.getTooltipEndDate'); + return incidentsPage.elements + .tooltip() + .find('p') + .contains('End:') + .invoke('text') + .then((text) => { + const match = text.match(/End\s*:\s*(.*)/); + return cy.wrap(match ? match[1].trim() : ''); + }); + }, + + getAlertsTooltipStartDate: (): Cypress.Chainable => { + cy.log('incidentsPage.getAlertsTooltipStartDate'); + return incidentsPage.elements + .alertsChartTooltip() + .find('p') + .contains('Start:') + .invoke('text') + .then((text) => { + const match = text.match(/Start\s*:\s*(.*)/); + return cy.wrap(match ? match[1].trim() : ''); + }); + }, + + getAlertsTooltipEndDate: (): Cypress.Chainable => { + cy.log('incidentsPage.getAlertsTooltipEndDate'); + return incidentsPage.elements + .alertsChartTooltip() + .find('p') + .contains('End:') + .invoke('text') + .then((text) => { + const match = text.match(/End\s*:\s*(.*)/); + return cy.wrap(match ? match[1].trim() : ''); + }); + }, + + collectSegmentTooltip: ( + incidentId: string, + segmentIndex: number, + ): Cypress.Chainable => { + cy.log(`incidentsPage.collectSegmentTooltip: ${incidentId}, segment: ${segmentIndex}`); + let severity: string; + let start: string; + incidentsPage.hoverOverIncidentBarById(incidentId, segmentIndex); + incidentsPage.getTooltipSeverity().then((s) => { + severity = s; + }); + incidentsPage.getTooltipStartDate().then((d) => { + start = d; + }); + return incidentsPage.getTooltipEndDate().then((end) => cy.wrap({ severity, start, end })); + }, + + collectAlertTooltip: (barIndex: number): Cypress.Chainable => { + cy.log(`incidentsPage.collectAlertTooltip: ${barIndex}`); + let start: string; + incidentsPage.hoverOverAlertBar(barIndex); + incidentsPage.getAlertsTooltipStartDate().then((d) => { + start = d; + }); + return incidentsPage.getAlertsTooltipEndDate().then((end) => cy.wrap({ start, end })); + }, + // Constants for search configuration SEARCH_CONFIG: { CHART_LOAD_WAIT: 1000, diff --git a/web/src/components/data-test.ts b/web/src/components/data-test.ts index e3c71b1c5..fcdb765ee 100644 --- a/web/src/components/data-test.ts +++ b/web/src/components/data-test.ts @@ -129,6 +129,8 @@ export const DataTestIDs = { }, // Incidents Table Test IDs + // NOTE: StartCell is missing — IncidentsTable.tsx line 182 has no data-test attribute. + // The Cypress page object uses a dataLabel fallback for now (incidentsTableStartCell). IncidentsTable: { Table: 'incidents-alerts-table', ExpandButton: 'incidents-table-expand-button', @@ -139,6 +141,11 @@ export const DataTestIDs = { }, // Incidents Details Row Table Test IDs + // NOTE: Only Table and LoadingSpinner are rendered in IncidentsDetailsRowTable.tsx. + // All per-row/per-cell IDs below (Row through AlertRuleLink) are defined here but + // NOT wired up as data-test attributes in the component. The Cypress page object + // uses dataLabel-based fallback selectors for these. When adding data-test attrs + // to the component, the page object selectors can switch to cy.byTestID(). IncidentsDetailsTable: { Table: 'incidents-details-table', LoadingSpinner: 'incidents-details-loading-spinner',