diff --git a/integration-tests/cypress.config.js b/integration-tests/cypress.config.js index fc195f43..14aed7d6 100644 --- a/integration-tests/cypress.config.js +++ b/integration-tests/cypress.config.js @@ -9,6 +9,9 @@ module.exports = defineConfig({ specPattern: 'tests/**/*.cy.{js,jsx,ts,tsx}', supportFile: 'support/index.ts', }, + env: { + openshift: true, + }, fixturesFolder: 'fixtures', reporter: '../../node_modules/cypress-multi-reporters', reporterOptions: { diff --git a/integration-tests/fixtures/endpoint-health.yaml b/integration-tests/fixtures/endpoint-health.yaml new file mode 100644 index 00000000..3c21dcc5 --- /dev/null +++ b/integration-tests/fixtures/endpoint-health.yaml @@ -0,0 +1,183 @@ +# Deterministic health fixtures for Services / Routes list E2E. +# +# Healthy: real Deployment + Service (selector-based EndpointSlices created by the control plane) +# Degraded / Down: selector-less Services + handcrafted EndpointSlices (scale-down is flaky for Degraded) +# ExternalName: no endpoints → Unknown +# Route: points at the Healthy service for Backend health assertions +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: eph-healthy-ingress + labels: + app: eph-healthy +spec: + podSelector: + matchLabels: + app: eph-healthy + policyTypes: + - Ingress + ingress: + - ports: + - protocol: TCP + port: 8080 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: eph-healthy + labels: + app: eph-healthy +spec: + replicas: 1 + selector: + matchLabels: + app: eph-healthy + template: + metadata: + labels: + app: eph-healthy + spec: + automountServiceAccountToken: false + containers: + - name: hello + image: quay.io/openshifttest/hello-openshift:1.2.0 + ports: + - containerPort: 8080 + livenessProbe: + httpGet: + path: / + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + httpGet: + path: / + port: 8080 + initialDelaySeconds: 1 + periodSeconds: 5 + resources: + limits: + cpu: 50m + memory: 64Mi + requests: + cpu: 10m + memory: 32Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault +--- +apiVersion: v1 +kind: Service +metadata: + name: eph-healthy + labels: + app: eph-healthy +spec: + selector: + app: eph-healthy + ports: + - name: http + port: 80 + targetPort: 8080 +--- +apiVersion: route.openshift.io/v1 +kind: Route +metadata: + name: eph-healthy + labels: + app: eph-healthy +spec: + to: + kind: Service + name: eph-healthy + port: + targetPort: http +--- +apiVersion: v1 +kind: Service +metadata: + name: eph-degraded + labels: + app: eph-degraded +spec: + ports: + - name: http + port: 80 + targetPort: 8080 +--- +apiVersion: discovery.k8s.io/v1 +kind: EndpointSlice +metadata: + name: eph-degraded-slice + labels: + kubernetes.io/service-name: eph-degraded + app: eph-degraded +addressType: IPv4 +ports: + - name: http + protocol: TCP + port: 8080 +endpoints: + - addresses: + - 192.0.2.1 + conditions: + ready: true + - addresses: + - 192.0.2.2 + conditions: + ready: false +--- +apiVersion: v1 +kind: Service +metadata: + name: eph-down + labels: + app: eph-down +spec: + ports: + - name: http + port: 80 + targetPort: 8080 +--- +apiVersion: discovery.k8s.io/v1 +kind: EndpointSlice +metadata: + name: eph-down-slice + labels: + kubernetes.io/service-name: eph-down + app: eph-down +addressType: IPv4 +ports: + - name: http + protocol: TCP + port: 8080 +endpoints: + - addresses: + - 192.0.2.10 + conditions: + ready: false + - addresses: + - 192.0.2.11 + conditions: + ready: false +--- +apiVersion: v1 +kind: Service +metadata: + name: eph-external + labels: + app: eph-external +spec: + type: ExternalName + externalName: example.com diff --git a/integration-tests/support/commands.ts b/integration-tests/support/commands.ts new file mode 100644 index 00000000..770e62ca --- /dev/null +++ b/integration-tests/support/commands.ts @@ -0,0 +1,114 @@ +import Loggable = Cypress.Loggable; +import Shadow = Cypress.Shadow; +import Timeoutable = Cypress.Timeoutable; +import Withinable = Cypress.Withinable; + +export const MINUTE = 60 * 1000; + +export const itemFilter = '[data-test-id="item-filter"]'; +export const resourceRow = '[data-test-rows="resource-row"]'; + +type EndpointHealthStatus = 'Degraded' | 'Down' | 'Healthy' | 'Unknown'; + +const assertOcSuccess = ( + result: { + code: number; + stderr: string; + stdout: string; + }, + message: string, +): void => { + // On some macOS/Electron setups Cypress omits `code` even for successful commands. + const code = result.code ?? 0; + expect(code, message).to.eq(0); +}; + +const healthAriaLabel = (status: EndpointHealthStatus, ready?: number, total?: number): string => { + if (status === 'Unknown') { + return 'Unknown: endpoint readiness not available'; + } + return `${status}: ${ready} of ${total} endpoints ready`; +}; + +declare global { + namespace Cypress { + interface Chainable { + applyFixture(fixturePath: string, namespace: string): Chainable; + assertEndpointHealth( + name: string, + status: EndpointHealthStatus, + ready?: number, + total?: number, + ): Chainable; + byTestID( + selector: string, + options?: Partial, + ): Chainable; + deleteNamespace(namespace: string): Chainable; + ensureNamespace(namespace: string): Chainable; + filterByName(name: string): Chainable; + getResourceRow(name: string): Chainable; + } + } +} + +Cypress.Commands.add( + 'byTestID', + (selector: string, options?: Partial) => + cy.get(`[data-test="${selector}"]`, options), +); + +Cypress.Commands.add('ensureNamespace', (namespace: string) => { + cy.exec(`oc create namespace ${namespace}`, { failOnNonZeroExit: false }).then((result) => { + const code = result.code ?? 0; + const alreadyExists = /AlreadyExists/i.test(`${result.stderr || ''}${result.stdout || ''}`); + if (code !== 0 && !alreadyExists) { + expect(code, 'failed to create namespace').to.eq(0); + } + }); + cy.exec(`oc project ${namespace}`, { failOnNonZeroExit: false }).then((result) => { + assertOcSuccess(result, 'failed to select namespace'); + }); +}); + +Cypress.Commands.add('deleteNamespace', (namespace: string) => { + cy.exec(`oc delete namespace ${namespace} --ignore-not-found=true --wait=false`, { + failOnNonZeroExit: false, + timeout: 2 * MINUTE, + }); +}); + +Cypress.Commands.add('applyFixture', (fixturePath: string, namespace: string) => { + cy.exec(`oc apply -n ${namespace} -f "${fixturePath}"`, { + failOnNonZeroExit: false, + timeout: 2 * MINUTE, + }).then((result) => { + assertOcSuccess(result, 'oc apply failed'); + }); +}); + +Cypress.Commands.add('filterByName', (name: string) => { + cy.get(itemFilter, { timeout: MINUTE }).should('be.visible').clear(); + cy.get(itemFilter, { timeout: MINUTE }).type(name); +}); + +Cypress.Commands.add('getResourceRow', (name: string) => + cy.contains(resourceRow, name, { timeout: MINUTE }).should('exist'), +); + +Cypress.Commands.add( + 'assertEndpointHealth', + (name: string, status: EndpointHealthStatus, ready?: number, total?: number) => { + const ariaLabel = healthAriaLabel(status, ready, total); + + cy.getResourceRow(name).within(() => { + cy.get(`[aria-label="${ariaLabel}"]`, { timeout: MINUTE }).should('exist'); + + if (status === 'Unknown') { + cy.contains('Unknown').should('exist'); + } else if (ready !== undefined && total !== undefined) { + cy.contains(`${ready}/${total}`).should('exist'); + } + }); + }, +); diff --git a/integration-tests/support/index.ts b/integration-tests/support/index.ts index 808cd893..1ec1c8e7 100644 --- a/integration-tests/support/index.ts +++ b/integration-tests/support/index.ts @@ -1,4 +1,4 @@ -// Import commands.js using ES2015 syntax: +import './commands'; import './login'; export const checkErrors = () => diff --git a/integration-tests/support/login.ts b/integration-tests/support/login.ts index 0c590742..577c0ea9 100644 --- a/integration-tests/support/login.ts +++ b/integration-tests/support/login.ts @@ -1,38 +1,63 @@ declare global { namespace Cypress { interface Chainable { - login(username?: string, password?: string): Chainable; - logout(): Chainable; + login(providerName?: string, username?: string, password?: string): Chainable; + logout(): void; } } } const KUBEADMIN_USERNAME = 'kubeadmin'; -const loginUsername = Cypress.env('BRIDGE_KUBEADMIN_PASSWORD') ? 'user-dropdown' : 'username'; +const KUBEADMIN_IDP = 'kube:admin'; +const TOUR_DISMISS = '[data-test="tour-step-footer-secondary"]'; +const MINUTE = 60 * 1000; -// This will add 'cy.login(...)' -// ex: cy.login('my-user', 'my-password') -Cypress.Commands.add('login', (username: string, password: string) => { - // Check if auth is disabled (for a local development environment). - cy.visit('/'); // visits baseUrl which is set in plugins/index.js +Cypress.Commands.add('login', (provider?: string, username?: string, password?: string) => { + const usr = username || KUBEADMIN_USERNAME; + const pwd = password || Cypress.env('BRIDGE_KUBEADMIN_PASSWORD'); + const idp = provider || KUBEADMIN_IDP; + + cy.visit('/'); cy.window().then((win) => { if (win.SERVER_FLAGS?.authDisabled) { return; } - // Make sure we clear the cookie in case a previous test failed to logout. cy.clearCookie('openshift-session-token'); - cy.get('#inputUsername').type(username || KUBEADMIN_USERNAME); - cy.get('#inputPassword').type(password || Cypress.env('BRIDGE_KUBEADMIN_PASSWORD')); - cy.get('button[type=submit]').click(); + cy.origin( + Cypress.config('baseUrl').replace('console-openshift-console', 'oauth-openshift'), + { args: { idp, pwd, usr } }, + ({ idp: originIdp, pwd: originPwd, usr: originUsr }) => { + cy.get('body', { timeout: 180000 }).should('be.visible'); + cy.get('body').then(($body) => { + if ($body.find('#inputUsername').length === 0) { + if ($body.text().includes(originIdp)) { + cy.contains('a', originIdp).click(); + } else if ($body.text().includes('kubeadmin')) { + cy.contains('a', 'kubeadmin').click(); + } else { + cy.get('a').first().click(); + } + } + }); + cy.get('#inputUsername', { timeout: 180000 }).should('be.visible'); + cy.get('#inputUsername').type(originUsr); + cy.get('#inputPassword').type(originPwd, { log: false }); + cy.get('button[type=submit]').click(); + }, + ); - cy.get(`[data-test="${loginUsername}"]`).should('be.visible'); + cy.url({ timeout: 2 * MINUTE }).should('include', 'console-openshift-console'); + cy.get('body').then(($body) => { + if ($body.find(TOUR_DISMISS).length) { + cy.get(TOUR_DISMISS).click(); + } + }); }); }); Cypress.Commands.add('logout', () => { - // Check if auth is disabled (for a local development environment). cy.window().then((win) => { if (win.SERVER_FLAGS?.authDisabled) { return; diff --git a/integration-tests/tests/service-endpoint-health.cy.ts b/integration-tests/tests/service-endpoint-health.cy.ts new file mode 100644 index 00000000..616e2ea7 --- /dev/null +++ b/integration-tests/tests/service-endpoint-health.cy.ts @@ -0,0 +1,84 @@ +import { checkErrors } from '../support'; +import { MINUTE } from '../support/commands'; + +const TEST_NS = `eph-health-${Date.now()}`; +const FIXTURE = 'fixtures/endpoint-health.yaml'; + +const visitServices = () => { + cy.visit(`/k8s/ns/${TEST_NS}/services`); + cy.contains('h1', 'Services', { timeout: MINUTE }).should('be.visible'); + cy.contains('th', 'Health', { timeout: MINUTE }).should('exist'); +}; + +const visitRoutes = () => { + cy.visit(`/k8s/ns/${TEST_NS}/routes`); + cy.contains('h1', 'Routes', { timeout: MINUTE }).should('be.visible'); + cy.contains('th', 'Backend health', { timeout: MINUTE }).should('exist'); +}; + +const waitForHealthyService = () => { + cy.exec(`oc wait --for=condition=available deployment/eph-healthy -n ${TEST_NS} --timeout=180s`, { + failOnNonZeroExit: false, + timeout: 3 * MINUTE, + }).then((result) => { + const code = result.code ?? 0; + expect(code, 'wait for deployment failed').to.eq(0); + }); + + // EndpointSlice for the selector-based Service may lag the Deployment Available condition. + cy.exec( + `oc wait endpointslices -n ${TEST_NS} -l kubernetes.io/service-name=eph-healthy --for=jsonpath='{.endpoints[0].conditions.ready}'=true --timeout=120s`, + { failOnNonZeroExit: false, timeout: 2 * MINUTE }, + ).then((result) => { + const code = result.code ?? 0; + expect(code, 'wait for EndpointSlice failed').to.eq(0); + }); +}; + +describe('OCPNETUI-59: Service and Route endpoint health', () => { + before(() => { + cy.login(); + cy.ensureNamespace(TEST_NS); + cy.applyFixture(FIXTURE, TEST_NS); + waitForHealthyService(); + }); + + afterEach(() => { + checkErrors(); + }); + + after(() => { + cy.deleteNamespace(TEST_NS); + cy.logout(); + }); + + it('shows Healthy with ready/total for a Service backed by ready pods', () => { + visitServices(); + cy.filterByName('eph-healthy'); + cy.assertEndpointHealth('eph-healthy', 'Healthy', 1, 1); + }); + + it('shows Degraded with partial ready/total from EndpointSlice fixture', () => { + visitServices(); + cy.filterByName('eph-degraded'); + cy.assertEndpointHealth('eph-degraded', 'Degraded', 1, 2); + }); + + it('shows Down as 0/N when no endpoints are ready', () => { + visitServices(); + cy.filterByName('eph-down'); + cy.assertEndpointHealth('eph-down', 'Down', 0, 2); + }); + + it('shows Unknown for ExternalName Services', () => { + visitServices(); + cy.filterByName('eph-external'); + cy.assertEndpointHealth('eph-external', 'Unknown'); + }); + + it('shows matching Backend health on the Routes list', () => { + visitRoutes(); + cy.filterByName('eph-healthy'); + cy.assertEndpointHealth('eph-healthy', 'Healthy', 1, 1); + }); +}); diff --git a/package-lock.json b/package-lock.json index 8a063d2a..d9451d94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@patternfly/react-charts": "~8.5.1", "@patternfly/react-component-groups": "~6.5.0", "@patternfly/react-core": "~6.5.1", - "@patternfly/react-data-view": "~6.5.0", + "@patternfly/react-data-view": "6.5.0", "@patternfly/react-drag-drop": "~6.5.1", "@patternfly/react-icons": "~6.5.1", "@patternfly/react-styles": "~6.5.1",