testable 0.3.0 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. checksums.yaml +5 -5
  2. data/.gitignore +37 -25
  3. data/.hound.yml +31 -12
  4. data/.rubocop.yml +4 -0
  5. data/.travis.yml +7 -0
  6. data/CODE_OF_CONDUCT.md +1 -1
  7. data/Gemfile +3 -1
  8. data/{LICENSE.txt → LICENSE.md} +2 -2
  9. data/README.md +36 -17
  10. data/Rakefile +52 -11
  11. data/bin/console +2 -2
  12. data/bin/setup +0 -0
  13. data/examples/testable-capybara-context.rb +64 -0
  14. data/examples/testable-capybara-rspec.rb +70 -0
  15. data/examples/testable-capybara.rb +46 -0
  16. data/examples/testable-info.rb +65 -0
  17. data/examples/testable-watir-context.rb +67 -0
  18. data/examples/testable-watir-datasetter.rb +52 -0
  19. data/examples/testable-watir-events.rb +44 -0
  20. data/examples/testable-watir-ready.rb +34 -0
  21. data/examples/testable-watir-test.rb +80 -0
  22. data/examples/testable-watir.rb +118 -0
  23. data/lib/testable.rb +142 -10
  24. data/lib/testable/attribute.rb +38 -0
  25. data/lib/testable/capybara/dsl.rb +82 -0
  26. data/lib/testable/capybara/node.rb +30 -0
  27. data/lib/testable/capybara/page.rb +29 -0
  28. data/lib/testable/context.rb +73 -0
  29. data/lib/testable/deprecator.rb +40 -0
  30. data/lib/testable/element.rb +162 -31
  31. data/lib/testable/errors.rb +6 -2
  32. data/lib/testable/extensions/core_ruby.rb +13 -0
  33. data/lib/testable/extensions/data_setter.rb +144 -0
  34. data/lib/testable/extensions/dom_observer.js +58 -4
  35. data/lib/testable/extensions/dom_observer.rb +73 -0
  36. data/lib/testable/locator.rb +63 -0
  37. data/lib/testable/logger.rb +16 -0
  38. data/lib/testable/page.rb +216 -0
  39. data/lib/testable/ready.rb +49 -7
  40. data/lib/testable/situation.rb +9 -28
  41. data/lib/testable/version.rb +7 -6
  42. data/testable.gemspec +19 -9
  43. metadata +90 -23
  44. data/circle.yml +0 -3
  45. data/lib/testable/data_setter.rb +0 -51
  46. data/lib/testable/dom_update.rb +0 -19
  47. data/lib/testable/factory.rb +0 -27
  48. data/lib/testable/interface.rb +0 -114
@@ -0,0 +1,29 @@
1
+ require "capybara"
2
+ require "testable/capybara/node"
3
+
4
+ module Testable
5
+ class Page < Node
6
+ # The `Page` class wraps an HTML page with an application-specific API.
7
+ # This can be extended to define an API for manipulating the pages of
8
+ # the web application.
9
+ attr_reader :path
10
+
11
+ def self.visit
12
+ new.visit
13
+ end
14
+
15
+ def initialize(node: Capybara.current_session, path: nil)
16
+ @node = node
17
+ @path = path
18
+ end
19
+
20
+ def visit
21
+ @node.visit(path)
22
+ self
23
+ end
24
+
25
+ def current?
26
+ @node.current_path == path
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,73 @@
1
+ module Testable
2
+ module Context
3
+ # Creates a definition context for actions and establishes the context
4
+ # for execution. Given an interface definition for a page like this:
5
+ #
6
+ # class TestPage
7
+ # include Testable
8
+ #
9
+ # url_is "http://localhost:9292"
10
+ # end
11
+ #
12
+ # You can do the following:
13
+ #
14
+ # on_visit(TestPage)
15
+ def on_visit(definition, &block)
16
+ create_active(definition)
17
+ @context.visit
18
+ verify_page(@context)
19
+ call_block(&block)
20
+ end
21
+
22
+ # Creates a definition context for actions. If an existing context
23
+ # exists, that context will be re-used. You can use this simply to keep
24
+ # the context for a script clear. For example, say you have the following
25
+ # interface definitions for pages:
26
+ #
27
+ # class Home
28
+ # include Testable
29
+ # url_is "http://localhost:9292"
30
+ # end
31
+ #
32
+ # class Navigation
33
+ # include Testable
34
+ # end
35
+ #
36
+ # You could then do this:
37
+ #
38
+ # on_visit(Home)
39
+ # on(Navigation)
40
+ #
41
+ # The Home definition needs the url_is attribute in order for the on_view
42
+ # factory to work. But Navigation does not because the `on` method is not
43
+ # attempting to visit, simply to reference.
44
+ def on(definition, &block)
45
+ create_active(definition)
46
+ call_block(&block)
47
+ end
48
+
49
+ private
50
+
51
+ # This method is used to provide a means for checking if a page has been
52
+ # navigated to correctly as part of a context. This is useful because
53
+ # the context signature should remain highly readable, and checks for
54
+ # whether a given page has been reached would make the context definition
55
+ # look sloppy.
56
+ def verify_page(context)
57
+ return unless defined?(context.url_match_attribute)
58
+ return if context.url_match_attribute.nil?
59
+ return if context.has_correct_url?
60
+
61
+ raise Testable::Errors::PageURLFromFactoryNotVerified
62
+ end
63
+
64
+ def create_active(definition)
65
+ @context = definition.new unless @context.is_a?(definition)
66
+ end
67
+
68
+ def call_block(&block)
69
+ yield @context if block
70
+ @context
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,40 @@
1
+ module Testable
2
+ class Deprecator
3
+ class << self
4
+ def deprecate(current, upcoming = nil, known_version = nil)
5
+ if upcoming
6
+ warn(
7
+ "#{current} is being deprecated and should no longer be used. \
8
+ Use #{upcoming} instead."
9
+ )
10
+ else
11
+ warn("#{current} is being deprecated and should no longer be used.")
12
+ end
13
+
14
+ warn(
15
+ "#{current} will be removed in Testable #{known_version}."
16
+ ) if known_version
17
+ end
18
+
19
+ def soft_deprecate(current, reason, known_version, upcoming = nil)
20
+ debug("The #{current} method is changing and is now configurable.")
21
+ debug("REASON: #{reason}.")
22
+ debug(
23
+ "Moving forwards into Testable #{known_version}, \
24
+ the default behavior will change."
25
+ )
26
+ debug("It is advised that you change to using #{upcoming}") if upcoming
27
+ end
28
+
29
+ private
30
+
31
+ def warn(message)
32
+ Testable.logger.warn(message)
33
+ end
34
+
35
+ def debug(message)
36
+ Testable.logger.debug(message)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -1,14 +1,9 @@
1
1
  require "watir"
2
- require "testable/situation"
3
2
 
4
3
  module Testable
5
- include Situation
6
-
7
4
  module_function
8
5
 
9
- def elements
10
- @elements = Watir::Container.instance_methods unless @elements
11
- end
6
+ NATIVE_QUALIFIERS = %i[visible].freeze
12
7
 
13
8
  def elements?
14
9
  @elements
@@ -18,38 +13,174 @@ module Testable
18
13
  @elements.include? method.to_sym
19
14
  end
20
15
 
21
- module Element
22
- Testable.elements.each do |element|
23
- # This is what allows Watir-based elements to be defined
24
- # on an element definition.
25
- define_method(element) do |*signature|
26
- identifier, locator = parse_signature(signature)
27
- define_element_accessor(identifier, locator, element)
16
+ def elements
17
+ @elements ||= Watir::Container.instance_methods unless @elements
18
+ end
19
+
20
+ module Pages
21
+ module Element
22
+ # This iterator goes through the Watir container methods and
23
+ # provides a method for each so that Watir-based element names
24
+ # cane be defined on an interface definition, as part of an
25
+ # element definition.
26
+ Testable.elements.each do |element|
27
+ define_method(element) do |*signature, &block|
28
+ identifier, signature = parse_signature(signature)
29
+ context = context_from_signature(signature, &block)
30
+ define_element_accessor(identifier, signature, element, &context)
31
+ end
28
32
  end
29
- end
30
33
 
31
- private
34
+ private
35
+
36
+ # A "signature" consists of a full element definition. For example:
37
+ #
38
+ # text_field :username, id: 'username'
39
+ #
40
+ # The signature of this element definition is:
41
+ #
42
+ # [:username, {:id=>"username"}]
43
+ #
44
+ # This is the identifier of the element (`username`) and the locator
45
+ # provided for it. This method separates out the identifier and the
46
+ # locator.
47
+ def parse_signature(signature)
48
+ [signature.shift, signature.shift]
49
+ end
32
50
 
33
- def define_element_accessor(identifier, *locator, element)
34
- # This is what allows each identifier to be a method.
35
- define_method(identifier.to_s.to_sym) do |*values|
36
- no_locator(self.class, identifier) if empty_locator(locator, values)
37
- locator = values if locator[0].nil?
38
- reference_element(element, locator)
51
+ # Returns the block or proc that serves as a context for an element
52
+ # definition. Consider the following element definitions:
53
+ #
54
+ # ul :facts, id: 'fact-list'
55
+ # span :fact, -> { facts.span(class: 'site-item')}
56
+ #
57
+ # Here the second element definition provides a proc that contains a
58
+ # context for another element definition. That leads to the following
59
+ # construction being sent to the browser:
60
+ #
61
+ # @browser.ul(id: 'fact-list').span(class: 'site-item')
62
+ def context_from_signature(*signature, &block)
63
+ if block_given?
64
+ block
65
+ else
66
+ context = signature.shift
67
+ context.is_a?(Proc) && signature.empty? ? context : nil
68
+ end
39
69
  end
40
- end
41
70
 
42
- def parse_signature(signature)
43
- [signature.shift, signature.shift]
44
- end
71
+ # This method provides the means to get the aspects of an accessor
72
+ # signature. The "aspects" refer to the locator information and any
73
+ # qualifier information that was provided along with the locator.
74
+ # This is important because the qualifier is not used to locate an
75
+ # element but rather to put conditions on how the state of the
76
+ # element is checked as it is being looked for.
77
+ #
78
+ # Note that "qualifiers" here refers to Watir boolean methods.
79
+ def accessor_aspects(element, *signature)
80
+ identifier = signature.shift
81
+ locator_args = {}
82
+ qualifier_args = {}
83
+ gather_aspects(identifier, element, locator_args, qualifier_args)
84
+ [locator_args, qualifier_args]
85
+ end
45
86
 
46
- module Locator
47
- private
87
+ # This method is used to separate the two aspects of an accessor --
88
+ # the locators and the qualifiers. Part of this process involves
89
+ # querying the Watir driver library to determine what qualifiers
90
+ # it handles natively. Consider the following:
91
+ #
92
+ # select_list :accounts, id: 'accounts', selected: 'Select Option'
93
+ #
94
+ # Given that, this method will return with the following:
95
+ #
96
+ # locator_args: {:id=>"accounts"}
97
+ # qualifier_args: {:selected=>"Select Option"}
98
+ #
99
+ # Consider this:
100
+ #
101
+ # p :login_form, id: 'open', index: 0, visible: true
102
+ #
103
+ # Given that, this method will return with the following:
104
+ #
105
+ # locator_args: {:id=>"open", :index=>0, :visible=>true}
106
+ # qualifier_args: {}
107
+ #
108
+ # Notice that the `visible` qualifier is part of the locator arguments
109
+ # as opposed to being a qualifier argument, like `selected` was in the
110
+ # previous example. This is because Watir 6.x handles the `visible`
111
+ # qualifier natively. "Handling natively" means that when a qualifier
112
+ # is part of the locator, Watir knows how to intrpret the qualifier
113
+ # as a condition on the element, not as a way to locate the element.
114
+ def gather_aspects(identifier, element, locator_args, qualifier_args)
115
+ identifier.each_with_index do |hashes, index|
116
+ next if hashes.nil? || hashes.is_a?(Proc)
117
+
118
+ hashes.each do |k, v|
119
+ methods = Watir.element_class_for(element).instance_methods
120
+ if methods.include?(:"#{k}?") && !NATIVE_QUALIFIERS.include?(k)
121
+ qualifier_args[k] = identifier[index][k]
122
+ else
123
+ locator_args[k] = v
124
+ end
125
+ end
126
+ end
127
+ [locator_args, qualifier_args]
128
+ end
48
129
 
49
- def reference_element(element, locator)
50
- @browser.__send__(element, *locator).to_subtype
51
- rescue NoMethodError
52
- @browser.__send__(element, *locator)
130
+ # Defines an accessor method for an element that allows the "friendly
131
+ # name" (identifier) of the element to be proxied to a Watir element
132
+ # object that corresponds to the element type. When this identifier
133
+ # is referenced, it generates an accessor method for that element
134
+ # in the browser. Consider this element definition defined on a class
135
+ # with an instance of `page`:
136
+ #
137
+ # text_field :username, id: 'username'
138
+ #
139
+ # This allows:
140
+ #
141
+ # page.username.set 'tester'
142
+ #
143
+ # So any element identifier can be called as if it were a method on
144
+ # the interface (class) on which it is defined. Because the method
145
+ # is proxied to Watir, you can use the full Watir API by calling
146
+ # methods (like `set`, `click`, etc) on the element identifier.
147
+ #
148
+ # It is also possible to have an element definition like this:
149
+ #
150
+ # text_field :password
151
+ #
152
+ # This would allow access like this:
153
+ #
154
+ # page.username(id: 'username').set 'tester'
155
+ #
156
+ # This approach would lead to the *values variable having an array
157
+ # like this: [{:id => 'username'}].
158
+ #
159
+ # A third approach would be to utilize one element definition within
160
+ # the context of another. Consider the following element definitions:
161
+ #
162
+ # article :practice, id: 'practice'
163
+ #
164
+ # a :page_link do |text|
165
+ # practice.a(text: text)
166
+ # end
167
+ #
168
+ # This would allow access like this:
169
+ #
170
+ # page.page_link('Drag and Drop').click
171
+ #
172
+ # This approach would lead to the *values variable having an array
173
+ # like this: ["Drag and Drop"].
174
+ def define_element_accessor(identifier, *signature, element, &block)
175
+ locators, qualifiers = accessor_aspects(element, signature)
176
+ define_method(identifier.to_s) do |*values|
177
+ if block_given?
178
+ instance_exec(*values, &block)
179
+ else
180
+ locators = values[0] if locators.empty?
181
+ access_element(element, locators, qualifiers)
182
+ end
183
+ end
53
184
  end
54
185
  end
55
186
  end
@@ -4,8 +4,12 @@ module Testable
4
4
  NoUrlMatchForDefinition = Class.new(StandardError)
5
5
  NoUrlMatchPossible = Class.new(StandardError)
6
6
  NoTitleForDefinition = Class.new(StandardError)
7
- NoLocatorForDefinition = Class.new(StandardError)
8
7
  PageNotValidatedError = Class.new(StandardError)
9
- NoBlockForWhenReady = Class.new(StandardError)
8
+
9
+ class PageURLFromFactoryNotVerified < StandardError
10
+ def message
11
+ "The page URL was not verified during a factory setup."
12
+ end
13
+ end
10
14
  end
11
15
  end
@@ -0,0 +1,13 @@
1
+ class String
2
+ # This is only required if using a version of Ruby before 2.4. A match?
3
+ # method for String was added in version 2.4.
4
+ def match?(string, pos = 0)
5
+ !!match(string, pos)
6
+ end unless //.respond_to?(:match?)
7
+ end
8
+
9
+ class FalseClass
10
+ def exists?
11
+ false
12
+ end
13
+ end
@@ -0,0 +1,144 @@
1
+ class Object
2
+ # This method is necessary to dynamically chain method calls. The reason
3
+ # this is necessary the data setter initially has no idea of the actual
4
+ # object it's going to be dealing with, particularly because part of its
5
+ # job is to find that object and map a data string to it. Not only this,
6
+ # but that element will have been called on a specific instance of a
7
+ # interface class. With the example provide in the comments below for the
8
+ # `using` method, the following would be the case:
9
+ #
10
+ # method_chain: warp_factor.set
11
+ # o (object): <WarpTravel:0x007f8b23224218>
12
+ # m (method): warp_factor
13
+ # data: 1
14
+ #
15
+ # Thus what you end up with is:
16
+ #
17
+ # <WarpTravel:0x007f8b23224218>.warp_factor.set 1
18
+ def chain(method_chain, data = nil)
19
+ return self if method_chain.empty?
20
+
21
+ method_chain.split('.').inject(self) do |o, m|
22
+ if data.nil?
23
+ o.send(m.intern)
24
+ else
25
+ o.send(m.intern, data)
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ module Testable
32
+ module DataSetter
33
+ # The `using` method tells Testable to match up whatever data is passed
34
+ # in via the action with element definitions. If those elements are found,
35
+ # they will be populated with the specified data. Consider the following:
36
+ #
37
+ # class WarpTravel
38
+ # include Testable
39
+ #
40
+ # text_field :warp_factor, id: 'warpInput'
41
+ # text_field :velocity, id: 'velocityInput'
42
+ # text_field :distance, id: 'distInput'
43
+ # end
44
+ #
45
+ # Assuming an instance of this class called `page`, you could do the
46
+ # following:
47
+ #
48
+ # page.using_data(warp_factor: 1, velocity: 1, distance: 4.3)
49
+ #
50
+ # This is based on conventions. The idea is that element definitions are
51
+ # written in the form of "snake case" -- meaning, underscores between
52
+ # each separate word. In the above example, "warp_factor: 1" would be
53
+ # matched to the `warp_factor` element and the value used for that
54
+ # element would be "1". The default operation for a text field is to
55
+ # enter the value in. It is also possible to use strings:
56
+ #
57
+ # page.using_data("warp factor": 1, velocity: 1, distance: 4.3)
58
+ #
59
+ # Here "warp factor" would be converted to "warp_factor".
60
+ def using(data)
61
+ data.each do |key, value|
62
+ use_data_with(key, value.to_s) if object_enabled_for(key)
63
+ end
64
+ end
65
+
66
+ alias using_data using
67
+ alias use_data using
68
+ alias using_values using
69
+ alias use_values using
70
+ alias use using
71
+
72
+ private
73
+
74
+ # This is the method that is delegated to in order to make sure that
75
+ # elements are interacted with appropriately. This will in turn delegate
76
+ # to `set_and_select` and `check_and_uncheck`, which determines what
77
+ # actions are viable based on the type of element that is being dealt
78
+ # with. These aspects are what tie this particular implementation to
79
+ # Watir.
80
+ def use_data_with(key, value)
81
+ value = preprocess_value(value, key)
82
+
83
+ element = send(key.to_s.tr(' ', '_'))
84
+ set_and_select(key, element, value)
85
+ check_and_uncheck(key, element, value)
86
+ click(key, element)
87
+ end
88
+
89
+ # rubocop:disable Metrics/AbcSize
90
+ # rubocop:disable Metrics/MethodLength
91
+ def preprocess_value(value, key)
92
+ return value unless value =~ /\(\(.*\)\)/
93
+
94
+ starter = value.index("((")
95
+ ender = value.index("))")
96
+ qualifier = value[starter + 2, ender - starter - 2]
97
+
98
+ if qualifier == "random_large"
99
+ value[starter..ender + 1] = rand(1_000_000_000_000).to_s
100
+ elsif qualifier == "random_ssn"
101
+ value = rand(9**9).to_s.rjust(9, '0')
102
+ value.insert 5, "-"
103
+ value.insert 3, "-"
104
+ elsif qualifier == "random_selection"
105
+ list = chain("#{key}.options.to_a")
106
+
107
+ selected = list.sample.text
108
+ selected = list.sample.text if selected.nil?
109
+ value = selected
110
+ end
111
+
112
+ value
113
+ end
114
+ # rubocop:enable Metrics/AbcSize
115
+ # rubocop:enable Metrics/MethodLength
116
+
117
+ def set_and_select(key, element, value)
118
+ key = key.to_s.tr(' ', '_')
119
+ chain("#{key}.set", value) if element.class == Watir::TextField
120
+ chain("#{key}.set") if element.class == Watir::Radio
121
+ chain("#{key}.select", value) if element.class == Watir::Select
122
+ end
123
+
124
+ def check_and_uncheck(key, element, value)
125
+ key = key.to_s.tr(' ', '_')
126
+ return chain("#{key}.check") if element.class == Watir::CheckBox && value
127
+
128
+ chain("#{key}.uncheck") if element.class == Watir::CheckBox
129
+ end
130
+
131
+ def click(key, element)
132
+ chain("#{key}.click") if element.class == Watir::Label
133
+ end
134
+
135
+ # This is a sanity check method to make sure that whatever element is
136
+ # being used as part of the data setting, it exists in the DOM, is
137
+ # visible (meaning, display is not 'none'), and is capable of accepting
138
+ # input, thus being enabled.
139
+ def object_enabled_for(key)
140
+ web_element = send(key.to_s.tr(' ', '_'))
141
+ web_element.enabled? && web_element.present?
142
+ end
143
+ end
144
+ end