appium_instrumenter 0.1.0
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.
- checksums.yaml +7 -0
- data/.gitignore +11 -0
- data/.rspec +3 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/.travis.yml +5 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +55 -0
- data/LICENSE.txt +21 -0
- data/README.md +40 -0
- data/Rakefile +6 -0
- data/appium_instrumenter.gemspec +42 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/exe/appium_instrumenter +57 -0
- data/lib/appium_instrumenter.rb +76 -0
- data/lib/appium_instrumenter/dependencies.rb +539 -0
- data/lib/appium_instrumenter/environment.rb +123 -0
- data/lib/appium_instrumenter/helpers.rb +220 -0
- data/lib/appium_instrumenter/java_keystore.rb +125 -0
- data/lib/appium_instrumenter/resources/AndroidManifest.xml +34 -0
- data/lib/appium_instrumenter/utils.rb +25 -0
- data/lib/appium_instrumenter/version.rb +3 -0
- metadata +169 -0
@@ -0,0 +1,123 @@
|
|
1
|
+
module Calabash
|
2
|
+
module Android
|
3
|
+
# @!visibility private
|
4
|
+
class Environment
|
5
|
+
# @!visibility private
|
6
|
+
class InvalidEnvironmentError < RuntimeError; end
|
7
|
+
# @!visibility private
|
8
|
+
class InvalidJavaSDKHome < RuntimeError; end
|
9
|
+
|
10
|
+
# @!visibility private
|
11
|
+
# Returns true if running on Windows
|
12
|
+
def self.windows?
|
13
|
+
RbConfig::CONFIG['host_os'][/mswin|mingw|cygwin/, 0] != nil
|
14
|
+
end
|
15
|
+
|
16
|
+
# @!visibility private
|
17
|
+
# Returns the user home directory
|
18
|
+
def self.user_home_directory
|
19
|
+
if self.xtc?
|
20
|
+
home = File.join("./", "tmp", "home")
|
21
|
+
FileUtils.mkdir_p(home)
|
22
|
+
home
|
23
|
+
else
|
24
|
+
if self.windows?
|
25
|
+
# http://stackoverflow.com/questions/4190930/cross-platform-means-of-getting-users-home-directory-in-ruby
|
26
|
+
home = ENV["HOME"] || ENV["USERPROFILE"]
|
27
|
+
else
|
28
|
+
require "etc"
|
29
|
+
home = Etc.getpwuid.dir
|
30
|
+
end
|
31
|
+
|
32
|
+
unless File.exist?(home)
|
33
|
+
home = File.join("./", "tmp", "home")
|
34
|
+
FileUtils.mkdir_p(home)
|
35
|
+
end
|
36
|
+
|
37
|
+
home
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# @!visibility private
|
42
|
+
# Returns true if debugging is enabled.
|
43
|
+
def self.debug?
|
44
|
+
ENV['DEBUG'] == '1' ||
|
45
|
+
ARGV.include?("-v") ||
|
46
|
+
ARGV.include?("--verbose")
|
47
|
+
end
|
48
|
+
|
49
|
+
# @!visibility private
|
50
|
+
# Returns true if we are running on the XTC
|
51
|
+
def self.xtc?
|
52
|
+
ENV['XAMARIN_TEST_CLOUD'] == '1'
|
53
|
+
end
|
54
|
+
|
55
|
+
# @!visibility private
|
56
|
+
# Returns true if running in Jenkins CI
|
57
|
+
#
|
58
|
+
# Checks the value of JENKINS_HOME
|
59
|
+
def self.jenkins?
|
60
|
+
value = ENV["JENKINS_HOME"]
|
61
|
+
!!value && value != ''
|
62
|
+
end
|
63
|
+
|
64
|
+
# @!visibility private
|
65
|
+
# Returns true if running in Travis CI
|
66
|
+
#
|
67
|
+
# Checks the value of TRAVIS
|
68
|
+
def self.travis?
|
69
|
+
value = ENV["TRAVIS"]
|
70
|
+
!!value && value != ''
|
71
|
+
end
|
72
|
+
|
73
|
+
# @!visibility private
|
74
|
+
# Returns true if running in Circle CI
|
75
|
+
#
|
76
|
+
# Checks the value of CIRCLECI
|
77
|
+
def self.circle_ci?
|
78
|
+
value = ENV["CIRCLECI"]
|
79
|
+
!!value && value != ''
|
80
|
+
end
|
81
|
+
|
82
|
+
# @!visibility private
|
83
|
+
# Returns true if running in Teamcity
|
84
|
+
#
|
85
|
+
# Checks the value of TEAMCITY_PROJECT_NAME
|
86
|
+
def self.teamcity?
|
87
|
+
value = ENV["TEAMCITY_PROJECT_NAME"]
|
88
|
+
!!value && value != ''
|
89
|
+
end
|
90
|
+
|
91
|
+
# @!visibility private
|
92
|
+
# Returns true if running in Teamcity
|
93
|
+
#
|
94
|
+
# Checks the value of GITLAB_CI
|
95
|
+
def self.gitlab?
|
96
|
+
value = ENV["GITLAB_CI"]
|
97
|
+
!!value && value != ''
|
98
|
+
end
|
99
|
+
|
100
|
+
# @!visibility private
|
101
|
+
# Returns true if running in a CI environment
|
102
|
+
def self.ci?
|
103
|
+
[
|
104
|
+
self.ci_var_defined?,
|
105
|
+
self.travis?,
|
106
|
+
self.jenkins?,
|
107
|
+
self.circle_ci?,
|
108
|
+
self.teamcity?,
|
109
|
+
self.gitlab?
|
110
|
+
].any?
|
111
|
+
end
|
112
|
+
|
113
|
+
private
|
114
|
+
|
115
|
+
# !@visibility private
|
116
|
+
def self.ci_var_defined?
|
117
|
+
value = ENV["CI"]
|
118
|
+
!!value && value != ''
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
@@ -0,0 +1,220 @@
|
|
1
|
+
require "stringio"
|
2
|
+
require 'zip'
|
3
|
+
require 'tempfile'
|
4
|
+
require 'escape'
|
5
|
+
require 'rbconfig'
|
6
|
+
require_relative 'utils'
|
7
|
+
require_relative 'dependencies'
|
8
|
+
require_relative 'environment'
|
9
|
+
|
10
|
+
def package_name(app)
|
11
|
+
unless File.exist?(app)
|
12
|
+
raise "Application '#{app}' does not exist"
|
13
|
+
end
|
14
|
+
|
15
|
+
package_line = aapt_dump(app, "package").first
|
16
|
+
raise "'package' not found in aapt output" unless package_line
|
17
|
+
m = package_line.match(/name='([^']+)'/)
|
18
|
+
raise "Unexpected output from aapt: #{package_line}" unless m
|
19
|
+
m[1]
|
20
|
+
end
|
21
|
+
|
22
|
+
def main_activity(app)
|
23
|
+
unless File.exist?(app)
|
24
|
+
raise "Application '#{app}' does not exist"
|
25
|
+
end
|
26
|
+
|
27
|
+
begin
|
28
|
+
log("Trying to find launchable activity")
|
29
|
+
launchable_activity_line = aapt_dump(app, "launchable-activity").first
|
30
|
+
raise "'launchable-activity' not found in aapt output" unless launchable_activity_line
|
31
|
+
m = launchable_activity_line.match(/name='([^']+)'/)
|
32
|
+
raise "Unexpected output from aapt: #{launchable_activity_line}" unless m
|
33
|
+
log("Found launchable activity '#{m[1]}'")
|
34
|
+
m[1]
|
35
|
+
rescue => e
|
36
|
+
log("Could not find launchable activity, trying to parse raw AndroidManifest. #{e.message}")
|
37
|
+
|
38
|
+
manifest_data = `"#{Calabash::Android::Dependencies.aapt_path}" dump xmltree "#{app}" AndroidManifest.xml`
|
39
|
+
regex = /^\s*A:[\s*]android:name\(\w+\)\=\"android.intent.category.LAUNCHER\"/
|
40
|
+
lines = manifest_data.lines.collect(&:strip)
|
41
|
+
indicator_line = nil
|
42
|
+
|
43
|
+
lines.each_with_index do |line, index|
|
44
|
+
match = line.match(regex)
|
45
|
+
|
46
|
+
unless match.nil?
|
47
|
+
raise 'More than one launchable activity in AndroidManifest' unless indicator_line.nil?
|
48
|
+
indicator_line = index
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
raise 'No launchable activity found in AndroidManifest' unless indicator_line
|
53
|
+
|
54
|
+
intent_filter_found = false
|
55
|
+
|
56
|
+
(0..indicator_line).reverse_each do |index|
|
57
|
+
if intent_filter_found
|
58
|
+
match = lines[index].match(/\s*E:\s*activity-alias/)
|
59
|
+
|
60
|
+
raise 'Could not find target activity in activity alias' if match
|
61
|
+
|
62
|
+
match = lines[index].match(/^\s*A:\s*android:targetActivity\(\w*\)\=\"([^\"]+)/){$1}
|
63
|
+
|
64
|
+
if match
|
65
|
+
log("Found launchable activity '#{match}'")
|
66
|
+
|
67
|
+
return match
|
68
|
+
end
|
69
|
+
else
|
70
|
+
unless lines[index].match(/\s*E: intent-filter/).nil?
|
71
|
+
log("Read intent filter")
|
72
|
+
intent_filter_found = true
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
raise 'Could not find launchable activity'
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def aapt_dump(app, key)
|
82
|
+
lines = `"#{Calabash::Android::Dependencies.aapt_path}" dump badging "#{app}"`.lines.collect(&:strip)
|
83
|
+
lines.select { |l| l.start_with?("#{key}:") }
|
84
|
+
end
|
85
|
+
|
86
|
+
def checksum(file_path)
|
87
|
+
require 'digest/md5'
|
88
|
+
Digest::MD5.file(file_path).hexdigest
|
89
|
+
end
|
90
|
+
|
91
|
+
def test_server_path(apk_file_path)
|
92
|
+
"test_servers/#{checksum(apk_file_path)}_#{Calabash::Android::VERSION}.apk"
|
93
|
+
end
|
94
|
+
|
95
|
+
def build_test_server_if_needed(app_path)
|
96
|
+
unless File.exist?(test_server_path(app_path))
|
97
|
+
if ARGV.include? "--no-build"
|
98
|
+
puts "No test server found for this combination of app and calabash version. Exiting!"
|
99
|
+
exit 1
|
100
|
+
else
|
101
|
+
puts "No test server found for this combination of app and calabash version. Recreating test server."
|
102
|
+
require 'calabash-android/operations'
|
103
|
+
require File.join(File.dirname(__FILE__), '..', '..', 'bin', 'calabash-android-build')
|
104
|
+
calabash_build(app_path)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def resign_apk(app_path)
|
110
|
+
Dir.mktmpdir do |tmp_dir|
|
111
|
+
log "Resign apk"
|
112
|
+
unsigned_path = File.join(tmp_dir, 'unsigned.apk')
|
113
|
+
unaligned_path = File.join(tmp_dir, 'unaligned.apk')
|
114
|
+
FileUtils.cp(app_path, unsigned_path)
|
115
|
+
unsign_apk(unsigned_path)
|
116
|
+
sign_apk(unsigned_path, unaligned_path)
|
117
|
+
zipalign_apk(unaligned_path, app_path)
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
def unsign_apk(path)
|
122
|
+
meta_files = `"#{Calabash::Android::Dependencies.aapt_path}" list "#{path}"`.lines.collect(&:strip).grep(/^META-INF\//)
|
123
|
+
|
124
|
+
signing_file_names = ['.mf', '.rsa', '.dsa', '.ec', '.sf']
|
125
|
+
|
126
|
+
files_to_remove = meta_files.select do |file|
|
127
|
+
# other will be:
|
128
|
+
# META-INF/foo/bar
|
129
|
+
# other #=> bar
|
130
|
+
directory, file_name, other = file.split('/')
|
131
|
+
|
132
|
+
if other != nil || file_name.nil?
|
133
|
+
false
|
134
|
+
else
|
135
|
+
if signing_file_names.include?(File.extname(file_name).downcase)
|
136
|
+
true
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
if files_to_remove.empty?
|
142
|
+
log "App wasn't signed. Will not try to unsign it."
|
143
|
+
else
|
144
|
+
system("\"#{Calabash::Android::Dependencies.aapt_path}\" remove \"#{path}\" #{files_to_remove.join(" ")}")
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
def zipalign_apk(inpath, outpath)
|
149
|
+
cmd = %Q("#{Calabash::Android::Dependencies.zipalign_path}" -f 4 "#{inpath}" "#{outpath}")
|
150
|
+
log "Zipaligning using: #{cmd}"
|
151
|
+
system(cmd)
|
152
|
+
end
|
153
|
+
|
154
|
+
def sign_apk(app_path, dest_path)
|
155
|
+
java_keystore = JavaKeystore.get_keystores.first
|
156
|
+
|
157
|
+
if java_keystore.nil?
|
158
|
+
raise 'No keystores found. You can specify the keystore location and credentials using calabash-android setup'
|
159
|
+
end
|
160
|
+
|
161
|
+
java_keystore.sign_apk(app_path, dest_path)
|
162
|
+
end
|
163
|
+
|
164
|
+
def fingerprint_from_apk(app_path)
|
165
|
+
app_path = File.expand_path(app_path)
|
166
|
+
Dir.mktmpdir do |tmp_dir|
|
167
|
+
Dir.chdir(tmp_dir) do
|
168
|
+
FileUtils.cp(app_path, "app.apk")
|
169
|
+
FileUtils.mkdir("META-INF")
|
170
|
+
|
171
|
+
Calabash::Utils.with_silent_zip do
|
172
|
+
Zip::File.foreach("app.apk") do |z|
|
173
|
+
z.extract if /^META-INF\/\w+.(rsa|dsa)/i =~ z.name
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
signature_files = Dir["#{tmp_dir}/META-INF/*"]
|
178
|
+
|
179
|
+
log 'Signature files:'
|
180
|
+
|
181
|
+
signature_files.each do |signature_file|
|
182
|
+
log signature_file
|
183
|
+
end
|
184
|
+
|
185
|
+
raise "No signature files found in META-INF. Cannot proceed." if signature_files.empty?
|
186
|
+
raise "More than one signature file (DSA or RSA) found in META-INF. Cannot proceed." if signature_files.length > 1
|
187
|
+
|
188
|
+
cmd = "\"#{Calabash::Android::Dependencies.keytool_path}\" -v -printcert -J\"-Dfile.encoding=utf-8\" -file \"#{signature_files.first}\""
|
189
|
+
log cmd
|
190
|
+
fingerprints = `#{cmd}`
|
191
|
+
md5_fingerprint = extract_sha1_fingerprint(fingerprints)
|
192
|
+
log "SHA1 fingerprint for signing cert (#{app_path}): #{md5_fingerprint}"
|
193
|
+
md5_fingerprint
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
def extract_md5_fingerprint(fingerprints)
|
199
|
+
m = fingerprints.scan(/MD5.*((?:[a-fA-F\d]{2}:){15}[a-fA-F\d]{2})/).flatten
|
200
|
+
raise "No MD5 fingerprint found:\n #{fingerprints}" if m.empty?
|
201
|
+
m.first
|
202
|
+
end
|
203
|
+
|
204
|
+
def extract_sha1_fingerprint(fingerprints)
|
205
|
+
m = fingerprints.scan(/SHA1.*((?:[a-fA-F\d]{2}:){15}[a-fA-F\d]{2})/).flatten
|
206
|
+
raise "No SHA1 fingerprint found:\n #{fingerprints}" if m.empty?
|
207
|
+
m.first
|
208
|
+
end
|
209
|
+
|
210
|
+
def extract_signature_algorithm_name(fingerprints)
|
211
|
+
m = fingerprints.scan(/Signature algorithm name: (.*)/).flatten
|
212
|
+
raise "No signature algorithm names found:\n #{fingerprints}" if m.empty?
|
213
|
+
m.first
|
214
|
+
end
|
215
|
+
|
216
|
+
def log(message, error = false)
|
217
|
+
if error or ARGV.include? "-v" or ARGV.include? "--verbose" or ENV["DEBUG"] == "1"
|
218
|
+
$stdout.puts "#{Time.now.strftime("%Y-%m-%d %H:%M:%S")} - #{message}"
|
219
|
+
end
|
220
|
+
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
require 'json'
|
2
|
+
class JavaKeystore
|
3
|
+
attr_reader :errors, :location, :keystore_alias, :password, :fingerprint
|
4
|
+
attr_reader :signature_algorithm_name
|
5
|
+
|
6
|
+
def initialize(location, keystore_alias, password)
|
7
|
+
raise "No such keystore file '#{location}'" unless File.exists?(File.expand_path(location))
|
8
|
+
log "Reading keystore data from keystore file '#{File.expand_path(location)}'"
|
9
|
+
|
10
|
+
keystore_data = system_with_stdout_on_success(Calabash::Android::Dependencies.keytool_path, '-list', '-v', '-alias', keystore_alias, '-keystore', location, '-storepass', password, '-J"-Dfile.encoding=utf-8"', '-J"-Duser.language=en-US"')
|
11
|
+
|
12
|
+
if keystore_data.nil?
|
13
|
+
if keystore_alias.empty?
|
14
|
+
log "Could not obtain keystore data. Will try to extract alias automatically"
|
15
|
+
|
16
|
+
keystore_data = system_with_stdout_on_success(Calabash::Android::Dependencies.keytool_path, '-list', '-v', '-keystore', location, '-storepass', password, '-J"-Dfile.encoding=utf-8"', '-J"-Duser.language=en-US"')
|
17
|
+
aliases = keystore_data.scan(/Alias name\:\s*(.*)/).flatten
|
18
|
+
|
19
|
+
if aliases.length == 0
|
20
|
+
raise 'Could not extract alias automatically. Please specify alias using calabash-android setup'
|
21
|
+
elsif aliases.length > 1
|
22
|
+
raise 'Multiple aliases found in keystore. Please specify alias using calabash-android setup'
|
23
|
+
else
|
24
|
+
keystore_alias = aliases.first
|
25
|
+
log "Extracted keystore alias '#{keystore_alias}'. Continuing"
|
26
|
+
|
27
|
+
return initialize(location, keystore_alias, password)
|
28
|
+
end
|
29
|
+
else
|
30
|
+
error = "Could not list certificates in keystore. Probably because the password was incorrect."
|
31
|
+
@errors = [{:message => error}]
|
32
|
+
log error
|
33
|
+
raise error
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
@location = location
|
38
|
+
@keystore_alias = keystore_alias
|
39
|
+
@password = password
|
40
|
+
log "Key store data:"
|
41
|
+
log keystore_data
|
42
|
+
@fingerprint = extract_sha1_fingerprint(keystore_data)
|
43
|
+
@signature_algorithm_name = extract_signature_algorithm_name(keystore_data)
|
44
|
+
log "Fingerprint: #{fingerprint}"
|
45
|
+
log "Signature algorithm name: #{signature_algorithm_name}"
|
46
|
+
end
|
47
|
+
|
48
|
+
def sign_apk(apk_path, dest_path)
|
49
|
+
raise "Cannot sign with a miss configured keystore" if errors
|
50
|
+
raise "No such file: #{apk_path}" unless File.exists?(apk_path)
|
51
|
+
|
52
|
+
# E.g. MD5withRSA or MD5withRSAandMGF1
|
53
|
+
encryption = signature_algorithm_name.split('with')[1].split('and')[0]
|
54
|
+
signing_algorithm = "SHA1with#{encryption}"
|
55
|
+
digest_algorithm = 'SHA1'
|
56
|
+
|
57
|
+
log "Signing using the signature algorithm: '#{signing_algorithm}'"
|
58
|
+
log "Signing using the digest algorithm: '#{digest_algorithm}'"
|
59
|
+
|
60
|
+
unless system_with_stdout_on_success(Calabash::Android::Dependencies.jarsigner_path, '-sigfile', 'CERT', '-sigalg', signing_algorithm, '-digestalg', digest_algorithm, '-signedjar', dest_path, '-storepass', password, '-keystore', location, apk_path, keystore_alias)
|
61
|
+
raise "Could not sign app: #{apk_path}"
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def system_with_stdout_on_success(cmd, *args)
|
66
|
+
a = Escape.shell_command(args)
|
67
|
+
cmd = "\"#{cmd}\" #{a.gsub("'", '"')}"
|
68
|
+
log cmd
|
69
|
+
out = `#{cmd}`
|
70
|
+
if $?.exitstatus == 0
|
71
|
+
out
|
72
|
+
else
|
73
|
+
nil
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def self.read_keystore_with_default_password_and_alias(path)
|
78
|
+
path = File.expand_path path
|
79
|
+
|
80
|
+
if File.exists? path
|
81
|
+
keystore = JavaKeystore.new(path, 'androiddebugkey', 'android')
|
82
|
+
if keystore.errors
|
83
|
+
log "Trying to "
|
84
|
+
nil
|
85
|
+
else
|
86
|
+
log "Unlocked keystore at #{path} - fingerprint: #{keystore.fingerprint}"
|
87
|
+
keystore
|
88
|
+
end
|
89
|
+
else
|
90
|
+
log "Trying to read keystore from: #{path} - no such file"
|
91
|
+
nil
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
def self.get_keystores
|
96
|
+
if keystore = keystore_from_settings
|
97
|
+
p "found calabash_settings file"
|
98
|
+
[ keystore ]
|
99
|
+
else
|
100
|
+
[
|
101
|
+
read_keystore_with_default_password_and_alias(File.join(ENV["HOME"], "/.android/debug.keystore")),
|
102
|
+
read_keystore_with_default_password_and_alias("debug.keystore"),
|
103
|
+
read_keystore_with_default_password_and_alias(File.join(ENV["HOME"], ".local/share/Xamarin/Mono\\ for\\ Android/debug.keystore")),
|
104
|
+
read_keystore_with_default_password_and_alias(File.join(ENV["HOME"], "AppData/Local/Xamarin/Mono for Android/debug.keystore")),
|
105
|
+
].compact
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def self.keystore_from_settings
|
110
|
+
keystore = JSON.parse(IO.read(".calabash_settings")) if File.exist? ".calabash_settings"
|
111
|
+
keystore = JSON.parse(IO.read("calabash_settings")) if File.exist? "calabash_settings"
|
112
|
+
return unless keystore
|
113
|
+
fail_if_key_missing(keystore, "keystore_location")
|
114
|
+
fail_if_key_missing(keystore, "keystore_password")
|
115
|
+
fail_if_key_missing(keystore, "keystore_alias")
|
116
|
+
keystore["keystore_location"] = File.expand_path(keystore["keystore_location"])
|
117
|
+
log("Keystore location specified in #{File.exist?(".calabash_settings") ? ".calabash_settings" : "calabash_settings"}.")
|
118
|
+
JavaKeystore.new(keystore["keystore_location"], keystore["keystore_alias"], keystore["keystore_password"])
|
119
|
+
end
|
120
|
+
|
121
|
+
def self.fail_if_key_missing(map, key)
|
122
|
+
raise "Found .calabash_settings but no #{key} defined." unless map[key]
|
123
|
+
end
|
124
|
+
|
125
|
+
end
|