require 'thor' require 'yaml' require 'rubygems' require 'zip/zip' require 'digest' require 'rest_client' require 'json' require 'rbconfig' require 'tmpdir' require 'fileutils' module XamarinTestCloud class ValidationError < Thor::InvocationError end class CLI < Thor include Thor::Actions attr_accessor :host, :app, :api_key, :appname, :app_explorer, :test_parameters, :workspace, :config, :profile, :features_zip, :skip_check, :dry_run, :device_selection attr_accessor :pretty, :async attr_accessor :endpoint_path FILE_UPLOAD_ENDPOINT = 'upload2' FORM_URL_ENCODED_ENDPOINT = 'upload' 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 :host, :desc => 'Test Cloud host to submit to', :aliases => '-h', :type => :string, :default => (ENV['XTC_ENDPOINT'] || 'https://testcloud.xamarin.com/ci') method_option 'app-name', :desc => 'App name to create or add test to', :aliases => '-a', :required => false, :type => :string method_option 'device-selection', :desc => 'Device selection', :aliases => '-d', :required => true, :type => :string method_option 'app-explorer', :desc => 'Explore using AppExplorer only (no Calabash)', :aliases => '-e', :type => :boolean, :default => false method_option 'test-parameters', :desc => 'Test parameters (e.g., -params username:nat@xamarin.com password:xamarin)', :aliases => '-params', :type => :hash method_option :features, :desc => 'Zip file with features, step definitions, etc...', :aliases => '-f', :type => :string method_option :config, :desc => 'Cucumber configuration file (cucumber.yml)', :aliases => '-c', :type => :string 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 'skip-check', :desc => 'Skip checking for ipa linked with Calabash (iOS only)', :type => :boolean method_option 'dry-run', :desc => "Sanity check only, don't upload", :aliases => '-s', :type => :boolean, :default => false #do upload by default def submit(app, api_key) self.host = options[:host] self.pretty = options[:pretty] app_path = File.expand_path(app) unless File.exist?(app_path) raise ValidationError, "App is not a file: #{app_path}" end app_extension = File.extname(app_path) unless /ipa/i.match(app_extension) || /apk/i.match(app_extension) raise ValidationError, "App #{app_path} must be an .ipa or .apk file" end self.app = app_path self.async = options[:async] 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['device-selection'] device_selection_data = validate_device_selection self.app_explorer = options['app-explorer'] self.skip_check = options['skip-check'] unless app_explorer parse_and_set_config_and_profile end workspace_path = options[:workspace] || File.expand_path('.') unless File.directory?(workspace_path) raise ValidationError, "Provided workspace: #{workspace_path} is not a directory." end self.workspace = File.join(File.expand_path(workspace_path), File::Separator) features_path = options[:features] unless features_path.nil? || self.app_explorer if File.exist?(features_path) self.features_zip = File.expand_path(features_path) else raise ValidationError, "Provided features file does not exist #{features_path}" end end if ENV['DEBUG'] puts "Host = #{self.host}" puts "App = #{self.app}" puts "App Name = #{self.app}" puts "AppExplorer = #{self.app_explorer}" puts "TestParams = #{self.test_parameters}" puts "API Key = #{self.api_key}" puts "Device Selection = #{self.device_selection}" puts "Workspace = #{self.workspace}" puts "Features Zip = #{self.features_zip}" puts "Config = #{self.config}" puts "Profile = #{self.profile}" puts "Skip Check = #{self.skip_check}" 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 ENV['DEBUG']=='1' p json end log_header("Test enqueued") puts "User: #{json["user_email"]}" puts "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' end end default_task :submit no_tasks do def exit_on_failure? true end def wait_for_job(id) while(true) status_json = JSON.parse(http_post("status", {'id' => id, 'api_key' => api_key})) log "Status: #{status_json["status_description"]}" if status_json["status"] == "finished" puts "Done!" if ENV['DEBUG'] == '1' log "Status JSON result" puts status_json end log_header "Test Summary" calabash_data = status_json["calabash_data"] if calabash_data puts "Total scenarios: #{calabash_data["scenarios"]["total"]}" puts "#{calabash_data["scenarios"]["passed"]} passed" puts "#{calabash_data["scenarios"]["failed"]} failed" puts "Total steps: #{calabash_data["steps"]["total"]}" end exit 0 end if ["cancelled", "invalid"].include?(status_json["status"]) puts "Failed!" if status_json['reason'] puts "Reason: #{status_json['status']}" puts "Details:" puts status_json['reason'] end exit 1 end if ENV['DEBUG']=='1' sleep 1 else sleep 10 end end end def validate_device_selection 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 submit_test_job(device_selection_data) start_at = Time.now unless self.app_explorer server = verify_app_and_extract_test_server log_header('Checking for Gemfile') gemfile_path = File.join(self.workspace, 'Gemfile') unless File.exist?(gemfile_path) copy_default_gemfile(gemfile_path, server) end log_header('Packaging') FileUtils.cd(self.workspace) do unless system('bundle package --all') log_and_abort 'Bundler failed. Please check command: bundle package' end end log_header('Verifying dependencies') verify_dependencies end if dry_run log_header('Dry run only') log('Dry run completed OK') return end app_file, files, paths = gather_files_and_paths_to_upload(all_files) log_header('Uploading negotiated files') upload_data = {'files' => files, 'paths' => paths, 'app_file' => app_file, 'device_selection' => device_selection_data, 'app' => self.appname, 'test_parameters' => self.test_parameters, 'app_explorer' => self.app_explorer, 'api_key' => api_key, 'app_filename' => File.basename(app)} if profile #only if config and profile upload_data['profile'] = profile end if ENV['DEBUG'] puts JSON.pretty_generate(upload_data) end contains_file = files.find { |f| f.is_a?(File) } contains_file = contains_file || app_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 ENV['DEBUG'] puts "Will upload to file path: #{self.endpoint_path}" end response = http_post(endpoint_path, upload_data) do |response, request, result, &block| if ENV['DEBUG'] puts "Request url: #{request.url}" puts "Response code: #{response.code}" puts "Response body: #{response.body}" end case response.code when 200..202 response when 403 abort do puts 'Invalid API key' end when 413 abort do puts 'Files too large' end else abort do log 'Unexpected Error. Please contact support at testcloud@xamarin.com.' end end end return :status => response.code, :body => JSON.parse(response) end def copy_default_gemfile(gemfile_path, server) 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://gembundler.com/v1.3/gemfile.html') log('') log('Warning proceeding with default Gemfile.') log('It is strongly recommended that you create a custom Gemfile.') tgt = nil if is_android? log('Creating Gemfile for Android') tgt = File.join(CLI.source_root, 'GemfileAndroid') elsif is_ios? log('Creating Gemfile for iOS') gemfile = 'GemfileIOS' if server == :frank raise ValidationError, 'Frank not supported just yet' end tgt = File.join(CLI.source_root, gemfile) else raise ValidationError, 'Your app must be an ipa or apk file.' end log("Proceeding with Gemfile: #{gemfile_path}") FileUtils.cp(File.expand_path(tgt), gemfile_path) puts(File.read(gemfile_path)) log('') end def gather_files_and_paths_to_upload(collected_files) log_header('Calculating digests') file_paths = collected_files[:files] feature_prefix = collected_files[:feature_prefix] workspace_prefix = collected_files[:workspace_prefix] hashes = file_paths.collect { |f| digest(f) } hashes << digest(app) log_header('Negotiating upload') response = http_post('check_hash', {'hashes' => hashes}) cache_status = JSON.parse(response) log_header('Gathering files based on negotiation') files = [] paths = [] file_paths.each do |file| if cache_status[digest(file)] #Server already knows about this file. No need to upload it. files << digest(file) else #Upload file files << File.new(file) end if file.start_with?(feature_prefix) prefix = feature_prefix else prefix = workspace_prefix end paths << file.sub(prefix, '') end if config files << File.new(config) paths << 'config/cucumber.yml' end app_file = cache_status[digest(app)] ? digest(app) : File.new(app) return app_file, files, paths end def digest(file) Digest::SHA256.file(file).hexdigest end def unzip_file (file, destination) Zip::ZipFile.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 calabash_android_version `bundle exec calabash-android version`.strip end def is_ios? app.end_with? '.ipa' end def test_server_path require 'digest/md5' digest = Digest::MD5.file(app).hexdigest File.join('test_servers', "#{digest}_#{calabash_android_version}.apk") end def all_files dir = workspace if features_zip dir = Dir.mktmpdir unzip_file(features_zip, dir) dir = File.join(dir, File::Separator) end if self.app_explorer files = [] else files = Dir.glob(File.join("#{dir}features", '**', '*')) if File.directory?("#{dir}playback") files += Dir.glob(File.join("#{dir}playback", '*')) end if config files << config end files += Dir.glob(File.join("#{workspace}vendor", 'cache', '*')) if workspace and workspace.strip != '' files += Dir.glob("#{workspace}Gemfile") files += Dir.glob("#{workspace}Gemfile.lock") end if is_android? files << test_server_path end end {:feature_prefix => dir, :workspace_prefix => workspace, :files => files.find_all { |file_or_dir| File.file? file_or_dir }} end def http_post(address, args, &block) exec_options = {} if ENV['XTC_USERNAME'] && ENV['XTC_PASSWORD'] exec_options[:user] = ENV['XTC_USERNAME'] exec_options[:password] = ENV['XTC_PASSWORD'] end if block_given? exec_options = exec_options.merge({:method => :post, :url => "#{host}/#{address}", :payload => args, :timeout => 90000000, :open_timeout => 90000000, :headers => {:content_type => 'multipart/form-data'}}) response = RestClient::Request.execute(exec_options) do |response, request, result, &other_block| block.call(response, request, result, &other_block) end else exec_options = exec_options.merge(:method => :post, :url => "#{host}/#{address}", :payload => args) response = RestClient::Request.execute(exec_options) end response.body end def is_windows? (RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/) end def is_macosx? (RbConfig::CONFIG['host_os'] =~ /darwin/) end def validate_ipa(ipa) result = false dir = Dir.mktmpdir #do |dir| unzip_file(ipa, dir) unless File.directory?("#{dir}/Payload") #macos only raise ValidationError, "Unzipping #{ipa} to #{dir} failed: Did not find a Payload directory (invalid .ipa)." end app_dir = Dir.foreach("#{dir}/Payload").find { |d| /\.app$/.match(d) } app = app_dir.split('.')[0] res = `otool "#{File.expand_path(dir)}/Payload/#{app_dir}/#{app}" -o 2> /dev/null | grep CalabashServer` if /CalabashServer/.match(res) puts "ipa: #{ipa} *contains* calabash.framework" result = :calabash end unless result res = `otool "#{File.expand_path(dir)}/Payload/#{app_dir}/#{app}" -o 2> /dev/null | grep FrankServer` if /FrankServer/.match(res) puts "ipa: #{ipa} *contains* FrankServer" result = :frank else puts "ipa: #{ipa} *does not contain* calabash.framework" result = false end end result end def log(message) puts "#{Time.now } #{message}" $stdout.flush end def log_header(message) if is_windows? puts "\n### #{message} ###" else puts "\n\e[#{35}m### #{message} ###\e[0m" end end def verify_app_and_extract_test_server server = nil unless File.exist?(app) raise ValidationError, "No such file: #{app}" end unless (/\.(apk|ipa)$/ =~ app) raise ValidationError, ' should be either an ipa or apk file.' end if is_ios? and is_macosx? and not skip_check log_header('Checking ipa for linking with Calabash') server = validate_ipa(app) abort_unless(server) do puts 'The .ipa file does not seem to be linked with Calabash.' puts 'Verify that your app is linked correctly.' puts 'To disable this check run with --skip-check or set Environment Variable CHECK_IPA=0' end end server end def abort(&block) yield block exit 1 end def abort_unless(condition, &block) unless condition yield block exit 1 end end def log_and_abort(message) abort do puts message end end def verify_dependencies if is_android? abort_unless(File.exist?(test_server_path)) do puts 'No test server found. Please run:' puts " calabash-android build #{app}" end calabash_gem = Dir.glob('vendor/cache/calabash-android-*').first abort_unless(calabash_gem) do puts 'calabash-android was not packaged correct.' puts 'Please tell testcloud@xamarin.com about this bug.' end end end def parse_and_set_config_and_profile config_path = options[:config] if config_path config_path = File.expand_path(config_path) unless File.exist?(config_path) raise ValidationError, "Config file does not exist #{config_path}" end begin config_yml = YAML.load_file(config_path) rescue Exception => e puts "Unable to parse #{config_path} as yml. Is this your Cucumber.yml file?" raise ValidationError, e end if ENV['DEBUG'] puts 'Parsed Cucumber config as:' puts config_yml.inspect end profile = options[:profile] unless profile raise ValidationError, 'Config file provided but no profile selected.' else unless config_yml[profile] raise ValidationError, "Config file provided did not contain profile #{profile}." else puts "Using profile #{profile}..." self.profile = profile end end else if options[:profile] raise ValidationError, 'Profile selected but no config file provided.' end end self.config = config_path end end end end