require 'thor' require 'yaml' require 'erb' require 'rubygems' require 'zip' require 'digest' require 'json' require 'rbconfig' require 'tmpdir' require 'fileutils' require 'retriable' require 'xamarin-test-cloud/version' require 'xamarin-test-cloud/retriable_options' require 'xamarin-test-cloud/http/request' require 'xamarin-test-cloud/http/retriable_client' require 'xamarin-test-cloud/environment' require "xamarin-test-cloud/dsym" require "xamarin-test-cloud/test_file" require "xamarin-test-cloud/calabash_version_detector" require 'securerandom' require 'open3' require "bundler" require 'pathname' trap "SIGINT" do puts "Exiting" exit 10 end module XamarinTestCloud class ValidationError < Thor::InvocationError end class CLI < Thor include Thor::Actions attr_accessor :app, :api_key, :appname, :test_parameters, :user, :config, :profile, :skip_config_check, :dry_run, :device_selection, :pretty, :async, :async_json, :priority, :endpoint_path, :include_files_input, :locale, :language, :series, :session_id attr_reader :included_files_map def self.exit_on_failure? true end def initialize(*args) self.session_id = SecureRandom.hex begin r = JSON.parse(http_post("check_version", {args: ARGV})) if r["error_message"] puts r["error_message"] exit 1 end rescue end super(*args) end FILE_UPLOAD_ENDPOINT = 'upload2' FORM_URL_ENCODED_ENDPOINT = 'upload' MANIFEST_SCHEMA_VERSION = "1.0.0" def self.source_root File.join(File.dirname(__FILE__), '..') end desc 'version', 'Prints version of the xamarin-test-cloud gem' def version puts XamarinTestCloud::VERSION end desc 'submit ', 'Submits your app and test suite to Xamarin Test Cloud' method_option 'app-name', :desc => 'App name to create or add test to', :aliases => '-a', :required => false, :type => :string method_option 'devices', :desc => 'Device selection', :aliases => '-d', :required => true, :type => :string method_option 'test-parameters', :desc => 'Test parameters (e.g., -params username:nat@xamarin.com password:xamarin)', :aliases => '-params', :type => :hash method_option :workspace, :desc => 'Path to your Calabash workspace (containing your features folder)', :aliases => '-w', :type => :string method_option :config, :desc => 'Cucumber configuration file (cucumber.yml)', :aliases => '-c', :type => :string method_option 'skip-config-check', :desc => "Force running without Cucumber profile (cucumber.yml)", :type => :boolean, :default => false method_option :profile, :desc => 'Profile to run (profile from cucumber.yml)', :aliases => '-p', :type => :string method_option :pretty, :desc => 'Pretty print output.', :type => :boolean, :default => false method_option :async, :desc => "Don't block waiting for test results.", :type => :boolean, :default => false method_option 'async-json', :desc => "Don't block waiting for test results. Output results in json format.", :type => :boolean, :default => false method_option :priority, :desc => "REMOVED. You should not pass this option.", :type => :boolean, :default => false # A list of items which are files or folders in the workspace. # Must be a path relative to workspace and # must not refer outside (e.g. no usage of ../ etc). Example --include .xtc foo method_option 'include', :desc => 'Include folders or files in workspace in addition the features folder.', :aliases => '-i', :type => :array, :default => [] method_option 'dry-run', :desc => "Sanity check only, don't upload", :aliases => '-s', :type => :boolean, :default => false #do upload by default method_option 'locale', :desc => "System locale", :type => :string method_option 'language', :desc => "Override language (iOS only)", :type => :string method_option 'series', :desc => "Test series", :type => :string method_option 'dsym-file', :desc => 'Optional dSym file for iOS Crash symbolication', :aliases => '-y', :required => false, :type => :string method_option 'user', :desc => 'Email address of the user uploading', :aliases => '-u', :required => false, :type => :string def submit(app, api_key) self.pretty = options[:pretty] self.async_json = options['async-json'] self.async = options[:async] || self.async_json # Async mode wraps all console output in a json object # So we need to intercept all writes to $stdout if self.async_json @async_log = StringIO.new @async_result = { test_run_id: nil, error_messages: [], log: [] } $stdout = @async_log end # Verifies the app argument and sets the @app instance variable verify_app!(app) self.user = options['user'] self.dry_run = options['dry-run'] self.api_key = api_key self.test_parameters = options['test-parameters'] || {} self.appname = options['app-name'] self.device_selection = options['devices'] device_selection_data = validate_device_selection self.locale = options['locale'] self.language = options['language'] self.series = options['series'] self.priority = options['priority'] self.include_files_input = options['include'] # Resolves the workspace and sets the @derived_workspace variable. expect_features_directory_in_workspace # Verifies the --config and --profile options and sets these variables: # @config, @profile, @skip_config_check verify_cucumber_config_and_profile! if Environment.debug? puts "Host = #{self.host}" puts "User = #{self.user}" puts "App = #{self.app}" puts "App Name = #{self.app}" puts "TestParams = #{self.test_parameters}" puts "API Key = #{self.api_key}" puts "Device Selection = #{self.device_selection}" puts "Workspace = #{derived_workspace}" puts "Config = #{self.config}" puts "Profile = #{self.profile}" puts "dSym = #{self.dsym}" puts "Include = #{included_files_map}" puts "Locale = #{self.locale}" puts "Language = #{self.language}" end # Argument parsing done test_jon_data = submit_test_job(device_selection_data) if self.dry_run return end json = test_jon_data[:body] if Environment.debug? p json end warn_about_priority_flag(options) log_header('Test enqueued') puts "User: #{json['user_email']}" puts "Team: #{json['team']}" if json['team'] rejected_devices = json['rejected_devices'] if rejected_devices && rejected_devices.size > 0 # TODO Break this into multiple lines and remove the parenthetical puts 'Skipping devices (you can update your selections via https://testcloud.xamarin.com)' rejected_devices.each { |d| puts d } end puts '' puts 'Running on Devices:' json['devices'].each do |device| puts device end puts '' unless self.async wait_for_job(json['id']) else log 'Async mode: not awaiting test results' @async_result[:test_run_id] = json['id'] if self.async_json end rescue XamarinTestCloud::ValidationError => e if self.async_json @async_result[:error_messages] << e.message else raise end ensure $stdout = STDOUT if self.async_json process_async_log puts @async_result.to_json end end desc "prepare ", "Prepares workspace and creates a manifest of files to be uploaded for test" method_option :config, :desc => "Cucumber configuration file (cucumber.yml)", :aliases => "-c", :type => :string method_option :profile, :desc => "Profile to run (profile from Cucumber configuration file)", :aliases => "-p", :type => :string method_option :workspace, :desc => "Alternate path to your Calabash workspace", :aliases => "-w", :type => :string method_option "test-parameters", :desc => "Example: -params username:nat@xamarin.com password:xamarin)", :aliases => "-params", :type => :hash, :default => {} method_option "skip-config-check", :desc => "Force running without Cucumber profile (cucumber.yml)", :type => :boolean, :default => false method_option "artifacts-dir", :desc => "Directory where the test files should be staged.", :type => :string, :required => true # A list of items which are files or folders in the workspace. # Must be a path relative to workspace and # must not refer outside (e.g. no usage of ../ etc). Example --include .xtc foo method_option 'include', :desc => 'Include folders or files in workspace in addition the features folder.', :aliases => '-i', :type => :array, :default => [] def prepare(app) @include_files_input = options[:include] artifact_directory = File.expand_path(options["artifacts-dir"]) # Validates and sets the @app instance variable. verify_app!(app) # Resolves the workspace and sets the @derived_workspace variable. expect_features_directory_in_workspace # Verifies the --config and --profile options and sets these variables: # @config, @profile, @skip_config_check verify_cucumber_config_and_profile! require "xamarin-test-cloud/tmpdir" bundle_package_dir = XamarinTestCloud::TmpDir.mktmpdir stage_gemfile_and_bundle_package(bundle_package_dir) files = collect_test_suite_files(bundle_package_dir) FileUtils.mkdir_p(artifact_directory) manifest_file = File.join(artifact_directory, "manifest.json") log_header("Preparing the Artifact Directory") test_parameters = options["test-parameters"] test_parameters["profile"] = options[:profile] if options[:profile] manifest = generate_manifest_hash(files, test_parameters) write_manifest_to_file(manifest, manifest_file) puts "" puts "Copied files to:" puts " #{File.expand_path(artifact_directory)}" puts "" puts "Wrote upload manifest to:" puts " #{File.expand_path(manifest_file)}" files.each do |test_file| target = File.join(artifact_directory, test_file.remote_path) basedir = File.dirname(target) FileUtils.mkdir_p(basedir) FileUtils.cp(test_file.path, target) end end default_task :submit no_tasks do # TODO: move to separate module/class def expect_app_exists_at_path(app_path) unless File.exist?(File.expand_path(app_path)) raise ValidationError, %Q[ App does not exist at path: #{File.expand_path(app_path)} ] end end # TODO: move to separate module/class def expect_apk_or_ipa(app_path) app_extension = File.extname(app_path) unless /ipa/i.match(app_extension) || /apk/i.match(app_extension) raise ValidationError, %Q[ App must be an .ipa or .apk file. Found: #{app_path} with extension: #{app_extension} ] end end # TODO: move to separate module/class def expect_no_shared_runtime(app_path) if shared_runtime?(app_path) raise ValidationError, %Q[ Xamarin Test Cloud does not support shared runtime apps. To test your app it needs to be compiled for release. You can learn how to compile your app for release here: http://docs.xamarin.com/guides/android/deployment%2C_testing%2C_and_metrics/publishing_an_application/part_1_-_preparing_an_application_for_release ] end end # TODO: move to separate module/class def expect_ipa_linked_with_calabash(app_path) # Cannot use #is_ios because that method depends on the @app instance # variable which is not set when this method is called. return nil if !app_path.end_with?(".ipa") # otool does not exist in non-macOS environments. We have to rely on # the Test Cloud to perform the validation. return nil if !Environment.macos_env? # This will allow the automatic dylib injection on Test Cloud parameters = options["test-parameters"] || {} return nil if parameters[:skip_tca_check] || parameters["skip_tca_check"] require "xamarin-test-cloud/tmpdir" tmpdir = TmpDir.mktmpdir unzip_file(app_path, tmpdir) payload_dir = File.join(tmpdir, "Payload") if !File.directory?(payload_dir) raise ValidationError, %Q[ Did not find a Payload directory after expanding the ipa. #{app_path} The .ipa was not packaged correctly. ] end app_dir = Dir.foreach("#{tmpdir}/Payload").find { |d| /\.app$/.match(d) } res = check_for_calabash_server(tmpdir, app_dir) if /CalabashServer/.match(res) puts "ipa: #{app_path} *contains* calabash.framework" else raise ValidationError, %Q[ The ipa does not contain an app that is linked with Calabash. Your app must link the calabash.framework at compile time or it must dynamically load (in code) a Calabash dylib. https://github.com/calabash/calabash-ios/wiki/Tutorial%3A-How-to-add-Calabash-to-Xcode ] end end def verify_app!(app_path) expanded = File.expand_path(app_path) expect_app_exists_at_path(expanded) expect_apk_or_ipa(expanded) expect_no_shared_runtime(expanded) expect_ipa_linked_with_calabash(expanded) @app = expanded end def dsym if @dsym.nil? value = options["dsym-file"] if !value @dsym = false else begin @dsym = XamarinTestCloud::Dsym.new(value) rescue RuntimeError => e raise(ValidationError, e.message) end end end @dsym end # TODO Capture the log output when --async-json?; warn ==> stderr def warn_about_priority_flag(options) if options["priority"] log_warn = lambda do |string| if XamarinTestCloud::Environment.windows_env? message = "WARN: #{string}" else message = "\033[34mWARN: #{string}\033[0m" end warn message end log_header("Detected Legacy Option") puts "" log_warn.call("The --priority option has been removed.") log_warn.call("Priority execution is enabled automatically for Enterprise subscriptions.") true else false end end def process_async_log @async_result[:log] = @async_log.string .split(/\n/).map { |string| string.gsub(/\e\[(\d+)m/, '').strip } .select { |string| string.length > 0 } end def exit_on_failure? true end def wait_for_job(id) retry_opts = XamarinTestCloud::RetriableOptions.tries_and_interval(60, 10) while(true) status_json = Retriable.retriable(retry_opts) do JSON.parse(http_post("status_v3", {'id' => id, 'api_key' => api_key, 'user' => user})) end if Environment.debug? log "Status JSON result:" puts status_json end wait_time = (Integer status_json["wait_time"] rescue nil) || 10 if Environment.debug? wait_time = 1 end log status_json["message"] if status_json["exit_code"] exit Integer status_json["exit_code"] end sleep wait_time end end def validate_device_selection return device_selection if device_selection == device_selection.upcase #Allow for special device selections to be passed to the server. Needs to been in all caps. unless /^[0-9a-fA-F]{8,12}$/ =~ device_selection raise ValidationError, 'Device selection is not in the proper format. Please generate a new one on the Xamarin Test Cloud website.' end device_selection end def workspace_gemfile File.join(derived_workspace, 'Gemfile') end def workspace_gemfile_lock File.join(derived_workspace, 'Gemfile.lock') end def submit_test_job(device_selection_data) # See docs in TmpDir require "xamarin-test-cloud/tmpdir" tmpdir = XamarinTestCloud::TmpDir.mktmpdir if Environment.debug? log "Packaging gems in: #{tmpdir}" end stage_gemfile_and_bundle_package(tmpdir) log_header('Verifying dependencies') # TODO Move to common expect_valid_application if is_android? expect_android_test_server end # TODO this happens too soon, it should collect the files to upload if dry_run log_header('Dry run only') log('Dry run completed OK') return end # TestFile instances test_suite_files = collect_test_suite_files(tmpdir) negotiated = negotiate_contents_of_upload(test_suite_files) app_digest_or_file = negotiated[:app_digest_or_file] dsym_digest_or_file = negotiated[:dsym_digest_or_file] digests_and_files = negotiated[:digests_and_files] remote_paths = negotiated[:remote_paths] log_header('Uploading negotiated files') upload_data = { 'calabash' => true, 'files' => digests_and_files, 'paths' => remote_paths, 'user' => self.user, 'client_version' => XamarinTestCloud::VERSION, 'app_file' => app_digest_or_file, 'device_selection' => device_selection_data, 'app' => self.appname, 'test_parameters' => self.test_parameters, 'locale' => self.locale, 'series' => self.series, 'api_key' => api_key, 'app_filename' => File.basename(app) } upload_data['language'] = self.language if self.language if dsym upload_data["dsym_file"] = dsym_digest_or_file upload_data["dsym_filename"] = dsym.remote_path(app) else upload_data["dsym_file"] = nil upload_data["dsym_filename"] = nil end if profile #only if config and profile upload_data['profile'] = profile end if Environment.debug? puts JSON.pretty_generate(upload_data) end contains_file = digests_and_files.find { |f| f.is_a?(File) } contains_file = contains_file || app_digest_or_file.is_a?(File) if contains_file self.endpoint_path = FILE_UPLOAD_ENDPOINT #nginx receives upload else self.endpoint_path = FORM_URL_ENCODED_ENDPOINT #ruby receives upload end if Environment.debug? puts "Will upload to file path: #{self.endpoint_path}" end response = http_post(endpoint_path, upload_data) do |response, request| if Environment.debug? puts "Request url: #{self.host}/#{request.route}" puts "Response code: #{response.code}" puts "Response body: #{response.body}" end case response.status_code when 200..202 response when 400 error_message = JSON.parse(response.body)['error_message'] rescue 'Bad request' log_and_abort(error_message) when 403 error_message = JSON.parse(response.body)['message'] rescue 'Forbidden' log_and_abort(error_message) when 413 error_message = 'Files are too large' log_and_abort(error_message) else error_message = 'Unexpected Error. Please contact support at testcloud@xamarin.com.' log_and_abort(error_message) end end return :status => response.status_code, :body => JSON.parse(response.body) end def app_and_dsym_details dsym_digest = nil dsym_file = nil if dsym dsym_file = dsym.symbol_file dsym_digest = digest(dsym_file) end { :app_digest => digest(app), :dsym_digest => dsym_digest, :dsym_file => dsym_file } end def http_fetch_remote_digests(file_digests, app_digest, dsym_digest) parameters = { "hashes" => file_digests, "app_hash" => app_digest, "dsym_hash" => dsym_digest } response = http_post("check_hash", parameters) JSON.parse(response) end # @param [Array] test_files A list of TestFile instances. # @param [Hash] cache_status => true/false pairs based on whether # or not the server (Test Cloud) already has this file. def negotiated_digests_files_and_remote_paths(test_files, cache_status) digests_and_files = [] remote_paths = [] test_files.each do |test_file| if cache_status[test_file.digest] # Server already knows about this file; don't upload it. digests_and_files << test_file.digest else # Upload file digests_and_files << test_file.file_instance end remote_paths << test_file.remote_path end { :digests_and_files => digests_and_files, :remote_paths => remote_paths } end # test_files is a list of TestFile instance for the assets in: # # 1. /vendor/cache/* # 2. features/**/* # 3. test_server/ # 4. The cucumber configuration file # # 3 and 4 are only present if they exist. # # @param [Array] test_files a list of TestFile instances def negotiate_contents_of_upload(test_files) log_header('Calculating digests') file_digests = collect_test_suite_file_digests(test_files) log_header('Negotiating upload') app_and_dsym = app_and_dsym_details app_digest = app_and_dsym[:app_digest] dsym_digest = app_and_dsym[:dsym_digest] server_digests = http_fetch_remote_digests(file_digests, app_digest, dsym_digest) log_header('Gathering files based on negotiation') negotiated = negotiated_digests_files_and_remote_paths(test_files, server_digests) digests_and_files = negotiated[:digests_and_files] remote_paths = negotiated[:remote_paths] if server_digests[app_digest] app_digest_or_file = app_digest else app_digest_or_file = File.new(app) end dsym_digest_or_file = nil if dsym if server_digests[dsym_digest] dsym_digest_or_file = dsym_digest else dsym_file = app_and_dsym[:dsym_file] dsym_digest_or_file = File.new(dsym_file) end end { :app_digest_or_file => app_digest_or_file, :dsym_digest_or_file => dsym_digest_or_file, :digests_and_files => digests_and_files, :remote_paths => remote_paths } end def digest(file) Digest::SHA256.file(file).hexdigest end def unzip_file (file, destination) Zip::File.open(file) { |zip_file| zip_file.each { |f| f_path=File.join(destination, f.name) FileUtils.mkdir_p(File.dirname(f_path)) zip_file.extract(f, f_path) unless File.exist?(f_path) } } end def is_android? app.end_with? '.apk' end def is_ios? app.end_with? '.ipa' end def is_calabash_2? calabash_2_version end # Valid gem keywords are: :calabash, :android, :ios # This is potentially very expensive because there is at least one shell # call and possibly a `bundle install`. We only want to call this method # once. def detect_calabash_version(gem_keyword) begin # Raises RuntimeError if there is an error shelling out. # Raises ArgumentError on incorrect (logical) usage. # Returns nil if there is no version information available. CalabashVersionDetector.new(derived_workspace, gem_keyword).version rescue RuntimeError => e raise(ValidationError, e.message) end end # Memoize: we only want to call detect_calabash_version once. def calabash_2_version if @calabash_2_version.nil? version = detect_calabash_version(:calabash) if version @calabash_2_version = version else @calabash_2_version = false end end @calabash_2_version end # TODO: this should be called as part of a validation step. # Memoize: we only want to call detect_calabash_version once. def calabash_android_version return calabash_2_version if is_calabash_2? if @calabash_android_version.nil? version = detect_calabash_version(:android) if version @calabash_android_version = version else @calabash_android_version = false end end @calabash_android_version end # TODO: this should be called as part of a validation step. # Memoize: we only want to call detect_calabash_version once. def calabash_ios_version return calabash_2_version if is_calabash_2? if @calabash_ios_version.nil? version = detect_calabash_version(:ios) if version @calabash_ios_version = version else @calabash_ios_version = false end end @calabash_ios_version end # TODO What to do about logical errors? def android_test_server @android_test_server ||= begin if !is_android? raise RuntimeError, %Q[ This method cannot be called on a non-android project. Please send a bug report to testcloud@xamarin.com that includes: 1. The exact command you are running. 2. The following stack trace. ] else digest = XamarinTestCloud::TestFile.file_digest(app, :MD5) name = "#{digest}_#{calabash_android_version}.apk" basedir = derived_workspace file = File.join(basedir, 'test_servers', name) TestFile.new(file, basedir) end end end def expect_android_test_server return nil if !is_android? path = android_test_server.path return true if File.exist?(path) if is_calabash_2? build_command = "calabash build #{app}" else build_command = "calabash-android build #{app}" end raise ValidationError, %Q[ No test server '#{path}' found. Please run: #{build_command} and try submitting again. ] end def collect_test_suite_files(tmpdir) files = collect_files_from_features + collect_files_from_tmpdir(tmpdir) + collect_gemfile_files(tmpdir) + collect_included_files(tmpdir) if config files << config end if is_android? files << android_test_server end files end def collect_file?(path) File.file?(path) && File.basename(path) != ".DS_Store" && !path[/__MACOSX/] end # Returns file paths as Strings def collect_files_with_glob(glob) Dir.glob(glob, File::FNM_DOTMATCH).select do |path| collect_file?(path) end end # Returns TestFile instances. def collect_files_from_features basedir = derived_workspace glob = File.join(basedir, "features", "**", "*") collect_files_with_glob(glob).map do |file| TestFile.new(file, basedir) end end # Returns TestFile instances. def collect_files_from_tmpdir(tmpdir) glob = File.join(tmpdir, "vendor", "cache", "*") collect_files_with_glob(glob).map do |file| TestFile.new(file, tmpdir) end end # Returns TestFile instances. def collect_gemfile_files(tmpdir) glob = File.join(tmpdir, "{Gemfile,Gemfile.lock}") collect_files_with_glob(glob).map do |file| TestFile.new(file, tmpdir) end end def collect_included_files(tmpdir) collected_files = [] included_files_map.each_pair do |source, target| # Use File.expand_path to handle the Windows case where the user passes: # --workspace "test-cloud\ios" # --include ".xtc\sample.txt" # # A File.join will result in: # /path/to/test-cloud/ios/.xtc\sample.txt # because the join naively appends the ".xtc\sample.txt" # Use File.expand_path to handle the / tmp_target = File.expand_path(File.join(tmpdir, target)) FileUtils.mkdir_p(File.dirname(tmp_target)) FileUtils.cp_r(source, tmp_target) if File.directory?(tmp_target) collect_files_with_glob(File.join(tmpdir,target,"**","*")).map do |file| collected_files << TestFile.new(file, tmpdir) end else collected_files << TestFile.new(tmp_target, tmpdir) end end collected_files end # Returns a list of digests # @param [Array] test_files A list of TestFile instances. def collect_test_suite_file_digests(test_files) test_files.map do |test_file| test_file.digest end end def host ENV['XTC_ENDPOINT'] || 'https://testcloud.xamarin.com/ci' end def http_post(address, args = {}, &block) args['uploader_version'] = XamarinTestCloud::VERSION args['session_id'] = session_id request = XamarinTestCloud::HTTP::Request.new(address, args) exec_options = {:header => { 'Content-Type' => 'application/x-www-form-urlencoded' }} if ENV['XTC_USERNAME'] && ENV['XTC_PASSWORD'] exec_options[:user] = ENV['XTC_USERNAME'] exec_options[:password] = ENV['XTC_PASSWORD'] end client = XamarinTestCloud::HTTP::RetriableClient.new("#{host}/") if block_given? exec_options[:header] = {'Content-Type' => 'multipart/form-data'} # How long to wait for the files to upload. exec_options[:send_timeout] = 30 * 60 # There is some strange behavior here. The original timeout value # was very large: 90000000. With a large send_timeout and a modest # receive_timeout, it is possible to get a ReceiveTimeoutError at a # a random time between ~4 and ~20 minutes. The current working # theory is that the original, very large, timeout is hiding some # unexpected behavior on the server. # # In any event, if send_timeout is exceeeded, the client will raise # a SendTimeoutError so this value can be very large. exec_options[:timeout] = exec_options[:send_timeout] + 10 # Do not retry. Files are written to a stream; let the upload # succeed or fail on the first pass. exec_options[:retries] = 1 response = client.post(request, exec_options) block.call(response, request) response else response = client.post(request, exec_options) response.body end end def log(message) if message.is_a? Array message.each { |m| log(m) } else puts "#{Time.now } #{message}" $stdout.flush end end def log_header(message) if XamarinTestCloud::Environment.windows_env? puts "\n### #{message} ###" else puts "\n\e[#{35}m### #{message} ###\e[0m" end end # TODO Rename or remove; abort is not the right verb - use exit # +1 for remove def abort(&block) yield block exit 1 end # TODO Rename or remove; abort is not the right verb - use exit # +1 for remove def abort_unless(condition, &block) unless condition yield block exit 1 end end # TODO Rename or remove; abort is not the right verb - use exit # +1 for remove def log_and_abort(message) raise XamarinTestCloud::ValidationError.new(message) if self.async_json abort do print 'Error: ' puts message end end # TODO Move to a module # TODO Needs tests def shared_runtime?(app_path) f = files(app_path) f.any? do |file| filename = file[:filename] if filename.end_with?("libmonodroid.so") file[:size] < 120 * 1024 && f.none? do |x| x[:filename] == filename.sub("libmonodroid.so", "libmonosgen-2.0.so") end end end end # TODO One caller #shared_runtime? move to a module def files(app) Zip::File.open(app) do |zip_file| zip_file.collect do |entry| {:filename => entry.to_s, :size => entry.size} end end end # TODO: extract this method to a module # TODO: .config/cucumber.yml is a valid cucumber profile # TODO: cucumber.yaml and cucumber.yml are both valid names # # Raises if there is config/cucumber.yml file but no --config option is # passed. # # Check can be skipped by passing --skip-config-check. # # This method, sadly, has a preconditions: @config and @skip_config_check # must be set correctly before calling this method. def expect_config_option_if_cucumber_config_file_exists return nil if config return nil if skip_config_check default_config = File.join(derived_workspace, "config", "cucumber.yml") if File.exist?(default_config) raise ValidationError, %Q[ Detected a cucumber configuration file, but no --config option was specified. #{default_config} You can pass the cucumber configuration file to the uploader using: --config #{default_config} and specify a profile using the --profile option. You can skip this check using the --skip-config-check option, but this is not recommended. ] end end # TODO: extract this method to a module def resolve_cucumber_config_path(path) return nil if !path expanded = File.expand_path(path) return expanded if expanded == path workspace = derived_workspace # Use File.expand_path to handle the Windows case where the user passes: # --workspace "test-cloud\ios" # --config "config\cucumber.yml" # # A File.join will result in: # /path/to/test-cloud/ios/config\cucumber.yml # because the join naively appends the "config\cucumber.yml". File.expand_path(File.join(workspace, path)) end # TODO: extract this method to a module # # Sets @config to a TestFile instance # Sets @skip_config_check to option passed on the CLI # Sets @profile to option passed on the CLI # # Raises if there is problem parsing or interpreting the profile with # respect to the configuration file. # # Raises if there is config/cucumber.yml file but no --config option is # passed. def verify_cucumber_config_and_profile! config = options[:config] profile = options[:profile] if profile && !config raise ValidationError, %Q[ The --profile option was set without a --config option. If a --profile is specified, then a cucumber configuration file is required. https://github.com/cucumber/cucumber/wiki/cucumber.yml ] end # Defaults to false @skip_config_check = options["skip-config-check"] if !config @config = nil @profile = nil expect_config_option_if_cucumber_config_file_exists else config_path = resolve_cucumber_config_path(config) if !File.exist?(config_path) raise ValidationError, %Q[ File specified by --config option does not exist: #{config_path} ] end if profile expect_profile_exists_in_cucumber_config(config_path, profile) @profile = profile else @profile = nil end @config = TestFile.cucumber_config(config_path) end end # TODO: extract this method to a module def expect_profile_exists_in_cucumber_config(config_path, profile) config_yml = {} begin config_yml = YAML.load(ERB.new(File.read(config_path)).result) rescue => e raise ValidationError, %Q[ #{e.message} Unable to parse --config option as yml: #{config_path} ] end if Environment.debug? puts "Parsed Cucumber config as:" puts config_yml.inspect end if !config_yml[profile] raise ValidationError, %Q[ The cucumber configuration file specified by the --config option does not contain the profile specified by --profile option: :config => #{config_path} :profile => #{profile} ] end end # TODO Make --include'd paths generic when possible (see code comment below) def generate_manifest_hash(test_files, test_parameters) files = test_files.map do |test_file| path = test_file.remote_path if path[/features/] "features" elsif path[/vendor/] "vendor" else # --include'd files. It is not possible to filter these to a single # directory without inspecting the --include option itself. # Consider the example where --include is `config/xtc-users.json`. # We cannot filter to `config` because the config directory might # include a cucumber.yml or some other file that the user does _not_ # want to upload. path end end.uniq! { schemaVersion: MANIFEST_SCHEMA_VERSION, files: files, testFramework: { name: "calabash", data: test_parameters } } end def write_manifest_to_file(manifest, manifest_file) json = JSON.pretty_generate(manifest) File.open(manifest_file, "w:UTF-8") do |file| file.write(json) end end def included_files_map @included_files_map ||= parse_included_folders end def parse_included_folders basedir = derived_workspace source_target_map = {} include_files_input.each do |file| source = File.join(basedir, file) expect_valid_included_file(source, file) source_target_map[source] = file end source_target_map end def expect_valid_included_file(src, tgt) unless File.exists?(src) raise ValidationError, %Q[ Requested include folder: #{src} does not exist. Specified include options: #{self.include_files_input} ] end tgt_pathname = Pathname.new(tgt) if tgt_pathname.absolute? raise ValidationError, %Q[ Only relative target path names are allowed. Requested target folder: #{tgt} is an absolute file name. Specified include options: #{self.include_files_input}" ] end if /\.\./.match(tgt) raise ValidationError, %Q[ Only expanded relative target paths are allowed. Requested target folder includes '..'. Specified include options: #{self.include_files_input}" ] end end # TODO rename to verify_workspace! def expect_features_directory_in_workspace path = derived_workspace features = File.join(path, "features") return true if File.directory?(features) log_header("Missing features Directory") raise(ValidationError, %Q[Did not find a features directory in workspace: #{path} You have two options: 1. Run the test-cloud submit command in the directory that contains your features directory or 2. Use the --workspace option point to the directory that contains your features directory. See also $ test-cloud help submit ]) end def derived_workspace @derived_workspace ||= begin path = detect_workspace(options) expect_workspace_directory(path) workspace_basename = File.basename(path) if workspace_basename.downcase == "features" derived = File.expand_path(File.join(path, "..")) log("Deriving workspace as: #{derived} from features folder") derived else File.expand_path(path) end end end def detect_workspace(options) options[:workspace] || File.expand_path(".") end def expect_workspace_directory(path) message = nil if !File.exist?(path) message = %Q[The path specified by --workspace: #{path} does not exist. ] elsif !File.directory?(path) message = %Q[The path specified by --workspace: #{path} is not a directory. ] end if message raise(ValidationError, message) else true end end # TODO We should require a Gemfile # TODO Untested def copy_default_gemfile(gemfile_path) log('') log('Gemfile missing.') log('You must provide a Gemfile in your workspace.') log('A Gemfile must describe your dependencies and their versions') log('See: http://bundler.io/') log('') log('Warning proceeding with default Gemfile.') log('It is strongly recommended that you create a custom Gemfile.') File.open(gemfile_path, "w") do |f| f.puts "source 'http://rubygems.org'" if is_android? f.puts "gem 'calabash-android', '#{calabash_android_version}'" elsif is_ios? f.puts "gem 'calabash-cucumber', '#{calabash_ios_version}'" else raise ValidationError, 'Your app must be an ipa or apk file.' end end log("Proceeding with Gemfile: #{gemfile_path}") puts(File.read(gemfile_path)) log('') end def stage_gemfile_and_bundle_package(tmpdir) log_header('Checking for Gemfile') if File.exist?(workspace_gemfile) FileUtils.cp(workspace_gemfile, tmpdir) if File.exist?(workspace_gemfile_lock) FileUtils.cp(workspace_gemfile_lock, tmpdir) end else copy_default_gemfile(File.join(tmpdir, "Gemfile")) end gemfile = File.join(tmpdir, "Gemfile") bundle_package(gemfile) end def bundle_package(gemfile) log_header('Packaging') Bundler.with_clean_env do args = ["package", "--all", "--gemfile", gemfile] success = shell_out_with_system("bundle", args) if !success cmd = "bundle #{args.join(" ")}" raise(ValidationError, %Q[Could not package gems. This command failed: #{cmd} Check your local Gemfile and and the remote Gemfile at: #{gemfile}) ]) end end true end # stderr will not be captured during --async-json def shell_out_with_system(command, arguments) system(command, *arguments) $?.exitstatus == 0 end # TODO: Server detection algorithm might be broken. # https://github.com/xamarinhq/test-cloud-command-line/issues/19 def check_for_calabash_server(dir, app_dir) if system("xcrun --find otool-classic &> /dev/null") otool_cmd = "xcrun otool-classic" else otool_cmd = "xcrun otool" end `#{otool_cmd} "#{File.expand_path(dir)}/Payload/#{app_dir}/"* -o 2> /dev/null | grep CalabashServer` end end end end