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 +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +8 -0
- data/ROADMAP.md +6 -0
- data/lib/turbo_rspec/matchers/have_broadcasted_turbo_stream_to.rb +103 -6
- data/lib/turbo_rspec/matchers/match_turbo_stream_snapshot.rb +18 -2
- data/lib/turbo_rspec/version.rb +1 -1
- data/sig/turbo_rspec.rbs +2 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d66e89583c0c374bd4b69c308390e275e70623726fd8cdc6f7f44e4df7028e06
|
|
4
|
+
data.tar.gz: a0be5a9653a1cfc59604b506ac2d890db6f1fd931800e21f9eccf528895d3bb8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
@@ -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/version.rb
CHANGED
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
|