browsery 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,266 @@
1
+ module Browsery
2
+
3
+ # An Browsery-specific test case container, which extends the default ones,
4
+ # adds convenience helper methods, and manages page objects automatically.
5
+ class TestCase < Minitest::Test
6
+ @@selected_methods = []
7
+ @@runnables_count = 0
8
+ @@regression_suite = Array.new
9
+ @@serials = Array.new
10
+ @@test_suite_data = if File.exist?(Browsery.root.join("config/browsery/test_suite.yml"))
11
+ YAML.load_file(Browsery.root.join("config/browsery/test_suite.yml"))
12
+ else
13
+ default = {"regression"=>{"tag_to_exclude"=>:non_regression}}
14
+ if Browsery.root != Browsery.gem_root
15
+ # Only necessary to notify gem user, not gem developer
16
+ puts "config/browsery/test_suite.yml doesn't exist, using default:\n#{default}"
17
+ puts "It's recommended to have this config file as it'll avoid problem when using tapout"
18
+ end
19
+ default
20
+ end
21
+
22
+ # Standard exception class that signals that the test with that name has
23
+ # already been defined.
24
+ class TestAlreadyDefined < ::StandardError; end
25
+
26
+ # Include helper modules
27
+ include Browsery::Utils::AssertionHelper
28
+ include Browsery::Utils::DataGeneratorHelper
29
+ include Browsery::Utils::Loggable
30
+ include Browsery::Utils::PageObjectHelper
31
+
32
+ class <<self
33
+
34
+ # @!attribute [rw] options
35
+ # @return [Hash] test case options
36
+ attr_accessor :options
37
+
38
+ # Explicitly remove _all_ tests from the current class. This will also
39
+ # remove inherited test cases.
40
+ #
41
+ # @return [TestCase] self
42
+ def remove_tests
43
+ klass = class <<self; self; end
44
+ public_instance_methods.grep(/^test_/).each do |method|
45
+ klass.send(:undef_method, method.to_sym)
46
+ end
47
+ self
48
+ end
49
+
50
+ # Call this at the top of your test case class in order to run all tests
51
+ # in alphabetical order
52
+ #
53
+ # @return [TestCase] self
54
+ # @example
55
+ # class SomeName < TestCase
56
+ # run_in_order!
57
+ #
58
+ # test :feature_search_01 { ... }
59
+ # test :feature_search_02 { ... }
60
+ # end
61
+ def run_in_order!
62
+ # `self` is the class, so we want to reopen the metaclass instead, and
63
+ # redefine the methods there
64
+ class <<self
65
+ undef_method :test_order if method_defined? :test_order
66
+ define_method :test_order do
67
+ :alpha
68
+ end
69
+ end
70
+
71
+ # Be nice and return the class back
72
+ self
73
+ end
74
+
75
+ # Filter out anything not matching our tag selection, if any.
76
+ #
77
+ # If it's parallel run,
78
+ # only add filtered methods from each runnable to a list of to run methods,
79
+ # instead of running them one by one right away,
80
+ # and finally when all runnable methods are traversed, call parallel to run that list of methods.
81
+ #
82
+ # @return [Enumerable<Symbol>] the methods marked runnable
83
+ def runnable_methods
84
+ methods = super
85
+ selected = Browsery.settings.tags
86
+
87
+ filtered_methods = filter_methods(methods, selected)
88
+
89
+ if Browsery.settings.parallel
90
+ unless filtered_methods.empty?
91
+ if selected.nil? || selected.empty?
92
+ @@selected_methods = @@regression_suite
93
+ else
94
+ methods_to_add = filtered_methods.map { |method| method.to_sym if @@regression_suite.include?(method.to_sym) }
95
+ @@selected_methods += methods_to_add
96
+ end
97
+ end
98
+
99
+ @@runnables_count += 1
100
+ browsery_runnables = Minitest::Runnable.runnables - [Minitest::Test, Minitest::Unit::TestCase]
101
+
102
+ if @@runnables_count == browsery_runnables.size
103
+ parallel = Parallel.new(Browsery.settings.parallel, @@selected_methods)
104
+ parallel.clean_result!
105
+ parallel.run_in_parallel!
106
+ parallel.remove_redundant_tap if Browsery.settings.rerun_failure
107
+ parallel.aggregate_tap_results
108
+ exit
109
+ end
110
+
111
+ return [] # no test will run
112
+ else
113
+ filtered_methods
114
+ end
115
+ end
116
+
117
+ # Filter methods in a runnable based on our tag selection
118
+ def filter_methods(methods, selected)
119
+ # If no tags are selected, run all tests
120
+ if selected.nil? || selected.empty?
121
+ return methods
122
+ end
123
+
124
+ selected_methods = methods.select do |method|
125
+ # If the method's tags match any of the tag sets, allow it to run
126
+ selected.any? do |tag_set|
127
+ # Retrieve the tags for that method
128
+ method_options = self.options[method.to_sym] rescue nil
129
+ tags = method_options[:tags] rescue nil
130
+
131
+ # If the method's tags match ALL of the tags in the tag set, allow
132
+ # it to run; in the event of a problem, allow the test to run
133
+ tag_set.all? do |tag|
134
+ if tag =~ %r/^!/
135
+ !tags.include?(tag[%r/^!(.*)/,1].to_sym) || nil
136
+ else
137
+ tags.include?(tag.to_sym) || nil
138
+ end rescue true
139
+ end
140
+ end
141
+ end
142
+
143
+ selected_methods
144
+ end
145
+
146
+ # Install a setup method that runs before every test.
147
+ #
148
+ # @return [void]
149
+ def setup(&block)
150
+ define_method(:setup) do
151
+ super()
152
+ instance_eval(&block)
153
+ end
154
+ end
155
+
156
+ # Install a teardown method that runs after every test.
157
+ #
158
+ # @return [void]
159
+ def teardown(&block)
160
+ define_method(:teardown) do
161
+ super()
162
+ instance_eval(&block)
163
+ end
164
+ end
165
+
166
+ # Defines a test case.
167
+ #
168
+ # It can take the following options:
169
+ #
170
+ # * `tags`: An array of any number of tags associated with the test case.
171
+ # When not specified, the test will always be run even when only
172
+ # certain tags are run. When specified but an empty array, the
173
+ # test will only be run if all tags are set to run. When the array
174
+ # contains one or more tags, then the test will only be run if at
175
+ # least one tag matches.
176
+ # * `serial`: An arbitrary string that is used to refer to all a specific
177
+ # test case. For example, this can be used to store the serial
178
+ # number for the test case.
179
+ #
180
+ # @param name [String, Symbol] an arbitrary but unique name for the test,
181
+ # preferably unique across all test classes, but not required
182
+ # @param opts [Hash]
183
+ # @param block [Proc] the testing logic
184
+ # @return [void]
185
+ def test(name, **opts, &block)
186
+ # Ensure that the test isn't already defined to prevent tests from being
187
+ # swallowed silently
188
+ method_name = test_name(name)
189
+ check_not_defined!(method_name)
190
+
191
+ # Add an additional tag, which is unique for each test class, to all tests
192
+ # To allow user to run tests with option '-t class_name_of_the_test' without
193
+ # duplicate run for all tests in NameOfTheTest. The namespace of the class
194
+ # is ignored here.
195
+ opts[:tags] << ('class_'+ self.name.demodulize.underscore).to_sym
196
+
197
+ # Flunk unless a logic block was provided
198
+ if block_given?
199
+ self.options ||= {}
200
+ self.options[method_name.to_sym] = opts.deep_symbolize_keys
201
+ define_method(method_name, &block)
202
+ else
203
+ flunk "No implementation was provided for test '#{method_name}' in #{self}"
204
+ end
205
+
206
+ # add all tests to @@regression_suite
207
+ # excluding the ones with tags in tags_to_exclude defined in config
208
+ unless exclude_by_tag?('regression', opts[:tags])
209
+ @@regression_suite << method_name
210
+ @@serials << opts[:serial]
211
+ end
212
+ end
213
+
214
+ # @param suite [String] type of test suite
215
+ # @param tags [Array] an array of tags a test has
216
+ # @return [Boolean]
217
+ def exclude_by_tag?(suite, tags)
218
+ tag_to_exclude = @@test_suite_data[suite]['tag_to_exclude']
219
+ if tags.include? tag_to_exclude
220
+ true
221
+ else
222
+ false
223
+ end
224
+ end
225
+
226
+ # Check that +method_name+ hasn't already been defined as an instance
227
+ # method in the current class, or in any superclasses.
228
+ #
229
+ # @param method_name [Symbol] the method name to check
230
+ # @return [void]
231
+ protected
232
+ def check_not_defined!(method_name)
233
+ already_defined = instance_method(method_name) rescue false
234
+ raise TestAlreadyDefined, "Test #{method_name} already exists in #{self}" if already_defined
235
+ end
236
+
237
+ # Transform the test +name+ into a snake-case name, prefixed with "test_".
238
+ #
239
+ # @param name [#to_s] the test name
240
+ # @return [Symbol] the transformed test name symbol
241
+ # @example
242
+ # test_name(:search_zip) # => :test_search_zip
243
+ private
244
+ def test_name(name)
245
+ undercased_name = sanitize_name(name).gsub(/\s+/, '_')
246
+ "test_#{undercased_name}".to_sym
247
+ end
248
+
249
+ # Sanitize the +name+ by removing consecutive non-word characters into a
250
+ # single whitespace.
251
+ #
252
+ # @param name [#to_s] the name to sanitize
253
+ # @return [String] the sanitized value
254
+ # @example
255
+ # sanitize_name('The Best Thing [#5]') # => 'The Best Thing 5'
256
+ # sanitize_name(:ReallySuper___awesome) # => 'ReallySuper Awesome'
257
+ private
258
+ def sanitize_name(name)
259
+ name.to_s.gsub(/\W+/, ' ').strip
260
+ end
261
+
262
+ end
263
+
264
+ end
265
+
266
+ end
@@ -0,0 +1,7 @@
1
+ module Browsery
2
+
3
+ # An empty container for actual test cases and test classes.
4
+ module TestCases
5
+ end
6
+
7
+ end
@@ -0,0 +1,10 @@
1
+ module Browsery
2
+ module Utils; end
3
+ end
4
+
5
+ require_relative 'utils/assertion_helper'
6
+ require_relative 'utils/castable'
7
+ require_relative 'utils/data_generator_helper'
8
+ require_relative 'utils/loggable'
9
+ require_relative 'utils/page_object_helper'
10
+ require_relative 'utils/overlay_and_widget_helper'
@@ -0,0 +1,35 @@
1
+ require 'minitest/assertions'
2
+
3
+ module Browsery
4
+ module Utils
5
+
6
+ # A collection of custom, but frequently-used assertions.
7
+ module AssertionHelper
8
+
9
+ # Assert that an element, specified by `how` and `what`, are absent from
10
+ # the current page's context.
11
+ #
12
+ # @param how [:class, :class_name, :css, :id, :link_text, :link,
13
+ # :partial_link_text, :name, :tag_name, :xpath]
14
+ # @param what [String, Symbol]
15
+ def assert_element_absent(how, what)
16
+ assert_raises Selenium::WebDriver::Error::NoSuchElementError do
17
+ @driver.find_element(how, what)
18
+ end
19
+ end
20
+
21
+ # Assert that an element, specified by `how` and `what`, are present from
22
+ # the current page's context.
23
+ #
24
+ # @param how [:class, :class_name, :css, :id, :link_text, :link,
25
+ # :partial_link_text, :name, :tag_name, :xpath]
26
+ # @param what [String, Symbol]
27
+ def assert_element_present(how, what)
28
+ @driver.find_element(how, what)
29
+ end
30
+
31
+ end
32
+
33
+ end
34
+ end
35
+
@@ -0,0 +1,103 @@
1
+
2
+ module Browsery
3
+ module Utils
4
+
5
+ module Castable
6
+
7
+ module ClassMethods
8
+
9
+ # Attempts to create a new page object from a driver state. Use the
10
+ # instance method for convenience. Raises `NameError` if the page could
11
+ # not be found.
12
+ #
13
+ # @param driver [Selenium::WebDriver] The instance of the current
14
+ # WebDriver.
15
+ # @param name [#to_s] The name of the page object to instantiate.
16
+ # @return [Base] A subclass of `Base` representing the page object.
17
+ # @raise InvalidPageState if the page cannot be casted to
18
+ # @raise NameError if the page object doesn't exist
19
+ def cast(driver, name)
20
+ # Transform the name string into a file path and then into a module name
21
+ klass_name = "browsery/page_objects/#{name}".camelize
22
+
23
+ # Attempt to load the class
24
+ klass = begin
25
+ klass_name.constantize
26
+ rescue => exc
27
+ msg = ""
28
+ msg << "Cannot find page object '#{name}', "
29
+ msg << "because could not load class '#{klass_name}' "
30
+ msg << "with underlying error:\n #{exc.class}: #{exc.message}\n"
31
+ msg << exc.backtrace.map { |str| " #{str}" }.join("\n")
32
+ raise NameError, msg
33
+ end
34
+
35
+ # Instantiate the class, passing the driver automatically, and
36
+ # validates to ensure the driver is in the correct state
37
+ instance = klass.new(driver)
38
+ begin
39
+ instance.validate!
40
+ rescue Minitest::Assertion => exc
41
+ raise Browsery::PageObjects::InvalidePageState, "#{klass}: #{exc.message}"
42
+ end
43
+ instance
44
+ end
45
+
46
+ end
47
+
48
+ # Extend the base class in which this module is included in order to
49
+ # inject class methods.
50
+ #
51
+ # @param base [Class]
52
+ # @return [void]
53
+ def self.included(base)
54
+ base.extend(ClassMethods)
55
+ end
56
+
57
+ # The preferred way to create a new page object from the current page's
58
+ # driver state. Raises a NameError if the page could not be found. If
59
+ # casting causes a StaleElementReferenceError, the method will retry up
60
+ # to 2 more times.
61
+ #
62
+ # @param name [String] see {Base.cast}
63
+ # @return [Base] The casted page object.
64
+ # @raise InvalidPageState if the page cannot be casted to
65
+ # @raise NameError if the page object doesn't exist
66
+ def cast(name)
67
+ tries ||= 3
68
+ self.class.cast(@driver, name).tap do |new_page|
69
+ self.freeze
70
+ Browsery.logger.debug("Casting #{self.class}(##{self.object_id}) into #{new_page.class}(##{new_page.object_id})")
71
+ end
72
+ rescue Selenium::WebDriver::Error::StaleElementReferenceError => sere
73
+ Browsery.logger.debug("#{self.class}(##{@driver.object_id})->cast(#{name}) raised a potentially-swallowed StaleElementReferenceError")
74
+ sleep 1
75
+ retry unless (tries -= 1).zero?
76
+ end
77
+
78
+ # Cast the page to any of the listed `names`, in order of specification.
79
+ # Returns the first page that accepts the casting, or returns nil, rather
80
+ # than raising InvalidPageState.
81
+ #
82
+ # @param names [Enumerable<String>] see {Base.cast}
83
+ # @return [Base, nil] the casted page object, if successful; nil otherwise.
84
+ # @raise NameError if the page object doesn't exist
85
+ def cast_any(*names)
86
+ # Try one at a time, swallowing InvalidPageState exceptions
87
+ names.each do |name|
88
+ begin
89
+ return self.cast(name)
90
+ rescue InvalidPageState
91
+ # noop
92
+ end
93
+ end
94
+
95
+ # Return nil otherwise
96
+ return nil
97
+ end
98
+
99
+ end
100
+
101
+ end
102
+ end
103
+
@@ -0,0 +1,145 @@
1
+
2
+ module Browsery
3
+ module Utils
4
+
5
+ # Useful helpers to generate fake data.
6
+ module DataGeneratorHelper
7
+
8
+ # All valid area codes in the US
9
+ NPA = ["201", "202", "203", "205", "206", "207", "208", "209", "210", "212", "213", "214", "215", "216", "217", "218", "219", "224", "225", "227", "228", "229", "231", "234", "239", "240", "248", "251", "252", "253", "254", "256", "260", "262", "267", "269", "270", "276", "281", "283", "301", "302", "303", "304", "305", "307", "308", "309", "310", "312", "313", "314", "315", "316", "317", "318", "319", "320", "321", "323", "330", "331", "334", "336", "337", "339", "347", "351", "352", "360", "361", "386", "401", "402", "404", "405", "406", "407", "408", "409", "410", "412", "413", "414", "415", "417", "419", "423", "424", "425", "434", "435", "440", "443", "445", "464", "469", "470", "475", "478", "479", "480", "484", "501", "502", "503", "504", "505", "507", "508", "509", "510", "512", "513", "515", "516", "517", "518", "520", "530", "540", "541", "551", "557", "559", "561", "562", "563", "564", "567", "570", "571", "573", "574", "580", "585", "586", "601", "602", "603", "605", "606", "607", "608", "609", "610", "612", "614", "615", "616", "617", "618", "619", "620", "623", "626", "630", "631", "636", "641", "646", "650", "651", "660", "661", "662", "667", "678", "682", "701", "702", "703", "704", "706", "707", "708", "712", "713", "714", "715", "716", "717", "718", "719", "720", "724", "727", "731", "732", "734", "737", "740", "754", "757", "760", "763", "765", "770", "772", "773", "774", "775", "781", "785", "786", "801", "802", "803", "804", "805", "806", "808", "810", "812", "813", "814", "815", "816", "817", "818", "828", "830", "831", "832", "835", "843", "845", "847", "848", "850", "856", "857", "858", "859", "860", "862", "863", "864", "865", "870", "872", "878", "901", "903", "904", "906", "907", "908", "909", "910", "912", "913", "914", "915", "916", "917", "918", "919", "920", "925", "928", "931", "936", "937", "940", "941", "947", "949", "952", "954", "956", "959", "970", "971", "972", "973", "975", "978", "979", "980", "984", "985", "989"]
10
+
11
+ # Easier to assume for now a list of valid exchanges
12
+ NXX = NPA
13
+
14
+ # Generate a string of random digits.
15
+ #
16
+ # @param digits [Fixnum] the number of digits in the string
17
+ # @return [String] the string of digits
18
+ def generate_digits(digits = 1)
19
+ Faker::Number.number(digits)
20
+ end
21
+
22
+ # Generate a random email address.
23
+ #
24
+ # The specifier portion may be:
25
+ #
26
+ # * `nil`, in which case nothing special happens;
27
+ # * a `String`, in which case the words in the string is shuffled, and
28
+ # random separators (`.` or `_`) are inserted between them;
29
+ # * an `Integer`, in which case a random alpha-string will be created
30
+ # with length of at least that many characters;
31
+ # * a `Range`, in which case a random alpha-string of length within the
32
+ # range will be produced.
33
+ #
34
+ # @param specifier [nil, String, Integer, Range] a specifier to help
35
+ # generate the username part of the email address
36
+ # @return [String]
37
+ def generate_email(specifier = nil)
38
+ Faker::Internet.email(name)
39
+ end
40
+
41
+ # Generate a handsome first name.
42
+ #
43
+ # @param length [#to_i, nil]
44
+ # @return [String]
45
+ def generate_first_name(length = nil)
46
+ first_name = ''
47
+ if length.nil?
48
+ first_name = Faker::Name.first_name
49
+ else
50
+ # Ensure a name with requested length is generated
51
+ name_length = Faker::Name.first_name.length
52
+ if length > name_length
53
+ first_name = Faker::Lorem.characters(length)
54
+ else
55
+ first_name = Faker::Name.first_name[0..length.to_i]
56
+ end
57
+ end
58
+ # remove all special characters since name fields on our site have this requirement
59
+ first_name.gsub!(/[^0-9A-Za-z]/, '')
60
+ first_name
61
+ end
62
+
63
+ # Generate a gosh-darn awesome last name.
64
+ #
65
+ # @param length [#to_i, nil]
66
+ # @return [String]
67
+ def generate_last_name(length = nil)
68
+ last_name = ''
69
+ if length.nil?
70
+ last_name = Faker::Name.last_name
71
+ else
72
+ # Ensure a name with requested length is generated
73
+ name_length = Faker::Name.last_name.length
74
+ if length > name_length
75
+ last_name = Faker::Lorem.characters(length)
76
+ else
77
+ last_name = Faker::Name.last_name[0..length.to_i]
78
+ end
79
+ end
80
+ # remove all special characters since name fields on our site have this requirement
81
+ last_name.gsub!(/[^0-9A-Za-z]/, '')
82
+ last_name
83
+ end
84
+
85
+ # Generate a unique random email ends with @test.com
86
+ def generate_test_email
87
+ [ "#{generate_last_name}.#{generate_unique_id}", 'test.com' ].join('@')
88
+ end
89
+
90
+ # Generate a random number between 0 and `max - 1` if `max` is >= 1,
91
+ # or between 0 and 1 otherwise.
92
+ def generate_number(max = nil)
93
+ rand(max)
94
+ end
95
+
96
+ # Generates a U.S. phone number (NANPA-aware).
97
+ #
98
+ # @param format [Symbol, nil] the format of the phone, one of: nil,
99
+ # `:paren`, `:dotted`, or `:dashed`
100
+ # @return [String] the phone number
101
+ def generate_phone_number(format = nil)
102
+ case format
103
+ when :paren, :parenthesis, :parentheses
104
+ '(' + NPA.sample + ') ' + NXX.sample + '-' + generate_digits(4)
105
+ when :dot, :dotted, :dots, :period, :periods
106
+ [ NPA.sample, NXX.sample, generate_digits(4) ].join('.')
107
+ when :dash, :dashed, :dashes
108
+ [ NPA.sample, NXX.sample, generate_digits(4) ].join('-')
109
+ else
110
+ NPA.sample + NXX.sample + generate_digits(4)
111
+ end
112
+ end
113
+
114
+ # Generate a random date.
115
+ #
116
+ # @param start_date [Integer] minimum date range
117
+ # @param end_date [Integer] maximum date range
118
+ # @return [String] the generated date
119
+ def generate_date(start_date, end_date)
120
+ random_date = rand start_date..end_date
121
+ return random_date.to_formatted_s(:month_day_year)
122
+ end
123
+
124
+ # Generate a unique id with a random hex string and time stamp string
125
+ def generate_unique_id
126
+ SecureRandom.hex(3) + Time.current.to_i.to_s
127
+ end
128
+
129
+ # Generate a random password of a certain length, or default length 12
130
+ #
131
+ # @param length [#to_i, nil]
132
+ # @return [String]
133
+ def generate_password(length = nil)
134
+ if length.nil?
135
+ SecureRandom.hex(6) # result length = 12
136
+ else
137
+ chars = (('a'..'z').to_a + ('0'..'9').to_a) - %w(i o 0 1 l 0)
138
+ (1..length).collect{|a| chars[rand(chars.length)] }.join
139
+ end
140
+ end
141
+
142
+ end
143
+
144
+ end
145
+ end