rsel 0.1.1 → 0.1.2
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +1 -0
- data/Rakefile +22 -8
- data/docs/development.md +1 -0
- data/docs/history.md +11 -1
- data/docs/index.md +1 -0
- data/docs/scoping.md +1 -1
- data/docs/studying.md +76 -0
- data/lib/rsel/selenium_test.rb +505 -90
- data/lib/rsel/study_html.rb +198 -0
- data/lib/rsel/support.rb +105 -4
- data/rsel.gemspec +1 -1
- data/spec/spec_helper.rb +4 -22
- data/spec/st_alerts.rb +48 -0
- data/spec/st_browser_spec.rb +58 -0
- data/spec/st_buttons_spec.rb +95 -0
- data/spec/st_checkboxes_spec.rb +235 -0
- data/spec/st_conditionals_spec.rb +180 -0
- data/spec/st_dropdowns_spec.rb +140 -0
- data/spec/st_field_equals_among_spec.rb +48 -0
- data/spec/st_fields_equal_among_spec.rb +74 -0
- data/spec/st_fields_equal_spec.rb +90 -0
- data/spec/st_fields_spec.rb +167 -0
- data/spec/st_initialization_spec.rb +33 -0
- data/spec/st_links_spec.rb +84 -0
- data/spec/st_method_missing_spec.rb +59 -0
- data/spec/st_navigation_spec.rb +56 -0
- data/spec/st_radiobuttons_spec.rb +123 -0
- data/spec/st_respond_to_spec.rb +16 -0
- data/spec/st_scenario_spec.rb +26 -0
- data/spec/st_set_field_among_spec.rb +45 -0
- data/spec/st_set_field_spec.rb +842 -0
- data/spec/st_set_fields_among_spec.rb +74 -0
- data/spec/st_set_fields_spec.rb +97 -0
- data/spec/st_spec_helper.rb +43 -0
- data/spec/st_stop_on_failure_spec.rb +199 -0
- data/spec/st_tables_spec.rb +42 -0
- data/spec/st_temporal_visibility_spec.rb +122 -0
- data/spec/st_visibility_spec.rb +125 -0
- data/spec/st_waiting_spec.rb +37 -0
- data/spec/study_html_spec.rb +310 -0
- data/spec/support_spec.rb +163 -13
- data/test/server/README.txt +3 -0
- data/test/views/alert.erb +15 -0
- data/test/views/form.erb +6 -1
- data/test/views/index.erb +2 -0
- data/test/views/slowtext.erb +1 -1
- metadata +38 -9
- 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
|
data/lib/rsel/support.rb
CHANGED
@@ -36,7 +36,11 @@ module Rsel
|
|
36
36
|
elsif locator =~ /^(id=|name=|dom=|xpath=|link=|css=)/
|
37
37
|
return locator
|
38
38
|
else
|
39
|
-
|
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
|
|
data/rsel.gemspec
CHANGED
data/spec/spec_helper.rb
CHANGED
@@ -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
|
-
|
data/spec/st_alerts.rb
ADDED
@@ -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
|