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 +7 -0
- data/.github/workflows/ci.yml +57 -0
- data/.github/workflows/publish.yml +37 -0
- data/.rubocop.yml +18 -0
- data/CHANGELOG.md +22 -0
- data/CLAUDE.md +89 -0
- data/LICENSE.txt +21 -0
- data/README.md +145 -0
- data/ROADMAP.md +87 -0
- data/Rakefile +13 -0
- data/codecov.yml +17 -0
- data/lib/turbo_rspec/configuration.rb +11 -0
- data/lib/turbo_rspec/matchers/have_turbo_frame.rb +103 -0
- data/lib/turbo_rspec/matchers/have_turbo_stream.rb +127 -0
- data/lib/turbo_rspec/matchers.rb +16 -0
- data/lib/turbo_rspec/version.rb +5 -0
- data/lib/turbo_rspec.rb +30 -0
- data/sig/turbo_rspec.rbs +4 -0
- metadata +78 -0
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
|
+
[](https://github.com/eclectic-coding/turbo_rspec/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/turbo_rspec)
|
|
5
|
+
[](https://rubygems.org/gems/turbo_rspec)
|
|
6
|
+
[](https://rubygems.org/gems/turbo_rspec)
|
|
7
|
+
[](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,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
|
data/lib/turbo_rspec.rb
ADDED
|
@@ -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
|
data/sig/turbo_rspec.rbs
ADDED
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: []
|