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