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
data/lib/dommy/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dommy
4
- VERSION = "0.8.0"
4
+ VERSION = "0.9.0"
5
5
  end
data/lib/dommy/window.rb CHANGED
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "uri"
4
+
5
+ require_relative "internal/css/cascade"
6
+ require_relative "internal/css/media_query"
7
+
3
8
  # Dommy — a happy-dom-style DOM polyfill in pure Ruby. Backbone is
4
9
  # Nokogiri::HTML5 plus a small scheduler/event-loop layer.
5
10
  #
@@ -20,7 +25,14 @@ module Dommy
20
25
 
21
26
  attr_reader :document, :scheduler, :location, :globals, :custom_elements, :navigator
22
27
 
23
- def initialize(host = nil, nokogiri_doc: nil)
28
+ # Opt into best-effort geometry: when true, getBoundingClientRect / client* /
29
+ # offset* return non-zero estimates from a cheap pseudo-layout (viewport width
30
+ # + text content) instead of all-zero. Off by default so the no-layout
31
+ # contract (and the tests asserting 0) is unchanged; a browser front end
32
+ # (dommynx) turns it on so sites that bail on all-zero rects can proceed.
33
+ attr_accessor :approximate_layout
34
+
35
+ def initialize(host = nil, backend_doc: nil)
24
36
  @host = host
25
37
  @scheduler = Scheduler.new
26
38
  @crypto = Crypto.new(self)
@@ -35,8 +47,15 @@ module Dommy
35
47
  # production code stays on the typed accessors. Kept last in the read
36
48
  # fallback so it can't shadow intentional getters.
37
49
  @globals = {}
38
- @document = Document.new(host, nokogiri_doc: nokogiri_doc)
50
+ @document = Document.new(host, backend_doc: backend_doc)
39
51
  @document.default_view = self
52
+ # Per the HTML parsing algorithm, a <template>'s contents are parsed into a
53
+ # separate "template contents" DocumentFragment, not as children of the
54
+ # element. Backends (libxml2) leave them as direct children, so migrate
55
+ # eagerly at page-load time — before any framework walks the tree. Without
56
+ # this, a tree-walk (Alpine's x-for/x-if scan, etc.) descends into the
57
+ # template's inert content and evaluates directives there out of scope.
58
+ @document.migrate_template_descendants(@document.backend_doc)
40
59
  @custom_elements = CustomElementRegistry.new(self)
41
60
  @navigator = Navigator.new(self)
42
61
  # All JS global constructors (`new Event()`, `new URL()`, ...) live in a
@@ -95,6 +114,14 @@ module Dommy
95
114
  @custom_elements
96
115
  when "navigator"
97
116
  @navigator
117
+ when "screen"
118
+ @screen ||= Screen.new(self)
119
+ when "innerWidth", "outerWidth"
120
+ media_environment.viewport_width
121
+ when "innerHeight", "outerHeight"
122
+ media_environment.viewport_height
123
+ when "devicePixelRatio"
124
+ media_environment.device_pixel_ratio
98
125
  when "scrollX", "pageXOffset"
99
126
  @scroll_x || 0
100
127
  when "scrollY", "pageYOffset"
@@ -102,8 +129,18 @@ module Dommy
102
129
  when "scrollMaxX", "scrollMaxY"
103
130
  # No real content box to scroll past, so the max offset is 0.
104
131
  0
132
+ when "event"
133
+ # The legacy global current-event is *absent* (undefined, not null) when
134
+ # no event is being dispatched, so feature detection like
135
+ # `window.event === undefined` (React's getCurrentEventPriority) takes the
136
+ # not-supported path instead of dereferencing null. An explicitly-set
137
+ # value still wins.
138
+ @globals.key?("event") ? @globals["event"] : Bridge::UNDEFINED
105
139
  else
106
- @globals[key]
140
+ # A stashed global wins (even if its value is nil/null); a key never set
141
+ # is genuinely absent → ABSENT so JS sees `undefined` and `"x" in window`
142
+ # is false (feature detection like `isUndefined(window.Vue)` works).
143
+ @globals.key?(key) ? @globals[key] : Bridge::ABSENT
107
144
  end
108
145
  end
109
146
 
@@ -122,10 +159,11 @@ module Dommy
122
159
 
123
160
  include Bridge::Methods
124
161
  js_methods %w[
125
- fetch encodeURIComponent decodeURIComponent addEventListener removeEventListener
162
+ fetch encodeURIComponent decodeURIComponent btoa atob addEventListener removeEventListener
126
163
  dispatchEvent setTimeout clearTimeout setInterval clearInterval requestAnimationFrame
127
164
  cancelAnimationFrame queueMicrotask requestIdleCallback cancelIdleCallback structuredClone
128
- matchMedia getComputedStyle scroll scrollTo scrollBy
165
+ matchMedia getComputedStyle scroll scrollTo scrollBy resizeTo
166
+ alert confirm prompt open reportError getSelection postMessage
129
167
  ]
130
168
  def __js_call__(method, args)
131
169
  case method
@@ -135,6 +173,10 @@ module Dommy
135
173
  Internal::GlobalFunctions.encode_uri_component(args[0])
136
174
  when "decodeURIComponent"
137
175
  Internal::GlobalFunctions.decode_uri_component(args[0])
176
+ when "btoa"
177
+ Internal::GlobalFunctions.btoa(args[0])
178
+ when "atob"
179
+ Internal::GlobalFunctions.atob(args[0])
138
180
  when "addEventListener"
139
181
  add_event_listener(args[0], args[1], args[2])
140
182
  when "removeEventListener"
@@ -164,15 +206,27 @@ module Dommy
164
206
  when "matchMedia"
165
207
  MediaQueryList.new(self, args[0].to_s)
166
208
  when "getComputedStyle"
167
- # No CSS engine — return the element's inline style. That
168
- # covers `getComputedStyle(el).getPropertyValue("color")` for
169
- # values the test set inline via `el.style.color = "..."`.
170
- target = args[0]
171
- target.respond_to?(:style) ? target.style : nil
209
+ get_computed_style(args[0], args[1])
210
+ when "resizeTo"
211
+ resize_to(args[0], args[1])
172
212
  when "scroll", "scrollTo"
173
213
  scroll_to(*args)
174
214
  when "scrollBy"
175
215
  scroll_by(*args)
216
+ when "alert"
217
+ nil # headless: no dialog (happy-dom semantics)
218
+ when "confirm"
219
+ false # no user -> treated as "Cancel"
220
+ when "prompt"
221
+ nil # no user input
222
+ when "open"
223
+ nil # cannot open a new browsing context headlessly
224
+ when "reportError"
225
+ nil # swallow programmatic error reports (no uncaught surfacing here)
226
+ when "getSelection"
227
+ document&.get_selection
228
+ when "postMessage"
229
+ post_message(args[0])
176
230
  else
177
231
  # Additional window-level methods (fetch, location, history,
178
232
  # Promise, MutationObserver, etc.) arrive in later sessions.
@@ -199,6 +253,80 @@ module Dommy
199
253
  dispatch_event(event)
200
254
  end
201
255
 
256
+ # --- Viewport / media environment (cssom-view) ---
257
+
258
+ # The media-feature environment matchMedia and @media evaluate against
259
+ # (viewport 1280x720, light scheme, dpr 1 by default). Mutable; after a
260
+ # direct mutation call __internal_media_environment_changed__ to propagate —
261
+ # resize_to does both for the viewport.
262
+ def media_environment
263
+ @media_environment ||= Internal::CSS::MediaQuery::Environment.default
264
+ end
265
+
266
+ def inner_width = media_environment.viewport_width
267
+ def inner_height = media_environment.viewport_height
268
+
269
+ # Resolve a (possibly relative) URL against the document base URL — the
270
+ # API base URL of this window's environment, as fetch/XHR use when
271
+ # constructing a request. Returns the input unchanged if it can't resolve.
272
+ def __internal_resolve_url__(url)
273
+ base = @document&.base_uri
274
+ return url.to_s if base.to_s.empty?
275
+
276
+ URI.join(base.to_s, url.to_s).to_s
277
+ rescue URI::Error
278
+ url.to_s
279
+ end
280
+
281
+ # The path (with query) of a URL — lets a stub keyed by a path ("/api")
282
+ # match its resolved absolute form ("http://host/api").
283
+ def __internal_url_path__(url)
284
+ uri = URI.parse(url.to_s)
285
+ uri.query ? "#{uri.path}?#{uri.query}" : uri.path
286
+ rescue URI::Error
287
+ url.to_s
288
+ end
289
+
290
+ # Resize the virtual viewport: updates the environment, invalidates
291
+ # computed styles (@media), re-evaluates handed-out MediaQueryLists
292
+ # (firing their `change` events), and fires the window `resize` event.
293
+ def resize_to(width, height)
294
+ media_environment.viewport_width = width.to_i
295
+ media_environment.viewport_height = height.to_i
296
+ __internal_media_environment_changed__
297
+ dispatch_event(Event.new("resize"))
298
+ nil
299
+ end
300
+
301
+ def __internal_media_environment_changed__
302
+ @document&.__internal_bump_style_generation__
303
+ (@media_query_lists || []).each(&:__internal_environment_changed__)
304
+ nil
305
+ end
306
+
307
+ def __internal_register_media_query_list__(mql)
308
+ (@media_query_lists ||= []) << mql
309
+ nil
310
+ end
311
+
312
+ # CSSOM getComputedStyle. With the makiri-backed CSS parser available
313
+ # this resolves the full cascade (UA sheet + <style> sheets + style
314
+ # attribute); without it, falls back to the element's inline style (the
315
+ # historical behavior). A pseudo-element argument yields an empty
316
+ # declaration (Dommy renders no ::before/::after boxes).
317
+ def get_computed_style(element, pseudo_element = nil)
318
+ return nil unless element
319
+
320
+ if Internal::CSS::Parser.available?
321
+ pseudo = pseudo_element.to_s
322
+ Internal::CSS::ComputedStyleDeclaration.new(
323
+ element, pseudo_element: pseudo.empty? ? nil : pseudo
324
+ )
325
+ else
326
+ element.respond_to?(:style) ? element.style : nil
327
+ end
328
+ end
329
+
202
330
  private
203
331
 
204
332
  # Virtual scroll position. There's no real layout, but tracking a logical
@@ -216,6 +344,15 @@ module Dommy
216
344
  update_scroll(x, y)
217
345
  end
218
346
 
347
+ # `window.postMessage`: deliver a structured-cloned `message` to this window's
348
+ # own message handlers from a TASK (the "post message" task source, not a
349
+ # microtask) — so it lands in a later event-loop turn, as the spec requires.
350
+ def post_message(message)
351
+ data = Dommy.structured_clone(message)
352
+ @scheduler.set_timeout(proc { dispatch_event(MessageEvent.new("message", "data" => data)) }, 0)
353
+ nil
354
+ end
355
+
219
356
  # Accept either positional `(x, y)` or a `{ left:, top: }` options dict.
220
357
  def parse_scroll_args(args, cur_x, cur_y, relative:)
221
358
  if args[0].is_a?(Hash)
@@ -256,6 +393,20 @@ module Dommy
256
393
  0
257
394
  end
258
395
 
396
+ # WebIDL coercion for the `Text`/`Comment` constructor's `optional DOMString
397
+ # data = ""`: an omitted or undefined argument uses the default (""), but an
398
+ # explicit `null` stringifies to "null" per ToString. (Omitted arrives as an
399
+ # empty args list; explicit JS null arrives as a Ruby nil element.)
400
+ def node_data_arg(args)
401
+ return "" if args.empty?
402
+
403
+ value = args[0]
404
+ return "" if defined?(Bridge::UNDEFINED) && value.equal?(Bridge::UNDEFINED)
405
+ return "null" if value.nil?
406
+
407
+ value.to_s
408
+ end
409
+
259
410
  # Build the JS-global constructor map. Blocks are lazy (run at `new X()`
260
411
  # time), so they may reference `win` / `@document` freely.
261
412
  def build_constructors
@@ -281,8 +432,13 @@ module Dommy
281
432
  # `new Document()` — a fresh empty document (content type application/xml
282
433
  # per the DOM Standard, so it behaves as a non-HTML document).
283
434
  "Document" => Bridge::Constructor.new do
284
- Document.new(nil, nokogiri_doc: Backend.document_class.new).tap { |d| d.content_type = "application/xml" }
435
+ Document.new(nil, backend_doc: Backend.empty_xml_document).tap { |d| d.content_type = "application/xml" }
285
436
  end,
437
+ # `new Text(data?)` / `new Comment(data?)` / `new DocumentFragment()` —
438
+ # create the node in this window's associated document (DOM Standard).
439
+ "Text" => Bridge::Constructor.new { |args| win.document.create_text_node(node_data_arg(args)) },
440
+ "Comment" => Bridge::Constructor.new { |args| win.document.create_comment(node_data_arg(args)) },
441
+ "DocumentFragment" => Bridge::Constructor.new { |_args| win.document.create_document_fragment },
286
442
  "Event" => Bridge::Constructor.new { |args| Event.new(args[0], args[1]) },
287
443
  "CustomEvent" => Bridge::Constructor.new { |args| CustomEvent.new(args[0], args[1]) },
288
444
  "MessageEvent" => Bridge::Constructor.new { |args| MessageEvent.new(args[0], args[1]) },
@@ -290,8 +446,16 @@ module Dommy
290
446
  "CloseEvent" => Bridge::Constructor.new { |args| CloseEvent.new(args[0], args[1]) },
291
447
  "MouseEvent" => Bridge::Constructor.new { |args| MouseEvent.new(args[0], args[1]) },
292
448
  "KeyboardEvent" => Bridge::Constructor.new { |args| KeyboardEvent.new(args[0], args[1]) },
449
+ "PromiseRejectionEvent" => Bridge::Constructor.new { |args| PromiseRejectionEvent.new(args[0], args[1]) },
450
+ "ErrorEvent" => Bridge::Constructor.new { |args| ErrorEvent.new(args[0], args[1]) },
293
451
  "EventTarget" => Bridge::Constructor.new { |_args| StandaloneEventTarget.new },
294
452
  "Error" => Bridge::Constructor.new { |args| ErrorValue.new(args[0]) },
453
+ # The host PromiseConstructor backs Ruby-side promises (fetch, the
454
+ # scheduler bridge). It must NOT shadow the engine's native Promise on the
455
+ # JS side, though — `window.Promise` is forced to globalThis.Promise in
456
+ # host_runtime.js's exposeConstructorsOnWindow so feature detection
457
+ # (core-js et al.) sees a real Promise (=== globalThis.Promise) and does
458
+ # not swap in a polyfill whose microtasks the host can't flush.
295
459
  "Promise" => Bridge::PromiseConstructor.new(win),
296
460
  "MutationObserver" => Bridge::Constructor.new { |args| MutationObserver.new(win, args[0]) },
297
461
  "AbortController" => Bridge::Constructor.new { |_args| AbortController.new },
@@ -352,6 +516,30 @@ module Dommy
352
516
  "URLPattern" => Bridge::Constructor.new { |args| URLPattern.new(args[0], args[1]) },
353
517
  "Range" => Bridge::Constructor.new { |_args| Range.new(@document) },
354
518
  "URL" => url,
519
+ # Legacy named constructors (HTML `[LegacyFactoryFunction]`): each builds
520
+ # the corresponding element. `new Image()` is `<img>`, `new Audio()` is
521
+ # `<audio>` (preload="auto"), `new Option()` is `<option>`. The JS side
522
+ # exposes the globals with the target interface's prototype so e.g.
523
+ # `new Image() instanceof HTMLImageElement` holds.
524
+ "Image" => Bridge::Constructor.new { |args|
525
+ img = win.document.create_element("img")
526
+ img.set_attribute("width", args[0].to_s) unless args[0].nil?
527
+ img.set_attribute("height", args[1].to_s) unless args[1].nil?
528
+ img
529
+ },
530
+ "Audio" => Bridge::Constructor.new { |args|
531
+ audio = win.document.create_element("audio")
532
+ audio.set_attribute("preload", "auto")
533
+ audio.set_attribute("src", args[0].to_s) unless args[0].nil?
534
+ audio
535
+ },
536
+ "Option" => Bridge::Constructor.new { |args|
537
+ opt = win.document.create_element("option")
538
+ opt.text = args[0].to_s unless args[0].nil? || args[0].to_s.empty?
539
+ opt.value = args[1].to_s if args.length >= 2 && !args[1].nil?
540
+ opt.selected = true if args[2] || args[3] # defaultSelected / selectedness
541
+ opt
542
+ },
355
543
  }
356
544
  end
357
545
  end
data/lib/dommy/worker.rb CHANGED
@@ -35,10 +35,13 @@ module Dommy
35
35
  return if @terminated
36
36
 
37
37
  cloned = Dommy.structured_clone(data)
38
- @window.scheduler.queue_microtask(
38
+ # The "post message" task source — a task, not a microtask (a real worker
39
+ # delivers across the agent boundary in a later event-loop turn).
40
+ @window.scheduler.set_timeout(
39
41
  proc do
40
42
  @worker_side_handlers.each { |h| invoke(h, [{"data" => cloned}]) }
41
- end
43
+ end,
44
+ 0
42
45
  )
43
46
 
44
47
  nil
@@ -63,10 +66,11 @@ module Dommy
63
66
  # Worker-side: deliver a message to the main-side `message` event.
64
67
  def __test_post_to_main__(data)
65
68
  cloned = Dommy.structured_clone(data)
66
- @window.scheduler.queue_microtask(
69
+ @window.scheduler.set_timeout(
67
70
  proc do
68
71
  dispatch_event(MessageEvent.new("message", "data" => cloned))
69
- end
72
+ end,
73
+ 0
70
74
  )
71
75
 
72
76
  nil
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "data_uri"
4
5
 
5
6
  module Dommy
6
7
  # `XMLHttpRequest` polyfill. Consults the same stub maps that
@@ -68,7 +69,8 @@ module Dommy
68
69
  def open(method, url, async = true, _user = nil, _password = nil)
69
70
  reset_state
70
71
  @method = method.to_s.upcase
71
- @url = url.to_s
72
+ # XHR resolves the request URL against the document base URL, like fetch.
73
+ @url = @window.__internal_resolve_url__(url.to_s)
72
74
  @async = async.nil? ? true : !!async
73
75
  @request_headers = {}
74
76
  transition(OPENED)
@@ -95,12 +97,30 @@ module Dommy
95
97
  gen = @generation
96
98
  dispatch_event(ProgressEvent.new("loadstart"))
97
99
 
98
- entry = lookup_stub
100
+ entry = data_uri_entry || lookup_stub
99
101
  track_globals
100
102
 
103
+ # Async (live network): the handler returns a deferred whose response
104
+ # arrives off-thread and is delivered on the page thread. The request stays
105
+ # open until then. The sync path (stubs / cache / data:) delivers inline.
106
+ if entry.respond_to?(:on_complete)
107
+ entry.on_complete { |resolved| deliver_resolved(resolved, gen) }
108
+ return nil
109
+ end
110
+
111
+ deliver_resolved(entry, gen)
112
+ nil
113
+ end
114
+
115
+ # Deliver a resolved response entry (nil -> 404), honoring abort/reopen (the
116
+ # generation may have changed) and a simulated `delay`. Shared by the
117
+ # synchronous path and an async deferred completion.
118
+ def deliver_resolved(entry, gen)
119
+ return unless active?(gen)
120
+
101
121
  if entry.nil?
102
122
  deliver(body: "not found", status: 404, status_text: "Not Found", headers: {})
103
- return nil
123
+ return
104
124
  end
105
125
 
106
126
  delay = entry["delay"]
@@ -111,8 +131,6 @@ module Dommy
111
131
  else
112
132
  deliver_entry(entry)
113
133
  end
114
-
115
- nil
116
134
  end
117
135
 
118
136
  def abort
@@ -288,13 +306,36 @@ module Dommy
288
306
  dispatch_event(Event.new("readystatechange"))
289
307
  end
290
308
 
309
+ # A `data:` URI resolves to a synthetic 200 stub entry (see Dommy::DataUri),
310
+ # so XHR to an inline `data:image/svg+xml;…` works like a browser. nil for any
311
+ # non-data URL.
312
+ def data_uri_entry
313
+ decoded = DataUri.parse(@url)
314
+ return nil unless decoded
315
+
316
+ {"body" => decoded[:body], "status" => 200, "statusText" => "OK",
317
+ "contentType" => decoded[:content_type]}
318
+ end
319
+
291
320
  def lookup_stub
321
+ # Same resolution order as FetchFn: a `__fetch_handler__` callable
322
+ # (e.g. dommy-rack's NetworkBridge serving same-origin requests from
323
+ # the Rack app) gets first refusal; nil falls through to the stubs.
324
+ handler = @window.globals["__fetch_handler__"]
325
+ if handler.respond_to?(:call)
326
+ entry = handler.call(
327
+ @url,
328
+ {"method" => @method, "headers" => @request_headers, "body" => @request_body&.to_s}
329
+ )
330
+ return entry if entry
331
+ end
332
+
292
333
  stub_map = @window.globals["__fetchy_stub__"] ||
293
334
  @window.globals["__resource_fetch_stub__"] ||
294
335
  @window.globals["__inject_fetch_stub__"]
295
336
  return nil unless stub_map.is_a?(Hash)
296
337
 
297
- stub_map[@url]
338
+ stub_map[@url] || stub_map[@window.__internal_url_path__(@url)]
298
339
  end
299
340
 
300
341
  # Bookkeeping that lets specs assert "fetch was called N times"
data/lib/dommy.rb CHANGED
@@ -15,13 +15,24 @@ require_relative "dommy/event"
15
15
  require_relative "dommy/scheduler"
16
16
  require_relative "dommy/mutation_observer"
17
17
  require_relative "dommy/promise"
18
+ require_relative "dommy/deferred_response"
18
19
  require_relative "dommy/blob"
19
20
  require_relative "dommy/data_transfer"
20
21
  require_relative "dommy/crypto"
21
22
  require_relative "dommy/text_codec"
22
23
  require_relative "dommy/internal/observable_callback"
24
+ require_relative "dommy/internal/aria_role"
25
+ require_relative "dommy/internal/accessible_name"
26
+ require_relative "dommy/internal/accessible_description"
27
+ require_relative "dommy/internal/aria_state"
28
+ require_relative "dommy/internal/accessibility_tree"
29
+ require_relative "dommy/internal/aria_snapshot"
23
30
  require_relative "dommy/internal/css_pseudo_handlers"
31
+ require_relative "dommy/internal/css_rule_text"
32
+ require_relative "dommy/internal/selector_ast"
24
33
  require_relative "dommy/internal/selector_parser"
34
+ require_relative "dommy/internal/selector_index"
35
+ require_relative "dommy/internal/selector_matcher"
25
36
  require_relative "dommy/internal/punycode"
26
37
  require_relative "dommy/internal/idna"
27
38
  require_relative "dommy/internal/ipv4_parser"
@@ -29,6 +40,7 @@ require_relative "dommy/intersection_observer"
29
40
  require_relative "dommy/resize_observer"
30
41
  require_relative "dommy/performance_observer"
31
42
  require_relative "dommy/internal/range_text_serializer"
43
+ require_relative "dommy/internal/xml_serialization"
32
44
  require_relative "dommy/range"
33
45
  require_relative "dommy/animation"
34
46
  require_relative "dommy/bridge"
@@ -51,6 +63,7 @@ require_relative "dommy/worker"
51
63
  require_relative "dommy/location"
52
64
  require_relative "dommy/history"
53
65
  require_relative "dommy/navigator"
66
+ require_relative "dommy/screen"
54
67
  require_relative "dommy/parser"
55
68
  require_relative "dommy/attr"
56
69
  require_relative "dommy/window"
@@ -67,6 +80,28 @@ require_relative "dommy/url"
67
80
  require_relative "dommy/form_data"
68
81
  require_relative "dommy/dom_parser"
69
82
  require_relative "dommy/css"
83
+ require_relative "dommy/resources"
84
+ require_relative "dommy/interaction"
85
+
86
+ # JS-runtime host layer (engine-agnostic): the Runtime port + backend registry,
87
+ # script-boot orchestration, and the standalone Browser. A concrete JS engine
88
+ # (e.g. dommy-js-quickjs) registers itself through `Dommy::Js.register_runtime`.
89
+ require_relative "dommy/js/runtime"
90
+ require_relative "dommy/js/import_map"
91
+ require_relative "dommy/js/module_loader"
92
+ require_relative "dommy/js/script_boot"
93
+ # Engine-agnostic JS<->Ruby DOM bridge: the marshalling core (HostBridge) and
94
+ # its collaborators, plus the JS-side runtime bundles. A concrete backend
95
+ # (dommy-js-quickjs) plugs an engine in underneath via the backend contract
96
+ # documented in HostBridge.
97
+ require_relative "dommy/js/wire_tags"
98
+ require_relative "dommy/js/handle_table"
99
+ require_relative "dommy/js/marshaller"
100
+ require_relative "dommy/js/dom_interfaces"
101
+ require_relative "dommy/js/constructor_resolver"
102
+ require_relative "dommy/js/custom_element_bridge"
103
+ require_relative "dommy/js/host_bridge"
104
+ require_relative "dommy/browser"
70
105
 
71
106
  module Dommy
72
107
  # Parse an HTML string and return a fresh `Window`.
@@ -81,7 +116,7 @@ module Dommy
81
116
  def self.parse(html)
82
117
  s = html.to_s
83
118
  if s.match?(/\A\s*(<!doctype|<html\b)/i)
84
- Window.new(nil, nokogiri_doc: Backend.parse(s))
119
+ Window.new(nil, backend_doc: Backend.parse(s))
85
120
  else
86
121
  window = Window.new
87
122
  window.document.body.inner_html = s