fastlane-plugin-saucectl 0.1.0.pre

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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