diff options
Diffstat (limited to 'qa')
92 files changed, 2527 insertions, 243 deletions
diff --git a/qa/Gemfile b/qa/Gemfile index 4c866a3f893..c3e61568f3d 100644 --- a/qa/Gemfile +++ b/qa/Gemfile @@ -6,3 +6,5 @@ gem 'capybara-screenshot', '~> 1.0.18' gem 'rake', '~> 12.3.0' gem 'rspec', '~> 3.7' gem 'selenium-webdriver', '~> 3.8.0' +gem 'net-ssh', require: false +gem 'airborne', '~> 0.2.13' diff --git a/qa/Gemfile.lock b/qa/Gemfile.lock index 88d5fe834a0..51d2e4d7a10 100644 --- a/qa/Gemfile.lock +++ b/qa/Gemfile.lock @@ -1,8 +1,19 @@ GEM remote: https://rubygems.org/ specs: + activesupport (5.1.4) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (~> 0.7) + minitest (~> 5.1) + tzinfo (~> 1.1) addressable (2.5.2) public_suffix (>= 2.0.2, < 4.0) + airborne (0.2.13) + activesupport + rack + rack-test (~> 0.6, >= 0.6.2) + rest-client (>= 1.7.3, < 3.0) + rspec (~> 3.1) byebug (9.1.0) capybara (2.16.1) addressable @@ -17,13 +28,26 @@ GEM childprocess (0.8.0) ffi (~> 1.0, >= 1.0.11) coderay (1.1.2) + concurrent-ruby (1.0.5) diff-lcs (1.3) + domain_name (0.5.20170404) + unf (>= 0.0.5, < 1.0.0) ffi (1.9.18) + http-cookie (1.0.3) + domain_name (~> 0.5) + i18n (0.9.1) + concurrent-ruby (~> 1.0) launchy (2.4.3) addressable (~> 2.3) method_source (0.9.0) + mime-types (3.1) + mime-types-data (~> 3.2015) + mime-types-data (3.2016.0521) mini_mime (1.0.0) mini_portile2 (2.3.0) + minitest (5.11.1) + net-ssh (4.1.0) + netrc (0.11.0) nokogiri (1.8.1) mini_portile2 (~> 2.3.0) pry (0.11.3) @@ -37,11 +61,15 @@ GEM rack-test (0.8.2) rack (>= 1.0, < 3) rake (12.3.0) + rest-client (2.0.2) + http-cookie (>= 1.0.2, < 2.0) + mime-types (>= 1.16, < 4.0) + netrc (~> 0.8) rspec (3.7.0) rspec-core (~> 3.7.0) rspec-expectations (~> 3.7.0) rspec-mocks (~> 3.7.0) - rspec-core (3.7.0) + rspec-core (3.7.1) rspec-support (~> 3.7.0) rspec-expectations (3.7.0) diff-lcs (>= 1.2.0, < 2.0) @@ -54,6 +82,12 @@ GEM selenium-webdriver (3.8.0) childprocess (~> 0.5) rubyzip (~> 1.0) + thread_safe (0.3.6) + tzinfo (1.2.4) + thread_safe (~> 0.1) + unf (0.1.4) + unf_ext + unf_ext (0.0.7.4) xpath (2.1.0) nokogiri (~> 1.3) @@ -61,12 +95,14 @@ PLATFORMS ruby DEPENDENCIES + airborne (~> 0.2.13) capybara (~> 2.16.1) capybara-screenshot (~> 1.0.18) + net-ssh pry-byebug (~> 3.5.1) rake (~> 12.3.0) rspec (~> 3.7) selenium-webdriver (~> 3.8.0) BUNDLED WITH - 1.16.0 + 1.16.1 diff --git a/qa/README.md b/qa/README.md index 7f2dd39ff63..b937dc4c7a0 100644 --- a/qa/README.md +++ b/qa/README.md @@ -17,23 +17,41 @@ against any existing instance. 1. Along with GitLab Docker Images we also build and publish GitLab QA images. 1. GitLab QA project uses these images to execute integration tests. +## Validating GitLab views / partials / selectors in merge requests + +We recently added a new CI job that is going to be triggered for every push +event in CE and EE projects. The job is called `qa:selectors` and it will +verify coupling between page objects implemented as a part of GitLab QA +and corresponding views / partials / selectors in CE / EE. + +Whenever `qa:selectors` job fails in your merge request, you are supposed to +fix [page objects](qa/page/README.md). You should also trigger end-to-end tests +using `package-qa` manual action, to test if everything works fine. + ## How can I use it? You can use GitLab QA to exercise tests on any live instance! For example, the following call would login to a local [GDK] instance and run all specs in `qa/specs/features`: +First, `cd` into the `$gdk/gitlab/qa` directory. +The `bin/qa` script expects you to be in the `qa` folder of the app. + ``` bin/qa Test::Instance http://localhost:3000 ``` +### Writing tests + +1. [Using page objects](qa/page/README.md) + ### Running specific tests You can also supply specific tests to run as another parameter. For example, to -test the EE license specs, you can run: +run the repository-related specs, you can execute: ``` -EE_LICENSE="<YOUR LICENSE KEY>" bin/qa Test::Instance http://localhost qa/specs/features/ee +bin/qa Test::Instance http://localhost qa/specs/features/repository/ ``` Since the arguments would be passed to `rspec`, you could use all `rspec` @@ -11,6 +11,9 @@ module QA autoload :Scenario, 'qa/runtime/scenario' autoload :Browser, 'qa/runtime/browser' autoload :Env, 'qa/runtime/env' + autoload :RSAKey, 'qa/runtime/rsa_key' + autoload :Address, 'qa/runtime/address' + autoload :API, 'qa/runtime/api' end ## @@ -24,8 +27,13 @@ module QA module Resource autoload :Sandbox, 'qa/factory/resource/sandbox' autoload :Group, 'qa/factory/resource/group' + autoload :Issue, 'qa/factory/resource/issue' autoload :Project, 'qa/factory/resource/project' + autoload :MergeRequest, 'qa/factory/resource/merge_request' autoload :DeployKey, 'qa/factory/resource/deploy_key' + autoload :SecretVariable, 'qa/factory/resource/secret_variable' + autoload :Runner, 'qa/factory/resource/runner' + autoload :PersonalAccessToken, 'qa/factory/resource/personal_access_token' end module Repository @@ -46,7 +54,7 @@ module QA # autoload :Bootable, 'qa/scenario/bootable' autoload :Actable, 'qa/scenario/actable' - autoload :Entrypoint, 'qa/scenario/entrypoint' + autoload :Taggable, 'qa/scenario/taggable' autoload :Template, 'qa/scenario/template' ## @@ -58,6 +66,10 @@ module QA module Integration autoload :Mattermost, 'qa/scenario/test/integration/mattermost' end + + module Sanity + autoload :Selectors, 'qa/scenario/test/sanity/selectors' + end end end @@ -68,6 +80,9 @@ module QA # module Page autoload :Base, 'qa/page/base' + autoload :View, 'qa/page/view' + autoload :Element, 'qa/page/element' + autoload :Validator, 'qa/page/validator' module Main autoload :Login, 'qa/page/main/login' @@ -78,6 +93,7 @@ module QA autoload :Main, 'qa/page/menu/main' autoload :Side, 'qa/page/menu/side' autoload :Admin, 'qa/page/menu/admin' + autoload :Profile, 'qa/page/menu/profile' end module Dashboard @@ -93,12 +109,37 @@ module QA module Project autoload :New, 'qa/page/project/new' autoload :Show, 'qa/page/project/show' + autoload :Activity, 'qa/page/project/activity' + + module Pipeline + autoload :Index, 'qa/page/project/pipeline/index' + autoload :Show, 'qa/page/project/pipeline/show' + end module Settings autoload :Common, 'qa/page/project/settings/common' + autoload :Advanced, 'qa/page/project/settings/advanced' + autoload :Main, 'qa/page/project/settings/main' autoload :Repository, 'qa/page/project/settings/repository' + autoload :CICD, 'qa/page/project/settings/ci_cd' autoload :DeployKeys, 'qa/page/project/settings/deploy_keys' + autoload :SecretVariables, 'qa/page/project/settings/secret_variables' + autoload :Runners, 'qa/page/project/settings/runners' end + + module Issue + autoload :New, 'qa/page/project/issue/new' + autoload :Show, 'qa/page/project/issue/show' + autoload :Index, 'qa/page/project/issue/index' + end + end + + module Profile + autoload :PersonalAccessTokens, 'qa/page/profile/personal_access_tokens' + end + + module MergeRequest + autoload :New, 'qa/page/merge_request/new' end module Admin @@ -109,6 +150,13 @@ module QA autoload :Main, 'qa/page/mattermost/main' autoload :Login, 'qa/page/mattermost/login' end + + ## + # Classes describing components that are used by several pages. + # + module Component + autoload :Dropzone, 'qa/page/component/dropzone' + end end ## @@ -119,10 +167,13 @@ module QA end ## - # Classes describing shell interaction with GitLab + # Classes describing services being part of GitLab and how we can interact + # with these services, like through the shell. # - module Shell - autoload :Omnibus, 'qa/shell/omnibus' + module Service + autoload :Shellout, 'qa/service/shellout' + autoload :Omnibus, 'qa/service/omnibus' + autoload :Runner, 'qa/service/runner' end ## diff --git a/qa/qa/factory/base.rb b/qa/qa/factory/base.rb index 00851a7bece..bd66b74a164 100644 --- a/qa/qa/factory/base.rb +++ b/qa/qa/factory/base.rb @@ -1,12 +1,19 @@ +require 'forwardable' + module QA module Factory class Base + extend SingleForwardable + + def_delegators :evaluator, :dependency, :dependencies + def_delegators :evaluator, :product, :attributes + def fabricate!(*_args) raise NotImplementedError end def self.fabricate!(*args) - Factory::Product.populate!(new) do |factory| + new.tap do |factory| yield factory if block_given? dependencies.each do |name, signature| @@ -14,19 +21,37 @@ module QA end factory.fabricate!(*args) + + return Factory::Product.populate!(self) end end - def self.dependencies - @dependencies ||= {} + def self.evaluator + @evaluator ||= Factory::Base::DSL.new(self) end - def self.dependency(factory, as:, &block) - as.tap do |name| - class_eval { attr_accessor name } + class DSL + attr_reader :dependencies, :attributes + + def initialize(base) + @base = base + @dependencies = {} + @attributes = {} + end + + def dependency(factory, as:, &block) + as.tap do |name| + @base.class_eval { attr_accessor name } + + Dependency::Signature.new(factory, block).tap do |signature| + @dependencies.store(name, signature) + end + end + end - Dependency::Signature.new(factory, block).tap do |signature| - dependencies.store(name, signature) + def product(attribute, &block) + Product::Attribute.new(attribute, block).tap do |signature| + @attributes.store(attribute, signature) end end end diff --git a/qa/qa/factory/dependency.rb b/qa/qa/factory/dependency.rb index d0e85a68237..fc5dc82ce29 100644 --- a/qa/qa/factory/dependency.rb +++ b/qa/qa/factory/dependency.rb @@ -16,20 +16,21 @@ module QA def build! return if overridden? - Builder.new(@signature).fabricate!.tap do |product| + Builder.new(@signature, @factory).fabricate!.tap do |product| @factory.public_send("#{@name}=", product) end end class Builder - def initialize(signature) + def initialize(signature, caller_factory) @factory = signature.factory @block = signature.block + @caller_factory = caller_factory end def fabricate! @factory.fabricate! do |factory| - @block&.call(factory) + @block&.call(factory, @caller_factory) end end end diff --git a/qa/qa/factory/product.rb b/qa/qa/factory/product.rb index df35bbbb443..d004e642f9b 100644 --- a/qa/qa/factory/product.rb +++ b/qa/qa/factory/product.rb @@ -5,8 +5,9 @@ module QA class Product include Capybara::DSL - def initialize(factory) - @factory = factory + Attribute = Struct.new(:name, :block) + + def initialize @location = current_url end @@ -15,11 +16,13 @@ module QA end def self.populate!(factory) - raise ArgumentError unless block_given? - - yield factory - - new(factory) + new.tap do |product| + factory.attributes.each_value do |attribute| + product.instance_exec(&attribute.block).tap do |value| + product.define_singleton_method(attribute.name) { value } + end + end + end end end end diff --git a/qa/qa/factory/resource/deploy_key.rb b/qa/qa/factory/resource/deploy_key.rb index 7c58e70bcc4..ff0b4a46b77 100644 --- a/qa/qa/factory/resource/deploy_key.rb +++ b/qa/qa/factory/resource/deploy_key.rb @@ -4,6 +4,18 @@ module QA class DeployKey < Factory::Base attr_accessor :title, :key + product :title do + Page::Project::Settings::Repository.act do + expand_deploy_keys(&:key_title) + end + end + + product :fingerprint do + Page::Project::Settings::Repository.act do + expand_deploy_keys(&:key_fingerprint) + end + end + dependency Factory::Resource::Project, as: :project do |project| project.name = 'project-to-deploy' project.description = 'project for adding deploy key test' @@ -13,7 +25,7 @@ module QA project.visit! Page::Menu::Side.act do - click_repository_setting + click_repository_settings end Page::Project::Settings::Repository.perform do |setting| diff --git a/qa/qa/factory/resource/issue.rb b/qa/qa/factory/resource/issue.rb new file mode 100644 index 00000000000..95f48e20b3e --- /dev/null +++ b/qa/qa/factory/resource/issue.rb @@ -0,0 +1,32 @@ +module QA + module Factory + module Resource + class Issue < Factory::Base + attr_writer :title, :description, :project + + dependency Factory::Resource::Project, as: :project do |project| + project.name = 'project-for-issues' + project.description = 'project for adding issues' + end + + product :title do + Page::Project::Issue::Show.act { issue_title } + end + + def fabricate! + project.visit! + + Page::Project::Show.act do + go_to_new_issue + end + + Page::Project::Issue::New.perform do |page| + page.add_title(@title) + page.add_description(@description) + page.create_new_issue + end + end + end + end + end +end diff --git a/qa/qa/factory/resource/merge_request.rb b/qa/qa/factory/resource/merge_request.rb new file mode 100644 index 00000000000..ce04e904aaf --- /dev/null +++ b/qa/qa/factory/resource/merge_request.rb @@ -0,0 +1,49 @@ +require 'securerandom' + +module QA + module Factory + module Resource + class MergeRequest < Factory::Base + attr_accessor :title, + :description, + :source_branch, + :target_branch + + dependency Factory::Resource::Project, as: :project do |project| + project.name = 'project-with-merge-request' + end + + dependency Factory::Repository::Push, as: :target do |push, factory| + push.project = factory.project + push.branch_name = "master:#{factory.target_branch}" + end + + dependency Factory::Repository::Push, as: :source do |push, factory| + push.project = factory.project + push.branch_name = "#{factory.target_branch}:#{factory.source_branch}" + push.file_name = "added_file.txt" + push.file_content = "File Added" + end + + def initialize + @title = 'QA test - merge request' + @description = 'This is a test merge request' + @source_branch = "qa-test-feature-#{SecureRandom.hex(8)}" + @target_branch = "master" + end + + def fabricate! + project.visit! + + Page::Project::Show.act { new_merge_request } + + Page::MergeRequest::New.perform do |page| + page.fill_title(@title) + page.fill_description(@description) + page.create_merge_request + end + end + end + end + end +end diff --git a/qa/qa/factory/resource/personal_access_token.rb b/qa/qa/factory/resource/personal_access_token.rb new file mode 100644 index 00000000000..514e3615d18 --- /dev/null +++ b/qa/qa/factory/resource/personal_access_token.rb @@ -0,0 +1,27 @@ +module QA + module Factory + module Resource + ## + # Create a personal access token that can be used by the api + # + class PersonalAccessToken < Factory::Base + attr_accessor :name + + product :access_token do + Page::Profile::PersonalAccessTokens.act { created_access_token } + end + + def fabricate! + Page::Menu::Main.act { go_to_profile_settings } + Page::Menu::Profile.act { click_access_tokens } + + Page::Profile::PersonalAccessTokens.perform do |page| + page.fill_token_name(name || 'api-test-token') + page.check_api + page.create_token + end + end + end + end + end +end diff --git a/qa/qa/factory/resource/project.rb b/qa/qa/factory/resource/project.rb index 07c2e3086d1..7df2dc6618c 100644 --- a/qa/qa/factory/resource/project.rb +++ b/qa/qa/factory/resource/project.rb @@ -13,6 +13,10 @@ module QA @description = 'My awesome project' end + product :name do + Page::Project::Show.act { project_name } + end + def fabricate! group.visit! diff --git a/qa/qa/factory/resource/runner.rb b/qa/qa/factory/resource/runner.rb new file mode 100644 index 00000000000..5f37f8ac2e9 --- /dev/null +++ b/qa/qa/factory/resource/runner.rb @@ -0,0 +1,42 @@ +require 'securerandom' + +module QA + module Factory + module Resource + class Runner < Factory::Base + attr_writer :name, :tags + + dependency Factory::Resource::Project, as: :project do |project| + project.name = 'project-with-ci-cd' + project.description = 'Project with CI/CD Pipelines' + end + + def name + @name || "qa-runner-#{SecureRandom.hex(4)}" + end + + def tags + @tags || %w[qa e2e] + end + + def fabricate! + project.visit! + + Page::Menu::Side.act { click_ci_cd_settings } + + Service::Runner.new(name).tap do |runner| + Page::Project::Settings::CICD.perform do |settings| + settings.expand_runners_settings do |runners| + runner.pull + runner.token = runners.registration_token + runner.address = runners.coordinator_address + runner.tags = tags + runner.register! + end + end + end + end + end + end + end +end diff --git a/qa/qa/factory/resource/secret_variable.rb b/qa/qa/factory/resource/secret_variable.rb new file mode 100644 index 00000000000..54ef4d8d964 --- /dev/null +++ b/qa/qa/factory/resource/secret_variable.rb @@ -0,0 +1,41 @@ +module QA + module Factory + module Resource + class SecretVariable < Factory::Base + attr_accessor :key, :value + + product :key do + Page::Project::Settings::CICD.act do + expand_secret_variables(&:variable_key) + end + end + + product :value do + Page::Project::Settings::CICD.act do + expand_secret_variables(&:variable_value) + end + end + + dependency Factory::Resource::Project, as: :project do |project| + project.name = 'project-with-secret-variables' + project.description = 'project for adding secret variable test' + end + + def fabricate! + project.visit! + + Page::Menu::Side.act { click_ci_cd_settings } + + Page::Project::Settings::CICD.perform do |setting| + setting.expand_secret_variables do |page| + page.fill_variable_key(key) + page.fill_variable_value(value) + + page.add_variable + end + end + end + end + end + end +end diff --git a/qa/qa/page/README.md b/qa/qa/page/README.md new file mode 100644 index 00000000000..83710606d7c --- /dev/null +++ b/qa/qa/page/README.md @@ -0,0 +1,122 @@ +# Page objects in GitLab QA + +In GitLab QA we are using a known pattern, called _Page Objects_. + +This means that we have built an abstraction for all GitLab pages that we use +to drive GitLab QA scenarios. Whenever we do something on a page, like filling +in a form, or clicking a button, we do that only through a page object +associated with this area of GitLab. + +For example, when GitLab QA test harness signs in into GitLab, it needs to fill +in a user login and user password. In order to do that, we have a class, called +`Page::Main::Login` and `sign_in_using_credentials` methods, that is the only +piece of the code, that has knowledge about `user_login` and `user_password` +fields. + +## Why do we need that? + +We need page objects, because we need to reduce duplication and avoid problems +whenever someone changes some selectors in GitLab's source code. + +Imagine that we have a hundred specs in GitLab QA, and we need to sign into +GitLab each time, before we make assertions. Without a page object one would +need to rely on volatile helpers or invoke Capybara methods directly. Imagine +invoking `fill_in :user_login` in every `*_spec.rb` file / test example. + +When someone later changes `t.text_field :login` in the view associated with +this page to `t.text_field :username` it will generate a different field +identifier, what would effectively break all tests. + +Because we are using `Page::Main::Login.act { sign_in_using_credentials }` +everywhere, when we want to sign into GitLab, the page object is the single +source of truth, and we will need to update `fill_in :user_login` +to `fill_in :user_username` only in a one place. + +## What problems did we have in the past? + +We do not run QA tests for every commit, because of performance reasons, and +the time it would take to build packages and test everything. + +That is why when someone changes `t.text_field :login` to +`t.text_field :username` in the _new session_ view we won't know about this +change until our GitLab QA nightly pipeline fails, or until someone triggers +`package-qa` action in their merge request. + +Obviously such a change would break all tests. We call this problem a _fragile +tests problem_. + +In order to make GitLab QA more reliable and robust, we had to solve this +problem by introducing coupling between GitLab CE / EE views and GitLab QA. + +## How did we solve fragile tests problem? + +Currently, when you add a new `Page::Base` derived class, you will also need to +define all selectors that your page objects depends on. + +Whenever you push your code to CE / EE repository, `qa:selectors` sanity test +job is going to be run as a part of a CI pipeline. + +This test is going to validate all page objects that we have implemented in +`qa/page` directory. When it fails, you will be notified about missing +or invalid views / selectors definition. + +## How to properly implement a page object? + +We have built a DSL to define coupling between a page object and GitLab views +it is actually implemented by. See an example below. + +```ruby +module Page + module Main + class Login < Page::Base + view 'app/views/devise/passwords/edit.html.haml' do + element :password_field, 'password_field :password' + element :password_confirmation, 'password_field :password_confirmation' + element :change_password_button, 'submit "Change your password"' + end + + view 'app/views/devise/sessions/_new_base.html.haml' do + element :login_field, 'text_field :login' + element :password_field, 'password_field :password' + element :sign_in_button, 'submit "Sign in"' + end + + # ... + end +end +``` + +It is possible to use `element` DSL method without value, with a String value +or with a Regexp. + +```ruby +view 'app/views/my/view.html.haml' do + # Require `f.submit "Sign in"` to be present in `my/view.html.haml + element :my_button, 'f.submit "Sign in"' + + # Match every line in `my/view.html.haml` against + # `/link_to .* "My Profile"/` regexp. + element :profile_link, /link_to .* "My Profile"/ + + # Implicitly require `.qa-logout-button` CSS class to be present in the view + element :logout_button +end +``` + +## Running the test locally + +During development, you can run the `qa:selectors` test by running + +```shell +bin/qa Test::Sanity::Selectors +``` + +from within the `qa` directory. + +## Where to ask for help? + +If you need more information, ask for help on `#qa` channel on Slack (GitLab +Team only). + +If you are not a Team Member, and you still need help to contribute, please +open an issue in GitLab QA issue tracker. diff --git a/qa/qa/page/admin/settings.rb b/qa/qa/page/admin/settings.rb index 39e2f2062ad..1f646103e7f 100644 --- a/qa/qa/page/admin/settings.rb +++ b/qa/qa/page/admin/settings.rb @@ -2,6 +2,14 @@ module QA module Page module Admin class Settings < Page::Base + view 'app/views/admin/application_settings/_form.html.haml' do + element :form_actions, '.form-actions' + element :submit, "submit 'Save'" + element :repository_storage, '%legend Repository Storage' + element :hashed_storage, + 'Create new projects using hashed storage paths' + end + def enable_hashed_storage scroll_to 'legend', text: 'Repository Storage' check 'Create new projects using hashed storage paths' diff --git a/qa/qa/page/base.rb b/qa/qa/page/base.rb index 99eba02b6e3..5c3af4b9115 100644 --- a/qa/qa/page/base.rb +++ b/qa/qa/page/base.rb @@ -5,21 +5,26 @@ module QA class Base include Capybara::DSL include Scenario::Actable + extend SingleForwardable + + def_delegators :evaluator, :view, :views def refresh visit current_url end - def wait(css = '.application', time: 60) - Time.now.tap do |start| - while Time.now - start < time - break if page.has_css?(css, wait: 5) + def wait(max: 60, time: 1, reload: true) + start = Time.now - refresh - end + while Time.now - start < max + return true if yield + + sleep(time) + + refresh if reload end - yield if block_given? + false end def scroll_to(selector, text: nil) @@ -37,9 +42,74 @@ module QA page.within(selector) { yield } if block_given? end + # Returns true if successfully GETs the given URL + # Useful because `page.status_code` is unsupported by our driver, and + # we don't have access to the `response` to use `have_http_status`. + def asset_exists?(url) + page.execute_script <<~JS + xhr = new XMLHttpRequest(); + xhr.open('GET', '#{url}', true); + xhr.send(); + JS + + return false unless wait(time: 0.5, max: 60, reload: false) do + page.evaluate_script('xhr.readyState == XMLHttpRequest.DONE') + end + + page.evaluate_script('xhr.status') == 200 + end + + def find_element(name) + find(element_selector_css(name)) + end + + def click_element(name) + find_element(name).click + end + + def fill_element(name, content) + find_element(name).set(content) + end + + def within_element(name) + page.within(element_selector_css(name)) do + yield + end + end + + def element_selector_css(name) + Page::Element.new(name).selector_css + end + def self.path raise NotImplementedError end + + def self.evaluator + @evaluator ||= Page::Base::DSL.new + end + + def self.errors + if views.empty? + return ["Page class does not have views / elements defined!"] + end + + views.map(&:errors).flatten + end + + class DSL + attr_reader :views + + def initialize + @views = [] + end + + def view(path, &block) + Page::View.evaluate(&block).tap do |view| + @views.push(Page::View.new(path, view.elements)) + end + end + end end end end diff --git a/qa/qa/page/component/dropzone.rb b/qa/qa/page/component/dropzone.rb new file mode 100644 index 00000000000..15bdc742fda --- /dev/null +++ b/qa/qa/page/component/dropzone.rb @@ -0,0 +1,31 @@ +module QA + module Page + module Component + class Dropzone + attr_reader :page, :container + + # page - A QA::Page::Base object + # container - CSS selector of the comment textarea's container + def initialize(page, container) + @page = page + @container = container + end + + # Not tested and not expected to work with multiple dropzones + # instantiated on one page because there is no distinguishing + # attribute per dropzone file field. + def attach_file(attachment) + filename = File.basename(attachment) + + field_style = { visibility: 'visible', height: '', width: '' } + page.attach_file(attachment, class: 'dz-hidden-input', make_visible: field_style) + + # Wait for link to be appended to dropzone text + page.wait(reload: false) do + page.find("#{container} textarea").value.match(filename) + end + end + end + end + end +end diff --git a/qa/qa/page/dashboard/groups.rb b/qa/qa/page/dashboard/groups.rb index 083d2e1ab16..e853e0d85e0 100644 --- a/qa/qa/page/dashboard/groups.rb +++ b/qa/qa/page/dashboard/groups.rb @@ -2,6 +2,15 @@ module QA module Page module Dashboard class Groups < Page::Base + view 'app/views/shared/groups/_search_form.html.haml' do + element :groups_filter, 'search_field_tag :filter' + element :groups_filter_placeholder, 'Filter by name...' + end + + view 'app/views/dashboard/_groups_head.html.haml' do + element :new_group_button, 'link_to _("New group")' + end + def filter_by_name(name) fill_in 'Filter by name...', with: name end diff --git a/qa/qa/page/dashboard/projects.rb b/qa/qa/page/dashboard/projects.rb index 7ed27da6d89..73942cb856a 100644 --- a/qa/qa/page/dashboard/projects.rb +++ b/qa/qa/page/dashboard/projects.rb @@ -2,9 +2,22 @@ module QA module Page module Dashboard class Projects < Page::Base + view 'app/views/dashboard/projects/index.html.haml' + view 'app/views/shared/projects/_search_form.html.haml' do + element :form_filter_by_name, /form_tag.+id: 'project-filter-form'/ + end + def go_to_project(name) + filter_by_name(name) + find_link(text: name).click end + + def filter_by_name(name) + page.within('form#project-filter-form') do + fill_in :name, with: name + end + end end end end diff --git a/qa/qa/page/element.rb b/qa/qa/page/element.rb new file mode 100644 index 00000000000..9944a39ce07 --- /dev/null +++ b/qa/qa/page/element.rb @@ -0,0 +1,32 @@ +module QA + module Page + class Element + attr_reader :name + + def initialize(name, pattern = nil) + @name = name + @pattern = pattern || selector + end + + def selector + "qa-#{@name.to_s.tr('_', '-')}" + end + + def selector_css + ".#{selector}" + end + + def expression + if @pattern.is_a?(String) + @_regexp ||= Regexp.new(Regexp.escape(@pattern)) + else + @pattern + end + end + + def matches?(line) + !!(line =~ expression) + end + end + end +end diff --git a/qa/qa/page/group/new.rb b/qa/qa/page/group/new.rb index 53fdaaed078..48b71a7c883 100644 --- a/qa/qa/page/group/new.rb +++ b/qa/qa/page/group/new.rb @@ -2,6 +2,17 @@ module QA module Page module Group class New < Page::Base + view 'app/views/shared/_group_form.html.haml' do + element :group_path_field, 'text_field :path' + element :group_name_field, 'text_field :name' + element :group_description_field, 'text_area :description' + end + + view 'app/views/groups/new.html.haml' do + element :create_group_button, "submit 'Create group'" + element :visibility_radios, 'visibility_level:' + end + def set_path(path) fill_in 'group_path', with: path fill_in 'group_name', with: path diff --git a/qa/qa/page/group/show.rb b/qa/qa/page/group/show.rb index 0a16c07d64b..d215518d316 100644 --- a/qa/qa/page/group/show.rb +++ b/qa/qa/page/group/show.rb @@ -2,6 +2,21 @@ module QA module Page module Group class Show < Page::Base + view 'app/views/groups/show.html.haml' do + element :new_project_or_subgroup_dropdown, '.new-project-subgroup' + element :new_project_or_subgroup_dropdown_toggle, '.dropdown-toggle' + element :new_project_option, /%li.*data:.*value: "new-project"/ + element :new_project_button, /%input.*data:.*action: "new-project"/ + element :new_subgroup_option, /%li.*data:.*value: "new-subgroup"/ + + # data-value and data-action get modified by JS for subgroup + element :new_subgroup_button, /%input.*\.js-new-group-child/ + end + + view 'app/assets/javascripts/groups/constants.js' do + element :no_result_text, 'Sorry, no groups or projects matched your search' + end + def go_to_subgroup(name) click_link name end @@ -13,26 +28,41 @@ module QA def has_subgroup?(name) filter_by_name(name) - page.has_link?(name) + wait(reload: false) do + return false if page.has_content?('Sorry, no groups or projects matched your search') + + page.has_link?(name) + end end def go_to_new_subgroup - within '.new-project-subgroup' do - find('.dropdown-toggle').click - find("li[data-value='new-subgroup']").click - end + click_new('subgroup') find("input[data-action='new-subgroup']").click end def go_to_new_project - within '.new-project-subgroup' do - find('.dropdown-toggle').click - find("li[data-value='new-project']").click - end + click_new('project') find("input[data-action='new-project']").click end + + private + + def click_new(kind) + within '.new-project-subgroup' do + css = "li[data-value='new-#{kind}']" + + # May need to click again because it is possible to click the button quicker than the JS is bound + wait(reload: false) do + find('.dropdown-toggle').click + + page.has_css?(css) + end + + find(css).click + end + end end end end diff --git a/qa/qa/page/main/login.rb b/qa/qa/page/main/login.rb index f88325f408b..95880475ffa 100644 --- a/qa/qa/page/main/login.rb +++ b/qa/qa/page/main/login.rb @@ -2,20 +2,36 @@ module QA module Page module Main class Login < Page::Base + view 'app/views/devise/passwords/edit.html.haml' do + element :password_field, 'password_field :password' + element :password_confirmation, 'password_field :password_confirmation' + element :change_password_button, 'submit "Change your password"' + end + + view 'app/views/devise/sessions/_new_base.html.haml' do + element :login_field, 'text_field :login' + element :password_field, 'password_field :password' + element :sign_in_button, 'submit "Sign in"' + end + def initialize - wait('.application', time: 500) + wait(max: 500) do + page.has_css?('.application') + end end def sign_in_using_credentials - if page.has_content?('Change your password') + using_wait_time 0 do + if page.has_content?('Change your password') + fill_in :user_password, with: Runtime::User.password + fill_in :user_password_confirmation, with: Runtime::User.password + click_button 'Change your password' + end + + fill_in :user_login, with: Runtime::User.name fill_in :user_password, with: Runtime::User.password - fill_in :user_password_confirmation, with: Runtime::User.password - click_button 'Change your password' + click_button 'Sign in' end - - fill_in :user_login, with: Runtime::User.name - fill_in :user_password, with: Runtime::User.password - click_button 'Sign in' end def self.path diff --git a/qa/qa/page/main/oauth.rb b/qa/qa/page/main/oauth.rb index e746cff0a80..6f548148363 100644 --- a/qa/qa/page/main/oauth.rb +++ b/qa/qa/page/main/oauth.rb @@ -2,6 +2,10 @@ module QA module Page module Main class OAuth < Page::Base + view 'app/views/doorkeeper/authorizations/new.html.haml' do + element :authorization_button, 'submit_tag "Authorize"' + end + def needs_authorization? page.current_url.include?('/oauth') end diff --git a/qa/qa/page/mattermost/login.rb b/qa/qa/page/mattermost/login.rb index 8ffd4fdad13..9b21300ea3c 100644 --- a/qa/qa/page/mattermost/login.rb +++ b/qa/qa/page/mattermost/login.rb @@ -2,6 +2,13 @@ module QA module Page module Mattermost class Login < Page::Base + ## + # TODO, define all selectors required by this page object + # + # See gitlab-org/gitlab-qa#154 + # + view 'app/views/projects/mattermosts/new.html.haml' + def sign_in_using_oauth click_link class: 'btn btn-custom-login gitlab' diff --git a/qa/qa/page/mattermost/main.rb b/qa/qa/page/mattermost/main.rb index 4b8fc28e53f..bc2f9acc729 100644 --- a/qa/qa/page/mattermost/main.rb +++ b/qa/qa/page/mattermost/main.rb @@ -2,6 +2,13 @@ module QA module Page module Mattermost class Main < Page::Base + ## + # TODO, define all selectors required by this page object + # + # See gitlab-org/gitlab-qa#154 + # + view 'app/views/projects/mattermosts/new.html.haml' + def initialize visit(Runtime::Scenario.mattermost_address) end diff --git a/qa/qa/page/menu/admin.rb b/qa/qa/page/menu/admin.rb index 07fe40fda3a..573b98f7386 100644 --- a/qa/qa/page/menu/admin.rb +++ b/qa/qa/page/menu/admin.rb @@ -2,8 +2,8 @@ module QA module Page module Menu class Admin < Page::Base - def go_to_license - click_link 'License' + view 'app/views/layouts/nav/sidebar/_admin.html.haml' do + element :settings, "_('Settings')" end def go_to_settings diff --git a/qa/qa/page/menu/main.rb b/qa/qa/page/menu/main.rb index b94c2c6c23d..df93a5fa2d2 100644 --- a/qa/qa/page/menu/main.rb +++ b/qa/qa/page/menu/main.rb @@ -2,42 +2,70 @@ module QA module Page module Menu class Main < Page::Base + view 'app/views/layouts/header/_default.html.haml' do + element :navbar + element :user_avatar + element :user_menu, '.dropdown-menu-nav' + element :user_sign_out_link, 'link_to "Sign out"' + element :settings_link, 'link_to "Settings"' + end + + view 'app/views/layouts/nav/_dashboard.html.haml' do + element :admin_area_link + element :projects_dropdown + element :groups_link + end + + view 'app/views/layouts/nav/projects_dropdown/_show.html.haml' do + element :projects_dropdown_sidebar + element :your_projects_link + end + def go_to_groups - within_top_menu { click_link 'Groups' } + within_top_menu { click_element :groups_link } end def go_to_projects within_top_menu do - click_link 'Projects' - click_link 'Your projects' + click_element :projects_dropdown + end + + page.within('.qa-projects-dropdown-sidebar') do + click_element :your_projects_link end end def go_to_admin_area - within_top_menu { find('.admin-icon').click } + within_top_menu { click_element :admin_area_link } end def sign_out within_user_menu do - click_link('Sign out') + click_link 'Sign out' + end + end + + def go_to_profile_settings + within_user_menu do + click_link 'Settings' end end def has_personal_area? - page.has_selector?('.header-user-dropdown-toggle') + page.has_selector?('.qa-user-avatar') end private def within_top_menu - page.within('.navbar') do + page.within('.qa-navbar') do yield end end def within_user_menu within_top_menu do - find('.header-user-dropdown-toggle').click + click_element :user_avatar page.within('.dropdown-menu-nav') do yield diff --git a/qa/qa/page/menu/profile.rb b/qa/qa/page/menu/profile.rb new file mode 100644 index 00000000000..95e88d863e4 --- /dev/null +++ b/qa/qa/page/menu/profile.rb @@ -0,0 +1,27 @@ +module QA + module Page + module Menu + class Profile < Page::Base + view 'app/views/layouts/nav/sidebar/_profile.html.haml' do + element :access_token_link, 'link_to profile_personal_access_tokens_path' + element :access_token_title, 'Access Tokens' + element :top_level_items, '.sidebar-top-level-items' + end + + def click_access_tokens + within_sidebar do + click_link('Access Tokens') + end + end + + private + + def within_sidebar + page.within('.sidebar-top-level-items') do + yield + end + end + end + end + end +end diff --git a/qa/qa/page/menu/side.rb b/qa/qa/page/menu/side.rb index 6c25aba4bac..7e028add2ef 100644 --- a/qa/qa/page/menu/side.rb +++ b/qa/qa/page/menu/side.rb @@ -2,17 +2,60 @@ module QA module Page module Menu class Side < Page::Base - def click_repository_setting - hover_setting do - click_link('Repository') + view 'app/views/layouts/nav/sidebar/_project.html.haml' do + element :settings_item + element :settings_link, 'link_to edit_project_path' + element :repository_link, "title: 'Repository'" + element :pipelines_settings_link, "title: 'CI / CD'" + element :issues_link, /link_to.*shortcuts-issues/ + element :issues_link_text, "Issues" + element :top_level_items, '.sidebar-top-level-items' + element :activity_link, "title: 'Activity'" + end + + view 'app/assets/javascripts/fly_out_nav.js' do + element :fly_out, "classList.add('fly-out-list')" + end + + def click_repository_settings + hover_settings do + within_submenu do + click_link('Repository') + end + end + end + + def click_ci_cd_settings + hover_settings do + within_submenu do + click_link('CI / CD') + end + end + end + + def click_ci_cd_pipelines + within_sidebar do + click_link('CI / CD') + end + end + + def go_to_settings + within_sidebar do + click_on 'Settings' + end + end + + def click_issues + within_sidebar do + click_link('Issues') end end private - def hover_setting + def hover_settings within_sidebar do - find('.nav-item-name', text: 'Settings').hover + find('.qa-settings-item').hover yield end @@ -23,6 +66,18 @@ module QA yield end end + + def go_to_activity + within_sidebar do + click_on 'Activity' + end + end + + def within_submenu + page.within('.fly-out-list') do + yield + end + end end end end diff --git a/qa/qa/page/merge_request/new.rb b/qa/qa/page/merge_request/new.rb new file mode 100644 index 00000000000..ec94ff4ac98 --- /dev/null +++ b/qa/qa/page/merge_request/new.rb @@ -0,0 +1,31 @@ +module QA + module Page + module MergeRequest + class New < Page::Base + view 'app/views/shared/issuable/_form.html.haml' do + element :issuable_create_button + end + + view 'app/views/shared/issuable/form/_title.html.haml' do + element :issuable_form_title + end + + view 'app/views/shared/form_elements/_description.html.haml' do + element :issuable_form_description + end + + def create_merge_request + click_element :issuable_create_button + end + + def fill_title(title) + fill_element :issuable_form_title, title + end + + def fill_description(description) + fill_element :issuable_form_description, description + end + end + end + end +end diff --git a/qa/qa/page/profile/personal_access_tokens.rb b/qa/qa/page/profile/personal_access_tokens.rb new file mode 100644 index 00000000000..f5ae47dadd0 --- /dev/null +++ b/qa/qa/page/profile/personal_access_tokens.rb @@ -0,0 +1,33 @@ +module QA + module Page + module Profile + class PersonalAccessTokens < Page::Base + view 'app/views/shared/_personal_access_tokens_form.html.haml' do + element :personal_access_token_name_field, 'text_field :name' + element :create_token_button, 'submit "Create #{type} token"' # rubocop:disable Lint/InterpolationCheck + element :scopes_api_radios, "label :scopes" + end + + view 'app/views/profiles/personal_access_tokens/index.html.haml' do + element :create_token_field, "text_field_tag 'created-personal-access-token'" + end + + def fill_token_name(name) + fill_in 'personal_access_token_name', with: name + end + + def check_api + check 'personal_access_token_scopes_api' + end + + def create_token + click_on 'Create personal access token' + end + + def created_access_token + page.find('#created-personal-access-token').value + end + end + end + end +end diff --git a/qa/qa/page/project/activity.rb b/qa/qa/page/project/activity.rb new file mode 100644 index 00000000000..0196922c889 --- /dev/null +++ b/qa/qa/page/project/activity.rb @@ -0,0 +1,15 @@ +module QA + module Page + module Project + class Activity < Page::Base + view 'app/views/shared/_event_filter.html.haml' do + element :push_events, "event_filter_link EventFilter.push, _('Push events')" + end + + def go_to_push_events + click_on 'Push events' + end + end + end + end +end diff --git a/qa/qa/page/project/issue/index.rb b/qa/qa/page/project/issue/index.rb new file mode 100644 index 00000000000..b5903f536a4 --- /dev/null +++ b/qa/qa/page/project/issue/index.rb @@ -0,0 +1,17 @@ +module QA + module Page + module Project + module Issue + class Index < Page::Base + view 'app/views/projects/issues/_issue.html.haml' do + element :issue_link, 'link_to issue.title' + end + + def go_to_issue(title) + click_link(title) + end + end + end + end + end +end diff --git a/qa/qa/page/project/issue/new.rb b/qa/qa/page/project/issue/new.rb new file mode 100644 index 00000000000..7fc581da1ed --- /dev/null +++ b/qa/qa/page/project/issue/new.rb @@ -0,0 +1,33 @@ +module QA + module Page + module Project + module Issue + class New < Page::Base + view 'app/views/shared/issuable/_form.html.haml' do + element :submit_issue_button, 'form.submit "Submit' + end + + view 'app/views/shared/issuable/form/_title.html.haml' do + element :issue_title_textbox, 'form.text_field :title' + end + + view 'app/views/shared/form_elements/_description.html.haml' do + element :issue_description_textarea, "render 'projects/zen', f: form, attr: :description" + end + + def add_title(title) + fill_in 'issue_title', with: title + end + + def add_description(description) + fill_in 'issue_description', with: description + end + + def create_new_issue + click_on 'Submit issue' + end + end + end + end + end +end diff --git a/qa/qa/page/project/issue/show.rb b/qa/qa/page/project/issue/show.rb new file mode 100644 index 00000000000..5bc0598a524 --- /dev/null +++ b/qa/qa/page/project/issue/show.rb @@ -0,0 +1,40 @@ +module QA + module Page + module Project + module Issue + class Show < Page::Base + view 'app/views/projects/issues/show.html.haml' do + element :issue_details, '.issue-details' + element :title, '.title' + end + + view 'app/views/shared/notes/_form.html.haml' do + element :new_note_form, 'new-note' + element :new_note_form, 'attr: :note' + end + + view 'app/views/shared/notes/_comment_button.html.haml' do + element :comment_button, '%strong Comment' + end + + def issue_title + find('.issue-details .title').text + end + + # Adds a comment to an issue + # attachment option should be an absolute path + def comment(text, attachment: nil) + fill_in(with: text, name: 'note[note]') + + unless attachment.nil? + QA::Page::Component::Dropzone.new(self, '.new-note') + .attach_file(attachment) + end + + click_on 'Comment' + end + end + end + end + end +end diff --git a/qa/qa/page/project/new.rb b/qa/qa/page/project/new.rb index b31bec27b59..186a4724326 100644 --- a/qa/qa/page/project/new.rb +++ b/qa/qa/page/project/new.rb @@ -2,9 +2,18 @@ module QA module Page module Project class New < Page::Base + view 'app/views/projects/_new_project_fields.html.haml' do + element :project_namespace_select + element :project_namespace_field, /select :namespace_id.*class: 'select2/ + element :project_path, 'text_field :path' + element :project_description, 'text_area :description' + element :project_create_button, "submit 'Create project'" + end + def choose_test_namespace - find('#s2id_project_namespace_id').click - find('.select2-result-label', text: Runtime::Namespace.name).click + click_element :project_namespace_select + + find('ul.select2-result-sub > li', text: Runtime::Namespace.path).click end def choose_name(name) diff --git a/qa/qa/page/project/pipeline/index.rb b/qa/qa/page/project/pipeline/index.rb new file mode 100644 index 00000000000..32c108393b9 --- /dev/null +++ b/qa/qa/page/project/pipeline/index.rb @@ -0,0 +1,13 @@ +module QA::Page + module Project::Pipeline + class Index < QA::Page::Base + view 'app/assets/javascripts/pipelines/components/pipeline_url.vue' do + element :pipeline_link, 'class="js-pipeline-url-link"' + end + + def go_to_latest_pipeline + first('.js-pipeline-url-link').click + end + end + end +end diff --git a/qa/qa/page/project/pipeline/show.rb b/qa/qa/page/project/pipeline/show.rb new file mode 100644 index 00000000000..0835173f1cd --- /dev/null +++ b/qa/qa/page/project/pipeline/show.rb @@ -0,0 +1,35 @@ +module QA::Page + module Project::Pipeline + class Show < QA::Page::Base + view 'app/assets/javascripts/vue_shared/components/header_ci_component.vue' do + element :pipeline_header, /header class.*ci-header-container.*/ + end + + view 'app/assets/javascripts/pipelines/components/graph/graph_component.vue' do + element :pipeline_graph, /class.*pipeline-graph.*/ + end + + view 'app/assets/javascripts/pipelines/components/graph/job_component.vue' do + element :job_component, /class.*ci-job-component.*/ + end + + view 'app/assets/javascripts/vue_shared/components/ci_icon.vue' do + element :status_icon, 'ci-status-icon-${status}' + end + + def running? + within('.ci-header-container') do + return page.has_content?('running') + end + end + + def has_build?(name, status: :success) + within('.pipeline-graph') do + within('.ci-job-component', text: name) do + return has_selector?(".ci-status-icon-#{status}") + end + end + end + end + end +end diff --git a/qa/qa/page/project/settings/advanced.rb b/qa/qa/page/project/settings/advanced.rb new file mode 100644 index 00000000000..5ef00504fdf --- /dev/null +++ b/qa/qa/page/project/settings/advanced.rb @@ -0,0 +1,33 @@ +module QA + module Page + module Project + module Settings + class Advanced < Page::Base + view 'app/views/projects/edit.html.haml' do + element :project_path_field, 'f.text_field :path' + element :project_name_field, 'f.text_field :name' + element :rename_project_button, "f.submit 'Rename project'" + end + + def rename_to(path) + fill_project_name(path) + fill_project_path(path) + rename_project! + end + + def fill_project_path(path) + fill_in :project_path, with: path + end + + def fill_project_name(name) + fill_in :project_name, with: name + end + + def rename_project! + click_on 'Rename project' + end + end + end + end + end +end diff --git a/qa/qa/page/project/settings/ci_cd.rb b/qa/qa/page/project/settings/ci_cd.rb new file mode 100644 index 00000000000..99be21bbe89 --- /dev/null +++ b/qa/qa/page/project/settings/ci_cd.rb @@ -0,0 +1,28 @@ +module QA + module Page + module Project + module Settings + class CICD < Page::Base + include Common + + view 'app/views/projects/settings/ci_cd/show.html.haml' do + element :runners_settings, 'Runners settings' + element :secret_variables, 'Secret variables' + end + + def expand_runners_settings(&block) + expand_section('Runners settings') do + Settings::Runners.perform(&block) + end + end + + def expand_secret_variables(&block) + expand_section('Secret variables') do + Settings::SecretVariables.perform(&block) + end + end + end + end + end + end +end diff --git a/qa/qa/page/project/settings/common.rb b/qa/qa/page/project/settings/common.rb index 5d1d5120929..319cb1045b6 100644 --- a/qa/qa/page/project/settings/common.rb +++ b/qa/qa/page/project/settings/common.rb @@ -3,11 +3,29 @@ module QA module Project module Settings module Common - def expand(selector) + def self.included(base) + base.class_eval do + view 'app/views/projects/edit.html.haml' do + element :advanced_settings_expand, "= expanded ? 'Collapse' : 'Expand'" + end + end + end + + # Click the Expand button present in the specified section + # + # @param [String] name present in the container in the DOM + def expand_section(name) page.within('#content-body') do - find(selector).click + page.within('section', text: name) do + # Because it is possible to click the button before the JS toggle code is bound + wait(reload: false) do + click_button 'Expand' unless first('button', text: 'Collapse') + + page.has_content?('Collapse') + end - yield + yield if block_given? + end end end end diff --git a/qa/qa/page/project/settings/deploy_keys.rb b/qa/qa/page/project/settings/deploy_keys.rb index 4028b8cccc5..332e84724c7 100644 --- a/qa/qa/page/project/settings/deploy_keys.rb +++ b/qa/qa/page/project/settings/deploy_keys.rb @@ -3,6 +3,21 @@ module QA module Project module Settings class DeployKeys < Page::Base + view 'app/views/projects/deploy_keys/_form.html.haml' do + element :deploy_key_title, 'text_field :title' + element :deploy_key_key, 'text_area :key' + end + + view 'app/assets/javascripts/deploy_keys/components/app.vue' do + element :deploy_keys_section, /class=".*deploy\-keys.*"/ + element :project_deploy_keys, 'class="qa-project-deploy-keys"' + end + + view 'app/assets/javascripts/deploy_keys/components/key.vue' do + element :key_title, /class=".*qa-key-title.*"/ + element :key_fingerprint, /class=".*qa-key-fingerprint.*"/ + end + def fill_key_title(title) fill_in 'deploy_key_title', with: title end @@ -15,9 +30,23 @@ module QA click_on 'Add key' end - def has_key_title?(title) - page.within('.deploy-keys') do - page.find('.title', text: title) + def key_title + within_project_deploy_keys do + find_element(:key_title).text + end + end + + def key_fingerprint + within_project_deploy_keys do + find_element(:key_fingerprint).text + end + end + + private + + def within_project_deploy_keys + within_element(:project_deploy_keys) do + yield end end end diff --git a/qa/qa/page/project/settings/main.rb b/qa/qa/page/project/settings/main.rb new file mode 100644 index 00000000000..5d743f4c9c8 --- /dev/null +++ b/qa/qa/page/project/settings/main.rb @@ -0,0 +1,21 @@ +module QA + module Page + module Project + module Settings + class Main < Page::Base + include Common + + view 'app/views/projects/edit.html.haml' do + element :advanced_settings_section, 'Advanced settings' + end + + def expand_advanced_settings(&block) + expand_section('Advanced settings') do + Advanced.perform(&block) + end + end + end + end + end + end +end diff --git a/qa/qa/page/project/settings/repository.rb b/qa/qa/page/project/settings/repository.rb index 034b0d09c1c..22362164a1a 100644 --- a/qa/qa/page/project/settings/repository.rb +++ b/qa/qa/page/project/settings/repository.rb @@ -5,8 +5,12 @@ module QA class Repository < Page::Base include Common + view 'app/views/projects/deploy_keys/_index.html.haml' do + element :deploy_keys_section, 'Deploy Keys' + end + def expand_deploy_keys(&block) - expand('.qa-expand-deploy-keys') do + expand_section('Deploy Keys') do DeployKeys.perform(&block) end end diff --git a/qa/qa/page/project/settings/runners.rb b/qa/qa/page/project/settings/runners.rb new file mode 100644 index 00000000000..b41668c94cd --- /dev/null +++ b/qa/qa/page/project/settings/runners.rb @@ -0,0 +1,35 @@ +module QA + module Page + module Project + module Settings + class Runners < Page::Base + view 'app/views/ci/runner/_how_to_setup_runner.html.haml' do + element :registration_token, '%code#registration_token' + element :coordinator_address, '%code#coordinator_address' + end + + ## + # TODO, phase-out CSS classes added in Ruby helpers. + # + view 'app/helpers/runners_helper.rb' do + # rubocop:disable Lint/InterpolationCheck + element :runner_status, 'runner-status-#{status}' + # rubocop:enable Lint/InterpolationCheck + end + + def registration_token + find('code#registration_token').text + end + + def coordinator_address + find('code#coordinator_address').text + end + + def has_online_runner? + page.has_css?('.runner-status-online') + end + end + end + end + end +end diff --git a/qa/qa/page/project/settings/secret_variables.rb b/qa/qa/page/project/settings/secret_variables.rb new file mode 100644 index 00000000000..e3bfbfcf080 --- /dev/null +++ b/qa/qa/page/project/settings/secret_variables.rb @@ -0,0 +1,57 @@ +module QA + module Page + module Project + module Settings + class SecretVariables < Page::Base + include Common + + view 'app/views/ci/variables/_table.html.haml' do + element :variable_key, '.variable-key' + element :variable_value, '.variable-value' + end + + view 'app/views/ci/variables/_index.html.haml' do + element :add_new_variable, 'btn_text: "Add new variable"' + end + + view 'app/assets/javascripts/behaviors/secret_values.js' do + element :reveal_value, 'Reveal value' + element :hide_value, 'Hide value' + end + + def fill_variable_key(key) + fill_in 'variable_key', with: key + end + + def fill_variable_value(value) + fill_in 'variable_value', with: value + end + + def add_variable + click_on 'Add new variable' + end + + def variable_key + page.find('.variable-key').text + end + + def variable_value + reveal_value do + page.find('.variable-value').text + end + end + + private + + def reveal_value + click_button('Reveal value') + + yield.tap do + click_button('Hide value') + end + end + end + end + end + end +end diff --git a/qa/qa/page/project/show.rb b/qa/qa/page/project/show.rb index 3b2bac84f3f..553d35f9579 100644 --- a/qa/qa/page/project/show.rb +++ b/qa/qa/page/project/show.rb @@ -2,11 +2,36 @@ module QA module Page module Project class Show < Page::Base + view 'app/views/shared/_clone_panel.html.haml' do + element :clone_holder, '.git-clone-holder' + element :clone_dropdown + element :clone_options_dropdown, '.clone-options-dropdown' + element :project_repository_location, 'text_field_tag :project_clone' + end + + view 'app/views/projects/_last_push.html.haml' do + element :create_merge_request + end + + view 'app/views/projects/_home_panel.html.haml' do + element :project_name + end + + view 'app/views/layouts/header/_new_dropdown.haml' do + element :new_menu_toggle + element :new_issue_link, "link_to 'New issue', new_project_issue_path(@project)" + end + def choose_repository_clone_http - find('#clone-dropdown').click + wait(reload: false) do + click_element :clone_dropdown + + page.within('.clone-options-dropdown') do + click_link('HTTP') + end - page.within('.clone-options-dropdown') do - click_link('HTTP') + # Ensure git clone textbox was updated to http URI + page.has_css?('.git-clone-holder input#project_clone[value*="http"]') end end @@ -15,11 +40,22 @@ module QA end def project_name - find('.project-title').text + find('.qa-project-name').text + end + + def new_merge_request + click_element :create_merge_request end def wait_for_push sleep 5 + refresh + end + + def go_to_new_issue + click_element :new_menu_toggle + + click_link 'New issue' end end end diff --git a/qa/qa/page/validator.rb b/qa/qa/page/validator.rb new file mode 100644 index 00000000000..117d8d4db67 --- /dev/null +++ b/qa/qa/page/validator.rb @@ -0,0 +1,52 @@ +module QA + module Page + class Validator + ValidationError = Class.new(StandardError) + + Error = Struct.new(:page, :message) do + def to_s + "Error: #{page} - #{message}" + end + end + + def initialize(constant) + @module = constant + end + + def constants + @consts ||= @module.constants.map do |const| + @module.const_get(const) + end + end + + def descendants + @descendants ||= constants.map do |const| + case const + when Class + const if const < Page::Base + when Module + Page::Validator.new(const).descendants + end + end + + @descendants.flatten.compact + end + + def errors + [].tap do |errors| + descendants.each do |page| + page.errors.each do |message| + errors.push(Error.new(page.name, message)) + end + end + end + end + + def validate! + return if errors.none? + + raise ValidationError, 'Page views / elements validation error!' + end + end + end +end diff --git a/qa/qa/page/view.rb b/qa/qa/page/view.rb new file mode 100644 index 00000000000..6635e1ce039 --- /dev/null +++ b/qa/qa/page/view.rb @@ -0,0 +1,55 @@ +module QA + module Page + class View + attr_reader :path, :elements + + def initialize(path, elements) + @path = path + @elements = elements + end + + def pathname + @pathname ||= Pathname.new(File.join(__dir__, '../../../', @path)) + .cleanpath.expand_path + end + + def errors + unless pathname.readable? + return ["Missing view partial `#{pathname}`!"] + end + + ## + # Reduce required elements by streaming view and making assertions on + # elements' existence. + # + @missing ||= @elements.dup.tap do |elements| + File.foreach(pathname.to_s) do |line| + elements.reject! { |element| element.matches?(line) } + end + end + + @missing.map do |missing| + "Missing element `#{missing.name}` in `#{pathname}` view partial!" + end + end + + def self.evaluate(&block) + Page::View::DSL.new.tap do |evaluator| + evaluator.instance_exec(&block) if block_given? + end + end + + class DSL + attr_reader :elements + + def initialize + @elements = [] + end + + def element(name, pattern = nil) + @elements.push(Page::Element.new(name, pattern)) + end + end + end + end +end diff --git a/qa/qa/runtime/address.rb b/qa/qa/runtime/address.rb new file mode 100644 index 00000000000..ffad3974b02 --- /dev/null +++ b/qa/qa/runtime/address.rb @@ -0,0 +1,20 @@ +module QA + module Runtime + class Address + attr_reader :address + + def initialize(instance, page = nil) + @instance = instance + @address = host + (page.is_a?(String) ? page : page&.path) + end + + def host + if @instance.is_a?(Symbol) + Runtime::Scenario.send("#{@instance}_address") + else + @instance.to_s + end + end + end + end +end diff --git a/qa/qa/runtime/api.rb b/qa/qa/runtime/api.rb new file mode 100644 index 00000000000..e2a096b971d --- /dev/null +++ b/qa/qa/runtime/api.rb @@ -0,0 +1,82 @@ +require 'airborne' + +module QA + module Runtime + module API + class Client + attr_reader :address + + def initialize(address = :gitlab) + @address = address + end + + def personal_access_token + @personal_access_token ||= get_personal_access_token + end + + def get_personal_access_token + # you can set the environment variable PERSONAL_ACCESS_TOKEN + # to use a specific access token rather than create one from the UI + if Runtime::Env.personal_access_token + Runtime::Env.personal_access_token + else + create_personal_access_token + end + end + + private + + def create_personal_access_token + Runtime::Browser.visit(@address, Page::Main::Login) do + Page::Main::Login.act { sign_in_using_credentials } + Factory::Resource::PersonalAccessToken.fabricate!.access_token + end + end + end + + class Request + API_VERSION = 'v4'.freeze + + def initialize(api_client, path, personal_access_token: nil) + personal_access_token ||= api_client.personal_access_token + request_path = request_path(path, personal_access_token: personal_access_token) + @session_address = Runtime::Address.new(api_client.address, request_path) + end + + def url + @session_address.address + end + + # Prepend a request path with the path to the API + # + # path - Path to append + # + # Examples + # + # >> request_path('/issues') + # => "/api/v4/issues" + # + # >> request_path('/issues', personal_access_token: 'sometoken) + # => "/api/v4/issues?private_token=..." + # + # Returns the relative path to the requested API resource + def request_path(path, version: API_VERSION, personal_access_token: nil, oauth_access_token: nil) + full_path = File.join('/api', version, path) + + if oauth_access_token + query_string = "access_token=#{oauth_access_token}" + elsif personal_access_token + query_string = "private_token=#{personal_access_token}" + end + + if query_string + full_path << (path.include?('?') ? '&' : '?') + full_path << query_string + end + + full_path + end + end + end + end +end diff --git a/qa/qa/runtime/browser.rb b/qa/qa/runtime/browser.rb index 14b2a488760..a12d95683af 100644 --- a/qa/qa/runtime/browser.rb +++ b/qa/qa/runtime/browser.rb @@ -23,13 +23,11 @@ module QA # In case of an address that is a symbol we will try to guess address # based on `Runtime::Scenario#something_address`. # - def visit(address, page, &block) - Browser::Session.new(address, page).tap do |session| - session.perform(&block) - end + def visit(address, page = nil, &block) + Browser::Session.new(address, page).perform(&block) end - def self.visit(address, page, &block) + def self.visit(address, page = nil, &block) new.visit(address, page, &block) end @@ -86,7 +84,7 @@ module QA config.javascript_driver = :chrome config.default_max_wait_time = 10 # https://github.com/mattheworiordan/capybara-screenshot/issues/164 - config.save_path = 'tmp' + config.save_path = File.expand_path('../../tmp', __dir__) end end @@ -94,20 +92,15 @@ module QA include Capybara::DSL def initialize(instance, page = nil) - @instance = instance - @address = host + page&.path + @session_address = Runtime::Address.new(instance, page) end - def host - if @instance.is_a?(Symbol) - Runtime::Scenario.send("#{@instance}_address") - else - @instance.to_s - end + def url + @session_address.address end def perform(&block) - visit(@address) + visit(url) yield if block_given? rescue @@ -130,7 +123,7 @@ module QA # See gitlab-org/gitlab-qa#102 # def clear! - visit(@address) + visit(url) reset_session! end end diff --git a/qa/qa/runtime/env.rb b/qa/qa/runtime/env.rb index d5c28e9a7db..56944e8b641 100644 --- a/qa/qa/runtime/env.rb +++ b/qa/qa/runtime/env.rb @@ -3,6 +3,7 @@ module QA module Env extend self + # set to 'false' to have Chrome run visibly instead of headless def chrome_headless? (ENV['CHROME_HEADLESS'] =~ /^(false|no|0)$/i) != 0 end @@ -10,6 +11,11 @@ module QA def running_in_ci? ENV['CI'] || ENV['CI_SERVER'] end + + # specifies token that can be used for the api + def personal_access_token + ENV['PERSONAL_ACCESS_TOKEN'] + end end end end diff --git a/qa/qa/runtime/namespace.rb b/qa/qa/runtime/namespace.rb index b00e925986b..a72c2d21898 100644 --- a/qa/qa/runtime/namespace.rb +++ b/qa/qa/runtime/namespace.rb @@ -11,6 +11,10 @@ module QA 'qa-test-' + time.strftime('%d-%m-%Y-%H-%M-%S') end + def path + "#{sandbox_name}/#{name}" + end + def sandbox_name 'gitlab-qa-sandbox' end diff --git a/qa/qa/runtime/rsa_key.rb b/qa/qa/runtime/rsa_key.rb new file mode 100644 index 00000000000..d456062bce7 --- /dev/null +++ b/qa/qa/runtime/rsa_key.rb @@ -0,0 +1,21 @@ +require 'net/ssh' +require 'forwardable' + +module QA + module Runtime + class RSAKey + extend Forwardable + + attr_reader :key + def_delegators :@key, :fingerprint + + def initialize(bits = 4096) + @key = OpenSSL::PKey::RSA.new(bits) + end + + def public_key + @public_key ||= "#{key.ssh_type} #{[key.to_blob].pack('m0')}" + end + end + end +end diff --git a/qa/qa/runtime/user.rb b/qa/qa/runtime/user.rb index 2832439d9e0..60027c89ab1 100644 --- a/qa/qa/runtime/user.rb +++ b/qa/qa/runtime/user.rb @@ -10,17 +10,6 @@ module QA def password ENV['GITLAB_PASSWORD'] || '5iveL!fe' end - - def ssh_key - <<~KEY.delete("\n") - ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDFf6RYK3qu/RKF/3ndJmL5xgMLp3O9 - 6x8lTay+QGZ0+9FnnAXMdUqBq/ZU6d/gyMB4IaW3nHzM1w049++yAB6UPCzMB8Uo27K5 - /jyZCtj7Vm9PFNjF/8am1kp46c/SeYicQgQaSBdzIW3UDEa1Ef68qroOlvpi9PYZ/tA7 - M0YP0K5PXX+E36zaIRnJVMPT3f2k+GnrxtjafZrwFdpOP/Fol5BQLBgcsyiU+LM1SuaC - rzd8c9vyaTA1CxrkxaZh+buAi0PmdDtaDrHd42gqZkXCKavyvgM5o2CkQ5LJHCgzpXy0 - 5qNFzmThBSkb+XtoxbyagBiGbVZtSVow6Xa7qewz= dummy@gitlab.com - KEY - end end end end diff --git a/qa/qa/scenario/entrypoint.rb b/qa/qa/scenario/entrypoint.rb deleted file mode 100644 index ae099fd911e..00000000000 --- a/qa/qa/scenario/entrypoint.rb +++ /dev/null @@ -1,34 +0,0 @@ -module QA - module Scenario - ## - # Base class for running the suite against any GitLab instance, - # including staging and on-premises installation. - # - class Entrypoint < Template - include Bootable - - def perform(address, *files) - Runtime::Scenario.define(:gitlab_address, address) - - ## - # Perform before hooks, which are different for CE and EE - # - Runtime::Release.perform_before_hooks - - Specs::Runner.perform do |specs| - specs.tty = true - specs.tags = self.class.get_tags - specs.files = files.any? ? files : 'qa/specs/features' - end - end - - def self.tags(*tags) - @tags = tags - end - - def self.get_tags - @tags - end - end - end -end diff --git a/qa/qa/scenario/taggable.rb b/qa/qa/scenario/taggable.rb new file mode 100644 index 00000000000..b1f24d742e0 --- /dev/null +++ b/qa/qa/scenario/taggable.rb @@ -0,0 +1,17 @@ +module QA + module Scenario + module Taggable + # rubocop:disable Gitlab/ModuleWithInstanceVariables + + def tags(*tags) + @tags = tags + end + + def focus + @tags.to_a + end + + # rubocop:enable Gitlab/ModuleWithInstanceVariables + end + end +end diff --git a/qa/qa/scenario/test/instance.rb b/qa/qa/scenario/test/instance.rb index e2a1f6bf2bd..993bbd723a3 100644 --- a/qa/qa/scenario/test/instance.rb +++ b/qa/qa/scenario/test/instance.rb @@ -2,11 +2,29 @@ module QA module Scenario module Test ## - # Run test suite against any GitLab instance, + # Base class for running the suite against any GitLab instance, # including staging and on-premises installation. # - class Instance < Entrypoint + class Instance < Template + include Bootable + extend Taggable + tags :core + + def perform(address, *files) + Runtime::Scenario.define(:gitlab_address, address) + + ## + # Perform before hooks, which are different for CE and EE + # + Runtime::Release.perform_before_hooks + + Specs::Runner.perform do |specs| + specs.tty = true + specs.tags = self.class.focus + specs.files = files.any? ? files : 'qa/specs/features' + end + end end end end diff --git a/qa/qa/scenario/test/integration/mattermost.rb b/qa/qa/scenario/test/integration/mattermost.rb index 7d0702afdb1..d939f52ab16 100644 --- a/qa/qa/scenario/test/integration/mattermost.rb +++ b/qa/qa/scenario/test/integration/mattermost.rb @@ -6,7 +6,7 @@ module QA # Run test suite against any GitLab instance where mattermost is enabled, # including staging and on-premises installation. # - class Mattermost < Scenario::Entrypoint + class Mattermost < Test::Instance tags :core, :mattermost def perform(address, mattermost, *files) diff --git a/qa/qa/scenario/test/sanity/selectors.rb b/qa/qa/scenario/test/sanity/selectors.rb new file mode 100644 index 00000000000..c87eb5f3dfb --- /dev/null +++ b/qa/qa/scenario/test/sanity/selectors.rb @@ -0,0 +1,54 @@ +module QA + module Scenario + module Test + module Sanity + class Selectors < Scenario::Template + include Scenario::Bootable + + PAGES = [QA::Page].freeze + + def perform(*) + validators = PAGES.map do |pages| + Page::Validator.new(pages) + end + + validators.map(&:errors).flatten.tap do |errors| + break if errors.none? + + warn <<~EOS + GitLab QA sanity selectors validation test detected problems + with your merge request! + + The purpose of this test is to make sure that GitLab QA tests, + that are entirely black-box, click-driven scenarios, do match + pages structure / layout in GitLab CE / EE repositories. + + It looks like you have changed views / pages / selectors, and + these are now out of sync with what we have defined in `qa/` + directory. + + Please update the code in `qa/` directory to make it match + current changes in this merge request. + + For more help see documentation in `qa/page/README.md` file or + ask for help on #qa channel on Slack (GitLab Team only). + + If you are not a Team Member, and you still need help to + contribute, please open an issue in GitLab QA issue tracker. + + Please see errors described below. + + EOS + + warn errors + end + + validators.each(&:validate!) + + puts 'Views / selectors validation passed!' + end + end + end + end + end +end diff --git a/qa/qa/service/omnibus.rb b/qa/qa/service/omnibus.rb new file mode 100644 index 00000000000..b5c06874e5c --- /dev/null +++ b/qa/qa/service/omnibus.rb @@ -0,0 +1,20 @@ +module QA + module Service + class Omnibus + include Scenario::Actable + include Service::Shellout + + def initialize(container) + @name = container + end + + def gitlab_ctl(command, input: nil) + if input.nil? + shell "docker exec #{@name} gitlab-ctl #{command}" + else + shell "docker exec #{@name} bash -c '#{input} | gitlab-ctl #{command}'" + end + end + end + end +end diff --git a/qa/qa/service/runner.rb b/qa/qa/service/runner.rb new file mode 100644 index 00000000000..d0ee33c69f2 --- /dev/null +++ b/qa/qa/service/runner.rb @@ -0,0 +1,41 @@ +require 'securerandom' + +module QA + module Service + class Runner + include Scenario::Actable + include Service::Shellout + + attr_accessor :token, :address, :tags, :image + + def initialize(name) + @image = 'gitlab/gitlab-runner:alpine' + @name = name || "qa-runner-#{SecureRandom.hex(4)}" + @network = Runtime::Scenario.attributes[:network] || 'test' + @tags = %w[qa test] + end + + def pull + shell "docker pull #{@image}" + end + + def register! + shell <<~CMD.tr("\n", ' ') + docker run -d --rm --entrypoint=/bin/sh + --network #{@network} --name #{@name} + -e CI_SERVER_URL=#{@address} + -e REGISTER_NON_INTERACTIVE=true + -e REGISTRATION_TOKEN=#{@token} + -e RUNNER_EXECUTOR=shell + -e RUNNER_TAG_LIST=#{@tags.join(',')} + -e RUNNER_NAME=#{@name} + #{@image} -c 'gitlab-runner register && gitlab-runner run' + CMD + end + + def remove! + shell "docker rm -f #{@name}" + end + end + end +end diff --git a/qa/qa/service/shellout.rb b/qa/qa/service/shellout.rb new file mode 100644 index 00000000000..898febde63c --- /dev/null +++ b/qa/qa/service/shellout.rb @@ -0,0 +1,23 @@ +require 'open3' + +module QA + module Service + module Shellout + ## + # TODO, make it possible to use generic QA framework classes + # as a library - gitlab-org/gitlab-qa#94 + # + def shell(command) + puts "Executing `#{command}`" + + Open3.popen2e(command) do |_in, out, wait| + out.each { |line| puts line } + + if wait.value.exited? && wait.value.exitstatus.nonzero? + raise "Command `#{command}` failed!" + end + end + end + end + end +end diff --git a/qa/qa/shell/omnibus.rb b/qa/qa/shell/omnibus.rb deleted file mode 100644 index 6b3628d3109..00000000000 --- a/qa/qa/shell/omnibus.rb +++ /dev/null @@ -1,39 +0,0 @@ -require 'open3' - -module QA - module Shell - class Omnibus - include Scenario::Actable - - def initialize(container) - @name = container - end - - def gitlab_ctl(command, input: nil) - if input.nil? - shell "docker exec #{@name} gitlab-ctl #{command}" - else - shell "docker exec #{@name} bash -c '#{input} | gitlab-ctl #{command}'" - end - end - - private - - ## - # TODO, make it possible to use generic QA framework classes - # as a library - gitlab-org/gitlab-qa#94 - # - def shell(command) - puts "Executing `#{command}`" - - Open3.popen2e(command) do |_in, out, wait| - out.each { |line| puts line } - - if wait.value.exited? && wait.value.exitstatus.nonzero? - raise "Docker command `#{command}` failed!" - end - end - end - end - end -end diff --git a/qa/qa/specs/features/api/users_spec.rb b/qa/qa/specs/features/api/users_spec.rb new file mode 100644 index 00000000000..9d039590a0e --- /dev/null +++ b/qa/qa/specs/features/api/users_spec.rb @@ -0,0 +1,42 @@ +module QA + feature 'API users', :core do + before(:context) do + @api_client = Runtime::API::Client.new(:gitlab) + end + + context 'when authenticated' do + let(:request) { Runtime::API::Request.new(@api_client, '/users') } + + scenario 'get list of users' do + get request.url + + expect_status(200) + end + + scenario 'submit request with a valid user name' do + get request.url, { params: { username: 'root' } } + + expect_status(200) + expect(json_body).to be_an Array + expect(json_body.size).to eq(1) + expect(json_body.first[:username]).to eq Runtime::User.name + end + + scenario 'submit request with an invalid user name' do + get request.url, { params: { username: 'invalid' } } + + expect_status(200) + expect(json_body).to be_an Array + expect(json_body.size).to eq(0) + end + end + + scenario 'submit request with an invalid token' do + request = Runtime::API::Request.new(@api_client, '/users', personal_access_token: 'invalid') + + get request.url + + expect_status(401) + end + end +end diff --git a/qa/qa/specs/features/merge_request/create_spec.rb b/qa/qa/specs/features/merge_request/create_spec.rb new file mode 100644 index 00000000000..fbf9a4d17e5 --- /dev/null +++ b/qa/qa/specs/features/merge_request/create_spec.rb @@ -0,0 +1,17 @@ +module QA + feature 'creates a merge request', :core do + scenario 'user creates a new merge request' do + Runtime::Browser.visit(:gitlab, Page::Main::Login) + Page::Main::Login.act { sign_in_using_credentials } + + Factory::Resource::MergeRequest.fabricate! do |merge_request| + merge_request.title = 'This is a merge request' + merge_request.description = 'Great feature' + end + + expect(page).to have_content('This is a merge request') + expect(page).to have_content('Great feature') + expect(page).to have_content('Opened less than a minute ago') + end + end +end diff --git a/qa/qa/specs/features/project/activity_spec.rb b/qa/qa/specs/features/project/activity_spec.rb new file mode 100644 index 00000000000..ba94ce8cf28 --- /dev/null +++ b/qa/qa/specs/features/project/activity_spec.rb @@ -0,0 +1,20 @@ +module QA + feature 'activity page', :core do + scenario 'push creates an event in the activity page' do + Runtime::Browser.visit(:gitlab, Page::Main::Login) + Page::Main::Login.act { sign_in_using_credentials } + + Factory::Repository::Push.fabricate! do |push| + push.file_name = 'README.md' + push.file_content = '# This is a test project' + push.commit_message = 'Add README.md' + end + + Page::Menu::Side.act { go_to_activity } + + Page::Project::Activity.act { go_to_push_events } + + expect(page).to have_content('pushed new branch master') + end + end +end diff --git a/qa/qa/specs/features/project/add_deploy_key_spec.rb b/qa/qa/specs/features/project/add_deploy_key_spec.rb index 43a85213501..b9998dda895 100644 --- a/qa/qa/specs/features/project/add_deploy_key_spec.rb +++ b/qa/qa/specs/features/project/add_deploy_key_spec.rb @@ -1,22 +1,20 @@ module QA feature 'deploy keys support', :core do - given(:deploy_key_title) { 'deploy key title' } - given(:deploy_key_value) { Runtime::User.ssh_key } - scenario 'user adds a deploy key' do Runtime::Browser.visit(:gitlab, Page::Main::Login) Page::Main::Login.act { sign_in_using_credentials } - Factory::Resource::DeployKey.fabricate! do |deploy_key| - deploy_key.title = deploy_key_title - deploy_key.key = deploy_key_value - end + key = Runtime::RSAKey.new + deploy_key_title = 'deploy key title' + deploy_key_value = key.public_key - Page::Project::Settings::Repository.perform do |setting| - setting.expand_deploy_keys do |page| - expect(page).to have_key_title(deploy_key_title) - end + deploy_key = Factory::Resource::DeployKey.fabricate! do |resource| + resource.title = deploy_key_title + resource.key = deploy_key_value end + + expect(deploy_key.title).to eq(deploy_key_title) + expect(deploy_key.fingerprint).to eq(key.fingerprint) end end end diff --git a/qa/qa/specs/features/project/add_secret_variable_spec.rb b/qa/qa/specs/features/project/add_secret_variable_spec.rb new file mode 100644 index 00000000000..36422a92afc --- /dev/null +++ b/qa/qa/specs/features/project/add_secret_variable_spec.rb @@ -0,0 +1,19 @@ +module QA + feature 'secret variables support', :core do + scenario 'user adds a secret variable' do + Runtime::Browser.visit(:gitlab, Page::Main::Login) + Page::Main::Login.act { sign_in_using_credentials } + + variable_key = 'VARIABLE_KEY' + variable_value = 'variable value' + + variable = Factory::Resource::SecretVariable.fabricate! do |resource| + resource.key = variable_key + resource.value = variable_value + end + + expect(variable.key).to eq(variable_key) + expect(variable.value).to eq(variable_value) + end + end +end diff --git a/qa/qa/specs/features/project/create_issue_spec.rb b/qa/qa/specs/features/project/create_issue_spec.rb new file mode 100644 index 00000000000..b73f108c2d9 --- /dev/null +++ b/qa/qa/specs/features/project/create_issue_spec.rb @@ -0,0 +1,18 @@ +module QA + feature 'creates issue', :core do + let(:issue_title) { 'issue title' } + + scenario 'user creates issue' do + Runtime::Browser.visit(:gitlab, Page::Main::Login) + Page::Main::Login.act { sign_in_using_credentials } + + Factory::Resource::Issue.fabricate! do |issue| + issue.title = issue_title + end + + Page::Menu::Side.act { click_issues } + + expect(page).to have_content(issue_title) + end + end +end diff --git a/qa/qa/specs/features/project/create_spec.rb b/qa/qa/specs/features/project/create_spec.rb index 61c19378ae0..b1c07249892 100644 --- a/qa/qa/specs/features/project/create_spec.rb +++ b/qa/qa/specs/features/project/create_spec.rb @@ -4,11 +4,13 @@ module QA Runtime::Browser.visit(:gitlab, Page::Main::Login) Page::Main::Login.act { sign_in_using_credentials } - Factory::Resource::Project.fabricate! do |project| + created_project = Factory::Resource::Project.fabricate! do |project| project.name = 'awesome-project' project.description = 'create awesome project test' end + expect(created_project.name).to match /^awesome-project-\h{16}$/ + expect(page).to have_content( /Project \S?awesome-project\S+ was successfully created/ ) diff --git a/qa/qa/specs/features/project/pipelines_spec.rb b/qa/qa/specs/features/project/pipelines_spec.rb new file mode 100644 index 00000000000..1bb7730e06c --- /dev/null +++ b/qa/qa/specs/features/project/pipelines_spec.rb @@ -0,0 +1,102 @@ +module QA + feature 'CI/CD Pipelines', :core, :docker do + let(:executor) { "qa-runner-#{Time.now.to_i}" } + + after do + Service::Runner.new(executor).remove! + end + + scenario 'user registers a new specific runner' do + Runtime::Browser.visit(:gitlab, Page::Main::Login) + Page::Main::Login.act { sign_in_using_credentials } + + Factory::Resource::Runner.fabricate! do |runner| + runner.name = executor + end + + Page::Project::Settings::CICD.perform do |settings| + sleep 5 # Runner should register within 5 seconds + settings.refresh + + settings.expand_runners_settings do |page| + expect(page).to have_content(executor) + expect(page).to have_online_runner + end + end + end + + scenario 'users creates a new pipeline' do + Runtime::Browser.visit(:gitlab, Page::Main::Login) + Page::Main::Login.act { sign_in_using_credentials } + + project = Factory::Resource::Project.fabricate! do |project| + project.name = 'project-with-pipelines' + project.description = 'Project with CI/CD Pipelines.' + end + + Factory::Resource::Runner.fabricate! do |runner| + runner.project = project + runner.name = executor + runner.tags = %w[qa test] + end + + Factory::Repository::Push.fabricate! do |push| + push.project = project + push.file_name = '.gitlab-ci.yml' + push.commit_message = 'Add .gitlab-ci.yml' + push.file_content = <<~EOF + test-success: + tags: + - qa + - test + script: echo 'OK' + + test-failure: + tags: + - qa + - test + script: + - echo 'FAILURE' + - exit 1 + + test-tags: + tags: + - qa + - docker + script: echo 'NOOP' + + test-artifacts: + tags: + - qa + - test + script: echo "CONTENTS" > my-artifacts/artifact.txt + artifacts: + paths: + - my-artifacts/ + EOF + end + + Page::Project::Show.act { wait_for_push } + + expect(page).to have_content('Add .gitlab-ci.yml') + + Page::Menu::Side.act { click_ci_cd_pipelines } + + expect(page).to have_content('All 1') + expect(page).to have_content('Add .gitlab-ci.yml') + + puts 'Waiting for the runner to process the pipeline' + sleep 15 # Runner should process all jobs within 15 seconds. + + Page::Project::Pipeline::Index.act { go_to_latest_pipeline } + + Page::Project::Pipeline::Show.perform do |pipeline| + expect(pipeline).to be_running + expect(pipeline).to have_build('test-success', status: :success) + expect(pipeline).to have_build('test-failure', status: :failed) + expect(pipeline).to have_build('test-tags', status: :pending) + expect(pipeline).to have_build('test-artifacts', status: :failed) + end + end + end +end diff --git a/qa/qa/specs/features/repository/push_spec.rb b/qa/qa/specs/features/repository/push_spec.rb index 4f6ffe14c9f..51d9c2c7fd2 100644 --- a/qa/qa/specs/features/repository/push_spec.rb +++ b/qa/qa/specs/features/repository/push_spec.rb @@ -11,10 +11,7 @@ module QA push.commit_message = 'Add README.md' end - Page::Project::Show.act do - wait_for_push - refresh - end + Page::Project::Show.act { wait_for_push } expect(page).to have_content('README.md') expect(page).to have_content('This is a test project') diff --git a/qa/spec/factory/base_spec.rb b/qa/spec/factory/base_spec.rb index a3ba0176819..c5663049be8 100644 --- a/qa/spec/factory/base_spec.rb +++ b/qa/spec/factory/base_spec.rb @@ -1,8 +1,9 @@ describe QA::Factory::Base do + let(:factory) { spy('factory') } + let(:product) { spy('product') } + describe '.fabricate!' do subject { Class.new(described_class) } - let(:factory) { spy('factory') } - let(:product) { spy('product') } before do allow(QA::Factory::Product).to receive(:new).and_return(product) @@ -18,7 +19,6 @@ describe QA::Factory::Base do it 'returns fabrication product' do allow(subject).to receive(:new).and_return(factory) - allow(factory).to receive(:fabricate!).and_return('something') result = subject.fabricate!('something') @@ -59,30 +59,63 @@ describe QA::Factory::Base do it 'defines dependency accessors' do expect(subject.new).to respond_to :mydep, :mydep= end - end - describe 'building dependencies' do - let(:dependency) { double('dependency') } - let(:instance) { spy('instance') } + describe 'dependencies fabrication' do + let(:dependency) { double('dependency') } + let(:instance) { spy('instance') } + + subject do + Class.new(described_class) do + dependency Some::MyDependency, as: :mydep + end + end + + before do + stub_const('Some::MyDependency', dependency) + allow(subject).to receive(:new).and_return(instance) + allow(instance).to receive(:mydep).and_return(nil) + allow(QA::Factory::Product).to receive(:new) + end + + it 'builds all dependencies first' do + expect(dependency).to receive(:fabricate!).once + + subject.fabricate! + end + end + end + + describe '.product' do subject do Class.new(described_class) do - dependency Some::MyDependency, as: :mydep + product :token do + page.do_something_on_page! + 'resulting value' + end end end - before do - stub_const('Some::MyDependency', dependency) - - allow(subject).to receive(:new).and_return(instance) - allow(instance).to receive(:mydep).and_return(nil) - allow(QA::Factory::Product).to receive(:new) + it 'appends new product attribute' do + expect(subject.attributes).to be_one + expect(subject.attributes).to have_key(:token) end - it 'builds all dependencies first' do - expect(dependency).to receive(:fabricate!).once + describe 'populating fabrication product with data' do + let(:page) { spy('page') } + + before do + allow(subject).to receive(:new).and_return(factory) + allow(QA::Factory::Product).to receive(:new).and_return(product) + allow(product).to receive(:page).and_return(page) + end - subject.fabricate! + it 'populates product after fabrication' do + subject.fabricate! + + expect(page).to have_received(:do_something_on_page!) + expect(product.token).to eq 'resulting value' + end end end end diff --git a/qa/spec/factory/dependency_spec.rb b/qa/spec/factory/dependency_spec.rb index 32405415126..8aaa6665a18 100644 --- a/qa/spec/factory/dependency_spec.rb +++ b/qa/spec/factory/dependency_spec.rb @@ -54,6 +54,19 @@ describe QA::Factory::Dependency do expect(factory).to have_received(:mydep=).with(dependency) end + + context 'when receives a caller factory as block argument' do + let(:dependency) { QA::Factory::Base } + + it 'calls given block with dependency factory and caller factory' do + allow_any_instance_of(QA::Factory::Base).to receive(:fabricate!).and_return(factory) + allow(QA::Factory::Product).to receive(:populate!).and_return(spy('any')) + + subject.build! + + expect(block).to have_received(:call).with(an_instance_of(QA::Factory::Base), factory) + end + end end end end diff --git a/qa/spec/factory/product_spec.rb b/qa/spec/factory/product_spec.rb index 3d9e86a641b..fdfb1ec90cc 100644 --- a/qa/spec/factory/product_spec.rb +++ b/qa/spec/factory/product_spec.rb @@ -3,19 +3,8 @@ describe QA::Factory::Product do let(:product) { spy('product') } describe '.populate!' do - it 'instantiates and yields factory' do - expect(described_class).to receive(:new).with(factory) - - described_class.populate!(factory) do |instance| - instance.something = 'string' - end - - expect(factory).to have_received(:something=).with('string') - end - it 'returns a fabrication product' do - expect(described_class).to receive(:new) - .with(factory).and_return(product) + expect(described_class).to receive(:new).and_return(product) result = described_class.populate!(factory) do |instance| instance.something = 'string' @@ -23,11 +12,6 @@ describe QA::Factory::Product do expect(result).to be product end - - it 'raises unless block given' do - expect { described_class.populate!(factory) } - .to raise_error ArgumentError - end end describe '.visit!' do @@ -37,8 +21,7 @@ describe QA::Factory::Product do allow_any_instance_of(described_class) .to receive(:visit).and_return('visited some url') - expect(described_class.new(factory).visit!) - .to eq 'visited some url' + expect(subject.visit!).to eq 'visited some url' end end end diff --git a/qa/spec/fixtures/banana_sample.gif b/qa/spec/fixtures/banana_sample.gif Binary files differnew file mode 100644 index 00000000000..1322ac92d14 --- /dev/null +++ b/qa/spec/fixtures/banana_sample.gif diff --git a/qa/spec/page/base_spec.rb b/qa/spec/page/base_spec.rb new file mode 100644 index 00000000000..287adf35c46 --- /dev/null +++ b/qa/spec/page/base_spec.rb @@ -0,0 +1,63 @@ +describe QA::Page::Base do + describe 'page helpers' do + it 'exposes helpful page helpers' do + expect(subject).to respond_to :refresh, :wait, :scroll_to + end + end + + describe '.view', 'DSL for defining view partials' do + subject do + Class.new(described_class) do + view 'path/to/some/view.html.haml' do + element :something, 'string pattern' + element :something_else, /regexp pattern/ + end + + view 'path/to/some/_partial.html.haml' do + element :something, 'string pattern' + end + end + end + + it 'makes it possible to define page views' do + expect(subject.views.size).to eq 2 + expect(subject.views).to all(be_an_instance_of QA::Page::View) + end + + it 'populates views objects with data about elements' do + subject.views.first.elements.tap do |elements| + expect(elements.size).to eq 2 + expect(elements).to all(be_an_instance_of QA::Page::Element) + expect(elements.map(&:name)).to eq [:something, :something_else] + end + end + end + + describe '.errors' do + let(:view) { double('view') } + + context 'when page has views and elements defined' do + before do + allow(described_class).to receive(:views) + .and_return([view]) + + allow(view).to receive(:errors).and_return(['some error']) + end + + it 'iterates views composite and returns errors' do + expect(described_class.errors).to eq ['some error'] + end + end + + context 'when page has no views and elements defined' do + before do + allow(described_class).to receive(:views).and_return([]) + end + + it 'appends an error about missing views / elements block' do + expect(described_class.errors) + .to include 'Page class does not have views / elements defined!' + end + end + end +end diff --git a/qa/spec/page/element_spec.rb b/qa/spec/page/element_spec.rb new file mode 100644 index 00000000000..8598c57ad34 --- /dev/null +++ b/qa/spec/page/element_spec.rb @@ -0,0 +1,51 @@ +describe QA::Page::Element do + describe '#selector' do + it 'transforms element name into QA-specific selector' do + expect(described_class.new(:sign_in_button).selector) + .to eq 'qa-sign-in-button' + end + end + + describe '#selector_css' do + it 'transforms element name into QA-specific clickable css selector' do + expect(described_class.new(:sign_in_button).selector_css) + .to eq '.qa-sign-in-button' + end + end + + context 'when pattern is an expression' do + subject { described_class.new(:something, /button 'Sign in'/) } + + it 'matches when there is a match' do + expect(subject.matches?("button 'Sign in'")).to be true + end + + it 'does not match if pattern is not present' do + expect(subject.matches?("button 'Sign out'")).to be false + end + end + + context 'when pattern is a string' do + subject { described_class.new(:something, 'button') } + + it 'matches when there is match' do + expect(subject.matches?('some button in the view')).to be true + end + + it 'does not match if pattern is not present' do + expect(subject.matches?('text_field :name')).to be false + end + end + + context 'when pattern is not provided' do + subject { described_class.new(:some_name) } + + it 'matches when QA specific selector is present' do + expect(subject.matches?('some qa-some-name selector')).to be true + end + + it 'does not match if QA selector is not there' do + expect(subject.matches?('some_name selector')).to be false + end + end +end diff --git a/qa/spec/page/validator_spec.rb b/qa/spec/page/validator_spec.rb new file mode 100644 index 00000000000..02822d7d18f --- /dev/null +++ b/qa/spec/page/validator_spec.rb @@ -0,0 +1,79 @@ +describe QA::Page::Validator do + describe '#constants' do + subject do + described_class.new(QA::Page::Project) + end + + it 'returns all constants that are module children' do + expect(subject.constants) + .to include QA::Page::Project::New, QA::Page::Project::Settings + end + end + + describe '#descendants' do + subject do + described_class.new(QA::Page::Project) + end + + it 'recursively returns all descendants that are page objects' do + expect(subject.descendants) + .to include QA::Page::Project::New, QA::Page::Project::Settings::Repository + end + + it 'does not return modules that aggregate page objects' do + expect(subject.descendants) + .not_to include QA::Page::Project::Settings + end + end + + context 'when checking validation errors' do + let(:view) { spy('view') } + + before do + allow(QA::Page::Admin::Settings) + .to receive(:views).and_return([view]) + end + + subject do + described_class.new(QA::Page::Admin) + end + + context 'when there are no validation errors' do + before do + allow(view).to receive(:errors).and_return([]) + end + + describe '#errors' do + it 'does not return errors' do + expect(subject.errors).to be_empty + end + end + + describe '#validate!' do + it 'does not raise error' do + expect { subject.validate! }.not_to raise_error + end + end + end + + context 'when there are validation errors' do + before do + allow(view).to receive(:errors) + .and_return(['some error', 'another error']) + end + + describe '#errors' do + it 'returns errors' do + expect(subject.errors.count).to eq 2 + end + end + + describe '#validate!' do + it 'raises validation error' do + expect { subject.validate! } + .to raise_error described_class::ValidationError + end + end + end + end +end diff --git a/qa/spec/page/view_spec.rb b/qa/spec/page/view_spec.rb new file mode 100644 index 00000000000..aedbc3863a7 --- /dev/null +++ b/qa/spec/page/view_spec.rb @@ -0,0 +1,70 @@ +describe QA::Page::View do + let(:element) do + double('element', name: :something, pattern: /some element/) + end + + subject { described_class.new('some/file.html', [element]) } + + describe '.evaluate' do + it 'evaluates a block and returns a DSL object' do + results = described_class.evaluate do + element :something, 'my pattern' + element :something_else, /another pattern/ + end + + expect(results.elements.size).to eq 2 + end + end + + describe '#pathname' do + it 'returns an absolute and clean path to the view' do + expect(subject.pathname.to_s).not_to include 'qa/page/' + expect(subject.pathname.to_s).to include 'some/file.html' + end + end + + describe '#errors' do + context 'when view partial is present' do + before do + allow(subject.pathname).to receive(:readable?) + .and_return(true) + end + + context 'when pattern is found' do + before do + allow(File).to receive(:foreach) + .and_yield('some element').once + allow(element).to receive(:matches?) + .with('some element').and_return(true) + end + + it 'walks through the view and asserts on elements existence' do + expect(subject.errors).to be_empty + end + end + + context 'when pattern has not been found' do + before do + allow(File).to receive(:foreach) + .and_yield('some element').once + allow(element).to receive(:matches?) + .with('some element').and_return(false) + end + + it 'returns an array of errors related to missing elements' do + expect(subject.errors).not_to be_empty + expect(subject.errors.first) + .to match %r(Missing element `.*` in `.*/some/file.html` view) + end + end + end + + context 'when view partial has not been found' do + it 'returns an error when it is not able to find the partial' do + expect(subject.errors).to be_one + expect(subject.errors.first) + .to match %r(Missing view partial `.*/some/file.html`!) + end + end + end +end diff --git a/qa/spec/runtime/api_client_spec.rb b/qa/spec/runtime/api_client_spec.rb new file mode 100644 index 00000000000..d497d8839b8 --- /dev/null +++ b/qa/spec/runtime/api_client_spec.rb @@ -0,0 +1,30 @@ +describe QA::Runtime::API::Client do + include Support::StubENV + + describe 'initialization' do + it 'defaults to :gitlab address' do + expect(described_class.new.address).to eq :gitlab + end + + it 'uses specified address' do + client = described_class.new('http:///example.com') + + expect(client.address).to eq 'http:///example.com' + end + end + + describe '#get_personal_access_token' do + it 'returns specified token from env' do + stub_env('PERSONAL_ACCESS_TOKEN', 'a_token') + + expect(described_class.new.get_personal_access_token).to eq 'a_token' + end + + it 'returns a created token' do + allow_any_instance_of(described_class) + .to receive(:create_personal_access_token).and_return('created_token') + + expect(described_class.new.get_personal_access_token).to eq 'created_token' + end + end +end diff --git a/qa/spec/runtime/api_request_spec.rb b/qa/spec/runtime/api_request_spec.rb new file mode 100644 index 00000000000..9a1ed8a7a46 --- /dev/null +++ b/qa/spec/runtime/api_request_spec.rb @@ -0,0 +1,42 @@ +describe QA::Runtime::API::Request do + include Support::StubENV + + before do + stub_env('PERSONAL_ACCESS_TOKEN', 'a_token') + end + + let(:client) { QA::Runtime::API::Client.new('http://example.com') } + let(:request) { described_class.new(client, '/users') } + + describe '#url' do + it 'returns the full api request url' do + expect(request.url).to eq 'http://example.com/api/v4/users?private_token=a_token' + end + end + + describe '#request_path' do + it 'prepends the api path' do + expect(request.request_path('/users')).to eq '/api/v4/users' + end + + it 'adds the personal access token' do + expect(request.request_path('/users', personal_access_token: 'token')) + .to eq '/api/v4/users?private_token=token' + end + + it 'adds the oauth access token' do + expect(request.request_path('/users', oauth_access_token: 'otoken')) + .to eq '/api/v4/users?access_token=otoken' + end + + it 'respects query parameters' do + expect(request.request_path('/users?page=1')).to eq '/api/v4/users?page=1' + expect(request.request_path('/users?page=1', personal_access_token: 'token')) + .to eq '/api/v4/users?page=1&private_token=token' + end + + it 'uses a different api version' do + expect(request.request_path('/users', version: 'v3')).to eq '/api/v3/users' + end + end +end diff --git a/qa/spec/runtime/env_spec.rb b/qa/spec/runtime/env_spec.rb index 57a72a04507..103573db6be 100644 --- a/qa/spec/runtime/env_spec.rb +++ b/qa/spec/runtime/env_spec.rb @@ -1,7 +1,5 @@ describe QA::Runtime::Env do - before do - allow(ENV).to receive(:[]).and_call_original - end + include Support::StubENV describe '.chrome_headless?' do context 'when there is an env variable set' do @@ -57,8 +55,4 @@ describe QA::Runtime::Env do end end end - - def stub_env(name, value) - allow(ENV).to receive(:[]).with(name).and_return(value) - end end diff --git a/qa/spec/runtime/rsa_key.rb b/qa/spec/runtime/rsa_key.rb new file mode 100644 index 00000000000..6d7ab4dcd2e --- /dev/null +++ b/qa/spec/runtime/rsa_key.rb @@ -0,0 +1,9 @@ +describe QA::Runtime::RSAKey do + describe '#public_key' do + subject { described_class.new.public_key } + + it 'generates a public RSA key' do + expect(subject).to match(%r{\Assh\-rsa AAAA[0-9A-Za-z+/]+={0,3}\z}) + end + end +end diff --git a/qa/spec/scenario/entrypoint_spec.rb b/qa/spec/scenario/test/instance_spec.rb index aec79dcea04..1824db54c9b 100644 --- a/qa/spec/scenario/entrypoint_spec.rb +++ b/qa/spec/scenario/test/instance_spec.rb @@ -1,6 +1,6 @@ -describe QA::Scenario::Entrypoint do +describe QA::Scenario::Test::Instance do subject do - Class.new(QA::Scenario::Entrypoint) do + Class.new(described_class) do tags :rspec end end diff --git a/qa/spec/scenario/test/sanity/selectors_spec.rb b/qa/spec/scenario/test/sanity/selectors_spec.rb new file mode 100644 index 00000000000..45d21d54955 --- /dev/null +++ b/qa/spec/scenario/test/sanity/selectors_spec.rb @@ -0,0 +1,40 @@ +describe QA::Scenario::Test::Sanity::Selectors do + let(:validator) { spy('validator') } + + before do + stub_const('QA::Page::Validator', validator) + end + + context 'when there are errors detected' do + before do + allow(validator).to receive(:errors).and_return(['some error']) + end + + it 'outputs information about errors' do + expect { described_class.perform } + .to output(/some error/).to_stderr + + expect { described_class.perform } + .to output(/electors validation test detected problems/) + .to_stderr + end + end + + context 'when there are no errors detected' do + before do + allow(validator).to receive(:errors).and_return([]) + end + + it 'processes pages module' do + described_class.perform + + expect(validator).to have_received(:new).with(QA::Page) + end + + it 'triggers validation' do + described_class.perform + + expect(validator).to have_received(:validate!).at_least(:once) + end + end +end diff --git a/qa/spec/spec_helper.rb b/qa/spec/spec_helper.rb index 64d06ef6558..c2c6cf95406 100644 --- a/qa/spec/spec_helper.rb +++ b/qa/spec/spec_helper.rb @@ -1,5 +1,7 @@ require_relative '../qa' +Dir[File.join(__dir__, 'support', '**', '*.rb')].each { |f| require f } + RSpec.configure do |config| config.expect_with :rspec do |expectations| expectations.include_chain_clauses_in_custom_matcher_descriptions = true diff --git a/qa/spec/support/stub_env.rb b/qa/spec/support/stub_env.rb new file mode 100644 index 00000000000..bc8f3a5e22e --- /dev/null +++ b/qa/spec/support/stub_env.rb @@ -0,0 +1,38 @@ +# Inspired by https://github.com/ljkbennett/stub_env/blob/master/lib/stub_env/helpers.rb +module Support + module StubENV + def stub_env(key_or_hash, value = nil) + init_stub unless env_stubbed? + + if key_or_hash.is_a? Hash + key_or_hash.each { |k, v| add_stubbed_value(k, v) } + else + add_stubbed_value key_or_hash, value + end + end + + private + + STUBBED_KEY = '__STUBBED__'.freeze + + def add_stubbed_value(key, value) + allow(ENV).to receive(:[]).with(key).and_return(value) + allow(ENV).to receive(:key?).with(key).and_return(true) + allow(ENV).to receive(:fetch).with(key).and_return(value) + allow(ENV).to receive(:fetch).with(key, anything()) do |_, default_val| + value || default_val + end + end + + def env_stubbed? + ENV[STUBBED_KEY] + end + + def init_stub + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:key?).and_call_original + allow(ENV).to receive(:fetch).and_call_original + add_stubbed_value(STUBBED_KEY, true) + end + end +end |