browsery 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,265 @@
1
+ module Browsery
2
+ class Parallel
3
+
4
+ attr_reader :all_tests, :simultaneous_jobs
5
+
6
+ def initialize(simultaneous_jobs, all_tests)
7
+ @start_time = Time.now
8
+
9
+ @result_dir = 'logs/tap_results'
10
+
11
+ connector = Browsery.settings.connector
12
+ @on_sauce = true if connector.include? 'saucelabs'
13
+ @platform = connector.split(':')[2] || ''
14
+
15
+ @simultaneous_jobs = simultaneous_jobs
16
+ @simultaneous_jobs = 10 if run_on_mac? # saucelabs account limit for parallel is 10 for mac
17
+ @all_tests = all_tests
18
+
19
+ @pids = []
20
+ @static_run_command = "browsery -c #{Browsery.settings.connector} -e #{Browsery.settings.env}"
21
+ if Browsery.settings.rerun_failure
22
+ @static_run_command += " -R #{Browsery.settings.rerun_failure}"
23
+ end
24
+ tap_reporter_path = Browsery.gem_root.join('lib/tapout/custom_reporters/fancy_tap_reporter.rb')
25
+ @pipe_tap = "--tapy | tapout --no-color -r #{tap_reporter_path.to_s} fancytap"
26
+ end
27
+
28
+ # return true only if specified to run on mac in connector
29
+ # @return [boolean]
30
+ def run_on_mac?
31
+ @platform.include?('osx')
32
+ end
33
+
34
+ # remove all results files under @result_dir if there's any
35
+ def clean_result!
36
+ raise Exception, '@result_dir is not set' if @result_dir.nil?
37
+ unless Dir.glob("#{@result_dir}/*").empty?
38
+ FileUtils.rm_rf(Dir.glob("#{@result_dir}/*"))
39
+ end
40
+ puts "Cleaning result files.\n"
41
+ end
42
+
43
+ def remove_redundant_tap
44
+ ever_failed_tests_file = "#{@result_dir}/ever_failed_tests.json"
45
+ if File.file? ever_failed_tests_file
46
+ data_hash = JSON.parse(File.read(ever_failed_tests_file))
47
+ data_hash.keys.each do |test|
48
+ if test.start_with? 'test_'
49
+ tap_result_file = "#{@result_dir}/#{test}.t"
50
+ result_lines = IO.readlines(tap_result_file)
51
+ last_tap_start_index = 0
52
+ last_tap_end_index = result_lines.size - 1
53
+ result_lines.each_with_index do |l, index|
54
+ last_tap_start_index = index if l.delete!("\n") == '1..1'
55
+ end
56
+ File.open(tap_result_file, 'w') do |f|
57
+ f.puts result_lines[last_tap_start_index..last_tap_end_index]
58
+ end
59
+ puts "Processed #{tap_result_file}"
60
+ else
61
+ next
62
+ end
63
+ end
64
+ else
65
+ puts "==> File #{ever_failed_tests_file} doesn't exist - all tests passed!"
66
+ end
67
+ end
68
+
69
+ # Aggregate all individual test_*.t files
70
+ # replace them with one file - test_aggregated_result.tap
71
+ # so they will be considered as one test plan by tap result parser
72
+ def aggregate_tap_results
73
+ results_count = Dir.glob("#{@result_dir}/*.t").size
74
+ File.open("#{@result_dir}/test_aggregated_result.tap", 'a+') do |result_file|
75
+ result_stats = {
76
+ 'pass' => 0,
77
+ 'fail' => 0,
78
+ 'errs' => 0,
79
+ 'todo' => 0,
80
+ 'omit' => 0
81
+ }
82
+ result_stats_line_start = ' # 1 tests:'
83
+ result_file.puts "1..#{results_count}"
84
+ file_count = 0
85
+ Dir.glob("#{@result_dir}/*.t") do |filename|
86
+ file_count += 1
87
+ File.open(filename, 'r') do |file|
88
+ breakpoint_line = 0
89
+ file.each_with_index do |line, index|
90
+ next if index == 0 || (breakpoint_line > 0 && index > breakpoint_line)
91
+ if line.start_with?(result_stats_line_start)
92
+ pass, fail, errs, todo, omit = line.match(/(\d+) pass, (\d+) fail, (\d+) errs, (\d+) todo, (\d+) omit/).captures
93
+ one_test_result = {
94
+ 'pass' => pass.to_i,
95
+ 'fail' => fail.to_i,
96
+ 'errs' => errs.to_i,
97
+ 'todo' => todo.to_i,
98
+ 'omit' => omit.to_i
99
+ }
100
+ result_stats = result_stats.merge(one_test_result) { |k, total, one| total + one }
101
+ breakpoint_line = index
102
+ elsif line.strip == '#'
103
+ next
104
+ else
105
+ if line.start_with?('ok 1') || line.start_with?('not ok 1')
106
+ line_begin, line_end = line.split('1 -')
107
+ result_file.puts [line_begin, line_end].join("#{file_count} -")
108
+ else
109
+ result_file.puts line
110
+ end
111
+ end
112
+ end
113
+ end
114
+ File.delete(filename)
115
+ end
116
+ result_file.puts ' #'
117
+ result_file.puts " # #{results_count} tests: #{result_stats['pass']} pass, #{result_stats['fail']} fail, #{result_stats['errs']} errs, #{result_stats['todo']} todo, #{result_stats['omit']} omit"
118
+ result_file.puts " # [00:00:00.00 0.00t/s 00.0000s/t] Finished at: #{Time.now}"
119
+ end
120
+ end
121
+
122
+ def count_browsery_process
123
+ counting_process_output = IO.popen "ps -ef | grep 'bin/#{@static_run_command}' -c"
124
+ counting_process_output.readlines[0].to_i - 1 # minus grep process
125
+ end
126
+
127
+ # run multiple commands with logging to start multiple tests in parallel
128
+ # @param [Integer, Array]
129
+ # n = number of tests will be running in parallel
130
+ def run_in_parallel!
131
+ size = all_tests.size
132
+ if size <= simultaneous_jobs
133
+ run_test_set(all_tests)
134
+ puts "CAUTION! All #{size} tests are starting at the same time!"
135
+ puts "will not really run it since computer will die" if size > 30
136
+ sleep 20
137
+ else
138
+ first_test_set = all_tests[0, simultaneous_jobs]
139
+ all_to_run = all_tests[simultaneous_jobs..(all_tests.size - 1)]
140
+ run_test_set(first_test_set)
141
+ keep_running_full(all_to_run)
142
+ end
143
+
144
+ Process.waitall
145
+ puts "\nAll Complete! Started at #{@start_time} and finished at #{Time.now}\n"
146
+ end
147
+
148
+ # runs each test from a test set in a separate child process
149
+ def run_test_set(test_set)
150
+ test_set.each do |test|
151
+ run_command = "#{@static_run_command} -n #{test} #{@pipe_tap} > #{@result_dir}/#{test}.t"
152
+ pipe = IO.popen(run_command)
153
+ puts "Running #{test} #{pipe.pid}"
154
+ end
155
+ end
156
+
157
+ # recursively keep running #{simultaneous_jobs} number of tests in parallel
158
+ # exit when no test left to run
159
+ def keep_running_full(all_to_run)
160
+ running_subprocess_count = count_browsery_process - 1 # minus parent process
161
+ puts "WARNING: running_subprocess_count = #{running_subprocess_count}
162
+ is more than what it is supposed to run(#{simultaneous_jobs}),
163
+ notify browsery maintainers" if running_subprocess_count > simultaneous_jobs + 1
164
+ while running_subprocess_count >= simultaneous_jobs
165
+ sleep 5
166
+ running_subprocess_count = count_browsery_process - 1
167
+ end
168
+ to_run_count = simultaneous_jobs - running_subprocess_count
169
+ tests_to_run = all_to_run.slice!(0, to_run_count)
170
+
171
+ run_test_set(tests_to_run)
172
+
173
+ keep_running_full(all_to_run) if all_to_run.size > 0
174
+ end
175
+
176
+ # @deprecated Use more native wait/check of Process
177
+ def wait_for_pids(pids)
178
+ running_pids = pids # assume all pids are running at this moment
179
+ while running_pids.size > 1
180
+ sleep 5
181
+ puts "running_pids = #{running_pids}"
182
+ running_pids.each do |pid|
183
+ unless process_running?(pid)
184
+ puts "#{pid} is not running, removing it from pool"
185
+ running_pids.delete(pid)
186
+ end
187
+ end
188
+ end
189
+ end
190
+
191
+ # @deprecated Too time consuming and fragile, should use more native wait/check of Process
192
+ def wait_all_done_saucelabs
193
+ size = all_tests.size
194
+ job_statuses = saucelabs_last_n_statuses(size)
195
+ while job_statuses.include?('in progress')
196
+ puts "There are tests still running, waiting..."
197
+ sleep 20
198
+ job_statuses = saucelabs_last_n_statuses(size)
199
+ end
200
+ end
201
+
202
+ private
203
+
204
+ # call saucelabs REST API to get last #{limit} jobs' statuses
205
+ # possible job status: complete, error, in progress
206
+ def saucelabs_last_n_statuses(limit)
207
+ username = Browsery.settings.sauce_username
208
+ access_key = Browsery.settings.sauce_access_key
209
+
210
+ # call api to get most recent #{limit} jobs' ids
211
+ http_auth = "https://#{username}:#{access_key}@saucelabs.com/rest/v1/#{username}/jobs?limit=#{limit}"
212
+ response = get_response_with_retry(http_auth) # response was originally an array of hashs, but RestClient converts it to a string
213
+ # convert response back to array
214
+ response[0] = ''
215
+ response[response.length-1] = ''
216
+ array_of_hash = response.split(',')
217
+ id_array = Array.new
218
+ array_of_hash.each do |hash|
219
+ hash = hash.gsub(':', '=>')
220
+ hash = eval(hash)
221
+ id_array << hash['id'] # each hash contains key 'id' and value of id
222
+ end
223
+
224
+ # call api to get job statuses
225
+ statuses = Array.new
226
+ id_array.each do |id|
227
+ http_auth = "https://#{username}:#{access_key}@saucelabs.com/rest/v1/#{username}/jobs/#{id}"
228
+ response = get_response_with_retry(http_auth)
229
+ begin
230
+ # convert response back to hash
231
+ str = response.gsub(':', '=>')
232
+ # this is a good example why using eval is dangerous, the string has to contain only proper Ruby syntax, here it has 'null' instead of 'nil'
233
+ formatted_str = str.gsub('null', 'nil')
234
+ hash = eval(formatted_str)
235
+ statuses << hash['status']
236
+ rescue SyntaxError
237
+ puts "SyntaxError, response from saucelabs has syntax error"
238
+ end
239
+ end
240
+ return statuses
241
+ end
242
+
243
+ def get_response_with_retry(url)
244
+ retries = 5 # number of retries
245
+ begin
246
+ response = RestClient.get(url) # returns a String
247
+ rescue
248
+ puts "Failed at getting response from #{url} via RestClient \n Retrying..."
249
+ retries -= 1
250
+ retry if retries > 0
251
+ response = RestClient.get(url) # retry the last time, fail if it still throws exception
252
+ end
253
+ end
254
+
255
+ def process_running?(pid)
256
+ begin
257
+ Process.getpgid(pid)
258
+ true
259
+ rescue Errno::ESRCH
260
+ false
261
+ end
262
+ end
263
+
264
+ end
265
+ end
@@ -0,0 +1,111 @@
1
+ module Browsery
2
+ class Runner
3
+
4
+ attr_accessor :options
5
+ @after_hooks = []
6
+ @@rerun_count = 0
7
+
8
+ def self.after_run(&blk)
9
+ @after_hooks << blk
10
+ end
11
+
12
+ def self.run!(args)
13
+ exit_code = self.run(args)
14
+ @after_hooks.reverse_each(&:call)
15
+ Kernel.exit(exit_code || false)
16
+ end
17
+
18
+ def self.run args = []
19
+ Minitest.load_plugins
20
+
21
+ @options = Minitest.process_args args
22
+
23
+ self.before_run
24
+
25
+ reporter = self.single_run
26
+
27
+ rerun_failure = @options[:rerun_failure]
28
+ if rerun_failure && !reporter.passed?
29
+ while @@rerun_count < rerun_failure && !reporter.passed?
30
+ reporter = self.single_run
31
+ @@rerun_count += 1
32
+ end
33
+ end
34
+
35
+ reporter.passed?
36
+ end
37
+
38
+ # Inialize a new reporter, run test
39
+ # Return reporter, which carrys test result
40
+ def self.single_run
41
+ reporter = Minitest::CompositeReporter.new
42
+ reporter << Minitest::SummaryReporter.new(@options[:io], @options)
43
+ reporter << Minitest::ProgressReporter.new(@options[:io], @options)
44
+
45
+ Minitest.reporter = reporter # this makes it available to plugins
46
+ Minitest.init_plugins @options
47
+ Minitest.reporter = nil # runnables shouldn't depend on the reporter, ever
48
+
49
+ reporter.start
50
+ Minitest.__run reporter, @options
51
+ Minitest.parallel_executor.shutdown
52
+ reporter.report
53
+
54
+ reporter
55
+ end
56
+
57
+ # before hook where you have parsed @options when loading tests
58
+ def self.before_run
59
+ tests_yml_full_path = Browsery.root.join('config/browsery', 'tests.yml').to_s
60
+ if File.exist? tests_yml_full_path
61
+ self.load_tests(tests_yml_full_path)
62
+ else
63
+ puts "Config file #{tests_yml_full_path} doesn't exist"
64
+ puts "browsery doesn't know where your tests are located and how they are structured"
65
+ end
66
+ end
67
+
68
+ # only load tests you need by specifying env option in command line
69
+ def self.load_tests(tests_yml_full_path)
70
+ tests_yml = YAML.load_file tests_yml_full_path
71
+
72
+ self.check_config(tests_yml)
73
+
74
+ tests_dir_relative_path = tests_yml['tests_dir']['relative_path']
75
+ multi_host_flag = tests_yml['tests_dir']['multi-host']
76
+ default_host = tests_yml['tests_dir']['default_host']
77
+ host = @options[:env].split(/_/)[0] rescue default_host
78
+
79
+ self.configure_load_path(tests_dir_relative_path)
80
+
81
+ # load page_objects.rb first
82
+ Dir.glob("#{tests_dir_relative_path}/#{multi_host_flag ? host+'/' : ''}*.rb") do |f|
83
+ f.sub!(/^#{tests_dir_relative_path}\//, '')
84
+ require f
85
+ end
86
+
87
+ # files under subdirectories shouldn't be loaded, eg. archive/
88
+ Dir.glob("#{tests_dir_relative_path}/#{multi_host_flag ? host+'/' : ''}test_cases/*.rb") do |f|
89
+ f.sub!(/^#{tests_dir_relative_path}\//, '')
90
+ require f
91
+ end
92
+ end
93
+
94
+ def self.check_config(tests_yml)
95
+ raise "relative_path must be provided in #{tests_yml}" unless tests_yml['tests_dir']['relative_path'].is_a? String
96
+ raise "multi-host must be provided in #{tests_yml}" unless [true, false].include?(tests_yml['tests_dir']['multi-host'])
97
+ raise "default_host must be provided in #{tests_yml}" unless tests_yml['tests_dir']['default_host'].is_a? String
98
+ end
99
+
100
+ def self.configure_load_path(tests_dir_relative_path)
101
+ tests_dir_full_path = Browsery.root.join(tests_dir_relative_path).to_s
102
+ if Dir.exist? tests_dir_full_path
103
+ $LOAD_PATH << tests_dir_full_path
104
+ else
105
+ puts "Tests directory #{tests_dir_full_path} doesn't exist"
106
+ puts "No test will run."
107
+ end
108
+ end
109
+
110
+ end
111
+ end
@@ -0,0 +1,114 @@
1
+ module Browsery
2
+
3
+ # An object that holds runtime settings.
4
+ #
5
+ # Furthermore, Minitest doesn't provide any good way of passing a hash of
6
+ # options to each test.
7
+ #
8
+ # TODO: We're importing ActiveSupport's extensions to Hash, which means that
9
+ # we'll be amending the way Hash objects work; once AS updates themselves to
10
+ # ruby 2.0 refinements, let's move towards that.
11
+ class Settings
12
+
13
+ def initialize
14
+ @hsh = {}
15
+ end
16
+
17
+ def inspect
18
+ settings = self.class.public_instance_methods(false).sort.map(&:inspect).join(', ')
19
+ "#<Browsery::Settings #{settings}>"
20
+ end
21
+
22
+ def auto_finalize?
23
+ hsh.fetch(:auto_finalize, true)
24
+ end
25
+
26
+ def connector
27
+ hsh.fetch(:connector, :firefox).to_s
28
+ end
29
+
30
+ def env
31
+ # add a gitignored env file which stores a default env
32
+ # pass the default env in as default
33
+ hsh.fetch(:env, :rent_qa).to_s
34
+ end
35
+
36
+ def sauce_session_http_auth(driver)
37
+ session_id = driver.session_id
38
+ "https://#{sauce_username}:#{sauce_access_key}@saucelabs.com/rest/v1/#{sauce_username}/jobs/#{session_id}"
39
+ end
40
+
41
+ def sauce_username
42
+ sauce_user["user"]
43
+ end
44
+
45
+ def sauce_access_key
46
+ sauce_user["pass"]
47
+ end
48
+
49
+ def io
50
+ hsh[:io]
51
+ end
52
+
53
+ def merge!(other)
54
+ hsh.merge!(other.symbolize_keys)
55
+ self
56
+ end
57
+
58
+ # can be used as a flag no matter parallel option is used in command line or not
59
+ # can also be used to fetch the value if a valid value is specified
60
+ def parallel
61
+ if hsh[:parallel] == 0
62
+ return nil
63
+ else
64
+ hsh.fetch(:parallel).to_i
65
+ end
66
+ end
67
+
68
+ def raw_arguments
69
+ hsh.fetch(:args, nil).to_s
70
+ end
71
+
72
+ def reuse_driver?
73
+ hsh.fetch(:reuse_driver, false)
74
+ end
75
+
76
+ def rerun_failure
77
+ hsh.fetch(:rerun_failure)
78
+ end
79
+
80
+ def seed
81
+ hsh.fetch(:seed, nil).to_i
82
+ end
83
+
84
+ def tags
85
+ hsh[:tags] ||= []
86
+ end
87
+
88
+ def verbose?
89
+ verbosity_level > 0
90
+ end
91
+
92
+ def verbosity_level
93
+ hsh.fetch(:verbosity_level, 0).to_i
94
+ end
95
+
96
+ private
97
+ attr_reader :hsh
98
+
99
+ def sauce_user
100
+ overrides = connector.split(/:/)
101
+ file_name = overrides.shift
102
+ path = Browsery.root.join('config/browsery', 'connectors')
103
+ filepath = path.join("#{file_name}.yml")
104
+ raise ArgumentError, "Cannot load profile #{file_name.inspect} because #{filepath.inspect} does not exist" unless filepath.exist?
105
+
106
+ cfg = YAML.load(File.read(filepath))
107
+ cfg = Connector.resolve(cfg, overrides)
108
+ cfg.freeze
109
+ cfg["hub"]
110
+ end
111
+
112
+ end
113
+
114
+ end