fastlane-plugin-saucectl 0.1.3.pre → 0.1.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,180 @@
1
+ require 'base64'
2
+ require 'fastlane'
3
+ require 'fastlane_core/ui/ui'
4
+ require_relative 'espresso'
5
+ require_relative 'xctest'
6
+
7
+ module Fastlane
8
+ module Saucectl
9
+ #
10
+ # This class will create test suites based on user specified configuration properties
11
+ #
12
+ class AndroidSuites
13
+ include FileUtils
14
+
15
+ UI = FastlaneCore::UI unless Fastlane.const_defined?(:UI)
16
+
17
+ def initialize(config)
18
+ UI.user_error!("❌ #{config[:kind]} is not a supported test framework for android. Use espresso") unless config[:kind].eql?('espresso')
19
+ @devices = config[:devices].nil? ? config[:emulators] : config[:devices]
20
+ @is_real_device = config[:emulators].nil?
21
+ @config = config
22
+ end
23
+
24
+ def generate
25
+ if @config[:test_distribution] || @config[:size] || @config[:annotation]
26
+ create_test_distribution_suite
27
+ elsif @config[:test_class].kind_of?(Array)
28
+ custom_test_class_suite
29
+ else
30
+ test_runner_suite
31
+ end
32
+ end
33
+
34
+ def test_distribution_array
35
+ @config[:test_class] || Fastlane::Saucectl::Espresso.new(@config).test_distribution
36
+ end
37
+
38
+ def suite_name(test_type)
39
+ if ENV['JOB_NAME'].nil? && ENV['BUILD_NUMBER'].nil?
40
+ "#{@config[:kind]}-#{test_type}"
41
+ else
42
+ "#{ENV['JOB_NAME']}-#{ENV['BUILD_NUMBER']}-#{test_type}"
43
+ end
44
+ end
45
+
46
+ def shard_suites
47
+ UI.user_error!("❌ Cannot split #{@config[:test_distribution]}'s across devices with a single device/emulator. \nPlease specify a minimum of two devices/emulators!") if @devices.size.eql?(1)
48
+
49
+ test_suites = []
50
+ arr = test_distribution_array
51
+ shards = arr.each_slice((arr.size / @devices.size.to_f).round).to_a
52
+ shards.each_with_index do |suite, i|
53
+ device_options = @is_real_device ? real_device_options(@devices[i]) : virtual_device_options(@devices[i])
54
+ test_suites << {
55
+ 'name' => suite_name("shard #{i + 1}").downcase,
56
+ 'testOptions' => test_option_type(suite)
57
+ }.merge(device_options)
58
+ end
59
+ test_suites
60
+ end
61
+
62
+ def test_runner_suite
63
+ test_suites = []
64
+ @devices.each do |device|
65
+ device_options = @is_real_device ? real_device_options(device) : virtual_device_options(device)
66
+ device_name = device.key?(:id) ? device[:id] : device[:name]
67
+ test_suites << {
68
+ 'name' => suite_name(device_name),
69
+ 'testOptions' => test_option_type
70
+ }.merge(device_options)
71
+ end
72
+ test_suites
73
+ end
74
+
75
+ def custom_test_class_suite
76
+ test_suites = []
77
+ @devices.each do |device|
78
+ device_options = @is_real_device ? real_device_options(device) : virtual_device_options(device)
79
+ test_classes = @config[:test_class].reject(&:empty?).join(',')
80
+ test_suites << {
81
+ 'name' => suite_name(device[:name]).downcase,
82
+ 'testOptions' => test_option_type(test_classes.split(','))
83
+ }.merge(device_options)
84
+ end
85
+ test_suites
86
+ end
87
+
88
+ def create_test_distribution_suite
89
+ if @config[:test_distribution].eql?('shard')
90
+ shard_suites
91
+ else
92
+ test_distribution_suite
93
+ end
94
+ end
95
+
96
+ def test_distribution_suite
97
+ UI.user_error!("❌ to distribute tests you must specify the path to your espresso tests. For example `path_to_tests:someModule/src/androidTest`") if @config[:path_to_tests].nil?
98
+
99
+ test_suites = []
100
+ if @config[:size] || @config[:annotation]
101
+ @devices.each do |device|
102
+ device_options = @is_real_device ? real_device_options(device) : virtual_device_options(device)
103
+ test_suites << {
104
+ 'name' => suite_name(device[:name]).downcase,
105
+ 'testOptions' => test_option_type
106
+ }.merge(device_options)
107
+ end
108
+ else
109
+ @devices.each do |device|
110
+ device_options = @is_real_device ? real_device_options(device) : virtual_device_options(device)
111
+ test_distribution_array.each do |test_type|
112
+ test_suites << {
113
+ 'name' => suite_name(test_type).downcase,
114
+ 'testOptions' => test_option_type(test_type)
115
+ }.merge(device_options)
116
+ end
117
+ end
118
+ end
119
+ test_suites
120
+ end
121
+
122
+ def virtual_device_options(device)
123
+ platform_versions = device[:platform_versions].reject(&:empty?).join(',')
124
+ { 'emulators' => [{ 'name' => device[:name],
125
+ 'orientation' => device[:orientation],
126
+ 'platformVersions' => platform_versions.split(',') }] }
127
+ end
128
+
129
+ def real_device_options(device)
130
+ { 'devices' => [rdc_options(device)] }
131
+ end
132
+
133
+ def rdc_options(device)
134
+ device_type_key = device.key?(:id) ? 'id' : 'name'
135
+ name = device.key?(:id) ? device[:id] : device[:name]
136
+
137
+ base_device_hash = {
138
+ device_type_key => name,
139
+ 'orientation' => device[:orientation]
140
+ }.merge('options' => device_options(device))
141
+
142
+ unless device[:platform_version].nil?
143
+ base_device_hash = base_device_hash.merge({ 'platformVersion' => device[:platform_version] })
144
+ end
145
+
146
+ base_device_hash
147
+ end
148
+
149
+ def device_options(device)
150
+ {
151
+ 'carrierConnectivity' => device[:carrier_connectivity],
152
+ 'deviceType' => device[:device_type].upcase!,
153
+ 'private' => device[:private]
154
+ }
155
+ end
156
+
157
+ def test_option_type(test_type = nil)
158
+ if @config[:size] || @config[:annotation]
159
+ key = @config[:size] ? 'size' : 'annotation'
160
+ value = @config[:size] || @config[:annotation]
161
+ { key => value }.merge(android_test_options)
162
+ else
163
+ if test_type.nil?
164
+ android_test_options
165
+ else
166
+ test_option_type = @config[:test_distribution].eql?('package') ? 'package' : 'class'
167
+ { test_option_type => test_type }.merge(android_test_options)
168
+ end
169
+ end
170
+ end
171
+
172
+ def android_test_options
173
+ {
174
+ 'clearPackageData' => @config[:clear_data],
175
+ 'useTestOrchestrator' => @config[:use_test_orchestrator]
176
+ }
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require 'net/http'
5
+ require 'open-uri'
6
+ require 'json'
7
+ require 'base64'
8
+ require 'timeout'
9
+ require 'fastlane_core/ui/ui'
10
+ require 'fileutils'
11
+
12
+ module Fastlane
13
+ module Saucectl
14
+ #
15
+ # This class provides the functions required to interact with the saucectl api
16
+ # for more information see: https://docs.saucelabs.com/dev/api/storage/
17
+ #
18
+ class Api
19
+ UI = FastlaneCore::UI unless Fastlane.const_defined?(:UI)
20
+
21
+ def initialize(config)
22
+ @config = config
23
+ @encoded_auth_string = Base64.strict_encode64("#{@config[:sauce_username]}:#{@config[:sauce_access_key]}")
24
+ @messages = YAML.load_file("#{__dir__}/../strings/messages.yml")
25
+ end
26
+
27
+ def available_devices
28
+ path = 'v1/rdc/devices/available'
29
+ https, url = build_http_request_for(path)
30
+ request = Net::HTTP::Get.new(url)
31
+ request['Authorization'] = "Basic #{@encoded_auth_string}"
32
+ response = https.request(request)
33
+ UI.user_error!("❌ Request failed: #{response.code} #{response.message}") unless response.kind_of?(Net::HTTPOK)
34
+
35
+ JSON.parse(response.body)
36
+ end
37
+
38
+ def fetch_ios_devices
39
+ devices = []
40
+ get_devices = available_devices
41
+ get_devices.each do |device|
42
+ devices << device if device =~ /iPhone_.*/ || device =~ /iPad_.*/
43
+ end
44
+ devices
45
+ end
46
+
47
+ def fetch_android_devices
48
+ devices = []
49
+ get_devices = available_devices
50
+ get_devices.each do |device|
51
+ devices << device unless device =~ /iPhone_.*/ || device =~ /iPad_.*/
52
+ end
53
+ devices
54
+ end
55
+
56
+ def retrieve_all_apps
57
+ UI.message("retrieving all apps for \"#{@config[:query]}\".")
58
+ path = "v1/storage/files?q=#{@config[:query]}&kind=#{@config[:platform]}"
59
+ https, url = build_http_request_for(path)
60
+ request = Net::HTTP::Get.new(url)
61
+ request['Authorization'] = "Basic #{@encoded_auth_string}"
62
+ response = https.request(request)
63
+
64
+ UI.user_error!("❌ Request failed: #{response.code} #{response.message}") unless response.kind_of?(Net::HTTPOK)
65
+
66
+ response
67
+ end
68
+
69
+ def upload
70
+ UI.message("⏳ Uploading \"#{@config[:app]}\" upload to Sauce Labs.")
71
+ path = 'v1/storage/upload'
72
+ https, url = build_http_request_for(path)
73
+ request = Net::HTTP::Post.new(url)
74
+ request['Authorization'] = "Basic #{@encoded_auth_string}"
75
+ form_data = [['payload', File.open(@config[:file])], ['name', @config[:app]]]
76
+ request.set_form(form_data, 'multipart/form-data')
77
+ response = https.request(request)
78
+ UI.success("✅ Successfully uploaded app to sauce labs: \n #{response.body}") if response.code.eql?('201')
79
+ UI.user_error!("❌ Request failed: #{response.code} #{response.message}") unless response.code.eql?('201')
80
+
81
+ response
82
+ end
83
+
84
+ def delete_app(path)
85
+ https, url = build_http_request_for(path)
86
+ request = Net::HTTP::Delete.new(url.path)
87
+ request['Authorization'] = "Basic #{@encoded_auth_string}"
88
+ response = https.request(request)
89
+ UI.success("✅ Successfully deleted app from sauce labs storage: \n #{response.body}") if response.kind_of?(Net::HTTPOK)
90
+ UI.user_error!("❌ Request failed: #{response.code} #{response.message}") unless response.kind_of?(Net::HTTPOK)
91
+
92
+ response
93
+ end
94
+
95
+ def base_url_for_region
96
+ case @config[:region]
97
+ when 'eu' then base_url('eu-central-1')
98
+ when 'us' then base_url('us-west-1')
99
+ else UI.user_error!("#{@config[:region]} is an invalid region ❌. Available: #{@messages['supported_regions']}")
100
+ end
101
+ end
102
+
103
+ def build_http_request_for(path)
104
+ url = URI("#{base_url_for_region}/#{path}")
105
+ https = Net::HTTP.new(url.host, url.port)
106
+ https.use_ssl = true
107
+ [https, url]
108
+ end
109
+
110
+ def base_url(region)
111
+ "https://api.#{region}.saucelabs.com"
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,84 @@
1
+ require 'fileutils'
2
+ require 'yaml'
3
+ require 'uri'
4
+ require 'net/http'
5
+ require 'json'
6
+ require 'base64'
7
+ require 'open3'
8
+ require_relative 'ios_suites'
9
+ require_relative 'android_suites'
10
+
11
+ module Fastlane
12
+ module Saucectl
13
+ #
14
+ # This class creates saucectl config.yml file based on given specifications
15
+ #
16
+ class ConfigGenerator
17
+ UI = FastlaneCore::UI unless Fastlane.const_defined?(:UI)
18
+
19
+ def initialize(config)
20
+ @config = config
21
+ end
22
+
23
+ def base_config
24
+ {
25
+ 'apiVersion' => 'v1alpha',
26
+ 'kind' => @config[:kind],
27
+ 'retries' => @config[:retries],
28
+ 'sauce' => {
29
+ 'region' => set_region.to_s,
30
+ 'concurrency' => @config[:max_concurrency_size]
31
+ },
32
+ (@config[:kind]).to_s => set_apps,
33
+ 'artifacts' => {
34
+ 'download' => {
35
+ 'when' => 'always',
36
+ 'match' => ['junit.xml'],
37
+ 'directory' => './artifacts/'
38
+ }
39
+ },
40
+ 'reporters' => {
41
+ 'junit' => {
42
+ 'enabled' => true
43
+ }
44
+ }
45
+ }
46
+ end
47
+
48
+ def set_region
49
+ @config[:region] == 'eu' ? 'eu-central-1' : 'us-west-1'
50
+ end
51
+
52
+ def set_apps
53
+ {
54
+ 'app' => @config[:app],
55
+ 'testApp' => @config[:test_app]
56
+ }
57
+ end
58
+
59
+ def suite
60
+ @config[:platform].eql?('ios') ? Fastlane::Saucectl::IosSuites.new(@config) : Fastlane::Saucectl::AndroidSuites.new(@config)
61
+ end
62
+
63
+ def create
64
+ UI.message("Creating saucectl config .....🚕💨")
65
+ file_name = 'config.yml'
66
+ UI.user_error!("❌ Sauce Labs platform does not support virtual device execution for ios apps") if @config[:platform].eql?('ios') && @config[:emulators]
67
+
68
+ config = base_config.merge({ 'suites' => suite.generate })
69
+ out_file = File.new(file_name, 'w')
70
+ out_file.puts(config.to_yaml)
71
+ out_file.close
72
+ creat_sauce_dir
73
+ FileUtils.move(file_name, './.sauce')
74
+ UI.message("Successfully created saucectl config ✅") if Dir.exist?('.sauce')
75
+ UI.user_error!("Failed to create saucectl config ❌") unless Dir.exist?('.sauce')
76
+ end
77
+
78
+ def creat_sauce_dir
79
+ dirname = '.sauce'
80
+ FileUtils.mkdir_p(dirname) unless File.directory?(dirname)
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,93 @@
1
+ require "find"
2
+ require "open3"
3
+ require "json"
4
+ require_relative "file_utils"
5
+
6
+ module Fastlane
7
+ module Saucectl
8
+ # This class is responsible for creating test execution plans for android applications and will distribute tests
9
+ # that will be be executed via the cloud provider.
10
+ #
11
+ class Espresso
12
+ include FileUtils
13
+ UI = FastlaneCore::UI unless Fastlane.const_defined?(:UI)
14
+
15
+ TEST_FUNCTION_REGEX = /([a-z]+[A-Z][a-zA-Z]+)[(][)]/.freeze
16
+
17
+ def initialize(config)
18
+ @config = config
19
+ end
20
+
21
+ def test_data
22
+ test_details = []
23
+ search_retrieve_test_classes(@config[:path_to_tests]).each do |f|
24
+ next unless File.basename(f) =~ CLASS_NAME_REGEX
25
+
26
+ test_details << { package: File.readlines(f).first.chomp.gsub("package ", "").gsub(";", ""),
27
+ class: File.basename(f).gsub(FILE_TYPE_REGEX, ""),
28
+ tests: tests_from(f) }
29
+ end
30
+
31
+ strip_empty(test_details)
32
+ end
33
+
34
+ def test_distribution
35
+ test_distribution_check
36
+ tests_arr = []
37
+ case @config[:test_distribution]
38
+ when "package"
39
+ test_data.each { |type| tests_arr << type[:package] }
40
+ when 'class', 'shard'
41
+ test_data.each { |type| tests_arr << "#{type[:package]}.#{type[:class]}" }
42
+ else
43
+ test_data.each do |type|
44
+ type[:tests].each { |test| tests_arr << "#{type[:package]}.#{type[:class]}##{test}" }
45
+ end
46
+ end
47
+ tests_arr.uniq
48
+ end
49
+
50
+ def test_distribution_check
51
+ return @config[:test_distribution] if @config[:test_distribution].kind_of?(Array)
52
+
53
+ distribution_types = %w[class testCase package shard]
54
+ unless distribution_types.include?(@config[:test_distribution]) || @config[:test_distribution].nil?
55
+ UI.user_error!("#{@config[:test_distribution]} is not a valid method of test distribution")
56
+ end
57
+ end
58
+
59
+ def strip_empty(test_details)
60
+ tests = []
61
+ test_details.each { |test| tests << test unless test[:tests].size.zero? }
62
+ tests
63
+ end
64
+
65
+ def tests_from(path)
66
+ stdout, = find(path, "@Test")
67
+ test_cases = []
68
+ stdout.split.each do |line|
69
+ test_cases << line.match(TEST_FUNCTION_REGEX).to_s.gsub(/[()]/, "") if line =~ TEST_FUNCTION_REGEX
70
+ end
71
+ strip_skipped(path, test_cases)
72
+ end
73
+
74
+ def fetch_disabled_tests(path)
75
+ stdout, = find(path, "@Ignore")
76
+ test_cases = []
77
+ stdout.split.each do |line|
78
+ test_cases << line.match(TEST_FUNCTION_REGEX).to_s.gsub(/[()]/, "") if line =~ TEST_FUNCTION_REGEX
79
+ end
80
+ test_cases
81
+ end
82
+
83
+ def strip_skipped(path, tests)
84
+ enabled_ui_tests = []
85
+ skipped_tests = fetch_disabled_tests(path)
86
+ tests.each do |test|
87
+ enabled_ui_tests << test unless skipped_tests.include?(test)
88
+ end
89
+ enabled_ui_tests
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,27 @@
1
+ require "open3"
2
+
3
+ # utility module for helper functions
4
+ module FileUtils
5
+ CLASS_NAME_REGEX = /(Spec|Specs|Test|Tests)/.freeze
6
+ FILE_TYPE_REGEX = /(.swift|.kt|.java)/.freeze
7
+
8
+ def read_file(name)
9
+ raise "File not found: #{name}" unless File.exist?(name)
10
+
11
+ File.read(name).split
12
+ end
13
+
14
+ def search_retrieve_test_classes(path)
15
+ Find.find(path).select do |f|
16
+ File.file?(f) if File.basename(f) =~ CLASS_NAME_REGEX
17
+ end
18
+ end
19
+
20
+ def find(class_name, regex)
21
+ syscall("find '#{class_name}' -type f -exec grep -h -C2 '#{regex}' {} +")
22
+ end
23
+
24
+ def syscall(*cmd)
25
+ Open3.capture3(*cmd)
26
+ end
27
+ end
@@ -0,0 +1,54 @@
1
+ require 'fastlane_core/ui/ui'
2
+ require 'fastlane'
3
+ require 'open-uri'
4
+ require_relative 'file_utils'
5
+
6
+ module Fastlane
7
+ module Saucectl
8
+ #
9
+ # This class provides the functions required to install the saucectl binary
10
+ #
11
+ class Installer
12
+ include FileUtils
13
+ UI = FastlaneCore::UI unless Fastlane.const_defined?(:UI)
14
+
15
+ def install(version)
16
+ timeout_in_seconds = 90
17
+ Timeout.timeout(timeout_in_seconds) do
18
+ download_saucectl_installer
19
+ execute_saucectl_installer(version)
20
+ UI.success("✅ Successfully installed saucectl runner binary 🚀")
21
+ rescue OpenURI::HTTPError => e
22
+ response = e.io
23
+ UI.user_error!("❌ Failed to install saucectl binary: status #{response.status[0]}")
24
+ end
25
+ end
26
+
27
+ def download_saucectl_installer
28
+ URI.open('sauce', 'wb') do |file|
29
+ file << URI.open('https://saucelabs.github.io/saucectl/install', ssl_verify_mode: OpenSSL::SSL::VERIFY_NONE).read
30
+ end
31
+ end
32
+
33
+ def execute_saucectl_installer(version)
34
+ saucectl_version = version[:version].nil? ? '' : "v#{version[:version]}"
35
+ status = system("sh sauce #{saucectl_version}")
36
+ status == 1 ? UI.user_error!("❌ failed to install saucectl") : status
37
+ executable = 'saucectl'
38
+ FileUtils.mv("bin/#{executable}", executable) unless File.exist?(executable)
39
+ end
40
+
41
+ def system(*cmd)
42
+ Open3.popen2e(*cmd) do |stdin, stdout_stderr, wait_thread|
43
+ Thread.new do
44
+ stdout_stderr.each do |out|
45
+ UI.message(out)
46
+ end
47
+ end
48
+ stdin.close
49
+ wait_thread.value
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end