stimulus_spec 0.6.0 → 0.8.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: 016bc15660c32251a12c1c9129fa88c86a3e2ac1059949535a67cdd5d64448ce
4
- data.tar.gz: 76277d4b548369259e6ae9f28b8ad2b4d22637659f7c13a938e5cf2fd95127e5
3
+ metadata.gz: 6b9e26e1184c0d81c9ebcdffd070ab00c4822f76de661fc229178e2989497588
4
+ data.tar.gz: 91e8c98384c15baba42e7bb5022a47eb14756a236ea6f2b340cf85ecc9bb4db7
5
5
  SHA512:
6
- metadata.gz: 8502635e782f3c870ee6aa638e09cba1bbc054c731d1c4599e5b23a31551bd0fb5dce9fc71eaa3d2ca8d3713b2ab8ba26591749a59b9bcd51bb09d871d11a2cc
7
- data.tar.gz: 300a7bc9351bb8d0dbcc3f70122094d03705fd876322f36187c587daa0bf44446477a57df6e2130252d3501577b1e410bd1510836e70c97729b430b7dd5a6f33
6
+ metadata.gz: 3bbf987dbddff7265d933f041e11d2e1223bfd923d855c8836721cc3b5379534b8100e099d7e3952cfba280d26feee50f07000aae6910ccf2c3c86c9cc71eac9
7
+ data.tar.gz: 2d9372a0337c758b3f41eee17f6b650b285bc38e109b972017b41befdc2462f9bd0a9ac1adbcf8be1da2e7615c1cdfd1c1bf9c3b0ab3e017b4156ec84a5bbc54
data/.yardopts ADDED
@@ -0,0 +1,8 @@
1
+ --markup markdown
2
+ --output-dir doc
3
+ --protected
4
+ --no-private
5
+ lib/**/*.rb
6
+ -
7
+ CHANGELOG.md
8
+ LICENSE.txt
data/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.0] - 2026-06-23
11
+
12
+ ### Added
13
+
14
+ - `bin/benchmark` script for measuring matcher overhead
15
+ - Version spec enforcing semver format on `StimulusSpec::VERSION`
16
+ - `docs/migration_guide.md` for migrating from turbo_rspec's Stimulus matchers
17
+ - Guiding principles section in README
18
+
19
+ ## [0.7.0] - 2026-06-23
20
+
21
+ ### Added
22
+
23
+ - Full YARD documentation (`@param`, `@return`, `@example`) on all public methods and classes
24
+ - `docs/cookbook.md` with common patterns: request specs, system specs, multi-controller, scoped matching, typed values, outlets
25
+ - `.yardopts` configuration file
26
+
10
27
  ## [0.6.0] - 2026-06-23
11
28
 
12
29
  ### Added
@@ -53,7 +70,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
53
70
  - `have_stimulus_action(descriptor)` matcher — full descriptor (`~=`) and shorthand without event (`*=`)
54
71
  - `have_stimulus_target(controller, target)` matcher — asserts `[data-{controller}-target~="target"]`
55
72
 
56
- [Unreleased]: https://github.com/eclectic-coding/stimulus_spec/compare/v0.6.0...HEAD
73
+ [Unreleased]: https://github.com/eclectic-coding/stimulus_spec/compare/v0.8.0...HEAD
74
+ [0.8.0]: https://github.com/eclectic-coding/stimulus_spec/releases/tag/v0.8.0
75
+ [0.7.0]: https://github.com/eclectic-coding/stimulus_spec/releases/tag/v0.7.0
57
76
  [0.6.0]: https://github.com/eclectic-coding/stimulus_spec/releases/tag/v0.6.0
58
77
  [0.5.0]: https://github.com/eclectic-coding/stimulus_spec/releases/tag/v0.5.0
59
78
  [0.4.0]: https://github.com/eclectic-coding/stimulus_spec/releases/tag/v0.4.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)
data/ROADMAP.md CHANGED
@@ -1,27 +1,3 @@
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.7.0 — Documentation and Polish
15
-
16
- - Full YARD documentation on all public methods and classes
17
- - `docs/cookbook.md` — common patterns: request specs, system specs, multi-controller elements, typed values, outlets
18
- - Graceful no-op when `stimulus-rails` is not bundled (no `LoadError`)
19
-
20
- ---
21
-
22
- ## 1.0.0 — Stable API
23
-
24
- - API freeze — public method signatures are part of the semver contract
25
- - `bin/benchmark` — matcher overhead measurement
26
- - Version spec (semver format enforced)
27
- - `docs/migration_guide.md` — migrating from turbo_rspec's Stimulus matchers to stimulus_spec
3
+ All planned milestones through 0.8.0 have been completed. Gathering user feedback before a 1.0.0 release.
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
+ ```
@@ -0,0 +1,84 @@
1
+ # Migrating from turbo_rspec to stimulus_spec
2
+
3
+ [turbo_rspec](https://github.com/eclectic-coding/turbo_rspec) includes basic Stimulus matchers: `have_stimulus_controller`, `have_stimulus_action`, and `have_stimulus_target`. **stimulus_spec** provides the same three matchers plus `have_stimulus_value`, `have_stimulus_class`, `have_stimulus_outlet`, multi-controller assertions, and scoped matching.
4
+
5
+ Both gems can coexist — they use separate namespaces (`TurboRspec::Matchers` vs `StimulusSpec::Matchers`) and won't conflict. You can migrate incrementally.
6
+
7
+ ## Step 1: Add stimulus_spec
8
+
9
+ ```ruby
10
+ # Gemfile
11
+ group :test do
12
+ gem "stimulus_spec"
13
+ end
14
+ ```
15
+
16
+ ```bash
17
+ bundle install
18
+ ```
19
+
20
+ With `stimulus-rails` in your bundle, `stimulus_spec` auto-includes its matchers into request, controller, system, and feature specs. No configuration needed.
21
+
22
+ ## Step 2: Verify existing tests pass
23
+
24
+ Your existing turbo_rspec Stimulus matchers (`TurboRspec::Matchers`) will continue to work alongside `StimulusSpec::Matchers`. Run your test suite to confirm nothing breaks:
25
+
26
+ ```bash
27
+ bundle exec rspec
28
+ ```
29
+
30
+ ## Step 3: Switch namespaces (optional)
31
+
32
+ If you manually include turbo_rspec's Stimulus matchers, update to the stimulus_spec equivalents:
33
+
34
+ ```ruby
35
+ # Before
36
+ config.include TurboRspec::Matchers, type: :request
37
+
38
+ # After
39
+ config.include StimulusSpec::Matchers, type: :request
40
+ config.include StimulusSpec::Capybara::Matchers, type: :system
41
+ ```
42
+
43
+ If you rely on auto-include (the default for both gems), this step is unnecessary — both sets of matchers are already available.
44
+
45
+ ## Step 4: Use new matchers
46
+
47
+ With stimulus_spec installed, you now have access to matchers that turbo_rspec doesn't provide:
48
+
49
+ ```ruby
50
+ # Value assertions
51
+ expect(response).to have_stimulus_value("search", "url", "/results")
52
+
53
+ # CSS class assertions
54
+ expect(response).to have_stimulus_class("search", "loading", "opacity-50")
55
+
56
+ # Outlet assertions
57
+ expect(response).to have_stimulus_outlet("search", "results", "#results-list")
58
+
59
+ # Multi-controller on a single element
60
+ expect(response).to have_stimulus_controller("hello", "clipboard")
61
+
62
+ # Scoped matching
63
+ expect(response).to have_stimulus_controller("search").within(".search-form")
64
+ ```
65
+
66
+ ## Matcher comparison
67
+
68
+ | Matcher | turbo_rspec | stimulus_spec |
69
+ |---|---|---|
70
+ | `have_stimulus_controller` | Single name | Single or multiple names |
71
+ | `have_stimulus_action` | Full or shorthand | Full or shorthand |
72
+ | `have_stimulus_target` | Controller + target | Controller + target |
73
+ | `have_stimulus_value` | — | Existence or equality |
74
+ | `have_stimulus_class` | — | Existence or equality |
75
+ | `have_stimulus_outlet` | — | Existence or selector |
76
+ | `.within(selector)` | — | All matchers |
77
+ | Capybara variants | Controller, action, target | All six matchers |
78
+ | Failure messages | Basic | Shows found values and HTML context |
79
+
80
+ ## Step 5: Remove turbo_rspec Stimulus matchers (optional)
81
+
82
+ If you want to fully consolidate, you can stop including turbo_rspec's Stimulus matchers. turbo_rspec's Turbo-specific matchers (`have_turbo_frame`, `have_turbo_stream`, etc.) are unaffected — only the Stimulus matchers overlap.
83
+
84
+ If you use turbo_rspec solely for Turbo matchers, no changes are needed. Both gems coexist cleanly.
@@ -3,16 +3,25 @@
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]
11
18
  def within(selector)
12
19
  @scope = selector
13
20
  self
14
21
  end
15
22
 
23
+ # @param page [Capybara::Session, Capybara::Node::Simple]
24
+ # @return [Boolean]
16
25
  def matches?(page)
17
26
  @page = page
18
27
  page.has_css?(scoped_selector, wait: 0)
@@ -46,6 +55,8 @@ module StimulusSpec
46
55
  end
47
56
  end
48
57
 
58
+ # @param descriptor [String] action descriptor
59
+ # @return [HaveStimulusAction]
49
60
  def have_stimulus_action(descriptor)
50
61
  HaveStimulusAction.new(descriptor)
51
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,11 +18,15 @@ 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]
14
23
  def within(selector)
15
24
  @scope = selector
16
25
  self
17
26
  end
18
27
 
28
+ # @param page [Capybara::Session, Capybara::Node::Simple]
29
+ # @return [Boolean]
19
30
  def matches?(page)
20
31
  @page = page
21
32
  element = page.first(scoped_selector, minimum: 0, wait: 0)
@@ -61,6 +72,10 @@ module StimulusSpec
61
72
  end
62
73
  end
63
74
 
75
+ # @param controller [String] controller name
76
+ # @param name [String] class name
77
+ # @param expected [String, nil] expected class value
78
+ # @return [HaveStimulusClass]
64
79
  def have_stimulus_class(controller, name, expected = nil)
65
80
  HaveStimulusClass.new(controller, name, expected)
66
81
  end
@@ -3,16 +3,27 @@
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
13
+ # @param names [Array<String>] one or more controller names
7
14
  def initialize(*names)
8
15
  @names = names.map(&:to_s)
9
16
  end
10
17
 
18
+ # @param selector [String] CSS selector for the scope element
19
+ # @return [self]
11
20
  def within(selector)
12
21
  @scope = selector
13
22
  self
14
23
  end
15
24
 
25
+ # @param page [Capybara::Session, Capybara::Node::Simple] Capybara page object
26
+ # @return [Boolean]
16
27
  def matches?(page)
17
28
  @page = page
18
29
  page.has_css?(scoped_selector, wait: 0)
@@ -46,6 +57,8 @@ module StimulusSpec
46
57
  end
47
58
  end
48
59
 
60
+ # @param names [Array<String>] one or more controller names
61
+ # @return [HaveStimulusController]
49
62
  def have_stimulus_controller(*names)
50
63
  HaveStimulusController.new(*names)
51
64
  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,11 +18,15 @@ 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]
14
23
  def within(selector)
15
24
  @scope = selector
16
25
  self
17
26
  end
18
27
 
28
+ # @param page [Capybara::Session, Capybara::Node::Simple]
29
+ # @return [Boolean]
19
30
  def matches?(page)
20
31
  @page = page
21
32
  element = page.first(scoped_selector, minimum: 0, wait: 0)
@@ -61,6 +72,10 @@ module StimulusSpec
61
72
  end
62
73
  end
63
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]
64
79
  def have_stimulus_outlet(controller, outlet, selector = nil)
65
80
  HaveStimulusOutlet.new(controller, outlet, selector)
66
81
  end
@@ -3,17 +3,27 @@
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]
12
20
  def within(selector)
13
21
  @scope = selector
14
22
  self
15
23
  end
16
24
 
25
+ # @param page [Capybara::Session, Capybara::Node::Simple]
26
+ # @return [Boolean]
17
27
  def matches?(page)
18
28
  @page = page
19
29
  page.has_css?(scoped_selector, wait: 0)
@@ -43,6 +53,9 @@ module StimulusSpec
43
53
  end
44
54
  end
45
55
 
56
+ # @param controller [String] controller name
57
+ # @param target [String] target name
58
+ # @return [HaveStimulusTarget]
46
59
  def have_stimulus_target(controller, target)
47
60
  HaveStimulusTarget.new(controller, target)
48
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,11 +18,15 @@ 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]
14
23
  def within(selector)
15
24
  @scope = selector
16
25
  self
17
26
  end
18
27
 
28
+ # @param page [Capybara::Session, Capybara::Node::Simple]
29
+ # @return [Boolean]
19
30
  def matches?(page)
20
31
  @page = page
21
32
  element = page.first(scoped_selector, minimum: 0, wait: 0)
@@ -61,6 +72,10 @@ module StimulusSpec
61
72
  end
62
73
  end
63
74
 
75
+ # @param controller [String] controller name
76
+ # @param name [String] value name
77
+ # @param expected [String, nil] expected value
78
+ # @return [HaveStimulusValue]
64
79
  def have_stimulus_value(controller, name, expected = nil)
65
80
  HaveStimulusValue.new(controller, name, expected)
66
81
  end
@@ -9,6 +9,7 @@ require_relative "matchers/have_stimulus_outlet"
9
9
 
10
10
  module StimulusSpec
11
11
  module Capybara
12
+ # Matchers for system and feature specs (Capybara page objects).
12
13
  module Matchers
13
14
  end
14
15
  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,16 +2,27 @@
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]
10
19
  def within(selector)
11
20
  @scope = selector
12
21
  self
13
22
  end
14
23
 
24
+ # @param subject [#body, String] response object or HTML string
25
+ # @return [Boolean]
15
26
  def matches?(subject)
16
27
  @body = extract_body(subject)
17
28
  @doc = Nokogiri::HTML5.fragment(@body)
@@ -66,6 +77,8 @@ module StimulusSpec
66
77
  end
67
78
  end
68
79
 
80
+ # @param descriptor [String] action descriptor
81
+ # @return [HaveStimulusAction]
69
82
  def have_stimulus_action(descriptor)
70
83
  HaveStimulusAction.new(descriptor)
71
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,11 +19,15 @@ 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]
13
24
  def within(selector)
14
25
  @scope = selector
15
26
  self
16
27
  end
17
28
 
29
+ # @param subject [#body, String] response object or HTML string
30
+ # @return [Boolean]
18
31
  def matches?(subject)
19
32
  @body = extract_body(subject)
20
33
  @doc = Nokogiri::HTML5.fragment(@body)
@@ -72,6 +85,10 @@ module StimulusSpec
72
85
  end
73
86
  end
74
87
 
88
+ # @param controller [String] controller name
89
+ # @param name [String] class name
90
+ # @param expected [String, nil] expected class value
91
+ # @return [HaveStimulusClass]
75
92
  def have_stimulus_class(controller, name, expected = nil)
76
93
  HaveStimulusClass.new(controller, name, expected)
77
94
  end
@@ -1,17 +1,33 @@
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
15
+ # @param names [Array<String>] one or more controller names to match
6
16
  def initialize(*names)
7
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]
10
24
  def within(selector)
11
25
  @scope = selector
12
26
  self
13
27
  end
14
28
 
29
+ # @param subject [#body, String] response object or HTML string
30
+ # @return [Boolean]
15
31
  def matches?(subject)
16
32
  @body = extract_body(subject)
17
33
  @doc = Nokogiri::HTML5.fragment(@body)
@@ -68,6 +84,8 @@ module StimulusSpec
68
84
  end
69
85
  end
70
86
 
87
+ # @param names [Array<String>] one or more controller names
88
+ # @return [HaveStimulusController]
71
89
  def have_stimulus_controller(*names)
72
90
  HaveStimulusController.new(*names)
73
91
  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,11 +19,15 @@ 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]
13
24
  def within(selector)
14
25
  @scope = selector
15
26
  self
16
27
  end
17
28
 
29
+ # @param subject [#body, String] response object or HTML string
30
+ # @return [Boolean]
18
31
  def matches?(subject)
19
32
  @body = extract_body(subject)
20
33
  @doc = Nokogiri::HTML5.fragment(@body)
@@ -72,6 +85,10 @@ module StimulusSpec
72
85
  end
73
86
  end
74
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]
75
92
  def have_stimulus_outlet(controller, outlet, selector = nil)
76
93
  HaveStimulusOutlet.new(controller, outlet, selector)
77
94
  end
@@ -2,18 +2,28 @@
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]
12
20
  def within(selector)
13
21
  @scope = selector
14
22
  self
15
23
  end
16
24
 
25
+ # @param subject [#body, String] response object or HTML string
26
+ # @return [Boolean]
17
27
  def matches?(subject)
18
28
  @body = extract_body(subject)
19
29
  @doc = Nokogiri::HTML5.fragment(@body)
@@ -64,6 +74,9 @@ module StimulusSpec
64
74
  end
65
75
  end
66
76
 
77
+ # @param controller [String] controller name
78
+ # @param target [String] target name
79
+ # @return [HaveStimulusTarget]
67
80
  def have_stimulus_target(controller, target)
68
81
  HaveStimulusTarget.new(controller, target)
69
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,11 +19,15 @@ 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]
13
24
  def within(selector)
14
25
  @scope = selector
15
26
  self
16
27
  end
17
28
 
29
+ # @param subject [#body, String] response object or HTML string
30
+ # @return [Boolean]
18
31
  def matches?(subject)
19
32
  @body = extract_body(subject)
20
33
  @doc = Nokogiri::HTML5.fragment(@body)
@@ -72,6 +85,10 @@ module StimulusSpec
72
85
  end
73
86
  end
74
87
 
88
+ # @param controller [String] controller name
89
+ # @param name [String] value name
90
+ # @param expected [String, nil] expected value
91
+ # @return [HaveStimulusValue]
75
92
  def have_stimulus_value(controller, name, expected = nil)
76
93
  HaveStimulusValue.new(controller, name, expected)
77
94
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StimulusSpec
4
- VERSION = "0.6.0"
4
+ VERSION = "0.8.0"
5
5
  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
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.6.0
4
+ version: 0.8.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,8 @@ files:
43
44
  - ROADMAP.md
44
45
  - Rakefile
45
46
  - codecov.yml
47
+ - docs/cookbook.md
48
+ - docs/migration_guide.md
46
49
  - lib/stimulus_spec.rb
47
50
  - lib/stimulus_spec/capybara/matchers.rb
48
51
  - lib/stimulus_spec/capybara/matchers/have_stimulus_action.rb