capybara-simulated
A lightweight Capybara driver that runs JavaScript in a long-lived mini_racer V8 context against a happy-dom DOM. XPath queries are powered by fontoxpath.
The goal is the middle ground between rack-test (zero JS) and full
headless browsers like cuprite/selenium: in-process tests, no Chrome,
inline <script> and event handlers run, the Capybara DSL works, and
forms submit through Rack::MockRequest.
Status
PoC. Against Capybara 3.40's shared Capybara::SpecHelper.spec suite the
driver passes 1337 / 1357 examples (98.5%) with the unsupported-
capability tags about_scheme, css, download, frames, hover,
screenshot, scroll, server, spatial, windows filtered out.
Tags that started skipped but now pass: active_element, modals
(alert/confirm/prompt incl. nested + page-change + async),
html_validation, send_keys, shadow_dom. The remaining 20
failures all need capabilities the driver intentionally does not
implement:
- 19
#drag_totests — Dragula / SortableJS / jsTree resolve drop targets throughelementFromPoint(clientX, clientY), which needs a real layout engine with stacking-context awareness. - 1
#click should not retry clicking when wait is disabled— depends on the sameelementFromPoint-based obscured-element detection.
evaluate_async_script is supported by polling the Ruby↔V8 bridge while
draining the virtual clock until the user callback fires (or
Capybara.default_max_wait_time elapses). Click offsets and Element#drop
work without a real layout engine: click x/y are resolved into clientX/Y
by walking computed top/left/width/height (px only, ancestor-sum)
on the way out of the runtime.
Turbo + Stimulus (Rails)
The driver targets Rails apps using importmap-rails + Turbo + Stimulus out of the box:
<script type="importmap">is parsed;<script type="module">and every reachableimportare pre-fetched through the Rack app and bundled on the fly vianode vendor/js/bundle-modules.mjs(a small esbuild driver). The resulting IIFE is evaluated in the same isolate as inline scripts and shares the same happy-dom Window.fetchis replaced with a Rack-routed implementation: cookies follow the jar,X-CSRF-Tokenpropagates, redirects are followed up to 20 hops, andResponse/Headers/Request/AbortControllercome from happy-dom. WebSocket / EventSource / Action Cable broadcasts are out of scope — use Selenium for those flows.- Custom-element upgrade is patched at
customElements.defineto rebuild the document's id-element index — happy-dom 20's auto-upgrade leaves the original (un-upgraded) element in the index, sogetElementById('records')after a Turbo Stream<turbo-frame>swap would otherwise return a detached ghost. - Click on
<button type="submit">lets happy-dom auto-dispatch thesubmitevent with the propersubmitterfield; we capture whether the event was preventDefaulted and skip our own redundant submit dispatch. Without this, Turbo'sFormSubmitObserversaw a second submitter-less event and intercepted forms whose submitter haddata-turbo="false". - Page-level globals (
addEventListener,MutationObserver,requestAnimationFrame,CustomEvent,getComputedStyle,IntersectionObserverstub, etc.) are mirrored from the active Window ontoglobalThisso module bundles running at the top level find them without going throughwindow.*. - happy-dom's
MutationObserverListenerkeeps its dispatch callback in aWeakRef, with no other strong reference. V8 collects the arrow before the next mutation fires, sotarget[mutationListeners]still carries the listener butcallback.deref()returns undefined and every record (and every subtree-propagation hop onappendChild) is silently dropped — Stimulus loses sight of buttons added inside a swapped<turbo-frame>. We patchMutationObserver.prototype.observeper Window to swap each WeakRef out for a strong-reference shim with the same.deref()shape, so listeners survive the next GC.
WebSocket, frames and multi-window remain explicitly out of scope — they need a real browser (Selenium / Cuprite) or a separate transport that this driver does not provide.
The V8 isolate is created once per Capybara::Simulated::Browser instance
and reused across all visits and resets — only the happy-dom Window is
torn down between specs. This keeps reset! cheap.
Build
npm install
npm run build # produces vendor/js/csim.bundle.js (~3.3MB)
bundle install
bundle exec rspec
Use
require 'capybara/simulated'
Capybara.javascript_driver = :simulated
How it fits together
vendor/js/prelude.js— minimal Web Platform polyfills (TextEncoder, atob/btoa, crypto.getRandomValues, performance, timers, process).vendor/js/csim.bundle.js— bundled happy-dom + whatwg-url + fontoxpath. Built viabuild.mjswith esbuild, with shims for the Node built-ins happy-dom imports (url,buffer,vm,path, etc.).vendor/js/runtime.js— driver glue exposed onglobalThis.__csim. Manages the active happy-domWindow, an integer→DOM-node handle table, modal capture, form serialization, click/submit dispatch, and XPath via fontoxpath with a custom DOM facade.lib/capybara/simulated/browser.rb— owns theMiniRacer::Context, drives HTTP viaRack::MockRequest, fetches<script src>inline, and routes form submissions back through Rack.lib/capybara/simulated/{driver,node}.rb— CapybaraDriver::BaseandDriver::Nodeimplementations.
Known limits
- happy-dom is not a layout engine —
visible?is heuristic (display:none,visibility:hidden,hiddenattribute, head/script). - No fetch/XHR.
<script src>is inlined viaRack::MockRequest. Real navigation only happens on link click and form submit. evaluate_async_script, frames, multi-window, file uploads, screenshots, CSS computed-style filters, scroll/drag pixel coordinates are out of scope.