stimulus_spec 0.5.0 → 0.7.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/.yardopts +8 -0
- data/CHANGELOG.md +18 -1
- data/README.md +24 -2
- data/ROADMAP.md +0 -26
- data/docs/cookbook.md +152 -0
- data/lib/stimulus_spec/capybara/matchers/have_stimulus_action.rb +25 -8
- data/lib/stimulus_spec/capybara/matchers/have_stimulus_class.rb +28 -1
- data/lib/stimulus_spec/capybara/matchers/have_stimulus_controller.rb +38 -9
- data/lib/stimulus_spec/capybara/matchers/have_stimulus_outlet.rb +28 -1
- data/lib/stimulus_spec/capybara/matchers/have_stimulus_target.rb +27 -2
- data/lib/stimulus_spec/capybara/matchers/have_stimulus_value.rb +28 -1
- data/lib/stimulus_spec/capybara/matchers.rb +1 -0
- data/lib/stimulus_spec/configuration.rb +2 -0
- data/lib/stimulus_spec/matchers/have_stimulus_action.rb +31 -3
- data/lib/stimulus_spec/matchers/have_stimulus_class.rb +35 -8
- data/lib/stimulus_spec/matchers/have_stimulus_controller.rb +48 -11
- data/lib/stimulus_spec/matchers/have_stimulus_outlet.rb +35 -8
- data/lib/stimulus_spec/matchers/have_stimulus_target.rb +30 -2
- data/lib/stimulus_spec/matchers/have_stimulus_value.rb +35 -8
- data/lib/stimulus_spec/version.rb +1 -1
- data/lib/stimulus_spec.rb +25 -0
- data/sig/stimulus_spec.rbs +17 -5
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 31b3ef6e3b0ef09e3f6353d975ee2a65dd7c42dbc4d3be8aa78c7508b88d9ba7
|
|
4
|
+
data.tar.gz: 5f79191a5baccfabe6a8502fa8708248dc71efe1ba63d87f816a2c882ad5e941
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6b789aa21b411f503223b5c6116507f48168cfd675ea198c4c3a8cf68490754ebcaf0bcdea718c4cf492400cf3df6eba5d0079c41b2e2ce48020165b6b4bb348
|
|
7
|
+
data.tar.gz: 97623a443042991da1c942449406f0e92c0b8918d8a9bdbd8b001962c0c238db103d0d2cc9d27a3aff05bd75256d65b593686876fd731f883f8a5c0547af9d0d
|
data/.yardopts
ADDED
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.7.0] - 2026-06-23
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Full YARD documentation (`@param`, `@return`, `@example`) on all public methods and classes
|
|
15
|
+
- `docs/cookbook.md` with common patterns: request specs, system specs, multi-controller, scoped matching, typed values, outlets
|
|
16
|
+
- `.yardopts` configuration file
|
|
17
|
+
|
|
18
|
+
## [0.6.0] - 2026-06-23
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- Multi-controller assertion: `have_stimulus_controller("hello", "clipboard")` asserts multiple controllers on a single element
|
|
23
|
+
- Scoped matching via `.within(selector)` on all matchers (request-spec and Capybara)
|
|
24
|
+
|
|
10
25
|
## [0.5.0] - 2026-06-23
|
|
11
26
|
|
|
12
27
|
### Added
|
|
@@ -46,7 +61,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
46
61
|
- `have_stimulus_action(descriptor)` matcher — full descriptor (`~=`) and shorthand without event (`*=`)
|
|
47
62
|
- `have_stimulus_target(controller, target)` matcher — asserts `[data-{controller}-target~="target"]`
|
|
48
63
|
|
|
49
|
-
[Unreleased]: https://github.com/eclectic-coding/stimulus_spec/compare/v0.
|
|
64
|
+
[Unreleased]: https://github.com/eclectic-coding/stimulus_spec/compare/v0.7.0...HEAD
|
|
65
|
+
[0.7.0]: https://github.com/eclectic-coding/stimulus_spec/releases/tag/v0.7.0
|
|
66
|
+
[0.6.0]: https://github.com/eclectic-coding/stimulus_spec/releases/tag/v0.6.0
|
|
50
67
|
[0.5.0]: https://github.com/eclectic-coding/stimulus_spec/releases/tag/v0.5.0
|
|
51
68
|
[0.4.0]: https://github.com/eclectic-coding/stimulus_spec/releases/tag/v0.4.0
|
|
52
69
|
[0.3.0]: https://github.com/eclectic-coding/stimulus_spec/releases/tag/v0.3.0
|
data/README.md
CHANGED
|
@@ -15,6 +15,13 @@ Drop-in RSpec matchers for [hotwired/stimulus-rails](https://github.com/hotwired
|
|
|
15
15
|
|
|
16
16
|
Companion gem to [turbo_rspec](https://github.com/eclectic-coding/turbo_rspec) — together they cover the full Hotwire testing stack.
|
|
17
17
|
|
|
18
|
+
## Guiding principles
|
|
19
|
+
|
|
20
|
+
- **Zero magic by default.** Auto-include only when it's unambiguous (Rails request specs with `stimulus-rails` present). Everything else is opt-in.
|
|
21
|
+
- **Fail loudly with useful output.** A cryptic failure message is a bug. Show what was expected, what was found, and the relevant HTML.
|
|
22
|
+
- **Stay close to Stimulus conventions.** Matcher names and arguments mirror Stimulus's `data-*` attribute conventions so the docs cross-reference naturally.
|
|
23
|
+
- **Pure test helper.** No Rails engine, no generators, no runtime code — just matchers you include in your specs.
|
|
24
|
+
|
|
18
25
|
## Table of Contents
|
|
19
26
|
|
|
20
27
|
- [Installation](#installation)
|
|
@@ -26,6 +33,7 @@ Companion gem to [turbo_rspec](https://github.com/eclectic-coding/turbo_rspec)
|
|
|
26
33
|
- [have\_stimulus\_value](#have_stimulus_value)
|
|
27
34
|
- [have\_stimulus\_class](#have_stimulus_class)
|
|
28
35
|
- [have\_stimulus\_outlet](#have_stimulus_outlet)
|
|
36
|
+
- [Scoped matching with `.within`](#scoped-matching-with-within)
|
|
29
37
|
- [Example](#example)
|
|
30
38
|
- [Relationship to turbo\_rspec](#relationship-to-turbo_rspec)
|
|
31
39
|
- [Contributing](#contributing)
|
|
@@ -84,8 +92,8 @@ Assert that rendered HTML contains a `data-controller` attribute with the given
|
|
|
84
92
|
```ruby
|
|
85
93
|
expect(response).to have_stimulus_controller("hello")
|
|
86
94
|
|
|
87
|
-
#
|
|
88
|
-
expect(response).to have_stimulus_controller("clipboard")
|
|
95
|
+
# Assert multiple controllers on a single element
|
|
96
|
+
expect(response).to have_stimulus_controller("hello", "clipboard")
|
|
89
97
|
|
|
90
98
|
# Negation
|
|
91
99
|
expect(response).not_to have_stimulus_controller("missing")
|
|
@@ -165,6 +173,20 @@ expect(response).to have_stimulus_outlet("search", "results", "#results-list")
|
|
|
165
173
|
expect(response).not_to have_stimulus_outlet("search", "results")
|
|
166
174
|
```
|
|
167
175
|
|
|
176
|
+
### Scoped matching with `.within`
|
|
177
|
+
|
|
178
|
+
All matchers support `.within(selector)` to restrict matching to a specific part of the page:
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
# Request/controller specs — search within a CSS selector
|
|
182
|
+
expect(response).to have_stimulus_controller("search").within(".search-form")
|
|
183
|
+
expect(response).to have_stimulus_action("click->search#query").within(".search-form")
|
|
184
|
+
expect(response).to have_stimulus_target("search", "input").within(".search-form")
|
|
185
|
+
|
|
186
|
+
# System/feature specs — same API
|
|
187
|
+
expect(page).to have_stimulus_controller("search").within(".search-form")
|
|
188
|
+
```
|
|
189
|
+
|
|
168
190
|
[Back to top](#stimulusspec)
|
|
169
191
|
|
|
170
192
|
## Example
|
data/ROADMAP.md
CHANGED
|
@@ -1,31 +1,5 @@
|
|
|
1
1
|
# stimulus_spec Roadmap
|
|
2
2
|
|
|
3
|
-
RSpec matchers for [Stimulus](https://github.com/hotwired/stimulus-rails): controllers, actions, targets, values, classes, and outlets. The goal is to replace hand-rolled `data-controller` assertions with expressive, purpose-built matchers.
|
|
4
|
-
|
|
5
|
-
## Guiding principles
|
|
6
|
-
|
|
7
|
-
- **Zero magic by default.** Auto-include only when it's unambiguous (Rails request specs with `stimulus-rails` present). Everything else is opt-in.
|
|
8
|
-
- **Fail loudly with useful output.** A cryptic failure message is a bug. Show what was expected, what was found, and the relevant HTML.
|
|
9
|
-
- **Stay close to Stimulus conventions.** Matcher names and arguments mirror Stimulus's `data-*` attribute conventions so the docs cross-reference naturally.
|
|
10
|
-
- **Pure test helper.** No Rails engine, no generators, no runtime code — just matchers you include in your specs.
|
|
11
|
-
|
|
12
|
-
---
|
|
13
|
-
|
|
14
|
-
## 0.6.0 — Multi-Controller and Scoped Matching
|
|
15
|
-
|
|
16
|
-
- `have_stimulus_controller("hello", "clipboard")` — assert multiple controllers on a single element
|
|
17
|
-
- Scoped matching — assert Stimulus attributes within a specific CSS selector (e.g., within a form or component root)
|
|
18
|
-
|
|
19
|
-
---
|
|
20
|
-
|
|
21
|
-
## 0.7.0 — Documentation and Polish
|
|
22
|
-
|
|
23
|
-
- Full YARD documentation on all public methods and classes
|
|
24
|
-
- `docs/cookbook.md` — common patterns: request specs, system specs, multi-controller elements, typed values, outlets
|
|
25
|
-
- Graceful no-op when `stimulus-rails` is not bundled (no `LoadError`)
|
|
26
|
-
|
|
27
|
-
---
|
|
28
|
-
|
|
29
3
|
## 1.0.0 — Stable API
|
|
30
4
|
|
|
31
5
|
- API freeze — public method signatures are part of the semver contract
|
data/docs/cookbook.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# StimulusSpec Cookbook
|
|
2
|
+
|
|
3
|
+
Common patterns for testing Stimulus wiring with `stimulus_spec`.
|
|
4
|
+
|
|
5
|
+
## Request specs
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
RSpec.describe "Search", type: :request do
|
|
9
|
+
describe "GET /search" do
|
|
10
|
+
before { get search_path }
|
|
11
|
+
|
|
12
|
+
it "wires up the search controller" do
|
|
13
|
+
expect(response).to have_stimulus_controller("search")
|
|
14
|
+
expect(response).to have_stimulus_action("input->search#query")
|
|
15
|
+
expect(response).to have_stimulus_target("search", "input")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it "sets the URL value" do
|
|
19
|
+
expect(response).to have_stimulus_value("search", "url", "/results")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## System specs
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
RSpec.describe "Search", type: :system do
|
|
29
|
+
it "has the search controller wired up" do
|
|
30
|
+
visit search_path
|
|
31
|
+
|
|
32
|
+
expect(page).to have_stimulus_controller("search")
|
|
33
|
+
expect(page).to have_stimulus_action("input->search#query")
|
|
34
|
+
expect(page).to have_stimulus_target("search", "input")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Multi-controller elements
|
|
40
|
+
|
|
41
|
+
When a single element declares multiple Stimulus controllers, assert them all at once:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
# Assert both controllers are on the same element
|
|
45
|
+
expect(response).to have_stimulus_controller("dropdown", "keyboard-nav")
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Or assert them individually:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
expect(response).to have_stimulus_controller("dropdown")
|
|
52
|
+
expect(response).to have_stimulus_controller("keyboard-nav")
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Scoped matching
|
|
56
|
+
|
|
57
|
+
Use `.within(selector)` to restrict matching to a specific part of the page. Useful when the same controller appears in multiple places:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
# Only match within the sidebar
|
|
61
|
+
expect(response).to have_stimulus_controller("nav").within(".sidebar")
|
|
62
|
+
|
|
63
|
+
# Assert a target exists within a specific form
|
|
64
|
+
expect(response).to have_stimulus_target("search", "input").within("#search-form")
|
|
65
|
+
|
|
66
|
+
# Works with all matchers
|
|
67
|
+
expect(response).to have_stimulus_action("click->tabs#select").within(".tab-bar")
|
|
68
|
+
expect(response).to have_stimulus_value("editor", "language", "ruby").within(".code-panel")
|
|
69
|
+
expect(response).to have_stimulus_class("menu", "active", "bg-blue-500").within("nav")
|
|
70
|
+
expect(response).to have_stimulus_outlet("search", "results", "#results-list").within(".search-form")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Scoped matching works the same way with Capybara matchers in system/feature specs:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
expect(page).to have_stimulus_controller("nav").within(".sidebar")
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Typed values
|
|
80
|
+
|
|
81
|
+
Stimulus values are always stored as strings in the DOM. Pass the expected string representation:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
# Boolean value
|
|
85
|
+
expect(response).to have_stimulus_value("toggle", "open", "true")
|
|
86
|
+
|
|
87
|
+
# Numeric value
|
|
88
|
+
expect(response).to have_stimulus_value("pagination", "page", "1")
|
|
89
|
+
|
|
90
|
+
# JSON value
|
|
91
|
+
expect(response).to have_stimulus_value("chart", "data", '{"labels":["Jan","Feb"]}')
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
To assert a value attribute exists without checking its content, use the two-argument form:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
expect(response).to have_stimulus_value("search", "url")
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## CSS classes
|
|
101
|
+
|
|
102
|
+
Assert Stimulus CSS class attributes (`data-{controller}-{name}-class`):
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
# Assert the class attribute exists
|
|
106
|
+
expect(response).to have_stimulus_class("search", "loading")
|
|
107
|
+
|
|
108
|
+
# Assert a specific class value
|
|
109
|
+
expect(response).to have_stimulus_class("search", "loading", "opacity-50")
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Outlets
|
|
113
|
+
|
|
114
|
+
Assert Stimulus outlet attributes (`data-{controller}-{outlet}-outlet`):
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
# Assert the outlet attribute exists
|
|
118
|
+
expect(response).to have_stimulus_outlet("search", "results")
|
|
119
|
+
|
|
120
|
+
# Assert a specific CSS selector
|
|
121
|
+
expect(response).to have_stimulus_outlet("search", "results", "#results-list")
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Negation
|
|
125
|
+
|
|
126
|
+
All matchers support `not_to`:
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
expect(response).not_to have_stimulus_controller("admin")
|
|
130
|
+
expect(response).not_to have_stimulus_action("click->admin#delete")
|
|
131
|
+
expect(response).not_to have_stimulus_target("admin", "panel")
|
|
132
|
+
expect(response).not_to have_stimulus_value("admin", "role")
|
|
133
|
+
expect(response).not_to have_stimulus_class("admin", "active")
|
|
134
|
+
expect(response).not_to have_stimulus_outlet("admin", "panel")
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Disabling auto-include
|
|
138
|
+
|
|
139
|
+
By default, matchers are auto-included when `stimulus-rails` is in your bundle. To disable:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
# spec/support/stimulus_spec.rb
|
|
143
|
+
StimulusSpec.configure do |config|
|
|
144
|
+
config.auto_include = false
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Then include manually where needed
|
|
148
|
+
RSpec.configure do |config|
|
|
149
|
+
config.include StimulusSpec::Matchers, type: :request
|
|
150
|
+
config.include StimulusSpec::Capybara::Matchers, type: :system
|
|
151
|
+
end
|
|
152
|
+
```
|
|
@@ -3,18 +3,32 @@
|
|
|
3
3
|
module StimulusSpec
|
|
4
4
|
module Capybara
|
|
5
5
|
module Matchers
|
|
6
|
+
# Capybara matcher for +data-action+ attributes.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# expect(page).to have_stimulus_action("click->hello#greet")
|
|
6
10
|
class HaveStimulusAction
|
|
11
|
+
# @param descriptor [String] action descriptor
|
|
7
12
|
def initialize(descriptor)
|
|
8
13
|
@descriptor = descriptor.to_s
|
|
9
14
|
end
|
|
10
15
|
|
|
16
|
+
# @param selector [String] CSS selector for the scope element
|
|
17
|
+
# @return [self]
|
|
18
|
+
def within(selector)
|
|
19
|
+
@scope = selector
|
|
20
|
+
self
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param page [Capybara::Session, Capybara::Node::Simple]
|
|
24
|
+
# @return [Boolean]
|
|
11
25
|
def matches?(page)
|
|
12
26
|
@page = page
|
|
13
|
-
page.has_css?(
|
|
27
|
+
page.has_css?(scoped_selector, wait: 0)
|
|
14
28
|
end
|
|
15
29
|
|
|
16
30
|
def does_not_match?(page)
|
|
17
|
-
page.has_no_css?(
|
|
31
|
+
page.has_no_css?(scoped_selector, wait: 0)
|
|
18
32
|
end
|
|
19
33
|
|
|
20
34
|
def failure_message
|
|
@@ -31,15 +45,18 @@ module StimulusSpec
|
|
|
31
45
|
|
|
32
46
|
private
|
|
33
47
|
|
|
34
|
-
def
|
|
35
|
-
if @descriptor.include?("->")
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
48
|
+
def scoped_selector
|
|
49
|
+
base = if @descriptor.include?("->")
|
|
50
|
+
"[data-action~='#{@descriptor}']"
|
|
51
|
+
else
|
|
52
|
+
"[data-action*='#{@descriptor}']"
|
|
53
|
+
end
|
|
54
|
+
@scope ? "#{@scope} #{base}" : base
|
|
40
55
|
end
|
|
41
56
|
end
|
|
42
57
|
|
|
58
|
+
# @param descriptor [String] action descriptor
|
|
59
|
+
# @return [HaveStimulusAction]
|
|
43
60
|
def have_stimulus_action(descriptor)
|
|
44
61
|
HaveStimulusAction.new(descriptor)
|
|
45
62
|
end
|
|
@@ -3,7 +3,14 @@
|
|
|
3
3
|
module StimulusSpec
|
|
4
4
|
module Capybara
|
|
5
5
|
module Matchers
|
|
6
|
+
# Capybara matcher for +data-{controller}-{name}-class+ attributes.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# expect(page).to have_stimulus_class("search", "loading", "opacity-50")
|
|
6
10
|
class HaveStimulusClass
|
|
11
|
+
# @param controller [String] controller name
|
|
12
|
+
# @param name [String] class name
|
|
13
|
+
# @param expected [String, nil] expected attribute value
|
|
7
14
|
def initialize(controller, name, expected = nil)
|
|
8
15
|
@controller = controller.to_s
|
|
9
16
|
@name = name.to_s
|
|
@@ -11,9 +18,18 @@ module StimulusSpec
|
|
|
11
18
|
@attr = "data-#{@controller}-#{@name}-class"
|
|
12
19
|
end
|
|
13
20
|
|
|
21
|
+
# @param selector [String] CSS selector for the scope element
|
|
22
|
+
# @return [self]
|
|
23
|
+
def within(selector)
|
|
24
|
+
@scope = selector
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param page [Capybara::Session, Capybara::Node::Simple]
|
|
29
|
+
# @return [Boolean]
|
|
14
30
|
def matches?(page)
|
|
15
31
|
@page = page
|
|
16
|
-
element = page.first(
|
|
32
|
+
element = page.first(scoped_selector, minimum: 0, wait: 0)
|
|
17
33
|
return false unless element
|
|
18
34
|
|
|
19
35
|
if @expected
|
|
@@ -47,8 +63,19 @@ module StimulusSpec
|
|
|
47
63
|
"have Stimulus class \"#{@name}\" for controller \"#{@controller}\""
|
|
48
64
|
end
|
|
49
65
|
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def scoped_selector
|
|
70
|
+
base = "[#{@attr}]"
|
|
71
|
+
@scope ? "#{@scope} #{base}" : base
|
|
72
|
+
end
|
|
50
73
|
end
|
|
51
74
|
|
|
75
|
+
# @param controller [String] controller name
|
|
76
|
+
# @param name [String] class name
|
|
77
|
+
# @param expected [String, nil] expected class value
|
|
78
|
+
# @return [HaveStimulusClass]
|
|
52
79
|
def have_stimulus_class(controller, name, expected = nil)
|
|
53
80
|
HaveStimulusClass.new(controller, name, expected)
|
|
54
81
|
end
|
|
@@ -3,35 +3,64 @@
|
|
|
3
3
|
module StimulusSpec
|
|
4
4
|
module Capybara
|
|
5
5
|
module Matchers
|
|
6
|
+
# Capybara matcher for +data-controller+ attributes.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# expect(page).to have_stimulus_controller("hello")
|
|
10
|
+
# @example Multiple controllers
|
|
11
|
+
# expect(page).to have_stimulus_controller("hello", "clipboard")
|
|
6
12
|
class HaveStimulusController
|
|
7
|
-
|
|
8
|
-
|
|
13
|
+
# @param names [Array<String>] one or more controller names
|
|
14
|
+
def initialize(*names)
|
|
15
|
+
@names = names.map(&:to_s)
|
|
9
16
|
end
|
|
10
17
|
|
|
18
|
+
# @param selector [String] CSS selector for the scope element
|
|
19
|
+
# @return [self]
|
|
20
|
+
def within(selector)
|
|
21
|
+
@scope = selector
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param page [Capybara::Session, Capybara::Node::Simple] Capybara page object
|
|
26
|
+
# @return [Boolean]
|
|
11
27
|
def matches?(page)
|
|
12
28
|
@page = page
|
|
13
|
-
page.has_css?(
|
|
29
|
+
page.has_css?(scoped_selector, wait: 0)
|
|
14
30
|
end
|
|
15
31
|
|
|
16
32
|
def does_not_match?(page)
|
|
17
|
-
page.has_no_css?(
|
|
33
|
+
page.has_no_css?(scoped_selector, wait: 0)
|
|
18
34
|
end
|
|
19
35
|
|
|
20
36
|
def failure_message
|
|
21
|
-
"expected to find an element with data-controller
|
|
37
|
+
"expected to find an element with data-controller=#{label} on the page"
|
|
22
38
|
end
|
|
23
39
|
|
|
24
40
|
def failure_message_when_negated
|
|
25
|
-
"expected not to find an element with data-controller
|
|
41
|
+
"expected not to find an element with data-controller=#{label} on the page"
|
|
26
42
|
end
|
|
27
43
|
|
|
28
44
|
def description
|
|
29
|
-
"have Stimulus controller
|
|
45
|
+
"have Stimulus controller #{label}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def label
|
|
51
|
+
@names.map { |n| "\"#{n}\"" }.join(", ")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def scoped_selector
|
|
55
|
+
selector = @names.map { |n| "[data-controller~='#{n}']" }.join
|
|
56
|
+
@scope ? "#{@scope} #{selector}" : selector
|
|
30
57
|
end
|
|
31
58
|
end
|
|
32
59
|
|
|
33
|
-
|
|
34
|
-
|
|
60
|
+
# @param names [Array<String>] one or more controller names
|
|
61
|
+
# @return [HaveStimulusController]
|
|
62
|
+
def have_stimulus_controller(*names)
|
|
63
|
+
HaveStimulusController.new(*names)
|
|
35
64
|
end
|
|
36
65
|
end
|
|
37
66
|
end
|
|
@@ -3,7 +3,14 @@
|
|
|
3
3
|
module StimulusSpec
|
|
4
4
|
module Capybara
|
|
5
5
|
module Matchers
|
|
6
|
+
# Capybara matcher for +data-{controller}-{outlet}-outlet+ attributes.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# expect(page).to have_stimulus_outlet("search", "results", "#results-list")
|
|
6
10
|
class HaveStimulusOutlet
|
|
11
|
+
# @param controller [String] controller name
|
|
12
|
+
# @param outlet [String] outlet name
|
|
13
|
+
# @param selector [String, nil] expected CSS selector value
|
|
7
14
|
def initialize(controller, outlet, selector = nil)
|
|
8
15
|
@controller = controller.to_s
|
|
9
16
|
@outlet = outlet.to_s
|
|
@@ -11,9 +18,18 @@ module StimulusSpec
|
|
|
11
18
|
@attr = "data-#{@controller}-#{@outlet}-outlet"
|
|
12
19
|
end
|
|
13
20
|
|
|
21
|
+
# @param selector [String] CSS selector for the scope element
|
|
22
|
+
# @return [self]
|
|
23
|
+
def within(selector)
|
|
24
|
+
@scope = selector
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param page [Capybara::Session, Capybara::Node::Simple]
|
|
29
|
+
# @return [Boolean]
|
|
14
30
|
def matches?(page)
|
|
15
31
|
@page = page
|
|
16
|
-
element = page.first(
|
|
32
|
+
element = page.first(scoped_selector, minimum: 0, wait: 0)
|
|
17
33
|
return false unless element
|
|
18
34
|
|
|
19
35
|
if @selector
|
|
@@ -47,8 +63,19 @@ module StimulusSpec
|
|
|
47
63
|
"have Stimulus outlet \"#{@outlet}\" for controller \"#{@controller}\""
|
|
48
64
|
end
|
|
49
65
|
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def scoped_selector
|
|
70
|
+
base = "[#{@attr}]"
|
|
71
|
+
@scope ? "#{@scope} #{base}" : base
|
|
72
|
+
end
|
|
50
73
|
end
|
|
51
74
|
|
|
75
|
+
# @param controller [String] controller name
|
|
76
|
+
# @param outlet [String] outlet name
|
|
77
|
+
# @param selector [String, nil] expected CSS selector value
|
|
78
|
+
# @return [HaveStimulusOutlet]
|
|
52
79
|
def have_stimulus_outlet(controller, outlet, selector = nil)
|
|
53
80
|
HaveStimulusOutlet.new(controller, outlet, selector)
|
|
54
81
|
end
|
|
@@ -3,19 +3,34 @@
|
|
|
3
3
|
module StimulusSpec
|
|
4
4
|
module Capybara
|
|
5
5
|
module Matchers
|
|
6
|
+
# Capybara matcher for +data-{controller}-target+ attributes.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# expect(page).to have_stimulus_target("hello", "name")
|
|
6
10
|
class HaveStimulusTarget
|
|
11
|
+
# @param controller [String] controller name
|
|
12
|
+
# @param target [String] target name
|
|
7
13
|
def initialize(controller, target)
|
|
8
14
|
@controller = controller.to_s
|
|
9
15
|
@target = target.to_s
|
|
10
16
|
end
|
|
11
17
|
|
|
18
|
+
# @param selector [String] CSS selector for the scope element
|
|
19
|
+
# @return [self]
|
|
20
|
+
def within(selector)
|
|
21
|
+
@scope = selector
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param page [Capybara::Session, Capybara::Node::Simple]
|
|
26
|
+
# @return [Boolean]
|
|
12
27
|
def matches?(page)
|
|
13
28
|
@page = page
|
|
14
|
-
page.has_css?(
|
|
29
|
+
page.has_css?(scoped_selector, wait: 0)
|
|
15
30
|
end
|
|
16
31
|
|
|
17
32
|
def does_not_match?(page)
|
|
18
|
-
page.has_no_css?(
|
|
33
|
+
page.has_no_css?(scoped_selector, wait: 0)
|
|
19
34
|
end
|
|
20
35
|
|
|
21
36
|
def failure_message
|
|
@@ -29,8 +44,18 @@ module StimulusSpec
|
|
|
29
44
|
def description
|
|
30
45
|
"have Stimulus target \"#{@target}\" for controller \"#{@controller}\""
|
|
31
46
|
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def scoped_selector
|
|
51
|
+
base = "[data-#{@controller}-target~='#{@target}']"
|
|
52
|
+
@scope ? "#{@scope} #{base}" : base
|
|
53
|
+
end
|
|
32
54
|
end
|
|
33
55
|
|
|
56
|
+
# @param controller [String] controller name
|
|
57
|
+
# @param target [String] target name
|
|
58
|
+
# @return [HaveStimulusTarget]
|
|
34
59
|
def have_stimulus_target(controller, target)
|
|
35
60
|
HaveStimulusTarget.new(controller, target)
|
|
36
61
|
end
|
|
@@ -3,7 +3,14 @@
|
|
|
3
3
|
module StimulusSpec
|
|
4
4
|
module Capybara
|
|
5
5
|
module Matchers
|
|
6
|
+
# Capybara matcher for +data-{controller}-{name}-value+ attributes.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# expect(page).to have_stimulus_value("search", "url", "/results")
|
|
6
10
|
class HaveStimulusValue
|
|
11
|
+
# @param controller [String] controller name
|
|
12
|
+
# @param name [String] value name
|
|
13
|
+
# @param expected [String, nil] expected attribute value
|
|
7
14
|
def initialize(controller, name, expected = nil)
|
|
8
15
|
@controller = controller.to_s
|
|
9
16
|
@name = name.to_s
|
|
@@ -11,9 +18,18 @@ module StimulusSpec
|
|
|
11
18
|
@attr = "data-#{@controller}-#{@name}-value"
|
|
12
19
|
end
|
|
13
20
|
|
|
21
|
+
# @param selector [String] CSS selector for the scope element
|
|
22
|
+
# @return [self]
|
|
23
|
+
def within(selector)
|
|
24
|
+
@scope = selector
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param page [Capybara::Session, Capybara::Node::Simple]
|
|
29
|
+
# @return [Boolean]
|
|
14
30
|
def matches?(page)
|
|
15
31
|
@page = page
|
|
16
|
-
element = page.first(
|
|
32
|
+
element = page.first(scoped_selector, minimum: 0, wait: 0)
|
|
17
33
|
return false unless element
|
|
18
34
|
|
|
19
35
|
if @expected
|
|
@@ -47,8 +63,19 @@ module StimulusSpec
|
|
|
47
63
|
"have Stimulus value \"#{@name}\" for controller \"#{@controller}\""
|
|
48
64
|
end
|
|
49
65
|
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def scoped_selector
|
|
70
|
+
base = "[#{@attr}]"
|
|
71
|
+
@scope ? "#{@scope} #{base}" : base
|
|
72
|
+
end
|
|
50
73
|
end
|
|
51
74
|
|
|
75
|
+
# @param controller [String] controller name
|
|
76
|
+
# @param name [String] value name
|
|
77
|
+
# @param expected [String, nil] expected value
|
|
78
|
+
# @return [HaveStimulusValue]
|
|
52
79
|
def have_stimulus_value(controller, name, expected = nil)
|
|
53
80
|
HaveStimulusValue.new(controller, name, expected)
|
|
54
81
|
end
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module StimulusSpec
|
|
4
|
+
# Controls auto-include behavior for RSpec integration.
|
|
4
5
|
class Configuration
|
|
6
|
+
# @return [Boolean] whether matchers are auto-included into RSpec groups (default: +true+)
|
|
5
7
|
attr_accessor :auto_include
|
|
6
8
|
|
|
7
9
|
def initialize
|
|
@@ -2,18 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
module StimulusSpec
|
|
4
4
|
module Matchers
|
|
5
|
+
# Asserts that rendered HTML contains a +data-action+ attribute with the given descriptor.
|
|
6
|
+
#
|
|
7
|
+
# @example Full descriptor
|
|
8
|
+
# expect(response).to have_stimulus_action("click->hello#greet")
|
|
9
|
+
# @example Shorthand (any event)
|
|
10
|
+
# expect(response).to have_stimulus_action("hello#greet")
|
|
5
11
|
class HaveStimulusAction
|
|
12
|
+
# @param descriptor [String] full (+event->controller#method+) or shorthand (+controller#method+) descriptor
|
|
6
13
|
def initialize(descriptor)
|
|
7
14
|
@descriptor = descriptor.to_s
|
|
8
15
|
end
|
|
9
16
|
|
|
17
|
+
# @param selector [String] CSS selector for the scope element
|
|
18
|
+
# @return [self]
|
|
19
|
+
def within(selector)
|
|
20
|
+
@scope = selector
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param subject [#body, String] response object or HTML string
|
|
25
|
+
# @return [Boolean]
|
|
10
26
|
def matches?(subject)
|
|
11
27
|
@body = extract_body(subject)
|
|
12
28
|
@doc = Nokogiri::HTML5.fragment(@body)
|
|
29
|
+
root = search_root
|
|
30
|
+
return false unless root
|
|
31
|
+
|
|
13
32
|
if @descriptor.include?("->")
|
|
14
|
-
|
|
33
|
+
!root.at_css("[data-action~='#{@descriptor}']").nil?
|
|
15
34
|
else
|
|
16
|
-
|
|
35
|
+
!root.at_css("[data-action*='#{@descriptor}']").nil?
|
|
17
36
|
end
|
|
18
37
|
end
|
|
19
38
|
|
|
@@ -43,14 +62,23 @@ module StimulusSpec
|
|
|
43
62
|
subject.respond_to?(:body) ? subject.body : subject.to_s
|
|
44
63
|
end
|
|
45
64
|
|
|
65
|
+
def search_root
|
|
66
|
+
return @doc unless @scope
|
|
67
|
+
|
|
68
|
+
@doc.at_css(@scope)
|
|
69
|
+
end
|
|
70
|
+
|
|
46
71
|
def snippet
|
|
47
|
-
|
|
72
|
+
root = search_root || @doc
|
|
73
|
+
elements = root.css("[data-action]")
|
|
48
74
|
return @body if elements.empty?
|
|
49
75
|
|
|
50
76
|
elements.map(&:to_html).join("\n")
|
|
51
77
|
end
|
|
52
78
|
end
|
|
53
79
|
|
|
80
|
+
# @param descriptor [String] action descriptor
|
|
81
|
+
# @return [HaveStimulusAction]
|
|
54
82
|
def have_stimulus_action(descriptor)
|
|
55
83
|
HaveStimulusAction.new(descriptor)
|
|
56
84
|
end
|
|
@@ -2,7 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
module StimulusSpec
|
|
4
4
|
module Matchers
|
|
5
|
+
# Asserts that rendered HTML contains a +data-{controller}-{name}-class+ attribute.
|
|
6
|
+
#
|
|
7
|
+
# @example Existence check
|
|
8
|
+
# expect(response).to have_stimulus_class("search", "loading")
|
|
9
|
+
# @example Equality check
|
|
10
|
+
# expect(response).to have_stimulus_class("search", "loading", "opacity-50")
|
|
5
11
|
class HaveStimulusClass
|
|
12
|
+
# @param controller [String] controller name
|
|
13
|
+
# @param name [String] class name
|
|
14
|
+
# @param expected [String, nil] expected attribute value (omit for existence check)
|
|
6
15
|
def initialize(controller, name, expected = nil)
|
|
7
16
|
@controller = controller.to_s
|
|
8
17
|
@name = name.to_s
|
|
@@ -10,18 +19,25 @@ module StimulusSpec
|
|
|
10
19
|
@attr = "data-#{@controller}-#{@name}-class"
|
|
11
20
|
end
|
|
12
21
|
|
|
22
|
+
# @param selector [String] CSS selector for the scope element
|
|
23
|
+
# @return [self]
|
|
24
|
+
def within(selector)
|
|
25
|
+
@scope = selector
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param subject [#body, String] response object or HTML string
|
|
30
|
+
# @return [Boolean]
|
|
13
31
|
def matches?(subject)
|
|
14
32
|
@body = extract_body(subject)
|
|
15
33
|
@doc = Nokogiri::HTML5.fragment(@body)
|
|
16
|
-
@element =
|
|
34
|
+
@element = search_root&.at_css("[#{@attr}]")
|
|
17
35
|
return false unless @element
|
|
18
36
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
true
|
|
24
|
-
end
|
|
37
|
+
return true unless @expected
|
|
38
|
+
|
|
39
|
+
@actual = @element[@attr]
|
|
40
|
+
@actual == @expected.to_s
|
|
25
41
|
end
|
|
26
42
|
|
|
27
43
|
def does_not_match?(subject)
|
|
@@ -54,14 +70,25 @@ module StimulusSpec
|
|
|
54
70
|
subject.respond_to?(:body) ? subject.body : subject.to_s
|
|
55
71
|
end
|
|
56
72
|
|
|
73
|
+
def search_root
|
|
74
|
+
return @doc unless @scope
|
|
75
|
+
|
|
76
|
+
@doc.at_css(@scope)
|
|
77
|
+
end
|
|
78
|
+
|
|
57
79
|
def snippet
|
|
58
|
-
|
|
80
|
+
root = search_root || @doc
|
|
81
|
+
elements = root.css("[data-controller]")
|
|
59
82
|
return @body if elements.empty?
|
|
60
83
|
|
|
61
84
|
elements.map(&:to_html).join("\n")
|
|
62
85
|
end
|
|
63
86
|
end
|
|
64
87
|
|
|
88
|
+
# @param controller [String] controller name
|
|
89
|
+
# @param name [String] class name
|
|
90
|
+
# @param expected [String, nil] expected class value
|
|
91
|
+
# @return [HaveStimulusClass]
|
|
65
92
|
def have_stimulus_class(controller, name, expected = nil)
|
|
66
93
|
HaveStimulusClass.new(controller, name, expected)
|
|
67
94
|
end
|
|
@@ -1,17 +1,42 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module StimulusSpec
|
|
4
|
+
# Matchers for request and controller specs (Nokogiri-based HTML parsing).
|
|
4
5
|
module Matchers
|
|
6
|
+
# Asserts that rendered HTML contains a +data-controller+ attribute with the given name(s).
|
|
7
|
+
#
|
|
8
|
+
# @example Single controller
|
|
9
|
+
# expect(response).to have_stimulus_controller("hello")
|
|
10
|
+
# @example Multiple controllers on one element
|
|
11
|
+
# expect(response).to have_stimulus_controller("hello", "clipboard")
|
|
12
|
+
# @example Scoped
|
|
13
|
+
# expect(response).to have_stimulus_controller("search").within(".search-form")
|
|
5
14
|
class HaveStimulusController
|
|
6
|
-
|
|
7
|
-
|
|
15
|
+
# @param names [Array<String>] one or more controller names to match
|
|
16
|
+
def initialize(*names)
|
|
17
|
+
@names = names.map(&:to_s)
|
|
8
18
|
end
|
|
9
19
|
|
|
20
|
+
# Restricts matching to descendants of the given CSS selector.
|
|
21
|
+
#
|
|
22
|
+
# @param selector [String] CSS selector for the scope element
|
|
23
|
+
# @return [self]
|
|
24
|
+
def within(selector)
|
|
25
|
+
@scope = selector
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param subject [#body, String] response object or HTML string
|
|
30
|
+
# @return [Boolean]
|
|
10
31
|
def matches?(subject)
|
|
11
32
|
@body = extract_body(subject)
|
|
12
33
|
@doc = Nokogiri::HTML5.fragment(@body)
|
|
13
|
-
|
|
14
|
-
|
|
34
|
+
root = search_root
|
|
35
|
+
return false unless root
|
|
36
|
+
|
|
37
|
+
@found_controllers = root.css("[data-controller]").flat_map { |el| el["data-controller"].split }
|
|
38
|
+
selector = @names.map { |n| "[data-controller~='#{n}']" }.join
|
|
39
|
+
!root.at_css(selector).nil?
|
|
15
40
|
end
|
|
16
41
|
|
|
17
42
|
def does_not_match?(subject)
|
|
@@ -19,8 +44,9 @@ module StimulusSpec
|
|
|
19
44
|
end
|
|
20
45
|
|
|
21
46
|
def failure_message
|
|
22
|
-
|
|
23
|
-
|
|
47
|
+
label = @names.map { |n| "\"#{n}\"" }.join(", ")
|
|
48
|
+
msg = "expected to find an element with data-controller=#{label}"
|
|
49
|
+
if @found_controllers&.any?
|
|
24
50
|
msg += "\n found controllers: #{@found_controllers.uniq.map { |c| "\"#{c}\"" }.join(", ")}"
|
|
25
51
|
end
|
|
26
52
|
msg += "\n in:\n#{snippet}"
|
|
@@ -28,11 +54,13 @@ module StimulusSpec
|
|
|
28
54
|
end
|
|
29
55
|
|
|
30
56
|
def failure_message_when_negated
|
|
31
|
-
|
|
57
|
+
label = @names.map { |n| "\"#{n}\"" }.join(", ")
|
|
58
|
+
"expected not to find an element with data-controller=#{label} but found one"
|
|
32
59
|
end
|
|
33
60
|
|
|
34
61
|
def description
|
|
35
|
-
|
|
62
|
+
label = @names.map { |n| "\"#{n}\"" }.join(", ")
|
|
63
|
+
"have Stimulus controller #{label}"
|
|
36
64
|
end
|
|
37
65
|
|
|
38
66
|
private
|
|
@@ -41,16 +69,25 @@ module StimulusSpec
|
|
|
41
69
|
subject.respond_to?(:body) ? subject.body : subject.to_s
|
|
42
70
|
end
|
|
43
71
|
|
|
72
|
+
def search_root
|
|
73
|
+
return @doc unless @scope
|
|
74
|
+
|
|
75
|
+
@doc.at_css(@scope)
|
|
76
|
+
end
|
|
77
|
+
|
|
44
78
|
def snippet
|
|
45
|
-
|
|
79
|
+
root = search_root || @doc
|
|
80
|
+
elements = root.css("[data-controller]")
|
|
46
81
|
return @body if elements.empty?
|
|
47
82
|
|
|
48
83
|
elements.map(&:to_html).join("\n")
|
|
49
84
|
end
|
|
50
85
|
end
|
|
51
86
|
|
|
52
|
-
|
|
53
|
-
|
|
87
|
+
# @param names [Array<String>] one or more controller names
|
|
88
|
+
# @return [HaveStimulusController]
|
|
89
|
+
def have_stimulus_controller(*names)
|
|
90
|
+
HaveStimulusController.new(*names)
|
|
54
91
|
end
|
|
55
92
|
end
|
|
56
93
|
end
|
|
@@ -2,7 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
module StimulusSpec
|
|
4
4
|
module Matchers
|
|
5
|
+
# Asserts that rendered HTML contains a +data-{controller}-{outlet}-outlet+ attribute.
|
|
6
|
+
#
|
|
7
|
+
# @example Existence check
|
|
8
|
+
# expect(response).to have_stimulus_outlet("search", "results")
|
|
9
|
+
# @example Selector check
|
|
10
|
+
# expect(response).to have_stimulus_outlet("search", "results", "#results-list")
|
|
5
11
|
class HaveStimulusOutlet
|
|
12
|
+
# @param controller [String] controller name
|
|
13
|
+
# @param outlet [String] outlet name
|
|
14
|
+
# @param selector [String, nil] expected CSS selector value (omit for existence check)
|
|
6
15
|
def initialize(controller, outlet, selector = nil)
|
|
7
16
|
@controller = controller.to_s
|
|
8
17
|
@outlet = outlet.to_s
|
|
@@ -10,18 +19,25 @@ module StimulusSpec
|
|
|
10
19
|
@attr = "data-#{@controller}-#{@outlet}-outlet"
|
|
11
20
|
end
|
|
12
21
|
|
|
22
|
+
# @param selector [String] CSS selector for the scope element
|
|
23
|
+
# @return [self]
|
|
24
|
+
def within(selector)
|
|
25
|
+
@scope = selector
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param subject [#body, String] response object or HTML string
|
|
30
|
+
# @return [Boolean]
|
|
13
31
|
def matches?(subject)
|
|
14
32
|
@body = extract_body(subject)
|
|
15
33
|
@doc = Nokogiri::HTML5.fragment(@body)
|
|
16
|
-
@element =
|
|
34
|
+
@element = search_root&.at_css("[#{@attr}]")
|
|
17
35
|
return false unless @element
|
|
18
36
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
true
|
|
24
|
-
end
|
|
37
|
+
return true unless @selector
|
|
38
|
+
|
|
39
|
+
@actual = @element[@attr]
|
|
40
|
+
@actual == @selector.to_s
|
|
25
41
|
end
|
|
26
42
|
|
|
27
43
|
def does_not_match?(subject)
|
|
@@ -54,14 +70,25 @@ module StimulusSpec
|
|
|
54
70
|
subject.respond_to?(:body) ? subject.body : subject.to_s
|
|
55
71
|
end
|
|
56
72
|
|
|
73
|
+
def search_root
|
|
74
|
+
return @doc unless @scope
|
|
75
|
+
|
|
76
|
+
@doc.at_css(@scope)
|
|
77
|
+
end
|
|
78
|
+
|
|
57
79
|
def snippet
|
|
58
|
-
|
|
80
|
+
root = search_root || @doc
|
|
81
|
+
elements = root.css("[data-controller]")
|
|
59
82
|
return @body if elements.empty?
|
|
60
83
|
|
|
61
84
|
elements.map(&:to_html).join("\n")
|
|
62
85
|
end
|
|
63
86
|
end
|
|
64
87
|
|
|
88
|
+
# @param controller [String] controller name
|
|
89
|
+
# @param outlet [String] outlet name
|
|
90
|
+
# @param selector [String, nil] expected CSS selector value
|
|
91
|
+
# @return [HaveStimulusOutlet]
|
|
65
92
|
def have_stimulus_outlet(controller, outlet, selector = nil)
|
|
66
93
|
HaveStimulusOutlet.new(controller, outlet, selector)
|
|
67
94
|
end
|
|
@@ -2,17 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
module StimulusSpec
|
|
4
4
|
module Matchers
|
|
5
|
+
# Asserts that rendered HTML contains a +data-{controller}-target+ attribute with the given target name.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# expect(response).to have_stimulus_target("hello", "name")
|
|
5
9
|
class HaveStimulusTarget
|
|
10
|
+
# @param controller [String] controller name
|
|
11
|
+
# @param target [String] target name
|
|
6
12
|
def initialize(controller, target)
|
|
7
13
|
@controller = controller.to_s
|
|
8
14
|
@target = target.to_s
|
|
9
15
|
@attr = "data-#{@controller}-target"
|
|
10
16
|
end
|
|
11
17
|
|
|
18
|
+
# @param selector [String] CSS selector for the scope element
|
|
19
|
+
# @return [self]
|
|
20
|
+
def within(selector)
|
|
21
|
+
@scope = selector
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param subject [#body, String] response object or HTML string
|
|
26
|
+
# @return [Boolean]
|
|
12
27
|
def matches?(subject)
|
|
13
28
|
@body = extract_body(subject)
|
|
14
29
|
@doc = Nokogiri::HTML5.fragment(@body)
|
|
15
|
-
|
|
30
|
+
root = search_root
|
|
31
|
+
return false unless root
|
|
32
|
+
|
|
33
|
+
!root.at_css("[#{@attr}~='#{@target}']").nil?
|
|
16
34
|
end
|
|
17
35
|
|
|
18
36
|
def does_not_match?(subject)
|
|
@@ -41,14 +59,24 @@ module StimulusSpec
|
|
|
41
59
|
subject.respond_to?(:body) ? subject.body : subject.to_s
|
|
42
60
|
end
|
|
43
61
|
|
|
62
|
+
def search_root
|
|
63
|
+
return @doc unless @scope
|
|
64
|
+
|
|
65
|
+
@doc.at_css(@scope)
|
|
66
|
+
end
|
|
67
|
+
|
|
44
68
|
def snippet
|
|
45
|
-
|
|
69
|
+
root = search_root || @doc
|
|
70
|
+
elements = root.css("[#{@attr}]")
|
|
46
71
|
return @body if elements.empty?
|
|
47
72
|
|
|
48
73
|
elements.map(&:to_html).join("\n")
|
|
49
74
|
end
|
|
50
75
|
end
|
|
51
76
|
|
|
77
|
+
# @param controller [String] controller name
|
|
78
|
+
# @param target [String] target name
|
|
79
|
+
# @return [HaveStimulusTarget]
|
|
52
80
|
def have_stimulus_target(controller, target)
|
|
53
81
|
HaveStimulusTarget.new(controller, target)
|
|
54
82
|
end
|
|
@@ -2,7 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
module StimulusSpec
|
|
4
4
|
module Matchers
|
|
5
|
+
# Asserts that rendered HTML contains a +data-{controller}-{name}-value+ attribute.
|
|
6
|
+
#
|
|
7
|
+
# @example Existence check
|
|
8
|
+
# expect(response).to have_stimulus_value("search", "url")
|
|
9
|
+
# @example Equality check
|
|
10
|
+
# expect(response).to have_stimulus_value("search", "url", "/results")
|
|
5
11
|
class HaveStimulusValue
|
|
12
|
+
# @param controller [String] controller name
|
|
13
|
+
# @param name [String] value name
|
|
14
|
+
# @param expected [String, nil] expected attribute value (omit for existence check)
|
|
6
15
|
def initialize(controller, name, expected = nil)
|
|
7
16
|
@controller = controller.to_s
|
|
8
17
|
@name = name.to_s
|
|
@@ -10,18 +19,25 @@ module StimulusSpec
|
|
|
10
19
|
@attr = "data-#{@controller}-#{@name}-value"
|
|
11
20
|
end
|
|
12
21
|
|
|
22
|
+
# @param selector [String] CSS selector for the scope element
|
|
23
|
+
# @return [self]
|
|
24
|
+
def within(selector)
|
|
25
|
+
@scope = selector
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param subject [#body, String] response object or HTML string
|
|
30
|
+
# @return [Boolean]
|
|
13
31
|
def matches?(subject)
|
|
14
32
|
@body = extract_body(subject)
|
|
15
33
|
@doc = Nokogiri::HTML5.fragment(@body)
|
|
16
|
-
@element =
|
|
34
|
+
@element = search_root&.at_css("[#{@attr}]")
|
|
17
35
|
return false unless @element
|
|
18
36
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
true
|
|
24
|
-
end
|
|
37
|
+
return true unless @expected
|
|
38
|
+
|
|
39
|
+
@actual = @element[@attr]
|
|
40
|
+
@actual == @expected.to_s
|
|
25
41
|
end
|
|
26
42
|
|
|
27
43
|
def does_not_match?(subject)
|
|
@@ -54,14 +70,25 @@ module StimulusSpec
|
|
|
54
70
|
subject.respond_to?(:body) ? subject.body : subject.to_s
|
|
55
71
|
end
|
|
56
72
|
|
|
73
|
+
def search_root
|
|
74
|
+
return @doc unless @scope
|
|
75
|
+
|
|
76
|
+
@doc.at_css(@scope)
|
|
77
|
+
end
|
|
78
|
+
|
|
57
79
|
def snippet
|
|
58
|
-
|
|
80
|
+
root = search_root || @doc
|
|
81
|
+
elements = root.css("[data-controller]")
|
|
59
82
|
return @body if elements.empty?
|
|
60
83
|
|
|
61
84
|
elements.map(&:to_html).join("\n")
|
|
62
85
|
end
|
|
63
86
|
end
|
|
64
87
|
|
|
88
|
+
# @param controller [String] controller name
|
|
89
|
+
# @param name [String] value name
|
|
90
|
+
# @param expected [String, nil] expected value
|
|
91
|
+
# @return [HaveStimulusValue]
|
|
65
92
|
def have_stimulus_value(controller, name, expected = nil)
|
|
66
93
|
HaveStimulusValue.new(controller, name, expected)
|
|
67
94
|
end
|
data/lib/stimulus_spec.rb
CHANGED
|
@@ -5,21 +5,46 @@ require_relative "stimulus_spec/configuration"
|
|
|
5
5
|
require_relative "stimulus_spec/matchers"
|
|
6
6
|
require_relative "stimulus_spec/capybara/matchers"
|
|
7
7
|
|
|
8
|
+
# RSpec matchers for testing Stimulus controller wiring in Rails applications.
|
|
9
|
+
#
|
|
10
|
+
# Provides matchers for asserting +data-controller+, +data-action+,
|
|
11
|
+
# +data-*-target+, +data-*-*-value+, +data-*-*-class+, and
|
|
12
|
+
# +data-*-*-outlet+ attributes in rendered HTML and Capybara pages.
|
|
13
|
+
#
|
|
14
|
+
# @example Auto-included in Rails with stimulus-rails
|
|
15
|
+
# RSpec.describe "Search", type: :request do
|
|
16
|
+
# it "wires up the controller" do
|
|
17
|
+
# get search_path
|
|
18
|
+
# expect(response).to have_stimulus_controller("search")
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
8
21
|
module StimulusSpec
|
|
9
22
|
class Error < StandardError; end
|
|
10
23
|
|
|
24
|
+
# @return [Configuration] the current configuration instance
|
|
11
25
|
def self.configuration
|
|
12
26
|
@configuration ||= Configuration.new
|
|
13
27
|
end
|
|
14
28
|
|
|
29
|
+
# Yields the configuration for modification.
|
|
30
|
+
#
|
|
31
|
+
# @yieldparam config [Configuration]
|
|
32
|
+
# @return [void]
|
|
15
33
|
def self.configure
|
|
16
34
|
yield configuration
|
|
17
35
|
end
|
|
18
36
|
|
|
37
|
+
# Replaces the current configuration with a fresh instance.
|
|
38
|
+
#
|
|
39
|
+
# @return [Configuration] the new configuration
|
|
19
40
|
def self.reset_configuration!
|
|
20
41
|
@configuration = Configuration.new
|
|
21
42
|
end
|
|
22
43
|
|
|
44
|
+
# Includes matchers into RSpec example groups when stimulus-rails is present.
|
|
45
|
+
#
|
|
46
|
+
# @param config [RSpec::Core::Configuration]
|
|
47
|
+
# @return [void]
|
|
23
48
|
def self.install_rspec_integration(config)
|
|
24
49
|
return unless Gem.loaded_specs.key?("stimulus-rails")
|
|
25
50
|
return unless configuration.auto_include
|
data/sig/stimulus_spec.rbs
CHANGED
|
@@ -13,7 +13,7 @@ module StimulusSpec
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
module Matchers
|
|
16
|
-
def have_stimulus_controller: (String
|
|
16
|
+
def have_stimulus_controller: (*String names) -> HaveStimulusController
|
|
17
17
|
def have_stimulus_action: (String descriptor) -> HaveStimulusAction
|
|
18
18
|
def have_stimulus_target: (String controller, String target) -> HaveStimulusTarget
|
|
19
19
|
def have_stimulus_value: (String controller, String name, ?String? expected) -> HaveStimulusValue
|
|
@@ -21,7 +21,8 @@ module StimulusSpec
|
|
|
21
21
|
def have_stimulus_outlet: (String controller, String outlet, ?String? selector) -> HaveStimulusOutlet
|
|
22
22
|
|
|
23
23
|
class HaveStimulusController
|
|
24
|
-
def initialize: (String
|
|
24
|
+
def initialize: (*String names) -> void
|
|
25
|
+
def within: (String selector) -> self
|
|
25
26
|
def matches?: (untyped subject) -> bool
|
|
26
27
|
def does_not_match?: (untyped subject) -> bool
|
|
27
28
|
def failure_message: () -> String
|
|
@@ -31,6 +32,7 @@ module StimulusSpec
|
|
|
31
32
|
|
|
32
33
|
class HaveStimulusAction
|
|
33
34
|
def initialize: (String descriptor) -> void
|
|
35
|
+
def within: (String selector) -> self
|
|
34
36
|
def matches?: (untyped subject) -> bool
|
|
35
37
|
def does_not_match?: (untyped subject) -> bool
|
|
36
38
|
def failure_message: () -> String
|
|
@@ -40,6 +42,7 @@ module StimulusSpec
|
|
|
40
42
|
|
|
41
43
|
class HaveStimulusTarget
|
|
42
44
|
def initialize: (String controller, String target) -> void
|
|
45
|
+
def within: (String selector) -> self
|
|
43
46
|
def matches?: (untyped subject) -> bool
|
|
44
47
|
def does_not_match?: (untyped subject) -> bool
|
|
45
48
|
def failure_message: () -> String
|
|
@@ -49,6 +52,7 @@ module StimulusSpec
|
|
|
49
52
|
|
|
50
53
|
class HaveStimulusValue
|
|
51
54
|
def initialize: (String controller, String name, ?String? expected) -> void
|
|
55
|
+
def within: (String selector) -> self
|
|
52
56
|
def matches?: (untyped subject) -> bool
|
|
53
57
|
def does_not_match?: (untyped subject) -> bool
|
|
54
58
|
def failure_message: () -> String
|
|
@@ -58,6 +62,7 @@ module StimulusSpec
|
|
|
58
62
|
|
|
59
63
|
class HaveStimulusClass
|
|
60
64
|
def initialize: (String controller, String name, ?String? expected) -> void
|
|
65
|
+
def within: (String selector) -> self
|
|
61
66
|
def matches?: (untyped subject) -> bool
|
|
62
67
|
def does_not_match?: (untyped subject) -> bool
|
|
63
68
|
def failure_message: () -> String
|
|
@@ -67,6 +72,7 @@ module StimulusSpec
|
|
|
67
72
|
|
|
68
73
|
class HaveStimulusOutlet
|
|
69
74
|
def initialize: (String controller, String outlet, ?String? selector) -> void
|
|
75
|
+
def within: (String selector) -> self
|
|
70
76
|
def matches?: (untyped subject) -> bool
|
|
71
77
|
def does_not_match?: (untyped subject) -> bool
|
|
72
78
|
def failure_message: () -> String
|
|
@@ -77,7 +83,7 @@ module StimulusSpec
|
|
|
77
83
|
|
|
78
84
|
module Capybara
|
|
79
85
|
module Matchers
|
|
80
|
-
def have_stimulus_controller: (String
|
|
86
|
+
def have_stimulus_controller: (*String names) -> HaveStimulusController
|
|
81
87
|
def have_stimulus_action: (String descriptor) -> HaveStimulusAction
|
|
82
88
|
def have_stimulus_target: (String controller, String target) -> HaveStimulusTarget
|
|
83
89
|
def have_stimulus_value: (String controller, String name, ?String? expected) -> HaveStimulusValue
|
|
@@ -85,7 +91,8 @@ module StimulusSpec
|
|
|
85
91
|
def have_stimulus_outlet: (String controller, String outlet, ?String? selector) -> HaveStimulusOutlet
|
|
86
92
|
|
|
87
93
|
class HaveStimulusController
|
|
88
|
-
def initialize: (String
|
|
94
|
+
def initialize: (*String names) -> void
|
|
95
|
+
def within: (String selector) -> self
|
|
89
96
|
def matches?: (untyped page) -> bool
|
|
90
97
|
def does_not_match?: (untyped page) -> bool
|
|
91
98
|
def failure_message: () -> String
|
|
@@ -95,6 +102,7 @@ module StimulusSpec
|
|
|
95
102
|
|
|
96
103
|
class HaveStimulusAction
|
|
97
104
|
def initialize: (String descriptor) -> void
|
|
105
|
+
def within: (String selector) -> self
|
|
98
106
|
def matches?: (untyped page) -> bool
|
|
99
107
|
def does_not_match?: (untyped page) -> bool
|
|
100
108
|
def failure_message: () -> String
|
|
@@ -104,6 +112,7 @@ module StimulusSpec
|
|
|
104
112
|
|
|
105
113
|
class HaveStimulusTarget
|
|
106
114
|
def initialize: (String controller, String target) -> void
|
|
115
|
+
def within: (String selector) -> self
|
|
107
116
|
def matches?: (untyped page) -> bool
|
|
108
117
|
def does_not_match?: (untyped page) -> bool
|
|
109
118
|
def failure_message: () -> String
|
|
@@ -113,6 +122,7 @@ module StimulusSpec
|
|
|
113
122
|
|
|
114
123
|
class HaveStimulusValue
|
|
115
124
|
def initialize: (String controller, String name, ?String? expected) -> void
|
|
125
|
+
def within: (String selector) -> self
|
|
116
126
|
def matches?: (untyped page) -> bool
|
|
117
127
|
def does_not_match?: (untyped page) -> bool
|
|
118
128
|
def failure_message: () -> String
|
|
@@ -122,6 +132,7 @@ module StimulusSpec
|
|
|
122
132
|
|
|
123
133
|
class HaveStimulusClass
|
|
124
134
|
def initialize: (String controller, String name, ?String? expected) -> void
|
|
135
|
+
def within: (String selector) -> self
|
|
125
136
|
def matches?: (untyped page) -> bool
|
|
126
137
|
def does_not_match?: (untyped page) -> bool
|
|
127
138
|
def failure_message: () -> String
|
|
@@ -131,6 +142,7 @@ module StimulusSpec
|
|
|
131
142
|
|
|
132
143
|
class HaveStimulusOutlet
|
|
133
144
|
def initialize: (String controller, String outlet, ?String? selector) -> void
|
|
145
|
+
def within: (String selector) -> self
|
|
134
146
|
def matches?: (untyped page) -> bool
|
|
135
147
|
def does_not_match?: (untyped page) -> bool
|
|
136
148
|
def failure_message: () -> String
|
|
@@ -139,4 +151,4 @@ module StimulusSpec
|
|
|
139
151
|
end
|
|
140
152
|
end
|
|
141
153
|
end
|
|
142
|
-
end
|
|
154
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: stimulus_spec
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chuck Smith
|
|
@@ -36,6 +36,7 @@ files:
|
|
|
36
36
|
- ".github/FUNDING.yml"
|
|
37
37
|
- ".github/workflows/main.yml"
|
|
38
38
|
- ".github/workflows/publish.yml"
|
|
39
|
+
- ".yardopts"
|
|
39
40
|
- CHANGELOG.md
|
|
40
41
|
- CONTRIBUTING.md
|
|
41
42
|
- LICENSE.txt
|
|
@@ -43,6 +44,7 @@ files:
|
|
|
43
44
|
- ROADMAP.md
|
|
44
45
|
- Rakefile
|
|
45
46
|
- codecov.yml
|
|
47
|
+
- docs/cookbook.md
|
|
46
48
|
- lib/stimulus_spec.rb
|
|
47
49
|
- lib/stimulus_spec/capybara/matchers.rb
|
|
48
50
|
- lib/stimulus_spec/capybara/matchers/have_stimulus_action.rb
|