dommy 0.7.0 → 0.8.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/animation.rb +9 -1
  3. data/lib/dommy/attr.rb +192 -39
  4. data/lib/dommy/backend/nokogiri_adapter.rb +76 -0
  5. data/lib/dommy/backend/nokolexbor_adapter.rb +37 -0
  6. data/lib/dommy/backend.rb +46 -0
  7. data/lib/dommy/blob.rb +28 -9
  8. data/lib/dommy/bridge/constructor_registry.rb +28 -0
  9. data/lib/dommy/bridge/methods.rb +57 -0
  10. data/lib/dommy/bridge.rb +97 -0
  11. data/lib/dommy/callable_invoker.rb +36 -0
  12. data/lib/dommy/cookie_store.rb +3 -1
  13. data/lib/dommy/crypto.rb +7 -1
  14. data/lib/dommy/css.rb +46 -0
  15. data/lib/dommy/custom_elements.rb +27 -3
  16. data/lib/dommy/data_transfer.rb +4 -0
  17. data/lib/dommy/document.rb +615 -48
  18. data/lib/dommy/dom_parser.rb +28 -15
  19. data/lib/dommy/element.rb +999 -471
  20. data/lib/dommy/event.rb +260 -96
  21. data/lib/dommy/event_source.rb +6 -2
  22. data/lib/dommy/fetch.rb +505 -43
  23. data/lib/dommy/file_reader.rb +11 -3
  24. data/lib/dommy/form_data.rb +2 -0
  25. data/lib/dommy/history.rb +43 -8
  26. data/lib/dommy/html_collection.rb +55 -2
  27. data/lib/dommy/html_elements.rb +102 -1519
  28. data/lib/dommy/internal/css_pseudo_handlers.rb +109 -0
  29. data/lib/dommy/internal/global_functions.rb +26 -0
  30. data/lib/dommy/internal/idna.rb +16 -7
  31. data/lib/dommy/internal/ipv4_parser.rb +22 -7
  32. data/lib/dommy/internal/mutation_coordinator.rb +11 -2
  33. data/lib/dommy/internal/namespaces.rb +70 -0
  34. data/lib/dommy/internal/node_equality.rb +86 -0
  35. data/lib/dommy/internal/node_wrapper_cache.rb +62 -27
  36. data/lib/dommy/internal/observable_callback.rb +1 -5
  37. data/lib/dommy/internal/parent_node.rb +126 -0
  38. data/lib/dommy/internal/reflected_attributes.rb +103 -13
  39. data/lib/dommy/internal/selector_parser.rb +664 -0
  40. data/lib/dommy/internal/url_parser.rb +677 -0
  41. data/lib/dommy/intersection_observer.rb +2 -0
  42. data/lib/dommy/location.rb +2 -0
  43. data/lib/dommy/media_query_list.rb +7 -1
  44. data/lib/dommy/message_channel.rb +32 -2
  45. data/lib/dommy/mutation_observer.rb +55 -12
  46. data/lib/dommy/navigator.rb +26 -12
  47. data/lib/dommy/node.rb +158 -28
  48. data/lib/dommy/notification.rb +3 -1
  49. data/lib/dommy/performance.rb +4 -0
  50. data/lib/dommy/performance_observer.rb +2 -0
  51. data/lib/dommy/promise.rb +14 -14
  52. data/lib/dommy/range.rb +74 -5
  53. data/lib/dommy/resize_observer.rb +2 -0
  54. data/lib/dommy/scheduler.rb +34 -13
  55. data/lib/dommy/shadow_root.rb +23 -54
  56. data/lib/dommy/storage.rb +2 -0
  57. data/lib/dommy/streams.rb +18 -27
  58. data/lib/dommy/svg_elements.rb +204 -3606
  59. data/lib/dommy/text_codec.rb +174 -21
  60. data/lib/dommy/tree_walker.rb +255 -66
  61. data/lib/dommy/url.rb +287 -449
  62. data/lib/dommy/url_pattern.rb +2 -0
  63. data/lib/dommy/version.rb +1 -1
  64. data/lib/dommy/web_socket.rb +37 -7
  65. data/lib/dommy/window.rb +202 -213
  66. data/lib/dommy/worker.rb +7 -7
  67. data/lib/dommy/xml_http_request.rb +15 -5
  68. data/lib/dommy.rb +7 -0
  69. metadata +12 -3
@@ -54,6 +54,8 @@ module Dommy
54
54
  result
55
55
  end
56
56
 
57
+ include Bridge::Methods
58
+ js_methods %w[test exec]
57
59
  def __js_call__(method, args)
58
60
  case method
59
61
  when "test"
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.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -27,7 +27,7 @@ module Dommy
27
27
  INLINE_HANDLERS = %w[open message close error].freeze
28
28
 
29
29
  attr_reader :url, :protocol, :ready_state, :buffered_amount, :extensions
30
- attr_accessor :binary_type
30
+ attr_reader :binary_type
31
31
 
32
32
  def initialize(window, url, protocols = nil)
33
33
  @window = window
@@ -36,7 +36,10 @@ module Dommy
36
36
  @buffered_amount = 0
37
37
  @extensions = ""
38
38
  @binary_type = "blob"
39
- @protocol = Array(protocols).first.to_s
39
+ # The subprotocol stays "" until the server selects one at the handshake;
40
+ # remember what was requested and adopt the first on open.
41
+ @requested_protocols = Array(protocols).flatten.map(&:to_s)
42
+ @protocol = ""
40
43
  @sent_messages = []
41
44
  @inline_handlers = {}
42
45
 
@@ -45,18 +48,41 @@ module Dommy
45
48
  @window.scheduler.queue_microtask(proc { __test_simulate_open__ }) unless auto_open == false
46
49
  end
47
50
 
51
+ # binaryType is an enumerated attribute: only "blob"/"arraybuffer" are
52
+ # accepted, any other assignment is ignored (per WebIDL enum reflection).
53
+ def binary_type=(value)
54
+ v = value.to_s
55
+ @binary_type = v if %w[blob arraybuffer].include?(v)
56
+ end
57
+
48
58
  def send(data)
49
- raise Error, "WebSocket not OPEN" if @ready_state != OPEN
59
+ # send() before the connection opens is an InvalidStateError (a
60
+ # DOMException), not a bare Ruby error.
61
+ raise DOMException::InvalidStateError, "WebSocket is not open" if @ready_state == CONNECTING
62
+ return if @ready_state != OPEN # CLOSING/CLOSED silently discard (buffered)
50
63
 
51
64
  @sent_messages << data
52
65
  nil
53
66
  end
54
67
 
55
- def close(code = 1000, reason = "")
68
+ # close([code[, reason]]): code must be 1000 or in 3000–4999, and the UTF-8
69
+ # reason must be ≤ 123 bytes, else throw — matching the WebSocket spec.
70
+ def close(code = nil, reason = nil)
71
+ unless code.nil?
72
+ c = code.to_i
73
+ unless c == 1000 || c.between?(3000, 4999)
74
+ raise DOMException::InvalidAccessError, "The close code must be 1000 or in 3000-4999, got #{c}."
75
+ end
76
+ end
77
+ if reason && reason.to_s.bytesize > 123
78
+ raise DOMException::SyntaxError, "The close reason must not exceed 123 UTF-8 bytes."
79
+ end
56
80
  return if @ready_state == CLOSED || @ready_state == CLOSING
57
81
 
58
82
  @ready_state = CLOSING
59
- @window.scheduler.queue_microtask(proc { __test_simulate_close__(code, reason) })
83
+ final_code = code.nil? ? 1005 : code.to_i
84
+ final_reason = reason.to_s
85
+ @window.scheduler.queue_microtask(proc { __test_simulate_close__(final_code, final_reason) })
60
86
  nil
61
87
  end
62
88
 
@@ -70,6 +96,8 @@ module Dommy
70
96
  return if @ready_state != CONNECTING
71
97
 
72
98
  @ready_state = OPEN
99
+ # The handshake "selects" the first requested subprotocol.
100
+ @protocol = @requested_protocols.first || ""
73
101
  dispatch_event(Event.new("open"))
74
102
  end
75
103
 
@@ -127,7 +155,7 @@ module Dommy
127
155
  def __js_set__(key, value)
128
156
  case key
129
157
  when "binaryType"
130
- @binary_type = value.to_s
158
+ self.binary_type = value
131
159
  else
132
160
  event = inline_event_for(key)
133
161
  set_inline_handler(event, value) if event
@@ -136,6 +164,8 @@ module Dommy
136
164
  nil
137
165
  end
138
166
 
167
+ include Bridge::Methods
168
+ js_methods %w[send close addEventListener removeEventListener dispatchEvent]
139
169
  def __js_call__(method, args)
140
170
  case method
141
171
  when "send"
@@ -145,7 +175,7 @@ module Dommy
145
175
  when "addEventListener"
146
176
  add_event_listener(args[0], args[1], args[2])
147
177
  when "removeEventListener"
148
- remove_event_listener(args[0], args[1])
178
+ remove_event_listener(args[0], args[1], args[2])
149
179
  when "dispatchEvent"
150
180
  dispatch_event(args[0])
151
181
  end
data/lib/dommy/window.rb CHANGED
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "cgi"
4
- require "erb"
5
-
6
3
  # Dommy — a happy-dom-style DOM polyfill in pure Ruby. Backbone is
7
4
  # Nokogiri::HTML5 plus a small scheduler/event-loop layer.
8
5
  #
@@ -26,93 +23,26 @@ module Dommy
26
23
  def initialize(host = nil, nokogiri_doc: nil)
27
24
  @host = host
28
25
  @scheduler = Scheduler.new
29
- @event_ctor = Bridge::Constructor.new { |args| Event.new(args[0], args[1]) }
30
- @custom_event_ctor = Bridge::Constructor.new { |args| CustomEvent.new(args[0], args[1]) }
31
- @mouse_event_ctor = Bridge::Constructor.new { |args| MouseEvent.new(args[0], args[1]) }
32
- @keyboard_event_ctor = Bridge::Constructor.new { |args| KeyboardEvent.new(args[0], args[1]) }
33
- @event_target_ctor = Bridge::Constructor.new { |_args| StandaloneEventTarget.new }
34
- @error_ctor = Bridge::Constructor.new { |args| ErrorValue.new(args[0]) }
35
- @promise_ctor = Bridge::PromiseConstructor.new(self)
36
- @mutation_observer_ctor = Bridge::Constructor.new { |args| MutationObserver.new(self, args[0]) }
37
- @abort_controller_ctor = Bridge::Constructor.new { |_args| AbortController.new }
38
- @blob_ctor = Bridge::Constructor.new { |args| Blob.new(args[0] || [], args[1] || {}) }
39
- @file_ctor = Bridge::Constructor.new { |args| File.new(args[0] || [], args[1].to_s, args[2] || {}) }
40
- @file_list_ctor = Bridge::Constructor.new { |args| FileList.new(args[0] || []) }
41
- @data_transfer_ctor = Bridge::Constructor.new { |args|
42
- opts = args[0] || {}
43
- DataTransfer.new(
44
- files: opts["files"] || opts[:files] || [],
45
- data: opts["data"] || opts[:data] || {}
46
- )
47
- }
48
- @drag_event_ctor = Bridge::Constructor.new { |args| DragEvent.new(args[0], args[1]) }
49
- @input_event_ctor = Bridge::Constructor.new { |args| InputEvent.new(args[0], args[1]) }
50
- @pointer_event_ctor = Bridge::Constructor.new { |args| PointerEvent.new(args[0], args[1]) }
51
- @progress_event_ctor = Bridge::Constructor.new { |args| ProgressEvent.new(args[0], args[1]) }
52
- @touch_ctor = Bridge::Constructor.new { |args| Touch.new(args[0] || {}) }
53
- @touch_event_ctor = Bridge::Constructor.new { |args| TouchEvent.new(args[0], args[1]) }
54
- @clipboard_event_ctor = Bridge::Constructor.new { |args| ClipboardEvent.new(args[0], args[1]) }
55
- @composition_event_ctor = Bridge::Constructor.new { |args| CompositionEvent.new(args[0], args[1]) }
56
- @wheel_event_ctor = Bridge::Constructor.new { |args| WheelEvent.new(args[0], args[1]) }
57
- @focus_event_ctor = Bridge::Constructor.new { |args| FocusEvent.new(args[0], args[1]) }
58
- @before_unload_event_ctor = Bridge::Constructor.new { |args|
59
- BeforeUnloadEvent.new(args[0] || "beforeunload", args[1])
60
- }
61
- win_ref = self
62
- @animation_ctor = Bridge::Constructor.new { |args| Animation.new(args[0], args[1], window: win_ref) }
63
- @keyframe_effect_ctor = Bridge::Constructor.new { |args| KeyframeEffect.new(args[0], args[1] || [], args[2]) }
64
26
  @crypto = Crypto.new(self)
65
- @text_encoder_ctor = Bridge::Constructor.new { |_args| TextEncoder.new }
66
- @text_decoder_ctor = Bridge::Constructor.new { |args| TextDecoder.new(args[0] || "utf-8", args[1]) }
67
- @intersection_observer_ctor = Bridge::Constructor.new { |args| IntersectionObserver.new(args[0], args[1]) }
68
- @resize_observer_ctor = Bridge::Constructor.new { |args| ResizeObserver.new(args[0]) }
69
- @performance_observer_ctor = Bridge::Constructor.new { |args| PerformanceObserver.new(args[0]) }
70
- @request_ctor = Bridge::Constructor.new { |args| Request.new(args[0], args[1]) }
71
- xhr_win_ref = self
72
- @xhr_ctor = Bridge::Constructor.new { |_args| XMLHttpRequest.new(xhr_win_ref) }
73
- @file_reader_ctor = Bridge::Constructor.new { |_args| FileReader.new(xhr_win_ref) }
74
- @message_channel_ctor = Bridge::Constructor.new { |_args| MessageChannel.new(xhr_win_ref) }
75
- @broadcast_channel_ctor = Bridge::Constructor.new { |args| BroadcastChannel.new(xhr_win_ref, args[0]) }
76
- @web_socket_ctor = Bridge::Constructor.new { |args| WebSocket.new(xhr_win_ref, args[0], args[1]) }
77
- @event_source_ctor = Bridge::Constructor.new { |args| EventSource.new(xhr_win_ref, args[0], args[1]) }
78
- @notification_ctor = Bridge::Constructor.new { |args| Notification.new(xhr_win_ref, args[0], args[1]) }
79
- @notification_ctor.define_class_method("requestPermission") do |args|
80
- Notification.request_permission(xhr_win_ref, args[0])
81
- end
82
-
83
- @worker_ctor = Bridge::Constructor.new { |args| Worker.new(xhr_win_ref, args[0], args[1]) }
84
- @readable_stream_ctor = Bridge::Constructor.new { |args| ReadableStream.new(xhr_win_ref, args[0]) }
85
- @writable_stream_ctor = Bridge::Constructor.new { |args| WritableStream.new(xhr_win_ref, args[0]) }
86
- @transform_stream_ctor = Bridge::Constructor.new { |args| TransformStream.new(xhr_win_ref, args[0]) }
87
- @text_encoder_stream_ctor = Bridge::Constructor.new { |_args| TextEncoderStream.new(xhr_win_ref) }
88
- @text_decoder_stream_ctor = Bridge::Constructor.new { |args|
89
- TextDecoderStream.new(xhr_win_ref, args[0] || "utf-8", args[1])
90
- }
91
- @compression_stream_ctor = Bridge::Constructor.new { |args| CompressionStream.new(xhr_win_ref, args[0]) }
92
- @decompression_stream_ctor = Bridge::Constructor.new { |args| DecompressionStream.new(xhr_win_ref, args[0]) }
93
- @url_pattern_ctor = Bridge::Constructor.new { |args| URLPattern.new(args[0], args[1]) }
94
- @cookie_store = CookieStore.new(xhr_win_ref)
95
-
96
- @range_ctor = Bridge::Constructor.new { |_args| Range.new(@document) }
27
+ @css_namespace = CSSNamespace.new
28
+ @cookie_store = CookieStore.new(self)
97
29
  @local_storage = Storage.new
98
30
  @session_storage = Storage.new
99
31
  @location = Location.new(self)
100
32
  @history = History.new(self, @location)
101
- @url_ctor = Bridge::Constructor.new { |args| URL.new(args[0], args[1]) }
102
- @url_ctor.define_class_method("createObjectURL") { |args| URL.create_object_url(args[0]) }
103
- @url_ctor.define_class_method("revokeObjectURL") { |args| URL.revoke_object_url(args[0]) }
104
- @url_ctor.define_class_method("parse") { |args| URL.parse(args[0], args[1]) }
105
- @url_ctor.define_class_method("canParse") { |args| URL.can_parse(args[0], args[1]) }
106
- # `JS.global[:__some_key__] = ...` from user code lands here.
107
- # Test code uses this for stub installation (e.g. a custom
108
- # `__fetch_stub__`); production code stays on the typed
109
- # accessors above. We keep it last in the read fallback to
110
- # avoid shadowing intentional getters.
33
+ # `JS.global[:__some_key__] = ...` from user code lands here. Test code
34
+ # uses this for stub installation (e.g. a custom `__fetch_stub__`);
35
+ # production code stays on the typed accessors. Kept last in the read
36
+ # fallback so it can't shadow intentional getters.
111
37
  @globals = {}
112
38
  @document = Document.new(host, nokogiri_doc: nokogiri_doc)
113
39
  @document.default_view = self
114
40
  @custom_elements = CustomElementRegistry.new(self)
115
41
  @navigator = Navigator.new(self)
42
+ # All JS global constructors (`new Event()`, `new URL()`, ...) live in a
43
+ # single name→Constructor registry rather than one ivar + one __js_get__
44
+ # arm each.
45
+ @constructors = Bridge::ConstructorRegistry.new(build_constructors)
116
46
  end
117
47
 
118
48
  # Bridge protocol: respond to a JS-style property read by name.
@@ -124,115 +54,23 @@ module Dommy
124
54
  # nil (= JS undefined). Spec failures here are the signal to widen
125
55
  # the surface in a future session.
126
56
  def __js_get__(key)
57
+ ctor = @constructors[key]
58
+ return ctor if ctor
59
+
127
60
  case key
128
61
  when "document"
129
62
  @document
130
- when "Event"
131
- @event_ctor
132
- when "CustomEvent"
133
- @custom_event_ctor
134
- when "MouseEvent"
135
- @mouse_event_ctor
136
- when "KeyboardEvent"
137
- @keyboard_event_ctor
138
- when "EventTarget"
139
- @event_target_ctor
140
- when "Error"
141
- @error_ctor
142
- when "Promise"
143
- @promise_ctor
144
- when "MutationObserver"
145
- @mutation_observer_ctor
146
- when "AbortController"
147
- @abort_controller_ctor
148
- when "Blob"
149
- @blob_ctor
150
- when "File"
151
- @file_ctor
152
- when "FileList"
153
- @file_list_ctor
154
- when "DataTransfer"
155
- @data_transfer_ctor
156
- when "DragEvent"
157
- @drag_event_ctor
158
- when "InputEvent"
159
- @input_event_ctor
160
- when "PointerEvent"
161
- @pointer_event_ctor
162
- when "ProgressEvent"
163
- @progress_event_ctor
164
- when "Touch"
165
- @touch_ctor
166
- when "TouchEvent"
167
- @touch_event_ctor
168
- when "ClipboardEvent"
169
- @clipboard_event_ctor
170
- when "CompositionEvent"
171
- @composition_event_ctor
172
- when "WheelEvent"
173
- @wheel_event_ctor
174
- when "FocusEvent"
175
- @focus_event_ctor
176
- when "BeforeUnloadEvent"
177
- @before_unload_event_ctor
178
- when "Animation"
179
- @animation_ctor
180
- when "KeyframeEffect"
181
- @keyframe_effect_ctor
63
+ when "window", "self", "parent", "top", "frames"
64
+ # A top-level browsing context refers to itself for these. Returning the
65
+ # window (not nil) lets `window === window.parent` and frame-walking
66
+ # loops (e.g. testharness.js's `while (w != w.parent)`) terminate.
67
+ self
182
68
  when "crypto"
183
69
  @crypto
184
- when "TextEncoder"
185
- @text_encoder_ctor
186
- when "TextDecoder"
187
- @text_decoder_ctor
188
- when "IntersectionObserver"
189
- @intersection_observer_ctor
190
- when "ResizeObserver"
191
- @resize_observer_ctor
192
- when "PerformanceObserver"
193
- @performance_observer_ctor
194
- when "Request"
195
- @request_ctor
196
- when "XMLHttpRequest"
197
- @xhr_ctor
198
- when "FileReader"
199
- @file_reader_ctor
200
- when "MessageChannel"
201
- @message_channel_ctor
202
- when "BroadcastChannel"
203
- @broadcast_channel_ctor
204
- when "WebSocket"
205
- @web_socket_ctor
206
- when "EventSource"
207
- @event_source_ctor
208
- when "Notification"
209
- @notification_ctor
210
- when "Worker"
211
- @worker_ctor
212
- when "ReadableStream"
213
- @readable_stream_ctor
214
- when "WritableStream"
215
- @writable_stream_ctor
216
- when "TransformStream"
217
- @transform_stream_ctor
218
- when "TextEncoderStream"
219
- @text_encoder_stream_ctor
220
- when "TextDecoderStream"
221
- @text_decoder_stream_ctor
222
- when "CompressionStream"
223
- @compression_stream_ctor
224
- when "DecompressionStream"
225
- @decompression_stream_ctor
226
- when "URLPattern"
227
- @url_pattern_ctor
228
70
  when "cookieStore"
229
71
  @cookie_store
230
- when "Range"
231
- @range_ctor
232
- # handled by Symbol sentinel
233
72
  when "console"
234
73
  :console
235
- # likewise
236
74
  when "Object"
237
75
  :object_ctor
238
76
  when "Array"
@@ -249,14 +87,21 @@ module Dommy
249
87
  @location
250
88
  when "history"
251
89
  @history
252
- when "URL"
253
- @url_ctor
90
+ when "CSS"
91
+ @css_namespace
254
92
  when "fetch"
255
93
  FetchFn.new(self)
256
94
  when "customElements"
257
95
  @custom_elements
258
96
  when "navigator"
259
97
  @navigator
98
+ when "scrollX", "pageXOffset"
99
+ @scroll_x || 0
100
+ when "scrollY", "pageYOffset"
101
+ @scroll_y || 0
102
+ when "scrollMaxX", "scrollMaxY"
103
+ # No real content box to scroll past, so the max offset is 0.
104
+ 0
260
105
  else
261
106
  @globals[key]
262
107
  end
@@ -275,40 +120,33 @@ module Dommy
275
120
  nil
276
121
  end
277
122
 
278
- # Methods routed through __js_call__ (keep in sync with its when-arms).
279
- JS_METHOD_NAMES = %w[
123
+ include Bridge::Methods
124
+ js_methods %w[
280
125
  fetch encodeURIComponent decodeURIComponent addEventListener removeEventListener
281
126
  dispatchEvent setTimeout clearTimeout setInterval clearInterval requestAnimationFrame
282
- cancelAnimationFrame queueMicrotask requestIdleCallback cancelIdleCallback
283
- structuredClone matchMedia getComputedStyle
284
- ].freeze
285
- def __js_method_names__
286
- JS_METHOD_NAMES
287
- end
288
-
127
+ cancelAnimationFrame queueMicrotask requestIdleCallback cancelIdleCallback structuredClone
128
+ matchMedia getComputedStyle scroll scrollTo scrollBy
129
+ ]
289
130
  def __js_call__(method, args)
290
131
  case method
291
132
  when "fetch"
292
133
  FetchFn.new(self).__js_call__("call", args)
293
134
  when "encodeURIComponent"
294
- # JS spec encoding: percent-encode anything except
295
- # `A-Za-z0-9 - _ . ! ~ * ' ( )`. Ruby's `CGI.escape` uses
296
- # `+` for space; ERB::Util.url_encode matches JS behavior.
297
- ERB::Util.url_encode(args[0].to_s)
135
+ Internal::GlobalFunctions.encode_uri_component(args[0])
298
136
  when "decodeURIComponent"
299
- CGI.unescape(args[0].to_s)
137
+ Internal::GlobalFunctions.decode_uri_component(args[0])
300
138
  when "addEventListener"
301
139
  add_event_listener(args[0], args[1], args[2])
302
140
  when "removeEventListener"
303
- remove_event_listener(args[0], args[1])
141
+ remove_event_listener(args[0], args[1], args[2])
304
142
  when "dispatchEvent"
305
143
  dispatch_event(args[0])
306
144
  when "setTimeout"
307
- @scheduler.set_timeout(args[0], args[1] || 0)
145
+ @scheduler.set_timeout(args[0], timer_delay(args[1]))
308
146
  when "clearTimeout"
309
147
  @scheduler.clear_timeout(args[0])
310
148
  when "setInterval"
311
- @scheduler.set_interval(args[0], args[1] || 0)
149
+ @scheduler.set_interval(args[0], timer_delay(args[1]))
312
150
  when "clearInterval"
313
151
  @scheduler.clear_interval(args[0])
314
152
  when "requestAnimationFrame"
@@ -318,20 +156,9 @@ module Dommy
318
156
  when "queueMicrotask"
319
157
  @scheduler.queue_microtask(args[0])
320
158
  when "requestIdleCallback"
321
- # WHATWG `requestIdleCallback` no real idle period in
322
- # dommy, so we model it as a deferred setTimeout. The
323
- # callback receives an `IdleDeadline`-shaped Hash.
324
- @scheduler.set_timeout(
325
- proc {
326
- args[0].respond_to?(:__js_call__) ? args[0].__js_call__(
327
- "call",
328
- [{"timeRemaining" => 50.0, "didTimeout" => false}]
329
- ) : args[0].call({"timeRemaining" => 50.0, "didTimeout" => false})
330
- },
331
- (args[1].is_a?(Hash) && args[1]["timeout"]) || 0
332
- )
159
+ @scheduler.request_idle_callback(args[0], (args[1].is_a?(Hash) && args[1]["timeout"]) || 0)
333
160
  when "cancelIdleCallback"
334
- @scheduler.clear_timeout(args[0])
161
+ @scheduler.cancel_idle_callback(args[0])
335
162
  when "structuredClone"
336
163
  Dommy.structured_clone(args[0])
337
164
  when "matchMedia"
@@ -342,6 +169,10 @@ module Dommy
342
169
  # values the test set inline via `el.style.color = "..."`.
343
170
  target = args[0]
344
171
  target.respond_to?(:style) ? target.style : nil
172
+ when "scroll", "scrollTo"
173
+ scroll_to(*args)
174
+ when "scrollBy"
175
+ scroll_by(*args)
345
176
  else
346
177
  # Additional window-level methods (fetch, location, history,
347
178
  # Promise, MutationObserver, etc.) arrive in later sessions.
@@ -357,7 +188,9 @@ module Dommy
357
188
  # hashchange events. Listeners registered on the Window via
358
189
  # `addEventListener("popstate"|"hashchange", cb)` receive them.
359
190
  def fire_popstate(state)
360
- event = CustomEvent.new("popstate", "detail" => state)
191
+ # PopStateEvent exposes the entry's state as `event.state` (the spec
192
+ # property). Routers (Turbo) branch on `event.state`.
193
+ event = PopStateEvent.new("popstate", "state" => state)
361
194
  dispatch_event(event)
362
195
  end
363
196
 
@@ -365,5 +198,161 @@ module Dommy
365
198
  event = CustomEvent.new("hashchange", "detail" => {"oldURL" => old_hash, "newURL" => new_hash})
366
199
  dispatch_event(event)
367
200
  end
201
+
202
+ private
203
+
204
+ # Virtual scroll position. There's no real layout, but tracking a logical
205
+ # `(scrollX, scrollY)` makes scroll-dependent behaviour observable: scrollTo/
206
+ # scroll set it absolutely, scrollBy relatively, and a `scroll` event fires on
207
+ # change so observers (e.g. Turbo's ScrollObserver, which records the position
208
+ # into history restoration data and replays it on back/forward) work.
209
+ def scroll_to(*args)
210
+ x, y = parse_scroll_args(args, @scroll_x || 0, @scroll_y || 0, relative: false)
211
+ update_scroll(x, y)
212
+ end
213
+
214
+ def scroll_by(*args)
215
+ x, y = parse_scroll_args(args, @scroll_x || 0, @scroll_y || 0, relative: true)
216
+ update_scroll(x, y)
217
+ end
218
+
219
+ # Accept either positional `(x, y)` or a `{ left:, top: }` options dict.
220
+ def parse_scroll_args(args, cur_x, cur_y, relative:)
221
+ if args[0].is_a?(Hash)
222
+ dx = scroll_coord(args[0]["left"] || args[0][:left])
223
+ dy = scroll_coord(args[0]["top"] || args[0][:top])
224
+ else
225
+ dx = scroll_coord(args[0])
226
+ dy = scroll_coord(args[1])
227
+ end
228
+ if relative
229
+ [cur_x + (dx || 0), cur_y + (dy || 0)]
230
+ else
231
+ [dx.nil? ? cur_x : dx, dy.nil? ? cur_y : dy]
232
+ end
233
+ end
234
+
235
+ def scroll_coord(value)
236
+ return nil if value.nil? || (defined?(Bridge::UNDEFINED) && value.equal?(Bridge::UNDEFINED))
237
+
238
+ value.is_a?(Numeric) ? value.to_i : value.to_s.to_i
239
+ end
240
+
241
+ def update_scroll(x, y)
242
+ return nil if x == (@scroll_x || 0) && y == (@scroll_y || 0)
243
+
244
+ @scroll_x = x
245
+ @scroll_y = y
246
+ dispatch_event(Event.new("scroll"))
247
+ nil
248
+ end
249
+
250
+ # The timer delay (WebIDL `long`, default 0). A missing/undefined argument
251
+ # or any non-numeric value coerces to 0 rather than raising.
252
+ def timer_delay(value)
253
+ return value if value.is_a?(Numeric)
254
+ return value.to_i if value.is_a?(String) && value =~ /\A\s*-?\d+/
255
+
256
+ 0
257
+ end
258
+
259
+ # Build the JS-global constructor map. Blocks are lazy (run at `new X()`
260
+ # time), so they may reference `win` / `@document` freely.
261
+ def build_constructors
262
+ win = self
263
+
264
+ notification = Bridge::Constructor.new { |args| Notification.new(win, args[0], args[1]) }
265
+ notification.define_class_method("requestPermission") { |args| Notification.request_permission(win, args[0]) }
266
+
267
+ url = Bridge::Constructor.new { |args| URL.new(args[0], args[1]) }
268
+ url.define_class_method("createObjectURL") { |args| URL.create_object_url(args[0]) }
269
+ url.define_class_method("revokeObjectURL") { |args| URL.revoke_object_url(args[0]) }
270
+ url.define_class_method("parse") { |args| URL.parse(args[0], args[1]) }
271
+ url.define_class_method("canParse") { |args| URL.can_parse(args[0], args[1]) }
272
+
273
+ # AbortSignal is not constructible (`new AbortSignal()` → TypeError); it is
274
+ # exposed only for its static factories abort()/any()/timeout().
275
+ abort_signal = Bridge::Constructor.new { |_args| raise Bridge::TypeError, "Illegal constructor" }
276
+ abort_signal.define_class_method("abort") { |args| args.empty? ? AbortSignal.abort : AbortSignal.abort(args[0]) }
277
+ abort_signal.define_class_method("any") { |args| AbortSignal.any(args[0]) }
278
+ abort_signal.define_class_method("timeout") { |args| AbortSignal.timeout(args[0], scheduler: win.scheduler) }
279
+
280
+ {
281
+ # `new Document()` — a fresh empty document (content type application/xml
282
+ # per the DOM Standard, so it behaves as a non-HTML document).
283
+ "Document" => Bridge::Constructor.new do
284
+ Document.new(nil, nokogiri_doc: Backend.document_class.new).tap { |d| d.content_type = "application/xml" }
285
+ end,
286
+ "Event" => Bridge::Constructor.new { |args| Event.new(args[0], args[1]) },
287
+ "CustomEvent" => Bridge::Constructor.new { |args| CustomEvent.new(args[0], args[1]) },
288
+ "MessageEvent" => Bridge::Constructor.new { |args| MessageEvent.new(args[0], args[1]) },
289
+ "PopStateEvent" => Bridge::Constructor.new { |args| PopStateEvent.new(args[0], args[1]) },
290
+ "CloseEvent" => Bridge::Constructor.new { |args| CloseEvent.new(args[0], args[1]) },
291
+ "MouseEvent" => Bridge::Constructor.new { |args| MouseEvent.new(args[0], args[1]) },
292
+ "KeyboardEvent" => Bridge::Constructor.new { |args| KeyboardEvent.new(args[0], args[1]) },
293
+ "EventTarget" => Bridge::Constructor.new { |_args| StandaloneEventTarget.new },
294
+ "Error" => Bridge::Constructor.new { |args| ErrorValue.new(args[0]) },
295
+ "Promise" => Bridge::PromiseConstructor.new(win),
296
+ "MutationObserver" => Bridge::Constructor.new { |args| MutationObserver.new(win, args[0]) },
297
+ "AbortController" => Bridge::Constructor.new { |_args| AbortController.new },
298
+ "AbortSignal" => abort_signal,
299
+ "Blob" => Bridge::Constructor.new { |args| Blob.new(args[0] || [], args[1] || {}, win) },
300
+ "File" => Bridge::Constructor.new { |args| File.new(args[0] || [], args[1].to_s, args[2] || {}, win) },
301
+ "FileList" => Bridge::Constructor.new { |args| FileList.new(args[0] || []) },
302
+ "FormData" => Bridge::Constructor.new { |args| FormData.new(args[0]) },
303
+ "DOMParser" => Bridge::Constructor.new { |_args| DOMParser.new },
304
+ "XMLSerializer" => Bridge::Constructor.new { |_args| XMLSerializer.new },
305
+ "URLSearchParams" => Bridge::Constructor.new { |args| URLSearchParams.new(args[0] || "") },
306
+ "Headers" => Bridge::Constructor.new { |args| Headers.new(args[0] || {}) },
307
+ "Response" => Bridge::Constructor.new { |args| Response.__construct__(win, args[0], args[1]) }
308
+ .define_class_method("json") { |args| Response.__json__(win, args.length >= 1 ? args[0] : Bridge::UNDEFINED, args[1]) }
309
+ .define_class_method("redirect") { |args| Response.__redirect__(win, args[0], args[1]) }
310
+ .define_class_method("error") { |_args| Response.__error__(win) },
311
+ "DataTransfer" => Bridge::Constructor.new { |args|
312
+ opts = args[0] || {}
313
+ DataTransfer.new(
314
+ files: opts["files"] || opts[:files] || [],
315
+ data: opts["data"] || opts[:data] || {}
316
+ )
317
+ },
318
+ "DragEvent" => Bridge::Constructor.new { |args| DragEvent.new(args[0], args[1]) },
319
+ "InputEvent" => Bridge::Constructor.new { |args| InputEvent.new(args[0], args[1]) },
320
+ "PointerEvent" => Bridge::Constructor.new { |args| PointerEvent.new(args[0], args[1]) },
321
+ "ProgressEvent" => Bridge::Constructor.new { |args| ProgressEvent.new(args[0], args[1]) },
322
+ "Touch" => Bridge::Constructor.new { |args| Touch.new(args[0] || {}) },
323
+ "TouchEvent" => Bridge::Constructor.new { |args| TouchEvent.new(args[0], args[1]) },
324
+ "ClipboardEvent" => Bridge::Constructor.new { |args| ClipboardEvent.new(args[0], args[1]) },
325
+ "CompositionEvent" => Bridge::Constructor.new { |args| CompositionEvent.new(args[0], args[1]) },
326
+ "WheelEvent" => Bridge::Constructor.new { |args| WheelEvent.new(args[0], args[1]) },
327
+ "FocusEvent" => Bridge::Constructor.new { |args| FocusEvent.new(args[0], args[1]) },
328
+ "BeforeUnloadEvent" => Bridge::Constructor.new { |args| BeforeUnloadEvent.new(args[0] || "beforeunload", args[1]) },
329
+ "Animation" => Bridge::Constructor.new { |args| Animation.new(args[0], args[1], window: win) },
330
+ "KeyframeEffect" => Bridge::Constructor.new { |args| KeyframeEffect.new(args[0], args[1] || [], args[2]) },
331
+ "TextEncoder" => Bridge::Constructor.new { |_args| TextEncoder.new },
332
+ "TextDecoder" => Bridge::Constructor.new { |args| TextDecoder.new(args[0] || "utf-8", args[1]) },
333
+ "IntersectionObserver" => Bridge::Constructor.new { |args| IntersectionObserver.new(args[0], args[1]) },
334
+ "ResizeObserver" => Bridge::Constructor.new { |args| ResizeObserver.new(args[0]) },
335
+ "PerformanceObserver" => Bridge::Constructor.new { |args| PerformanceObserver.new(args[0]) },
336
+ "Request" => Bridge::Constructor.new { |args| Request.new(args[0], args[1]) },
337
+ "XMLHttpRequest" => Bridge::Constructor.new { |_args| XMLHttpRequest.new(win) },
338
+ "FileReader" => Bridge::Constructor.new { |_args| FileReader.new(win) },
339
+ "MessageChannel" => Bridge::Constructor.new { |_args| MessageChannel.new(win) },
340
+ "BroadcastChannel" => Bridge::Constructor.new { |args| BroadcastChannel.new(win, args[0]) },
341
+ "WebSocket" => Bridge::Constructor.new { |args| WebSocket.new(win, args[0], args[1]) },
342
+ "EventSource" => Bridge::Constructor.new { |args| EventSource.new(win, args[0], args[1]) },
343
+ "Notification" => notification,
344
+ "Worker" => Bridge::Constructor.new { |args| Worker.new(win, args[0], args[1]) },
345
+ "ReadableStream" => Bridge::Constructor.new { |args| ReadableStream.new(win, args[0]) },
346
+ "WritableStream" => Bridge::Constructor.new { |args| WritableStream.new(win, args[0]) },
347
+ "TransformStream" => Bridge::Constructor.new { |args| TransformStream.new(win, args[0]) },
348
+ "TextEncoderStream" => Bridge::Constructor.new { |_args| TextEncoderStream.new(win) },
349
+ "TextDecoderStream" => Bridge::Constructor.new { |args| TextDecoderStream.new(win, args[0] || "utf-8", args[1]) },
350
+ "CompressionStream" => Bridge::Constructor.new { |args| CompressionStream.new(win, args[0]) },
351
+ "DecompressionStream" => Bridge::Constructor.new { |args| DecompressionStream.new(win, args[0]) },
352
+ "URLPattern" => Bridge::Constructor.new { |args| URLPattern.new(args[0], args[1]) },
353
+ "Range" => Bridge::Constructor.new { |_args| Range.new(@document) },
354
+ "URL" => url,
355
+ }
356
+ end
368
357
  end
369
358
  end
data/lib/dommy/worker.rb CHANGED
@@ -85,10 +85,14 @@ module Dommy
85
85
 
86
86
  def __js_set__(key, value)
87
87
  event = inline_event_for(key)
88
- set_inline_handler(event, value) if event
88
+ return Bridge::UNHANDLED unless event
89
+
90
+ set_inline_handler(event, value)
89
91
  nil
90
92
  end
91
93
 
94
+ include Bridge::Methods
95
+ js_methods %w[postMessage terminate addEventListener removeEventListener dispatchEvent]
92
96
  def __js_call__(method, args)
93
97
  case method
94
98
  when "postMessage"
@@ -98,7 +102,7 @@ module Dommy
98
102
  when "addEventListener"
99
103
  add_event_listener(args[0], args[1], args[2])
100
104
  when "removeEventListener"
101
- remove_event_listener(args[0], args[1])
105
+ remove_event_listener(args[0], args[1], args[2])
102
106
  when "dispatchEvent"
103
107
  dispatch_event(args[0])
104
108
  end
@@ -133,11 +137,7 @@ module Dommy
133
137
  end
134
138
 
135
139
  def invoke(callback, args)
136
- if callback.respond_to?(:__js_call__)
137
- callback.__js_call__("call", args)
138
- elsif callback.respond_to?(:call)
139
- callback.call(*args)
140
- end
140
+ CallableInvoker.invoke(callback, *args)
141
141
  end
142
142
  end
143
143
  end