turbo_rspec 1.1.0 → 1.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: 8356be265d63e581709f7252b6981d9f721172d68e417e23bc02581214773629
4
- data.tar.gz: 3394a171db1031c16533bde8eb141eb1cd1f40bfed737b47c4f6e80b05ca23b5
3
+ metadata.gz: cdd85fcc9f87814d171569e835ad85de4767daf48642d5797f15ac02decf4641
4
+ data.tar.gz: a92c047884633fd146aa6806dc350edef72bd18747c39e3da618feb12223a29e
5
5
  SHA512:
6
- metadata.gz: c2094508851098b590cd9539c01b60768a44f959b4af7bb20083e736a6e9b7631776f8bdde870fd543a7302d0fb1983a839932d8839b74de450274d00470c335
7
- data.tar.gz: 06c44da054b038c40cb83b681da7d9897f3d76f436afe2adf484c1cd2cab243caf2f824d763ce958172d9d66f02c11e2646130fbe31c47a59df68ac800e0ab33
6
+ metadata.gz: 7ecdc1dc0c21c083a32270e7ab085e31e5fd4e6afde0193c9f58a967e7d79aaf543387e874d8cc2fd4f1afdcb0b56a2b2b53b6c6a230f3d80a41736b68c5c01e
7
+ data.tar.gz: b12927d25812f2991d889a7cedc37fe02cf4feb439adc76555dcfc8d6fd31151f9dd5cd80257ec82057d350c52e607cc2206e924bf3b159257453de9fad4ced5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.3.0] - 2026-06-02
4
+
5
+ ### Added
6
+
7
+ - `have_stimulus_controller(name)` — Capybara matcher asserting `data-controller` contains the given controller name
8
+ - `have_stimulus_action(descriptor)` — Capybara matcher asserting `data-action` contains the descriptor; accepts a full descriptor (`click->hello#greet`) or shorthand without event (`hello#greet`)
9
+ - `have_stimulus_target(controller, target)` — Capybara matcher asserting `data-{controller}-target` contains the target name
10
+
11
+ ## [1.2.0] - 2026-06-01
12
+
13
+ ### Added
14
+
15
+ - `have_turbo_stream.with_attributes(hash)` — assert arbitrary HTML attributes on a stream element; accepts string or symbol keys
16
+ - `have_turbo_frame.with_attributes(hash)` — same for frame elements in request specs
17
+ - `have_turbo_stream.children_only` — assert the `children-only` attribute on Turbo 8 morph streams
18
+ - `TurboRspec.register_action(:name)` — declare custom Turbo stream actions; `with_action` now raises `ArgumentError` for unrecognised actions with a hint to register them
19
+ - `TurboRspec::BUILTIN_ACTIONS` — public constant listing all built-in action names
20
+ - `TurboRspec.known_actions` — returns built-in + registered custom actions
21
+
3
22
  ## [1.1.0] - 2026-06-01
4
23
 
5
24
  ### Added
data/README.md CHANGED
@@ -10,7 +10,7 @@ Drop-in test matchers for [hotwired/turbo-rails](https://github.com/hotwired/tur
10
10
 
11
11
  - **Request/controller specs** — `have_turbo_stream`, `have_turbo_frame`, `have_turbo_streams`
12
12
  - **Broadcast specs** — `have_broadcasted_turbo_stream_to` with count qualifiers
13
- - **System/feature specs** — Capybara matchers: `have_turbo_frame`, `have_turbo_stream_tag`, `within_turbo_frame`
13
+ - **System/feature specs** — Capybara matchers: `have_turbo_frame`, `have_turbo_stream_tag`, `within_turbo_frame`, `have_stimulus_controller`, `have_stimulus_action`, `have_stimulus_target`
14
14
  - **Minitest** — `assert_turbo_stream`, `refute_turbo_stream`, `assert_turbo_frame`, `refute_turbo_frame`
15
15
  - **Factory helpers** — `turbo_stream_html`, `turbo_frame_html`
16
16
  - **Shared examples** — `it_behaves_like "a turbo stream response"`
@@ -99,13 +99,33 @@ expect(response).to have_turbo_stream
99
99
  .targeting("messages")
100
100
  .with_content("Hello")
101
101
 
102
+ # With arbitrary attributes
103
+ expect(response).to have_turbo_stream.with_attributes("data-controller" => "messages")
104
+
105
+ # Turbo 8 morph with children-only
106
+ expect(response).to have_turbo_stream.with_action(:morph).children_only
107
+
108
+ # Count qualifiers — assert how many matching streams appear
109
+ expect(response).to have_turbo_stream.with_action(:append).once
110
+ expect(response).to have_turbo_stream.with_action(:append).twice
111
+ expect(response).to have_turbo_stream.with_action(:append).exactly(3).times
112
+ expect(response).to have_turbo_stream.with_action(:append).at_least(2).times
113
+ expect(response).to have_turbo_stream.with_action(:append).at_most(1).times
114
+
102
115
  # Negation
103
116
  expect(response).not_to have_turbo_stream.with_action(:replace)
104
117
  ```
105
118
 
106
119
  #### Actions
107
120
 
108
- Turbo supports the following stream actions: `append`, `prepend`, `replace`, `update`, `remove`, `before`, `after`, `refresh`.
121
+ Turbo's built-in stream actions: `append`, `prepend`, `replace`, `update`, `remove`, `before`, `after`, `refresh`, `morph`.
122
+
123
+ `with_action` raises `ArgumentError` for unrecognised names. Register custom actions in your test setup:
124
+
125
+ ```ruby
126
+ # spec/support/turbo_rspec.rb
127
+ TurboRspec.register_action(:sparkle, :highlight)
128
+ ```
109
129
 
110
130
  ### `have_turbo_streams`
111
131
 
@@ -189,6 +209,18 @@ expect(page).to have_turbo_frame("messages").with_content("Hello")
189
209
  # Loaded (frame finished loading)
190
210
  expect(page).to have_turbo_frame("messages").loaded
191
211
 
212
+ # Lazy frame — assert loading="lazy" attribute
213
+ expect(page).to have_turbo_frame("messages").lazy
214
+
215
+ # With src — assert the src attribute on a lazy-loaded frame
216
+ expect(page).to have_turbo_frame("messages").with_src("/messages/new")
217
+
218
+ # With arbitrary attributes (request specs only — use Capybara selectors in system specs)
219
+ expect(response).to have_turbo_frame.with_attributes("data-controller" => "chat")
220
+
221
+ # Chained
222
+ expect(page).to have_turbo_frame("messages").lazy.with_src("/messages/new")
223
+
192
224
  # Negation
193
225
  expect(page).not_to have_turbo_frame("notifications")
194
226
  ```
@@ -204,6 +236,29 @@ within_turbo_frame("messages") do
204
236
  end
205
237
  ```
206
238
 
239
+ ### Stimulus matchers
240
+
241
+ Assert Stimulus controller, action, and target attributes on the page (Capybara).
242
+
243
+ ```ruby
244
+ # Controller — data-controller contains "hello"
245
+ expect(page).to have_stimulus_controller("hello")
246
+
247
+ # Action — full descriptor
248
+ expect(page).to have_stimulus_action("click->hello#greet")
249
+
250
+ # Action — shorthand without event (matches any event prefix)
251
+ expect(page).to have_stimulus_action("hello#greet")
252
+
253
+ # Target — data-hello-target contains "name"
254
+ expect(page).to have_stimulus_target("hello", "name")
255
+
256
+ # Negation
257
+ expect(page).not_to have_stimulus_controller("missing")
258
+ ```
259
+
260
+ All three matchers use space-separated token matching (`~=`), so they work correctly when multiple controllers, actions, or targets are declared on a single element.
261
+
207
262
  ### `have_turbo_stream_tag`
208
263
 
209
264
  Assert that a `<turbo-stream-source>` subscription element is on the page.
data/ROADMAP.md CHANGED
@@ -4,21 +4,6 @@ RSpec matchers for [Turbo](https://github.com/hotwired/turbo-rails): Turbo Strea
4
4
 
5
5
  ---
6
6
 
7
- ## 1.1 — Matcher parity
8
-
9
- - **Count qualifiers on `have_turbo_stream`** — `.once`, `.twice`, `.exactly(n).times` for request specs, matching the parity already present on `have_broadcasted_turbo_stream_to`. A single response with two `append` streams is a real case that currently requires two separate expectations.
10
- - **Capybara `have_turbo_frame` additions** — `.with_src(url)` (assert the `src` attribute of a lazy frame) and `.lazy` (assert `loading="lazy"` is set). These cover the lazy-loading pattern that's very common with Turbo Frames.
11
-
12
- ## 1.2 — Turbo 8 / morphing
13
-
14
- - **`with_attributes(hash)` chain** — generic attribute assertion for both `have_turbo_stream` and `have_turbo_frame`. Covers non-standard attributes (e.g., `data-*` passed through custom actions) without needing new named chains per attribute.
15
- - **Morph-aware assertions** — `morph` is a supported action but Turbo 8's `<turbo-stream action="morph">` has distinct semantics (`[children-only]` attribute). A `.children_only` chain on `have_turbo_stream` would make morph specs readable without raw attribute checks.
16
- - **Custom Turbo action support** — Turbo 8 allows registering custom stream actions. A `TurboRspec.register_action(:sparkle)` API would let teams assert their own actions without them being treated as unknown values.
17
-
18
- ## 1.3 — Stimulus companion
19
-
20
- - **Stimulus matchers** — `have_stimulus_controller`, `have_stimulus_action`, `have_stimulus_target` Capybara matchers. turbo-rails ships with Stimulus; teams using both already test Stimulus behavior manually. This is the natural next surface area for a complete Hotwire testing toolkit.
21
-
22
7
  ## 1.4 — Tooling
23
8
 
24
9
  - **RuboCop cop** — flag request specs that assert `response.body` with a raw string match or `include "<turbo-stream"` instead of using the gem's matchers. Useful for migration and incremental adoption.
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurboRspec
4
+ module Capybara
5
+ module Matchers
6
+ # Capybara matcher asserting that an element with the given Stimulus
7
+ # action descriptor is present on the page (+data-action+ contains the
8
+ # descriptor as a space-separated token).
9
+ #
10
+ # Accepts either a full descriptor (+click->hello#greet+) or a shorthand
11
+ # without an event (+hello#greet+), which matches any event prefix.
12
+ #
13
+ # @example Full descriptor
14
+ # expect(page).to have_stimulus_action("click->hello#greet")
15
+ #
16
+ # @example Shorthand (any event)
17
+ # expect(page).to have_stimulus_action("hello#greet")
18
+ class HaveStimulusAction
19
+ def initialize(action_descriptor)
20
+ @action_descriptor = action_descriptor.to_s
21
+ end
22
+
23
+ def matches?(page_or_node)
24
+ page_or_node.has_css?(selector, wait: 0)
25
+ end
26
+
27
+ def does_not_match?(page_or_node)
28
+ page_or_node.has_no_css?(selector, wait: 0)
29
+ end
30
+
31
+ def failure_message
32
+ "expected page to have Stimulus action #{@action_descriptor.inspect}"
33
+ end
34
+
35
+ def failure_message_when_negated
36
+ "expected page not to have Stimulus action #{@action_descriptor.inspect}"
37
+ end
38
+
39
+ def description
40
+ "have Stimulus action #{@action_descriptor.inspect}"
41
+ end
42
+
43
+ private
44
+
45
+ def selector
46
+ if @action_descriptor.include?("->")
47
+ "[data-action~='#{@action_descriptor}']"
48
+ else
49
+ "[data-action*='#{@action_descriptor}']"
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurboRspec
4
+ module Capybara
5
+ module Matchers
6
+ # Capybara matcher asserting that an element with the given Stimulus
7
+ # controller is present on the page (+data-controller+ contains the name
8
+ # as a space-separated token).
9
+ #
10
+ # @example
11
+ # expect(page).to have_stimulus_controller("hello")
12
+ # expect(page).not_to have_stimulus_controller("missing")
13
+ class HaveStimulusController
14
+ def initialize(controller_name)
15
+ @controller_name = controller_name.to_s
16
+ end
17
+
18
+ def matches?(page_or_node)
19
+ page_or_node.has_css?(selector, wait: 0)
20
+ end
21
+
22
+ def does_not_match?(page_or_node)
23
+ page_or_node.has_no_css?(selector, wait: 0)
24
+ end
25
+
26
+ def failure_message
27
+ "expected page to have Stimulus controller #{@controller_name.inspect}"
28
+ end
29
+
30
+ def failure_message_when_negated
31
+ "expected page not to have Stimulus controller #{@controller_name.inspect}"
32
+ end
33
+
34
+ def description
35
+ "have Stimulus controller #{@controller_name.inspect}"
36
+ end
37
+
38
+ private
39
+
40
+ def selector
41
+ "[data-controller~='#{@controller_name}']"
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurboRspec
4
+ module Capybara
5
+ module Matchers
6
+ # Capybara matcher asserting that an element with the given Stimulus
7
+ # target is present on the page. Checks +data-{controller}-target+
8
+ # contains the target name as a space-separated token.
9
+ #
10
+ # @example
11
+ # expect(page).to have_stimulus_target("hello", "name")
12
+ # expect(page).not_to have_stimulus_target("hello", "missing")
13
+ class HaveStimulusTarget
14
+ def initialize(controller_name, target_name)
15
+ @controller_name = controller_name.to_s
16
+ @target_name = target_name.to_s
17
+ end
18
+
19
+ def matches?(page_or_node)
20
+ page_or_node.has_css?(selector, wait: 0)
21
+ end
22
+
23
+ def does_not_match?(page_or_node)
24
+ page_or_node.has_no_css?(selector, wait: 0)
25
+ end
26
+
27
+ def failure_message
28
+ "expected page to have Stimulus target #{@target_name.inspect} for controller #{@controller_name.inspect}"
29
+ end
30
+
31
+ def failure_message_when_negated
32
+ "expected page not to have Stimulus target #{@target_name.inspect} for controller #{@controller_name.inspect}"
33
+ end
34
+
35
+ def description
36
+ "have Stimulus target #{@target_name.inspect} for #{@controller_name.inspect}"
37
+ end
38
+
39
+ private
40
+
41
+ def selector
42
+ "[data-#{@controller_name}-target~='#{@target_name}']"
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -2,6 +2,9 @@
2
2
 
3
3
  require_relative "matchers/have_turbo_frame"
4
4
  require_relative "matchers/have_turbo_stream_tag"
5
+ require_relative "matchers/have_stimulus_controller"
6
+ require_relative "matchers/have_stimulus_action"
7
+ require_relative "matchers/have_stimulus_target"
5
8
 
6
9
  module TurboRspec
7
10
  module Capybara
@@ -14,6 +17,18 @@ module TurboRspec
14
17
  HaveTurboStreamTag.new(signed_stream_name: signed_stream_name)
15
18
  end
16
19
 
20
+ def have_stimulus_controller(controller_name)
21
+ HaveStimulusController.new(controller_name)
22
+ end
23
+
24
+ def have_stimulus_action(action_descriptor)
25
+ HaveStimulusAction.new(action_descriptor)
26
+ end
27
+
28
+ def have_stimulus_target(controller_name, target_name)
29
+ HaveStimulusTarget.new(controller_name, target_name)
30
+ end
31
+
17
32
  # :nocov:
18
33
  def within_turbo_frame(id, &block)
19
34
  page.within("turbo-frame##{id}", &block)
@@ -13,8 +13,14 @@ module TurboRspec
13
13
  # @return [Boolean]
14
14
  attr_accessor :auto_include
15
15
 
16
+ # @!attribute [rw] custom_actions
17
+ # Additional stream action names registered via +TurboRspec.register_action+.
18
+ # @return [Array<String>]
19
+ attr_accessor :custom_actions
20
+
16
21
  def initialize
17
22
  @auto_include = true
23
+ @custom_actions = []
18
24
  end
19
25
  end
20
26
  end
@@ -21,6 +21,7 @@ module TurboRspec
21
21
  @id = nil
22
22
  @content = nil
23
23
  @partial = nil
24
+ @attributes = {}
24
25
  end
25
26
 
26
27
  # Constrains the match to frames with the given id attribute.
@@ -47,6 +48,14 @@ module TurboRspec
47
48
  self
48
49
  end
49
50
 
51
+ # Constrains the match to frames that have all of the given HTML attributes.
52
+ # @param attrs [Hash]
53
+ # @return [self]
54
+ def with_attributes(attrs)
55
+ @attributes = attrs.transform_keys(&:to_s).transform_values(&:to_s)
56
+ self
57
+ end
58
+
50
59
  # @param response_or_body [#body, String]
51
60
  # @return [Boolean]
52
61
  def matches?(response_or_body)
@@ -92,7 +101,8 @@ module TurboRspec
92
101
  def frame_matches?(frame)
93
102
  matches_id?(frame) &&
94
103
  matches_content?(frame) &&
95
- matches_partial?(frame)
104
+ matches_partial?(frame) &&
105
+ matches_attributes?(frame)
96
106
  end
97
107
 
98
108
  def matches_id?(frame)
@@ -109,11 +119,17 @@ module TurboRspec
109
119
  frame.to_html.include?(@partial)
110
120
  end
111
121
 
122
+ def matches_attributes?(frame)
123
+ return true if @attributes.empty?
124
+ @attributes.all? { |k, v| frame[k] == v }
125
+ end
126
+
112
127
  def constraint_description
113
128
  parts = []
114
129
  parts << " with id #{@id.inspect}" if @id
115
130
  parts << " with content #{@content.inspect}" if @content
116
131
  parts << " rendering #{@partial.inspect}" if @partial
132
+ parts << " with attributes #{@attributes.inspect}" if @attributes.any?
117
133
  parts.join
118
134
  end
119
135
 
@@ -144,11 +160,13 @@ module TurboRspec
144
160
  count += 1 if !@id.nil? && matches_id?(frame)
145
161
  count += 1 if !@content.nil? && matches_content?(frame)
146
162
  count += 1 if !@partial.nil? && matches_partial?(frame)
163
+ count += 1 if @attributes.any? && matches_attributes?(frame)
147
164
  count
148
165
  end
149
166
 
150
167
  def constraint_count
151
- [@id, @content, @partial].count { |c| !c.nil? }
168
+ [@id, @content, @partial].count { |c| !c.nil? } +
169
+ (@attributes.any? ? 1 : 0)
152
170
  end
153
171
 
154
172
  def constraint_diff(frame)
@@ -156,6 +174,10 @@ module TurboRspec
156
174
  lines << " #{matches_id?(frame) ? "✓" : "✗"} id: expected #{@id.inspect}, got #{frame["id"].inspect}" if @id
157
175
  lines << " #{matches_content?(frame) ? "✓" : "✗"} content: expected to include #{@content.inspect}, got #{frame.text.strip.slice(0, 50).inspect}" if @content
158
176
  lines << " #{matches_partial?(frame) ? "✓" : "✗"} rendering: expected to include #{@partial.inspect}" if @partial
177
+ @attributes.each do |k, v|
178
+ actual = frame[k]
179
+ lines << " #{(actual == v) ? "✓" : "✗"} attr[#{k}]: expected #{v.inspect}, got #{actual.inspect}"
180
+ end
159
181
  lines
160
182
  end
161
183
  end
@@ -28,6 +28,8 @@ module TurboRspec
28
28
  @target_all = nil
29
29
  @content = nil
30
30
  @partial = nil
31
+ @attributes = {}
32
+ @children_only = false
31
33
  @expected_count = nil
32
34
  @count_type = :at_least
33
35
  @matching_count = 0
@@ -35,9 +37,15 @@ module TurboRspec
35
37
 
36
38
  # Constrains the match to streams with the given action.
37
39
  # @param action [Symbol, String] e.g. +:append+, +:replace+, +:remove+, +:refresh+, +:morph+
40
+ # @raise [ArgumentError] if the action is not a known built-in or registered custom action
38
41
  # @return [self]
39
42
  def with_action(action)
40
- @action = action.to_s
43
+ action_str = action.to_s
44
+ unless TurboRspec.known_actions.include?(action_str)
45
+ raise ArgumentError, "Unknown Turbo stream action #{action_str.inspect}. " \
46
+ "Register custom actions with TurboRspec.register_action(:#{action_str})."
47
+ end
48
+ @action = action_str
41
49
  self
42
50
  end
43
51
 
@@ -73,6 +81,26 @@ module TurboRspec
73
81
  self
74
82
  end
75
83
 
84
+ # Constrains the match to streams that have all of the given HTML attributes.
85
+ # Keys and values are compared as strings.
86
+ # @param attrs [Hash]
87
+ # @return [self]
88
+ # @example
89
+ # expect(response).to have_turbo_stream.with_attributes("data-controller" => "messages")
90
+ def with_attributes(attrs)
91
+ @attributes = attrs.transform_keys(&:to_s).transform_values(&:to_s)
92
+ self
93
+ end
94
+
95
+ # Constrains the match to morph streams with the +children-only+ attribute set.
96
+ # @return [self]
97
+ # @example
98
+ # expect(response).to have_turbo_stream.with_action(:morph).children_only
99
+ def children_only
100
+ @children_only = true
101
+ self
102
+ end
103
+
76
104
  # Asserts exactly one matching stream.
77
105
  # @return [self]
78
106
  def once
@@ -167,7 +195,9 @@ module TurboRspec
167
195
  matches_target?(stream) &&
168
196
  matches_target_all?(stream) &&
169
197
  matches_content?(stream) &&
170
- matches_partial?(stream)
198
+ matches_partial?(stream) &&
199
+ matches_attributes?(stream) &&
200
+ matches_children_only?(stream)
171
201
  end
172
202
 
173
203
  def matches_action?(stream)
@@ -192,6 +222,16 @@ module TurboRspec
192
222
  stream.to_html.include?(@partial)
193
223
  end
194
224
 
225
+ def matches_attributes?(stream)
226
+ return true if @attributes.empty?
227
+ @attributes.all? { |k, v| stream[k] == v }
228
+ end
229
+
230
+ def matches_children_only?(stream)
231
+ return true unless @children_only
232
+ !stream["children-only"].nil?
233
+ end
234
+
195
235
  def count_matches?(n)
196
236
  if @expected_count.nil?
197
237
  n >= 1
@@ -213,6 +253,8 @@ module TurboRspec
213
253
  parts << " targeting all #{@target_all.inspect}" if @target_all
214
254
  parts << " with content #{@content.inspect}" if @content
215
255
  parts << " rendering #{@partial.inspect}" if @partial
256
+ parts << " with attributes #{@attributes.inspect}" if @attributes.any?
257
+ parts << " children only" if @children_only
216
258
  parts.join
217
259
  end
218
260
 
@@ -261,11 +303,15 @@ module TurboRspec
261
303
  count += 1 if !@target_all.nil? && matches_target_all?(stream)
262
304
  count += 1 if !@content.nil? && matches_content?(stream)
263
305
  count += 1 if !@partial.nil? && matches_partial?(stream)
306
+ count += 1 if @attributes.any? && matches_attributes?(stream)
307
+ count += 1 if @children_only && matches_children_only?(stream)
264
308
  count
265
309
  end
266
310
 
267
311
  def constraint_count
268
- [@action, @target, @target_all, @content, @partial].count { |c| !c.nil? }
312
+ [@action, @target, @target_all, @content, @partial].count { |c| !c.nil? } +
313
+ (@attributes.any? ? 1 : 0) +
314
+ (@children_only ? 1 : 0)
269
315
  end
270
316
 
271
317
  def constraint_diff(stream)
@@ -275,6 +321,11 @@ module TurboRspec
275
321
  lines << " #{matches_target_all?(stream) ? "✓" : "✗"} targets: expected #{@target_all.inspect}, got #{stream["targets"].inspect}" if @target_all
276
322
  lines << " #{matches_content?(stream) ? "✓" : "✗"} content: expected to include #{@content.inspect}, got #{stream.text.strip.slice(0, 50).inspect}" if @content
277
323
  lines << " #{matches_partial?(stream) ? "✓" : "✗"} rendering: expected to include #{@partial.inspect}" if @partial
324
+ @attributes.each do |k, v|
325
+ actual = stream[k]
326
+ lines << " #{(actual == v) ? "✓" : "✗"} attr[#{k}]: expected #{v.inspect}, got #{actual.inspect}"
327
+ end
328
+ lines << " #{matches_children_only?(stream) ? "✓" : "✗"} children-only: expected attribute to be present" if @children_only
278
329
  lines
279
330
  end
280
331
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurboRspec
4
- VERSION = "1.1.0"
4
+ VERSION = "1.3.0"
5
5
  end
data/lib/turbo_rspec.rb CHANGED
@@ -19,6 +19,9 @@ module TurboRspec
19
19
  # Base error class for TurboRspec.
20
20
  class Error < StandardError; end
21
21
 
22
+ # Built-in Turbo stream action names.
23
+ BUILTIN_ACTIONS = %w[append prepend replace update remove before after refresh morph].freeze
24
+
22
25
  class << self
23
26
  # Yields the configuration object for customization.
24
27
  #
@@ -46,6 +49,24 @@ module TurboRspec
46
49
  @configuration = Configuration.new
47
50
  end
48
51
 
52
+ # Registers one or more custom Turbo stream action names so that
53
+ # +have_turbo_stream.with_action(:name)+ does not raise +ArgumentError+.
54
+ #
55
+ # @param actions [Array<Symbol, String>]
56
+ # @return [void]
57
+ # @example
58
+ # TurboRspec.register_action(:sparkle, :highlight)
59
+ def register_action(*actions)
60
+ configuration.custom_actions |= actions.map(&:to_s)
61
+ end
62
+
63
+ # Returns all known stream action names (built-in + registered custom).
64
+ #
65
+ # @return [Array<String>]
66
+ def known_actions
67
+ BUILTIN_ACTIONS + configuration.custom_actions
68
+ end
69
+
49
70
  # Installs RSpec integration — includes matchers and helpers into the
50
71
  # appropriate example groups. Called automatically when RSpec is present.
51
72
  #
data/sig/turbo_rspec.rbs CHANGED
@@ -1,4 +1,189 @@
1
1
  module TurboRspec
2
2
  VERSION: String
3
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
- end
3
+
4
+ # All built-in Turbo stream action names.
5
+ BUILTIN_ACTIONS: Array[String]
6
+
7
+ def self.configure: () { (Configuration) -> void } -> void
8
+ def self.configuration: () -> Configuration
9
+ def self.reset_configuration!: () -> void
10
+ def self.register_action: (*(Symbol | String) actions) -> void
11
+ def self.known_actions: () -> Array[String]
12
+ def self.install_rspec_integration: (untyped config) -> void
13
+
14
+ class Error < StandardError
15
+ end
16
+
17
+ class Configuration
18
+ attr_accessor auto_include: bool
19
+ attr_accessor custom_actions: Array[String]
20
+
21
+ def initialize: () -> void
22
+ end
23
+
24
+ module Matchers
25
+ def have_turbo_stream: () -> HaveTurboStream
26
+ def assert_no_turbo_stream: () -> HaveTurboStream
27
+ def have_turbo_streams: (*HaveTurboStream matchers) -> HaveTurboStreams
28
+ def have_turbo_frame: () -> HaveTurboFrame
29
+ def have_broadcasted_turbo_stream_to: (String | untyped stream_or_object) -> HaveBroadcastedTurboStreamTo
30
+ def broadcast_turbo_stream_to: (String | untyped stream_or_object) -> HaveBroadcastedTurboStreamTo
31
+
32
+ class HaveTurboStream
33
+ def initialize: () -> void
34
+
35
+ # Constraint chain
36
+ def with_action: (Symbol | String action) -> self
37
+ def targeting: (String dom_id) -> self
38
+ def targeting_all: (String selector) -> self
39
+ def with_content: (String text) -> self
40
+ def rendering: (String partial) -> self
41
+ def with_attributes: (Hash[String | Symbol, untyped] attrs) -> self
42
+ def children_only: () -> self
43
+
44
+ # Count qualifiers
45
+ def once: () -> self
46
+ def twice: () -> self
47
+ def exactly: (Integer n) -> self
48
+ def at_least: (Integer n) -> self
49
+ def at_most: (Integer n) -> self
50
+ def times: () -> self
51
+
52
+ # RSpec matcher interface
53
+ def matches?: (untyped response_or_body) -> bool
54
+ def does_not_match?: (untyped response_or_body) -> bool
55
+ def failure_message: () -> String
56
+ def failure_message_when_negated: () -> String
57
+ def description: () -> String
58
+ end
59
+
60
+ class HaveTurboFrame
61
+ def initialize: () -> void
62
+
63
+ # Constraint chain
64
+ def with_id: (String id) -> self
65
+ def with_content: (String text) -> self
66
+ def rendering: (String partial) -> self
67
+ def with_attributes: (Hash[String | Symbol, untyped] attrs) -> self
68
+
69
+ # RSpec matcher interface
70
+ def matches?: (untyped response_or_body) -> bool
71
+ def does_not_match?: (untyped response_or_body) -> bool
72
+ def failure_message: () -> String
73
+ def failure_message_when_negated: () -> String
74
+ def description: () -> String
75
+ end
76
+
77
+ class HaveBroadcastedTurboStreamTo
78
+ def initialize: (String | untyped stream_or_object) -> void
79
+
80
+ # Constraint chain (mirrors HaveTurboStream)
81
+ def with_action: (Symbol | String action) -> self
82
+ def targeting: (String dom_id) -> self
83
+ def targeting_all: (String selector) -> self
84
+ def with_content: (String text) -> self
85
+ def rendering: (String partial) -> self
86
+
87
+ # Count qualifiers
88
+ def once: () -> self
89
+ def twice: () -> self
90
+ def exactly: (Integer n) -> self
91
+ def at_least: (Integer n) -> self
92
+ def at_most: (Integer n) -> self
93
+ def times: () -> self
94
+
95
+ # RSpec block-expectation matcher interface
96
+ def supports_block_expectations?: () -> bool
97
+ def matches?: (untyped block) -> bool
98
+ def does_not_match?: (untyped block) -> bool
99
+ def failure_message: () -> String
100
+ def failure_message_when_negated: () -> String
101
+ def description: () -> String
102
+ end
103
+
104
+ class HaveTurboStreams
105
+ def initialize: (Array[HaveTurboStream] expected_streams) -> void
106
+ def matches?: (untyped response_or_body) -> bool
107
+ def does_not_match?: (untyped response_or_body) -> bool
108
+ def failure_message: () -> String
109
+ def failure_message_when_negated: () -> String
110
+ def description: () -> String
111
+ end
112
+ end
113
+
114
+ module Helpers
115
+ def turbo_stream_html: (action: Symbol | String, ?target: String?, ?targets: String?, ?content: String?) -> String
116
+ def turbo_frame_html: (id: String, ?content: String?) -> String
117
+ end
118
+
119
+ module Assertions
120
+ def assert_turbo_stream: (untyped response_or_body, ?action: (Symbol | String)?, ?target: String?, ?targets: String?, ?content: String?, ?partial: String?, ?message: String?) -> void
121
+ def refute_turbo_stream: (untyped response_or_body, ?action: (Symbol | String)?, ?target: String?, ?targets: String?, ?content: String?, ?partial: String?, ?message: String?) -> void
122
+ def assert_turbo_frame: (untyped response_or_body, ?id: String?, ?content: String?, ?partial: String?, ?message: String?) -> void
123
+ def refute_turbo_frame: (untyped response_or_body, ?id: String?, ?content: String?, ?partial: String?, ?message: String?) -> void
124
+ end
125
+
126
+ module Capybara
127
+ module Matchers
128
+ def have_turbo_frame: (String id) -> HaveTurboFrame
129
+ def have_turbo_stream_tag: (?String? signed_stream_name) -> HaveTurboStreamTag
130
+ def have_stimulus_controller: (String controller_name) -> HaveStimulusController
131
+ def have_stimulus_action: (String action_descriptor) -> HaveStimulusAction
132
+ def have_stimulus_target: (String controller_name, String target_name) -> HaveStimulusTarget
133
+ def within_turbo_frame: (String id) { () -> void } -> void
134
+
135
+ class HaveTurboFrame
136
+ def initialize: (String id) -> void
137
+
138
+ # Constraint chain
139
+ def with_content: (String text) -> self
140
+ def loaded: () -> self
141
+ def with_src: (String url) -> self
142
+ def lazy: () -> self
143
+
144
+ # Capybara matcher interface
145
+ def matches?: (untyped page_or_node) -> bool
146
+ def does_not_match?: (untyped page_or_node) -> bool
147
+ def failure_message: () -> String
148
+ def failure_message_when_negated: () -> String
149
+ def description: () -> String
150
+ end
151
+
152
+ class HaveStimulusController
153
+ def initialize: (String controller_name) -> void
154
+ def matches?: (untyped page_or_node) -> bool
155
+ def does_not_match?: (untyped page_or_node) -> bool
156
+ def failure_message: () -> String
157
+ def failure_message_when_negated: () -> String
158
+ def description: () -> String
159
+ end
160
+
161
+ class HaveStimulusAction
162
+ def initialize: (String action_descriptor) -> void
163
+ def matches?: (untyped page_or_node) -> bool
164
+ def does_not_match?: (untyped page_or_node) -> bool
165
+ def failure_message: () -> String
166
+ def failure_message_when_negated: () -> String
167
+ def description: () -> String
168
+ end
169
+
170
+ class HaveStimulusTarget
171
+ def initialize: (String controller_name, String target_name) -> void
172
+ def matches?: (untyped page_or_node) -> bool
173
+ def does_not_match?: (untyped page_or_node) -> bool
174
+ def failure_message: () -> String
175
+ def failure_message_when_negated: () -> String
176
+ def description: () -> String
177
+ end
178
+
179
+ class HaveTurboStreamTag
180
+ def initialize: (?signed_stream_name: String?) -> void
181
+ def matches?: (untyped page_or_node) -> bool
182
+ def does_not_match?: (untyped page_or_node) -> bool
183
+ def failure_message: () -> String
184
+ def failure_message_when_negated: () -> String
185
+ def description: () -> String
186
+ end
187
+ end
188
+ end
189
+ 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: 1.1.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -54,6 +54,9 @@ files:
54
54
  - lib/turbo_rspec.rb
55
55
  - lib/turbo_rspec/assertions.rb
56
56
  - lib/turbo_rspec/capybara/matchers.rb
57
+ - lib/turbo_rspec/capybara/matchers/have_stimulus_action.rb
58
+ - lib/turbo_rspec/capybara/matchers/have_stimulus_controller.rb
59
+ - lib/turbo_rspec/capybara/matchers/have_stimulus_target.rb
57
60
  - lib/turbo_rspec/capybara/matchers/have_turbo_frame.rb
58
61
  - lib/turbo_rspec/capybara/matchers/have_turbo_stream_tag.rb
59
62
  - lib/turbo_rspec/configuration.rb