turbo_rspec 1.0.0 → 1.1.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: '036718d6f9e9e1960f6e12b668a288c6562ec914aa87eaaaddacfbccb47fc2fd'
4
- data.tar.gz: 6f0345d41a0803871c89fbbbaa9e7e1e30b7ca155e9d60b8c41052a1fe0c59e6
3
+ metadata.gz: 8356be265d63e581709f7252b6981d9f721172d68e417e23bc02581214773629
4
+ data.tar.gz: 3394a171db1031c16533bde8eb141eb1cd1f40bfed737b47c4f6e80b05ca23b5
5
5
  SHA512:
6
- metadata.gz: e2269c5a1df90467e5487cb8b029e75591a91c07df1a5af5182af6075d0803dbbc8f8d97418bfbfb89232f63d0fc09646e894533d78cea61fd6937550707dfca
7
- data.tar.gz: 0b4c4d7ed1c62e796b03eaed49edb1449e0ea265952f8b6a97f055a49f3c1708fee739a0fc47e99282e7e222d090690859bf540b3bfe3893fd8a523c00657640
6
+ metadata.gz: c2094508851098b590cd9539c01b60768a44f959b4af7bb20083e736a6e9b7631776f8bdde870fd543a7302d0fb1983a839932d8839b74de450274d00470c335
7
+ data.tar.gz: 06c44da054b038c40cb83b681da7d9897f3d76f436afe2adf484c1cd2cab243caf2f824d763ce958172d9d66f02c11e2646130fbe31c47a59df68ac800e0ab33
@@ -0,0 +1,15 @@
1
+ # These are supported funding model platforms
2
+
3
+ github: [eclectic-coding] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4
+ patreon: # Replace with a single Patreon username
5
+ open_collective: # Replace with a single Open Collective username
6
+ ko_fi: # Replace with a single Ko-fi username
7
+ tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8
+ community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9
+ liberapay: # Replace with a single Liberapay username
10
+ issuehunt: # Replace with a single IssueHunt username
11
+ lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
12
+ polar: # Replace with a single Polar username
13
+ buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
14
+ thanks_dev: # Replace with a single thanks.dev username
15
+ custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.1.0] - 2026-06-01
4
+
5
+ ### Added
6
+
7
+ - Count qualifiers on `have_turbo_stream`: `.once`, `.twice`, `.exactly(n).times`, `.at_least(n).times`, `.at_most(n).times` — mirrors the API already available on `have_broadcasted_turbo_stream_to`
8
+ - `have_turbo_frame` (Capybara) `.with_src(url)` chain — asserts the `src` attribute of a lazy-loaded frame
9
+ - `have_turbo_frame` (Capybara) `.lazy` chain — asserts `loading="lazy"` is set on the frame
10
+
3
11
  ## [1.0.0] - 2026-05-28
4
12
 
5
13
  ### Added
data/ROADMAP.md CHANGED
@@ -4,10 +4,25 @@ RSpec matchers for [Turbo](https://github.com/hotwired/turbo-rails): Turbo Strea
4
4
 
5
5
  ---
6
6
 
7
- ## Post-1.0 ideas (not scheduled)
7
+ ## 1.1 Matcher parity
8
8
 
9
- - VS Code / RubyMine snippet pack for common patterns
10
- - Playwright/Puppeteer bridge for headless assertions outside Capybara
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
+ ## 1.4 — Tooling
23
+
24
+ - **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.
25
+ - **Snapshot/fixture matcher** — `match_turbo_stream_snapshot("name")` records the stream on the first run and diffs on subsequent runs. Good for complex multi-stream responses where re-specifying every constraint in detail is noisy.
11
26
 
12
27
  ---
13
28
 
@@ -8,6 +8,8 @@ module TurboRspec
8
8
  @id = id.to_s
9
9
  @content = nil
10
10
  @loaded = false
11
+ @src = nil
12
+ @lazy = false
11
13
  end
12
14
 
13
15
  def with_content(text)
@@ -20,10 +22,27 @@ module TurboRspec
20
22
  self
21
23
  end
22
24
 
25
+ # Constrains the match to frames with the given +src+ attribute.
26
+ # @param url [String]
27
+ # @return [self]
28
+ def with_src(url)
29
+ @src = url.to_s
30
+ self
31
+ end
32
+
33
+ # Constrains the match to frames with +loading="lazy"+.
34
+ # @return [self]
35
+ def lazy
36
+ @lazy = true
37
+ self
38
+ end
39
+
23
40
  def matches?(page_or_node)
24
41
  @node = find_frame(page_or_node)
25
42
  return false unless @node
26
43
  return false if @loaded && !@node[:complete]
44
+ return false if @lazy && @node[:loading] != "lazy"
45
+ return false if @src && @node[:src] != @src
27
46
  return false if @content && !@node.has_content?(@content, wait: 0)
28
47
  true
29
48
  end
@@ -37,6 +56,10 @@ module TurboRspec
37
56
  "expected page to have turbo-frame##{@id}#{constraint_description} but it was not found"
38
57
  elsif @loaded && !@node[:complete]
39
58
  "expected turbo-frame##{@id} to be loaded (missing [complete] attribute)"
59
+ elsif @lazy && @node[:loading] != "lazy"
60
+ "expected turbo-frame##{@id} to be lazy (missing loading=\"lazy\" attribute)"
61
+ elsif @src && @node[:src] != @src
62
+ "expected turbo-frame##{@id} to have src #{@src.inspect}, got #{@node[:src].inspect}"
40
63
  else
41
64
  "expected turbo-frame##{@id} to have content #{@content.inspect}"
42
65
  end
@@ -61,6 +84,8 @@ module TurboRspec
61
84
  def constraint_description
62
85
  parts = []
63
86
  parts << " loaded" if @loaded
87
+ parts << " lazy" if @lazy
88
+ parts << " with src #{@src.inspect}" if @src
64
89
  parts << " with content #{@content.inspect}" if @content
65
90
  parts.join
66
91
  end
@@ -28,6 +28,9 @@ module TurboRspec
28
28
  @target_all = nil
29
29
  @content = nil
30
30
  @partial = nil
31
+ @expected_count = nil
32
+ @count_type = :at_least
33
+ @matching_count = 0
31
34
  end
32
35
 
33
36
  # Constrains the match to streams with the given action.
@@ -70,12 +73,58 @@ module TurboRspec
70
73
  self
71
74
  end
72
75
 
76
+ # Asserts exactly one matching stream.
77
+ # @return [self]
78
+ def once
79
+ exactly(1)
80
+ end
81
+
82
+ # Asserts exactly two matching streams.
83
+ # @return [self]
84
+ def twice
85
+ exactly(2)
86
+ end
87
+
88
+ # Asserts exactly +n+ matching streams.
89
+ # @param n [Integer]
90
+ # @return [self]
91
+ def exactly(n)
92
+ @expected_count = n
93
+ @count_type = :exactly
94
+ self
95
+ end
96
+
97
+ # Asserts at least +n+ matching streams.
98
+ # @param n [Integer]
99
+ # @return [self]
100
+ def at_least(n)
101
+ @expected_count = n
102
+ @count_type = :at_least
103
+ self
104
+ end
105
+
106
+ # Asserts at most +n+ matching streams.
107
+ # @param n [Integer]
108
+ # @return [self]
109
+ def at_most(n)
110
+ @expected_count = n
111
+ @count_type = :at_most
112
+ self
113
+ end
114
+
115
+ # Fluent terminator so +.exactly(2).times+ reads naturally.
116
+ # @return [self]
117
+ def times
118
+ self
119
+ end
120
+
73
121
  # @param response_or_body [#body, String]
74
122
  # @return [Boolean]
75
123
  def matches?(response_or_body)
76
124
  @body = extract_body(response_or_body)
77
125
  @streams = parse_streams(@body)
78
- @streams.any? { |stream| stream_matches?(stream) }
126
+ @matching_count = @streams.count { |stream| stream_matches?(stream) }
127
+ count_matches?(@matching_count)
79
128
  end
80
129
 
81
130
  # @param response_or_body [#body, String]
@@ -86,17 +135,17 @@ module TurboRspec
86
135
 
87
136
  # @return [String]
88
137
  def failure_message
89
- "expected response to contain a turbo stream#{constraint_description}\n#{found_streams_message}"
138
+ "expected response to contain a turbo stream#{constraint_description}#{count_description}\n#{found_streams_message}"
90
139
  end
91
140
 
92
141
  # @return [String]
93
142
  def failure_message_when_negated
94
- "expected response not to contain a turbo stream#{constraint_description}"
143
+ "expected response not to contain a turbo stream#{constraint_description}#{count_description}"
95
144
  end
96
145
 
97
146
  # @return [String]
98
147
  def description
99
- "have turbo stream#{constraint_description}"
148
+ "have turbo stream#{constraint_description}#{count_description}"
100
149
  end
101
150
 
102
151
  private
@@ -143,6 +192,20 @@ module TurboRspec
143
192
  stream.to_html.include?(@partial)
144
193
  end
145
194
 
195
+ def count_matches?(n)
196
+ if @expected_count.nil?
197
+ n >= 1
198
+ else
199
+ # :nocov:
200
+ case @count_type
201
+ # :nocov:
202
+ when :exactly then n == @expected_count
203
+ when :at_least then n >= @expected_count
204
+ when :at_most then n <= @expected_count
205
+ end
206
+ end
207
+ end
208
+
146
209
  def constraint_description
147
210
  parts = []
148
211
  parts << " with action #{@action.inspect}" if @action
@@ -153,6 +216,17 @@ module TurboRspec
153
216
  parts.join
154
217
  end
155
218
 
219
+ def count_description
220
+ return "" if @expected_count.nil?
221
+ # :nocov:
222
+ case @count_type
223
+ # :nocov:
224
+ when :exactly then " exactly #{@expected_count} time(s)"
225
+ when :at_least then " at least #{@expected_count} time(s)"
226
+ when :at_most then " at most #{@expected_count} time(s)"
227
+ end
228
+ end
229
+
156
230
  def found_streams_message
157
231
  return "but no turbo streams were found in the response" if @streams.empty?
158
232
 
@@ -163,10 +237,15 @@ module TurboRspec
163
237
  lines << " #{i + 1}. action=#{s["action"].inspect} target=#{s["target"].inspect} content=#{content_preview}"
164
238
  end
165
239
 
166
- closest = closest_match
167
- lines << ""
168
- lines << "closest match (#{count_matching_constraints(closest)}/#{constraint_count} constraint(s) matched):"
169
- lines.concat(constraint_diff(closest))
240
+ if @expected_count
241
+ lines << ""
242
+ lines << "#{@matching_count} of #{@streams.size} stream(s) matched the constraints"
243
+ else
244
+ closest = closest_match
245
+ lines << ""
246
+ lines << "closest match (#{count_matching_constraints(closest)}/#{constraint_count} constraint(s) matched):"
247
+ lines.concat(constraint_diff(closest))
248
+ end
170
249
 
171
250
  lines.join("\n")
172
251
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurboRspec
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.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: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -34,6 +34,7 @@ executables: []
34
34
  extensions: []
35
35
  extra_rdoc_files: []
36
36
  files:
37
+ - ".github/FUNDING.yml"
37
38
  - ".github/workflows/ci.yml"
38
39
  - ".github/workflows/publish.yml"
39
40
  - ".rubocop.yml"