tapestry 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,16 @@
1
+ module Tapestry
2
+ module Errors
3
+ NoUrlForDefinition = Class.new(StandardError)
4
+ NoUrlMatchForDefinition = Class.new(StandardError)
5
+ NoTitleForDefinition = Class.new(StandardError)
6
+ NoUrlMatchPossible = Class.new(StandardError)
7
+ PageNotValidatedError = Class.new(StandardError)
8
+ NoBlockForWhenReady = Class.new(StandardError)
9
+
10
+ class PageURLFromFactoryNotVerified < StandardError
11
+ def message
12
+ 'The page URL was not verified during a factory setup.'
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,106 @@
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
+ method_chain.split('.').inject(self) do |o, m|
21
+ if data.nil?
22
+ o.send(m.intern)
23
+ else
24
+ o.send(m.intern, data)
25
+ end
26
+ end
27
+ end
28
+ end
29
+
30
+ module Tapestry
31
+ module DataSetter
32
+ # The `using` method tells Tapestry to match up whatever data is passed
33
+ # in via the action with element definitions. If those elements are found,
34
+ # they will be populated with the specified data. Consider the following:
35
+ #
36
+ # class WarpTravel
37
+ # include Tapestry
38
+ #
39
+ # text_field :warp_factor, id: 'warpInput'
40
+ # text_field :velocity, id: 'velocityInput'
41
+ # text_field :distance, id: 'distInput'
42
+ # end
43
+ #
44
+ # Assuming an instance of this class called `page`, you could do the
45
+ # following:
46
+ #
47
+ # page.using_data(warp_factor: 1, velocity: 1, distance: 4.3)
48
+ #
49
+ # This is based on conventions. The idea is that element definitions are
50
+ # written in the form of "snake case" -- meaning, underscores between
51
+ # each separate word. In the above example, "warp_factor: 1" would be
52
+ # matched to the `warp_factor` element and the value used for that
53
+ # element would be "1". The default operation for a text field is to
54
+ # enter the value in. It is also possible to use strings:
55
+ #
56
+ # page.using_data("warp factor": 1, velocity: 1, distance: 4.3)
57
+ #
58
+ # Here "warp factor" would be converted to "warp_factor".
59
+ def using(data)
60
+ data.each do |key, value|
61
+ use_data_with(key, value) if object_enabled_for(key)
62
+ end
63
+ end
64
+
65
+ alias using_data using
66
+ alias use_data using
67
+ alias using_values using
68
+ alias use_values using
69
+
70
+ private
71
+
72
+ # This is the method that is delegated to in order to make sure that
73
+ # elements are interacted with appropriately. This will in turn delegate
74
+ # to `set_and_select` and `check_and_uncheck`, which determines what
75
+ # actions are viable based on the type of element that is being dealt
76
+ # with. These aspects are what tie this particular implementation to
77
+ # Watir.
78
+ def use_data_with(key, value)
79
+ element = send(key.to_s.tr(' ', '_'))
80
+ set_and_select(key, element, value)
81
+ check_and_uncheck(key, element, value)
82
+ end
83
+
84
+ def set_and_select(key, element, value)
85
+ key = key.to_s.tr(' ', '_')
86
+ chain("#{key}.set", value) if element.class == Watir::TextField
87
+ chain("#{key}.set") if element.class == Watir::Radio
88
+ chain("#{key}.select", value) if element.class == Watir::Select
89
+ end
90
+
91
+ def check_and_uncheck(key, element, value)
92
+ key = key.to_s.tr(' ', '_')
93
+ return chain("#{key}.check") if element.class == Watir::CheckBox && value
94
+ chain("#{key}.uncheck") if element.class == Watir::CheckBox
95
+ end
96
+
97
+ # This is a sanity check method to make sure that whatever element is
98
+ # being used as part of the data setting, it exists in the DOM, is
99
+ # visible (meaning, display is not 'none'), and is capable of accepting
100
+ # input, thus being enabled.
101
+ def object_enabled_for(key)
102
+ web_element = send(key.to_s.tr(' ', '_'))
103
+ web_element.enabled? && web_element.visible?
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,78 @@
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;
9
+ var callback = arguments[2];
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 Tapestry 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
+ */
33
+ var notStartedUpdating = function() {
34
+ return setTimeout(function() {
35
+ observer.disconnect();
36
+ callback(true);
37
+ }, 1000);
38
+ };
39
+
40
+ var startedUpdating = function() {
41
+ clearTimeout(notStartedUpdating);
42
+ observer.disconnect();
43
+ callback(false);
44
+ };
45
+
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 Tapestry 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
+ */
74
+ var observer = new MutationObserver(startedUpdating);
75
+ var config = { attributes: true, childList: true, characterData: true, subtree: true };
76
+ observer.observe(element, config);
77
+
78
+ var notStartedUpdating = notStartedUpdating();
@@ -0,0 +1,74 @@
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
+ driver.manage.timeouts.script_timeout = delay + 1
48
+ driver.execute_async_script(DOM_OBSERVER, wd, delay)
49
+ rescue Selenium::WebDriver::Error::StaleElementReferenceError
50
+ # This situation can occur when the DOM changes between two calls to
51
+ # some element or aspect of the page. In this case, we are expecting
52
+ # the DOM to be different so what's being handled here are those hard
53
+ # to anticipate race conditions when "weird things happen" and DOM
54
+ # updating plus script execution get interleaved.
55
+ retry
56
+ rescue Selenium::WebDriver::Error::JavascriptError => e
57
+ # This situation can occur if the script execution has started before
58
+ # a new page is fully loaded. The specific error being checked for here
59
+ # is one that occurs when a new page is loaded as that page is trying
60
+ # to execute a JavaScript function.
61
+ retry if e.message.include?('document unloaded while waiting for result')
62
+ raise
63
+ ensure
64
+ # Note that this setting here means any user-defined timeout would
65
+ # effectively be overwritten.
66
+ driver.manage.timeouts.script_timeout = 1
67
+ end
68
+
69
+ alias dom_has_updated? dom_updated?
70
+ alias dom_has_changed? dom_updated?
71
+ alias when_dom_updated dom_updated?
72
+ alias when_dom_changed dom_updated?
73
+ end
74
+ end
@@ -0,0 +1,16 @@
1
+ module Watir
2
+ class CheckBox
3
+ alias check set
4
+ alias uncheck clear
5
+ alias checked? set?
6
+ end
7
+
8
+ class Radio
9
+ alias choose set
10
+ alias chosen? set?
11
+ end
12
+
13
+ class TextField
14
+ alias enter set
15
+ end
16
+ end
@@ -0,0 +1,92 @@
1
+ module Tapestry
2
+ module Factory
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 Tapestry
8
+ #
9
+ # url_is "http://localhost:9292"
10
+ # end
11
+ #
12
+ # You can do the following:
13
+ #
14
+ # on_view(TestPage)
15
+ #
16
+ # Note that the actual factory creation is handled by `on`. This method
17
+ # exists as a way to differentiate when an interface needs to be
18
+ # visited.
19
+ def on_view(definition, &block)
20
+ on(definition, true, &block)
21
+ end
22
+
23
+ alias on_visit on_view
24
+
25
+ # Creates a definition context for actions. If an existing context
26
+ # exists, that context will be re-used. You can use this simply to keep
27
+ # the context for a script clear. For example, say you have the following
28
+ # interface definitions for pages:
29
+ #
30
+ # class Home
31
+ # include Tapestry
32
+ # url_is "http://localhost:9292"
33
+ # end
34
+ #
35
+ # class Navigation
36
+ # include Tapestry
37
+ # end
38
+ #
39
+ # You could then do this:
40
+ #
41
+ # on_view(Home)
42
+ # on(Navigation)
43
+ #
44
+ # The Home definition needs the url_is attribute in order for the on_view
45
+ # factory to work. But Navigation does not because the `on` method is not
46
+ # attempting to visit, simply to reference. Note that you can use `on`
47
+ # to visit, just by doing this:
48
+ #
49
+ # on(Home, true)
50
+ def on(definition, visit = false, &block)
51
+ unless @context.is_a?(definition)
52
+ @context = definition.new(@browser) if @browser
53
+ @context = definition.new unless @browser
54
+ @context.visit if visit
55
+ end
56
+
57
+ verify_page(@context)
58
+
59
+ yield @context if block
60
+ @context
61
+ end
62
+
63
+ # Creates a definition context for actions. Unlike the `on` factory, the
64
+ # `on_new` factory will always create a new context and will never re-use
65
+ # an existing one. The reason for using this factory might be that you
66
+ # are on the same page, but a given action has changed it so much that
67
+ # you want to reference it as a new version of that page, meaning a new
68
+ # context is established.
69
+ #
70
+ # It's doubtful that you will want to rely on this factory too much.
71
+ def on_new(definition, &block)
72
+ @context = nil
73
+ on(definition, &block)
74
+ end
75
+
76
+ alias on_page on
77
+ alias while_on on
78
+
79
+ private
80
+
81
+ # This method is used to provide a means for checking if a page has been
82
+ # navigated to correctly as part of a context. This is useful because
83
+ # the context signature should remain highly readable, and checks for
84
+ # whether a given page has been reached would make the context definition
85
+ # look sloppy.
86
+ def verify_page(context)
87
+ return if context.url_match_attribute.nil?
88
+ return if context.has_correct_url?
89
+ raise Tapestry::Errors::PageURLFromFactoryNotVerified
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,203 @@
1
+ require "tapestry/situation"
2
+
3
+ module Tapestry
4
+ module Interface
5
+ module Page
6
+ include Situation
7
+
8
+ # The `visit` method provides navigation to a specific page by passing
9
+ # in the URL. If no URL is passed in, this method will attempt to use
10
+ # the `url_is` attribute from the interface it is being called on.
11
+ def visit(url = nil)
12
+ no_url_provided if url.nil? && url_attribute.nil?
13
+ @browser.goto(url) unless url.nil?
14
+ @browser.goto(url_attribute) if url.nil?
15
+ self
16
+ end
17
+
18
+ alias view visit
19
+ alias navigate_to visit
20
+ alias goto visit
21
+ alias perform visit
22
+
23
+ # A call to `url_attribute` returns what the value of the `url_is`
24
+ # attribute is for the given interface. It's important to note that
25
+ # this is not grabbing the URL that is displayed in the browser;
26
+ # rather it's the one declared in the interface, if any.
27
+ def url_attribute
28
+ self.class.url_attribute
29
+ end
30
+
31
+ # A call to `url_match_attribute` returns what the value of the
32
+ # `url_matches` attribute is for the given interface. It's important
33
+ # to note that the URL matching mechanism is effectively a regular
34
+ # expression check.
35
+ def url_match_attribute
36
+ value = self.class.url_match_attribute
37
+ return if value.nil?
38
+ value = Regexp.new(value) unless value.is_a?(Regexp)
39
+ value
40
+ end
41
+
42
+ # A call to `title_attribute` returns what the value of the `title_is`
43
+ # attribute is for the given definition. It's important to note that
44
+ # this is not grabbing the title that is displayed in the browser;
45
+ # rather it's the one declared in the interface, if any.
46
+ def title_attribute
47
+ self.class.title_attribute
48
+ end
49
+
50
+ # A call to `has_correct_url?`returns true or false if the actual URL
51
+ # found in the browser matches the `url_matches` assertion. This is
52
+ # important to note. It's not using the `url_is` attribute nor the URL
53
+ # displayed in the browser. It's using the `url_matches` attribute.
54
+ def has_correct_url?
55
+ if url_attribute.nil? && url_match_attribute.nil?
56
+ no_url_match_is_possible
57
+ end
58
+ !(url =~ url_match_attribute).nil?
59
+ end
60
+
61
+ alias displayed? has_correct_url?
62
+
63
+ # A call to `has_correct_title?` returns true or false if the actual
64
+ # title of the current page in the browser matches the `title_is`
65
+ # attribute. Notice that this check is done as part of a match rather
66
+ # than a direct check. This allows for regular expressions to be used.
67
+ def has_correct_title?
68
+ no_title_is_provided if title_attribute.nil?
69
+ !title.match(title_attribute).nil?
70
+ end
71
+
72
+ # A call to `secure?` returns true if the page is secure and false
73
+ # otherwise. This is a simple check that looks for whether or not the
74
+ # current URL begins with 'https'.
75
+ def secure?
76
+ !url.match(/^https/).nil?
77
+ end
78
+
79
+ # A call to `url` returns the actual URL of the page that is displayed
80
+ # in the browser.
81
+ def url
82
+ @browser.url
83
+ end
84
+
85
+ alias page_url url
86
+ alias current_url url
87
+
88
+ # A call to `title` returns the actual title of the page that is
89
+ # displayed in the browser.
90
+ def title
91
+ @browser.title
92
+ end
93
+
94
+ alias page_title title
95
+
96
+ # A call to `markup` returns all markup on a page. Generally you don't
97
+ # just want the entire markup but rather want to parse the output of
98
+ # the `markup` call.
99
+ def markup
100
+ browser.html
101
+ end
102
+
103
+ alias html markup
104
+
105
+ # A call to `text` returns all text on a page. Note that this is text
106
+ # that is taken out of the markup context. It is unlikely you will just
107
+ # want the entire text but rather want to parse the output of the
108
+ # `text` call.
109
+ def text
110
+ browser.text
111
+ end
112
+
113
+ alias page_text text
114
+
115
+ # This method sends a standard "browser refresh" message to the browser.
116
+ def refresh
117
+ browser.refresh
118
+ end
119
+
120
+ alias refresh_page refresh
121
+
122
+ # This method provides a call to the browser window to resize that
123
+ # window to the specified width and height values.
124
+ def resize(width, height)
125
+ browser.window.resize_to(width, height)
126
+ end
127
+
128
+ alias resize_to resize
129
+
130
+ # This method provides a call to the browser window to move the
131
+ # window to the specified x and y screen coordinates.
132
+ def move_to(x, y)
133
+ browser.window.move_to(x, y)
134
+ end
135
+
136
+ # This method provides a call to the synchronous `execute_script`
137
+ # action on the browser, passing in JavaScript that you want to have
138
+ # executed against the current page. For example:
139
+ #
140
+ # result = page.run_script("alert('Tapestry ran a script.')")
141
+ #
142
+ # You can also run full JavaScript snippets.
143
+ #
144
+ # script = <<-JS
145
+ # return arguments[0].innerHTML
146
+ # JS
147
+ #
148
+ # page.run_script(script, page.account)
149
+ #
150
+ # Here you pass two arguments to `run_script`. One is the script itself
151
+ # and the other are some arguments that you want to pass as part of
152
+ # of the execution. In this case, an element definition (`account`) is
153
+ # being passed in.
154
+ def run_script(script, *args)
155
+ browser.execute_script(script, *args)
156
+ end
157
+
158
+ alias execute_script run_script
159
+
160
+ # A call to `screenshot` saves a screenshot of the current browser
161
+ # page. Note that this will grab the entire browser page, even portions
162
+ # of it that are off panel and need to be scrolled to. You can pass in
163
+ # the path and filename of the image that you want the screenshot
164
+ # saved to.
165
+ def screenshot(file)
166
+ browser.save.screenshot(file)
167
+ end
168
+
169
+ alias save_screenshot screenshot
170
+
171
+ # A call to `screen_width` returns the width of the browser screen as
172
+ # reported by the browser API, using a JavaScript call to the `screen`
173
+ # object.
174
+ def screen_width
175
+ run_script("return screen.width;")
176
+ end
177
+
178
+ # A call to `screen_height` returns the height of the browser screen as
179
+ # reported by the browser API, using a JavaScript call to the `screen`
180
+ # object.
181
+ def screen_height
182
+ run_script("return screen.height;")
183
+ end
184
+
185
+ # A call to `get_cookie` allows you to specify a particular cookie, by
186
+ # name, and return the information specified in the cookie.
187
+ def get_cookie(name)
188
+ browser.cookies.to_a.each do |cookie|
189
+ return cookie[:value] if cookie[:name] == name
190
+ end
191
+ nil
192
+ end
193
+
194
+ # A call to `clear_cookies` removes all the cookies from the current
195
+ # instance of the browser that is being controlled by WebDriver.
196
+ def clear_cookies
197
+ browser.cookies.clear
198
+ end
199
+
200
+ alias remove_cookies clear_cookies
201
+ end
202
+ end
203
+ end