require 'thor' require 'yaml' require 'rubygems' require 'zip/zip' require 'digest' require 'rest_client' require 'json' require 'rbconfig' require 'tmpdir' require 'fileutils' module LessPainful class CLI < Thor include Thor::Actions attr_accessor :host, :app, :api_key, :workspace, :config, :profile, :features_zip, :skip_check, :reset_between_scenarios, :dry_run attr_accessor :pretty 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 this lesspainful gem" def version puts LessPainful::VERSION end desc "submit ", "Submits your app and test suite to LessPainful's device labs" method_option :host, :desc => "Device Lab Host to submit to.", :aliases => '-h', :type => :string, :default => (ENV["LP_HOST"] || 'https://www.lesspainful.com') method_option :workspace, :desc => "Workspace containing Gemfile and features.", :aliases => '-w', :type => :string, :default => File.expand_path(".") 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 JSON output.", :type => :boolean, :default => false method_option "skip-check", :desc => "Skip checking for ipa linked with Calabash (iOS only).", :type => :boolean method_option "reset-between-scenarios", :desc => "Reinstall app between each scenario (iOS only).", :type => :string, :default => ENV['RESET_BETWEEN_SCENARIOS']=='1' ? '1': "0" method_option "dry-run", :desc => "Sanity check only, don't upload.", :aliases => '-d', :type => :boolean, :default => false #do upload by default def submit(app, api_key, *args) self.host = options[:host] self.pretty = options[:pretty] || false unless self.host #if invoked though old-style self.host = ENV["LP_HOST"] || 'https://www.lesspainful.com' else end app_path = File.expand_path(app) unless File.exist?(app_path) raise "App is not a file: #{app_path}" end self.app = app_path self.dry_run = options["dry-run"] self.api_key = api_key self.skip_check = ENV['CHECK_IPA'] == '0' self.skip_check = options["skip-check"] unless options["skip-check"].nil? self.reset_between_scenarios = options["reset-between-scenarios"] parse_and_set_config_and_profile workspace_path = options[:workspace] || File.expand_path(".") if args[0] workspace_path = args[0] end unless File.directory?(workspace_path) raise "Provided workspace: #{workspace_path} is not a directory." end self.workspace = File.join(File.expand_path(workspace_path), File::Separator) features_path = options[:features] features_path = args[1] if args[1] unless features_path.nil? if File.exist?(features_path) self.features_zip = File.expand_path(features_path) else raise "Provided features file does not exist #{features_path}" end end if ENV['DEBUG'] puts "Host = #{self.host}" puts "App = #{self.app}" puts "API Key = #{self.api_key}" puts "Workspace = #{self.workspace}" puts "Features Zip = #{self.features_zip}" puts "Config = #{self.config}" puts "Profile = #{self.profile}" puts "Skip Check = #{self.skip_check}" puts "Reset Between Scenarios = #{self.reset_between_scenarios}" end #Argument parsing done json = submit_test_job unless dry_run if pretty and JSON.respond_to?(:pretty_generate) puts JSON.pretty_generate(json) else puts json.to_json end end end default_task :submit no_tasks do def submit_test_job start_at = Time.now 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 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, "reset_between_scenarios" => reset_between_scenarios, "app" => app_file, "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| 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 contact@lesspainful.com and provide the timestamp on your left." end end end end_at = Time.now log_header("Done (took %.1fs)" % (end_at - start_at)) 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.2/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 gemfile = "GemfileIOSFrank" end tgt = File.join(CLI.source_root, gemfile) else raise "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 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 {: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) if block_given? response = RestClient::Request.execute(:method => :post, :url => "#{host}/#{address}", :payload => args, :timeout => 90000000, :open_timeout => 90000000, :headers => {:content_type => "multipart/form-data"}) do |response, request, result, &other_block| block.call(response, request, result, &other_block) end else response = RestClient.post "#{host}/#{address}", args 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 "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 "No such file: #{app}" end unless (/\.(apk|ipa)$/ =~ app) raise " 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 or Frank") 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 contact@lesspainful.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 "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 e end if ENV['DEBUG'] puts "Parsed Cucumber config as:" puts config_yml.inspect end profile = options[:profile] unless profile raise "Config file provided but no profile selected." else unless config_yml[profile] raise "Config file provided did not contain profile #{profile}." else puts "Using profile #{profile}..." self.profile = profile end end else if options[:profile] raise "Profile selected but no config file provided." end end self.config = config_path end end end end