browsery 0.1.0 → 0.2.0

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