From 7526e64be69cb1aca0c97cedad2b623f74d7a93e Mon Sep 17 00:00:00 2001 From: Leon Kladnitsky Date: Wed, 12 Aug 2026 10:39:37 +0300 Subject: [PATCH 1/3] OCPNETUI-56: Add Cypress E2E framework config and support files Set up Cypress test infrastructure for networking plugin E2E tests: - cypress.config.js with esbuild preprocessor, timeouts, reporter - Support files: login commands, navigation helpers, selectors - Plugin config with dotenv and webpack preprocessor - ESLint, tsconfig, reporter config for the test suite - .env.example with required environment variables - Package deps: esbuild-loader, dotenv, cypress-multi-reporters - Minor fixes in integration-tests/ login and example spec Jira: OCPNETUI-56 Co-authored-by: Cursor --- .env.example | 5 ++ .gitignore | 2 + integration-tests/support/login.ts | 4 +- integration-tests/tests/example-page.cy.ts | 2 +- package-lock.json | 14 ++++ package.json | 13 ++-- ui-tests-cy/.eslintrc | 17 +++++ ui-tests-cy/cypress.config.js | 31 +++++++++ ui-tests-cy/plugins/index.ts | 31 +++++++++ ui-tests-cy/reporter-config.json | 14 ++++ ui-tests-cy/support/commands.ts | 48 +++++++++++++ ui-tests-cy/support/index.ts | 18 +++++ ui-tests-cy/support/login.ts | 60 ++++++++++++++++ ui-tests-cy/support/nav.ts | 28 ++++++++ ui-tests-cy/support/selectors.ts | 81 ++++++++++++++++++++++ ui-tests-cy/tsconfig.json | 9 +++ 16 files changed, 369 insertions(+), 8 deletions(-) create mode 100644 .env.example create mode 100644 ui-tests-cy/.eslintrc create mode 100644 ui-tests-cy/cypress.config.js create mode 100644 ui-tests-cy/plugins/index.ts create mode 100644 ui-tests-cy/reporter-config.json create mode 100644 ui-tests-cy/support/commands.ts create mode 100644 ui-tests-cy/support/index.ts create mode 100644 ui-tests-cy/support/login.ts create mode 100644 ui-tests-cy/support/nav.ts create mode 100644 ui-tests-cy/support/selectors.ts create mode 100644 ui-tests-cy/tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..9550c3d1 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +BRIDGE_BASE_ADDRESS=https://console-openshift-console.apps.your-cluster.example.com +BRIDGE_KUBEADMIN_PASSWORD=your-password +TEST_NS=cy-test-ns +UDN_NS=udn-test-ns +HIDE_XHR=true diff --git a/.gitignore b/.gitignore index bde351d5..ceb70bf2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ integration-tests/screenshots integration-tests/.DS_Store yarn-error.log .DS_Store +cypress/gui-test-screenshots/ +.env diff --git a/integration-tests/support/login.ts b/integration-tests/support/login.ts index 0c590742..0887092c 100644 --- a/integration-tests/support/login.ts +++ b/integration-tests/support/login.ts @@ -8,7 +8,7 @@ declare global { } const KUBEADMIN_USERNAME = 'kubeadmin'; -const loginUsername = Cypress.env('BRIDGE_KUBEADMIN_PASSWORD') ? 'user-dropdown' : 'username'; +const loginUsername = cy.env('BRIDGE_KUBEADMIN_PASSWORD') ? 'user-dropdown' : 'username'; // This will add 'cy.login(...)' // ex: cy.login('my-user', 'my-password') @@ -24,7 +24,7 @@ Cypress.Commands.add('login', (username: string, password: string) => { cy.clearCookie('openshift-session-token'); cy.get('#inputUsername').type(username || KUBEADMIN_USERNAME); - cy.get('#inputPassword').type(password || Cypress.env('BRIDGE_KUBEADMIN_PASSWORD')); + cy.get('#inputPassword').type(cy.env('BRIDGE_KUBEADMIN_PASSWORD') || password); cy.get('button[type=submit]').click(); cy.get(`[data-test="${loginUsername}"]`).should('be.visible'); diff --git a/integration-tests/tests/example-page.cy.ts b/integration-tests/tests/example-page.cy.ts index 922011c7..9e1ef5ac 100644 --- a/integration-tests/tests/example-page.cy.ts +++ b/integration-tests/tests/example-page.cy.ts @@ -1,7 +1,7 @@ import { checkErrors } from '../support'; const PLUGIN_TEMPLATE_NAME = 'networking-console-plugin'; -const PLUGIN_TEMPLATE_PULL_SPEC = Cypress.env('PLUGIN_TEMPLATE_PULL_SPEC'); +const PLUGIN_TEMPLATE_PULL_SPEC = Cypress.expose('PLUGIN_TEMPLATE_PULL_SPEC'); export const isLocalDevEnvironment = Cypress.config('baseUrl').includes('localhost'); const installHelmChart = (path: string) => { diff --git a/package-lock.json b/package-lock.json index 8a063d2a..82ca50cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "css-loader": "^6.7.1", "cypress": "^15.0.0", "cypress-multi-reporters": "^2.0.0", + "dotenv": "^17.4.2", "esbuild-loader": "^4.4.2", "eslint": "^8.57.1", "eslint-config-prettier": "^8.10.0", @@ -7642,6 +7643,19 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/package.json b/package.json index d3630408..a5337278 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,14 @@ "start-console": "./start-console.sh", "i18n": "./i18n-scripts/build-i18n.sh && node ./i18n-scripts/set-english-defaults.js", "ts-node": "ts-node -O '{\"module\":\"commonjs\"}'", - "lint": "eslint ./src ./integration-tests", - "lint-fix": "eslint ./src ./integration-tests --fix", - "test-cypress": "cd integration-tests s && cypress open --env openshift=true", + "lint": "eslint ./src ./integration-tests ./cypress", + "lint-fix": "eslint ./src ./integration-tests ./cypress --fix", + "test-cypress": "cd integration-tests && cypress open --env openshift=true", "test-cypress-headless": "cd integration-tests && node --max-old-space-size=4096 ../node_modules/.bin/cypress run --env openshift=true --browser ${BRIDGE_E2E_BROWSER_NAME:=electron}", - "cypress-merge": "mochawesome-merge ./integration-tests/screenshots/cypress_report*.json > ./integration-tests/screenshots/cypress.json", - "cypress-generate": "marge -o ./integration-tests/screenshots/ -f cypress-report -t 'OpenShift Console Plugin Template Cypress Test Results' -p 'OpenShift Cypress Plugin Template Test Results' --showPassed false --assetsDir ./integration-tests/screenshots/cypress/assets ./integration-tests/screenshots/cypress.json", + "test-e2e": "cd cypress && node --max-old-space-size=4096 ../node_modules/.bin/cypress run --env openshift=true --browser ${BRIDGE_E2E_BROWSER_NAME:=electron}", + "test-e2e-open": "cd cypress && cypress open --env openshift=true", + "cypress-merge": "mochawesome-merge ./cypress/gui-test-screenshots/cypress_report*.json > ./cypress/gui-test-screenshots/cypress.json", + "cypress-generate": "marge -o ./cypress/gui-test-screenshots/ -f cypress-report -t 'Networking Console Plugin Test Results' -p 'Networking Plugin Test Results' --showPassed false --assetsDir ./cypress/gui-test-screenshots/assets ./cypress/gui-test-screenshots/cypress.json", "cypress-postreport": "npm run cypress-merge && npm run cypress-generate", "memsource-upload": "./i18n-scripts/memsource-upload.sh", "memsource-download": "./i18n-scripts/memsource-download.sh", @@ -59,6 +61,7 @@ "css-loader": "^6.7.1", "cypress": "^15.0.0", "cypress-multi-reporters": "^2.0.0", + "dotenv": "^17.4.2", "esbuild-loader": "^4.4.2", "eslint": "^8.57.1", "eslint-config-prettier": "^8.10.0", diff --git a/ui-tests-cy/.eslintrc b/ui-tests-cy/.eslintrc new file mode 100644 index 00000000..f01d92ce --- /dev/null +++ b/ui-tests-cy/.eslintrc @@ -0,0 +1,17 @@ +{ + "env": { + "cypress/globals": true, + "node": true + }, + "extends": ["../.eslintrc.json", "plugin:cypress/recommended"], + "plugins": ["cypress"], + "rules": { + "no-console": "off", + "no-namespace": "off", + "no-redeclare": "off", + "promise/catch-or-return": "off", + "promise/no-nesting": "off", + "@typescript-eslint/no-var-requires":"off", + "@typescript-eslint/no-namespace":"off" + } + } \ No newline at end of file diff --git a/ui-tests-cy/cypress.config.js b/ui-tests-cy/cypress.config.js new file mode 100644 index 00000000..f2dcc5a2 --- /dev/null +++ b/ui-tests-cy/cypress.config.js @@ -0,0 +1,31 @@ +const { defineConfig } = require('cypress'); + +module.exports = defineConfig({ + defaultCommandTimeout: 60000, + e2e: { + setupNodeEvents(on, config) { + return require('./plugins/index.ts')(on, config); + }, + specPattern: 'tests/**/*.cy.ts', + supportFile: 'support/index.ts', + testIsolation: false, + }, + fixturesFolder: false, + pageLoadTimeout: 120000, + reporter: '../node_modules/cypress-multi-reporters', + reporterOptions: { + configFile: 'reporter-config.json', + }, + retries: { + openMode: 0, + runMode: 0, + }, + screenshotOnRunFailure: true, + screenshotsFolder: './gui-test-screenshots/screenshots/', + trashAssetsBeforeRuns: true, + video: true, + videosFolder: './gui-test-screenshots/videos/', + viewportHeight: 1080, + viewportWidth: 1920, + watchForFileChanges: false, +}); diff --git a/ui-tests-cy/plugins/index.ts b/ui-tests-cy/plugins/index.ts new file mode 100644 index 00000000..9d7dc67f --- /dev/null +++ b/ui-tests-cy/plugins/index.ts @@ -0,0 +1,31 @@ +const wp = require('@cypress/webpack-preprocessor'); +const path = require('path'); +const dotenv = require('dotenv'); + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +module.exports = (on, config) => { + const options = { + webpackOptions: { + module: { + rules: [ + { + exclude: /node_modules/, + loader: 'esbuild-loader', + test: /\.ts$/, + }, + ], + }, + resolve: { + extensions: ['.ts', '.tsx', '.js'], + }, + }, + }; + on('file:preprocessor', wp(options)); + config.baseUrl = `${process.env.BRIDGE_BASE_ADDRESS || 'http://localhost:9000/'}`; + config.env.BRIDGE_KUBEADMIN_PASSWORD = process.env.BRIDGE_KUBEADMIN_PASSWORD; + config.env.TEST_NS = process.env.TEST_NS; + config.env.UDN_NS = process.env.UDN_NS; + config.env.HIDE_XHR = process.env.HIDE_XHR; + return config; +}; diff --git a/ui-tests-cy/reporter-config.json b/ui-tests-cy/reporter-config.json new file mode 100644 index 00000000..83ca6c5d --- /dev/null +++ b/ui-tests-cy/reporter-config.json @@ -0,0 +1,14 @@ +{ + "reporterEnabled": "mocha-junit-reporter, mochawesome", + "mochaJunitReporterReporterOptions": { + "mochaFile": "./gui-test-screenshots/junit_cypress-[hash].xml", + "toConsole": false + }, + "mochawesomeReporterOptions": { + "reportDir": "./gui-test-screenshots/", + "reportFilename": "cypress_report", + "overwrite": false, + "html": true, + "json": true + } +} \ No newline at end of file diff --git a/ui-tests-cy/support/commands.ts b/ui-tests-cy/support/commands.ts new file mode 100644 index 00000000..c2a7ebed --- /dev/null +++ b/ui-tests-cy/support/commands.ts @@ -0,0 +1,48 @@ +import { MINUTE, SECOND } from '../utils/const/base'; + +declare global { + namespace Cypress { + interface Chainable { + deleteResource(kind: string, name: string, namespace?: string): void; + switchProject(projectName: string): void; + } + } +} + +Cypress.Commands.add('deleteResource', (kind: string, name: string, namespace?: string) => { + if (!namespace) { + cy.exec(`oc delete --ignore-not-found=true ${kind} ${name} --wait=true --timeout=300s`, { + failOnNonZeroExit: false, + timeout: 5 * MINUTE, + }); + return; + } + cy.exec( + `oc delete --ignore-not-found=true -n ${namespace} ${kind} ${name} --wait=true --timeout=300s`, + { failOnNonZeroExit: false, timeout: 5 * MINUTE }, + ); +}); + +Cypress.Commands.add('switchProject', (projectName: string) => { + cy.byLegacyTestID('namespace-bar-dropdown').contains('Project:').click(); + if (projectName === 'All Projects') { + cy.contains('All Projects').click(); + return; + } + cy.get('body').then(($body) => { + if ($body.find('[data-test="showSystemSwitch"]').length) { + cy.byTestID('showSystemSwitch').check(); + cy.wait(3 * SECOND); + } + }); + cy.get( + 'input[data-test="dropdown-text-filter"], input[placeholder*="name"], input[aria-label*="filter"]', + { timeout: 10000 }, + ) + .first() + .clear() + .type(projectName); + cy.contains('[data-test="dropdown-menu-item-link"], [role="option"], button', projectName, { + timeout: 10000, + }).click(); +}); diff --git a/ui-tests-cy/support/index.ts b/ui-tests-cy/support/index.ts new file mode 100644 index 00000000..588da326 --- /dev/null +++ b/ui-tests-cy/support/index.ts @@ -0,0 +1,18 @@ +import './login'; +import './selectors'; +import './commands'; +import './nav'; + +Cypress.on('uncaught:exception', () => { + return false; +}); + +if (Cypress.env('HIDE_XHR')) { + const origLog = Cypress.log; + Cypress.log = function (opts, ...other) { + if (opts.displayName === 'fetch' || opts.displayName === 'xhr') { + return; + } + return origLog(opts, ...other); + }; +} diff --git a/ui-tests-cy/support/login.ts b/ui-tests-cy/support/login.ts new file mode 100644 index 00000000..484828d5 --- /dev/null +++ b/ui-tests-cy/support/login.ts @@ -0,0 +1,60 @@ +import { MINUTE } from '../utils/const/base'; + +const KUBEADMIN_USERNAME = 'kubeadmin'; +const KUBEADMIN_IDP = 'kube:admin'; +const TOUR_DISMISS = '[data-test="tour-step-footer-secondary"]'; + +declare global { + namespace Cypress { + interface Chainable { + login(providerName?: string, username?: string, password?: string): Chainable; + logout(): void; + } + } +} + +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.origin( + Cypress.config('baseUrl').replace('console-openshift-console', 'oauth-openshift'), + { args: { idp, pwd, usr } }, + ({ idp: originIdp, pwd: originPwd, usr: originUsr }) => { + // Wait for either the login form or IDP selection page + cy.get('body', { timeout: 180000 }).should('be.visible'); + cy.get('body').then(($body) => { + if ($body.find('#inputUsername').length === 0) { + // IDP selection page — click the matching provider + 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); + cy.get('button[type=submit]').click(); + }, + ); + + 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', () => { + 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 }); +}); diff --git a/ui-tests-cy/support/nav.ts b/ui-tests-cy/support/nav.ts new file mode 100644 index 00000000..bde8f12d --- /dev/null +++ b/ui-tests-cy/support/nav.ts @@ -0,0 +1,28 @@ +import { MINUTE } from '../utils/const/base'; + +declare global { + namespace Cypress { + interface Chainable { + visitNAD(): void; + visitService(): void; + visitUDN(): void; + } + } +} + +Cypress.Commands.add('visitNAD', () => { + const ns = Cypress.env('TEST_NS') || 'cy-test-ns'; + cy.visit(`/k8s/ns/${ns}/k8s.cni.cncf.io~v1~NetworkAttachmentDefinition`); + cy.checkTitle('NetworkAttachmentDefinitions', MINUTE); +}); + +Cypress.Commands.add('visitUDN', () => { + cy.visit('/k8s/all-namespaces/k8s.ovn.org~v1~UserDefinedNetwork'); + cy.checkTitle('UserDefinedNetworks', MINUTE); + cy.byButtonText('Create').should('be.visible'); +}); + +Cypress.Commands.add('visitService', () => { + cy.visit('/k8s/all-namespaces/services'); + cy.checkTitle('Services', MINUTE); +}); diff --git a/ui-tests-cy/support/selectors.ts b/ui-tests-cy/support/selectors.ts new file mode 100644 index 00000000..935fb8ec --- /dev/null +++ b/ui-tests-cy/support/selectors.ts @@ -0,0 +1,81 @@ +import Loggable = Cypress.Loggable; +import Timeoutable = Cypress.Timeoutable; +import Withinable = Cypress.Withinable; +import Shadow = Cypress.Shadow; +import { MINUTE, SECOND } from '../utils/const/base'; + +export {}; +declare global { + namespace Cypress { + interface Chainable { + byButtonText(selector: string): Chainable; + byLegacyTestID(selector: string): Chainable; + byTestActionID(selector: string): Chainable; + byTestID( + selector: string, + options?: Partial, + ): Chainable; + byTestRows(selector: string): Chainable; + checkSubTitle(title: string, timeout?: number): void; + checkTitle(title: string, timeout?: number): void; + clickBtn(btnTxt: string): void; + clickNavLink(path: [string, string?]): Chainable; + clickNextBtn(): void; + clickSaveBtn(): void; + } + } +} + +Cypress.Commands.add( + 'byTestID', + (selector: string, options?: Partial) => { + cy.get(`[data-test="${selector}"]`, options); + }, +); + +Cypress.Commands.add('byLegacyTestID', (selector: string) => + cy.get(`[data-test-id="${selector}"]`, { timeout: MINUTE * 3 }), +); + +Cypress.Commands.add('byTestRows', (selector: string) => + cy.get(`[data-test-rows="${selector}"]`, { timeout: MINUTE }), +); + +Cypress.Commands.add('byTestActionID', (selector: string) => + cy.get(`[data-test-action="${selector}"]:not(.pf-m-disabled)`), +); + +Cypress.Commands.add('clickNavLink', (path: [string, string?]) => { + cy.byTestID('nav', { timeout: MINUTE }) + .contains(path[0], { timeout: SECOND * 10 }) + .should(($el) => { + if ($el.attr('aria-expanded') == 'false') { + $el.click(); + } + }); + if (path.length > 1) { + cy.get('#page-sidebar').contains(path[1]).click(); + } +}); + +Cypress.Commands.add('byButtonText', (selector: string) => cy.contains('button', `${selector}`)); + +Cypress.Commands.add('clickSaveBtn', () => { + cy.get('button[data-test="save-button"]').click({ force: true }); +}); + +Cypress.Commands.add('checkTitle', (title: string, timeout?: number) => { + const t_o = timeout ? timeout : MINUTE * 3; + cy.contains('h1', title, { timeout: t_o }).should('exist'); +}); + +Cypress.Commands.add('checkSubTitle', (subTitle: string, timeout?: number) => { + const t_o = timeout ? timeout : MINUTE * 3; + cy.contains('h2', subTitle, { timeout: t_o }).should('exist'); +}); + +Cypress.Commands.add('clickNextBtn', () => cy.contains('button[type="submit"]', 'Next').click()); + +Cypress.Commands.add('clickBtn', (btnTxt?: string) => { + return cy.contains('button[type="submit"]', btnTxt).click(); +}); diff --git a/ui-tests-cy/tsconfig.json b/ui-tests-cy/tsconfig.json new file mode 100644 index 00000000..c8f71161 --- /dev/null +++ b/ui-tests-cy/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "types":["cypress","node"], + "isolatedModules": false + }, + "include": ["../node_modules/cypress", "./**/*.ts"] +} \ No newline at end of file From f695767102c5bbe4ae1fa2219e92188598876f18 Mon Sep 17 00:00:00 2001 From: Leon Kladnitsky Date: Wed, 12 Aug 2026 10:39:53 +0300 Subject: [PATCH 2/3] OCPNETUI-56: Add Cypress page objects and test constants Add view helpers and constants for networking E2E tests: - Views: NAD form helpers, UDN form helpers, common selectors, actions - Constants: base test values, NAD config constants - Types: NAD type definitions These are the page object layer used by test specs (next PR). Jira: OCPNETUI-56 Co-authored-by: Cursor --- ui-tests-cy/utils/const/base.ts | 23 +++++++++ ui-tests-cy/utils/const/nad.ts | 28 ++++++++++ ui-tests-cy/utils/types/nad.ts | 11 ++++ ui-tests-cy/views/actions.ts | 12 +++++ ui-tests-cy/views/nad.ts | 77 ++++++++++++++++++++++++++++ ui-tests-cy/views/selector-common.ts | 14 +++++ ui-tests-cy/views/udn.ts | 39 ++++++++++++++ 7 files changed, 204 insertions(+) create mode 100644 ui-tests-cy/utils/const/base.ts create mode 100644 ui-tests-cy/utils/const/nad.ts create mode 100644 ui-tests-cy/utils/types/nad.ts create mode 100644 ui-tests-cy/views/actions.ts create mode 100644 ui-tests-cy/views/nad.ts create mode 100644 ui-tests-cy/views/selector-common.ts create mode 100644 ui-tests-cy/views/udn.ts diff --git a/ui-tests-cy/utils/const/base.ts b/ui-tests-cy/utils/const/base.ts new file mode 100644 index 00000000..432cbbfd --- /dev/null +++ b/ui-tests-cy/utils/const/base.ts @@ -0,0 +1,23 @@ +export const adminOnlyDescribe = Cypress.env('NON_PRIV') ? xdescribe : describe; +export const adminOnlyIT = Cypress.env('NON_PRIV') ? xit : it; + +export const TEST_NS = Cypress.env('TEST_NS') || 'cy-test-ns'; +export const UDN_NS = Cypress.env('UDN_NS') || 'udn-test-ns'; + +export const EXAMPLE = 'example'; +export const ALL_PROJ_NS = 'All Projects'; +export const TEST_ROUTE_NAME = 'auto-test-route'; +export const TEST_POL_NAME = 'auto-test-net-policy'; +export const TEST_MULTI_NAME = 'auto-test-multi-policy'; + +export const SECOND = 1000; +export const MINUTE = 60000; + +export const UDN_LABEL = 'k8s.ovn.org/primary-user-defined-network'; + +export enum K8S_KIND { + CUDN = 'ClusterUserDefinedNetwork', + NAD = 'net-attach-def', + Project = 'project', + UDN = 'UserDefinedNetwork', +} diff --git a/ui-tests-cy/utils/const/nad.ts b/ui-tests-cy/utils/const/nad.ts new file mode 100644 index 00000000..96cc3434 --- /dev/null +++ b/ui-tests-cy/utils/const/nad.ts @@ -0,0 +1,28 @@ +import { NadData } from '../types/nad'; + +export const bridge = 'br0'; + +export const NAD_BRIDGE: NadData = { + bridge: bridge, + description: 'bridge nad', + macSpoof: true, + name: 'network-bridge', + type: 'Bridge', + vlan: '100', +}; + +export const NAD_OVN: NadData = { + description: 'ovn nad', + name: 'network-ovn', + type: 'OVN', + vlan: '200', +}; + +export const NAD_LOCALNET: NadData = { + bridge: bridge, + description: 'localnet nad', + mtu: '1500', + name: 'network-localnet', + type: 'Localnet', + vlan: '300', +}; diff --git a/ui-tests-cy/utils/types/nad.ts b/ui-tests-cy/utils/types/nad.ts new file mode 100644 index 00000000..58116cb6 --- /dev/null +++ b/ui-tests-cy/utils/types/nad.ts @@ -0,0 +1,11 @@ +export type NadData = { + name?: string; + description?: string; + type?: string; + bridge?: string; + vlan?: string; + mtu?: string; + macSpoof?: boolean; + subnet?: string; + exclude?: string; +}; diff --git a/ui-tests-cy/views/actions.ts b/ui-tests-cy/views/actions.ts new file mode 100644 index 00000000..f41d8972 --- /dev/null +++ b/ui-tests-cy/views/actions.ts @@ -0,0 +1,12 @@ +export const nameInput = 'input#name'; + +export const getRow = (name: string, within: VoidFunction) => + cy.byTestRows('resource-row').contains(name).parents('tr').within(within); + +export const checkActionMenu = (item: string) => { + cy.byButtonText('Actions').click(); + cy.contains('button', 'Edit labels').should('exist'); + cy.contains('button', 'Edit annotations').should('exist'); + cy.contains('button', `Edit ${item}`).should('exist'); + cy.contains('button', `Delete ${item}`).should('exist'); +}; diff --git a/ui-tests-cy/views/nad.ts b/ui-tests-cy/views/nad.ts new file mode 100644 index 00000000..f7e2e63d --- /dev/null +++ b/ui-tests-cy/views/nad.ts @@ -0,0 +1,77 @@ +import { NadData } from '../utils/types/nad'; + +import { row } from './selector-common'; + +export const name = 'input[name="name"]'; +export const description = 'input[name="description"]'; +export const type = '#toggle-nads-network-type'; +export const cnvBridgeLink = '[data-test-dropdown-menu="cnv-bridge"]'; +export const ovnNetwork = 'button[id="ovn-k8s-cni-overlay-link"]'; +export const localnet = 'button[id="ovn-k8s-cni-overlay-localnet-link"]'; +export const bridgeName = 'input[name="bridge.bridge"]'; +export const bridgeVlan = 'input[name="bridge.vlanTagNum"]'; +export const bridgeMap = 'input[name="ovn-k8s-cni-overlay-localnet.bridgeMapping"]'; +export const bridgeMTU = 'input[name="ovn-k8s-cni-overlay-localnet.mtu"]'; +export const localnetVlan = 'input[name="ovn-k8s-cni-overlay-localnet.vlanID"]'; +export const confirmBtn = '[data-test="confirm-action"]'; +export const heading = '[data-test-section-heading="NetworkAttachmentDefinition details"]'; +export const createBtn = '#save-changes'; +export const macSpoofCHK = 'input[id="bridge.macspoofchk"]'; +export const ovnSubnet = 'input[name="ovn-k8s-cni-overlay.subnets"]'; +export const localnetSubnet = 'input[name="ovn-k8s-cni-overlay-localnet.subnets"]'; +export const exclude = 'input[name="ovn-k8s-cni-overlay-localnet.excludeSubnets"]'; + +export const createNAD = (nad: NadData) => { + cy.visitNAD(); + cy.byButtonText('Create NetworkAttachmentDefinition').click(); + cy.get('#form').check(); + cy.get(name, { timeout: 10000 }).should('be.visible'); + cy.get(name).clear().type(nad.name); + cy.get(description).clear().type(nad.description); + cy.get(type).click(); + switch (nad.type) { + case 'Bridge': { + cy.byButtonText('Linux bridge').click(); + cy.get(bridgeName).clear().type(nad.bridge); + if (nad.vlan) { + cy.get(bridgeVlan).clear().type(nad.vlan); + } + if (!nad.macSpoof) { + cy.get(macSpoofCHK).uncheck(); + } + break; + } + case 'OVN': { + cy.byButtonText('L2 overlay').click(); + if (nad.subnet) { + cy.get(ovnSubnet).type(nad.subnet); + } + break; + } + case 'Localnet': { + cy.byButtonText('secondary localnet').click(); + cy.get(bridgeMap).clear().type(nad.bridge); + cy.get(bridgeMTU).clear().type(nad.mtu); + if (nad.vlan) { + cy.get(localnetVlan).clear().type(nad.vlan); + } + if (nad.subnet) { + cy.get(localnetSubnet).type(nad.subnet); + cy.get(exclude).type(nad.exclude); + } + break; + } + } + cy.get(createBtn).click(); + cy.get(heading).should('exist'); + cy.contains('[data-test-selector="details-item-label__Description"]', 'Description') + .parent() + .should('contain', nad.description); +}; + +export const deleteNAD = (nadName: string) => { + cy.contains(row, nadName).find('.pf-v6-c-table__action').find('button').click(); + cy.byButtonText('Delete').click(); + cy.get(confirmBtn).click(); + cy.contains(row, nadName).should('not.exist'); +}; diff --git a/ui-tests-cy/views/selector-common.ts b/ui-tests-cy/views/selector-common.ts new file mode 100644 index 00000000..77ed1559 --- /dev/null +++ b/ui-tests-cy/views/selector-common.ts @@ -0,0 +1,14 @@ +export const row = '[data-test-rows="resource-row"]'; +export const actionsBtn = 'button.pf-v6-c-menu-toggle.pf-m-plain'; +export const itemCreateBtn = '[data-test="item-create"]'; +export const labelContent = '.pf-v6-c-label__content'; +export const brCrumbItem = '[aria-label="Breadcrumb"]'; +export const itemFilter = '[data-test-id="item-filter"]'; +export const createBtn = '#save-changes'; +export const confirmBtn = '[data-test="confirm-action"]'; +export const dropDownItem = '.pf-v6-c-dropdown__menu-item'; + +export const ingressHeader = '#ingress-header'; +export const egressHeader = '#egress-header'; +export const ingressOff = '#denyAllIngress'; +export const egressOff = '#denyAllEgress'; diff --git a/ui-tests-cy/views/udn.ts b/ui-tests-cy/views/udn.ts new file mode 100644 index 00000000..8b063796 --- /dev/null +++ b/ui-tests-cy/views/udn.ts @@ -0,0 +1,39 @@ +import { ALL_PROJ_NS } from '../utils/const/base'; + +import { checkActionMenu } from './actions'; +import { row } from './selector-common'; +import { brCrumbItem } from './selector-common'; + +export const UDN_PRIM_UDN_NS = 'primary-udn'; + +export const createUDN = (project: string, subnet: string) => { + cy.visitUDN(); + cy.byButtonText('Create').click(); + cy.get('body').then(($body) => { + if ($body.find('[data-test="list-page-create-dropdown-item-UserDefinedNetwork"]').length) { + cy.get('[data-test="list-page-create-dropdown-item-UserDefinedNetwork"]') + .find('button') + .click(); + } + }); + cy.checkTitle('Create UserDefinedNetwork'); + cy.get('#typeahead-select-input').find('input').type(project); + cy.get(`#select-typeahead-${project}`, { timeout: 10000 }).click(); + cy.byTestID('input-udn-subnet').clear(); + cy.byTestID('input-udn-subnet').type(subnet); + cy.byTestID('create-udn-submit').should('not.be.disabled').click(); + cy.checkTitle(UDN_PRIM_UDN_NS); + checkActionMenu('UserDefinedNetwork'); + cy.contains(brCrumbItem, 'UserDefinedNetworks').find('a').eq(0).click(); + cy.contains(row, UDN_PRIM_UDN_NS).should('exist'); +}; + +export const deleteUDN = () => { + cy.visitUDN(); + cy.switchProject(ALL_PROJ_NS); + cy.contains('[data-label="name"]', UDN_PRIM_UDN_NS).find('a').click(); + cy.byButtonText('Actions').click(); + cy.get('[data-test-action="Delete UserDefinedNetwork"]').click(); + cy.byTestID('confirm-action').click(); + cy.contains(UDN_PRIM_UDN_NS).should('not.exist'); +}; From a1e0829b1d78f35a6c160bb3444d06e02c64f390 Mon Sep 17 00:00:00 2001 From: Leon Kladnitsky Date: Wed, 12 Aug 2026 10:40:10 +0300 Subject: [PATCH 3/3] OCPNETUI-56: Add Cypress test specs and runner scripts Add E2E test specs for all networking resources: - NADs: bridge, localnet, OVN topologies - Network policies, routes, services, ingresses - User-defined networks (UDN + ClusterUDN) - Login and page navigation verification Add test runner infrastructure: - setup.sh / cleanup.sh for test namespace lifecycle - test-cypress.sh with GUI (-g) and headless modes - research-flakiness.sh for repeated execution analysis - e2e.yml GitHub Actions workflow for CI - Migration and planning docs Depends on: cypress-infra (1a) + cypress-views (1b) Jira: OCPNETUI-56 Co-authored-by: Cursor --- .github/workflows/e2e.yml | 52 ++++ cleanup.sh | 22 ++ integration-tests/support/login.ts | 4 +- integration-tests/tests/example-page.cy.ts | 2 +- package.json | 12 +- research-flakiness.sh | 120 +++++++++ setup.sh | 43 ++++ test-cypress.sh | 37 +++ ui-tests-cy/MIGRATION.md | 55 ++++ ui-tests-cy/PLAN.md | 242 ++++++++++++++++++ ui-tests-cy/support/commands.ts | 21 +- ui-tests-cy/support/index.ts | 6 +- ui-tests-cy/support/login.ts | 2 +- ui-tests-cy/support/selectors.ts | 2 +- ui-tests-cy/tests/all.cy.ts | 10 + ui-tests-cy/tests/networking/ingresses.cy.ts | 20 ++ ui-tests-cy/tests/networking/nad-bridge.cy.ts | 9 + .../tests/networking/nad-localnet.cy.ts | 14 + ui-tests-cy/tests/networking/nad-ovn.cy.ts | 9 + .../tests/networking/net-policies.cy.ts | 31 +++ ui-tests-cy/tests/networking/routes.cy.ts | 32 +++ ui-tests-cy/tests/networking/services.cy.ts | 19 ++ ui-tests-cy/tests/networking/udn.cy.ts | 46 ++++ ui-tests-cy/tests/setup/login.cy.ts | 5 + ui-tests-cy/tests/setup/visit-pages.cy.ts | 35 +++ ui-tests-cy/utils/types/nad.ts | 12 +- ui-tests-cy/views/nad.ts | 21 +- 27 files changed, 845 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100755 cleanup.sh create mode 100755 research-flakiness.sh create mode 100755 setup.sh create mode 100755 test-cypress.sh create mode 100644 ui-tests-cy/MIGRATION.md create mode 100644 ui-tests-cy/PLAN.md create mode 100644 ui-tests-cy/tests/all.cy.ts create mode 100644 ui-tests-cy/tests/networking/ingresses.cy.ts create mode 100644 ui-tests-cy/tests/networking/nad-bridge.cy.ts create mode 100644 ui-tests-cy/tests/networking/nad-localnet.cy.ts create mode 100644 ui-tests-cy/tests/networking/nad-ovn.cy.ts create mode 100644 ui-tests-cy/tests/networking/net-policies.cy.ts create mode 100644 ui-tests-cy/tests/networking/routes.cy.ts create mode 100644 ui-tests-cy/tests/networking/services.cy.ts create mode 100644 ui-tests-cy/tests/networking/udn.cy.ts create mode 100644 ui-tests-cy/tests/setup/login.cy.ts create mode 100644 ui-tests-cy/tests/setup/visit-pages.cy.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..1afd9108 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,52 @@ +name: E2E Tests + +on: + pull_request: + branches: [main, release-*] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: e2e-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + +jobs: + e2e: + name: Cypress E2E + runs-on: ubuntu-latest + timeout-minutes: 30 + + env: + BRIDGE_BASE_ADDRESS: ${{ secrets.CONSOLE_URL }} + BRIDGE_KUBEADMIN_PASSWORD: ${{ secrets.KUBEADMIN_PASSWORD }} + BRIDGE_E2E_BROWSER_NAME: electron + TEST_NS: ${{ secrets.TEST_NS || 'cy-test-ns' }} + UDN_NS: udn-test-ns + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run E2E tests + run: npm run test-e2e + + - name: Upload test artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-results + path: ui-tests-cy/gui-test-screenshots/ + retention-days: 7 diff --git a/cleanup.sh b/cleanup.sh new file mode 100755 index 00000000..b3dd55c6 --- /dev/null +++ b/cleanup.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +# Cleanup test resources from previous runs +# Sources .env for namespace names + +source .env 2>/dev/null || true +TEST_NS="${TEST_NS:-cy-test-ns}" +UDN_NS="${UDN_NS:-udn-test-ns}" + +cleanup () { + echo "Cleaning up test resources..." + oc delete --ignore-not-found=true -n "${TEST_NS}" net-attach-def network-bridge network-ovn network-localnet --wait=false + oc delete --ignore-not-found=true -n "${TEST_NS}" service example --wait=false + oc delete --ignore-not-found=true -n "${TEST_NS}" route auto-test-route --wait=false + oc delete --ignore-not-found=true -n "${TEST_NS}" ingress example --wait=false + oc delete --ignore-not-found=true -n "${TEST_NS}" networkpolicy auto-test-net-policy --wait=true --timeout=60s + oc delete --ignore-not-found=true -n "${TEST_NS}" multi-networkpolicy auto-test-multi-policy --wait=false + oc delete --ignore-not-found=true ClusterUserDefinedNetwork cluster-udn --wait=false + oc delete --ignore-not-found=true -n "${UDN_NS}" UserDefinedNetwork primary-udn --wait=false + oc delete --ignore-not-found=true nncp -l app.kubernetes.io/managed-by=networking-console-plugin-test --wait=false + echo "Cleanup done." +} diff --git a/integration-tests/support/login.ts b/integration-tests/support/login.ts index 0887092c..0c590742 100644 --- a/integration-tests/support/login.ts +++ b/integration-tests/support/login.ts @@ -8,7 +8,7 @@ declare global { } const KUBEADMIN_USERNAME = 'kubeadmin'; -const loginUsername = cy.env('BRIDGE_KUBEADMIN_PASSWORD') ? 'user-dropdown' : 'username'; +const loginUsername = Cypress.env('BRIDGE_KUBEADMIN_PASSWORD') ? 'user-dropdown' : 'username'; // This will add 'cy.login(...)' // ex: cy.login('my-user', 'my-password') @@ -24,7 +24,7 @@ Cypress.Commands.add('login', (username: string, password: string) => { cy.clearCookie('openshift-session-token'); cy.get('#inputUsername').type(username || KUBEADMIN_USERNAME); - cy.get('#inputPassword').type(cy.env('BRIDGE_KUBEADMIN_PASSWORD') || password); + cy.get('#inputPassword').type(password || Cypress.env('BRIDGE_KUBEADMIN_PASSWORD')); cy.get('button[type=submit]').click(); cy.get(`[data-test="${loginUsername}"]`).should('be.visible'); diff --git a/integration-tests/tests/example-page.cy.ts b/integration-tests/tests/example-page.cy.ts index 9e1ef5ac..922011c7 100644 --- a/integration-tests/tests/example-page.cy.ts +++ b/integration-tests/tests/example-page.cy.ts @@ -1,7 +1,7 @@ import { checkErrors } from '../support'; const PLUGIN_TEMPLATE_NAME = 'networking-console-plugin'; -const PLUGIN_TEMPLATE_PULL_SPEC = Cypress.expose('PLUGIN_TEMPLATE_PULL_SPEC'); +const PLUGIN_TEMPLATE_PULL_SPEC = Cypress.env('PLUGIN_TEMPLATE_PULL_SPEC'); export const isLocalDevEnvironment = Cypress.config('baseUrl').includes('localhost'); const installHelmChart = (path: string) => { diff --git a/package.json b/package.json index a5337278..ee44f7fb 100644 --- a/package.json +++ b/package.json @@ -13,14 +13,14 @@ "start-console": "./start-console.sh", "i18n": "./i18n-scripts/build-i18n.sh && node ./i18n-scripts/set-english-defaults.js", "ts-node": "ts-node -O '{\"module\":\"commonjs\"}'", - "lint": "eslint ./src ./integration-tests ./cypress", - "lint-fix": "eslint ./src ./integration-tests ./cypress --fix", + "lint": "eslint ./src ./integration-tests ./ui-tests-cy", + "lint-fix": "eslint ./src ./integration-tests ./ui-tests-cy --fix", "test-cypress": "cd integration-tests && cypress open --env openshift=true", "test-cypress-headless": "cd integration-tests && node --max-old-space-size=4096 ../node_modules/.bin/cypress run --env openshift=true --browser ${BRIDGE_E2E_BROWSER_NAME:=electron}", - "test-e2e": "cd cypress && node --max-old-space-size=4096 ../node_modules/.bin/cypress run --env openshift=true --browser ${BRIDGE_E2E_BROWSER_NAME:=electron}", - "test-e2e-open": "cd cypress && cypress open --env openshift=true", - "cypress-merge": "mochawesome-merge ./cypress/gui-test-screenshots/cypress_report*.json > ./cypress/gui-test-screenshots/cypress.json", - "cypress-generate": "marge -o ./cypress/gui-test-screenshots/ -f cypress-report -t 'Networking Console Plugin Test Results' -p 'Networking Plugin Test Results' --showPassed false --assetsDir ./cypress/gui-test-screenshots/assets ./cypress/gui-test-screenshots/cypress.json", + "test-e2e": "node --max-old-space-size=4096 node_modules/.bin/cypress run --project ui-tests-cy --env openshift=true --browser ${BRIDGE_E2E_BROWSER_NAME:=electron}", + "test-e2e-open": "cypress open --project ui-tests-cy --env openshift=true", + "cypress-merge": "mochawesome-merge ./ui-tests-cy/gui-test-screenshots/cypress_report*.json > ./ui-tests-cy/gui-test-screenshots/cypress.json", + "cypress-generate": "marge -o ./ui-tests-cy/gui-test-screenshots/ -f cypress-report -t 'Networking Console Plugin Test Results' -p 'Networking Plugin Test Results' --showPassed false --assetsDir ./ui-tests-cy/gui-test-screenshots/assets ./ui-tests-cy/gui-test-screenshots/cypress.json", "cypress-postreport": "npm run cypress-merge && npm run cypress-generate", "memsource-upload": "./i18n-scripts/memsource-upload.sh", "memsource-download": "./i18n-scripts/memsource-download.sh", diff --git a/research-flakiness.sh b/research-flakiness.sh new file mode 100755 index 00000000..459d6ba1 --- /dev/null +++ b/research-flakiness.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash + +set -x +set +e + +PROBLEM_DIR="problem" +TOTAL_RUNS=10 +PASS_COUNT=0 +FAIL_COUNT=0 + +mkdir -p "${PROBLEM_DIR}" + +for i in $(seq -w 1 ${TOTAL_RUNS}); do + echo "========================================" + echo " RUN ${i} of ${TOTAL_RUNS}" + echo "========================================" + + # Clean previous test artifacts + rm -rf cypress/gui-test-screenshots/* + + # Run the test suite + ./test-cypress.sh + exit_code=$? + + if [ ${exit_code} -ne 0 ]; then + FAIL_COUNT=$((FAIL_COUNT + 1)) + RUN_DIR="${PROBLEM_DIR}/run${i}" + mkdir -p "${RUN_DIR}" + + # Copy screenshots and videos + if [ -d "cypress/gui-test-screenshots/screenshots" ]; then + cp -r cypress/gui-test-screenshots/screenshots "${RUN_DIR}/" + fi + if [ -d "cypress/gui-test-screenshots/videos" ]; then + cp -r cypress/gui-test-screenshots/videos "${RUN_DIR}/" + fi + if [ -f "cypress/gui-test-screenshots/build.log" ]; then + cp cypress/gui-test-screenshots/build.log "${RUN_DIR}/" + fi + + # Extract failure info into analysis.md + cat > "${RUN_DIR}/analysis.md" </dev/null || echo "Could not extract test names") + +## Error messages + +$(grep -A5 "^\s+\d\) " cypress/gui-test-screenshots/build.log 2>/dev/null | head -60 || echo "Could not extract errors") + +## Observations + +- The NAD creation form uses react-hook-form with auto-generated names +- The naive approach (clear + type without waits) races with React re-renders +- The form may overwrite user input after async state updates + +ANALYSIS + + echo " -> FAILED (evidence saved to ${RUN_DIR}/)" + else + PASS_COUNT=$((PASS_COUNT + 1)) + echo " -> PASSED" + fi +done + +echo "" +echo "========================================" +echo " RESULTS: ${PASS_COUNT} passed, ${FAIL_COUNT} failed out of ${TOTAL_RUNS} runs" +echo "========================================" + +# Write summary +cat > "${PROBLEM_DIR}/summary.md" </dev/null || true +TEST_NS="${TEST_NS:-cy-test-ns}" +UDN_NS="${UDN_NS:-udn-test-ns}" +UDN_LABEL="k8s.ovn.org/primary-user-defined-network" + +setup () { + echo "Setting up test namespaces..." + oc get namespace ${TEST_NS} 2>/dev/null || oc create namespace ${TEST_NS} + + # UDN namespace must have the label at creation time (admission policy prevents adding later) + if oc get namespace ${UDN_NS} 2>/dev/null; then + if ! oc get namespace ${UDN_NS} -o jsonpath='{.metadata.labels}' | grep -q "${UDN_LABEL}"; then + echo "UDN namespace exists but missing label, recreating..." + oc delete namespace ${UDN_NS} --wait=true --timeout=120s + oc wait --for=delete namespace/${UDN_NS} --timeout=120s 2>/dev/null || true + oc create -f - < ghWorkflow + prow --> prowScript + ghWorkflow --> cypressConfig + prowScript --> cypressConfig +``` + +## Migration Strategy + +The `release-4.21` Cypress tests are the primary source — copy them and adapt (fix imports, strip VM tests). Cross-reference with `main` Playwright specs only to check for newer test logic that may have been added after the Cypress versions were dropped. + +### What to copy from kubevirt-ui `release-4.21` + +| Source | Copy to networking-console-plugin | +|---|---| +| `cypress/views/nad.ts` | `cypress/views/nad.ts` | +| `cypress/views/udn.ts` | `cypress/views/udn.ts` | +| `cypress/views/actions.ts` (partial) | `cypress/views/actions.ts` | +| `cypress/views/selector-common.ts` (partial) | `cypress/views/selector-common.ts` | +| `cypress/views/selector-template.ts` (partial) | `cypress/views/selector-common.ts` | +| `cypress/support/nav.ts` (networking parts) | `cypress/support/nav.ts` | +| `cypress/support/selectors.ts` | `cypress/support/selectors.ts` | +| `cypress/support/commands.ts` (partial) | `cypress/support/commands.ts` | +| `cypress/utils/const/nad.ts` | `cypress/utils/const/nad.ts` | +| `cypress/utils/const/index.ts` (partial) | `cypress/utils/const/index.ts` | +| `cypress/utils/types/nad.ts` | `cypress/utils/types/nad.ts` | + +### What needs adaptation + +- **Remove VM-dependent code**: all `vm.*` calls, `cy.deleteVM()`, `VirtualMachineData` imports, VM status assertions +- **Remove kubevirt-specific imports**: `TEMPLATE`, `vm-flow`, `tab`, `vm` view modules +- **Remove kubevirt-perspective switching**: `cy.beforeSpec()` switches to Virtualization perspective — networking tests should stay in Administrator perspective +- **Fix relative import paths**: `../../../views/` -> `../views/` (flatter structure) +- **Keep only networking custom commands**: `cy.visitNAD()`, `cy.visitUDN()`, `cy.switchProject()`, `cy.deleteResource()` + +## Implementation Steps + +### 1. Cypress support infrastructure (`cypress/support/`) + +- **`support/selectors.ts`** — `cy.byTestID()`, `cy.byButtonText()`, `cy.checkTitle()`, `cy.checkSubTitle()`, `cy.switchProject()`, `cy.clickNavLink()`, `cy.clickBtn()` +- **`support/commands.ts`** — `cy.deleteResource(kind, name, ns)`, `cy.beforeSpec()` +- **`support/nav.ts`** — `cy.visitNAD()`, `cy.visitUDN()`, `cy.visitService()` + +### 2. Views and constants + +- **`views/nad.ts`** — `createNAD(nad: NadData)`, `deleteNAD(name: string)` + selectors +- **`views/udn.ts`** — `createUDN(project, subnet)`, `createClusterUDN(name, subnet, nsSelector)`, `deleteClusterUDN(name)` +- **`views/net-policies.ts`** — `denyTraffic()`, form radio/name fill helpers +- **`views/actions.ts`** — `checkActionMenu(kind)`, `getRow(name, within)` +- **`views/selector-common.ts`** — `row`, `brCrumbItem`, `itemFilter`, `createBtn`, `confirmBtn` +- **`utils/const/index.ts`** — `TEST_NS`, `UDN_NS`, `adminOnlyDescribe`, test names +- **`utils/const/nad.ts`** — `NAD_BRIDGE`, `NAD_OVN`, `NAD_LOCALNET` data objects +- **`utils/types/nad.ts`** — `NadData` interface + +### 3. Copy and adapt spec files + +| New file | Copies from (release-4.21) | Adaptations | +|---|---|---| +| `tests/udn.cy.ts` | `cypress/tests/tier2/networking/udn.cy.ts` | Remove VM creation tests + Passt section; keep create UDN, create CUDN, delete CUDN. UDN-enabled namespace created via shell (`oc`/`kubectl`) in `before()` hook, not via UI | +| `tests/nad-bridge.cy.ts` | `cypress/tests/tier2/networking/nad-bridge.cy.ts` | Remove VM creation/IP verification tests; keep `createNAD(NAD_BRIDGE)` | +| `tests/nad-localnet.cy.ts` | `cypress/tests/tier2/networking/nad-localnet.cy.ts` | Remove VM test; keep create + delete NAD | +| `tests/nad-ovn.cy.ts` | `cypress/tests/tier2/networking/nad-ovn.cy.ts` | Remove VM tests; keep `createNAD(NAD_OVN)` | +| `tests/net-policies.cy.ts` | `cypress/tests/tier2/networking/net-policies.cy.ts` | Keep as-is (MultiNetworkPolicy already xit) | +| `tests/services.cy.ts` | `cypress/tests/tier2/networking/services.cy.ts` | Adapt imports only | +| `tests/routes.cy.ts` | `cypress/tests/tier2/networking/routes.cy.ts` | Adapt imports only | +| `tests/ingresses.cy.ts` | `cypress/tests/tier2/networking/ingresses.cy.ts` | Adapt imports only | + +**Total: ~14 test cases** across 8 files + +### 4. GitHub Actions hot-cluster CI + +Create `.github/workflows/e2e.yml` following kubevirt-plugin PR #3713: +- Self-hosted runner on persistent OpenShift cluster +- Secrets: `CONSOLE_URL`, `KUBEADMIN_PASSWORD` +- Runs `npm run test-cypress-headless` +- Uploads JUnit + screenshots as artifacts +- ~5 min feedback loop + +### 5. Prow CI (existing) + +[`test-prow-e2e.sh`](../test-prow-e2e.sh) already runs `npm run test-cypress-headless`. Once specs are in place, it will run them with no script changes. A Prow job definition may need to be added/updated in `openshift/release`. + +### 6. Update kubevirt-ui (post-migration) + +The VM-dependent tests remaining in kubevirt-ui will break because they relied on UDN/CUDN/NAD creation from preceding tests in the same file. These need API-based setup: +- `oc apply -f` or `cy.exec('oc create ...')` to create UDN/CUDN/NAD resources before VM tests run +- This is tracked as part of the kubevirt-ui side of CNV-87983 + +### 7. Update Jira CNV-87983 + +- Update epic description with PR links +- Progress subtask CNV-87989 ("automated tests") +- Document which tests were migrated, which stay (with API setup), and which go to nmstate-console-plugin + +## File Structure (final) + +``` +cypress/ + cypress.config.js + tsconfig.json + plugins/ + index.ts + support/ + index.ts + login.ts + commands.ts + nav.ts + selectors.ts + views/ + nad.ts + udn.ts + actions.ts + selector-common.ts + utils/ + types/ + nad.ts + const/ + index.ts + nad.ts + scale.ts + tests/ + nad-bridge.cy.ts + nad-localnet.cy.ts + nad-ovn.cy.ts + udn.cy.ts + net-policies.cy.ts + services.cy.ts + routes.cy.ts + ingresses.cy.ts +.github/ + workflows/ + e2e.yml (hot-cluster CI) +test-prow-e2e.sh (updated screenshots path) +``` + +## Key Design Decisions + +- **Copy from Cypress (release-4.21)** — the primary source; tests did not differ much between release-4.21 and main +- **Cross-reference Playwright (main)** — check for any newer logic added after Cypress was dropped +- **Strip VM-dependent tests, don't delete them** — they stay in kubevirt-ui and will need API-based setup (create UDN/CUDN/NAD via `oc apply`) as a precondition instead of relying on prior UI tests +- **adminOnlyDescribe** — NAD/UDN tests require admin privileges; guard with `Cypress.expose('NON_PRIV')` check +- **beforeSpec without Virtualization perspective** — kubevirt-ui's `cy.beforeSpec()` switches to Virtualization perspective; our version should remain in Administrator perspective since networking resources are accessed from there +- **UDN namespace via shell** — UDN-enabled namespace creation uses `cy.exec('oc ...')` (shell/API), matching the kubevirt-ui approach where it's done in global setup via `setupTestNamespace(namespace, { 'k8s.ovn.org/primary-user-defined-network': '' })`. In Cypress this translates to `cy.exec('oc create namespace ...')` + label application diff --git a/ui-tests-cy/support/commands.ts b/ui-tests-cy/support/commands.ts index c2a7ebed..2de56782 100644 --- a/ui-tests-cy/support/commands.ts +++ b/ui-tests-cy/support/commands.ts @@ -10,17 +10,12 @@ declare global { } Cypress.Commands.add('deleteResource', (kind: string, name: string, namespace?: string) => { - if (!namespace) { - cy.exec(`oc delete --ignore-not-found=true ${kind} ${name} --wait=true --timeout=300s`, { - failOnNonZeroExit: false, - timeout: 5 * MINUTE, - }); - return; + const args = ['oc', 'delete', '--ignore-not-found=true']; + if (namespace) { + args.push('-n', namespace); } - cy.exec( - `oc delete --ignore-not-found=true -n ${namespace} ${kind} ${name} --wait=true --timeout=300s`, - { failOnNonZeroExit: false, timeout: 5 * MINUTE }, - ); + args.push(kind, name, '--wait=true', '--timeout=300s'); + cy.exec(args.join(' '), { failOnNonZeroExit: false, timeout: 5 * MINUTE }); }); Cypress.Commands.add('switchProject', (projectName: string) => { @@ -40,8 +35,10 @@ Cypress.Commands.add('switchProject', (projectName: string) => { { timeout: 10000 }, ) .first() - .clear() - .type(projectName); + .then(($input) => { + cy.wrap($input).clear(); + cy.wrap($input).type(projectName); + }); cy.contains('[data-test="dropdown-menu-item-link"], [role="option"], button', projectName, { timeout: 10000, }).click(); diff --git a/ui-tests-cy/support/index.ts b/ui-tests-cy/support/index.ts index 588da326..37d65a8a 100644 --- a/ui-tests-cy/support/index.ts +++ b/ui-tests-cy/support/index.ts @@ -3,8 +3,10 @@ import './selectors'; import './commands'; import './nav'; -Cypress.on('uncaught:exception', () => { - return false; +Cypress.on('uncaught:exception', (err) => { + if (err.message.includes('ResizeObserver') || err.message.includes('Script error')) { + return false; + } }); if (Cypress.env('HIDE_XHR')) { diff --git a/ui-tests-cy/support/login.ts b/ui-tests-cy/support/login.ts index 484828d5..39df9b4f 100644 --- a/ui-tests-cy/support/login.ts +++ b/ui-tests-cy/support/login.ts @@ -40,7 +40,7 @@ Cypress.Commands.add('login', (provider: string, username: string, password: str }); cy.get('#inputUsername', { timeout: 180000 }).should('be.visible'); cy.get('#inputUsername').type(originUsr); - cy.get('#inputPassword').type(originPwd); + cy.get('#inputPassword').type(originPwd, { log: false }); cy.get('button[type=submit]').click(); }, ); diff --git a/ui-tests-cy/support/selectors.ts b/ui-tests-cy/support/selectors.ts index 935fb8ec..30212a96 100644 --- a/ui-tests-cy/support/selectors.ts +++ b/ui-tests-cy/support/selectors.ts @@ -29,7 +29,7 @@ declare global { Cypress.Commands.add( 'byTestID', (selector: string, options?: Partial) => { - cy.get(`[data-test="${selector}"]`, options); + return cy.get(`[data-test="${selector}"]`, options); }, ); diff --git a/ui-tests-cy/tests/all.cy.ts b/ui-tests-cy/tests/all.cy.ts new file mode 100644 index 00000000..a78e00c0 --- /dev/null +++ b/ui-tests-cy/tests/all.cy.ts @@ -0,0 +1,10 @@ +import './setup/login.cy.ts'; +import './setup/visit-pages.cy.ts'; +import './networking/udn.cy.ts'; +import './networking/nad-bridge.cy.ts'; +import './networking/nad-localnet.cy.ts'; +import './networking/nad-ovn.cy.ts'; +import './networking/services.cy.ts'; +import './networking/routes.cy.ts'; +import './networking/ingresses.cy.ts'; +import './networking/net-policies.cy.ts'; diff --git a/ui-tests-cy/tests/networking/ingresses.cy.ts b/ui-tests-cy/tests/networking/ingresses.cy.ts new file mode 100644 index 00000000..c6f820ba --- /dev/null +++ b/ui-tests-cy/tests/networking/ingresses.cy.ts @@ -0,0 +1,20 @@ +import { EXAMPLE, MINUTE, TEST_NS } from '../../utils/const/base'; +import { checkActionMenu } from '../../views/actions'; +import { createBtn } from '../../views/nad'; +import { brCrumbItem, itemFilter, row } from '../../views/selector-common'; + +describe('Check Ingresses page', () => { + it('create Ingress with YAML', () => { + cy.visit(`/k8s/ns/${TEST_NS}/ingresses`); + cy.checkTitle('Ingresses', MINUTE); + cy.byButtonText('Create Ingress').click(); + cy.get('.monaco-editor', { timeout: 30000 }).should('exist'); + cy.get(createBtn).click(); + cy.checkTitle(EXAMPLE); + checkActionMenu('Ingress'); + cy.contains(brCrumbItem, 'Ingresses').find('a').click(); + cy.get(itemFilter).clear(); + cy.get(itemFilter).type(EXAMPLE); + cy.contains(row, EXAMPLE).should('exist'); + }); +}); diff --git a/ui-tests-cy/tests/networking/nad-bridge.cy.ts b/ui-tests-cy/tests/networking/nad-bridge.cy.ts new file mode 100644 index 00000000..e77949fa --- /dev/null +++ b/ui-tests-cy/tests/networking/nad-bridge.cy.ts @@ -0,0 +1,9 @@ +import { adminOnlyDescribe } from '../../utils/const/base'; +import { NAD_BRIDGE } from '../../utils/const/nad'; +import { createNAD } from '../../views/nad'; + +adminOnlyDescribe('Test linux bridge NAD', () => { + it('create NAD with MAC Spoof checked', () => { + createNAD(NAD_BRIDGE); + }); +}); diff --git a/ui-tests-cy/tests/networking/nad-localnet.cy.ts b/ui-tests-cy/tests/networking/nad-localnet.cy.ts new file mode 100644 index 00000000..a92c4b1d --- /dev/null +++ b/ui-tests-cy/tests/networking/nad-localnet.cy.ts @@ -0,0 +1,14 @@ +import { adminOnlyDescribe } from '../../utils/const/base'; +import { NAD_LOCALNET } from '../../utils/const/nad'; +import { createNAD, deleteNAD } from '../../views/nad'; + +adminOnlyDescribe('Test secondary localnet NAD', () => { + it('create NAD with secondary localnet network', () => { + createNAD(NAD_LOCALNET); + }); + + it('delete NAD', () => { + cy.visitNAD(); + deleteNAD(NAD_LOCALNET.name); + }); +}); diff --git a/ui-tests-cy/tests/networking/nad-ovn.cy.ts b/ui-tests-cy/tests/networking/nad-ovn.cy.ts new file mode 100644 index 00000000..dae5402c --- /dev/null +++ b/ui-tests-cy/tests/networking/nad-ovn.cy.ts @@ -0,0 +1,9 @@ +import { adminOnlyDescribe } from '../../utils/const/base'; +import { NAD_OVN } from '../../utils/const/nad'; +import { createNAD } from '../../views/nad'; + +adminOnlyDescribe('Test L2 overlay NAD', () => { + it('create NAD with L2 overlay network', () => { + createNAD(NAD_OVN); + }); +}); diff --git a/ui-tests-cy/tests/networking/net-policies.cy.ts b/ui-tests-cy/tests/networking/net-policies.cy.ts new file mode 100644 index 00000000..ca9edb24 --- /dev/null +++ b/ui-tests-cy/tests/networking/net-policies.cy.ts @@ -0,0 +1,31 @@ +import { MINUTE, TEST_NS, TEST_POL_NAME } from '../../utils/const/base'; +import { checkActionMenu, nameInput } from '../../views/actions'; +import { brCrumbItem, egressOff, ingressOff, itemFilter, row } from '../../views/selector-common'; + +const denyTraffic = () => { + cy.get(ingressOff).check(); + cy.get('body').then(($body) => { + if ($body.find(egressOff).length) { + cy.get(egressOff).check(); + } + }); +}; + +describe('Check NetworkPolicies page', () => { + it('create NetworkPolicy with form', () => { + cy.visit(`/k8s/ns/${TEST_NS}/networkpolicies`); + cy.checkTitle('NetworkPolicies', MINUTE); + cy.byButtonText('Create NetworkPolicy').click(); + cy.get('input#form').check(); + cy.get(nameInput, { timeout: 10000 }).should('be.visible').clear(); + cy.get(nameInput).type(TEST_POL_NAME); + cy.get(nameInput).should('have.value', TEST_POL_NAME); + denyTraffic(); + cy.clickBtn('Create'); + cy.checkTitle(TEST_POL_NAME); + checkActionMenu('NetworkPolicy'); + cy.contains(brCrumbItem, 'NetworkPolicy').find('a').click(); + cy.get(itemFilter).type(TEST_POL_NAME); + cy.contains(row, TEST_POL_NAME).should('exist'); + }); +}); diff --git a/ui-tests-cy/tests/networking/routes.cy.ts b/ui-tests-cy/tests/networking/routes.cy.ts new file mode 100644 index 00000000..3ea07dd9 --- /dev/null +++ b/ui-tests-cy/tests/networking/routes.cy.ts @@ -0,0 +1,32 @@ +import { EXAMPLE, MINUTE, TEST_NS, TEST_ROUTE_NAME } from '../../utils/const/base'; +import { checkActionMenu, nameInput } from '../../views/actions'; +import { createBtn } from '../../views/nad'; +import { brCrumbItem, itemFilter, row } from '../../views/selector-common'; + +describe('Check Routes page', () => { + it('create Route with form', () => { + cy.exec( + `oc get service ${EXAMPLE} -n ${TEST_NS} 2>/dev/null || oc create service clusterip ${EXAMPLE} --tcp=80:80 -n ${TEST_NS}`, + { failOnNonZeroExit: false }, + ); + cy.visit(`/k8s/ns/${TEST_NS}/routes`); + cy.checkTitle('Routes', MINUTE); + cy.byButtonText('Create Route').click(); + cy.get('input#form').check(); + cy.get(nameInput).clear(); + cy.get(nameInput).type(TEST_ROUTE_NAME); + cy.byButtonText('Select a Service').click(); + cy.byButtonText(EXAMPLE).click(); + cy.byButtonText('Select target port').click(); + cy.get('#target-port').within(() => { + cy.get('button').click(); + }); + cy.get(createBtn).click(); + cy.checkTitle(TEST_ROUTE_NAME); + checkActionMenu('Route'); + cy.contains(brCrumbItem, 'Routes').find('a').click(); + cy.get(itemFilter).clear(); + cy.get(itemFilter).type(TEST_ROUTE_NAME); + cy.contains(row, TEST_ROUTE_NAME).should('exist'); + }); +}); diff --git a/ui-tests-cy/tests/networking/services.cy.ts b/ui-tests-cy/tests/networking/services.cy.ts new file mode 100644 index 00000000..8f502647 --- /dev/null +++ b/ui-tests-cy/tests/networking/services.cy.ts @@ -0,0 +1,19 @@ +import { EXAMPLE, MINUTE, TEST_NS } from '../../utils/const/base'; +import { checkActionMenu } from '../../views/actions'; +import { createBtn } from '../../views/nad'; +import { brCrumbItem, itemFilter, row } from '../../views/selector-common'; + +describe('Check Services page', () => { + it('create Service with YAML', () => { + cy.visit(`/k8s/ns/${TEST_NS}/services`); + cy.checkTitle('Services', MINUTE); + cy.byButtonText('Create Service').click(); + cy.get('.monaco-editor', { timeout: 30000 }).should('exist'); + cy.get(createBtn).click(); + cy.checkTitle(EXAMPLE); + checkActionMenu('Service'); + cy.contains(brCrumbItem, 'Services').find('a').click(); + cy.get(itemFilter).type(EXAMPLE); + cy.contains(row, EXAMPLE).should('exist'); + }); +}); diff --git a/ui-tests-cy/tests/networking/udn.cy.ts b/ui-tests-cy/tests/networking/udn.cy.ts new file mode 100644 index 00000000..cc6d5599 --- /dev/null +++ b/ui-tests-cy/tests/networking/udn.cy.ts @@ -0,0 +1,46 @@ +import { adminOnlyDescribe, MINUTE, UDN_NS } from '../../utils/const/base'; +import { checkActionMenu } from '../../views/actions'; +import { brCrumbItem } from '../../views/selector-common'; +import { createUDN } from '../../views/udn'; + +const SUBNET = '192.168.1.1/24'; +const UDN_CLST = 'cluster-udn'; + +adminOnlyDescribe('Test UDN', () => { + it('create UDN', () => { + createUDN(UDN_NS, SUBNET); + }); + + it('create CUDN', () => { + cy.visit('/k8s/all-namespaces/k8s.ovn.org~v1~UserDefinedNetwork'); + cy.checkTitle('UserDefinedNetworks', MINUTE); + cy.get( + '[data-test="item-create"] .pf-v6-c-menu-toggle__controls, [data-test="item-create"] [aria-label="Menu toggle"]', + ).click(); + cy.contains('ClusterUserDefinedNetwork').click(); + cy.byTestID('input-name', { timeout: 30000 }).should('be.visible'); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(1000); + cy.byTestID('input-name').clear(); + cy.byTestID('input-name').type(UDN_CLST); + cy.byTestID('input-udn-subnet').clear(); + cy.byTestID('input-udn-subnet').type(SUBNET); + cy.byTestID('tags-input').clear(); + cy.byTestID('tags-input').type(`kubernetes.io/metadata.name=${UDN_NS}`); + cy.byButtonText('Review selected').click(); + cy.byTestID('create-udn-submit').click(); + cy.checkTitle(UDN_CLST); + checkActionMenu('ClusterUserDefinedNetwork'); + cy.contains(brCrumbItem, 'ClusterUserDefinedNetworks').find('a').eq(0).click(); + }); + + it('delete CUDN', () => { + cy.visit('/k8s/all-namespaces/k8s.ovn.org~v1~UserDefinedNetwork'); + cy.checkTitle('UserDefinedNetworks', MINUTE); + cy.contains('[data-label="name"]', UDN_CLST).find('a').click(); + cy.byButtonText('Actions').click(); + cy.get('[data-test-action="Delete ClusterUserDefinedNetwork"]').click(); + cy.byTestID('confirm-action').click(); + cy.contains(UDN_CLST).should('not.exist'); + }); +}); diff --git a/ui-tests-cy/tests/setup/login.cy.ts b/ui-tests-cy/tests/setup/login.cy.ts new file mode 100644 index 00000000..00492e98 --- /dev/null +++ b/ui-tests-cy/tests/setup/login.cy.ts @@ -0,0 +1,5 @@ +describe('Login', () => { + it('login to OpenShift console', () => { + cy.login(); + }); +}); diff --git a/ui-tests-cy/tests/setup/visit-pages.cy.ts b/ui-tests-cy/tests/setup/visit-pages.cy.ts new file mode 100644 index 00000000..6ec553f9 --- /dev/null +++ b/ui-tests-cy/tests/setup/visit-pages.cy.ts @@ -0,0 +1,35 @@ +import { MINUTE } from '../../utils/const/base'; + +describe('Visit networking pages', () => { + it('visit NetworkAttachmentDefinitions page', () => { + cy.get('[data-quickstart-id="qs-nav-networking"]', { timeout: MINUTE }).scrollIntoView(); + cy.contains('Networking').should('be.visible'); + cy.clickNavLink(['Networking', 'NetworkAttachmentDefinitions']); + cy.checkTitle('NetworkAttachmentDefinitions', MINUTE); + }); + + it('visit UserDefinedNetworks page', () => { + cy.clickNavLink(['Networking', 'UserDefinedNetworks']); + cy.checkTitle('UserDefinedNetworks', MINUTE); + }); + + it('visit Services page', () => { + cy.clickNavLink(['Networking', 'Services']); + cy.checkTitle('Services', MINUTE); + }); + + it('visit Routes page', () => { + cy.clickNavLink(['Networking', 'Routes']); + cy.checkTitle('Routes', MINUTE); + }); + + it('visit Ingresses page', () => { + cy.clickNavLink(['Networking', 'Ingresses']); + cy.checkTitle('Ingresses', MINUTE); + }); + + it('visit NetworkPolicies page', () => { + cy.clickNavLink(['Networking', 'NetworkPolicies']); + cy.checkTitle('NetworkPolicies', MINUTE); + }); +}); diff --git a/ui-tests-cy/utils/types/nad.ts b/ui-tests-cy/utils/types/nad.ts index 58116cb6..5d8898bb 100644 --- a/ui-tests-cy/utils/types/nad.ts +++ b/ui-tests-cy/utils/types/nad.ts @@ -1,11 +1,11 @@ export type NadData = { - name?: string; - description?: string; - type?: string; bridge?: string; - vlan?: string; - mtu?: string; + description?: string; + exclude?: string; macSpoof?: boolean; + mtu?: string; + name?: string; subnet?: string; - exclude?: string; + type?: string; + vlan?: string; }; diff --git a/ui-tests-cy/views/nad.ts b/ui-tests-cy/views/nad.ts index f7e2e63d..7fa38635 100644 --- a/ui-tests-cy/views/nad.ts +++ b/ui-tests-cy/views/nad.ts @@ -26,15 +26,19 @@ export const createNAD = (nad: NadData) => { cy.byButtonText('Create NetworkAttachmentDefinition').click(); cy.get('#form').check(); cy.get(name, { timeout: 10000 }).should('be.visible'); - cy.get(name).clear().type(nad.name); - cy.get(description).clear().type(nad.description); + cy.get(name).clear(); + cy.get(name).type(nad.name); + cy.get(description).clear(); + cy.get(description).type(nad.description); cy.get(type).click(); switch (nad.type) { case 'Bridge': { cy.byButtonText('Linux bridge').click(); - cy.get(bridgeName).clear().type(nad.bridge); + cy.get(bridgeName).clear(); + cy.get(bridgeName).type(nad.bridge); if (nad.vlan) { - cy.get(bridgeVlan).clear().type(nad.vlan); + cy.get(bridgeVlan).clear(); + cy.get(bridgeVlan).type(nad.vlan); } if (!nad.macSpoof) { cy.get(macSpoofCHK).uncheck(); @@ -50,10 +54,13 @@ export const createNAD = (nad: NadData) => { } case 'Localnet': { cy.byButtonText('secondary localnet').click(); - cy.get(bridgeMap).clear().type(nad.bridge); - cy.get(bridgeMTU).clear().type(nad.mtu); + cy.get(bridgeMap).clear(); + cy.get(bridgeMap).type(nad.bridge); + cy.get(bridgeMTU).clear(); + cy.get(bridgeMTU).type(nad.mtu); if (nad.vlan) { - cy.get(localnetVlan).clear().type(nad.vlan); + cy.get(localnetVlan).clear(); + cy.get(localnetVlan).type(nad.vlan); } if (nad.subnet) { cy.get(localnetSubnet).type(nad.subnet);