dbrady-tourbus 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
data/MIT-LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2008-2009 David Brady github@shinybit.com
2
+
3
+ Permission is hereby granted, free of charge, to any person
4
+ obtaining a copy of this software and associated documentation
5
+ files (the "Software"), to deal in the Software without
6
+ restriction, including without limitation the rights to use,
7
+ copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the
9
+ Software is furnished to do so, subject to the following
10
+ conditions:
11
+
12
+ The above copyright notice and this permission notice shall be
13
+ included in all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
17
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
19
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
20
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
data/README.txt ADDED
@@ -0,0 +1,139 @@
1
+ = TourBus
2
+
3
+ Flexible and scalable website testing tool.
4
+
5
+ == Authors
6
+
7
+ * David Brady -- david.brady@leadmediapartners.com
8
+ * Tim Harper -- tim.harper@leadmediapartners.com
9
+
10
+ == General Info
11
+
12
+ TourBus is an intelligent website load testing tool. Allows for
13
+ complicated testing scenarios including filling out forms, following
14
+ redirects, handling cookies, and following links--all of the things
15
+ you'd normally associate with a regression suite or integration
16
+ testing tool. The difference is that TourBus also scales concurrently,
17
+ and you can perform hundreds of complicated regression tests
18
+ simultaneously in order to thoroughly load test your website.
19
+
20
+ == Motivation
21
+
22
+ I started writing TourBus because I needed flexibility and scalability
23
+ in a website testing tool, and the extant tools all provided one but
24
+ not the other. Selenium is ultraflexible but limited to the number of
25
+ browsers you can have open at once, while Apache Bench is powerful and
26
+ fast but limited to simple tests.
27
+
28
+ TourBus lets you define complicated paths through your website, then
29
+ execute those paths concurrently for stress testing.
30
+
31
+ == Example
32
+
33
+ To see TourBus in action, you need to write scripts. For lack of a
34
+ better name, these are called Tours.
35
+
36
+ === Example Tour
37
+
38
+ * Make a folder called tours and put a file in it called simple.rb. In
39
+ it write:
40
+
41
+ class Simple < Tour
42
+ def test_homepage
43
+ open_page "http://#{@host}/"
44
+ assert_page_body_contains "My Home Page"
45
+ end
46
+ end
47
+
48
+ * Files in ./tours should have classes that match their names. E.g.
49
+ "class BigHairyTest < Tour" belongs in ./tours/big_hairy_test.rb
50
+
51
+ * Think Test::Unit. test_* methods will be found automagically.
52
+ setup() and teardown() methods will be executed at the appropriate
53
+ times.
54
+
55
+ === Example TourBus Run
56
+
57
+ tourbus -c 2 -n 3 simple
58
+
59
+ This will create 2 concurrent Tour runners, each of which will run all
60
+ of the methods in Simple three times.
61
+
62
+ * You can specify multiple tours.
63
+
64
+ * If you don't specify a tour, all tours in ./tours will be run.
65
+
66
+ * tourbus --help will give you more information.
67
+
68
+ === Example TourWatch Run
69
+
70
+ On the webserver, you can type
71
+
72
+ tourwatch -c 4
73
+
74
+ To begin running tourwatch. It's basically a stripped-down version of
75
+ top with cheesy text graphs. (TourWatch's development cycles were
76
+ included in the 2 days for TourBus.)
77
+
78
+ * The -c option is for the total number of cores on the server. The
79
+ top app will cheerfully report a process as taking 392% CPU if it is
80
+ using 98% of four cores. This option is only necessary for making
81
+ the little text graphs scale correctly.
82
+
83
+ * You can choose which processes to watch by passing a csv to -p:
84
+
85
+ tourwatch -p ruby,mongrel
86
+
87
+ Each process name is a partial regexp, so the above would match
88
+ mongrel AND mongrel_rails, etc.
89
+
90
+ * tourwatch --help will give you more information.
91
+
92
+ == History and Status
93
+
94
+ TourBus began life as a 2-day throwaway app. It is definitely an app
95
+ whose development provides many opportunities for open-source
96
+ contributors to make improvements. It is chock-full of brutal hacks,
97
+ duplications, oversights, and kludges.
98
+
99
+ == Hacks, Kludges, Known Issues, and Piles of Steaming Poo
100
+
101
+ * Mechanize 0.8 doesn't always play well together with TourBus. If you
102
+ get "connection refused" socket errors, try upgrading to Mechanize
103
+ 0.9.
104
+
105
+ * JRuby doesn't play well with Nokogiri. I have set the html_parser to
106
+ use hpricot, which should work around the issue for now.
107
+
108
+ * There are no specs. Yikes! This is to my eternal shame because I'm
109
+ sort of a testing freak. Because TourBus *WAS* a testing tool, I
110
+ didn't put tests on it. I haven't put tests on it yet because I'm
111
+ not sure how to go about it. Instead of exercising a web app with a
112
+ test browser, we need to exercise a test browser with... um... a web
113
+ app?
114
+
115
+ * Web-Sickle is another internal app, written by Tim Harper, that
116
+ works "well enough". Until I open-sourced this project, it was a
117
+ submodule in the app. We wanted to keep TourBus extensions separate
118
+ from WebSickle itself, so there's a lot of code in Runner that
119
+ really belongs in WebSickle.
120
+
121
+ * Documentation is horrible.
122
+
123
+ * There's not much in the way of examples, either. When I removed all
124
+ the LMP-specific code, all of the examples went with it. Sorry about
125
+ that. Coming soon.
126
+
127
+ == Credits
128
+
129
+ * Tim Harper camped at my place for a day fixing bugs in WebSickle as
130
+ I exercised more and more new bits of it. Thanks, dude.
131
+
132
+ * Lead Media Partners paid me to write TourBus, then let me open
133
+ source it. How much do they rock? All the way to 11, that's how much
134
+ they rock.
135
+
136
+ == License
137
+
138
+ MIT. See the license file.
139
+
data/bin/tourbus ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib', 'common'))
4
+ require 'trollop'
5
+ require_all_files_in_folder 'tours'
6
+
7
+ opts = Trollop.options do
8
+ opt :host, "Remote hostname to test", :default => "localhost:3000"
9
+ opt :concurrency, "Number of simultaneous runs to perform", :type => :integer, :default => 1
10
+ opt :number, "Number of times to run the tour (in each concurrent step, so -c 10 -n 10 will run the tour 100 times)", :type => :integer, :default => 1
11
+ opt :verbose, "Run in verbose mode", :type => :boolean, :default => false
12
+ opt :list, "List tours and runs available. If tours or runs are included, filters the list", :type => :boolean, :default => nil
13
+ end
14
+
15
+ tours = if ARGV.empty?
16
+ Dir[File.join('.', 'tours', '*.rb')].map {|f| File.basename(f, ".rb")}
17
+ else
18
+ ARGV
19
+ end
20
+
21
+ if opts[:list]
22
+ Tour.tours(ARGV).each do |tour|
23
+ puts tour
24
+ puts Tour.tests(tour).map {|test| " #{test}"}
25
+ end
26
+ else
27
+ puts Benchmark.measure { TourBus.new(opts[:host], opts[:concurrency], opts[:number], tours).run }
28
+ end
29
+
data/bin/tourwatch ADDED
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # tourwatch - cheap monitor program for tourbus
4
+ #
5
+ # Notes:
6
+ #
7
+ # tourwatch is a cheap logger program for tourbus. It runs on the
8
+ # targeted server and monitors cpu and memory usage of webserver
9
+ # processes. It's a moderately quick hack: I have a 2-hour budget to
10
+ # write and debug the whole thing and here I am wasting time by
11
+ # starting with documentation. This is because I figure the chance of
12
+ # this program needing maintenance in the next 6 months to be well
13
+ # over 100%, and the poor guy behind me (Hey, that's you! Hi.) will
14
+ # need to know why tourwatch is so barebones.
15
+ #
16
+ # So. TourWatch runs on the target server, collects top information
17
+ # every second, and logs it to file. End of story. "Automation" is
18
+ # handled by the meat cloud (Hey, that's you! Hi.) when the maintainer
19
+ # starts and stops the process manually. Report collection is handled
20
+ # by you reading the logfiles in a terminal. Report aggregation is
21
+ # handled by you aggregating the reports. Yes, there's a theme here.
22
+ #
23
+ # TODO:
24
+ #
25
+ # - Remote reporting? Send log events to main log server?
26
+ #
27
+ # - If we logged to a lightweight database like sqlite3, we could do
28
+ # some clever things like track individual pids and process groups.
29
+ # This would let us track, e.g., aggregate apache stress as well as
30
+ # rogue mongrels. I'm not doing this now because it will require
31
+ # writing something to read and parse the previous information. For
32
+ # now, we'll leave it up to the user (Hey, that's you! Hi.) to parse
33
+ # the logfiles.
34
+ #
35
+ # - Tweak output format. Currently it's crap. I don't think we need
36
+ # dynamic templating or anything, but it might be nice to improve
37
+ # the existing formats.
38
+
39
+ require File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib', 'common'))
40
+ require 'trollop'
41
+ require 'tour_watch'
42
+
43
+ opts = Trollop.options do
44
+ opt :outfile, "Logfile name (default to STDOUT)", :type => :string, :default => nil
45
+ opt :processes, "csv of processes to monitor", :type => :string, :default => nil
46
+ opt :cores, "number of cores present (max CPU% is number of cores * 100)", :type => :integer, :default => 4
47
+ opt :mac, "Set if running on MacOSX. The Mac top command is different than linux top.", :type => :boolean, :default => false
48
+ end
49
+
50
+ TourWatch.new(opts).run
51
+
52
+
data/lib/common.rb ADDED
@@ -0,0 +1,31 @@
1
+ # common.rb - Common settings, requires and helpers
2
+ unless defined? TOURBUS_LIB_PATH
3
+ TOURBUS_LIB_PATH = File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib'))
4
+ $:<< TOURBUS_LIB_PATH unless $:.include? TOURBUS_LIB_PATH
5
+ end
6
+
7
+ require 'rubygems'
8
+
9
+ gem 'mechanize', ">= 0.8.5"
10
+ gem 'trollop', ">= 1.10.0"
11
+ gem 'faker', '>= 0.3.1'
12
+
13
+ # TODO: I'd like to remove dependency on Rails. Need to see what all
14
+ # we're using (like classify) and remove each dependency individually.
15
+ require 'activesupport'
16
+
17
+ require 'monitor'
18
+ require 'faker'
19
+ require 'web-sickle/init'
20
+ require 'tour_bus'
21
+ require 'runner'
22
+ require 'tour'
23
+
24
+
25
+ class TourBusException < Exception; end
26
+
27
+ def require_all_files_in_folder(folder, extension = "*.rb")
28
+ for file in Dir[File.join('.', folder, "**/#{extension}")]
29
+ require file
30
+ end
31
+ end
data/lib/runner.rb ADDED
@@ -0,0 +1,67 @@
1
+ require 'monitor'
2
+ require 'common'
3
+
4
+ class Runner
5
+ attr_reader :host, :tours, :number, :runner_type, :runner_id
6
+
7
+ def initialize(host, tours, number, runner_id)
8
+ @host, @tours, @number, @runner_id = host, tours, number, runner_id
9
+ @runner_type = self.send(:class).to_s
10
+ log("Ready to run #{@runner_type}")
11
+ end
12
+
13
+ # Dispatches to subclass run method
14
+ def run_tours
15
+ runs,passes,fails,errors = 0,0,0,0
16
+ 1.upto(number) do |num|
17
+ log("Starting #{@runner_type} run #{num}/#{number}")
18
+ begin
19
+ @tours.each do |tour|
20
+ runs += 1
21
+ tour = Tour.make_tour(tour,@host,@tours,@number,@runner_id)
22
+ tour.tests.each do |test|
23
+ tour.run_test test
24
+ end
25
+ end
26
+ passes += 1
27
+ rescue TourBusException => e
28
+ log("***********************************")
29
+ log("********** ERROR IN RUN! **********")
30
+ log("***********************************")
31
+ log e.message
32
+ e.backtrace.each do |trace|
33
+ log trace
34
+ end
35
+ fails += 1
36
+ rescue WebsickleException => e
37
+ log("***********************************")
38
+ log("********** ERROR IN RUN! **********")
39
+ log("***********************************")
40
+ log e.message
41
+ e.backtrace.each do |trace|
42
+ log trace
43
+ end
44
+ fails += 1
45
+ rescue Exception => e
46
+ log("***********************************")
47
+ log("********** ERROR IN RUN! **********")
48
+ log("***********************************")
49
+ log e.message
50
+ e.backtrace.each do |trace|
51
+ log trace
52
+ end
53
+ errors += 1
54
+ end
55
+ log("Finished #{@runner_type} run #{num}/#{number}")
56
+ end
57
+ log("Finished all #{@runner_type} runs.")
58
+ [runs,passes,fails,errors]
59
+ end
60
+
61
+ protected
62
+
63
+ def log(message)
64
+ puts "#{Time.now.strftime('%F %H:%M:%S')} Runner ##{@runner_id}: #{message}"
65
+ end
66
+ end
67
+
data/lib/tour.rb ADDED
@@ -0,0 +1,110 @@
1
+ require 'monitor'
2
+ require 'common'
3
+
4
+ # A tour is essentially a test suite file. A Tour subclass
5
+ # encapsulates a set of tests that can be done, and may contain helper
6
+ # and support methods for a given task. If you have a two or three
7
+ # paths through a specific area of your website, define a tour for
8
+ # that area and create test_ methods for each type of test to be done.
9
+
10
+ class Tour
11
+ include WebSickle
12
+ attr_reader :host, :tours, :number, :tour_type, :tour_id
13
+
14
+ def initialize(host, tours, number, tour_id)
15
+ @host, @tours, @number, @tour_id = host, tours, number, tour_id
16
+ @tour_type = self.send(:class).to_s
17
+ end
18
+
19
+ def setup
20
+ end
21
+
22
+ def teardown
23
+ end
24
+
25
+ # Lists tours in tours folder. If a string is given, filters the
26
+ # list by that string. If an array of filter strings is given,
27
+ # returns items that match ANY filter string in the array.
28
+ def self.tours(filter=[])
29
+ filter = [filter].flatten
30
+ # All files in tours folder, stripped to basename, that match any item in filter
31
+ Dir[File.join('.', 'tours', '**', '*.rb')].map {|fn| File.basename(fn, ".rb")}.select {|fn| filter.size.zero? || filter.any?{|f| fn =~ /#{f}/}}
32
+ end
33
+
34
+ def self.tests(tour_name)
35
+ Tour.make_tour.tests
36
+ end
37
+
38
+ # Factory method, creates the named child class instance
39
+ def self.make_tour(tour_name,host,tours,number,tour_id)
40
+ tour_name.classify.constantize.new(host,tours,number,tour_id)
41
+ end
42
+
43
+ # Returns list of tests in this tour. (Meant to be run on a subclass
44
+ # instance; returns the list of tests available).
45
+ def tests
46
+ methods.grep(/^test_/).map {|m| m.sub(/^test_/,'')}
47
+ end
48
+
49
+ def run_test(test_name)
50
+ test = "test_#{test_name}"
51
+ raise TourBusException.new("run_test couldn't run test '#{test_name}' because this tour did not respond to :#{test}") unless respond_to? test
52
+ setup
53
+ send test
54
+ teardown
55
+ end
56
+
57
+ protected
58
+
59
+ def log(message)
60
+ puts "#{Time.now.strftime('%F %H:%M:%S')} Tour ##{@tour_id}: #{message}"
61
+ end
62
+
63
+ # given "portal", opens "http://#{@host}/portal"
64
+ def open_site_page(path)
65
+ open_page "http://#{@host}/#{path}"
66
+ end
67
+
68
+ def dump_form
69
+ log "Dumping Forms:"
70
+ page.forms.each do |form|
71
+ puts "Form: #{form.name}"
72
+ puts '-' * 20
73
+ (form.fields + form.radiobuttons + form.checkboxes + form.file_uploads).each do |field|
74
+ puts " #{field.name}"
75
+ end
76
+ end
77
+ end
78
+
79
+ # True if uri ends with the string given. If a regex is given, it is
80
+ # matched instead.
81
+ #
82
+ # TODO: Refactor me--these were separated out back when Websickle
83
+ # was a shared submodule and we couldn't pollute it. Now that it's
84
+ # frozen these probably belong there.
85
+ def assert_page_uri_matches(uri)
86
+ case uri
87
+ when String:
88
+ raise WebsickleException, "Expected page uri to match String '#{uri}' but did not. It was #{page.uri}" unless page.uri.to_s[-uri.size..-1] == uri
89
+ when Regexp:
90
+ raise WebsickleException, "Expected page uri to match Regexp '#{uri}' but did not. It was #{page.uri}" unless page.uri.to_s =~ uri
91
+ end
92
+ log "Page URI ok (#{page.uri} matches: #{uri})"
93
+ end
94
+
95
+ # True if page contains (or matches) the given string (or regexp)
96
+ #
97
+ # TODO: Refactor me--these were separated out back when Websickle
98
+ # was a shared submodule and we couldn't pollute it. Now that it's
99
+ # frozen these probably belong there.
100
+ def assert_page_body_contains(pattern)
101
+ case pattern
102
+ when String:
103
+ raise WebsickleException, "Expected page body to contain String '#{pattern}' but did not. It was #{page.body}" unless page.body.to_s.index(pattern)
104
+ when Regexp:
105
+ raise WebsickleException, "Expected page body to match Regexp '#{pattern}' but did not. It was #{page.body}" unless page.body.to_s =~ pattern
106
+ end
107
+ log "Page body ok (matches #{pattern})"
108
+ end
109
+ end
110
+
data/lib/tour_bus.rb ADDED
@@ -0,0 +1,84 @@
1
+ require 'benchmark'
2
+
3
+ class TourBus < Monitor
4
+ attr_reader :host, :concurrency, :number, :tours, :runs, :passes, :fails, :errors, :benchmarks
5
+
6
+ def initialize(host="localhost", concurrency=1, number=1, tours=[])
7
+ @host, @concurrency, @number, @tours = host, concurrency, number, tours
8
+ @runner_id = 0
9
+ @runs, @passes, @fails, @errors = 0,0,0,0
10
+ super()
11
+ end
12
+
13
+ def next_runner_id
14
+ synchronize do
15
+ @runner_id += 1
16
+ end
17
+ end
18
+
19
+ def update_stats(runs,passes,fails,errors)
20
+ synchronize do
21
+ @runs += runs
22
+ @passes += passes
23
+ @fails += fails
24
+ @errors += errors
25
+ end
26
+ end
27
+
28
+ def update_benchmarks(bm)
29
+ synchronize do
30
+ @benchmarks = @benchmarks.zip(bm).map { |a,b| a+b}
31
+ end
32
+ end
33
+
34
+ def runners(filter=[])
35
+ # All files in tours folder, stripped to basename, that match any item in filter
36
+ Dir[File.join('.', 'tours', '**', '*.rb')].map {|fn| File.basename(fn, ".rb")}.select {|fn| filter.size.zero? || filter.any?{|f| fn =~ /#{f}/}}
37
+ end
38
+
39
+ def run
40
+ threads = []
41
+ started = Time.now.to_f
42
+ concurrency.times do |conc|
43
+ log "Starting #{concurrency} runners to run #{tours.size} tours #{number} times (for a total of #{tours.size*concurrency*number} times)"
44
+ threads << Thread.new do
45
+ runner_id = next_runner_id
46
+ runs,passes,fails,errors,start = 0,0,0,0,Time.now.to_f
47
+ bm = Benchmark.measure do
48
+ runner = Runner.new(@host, @tours, @number, runner_id)
49
+ runs,passes,fails,errors = runner.run_tours
50
+ update_stats runs, passes, fails, errors
51
+ end
52
+ log "Runner Finished!"
53
+ log "Runner finished in %0.3f seconds" % (Time.now.to_f - start)
54
+ log "Runner Finished! runs,passes,fails,errors: #{runs},#{passes},#{fails},#{errors}"
55
+ log "Benchmark for runner #{runner_id}: #{bm}"
56
+ end
57
+ end
58
+ log "All Runners started!"
59
+ threads.each {|t| t.join }
60
+ finished = Time.now.to_f
61
+ log '-' * 80
62
+ log "All Runners finished."
63
+ log "Total Runs: #{@runs}"
64
+ log "Total Passes: #{@passes}"
65
+ log "Total Fails: #{@fails}"
66
+ log "Total Errors: #{@errors}"
67
+ log "Elapsed Time: #{finished - started}"
68
+ log "Speed: %5.3f v/s" % (@runs / (finished-started))
69
+ log '-' * 80
70
+ if @fails > 0 || @errors > 0
71
+ log '********************************************************************************'
72
+ log '********************************************************************************'
73
+ log ' !! THERE WERE FAILURES !!'
74
+ log '********************************************************************************'
75
+ log '********************************************************************************'
76
+ end
77
+ end
78
+
79
+ def log(message)
80
+ puts "#{Time.now.strftime('%F %H:%M:%S')} TourBus: #{message}"
81
+ end
82
+
83
+ end
84
+
data/lib/tour_watch.rb ADDED
@@ -0,0 +1,88 @@
1
+ class TourWatch
2
+ attr_reader :processes
3
+
4
+ def initialize(options={})
5
+ @processes = if options[:processes]
6
+ options[:processes].split(/,/) * '|'
7
+ else
8
+ "ruby|mysql|apache|http|rails|mongrel"
9
+ end
10
+ @cores = options[:cores] || 4
11
+ @logfile = options[:outfile]
12
+ @mac = options[:mac]
13
+ end
14
+
15
+ def stats
16
+ top = @mac ? top_mac : top_linux
17
+ lines = []
18
+ @longest = Hash.new(0)
19
+ top.each_line do |line|
20
+ name,pid,cpu = fields(line.split(/\s+/))
21
+ lines << [name,pid,cpu]
22
+ @longest[:name] = name.size if name.size > @longest[:name]
23
+ @longest[:pid] = pid.to_s.size if pid.to_s.size > @longest[:pid]
24
+ end
25
+ lines
26
+ end
27
+
28
+ def fields(parts)
29
+ @mac ? fields_mac(parts) : fields_linux(parts)
30
+ end
31
+
32
+ # Note: MacOSX is so awesome I just cacked. Top will report 0.0% cpu
33
+ # the first time you run top, every time. The only way to get actual
34
+ # CPU% here is to wait for it to send another page and then throw
35
+ # away the first page. Isn't that just awesome?!? I KNOW!!!
36
+ def top_mac
37
+ top = `top -l 1 | grep -E '(#{@processes})'`
38
+ end
39
+
40
+ def fields_mac(fields)
41
+ name,pid,cpu = fields[1], fields[0].to_i, fields[2].to_f
42
+ end
43
+
44
+ def top_linux
45
+ top = `top -bn 1 | grep -E '(#{@processes})'`
46
+ end
47
+
48
+
49
+ def fields_linux(fields)
50
+ # linux top isn't much smarter. It spits out a blank field ahead
51
+ # of the pid if the pid is too short, which makes the indexes
52
+ # shift off by one.
53
+ a,b,c = if fields.size == 13
54
+ [-1,1,9]
55
+ else
56
+ [-1,0,8]
57
+ end
58
+ name,pid,cpu = fields[a], fields[b].to_i, fields[c].to_f
59
+ end
60
+
61
+
62
+ def run()
63
+ while(true)
64
+ now = Time.now.to_i
65
+ if @time != now
66
+ log '--'
67
+ lines = stats
68
+ lines.sort! {|a,b| a[1]==b[1] ? a[2]<=>b[2] : a[1]<=>b[1] }
69
+ lines.each do |vars|
70
+ vars << bargraph(vars[2], 100 * @cores)
71
+ log "%#{@longest[:name]}s %#{@longest[:pid]}d CPU: %6.2f%% [%-40s]" % vars
72
+ end
73
+ end
74
+ sleep 0.1
75
+ @time = now
76
+ end
77
+ end
78
+
79
+ def bargraph(value, max=100, length=40, on='#', off='.')
80
+ (on * (([[value, 0].max, max].min * length) / max).to_i).ljust(length, off)
81
+ end
82
+
83
+ def log(message)
84
+ msg = "#{Time.now.strftime('%F %H:%M:%S')} TourWatch: #{message}"
85
+ puts msg
86
+ File.open(@logfile, "a") {|f| f.puts msg } if @logfile
87
+ end
88
+ end
@@ -0,0 +1,17 @@
1
+ require 'rubygems'
2
+ gem 'mechanize', ">= 0.7.6"
3
+ gem "hpricot", ">= 0.6"
4
+ $: << File.join(File.dirname(__FILE__), 'lib')
5
+
6
+ require 'hpricot'
7
+ require 'mechanize'
8
+
9
+ WWW::Mechanize.html_parser = Hpricot
10
+
11
+ require 'web_sickle'
12
+ require "assertions"
13
+ require "hash_proxy"
14
+ require "helpers/asp_net"
15
+ require "helpers/table_reader"
16
+
17
+ Hpricot.buffer_size = 524288
@@ -0,0 +1,51 @@
1
+ class WebSickleAssertionException < Exception; end
2
+
3
+ module WebSickle::Assertions
4
+ def assert_equals(expected, actual, message = nil)
5
+ unless(expected == actual)
6
+ report_error <<-EOF
7
+ Error: Expected
8
+ #{expected.inspect}, but got
9
+ #{actual.inspect}
10
+ #{message}
11
+ EOF
12
+ end
13
+ end
14
+
15
+ def assert_select(selector, message)
16
+ assert_select_in(@page, selector, message)
17
+ end
18
+
19
+ def assert_no_select(selector, message)
20
+ assert_no_select_in(@page, selector, message)
21
+ end
22
+
23
+ def assert_select_in(content, selector, message)
24
+ report_error("Error: Expected selector #{selector.inspect} to find a page element, but didn't. #{message}") if (content / selector).blank?
25
+ end
26
+
27
+ def assert_no_select_in(content, selector, message)
28
+ report_error("Error: Expected selector #{selector.inspect} to not find a page element, but did. #{message}") unless (content / selector).blank?
29
+ end
30
+
31
+ def assert_contains(left, right, message = nil)
32
+ (right.is_a?(Array) ? right : [right]).each do | item |
33
+ report_error("Error: Expected #{left.inspect} to contain #{right.inspect}, but didn't. #{message}") unless left.include?(item)
34
+ end
35
+ end
36
+
37
+ def assert(passes, message = nil)
38
+ report_error("Error: expected true, got false. #{message}") unless passes
39
+ end
40
+
41
+ def assert_link_text(link, text)
42
+ case text
43
+ when String
44
+ assert_equals(link.text, text)
45
+ when Regexp
46
+ assert(link.text.match(text))
47
+ else
48
+ raise ArgumentError, "Don't know how to assert an object like #{text.inspect} - expected: Regexp or String"
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,9 @@
1
+ class HashProxy
2
+ def initialize(options = {})
3
+ @set = options[:set]
4
+ @get = options[:get]
5
+ end
6
+
7
+ def [](key); @get && @get.call(key); end
8
+ def []=(key, value); @set && @set.call(key, value); end
9
+ end
@@ -0,0 +1,16 @@
1
+ module WebSickle::Helpers
2
+ module AspNet
3
+ def asp_net_do_postback(options)
4
+ target_element = case
5
+ when options[:button]
6
+ find_button(options[:button])
7
+ when options[:field]
8
+ find_field(options[:field])
9
+ else
10
+ nil
11
+ end
12
+ @form.fields << WWW::Mechanize::Form::Field.new("__EVENTTARGET", target_element ? target_element.name : "") if target_element
13
+ submit_form_button
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,39 @@
1
+ module WebSickle::Helpers
2
+ class TableReader
3
+ attr_reader :headers, :options, :body_rows, :header_row, :extra_rows
4
+
5
+ def initialize(element, p_options = {})
6
+ @options = {
7
+ :row_selectors => [" > tr", "thead > tr", "tbody > tr"],
8
+ :header_selector => " > th",
9
+ :header_proc => lambda { |th| th.inner_text.gsub(/[\n\s]+/, ' ').strip },
10
+ :body_selector => " > td",
11
+ :body_proc => lambda { |header, td| td.inner_text.strip },
12
+ :header_offset => 0,
13
+ :body_offset => 1
14
+ }.merge(p_options)
15
+ @options[:body_range] ||= options[:body_offset]..-1
16
+ raw_rows = options[:row_selectors].map{|row_selector| element / row_selector}.compact.flatten
17
+
18
+ @header_row = raw_rows[options[:header_offset]]
19
+ @body_rows = raw_rows[options[:body_range]]
20
+ @extra_rows = (options[:body_range].last+1)==0 ? [] : raw_rows[(options[:body_range].last+1)..-1]
21
+
22
+ @headers = (@header_row / options[:header_selector]).map(&options[:header_proc])
23
+ end
24
+
25
+ def rows
26
+ @rows ||= @body_rows.map do |row|
27
+ hash = {}
28
+ data_array = (headers).zip(row / options[:body_selector]).each do |column_name, td|
29
+ hash[column_name] = options[:body_proc].call(column_name, td)
30
+ end
31
+ hash
32
+ end
33
+ end
34
+
35
+ def array_to_hash(data, column_names)
36
+ column_names.inject({}) {|h,column_name| h[column_name] = data[column_names.index(column_name)]; h }
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,15 @@
1
+ # Nokogiri::XML::Element.class_eval do
2
+ # def inspect(indent = "")
3
+ # breaker = "\n#{indent}"
4
+ # if children.length == 0
5
+ # %(#{indent}<#{name}#{breaker} #{attributes.map {|k,v| k + '=' + v.inspect} * "#{breaker} "}/>)
6
+ # else
7
+ # %(#{indent}<#{name} #{attributes.map {|k,v| k + '=' + v.inspect} * " "}>\n#{children.map {|c| c.inspect(indent + ' ') rescue c.class} * "\n"}#{breaker}</#{name}>)
8
+ # end
9
+ # end
10
+ # end
11
+ # Nokogiri::XML::Text.class_eval do
12
+ # def inspect(indent = "")
13
+ # "#{indent}#{text.inspect}"
14
+ # end
15
+ # end
@@ -0,0 +1,224 @@
1
+ class WebsickleException < Exception; end
2
+
3
+ module WebSickle
4
+ # form_value is used to interface with the current select form
5
+ attr_reader :form_value
6
+ attr_accessor :page
7
+
8
+ def initialize(options = {})
9
+ @page = nil
10
+ @form_value = HashProxy.new(
11
+ :set => lambda { |identifier, value| set_form_value(identifier, value)},
12
+ :get => lambda { |identifier| get_form_value(identifier)}
13
+ )
14
+ end
15
+
16
+ def click_link(link)
17
+ set_page(agent.click(find_link(link)))
18
+ end
19
+
20
+ def submit_form(options = {})
21
+ options[:button] = :first unless options.has_key?(:button)
22
+ options[:identified_by] ||= :first
23
+ select_form(options[:identified_by])
24
+ set_form_values(options[:values]) if options[:values]
25
+ submit_form_button(options[:button])
26
+ end
27
+
28
+ # select the current form
29
+ def select_form(identifier = {})
30
+ identifier = make_identifier(identifier, [:name, :action, :method])
31
+ @form = find_in_collection(@page.forms, identifier)
32
+ report_error("Couldn't find form on page at #{@page.uri} with attributes #{identifier.inspect}") if @form.nil?
33
+ @form
34
+ end
35
+
36
+ # submits the current form
37
+ def submit_form_button(button_criteria = nil, options = {})
38
+ button =
39
+ case button_criteria
40
+ when nil
41
+ nil
42
+ else
43
+ find_button(button_criteria)
44
+ end
45
+ set_page(agent.submit(@form, button))
46
+ end
47
+
48
+ # sets the given path to the current page, then opens it using our agent
49
+ def open_page(path, parameters = [], referer = nil)
50
+ set_page(agent.get(path, parameters, referer))
51
+ end
52
+
53
+ # uses Hpricot style css selectors to find the elements in the current +page+.
54
+ # Uses Hpricot#/ (or Hpricot#search)
55
+ def select_element(match)
56
+ select_element_in(@page, match)
57
+ end
58
+
59
+ # uses Hpricot style css selectors to find the element in the given container. Works with html pages, and file pages that happen to have xml-like content.
60
+ # throws error if can't find a match
61
+ def select_element_in(contents, match)
62
+ result = (contents.respond_to?(:/) ? contents : Hpricot(contents.body)) / match
63
+ if result.blank?
64
+ report_error("Tried to find element matching #{match}, but couldn't")
65
+ else
66
+ result
67
+ end
68
+ end
69
+
70
+ # uses Hpricot style css selectors to find the element. Works with html pages, and file pages that happen to have xml-like content.
71
+ # throws error if can't find a match
72
+ # Uses Hpricot#at
73
+ def detect_element(match)
74
+ result = (@page.respond_to?(:at) ? @page : Hpricot(@page.body)).at(match)
75
+ if result.blank?
76
+ report_error("Tried to find element matching #{match}, but couldn't")
77
+ else
78
+ result
79
+ end
80
+ end
81
+
82
+ protected
83
+ # our friendly mechinze agent
84
+ def agent
85
+ @agent ||= new_mechanize_agent
86
+ end
87
+
88
+ def make_identifier(identifier, valid_keys = nil, default_key = :name)
89
+ identifier = {default_key => identifier} unless identifier.is_a?(Hash) || identifier.is_a?(Symbol)
90
+ identifier.assert_valid_keys(valid_keys) if identifier.is_a?(Hash) && valid_keys
91
+ identifier
92
+ end
93
+
94
+ def find_field(identifier)
95
+ if @form.nil?
96
+ report_error("No form is selected when trying to find field by #{identifier.inspect}")
97
+ return
98
+ end
99
+ identifier = make_identifier(identifier, [:name, :value])
100
+ find_in_collection(@form.radiobuttons + @form.fields + @form.checkboxes + @form.file_uploads, identifier) ||
101
+ report_error("Tried to find field identified by #{identifier.inspect}, but failed.\nForm fields are: #{(@form.radiobuttons + @form.fields + @form.checkboxes + @form.file_uploads).map{|f| f.inspect} * ", \n "}")
102
+ end
103
+
104
+ def find_link(identifier)
105
+ identifier = make_identifier(identifier, [:href, :text], :text)
106
+ find_in_collection(page.links, identifier) ||
107
+ report_error("Tried to find link identified by #{identifier.inspect}, but failed.\nValid links are: #{page.links.map{|f| f.inspect} * ", \n "}")
108
+ end
109
+
110
+ # finds a button by parameters. Throws error if not able to find.
111
+ # example:
112
+ # find_button("btnSubmit") - finds a button named "btnSubmit"
113
+ # find_button(:name => "btnSubmit")
114
+ # find_button(:name => "btnSubmit", :value => /Lucky/) - finds a button named btnSubmit with a value matching /Lucky/
115
+ def find_button(identifier)
116
+ identifier = make_identifier(identifier, [:value, :name])
117
+ find_in_collection(@form.buttons, identifier) ||
118
+ report_error("Tried to find button identified by #{identifier.inspect}, but failed. Buttons on selected form are: #{@form.buttons.map{|f| f.inspect} * ','}")
119
+ end
120
+
121
+ # the magic method that powers find_button, find_field. Does not throw an error if not found
122
+ def find_in_collection(collection, identifier, via = :find)
123
+ return collection.first if identifier == :first
124
+ find_all_in_collection(collection, identifier, :find)
125
+ end
126
+
127
+ def find_all_in_collection(collection, identifier, via = :select)
128
+ return [collection.first] if identifier == :first
129
+ collection.send(via) do |item|
130
+ identifier.all? { |k, criteria| is_a_match?(criteria, item.send(k)) }
131
+ end
132
+ end
133
+
134
+ # sets a form-field's value by identifier. Throw's error if field does not exist
135
+ def set_form_value(identifier, value)
136
+ field = find_field(identifier)
137
+ case field
138
+ when WWW::Mechanize::Form::CheckBox
139
+ field.checked = value
140
+ when WWW::Mechanize::Form::RadioButton
141
+ radio_collection = find_all_in_collection(@form.radiobuttons, :name => field.name)
142
+ radio_collection.each { |f|; f.checked = false }
143
+ finder = (value.is_a?(Hash) || value.is_a?(Symbol)) ? value : {:value => value}
144
+ find_in_collection(radio_collection, finder).checked = true
145
+ when WWW::Mechanize::Form::SelectList
146
+ if value.is_a?(Hash) || value.is_a?(Symbol)
147
+ field.value = find_in_collection(field.options, value).value
148
+ else
149
+ field.value = value
150
+ end
151
+ else
152
+ field.value = value
153
+ end
154
+ end
155
+
156
+ def set_form_values(set_pairs = {})
157
+ flattened_value_hash(set_pairs).each do |identifier, value|
158
+ set_form_value(identifier, value)
159
+ end
160
+ end
161
+
162
+ def flattened_value_hash(hash, parents = [])
163
+ new_hash = {}
164
+ hash.each do |key, value|
165
+ if value.is_a?(Hash) && value.keys.first.is_a?(String)
166
+ new_hash.update(flattened_value_hash(value, [key] + parents))
167
+ else
168
+ parents.each { |parent| key = "#{parent}[#{key}]"}
169
+ new_hash[key] = value
170
+ end
171
+ end
172
+ new_hash
173
+ end
174
+
175
+ # sets a form-field's value by identifier. Throw's error if field does not exist
176
+ def get_form_value(identifier)
177
+ field = find_field(identifier)
178
+ case field
179
+ when WWW::Mechanize::Form::CheckBox
180
+ field.checked
181
+ else
182
+ field.value
183
+ end
184
+ end
185
+
186
+ def format_error(msg)
187
+ error = "Error encountered: #{msg}."
188
+ begin
189
+ error << "\n\nPage URL:#{@page.uri.to_s}" if @page
190
+ rescue
191
+ end
192
+ error
193
+ end
194
+
195
+ def report_error(msg)
196
+ raise WebsickleException, format_error(msg)
197
+ nil
198
+ end
199
+
200
+ private
201
+ def set_page(p)
202
+ @form = nil
203
+ @page = p
204
+ end
205
+
206
+ def is_a_match?(criteria, value)
207
+ case criteria
208
+ when Regexp
209
+ criteria.match(value)
210
+ when String
211
+ criteria == value
212
+ when Array
213
+ criteria.include?(value)
214
+ else
215
+ criteria.to_s == value.to_s
216
+ end
217
+ end
218
+
219
+ def new_mechanize_agent
220
+ a = WWW::Mechanize.new
221
+ a.read_timeout = 600 # 10 minutes
222
+ a
223
+ end
224
+ end
@@ -0,0 +1,137 @@
1
+ require File.dirname(__FILE__) + '/../../spec_helper'
2
+
3
+ describe WebSickle::Helpers::TableReader do
4
+ describe "Simple example" do
5
+ before(:each) do
6
+ @content = <<-EOF
7
+ <table>
8
+ <tr>
9
+ <th>Name</th>
10
+ <th>Age</th>
11
+ </tr>
12
+ <tr>
13
+ <td>Googly</td>
14
+ <td>2</td>
15
+ </tr>
16
+ </table>
17
+ EOF
18
+ h = Hpricot(@content)
19
+ @table = WebSickle::Helpers::TableReader.new(h / "table")
20
+ end
21
+
22
+ it "should extract headers" do
23
+ @table.headers.should == ["Name", "Age"]
24
+ end
25
+
26
+ it "should extract rows" do
27
+ @table.rows.should == [
28
+ {"Name" => "Googly", "Age" => "2"}
29
+ ]
30
+ end
31
+ end
32
+
33
+
34
+
35
+ describe "Targetted example" do
36
+ before(:each) do
37
+ @content = <<-EOF
38
+ <table>
39
+ <thead>
40
+ <tr>
41
+ <td colspan='2'>----</td>
42
+ </tr>
43
+ <tr>
44
+ <th><b>Name</b></th>
45
+ <th><b>Age</b></th>
46
+ </tr>
47
+ </thead>
48
+ <tbody>
49
+ <tr>
50
+ <td>Googly</td>
51
+ <td>2</td>
52
+ </tr>
53
+ <tr>
54
+ <td>Bear</td>
55
+ <td>3</td>
56
+ </tr>
57
+ <tr>
58
+ <td colspan='2'>Totals!</td>
59
+ </tr>
60
+ <tr>
61
+ <td>---</td>
62
+ <td>5</td>
63
+ </tr>
64
+ </tbody>
65
+ </table>
66
+ EOF
67
+ h = Hpricot(@content)
68
+ @table = WebSickle::Helpers::TableReader.new(h / " > table",
69
+ :header_selector => " > th > b",
70
+ :header_offset => 1,
71
+ :body_range => 2..-3
72
+ )
73
+ end
74
+
75
+ it "should extract the column headers" do
76
+ @table.headers.should == ["Name", "Age"]
77
+ end
78
+
79
+ it "should extract the row data for the specified range" do
80
+ @table.rows.should == [
81
+ {"Name" => "Googly", "Age" => "2"},
82
+ {"Name" => "Bear", "Age" => "3"},
83
+ ]
84
+ end
85
+
86
+ it "should allow you to check extra rows to assert you didn't chop off too much" do
87
+ (@table.extra_rows.first / "td").inner_text.should == "Totals!"
88
+ end
89
+ end
90
+
91
+
92
+
93
+ describe "when using procs to extract data" do
94
+ before(:each) do
95
+ @content = <<-EOF
96
+ <table>
97
+ <tr>
98
+ <th>Name</th>
99
+ <th>Age</th>
100
+ </tr>
101
+ <tr>
102
+ <td>Googly</td>
103
+ <td>2</td>
104
+ </tr>
105
+ <tr>
106
+ <td>Bear</td>
107
+ <td>3</td>
108
+ </tr>
109
+ </table>
110
+ EOF
111
+ h = Hpricot(@content)
112
+ @table = WebSickle::Helpers::TableReader.new(h / " > table",
113
+ :header_proc => lambda {|th| th.inner_text.downcase.to_sym},
114
+ :body_proc => lambda {|col_name, td|
115
+ value = td.inner_text
116
+ case col_name
117
+ when :name
118
+ value.upcase
119
+ when :age
120
+ value.to_i
121
+ end
122
+ }
123
+ )
124
+ end
125
+
126
+ it "should use the header proc to extract column headers" do
127
+ @table.headers.should == [:name, :age]
128
+ end
129
+
130
+ it "should use the body proc to format the data" do
131
+ @table.rows.should == [
132
+ {:name => "GOOGLY", :age => 2},
133
+ {:name => "BEAR", :age => 3}
134
+ ]
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,7 @@
1
+ require File.dirname(__FILE__) + '/../init.rb'
2
+ require 'rubygems'
3
+ require 'hpricot'
4
+ require 'test/unit'
5
+ require 'spec'
6
+ require 'active_support'
7
+ require File.dirname(__FILE__) + '/spec_helpers/mechanize_mock_helper.rb'
@@ -0,0 +1,12 @@
1
+ module MechanizeMockHelper
2
+ def fixture_file(filename)
3
+ File.read("#{File.dirname(__FILE__)}/../fixtures/#{filename}")
4
+ end
5
+
6
+ def mechanize_page(path_to_data, options = {})
7
+ options[:uri] ||= URI.parse("http://url.com/#{path_to_data}")
8
+ options[:response] ||= {'content-type' => 'text/html'}
9
+
10
+ WWW::Mechanize::Page.new(options[:uri], options[:response], fixture_file("/#{path_to_data}"))
11
+ end
12
+ end
@@ -0,0 +1,50 @@
1
+ require File.dirname(__FILE__) + '/spec_helper'
2
+
3
+ class WebSickleHelper
4
+ include WebSickle
5
+ end
6
+
7
+ describe WebSickle do
8
+ include MechanizeMockHelper
9
+
10
+ before(:all) do
11
+ WebSickleHelper.protected_instance_methods.each do |method|
12
+ WebSickleHelper.send(:public, method)
13
+ end
14
+ end
15
+
16
+ before(:each) do
17
+ @helper = WebSickleHelper.new
18
+ end
19
+
20
+ it "should flatten a value hash" do
21
+ @helper.flattened_value_hash("contact" => {"first_name" => "bob"}).should == {"contact[first_name]" => "bob"}
22
+ end
23
+
24
+ describe "clicking links" do
25
+ before(:each) do
26
+ @helper.stub!(:page).and_return(mechanize_page("linkies.html"))
27
+ end
28
+
29
+ it "should click a link by matching the link text" do
30
+ @helper.agent.should_receive(:click) do |link|
31
+ link.text.should include("one")
32
+ end
33
+ @helper.click_link(:text => /one/)
34
+ end
35
+
36
+ it "should click a link by matching the link href" do
37
+ @helper.agent.should_receive(:click) do |link|
38
+ link.href.should include("/two")
39
+ end
40
+ @helper.click_link(:href => %r{/two})
41
+ end
42
+
43
+ it "should default matching the link text" do
44
+ @helper.agent.should_receive(:click) do |link|
45
+ link.text.should include("Link number one")
46
+ end
47
+ @helper.click_link("Link number one")
48
+ end
49
+ end
50
+ end
metadata ADDED
@@ -0,0 +1,114 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dbrady-tourbus
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.2
5
+ platform: ruby
6
+ authors:
7
+ - David Brady
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-01-05 00:00:00 -08:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: mechanize
17
+ version_requirement:
18
+ version_requirements: !ruby/object:Gem::Requirement
19
+ requirements:
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 0.8.5
23
+ version:
24
+ - !ruby/object:Gem::Dependency
25
+ name: trollop
26
+ version_requirement:
27
+ version_requirements: !ruby/object:Gem::Requirement
28
+ requirements:
29
+ - - ">="
30
+ - !ruby/object:Gem::Version
31
+ version: "0"
32
+ version:
33
+ - !ruby/object:Gem::Dependency
34
+ name: faker
35
+ version_requirement:
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: "0"
41
+ version:
42
+ - !ruby/object:Gem::Dependency
43
+ name: hpricot
44
+ version_requirement:
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: "0"
50
+ version:
51
+ description: TourBus web stress-testing tool
52
+ email: github@shinybit.com
53
+ executables:
54
+ - tourbus
55
+ - tourwatch
56
+ extensions: []
57
+
58
+ extra_rdoc_files:
59
+ - README.txt
60
+ - MIT-LICENSE
61
+ files:
62
+ - bin/tourbus
63
+ - bin/tourwatch
64
+ - lib/common.rb
65
+ - lib/runner.rb
66
+ - lib/tour.rb
67
+ - lib/tour_bus.rb
68
+ - lib/tour_watch.rb
69
+ - lib/web-sickle/init.rb
70
+ - lib/web-sickle/lib/assertions.rb
71
+ - lib/web-sickle/lib/hash_proxy.rb
72
+ - lib/web-sickle/lib/helpers/asp_net.rb
73
+ - lib/web-sickle/lib/helpers/table_reader.rb
74
+ - lib/web-sickle/lib/make_nokigiri_output_useful.rb
75
+ - lib/web-sickle/lib/web_sickle.rb
76
+ - lib/web-sickle/spec/lib/helpers/table_reader_spec.rb
77
+ - lib/web-sickle/spec/spec_helper.rb
78
+ - lib/web-sickle/spec/spec_helpers/mechanize_mock_helper.rb
79
+ - lib/web-sickle/spec/web_sickle_spec.rb
80
+ - README.txt
81
+ - MIT-LICENSE
82
+ has_rdoc: true
83
+ homepage: http://github.com/dbrady/tourbus
84
+ post_install_message:
85
+ rdoc_options:
86
+ - --line-numbers
87
+ - --inline-source
88
+ - --main
89
+ - README.txt
90
+ - --title
91
+ - Tourbus - Web Stress Testing in Ruby
92
+ require_paths:
93
+ - lib
94
+ required_ruby_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: "0"
99
+ version:
100
+ required_rubygems_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: "0"
105
+ version:
106
+ requirements: []
107
+
108
+ rubyforge_project:
109
+ rubygems_version: 1.2.0
110
+ signing_key:
111
+ specification_version: 2
112
+ summary: TourBus web stress-testing tool
113
+ test_files: []
114
+