rails-jsdom 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/CHANGELOG.md +13 -0
- data/LICENSE +21 -0
- data/README.md +351 -0
- data/lib/generators/rails_jsdom/install/install_generator.rb +108 -0
- data/lib/rails_jsdom/bridge.rb +80 -0
- data/lib/rails_jsdom/element.rb +186 -0
- data/lib/rails_jsdom/page.rb +33 -0
- data/lib/rails_jsdom/query.rb +76 -0
- data/lib/rails_jsdom/runner.js +3 -0
- data/lib/rails_jsdom/runtime.js +926 -0
- data/lib/rails_jsdom/test_case.rb +29 -0
- data/lib/rails_jsdom/test_helper.rb +767 -0
- data/lib/rails_jsdom/turbo_stream_deliveries.rb +25 -0
- data/lib/rails_jsdom/version.rb +4 -0
- data/lib/rails_jsdom.rb +83 -0
- metadata +158 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a6e708481667ec3be843dfa039b7c931ee9bfd5c9db36befb76db5a73fc380cd
|
|
4
|
+
data.tar.gz: 9d83fd3debee83c77246658a30b5ebed04d6d1446bba341422f7aed2f8e584d9
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e7a0d5cd8b4718c00f89213eba27ac26d2deca91b3ffaf63b9ab95c0382635d29d0e964ea8c9c1e3b60ecee715d21463ab538aa80a36a86ca78ebffa21ece825
|
|
7
|
+
data.tar.gz: c572e6c1c3e090904fa6c09f72552c15be245f8b2d3185e35d389110bd3e25e378d89a0e4459616cf04735c4a4adb87905bc7de04703630d6b213e5c839265eb
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (unreleased)
|
|
4
|
+
|
|
5
|
+
- First release.
|
|
6
|
+
- `bin/rails generate rails_jsdom:install` sets an app up: it requires the library, creates `test/interactions`, adds jsdom and esbuild to `package.json` and installs them, ignores `node_modules`, and checks the ActionCable test adapter. It pins jsdom and esbuild to a major version and sets `min-release-age=7` in `.npmrc`, warning when npm is too old to honour it.
|
|
7
|
+
- `RailsJsdom::TestCase`: interaction tests on `ActionDispatch::IntegrationTest` with the system test methods: navigation (`visit`, `refresh`, `go_back`, `go_forward`, `using_session`), actions (`click_on`, `click_link`, `click_button`, `fill_in`, `check`, `uncheck`, `choose`, `select`, `unselect`, `attach_file`, `send_keys`, `hover`, `double_click`, `right_click`), scoping (`within`, `within_fieldset`, `within_table`), finders and elements (`find`, `all`, `first`, `find_field`, `find_button`, `find_link`, `page`), dialogs (`accept_alert`, `accept_confirm`, `dismiss_confirm`, `accept_prompt`, `dismiss_prompt`), scripts (`evaluate_script`, `execute_script`), and the `assert_*` and `has_*?` assertions for text, selectors, fields, buttons, links, the current path and the title. Plus `click`, `mount_partial`, `mount_html`, `stub_fetch` and `page_text`. `assert_dom` and `assert_select` read the live page.
|
|
8
|
+
- One persistent Node + jsdom process per test worker; Stimulus controllers bundled once per process with esbuild from your importmap.
|
|
9
|
+
- Page `fetch`, Turbo Drive visits and Turbo form submissions are answered by Rails in-process, in the test's session, following redirects.
|
|
10
|
+
- Turbo Stream responses and Turbo Stream broadcasts (`turbo_stream_from`, `broadcast_*`, `broadcast_*_later` performed by the test) reach the page.
|
|
11
|
+
- Deterministic settling on a virtual clock; page errors, unhandled rejections and unexpected alert, confirm and prompt dialogs fail the test.
|
|
12
|
+
- Node output and page console messages are written to `Rails.logger`, prefixed with `[rails_jsdom]`.
|
|
13
|
+
- If Rails raises while answering a page fetch, the exception fails the test and the next command starts a fresh Node process.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jake Howerton
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
# rails-jsdom
|
|
2
|
+
|
|
3
|
+
Browserless interaction tests for Rails apps built with Stimulus, Turbo and importmap.
|
|
4
|
+
|
|
5
|
+
`rails-jsdom` lets you write tests that read like system tests — `visit`, `click_on`, `fill_in`, `assert_selector`, `assert_text` — without Capybara, a test server or a browser. Your real pages render through Rails, your real Stimulus controllers and Turbo run in [jsdom](https://github.com/jsdom/jsdom), and every request the page makes is answered by Rails in the same test process.
|
|
6
|
+
|
|
7
|
+
Interaction tests written this way can be as much as **4–10x faster** than equivalent Rails system tests, run deterministically, and fail on JavaScript errors. `rails-jsdom` aims to match the API available in system tests that users are already familiar with.
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
class CartInteractionTest < RailsJsdom::TestCase
|
|
11
|
+
test "adding an item updates the cart through its Turbo Stream" do
|
|
12
|
+
sign_in_as users(:shopper)
|
|
13
|
+
visit product_path(products(:mug))
|
|
14
|
+
fill_in "Quantity", with: 2
|
|
15
|
+
click_on "Add to cart"
|
|
16
|
+
|
|
17
|
+
assert_dom "#cart-count", text: "2"
|
|
18
|
+
assert_text "Added to your cart"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Contents
|
|
24
|
+
|
|
25
|
+
- [How it works](#how-it-works)
|
|
26
|
+
- [Trade-offs compared with system tests](#trade-offs-compared-with-system-tests)
|
|
27
|
+
- [Requirements](#requirements)
|
|
28
|
+
- [Installation](#installation)
|
|
29
|
+
- [Supply chain](#supply-chain)
|
|
30
|
+
- [Writing tests](#writing-tests)
|
|
31
|
+
- [API](#api)
|
|
32
|
+
- [Turbo Stream broadcasts](#turbo-stream-broadcasts)
|
|
33
|
+
- [Timers and settling](#timers-and-settling)
|
|
34
|
+
- [Continuous integration](#continuous-integration)
|
|
35
|
+
- [Troubleshooting](#troubleshooting)
|
|
36
|
+
- [Limitations](#limitations)
|
|
37
|
+
- [Development](#development)
|
|
38
|
+
- [License](#license)
|
|
39
|
+
|
|
40
|
+
## How it works
|
|
41
|
+
|
|
42
|
+
1. Each test worker starts **one persistent Node process** running jsdom. It stays up for the whole run.
|
|
43
|
+
2. On start, Node bundles every Stimulus controller under `app/javascript/controllers` once with [esbuild](https://esbuild.github.io), resolving `@hotwired/turbo-rails`, `@hotwired/stimulus` and your other pins through your app's importmap and Propshaft load path. The versions under test are exactly the ones your app ships.
|
|
44
|
+
3. `visit` makes a real integration request (`get`), follows redirects, and loads the response HTML into a fresh jsdom window at the request's URL. Turbo and Stimulus boot; controllers connect.
|
|
45
|
+
4. Every `fetch` the page makes — your controllers' own calls, Turbo Drive visits, Turbo form submissions, Turbo Frames — is sent back to Ruby and answered by Rails **in-process**, with the test's cookies and session, following redirects. Turbo then renders the response exactly as it would in a browser, including Turbo Stream responses.
|
|
46
|
+
5. After every action, the runner **settles** the page deterministically: it runs timers on a virtual clock and waits until no Rails request is pending.
|
|
47
|
+
6. Assertions read a snapshot of the live DOM: `assert_dom`, `assert_select` and `css_select` work as in any Rails test because `document_root_element` is the page.
|
|
48
|
+
7. JavaScript errors, unhandled promise rejections, Stimulus errors and unexpected alert, confirm or prompt dialogs fail the action that caused them.
|
|
49
|
+
|
|
50
|
+
## Trade-offs compared with system tests
|
|
51
|
+
|
|
52
|
+
**What you gain**
|
|
53
|
+
|
|
54
|
+
- **Speed.** No browser, no Capybara server, no Puma, no Chrome. A test is a Rails request plus a jsdom page. As much as 4–10x faster than the same behaviour in system tests.
|
|
55
|
+
- **Determinism.** Timers run on a virtual clock and the runner waits for pending requests itself. There are no sleeps, waits or retries to tune.
|
|
56
|
+
- **Real code paths.** Real controllers, real views, real Stimulus controllers, real Turbo, real session — only the browser engine is replaced.
|
|
57
|
+
- **Clear failures.** A JavaScript exception, an unhandled rejection or a dialog you did not expect fails the test instead of being swallowed by the browser.
|
|
58
|
+
- **Parallel-friendly.** Each test worker gets its own Node process; nothing is shared between workers.
|
|
59
|
+
|
|
60
|
+
**What you lose**
|
|
61
|
+
|
|
62
|
+
jsdom implements the DOM and runs JavaScript, but it does not lay out or paint pages. These cannot be tested with `rails-jsdom`; if they matter to you, keep a small number of real-browser system tests for them:
|
|
63
|
+
|
|
64
|
+
- **Computed CSS** — colours, fonts, backgrounds, hover styles.
|
|
65
|
+
- **Layout and geometry** — `getBoundingClientRect`, centring, gaps, overflow, wrapping, "these cards are the same height".
|
|
66
|
+
- **CSS media-query breakpoints** — elements shown or hidden only by responsive CSS at a viewport width (for example Tailwind's `md:hidden`). `matchMedia` is stubbed so controllers don't crash, but CSS does not respond to width. Responsive behaviour driven by JavaScript, such as a mobile menu toggle, is still testable.
|
|
67
|
+
- **Theme switching** where the theme only changes CSS.
|
|
68
|
+
- **Screenshots** and other visual evidence.
|
|
69
|
+
|
|
70
|
+
Layout assertions are brittle in any case; prefer asserting behaviour — what is in the DOM, what is hidden, what the URL is, what Rails received.
|
|
71
|
+
|
|
72
|
+
**Not currently supported, but future support or partial support possible**
|
|
73
|
+
|
|
74
|
+
These are limits of this library, not of jsdom. Support would be an imitation of the browser, so some behaviour stays out of reach:
|
|
75
|
+
|
|
76
|
+
- **Browser default actions for keys** are partly supported. `send_keys` performs the common ones itself: typing and Backspace at the end of a text field, Enter submitting a form, Space and Enter pressing a focused button, Tab and Shift+Tab moving focus, and arrow keys, Home and End stepping a range or number field. Caret movement, text selection, IME input and OS shortcuts depend on layout or the platform and are not supported.
|
|
77
|
+
- **Third-party scripts loaded from a CDN** — remote importmap pins are replaced by empty modules, and `<script src>` tags are not loaded. Pins vendored with `bin/importmap pin --download` are bundled and work. Remote scripts could be downloaded once and cached, but many of them (payment widgets, maps, charts, analytics) need cross-origin iframes, canvas, layout or their vendor's servers, so they would still need stubs.
|
|
78
|
+
- **Custom ActionCable channels** with their own JavaScript consumers. Turbo Stream broadcasts *are* delivered (see below). Custom channels could be supported in-process by giving the page a fake WebSocket that runs the real channel in Ruby; reconnects, pings, concurrent delivery and several clients at once would stay untested.
|
|
79
|
+
|
|
80
|
+
**Where the time goes**
|
|
81
|
+
|
|
82
|
+
The esbuild bundle is built once per test worker. Each `visit` then costs a Rails render, a jsdom parse and a Turbo/Stimulus boot, and each interaction costs whatever Rails requests it makes. If several tests repeat the same visit and interactions only to check different things about the result, each one pays for them again; checking those things in one test pays once.
|
|
83
|
+
|
|
84
|
+
## Requirements
|
|
85
|
+
|
|
86
|
+
- Ruby 3.3, 3.4 or 4.0
|
|
87
|
+
- Rails 8.1 with `importmap-rails`, `propshaft`, `turbo-rails` and `stimulus-rails`
|
|
88
|
+
- Stimulus controllers under `app/javascript/controllers`, named `*_controller.js`
|
|
89
|
+
- Node.js supported by jsdom (currently 20.19+, 22.13+ or 24+)
|
|
90
|
+
|
|
91
|
+
These are the versions CI tests and that issues are fixed for. Ruby 3.2 has reached end of life. Older Rails releases may still work, but they are not tested or supported.
|
|
92
|
+
|
|
93
|
+
## Installation
|
|
94
|
+
|
|
95
|
+
Add the gem to the test group and install it:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
group :test do
|
|
99
|
+
gem "rails-jsdom"
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
bundle install
|
|
105
|
+
bin/rails generate rails_jsdom:install
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The install generator:
|
|
109
|
+
|
|
110
|
+
1. Adds `require "rails_jsdom"` to `test/test_helper.rb`, after `require "rails/test_help"`.
|
|
111
|
+
2. Creates `test/interactions/` for your interaction tests. Any test directory works; `bin/rails test` picks them up.
|
|
112
|
+
3. Adds `jsdom` and `esbuild` to the `devDependencies` in `package.json`, creating the file if your app has none. The ranges (`^29.1.1`, `^0.28.2`) allow newer minor and patch releases but never a new major version. The gem loads both from your app's `node_modules`.
|
|
113
|
+
4. Sets `min-release-age=7` in `.npmrc`, unless it is already set, so npm installs only versions published at least 7 days ago. See [Supply chain](#supply-chain).
|
|
114
|
+
5. Warns if your npm is older than 11.10, which ignores `min-release-age`.
|
|
115
|
+
6. Runs `npm install`. Pass `--skip-npm` to run it yourself.
|
|
116
|
+
7. Adds `/node_modules` to `.gitignore`.
|
|
117
|
+
8. Warns if `config/cable.yml` does not use the `test` adapter in the test environment, which [Turbo Stream broadcasts](#turbo-stream-broadcasts) need.
|
|
118
|
+
|
|
119
|
+
Running it again changes nothing that is already set up.
|
|
120
|
+
|
|
121
|
+
### Supply chain
|
|
122
|
+
|
|
123
|
+
jsdom and esbuild run with your test process's access to your machine and CI, so a compromised release of either, or of anything they depend on, could take them over. Compromised npm releases are usually found and removed within days. The install generator sets up three defences:
|
|
124
|
+
|
|
125
|
+
- **A major-version range.** `npm update` never moves to a new major version without you changing `package.json`.
|
|
126
|
+
- **A release age.** With `min-release-age=7` in `.npmrc`, npm skips any version published in the last 7 days when it resolves a range, and fails with `ETARGET` if no older version fits. It needs npm 11.10 or later; older npm reads the setting and ignores it.
|
|
127
|
+
- **Your lockfile.** Commit `package-lock.json` and install with `npm ci` in CI. `npm ci` installs exactly the locked versions, whatever your npm version, so new releases only arrive when someone runs `npm install` or `npm update` and commits the result.
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
## Writing tests
|
|
131
|
+
|
|
132
|
+
Inherit from `RailsJsdom::TestCase`, which is an `ActionDispatch::IntegrationTest`, so fixtures, `sign_in` helpers that post to your session controller, `perform_enqueued_jobs` and all Rails assertions work as usual.
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
class ProjectFilterInteractionTest < RailsJsdom::TestCase
|
|
136
|
+
setup do
|
|
137
|
+
sign_in_as users(:owner)
|
|
138
|
+
visit projects_path
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
test "filtering by name leaves one card visible" do
|
|
142
|
+
fill_in "search", with: "Billing"
|
|
143
|
+
|
|
144
|
+
assert_dom "[data-project-filter-target=card]:not([hidden])", count: 1
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
test "an unknown name shows the empty state instead of the grid" do
|
|
148
|
+
fill_in "search", with: "no-such-project"
|
|
149
|
+
|
|
150
|
+
assert_dom "[data-project-filter-target=noResults]:not([hidden])"
|
|
151
|
+
assert_dom "[data-project-filter-target=grid][hidden]"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Patterns:
|
|
157
|
+
|
|
158
|
+
- **Turbo forms** submit through Turbo and Rails answers in-process; Turbo Stream responses are applied before the action returns.
|
|
159
|
+
- **Dialogs**: `<dialog>` `showModal`/`close` and `method="dialog"` forms work.
|
|
160
|
+
- **Requests to other hosts** fail like a network error (`TypeError`), so a page cannot reach the internet.
|
|
161
|
+
|
|
162
|
+
## API
|
|
163
|
+
|
|
164
|
+
The full API reference is at **[jakehow.github.io/rails-jsdom](https://jakehow.github.io/rails-jsdom/)**. Tests inherit from [`RailsJsdom::TestCase`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestCase.html); every helper is documented in [`RailsJsdom::TestHelper`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html), with the locator rules, filters, scoping, dialogs and settling.
|
|
165
|
+
|
|
166
|
+
### System test methods
|
|
167
|
+
|
|
168
|
+
| System test method | Status | Notes |
|
|
169
|
+
| --- | --- | --- |
|
|
170
|
+
| [`visit`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-visit) | ✅ | |
|
|
171
|
+
| [`refresh`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-refresh) | ✅ | Requests the current URL again, without a history entry |
|
|
172
|
+
| [`go_back`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-go_back), [`go_forward`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-go_forward) | ✅ | Across Turbo Drive visits and full page loads |
|
|
173
|
+
| [`using_session`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-using_session) | ✅ | Each session has its own cookies and page; broadcasts reach a session's page when the test returns to it |
|
|
174
|
+
| [`click_on`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-click_on), [`click_link_or_button`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-click_link_or_button) | ✅ | |
|
|
175
|
+
| [`click_link`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-click_link) | ✅ | |
|
|
176
|
+
| [`click_button`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-click_button) | ✅ | |
|
|
177
|
+
| [`fill_in`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-fill_in) | ✅ | Text-like fields only, as in system tests |
|
|
178
|
+
| [`check`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-check), [`uncheck`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-uncheck) | ✅ | |
|
|
179
|
+
| [`choose`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-choose) | ✅ | |
|
|
180
|
+
| [`select`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-select), [`unselect`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-unselect) | ✅ | |
|
|
181
|
+
| [`attach_file`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-attach_file) | ✅ | Forms submit files to Rails as multipart requests |
|
|
182
|
+
| [`send_keys`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-send_keys) | ✅ | Keys perform the common browser default actions; typing happens at the end of the value, with no caret or selection |
|
|
183
|
+
| [`within`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-within) | ✅ | CSS selector or an element from `find`; also scopes `assert_dom` |
|
|
184
|
+
| [`within_fieldset`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-within_fieldset), [`within_table`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-within_table) | ✅ | |
|
|
185
|
+
| [`find`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-find), [`all`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-all), [`first`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-first) | ✅ | `text:`, `exact_text:`, `visible:` and the field filters |
|
|
186
|
+
| [`find_field`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-find_field), [`find_button`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-find_button), [`find_link`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-find_link) | ✅ | `with:`, `checked:`, `unchecked:`, `disabled:`, `href:`, `type:` |
|
|
187
|
+
| Element [`.click`](https://jakehow.github.io/rails-jsdom/RailsJsdom/Element.html#method-i-click), [`.set`](https://jakehow.github.io/rails-jsdom/RailsJsdom/Element.html#method-i-set), [`.text`](https://jakehow.github.io/rails-jsdom/RailsJsdom/Element.html#method-i-text), [`.value`](https://jakehow.github.io/rails-jsdom/RailsJsdom/Element.html#method-i-value), [`.[]`](https://jakehow.github.io/rails-jsdom/RailsJsdom/Element.html#method-i-5B-5D), [`.checked?`](https://jakehow.github.io/rails-jsdom/RailsJsdom/Element.html#method-i-checked-3F), [`.selected?`](https://jakehow.github.io/rails-jsdom/RailsJsdom/Element.html#method-i-selected-3F), [`.disabled?`](https://jakehow.github.io/rails-jsdom/RailsJsdom/Element.html#method-i-disabled-3F), [`.visible?`](https://jakehow.github.io/rails-jsdom/RailsJsdom/Element.html#method-i-visible-3F), [`.send_keys`](https://jakehow.github.io/rails-jsdom/RailsJsdom/Element.html#method-i-send_keys), [`.select_option`](https://jakehow.github.io/rails-jsdom/RailsJsdom/Element.html#method-i-select_option), [`.hover`](https://jakehow.github.io/rails-jsdom/RailsJsdom/Element.html#method-i-hover) | ✅ | Plus the finders and assertions inside the element; using an element that left the page raises |
|
|
188
|
+
| [`page`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-page) | ✅ | Every helper, plus [`body`](https://jakehow.github.io/rails-jsdom/RailsJsdom/Page.html#method-i-body) and [`title`](https://jakehow.github.io/rails-jsdom/RailsJsdom/Page.html#method-i-title) |
|
|
189
|
+
| [`assert_text`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-assert_text), [`assert_no_text`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-assert_no_text), [`refute_text`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-refute_text) | ✅ | String or Regexp |
|
|
190
|
+
| [`assert_selector`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-assert_selector), [`assert_css`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-assert_css) | ✅ | Counts visible elements, as in system tests; `count:`, `minimum:`, `maximum:` |
|
|
191
|
+
| [`assert_no_selector`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-assert_no_selector), [`assert_no_css`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-assert_no_css), [`refute_selector`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-refute_selector) | ✅ | |
|
|
192
|
+
| [`assert_field`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-assert_field), [`assert_no_field`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-assert_no_field) | ✅ | |
|
|
193
|
+
| [`assert_checked_field`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-assert_checked_field), [`assert_unchecked_field`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-assert_unchecked_field) | ✅ | |
|
|
194
|
+
| [`assert_button`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-assert_button), [`assert_no_button`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-assert_no_button) | ✅ | Disabled buttons match only with `disabled:` |
|
|
195
|
+
| [`assert_link`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-assert_link), [`assert_no_link`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-assert_no_link) | ✅ | |
|
|
196
|
+
| [`assert_current_path`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-assert_current_path), [`assert_no_current_path`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-assert_no_current_path) | ✅ | `ignore_query:`, `url:`, Regexp |
|
|
197
|
+
| [`assert_title`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-assert_title), [`assert_no_title`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-assert_no_title) | ✅ | |
|
|
198
|
+
| [`has_text?`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-has_text-3F), [`has_content?`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-has_content-3F), [`has_no_text?`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-has_no_text-3F), [`has_no_content?`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-has_no_content-3F) | ✅ | |
|
|
199
|
+
| [`has_selector?`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-has_selector-3F), [`has_css?`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-has_css-3F), [`has_no_selector?`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-has_no_selector-3F), [`has_no_css?`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-has_no_css-3F) | ✅ | |
|
|
200
|
+
| [`has_field?`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-has_field-3F), [`has_no_field?`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-has_no_field-3F), [`has_checked_field?`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-has_checked_field-3F), [`has_unchecked_field?`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-has_unchecked_field-3F) | ✅ | |
|
|
201
|
+
| [`has_button?`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-has_button-3F), [`has_no_button?`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-has_no_button-3F), [`has_link?`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-has_link-3F), [`has_no_link?`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-has_no_link-3F) | ✅ | |
|
|
202
|
+
| [`has_current_path?`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-has_current_path-3F), [`has_no_current_path?`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-has_no_current_path-3F), [`has_title?`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-has_title-3F), [`has_no_title?`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-has_no_title-3F) | ✅ | |
|
|
203
|
+
| [`current_path`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-current_path), [`current_url`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-current_url) | ✅ | |
|
|
204
|
+
| [`evaluate_script`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-evaluate_script) | ✅ | Awaits a returned promise |
|
|
205
|
+
| [`execute_script`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-execute_script) | ✅ | |
|
|
206
|
+
| [`accept_alert`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-accept_alert) | ✅ | An unexpected alert fails the test |
|
|
207
|
+
| [`accept_confirm`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-accept_confirm), [`dismiss_confirm`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-dismiss_confirm) | ✅ | An unexpected confirm fails the test |
|
|
208
|
+
| [`accept_prompt`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-accept_prompt), [`dismiss_prompt`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-dismiss_prompt) | ✅ | An unexpected prompt fails the test |
|
|
209
|
+
| [`hover`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-hover), [`double_click`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-double_click), [`right_click`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-right_click) | ✅ | Element methods in system tests; here they also take a locator. CSS `:hover` styles do not apply |
|
|
210
|
+
| `within_frame` | ❌ | Not planned: real iframes are mostly cross-origin; Turbo Frames already work |
|
|
211
|
+
| `within_window`, `switch_to_window` | ❌ | Not planned: check a `target="_blank"` link with `assert_link`, then `visit` it |
|
|
212
|
+
| `drag_to` | ❌ | Never: drag-and-drop needs layout, which jsdom does not have |
|
|
213
|
+
| `take_screenshot` | ❌ | Never: jsdom does not render pages |
|
|
214
|
+
| Waiting (`default_max_wait_time`, retrying assertions) | — | Not needed: the page settles before every action and assertion |
|
|
215
|
+
|
|
216
|
+
### Only in rails-jsdom
|
|
217
|
+
|
|
218
|
+
| Method | What it does |
|
|
219
|
+
| --- | --- |
|
|
220
|
+
| [`click`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-click) | Clicks any element by locator, including Stimulus action shorthand (`click "dropdown#toggle"`) |
|
|
221
|
+
| [`mount_partial`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-mount_partial) | Renders a partial and loads it as a page, with no route |
|
|
222
|
+
| [`mount_html`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-mount_html) | Loads an HTML fragment as a page |
|
|
223
|
+
| [`stub_fetch`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-stub_fetch) | Answers a page request without Rails |
|
|
224
|
+
| [`page_text`](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html#method-i-page_text) | The visible text of the page or the current `within` scope |
|
|
225
|
+
|
|
226
|
+
`assert_dom`, `assert_select` and `css_select` work as in any Rails test and read the live page.
|
|
227
|
+
|
|
228
|
+
## Turbo Stream broadcasts
|
|
229
|
+
|
|
230
|
+
Pages that subscribe with `turbo_stream_from` receive broadcasts made during the test:
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
test "a new comment appears for everyone watching the task" do
|
|
234
|
+
visit task_path(tasks(:open))
|
|
235
|
+
perform_enqueued_jobs { Comment.create!(task: tasks(:open), body: "Looks good") }
|
|
236
|
+
|
|
237
|
+
assert_text "Looks good"
|
|
238
|
+
end
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
After each action (and before each assertion), the runner finds the page's `<turbo-cable-stream-source>` elements, verifies each signed stream name with `Turbo::StreamsChannel.verified_stream_name`, reads the broadcasts recorded by the ActionCable `test` adapter, and applies new Turbo Stream messages with `Turbo.renderStreamMessage`. Broadcasts made before the page subscribed are skipped, forged stream names receive nothing, and each broadcast is applied once. `broadcast_*_later` broadcasts arrive when the test performs the jobs.
|
|
242
|
+
|
|
243
|
+
Your test environment must use the ActionCable `test` adapter (`config/cable.yml`: `test: adapter: test`), which is the Rails default.
|
|
244
|
+
|
|
245
|
+
## Timers and settling
|
|
246
|
+
|
|
247
|
+
`setTimeout`, `setInterval` and `requestAnimationFrame` run on a virtual clock. After each action the runner keeps running due timers and waits for pending Rails requests until nothing is due within a one-second horizon. Timers further out — polling intervals, auto-dismissing flashes — stay pending, so pages never loop forever. A test does not need, and should not use, sleeps or waits.
|
|
248
|
+
|
|
249
|
+
## Continuous integration
|
|
250
|
+
|
|
251
|
+
CI needs Node and your app's npm packages before the tests run:
|
|
252
|
+
|
|
253
|
+
```yaml
|
|
254
|
+
- uses: actions/setup-node@v4
|
|
255
|
+
with:
|
|
256
|
+
node-version: 22
|
|
257
|
+
- run: npm ci
|
|
258
|
+
- run: bin/rails test
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Each parallel test worker starts its own Node process the first time it runs an interaction test.
|
|
262
|
+
|
|
263
|
+
## Troubleshooting
|
|
264
|
+
|
|
265
|
+
- **Node output and page console messages** go to `Rails.logger` at info level, prefixed with `[rails_jsdom]`, so they appear wherever your test environment logs (by default `log/test.log`).
|
|
266
|
+
- **`Cannot find module 'jsdom'`**: run `npm install --save-dev jsdom esbuild` in the app (and `npm ci` in CI).
|
|
267
|
+
- **"The RailsJsdom Node process exited"**: see the `[rails_jsdom]` lines in your Rails log; the next command starts a new process.
|
|
268
|
+
- **An exception in your Rails code while answering a page request** fails the test with that exception; the Node process is restarted for the next command.
|
|
269
|
+
- **An element is not found**: `assert_dom` your expectation first, check the [locator rules](https://jakehow.github.io/rails-jsdom/RailsJsdom/TestHelper.html), or scope with `within`.
|
|
270
|
+
|
|
271
|
+
## Limitations
|
|
272
|
+
|
|
273
|
+
See [what you lose](#trade-offs-compared-with-system-tests). In addition: Stimulus outlets are untested, and pages that depend on browser APIs jsdom does not implement need a small stub in your controller or test.
|
|
274
|
+
|
|
275
|
+
## Development
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
bundle install
|
|
279
|
+
npm install
|
|
280
|
+
bundle exec rake test
|
|
281
|
+
bundle exec rubocop
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
`bundle exec rake test` runs, in order:
|
|
285
|
+
|
|
286
|
+
1. ESLint over all JavaScript (`bundle exec rake lint_js`).
|
|
287
|
+
2. The Node tests for the runtime and the ESLint rule (`bundle exec rake test_runner`).
|
|
288
|
+
3. The Ruby tests.
|
|
289
|
+
|
|
290
|
+
CI runs the same, plus RuboCop.
|
|
291
|
+
|
|
292
|
+
This repository's own `.npmrc` sets `min-release-age=7` too, so `npm install` here skips releases from the last 7 days. Use npm 11.10 or later when you change `package.json`.
|
|
293
|
+
|
|
294
|
+
### How the pieces fit
|
|
295
|
+
|
|
296
|
+
- The Ruby side (`lib/rails_jsdom`) runs in the test process. The Node side (`lib/rails_jsdom/runtime.js`) runs the pages in jsdom.
|
|
297
|
+
- They talk over a line protocol, documented in **[Runtime protocol](https://jakehow.github.io/rails-jsdom/docs/protocol_md.html)** ([source](docs/protocol.md)). Update that page whenever a command, field or reply changes.
|
|
298
|
+
|
|
299
|
+
### Tests
|
|
300
|
+
|
|
301
|
+
The library is tested against a small Rails app in `test/dummy`, with importmap, Turbo and Stimulus.
|
|
302
|
+
|
|
303
|
+
When you add or change a helper, add its real usage to the dummy app and cover it with interaction tests in `test/interactions`:
|
|
304
|
+
|
|
305
|
+
- Put the markup it acts on in a dummy page (`test/dummy/app/views`), with a route and controller action if it needs one. Prefer what a real app has: Rails form helpers, Turbo forms and links, Stimulus controllers.
|
|
306
|
+
- Add one variant to the page for each behaviour that differs: each option and filter, each kind of element it matches, Turbo and non-Turbo forms, and the cases that must fail, such as no match, several matches, a disabled field or an unexpected dialog.
|
|
307
|
+
- Write one interaction test per variant against the real page, and check the outcome Rails or the page sees, such as submitted params, the current path or the DOM.
|
|
308
|
+
- Keep the unit tests in `test/rails_jsdom` for code that does not need a page.
|
|
309
|
+
|
|
310
|
+
The Node runtime has its own unit tests in `test/runner/*.test.js`, written with Node's built-in test runner:
|
|
311
|
+
|
|
312
|
+
- They drive the runtime only through its public interface, the line protocol. `test/runner/support.js` plays the Ruby side and answers the runtime's requests to Rails.
|
|
313
|
+
- They bundle a small fixture app in `test/runner/fixtures`, with stand-ins for Stimulus and Turbo.
|
|
314
|
+
- Write them in the same style: no reaching into private functions.
|
|
315
|
+
|
|
316
|
+
### Standards
|
|
317
|
+
|
|
318
|
+
These are enforced by `rake test` and RuboCop, and CI fails if any of them fails.
|
|
319
|
+
|
|
320
|
+
- **Coverage:**
|
|
321
|
+
- The Ruby code is held at 100% of lines and branches (SimpleCov).
|
|
322
|
+
- `runtime.js` and the ESLint rule are held at 100% of lines, branches and functions (Node's test coverage).
|
|
323
|
+
- Code that cannot be tested is deleted.
|
|
324
|
+
- **Comments:**
|
|
325
|
+
- Only API documentation is allowed. In Ruby that means RDoc: a comment block directly above a class, module, method, constant, attribute, alias or delegation, an RDoc directive such as `:nodoc:`, or a `##` block for a generated method. In JavaScript it means a `/** */` doc comment directly above a declaration.
|
|
326
|
+
- Every other comment fails the build. Put the meaning into a name, a method or a test.
|
|
327
|
+
- Directives that switch a cop off (`# rubocop:disable`, `// eslint-disable`) are not allowed either.
|
|
328
|
+
- **API documentation:**
|
|
329
|
+
- Every public class, module, method and constant has RDoc. `bundle exec rdoc -C lib` reports 100%.
|
|
330
|
+
- The API reference is built with `bundle exec rake rdoc` and published to GitHub Pages.
|
|
331
|
+
- **Readable code (Ruby):**
|
|
332
|
+
- Implicit returns, with no `return` and no explicit `return nil`.
|
|
333
|
+
- `unless` only as a one-line modifier with a predicate method.
|
|
334
|
+
- No boolean flags set more than once. No ternaries with a `nil`, `true` or `false` branch. No `next unless`. No `.nil?` as a condition.
|
|
335
|
+
- Bang methods start with a verb, and predicate methods return booleans.
|
|
336
|
+
- Blocks are named (`&block`), not forwarded anonymously.
|
|
337
|
+
- These cops live in `rubocop/cop/readability`, with their tests in `test/rubocop`.
|
|
338
|
+
- **Size and complexity:**
|
|
339
|
+
- Ruby: methods up to 10 lines, AbcSize 13, cyclomatic and perceived complexity 5, and one level of block nesting.
|
|
340
|
+
- JavaScript: complexity 8, 25 lines and 16 statements per function, nesting depth 2, 4 parameters, and no nested ternaries.
|
|
341
|
+
- When code goes over a limit, remove the complexity: give a concept its own method or class, or split a function by the questions it answers. Never shorten lines with compressed or unusual syntax. A clear method slightly over a limit is better than a contorted one under it.
|
|
342
|
+
- **Style:**
|
|
343
|
+
- Ruby follows rubocop-rails-omakase and rubocop-minitest, with RuboCop's Lint and Gemspec departments.
|
|
344
|
+
- Write `case` with each `when` body on its own line.
|
|
345
|
+
- JavaScript: double quotes, no semicolons, two-space indentation, `const` over `let`, and `===`.
|
|
346
|
+
|
|
347
|
+
Releases are published to RubyGems from GitHub Actions with trusted publishing when a `v*` tag is pushed.
|
|
348
|
+
|
|
349
|
+
## License
|
|
350
|
+
|
|
351
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
require "erb"
|
|
2
|
+
require "json"
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "rails/generators"
|
|
5
|
+
|
|
6
|
+
module RailsJsdom
|
|
7
|
+
# Rails generators for rails-jsdom.
|
|
8
|
+
module Generators
|
|
9
|
+
# Sets an app up for rails-jsdom interaction tests:
|
|
10
|
+
#
|
|
11
|
+
# bin/rails generate rails_jsdom:install
|
|
12
|
+
#
|
|
13
|
+
# Pass `--skip-npm` to add the Node packages to `package.json` without
|
|
14
|
+
# running `npm install`.
|
|
15
|
+
class InstallGenerator < Rails::Generators::Base
|
|
16
|
+
# The Node packages the app needs. Each range allows newer minor and patch
|
|
17
|
+
# releases but never a new major version.
|
|
18
|
+
PACKAGES = { "jsdom" => "^29.1.1", "esbuild" => "^0.28.2" }.freeze
|
|
19
|
+
|
|
20
|
+
# How many days old a package version must be before npm installs it.
|
|
21
|
+
RELEASE_AGE_DAYS = 7
|
|
22
|
+
|
|
23
|
+
# The first npm version that understands `min-release-age`.
|
|
24
|
+
RELEASE_AGE_NPM = Gem::Version.new("11.10.0")
|
|
25
|
+
|
|
26
|
+
class_option :skip_npm, type: :boolean, default: false, desc: "Add jsdom and esbuild to package.json without running npm install"
|
|
27
|
+
|
|
28
|
+
# Requires the library in `test/test_helper.rb`, after `rails/test_help`.
|
|
29
|
+
def require_library
|
|
30
|
+
inject_into_file "test/test_helper.rb", "require \"rails_jsdom\"\n", after: %r{require ["']rails/test_help["']\n}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Creates `test/interactions`, where interaction tests live.
|
|
34
|
+
def create_interactions_directory
|
|
35
|
+
create_file "test/interactions/.keep", ""
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Adds jsdom and esbuild to the development dependencies in
|
|
39
|
+
# `package.json`, creating the file if the app has none. Versions the
|
|
40
|
+
# app already lists are kept.
|
|
41
|
+
def add_node_packages
|
|
42
|
+
package = existing_package || { "private" => true }
|
|
43
|
+
package["devDependencies"] = PACKAGES.merge(package.fetch("devDependencies", {}))
|
|
44
|
+
create_file "package.json", "#{JSON.pretty_generate(package)}\n", force: true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Sets `min-release-age` in `.npmrc`, so npm installs only package
|
|
48
|
+
# versions published at least RELEASE_AGE_DAYS days ago. A compromised
|
|
49
|
+
# release of jsdom, esbuild or anything they depend on is usually found
|
|
50
|
+
# and removed within that time. An existing `min-release-age` is kept.
|
|
51
|
+
def delay_new_releases
|
|
52
|
+
settings = npm_settings
|
|
53
|
+
create_file ".npmrc", "#{settings}min-release-age=#{RELEASE_AGE_DAYS}\n", force: true unless settings.match?(/^min-release-age\s*=/)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Warns when the app's npm is older than 11.10, which ignores
|
|
57
|
+
# `min-release-age`.
|
|
58
|
+
def check_npm_version
|
|
59
|
+
say_status :warning, "npm #{npm_version} ignores min-release-age, so it would install brand-new releases. Upgrade to npm #{RELEASE_AGE_NPM} or later.", :yellow if npm_version < RELEASE_AGE_NPM
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Runs `npm install`, unless `--skip-npm` is given.
|
|
63
|
+
def install_node_packages
|
|
64
|
+
run "npm install" unless options.skip_npm?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Adds `/node_modules` to `.gitignore`, unless it is already ignored.
|
|
68
|
+
def ignore_node_modules
|
|
69
|
+
gitignore = path(".gitignore")
|
|
70
|
+
append_to_file ".gitignore", "\n/node_modules\n" if File.exist?(gitignore) && !File.read(gitignore).include?("node_modules")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Warns when the test environment does not use the ActionCable `test`
|
|
74
|
+
# adapter, which Turbo Stream broadcasts need.
|
|
75
|
+
def check_cable_adapter
|
|
76
|
+
say_status :warning, "Set `adapter: test` for the test environment in config/cable.yml so pages receive Turbo Stream broadcasts", :yellow unless test_cable_adapter?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def npm_settings
|
|
82
|
+
settings = File.exist?(path(".npmrc")) ? File.read(path(".npmrc")) : ""
|
|
83
|
+
settings.empty? || settings.end_with?("\n") ? settings : "#{settings}\n"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def npm_version
|
|
87
|
+
@npm_version ||= Gem::Version.new(run("npm --version", capture: true, verbose: false).to_s.strip)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def existing_package
|
|
91
|
+
JSON.parse(File.read(path("package.json"))) if File.exist?(path("package.json"))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def test_cable_adapter?
|
|
95
|
+
cable = path("config/cable.yml")
|
|
96
|
+
File.exist?(cable) && cable_config(cable).dig("test", "adapter") == "test"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def cable_config(file)
|
|
100
|
+
YAML.safe_load(ERB.new(File.read(file)).result, aliases: true)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def path(relative)
|
|
104
|
+
File.join(destination_root, relative)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
module RailsJsdom
|
|
2
|
+
class Bridge # :nodoc:
|
|
3
|
+
def initialize(command:, setup:, logger:)
|
|
4
|
+
@command = command
|
|
5
|
+
@setup = setup
|
|
6
|
+
@logger = logger
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(message, &on_fetch)
|
|
10
|
+
start unless started?
|
|
11
|
+
write(message)
|
|
12
|
+
receive(&on_fetch)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def started?
|
|
16
|
+
!!@input
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def reset
|
|
20
|
+
call(command: "reset") if started?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def start
|
|
26
|
+
@input, @output, error, @process = Open3.popen3(*@command)
|
|
27
|
+
@log = Thread.new { log(error) }
|
|
28
|
+
write(@setup)
|
|
29
|
+
receive
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def log(error)
|
|
33
|
+
error.each_line { |line| @logger.info("[rails_jsdom] #{line.chomp}") }
|
|
34
|
+
error.close
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def stop
|
|
38
|
+
@input.close
|
|
39
|
+
@output.close
|
|
40
|
+
Process.kill("TERM", @process.pid) if @process&.alive?
|
|
41
|
+
@log.join
|
|
42
|
+
@input = nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def receive(&on_fetch)
|
|
46
|
+
message = read
|
|
47
|
+
case message.fetch("type")
|
|
48
|
+
when "fetch"
|
|
49
|
+
write(type: "fetch_response", id: message.fetch("id"), response: answer(message.fetch("request"), &on_fetch))
|
|
50
|
+
receive(&on_fetch)
|
|
51
|
+
when "error"
|
|
52
|
+
raise Error, message.fetch("message")
|
|
53
|
+
else
|
|
54
|
+
message.fetch("value")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def answer(request, &on_fetch)
|
|
59
|
+
on_fetch.call(request)
|
|
60
|
+
rescue StandardError
|
|
61
|
+
stop
|
|
62
|
+
raise
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def read
|
|
66
|
+
line = @output.gets
|
|
67
|
+
if line
|
|
68
|
+
JSON.parse(line)
|
|
69
|
+
else
|
|
70
|
+
stop
|
|
71
|
+
raise Error, "The RailsJsdom Node process exited. See the [rails_jsdom] lines in the Rails log."
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def write(payload)
|
|
76
|
+
@input.puts(JSON.generate(payload))
|
|
77
|
+
@input.flush
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|