xpath 0.1.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.
- data/README.rdoc +29 -0
- data/lib/xpath.rb +51 -0
- data/lib/xpath/expression.rb +298 -0
- data/lib/xpath/html.rb +124 -0
- data/lib/xpath/union.rb +27 -0
- data/lib/xpath/version.rb +3 -0
- data/spec/fixtures/form.html +245 -0
- data/spec/fixtures/simple.html +26 -0
- data/spec/fixtures/stuff.html +43 -0
- data/spec/spec_helper.rb +4 -0
- data/spec/union_spec.rb +63 -0
- data/spec/xpath_spec.rb +301 -0
- metadata +127 -0
data/README.rdoc
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
= XPath
|
2
|
+
|
3
|
+
XPath is a Ruby DSL around a subset of XPath 1.0. It's primary purpose is to facilitate writing complex XPath queries from Ruby code.
|
4
|
+
|
5
|
+
== Generating expressions
|
6
|
+
|
7
|
+
To create quick, one of expressions, XPath.generate can be used:
|
8
|
+
|
9
|
+
XPath.generate { |x| x.descendant(:ul)[x.attr(:id) == 'foo'] }
|
10
|
+
|
11
|
+
However for more complex expressions, it is probably ore convenient to include the XPath module into your own class or module:
|
12
|
+
|
13
|
+
module MyXPaths
|
14
|
+
include XPath
|
15
|
+
|
16
|
+
def foo_ul
|
17
|
+
descendant(:ul)[attr(:id) == 'foo']
|
18
|
+
end
|
19
|
+
|
20
|
+
def password_field(id)
|
21
|
+
descendant(:input)[attr(:type) == 'password'][attr(:id) == id]
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
Both ways return an XPath::Expression instance, which can be further modified. To convert the expression to a string, just call #to_s on it.
|
26
|
+
|
27
|
+
== HTML
|
28
|
+
|
29
|
+
XPath comes with a set of premade XPaths for use with HTML documents.
|
data/lib/xpath.rb
ADDED
@@ -0,0 +1,51 @@
|
|
1
|
+
module XPath
|
2
|
+
autoload :Expression, 'xpath/expression'
|
3
|
+
autoload :Union, 'xpath/union'
|
4
|
+
autoload :HTML, 'xpath/html'
|
5
|
+
|
6
|
+
extend self
|
7
|
+
|
8
|
+
def self.generate
|
9
|
+
yield(Expression::Self.new)
|
10
|
+
end
|
11
|
+
|
12
|
+
def current
|
13
|
+
Expression::Self.new
|
14
|
+
end
|
15
|
+
|
16
|
+
def name
|
17
|
+
Expression::Name.new(current)
|
18
|
+
end
|
19
|
+
|
20
|
+
def descendant(*expressions)
|
21
|
+
Expression::Descendant.new(current, expressions)
|
22
|
+
end
|
23
|
+
|
24
|
+
def child(*expressions)
|
25
|
+
Expression::Child.new(current, expressions)
|
26
|
+
end
|
27
|
+
|
28
|
+
def anywhere(expression)
|
29
|
+
Expression::Anywhere.new(expression)
|
30
|
+
end
|
31
|
+
|
32
|
+
def attr(expression)
|
33
|
+
Expression::Attribute.new(current, expression)
|
34
|
+
end
|
35
|
+
|
36
|
+
def contains(expression)
|
37
|
+
Expression::Contains.new(current, expression)
|
38
|
+
end
|
39
|
+
|
40
|
+
def text
|
41
|
+
Expression::Text.new(current)
|
42
|
+
end
|
43
|
+
|
44
|
+
def var(name)
|
45
|
+
Expression::Variable.new(name)
|
46
|
+
end
|
47
|
+
|
48
|
+
def varstring(name)
|
49
|
+
var(name).string_literal
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,298 @@
|
|
1
|
+
module XPath
|
2
|
+
class Expression
|
3
|
+
include XPath
|
4
|
+
|
5
|
+
class Self < Expression
|
6
|
+
def to_xpath(predicate=nil)
|
7
|
+
'.'
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
class Unary < Expression
|
12
|
+
def initialize(expression)
|
13
|
+
@expression = wrap_xpath(expression)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
class Binary < Expression
|
18
|
+
def initialize(left, right)
|
19
|
+
@left = wrap_xpath(left)
|
20
|
+
@right = wrap_xpath(right)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
class Multiple < Expression
|
25
|
+
def initialize(left, expressions)
|
26
|
+
@left = wrap_xpath(left)
|
27
|
+
@expressions = expressions.map { |e| wrap_xpath(e) }
|
28
|
+
if @expressions.empty?
|
29
|
+
raise ArgumentError, "must specify at least one expression"
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
class Literal < Expression
|
35
|
+
def initialize(expression)
|
36
|
+
@expression = expression
|
37
|
+
end
|
38
|
+
|
39
|
+
def to_xpath(predicate=nil)
|
40
|
+
@expression.to_s
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
class Child < Multiple
|
45
|
+
def to_xpath(predicate=nil)
|
46
|
+
if @expressions.length == 1
|
47
|
+
"#{@left.to_xpath(predicate)}/#{@expressions.first.to_xpath(predicate)}"
|
48
|
+
else
|
49
|
+
"#{@left.to_xpath(predicate)}/*[#{@expressions.map { |e| "self::#{e.to_xpath(predicate)}" }.join(" | ")}]"
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
class Descendant < Multiple
|
55
|
+
def to_xpath(predicate=nil)
|
56
|
+
if @expressions.length == 1
|
57
|
+
"#{@left.to_xpath(predicate)}//#{@expressions.first.to_xpath(predicate)}"
|
58
|
+
else
|
59
|
+
"#{@left.to_xpath(predicate)}//*[#{@expressions.map { |e| "self::#{e.to_xpath(predicate)}" }.join(" | ")}]"
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
class NextSibling < Multiple
|
65
|
+
def to_xpath(predicate=nil)
|
66
|
+
if @expressions.length == 1
|
67
|
+
"#{@left.to_xpath(predicate)}/following-sibling::*[1]/self::#{@expressions.first.to_xpath(predicate)}"
|
68
|
+
else
|
69
|
+
"#{@left.to_xpath(predicate)}/following-sibling::*[1]/self::*[#{@expressions.map { |e| "self::#{e.to_xpath(predicate)}" }.join(" | ")}]"
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
class Anywhere < Unary
|
75
|
+
def to_xpath(predicate=nil)
|
76
|
+
"//#{@expression.to_xpath(predicate)}"
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
class Name < Unary
|
81
|
+
def to_xpath(predicate=nil)
|
82
|
+
"name(#{@expression.to_xpath(predicate)})"
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
class Where < Binary
|
87
|
+
def to_xpath(predicate=nil)
|
88
|
+
"#{@left.to_xpath(predicate)}[#{@right.to_xpath(predicate)}]"
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
class Attribute < Binary
|
93
|
+
def to_xpath(predicate=nil)
|
94
|
+
if @right.is_a?(Literal)
|
95
|
+
"#{@left.to_xpath(predicate)}/@#{@right.to_xpath(predicate)}"
|
96
|
+
else
|
97
|
+
"#{@left.to_xpath(predicate)}/attribute::node()[name(.) = #{@right.to_xpath(predicate)}]"
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
class Equality < Binary
|
103
|
+
def to_xpath(predicate=nil)
|
104
|
+
"#{@left.to_xpath(predicate)} = #{@right.to_xpath(predicate)}"
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
class StringFunction < Unary
|
109
|
+
def to_xpath(predicate=nil)
|
110
|
+
"string(#{@expression.to_xpath(predicate)})"
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
class StringLiteral < Expression
|
115
|
+
def initialize(expression)
|
116
|
+
@expression = expression
|
117
|
+
end
|
118
|
+
|
119
|
+
def to_xpath(predicate=nil)
|
120
|
+
@expression = @expression.to_xpath(predicate) unless @expression.is_a?(String)
|
121
|
+
if @expression.include?("'")
|
122
|
+
@expression = @expression.split("'", -1).map do |substr|
|
123
|
+
"'#{substr}'"
|
124
|
+
end.join(%q{,"'",})
|
125
|
+
"concat(#{@expression})"
|
126
|
+
else
|
127
|
+
"'#{@expression}'"
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
class NormalizedSpace < Unary
|
133
|
+
def to_xpath(predicate=nil)
|
134
|
+
"normalize-space(#{@expression.to_xpath(predicate)})"
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
class And < Binary
|
139
|
+
def to_xpath(predicate=nil)
|
140
|
+
"(#{@left.to_xpath(predicate)} and #{@right.to_xpath(predicate)})"
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
class Or < Binary
|
145
|
+
def to_xpath(predicate=nil)
|
146
|
+
"(#{@left.to_xpath(predicate)} or #{@right.to_xpath(predicate)})"
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
class OneOf < Expression
|
151
|
+
def initialize(left, right)
|
152
|
+
@left = wrap_xpath(left)
|
153
|
+
@right = right.map { |r| wrap_xpath(r) }
|
154
|
+
end
|
155
|
+
|
156
|
+
def to_xpath(predicate=nil)
|
157
|
+
@right.map { |r| "#{@left.to_xpath(predicate)} = #{r.to_xpath(predicate)}" }.join(' or ')
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
class Contains < Binary
|
162
|
+
def to_xpath(predicate=nil)
|
163
|
+
"contains(#{@left.to_xpath(predicate)}, #{@right.to_xpath(predicate)})"
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
class Is < Binary
|
168
|
+
def to_xpath(predicate=nil)
|
169
|
+
if predicate == :exact
|
170
|
+
Equality.new(@left, @right).to_xpath(predicate)
|
171
|
+
else
|
172
|
+
Contains.new(@left, @right).to_xpath(predicate)
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
class Text < Unary
|
178
|
+
def to_xpath(predicate=nil)
|
179
|
+
"#{@expression.to_xpath(predicate)}/text()"
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
class Variable < Expression
|
184
|
+
def initialize(name)
|
185
|
+
@name = name
|
186
|
+
end
|
187
|
+
|
188
|
+
def to_xpath(predicate=nil)
|
189
|
+
"%{#{@name}}"
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
class Inverse < Unary
|
194
|
+
def to_xpath(predicate=nil)
|
195
|
+
"not(#{@expression.to_xpath(predicate)})"
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
class Applied < Expression
|
200
|
+
def initialize(expression, variables={})
|
201
|
+
@variables = variables
|
202
|
+
@expression = expression
|
203
|
+
end
|
204
|
+
|
205
|
+
def to_xpath(predicate=nil)
|
206
|
+
@expression.to_xpath(predicate) % @variables
|
207
|
+
rescue ArgumentError # for ruby < 1.9 compat
|
208
|
+
@expression.to_xpath(predicate).gsub(/%\{(\w+)\}/) do |_|
|
209
|
+
@variables[$1.to_sym] or raise(ArgumentError, "expected variable #{$1} to be set")
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
def current
|
215
|
+
self
|
216
|
+
end
|
217
|
+
|
218
|
+
def next_sibling(*expressions)
|
219
|
+
Expression::NextSibling.new(current, expressions)
|
220
|
+
end
|
221
|
+
|
222
|
+
def where(expression)
|
223
|
+
Expression::Where.new(current, expression)
|
224
|
+
end
|
225
|
+
alias_method :[], :where
|
226
|
+
|
227
|
+
def one_of(*expressions)
|
228
|
+
Expression::OneOf.new(current, expressions)
|
229
|
+
end
|
230
|
+
|
231
|
+
def equals(expression)
|
232
|
+
Expression::Equality.new(current, expression)
|
233
|
+
end
|
234
|
+
alias_method :==, :equals
|
235
|
+
|
236
|
+
def is(expression)
|
237
|
+
Expression::Is.new(current, expression)
|
238
|
+
end
|
239
|
+
|
240
|
+
def string
|
241
|
+
Expression::StringFunction.new(current)
|
242
|
+
end
|
243
|
+
|
244
|
+
def or(expression)
|
245
|
+
Expression::Or.new(current, expression)
|
246
|
+
end
|
247
|
+
alias_method :|, :or
|
248
|
+
|
249
|
+
def and(expression)
|
250
|
+
Expression::And.new(current, expression)
|
251
|
+
end
|
252
|
+
alias_method :&, :and
|
253
|
+
|
254
|
+
def union(*expressions)
|
255
|
+
Union.new(*[self, expressions].flatten)
|
256
|
+
end
|
257
|
+
alias_method :+, :union
|
258
|
+
|
259
|
+
def inverse
|
260
|
+
Expression::Inverse.new(current)
|
261
|
+
end
|
262
|
+
alias_method :~, :inverse
|
263
|
+
|
264
|
+
def string_literal
|
265
|
+
Expression::StringLiteral.new(self)
|
266
|
+
end
|
267
|
+
|
268
|
+
def to_xpath(predicate=nil)
|
269
|
+
raise NotImplementedError, "please implement in subclass"
|
270
|
+
end
|
271
|
+
|
272
|
+
def to_s
|
273
|
+
to_xpaths.join(' | ')
|
274
|
+
end
|
275
|
+
|
276
|
+
def to_xpaths
|
277
|
+
[to_xpath(:exact), to_xpath(:fuzzy)].uniq
|
278
|
+
end
|
279
|
+
|
280
|
+
def apply(variables={})
|
281
|
+
Expression::Applied.new(current, variables)
|
282
|
+
end
|
283
|
+
|
284
|
+
def normalize
|
285
|
+
Expression::NormalizedSpace.new(current)
|
286
|
+
end
|
287
|
+
alias_method :n, :normalize
|
288
|
+
|
289
|
+
def wrap_xpath(expression)
|
290
|
+
case expression
|
291
|
+
when ::String then Expression::StringLiteral.new(expression)
|
292
|
+
when ::Symbol then Expression::Literal.new(expression)
|
293
|
+
else expression
|
294
|
+
end
|
295
|
+
end
|
296
|
+
end
|
297
|
+
end
|
298
|
+
|
data/lib/xpath/html.rb
ADDED
@@ -0,0 +1,124 @@
|
|
1
|
+
module XPath
|
2
|
+
module HTML
|
3
|
+
include XPath
|
4
|
+
extend self
|
5
|
+
|
6
|
+
def from_css(css)
|
7
|
+
XPath::Union.new(*Nokogiri::CSS.xpath_for(css).map { |selector| ::XPath::Expression::Literal.new(:".#{selector}") }.flatten)
|
8
|
+
end
|
9
|
+
|
10
|
+
def link(locator)
|
11
|
+
link = descendant(:a)[attr(:href)]
|
12
|
+
link[attr(:id).equals(locator) | text.is(locator) | attr(:title).is(locator) | descendant(:img)[attr(:alt).is(locator)]]
|
13
|
+
end
|
14
|
+
|
15
|
+
def content(locator)
|
16
|
+
child(:"descendant-or-self::*")[current.n.contains(locator)]
|
17
|
+
end
|
18
|
+
|
19
|
+
def button(locator)
|
20
|
+
button = descendant(:input)[attr(:type).one_of('submit', 'image', 'button')][attr(:id).equals(locator) | attr(:value).is(locator)]
|
21
|
+
button += descendant(:button)[attr(:id).equals(locator) | attr(:value).is(locator) | text.is(locator)]
|
22
|
+
button += descendant(:input)[attr(:type).equals('image')][attr(:alt).is(locator)]
|
23
|
+
end
|
24
|
+
|
25
|
+
def link_or_button(locator)
|
26
|
+
link(locator) + button(locator)
|
27
|
+
end
|
28
|
+
|
29
|
+
def fieldset(locator)
|
30
|
+
descendant(:fieldset)[attr(:id).equals(locator) | descendant(:legend)[text.is(locator)]]
|
31
|
+
end
|
32
|
+
|
33
|
+
def field(locator, options={})
|
34
|
+
if options[:with]
|
35
|
+
fillable_field(locator, options)
|
36
|
+
else
|
37
|
+
xpath = descendant(:input, :textarea, :select)[~attr(:type).one_of('submit', 'image', 'hidden')]
|
38
|
+
xpath = locate_field(xpath, locator)
|
39
|
+
xpath = xpath[attr(:checked)] if options[:checked]
|
40
|
+
xpath = xpath[~attr(:checked)] if options[:unchecked]
|
41
|
+
xpath
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def fillable_field(locator, options={})
|
46
|
+
xpath = descendant(:input, :textarea)[~attr(:type).one_of('submit', 'image', 'radio', 'checkbox', 'hidden', 'file')]
|
47
|
+
xpath = locate_field(xpath, locator)
|
48
|
+
xpath = xpath[field_value(options[:with])] if options.has_key?(:with)
|
49
|
+
xpath
|
50
|
+
end
|
51
|
+
|
52
|
+
def select(locator, options={})
|
53
|
+
xpath = locate_field(descendant(:select), locator)
|
54
|
+
|
55
|
+
options[:options].each do |option|
|
56
|
+
xpath = xpath[descendant(:option).text.equals(option)]
|
57
|
+
end if options[:options]
|
58
|
+
|
59
|
+
[options[:selected]].flatten.each do |option|
|
60
|
+
xpath = xpath[descendant(:option)[attr(:selected)].text.equals(option)]
|
61
|
+
end if options[:selected]
|
62
|
+
|
63
|
+
xpath
|
64
|
+
end
|
65
|
+
|
66
|
+
def checkbox(locator, options={})
|
67
|
+
xpath = locate_field(descendant(:input)[attr(:type).equals('checkbox')], locator)
|
68
|
+
end
|
69
|
+
|
70
|
+
def radio_button(locator, options={})
|
71
|
+
locate_field(descendant(:input)[attr(:type).equals('radio')], locator)
|
72
|
+
end
|
73
|
+
|
74
|
+
def file_field(locator, options={})
|
75
|
+
locate_field(descendant(:input)[attr(:type).equals('file')], locator)
|
76
|
+
end
|
77
|
+
|
78
|
+
def option(name)
|
79
|
+
descendant(:option)[text.n.is(name)]
|
80
|
+
end
|
81
|
+
|
82
|
+
def table(locator, options={})
|
83
|
+
xpath = descendant(:table)[attr(:id).equals(locator) | descendant(:caption).contains(locator)]
|
84
|
+
xpath = xpath[table_rows(options[:rows])] if options[:rows]
|
85
|
+
xpath
|
86
|
+
end
|
87
|
+
|
88
|
+
def table_rows(rows)
|
89
|
+
row_conditions = descendant(:tr)[table_row(rows.first)]
|
90
|
+
rows.drop(1).each do |row|
|
91
|
+
row_conditions = row_conditions.next_sibling(:tr)[table_row(row)]
|
92
|
+
end
|
93
|
+
row_conditions
|
94
|
+
end
|
95
|
+
|
96
|
+
def table_row(cells)
|
97
|
+
cell_conditions = child(:td, :th)[text.equals(cells.first)]
|
98
|
+
cells.drop(1).each do |cell|
|
99
|
+
cell_conditions = cell_conditions.next_sibling(:td, :th)[text.equals(cell)]
|
100
|
+
end
|
101
|
+
cell_conditions
|
102
|
+
end
|
103
|
+
|
104
|
+
def wrap(path)
|
105
|
+
if path.respond_to?(:to_xpaths)
|
106
|
+
path.to_xpaths
|
107
|
+
else
|
108
|
+
[path.to_s].flatten
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
protected
|
113
|
+
|
114
|
+
def locate_field(xpath, locator)
|
115
|
+
locate_field = xpath[attr(:id).equals(locator) | attr(:name).equals(locator) | attr(:id).equals(anywhere(:label)[text.is(locator)].attr(:for))]
|
116
|
+
locate_field += descendant(:label)[text.is(locator)].descendant(xpath)
|
117
|
+
end
|
118
|
+
|
119
|
+
def field_value(value)
|
120
|
+
(text.is(value) & name.equals('textarea')) | (attr(:value).equals(value) & ~name.equals('textarea'))
|
121
|
+
end
|
122
|
+
|
123
|
+
end
|
124
|
+
end
|
data/lib/xpath/union.rb
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
module XPath
|
2
|
+
class Union
|
3
|
+
include Enumerable
|
4
|
+
|
5
|
+
attr_reader :expressions
|
6
|
+
|
7
|
+
def initialize(*expressions)
|
8
|
+
@expressions = expressions
|
9
|
+
end
|
10
|
+
|
11
|
+
def each(&block)
|
12
|
+
expressions.each(&block)
|
13
|
+
end
|
14
|
+
|
15
|
+
def to_xpath(predicate=nil)
|
16
|
+
expressions.map { |e| e.to_xpath(predicate) }.join(' | ')
|
17
|
+
end
|
18
|
+
|
19
|
+
def to_xpaths
|
20
|
+
[to_xpath(:exact), to_xpath(:fuzzy)].uniq
|
21
|
+
end
|
22
|
+
|
23
|
+
def method_missing(*args)
|
24
|
+
XPath::Union.new(*expressions.map { |e| e.send(*args) })
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,245 @@
|
|
1
|
+
<h1>Form</h1>
|
2
|
+
|
3
|
+
<form action="/form" method="post">
|
4
|
+
|
5
|
+
<p>
|
6
|
+
<label for="form_title">Title</label>
|
7
|
+
<select name="form[title]" id="form_title">
|
8
|
+
<option>Mrs</option>
|
9
|
+
<option>Mr</option>
|
10
|
+
<option>Miss</option>
|
11
|
+
</select>
|
12
|
+
</p>
|
13
|
+
|
14
|
+
|
15
|
+
<p>
|
16
|
+
<label for="form_other_title">Other title</label>
|
17
|
+
<select name="form[other_title]" id="form_other_title">
|
18
|
+
<option>Mrs</option>
|
19
|
+
<option>Mr</option>
|
20
|
+
<option>Miss</option>
|
21
|
+
</select>
|
22
|
+
</p>
|
23
|
+
|
24
|
+
<p>
|
25
|
+
<label for="form_first_name">
|
26
|
+
First Name
|
27
|
+
<input type="text" name="form[first_name]" value="John" id="form_first_name"/>
|
28
|
+
</label>
|
29
|
+
</p>
|
30
|
+
|
31
|
+
<p>
|
32
|
+
<label for="form_last_name">Last Name</label>
|
33
|
+
<input type="text" name="form[last_name]" value="Smith" id="form_last_name"/>
|
34
|
+
</p>
|
35
|
+
|
36
|
+
<p>
|
37
|
+
<label for="form_name_explanation">Explanation of Name</label>
|
38
|
+
<textarea name="form[name_explanation]" id="form_name_explanation"></textarea>
|
39
|
+
</p>
|
40
|
+
|
41
|
+
<p>
|
42
|
+
<label for="form_name">Name</label>
|
43
|
+
<input type="text" name="form[name]" value="John Smith" id="form_name"/>
|
44
|
+
</p>
|
45
|
+
|
46
|
+
<p>
|
47
|
+
<label for="form_schmooo">Schmooo</label>
|
48
|
+
<input type="schmooo" name="form[schmooo]" value="This is Schmooo!" id="form_schmooo"/>
|
49
|
+
</p>
|
50
|
+
|
51
|
+
<p>
|
52
|
+
<label>Street<br/>
|
53
|
+
<input type="text" name="form[street]" value="Sesame street 66"/>
|
54
|
+
</label>
|
55
|
+
</p>
|
56
|
+
|
57
|
+
<p>
|
58
|
+
<label for="form_phone">Phone</label>
|
59
|
+
<input name="form[phone]" value="+1 555 7021" id="form_phone"/>
|
60
|
+
</p>
|
61
|
+
|
62
|
+
<p>
|
63
|
+
<label for="form_password">Password</label>
|
64
|
+
<input type="password" name="form[password]" value="seeekrit" id="form_password"/>
|
65
|
+
</p>
|
66
|
+
|
67
|
+
<p>
|
68
|
+
<label for="form_terms_of_use">Terms of Use</label>
|
69
|
+
<input type="hidden" name="form[terms_of_use]" value="0" id="form_terms_of_use_default">
|
70
|
+
<input type="checkbox" name="form[terms_of_use]" value="1" id="form_terms_of_use">
|
71
|
+
</p>
|
72
|
+
|
73
|
+
<p>
|
74
|
+
<label for="form_image">Image</label>
|
75
|
+
<input type="file" name="form[image]" id="form_image"/>
|
76
|
+
</p>
|
77
|
+
|
78
|
+
<p>
|
79
|
+
<input type="hidden" name="form[token]" value="12345" id="form_token"/>
|
80
|
+
</p>
|
81
|
+
|
82
|
+
<p>
|
83
|
+
<label for="form_locale">Locale</label>
|
84
|
+
<select name="form[locale]" id="form_locale">
|
85
|
+
<option value="sv">Swedish</option>
|
86
|
+
<option selected="selected" value="en">English</option>
|
87
|
+
<option value="fi">Finish</option>
|
88
|
+
<option value="no">Norwegian</option>
|
89
|
+
<option value="jo">John's made-up language</option>
|
90
|
+
<option value="jbo"> Lojban </option>
|
91
|
+
</select>
|
92
|
+
</p>
|
93
|
+
|
94
|
+
<p>
|
95
|
+
<label for="form_region">Region</label>
|
96
|
+
<select name="form[region]" id="form_region">
|
97
|
+
<option>Sweden</option>
|
98
|
+
<option selected="selected">Norway</option>
|
99
|
+
<option>Finland</option>
|
100
|
+
</select>
|
101
|
+
</p>
|
102
|
+
|
103
|
+
<p>
|
104
|
+
<label for="form_city">City</label>
|
105
|
+
<select name="form[city]" id="form_city">
|
106
|
+
<option>London</option>
|
107
|
+
<option>Stockholm</option>
|
108
|
+
<option>Paris</option>
|
109
|
+
</select>
|
110
|
+
</p>
|
111
|
+
|
112
|
+
<p>
|
113
|
+
<label for="form_tendency">Tendency</label>
|
114
|
+
<select name="form[tendency]" id="form_tendency"></select>
|
115
|
+
</p>
|
116
|
+
|
117
|
+
<p>
|
118
|
+
<label for="form_description">Description</label></br>
|
119
|
+
<textarea name="form[description]" id="form_description">Descriptive text goes here</textarea>
|
120
|
+
<p>
|
121
|
+
|
122
|
+
<p>
|
123
|
+
<input type="radio" name="form[gender]" value="male" id="gender_male"/>
|
124
|
+
<label for="gender_male">Male</label>
|
125
|
+
<input type="radio" name="form[gender]" value="female" id="gender_female" checked="checked"/>
|
126
|
+
<label for="gender_female">Female</label>
|
127
|
+
<input type="radio" name="form[gender]" value="both" id="gender_both"/>
|
128
|
+
<label for="gender_both">Both</label>
|
129
|
+
</p>
|
130
|
+
|
131
|
+
<p>
|
132
|
+
<input type="checkbox" value="dog" name="form[pets][]" id="form_pets_dog" checked="checked"/>
|
133
|
+
<label for="form_pets_dog">Dog</label>
|
134
|
+
<input type="checkbox" value="cat" name="form[pets][]" id="form_pets_cat"/>
|
135
|
+
<label for="form_pets_cat">Cat</label>
|
136
|
+
<input type="checkbox" value="hamster" name="form[pets][]" id="form_pets_hamster" checked="checked"/>
|
137
|
+
<label for="form_pets_hamster">Hamster</label>
|
138
|
+
</p>
|
139
|
+
|
140
|
+
<p>
|
141
|
+
<label for="form_languages">Languages</label>
|
142
|
+
<select name="form[languages][]" id="form_languages" multiple="multiple">
|
143
|
+
<option>Ruby</option>
|
144
|
+
<option>SQL</option>
|
145
|
+
<option>HTML</option>
|
146
|
+
<option>Javascript</option>
|
147
|
+
</select>
|
148
|
+
</p>
|
149
|
+
|
150
|
+
<p>
|
151
|
+
<label for="form_underwear">Underwear</label>
|
152
|
+
<select name="form[underwear][]" id="form_underwear" multiple="multiple">
|
153
|
+
<option selected="selected">Boxer Briefs</option>
|
154
|
+
<option>Boxers</option>
|
155
|
+
<option selected="selected">Briefs</option>
|
156
|
+
<option selected="selected">Commando</option>
|
157
|
+
<option selected="selected">Frenchman's Pantalons</option>
|
158
|
+
</select>
|
159
|
+
</p>
|
160
|
+
|
161
|
+
<div style="display:none;">
|
162
|
+
<label for="form_first_name_hidden">
|
163
|
+
Super Secret
|
164
|
+
<input type="text" name="form[super_secret]" value="test123" id="form_super_secret"/>
|
165
|
+
</label>
|
166
|
+
</div>
|
167
|
+
|
168
|
+
<p>
|
169
|
+
<input type="button" name="form[fresh]" id="fresh_btn" value="i am fresh"/>
|
170
|
+
<input type="submit" name="form[awesome]" id="awe123" value="awesome"/>
|
171
|
+
<input type="submit" name="form[crappy]" id="crap321" value="crappy"/>
|
172
|
+
<input type="image" name="form[okay]" id="okay556" value="okay" alt="oh hai thar"/>
|
173
|
+
<button type="submit" id="click_me_123" value="click_me">Click me!</button>
|
174
|
+
<button type="submit" name="form[no_value]">No Value!</button>
|
175
|
+
</p>
|
176
|
+
</form>
|
177
|
+
|
178
|
+
<form id="get-form" action="/form/get?foo=bar" method="get">
|
179
|
+
<p>
|
180
|
+
<label for="form_middle_name">Middle Name</label>
|
181
|
+
<input type="text" name="form[middle_name]" value="Darren" id="form_middle_name"/>
|
182
|
+
</p>
|
183
|
+
|
184
|
+
<p>
|
185
|
+
<input type="submit" name="form[mediocre]" id="mediocre" value="med"/>
|
186
|
+
<p>
|
187
|
+
</form>
|
188
|
+
|
189
|
+
<form action="/upload" method="post" enctype="multipart/form-data">
|
190
|
+
<p>
|
191
|
+
<label for="form_file_name">File Name</label>
|
192
|
+
<input type="file" name="form[file_name]" id="form_file_name"/>
|
193
|
+
</p>
|
194
|
+
|
195
|
+
<p>
|
196
|
+
<label for="form_document">Document</label>
|
197
|
+
<input type="file" name="form[document]" id="form_document"/>
|
198
|
+
</p>
|
199
|
+
|
200
|
+
<p>
|
201
|
+
<input type="submit" value="Upload"/>
|
202
|
+
<p>
|
203
|
+
</form>
|
204
|
+
|
205
|
+
<form action="/redirect" method="post">
|
206
|
+
<p>
|
207
|
+
<input type="submit" value="Go FAR"/>
|
208
|
+
</p>
|
209
|
+
</form>
|
210
|
+
|
211
|
+
<form action="/form" method="post">
|
212
|
+
<p>
|
213
|
+
<label for="html5_email">Html5 Email</label>
|
214
|
+
<input type="email" name="form[html5_email]" value="person@email.com" id="html5_email"/>
|
215
|
+
</p>
|
216
|
+
<p>
|
217
|
+
<label for="html5_url">Html5 Url</label>
|
218
|
+
<input type="url" name="form[html5_url]" value="http://www.example.com" id="html5_url"/>
|
219
|
+
</p>
|
220
|
+
<p>
|
221
|
+
<label for="html5_search">Html5 Search</label>
|
222
|
+
<input type="search" name="form[html5_search]" value="what are you looking for" id="html5_search"/>
|
223
|
+
</p>
|
224
|
+
<p>
|
225
|
+
<label for="html5_tel">Html5 Tel</label>
|
226
|
+
<input type="tel" name="form[html5_tel]" value="911" id="html5_tel"/>
|
227
|
+
</p>
|
228
|
+
<p>
|
229
|
+
<label for="html5_color">Html5 Color</label>
|
230
|
+
<input type="color" name="form[html5_color]" value="#FFF" id="html5_color"/>
|
231
|
+
</p>
|
232
|
+
|
233
|
+
<p>
|
234
|
+
<input type="submit" name="form[html5_submit]" value="html5_submit"/>
|
235
|
+
</p>
|
236
|
+
</form>
|
237
|
+
|
238
|
+
<form action="/form" method="post">
|
239
|
+
<p>
|
240
|
+
<button type="submit" name="form[button]" value="button_first">Just an input that came first</button>
|
241
|
+
<button type="submit" name="form[button]" value="button_second">Just an input</button>
|
242
|
+
<input type="submit" name="form[button]" value="Just a button that came first"/>
|
243
|
+
<input type="submit" name="form[button]" value="Just a button"/>
|
244
|
+
</p>
|
245
|
+
</form>
|
@@ -0,0 +1,26 @@
|
|
1
|
+
<div id="bar" title="barDiv">
|
2
|
+
|
3
|
+
</div>
|
4
|
+
|
5
|
+
<div title="noId"></div>
|
6
|
+
|
7
|
+
<div id="foo" title="fooDiv" data="id">
|
8
|
+
<p id="fooDiv">Blah</p>
|
9
|
+
<p>Bax</p>
|
10
|
+
<p title="monkey">Bax</p>
|
11
|
+
<ul><li>A list</li></ul>
|
12
|
+
</div>
|
13
|
+
|
14
|
+
<div id="baz" title="bazDiv"></div>
|
15
|
+
|
16
|
+
<div id="preference">
|
17
|
+
<p id="is-fuzzy">allamas</p>
|
18
|
+
<p id="is-exact">llama</p>
|
19
|
+
</div>
|
20
|
+
|
21
|
+
<p id="whitespace">
|
22
|
+
A lot
|
23
|
+
|
24
|
+
of
|
25
|
+
whitespace
|
26
|
+
</p>
|
@@ -0,0 +1,43 @@
|
|
1
|
+
<h1>This is a test</h1>
|
2
|
+
|
3
|
+
<p id="first">
|
4
|
+
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod
|
5
|
+
tempor incididunt ut <a href="/with_simple_html" title="awesome title" class="simple">labore</a>
|
6
|
+
et dolore magna aliqua. Ut enim ad minim veniam,
|
7
|
+
quis nostrud exercitation <a href="/foo" id="foo">ullamco</a> laboris nisi
|
8
|
+
ut aliquip ex ea commodo consequat.
|
9
|
+
<a href="/with_simple_html"><img src="http://www.foobar.sun/dummy_image.jpg" width="20" height="20" alt="awesome image" /></a>
|
10
|
+
</p>
|
11
|
+
|
12
|
+
<p id="second">
|
13
|
+
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum
|
14
|
+
dolore eu fugiat <a href="/redirect" id="red">Redirect</a> pariatur. Excepteur sint occaecat cupidatat non proident,
|
15
|
+
sunt in culpa qui officia
|
16
|
+
text with
|
17
|
+
whitespace
|
18
|
+
id est laborum.
|
19
|
+
</p>
|
20
|
+
|
21
|
+
<p>
|
22
|
+
<input type="text" id="test_field" value="monkey"/>
|
23
|
+
<textarea>banana</textarea>
|
24
|
+
<a href="/redirect_back">BackToMyself</a>
|
25
|
+
<a title="twas a fine link" href="/redirect">A link came first</a>
|
26
|
+
<a title="a fine link" href="/with_simple_html">A link</a>
|
27
|
+
<a title="a fine link with data method" data-method="delete" href="/delete">A link with data-method</a>
|
28
|
+
<a>No Href</a>
|
29
|
+
<a href="">Blank Href</a>
|
30
|
+
<a href="#">Blank Anchor</a>
|
31
|
+
<a href="#anchor">Anchor</a>
|
32
|
+
<a href="/with_simple_html#anchor">Anchor on different page</a>
|
33
|
+
<a href="/with_html#anchor">Anchor on same page</a>
|
34
|
+
<input type="text" value="" id="test_field">
|
35
|
+
<input type="text" checked="checked" id="checked_field">
|
36
|
+
<a href="/redirect"><img src="http://www.foobar.sun/dummy_image.jpg" width="20" height="20" alt="very fine image" /></a>
|
37
|
+
<a href="/with_simple_html"><img src="http://www.foobar.sun/dummy_image.jpg" width="20" height="20" alt="fine image" /></a>
|
38
|
+
</p>
|
39
|
+
|
40
|
+
<div id="hidden" style="display: none;">
|
41
|
+
<div id="hidden_via_ancestor">Inside element with hidden ancestor</div>
|
42
|
+
<a href="/with_simple_html" title="awesome title" class="simple">hidden link</a>
|
43
|
+
</div>
|
data/spec/spec_helper.rb
ADDED
data/spec/union_spec.rb
ADDED
@@ -0,0 +1,63 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe XPath::Union do
|
4
|
+
let(:template) { File.read(File.expand_path('fixtures/simple.html', File.dirname(__FILE__))) }
|
5
|
+
let(:doc) { Nokogiri::HTML(template) }
|
6
|
+
|
7
|
+
describe '#expressions' do
|
8
|
+
it "should return the expressions" do
|
9
|
+
@expr1 = XPath.generate { |x| x.descendant(:p) }
|
10
|
+
@expr2 = XPath.generate { |x| x.descendant(:div) }
|
11
|
+
@collection = XPath::Union.new(@expr1, @expr2)
|
12
|
+
@collection.expressions.should == [@expr1, @expr2]
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
describe '#each' do
|
17
|
+
it "should iterate through the expressions" do
|
18
|
+
@expr1 = XPath.generate { |x| x.descendant(:p) }
|
19
|
+
@expr2 = XPath.generate { |x| x.descendant(:div) }
|
20
|
+
@collection = XPath::Union.new(@expr1, @expr2)
|
21
|
+
exprs = []
|
22
|
+
@collection.each { |expr| exprs << expr }
|
23
|
+
exprs.should == [@expr1, @expr2]
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
describe '#map' do
|
28
|
+
it "should map the expressions" do
|
29
|
+
@expr1 = XPath.generate { |x| x.descendant(:p) }
|
30
|
+
@expr2 = XPath.generate { |x| x.descendant(:div) }
|
31
|
+
@collection = XPath::Union.new(@expr1, @expr2)
|
32
|
+
@collection.map { |expr| expr.class }.should == [XPath::Expression::Descendant, XPath::Expression::Descendant]
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
describe '#to_xpath' do
|
37
|
+
it "should create a valid xpath expression" do
|
38
|
+
@expr1 = XPath.generate { |x| x.descendant(:p) }
|
39
|
+
@expr2 = XPath.generate { |x| x.descendant(:div).where(x.attr(:id) == 'foo') }
|
40
|
+
@collection = XPath::Union.new(@expr1, @expr2)
|
41
|
+
@results = doc.xpath(@collection.to_xpath)
|
42
|
+
@results[0][:title].should == 'fooDiv'
|
43
|
+
@results[1].text.should == 'Blah'
|
44
|
+
@results[2].text.should == 'Bax'
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
describe '#where, #apply and others' do
|
49
|
+
it "should be delegated to the individual expressions" do
|
50
|
+
@expr1 = XPath.generate { |x| x.descendant(:p) }
|
51
|
+
@expr2 = XPath.generate { |x| x.descendant(:div) }
|
52
|
+
@collection = XPath::Union.new(@expr1, @expr2)
|
53
|
+
@xpath1 = @collection.where(XPath.attr(:id) == 'foo').to_xpath
|
54
|
+
@xpath2 = @collection.where(XPath.attr(:id) == XPath.varstring(:id)).apply(:id => 'fooDiv').to_xpath
|
55
|
+
@results = doc.xpath(@xpath1)
|
56
|
+
@results[0][:title].should == 'fooDiv'
|
57
|
+
@results = doc.xpath(@xpath2)
|
58
|
+
@results[0][:id].should == 'fooDiv'
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
63
|
+
|
data/spec/xpath_spec.rb
ADDED
@@ -0,0 +1,301 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
require 'nokogiri'
|
4
|
+
|
5
|
+
class Thingy
|
6
|
+
include XPath
|
7
|
+
|
8
|
+
def foo_div
|
9
|
+
descendant(:div).where(attr(:id) == 'foo')
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
describe XPath do
|
14
|
+
let(:template) { File.read(File.expand_path('fixtures/simple.html', File.dirname(__FILE__))) }
|
15
|
+
let(:doc) { Nokogiri::HTML(template) }
|
16
|
+
|
17
|
+
def xpath(predicate=nil, &block)
|
18
|
+
doc.xpath XPath.generate(&block).to_xpath(predicate)
|
19
|
+
end
|
20
|
+
|
21
|
+
it "should work as a mixin" do
|
22
|
+
xpath = Thingy.new.foo_div.to_xpath
|
23
|
+
doc.xpath(xpath).first[:title].should == 'fooDiv'
|
24
|
+
end
|
25
|
+
|
26
|
+
describe '#descendant' do
|
27
|
+
it "should find nodes that are nested below the current node" do
|
28
|
+
@results = xpath { |x| x.descendant(:p) }
|
29
|
+
@results[0].text.should == "Blah"
|
30
|
+
@results[1].text.should == "Bax"
|
31
|
+
end
|
32
|
+
|
33
|
+
it "should not find nodes outside the context" do
|
34
|
+
@results = xpath do |x|
|
35
|
+
foo_div = x.descendant(:div).where(x.attr(:id) == 'foo')
|
36
|
+
x.descendant(:p).where(x.attr(:id) == foo_div.attr(:title))
|
37
|
+
end
|
38
|
+
@results[0].should be_nil
|
39
|
+
end
|
40
|
+
|
41
|
+
it "should find multiple kinds of nodes" do
|
42
|
+
@results = xpath { |x| x.descendant(:p, :ul) }
|
43
|
+
@results[0].text.should == 'Blah'
|
44
|
+
@results[3].text.should == 'A list'
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
describe '#child' do
|
49
|
+
it "should find nodes that are nested directly below the current node" do
|
50
|
+
@results = xpath { |x| x.descendant(:div).child(:p) }
|
51
|
+
@results[0].text.should == "Blah"
|
52
|
+
@results[1].text.should == "Bax"
|
53
|
+
end
|
54
|
+
|
55
|
+
it "should not find nodes that are nested further down below the current node" do
|
56
|
+
@results = xpath { |x| x.child(:p) }
|
57
|
+
@results[0].should be_nil
|
58
|
+
end
|
59
|
+
|
60
|
+
it "should find multiple kinds of nodes" do
|
61
|
+
@results = xpath { |x| x.descendant(:div).child(:p, :ul) }
|
62
|
+
@results[0].text.should == 'Blah'
|
63
|
+
@results[3].text.should == 'A list'
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
describe '#next_sibling' do
|
68
|
+
it "should find nodes which are immediate siblings of the current node" do
|
69
|
+
xpath { |x| x.descendant(:p)[x.attr(:id) == 'fooDiv'].next_sibling(:p) }.first.text.should == 'Bax'
|
70
|
+
xpath { |x| x.descendant(:p)[x.attr(:id) == 'fooDiv'].next_sibling(:ul, :p) }.first.text.should == 'Bax'
|
71
|
+
xpath { |x| x.descendant(:p)[x.attr(:title) == 'monkey'].next_sibling(:ul, :p) }.first.text.should == 'A list'
|
72
|
+
xpath { |x| x.descendant(:p)[x.attr(:id) == 'fooDiv'].next_sibling(:ul, :li) }.first.should be_nil
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
describe '#anywhere' do
|
77
|
+
it "should find nodes regardless of the context" do
|
78
|
+
@results = xpath do |x|
|
79
|
+
foo_div = x.anywhere(:div).where(x.attr(:id) == 'foo')
|
80
|
+
x.descendant(:p).where(x.attr(:id) == foo_div.attr(:title))
|
81
|
+
end
|
82
|
+
@results[0].text.should == "Blah"
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
describe '#contains' do
|
87
|
+
it "should find nodes that contain the given string" do
|
88
|
+
@results = xpath do |x|
|
89
|
+
x.descendant(:div).where(x.attr(:title).contains('ooD'))
|
90
|
+
end
|
91
|
+
@results[0][:id].should == "foo"
|
92
|
+
end
|
93
|
+
|
94
|
+
it "should find nodes that contain the given expression" do
|
95
|
+
@results = xpath do |x|
|
96
|
+
expression = x.anywhere(:div).where(x.attr(:title) == 'fooDiv').attr(:id)
|
97
|
+
x.descendant(:div).where(x.attr(:title).contains(expression))
|
98
|
+
end
|
99
|
+
@results[0][:id].should == "foo"
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
describe '#text' do
|
104
|
+
it "should select a node's text" do
|
105
|
+
@results = xpath { |x| x.descendant(:p).where(x.text == 'Bax') }
|
106
|
+
@results[0].text.should == 'Bax'
|
107
|
+
@results[1][:title].should == 'monkey'
|
108
|
+
@results = xpath { |x| x.descendant(:div).where(x.descendant(:p).text == 'Bax') }
|
109
|
+
@results[0][:title].should == 'fooDiv'
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
describe '#where' do
|
114
|
+
it "should limit the expression to find only certain nodes" do
|
115
|
+
xpath { |x| x.descendant(:div).where(:"@id = 'foo'") }.first[:title].should == "fooDiv"
|
116
|
+
end
|
117
|
+
|
118
|
+
it "should be aliased as []" do
|
119
|
+
xpath { |x| x.descendant(:div)[:"@id = 'foo'"] }.first[:title].should == "fooDiv"
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
describe '#inverse' do
|
124
|
+
it "should invert the expression" do
|
125
|
+
xpath { |x| x.descendant(:p).where(x.attr(:id).equals('fooDiv').inverse) }.first.text.should == 'Bax'
|
126
|
+
end
|
127
|
+
|
128
|
+
it "should be aliased as the unary tilde" do
|
129
|
+
xpath { |x| x.descendant(:p).where(~x.attr(:id).equals('fooDiv')) }.first.text.should == 'Bax'
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
describe '#equals' do
|
134
|
+
it "should limit the expression to find only certain nodes" do
|
135
|
+
xpath { |x| x.descendant(:div).where(x.attr(:id).equals('foo')) }.first[:title].should == "fooDiv"
|
136
|
+
end
|
137
|
+
|
138
|
+
it "should be aliased as ==" do
|
139
|
+
xpath { |x| x.descendant(:div).where(x.attr(:id) == 'foo') }.first[:title].should == "fooDiv"
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
describe '#is' do
|
144
|
+
it "should limit the expression to only nodes that contain the given expression" do
|
145
|
+
@results = xpath { |x| x.descendant(:p).where(x.text.is('llama')) }
|
146
|
+
@results[0][:id].should == 'is-fuzzy'
|
147
|
+
@results[1][:id].should == 'is-exact'
|
148
|
+
end
|
149
|
+
|
150
|
+
it "should limit the expression to only nodes that contain the given expression if fuzzy predicate given" do
|
151
|
+
@results = xpath(:fuzzy) { |x| x.descendant(:p).where(x.text.is('llama')) }
|
152
|
+
@results[0][:id].should == 'is-fuzzy'
|
153
|
+
@results[1][:id].should == 'is-exact'
|
154
|
+
end
|
155
|
+
|
156
|
+
it "should limit the expression to only nodes that equal the given expression if exact predicate given" do
|
157
|
+
@results = xpath(:exact) { |x| x.descendant(:p).where(x.text.is('llama')) }
|
158
|
+
@results[0][:id].should == 'is-exact'
|
159
|
+
@results[1].should be_nil
|
160
|
+
end
|
161
|
+
|
162
|
+
context "with to_xpaths" do
|
163
|
+
it "should prefer exact matches" do
|
164
|
+
@xpath = XPath.generate { |x| x.descendant(:p).where(x.text.is('llama')) }
|
165
|
+
@results = @xpath.to_xpaths.map { |path| doc.xpath(path) }.flatten
|
166
|
+
@results[0][:id].should == 'is-exact'
|
167
|
+
@results[1][:id].should == 'is-fuzzy'
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
describe '#one_of' do
|
173
|
+
it "should return all nodes where the condition matches" do
|
174
|
+
@results = xpath do |x|
|
175
|
+
p = x.anywhere(:div).where(x.attr(:id) == 'foo').attr(:title)
|
176
|
+
x.descendant(:*).where(x.attr(:id).one_of('foo', p, 'baz'))
|
177
|
+
end
|
178
|
+
@results[0][:title].should == "fooDiv"
|
179
|
+
@results[1].text.should == "Blah"
|
180
|
+
@results[2][:title].should == "bazDiv"
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
describe '#and' do
|
185
|
+
it "should find all nodes in both expression" do
|
186
|
+
@results = xpath do |x|
|
187
|
+
x.descendant(:*).where(x.contains('Bax').and(x.attr(:title).equals('monkey')))
|
188
|
+
end
|
189
|
+
@results[0][:title].should == "monkey"
|
190
|
+
end
|
191
|
+
|
192
|
+
it "should be aliased as ampersand (&)" do
|
193
|
+
@results = xpath do |x|
|
194
|
+
x.descendant(:*).where(x.contains('Bax') & x.attr(:title).equals('monkey'))
|
195
|
+
end
|
196
|
+
@results[0][:title].should == "monkey"
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
describe '#or' do
|
201
|
+
it "should find all nodes in either expression" do
|
202
|
+
@results = xpath do |x|
|
203
|
+
x.descendant(:*).where(x.attr(:id).equals('foo').or(x.attr(:id).equals('fooDiv')))
|
204
|
+
end
|
205
|
+
@results[0][:title].should == "fooDiv"
|
206
|
+
@results[1].text.should == "Blah"
|
207
|
+
end
|
208
|
+
|
209
|
+
it "should be aliased as pipe (|)" do
|
210
|
+
@results = xpath do |x|
|
211
|
+
x.descendant(:*).where(x.attr(:id).equals('foo') | x.attr(:id).equals('fooDiv'))
|
212
|
+
end
|
213
|
+
@results[0][:title].should == "fooDiv"
|
214
|
+
@results[1].text.should == "Blah"
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
describe '#attr' do
|
219
|
+
it "should be an attribute" do
|
220
|
+
@results = xpath { |x| x.descendant(:div).where(x.attr(:id)) }
|
221
|
+
@results[0][:title].should == "barDiv"
|
222
|
+
@results[1][:title].should == "fooDiv"
|
223
|
+
end
|
224
|
+
|
225
|
+
it "should be closed" do
|
226
|
+
@results = xpath do |x|
|
227
|
+
foo_div = x.anywhere(:div).where(x.attr(:id) == 'foo')
|
228
|
+
id = x.attr(foo_div.attr(:data))
|
229
|
+
x.descendant(:div).where(id == 'bar')
|
230
|
+
end.first[:title].should == "barDiv"
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
describe '#name' do
|
235
|
+
it "should match the node's name" do
|
236
|
+
xpath { |x| x.descendant(:*).where(x.name == 'ul') }.first.text.should == "A list"
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
describe '#apply and #var' do
|
241
|
+
it "should interpolate variables in the xpath expression" do
|
242
|
+
@xpath = XPath.generate do |x|
|
243
|
+
exp = x.descendant(:*).where(x.attr(:id) == x.var(:id).string_literal)
|
244
|
+
end
|
245
|
+
@result1 = doc.xpath(@xpath.apply(:id => 'foo').to_xpath).first
|
246
|
+
@result1[:title].should == 'fooDiv'
|
247
|
+
@result2 = doc.xpath(@xpath.apply(:id => 'baz').to_xpath).first
|
248
|
+
@result2[:title].should == 'bazDiv'
|
249
|
+
end
|
250
|
+
|
251
|
+
it "should raise an argument error if the interpolation key is not given" do
|
252
|
+
@xpath = XPath.generate { |x| x.descendant(:*).where(x.attr(:id) == x.var(:id).string_literal) }
|
253
|
+
lambda { @xpath.apply.to_xpath }.should raise_error(ArgumentError)
|
254
|
+
end
|
255
|
+
end
|
256
|
+
|
257
|
+
describe '#varstring' do
|
258
|
+
it "should add a literal string variable" do
|
259
|
+
@xpath = XPath.generate { |x| x.descendant(:*).where(x.attr(:id) == x.varstring(:id)) }
|
260
|
+
@result1 = doc.xpath(@xpath.apply(:id => 'foo').to_xpath).first
|
261
|
+
@result1[:title].should == 'fooDiv'
|
262
|
+
end
|
263
|
+
end
|
264
|
+
|
265
|
+
describe '#normalize' do
|
266
|
+
it "should normalize whitespace" do
|
267
|
+
xpath { |x| x.descendant(:p).where(x.text.normalize == 'A lot of whitespace') }.first[:id].should == "whitespace"
|
268
|
+
end
|
269
|
+
|
270
|
+
it "should be aliased as 'n'" do
|
271
|
+
xpath { |x| x.descendant(:p).where(x.text.n == 'A lot of whitespace') }.first[:id].should == "whitespace"
|
272
|
+
end
|
273
|
+
end
|
274
|
+
|
275
|
+
describe '#union' do
|
276
|
+
it "should create a union expression" do
|
277
|
+
@expr1 = XPath.generate { |x| x.descendant(:p) }
|
278
|
+
@expr2 = XPath.generate { |x| x.descendant(:div) }
|
279
|
+
@collection = @expr1.union(@expr2)
|
280
|
+
@xpath1 = @collection.where(XPath.attr(:id) == 'foo').to_xpath
|
281
|
+
@xpath2 = @collection.where(XPath.attr(:id) == XPath.varstring(:id)).apply(:id => 'fooDiv').to_xpath
|
282
|
+
@results = doc.xpath(@xpath1)
|
283
|
+
@results[0][:title].should == 'fooDiv'
|
284
|
+
@results = doc.xpath(@xpath2)
|
285
|
+
@results[0][:id].should == 'fooDiv'
|
286
|
+
end
|
287
|
+
|
288
|
+
it "should be aliased as +" do
|
289
|
+
@expr1 = XPath.generate { |x| x.descendant(:p) }
|
290
|
+
@expr2 = XPath.generate { |x| x.descendant(:div) }
|
291
|
+
@collection = @expr1 + @expr2
|
292
|
+
@xpath1 = @collection.where(XPath.attr(:id) == 'foo').to_xpath
|
293
|
+
@xpath2 = @collection.where(XPath.attr(:id) == XPath.varstring(:id)).apply(:id => 'fooDiv').to_xpath
|
294
|
+
@results = doc.xpath(@xpath1)
|
295
|
+
@results[0][:title].should == 'fooDiv'
|
296
|
+
@results = doc.xpath(@xpath2)
|
297
|
+
@results[0][:id].should == 'fooDiv'
|
298
|
+
end
|
299
|
+
end
|
300
|
+
|
301
|
+
end
|
metadata
ADDED
@@ -0,0 +1,127 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: xpath
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
hash: 27
|
5
|
+
prerelease: false
|
6
|
+
segments:
|
7
|
+
- 0
|
8
|
+
- 1
|
9
|
+
- 0
|
10
|
+
version: 0.1.0
|
11
|
+
platform: ruby
|
12
|
+
authors:
|
13
|
+
- Jonas Nicklas
|
14
|
+
autorequire:
|
15
|
+
bindir: bin
|
16
|
+
cert_chain: []
|
17
|
+
|
18
|
+
date: 2010-08-15 00:00:00 +02:00
|
19
|
+
default_executable:
|
20
|
+
dependencies:
|
21
|
+
- !ruby/object:Gem::Dependency
|
22
|
+
name: rspec
|
23
|
+
prerelease: false
|
24
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ">="
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
hash: 13
|
30
|
+
segments:
|
31
|
+
- 1
|
32
|
+
- 2
|
33
|
+
- 9
|
34
|
+
version: 1.2.9
|
35
|
+
type: :development
|
36
|
+
version_requirements: *id001
|
37
|
+
- !ruby/object:Gem::Dependency
|
38
|
+
name: nokogiri
|
39
|
+
prerelease: false
|
40
|
+
requirement: &id002 !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ">="
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
hash: 29
|
46
|
+
segments:
|
47
|
+
- 1
|
48
|
+
- 3
|
49
|
+
- 3
|
50
|
+
version: 1.3.3
|
51
|
+
type: :development
|
52
|
+
version_requirements: *id002
|
53
|
+
- !ruby/object:Gem::Dependency
|
54
|
+
name: yard
|
55
|
+
prerelease: false
|
56
|
+
requirement: &id003 !ruby/object:Gem::Requirement
|
57
|
+
none: false
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
hash: 27
|
62
|
+
segments:
|
63
|
+
- 0
|
64
|
+
- 5
|
65
|
+
- 8
|
66
|
+
version: 0.5.8
|
67
|
+
type: :development
|
68
|
+
version_requirements: *id003
|
69
|
+
description: XPath is a Ruby DSL for generating XPath expressions
|
70
|
+
email:
|
71
|
+
- jonas.nicklas@gmail.com
|
72
|
+
executables: []
|
73
|
+
|
74
|
+
extensions: []
|
75
|
+
|
76
|
+
extra_rdoc_files:
|
77
|
+
- README.rdoc
|
78
|
+
files:
|
79
|
+
- lib/xpath/expression.rb
|
80
|
+
- lib/xpath/html.rb
|
81
|
+
- lib/xpath/union.rb
|
82
|
+
- lib/xpath/version.rb
|
83
|
+
- lib/xpath.rb
|
84
|
+
- spec/fixtures/form.html
|
85
|
+
- spec/fixtures/simple.html
|
86
|
+
- spec/fixtures/stuff.html
|
87
|
+
- spec/spec_helper.rb
|
88
|
+
- spec/union_spec.rb
|
89
|
+
- spec/xpath_spec.rb
|
90
|
+
- README.rdoc
|
91
|
+
has_rdoc: true
|
92
|
+
homepage: http://github.com/jnicklas/xpath
|
93
|
+
licenses: []
|
94
|
+
|
95
|
+
post_install_message:
|
96
|
+
rdoc_options:
|
97
|
+
- --main
|
98
|
+
- README.rdoc
|
99
|
+
require_paths:
|
100
|
+
- lib
|
101
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
102
|
+
none: false
|
103
|
+
requirements:
|
104
|
+
- - ">="
|
105
|
+
- !ruby/object:Gem::Version
|
106
|
+
hash: 3
|
107
|
+
segments:
|
108
|
+
- 0
|
109
|
+
version: "0"
|
110
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
111
|
+
none: false
|
112
|
+
requirements:
|
113
|
+
- - ">="
|
114
|
+
- !ruby/object:Gem::Version
|
115
|
+
hash: 3
|
116
|
+
segments:
|
117
|
+
- 0
|
118
|
+
version: "0"
|
119
|
+
requirements: []
|
120
|
+
|
121
|
+
rubyforge_project: xpath
|
122
|
+
rubygems_version: 1.3.7
|
123
|
+
signing_key:
|
124
|
+
specification_version: 3
|
125
|
+
summary: Generate XPath expressions from Ruby
|
126
|
+
test_files: []
|
127
|
+
|