dommy 0.5.0 → 0.6.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -4
  3. data/lib/dommy/animation.rb +288 -0
  4. data/lib/dommy/compression_streams.rb +147 -0
  5. data/lib/dommy/cookie_store.rb +128 -0
  6. data/lib/dommy/crypto.rb +395 -0
  7. data/lib/dommy/document.rb +93 -1
  8. data/lib/dommy/element.rb +131 -9
  9. data/lib/dommy/event.rb +370 -0
  10. data/lib/dommy/event_source.rb +131 -0
  11. data/lib/dommy/fetch.rb +62 -0
  12. data/lib/dommy/file_reader.rb +176 -0
  13. data/lib/dommy/history.rb +79 -0
  14. data/lib/dommy/html_elements.rb +20 -25
  15. data/lib/dommy/internal/cookie_jar.rb +2 -0
  16. data/lib/dommy/internal/dom_matching.rb +1 -1
  17. data/lib/dommy/internal/idna.rb +443 -0
  18. data/lib/dommy/internal/idna_data.rb +10379 -0
  19. data/lib/dommy/internal/ipv4_parser.rb +78 -0
  20. data/lib/dommy/internal/node_wrapper_cache.rb +1 -1
  21. data/lib/dommy/internal/observable_callback.rb +25 -0
  22. data/lib/dommy/internal/punycode.rb +202 -0
  23. data/lib/dommy/internal/range_text_serializer.rb +72 -0
  24. data/lib/dommy/internal/reflected_attributes.rb +45 -0
  25. data/lib/dommy/intersection_observer.rb +82 -0
  26. data/lib/dommy/{router.rb → location.rb} +0 -138
  27. data/lib/dommy/media_query_list.rb +118 -0
  28. data/lib/dommy/message_channel.rb +249 -0
  29. data/lib/dommy/navigator.rb +361 -1
  30. data/lib/dommy/notification.rb +89 -0
  31. data/lib/dommy/performance.rb +146 -0
  32. data/lib/dommy/performance_observer.rb +55 -0
  33. data/lib/dommy/range.rb +597 -0
  34. data/lib/dommy/resize_observer.rb +53 -0
  35. data/lib/dommy/streams.rb +386 -0
  36. data/lib/dommy/svg_elements.rb +3863 -0
  37. data/lib/dommy/text_codec.rb +175 -0
  38. data/lib/dommy/url.rb +249 -21
  39. data/lib/dommy/url_pattern.rb +144 -0
  40. data/lib/dommy/version.rb +1 -1
  41. data/lib/dommy/web_socket.rb +209 -0
  42. data/lib/dommy/{world.rb → window.rb} +149 -2
  43. data/lib/dommy/worker.rb +143 -0
  44. data/lib/dommy/xml_http_request.rb +423 -0
  45. data/lib/dommy.rb +31 -3
  46. metadata +34 -5
  47. /data/lib/dommy/{observer.rb → mutation_observer.rb} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 217a4ff53f58e6c12e424951b057cc0d3cbab3b898fdb85d5fd69ca7c34cd3c5
4
- data.tar.gz: bedaba24772a900d85e62a27729b8e62f8db8a18e0b48360f1717799108432f8
3
+ metadata.gz: 7ce9aec097da24f1f6a44792f44615605a1405483b6c6c5183031757d11a0853
4
+ data.tar.gz: cf7c0e05839c313763590fcdb4d134459f5b1fe91ed1b479f819b18181cf1058
5
5
  SHA512:
6
- metadata.gz: 3a9687cf21dde61fa2c65cd47ca90f18e4d65da61912ffdbd8366cbf75f1110a96da52c43a27abee498dc9f2a19ffc306a491e14bb6aa4d985f7dd74fec88816
7
- data.tar.gz: 201bb5b9217bc0373923f89e36cc968cb55328f249d902acdfa973f9f01179d9912463b5ee9d165c8c5e862fb7ff3f40ed531db9f2ee811432e6f77db8c42faa
6
+ metadata.gz: 0db6e083fb62dd609fd719264a4d35e6bcadd6b97ce8515d08a8a8a53028e1f3f870224ef40ca33240688ddaf782d41e09d261c079c145144fe7b47b83288ef6
7
+ data.tar.gz: d33e89e420bf0f93c2e478c93dd9241643ee47c40efa40927ff77714f20b92af561330b2cbedcaa287951cd07aab4ecfb899e24371fe8d6e2b20f289f6973b53
data/README.md CHANGED
@@ -177,6 +177,7 @@ Implemented:
177
177
 
178
178
  - Core DOM (Document, Element, Text/Comment/Fragment, NodeList, Attr)
179
179
  - 26 specialized HTMLElement subclasses
180
+ - SVG: SVGElement base + ~63 specialized subclasses covering shapes, gradients, marker / mask, the full set of standard filter primitives (Gaussian blur, offset, blend, color matrix, flood, composite, merge, component transfer, tile, morphology, image, drop shadow, turbulence, displacement map, convolve matrix, diffuse / specular lighting + light sources), `<a>` / `<textPath>` / `<view>` / `<switch>` / `<metadata>`, and SMIL animation (`<animate>` / `<animateTransform>` / `<animateMotion>` / `<set>` / `<mpath>` / `<discard>`) — with case-sensitive attribute round-trip
180
181
  - events with composedPath / AbortSignal
181
182
  - MutationObserver (childList / attributes / characterData / subtree)
182
183
  - Custom Elements lifecycle
@@ -184,12 +185,36 @@ Implemented:
184
185
  - form validation
185
186
  - Scheduler (timers + microtasks with `advance_time`)
186
187
  - Promise
187
- - Location / History / URL
188
+ - Location / History / URL (WHATWG-leaning parsing with whitespace/tab/newline stripping, percent-encoding of unsafe path chars, `\` → `/` conversion for special schemes, `./` and `../` resolution, IPv4 number forms normalization, ws/wss default port stripping, Punycode hostname encoding, full UTS #46 IDNA with RFC 5893 Bidi and RFC 5892 ContextJ/ContextO)
188
189
  - Storage
189
- - fetch (stub)
190
+ - fetch (stub) / XMLHttpRequest (stub-driven, sync + async, shares the `__fetchy_stub__` fixture map)
191
+ - WebSocket / EventSource — test seams (`__simulate_open__` / `__simulate_message__` / `__simulate_close__`) drive the streams
192
+ - MessageChannel / MessagePort / BroadcastChannel — in-process pub/sub with `structuredClone`-on-transfer
193
+ - FileReader (`readAsText` / `readAsDataURL` / `readAsArrayBuffer` / `readAsBinaryString`)
194
+ - Notification (permission settable via `Notification.__set_permission__`)
195
+ - Geolocation (mock position via `navigator.geolocation.__set_position__`)
196
+ - `window.matchMedia` returning a `MediaQueryList` (`__set_matches__` flips and fires `change`)
197
+ - `requestIdleCallback` / `cancelIdleCallback` (modelled on scheduler), `structuredClone` global
198
+ - `crypto.subtle.digest` (SHA-1/256/384/512), HMAC sign/verify/import/generateKey, and AES-GCM encrypt/decrypt (128/256-bit with additionalData and tagLength options)
199
+ - Streams API (`ReadableStream` / `WritableStream` / `TransformStream` + `TextEncoderStream` / `TextDecoderStream`)
200
+ - `CompressionStream` / `DecompressionStream` (gzip / deflate / deflate-raw via Ruby `Zlib`)
201
+ - Worker (inline-emulated — same-process message round-trip; tests register handlers via `worker.__on_message__`)
202
+ - Performance User Timing (`performance.mark` / `measure` / `getEntriesByName`)
203
+ - `cookieStore` (async Cookie Store API, backed by the same jar `document.cookie` uses)
204
+ - Navigator extras: `share` / `vibrate` / `wakeLock.request` / `getBattery` / `locks` (Web Locks) / `storage.estimate` / `persist` / `persisted` (StorageManager)
205
+ - Layout-adjacent stubs: `element.scrollIntoView` / `scrollTo` / scroll & client / offset metrics (return 0), `getComputedStyle` (inline style passthrough)
206
+ - Popover API (`showPopover` / `hidePopover` / `togglePopover` with `beforetoggle` / `toggle` events)
207
+ - Fullscreen API (`element.requestFullscreen` / `document.exitFullscreen` / `fullscreenchange`)
208
+ - `URLPattern` (pattern matching for URL components — protocol, username, password, hostname, port, pathname, search, hash — with named capture groups and modifiers: `:id`, `*`, `:version+`, `:version?`)
209
+ - `document.startViewTransition` (View Transitions API stub)
190
210
  - Navigator / Clipboard
191
211
  - TreeWalker / NodeIterator / NodeFilter
192
212
  - File API (Blob / File / FileList / FormData / DataTransfer)
213
+ - Web Crypto (`crypto.randomUUID`, `getRandomValues`) / TextEncoder / TextDecoder
214
+ - IntersectionObserver / ResizeObserver / PerformanceObserver (test-driven `__trigger__`)
215
+ - Range / Selection (DOM-level only, no layout)
216
+ - Web Animations API (Animation / KeyframeEffect; lifecycle via scheduler, finished/ready Promises)
217
+ - Extended events: Touch / Clipboard / Composition / Wheel / Focus / BeforeUnload / Input / Pointer / Progress / Drag
193
218
 
194
219
  > [!IMPORTANT]
195
220
  > Out of scope:
@@ -198,8 +223,9 @@ Implemented:
198
223
  > - CSS scoping (`:host`, `::slotted`, computed styles)
199
224
  > - JS evaluation
200
225
  > - Canvas / WebGL / media playback
201
- > - layout-dependent Range / Selection
202
- > - SVG specialized classes
226
+ > - layout-dependent Range / Selection geometry (`getBoundingClientRect` returns zero rects)
227
+ > - SVG-specific value types (SVGAnimatedLength, SVGTransform, SVGMatrix)
228
+ > - Web Animations: no actual value interpolation — `Animation` is a state machine (`idle` / `running` / `paused` / `finished`) for testing lifecycle and event wiring
203
229
 
204
230
  ## Running the tests
205
231
 
@@ -0,0 +1,288 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ # `KeyframeEffect` — wraps a target element + keyframes + timing.
5
+ # Dommy doesn't interpolate values (no layout / render); the
6
+ # effect is just a record of what the animation describes.
7
+ #
8
+ # Spec: https://drafts.csswg.org/web-animations/#keyframeeffect
9
+ class KeyframeEffect
10
+ attr_reader :target, :keyframes
11
+
12
+ def initialize(target, keyframes, options = nil)
13
+ @target = target
14
+ @keyframes = case keyframes
15
+ when nil
16
+ []
17
+ when Array
18
+ keyframes
19
+ else
20
+ [keyframes]
21
+ end
22
+
23
+ @timing = normalize_timing(options)
24
+ end
25
+
26
+ def get_timing
27
+ @timing.dup
28
+ end
29
+
30
+ alias getTiming get_timing
31
+
32
+ def update_timing(timing)
33
+ @timing.merge!(timing.transform_keys(&:to_s))
34
+ nil
35
+ end
36
+
37
+ alias updateTiming update_timing
38
+
39
+ def duration_ms
40
+ d = @timing["duration"]
41
+ d.is_a?(Numeric) ? d.to_i : 0
42
+ end
43
+
44
+ def __js_get__(key)
45
+ case key
46
+ when "target"
47
+ @target
48
+ end
49
+ end
50
+
51
+ def __js_call__(method, args)
52
+ case method
53
+ when "getTiming"
54
+ get_timing
55
+ when "updateTiming"
56
+ update_timing(args[0] || {})
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def normalize_timing(input)
63
+ case input
64
+ when nil
65
+ {"duration" => 0}
66
+ when Numeric
67
+ {"duration" => input.to_i}
68
+ when Hash
69
+ h = input.transform_keys(&:to_s)
70
+ h["duration"] = (h["duration"] || 0).is_a?(Numeric) ? h["duration"].to_i : 0
71
+ h
72
+ else
73
+ {"duration" => 0}
74
+ end
75
+ end
76
+ end
77
+
78
+ # `Animation` — represents a running animation, mirroring the Web
79
+ # Animations API's lifecycle and event surface. Dommy doesn't
80
+ # interpolate any property values; the animation transitions
81
+ # through "idle" → "running" → "finished" by either virtual time
82
+ # (`scheduler.advance_time`) or by an explicit `finish()` call.
83
+ #
84
+ # Spec: https://drafts.csswg.org/web-animations/#animation
85
+ class Animation
86
+ include EventTarget
87
+
88
+ attr_accessor :id
89
+ attr_reader :effect, :timeline, :play_state
90
+
91
+ def initialize(effect = nil, timeline = nil, window: nil)
92
+ @effect = effect
93
+ @timeline = timeline
94
+ @window = window
95
+ @play_state = "idle"
96
+ @playback_rate = 1.0
97
+ @current_time = nil
98
+ @start_time = nil
99
+ @id = ""
100
+ @finished_promise = nil
101
+ @ready_promise = nil
102
+ @scheduled_finish_id = nil
103
+ end
104
+
105
+ def current_time
106
+ @current_time
107
+ end
108
+
109
+ def current_time=(value)
110
+ @current_time = value
111
+ end
112
+
113
+ def start_time
114
+ @start_time
115
+ end
116
+
117
+ def start_time=(value)
118
+ @start_time = value
119
+ end
120
+
121
+ def playback_rate
122
+ @playback_rate
123
+ end
124
+
125
+ def playback_rate=(value)
126
+ @playback_rate = value.to_f
127
+ end
128
+
129
+ # Start (or resume) the animation. Returns self.
130
+ def play
131
+ return self if @play_state == "running"
132
+
133
+ previous = @play_state
134
+ @play_state = "running"
135
+ @start_time ||= @window&.scheduler&.now_ms || 0
136
+ ensure_ready_resolved
137
+ schedule_auto_finish if previous != "paused"
138
+ self
139
+ end
140
+
141
+ def pause
142
+ cancel_scheduled_finish
143
+ @play_state = "paused" unless @play_state == "idle"
144
+ self
145
+ end
146
+
147
+ def cancel
148
+ cancel_scheduled_finish
149
+ @play_state = "idle"
150
+ @current_time = nil
151
+ reject_finished_with_abort
152
+ dispatch_event(Event.new("cancel"))
153
+ self
154
+ end
155
+
156
+ def finish
157
+ cancel_scheduled_finish
158
+ @play_state = "finished"
159
+ @current_time = effect_duration_ms
160
+ resolve_finished
161
+ dispatch_event(Event.new("finish"))
162
+ self
163
+ end
164
+
165
+ def reverse
166
+ @playback_rate = -@playback_rate
167
+ play if @play_state == "idle"
168
+ self
169
+ end
170
+
171
+ # PromiseValue that resolves when the animation finishes.
172
+ # Rejected (with AbortError-style RuntimeError) on cancel.
173
+ def finished
174
+ @finished_promise ||= PromiseValue.new(@window)
175
+ end
176
+
177
+ # PromiseValue that resolves once the animation is ready to play
178
+ # (immediately in Dommy — there's no render-thread handoff).
179
+ def ready
180
+ @ready_promise ||= if @window
181
+ PromiseValue.resolve(@window, self)
182
+ else
183
+ PromiseValue.new(@window)
184
+ end
185
+ end
186
+
187
+ def __js_get__(key)
188
+ case key
189
+ when "playState"
190
+ @play_state
191
+ when "playbackRate"
192
+ @playback_rate
193
+ when "currentTime"
194
+ @current_time
195
+ when "startTime"
196
+ @start_time
197
+ when "effect"
198
+ @effect
199
+ when "timeline"
200
+ @timeline
201
+ when "finished"
202
+ finished
203
+ when "ready"
204
+ ready
205
+ when "id"
206
+ @id
207
+ end
208
+ end
209
+
210
+ def __js_set__(key, value)
211
+ case key
212
+ when "currentTime"
213
+ @current_time = value
214
+ when "startTime"
215
+ @start_time = value
216
+ when "playbackRate"
217
+ @playback_rate = value.to_f
218
+ when "id"
219
+ @id = value.to_s
220
+ end
221
+
222
+ nil
223
+ end
224
+
225
+ def __js_call__(method, args)
226
+ case method
227
+ when "play"
228
+ play
229
+ when "pause"
230
+ pause
231
+ when "cancel"
232
+ cancel
233
+ when "finish"
234
+ finish
235
+ when "reverse"
236
+ reverse
237
+ when "addEventListener"
238
+ add_event_listener(args[0], args[1], args[2])
239
+ when "removeEventListener"
240
+ remove_event_listener(args[0], args[1])
241
+ when "dispatchEvent"
242
+ dispatch_event(args[0])
243
+ end
244
+ end
245
+
246
+ # Event bubbling stops at Animation — it isn't part of the DOM tree.
247
+ def __event_parent__
248
+ nil
249
+ end
250
+
251
+ private
252
+
253
+ def effect_duration_ms
254
+ @effect ? @effect.duration_ms : 0
255
+ end
256
+
257
+ def schedule_auto_finish
258
+ return unless @window&.scheduler
259
+ return if effect_duration_ms <= 0
260
+
261
+ @scheduled_finish_id = @window.scheduler.set_timeout(
262
+ proc { finish if @play_state == "running" },
263
+ effect_duration_ms
264
+ )
265
+ end
266
+
267
+ def cancel_scheduled_finish
268
+ return unless @scheduled_finish_id && @window&.scheduler
269
+
270
+ @window.scheduler.clear_timeout(@scheduled_finish_id)
271
+ @scheduled_finish_id = nil
272
+ end
273
+
274
+ def ensure_ready_resolved
275
+ ready.fulfill(self) if ready.respond_to?(:fulfill)
276
+ end
277
+
278
+ def resolve_finished
279
+ finished.fulfill(self)
280
+ end
281
+
282
+ def reject_finished_with_abort
283
+ return unless @finished_promise
284
+
285
+ @finished_promise.reject(RuntimeError.new("AbortError: animation cancelled"))
286
+ end
287
+ end
288
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+ require "stringio"
5
+
6
+ module Dommy
7
+ # `CompressionStream` / `DecompressionStream` — wrap `TransformStream`
8
+ # over Ruby's `Zlib` to gzip/deflate/raw-deflate byte chunks. Each
9
+ # `write(chunk)` accumulates into an internal buffer; `close()`
10
+ # finalizes and emits the compressed/decompressed bytes downstream.
11
+ #
12
+ # Spec: https://wicg.github.io/compression/
13
+ class CompressionStream
14
+ SUPPORTED = %w[gzip deflate deflate-raw].freeze
15
+
16
+ attr_reader :readable, :writable
17
+
18
+ def initialize(window, format)
19
+ raise ArgumentError, "unsupported format #{format.inspect}" unless SUPPORTED.include?(format.to_s)
20
+
21
+ @buffer = +""
22
+ compressor = build_compressor(format.to_s)
23
+
24
+ @readable = ReadableStream.new(window)
25
+ controller = TransformStreamDefaultController.new(@readable)
26
+
27
+ @writable = WritableStream.new(
28
+ window,
29
+ {
30
+ "write" => proc { |chunk| @buffer << coerce(chunk) },
31
+ "close" => proc do
32
+ compressed = compressor.call(@buffer)
33
+ controller.enqueue(compressed)
34
+ @readable.__close__
35
+ end,
36
+ "abort" => proc { |r| @readable.__error__(r) }
37
+ }
38
+ )
39
+ end
40
+
41
+ def __js_get__(key)
42
+ case key
43
+ when "readable"
44
+ @readable
45
+ when "writable"
46
+ @writable
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def coerce(chunk)
53
+ chunk.is_a?(Array) ? chunk.pack("C*") : chunk.to_s
54
+ end
55
+
56
+ def build_compressor(format)
57
+ case format
58
+ when "gzip"
59
+ proc do |data|
60
+ io = StringIO.new
61
+ gz = Zlib::GzipWriter.new(io)
62
+ gz.write(data)
63
+ gz.close
64
+ io.string.bytes
65
+ end
66
+
67
+ when "deflate"
68
+ proc { |data| Zlib::Deflate.deflate(data).bytes }
69
+ when "deflate-raw"
70
+ proc do |data|
71
+ z = Zlib::Deflate.new(Zlib::DEFAULT_COMPRESSION, -Zlib::MAX_WBITS)
72
+ out = z.deflate(data, Zlib::FINISH)
73
+ z.close
74
+ out.bytes
75
+ end
76
+ end
77
+ end
78
+ end
79
+
80
+ class DecompressionStream
81
+ SUPPORTED = %w[gzip deflate deflate-raw].freeze
82
+
83
+ attr_reader :readable, :writable
84
+
85
+ def initialize(window, format)
86
+ raise ArgumentError, "unsupported format #{format.inspect}" unless SUPPORTED.include?(format.to_s)
87
+
88
+ @buffer = +""
89
+ decompressor = build_decompressor(format.to_s)
90
+
91
+ @readable = ReadableStream.new(window)
92
+ controller = TransformStreamDefaultController.new(@readable)
93
+
94
+ @writable = WritableStream.new(
95
+ window,
96
+ {
97
+ "write" => proc { |chunk| @buffer << coerce(chunk) },
98
+ "close" => proc do
99
+ plain = decompressor.call(@buffer)
100
+ controller.enqueue(plain)
101
+ @readable.__close__
102
+ end,
103
+ "abort" => proc { |r| @readable.__error__(r) }
104
+ }
105
+ )
106
+ end
107
+
108
+ def __js_get__(key)
109
+ case key
110
+ when "readable"
111
+ @readable
112
+ when "writable"
113
+ @writable
114
+ end
115
+ end
116
+
117
+ private
118
+
119
+ def coerce(chunk)
120
+ chunk.is_a?(Array) ? chunk.pack("C*") : chunk.to_s
121
+ end
122
+
123
+ def build_decompressor(format)
124
+ case format
125
+ when "gzip"
126
+ proc do |data|
127
+ io = StringIO.new(data)
128
+ gz = Zlib::GzipReader.new(io)
129
+ out = gz.read
130
+ gz.close
131
+ out.bytes
132
+ end
133
+
134
+ when "deflate"
135
+ proc { |data| Zlib::Inflate.inflate(data).bytes }
136
+ when "deflate-raw"
137
+ proc do |data|
138
+ z = Zlib::Inflate.new(-Zlib::MAX_WBITS)
139
+ out = z.inflate(data)
140
+ z.finish
141
+ z.close
142
+ out.bytes
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ # `cookieStore` — the asynchronous Cookie Store API. Reads and
5
+ # writes go through the existing `Document#cookie_jar`, so values
6
+ # set via `document.cookie = ...` round-trip via `cookieStore.get`.
7
+ #
8
+ # Spec: https://wicg.github.io/cookie-store/
9
+ class CookieStore
10
+ include EventTarget
11
+
12
+ def initialize(window)
13
+ @window = window
14
+ end
15
+
16
+ def get(name_or_options)
17
+ name = name_or_options.is_a?(Hash) ? (name_or_options["name"] || name_or_options[:name]) : name_or_options
18
+ raw = cookie_jar.cookies[name.to_s]
19
+ raw ? PromiseValue.resolve(@window, build_record(name.to_s, raw)) : PromiseValue.resolve(@window, nil)
20
+ end
21
+
22
+ def get_all(name = nil)
23
+ records = cookie_jar.cookies.map { |k, v| build_record(k, v) }
24
+ records = records.select { |r| r["name"] == name.to_s } if name
25
+ PromiseValue.resolve(@window, records)
26
+ end
27
+
28
+ alias getAll get_all
29
+
30
+ def set(name_or_options, value = nil)
31
+ if name_or_options.is_a?(Hash)
32
+ opts = name_or_options.transform_keys(&:to_s)
33
+ name = opts["name"]
34
+ value = opts["value"]
35
+ else
36
+ name = name_or_options
37
+ end
38
+
39
+ cookie_jar.set_cookie("#{name}=#{value}")
40
+ dispatch_event(
41
+ CookieChangeEvent.new(
42
+ "change",
43
+ "changed" => [build_record(name.to_s, value.to_s)],
44
+ "deleted" => []
45
+ )
46
+ )
47
+ PromiseValue.resolve(@window, nil)
48
+ end
49
+
50
+ def delete(name_or_options)
51
+ name = name_or_options.is_a?(Hash) ? (name_or_options["name"] || name_or_options[:name]) : name_or_options
52
+ cookie_jar.cookies.delete(name.to_s)
53
+ dispatch_event(
54
+ CookieChangeEvent.new(
55
+ "change",
56
+ "changed" => [],
57
+ "deleted" => [build_record(name.to_s, "")]
58
+ )
59
+ )
60
+ PromiseValue.resolve(@window, nil)
61
+ end
62
+
63
+ def __js_call__(method, args)
64
+ case method
65
+ when "get"
66
+ get(args[0])
67
+ when "getAll"
68
+ get_all(args[0])
69
+ when "set"
70
+ set(args[0], args[1])
71
+ when "delete"
72
+ delete(args[0])
73
+ when "addEventListener"
74
+ add_event_listener(args[0], args[1], args[2])
75
+ when "removeEventListener"
76
+ remove_event_listener(args[0], args[1])
77
+ when "dispatchEvent"
78
+ dispatch_event(args[0])
79
+ end
80
+ end
81
+
82
+ def __event_parent__
83
+ nil
84
+ end
85
+
86
+ private
87
+
88
+ def cookie_jar
89
+ # `document.cookie_jar` is private; we reach the same backing
90
+ # store via the public `document.cookie` round-trip. Easier: use
91
+ # instance_variable_get on the document.
92
+ @window.document.instance_variable_get(:@cookie_jar)
93
+ end
94
+
95
+ def build_record(name, value)
96
+ {
97
+ "name" => name.to_s,
98
+ "value" => value.to_s,
99
+ "domain" => nil,
100
+ "path" => "/",
101
+ "expires" => nil,
102
+ "secure" => false,
103
+ "sameSite" => "strict"
104
+ }
105
+ end
106
+ end
107
+
108
+ class CookieChangeEvent < Event
109
+ def initialize(type, init = nil)
110
+ super
111
+ @changed = Array(read_init(init, "changed") || [])
112
+ @deleted = Array(read_init(init, "deleted") || [])
113
+ end
114
+
115
+ attr_reader :changed, :deleted
116
+
117
+ def __js_get__(key)
118
+ case key
119
+ when "changed"
120
+ @changed
121
+ when "deleted"
122
+ @deleted
123
+ else
124
+ super
125
+ end
126
+ end
127
+ end
128
+ end