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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +303 -158
  3. data/lib/capybara/simulated/asset_cache.rb +232 -0
  4. data/lib/capybara/simulated/browser.rb +3409 -845
  5. data/lib/capybara/simulated/driver.rb +341 -134
  6. data/lib/capybara/simulated/errors.rb +9 -5
  7. data/lib/capybara/simulated/js/bridge.bundle.js +19409 -0
  8. data/lib/capybara/simulated/js/snapshot_stubs.js +110 -0
  9. data/lib/capybara/simulated/node.rb +151 -163
  10. data/lib/capybara/simulated/quickjs_runtime.rb +424 -0
  11. data/lib/capybara/simulated/runtime_shared.rb +183 -0
  12. data/lib/capybara/simulated/script_cache.rb +168 -0
  13. data/lib/capybara/simulated/sourcemap.rb +119 -0
  14. data/lib/capybara/simulated/stack_resolver.rb +97 -0
  15. data/lib/capybara/simulated/trace.rb +111 -0
  16. data/lib/capybara/simulated/v8_runtime.rb +987 -0
  17. data/lib/capybara/simulated/version.rb +3 -1
  18. data/lib/capybara/simulated/webauthn_state.rb +367 -0
  19. data/lib/capybara/simulated/whitespace_normalizer.rb +45 -0
  20. data/lib/capybara/simulated/worker_runtime.rb +30 -0
  21. data/lib/capybara/simulated.rb +31 -4
  22. data/lib/capybara-simulated.rb +2 -0
  23. data/vendor/js/vendor.bundle.js +13 -0
  24. metadata +24 -32
  25. data/vendor/esbuild-wasm/LICENSE.md +0 -21
  26. data/vendor/esbuild-wasm/bin/esbuild +0 -91
  27. data/vendor/esbuild-wasm/esbuild.wasm +0 -0
  28. data/vendor/esbuild-wasm/lib/main.js +0 -2337
  29. data/vendor/esbuild-wasm/wasm_exec.js +0 -575
  30. data/vendor/esbuild-wasm/wasm_exec_node.js +0 -40
  31. data/vendor/js/bundle-modules.mjs +0 -168
  32. data/vendor/js/csim.bundle.js +0 -91560
  33. data/vendor/js/entry.mjs +0 -23
  34. data/vendor/js/prelude.js +0 -190
  35. data/vendor/js/runtime.js +0 -2208
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3d7b5f908d1c75a41fabf90b90c779ae21813de6a4620a7b574e4dfca69134cf
4
- data.tar.gz: 57ddffb46aca509cd981106c4ac0ad421d38956c52fba5d439add59ae2e10aab
3
+ metadata.gz: c645fc942083807ecc024135e4bde092fa7859628346a14bac9d4423ab04a99f
4
+ data.tar.gz: b52e0d0c5ce4755653e457e25abcaa0e12735edb25f98e89ebd73f666617088b
5
5
  SHA512:
6
- metadata.gz: 576d88b9d8fe7d0c480e8ad7309e7861d9be563a95ab992f6d870f66c06a12d0255f779622ac25e7bab19aa7c6868bf929593e31fe146b4b3fe4d01e3447a37b
7
- data.tar.gz: 8cfe9a00eff04e4df4d8aa3b1938f122001a00bc7e15f0f3c5b92975fd51c82b2d115aa9b5031ead063a236e4b4c81159ec9d947ea7e5df948e165d5047f3055
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 in a long-lived
4
- [mini_racer](https://github.com/rubyjs/mini_racer) V8 context against a
5
- [happy-dom](https://github.com/capricorn86/happy-dom) DOM. XPath queries
6
- are powered by [Wicked Good XPath](https://github.com/google/wicked-good-xpath).
7
-
8
- The goal is the middle ground between `rack-test` (zero JS) and full
9
- headless browsers like cuprite/selenium: in-process tests, no Chrome,
10
- inline `<script>` and event handlers run, the Capybara DSL works, and
11
- forms submit through `Rack::MockRequest`.
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
- Used in production by a Rails 8 app to run ~200 `js: true` system specs
16
- without spawning a headless Chrome.
17
-
18
- Against Capybara 3.40's shared `Capybara::SpecHelper.spec` suite the
19
- driver passes **1335 / 1357 examples** with 0 failures and 22 pending,
20
- once the unsupported-capability tags `about_scheme`, `css`, `download`,
21
- `frames`, `hover`, `screenshot`, `scroll`, `server`, `spatial`, `windows`
22
- are filtered out. The 22 pending all need capabilities the driver
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
- npm install
101
- npm run build # produces vendor/js/csim.bundle.js (~2.9MB)
102
- bundle install
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
- Add to your Gemfile (development / test group):
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 'capybara-simulated', '~> 0.0', group: :test
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
- Then `bundle install`. The gem ships its own pre-built happy-dom bundle
115
- under `vendor/js/`, so no `npm install` is required at consume time.
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. The
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: make :simulated the default for non-JS specs too.
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) will run
135
- in the simulated driver:
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 a Rails system test, set the driver in `before_setup` /
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 and `Capybara::Minitest::Test` has no `js: true` metadata
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
- ## How it fits together
211
-
212
- - `vendor/js/prelude.js` minimal Web Platform polyfills (TextEncoder,
213
- atob/btoa, crypto.getRandomValues, performance, timers, process).
214
- - `vendor/js/csim.bundle.js` bundled happy-dom + whatwg-url +
215
- Wicked Good XPath. Built via `build.mjs` with esbuild, with shims
216
- for the Node built-ins happy-dom imports (`url`, `buffer`, `vm`,
217
- `path`, etc.).
218
- - `vendor/js/runtime.js`driver glue exposed on `globalThis.__csim`.
219
- Manages the active happy-dom `Window`, an integer→DOM-node handle
220
- table, modal capture, form serialization, click/submit dispatch, and
221
- XPath through `document.evaluate` (installed by wgxpath). Fast-paths
222
- Capybara's hot xpath shapes (`:option`, `:select`, `:link_or_button`)
223
- to native `querySelectorAll` + `getElementById`.
224
- - `vendor/esbuild-wasm/` — vendored copy of esbuild-wasm so the gem can
225
- bundle Rails importmap modules without a runtime npm dependency.
226
- - `lib/capybara/simulated/browser.rb` — owns the `MiniRacer::Context`,
227
- drives HTTP via `Rack::MockRequest`, fetches `<script src>` inline,
228
- and routes form submissions back through Rack.
229
- - `lib/capybara/simulated/{driver,node}.rb` Capybara `Driver::Base` and
230
- `Driver::Node` implementations.
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
- - happy-dom is not a layout engine `visible?` is heuristic
235
- (`display:none`, `visibility:hidden`, `hidden` attribute, head/script).
236
- - No fetch/XHR. `<script src>` is inlined via `Rack::MockRequest`. Real
237
- navigation only happens on link click and form submit.
238
- - `evaluate_async_script`, frames, multi-window, file uploads, screenshots,
239
- CSS computed-style filters, scroll/drag pixel coordinates are out of
240
- scope.
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).