2 # iExploder browser Harness (test a single web browser)
4 # Copyright 2010 Thomas Stromberg - All Rights Reserved.
6 # Licensed under the Apache License, Version 2.0 (the "License");
7 # you may not use this file except in compliance with the License.
8 # You may obtain a copy of the License at
10 # http://www.apache.org/licenses/LICENSE-2.0
12 # Unless required by applicable law or agreed to in writing, software
13 # distributed under the License is distributed on an "AS IS" BASIS,
14 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 # See the License for the specific language governing permissions and
16 # limitations under the License.
18 #----------------------------------------------------------------------------
21 # You must disable automatic session restoring for this to be useful.
24 # opera --nosession -newprivatetab
29 require './iexploder.rb'
30 require './scanner.rb'
32 MAC_CRASH_PATH = "#{ENV['HOME']}/Library/Logs/CrashReporter"
33 TESTCASE_URL = "http://127.0.0.1:3100/iexploder.cgi"
36 def initialize(port, config_path, log_dir, test_dir, watchdog_timer, scan_timer)
37 @app_base_url = "http://127.0.0.1:#{port}/"
38 @app_url = "#{@app_base_url}iexploder.cgi"
41 @server_log_path = "#{log_dir}/iexploder_webserver-#{port}.log"
42 @client_log_path = "#{log_dir}/iexploder_harness-#{port}.log"
44 @watchdog_timer = watchdog_timer
45 @scan_timer = scan_timer
46 @config_path = config_path
48 @ie = IExploder.new(@config_path)
49 @ie.cgi_url = @app_url
53 msg("Client log: #{@client_log_path}")
54 msg("Server log: #{@server_log_path}")
55 @server_pid = launch_server()
60 msg = ">>> #{@browser_name}:#{@port} | #{now}: #{text}"
64 f = File.open(@client_log_path, 'a')
70 args = ['./webserver.rb', "-p#{@port}", "-c#{@config_path}", "-l#{@server_log_path}"]
71 pids = fork { exec(*args) }
72 msg("Server args: #{args.inspect}")
73 msg("Server pid: #{pids.inspect}")
77 def launch_browser(args, url)
78 if ! File.exist?(args[0])
79 msg("First argument does not appear to be an executable file: #{args[0]}")
84 browser = File.basename(args[0])
85 @browser_name = File.basename(browser)
86 if browser =~ /\.app$/
87 pids = launch_mac_browser(args, url)
89 pids = launch_posix_browser(args, url)
91 sleep(@scan_timer * 3)
92 if ! File.size?(@server_log_path)
93 puts "#{@server_log_path} was never written to. Unable to launch browser?"
100 def launch_posix_browser(args, url)
101 browser = File.basename(args[0])
102 msg("Killing browser processes: #{browser}")
103 system("pkill #{browser} && pkill -9 #{browser}")
105 msg("Launching browser: #{args.inspect}")
113 # Only tested on Mac OS X.
115 `ps -x`.each do |proc_line|
116 if proc_line =~ /^ *(\d+).*#{text}/
118 # Do not include yourself.
119 if pid != Process.pid
127 def launch_mac_browser(args, url)
128 # This is dedicated to Safari.
130 msg(".app type launches do not support arguments, ignoring #{args[1..99].inspect}")
133 pids = find_pids(browser)
135 kill_pids(find_pids(browser))
138 command = "open -a \"#{browser}\" \"#{url}\""
139 msg(".app open command: #{command}")
141 return find_pids(browser)
146 msg("Killing #{pid}")
148 Process.kill("INT", pid)
150 Process.kill("KILL", pid)
158 return @browser_id.gsub(' ', '_').gsub(';', '').gsub('/', '-').gsub(/[\(\):\!\@\#\$\%\^\&\*\+=\{\}\[\]\'\"\<\>\?\|\\]/, '').gsub(/_$/, '').gsub(/^_/, '')
162 kill_pids([@server_pid])
165 def parse_test_url(value)
169 lookup_values = false
170 if value =~ /iexploder.cgi(.*)/
172 if current_vars =~ /[&\?]t=(\d+)/
175 if current_vars =~ /[&\?]s=([\d_,]+)/
178 if current_vars =~ /[&\?]l=(\w+)/
182 msg("Unable to parse url in #{value}")
183 return [nil, nil, nil, nil]
185 return [current_vars, test_num, subtest_data, lookup_value]
188 def check_log_status()
189 timestamp, uri, user_agent = open("#{@app_base_url}last_page.cgi").read().chomp.split(' ')
190 age = (Time.now() - timestamp.to_i).to_i
192 @browser_id = CGI.unescape(user_agent)
193 msg("My browser is #{@browser_id}")
200 def save_testcase(url, case_type=nil)
201 msg("Saving testcase: #{url}")
202 vars, test_num, subtest_data, lookup_value = parse_test_url(url)
204 case_type = 'testcase'
207 testcase_name = ([case_type, encode_browser(), 'TEST', test_num, subtest_data].join('-')).gsub(/-$/, '') + ".html"
208 testcase_path = "#{@test_dir}/#{testcase_name}"
209 data = open(url).read()
210 # Slow down our redirection time, and replace our testcase urls.
211 data.gsub!(/0;URL=\/iexploder.*?\"/, "1;URL=#{testcase_name}\"")
212 data.gsub!(/window\.location=\"\/iexploder.*?\"/, "window\.location=\"#{testcase_name}\"")
214 # I wish I did not have to do this, but the reality is that I can't imitate header fuzzing
215 # without a webservice in the backend. Change all URL's to use a well known localhost
217 data.gsub!(/\/iexploder.cgi/, TESTCASE_URL)
219 f = File.open(testcase_path, 'w')
222 msg("Wrote testcase #{testcase_path}")
226 def calculate_next_url(test_num, subtest_data)
227 @ie.test_num = test_num.to_i
228 @ie.subtest_data = subtest_data
229 if subtest_data and subtest_data.length > 0
230 (width, offsets) = @ie.parseSubTestData(subtest_data)
231 # We increment within combo_creator
232 (width, offsets, lines) = combine_combo_creator(@ie.config['html_tags_per_page'], width, offsets)
233 return @ie.generateTestUrl(@ie.nextTestNum(), width, offsets)
235 return @ie.generateTestUrl(@ie.nextTestNum())
239 def find_crash_logs(max_age)
241 check_files = Dir.glob("*core*")
242 if File.exists?(MAC_CRASH_PATH)
243 check_files = check_files + Dir.glob("#{MAC_CRASH_PATH}/*.*")
245 check_files.each do |file|
246 mtime = File.stat(file).mtime
247 age = (Time.now() - mtime).to_i
249 msg("#{file} is only #{age}s old: #{mtime}")
250 crashed_files << file
256 def test_browser(args, test_num, random_mode=false)
257 # NOTE: random_mode is not yet supported.
261 @ie.test_num = test_num
262 @ie.random_mode = random_mode
263 next_url = @ie.generateTestUrl(test_num)
266 msg("Starting at: #{next_url}")
268 kill_pids(browser_pids)
270 browser_pids = launch_browser(args, next_url)
271 test_is_running = true
274 while test_is_running
277 age, request_uri = check_log_status()
279 msg("Failed to get status. webserver likely crashed.")
280 kill_pids([@server_pid])
281 @server_pid = launch_server()
282 next_url = @ie.generateTestUrl(test_num)
283 test_is_running = false
286 vars, test_num, subtest_data, lookup_value = parse_test_url(request_uri)
287 if lookup_value == 'survived_redirect'
288 msg("We survived #{vars}. Bummer, could not repeat crash. Moving on.")
289 test_is_running = false
290 next_url = calculate_next_url(test_num, subtest_data)
292 elsif age > @watchdog_timer
293 msg("Stuck at #{vars}, waited for #{@watchdog_timer}s. Killing browser.")
294 kill_pids(browser_pids)
295 current_url = "#{@app_url}#{vars}"
296 # save_testcase(current_url, 'possible')
297 crash_files = find_crash_logs(@watchdog_timer + (@scan_timer * 2))
298 if crash_files.length > 0
299 msg("Found recent crash logs: #{crash_files.inspect} - last page: #{current_url}")
303 msg("We hung at the end. Saving a testcase just in case.")
304 save_testcase(current_url)
305 next_url = calculate_next_url(test_num, nil)
306 test_is_running = false
310 # This is for subtesting
313 msg("Confirmed crashing/hanging page at #{current_url} - saving testcase.")
314 save_testcase(current_url)
315 next_url = calculate_next_url(test_num, nil)
316 test_is_running = false
319 msg("Stopped at #{current_url}. Attempting to reproduce simplified crash/hang condition.")
320 browser_pids = launch_browser(args, "#{current_url}&l=test_redirect")
322 # Normal testing goes here
325 msg("Reproducible crash/hang at #{current_url}, generating smaller test case.")
326 url = current_url.gsub(/&l=(\w+)/, '')
327 browser_pids = launch_browser(args, "#{url}&s=0")
329 msg("Stopped at #{current_url}. Attempting to reproduce crash/hang condition.")
330 browser_pids = launch_browser(args, "#{current_url}&l=test_redirect")
333 elsif age > @scan_timer
334 msg("Waiting for #{vars} to finish loading... (#{age}s of #{@watchdog_timer}s)")
343 :port => rand(16000).to_i + 16000,
344 :test_dir => File.dirname($0) + '/../output',
345 :log_dir => File.dirname($0) + '/../output',
347 :watchdog_timer => 60,
349 :config_path => 'config.yaml',
350 :random_mode => false
353 optparse = OptionParser.new do |opts|
354 opts.banner = "Usage: browser_harness.rb [options] -- <browser path> <browser options>"
355 opts.on( '-t', '--test NUM', 'Test to start at' ) { |test_num| options[:test_num] = test_num.to_i }
356 opts.on( '-p', '--port NUM', 'Listen on TCP port NUM (random)' ) { |port| options[:port] = port.to_i }
357 opts.on( '-c', '--config PATH', 'Use PATH for configuration file' ) { |path| options[:config_path] = path }
358 opts.on( '-d', '--testdir PATH', 'Use PATH to save testcases (/tmp)' ) { |path| options[:test_dir] = path }
359 opts.on( '-l', '--logdir PATH', 'Use PATH to save logs (/tmp)' ) { |path| options[:log_dir] = path }
360 opts.on( '-w', '--watchdog NUM', 'How many seconds to wait for pages to load (45s)' ) { |sec| options[:watchdog_timer] = sec.to_i }
361 opts.on( '-r', '--random', 'Generate test numbers pseudo-randomly' ) { options[:random_mode] = true }
362 opts.on( '-s', '--scan NUM', 'How often to check for new log data (5s)' ) { |sec| options[:scan_timer] = sec.to_i }
363 opts.on( '-h', '--help', 'Display this screen' ) { puts opts; exit }
367 if options[:port] == 0
368 puts "Unable to parse port option. Try adding -- as an argument before you specify your browser location."
373 puts "No browser specified. Perhaps you need some --help?"
376 puts "options: #{options.inspect}"
377 puts "browser: #{ARGV.inspect}"
379 harness = BrowserHarness.new(
381 options[:config_path],
384 options[:watchdog_timer],
388 harness.test_browser(ARGV, options[:test_num], options[:random_mode])