capybara-simulated 0.4.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: 3b2b947ec8b6945ec441d61c70a2bf0155283696c7d8188a135c990850149f68
4
- data.tar.gz: c7530db87c7b019f25db0370213c6965a4c3f44172284ef469f84e8617319291
3
+ metadata.gz: 7b82726ef99cc26d3aaba51a010103e2712615464a13d5513400877cbbbe686e
4
+ data.tar.gz: ce5d823da6b55a169d76db983a18919893172fb7b88e1c96b71d2f9b6ee85bc7
5
5
  SHA512:
6
- metadata.gz: 1c3e5d6358892be026316a31531ed4ced5e66f52ac0dafcda51236977f8f94f40b5465e9653cce4ce2ad84cee0ef4d29b8fbe2d850cd94efb3f1a3ab47db3eb6
7
- data.tar.gz: '099d9b40e65a21667a543721f26b1df60176a2651da945f175016d5d85bbd81b0296099a17bf23b2a415101387489573d6b487aecdf8255ee0a923935d0f9b10'
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
 
@@ -200,25 +149,39 @@ end
200
149
 
201
150
  ### File output
202
151
 
203
- Set `CSIM_TRACE_DIR=/path/to/dir` to enable file output. The bundled
204
- RSpec hook ([`csim_rspec.rb`](https://github.com/ursm/capybara-simulated-vs-world/blob/main/support/csim_rspec.rb))
205
- writes `<example slug>.json` into that directory after each test;
206
- mirror it in `application_system_test_case.rb`'s teardown for
207
- Minitest.
152
+ Require the test-framework integration in your `spec_helper` /
153
+ `rails_helper` (RSpec) or `test_helper` /
154
+ `application_system_test_case.rb` (Minitest):
155
+
156
+ ```ruby
157
+ require 'capybara/simulated/rspec' # RSpec
158
+ require 'capybara/simulated/minitest' # Minitest / Rails system tests
159
+ ```
160
+
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.
208
162
 
209
163
  ```sh
210
164
  CSIM_TRACE_DIR=tmp/csim-traces bundle exec rspec spec/system
211
165
  ```
212
166
 
213
- The metadata block on each trace includes `title`, `file`, `outcome`
214
- (`passed` / `failed`), and the exception message — enough to index a
215
- 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.
168
+
169
+ ### Viewing traces
170
+
171
+ The recorded JSON stays plain data; to look at one, render it into a self-contained HTML viewer with the bundled CLI:
172
+
173
+ ```sh
174
+ capybara-simulated trace tmp/csim-traces/checkout_flow.json
175
+ # wrote /tmp/checkout_flow.html (then opens it in your browser)
176
+ ```
177
+
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.
216
181
 
217
182
  ### Programmatic
218
183
 
219
- For finer control, call `driver.start_tracing(...)` /
220
- `driver.stop_tracing(path: ...)`. The shape mirrors
221
- `capybara-playwright-driver`:
184
+ For finer control, call `driver.start_tracing(...)` / `driver.stop_tracing(path: ...)`. The shape mirrors `capybara-playwright-driver`:
222
185
 
223
186
  ```ruby
224
187
  RSpec.describe 'flaky payment flow', type: :system, js: true do
@@ -260,134 +223,43 @@ end
260
223
 
261
224
  ## Performance characteristics
262
225
 
263
- The driver builds a base snapshot once per process — the bundled
264
- bridge plus the vendored JS deps, as a V8 `Snapshot` for rusty_racer or
265
- bytecode for QuickJS. On V8 that snapshot warms a single long-lived
266
- isolate whose context is reset to a clean realm per navigation
267
- (`Context#reset`); on QuickJS each navigation checks a freshly
268
- snapshot-loaded VM out of a small pre-warmed pool. Either way, every
269
- 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.
270
227
 
271
228
  ### Library snapshot policy
272
229
 
273
- Per visit, `<script src>`-referenced libraries (jQuery, Stimulus,
274
- …) re-evaluate fresh against the new page. They are **not** baked
275
- into a per-app snapshot — preserving library state across page
276
- navigations is what real browsers don't do, and trying to do it
277
- broke `$.ready` Callbacks queues whose user-app callbacks
278
- 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.
279
231
 
280
232
  ### Other factors
281
233
 
282
- - **`<script src>` parsing** dominates `visit` on JS-heavy pages.
283
- Each external script is fetched through the in-process Rack app,
284
- compiled, and run in the JS engine with bytecode cache hits from
285
- the base snapshot warmup.
286
- - **CSS cascade resolution**: stylesheets are parsed once per distinct
287
- set of sources and cached content-addressably, so repeat visits and
288
- subsequent finds on the same page reuse the resolved cascade instead
289
- of re-parsing.
290
- - **DOM ops stay inside the JS engine** — find / has_? / event
291
- dispatch never cross the Ruby ↔ JS boundary for the actual tree
292
- walk; only the resulting handle ids do. Modify-heavy tests
293
- (SortableJS dragging thousands of items) run at JS-engine speed,
294
- not at host-call-IPC speed.
295
- - **Polling** (Capybara `default_max_wait_time`) advances a *virtual*
296
- JS clock — timers fire as polling steps the clock forward, not in
297
- real time. A page that schedules `setTimeout(2000, x)` doesn't block
298
- for 2 s; the callback fires once polling has advanced the clock past
299
- 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.
300
238
 
301
239
  ## Capabilities & limits
302
240
 
303
- Most features run in-process; the notes below are mostly "works, but…",
304
- 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**.
305
242
 
306
243
  ### Works, with constraints
307
244
 
308
- - **`within_frame` / `switch_to_frame`** (V8 engine) — each `<iframe>` runs
309
- its own scripts in its own per-frame realm; the DSL routes finds, reads,
310
- interactions, `evaluate_script`, and navigation into the active frame,
311
- nested frames includedthe target frame's realm is rebuilt from the
312
- fetched document, the top page untouched. QuickJS has no nested browsing
313
- context, so `within_frame` raises there.
314
- - **Multiple windows / tabs** (both engines) — each window is its own
315
- Browser + JS VM (own DOM, sessionStorage, history; cookies + localStorage
316
- shared). `open_new_window` / `within_window` / `switch_to_window` /
317
- `window_opened_by` drive them; JS `window.open` opens a real window,
318
- `window.opener` links back, and `postMessage` crosses windows. Only the
319
- active window's event loop runs, so a message is delivered when you switch
320
- to its window. `target="_blank"` opens with no opener (modern-browser
321
- default). `postMessage` carries real structured data (not a lossy JSON
322
- hop) — `Map` / `Set` / `Date` / `BigInt` / typed arrays / cyclic graphs all
323
- round-trip on V8 — and a `transfer`-list buffer moves **zero-copy** (backing
324
- store by token, source detached); only bare `undefined` collapses to `null`
325
- (Ruby has no distinct `undefined`). Window viewport APIs (`maximize` /
326
- `fullscreen` / pixel-exact `resize_to`) are no-ops (no layout engine).
327
- - **WebSocket + Action Cable** — `new WebSocket(url)` works in-process over
328
- the `rack.hijack` socket the Rack app hijacks (hand-rolled RFC6455, including
329
- subprotocol negotiation). The real `@rails/actioncable` consumer connects,
330
- subscribes, and receives broadcasts, so `turbo_stream_from` live updates
331
- work. Constraints: server
332
- pushes land at settle (not instant); the Cable app must use the **async /
333
- in-process** adapter (a real Redis adapter needs real Redis); binary frames
334
- are V8-only (QuickJS corrupts raw bytes across the host boundary — text,
335
- hence Action Cable, works on both engines). `EventSource` and Web Workers
336
- are likewise real (background reader threads draining at settle).
337
- - **`fetch` / XHR** — synchronous through Rack: HTML / JSON round-trips work,
338
- but there's no streaming, no `Request#body` ReadableStream, and no
339
- concurrent requests.
340
- - **`:hover` / `:focus-within`-gated content** — reachable two ways: call
341
- `element.hover` explicitly (we track the most-recently-hovered element and
342
- propagate `:hover` up its chain), or rely on the candidate-chain fallback
343
- (when the stateless cascade reports `display: none`, we re-evaluate with the
344
- candidate itself in the `:hover` set). Symmetric peers — N rows each with
345
- `tr:hover .icon` revealing `.icon`, queried as a bare `find('.icon')` —
346
- reveal all and Capybara raises `Capybara::Ambiguous`; scope the test
347
- (`find('tr', text: 'foo').hover` then `find('.icon')`), which is also more
348
- 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.
349
250
 
350
251
  ### Out of scope (by design — use Selenium / Cuprite)
351
252
 
352
- - **Layout / pixel geometry.** `visible?` and `Node#style` consult the CSS
353
- cascade and the inline `style` attribute, but `getBoundingClientRect()`
354
- returns zeros and `elementFromPoint()` isn't implemented. Click offsets work
355
- for fixture-style absolute / relative positioning (ancestor-summed
356
- `top`/`left`); position-via-layout (Dragula drops, sticky-header scroll math,
357
- 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.
358
254
  - **Screenshots.**
359
255
 
360
256
  ## Architecture
361
257
 
362
- - `lib/capybara/simulated/js/src/` — the entire DOM lives here, split
363
- across ~50 ES modules bundled into `bridge.bundle.js` (esbuild; no
364
- Node toolchain at consume time). `Document` / `Element` / `Text` /
365
- `DocumentFragment` / `ShadowRoot` classes; event dispatch
366
- (capture / target / bubble with shadow retargeting, via
367
- `dispatchEvent(target, event)`); a virtual `setTimeout` /
368
- `setInterval` / `requestAnimationFrame` clock; MutationObserver;
369
- custom-element registry; `Range` / `Selection`; and the cascade
370
- resolver for `display` / `visibility` / `text-transform`. Capybara's
371
- finds run through the vendored css-select (with css-what / css-tree)
372
- for CSS and xpathway for XPath — both true third parties under
373
- `vendor/js/`, executing in the same context as the page's JS.
374
- - `lib/capybara/simulated/browser.rb` — Rack client, history stack,
375
- modal handler queue, virtual-clock anchor, trace recorder. Owns
376
- the JS runtime via `V8Runtime` or `QuickJSRuntime`. The hot
377
- operations (`find_css` / `find_xpath` / DOM ops / event dispatch)
378
- are single-`Context#call` round-trips returning handle id arrays;
379
- per-result iteration stays Ruby-side.
380
- - `lib/capybara/simulated/v8_runtime.rb` / `quickjs_runtime.rb` —
381
- per-engine wrappers, common bits in `runtime_shared.rb`. The V8
382
- base-snapshot (and the QuickJS bytecode equivalent) bakes in the
383
- bundled bridge + vendored deps, so a per-navigation context reset
384
- (V8) or pooled VM checkout (QuickJS) is sub-millisecond.
385
- - `lib/capybara/simulated/driver.rb` — Capybara `Driver::Base`
386
- surface (visit / find / execute_script / window handling / modal /
387
- tracing API).
388
- - `lib/capybara/simulated/node.rb` — `Driver::Node` over a
389
- `(handle_id, context_gen)` pair so a handle from a pre-rebuild
390
- 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.
391
263
 
392
264
  ## License
393
265
 
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # capybara-simulated CLI. Currently one subcommand:
5
+ #
6
+ # capybara-simulated trace TRACE.json [-o OUT.html] [--no-open]
7
+ #
8
+ # Renders a trace JSON (produced by CSIM_TRACE_DIR / driver.stop_tracing)
9
+ # into a self-contained HTML viewer and opens it in your browser. The
10
+ # viewer embeds the JSON inline so it opens straight from file:// — no
11
+ # server, unlike a fetch/import-based loader (CORS blocks those for
12
+ # file:// origins). The persisted trace stays plain JSON; this turns one
13
+ # into a UI on demand.
14
+
15
+ require 'optparse'
16
+ require 'tmpdir'
17
+
18
+ # Prefer the library co-located with this executable. In an installed
19
+ # gem that's the gem's own lib (correct version); in a checkout it's the
20
+ # working tree — so `ruby exe/capybara-simulated` doesn't accidentally
21
+ # load an older *installed* capybara-simulated that predates this CLI.
22
+ lib = File.expand_path('../lib', __dir__)
23
+ $LOAD_PATH.unshift(lib) if File.directory?(lib) && !$LOAD_PATH.include?(lib)
24
+ require 'capybara/simulated/trace'
25
+
26
+ PROG = 'capybara-simulated'
27
+
28
+ # `file://` URL for a local path, percent-encoding each segment so a
29
+ # path with spaces / special chars stays a valid URI for launchy.
30
+ def file_url(path)
31
+ require 'cgi'
32
+ 'file://' + path.split('/').map {|seg| CGI.escapeURIComponent(seg) }.join('/')
33
+ end
34
+
35
+ # Hand the raw path (not a URL) to the platform opener; `system`'s array
36
+ # form needs no shell-quoting, so spaces are fine.
37
+ def open_with_platform(path)
38
+ cmd =
39
+ case RbConfig::CONFIG['host_os']
40
+ when /darwin/ then ['open', path]
41
+ when /mswin|mingw/ then ['cmd', '/c', 'start', '', path]
42
+ else ['xdg-open', path] # Linux / *BSD
43
+ end
44
+ system(*cmd)
45
+ rescue StandardError
46
+ false
47
+ end
48
+
49
+ # Open a local file in the default browser. Prefer launchy (handles
50
+ # macOS / Linux / Windows / WSL) when installed; on either a missing gem
51
+ # OR a launchy runtime failure (headless box, no opener), fall back to
52
+ # the platform opener.
53
+ def open_in_browser(path)
54
+ begin
55
+ require 'launchy'
56
+ Launchy.open(file_url(path))
57
+ return true
58
+ rescue LoadError
59
+ # launchy not installed
60
+ rescue StandardError
61
+ # launchy present but failed — try the platform opener below
62
+ end
63
+ open_with_platform(path)
64
+ end
65
+
66
+ def cmd_trace(argv)
67
+ out = nil
68
+ open = nil # nil → default (open), unless writing to stdout
69
+ parser = OptionParser.new do |o|
70
+ o.banner = "Usage: #{PROG} trace [options] TRACE.json\n\n" \
71
+ "Render TRACE.json into a self-contained HTML viewer and open it\n" \
72
+ "in your browser. By default the HTML goes to a temp file."
73
+ o.on('-o', '--output PATH', "write HTML to PATH instead of a temp file ('-' = stdout)") {|v| out = v }
74
+ o.on('--[no-]open', 'open the HTML in your browser (default: yes)') {|v| open = v }
75
+ o.on('-h', '--help') do
76
+ puts o
77
+ exit
78
+ end
79
+ end
80
+ begin
81
+ parser.parse!(argv)
82
+ rescue OptionParser::ParseError => e
83
+ warn e.message
84
+ warn parser.help
85
+ exit 1
86
+ end
87
+
88
+ input = argv.shift
89
+ unless input
90
+ warn parser.help
91
+ exit 1
92
+ end
93
+ unless File.file?(input)
94
+ warn "#{PROG}: no such file: #{input}"
95
+ exit 1
96
+ end
97
+
98
+ json = File.read(input)
99
+ begin
100
+ require 'json'
101
+ JSON.parse(json) # fail fast on a non-JSON / corrupt trace
102
+ rescue JSON::ParserError => e
103
+ warn "#{PROG}: #{input} is not valid JSON: #{e.message}"
104
+ exit 1
105
+ end
106
+
107
+ html = Capybara::Simulated::Trace.render_viewer(json)
108
+
109
+ if out == '-'
110
+ $stdout.write(html)
111
+ return
112
+ end
113
+ # Default output: a temp file named after the trace (re-runs overwrite).
114
+ out ||= File.join(Dir.tmpdir, File.basename(input).sub(/\.json\z/, '') + '.html')
115
+ File.write(out, html)
116
+ warn "wrote #{out}"
117
+ open_in_browser(File.expand_path(out)) unless open == false
118
+ end
119
+
120
+ def main(argv)
121
+ case argv.first
122
+ when 'trace'
123
+ cmd_trace(argv.drop(1))
124
+ when '-v', '--version'
125
+ require 'capybara/simulated/version'
126
+ puts Capybara::Simulated::VERSION
127
+ when nil, '-h', '--help'
128
+ puts <<~USAGE
129
+ Usage: #{PROG} <command> [options]
130
+
131
+ Commands:
132
+ trace TRACE.json render a trace JSON into a self-contained HTML viewer
133
+
134
+ Run `#{PROG} trace --help` for command options.
135
+ USAGE
136
+ else
137
+ warn "#{PROG}: unknown command: #{argv.first}"
138
+ warn "Run `#{PROG} --help`."
139
+ exit 1
140
+ end
141
+ end
142
+
143
+ main(ARGV)