From b53072a8145c24a830cea3752cab2c66e286df7b Mon Sep 17 00:00:00 2001 From: Leon Kladnitsky Date: Wed, 12 Aug 2026 10:39:37 +0300 Subject: [PATCH] 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 + 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 | 45 ++++++++++++++++++ ui-tests-cy/support/index.ts | 20 ++++++++ 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 ++++ 14 files changed, 365 insertions(+), 5 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/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..ac7757f6 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 ./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}", - "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..2de56782 --- /dev/null +++ b/ui-tests-cy/support/commands.ts @@ -0,0 +1,45 @@ +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) => { + const args = ['oc', 'delete', '--ignore-not-found=true']; + if (namespace) { + args.push('-n', namespace); + } + args.push(kind, name, '--wait=true', '--timeout=300s'); + cy.exec(args.join(' '), { 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() + .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 new file mode 100644 index 00000000..37d65a8a --- /dev/null +++ b/ui-tests-cy/support/index.ts @@ -0,0 +1,20 @@ +import './login'; +import './selectors'; +import './commands'; +import './nav'; + +Cypress.on('uncaught:exception', (err) => { + if (err.message.includes('ResizeObserver') || err.message.includes('Script error')) { + 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..39df9b4f --- /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, { log: false }); + 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..30212a96 --- /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) => { + return 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