Dommy
A pure-Ruby DOM polyfill on top of Nokogiri::HTML5 — a Ruby-side analogue to happy-dom / jsdom. Dommy exposes browser-like DOM semantics (events, MutationObserver, Custom Elements, Shadow DOM, File API, timers, Storage) so view / component / request specs can verify DOM structure and behavior without spinning up a real browser.
Quick start
require "dommy"
win = Dommy.parse("<div id='root'><button class='primary'>Click me</button></div>")
btn = win.document.query_selector(".primary")
clicks = 0
btn.on("click") { clicks += 1 }
btn.click
clicks #=> 1
Installation
# Gemfile
gem "dommy"
Highlights
DOM operations
doc = win.document
li = doc.create_element("li")
li.text_content = "added"
doc.body.append_child(li)
doc.query_selector_all("li").length #=> 1
Custom Elements + lifecycle callbacks
class MyButton < Dommy::HTMLElement
def self.observed_attributes = ["data-state"]
def connected_callback
end
def attribute_changed_callback(name, old, new)
end
end
win.custom_elements.define("my-button", MyButton)
Shadow DOM
host = win.document.create_element("my-card")
sr = host.attach_shadow(mode: "open")
sr.inner_html = "<slot></slot>"
# Outer queries can't reach inside the shadow tree
win.document.query_selector("p") # light DOM only
Form validation
input = win.document.create_element("input")
input.type = "email"
input.set_attribute("required", "")
input.check_validity #=> false
input. #=> "Please fill out this field."
File API (Blob / File / FormData / DataTransfer)
file = Dommy::File.new(["pdf body"], "doc.pdf", "type" => "application/pdf")
# Seed a file input for tests
input = dom.query_selector("input[type='file']")
input.__set_files__([file])
# FormData picks it up
fd = Dommy::FormData.new(dom.query_selector("form"))
fd.entries.to_a #=> [["attachment", #<Dommy::File doc.pdf>]]
# Drag-and-drop simulation
dt = Dommy::DataTransfer.new(files: [file])
ev = Dommy::DragEvent.new("drop", "dataTransfer" => dt, "bubbles" => true)
dropzone.dispatch_event(ev)
# Blob URLs
url = Dommy::URL.create_object_url(blob) # "blob:dommy/..."
Async / Promise#await
Dommy's async surfaces (fetch, custom JS promises) return PromiseValue. Use .await from Ruby to unwrap synchronously:
response = win.__js_call__("fetch", ["/api"]).await
[!WARNING] Most Dommy accessors (
Blob#text,Response#text,localStorage.get_item) return synchronous Ruby values — not Promises..awaitis for the JS-bridged async surface.
Test helpers
Dommy ships test-side modules you can include into RSpec / Minitest. Matchers accept a Dommy::Document / element or a raw HTML string (auto-parsed), matching Capybara's expect(rendered).to ... ergonomics.
Minitest
require "dommy/minitest"
class UserCardTest < Minitest::Test
include Dommy::TestHelpers
include Dommy::Minitest::Assertions
def test_renders
dom = parse_html(render(UserCardComponent.new(name: "Alice")))
assert_dom_contains(dom, "h2", text: "Alice")
assert_dom_contains(dom, "li", count: 3)
end
end
Assertions: assert_dom_contains, assert_dom_contains_text, assert_dom_has_attribute, assert_dom_has_class, assert_dom_html_equal (each with a refute_ counterpart).
RSpec — two matcher flavors
require "dommy/rspec" to get both flavors:
1. Dommy::RSpec::Matchers
_dom_ infix names, coexist with Capybara and rails-dom-testing:
expect(rendered).to contain_dom("h2", text: "Alice")
expect().to have_dom_attribute("type", "submit")
expect().to have_dom_class("primary")
2. Dommy::RSpec::CapyStyleMatchers
Capybara-compatible names for drop-in replacement in view / component / request specs:
expect(rendered).to have_selector("h1", text: "Products")
expect(rendered).to have_link("Sign up", href: "/signup")
expect(rendered).to ("Submit")
expect(rendered).to have_no_selector(".hidden")
Use a type: split to keep real-browser Capybara on feature specs while letting Dommy run the rest:
RSpec.configure do |c|
c.include Capybara::DSL, type: :feature
c.include Capybara::RSpecMatchers, type: :feature
%i[view component request controller helper].each do |t|
c.include Dommy::TestHelpers, type: t
c.include Dommy::RSpec::CapyStyleMatchers, type: t
end
end
Supported Capybara-style options: text: / exact: / count: (Integer or Range) / visible: / href: / with: / type:. wait: is accepted and ignored (Dommy is synchronous).
[!CAUTION]
:visibleis HTML-level only. Dommy has no CSS engine, sodisplay: noneset via a CSS class is not detected. Detection covers thehiddenattribute,<input type=hidden>, non-rendering ancestors (head/script/style/template), and inlinestyle="display: none"/visibility: hidden. If you toggle visibility through a CSS class, assert on the class instead (have_dom_class("hidden")) or keep that spec on Capybara + a real browser.
What's in scope
Implemented:
- Core DOM (Document, Element, Text/Comment/Fragment, NodeList, Attr)
- 26 specialized HTMLElement subclasses
- SVG: SVGElement base + ~63 specialized subclasses covering shapes, gradients, marker / mask, the full set of standard filter primitives (Gaussian blur, offset, blend, color matrix, flood, composite, merge, component transfer, tile, morphology, image, drop shadow, turbulence, displacement map, convolve matrix, diffuse / specular lighting + light sources),
<a>/<textPath>/<view>/<switch>/<metadata>, and SMIL animation (<animate>/<animateTransform>/<animateMotion>/<set>/<mpath>/<discard>) — with case-sensitive attribute round-trip - events with composedPath / AbortSignal
- MutationObserver (childList / attributes / characterData / subtree)
- Custom Elements lifecycle
- Shadow DOM (open/closed, slots, event composition)
- form validation
- Scheduler (timers + microtasks with
advance_time) - Promise
- Location / History / URL (WHATWG-leaning parsing with whitespace/tab/newline stripping, percent-encoding of unsafe path chars,
\→/conversion for special schemes,./and../resolution, IPv4 number forms normalization, ws/wss default port stripping, Punycode hostname encoding, full UTS #46 IDNA with RFC 5893 Bidi and RFC 5892 ContextJ/ContextO) - Storage
- fetch (stub) / XMLHttpRequest (stub-driven, sync + async, shares the
__fetchy_stub__fixture map) - WebSocket / EventSource — test seams (
__simulate_open__/__simulate_message__/__simulate_close__) drive the streams - MessageChannel / MessagePort / BroadcastChannel — in-process pub/sub with
structuredClone-on-transfer - FileReader (
readAsText/readAsDataURL/readAsArrayBuffer/readAsBinaryString) - Notification (permission settable via
Notification.__set_permission__) - Geolocation (mock position via
navigator.geolocation.__set_position__) window.matchMediareturning aMediaQueryList(__set_matches__flips and fireschange)requestIdleCallback/cancelIdleCallback(modelled on scheduler),structuredCloneglobalcrypto.subtle.digest(SHA-1/256/384/512), HMAC sign/verify/import/generateKey, and AES-GCM encrypt/decrypt (128/256-bit with additionalData and tagLength options)- Streams API (
ReadableStream/WritableStream/TransformStream+TextEncoderStream/TextDecoderStream) CompressionStream/DecompressionStream(gzip / deflate / deflate-raw via RubyZlib)- Worker (inline-emulated — same-process message round-trip; tests register handlers via
worker.__on_message__) - Performance User Timing (
performance.mark/measure/getEntriesByName) cookieStore(async Cookie Store API, backed by the same jardocument.cookieuses)- Navigator extras:
share/vibrate/wakeLock.request/getBattery/locks(Web Locks) /storage.estimate/persist/persisted(StorageManager) - Layout-adjacent stubs:
element.scrollIntoView/scrollTo/ scroll & client / offset metrics (return 0),getComputedStyle(inline style passthrough) - Popover API (
showPopover/hidePopover/togglePopoverwithbeforetoggle/toggleevents) - Fullscreen API (
element.requestFullscreen/document.exitFullscreen/fullscreenchange) URLPattern(pattern matching for URL components — protocol, username, password, hostname, port, pathname, search, hash — with named capture groups and modifiers::id,*,:version+,:version?)document.startViewTransition(View Transitions API stub)- Navigator / Clipboard
- TreeWalker / NodeIterator / NodeFilter
- File API (Blob / File / FileList / FormData / DataTransfer)
- Web Crypto (
crypto.randomUUID,getRandomValues) / TextEncoder / TextDecoder - IntersectionObserver / ResizeObserver / PerformanceObserver (test-driven
__trigger__) - Range / Selection (DOM-level only, no layout)
- Web Animations API (Animation / KeyframeEffect; lifecycle via scheduler, finished/ready Promises)
- Extended events: Touch / Clipboard / Composition / Wheel / Focus / BeforeUnload / Input / Pointer / Progress / Drag
[!IMPORTANT] Out of scope:
- requires a layout / CSS engine or media subsystems: real
getBoundingClientRect/ scroll metrics- CSS scoping (
:host,::slotted, computed styles)- JS evaluation
- Canvas / WebGL / media playback
- layout-dependent Range / Selection geometry (
getBoundingClientRectreturns zero rects)- SVG-specific value types (SVGAnimatedLength, SVGTransform, SVGMatrix)
- Web Animations: no actual value interpolation —
Animationis a state machine (idle/running/paused/finished) for testing lifecycle and event wiring
Running the tests
$ bundle install
$ bundle exec rake test
License
MIT