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