dommy 0.12.0 → 0.14.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/backend/makiri_adapter.rb +63 -29
- data/lib/dommy/backend.rb +32 -9
- data/lib/dommy/bridge/constructor.rb +72 -0
- data/lib/dommy/bridge/errors.rb +42 -0
- data/lib/dommy/bridge/methods.rb +21 -14
- data/lib/dommy/bridge/sentinels.rb +39 -0
- data/lib/dommy/bridge/values.rb +78 -0
- data/lib/dommy/{js → bridge}/wire_tags.rb +22 -11
- data/lib/dommy/bridge.rb +22 -237
- data/lib/dommy/browser.rb +155 -58
- data/lib/dommy/callable_invoker.rb +16 -0
- data/lib/dommy/character_data.rb +539 -0
- data/lib/dommy/class_list.rb +311 -0
- data/lib/dommy/compression_streams.rb +12 -62
- data/lib/dommy/crypto.rb +12 -1
- data/lib/dommy/css.rb +12 -0
- data/lib/dommy/data_transfer.rb +106 -0
- data/lib/dommy/document.rb +312 -374
- data/lib/dommy/dom_parser.rb +3 -0
- data/lib/dommy/element.rb +201 -1820
- data/lib/dommy/encodings/tables.rb +751 -0
- data/lib/dommy/encodings.rb +330 -0
- data/lib/dommy/event.rb +231 -6
- data/lib/dommy/event_source.rb +41 -0
- data/lib/dommy/fetch.rb +60 -49
- data/lib/dommy/form_data.rb +16 -64
- data/lib/dommy/form_entry_list.rb +227 -0
- data/lib/dommy/fragment.rb +320 -0
- data/lib/dommy/history.rb +3 -0
- data/lib/dommy/html_collection.rb +18 -8
- data/lib/dommy/html_elements/base.rb +338 -0
- data/lib/dommy/html_elements/embedded.rb +237 -0
- data/lib/dommy/html_elements/forms/form.rb +1088 -0
- data/lib/dommy/html_elements/forms/input.rb +642 -0
- data/lib/dommy/html_elements/forms/select.rb +536 -0
- data/lib/dommy/html_elements/forms.rb +8 -0
- data/lib/dommy/html_elements/interactive.rb +382 -0
- data/lib/dommy/html_elements/media.rb +329 -0
- data/lib/dommy/html_elements/metadata.rb +353 -0
- data/lib/dommy/html_elements/miscellaneous.rb +77 -0
- data/lib/dommy/html_elements/tables.rb +468 -0
- data/lib/dommy/html_elements.rb +11 -5248
- data/lib/dommy/hyperlink_utils.rb +171 -0
- data/lib/dommy/interaction/debug.rb +7 -6
- data/lib/dommy/interaction/dom_summary.rb +65 -40
- data/lib/dommy/interaction/driver.rb +29 -115
- data/lib/dommy/interaction/event_synthesis.rb +97 -4
- data/lib/dommy/interaction/field_interactor.rb +1 -13
- data/lib/dommy/interaction/form_submission.rb +89 -167
- data/lib/dommy/interaction/key_sender.rb +121 -0
- data/lib/dommy/interaction/mime_types.rb +25 -0
- data/lib/dommy/interaction.rb +2 -0
- data/lib/dommy/internal/accessibility_tree.rb +32 -9
- data/lib/dommy/internal/accessibility_visibility.rb +44 -0
- data/lib/dommy/internal/accessible_name.rb +85 -65
- data/lib/dommy/internal/aria_role.rb +21 -6
- data/lib/dommy/internal/aria_snapshot.rb +5 -0
- data/lib/dommy/internal/aria_state.rb +24 -5
- data/lib/dommy/internal/autofill.rb +141 -0
- data/lib/dommy/internal/backend_prefilter.rb +170 -0
- data/lib/dommy/internal/bounded_cache.rb +50 -0
- data/lib/dommy/internal/child_node.rb +9 -34
- data/lib/dommy/internal/css/calc.rb +143 -145
- data/lib/dommy/internal/css/cascade.rb +38 -393
- data/lib/dommy/internal/css/cascaded_declarations.rb +245 -0
- data/lib/dommy/internal/css/color.rb +61 -34
- data/lib/dommy/internal/css/computed_style_builder.rb +205 -0
- data/lib/dommy/internal/css/computed_style_declaration.rb +20 -4
- data/lib/dommy/internal/css/counters.rb +60 -37
- data/lib/dommy/internal/css/custom_properties.rb +87 -39
- data/lib/dommy/internal/css/media_query.rb +228 -203
- data/lib/dommy/internal/css/parser.rb +29 -12
- data/lib/dommy/internal/css/property_registry.rb +32 -56
- data/lib/dommy/internal/css/renderability.rb +61 -0
- data/lib/dommy/internal/css/rule_index.rb +17 -140
- data/lib/dommy/internal/css/selector_dependencies.rb +162 -0
- data/lib/dommy/internal/css/style_cache.rb +61 -0
- data/lib/dommy/internal/css/ua_stylesheet.rb +40 -1
- data/lib/dommy/internal/css/value_tokens.rb +60 -0
- data/lib/dommy/internal/directionality.rb +188 -0
- data/lib/dommy/internal/document_generations.rb +169 -0
- data/lib/dommy/internal/document_interaction_state.rb +47 -0
- data/lib/dommy/internal/document_live_ranges.rb +97 -0
- data/lib/dommy/internal/dom_matching.rb +28 -12
- data/lib/dommy/internal/element_aria.rb +163 -0
- data/lib/dommy/internal/element_geometry.rb +69 -0
- data/lib/dommy/internal/element_matching.rb +5 -12
- data/lib/dommy/internal/element_shadow.rb +117 -0
- data/lib/dommy/internal/element_state.rb +232 -0
- data/lib/dommy/internal/element_tasks.rb +19 -0
- data/lib/dommy/internal/element_top_layer.rb +63 -0
- data/lib/dommy/internal/enumerated_keyword_sets.rb +53 -0
- data/lib/dommy/internal/exception_report.rb +123 -0
- data/lib/dommy/internal/idna.rb +4 -4
- data/lib/dommy/internal/idna_data.rb +513 -100
- data/lib/dommy/internal/input_type.rb +280 -0
- data/lib/dommy/internal/insertion_point.rb +62 -0
- data/lib/dommy/internal/mime_type.rb +74 -0
- data/lib/dommy/internal/mutation_coordinator.rb +57 -198
- data/lib/dommy/internal/namespaces.rb +41 -43
- data/lib/dommy/internal/node_adopter.rb +84 -77
- data/lib/dommy/internal/node_factory.rb +157 -0
- data/lib/dommy/internal/node_identity.rb +31 -0
- data/lib/dommy/internal/node_wrapper_cache.rb +61 -207
- data/lib/dommy/internal/observer_options.rb +93 -0
- data/lib/dommy/internal/parent_node.rb +27 -27
- data/lib/dommy/internal/post_insertion_steps.rb +213 -0
- data/lib/dommy/internal/reflected_attributes.rb +425 -13
- data/lib/dommy/internal/rendered_text/collector.rb +221 -0
- data/lib/dommy/internal/rendered_text/computed_box.rb +39 -0
- data/lib/dommy/internal/rendered_text/fragment.rb +55 -0
- data/lib/dommy/internal/rendered_text/items.rb +57 -0
- data/lib/dommy/internal/rendered_text/whitespace_collapser.rb +132 -0
- data/lib/dommy/internal/selector_arguments.rb +94 -0
- data/lib/dommy/internal/selector_index.rb +3 -1
- data/lib/dommy/internal/selector_matcher.rb +23 -383
- data/lib/dommy/internal/selector_parser.rb +2 -37
- data/lib/dommy/internal/template_content_registry.rb +112 -66
- data/lib/dommy/internal/text_flattening.rb +20 -0
- data/lib/dommy/internal/text_selection.rb +104 -0
- data/lib/dommy/internal/toggle_events.rb +56 -0
- data/lib/dommy/internal/toggle_task_tracker.rb +57 -0
- data/lib/dommy/internal/url_parser.rb +732 -527
- data/lib/dommy/internal/url_record_accessors.rb +90 -0
- data/lib/dommy/internal/window_constructors.rb +190 -0
- data/lib/dommy/internal/xml_serialization.rb +168 -80
- data/lib/dommy/js/crossing_profile.rb +75 -0
- data/lib/dommy/js/dom_interfaces.rb +23 -2
- data/lib/dommy/js/error_log.rb +137 -0
- data/lib/dommy/js/host_bridge.rb +56 -187
- data/lib/dommy/js/host_callbacks.rb +96 -0
- data/lib/dommy/js/host_runtime.js +896 -729
- data/lib/dommy/js/import_map.rb +10 -1
- data/lib/dommy/js/marshaller.rb +111 -63
- data/lib/dommy/js/observable_runtime.js +125 -146
- data/lib/dommy/js/runtime.rb +17 -43
- data/lib/dommy/js/runtime_registry.rb +46 -0
- data/lib/dommy/js/script_boot.rb +94 -85
- data/lib/dommy/js/webidl_tables.js +619 -0
- data/lib/dommy/js/window_wiring.rb +69 -0
- data/lib/dommy/location.rb +161 -102
- data/lib/dommy/mutation_observer.rb +3 -57
- data/lib/dommy/navigation.rb +65 -12
- data/lib/dommy/navigator.rb +3 -1
- data/lib/dommy/node.rb +46 -20
- data/lib/dommy/promise.rb +62 -8
- data/lib/dommy/resources.rb +19 -1
- data/lib/dommy/scheduler.rb +35 -6
- data/lib/dommy/shadow_root.rb +11 -5
- data/lib/dommy/streams.rb +1200 -187
- data/lib/dommy/style_declaration.rb +284 -0
- data/lib/dommy/svg_elements.rb +1 -45
- data/lib/dommy/text_codec.rb +110 -213
- data/lib/dommy/url.rb +32 -79
- data/lib/dommy/url_pattern/regexp_translator.rb +1 -6
- data/lib/dommy/url_pattern/tokenizer.rb +4 -2
- data/lib/dommy/validity_state.rb +383 -0
- data/lib/dommy/version.rb +1 -1
- data/lib/dommy/window.rb +184 -192
- data/lib/dommy/xml_http_request.rb +52 -7
- data/lib/dommy.rb +25 -3
- metadata +78 -7
- data/lib/dommy/bridge/constructor_registry.rb +0 -28
- data/lib/dommy/internal/css_pseudo_handlers.rb +0 -378
data/lib/dommy/bridge.rb
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Dommy
|
|
4
|
-
# `Dommy::Bridge` —
|
|
5
|
-
#
|
|
6
|
-
# through the `__js_get__` / `__js_set__` / `__js_call__` /
|
|
7
|
-
# `__js_new__` protocol).
|
|
4
|
+
# `Dommy::Bridge` — the protocol every JS host speaks to Dommy, and the
|
|
5
|
+
# vocabulary the DOM answers it in.
|
|
8
6
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
7
|
+
# THE RULE, because it has drifted before: what is true of ANY host belongs
|
|
8
|
+
# here; what is true of the host we happen to ship belongs in `Dommy::Js`.
|
|
9
|
+
# So the sentinels, the value types that cross the boundary, the exceptions
|
|
10
|
+
# a spec mandates and the wire tags are Bridge's, while the engine ABI, the
|
|
11
|
+
# marshaller that reads those tags and the QuickJS-facing runtime are Js's.
|
|
12
|
+
#
|
|
13
|
+
# This namespace is NOT optional or embedder-only, whatever an older version
|
|
14
|
+
# of this comment claimed: `Bridge::UNDEFINED` and `Bridge::Methods` are
|
|
15
|
+
# spoken by most of the DOM classes in this gem. A backend gem (dommy-js-
|
|
16
|
+
# quickjs, a future wasm one) depends on Bridge; nothing in the DOM depends
|
|
17
|
+
# on Js.
|
|
13
18
|
#
|
|
14
19
|
# The protocol contract:
|
|
15
20
|
# - `__js_get__(name)` reads a JS-style property by string name
|
|
@@ -17,233 +22,13 @@ module Dommy
|
|
|
17
22
|
# - `__js_call__(method, args)` invokes a method with positional
|
|
18
23
|
# args (Array)
|
|
19
24
|
# - `__js_new__(args)` invokes the value as a JS constructor
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
# rather than silently dropping it.
|
|
24
|
-
UNHANDLED = :__js_unhandled__
|
|
25
|
-
|
|
26
|
-
# Sentinel for the JS `undefined` value, used in both directions:
|
|
27
|
-
# - a `__js_call__` returns it for a void (undefined-returning) op, so the
|
|
28
|
-
# host marshals JS `undefined` rather than the `null` a bare Ruby `nil`
|
|
29
|
-
# would (e.g. DOMTokenList add/remove return undefined);
|
|
30
|
-
# - a top-level JS `undefined` *argument* arrives as it (whereas JS `null`
|
|
31
|
-
# arrives as `nil`), so WebIDL-style dispatch can tell an omitted optional
|
|
32
|
-
# argument from an explicit null.
|
|
33
|
-
# Its `to_s` is "undefined" so a DOMString coercion of a stray undefined is
|
|
34
|
-
# still spec-faithful.
|
|
35
|
-
UNDEFINED = Object.new
|
|
36
|
-
def UNDEFINED.to_s = "undefined"
|
|
37
|
-
def UNDEFINED.inspect = "#<Dommy::Bridge::UNDEFINED>"
|
|
38
|
-
UNDEFINED.freeze
|
|
39
|
-
|
|
40
|
-
# The sentinel a `__js_get__` returns for a GENUINELY-ABSENT property (a key
|
|
41
|
-
# the object does not have), as distinct from a present property whose value
|
|
42
|
-
# is `nil`/JS null or `UNDEFINED`/JS undefined. It marshals to JS `undefined`
|
|
43
|
-
# for the value, but the proxy reports `("x" in obj) === false` for it — so
|
|
44
|
-
# feature detection like `isUndefined(window.Vue)` AND `"Vue" in window` are
|
|
45
|
-
# both correct. (UNDEFINED means present-but-undefined → reported present.)
|
|
46
|
-
ABSENT = Object.new
|
|
47
|
-
def ABSENT.to_s = "undefined"
|
|
48
|
-
def ABSENT.inspect = "#<Dommy::Bridge::ABSENT>"
|
|
49
|
-
ABSENT.freeze
|
|
50
|
-
|
|
51
|
-
# An opaque handle to a JS-side value that Ruby only stores and hands back
|
|
52
|
-
# (an AbortSignal's reason, a CustomEvent's detail). A non-plain JS object
|
|
53
|
-
# (Error, class instance, …) crosses as one of these instead of being
|
|
54
|
-
# flattened to a Hash, so it round-trips with IDENTITY preserved. `to_s`
|
|
55
|
-
# exposes the captured JS string form for the rare Ruby consumer that needs
|
|
56
|
-
# text (e.g. building a message).
|
|
57
|
-
class JSValue
|
|
58
|
-
attr_reader :ref
|
|
59
|
-
|
|
60
|
-
def initialize(ref, label = nil)
|
|
61
|
-
@ref = ref
|
|
62
|
-
@label = label
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def to_s = (@label || "[object]").to_s
|
|
66
|
-
def inspect = "#<Dommy::Bridge::JSValue #{to_s}>"
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# Raised by a host method that must throw an ARBITRARY value back to JS —
|
|
70
|
-
# not a DOMException/Error, but e.g. `signal.throwIfAborted()` throwing the
|
|
71
|
-
# exact abort reason (a string, number, or opaque JSValue). The bridge
|
|
72
|
-
# re-throws the wrapped value verbatim (identity preserved). Subclasses
|
|
73
|
-
# RuntimeError (with the value's string form as the message) so standalone
|
|
74
|
-
# CRuby callers still see a normal `raise`-able error.
|
|
75
|
-
class ThrowValue < RuntimeError
|
|
76
|
-
attr_reader :value
|
|
77
|
-
|
|
78
|
-
def initialize(value)
|
|
79
|
-
@value = value
|
|
80
|
-
super(value.to_s)
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
# A byte buffer that crosses the JS boundary as a `Uint8Array` (rather than a
|
|
85
|
-
# plain Array). Wrap a host method's byte-array result in this so JS sees a
|
|
86
|
-
# real typed array — e.g. `TextEncoder#encode`, `Blob#arrayBuffer`. The
|
|
87
|
-
# reverse direction (a JS ArrayBuffer/TypedArray argument) arrives as a
|
|
88
|
-
# `Bytes` too. It subclasses Array so plain-Array callers (and `== [..]`
|
|
89
|
-
# comparisons) keep working; only the bridge treats it specially.
|
|
90
|
-
class Bytes < ::Array
|
|
91
|
-
def initialize(bytes = [])
|
|
92
|
-
super()
|
|
93
|
-
concat(Array(bytes).map { |b| b.to_i & 0xFF })
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
alias bytes to_a
|
|
97
|
-
def pack_bytes = pack("C*")
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
# Like `Bytes`, but crosses the JS boundary as a bare `ArrayBuffer` rather
|
|
101
|
-
# than a `Uint8Array` view. Use this for the spec methods whose return type
|
|
102
|
-
# is `ArrayBuffer` — `Response`/`Blob`/`FileReader`/`XMLHttpRequest`'s
|
|
103
|
-
# `arrayBuffer`. Subclasses `Bytes` so Ruby-level `== [..]` comparisons still
|
|
104
|
-
# hold; only the bridge distinguishes it (and must check it before `Bytes`).
|
|
105
|
-
class ArrayBuffer < Bytes
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
# A Ruby-side signal that the JS boundary should surface a JS `TypeError`
|
|
109
|
-
# (not a `DOMException`). Some WebIDL operations — notably the `URL`
|
|
110
|
-
# constructor and its `href` setter — throw `TypeError` on failure rather
|
|
111
|
-
# than a DOMException; raising this lets a host bridge rethrow the correct
|
|
112
|
-
# JS error type, while Ruby callers can still rescue it like any other
|
|
113
|
-
# error. Kept distinct from Ruby's built-in `::TypeError` so a host can map
|
|
114
|
-
# only deliberate, spec-mandated TypeErrors (and not mask genuine Ruby type
|
|
115
|
-
# bugs) across the boundary.
|
|
116
|
-
class TypeError < ::StandardError; end
|
|
117
|
-
|
|
118
|
-
# Like `Bridge::TypeError`, but for spec-mandated `RangeError`s (e.g. the
|
|
119
|
-
# `Response` constructor rejecting a status outside 200–599). A host bridge
|
|
120
|
-
# rethrows a real JS `RangeError`; kept distinct from Ruby's `::RangeError`.
|
|
121
|
-
class RangeError < ::StandardError; end
|
|
122
|
-
|
|
123
|
-
# Wraps an external callback handle (registered in a host-side
|
|
124
|
-
# callback table) so the JS bridge can resolve / invoke it. The
|
|
125
|
-
# external host that creates these is responsible for honoring
|
|
126
|
-
# `invoke_callback(callback_id, args)`.
|
|
127
|
-
#
|
|
128
|
-
# The `__callback_id__` key on this object exposes the integer
|
|
129
|
-
# id to JS-side code that needs to round-trip it (e.g. for
|
|
130
|
-
# release / introspection).
|
|
131
|
-
class Callback
|
|
132
|
-
ID_KEY = "__callback_id__"
|
|
133
|
-
|
|
134
|
-
def initialize(host, callback_id)
|
|
135
|
-
@host = host
|
|
136
|
-
@callback_id = callback_id
|
|
137
|
-
@props = {}
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
def __js_get__(key)
|
|
141
|
-
if key == ID_KEY
|
|
142
|
-
@props.fetch(key, @callback_id)
|
|
143
|
-
else
|
|
144
|
-
@props[key]
|
|
145
|
-
end
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
def __js_set__(key, value)
|
|
149
|
-
@props[key] = value
|
|
150
|
-
nil
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def __js_call__(method, args)
|
|
154
|
-
case method
|
|
155
|
-
when "call"
|
|
156
|
-
@host.invoke_callback(@callback_id, args)
|
|
157
|
-
end
|
|
158
|
-
end
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
# Block-as-constructor adapter — invoking `__js_new__(args)`
|
|
162
|
-
# calls the wrapped block with `args` and returns whatever the
|
|
163
|
-
# block produces. Used by Window to wire up `new Event(init)`,
|
|
164
|
-
# `new CustomEvent(init)`, etc. without hand-rolling a class
|
|
165
|
-
# for each constructor.
|
|
166
|
-
class Constructor
|
|
167
|
-
def initialize(&block)
|
|
168
|
-
@block = block
|
|
169
|
-
@class_methods = {}
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
def __js_new__(args)
|
|
173
|
-
@block.call(args)
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
# Register a class-level method (e.g. `URL.createObjectURL`)
|
|
177
|
-
# that JS bridges resolve via `__js_call__` on the constructor
|
|
178
|
-
# itself. Returns self for chaining.
|
|
179
|
-
def define_class_method(name, &block)
|
|
180
|
-
@class_methods[name.to_s] = block
|
|
181
|
-
self
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
def __js_call__(method, args)
|
|
185
|
-
handler = @class_methods[method.to_s]
|
|
186
|
-
handler&.call(args)
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
# Names of the registered class-level (static) methods, so a JS host can
|
|
190
|
-
# expose them on the constructor function (e.g. `URL.createObjectURL`).
|
|
191
|
-
def __js_class_method_names__
|
|
192
|
-
@class_methods.keys
|
|
193
|
-
end
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
# `JS.global[:Promise]` view. Implements the `resolve` / `reject`
|
|
197
|
-
# class methods plus `new Promise(executor)` via `__js_new__`.
|
|
198
|
-
class PromiseConstructor
|
|
199
|
-
def initialize(window)
|
|
200
|
-
@window = window
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
def __js_call__(method, args)
|
|
204
|
-
case method
|
|
205
|
-
when "resolve"
|
|
206
|
-
PromiseValue.resolve(@window, args[0])
|
|
207
|
-
when "reject"
|
|
208
|
-
PromiseValue.reject(@window, args[0])
|
|
209
|
-
end
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
# `new Promise(executor)` — runs executor synchronously with
|
|
213
|
-
# (resolve, reject) callbacks.
|
|
214
|
-
def __js_new__(args)
|
|
215
|
-
executor = args[0]
|
|
216
|
-
promise = PromiseValue.new(@window)
|
|
217
|
-
resolve = PromiseSettler.new(promise, fulfilled: true)
|
|
218
|
-
reject = PromiseSettler.new(promise, fulfilled: false)
|
|
219
|
-
if executor.respond_to?(:__js_call__)
|
|
220
|
-
executor.__js_call__("call", [resolve, reject])
|
|
221
|
-
elsif executor.respond_to?(:call)
|
|
222
|
-
executor.call(resolve, reject)
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
promise
|
|
226
|
-
end
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
# Adapter so a Ruby-side executor can deliver resolve/reject
|
|
230
|
-
# through the same `__js_call__("call", args)` interface that
|
|
231
|
-
# the scheduler and JS bridge use for callbacks.
|
|
232
|
-
class PromiseSettler
|
|
233
|
-
def initialize(promise, fulfilled:)
|
|
234
|
-
@promise = promise
|
|
235
|
-
@fulfilled = fulfilled
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
def __js_call__(_method, args)
|
|
239
|
-
if @fulfilled
|
|
240
|
-
@promise.fulfill(args[0])
|
|
241
|
-
else
|
|
242
|
-
@promise.reject(args[0])
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
nil
|
|
246
|
-
end
|
|
247
|
-
end
|
|
248
|
-
end
|
|
25
|
+
#
|
|
26
|
+
# The pieces are one file each, by what they are — see the requires below.
|
|
27
|
+
module Bridge; end
|
|
249
28
|
end
|
|
29
|
+
|
|
30
|
+
require_relative "bridge/sentinels"
|
|
31
|
+
require_relative "bridge/values"
|
|
32
|
+
require_relative "bridge/errors"
|
|
33
|
+
require_relative "bridge/constructor"
|
|
34
|
+
require_relative "bridge/wire_tags"
|
data/lib/dommy/browser.rb
CHANGED
|
@@ -23,24 +23,14 @@ module Dommy
|
|
|
23
23
|
# settle before the next line.
|
|
24
24
|
include Dommy::Interaction::Driver
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
class JsError < StandardError
|
|
28
|
-
attr_reader :causes
|
|
26
|
+
attr_reader :window, :runtime, :console
|
|
29
27
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
private
|
|
36
|
-
|
|
37
|
-
def build_message(causes)
|
|
38
|
-
lines = causes.map { |e| " #{e.class}: #{e.message}" }
|
|
39
|
-
"#{causes.length} uncaught JS error(s):\n#{lines.join("\n")}"
|
|
40
|
-
end
|
|
41
|
-
end
|
|
28
|
+
# The ledger of errors the page left unhandled (`Dommy::Js::ErrorLog`);
|
|
29
|
+
# shared with the other hosts so strict mode behaves the same everywhere.
|
|
30
|
+
attr_reader :error_log
|
|
42
31
|
|
|
43
|
-
|
|
32
|
+
# The errors themselves, as a browser console's scrollback.
|
|
33
|
+
def js_errors = @error_log.errors
|
|
44
34
|
|
|
45
35
|
# Build a browser and (unless `execute_scripts: false`) boot its scripts. In
|
|
46
36
|
# block form the browser is yielded and disposed afterward, returning the
|
|
@@ -72,18 +62,15 @@ module Dommy
|
|
|
72
62
|
wasm_memory_shim: false, backend: nil, navigable: false, same_origin: false)
|
|
73
63
|
@resources = resources
|
|
74
64
|
@same_origin = same_origin
|
|
75
|
-
@strict = strict
|
|
76
65
|
@backend = backend
|
|
77
66
|
@execute_scripts = execute_scripts
|
|
78
67
|
@settle_after_boot = settle
|
|
79
68
|
@wasm_memory_shim = wasm_memory_shim
|
|
80
69
|
@navigable = navigable
|
|
81
|
-
@
|
|
70
|
+
@error_log = Js::ErrorLog.new(strict: strict)
|
|
82
71
|
@console = []
|
|
83
|
-
@acknowledged = 0
|
|
84
|
-
@allow_errors = false
|
|
85
72
|
@disposed = false
|
|
86
|
-
@
|
|
73
|
+
@pending_navigations = []
|
|
87
74
|
@runtime = nil
|
|
88
75
|
|
|
89
76
|
@window = Dommy.parse(html)
|
|
@@ -117,7 +104,7 @@ module Dommy
|
|
|
117
104
|
def visit(url, replace: false)
|
|
118
105
|
raise "browser is not navigable (use Browser.visit or navigable: true)" unless @navigable
|
|
119
106
|
|
|
120
|
-
@
|
|
107
|
+
@pending_navigations << {url: url.to_s, method: "GET", source: :visit, replace: replace}
|
|
121
108
|
flush_navigation!
|
|
122
109
|
self
|
|
123
110
|
end
|
|
@@ -141,14 +128,61 @@ module Dommy
|
|
|
141
128
|
# inside a script), record it and perform the fetch + swap at the next drain
|
|
142
129
|
# boundary (settle / after_interaction / advance_time). Ruby-initiated visits
|
|
143
130
|
# 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
|
-
|
|
131
|
+
def navigate(url:, source:, method: "GET", body: nil, params: nil, enctype: nil, target: nil, headers: {}, replace: false)
|
|
132
|
+
# A target that names an iframe in this document navigates that nested
|
|
133
|
+
# browsing context. It can be performed now — it does not replace the top
|
|
134
|
+
# realm — with only the frame's `load` deferred to a microtask, so a
|
|
135
|
+
# listener installed right after `form.submit()` is registered first.
|
|
136
|
+
frame = @navigable ? resolve_target_frame(target) : nil
|
|
137
|
+
if frame
|
|
138
|
+
navigate_frame(frame,
|
|
139
|
+
{method: method, url: url, params: params, body: body, enctype: enctype, headers: headers},
|
|
140
|
+
resolve_against_current(url.to_s))
|
|
141
|
+
@window.scheduler.queue_microtask(proc { frame.dispatch_event(Dommy::Event.new("load")) })
|
|
142
|
+
return nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
@pending_navigations << {
|
|
146
146
|
url: url, method: method, body: body, params: params, enctype: enctype,
|
|
147
|
-
headers: headers, replace: replace, source: source
|
|
147
|
+
target: target, headers: headers, replace: replace, source: source
|
|
148
148
|
}
|
|
149
149
|
nil
|
|
150
150
|
end
|
|
151
151
|
|
|
152
|
+
# A navigation delegate for an embedder-managed nested browsing context (an
|
|
153
|
+
# iframe whose content document the host injected). A navigation from inside
|
|
154
|
+
# that document loads into the frame itself.
|
|
155
|
+
class FrameNavigationDelegate
|
|
156
|
+
def initialize(browser, frame)
|
|
157
|
+
@browser = browser
|
|
158
|
+
@frame = frame
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def navigate(url:, source:, method: "GET", body: nil, params: nil, enctype: nil, target: nil, headers: {}, replace: false)
|
|
162
|
+
@browser.__internal_load_frame__(@frame,
|
|
163
|
+
{url: url, method: method, body: body, params: params, enctype: enctype, headers: headers})
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def traverse(_delta) = nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def frame_navigation_delegate(frame) = FrameNavigationDelegate.new(self, frame)
|
|
170
|
+
|
|
171
|
+
# Load `nav` into `frame`, resolving the URL against the frame's own document,
|
|
172
|
+
# then fire the frame's `load`. Public (an `__internal_` seam) so a host that
|
|
173
|
+
# injected the frame's content document can wire it as that frame's delegate.
|
|
174
|
+
def __internal_load_frame__(frame, nav)
|
|
175
|
+
base = frame.content_window&.location&.__js_get__("href").to_s
|
|
176
|
+
resolved = begin
|
|
177
|
+
URI.join(base, nav[:url].to_s).to_s
|
|
178
|
+
rescue URI::InvalidURIError, ArgumentError
|
|
179
|
+
nav[:url].to_s
|
|
180
|
+
end
|
|
181
|
+
navigate_frame(frame, nav, resolved)
|
|
182
|
+
frame.dispatch_event(Dommy::Event.new("load"))
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
|
|
152
186
|
# A cross-document history traversal. Ruby-initiated (back / forward), so it
|
|
153
187
|
# runs immediately: a same-document target (its window is still live)
|
|
154
188
|
# traverses in place (popstate); a document-boundary target is re-fetched.
|
|
@@ -238,22 +272,19 @@ module Dommy
|
|
|
238
272
|
|
|
239
273
|
# Suppress strict-mode failure for JS errors raised inside the block (they
|
|
240
274
|
# stay collected in #js_errors for inspection). For tests that expect errors.
|
|
241
|
-
def allow_js_errors
|
|
242
|
-
|
|
243
|
-
@allow_errors = true
|
|
244
|
-
yield
|
|
245
|
-
ensure
|
|
246
|
-
@allow_errors = prev
|
|
247
|
-
@acknowledged = @js_errors.length
|
|
275
|
+
def allow_js_errors(&block)
|
|
276
|
+
@error_log.allow(&block)
|
|
248
277
|
end
|
|
249
278
|
|
|
279
|
+
# Tear the realm down, then fail on anything the page left unhandled and
|
|
280
|
+
# nobody has reported. Disposing first means the failure cannot leave a live
|
|
281
|
+
# VM behind.
|
|
250
282
|
def dispose
|
|
251
283
|
return if @disposed
|
|
252
284
|
|
|
253
285
|
@disposed = true
|
|
254
|
-
pending = unacknowledged
|
|
255
286
|
@runtime&.dispose
|
|
256
|
-
|
|
287
|
+
@error_log.check!(context: current_url)
|
|
257
288
|
end
|
|
258
289
|
|
|
259
290
|
private
|
|
@@ -268,8 +299,19 @@ module Dommy
|
|
|
268
299
|
# The JS engine is pluggable: `@backend` selects a registered runtime
|
|
269
300
|
# (nil → the configured default, QuickJS when dommy-js-quickjs is loaded).
|
|
270
301
|
runtime = Js.build_runtime(@backend)
|
|
271
|
-
|
|
272
|
-
|
|
302
|
+
# Every uncaught error the page produces goes through the window's
|
|
303
|
+
# "report an exception" / "notify about rejected promises" funnel, and only
|
|
304
|
+
# what the page left unhandled lands here. A page that installs its own
|
|
305
|
+
# `window.onerror` / `unhandledrejection` handler and cancels the event
|
|
306
|
+
# suppresses the failure, exactly as it would in a browser.
|
|
307
|
+
window.__internal_on_unhandled_error__ { |err| @error_log.record(err) }
|
|
308
|
+
# A rejection the page handled after we reported it is retracted, so it
|
|
309
|
+
# stops failing anything (WHATWG `rejectionhandled`).
|
|
310
|
+
window.__internal_on_rejection_handled__ { |record| @error_log.retract(record) }
|
|
311
|
+
runtime.on_unhandled_rejection { |err| report_rejection(window, err) }
|
|
312
|
+
if runtime.respond_to?(:on_callback_error)
|
|
313
|
+
runtime.on_callback_error { |err| Internal::ExceptionReport.report_at(window, err) }
|
|
314
|
+
end
|
|
273
315
|
runtime.on_log { |log| @console << log }
|
|
274
316
|
runtime.define_host_object("document", window.document)
|
|
275
317
|
runtime.install_window(window)
|
|
@@ -285,24 +327,35 @@ module Dommy
|
|
|
285
327
|
# Installed whenever a runtime is attached — an embedder that drives script
|
|
286
328
|
# boot itself (`execute_scripts: false`) still needs inline handlers wired.
|
|
287
329
|
doc.inline_handler_wirer = lambda do
|
|
288
|
-
Js::ScriptBoot.wire_inline_handlers(runtime, on_error: ->(e) { @
|
|
330
|
+
Js::ScriptBoot.wire_inline_handlers(runtime, on_error: ->(e) { @error_log.record(e) })
|
|
289
331
|
end
|
|
290
332
|
return unless @execute_scripts
|
|
291
333
|
|
|
334
|
+
# `on_error:` here is only the windowless fallback — a booted document has
|
|
335
|
+
# a window, so a throwing script reports through it (see ScriptBoot).
|
|
336
|
+
|
|
292
337
|
# Dynamically-inserted `<script src>` (webpack/Vite on-demand chunks)
|
|
293
338
|
# fetch + run through the same resources adapter, after boot.
|
|
294
339
|
doc.external_script_runner = lambda do |element, src|
|
|
295
340
|
Js::ScriptBoot.run_external_script(runtime, doc, element, src,
|
|
296
|
-
resources: @resources, on_error: ->(e) { @
|
|
341
|
+
resources: @resources, on_error: ->(e) { @error_log.record(e) })
|
|
297
342
|
end
|
|
298
343
|
Js::ScriptBoot.run_document_scripts(
|
|
299
|
-
runtime, doc, resources: @resources, on_error: ->(e) { @
|
|
344
|
+
runtime, doc, resources: @resources, on_error: ->(e) { @error_log.record(e) }
|
|
300
345
|
)
|
|
301
346
|
# Leave the page in a ready state: run on-load promises, due-now timers,
|
|
302
347
|
# and rAF (not future timers). `settle: false` observes it mid-flight.
|
|
303
348
|
runtime.settle if @settle_after_boot
|
|
304
349
|
end
|
|
305
350
|
|
|
351
|
+
# Route an unhandled promise rejection through the page's own
|
|
352
|
+
# `unhandledrejection` handling first. The engine decides WHEN a rejection
|
|
353
|
+
# counts as unhandled (see Window#__internal_report_rejection__).
|
|
354
|
+
def report_rejection(window, error)
|
|
355
|
+
value = Internal::ExceptionReport.error_value(error)
|
|
356
|
+
window.__internal_report_rejection__(value, host_error: error)
|
|
357
|
+
end
|
|
358
|
+
|
|
306
359
|
# Perform a recorded navigation: fetch the target (following redirects),
|
|
307
360
|
# fire the old document's unload, then replace the Window + JS realm with the
|
|
308
361
|
# freshly parsed document and update the joint history. A network miss or a
|
|
@@ -310,8 +363,16 @@ module Dommy
|
|
|
310
363
|
MAX_META_REFRESHES = 20
|
|
311
364
|
|
|
312
365
|
def perform_navigation!(nav, rebind: false, refresh_depth: 0)
|
|
366
|
+
# A form's action is a document-relative URL; resolve it against the
|
|
367
|
+
# current address (links already arrive resolved via Location).
|
|
368
|
+
resolved = resolve_against_current(nav[:url].to_s)
|
|
369
|
+
# A `target` naming an iframe in this document navigates that nested
|
|
370
|
+
# browsing context instead of replacing the top-level page.
|
|
371
|
+
frame = resolve_target_frame(nav[:target])
|
|
372
|
+
return navigate_frame(frame, nav, resolved) if frame
|
|
373
|
+
|
|
313
374
|
response, final_url = @fetcher.request(
|
|
314
|
-
method: nav[:method] || "GET", url:
|
|
375
|
+
method: nav[:method] || "GET", url: resolved, params: nav[:params],
|
|
315
376
|
body: nav[:body], enctype: nav[:enctype], headers: nav[:headers] || {}
|
|
316
377
|
)
|
|
317
378
|
return unless response&.success? && document_response?(response)
|
|
@@ -337,6 +398,53 @@ module Dommy
|
|
|
337
398
|
follow_meta_refresh!(refresh_depth)
|
|
338
399
|
end
|
|
339
400
|
|
|
401
|
+
# The reserved browsing-context keywords; anything else names an iframe.
|
|
402
|
+
RESERVED_TARGETS = %w[_self _top _parent _blank].freeze
|
|
403
|
+
|
|
404
|
+
# The iframe a `target` names in the current document, or nil when the
|
|
405
|
+
# target is empty / a reserved keyword / names no frame (then the navigation
|
|
406
|
+
# is top-level, the only context Dommy models).
|
|
407
|
+
def resolve_target_frame(target)
|
|
408
|
+
name = target.to_s
|
|
409
|
+
return nil if name.empty? || RESERVED_TARGETS.include?(name.downcase)
|
|
410
|
+
|
|
411
|
+
@window.document.query_selector_all("iframe").find do |frame|
|
|
412
|
+
frame.get_attribute("name").to_s == name
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# Load a navigation into a nested browsing context (a named iframe): fetch
|
|
417
|
+
# it, install the response document as the frame's content, and fire the
|
|
418
|
+
# frame's `load`. The top-level window and joint history are left alone.
|
|
419
|
+
def navigate_frame(frame, nav, resolved_url)
|
|
420
|
+
response, final_url = @fetcher.request(
|
|
421
|
+
method: nav[:method] || "GET", url: resolved_url, params: nav[:params],
|
|
422
|
+
body: nav[:body], enctype: nav[:enctype], headers: nav[:headers] || {}
|
|
423
|
+
)
|
|
424
|
+
return unless response&.success?
|
|
425
|
+
|
|
426
|
+
sub_window = frame_document_for(response)
|
|
427
|
+
sub_window.location.__internal_set_url__(final_url)
|
|
428
|
+
# A navigation from inside the loaded frame also stays in that frame.
|
|
429
|
+
sub_window.navigation_delegate = frame_navigation_delegate(frame)
|
|
430
|
+
# A nested realm needs the seeded constructors to run the response's
|
|
431
|
+
# scripts; a runtime that cannot expose them simply runs without them.
|
|
432
|
+
@runtime.expose_constructors_on(sub_window) if @runtime.respond_to?(:expose_constructors_on)
|
|
433
|
+
frame.__internal_set_content_document__(sub_window.document)
|
|
434
|
+
nil
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# The document a frame shows for a response: HTML/XML is parsed as-is; a
|
|
438
|
+
# non-document response (e.g. text/plain from an echo endpoint) is displayed
|
|
439
|
+
# as text, so the frame gets a document whose body holds it.
|
|
440
|
+
def frame_document_for(response)
|
|
441
|
+
return Dommy.parse(response.body) if document_response?(response)
|
|
442
|
+
|
|
443
|
+
win = Dommy.parse("<!doctype html><html><head></head><body></body></html>")
|
|
444
|
+
win.document.body.text_content = response.body.to_s.dup.force_encoding(Encoding::UTF_8)
|
|
445
|
+
win
|
|
446
|
+
end
|
|
447
|
+
|
|
340
448
|
# If the freshly loaded document asks for an immediate `<meta http-equiv=
|
|
341
449
|
# refresh>`, follow it (as a replace, like a redirect), capped so a page that
|
|
342
450
|
# refreshes to itself can't loop forever.
|
|
@@ -377,12 +485,11 @@ module Dommy
|
|
|
377
485
|
# the swap never runs with the outgoing realm's JS on the stack.
|
|
378
486
|
def flush_navigation!
|
|
379
487
|
return unless @navigable
|
|
488
|
+
return if @pending_navigations.empty?
|
|
380
489
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
@pending_navigation = nil
|
|
385
|
-
perform_navigation!(nav)
|
|
490
|
+
navs = @pending_navigations
|
|
491
|
+
@pending_navigations = []
|
|
492
|
+
navs.each { |nav| perform_navigation!(nav) }
|
|
386
493
|
end
|
|
387
494
|
|
|
388
495
|
def fire_unload(window)
|
|
@@ -400,8 +507,6 @@ module Dommy
|
|
|
400
507
|
content_type.empty? || content_type.include?("html") || content_type.include?("xml")
|
|
401
508
|
end
|
|
402
509
|
|
|
403
|
-
def unacknowledged = @js_errors[@acknowledged..] || []
|
|
404
|
-
|
|
405
510
|
def submit_button?(button)
|
|
406
511
|
if button.tag_name == "BUTTON"
|
|
407
512
|
button.type == "submit"
|
|
@@ -410,18 +515,10 @@ module Dommy
|
|
|
410
515
|
end
|
|
411
516
|
end
|
|
412
517
|
|
|
413
|
-
# In strict mode, fail on
|
|
414
|
-
#
|
|
415
|
-
# at most once.
|
|
518
|
+
# In strict mode, fail on anything the page left unhandled since the last
|
|
519
|
+
# checkpoint. The ledger drains itself, so each error is reported once.
|
|
416
520
|
def check_js_errors!
|
|
417
|
-
|
|
418
|
-
return unless @strict
|
|
419
|
-
|
|
420
|
-
pending = unacknowledged
|
|
421
|
-
return if pending.empty?
|
|
422
|
-
|
|
423
|
-
@acknowledged = @js_errors.length
|
|
424
|
-
raise JsError, pending
|
|
521
|
+
@error_log.check!(context: current_url)
|
|
425
522
|
end
|
|
426
523
|
end
|
|
427
524
|
end
|
|
@@ -20,6 +20,22 @@ module Dommy
|
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# Invoke `callback` so a JS throw SURFACES as a Ruby exception instead of
|
|
24
|
+
# being swallowed by the bridge. WHATWG requires a task's exception to be
|
|
25
|
+
# reported at the global ("report an exception") rather than silently
|
|
26
|
+
# dropped, so the entry points that own a task — a timer / rAF callback —
|
|
27
|
+
# invoke this form and report whatever comes out. The plain `invoke` keeps
|
|
28
|
+
# the swallowing behavior for callers that handle their own errors.
|
|
29
|
+
def invoke_raising(callback, *args)
|
|
30
|
+
return if callback.nil?
|
|
31
|
+
|
|
32
|
+
if callback.respond_to?(:__js_invoke__)
|
|
33
|
+
callback.__js_invoke__(args, raising: true)
|
|
34
|
+
else
|
|
35
|
+
invoke(callback, *args)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
23
39
|
# Invoke a DOM event listener per the EventTarget rule: an object with
|
|
24
40
|
# `handle_event`, else a Ruby callable, else a JS-bridged callable (tried in
|
|
25
41
|
# that order). A JS function listener's `this` must be the event's
|