turbo_rspec 1.4.0 → 1.6.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 +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +30 -11
- data/ROADMAP.md +0 -2
- data/lib/turbo_rspec/assertions.rb +35 -0
- data/lib/turbo_rspec/capybara/matchers/have_turbo_frame.rb +24 -0
- data/lib/turbo_rspec/matchers/have_broadcasted_turbo_stream_to.rb +103 -6
- data/lib/turbo_rspec/matchers/have_stimulus_action.rb +71 -0
- data/lib/turbo_rspec/matchers/have_stimulus_controller.rb +58 -0
- data/lib/turbo_rspec/matchers/have_stimulus_target.rb +59 -0
- data/lib/turbo_rspec/matchers/match_turbo_stream_snapshot.rb +18 -2
- data/lib/turbo_rspec/matchers.rb +25 -0
- data/lib/turbo_rspec/version.rb +1 -1
- data/sig/turbo_rspec.rbs +36 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3211f811cbd7919d35f8b918260ea97346668fb2ef3213f189d91c642a014b53
|
|
4
|
+
data.tar.gz: dda8641bcf4d6cb1a8ebc6de4ee5d7622f90bcec3a79b8180444b6e4bfcd2c38
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 352e59c5604c6362263a51d6b0eb9903b1b7c7b337bca65a05e01c15b42bae5e121def5d638ed66f8b73d06fda382460a85dce4d15ab7a3bff234fb26c310759
|
|
7
|
+
data.tar.gz: 1243fc05f3fc0256f3e028c66d505a0cbf8335108238c6fde0c0b4e6a0fc97f630bd2ae59c51cb37e64a0ce81265fb20b2d8c9be2e850ef7b29a3442c18f6991
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.6.0] - 2026-06-02
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `have_stimulus_controller`, `have_stimulus_action`, `have_stimulus_target` request-spec matchers — assert Stimulus `data-*` attributes in response HTML, auto-included in `type: :request` and `type: :controller` example groups
|
|
8
|
+
- `assert_broadcasted_turbo_stream_to` and `refute_broadcasted_turbo_stream_to` in `TurboRspec::Assertions` — Minitest broadcast parity with the RSpec `have_broadcasted_turbo_stream_to` matcher
|
|
9
|
+
- `have_turbo_frame` (Capybara) `.eager` chain — asserts `loading="eager"` on the frame (Turbo 8)
|
|
10
|
+
- `have_turbo_frame` (Capybara) `.strict` chain — asserts the `[strict]` boolean attribute on the frame (Turbo 8)
|
|
11
|
+
|
|
12
|
+
## [1.5.0] - 2026-06-02
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- `match_turbo_stream_snapshot` failure message now shows a line-by-line inline diff (stored → actual) instead of raw before/after strings
|
|
17
|
+
- `have_broadcasted_turbo_stream_to` failure message now lists all streams broadcast to the channel and shows a closest-match diff with per-constraint ✓/✗ indicators — mirrors the behaviour already present in `have_turbo_stream`
|
|
18
|
+
- `have_broadcasted_turbo_stream_to` now validates action names via `TurboRspec.known_actions`, raising `ArgumentError` for unrecognised actions — consistent with `have_turbo_stream`
|
|
19
|
+
- `have_broadcasted_turbo_stream_to.with_attributes(hash)` — assert arbitrary HTML attributes on broadcast stream elements
|
|
20
|
+
- `have_broadcasted_turbo_stream_to.children_only` — assert the `[children-only]` attribute on morph broadcasts
|
|
21
|
+
|
|
3
22
|
## [1.4.0] - 2026-06-02
|
|
4
23
|
|
|
5
24
|
### Added
|
data/README.md
CHANGED
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
Drop-in test matchers for [hotwired/turbo-rails](https://github.com/hotwired/turbo-rails) — replace every hand-rolled Turbo helper in your test suite with a single gem.
|
|
10
10
|
|
|
11
|
-
- **Request/controller specs** — `have_turbo_stream`, `have_turbo_frame`, `have_turbo_streams`
|
|
11
|
+
- **Request/controller specs** — `have_turbo_stream`, `have_turbo_frame`, `have_turbo_streams`, `have_stimulus_controller`, `have_stimulus_action`, `have_stimulus_target`
|
|
12
12
|
- **Broadcast specs** — `have_broadcasted_turbo_stream_to` with count qualifiers
|
|
13
13
|
- **System/feature specs** — Capybara matchers: `have_turbo_frame`, `have_turbo_stream_tag`, `within_turbo_frame`, `have_stimulus_controller`, `have_stimulus_action`, `have_stimulus_target`
|
|
14
|
-
- **Minitest** — `assert_turbo_stream`, `refute_turbo_stream`, `assert_turbo_frame`, `refute_turbo_frame`
|
|
14
|
+
- **Minitest** — `assert_turbo_stream`, `refute_turbo_stream`, `assert_turbo_frame`, `refute_turbo_frame`, `assert_broadcasted_turbo_stream_to`, `refute_broadcasted_turbo_stream_to`
|
|
15
15
|
- **Factory helpers** — `turbo_stream_html`, `turbo_frame_html`
|
|
16
16
|
- **Shared examples** — `it_behaves_like "a turbo stream response"`
|
|
17
17
|
- **Auto-included** — zero setup required when `turbo-rails` is in your bundle
|
|
@@ -183,6 +183,14 @@ expect { MyJob.perform_now }.to have_broadcasted_turbo_stream_to("notifications"
|
|
|
183
183
|
.targeting("messages")
|
|
184
184
|
.with_content("Hello")
|
|
185
185
|
|
|
186
|
+
# With arbitrary attributes
|
|
187
|
+
expect { MyJob.perform_now }.to have_broadcasted_turbo_stream_to("notifications")
|
|
188
|
+
.with_attributes("data-controller" => "messages")
|
|
189
|
+
|
|
190
|
+
# Turbo 8 morph with children-only
|
|
191
|
+
expect { MyJob.perform_now }.to have_broadcasted_turbo_stream_to("body")
|
|
192
|
+
.with_action(:morph).children_only
|
|
193
|
+
|
|
186
194
|
# Count qualifiers
|
|
187
195
|
expect { MyJob.perform_now }.to have_broadcasted_turbo_stream_to("notifications").once
|
|
188
196
|
expect { MyJob.perform_now }.to have_broadcasted_turbo_stream_to("notifications").exactly(3).times
|
|
@@ -212,6 +220,12 @@ expect(page).to have_turbo_frame("messages").loaded
|
|
|
212
220
|
# Lazy frame — assert loading="lazy" attribute
|
|
213
221
|
expect(page).to have_turbo_frame("messages").lazy
|
|
214
222
|
|
|
223
|
+
# Eager frame — assert loading="eager" attribute (Turbo 8)
|
|
224
|
+
expect(page).to have_turbo_frame("messages").eager
|
|
225
|
+
|
|
226
|
+
# Strict frame — assert [strict] attribute (Turbo 8)
|
|
227
|
+
expect(page).to have_turbo_frame("messages").strict
|
|
228
|
+
|
|
215
229
|
# With src — assert the src attribute on a lazy-loaded frame
|
|
216
230
|
expect(page).to have_turbo_frame("messages").with_src("/messages/new")
|
|
217
231
|
|
|
@@ -238,22 +252,22 @@ end
|
|
|
238
252
|
|
|
239
253
|
### Stimulus matchers
|
|
240
254
|
|
|
241
|
-
|
|
255
|
+
`have_stimulus_controller`, `have_stimulus_action`, and `have_stimulus_target` work in both **request specs** (parsing response HTML) and **system/feature specs** (Capybara).
|
|
242
256
|
|
|
243
257
|
```ruby
|
|
244
|
-
#
|
|
245
|
-
expect(
|
|
258
|
+
# Request specs — asserts Stimulus attributes in rendered HTML response
|
|
259
|
+
expect(response).to have_stimulus_controller("hello")
|
|
260
|
+
expect(response).to have_stimulus_action("click->hello#greet")
|
|
261
|
+
expect(response).to have_stimulus_action("hello#greet") # shorthand — matches any event
|
|
262
|
+
expect(response).to have_stimulus_target("hello", "name")
|
|
246
263
|
|
|
247
|
-
#
|
|
264
|
+
# System/feature specs — asserts on the live Capybara page
|
|
265
|
+
expect(page).to have_stimulus_controller("hello")
|
|
248
266
|
expect(page).to have_stimulus_action("click->hello#greet")
|
|
249
|
-
|
|
250
|
-
# Action — shorthand without event (matches any event prefix)
|
|
251
|
-
expect(page).to have_stimulus_action("hello#greet")
|
|
252
|
-
|
|
253
|
-
# Target — data-hello-target contains "name"
|
|
254
267
|
expect(page).to have_stimulus_target("hello", "name")
|
|
255
268
|
|
|
256
269
|
# Negation
|
|
270
|
+
expect(response).not_to have_stimulus_controller("missing")
|
|
257
271
|
expect(page).not_to have_stimulus_controller("missing")
|
|
258
272
|
```
|
|
259
273
|
|
|
@@ -422,6 +436,11 @@ refute_turbo_frame(response, id: "notifications")
|
|
|
422
436
|
|
|
423
437
|
# Custom failure message
|
|
424
438
|
assert_turbo_stream(response, action: :append, message: "expected append stream")
|
|
439
|
+
|
|
440
|
+
# Broadcast assertions (requires ActionCable test adapter)
|
|
441
|
+
assert_broadcasted_turbo_stream_to("notifications") { MyJob.perform_now }
|
|
442
|
+
assert_broadcasted_turbo_stream_to("notifications", action: :append, target: "messages") { MyJob.perform_now }
|
|
443
|
+
refute_broadcasted_turbo_stream_to("notifications") { MyJob.perform_now }
|
|
425
444
|
```
|
|
426
445
|
|
|
427
446
|
## Contributing
|
data/ROADMAP.md
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
RSpec matchers for [Turbo](https://github.com/hotwired/turbo-rails): Turbo Streams, Turbo Frames, and ActionCable broadcasts. The goal is to replace the hand-rolled helpers that every Rails/Turbo project accumulates.
|
|
4
4
|
|
|
5
|
-
---
|
|
6
|
-
|
|
7
5
|
## Guiding principles
|
|
8
6
|
|
|
9
7
|
- **Zero magic by default.** Auto-include only when it's unambiguous (Rails request specs). Everything else is opt-in.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "matchers/have_broadcasted_turbo_stream_to"
|
|
3
4
|
require_relative "matchers/have_turbo_frame"
|
|
4
5
|
require_relative "matchers/have_turbo_stream"
|
|
5
6
|
|
|
@@ -12,6 +13,30 @@ module TurboRspec
|
|
|
12
13
|
#
|
|
13
14
|
# No RSpec dependency required.
|
|
14
15
|
module Assertions
|
|
16
|
+
# Assert that a block broadcasts a +<turbo-stream>+ to the given stream.
|
|
17
|
+
# Requires ActionCable's test adapter.
|
|
18
|
+
#
|
|
19
|
+
# @param stream [String] the stream name
|
|
20
|
+
# @param action [Symbol, String, nil] optional action constraint
|
|
21
|
+
# @param target [String, nil] optional target constraint
|
|
22
|
+
# @param targets [String, nil] optional targets (CSS selector) constraint
|
|
23
|
+
# @param content [String, nil] optional content constraint
|
|
24
|
+
# @param partial [String, nil] optional partial path constraint
|
|
25
|
+
# @param message [String, nil] optional custom failure message
|
|
26
|
+
def assert_broadcasted_turbo_stream_to(stream, action: nil, target: nil, targets: nil, content: nil, partial: nil, message: nil, &block)
|
|
27
|
+
matcher = build_broadcast_matcher(stream, action: action, target: target, targets: targets, content: content, partial: partial)
|
|
28
|
+
assert matcher.matches?(block), message || matcher.failure_message
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Assert that a block does NOT broadcast a +<turbo-stream>+ to the given stream.
|
|
32
|
+
# Requires ActionCable's test adapter.
|
|
33
|
+
#
|
|
34
|
+
# @param stream [String] the stream name
|
|
35
|
+
def refute_broadcasted_turbo_stream_to(stream, action: nil, target: nil, targets: nil, content: nil, partial: nil, message: nil, &block)
|
|
36
|
+
matcher = build_broadcast_matcher(stream, action: action, target: target, targets: targets, content: content, partial: partial)
|
|
37
|
+
assert matcher.does_not_match?(block), message || matcher.failure_message_when_negated
|
|
38
|
+
end
|
|
39
|
+
|
|
15
40
|
def assert_turbo_stream(response_or_body, action: nil, target: nil, targets: nil, content: nil, partial: nil, message: nil)
|
|
16
41
|
matcher = build_stream_matcher(action: action, target: target, targets: targets, content: content, partial: partial)
|
|
17
42
|
assert matcher.matches?(response_or_body), message || matcher.failure_message
|
|
@@ -34,6 +59,16 @@ module TurboRspec
|
|
|
34
59
|
|
|
35
60
|
private
|
|
36
61
|
|
|
62
|
+
def build_broadcast_matcher(stream, action:, target:, targets:, content:, partial:)
|
|
63
|
+
matcher = Matchers::HaveBroadcastedTurboStreamTo.new(stream)
|
|
64
|
+
matcher.with_action(action) if action
|
|
65
|
+
matcher.targeting(target) if target
|
|
66
|
+
matcher.targeting_all(targets) if targets
|
|
67
|
+
matcher.with_content(content) if content
|
|
68
|
+
matcher.rendering(partial) if partial
|
|
69
|
+
matcher
|
|
70
|
+
end
|
|
71
|
+
|
|
37
72
|
def build_stream_matcher(action:, target:, targets:, content:, partial:)
|
|
38
73
|
matcher = Matchers::HaveTurboStream.new
|
|
39
74
|
matcher.with_action(action) if action
|
|
@@ -10,6 +10,8 @@ module TurboRspec
|
|
|
10
10
|
@loaded = false
|
|
11
11
|
@src = nil
|
|
12
12
|
@lazy = false
|
|
13
|
+
@eager = false
|
|
14
|
+
@strict = false
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def with_content(text)
|
|
@@ -37,11 +39,27 @@ module TurboRspec
|
|
|
37
39
|
self
|
|
38
40
|
end
|
|
39
41
|
|
|
42
|
+
# Constrains the match to frames with +loading="eager"+.
|
|
43
|
+
# @return [self]
|
|
44
|
+
def eager
|
|
45
|
+
@eager = true
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Constrains the match to frames with the +[strict]+ attribute (Turbo 8).
|
|
50
|
+
# @return [self]
|
|
51
|
+
def strict
|
|
52
|
+
@strict = true
|
|
53
|
+
self
|
|
54
|
+
end
|
|
55
|
+
|
|
40
56
|
def matches?(page_or_node)
|
|
41
57
|
@node = find_frame(page_or_node)
|
|
42
58
|
return false unless @node
|
|
43
59
|
return false if @loaded && !@node[:complete]
|
|
44
60
|
return false if @lazy && @node[:loading] != "lazy"
|
|
61
|
+
return false if @eager && @node[:loading] != "eager"
|
|
62
|
+
return false if @strict && @node[:strict].nil?
|
|
45
63
|
return false if @src && @node[:src] != @src
|
|
46
64
|
return false if @content && !@node.has_content?(@content, wait: 0)
|
|
47
65
|
true
|
|
@@ -58,6 +76,10 @@ module TurboRspec
|
|
|
58
76
|
"expected turbo-frame##{@id} to be loaded (missing [complete] attribute)"
|
|
59
77
|
elsif @lazy && @node[:loading] != "lazy"
|
|
60
78
|
"expected turbo-frame##{@id} to be lazy (missing loading=\"lazy\" attribute)"
|
|
79
|
+
elsif @eager && @node[:loading] != "eager"
|
|
80
|
+
"expected turbo-frame##{@id} to be eager (missing loading=\"eager\" attribute)"
|
|
81
|
+
elsif @strict && @node[:strict].nil?
|
|
82
|
+
"expected turbo-frame##{@id} to be strict (missing [strict] attribute)"
|
|
61
83
|
elsif @src && @node[:src] != @src
|
|
62
84
|
"expected turbo-frame##{@id} to have src #{@src.inspect}, got #{@node[:src].inspect}"
|
|
63
85
|
else
|
|
@@ -85,6 +107,8 @@ module TurboRspec
|
|
|
85
107
|
parts = []
|
|
86
108
|
parts << " loaded" if @loaded
|
|
87
109
|
parts << " lazy" if @lazy
|
|
110
|
+
parts << " eager" if @eager
|
|
111
|
+
parts << " strict" if @strict
|
|
88
112
|
parts << " with src #{@src.inspect}" if @src
|
|
89
113
|
parts << " with content #{@content.inspect}" if @content
|
|
90
114
|
parts.join
|
|
@@ -12,6 +12,8 @@ module TurboRspec
|
|
|
12
12
|
@target_all = nil
|
|
13
13
|
@content = nil
|
|
14
14
|
@partial = nil
|
|
15
|
+
@attributes = {}
|
|
16
|
+
@children_only = false
|
|
15
17
|
@expected_count = nil
|
|
16
18
|
@count_type = :at_least
|
|
17
19
|
end
|
|
@@ -19,7 +21,12 @@ module TurboRspec
|
|
|
19
21
|
# Stream constraints (mirrors HaveTurboStream)
|
|
20
22
|
|
|
21
23
|
def with_action(action)
|
|
22
|
-
|
|
24
|
+
action_str = action.to_s
|
|
25
|
+
unless TurboRspec.known_actions.include?(action_str)
|
|
26
|
+
raise ArgumentError, "Unknown Turbo stream action #{action_str.inspect}. " \
|
|
27
|
+
"Register custom actions with TurboRspec.register_action(:#{action_str})."
|
|
28
|
+
end
|
|
29
|
+
@action = action_str
|
|
23
30
|
self
|
|
24
31
|
end
|
|
25
32
|
|
|
@@ -43,6 +50,16 @@ module TurboRspec
|
|
|
43
50
|
self
|
|
44
51
|
end
|
|
45
52
|
|
|
53
|
+
def with_attributes(attrs)
|
|
54
|
+
@attributes = attrs.transform_keys(&:to_s).transform_values(&:to_s)
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def children_only
|
|
59
|
+
@children_only = true
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
46
63
|
# Count qualifiers
|
|
47
64
|
|
|
48
65
|
def once
|
|
@@ -82,7 +99,9 @@ module TurboRspec
|
|
|
82
99
|
def matches?(block)
|
|
83
100
|
before = snapshot
|
|
84
101
|
block.call
|
|
85
|
-
@
|
|
102
|
+
@new_broadcasts = snapshot - before
|
|
103
|
+
@all_streams = parse_all_streams(@new_broadcasts)
|
|
104
|
+
@matching = @new_broadcasts.select { |msg| message_matches?(msg) }
|
|
86
105
|
count_matches?(@matching.size)
|
|
87
106
|
end
|
|
88
107
|
|
|
@@ -131,7 +150,9 @@ module TurboRspec
|
|
|
131
150
|
matches_target?(stream) &&
|
|
132
151
|
matches_target_all?(stream) &&
|
|
133
152
|
matches_content?(stream) &&
|
|
134
|
-
matches_partial?(stream)
|
|
153
|
+
matches_partial?(stream) &&
|
|
154
|
+
matches_attributes?(stream) &&
|
|
155
|
+
matches_children_only?(stream)
|
|
135
156
|
end
|
|
136
157
|
|
|
137
158
|
def matches_action?(stream)
|
|
@@ -156,6 +177,16 @@ module TurboRspec
|
|
|
156
177
|
stream.to_html.include?(@partial)
|
|
157
178
|
end
|
|
158
179
|
|
|
180
|
+
def matches_attributes?(stream)
|
|
181
|
+
return true if @attributes.empty?
|
|
182
|
+
@attributes.all? { |k, v| stream[k] == v }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def matches_children_only?(stream)
|
|
186
|
+
return true unless @children_only
|
|
187
|
+
!stream["children-only"].nil?
|
|
188
|
+
end
|
|
189
|
+
|
|
159
190
|
def count_matches?(n)
|
|
160
191
|
if @expected_count.nil?
|
|
161
192
|
n >= 1
|
|
@@ -177,6 +208,8 @@ module TurboRspec
|
|
|
177
208
|
parts << " targeting all #{@target_all.inspect}" if @target_all
|
|
178
209
|
parts << " with content #{@content.inspect}" if @content
|
|
179
210
|
parts << " rendering #{@partial.inspect}" if @partial
|
|
211
|
+
parts << " with attributes #{@attributes.inspect}" if @attributes.any?
|
|
212
|
+
parts << " children only" if @children_only
|
|
180
213
|
parts.join
|
|
181
214
|
end
|
|
182
215
|
|
|
@@ -192,11 +225,75 @@ module TurboRspec
|
|
|
192
225
|
end
|
|
193
226
|
|
|
194
227
|
def found_message
|
|
195
|
-
if @matching.
|
|
196
|
-
"but no matching broadcasts were found"
|
|
197
|
-
else
|
|
228
|
+
if @matching.size > 0
|
|
198
229
|
"found #{@matching.size} matching broadcast(s)"
|
|
230
|
+
elsif @all_streams.empty?
|
|
231
|
+
"but no broadcasts were made to #{stream_name.inspect}"
|
|
232
|
+
else
|
|
233
|
+
no_match_message
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def no_match_message
|
|
238
|
+
lines = ["found #{@all_streams.size} stream(s) broadcast to #{stream_name.inspect}:"]
|
|
239
|
+
@all_streams.each_with_index do |s, i|
|
|
240
|
+
content_preview = s.text.strip.slice(0, 50)
|
|
241
|
+
content_preview = content_preview.empty? ? "(empty)" : content_preview.inspect
|
|
242
|
+
lines << " #{i + 1}. action=#{s["action"].inspect} target=#{s["target"].inspect} content=#{content_preview}"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# :nocov:
|
|
246
|
+
if constraint_count > 0
|
|
247
|
+
# :nocov:
|
|
248
|
+
closest = @all_streams.max_by { |s| count_matching_constraints(s) }
|
|
249
|
+
lines << ""
|
|
250
|
+
lines << "closest match (#{count_matching_constraints(closest)}/#{constraint_count} constraint(s) matched):"
|
|
251
|
+
lines.concat(constraint_diff(closest))
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
lines.join("\n")
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def parse_all_streams(broadcasts)
|
|
258
|
+
broadcasts.flat_map do |msg|
|
|
259
|
+
html = JSON.parse(msg)
|
|
260
|
+
Nokogiri::HTML5.fragment(html).css("turbo-stream").to_a
|
|
261
|
+
rescue JSON::ParserError
|
|
262
|
+
[]
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def count_matching_constraints(stream)
|
|
267
|
+
count = 0
|
|
268
|
+
count += 1 if !@action.nil? && matches_action?(stream)
|
|
269
|
+
count += 1 if !@target.nil? && matches_target?(stream)
|
|
270
|
+
count += 1 if !@target_all.nil? && matches_target_all?(stream)
|
|
271
|
+
count += 1 if !@content.nil? && matches_content?(stream)
|
|
272
|
+
count += 1 if !@partial.nil? && matches_partial?(stream)
|
|
273
|
+
count += 1 if @attributes.any? && matches_attributes?(stream)
|
|
274
|
+
count += 1 if @children_only && matches_children_only?(stream)
|
|
275
|
+
count
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def constraint_count
|
|
279
|
+
[@action, @target, @target_all, @content, @partial].count { |c| !c.nil? } +
|
|
280
|
+
(@attributes.any? ? 1 : 0) +
|
|
281
|
+
(@children_only ? 1 : 0)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def constraint_diff(stream)
|
|
285
|
+
lines = []
|
|
286
|
+
lines << " #{matches_action?(stream) ? "✓" : "✗"} action: expected #{@action.inspect}, got #{stream["action"].inspect}" if @action
|
|
287
|
+
lines << " #{matches_target?(stream) ? "✓" : "✗"} target: expected #{@target.inspect}, got #{stream["target"].inspect}" if @target
|
|
288
|
+
lines << " #{matches_target_all?(stream) ? "✓" : "✗"} targets: expected #{@target_all.inspect}, got #{stream["targets"].inspect}" if @target_all
|
|
289
|
+
lines << " #{matches_content?(stream) ? "✓" : "✗"} content: expected to include #{@content.inspect}, got #{stream.text.strip.slice(0, 50).inspect}" if @content
|
|
290
|
+
lines << " #{matches_partial?(stream) ? "✓" : "✗"} rendering: expected to include #{@partial.inspect}" if @partial
|
|
291
|
+
@attributes.each do |k, v|
|
|
292
|
+
actual = stream[k]
|
|
293
|
+
lines << " #{(actual == v) ? "✓" : "✗"} attr[#{k}]: expected #{v.inspect}, got #{actual.inspect}"
|
|
199
294
|
end
|
|
295
|
+
lines << " #{matches_children_only?(stream) ? "✓" : "✗"} children-only: expected attribute to be present" if @children_only
|
|
296
|
+
lines
|
|
200
297
|
end
|
|
201
298
|
end
|
|
202
299
|
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
module TurboRspec
|
|
6
|
+
module Matchers
|
|
7
|
+
# RSpec matcher for asserting that a response body contains an element with
|
|
8
|
+
# the given Stimulus action descriptor (+data-action+ contains the descriptor
|
|
9
|
+
# as a space-separated token).
|
|
10
|
+
#
|
|
11
|
+
# Accepts a full descriptor (+click->hello#greet+) or a shorthand without
|
|
12
|
+
# an event (+hello#greet+), which matches any event prefix.
|
|
13
|
+
#
|
|
14
|
+
# @example Full descriptor
|
|
15
|
+
# expect(response).to have_stimulus_action("click->hello#greet")
|
|
16
|
+
#
|
|
17
|
+
# @example Shorthand (any event)
|
|
18
|
+
# expect(response).to have_stimulus_action("hello#greet")
|
|
19
|
+
class HaveStimulusAction
|
|
20
|
+
def initialize(action_descriptor)
|
|
21
|
+
@action_descriptor = action_descriptor.to_s
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param response_or_body [#body, String]
|
|
25
|
+
# @return [Boolean]
|
|
26
|
+
def matches?(response_or_body)
|
|
27
|
+
@body = extract_body(response_or_body)
|
|
28
|
+
Nokogiri::HTML5.fragment(@body).css(selector).any?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param response_or_body [#body, String]
|
|
32
|
+
# @return [Boolean]
|
|
33
|
+
def does_not_match?(response_or_body)
|
|
34
|
+
!matches?(response_or_body)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [String]
|
|
38
|
+
def failure_message
|
|
39
|
+
"expected response to have Stimulus action #{@action_descriptor.inspect}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [String]
|
|
43
|
+
def failure_message_when_negated
|
|
44
|
+
"expected response not to have Stimulus action #{@action_descriptor.inspect}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [String]
|
|
48
|
+
def description
|
|
49
|
+
"have Stimulus action #{@action_descriptor.inspect}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def extract_body(response_or_body)
|
|
55
|
+
if response_or_body.respond_to?(:body)
|
|
56
|
+
response_or_body.body
|
|
57
|
+
else
|
|
58
|
+
response_or_body.to_s
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def selector
|
|
63
|
+
if @action_descriptor.include?("->")
|
|
64
|
+
"[data-action~='#{@action_descriptor}']"
|
|
65
|
+
else
|
|
66
|
+
"[data-action*='#{@action_descriptor}']"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
module TurboRspec
|
|
6
|
+
module Matchers
|
|
7
|
+
# RSpec matcher for asserting that a response body contains an element with
|
|
8
|
+
# the given Stimulus controller (+data-controller+ contains the name as a
|
|
9
|
+
# space-separated token).
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# expect(response).to have_stimulus_controller("hello")
|
|
13
|
+
# expect(response).not_to have_stimulus_controller("missing")
|
|
14
|
+
class HaveStimulusController
|
|
15
|
+
def initialize(controller_name)
|
|
16
|
+
@controller_name = controller_name.to_s
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param response_or_body [#body, String]
|
|
20
|
+
# @return [Boolean]
|
|
21
|
+
def matches?(response_or_body)
|
|
22
|
+
@body = extract_body(response_or_body)
|
|
23
|
+
Nokogiri::HTML5.fragment(@body).css("[data-controller~='#{@controller_name}']").any?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param response_or_body [#body, String]
|
|
27
|
+
# @return [Boolean]
|
|
28
|
+
def does_not_match?(response_or_body)
|
|
29
|
+
!matches?(response_or_body)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [String]
|
|
33
|
+
def failure_message
|
|
34
|
+
"expected response to have Stimulus controller #{@controller_name.inspect}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [String]
|
|
38
|
+
def failure_message_when_negated
|
|
39
|
+
"expected response not to have Stimulus controller #{@controller_name.inspect}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [String]
|
|
43
|
+
def description
|
|
44
|
+
"have Stimulus controller #{@controller_name.inspect}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def extract_body(response_or_body)
|
|
50
|
+
if response_or_body.respond_to?(:body)
|
|
51
|
+
response_or_body.body
|
|
52
|
+
else
|
|
53
|
+
response_or_body.to_s
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
module TurboRspec
|
|
6
|
+
module Matchers
|
|
7
|
+
# RSpec matcher for asserting that a response body contains an element with
|
|
8
|
+
# the given Stimulus target (+data-{controller}-target+ contains the target
|
|
9
|
+
# name as a space-separated token).
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# expect(response).to have_stimulus_target("hello", "name")
|
|
13
|
+
# expect(response).not_to have_stimulus_target("hello", "missing")
|
|
14
|
+
class HaveStimulusTarget
|
|
15
|
+
def initialize(controller_name, target_name)
|
|
16
|
+
@controller_name = controller_name.to_s
|
|
17
|
+
@target_name = target_name.to_s
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @param response_or_body [#body, String]
|
|
21
|
+
# @return [Boolean]
|
|
22
|
+
def matches?(response_or_body)
|
|
23
|
+
@body = extract_body(response_or_body)
|
|
24
|
+
Nokogiri::HTML5.fragment(@body).css("[data-#{@controller_name}-target~='#{@target_name}']").any?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param response_or_body [#body, String]
|
|
28
|
+
# @return [Boolean]
|
|
29
|
+
def does_not_match?(response_or_body)
|
|
30
|
+
!matches?(response_or_body)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [String]
|
|
34
|
+
def failure_message
|
|
35
|
+
"expected response to have Stimulus target #{@target_name.inspect} for controller #{@controller_name.inspect}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [String]
|
|
39
|
+
def failure_message_when_negated
|
|
40
|
+
"expected response not to have Stimulus target #{@target_name.inspect} for controller #{@controller_name.inspect}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [String]
|
|
44
|
+
def description
|
|
45
|
+
"have Stimulus target #{@target_name.inspect} for #{@controller_name.inspect}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def extract_body(response_or_body)
|
|
51
|
+
if response_or_body.respond_to?(:body)
|
|
52
|
+
response_or_body.body
|
|
53
|
+
else
|
|
54
|
+
response_or_body.to_s
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "diff/lcs"
|
|
4
5
|
|
|
5
6
|
module TurboRspec
|
|
6
7
|
module Matchers
|
|
@@ -44,8 +45,7 @@ module TurboRspec
|
|
|
44
45
|
# @return [String]
|
|
45
46
|
def failure_message
|
|
46
47
|
"expected response to match turbo stream snapshot #{@name.inspect}\n\n" \
|
|
47
|
-
"stored:\n#{
|
|
48
|
-
"actual:\n#{@actual}\n\n" \
|
|
48
|
+
"diff (stored → actual):\n#{inline_diff}\n\n" \
|
|
49
49
|
"To update: run with UPDATE_TURBO_SNAPSHOTS=1"
|
|
50
50
|
end
|
|
51
51
|
|
|
@@ -81,6 +81,22 @@ module TurboRspec
|
|
|
81
81
|
def update_snapshots?
|
|
82
82
|
ENV["UPDATE_TURBO_SNAPSHOTS"] == "1"
|
|
83
83
|
end
|
|
84
|
+
|
|
85
|
+
def inline_diff
|
|
86
|
+
stored_lines = @stored.strip.lines
|
|
87
|
+
actual_lines = @actual.strip.lines
|
|
88
|
+
hunks = Diff::LCS.diff(stored_lines, actual_lines)
|
|
89
|
+
return " (no textual difference — whitespace only?)" if hunks.empty?
|
|
90
|
+
|
|
91
|
+
lines = []
|
|
92
|
+
hunks.each do |hunk|
|
|
93
|
+
hunk.each do |change|
|
|
94
|
+
prefix = (change.action == "+") ? "+" : "-"
|
|
95
|
+
lines << "#{prefix} #{change.element.chomp}"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
lines.join("\n")
|
|
99
|
+
end
|
|
84
100
|
end
|
|
85
101
|
end
|
|
86
102
|
end
|
data/lib/turbo_rspec/matchers.rb
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "matchers/have_broadcasted_turbo_stream_to"
|
|
4
|
+
require_relative "matchers/have_stimulus_action"
|
|
5
|
+
require_relative "matchers/have_stimulus_controller"
|
|
6
|
+
require_relative "matchers/have_stimulus_target"
|
|
4
7
|
require_relative "matchers/have_turbo_frame"
|
|
5
8
|
require_relative "matchers/have_turbo_stream"
|
|
6
9
|
require_relative "matchers/have_turbo_streams"
|
|
@@ -48,6 +51,28 @@ module TurboRspec
|
|
|
48
51
|
HaveTurboStreams.new(matchers)
|
|
49
52
|
end
|
|
50
53
|
|
|
54
|
+
# Assert that a response body contains an element with the given Stimulus controller.
|
|
55
|
+
# @param controller_name [String]
|
|
56
|
+
# @return [HaveStimulusController]
|
|
57
|
+
def have_stimulus_controller(controller_name)
|
|
58
|
+
HaveStimulusController.new(controller_name)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Assert that a response body contains an element with the given Stimulus action.
|
|
62
|
+
# @param action_descriptor [String]
|
|
63
|
+
# @return [HaveStimulusAction]
|
|
64
|
+
def have_stimulus_action(action_descriptor)
|
|
65
|
+
HaveStimulusAction.new(action_descriptor)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Assert that a response body contains an element with the given Stimulus target.
|
|
69
|
+
# @param controller_name [String]
|
|
70
|
+
# @param target_name [String]
|
|
71
|
+
# @return [HaveStimulusTarget]
|
|
72
|
+
def have_stimulus_target(controller_name, target_name)
|
|
73
|
+
HaveStimulusTarget.new(controller_name, target_name)
|
|
74
|
+
end
|
|
75
|
+
|
|
51
76
|
# Assert that a response body matches a stored turbo stream snapshot.
|
|
52
77
|
# Creates the snapshot on the first run; diffs against it on subsequent runs.
|
|
53
78
|
# Set +UPDATE_TURBO_SNAPSHOTS=1+ to overwrite an existing snapshot.
|
data/lib/turbo_rspec/version.rb
CHANGED
data/sig/turbo_rspec.rbs
CHANGED
|
@@ -26,6 +26,9 @@ module TurboRspec
|
|
|
26
26
|
def have_turbo_stream: () -> HaveTurboStream
|
|
27
27
|
def assert_no_turbo_stream: () -> HaveTurboStream
|
|
28
28
|
def have_turbo_streams: (*HaveTurboStream matchers) -> HaveTurboStreams
|
|
29
|
+
def have_stimulus_controller: (String controller_name) -> HaveStimulusController
|
|
30
|
+
def have_stimulus_action: (String action_descriptor) -> HaveStimulusAction
|
|
31
|
+
def have_stimulus_target: (String controller_name, String target_name) -> HaveStimulusTarget
|
|
29
32
|
def match_turbo_stream_snapshot: (String name) -> MatchTurboStreamSnapshot
|
|
30
33
|
def have_turbo_frame: () -> HaveTurboFrame
|
|
31
34
|
def have_broadcasted_turbo_stream_to: (String | untyped stream_or_object) -> HaveBroadcastedTurboStreamTo
|
|
@@ -85,6 +88,8 @@ module TurboRspec
|
|
|
85
88
|
def targeting_all: (String selector) -> self
|
|
86
89
|
def with_content: (String text) -> self
|
|
87
90
|
def rendering: (String partial) -> self
|
|
91
|
+
def with_attributes: (Hash[String | Symbol, untyped] attrs) -> self
|
|
92
|
+
def children_only: () -> self
|
|
88
93
|
|
|
89
94
|
# Count qualifiers
|
|
90
95
|
def once: () -> self
|
|
@@ -103,6 +108,33 @@ module TurboRspec
|
|
|
103
108
|
def description: () -> String
|
|
104
109
|
end
|
|
105
110
|
|
|
111
|
+
class HaveStimulusController
|
|
112
|
+
def initialize: (String controller_name) -> void
|
|
113
|
+
def matches?: (untyped response_or_body) -> bool
|
|
114
|
+
def does_not_match?: (untyped response_or_body) -> bool
|
|
115
|
+
def failure_message: () -> String
|
|
116
|
+
def failure_message_when_negated: () -> String
|
|
117
|
+
def description: () -> String
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
class HaveStimulusAction
|
|
121
|
+
def initialize: (String action_descriptor) -> void
|
|
122
|
+
def matches?: (untyped response_or_body) -> bool
|
|
123
|
+
def does_not_match?: (untyped response_or_body) -> bool
|
|
124
|
+
def failure_message: () -> String
|
|
125
|
+
def failure_message_when_negated: () -> String
|
|
126
|
+
def description: () -> String
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
class HaveStimulusTarget
|
|
130
|
+
def initialize: (String controller_name, String target_name) -> void
|
|
131
|
+
def matches?: (untyped response_or_body) -> bool
|
|
132
|
+
def does_not_match?: (untyped response_or_body) -> bool
|
|
133
|
+
def failure_message: () -> String
|
|
134
|
+
def failure_message_when_negated: () -> String
|
|
135
|
+
def description: () -> String
|
|
136
|
+
end
|
|
137
|
+
|
|
106
138
|
class MatchTurboStreamSnapshot
|
|
107
139
|
def initialize: (String name) -> void
|
|
108
140
|
def matches?: (untyped response_or_body) -> bool
|
|
@@ -128,6 +160,8 @@ module TurboRspec
|
|
|
128
160
|
end
|
|
129
161
|
|
|
130
162
|
module Assertions
|
|
163
|
+
def assert_broadcasted_turbo_stream_to: (String stream, ?action: (Symbol | String)?, ?target: String?, ?targets: String?, ?content: String?, ?partial: String?, ?message: String?) { () -> void } -> void
|
|
164
|
+
def refute_broadcasted_turbo_stream_to: (String stream, ?action: (Symbol | String)?, ?target: String?, ?targets: String?, ?content: String?, ?partial: String?, ?message: String?) { () -> void } -> void
|
|
131
165
|
def assert_turbo_stream: (untyped response_or_body, ?action: (Symbol | String)?, ?target: String?, ?targets: String?, ?content: String?, ?partial: String?, ?message: String?) -> void
|
|
132
166
|
def refute_turbo_stream: (untyped response_or_body, ?action: (Symbol | String)?, ?target: String?, ?targets: String?, ?content: String?, ?partial: String?, ?message: String?) -> void
|
|
133
167
|
def assert_turbo_frame: (untyped response_or_body, ?id: String?, ?content: String?, ?partial: String?, ?message: String?) -> void
|
|
@@ -151,6 +185,8 @@ module TurboRspec
|
|
|
151
185
|
def loaded: () -> self
|
|
152
186
|
def with_src: (String url) -> self
|
|
153
187
|
def lazy: () -> self
|
|
188
|
+
def eager: () -> self
|
|
189
|
+
def strict: () -> self
|
|
154
190
|
|
|
155
191
|
# Capybara matcher interface
|
|
156
192
|
def matches?: (untyped page_or_node) -> bool
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: turbo_rspec
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chuck Smith
|
|
@@ -64,6 +64,9 @@ files:
|
|
|
64
64
|
- lib/turbo_rspec/helpers.rb
|
|
65
65
|
- lib/turbo_rspec/matchers.rb
|
|
66
66
|
- lib/turbo_rspec/matchers/have_broadcasted_turbo_stream_to.rb
|
|
67
|
+
- lib/turbo_rspec/matchers/have_stimulus_action.rb
|
|
68
|
+
- lib/turbo_rspec/matchers/have_stimulus_controller.rb
|
|
69
|
+
- lib/turbo_rspec/matchers/have_stimulus_target.rb
|
|
67
70
|
- lib/turbo_rspec/matchers/have_turbo_frame.rb
|
|
68
71
|
- lib/turbo_rspec/matchers/have_turbo_stream.rb
|
|
69
72
|
- lib/turbo_rspec/matchers/have_turbo_streams.rb
|