turbo_rspec 0.1.0 → 0.3.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: ba06cebbff8c0ae4b76e280171ccd5ae57d06fe8e027010071f9fd9c923696d2
4
- data.tar.gz: 1838772f6e8673a0949e3e19ccdadd1f7706b6cdd02b77a1dc272bb7c3524f58
3
+ metadata.gz: a7ce28ec048a3f308d82036741fa064f49ecdba1d9d66c7c84af42a3ef2b69e3
4
+ data.tar.gz: ef5cb3cfba998e9b7c64070eb8dbd319532c3e5584c09c7f6d5cc651ec53b71e
5
5
  SHA512:
6
- metadata.gz: f7e2693b3fdd61b62e66e248b9a4dbf16ff52096e4084dc24c3dd56c6dfabfed222f458ef1df188f81378d00815e0c1733399fccddb2520a345c9d0e367d6ed5
7
- data.tar.gz: 2dbd1d94ff557eb93b7f98882a80a03dbc7c43eaece27fdeef5755c7671e179999aafd762a9a8c32ef1ab4c8708291f0bcef8825b860dfcb2d43c75a8b98e10a
6
+ metadata.gz: 6d85a8b12609e066c575f0d728ebffd0827c35fbb5b9d7572e6c7ec62493556aee058fb4dc005b1cfe9f2abe1359f5df7ef1c280bfc74b3427903aab9a8c92e5
7
+ data.tar.gz: 513effacfe8f0a663ae31456e9d82e7acb763ebc6eb04931fb536caab9d9c88752ad405ade01d79a296d8ef67e932ab6ce9e4edf0c6487b46873d9e3dfc4cab5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2026-05-28
4
+
5
+ ### Added
6
+
7
+ - `have_turbo_frame(id)` Capybara matcher for system/feature specs — asserts a `<turbo-frame>` element is on the page
8
+ - `.with_content(text)` — asserts text content within the frame
9
+ - `.loaded` — asserts the frame has the `[complete]` attribute (finished loading)
10
+ - `have_turbo_stream_tag` Capybara matcher — asserts a `<turbo-stream-source>` subscription element is present; accepts an optional signed stream name
11
+ - `within_turbo_frame(id) { }` — scopes Capybara assertions to the frame's DOM
12
+ - Auto-include `TurboRspec::Capybara::Matchers` into `type: :system` and `type: :feature` example groups when both `turbo-rails` and `capybara` are present
13
+
14
+ ## [0.2.0] - 2026-05-28
15
+
16
+ ### Added
17
+
18
+ - `have_broadcasted_turbo_stream_to(stream)` block matcher for asserting ActionCable broadcasts contain a `<turbo-stream>` element
19
+ - Same fluent chain as `have_turbo_stream`: `.with_action`, `.targeting`, `.targeting_all`, `.with_content`, `.rendering`
20
+ - Count qualifiers: `.once`, `.twice`, `.exactly(n).times`, `.at_least(n).times`, `.at_most(n).times`
21
+ - `broadcast_turbo_stream_to` alias for naming symmetry with ActionCable's API
22
+ - Negation via `not_to have_broadcasted_turbo_stream_to` works out of the box
23
+
3
24
  ## [0.1.0] - 2026-05-28
4
25
 
5
26
  ### Added
data/README.md CHANGED
@@ -22,7 +22,10 @@ end
22
22
 
23
23
  ### Rails + turbo-rails (automatic)
24
24
 
25
- No setup needed. When `turbo-rails` is in your bundle, `TurboRspec::Matchers` is automatically included in all `type: :request` example groups.
25
+ No setup needed. When `turbo-rails` is in your bundle:
26
+
27
+ - `TurboRspec::Matchers` is automatically included in all `type: :request` example groups
28
+ - `TurboRspec::Capybara::Matchers` is automatically included in all `type: :system` and `type: :feature` example groups when `capybara` is also present
26
29
 
27
30
  ### Manual include
28
31
 
@@ -31,7 +34,8 @@ For non-Rails projects or custom contexts, include the matchers explicitly:
31
34
  ```ruby
32
35
  # spec/spec_helper.rb
33
36
  RSpec.configure do |config|
34
- config.include TurboRspec::Matchers
37
+ config.include TurboRspec::Matchers # request specs
38
+ config.include TurboRspec::Capybara::Matchers # system/feature specs
35
39
  end
36
40
  ```
37
41
 
@@ -40,7 +44,7 @@ end
40
44
  ```ruby
41
45
  # spec/support/turbo_rspec.rb
42
46
  TurboRspec.configure do |config|
43
- config.auto_include = false # disable automatic inclusion into request specs
47
+ config.auto_include = false # disable automatic inclusion
44
48
  end
45
49
  ```
46
50
 
@@ -106,6 +110,76 @@ expect(response).to have_turbo_frame.with_id("post").rendering("posts/_post")
106
110
  expect(response).not_to have_turbo_frame.with_id("notifications")
107
111
  ```
108
112
 
113
+ ### `have_broadcasted_turbo_stream_to`
114
+
115
+ Assert that a block broadcasts a `<turbo-stream>` over ActionCable. Requires ActionCable's test adapter.
116
+
117
+ ```ruby
118
+ # Basic — any broadcast to the stream
119
+ expect { MyJob.perform_now }.to have_broadcasted_turbo_stream_to("notifications")
120
+
121
+ # With constraints (same chain as have_turbo_stream)
122
+ expect { MyJob.perform_now }.to have_broadcasted_turbo_stream_to("notifications")
123
+ .with_action(:append)
124
+ .targeting("messages")
125
+ .with_content("Hello")
126
+
127
+ # Count qualifiers
128
+ expect { MyJob.perform_now }.to have_broadcasted_turbo_stream_to("notifications").once
129
+ expect { MyJob.perform_now }.to have_broadcasted_turbo_stream_to("notifications").exactly(3).times
130
+ expect { MyJob.perform_now }.to have_broadcasted_turbo_stream_to("notifications").at_least(2).times
131
+
132
+ # Alias
133
+ expect { MyJob.perform_now }.to broadcast_turbo_stream_to("notifications")
134
+
135
+ # Negation
136
+ expect { MyJob.perform_now }.not_to have_broadcasted_turbo_stream_to("notifications")
137
+ ```
138
+
139
+ ### `have_turbo_frame` (system/feature specs)
140
+
141
+ Assert that a `<turbo-frame>` element is present on the page (Capybara).
142
+
143
+ ```ruby
144
+ # Basic
145
+ expect(page).to have_turbo_frame("messages")
146
+
147
+ # With content
148
+ expect(page).to have_turbo_frame("messages").with_content("Hello")
149
+
150
+ # Loaded (frame finished loading)
151
+ expect(page).to have_turbo_frame("messages").loaded
152
+
153
+ # Negation
154
+ expect(page).not_to have_turbo_frame("notifications")
155
+ ```
156
+
157
+ ### `within_turbo_frame`
158
+
159
+ Scope Capybara assertions to a specific frame's DOM.
160
+
161
+ ```ruby
162
+ within_turbo_frame("messages") do
163
+ expect(page).to have_content("Hello")
164
+ click_button "Reply"
165
+ end
166
+ ```
167
+
168
+ ### `have_turbo_stream_tag`
169
+
170
+ Assert that a `<turbo-stream-source>` subscription element is on the page.
171
+
172
+ ```ruby
173
+ # Any stream source
174
+ expect(page).to have_turbo_stream_tag
175
+
176
+ # With signed stream name
177
+ expect(page).to have_turbo_stream_tag("signed_stream_name")
178
+
179
+ # Negation
180
+ expect(page).not_to have_turbo_stream_tag
181
+ ```
182
+
109
183
  ## Example: request spec
110
184
 
111
185
  ```ruby
@@ -136,6 +210,25 @@ RSpec.describe "Messages", type: :request do
136
210
  end
137
211
  ```
138
212
 
213
+ ## Example: system spec
214
+
215
+ ```ruby
216
+ RSpec.describe "Messages", type: :system do
217
+ it "appends a new message via Turbo Frame" do
218
+ visit messages_path
219
+ fill_in "Body", with: "Hello"
220
+ click_button "Send"
221
+
222
+ expect(page).to have_turbo_frame("messages").with_content("Hello")
223
+ end
224
+
225
+ it "shows the subscription stream tag" do
226
+ visit messages_path
227
+ expect(page).to have_turbo_stream_tag
228
+ end
229
+ end
230
+ ```
231
+
139
232
  ## Contributing
140
233
 
141
234
  Bug reports and pull requests are welcome on [GitHub](https://github.com/eclectic-coding/turbo_rspec).
data/ROADMAP.md CHANGED
@@ -5,29 +5,6 @@ RSpec matchers for [Turbo](https://github.com/hotwired/turbo-rails): Turbo Strea
5
5
  ---
6
6
 
7
7
 
8
- ## v0.2.0 — Broadcast matchers
9
-
10
- **Goal:** cover the broadcast side — jobs/services that push streams over ActionCable.
11
-
12
- - `have_broadcasted_turbo_stream_to(channel_or_object)` — wraps ActionCable's test adapter
13
- - Same fluent chain as `have_turbo_stream`: `.with_action`, `.targeting`, `.rendering`, `.with_content`
14
- - Count qualifiers: `.exactly(n).times`, `.at_least(n).times`, `.at_most(n).times`, `.once`, `.twice`
15
- - Works inside `expect { }.to have_broadcasted_turbo_stream_to(...)` blocks
16
- - Helper `broadcast_turbo_stream_to` alias for symmetry with ActionCable's naming
17
- - Docs: "testing broadcasts in job specs and service specs"
18
-
19
- ---
20
-
21
- ## v0.3.0 — Capybara / system spec integration
22
-
23
- **Goal:** assertions that work against a live browser in feature/system specs.
24
-
25
- - `have_turbo_frame(id)` Capybara matcher — waits for the frame to appear on the page
26
- - `.with_content(...)` — delegates to Capybara's `have_content` with correct scope
27
- - `.loaded` — asserts `[complete]` attribute is present (frame finished loading)
28
- - `within_turbo_frame(id) { ... }` — scopes Capybara assertions to the frame's DOM
29
- - `have_turbo_stream_tag` — asserts a `<turbo-stream-source>` subscription element exists on the page
30
- - Docs: system spec patterns, async update testing
31
8
 
32
9
  ---
33
10
 
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurboRspec
4
+ module Capybara
5
+ module Matchers
6
+ class HaveTurboFrame
7
+ def initialize(id)
8
+ @id = id.to_s
9
+ @content = nil
10
+ @loaded = false
11
+ end
12
+
13
+ def with_content(text)
14
+ @content = text.to_s
15
+ self
16
+ end
17
+
18
+ def loaded
19
+ @loaded = true
20
+ self
21
+ end
22
+
23
+ def matches?(page_or_node)
24
+ @node = find_frame(page_or_node)
25
+ return false unless @node
26
+ return false if @loaded && !@node[:complete]
27
+ return false if @content && !@node.has_content?(@content, wait: 0)
28
+ true
29
+ end
30
+
31
+ def does_not_match?(page_or_node)
32
+ !matches?(page_or_node)
33
+ end
34
+
35
+ def failure_message
36
+ if @node.nil?
37
+ "expected page to have turbo-frame##{@id}#{constraint_description} but it was not found"
38
+ elsif @loaded && !@node[:complete]
39
+ "expected turbo-frame##{@id} to be loaded (missing [complete] attribute)"
40
+ else
41
+ "expected turbo-frame##{@id} to have content #{@content.inspect}"
42
+ end
43
+ end
44
+
45
+ def failure_message_when_negated
46
+ "expected page not to have turbo-frame##{@id}#{constraint_description}"
47
+ end
48
+
49
+ def description
50
+ "have turbo-frame##{@id}#{constraint_description}"
51
+ end
52
+
53
+ private
54
+
55
+ def find_frame(page_or_node)
56
+ page_or_node.find("turbo-frame##{@id}", wait: 0)
57
+ rescue ::Capybara::ElementNotFound
58
+ nil
59
+ end
60
+
61
+ def constraint_description
62
+ parts = []
63
+ parts << " loaded" if @loaded
64
+ parts << " with content #{@content.inspect}" if @content
65
+ parts.join
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurboRspec
4
+ module Capybara
5
+ module Matchers
6
+ class HaveTurboStreamTag
7
+ def initialize(signed_stream_name: nil)
8
+ @signed_stream_name = signed_stream_name
9
+ end
10
+
11
+ def matches?(page_or_node)
12
+ selector = build_selector
13
+ page_or_node.has_css?(selector, wait: 0)
14
+ end
15
+
16
+ def does_not_match?(page_or_node)
17
+ selector = build_selector
18
+ page_or_node.has_no_css?(selector, wait: 0)
19
+ end
20
+
21
+ def failure_message
22
+ "expected page to have a turbo-stream-source element#{stream_description}"
23
+ end
24
+
25
+ def failure_message_when_negated
26
+ "expected page not to have a turbo-stream-source element#{stream_description}"
27
+ end
28
+
29
+ def description
30
+ "have turbo-stream-source#{stream_description}"
31
+ end
32
+
33
+ private
34
+
35
+ def build_selector
36
+ if @signed_stream_name
37
+ "turbo-stream-source[src*=\"#{@signed_stream_name}\"]"
38
+ else
39
+ "turbo-stream-source"
40
+ end
41
+ end
42
+
43
+ def stream_description
44
+ @signed_stream_name ? " for #{@signed_stream_name.inspect}" : ""
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "matchers/have_turbo_frame"
4
+ require_relative "matchers/have_turbo_stream_tag"
5
+
6
+ module TurboRspec
7
+ module Capybara
8
+ module Matchers
9
+ def have_turbo_frame(id)
10
+ HaveTurboFrame.new(id)
11
+ end
12
+
13
+ def have_turbo_stream_tag(signed_stream_name = nil)
14
+ HaveTurboStreamTag.new(signed_stream_name: signed_stream_name)
15
+ end
16
+
17
+ # :nocov:
18
+ def within_turbo_frame(id, &block)
19
+ page.within("turbo-frame##{id}", &block)
20
+ end
21
+ # :nocov:
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module TurboRspec
6
+ module Matchers
7
+ class HaveBroadcastedTurboStreamTo
8
+ def initialize(stream_or_object)
9
+ @stream_or_object = stream_or_object
10
+ @action = nil
11
+ @target = nil
12
+ @target_all = nil
13
+ @content = nil
14
+ @partial = nil
15
+ @expected_count = nil
16
+ @count_type = :at_least
17
+ end
18
+
19
+ # Stream constraints (mirrors HaveTurboStream)
20
+
21
+ def with_action(action)
22
+ @action = action.to_s
23
+ self
24
+ end
25
+
26
+ def targeting(dom_id)
27
+ @target = dom_id.to_s
28
+ self
29
+ end
30
+
31
+ def targeting_all(selector)
32
+ @target_all = selector.to_s
33
+ self
34
+ end
35
+
36
+ def with_content(text)
37
+ @content = text.to_s
38
+ self
39
+ end
40
+
41
+ def rendering(partial)
42
+ @partial = partial.to_s
43
+ self
44
+ end
45
+
46
+ # Count qualifiers
47
+
48
+ def once
49
+ exactly(1)
50
+ end
51
+
52
+ def twice
53
+ exactly(2)
54
+ end
55
+
56
+ def exactly(n)
57
+ @expected_count = n
58
+ @count_type = :exactly
59
+ self
60
+ end
61
+
62
+ def at_least(n)
63
+ @expected_count = n
64
+ @count_type = :at_least
65
+ self
66
+ end
67
+
68
+ def at_most(n)
69
+ @expected_count = n
70
+ @count_type = :at_most
71
+ self
72
+ end
73
+
74
+ def times
75
+ self
76
+ end
77
+
78
+ def supports_block_expectations?
79
+ true
80
+ end
81
+
82
+ def matches?(block)
83
+ before = snapshot
84
+ block.call
85
+ @matching = (snapshot - before).select { |msg| message_matches?(msg) }
86
+ count_matches?(@matching.size)
87
+ end
88
+
89
+ def does_not_match?(block)
90
+ !matches?(block)
91
+ end
92
+
93
+ def failure_message
94
+ "expected block to broadcast a turbo stream to #{stream_name.inspect}#{constraint_description}#{count_description}\n#{found_message}"
95
+ end
96
+
97
+ def failure_message_when_negated
98
+ "expected block not to broadcast a turbo stream to #{stream_name.inspect}#{constraint_description}"
99
+ end
100
+
101
+ def description
102
+ "broadcast a turbo stream to #{stream_name.inspect}#{constraint_description}"
103
+ end
104
+
105
+ private
106
+
107
+ def stream_name
108
+ @stream_name ||= if @stream_or_object.respond_to?(:to_str)
109
+ @stream_or_object
110
+ elsif defined?(Turbo::StreamsChannel)
111
+ Turbo::StreamsChannel.broadcasting_for(@stream_or_object)
112
+ else
113
+ @stream_or_object.to_s
114
+ end
115
+ end
116
+
117
+ def snapshot
118
+ ActionCable.server.pubsub.broadcasts(stream_name).dup
119
+ end
120
+
121
+ def message_matches?(message)
122
+ html = JSON.parse(message)
123
+ streams = Nokogiri::HTML5.fragment(html).css("turbo-stream")
124
+ streams.any? { |stream| stream_matches?(stream) }
125
+ rescue JSON::ParserError
126
+ false
127
+ end
128
+
129
+ def stream_matches?(stream)
130
+ matches_action?(stream) &&
131
+ matches_target?(stream) &&
132
+ matches_target_all?(stream) &&
133
+ matches_content?(stream) &&
134
+ matches_partial?(stream)
135
+ end
136
+
137
+ def matches_action?(stream)
138
+ @action.nil? || stream["action"] == @action
139
+ end
140
+
141
+ def matches_target?(stream)
142
+ @target.nil? || stream["target"] == @target
143
+ end
144
+
145
+ def matches_target_all?(stream)
146
+ @target_all.nil? || stream["targets"] == @target_all
147
+ end
148
+
149
+ def matches_content?(stream)
150
+ return true if @content.nil?
151
+ stream.text.include?(@content)
152
+ end
153
+
154
+ def matches_partial?(stream)
155
+ return true if @partial.nil?
156
+ stream.to_html.include?(@partial)
157
+ end
158
+
159
+ def count_matches?(n)
160
+ if @expected_count.nil?
161
+ n >= 1
162
+ else
163
+ # :nocov:
164
+ case @count_type
165
+ # :nocov:
166
+ when :exactly then n == @expected_count
167
+ when :at_least then n >= @expected_count
168
+ when :at_most then n <= @expected_count
169
+ end
170
+ end
171
+ end
172
+
173
+ def constraint_description
174
+ parts = []
175
+ parts << " with action #{@action.inspect}" if @action
176
+ parts << " targeting #{@target.inspect}" if @target
177
+ parts << " targeting all #{@target_all.inspect}" if @target_all
178
+ parts << " with content #{@content.inspect}" if @content
179
+ parts << " rendering #{@partial.inspect}" if @partial
180
+ parts.join
181
+ end
182
+
183
+ def count_description
184
+ return "" if @expected_count.nil? && @count_type == :at_least
185
+ # :nocov:
186
+ case @count_type
187
+ # :nocov:
188
+ when :exactly then " exactly #{@expected_count} time(s)"
189
+ when :at_least then " at least #{@expected_count} time(s)"
190
+ when :at_most then " at most #{@expected_count} time(s)"
191
+ end
192
+ end
193
+
194
+ def found_message
195
+ if @matching.empty?
196
+ "but no matching broadcasts were found"
197
+ else
198
+ "found #{@matching.size} matching broadcast(s)"
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
@@ -1,10 +1,17 @@
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
 
6
7
  module TurboRspec
7
8
  module Matchers
9
+ def have_broadcasted_turbo_stream_to(stream_or_object)
10
+ HaveBroadcastedTurboStreamTo.new(stream_or_object)
11
+ end
12
+
13
+ alias_method :broadcast_turbo_stream_to, :have_broadcasted_turbo_stream_to
14
+
8
15
  def have_turbo_frame
9
16
  HaveTurboFrame.new
10
17
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurboRspec
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.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/capybara/matchers"
6
7
 
7
8
  module TurboRspec
8
9
  class Error < StandardError; end
@@ -19,12 +20,20 @@ module TurboRspec
19
20
  def reset_configuration!
20
21
  @configuration = Configuration.new
21
22
  end
23
+
24
+ def install_rspec_integration(config)
25
+ return unless configuration.auto_include && Gem.loaded_specs.key?("turbo-rails")
26
+ config.include Matchers, type: :request
27
+ if Gem.loaded_specs.key?("capybara")
28
+ config.include Capybara::Matchers, type: :system
29
+ config.include Capybara::Matchers, type: :feature
30
+ end
31
+ end
22
32
  end
23
33
  end
24
34
 
35
+ # :nocov:
25
36
  if defined?(RSpec)
26
- RSpec.configure do |config|
27
- config.include TurboRspec::Matchers, type: :request if TurboRspec.configuration.auto_include &&
28
- Gem.loaded_specs.key?("turbo-rails")
29
- end
37
+ RSpec.configure { |config| TurboRspec.install_rspec_integration(config) }
30
38
  end
39
+ # :nocov:
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.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -43,8 +43,12 @@ files:
43
43
  - Rakefile
44
44
  - codecov.yml
45
45
  - lib/turbo_rspec.rb
46
+ - lib/turbo_rspec/capybara/matchers.rb
47
+ - lib/turbo_rspec/capybara/matchers/have_turbo_frame.rb
48
+ - lib/turbo_rspec/capybara/matchers/have_turbo_stream_tag.rb
46
49
  - lib/turbo_rspec/configuration.rb
47
50
  - lib/turbo_rspec/matchers.rb
51
+ - lib/turbo_rspec/matchers/have_broadcasted_turbo_stream_to.rb
48
52
  - lib/turbo_rspec/matchers/have_turbo_frame.rb
49
53
  - lib/turbo_rspec/matchers/have_turbo_stream.rb
50
54
  - lib/turbo_rspec/version.rb