dommy-rails 0.9.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.
@@ -0,0 +1,793 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Rails
5
+ module RSpec
6
+ module Matchers
7
+ def have_form(action: nil, method: nil, model: nil)
8
+ HaveForm.new(action: action, method: method, model: model)
9
+ end
10
+
11
+ def have_xpath(expression, text: nil, count: nil)
12
+ HaveXPath.new(expression, text: text, count: count)
13
+ end
14
+
15
+ def have_title(expected)
16
+ HaveTitle.new(expected)
17
+ end
18
+
19
+ def match_aria_snapshot(expected)
20
+ MatchAriaSnapshot.new(expected)
21
+ end
22
+
23
+ def have_meta(name: nil, property: nil, content: nil)
24
+ HaveMeta.new(name: name, property: property, content: content)
25
+ end
26
+
27
+ def have_csrf_meta_tags
28
+ HaveCsrfMetaTags.new
29
+ end
30
+
31
+ def have_authenticity_token
32
+ HaveAuthenticityToken.new
33
+ end
34
+
35
+ def have_link(text = nil, href: nil, count: nil)
36
+ HaveLink.new(text, href: href, count: count)
37
+ end
38
+
39
+ def have_turbo_frame(id = nil, text: nil, count: nil)
40
+ HaveTurboFrame.new(id, text: text, count: count)
41
+ end
42
+
43
+ def have_select(name = nil, label: nil, count: nil)
44
+ HaveSelect.new(name, label: label, count: count)
45
+ end
46
+
47
+ def have_checked_field(name = nil)
48
+ HaveCheckableField.new(name, checked: true)
49
+ end
50
+
51
+ def have_unchecked_field(name = nil)
52
+ HaveCheckableField.new(name, checked: false)
53
+ end
54
+
55
+ def have_turbo_stream(action:, target:)
56
+ HaveTurboStream.new(action: action, target: target)
57
+ end
58
+
59
+ def append_turbo_stream(target)
60
+ HaveTurboStream.new(action: "append", target: target)
61
+ end
62
+
63
+ def replace_turbo_stream(target)
64
+ HaveTurboStream.new(action: "replace", target: target)
65
+ end
66
+
67
+ def update_turbo_stream(target)
68
+ HaveTurboStream.new(action: "update", target: target)
69
+ end
70
+
71
+ def remove_turbo_stream(target)
72
+ HaveTurboStream.new(action: "remove", target: target)
73
+ end
74
+
75
+ def have_stimulus_controller(name)
76
+ HaveStimulusController.new(name)
77
+ end
78
+
79
+ def have_stimulus_action(action)
80
+ HaveStimulusAction.new(action)
81
+ end
82
+
83
+ def have_stimulus_target(controller, target)
84
+ HaveStimulusTarget.new(controller, target)
85
+ end
86
+
87
+ def have_stimulus_value(controller, key, value)
88
+ HaveStimulusValue.new(controller, key, value)
89
+ end
90
+
91
+ def have_no_duplicate_ids
92
+ HaveNoDuplicateIds.new
93
+ end
94
+
95
+ def have_no_invalid_aria_references
96
+ HaveNoInvalidAriaReferences.new
97
+ end
98
+
99
+ def have_no_missing_form_labels
100
+ HaveNoMissingFormLabels.new
101
+ end
102
+
103
+ def have_no_empty_links
104
+ HaveNoEmptyLinks.new
105
+ end
106
+
107
+ def have_no_nested_interactive_elements
108
+ HaveNoNestedInteractiveElements.new
109
+ end
110
+
111
+ def have_html_link(text = nil, href: nil, count: nil)
112
+ HaveHtmlLink.new(text, href: href, count: count)
113
+ end
114
+
115
+ def have_html_text(text)
116
+ HaveHtmlText.new(text)
117
+ end
118
+
119
+ def have_plain_text(text)
120
+ HavePlainText.new(text)
121
+ end
122
+
123
+ class HaveForm
124
+ def initialize(action:, method:, model:)
125
+ @action = action
126
+ @method = method
127
+ @model = model
128
+ end
129
+
130
+ def matches?(actual)
131
+ @document = MatchTarget.document(actual)
132
+ Dommy::Rails::FormInspector.matches?(@document, action: @action, method: @method, model: @model)
133
+ end
134
+
135
+ def does_not_match?(actual)
136
+ !matches?(actual)
137
+ end
138
+
139
+ def description
140
+ "have form #{criteria_description}"
141
+ end
142
+
143
+ def failure_message
144
+ "expected to find form #{criteria_description}"
145
+ end
146
+
147
+ def failure_message_when_negated
148
+ "expected not to find form #{criteria_description}"
149
+ end
150
+
151
+ private
152
+
153
+ def criteria_description
154
+ parts = []
155
+ parts << "action=#{@action.inspect}" if @action
156
+ parts << "method=#{@method.inspect}" if @method
157
+ parts << "model=#{@model.inspect}" if @model
158
+ parts.empty? ? "matching any criteria" : parts.join(" ")
159
+ end
160
+ end
161
+
162
+ class HaveXPath
163
+ def initialize(expression, text:, count:)
164
+ @expression = expression
165
+ @text = text
166
+ @count = count
167
+ end
168
+
169
+ def matches?(actual)
170
+ @matched = Dommy::Rails::PageInspector.xpath_matches(MatchTarget.document(actual), @expression, text: @text)
171
+ Dommy::Internal::DomMatching.count_matches?(@matched.size, @count)
172
+ end
173
+
174
+ def does_not_match?(actual)
175
+ !matches?(actual)
176
+ end
177
+
178
+ def description
179
+ "have XPath #{@expression.inspect}"
180
+ end
181
+
182
+ def failure_message
183
+ "expected to find XPath #{@expression.inspect}, found #{@matched.size}"
184
+ end
185
+
186
+ def failure_message_when_negated
187
+ "expected not to find XPath #{@expression.inspect}, found #{@matched.size}"
188
+ end
189
+ end
190
+
191
+ class HaveTitle
192
+ def initialize(expected)
193
+ @expected = expected
194
+ end
195
+
196
+ def matches?(actual)
197
+ @document = MatchTarget.document(actual)
198
+ Dommy::Rails::PageInspector.title_matches?(@document, @expected)
199
+ end
200
+
201
+ def does_not_match?(actual)
202
+ !matches?(actual)
203
+ end
204
+
205
+ def description
206
+ "have title #{@expected.inspect}"
207
+ end
208
+
209
+ def failure_message
210
+ "expected title to match #{@expected.inspect}, got #{@document.title.inspect}"
211
+ end
212
+
213
+ def failure_message_when_negated
214
+ "expected title not to match #{@expected.inspect}"
215
+ end
216
+ end
217
+
218
+ # Playwright-style ARIA snapshot subset match. `expected` is a template
219
+ # snapshot (its nodes must appear in the actual tree, in order); names
220
+ # may be `/regex/`.
221
+ class MatchAriaSnapshot
222
+ def initialize(expected)
223
+ @expected = expected
224
+ end
225
+
226
+ def matches?(actual)
227
+ @actual = MatchTarget.document(actual).aria_snapshot
228
+ Dommy::Rails::AriaSnapshotMatching.matches?(@actual, @expected)
229
+ end
230
+
231
+ def does_not_match?(actual)
232
+ !matches?(actual)
233
+ end
234
+
235
+ def description
236
+ "match aria snapshot"
237
+ end
238
+
239
+ def failure_message
240
+ "expected aria snapshot to match:\n#{@expected}\ngot:\n#{@actual}"
241
+ end
242
+
243
+ def failure_message_when_negated
244
+ "expected aria snapshot not to match:\n#{@expected}"
245
+ end
246
+ end
247
+
248
+ class HaveMeta
249
+ def initialize(name:, property:, content:)
250
+ @name = name
251
+ @property = property
252
+ @content = content
253
+ end
254
+
255
+ def matches?(actual)
256
+ Dommy::Rails::PageInspector.meta_matches?(MatchTarget.document(actual), name: @name, property: @property, content: @content)
257
+ end
258
+
259
+ def does_not_match?(actual)
260
+ !matches?(actual)
261
+ end
262
+
263
+ def description
264
+ "have meta #{criteria_description}"
265
+ end
266
+
267
+ def failure_message
268
+ "expected to find meta #{criteria_description}"
269
+ end
270
+
271
+ def failure_message_when_negated
272
+ "expected not to find meta #{criteria_description}"
273
+ end
274
+
275
+ private
276
+
277
+ def criteria_description
278
+ parts = []
279
+ parts << "name=#{@name.inspect}" if @name
280
+ parts << "property=#{@property.inspect}" if @property
281
+ parts << "content=#{@content.inspect}" if @content
282
+ parts.empty? ? "matching any criteria" : parts.join(" ")
283
+ end
284
+ end
285
+
286
+ class HaveCsrfMetaTags
287
+ def matches?(actual)
288
+ Dommy::Rails::PageInspector.csrf_meta_tags?(MatchTarget.document(actual))
289
+ end
290
+
291
+ def does_not_match?(actual)
292
+ !matches?(actual)
293
+ end
294
+
295
+ def description
296
+ "have Rails CSRF meta tags"
297
+ end
298
+
299
+ def failure_message
300
+ "expected to find Rails CSRF meta tags"
301
+ end
302
+
303
+ def failure_message_when_negated
304
+ "expected not to find Rails CSRF meta tags"
305
+ end
306
+ end
307
+
308
+ class HaveAuthenticityToken
309
+ def matches?(actual)
310
+ Dommy::Rails::PageInspector.authenticity_token?(MatchTarget.document(actual))
311
+ end
312
+
313
+ def does_not_match?(actual)
314
+ !matches?(actual)
315
+ end
316
+
317
+ def description
318
+ "have Rails authenticity token field"
319
+ end
320
+
321
+ def failure_message
322
+ "expected to find Rails authenticity token field"
323
+ end
324
+
325
+ def failure_message_when_negated
326
+ "expected not to find Rails authenticity token field"
327
+ end
328
+ end
329
+
330
+ class HaveLink
331
+ def initialize(text, href:, count:)
332
+ @text = text
333
+ @href = href
334
+ @count = count
335
+ end
336
+
337
+ def matches?(actual)
338
+ @matched = Dommy::Rails::PageInspector.links(MatchTarget.document(actual), text: @text, href: @href)
339
+ Dommy::Internal::DomMatching.count_matches?(@matched.size, @count)
340
+ end
341
+
342
+ def does_not_match?(actual)
343
+ !matches?(actual)
344
+ end
345
+
346
+ def description
347
+ "have link"
348
+ end
349
+
350
+ def failure_message
351
+ "expected to find link, found #{@matched.size}"
352
+ end
353
+
354
+ def failure_message_when_negated
355
+ "expected not to find link, found #{@matched.size}"
356
+ end
357
+ end
358
+
359
+ class HaveTurboFrame
360
+ def initialize(id, text:, count:)
361
+ @id = id
362
+ @text = text
363
+ @count = count
364
+ end
365
+
366
+ def matches?(actual, &block)
367
+ @matched = Dommy::Rails::PageInspector.turbo_frames(MatchTarget.document(actual), @id, text: @text)
368
+ ok = Dommy::Internal::DomMatching.count_matches?(@matched.size, @count)
369
+ block.call(@matched.first) if ok && block && @matched.any?
370
+ ok
371
+ end
372
+
373
+ def does_not_match?(actual)
374
+ !matches?(actual)
375
+ end
376
+
377
+ def description
378
+ "have turbo-frame#{@id ? " ##{@id}" : ""}"
379
+ end
380
+
381
+ def failure_message
382
+ "expected to find turbo-frame#{@id ? " ##{@id}" : ""}, found #{@matched.size}"
383
+ end
384
+
385
+ def failure_message_when_negated
386
+ "expected not to find turbo-frame#{@id ? " ##{@id}" : ""}, found #{@matched.size}"
387
+ end
388
+ end
389
+
390
+ class HaveSelect
391
+ def initialize(name, label:, count:)
392
+ @name = name
393
+ @label = label
394
+ @count = count
395
+ end
396
+
397
+ def matches?(actual)
398
+ @matched = Dommy::Rails::PageInspector.selects(MatchTarget.document(actual), name: @name, label: @label)
399
+ Dommy::Internal::DomMatching.count_matches?(@matched.size, @count)
400
+ end
401
+
402
+ def does_not_match?(actual)
403
+ !matches?(actual)
404
+ end
405
+
406
+ def description
407
+ "have select"
408
+ end
409
+
410
+ def failure_message
411
+ "expected to find select, found #{@matched.size}"
412
+ end
413
+
414
+ def failure_message_when_negated
415
+ "expected not to find select, found #{@matched.size}"
416
+ end
417
+ end
418
+
419
+ class HaveCheckableField
420
+ def initialize(name, checked:)
421
+ @name = name
422
+ @checked = checked
423
+ end
424
+
425
+ def matches?(actual)
426
+ @matched = Dommy::Rails::PageInspector.checkable_fields(MatchTarget.document(actual), name: @name, checked: @checked)
427
+ @matched.any?
428
+ end
429
+
430
+ def does_not_match?(actual)
431
+ !matches?(actual)
432
+ end
433
+
434
+ def description
435
+ "have #{@checked ? 'checked' : 'unchecked'} field"
436
+ end
437
+
438
+ def failure_message
439
+ "expected to find #{@checked ? 'checked' : 'unchecked'} field"
440
+ end
441
+
442
+ def failure_message_when_negated
443
+ "expected not to find #{@checked ? 'checked' : 'unchecked'} field"
444
+ end
445
+ end
446
+
447
+ class HaveTurboStream
448
+ def initialize(action:, target:)
449
+ @action = action
450
+ @target = target
451
+ end
452
+
453
+ def matches?(actual, &block)
454
+ stream = Dommy::Rails::TurboStream.find(MatchTarget.body(actual), action: @action, target: @target)
455
+ block.call(Dommy::Rails::TurboStream.fragment_document(stream)) if stream && block
456
+ !stream.nil?
457
+ end
458
+
459
+ def does_not_match?(actual)
460
+ !matches?(actual)
461
+ end
462
+
463
+ def description
464
+ "have turbo-stream action=#{@action.inspect} target=#{@target.inspect}"
465
+ end
466
+
467
+ def failure_message
468
+ "expected to find turbo-stream action=#{@action.inspect} target=#{@target.inspect}"
469
+ end
470
+
471
+ def failure_message_when_negated
472
+ "expected not to find turbo-stream action=#{@action.inspect} target=#{@target.inspect}"
473
+ end
474
+ end
475
+
476
+ class HaveStimulusController
477
+ def initialize(name)
478
+ @name = name
479
+ end
480
+
481
+ def matches?(actual)
482
+ Dommy::Rails::Stimulus.controller?(MatchTarget.document(actual), @name)
483
+ end
484
+
485
+ def does_not_match?(actual)
486
+ !matches?(actual)
487
+ end
488
+
489
+ def description
490
+ "have Stimulus controller #{@name.inspect}"
491
+ end
492
+
493
+ def failure_message
494
+ "expected to find element with Stimulus controller #{@name.inspect}"
495
+ end
496
+
497
+ def failure_message_when_negated
498
+ "expected not to find element with Stimulus controller #{@name.inspect}"
499
+ end
500
+ end
501
+
502
+ class HaveStimulusAction
503
+ def initialize(action)
504
+ @action = action
505
+ end
506
+
507
+ def matches?(actual)
508
+ Dommy::Rails::Stimulus.action?(MatchTarget.document(actual), @action)
509
+ end
510
+
511
+ def does_not_match?(actual)
512
+ !matches?(actual)
513
+ end
514
+
515
+ def description
516
+ "have Stimulus action #{@action.inspect}"
517
+ end
518
+
519
+ def failure_message
520
+ "expected to find element with Stimulus action #{@action.inspect}"
521
+ end
522
+
523
+ def failure_message_when_negated
524
+ "expected not to find element with Stimulus action #{@action.inspect}"
525
+ end
526
+ end
527
+
528
+ class HaveStimulusTarget
529
+ def initialize(controller, target)
530
+ @controller = controller
531
+ @target = target
532
+ end
533
+
534
+ def matches?(actual)
535
+ Dommy::Rails::Stimulus.target?(MatchTarget.document(actual), @controller, @target)
536
+ end
537
+
538
+ def does_not_match?(actual)
539
+ !matches?(actual)
540
+ end
541
+
542
+ def description
543
+ "have Stimulus target #{@controller.inspect}.#{@target.inspect}"
544
+ end
545
+
546
+ def failure_message
547
+ "expected to find element with Stimulus target #{@controller.inspect}.#{@target.inspect}"
548
+ end
549
+
550
+ def failure_message_when_negated
551
+ "expected not to find element with Stimulus target #{@controller.inspect}.#{@target.inspect}"
552
+ end
553
+ end
554
+
555
+ class HaveStimulusValue
556
+ def initialize(controller, key, value)
557
+ @controller = controller
558
+ @key = key
559
+ @value = value
560
+ end
561
+
562
+ def matches?(actual)
563
+ Dommy::Rails::Stimulus.value?(MatchTarget.document(actual), @controller, @key, @value)
564
+ end
565
+
566
+ def does_not_match?(actual)
567
+ !matches?(actual)
568
+ end
569
+
570
+ def description
571
+ "have Stimulus value #{@controller.inspect}.#{@key.inspect}"
572
+ end
573
+
574
+ def failure_message
575
+ "expected to find element with Stimulus value #{@controller.inspect}.#{@key.inspect} = #{@value.inspect}"
576
+ end
577
+
578
+ def failure_message_when_negated
579
+ "expected not to find element with Stimulus value #{@controller.inspect}.#{@key.inspect} = #{@value.inspect}"
580
+ end
581
+ end
582
+
583
+ class HaveNoDuplicateIds
584
+ def matches?(actual)
585
+ @issues = Dommy::Rails::Lint.duplicate_ids(MatchTarget.document(actual))
586
+ @issues.empty?
587
+ end
588
+
589
+ def does_not_match?(actual)
590
+ !matches?(actual)
591
+ end
592
+
593
+ def description
594
+ "have no duplicate ids"
595
+ end
596
+
597
+ def failure_message
598
+ "expected no duplicate IDs, found: #{@issues.join(', ')}"
599
+ end
600
+
601
+ def failure_message_when_negated
602
+ "expected duplicate IDs"
603
+ end
604
+ end
605
+
606
+ class HaveNoInvalidAriaReferences
607
+ def matches?(actual)
608
+ @issues = Dommy::Rails::Lint.invalid_aria_references(MatchTarget.document(actual))
609
+ @issues.empty?
610
+ end
611
+
612
+ def does_not_match?(actual)
613
+ !matches?(actual)
614
+ end
615
+
616
+ def description
617
+ "have no invalid ARIA references"
618
+ end
619
+
620
+ def failure_message
621
+ "expected no invalid ARIA references, found #{@issues.size} issues"
622
+ end
623
+
624
+ def failure_message_when_negated
625
+ "expected invalid ARIA references"
626
+ end
627
+ end
628
+
629
+ class HaveNoMissingFormLabels
630
+ def matches?(actual)
631
+ @issues = Dommy::Rails::Lint.missing_form_labels(MatchTarget.document(actual))
632
+ @issues.empty?
633
+ end
634
+
635
+ def does_not_match?(actual)
636
+ !matches?(actual)
637
+ end
638
+
639
+ def description
640
+ "have no missing form labels"
641
+ end
642
+
643
+ def failure_message
644
+ "expected no missing form labels, found #{@issues.size} issues"
645
+ end
646
+
647
+ def failure_message_when_negated
648
+ "expected missing form labels"
649
+ end
650
+ end
651
+
652
+ class HaveNoEmptyLinks
653
+ def matches?(actual)
654
+ @issues = Dommy::Rails::Lint.empty_links(MatchTarget.document(actual))
655
+ @issues.empty?
656
+ end
657
+
658
+ def does_not_match?(actual)
659
+ !matches?(actual)
660
+ end
661
+
662
+ def description
663
+ "have no empty links"
664
+ end
665
+
666
+ def failure_message
667
+ "expected no empty links, found #{@issues.size} issues"
668
+ end
669
+
670
+ def failure_message_when_negated
671
+ "expected empty links"
672
+ end
673
+ end
674
+
675
+ class HaveNoNestedInteractiveElements
676
+ def matches?(actual)
677
+ @issues = Dommy::Rails::Lint.nested_interactive_elements(MatchTarget.document(actual))
678
+ @issues.empty?
679
+ end
680
+
681
+ def does_not_match?(actual)
682
+ !matches?(actual)
683
+ end
684
+
685
+ def description
686
+ "have no nested interactive elements"
687
+ end
688
+
689
+ def failure_message
690
+ "expected no nested interactive elements, found #{@issues.size} issues"
691
+ end
692
+
693
+ def failure_message_when_negated
694
+ "expected nested interactive elements"
695
+ end
696
+ end
697
+
698
+ class HaveHtmlLink
699
+ def initialize(text, href:, count:)
700
+ @text = text
701
+ @href = href
702
+ @count = count
703
+ end
704
+
705
+ def matches?(mail)
706
+ @document = Dommy::Rails::MailPart.html_document(mail)
707
+ return false unless @document
708
+
709
+ @matched = Dommy::Rails::PageInspector.links(@document, text: @text, href: @href)
710
+ Dommy::Internal::DomMatching.count_matches?(@matched.size, @count)
711
+ end
712
+
713
+ def does_not_match?(mail)
714
+ !matches?(mail)
715
+ end
716
+
717
+ def description
718
+ "have HTML link"
719
+ end
720
+
721
+ def failure_message
722
+ return "expected mail to have an HTML part" unless @document
723
+
724
+ "expected mail HTML to contain link, found #{@matched.size}"
725
+ end
726
+
727
+ def failure_message_when_negated
728
+ "expected mail HTML not to contain link"
729
+ end
730
+ end
731
+
732
+ class HaveHtmlText
733
+ def initialize(text)
734
+ @text = text
735
+ end
736
+
737
+ def matches?(mail)
738
+ @document = Dommy::Rails::MailPart.html_document(mail)
739
+ return false unless @document
740
+
741
+ @actual = Dommy::Internal::DomMatching.text_of(@document)
742
+ Dommy::Internal::DomMatching.text_matches?(@actual, @text)
743
+ end
744
+
745
+ def does_not_match?(mail)
746
+ !matches?(mail)
747
+ end
748
+
749
+ def description
750
+ "have HTML text #{@text.inspect}"
751
+ end
752
+
753
+ def failure_message
754
+ return "expected mail to have an HTML part" unless @document
755
+
756
+ "expected mail HTML to include #{@text.inspect}, got #{@actual.inspect}"
757
+ end
758
+
759
+ def failure_message_when_negated
760
+ "expected mail HTML not to include #{@text.inspect}"
761
+ end
762
+ end
763
+
764
+ class HavePlainText
765
+ def initialize(text)
766
+ @text = text
767
+ end
768
+
769
+ def matches?(mail)
770
+ @actual = Dommy::Rails::MailPart.plain_body(mail).to_s
771
+ Dommy::Internal::DomMatching.text_matches?(@actual, @text)
772
+ end
773
+
774
+ def does_not_match?(mail)
775
+ !matches?(mail)
776
+ end
777
+
778
+ def description
779
+ "have plain text #{@text.inspect}"
780
+ end
781
+
782
+ def failure_message
783
+ "expected mail plain text to include #{@text.inspect}, got #{@actual.inspect}"
784
+ end
785
+
786
+ def failure_message_when_negated
787
+ "expected mail plain text not to include #{@text.inspect}"
788
+ end
789
+ end
790
+ end
791
+ end
792
+ end
793
+ end