fastlane-plugin-saucectl 0.1.0.pre

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,95 @@
1
+ require 'fastlane/action'
2
+ require_relative '../helper/api'
3
+
4
+ module Fastlane
5
+ module Actions
6
+ class SauceDevicesAction < Action
7
+ @messages = YAML.load_file("#{__dir__}/../strings/messages.yml")
8
+
9
+ def self.run(params)
10
+ platform = params[:platform]
11
+ case platform
12
+ when 'android'
13
+ Fastlane::Saucectl::Api.new(params).fetch_android_devices
14
+ when 'ios'
15
+ Fastlane::Saucectl::Api.new(params).fetch_ios_devices
16
+ else
17
+ Fastlane::Saucectl::Api.new(params).available_devices
18
+ end
19
+ end
20
+
21
+ def self.description
22
+ "Returns a list of Device IDs for all devices in the data center that are currently free for testing."
23
+ end
24
+
25
+ def self.details
26
+ "Returns a list of Device IDs for all devices in the data center that are currently free for testing."
27
+ end
28
+
29
+ def self.available_options
30
+ [
31
+ FastlaneCore::ConfigItem.new(key: :platform,
32
+ description: "Device platform that you wish to query",
33
+ optional: true,
34
+ is_string: true,
35
+ default_value: ''),
36
+ FastlaneCore::ConfigItem.new(key: :region,
37
+ description: "Data Center region (us or eu), set using: region: 'eu'",
38
+ optional: false,
39
+ type: String,
40
+ verify_block: proc do |value|
41
+ UI.user_error!(@messages['region_error'].gsub!('$region', value)) unless @messages['supported_regions'].include?(value)
42
+ end),
43
+ FastlaneCore::ConfigItem.new(key: :sauce_username,
44
+ env_name: "SAUCE_USERNAME",
45
+ description: "Your sauce labs username in order to authenticate upload requests",
46
+ default_value: Actions.lane_context[SharedValues::SAUCE_USERNAME],
47
+ optional: false,
48
+ type: String,
49
+ verify_block: proc do |value|
50
+ UI.user_error!(@messages['sauce_username_error']) unless value && !value.empty?
51
+ end),
52
+ FastlaneCore::ConfigItem.new(key: :sauce_access_key,
53
+ env_name: "SAUCE_ACCESS_KEY",
54
+ description: "Your sauce labs access key in order to authenticate upload requests",
55
+ default_value: Actions.lane_context[SharedValues::SAUCE_ACCESS_KEY],
56
+ optional: false,
57
+ type: String,
58
+ verify_block: proc do |value|
59
+ UI.user_error!(@messages['sauce_api_key_error']) unless value && !value.empty?
60
+ end)
61
+ ]
62
+ end
63
+
64
+ def self.authors
65
+ ["Ian Hamilton"]
66
+ end
67
+
68
+ def self.category
69
+ :testing
70
+ end
71
+
72
+ def self.is_supported?(platform)
73
+ [:ios, :android].include?(platform)
74
+ end
75
+
76
+ def self.example_code
77
+ [
78
+ "sauce_devices({platform: 'android',
79
+ region: 'eu',
80
+ sauce_username: 'foo',
81
+ sauce_access_key: 'bar123',
82
+ })",
83
+ "sauce_devices({region: 'eu',
84
+ sauce_username: 'foo',
85
+ sauce_access_key: 'bar123',
86
+ })",
87
+ "sauce_devices({region: 'us',
88
+ sauce_username: 'foo',
89
+ sauce_access_key: 'bar123',
90
+ })"
91
+ ]
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,52 @@
1
+ require_relative '../helper/runner'
2
+
3
+ module Fastlane
4
+ module Actions
5
+ class SauceRunnerAction < Action
6
+ @messages = YAML.load_file("#{__dir__}/../strings/messages.yml")
7
+
8
+ def self.run(run = '')
9
+ Saucectl::Runner.new.execute
10
+ end
11
+
12
+ def self.description
13
+ "Execute automated tests on sauce labs platform via saucectl binary for specified configuration"
14
+ end
15
+
16
+ def self.available_options
17
+ [
18
+ FastlaneCore::ConfigItem.new(key: :sauce_username,
19
+ env_name: "SAUCE_USERNAME",
20
+ default_value: Actions.lane_context[SharedValues::SAUCE_USERNAME],
21
+ description: "Your sauce labs username",
22
+ optional: false,
23
+ is_string: true,
24
+ verify_block: proc do |value|
25
+ UI.user_error!(@messages['sauce_username_error']) if value.empty?
26
+ end),
27
+ FastlaneCore::ConfigItem.new(key: :sauce_access_key,
28
+ env_name: "SAUCE_ACCESS_KEY",
29
+ default_value: Actions.lane_context[SharedValues::SAUCE_ACCESS_KEY],
30
+ description: "Your sauce labs access key",
31
+ optional: false,
32
+ is_string: true,
33
+ verify_block: proc do |value|
34
+ UI.user_error!(@messages['sauce_api_key_error']) if value.empty?
35
+ end)
36
+ ]
37
+ end
38
+
39
+ def self.authors
40
+ ["Ian Hamilton"]
41
+ end
42
+
43
+ def self.category
44
+ :testing
45
+ end
46
+
47
+ def self.is_supported?(platform)
48
+ [:ios, :android].include?(platform)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,133 @@
1
+ require 'fastlane/action'
2
+ require 'json'
3
+ require 'yaml'
4
+ require_relative '../helper/api'
5
+
6
+ module Fastlane
7
+ module Actions
8
+ module SharedValues
9
+ SAUCE_USERNAME = :SAUCE_USERNAME
10
+ SAUCE_ACCESS_KEY = :SAUCE_ACCESS_KEY
11
+ end
12
+
13
+ class SauceUploadAction < Action
14
+ @messages = YAML.load_file("#{__dir__}/../strings/messages.yml")
15
+
16
+ def self.run(params)
17
+ response = Fastlane::Saucectl::Api.new(params).upload
18
+ body = JSON.parse(response.body)
19
+ body['item']['id']
20
+ end
21
+
22
+ def self.description
23
+ "Upload test artifacts to sauce labs storage"
24
+ end
25
+
26
+ def self.details
27
+ "Upload test artifacts to sauce labs storage"
28
+ end
29
+
30
+ def self.available_options
31
+ [
32
+ FastlaneCore::ConfigItem.new(key: :platform,
33
+ description: "application under test platform (ios or android)",
34
+ optional: false,
35
+ is_string: true,
36
+ verify_block: proc do |value|
37
+ UI.user_error!(@messages['platform_error']) if value.to_s.empty?
38
+ end),
39
+ FastlaneCore::ConfigItem.new(key: :file,
40
+ description: "File to upload to sauce storage",
41
+ optional: false,
42
+ is_string: true,
43
+ verify_block: proc do |value|
44
+ UI.user_error!(@messages['file_error']) unless value && !value.empty?
45
+ if value
46
+ UI.user_error!("Could not find file to upload \"#{value}\" ") unless File.exist?(value)
47
+ extname = File.extname(value)
48
+ UI.user_error!("Extension not supported for \"#{value}\" ") unless @messages['accepted_file_types'].include?(extname)
49
+ end
50
+ end),
51
+ FastlaneCore::ConfigItem.new(key: :app,
52
+ description: "Name of the application to be uploaded",
53
+ optional: false,
54
+ is_string: true,
55
+ verify_block: proc do |value|
56
+ UI.user_error!(@messages['app_name_error']) unless value && !value.empty?
57
+ end),
58
+ FastlaneCore::ConfigItem.new(key: :region,
59
+ description: "Data Center region (us or eu), set using: region: 'eu'",
60
+ optional: false,
61
+ is_string: true,
62
+ verify_block: proc do |value|
63
+ UI.user_error!(@messages['region_error'].gsub!('$region', value)) unless @messages['supported_regions'].include?(value)
64
+ end),
65
+ FastlaneCore::ConfigItem.new(key: :sauce_username,
66
+ env_name: "SAUCE_USERNAME",
67
+ description: "Your sauce labs username in order to authenticate upload requests",
68
+ default_value: Actions.lane_context[SharedValues::SAUCE_USERNAME],
69
+ optional: false,
70
+ type: String,
71
+ verify_block: proc do |value|
72
+ UI.user_error!(@messages['sauce_username_error']) unless value && !value.empty?
73
+ end),
74
+ FastlaneCore::ConfigItem.new(key: :sauce_access_key,
75
+ env_name: "SAUCE_ACCESS_KEY",
76
+ description: "Your sauce labs access key in order to authenticate upload requests",
77
+ default_value: Actions.lane_context[SharedValues::SAUCE_ACCESS_KEY],
78
+ optional: false,
79
+ type: String,
80
+ verify_block: proc do |value|
81
+ UI.user_error!(@messages['sauce_api_key_error']) unless value && !value.empty?
82
+ end)
83
+ ]
84
+ end
85
+
86
+ def self.authors
87
+ ["Ian Hamilton"]
88
+ end
89
+
90
+ def self.category
91
+ :testing
92
+ end
93
+
94
+ def self.is_supported?(platform)
95
+ [:ios, :android].include?(platform)
96
+ end
97
+
98
+ def self.example_code
99
+ [
100
+ "sauce_upload({
101
+ platform: 'android',
102
+ sauce_username: 'username',
103
+ sauce_access_key: 'accessKey',
104
+ app: 'Android.MyCustomApp.apk',
105
+ file: 'app/build/outputs/apk/debug/app-debug.apk',
106
+ region: 'eu'
107
+ })",
108
+ "sauce_upload({
109
+ platform: 'android',
110
+ sauce_username: 'username',
111
+ sauce_access_key: 'accessKey',
112
+ app: 'Android.MyCustomApp.apk',
113
+ file: 'app/build/outputs/apk/debug/app-debug.apk',
114
+ region: 'eu',
115
+ app_description: 'this is a test description'
116
+ })",
117
+ "sauce_upload({
118
+ platform: 'ios',
119
+ sauce_username: 'username',
120
+ sauce_access_key: 'accessKey',
121
+ app: 'MyTestApp.ipa',
122
+ file: 'path/to/my/app/MyTestApp.ipa',
123
+ region: 'eu'
124
+ })"
125
+ ]
126
+ end
127
+
128
+ def self.return_value
129
+ "Returns the application id of the app uploaded"
130
+ end
131
+ end
132
+ end
133
+ 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,97 @@
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 'suites'
9
+
10
+ module Fastlane
11
+ module Saucectl
12
+ #
13
+ # This class creates saucectl config.yml file based on given specifications
14
+ #
15
+ class ConfigGenerator
16
+ UI = FastlaneCore::UI unless Fastlane.const_defined?(:UI)
17
+
18
+ def initialize(config)
19
+ @config = config
20
+ end
21
+
22
+ def base_config
23
+ {
24
+ 'apiVersion' => 'v1alpha',
25
+ 'kind' => @config[:kind],
26
+ 'retries' => @config[:retries],
27
+ 'sauce' => {
28
+ 'region' => set_region.to_s,
29
+ 'concurrency' => @config[:max_concurrency_size],
30
+ 'metadata' => {
31
+ 'name' => "#{ENV['JOB_NAME']}-#{ENV['BUILD_NUMBER']}",
32
+ 'build' => "Release #{ENV['CI_COMMIT_SHORT_SHA']}"
33
+ }
34
+ },
35
+ (@config[:kind]).to_s => set_apps,
36
+ 'artifacts' => {
37
+ 'download' => {
38
+ 'when' => 'always',
39
+ 'match' => ['junit.xml'],
40
+ 'directory' => './artifacts/'
41
+ }
42
+ },
43
+ 'reporters' => {
44
+ 'junit' => {
45
+ 'enabled' => true
46
+ }
47
+ }
48
+ }
49
+ end
50
+
51
+ def set_region
52
+ case @config[:region]
53
+ when 'eu'
54
+ 'eu-central-1'
55
+ else
56
+ 'us-west-1'
57
+ end
58
+ end
59
+
60
+ def set_apps
61
+ {
62
+ 'app' => @config[:app],
63
+ 'testApp' => @config[:test_app]
64
+ }
65
+ end
66
+
67
+ def create
68
+ UI.message("Creating saucectl config .....🚕💨")
69
+ file_name = 'config.yml'
70
+ UI.user_error!("❌ Sauce Labs platform does not support virtual device execution for ios apps") if @config[:platform].eql?('ios') && @config[:emulators]
71
+
72
+ config = base_config.merge(create_suite)
73
+ out_file = File.new(file_name, 'w')
74
+ out_file.puts(config.to_yaml)
75
+ out_file.close
76
+ creat_sauce_dir
77
+ FileUtils.move(file_name, './.sauce')
78
+ UI.message("Successfully created saucectl config ✅") if Dir.exist?('.sauce')
79
+ UI.user_error!("Failed to create saucectl config ❌") unless Dir.exist?('.sauce')
80
+ end
81
+
82
+ def create_suite
83
+ suite = Fastlane::Saucectl::Suites.new(@config)
84
+ { 'suites' => if @config[:emulators]
85
+ suite.create_virtual_device_suites
86
+ else
87
+ suite.create_real_device_suites
88
+ end }
89
+ end
90
+
91
+ def creat_sauce_dir
92
+ dirname = '.sauce'
93
+ FileUtils.mkdir_p(dirname) unless File.directory?(dirname)
94
+ end
95
+ end
96
+ end
97
+ 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 ios 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,51 @@
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
16
+ timeout_in_seconds = 30
17
+ Timeout.timeout(timeout_in_seconds) do
18
+ download_saucectl_installer
19
+ execute_saucectl_installer
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').read
30
+ end
31
+ end
32
+
33
+ def execute_saucectl_installer
34
+ status = system('sh sauce')
35
+ status == 1 ? UI.user_error!("❌ failed to install saucectl: #{stderr}") : status
36
+ executable = 'saucectl'
37
+ FileUtils.mv("bin/#{executable}", executable) unless File.exist?(executable)
38
+ end
39
+
40
+ def system(*cmd)
41
+ Open3.popen2e(*cmd) do |stdin, stdout_stderr, wait_thread|
42
+ Thread.new do
43
+ stdout_stderr.each { |out| UI.message(out) }
44
+ end
45
+ stdin.close
46
+ wait_thread.value
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end