turbo_rspec 0.7.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: 3049c79cdd7a1c8b6dc2447fb2034458f129b900343dcc759e5f8b1ae200189b
4
- data.tar.gz: ca51420063475b7870a6d77e95d0e4b33f5d2b0e725c4f9b2419acdd4dbb4cd9
3
+ metadata.gz: 8356be265d63e581709f7252b6981d9f721172d68e417e23bc02581214773629
4
+ data.tar.gz: 3394a171db1031c16533bde8eb141eb1cd1f40bfed737b47c4f6e80b05ca23b5
5
5
  SHA512:
6
- metadata.gz: 3e096c87e0b36b9c291308e95a30f075c4c3761d25c9b2d6779647a5e6ace92ed7ff60b5e04cd31b837611dfe92098067a9cb7a4c302e8c12d26eba7bd0d6840
7
- data.tar.gz: 82fcd0aaf837b61d78c079619d52ab99d79d99bc6d2eafec7cd1ce56f0a6558e3b69748928fadbc81fc0651b8910c4b0d44d45795f623c183b012ff423bf44e2
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']
@@ -57,4 +57,9 @@ jobs:
57
57
 
58
58
  - run: bundle exec rspec
59
59
  env:
60
- RAILS_VERSION: ${{ matrix.rails }}
60
+ RAILS_VERSION: ${{ matrix.rails }}
61
+
62
+ - name: Upload coverage to Codecov
63
+ uses: codecov/codecov-action@v6
64
+ with:
65
+ token: ${{ secrets.CODECOV_TOKEN }}
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
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
+
11
+ ## [1.0.0] - 2026-05-28
12
+
13
+ ### Added
14
+
15
+ - `rails generate turbo_rspec:install` — scaffolds `spec/support/turbo_rspec.rb` with sensible defaults
16
+ - `bin/benchmark` — measures matcher overhead against a 100-element response body
17
+ - Semver format enforced via spec (`TurboRspec::VERSION` must match `MAJOR.MINOR.PATCH`)
18
+ - SimpleCov minimum coverage enforced: 100% line and branch required for CI to pass
19
+
3
20
  ## [0.7.0] - 2026-05-28
4
21
 
5
22
  ### Added
data/README.md CHANGED
@@ -6,7 +6,15 @@
6
6
  [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.3-ruby)](https://rubygems.org/gems/turbo_rspec)
7
7
  [![codecov](https://codecov.io/gh/eclectic-coding/turbo_rspec/branch/main/graph/badge.svg)](https://codecov.io/gh/eclectic-coding/turbo_rspec)
8
8
 
9
- RSpec matchers for [Turbo](https://github.com/hotwired/turbo-rails) — assert Turbo Stream responses, Turbo Frame content, and ActionCable broadcasts without hand-rolling helpers in every project.
9
+ Drop-in test matchers for [hotwired/turbo-rails](https://github.com/hotwired/turbo-rails) — replace every hand-rolled Turbo helper in your test suite with a single gem.
10
+
11
+ - **Request/controller specs** — `have_turbo_stream`, `have_turbo_frame`, `have_turbo_streams`
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`
14
+ - **Minitest** — `assert_turbo_stream`, `refute_turbo_stream`, `assert_turbo_frame`, `refute_turbo_frame`
15
+ - **Factory helpers** — `turbo_stream_html`, `turbo_frame_html`
16
+ - **Shared examples** — `it_behaves_like "a turbo stream response"`
17
+ - **Auto-included** — zero setup required when `turbo-rails` is in your bundle
10
18
 
11
19
  **Docs:** [API Reference](https://rubydoc.info/gems/turbo_rspec) · [Migration Guide](docs/migration_guide.md) · [Cookbook](docs/cookbook.md)
12
20
 
@@ -22,6 +30,14 @@ end
22
30
 
23
31
  ## Setup
24
32
 
33
+ ### Generator
34
+
35
+ Run the install generator to scaffold a `spec/support/turbo_rspec.rb` configuration file:
36
+
37
+ ```bash
38
+ rails generate turbo_rspec:install
39
+ ```
40
+
25
41
  ### Rails + turbo-rails (automatic)
26
42
 
27
43
  No setup needed. When `turbo-rails` is in your bundle:
data/ROADMAP.md CHANGED
@@ -4,24 +4,25 @@ RSpec matchers for [Turbo](https://github.com/hotwired/turbo-rails): Turbo Strea
4
4
 
5
5
  ---
6
6
 
7
- ## v1.0.0Stable API
7
+ ## 1.1Matcher parity
8
8
 
9
- **Goal:** API freeze. Commit to semver stability. Make the gem the obvious default choice.
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.
10
11
 
11
- - API stability guarantee: no breaking changes without a major version bump
12
- - `TurboRspec::VERSION` semantic versioning enforced via CI check
13
- - 100% branch coverage enforced in CI (`simplecov`)
14
- - Performance: benchmark matcher overhead to keep it negligible in large suites
15
- - `bin/release` script (mirrors solid_queue_web pattern): bump version, update CHANGELOG, tag, push; CI publishes via Trusted Publishing
16
- - `turbo_rspec` generator (`rails generate turbo_rspec:install`) to scaffold `spec/support/turbo.rb`
12
+ ## 1.2 Turbo 8 / morphing
17
13
 
18
- ---
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.
19
21
 
20
- ## Post-1.0 ideas (not scheduled)
22
+ ## 1.4 Tooling
21
23
 
22
- - VS Code / RubyMine snippet pack for common patterns
23
- - Playwright/Puppeteer bridge for headless assertions outside Capybara
24
- - Shared examples: `it_behaves_like "a turbo stream response"` for controller testing
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.
25
26
 
26
27
  ---
27
28
 
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module TurboRspec
6
+ module Generators
7
+ # Rails generator that scaffolds a spec/support/turbo_rspec.rb file
8
+ # with sensible defaults for TurboRspec configuration.
9
+ #
10
+ # @example
11
+ # rails generate turbo_rspec:install
12
+ class InstallGenerator < Rails::Generators::Base
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ desc "Creates a spec/support/turbo_rspec.rb configuration file"
16
+
17
+ def create_support_file
18
+ template "turbo_rspec.rb", "spec/support/turbo_rspec.rb"
19
+ end
20
+
21
+ def show_readme
22
+ readme "README" if behavior == :invoke
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,13 @@
1
+ TurboRspec installed!
2
+
3
+ A configuration file has been created at spec/support/turbo_rspec.rb.
4
+
5
+ Make sure it is required in your spec/rails_helper.rb:
6
+
7
+ Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f }
8
+
9
+ Matchers are automatically included in request, controller, system,
10
+ and feature specs when turbo-rails is present.
11
+
12
+ See the docs for usage examples:
13
+ https://github.com/eclectic-coding/turbo_rspec
@@ -0,0 +1,13 @@
1
+ # TurboRspec configuration
2
+ # See: https://github.com/eclectic-coding/turbo_rspec
3
+
4
+ TurboRspec.configure do |config|
5
+ # Set to false to disable automatic inclusion of matchers into
6
+ # request, controller, system, and feature example groups.
7
+ # config.auto_include = false
8
+ end
9
+
10
+ # Uncomment to include Minitest assertions in integration tests:
11
+ # class ActionDispatch::IntegrationTest
12
+ # include TurboRspec::Assertions
13
+ # end
@@ -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 = "0.7.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: 0.7.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -23,15 +23,18 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '1.13'
26
- description: 'Drop-in RSpec matchers for hotwired/turbo-rails: assert Turbo Stream
27
- responses, Turbo Frame content, and ActionCable broadcasts without hand-rolling
28
- helpers in every project.'
26
+ description: 'Drop-in test matchers for hotwired/turbo-rails: assert Turbo Stream
27
+ responses, Turbo Frame content, ActionCable broadcasts, and Capybara page assertions.
28
+ Includes RSpec matchers (have_turbo_stream, have_turbo_frame, have_broadcasted_turbo_stream_to),
29
+ Minitest assertions, factory helpers, shared examples, and a Rails generator — all
30
+ auto-included with zero setup.'
29
31
  email:
30
32
  - eclectic-coding@users.noreply.github.com
31
33
  executables: []
32
34
  extensions: []
33
35
  extra_rdoc_files: []
34
36
  files:
37
+ - ".github/FUNDING.yml"
35
38
  - ".github/workflows/ci.yml"
36
39
  - ".github/workflows/publish.yml"
37
40
  - ".rubocop.yml"
@@ -45,6 +48,9 @@ files:
45
48
  - codecov.yml
46
49
  - docs/cookbook.md
47
50
  - docs/migration_guide.md
51
+ - lib/generators/turbo_rspec/install_generator.rb
52
+ - lib/generators/turbo_rspec/templates/README
53
+ - lib/generators/turbo_rspec/templates/turbo_rspec.rb
48
54
  - lib/turbo_rspec.rb
49
55
  - lib/turbo_rspec/assertions.rb
50
56
  - lib/turbo_rspec/capybara/matchers.rb
@@ -85,5 +91,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
85
91
  requirements: []
86
92
  rubygems_version: 3.6.9
87
93
  specification_version: 4
88
- summary: RSpec matchers for Turbo Streams, Turbo Frames, and ActionCable broadcasts.
94
+ summary: RSpec matchers and Minitest assertions for hotwired/turbo-rails.
89
95
  test_files: []