turbo_rspec 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ba06cebbff8c0ae4b76e280171ccd5ae57d06fe8e027010071f9fd9c923696d2
4
+ data.tar.gz: 1838772f6e8673a0949e3e19ccdadd1f7706b6cdd02b77a1dc272bb7c3524f58
5
+ SHA512:
6
+ metadata.gz: f7e2693b3fdd61b62e66e248b9a4dbf16ff52096e4084dc24c3dd56c6dfabfed222f458ef1df188f81378d00815e0c1733399fccddb2520a345c9d0e367d6ed5
7
+ data.tar.gz: 2dbd1d94ff557eb93b7f98882a80a03dbc7c43eaece27fdeef5755c7671e179999aafd762a9a8c32ef1ab4c8708291f0bcef8825b860dfcb2d43c75a8b98e10a
@@ -0,0 +1,57 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ env:
9
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
10
+
11
+ jobs:
12
+ lint:
13
+ name: Lint
14
+ runs-on: ubuntu-latest
15
+
16
+ steps:
17
+ - uses: actions/checkout@v6
18
+
19
+ - uses: ruby/setup-ruby@v1
20
+ with:
21
+ ruby-version: "3.4"
22
+ bundler-cache: true
23
+
24
+ - run: bundle exec standardrb --no-fix
25
+
26
+ audit:
27
+ name: Security Audit
28
+ runs-on: ubuntu-latest
29
+
30
+ steps:
31
+ - uses: actions/checkout@v6
32
+
33
+ - uses: ruby/setup-ruby@v1
34
+ with:
35
+ ruby-version: "3.4"
36
+ bundler-cache: true
37
+
38
+ - run: bundle exec bundle-audit update
39
+ - run: bundle exec bundle-audit check
40
+
41
+ test:
42
+ name: Ruby ${{ matrix.ruby }}
43
+ runs-on: ubuntu-latest
44
+ strategy:
45
+ fail-fast: false
46
+ matrix:
47
+ ruby: ["3.3", "3.4", "4.0"]
48
+
49
+ steps:
50
+ - uses: actions/checkout@v6
51
+
52
+ - uses: ruby/setup-ruby@v1
53
+ with:
54
+ ruby-version: ${{ matrix.ruby }}
55
+ bundler-cache: true
56
+
57
+ - run: bundle exec rspec
@@ -0,0 +1,37 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ env:
9
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
10
+
11
+ jobs:
12
+ publish:
13
+ name: Publish to RubyGems
14
+ runs-on: ubuntu-latest
15
+ permissions:
16
+ contents: write
17
+ id-token: write
18
+
19
+ steps:
20
+ - uses: actions/checkout@v6
21
+
22
+ - uses: ruby/setup-ruby@v1
23
+ with:
24
+ ruby-version: "3.4"
25
+ bundler-cache: true
26
+
27
+ - name: Create GitHub Release
28
+ env:
29
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
30
+ run: |
31
+ gh release create "${{ github.ref_name }}" \
32
+ --title "${{ github.ref_name }}" \
33
+ --notes "See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details." \
34
+ --verify-tag
35
+
36
+ - name: Publish to RubyGems
37
+ uses: rubygems/release-gem@v1
data/.rubocop.yml ADDED
@@ -0,0 +1,18 @@
1
+ # Omakase Ruby styling for Rails
2
+ inherit_gem: { rubocop-rails-omakase: rubocop.yml }
3
+
4
+ AllCops:
5
+ Exclude:
6
+ - "bin/release"
7
+ - "README.md"
8
+ - "spec/dummy/config/database.yml"
9
+
10
+ # Overwrite or add rules to create your own house style
11
+ #
12
+ # # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`
13
+ # Layout/SpaceInsideArrayLiteralBrackets:
14
+ # Enabled: false
15
+
16
+ Layout/SpaceInsideArrayLiteralBrackets:
17
+ Enabled: true
18
+ EnforcedStyle: no_space
data/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-05-28
4
+
5
+ ### Added
6
+
7
+ - Real usage README covering both matchers, setup, and configuration
8
+ - `TurboRspec.configure` block for opt-in configuration
9
+ - Auto-include `TurboRspec::Matchers` into `RSpec::Rails` request example groups when `turbo-rails` is present; disable with `TurboRspec.configure { |c| c.auto_include = false }`
10
+ - Explicit `include TurboRspec::Matchers` supported in any non-Rails or custom context
11
+ - `have_turbo_frame` matcher for asserting `<turbo-frame>` elements in response bodies
12
+ - `.with_id(id)` — assert a specific `id` attribute
13
+ - `.with_content(text)` — assert literal text content inside the frame element
14
+ - `.rendering(partial)` — assert a rendered partial within the frame element
15
+ - Negation via `not_to have_turbo_frame` works out of the box
16
+ - `have_turbo_stream` matcher for asserting `<turbo-stream>` elements in response bodies
17
+ - `.with_action(action)` — assert a specific action (append, prepend, replace, update, remove, before, after, refresh)
18
+ - `.targeting(dom_id)` — assert a specific `target` attribute
19
+ - `.targeting_all(selector)` — assert a specific `targets` CSS selector attribute
20
+ - `.with_content(text)` — assert literal text content inside the stream element
21
+ - `.rendering(partial)` — assert a rendered partial within the stream element
22
+ - Negation via `not_to have_turbo_stream` works out of the box
data/CLAUDE.md ADDED
@@ -0,0 +1,89 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## What this is
6
+
7
+ `solid_queue_web` is a mountable Rails engine (published as a gem) that provides a web dashboard for [Solid Queue](https://github.com/rails/solid_queue). It has no host application — development and testing both use the dummy Rails app under `spec/dummy/`.
8
+
9
+ ## Commands
10
+
11
+ ```bash
12
+ # Run the full suite (rubocop + rspec) — this is what CI runs
13
+ bundle exec rake
14
+
15
+ # Run only tests
16
+ bundle exec rspec
17
+
18
+ # Run a single spec file
19
+ bundle exec rspec spec/requests/solid_queue_web/jobs_spec.rb
20
+
21
+ # Run a single example by line number
22
+ bundle exec rspec spec/requests/solid_queue_web/jobs_spec.rb:42
23
+
24
+ # Lint
25
+ bin/rubocop
26
+
27
+ # Set up and seed the development database (dummy app)
28
+ bundle exec rake dev:setup # creates and migrates spec/dummy/db/development.sqlite3
29
+ bundle exec rake dev:seed # populates with realistic fake jobs/processes
30
+
31
+ # Reset dev database (setup + seed)
32
+ bundle exec rake dev:reset
33
+
34
+ # Start the dummy app for manual testing
35
+ cd spec/dummy && bin/rails server
36
+ # Dashboard is at http://localhost:3000/jobs
37
+ ```
38
+
39
+ ## Architecture
40
+
41
+ ### Engine isolation
42
+
43
+ The engine uses `isolate_namespace SolidQueueWeb`, so all routes, controllers, helpers, and models live under that namespace. The engine requires no database migrations of its own — it reads directly from the host app's Solid Queue tables via `SolidQueue::*` models (a declared gem dependency).
44
+
45
+ ### Execution model pattern
46
+
47
+ Solid Queue represents job state with separate execution tables. Each status maps to a distinct ActiveRecord model:
48
+
49
+ | Status | Model |
50
+ |-------------|------------------------------------|
51
+ | `ready` | `SolidQueue::ReadyExecution` |
52
+ | `scheduled` | `SolidQueue::ScheduledExecution` |
53
+ | `claimed` | `SolidQueue::ClaimedExecution` |
54
+ | `blocked` | `SolidQueue::BlockedExecution` |
55
+ | `failed` | `SolidQueue::FailedExecution` |
56
+
57
+ `JobsController` maps the `?status=` param to these models via `EXECUTION_MODELS`. Only `ready`, `scheduled`, and `blocked` jobs can be discarded from the jobs list (`DISCARDABLE`); failed jobs have their own `FailedJobsController` with retry/discard.
58
+
59
+ ### No asset pipeline dependency
60
+
61
+ CSS is delivered entirely via the `inline_styles` helper, which reads `application.css` at request time and injects it as a `<style>` tag. This prevents conflicts when mounted in any host app. There is no JavaScript — queue pause/resume and job discard use standard form POSTs or Turbo Stream responses.
62
+
63
+ ### Turbo Stream responses
64
+
65
+ `JobsController#destroy` responds to both HTML and `turbo_stream` format. When a job is discarded:
66
+ - If more jobs remain in the filtered scope → removes that row from the DOM.
67
+ - If it was the last job → replaces the table with an empty-state element (`sqd-empty`).
68
+
69
+ ### Authentication
70
+
71
+ `SolidQueueWeb.authenticate` stores a single block (set via an initializer in the host app). `ApplicationController#authenticate!` runs that block in controller context via `instance_exec`. If the block returns falsy, it falls back to HTTP Basic auth. No auth is enforced by default.
72
+
73
+ ### Pagination
74
+
75
+ Pagy is included via `Pagy::Method` (not `Pagy::Backend`) and configured globally in the engine initializer with a limit of 25.
76
+
77
+ ### Test setup
78
+
79
+ `spec/rails_helper.rb` loads `spec/dummy/db/schema.rb` directly on every test run (no migrations). Tests are all request specs that hit the dummy app's mounted engine at `/jobs`. Factories are not used — records are created directly with `SolidQueue::*` models.
80
+
81
+ ### Releasing
82
+
83
+ `bin/release <version>` — bumps the version file, updates `Gemfile.lock` and `CHANGELOG.md`, commits, tags, and pushes. CI picks up the tag and publishes to RubyGems via Trusted Publishing. Must be run from `main` with a clean working tree.
84
+
85
+ ### CHANGELOG conventions
86
+
87
+ - New entries go under `## [Unreleased]` on the feature branch, before opening a PR.
88
+ - Sections within each version must appear in this order: `### Added`, `### Changed`, `### Fixed`. Omit sections that have no entries — never add an empty section header.
89
+ - Branch workflow: always commit on a `feat/*` or `chore/*` branch; never commit directly to `main`.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Chuck Smith
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # TurboRspec
2
+
3
+ [![CI](https://github.com/eclectic-coding/turbo_rspec/actions/workflows/ci.yml/badge.svg)](https://github.com/eclectic-coding/turbo_rspec/actions/workflows/ci.yml)
4
+ [![Gem Version](https://img.shields.io/gem/v/turbo_rspec)](https://rubygems.org/gems/turbo_rspec)
5
+ [![Gem Downloads](https://img.shields.io/gem/dt/turbo_rspec)](https://rubygems.org/gems/turbo_rspec)
6
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.3-ruby)](https://rubygems.org/gems/turbo_rspec)
7
+ [![codecov](https://codecov.io/gh/eclectic-coding/turbo_rspec/branch/main/graph/badge.svg)](https://codecov.io/gh/eclectic-coding/turbo_rspec)
8
+
9
+ RSpec matchers for [Turbo](https://github.com/hotwired/turbo-rails) — assert Turbo Stream responses, Turbo Frame content, and ActionCable broadcasts without hand-rolling helpers in every project.
10
+
11
+ ## Installation
12
+
13
+ Add to your application's `Gemfile`:
14
+
15
+ ```ruby
16
+ group :test do
17
+ gem "turbo_rspec"
18
+ end
19
+ ```
20
+
21
+ ## Setup
22
+
23
+ ### Rails + turbo-rails (automatic)
24
+
25
+ No setup needed. When `turbo-rails` is in your bundle, `TurboRspec::Matchers` is automatically included in all `type: :request` example groups.
26
+
27
+ ### Manual include
28
+
29
+ For non-Rails projects or custom contexts, include the matchers explicitly:
30
+
31
+ ```ruby
32
+ # spec/spec_helper.rb
33
+ RSpec.configure do |config|
34
+ config.include TurboRspec::Matchers
35
+ end
36
+ ```
37
+
38
+ ### Configuration
39
+
40
+ ```ruby
41
+ # spec/support/turbo_rspec.rb
42
+ TurboRspec.configure do |config|
43
+ config.auto_include = false # disable automatic inclusion into request specs
44
+ end
45
+ ```
46
+
47
+ ## Matchers
48
+
49
+ ### `have_turbo_stream`
50
+
51
+ Assert that a response body contains a `<turbo-stream>` element.
52
+
53
+ ```ruby
54
+ # Basic — any turbo stream present
55
+ expect(response).to have_turbo_stream
56
+
57
+ # With action
58
+ expect(response).to have_turbo_stream.with_action(:append)
59
+ expect(response).to have_turbo_stream.with_action(:replace)
60
+ expect(response).to have_turbo_stream.with_action(:remove)
61
+
62
+ # With target (single DOM id)
63
+ expect(response).to have_turbo_stream.targeting("messages")
64
+
65
+ # With targets (CSS selector)
66
+ expect(response).to have_turbo_stream.targeting_all(".message-item")
67
+
68
+ # With content
69
+ expect(response).to have_turbo_stream.with_content("Hello, world!")
70
+
71
+ # With partial
72
+ expect(response).to have_turbo_stream.rendering("messages/_message")
73
+
74
+ # Chained — all constraints must match the same stream
75
+ expect(response).to have_turbo_stream
76
+ .with_action(:append)
77
+ .targeting("messages")
78
+ .with_content("Hello")
79
+
80
+ # Negation
81
+ expect(response).not_to have_turbo_stream.with_action(:replace)
82
+ ```
83
+
84
+ #### Actions
85
+
86
+ Turbo supports the following stream actions: `append`, `prepend`, `replace`, `update`, `remove`, `before`, `after`, `refresh`.
87
+
88
+ ### `have_turbo_frame`
89
+
90
+ Assert that a response body contains a `<turbo-frame>` element.
91
+
92
+ ```ruby
93
+ # Basic — any turbo frame present
94
+ expect(response).to have_turbo_frame
95
+
96
+ # With id
97
+ expect(response).to have_turbo_frame.with_id("messages")
98
+
99
+ # With content
100
+ expect(response).to have_turbo_frame.with_id("messages").with_content("Hello")
101
+
102
+ # With partial
103
+ expect(response).to have_turbo_frame.with_id("post").rendering("posts/_post")
104
+
105
+ # Negation
106
+ expect(response).not_to have_turbo_frame.with_id("notifications")
107
+ ```
108
+
109
+ ## Example: request spec
110
+
111
+ ```ruby
112
+ RSpec.describe "Messages", type: :request do
113
+ describe "POST /messages" do
114
+ it "appends the new message to the list" do
115
+ post messages_path, params: { message: { body: "Hello" } },
116
+ headers: { "Accept" => "text/vnd.turbo-stream.html" }
117
+
118
+ expect(response).to have_turbo_stream
119
+ .with_action(:append)
120
+ .targeting("messages")
121
+ .with_content("Hello")
122
+ end
123
+ end
124
+
125
+ describe "DELETE /messages/:id" do
126
+ it "removes the message row" do
127
+ message = create(:message)
128
+ delete message_path(message),
129
+ headers: { "Accept" => "text/vnd.turbo-stream.html" }
130
+
131
+ expect(response).to have_turbo_stream
132
+ .with_action(:remove)
133
+ .targeting("message_#{message.id}")
134
+ end
135
+ end
136
+ end
137
+ ```
138
+
139
+ ## Contributing
140
+
141
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/eclectic-coding/turbo_rspec).
142
+
143
+ ## License
144
+
145
+ The gem is available as open source under the [MIT License](https://opensource.org/licenses/MIT).
data/ROADMAP.md ADDED
@@ -0,0 +1,87 @@
1
+ # turbo_rspec Roadmap
2
+
3
+ RSpec matchers for [Turbo](https://github.com/hotwired/turbo-rails): Turbo Streams, Turbo Frames, and ActionCable broadcasts. The goal is to replace the hand-rolled helpers that every Rails/Turbo project accumulates.
4
+
5
+ ---
6
+
7
+
8
+ ## v0.2.0 — Broadcast matchers
9
+
10
+ **Goal:** cover the broadcast side — jobs/services that push streams over ActionCable.
11
+
12
+ - `have_broadcasted_turbo_stream_to(channel_or_object)` — wraps ActionCable's test adapter
13
+ - Same fluent chain as `have_turbo_stream`: `.with_action`, `.targeting`, `.rendering`, `.with_content`
14
+ - Count qualifiers: `.exactly(n).times`, `.at_least(n).times`, `.at_most(n).times`, `.once`, `.twice`
15
+ - Works inside `expect { }.to have_broadcasted_turbo_stream_to(...)` blocks
16
+ - Helper `broadcast_turbo_stream_to` alias for symmetry with ActionCable's naming
17
+ - Docs: "testing broadcasts in job specs and service specs"
18
+
19
+ ---
20
+
21
+ ## v0.3.0 — Capybara / system spec integration
22
+
23
+ **Goal:** assertions that work against a live browser in feature/system specs.
24
+
25
+ - `have_turbo_frame(id)` Capybara matcher — waits for the frame to appear on the page
26
+ - `.with_content(...)` — delegates to Capybara's `have_content` with correct scope
27
+ - `.loaded` — asserts `[complete]` attribute is present (frame finished loading)
28
+ - `within_turbo_frame(id) { ... }` — scopes Capybara assertions to the frame's DOM
29
+ - `have_turbo_stream_tag` — asserts a `<turbo-stream-source>` subscription element exists on the page
30
+ - Docs: system spec patterns, async update testing
31
+
32
+ ---
33
+
34
+ ## v0.4.0 — Developer experience pass
35
+
36
+ **Goal:** make failure output good enough that you never have to drop into a debugger just to read a matcher failure.
37
+
38
+ - Rich failure messages: show actual stream actions/targets found vs. expected
39
+ - `assert_no_turbo_stream` alias for teams that mix RSpec/minitest terminology
40
+ - Composable matchers: `include(have_turbo_stream(...), have_turbo_stream(...))` for multi-stream assertions
41
+ - `have_turbo_streams` (plural) — assert multiple streams in one expectation with an array DSL
42
+ - Support for `aggregate_failures` blocks
43
+
44
+ ---
45
+
46
+ ## v0.5.0 — Compatibility and edge cases
47
+
48
+ **Goal:** harden the gem against real-world app variations.
49
+
50
+ - Rails 7.1/7.2/8.0/8.1 and Turbo 1.x/2.x compatibility matrix in CI
51
+ - Multi-stream response body parsing (a single response can contain multiple `<turbo-stream>` tags)
52
+ - `refresh` action support (Turbo 8 page refresh streams)
53
+ - `morph` action support (Turbo Morphing)
54
+ - Graceful no-op when `turbo-rails` is not in the Gemfile (no `LoadError`)
55
+ - Minitest module (`TurboRspec::Assertions`) as opt-in companion (no RSpec dependency for that module)
56
+
57
+ ---
58
+
59
+ ## v1.0.0 — Stable API
60
+
61
+ **Goal:** API freeze. Commit to semver stability. Make the gem the obvious default choice.
62
+
63
+ - API stability guarantee: no breaking changes without a major version bump
64
+ - `TurboRspec::VERSION` semantic versioning enforced via CI check
65
+ - Migration guide: "replacing hand-rolled Turbo Stream helpers" in the docs
66
+ - Full YARD documentation with `yard` and hosted on RubyDoc.info
67
+ - 100% branch coverage enforced in CI (`simplecov`)
68
+ - Performance: benchmark matcher overhead to keep it negligible in large suites
69
+ - `bin/release` script (mirrors solid_queue_web pattern): bump version, update CHANGELOG, tag, push; CI publishes via Trusted Publishing
70
+
71
+ ---
72
+
73
+ ## Post-1.0 ideas (not scheduled)
74
+
75
+ - VS Code / RubyMine snippet pack for common patterns
76
+ - `turbo_rspec` generator (`rails generate turbo_rspec:install`) to scaffold `spec/support/turbo.rb`
77
+ - Playwright/Puppeteer bridge for headless assertions outside Capybara
78
+ - Shared examples: `it_behaves_like "a turbo stream response"` for controller testing
79
+
80
+ ---
81
+
82
+ ## Guiding principles
83
+
84
+ - **Zero magic by default.** Auto-include only when it's unambiguous (Rails request specs). Everything else is opt-in.
85
+ - **Fail loudly with useful output.** A cryptic failure message is a bug.
86
+ - **No minitest dependency in the core.** The gem is RSpec-first; minitest support is a separate module.
87
+ - **Stay close to Turbo's own naming.** Matcher names mirror Turbo's action names so the docs cross-reference naturally.
data/Rakefile ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "standard/rake"
9
+
10
+ require "bundler/audit/task"
11
+ Bundler::Audit::Task.new
12
+
13
+ task default: %i[bundle:audit:update bundle:audit spec standard]
data/codecov.yml ADDED
@@ -0,0 +1,17 @@
1
+ # Configure Pull Request Bot Comments
2
+ comment:
3
+ layout: "reach, diff, flags, files"
4
+ behavior: default
5
+ # Only post or update the comment if the coverage drops
6
+ require_changes: "coverage_drop"
7
+
8
+ # Configure Commit Status Checks (the green checkmark/red X list)
9
+ coverage:
10
+ status:
11
+ project:
12
+ default:
13
+ # Prevent "Informational" mode so a drop causes an actual red failure check
14
+ informational: false
15
+ patch:
16
+ default:
17
+ informational: false
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurboRspec
4
+ class Configuration
5
+ attr_accessor :auto_include
6
+
7
+ def initialize
8
+ @auto_include = true
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module TurboRspec
6
+ module Matchers
7
+ class HaveTurboFrame
8
+ def initialize
9
+ @id = nil
10
+ @content = nil
11
+ @partial = nil
12
+ end
13
+
14
+ def with_id(id)
15
+ @id = id.to_s
16
+ self
17
+ end
18
+
19
+ def with_content(text)
20
+ @content = text.to_s
21
+ self
22
+ end
23
+
24
+ def rendering(partial)
25
+ @partial = partial.to_s
26
+ self
27
+ end
28
+
29
+ def matches?(response_or_body)
30
+ @body = extract_body(response_or_body)
31
+ @frames = parse_frames(@body)
32
+ @frames.any? { |frame| frame_matches?(frame) }
33
+ end
34
+
35
+ def does_not_match?(response_or_body)
36
+ !matches?(response_or_body)
37
+ end
38
+
39
+ def failure_message
40
+ "expected response to contain a turbo frame#{constraint_description}\n#{found_frames_message}"
41
+ end
42
+
43
+ def failure_message_when_negated
44
+ "expected response not to contain a turbo frame#{constraint_description}"
45
+ end
46
+
47
+ def description
48
+ "have turbo frame#{constraint_description}"
49
+ end
50
+
51
+ private
52
+
53
+ def extract_body(response_or_body)
54
+ if response_or_body.respond_to?(:body)
55
+ response_or_body.body
56
+ else
57
+ response_or_body.to_s
58
+ end
59
+ end
60
+
61
+ def parse_frames(body)
62
+ Nokogiri::HTML5.fragment(body).css("turbo-frame")
63
+ end
64
+
65
+ def frame_matches?(frame)
66
+ matches_id?(frame) &&
67
+ matches_content?(frame) &&
68
+ matches_partial?(frame)
69
+ end
70
+
71
+ def matches_id?(frame)
72
+ @id.nil? || frame["id"] == @id
73
+ end
74
+
75
+ def matches_content?(frame)
76
+ return true if @content.nil?
77
+ frame.text.include?(@content)
78
+ end
79
+
80
+ def matches_partial?(frame)
81
+ return true if @partial.nil?
82
+ frame.to_html.include?(@partial)
83
+ end
84
+
85
+ def constraint_description
86
+ parts = []
87
+ parts << " with id #{@id.inspect}" if @id
88
+ parts << " with content #{@content.inspect}" if @content
89
+ parts << " rendering #{@partial.inspect}" if @partial
90
+ parts.join
91
+ end
92
+
93
+ def found_frames_message
94
+ if @frames.empty?
95
+ "but no turbo frames were found in the response"
96
+ else
97
+ ids = @frames.map { |f| " <turbo-frame id=#{f["id"].inspect}>" }
98
+ "found turbo frames:\n#{ids.join("\n")}"
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module TurboRspec
6
+ module Matchers
7
+ class HaveTurboStream
8
+ def initialize
9
+ @action = nil
10
+ @target = nil
11
+ @target_all = nil
12
+ @content = nil
13
+ @partial = nil
14
+ end
15
+
16
+ def with_action(action)
17
+ @action = action.to_s
18
+ self
19
+ end
20
+
21
+ def targeting(dom_id)
22
+ @target = dom_id.to_s
23
+ self
24
+ end
25
+
26
+ def targeting_all(selector)
27
+ @target_all = selector.to_s
28
+ self
29
+ end
30
+
31
+ def with_content(text)
32
+ @content = text.to_s
33
+ self
34
+ end
35
+
36
+ def rendering(partial)
37
+ @partial = partial.to_s
38
+ self
39
+ end
40
+
41
+ def matches?(response_or_body)
42
+ @body = extract_body(response_or_body)
43
+ @streams = parse_streams(@body)
44
+ @streams.any? { |stream| stream_matches?(stream) }
45
+ end
46
+
47
+ def does_not_match?(response_or_body)
48
+ !matches?(response_or_body)
49
+ end
50
+
51
+ def failure_message
52
+ "expected response to contain a turbo stream#{constraint_description}\n#{found_streams_message}"
53
+ end
54
+
55
+ def failure_message_when_negated
56
+ "expected response not to contain a turbo stream#{constraint_description}"
57
+ end
58
+
59
+ def description
60
+ "have turbo stream#{constraint_description}"
61
+ end
62
+
63
+ private
64
+
65
+ def extract_body(response_or_body)
66
+ if response_or_body.respond_to?(:body)
67
+ response_or_body.body
68
+ else
69
+ response_or_body.to_s
70
+ end
71
+ end
72
+
73
+ def parse_streams(body)
74
+ Nokogiri::HTML5.fragment(body).css("turbo-stream")
75
+ end
76
+
77
+ def stream_matches?(stream)
78
+ matches_action?(stream) &&
79
+ matches_target?(stream) &&
80
+ matches_target_all?(stream) &&
81
+ matches_content?(stream) &&
82
+ matches_partial?(stream)
83
+ end
84
+
85
+ def matches_action?(stream)
86
+ @action.nil? || stream["action"] == @action
87
+ end
88
+
89
+ def matches_target?(stream)
90
+ @target.nil? || stream["target"] == @target
91
+ end
92
+
93
+ def matches_target_all?(stream)
94
+ @target_all.nil? || stream["targets"] == @target_all
95
+ end
96
+
97
+ def matches_content?(stream)
98
+ return true if @content.nil?
99
+ stream.text.include?(@content)
100
+ end
101
+
102
+ def matches_partial?(stream)
103
+ return true if @partial.nil?
104
+ stream.to_html.include?(@partial)
105
+ end
106
+
107
+ def constraint_description
108
+ parts = []
109
+ parts << " with action #{@action.inspect}" if @action
110
+ parts << " targeting #{@target.inspect}" if @target
111
+ parts << " targeting all #{@target_all.inspect}" if @target_all
112
+ parts << " with content #{@content.inspect}" if @content
113
+ parts << " rendering #{@partial.inspect}" if @partial
114
+ parts.join
115
+ end
116
+
117
+ def found_streams_message
118
+ if @streams.empty?
119
+ "but no turbo streams were found in the response"
120
+ else
121
+ actions = @streams.map { |s| " <turbo-stream action=#{s["action"].inspect} target=#{s["target"].inspect}>" }
122
+ "found turbo streams:\n#{actions.join("\n")}"
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "matchers/have_turbo_frame"
4
+ require_relative "matchers/have_turbo_stream"
5
+
6
+ module TurboRspec
7
+ module Matchers
8
+ def have_turbo_frame
9
+ HaveTurboFrame.new
10
+ end
11
+
12
+ def have_turbo_stream
13
+ HaveTurboStream.new
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurboRspec
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "turbo_rspec/version"
4
+ require_relative "turbo_rspec/configuration"
5
+ require_relative "turbo_rspec/matchers"
6
+
7
+ module TurboRspec
8
+ class Error < StandardError; end
9
+
10
+ class << self
11
+ def configure
12
+ yield configuration
13
+ end
14
+
15
+ def configuration
16
+ @configuration ||= Configuration.new
17
+ end
18
+
19
+ def reset_configuration!
20
+ @configuration = Configuration.new
21
+ end
22
+ end
23
+ end
24
+
25
+ if defined?(RSpec)
26
+ RSpec.configure do |config|
27
+ config.include TurboRspec::Matchers, type: :request if TurboRspec.configuration.auto_include &&
28
+ Gem.loaded_specs.key?("turbo-rails")
29
+ end
30
+ end
@@ -0,0 +1,4 @@
1
+ module TurboRspec
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,78 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: turbo_rspec
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Chuck Smith
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: nokogiri
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.13'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '1.13'
26
+ description: 'Drop-in RSpec matchers for hotwired/turbo-rails: assert Turbo Stream
27
+ responses, Turbo Frame content, and ActionCable broadcasts without hand-rolling
28
+ helpers in every project.'
29
+ email:
30
+ - eclectic-coding@users.noreply.github.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - ".github/workflows/ci.yml"
36
+ - ".github/workflows/publish.yml"
37
+ - ".rubocop.yml"
38
+ - CHANGELOG.md
39
+ - CLAUDE.md
40
+ - LICENSE.txt
41
+ - README.md
42
+ - ROADMAP.md
43
+ - Rakefile
44
+ - codecov.yml
45
+ - lib/turbo_rspec.rb
46
+ - lib/turbo_rspec/configuration.rb
47
+ - lib/turbo_rspec/matchers.rb
48
+ - lib/turbo_rspec/matchers/have_turbo_frame.rb
49
+ - lib/turbo_rspec/matchers/have_turbo_stream.rb
50
+ - lib/turbo_rspec/version.rb
51
+ - sig/turbo_rspec.rbs
52
+ homepage: https://github.com/eclectic-coding/turbo_rspec
53
+ licenses:
54
+ - MIT
55
+ metadata:
56
+ allowed_push_host: https://rubygems.org
57
+ homepage_uri: https://github.com/eclectic-coding/turbo_rspec
58
+ source_code_uri: https://github.com/eclectic-coding/turbo_rspec
59
+ changelog_uri: https://github.com/eclectic-coding/turbo_rspec/blob/main/CHANGELOG.md
60
+ rubygems_mfa_required: 'true'
61
+ rdoc_options: []
62
+ require_paths:
63
+ - lib
64
+ required_ruby_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: 3.3.0
69
+ required_rubygems_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ requirements: []
75
+ rubygems_version: 3.6.9
76
+ specification_version: 4
77
+ summary: RSpec matchers for Turbo Streams, Turbo Frames, and ActionCable broadcasts.
78
+ test_files: []