dommy 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +213 -0
  3. data/lib/dommy/attr.rb +200 -0
  4. data/lib/dommy/blob.rb +182 -0
  5. data/lib/dommy/bridge.rb +141 -0
  6. data/lib/dommy/css.rb +283 -0
  7. data/lib/dommy/custom_elements.rb +125 -0
  8. data/lib/dommy/data_transfer.rb +98 -0
  9. data/lib/dommy/document.rb +674 -0
  10. data/lib/dommy/dom_exception.rb +258 -0
  11. data/lib/dommy/dom_parser.rb +88 -0
  12. data/lib/dommy/element.rb +1975 -0
  13. data/lib/dommy/event.rb +589 -0
  14. data/lib/dommy/fetch.rb +241 -0
  15. data/lib/dommy/form_data.rb +208 -0
  16. data/lib/dommy/html_collection.rb +207 -0
  17. data/lib/dommy/html_elements.rb +4455 -0
  18. data/lib/dommy/internal/cookie_jar.rb +27 -0
  19. data/lib/dommy/internal/dom_matching.rb +141 -0
  20. data/lib/dommy/internal/mutation_coordinator.rb +172 -0
  21. data/lib/dommy/internal/node_traversal.rb +36 -0
  22. data/lib/dommy/internal/node_wrapper_cache.rb +179 -0
  23. data/lib/dommy/internal/observer_manager.rb +31 -0
  24. data/lib/dommy/internal/observer_matcher.rb +31 -0
  25. data/lib/dommy/internal/scope_resolution.rb +27 -0
  26. data/lib/dommy/internal/shadow_root_registry.rb +35 -0
  27. data/lib/dommy/internal/template_content_registry.rb +97 -0
  28. data/lib/dommy/minitest/assertions.rb +105 -0
  29. data/lib/dommy/minitest.rb +17 -0
  30. data/lib/dommy/navigator.rb +271 -0
  31. data/lib/dommy/node.rb +218 -0
  32. data/lib/dommy/observer.rb +199 -0
  33. data/lib/dommy/parser.rb +29 -0
  34. data/lib/dommy/promise.rb +199 -0
  35. data/lib/dommy/router.rb +275 -0
  36. data/lib/dommy/rspec/capy_style_matchers.rb +356 -0
  37. data/lib/dommy/rspec/matchers.rb +230 -0
  38. data/lib/dommy/rspec.rb +18 -0
  39. data/lib/dommy/scheduler.rb +135 -0
  40. data/lib/dommy/shadow_root.rb +255 -0
  41. data/lib/dommy/storage.rb +112 -0
  42. data/lib/dommy/test_helpers.rb +78 -0
  43. data/lib/dommy/tree_walker.rb +425 -0
  44. data/lib/dommy/url.rb +479 -0
  45. data/lib/dommy/version.rb +5 -0
  46. data/lib/dommy/world.rb +209 -0
  47. data/lib/dommy.rb +119 -0
  48. metadata +110 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 217a4ff53f58e6c12e424951b057cc0d3cbab3b898fdb85d5fd69ca7c34cd3c5
4
+ data.tar.gz: bedaba24772a900d85e62a27729b8e62f8db8a18e0b48360f1717799108432f8
5
+ SHA512:
6
+ metadata.gz: 3a9687cf21dde61fa2c65cd47ca90f18e4d65da61912ffdbd8366cbf75f1110a96da52c43a27abee498dc9f2a19ffc306a491e14bb6aa4d985f7dd74fec88816
7
+ data.tar.gz: 201bb5b9217bc0373923f89e36cc968cb55328f249d902acdfa973f9f01179d9912463b5ee9d165c8c5e862fb7ff3f40ed531db9f2ee811432e6f77db8c42faa
data/README.md ADDED
@@ -0,0 +1,213 @@
1
+ # Dommy
2
+
3
+ A pure-Ruby DOM polyfill on top of [Nokogiri::HTML5](https://nokogiri.org/) — a Ruby-side analogue to happy-dom / jsdom.
4
+ 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.
5
+
6
+ ## Quick start
7
+
8
+ ```ruby
9
+ require "dommy"
10
+
11
+ win = Dommy.parse("<div id='root'><button class='primary'>Click me</button></div>")
12
+ btn = win.document.query_selector(".primary")
13
+
14
+ clicks = 0
15
+ btn.on("click") { clicks += 1 }
16
+ btn.click
17
+ clicks #=> 1
18
+ ```
19
+
20
+ ## Installation
21
+
22
+ ```ruby
23
+ # Gemfile
24
+ gem "dommy"
25
+ ```
26
+
27
+ ## Highlights
28
+
29
+ ### DOM operations
30
+
31
+ ```ruby
32
+ doc = win.document
33
+ li = doc.create_element("li")
34
+ li.text_content = "added"
35
+ doc.body.append_child(li)
36
+ doc.query_selector_all("li").length #=> 1
37
+ ```
38
+
39
+ ### Custom Elements + lifecycle callbacks
40
+
41
+ ```ruby
42
+ class MyButton < Dommy::HTMLElement
43
+ def self.observed_attributes = ["data-state"]
44
+ def connected_callback
45
+ end
46
+ def attribute_changed_callback(name, old, new)
47
+ end
48
+ end
49
+
50
+ win.custom_elements.define("my-button", MyButton)
51
+ ```
52
+
53
+ ### Shadow DOM
54
+
55
+ ```ruby
56
+ host = win.document.create_element("my-card")
57
+ sr = host.attach_shadow(mode: "open")
58
+ sr.inner_html = "<slot></slot>"
59
+
60
+ # Outer queries can't reach inside the shadow tree
61
+ win.document.query_selector("p") # light DOM only
62
+ ```
63
+
64
+ ### Form validation
65
+
66
+ ```ruby
67
+ input = win.document.create_element("input")
68
+ input.type = "email"
69
+ input.set_attribute("required", "")
70
+ input.check_validity #=> false
71
+ input.validation_message #=> "Please fill out this field."
72
+ ```
73
+
74
+ ### File API (Blob / File / FormData / DataTransfer)
75
+
76
+ ```ruby
77
+ file = Dommy::File.new(["pdf body"], "doc.pdf", "type" => "application/pdf")
78
+
79
+ # Seed a file input for tests
80
+ input = dom.query_selector("input[type='file']")
81
+ input.__set_files__([file])
82
+
83
+ # FormData picks it up
84
+ fd = Dommy::FormData.new(dom.query_selector("form"))
85
+ fd.entries.to_a #=> [["attachment", #<Dommy::File doc.pdf>]]
86
+
87
+ # Drag-and-drop simulation
88
+ dt = Dommy::DataTransfer.new(files: [file])
89
+ ev = Dommy::DragEvent.new("drop", "dataTransfer" => dt, "bubbles" => true)
90
+ dropzone.dispatch_event(ev)
91
+
92
+ # Blob URLs
93
+ url = Dommy::URL.create_object_url(blob) # "blob:dommy/..."
94
+ ```
95
+
96
+ ### Async / Promise#await
97
+
98
+ Dommy's async surfaces (fetch, custom JS promises) return `PromiseValue`. Use `.await` from Ruby to unwrap synchronously:
99
+
100
+ ```ruby
101
+ response = win.__js_call__("fetch", ["/api"]).await
102
+ ```
103
+
104
+ > [!WARNING]
105
+ > Most Dommy accessors (`Blob#text`, `Response#text`, `localStorage.get_item`) return synchronous Ruby values — not Promises. `.await` is for the JS-bridged async surface.
106
+
107
+ ## Test helpers
108
+
109
+ 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.
110
+
111
+ ### Minitest
112
+
113
+ ```ruby
114
+ require "dommy/minitest"
115
+
116
+ class UserCardTest < Minitest::Test
117
+ include Dommy::TestHelpers
118
+ include Dommy::Minitest::Assertions
119
+
120
+ def test_renders
121
+ dom = parse_html(render(UserCardComponent.new(name: "Alice")))
122
+ assert_dom_contains(dom, "h2", text: "Alice")
123
+ assert_dom_contains(dom, "li", count: 3)
124
+ end
125
+ end
126
+ ```
127
+
128
+ 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).
129
+
130
+ ### RSpec — two matcher flavors
131
+
132
+ `require "dommy/rspec"` to get both flavors:
133
+
134
+ #### 1. `Dommy::RSpec::Matchers`
135
+
136
+ `_dom_` infix names, coexist with Capybara and `rails-dom-testing`:
137
+
138
+ ```ruby
139
+ expect(rendered).to contain_dom("h2", text: "Alice")
140
+ expect(button).to have_dom_attribute("type", "submit")
141
+ expect(button).to have_dom_class("primary")
142
+ ```
143
+
144
+ #### 2. `Dommy::RSpec::CapyStyleMatchers`
145
+
146
+ Capybara-compatible names for drop-in replacement in view / component / request specs:
147
+
148
+ ```ruby
149
+ expect(rendered).to have_selector("h1", text: "Products")
150
+ expect(rendered).to have_link("Sign up", href: "/signup")
151
+ expect(rendered).to have_button("Submit")
152
+ expect(rendered).to have_no_selector(".hidden")
153
+ ```
154
+
155
+ Use a `type:` split to keep real-browser Capybara on feature specs while letting Dommy run the rest:
156
+
157
+ ```ruby
158
+ RSpec.configure do |c|
159
+ c.include Capybara::DSL, type: :feature
160
+ c.include Capybara::RSpecMatchers, type: :feature
161
+
162
+ %i[view component request controller helper].each do |t|
163
+ c.include Dommy::TestHelpers, type: t
164
+ c.include Dommy::RSpec::CapyStyleMatchers, type: t
165
+ end
166
+ end
167
+ ```
168
+
169
+ Supported Capybara-style options: `text:` / `exact:` / `count:` (Integer or Range) / `visible:` / `href:` / `with:` / `type:`. `wait:` is accepted and ignored (Dommy is synchronous).
170
+
171
+ > [!CAUTION]
172
+ > `:visible` is HTML-level only. Dommy has no CSS engine, so `display: none` set via a CSS class is **not** detected. Detection covers the `hidden` attribute, `<input type=hidden>`, non-rendering ancestors (`head`/`script`/`style`/`template`), and inline `style="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.
173
+
174
+ ## What's in scope
175
+
176
+ Implemented:
177
+
178
+ - Core DOM (Document, Element, Text/Comment/Fragment, NodeList, Attr)
179
+ - 26 specialized HTMLElement subclasses
180
+ - events with composedPath / AbortSignal
181
+ - MutationObserver (childList / attributes / characterData / subtree)
182
+ - Custom Elements lifecycle
183
+ - Shadow DOM (open/closed, slots, event composition)
184
+ - form validation
185
+ - Scheduler (timers + microtasks with `advance_time`)
186
+ - Promise
187
+ - Location / History / URL
188
+ - Storage
189
+ - fetch (stub)
190
+ - Navigator / Clipboard
191
+ - TreeWalker / NodeIterator / NodeFilter
192
+ - File API (Blob / File / FileList / FormData / DataTransfer)
193
+
194
+ > [!IMPORTANT]
195
+ > Out of scope:
196
+ >
197
+ > - requires a layout / CSS engine or media subsystems: real `getBoundingClientRect` / scroll metrics
198
+ > - CSS scoping (`:host`, `::slotted`, computed styles)
199
+ > - JS evaluation
200
+ > - Canvas / WebGL / media playback
201
+ > - layout-dependent Range / Selection
202
+ > - SVG specialized classes
203
+
204
+ ## Running the tests
205
+
206
+ ```sh
207
+ $ bundle install
208
+ $ bundle exec rake test
209
+ ```
210
+
211
+ ## License
212
+
213
+ MIT
data/lib/dommy/attr.rb ADDED
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ # `Attr` — wraps an HTML attribute as a Node-like object. In real
5
+ # DOM each attribute on an element is an Attr; `el.getAttributeNode`
6
+ # returns the instance, `attr.value = "x"` mutates the element's
7
+ # attribute, `attr.ownerElement` points back to the element.
8
+ #
9
+ # We represent two states:
10
+ # - "owned" — the Attr is attached to an Element. value reads/writes
11
+ # go through the element's Nokogiri attribute slot.
12
+ # - "detached" — created via `document.createAttribute(name)` but
13
+ # not yet attached. Value is stored locally; `setAttributeNode`
14
+ # transfers it to an element.
15
+ class Attr
16
+ attr_reader :name
17
+
18
+ def initialize(name, owner: nil, value: "")
19
+ @name = name.to_s.downcase
20
+ @owner = owner
21
+ @detached_value = value.to_s
22
+ end
23
+
24
+ # The Element this attr is on, or nil if detached.
25
+ def owner_element
26
+ @owner
27
+ end
28
+
29
+ def value
30
+ if @owner
31
+ @owner.__node__[@name].to_s
32
+ else
33
+ @detached_value
34
+ end
35
+ end
36
+
37
+ def value=(new_value)
38
+ if @owner
39
+ @owner.set_attribute(@name, new_value.to_s)
40
+ else
41
+ @detached_value = new_value.to_s
42
+ end
43
+ end
44
+
45
+ def __js_get__(key)
46
+ case key
47
+ when "name"
48
+ @name
49
+ when "value"
50
+ value
51
+ when "nodeName"
52
+ @name
53
+ when "nodeValue"
54
+ value
55
+ when "ownerElement"
56
+ @owner
57
+ when "localName"
58
+ @name
59
+ when "namespaceURI"
60
+ nil
61
+ when "nodeType"
62
+ 2
63
+ end
64
+ end
65
+
66
+ def __js_set__(key, val)
67
+ case key
68
+ when "value", "nodeValue"
69
+ self.value = val
70
+ end
71
+
72
+ nil
73
+ end
74
+
75
+ def __js_call__(method, _args)
76
+ case method
77
+ when "cloneNode"
78
+ Attr.new(@name, owner: nil, value: value)
79
+ end
80
+ end
81
+
82
+ # Internal: called by Element when the attr is being transferred
83
+ # to (or detached from) an Element.
84
+ def __attach__(element)
85
+ @owner = element
86
+ @detached_value = ""
87
+ nil
88
+ end
89
+
90
+ def __detach__
91
+ cached = value
92
+ @owner = nil
93
+ @detached_value = cached
94
+ nil
95
+ end
96
+ end
97
+
98
+ # `Element.attributes` returns this. Iterable, `.length`, `.item(i)`,
99
+ # `.getNamedItem(name)`, `.removeNamedItem(name)`, `.setNamedItem(attr)`,
100
+ # plus property-style access (`attributes.id`, `attributes.class`).
101
+ #
102
+ # NamedNodeMap is *live* — it re-reads the element's Nokogiri
103
+ # attributes on every access so DOM mutations are reflected.
104
+ class NamedNodeMap
105
+ include Enumerable
106
+
107
+ def initialize(element)
108
+ @element = element
109
+ end
110
+
111
+ def length
112
+ @element.__node__.attribute_nodes.size
113
+ end
114
+
115
+ alias size length
116
+
117
+ def item(index)
118
+ name = @element.__node__.attribute_nodes[index.to_i]&.name
119
+ name && Attr.new(name, owner: @element)
120
+ end
121
+
122
+ def get_named_item(name)
123
+ key = name.to_s.downcase
124
+ return nil unless @element.__node__.key?(key)
125
+
126
+ Attr.new(key, owner: @element)
127
+ end
128
+
129
+ def set_named_item(attr)
130
+ return nil unless attr.is_a?(Attr)
131
+
132
+ key = attr.name
133
+ val = attr.value
134
+ attr.__attach__(@element)
135
+ @element.set_attribute(key, val)
136
+ attr
137
+ end
138
+
139
+ def remove_named_item(name)
140
+ key = name.to_s.downcase
141
+ return nil unless @element.__node__.key?(key)
142
+
143
+ attr = Attr.new(key, owner: nil, value: @element.__node__[key].to_s)
144
+ @element.remove_attribute(key)
145
+ attr
146
+ end
147
+
148
+ def each(&blk)
149
+ @element.__node__.attribute_nodes.each do |a|
150
+ yield Attr.new(a.name, owner: @element)
151
+ end
152
+ end
153
+
154
+ # Property-style access — `el.attributes.id`, `el.attributes["class"]`.
155
+ def [](key)
156
+ case key
157
+ when Integer
158
+ item(key)
159
+ else
160
+ get_named_item(key)
161
+ end
162
+ end
163
+
164
+ def __js_get__(key)
165
+ case key
166
+ when "length"
167
+ length
168
+ else
169
+ # Numeric key = item(i); string key = named item
170
+ if key.is_a?(Integer) || key.to_s.match?(/\A\d+\z/)
171
+ item(key.to_i)
172
+ else
173
+ get_named_item(key)
174
+ end
175
+ end
176
+ end
177
+
178
+ def __js_call__(method, args)
179
+ case method
180
+ when "item"
181
+ item(args[0])
182
+ when "getNamedItem"
183
+ get_named_item(args[0])
184
+ when "setNamedItem"
185
+ set_named_item(args[0])
186
+ when "removeNamedItem"
187
+ remove_named_item(args[0])
188
+ end
189
+ end
190
+
191
+ def method_missing(name, *args)
192
+ attr = get_named_item(name)
193
+ attr || super
194
+ end
195
+
196
+ def respond_to_missing?(name, include_private = false)
197
+ @element.__node__.key?(name.to_s.downcase) || super
198
+ end
199
+ end
200
+ end
data/lib/dommy/blob.rb ADDED
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ # `Blob` — opaque binary chunk with a MIME type, mirroring the
5
+ # File API's `Blob` interface. Used by File, FormData, and any
6
+ # code that needs to round-trip bytes through the DOM (e.g. a
7
+ # `<input type="file">` test scenario).
8
+ #
9
+ # Spec: https://w3c.github.io/FileAPI/#blob-section
10
+ class Blob
11
+ attr_reader :size, :type
12
+
13
+ # Construct a Blob from a list of parts. Each part can be:
14
+ # - String (treated as binary bytes)
15
+ # - Blob / File (their bytes are concatenated)
16
+ # - Array<Integer> (byte values, like ArrayBuffer)
17
+ # - anything else: coerced via to_s
18
+ #
19
+ # `options["type"]` sets the MIME type (lowercased per spec).
20
+ def initialize(parts = [], options = {})
21
+ parts = [parts] unless parts.is_a?(Array)
22
+ @data = collect_bytes(parts)
23
+ @size = @data.bytesize
24
+ raw_type = options["type"] || options[:type] || ""
25
+ @type = raw_type.to_s.downcase
26
+ end
27
+
28
+ # Return a new Blob over a byte range of this one.
29
+ # Negative indices are treated as offsets from the end (per spec).
30
+ def slice(start = 0, last = @size, content_type = "")
31
+ s = clamp_index(start.to_i, @size)
32
+ e = clamp_index(last.to_i, @size)
33
+ e = s if e < s
34
+ Blob.new([@data.byteslice(s, e - s) || ""], "type" => content_type.to_s)
35
+ end
36
+
37
+ # Read the bytes as UTF-8 text. The DOM spec returns a Promise,
38
+ # but Dommy is synchronous, so callers can use the result directly.
39
+ def text
40
+ @data.dup.force_encoding(Encoding::UTF_8)
41
+ end
42
+
43
+ # Read the bytes as an Array<Integer>. The DOM spec returns a
44
+ # Promise<ArrayBuffer>; Dommy is synchronous.
45
+ def array_buffer
46
+ @data.bytes
47
+ end
48
+
49
+ # Raw binary bytes (Ruby ASCII-8BIT string). Used by FormData /
50
+ # fetch when serializing multipart bodies.
51
+ def __bytes__
52
+ @data
53
+ end
54
+
55
+ def __js_get__(key)
56
+ case key
57
+ when "size"
58
+ @size
59
+ when "type"
60
+ @type
61
+ end
62
+ end
63
+
64
+ def __js_call__(method, args)
65
+ case method
66
+ when "slice"
67
+ slice(args[0] || 0, args[1] || @size, args[2] || "")
68
+ when "text"
69
+ text
70
+ when "arrayBuffer"
71
+ array_buffer
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def collect_bytes(parts)
78
+ buf = String.new(encoding: Encoding::ASCII_8BIT)
79
+ parts.each do |part|
80
+ case part
81
+ when Blob
82
+ buf << part.__bytes__
83
+ when String
84
+ buf << part.dup.force_encoding(Encoding::ASCII_8BIT)
85
+ when Array
86
+ buf << part.pack("C*")
87
+ else
88
+ buf << part.to_s.dup.force_encoding(Encoding::ASCII_8BIT)
89
+ end
90
+ end
91
+
92
+ buf
93
+ end
94
+
95
+ def clamp_index(idx, length)
96
+ idx = length + idx if idx.negative?
97
+ idx.clamp(0, length)
98
+ end
99
+ end
100
+
101
+ # `File` — Blob with a filename and an optional last-modified
102
+ # timestamp. Returned from `<input type="file">` / drag-and-drop,
103
+ # and accepted by FormData.
104
+ #
105
+ # Spec: https://w3c.github.io/FileAPI/#file-section
106
+ class File < Blob
107
+ attr_reader :name, :last_modified
108
+
109
+ def initialize(parts, name, options = {})
110
+ super(parts, options)
111
+ @name = name.to_s
112
+ raw_lm = options["lastModified"] || options[:lastModified]
113
+ @last_modified = (raw_lm || (Time.now.to_f * 1000)).to_i
114
+ end
115
+
116
+ def __js_get__(key)
117
+ case key
118
+ when "name"
119
+ @name
120
+ when "lastModified"
121
+ @last_modified
122
+ else
123
+ super
124
+ end
125
+ end
126
+ end
127
+
128
+ # `FileList` — immutable, ordered collection of File objects.
129
+ # Returned by `<input type="file">#files` and DataTransfer#files.
130
+ #
131
+ # Spec: https://w3c.github.io/FileAPI/#filelist-section
132
+ class FileList
133
+ include Enumerable
134
+
135
+ def initialize(files = [])
136
+ @files = files.to_a.freeze
137
+ end
138
+
139
+ def length
140
+ @files.length
141
+ end
142
+
143
+ alias size length
144
+
145
+ def item(index)
146
+ @files[index.to_i]
147
+ end
148
+
149
+ def [](index)
150
+ item(index)
151
+ end
152
+
153
+ def each(&block)
154
+ @files.each(&block)
155
+ self
156
+ end
157
+
158
+ def empty?
159
+ @files.empty?
160
+ end
161
+
162
+ def to_a
163
+ @files.dup
164
+ end
165
+
166
+ def __js_get__(key)
167
+ case key
168
+ when "length"
169
+ length
170
+ else
171
+ item(key.to_i) if key.is_a?(Integer) || key.to_s.match?(/\A-?\d+\z/)
172
+ end
173
+ end
174
+
175
+ def __js_call__(method, args)
176
+ case method
177
+ when "item"
178
+ item(args[0])
179
+ end
180
+ end
181
+ end
182
+ end