dommy 0.8.0 → 0.9.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.
- checksums.yaml +4 -4
- data/README.md +3 -3
- data/lib/dommy/animation.rb +4 -0
- data/lib/dommy/attr.rb +11 -5
- data/lib/dommy/backend/makiri_adapter.rb +330 -0
- data/lib/dommy/backend.rb +114 -33
- data/lib/dommy/blob.rb +2 -0
- data/lib/dommy/bridge.rb +11 -0
- data/lib/dommy/browser.rb +217 -0
- data/lib/dommy/compression_streams.rb +4 -0
- data/lib/dommy/crypto.rb +4 -0
- data/lib/dommy/css.rb +487 -50
- data/lib/dommy/custom_elements.rb +2 -2
- data/lib/dommy/data_transfer.rb +2 -0
- data/lib/dommy/data_uri.rb +35 -0
- data/lib/dommy/deferred_response.rb +59 -0
- data/lib/dommy/document.rb +386 -228
- data/lib/dommy/dom_exception.rb +2 -0
- data/lib/dommy/dom_parser.rb +7 -17
- data/lib/dommy/element.rb +502 -155
- data/lib/dommy/event.rb +240 -9
- data/lib/dommy/fetch.rb +152 -34
- data/lib/dommy/form_data.rb +2 -0
- data/lib/dommy/history.rb +2 -0
- data/lib/dommy/html_canvas_element.rb +230 -0
- data/lib/dommy/html_collection.rb +5 -6
- data/lib/dommy/html_elements.rb +304 -27
- data/lib/dommy/interaction/debug.rb +35 -0
- data/lib/dommy/interaction/dom_summary.rb +131 -0
- data/lib/dommy/interaction/driver.rb +244 -0
- data/lib/dommy/interaction/event_synthesis.rb +56 -0
- data/lib/dommy/interaction/field_interactor.rb +117 -0
- data/lib/dommy/interaction/form_submission.rb +268 -0
- data/lib/dommy/interaction/locator.rb +158 -0
- data/lib/dommy/interaction/role_query.rb +58 -0
- data/lib/dommy/interaction.rb +32 -0
- data/lib/dommy/internal/accessibility_tree.rb +215 -0
- data/lib/dommy/internal/accessible_description.rb +38 -0
- data/lib/dommy/internal/accessible_name.rb +301 -0
- data/lib/dommy/internal/aria_role.rb +252 -0
- data/lib/dommy/internal/aria_snapshot.rb +64 -0
- data/lib/dommy/internal/aria_state.rb +151 -0
- data/lib/dommy/internal/css/calc.rb +242 -0
- data/lib/dommy/internal/css/cascade.rb +430 -0
- data/lib/dommy/internal/css/color.rb +381 -0
- data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
- data/lib/dommy/internal/css/counters.rb +227 -0
- data/lib/dommy/internal/css/custom_properties.rb +183 -0
- data/lib/dommy/internal/css/media_query.rb +302 -0
- data/lib/dommy/internal/css/parser.rb +265 -0
- data/lib/dommy/internal/css/property_registry.rb +512 -0
- data/lib/dommy/internal/css/rule_index.rb +494 -0
- data/lib/dommy/internal/css/supports.rb +158 -0
- data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
- data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
- data/lib/dommy/internal/css_rule_text.rb +160 -0
- data/lib/dommy/internal/dom_matching.rb +80 -9
- data/lib/dommy/internal/element_matching.rb +109 -0
- data/lib/dommy/internal/global_functions.rb +33 -0
- data/lib/dommy/internal/mutation_coordinator.rb +95 -4
- data/lib/dommy/internal/namespaces.rb +49 -5
- data/lib/dommy/internal/node_wrapper_cache.rb +163 -26
- data/lib/dommy/internal/parent_node.rb +82 -5
- data/lib/dommy/internal/selector_ast.rb +124 -0
- data/lib/dommy/internal/selector_index.rb +146 -0
- data/lib/dommy/internal/selector_matcher.rb +756 -0
- data/lib/dommy/internal/selector_parser.rb +283 -131
- data/lib/dommy/internal/shadow_root_registry.rb +9 -2
- data/lib/dommy/internal/template_content_registry.rb +26 -18
- data/lib/dommy/internal/xml_serialization.rb +344 -0
- data/lib/dommy/intersection_observer.rb +2 -0
- data/lib/dommy/js/bridge_conformance.rb +80 -0
- data/lib/dommy/js/constructor_resolver.rb +44 -0
- data/lib/dommy/js/custom_element_bridge.rb +90 -0
- data/lib/dommy/js/dom_interfaces.rb +162 -0
- data/lib/dommy/js/handle_table.rb +60 -0
- data/lib/dommy/js/host_bridge.rb +517 -0
- data/lib/dommy/js/host_runtime.js +1495 -0
- data/lib/dommy/js/import_map.rb +58 -0
- data/lib/dommy/js/marshaller.rb +240 -0
- data/lib/dommy/js/module_loader.rb +99 -0
- data/lib/dommy/js/observable_runtime.js +742 -0
- data/lib/dommy/js/runtime.rb +115 -0
- data/lib/dommy/js/script_boot.rb +221 -0
- data/lib/dommy/js/wire_tags.rb +62 -0
- data/lib/dommy/location.rb +2 -0
- data/lib/dommy/media_query_list.rb +50 -14
- data/lib/dommy/message_channel.rb +22 -6
- data/lib/dommy/minitest/assertions.rb +27 -0
- data/lib/dommy/mutation_observer.rb +89 -4
- data/lib/dommy/navigator.rb +34 -2
- data/lib/dommy/node.rb +24 -14
- data/lib/dommy/notification.rb +2 -0
- data/lib/dommy/parser.rb +1 -1
- data/lib/dommy/performance.rb +21 -1
- data/lib/dommy/promise.rb +94 -10
- data/lib/dommy/range.rb +173 -31
- data/lib/dommy/resources.rb +178 -0
- data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
- data/lib/dommy/scheduler.rb +149 -13
- data/lib/dommy/screen.rb +91 -0
- data/lib/dommy/shadow_root.rb +76 -13
- data/lib/dommy/storage.rb +2 -1
- data/lib/dommy/streams.rb +6 -0
- data/lib/dommy/text_codec.rb +7 -1
- data/lib/dommy/tree_walker.rb +33 -10
- data/lib/dommy/url.rb +13 -1
- data/lib/dommy/version.rb +1 -1
- data/lib/dommy/window.rb +199 -11
- data/lib/dommy/worker.rb +8 -4
- data/lib/dommy/xml_http_request.rb +47 -6
- data/lib/dommy.rb +36 -1
- metadata +96 -10
- data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
- data/lib/dommy/backend/nokolexbor_adapter.rb +0 -117
|
@@ -63,6 +63,8 @@ module Dommy
|
|
|
63
63
|
@attribute_namespace
|
|
64
64
|
when "oldValue"
|
|
65
65
|
@old_value
|
|
66
|
+
else
|
|
67
|
+
Bridge::ABSENT
|
|
66
68
|
end
|
|
67
69
|
end
|
|
68
70
|
end
|
|
@@ -76,6 +78,12 @@ module Dommy
|
|
|
76
78
|
@records = []
|
|
77
79
|
@scheduled = false
|
|
78
80
|
@registered_docs = []
|
|
81
|
+
# Transient registered observers (WHATWG DOM): when a node is removed from
|
|
82
|
+
# an observed subtree, its subtree keeps being observed until the next
|
|
83
|
+
# microtask checkpoint, so mutations inside the just-removed subtree (e.g.
|
|
84
|
+
# removing its children in the same task) are still recorded. Each entry is
|
|
85
|
+
# { root: wrapped removed node, source: the registration that matched }.
|
|
86
|
+
@transients = []
|
|
79
87
|
end
|
|
80
88
|
|
|
81
89
|
include Bridge::Methods
|
|
@@ -102,16 +110,37 @@ module Dommy
|
|
|
102
110
|
# or nil if target doesn't match any observed scope.
|
|
103
111
|
def find_matching_entry(target_wrapped)
|
|
104
112
|
matcher = Internal::ObserverMatcher.new
|
|
105
|
-
@observed.find do |
|
|
106
|
-
observed_wrapped =
|
|
113
|
+
entry = @observed.find do |e|
|
|
114
|
+
observed_wrapped = e[:target]
|
|
107
115
|
next false unless observed_wrapped
|
|
108
116
|
|
|
109
117
|
if observed_wrapped.is_a?(Document)
|
|
110
|
-
matcher.matches_document?(target_wrapped, subtree:
|
|
118
|
+
matcher.matches_document?(target_wrapped, subtree: e[:subtree])
|
|
111
119
|
else
|
|
112
|
-
matcher.matches?(observed_wrapped, target_wrapped, subtree:
|
|
120
|
+
matcher.matches?(observed_wrapped, target_wrapped, subtree: e[:subtree])
|
|
113
121
|
end
|
|
114
122
|
end
|
|
123
|
+
return entry if entry
|
|
124
|
+
|
|
125
|
+
# A transient registered observer matches the removed node itself and its
|
|
126
|
+
# (now-detached) descendants, with the source registration's options.
|
|
127
|
+
transient = @transients.find do |t|
|
|
128
|
+
root = t[:root]
|
|
129
|
+
root && (root.equal?(target_wrapped) || matcher.matches?(root, target_wrapped, subtree: true))
|
|
130
|
+
end
|
|
131
|
+
transient && transient[:source]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Register a transient registered observer for a node just removed from an
|
|
135
|
+
# observed subtree (see @transients). Carries the matched registration's
|
|
136
|
+
# options so subsequent mutations inside the removed subtree record the same
|
|
137
|
+
# types. Deduped by node identity.
|
|
138
|
+
def add_transient(root_wrapped, source_entry)
|
|
139
|
+
return unless root_wrapped && source_entry && source_entry[:subtree]
|
|
140
|
+
return if @transients.any? { |t| t[:root].equal?(root_wrapped) }
|
|
141
|
+
|
|
142
|
+
@transients << {root: root_wrapped, source: source_entry}
|
|
143
|
+
nil
|
|
115
144
|
end
|
|
116
145
|
|
|
117
146
|
def enqueue(record)
|
|
@@ -217,17 +246,34 @@ module Dommy
|
|
|
217
246
|
out = @records.dup
|
|
218
247
|
@records.clear
|
|
219
248
|
@scheduled = false
|
|
249
|
+
# A microtask checkpoint ends the transient registrations' lifetime.
|
|
250
|
+
@transients.clear
|
|
220
251
|
out
|
|
221
252
|
end
|
|
222
253
|
|
|
223
254
|
def flush
|
|
224
255
|
@scheduled = false
|
|
256
|
+
@transients.clear
|
|
225
257
|
return if @records.empty?
|
|
226
258
|
|
|
227
259
|
records = @records.dup
|
|
228
260
|
@records.clear
|
|
229
261
|
# Per spec the callback receives (mutationRecords, observer) and is invoked
|
|
230
262
|
# with `this` set to the observer.
|
|
263
|
+
invoke_observer_callback(records)
|
|
264
|
+
rescue StandardError => e
|
|
265
|
+
# A throwing observer callback MUST NOT escape its dispatch — WHATWG says to
|
|
266
|
+
# report the exception and keep notifying the other observers, and a page's
|
|
267
|
+
# JS must not be derailed by it. The engine swallows this for callbacks
|
|
268
|
+
# (HostBridge#invoke_callback, raising:false), but that has proven
|
|
269
|
+
# engine/Ruby-version-dependent, so guard here too. Without this, one broken
|
|
270
|
+
# observer (e.g. an image lazy-loader that throws on a batch of <img>
|
|
271
|
+
# mutations) cascades into a blank / "something went wrong" page.
|
|
272
|
+
__dommy_dump_mo_failure__(records, e) if ENV["DOMMY_MO_DEBUG"]
|
|
273
|
+
nil
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def invoke_observer_callback(records)
|
|
231
277
|
if @callback.respond_to?(:__js_call_with_this__)
|
|
232
278
|
@callback.__js_call_with_this__([records, self], self)
|
|
233
279
|
elsif @callback.respond_to?(:__js_call__)
|
|
@@ -237,6 +283,45 @@ module Dommy
|
|
|
237
283
|
end
|
|
238
284
|
end
|
|
239
285
|
|
|
286
|
+
# Diagnostic only (DOMMY_MO_DEBUG=1): when a page's MutationObserver callback
|
|
287
|
+
# throws, append what it was handed + the error to a log file, so an
|
|
288
|
+
# otherwise-unreproducible failure (e.g. note.com's React #446 inside its
|
|
289
|
+
# resource observer) can be traced to the records/DOM it choked on.
|
|
290
|
+
def __dommy_dump_mo_failure__(records, error)
|
|
291
|
+
path = ENV["DOMMY_MO_DEBUG"]
|
|
292
|
+
path = "/tmp/dommy_mo_debug.log" if path == "1" || path.to_s.empty?
|
|
293
|
+
lines = ["=== MutationObserver callback raised: #{error.class}: #{error.message.to_s[0, 200]}",
|
|
294
|
+
" observing #{@observed.size} target(s): " +
|
|
295
|
+
@observed.first(3).map { |e|
|
|
296
|
+
t = e[:target]
|
|
297
|
+
"#{t.respond_to?(:__js_get__) ? t.__js_get__("nodeName") : t.class} " \
|
|
298
|
+
"{childList:#{e[:child_list]},subtree:#{e[:subtree]},attrs:#{e[:attributes]},cdata:#{e[:character_data]}}"
|
|
299
|
+
}.inspect[0, 200],
|
|
300
|
+
" #{records.size} record(s):"]
|
|
301
|
+
records.first(40).each { |r| lines << " - #{__dommy_record_summary__(r)}" }
|
|
302
|
+
(error.backtrace || []).grep(/\.js:/).first(6).each { |f| lines << " js@ #{f[0, 140]}" }
|
|
303
|
+
::File.open(path, "a") { |f| f.puts(lines.join("\n")) }
|
|
304
|
+
rescue StandardError
|
|
305
|
+
nil
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def __dommy_record_summary__(record)
|
|
309
|
+
get = ->(key) { record.respond_to?(:__js_get__) ? record.__js_get__(key) : nil }
|
|
310
|
+
name = ->(n) { n.respond_to?(:__js_get__) ? n.__js_get__("nodeName") : n.class.name }
|
|
311
|
+
added = __dommy_node_names__(get.call("addedNodes"), name)
|
|
312
|
+
removed = __dommy_node_names__(get.call("removedNodes"), name)
|
|
313
|
+
"type=#{get.call("type")} target=#{name.call(get.call("target"))} " \
|
|
314
|
+
"added=#{added.first(6).inspect} removed=#{removed.first(6).inspect} attr=#{get.call("attributeName").inspect}"
|
|
315
|
+
rescue StandardError => e
|
|
316
|
+
"(summary failed: #{e.class})"
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def __dommy_node_names__(list, name)
|
|
320
|
+
(list.respond_to?(:to_a) ? list.to_a : []).map { |n| name.call(n) }
|
|
321
|
+
rescue StandardError
|
|
322
|
+
[]
|
|
323
|
+
end
|
|
324
|
+
|
|
240
325
|
# A MutationObserverInit member is a WebIDL `boolean`, so its value is
|
|
241
326
|
# converted with JS ToBoolean — any object (e.g. `attributes: ["abc"]`) is
|
|
242
327
|
# truthy; only false / 0 / "" / null / undefined / NaN are falsy.
|
data/lib/dommy/navigator.rb
CHANGED
|
@@ -8,7 +8,8 @@ module Dommy
|
|
|
8
8
|
class Navigator
|
|
9
9
|
DEFAULT_USER_AGENT = "Mozilla/5.0 (Dommy) Ruby"
|
|
10
10
|
|
|
11
|
-
attr_accessor :user_agent, :language, :languages, :platform, :vendor, :on_line, :cookie_enabled
|
|
11
|
+
attr_accessor :user_agent, :language, :languages, :platform, :vendor, :on_line, :cookie_enabled,
|
|
12
|
+
:hardware_concurrency, :max_touch_points
|
|
12
13
|
|
|
13
14
|
def initialize(window)
|
|
14
15
|
@window = window
|
|
@@ -19,6 +20,8 @@ module Dommy
|
|
|
19
20
|
@vendor = "Dommy"
|
|
20
21
|
@on_line = true
|
|
21
22
|
@cookie_enabled = true
|
|
23
|
+
@hardware_concurrency = 8 # logical CPU count reported to JS
|
|
24
|
+
@max_touch_points = 0 # 0 => not a touch device
|
|
22
25
|
@clipboard = Clipboard.new(window)
|
|
23
26
|
@permissions = Permissions.new(window)
|
|
24
27
|
@geolocation = Geolocation.new(window)
|
|
@@ -43,6 +46,15 @@ module Dommy
|
|
|
43
46
|
|
|
44
47
|
alias canShare can_share
|
|
45
48
|
|
|
49
|
+
# `navigator.sendBeacon(url, data)` — fire-and-forget POST used for analytics.
|
|
50
|
+
# Dommy does not emit the request (no background egress); it just reports
|
|
51
|
+
# success so callers that feature-detect `sendBeacon` take the beacon path.
|
|
52
|
+
def send_beacon(_url, _data = nil)
|
|
53
|
+
true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
alias sendBeacon send_beacon
|
|
57
|
+
|
|
46
58
|
# Vibration API. No-op in dommy, but the requested pattern is
|
|
47
59
|
# recorded so tests can assert "we asked to vibrate".
|
|
48
60
|
def vibrate(pattern)
|
|
@@ -91,6 +103,10 @@ module Dommy
|
|
|
91
103
|
@on_line
|
|
92
104
|
when "cookieEnabled"
|
|
93
105
|
@cookie_enabled
|
|
106
|
+
when "hardwareConcurrency"
|
|
107
|
+
@hardware_concurrency
|
|
108
|
+
when "maxTouchPoints"
|
|
109
|
+
@max_touch_points
|
|
94
110
|
when "clipboard"
|
|
95
111
|
@clipboard
|
|
96
112
|
when "permissions"
|
|
@@ -103,11 +119,17 @@ module Dommy
|
|
|
103
119
|
@locks
|
|
104
120
|
when "storage"
|
|
105
121
|
@storage
|
|
122
|
+
else
|
|
123
|
+
# An unknown navigator property is genuinely absent: JS `undefined` value
|
|
124
|
+
# AND `"x" in navigator` false — so feature detection like
|
|
125
|
+
# `navigator.scheduling !== undefined` / `"serviceWorker" in navigator`
|
|
126
|
+
# both take the not-supported path instead of dereferencing null.
|
|
127
|
+
Bridge::ABSENT
|
|
106
128
|
end
|
|
107
129
|
end
|
|
108
130
|
|
|
109
131
|
include Bridge::Methods
|
|
110
|
-
js_methods %w[share canShare vibrate getBattery]
|
|
132
|
+
js_methods %w[share canShare vibrate getBattery sendBeacon]
|
|
111
133
|
def __js_call__(method, args)
|
|
112
134
|
case method
|
|
113
135
|
when "share"
|
|
@@ -118,6 +140,8 @@ module Dommy
|
|
|
118
140
|
vibrate(args[0])
|
|
119
141
|
when "getBattery"
|
|
120
142
|
get_battery
|
|
143
|
+
when "sendBeacon"
|
|
144
|
+
send_beacon(args[0], args[1])
|
|
121
145
|
end
|
|
122
146
|
end
|
|
123
147
|
|
|
@@ -307,6 +331,8 @@ module Dommy
|
|
|
307
331
|
@state
|
|
308
332
|
when "onchange"
|
|
309
333
|
@onchange
|
|
334
|
+
else
|
|
335
|
+
Bridge::ABSENT
|
|
310
336
|
end
|
|
311
337
|
end
|
|
312
338
|
|
|
@@ -484,6 +510,8 @@ module Dommy
|
|
|
484
510
|
@type
|
|
485
511
|
when "released"
|
|
486
512
|
@released
|
|
513
|
+
else
|
|
514
|
+
Bridge::ABSENT
|
|
487
515
|
end
|
|
488
516
|
end
|
|
489
517
|
|
|
@@ -525,6 +553,8 @@ module Dommy
|
|
|
525
553
|
@discharging_time
|
|
526
554
|
when "level"
|
|
527
555
|
@level
|
|
556
|
+
else
|
|
557
|
+
Bridge::ABSENT
|
|
528
558
|
end
|
|
529
559
|
end
|
|
530
560
|
|
|
@@ -602,6 +632,8 @@ module Dommy
|
|
|
602
632
|
name
|
|
603
633
|
when "mode"
|
|
604
634
|
mode
|
|
635
|
+
else
|
|
636
|
+
Bridge::ABSENT
|
|
605
637
|
end
|
|
606
638
|
end
|
|
607
639
|
end
|
data/lib/dommy/node.rb
CHANGED
|
@@ -58,20 +58,23 @@ module Dommy
|
|
|
58
58
|
if key.is_a?(Integer) || key.to_s.match?(/\A-?\d+\z/)
|
|
59
59
|
token = item(key.to_i)
|
|
60
60
|
token.nil? ? Bridge::UNDEFINED : token
|
|
61
|
+
else
|
|
62
|
+
Bridge::ABSENT # unknown non-index property
|
|
61
63
|
end
|
|
62
64
|
end
|
|
63
65
|
end
|
|
64
66
|
|
|
65
67
|
include Bridge::Methods
|
|
66
|
-
# keys/values/entries/Symbol.iterator
|
|
67
|
-
# prototype
|
|
68
|
-
|
|
68
|
+
# forEach/keys/values/entries/Symbol.iterator come from the array-like
|
|
69
|
+
# prototype JS-side (the actual %Array.prototype% functions) — see NodeList
|
|
70
|
+
# and host_runtime.js. A host `forEach` would shadow that and break the call
|
|
71
|
+
# (a JS callback arrives as a HostCallback, not a block). Only `item` needs a
|
|
72
|
+
# host method.
|
|
73
|
+
js_methods %w[item]
|
|
69
74
|
def __js_call__(method, args)
|
|
70
75
|
case method
|
|
71
76
|
when "item"
|
|
72
77
|
item(args[0])
|
|
73
|
-
when "forEach"
|
|
74
|
-
for_each(&args[0])
|
|
75
78
|
end
|
|
76
79
|
end
|
|
77
80
|
end
|
|
@@ -167,20 +170,24 @@ module Dommy
|
|
|
167
170
|
if key.is_a?(Integer) || key.to_s.match?(/\A-?\d+\z/)
|
|
168
171
|
token = item(key.to_i)
|
|
169
172
|
token.nil? ? Bridge::UNDEFINED : token
|
|
173
|
+
else
|
|
174
|
+
Bridge::ABSENT # unknown non-index property
|
|
170
175
|
end
|
|
171
176
|
end
|
|
172
177
|
end
|
|
173
178
|
|
|
174
179
|
include Bridge::Methods
|
|
175
|
-
# keys/values/entries/Symbol.iterator are provided JS-side (the
|
|
176
|
-
# prototype
|
|
177
|
-
|
|
180
|
+
# forEach/keys/values/entries/Symbol.iterator are provided JS-side (the
|
|
181
|
+
# array-like prototype is seeded with the actual %Array.prototype% functions)
|
|
182
|
+
# so `list.forEach === Array.prototype.forEach` and they return real
|
|
183
|
+
# iterators, not arrays — see host_runtime.js. Exposing a host `forEach` here
|
|
184
|
+
# would shadow that prototype copy (and a JS callback reaches Ruby as a
|
|
185
|
+
# HostCallback, not a block). Only `item` needs a host method.
|
|
186
|
+
js_methods %w[item]
|
|
178
187
|
def __js_call__(method, args)
|
|
179
188
|
case method
|
|
180
189
|
when "item"
|
|
181
190
|
item(args[0])
|
|
182
|
-
when "forEach"
|
|
183
|
-
for_each(&args[0])
|
|
184
191
|
end
|
|
185
192
|
end
|
|
186
193
|
end
|
|
@@ -271,7 +278,7 @@ module Dommy
|
|
|
271
278
|
# The topmost node of an attached subtree is the Nokogiri document, which
|
|
272
279
|
# has no element wrapper — map it to the Document. A detached node's root is
|
|
273
280
|
# itself.
|
|
274
|
-
return @document if @document && node.equal?(@document.
|
|
281
|
+
return @document if @document && node.equal?(@document.backend_doc)
|
|
275
282
|
|
|
276
283
|
(@document && @document.wrap_node(node)) || self
|
|
277
284
|
end
|
|
@@ -286,10 +293,13 @@ module Dommy
|
|
|
286
293
|
wanted = namespace_prefix_arg(prefix)
|
|
287
294
|
nk = nearest_namespaceable_node
|
|
288
295
|
while nk.respond_to?(:element?) && nk.element?
|
|
289
|
-
|
|
296
|
+
Backend.namespace_definitions(nk).each do |d|
|
|
290
297
|
return d.href if normalize_ns_prefix(d.prefix) == wanted
|
|
291
298
|
end
|
|
292
|
-
|
|
299
|
+
if wanted.nil?
|
|
300
|
+
ns = Backend.namespace_of(nk)
|
|
301
|
+
return ns ? ns.href : HTML_NAMESPACE
|
|
302
|
+
end
|
|
293
303
|
|
|
294
304
|
nk = nk.parent
|
|
295
305
|
end
|
|
@@ -304,7 +314,7 @@ module Dommy
|
|
|
304
314
|
|
|
305
315
|
nk = nearest_namespaceable_node
|
|
306
316
|
while nk.respond_to?(:element?) && nk.element?
|
|
307
|
-
|
|
317
|
+
Backend.namespace_definitions(nk).each do |d|
|
|
308
318
|
return d.prefix if d.href == ns && d.prefix
|
|
309
319
|
end
|
|
310
320
|
nk = nk.parent
|
data/lib/dommy/notification.rb
CHANGED
data/lib/dommy/parser.rb
CHANGED
|
@@ -8,7 +8,7 @@ module Dommy
|
|
|
8
8
|
# Known quirks (vary by backend):
|
|
9
9
|
# - Nokogiri (libxml2): `<table>`-only fragments wrap children in
|
|
10
10
|
# an implicit `<tbody>`; `<select>` reparents non-option children.
|
|
11
|
-
# -
|
|
11
|
+
# - Makiri (Lexbor): similar behavior, slightly different edge
|
|
12
12
|
# cases for malformed input.
|
|
13
13
|
#
|
|
14
14
|
# `owner_doc` is critical: when a node parsed via a detached
|
data/lib/dommy/performance.rb
CHANGED
|
@@ -11,10 +11,26 @@ module Dommy
|
|
|
11
11
|
def initialize(window)
|
|
12
12
|
@window = window
|
|
13
13
|
@entries = []
|
|
14
|
+
# In browser mode (dommynx sets DOMMY_REAL_TIME_PERFORMANCE) `now` tracks
|
|
15
|
+
# REAL wall-clock, not the deterministic scheduler's virtual clock. Virtual
|
|
16
|
+
# time only advances between tasks (in advance_time), so it is FROZEN inside
|
|
17
|
+
# a long synchronous task — and a concurrent renderer (React's scheduler)
|
|
18
|
+
# decides when to yield by watching performance.now() cross a ~5ms frame
|
|
19
|
+
# budget. With a frozen clock it never crosses, so React renders the entire
|
|
20
|
+
# fiber tree in ONE eval that overruns the eval timeout ("InternalError:
|
|
21
|
+
# interrupted"). A real clock lets it slice the work and yield (via
|
|
22
|
+
# MessageChannel), so each eval stays short. Off by default → tests keep the
|
|
23
|
+
# deterministic virtual clock.
|
|
24
|
+
@real_time = !ENV["DOMMY_REAL_TIME_PERFORMANCE"].to_s.empty?
|
|
25
|
+
@real_origin = nil
|
|
14
26
|
end
|
|
15
27
|
|
|
16
28
|
def now
|
|
17
|
-
@window.scheduler.now_ms.to_f
|
|
29
|
+
return @window.scheduler.now_ms.to_f unless @real_time
|
|
30
|
+
|
|
31
|
+
current = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
|
|
32
|
+
@real_origin ||= current
|
|
33
|
+
current - @real_origin
|
|
18
34
|
end
|
|
19
35
|
|
|
20
36
|
def mark(name, options = nil)
|
|
@@ -90,6 +106,8 @@ module Dommy
|
|
|
90
106
|
now
|
|
91
107
|
when "timeOrigin"
|
|
92
108
|
0.0
|
|
109
|
+
else
|
|
110
|
+
Bridge::ABSENT
|
|
93
111
|
end
|
|
94
112
|
end
|
|
95
113
|
|
|
@@ -144,6 +162,8 @@ module Dommy
|
|
|
144
162
|
duration
|
|
145
163
|
when "detail"
|
|
146
164
|
detail
|
|
165
|
+
else
|
|
166
|
+
Bridge::ABSENT
|
|
147
167
|
end
|
|
148
168
|
end
|
|
149
169
|
end
|
data/lib/dommy/promise.rb
CHANGED
|
@@ -14,7 +14,7 @@ module Dommy
|
|
|
14
14
|
when "name"
|
|
15
15
|
@name
|
|
16
16
|
else
|
|
17
|
-
|
|
17
|
+
Bridge::ABSENT
|
|
18
18
|
end
|
|
19
19
|
end
|
|
20
20
|
|
|
@@ -52,13 +52,15 @@ module Dommy
|
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
include Bridge::Methods
|
|
55
|
-
js_methods %w[then catch]
|
|
55
|
+
js_methods %w[then catch finally]
|
|
56
56
|
def __js_call__(method, args)
|
|
57
57
|
case method
|
|
58
58
|
when "then"
|
|
59
59
|
attach_then(args[0], args[1])
|
|
60
60
|
when "catch"
|
|
61
61
|
attach_then(nil, args[0])
|
|
62
|
+
when "finally"
|
|
63
|
+
attach_finally(args[0])
|
|
62
64
|
else
|
|
63
65
|
nil
|
|
64
66
|
end
|
|
@@ -86,7 +88,7 @@ module Dommy
|
|
|
86
88
|
# microtask drain — that's a sign that real-time work (e.g. a
|
|
87
89
|
# `setTimeout`) needs to advance via `advance_time` first.
|
|
88
90
|
def await
|
|
89
|
-
|
|
91
|
+
drive_until_settled
|
|
90
92
|
|
|
91
93
|
case @state
|
|
92
94
|
when :fulfilled
|
|
@@ -94,12 +96,29 @@ module Dommy
|
|
|
94
96
|
when :rejected
|
|
95
97
|
raise unwrap_rejection(@value)
|
|
96
98
|
else
|
|
97
|
-
raise "Promise#await: still pending after
|
|
99
|
+
raise "Promise#await: still pending after draining the event loop"
|
|
98
100
|
end
|
|
99
101
|
end
|
|
100
102
|
|
|
101
103
|
private
|
|
102
104
|
|
|
105
|
+
# Drive the event loop over the work that is ready NOW until this promise
|
|
106
|
+
# settles: `advance_time(0)` runs the microtask checkpoint plus every task
|
|
107
|
+
# already due (a fetch's setTimeout(0) delivery and anything it queues),
|
|
108
|
+
# WITHOUT moving the clock forward. A promise waiting on a real delay
|
|
109
|
+
# (setTimeout(100)) is left pending — that still needs an explicit
|
|
110
|
+
# `advance_time`, so #await reports it pending rather than silently jumping
|
|
111
|
+
# virtual time. Bounded against a self-rescheduling setTimeout(0).
|
|
112
|
+
def drive_until_settled
|
|
113
|
+
sched = @window&.scheduler
|
|
114
|
+
return unless sched
|
|
115
|
+
|
|
116
|
+
64.times do
|
|
117
|
+
sched.advance_time(0)
|
|
118
|
+
break unless @state == :pending && sched.next_due_timer_at == sched.now_ms
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
103
122
|
def unwrap_rejection(value)
|
|
104
123
|
case value
|
|
105
124
|
when Exception
|
|
@@ -118,10 +137,47 @@ module Dommy
|
|
|
118
137
|
child
|
|
119
138
|
end
|
|
120
139
|
|
|
140
|
+
# ES2018 `finally(onFinally)` — run `onFinally` (no args) whichever way the
|
|
141
|
+
# promise settles, then PASS THROUGH the original value/reason. Equivalent to
|
|
142
|
+
# `then(v => P.resolve(onFinally()).then(() => v),
|
|
143
|
+
# e => P.resolve(onFinally()).then(() => { throw e }))`:
|
|
144
|
+
# onFinally can't change the resolution value, but if it throws or returns a
|
|
145
|
+
# rejected promise that rejection wins, and a returned promise is awaited
|
|
146
|
+
# before passing through. A non-callable onFinally is a plain passthrough.
|
|
147
|
+
# `fetch(...).finally(hideSpinner)` is ubiquitous, so a missing `finally`
|
|
148
|
+
# crashes real bundles (hatena's ad/guide scripts hit exactly this).
|
|
149
|
+
def attach_finally(on_finally)
|
|
150
|
+
return attach_then(on_finally, on_finally) unless callable?(on_finally)
|
|
151
|
+
|
|
152
|
+
on_fulfilled = proc { |value| coerce_to_promise(call_finally(on_finally)).__js_call__("then", [proc { value }]) }
|
|
153
|
+
on_rejected = proc { |reason|
|
|
154
|
+
coerce_to_promise(call_finally(on_finally)).__js_call__("then", [proc { raise Bridge::ThrowValue.new(reason) }])
|
|
155
|
+
}
|
|
156
|
+
attach_then(on_fulfilled, on_rejected)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Invoke onFinally with no arguments, in raising mode so a throw rejects the
|
|
160
|
+
# finally-chain (run_handler's rescue carries the thrown value through).
|
|
161
|
+
def call_finally(on_finally)
|
|
162
|
+
if on_finally.respond_to?(:__js_call_with_raise__)
|
|
163
|
+
on_finally.__js_call_with_raise__([])
|
|
164
|
+
else
|
|
165
|
+
on_finally.call
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def coerce_to_promise(value)
|
|
170
|
+
value.is_a?(PromiseValue) ? value : self.class.resolve(@window, value)
|
|
171
|
+
end
|
|
172
|
+
|
|
121
173
|
def settle(state, value)
|
|
122
174
|
return self if settled?
|
|
123
175
|
|
|
124
|
-
|
|
176
|
+
# Only RESOLUTION adopts a promise — fulfilling with a host promise takes
|
|
177
|
+
# its eventual state (Promises/A+ §2.3.2). A rejection REASON is never
|
|
178
|
+
# resolved: `reject(aPromise)` rejects WITH that promise as the reason, so
|
|
179
|
+
# `then(null, r => r === aPromise)` holds (2.3.3.3.2 with a promise reason).
|
|
180
|
+
if state == :fulfilled && value.is_a?(PromiseValue)
|
|
125
181
|
return adopt(value)
|
|
126
182
|
end
|
|
127
183
|
|
|
@@ -168,15 +224,22 @@ module Dommy
|
|
|
168
224
|
|
|
169
225
|
def run_handler(handler)
|
|
170
226
|
callback = @state == :fulfilled ? handler.on_fulfilled : handler.on_rejected
|
|
171
|
-
|
|
227
|
+
unless callable?(callback)
|
|
228
|
+
# §2.2.7.3/.4: a non-function handler (`then(5)`, `then(null, {})`) passes
|
|
229
|
+
# the settled state straight through to the child, value/reason intact.
|
|
172
230
|
propagate(handler.child)
|
|
173
231
|
return
|
|
174
232
|
end
|
|
175
233
|
|
|
176
|
-
result =
|
|
177
|
-
if result.
|
|
178
|
-
#
|
|
179
|
-
#
|
|
234
|
+
result = invoke_handler(callback, @value)
|
|
235
|
+
if result.equal?(handler.child)
|
|
236
|
+
# §2.3.1: a handler returning its own promise is a cycle — reject with a
|
|
237
|
+
# TypeError rather than adopting itself forever.
|
|
238
|
+
handler.child.reject(Bridge::TypeError.new("Chaining cycle detected for promise"))
|
|
239
|
+
elsif result.is_a?(PromiseValue)
|
|
240
|
+
# Adopt the returned host promise. The continuation procs return nil so
|
|
241
|
+
# their `self`-returning fulfill/reject don't get re-adopted (infinite
|
|
242
|
+
# chain).
|
|
180
243
|
result.__js_call__(
|
|
181
244
|
"then",
|
|
182
245
|
[
|
|
@@ -188,10 +251,31 @@ module Dommy
|
|
|
188
251
|
handler.child.fulfill(result)
|
|
189
252
|
end
|
|
190
253
|
|
|
254
|
+
rescue Bridge::ThrowValue => e
|
|
255
|
+
# §2.2.7.2: the handler threw — reject the child with the thrown value
|
|
256
|
+
# ITSELF (identity preserved across the bridge), not a wrapping ErrorValue.
|
|
257
|
+
handler.child.reject(e.value)
|
|
191
258
|
rescue StandardError => e
|
|
192
259
|
handler.child.reject(ErrorValue.new(e.message, name: e.class.to_s))
|
|
193
260
|
end
|
|
194
261
|
|
|
262
|
+
# A callable handler is a JS function (HostCallback) or a Ruby proc; a plain
|
|
263
|
+
# value (number, null, object) is not, and triggers §2.2.7.3/.4 passthrough.
|
|
264
|
+
def callable?(callback)
|
|
265
|
+
callback.respond_to?(:__js_call__) || callback.respond_to?(:call)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Invoke a `.then` handler. A JS callback is invoked in RAISING mode so a
|
|
269
|
+
# thrown value re-raises as a Bridge::ThrowValue (§2.2.7.2) instead of being
|
|
270
|
+
# swallowed; a Ruby callable raises naturally.
|
|
271
|
+
def invoke_handler(callback, value)
|
|
272
|
+
if callback.respond_to?(:__js_call_with_raise__)
|
|
273
|
+
callback.__js_call_with_raise__([value])
|
|
274
|
+
else
|
|
275
|
+
CallableInvoker.invoke(callback, value)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
195
279
|
def propagate(child)
|
|
196
280
|
@state == :fulfilled ? child.fulfill(@value) : child.reject(@value)
|
|
197
281
|
end
|