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,396 @@
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.__dommy_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.__dommy_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.__dommy_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 raw bytes are
363
+ # reachable only through the `__dommy_bytes__` ecosystem accessor, never
364
+ # the public (Web-mirroring) API.
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
+ # Low-level ecosystem accessor (see __dommy_ convention) — the public
378
+ # Web API never exposes raw key bytes.
379
+ def __dommy_bytes__
380
+ @bytes
381
+ end
382
+
383
+ def __js_get__(key)
384
+ case key
385
+ when "type"
386
+ @type.to_s
387
+ when "extractable"
388
+ @extractable
389
+ when "algorithm"
390
+ {"name" => @algorithm_name, "hash" => {"name" => @hash_name}}
391
+ when "usages"
392
+ @usages
393
+ end
394
+ end
395
+ end
396
+ end
data/lib/dommy/css.rb CHANGED
@@ -63,7 +63,7 @@ module Dommy
63
63
  idx = index.nil? ? @css_rules.length : index.to_i
64
64
  raise DOMException::IndexSizeError, "out of range" if idx < 0 || idx > @css_rules.length
65
65
 
66
- @css_rules.__insert__(idx, CSSRule.new(rule_text.to_s, self))
66
+ @css_rules.__internal_insert__(idx, CSSRule.new(rule_text.to_s, self))
67
67
  idx
68
68
  end
69
69
 
@@ -71,17 +71,17 @@ module Dommy
71
71
  idx = index.to_i
72
72
  raise DOMException::IndexSizeError, "out of range" if idx < 0 || idx >= @css_rules.length
73
73
 
74
- @css_rules.__delete_at__(idx)
74
+ @css_rules.__internal_delete_at__(idx)
75
75
  nil
76
76
  end
77
77
 
78
78
  # `replaceSync(text)` — replace all rules with a single rule blob
79
79
  # (no parsing — we keep it as one opaque entry).
80
80
  def replace_sync(text)
81
- @css_rules.__clear__
81
+ @css_rules.__internal_clear__
82
82
  return nil if text.to_s.empty?
83
83
 
84
- @css_rules.__insert__(0, CSSRule.new(text.to_s, self))
84
+ @css_rules.__internal_insert__(0, CSSRule.new(text.to_s, self))
85
85
  nil
86
86
  end
87
87
 
@@ -173,15 +173,15 @@ module Dommy
173
173
  @rules.dup
174
174
  end
175
175
 
176
- def __insert__(index, rule)
176
+ def __internal_insert__(index, rule)
177
177
  @rules.insert(index, rule)
178
178
  end
179
179
 
180
- def __delete_at__(index)
180
+ def __internal_delete_at__(index)
181
181
  @rules.delete_at(index)
182
182
  end
183
183
 
184
- def __clear__
184
+ def __internal_clear__
185
185
  @rules.clear
186
186
  end
187
187
 
@@ -64,16 +64,16 @@ module Dommy
64
64
  # registered; fires `connectedCallback` for each upgraded node
65
65
  # that's currently attached to a document tree.
66
66
  def upgrade(root)
67
- return nil unless root.respond_to?(:__node__)
67
+ return nil unless root.respond_to?(:__dommy_backend_node__)
68
68
 
69
- walk_descendants(root.__node__) do |nk|
69
+ walk_descendants(root.__dommy_backend_node__) do |nk|
70
70
  next unless nk.element?
71
71
  next unless @definitions.key?(nk.name)
72
72
 
73
73
  # Force re-wrap by clearing the document's cached wrapper.
74
- @window.document.__reset_wrapper__(nk)
74
+ @window.document.__internal_reset_wrapper__(nk)
75
75
  wrapped = @window.document.wrap_node(nk)
76
- @window.document.__notify_connected__(wrapped) if wrapped
76
+ @window.document.__internal_notify_connected__(wrapped) if wrapped
77
77
  end
78
78
 
79
79
  nil
@@ -109,9 +109,9 @@ module Dommy
109
109
  def upgrade_existing(name)
110
110
  doc = @window.document
111
111
  doc.nokogiri_doc.css(name).each do |nk|
112
- doc.__reset_wrapper__(nk)
112
+ doc.__internal_reset_wrapper__(nk)
113
113
  wrapped = doc.wrap_node(nk)
114
- doc.__notify_connected__(wrapped) if wrapped
114
+ doc.__internal_notify_connected__(wrapped) if wrapped
115
115
  end
116
116
  end
117
117