Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions integration-tests/cypress.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const { defineConfig } = require('cypress');

module.exports = defineConfig({
chromeWebSecurity: false,
defaultCommandTimeout: 30000,
e2e: {
setupNodeEvents(on, config) {
Expand All @@ -9,6 +10,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: {
Expand Down
183 changes: 183 additions & 0 deletions integration-tests/fixtures/endpoint-health.yaml
Original file line number Diff line number Diff line change
@@ -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
115 changes: 115 additions & 0 deletions integration-tests/support/commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
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<Loggable & Shadow & Timeoutable & Withinable>,
): 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<Loggable & Shadow & Timeoutable & Withinable>) =>
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}{enter}`);
cy.contains(resourceRow, name, { timeout: MINUTE }).should('exist');
});

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');
}
});
},
);
43 changes: 41 additions & 2 deletions integration-tests/support/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,46 @@
// Import commands.js using ES2015 syntax:
import './commands';
import './login';

declare global {
interface Window {
SERVER_FLAGS?: { authDisabled?: boolean };
windowError?: string;
}
}

const NETWORKING_I18N_NS = 'plugin__networking-console-plugin';

/**
* Console aggregates missing-key warnings into window.windowError.
* Ignore keys from other dynamic plugins (e.g. kubevirt) that this suite does not own.
*/
const isIgnorableWindowError = (raw: unknown): boolean => {
const message = typeof raw === 'string' ? raw : String(raw ?? '');
if (!message) {
return true;
}

const parts = message
.split(';')
.map((part) => part.trim())
.filter(Boolean);

if (parts.length === 0) {
return true;
}

return parts.every((part) => {
const match = part.match(/Missing i18n key ".+" in namespace "([^"]+)"/);
return match !== null && match[1] !== NETWORKING_I18N_NS;
});
};

export const checkErrors = () =>
cy.window().then((win) => {
assert.isTrue(!win.windowError, win.windowError);
const err = win.windowError;
if (!err || isIgnorableWindowError(err)) {
win.windowError = undefined;
return;
}
assert.isTrue(!err, err);
});
Loading