capybara-simulated 0.5.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 67e9168ef15e413c1d85b1805ea4d68f36cae09578af5fc539bd1613d52ac04d
4
- data.tar.gz: 6214303ef25079d567bc91db43460eda49a54a292ed08021b98a81ce48af0c31
3
+ metadata.gz: 7b82726ef99cc26d3aaba51a010103e2712615464a13d5513400877cbbbe686e
4
+ data.tar.gz: ce5d823da6b55a169d76db983a18919893172fb7b88e1c96b71d2f9b6ee85bc7
5
5
  SHA512:
6
- metadata.gz: 8112cd3056184946f65739dfb30e76e55bee2a79f1058e3128bc8ae15f0b5790f195d0c289759518e667b69902962e70922af21f48dea6610744ab1093a10ca4
7
- data.tar.gz: 53ccb66cb2bd4723d1e999ebd03b0a56bdbc83978834f82b66c128aa2c7e3ba1c4f5464c5965442104de94de2fbabe79e185a74f6e1aaa3cd2c2c2c2bfe68a26
6
+ metadata.gz: cbdaecdbc0b555e43842c7569f8b3b144efd1c0fe1e2bd0f5f48c577056cd1d356154c4d15a8910b36b5a88f9d069f1dfc7bf98961fbf2431b81e6b64350b3c0
7
+ data.tar.gz: 8ea39123d1b9c5d13b5e8289e06c21d369a6d886a0e87ba9da07fa6071f71db10aa2a208dfde6ec713a34e7df4b76d3cbe0d5c15fd3cc05eb3ee9503499072d6
data/README.md CHANGED
@@ -1,65 +1,30 @@
1
1
  # capybara-simulated
2
2
 
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, and the
6
- Capybara DSL is unchanged.
7
-
8
- The DOM lives entirely inside the JS engine — V8 via
9
- [rusty_racer](https://github.com/ursm/rusty_racer) or QuickJS via
10
- [quickjs.rb](https://github.com/hmsk/quickjs.rb), whichever is
11
- installed with no Nokogiri tree on the Ruby side. Capybara finds
12
- resolve through css-select (CSS) and xpathway (XPath) running in the
13
- same context as the page's JS, so `find` / `has_css?` / `within` see
14
- exactly the tree the app sees.
3
+ *A simulated browser environment with DOM, JavaScript, and CSS cascade—without a rendering engine.*
4
+
5
+ capybara-simulated is an in-process Capybara driver. You shouldn't have to drop a system test down to a lower layer just because a real browser is expensive — a test written from the user's point of view should describe what the user actually does, not the HTTP requests your test code assembles.
6
+
7
+ capybara-simulated lets you keep those user-facing system tests without paying the cost of a real browser: nothing to download or boot, no WebDriver to set up. Everything runs in-process, and the JavaScript your app actually loads — Turbo, Stimulus, React, … — runs as-is, so you're verifying real behavior rather than a mock.
8
+
9
+ Its correctness is held continuously to the same
10
+ [web-platform-tests](https://github.com/web-platform-tests/wpt) that
11
+ Chromium and Firefox use.
12
+
13
+ capybara-simulated is not a complete replacement for real-browser testing. But it sits between `rack_test` and a real browser, running the majority of system tests that don't depend on visual layout.
15
14
 
16
15
  ## Is it a fit?
17
16
 
18
- **A good fit when** your tests are JavaScript-driven but don't depend on
19
- visual layout:
20
-
21
- - **Fast, in-process** no Chrome to boot, no WebDriver, no Node
22
- toolchain. About **1.9× faster** than a headless browser on
23
- server-rendered / Hotwire apps, and roughly at parity on JS-heavy SPAs
24
- (with rusty_racer).
25
- - **Deterministic** — a virtual clock and synchronous in-process execution
26
- remove the wall-clock timing, network, and rendering races that make
27
- headless-browser suites flaky.
28
- - **Real front-end JS runs**: inline `<script>` + event handlers,
29
- MutationObserver, custom elements, `<template>`, Shadow DOM, ES modules
30
- + importmap, **Hotwire (Stimulus + Turbo)**, Trix.
31
- - **Drop-in**: the Capybara DSL is unchanged — register `:simulated` and
32
- go. Just this gem plus one JS-engine gem.
33
- - **Held to spec**: a vendored
34
- [web-platform-tests](https://github.com/web-platform-tests/wpt)
35
- conformance gate plus five real app suites (see [Status](#status)).
36
-
37
- **Reach for a real browser** (Selenium / Cuprite) **when** your tests need
38
- what this driver doesn't simulate **by design** — there's no rendering
39
- engine: **pixel layout** (`getBoundingClientRect()` returns zeros,
40
- `elementFromPoint()` isn't implemented, so visual hit-testing, coordinate
41
- drag-and-drop, and sticky-scroll math don't work) and **screenshots**.
42
-
43
- Most of the rest runs in-process — including the things that usually mean
44
- "you need a real browser": **`within_frame`**, **multiple windows / tabs**,
45
- **WebSocket + Action Cable**, **EventSource**, and **Web Workers** all work.
46
- Each has constraints (JS engine, settle-timing, no layout); see
47
- [Capabilities & limits](#capabilities--limits).
48
-
49
- ## Status
50
-
51
- The architecture and behaviour are stable. Correctness is held to two
52
- bars. A vendored subset of
53
- [web-platform-tests](https://github.com/web-platform-tests/wpt) — the
54
- same DOM / HTML tests Chromium and Firefox hold themselves to — runs as
55
- a conformance gate. And each target app (Redmine / Forem / Avo /
56
- Mastodon / Discourse) runs its full system suite against the driver in
57
- [capybara-simulated-vs-world](https://github.com/ursm/capybara-simulated-vs-world)
58
- as an integration check.
59
-
60
- The remaining gaps are the layout / pixel-geometry features the driver
61
- deliberately doesn't simulate — the same set Selenium escapes via
62
- screenshots (see [Capabilities & limits](#capabilities--limits)).
17
+ **A good fit when** your tests are JavaScript-driven but don't depend on visual layout:
18
+
19
+ - **No browser to install or boot** — no Chrome, no WebDriver, no Node toolchain; everything runs in-process. Execution is about **1.9×** faster than a headless browser on server-rendered / Hotwire apps and roughly at parity on JS-heavy SPAs (rusty_racer) — but the real win is skipping the browser's install, boot, and driver setup, not raw speed.
20
+ - **Deterministic** — a virtual clock and synchronous in-process execution remove the wall-clock timing, network, and rendering races that make headless-browser suites flaky.
21
+ - **Real front-end JS runs**: inline `<script>` + event handlers, MutationObserver, custom elements, `<template>`, Shadow DOM, ES modules importmap, **Hotwire (Stimulus + Turbo)**, Trix.
22
+ - **Drop-in**: the Capybara DSL is unchanged — register `:simulated` and go. Just this gem plus one JS-engine gem.
23
+ - **Held to spec**: a vendored [web-platform-tests](https://github.com/web-platform-tests/wpt) conformance gate (the same DOM / HTML tests Chromium and Firefox hold themselves to), plus the full system suites of five real apps — Redmine / Forem / Avo / Mastodon / Discourse — run against the driver in [capybara-simulated-vs-world](https://github.com/ursm/capybara-simulated-vs-world).
24
+
25
+ **Reach for a real browser** (Selenium / Cuprite) **when** your tests need what this driver doesn't simulate **by design** — there's no rendering engine: **pixel layout** (`getBoundingClientRect()` returns zeros, `elementFromPoint()` isn't implemented, so visual hit-testing, coordinate drag-and-drop, and sticky-scroll math don't work) and **screenshots**.
26
+
27
+ Most of the rest runs in-process — including the things that usually mean "you need a real browser": **`within_frame`**, **multiple windows / tabs**, **WebSocket + Action Cable**, **EventSource**, and **Web Workers** all work. Each has constraints (JS engine, settle-timing, no layout); see [Capabilities & limits](#capabilities--limits).
63
28
 
64
29
  ## Install
65
30
 
@@ -68,29 +33,22 @@ gem 'capybara-simulated', group: :test
68
33
  gem 'rusty_racer', group: :test # JS engine — pick one
69
34
  ```
70
35
 
71
- `bundle install`. Requires Ruby ≥ 3.3. The gem ships its JS bridge
72
- under `lib/capybara/simulated/js/` and the vendored JS deps under
73
- `vendor/js/`, so there's no Node toolchain at consume time.
36
+ `bundle install`. Requires Ruby ≥ 3.3. The gem ships its JS bridge under `lib/capybara/simulated/js/` and the vendored JS deps under `vendor/js/`, so there's no Node toolchain at consume time.
74
37
 
75
38
  ### JS engine
76
39
 
77
40
  The gem treats the JS engine as a soft dependency. Pick one of:
78
41
 
79
42
  ```ruby
80
- gem 'rusty_racer' # V8 (JIT, fastest per spec) — default
81
- gem 'quickjs', '>= 0.18' # QuickJS (interpreter, smaller per-VM RAM —
82
- # wins when scaling parallel workers under
83
- # a fixed memory budget)
43
+ gem 'rusty_racer', '>= 0.1.9' # V8 (JIT, fastest per spec) — default
44
+ gem 'quickjs', '>= 0.18' # QuickJS (interpreter, smaller per-VM RAM —
45
+ # wins when scaling parallel workers under
46
+ # a fixed memory budget)
84
47
  ```
85
48
 
86
- The V8 engine comes from [rusty_racer](https://github.com/ursm/rusty_racer),
87
- a rusty_v8-based Ruby binding with the native ES Module API,
88
- `ScriptCompiler::CachedData` snapshots, and per-frame realm contexts the
89
- driver builds on.
49
+ The V8 engine comes from [rusty_racer](https://github.com/ursm/rusty_racer), a rusty_v8-based Ruby binding with the native ES Module API, `ScriptCompiler::CachedData` snapshots, and per-frame realm contexts the driver builds on.
90
50
 
91
- The engine is auto-detected at boot; if both gems are present V8 wins.
92
- Override explicitly with `CSIM_JS_ENGINE=v8|quickjs`
93
- or `Capybara::Simulated::Driver.new(app, js_engine: :quickjs)`.
51
+ The engine is auto-detected at boot; if both gems are present V8 wins. Override explicitly with `CSIM_JS_ENGINE=v8|quickjs` or `Capybara::Simulated::Driver.new(app, js_engine: :quickjs)`.
94
52
 
95
53
  ## Use
96
54
 
@@ -108,8 +66,7 @@ Capybara.javascript_driver = :simulated
108
66
  # Capybara.default_driver = :simulated
109
67
  ```
110
68
 
111
- Tests tagged `js: true` (or `type: :system, js: true` in Rails) run
112
- in the driver:
69
+ Tests tagged `js: true` (or `type: :system, js: true` in Rails) run in the driver:
113
70
 
114
71
  ```ruby
115
72
  RSpec.describe 'sign-in', type: :system, js: true do
@@ -134,8 +91,7 @@ end
134
91
 
135
92
  ### Minitest
136
93
 
137
- `Capybara.javascript_driver` is RSpec-only — `ActionDispatch::SystemTestCase`
138
- ignores it. Set the driver explicitly:
94
+ `Capybara.javascript_driver` is RSpec-only — `ActionDispatch::SystemTestCase` ignores it. Set the driver explicitly:
139
95
 
140
96
  ```ruby
141
97
  # test/application_system_test_case.rb
@@ -165,16 +121,9 @@ puts page.text
165
121
 
166
122
  ## Trace
167
123
 
168
- Each Capybara action (`visit`, `click`, `set`, …) is recorded as a step
169
- in a per-test trace: URL before / after, console output and network
170
- requests during the step, plus elapsed and per-step durations. On
171
- action failure (and only then, by default) the post-action DOM is
172
- captured too.
124
+ Each Capybara action (`visit`, `click`, `set`, …) is recorded as a step in a per-test trace: URL before / after, console output and network requests during the step, plus elapsed and per-step durations. On action failure (and only then, by default) the post-action DOM is captured too.
173
125
 
174
- Recording is **on by default** — fully in-memory, no files written
175
- unless you opt in via `CSIM_TRACE_DIR`. Wall-time overhead is
176
- run-to-run-variance equivalent because the expensive part — DOM
177
- serialization — only fires on action error.
126
+ Recording is **on by default** — fully in-memory, no files written unless you opt in via `CSIM_TRACE_DIR`. Wall-time overhead is run-to-run-variance equivalent because the expensive part — DOM serialization — only fires on action error.
178
127
 
179
128
  ### Modes (`CSIM_TRACE=…`)
180
129
 
@@ -209,48 +158,30 @@ require 'capybara/simulated/rspec' # RSpec
209
158
  require 'capybara/simulated/minitest' # Minitest / Rails system tests
210
159
  ```
211
160
 
212
- With `CSIM_TRACE_DIR=/path/to/dir` set, each example that recorded a
213
- trace is written to `<dir>/<slug>.json` after it runs; both integrations
214
- are inert when the env var is unset.
161
+ With `CSIM_TRACE_DIR=/path/to/dir` set, each example that recorded a trace is written to `<dir>/<slug>.json` after it runs; both integrations are inert when the env var is unset.
215
162
 
216
163
  ```sh
217
164
  CSIM_TRACE_DIR=tmp/csim-traces bundle exec rspec spec/system
218
165
  ```
219
166
 
220
- The metadata block on each trace includes `title`, `file`, `outcome`
221
- (`passed` / `failed`), and the exception message — enough to index a
222
- CI artifact directory by failure.
167
+ The metadata block on each trace includes `title`, `file`, `outcome` (`passed` / `failed`), and the exception message — enough to index a CI artifact directory by failure.
223
168
 
224
169
  ### Viewing traces
225
170
 
226
- The recorded JSON stays plain data; to look at one, render it into a
227
- self-contained HTML viewer with the bundled CLI:
171
+ The recorded JSON stays plain data; to look at one, render it into a self-contained HTML viewer with the bundled CLI:
228
172
 
229
173
  ```sh
230
174
  capybara-simulated trace tmp/csim-traces/checkout_flow.json
231
175
  # wrote /tmp/checkout_flow.html (then opens it in your browser)
232
176
  ```
233
177
 
234
- By default the HTML is written to a temp file and opened in your
235
- browser. The viewer works straight from `file://` — the trace JSON is
236
- embedded inline, so no server is needed and shows a step-by-step UI: a
237
- timeline of actions, and per step the URL before/after, console output,
238
- network requests, the error, and a rendered preview of the post-action
239
- DOM snapshot. Its **Load JSON…** button / drag-and-drop swaps in any
240
- other trace file.
241
-
242
- `-o PATH` writes the HTML somewhere specific (`-o -` to stdout);
243
- `--no-open` skips launching the browser. Browser launching uses
244
- [launchy](https://rubygems.org/gems/launchy) when it's installed
245
- (`gem 'launchy'`, recommended for reliable cross-platform / WSL opening)
246
- and falls back to the platform opener (`xdg-open` / `open` / `start`)
247
- otherwise.
178
+ By default the HTML is written to a temp file and opened in your browser. The viewer works straight from `file://` — the trace JSON is embedded inline, so no server is needed — and shows a step-by-step UI: a timeline of actions, and per step the URL before/after, console output, network requests, the error, and a rendered preview of the post-action DOM snapshot. Its **Load JSON…** button / drag-and-drop swaps in any other trace file.
179
+
180
+ `-o PATH` writes the HTML somewhere specific (`-o -` to stdout); `--no-open` skips launching the browser. Browser launching uses [launchy](https://rubygems.org/gems/launchy) when it's installed (`gem 'launchy'`, recommended for reliable cross-platform / WSL opening) and falls back to the platform opener (`xdg-open` / `open` / `start`) otherwise.
248
181
 
249
182
  ### Programmatic
250
183
 
251
- For finer control, call `driver.start_tracing(...)` /
252
- `driver.stop_tracing(path: ...)`. The shape mirrors
253
- `capybara-playwright-driver`:
184
+ For finer control, call `driver.start_tracing(...)` / `driver.stop_tracing(path: ...)`. The shape mirrors `capybara-playwright-driver`:
254
185
 
255
186
  ```ruby
256
187
  RSpec.describe 'flaky payment flow', type: :system, js: true do
@@ -292,134 +223,43 @@ end
292
223
 
293
224
  ## Performance characteristics
294
225
 
295
- The driver builds a base snapshot once per process — the bundled
296
- bridge plus the vendored JS deps, as a V8 `Snapshot` for rusty_racer or
297
- bytecode for QuickJS. On V8 that snapshot warms a single long-lived
298
- isolate whose context is reset to a clean realm per navigation
299
- (`Context#reset`); on QuickJS each navigation checks a freshly
300
- snapshot-loaded VM out of a small pre-warmed pool. Either way, every
301
- navigation lands on a clean, warm JS context near-instantly.
226
+ The driver builds a base snapshot once per process — the bundled bridge plus the vendored JS deps, as a V8 `Snapshot` for rusty_racer or bytecode for QuickJS. On V8 that snapshot warms a single long-lived isolate whose context is reset to a clean realm per navigation (`Context#reset`); on QuickJS each navigation checks a freshly snapshot-loaded VM out of a small pre-warmed pool. Either way, every navigation lands on a clean, warm JS context near-instantly.
302
227
 
303
228
  ### Library snapshot policy
304
229
 
305
- Per visit, `<script src>`-referenced libraries (jQuery, Stimulus,
306
- …) re-evaluate fresh against the new page. They are **not** baked
307
- into a per-app snapshot — preserving library state across page
308
- navigations is what real browsers don't do, and trying to do it
309
- broke `$.ready` Callbacks queues whose user-app callbacks
310
- referenced page-specific DOM.
230
+ Per visit, `<script src>`-referenced libraries (jQuery, Stimulus, …) re-evaluate fresh against the new page. They are **not** baked into a per-app snapshot — preserving library state across page navigations is what real browsers don't do, and trying to do it broke `$.ready` Callbacks queues whose user-app callbacks referenced page-specific DOM.
311
231
 
312
232
  ### Other factors
313
233
 
314
- - **`<script src>` parsing** dominates `visit` on JS-heavy pages.
315
- Each external script is fetched through the in-process Rack app,
316
- compiled, and run in the JS engine with bytecode cache hits from
317
- the base snapshot warmup.
318
- - **CSS cascade resolution**: stylesheets are parsed once per distinct
319
- set of sources and cached content-addressably, so repeat visits and
320
- subsequent finds on the same page reuse the resolved cascade instead
321
- of re-parsing.
322
- - **DOM ops stay inside the JS engine** — find / has_? / event
323
- dispatch never cross the Ruby ↔ JS boundary for the actual tree
324
- walk; only the resulting handle ids do. Modify-heavy tests
325
- (SortableJS dragging thousands of items) run at JS-engine speed,
326
- not at host-call-IPC speed.
327
- - **Polling** (Capybara `default_max_wait_time`) advances a *virtual*
328
- JS clock — timers fire as polling steps the clock forward, not in
329
- real time. A page that schedules `setTimeout(2000, x)` doesn't block
330
- for 2 s; the callback fires once polling has advanced the clock past
331
- it.
234
+ - **`<script src>` parsing** dominates `visit` on JS-heavy pages. Each external script is fetched through the in-process Rack app, compiled, and run in the JS engine with bytecode cache hits from the base snapshot warmup.
235
+ - **CSS cascade resolution**: stylesheets are parsed once per distinct set of sources and cached content-addressably, so repeat visits and subsequent finds on the same page reuse the resolved cascade instead of re-parsing.
236
+ - **DOM ops stay inside the JS engine** find / has_? / event dispatch never cross the Ruby ↔ JS boundary for the actual tree walk; only the resulting handle ids do. Modify-heavy tests (SortableJS dragging thousands of items) run at JS-engine speed, not at host-call-IPC speed.
237
+ - **Polling** (Capybara `default_max_wait_time`) advances a *virtual* JS clock — timers fire as polling steps the clock forward, not in real time. A page that schedules `setTimeout(2000, x)` doesn't block for 2 s; the callback fires once polling has advanced the clock past it.
332
238
 
333
239
  ## Capabilities & limits
334
240
 
335
- Most features run in-process; the notes below are mostly "works, but…",
336
- followed by the short list of things that need a real browser **by design**.
241
+ Most features run in-process; the notes below are mostly "works, but…", followed by the short list of things that need a real browser **by design**.
337
242
 
338
243
  ### Works, with constraints
339
244
 
340
- - **`within_frame` / `switch_to_frame`** (V8 engine) — each `<iframe>` runs
341
- its own scripts in its own per-frame realm; the DSL routes finds, reads,
342
- interactions, `evaluate_script`, and navigation into the active frame,
343
- nested frames includedthe target frame's realm is rebuilt from the
344
- fetched document, the top page untouched. QuickJS has no nested browsing
345
- context, so `within_frame` raises there.
346
- - **Multiple windows / tabs** (both engines) — each window is its own
347
- Browser + JS VM (own DOM, sessionStorage, history; cookies + localStorage
348
- shared). `open_new_window` / `within_window` / `switch_to_window` /
349
- `window_opened_by` drive them; JS `window.open` opens a real window,
350
- `window.opener` links back, and `postMessage` crosses windows. Only the
351
- active window's event loop runs, so a message is delivered when you switch
352
- to its window. `target="_blank"` opens with no opener (modern-browser
353
- default). `postMessage` carries real structured data (not a lossy JSON
354
- hop) — `Map` / `Set` / `Date` / `BigInt` / typed arrays / cyclic graphs all
355
- round-trip on V8 — and a `transfer`-list buffer moves **zero-copy** (backing
356
- store by token, source detached); only bare `undefined` collapses to `null`
357
- (Ruby has no distinct `undefined`). Window viewport APIs (`maximize` /
358
- `fullscreen` / pixel-exact `resize_to`) are no-ops (no layout engine).
359
- - **WebSocket + Action Cable** — `new WebSocket(url)` works in-process over
360
- the `rack.hijack` socket the Rack app hijacks (hand-rolled RFC6455, including
361
- subprotocol negotiation). The real `@rails/actioncable` consumer connects,
362
- subscribes, and receives broadcasts, so `turbo_stream_from` live updates
363
- work. Constraints: server
364
- pushes land at settle (not instant); the Cable app must use the **async /
365
- in-process** adapter (a real Redis adapter needs real Redis); binary frames
366
- are V8-only (QuickJS corrupts raw bytes across the host boundary — text,
367
- hence Action Cable, works on both engines). `EventSource` and Web Workers
368
- are likewise real (background reader threads draining at settle).
369
- - **`fetch` / XHR** — synchronous through Rack: HTML / JSON round-trips work,
370
- but there's no streaming, no `Request#body` ReadableStream, and no
371
- concurrent requests.
372
- - **`:hover` / `:focus-within`-gated content** — reachable two ways: call
373
- `element.hover` explicitly (we track the most-recently-hovered element and
374
- propagate `:hover` up its chain), or rely on the candidate-chain fallback
375
- (when the stateless cascade reports `display: none`, we re-evaluate with the
376
- candidate itself in the `:hover` set). Symmetric peers — N rows each with
377
- `tr:hover .icon` revealing `.icon`, queried as a bare `find('.icon')` —
378
- reveal all and Capybara raises `Capybara::Ambiguous`; scope the test
379
- (`find('tr', text: 'foo').hover` then `find('.icon')`), which is also more
380
- robust against real-browser flake.
245
+ - **`within_frame` / `switch_to_frame`** (V8 engine) — each `<iframe>` runs its own scripts in its own per-frame realm; the DSL routes finds, reads, interactions, `evaluate_script`, and navigation into the active frame, nested frames included — the target frame's realm is rebuilt from the fetched document, the top page untouched. QuickJS has no nested browsing context, so `within_frame` raises there.
246
+ - **Multiple windows / tabs** (both engines) — each window is its own Browser + JS VM (own DOM, sessionStorage, history; cookies + localStorage shared). `open_new_window` / `within_window` / `switch_to_window` / `window_opened_by` drive them; JS `window.open` opens a real window, `window.opener` links back, and `postMessage` crosses windows. Only the active window's event loop runs, so a message is delivered when you switch to its window. `target="_blank"` opens with no opener (modern-browser default). `postMessage` carries real structured data (not a lossy JSON hop) — `Map` / `Set` / `Date` / `BigInt` / typed arrays / cyclic graphs all round-trip on V8 — and a `transfer`-list buffer moves **zero-copy** (backing store by token, source detached); only bare `undefined` collapses to `null` (Ruby has no distinct `undefined`). Window viewport APIs (`maximize` / `fullscreen` / pixel-exact `resize_to`) are no-ops (no layout engine).
247
+ - **WebSocket + Action Cable** — `new WebSocket(url)` works in-process over the `rack.hijack` socket the Rack app hijacks (hand-rolled RFC6455, including subprotocol negotiation). The real `@rails/actioncable` consumer connects, subscribes, and receives broadcasts, so `turbo_stream_from` live updates work. Constraints: server pushes land at settle (not instant); the Cable app must use the **async / in-process** adapter (a real Redis adapter needs real Redis); binary frames are V8-only (QuickJS corrupts raw bytes across the host boundary — text, hence Action Cable, works on both engines). `EventSource` and Web Workers are likewise real (background reader threads draining at settle).
248
+ - **`fetch` / XHR** synchronous through Rack: HTML / JSON round-trips work, but there's no streaming, no `Request#body` ReadableStream, and no concurrent requests.
249
+ - **`:hover` / `:focus-within`-gated content** — reachable two ways: call `element.hover` explicitly (we track the most-recently-hovered element and propagate `:hover` up its chain), or rely on the candidate-chain fallback (when the stateless cascade reports `display: none`, we re-evaluate with the candidate itself in the `:hover` set). Symmetric peers N rows each with `tr:hover .icon` revealing `.icon`, queried as a bare `find('.icon')` — reveal all and Capybara raises `Capybara::Ambiguous`; scope the test (`find('tr', text: 'foo').hover` then `find('.icon')`), which is also more robust against real-browser flake.
381
250
 
382
251
  ### Out of scope (by design — use Selenium / Cuprite)
383
252
 
384
- - **Layout / pixel geometry.** `visible?` and `Node#style` consult the CSS
385
- cascade and the inline `style` attribute, but `getBoundingClientRect()`
386
- returns zeros and `elementFromPoint()` isn't implemented. Click offsets work
387
- for fixture-style absolute / relative positioning (ancestor-summed
388
- `top`/`left`); position-via-layout (Dragula drops, sticky-header scroll math,
389
- viewport-clip visibility) needs a real browser.
253
+ - **Layout / pixel geometry.** `visible?` and `Node#style` consult the CSS cascade and the inline `style` attribute, but `getBoundingClientRect()` returns zeros and `elementFromPoint()` isn't implemented. Click offsets work for fixture-style absolute / relative positioning (ancestor-summed `top`/`left`); position-via-layout (Dragula drops, sticky-header scroll math, viewport-clip visibility) needs a real browser.
390
254
  - **Screenshots.**
391
255
 
392
256
  ## Architecture
393
257
 
394
- - `lib/capybara/simulated/js/src/` — the entire DOM lives here, split
395
- across ~50 ES modules bundled into `bridge.bundle.js` (esbuild; no
396
- Node toolchain at consume time). `Document` / `Element` / `Text` /
397
- `DocumentFragment` / `ShadowRoot` classes; event dispatch
398
- (capture / target / bubble with shadow retargeting, via
399
- `dispatchEvent(target, event)`); a virtual `setTimeout` /
400
- `setInterval` / `requestAnimationFrame` clock; MutationObserver;
401
- custom-element registry; `Range` / `Selection`; and the cascade
402
- resolver for `display` / `visibility` / `text-transform`. Capybara's
403
- finds run through the vendored css-select (with css-what / css-tree)
404
- for CSS and xpathway for XPath — both true third parties under
405
- `vendor/js/`, executing in the same context as the page's JS.
406
- - `lib/capybara/simulated/browser.rb` — Rack client, history stack,
407
- modal handler queue, virtual-clock anchor, trace recorder. Owns
408
- the JS runtime via `V8Runtime` or `QuickJSRuntime`. The hot
409
- operations (`find_css` / `find_xpath` / DOM ops / event dispatch)
410
- are single-`Context#call` round-trips returning handle id arrays;
411
- per-result iteration stays Ruby-side.
412
- - `lib/capybara/simulated/v8_runtime.rb` / `quickjs_runtime.rb` —
413
- per-engine wrappers, common bits in `runtime_shared.rb`. The V8
414
- base-snapshot (and the QuickJS bytecode equivalent) bakes in the
415
- bundled bridge + vendored deps, so a per-navigation context reset
416
- (V8) or pooled VM checkout (QuickJS) is sub-millisecond.
417
- - `lib/capybara/simulated/driver.rb` — Capybara `Driver::Base`
418
- surface (visit / find / execute_script / window handling / modal /
419
- tracing API).
420
- - `lib/capybara/simulated/node.rb` — `Driver::Node` over a
421
- `(handle_id, context_gen)` pair so a handle from a pre-rebuild
422
- Context can't ghost into the next one.
258
+ - `lib/capybara/simulated/js/src/` — the entire DOM lives here, split across ~50 ES modules bundled into `bridge.bundle.js` (esbuild; no Node toolchain at consume time). `Document` / `Element` / `Text` / `DocumentFragment` / `ShadowRoot` classes; event dispatch (capture / target / bubble with shadow retargeting, via `dispatchEvent(target, event)`); a virtual `setTimeout` / `setInterval` / `requestAnimationFrame` clock; MutationObserver; custom-element registry; `Range` / `Selection`; and the cascade resolver for `display` / `visibility` / `text-transform`. Capybara's finds run through the vendored css-select (with css-what / css-tree) for CSS and xpathway for XPath — both true third parties under `vendor/js/`, executing in the same context as the page's JS.
259
+ - `lib/capybara/simulated/browser.rb` Rack client, history stack, modal handler queue, virtual-clock anchor, trace recorder. Owns the JS runtime via `V8Runtime` or `QuickJSRuntime`. The hot operations (`find_css` / `find_xpath` / DOM ops / event dispatch) are single-`Context#call` round-trips returning handle id arrays; per-result iteration stays Ruby-side.
260
+ - `lib/capybara/simulated/v8_runtime.rb` / `quickjs_runtime.rb` per-engine wrappers, common bits in `runtime_shared.rb`. The V8 base-snapshot (and the QuickJS bytecode equivalent) bakes in the bundled bridge + vendored deps, so a per-navigation context reset (V8) or pooled VM checkout (QuickJS) is sub-millisecond.
261
+ - `lib/capybara/simulated/driver.rb` Capybara `Driver::Base` surface (visit / find / execute_script / window handling / modal / tracing API).
262
+ - `lib/capybara/simulated/node.rb` `Driver::Node` over a `(handle_id, context_gen)` pair so a handle from a pre-rebuild Context can't ghost into the next one.
423
263
 
424
264
  ## License
425
265