@@ -10,22 +10,25 @@ The DOM lives entirely inside the JS engine — V8 via
1010[ rusty_racer] ( https://github.com/ursm/rusty_racer ) or QuickJS via
1111[ quickjs.rb] ( https://github.com/hmsk/quickjs.rb ) , whichever is
1212installed — with no Nokogiri tree on the Ruby side. Capybara finds
13- resolve through xpathway / CSS-selector code running in the same
14- context as the page's JS, so ` find ` / ` has_css? ` / ` within ` see
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
1515exactly the tree the app sees.
1616
1717## Status
1818
19- Pre-release — the API is unstable and the gem isn't published to
20- RubyGems yet. The numbers and benchmarks live in
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
2125[ capybara-simulated-vs-world] ( https://github.com/ursm/capybara-simulated-vs-world )
22- where each target app (Redmine / Forem / Avo / Mastodon / Discourse)
23- runs its system suite against this driver.
26+ as an integration check.
2427
25- The pending shared-spec tests all need a real layout engine
26- ( ` elementFromPoint ` , real ` getBoundingClientRect ` , viewport-clip
27- visibility, ` display: contents` table edge cases) — same set Selenium
28- escapes via screenshots and we don't try to simulate.
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.
2932
3033## Install
3134
@@ -34,10 +37,9 @@ gem 'capybara-simulated', group: :test
3437gem ' rusty_racer' , group: :test # JS engine — pick one
3538```
3639
37- ` bundle install ` . The gem ships its JS bridge under
38- ` lib/capybara/simulated/js/ ` , with the vendored JS deps under
39- ` vendor/js/ ` , so
40- there's no Node toolchain at consume time.
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.
4143
4244### JS engine
4345
@@ -227,11 +229,13 @@ end
227229
228230## Performance characteristics
229231
230- The driver builds a base snapshot once per process (bridge.js +
231- the vendored JS deps — a V8 ` Snapshot ` for rusty_racer, bytecode for
232- QuickJS) and
233- checks Contexts out of a small process-wide pool of pre-warmed
234- clones, so each navigation lands on a fresh JS context instantly.
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.
235239
236240** Wall time is sensitive to whether the app uses Turbo Drive** ,
237241because navigation simulates real-browser semantics:
@@ -263,18 +267,20 @@ referenced page-specific DOM.
263267 Each external script is fetched through the in-process Rack app,
264268 compiled, and run in the JS engine with bytecode cache hits from
265269 the base snapshot warmup.
266- - ** CSS cascade resolution** : rules are parsed once on first encounter
267- per stylesheet set; subsequent finds on the same page hit the
268- cached ` __layoutRules ` / ` __hideRules ` arrays in JS-side memory.
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.
269274- ** DOM ops stay inside the JS engine** — find / has_ ? / event
270275 dispatch never cross the Ruby ↔ JS boundary for the actual tree
271276 walk; only the resulting handle ids do. Modify-heavy tests
272277 (SortableJS dragging thousands of items) run at JS-engine speed,
273278 not at host-call-IPC speed.
274279- ** Polling** (Capybara ` default_max_wait_time ` ) advances a * virtual*
275- JS clock — ` setTimeout(N) ` fires after ` N ` ms of accumulated wall
276- time, not real time. A page that schedules ` setTimeout(2000, x) `
277- doesn't block for 2 s; it fires once polling has waited that long.
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.
278284
279285## Known limits
280286
@@ -303,21 +309,26 @@ referenced page-specific DOM.
303309 open a window-handle and ` current_window ` / ` switch_to_window `
304310 work, but each aux window only records its URL (no per-window JS
305311 context or cross-window ` postMessage ` ).
306- - ** Frames, WebSocket, screenshots, and drag pixel coordinates** are
307- out of scope — use Selenium / Cuprite. (EventSource and Web Workers
308- * are* implemented.)
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.)
309317
310318## Architecture
311319
312- - ` lib/capybara/simulated/js/bridge.js ` — the entire DOM lives here.
313- ` Document ` / ` Element ` / ` Text ` / ` DocumentFragment ` / ` ShadowRoot `
314- classes; CSS selector tokeniser + matcher; event dispatch
315- (capture / target / bubble phases with `dispatchEvent(target,
316- event)` ); virtual ` setTimeout` / ` setInterval` /
317- ` requestAnimationFrame ` clock; MutationObserver; custom-element
318- registry; ` Range ` / ` Selection ` ; cascade resolver for ` display ` /
319- ` visibility ` / ` text-transform ` / layout primitives. xpathway (true
320- third-party, under ` vendor/js/ ` ) sits on top for XPath.
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.
321332- ` lib/capybara/simulated/browser.rb ` — Rack client, history stack,
322333 modal handler queue, virtual-clock anchor, trace recorder. Owns
323334 the JS runtime via ` V8Runtime ` or ` QuickJSRuntime ` . The hot
@@ -326,9 +337,9 @@ referenced page-specific DOM.
326337 per-result iteration stays Ruby-side.
327338- ` lib/capybara/simulated/v8_runtime.rb ` / ` quickjs_runtime.rb ` —
328339 per-engine wrappers, common bits in ` runtime_shared.rb ` . The V8
329- base-snapshot (and the QuickJS bytecode equivalent) caches
330- bridge.js + the vendored deps so each Context spawn is
331- sub-millisecond.
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.
332343- ` lib/capybara/simulated/driver.rb ` — Capybara ` Driver::Base `
333344 surface (visit / find / execute_script / window handling / modal /
334345 tracing API).
@@ -368,3 +379,7 @@ pin '@hotwired/turbo'
368379
369380` window.fetch ` routes through Rack, so Turbo's frame fetch and
370381link-action POSTs round-trip the test app.
382+
383+ ## License
384+
385+ [ MIT] ( LICENSE ) .
0 commit comments