dommy 0.5.0 → 0.7.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +31 -13
  3. data/lib/dommy/animation.rb +288 -0
  4. data/lib/dommy/attr.rb +23 -11
  5. data/lib/dommy/backend/nokogiri_adapter.rb +51 -0
  6. data/lib/dommy/backend/nokolexbor_adapter.rb +80 -0
  7. data/lib/dommy/backend.rb +129 -0
  8. data/lib/dommy/blob.rb +2 -2
  9. data/lib/dommy/compression_streams.rb +147 -0
  10. data/lib/dommy/cookie_store.rb +128 -0
  11. data/lib/dommy/crypto.rb +396 -0
  12. data/lib/dommy/css.rb +7 -7
  13. data/lib/dommy/custom_elements.rb +6 -6
  14. data/lib/dommy/document.rb +190 -32
  15. data/lib/dommy/dom_parser.rb +5 -4
  16. data/lib/dommy/element.rb +356 -53
  17. data/lib/dommy/event.rb +431 -25
  18. data/lib/dommy/event_source.rb +131 -0
  19. data/lib/dommy/fetch.rb +76 -6
  20. data/lib/dommy/file_reader.rb +176 -0
  21. data/lib/dommy/form_data.rb +1 -3
  22. data/lib/dommy/history.rb +82 -0
  23. data/lib/dommy/html_collection.rb +4 -4
  24. data/lib/dommy/html_elements.rb +130 -67
  25. data/lib/dommy/internal/cookie_jar.rb +2 -0
  26. data/lib/dommy/internal/css_pseudo_handlers.rb +28 -0
  27. data/lib/dommy/internal/dom_matching.rb +4 -4
  28. data/lib/dommy/internal/idna.rb +443 -0
  29. data/lib/dommy/internal/idna_data.rb +10379 -0
  30. data/lib/dommy/internal/ipv4_parser.rb +78 -0
  31. data/lib/dommy/internal/node_traversal.rb +1 -1
  32. data/lib/dommy/internal/node_wrapper_cache.rb +23 -12
  33. data/lib/dommy/internal/observable_callback.rb +25 -0
  34. data/lib/dommy/internal/punycode.rb +202 -0
  35. data/lib/dommy/internal/range_text_serializer.rb +72 -0
  36. data/lib/dommy/internal/reflected_attributes.rb +45 -0
  37. data/lib/dommy/internal/template_content_registry.rb +6 -6
  38. data/lib/dommy/intersection_observer.rb +82 -0
  39. data/lib/dommy/{router.rb → location.rb} +8 -142
  40. data/lib/dommy/media_query_list.rb +118 -0
  41. data/lib/dommy/message_channel.rb +249 -0
  42. data/lib/dommy/{observer.rb → mutation_observer.rb} +21 -11
  43. data/lib/dommy/navigator.rb +365 -5
  44. data/lib/dommy/node.rb +12 -0
  45. data/lib/dommy/notification.rb +89 -0
  46. data/lib/dommy/parser.rb +13 -13
  47. data/lib/dommy/performance.rb +146 -0
  48. data/lib/dommy/performance_observer.rb +55 -0
  49. data/lib/dommy/range.rb +597 -0
  50. data/lib/dommy/resize_observer.rb +53 -0
  51. data/lib/dommy/shadow_root.rb +10 -8
  52. data/lib/dommy/streams.rb +386 -0
  53. data/lib/dommy/svg_elements.rb +3863 -0
  54. data/lib/dommy/text_codec.rb +175 -0
  55. data/lib/dommy/tree_walker.rb +21 -21
  56. data/lib/dommy/url.rb +274 -29
  57. data/lib/dommy/url_pattern.rb +144 -0
  58. data/lib/dommy/version.rb +1 -1
  59. data/lib/dommy/web_socket.rb +209 -0
  60. data/lib/dommy/window.rb +369 -0
  61. data/lib/dommy/worker.rb +143 -0
  62. data/lib/dommy/xml_http_request.rb +438 -0
  63. data/lib/dommy.rb +43 -5
  64. metadata +44 -29
  65. data/lib/dommy/world.rb +0 -209
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ # `WebSocket` polyfill. Real implementations open a TCP-then-frame
5
+ # connection; dommy exposes an in-memory transport tests drive via
6
+ # the `__*` seams:
7
+ #
8
+ # ws.__test_simulate_open__ — fires `open`
9
+ # ws.__test_simulate_message__(data) — fires `message`
10
+ # ws.__test_simulate_close__(code, reason) — fires `close`
11
+ # ws.__test_simulate_error__ — fires `error`
12
+ # ws.__test_sent_messages__ — array of sent payloads
13
+ #
14
+ # By default a `new WebSocket(url)` auto-opens via microtask so the
15
+ # common pattern (`ws.onopen = ...; ws.send(...)`) works without
16
+ # extra setup.
17
+ #
18
+ # Spec: https://websockets.spec.whatwg.org/
19
+ class WebSocket
20
+ include EventTarget
21
+
22
+ CONNECTING = 0
23
+ OPEN = 1
24
+ CLOSING = 2
25
+ CLOSED = 3
26
+
27
+ INLINE_HANDLERS = %w[open message close error].freeze
28
+
29
+ attr_reader :url, :protocol, :ready_state, :buffered_amount, :extensions
30
+ attr_accessor :binary_type
31
+
32
+ def initialize(window, url, protocols = nil)
33
+ @window = window
34
+ @url = url.to_s
35
+ @ready_state = CONNECTING
36
+ @buffered_amount = 0
37
+ @extensions = ""
38
+ @binary_type = "blob"
39
+ @protocol = Array(protocols).first.to_s
40
+ @sent_messages = []
41
+ @inline_handlers = {}
42
+
43
+ # Auto-open via microtask unless tests disable.
44
+ auto_open = window.globals["__ws_auto_open__"]
45
+ @window.scheduler.queue_microtask(proc { __test_simulate_open__ }) unless auto_open == false
46
+ end
47
+
48
+ def send(data)
49
+ raise Error, "WebSocket not OPEN" if @ready_state != OPEN
50
+
51
+ @sent_messages << data
52
+ nil
53
+ end
54
+
55
+ def close(code = 1000, reason = "")
56
+ return if @ready_state == CLOSED || @ready_state == CLOSING
57
+
58
+ @ready_state = CLOSING
59
+ @window.scheduler.queue_microtask(proc { __test_simulate_close__(code, reason) })
60
+ nil
61
+ end
62
+
63
+ # --- Test seams ------------------------------------------------
64
+
65
+ def __test_sent_messages__
66
+ @sent_messages.dup
67
+ end
68
+
69
+ def __test_simulate_open__
70
+ return if @ready_state != CONNECTING
71
+
72
+ @ready_state = OPEN
73
+ dispatch_event(Event.new("open"))
74
+ end
75
+
76
+ def __test_simulate_message__(data)
77
+ return if @ready_state != OPEN
78
+
79
+ dispatch_event(MessageEvent.new("message", "data" => data))
80
+ end
81
+
82
+ def __test_simulate_close__(code = 1000, reason = "", was_clean: true)
83
+ @ready_state = CLOSED
84
+ dispatch_event(
85
+ CloseEvent.new(
86
+ "close",
87
+ "code" => code,
88
+ "reason" => reason,
89
+ "wasClean" => was_clean
90
+ )
91
+ )
92
+ end
93
+
94
+ def __test_simulate_error__
95
+ dispatch_event(Event.new("error"))
96
+ end
97
+
98
+ # --- JS bridge -------------------------------------------------
99
+
100
+ def __js_get__(key)
101
+ case key
102
+ when "url"
103
+ @url
104
+ when "readyState"
105
+ @ready_state
106
+ when "bufferedAmount"
107
+ @buffered_amount
108
+ when "extensions"
109
+ @extensions
110
+ when "protocol"
111
+ @protocol
112
+ when "binaryType"
113
+ @binary_type
114
+ when "CONNECTING"
115
+ CONNECTING
116
+ when "OPEN"
117
+ OPEN
118
+ when "CLOSING"
119
+ CLOSING
120
+ when "CLOSED"
121
+ CLOSED
122
+ else
123
+ @inline_handlers[inline_event_for(key)]
124
+ end
125
+ end
126
+
127
+ def __js_set__(key, value)
128
+ case key
129
+ when "binaryType"
130
+ @binary_type = value.to_s
131
+ else
132
+ event = inline_event_for(key)
133
+ set_inline_handler(event, value) if event
134
+ end
135
+
136
+ nil
137
+ end
138
+
139
+ def __js_call__(method, args)
140
+ case method
141
+ when "send"
142
+ send(args[0])
143
+ when "close"
144
+ close(args[0] || 1000, args[1] || "")
145
+ when "addEventListener"
146
+ add_event_listener(args[0], args[1], args[2])
147
+ when "removeEventListener"
148
+ remove_event_listener(args[0], args[1])
149
+ when "dispatchEvent"
150
+ dispatch_event(args[0])
151
+ end
152
+ end
153
+
154
+ def __internal_event_parent__
155
+ nil
156
+ end
157
+
158
+ class Error < StandardError
159
+ end
160
+
161
+ private
162
+
163
+ INLINE_EVENT_MAP = INLINE_HANDLERS
164
+ .each_with_object({}) do |name, h|
165
+ h["on#{name}"] = name
166
+ end
167
+ .freeze
168
+
169
+ def inline_event_for(key)
170
+ INLINE_EVENT_MAP[key.to_s]
171
+ end
172
+
173
+ def set_inline_handler(event, handler)
174
+ previous = @inline_handlers[event]
175
+ remove_event_listener(event, previous) if previous
176
+ if handler.nil?
177
+ @inline_handlers.delete(event)
178
+ else
179
+ add_event_listener(event, handler)
180
+ @inline_handlers[event] = handler
181
+ end
182
+ end
183
+ end
184
+
185
+ # `CloseEvent` — payload for the `close` event on WebSocket.
186
+ class CloseEvent < Event
187
+ def initialize(type, init = nil)
188
+ super
189
+ @code = (read_init(init, "code") || 1005).to_i
190
+ @reason = (read_init(init, "reason") || "").to_s
191
+ @was_clean = !!read_init(init, "wasClean")
192
+ end
193
+
194
+ attr_reader :code, :reason, :was_clean
195
+
196
+ def __js_get__(key)
197
+ case key
198
+ when "code"
199
+ @code
200
+ when "reason"
201
+ @reason
202
+ when "wasClean"
203
+ @was_clean
204
+ else
205
+ super
206
+ end
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,369 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "erb"
5
+
6
+ # Dommy — a happy-dom-style DOM polyfill in pure Ruby. Backbone is
7
+ # Nokogiri::HTML5 plus a small scheduler/event-loop layer.
8
+ #
9
+ # Two views into the same objects:
10
+ # - Public Ruby API (snake_case methods like `text_content`,
11
+ # `append_child`) for CRuby users writing tests against rendered
12
+ # HTML.
13
+ # - `__js_get__` / `__js_set__` / `__js_call__` / `__js_new__`
14
+ # bridge protocol for JS bridge embedders — dispatches into the
15
+ # same underlying Ruby methods.
16
+ module Dommy
17
+ # The browser global. `JS.global` from inside wasm resolves to this.
18
+ # Property access (`JS.global[:document]`, `JS.global[:console]`) is
19
+ # routed through `#__js_get__`. Method calls (`JS.global.call(:foo)`)
20
+ # are routed through `#__js_call__`.
21
+ class Window
22
+ include EventTarget
23
+
24
+ attr_reader :document, :scheduler, :location, :globals, :custom_elements, :navigator
25
+
26
+ def initialize(host = nil, nokogiri_doc: nil)
27
+ @host = host
28
+ @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
+ @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) }
97
+ @local_storage = Storage.new
98
+ @session_storage = Storage.new
99
+ @location = Location.new(self)
100
+ @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.
111
+ @globals = {}
112
+ @document = Document.new(host, nokogiri_doc: nokogiri_doc)
113
+ @document.default_view = self
114
+ @custom_elements = CustomElementRegistry.new(self)
115
+ @navigator = Navigator.new(self)
116
+ end
117
+
118
+ # Bridge protocol: respond to a JS-style property read by name.
119
+ # Returns either a Ruby primitive (Integer / String / true / false /
120
+ # nil), a Hash/Array (for JS object/array literals), or a Dom::*
121
+ # instance for live DOM/BOM objects.
122
+ #
123
+ # Anything outside the surface we've explicitly polyfilled returns
124
+ # nil (= JS undefined). Spec failures here are the signal to widen
125
+ # the surface in a future session.
126
+ def __js_get__(key)
127
+ case key
128
+ when "document"
129
+ @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
182
+ when "crypto"
183
+ @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
+ when "cookieStore"
229
+ @cookie_store
230
+ when "Range"
231
+ @range_ctor
232
+ # handled by Symbol sentinel
233
+ when "console"
234
+ :console
235
+ # likewise
236
+ when "Object"
237
+ :object_ctor
238
+ when "Array"
239
+ :array_ctor
240
+ when "JSON"
241
+ :json_ctor
242
+ when "performance"
243
+ @performance ||= Performance.new(self)
244
+ when "localStorage"
245
+ @local_storage
246
+ when "sessionStorage"
247
+ @session_storage
248
+ when "location"
249
+ @location
250
+ when "history"
251
+ @history
252
+ when "URL"
253
+ @url_ctor
254
+ when "fetch"
255
+ FetchFn.new(self)
256
+ when "customElements"
257
+ @custom_elements
258
+ when "navigator"
259
+ @navigator
260
+ else
261
+ @globals[key]
262
+ end
263
+ end
264
+
265
+ def __js_set__(key, value)
266
+ # Stash arbitrary keys for later reads (e.g.
267
+ # `JS.global[:__fetchy_stub__] = map`).
268
+ @globals[key] = value
269
+ # The Fetchy spec's `install_fetch_stub` resets `__fetch_count__`
270
+ # to 0 inside its JS installer (`globalThis.__fetch_count__ = 0;
271
+ # globalThis.fetch = ...`). Our polyfill ignores raw JS, so we
272
+ # piggy-back on the stub assignment to perform the same reset
273
+ # — without it the count accumulates across tests in one VM run.
274
+ @globals["__fetch_count__"] = 0 if %w[__fetchy_stub__ __resource_fetch_stub__ __inject_fetch_stub__].include?(key)
275
+ nil
276
+ end
277
+
278
+ # Methods routed through __js_call__ (keep in sync with its when-arms).
279
+ JS_METHOD_NAMES = %w[
280
+ fetch encodeURIComponent decodeURIComponent addEventListener removeEventListener
281
+ 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
+
289
+ def __js_call__(method, args)
290
+ case method
291
+ when "fetch"
292
+ FetchFn.new(self).__js_call__("call", args)
293
+ 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)
298
+ when "decodeURIComponent"
299
+ CGI.unescape(args[0].to_s)
300
+ when "addEventListener"
301
+ add_event_listener(args[0], args[1], args[2])
302
+ when "removeEventListener"
303
+ remove_event_listener(args[0], args[1])
304
+ when "dispatchEvent"
305
+ dispatch_event(args[0])
306
+ when "setTimeout"
307
+ @scheduler.set_timeout(args[0], args[1] || 0)
308
+ when "clearTimeout"
309
+ @scheduler.clear_timeout(args[0])
310
+ when "setInterval"
311
+ @scheduler.set_interval(args[0], args[1] || 0)
312
+ when "clearInterval"
313
+ @scheduler.clear_interval(args[0])
314
+ when "requestAnimationFrame"
315
+ @scheduler.request_animation_frame(args[0])
316
+ when "cancelAnimationFrame"
317
+ @scheduler.cancel_animation_frame(args[0])
318
+ when "queueMicrotask"
319
+ @scheduler.queue_microtask(args[0])
320
+ 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
+ )
333
+ when "cancelIdleCallback"
334
+ @scheduler.clear_timeout(args[0])
335
+ when "structuredClone"
336
+ Dommy.structured_clone(args[0])
337
+ when "matchMedia"
338
+ MediaQueryList.new(self, args[0].to_s)
339
+ when "getComputedStyle"
340
+ # No CSS engine — return the element's inline style. That
341
+ # covers `getComputedStyle(el).getPropertyValue("color")` for
342
+ # values the test set inline via `el.style.color = "..."`.
343
+ target = args[0]
344
+ target.respond_to?(:style) ? target.style : nil
345
+ else
346
+ # Additional window-level methods (fetch, location, history,
347
+ # Promise, MutationObserver, etc.) arrive in later sessions.
348
+ nil
349
+ end
350
+ end
351
+
352
+ def __internal_event_parent__
353
+ nil
354
+ end
355
+
356
+ # Called by History#go and Location.href= to fire popstate /
357
+ # hashchange events. Listeners registered on the Window via
358
+ # `addEventListener("popstate"|"hashchange", cb)` receive them.
359
+ def fire_popstate(state)
360
+ event = CustomEvent.new("popstate", "detail" => state)
361
+ dispatch_event(event)
362
+ end
363
+
364
+ def fire_hashchange(old_hash, new_hash)
365
+ event = CustomEvent.new("hashchange", "detail" => {"oldURL" => old_hash, "newURL" => new_hash})
366
+ dispatch_event(event)
367
+ end
368
+ end
369
+ end