turbo_rspec 1.4.0 → 1.5.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: d66e89583c0c374bd4b69c308390e275e70623726fd8cdc6f7f44e4df7028e06
4
+ data.tar.gz: a0be5a9653a1cfc59604b506ac2d890db6f1fd931800e21f9eccf528895d3bb8
5
5
  SHA512:
6
- metadata.gz: 5d01faa2f37db0c9ea82322fd3445a70a8c0201f9176a0f578f6d87a84954ce2f0dbecc5ccaeb6a347aad02d1d47ab16c2fb9568eb0b0b19a2230a6f06917267
7
- data.tar.gz: a3958b07e62ff57af1d72540364df29e374b912752a16c0faf7a3533ed8a08f4fb51dc54ad691c3a67fae39f3aac378f40f16af3d3b567650fc469d076db1707
6
+ metadata.gz: 49f3d0f02e97d332ed7a8c9bc28bd5c555c7ce8d969647a691e38d064afe94a77e8f0b1bd4c42b3138917c0291031aab073ae132482db60569c252acbe889c97
7
+ data.tar.gz: 41dad12a7885d0091afc1d5fa01b322f884c295b12ae753795c59ba86bbf86123281c768415c9bfa89c79d3f2812210680fcb7fd19e7f66a402db0b31cf7106e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.5.0] - 2026-06-02
4
+
5
+ ### Added
6
+
7
+ - `match_turbo_stream_snapshot` failure message now shows a line-by-line inline diff (stored → actual) instead of raw before/after strings
8
+ - `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`
9
+ - `have_broadcasted_turbo_stream_to` now validates action names via `TurboRspec.known_actions`, raising `ArgumentError` for unrecognised actions — consistent with `have_turbo_stream`
10
+ - `have_broadcasted_turbo_stream_to.with_attributes(hash)` — assert arbitrary HTML attributes on broadcast stream elements
11
+ - `have_broadcasted_turbo_stream_to.children_only` — assert the `[children-only]` attribute on morph broadcasts
12
+
3
13
  ## [1.4.0] - 2026-06-02
4
14
 
5
15
  ### Added
data/README.md CHANGED
@@ -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
data/ROADMAP.md CHANGED
@@ -2,6 +2,12 @@
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
+ ## 1.6 — Minitest & Turbo 8 extensions
6
+
7
+ - **`assert_broadcasted_turbo_stream_to` / `refute_broadcasted_turbo_stream_to`** — `TurboRspec::Assertions` covers request-spec stream and frame assertions but has no broadcast counterpart. Teams using Minitest with ActionCable have no parity with the RSpec broadcast matcher.
8
+ - **`have_turbo_frame` Capybara `.eager` and `.strict` chains** — `.lazy` is already supported; Turbo 8 also added `loading="eager"` and a `[strict]` boolean attribute for strict frame loading. Both are missing from the Capybara matcher.
9
+ - **Stimulus request-spec matchers** — `have_stimulus_controller`, `have_stimulus_action`, and `have_stimulus_target` currently only work in Capybara system/feature specs. Teams writing request specs can't assert Stimulus attributes on rendered HTML responses without dropping to raw string matching.
10
+
5
11
  ---
6
12
 
7
13
  ## Guiding principles
@@ -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
@@ -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,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurboRspec
4
- VERSION = "1.4.0"
4
+ VERSION = "1.5.0"
5
5
  end
data/sig/turbo_rspec.rbs CHANGED
@@ -85,6 +85,8 @@ module TurboRspec
85
85
  def targeting_all: (String selector) -> self
86
86
  def with_content: (String text) -> self
87
87
  def rendering: (String partial) -> self
88
+ def with_attributes: (Hash[String | Symbol, untyped] attrs) -> self
89
+ def children_only: () -> self
88
90
 
89
91
  # Count qualifiers
90
92
  def once: () -> self
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.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith