diff --git a/.cypress.ci.env.json b/.cypress.ci.env.json new file mode 100644 index 00000000000..1a79ceb1163 --- /dev/null +++ b/.cypress.ci.env.json @@ -0,0 +1,3 @@ +{ + "disable_screenshots": "true" +} diff --git a/.cypress.dev.env.json b/.cypress.dev.env.json new file mode 100644 index 00000000000..18c5362392c --- /dev/null +++ b/.cypress.dev.env.json @@ -0,0 +1,3 @@ +{ + "disable_screenshots": "false" +} diff --git a/.gitignore b/.gitignore index bf1f7099e4e..6c5c1acb42a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ config/webpack/paths.json *.sw[po] cypress/screenshots cypress/videos +cypress.env.json diff --git a/.travis.yml b/.travis.yml index 1a56fe7785e..a8dc4e09d01 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,12 +14,7 @@ addons: chrome: beta env: matrix: - - TEST_SUITE=spec - - TEST_SUITE=spec:routes - - TEST_SUITE=spec:javascript - - TEST_SUITE=spec:compile - - TEST_SUITE=spec:jest - - TEST_SUITE=spec:debride + - TEST_SUITE=spec:cypress matrix: exclude: - rvm: 2.5.7 @@ -32,6 +27,8 @@ matrix: env: TEST_SUITE=spec:jest - rvm: 2.5.7 env: TEST_SUITE=spec:debride + - rvm: 2.5.7 + env: TEST_SUITE=spec:cypress bundler_args: "--no-deployment" before_install: source tools/ci/before_install.sh install: bin/setup diff --git a/Rakefile b/Rakefile index 7871428aae0..de1e144ddc2 100644 --- a/Rakefile +++ b/Rakefile @@ -54,6 +54,27 @@ namespace :spec do desc "Run all javascript specs" task :javascript => ["app:test:initialize", :environment, "jasmine:ci"] + desc "Run all cypress specs" + task :cypress => ["app:cypress:ui:run"] + + namespace :cypress do + task :ci => ["ci:set_node_path", "app:cypress:ui:seed", "spec:cypress"] + + task "ci:install_yarn" do + sh "/bin/bash", "-c", "source ~/.nvm/nvm.sh && nvm install 12 && npm install -g yarn" + end + + task "ci:set_node_path" do + ENV["PATH"] = "#{Dir.glob("/home/travis/.nvm/versions/node/v12*").sort.last}/bin:#{ENV["PATH"]}" + # puts;puts;puts + # puts ">>>>>> PATH: #{ENV["PATH"]}" + # puts;puts;puts + end + + desc "Setup for CI" + task "ci:setup" => ["ci:install_yarn", "ci:set_node_path", "update:ui"] + end + desc "Try to compile assets" task :compile => ["app:assets:precompile"] diff --git a/bin/webpack b/bin/webpack index 1395d029d9f..ab070d3b88d 100755 --- a/bin/webpack +++ b/bin/webpack @@ -7,12 +7,12 @@ require "yaml" ENV["RAILS_ENV"] ||= "development" RAILS_ENV = ENV["RAILS_ENV"] -ENV["NODE_ENV"] ||= RAILS_ENV +ENV["NODE_ENV"] ||= RAILS_ENV == "integration" ? "production" : RAILS_ENV NODE_ENV = ENV["NODE_ENV"] APP_PATH = File.expand_path("../", __dir__) NODE_MODULES_PATH = File.join(APP_PATH, "node_modules") -WEBPACK_CONFIG = File.join(APP_PATH, "config/webpack/#{NODE_ENV}.js") +WEBPACK_CONFIG = File.join(APP_PATH, "config/webpack/#{RAILS_ENV}.js") unless File.exist?(WEBPACK_CONFIG) puts "Webpack configuration not found." diff --git a/config/webpack/integration.js b/config/webpack/integration.js new file mode 100644 index 00000000000..a03774642c4 --- /dev/null +++ b/config/webpack/integration.js @@ -0,0 +1,11 @@ +// Note: You must restart bin/webpack-dev-server for changes to take effect + +/* eslint global-require: 0 */ + +const { env } = require('process') + +if (env.CYPRESS_DEV) { + module.exports = require('./development.js') +} else { + module.exports = require('./production.js') +} diff --git a/config/webpacker.yml b/config/webpacker.yml index 57f56bf7674..ecc351bcbef 100644 --- a/config/webpacker.yml +++ b/config/webpacker.yml @@ -26,5 +26,14 @@ development: test: <<: *default +integration: + <<: *default + +# uncomment these to enable webpack:server + dev_server: + host: 0.0.0.0 + port: 8080 + https: false + production: <<: *default diff --git a/cypress/support/assertions/expect_title.js b/cypress/support/assertions/expect_title.js new file mode 100644 index 00000000000..8b795f7d777 --- /dev/null +++ b/cypress/support/assertions/expect_title.js @@ -0,0 +1,7 @@ +Cypress.Commands.add("expect_explorer_title", (text) => { + return cy.get('#explorer_title_text').contains(text); +}); + +Cypress.Commands.add("expect_show_list_title", (text) => { + return cy.get('#main-content h1').contains(text); +}); diff --git a/cypress/support/commands.js b/cypress/support/commands.js deleted file mode 100644 index f2f15b9fd86..00000000000 --- a/cypress/support/commands.js +++ /dev/null @@ -1,198 +0,0 @@ -// *********************************************** -// This example commands.js shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add("login", (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) - -// cy.login() - log in -// FIXME: use cy.request and inject cookie and localStorage.miqToken -Cypress.Commands.add("login", (user = 'admin', password = 'smartvm') => { - cy.visit('/'); - - cy.get('#user_name').type(user); - cy.get('#user_password').type(password); - return cy.get('#login').click(); -}); - -// cy.menu('Compute', 'Infrastructure', 'VMs') - navigate the main menu -Cypress.Commands.add("menu", (...items) => { - expect(items.length).to.be.within(1, 3); - - const primary = '#main-menu nav.primary'; - const secondary = '#main-menu nav.secondary'; - - let ret = cy.get(`${primary} > ul > li`) - .contains('a > span', items[0]) - .parent().parent() - .click(); - - if (items.length === 2) { - ret = cy.get(`${secondary} > ul > li`) - .contains('a > span', items[1]) - .parent().parent() - .click(); - } - - if (items.length === 3) { - ret = cy.get(`${secondary} > ul > li`) - .contains('button > span', items[1]) - .parent().parent() - .click() - .find(`ul > li`) - .contains('a > span', items[2]) - .parent().parent() - .click(); - } - - return ret; - // TODO support by id: cy.get('li[id=menu_item_provider_foreman]').click({ force: true }); -}); - -// cy.menuItems() - returns an array of top level menu items with {title, href, items (array of children)} -Cypress.Commands.add("menuItems", () => { - cy.get('#main-menu nav.primary'); // Wait for menu to appear - return cy.window().then((window) => window.ManageIQ.menu); -}); - -// cy.accordion('Catalog Items') - click accordion, unless already expanded -Cypress.Commands.add('accordion', (text) => { - cy.get('#main-content'); // ensure screen loads first - - let ret = cy.get('#accordion') - .find('.panel-title a') - .contains(new RegExp(`^${text}$`)) - - ret.then((el) => { - // do not collapse if expanded - if (el.is('.collapsed')) { - el.click(); - } - return el; - }); - - return ret.parents('.panel'); -}); - -// cy.toolbarItem('Configuration', 'Edit this VM') - return a toolbar button state -Cypress.Commands.add('toolbarItem', (...items) => { - expect(items.length).to.be.within(1, 2); - - let ret = cy.get('#toolbar') - .find(items[1] ? '.dropdown-toggle' : 'button') - .contains(items[0]); - - if (items[1]) { - ret = ret.siblings('.dropdown-menu') - .find('a > span') - .contains(items[1]) - .parents('a > span'); - } - - return ret.then((el) => { - return { - id: el[0].id, - label: el.text().trim(), - icon: el.find('i'), - disabled: !items[1] ? !!el.prop('disabled') : !!el.parents('li').hasClass('disabled'), - }; - }); -}); - -// cy.toolbar('Configuration', 'Edit this VM') - click a toolbar button -// TODO {id: 'view_grid'|'view_tile'|'view_list'} for the view toolbar -// TODO {id: 'download_choice'}, .. for the download dropdown -// TODO: some alias for having %i.fa-download, .fa-refresh, .pficon-print, .fa-arrow-left -// TODO custom buttons -Cypress.Commands.add('toolbar', (...items) => { - expect(items.length).to.be.within(1, 2); - - let ret = cy.get('#toolbar') - .contains(items[1] ? '.dropdown-toggle' : 'button', items[0]); - // by-id: #id on button instead of .contains - - ret = ret.then((el) => { - assert.equal(!!el.prop('disabled'), false, "Parent toolbar button disabled"); - return el; - }); - - if (items[1]) { - ret.click(); - - // a doesn't react to click here, have to click the span - ret = ret.siblings('.dropdown-menu') - .find('a > span') - .contains(items[1]) - .parents('a > span'); - // by-id: #id on the same span - - ret = ret.then((el) => { - assert.equal(el.parents('li').hasClass('disabled'), false, "Child toolbar button disabled"); - return el; - }); - } - - return ret.click(); - // TODO .dropdown-toggle#vm_lifecycle_choice -}); - -// assertions - -Cypress.Commands.add("expect_explorer_title", (text) => { - return cy.get('#explorer_title_text').contains(text); -}); - -Cypress.Commands.add("expect_show_list_title", (text) => { - return cy.get('#main-content h1').contains(text); -}); - -// GTL related helpers -Cypress.Commands.add("gtl_error", () => { - return cy.get('#miq-gtl-view > #flash_msg_div').should('be.visible'); -}); - -Cypress.Commands.add("gtl_no_record", () => { - return cy.get('#miq-gtl-view > div.no-record').should('be.visible'); -}); - -Cypress.Commands.add("gtl", () => { - cy.get('#miq-gtl-view').then($gtlTile => { - if ($gtlTile.find("miq-tile-view").length > 0) { - return cy.get("div[pf-card-view] > .card-view-pf > .card"); - } else { - return cy.get('#miq-gtl-view > miq-data-table > div > table'); - }; - }); -}); - -Cypress.Commands.add("gtl_click", (name) => { - cy.gtl().contains(name).click() -}); - -// Searchbox related helpers -Cypress.Commands.add("search_box", () => { - return cy.get('#search_text').should('be.visible'); -}); - -Cypress.Commands.add("no_search_box", () => { - return cy.get('#search_text').should('not.be.visible'); -}); diff --git a/cypress/support/commands/explorer.js b/cypress/support/commands/explorer.js new file mode 100644 index 00000000000..ad89345b238 --- /dev/null +++ b/cypress/support/commands/explorer.js @@ -0,0 +1,18 @@ +// cy.accordion('Catalog Items') - click accordion, unless already expanded +Cypress.Commands.add('accordion', (text) => { + cy.get('#main-content'); // ensure screen loads first + + let ret = cy.get('#accordion') + .find('.panel-title a') + .contains(new RegExp(`^${text}$`)) + + ret.then((el) => { + // do not collapse if expanded + if (el.is('.collapsed')) { + el.click(); + } + return el; + }); + + return ret.parents('.panel'); +}); diff --git a/cypress/support/commands/gtl.js b/cypress/support/commands/gtl.js new file mode 100644 index 00000000000..06d16bd7804 --- /dev/null +++ b/cypress/support/commands/gtl.js @@ -0,0 +1,22 @@ +// GTL related helpers +Cypress.Commands.add("gtl_error", () => { + return cy.get('#miq-gtl-view > #flash_msg_div').should('be.visible'); +}); + +Cypress.Commands.add("gtl_no_record", () => { + return cy.get('#miq-gtl-view > div.no-record').should('be.visible'); +}); + +Cypress.Commands.add("gtl", () => { + cy.get('#miq-gtl-view').then($gtlTile => { + if ($gtlTile.find("miq-tile-view").length > 0) { + return cy.get("div[pf-card-view] > .card-view-pf > .card"); + } else { + return cy.get('#miq-gtl-view > miq-data-table > div > table'); + }; + }); +}); + +Cypress.Commands.add("gtl_click", (name) => { + cy.gtl().contains(name).click() +}); diff --git a/cypress/support/commands/login.js b/cypress/support/commands/login.js new file mode 100644 index 00000000000..d35c2ecb515 --- /dev/null +++ b/cypress/support/commands/login.js @@ -0,0 +1,10 @@ +// cy.login() - log in +// FIXME: use cy.request and inject cookie and localStorage.miqToken +Cypress.Commands.add("login", (user = 'admin', password = 'smartvm') => { + cy.visit('/'); + + cy.get('#user_name').type(user); + cy.get('#user_password').type(password); + return cy.get('#login').click(); +}); + diff --git a/cypress/support/commands/menu.js b/cypress/support/commands/menu.js new file mode 100644 index 00000000000..e880df93a8e --- /dev/null +++ b/cypress/support/commands/menu.js @@ -0,0 +1,39 @@ +// cy.menu('Compute', 'Infrastructure', 'VMs') - navigate the main menu +Cypress.Commands.add("menu", (...items) => { + expect(items.length).to.be.within(1, 3); + + const primary = '#main-menu nav.primary'; + const secondary = '#main-menu nav.secondary'; + + let ret = cy.get(`${primary} > ul > li`) + .contains('a > span', items[0]) + .parent().parent() + .click(); + + if (items.length === 2) { + ret = cy.get(`${secondary} > ul > li`) + .contains('a > span', items[1]) + .parent().parent() + .click(); + } + + if (items.length === 3) { + ret = cy.get(`${secondary} > ul > li`) + .contains('button > span', items[1]) + .parent().parent() + .click() + .find(`ul > li`) + .contains('a > span', items[2]) + .parent().parent() + .click(); + } + + return ret; + // TODO support by id: cy.get('li[id=menu_item_provider_foreman]').click({ force: true }); +}); + +// cy.menuItems() - returns an array of top level menu items with {title, href, items (array of children)} +Cypress.Commands.add("menuItems", () => { + cy.get('#main-menu nav.primary'); // Wait for menu to appear + return cy.window().then((window) => window.ManageIQ.menu); +}); diff --git a/cypress/support/commands/search.js b/cypress/support/commands/search.js new file mode 100644 index 00000000000..906a8f1b41b --- /dev/null +++ b/cypress/support/commands/search.js @@ -0,0 +1,8 @@ +// Searchbox related helpers +Cypress.Commands.add("search_box", () => { + return cy.get('#search_text').should('be.visible'); +}); + +Cypress.Commands.add("no_search_box", () => { + return cy.get('#search_text').should('not.be.visible'); +}); diff --git a/cypress/support/commands/toolbar.js b/cypress/support/commands/toolbar.js new file mode 100644 index 00000000000..2e4a4d05b17 --- /dev/null +++ b/cypress/support/commands/toolbar.js @@ -0,0 +1,61 @@ +// cy.toolbarItem('Configuration', 'Edit this VM') - return a toolbar button state +Cypress.Commands.add('toolbarItem', (...items) => { + expect(items.length).to.be.within(1, 2); + + let ret = cy.get('#toolbar') + .find(items[1] ? '.dropdown-toggle' : 'button') + .contains(items[0]); + + if (items[1]) { + ret = ret.siblings('.dropdown-menu') + .find('a > span') + .contains(items[1]) + .parents('a > span'); + } + + return ret.then((el) => { + return { + id: el[0].id, + label: el.text().trim(), + icon: el.find('i'), + disabled: !items[1] ? !!el.prop('disabled') : !!el.parents('li').hasClass('disabled'), + }; + }); +}); + +// cy.toolbar('Configuration', 'Edit this VM') - click a toolbar button +// TODO {id: 'view_grid'|'view_tile'|'view_list'} for the view toolbar +// TODO {id: 'download_choice'}, .. for the download dropdown +// TODO: some alias for having %i.fa-download, .fa-refresh, .pficon-print, .fa-arrow-left +// TODO custom buttons +Cypress.Commands.add('toolbar', (...items) => { + expect(items.length).to.be.within(1, 2); + + let ret = cy.get('#toolbar') + .contains(items[1] ? '.dropdown-toggle' : 'button', items[0]); + // by-id: #id on button instead of .contains + + ret = ret.then((el) => { + assert.equal(!!el.prop('disabled'), false, "Parent toolbar button disabled"); + return el; + }); + + if (items[1]) { + ret.click(); + + // a doesn't react to click here, have to click the span + ret = ret.siblings('.dropdown-menu') + .find('a > span') + .contains(items[1]) + .parents('a > span'); + // by-id: #id on the same span + + ret = ret.then((el) => { + assert.equal(el.parents('li').hasClass('disabled'), false, "Child toolbar button disabled"); + return el; + }); + } + + return ret.click(); + // TODO .dropdown-toggle#vm_lifecycle_choice +}); diff --git a/cypress/support/index.js b/cypress/support/index.js index d68db96df26..0a79512a6ef 100644 --- a/cypress/support/index.js +++ b/cypress/support/index.js @@ -11,10 +11,45 @@ // // You can read more here: // https://on.cypress.io/configuration + + +// *********************************************** +// Below shows you how to create various custom +// commands and overwrite existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** +// +// +// -- This is a parent command -- +// Cypress.Commands.add("login", (email, password) => { ... }) +// +// +// -- This is a child command -- +// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) +// +// +// -- This is a dual command -- +// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) +// +// +// -- This will overwrite an existing command -- +// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) // *********************************************************** -// Import commands.js using ES2015 syntax: -import './commands' +// Commands +import './commands/gtl.js' +import './commands/login.js' +import './commands/menu.js' +import './commands/search.js' +import './commands/toolbar.js' + +// Assertions +import './assertions/expect_explorer_title.js' +import './assertions/expect_show_list_title.js' -// Alternatively you can use CommonJS syntax: -// require('./commands') +Cypress.Screenshot.defaults({ + screenshotOnRunFailure: !(Cypress.env('disable_screenshots') == 'true') +}) diff --git a/lib/tasks/manageiq/cypress.rake b/lib/tasks/manageiq/cypress.rake new file mode 100644 index 00000000000..c88a4bf8d4c --- /dev/null +++ b/lib/tasks/manageiq/cypress.rake @@ -0,0 +1,16 @@ +# This check ensures that we only load this task under the `app:` prefix when +# in `manageiq-ui-classic`, and loads it normally under `manageiq` +# +# Without it, a few things happen: +# +# - There is an error since there is no manageiq/integration file in the path +# - If the above is addressed, it will load this task in two contexts when +# loading from manageiq-ui-classic: +# - cypress:ui:* +# - app:cypress:ui:* +# +if Rails.root + require "manageiq/integration" + + ManageIQ::Integration::CypressRakeTask.new(:ui) +end diff --git a/lib/tasks/manageiq/ui_tasks.rake b/lib/tasks/manageiq/ui_tasks.rake index 2fd22ce21d0..9d93fc4f702 100644 --- a/lib/tasks/manageiq/ui_tasks.rake +++ b/lib/tasks/manageiq/ui_tasks.rake @@ -88,27 +88,33 @@ namespace :webpack do end end +miq_app_prefix = defined?(ENGINE_ROOT) ? "app:" : "" + # compile and clobber when running assets:* tasks -if Rake::Task.task_defined?("assets:precompile") - Rake::Task["assets:precompile"].enhance do - Rake::Task["webpack:compile"].invoke unless ENV["TRAVIS"] +if Rake::Task.task_defined?("#{miq_app_prefix}assets:precompile") + unless ENV["TRAVIS"] + Rake::Task["#{miq_app_prefix}assets:precompile"].enhance do + Rake::Task["webpack:compile"].invoke + end end - Rake::Task["assets:precompile"].actions.each do |action| + Rake::Task["#{miq_app_prefix}assets:precompile"].actions.each do |action| if action.source_location[0].include?(File.join("lib", "tasks", "webpacker")) - Rake::Task["assets:precompile"].actions.delete(action) + Rake::Task["#{miq_app_prefix}assets:precompile"].actions.delete(action) end end end -if Rake::Task.task_defined?("assets:clobber") - Rake::Task["assets:clobber"].enhance do - Rake::Task["webpack:clobber"].invoke unless ENV["TRAVIS"] +if Rake::Task.task_defined?("#{miq_app_prefix}assets:clobber") + unless ENV["TRAVIS"] + Rake::Task["#{miq_app_prefix}assets:clobber"].enhance do + Rake::Task["webpack:clobber"].invoke + end end - Rake::Task["assets:clobber"].actions.each do |action| + Rake::Task["#{miq_app_prefix}assets:clobber"].actions.each do |action| if action.source_location[0].include?(File.join("lib", "tasks", "webpacker")) - Rake::Task["assets:clobber"].actions.delete(action) + Rake::Task["#{miq_app_prefix}assets:clobber"].actions.delete(action) end end end diff --git a/package.json b/package.json index a634ff7e2f2..4a1a9ddd7ed 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ ], "scripts": { "cypress:open": "cypress open", + "cypress:run:ci": "cypress run --headless --browser chrome --config video=false ", "cypress:run:chrome": "cypress run --headless --browser chrome", "cypress:run:firefox": "cypress run --headless --browser firefox", "test": "jest", diff --git a/tools/ci/before_install.sh b/tools/ci/before_install.sh index a5c7c6dca14..8d2f89c8fc4 100644 --- a/tools/ci/before_install.sh +++ b/tools/ci/before_install.sh @@ -1,4 +1,4 @@ # not spec, and not cross repo -if [ "$TEST_SUITE" = "spec:javascript" -o "$TEST_SUITE" = "spec:jest" -o "$TEST_SUITE" = "spec:compile" ]; then +if [ "$TEST_SUITE" = "spec:javascript" -o "$TEST_SUITE" = "spec:jest" -o "$TEST_SUITE" = "spec:compile" -o "$TEST_SUITE" = "spec:cypress" ]; then nvm install 12 fi diff --git a/tools/ci/before_script.sh b/tools/ci/before_script.sh index 7fb1ed2d92f..46625b84bec 100644 --- a/tools/ci/before_script.sh +++ b/tools/ci/before_script.sh @@ -1,8 +1,13 @@ # not spec, and not cross repo -if [ "$TEST_SUITE" = "spec:javascript" -o "$TEST_SUITE" = "spec:jest" -o "$TEST_SUITE" = "spec:compile" ]; then +if [ "$TEST_SUITE" = "spec:javascript" -o "$TEST_SUITE" = "spec:jest" -o "$TEST_SUITE" = "spec:compile" -o "$TEST_SUITE" = "spec:cypress" ]; then # make sure yarn is installed, in the right version bundle exec rake webpacker:check_yarn || npm install -g yarn # install & compile dependencies bundle exec rake update:ui + + if [ "$TEST_SUITE" = "spec:cypress" ]; then + # run evm:compile_assets (and friends) if cypress + bundle exec rake app:cypress:ui:seed + fi fi