From 80469bb27f5cda7d4ee6e510ec36a0a82b2eec75 Mon Sep 17 00:00:00 2001 From: Leon Kladnitsky Date: Sun, 23 Aug 2026 14:39:39 +0300 Subject: [PATCH] OCPNETUI-22: Add Cypress E2E tests for Service create and edit form Cover ClusterIP, NodePort, LoadBalancer, and ExternalName workflows, plus validation, form/YAML sync, edit, and delete. Add data-test attributes so selectors stay stable in CI. Use the core~v1~Service path for details navigation and skip a redundant reload when already on the page. Reorder the ConsoleWindow intersection so perfectionist lint passes. Co-authored-by: Cursor --- integration-tests/cypress.config.js | 2 + integration-tests/plugins/index.ts | 6 + integration-tests/support/index.ts | 1 + integration-tests/support/login.ts | 73 ++++-- integration-tests/support/selectors.ts | 26 ++ integration-tests/support/service-form.ts | 222 ++++++++++++++++ integration-tests/tests/service-form.cy.ts | 243 ++++++++++++++++++ .../ActionDropdownItem/ActionDropdownItem.tsx | 1 + .../ActionsDropdown/ActionsDropdown.tsx | 7 +- .../LabelSelectorEditor.tsx | 10 +- .../components/SyncedEditor/EditorToggle.tsx | 1 + src/views/services/form/ExternalNameField.tsx | 1 + src/views/services/form/ServiceForm.tsx | 2 + .../services/form/ServiceFormActions.tsx | 8 +- src/views/services/form/ServiceFormPage.tsx | 2 +- src/views/services/form/ServiceTypeFields.tsx | 1 + src/views/services/form/ServiceTypeSelect.tsx | 2 + 17 files changed, 588 insertions(+), 20 deletions(-) create mode 100644 integration-tests/support/selectors.ts create mode 100644 integration-tests/support/service-form.ts create mode 100644 integration-tests/tests/service-form.cy.ts diff --git a/integration-tests/cypress.config.js b/integration-tests/cypress.config.js index fc195f43..98a5fcb5 100644 --- a/integration-tests/cypress.config.js +++ b/integration-tests/cypress.config.js @@ -1,6 +1,7 @@ const { defineConfig } = require('cypress'); module.exports = defineConfig({ + chromeWebSecurity: false, defaultCommandTimeout: 30000, e2e: { setupNodeEvents(on, config) { @@ -10,6 +11,7 @@ module.exports = defineConfig({ supportFile: 'support/index.ts', }, fixturesFolder: 'fixtures', + pageLoadTimeout: 120000, reporter: '../../node_modules/cypress-multi-reporters', reporterOptions: { configFile: 'reporter-config.json', diff --git a/integration-tests/plugins/index.ts b/integration-tests/plugins/index.ts index 6033dbcd..bde14f98 100644 --- a/integration-tests/plugins/index.ts +++ b/integration-tests/plugins/index.ts @@ -18,6 +18,12 @@ module.exports = (on, config) => { }, }; on('file:preprocessor', wp(options)); + on('before:browser:launch', (browser = {}, launchOptions) => { + if (browser.family === 'chromium' || browser.name === 'electron') { + launchOptions.args.push('--ignore-certificate-errors'); + } + return launchOptions; + }); // `config` is the resolved Cypress config config.baseUrl = `${process.env.BRIDGE_BASE_ADDRESS || 'http://localhost:9000/'}`; config.env.BRIDGE_KUBEADMIN_PASSWORD = process.env.BRIDGE_KUBEADMIN_PASSWORD; diff --git a/integration-tests/support/index.ts b/integration-tests/support/index.ts index 808cd893..3c55d4f4 100644 --- a/integration-tests/support/index.ts +++ b/integration-tests/support/index.ts @@ -1,5 +1,6 @@ // Import commands.js using ES2015 syntax: import './login'; +import './selectors'; export const checkErrors = () => cy.window().then((win) => { diff --git a/integration-tests/support/login.ts b/integration-tests/support/login.ts index 0c590742..eaf9548b 100644 --- a/integration-tests/support/login.ts +++ b/integration-tests/support/login.ts @@ -8,37 +8,78 @@ declare global { } const KUBEADMIN_USERNAME = 'kubeadmin'; -const loginUsername = Cypress.env('BRIDGE_KUBEADMIN_PASSWORD') ? 'user-dropdown' : 'username'; +const loggedInSelector = + '[data-test="user-dropdown-toggle"], [data-test="user-dropdown"], [data-test="username"]'; +const kubeadminAliases = new Set(['kubeadmin', 'kube:admin']); + +type ConsoleWindow = { SERVER_FLAGS?: { authDisabled?: boolean } } & Window; + +const typeLoginForm = (user: string, pwd: string) => { + cy.get('#inputUsername').type(user); + cy.get('#inputPassword').type(pwd, { log: false }); + cy.get('#co-login-button, button[type=submit]').click(); +}; + +const displayedIdentity = ($body: JQuery): string => + $body.find(loggedInSelector).first().text().trim().toLowerCase(); + +const isDisplayedUser = (user: string, $body: JQuery): boolean => { + const displayed = displayedIdentity($body); + const requested = user.toLowerCase(); + if (kubeadminAliases.has(requested)) { + return kubeadminAliases.has(displayed) || displayed.includes('kube:admin'); + } + return Boolean(displayed) && displayed.includes(requested); +}; + +const logoutFromMasthead = () => { + cy.get(loggedInSelector).first().click(); + cy.get('[data-test="log-out"]').should('be.visible'); + cy.get('[data-test="log-out"]').click({ force: true }); +}; // 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 - cy.window().then((win) => { +Cypress.Commands.add('login', (username?: string, password?: string) => { + const user = username || KUBEADMIN_USERNAME; + const pwd = password || Cypress.env('BRIDGE_KUBEADMIN_PASSWORD'); + + cy.visit('/'); + cy.window().then((win: ConsoleWindow) => { 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, ${loggedInSelector}`, { timeout: 60000 }).should('exist'); + cy.get('body').then(($body) => { + if ($body.find('#inputUsername').length) { + typeLoginForm(user, pwd); + return; + } - cy.get('#inputUsername').type(username || KUBEADMIN_USERNAME); - cy.get('#inputPassword').type(password || Cypress.env('BRIDGE_KUBEADMIN_PASSWORD')); - cy.get('button[type=submit]').click(); + const reuseSession = !username || isDisplayedUser(user, $body); + if (reuseSession) { + return; + } - cy.get(`[data-test="${loginUsername}"]`).should('be.visible'); + logoutFromMasthead(); + cy.get('#inputUsername', { timeout: 60000 }).should('be.visible'); + typeLoginForm(user, pwd); + }); + cy.get(loggedInSelector, { timeout: 120000 }).should('be.visible'); }); }); Cypress.Commands.add('logout', () => { - // Check if auth is disabled (for a local development environment). - cy.window().then((win) => { + cy.window().then((win: ConsoleWindow) => { if (win.SERVER_FLAGS?.authDisabled) { return; } - cy.get('[data-test="user-dropdown"]').click(); - cy.get('[data-test="log-out"]').should('be.visible'); - cy.get('[data-test="log-out"]').click({ force: true }); + cy.get('body').then(($body) => { + if (!$body.find(loggedInSelector).length) { + return; + } + logoutFromMasthead(); + }); }); }); diff --git a/integration-tests/support/selectors.ts b/integration-tests/support/selectors.ts new file mode 100644 index 00000000..0fb6af39 --- /dev/null +++ b/integration-tests/support/selectors.ts @@ -0,0 +1,26 @@ +declare global { + namespace Cypress { + interface Chainable { + byLegacyTestID( + selector: string, + options?: Partial< + Cypress.Loggable & Cypress.Shadow & Cypress.Timeoutable & Cypress.Withinable + >, + ): Chainable>; + byTestID( + selector: string, + options?: Partial< + Cypress.Loggable & Cypress.Shadow & Cypress.Timeoutable & Cypress.Withinable + >, + ): Chainable>; + } + } +} + +Cypress.Commands.add('byTestID', (selector, options) => { + cy.get(`[data-test="${selector}"]`, options); +}); + +Cypress.Commands.add('byLegacyTestID', (selector, options) => { + cy.get(`[data-test-id="${selector}"]`, options); +}); diff --git a/integration-tests/support/service-form.ts b/integration-tests/support/service-form.ts new file mode 100644 index 00000000..fc0b70d1 --- /dev/null +++ b/integration-tests/support/service-form.ts @@ -0,0 +1,222 @@ +export const SERVICE_FORM_NS = 'default'; + +const byTestOr = (dataTest: string, fallback: string): Cypress.Chainable> => + cy.get(`[data-test="${dataTest}"], ${fallback}`); + +export const uniqueServiceName = (prefix: string): string => + `${prefix}-${Date.now().toString().slice(-8)}`; + +export const serviceFormUrl = (namespace = SERVICE_FORM_NS): string => + `/k8s/ns/${namespace}/core~v1~Service/~new/form`; + +export const serviceDetailsUrl = (name: string, namespace = SERVICE_FORM_NS): string => + `/k8s/ns/${namespace}/core~v1~Service/${name}`; + +export const serviceEditUrl = (name: string, namespace = SERVICE_FORM_NS): string => + `/k8s/ns/${namespace}/core~v1~Service/${name}/form`; + +export const dismissGuidedTourIfPresent = (): void => { + cy.get('body').then(($body) => { + if ($body.find('[data-test="tour-step-footer-secondary"]').length) { + cy.byTestID('tour-step-footer-secondary').click(); + } + }); +}; + +export const serviceNameField = () => byTestOr('service-name', '#service-name'); +export const serviceNamespaceField = () => byTestOr('service-namespace', '#service-namespace'); +export const serviceTypeToggle = () => byTestOr('service-type', '#toggle-service-type'); +export const servicePortsField = () => byTestOr('service-ports', '#service-ports'); +export const serviceExternalNameField = () => + byTestOr('service-external-name', '#service-external-name'); +export const saveChangesButton = () => byTestOr('save-changes', '#save-changes'); +export const selectorKeyField = () => + byTestOr('pairs-list-name', 'input[aria-labelledby="editor-label-header"]'); +export const selectorValueField = () => + byTestOr('pairs-list-value', 'input[aria-labelledby="editor-selector-header"]'); +export const addSelectorButton = () => byTestOr('pairs-list-add', 'button:contains("Add label")'); +export const deleteSelectorButton = () => + byTestOr('pairs-list-delete', '[data-test-id="pairs-list__delete-from-btn"]'); +export const actionsToggle = () => byTestOr('service-actions-toggle', 'button:contains("Actions")'); + +export const visitServiceCreateForm = (namespace = SERVICE_FORM_NS): void => { + cy.visit(serviceFormUrl(namespace)); + dismissGuidedTourIfPresent(); + cy.contains('h2', 'Create Service', { timeout: 90000 }).should('be.visible'); + cy.contains('label', 'Form view', { timeout: 60000 }).click(); + serviceNameField().should('be.visible'); +}; + +export const visitServiceEditForm = (name: string, namespace = SERVICE_FORM_NS): void => { + cy.visit(serviceEditUrl(name, namespace)); + dismissGuidedTourIfPresent(); + cy.contains('h2', 'Edit Service', { timeout: 90000 }).should('be.visible'); + cy.contains('label', 'Form view', { timeout: 60000 }).click(); + serviceNameField().should('be.visible'); +}; + +export const selectServiceType = ( + type: 'ClusterIP' | 'ExternalName' | 'LoadBalancer' | 'NodePort', +) => { + serviceTypeToggle().click(); + cy.get(`[data-test="service-type-${type}"], [role="menuitem"]`).contains(type).click(); + serviceTypeToggle().should('contain', type); +}; + +const setNativeInputValue = (input: HTMLInputElement | HTMLTextAreaElement, value: string) => { + const win = input.ownerDocument.defaultView; + if (!win) { + return; + } + const proto = + input.tagName === 'TEXTAREA' + ? win.HTMLTextAreaElement.prototype + : win.HTMLInputElement.prototype; + const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set; + setter?.call(input, value); + input.dispatchEvent(new win.Event('input', { bubbles: true })); + input.dispatchEvent(new win.Event('change', { bubbles: true })); +}; + +export const fillServiceName = (name: string): void => { + serviceNameField().then(($el) => { + setNativeInputValue($el[0] as HTMLInputElement, name); + }); + serviceNameField().should('have.value', name); +}; + +export const fillSelector = (key: string, value: string, index = 0): void => { + selectorKeyField() + .eq(index) + .then(($el) => { + setNativeInputValue($el[0] as HTMLInputElement, key); + }); + selectorValueField() + .eq(index) + .then(($el) => { + setNativeInputValue($el[0] as HTMLInputElement, value); + }); +}; + +export const fillPorts = (portsText: string): void => { + servicePortsField().then(($el) => { + setNativeInputValue($el[0] as HTMLTextAreaElement, portsText); + }); + servicePortsField().should('have.value', portsText); +}; + +export const submitServiceForm = (): void => { + saveChangesButton().should('not.be.disabled').click(); +}; + +export const getYamlEditorValue = (): Cypress.Chainable => + cy.window().then((win) => { + const monaco = ( + win as { + monaco?: { editor: { getModels: () => { getValue: () => string }[] } }; + } & Window + ).monaco; + const fromMonaco = monaco?.editor + ?.getModels() + ?.map((model) => model.getValue()) + .find((value) => value?.trim()); + if (fromMonaco) { + return fromMonaco; + } + const lines = win.document.querySelector('.yaml-editor .view-lines'); + return (lines?.textContent || '').replace(/\u00a0/g, ' '); + }); + +export const setYamlEditorValue = (value: string): void => { + cy.window().then((win) => { + const monaco = ( + win as { + monaco?: { + editor: { + getModels: () => { getValue: () => string; setValue: (next: string) => void }[]; + }; + }; + } & Window + ).monaco; + const models = monaco?.editor?.getModels() ?? []; + const target = models.find((model) => model.getValue()?.trim()) || models[0]; + if (!target) { + throw new Error('YAML editor is not available: no Monaco model found'); + } + target.setValue(value); + }); +}; + +export const switchToYamlView = (): void => { + cy.contains('label', 'YAML view').click(); + cy.get('.yaml-editor', { timeout: 30000 }).should('be.visible'); + cy.get('.yaml-editor .view-line', { timeout: 30000 }).should('contain', 'kind'); +}; + +export const switchToFormView = (): void => { + cy.contains('label', 'Form view').click(); + serviceNameField().should('be.visible'); +}; + +export const getService = (name: string, namespace = SERVICE_FORM_NS) => + cy.request({ + failOnStatusCode: false, + url: `/api/kubernetes/api/v1/namespaces/${namespace}/services/${name}`, + }); + +export const expectServiceSpec = ( + name: string, + expected: { externalName?: string; selector?: Record; type: string }, + namespace = SERVICE_FORM_NS, +) => { + getService(name, namespace).then((response) => { + expect(response.status, `Service ${name} should exist`).to.eq(200); + expect(response.body?.spec?.type).to.eq(expected.type); + if (expected.externalName) { + expect(response.body?.spec?.externalName).to.eq(expected.externalName); + } + if (expected.selector) { + expect(response.body?.spec?.selector).to.include(expected.selector); + } + }); +}; + +export const expectYamlToContain = (...snippets: string[]): void => { + cy.get('.yaml-editor', { timeout: 30000 }).should('be.visible'); + cy.get('.yaml-editor .view-lines', { timeout: 30000 }) + .invoke('text') + .then((text) => { + const normalized = String(text).replace(/\u00a0/g, ' '); + snippets.forEach((snippet) => { + expect(normalized, `YAML should contain "${snippet}"`).to.include(snippet); + }); + }); +}; + +export const confirmDeleteModal = (name: string): void => { + cy.get('body').then(($body) => { + const nameInput = $body.find( + '[data-test="delete-resource-modal"], input#resource-name, [data-test="confirm-modal-resource"]', + ); + if (nameInput.length) { + cy.wrap(nameInput.first()).clear(); + cy.wrap(nameInput.first()).type(name); + } + }); + cy.byTestID('confirm-action').click(); +}; + +export const deleteServiceFromDetails = (name: string, namespace = SERVICE_FORM_NS): void => { + cy.location('pathname').then((pathname) => { + if (!String(pathname).includes(`/${name}`)) { + cy.visit(serviceDetailsUrl(name, namespace)); + } + }); + dismissGuidedTourIfPresent(); + cy.contains('h1', name, { timeout: 60000 }).should('be.visible'); + actionsToggle().should('be.visible').click(); + cy.get('[data-test="delete-services"], [data-test-id="delete-services"]').click(); + confirmDeleteModal(name); + cy.location('pathname', { timeout: 30000 }).should('match', /service/i); + cy.location('pathname').should('not.include', `/${name}`); +}; diff --git a/integration-tests/tests/service-form.cy.ts b/integration-tests/tests/service-form.cy.ts new file mode 100644 index 00000000..7bb0ab2b --- /dev/null +++ b/integration-tests/tests/service-form.cy.ts @@ -0,0 +1,243 @@ +import { + addSelectorButton, + deleteSelectorButton, + deleteServiceFromDetails, + expectServiceSpec, + expectYamlToContain, + fillPorts, + fillSelector, + fillServiceName, + getService, + getYamlEditorValue, + saveChangesButton, + selectorKeyField, + selectorValueField, + selectServiceType, + SERVICE_FORM_NS, + serviceExternalNameField, + serviceNameField, + serviceNamespaceField, + servicePortsField, + serviceTypeToggle, + setYamlEditorValue, + submitServiceForm, + switchToFormView, + switchToYamlView, + uniqueServiceName, + visitServiceCreateForm, + visitServiceEditForm, +} from '../support/service-form'; + +describe('Service creation and editing form', { testIsolation: false }, () => { + const createdServices: string[] = []; + const track = (name: string): string => { + createdServices.push(name); + return name; + }; + + before(() => { + cy.login(); + }); + + after(() => { + createdServices.splice(0).forEach((name) => { + cy.visit(`/k8s/ns/${SERVICE_FORM_NS}/core~v1~Service/${name}`, { failOnStatusCode: false }); + cy.get('body').then(($body) => { + if ($body.find('[data-test="service-actions-toggle"], button:contains("Actions")').length) { + deleteServiceFromDetails(name); + } + }); + }); + cy.logout(); + }); + + it('creates a ClusterIP Service via form with a pod selector and a single port', () => { + const name = track(uniqueServiceName('e2e-clusterip')); + visitServiceCreateForm(); + + fillServiceName(name); + serviceNamespaceField().should('have.value', SERVICE_FORM_NS); + serviceTypeToggle().should('contain', 'ClusterIP'); + fillSelector('app', 'e2e-clusterip'); + fillPorts('http:80:8080/TCP'); + submitServiceForm(); + + cy.contains('h1', name, { timeout: 60000 }).should('be.visible'); + expectServiceSpec(name, { selector: { app: 'e2e-clusterip' }, type: 'ClusterIP' }); + }); + + it('keeps selector and ports visible for NodePort and creates the Service', () => { + const name = track(uniqueServiceName('e2e-nodeport')); + visitServiceCreateForm(); + + selectServiceType('NodePort'); + selectorKeyField().should('be.visible'); + servicePortsField().should('be.visible'); + serviceExternalNameField().should('not.exist'); + + fillServiceName(name); + fillSelector('app', 'e2e-nodeport'); + fillPorts('http:80:8080/TCP'); + submitServiceForm(); + + cy.contains('h1', name, { timeout: 60000 }).should('be.visible'); + cy.contains('Node port').should('be.visible'); + expectServiceSpec(name, { selector: { app: 'e2e-nodeport' }, type: 'NodePort' }); + }); + + it('keeps selector and ports visible for LoadBalancer and creates the Service', () => { + const name = track(uniqueServiceName('e2e-lb')); + visitServiceCreateForm(); + + selectServiceType('LoadBalancer'); + selectorKeyField().should('be.visible'); + servicePortsField().should('be.visible'); + serviceExternalNameField().should('not.exist'); + + fillServiceName(name); + fillSelector('app', 'e2e-lb'); + fillPorts('http:80:8080/TCP'); + submitServiceForm(); + + cy.contains('h1', name, { timeout: 60000 }).should('be.visible'); + expectServiceSpec(name, { selector: { app: 'e2e-lb' }, type: 'LoadBalancer' }); + }); + + it('shows ExternalName and hides selector and ports, then creates the Service', () => { + const name = track(uniqueServiceName('e2e-extname')); + visitServiceCreateForm(); + + selectServiceType('ExternalName'); + serviceExternalNameField().should('be.visible'); + selectorKeyField().should('not.exist'); + servicePortsField().should('not.exist'); + saveChangesButton().should('be.disabled'); + + fillServiceName(name); + serviceExternalNameField().type('example.com'); + submitServiceForm(); + + cy.contains('h1', name, { timeout: 60000 }).should('be.visible'); + expectServiceSpec(name, { externalName: 'example.com', type: 'ExternalName' }); + }); + + it('adds and removes multiple port entries in the ports field', () => { + visitServiceCreateForm(); + fillServiceName('e2e-ports'); + fillSelector('app', 'e2e-ports'); + + fillPorts('http:80:8080/TCP\nmetrics:9090:9090/TCP'); + servicePortsField().should('have.value', 'http:80:8080/TCP\nmetrics:9090:9090/TCP'); + saveChangesButton().should('not.be.disabled'); + + fillPorts('http:80:8080/TCP'); + servicePortsField().should('have.value', 'http:80:8080/TCP'); + }); + + it('adds selector pairs and previews matching pods', () => { + visitServiceCreateForm(); + + fillSelector('app', 'MyApp'); + addSelectorButton().click(); + selectorKeyField().should('have.length', 2); + fillSelector('tier', 'frontend', 1); + selectorKeyField().should('have.length', 2); + + deleteSelectorButton().eq(1).click(); + selectorKeyField().should('have.length', 1); + + cy.byTestID('show-matching-pods').click(); + cy.byTestID('selector-preview-title', { timeout: 30000 }).should('be.visible'); + }); + + it('syncs form changes into YAML and YAML changes back into the form', () => { + const name = uniqueServiceName('e2e-sync'); + visitServiceCreateForm(); + + fillServiceName(name); + fillSelector('app', 'synced'); + fillPorts('http:80:8080/TCP'); + selectServiceType('NodePort'); + + switchToYamlView(); + expectYamlToContain(`name: ${name}`, 'type: NodePort', 'app: synced'); + getYamlEditorValue().then((yaml) => { + const updated = yaml + .replace(`name: ${name}`, `name: ${name}-from-yaml`) + .replace('type: NodePort', 'type: ClusterIP'); + setYamlEditorValue(updated); + }); + + switchToFormView(); + serviceNameField().should('have.value', `${name}-from-yaml`); + serviceTypeToggle().should('contain', 'ClusterIP'); + }); + + it('shows validation errors for missing and invalid fields', () => { + visitServiceCreateForm(); + + serviceNameField().clear().blur(); + cy.contains('Name is required').should('be.visible'); + saveChangesButton().should('be.disabled'); + + fillServiceName(uniqueServiceName('e2e-invalid')); + fillSelector('', '', 0); + cy.contains('Selector is required').should('be.visible'); + + fillSelector('app', 'valid'); + fillPorts('not-a-port'); + cy.contains('Ports must use the format [name:]port:targetPort/PROTOCOL').should('be.visible'); + saveChangesButton().should('be.disabled'); + + fillPorts('http:99999:80/TCP'); + cy.contains('Port must be between 1 and 65535').should('be.visible'); + + selectServiceType('ExternalName'); + serviceExternalNameField().type('x').clear().blur(); + cy.contains('External name is required').should('be.visible'); + serviceExternalNameField().type('Not_A_Host'); + cy.contains('External name must be a valid hostname').should('be.visible'); + saveChangesButton().should('be.disabled'); + }); + + it('edits an existing Service and pre-populates form fields', () => { + const name = track(uniqueServiceName('e2e-edit')); + visitServiceCreateForm(); + fillServiceName(name); + fillSelector('app', 'before-edit'); + fillPorts('http:80:8080/TCP'); + submitServiceForm(); + cy.contains('h1', name, { timeout: 60000 }).should('be.visible'); + + visitServiceEditForm(name); + serviceNameField().should('have.value', name).and('be.disabled'); + serviceNamespaceField().should('have.value', SERVICE_FORM_NS).and('be.disabled'); + serviceTypeToggle().should('contain', 'ClusterIP'); + selectorKeyField().should('have.value', 'app'); + selectorValueField().should('have.value', 'before-edit'); + servicePortsField().should('contain.value', '80:8080/TCP'); + + fillSelector('app', 'after-edit'); + fillPorts('http:8080:9090/TCP'); + submitServiceForm(); + + cy.contains('h1', name, { timeout: 60000 }).should('be.visible'); + expectServiceSpec(name, { selector: { app: 'after-edit' }, type: 'ClusterIP' }); + getService(name).its('body.spec.ports.0.port').should('eq', 8080); + }); + + it('deletes a created Service from the details page', () => { + const name = uniqueServiceName('e2e-delete'); + visitServiceCreateForm(); + fillServiceName(name); + fillSelector('app', 'e2e-delete'); + fillPorts('80:8080/TCP'); + submitServiceForm(); + cy.contains('h1', name, { timeout: 60000 }).should('be.visible'); + + deleteServiceFromDetails(name); + cy.visit(`/k8s/ns/${SERVICE_FORM_NS}/core~v1~Service`); + cy.byTestID('name-filter-input').type(name); + cy.contains('[data-test="resource-row"]', name).should('not.exist'); + }); +}); diff --git a/src/utils/components/ActionDropdownItem/ActionDropdownItem.tsx b/src/utils/components/ActionDropdownItem/ActionDropdownItem.tsx index cbab41dd..557749a4 100644 --- a/src/utils/components/ActionDropdownItem/ActionDropdownItem.tsx +++ b/src/utils/components/ActionDropdownItem/ActionDropdownItem.tsx @@ -27,6 +27,7 @@ const ActionDropdownItem: FC = ({ action, setIsOpen }) return ( = ({ }; const Toggle = isKebabToggle - ? KebabToggle({ isExpanded: isOpen, onClick: onToggle }) + ? KebabToggle({ + 'data-test': id ? `${id}-toggle` : 'actions-toggle', + isExpanded: isOpen, + onClick: onToggle, + }) : DropdownToggle({ children: t('Actions'), + 'data-test': id ? `${id}-toggle` : 'actions-toggle', isDisabled, isExpanded: isOpen, onClick: onToggle, diff --git a/src/utils/components/LabelSelectorEditor/LabelSelectorEditor.tsx b/src/utils/components/LabelSelectorEditor/LabelSelectorEditor.tsx index be194450..6c80e2e2 100644 --- a/src/utils/components/LabelSelectorEditor/LabelSelectorEditor.tsx +++ b/src/utils/components/LabelSelectorEditor/LabelSelectorEditor.tsx @@ -50,6 +50,7 @@ const LabelSelectorEditor: FC = ({ onChange(value, labelSelectorPair[1], index)} type="text" @@ -59,6 +60,7 @@ const LabelSelectorEditor: FC = ({ onChange(labelSelectorPair[0], value, index)} type="text" @@ -68,6 +70,7 @@ const LabelSelectorEditor: FC = ({ diff --git a/src/utils/components/SyncedEditor/EditorToggle.tsx b/src/utils/components/SyncedEditor/EditorToggle.tsx index 255f976a..c858bb5e 100644 --- a/src/utils/components/SyncedEditor/EditorToggle.tsx +++ b/src/utils/components/SyncedEditor/EditorToggle.tsx @@ -31,6 +31,7 @@ export const EditorToggle: FC = ({ onChange, value }) => { {t('Configure via:')} { = ({ formData, onChange: onFormChange }) = ({ formData, onChange: onFormChange }) = ({ )} - diff --git a/src/views/services/form/ServiceFormPage.tsx b/src/views/services/form/ServiceFormPage.tsx index c2c10044..6a22b569 100644 --- a/src/views/services/form/ServiceFormPage.tsx +++ b/src/views/services/form/ServiceFormPage.tsx @@ -45,7 +45,7 @@ const ServiceFormPage: FC = ({ serviceToEdit }) => { return ( <> - + <Title data-test="service-form-title" headingLevel="h2"> {isEditing ? t('Edit {{label}}', { label: ServiceModel.label }) : t('Create {{label}}', { label: ServiceModel.label })} diff --git a/src/views/services/form/ServiceTypeFields.tsx b/src/views/services/form/ServiceTypeFields.tsx index f3d33697..504b25f1 100644 --- a/src/views/services/form/ServiceTypeFields.tsx +++ b/src/views/services/form/ServiceTypeFields.tsx @@ -48,6 +48,7 @@ const ServiceTypeFields: FC<ServiceTypeFieldsProps> = ({ <TextArea aria-invalid={Boolean(portsError)} aria-label={t('Ports')} + data-test={PORTS_FIELD_ID} id={PORTS_FIELD_ID} onChange={(_event, text) => onPortsChange(text)} resizeOrientation="vertical" diff --git a/src/views/services/form/ServiceTypeSelect.tsx b/src/views/services/form/ServiceTypeSelect.tsx index b0c2092d..618044e1 100644 --- a/src/views/services/form/ServiceTypeSelect.tsx +++ b/src/views/services/form/ServiceTypeSelect.tsx @@ -35,6 +35,7 @@ const ServiceTypeSelect: FC = () => { <MenuToggle aria-invalid={Boolean(error)} aria-label={t('Type')} + data-test={SERVICE_TYPE_FIELD_ID} id="toggle-service-type" isExpanded={isDropdownOpen} isFullWidth @@ -49,6 +50,7 @@ const ServiceTypeSelect: FC = () => { <DropdownList> {SERVICE_TYPES.map((type) => ( <DropdownItem + data-test={`${SERVICE_TYPE_FIELD_ID}-${type}`} key={type} onClick={() => { onChange(type);