dommy 0.9.0 → 0.10.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 +4 -4
- data/lib/dommy/attr.rb +46 -8
- data/lib/dommy/backend/makiri_adapter.rb +34 -0
- data/lib/dommy/backend.rb +29 -0
- data/lib/dommy/blob.rb +48 -6
- data/lib/dommy/browser.rb +239 -36
- data/lib/dommy/callable_invoker.rb +6 -2
- data/lib/dommy/document.rb +525 -57
- data/lib/dommy/element.rb +479 -239
- data/lib/dommy/event.rb +296 -15
- data/lib/dommy/fetch.rb +431 -25
- data/lib/dommy/history.rb +24 -3
- data/lib/dommy/html_collection.rb +145 -21
- data/lib/dommy/html_elements.rb +1675 -266
- data/lib/dommy/interaction/driver.rb +155 -14
- data/lib/dommy/interaction/event_synthesis.rb +115 -7
- data/lib/dommy/interaction/field_interactor.rb +60 -0
- data/lib/dommy/internal/child_node.rb +199 -0
- data/lib/dommy/internal/css/cascade.rb +44 -2
- data/lib/dommy/internal/global_functions.rb +23 -10
- data/lib/dommy/internal/mutation_coordinator.rb +27 -7
- data/lib/dommy/internal/node_wrapper_cache.rb +56 -14
- data/lib/dommy/internal/observer_manager.rb +6 -0
- data/lib/dommy/internal/observer_matcher.rb +6 -8
- data/lib/dommy/internal/parent_node.rb +37 -73
- data/lib/dommy/internal/selector_matcher.rb +89 -0
- data/lib/dommy/internal/selector_parser.rb +44 -7
- data/lib/dommy/js/custom_element_bridge.rb +13 -2
- data/lib/dommy/js/dom_interfaces.rb +19 -4
- data/lib/dommy/js/host_bridge.rb +21 -0
- data/lib/dommy/js/host_runtime.js +926 -64
- data/lib/dommy/js/script_boot.rb +80 -0
- data/lib/dommy/location.rb +76 -13
- data/lib/dommy/mutation_observer.rb +3 -4
- data/lib/dommy/navigation.rb +263 -0
- data/lib/dommy/node.rb +180 -27
- data/lib/dommy/range.rb +15 -3
- data/lib/dommy/scheduler.rb +16 -0
- data/lib/dommy/shadow_root.rb +33 -0
- data/lib/dommy/storage.rb +61 -10
- data/lib/dommy/tree_walker.rb +18 -36
- data/lib/dommy/url.rb +4 -2
- data/lib/dommy/version.rb +1 -1
- data/lib/dommy/web_socket.rb +45 -2
- data/lib/dommy/window.rb +126 -7
- data/lib/dommy/xml_http_request.rb +30 -4
- data/lib/dommy.rb +2 -0
- metadata +6 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 52933caebd00a8d1b7dcfa14421116be45617e04d53ee6f8d8771c5fc5ccefdc
|
|
4
|
+
data.tar.gz: da2372f52dac931a6e7ad4316ec4cb15efb0effa799229eae7bde1435b8aa3c8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e05358f558f5e84fe6da7e7f705dbdc5bdd178646ef71a3ee9311a9d05893320cda1c0e6ca3596aa05e7e389e9112e96c8d44b640b7dea55480066af40e65eb7
|
|
7
|
+
data.tar.gz: ac0dea6fecd2fe7624f71cdef6f9eab786be60a584a4307d0fcac66540002de7cc1cc52812a638bfc8bb35afb53dd836ed7ebb2b007fee8fabfd172e62e8850a
|
data/lib/dommy/attr.rb
CHANGED
|
@@ -22,9 +22,12 @@ module Dommy
|
|
|
22
22
|
nil
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
def initialize(name, owner: nil, value: "", namespace_uri: nil, prefix: nil, local_name: nil)
|
|
25
|
+
def initialize(name, owner: nil, value: "", namespace_uri: nil, prefix: nil, local_name: nil, document: nil)
|
|
26
26
|
qname = name.to_s
|
|
27
27
|
@owner = owner
|
|
28
|
+
# The node document (for baseURI/ownerDocument when detached from an
|
|
29
|
+
# element). Owned attrs derive it from their owner instead.
|
|
30
|
+
@document = document
|
|
28
31
|
@detached_value = value.to_s
|
|
29
32
|
if namespace_uri && !namespace_uri.to_s.empty?
|
|
30
33
|
# Namespaced attributes preserve case and carry prefix / localName.
|
|
@@ -50,6 +53,22 @@ module Dommy
|
|
|
50
53
|
@owner
|
|
51
54
|
end
|
|
52
55
|
|
|
56
|
+
# Node.baseURI — the node document's base URL. Derived from the owner
|
|
57
|
+
# element when attached, else the document the attr was created in.
|
|
58
|
+
def base_uri
|
|
59
|
+
return @owner.base_uri if @owner.respond_to?(:base_uri)
|
|
60
|
+
|
|
61
|
+
@document&.base_uri
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Node.ownerDocument — the owner element's current document when attached (so
|
|
65
|
+
# it follows the element across adoptNode), else the creation document.
|
|
66
|
+
def owner_document
|
|
67
|
+
return @owner.document if @owner.respond_to?(:document)
|
|
68
|
+
|
|
69
|
+
@document
|
|
70
|
+
end
|
|
71
|
+
|
|
53
72
|
def value
|
|
54
73
|
if @owner
|
|
55
74
|
if @namespace_uri
|
|
@@ -89,6 +108,8 @@ module Dommy
|
|
|
89
108
|
value
|
|
90
109
|
when "ownerElement"
|
|
91
110
|
@owner
|
|
111
|
+
when "ownerDocument"
|
|
112
|
+
owner_document
|
|
92
113
|
when "localName"
|
|
93
114
|
@local_name
|
|
94
115
|
when "namespaceURI"
|
|
@@ -97,6 +118,8 @@ module Dommy
|
|
|
97
118
|
@prefix
|
|
98
119
|
when "nodeType"
|
|
99
120
|
2
|
|
121
|
+
when "baseURI"
|
|
122
|
+
base_uri
|
|
100
123
|
when "specified"
|
|
101
124
|
# Legacy/useless attribute — always true (WHATWG DOM).
|
|
102
125
|
true
|
|
@@ -119,12 +142,20 @@ module Dommy
|
|
|
119
142
|
include Bridge::Methods
|
|
120
143
|
js_methods %w[cloneNode isSameNode getRootNode hasChildNodes normalize compareDocumentPosition
|
|
121
144
|
appendChild insertBefore removeChild replaceChild
|
|
145
|
+
lookupNamespaceURI lookupPrefix isDefaultNamespace
|
|
122
146
|
addEventListener removeEventListener dispatchEvent]
|
|
123
147
|
def __js_call__(method, args)
|
|
124
148
|
case method
|
|
149
|
+
when "lookupNamespaceURI"
|
|
150
|
+
lookup_namespace_uri(args[0])
|
|
151
|
+
when "lookupPrefix"
|
|
152
|
+
lookup_prefix(args[0])
|
|
153
|
+
when "isDefaultNamespace"
|
|
154
|
+
is_default_namespace(args[0])
|
|
125
155
|
when "cloneNode"
|
|
126
156
|
Attr.new(@name, owner: nil, value: value,
|
|
127
|
-
namespace_uri: @namespace_uri, prefix: @prefix, local_name: @local_name
|
|
157
|
+
namespace_uri: @namespace_uri, prefix: @prefix, local_name: @local_name,
|
|
158
|
+
document: @document || (@owner.respond_to?(:document) ? @owner.document : nil))
|
|
128
159
|
when "isSameNode"
|
|
129
160
|
is_same_node(args[0])
|
|
130
161
|
when "getRootNode"
|
|
@@ -209,7 +240,9 @@ module Dommy
|
|
|
209
240
|
end
|
|
210
241
|
|
|
211
242
|
def get_named_item(name)
|
|
212
|
-
|
|
243
|
+
# getNamedItem / getAttribute lowercase the qualified name only for an HTML
|
|
244
|
+
# element in an HTML document; other elements match case-sensitively.
|
|
245
|
+
key = @element.__internal_normalize_attr_key__(name)
|
|
213
246
|
node = Backend.attribute_nodes(@element.__dommy_backend_node__).find do |a|
|
|
214
247
|
Backend.attribute_ns_info(a)[:qualified_name] == key
|
|
215
248
|
end
|
|
@@ -221,7 +254,7 @@ module Dommy
|
|
|
221
254
|
end
|
|
222
255
|
|
|
223
256
|
def remove_named_item(name)
|
|
224
|
-
key = name
|
|
257
|
+
key = @element.__internal_normalize_attr_key__(name)
|
|
225
258
|
node = Backend.attribute_nodes(@element.__dommy_backend_node__).find do |a|
|
|
226
259
|
Backend.attribute_ns_info(a)[:qualified_name] == key
|
|
227
260
|
end
|
|
@@ -329,12 +362,17 @@ module Dommy
|
|
|
329
362
|
end
|
|
330
363
|
end
|
|
331
364
|
|
|
332
|
-
# WebIDL "supported property names" for NamedNodeMap:
|
|
333
|
-
#
|
|
365
|
+
# WebIDL "supported property names" for NamedNodeMap: each attribute's
|
|
366
|
+
# qualified name, in order with duplicates omitted (the indexed names are
|
|
367
|
+
# reflected separately). For an HTML element in an HTML document, names
|
|
368
|
+
# containing an ASCII upper alpha are excluded (they can't be reached by the
|
|
369
|
+
# case-insensitive named getter).
|
|
334
370
|
def __js_named_props__
|
|
335
|
-
Backend.attribute_nodes(@element.__dommy_backend_node__).map do |a|
|
|
371
|
+
names = Backend.attribute_nodes(@element.__dommy_backend_node__).map do |a|
|
|
336
372
|
Backend.attribute_ns_info(a)[:qualified_name]
|
|
337
|
-
end
|
|
373
|
+
end.uniq
|
|
374
|
+
names.reject! { |n| n.match?(/[A-Z]/) } unless @element.__internal_case_sensitive_attribute_names__?
|
|
375
|
+
names
|
|
338
376
|
end
|
|
339
377
|
|
|
340
378
|
include Bridge::Methods
|
|
@@ -21,6 +21,7 @@ module Dommy
|
|
|
21
21
|
CDATASection = ::Makiri::CDATASection
|
|
22
22
|
ProcessingInstruction = ::Makiri::ProcessingInstruction
|
|
23
23
|
DocumentFragment = ::Makiri::DocumentFragment
|
|
24
|
+
DocumentType = ::Makiri::DocumentType
|
|
24
25
|
Node = ::Makiri::Node
|
|
25
26
|
|
|
26
27
|
# A minimal namespace wrapper exposing the same `href` API that Nokogiri's
|
|
@@ -168,6 +169,39 @@ module Dommy
|
|
|
168
169
|
doc.create_element(name)
|
|
169
170
|
end
|
|
170
171
|
|
|
172
|
+
# An XML-backed document rejects a qualified name that DOM allows (e.g.
|
|
173
|
+
# "f}oo" — an invalid char in the local part), so createElementNS uses
|
|
174
|
+
# Makiri's loose creator, which builds it verbatim (case/prefix preserved).
|
|
175
|
+
# nil for a non-XML backend → the caller uses the strict #create_element.
|
|
176
|
+
def create_element_loose(qualified_name, prefix, local, namespace, doc)
|
|
177
|
+
return nil unless doc.is_a?(::Makiri::XML::Document) && doc.respond_to?(:create_loose_dom_element)
|
|
178
|
+
|
|
179
|
+
doc.create_loose_dom_element(qualified_name, prefix, local, namespace)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# A detached DocumentType node owned by `doc`, for
|
|
183
|
+
# DOMImplementation.createDocumentType. Only the HTML backend ships the
|
|
184
|
+
# factory (`create_document_type`); nil signals the caller to fall back to a
|
|
185
|
+
# synthetic (non-tree) DocumentType. Raises ArgumentError for a name the
|
|
186
|
+
# factory rejects.
|
|
187
|
+
def create_document_type(name, public_id, system_id, doc)
|
|
188
|
+
return nil unless doc.respond_to?(:create_document_type)
|
|
189
|
+
|
|
190
|
+
doc.create_document_type(name.to_s, public_id.to_s, system_id.to_s)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The parsed document's DocumentType node (`<!DOCTYPE …>`), or nil when the
|
|
194
|
+
# document declares none.
|
|
195
|
+
def internal_subset(doc)
|
|
196
|
+
doc.respond_to?(:internal_subset) ? doc.internal_subset : nil
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# The backend class for a DocumentType node, so the wrapper routes it to
|
|
200
|
+
# Dommy::DocumentType (node-backed).
|
|
201
|
+
def document_type_class
|
|
202
|
+
::Makiri::DocumentType
|
|
203
|
+
end
|
|
204
|
+
|
|
171
205
|
def create_text(content, doc)
|
|
172
206
|
doc.create_text_node(content)
|
|
173
207
|
end
|
data/lib/dommy/backend.rb
CHANGED
|
@@ -130,6 +130,35 @@ module Dommy
|
|
|
130
130
|
current.create_element(name, doc)
|
|
131
131
|
end
|
|
132
132
|
|
|
133
|
+
# Create a namespaced element permitting a DOM-valid qualified name that a
|
|
134
|
+
# strict XML backend would reject (an internal invalid char like "f}oo"),
|
|
135
|
+
# preserving case/prefix. Returns nil when the backend has no loose path
|
|
136
|
+
# (fall back to #create_element); raises ArgumentError for a genuinely
|
|
137
|
+
# invalid name (the caller maps it to InvalidCharacterError).
|
|
138
|
+
def create_element_loose(qualified_name, prefix, local, namespace, doc)
|
|
139
|
+
current.create_element_loose(qualified_name, prefix, local, namespace, doc)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# A detached DocumentType node owned by `doc` (for
|
|
143
|
+
# DOMImplementation.createDocumentType). Returns nil when the backend has no
|
|
144
|
+
# doctype factory (the caller falls back to a synthetic DocumentType); raises
|
|
145
|
+
# ArgumentError for a name the factory rejects (the caller then also falls
|
|
146
|
+
# back, since createDocumentType is permissive).
|
|
147
|
+
def create_document_type(name, public_id, system_id, doc)
|
|
148
|
+
current.respond_to?(:create_document_type) ? current.create_document_type(name, public_id, system_id, doc) : nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The parsed document's DocumentType node, or nil when it declares none.
|
|
152
|
+
def internal_subset(doc)
|
|
153
|
+
current.respond_to?(:internal_subset) ? current.internal_subset(doc) : nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The backend class for a DocumentType node (nil if unsupported), so the
|
|
157
|
+
# wrapper cache can route it to Dommy::DocumentType.
|
|
158
|
+
def document_type_class
|
|
159
|
+
current.respond_to?(:document_type_class) ? current.document_type_class : nil
|
|
160
|
+
end
|
|
161
|
+
|
|
133
162
|
def create_text(content, doc)
|
|
134
163
|
current.create_text(content, doc)
|
|
135
164
|
end
|
data/lib/dommy/blob.rb
CHANGED
|
@@ -21,19 +21,26 @@ module Dommy
|
|
|
21
21
|
# Promises (they need a scheduler). A window-less Blob falls back to a
|
|
22
22
|
# synchronous result, which `await` still handles.
|
|
23
23
|
def initialize(parts = [], options = {}, window = nil)
|
|
24
|
+
# WebIDL: an omitted / `undefined` blobParts argument defaults to an empty
|
|
25
|
+
# sequence (`new Blob()` / `new Blob(undefined)` is a zero-length Blob), so
|
|
26
|
+
# it must not be coerced to the string "undefined".
|
|
27
|
+
parts = [] if parts.nil? || (defined?(Bridge::UNDEFINED) && parts.equal?(Bridge::UNDEFINED))
|
|
24
28
|
parts = [parts] unless parts.is_a?(Array)
|
|
25
29
|
@data = collect_bytes(parts)
|
|
26
30
|
@size = @data.bytesize
|
|
27
|
-
raw_type = options["type"] || options[:type] || ""
|
|
28
|
-
|
|
31
|
+
raw_type = (options["type"] || options[:type] || "").to_s
|
|
32
|
+
# A type string with any code point outside U+0020..U+007E is discarded
|
|
33
|
+
# (→ ""); otherwise it is ASCII-lowercased (FileAPI "parse a MIME type"
|
|
34
|
+
# gate, applied to both the constructor and slice's contentType).
|
|
35
|
+
@type = raw_type.match?(/[^ -~]/) ? "" : raw_type.downcase
|
|
29
36
|
@window = window
|
|
30
37
|
end
|
|
31
38
|
|
|
32
39
|
# Return a new Blob over a byte range of this one.
|
|
33
40
|
# Negative indices are treated as offsets from the end (per spec).
|
|
34
41
|
def slice(start = 0, last = @size, content_type = "")
|
|
35
|
-
s = clamp_index(start
|
|
36
|
-
e = clamp_index(last
|
|
42
|
+
s = clamp_index(clamp_long_long(start), @size)
|
|
43
|
+
e = clamp_index(clamp_long_long(last), @size)
|
|
37
44
|
e = s if e < s
|
|
38
45
|
Blob.new([@data.byteslice(s, e - s) || ""], {"type" => content_type.to_s}, @window)
|
|
39
46
|
end
|
|
@@ -51,6 +58,13 @@ module Dommy
|
|
|
51
58
|
Bridge::ArrayBuffer.new(@data.bytes)
|
|
52
59
|
end
|
|
53
60
|
|
|
61
|
+
# Read the bytes as a Uint8Array (the spec return type). The DOM spec returns
|
|
62
|
+
# a Promise<Uint8Array>; Dommy is synchronous. `Bridge::Bytes` crosses the JS
|
|
63
|
+
# boundary as a Uint8Array (vs `ArrayBuffer` for #array_buffer).
|
|
64
|
+
def bytes
|
|
65
|
+
Bridge::Bytes.new(@data.bytes)
|
|
66
|
+
end
|
|
67
|
+
|
|
54
68
|
# Raw binary bytes (Ruby ASCII-8BIT string). Used by FormData /
|
|
55
69
|
# fetch when serializing multipart bodies.
|
|
56
70
|
def __dommy_bytes__
|
|
@@ -71,17 +85,30 @@ module Dommy
|
|
|
71
85
|
# Methods routed through __js_call__ (keep in sync with its when-arms).
|
|
72
86
|
# File < Blob inherits these (it adds only properties).
|
|
73
87
|
include Bridge::Methods
|
|
74
|
-
js_methods %w[slice text arrayBuffer]
|
|
88
|
+
js_methods %w[slice text arrayBuffer bytes]
|
|
75
89
|
def __js_call__(method, args)
|
|
76
90
|
case method
|
|
77
91
|
when "slice"
|
|
78
|
-
|
|
92
|
+
# An omitted / `undefined` start|end uses the default (0 / size); map
|
|
93
|
+
# UNDEFINED to nil so the `|| default` fallbacks apply (a bare UNDEFINED
|
|
94
|
+
# is truthy and has no #to_i). contentType is a plain DOMString: omitted
|
|
95
|
+
# / undefined → "" (default), but an explicit JS null coerces to "null".
|
|
96
|
+
a = args.map { |v| v.equal?(Bridge::UNDEFINED) ? nil : v }
|
|
97
|
+
ctype = if args.length < 3 || args[2].equal?(Bridge::UNDEFINED)
|
|
98
|
+
""
|
|
99
|
+
else
|
|
100
|
+
args[2].nil? ? "null" : args[2].to_s
|
|
101
|
+
end
|
|
102
|
+
slice(a[0] || 0, a[1] || @size, ctype)
|
|
79
103
|
when "text"
|
|
80
104
|
# WHATWG: Blob.text() returns a Promise<string>.
|
|
81
105
|
promise_or_value(text)
|
|
82
106
|
when "arrayBuffer"
|
|
83
107
|
# WHATWG: Blob.arrayBuffer() returns a Promise<ArrayBuffer>.
|
|
84
108
|
promise_or_value(array_buffer)
|
|
109
|
+
when "bytes"
|
|
110
|
+
# WHATWG: Blob.bytes() returns a Promise<Uint8Array>.
|
|
111
|
+
promise_or_value(bytes)
|
|
85
112
|
end
|
|
86
113
|
end
|
|
87
114
|
|
|
@@ -115,6 +142,21 @@ module Dommy
|
|
|
115
142
|
idx = length + idx if idx.negative?
|
|
116
143
|
idx.clamp(0, length)
|
|
117
144
|
end
|
|
145
|
+
|
|
146
|
+
# WebIDL `[Clamp] long long` conversion of a slice bound: a fractional value
|
|
147
|
+
# rounds to the nearest integer, ties to even (banker's rounding) — so
|
|
148
|
+
# `slice(1.5)` starts at 2 and `slice(3.5)` at 4, per the [Clamp] extended
|
|
149
|
+
# attribute on Blob.slice.
|
|
150
|
+
def clamp_long_long(value)
|
|
151
|
+
return value if value.is_a?(Integer)
|
|
152
|
+
|
|
153
|
+
f = value.to_f
|
|
154
|
+
return 0 if f.nan?
|
|
155
|
+
|
|
156
|
+
f.round(half: :even)
|
|
157
|
+
rescue StandardError
|
|
158
|
+
0
|
|
159
|
+
end
|
|
118
160
|
end
|
|
119
161
|
|
|
120
162
|
# `File` — Blob with a filename and an optional last-modified
|
data/lib/dommy/browser.rb
CHANGED
|
@@ -56,47 +56,45 @@ module Dommy
|
|
|
56
56
|
end
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
# Start a navigable session by fetching the initial document from
|
|
60
|
+
# `resources`, rather than passing literal HTML. Links / forms / location
|
|
61
|
+
# then perform real cross-document navigation (fetch → replace Window + JS
|
|
62
|
+
# realm), with the browser handle (history, resources, error log) surviving.
|
|
63
|
+
# Dommy::Browser.visit("http://localhost/", resources: my_resources)
|
|
64
|
+
def self.visit(url, resources:, **opts)
|
|
65
|
+
browser = new("<!doctype html><html><head></head><body></body></html>",
|
|
66
|
+
url: "about:blank", resources: resources, navigable: true, **opts)
|
|
67
|
+
browser.visit(url, replace: true)
|
|
68
|
+
browser
|
|
69
|
+
end
|
|
70
|
+
|
|
59
71
|
def initialize(html, url: "http://localhost/", resources: nil, execute_scripts: true, strict: true, settle: true,
|
|
60
|
-
wasm_memory_shim: false, backend: nil)
|
|
72
|
+
wasm_memory_shim: false, backend: nil, navigable: false, same_origin: false)
|
|
61
73
|
@resources = resources
|
|
74
|
+
@same_origin = same_origin
|
|
62
75
|
@strict = strict
|
|
76
|
+
@backend = backend
|
|
77
|
+
@execute_scripts = execute_scripts
|
|
78
|
+
@settle_after_boot = settle
|
|
79
|
+
@wasm_memory_shim = wasm_memory_shim
|
|
80
|
+
@navigable = navigable
|
|
63
81
|
@js_errors = []
|
|
64
82
|
@console = []
|
|
65
83
|
@acknowledged = 0
|
|
66
84
|
@allow_errors = false
|
|
67
85
|
@disposed = false
|
|
86
|
+
@pending_navigation = nil
|
|
87
|
+
@runtime = nil
|
|
68
88
|
|
|
69
89
|
@window = Dommy.parse(html)
|
|
70
90
|
@window.location.__internal_set_url__(url) if url
|
|
91
|
+
install_runtime(@window)
|
|
71
92
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
@runtime.on_log { |log| @console << log }
|
|
78
|
-
@runtime.define_host_object("document", @window.document)
|
|
79
|
-
@runtime.install_window(@window)
|
|
80
|
-
@runtime.install_browser_globals
|
|
81
|
-
# Opt-in WPT scaffolding (common/sab.js derives SharedArrayBuffer through
|
|
82
|
-
# WebAssembly.Memory); off by default so real pages don't see the shim.
|
|
83
|
-
@runtime.install_wasm_memory_shim if wasm_memory_shim && @runtime.respond_to?(:install_wasm_memory_shim)
|
|
84
|
-
@window.globals["__fetch_handler__"] = Resources::FetchHandler.new(@resources) if @resources
|
|
85
|
-
|
|
86
|
-
if execute_scripts
|
|
87
|
-
doc = @window.document
|
|
88
|
-
# Dynamically-inserted `<script src>` (webpack/Vite on-demand chunks)
|
|
89
|
-
# fetch + run through the same resources adapter, after boot.
|
|
90
|
-
doc.external_script_runner = lambda do |element, src|
|
|
91
|
-
Js::ScriptBoot.run_external_script(@runtime, doc, element, src,
|
|
92
|
-
resources: @resources, on_error: ->(e) { @js_errors << e })
|
|
93
|
-
end
|
|
94
|
-
Js::ScriptBoot.run_document_scripts(
|
|
95
|
-
@runtime, doc, resources: @resources, on_error: ->(e) { @js_errors << e }
|
|
96
|
-
)
|
|
97
|
-
# Leave the page in a ready state: run on-load promises, due-now timers,
|
|
98
|
-
# and rAF (not future timers). `settle: false` observes it mid-flight.
|
|
99
|
-
@runtime.settle if settle
|
|
93
|
+
if navigable
|
|
94
|
+
@fetcher = Navigation::Fetcher.new(@resources, same_origin: @same_origin)
|
|
95
|
+
@history = Navigation::JointHistory.new
|
|
96
|
+
@window.navigation_delegate = self
|
|
97
|
+
@history.push(current_url, window: @window, windex: @window.history.__internal_index__)
|
|
100
98
|
end
|
|
101
99
|
check_js_errors!
|
|
102
100
|
end
|
|
@@ -106,6 +104,70 @@ module Dommy
|
|
|
106
104
|
# Current document HTML (serialized).
|
|
107
105
|
def html = @window.document.document_element&.outer_html
|
|
108
106
|
|
|
107
|
+
# The current document's URL (the address bar).
|
|
108
|
+
def current_url = @window.location.href
|
|
109
|
+
|
|
110
|
+
# The joint (tab) history of a navigable browser, or nil for a plain
|
|
111
|
+
# single-document browser.
|
|
112
|
+
attr_reader :history
|
|
113
|
+
|
|
114
|
+
# Programmatically navigate to `url` (a Ruby-initiated visit). Only
|
|
115
|
+
# meaningful for a navigable browser; performs the fetch + document swap
|
|
116
|
+
# immediately (there is no JS on the stack).
|
|
117
|
+
def visit(url, replace: false)
|
|
118
|
+
raise "browser is not navigable (use Browser.visit or navigable: true)" unless @navigable
|
|
119
|
+
|
|
120
|
+
@pending_navigation = {url: url.to_s, method: "GET", source: :visit, replace: replace}
|
|
121
|
+
flush_navigation!
|
|
122
|
+
self
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Reload the current document (re-fetch, replace the current history entry).
|
|
126
|
+
def reload
|
|
127
|
+
visit(current_url, replace: true)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Move back / forward one entry in the joint history. A same-document target
|
|
131
|
+
# (the entry's window is still live) traverses in place (popstate); a
|
|
132
|
+
# document-boundary target is re-fetched (no bfcache — D2).
|
|
133
|
+
def back = traverse(-1)
|
|
134
|
+
def forward = traverse(1)
|
|
135
|
+
|
|
136
|
+
# --- NavigationDelegate port (see Dommy::Navigation) ---
|
|
137
|
+
|
|
138
|
+
# A cross-document navigation intent (link / form / location). Navigation is
|
|
139
|
+
# a task: rather than swap the Window + JS realm synchronously (which may run
|
|
140
|
+
# while the outgoing realm's JS is still on the stack — e.g. `location.href =`
|
|
141
|
+
# inside a script), record it and perform the fetch + swap at the next drain
|
|
142
|
+
# boundary (settle / after_interaction / advance_time). Ruby-initiated visits
|
|
143
|
+
# flush immediately since no JS is on the stack.
|
|
144
|
+
def navigate(url:, source:, method: "GET", body: nil, params: nil, enctype: nil, headers: {}, replace: false)
|
|
145
|
+
@pending_navigation = {
|
|
146
|
+
url: url, method: method, body: body, params: params, enctype: enctype,
|
|
147
|
+
headers: headers, replace: replace, source: source
|
|
148
|
+
}
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# A cross-document history traversal. Ruby-initiated (back / forward), so it
|
|
153
|
+
# runs immediately: a same-document target (its window is still live)
|
|
154
|
+
# traverses in place (popstate); a document-boundary target is re-fetched.
|
|
155
|
+
def traverse(delta)
|
|
156
|
+
return self unless @navigable
|
|
157
|
+
|
|
158
|
+
entry = delta.negative? ? @history.back : @history.forward
|
|
159
|
+
return self unless entry
|
|
160
|
+
|
|
161
|
+
if entry.window && entry.window.equal?(@window)
|
|
162
|
+
@window.history.__internal_go_to__(entry.windex)
|
|
163
|
+
@runtime.drain_microtasks
|
|
164
|
+
check_js_errors!
|
|
165
|
+
else
|
|
166
|
+
perform_navigation!({url: entry.url, method: "GET", source: :traverse}, rebind: true)
|
|
167
|
+
end
|
|
168
|
+
self
|
|
169
|
+
end
|
|
170
|
+
|
|
109
171
|
# Evaluate an expression / statement body and return the decoded value.
|
|
110
172
|
def evaluate(js)
|
|
111
173
|
result = @runtime.evaluate(js)
|
|
@@ -125,6 +187,7 @@ module Dommy
|
|
|
125
187
|
# `setTimeout(300)` — use `advance_time(300)` for debounce/throttle.
|
|
126
188
|
def settle
|
|
127
189
|
@runtime.settle
|
|
190
|
+
flush_navigation!
|
|
128
191
|
check_js_errors!
|
|
129
192
|
self
|
|
130
193
|
end
|
|
@@ -133,6 +196,7 @@ module Dommy
|
|
|
133
196
|
def advance_time(ms)
|
|
134
197
|
@window.scheduler.advance_time(ms)
|
|
135
198
|
@runtime.drain_microtasks
|
|
199
|
+
flush_navigation!
|
|
136
200
|
check_js_errors!
|
|
137
201
|
self
|
|
138
202
|
end
|
|
@@ -142,25 +206,29 @@ module Dommy
|
|
|
142
206
|
# land before the next line, then enforce strict mode.
|
|
143
207
|
def after_interaction
|
|
144
208
|
@runtime.drain_microtasks
|
|
209
|
+
flush_navigation!
|
|
145
210
|
check_js_errors!
|
|
146
211
|
end
|
|
147
212
|
|
|
148
213
|
# Click a submit-capable button. The button's click event fires (JS may
|
|
149
214
|
# handle / preventDefault it); if it is an un-prevented submit button, the
|
|
150
|
-
# form's
|
|
151
|
-
#
|
|
215
|
+
# owning form's submission algorithm runs (a real SubmitEvent a SPA can
|
|
216
|
+
# intercept, then the delegate navigation). In a navigable browser that
|
|
217
|
+
# follows the submit for real; otherwise the delegate just records it.
|
|
152
218
|
def click_button(locator)
|
|
153
219
|
button = finder.find_button(locator)
|
|
154
|
-
prevented
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
220
|
+
# An un-prevented click runs the button's activation behavior — a submit
|
|
221
|
+
# button submits its owning form (real SubmitEvent + delegate navigation),
|
|
222
|
+
# so a navigable browser follows the submit for real.
|
|
223
|
+
Dommy::Interaction::EventSynthesis.click(button)
|
|
158
224
|
after_interaction
|
|
159
225
|
button
|
|
160
226
|
end
|
|
161
227
|
|
|
162
228
|
# Click a link, firing its click event so SPA JS (Turbo, React Router, …)
|
|
163
|
-
# can intercept.
|
|
229
|
+
# can intercept. An un-prevented click runs the anchor's activation behavior
|
|
230
|
+
# (follow-the-hyperlink); in a navigable browser that navigates for real,
|
|
231
|
+
# otherwise the delegate records it.
|
|
164
232
|
def click_link(locator)
|
|
165
233
|
link = finder.find_link(locator)
|
|
166
234
|
Dommy::Interaction::EventSynthesis.click(link)
|
|
@@ -190,6 +258,141 @@ module Dommy
|
|
|
190
258
|
|
|
191
259
|
private
|
|
192
260
|
|
|
261
|
+
# Build a fresh JS realm for `window`, wire error/console/fetch/external-
|
|
262
|
+
# script seams, and boot its `<script>` tags. Disposes the previous realm
|
|
263
|
+
# first (a no-op on the initial load), so a navigation tears the outgoing
|
|
264
|
+
# realm — and with it every pending timer / microtask on the old Window's
|
|
265
|
+
# scheduler — down before the new page runs.
|
|
266
|
+
def install_runtime(window)
|
|
267
|
+
@runtime&.dispose
|
|
268
|
+
# The JS engine is pluggable: `@backend` selects a registered runtime
|
|
269
|
+
# (nil → the configured default, QuickJS when dommy-js-quickjs is loaded).
|
|
270
|
+
runtime = Js.build_runtime(@backend)
|
|
271
|
+
runtime.on_unhandled_rejection { |err| @js_errors << err }
|
|
272
|
+
runtime.on_callback_error { |err| @js_errors << err } if runtime.respond_to?(:on_callback_error)
|
|
273
|
+
runtime.on_log { |log| @console << log }
|
|
274
|
+
runtime.define_host_object("document", window.document)
|
|
275
|
+
runtime.install_window(window)
|
|
276
|
+
runtime.install_browser_globals
|
|
277
|
+
# Opt-in WPT scaffolding (common/sab.js derives SharedArrayBuffer through
|
|
278
|
+
# WebAssembly.Memory); off by default so real pages don't see the shim.
|
|
279
|
+
runtime.install_wasm_memory_shim if @wasm_memory_shim && runtime.respond_to?(:install_wasm_memory_shim)
|
|
280
|
+
window.globals["__fetch_handler__"] = Resources::FetchHandler.new(@resources) if @resources
|
|
281
|
+
@runtime = runtime
|
|
282
|
+
return unless @execute_scripts
|
|
283
|
+
|
|
284
|
+
doc = window.document
|
|
285
|
+
# Dynamically-inserted `<script src>` (webpack/Vite on-demand chunks)
|
|
286
|
+
# fetch + run through the same resources adapter, after boot.
|
|
287
|
+
doc.external_script_runner = lambda do |element, src|
|
|
288
|
+
Js::ScriptBoot.run_external_script(runtime, doc, element, src,
|
|
289
|
+
resources: @resources, on_error: ->(e) { @js_errors << e })
|
|
290
|
+
end
|
|
291
|
+
Js::ScriptBoot.run_document_scripts(
|
|
292
|
+
runtime, doc, resources: @resources, on_error: ->(e) { @js_errors << e }
|
|
293
|
+
)
|
|
294
|
+
# Leave the page in a ready state: run on-load promises, due-now timers,
|
|
295
|
+
# and rAF (not future timers). `settle: false` observes it mid-flight.
|
|
296
|
+
runtime.settle if @settle_after_boot
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Perform a recorded navigation: fetch the target (following redirects),
|
|
300
|
+
# fire the old document's unload, then replace the Window + JS realm with the
|
|
301
|
+
# freshly parsed document and update the joint history. A network miss or a
|
|
302
|
+
# non-document response leaves the current page in place.
|
|
303
|
+
MAX_META_REFRESHES = 20
|
|
304
|
+
|
|
305
|
+
def perform_navigation!(nav, rebind: false, refresh_depth: 0)
|
|
306
|
+
response, final_url = @fetcher.request(
|
|
307
|
+
method: nav[:method] || "GET", url: nav[:url], params: nav[:params],
|
|
308
|
+
body: nav[:body], enctype: nav[:enctype], headers: nav[:headers] || {}
|
|
309
|
+
)
|
|
310
|
+
return unless response&.success? && document_response?(response)
|
|
311
|
+
|
|
312
|
+
# Fire the outgoing document's unload sequence while its realm is still
|
|
313
|
+
# alive, then surface any of its errors before the realm is torn down.
|
|
314
|
+
fire_unload(@window)
|
|
315
|
+
check_js_errors!
|
|
316
|
+
|
|
317
|
+
new_window = Dommy.parse(response.body)
|
|
318
|
+
new_window.location.__internal_set_url__(final_url)
|
|
319
|
+
new_window.navigation_delegate = self
|
|
320
|
+
@window = new_window
|
|
321
|
+
install_runtime(new_window)
|
|
322
|
+
|
|
323
|
+
windex = new_window.history.__internal_index__
|
|
324
|
+
if rebind || nav[:replace]
|
|
325
|
+
@history.rebind_current(url: final_url, window: new_window, windex: windex)
|
|
326
|
+
else
|
|
327
|
+
@history.push(final_url, window: new_window, windex: windex)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
follow_meta_refresh!(refresh_depth)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# If the freshly loaded document asks for an immediate `<meta http-equiv=
|
|
334
|
+
# refresh>`, follow it (as a replace, like a redirect), capped so a page that
|
|
335
|
+
# refreshes to itself can't loop forever.
|
|
336
|
+
def follow_meta_refresh!(depth)
|
|
337
|
+
return if depth >= MAX_META_REFRESHES
|
|
338
|
+
|
|
339
|
+
target = meta_refresh_target(@window.document)
|
|
340
|
+
return unless target
|
|
341
|
+
|
|
342
|
+
perform_navigation!({url: target, method: "GET", source: :meta_refresh},
|
|
343
|
+
rebind: true, refresh_depth: depth + 1)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# The resolved URL a `<meta http-equiv="refresh" content="0; url=…">` points
|
|
347
|
+
# at, or nil when the document has none (or a refresh with no URL, which just
|
|
348
|
+
# reloads and is left alone to avoid a busy loop).
|
|
349
|
+
def meta_refresh_target(document)
|
|
350
|
+
document.query_selector_all("meta").each do |meta|
|
|
351
|
+
next unless meta.get_attribute("http-equiv").to_s.casecmp?("refresh")
|
|
352
|
+
|
|
353
|
+
_delay, separator, rest = meta.get_attribute("content").to_s.partition(";")
|
|
354
|
+
next if separator.empty?
|
|
355
|
+
|
|
356
|
+
url = rest.strip.sub(/\Aurl\s*=\s*/i, "").gsub(/\A["']|["']\z/, "").strip
|
|
357
|
+
return resolve_against_current(url) unless url.empty?
|
|
358
|
+
end
|
|
359
|
+
nil
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def resolve_against_current(url)
|
|
363
|
+
URI.join(current_url, url).to_s
|
|
364
|
+
rescue URI::InvalidURIError
|
|
365
|
+
url
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Perform a pending navigation recorded by the delegate (JS-initiated
|
|
369
|
+
# location.href= / form submit / link click). Called at drain boundaries so
|
|
370
|
+
# the swap never runs with the outgoing realm's JS on the stack.
|
|
371
|
+
def flush_navigation!
|
|
372
|
+
return unless @navigable
|
|
373
|
+
|
|
374
|
+
nav = @pending_navigation
|
|
375
|
+
return unless nav
|
|
376
|
+
|
|
377
|
+
@pending_navigation = nil
|
|
378
|
+
perform_navigation!(nav)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def fire_unload(window)
|
|
382
|
+
window.dispatch_event(Dommy::Event.new("pagehide"))
|
|
383
|
+
window.dispatch_event(Dommy::Event.new("unload"))
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Only HTML/XML responses replace the document; other content types (a JSON
|
|
387
|
+
# API hit, an image) leave the current page. A response with no Content-Type
|
|
388
|
+
# is treated as a document (fixtures commonly omit it).
|
|
389
|
+
def document_response?(response)
|
|
390
|
+
headers = response.headers || {}
|
|
391
|
+
key = headers.keys.find { |k| k.to_s.casecmp?("content-type") }
|
|
392
|
+
content_type = key ? headers[key].to_s.downcase : ""
|
|
393
|
+
content_type.empty? || content_type.include?("html") || content_type.include?("xml")
|
|
394
|
+
end
|
|
395
|
+
|
|
193
396
|
def unacknowledged = @js_errors[@acknowledged..] || []
|
|
194
397
|
|
|
195
398
|
def submit_button?(button)
|
|
@@ -22,12 +22,16 @@ module Dommy
|
|
|
22
22
|
|
|
23
23
|
# Invoke a DOM event listener per the EventTarget rule: an object with
|
|
24
24
|
# `handle_event`, else a Ruby callable, else a JS-bridged callable (tried in
|
|
25
|
-
# that order).
|
|
26
|
-
|
|
25
|
+
# that order). A JS function listener's `this` must be the event's
|
|
26
|
+
# currentTarget (the node the listener is attached to), so pass it through
|
|
27
|
+
# when the bridge supports an explicit receiver.
|
|
28
|
+
def invoke_listener(listener, event, current_target = nil)
|
|
27
29
|
if listener.respond_to?(:handle_event)
|
|
28
30
|
listener.handle_event(event)
|
|
29
31
|
elsif listener.respond_to?(:call) && !listener.is_a?(Module)
|
|
30
32
|
listener.call(event)
|
|
33
|
+
elsif listener.respond_to?(:__js_call_with_this__)
|
|
34
|
+
listener.__js_call_with_this__([event], current_target)
|
|
31
35
|
elsif listener.respond_to?(:__js_call__)
|
|
32
36
|
listener.__js_call__("call", [event])
|
|
33
37
|
end
|