rspec-html-matchers 0.7.0 → 0.10.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.
- checksums.yaml +5 -5
- data/CHANGELOG.md +79 -12
- data/README.md +63 -57
- data/features/step_definitions/steps.rb +3 -1
- data/features/support/env.rb +22 -3
- data/lib/rspec-html-matchers/have_tag.rb +290 -0
- data/lib/rspec-html-matchers/nokogiri_regexp_helper.rb +17 -0
- data/lib/rspec-html-matchers/nokogiri_text_helper.rb +24 -0
- data/lib/rspec-html-matchers/version.rb +5 -0
- data/lib/rspec-html-matchers.rb +167 -331
- data/spec/form_matchers_spec.rb +132 -130
- data/spec/have_empty_tag_spec.rb +31 -0
- data/spec/have_tag_spec.rb +281 -167
- data/spec/issues_spec.rb +17 -0
- data/spec/spec_helper.rb +5 -4
- data/spec/support/asset_helpers.rb +5 -5
- data/spec/support/raise_spec_error_helper.rb +9 -7
- metadata +74 -36
@@ -0,0 +1,290 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'nokogiri'
|
5
|
+
|
6
|
+
module RSpecHtmlMatchers
|
7
|
+
# @api
|
8
|
+
# @private
|
9
|
+
class HaveTag # rubocop:disable Metrics/ClassLength
|
10
|
+
DESCRIPTIONS = {
|
11
|
+
:have_at_least_1 => %(have at least 1 element matching "%s"),
|
12
|
+
:have_n => %(have %i element(s) matching "%s"),
|
13
|
+
}.freeze
|
14
|
+
|
15
|
+
MESSAGES = {
|
16
|
+
:expected_tag => %(expected following:\n%s\nto #{DESCRIPTIONS[:have_at_least_1]}, found 0.),
|
17
|
+
:unexpected_tag => %(expected following:\n%s\nto NOT have element matching "%s", found %s.),
|
18
|
+
|
19
|
+
:expected_count => %(expected following:\n%s\nto #{DESCRIPTIONS[:have_n]}, found %s.),
|
20
|
+
:unexpected_count => %(expected following:\n%s\nto NOT have %i element(s) matching "%s", but found.),
|
21
|
+
|
22
|
+
:expected_btw_count => %(expected following:\n%s\nto have at least %i and at most %i element(s) matching "%s", found %i.),
|
23
|
+
:unexpected_btw_count => %(expected following:\n%s\nto NOT have at least %i and at most %i element(s) matching "%s", but found %i.),
|
24
|
+
|
25
|
+
:expected_at_most => %(expected following:\n%s\nto have at most %i element(s) matching "%s", found %i.),
|
26
|
+
:unexpected_at_most => %(expected following:\n%s\nto NOT have at most %i element(s) matching "%s", but found %i.),
|
27
|
+
|
28
|
+
:expected_at_least => %(expected following:\n%s\nto have at least %i element(s) matching "%s", found %i.),
|
29
|
+
:unexpected_at_least => %(expected following:\n%s\nto NOT have at least %i element(s) matching "%s", but found %i.),
|
30
|
+
|
31
|
+
:expected_blank => %(expected following template to contain empty tag %s:\n%s),
|
32
|
+
:unexpected_blank => %(expected following template to contain tag %s with other tags:\n%s),
|
33
|
+
|
34
|
+
:expected_regexp => %(%s regexp expected within "%s" in following template:\n%s),
|
35
|
+
:unexpected_regexp => %(%s regexp unexpected within "%s" in following template:\n%s\nbut was found.),
|
36
|
+
|
37
|
+
:expected_text => %("%s" expected within "%s" in following template:\n%s),
|
38
|
+
:unexpected_text => %("%s" unexpected within "%s" in following template:\n%s\nbut was found.),
|
39
|
+
|
40
|
+
:wrong_count_error => %(:count with :minimum or :maximum has no sence!),
|
41
|
+
:min_max_error => %(:minimum should be less than :maximum!),
|
42
|
+
:bad_range_error => %(Your :count range(%s) has no sence!),
|
43
|
+
}.freeze
|
44
|
+
|
45
|
+
def initialize tag, options = {}, &block
|
46
|
+
@tag = tag.to_s
|
47
|
+
@options = options
|
48
|
+
@block = block
|
49
|
+
|
50
|
+
if with_attrs = @options.delete(:with)
|
51
|
+
if classes = with_attrs.delete(:class)
|
52
|
+
@tag += '.' + classes_to_selector(classes)
|
53
|
+
end
|
54
|
+
selector = with_attrs.inject('') do |html_attrs_string, (k, v)|
|
55
|
+
html_attrs_string += "[#{k}='#{v}']"
|
56
|
+
html_attrs_string
|
57
|
+
end
|
58
|
+
@tag += selector
|
59
|
+
end
|
60
|
+
|
61
|
+
if without_attrs = @options.delete(:without)
|
62
|
+
if classes = without_attrs.delete(:class)
|
63
|
+
@tag += ":not(.#{classes_to_selector(classes)})"
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
validate_options!
|
68
|
+
organize_options!
|
69
|
+
end
|
70
|
+
|
71
|
+
attr_reader :failure_message
|
72
|
+
attr_reader :failure_message_when_negated
|
73
|
+
attr_reader :current_scope
|
74
|
+
|
75
|
+
def matches? src, &block
|
76
|
+
@block = block if block
|
77
|
+
|
78
|
+
src = src.html if defined?(Capybara::Session) && src.is_a?(Capybara::Session)
|
79
|
+
|
80
|
+
case src
|
81
|
+
when String
|
82
|
+
parent_scope = Nokogiri::HTML(src)
|
83
|
+
@document = src
|
84
|
+
else
|
85
|
+
parent_scope = src.current_scope
|
86
|
+
@document = parent_scope.to_html
|
87
|
+
end
|
88
|
+
|
89
|
+
@current_scope = begin
|
90
|
+
parent_scope.css(tag)
|
91
|
+
# on jruby this produce exception if css was not found:
|
92
|
+
# undefined method `decorate' for nil:NilClass
|
93
|
+
rescue NoMethodError
|
94
|
+
Nokogiri::XML::NodeSet.new(Nokogiri::XML::Document.new)
|
95
|
+
end
|
96
|
+
if tag_presents? && proper_content? && count_right?
|
97
|
+
@block.call(self) if @block
|
98
|
+
true
|
99
|
+
else
|
100
|
+
false
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def description
|
105
|
+
# TODO: should it be more complicated?
|
106
|
+
if options.key?(:count)
|
107
|
+
format(DESCRIPTIONS[:have_n], options[:count], tag)
|
108
|
+
else
|
109
|
+
DESCRIPTIONS[:have_at_least_1] % tag
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
private
|
114
|
+
|
115
|
+
attr_reader :tag
|
116
|
+
attr_reader :options
|
117
|
+
attr_reader :document
|
118
|
+
|
119
|
+
def classes_to_selector classes
|
120
|
+
case classes
|
121
|
+
when Array
|
122
|
+
classes.join('.')
|
123
|
+
when String
|
124
|
+
classes.gsub(/\s+/, '.')
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
def tag_presents?
|
129
|
+
if current_scope.first
|
130
|
+
@count = current_scope.count
|
131
|
+
match_succeeded! :unexpected_tag, document, tag, @count
|
132
|
+
else
|
133
|
+
match_failed! :expected_tag, document, tag
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
def count_right? # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
138
|
+
case options[:count]
|
139
|
+
when Integer
|
140
|
+
if @count == options[:count]
|
141
|
+
match_succeeded! :unexpected_count, document, @count, tag
|
142
|
+
else
|
143
|
+
match_failed! :expected_count, document, options[:count], tag, @count
|
144
|
+
end
|
145
|
+
when Range
|
146
|
+
if options[:count].member? @count
|
147
|
+
match_succeeded! :unexpected_btw_count, document, options[:count].min, options[:count].max, tag, @count
|
148
|
+
else
|
149
|
+
match_failed! :expected_btw_count, document, options[:count].min, options[:count].max, tag, @count
|
150
|
+
end
|
151
|
+
when nil
|
152
|
+
if options[:maximum]
|
153
|
+
if @count <= options[:maximum]
|
154
|
+
match_succeeded! :unexpected_at_most, document, options[:maximum], tag, @count
|
155
|
+
else
|
156
|
+
match_failed! :expected_at_most, document, options[:maximum], tag, @count
|
157
|
+
end
|
158
|
+
elsif options[:minimum]
|
159
|
+
if @count >= options[:minimum]
|
160
|
+
match_succeeded! :unexpected_at_least, document, options[:minimum], tag, @count
|
161
|
+
else
|
162
|
+
match_failed! :expected_at_least, document, options[:minimum], tag, @count
|
163
|
+
end
|
164
|
+
else
|
165
|
+
true
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
def proper_content?
|
171
|
+
if options.key?(:blank)
|
172
|
+
maybe_empty?
|
173
|
+
else
|
174
|
+
text_right?
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
def maybe_empty?
|
179
|
+
if options[:blank] && current_scope.children.empty?
|
180
|
+
match_succeeded! :unexpected_blank, tag, document
|
181
|
+
else
|
182
|
+
match_failed! :expected_blank, tag, document
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
def text_right?
|
187
|
+
return true unless options[:text]
|
188
|
+
|
189
|
+
case text = options[:text]
|
190
|
+
when Regexp
|
191
|
+
new_scope = current_scope.css(':regexp()', NokogiriRegexpHelper.new(text))
|
192
|
+
if new_scope.empty?
|
193
|
+
match_failed! :expected_regexp, text.inspect, tag, document
|
194
|
+
else
|
195
|
+
@count = new_scope.count
|
196
|
+
match_succeeded! :unexpected_regexp, text.inspect, tag, document
|
197
|
+
end
|
198
|
+
else
|
199
|
+
new_scope = current_scope.css(':content()', NokogiriTextHelper.new(text, options[:squeeze_text]))
|
200
|
+
if new_scope.empty?
|
201
|
+
match_failed! :expected_text, text, tag, document
|
202
|
+
else
|
203
|
+
@count = new_scope.count
|
204
|
+
match_succeeded! :unexpected_text, text, tag, document
|
205
|
+
end
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
def validate_options!
|
210
|
+
validate_html_body_tags!
|
211
|
+
validate_text_options!
|
212
|
+
validate_count_presence!
|
213
|
+
validate_count_when_set_min_max!
|
214
|
+
validate_count_when_set_range!
|
215
|
+
end
|
216
|
+
|
217
|
+
# here is a demo:
|
218
|
+
# irb(main):009:0> Nokogiri::HTML('<p>asd</p>').xpath('//html')
|
219
|
+
# => [#<Nokogiri::XML::Element:0x3fea02cd3f58 name="html" children=[#<Nokogiri::XML::Element:0x3fea02cd37c4 name="body" children=[#<Nokogiri::XML::Element:0x3fea02cd34e0 name="p" children=[#<Nokogiri::XML::Text:0x3fea02cd3134 "asd">]>]>]>]
|
220
|
+
# irb(main):010:0> Nokogiri::HTML('<p>asd</p>').xpath('//body')
|
221
|
+
# => [#<Nokogiri::XML::Element:0x3fea02ce3df4 name="body" children=[#<Nokogiri::XML::Element:0x3fea02ce3a70 name="p" children=[#<Nokogiri::XML::Text:0x3fea02ce350c "asd">]>]>]
|
222
|
+
# irb(main):011:0> Nokogiri::HTML('<p>asd</p>').xpath('//p')
|
223
|
+
# => [#<Nokogiri::XML::Element:0x3fea02cf3754 name="p" children=[#<Nokogiri::XML::Text:0x3fea02cf2f98 "asd">]>]
|
224
|
+
# irb(main):012:0> Nokogiri::HTML('<p>asd</p>').xpath('//a')
|
225
|
+
# => []
|
226
|
+
def validate_html_body_tags!
|
227
|
+
if %w[html body].include?(tag) && options.empty?
|
228
|
+
raise ArgumentError, 'matching <html> and <body> tags without specifying additional options does not work, see: https://github.com/kucaahbe/rspec-html-matchers/pull/75'
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
def validate_text_options!
|
233
|
+
# TODO: test these options validations
|
234
|
+
if options.key?(:blank) && options[:blank] && options.key?(:text) # rubocop:disable Style/GuardClause, Style/IfUnlessModifier
|
235
|
+
raise ':text option is not accepted when :blank => true'
|
236
|
+
end
|
237
|
+
end
|
238
|
+
|
239
|
+
def validate_count_presence!
|
240
|
+
raise 'wrong :count specified' unless [Range, NilClass].include?(options[:count].class) || options[:count].is_a?(Integer)
|
241
|
+
|
242
|
+
[:min, :minimum, :max, :maximum].each do |key|
|
243
|
+
raise MESSAGES[:wrong_count_error] if options.key?(key) && options.key?(:count)
|
244
|
+
end
|
245
|
+
end
|
246
|
+
|
247
|
+
def validate_count_when_set_min_max!
|
248
|
+
raise MESSAGES[:min_max_error] if options[:minimum] > options[:maximum]
|
249
|
+
rescue NoMethodError # nil > 4 # rubocop:disable Lint/HandleExceptions
|
250
|
+
rescue ArgumentError # 2 < nil # rubocop:disable Lint/HandleExceptions
|
251
|
+
end
|
252
|
+
|
253
|
+
def validate_count_when_set_range!
|
254
|
+
begin
|
255
|
+
raise format(MESSAGES[:bad_range_error], options[:count].to_s) if count_is_range_but_no_min?
|
256
|
+
rescue ArgumentError, 'comparison of String with' # if options[:count] == 'a'..'z' # rubocop:disable Lint/RescueType
|
257
|
+
raise format(MESSAGES[:bad_range_error], options[:count].to_s)
|
258
|
+
end
|
259
|
+
rescue TypeError # fix for 1.8.7 for 'rescue ArgumentError, "comparison of String with"' stroke
|
260
|
+
raise format(MESSAGES[:bad_range_error], options[:count].to_s)
|
261
|
+
end
|
262
|
+
|
263
|
+
def count_is_range_but_no_min?
|
264
|
+
options[:count].is_a?(Range) &&
|
265
|
+
(options[:count].min.nil? || (options[:count].min < 0))
|
266
|
+
end
|
267
|
+
|
268
|
+
def organize_options!
|
269
|
+
@options[:minimum] ||= @options.delete(:min)
|
270
|
+
@options[:maximum] ||= @options.delete(:max)
|
271
|
+
|
272
|
+
@options[:text] = @options[:text].to_s if @options.key?(:text) && !@options[:text].is_a?(Regexp)
|
273
|
+
|
274
|
+
if @options.key?(:seen) && !@options[:seen].is_a?(Regexp) # rubocop:disable Style/GuardClause
|
275
|
+
@options[:text] = @options[:seen].to_s
|
276
|
+
@options[:squeeze_text] = true
|
277
|
+
end
|
278
|
+
end
|
279
|
+
|
280
|
+
def match_succeeded! message, *args
|
281
|
+
@failure_message_when_negated = format MESSAGES[message], *args
|
282
|
+
true
|
283
|
+
end
|
284
|
+
|
285
|
+
def match_failed! message, *args
|
286
|
+
@failure_message = format MESSAGES[message], *args
|
287
|
+
false
|
288
|
+
end
|
289
|
+
end
|
290
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module RSpecHtmlMatchers
|
5
|
+
# @api
|
6
|
+
# @private
|
7
|
+
# for nokogiri regexp matching
|
8
|
+
class NokogiriRegexpHelper
|
9
|
+
def initialize regex
|
10
|
+
@regex = regex
|
11
|
+
end
|
12
|
+
|
13
|
+
def regexp node_set
|
14
|
+
node_set.find_all { |node| node.content =~ @regex }
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module RSpecHtmlMatchers
|
5
|
+
# @api
|
6
|
+
# @private
|
7
|
+
class NokogiriTextHelper
|
8
|
+
NON_BREAKING_SPACE = "\u00a0"
|
9
|
+
|
10
|
+
def initialize text, squeeze_text = false
|
11
|
+
@text = text
|
12
|
+
@squeeze_text = squeeze_text
|
13
|
+
end
|
14
|
+
|
15
|
+
def content node_set
|
16
|
+
node_set.find_all do |node|
|
17
|
+
actual_content = node.content.gsub(NON_BREAKING_SPACE, ' ')
|
18
|
+
actual_content = node.content.gsub(/\s+/, ' ').strip if @squeeze_text
|
19
|
+
|
20
|
+
actual_content == @text
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|