dommy 0.5.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 (48) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +213 -0
  3. data/lib/dommy/attr.rb +200 -0
  4. data/lib/dommy/blob.rb +182 -0
  5. data/lib/dommy/bridge.rb +141 -0
  6. data/lib/dommy/css.rb +283 -0
  7. data/lib/dommy/custom_elements.rb +125 -0
  8. data/lib/dommy/data_transfer.rb +98 -0
  9. data/lib/dommy/document.rb +674 -0
  10. data/lib/dommy/dom_exception.rb +258 -0
  11. data/lib/dommy/dom_parser.rb +88 -0
  12. data/lib/dommy/element.rb +1975 -0
  13. data/lib/dommy/event.rb +589 -0
  14. data/lib/dommy/fetch.rb +241 -0
  15. data/lib/dommy/form_data.rb +208 -0
  16. data/lib/dommy/html_collection.rb +207 -0
  17. data/lib/dommy/html_elements.rb +4455 -0
  18. data/lib/dommy/internal/cookie_jar.rb +27 -0
  19. data/lib/dommy/internal/dom_matching.rb +141 -0
  20. data/lib/dommy/internal/mutation_coordinator.rb +172 -0
  21. data/lib/dommy/internal/node_traversal.rb +36 -0
  22. data/lib/dommy/internal/node_wrapper_cache.rb +179 -0
  23. data/lib/dommy/internal/observer_manager.rb +31 -0
  24. data/lib/dommy/internal/observer_matcher.rb +31 -0
  25. data/lib/dommy/internal/scope_resolution.rb +27 -0
  26. data/lib/dommy/internal/shadow_root_registry.rb +35 -0
  27. data/lib/dommy/internal/template_content_registry.rb +97 -0
  28. data/lib/dommy/minitest/assertions.rb +105 -0
  29. data/lib/dommy/minitest.rb +17 -0
  30. data/lib/dommy/navigator.rb +271 -0
  31. data/lib/dommy/node.rb +218 -0
  32. data/lib/dommy/observer.rb +199 -0
  33. data/lib/dommy/parser.rb +29 -0
  34. data/lib/dommy/promise.rb +199 -0
  35. data/lib/dommy/router.rb +275 -0
  36. data/lib/dommy/rspec/capy_style_matchers.rb +356 -0
  37. data/lib/dommy/rspec/matchers.rb +230 -0
  38. data/lib/dommy/rspec.rb +18 -0
  39. data/lib/dommy/scheduler.rb +135 -0
  40. data/lib/dommy/shadow_root.rb +255 -0
  41. data/lib/dommy/storage.rb +112 -0
  42. data/lib/dommy/test_helpers.rb +78 -0
  43. data/lib/dommy/tree_walker.rb +425 -0
  44. data/lib/dommy/url.rb +479 -0
  45. data/lib/dommy/version.rb +5 -0
  46. data/lib/dommy/world.rb +209 -0
  47. data/lib/dommy.rb +119 -0
  48. metadata +110 -0
@@ -0,0 +1,589 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ # Note: `Callback` and `Constructor` live in `Dommy::Bridge::*` —
5
+ # they're bridge-adapter classes, not part of the public DOM
6
+ # surface.
7
+
8
+ module EventTarget
9
+ def add_event_listener(type, listener = nil, options = nil, &block)
10
+ cb = listener || block
11
+ return nil if type.nil? || cb.nil?
12
+
13
+ list = listeners_for(type.to_s)
14
+ # Per spec, the same listener (by identity) registered on the
15
+ # same type is silently deduplicated.
16
+ return nil if list.any? { |entry| entry.listener.equal?(cb) }
17
+
18
+ list << Listener.new(cb, options)
19
+
20
+ # `{ signal: AbortSignal }` — when the signal aborts, auto-
21
+ # remove the listener. Per spec, if the signal is already aborted
22
+ # the listener must not be registered at all.
23
+ signal = options.is_a?(Hash) ? (options["signal"] || options[:signal]) : nil
24
+ if signal.respond_to?(:__js_get__)
25
+ if signal.__js_get__("aborted")
26
+ remove_event_listener(type, cb)
27
+ else
28
+ target = self
29
+ signal.__js_call__(
30
+ "addEventListener",
31
+ [
32
+ "abort",
33
+ proc {
34
+ target.remove_event_listener(type, cb)
35
+ }
36
+ ]
37
+ )
38
+ end
39
+ end
40
+
41
+ nil
42
+ end
43
+
44
+ def remove_event_listener(type, listener)
45
+ return nil if type.nil? || listener.nil?
46
+
47
+ listeners_for(type.to_s).reject! { |entry| entry.listener.equal?(listener) }
48
+ nil
49
+ end
50
+
51
+ def dispatch_event(event)
52
+ return true if event.nil?
53
+
54
+ # Per spec, dispatchEvent must receive an Event instance.
55
+ raise TypeError, "dispatchEvent requires an Event, got #{event.class}" unless event.is_a?(Event)
56
+
57
+ event.__prepare_for_dispatch__(self)
58
+ path = if event.bubbles?
59
+ event.__js_get__("composed") ? composed_bubble_path(event) : event_bubble_path
60
+ else
61
+ [self]
62
+ end
63
+
64
+ event.__record_path__(path) if event.respond_to?(:__record_path__)
65
+ path.each do |target|
66
+ event.__set_current_target__(target)
67
+ target.__deliver_event__(event)
68
+ break if event.propagation_stopped?
69
+ end
70
+
71
+ !event.default_prevented?
72
+ end
73
+
74
+ def __deliver_event__(event)
75
+ listeners = listeners_for(event.type).dup
76
+ listeners.each do |entry|
77
+ invoke_listener(entry.listener, event)
78
+ if entry.once?
79
+ listeners_for(event.type).reject! { |candidate| candidate.listener.equal?(entry.listener) }
80
+ end
81
+
82
+ break if event.immediate_propagation_stopped?
83
+ end
84
+
85
+ nil
86
+ end
87
+
88
+ private
89
+
90
+ Listener = Struct.new(:listener, :options) do
91
+ def once?
92
+ case options
93
+ when Hash
94
+ options["once"] || options[:once]
95
+ else
96
+ false
97
+ end
98
+ end
99
+ end
100
+
101
+ def listeners_for(type)
102
+ @event_listeners ||= Hash.new { |h, k| h[k] = [] }
103
+ @event_listeners[type]
104
+ end
105
+
106
+ def event_bubble_path
107
+ path = [self]
108
+ current = self
109
+ while (current = current.send(:__event_parent__))
110
+ path << current
111
+ end
112
+
113
+ path
114
+ end
115
+
116
+ # Build the propagation path with optional shadow-boundary
117
+ # crossing. When the in-flight event has `composed: true`, the
118
+ # walk continues from a ShadowRoot to its host; otherwise it
119
+ # stops at the shadow boundary (nil from `__event_parent__`).
120
+ def composed_bubble_path(event)
121
+ path = [self]
122
+ current = self
123
+ loop do
124
+ nxt = current.send(:__event_parent__)
125
+ if nxt.nil? && event.respond_to?(:__js_get__) && event.__js_get__("composed")
126
+ # Try to cross a shadow boundary
127
+ if current.is_a?(ShadowRoot)
128
+ # If current is a ShadowRoot, jump to its host
129
+ nxt = current.host
130
+ else
131
+ # If current is a node inside a ShadowRoot, find and jump to host
132
+ sr = enclosing_shadow_root_of(current)
133
+ break unless sr
134
+
135
+ nxt = sr.host
136
+ end
137
+ end
138
+
139
+ break unless nxt
140
+
141
+ path << nxt
142
+ current = nxt
143
+ end
144
+
145
+ path
146
+ end
147
+
148
+ private
149
+
150
+ def enclosing_shadow_root_of(target)
151
+ return nil unless target.respond_to?(:__node__)
152
+
153
+ doc = target.instance_variable_get(:@document)
154
+ return nil unless doc && doc.respond_to?(:__shadow_root_containing__)
155
+
156
+ doc.__shadow_root_containing__(target.__node__)
157
+ end
158
+
159
+ public
160
+
161
+ def invoke_listener(listener, event)
162
+ # DOM spec: a listener can be (a) a function, or (b) an object
163
+ # with a `handleEvent` method. Both Ruby and JS-bridged callables
164
+ # are supported.
165
+ if listener.respond_to?(:handle_event)
166
+ listener.handle_event(event)
167
+ elsif listener.respond_to?(:call) && !listener.is_a?(Module)
168
+ listener.call(event)
169
+ elsif listener.respond_to?(:__js_call__)
170
+ # Prefer handleEvent if the bridge object advertises it; fall
171
+ # back to call. We can't introspect on the JS side, so we just
172
+ # try call (the common case for JS.callback {}).
173
+ listener.__js_call__("call", [event])
174
+ end
175
+ end
176
+ end
177
+
178
+ class StandaloneEventTarget
179
+ include EventTarget
180
+
181
+ def __js_call__(method, args)
182
+ case method
183
+ when "addEventListener"
184
+ add_event_listener(args[0], args[1], args[2])
185
+ when "removeEventListener"
186
+ remove_event_listener(args[0], args[1])
187
+ when "dispatchEvent"
188
+ dispatch_event(args[0])
189
+ else
190
+ nil
191
+ end
192
+ end
193
+
194
+ def __event_parent__
195
+ nil
196
+ end
197
+ end
198
+
199
+ class Event
200
+ def initialize(type, init = nil)
201
+ @type = type.to_s
202
+ @bubbles = !!read_init(init, "bubbles")
203
+ @cancelable = !!read_init(init, "cancelable")
204
+ @composed = !!read_init(init, "composed")
205
+ @default_prevented = false
206
+ @propagation_stopped = false
207
+ @immediate_propagation_stopped = false
208
+ @target = nil
209
+ @current_target = nil
210
+ @composed_path = []
211
+ # `timeStamp` is the high-resolution timestamp at construction
212
+ # in ms (browser uses performance.now). We use monotonic time
213
+ # for determinism across spec runs.
214
+ @time_stamp = (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0)
215
+ end
216
+
217
+ attr_reader :type
218
+
219
+ def bubbles?
220
+ @bubbles
221
+ end
222
+
223
+ def default_prevented?
224
+ @default_prevented
225
+ end
226
+
227
+ def propagation_stopped?
228
+ @propagation_stopped
229
+ end
230
+
231
+ def immediate_propagation_stopped?
232
+ @immediate_propagation_stopped
233
+ end
234
+
235
+ def __prepare_for_dispatch__(target)
236
+ @target ||= target
237
+ end
238
+
239
+ def __set_current_target__(target)
240
+ @current_target = target
241
+ end
242
+
243
+ def __js_get__(key)
244
+ case key
245
+ when "type"
246
+ @type
247
+ when "bubbles"
248
+ @bubbles
249
+ when "cancelable"
250
+ @cancelable
251
+ when "composed"
252
+ @composed
253
+ when "defaultPrevented"
254
+ @default_prevented
255
+ when "target"
256
+ @target
257
+ when "currentTarget"
258
+ @current_target
259
+ when "timeStamp"
260
+ @time_stamp
261
+ when "cancelBubble"
262
+ @propagation_stopped
263
+ when "eventPhase"
264
+ event_phase
265
+ end
266
+ end
267
+
268
+ def __js_set__(key, value)
269
+ case key
270
+ when "cancelBubble"
271
+ # Setting to truthy stops propagation; spec quirk that
272
+ # `cancelBubble = false` does NOT un-stop (browser observation).
273
+ @propagation_stopped = true if value
274
+ end
275
+
276
+ nil
277
+ end
278
+
279
+ def __js_call__(method, args)
280
+ case method
281
+ when "preventDefault"
282
+ @default_prevented = true if @cancelable
283
+ nil
284
+ when "stopPropagation"
285
+ @propagation_stopped = true
286
+ nil
287
+ when "stopImmediatePropagation"
288
+ @propagation_stopped = true
289
+ @immediate_propagation_stopped = true
290
+ nil
291
+ when "composedPath"
292
+ @composed_path.dup
293
+ when "initEvent"
294
+ init_event(args[0], args[1], args[2])
295
+ end
296
+ end
297
+
298
+ # Deprecated `Event#initEvent(type, bubbles, cancelable)` — older
299
+ # browsers used `document.createEvent("Event").initEvent(...)`.
300
+ # Resets internal flags as a side effect.
301
+ def init_event(type, bubbles = false, cancelable = false)
302
+ @type = type.to_s
303
+ @bubbles = !!bubbles
304
+ @cancelable = !!cancelable
305
+ @default_prevented = false
306
+ @propagation_stopped = false
307
+ @immediate_propagation_stopped = false
308
+ nil
309
+ end
310
+
311
+ # Filled in by EventTarget#dispatch_event as the event walks the
312
+ # bubble path so `composedPath()` returns the right list.
313
+ #
314
+ # Per spec, `load` events do not propagate to the Window when
315
+ # composed paths are computed (resource-finished signal stays at
316
+ # the target).
317
+ def __record_path__(targets)
318
+ @composed_path = if @type == "load"
319
+ targets.reject { |t| t.is_a?(Window) }
320
+ else
321
+ targets
322
+ end
323
+ end
324
+
325
+ private
326
+
327
+ def event_phase
328
+ # 0 = NONE (default), 2 = AT_TARGET, 3 = BUBBLING_PHASE. We don't
329
+ # implement capturing (phase 1) by design.
330
+ return 0 if @current_target.nil?
331
+ return 2 if @current_target.equal?(@target)
332
+
333
+ 3
334
+ end
335
+
336
+ public
337
+
338
+ private
339
+
340
+ def read_init(init, key)
341
+ case init
342
+ when Hash
343
+ init[key] || init[key.to_sym]
344
+ else
345
+ init.respond_to?(:__js_get__) ? init.__js_get__(key) : nil
346
+ end
347
+ end
348
+ end
349
+
350
+ class CustomEvent < Event
351
+ def initialize(type, init = nil)
352
+ super
353
+ @detail = read_init(init, "detail")
354
+ end
355
+
356
+ def __js_get__(key)
357
+ return @detail if key == "detail"
358
+
359
+ super
360
+ end
361
+ end
362
+
363
+ class MouseEvent < Event
364
+ def initialize(type, init = nil)
365
+ super
366
+ @button = read_init(init, "button") || 0
367
+ @ctrl_key = !!read_init(init, "ctrlKey")
368
+ @shift_key = !!read_init(init, "shiftKey")
369
+ @alt_key = !!read_init(init, "altKey")
370
+ @meta_key = !!read_init(init, "metaKey")
371
+ @client_x = read_init(init, "clientX") || 0
372
+ @client_y = read_init(init, "clientY") || 0
373
+ end
374
+
375
+ def __js_get__(key)
376
+ case key
377
+ when "button"
378
+ @button
379
+ when "ctrlKey"
380
+ @ctrl_key
381
+ when "shiftKey"
382
+ @shift_key
383
+ when "altKey"
384
+ @alt_key
385
+ when "metaKey"
386
+ @meta_key
387
+ when "clientX"
388
+ @client_x
389
+ when "clientY"
390
+ @client_y
391
+ else
392
+ super
393
+ end
394
+ end
395
+ end
396
+
397
+ # `DragEvent` — fired during drag-and-drop with a `dataTransfer`
398
+ # payload. Inherits from MouseEvent so coordinates / modifier keys
399
+ # are available alongside the dragged data.
400
+ class DragEvent < MouseEvent
401
+ def initialize(type, init = nil)
402
+ super
403
+ @data_transfer = read_init(init, "dataTransfer")
404
+ end
405
+
406
+ attr_reader :data_transfer
407
+
408
+ def __js_get__(key)
409
+ case key
410
+ when "dataTransfer"
411
+ @data_transfer
412
+ else
413
+ super
414
+ end
415
+ end
416
+ end
417
+
418
+ class KeyboardEvent < Event
419
+ def initialize(type, init = nil)
420
+ super
421
+ @key = read_init(init, "key").to_s
422
+ @ctrl_key = !!read_init(init, "ctrlKey")
423
+ @shift_key = !!read_init(init, "shiftKey")
424
+ @alt_key = !!read_init(init, "altKey")
425
+ @meta_key = !!read_init(init, "metaKey")
426
+ end
427
+
428
+ def __js_get__(key)
429
+ case key
430
+ when "key"
431
+ @key
432
+ when "ctrlKey"
433
+ @ctrl_key
434
+ when "shiftKey"
435
+ @shift_key
436
+ when "altKey"
437
+ @alt_key
438
+ when "metaKey"
439
+ @meta_key
440
+ else
441
+ super
442
+ end
443
+ end
444
+ end
445
+
446
+ # `AbortController` + `AbortSignal` subset. Signal fires an
447
+ # "abort" event and flips `[:aborted]` to true when the controller's
448
+ # `abort()` is called; otherwise it stays inert.
449
+ class AbortSignal
450
+ include EventTarget
451
+
452
+ # Spec: `AbortSignal.abort(reason?)` returns a fresh, pre-aborted
453
+ # signal. Convenient for APIs that need an already-cancelled token.
454
+ def self.abort(reason = nil)
455
+ signal = new
456
+ signal.__mark_aborted__(reason)
457
+ signal
458
+ end
459
+
460
+ # Spec: `AbortSignal.timeout(ms)` returns a signal that aborts
461
+ # itself after `ms` milliseconds with a `TimeoutError` reason.
462
+ # Without a Window/scheduler we fall back to a Thread-based timer
463
+ # so the signal works in vanilla CRuby; embedders that want
464
+ # microtask integration can pass a window via `__schedule_via__`.
465
+ def self.timeout(ms, scheduler: nil)
466
+ signal = new
467
+ reason = DOMException::TimeoutError.new("operation timed out")
468
+ if scheduler
469
+ scheduler.set_timeout(proc { signal.__mark_aborted__(reason) }, ms.to_i)
470
+ else
471
+ signal.__schedule_thread_timeout__(ms.to_i, reason)
472
+ end
473
+
474
+ signal
475
+ end
476
+
477
+ # Spec: `AbortSignal.any([sig, ...])` returns a composite signal
478
+ # that aborts as soon as any of the inputs aborts. If any input
479
+ # is already aborted, the returned signal is pre-aborted with
480
+ # that input's reason.
481
+ def self.any(signals)
482
+ composite = new
483
+ list = Array(signals).select { |s| s.is_a?(AbortSignal) }
484
+ already = list.find(&:aborted?)
485
+ if already
486
+ composite.__mark_aborted__(already.reason)
487
+ return composite
488
+ end
489
+
490
+ list.each do |sig|
491
+ sig.add_event_listener("abort", proc { composite.__mark_aborted__(sig.reason) })
492
+ end
493
+
494
+ composite
495
+ end
496
+
497
+ def initialize
498
+ @aborted = false
499
+ @reason = nil
500
+ end
501
+
502
+ # Background-thread timeout used by `AbortSignal.timeout` when no
503
+ # scheduler is provided. Kept package-private; tests can also
504
+ # drive the abort manually via `__mark_aborted__`.
505
+ def __schedule_thread_timeout__(ms, reason)
506
+ Thread.new do
507
+ sleep(ms.to_f / 1000.0)
508
+ __mark_aborted__(reason)
509
+ end
510
+
511
+ nil
512
+ end
513
+
514
+ def aborted?
515
+ @aborted
516
+ end
517
+
518
+ def reason
519
+ @reason
520
+ end
521
+
522
+ # Spec: throws `signal.reason` if aborted, otherwise no-op. Used by
523
+ # consumer code that polls before doing async work.
524
+ def throw_if_aborted
525
+ return unless @aborted
526
+
527
+ raise @reason.is_a?(Exception) ? @reason : RuntimeError.new(@reason.to_s)
528
+ end
529
+
530
+ alias throwIfAborted throw_if_aborted
531
+
532
+ def __js_get__(key)
533
+ case key
534
+ when "aborted"
535
+ @aborted
536
+ when "reason"
537
+ @reason
538
+ end
539
+ end
540
+
541
+ def __js_set__(_key, _value)
542
+ nil
543
+ end
544
+
545
+ def __js_call__(method, args)
546
+ case method
547
+ when "addEventListener"
548
+ add_event_listener(args[0], args[1], args[2])
549
+ when "removeEventListener"
550
+ remove_event_listener(args[0], args[1])
551
+ when "dispatchEvent"
552
+ dispatch_event(args[0])
553
+ when "throwIfAborted"
554
+ throw_if_aborted
555
+ end
556
+ end
557
+
558
+ def __mark_aborted__(reason = nil)
559
+ return if @aborted
560
+
561
+ @aborted = true
562
+ @reason = reason
563
+ dispatch_event(Event.new("abort", "bubbles" => false, "cancelable" => false))
564
+ end
565
+ end
566
+
567
+ class AbortController
568
+ attr_reader :signal
569
+
570
+ def initialize
571
+ @signal = AbortSignal.new
572
+ end
573
+
574
+ def __js_get__(key)
575
+ @signal if key == "signal"
576
+ end
577
+
578
+ def __js_set__(_key, _value)
579
+ nil
580
+ end
581
+
582
+ def __js_call__(method, args)
583
+ case method
584
+ when "abort"
585
+ @signal.__mark_aborted__(args[0])
586
+ end
587
+ end
588
+ end
589
+ end