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.
- 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
|