rsel 0.1.1 → 0.1.2

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.
Files changed (48) hide show
  1. data/.gitignore +1 -0
  2. data/Rakefile +22 -8
  3. data/docs/development.md +1 -0
  4. data/docs/history.md +11 -1
  5. data/docs/index.md +1 -0
  6. data/docs/scoping.md +1 -1
  7. data/docs/studying.md +76 -0
  8. data/lib/rsel/selenium_test.rb +505 -90
  9. data/lib/rsel/study_html.rb +198 -0
  10. data/lib/rsel/support.rb +105 -4
  11. data/rsel.gemspec +1 -1
  12. data/spec/spec_helper.rb +4 -22
  13. data/spec/st_alerts.rb +48 -0
  14. data/spec/st_browser_spec.rb +58 -0
  15. data/spec/st_buttons_spec.rb +95 -0
  16. data/spec/st_checkboxes_spec.rb +235 -0
  17. data/spec/st_conditionals_spec.rb +180 -0
  18. data/spec/st_dropdowns_spec.rb +140 -0
  19. data/spec/st_field_equals_among_spec.rb +48 -0
  20. data/spec/st_fields_equal_among_spec.rb +74 -0
  21. data/spec/st_fields_equal_spec.rb +90 -0
  22. data/spec/st_fields_spec.rb +167 -0
  23. data/spec/st_initialization_spec.rb +33 -0
  24. data/spec/st_links_spec.rb +84 -0
  25. data/spec/st_method_missing_spec.rb +59 -0
  26. data/spec/st_navigation_spec.rb +56 -0
  27. data/spec/st_radiobuttons_spec.rb +123 -0
  28. data/spec/st_respond_to_spec.rb +16 -0
  29. data/spec/st_scenario_spec.rb +26 -0
  30. data/spec/st_set_field_among_spec.rb +45 -0
  31. data/spec/st_set_field_spec.rb +842 -0
  32. data/spec/st_set_fields_among_spec.rb +74 -0
  33. data/spec/st_set_fields_spec.rb +97 -0
  34. data/spec/st_spec_helper.rb +43 -0
  35. data/spec/st_stop_on_failure_spec.rb +199 -0
  36. data/spec/st_tables_spec.rb +42 -0
  37. data/spec/st_temporal_visibility_spec.rb +122 -0
  38. data/spec/st_visibility_spec.rb +125 -0
  39. data/spec/st_waiting_spec.rb +37 -0
  40. data/spec/study_html_spec.rb +310 -0
  41. data/spec/support_spec.rb +163 -13
  42. data/test/server/README.txt +3 -0
  43. data/test/views/alert.erb +15 -0
  44. data/test/views/form.erb +6 -1
  45. data/test/views/index.erb +2 -0
  46. data/test/views/slowtext.erb +1 -1
  47. metadata +38 -9
  48. data/spec/selenium_test_spec.rb +0 -2656
@@ -0,0 +1,198 @@
1
+ require 'rubygems'
2
+ require 'nokogiri'
3
+ require 'rsel/support'
4
+
5
+ module Rsel
6
+ # Class to study a web page: Parses it with Nokogiri, and allows searching and simplifying Selenium-like expressions.
7
+ class StudyHtml
8
+
9
+ include Support
10
+
11
+ # A large sentinel for @dirties.
12
+ NO_PAGE_LOADED = 1000000
13
+
14
+ def initialize(first_page=nil)
15
+ @sections_kept_clean = []
16
+ if first_page
17
+ study(first_page)
18
+ else
19
+ @studied_page = nil
20
+ # Invariant: @dirties == 0 while @keep_clean is true.
21
+ @keep_clean = false
22
+ # No page is loaded. Set a large sentinel value so it's never tested.
23
+ @dirties = NO_PAGE_LOADED
24
+ end
25
+ end
26
+
27
+ # Load a page to study.
28
+ #
29
+ # @param [String] page
30
+ # Any argument that works for Nokogiri::HTML. Often HTML in a string, or a path to a file.
31
+ # @param [Boolean] keep
32
+ # Sets {#keep_clean} with this argument. Default is false, so study(), by default, turns off keep_clean.
33
+ def study(page, keep=false)
34
+ @sections_kept_clean = []
35
+ begin
36
+ @studied_page = Nokogiri::HTML(page)
37
+ @dirties = 0
38
+ rescue => e
39
+ @keep_clean = false
40
+ @dirties = NO_PAGE_LOADED
41
+ @studied_page = nil
42
+ raise e
43
+ end
44
+ @keep_clean = keep
45
+ end
46
+
47
+ # "Dirty" the studied page, marking one (potential) change since the page was studied.
48
+ # This can be undone: see {#undo_last_dirty}
49
+ # Does nothing if {#keep_clean} has been called with true, which may occur from {#study}
50
+ def dirty
51
+ @dirties += 1 unless @keep_clean
52
+ end
53
+
54
+ # Try to un-{#dirty} the studied page by marking one (potential) change since the page was studied not actually a change.
55
+ # This may or may not be enough to resume using the studied page. Cannot be used preemptively.
56
+ def undo_last_dirty
57
+ @dirties -= 1 unless @dirties <= 0
58
+ end
59
+
60
+ # Try to un-{#dirty} the studied page. Returns true on success or false if there was no page to clean.
61
+ def undo_all_dirties
62
+ if @studied_page != nil
63
+ @dirties = 0
64
+ else
65
+ @keep_clean = false
66
+ @dirties = NO_PAGE_LOADED
67
+ end
68
+ return @dirties == 0
69
+ end
70
+
71
+ # Return whether the studied page is clean and ready for analysis. Not a verb - does not {#undo_all_dirties}.
72
+ def clean?
73
+ return @dirties == 0
74
+ end
75
+ #alias_method :clean, :clean?
76
+
77
+ # Turn on or off maintenance of clean status. True prevents {#dirty} from having any effect.
78
+ # Also cleans all dirties (with {#undo_all_dirties}) if true, or dirties the page (with {#dirty}) if false.
79
+ def keep_clean(switch)
80
+ if switch
81
+ if undo_all_dirties
82
+ @keep_clean = true
83
+ return true
84
+ else
85
+ return false
86
+ end
87
+ else
88
+ @keep_clean = false
89
+ dirty
90
+ return true
91
+ end
92
+ end
93
+
94
+ # Return whether keep_clean is on or not. Useful if you want to start keeping clean and then return to your previous state.
95
+ # Invariant: clean? == true if keeping_clean? == true.
96
+ def keeping_clean?
97
+ return @keep_clean
98
+ end
99
+
100
+ # Store the current keep_clean status, and begin forcing study use until the
101
+ # next {#end_section}.
102
+ # A semi-optional block argument returns the first argument to give to {#study}.
103
+ # It's not required if {#clean?}, but otherwise if it's not present an exception
104
+ # will be thrown.
105
+ def begin_section
106
+ last_keep_clean = @keep_clean
107
+ if clean?
108
+ @keep_clean = true
109
+ else
110
+ # This will erase all prior sections.
111
+ study(yield, true)
112
+ end
113
+ @sections_kept_clean.push(last_keep_clean)
114
+ end
115
+
116
+ # Restore the keep_clean status from before the last {#begin_section}.
117
+ # Also marks the page dirty unless the last keep_clean was true.
118
+ # It's fine to call this more than you call begin_section. It will act just
119
+ # like keep_clean(false) if it runs out of stack parameters.
120
+ def end_section
121
+ # Can't just assign - what if nil is popped?
122
+ if @sections_kept_clean.pop
123
+ @keep_clean = true
124
+ else
125
+ @keep_clean = false
126
+ dirty
127
+ end
128
+ return true
129
+ end
130
+
131
+ # Simplify a Selenium-like locator (xpath or a css path), based on the studied page
132
+ #
133
+ # @param [Boolean] x
134
+ # @param [Boolean] tocss
135
+ # Return a css= path as a last resort? Defaults to true.
136
+ def simplify_locator(locator, tocss=true)
137
+ return locator if @dirties > 0
138
+
139
+ # We need a locator using either a css= or locator= expression.
140
+ if locator[0,4] == 'css='
141
+ studied_node = @studied_page.at_css(locator[4,locator.length])
142
+ # If we're already using a css path, don't bother simplifying it to another css path.
143
+ tocss = false
144
+ elsif locator[0,6] == 'xpath=' || locator[0,2] == '//'
145
+ locator = 'xpath='+locator if locator[0,2] == '//'
146
+ studied_node = @studied_page.at_xpath(locator[6,locator.length])
147
+ else
148
+ # Some other kind of locator. Just return it.
149
+ return locator
150
+ end
151
+ # If the path wasn't found, just return the locator; maybe the browser will
152
+ # have better luck. (Or return a better error message!)
153
+ return locator if studied_node == nil
154
+
155
+ # Now let's try simplified locators. First, id.
156
+ return "id=#{studied_node['id']}" if(studied_node['id'] &&
157
+ @studied_page.at_xpath("//*[@id='#{studied_node['id']}']") == studied_node)
158
+ # Next, name. Same pattern.
159
+ return "name=#{studied_node['name']}" if(studied_node['name'] &&
160
+ @studied_page.at_xpath("//*[@name='#{studied_node['name']}']") == studied_node)
161
+
162
+ # Link, perhaps?
163
+ return "link=#{studied_node.inner_text}" if(studied_node.node_name.downcase == 'a' &&
164
+ @studied_page.at_xpath("//a[text()='#{studied_node.inner_text}']") == studied_node)
165
+
166
+ # Finally, try a CSS path. Make that a simple xpath, since nth-of-type doesn't work. But give up if we were told not to convert to CSS.
167
+ return locator unless tocss
168
+ return "xpath=#{studied_node.path}"
169
+ end
170
+
171
+ # Find a studied node by almost any type of Selenium locator. Returns a Nokogiri::Node, or nil if not found.
172
+ def get_node(locator)
173
+ return nil if @dirties > 0
174
+ case locator
175
+ when /^id=/, /^name=/
176
+ locator = locator.gsub("'","\\\\'").gsub(/([a-z]+)=([^ ]*) */, "[@\\1='\\2']")
177
+ locator = locator.sub(/\]([^ ]+) */, "][@value='\\1']")
178
+ return @studied_page.at_xpath("//*#{locator}")
179
+ when /^link=/
180
+ # Parse the link through loc (which may simplify it to an id or something).
181
+ # Then try get_studied_node again. It should not return to this spot.
182
+ return get_node(loc(locator[5,locator.length], 'link'))
183
+ when /^css=/
184
+ return @studied_page.at_css(locator[4,locator.length])
185
+ when /^xpath=/, /^\/\//
186
+ return @studied_page.at_xpath(locator.sub(/^xpath=/,''))
187
+ when /^dom=/, /^document\./
188
+ # Can't parse dom=
189
+ return nil
190
+ else
191
+ locator = locator.sub(/^id(entifier)?=/,'')
192
+ retval = @studied_page.at_xpath("//*[@id='#{locator}']")
193
+ retval = @studied_page.at_xpath("//*[@name='#{locator}']") unless retval
194
+ return retval
195
+ end
196
+ end
197
+ end
198
+ end
@@ -36,7 +36,11 @@ module Rsel
36
36
  elsif locator =~ /^(id=|name=|dom=|xpath=|link=|css=)/
37
37
  return locator
38
38
  else
39
- return xpath(kind, locator, scope)
39
+ if kind.empty?
40
+ raise ArgumentError, "kind is required for Rsel-style locators"
41
+ else
42
+ return xpath(kind, locator, scope)
43
+ end
40
44
  end
41
45
  end
42
46
 
@@ -171,6 +175,8 @@ module Rsel
171
175
  # Normalize the given hash of name => locator mappings.
172
176
  # Converts all keys to lowercase and calls {#escape_for_hash} on them.
173
177
  #
178
+ # @since 0.1.1
179
+ #
174
180
  def normalize_ids(ids)
175
181
  ids = {} unless ids.is_a? Hash
176
182
  ids.keys.each do |key|
@@ -198,10 +204,11 @@ module Rsel
198
204
  return text.gsub(/<\/?[^>]*>/, '')
199
205
  end
200
206
 
201
- # This module defines helper methods for building XPath expressions
202
- # copied from Kelp::XPaths
203
207
  # Return an XPath for any table row containing all strings in `texts`,
204
208
  # within the current context.
209
+ #
210
+ # @since 0.1.1
211
+ #
205
212
  def xpath_row_containing(texts)
206
213
  texts = [texts] if texts.class == String
207
214
  conditions = texts.collect do |text|
@@ -218,6 +225,8 @@ module Rsel
218
225
  # xpath_sanitize("Bob's")
219
226
  # # => concat('Bob', "'", 's')
220
227
  #
228
+ # @since 0.1.1
229
+ #
221
230
  def xpath_sanitize(text)
222
231
  # If there's nothing to escape, just wrap text in single-quotes
223
232
  if !text.include?("'")
@@ -230,6 +239,7 @@ module Rsel
230
239
 
231
240
  # Convert a string like "yes", "true", "1", etc.
232
241
  # Values currently recognized as true, case-insensitive:
242
+ #
233
243
  # * [empty string]
234
244
  # * 1
235
245
  # * Check
@@ -239,17 +249,22 @@ module Rsel
239
249
  # * Selected
240
250
  # * True
241
251
  # * Yes
252
+ #
253
+ # @since 0.1.1
254
+ #
242
255
  def string_is_true?(s)
243
256
  return /^(?:yes|true|on|(?:check|select)(?:ed)?|1|)$/i === s
244
257
  end
245
258
 
246
259
  # Compare values like Selenium does, with regexpi? and globs.
260
+ #
247
261
  # @param [String] text
248
262
  # A string.
249
- #
250
263
  # @param [String] expected
251
264
  # Another string. This one may have glob:, regexp:, etc.
252
265
  #
266
+ # @since 0.1.1
267
+ #
253
268
  def selenium_compare(text, expected)
254
269
  if expected.sub!(/^regexp:/, '')
255
270
  return /#{expected}/ === text
@@ -263,6 +278,92 @@ module Rsel
263
278
  return File.fnmatch(expected, text)
264
279
  end
265
280
  end
281
+
282
+ # Return `text` with glob markers `*` on each end, unless the text
283
+ # begins with `exact:`, `regexp:`, or `regexpi:`. This effectively
284
+ # allows normal text to match as a "contains" search instead of
285
+ # matching the entire string.
286
+ #
287
+ # @param [String] text
288
+ # Text to globify
289
+ #
290
+ # @since 0.1.2
291
+ #
292
+ def globify(text)
293
+ if /^(exact|regexpi?):/ === text
294
+ return text
295
+ else
296
+ return text.sub(/^(glob:)?\*?/, '*').sub(/\*?$/, '*')
297
+ end
298
+ end
299
+
300
+
301
+ # Ensure that a given block gets a result within a timeout.
302
+ #
303
+ # This executes the given block statement once per second, until it returns
304
+ # a value that evaluates as true (meaning anything other than `false` or
305
+ # `nil`), or until the `seconds` timeout is reached. If the block evaluates
306
+ # as true within the timeout, return the block result. Otherwise, return
307
+ # `nil`.
308
+ #
309
+ # If the block never returns a value other than `false` or `nil`, then
310
+ # return `nil`. If the block raises an exception (*any* exception), that's
311
+ # considered a false result, and the block will be retried until a true
312
+ # result is returned, or the `seconds` timeout is reached.
313
+ #
314
+ # @param [Integer, String] seconds
315
+ # Integer number of seconds to keep retrying the block
316
+ # @param [Block] block
317
+ # Any block of code that might evaluate to a non-false value
318
+ #
319
+ # @return
320
+ # Result of the block if it evaluated true-ish within the timeout, nil
321
+ # if the block always evaluated as false or raised an exception.
322
+ #
323
+ # @since 0.1.2
324
+ #
325
+ # TODO: Return false if the block takes too long to execute (and exceeds
326
+ # the timeout)
327
+ #
328
+ def result_within(seconds, &block)
329
+ (seconds.to_i + 1).times do
330
+ result = yield rescue nil
331
+ return result if result
332
+ sleep 1
333
+ end
334
+ return nil
335
+ end
336
+
337
+
338
+ # Ensure that a given block fails within a timeout.
339
+ #
340
+ # This is a kind of counterpart to {#result_within}
341
+ #
342
+ # @param [Integer, String] seconds
343
+ # Integer number of seconds to keep retrying the block
344
+ # @param [Block] block
345
+ # Any block of code that might evaluate to a false value,
346
+ # or raise an exception, within the timeout.
347
+ #
348
+ # @return [Boolean]
349
+ # true if the block failed (returned false/nil or raised an exception)
350
+ # within the timeout, false if the block never failed within the timeout.
351
+ #
352
+ # @since 0.1.2
353
+ #
354
+ def failed_within(seconds, &block)
355
+ (seconds.to_i + 1).times do
356
+ begin
357
+ result = yield
358
+ rescue
359
+ return true
360
+ else
361
+ return true if !result
362
+ end
363
+ sleep 1
364
+ end
365
+ return false
366
+ end
266
367
  end
267
368
  end
268
369
 
@@ -1,6 +1,6 @@
1
1
  Gem::Specification.new do |s|
2
2
  s.name = "rsel"
3
- s.version = "0.1.1"
3
+ s.version = "0.1.2"
4
4
  s.summary = "Runs Selenium tests from FitNesse"
5
5
  s.description = <<-EOS
6
6
  Rsel provides a Slim fixture for running Selenium tests, with
@@ -1,32 +1,14 @@
1
+ # This file includes RSpec configuration that is needed for all spec testing.
2
+
1
3
  require 'rspec'
4
+ require 'rspec/autorun' # needed for RSpec 2.6.x
2
5
  require 'rsel'
3
6
  require 'selenium/client'
4
7
 
5
8
  require File.expand_path(File.join(File.dirname(__FILE__), '..', 'test', 'app'))
6
9
 
7
10
  RSpec.configure do |config|
11
+ config.color_enabled = true
8
12
  config.include Rsel
9
13
  config.include Rsel::Support
10
14
  end
11
-
12
-
13
- # Monkeypatch the Selenium::Client::Protocol module,
14
- # to prevent http_post from spewing out a bunch of `puts`es on failure.
15
- # (It's really distracting when running spec tests)
16
- module Selenium
17
- module Client
18
- module Protocol
19
- def http_post(data)
20
- start = Time.now
21
- called_from = caller.detect{|line| line !~ /(selenium-client|vendor|usr\/lib\/ruby|\(eval\))/i}
22
- http = Net::HTTP.new(@host, @port)
23
- http.open_timeout = default_timeout_in_seconds
24
- http.read_timeout = default_timeout_in_seconds
25
- response = http.post('/selenium-server/driver/', data, HTTP_HEADERS)
26
- # <-- Here is where all the puts statements were -->
27
- [ response.body[0..1], response.body ]
28
- end
29
- end
30
- end
31
- end
32
-
@@ -0,0 +1,48 @@
1
+ require 'spec/st_spec_helper'
2
+
3
+ describe 'alerts' do
4
+ describe "#see_alert_within_seconds" do
5
+ before(:each) do
6
+ @st.visit("/alert").should be_true
7
+ end
8
+
9
+ context "passes when" do
10
+ it "sees a generic alert" do
11
+ @st.click("Alert me now")
12
+ @st.see_alert_within_seconds.should be_true
13
+ end
14
+ it "sees a generic alert in time" do
15
+ @st.click("Alert me now")
16
+ @st.see_alert_within_seconds(10).should be_true
17
+ end
18
+ it "sees the specific alert" do
19
+ @st.click("Alert me now")
20
+ @st.see_alert_within_seconds("Ruby alert! Automate your workstations!").should be_true
21
+ end
22
+ it "sees the specific alert in time" do
23
+ @st.click("Alert me soon")
24
+ @st.see_alert_within_seconds("Ruby alert! Automate your workstations!", 10).should be_true
25
+ end
26
+ end
27
+
28
+ context "fails when" do
29
+ it "does not see a generic alert in time" do
30
+ @st.click("Alert me soon")
31
+ @st.see_alert_within_seconds(1).should be_false
32
+ # Clean up the alert, to avoid random errors later.
33
+ #@st.see_alert_within_seconds("Ruby alert! Automate your workstations!", 10).should be_true
34
+ end
35
+ it "does not see the specific alert in time" do
36
+ @st.click("Alert me soon")
37
+ @st.see_alert_within_seconds("Ruby alert! Automate your workstations!", 1).should be_false
38
+ # Clean up the alert, to avoid random errors later.
39
+ #@st.see_alert_within_seconds("Ruby alert! Automate your workstations!", 10).should be_true
40
+ end
41
+ it "sees a different alert message" do
42
+ @st.click("Alert me now")
43
+ @st.see_alert_within_seconds("Ruby alert! Man your workstations!", 10).should be_false
44
+ end
45
+ end
46
+
47
+ end
48
+ end