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.
- checksums.yaml +7 -0
- data/README.md +213 -0
- data/lib/dommy/attr.rb +200 -0
- data/lib/dommy/blob.rb +182 -0
- data/lib/dommy/bridge.rb +141 -0
- data/lib/dommy/css.rb +283 -0
- data/lib/dommy/custom_elements.rb +125 -0
- data/lib/dommy/data_transfer.rb +98 -0
- data/lib/dommy/document.rb +674 -0
- data/lib/dommy/dom_exception.rb +258 -0
- data/lib/dommy/dom_parser.rb +88 -0
- data/lib/dommy/element.rb +1975 -0
- data/lib/dommy/event.rb +589 -0
- data/lib/dommy/fetch.rb +241 -0
- data/lib/dommy/form_data.rb +208 -0
- data/lib/dommy/html_collection.rb +207 -0
- data/lib/dommy/html_elements.rb +4455 -0
- data/lib/dommy/internal/cookie_jar.rb +27 -0
- data/lib/dommy/internal/dom_matching.rb +141 -0
- data/lib/dommy/internal/mutation_coordinator.rb +172 -0
- data/lib/dommy/internal/node_traversal.rb +36 -0
- data/lib/dommy/internal/node_wrapper_cache.rb +179 -0
- data/lib/dommy/internal/observer_manager.rb +31 -0
- data/lib/dommy/internal/observer_matcher.rb +31 -0
- data/lib/dommy/internal/scope_resolution.rb +27 -0
- data/lib/dommy/internal/shadow_root_registry.rb +35 -0
- data/lib/dommy/internal/template_content_registry.rb +97 -0
- data/lib/dommy/minitest/assertions.rb +105 -0
- data/lib/dommy/minitest.rb +17 -0
- data/lib/dommy/navigator.rb +271 -0
- data/lib/dommy/node.rb +218 -0
- data/lib/dommy/observer.rb +199 -0
- data/lib/dommy/parser.rb +29 -0
- data/lib/dommy/promise.rb +199 -0
- data/lib/dommy/router.rb +275 -0
- data/lib/dommy/rspec/capy_style_matchers.rb +356 -0
- data/lib/dommy/rspec/matchers.rb +230 -0
- data/lib/dommy/rspec.rb +18 -0
- data/lib/dommy/scheduler.rb +135 -0
- data/lib/dommy/shadow_root.rb +255 -0
- data/lib/dommy/storage.rb +112 -0
- data/lib/dommy/test_helpers.rb +78 -0
- data/lib/dommy/tree_walker.rb +425 -0
- data/lib/dommy/url.rb +479 -0
- data/lib/dommy/version.rb +5 -0
- data/lib/dommy/world.rb +209 -0
- data/lib/dommy.rb +119 -0
- metadata +110 -0
data/lib/dommy/url.rb
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require "cgi"
|
|
5
|
+
|
|
6
|
+
module Dommy
|
|
7
|
+
# `URL` — WHATWG-style URL parsing. Public API mirrors the JS class:
|
|
8
|
+
#
|
|
9
|
+
# u = Dommy::URL.new("https://x.test/a/b?k=v#h")
|
|
10
|
+
# u.protocol # "https:"
|
|
11
|
+
# u.host # "x.test"
|
|
12
|
+
# u.pathname # "/a/b"
|
|
13
|
+
# u.search # "?k=v"
|
|
14
|
+
# u.hash # "#h"
|
|
15
|
+
# u.search_params.get("k") # "v"
|
|
16
|
+
#
|
|
17
|
+
# Construction with a base URL is supported for relative inputs:
|
|
18
|
+
# Dommy::URL.new("/a", "https://x.test").href
|
|
19
|
+
# # => "https://x.test/a"
|
|
20
|
+
#
|
|
21
|
+
# Internally backed by Ruby's URI library — good enough for the
|
|
22
|
+
# common test cases. Edge cases that URI rejects raise
|
|
23
|
+
# `DOMException::SyntaxError` (called `TypeError` in JS but Dommy
|
|
24
|
+
# uses the closest WHATWG name).
|
|
25
|
+
class URL
|
|
26
|
+
# Registry of Blob URLs created via `URL.createObjectURL(blob)`.
|
|
27
|
+
# Process-wide because the spec scopes them to the document/window
|
|
28
|
+
# lifecycle, but Dommy is a single-process test harness.
|
|
29
|
+
@blob_urls = {}
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# Create a unique blob: URL that resolves back to `blob` via
|
|
33
|
+
# `URL.__resolve_blob_url__(url)`. Returns nil for non-Blob input.
|
|
34
|
+
def create_object_url(blob)
|
|
35
|
+
return nil unless blob.is_a?(Blob)
|
|
36
|
+
|
|
37
|
+
id = "%032x" % rand(2 ** 128)
|
|
38
|
+
url = "blob:dommy/#{id}"
|
|
39
|
+
@blob_urls[url] = blob
|
|
40
|
+
url
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
alias createObjectURL create_object_url
|
|
44
|
+
|
|
45
|
+
# Revoke a previously-created blob URL. No-op for unknown URLs,
|
|
46
|
+
# matching the spec.
|
|
47
|
+
def revoke_object_url(url)
|
|
48
|
+
@blob_urls.delete(url.to_s)
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
alias revokeObjectURL revoke_object_url
|
|
53
|
+
|
|
54
|
+
# Resolve a blob: URL back to its Blob, or nil if revoked / unknown.
|
|
55
|
+
# Internal — used by fetch / XHR implementations that load blob URLs.
|
|
56
|
+
def __resolve_blob_url__(url)
|
|
57
|
+
@blob_urls[url.to_s]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Test seam: drop all registered blob URLs.
|
|
61
|
+
def __reset_blob_urls__
|
|
62
|
+
@blob_urls.clear
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
attr_reader :search_params
|
|
67
|
+
|
|
68
|
+
def initialize(input, base = nil)
|
|
69
|
+
raw = parse_with_base(input, base)
|
|
70
|
+
@uri = raw
|
|
71
|
+
@search_params = URLSearchParams.new(raw.query.to_s, owner: self)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def href
|
|
75
|
+
build_href
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def href=(value)
|
|
79
|
+
raw = parse_with_base(value.to_s, nil)
|
|
80
|
+
@uri = raw
|
|
81
|
+
@search_params.__replace__(raw.query.to_s)
|
|
82
|
+
build_href
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def protocol
|
|
86
|
+
@uri.scheme ? "#{@uri.scheme}:" : ""
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def protocol=(value)
|
|
90
|
+
s = value.to_s.sub(/:$/, "")
|
|
91
|
+
@uri.scheme = s
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def host
|
|
95
|
+
port = @uri.port
|
|
96
|
+
default = @uri.default_port
|
|
97
|
+
hostpart = @uri.host.to_s
|
|
98
|
+
return hostpart if port.nil? || port == default
|
|
99
|
+
|
|
100
|
+
"#{hostpart}:#{port}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def host=(value)
|
|
104
|
+
h, p = value.to_s.split(":", 2)
|
|
105
|
+
@uri.host = h
|
|
106
|
+
@uri.port = p.to_i if p
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def hostname
|
|
110
|
+
@uri.host.to_s
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def hostname=(value)
|
|
114
|
+
@uri.host = value.to_s
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def port
|
|
118
|
+
return "" if @uri.port.nil? || @uri.port == @uri.default_port
|
|
119
|
+
|
|
120
|
+
@uri.port.to_s
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def port=(value)
|
|
124
|
+
@uri.port = value.to_s.empty? ? nil : value.to_i
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def pathname
|
|
128
|
+
@uri.path.to_s
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def pathname=(value)
|
|
132
|
+
v = value.to_s
|
|
133
|
+
v = "/#{v}" if !v.start_with?("/") && !v.empty?
|
|
134
|
+
@uri.path = v
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def search
|
|
138
|
+
q = @search_params.to_s
|
|
139
|
+
q.empty? ? "" : "?#{q}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def search=(value)
|
|
143
|
+
q = value.to_s.sub(/^\?/, "")
|
|
144
|
+
@search_params.__replace__(q)
|
|
145
|
+
sync_uri_query
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def hash
|
|
149
|
+
f = @uri.fragment.to_s
|
|
150
|
+
f.empty? ? "" : "##{f}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def hash=(value)
|
|
154
|
+
f = value.to_s.sub(/^#/, "")
|
|
155
|
+
@uri.fragment = f.empty? ? nil : f
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def origin
|
|
159
|
+
return "null" unless @uri.scheme && @uri.host
|
|
160
|
+
|
|
161
|
+
port_part = (@uri.port && @uri.port != @uri.default_port) ? ":#{@uri.port}" : ""
|
|
162
|
+
"#{@uri.scheme}://#{@uri.host}#{port_part}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def username
|
|
166
|
+
@uri.user.to_s
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def username=(value)
|
|
170
|
+
@uri.user = value.to_s.empty? ? nil : value.to_s
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def password
|
|
174
|
+
@uri.password.to_s
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def password=(value)
|
|
178
|
+
@uri.password = value.to_s.empty? ? nil : value.to_s
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def to_s
|
|
182
|
+
href
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def to_json(*_args)
|
|
186
|
+
# match JSON.stringify(url) -> "\"<href>\""
|
|
187
|
+
href.inspect
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def __js_get__(key)
|
|
191
|
+
case key
|
|
192
|
+
when "href"
|
|
193
|
+
href
|
|
194
|
+
when "protocol"
|
|
195
|
+
protocol
|
|
196
|
+
when "host"
|
|
197
|
+
host
|
|
198
|
+
when "hostname"
|
|
199
|
+
hostname
|
|
200
|
+
when "port"
|
|
201
|
+
port
|
|
202
|
+
when "pathname"
|
|
203
|
+
pathname
|
|
204
|
+
when "search"
|
|
205
|
+
search
|
|
206
|
+
when "hash"
|
|
207
|
+
hash
|
|
208
|
+
when "origin"
|
|
209
|
+
origin
|
|
210
|
+
when "username"
|
|
211
|
+
username
|
|
212
|
+
when "password"
|
|
213
|
+
password
|
|
214
|
+
when "searchParams"
|
|
215
|
+
@search_params
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def __js_set__(key, value)
|
|
220
|
+
case key
|
|
221
|
+
when "href"
|
|
222
|
+
self.href = value
|
|
223
|
+
when "protocol"
|
|
224
|
+
self.protocol = value
|
|
225
|
+
when "host"
|
|
226
|
+
self.host = value
|
|
227
|
+
when "hostname"
|
|
228
|
+
self.hostname = value
|
|
229
|
+
when "port"
|
|
230
|
+
self.port = value
|
|
231
|
+
when "pathname"
|
|
232
|
+
self.pathname = value
|
|
233
|
+
when "search"
|
|
234
|
+
self.search = value
|
|
235
|
+
when "hash"
|
|
236
|
+
self.hash = value
|
|
237
|
+
when "username"
|
|
238
|
+
self.username = value
|
|
239
|
+
when "password"
|
|
240
|
+
self.password = value
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
nil
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def __js_call__(method, _args)
|
|
247
|
+
case method
|
|
248
|
+
when "toString", "toJSON"
|
|
249
|
+
href
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Called by URLSearchParams when it mutates; we need to keep the
|
|
254
|
+
# underlying URI's query string in sync so subsequent `href` is
|
|
255
|
+
# accurate.
|
|
256
|
+
def __notify_params_changed__
|
|
257
|
+
sync_uri_query
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
private
|
|
261
|
+
|
|
262
|
+
def parse_with_base(input, base)
|
|
263
|
+
str = input.to_s
|
|
264
|
+
uri = nil
|
|
265
|
+
if base
|
|
266
|
+
base_uri = base.is_a?(URL) ? URI.parse(base.href) : URI.parse(base.to_s)
|
|
267
|
+
uri = URI.join(base_uri, str)
|
|
268
|
+
else
|
|
269
|
+
uri = URI.parse(str)
|
|
270
|
+
raise DOMException::SyntaxError, "Invalid URL: #{str}" unless uri.scheme
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
uri
|
|
274
|
+
rescue URI::InvalidURIError => e
|
|
275
|
+
raise DOMException::SyntaxError, "Invalid URL: #{e.message}"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def build_href
|
|
279
|
+
out = +""
|
|
280
|
+
out << "#{@uri.scheme}:" if @uri.scheme
|
|
281
|
+
if @uri.host
|
|
282
|
+
out << "//"
|
|
283
|
+
if @uri.user
|
|
284
|
+
out << @uri.user
|
|
285
|
+
out << ":#{@uri.password}" if @uri.password
|
|
286
|
+
out << "@"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
out << @uri.host
|
|
290
|
+
out << ":#{@uri.port}" if @uri.port && @uri.port != @uri.default_port
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
out << @uri.path.to_s
|
|
294
|
+
out << search
|
|
295
|
+
out << hash
|
|
296
|
+
out
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def sync_uri_query
|
|
300
|
+
q = @search_params.to_s
|
|
301
|
+
@uri.query = q.empty? ? nil : q
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# `URLSearchParams` — query-string manipulation. Constructed from a
|
|
306
|
+
# raw string (`"a=1&b=2"`), an array of `[k, v]` pairs, or a Hash.
|
|
307
|
+
# Order is preserved. Values are stringified per spec.
|
|
308
|
+
class URLSearchParams
|
|
309
|
+
include Enumerable
|
|
310
|
+
|
|
311
|
+
def initialize(input = "", owner: nil)
|
|
312
|
+
@owner = owner
|
|
313
|
+
@pairs = parse(input)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def get(name)
|
|
317
|
+
pair = @pairs.find { |k, _| k == name.to_s }
|
|
318
|
+
pair && pair[1]
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def get_all(name)
|
|
322
|
+
@pairs.select { |k, _| k == name.to_s }.map { |_, v| v }
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
alias getAll get_all
|
|
326
|
+
|
|
327
|
+
def has(name)
|
|
328
|
+
@pairs.any? { |k, _| k == name.to_s }
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
alias has? has
|
|
332
|
+
|
|
333
|
+
def set(name, value)
|
|
334
|
+
key = name.to_s
|
|
335
|
+
first_done = false
|
|
336
|
+
@pairs = @pairs.reject do |k, _|
|
|
337
|
+
next false unless k == key
|
|
338
|
+
|
|
339
|
+
if first_done
|
|
340
|
+
true
|
|
341
|
+
else
|
|
342
|
+
first_done = true
|
|
343
|
+
false
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
@pairs.map! { |pair| pair[0] == key ? [key, value.to_s] : pair }
|
|
348
|
+
@pairs << [key, value.to_s] unless first_done
|
|
349
|
+
notify
|
|
350
|
+
nil
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def append(name, value)
|
|
354
|
+
@pairs << [name.to_s, value.to_s]
|
|
355
|
+
notify
|
|
356
|
+
nil
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def delete(name, value = nil)
|
|
360
|
+
key = name.to_s
|
|
361
|
+
if value.nil?
|
|
362
|
+
@pairs.reject! { |k, _| k == key }
|
|
363
|
+
else
|
|
364
|
+
v = value.to_s
|
|
365
|
+
@pairs.reject! { |k, vv| k == key && vv == v }
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
notify
|
|
369
|
+
nil
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def sort
|
|
373
|
+
@pairs.sort_by! { |k, _| k }
|
|
374
|
+
notify
|
|
375
|
+
nil
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def size
|
|
379
|
+
@pairs.length
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
alias length size
|
|
383
|
+
|
|
384
|
+
def each(&block)
|
|
385
|
+
@pairs.each(&block)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def keys
|
|
389
|
+
@pairs.map { |k, _| k }
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def values
|
|
393
|
+
@pairs.map { |_, v| v }
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def entries
|
|
397
|
+
@pairs.dup
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def for_each(&block)
|
|
401
|
+
@pairs.each { |k, v| block.call(v, k, self) }
|
|
402
|
+
nil
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
alias forEach for_each
|
|
406
|
+
|
|
407
|
+
def to_s
|
|
408
|
+
@pairs.map { |k, v| "#{encode(k)}=#{encode(v)}" }.join("&")
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def __replace__(query_string)
|
|
412
|
+
@pairs = parse(query_string)
|
|
413
|
+
nil
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def __js_get__(key)
|
|
417
|
+
case key
|
|
418
|
+
when "size", "length"
|
|
419
|
+
size
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def __js_call__(method, args)
|
|
424
|
+
case method
|
|
425
|
+
when "get"
|
|
426
|
+
get(args[0])
|
|
427
|
+
when "getAll"
|
|
428
|
+
get_all(args[0])
|
|
429
|
+
when "has"
|
|
430
|
+
has(args[0])
|
|
431
|
+
when "set"
|
|
432
|
+
set(args[0], args[1])
|
|
433
|
+
when "append"
|
|
434
|
+
append(args[0], args[1])
|
|
435
|
+
when "delete"
|
|
436
|
+
delete(args[0], args[1])
|
|
437
|
+
when "sort"
|
|
438
|
+
sort
|
|
439
|
+
when "toString"
|
|
440
|
+
to_s
|
|
441
|
+
when "forEach"
|
|
442
|
+
for_each(&args[0])
|
|
443
|
+
when "keys"
|
|
444
|
+
keys
|
|
445
|
+
when "values"
|
|
446
|
+
values
|
|
447
|
+
when "entries"
|
|
448
|
+
entries
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
private
|
|
453
|
+
|
|
454
|
+
def parse(input)
|
|
455
|
+
case input
|
|
456
|
+
when Array
|
|
457
|
+
input.map { |k, v| [k.to_s, v.to_s] }
|
|
458
|
+
when Hash
|
|
459
|
+
input.map { |k, v| [k.to_s, v.to_s] }
|
|
460
|
+
else
|
|
461
|
+
s = input.to_s.sub(/^\?/, "")
|
|
462
|
+
return [] if s.empty?
|
|
463
|
+
|
|
464
|
+
s.split("&").map do |pair|
|
|
465
|
+
k, v = pair.split("=", 2)
|
|
466
|
+
[CGI.unescape(k.to_s), CGI.unescape(v.to_s)]
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def encode(str)
|
|
472
|
+
CGI.escape(str.to_s)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def notify
|
|
476
|
+
@owner&.__notify_params_changed__
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
end
|
data/lib/dommy/world.rb
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "erb"
|
|
5
|
+
|
|
6
|
+
# Dommy — a happy-dom-style DOM polyfill in pure Ruby. Backbone is
|
|
7
|
+
# Nokogiri::HTML5 plus a small scheduler/event-loop layer.
|
|
8
|
+
#
|
|
9
|
+
# Two views into the same objects:
|
|
10
|
+
# - Public Ruby API (snake_case methods like `text_content`,
|
|
11
|
+
# `append_child`) for CRuby users writing tests against rendered
|
|
12
|
+
# HTML.
|
|
13
|
+
# - `__js_get__` / `__js_set__` / `__js_call__` / `__js_new__`
|
|
14
|
+
# bridge protocol for JS bridge embedders — dispatches into the
|
|
15
|
+
# same underlying Ruby methods.
|
|
16
|
+
module Dommy
|
|
17
|
+
# The browser global. `JS.global` from inside wasm resolves to this.
|
|
18
|
+
# Property access (`JS.global[:document]`, `JS.global[:console]`) is
|
|
19
|
+
# routed through `#__js_get__`. Method calls (`JS.global.call(:foo)`)
|
|
20
|
+
# are routed through `#__js_call__`.
|
|
21
|
+
class Window
|
|
22
|
+
include EventTarget
|
|
23
|
+
|
|
24
|
+
attr_reader :document, :scheduler, :location, :globals, :custom_elements, :navigator
|
|
25
|
+
|
|
26
|
+
def initialize(host = nil, nokogiri_doc: nil)
|
|
27
|
+
@host = host
|
|
28
|
+
@scheduler = Scheduler.new
|
|
29
|
+
@event_ctor = Bridge::Constructor.new { |args| Event.new(args[0], args[1]) }
|
|
30
|
+
@custom_event_ctor = Bridge::Constructor.new { |args| CustomEvent.new(args[0], args[1]) }
|
|
31
|
+
@mouse_event_ctor = Bridge::Constructor.new { |args| MouseEvent.new(args[0], args[1]) }
|
|
32
|
+
@keyboard_event_ctor = Bridge::Constructor.new { |args| KeyboardEvent.new(args[0], args[1]) }
|
|
33
|
+
@event_target_ctor = Bridge::Constructor.new { |_args| StandaloneEventTarget.new }
|
|
34
|
+
@error_ctor = Bridge::Constructor.new { |args| ErrorValue.new(args[0]) }
|
|
35
|
+
@promise_ctor = Bridge::PromiseConstructor.new(self)
|
|
36
|
+
@mutation_observer_ctor = Bridge::Constructor.new { |args| MutationObserver.new(self, args[0]) }
|
|
37
|
+
@abort_controller_ctor = Bridge::Constructor.new { |_args| AbortController.new }
|
|
38
|
+
@blob_ctor = Bridge::Constructor.new { |args| Blob.new(args[0] || [], args[1] || {}) }
|
|
39
|
+
@file_ctor = Bridge::Constructor.new { |args| File.new(args[0] || [], args[1].to_s, args[2] || {}) }
|
|
40
|
+
@file_list_ctor = Bridge::Constructor.new { |args| FileList.new(args[0] || []) }
|
|
41
|
+
@data_transfer_ctor = Bridge::Constructor.new { |args|
|
|
42
|
+
opts = args[0] || {}
|
|
43
|
+
DataTransfer.new(
|
|
44
|
+
files: opts["files"] || opts[:files] || [],
|
|
45
|
+
data: opts["data"] || opts[:data] || {}
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
@drag_event_ctor = Bridge::Constructor.new { |args| DragEvent.new(args[0], args[1]) }
|
|
49
|
+
@local_storage = Storage.new
|
|
50
|
+
@session_storage = Storage.new
|
|
51
|
+
@location = Location.new(self)
|
|
52
|
+
@history = History.new(self, @location)
|
|
53
|
+
@url_ctor = Bridge::Constructor.new { |args| Url.new(args[0], args[1]) }
|
|
54
|
+
@url_ctor.define_class_method("createObjectURL") { |args| URL.create_object_url(args[0]) }
|
|
55
|
+
@url_ctor.define_class_method("revokeObjectURL") { |args| URL.revoke_object_url(args[0]) }
|
|
56
|
+
# `JS.global[:__some_key__] = ...` from user code lands here.
|
|
57
|
+
# Test code uses this for stub installation (e.g. a custom
|
|
58
|
+
# `__fetch_stub__`); production code stays on the typed
|
|
59
|
+
# accessors above. We keep it last in the read fallback to
|
|
60
|
+
# avoid shadowing intentional getters.
|
|
61
|
+
@globals = {}
|
|
62
|
+
@document = Document.new(host, nokogiri_doc: nokogiri_doc)
|
|
63
|
+
@document.default_view = self
|
|
64
|
+
@custom_elements = CustomElementRegistry.new(self)
|
|
65
|
+
@navigator = Navigator.new(self)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Bridge protocol: respond to a JS-style property read by name.
|
|
69
|
+
# Returns either a Ruby primitive (Integer / String / true / false /
|
|
70
|
+
# nil), a Hash/Array (for JS object/array literals), or a Dom::*
|
|
71
|
+
# instance for live DOM/BOM objects.
|
|
72
|
+
#
|
|
73
|
+
# Anything outside the surface we've explicitly polyfilled returns
|
|
74
|
+
# nil (= JS undefined). Spec failures here are the signal to widen
|
|
75
|
+
# the surface in a future session.
|
|
76
|
+
def __js_get__(key)
|
|
77
|
+
case key
|
|
78
|
+
when "document"
|
|
79
|
+
@document
|
|
80
|
+
when "Event"
|
|
81
|
+
@event_ctor
|
|
82
|
+
when "CustomEvent"
|
|
83
|
+
@custom_event_ctor
|
|
84
|
+
when "MouseEvent"
|
|
85
|
+
@mouse_event_ctor
|
|
86
|
+
when "KeyboardEvent"
|
|
87
|
+
@keyboard_event_ctor
|
|
88
|
+
when "EventTarget"
|
|
89
|
+
@event_target_ctor
|
|
90
|
+
when "Error"
|
|
91
|
+
@error_ctor
|
|
92
|
+
when "Promise"
|
|
93
|
+
@promise_ctor
|
|
94
|
+
when "MutationObserver"
|
|
95
|
+
@mutation_observer_ctor
|
|
96
|
+
when "AbortController"
|
|
97
|
+
@abort_controller_ctor
|
|
98
|
+
when "Blob"
|
|
99
|
+
@blob_ctor
|
|
100
|
+
when "File"
|
|
101
|
+
@file_ctor
|
|
102
|
+
when "FileList"
|
|
103
|
+
@file_list_ctor
|
|
104
|
+
when "DataTransfer"
|
|
105
|
+
@data_transfer_ctor
|
|
106
|
+
when "DragEvent"
|
|
107
|
+
@drag_event_ctor
|
|
108
|
+
# handled by Symbol sentinel
|
|
109
|
+
when "console"
|
|
110
|
+
:console
|
|
111
|
+
# likewise
|
|
112
|
+
when "Object"
|
|
113
|
+
:object_ctor
|
|
114
|
+
when "Array"
|
|
115
|
+
:array_ctor
|
|
116
|
+
when "JSON"
|
|
117
|
+
:json_ctor
|
|
118
|
+
when "performance"
|
|
119
|
+
{"now" => @scheduler.now_ms.to_f}
|
|
120
|
+
when "localStorage"
|
|
121
|
+
@local_storage
|
|
122
|
+
when "sessionStorage"
|
|
123
|
+
@session_storage
|
|
124
|
+
when "location"
|
|
125
|
+
@location
|
|
126
|
+
when "history"
|
|
127
|
+
@history
|
|
128
|
+
when "URL"
|
|
129
|
+
@url_ctor
|
|
130
|
+
when "fetch"
|
|
131
|
+
FetchFn.new(self)
|
|
132
|
+
when "customElements"
|
|
133
|
+
@custom_elements
|
|
134
|
+
when "navigator"
|
|
135
|
+
@navigator
|
|
136
|
+
else
|
|
137
|
+
@globals[key]
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def __js_set__(key, value)
|
|
142
|
+
# Stash arbitrary keys for later reads (e.g.
|
|
143
|
+
# `JS.global[:__fetchy_stub__] = map`).
|
|
144
|
+
@globals[key] = value
|
|
145
|
+
# The Fetchy spec's `install_fetch_stub` resets `__fetch_count__`
|
|
146
|
+
# to 0 inside its JS installer (`globalThis.__fetch_count__ = 0;
|
|
147
|
+
# globalThis.fetch = ...`). Our polyfill ignores raw JS, so we
|
|
148
|
+
# piggy-back on the stub assignment to perform the same reset
|
|
149
|
+
# — without it the count accumulates across tests in one VM run.
|
|
150
|
+
@globals["__fetch_count__"] = 0 if %w[__fetchy_stub__ __resource_fetch_stub__ __inject_fetch_stub__].include?(key)
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def __js_call__(method, args)
|
|
155
|
+
case method
|
|
156
|
+
when "fetch"
|
|
157
|
+
FetchFn.new(self).__js_call__("call", args)
|
|
158
|
+
when "encodeURIComponent"
|
|
159
|
+
# JS spec encoding: percent-encode anything except
|
|
160
|
+
# `A-Za-z0-9 - _ . ! ~ * ' ( )`. Ruby's `CGI.escape` uses
|
|
161
|
+
# `+` for space; ERB::Util.url_encode matches JS behavior.
|
|
162
|
+
ERB::Util.url_encode(args[0].to_s)
|
|
163
|
+
when "decodeURIComponent"
|
|
164
|
+
CGI.unescape(args[0].to_s)
|
|
165
|
+
when "addEventListener"
|
|
166
|
+
add_event_listener(args[0], args[1], args[2])
|
|
167
|
+
when "removeEventListener"
|
|
168
|
+
remove_event_listener(args[0], args[1])
|
|
169
|
+
when "dispatchEvent"
|
|
170
|
+
dispatch_event(args[0])
|
|
171
|
+
when "setTimeout"
|
|
172
|
+
@scheduler.set_timeout(args[0], args[1] || 0)
|
|
173
|
+
when "clearTimeout"
|
|
174
|
+
@scheduler.clear_timeout(args[0])
|
|
175
|
+
when "setInterval"
|
|
176
|
+
@scheduler.set_interval(args[0], args[1] || 0)
|
|
177
|
+
when "clearInterval"
|
|
178
|
+
@scheduler.clear_interval(args[0])
|
|
179
|
+
when "requestAnimationFrame"
|
|
180
|
+
@scheduler.request_animation_frame(args[0])
|
|
181
|
+
when "cancelAnimationFrame"
|
|
182
|
+
@scheduler.cancel_animation_frame(args[0])
|
|
183
|
+
when "queueMicrotask"
|
|
184
|
+
@scheduler.queue_microtask(args[0])
|
|
185
|
+
else
|
|
186
|
+
# Additional window-level methods (fetch, location, history,
|
|
187
|
+
# Promise, MutationObserver, etc.) arrive in later sessions.
|
|
188
|
+
nil
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def __event_parent__
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Called by History#go and Location.href= to fire popstate /
|
|
197
|
+
# hashchange events. Listeners registered on the Window via
|
|
198
|
+
# `addEventListener("popstate"|"hashchange", cb)` receive them.
|
|
199
|
+
def fire_popstate(state)
|
|
200
|
+
event = CustomEvent.new("popstate", "detail" => state)
|
|
201
|
+
dispatch_event(event)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def fire_hashchange(old_hash, new_hash)
|
|
205
|
+
event = CustomEvent.new("hashchange", "detail" => {"oldURL" => old_hash, "newURL" => new_hash})
|
|
206
|
+
dispatch_event(event)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|