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.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -3
  3. data/lib/dommy/animation.rb +4 -0
  4. data/lib/dommy/attr.rb +11 -5
  5. data/lib/dommy/backend/makiri_adapter.rb +330 -0
  6. data/lib/dommy/backend.rb +114 -33
  7. data/lib/dommy/blob.rb +2 -0
  8. data/lib/dommy/bridge.rb +11 -0
  9. data/lib/dommy/browser.rb +217 -0
  10. data/lib/dommy/compression_streams.rb +4 -0
  11. data/lib/dommy/crypto.rb +4 -0
  12. data/lib/dommy/css.rb +487 -50
  13. data/lib/dommy/custom_elements.rb +2 -2
  14. data/lib/dommy/data_transfer.rb +2 -0
  15. data/lib/dommy/data_uri.rb +35 -0
  16. data/lib/dommy/deferred_response.rb +59 -0
  17. data/lib/dommy/document.rb +386 -228
  18. data/lib/dommy/dom_exception.rb +2 -0
  19. data/lib/dommy/dom_parser.rb +7 -17
  20. data/lib/dommy/element.rb +502 -155
  21. data/lib/dommy/event.rb +240 -9
  22. data/lib/dommy/fetch.rb +152 -34
  23. data/lib/dommy/form_data.rb +2 -0
  24. data/lib/dommy/history.rb +2 -0
  25. data/lib/dommy/html_canvas_element.rb +230 -0
  26. data/lib/dommy/html_collection.rb +5 -6
  27. data/lib/dommy/html_elements.rb +304 -27
  28. data/lib/dommy/interaction/debug.rb +35 -0
  29. data/lib/dommy/interaction/dom_summary.rb +131 -0
  30. data/lib/dommy/interaction/driver.rb +244 -0
  31. data/lib/dommy/interaction/event_synthesis.rb +56 -0
  32. data/lib/dommy/interaction/field_interactor.rb +117 -0
  33. data/lib/dommy/interaction/form_submission.rb +268 -0
  34. data/lib/dommy/interaction/locator.rb +158 -0
  35. data/lib/dommy/interaction/role_query.rb +58 -0
  36. data/lib/dommy/interaction.rb +32 -0
  37. data/lib/dommy/internal/accessibility_tree.rb +215 -0
  38. data/lib/dommy/internal/accessible_description.rb +38 -0
  39. data/lib/dommy/internal/accessible_name.rb +301 -0
  40. data/lib/dommy/internal/aria_role.rb +252 -0
  41. data/lib/dommy/internal/aria_snapshot.rb +64 -0
  42. data/lib/dommy/internal/aria_state.rb +151 -0
  43. data/lib/dommy/internal/css/calc.rb +242 -0
  44. data/lib/dommy/internal/css/cascade.rb +430 -0
  45. data/lib/dommy/internal/css/color.rb +381 -0
  46. data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
  47. data/lib/dommy/internal/css/counters.rb +227 -0
  48. data/lib/dommy/internal/css/custom_properties.rb +183 -0
  49. data/lib/dommy/internal/css/media_query.rb +302 -0
  50. data/lib/dommy/internal/css/parser.rb +265 -0
  51. data/lib/dommy/internal/css/property_registry.rb +512 -0
  52. data/lib/dommy/internal/css/rule_index.rb +494 -0
  53. data/lib/dommy/internal/css/supports.rb +158 -0
  54. data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
  55. data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
  56. data/lib/dommy/internal/css_rule_text.rb +160 -0
  57. data/lib/dommy/internal/dom_matching.rb +80 -9
  58. data/lib/dommy/internal/element_matching.rb +109 -0
  59. data/lib/dommy/internal/global_functions.rb +33 -0
  60. data/lib/dommy/internal/mutation_coordinator.rb +95 -4
  61. data/lib/dommy/internal/namespaces.rb +49 -5
  62. data/lib/dommy/internal/node_wrapper_cache.rb +163 -26
  63. data/lib/dommy/internal/parent_node.rb +82 -5
  64. data/lib/dommy/internal/selector_ast.rb +124 -0
  65. data/lib/dommy/internal/selector_index.rb +146 -0
  66. data/lib/dommy/internal/selector_matcher.rb +756 -0
  67. data/lib/dommy/internal/selector_parser.rb +283 -131
  68. data/lib/dommy/internal/shadow_root_registry.rb +9 -2
  69. data/lib/dommy/internal/template_content_registry.rb +26 -18
  70. data/lib/dommy/internal/xml_serialization.rb +344 -0
  71. data/lib/dommy/intersection_observer.rb +2 -0
  72. data/lib/dommy/js/bridge_conformance.rb +80 -0
  73. data/lib/dommy/js/constructor_resolver.rb +44 -0
  74. data/lib/dommy/js/custom_element_bridge.rb +90 -0
  75. data/lib/dommy/js/dom_interfaces.rb +162 -0
  76. data/lib/dommy/js/handle_table.rb +60 -0
  77. data/lib/dommy/js/host_bridge.rb +517 -0
  78. data/lib/dommy/js/host_runtime.js +1495 -0
  79. data/lib/dommy/js/import_map.rb +58 -0
  80. data/lib/dommy/js/marshaller.rb +240 -0
  81. data/lib/dommy/js/module_loader.rb +99 -0
  82. data/lib/dommy/js/observable_runtime.js +742 -0
  83. data/lib/dommy/js/runtime.rb +115 -0
  84. data/lib/dommy/js/script_boot.rb +221 -0
  85. data/lib/dommy/js/wire_tags.rb +62 -0
  86. data/lib/dommy/location.rb +2 -0
  87. data/lib/dommy/media_query_list.rb +50 -14
  88. data/lib/dommy/message_channel.rb +22 -6
  89. data/lib/dommy/minitest/assertions.rb +27 -0
  90. data/lib/dommy/mutation_observer.rb +89 -4
  91. data/lib/dommy/navigator.rb +34 -2
  92. data/lib/dommy/node.rb +24 -14
  93. data/lib/dommy/notification.rb +2 -0
  94. data/lib/dommy/parser.rb +1 -1
  95. data/lib/dommy/performance.rb +21 -1
  96. data/lib/dommy/promise.rb +94 -10
  97. data/lib/dommy/range.rb +173 -31
  98. data/lib/dommy/resources.rb +178 -0
  99. data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
  100. data/lib/dommy/scheduler.rb +149 -13
  101. data/lib/dommy/screen.rb +91 -0
  102. data/lib/dommy/shadow_root.rb +76 -13
  103. data/lib/dommy/storage.rb +2 -1
  104. data/lib/dommy/streams.rb +6 -0
  105. data/lib/dommy/text_codec.rb +7 -1
  106. data/lib/dommy/tree_walker.rb +33 -10
  107. data/lib/dommy/url.rb +13 -1
  108. data/lib/dommy/version.rb +1 -1
  109. data/lib/dommy/window.rb +199 -11
  110. data/lib/dommy/worker.rb +8 -4
  111. data/lib/dommy/xml_http_request.rb +47 -6
  112. data/lib/dommy.rb +36 -1
  113. metadata +96 -10
  114. data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
  115. 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 |entry|
106
- observed_wrapped = entry[:target]
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: entry[:subtree])
118
+ matcher.matches_document?(target_wrapped, subtree: e[:subtree])
111
119
  else
112
- matcher.matches?(observed_wrapped, target_wrapped, subtree: entry[: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.
@@ -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 are provided JS-side (the array-like
67
- # prototype) so they return real iterators, not arrays — see host_runtime.js.
68
- js_methods %w[item forEach]
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 array-like
176
- # prototype) so they return real iterators, not arrays — see host_runtime.js.
177
- js_methods %w[item forEach]
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.nokogiri_doc)
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
- nk.namespace_definitions.each do |d|
296
+ Backend.namespace_definitions(nk).each do |d|
290
297
  return d.href if normalize_ns_prefix(d.prefix) == wanted
291
298
  end
292
- return nk.namespace ? nk.namespace.href : HTML_NAMESPACE if wanted.nil?
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
- nk.namespace_definitions.each do |d|
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
@@ -66,6 +66,8 @@ module Dommy
66
66
  @tag
67
67
  when "data"
68
68
  @data
69
+ else
70
+ Bridge::ABSENT
69
71
  end
70
72
  end
71
73
 
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
- # - Nokolexbor (Lexbor): similar behavior, slightly different edge
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
@@ -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
- nil
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
- @window&.scheduler&.drain_microtasks
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 microtask drain"
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
- if value.is_a?(PromiseValue)
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
- if callback.nil?
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 = CallableInvoker.invoke(callback, @value)
177
- if result.is_a?(PromiseValue) && !result.equal?(handler.child)
178
- # Adopt the returned thenable. The continuation procs return nil so their
179
- # `self`-returning fulfill/reject don't get re-adopted (infinite chain).
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