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 +22 -0
- data/README.txt +139 -0
- data/bin/tourbus +29 -0
- data/bin/tourwatch +52 -0
- data/lib/common.rb +31 -0
- data/lib/runner.rb +67 -0
- data/lib/tour.rb +110 -0
- data/lib/tour_bus.rb +84 -0
- data/lib/tour_watch.rb +88 -0
- data/lib/web-sickle/init.rb +17 -0
- data/lib/web-sickle/lib/assertions.rb +51 -0
- data/lib/web-sickle/lib/hash_proxy.rb +9 -0
- data/lib/web-sickle/lib/helpers/asp_net.rb +16 -0
- data/lib/web-sickle/lib/helpers/table_reader.rb +39 -0
- data/lib/web-sickle/lib/make_nokigiri_output_useful.rb +15 -0
- data/lib/web-sickle/lib/web_sickle.rb +224 -0
- data/lib/web-sickle/spec/lib/helpers/table_reader_spec.rb +137 -0
- data/lib/web-sickle/spec/spec_helper.rb +7 -0
- data/lib/web-sickle/spec/spec_helpers/mechanize_mock_helper.rb +12 -0
- data/lib/web-sickle/spec/web_sickle_spec.rb +50 -0
- metadata +114 -0
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,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,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
|
+
|