dbrady-tourbus 0.0.2

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.
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
+