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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0a09c91b6fcf16983b8ebbdf20dac6bfb350a9fea07e1779541a05200ba9fc16
4
- data.tar.gz: 43d963aacdbc42a3d90ff131953418c07063e78537bec4cabde4f9189416f06f
3
+ metadata.gz: 3211f811cbd7919d35f8b918260ea97346668fb2ef3213f189d91c642a014b53
4
+ data.tar.gz: dda8641bcf4d6cb1a8ebc6de4ee5d7622f90bcec3a79b8180444b6e4bfcd2c38
5
5
  SHA512:
6
- metadata.gz: 5d01faa2f37db0c9ea82322fd3445a70a8c0201f9176a0f578f6d87a84954ce2f0dbecc5ccaeb6a347aad02d1d47ab16c2fb9568eb0b0b19a2230a6f06917267
7
- data.tar.gz: a3958b07e62ff57af1d72540364df29e374b912752a16c0faf7a3533ed8a08f4fb51dc54ad691c3a67fae39f3aac378f40f16af3d3b567650fc469d076db1707
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
- Assert Stimulus controller, action, and target attributes on the page (Capybara).
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
- # Controllerdata-controller contains "hello"
245
- expect(page).to have_stimulus_controller("hello")
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
- # Actionfull descriptor
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
- @action = action.to_s
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
- @matching = (snapshot - before).select { |msg| message_matches?(msg) }
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.empty?
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#{@stored}\n\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
@@ -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.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurboRspec
4
- VERSION = "1.4.0"
4
+ VERSION = "1.6.0"
5
5
  end
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.0
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