turbo_rspec 1.0.0 → 1.2.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 +4 -4
- data/.github/FUNDING.yml +15 -0
- data/CHANGELOG.md +19 -0
- data/README.md +33 -1
- data/ROADMAP.md +7 -3
- data/lib/turbo_rspec/capybara/matchers/have_turbo_frame.rb +25 -0
- data/lib/turbo_rspec/configuration.rb +6 -0
- data/lib/turbo_rspec/matchers/have_turbo_frame.rb +24 -2
- data/lib/turbo_rspec/matchers/have_turbo_stream.rb +141 -11
- data/lib/turbo_rspec/version.rb +1 -1
- data/lib/turbo_rspec.rb +21 -0
- data/sig/turbo_rspec.rbs +157 -2
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7341e9dfd6be8235a2269e94ef9923e470aa5da675ccd52f8d507f42c3950398
|
|
4
|
+
data.tar.gz: 65f4f2bb0aa32c4032d4ba2daad8c9442e20d056b3a455b0937eaeaf380f924a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b433e046578d5f3914a95cd443ed32020bff526bddf3bf79eea34ef7bc52a7ce06823ac25714f309842c357d97e0e70edd208b75a85c8e0a118e7d8b86b36c9f
|
|
7
|
+
data.tar.gz: 44d25746b61d50d932f74f92b2f6a07c50c70a6d320c452f302693b507617da38facf8fca1d0ac61f66cf01a49187e8288a20c6182b27cbe4ab91db42fe28ec5
|
data/.github/FUNDING.yml
ADDED
|
@@ -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,24 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.2.0] - 2026-06-01
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `have_turbo_stream.with_attributes(hash)` — assert arbitrary HTML attributes on a stream element; accepts string or symbol keys
|
|
8
|
+
- `have_turbo_frame.with_attributes(hash)` — same for frame elements in request specs
|
|
9
|
+
- `have_turbo_stream.children_only` — assert the `children-only` attribute on Turbo 8 morph streams
|
|
10
|
+
- `TurboRspec.register_action(:name)` — declare custom Turbo stream actions; `with_action` now raises `ArgumentError` for unrecognised actions with a hint to register them
|
|
11
|
+
- `TurboRspec::BUILTIN_ACTIONS` — public constant listing all built-in action names
|
|
12
|
+
- `TurboRspec.known_actions` — returns built-in + registered custom actions
|
|
13
|
+
|
|
14
|
+
## [1.1.0] - 2026-06-01
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- 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`
|
|
19
|
+
- `have_turbo_frame` (Capybara) `.with_src(url)` chain — asserts the `src` attribute of a lazy-loaded frame
|
|
20
|
+
- `have_turbo_frame` (Capybara) `.lazy` chain — asserts `loading="lazy"` is set on the frame
|
|
21
|
+
|
|
3
22
|
## [1.0.0] - 2026-05-28
|
|
4
23
|
|
|
5
24
|
### Added
|
data/README.md
CHANGED
|
@@ -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
|
|
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
|
```
|
data/ROADMAP.md
CHANGED
|
@@ -4,10 +4,14 @@ RSpec matchers for [Turbo](https://github.com/hotwired/turbo-rails): Turbo Strea
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## 1.3 — Stimulus companion
|
|
8
8
|
|
|
9
|
-
-
|
|
10
|
-
|
|
9
|
+
- **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.
|
|
10
|
+
|
|
11
|
+
## 1.4 — Tooling
|
|
12
|
+
|
|
13
|
+
- **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.
|
|
14
|
+
- **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
15
|
|
|
12
16
|
---
|
|
13
17
|
|
|
@@ -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
|
|
@@ -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,13 +28,24 @@ module TurboRspec
|
|
|
28
28
|
@target_all = nil
|
|
29
29
|
@content = nil
|
|
30
30
|
@partial = nil
|
|
31
|
+
@attributes = {}
|
|
32
|
+
@children_only = false
|
|
33
|
+
@expected_count = nil
|
|
34
|
+
@count_type = :at_least
|
|
35
|
+
@matching_count = 0
|
|
31
36
|
end
|
|
32
37
|
|
|
33
38
|
# Constrains the match to streams with the given action.
|
|
34
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
|
|
35
41
|
# @return [self]
|
|
36
42
|
def with_action(action)
|
|
37
|
-
|
|
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
|
|
38
49
|
self
|
|
39
50
|
end
|
|
40
51
|
|
|
@@ -70,12 +81,78 @@ module TurboRspec
|
|
|
70
81
|
self
|
|
71
82
|
end
|
|
72
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
|
+
|
|
104
|
+
# Asserts exactly one matching stream.
|
|
105
|
+
# @return [self]
|
|
106
|
+
def once
|
|
107
|
+
exactly(1)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Asserts exactly two matching streams.
|
|
111
|
+
# @return [self]
|
|
112
|
+
def twice
|
|
113
|
+
exactly(2)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Asserts exactly +n+ matching streams.
|
|
117
|
+
# @param n [Integer]
|
|
118
|
+
# @return [self]
|
|
119
|
+
def exactly(n)
|
|
120
|
+
@expected_count = n
|
|
121
|
+
@count_type = :exactly
|
|
122
|
+
self
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Asserts at least +n+ matching streams.
|
|
126
|
+
# @param n [Integer]
|
|
127
|
+
# @return [self]
|
|
128
|
+
def at_least(n)
|
|
129
|
+
@expected_count = n
|
|
130
|
+
@count_type = :at_least
|
|
131
|
+
self
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Asserts at most +n+ matching streams.
|
|
135
|
+
# @param n [Integer]
|
|
136
|
+
# @return [self]
|
|
137
|
+
def at_most(n)
|
|
138
|
+
@expected_count = n
|
|
139
|
+
@count_type = :at_most
|
|
140
|
+
self
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Fluent terminator so +.exactly(2).times+ reads naturally.
|
|
144
|
+
# @return [self]
|
|
145
|
+
def times
|
|
146
|
+
self
|
|
147
|
+
end
|
|
148
|
+
|
|
73
149
|
# @param response_or_body [#body, String]
|
|
74
150
|
# @return [Boolean]
|
|
75
151
|
def matches?(response_or_body)
|
|
76
152
|
@body = extract_body(response_or_body)
|
|
77
153
|
@streams = parse_streams(@body)
|
|
78
|
-
@streams.
|
|
154
|
+
@matching_count = @streams.count { |stream| stream_matches?(stream) }
|
|
155
|
+
count_matches?(@matching_count)
|
|
79
156
|
end
|
|
80
157
|
|
|
81
158
|
# @param response_or_body [#body, String]
|
|
@@ -86,17 +163,17 @@ module TurboRspec
|
|
|
86
163
|
|
|
87
164
|
# @return [String]
|
|
88
165
|
def failure_message
|
|
89
|
-
"expected response to contain a turbo stream#{constraint_description}\n#{found_streams_message}"
|
|
166
|
+
"expected response to contain a turbo stream#{constraint_description}#{count_description}\n#{found_streams_message}"
|
|
90
167
|
end
|
|
91
168
|
|
|
92
169
|
# @return [String]
|
|
93
170
|
def failure_message_when_negated
|
|
94
|
-
"expected response not to contain a turbo stream#{constraint_description}"
|
|
171
|
+
"expected response not to contain a turbo stream#{constraint_description}#{count_description}"
|
|
95
172
|
end
|
|
96
173
|
|
|
97
174
|
# @return [String]
|
|
98
175
|
def description
|
|
99
|
-
"have turbo stream#{constraint_description}"
|
|
176
|
+
"have turbo stream#{constraint_description}#{count_description}"
|
|
100
177
|
end
|
|
101
178
|
|
|
102
179
|
private
|
|
@@ -118,7 +195,9 @@ module TurboRspec
|
|
|
118
195
|
matches_target?(stream) &&
|
|
119
196
|
matches_target_all?(stream) &&
|
|
120
197
|
matches_content?(stream) &&
|
|
121
|
-
matches_partial?(stream)
|
|
198
|
+
matches_partial?(stream) &&
|
|
199
|
+
matches_attributes?(stream) &&
|
|
200
|
+
matches_children_only?(stream)
|
|
122
201
|
end
|
|
123
202
|
|
|
124
203
|
def matches_action?(stream)
|
|
@@ -143,6 +222,30 @@ module TurboRspec
|
|
|
143
222
|
stream.to_html.include?(@partial)
|
|
144
223
|
end
|
|
145
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
|
+
|
|
235
|
+
def count_matches?(n)
|
|
236
|
+
if @expected_count.nil?
|
|
237
|
+
n >= 1
|
|
238
|
+
else
|
|
239
|
+
# :nocov:
|
|
240
|
+
case @count_type
|
|
241
|
+
# :nocov:
|
|
242
|
+
when :exactly then n == @expected_count
|
|
243
|
+
when :at_least then n >= @expected_count
|
|
244
|
+
when :at_most then n <= @expected_count
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
146
249
|
def constraint_description
|
|
147
250
|
parts = []
|
|
148
251
|
parts << " with action #{@action.inspect}" if @action
|
|
@@ -150,9 +253,22 @@ module TurboRspec
|
|
|
150
253
|
parts << " targeting all #{@target_all.inspect}" if @target_all
|
|
151
254
|
parts << " with content #{@content.inspect}" if @content
|
|
152
255
|
parts << " rendering #{@partial.inspect}" if @partial
|
|
256
|
+
parts << " with attributes #{@attributes.inspect}" if @attributes.any?
|
|
257
|
+
parts << " children only" if @children_only
|
|
153
258
|
parts.join
|
|
154
259
|
end
|
|
155
260
|
|
|
261
|
+
def count_description
|
|
262
|
+
return "" if @expected_count.nil?
|
|
263
|
+
# :nocov:
|
|
264
|
+
case @count_type
|
|
265
|
+
# :nocov:
|
|
266
|
+
when :exactly then " exactly #{@expected_count} time(s)"
|
|
267
|
+
when :at_least then " at least #{@expected_count} time(s)"
|
|
268
|
+
when :at_most then " at most #{@expected_count} time(s)"
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
156
272
|
def found_streams_message
|
|
157
273
|
return "but no turbo streams were found in the response" if @streams.empty?
|
|
158
274
|
|
|
@@ -163,10 +279,15 @@ module TurboRspec
|
|
|
163
279
|
lines << " #{i + 1}. action=#{s["action"].inspect} target=#{s["target"].inspect} content=#{content_preview}"
|
|
164
280
|
end
|
|
165
281
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
282
|
+
if @expected_count
|
|
283
|
+
lines << ""
|
|
284
|
+
lines << "#{@matching_count} of #{@streams.size} stream(s) matched the constraints"
|
|
285
|
+
else
|
|
286
|
+
closest = closest_match
|
|
287
|
+
lines << ""
|
|
288
|
+
lines << "closest match (#{count_matching_constraints(closest)}/#{constraint_count} constraint(s) matched):"
|
|
289
|
+
lines.concat(constraint_diff(closest))
|
|
290
|
+
end
|
|
170
291
|
|
|
171
292
|
lines.join("\n")
|
|
172
293
|
end
|
|
@@ -182,11 +303,15 @@ module TurboRspec
|
|
|
182
303
|
count += 1 if !@target_all.nil? && matches_target_all?(stream)
|
|
183
304
|
count += 1 if !@content.nil? && matches_content?(stream)
|
|
184
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)
|
|
185
308
|
count
|
|
186
309
|
end
|
|
187
310
|
|
|
188
311
|
def constraint_count
|
|
189
|
-
[@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)
|
|
190
315
|
end
|
|
191
316
|
|
|
192
317
|
def constraint_diff(stream)
|
|
@@ -196,6 +321,11 @@ module TurboRspec
|
|
|
196
321
|
lines << " #{matches_target_all?(stream) ? "✓" : "✗"} targets: expected #{@target_all.inspect}, got #{stream["targets"].inspect}" if @target_all
|
|
197
322
|
lines << " #{matches_content?(stream) ? "✓" : "✗"} content: expected to include #{@content.inspect}, got #{stream.text.strip.slice(0, 50).inspect}" if @content
|
|
198
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
|
|
199
329
|
lines
|
|
200
330
|
end
|
|
201
331
|
end
|
data/lib/turbo_rspec/version.rb
CHANGED
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,159 @@
|
|
|
1
1
|
module TurboRspec
|
|
2
2
|
VERSION: String
|
|
3
|
-
|
|
4
|
-
|
|
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 within_turbo_frame: (String id) { () -> void } -> void
|
|
131
|
+
|
|
132
|
+
class HaveTurboFrame
|
|
133
|
+
def initialize: (String id) -> void
|
|
134
|
+
|
|
135
|
+
# Constraint chain
|
|
136
|
+
def with_content: (String text) -> self
|
|
137
|
+
def loaded: () -> self
|
|
138
|
+
def with_src: (String url) -> self
|
|
139
|
+
def lazy: () -> self
|
|
140
|
+
|
|
141
|
+
# Capybara matcher interface
|
|
142
|
+
def matches?: (untyped page_or_node) -> bool
|
|
143
|
+
def does_not_match?: (untyped page_or_node) -> bool
|
|
144
|
+
def failure_message: () -> String
|
|
145
|
+
def failure_message_when_negated: () -> String
|
|
146
|
+
def description: () -> String
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
class HaveTurboStreamTag
|
|
150
|
+
def initialize: (?signed_stream_name: String?) -> void
|
|
151
|
+
def matches?: (untyped page_or_node) -> bool
|
|
152
|
+
def does_not_match?: (untyped page_or_node) -> bool
|
|
153
|
+
def failure_message: () -> String
|
|
154
|
+
def failure_message_when_negated: () -> String
|
|
155
|
+
def description: () -> String
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
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.
|
|
4
|
+
version: 1.2.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"
|