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
@@ -0,0 +1,395 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "digest"
5
+ require "openssl"
6
+
7
+ module Dommy
8
+ # `Crypto` — mirror of `window.crypto`. Exposes `randomUUID()`,
9
+ # `getRandomValues(typedArray)`, and a minimal `subtle` surface
10
+ # (digest only, sufficient for most test fixtures).
11
+ #
12
+ # Spec: https://w3c.github.io/webcrypto/
13
+ class Crypto
14
+ def initialize(window = nil)
15
+ @window = window
16
+ end
17
+
18
+ # JS: crypto.randomUUID() → version-4 UUID string.
19
+ def random_uuid
20
+ SecureRandom.uuid
21
+ end
22
+
23
+ alias randomUUID random_uuid
24
+
25
+ # JS: crypto.getRandomValues(typedArray) — fills the supplied
26
+ # buffer in place and returns it. JS TypedArrays carry a
27
+ # `byteLength` property; we honor that to fill multi-byte
28
+ # element arrays (Uint16Array, etc.) correctly. Plain Ruby
29
+ # arrays fall back to `size` (1 byte per slot).
30
+ def get_random_values(typed_array)
31
+ return typed_array unless typed_array.respond_to?(:size) && typed_array.respond_to?(:[]=)
32
+
33
+ byte_length = if typed_array.respond_to?(:byteLength)
34
+ typed_array.byteLength
35
+ elsif typed_array.respond_to?(:byte_length)
36
+ typed_array.byte_length
37
+ else
38
+ typed_array.size
39
+ end
40
+
41
+ bytes_per_element = [byte_length / typed_array.size, 1].max
42
+ bytes = SecureRandom.bytes(byte_length).bytes
43
+ typed_array.size.times do |i|
44
+ offset = i * bytes_per_element
45
+ value = bytes[offset, bytes_per_element].reduce(0) { |acc, b| (acc << 8) | b }
46
+ typed_array[i] = value
47
+ end
48
+
49
+ typed_array
50
+ end
51
+
52
+ alias getRandomValues get_random_values
53
+
54
+ def subtle
55
+ @subtle ||= SubtleCrypto.new(@window)
56
+ end
57
+
58
+ def __js_get__(key)
59
+ case key
60
+ when "subtle"
61
+ subtle
62
+ end
63
+ end
64
+
65
+ def __js_call__(method, args)
66
+ case method
67
+ when "randomUUID"
68
+ random_uuid
69
+ when "getRandomValues"
70
+ get_random_values(args[0])
71
+ end
72
+ end
73
+ end
74
+
75
+ # `SubtleCrypto` — `window.crypto.subtle`. Currently covers `digest`
76
+ # (SHA-1 / SHA-256 / SHA-384 / SHA-512), which is by far the most
77
+ # commonly used operation in test contexts. Encrypt / decrypt /
78
+ # sign / verify / key generation are out of scope; tests that
79
+ # need them should mock `crypto.subtle` directly.
80
+ #
81
+ # Returned values are byte arrays — real `SubtleCrypto.digest`
82
+ # resolves to an `ArrayBuffer`; we expose the equivalent Ruby
83
+ # byte array so callers can convert as needed.
84
+ class SubtleCrypto
85
+ ALGORITHMS = {
86
+ "SHA-1" => -> (data) { Digest::SHA1.digest(data) },
87
+ "SHA-256" => -> (data) { Digest::SHA256.digest(data) },
88
+ "SHA-384" => -> (data) { Digest::SHA384.digest(data) },
89
+ "SHA-512" => -> (data) { Digest::SHA512.digest(data) }
90
+ }.freeze
91
+
92
+ def initialize(window = nil)
93
+ @window = window
94
+ end
95
+
96
+ def digest(algorithm, data)
97
+ promise do
98
+ name = algorithm_name(algorithm)
99
+ hasher = ALGORITHMS[name]
100
+ raise ArgumentError, "unsupported algorithm: #{name}" unless hasher
101
+
102
+ hasher.call(coerce_bytes(data)).bytes
103
+ end
104
+ end
105
+
106
+ # Generate a fresh symmetric key. `algorithm` is `{name: "HMAC",
107
+ # hash: "SHA-256"}` or `{name: "AES-GCM", length: 128|256}`.
108
+ def generate_key(algorithm, extractable = true, usages = nil)
109
+ promise do
110
+ case primary_algorithm_name(algorithm)
111
+ when "HMAC"
112
+ hash = hmac_hash_from(algorithm)
113
+ CryptoKey.new(
114
+ :secret,
115
+ "HMAC",
116
+ hash,
117
+ SecureRandom.bytes(openssl_digest_size(hash)),
118
+ extractable: extractable,
119
+ usages: usages || %w[sign verify]
120
+ )
121
+ when "AES-GCM", "AES-CBC", "AES-CTR"
122
+ length = (algorithm.is_a?(Hash) && (algorithm["length"] || algorithm[:length])) || 256
123
+ raise ArgumentError, "AES key length must be 128/192/256" unless [128, 192, 256].include?(length)
124
+
125
+ CryptoKey.new(
126
+ :secret,
127
+ primary_algorithm_name(algorithm),
128
+ nil,
129
+ SecureRandom.bytes(length / 8),
130
+ extractable: extractable,
131
+ usages: usages || %w[encrypt decrypt]
132
+ )
133
+ else
134
+ raise ArgumentError, "unsupported algorithm: #{primary_algorithm_name(algorithm)}"
135
+ end
136
+ end
137
+ end
138
+
139
+ alias generateKey generate_key
140
+
141
+ # Import a raw key. Supports HMAC and AES-GCM/CBC/CTR.
142
+ def import_key(format, key_data, algorithm, extractable = true, usages = nil)
143
+ promise do
144
+ raise ArgumentError, "only raw format supported" unless format.to_s == "raw"
145
+
146
+ bytes = coerce_bytes(key_data)
147
+ case primary_algorithm_name(algorithm)
148
+ when "HMAC"
149
+ hash = hmac_hash_from(algorithm)
150
+ CryptoKey.new(
151
+ :secret,
152
+ "HMAC",
153
+ hash,
154
+ bytes,
155
+ extractable: extractable,
156
+ usages: usages || %w[sign verify]
157
+ )
158
+ when "AES-GCM", "AES-CBC", "AES-CTR"
159
+ unless [16, 24, 32].include?(bytes.bytesize)
160
+ raise ArgumentError, "AES key must be 16/24/32 bytes"
161
+ end
162
+
163
+ CryptoKey.new(
164
+ :secret,
165
+ primary_algorithm_name(algorithm),
166
+ nil,
167
+ bytes,
168
+ extractable: extractable,
169
+ usages: usages || %w[encrypt decrypt]
170
+ )
171
+ else
172
+ raise ArgumentError, "unsupported algorithm: #{primary_algorithm_name(algorithm)}"
173
+ end
174
+ end
175
+ end
176
+
177
+ alias importKey import_key
178
+
179
+ # HMAC sign — returns the MAC as a byte array.
180
+ def sign(_algorithm, key, data)
181
+ promise do
182
+ raise ArgumentError, "HMAC key required" unless key.is_a?(CryptoKey) && key.algorithm_name == "HMAC"
183
+ raise ArgumentError, "key.usages must include 'sign'" unless key.usages.include?("sign")
184
+
185
+ OpenSSL::HMAC.digest(openssl_digest_name(key.hash_name), key.__bytes__, coerce_bytes(data)).bytes
186
+ end
187
+ end
188
+
189
+ # HMAC verify — constant-time compare of the MAC.
190
+ def verify(_algorithm, key, signature, data)
191
+ promise do
192
+ raise ArgumentError, "HMAC key required" unless key.is_a?(CryptoKey) && key.algorithm_name == "HMAC"
193
+ raise ArgumentError, "key.usages must include 'verify'" unless key.usages.include?("verify")
194
+
195
+ expected = OpenSSL::HMAC.digest(openssl_digest_name(key.hash_name), key.__bytes__, coerce_bytes(data))
196
+ sig_bytes = coerce_bytes(signature)
197
+ if expected.bytesize == sig_bytes.bytesize
198
+ OpenSSL.fixed_length_secure_compare(expected, sig_bytes)
199
+ else
200
+ false
201
+ end
202
+ end
203
+ end
204
+
205
+ # AES-GCM encrypt. `algorithm` must be `{name: "AES-GCM", iv:
206
+ # <bytes>, additionalData?: <bytes>, tagLength?: 128}`.
207
+ # Output is `ciphertext || authTag`, matching WebCrypto.
208
+ def encrypt(algorithm, key, data)
209
+ promise do
210
+ cipher = build_gcm_cipher(:encrypt, algorithm, key)
211
+ ct = cipher.update(coerce_bytes(data)) + cipher.final
212
+ # OpenSSL always produces a 16-byte tag for GCM; truncate to
213
+ # the requested `tagLength` to honour the spec.
214
+ tag = cipher.auth_tag.byteslice(0, aes_gcm_tag_length(algorithm))
215
+ (ct + tag).bytes
216
+ end
217
+ end
218
+
219
+ def decrypt(algorithm, key, data)
220
+ promise do
221
+ bytes = coerce_bytes(data)
222
+ tag_len = aes_gcm_tag_length(algorithm)
223
+ raise ArgumentError, "ciphertext shorter than auth tag" if bytes.bytesize < tag_len
224
+
225
+ ct = bytes.byteslice(0, bytes.bytesize - tag_len)
226
+ tag = bytes.byteslice(bytes.bytesize - tag_len, tag_len)
227
+ cipher = build_gcm_cipher(:decrypt, algorithm, key)
228
+ cipher.auth_tag = tag
229
+ (cipher.update(ct) + cipher.final).bytes
230
+ end
231
+ end
232
+
233
+ def __js_call__(method, args)
234
+ case method
235
+ when "digest"
236
+ digest(args[0], args[1])
237
+ when "generateKey"
238
+ generate_key(args[0], args[1], args[2])
239
+ when "importKey"
240
+ import_key(args[0], args[1], args[2], args[3], args[4])
241
+ when "sign"
242
+ sign(args[0], args[1], args[2])
243
+ when "verify"
244
+ verify(args[0], args[1], args[2], args[3])
245
+ when "encrypt"
246
+ encrypt(args[0], args[1], args[2])
247
+ when "decrypt"
248
+ decrypt(args[0], args[1], args[2])
249
+ end
250
+ end
251
+
252
+ private
253
+
254
+ # Run `block` synchronously and wrap the result (or raised error)
255
+ # in a `PromiseValue`. Required by the WebCrypto spec — every
256
+ # method is `Promise`-returning even when the underlying work is
257
+ # synchronous.
258
+ def promise(&block)
259
+ result = block.call
260
+ PromiseValue.resolve(@window, result)
261
+ rescue StandardError => e
262
+ PromiseValue.reject(@window, e)
263
+ end
264
+
265
+ def algorithm_name(algorithm)
266
+ raw = algorithm.is_a?(Hash) ? (algorithm["name"] || algorithm[:name]) : algorithm
267
+ s = raw.to_s.upcase
268
+ # Normalize `SHA256` → `SHA-256`; preserve already-hyphenated `SHA-256`.
269
+ return s if s.include?("-")
270
+ return s.sub("SHA", "SHA-") if s.start_with?("SHA")
271
+
272
+ s
273
+ end
274
+
275
+ def coerce_bytes(data)
276
+ case data
277
+ when String
278
+ data
279
+ when Array
280
+ data.pack("C*")
281
+ else
282
+ data.respond_to?(:to_a) ? data.to_a.pack("C*") : data.to_s
283
+ end
284
+ end
285
+
286
+ # Resolve `algorithm["hash"]` to a canonical hash name (`"SHA-256"`
287
+ # etc.). Accepts the spec shapes:
288
+ # "SHA-256" (bare string)
289
+ # {hash: "SHA-256"}
290
+ # {hash: {name: "SHA-256"}}
291
+ # {name: "HMAC", hash: "SHA-256"}
292
+ # Raises `ArgumentError` if no hash can be resolved — unlike some
293
+ # browser UAs, dommy refuses to silently default to SHA-256.
294
+ def hmac_hash_from(algorithm)
295
+ hash_field = hash_descriptor(algorithm)
296
+ name = algorithm_name(hash_field)
297
+
298
+ if name.nil? || name.empty? || name == "HMAC"
299
+ raise ArgumentError, "HMAC requires an explicit hash (e.g. {hash: 'SHA-256'})"
300
+ end
301
+
302
+ raise ArgumentError, "unsupported HMAC hash: #{name}" unless ALGORITHMS.key?(name)
303
+
304
+ name
305
+ end
306
+
307
+ def hash_descriptor(algorithm)
308
+ return algorithm unless algorithm.is_a?(Hash)
309
+
310
+ algorithm["hash"] || algorithm[:hash] || algorithm
311
+ end
312
+
313
+ # The algorithm's primary name (`"HMAC"` / `"AES-GCM"` / ...) —
314
+ # ignoring any nested `hash` descriptor.
315
+ def primary_algorithm_name(algorithm)
316
+ raw = algorithm.is_a?(Hash) ? (algorithm["name"] || algorithm[:name]) : algorithm
317
+ algorithm_name(raw)
318
+ end
319
+
320
+ def openssl_digest_name(hash_name)
321
+ hash_name.sub("SHA-", "SHA")
322
+ end
323
+
324
+ def aes_gcm_tag_length(algorithm)
325
+ bits = (algorithm.is_a?(Hash) && (algorithm["tagLength"] || algorithm[:tagLength])) || 128
326
+ bits / 8
327
+ end
328
+
329
+ def build_gcm_cipher(direction, algorithm, key)
330
+ raw_key = key.is_a?(CryptoKey) ? key.__bytes__ : coerce_bytes(key)
331
+ raise ArgumentError, "AES-GCM key must be 16/24/32 bytes" unless [16, 24, 32].include?(raw_key.bytesize)
332
+
333
+ iv = algorithm.is_a?(Hash) ? (algorithm["iv"] || algorithm[:iv]) : nil
334
+ raise ArgumentError, "AES-GCM requires an iv" if iv.nil?
335
+
336
+ cipher = OpenSSL::Cipher.new("aes-#{raw_key.bytesize * 8}-gcm")
337
+ direction == :encrypt ? cipher.encrypt : cipher.decrypt
338
+ cipher.key = raw_key
339
+ cipher.iv = coerce_bytes(iv)
340
+ aad = algorithm.is_a?(Hash) ? (algorithm["additionalData"] || algorithm[:additionalData]) : nil
341
+ cipher.auth_data = coerce_bytes(aad) if aad
342
+ cipher
343
+ end
344
+
345
+ def openssl_digest_size(hash_name)
346
+ case hash_name
347
+ when "SHA-1"
348
+ 20
349
+ when "SHA-256"
350
+ 32
351
+ when "SHA-384"
352
+ 48
353
+ when "SHA-512"
354
+ 64
355
+ else
356
+ 32
357
+ end
358
+ end
359
+ end
360
+
361
+ # `CryptoKey` — opaque key handle returned by SubtleCrypto.
362
+ # `extractable: false` keys reject export attempts; the
363
+ # `__bytes__` accessor stays internal-only (`__double_underscore__`
364
+ # convention) so production code paths can't read the raw bytes.
365
+ class CryptoKey
366
+ attr_reader :type, :algorithm_name, :hash_name, :usages, :extractable
367
+
368
+ def initialize(type, algorithm_name, hash_name, bytes, extractable: true, usages: [])
369
+ @type = type
370
+ @algorithm_name = algorithm_name
371
+ @hash_name = hash_name
372
+ @bytes = bytes
373
+ @extractable = extractable
374
+ @usages = usages.map(&:to_s).freeze
375
+ end
376
+
377
+ # Test / internal seam — production callers should not reach in.
378
+ def __bytes__
379
+ @bytes
380
+ end
381
+
382
+ def __js_get__(key)
383
+ case key
384
+ when "type"
385
+ @type.to_s
386
+ when "extractable"
387
+ @extractable
388
+ when "algorithm"
389
+ {"name" => @algorithm_name, "hash" => {"name" => @hash_name}}
390
+ when "usages"
391
+ @usages
392
+ end
393
+ end
394
+ end
395
+ end
@@ -256,9 +256,36 @@ module Dommy
256
256
  alias has_focus has_focus?
257
257
 
258
258
  def get_selection
259
- nil
259
+ @__selection ||= Selection.new(self)
260
+ end
261
+
262
+ def create_range
263
+ Range.new(self)
260
264
  end
261
265
 
266
+ # Fullscreen API — no actual fullscreen mode, just track which
267
+ # element claimed it. `element.requestFullscreen()` sets it; this
268
+ # is the read side.
269
+ attr_reader :fullscreen_element
270
+
271
+ def __set_fullscreen_element__(element)
272
+ previous = @fullscreen_element
273
+ @fullscreen_element = element
274
+ return if previous == element
275
+
276
+ dispatch_event(Event.new("fullscreenchange"))
277
+ end
278
+
279
+ def exit_fullscreen
280
+ return PromiseValue.resolve(@default_view, nil) if @fullscreen_element.nil?
281
+
282
+ @fullscreen_element = nil
283
+ dispatch_event(Event.new("fullscreenchange"))
284
+ PromiseValue.resolve(@default_view, nil)
285
+ end
286
+
287
+ alias exitFullscreen exit_fullscreen
288
+
262
289
  def element_from_point(_x, _y)
263
290
  nil
264
291
  end
@@ -359,6 +386,12 @@ module Dommy
359
386
  doctype
360
387
  when "defaultView"
361
388
  @default_view
389
+ when "fullscreenElement"
390
+ @fullscreen_element
391
+ when "fullscreenEnabled"
392
+ true
393
+ when "scrollingElement"
394
+ wrap_node(@nokogiri_doc.at_css("html"))
362
395
  when "documentElement"
363
396
  wrap_node(@nokogiri_doc.at_css("html"))
364
397
  when "title"
@@ -413,6 +446,20 @@ module Dommy
413
446
 
414
447
  def __js_call__(method, args)
415
448
  case method
449
+ when "exitFullscreen"
450
+ exit_fullscreen
451
+ when "startViewTransition"
452
+ # View Transitions API stub. Spec: invoke the callback
453
+ # synchronously; return a ViewTransition with already-resolved
454
+ # `finished` / `ready` / `updateCallbackDone` promises.
455
+ callback = args[0]
456
+ if callback.respond_to?(:__js_call__)
457
+ callback.__js_call__("call", [])
458
+ elsif callback.respond_to?(:call)
459
+ callback.call
460
+ end
461
+
462
+ ViewTransition.new(@default_view)
416
463
  when "createElement"
417
464
  create_element(args[0])
418
465
  when "createElementNS"
@@ -671,4 +718,49 @@ module Dommy
671
718
  end
672
719
 
673
720
  end
721
+
722
+ # `ViewTransition` — return value of `document.startViewTransition()`.
723
+ # All three Promises (`finished` / `ready` / `updateCallbackDone`)
724
+ # resolve immediately since dommy has no actual paint phase.
725
+ #
726
+ # Spec: https://drafts.csswg.org/css-view-transitions/
727
+ class ViewTransition
728
+ def initialize(window)
729
+ @finished = PromiseValue.resolve(window, nil)
730
+ @ready = PromiseValue.resolve(window, nil)
731
+ @update_callback_done = PromiseValue.resolve(window, nil)
732
+ end
733
+
734
+ attr_reader :finished, :ready
735
+
736
+ def update_callback_done
737
+ @update_callback_done
738
+ end
739
+
740
+ alias updateCallbackDone update_callback_done
741
+
742
+ def skip_transition
743
+ nil
744
+ end
745
+
746
+ alias skipTransition skip_transition
747
+
748
+ def __js_get__(key)
749
+ case key
750
+ when "finished"
751
+ @finished
752
+ when "ready"
753
+ @ready
754
+ when "updateCallbackDone"
755
+ @update_callback_done
756
+ end
757
+ end
758
+
759
+ def __js_call__(method, _args)
760
+ case method
761
+ when "skipTransition"
762
+ skip_transition
763
+ end
764
+ end
765
+ end
674
766
  end