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
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ # Deterministic host-side scheduler for timers, rAF, and microtasks.
5
+ # Time advances only when the host explicitly calls `advance_time`.
6
+ class Scheduler
7
+ Timer = Struct.new(:id, :kind, :callback, :due_at, :interval_ms, :active)
8
+
9
+ FRAME_MS = 16
10
+
11
+ def initialize
12
+ @now_ms = 0
13
+ @next_id = 1
14
+ @timers = {}
15
+ @microtasks = []
16
+ end
17
+
18
+ attr_reader :now_ms
19
+
20
+ def set_timeout(callback, delay_ms)
21
+ register_timer(:timeout, callback, delay_ms.to_i, nil)
22
+ end
23
+
24
+ def clear_timeout(id)
25
+ cancel_timer(id)
26
+ end
27
+
28
+ def set_interval(callback, interval_ms)
29
+ ms = [interval_ms.to_i, 0].max
30
+ register_timer(:interval, callback, ms, ms)
31
+ end
32
+
33
+ def clear_interval(id)
34
+ cancel_timer(id)
35
+ end
36
+
37
+ def request_animation_frame(callback)
38
+ frames = ((@now_ms / FRAME_MS) + 1) * FRAME_MS
39
+ id = next_id
40
+ @timers[id] = Timer.new(id, :raf, callback, frames, nil, true)
41
+ id
42
+ end
43
+
44
+ def cancel_animation_frame(id)
45
+ cancel_timer(id)
46
+ end
47
+
48
+ def queue_microtask(callback)
49
+ @microtasks << callback
50
+ nil
51
+ end
52
+
53
+ def drain_microtasks
54
+ until @microtasks.empty?
55
+ callback = @microtasks.shift
56
+ invoke_callback(callback, [@now_ms])
57
+ end
58
+
59
+ nil
60
+ end
61
+
62
+ def advance_time(delta_ms)
63
+ target = @now_ms + [delta_ms.to_i, 0].max
64
+ while next_due_timer_at && next_due_timer_at <= target
65
+ @now_ms = next_due_timer_at
66
+ run_due_timers
67
+ drain_microtasks
68
+ end
69
+
70
+ @now_ms = target
71
+ drain_microtasks
72
+ nil
73
+ end
74
+
75
+ def drain_timers(advance: 0)
76
+ advance_time(advance)
77
+ end
78
+
79
+ # Public accessor for eval-time auto-drain: keep advancing the
80
+ # clock until no timers remain (or a safety budget runs out).
81
+ def next_due_timer_at
82
+ @timers.values.select(&:active).map(&:due_at).min
83
+ end
84
+
85
+ private
86
+
87
+ def next_id
88
+ id = @next_id
89
+ @next_id += 1
90
+ id
91
+ end
92
+
93
+ def register_timer(kind, callback, delay_ms, interval_ms)
94
+ id = next_id
95
+ due_at = @now_ms + [delay_ms, 0].max
96
+ @timers[id] = Timer.new(id, kind, callback, due_at, interval_ms, true)
97
+ id
98
+ end
99
+
100
+ def cancel_timer(id)
101
+ timer = @timers[id.to_i]
102
+ timer.active = false if timer
103
+ @timers.delete(id.to_i)
104
+ nil
105
+ end
106
+
107
+ def run_due_timers
108
+ due = @timers.values.select { |timer| timer.active && timer.due_at <= @now_ms }
109
+ due.sort_by!(&:id)
110
+ due.each do |timer|
111
+ next unless timer.active
112
+
113
+ case timer.kind
114
+ when :raf
115
+ @timers.delete(timer.id)
116
+ invoke_callback(timer.callback, [@now_ms.to_f])
117
+ when :interval
118
+ invoke_callback(timer.callback, [])
119
+ timer.due_at = @now_ms + timer.interval_ms if timer.active
120
+ else
121
+ @timers.delete(timer.id)
122
+ invoke_callback(timer.callback, [])
123
+ end
124
+ end
125
+ end
126
+
127
+ def invoke_callback(callback, args)
128
+ if callback.respond_to?(:__js_call__)
129
+ callback.__js_call__("call", args)
130
+ elsif callback.respond_to?(:call)
131
+ callback.call(*args)
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ # `ShadowRoot` — a DocumentFragment-shaped subtree attached to a
5
+ # host Element via `attachShadow`. Lives in its own Nokogiri
6
+ # fragment that's invisible to the outer document's tree walks
7
+ # (querySelector, getElementById, children, etc.), which is the
8
+ # core "encapsulation" the spec promises.
9
+ #
10
+ # Tree manipulation works the same as a normal Element/Fragment;
11
+ # the boundary is enforced only on outer queries and event
12
+ # composition. CSS scoping (`:host`, `::slotted`) is out of scope.
13
+ class ShadowRoot
14
+ include EventTarget
15
+ include Node
16
+
17
+ attr_reader :__node__, :host, :mode, :delegates_focus, :slot_assignment, :document
18
+
19
+ def initialize(host, mode:, delegates_focus: false, slot_assignment: "named")
20
+ @host = host
21
+ @mode = mode.to_s
22
+ @delegates_focus = !!delegates_focus
23
+ @slot_assignment = slot_assignment.to_s
24
+ @document = host.document
25
+ @__node__ = @document.nokogiri_doc.fragment("")
26
+ @document.__register_shadow_fragment__(@__node__, self)
27
+ end
28
+
29
+ # ---- Public Ruby API (ParentNode + DocumentFragment mixin) ----
30
+
31
+ def inner_html
32
+ @__node__.children.map(&:to_html).join
33
+ end
34
+
35
+ def inner_html=(html)
36
+ removed = @__node__.children.to_a
37
+ removed.each(&:unlink)
38
+ fragment = Parser.fragment(html.to_s, owner_doc: @document.nokogiri_doc)
39
+ added = fragment.children.to_a
40
+ added.each { |n| @__node__.add_child(n) }
41
+ @document.notify_child_list_mutation(target_node: @__node__, added_nodes: added, removed_nodes: removed)
42
+ nil
43
+ end
44
+
45
+ def text_content
46
+ @__node__.text
47
+ end
48
+
49
+ def text_content=(value)
50
+ @__node__.children.each(&:unlink)
51
+ @__node__.add_child(Nokogiri::XML::Text.new(value.to_s, @document.nokogiri_doc))
52
+ end
53
+
54
+ def children
55
+ @__node__.element_children.map { |n| @document.wrap_node(n) }.compact
56
+ end
57
+
58
+ def child_nodes
59
+ @__node__.children.map { |n| @document.wrap_node(n) }.compact
60
+ end
61
+
62
+ def child_element_count
63
+ @__node__.element_children.size
64
+ end
65
+
66
+ def first_child
67
+ @document.wrap_node(@__node__.children.first)
68
+ end
69
+
70
+ def last_child
71
+ @document.wrap_node(@__node__.children.last)
72
+ end
73
+
74
+ def first_element_child
75
+ @document.wrap_node(@__node__.element_children.first)
76
+ end
77
+
78
+ def last_element_child
79
+ @document.wrap_node(@__node__.element_children.last)
80
+ end
81
+
82
+ def append_child(child)
83
+ nodes = detach_dom_nodes(child)
84
+ nodes.each { |n| @__node__.add_child(n) }
85
+ @document.notify_child_list_mutation(target_node: @__node__, added_nodes: nodes, removed_nodes: [])
86
+ child
87
+ end
88
+
89
+ def append(*args)
90
+ nodes = args.flat_map { |a| detach_dom_nodes(a) }
91
+ nodes.each { |n| @__node__.add_child(n) }
92
+ @document.notify_child_list_mutation(target_node: @__node__, added_nodes: nodes, removed_nodes: [])
93
+ nil
94
+ end
95
+
96
+ def prepend(*args)
97
+ nodes = args.flat_map { |a| detach_dom_nodes(a) }
98
+ anchor = @__node__.children.first
99
+ if anchor
100
+ nodes.reverse_each { |n| anchor.add_previous_sibling(n) }
101
+ else
102
+ nodes.each { |n| @__node__.add_child(n) }
103
+ end
104
+
105
+ @document.notify_child_list_mutation(target_node: @__node__, added_nodes: nodes, removed_nodes: [])
106
+ nil
107
+ end
108
+
109
+ def replace_children(*args)
110
+ removed = @__node__.children.to_a
111
+ removed.each(&:unlink)
112
+ nodes = args.flat_map { |a| detach_dom_nodes(a) }
113
+ nodes.each { |n| @__node__.add_child(n) }
114
+ @document.notify_child_list_mutation(target_node: @__node__, added_nodes: nodes, removed_nodes: removed)
115
+ nil
116
+ end
117
+
118
+ def query_selector(selector)
119
+ return nil if selector.nil? || selector.to_s.empty?
120
+
121
+ @document.wrap_node(@__node__.at_css(selector.to_s))
122
+ end
123
+
124
+ def query_selector_all(selector)
125
+ return NodeList.new if selector.nil? || selector.to_s.empty?
126
+
127
+ NodeList.new(@__node__.css(selector.to_s).map { |n| @document.wrap_node(n) }.compact)
128
+ end
129
+
130
+ def get_element_by_id(id)
131
+ return nil if id.nil?
132
+
133
+ @document.wrap_node(@__node__.at_css("##{id}"))
134
+ end
135
+
136
+ # `getRootNode()` returns the ShadowRoot itself (closed-shadow
137
+ # semantics; `composed: true` callers go through the Event path).
138
+ def get_root_node(_options = nil)
139
+ self
140
+ end
141
+
142
+ def contains?(other)
143
+ return false unless other.respond_to?(:__node__)
144
+
145
+ other_node = other.__node__
146
+ return true if other_node == @__node__
147
+
148
+ Internal::NodeTraversal.ancestor_of?(@__node__, other_node)
149
+ end
150
+
151
+ # `[]` accessor mirrors the bracket convention used elsewhere.
152
+ def [](key)
153
+ __js_get__(key.to_s)
154
+ end
155
+
156
+ def []=(k, v)
157
+ __js_set__(k.to_s, v)
158
+ end
159
+
160
+ def __js_get__(key)
161
+ case key
162
+ when "host"
163
+ @host
164
+ when "mode"
165
+ @mode
166
+ when "delegatesFocus"
167
+ @delegates_focus
168
+ when "slotAssignment"
169
+ @slot_assignment
170
+ when "innerHTML"
171
+ inner_html
172
+ when "textContent"
173
+ text_content
174
+ when "children"
175
+ children
176
+ when "childNodes"
177
+ child_nodes
178
+ when "childElementCount"
179
+ child_element_count
180
+ when "firstChild"
181
+ first_child
182
+ when "lastChild"
183
+ last_child
184
+ when "firstElementChild"
185
+ first_element_child
186
+ when "lastElementChild"
187
+ last_element_child
188
+ when "nodeType"
189
+ 11
190
+ end
191
+ end
192
+
193
+ def __js_set__(key, value)
194
+ case key
195
+ when "innerHTML"
196
+ self.inner_html = value
197
+ when "textContent"
198
+ self.text_content = value
199
+ end
200
+
201
+ nil
202
+ end
203
+
204
+ def __js_call__(method, args)
205
+ case method
206
+ when "querySelector"
207
+ query_selector(args[0])
208
+ when "querySelectorAll"
209
+ query_selector_all(args[0])
210
+ when "getElementById"
211
+ get_element_by_id(args[0])
212
+ when "append"
213
+ append(*args)
214
+ when "prepend"
215
+ prepend(*args)
216
+ when "replaceChildren"
217
+ replace_children(*args)
218
+ when "appendChild"
219
+ append_child(args[0])
220
+ when "getRootNode"
221
+ get_root_node(args[0])
222
+ when "contains"
223
+ contains?(args[0])
224
+ when "addEventListener"
225
+ add_event_listener(args[0], args[1], args[2])
226
+ when "removeEventListener"
227
+ remove_event_listener(args[0], args[1])
228
+ when "dispatchEvent"
229
+ dispatch_event(args[0])
230
+ end
231
+ end
232
+
233
+ # Event bubbling stops at the ShadowRoot unless event has
234
+ # `composed: true`. The host is the bubble-path successor when
235
+ # composition crosses the boundary (handled in Event dispatch).
236
+ def __event_parent__
237
+ nil
238
+ end
239
+
240
+ private
241
+
242
+ def detach_dom_nodes(value)
243
+ case value
244
+ when String
245
+ [Nokogiri::XML::Text.new(value, @document.nokogiri_doc)]
246
+ else
247
+ node = value.respond_to?(:__node__) ? value.__node__ : nil
248
+ return [] unless node
249
+
250
+ node.unlink if node.parent
251
+ [node]
252
+ end
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ # Hash-backed `Storage` polyfill for `localStorage` /
5
+ # `sessionStorage`. Mirrors the Web Storage API:
6
+ # `getItem(key)`, `setItem(key, value)`, `removeItem(key)`,
7
+ # `clear()`, `key(index)`, `length`. Values are coerced to String
8
+ # to match browser semantics (browser stores everything as String).
9
+ #
10
+ # No persistence across Window instances — each fresh Window gets
11
+ # an empty Storage. Tests that depend on cross-instance behaviour
12
+ # (none currently) would need explicit hydration.
13
+ class Storage
14
+ include Enumerable
15
+
16
+ def initialize
17
+ @store = {}
18
+ end
19
+
20
+ # Ruby-idiomatic facade matching `Object.keys(storage)` /
21
+ # `Object.values(storage)` / `Object.entries(storage)` semantics
22
+ # that user code reaches for in browser JS.
23
+
24
+ def keys
25
+ @store.keys
26
+ end
27
+
28
+ def values
29
+ @store.values
30
+ end
31
+
32
+ def entries
33
+ @store.to_a
34
+ end
35
+
36
+ def to_h
37
+ @store.dup
38
+ end
39
+
40
+ def each(&blk)
41
+ @store.each(&blk)
42
+ end
43
+
44
+ def length
45
+ @store.size
46
+ end
47
+
48
+ alias size length
49
+
50
+ def get_item(key)
51
+ @store[key.to_s]
52
+ end
53
+
54
+ def set_item(key, value)
55
+ @store[key.to_s] = value.to_s
56
+ nil
57
+ end
58
+
59
+ def remove_item(key)
60
+ @store.delete(key.to_s)
61
+ nil
62
+ end
63
+
64
+ def clear
65
+ @store.clear
66
+ nil
67
+ end
68
+
69
+ def key(index)
70
+ @store.keys[index.to_i]
71
+ end
72
+
73
+ def [](key)
74
+ @store[key.to_s]
75
+ end
76
+
77
+ def []=(key, value)
78
+ @store[key.to_s] = value.to_s
79
+ end
80
+
81
+ def __js_get__(key)
82
+ case key
83
+ when "length"
84
+ @store.size
85
+ else
86
+ @store[key.to_s]
87
+ end
88
+ end
89
+
90
+ def __js_set__(key, value)
91
+ @store[key.to_s] = value.to_s
92
+ end
93
+
94
+ def __js_call__(method, args)
95
+ case method
96
+ when "getItem"
97
+ @store[args[0].to_s]
98
+ when "setItem"
99
+ @store[args[0].to_s] = args[1].to_s
100
+ nil
101
+ when "removeItem"
102
+ @store.delete(args[0].to_s)
103
+ nil
104
+ when "clear"
105
+ @store.clear
106
+ nil
107
+ when "key"
108
+ @store.keys[args[0].to_i]
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ # Lightweight helpers for using Dommy from RSpec / Minitest test suites.
5
+ #
6
+ # @example RSpec
7
+ # require "dommy/test_helpers"
8
+ #
9
+ # RSpec.configure do |c|
10
+ # c.include Dommy::TestHelpers
11
+ # end
12
+ #
13
+ # RSpec.describe MyComponent do
14
+ # it "renders the heading" do
15
+ # dom = parse_html(render(MyComponent.new))
16
+ # expect(dom.query_selector("h1").text_content).to eq("Welcome")
17
+ # end
18
+ # end
19
+ #
20
+ # @example Minitest
21
+ # require "dommy/test_helpers"
22
+ #
23
+ # class MyComponentTest < Minitest::Test
24
+ # include Dommy::TestHelpers
25
+ #
26
+ # def test_renders_the_heading
27
+ # dom = parse_html(render(MyComponent.new))
28
+ # assert_equal "Welcome", dom.query_selector("h1").text_content
29
+ # end
30
+ # end
31
+ module TestHelpers
32
+ # Parse an HTML string into a fresh Document and return it.
33
+ #
34
+ # When the input starts with `<!doctype` or `<html>`, it is parsed as
35
+ # a full HTML document (preserving <head>, <title>, etc.). Otherwise
36
+ # the input is treated as a body fragment and inserted into a fresh
37
+ # document's <body>.
38
+ #
39
+ # @param html [String] HTML to parse (full document or body fragment)
40
+ # @return [Dommy::Document] a fresh Document with the parsed content
41
+ def parse_html(html = "")
42
+ Dommy.parse(html).document
43
+ end
44
+
45
+ # Build a fresh Window with the given body HTML.
46
+ # When a block is given, yields the window first; the same window
47
+ # is returned in both cases so callers can choose their style.
48
+ #
49
+ # @param body_html [String] HTML to insert inside <body>
50
+ # @yieldparam window [Dommy::Window]
51
+ # @return [Dommy::Window] the new Window
52
+ def make_window(body_html = "")
53
+ window = Dommy::Window.new
54
+ window.document.body.inner_html = body_html.to_s
55
+ yield window if block_given?
56
+ window
57
+ end
58
+
59
+ # Drain pending microtasks on the window's scheduler.
60
+ # Use this after a mutation if you need MutationObserver callbacks
61
+ # (scheduled as microtasks) to fire before your assertions.
62
+ #
63
+ # @param window [Dommy::Window]
64
+ def flush_microtasks(window)
65
+ window.scheduler.drain_microtasks
66
+ end
67
+
68
+ # Advance the window's virtual clock. Timers that come due and
69
+ # any queued microtasks are run as part of the advance.
70
+ # Use this to test code that schedules work with setTimeout / setInterval.
71
+ #
72
+ # @param window [Dommy::Window]
73
+ # @param ms [Integer] milliseconds to advance
74
+ def advance_time(window, ms)
75
+ window.scheduler.advance_time(ms)
76
+ end
77
+ end
78
+ end