dommy 0.9.0 → 0.10.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 +4 -4
  2. data/lib/dommy/attr.rb +46 -8
  3. data/lib/dommy/backend/makiri_adapter.rb +34 -0
  4. data/lib/dommy/backend.rb +29 -0
  5. data/lib/dommy/blob.rb +48 -6
  6. data/lib/dommy/browser.rb +239 -36
  7. data/lib/dommy/callable_invoker.rb +6 -2
  8. data/lib/dommy/document.rb +525 -57
  9. data/lib/dommy/element.rb +479 -239
  10. data/lib/dommy/event.rb +296 -15
  11. data/lib/dommy/fetch.rb +431 -25
  12. data/lib/dommy/history.rb +24 -3
  13. data/lib/dommy/html_collection.rb +145 -21
  14. data/lib/dommy/html_elements.rb +1675 -266
  15. data/lib/dommy/interaction/driver.rb +155 -14
  16. data/lib/dommy/interaction/event_synthesis.rb +115 -7
  17. data/lib/dommy/interaction/field_interactor.rb +60 -0
  18. data/lib/dommy/internal/child_node.rb +199 -0
  19. data/lib/dommy/internal/css/cascade.rb +44 -2
  20. data/lib/dommy/internal/global_functions.rb +23 -10
  21. data/lib/dommy/internal/mutation_coordinator.rb +27 -7
  22. data/lib/dommy/internal/node_wrapper_cache.rb +56 -14
  23. data/lib/dommy/internal/observer_manager.rb +6 -0
  24. data/lib/dommy/internal/observer_matcher.rb +6 -8
  25. data/lib/dommy/internal/parent_node.rb +37 -73
  26. data/lib/dommy/internal/selector_matcher.rb +89 -0
  27. data/lib/dommy/internal/selector_parser.rb +44 -7
  28. data/lib/dommy/js/custom_element_bridge.rb +13 -2
  29. data/lib/dommy/js/dom_interfaces.rb +19 -4
  30. data/lib/dommy/js/host_bridge.rb +21 -0
  31. data/lib/dommy/js/host_runtime.js +926 -64
  32. data/lib/dommy/js/script_boot.rb +80 -0
  33. data/lib/dommy/location.rb +76 -13
  34. data/lib/dommy/mutation_observer.rb +3 -4
  35. data/lib/dommy/navigation.rb +263 -0
  36. data/lib/dommy/node.rb +180 -27
  37. data/lib/dommy/range.rb +15 -3
  38. data/lib/dommy/scheduler.rb +16 -0
  39. data/lib/dommy/shadow_root.rb +33 -0
  40. data/lib/dommy/storage.rb +61 -10
  41. data/lib/dommy/tree_walker.rb +18 -36
  42. data/lib/dommy/url.rb +4 -2
  43. data/lib/dommy/version.rb +1 -1
  44. data/lib/dommy/web_socket.rb +45 -2
  45. data/lib/dommy/window.rb +126 -7
  46. data/lib/dommy/xml_http_request.rb +30 -4
  47. data/lib/dommy.rb +2 -0
  48. metadata +6 -10
@@ -113,12 +113,11 @@ module Dommy
113
113
  # event sequence so JS click handlers run.
114
114
  def click(selector)
115
115
  element = find(selector)
116
- prevented = EventSynthesis.click(element)
117
- # Browser activation behavior: an un-prevented click on a submit button
118
- # runs form submission, whose JS-observable effect is a `submit` event
119
- # on the owning form (a SPA handles it / preventDefaults navigation).
120
- # This makes `click "button[type=submit]"` behave like a real click.
121
- submit_owning_form(element) unless prevented
116
+ # An un-prevented click runs the element's activation behavior — a submit
117
+ # button submits its owning form (SubmitButtonActivation), an anchor
118
+ # follows its href so `click "button[type=submit]"` / `click "a"` behave
119
+ # like a real click with no driver-level special-casing.
120
+ EventSynthesis.click(element)
122
121
  after_interaction
123
122
  element
124
123
  end
@@ -165,6 +164,87 @@ module Dommy
165
164
  result
166
165
  end
167
166
 
167
+ # Type into the element matching `selector` (focusing it first). Each
168
+ # key is a Symbol for a named key (:enter, :arrow_down, :escape, … see
169
+ # EventSynthesis::NAMED_KEYS) or a String typed character by character
170
+ # (keydown -> keypress -> beforeinput -> insertion + input -> keyup).
171
+ # Default actions mirror a browser's: characters insert into a text
172
+ # field, Backspace deletes, Enter inserts a newline in a textarea and
173
+ # triggers the owning form's implicit submission elsewhere. Preventing
174
+ # keydown / keypress / beforeinput suppresses the default action, so
175
+ # SPA keyboard handlers behave as they would in a browser.
176
+ #
177
+ # browser.send_keys "#q", :arrow_down, :enter
178
+ # browser.send_keys "#q", "hello"
179
+ def send_keys(selector, *keys)
180
+ send_keys_to(find(selector), *keys)
181
+ end
182
+
183
+ # Element-based variant of #send_keys, for hosts that already hold the
184
+ # element (capybara-dommy's Node#send_keys). Same semantics.
185
+ def send_keys_to(element, *keys)
186
+ EventSynthesis.focus(element)
187
+ keys.each { |key| dispatch_send_key(element, key) }
188
+ after_interaction
189
+ element
190
+ end
191
+
192
+ # Compose text through an IME, with the canonical (Chrome-like) event
193
+ # sequence an input method produces. `updates` are the visible
194
+ # composition states (e.g. ["に", "にほんご"] while converting to
195
+ # 日本語); each dispatches a `Process` keydown (keyCode 229),
196
+ # compositionupdate, the insertCompositionText beforeinput/input pair
197
+ # (`isComposing: true`, the field's value shows the composing text),
198
+ # and keyup. `commit: true` (default) then fires compositionend with
199
+ # `text` and leaves it in the field; `commit: false` cancels the
200
+ # composition, restoring the field's prior value.
201
+ #
202
+ # browser.ime_input "#q", "日本語", updates: ["に", "にほんご"]
203
+ # browser.ime_input "#q", "", updates: ["に"], commit: false
204
+ #
205
+ # A block runs after each composition state (with the update string),
206
+ # so a test can let virtual time pass mid-composition — e.g. prove a
207
+ # debounced handler that guards on `event.isComposing` stays quiet
208
+ # while the user pauses to pick a conversion candidate:
209
+ #
210
+ # browser.ime_input "#q", "日本語", updates: ["に", "にほんご"] do
211
+ # browser.advance_time(400) # longer than the debounce
212
+ # end
213
+ #
214
+ # Handlers that guard on `event.isComposing` (deferring work until
215
+ # compositionend) therefore behave exactly as they would under a real
216
+ # IME — something a real-browser test cannot drive deterministically.
217
+ def ime_input(selector, text, updates: nil, commit: true)
218
+ element = find(selector)
219
+ EventSynthesis.focus(element)
220
+ text = text.to_s
221
+ updates = Array(updates || (text.empty? ? [] : [text])).map(&:to_s)
222
+ base = element.respond_to?(:value) ? element.value.to_s : ""
223
+
224
+ EventSynthesis.keydown(element, "Process", "", {"keyCode" => 229})
225
+ EventSynthesis.compositionstart(element)
226
+ updates.each_with_index do |update, i|
227
+ EventSynthesis.keydown(element, "Process", "", {"keyCode" => 229, "isComposing" => true}) if i.positive?
228
+ EventSynthesis.compositionupdate(element, update)
229
+ field_interactor.set_composition_text(element, base, update)
230
+ EventSynthesis.keyup(element, "Process", "", {"isComposing" => true})
231
+ yield update if block_given?
232
+ end
233
+
234
+ if commit
235
+ unless text == updates.last
236
+ EventSynthesis.compositionupdate(element, text)
237
+ field_interactor.set_composition_text(element, base, text)
238
+ end
239
+ EventSynthesis.compositionend(element, text)
240
+ else
241
+ field_interactor.cancel_composition_text(element, base)
242
+ EventSynthesis.compositionend(element, "")
243
+ end
244
+ after_interaction
245
+ element
246
+ end
247
+
168
248
  # --- Matchers ---
169
249
 
170
250
  # True when an element matches `selector` in scope. `text:` keeps only
@@ -198,16 +278,77 @@ module Dommy
198
278
 
199
279
  private
200
280
 
201
- # Dispatch a `submit` event on the form owning a clicked submit button.
202
- # Real navigation on an un-prevented submit is a host (Session) concern;
203
- # here we only surface the event so SPA handlers run.
204
- def submit_owning_form(element)
205
- return unless respond_to?(:submit_button?, true) && submit_button?(element)
281
+ # The includer (Browser / Rack::Session) may define the actual rule for
282
+ # "is this a submit button"; treat none as present otherwise. Used to pick
283
+ # the default submit button for Enter's implicit submission.
284
+ def submit_button_element?(element)
285
+ respond_to?(:submit_button?, true) && submit_button?(element)
286
+ end
287
+
288
+ # The form's first submit button in tree order (the one Enter's
289
+ # implicit-submission default action would activate), or nil for a
290
+ # buttonless form.
291
+ def default_submit_button(form)
292
+ form.query_selector_all("button, input").find { |el| submit_button_element?(el) }
293
+ end
294
+
295
+ # --- send_keys internals ---
296
+
297
+ def dispatch_send_key(element, key)
298
+ case key
299
+ when Symbol
300
+ named = EventSynthesis::NAMED_KEYS[key] ||
301
+ raise(ArgumentError, "unknown key #{key.inspect} (known: #{EventSynthesis::NAMED_KEYS.keys.join(", ")})")
302
+ send_named_key(element, key, named[0], named[1])
303
+ when String
304
+ key.each_char { |char| send_character(element, char) }
305
+ else
306
+ raise ArgumentError, "send_keys takes Symbols (named keys) or Strings (typed text), got #{key.inspect}"
307
+ end
308
+ end
309
+
310
+ def send_named_key(element, name, key, code)
311
+ unless EventSynthesis.keydown(element, key, code)
312
+ case name
313
+ when :enter then enter_default_action(element)
314
+ when :space then typed_character_default_action(element, " ", "Space")
315
+ when :backspace then field_interactor.backspace(element)
316
+ end
317
+ end
318
+ EventSynthesis.keyup(element, key, code)
319
+ end
320
+
321
+ def send_character(element, char)
322
+ code = EventSynthesis.char_code(char)
323
+ typed_character_default_action(element, char, code) unless EventSynthesis.keydown(element, char, code)
324
+ EventSynthesis.keyup(element, char, code)
325
+ end
206
326
 
207
- form = finder.form_for(element)
208
- return unless form
327
+ # An un-prevented printable keydown fires keypress; an un-prevented
328
+ # keypress inserts the character (beforeinput -> value -> input).
329
+ def typed_character_default_action(element, char, code)
330
+ return if EventSynthesis.keypress(element, char, code)
209
331
 
210
- form.dispatch_event(Dommy::Event.new("submit", "bubbles" => true, "cancelable" => true))
332
+ field_interactor.insert_text(element, char)
333
+ end
334
+
335
+ # Enter's default action: newline in a textarea; elsewhere the owning
336
+ # form's implicit submission — click the form's default (first) submit
337
+ # button so its handlers run, or dispatch a cancelable submit event
338
+ # directly when the form has no submit button (HTML implicit submission).
339
+ def enter_default_action(element)
340
+ return field_interactor.insert_text(element, "\n") if element.local_name == "textarea"
341
+ return unless element.respond_to?(:form) && (form = element.form)
342
+
343
+ submitter = default_submit_button(form)
344
+ if submitter
345
+ # Clicking the default submit button runs its activation behavior
346
+ # (form submission); a prevented click naturally submits nothing.
347
+ EventSynthesis.click(submitter)
348
+ else
349
+ # No submit button: HTML implicit submission with no submitter.
350
+ form.__run_form_submission__(nil)
351
+ end
211
352
  end
212
353
 
213
354
  # "no element with role …" plus the roles that WERE present (the most
@@ -23,21 +23,31 @@ module Dommy
23
23
  dispatch(element, Dommy::MouseEvent.new("mouseup", mouse_init))
24
24
  event = Dommy::MouseEvent.new("click", mouse_init)
25
25
  element.dispatch_event(event)
26
- event.default_prevented?
26
+ prevented = event.default_prevented?
27
+ # Run the click's activation behavior (hyperlink navigation, …) the same
28
+ # way Element#click does, so a synthetic/user click triggers the default
29
+ # action too — unless it was prevented. Checkbox/radio toggling is handled
30
+ # by the field interactor, not here (their activation_target is nil).
31
+ element.__run_click_activation_behavior__(event) if !prevented && element.respond_to?(:__run_click_activation_behavior__)
32
+ prevented
27
33
  end
28
34
 
35
+ # Run the element's focusing steps (Element#focus): moves
36
+ # document.activeElement and fires blur/focusout on the previously
37
+ # focused element plus focus/focusin here — a no-op when the element
38
+ # already holds focus, like a real browser.
29
39
  def focus(element)
30
- dispatch(element, Dommy::FocusEvent.new("focus", "composed" => true))
31
- dispatch(element, Dommy::FocusEvent.new("focusin", BUBBLES))
40
+ element.focus if element.respond_to?(:focus)
41
+ nil
32
42
  end
33
43
 
34
44
  def blur(element)
35
- dispatch(element, Dommy::FocusEvent.new("blur", "composed" => true))
36
- dispatch(element, Dommy::FocusEvent.new("focusout", BUBBLES))
45
+ element.blur if element.respond_to?(:blur)
46
+ nil
37
47
  end
38
48
 
39
- def input(element, data = nil)
40
- dispatch(element, Dommy::InputEvent.new("input", BUBBLES.merge("data" => data, "inputType" => "insertText")))
49
+ def input(element, data = nil, input_type = "insertText")
50
+ dispatch(element, Dommy::InputEvent.new("input", BUBBLES.merge("data" => data, "inputType" => input_type)))
41
51
  end
42
52
 
43
53
  def change(element)
@@ -48,6 +58,104 @@ module Dommy
48
58
  element.dispatch_event(event)
49
59
  end
50
60
 
61
+ # Named keys for Driver#send_keys, mapped to their KeyboardEvent
62
+ # key/code pairs. Aliases (:up for :arrow_up, …) match Capybara's.
63
+ NAMED_KEYS = {
64
+ enter: ["Enter", "Enter"],
65
+ tab: ["Tab", "Tab"],
66
+ escape: ["Escape", "Escape"],
67
+ space: [" ", "Space"],
68
+ backspace: ["Backspace", "Backspace"],
69
+ delete: ["Delete", "Delete"],
70
+ arrow_up: ["ArrowUp", "ArrowUp"],
71
+ arrow_down: ["ArrowDown", "ArrowDown"],
72
+ arrow_left: ["ArrowLeft", "ArrowLeft"],
73
+ arrow_right: ["ArrowRight", "ArrowRight"],
74
+ up: ["ArrowUp", "ArrowUp"],
75
+ down: ["ArrowDown", "ArrowDown"],
76
+ left: ["ArrowLeft", "ArrowLeft"],
77
+ right: ["ArrowRight", "ArrowRight"],
78
+ home: ["Home", "Home"],
79
+ end: ["End", "End"],
80
+ page_up: ["PageUp", "PageUp"],
81
+ page_down: ["PageDown", "PageDown"],
82
+ }.freeze
83
+
84
+ # keydown is cancelable: a prevented keydown suppresses the key's
85
+ # default action (typing, implicit submission). Returns whether it was
86
+ # prevented, like #click.
87
+ def keydown(element, key, code, extra = nil)
88
+ event = Dommy::KeyboardEvent.new("keydown", key_init(key, code, extra))
89
+ element.dispatch_event(event)
90
+ event.default_prevented?
91
+ end
92
+
93
+ # keypress fires only for keys that produce a character (legacy but
94
+ # still widely handled). Also cancelable; a prevented keypress
95
+ # suppresses the character insertion.
96
+ def keypress(element, key, code)
97
+ event = Dommy::KeyboardEvent.new("keypress", key_init(key, code))
98
+ element.dispatch_event(event)
99
+ event.default_prevented?
100
+ end
101
+
102
+ def keyup(element, key, code, extra = nil)
103
+ dispatch(element, Dommy::KeyboardEvent.new("keyup", key_init(key, code, extra)))
104
+ end
105
+
106
+ # IME composition events. compositionstart is cancelable per spec;
107
+ # update / end are not.
108
+ def compositionstart(element, data = "")
109
+ dispatch(element, Dommy::CompositionEvent.new("compositionstart", BUBBLES.merge("data" => data)))
110
+ end
111
+
112
+ def compositionupdate(element, data)
113
+ dispatch(element, Dommy::CompositionEvent.new("compositionupdate",
114
+ "bubbles" => true, "composed" => true, "data" => data))
115
+ end
116
+
117
+ def compositionend(element, data)
118
+ dispatch(element, Dommy::CompositionEvent.new("compositionend",
119
+ "bubbles" => true, "composed" => true, "data" => data))
120
+ end
121
+
122
+ # The input pair during composition: beforeinput then input, both with
123
+ # inputType insertCompositionText and isComposing true. Unlike ordinary
124
+ # typing, the composition beforeinput is NOT cancelable (spec).
125
+ def composition_input(element, data)
126
+ init = {"bubbles" => true, "composed" => true,
127
+ "data" => data, "inputType" => "insertCompositionText", "isComposing" => true}
128
+ dispatch(element, Dommy::InputEvent.new("beforeinput", init))
129
+ dispatch(element, Dommy::InputEvent.new("input", init))
130
+ end
131
+
132
+ # beforeinput precedes the value mutation and is cancelable (the input
133
+ # event that follows the mutation is not).
134
+ def beforeinput(element, data, input_type)
135
+ event = Dommy::InputEvent.new(
136
+ "beforeinput", BUBBLES.merge("data" => data, "inputType" => input_type)
137
+ )
138
+ element.dispatch_event(event)
139
+ event.default_prevented?
140
+ end
141
+
142
+ # The KeyboardEvent code for a typed character ("a" -> "KeyA").
143
+ # Best-effort: unknown characters get an empty code, like a real
144
+ # browser does for keys it can't map to a physical position.
145
+ def char_code(char)
146
+ case char
147
+ when /\A[a-zA-Z]\z/ then "Key#{char.upcase}"
148
+ when /\A[0-9]\z/ then "Digit#{char}"
149
+ when " " then "Space"
150
+ else ""
151
+ end
152
+ end
153
+
154
+ def key_init(key, code, extra = nil)
155
+ init = BUBBLES.merge("key" => key, "code" => code)
156
+ extra ? init.merge(extra) : init
157
+ end
158
+
51
159
  def mouse_init
52
160
  BUBBLES.merge("button" => 0, "clientX" => 0, "clientY" => 0)
53
161
  end
@@ -83,6 +83,66 @@ module Dommy
83
83
  select_el
84
84
  end
85
85
 
86
+ TEXT_ENTRY_INPUT_TYPES = %w[text search email url tel password number].freeze
87
+
88
+ def text_entry_field?(element)
89
+ return true if element.local_name == "textarea"
90
+ return false unless element.local_name == "input"
91
+
92
+ type = (element.get_attribute("type") || "text").downcase
93
+ TEXT_ENTRY_INPUT_TYPES.include?(type)
94
+ end
95
+
96
+ # Append `text` to a text-entry field's value (no caret/selection model:
97
+ # insertion is at the end), firing cancelable beforeinput then input.
98
+ # Returns whether the field accepted the insertion (false for a
99
+ # non-text-entry field or a prevented beforeinput) — used by Driver's
100
+ # send_keys to decide whether a key's default action ran.
101
+ def insert_text(element, text)
102
+ return false unless text_entry_field?(element)
103
+ return false if EventSynthesis.beforeinput(element, text, "insertText")
104
+
105
+ element.value = element.value.to_s + text
106
+ EventSynthesis.input(element, text)
107
+ true
108
+ end
109
+
110
+ # Delete the last character of a text-entry field's value. Same return
111
+ # contract as #insert_text.
112
+ def backspace(element)
113
+ return false unless text_entry_field?(element)
114
+
115
+ value = element.value.to_s
116
+ return false if value.empty?
117
+ return false if EventSynthesis.beforeinput(element, nil, "deleteContentBackward")
118
+
119
+ element.value = value[0...-1]
120
+ EventSynthesis.input(element, nil, "deleteContentBackward")
121
+ true
122
+ end
123
+
124
+ # Show composition state `update` in a text-entry field (value becomes
125
+ # base + update) with the insertCompositionText beforeinput/input pair.
126
+ # No beforeinput veto: composition inputs are not cancelable (spec).
127
+ # Same return contract as #insert_text.
128
+ def set_composition_text(element, base, update)
129
+ return false unless text_entry_field?(element)
130
+
131
+ element.value = base + update
132
+ EventSynthesis.composition_input(element, update)
133
+ true
134
+ end
135
+
136
+ # Cancel a composition: restore the field's pre-composition value and
137
+ # fire a deleteCompositionText input. Same return contract.
138
+ def cancel_composition_text(element, base)
139
+ return false unless text_entry_field?(element)
140
+
141
+ element.value = base
142
+ EventSynthesis.input(element, nil, "deleteCompositionText")
143
+ true
144
+ end
145
+
86
146
  private
87
147
 
88
148
  def toggle(box, checked)
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Internal
5
+ # Shared ChildNode surface (WHATWG DOM `before` / `after` / `replaceWith`)
6
+ # plus the argument-coercion + childList-notification primitives those and
7
+ # ParentNode's own mutators build on. Included by both ParentNode (Element /
8
+ # Fragment / ShadowRoot) and the leaf CharacterData nodes (Text / Comment /
9
+ # ProcessingInstruction) — a leaf can be moved with `before`/`after` but must
10
+ # NOT gain `appendChild`/`insertBefore`, so those stay in ParentNode.
11
+ #
12
+ # Includers must expose `@__node__` (the backing node) and `@document`.
13
+ module ChildNode
14
+ # ChildNode#before — insert nodes as preceding siblings of `@__node__`.
15
+ # Follows the spec's "viable previous sibling" dance: the reference child
16
+ # is the first preceding sibling NOT among the argument nodes, resolved
17
+ # AFTER the arguments are detached (converting them into a node removes
18
+ # them from their old parents). Nodes are then inserted forward before the
19
+ # (fixed) reference — reversing would emit them backwards.
20
+ def child_node_before(args)
21
+ parent = @__node__.parent
22
+ return nil unless parent
23
+
24
+ arg_nodes = backend_nodes_in(args)
25
+ viable_prev = @__node__.previous_sibling
26
+ viable_prev = viable_prev.previous_sibling while viable_prev && arg_nodes.any? { |n| n == viable_prev }
27
+
28
+ nodes = args.flat_map { |arg| detach_dom_nodes(arg) }
29
+ ref = viable_prev.nil? ? parent.children.first : viable_prev.next_sibling
30
+ insert_child_nodes(nodes, ref, parent)
31
+ notify_child_list(added: nodes, target: parent)
32
+ nil
33
+ end
34
+
35
+ # ChildNode#after — insert nodes as following siblings of `@__node__`.
36
+ def child_node_after(args)
37
+ parent = @__node__.parent
38
+ return nil unless parent
39
+
40
+ arg_nodes = backend_nodes_in(args)
41
+ viable_next = @__node__.next_sibling
42
+ viable_next = viable_next.next_sibling while viable_next && arg_nodes.any? { |n| n == viable_next }
43
+
44
+ nodes = args.flat_map { |arg| detach_dom_nodes(arg) }
45
+ insert_child_nodes(nodes, viable_next, parent)
46
+ notify_child_list(added: nodes, target: parent)
47
+ nil
48
+ end
49
+
50
+ # ChildNode#replaceWith — replace `@__node__` with the given nodes.
51
+ def child_node_replace_with(args)
52
+ parent = @__node__.parent
53
+ return nil unless parent
54
+
55
+ arg_nodes = backend_nodes_in(args)
56
+ viable_next = @__node__.next_sibling
57
+ viable_next = viable_next.next_sibling while viable_next && arg_nodes.any? { |n| n == viable_next }
58
+
59
+ removed = @__node__
60
+ nodes = args.flat_map { |arg| detach_dom_nodes(arg) }
61
+ if @__node__.parent == parent
62
+ # `@__node__` survived the conversion (it wasn't among the arguments):
63
+ # insert the new nodes before it, then unlink it — a true replace.
64
+ nodes.each { |n| @__node__.add_previous_sibling(n) }
65
+ @__node__.unlink
66
+ notify_child_list(added: nodes, removed: [removed], target: parent)
67
+ else
68
+ # `@__node__` was itself an argument, so the conversion already moved
69
+ # it into `nodes`; pre-insert the set before the viable next sibling.
70
+ insert_child_nodes(nodes, viable_next, parent)
71
+ notify_child_list(added: nodes, target: parent)
72
+ end
73
+ nil
74
+ end
75
+
76
+ # WebIDL nullable DOMString coercion (`DOMString?`): JS null and undefined
77
+ # both become the null value, which callers treat as the empty string.
78
+ def nullable_dom_string(value)
79
+ return "" if value.nil? || (defined?(Bridge::UNDEFINED) && value.equal?(Bridge::UNDEFINED))
80
+
81
+ value.to_s
82
+ end
83
+
84
+ # WebIDL check for `insertBefore(node, child)`: `child` is a required but
85
+ # nullable Node, so a missing argument or a value that is neither a Node
86
+ # nor null/undefined is a TypeError before any DOM step runs. Now safe
87
+ # since the seeded stubs report the correct arity (2), so `.length`-based
88
+ # WPT helpers always pass the reference argument.
89
+ def validate_insert_before_ref!(args)
90
+ raise Bridge::TypeError, "insertBefore requires 2 arguments." if args.length < 2
91
+
92
+ ref = args[1]
93
+ return if ref.nil? || (defined?(Bridge::UNDEFINED) && ref.equal?(Bridge::UNDEFINED))
94
+ return if ref.is_a?(Dommy::Node)
95
+
96
+ raise Bridge::TypeError, "The reference child is not a Node."
97
+ end
98
+
99
+ private
100
+
101
+ # Insert `nodes` (raw backend nodes) into `parent` before `ref`, or append
102
+ # when `ref` is nil. Forward iteration against a fixed anchor preserves
103
+ # document order.
104
+ def insert_child_nodes(nodes, ref, parent)
105
+ if ref
106
+ nodes.each { |n| ref.add_previous_sibling(n) }
107
+ else
108
+ nodes.each { |n| parent.add_child(n) }
109
+ end
110
+ end
111
+
112
+ # The backing nodes of any ChildNode arguments that are already Nodes
113
+ # (strings / other values have none). Used to skip argument nodes when
114
+ # locating the viable previous / next sibling.
115
+ def backend_nodes_in(args)
116
+ args.filter_map do |arg|
117
+ arg.__dommy_backend_node__ if arg.respond_to?(:__dommy_backend_node__)
118
+ end
119
+ end
120
+
121
+ # Centralized MutationObserver childList notification. Defaults the
122
+ # target to this node; beforebegin/afterend/replaceWith/outerHTML
123
+ # callers pass the parent explicitly. The coordinator filters out
124
+ # empty added/removed sets, so this is always safe to call.
125
+ def notify_child_list(added: [], removed: [], target: @__node__)
126
+ @document.notify_child_list_mutation(
127
+ target_node: target,
128
+ added_nodes: added,
129
+ removed_nodes: removed
130
+ )
131
+ end
132
+
133
+ # Coerce an append/prepend/replaceChildren/before/after argument into raw
134
+ # backend node(s), detached from any current parent:
135
+ # - Element / TextNode / CommentNode → its backing node (unlinked)
136
+ # - Fragment → its extracted children
137
+ # - String → a fresh text node
138
+ # - anything else with a backing node → that node (unlinked)
139
+ #
140
+ # The class constants resolve at call time, so the mixin only needs to
141
+ # be defined before the including class bodies run.
142
+ def detach_dom_nodes(value)
143
+ case value
144
+ when Fragment
145
+ value.extract_children.map { |n| adopt_into_document(n) }
146
+ when String
147
+ [@document.create_text_node(value).__dommy_backend_node__]
148
+ else
149
+ node = value.respond_to?(:__dommy_backend_node__) ? value.__dommy_backend_node__ : nil
150
+ return [] unless node
151
+
152
+ # WHATWG pre-insert adopts the node into this node's document before
153
+ # linking it. libxml2 reassigns ownership in place during add_child, so
154
+ # the explicit adopt is a no-op move there; Makiri can't move a node
155
+ # between document arenas, so a cross-document insert must adopt (an
156
+ # imported copy) first. adopt_node reseats the Dommy wrapper onto the
157
+ # adopted node, so JS identity (`parent.appendChild(x); x` ===
158
+ # `parent.lastChild`) survives. Same-document: the wrapper's backend
159
+ # node is unchanged, so this is identical to the previous behavior.
160
+ detach_with_notify(node)
161
+ [@document.adopt_node(value).__dommy_backend_node__]
162
+ end
163
+ end
164
+
165
+ # Bring a raw backend node into this node's document (WHATWG adopt). A
166
+ # no-op when already same-document; otherwise Backend.adopt — in place for
167
+ # Nokogiri, an imported copy for Makiri (which can't move nodes between
168
+ # arenas). Used for fragment children, which have no standalone wrapper to
169
+ # reseat.
170
+ def adopt_into_document(node)
171
+ target = @document.backend_doc
172
+ node.document == target ? node : Backend.adopt(node, target)
173
+ end
174
+
175
+ # Detach a node from its current parent, queuing a childList removal
176
+ # record on that old parent first (WHATWG "remove" runs before the
177
+ # subsequent insert, so moving a node yields a removal record + an addition
178
+ # record). Returns the raw node, ready to be re-linked.
179
+ def detach_with_notify(node)
180
+ old_parent = node.parent
181
+ return node unless old_parent
182
+
183
+ # Capture the position (as wrapped nodes — the coordinator records
184
+ # explicit siblings verbatim) before unlinking.
185
+ prev_sib = node.previous_sibling && @document.wrap_node(node.previous_sibling)
186
+ next_sib = node.next_sibling && @document.wrap_node(node.next_sibling)
187
+ node.unlink
188
+ @document.notify_child_list_mutation(
189
+ target_node: old_parent,
190
+ added_nodes: [],
191
+ removed_nodes: [node],
192
+ previous_sibling: prev_sib,
193
+ next_sibling: next_sib
194
+ )
195
+ node
196
+ end
197
+ end
198
+ end
199
+ end
@@ -36,9 +36,9 @@ module Dommy
36
36
  def computed_style(element, pseudo_element: nil)
37
37
  document = element.owner_document
38
38
  return {}.freeze unless document
39
- # CSSOM: an element outside the flat tree has no computed style —
39
+ # CSSOM: an element that is not rendered has no computed style —
40
40
  # browsers return empty strings for every property.
41
- return {}.freeze if element.respond_to?(:is_connected?) && !element.is_connected?
41
+ return {}.freeze if not_rendered?(element)
42
42
 
43
43
  cache = style_cache(document)
44
44
  if pseudo_element
@@ -50,6 +50,48 @@ module Dommy
50
50
  end
51
51
  end
52
52
 
53
+ # An element is "not rendered" (and thus has an empty computed style, per
54
+ # CSSOM getComputedStyle) when it is disconnected, outside the flat tree
55
+ # (an unslotted light child of a shadow host), or inside a non-rendered
56
+ # frame (a `display:none` / disconnected `<iframe>`).
57
+ def not_rendered?(element)
58
+ return true if element.respond_to?(:is_connected?) && !element.is_connected?
59
+ return true if outside_flat_tree?(element)
60
+
61
+ in_non_rendered_frame?(element)
62
+ end
63
+
64
+ # Walk flat-tree parents: a light child of a shadow host is in the flat
65
+ # tree only if assigned to a slot, so an unslotted one (and its subtree)
66
+ # is outside it.
67
+ def outside_flat_tree?(element)
68
+ node = element
69
+ while node.respond_to?(:parent_element) && (host = node.parent_element)
70
+ if host.respond_to?(:shadow_root) && host.shadow_root &&
71
+ node.respond_to?(:assigned_slot) && node.assigned_slot.nil?
72
+ return true
73
+ end
74
+
75
+ node = host
76
+ end
77
+ false
78
+ end
79
+
80
+ # Follow the frame chain up to the top document; the element is not
81
+ # rendered if any hosting frame is disconnected or `display:none`.
82
+ def in_non_rendered_frame?(element)
83
+ doc = element.owner_document
84
+ seen = 0
85
+ while doc && (view = (doc.default_view if doc.respond_to?(:default_view))) &&
86
+ (frame = view.frame_element) && (seen += 1) < 64
87
+ return true if frame.respond_to?(:is_connected?) && !frame.is_connected?
88
+ return true if computed_style(frame)["display"] == "none"
89
+
90
+ doc = frame.owner_document
91
+ end
92
+ false
93
+ end
94
+
53
95
  # Cheap per-generation gate used by visibility checks: does the
54
96
  # document carry any author CSS at all? When it doesn't (and for
55
97
  # makiri-less installs), the HTML-level fast path is already exact