capybara-simulated 0.0.7 → 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 +4 -4
- data/README.md +303 -158
- data/lib/capybara/simulated/asset_cache.rb +232 -0
- data/lib/capybara/simulated/browser.rb +3409 -845
- data/lib/capybara/simulated/driver.rb +341 -134
- data/lib/capybara/simulated/errors.rb +9 -5
- data/lib/capybara/simulated/js/bridge.bundle.js +19409 -0
- data/lib/capybara/simulated/js/snapshot_stubs.js +110 -0
- data/lib/capybara/simulated/node.rb +151 -163
- data/lib/capybara/simulated/quickjs_runtime.rb +424 -0
- data/lib/capybara/simulated/runtime_shared.rb +183 -0
- data/lib/capybara/simulated/script_cache.rb +168 -0
- data/lib/capybara/simulated/sourcemap.rb +119 -0
- data/lib/capybara/simulated/stack_resolver.rb +97 -0
- data/lib/capybara/simulated/trace.rb +111 -0
- data/lib/capybara/simulated/v8_runtime.rb +987 -0
- data/lib/capybara/simulated/version.rb +3 -1
- data/lib/capybara/simulated/webauthn_state.rb +367 -0
- data/lib/capybara/simulated/whitespace_normalizer.rb +45 -0
- data/lib/capybara/simulated/worker_runtime.rb +30 -0
- data/lib/capybara/simulated.rb +31 -4
- data/lib/capybara-simulated.rb +2 -0
- data/vendor/js/vendor.bundle.js +13 -0
- metadata +24 -32
- data/vendor/esbuild-wasm/LICENSE.md +0 -21
- data/vendor/esbuild-wasm/bin/esbuild +0 -91
- data/vendor/esbuild-wasm/esbuild.wasm +0 -0
- data/vendor/esbuild-wasm/lib/main.js +0 -2337
- data/vendor/esbuild-wasm/wasm_exec.js +0 -575
- data/vendor/esbuild-wasm/wasm_exec_node.js +0 -40
- data/vendor/js/bundle-modules.mjs +0 -168
- data/vendor/js/csim.bundle.js +0 -91560
- data/vendor/js/entry.mjs +0 -23
- data/vendor/js/prelude.js +0 -190
- data/vendor/js/runtime.js +0 -2208
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c645fc942083807ecc024135e4bde092fa7859628346a14bac9d4423ab04a99f
|
|
4
|
+
data.tar.gz: b52e0d0c5ce4755653e457e25abcaa0e12735edb25f98e89ebd73f666617088b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b82c3e7db9b6024a4eaa3fae041083a6b298d72a6be826412b9b2825622ae6e02d2eff73ef864f33546bf6f763ace0da0e1c24c10dfe4255808a1447fc952293
|
|
7
|
+
data.tar.gz: 3da1582f39042d4f9c4789aee9db645318eccb7f0ef0393b4d39045cf3e94163459b51ba75f9a029716e0951532a9d056a711ccf78b9c73b752cd256cef46802
|
data/README.md
CHANGED
|
@@ -1,123 +1,69 @@
|
|
|
1
1
|
# capybara-simulated
|
|
2
2
|
|
|
3
|
-
A lightweight Capybara driver that runs JavaScript
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
3
|
+
A lightweight Capybara driver that runs JavaScript against an
|
|
4
|
+
in-process JS-resident DOM, with no Chrome. Forms submit through
|
|
5
|
+
`Rack::MockRequest`, inline `<script>` and event handlers run,
|
|
6
|
+
MutationObserver / custom elements / `<template>` / Shadow DOM /
|
|
7
|
+
Trix / Stimulus / Turbo all work, and the Capybara DSL is unchanged.
|
|
8
|
+
|
|
9
|
+
The DOM lives entirely inside the JS engine — V8 via
|
|
10
|
+
[rusty_racer](https://github.com/ursm/rusty_racer) or QuickJS via
|
|
11
|
+
[quickjs.rb](https://github.com/hmsk/quickjs.rb), whichever is
|
|
12
|
+
installed — with no Nokogiri tree on the Ruby side. Capybara finds
|
|
13
|
+
resolve through css-select (CSS) and xpathway (XPath) running in the
|
|
14
|
+
same context as the page's JS, so `find` / `has_css?` / `within` see
|
|
15
|
+
exactly the tree the app sees.
|
|
12
16
|
|
|
13
17
|
## Status
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
intentionally does not implement:
|
|
24
|
-
|
|
25
|
-
- 19 `#drag_to` tests — Dragula / SortableJS / jsTree resolve drop
|
|
26
|
-
targets through `elementFromPoint(clientX, clientY)`, which needs a
|
|
27
|
-
real layout engine with stacking-context awareness.
|
|
28
|
-
- 1 `#click should not retry clicking when wait is disabled` — depends
|
|
29
|
-
on the same `elementFromPoint`-based obscured-element detection.
|
|
30
|
-
- 2 unrelated upstream-pending specs.
|
|
31
|
-
|
|
32
|
-
`evaluate_async_script` is supported by polling the Ruby↔V8 bridge while
|
|
33
|
-
draining the virtual clock until the user callback fires (or
|
|
34
|
-
`Capybara.default_max_wait_time` elapses). Click offsets and `Element#drop`
|
|
35
|
-
work without a real layout engine: click x/y are resolved into clientX/Y
|
|
36
|
-
by walking computed `top`/`left`/`width`/`height` (px only, ancestor-sum)
|
|
37
|
-
on the way out of the runtime.
|
|
38
|
-
|
|
39
|
-
### Turbo + Stimulus (Rails)
|
|
40
|
-
|
|
41
|
-
The driver targets Rails apps using importmap-rails + Turbo + Stimulus
|
|
42
|
-
out of the box:
|
|
43
|
-
|
|
44
|
-
- `<script type="importmap">` is parsed; `<script type="module">` and
|
|
45
|
-
every reachable `import` are pre-fetched through the Rack app and
|
|
46
|
-
bundled on the fly via `node vendor/js/bundle-modules.mjs` (a small
|
|
47
|
-
esbuild driver). The resulting IIFE is evaluated in the same isolate
|
|
48
|
-
as inline scripts and shares the same happy-dom Window.
|
|
49
|
-
- `fetch` is replaced with a Rack-routed implementation: cookies follow
|
|
50
|
-
the jar, `X-CSRF-Token` propagates, redirects are followed up to 20
|
|
51
|
-
hops, and `Response`/`Headers`/`Request`/`AbortController` come from
|
|
52
|
-
happy-dom. WebSocket / EventSource / Action Cable broadcasts are out
|
|
53
|
-
of scope — use Selenium for those flows.
|
|
54
|
-
- Custom-element upgrade is patched at `customElements.define` to
|
|
55
|
-
rebuild the document's id-element index — happy-dom 20's auto-upgrade
|
|
56
|
-
leaves the original (un-upgraded) element in the index, so
|
|
57
|
-
`getElementById('records')` after a Turbo Stream `<turbo-frame>` swap
|
|
58
|
-
would otherwise return a detached ghost.
|
|
59
|
-
- Click on `<button type="submit">` lets happy-dom auto-dispatch the
|
|
60
|
-
`submit` event with the proper `submitter` field; we capture whether
|
|
61
|
-
the event was preventDefaulted and skip our own redundant submit
|
|
62
|
-
dispatch. Without this, Turbo's `FormSubmitObserver` saw a second
|
|
63
|
-
submitter-less event and intercepted forms whose submitter had
|
|
64
|
-
`data-turbo="false"`.
|
|
65
|
-
- Page-level globals (`addEventListener`, `MutationObserver`,
|
|
66
|
-
`requestAnimationFrame`, `CustomEvent`, `getComputedStyle`,
|
|
67
|
-
`IntersectionObserver` stub, etc.) are mirrored from the active
|
|
68
|
-
Window onto `globalThis` so module bundles running at the top level
|
|
69
|
-
find them without going through `window.*`.
|
|
70
|
-
- happy-dom's `MutationObserverListener` keeps its dispatch callback in
|
|
71
|
-
a `WeakRef`, with no other strong reference. V8 collects the arrow
|
|
72
|
-
before the next mutation fires, so `target[mutationListeners]` still
|
|
73
|
-
carries the listener but `callback.deref()` returns undefined and
|
|
74
|
-
every record (and every subtree-propagation hop on `appendChild`) is
|
|
75
|
-
silently dropped — Stimulus loses sight of buttons added inside a
|
|
76
|
-
swapped `<turbo-frame>`. We patch `MutationObserver.prototype.observe`
|
|
77
|
-
per Window to swap each WeakRef out for a strong-reference shim with
|
|
78
|
-
the same `.deref()` shape, so listeners survive the next GC.
|
|
79
|
-
- happy-dom 20's `Attr` class doesn't override `Node.nodeValue`, which
|
|
80
|
-
the [DOM spec](https://dom.spec.whatwg.org/#dom-node-nodevalue)
|
|
81
|
-
defines as the attribute's `value`. The default getter inherited
|
|
82
|
-
from `Node` returns `null`, so any XPath engine reading attribute
|
|
83
|
-
string-values via `nodeValue` (wgxpath included) collapses every
|
|
84
|
-
attribute compare to `"null" === "null"` and predicates like
|
|
85
|
-
`[@id = //label/@for]` match every element with any `@id`. We install
|
|
86
|
-
a per-Window `Attr.prototype.nodeValue` getter / setter that mirrors
|
|
87
|
-
`value`.
|
|
88
|
-
|
|
89
|
-
WebSocket, frames and multi-window remain explicitly out of scope — they
|
|
90
|
-
need a real browser (Selenium / Cuprite) or a separate transport that
|
|
91
|
-
this driver does not provide.
|
|
92
|
-
|
|
93
|
-
The V8 isolate is created once per `Capybara::Simulated::Browser` instance
|
|
94
|
-
and reused across all visits and resets — only the happy-dom `Window` is
|
|
95
|
-
torn down between specs. This keeps `reset!` cheap.
|
|
96
|
-
|
|
97
|
-
## Build
|
|
19
|
+
The architecture and behaviour are stable. Correctness is held to two
|
|
20
|
+
bars. A vendored subset of
|
|
21
|
+
[web-platform-tests](https://github.com/web-platform-tests/wpt) — the
|
|
22
|
+
same DOM / HTML tests Chromium and Firefox hold themselves to — runs as
|
|
23
|
+
a conformance gate. And each target app (Redmine / Forem / Avo /
|
|
24
|
+
Mastodon / Discourse) runs its full system suite against the driver in
|
|
25
|
+
[capybara-simulated-vs-world](https://github.com/ursm/capybara-simulated-vs-world)
|
|
26
|
+
as an integration check.
|
|
98
27
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
bundle exec rspec
|
|
104
|
-
```
|
|
28
|
+
The remaining gaps need a real layout engine (`elementFromPoint`,
|
|
29
|
+
truthy `getBoundingClientRect`, viewport-clip visibility, `display:
|
|
30
|
+
contents` table edge cases) — the same set Selenium escapes via
|
|
31
|
+
screenshots and this driver deliberately doesn't simulate.
|
|
105
32
|
|
|
106
33
|
## Install
|
|
107
34
|
|
|
108
|
-
|
|
35
|
+
```ruby
|
|
36
|
+
gem 'capybara-simulated', group: :test
|
|
37
|
+
gem 'rusty_racer', group: :test # JS engine — pick one
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`bundle install`. Requires Ruby ≥ 3.3. The gem ships its JS bridge
|
|
41
|
+
under `lib/capybara/simulated/js/` and the vendored JS deps under
|
|
42
|
+
`vendor/js/`, so there's no Node toolchain at consume time.
|
|
43
|
+
|
|
44
|
+
### JS engine
|
|
45
|
+
|
|
46
|
+
The gem treats the JS engine as a soft dependency. Pick one of:
|
|
109
47
|
|
|
110
48
|
```ruby
|
|
111
|
-
gem '
|
|
49
|
+
gem 'rusty_racer' # V8 (JIT, fastest per spec) — default
|
|
50
|
+
gem 'quickjs', '>= 0.18' # QuickJS (interpreter, smaller per-VM RAM —
|
|
51
|
+
# wins when scaling parallel workers under
|
|
52
|
+
# a fixed memory budget)
|
|
112
53
|
```
|
|
113
54
|
|
|
114
|
-
|
|
115
|
-
|
|
55
|
+
The V8 engine comes from [rusty_racer](https://github.com/ursm/rusty_racer),
|
|
56
|
+
a rusty_v8-based Ruby binding with the native ES Module API,
|
|
57
|
+
`ScriptCompiler::CachedData` snapshots, and per-frame realm contexts the
|
|
58
|
+
driver builds on.
|
|
59
|
+
|
|
60
|
+
The engine is auto-detected at boot; if both gems are present V8 wins.
|
|
61
|
+
Override explicitly with `CSIM_JS_ENGINE=v8|quickjs`
|
|
62
|
+
or `Capybara::Simulated::Driver.new(app, js_engine: :quickjs)`.
|
|
116
63
|
|
|
117
64
|
## Use
|
|
118
65
|
|
|
119
|
-
`require 'capybara/simulated'` registers the `:simulated` driver.
|
|
120
|
-
snippets below are minimal — drop them into your existing test bootstrap.
|
|
66
|
+
`require 'capybara/simulated'` registers the `:simulated` driver.
|
|
121
67
|
|
|
122
68
|
### RSpec
|
|
123
69
|
|
|
@@ -127,12 +73,12 @@ require 'capybara/rspec'
|
|
|
127
73
|
require 'capybara/simulated'
|
|
128
74
|
|
|
129
75
|
Capybara.javascript_driver = :simulated
|
|
130
|
-
# Optional:
|
|
76
|
+
# Optional: use :simulated for non-JS specs too.
|
|
131
77
|
# Capybara.default_driver = :simulated
|
|
132
78
|
```
|
|
133
79
|
|
|
134
|
-
Tests tagged `js: true` (or `type: :system, js: true` in Rails)
|
|
135
|
-
in the
|
|
80
|
+
Tests tagged `js: true` (or `type: :system, js: true` in Rails) run
|
|
81
|
+
in the driver:
|
|
136
82
|
|
|
137
83
|
```ruby
|
|
138
84
|
RSpec.describe 'sign-in', type: :system, js: true do
|
|
@@ -146,11 +92,9 @@ RSpec.describe 'sign-in', type: :system, js: true do
|
|
|
146
92
|
end
|
|
147
93
|
```
|
|
148
94
|
|
|
149
|
-
For
|
|
150
|
-
`driven_by`:
|
|
95
|
+
For Rails system tests, set the driver via `driven_by`:
|
|
151
96
|
|
|
152
97
|
```ruby
|
|
153
|
-
# spec/system/sign_in_spec.rb
|
|
154
98
|
RSpec.describe 'sign-in', type: :system do
|
|
155
99
|
before { driven_by :simulated }
|
|
156
100
|
# ...
|
|
@@ -160,8 +104,7 @@ end
|
|
|
160
104
|
### Minitest
|
|
161
105
|
|
|
162
106
|
`Capybara.javascript_driver` is RSpec-only — `ActionDispatch::SystemTestCase`
|
|
163
|
-
ignores it
|
|
164
|
-
mechanism. Set the driver explicitly:
|
|
107
|
+
ignores it. Set the driver explicitly:
|
|
165
108
|
|
|
166
109
|
```ruby
|
|
167
110
|
# test/application_system_test_case.rb
|
|
@@ -173,24 +116,6 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
|
|
|
173
116
|
end
|
|
174
117
|
```
|
|
175
118
|
|
|
176
|
-
(For pure `Capybara::Minitest::Test` outside Rails, set
|
|
177
|
-
`Capybara.default_driver = :simulated` in your test_helper.)
|
|
178
|
-
|
|
179
|
-
```ruby
|
|
180
|
-
# test/system/sign_in_test.rb
|
|
181
|
-
require 'application_system_test_case'
|
|
182
|
-
|
|
183
|
-
class SignInTest < ApplicationSystemTestCase
|
|
184
|
-
test 'logs the user in' do
|
|
185
|
-
visit '/login'
|
|
186
|
-
fill_in 'Email', with: 'alice@example.com'
|
|
187
|
-
fill_in 'Password', with: 'hunter2'
|
|
188
|
-
click_button 'Log in'
|
|
189
|
-
assert_text 'Welcome, Alice'
|
|
190
|
-
end
|
|
191
|
-
end
|
|
192
|
-
```
|
|
193
|
-
|
|
194
119
|
### Plain Capybara DSL (no framework)
|
|
195
120
|
|
|
196
121
|
```ruby
|
|
@@ -207,34 +132,254 @@ click_link 'About'
|
|
|
207
132
|
puts page.text
|
|
208
133
|
```
|
|
209
134
|
|
|
210
|
-
##
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
135
|
+
## Trace
|
|
136
|
+
|
|
137
|
+
Each Capybara action (`visit`, `click`, `set`, …) is recorded as a step
|
|
138
|
+
in a per-test trace: URL before / after, console output and network
|
|
139
|
+
requests during the step, plus elapsed and per-step durations. On
|
|
140
|
+
action failure (and only then, by default) the post-action DOM is
|
|
141
|
+
captured too.
|
|
142
|
+
|
|
143
|
+
Recording is **on by default** — fully in-memory, no files written
|
|
144
|
+
unless you opt in via `CSIM_TRACE_DIR`. Wall-time overhead is
|
|
145
|
+
run-to-run-variance equivalent because the expensive part — DOM
|
|
146
|
+
serialization — only fires on action error.
|
|
147
|
+
|
|
148
|
+
### Modes (`CSIM_TRACE=…`)
|
|
149
|
+
|
|
150
|
+
| value | recording | DOM snapshot |
|
|
151
|
+
|---|---|---|
|
|
152
|
+
| (unset) / `on-failure` | yes (default) | per step on action error only |
|
|
153
|
+
| `full` | yes | after every action — debug-heavy |
|
|
154
|
+
| `off` | nothing recorded, `record_action` early-exits | — |
|
|
155
|
+
|
|
156
|
+
### Inspecting traces
|
|
157
|
+
|
|
158
|
+
In an after-hook:
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
after(:each) do |example|
|
|
162
|
+
if example.exception
|
|
163
|
+
trace = page.driver.current_trace
|
|
164
|
+
puts trace.steps.last.dom_after # final-state HTML
|
|
165
|
+
puts trace.steps.flat_map(&:console).map {|c| "#{c[:severity]} #{c[:message]}" }
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### File output
|
|
171
|
+
|
|
172
|
+
Set `CSIM_TRACE_DIR=/path/to/dir` to enable file output. The bundled
|
|
173
|
+
RSpec hook ([`csim_rspec.rb`](https://github.com/ursm/capybara-simulated-vs-world/blob/main/support/csim_rspec.rb))
|
|
174
|
+
writes `<example slug>.json` into that directory after each test;
|
|
175
|
+
mirror it in `application_system_test_case.rb`'s teardown for
|
|
176
|
+
Minitest.
|
|
177
|
+
|
|
178
|
+
```sh
|
|
179
|
+
CSIM_TRACE_DIR=tmp/csim-traces bundle exec rspec spec/system
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The metadata block on each trace includes `title`, `file`, `outcome`
|
|
183
|
+
(`passed` / `failed`), and the exception message — enough to index a
|
|
184
|
+
CI artifact directory by failure.
|
|
185
|
+
|
|
186
|
+
### Programmatic
|
|
187
|
+
|
|
188
|
+
For finer control, call `driver.start_tracing(...)` /
|
|
189
|
+
`driver.stop_tracing(path: ...)`. The shape mirrors
|
|
190
|
+
`capybara-playwright-driver`:
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
RSpec.describe 'flaky payment flow', type: :system, js: true do
|
|
194
|
+
it 'completes a checkout' do
|
|
195
|
+
page.driver.start_tracing(case_id: 'PAY-1431')
|
|
196
|
+
visit '/checkout'
|
|
197
|
+
fill_in 'Card', with: '4242424242424242'
|
|
198
|
+
click_button 'Pay'
|
|
199
|
+
expect(page).to have_text 'Thank you'
|
|
200
|
+
ensure
|
|
201
|
+
page.driver.stop_tracing(path: "tmp/traces/#{example.full_description}.json")
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Trace JSON schema
|
|
207
|
+
|
|
208
|
+
```jsonc
|
|
209
|
+
{
|
|
210
|
+
"version": 1,
|
|
211
|
+
"metadata": { "title": "...", "outcome": "passed", "...": "..." },
|
|
212
|
+
"steps": [
|
|
213
|
+
{
|
|
214
|
+
"index": 0,
|
|
215
|
+
"kind": "visit", // visit / click / set / send_keys / select / submit / refresh / go_back / go_forward
|
|
216
|
+
"description": "visit /checkout",
|
|
217
|
+
"url_before": null,
|
|
218
|
+
"url_after": "http://www.example.com/checkout",
|
|
219
|
+
"dom_after": null, // populated only on action error or in `full` mode
|
|
220
|
+
"console": [{ "severity": "info", "message": "Stripe.js loaded" }],
|
|
221
|
+
"network": [{ "method": "GET", "url": "/checkout", "status": 200 }],
|
|
222
|
+
"elapsed_ms": 0,
|
|
223
|
+
"duration_ms": 38,
|
|
224
|
+
"error": null
|
|
225
|
+
}
|
|
226
|
+
]
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## Performance characteristics
|
|
231
|
+
|
|
232
|
+
The driver builds a base snapshot once per process — the bundled
|
|
233
|
+
bridge plus the vendored JS deps, as a V8 `Snapshot` for rusty_racer or
|
|
234
|
+
bytecode for QuickJS. On V8 that snapshot warms a single long-lived
|
|
235
|
+
isolate whose context is reset to a clean realm per navigation
|
|
236
|
+
(`Context#reset`); on QuickJS each navigation checks a freshly
|
|
237
|
+
snapshot-loaded VM out of a small pre-warmed pool. Either way, every
|
|
238
|
+
navigation lands on a clean, warm JS context near-instantly.
|
|
239
|
+
|
|
240
|
+
**Wall time is sensitive to whether the app uses Turbo Drive**,
|
|
241
|
+
because navigation simulates real-browser semantics:
|
|
242
|
+
|
|
243
|
+
| navigation source | what happens |
|
|
244
|
+
|---|---|
|
|
245
|
+
| `visit(...)`, `refresh`, programmatic `location.assign` | full reload — fresh JS Context, scripts re-evaluated |
|
|
246
|
+
| link click *with Turbo Drive loaded* | Turbo intercepts, body-swap via JS, **JS context preserved** |
|
|
247
|
+
| link click *without Turbo Drive* | full reload (anchor default action) |
|
|
248
|
+
| form submit *with Turbo Drive loaded* | Turbo intercepts (turbo-frame or page-level), body-swap |
|
|
249
|
+
| form submit *without Turbo Drive* | full reload |
|
|
250
|
+
|
|
251
|
+
So Turbo Drive apps stay fast even with click-heavy tests; non-Turbo
|
|
252
|
+
apps pay full-reload cost per click — exactly mirroring what the
|
|
253
|
+
production site does.
|
|
254
|
+
|
|
255
|
+
### Library snapshot policy
|
|
256
|
+
|
|
257
|
+
Per visit, `<script src>`-referenced libraries (jQuery, Stimulus,
|
|
258
|
+
…) re-evaluate fresh against the new page. They are **not** baked
|
|
259
|
+
into a per-app snapshot — preserving library state across page
|
|
260
|
+
navigations is what real browsers don't do, and trying to do it
|
|
261
|
+
broke `$.ready` Callbacks queues whose user-app callbacks
|
|
262
|
+
referenced page-specific DOM.
|
|
263
|
+
|
|
264
|
+
### Other factors
|
|
265
|
+
|
|
266
|
+
- **`<script src>` parsing** dominates `visit` on JS-heavy pages.
|
|
267
|
+
Each external script is fetched through the in-process Rack app,
|
|
268
|
+
compiled, and run in the JS engine with bytecode cache hits from
|
|
269
|
+
the base snapshot warmup.
|
|
270
|
+
- **CSS cascade resolution**: stylesheets are parsed once per distinct
|
|
271
|
+
set of sources and cached content-addressably, so repeat visits and
|
|
272
|
+
subsequent finds on the same page reuse the resolved cascade instead
|
|
273
|
+
of re-parsing.
|
|
274
|
+
- **DOM ops stay inside the JS engine** — find / has_? / event
|
|
275
|
+
dispatch never cross the Ruby ↔ JS boundary for the actual tree
|
|
276
|
+
walk; only the resulting handle ids do. Modify-heavy tests
|
|
277
|
+
(SortableJS dragging thousands of items) run at JS-engine speed,
|
|
278
|
+
not at host-call-IPC speed.
|
|
279
|
+
- **Polling** (Capybara `default_max_wait_time`) advances a *virtual*
|
|
280
|
+
JS clock — timers fire as polling steps the clock forward, not in
|
|
281
|
+
real time. A page that schedules `setTimeout(2000, x)` doesn't block
|
|
282
|
+
for 2 s; the callback fires once polling has advanced the clock past
|
|
283
|
+
it.
|
|
231
284
|
|
|
232
285
|
## Known limits
|
|
233
286
|
|
|
234
|
-
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
- `
|
|
239
|
-
|
|
240
|
-
|
|
287
|
+
- **No layout engine.** `visible?` and `Node#style` consult the CSS
|
|
288
|
+
cascade and the inline `style` attribute, but
|
|
289
|
+
`getBoundingClientRect()` returns zeros and `elementFromPoint()`
|
|
290
|
+
isn't implemented. Click offsets work for fixture-style absolute /
|
|
291
|
+
relative positioning (ancestor-summed `top`/`left`); position-via-
|
|
292
|
+
layout (Dragula drops, sticky-header scroll math) needs a real
|
|
293
|
+
browser.
|
|
294
|
+
- **`:hover` / `:focus-within`-gated content** is reachable two ways:
|
|
295
|
+
call `element.hover` explicitly (we track the most-recently-hovered
|
|
296
|
+
element and propagate `:hover` up its chain), or rely on the
|
|
297
|
+
candidate-chain fallback (when stateless cascade reports
|
|
298
|
+
`display: none`, we re-evaluate with the candidate itself in the
|
|
299
|
+
`:hover` set). Symmetric peers — N rows each with `tr:hover .icon`
|
|
300
|
+
revealing `.icon`, queried as bare `find('.icon')` — reveal all and
|
|
301
|
+
Capybara raises `Capybara::Ambiguous`. Scope the test (`find('tr',
|
|
302
|
+
text: 'foo').hover` then `find('.icon')`) — also more robust
|
|
303
|
+
against real-browser flake.
|
|
304
|
+
- **`fetch` is synchronous-via-Rack** — HTML / JSON round-trips work
|
|
305
|
+
but there's no real network, no streaming, no `Request#body`
|
|
306
|
+
ReadableStream, and no concurrent requests. XHR is implemented
|
|
307
|
+
with the same Rack pass-through.
|
|
308
|
+
- **Multi-window** is URL-tracking only — `target="_blank"` clicks
|
|
309
|
+
open a window-handle and `current_window` / `switch_to_window`
|
|
310
|
+
work, but each aux window only records its URL (no per-window JS
|
|
311
|
+
context or cross-window `postMessage`).
|
|
312
|
+
- **`within_frame`, WebSocket, screenshots, and drag pixel
|
|
313
|
+
coordinates** are out of scope — use Selenium / Cuprite. There's no
|
|
314
|
+
frame-switching DSL to drive a test into an `<iframe>`, though an
|
|
315
|
+
iframe's own scripts do run, in a per-frame JS realm. (EventSource
|
|
316
|
+
and Web Workers *are* implemented.)
|
|
317
|
+
|
|
318
|
+
## Architecture
|
|
319
|
+
|
|
320
|
+
- `lib/capybara/simulated/js/src/` — the entire DOM lives here, split
|
|
321
|
+
across ~50 ES modules bundled into `bridge.bundle.js` (esbuild; no
|
|
322
|
+
Node toolchain at consume time). `Document` / `Element` / `Text` /
|
|
323
|
+
`DocumentFragment` / `ShadowRoot` classes; event dispatch
|
|
324
|
+
(capture / target / bubble with shadow retargeting, via
|
|
325
|
+
`dispatchEvent(target, event)`); a virtual `setTimeout` /
|
|
326
|
+
`setInterval` / `requestAnimationFrame` clock; MutationObserver;
|
|
327
|
+
custom-element registry; `Range` / `Selection`; and the cascade
|
|
328
|
+
resolver for `display` / `visibility` / `text-transform`. Capybara's
|
|
329
|
+
finds run through the vendored css-select (with css-what / css-tree)
|
|
330
|
+
for CSS and xpathway for XPath — both true third parties under
|
|
331
|
+
`vendor/js/`, executing in the same context as the page's JS.
|
|
332
|
+
- `lib/capybara/simulated/browser.rb` — Rack client, history stack,
|
|
333
|
+
modal handler queue, virtual-clock anchor, trace recorder. Owns
|
|
334
|
+
the JS runtime via `V8Runtime` or `QuickJSRuntime`. The hot
|
|
335
|
+
operations (`find_css` / `find_xpath` / DOM ops / event dispatch)
|
|
336
|
+
are single-`Context#call` round-trips returning handle id arrays;
|
|
337
|
+
per-result iteration stays Ruby-side.
|
|
338
|
+
- `lib/capybara/simulated/v8_runtime.rb` / `quickjs_runtime.rb` —
|
|
339
|
+
per-engine wrappers, common bits in `runtime_shared.rb`. The V8
|
|
340
|
+
base-snapshot (and the QuickJS bytecode equivalent) bakes in the
|
|
341
|
+
bundled bridge + vendored deps, so a per-navigation context reset
|
|
342
|
+
(V8) or pooled VM checkout (QuickJS) is sub-millisecond.
|
|
343
|
+
- `lib/capybara/simulated/driver.rb` — Capybara `Driver::Base`
|
|
344
|
+
surface (visit / find / execute_script / window handling / modal /
|
|
345
|
+
tracing API).
|
|
346
|
+
- `lib/capybara/simulated/node.rb` — `Driver::Node` over a
|
|
347
|
+
`(handle_id, context_gen)` pair so a handle from a pre-rebuild
|
|
348
|
+
Context can't ghost into the next one.
|
|
349
|
+
|
|
350
|
+
## ES modules + importmap
|
|
351
|
+
|
|
352
|
+
`<script type="module">` and `<script type="importmap">` work the
|
|
353
|
+
same way they do in a real browser: bare specifiers resolve through
|
|
354
|
+
the importmap, relative paths resolve against the importer's URL,
|
|
355
|
+
and every load (including dynamic `import(...)`) routes back through
|
|
356
|
+
the in-process Rack app. No bundling step, no Node toolchain.
|
|
357
|
+
|
|
358
|
+
The standard importmap-rails layout works as-is:
|
|
359
|
+
|
|
360
|
+
```erb
|
|
361
|
+
<%= javascript_importmap_tags %>
|
|
362
|
+
<!-- emits:
|
|
363
|
+
<script type="importmap">{ "imports": { "application": "/assets/application-...js", ... } }</script>
|
|
364
|
+
<script type="module">import "application"</script>
|
|
365
|
+
-->
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
## Hotwire (Stimulus + Turbo)
|
|
369
|
+
|
|
370
|
+
Stimulus and Turbo work both via UMD (classic `<script src>`) and via
|
|
371
|
+
the standard ESM bundles imported through importmap. For
|
|
372
|
+
importmap-rails apps, no changes are needed:
|
|
373
|
+
|
|
374
|
+
```ruby
|
|
375
|
+
# config/importmap.rb
|
|
376
|
+
pin '@hotwired/stimulus'
|
|
377
|
+
pin '@hotwired/turbo'
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
`window.fetch` routes through Rack, so Turbo's frame fetch and
|
|
381
|
+
link-action POSTs round-trip the test app.
|
|
382
|
+
|
|
383
|
+
## License
|
|
384
|
+
|
|
385
|
+
[MIT](LICENSE).
|