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
- 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
- Storage
- fetch (stub)
- Navigator / Clipboard
- TreeWalker / NodeIterator / NodeFilter
- File API (Blob / File / FileList / FormData / DataTransfer)
[!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
- SVG specialized classes
Running the tests
$ bundle install
$ bundle exec rake test
License
MIT