From d77b9da1519204e038bd2422f020860a5526fc1f Mon Sep 17 00:00:00 2001 From: Tal Hilzenrat Date: Thu, 10 Sep 2026 15:02:57 -0400 Subject: [PATCH] fix: dual-support OCP 4.20 and 4.22 Cypress selectors flightctl-ui PR #803 renamed device details "Fleet name" to "Fleet", moved Add label outside LabelGroup, and made the security card expandable. Keep the OCP 4.20 layout selectors alongside the new 4.22 ones. Assisted-by: Claude --- cypress/e2e/vulnerability.cy.js | 15 ++--- cypress/views/common.js | 17 +++++ cypress/views/devicesPage.js | 47 +++++++------ cypress/views/fleetsPage.js | 13 ++-- cypress/views/securityPage.js | 114 +++++++++++++++++++++++--------- 5 files changed, 137 insertions(+), 69 deletions(-) diff --git a/cypress/e2e/vulnerability.cy.js b/cypress/e2e/vulnerability.cy.js index 4949ce6..cdba448 100644 --- a/cypress/e2e/vulnerability.cy.js +++ b/cypress/e2e/vulnerability.cy.js @@ -1,5 +1,5 @@ import { securityPage, KNOWN_VULNERABLE_IMAGE, CLEAN_IMAGE } from '../views/securityPage' -import { common } from '../views/common' +import { common, deviceDetailsFleetRow } from '../views/common' /** * Vulnerability Reporting End-to-End Tests @@ -135,10 +135,11 @@ describe('Vulnerability Reporting', () => { .clear() .type(FLEET_NAME) - // Add device selector label + // Add device selector label. OCP 4.20: button lives inside LabelGroup. + // OCP 4.22: EditableLabelControl renders it as a sibling of LabelGroup. cy.contains('.pf-v6-c-form__label-text', 'Device selector') .parents('.pf-v6-c-form__group') - .find('.pf-v6-c-label-group button[aria-label="Add label"]') + .contains('button', 'Add label') .click() cy.wait(500) @@ -350,9 +351,7 @@ describe('Vulnerability Reporting', () => { cy.contains('.pf-v6-c-label', fleetLabel).should('exist') // Verify fleet connection appears on device details (fleet propagation can take up to 30s) - cy.contains('.fctl-device-details-tab__label', 'Fleet name', { timeout: 30000 }) - .closest('.pf-v6-l-stack') - .should('contain', FLEET_NAME) + deviceDetailsFleetRow(30000).should('contain', FLEET_NAME) // Patch device status to set vulnerable image digest directly (like e2e tests). // Uses cy.task (Node.js binary) instead of cy.exec (bin/flightctl wrapper) because @@ -606,9 +605,7 @@ describe('Vulnerability Reporting', () => { cy.wait(2000) // Verify fleet disconnected - cy.contains('.fctl-device-details-tab__label', 'Fleet name', { timeout: 10000 }) - .closest('.pf-v6-l-stack') - .should('contain', 'None') + deviceDetailsFleetRow(10000).should('contain', 'None') // Patch device status to clear vulnerable digest (like e2e tests). cy.task('patchDeviceStatus', { diff --git a/cypress/views/common.js b/cypress/views/common.js index eb32df4..99085b8 100644 --- a/cypress/views/common.js +++ b/cypress/views/common.js @@ -2,6 +2,23 @@ * Common utilities for test operations */ +/** + * Device details "Fleet" field. Newer flightctl-ui (OCP 4.22) uses a DescriptionList + * term labeled "Fleet"; OCP 4.20 still uses `.fctl-device-details-tab__label` + "Fleet name". + * Returns a chainable scoped to the field's parent row. + */ +export const deviceDetailsFleetRow = (timeout = 60000) => { + const termOrLegacyLabel = [ + 'dt.pf-v6-c-description-list__term', + '.fctl-device-details-tab__label', + ].join(', ') + const row = [ + '.pf-v6-c-description-list__group', + '.pf-v6-l-stack', + ].join(', ') + return cy.contains(termOrLegacyLabel, /^(Fleet name|Fleet)$/, { timeout }).closest(row) +} + /** True after org selection was handled or confirmed absent — only run once per spec (first navigateTo). */ let organizationSelectionHandled = false diff --git a/cypress/views/devicesPage.js b/cypress/views/devicesPage.js index 99bef02..f0cb49f 100644 --- a/cypress/views/devicesPage.js +++ b/cypress/views/devicesPage.js @@ -1,4 +1,4 @@ -import { common } from './common' +import { common, deviceDetailsFleetRow } from './common' /** Default table row index for flows that assume a single primary device row */ const ROW_0 = 0 @@ -17,8 +17,9 @@ const DEVICE_EVENTS_NORMAL = [ /** Events list body on device details — Events tab */ const EVENTS_CONTAINER = '[data-testid="device-events-list"]' -/** Device details → Applications table (standalone UI expandable VM/apps table) */ -const DEVICE_APPLICATIONS_TABLE = '#fctl-applications-table' +/** Device details → Applications table (id on 4.20; 4.22 also has the card + aria-label). */ +const DEVICE_APPLICATIONS_TABLE = + '#fctl-applications-table, #device-applications-card table, table[aria-label="Applications"]' /** Device details → Terminal tab → VM serial console (Open console) */ const APP_CONSOLE_TERMINAL = '[data-testid="app-console-terminal"]' @@ -628,14 +629,14 @@ export const devicesPage = { .click({ force: true }) cy.get('[data-testid="device-details-title"]', { timeout: 120000 }).should('be.visible') cy.get('[data-testid="device-details-tab-details"]').should('be.visible') - cy.contains('.fctl-device-details-tab__label', 'Fleet name', { timeout: 60000 }) - .closest('.pf-v6-l-stack') - .find('.fctl-resource-link__text', { timeout: 60000 }) - .invoke('text') - .as('expectedFleetName') - cy.contains('.fctl-device-details-tab__label', 'Fleet name') - .closest('.pf-v6-l-stack') - .should('not.contain', 'None') + deviceDetailsFleetRow(60000).then(($row) => { + const linkEl = $row.find('.fctl-resource-link__text, a').first() + const fleet = linkEl.length + ? linkEl.text().trim() + : $row.text().replace(/Fleet\s*(name)?/i, '').trim() + cy.wrap(fleet).as('expectedFleetName') + }) + deviceDetailsFleetRow().should('not.contain', 'None') cy.get('@expectedFleetName').then((fleetName) => { const fleet = String(fleetName).trim() @@ -666,15 +667,11 @@ export const devicesPage = { }, expectDeviceDetailsFleetConnected: (fleetName = SCALE_FLEET_NAME) => { - cy.contains('.fctl-device-details-tab__label', 'Fleet name', { timeout: 120000 }) - .closest('.pf-v6-l-stack') - .should('contain', fleetName) + deviceDetailsFleetRow(120000).should('contain', fleetName) }, expectDeviceDetailsFleetDisconnected: () => { - cy.contains('.fctl-device-details-tab__label', 'Fleet name', { timeout: 120000 }) - .closest('.pf-v6-l-stack') - .should('contain', 'None') + deviceDetailsFleetRow(120000).should('contain', 'None') }, removeFleetLabelOnDeviceDetails: (labelText = SCALE_FLEET_LABEL_TEXT) => { @@ -713,8 +710,9 @@ export const devicesPage = { waitForVmAppRunning: (deviceName = 'test-device', appName = 'test-vm', timeoutMs = 600000) => { cy.get('[data-testid="device-details-title"]', { timeout: 30000 }).should('contain', deviceName) - cy.get(DEVICE_APPLICATIONS_TABLE, { timeout: timeoutMs }).scrollIntoView({ block: 'center' }) + cy.get(DEVICE_APPLICATIONS_TABLE, { timeout: timeoutMs }).first().scrollIntoView({ block: 'center' }) cy.get(DEVICE_APPLICATIONS_TABLE) + .first() .find('td[data-label="Name"]') .contains(new RegExp(`^${appName}$`), { timeout: timeoutMs }) .parents('tr') @@ -726,8 +724,9 @@ export const devicesPage = { common.navigateTo('Devices') enrolledDeviceLinkByAlias(deviceName).click() cy.get('[data-testid="device-details-title"]').should('contain', deviceName) - cy.get(DEVICE_APPLICATIONS_TABLE).scrollIntoView({ block: 'center' }) + cy.get(DEVICE_APPLICATIONS_TABLE).first().scrollIntoView({ block: 'center' }) cy.get(DEVICE_APPLICATIONS_TABLE) + .first() .find('td[data-label="Name"]') .contains(new RegExp(`^${appName}$`)) .parents('tr') @@ -767,20 +766,20 @@ export const devicesPage = { }, loginVmSerialConsole: (user = 'fedora', password = 'fedora') => { - cy.get(APP_CONSOLE_XTERM_INPUT, { timeout: 30000 }).should('exist').click({ force: true }).type('{enter}', { force: true }) + cy.get(APP_CONSOLE_XTERM_INPUT, { timeout: 30000 }).first().should('exist').click({ force: true }).type('{enter}', { force: true }) cy.get(APP_CONSOLE_XTERM_ROWS, { timeout: 120000 }).should(($el) => { expect($el.text()).to.match(/login:/i) }) - cy.get(APP_CONSOLE_XTERM_INPUT).click({ force: true }).type(`${user}{enter}`, { force: true, delay: 50 }) + cy.get(APP_CONSOLE_XTERM_INPUT).first().click({ force: true }).type(`${user}{enter}`, { force: true, delay: 50 }) cy.get(APP_CONSOLE_XTERM_ROWS, { timeout: 30000 }).should(($el) => { expect($el.text()).to.match(/Password:/i) }) - cy.get(APP_CONSOLE_XTERM_INPUT).type(`${password}{enter}`, { force: true, delay: 50, log: false }) - cy.get(APP_CONSOLE_XTERM_INPUT).type('{enter}', { force: true }) + cy.get(APP_CONSOLE_XTERM_INPUT).first().type(`${password}{enter}`, { force: true, delay: 50, log: false }) + cy.get(APP_CONSOLE_XTERM_INPUT).first().type('{enter}', { force: true }) cy.get(APP_CONSOLE_XTERM_ROWS, { timeout: 60000 }).should(($el) => { expect($el.text()).to.match(new RegExp(`${user}@`)) }) - cy.get(APP_CONSOLE_XTERM_INPUT).type('whoami{enter}', { force: true, delay: 50 }) + cy.get(APP_CONSOLE_XTERM_INPUT).first().type('whoami{enter}', { force: true, delay: 50 }) cy.get(APP_CONSOLE_XTERM_ROWS, { timeout: 30000 }).should(($el) => { expect($el.text()).to.match(new RegExp(`whoami[\\s\\S]*${user}`)) }) diff --git a/cypress/views/fleetsPage.js b/cypress/views/fleetsPage.js index 7cab8e9..1e5ca4a 100644 --- a/cypress/views/fleetsPage.js +++ b/cypress/views/fleetsPage.js @@ -78,8 +78,11 @@ export const fleetsPage = { cy.get('[data-testid="rich-validation-field-name"]').should('be.visible') cy.get('[data-testid="rich-validation-field-name"]').type(fleetname) cy.get('[data-testid="rich-validation-field-name"]').should('have.value', 'test-fleet') - cy.get('.pf-v6-l-stack__item > .pf-v6-c-label-group > .pf-v6-c-label-group__main > .pf-v6-c-label-group__list').click() - cy.get('[data-testid="wizard-next-button"]').click() + // Blur the name field so Formik validates. OCP 4.20 clicked the LabelGroup list + // (removed in newer PF / EditableLabelControl). Do not add a selector label here — + // createFleet historically submitted with an empty device selector. + cy.get('[data-testid="rich-validation-field-name"]').blur() + cy.get('[data-testid="wizard-next-button"]').should('not.be.disabled').click() cy.get('[data-testid="textfield-osSpec"]').should('be.visible') cy.get('[data-testid="textfield-osSpec"]').type(img) cy.get('[data-testid="textfield-osSpec"]').should('have.value', img) @@ -96,7 +99,7 @@ export const fleetsPage = { cy.get(`[data-testid="fleet-row-actions-${fleetname}"] .pf-v6-c-menu-toggle`).should('be.visible').click() cy.contains('.pf-v6-c-menu__item-text', 'Edit fleet configurations').should('be.visible').click() - cy.get(':nth-child(1) > .pf-v6-c-form__group-label > .pf-v6-c-form__label > .pf-v6-c-form__label-text').should('contain', 'Fleet name') + cy.get('.pf-v6-c-form__label-text').first().should('contain', 'Fleet name') cy.get('[data-testid="wizard-next-button"]').click() cy.get('[data-testid="textfield-osSpec"]').should('be.visible') cy.get('[data-testid="textfield-osSpec"]').clear() @@ -114,8 +117,8 @@ export const fleetsPage = { deleteFleet: (fleetname = Cypress.env('fleetname')) => { common.navigateTo('Fleets') - cy.get('[data-label="Name"]').contains(fleetname) - cy.get('.pf-v6-c-table__tbody > .pf-v6-c-table__tr > .pf-v6-c-table__check > label > input').click() + cy.contains('td', fleetname, { timeout: 10000 }).should('be.visible') + cy.contains('td', fleetname).closest('tr').find('input[type="checkbox"]').click() cy.get('[data-testid="toolbar-delete-fleets"]').should('be.visible') cy.get('[data-testid="toolbar-delete-fleets"]').click() cy.get('[data-testid="modal-delete-fleets-confirm"]').should('be.visible') diff --git a/cypress/views/securityPage.js b/cypress/views/securityPage.js index 07ebe25..091d556 100644 --- a/cypress/views/securityPage.js +++ b/cypress/views/securityPage.js @@ -6,7 +6,8 @@ import { common } from './common' */ /** Security overview card title */ -const SECURITY_OVERVIEW_CARD = '.pf-v6-c-card__title-text:contains("Security overview")' +const SECURITY_OVERVIEW_CARD = + '.pf-v6-c-card__title-text:contains("Security overview")' /** Filter by severity dropdown button */ const SEVERITY_FILTER_TOGGLE = 'button[aria-label="Filter by severity"]' @@ -25,6 +26,13 @@ const NO_VULNERABILITIES_EMPTY_STATE = '.pf-v6-c-empty-state' /** CVE details panel/drawer (when clicking on a CVE) */ const CVE_DETAILS_PANEL = '[role="dialog"], .pf-v6-c-drawer__panel' +const CVE_DETAILS_DRAWER = + '.pf-v6-c-drawer__panel:has(button[aria-label="Close drawer panel"])' +const CARD = '.pf-v6-c-card' +const CARD_TITLE = '.pf-v6-c-card__title-text' +const MENU_ITEM = '.pf-v6-c-menu li' +const CHIP_GROUP_CLOSE = '.pf-v6-c-chip-group__close button' +const EXPANDABLE_ROW = '.pf-v6-c-table__expandable-row' /** Close button for CVE details */ const CLOSE_DETAILS_BUTTON = 'button[aria-label="Close drawer panel"]' @@ -93,6 +101,27 @@ export const securityPage = { }) }, + /** + * Expand EntitySecurityOverviewCard on Device/Fleet pages (collapsed by default in + * current flightctl-ui). Overview page has no toggle — this is a no-op there. + */ + _expandSecurityCardIfNeeded(timeout = 15000) { + cy.scrollTo('bottom', { ensureScrollable: false }) + cy.get(CARD, { timeout }).then(($cards) => { + const card = $cards.filter((_, el) => + Cypress.$(el).find(CARD_TITLE).text().includes('Security overview'), + ) + if (!card.length) { + return + } + const toggle = card.find('button[aria-label="Toggle security details"]') + if (toggle.length > 0 && toggle.attr('aria-expanded') !== 'true') { + cy.wrap(toggle).click() + cy.wait(500) + } + }) + }, + /** * Verify vulnerability count is displayed. * @@ -112,6 +141,7 @@ export const securityPage = { * "0 CVEs" check at test start where no waiting is needed. */ expectVulnerabilityCount(count, timeout = 90000) { + this._expandSecurityCardIfNeeded(Math.min(timeout, 30000)) if (count === 0) { // Everything (card search, scroll, text assertion) inside one .should() so Cypress // retries the whole block on every tick within `timeout`. If we chain .contains() off @@ -137,7 +167,14 @@ export const securityPage = { text.includes('0') && (text.includes('Total active vulnerabilities') || text.includes('total active vulnerabilities')) - expect(hasNoVulnState || hasZeroTotal, 'Should show 0 vulnerabilities').to.be.true + const severityGrid = card.first().find('[aria-label="Vulnerability counts by severity"]') + const hasSeverityTilesAllZero = + severityGrid.length > 0 && + severityGrid + .find('.pf-v6-u-font-size-xl strong') + .toArray() + .every((el) => el.textContent.trim() === '0') + expect(hasNoVulnState || hasZeroTotal || hasSeverityTilesAllZero, 'Should show 0 vulnerabilities').to.be.true }) } else { // scroll + assert inside one .should() so the timeout covers the whole retry loop @@ -166,9 +203,19 @@ export const securityPage = { // each CVE has a companion that also // contains td[data-label] elements, doubling the count without this exclusion. const cveRows = card.first().find( - 'table[aria-label="Vulnerabilities table"] tbody tr:not(.pf-v6-c-table__expandable-row) td[data-label]' + `table[aria-label="Vulnerabilities table"] tbody tr:not(${EXPANDABLE_ROW}) td[data-label]` ).closest('tr') - expect(cveRows.length, `Expected ${count} CVE rows in vulnerability table`).to.equal(count) + if (cveRows.length > 0) { + expect(cveRows.length, `Expected ${count} CVE rows in vulnerability table`).to.equal(count) + } else { + const severityGrid = card.first().find('[aria-label="Vulnerability counts by severity"]') + expect(severityGrid.length, 'Severity summary grid should exist').to.be.gt(0) + const total = severityGrid + .find('.pf-v6-u-font-size-xl strong') + .toArray() + .reduce((sum, el) => sum + parseInt(el.textContent.trim() || '0', 10), 0) + expect(total, `Expected severity tiles to sum to ${count}`).to.equal(count) + } } }) } @@ -188,8 +235,9 @@ export const securityPage = { * @param {object} counts - Object with critical, high, medium, low counts */ expectSeverityCounts(counts) { - cy.get('.pf-v6-c-card').contains('.pf-v6-c-card__title-text', 'Security overview') - .parents('.pf-v6-c-card') + this._expandSecurityCardIfNeeded() + cy.get(CARD).contains(CARD_TITLE, 'Security overview') + .parents(CARD) .then(($card) => { if (!$card.text().includes('Total active vulnerabilities')) { // Device/Fleet page — no severity stat boxes, nothing to verify here. @@ -258,7 +306,7 @@ export const securityPage = { expectCveDrawerContent(cveId, severityLabel) { // ACM mode keeps a persistent .pf-v6-c-drawer__panel in the DOM. Scope to the CVE details // panel specifically by requiring it to contain the drawer close button. - cy.get('.pf-v6-c-drawer__panel:has(button[aria-label="Close drawer panel"])').within(() => { + cy.get(CVE_DETAILS_DRAWER).within(() => { cy.contains('h3', cveId).should('be.visible') cy.contains('Severity').should('be.visible') cy.contains(severityLabel).should('be.visible') @@ -274,7 +322,7 @@ export const securityPage = { // The CVE drawer (FlightCtlPageDrawer) is fully unmounted on close — the portal // disappears. Assert the scoped panel is gone rather than [role="dialog"], since // ACM may keep other role=dialog elements in the DOM at all times. - cy.get('.pf-v6-c-drawer__panel:has(button[aria-label="Close drawer panel"])', { timeout: 15000 }).should('not.exist') + cy.get(CVE_DETAILS_DRAWER, { timeout: 15000 }).should('not.exist') }, /** @@ -319,7 +367,7 @@ export const securityPage = { // Guards against the 10s useFetchPeriodically refetch that sets isUpdating=true and // temporarily replaces rows with a spinner, causing a 0-row false negative on filter apply. cy.get(VULNERABILITIES_TABLE, { timeout: 30000 }).should(($table) => { - const rows = $table.find('tbody tr:not(.pf-v6-c-table__expandable-row) td[data-label]').closest('tr') + const rows = $table.find(`tbody tr:not(${EXPANDABLE_ROW}) td[data-label]`).closest('tr') expect(rows.length, 'Table should have rows before filtering').to.be.gt(0) }) @@ -333,7 +381,7 @@ export const securityPage = { // Scope to .pf-v6-c-menu to avoid matching ACM sidebar
  • elements (those are inside // .pf-v6-c-page__sidebar). Do NOT use force:true — a real click is needed so React's synthetic // onClick on SelectOption fires toggleSeverityFilter. - cy.contains('.pf-v6-c-menu li', severityDisplayLabel).should('be.visible').click() + cy.contains(MENU_ITEM, severityDisplayLabel).should('be.visible').click() // Close the popup. shouldFocusToggleOnSelect returns focus to the toggle, which may auto-close // it. Conditionally click to close only if still open — avoids double-toggling. cy.get(SEVERITY_FILTER_TOGGLE, { timeout: 5000 }).then(($btn) => { @@ -351,7 +399,7 @@ export const securityPage = { // then the table. Wait up to 30s for the table to have the expected non-empty tbody count. cy.get(VULNERABILITIES_TABLE, { timeout: 30000 }).should('exist') cy.get(VULNERABILITIES_TABLE, { timeout: 30000 }).should(($table) => { - const cveRows = $table.find('tbody tr:not(.pf-v6-c-table__expandable-row) td[data-label]').closest('tr') + const cveRows = $table.find(`tbody tr:not(${EXPANDABLE_ROW}) td[data-label]`).closest('tr') expect(cveRows.length, `Expected ${expectedCount} filtered CVE rows`).to.equal(expectedCount) }) } @@ -359,7 +407,7 @@ export const securityPage = { // Deselect the filter (toggle it off), then wait for the table to settle before returning. cy.get(SEVERITY_FILTER_TOGGLE).should('be.visible').click() cy.get(SEVERITY_FILTER_TOGGLE).should('have.attr', 'aria-expanded', 'true') - cy.contains('.pf-v6-c-menu li', severityDisplayLabel).should('be.visible').click() + cy.contains(MENU_ITEM, severityDisplayLabel).should('be.visible').click() cy.get(SEVERITY_FILTER_TOGGLE, { timeout: 5000 }).then(($btn) => { if ($btn.attr('aria-expanded') === 'true') { cy.wrap($btn).click() @@ -378,8 +426,8 @@ export const securityPage = { cy.get('body').then(($body) => { if ($body.find('button:contains("Clear all filters")').length > 0) { cy.contains('button', 'Clear all filters').click() - } else if ($body.find('.pf-v6-c-chip-group__close button').length > 0) { - cy.get('.pf-v6-c-chip-group__close button').click() + } else if ($body.find(CHIP_GROUP_CLOSE).length > 0) { + cy.get(CHIP_GROUP_CLOSE).click() } }) }, @@ -420,7 +468,7 @@ export const securityPage = { } else { cy.get(VULNERABILITIES_TABLE, { timeout }).should('exist') cy.get(VULNERABILITIES_TABLE).should(($table) => { - const cveRows = $table.find('tbody tr:not(.pf-v6-c-table__expandable-row) td[data-label]').closest('tr') + const cveRows = $table.find(`tbody tr:not(${EXPANDABLE_ROW}) td[data-label]`).closest('tr') expect(cveRows.length, `Expected ${expectedCount} CVE rows`).to.equal(expectedCount) }) } @@ -471,38 +519,42 @@ export const securityPage = { * @param {Function} opts.beforeReload - Optional Cypress command chain to run before reload (e.g. re-patch device status) */ waitForVulnerabilityCountWithReload(count, { firstWaitMs = 60000, reloadWaitMs = 120000, beforeReload = null } = {}) { - // Helper: check whether the Security overview card currently shows the expected CVE count. - // Runs synchronously inside a Cypress .then() / .should() callback. + this._expandSecurityCardIfNeeded() const isCveCountVisible = ($cards) => { const card = $cards.filter((_, el) => - Cypress.$(el).find('.pf-v6-c-card__title-text').text().includes('Security overview') + Cypress.$(el).find(CARD_TITLE).text().includes('Security overview'), ) if (!card.length) return false card[0].scrollIntoView({ behavior: 'instant', block: 'end' }) const $c = card.first() const text = $c.text() + const severityGrid = $c.find('[aria-label="Vulnerability counts by severity"]') + const severityTotal = severityGrid + .find('.pf-v6-u-font-size-xl strong') + .toArray() + .reduce((sum, el) => sum + parseInt(el.textContent.trim() || '0', 10), 0) if (count === 0) { - return ( + const hasEmptyCopy = text.includes('No vulnerabilities detected') || text.includes('No vulnerabilities were found') || text.includes('No CVEs detected') || text.includes('No vulnerability data to display') || (text.includes('0') && text.includes('Total active vulnerabilities')) - ) + const tilesAllZero = + severityGrid.length > 0 && + severityGrid + .find('.pf-v6-u-font-size-xl strong') + .toArray() + .every((el) => el.textContent.trim() === '0') + return hasEmptyCopy || tilesAllZero } else if (text.includes('Total active vulnerabilities')) { - // Overview page: the count is in a large-font element whose text is exactly the number. - // text.includes(count) is too loose and false-positives on any digit in the card. const countText = $c.find('.pf-v6-u-font-size-4xl').text().trim() return countText === count.toString() - } else { - // Device / Fleet page: count actual CVE rows (tr elements whose td has data-label). - // Exclude expandable-row siblings — the Fleet table uses PF6 expandable rows where - // each CVE has a companion tr.pf-v6-c-table__expandable-row that doubles the count. - const cveRows = $c.find( - 'table[aria-label="Vulnerabilities table"] tbody tr:not(.pf-v6-c-table__expandable-row) td[data-label]' - ).closest('tr') - return cveRows.length === count } + const cveRows = $c + .find(`table[aria-label="Vulnerabilities table"] tbody tr:not(${EXPANDABLE_ROW}) td[data-label]`) + .closest('tr') + return cveRows.length === count || (severityGrid.length > 0 && severityTotal === count) } // Phase 1: poll the live DOM for up to firstWaitMs. The UI's 10s auto-fetch cycle @@ -522,7 +574,7 @@ export const securityPage = { // cy.get uses a 15s timeout so the card has time to appear after ACM page loads // (default 4s is too short for ACM/headless mode). const poll = (remaining) => { - cy.get('.pf-v6-c-card', { timeout: 15000 }).then(($cards) => { + cy.get(CARD, { timeout: 15000 }).then(($cards) => { if (isCveCountVisible($cards)) { cy.log(`✓ CVE count ${count} reached — no reload needed`) return