Commit 6f7ab301 authored by Rémy Coutable's avatar Rémy Coutable

Merge branch 'qa/gb/validate-qa-selectors' into 'master'

Validate GitLab QA selectors

Closes gitlab-qa#58

See merge request gitlab-org/gitlab-ce!16109
parents a733c26e f4a694ef
......@@ -623,6 +623,18 @@ qa:internal:
- bundle install
- bundle exec rspec
qa:selectors:
<<: *dedicated-runner
<<: *except-docs
stage: test
variables:
SETUP_DB: "false"
services: []
script:
- cd qa/
- bundle install
- bundle exec bin/qa Test::Sanity::Selectors
coverage:
<<: *dedicated-runner
<<: *except-docs-and-qa
......
%header.navbar.navbar-gitlab
%header.navbar.navbar-gitlab.qa-navbar
%a.sr-only.gl-accessibility{ href: "#content-body", tabindex: "1" } Skip to content
.container-fluid
.header-content
......@@ -43,7 +43,7 @@
= todos_count_format(todos_pending_count)
%li.header-user.dropdown
= link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do
= image_tag avatar_icon(current_user, 23), width: 23, height: 23, class: "header-user-avatar"
= image_tag avatar_icon(current_user, 23), width: 23, height: 23, class: "header-user-avatar qa-user-avatar"
= sprite_icon('angle-down', css_class: 'caret-down')
.dropdown-menu-nav.dropdown-menu-align-right
%ul
......
%ul.list-unstyled.navbar-sub-nav
= nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects" }) do
= nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects qa-projects-dropdown" }) do
%a{ href: "#", data: { toggle: "dropdown" } }
Projects
= sprite_icon('angle-down', css_class: 'caret-down')
......@@ -7,7 +7,7 @@
= render "layouts/nav/projects_dropdown/show"
= nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { class: "hidden-xs" }) do
= link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups', title: 'Groups' do
= link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups qa-groups-link', title: 'Groups' do
Groups
= nav_link(path: 'dashboard#activity', html_options: { class: "visible-lg" }) do
......@@ -59,7 +59,7 @@
%li.line-separator.hidden-xs
- if current_user.admin?
= nav_link(controller: 'admin/dashboard') do
= link_to admin_root_path, class: 'admin-icon', title: 'Admin area', aria: { label: "Admin area" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= link_to admin_root_path, class: 'admin-icon qa-admin-area-link', title: 'Admin area', aria: { label: "Admin area" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= sprite_icon('admin', size: 18)
- if Gitlab::Sherlock.enabled?
%li
......
- project_meta = { id: @project.id, name: @project.name, namespace: @project.name_with_namespace, web_url: project_path(@project), avatar_url: @project.avatar_url } if @project&.persisted?
.projects-dropdown-container
.project-dropdown-sidebar
.project-dropdown-sidebar.qa-projects-dropdown-sidebar
%ul
= nav_link(path: 'dashboard/projects#index') do
= link_to dashboard_projects_path do
= link_to dashboard_projects_path, class: 'qa-your-projects-link' do
= _('Your projects')
= nav_link(path: 'projects#starred') do
= link_to starred_dashboard_projects_path do
......
......@@ -226,7 +226,7 @@
= link_to edit_project_path(@project), class: 'shortcuts-tree' do
.nav-icon-container
= sprite_icon('settings')
%span.nav-item-name
%span.nav-item-name.qa-settings-item
Settings
%ul.sidebar-sub-level-items
......
......@@ -58,6 +58,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 +72,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'
......
......@@ -2,6 +2,13 @@ module QA
module Page
module Admin
class Settings < Page::Base
##
# TODO, define all selectors required by this page object
#
# See gitlab-org/gitlab-qa#154
#
view 'app/views/admin/application_settings/show.html.haml'
def enable_hashed_storage
scroll_to 'legend', text: 'Repository Storage'
check 'Create new projects using hashed storage paths'
......
......@@ -5,6 +5,9 @@ module QA
class Base
include Capybara::DSL
include Scenario::Actable
extend SingleForwardable
def_delegators :evaluator, :view, :views
def refresh
visit current_url
......@@ -37,9 +40,39 @@ module QA
page.within(selector) { yield } if block_given?
end
def click_element(name)
find(Page::Element.new(name).selector_css).click
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
......@@ -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
......
......@@ -2,6 +2,8 @@ module QA
module Page
module Dashboard
class Projects < Page::Base
view 'app/views/dashboard/projects/index.html.haml'
def go_to_project(name)
find_link(text: name).click
end
......
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
......@@ -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
......
......@@ -2,6 +2,13 @@ module QA
module Page
module Group
class Show < Page::Base
##
# TODO, define all selectors required by this page object
#
# See gitlab-org/gitlab-qa#154
#
view 'app/views/groups/show.html.haml'
def go_to_subgroup(name)
click_link name
end
......
......@@ -2,6 +2,18 @@ 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 :passowrd_field, 'password_field :password'
element :sign_in_button, 'submit "Sign in"'
end
def initialize
wait('.application', time: 500)
end
......
......@@ -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
......
......@@ -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'
......
......@@ -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
......
......@@ -2,6 +2,13 @@ module QA
module Page
module Menu
class Admin < Page::Base
##
# TODO, define all selectors required by this page object
#
# See gitlab-org/gitlab-qa#154
#
view 'app/views/admin/dashboard/index.html.haml'
def go_to_license
click_link 'License'
end
......
......@@ -2,19 +2,40 @@ 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"'
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
......@@ -24,20 +45,20 @@ module QA
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
......
......@@ -2,6 +2,12 @@ module QA
module Page
module Menu
class Side < Page::Base
view 'app/views/layouts/nav/sidebar/_project.html.haml' do
element :settings_item
element :repository_link, "title: 'Repository'"
element :top_level_items, '.sidebar-top-level-items'
end
def click_repository_setting
hover_setting do
click_link('Repository')
......@@ -12,7 +18,7 @@ module QA
def hover_setting
within_sidebar do
find('.nav-item-name', text: 'Settings').hover
find('.qa-settings-item').hover
yield
end
......
......@@ -2,6 +2,13 @@ module QA
module Page
module Project
class New < Page::Base
##
# TODO, define all selectors required by this page object
#
# See gitlab-org/gitlab-qa#154
#
view 'app/views/projects/new.html.haml'
def choose_test_namespace
find('#s2id_project_namespace_id').click
find('.select2-result-label', text: Runtime::Namespace.name).click
......
......@@ -3,6 +3,13 @@ module QA
module Project
module Settings
class DeployKeys < Page::Base
##
# TODO, define all selectors required by this page object
#
# See gitlab-org/gitlab-qa#154
#
view 'app/views/projects/deploy_keys/edit.html.haml'
def fill_key_title(title)
fill_in 'deploy_key_title', with: title
end
......
......@@ -5,6 +5,13 @@ module QA
class Repository < Page::Base
include Common
##
# TODO, define all selectors required by this page object
#
# See gitlab-org/gitlab-qa#154
#
view 'app/views/projects/settings/repository/show.html.haml'
def expand_deploy_keys(&block)
expand('.qa-expand-deploy-keys') do
DeployKeys.perform(&block)
......
......@@ -2,6 +2,13 @@ module QA
module Page
module Project
class Show < Page::Base
##
# TODO, define all selectors required by this page object
#
# See gitlab-org/gitlab-qa#154
#
view 'app/views/projects/show.html.haml'
def choose_repository_clone_http
find('#clone-dropdown').click
......
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
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
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
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
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
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
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
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
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment