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
@@ -1,8 +1,35 @@
1
- // WebDriver arguments
2
- var element = arguments[0];
3
- var delay = arguments[1] * 1000;
1
+ /*
2
+ This functionality will only work for browsers that support it.
3
+ See: http://caniuse.com/#feat=mutationobserver
4
+ */
5
+
6
+ // WebDriver arguments, which are passed to the MutationObserver.
7
+ var element = arguments[0];
8
+ var delay = arguments[1] * 1000;
4
9
  var callback = arguments[2];
5
10
 
11
+ /*
12
+ The two functions below are similar in what they are doing. Both are
13
+ disconneting the observer. Both are also invoking WebDriver's callback
14
+ function.
15
+
16
+ notStartedUpdating passes true to the callback, which indicates that the
17
+ DOM has not yet begun updating.
18
+
19
+ startedUpdating passes false to the callback, which indicates that the
20
+ DOM has begun updating.
21
+
22
+ The disconnect is important. You only want to be listening (observing) for the
23
+ period required, removing the listeners when done. Since there be many DOM
24
+ operations, you want to disconnet when there is interaction with the page by
25
+ the automated scripts.
26
+
27
+ When observing a node for changes, the callback will not be fired until the
28
+ DOM has finished changing. That is the only granularity that is required for
29
+ the Cogent implementation. What specific events occurred is not important
30
+ because the goal is not to conditionally respond to them; rather just to know
31
+ when the process has completed.
32
+ */
6
33
  var notStartedUpdating = function() {
7
34
  return setTimeout(function() {
8
35
  observer.disconnect();
@@ -16,7 +43,34 @@ var startedUpdating = function() {
16
43
  callback(false);
17
44
  };
18
45
 
19
- // Observer
46
+ /*
47
+ Mutation Observer
48
+ The W3C DOM4 specification initially introduced mutation observers as a
49
+ replacement for the deprecated mutation events.
50
+
51
+ The MutationObserver is a JavaScript native object that allows for observing
52
+ a change on any node-like DOM Element. "Mutation" means the addition or the
53
+ removal of a node as well as changes to the node's attribute and data.
54
+
55
+ The general approach is to create a MutationObserver object with a defined
56
+ callback function. The function will execute on every mutation observed by
57
+ the MutationObserver. The MutationObserver must be bound to a target, which
58
+ for Cogent would mean the element whose context it is being called upon.
59
+
60
+ A MutationObserver can be provided with a set of options, which indicate
61
+ what kind of events should be observed.
62
+
63
+ The childList option checks for additions and removals of the target node's
64
+ child elements, including text nodes. This is basically looking for any
65
+ nodes added or removed from documentElement.
66
+
67
+ The subtree option checks for mutations to the target as well the target's
68
+ descendants. So that means every child node of documentElement.
69
+
70
+ The attribute option checks for mutations to the target's attributes.
71
+
72
+ The characterData option checks for mutations to the target's data.
73
+ */
20
74
  var observer = new MutationObserver(startedUpdating);
21
75
  var config = { attributes: true, childList: true, characterData: true, subtree: true };
22
76
  observer.observe(element, config);
@@ -0,0 +1,73 @@
1
+ module Watir
2
+ class Element
3
+ OBSERVER_FILE = "/dom_observer.js".freeze
4
+ DOM_OBSERVER = File.read("#{File.dirname(__FILE__)}#{OBSERVER_FILE}").freeze
5
+
6
+ # This method makes a call to `execute_async_script` which means that the
7
+ # DOM observer script must explicitly signal that it is finished by
8
+ # invoking a callback. In this case, the callback is nothing more than
9
+ # a delay. The delay is being used to allow the DOM to be updated before
10
+ # script actions continue.
11
+ #
12
+ # The method returns true if the DOM has been changed within the element
13
+ # context, while false means that the DOM has not yet finished changing.
14
+ # Note the wording: "has not finished changing." It's known that the DOM
15
+ # is changing because the observer has recognized that. So the question
16
+ # this method is helping to answer is "has it finished?"
17
+ #
18
+ # Consider the following element definition:
19
+ #
20
+ # p :page_list, id: 'navlist'
21
+ #
22
+ # You could then do this:
23
+ #
24
+ # page_list.dom_updated?
25
+ #
26
+ # That would return true if the DOM content for page_list has finished
27
+ # updating. If the DOM was in the process of being updated, this would
28
+ # return false. You could also do this:
29
+ #
30
+ # page_list.wait_until(&:dom_updated?).click
31
+ #
32
+ # This will use Watir's wait until functionality to wait for the DOM to
33
+ # be updated within the context of the element. Note that the "&:" is
34
+ # that the object that `dom_updated?` is being called on (in this case
35
+ # `page_list`) substitutes the ampersand. You can also structure it like
36
+ # this:
37
+ #
38
+ # page_list.wait_until do |element|
39
+ # element.dom_updated?
40
+ # end
41
+ #
42
+ # The default delay of waiting for the DOM to start updating is 1.1
43
+ # second. However, you can pass a delay value when you call the method
44
+ # to set your own value, which can be useful for particular sensitivities
45
+ # in the application you are testing.
46
+ def dom_updated?(delay: 1.1)
47
+ element_call do
48
+ begin
49
+ driver.manage.timeouts.script_timeout = delay + 1
50
+ driver.execute_async_script(DOM_OBSERVER, wd, delay)
51
+ rescue Selenium::WebDriver::Error::JavascriptError => e
52
+ # This situation can occur if the script execution has started before
53
+ # a new page is fully loaded. The specific error being checked for
54
+ # here is one that occurs when a new page is loaded as that page is
55
+ # trying to execute a JavaScript function.
56
+ retry if e.message.include?(
57
+ 'document unloaded while waiting for result'
58
+ )
59
+ raise
60
+ ensure
61
+ # Note that this setting here means any user-defined timeout would
62
+ # effectively be overwritten.
63
+ driver.manage.timeouts.script_timeout = 1
64
+ end
65
+ end
66
+ end
67
+
68
+ alias dom_has_updated? dom_updated?
69
+ alias dom_has_changed? dom_updated?
70
+ alias when_dom_updated dom_updated?
71
+ alias when_dom_changed dom_updated?
72
+ end
73
+ end
@@ -0,0 +1,63 @@
1
+ module Testable
2
+ module Element
3
+ module Locator
4
+ private
5
+
6
+ # This method is what actually calls the browser instance to find
7
+ # an element. If there is an element definition like this:
8
+ #
9
+ # text_field :username, id: 'username'
10
+ #
11
+ # This will become the following:
12
+ #
13
+ # browser.text_field(id: 'username')
14
+ #
15
+ # Note that the `to_subtype` method is called, which allows for the
16
+ # generic `element` to be expressed as the type of element, as opposed
17
+ # to `text_field` or `select_list`. For example, an `element` may be
18
+ # defined like this:
19
+ #
20
+ # element :enable, id: 'enableForm'
21
+ #
22
+ # Which means it would look like this:
23
+ #
24
+ # Watir::HTMLElement:0x1c8c9 selector={:id=>"enableForm"}
25
+ #
26
+ # Whereas getting the subtype would give you:
27
+ #
28
+ # Watir::CheckBox:0x12f8b elector={element: (webdriver element)}
29
+ #
30
+ # Which is what you would get if the element definition were this:
31
+ #
32
+ # checkbox :enable, id: 'enableForm'
33
+ #
34
+ # Using the subtype does get tricky for scripts that require the
35
+ # built-in sychronization aspects and wait states of Watir.
36
+ #
37
+ # The approach being used in this method is necessary to allow actions
38
+ # like `set`, which are not available on `element`, even though other
39
+ # actions, like `click`, are. But if you use `element` for your element
40
+ # definitions, and your script requires a series of actions where elements
41
+ # may be delayed in appearing, you'll get an "unable to locate element"
42
+ # message, along with a Watir::Exception::UnknownObjectException.
43
+ #
44
+ # A check is made if an UnknownObjectException occurs due to a ready
45
+ # validation. That's necessary because a ready validation has to find
46
+ # an element in order to determine the ready state, but that element
47
+ # might not be present.
48
+ def access_element(element, locators, _qualifiers)
49
+ if element == "element".to_sym
50
+ @browser.element(locators).to_subtype
51
+ else
52
+ @browser.__send__(element, locators)
53
+ end
54
+ rescue Watir::Exception::UnknownObjectException
55
+ return false if caller_locations.any? do |str|
56
+ str.to_s.match?("ready_validations_pass?")
57
+ end
58
+
59
+ raise
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,16 @@
1
+ require "logger"
2
+
3
+ module Testable
4
+ class Logger
5
+ def create(output = $stdout)
6
+ logger = ::Logger.new(output)
7
+ logger.progname = 'Testable'
8
+ logger.level = :UNKNOWN
9
+ logger.formatter = proc do |severity, time, progname, msg|
10
+ "#{time.strftime('%F %T')} - #{severity} - #{progname} - #{msg}\n"
11
+ end
12
+
13
+ logger
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,216 @@
1
+ require "testable/situation"
2
+
3
+ module Testable
4
+ module Pages
5
+ include Situation
6
+
7
+ # This provides a list of the methods that are defined on the page
8
+ # definition. This is helpful is you need to query the page to see
9
+ # if an element has been provided since all elements automatically
10
+ # become methods on a definition instance.
11
+ def definition_api
12
+ public_methods(false) - Object.public_methods
13
+ end
14
+
15
+ # The `visit` method provides navigation to a specific page by passing
16
+ # in the URL. If no URL is passed in, this method will attempt to use
17
+ # the `url_is` attribute from the interface it is being called on.
18
+ def visit(url = nil, &block)
19
+ no_url_provided if url.nil? && url_attribute.nil?
20
+ @browser.goto(url) unless url.nil?
21
+ @browser.goto(url_attribute) if url.nil?
22
+ when_ready(&block) if block_given?
23
+ self
24
+ end
25
+
26
+ alias view visit
27
+ alias navigate_to visit
28
+ alias goto visit
29
+ alias perform visit
30
+
31
+ # A call to `url_attribute` returns what the value of the `url_is`
32
+ # attribute is for the given interface. It's important to note that
33
+ # this is not grabbing the URL that is displayed in the browser;
34
+ # rather it's the one declared in the interface definition, if any.
35
+ def url_attribute
36
+ self.class.url_attribute
37
+ end
38
+
39
+ # A call to `url` returns the actual URL of the page that is displayed
40
+ # in the browser.
41
+ def url
42
+ @browser.url
43
+ end
44
+
45
+ alias page_url url
46
+ alias current_url url
47
+
48
+ # A call to `url_match_attribute` returns what the value of the
49
+ # `url_matches` attribute is for the given interface. It's important
50
+ # to note that the URL matching mechanism is effectively a regular
51
+ # expression check.
52
+ def url_match_attribute
53
+ value = self.class.url_match_attribute
54
+ return if value.nil?
55
+
56
+ value = Regexp.new(value) unless value.is_a?(Regexp)
57
+ value
58
+ end
59
+
60
+ # A call to `has_correct_url?`returns true or false if the actual URL
61
+ # found in the browser matches the `url_matches` assertion. This is
62
+ # important to note. It's not using the `url_is` attribute nor the URL
63
+ # displayed in the browser. It's using the `url_matches` attribute.
64
+ def has_correct_url?
65
+ no_url_match_is_possible if url_attribute.nil? && url_match_attribute.nil?
66
+ !(url =~ url_match_attribute).nil?
67
+ end
68
+
69
+ alias displayed? has_correct_url?
70
+ alias loaded? has_correct_url?
71
+
72
+ # A call to `title_attribute` returns what the value of the `title_is`
73
+ # attribute is for the given definition. It's important to note that
74
+ # this is not grabbing the title that is displayed in the browser;
75
+ # rather it's the one declared in the interface definition, if any.
76
+ def title_attribute
77
+ self.class.title_attribute
78
+ end
79
+
80
+ # A call to `title` returns the actual title of the page that is
81
+ # displayed in the browser.
82
+ def title
83
+ @browser.title
84
+ end
85
+
86
+ alias page_title title
87
+
88
+ # A call to `has_correct_title?` returns true or false if the actual
89
+ # title of the current page in the browser matches the `title_is`
90
+ # attribute. Notice that this check is done as part of a match rather
91
+ # than a direct check. This allows for regular expressions to be used.
92
+ def has_correct_title?
93
+ no_title_is_provided if title_attribute.nil?
94
+ !title.match(title_attribute).nil?
95
+ end
96
+
97
+ # A call to `secure?` returns true if the page is secure and false
98
+ # otherwise. This is a simple check that looks for whether or not the
99
+ # current URL begins with 'https'.
100
+ def secure?
101
+ !url.match(/^https/).nil?
102
+ end
103
+
104
+ # A call to `markup` returns all markup on a page. Generally you don't
105
+ # just want the entire markup but rather want to parse the output of
106
+ # the `markup` call.
107
+ def markup
108
+ browser.html
109
+ end
110
+
111
+ alias html markup
112
+
113
+ # A call to `text` returns all text on a page. Note that this is text
114
+ # that is taken out of the markup context. It is unlikely you will just
115
+ # want the entire text but rather want to parse the output of the
116
+ # `text` call.
117
+ def text
118
+ browser.text
119
+ end
120
+
121
+ alias page_text text
122
+
123
+ # This method provides a call to the synchronous `execute_script`
124
+ # action on the browser, passing in JavaScript that you want to have
125
+ # executed against the current page. For example:
126
+ #
127
+ # result = page.run_script("alert('Cogent ran a script.')")
128
+ #
129
+ # You can also run full JavaScript snippets.
130
+ #
131
+ # script = <<-JS
132
+ # return arguments[0].innerHTML
133
+ # JS
134
+ #
135
+ # page.run_script(script, page.account)
136
+ #
137
+ # Here you pass two arguments to `run_script`. One is the script itself
138
+ # and the other are some arguments that you want to pass as part of
139
+ # of the execution. In this case, an element definition (`account`) is
140
+ # being passed in.
141
+ def run_script(script, *args)
142
+ browser.execute_script(script, *args)
143
+ end
144
+
145
+ alias execute_script run_script
146
+
147
+ # A call to `screen_width` returns the width of the browser screen as
148
+ # reported by the browser API, using a JavaScript call to the `screen`
149
+ # object.
150
+ def screen_width
151
+ run_script("return screen.width;")
152
+ end
153
+
154
+ # A call to `screen_height` returns the height of the browser screen as
155
+ # reported by the browser API, using a JavaScript call to the `screen`
156
+ # object.
157
+ def screen_height
158
+ run_script("return screen.height;")
159
+ end
160
+
161
+ # This method provides a means to maximize the browser window. This
162
+ # is done by getting the screen width and height via JavaScript calls.
163
+ def maximize
164
+ browser.window.resize_to(screen_width, screen_height)
165
+ end
166
+
167
+ # This method provides a call to the browser window to resize that
168
+ # window to the specified width and height values.
169
+ def resize(width, height)
170
+ browser.window.resize_to(width, height)
171
+ end
172
+
173
+ # This method provides a call to the browser window to move the
174
+ # window to the specified x and y screen coordinates.
175
+ def move_to(x_coord, y_coord)
176
+ browser.window.move_to(x_coord, y_coord)
177
+ end
178
+
179
+ alias resize_to resize
180
+
181
+ # This method sends a standard "browser refresh" message to the browser.
182
+ def refresh
183
+ browser.refresh
184
+ end
185
+
186
+ alias refresh_page refresh
187
+
188
+ # A call to `get_cookie` allows you to specify a particular cookie, by
189
+ # name, and return the information specified in the cookie.
190
+ def get_cookie(name)
191
+ browser.cookies.to_a.each do |cookie|
192
+ return cookie[:value] if cookie[:name] == name
193
+ end
194
+ nil
195
+ end
196
+
197
+ # A call to `clear_cookies` removes all the cookies from the current
198
+ # instance of the browser that is being controlled by WebDriver.
199
+ def clear_cookies
200
+ browser.cookies.clear
201
+ end
202
+
203
+ alias remove_cookies clear_cookies
204
+
205
+ # A call to `screenshot` saves a screenshot of the current browser
206
+ # page. Note that this will grab the entire browser page, even portions
207
+ # of it that are off panel and need to be scrolled to. You can pass in
208
+ # the path and filename of the image that you want the screenshot
209
+ # saved to.
210
+ def screenshot(file)
211
+ browser.screenshot.save(file)
212
+ end
213
+
214
+ alias save_screenshot screenshot
215
+ end
216
+ end
@@ -4,7 +4,15 @@ module Testable
4
4
  module Ready
5
5
  include Situation
6
6
 
7
- module ClassMethods
7
+ # The ReadyAttributes contains methods that can be called directly on the
8
+ # interface class definition. These are very much like the attributes
9
+ # that are used for defining aspects of the pages, such as `url_is` or
10
+ # `title_is`. These attributes are included separately so as to maintain
11
+ # more modularity.
12
+ module ReadyAttributes
13
+ # This method will provide a list of the ready_validations that have
14
+ # been defined. This list will contain the list in the order that the
15
+ # validations were defined in.
8
16
  def ready_validations
9
17
  if superclass.respond_to?(:ready_validations)
10
18
  superclass.ready_validations + _ready_validations
@@ -13,41 +21,75 @@ module Testable
13
21
  end
14
22
  end
15
23
 
24
+ # When this attribute method is specified on an interface, it will
25
+ # append the validation provided by the block.
16
26
  def page_ready(&block)
17
27
  _ready_validations << block
18
28
  end
19
29
 
20
30
  alias page_ready_when page_ready
21
31
 
32
+ private
33
+
22
34
  def _ready_validations
23
35
  @_ready_validations ||= []
24
36
  end
25
37
  end
26
38
 
27
- attr_accessor :ready, :ready_error
28
-
29
39
  def self.included(caller)
30
- caller.extend(ClassMethods)
40
+ caller.extend(ReadyAttributes)
31
41
  end
32
42
 
33
- def when_ready(&_block)
43
+ # If a ready validation fails, the message reported by that failure will
44
+ # be captured in the `ready_error` accessor.
45
+ attr_accessor :ready, :ready_error
46
+
47
+ # The `when_ready` method is called on an instance of an interface. This
48
+ # executes the provided validation block after the page has been loaded.
49
+ # The Ready object instance is yielded into the block.
50
+ #
51
+ # Calls to the `ready?` method use a poor-man's cache approach. The idea
52
+ # here being that when a page has confirmed that it is ready, meaning that
53
+ # no ready validations have failed, that information is stored so that any
54
+ # subsequent calls to `ready?` do not query the ready validations again.
55
+ def when_ready(simple_check = false, &_block)
34
56
  already_marked_ready = ready
35
57
 
36
- no_ready_check_possible unless block_given?
58
+ unless simple_check
59
+ no_ready_check_possible unless block_given?
60
+ end
37
61
 
38
62
  self.ready = ready?
63
+
39
64
  not_ready_validation(ready_error || 'NO REASON PROVIDED') unless ready
40
- yield self
65
+ yield self if block_given?
41
66
  ensure
42
67
  self.ready = already_marked_ready
43
68
  end
44
69
 
70
+ def check_if_ready
71
+ when_ready(true)
72
+ end
73
+
74
+ # The `ready?` method is used to check if the page has been loaded
75
+ # successfully, which means that none of the ready validations have
76
+ # indicated failure.
77
+ #
78
+ # When `ready?` is called, the blocks that were stored in the above
79
+ # `ready_validations` array are instance evaluated against the current
80
+ # page instance.
45
81
  def ready?
46
82
  self.ready_error = nil
47
83
  return true if ready
84
+
48
85
  ready_validations_pass?
49
86
  end
50
87
 
88
+ private
89
+
90
+ # This method checks if the ready validations that have been specified
91
+ # have passed. If any ready validation fails, no matter if others have
92
+ # succeeded, this method immediately returns false.
51
93
  def ready_validations_pass?
52
94
  self.class.ready_validations.all? do |validation|
53
95
  passed, message = instance_eval(&validation)