dommy 0.6.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -38
  3. data/lib/dommy/animation.rb +1 -1
  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 +4 -4
  10. data/lib/dommy/cookie_store.rb +1 -1
  11. data/lib/dommy/crypto.rb +9 -8
  12. data/lib/dommy/css.rb +7 -7
  13. data/lib/dommy/custom_elements.rb +6 -6
  14. data/lib/dommy/document.rb +98 -32
  15. data/lib/dommy/dom_parser.rb +5 -4
  16. data/lib/dommy/element.rb +231 -50
  17. data/lib/dommy/event.rb +61 -25
  18. data/lib/dommy/event_source.rb +8 -8
  19. data/lib/dommy/fetch.rb +14 -6
  20. data/lib/dommy/file_reader.rb +3 -3
  21. data/lib/dommy/form_data.rb +1 -3
  22. data/lib/dommy/history.rb +7 -4
  23. data/lib/dommy/html_collection.rb +4 -4
  24. data/lib/dommy/html_elements.rb +110 -42
  25. data/lib/dommy/internal/css_pseudo_handlers.rb +28 -0
  26. data/lib/dommy/internal/dom_matching.rb +3 -3
  27. data/lib/dommy/internal/node_traversal.rb +1 -1
  28. data/lib/dommy/internal/node_wrapper_cache.rb +23 -12
  29. data/lib/dommy/internal/template_content_registry.rb +6 -6
  30. data/lib/dommy/intersection_observer.rb +2 -2
  31. data/lib/dommy/location.rb +8 -4
  32. data/lib/dommy/media_query_list.rb +3 -3
  33. data/lib/dommy/message_channel.rb +9 -9
  34. data/lib/dommy/mutation_observer.rb +21 -11
  35. data/lib/dommy/navigator.rb +12 -12
  36. data/lib/dommy/node.rb +12 -0
  37. data/lib/dommy/notification.rb +3 -3
  38. data/lib/dommy/parser.rb +13 -13
  39. data/lib/dommy/performance_observer.rb +2 -2
  40. data/lib/dommy/range.rb +2 -2
  41. data/lib/dommy/resize_observer.rb +2 -2
  42. data/lib/dommy/shadow_root.rb +10 -8
  43. data/lib/dommy/streams.rb +22 -22
  44. data/lib/dommy/text_codec.rb +4 -4
  45. data/lib/dommy/tree_walker.rb +21 -21
  46. data/lib/dommy/url.rb +25 -8
  47. data/lib/dommy/version.rb +1 -1
  48. data/lib/dommy/web_socket.rb +13 -13
  49. data/lib/dommy/window.rb +14 -1
  50. data/lib/dommy/worker.rb +5 -5
  51. data/lib/dommy/xml_http_request.rb +19 -4
  52. data/lib/dommy.rb +12 -2
  53. metadata +12 -26
data/lib/dommy/event.rb CHANGED
@@ -54,24 +54,30 @@ module Dommy
54
54
  # Per spec, dispatchEvent must receive an Event instance.
55
55
  raise TypeError, "dispatchEvent requires an Event, got #{event.class}" unless event.is_a?(Event)
56
56
 
57
- event.__prepare_for_dispatch__(self)
57
+ event.__internal_prepare_for_dispatch__(self)
58
58
  path = if event.bubbles?
59
59
  event.__js_get__("composed") ? composed_bubble_path(event) : event_bubble_path
60
60
  else
61
61
  [self]
62
62
  end
63
63
 
64
- event.__record_path__(path) if event.respond_to?(:__record_path__)
64
+ event.__internal_record_path__(path) if event.respond_to?(:__internal_record_path__)
65
65
  path.each do |target|
66
- event.__set_current_target__(target)
67
- target.__deliver_event__(event)
66
+ event.__internal_set_current_target__(target)
67
+ target.__internal_deliver_event__(event)
68
68
  break if event.propagation_stopped?
69
69
  end
70
70
 
71
+ # WHATWG: after dispatch completes, currentTarget reverts to
72
+ # null and eventPhase reverts to NONE. Dommy derives
73
+ # eventPhase from currentTarget, so clearing it here covers
74
+ # both spec requirements.
75
+ event.__internal_set_current_target__(nil)
76
+
71
77
  !event.default_prevented?
72
78
  end
73
79
 
74
- def __deliver_event__(event)
80
+ def __internal_deliver_event__(event)
75
81
  listeners = listeners_for(event.type).dup
76
82
  listeners.each do |entry|
77
83
  invoke_listener(entry.listener, event)
@@ -106,7 +112,7 @@ module Dommy
106
112
  def event_bubble_path
107
113
  path = [self]
108
114
  current = self
109
- while (current = current.send(:__event_parent__))
115
+ while (current = current.send(:__internal_event_parent__))
110
116
  path << current
111
117
  end
112
118
 
@@ -116,12 +122,12 @@ module Dommy
116
122
  # Build the propagation path with optional shadow-boundary
117
123
  # crossing. When the in-flight event has `composed: true`, the
118
124
  # walk continues from a ShadowRoot to its host; otherwise it
119
- # stops at the shadow boundary (nil from `__event_parent__`).
125
+ # stops at the shadow boundary (nil from `__internal_event_parent__`).
120
126
  def composed_bubble_path(event)
121
127
  path = [self]
122
128
  current = self
123
129
  loop do
124
- nxt = current.send(:__event_parent__)
130
+ nxt = current.send(:__internal_event_parent__)
125
131
  if nxt.nil? && event.respond_to?(:__js_get__) && event.__js_get__("composed")
126
132
  # Try to cross a shadow boundary
127
133
  if current.is_a?(ShadowRoot)
@@ -148,12 +154,12 @@ module Dommy
148
154
  private
149
155
 
150
156
  def enclosing_shadow_root_of(target)
151
- return nil unless target.respond_to?(:__node__)
157
+ return nil unless target.respond_to?(:__dommy_backend_node__)
152
158
 
153
159
  doc = target.instance_variable_get(:@document)
154
- return nil unless doc && doc.respond_to?(:__shadow_root_containing__)
160
+ return nil unless doc && doc.respond_to?(:__internal_shadow_root_containing__)
155
161
 
156
- doc.__shadow_root_containing__(target.__node__)
162
+ doc.__internal_shadow_root_containing__(target.__dommy_backend_node__)
157
163
  end
158
164
 
159
165
  public
@@ -178,6 +184,12 @@ module Dommy
178
184
  class StandaloneEventTarget
179
185
  include EventTarget
180
186
 
187
+ # Methods routed through __js_call__ (keep in sync with its when-arms).
188
+ JS_METHOD_NAMES = %w[addEventListener removeEventListener dispatchEvent].freeze
189
+ def __js_method_names__
190
+ JS_METHOD_NAMES
191
+ end
192
+
181
193
  def __js_call__(method, args)
182
194
  case method
183
195
  when "addEventListener"
@@ -191,7 +203,7 @@ module Dommy
191
203
  end
192
204
  end
193
205
 
194
- def __event_parent__
206
+ def __internal_event_parent__
195
207
  nil
196
208
  end
197
209
  end
@@ -232,11 +244,11 @@ module Dommy
232
244
  @immediate_propagation_stopped
233
245
  end
234
246
 
235
- def __prepare_for_dispatch__(target)
247
+ def __internal_prepare_for_dispatch__(target)
236
248
  @target ||= target
237
249
  end
238
250
 
239
- def __set_current_target__(target)
251
+ def __internal_set_current_target__(target)
240
252
  @current_target = target
241
253
  end
242
254
 
@@ -276,6 +288,12 @@ module Dommy
276
288
  nil
277
289
  end
278
290
 
291
+ # Methods routed through __js_call__ (keep in sync with its when-arms).
292
+ JS_METHOD_NAMES = %w[preventDefault stopPropagation stopImmediatePropagation composedPath initEvent].freeze
293
+ def __js_method_names__
294
+ JS_METHOD_NAMES
295
+ end
296
+
279
297
  def __js_call__(method, args)
280
298
  case method
281
299
  when "preventDefault"
@@ -314,7 +332,7 @@ module Dommy
314
332
  # Per spec, `load` events do not propagate to the Window when
315
333
  # composed paths are computed (resource-finished signal stays at
316
334
  # the target).
317
- def __record_path__(targets)
335
+ def __internal_record_path__(targets)
318
336
  @composed_path = if @type == "load"
319
337
  targets.reject { |t| t.is_a?(Window) }
320
338
  else
@@ -640,6 +658,12 @@ module Dommy
640
658
  end
641
659
  end
642
660
 
661
+ # Methods routed through __js_call__ (keep in sync with its when-arms).
662
+ JS_METHOD_NAMES = %w[item].freeze
663
+ def __js_method_names__
664
+ JS_METHOD_NAMES
665
+ end
666
+
643
667
  def __js_call__(method, args)
644
668
  case method
645
669
  when "item"
@@ -823,7 +847,7 @@ module Dommy
823
847
  # signal. Convenient for APIs that need an already-cancelled token.
824
848
  def self.abort(reason = nil)
825
849
  signal = new
826
- signal.__mark_aborted__(reason)
850
+ signal.__internal_mark_aborted__(reason)
827
851
  signal
828
852
  end
829
853
 
@@ -836,9 +860,9 @@ module Dommy
836
860
  signal = new
837
861
  reason = DOMException::TimeoutError.new("operation timed out")
838
862
  if scheduler
839
- scheduler.set_timeout(proc { signal.__mark_aborted__(reason) }, ms.to_i)
863
+ scheduler.set_timeout(proc { signal.__internal_mark_aborted__(reason) }, ms.to_i)
840
864
  else
841
- signal.__schedule_thread_timeout__(ms.to_i, reason)
865
+ signal.__internal_schedule_thread_timeout__(ms.to_i, reason)
842
866
  end
843
867
 
844
868
  signal
@@ -853,12 +877,12 @@ module Dommy
853
877
  list = Array(signals).select { |s| s.is_a?(AbortSignal) }
854
878
  already = list.find(&:aborted?)
855
879
  if already
856
- composite.__mark_aborted__(already.reason)
880
+ composite.__internal_mark_aborted__(already.reason)
857
881
  return composite
858
882
  end
859
883
 
860
884
  list.each do |sig|
861
- sig.add_event_listener("abort", proc { composite.__mark_aborted__(sig.reason) })
885
+ sig.add_event_listener("abort", proc { composite.__internal_mark_aborted__(sig.reason) })
862
886
  end
863
887
 
864
888
  composite
@@ -871,11 +895,11 @@ module Dommy
871
895
 
872
896
  # Background-thread timeout used by `AbortSignal.timeout` when no
873
897
  # scheduler is provided. Kept package-private; tests can also
874
- # drive the abort manually via `__mark_aborted__`.
875
- def __schedule_thread_timeout__(ms, reason)
898
+ # drive the abort manually via `__internal_mark_aborted__`.
899
+ def __internal_schedule_thread_timeout__(ms, reason)
876
900
  Thread.new do
877
901
  sleep(ms.to_f / 1000.0)
878
- __mark_aborted__(reason)
902
+ __internal_mark_aborted__(reason)
879
903
  end
880
904
 
881
905
  nil
@@ -912,6 +936,12 @@ module Dommy
912
936
  nil
913
937
  end
914
938
 
939
+ # Methods routed through __js_call__ (keep in sync with its when-arms).
940
+ JS_METHOD_NAMES = %w[addEventListener removeEventListener dispatchEvent throwIfAborted].freeze
941
+ def __js_method_names__
942
+ JS_METHOD_NAMES
943
+ end
944
+
915
945
  def __js_call__(method, args)
916
946
  case method
917
947
  when "addEventListener"
@@ -925,7 +955,7 @@ module Dommy
925
955
  end
926
956
  end
927
957
 
928
- def __mark_aborted__(reason = nil)
958
+ def __internal_mark_aborted__(reason = nil)
929
959
  return if @aborted
930
960
 
931
961
  @aborted = true
@@ -949,10 +979,16 @@ module Dommy
949
979
  nil
950
980
  end
951
981
 
982
+ # Methods routed through __js_call__ (keep in sync with its when-arms).
983
+ JS_METHOD_NAMES = %w[abort].freeze
984
+ def __js_method_names__
985
+ JS_METHOD_NAMES
986
+ end
987
+
952
988
  def __js_call__(method, args)
953
989
  case method
954
990
  when "abort"
955
- @signal.__mark_aborted__(args[0])
991
+ @signal.__internal_mark_aborted__(args[0])
956
992
  end
957
993
  end
958
994
  end
@@ -4,9 +4,9 @@ module Dommy
4
4
  # `EventSource` (Server-Sent Events). Like `WebSocket`, dommy
5
5
  # provides simulation seams instead of network IO:
6
6
  #
7
- # es.__simulate_open__
8
- # es.__simulate_message__(data, event: "msg", id: "1")
9
- # es.__simulate_error__
7
+ # es.__test_simulate_open__
8
+ # es.__test_simulate_message__(data, event: "msg", id: "1")
9
+ # es.__test_simulate_error__
10
10
  #
11
11
  # Auto-opens on a microtask after construction, mirroring real
12
12
  # browser behavior.
@@ -31,7 +31,7 @@ module Dommy
31
31
  @with_credentials = !!(opts["withCredentials"] || opts[:withCredentials])
32
32
  @inline_handlers = {}
33
33
 
34
- @window.scheduler.queue_microtask(proc { __simulate_open__ })
34
+ @window.scheduler.queue_microtask(proc { __test_simulate_open__ })
35
35
  end
36
36
 
37
37
  def close
@@ -41,14 +41,14 @@ module Dommy
41
41
 
42
42
  # --- Test seams ------------------------------------------------
43
43
 
44
- def __simulate_open__
44
+ def __test_simulate_open__
45
45
  return if @ready_state != CONNECTING
46
46
 
47
47
  @ready_state = OPEN
48
48
  dispatch_event(Event.new("open"))
49
49
  end
50
50
 
51
- def __simulate_message__(data, event: "message", id: nil, retry_ms: nil)
51
+ def __test_simulate_message__(data, event: "message", id: nil, retry_ms: nil)
52
52
  return if @ready_state != OPEN
53
53
 
54
54
  payload = {"data" => data.to_s}
@@ -57,7 +57,7 @@ module Dommy
57
57
  dispatch_event(MessageEvent.new(event.to_s, payload))
58
58
  end
59
59
 
60
- def __simulate_error__
60
+ def __test_simulate_error__
61
61
  dispatch_event(Event.new("error"))
62
62
  end
63
63
 
@@ -101,7 +101,7 @@ module Dommy
101
101
  end
102
102
  end
103
103
 
104
- def __event_parent__
104
+ def __internal_event_parent__
105
105
  nil
106
106
  end
107
107
 
data/lib/dommy/fetch.rb CHANGED
@@ -227,8 +227,10 @@ module Dommy
227
227
  rejected(err)
228
228
  end
229
229
 
230
- when "arrayBuffer", "blob"
231
- immediate(@body)
230
+ when "arrayBuffer"
231
+ immediate(@body.bytes)
232
+ when "blob"
233
+ immediate(Blob.new([@body], "type" => @headers.__js_call__("get", ["content-type"]) || ""))
232
234
  when "clone"
233
235
  Response.new(
234
236
  @window,
@@ -280,15 +282,21 @@ module Dommy
280
282
  when "entries"
281
283
  @hash.to_a
282
284
  when "has"
283
- @hash.key?(args[0].to_s)
285
+ # Match `get`'s case-insensitive lookup: try the raw name
286
+ # first, then the Title-Case canonical form. WHATWG defines
287
+ # header names as case-insensitive throughout the Headers API.
288
+ name = args[0].to_s
289
+ @hash.key?(name) || @hash.key?(Headers.canonical(name))
284
290
  when "forEach"
285
- # Browser API: forEach(callback) — callback(value, key)
291
+ # WHATWG: forEach(callback) — callback(value, key, headers).
292
+ # Pass `self` as the third argument so consumers that read
293
+ # `(_, _, h) => h.get("Foo")` work the same as in a browser.
286
294
  cb = args[0]
287
295
  @hash.each do |k, v|
288
296
  if cb.respond_to?(:__js_call__)
289
- cb.__js_call__("call", [v, k])
297
+ cb.__js_call__("call", [v, k, self])
290
298
  elsif cb.respond_to?(:call)
291
- cb.call(v, k)
299
+ cb.call(v, k, self)
292
300
  end
293
301
  end
294
302
 
@@ -114,7 +114,7 @@ module Dommy
114
114
  end
115
115
  end
116
116
 
117
- def __event_parent__
117
+ def __internal_event_parent__
118
118
  nil
119
119
  end
120
120
 
@@ -145,8 +145,8 @@ module Dommy
145
145
 
146
146
  # Returns the blob's raw bytes as a binary String.
147
147
  def extract_raw(blob)
148
- if blob.respond_to?(:__bytes__)
149
- blob.__bytes__.to_s
148
+ if blob.respond_to?(:__dommy_bytes__)
149
+ blob.__dommy_bytes__.to_s
150
150
  else
151
151
  blob.to_s
152
152
  end
@@ -146,7 +146,7 @@ module Dommy
146
146
  next if name.empty?
147
147
  next if disabled?(el)
148
148
 
149
- case el.__node__.name
149
+ case el.__dommy_backend_node__.name
150
150
  when "input"
151
151
  collect_input(el, name)
152
152
  when "select"
@@ -198,8 +198,6 @@ module Dommy
198
198
  # File / Blob values pass through unchanged (multipart form
199
199
  # encoding handles them); other values are stringified per spec.
200
200
  return value if value.is_a?(Blob)
201
- # Backward-compat: embedders' own file-marker objects.
202
- return value if value.respond_to?(:__file_marker__)
203
201
  return "" if value.nil?
204
202
 
205
203
  value.to_s
data/lib/dommy/history.rb CHANGED
@@ -58,14 +58,17 @@ module Dommy
58
58
 
59
59
  def push(state, url)
60
60
  @stack = @stack[0..@cursor]
61
- @location.__set_url__(url.to_s) if url
62
- @stack << {state: state, url: nil}
61
+ @location.__internal_set_url__(url.to_s) if url
62
+ # WHATWG: pushState serializes the state via structured-clone
63
+ # so subsequent caller-side mutation of the original cannot
64
+ # affect history.state.
65
+ @stack << {state: Dommy.structured_clone(state), url: nil}
63
66
  @cursor = @stack.size - 1
64
67
  end
65
68
 
66
69
  def replace(state, url)
67
- @location.__set_url__(url.to_s) if url
68
- @stack[@cursor] = {state: state, url: nil}
70
+ @location.__internal_set_url__(url.to_s) if url
71
+ @stack[@cursor] = {state: Dommy.structured_clone(state), url: nil}
69
72
  end
70
73
 
71
74
  def go(delta)
@@ -46,9 +46,9 @@ module Dommy
46
46
  return nil if key.empty?
47
47
 
48
48
  to_a.find do |el|
49
- next false unless el.respond_to?(:__node__)
49
+ next false unless el.respond_to?(:__dommy_backend_node__)
50
50
 
51
- el.__node__["id"].to_s == key || el.__node__["name"].to_s == key
51
+ el.__dommy_backend_node__["id"].to_s == key || el.__dommy_backend_node__["name"].to_s == key
52
52
  end
53
53
  end
54
54
 
@@ -125,7 +125,7 @@ module Dommy
125
125
  # accepts either another option (insert before that node) or an
126
126
  # integer index. Strings/`null` append.
127
127
  def add(option, before = nil)
128
- return nil unless option.respond_to?(:__node__)
128
+ return nil unless option.respond_to?(:__dommy_backend_node__)
129
129
 
130
130
  case before
131
131
  when nil
@@ -134,7 +134,7 @@ module Dommy
134
134
  anchor = item(before)
135
135
  anchor ? @owner.insert_before(option, anchor) : @owner.append_child(option)
136
136
  else
137
- if before.respond_to?(:__node__)
137
+ if before.respond_to?(:__dommy_backend_node__)
138
138
  @owner.insert_before(option, before)
139
139
  else
140
140
  @owner.append_child(option)