capybara-simulated

A simulated browser environment with DOM, JavaScript, and CSS cascade—without a rendering engine.

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.

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.

Its correctness is held continuously to the same web-platform-tests that Chromium and Firefox use.

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 pixel-accurate rendering.

Is it a fit?

A good fit when your tests are JavaScript-driven but don't depend on pixel-accurate rendering:

  • 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.
  • Deterministic — a virtual clock and synchronous in-process execution remove the wall-clock timing, network, and rendering races that make headless-browser suites flaky.
  • Real front-end JS runs: inline <script> + event handlers, MutationObserver, custom elements, <template>, Shadow DOM, ES modules importmap, Hotwire (Stimulus + Turbo), Trix.
  • Drop-in: the Capybara DSL is unchanged — register :simulated and go. Just this gem plus one JS-engine gem.
  • Held to spec: a vendored web-platform-tests 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.

Reach for a real browser (Selenium / Cuprite) when your tests need what this driver doesn't simulate by design — there's no rendering engine, so pixel-accurate rendering (glyph shaping — kerning, ligatures, bidi — the real line-breaking algorithm, multi-line flex-wrap) is out. save_screenshot does paint a real PNG, but it paints what the layout engine believes — enough to see what a test saw, not a visual-regression baseline. There is a coarse box-layout engine — enough that getBoundingClientRect(), elementFromPoint(), obscured?, the spatial selectors, scrolling, and drag-and-drop all work against real boxes — but it answers "where is this, roughly, and what's on top", not "how would this render".

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, coarse layout); see Capabilities & limits.

Install

gem 'capybara-simulated', group: :test
gem 'rusty_racer', group: :test  # JS engine — pick one

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.

System libraries

libvips — Debian/Ubuntu libvips42, Homebrew vips, Gentoo media-libs/vips. The ruby-vips gem comes with the driver and binds to it; the driver names the package it wants if the library is missing.

fontconfig — text is MEASURED from the font file fontconfig resolves each CSS family to (the same face a browser gets on the same machine), so a box's height and a line's wrapping depend on the fonts installed. A machine with no fonts falls back to an estimate and measures text wider or narrower than a real browser would.

JS engine

The gem treats the JS engine as a soft dependency. Pick one of:

gem 'rusty_racer', '>= 0.2.1'  # V8 (JIT, fastest per spec) — default
gem 'quickjs', '>= 0.19'       # QuickJS (interpreter, smaller per-VM RAM —
gem 'quickjs-polyfill-intl'    # wins when scaling parallel workers under
                               # a fixed memory budget). Intl lives in the
                               # companion gem since quickjs 0.19.

The V8 engine comes from 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. 0.2.1 is the floor: the driver needs Context#eval_void (0.2.1) to wire a frame's parent/top without marshalling the WindowProxy it just assigned, and Module#graph_async? (0.2.0) to reject top-level await in a service worker.

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).

Use

require 'capybara/simulated' registers the :simulated driver.

RSpec

# spec/spec_helper.rb (or spec/rails_helper.rb)
require 'capybara/rspec'
require 'capybara/simulated'

Capybara.javascript_driver = :simulated
# Optional: use :simulated for non-JS specs too.
# Capybara.default_driver = :simulated

Tests tagged js: true (or type: :system, js: true in Rails) run in the driver:

RSpec.describe 'sign-in', type: :system, js: true do
  it 'logs the user in' do
    visit '/login'
    fill_in 'Email',    with: 'alice@example.com'
    fill_in 'Password', with: 'hunter2'
    click_button 'Log in'
    expect(page).to have_text('Welcome, Alice')
  end
end

For Rails system tests, set the driver via driven_by:

RSpec.describe 'sign-in', type: :system do
  before { driven_by :simulated }
  # ...
end

Minitest

Capybara.javascript_driver is RSpec-only — ActionDispatch::SystemTestCase ignores it. Set the driver explicitly:

# test/application_system_test_case.rb
require 'capybara/minitest'
require 'capybara/simulated'

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  driven_by :simulated
end

Plain Capybara DSL (no framework)

require 'capybara/dsl'
require 'capybara/simulated'

Capybara.app = MyRackApp
Capybara.default_driver = :simulated

include Capybara::DSL

visit '/'
click_link 'About'
puts page.text

Trace

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, and a failing example gets one screenshot of the state it ended in.

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 parts fire only where they cannot cost a test anything: the DOM is serialized on an action error and only once per action however many times Capybara retries it, and the screenshot is painted after the example.

Modes (CSIM_TRACE=…)

value recording DOM snapshot screenshot
(unset) / on-failure yes (default) per step on action error only one, of the state a failing example ended in
full yes after every action — debug-heavy per action too — debug-heavy
off nothing recorded, record_action early-exits

A screenshot is painted from the layout the driver already holds (see Screenshots), so it shows what the test saw, and rides inline as a data: URL so a trace stays one file and the viewer still opens from file://.

Where it is taken matters more than it sounds. A paint is 33 ms on V8 and 517 ms on QuickJS for a small page — 236 ms and 1.6 s for a 2000-row table — so painting an action's failure would put it inside Capybara's retry window. Capybara retries for the whole wait: one failing click records 183 attempts in its 2 s window here, and photographing them turned a click waiting on an overlay to clear from 35 ms into 563 ms, which is enough to turn an action a retry would have rescued into a failure.

So by default the trace paints exactly once, after the example and only if it failed, where no wait window is running. CSIM_TRACE=full adds a shot per action that succeeded — never a failing attempt, for the reason above, and the successful attempt is the interesting frame of a retried action anyway. Each shot is ~60-75 KB of inline base64, so a long trace in full mode is a multi-MB single-file HTML.

Inspecting traces

In an after-hook:

after(:each) do |example|
  if example.exception
    trace = page.driver.current_trace
    puts trace.steps.last.dom_after  # final-state HTML
    puts trace.steps.flat_map(&:console).map {|c| "#{c[:severity]} #{c[:message]}" }
  end
end

File output

Require the test-framework integration in your spec_helper / rails_helper (RSpec) or test_helper / application_system_test_case.rb (Minitest):

require 'capybara/simulated/rspec'     # RSpec
require 'capybara/simulated/minitest'  # Minitest / Rails system tests

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.

CSIM_TRACE_DIR=tmp/csim-traces bundle exec rspec spec/system

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.

Viewing traces

The recorded JSON stays plain data; to look at one, render it into a self-contained HTML viewer with the bundled CLI:

capybara-simulated trace tmp/csim-traces/checkout_flow.json
# wrote /tmp/checkout_flow.html   (then opens it in your browser)

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, and there is no webfont or CDN to reach for, so it opens offline from a CI download.

It opens on the step that failed (that is what you came for, and on a long trace it is nowhere near the top), keeps that failure in a banner while you read any other step, and marks the step list: ! where an action failed, · where one only logged a warning or error. Per step you get the URL before / after, console output, network requests — click a row for its headers and bodies — the error, and the post-action DOM snapshot as HTML. Screenshots sit in a side rail, which distinguishes the state the example ENDED in from the state at one step. j / k move, f jumps to the failure, and Load JSON… / drag-and-drop swaps in any other trace file.

-o PATH writes the HTML somewhere specific (-o - to stdout); --no-open skips launching the browser. Browser launching uses 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.

Programmatic

For finer control, call driver.start_tracing(...) / driver.stop_tracing(path: ...). The shape mirrors capybara-playwright-driver:

RSpec.describe 'flaky payment flow', type: :system, js: true do
  it 'completes a checkout' do
    page.driver.start_tracing(case_id: 'PAY-1431')
    visit '/checkout'
    fill_in 'Card', with: '4242424242424242'
    click_button 'Pay'
    expect(page).to have_text 'Thank you'
  ensure
    page.driver.stop_tracing(path: "tmp/traces/#{example.full_description}.json")
  end
end

Trace JSON schema

{
  "version": 1,
  "metadata": { "title": "...", "outcome": "passed", "...": "..." },
  "steps": [
    {
      "index":       0,
      "kind":        "visit",       // visit / click / set / send_keys / select / submit / refresh / go_back / go_forward
      "description": "visit /checkout",
      "url_before":  null,
      "url_after":   "http://www.example.com/checkout",
      "dom_after":   null,          // populated only on action error or in `full` mode
      "shot_after":  null,          // …and the same moment PAINTED, as a `data:image/png;base64,…`
                                    // URL — `CSIM_TRACE=full` only, and only for an action that
                                    // SUCCEEDED. A failing example's final state is painted once
                                    // into `metadata.screenshot` instead (see above).
      "console":     [{ "severity": "info", "message": "Stripe.js loaded" }],
      "network":     [{ "method": "GET",    "url": "/checkout", "status": 200 }],
      "elapsed_ms":  0,
      "duration_ms": 38,
      "error":       null
    }
  ]
}

Performance characteristics

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.

Library snapshot policy

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.

Other factors

  • <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.
  • 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.
  • HTTP cache: in-process fetches go through an RFC 9111 cache that is process-wide, like a persistent browser profile. Capybara.reset_sessions! keeps what that profile would: fresh Cache-Control: immutable responses (fingerprinted bundles), still-fresh <script src> / <link rel=stylesheet> sources and @font-face files; other responses are dropped so test-local server state reaches the app. A test whose app serves new bytes at a cacheable URL it already served — a stylesheet digested from a DB row that a rolled-back example reuses — gets the cold cache a fresh Playwright / Cuprite context starts with via page.driver.clear_http_cache (or Capybara::Simulated.clear_http_cache from a hook). Parsed stylesheets, compiled bytecode and decoded images are memoized by content, so they never go stale and are not affected.
  • 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.
  • 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.

Capabilities & limits

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.

Works, with constraints

  • Layout-backed geometry — a coarse box-layout engine (block flow, absolute / relative / fixed, flex and grid track sizing, percentage and viewport units, overflow clipping, the flat tree through shadow roots and slots, and cross-realm frames) backs the page-visible geometry, so there is one geometry that both the driver and the page's own JS read. That gives you obscured? (real occlusion, including out through nested iframes), the spatial selectors (:above / :below / :left_of / :right_of / :near), scroll_to / scroll_by clamped to the real scrollable range, geometry that follows resize_to (so a mobile-breakpoint test measures mobile boxes), and drag_to — which drives jQuery UI, SortableJS, Dragula and jsTree. Text runs are measured with the font's own advance widths, inline content shares a line, tables get real CSS Tables 3 column sizing, and a flex line resolves its items together (CSS Flexbox §9.7: bases, grow / shrink, the automatic minimum, gaps, auto margins, justify-content / align-items), so on the block, inline, absolute, flex and table shapes an app page is built from the boxes it reports match Chrome's to the sub-pixel (measured against headless Chrome, fixture by fixture). It is laid out once per mutation generation and only when something asks for geometry. What it does not do: glyph shaping (kerning / ligatures / bidi), the real line-breaking algorithm, multi-line flex (flex-wrap) — see Out of scope.
  • Screenshotssave_screenshot (and full: true for the whole document) rasters the laid-out page: backgrounds, borders, images, text runs, overflow clipping, scroll offsets and z-index order. It reads the same boxes every geometry query reads, so a screenshot shows what the driver believes — which makes it useful for seeing what a failing test saw, and unsuitable as a pixel baseline. Not painted: background-image, border-radius, opacity, dashed / dotted borders (drawn solid), SVG, and stacking CONTEXTS (z-index is compared globally, not within a parent context). Form controls carry the UA box a browser gives them — border, padding, background, and their own font — so a <button> paints as one; what they don't paint is the WIDGET a browser draws inside it (a checkbox's tick, a select's arrow, the value inside a text field).
  • 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.
  • 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). resize_to moves the whole viewport: innerWidth / innerHeight, the @media cascade, matchMedia change + resize events, and the layout the geometry surface reports — so a mobile-breakpoint test measures mobile boxes. Each window has its own viewport. maximize / fullscreen restore the display size (a fixed 1024×768 — that part isn't configurable).
  • WebSocket + Action Cablenew 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).
  • fetch / XHR — synchronous through Rack: HTML / JSON round-trips work, but there's no streaming, no Request#body ReadableStream, and no concurrent requests.
  • :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.

Out of scope (by design — use Selenium / Cuprite)

  • Pixel-accurate rendering. The layout engine (above) is coarse by design: text is measured from the font's advance widths but not SHAPED (no kerning, ligatures or bidi), lines wrap on an estimate rather than the real line-breaking algorithm, and a wrapping flex line (flex-wrap) stays on one line. (resize_to does move the viewport — what's fixed is the display it sits on, 1024×768.) Anything asserting rendered appearance — exact text wrapping, a sticky header's pixel offset, whether two boxes overlap by 3px — needs a real browser. That includes comparing screenshots: save_screenshot paints from this same coarse layout, so it will differ from a browser's PNG wherever the layout does.

Architecture

  • 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; the cascade resolver for display / visibility / text-transform; and layout.js, the coarse box-layout engine the geometry surface reads from. 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.
  • 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.
  • 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.
  • lib/capybara/simulated/driver.rb — Capybara Driver::Base surface (visit / find / execute_script / window handling / modal / tracing API).
  • lib/capybara/simulated/node.rbDriver::Node over a (handle_id, context_gen) pair so a handle from a pre-rebuild Context can't ghost into the next one.

License

MIT.