turbo_rspec 0.3.0 → 0.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: a7ce28ec048a3f308d82036741fa064f49ecdba1d9d66c7c84af42a3ef2b69e3
4
- data.tar.gz: ef5cb3cfba998e9b7c64070eb8dbd319532c3e5584c09c7f6d5cc651ec53b71e
3
+ metadata.gz: c9273ce0d0b091c73a80681f52986b639412d89b64fff6a8d79f40ce2b742997
4
+ data.tar.gz: 84d380b306f6a75ebc0af9ae8c5beed6c141c0da44ea736706eeb17392c63489
5
5
  SHA512:
6
- metadata.gz: 6d85a8b12609e066c575f0d728ebffd0827c35fbb5b9d7572e6c7ec62493556aee058fb4dc005b1cfe9f2abe1359f5df7ef1c280bfc74b3427903aab9a8c92e5
7
- data.tar.gz: 513effacfe8f0a663ae31456e9d82e7acb763ebc6eb04931fb536caab9d9c88752ad405ade01d79a296d8ef67e932ab6ce9e4edf0c6487b46873d9e3dfc4cab5
6
+ metadata.gz: 3965ea0fe3c731120ffa83a5efc331c1bd420e3271c153f1e3be37c1b62fe5b8243ae3fc20f4a8ab8723403ed0a9dd0aa7f07ad242a864b769e242dd6087a2f1
7
+ data.tar.gz: 82357eebbe233ed14882216b2075f29d1a97e4d0aed68c789a79747da1ac4aaf84a88ef8b8d0273204a9cd81c3f6e2dc5b04d933bca6e16b988e8749538812ff
@@ -39,12 +39,13 @@ jobs:
39
39
  - run: bundle exec bundle-audit check
40
40
 
41
41
  test:
42
- name: Ruby ${{ matrix.ruby }}
42
+ name: Ruby ${{ matrix.ruby }} / Rails ${{ matrix.rails }}
43
43
  runs-on: ubuntu-latest
44
44
  strategy:
45
45
  fail-fast: false
46
46
  matrix:
47
47
  ruby: ["3.3", "3.4", "4.0"]
48
+ rails: ["7.2", "8.0", "8.1"]
48
49
 
49
50
  steps:
50
51
  - uses: actions/checkout@v6
@@ -54,4 +55,6 @@ jobs:
54
55
  ruby-version: ${{ matrix.ruby }}
55
56
  bundler-cache: true
56
57
 
57
- - run: bundle exec rspec
58
+ - run: bundle exec rspec
59
+ env:
60
+ RAILS_VERSION: ${{ matrix.rails }}
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.0] - 2026-05-28
4
+
5
+ ### Added
6
+
7
+ - `TurboRspec::Assertions` — opt-in minitest companion module with `assert_turbo_stream`, `refute_turbo_stream`, `assert_turbo_frame`, `refute_turbo_frame`; no RSpec dependency required
8
+ - `refresh` and `morph` action support confirmed working via compatibility specs
9
+ - Multi-stream response body parsing confirmed — a single response body with multiple `<turbo-stream>` tags works with all matchers
10
+ - Graceful no-op when `turbo-rails` is not in the Gemfile — no `LoadError`
11
+ - CI Rails matrix: Ruby 3.3/3.4/4.0 × Rails 7.2/8.0/8.1
12
+
13
+ ## [0.4.0] - 2026-05-28
14
+
15
+ ### Added
16
+
17
+ - `have_turbo_streams(*matchers)` — assert multiple streams in one expectation; failure lists each unmatched stream
18
+ - `assert_no_turbo_stream` — alias of `have_turbo_stream` for teams that mix RSpec/minitest terminology
19
+ - 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
20
+
3
21
  ## [0.3.0] - 2026-05-28
4
22
 
5
23
  ### 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.
@@ -229,6 +250,35 @@ RSpec.describe "Messages", type: :system do
229
250
  end
230
251
  ```
231
252
 
253
+ ## Minitest support
254
+
255
+ `TurboRspec::Assertions` is an opt-in companion module with no RSpec dependency. Include it in any Minitest test class:
256
+
257
+ ```ruby
258
+ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
259
+ include TurboRspec::Assertions
260
+ end
261
+ ```
262
+
263
+ ### Available assertions
264
+
265
+ ```ruby
266
+ # Stream assertions
267
+ assert_turbo_stream(response, action: :append, target: "messages")
268
+ assert_turbo_stream(response, action: :append, target: "messages", content: "Hello")
269
+ assert_turbo_stream(response, targets: ".items")
270
+ assert_turbo_stream(response, partial: "messages/_message")
271
+ refute_turbo_stream(response, action: :replace)
272
+
273
+ # Frame assertions
274
+ assert_turbo_frame(response, id: "messages")
275
+ assert_turbo_frame(response, id: "messages", content: "Hello")
276
+ refute_turbo_frame(response, id: "notifications")
277
+
278
+ # Custom failure message
279
+ assert_turbo_stream(response, action: :append, message: "expected append stream")
280
+ ```
281
+
232
282
  ## Contributing
233
283
 
234
284
  Bug reports and pull requests are welcome on [GitHub](https://github.com/eclectic-coding/turbo_rspec).
data/ROADMAP.md CHANGED
@@ -4,32 +4,25 @@ RSpec matchers for [Turbo](https://github.com/hotwired/turbo-rails): Turbo Strea
4
4
 
5
5
  ---
6
6
 
7
+ ## v0.6.0 — Testing utilities
7
8
 
9
+ **Goal:** reduce boilerplate in real test suites and close the controller spec gap.
8
10
 
9
- ---
10
-
11
- ## v0.4.0Developer 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
11
+ - `turbo_stream_html(action:, target:, content: nil)` — factory helper for building `<turbo-stream>` HTML inline in tests
12
+ - Shared examples: `it_behaves_like "a turbo stream response"` and `"a turbo frame response"` for common assertions
13
+ - Controller spec support `have_turbo_stream` and `have_turbo_frame` working against `response` in `type: :controller`
14
+ - Auto-include `TurboRspec::Matchers` into `type: :controller` when `turbo-rails` is present
20
15
 
21
16
  ---
22
17
 
23
- ## v0.5.0 — Compatibility and edge cases
18
+ ## v0.7.0 — Documentation
24
19
 
25
- **Goal:** harden the gem against real-world app variations.
20
+ **Goal:** full docs before freezing the API.
26
21
 
27
- - Rails 7.1/7.2/8.0/8.1 and Turbo 1.x/2.x compatibility matrix in CI
28
- - Multi-stream response body parsing (a single response can contain multiple `<turbo-stream>` tags)
29
- - `refresh` action support (Turbo 8 page refresh streams)
30
- - `morph` action support (Turbo Morphing)
31
- - Graceful no-op when `turbo-rails` is not in the Gemfile (no `LoadError`)
32
- - Minitest module (`TurboRspec::Assertions`) as opt-in companion (no RSpec dependency for that module)
22
+ - Full YARD documentation on all public methods and classes
23
+ - Migration guide: "replacing hand-rolled Turbo helpers in your test suite"
24
+ - Cookbook: common patterns (lazy-loaded frames, job broadcast testing, multi-stream responses, controller specs)
25
+ - Hosted on RubyDoc.info
33
26
 
34
27
  ---
35
28
 
@@ -39,8 +32,6 @@ RSpec matchers for [Turbo](https://github.com/hotwired/turbo-rails): Turbo Strea
39
32
 
40
33
  - API stability guarantee: no breaking changes without a major version bump
41
34
  - `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
35
  - 100% branch coverage enforced in CI (`simplecov`)
45
36
  - Performance: benchmark matcher overhead to keep it negligible in large suites
46
37
  - `bin/release` script (mirrors solid_queue_web pattern): bump version, update CHANGELOG, tag, push; CI publishes via Trusted Publishing
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "matchers/have_turbo_frame"
4
+ require_relative "matchers/have_turbo_stream"
5
+
6
+ module TurboRspec
7
+ # Minitest-compatible assertions. Include in your test class:
8
+ #
9
+ # class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
10
+ # include TurboRspec::Assertions
11
+ # end
12
+ #
13
+ # No RSpec dependency required.
14
+ module Assertions
15
+ def assert_turbo_stream(response_or_body, action: nil, target: nil, targets: nil, content: nil, partial: nil, message: nil)
16
+ matcher = build_stream_matcher(action: action, target: target, targets: targets, content: content, partial: partial)
17
+ assert matcher.matches?(response_or_body), message || matcher.failure_message
18
+ end
19
+
20
+ def refute_turbo_stream(response_or_body, action: nil, target: nil, targets: nil, content: nil, partial: nil, message: nil)
21
+ matcher = build_stream_matcher(action: action, target: target, targets: targets, content: content, partial: partial)
22
+ assert matcher.does_not_match?(response_or_body), message || matcher.failure_message_when_negated
23
+ end
24
+
25
+ def assert_turbo_frame(response_or_body, id: nil, content: nil, partial: nil, message: nil)
26
+ matcher = build_frame_matcher(id: id, content: content, partial: partial)
27
+ assert matcher.matches?(response_or_body), message || matcher.failure_message
28
+ end
29
+
30
+ def refute_turbo_frame(response_or_body, id: nil, content: nil, partial: nil, message: nil)
31
+ matcher = build_frame_matcher(id: id, content: content, partial: partial)
32
+ assert matcher.does_not_match?(response_or_body), message || matcher.failure_message_when_negated
33
+ end
34
+
35
+ private
36
+
37
+ def build_stream_matcher(action:, target:, targets:, content:, partial:)
38
+ matcher = Matchers::HaveTurboStream.new
39
+ matcher.with_action(action) if action
40
+ matcher.targeting(target) if target
41
+ matcher.targeting_all(targets) if targets
42
+ matcher.with_content(content) if content
43
+ matcher.rendering(partial) if partial
44
+ matcher
45
+ end
46
+
47
+ def build_frame_matcher(id:, content:, partial:)
48
+ matcher = Matchers::HaveTurboFrame.new
49
+ matcher.with_id(id) if id
50
+ matcher.with_content(content) if content
51
+ matcher.rendering(partial) if partial
52
+ matcher
53
+ end
54
+ end
55
+ end
@@ -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.5.0"
5
5
  end
data/lib/turbo_rspec.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative "turbo_rspec/version"
4
4
  require_relative "turbo_rspec/configuration"
5
5
  require_relative "turbo_rspec/matchers"
6
+ require_relative "turbo_rspec/assertions"
6
7
  require_relative "turbo_rspec/capybara/matchers"
7
8
 
8
9
  module TurboRspec
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.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -43,6 +43,7 @@ files:
43
43
  - Rakefile
44
44
  - codecov.yml
45
45
  - lib/turbo_rspec.rb
46
+ - lib/turbo_rspec/assertions.rb
46
47
  - lib/turbo_rspec/capybara/matchers.rb
47
48
  - lib/turbo_rspec/capybara/matchers/have_turbo_frame.rb
48
49
  - lib/turbo_rspec/capybara/matchers/have_turbo_stream_tag.rb
@@ -51,6 +52,7 @@ files:
51
52
  - lib/turbo_rspec/matchers/have_broadcasted_turbo_stream_to.rb
52
53
  - lib/turbo_rspec/matchers/have_turbo_frame.rb
53
54
  - lib/turbo_rspec/matchers/have_turbo_stream.rb
55
+ - lib/turbo_rspec/matchers/have_turbo_streams.rb
54
56
  - lib/turbo_rspec/version.rb
55
57
  - sig/turbo_rspec.rbs
56
58
  homepage: https://github.com/eclectic-coding/turbo_rspec