kookaburra 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- data/.document +5 -0
- data/.rvmrc +2 -0
- data/Gemfile +17 -0
- data/Gemfile.lock +39 -0
- data/LICENSE.txt +20 -0
- data/README.rdoc +19 -0
- data/Rakefile +53 -0
- data/VERSION +1 -0
- data/kookaburra.gemspec +84 -0
- data/lib/kookaburra/api_driver.rb +43 -0
- data/lib/kookaburra/given_driver.rb +7 -0
- data/lib/kookaburra/test_data/factory.rb +18 -0
- data/lib/kookaburra/test_data.rb +51 -0
- data/lib/kookaburra/ui_driver/mixins/has_browser.rb +29 -0
- data/lib/kookaburra/ui_driver/mixins/has_fields.rb +178 -0
- data/lib/kookaburra/ui_driver/mixins/has_strategies.rb +50 -0
- data/lib/kookaburra/ui_driver/mixins/has_subcomponents.rb +10 -0
- data/lib/kookaburra/ui_driver/mixins/has_ui_component.rb +37 -0
- data/lib/kookaburra/ui_driver/ui_component.rb +103 -0
- data/lib/kookaburra/ui_driver.rb +13 -0
- data/lib/kookaburra/world_setup.rb +12 -0
- data/lib/kookaburra.rb +19 -0
- data/lib/requires.rb +17 -0
- data/test/helper.rb +19 -0
- metadata +209 -0
data/.document
ADDED
data/.rvmrc
ADDED
data/Gemfile
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
source 'http://rubygems.org'
|
2
|
+
|
3
|
+
gem 'activesupport', '>= 3.0'
|
4
|
+
gem 'rack'
|
5
|
+
|
6
|
+
|
7
|
+
|
8
|
+
# Add dependencies to develop your gem here.
|
9
|
+
# Include everything needed to run rake, tests, features, etc.
|
10
|
+
group :development do
|
11
|
+
gem 'minitest', '>= 0'
|
12
|
+
gem 'yard', '~> 0.6.0'
|
13
|
+
gem 'bundler', '~> 1.0.0'
|
14
|
+
gem 'jeweler', '~> 1.6.4'
|
15
|
+
gem 'rcov', '>= 0'
|
16
|
+
gem 'reek', '~> 1.2.8'
|
17
|
+
end
|
data/Gemfile.lock
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
GEM
|
2
|
+
remote: http://rubygems.org/
|
3
|
+
specs:
|
4
|
+
activesupport (3.1.3)
|
5
|
+
multi_json (~> 1.0)
|
6
|
+
git (1.2.5)
|
7
|
+
jeweler (1.6.4)
|
8
|
+
bundler (~> 1.0)
|
9
|
+
git (>= 1.2.5)
|
10
|
+
rake
|
11
|
+
minitest (2.9.0)
|
12
|
+
multi_json (1.0.4)
|
13
|
+
rack (1.3.5)
|
14
|
+
rake (0.9.2.2)
|
15
|
+
rcov (0.9.11)
|
16
|
+
reek (1.2.8)
|
17
|
+
ruby2ruby (~> 1.2)
|
18
|
+
ruby_parser (~> 2.0)
|
19
|
+
sexp_processor (~> 3.0)
|
20
|
+
ruby2ruby (1.3.1)
|
21
|
+
ruby_parser (~> 2.0)
|
22
|
+
sexp_processor (~> 3.0)
|
23
|
+
ruby_parser (2.3.1)
|
24
|
+
sexp_processor (~> 3.0)
|
25
|
+
sexp_processor (3.0.9)
|
26
|
+
yard (0.6.8)
|
27
|
+
|
28
|
+
PLATFORMS
|
29
|
+
ruby
|
30
|
+
|
31
|
+
DEPENDENCIES
|
32
|
+
activesupport (>= 3.0)
|
33
|
+
bundler (~> 1.0.0)
|
34
|
+
jeweler (~> 1.6.4)
|
35
|
+
minitest
|
36
|
+
rack
|
37
|
+
rcov
|
38
|
+
reek (~> 1.2.8)
|
39
|
+
yard (~> 0.6.0)
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2011 Renewable Funding, LLC
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.rdoc
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
= kookaburra
|
2
|
+
|
3
|
+
Description goes here.
|
4
|
+
|
5
|
+
== Contributing to kookaburra
|
6
|
+
|
7
|
+
* Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet
|
8
|
+
* Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it
|
9
|
+
* Fork the project
|
10
|
+
* Start a feature/bugfix branch
|
11
|
+
* Commit and push until you are happy with your contribution
|
12
|
+
* Make sure to add tests for it. This is important so I don't break it in a future version unintentionally.
|
13
|
+
* Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick around it.
|
14
|
+
|
15
|
+
== Copyright
|
16
|
+
|
17
|
+
Copyright (c) 2011 Renewable Funding, LLC. See LICENSE.txt for
|
18
|
+
further details.
|
19
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1,53 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require 'rubygems'
|
4
|
+
require 'bundler'
|
5
|
+
begin
|
6
|
+
Bundler.setup(:default, :development)
|
7
|
+
rescue Bundler::BundlerError => e
|
8
|
+
$stderr.puts e.message
|
9
|
+
$stderr.puts "Run `bundle install` to install missing gems"
|
10
|
+
exit e.status_code
|
11
|
+
end
|
12
|
+
require 'rake'
|
13
|
+
|
14
|
+
require 'jeweler'
|
15
|
+
Jeweler::Tasks.new do |gem|
|
16
|
+
# gem is a Gem::Specification... see http://docs.rubygems.org/read/chapter/20 for more options
|
17
|
+
gem.name = "kookaburra"
|
18
|
+
gem.homepage = "http://github.com/projectdx/kookaburra"
|
19
|
+
gem.license = "MIT"
|
20
|
+
gem.summary = %Q{WindowDriver testing pattern for Ruby apps}
|
21
|
+
gem.description = %Q{Cucumber + Capybara = Kookaburra? It made sense at the time.}
|
22
|
+
gem.email = "devteam@renewfund.com"
|
23
|
+
gem.authors = ["Renewable Funding, LLC"]
|
24
|
+
# dependencies defined in Gemfile
|
25
|
+
end
|
26
|
+
Jeweler::RubygemsDotOrgTasks.new
|
27
|
+
|
28
|
+
require 'rake/testtask'
|
29
|
+
Rake::TestTask.new(:test) do |test|
|
30
|
+
test.libs << 'lib' << 'test'
|
31
|
+
test.pattern = 'test/**/*_test.rb'
|
32
|
+
test.verbose = true
|
33
|
+
end
|
34
|
+
|
35
|
+
require 'rcov/rcovtask'
|
36
|
+
Rcov::RcovTask.new do |test|
|
37
|
+
test.libs << 'test'
|
38
|
+
test.pattern = 'test/**/*_test.rb'
|
39
|
+
test.verbose = true
|
40
|
+
test.rcov_opts << '--exclude "gems/*"'
|
41
|
+
end
|
42
|
+
|
43
|
+
require 'reek/rake/task'
|
44
|
+
Reek::Rake::Task.new do |t|
|
45
|
+
t.fail_on_error = true
|
46
|
+
t.verbose = false
|
47
|
+
t.source_files = 'lib/**/*.rb'
|
48
|
+
end
|
49
|
+
|
50
|
+
task :default => :test
|
51
|
+
|
52
|
+
require 'yard'
|
53
|
+
YARD::Rake::YardocTask.new
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.0.2
|
data/kookaburra.gemspec
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
# Generated by jeweler
|
2
|
+
# DO NOT EDIT THIS FILE DIRECTLY
|
3
|
+
# Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
|
4
|
+
# -*- encoding: utf-8 -*-
|
5
|
+
|
6
|
+
Gem::Specification.new do |s|
|
7
|
+
s.name = "kookaburra"
|
8
|
+
s.version = "0.0.2"
|
9
|
+
|
10
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
|
+
s.authors = ["Renewable Funding, LLC"]
|
12
|
+
s.date = "2012-01-15"
|
13
|
+
s.description = "Cucumber + Capybara = Kookaburra? It made sense at the time."
|
14
|
+
s.email = "devteam@renewfund.com"
|
15
|
+
s.extra_rdoc_files = [
|
16
|
+
"LICENSE.txt",
|
17
|
+
"README.rdoc"
|
18
|
+
]
|
19
|
+
s.files = [
|
20
|
+
".document",
|
21
|
+
".rvmrc",
|
22
|
+
"Gemfile",
|
23
|
+
"Gemfile.lock",
|
24
|
+
"LICENSE.txt",
|
25
|
+
"README.rdoc",
|
26
|
+
"Rakefile",
|
27
|
+
"VERSION",
|
28
|
+
"kookaburra.gemspec",
|
29
|
+
"lib/kookaburra.rb",
|
30
|
+
"lib/kookaburra/api_driver.rb",
|
31
|
+
"lib/kookaburra/given_driver.rb",
|
32
|
+
"lib/kookaburra/test_data.rb",
|
33
|
+
"lib/kookaburra/test_data/factory.rb",
|
34
|
+
"lib/kookaburra/ui_driver.rb",
|
35
|
+
"lib/kookaburra/ui_driver/mixins/has_browser.rb",
|
36
|
+
"lib/kookaburra/ui_driver/mixins/has_fields.rb",
|
37
|
+
"lib/kookaburra/ui_driver/mixins/has_strategies.rb",
|
38
|
+
"lib/kookaburra/ui_driver/mixins/has_subcomponents.rb",
|
39
|
+
"lib/kookaburra/ui_driver/mixins/has_ui_component.rb",
|
40
|
+
"lib/kookaburra/ui_driver/ui_component.rb",
|
41
|
+
"lib/kookaburra/world_setup.rb",
|
42
|
+
"lib/requires.rb",
|
43
|
+
"test/helper.rb"
|
44
|
+
]
|
45
|
+
s.homepage = "http://github.com/projectdx/kookaburra"
|
46
|
+
s.licenses = ["MIT"]
|
47
|
+
s.require_paths = ["lib"]
|
48
|
+
s.rubygems_version = "1.8.15"
|
49
|
+
s.summary = "WindowDriver testing pattern for Ruby apps"
|
50
|
+
|
51
|
+
if s.respond_to? :specification_version then
|
52
|
+
s.specification_version = 3
|
53
|
+
|
54
|
+
if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
|
55
|
+
s.add_runtime_dependency(%q<activesupport>, [">= 3.0"])
|
56
|
+
s.add_runtime_dependency(%q<rack>, [">= 0"])
|
57
|
+
s.add_development_dependency(%q<minitest>, [">= 0"])
|
58
|
+
s.add_development_dependency(%q<yard>, ["~> 0.6.0"])
|
59
|
+
s.add_development_dependency(%q<bundler>, ["~> 1.0.0"])
|
60
|
+
s.add_development_dependency(%q<jeweler>, ["~> 1.6.4"])
|
61
|
+
s.add_development_dependency(%q<rcov>, [">= 0"])
|
62
|
+
s.add_development_dependency(%q<reek>, ["~> 1.2.8"])
|
63
|
+
else
|
64
|
+
s.add_dependency(%q<activesupport>, [">= 3.0"])
|
65
|
+
s.add_dependency(%q<rack>, [">= 0"])
|
66
|
+
s.add_dependency(%q<minitest>, [">= 0"])
|
67
|
+
s.add_dependency(%q<yard>, ["~> 0.6.0"])
|
68
|
+
s.add_dependency(%q<bundler>, ["~> 1.0.0"])
|
69
|
+
s.add_dependency(%q<jeweler>, ["~> 1.6.4"])
|
70
|
+
s.add_dependency(%q<rcov>, [">= 0"])
|
71
|
+
s.add_dependency(%q<reek>, ["~> 1.2.8"])
|
72
|
+
end
|
73
|
+
else
|
74
|
+
s.add_dependency(%q<activesupport>, [">= 3.0"])
|
75
|
+
s.add_dependency(%q<rack>, [">= 0"])
|
76
|
+
s.add_dependency(%q<minitest>, [">= 0"])
|
77
|
+
s.add_dependency(%q<yard>, ["~> 0.6.0"])
|
78
|
+
s.add_dependency(%q<bundler>, ["~> 1.0.0"])
|
79
|
+
s.add_dependency(%q<jeweler>, ["~> 1.6.4"])
|
80
|
+
s.add_dependency(%q<rcov>, [">= 0"])
|
81
|
+
s.add_dependency(%q<reek>, ["~> 1.2.8"])
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
@@ -0,0 +1,43 @@
|
|
1
|
+
module Kookaburra
|
2
|
+
# Pattern:
|
3
|
+
# - Get some data from test_data.factory
|
4
|
+
# - Post it to the API
|
5
|
+
# - Remember the response in test_data
|
6
|
+
class APIDriver
|
7
|
+
include Rack::Test::Methods
|
8
|
+
attr_reader :app, :test_data
|
9
|
+
protected :app, :test_data
|
10
|
+
|
11
|
+
def initialize(opts)
|
12
|
+
@app = opts.fetch(:app)
|
13
|
+
@test_data = opts.fetch(:test_data)
|
14
|
+
end
|
15
|
+
|
16
|
+
protected
|
17
|
+
|
18
|
+
def raise_unless_status(expected_status, short_description)
|
19
|
+
message = "%s failed (#{last_response.status})\n#{last_response.body}" % short_description
|
20
|
+
raise message unless last_response.status == expected_status
|
21
|
+
end
|
22
|
+
|
23
|
+
##### JSON Tools #####
|
24
|
+
|
25
|
+
def post_as_json(short_description, path, data = {}, options = {})
|
26
|
+
header 'Content-Type', 'application/json'
|
27
|
+
header 'Accept', 'application/json'
|
28
|
+
post path, data.to_json
|
29
|
+
raise_unless_status options[:expected_status] || 201, short_description
|
30
|
+
end
|
31
|
+
|
32
|
+
def put_as_json(short_description, path, data = {}, options = {})
|
33
|
+
header 'Content-Type', 'application/json'
|
34
|
+
header 'Accept', 'application/json'
|
35
|
+
put path, data.to_json
|
36
|
+
raise_unless_status options[:expected_status] || 201, short_description
|
37
|
+
end
|
38
|
+
|
39
|
+
def hash_from_response_json
|
40
|
+
HashWithIndifferentAccess.new( JSON.parse(last_response.body) )
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# Factories for setting up attribute hashes
|
2
|
+
module Kookaburra
|
3
|
+
class TestData
|
4
|
+
class Factory
|
5
|
+
attr_reader :test_data
|
6
|
+
def initialize(test_data)
|
7
|
+
@test_data = test_data
|
8
|
+
end
|
9
|
+
|
10
|
+
protected
|
11
|
+
def hash_for_merging(overrides = {})
|
12
|
+
HashWithIndifferentAccess.new( overrides.with_indifferent_access ).tap do |hash_to_merge|
|
13
|
+
yield hash_to_merge if block_given?
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# This is the mechanism for sharing state between Cucumber steps.
|
2
|
+
# If you're using instance variables, YOU'RE DOING IT WRONG.
|
3
|
+
module Kookaburra
|
4
|
+
class TestData
|
5
|
+
def initialize
|
6
|
+
@data = Hash.new do |hash, key|
|
7
|
+
hash[key] = Hash.new { |hash, key| hash[key] = HashWithIndifferentAccess.new }
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
def __collection(collection_key)
|
12
|
+
@data[collection_key]
|
13
|
+
end
|
14
|
+
def __fetch_data(collection_key, value_key)
|
15
|
+
__collection(collection_key).fetch(value_key)
|
16
|
+
rescue IndexError => e
|
17
|
+
raise e.exception("Key #{value_key.inspect} not found in #{collection_key}")
|
18
|
+
end
|
19
|
+
def __get_data(collection_key, value_key)
|
20
|
+
__collection(collection_key)[value_key]
|
21
|
+
end
|
22
|
+
def __set_data(collection_key, value_key, value_hash = {})
|
23
|
+
__collection(collection_key)[value_key] = HashWithIndifferentAccess.new(value_hash)
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.provide_collection(name)
|
27
|
+
class_eval <<-RUBY
|
28
|
+
def #{name}(key = :default)
|
29
|
+
__get_data(:#{name}, key)
|
30
|
+
end
|
31
|
+
def fetch_#{name}(key = :default)
|
32
|
+
__fetch_data(:#{name}, key)
|
33
|
+
end
|
34
|
+
def set_#{name}(key, value_hash = {})
|
35
|
+
__set_data(:#{name}, key, value_hash)
|
36
|
+
end
|
37
|
+
RUBY
|
38
|
+
end
|
39
|
+
|
40
|
+
Defaults = HashWithIndifferentAccess.new
|
41
|
+
def default(key)
|
42
|
+
# NOTE: Marshal seems clunky, but gives us a deep copy.
|
43
|
+
# This keeps mutations from being preserved between test runs.
|
44
|
+
( @default ||= Marshal::load(Marshal.dump(Defaults)) )[key]
|
45
|
+
end
|
46
|
+
|
47
|
+
def factory
|
48
|
+
@factory ||= Kookaburra::TestData::Factory.new(self)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module Kookaburra
|
2
|
+
class UIDriver
|
3
|
+
module HasBrowser
|
4
|
+
Unexpected500 = Class.new(StandardError)
|
5
|
+
|
6
|
+
# This will fail if the options hash does not include a value for the key :browser
|
7
|
+
def initialize(options = {})
|
8
|
+
super()
|
9
|
+
@browserish = options.fetch(:browser)
|
10
|
+
end
|
11
|
+
|
12
|
+
def browser
|
13
|
+
@browserish
|
14
|
+
end
|
15
|
+
|
16
|
+
def visit(*args)
|
17
|
+
browser.visit *args
|
18
|
+
no_500_error!
|
19
|
+
end
|
20
|
+
|
21
|
+
def no_500_error!
|
22
|
+
if browser.has_css?('head title', :text => 'Internal Server Error')
|
23
|
+
sleep 30 if ENV['GIMME_CRAP']
|
24
|
+
raise Unexpected500
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,178 @@
|
|
1
|
+
module Kookaburra
|
2
|
+
class UIDriver
|
3
|
+
module HasFields
|
4
|
+
module ClassMethods
|
5
|
+
def submit_button_locator(locator)
|
6
|
+
define_method(:submit_button_locator) { locator }
|
7
|
+
end
|
8
|
+
|
9
|
+
def has_radio_button_for(*names)
|
10
|
+
names.each do |n|
|
11
|
+
input_xpath_builder_names[n] = :build_radio_input_xpath
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def has_select_for(*names)
|
16
|
+
names.each do |n|
|
17
|
+
input_xpath_builder_names[n] = :build_select_input_xpath
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def input_xpath_builder_names
|
22
|
+
# Inputs without a type attribute, or with a custom or unknown type are
|
23
|
+
# textual, so we need to exclude the types that we don't want.
|
24
|
+
@input_xpath_builder_names ||= HashWithIndifferentAccess.new(:build_textual_input_xpath)
|
25
|
+
end
|
26
|
+
|
27
|
+
def is_select?(name)
|
28
|
+
input_xpath_builder_names[name] == :build_select_input_xpath
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
module InstanceMethods
|
33
|
+
def build_input_xpath(attribute)
|
34
|
+
"(" +
|
35
|
+
"(.//textarea)|" +
|
36
|
+
"(.//select)|" +
|
37
|
+
"(.//input[(not(@type='button')) and (not(@type='reset')) and (not(@type='submit'))])" +
|
38
|
+
")[contains(@id,'_#{attribute}')]"
|
39
|
+
# TODO: Avoid false positives by matching to the end of the id
|
40
|
+
# string for everything but radio buttons and checkboxes
|
41
|
+
# ... or something better than that.
|
42
|
+
end
|
43
|
+
|
44
|
+
def build_textual_input_xpath(attribute, value=nil)
|
45
|
+
# Since the type attribute might not be present or might contain
|
46
|
+
# just about any value, check for and exclude nodes with type
|
47
|
+
# containing a non-textual value.
|
48
|
+
"(" +
|
49
|
+
"(.//textarea)|" +
|
50
|
+
"(.//input[" +
|
51
|
+
"(not(@type='button')) and (not(@type='checkbox')) and (not(@type='radio')) and " +
|
52
|
+
"(not(@type='reset')) and (not(@type='submit'))" +
|
53
|
+
"])" +
|
54
|
+
")[" +
|
55
|
+
"substring(@id,string-length(@id)-#{attribute.length-1},#{attribute.length})='#{attribute}'" +
|
56
|
+
"]"
|
57
|
+
end
|
58
|
+
|
59
|
+
def build_radio_input_xpath(attribute, value=nil)
|
60
|
+
# The id will be the attribute suffixed by something representative of
|
61
|
+
# the button value, with an underscore in between, but we don't necessarily
|
62
|
+
# know the correct suffix, so ensure that the "_" is in the id, and then
|
63
|
+
# find the correct match using the value attribute.
|
64
|
+
xpath = ".//input[@type='radio'][contains(@id, #{attribute}_)]"
|
65
|
+
xpath = "(#{xpath})[@value='#{value}']"
|
66
|
+
end
|
67
|
+
|
68
|
+
def build_select_input_xpath(attribute, value=nil)
|
69
|
+
xpath = ".//select[contains(@id, #{attribute})]/option[text()='#{value}']"
|
70
|
+
end
|
71
|
+
|
72
|
+
# Find the specified input by its field name. The value argument is
|
73
|
+
# used to find the correct input if (as in the case of a radio button)
|
74
|
+
# there might be a separate input element for each value the field might
|
75
|
+
# contain. Otherwise, it is ignored. When the name value will be used,
|
76
|
+
# its #to_s value must be valid as an xpath string body expression.
|
77
|
+
def find_input(attribute, value=nil, nth=1, msg=nil)
|
78
|
+
method = self.class.input_xpath_builder_names[attribute]
|
79
|
+
#TODO: Build a qualified attribute name, and pass that to the xpath builder
|
80
|
+
# to handle forms with multiple models that may have common attribute
|
81
|
+
# names.
|
82
|
+
xpath = send(method, attribute.to_s, value.to_s)
|
83
|
+
xpath = "(#{xpath})[#{nth}]" if nth > 1
|
84
|
+
browser.find(:xpath, xpath, :message => msg)
|
85
|
+
end
|
86
|
+
|
87
|
+
def submit_button_locator
|
88
|
+
raise "Subclass responsibility!"
|
89
|
+
end
|
90
|
+
|
91
|
+
def submit!
|
92
|
+
click_on submit_button_locator
|
93
|
+
no_500_error!
|
94
|
+
end
|
95
|
+
|
96
|
+
def fill_in_fields(hash, idx = 0)
|
97
|
+
@body = nil
|
98
|
+
|
99
|
+
# If not explicitly ordered, then we must think the fill-in order doesn't
|
100
|
+
# matter, so make the order more assuredly arbitrary to find out sooner
|
101
|
+
# if that's not a correct thought.
|
102
|
+
hash = hash.map.shuffle if Hash === hash && ! ActiveSupport::OrderedHash === hash
|
103
|
+
|
104
|
+
hash.each do |field, value|
|
105
|
+
fill_in_form_element(field, value, idx)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
private
|
110
|
+
def fill_in_form_element(attribute, value, idx = 0, opts = {})
|
111
|
+
msg = "cannot find the field for '#{attribute}' with value '#{value}' to fill it in."
|
112
|
+
input = find_input(attribute, value, idx+1, msg)
|
113
|
+
set_value(input, value, attribute)
|
114
|
+
end
|
115
|
+
def set_value(input, value, attribute)
|
116
|
+
if self.class.is_select?(attribute)
|
117
|
+
input.select_option
|
118
|
+
else
|
119
|
+
input.set value
|
120
|
+
end
|
121
|
+
end
|
122
|
+
public
|
123
|
+
|
124
|
+
def tag_visible?(css)
|
125
|
+
browser.has_css?(css, :visible => true)
|
126
|
+
end
|
127
|
+
|
128
|
+
def no_tag_visible?(css)
|
129
|
+
# use of wait_until is supposed to be redundant with #has_no_css?, but
|
130
|
+
# getting intermittent Selenium::WebDriver::Error::StaleElementReferenceError.
|
131
|
+
# Just wrapping in an explicit wait_until did not capture the exception,
|
132
|
+
# so apparently that one is explicitly passed through rather than being
|
133
|
+
# retried.
|
134
|
+
browser.wait_until do
|
135
|
+
begin
|
136
|
+
browser.has_no_css?(css, :visible => true)
|
137
|
+
rescue Selenium::WebDriver::Error::StaleElementReferenceError
|
138
|
+
next false # Keep trying if not yet timed out.
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
private
|
144
|
+
|
145
|
+
|
146
|
+
def find_disableable_inputs(attribute)
|
147
|
+
inputs = []
|
148
|
+
browser.wait_until do
|
149
|
+
inputs = browser.all(:xpath, build_input_xpath(attribute))
|
150
|
+
inputs.present?
|
151
|
+
end
|
152
|
+
inputs
|
153
|
+
end
|
154
|
+
|
155
|
+
def form_element_disabled?(attribute, *_)
|
156
|
+
# TODO (SLG): this will only check the currently-selected radio button, not all of them
|
157
|
+
find_disableable_inputs(attribute).all? { |input| !!input[:disabled] }
|
158
|
+
rescue Capybara::TimeoutError
|
159
|
+
puts "Timed out trying to find disableable inputs for #{attribute}"
|
160
|
+
end
|
161
|
+
|
162
|
+
def all_form_elements_disabled?(desc, attr_names, &b)
|
163
|
+
errs = attr_names.inject([]) do |mem, attr_name|
|
164
|
+
(mem << "#{desc} #{attr_name} is not read-only") unless form_element_disabled?(attr_name)
|
165
|
+
mem
|
166
|
+
end
|
167
|
+
raise errs.join("\n") if errs.present?
|
168
|
+
true
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
def self.included(receiver)
|
173
|
+
receiver.extend ClassMethods
|
174
|
+
receiver.send :include, InstanceMethods
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
module Kookaburra
|
2
|
+
class UIDriver
|
3
|
+
module HasStrategies
|
4
|
+
class Strategy
|
5
|
+
class_attribute :tag
|
6
|
+
attr_reader :ui_component
|
7
|
+
def initialize(ui_component)
|
8
|
+
@ui_component, @tag = ui_component
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
module ClassMethods
|
13
|
+
def strategy(tag, &proc)
|
14
|
+
Class.new(::Kookaburra::UIDriver::HasStrategies::Strategy).tap { |klass|
|
15
|
+
klass.tag = tag
|
16
|
+
klass.module_eval &proc
|
17
|
+
self.strategy_classes << klass
|
18
|
+
}
|
19
|
+
end
|
20
|
+
|
21
|
+
def use_strategy_for(*method_names)
|
22
|
+
def_delegators :current_strategy, *method_names
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
module InstanceMethods
|
27
|
+
def strategies
|
28
|
+
@strategies ||= strategy_classes.map { |klass| klass.new(self) }
|
29
|
+
end
|
30
|
+
|
31
|
+
def current_strategy
|
32
|
+
strategies.detect(&:applies?) or raise 'No applicable strategy!'
|
33
|
+
end
|
34
|
+
|
35
|
+
def strategy_tag
|
36
|
+
current_strategy.tag
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.included(receiver)
|
41
|
+
receiver.class_attribute :strategy_classes
|
42
|
+
receiver.strategy_classes = []
|
43
|
+
|
44
|
+
receiver.extend Forwardable
|
45
|
+
receiver.extend ClassMethods
|
46
|
+
receiver.send :include, InstanceMethods
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module Kookaburra
|
2
|
+
class UIDriver
|
3
|
+
module HasUIComponent
|
4
|
+
module ClassMethods
|
5
|
+
def ui_component(component_name)
|
6
|
+
component_class = component_name.to_s.camelize.constantize
|
7
|
+
|
8
|
+
self.ui_component_names << component_name
|
9
|
+
|
10
|
+
define_method(component_name) do
|
11
|
+
options = { :browser => browser, :test_data => test_data }
|
12
|
+
# TODO: memoize the following line?
|
13
|
+
component_class.new(options)
|
14
|
+
end
|
15
|
+
|
16
|
+
define_method("has_#{component_name}?") do
|
17
|
+
send(component_name).visible?
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
module InstanceMethods
|
23
|
+
def ui_components
|
24
|
+
ui_component_names.map { |name| self.send(name) }
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.included(receiver)
|
29
|
+
receiver.class_attribute :ui_component_names
|
30
|
+
receiver.ui_component_names = []
|
31
|
+
|
32
|
+
receiver.extend ClassMethods
|
33
|
+
receiver.send :include, InstanceMethods
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
module Kookaburra
|
2
|
+
class UIDriver
|
3
|
+
class UIComponent
|
4
|
+
include HasBrowser
|
5
|
+
include HasFields
|
6
|
+
include HasStrategies
|
7
|
+
extend HasSubcomponents
|
8
|
+
|
9
|
+
attr_reader :test_data
|
10
|
+
|
11
|
+
# This will fail if the options hash does not include a value for the key :test_data
|
12
|
+
def initialize(options = {})
|
13
|
+
super
|
14
|
+
@test_data = options.fetch(:test_data)
|
15
|
+
end
|
16
|
+
|
17
|
+
##### Class macros #####
|
18
|
+
def self.component_locator(locator)
|
19
|
+
define_method(:component_locator) { locator }
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.component_path(path)
|
23
|
+
case path
|
24
|
+
when Symbol
|
25
|
+
alias_method :component_path, path
|
26
|
+
else
|
27
|
+
define_method(:component_path) { path }
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.path_id_regex(regex)
|
32
|
+
define_method(:path_id_regex) { regex }
|
33
|
+
end
|
34
|
+
|
35
|
+
##### Instance methods #####
|
36
|
+
|
37
|
+
def visible!
|
38
|
+
raise "#{self.class} not currently visible!" unless visible?
|
39
|
+
end
|
40
|
+
|
41
|
+
def visible?
|
42
|
+
no_500_error!
|
43
|
+
_visible?
|
44
|
+
end
|
45
|
+
|
46
|
+
def _visible?
|
47
|
+
component_visible?
|
48
|
+
end
|
49
|
+
private :_visible?
|
50
|
+
|
51
|
+
def show(opts = {})
|
52
|
+
return if visible?
|
53
|
+
raise "Subclass responsibility!" unless self.respond_to?(:component_path)
|
54
|
+
path = component_path
|
55
|
+
path << ( '?' + opts[:query_params].map{|kv| "%s=%s" % kv}.join('&') ) if opts[:query_params]
|
56
|
+
visit path
|
57
|
+
end
|
58
|
+
|
59
|
+
def refresh
|
60
|
+
visit component_path
|
61
|
+
end
|
62
|
+
|
63
|
+
def show!(opts = {})
|
64
|
+
show opts
|
65
|
+
visible!
|
66
|
+
end
|
67
|
+
|
68
|
+
def at_path?
|
69
|
+
(component_path.to_a + alternate_paths.to_a).include?(browser.current_path)
|
70
|
+
end
|
71
|
+
|
72
|
+
def component_visible?
|
73
|
+
at_path? && browser.has_css?(component_locator)
|
74
|
+
end
|
75
|
+
|
76
|
+
def alternate_paths
|
77
|
+
[]
|
78
|
+
end
|
79
|
+
|
80
|
+
private
|
81
|
+
def id_from_path
|
82
|
+
browser.current_path =~ path_id_regex
|
83
|
+
$1.present? ? $1.to_i : nil
|
84
|
+
end
|
85
|
+
|
86
|
+
def fill_in(locator, options)
|
87
|
+
in_component { browser.fill_in(locator, options) }
|
88
|
+
end
|
89
|
+
|
90
|
+
def click_on(locator)
|
91
|
+
in_component { browser.find(locator).click }
|
92
|
+
end
|
93
|
+
|
94
|
+
def choose(locator)
|
95
|
+
in_component { browser.choose(locator) }
|
96
|
+
end
|
97
|
+
|
98
|
+
def in_component(&blk)
|
99
|
+
browser.within(component_locator, &blk)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# Can't use a custom World class, because cucumber-rails is already doing that
|
2
|
+
module Kookaburra
|
3
|
+
module WorldSetup
|
4
|
+
def ui; @drivers[:ui_driver ]; end
|
5
|
+
def api; @drivers[:api_driver ]; end
|
6
|
+
def given; @drivers[:given_driver]; end
|
7
|
+
|
8
|
+
def kookaburra_world_setup
|
9
|
+
@drivers = Kookaburra.drivers
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
data/lib/kookaburra.rb
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), *%w[requires])
|
2
|
+
|
3
|
+
module Kookaburra
|
4
|
+
def self.drivers
|
5
|
+
test_data = Kookaburra::TestData.new
|
6
|
+
api_driver = Kookaburra::APIDriver.new({
|
7
|
+
:app => Capybara.app,
|
8
|
+
:test_data => test_data,
|
9
|
+
})
|
10
|
+
given_driver = Kookaburra::GivenDriver.new({
|
11
|
+
:api_driver => api_driver,
|
12
|
+
})
|
13
|
+
ui_driver = Kookaburra::UIDriver.new({
|
14
|
+
:browser => Capybara.current_session,
|
15
|
+
:test_data => test_data,
|
16
|
+
})
|
17
|
+
{ :api_driver => api_driver, :given_driver => given_driver, :ui_driver => ui_driver }
|
18
|
+
end
|
19
|
+
end
|
data/lib/requires.rb
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
def kookaburra_require_glob(path_glob)
|
2
|
+
Dir.glob(path_glob).each do |file|
|
3
|
+
require file
|
4
|
+
end
|
5
|
+
end
|
6
|
+
|
7
|
+
def kookaburra_require_all_relative_to(base_path, *relative_path_array)
|
8
|
+
path = File.join(base_path, *relative_path_array.flatten)
|
9
|
+
kookaburra_require_glob File.join(path, '*.rb')
|
10
|
+
end
|
11
|
+
|
12
|
+
# Require specific paths from the bottom up. Hooray for dependency graphs!
|
13
|
+
base = File.dirname(__FILE__)
|
14
|
+
kookaburra_require_all_relative_to base, %w[kookaburra ui_driver mixins]
|
15
|
+
kookaburra_require_all_relative_to base, %w[kookaburra ui_driver]
|
16
|
+
kookaburra_require_all_relative_to base, %w[kookaburra test_data]
|
17
|
+
kookaburra_require_all_relative_to base, %w[kookaburra]
|
data/test/helper.rb
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'bundler'
|
3
|
+
begin
|
4
|
+
Bundler.setup(:default, :development)
|
5
|
+
rescue Bundler::BundlerError => e
|
6
|
+
$stderr.puts e.message
|
7
|
+
$stderr.puts "Run `bundle install` to install missing gems"
|
8
|
+
exit e.status_code
|
9
|
+
end
|
10
|
+
require 'minitest/unit'
|
11
|
+
|
12
|
+
$LOAD_PATH.unshift(File.dirname(__FILE__))
|
13
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
14
|
+
require 'kookaburra'
|
15
|
+
|
16
|
+
class MiniTest::Unit::TestCase
|
17
|
+
end
|
18
|
+
|
19
|
+
MiniTest::Unit.autorun
|
metadata
ADDED
@@ -0,0 +1,209 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: kookaburra
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
hash: 27
|
5
|
+
prerelease:
|
6
|
+
segments:
|
7
|
+
- 0
|
8
|
+
- 0
|
9
|
+
- 2
|
10
|
+
version: 0.0.2
|
11
|
+
platform: ruby
|
12
|
+
authors:
|
13
|
+
- Renewable Funding, LLC
|
14
|
+
autorequire:
|
15
|
+
bindir: bin
|
16
|
+
cert_chain: []
|
17
|
+
|
18
|
+
date: 2012-01-15 00:00:00 Z
|
19
|
+
dependencies:
|
20
|
+
- !ruby/object:Gem::Dependency
|
21
|
+
version_requirements: &id001 !ruby/object:Gem::Requirement
|
22
|
+
none: false
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
hash: 7
|
27
|
+
segments:
|
28
|
+
- 3
|
29
|
+
- 0
|
30
|
+
version: "3.0"
|
31
|
+
name: activesupport
|
32
|
+
prerelease: false
|
33
|
+
type: :runtime
|
34
|
+
requirement: *id001
|
35
|
+
- !ruby/object:Gem::Dependency
|
36
|
+
version_requirements: &id002 !ruby/object:Gem::Requirement
|
37
|
+
none: false
|
38
|
+
requirements:
|
39
|
+
- - ">="
|
40
|
+
- !ruby/object:Gem::Version
|
41
|
+
hash: 3
|
42
|
+
segments:
|
43
|
+
- 0
|
44
|
+
version: "0"
|
45
|
+
name: rack
|
46
|
+
prerelease: false
|
47
|
+
type: :runtime
|
48
|
+
requirement: *id002
|
49
|
+
- !ruby/object:Gem::Dependency
|
50
|
+
version_requirements: &id003 !ruby/object:Gem::Requirement
|
51
|
+
none: false
|
52
|
+
requirements:
|
53
|
+
- - ">="
|
54
|
+
- !ruby/object:Gem::Version
|
55
|
+
hash: 3
|
56
|
+
segments:
|
57
|
+
- 0
|
58
|
+
version: "0"
|
59
|
+
name: minitest
|
60
|
+
prerelease: false
|
61
|
+
type: :development
|
62
|
+
requirement: *id003
|
63
|
+
- !ruby/object:Gem::Dependency
|
64
|
+
version_requirements: &id004 !ruby/object:Gem::Requirement
|
65
|
+
none: false
|
66
|
+
requirements:
|
67
|
+
- - ~>
|
68
|
+
- !ruby/object:Gem::Version
|
69
|
+
hash: 7
|
70
|
+
segments:
|
71
|
+
- 0
|
72
|
+
- 6
|
73
|
+
- 0
|
74
|
+
version: 0.6.0
|
75
|
+
name: yard
|
76
|
+
prerelease: false
|
77
|
+
type: :development
|
78
|
+
requirement: *id004
|
79
|
+
- !ruby/object:Gem::Dependency
|
80
|
+
version_requirements: &id005 !ruby/object:Gem::Requirement
|
81
|
+
none: false
|
82
|
+
requirements:
|
83
|
+
- - ~>
|
84
|
+
- !ruby/object:Gem::Version
|
85
|
+
hash: 23
|
86
|
+
segments:
|
87
|
+
- 1
|
88
|
+
- 0
|
89
|
+
- 0
|
90
|
+
version: 1.0.0
|
91
|
+
name: bundler
|
92
|
+
prerelease: false
|
93
|
+
type: :development
|
94
|
+
requirement: *id005
|
95
|
+
- !ruby/object:Gem::Dependency
|
96
|
+
version_requirements: &id006 !ruby/object:Gem::Requirement
|
97
|
+
none: false
|
98
|
+
requirements:
|
99
|
+
- - ~>
|
100
|
+
- !ruby/object:Gem::Version
|
101
|
+
hash: 7
|
102
|
+
segments:
|
103
|
+
- 1
|
104
|
+
- 6
|
105
|
+
- 4
|
106
|
+
version: 1.6.4
|
107
|
+
name: jeweler
|
108
|
+
prerelease: false
|
109
|
+
type: :development
|
110
|
+
requirement: *id006
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
version_requirements: &id007 !ruby/object:Gem::Requirement
|
113
|
+
none: false
|
114
|
+
requirements:
|
115
|
+
- - ">="
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
hash: 3
|
118
|
+
segments:
|
119
|
+
- 0
|
120
|
+
version: "0"
|
121
|
+
name: rcov
|
122
|
+
prerelease: false
|
123
|
+
type: :development
|
124
|
+
requirement: *id007
|
125
|
+
- !ruby/object:Gem::Dependency
|
126
|
+
version_requirements: &id008 !ruby/object:Gem::Requirement
|
127
|
+
none: false
|
128
|
+
requirements:
|
129
|
+
- - ~>
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
hash: 15
|
132
|
+
segments:
|
133
|
+
- 1
|
134
|
+
- 2
|
135
|
+
- 8
|
136
|
+
version: 1.2.8
|
137
|
+
name: reek
|
138
|
+
prerelease: false
|
139
|
+
type: :development
|
140
|
+
requirement: *id008
|
141
|
+
description: Cucumber + Capybara = Kookaburra? It made sense at the time.
|
142
|
+
email: devteam@renewfund.com
|
143
|
+
executables: []
|
144
|
+
|
145
|
+
extensions: []
|
146
|
+
|
147
|
+
extra_rdoc_files:
|
148
|
+
- LICENSE.txt
|
149
|
+
- README.rdoc
|
150
|
+
files:
|
151
|
+
- .document
|
152
|
+
- .rvmrc
|
153
|
+
- Gemfile
|
154
|
+
- Gemfile.lock
|
155
|
+
- LICENSE.txt
|
156
|
+
- README.rdoc
|
157
|
+
- Rakefile
|
158
|
+
- VERSION
|
159
|
+
- kookaburra.gemspec
|
160
|
+
- lib/kookaburra.rb
|
161
|
+
- lib/kookaburra/api_driver.rb
|
162
|
+
- lib/kookaburra/given_driver.rb
|
163
|
+
- lib/kookaburra/test_data.rb
|
164
|
+
- lib/kookaburra/test_data/factory.rb
|
165
|
+
- lib/kookaburra/ui_driver.rb
|
166
|
+
- lib/kookaburra/ui_driver/mixins/has_browser.rb
|
167
|
+
- lib/kookaburra/ui_driver/mixins/has_fields.rb
|
168
|
+
- lib/kookaburra/ui_driver/mixins/has_strategies.rb
|
169
|
+
- lib/kookaburra/ui_driver/mixins/has_subcomponents.rb
|
170
|
+
- lib/kookaburra/ui_driver/mixins/has_ui_component.rb
|
171
|
+
- lib/kookaburra/ui_driver/ui_component.rb
|
172
|
+
- lib/kookaburra/world_setup.rb
|
173
|
+
- lib/requires.rb
|
174
|
+
- test/helper.rb
|
175
|
+
homepage: http://github.com/projectdx/kookaburra
|
176
|
+
licenses:
|
177
|
+
- MIT
|
178
|
+
post_install_message:
|
179
|
+
rdoc_options: []
|
180
|
+
|
181
|
+
require_paths:
|
182
|
+
- lib
|
183
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
184
|
+
none: false
|
185
|
+
requirements:
|
186
|
+
- - ">="
|
187
|
+
- !ruby/object:Gem::Version
|
188
|
+
hash: 3
|
189
|
+
segments:
|
190
|
+
- 0
|
191
|
+
version: "0"
|
192
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
193
|
+
none: false
|
194
|
+
requirements:
|
195
|
+
- - ">="
|
196
|
+
- !ruby/object:Gem::Version
|
197
|
+
hash: 3
|
198
|
+
segments:
|
199
|
+
- 0
|
200
|
+
version: "0"
|
201
|
+
requirements: []
|
202
|
+
|
203
|
+
rubyforge_project:
|
204
|
+
rubygems_version: 1.8.15
|
205
|
+
signing_key:
|
206
|
+
specification_version: 3
|
207
|
+
summary: WindowDriver testing pattern for Ruby apps
|
208
|
+
test_files: []
|
209
|
+
|