Commit b6f2f738 authored by Rémy Coutable's avatar Rémy Coutable Committed by Douglas Barbosa Alexandre

First iteration to allow creating QA resources using the API

parent ab9cf561
......@@ -36,6 +36,7 @@ module QA
# GitLab QA fabrication mechanisms
#
module Factory
autoload :ApiFabricator, 'qa/factory/api_fabricator'
autoload :Base, 'qa/factory/base'
autoload :Dependency, 'qa/factory/dependency'
autoload :Product, 'qa/factory/product'
......
This diff is collapsed.
# frozen_string_literal: true
require 'airborne'
require 'active_support/core_ext/object/deep_dup'
require 'capybara/dsl'
module QA
module Factory
module ApiFabricator
include Airborne
include Capybara::DSL
HTTP_STATUS_OK = 200
HTTP_STATUS_CREATED = 201
ResourceNotFoundError = Class.new(RuntimeError)
ResourceFabricationFailedError = Class.new(RuntimeError)
ResourceURLMissingError = Class.new(RuntimeError)
attr_reader :api_resource, :api_response
def api_support?
respond_to?(:api_get_path) &&
respond_to?(:api_post_path) &&
respond_to?(:api_post_body)
end
def fabricate_via_api!
unless api_support?
raise NotImplementedError, "Factory #{self.class.name} does not support fabrication via the API!"
end
resource_web_url(api_post)
end
def eager_load_api_client!
api_client.tap do |client|
# Eager-load the API client so that the personal token creation isn't
# taken in account in the actual resource creation timing.
client.personal_access_token
end
end
private
attr_writer :api_resource, :api_response
def resource_web_url(resource)
resource.fetch(:web_url) do
raise ResourceURLMissingError, "API resource for #{self.class.name} does not expose a `web_url` property: `#{resource}`."
end
end
def api_get
url = Runtime::API::Request.new(api_client, api_get_path).url
response = get(url)
unless response.code == HTTP_STATUS_OK
raise ResourceNotFoundError, "Resource at #{url} could not be found (#{response.code}): `#{response}`."
end
process_api_response(parse_body(response))
end
def api_post
response = post(
Runtime::API::Request.new(api_client, api_post_path).url,
api_post_body)
unless response.code == HTTP_STATUS_CREATED
raise ResourceFabricationFailedError, "Fabrication of #{self.class.name} using the API failed (#{response.code}) with `#{response}`."
end
process_api_response(parse_body(response))
end
def api_client
@api_client ||= begin
Runtime::API::Client.new(:gitlab, is_new_session: !current_url.start_with?('http'))
end
end
def parse_body(response)
JSON.parse(response.body, symbolize_names: true)
end
def process_api_response(parsed_response)
self.api_response = parsed_response
self.api_resource = transform_api_resource(parsed_response.deep_dup)
end
def transform_api_resource(resource)
resource
end
end
end
end
# frozen_string_literal: true
require 'forwardable'
require 'capybara/dsl'
module QA
module Factory
class Base
extend SingleForwardable
include ApiFabricator
extend Capybara::DSL
def_delegators :evaluator, :dependency, :dependencies
def_delegators :evaluator, :product, :attributes
......@@ -12,46 +17,96 @@ module QA
raise NotImplementedError
end
def self.fabricate!(*args)
new.tap do |factory|
yield factory if block_given?
def self.fabricate!(*args, &prepare_block)
fabricate_via_api!(*args, &prepare_block)
rescue NotImplementedError
fabricate_via_browser_ui!(*args, &prepare_block)
end
dependencies.each do |name, signature|
Factory::Dependency.new(name, factory, signature).build!
end
def self.fabricate_via_browser_ui!(*args, &prepare_block)
options = args.extract_options!
factory = options.fetch(:factory) { new }
parents = options.fetch(:parents) { [] }
do_fabricate!(factory: factory, prepare_block: prepare_block, parents: parents) do
log_fabrication(:browser_ui, factory, parents, args) { factory.fabricate!(*args) }
current_url
end
end
def self.fabricate_via_api!(*args, &prepare_block)
options = args.extract_options!
factory = options.fetch(:factory) { new }
parents = options.fetch(:parents) { [] }
raise NotImplementedError unless factory.api_support?
factory.eager_load_api_client!
do_fabricate!(factory: factory, prepare_block: prepare_block, parents: parents) do
log_fabrication(:api, factory, parents, args) { factory.fabricate_via_api! }
end
end
def self.do_fabricate!(factory:, prepare_block:, parents: [])
prepare_block.call(factory) if prepare_block
dependencies.each do |signature|
Factory::Dependency.new(factory, signature).build!(parents: parents + [self])
end
resource_web_url = yield
Factory::Product.populate!(factory, resource_web_url)
end
private_class_method :do_fabricate!
def self.log_fabrication(method, factory, parents, args)
return yield unless Runtime::Env.verbose?
factory.fabricate!(*args)
start = Time.now
prefix = "==#{'=' * parents.size}>"
msg = [prefix]
msg << "Built a #{name}"
msg << "as a dependency of #{parents.last}" if parents.any?
msg << "via #{method} with args #{args}"
break Factory::Product.populate!(factory)
yield.tap do
msg << "in #{Time.now - start} seconds"
puts msg.join(' ')
puts if parents.empty?
end
end
private_class_method :log_fabrication
def self.evaluator
@evaluator ||= Factory::Base::DSL.new(self)
end
private_class_method :evaluator
class DSL
attr_reader :dependencies, :attributes
def initialize(base)
@base = base
@dependencies = {}
@attributes = {}
@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)
Dependency::Signature.new(name, factory, block).tap do |signature|
@dependencies << signature
end
end
end
def product(attribute, &block)
Product::Attribute.new(attribute, block).tap do |signature|
@attributes.store(attribute, signature)
@attributes << signature
end
end
end
......
module QA
module Factory
class Dependency
Signature = Struct.new(:factory, :block)
Signature = Struct.new(:name, :factory, :block)
def initialize(name, factory, signature)
@name = name
@factory = factory
@signature = signature
def initialize(caller_factory, dependency_signature)
@caller_factory = caller_factory
@dependency_signature = dependency_signature
end
def overridden?
!!@factory.public_send(@name)
!!@caller_factory.public_send(@dependency_signature.name)
end
def build!
def build!(parents: [])
return if overridden?
Builder.new(@signature, @factory).fabricate!.tap do |product|
@factory.public_send("#{@name}=", product)
end
end
class Builder
def initialize(signature, caller_factory)
@factory = signature.factory
@block = signature.block
@caller_factory = caller_factory
dependency = @dependency_signature.factory.fabricate!(parents: parents) do |factory|
@dependency_signature.block&.call(factory, @caller_factory)
end
def fabricate!
@factory.fabricate! do |factory|
@block&.call(factory, @caller_factory)
end
dependency.tap do |dependency|
@caller_factory.public_send("#{@dependency_signature.name}=", dependency)
end
end
end
......
......@@ -5,26 +5,46 @@ module QA
class Product
include Capybara::DSL
NoValueError = Class.new(RuntimeError)
attr_reader :factory, :web_url
Attribute = Struct.new(:name, :block)
def initialize
@location = current_url
def initialize(factory, web_url)
@factory = factory
@web_url = web_url
populate_attributes!
end
def visit!
visit @location
visit(web_url)
end
def self.populate!(factory, web_url)
new(factory, web_url)
end
def self.populate!(factory)
new.tap do |product|
factory.class.attributes.each_value do |attribute|
product.instance_exec(factory, attribute.block) do |factory, block|
value = block.call(factory)
product.define_singleton_method(attribute.name) { value }
end
private
def populate_attributes!
factory.class.attributes.each do |attribute|
instance_exec(factory, attribute.block) do |factory, block|
value = attribute_value(attribute, block)
raise NoValueError, "No value was computed for product #{attribute.name} of factory #{factory.class.name}." unless value
define_singleton_method(attribute.name) { value }
end
end
end
def attribute_value(attribute, block)
factory.api_resource&.dig(attribute.name) ||
(block && block.call(factory)) ||
(factory.respond_to?(attribute.name) && factory.public_send(attribute.name))
end
end
end
end
......@@ -7,13 +7,8 @@ module QA
project.description = 'Project with repository'
end
product :output do |factory|
factory.output
end
product :project do |factory|
factory.project
end
product :output
product :project
def initialize
@file_name = 'file.txt'
......
......@@ -11,7 +11,7 @@ module QA
end
end
product(:user) { |factory| factory.user }
product :user
def visit_project_with_retry
# The user intermittently fails to stay signed in after visiting the
......
......@@ -6,6 +6,10 @@ module QA
dependency Factory::Resource::Sandbox, as: :sandbox
product :id do
true # We don't retrieve the Group ID when using the Browser UI
end
def initialize
@path = Runtime::Namespace.name
@description = "QA test run at #{Runtime::Namespace.time}"
......@@ -35,6 +39,29 @@ module QA
end
end
end
def fabricate_via_api!
resource_web_url(api_get)
rescue ResourceNotFoundError
super
end
def api_get_path
"/groups/#{CGI.escape("#{sandbox.path}/#{path}")}"
end
def api_post_path
'/groups'
end
def api_post_body
{
parent_id: sandbox.id,
path: path,
name: path,
visibility: 'public'
}
end
end
end
end
......
......@@ -2,16 +2,15 @@ module QA
module Factory
module Resource
class Issue < Factory::Base
attr_writer :title, :description, :project
attr_accessor :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
product :project
product :title
def fabricate!
project.visit!
......
......@@ -12,13 +12,8 @@ module QA
:milestone,
:labels
product :project do |factory|
factory.project
end
product :source_branch do |factory|
factory.source_branch
end
product :project
product :source_branch
dependency Factory::Resource::Project, as: :project do |project|
project.name = 'project-with-merge-request'
......
......@@ -4,14 +4,13 @@ module QA
module Factory
module Resource
class Project < Factory::Base
attr_writer :description
attr_accessor :description
attr_reader :name
dependency Factory::Resource::Group, as: :group
product :name do |factory|
factory.name
end
product :group
product :name
product :repository_ssh_location do
Page::Project::Show.act do
......@@ -48,6 +47,32 @@ module QA
page.create_new_project
end
end
def api_get_path
"/projects/#{name}"
end
def api_post_path
'/projects'
end
def api_post_body
{
namespace_id: group.id,
path: name,
name: name,
description: description,
visibility: 'public'
}
end
private
def transform_api_resource(resource)
resource[:repository_ssh_location] = Git::Location.new(resource[:ssh_url_to_repo])
resource[:repository_http_location] = Git::Location.new(resource[:http_url_to_repo])
resource
end
end
end
end
......
......@@ -8,9 +8,7 @@ module QA
dependency Factory::Resource::Group, as: :group
product :name do |factory|
factory.name
end
product :name
def fabricate!
group.visit!
......
......@@ -7,7 +7,7 @@ module QA
dependency Factory::Resource::Project, as: :project
product(:title) { |factory| factory.title }
product :title
def title=(title)
@title = "#{title}-#{SecureRandom.hex(4)}"
......
......@@ -6,21 +6,28 @@ module QA
# creating it if it doesn't yet exist.
#
class Sandbox < Factory::Base
attr_reader :path
product :id do
true # We don't retrieve the Group ID when using the Browser UI
end
product :path
def initialize
@name = Runtime::Namespace.sandbox_name
@path = Runtime::Namespace.sandbox_name
end
def fabricate!
Page::Main::Menu.act { go_to_groups }
Page::Dashboard::Groups.perform do |page|
if page.has_group?(@name)
page.go_to_group(@name)
if page.has_group?(path)
page.go_to_group(path)
else
page.go_to_new_group
Page::Group::New.perform do |group|
group.set_path(@name)
group.set_path(path)
group.set_description('GitLab QA Sandbox Group')
group.set_visibility('Public')
group.create
......@@ -28,6 +35,28 @@ module QA
end
end
end
def fabricate_via_api!
resource_web_url(api_get)
rescue ResourceNotFoundError
super
end
def api_get_path
"/groups/#{path}"
end
def api_post_path
'/groups'
end
def api_post_body
{
path: path,
name: path,
visibility: 'public'
}
end
end
end
end
......
......@@ -10,17 +10,9 @@ module QA
attr_reader :private_key, :public_key, :fingerprint
def_delegators :key, :private_key, :public_key, :fingerprint
product :private_key do |factory|
factory.private_key
end
product :title do |factory|
factory.title
end
product :fingerprint do |factory|
factory.fingerprint
end
product :private_key
product :title
product :fingerprint
def key
@key ||= Runtime::Key::RSA.new
......
......@@ -31,10 +31,10 @@ module QA
defined?(@username) && defined?(@password)
end
product(:name) { |factory| factory.name }
product(:username) { |factory| factory.username }
product(:email) { |factory| factory.email }
product(:password) { |factory| factory.password }
product :name
product :username
product :email
product :password
def fabricate!
# Don't try to log-out if we're not logged-in
......
......@@ -10,13 +10,16 @@ module QA
end
def fabricate!
Page::Project::Menu.act { click_wiki }
Page::Project::Wiki::New.perform do |page|
page.go_to_create_first_page
page.set_title(@title)
page.set_content(@content)
page.set_message(@message)
page.create_new_page
project.visit!
Page::Project::Menu.perform { |menu_side| menu_side.click_wiki }
Page::Project::Wiki::New.perform do |wiki_new|
wiki_new.go_to_create_first_page
wiki_new.set_title(@title)
wiki_new.set_content(@content)
wiki_new.set_message(@message)
wiki_new.create_new_page
end
end
end
......
......@@ -131,4 +131,4 @@ If you need more information, ask for help on `#quality` channel on Slack
(internal, 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.
open an issue in GitLab CE issue tracker with the `~QA` label.
......@@ -6,33 +6,34 @@ module QA
class Client
attr_reader :address
def initialize(address = :gitlab, personal_access_token: nil)
def initialize(address = :gitlab, personal_access_token: nil, is_new_session: true)
@address = address
@personal_access_token = personal_access_token
@is_new_session = is_new_session
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
@personal_access_token ||= begin
# you can set the environment variable PERSONAL_ACCESS_TOKEN
# to use a specific access token rather than create one from the UI
Runtime::Env.personal_access_token ||= 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
if @is_new_session
Runtime::Browser.visit(@address, Page::Main::Login) { do_create_personal_access_token }
else
do_create_personal_access_token
end
end
def do_create_personal_access_token
Page::Main::Login.act { sign_in_using_credentials }
Factory::Resource::PersonalAccessToken.fabricate!.access_token
end
end
end
end
......
......@@ -3,6 +3,12 @@ module QA
module Env
extend self
attr_writer :personal_access_token
def verbose?
enabled?(ENV['VERBOSE'], default: false)
end
# set to 'false' to have Chrome run visibly instead of headless
def chrome_headless?
enabled?(ENV['CHROME_HEADLESS'])
......@@ -22,7 +28,7 @@ module QA
# specifies token that can be used for the api
def personal_access_token
ENV['PERSONAL_ACCESS_TOKEN']