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