turbo_rspec 0.3.0 → 0.4.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: a7ce28ec048a3f308d82036741fa064f49ecdba1d9d66c7c84af42a3ef2b69e3
4
- data.tar.gz: ef5cb3cfba998e9b7c64070eb8dbd319532c3e5584c09c7f6d5cc651ec53b71e
3
+ metadata.gz: 9752bc68a0676737d5e31eda8279871c6aa23e09c0cafb6eab046ae153f8a103
4
+ data.tar.gz: d6a13d4d95ee05e4fb6221a36aaebc807e367d0a313b9f8e00eaa307138183dc
5
5
  SHA512:
6
- metadata.gz: 6d85a8b12609e066c575f0d728ebffd0827c35fbb5b9d7572e6c7ec62493556aee058fb4dc005b1cfe9f2abe1359f5df7ef1c280bfc74b3427903aab9a8c92e5
7
- data.tar.gz: 513effacfe8f0a663ae31456e9d82e7acb763ebc6eb04931fb536caab9d9c88752ad405ade01d79a296d8ef67e932ab6ce9e4edf0c6487b46873d9e3dfc4cab5
6
+ metadata.gz: 78309d93bfac9a17816989f570dd287f10ea149bb4781be1a6e514449387862d46cf93cf30914e456bc5d0c88c7585aff998fe62831902688edd0a3081007f86
7
+ data.tar.gz: dc224517ae6717151a75f01c45028732949fe06fc65248b20dc17807318a363b1aa96c375c10c2dcbb7ebadab686a3cf4d859105d15b1a00b579c1dba4478f18
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2026-05-28
4
+
5
+ ### Added
6
+
7
+ - `have_turbo_streams(*matchers)` — assert multiple streams in one expectation; failure lists each unmatched stream
8
+ - `assert_no_turbo_stream` — alias of `have_turbo_stream` for teams that mix RSpec/minitest terminology
9
+ - Rich failure messages for `have_turbo_stream` and `have_turbo_frame`: content preview on each found element, plus a "closest match" section with per-constraint pass (✓) / fail (✗) indicators
10
+
3
11
  ## [0.3.0] - 2026-05-28
4
12
 
5
13
  ### Added
data/README.md CHANGED
@@ -89,6 +89,27 @@ expect(response).not_to have_turbo_stream.with_action(:replace)
89
89
 
90
90
  Turbo supports the following stream actions: `append`, `prepend`, `replace`, `update`, `remove`, `before`, `after`, `refresh`.
91
91
 
92
+ ### `have_turbo_streams`
93
+
94
+ Assert that a response contains **all** of the specified streams in one expectation.
95
+
96
+ ```ruby
97
+ expect(response).to have_turbo_streams(
98
+ have_turbo_stream.with_action(:append).targeting("messages"),
99
+ have_turbo_stream.with_action(:replace).targeting("header")
100
+ )
101
+ ```
102
+
103
+ When a stream is missing the failure message lists each unmatched matcher so you can see at a glance which ones failed.
104
+
105
+ ### `assert_no_turbo_stream`
106
+
107
+ Alias of `have_turbo_stream` for teams that mix RSpec and minitest terminology.
108
+
109
+ ```ruby
110
+ expect(response).not_to assert_no_turbo_stream
111
+ ```
112
+
92
113
  ### `have_turbo_frame`
93
114
 
94
115
  Assert that a response body contains a `<turbo-frame>` element.
data/ROADMAP.md CHANGED
@@ -5,26 +5,13 @@ RSpec matchers for [Turbo](https://github.com/hotwired/turbo-rails): Turbo Strea
5
5
  ---
6
6
 
7
7
 
8
-
9
- ---
10
-
11
- ## v0.4.0 — Developer experience pass
12
-
13
- **Goal:** make failure output good enough that you never have to drop into a debugger just to read a matcher failure.
14
-
15
- - Rich failure messages: show actual stream actions/targets found vs. expected
16
- - `assert_no_turbo_stream` alias for teams that mix RSpec/minitest terminology
17
- - Composable matchers: `include(have_turbo_stream(...), have_turbo_stream(...))` for multi-stream assertions
18
- - `have_turbo_streams` (plural) — assert multiple streams in one expectation with an array DSL
19
- - Support for `aggregate_failures` blocks
20
-
21
8
  ---
22
9
 
23
10
  ## v0.5.0 — Compatibility and edge cases
24
11
 
25
12
  **Goal:** harden the gem against real-world app variations.
26
13
 
27
- - Rails 7.1/7.2/8.0/8.1 and Turbo 1.x/2.x compatibility matrix in CI
14
+ - Rails 7.2/8.0/8.1 and Turbo 1.x/2.x compatibility matrix in CI (7.1 is EOL)
28
15
  - Multi-stream response body parsing (a single response can contain multiple `<turbo-stream>` tags)
29
16
  - `refresh` action support (Turbo 8 page refresh streams)
30
17
  - `morph` action support (Turbo Morphing)
@@ -33,14 +20,34 @@ RSpec matchers for [Turbo](https://github.com/hotwired/turbo-rails): Turbo Strea
33
20
 
34
21
  ---
35
22
 
23
+ ## v0.6.0 — Testing utilities
24
+
25
+ **Goal:** reduce boilerplate in real test suites and close the controller spec gap.
26
+
27
+ - `turbo_stream_html(action:, target:, content: nil)` — factory helper for building `<turbo-stream>` HTML inline in tests
28
+ - Shared examples: `it_behaves_like "a turbo stream response"` and `"a turbo frame response"` for common assertions
29
+ - Controller spec support — `have_turbo_stream` and `have_turbo_frame` working against `response` in `type: :controller`
30
+ - Auto-include `TurboRspec::Matchers` into `type: :controller` when `turbo-rails` is present
31
+
32
+ ---
33
+
34
+ ## v0.7.0 — Documentation
35
+
36
+ **Goal:** full docs before freezing the API.
37
+
38
+ - Full YARD documentation on all public methods and classes
39
+ - Migration guide: "replacing hand-rolled Turbo helpers in your test suite"
40
+ - Cookbook: common patterns (lazy-loaded frames, job broadcast testing, multi-stream responses, controller specs)
41
+ - Hosted on RubyDoc.info
42
+
43
+ ---
44
+
36
45
  ## v1.0.0 — Stable API
37
46
 
38
47
  **Goal:** API freeze. Commit to semver stability. Make the gem the obvious default choice.
39
48
 
40
49
  - API stability guarantee: no breaking changes without a major version bump
41
50
  - `TurboRspec::VERSION` semantic versioning enforced via CI check
42
- - Migration guide: "replacing hand-rolled Turbo Stream helpers" in the docs
43
- - Full YARD documentation with `yard` and hosted on RubyDoc.info
44
51
  - 100% branch coverage enforced in CI (`simplecov`)
45
52
  - Performance: benchmark matcher overhead to keep it negligible in large suites
46
53
  - `bin/release` script (mirrors solid_queue_web pattern): bump version, update CHANGELOG, tag, push; CI publishes via Trusted Publishing
@@ -91,12 +91,45 @@ module TurboRspec
91
91
  end
92
92
 
93
93
  def found_frames_message
94
- if @frames.empty?
95
- "but no turbo frames were found in the response"
96
- else
97
- ids = @frames.map { |f| " <turbo-frame id=#{f["id"].inspect}>" }
98
- "found turbo frames:\n#{ids.join("\n")}"
94
+ return "but no turbo frames were found in the response" if @frames.empty?
95
+
96
+ lines = ["found #{@frames.size} turbo frame(s):"]
97
+ @frames.each_with_index do |f, i|
98
+ content_preview = f.text.strip.slice(0, 50)
99
+ content_preview = content_preview.empty? ? "(empty)" : content_preview.inspect
100
+ lines << " #{i + 1}. id=#{f["id"].inspect} content=#{content_preview}"
99
101
  end
102
+
103
+ closest = closest_match
104
+ lines << ""
105
+ lines << "closest match (#{count_matching_constraints(closest)}/#{constraint_count} constraint(s) matched):"
106
+ lines.concat(constraint_diff(closest))
107
+
108
+ lines.join("\n")
109
+ end
110
+
111
+ def closest_match
112
+ @frames.max_by { |f| count_matching_constraints(f) }
113
+ end
114
+
115
+ def count_matching_constraints(frame)
116
+ count = 0
117
+ count += 1 if !@id.nil? && matches_id?(frame)
118
+ count += 1 if !@content.nil? && matches_content?(frame)
119
+ count += 1 if !@partial.nil? && matches_partial?(frame)
120
+ count
121
+ end
122
+
123
+ def constraint_count
124
+ [@id, @content, @partial].count { |c| !c.nil? }
125
+ end
126
+
127
+ def constraint_diff(frame)
128
+ lines = []
129
+ lines << " #{matches_id?(frame) ? "✓" : "✗"} id: expected #{@id.inspect}, got #{frame["id"].inspect}" if @id
130
+ lines << " #{matches_content?(frame) ? "✓" : "✗"} content: expected to include #{@content.inspect}, got #{frame.text.strip.slice(0, 50).inspect}" if @content
131
+ lines << " #{matches_partial?(frame) ? "✓" : "✗"} rendering: expected to include #{@partial.inspect}" if @partial
132
+ lines
100
133
  end
101
134
  end
102
135
  end
@@ -115,12 +115,49 @@ module TurboRspec
115
115
  end
116
116
 
117
117
  def found_streams_message
118
- if @streams.empty?
119
- "but no turbo streams were found in the response"
120
- else
121
- actions = @streams.map { |s| " <turbo-stream action=#{s["action"].inspect} target=#{s["target"].inspect}>" }
122
- "found turbo streams:\n#{actions.join("\n")}"
118
+ return "but no turbo streams were found in the response" if @streams.empty?
119
+
120
+ lines = ["found #{@streams.size} turbo stream(s):"]
121
+ @streams.each_with_index do |s, i|
122
+ content_preview = s.text.strip.slice(0, 50)
123
+ content_preview = content_preview.empty? ? "(empty)" : content_preview.inspect
124
+ lines << " #{i + 1}. action=#{s["action"].inspect} target=#{s["target"].inspect} content=#{content_preview}"
123
125
  end
126
+
127
+ closest = closest_match
128
+ lines << ""
129
+ lines << "closest match (#{count_matching_constraints(closest)}/#{constraint_count} constraint(s) matched):"
130
+ lines.concat(constraint_diff(closest))
131
+
132
+ lines.join("\n")
133
+ end
134
+
135
+ def closest_match
136
+ @streams.max_by { |s| count_matching_constraints(s) }
137
+ end
138
+
139
+ def count_matching_constraints(stream)
140
+ count = 0
141
+ count += 1 if !@action.nil? && matches_action?(stream)
142
+ count += 1 if !@target.nil? && matches_target?(stream)
143
+ count += 1 if !@target_all.nil? && matches_target_all?(stream)
144
+ count += 1 if !@content.nil? && matches_content?(stream)
145
+ count += 1 if !@partial.nil? && matches_partial?(stream)
146
+ count
147
+ end
148
+
149
+ def constraint_count
150
+ [@action, @target, @target_all, @content, @partial].count { |c| !c.nil? }
151
+ end
152
+
153
+ def constraint_diff(stream)
154
+ lines = []
155
+ lines << " #{matches_action?(stream) ? "✓" : "✗"} action: expected #{@action.inspect}, got #{stream["action"].inspect}" if @action
156
+ lines << " #{matches_target?(stream) ? "✓" : "✗"} target: expected #{@target.inspect}, got #{stream["target"].inspect}" if @target
157
+ lines << " #{matches_target_all?(stream) ? "✓" : "✗"} targets: expected #{@target_all.inspect}, got #{stream["targets"].inspect}" if @target_all
158
+ lines << " #{matches_content?(stream) ? "✓" : "✗"} content: expected to include #{@content.inspect}, got #{stream.text.strip.slice(0, 50).inspect}" if @content
159
+ lines << " #{matches_partial?(stream) ? "✓" : "✗"} rendering: expected to include #{@partial.inspect}" if @partial
160
+ lines
124
161
  end
125
162
  end
126
163
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module TurboRspec
6
+ module Matchers
7
+ class HaveTurboStreams
8
+ def initialize(expected_streams)
9
+ @expected_streams = expected_streams
10
+ end
11
+
12
+ def matches?(response_or_body)
13
+ @body = extract_body(response_or_body)
14
+ @found = parse_streams(@body)
15
+ @unmatched = @expected_streams.reject { |expected| any_stream_matches?(expected) }
16
+ @unmatched.empty?
17
+ end
18
+
19
+ def does_not_match?(response_or_body)
20
+ !matches?(response_or_body)
21
+ end
22
+
23
+ def failure_message
24
+ descriptions = @unmatched.map { |m| " #{m.description}" }.join("\n")
25
+ "expected response to contain all turbo streams, but missing:\n#{descriptions}\n\n" \
26
+ "found streams:\n#{found_streams_summary}"
27
+ end
28
+
29
+ def failure_message_when_negated
30
+ "expected response not to contain all of the specified turbo streams"
31
+ end
32
+
33
+ def description
34
+ "have turbo streams: #{@expected_streams.map(&:description).join(", ")}"
35
+ end
36
+
37
+ private
38
+
39
+ def extract_body(response_or_body)
40
+ if response_or_body.respond_to?(:body)
41
+ response_or_body.body
42
+ else
43
+ response_or_body.to_s
44
+ end
45
+ end
46
+
47
+ def parse_streams(body)
48
+ Nokogiri::HTML5.fragment(body).css("turbo-stream")
49
+ end
50
+
51
+ def any_stream_matches?(matcher)
52
+ @found.any? { |stream| stream_matches_matcher?(stream, matcher) }
53
+ end
54
+
55
+ def stream_matches_matcher?(stream, matcher)
56
+ matcher.send(:stream_matches?, stream)
57
+ end
58
+
59
+ def found_streams_summary
60
+ return " (none)" if @found.empty?
61
+ @found.map { |s| " <turbo-stream action=#{s["action"].inspect} target=#{s["target"].inspect}>" }.join("\n")
62
+ end
63
+ end
64
+ end
65
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative "matchers/have_broadcasted_turbo_stream_to"
4
4
  require_relative "matchers/have_turbo_frame"
5
5
  require_relative "matchers/have_turbo_stream"
6
+ require_relative "matchers/have_turbo_streams"
6
7
 
7
8
  module TurboRspec
8
9
  module Matchers
@@ -19,5 +20,11 @@ module TurboRspec
19
20
  def have_turbo_stream
20
21
  HaveTurboStream.new
21
22
  end
23
+
24
+ alias_method :assert_no_turbo_stream, :have_turbo_stream
25
+
26
+ def have_turbo_streams(*matchers)
27
+ HaveTurboStreams.new(matchers)
28
+ end
22
29
  end
23
30
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurboRspec
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
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: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -51,6 +51,7 @@ files:
51
51
  - lib/turbo_rspec/matchers/have_broadcasted_turbo_stream_to.rb
52
52
  - lib/turbo_rspec/matchers/have_turbo_frame.rb
53
53
  - lib/turbo_rspec/matchers/have_turbo_stream.rb
54
+ - lib/turbo_rspec/matchers/have_turbo_streams.rb
54
55
  - lib/turbo_rspec/version.rb
55
56
  - sig/turbo_rspec.rbs
56
57
  homepage: https://github.com/eclectic-coding/turbo_rspec