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.
Files changed (165) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/backend/makiri_adapter.rb +63 -29
  3. data/lib/dommy/backend.rb +32 -9
  4. data/lib/dommy/bridge/constructor.rb +72 -0
  5. data/lib/dommy/bridge/errors.rb +42 -0
  6. data/lib/dommy/bridge/methods.rb +21 -14
  7. data/lib/dommy/bridge/sentinels.rb +39 -0
  8. data/lib/dommy/bridge/values.rb +78 -0
  9. data/lib/dommy/{js → bridge}/wire_tags.rb +22 -11
  10. data/lib/dommy/bridge.rb +22 -237
  11. data/lib/dommy/browser.rb +155 -58
  12. data/lib/dommy/callable_invoker.rb +16 -0
  13. data/lib/dommy/character_data.rb +539 -0
  14. data/lib/dommy/class_list.rb +311 -0
  15. data/lib/dommy/compression_streams.rb +12 -62
  16. data/lib/dommy/crypto.rb +12 -1
  17. data/lib/dommy/css.rb +12 -0
  18. data/lib/dommy/data_transfer.rb +106 -0
  19. data/lib/dommy/document.rb +312 -374
  20. data/lib/dommy/dom_parser.rb +3 -0
  21. data/lib/dommy/element.rb +201 -1820
  22. data/lib/dommy/encodings/tables.rb +751 -0
  23. data/lib/dommy/encodings.rb +330 -0
  24. data/lib/dommy/event.rb +231 -6
  25. data/lib/dommy/event_source.rb +41 -0
  26. data/lib/dommy/fetch.rb +60 -49
  27. data/lib/dommy/form_data.rb +16 -64
  28. data/lib/dommy/form_entry_list.rb +227 -0
  29. data/lib/dommy/fragment.rb +320 -0
  30. data/lib/dommy/history.rb +3 -0
  31. data/lib/dommy/html_collection.rb +18 -8
  32. data/lib/dommy/html_elements/base.rb +338 -0
  33. data/lib/dommy/html_elements/embedded.rb +237 -0
  34. data/lib/dommy/html_elements/forms/form.rb +1088 -0
  35. data/lib/dommy/html_elements/forms/input.rb +642 -0
  36. data/lib/dommy/html_elements/forms/select.rb +536 -0
  37. data/lib/dommy/html_elements/forms.rb +8 -0
  38. data/lib/dommy/html_elements/interactive.rb +382 -0
  39. data/lib/dommy/html_elements/media.rb +329 -0
  40. data/lib/dommy/html_elements/metadata.rb +353 -0
  41. data/lib/dommy/html_elements/miscellaneous.rb +77 -0
  42. data/lib/dommy/html_elements/tables.rb +468 -0
  43. data/lib/dommy/html_elements.rb +11 -5248
  44. data/lib/dommy/hyperlink_utils.rb +171 -0
  45. data/lib/dommy/interaction/debug.rb +7 -6
  46. data/lib/dommy/interaction/dom_summary.rb +65 -40
  47. data/lib/dommy/interaction/driver.rb +29 -115
  48. data/lib/dommy/interaction/event_synthesis.rb +97 -4
  49. data/lib/dommy/interaction/field_interactor.rb +1 -13
  50. data/lib/dommy/interaction/form_submission.rb +89 -167
  51. data/lib/dommy/interaction/key_sender.rb +121 -0
  52. data/lib/dommy/interaction/mime_types.rb +25 -0
  53. data/lib/dommy/interaction.rb +2 -0
  54. data/lib/dommy/internal/accessibility_tree.rb +32 -9
  55. data/lib/dommy/internal/accessibility_visibility.rb +44 -0
  56. data/lib/dommy/internal/accessible_name.rb +85 -65
  57. data/lib/dommy/internal/aria_role.rb +21 -6
  58. data/lib/dommy/internal/aria_snapshot.rb +5 -0
  59. data/lib/dommy/internal/aria_state.rb +24 -5
  60. data/lib/dommy/internal/autofill.rb +141 -0
  61. data/lib/dommy/internal/backend_prefilter.rb +170 -0
  62. data/lib/dommy/internal/bounded_cache.rb +50 -0
  63. data/lib/dommy/internal/child_node.rb +9 -34
  64. data/lib/dommy/internal/css/calc.rb +143 -145
  65. data/lib/dommy/internal/css/cascade.rb +38 -393
  66. data/lib/dommy/internal/css/cascaded_declarations.rb +245 -0
  67. data/lib/dommy/internal/css/color.rb +61 -34
  68. data/lib/dommy/internal/css/computed_style_builder.rb +205 -0
  69. data/lib/dommy/internal/css/computed_style_declaration.rb +20 -4
  70. data/lib/dommy/internal/css/counters.rb +60 -37
  71. data/lib/dommy/internal/css/custom_properties.rb +87 -39
  72. data/lib/dommy/internal/css/media_query.rb +228 -203
  73. data/lib/dommy/internal/css/parser.rb +29 -12
  74. data/lib/dommy/internal/css/property_registry.rb +32 -56
  75. data/lib/dommy/internal/css/renderability.rb +61 -0
  76. data/lib/dommy/internal/css/rule_index.rb +17 -140
  77. data/lib/dommy/internal/css/selector_dependencies.rb +162 -0
  78. data/lib/dommy/internal/css/style_cache.rb +61 -0
  79. data/lib/dommy/internal/css/ua_stylesheet.rb +40 -1
  80. data/lib/dommy/internal/css/value_tokens.rb +60 -0
  81. data/lib/dommy/internal/directionality.rb +188 -0
  82. data/lib/dommy/internal/document_generations.rb +169 -0
  83. data/lib/dommy/internal/document_interaction_state.rb +47 -0
  84. data/lib/dommy/internal/document_live_ranges.rb +97 -0
  85. data/lib/dommy/internal/dom_matching.rb +28 -12
  86. data/lib/dommy/internal/element_aria.rb +163 -0
  87. data/lib/dommy/internal/element_geometry.rb +69 -0
  88. data/lib/dommy/internal/element_matching.rb +5 -12
  89. data/lib/dommy/internal/element_shadow.rb +117 -0
  90. data/lib/dommy/internal/element_state.rb +232 -0
  91. data/lib/dommy/internal/element_tasks.rb +19 -0
  92. data/lib/dommy/internal/element_top_layer.rb +63 -0
  93. data/lib/dommy/internal/enumerated_keyword_sets.rb +53 -0
  94. data/lib/dommy/internal/exception_report.rb +123 -0
  95. data/lib/dommy/internal/idna.rb +4 -4
  96. data/lib/dommy/internal/idna_data.rb +513 -100
  97. data/lib/dommy/internal/input_type.rb +280 -0
  98. data/lib/dommy/internal/insertion_point.rb +62 -0
  99. data/lib/dommy/internal/mime_type.rb +74 -0
  100. data/lib/dommy/internal/mutation_coordinator.rb +57 -198
  101. data/lib/dommy/internal/namespaces.rb +41 -43
  102. data/lib/dommy/internal/node_adopter.rb +84 -77
  103. data/lib/dommy/internal/node_factory.rb +157 -0
  104. data/lib/dommy/internal/node_identity.rb +31 -0
  105. data/lib/dommy/internal/node_wrapper_cache.rb +61 -207
  106. data/lib/dommy/internal/observer_options.rb +93 -0
  107. data/lib/dommy/internal/parent_node.rb +27 -27
  108. data/lib/dommy/internal/post_insertion_steps.rb +213 -0
  109. data/lib/dommy/internal/reflected_attributes.rb +425 -13
  110. data/lib/dommy/internal/rendered_text/collector.rb +221 -0
  111. data/lib/dommy/internal/rendered_text/computed_box.rb +39 -0
  112. data/lib/dommy/internal/rendered_text/fragment.rb +55 -0
  113. data/lib/dommy/internal/rendered_text/items.rb +57 -0
  114. data/lib/dommy/internal/rendered_text/whitespace_collapser.rb +132 -0
  115. data/lib/dommy/internal/selector_arguments.rb +94 -0
  116. data/lib/dommy/internal/selector_index.rb +3 -1
  117. data/lib/dommy/internal/selector_matcher.rb +23 -383
  118. data/lib/dommy/internal/selector_parser.rb +2 -37
  119. data/lib/dommy/internal/template_content_registry.rb +112 -66
  120. data/lib/dommy/internal/text_flattening.rb +20 -0
  121. data/lib/dommy/internal/text_selection.rb +104 -0
  122. data/lib/dommy/internal/toggle_events.rb +56 -0
  123. data/lib/dommy/internal/toggle_task_tracker.rb +57 -0
  124. data/lib/dommy/internal/url_parser.rb +732 -527
  125. data/lib/dommy/internal/url_record_accessors.rb +90 -0
  126. data/lib/dommy/internal/window_constructors.rb +190 -0
  127. data/lib/dommy/internal/xml_serialization.rb +168 -80
  128. data/lib/dommy/js/crossing_profile.rb +75 -0
  129. data/lib/dommy/js/dom_interfaces.rb +23 -2
  130. data/lib/dommy/js/error_log.rb +137 -0
  131. data/lib/dommy/js/host_bridge.rb +56 -187
  132. data/lib/dommy/js/host_callbacks.rb +96 -0
  133. data/lib/dommy/js/host_runtime.js +896 -729
  134. data/lib/dommy/js/import_map.rb +10 -1
  135. data/lib/dommy/js/marshaller.rb +111 -63
  136. data/lib/dommy/js/observable_runtime.js +125 -146
  137. data/lib/dommy/js/runtime.rb +17 -43
  138. data/lib/dommy/js/runtime_registry.rb +46 -0
  139. data/lib/dommy/js/script_boot.rb +94 -85
  140. data/lib/dommy/js/webidl_tables.js +619 -0
  141. data/lib/dommy/js/window_wiring.rb +69 -0
  142. data/lib/dommy/location.rb +161 -102
  143. data/lib/dommy/mutation_observer.rb +3 -57
  144. data/lib/dommy/navigation.rb +65 -12
  145. data/lib/dommy/navigator.rb +3 -1
  146. data/lib/dommy/node.rb +46 -20
  147. data/lib/dommy/promise.rb +62 -8
  148. data/lib/dommy/resources.rb +19 -1
  149. data/lib/dommy/scheduler.rb +35 -6
  150. data/lib/dommy/shadow_root.rb +11 -5
  151. data/lib/dommy/streams.rb +1200 -187
  152. data/lib/dommy/style_declaration.rb +284 -0
  153. data/lib/dommy/svg_elements.rb +1 -45
  154. data/lib/dommy/text_codec.rb +110 -213
  155. data/lib/dommy/url.rb +32 -79
  156. data/lib/dommy/url_pattern/regexp_translator.rb +1 -6
  157. data/lib/dommy/url_pattern/tokenizer.rb +4 -2
  158. data/lib/dommy/validity_state.rb +383 -0
  159. data/lib/dommy/version.rb +1 -1
  160. data/lib/dommy/window.rb +184 -192
  161. data/lib/dommy/xml_http_request.rb +52 -7
  162. data/lib/dommy.rb +25 -3
  163. metadata +78 -7
  164. data/lib/dommy/bridge/constructor_registry.rb +0 -28
  165. 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` — adapter classes for JS-style bridges (wasm
5
- # embedders that route DOM method calls and constructor `new` ops
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
- # CRuby users writing happy-dom-style tests can ignore everything
10
- # in this namespace; it's only relevant when integrating Dommy
11
- # with an external runtime (such as an mruby-on-wasm host) that
12
- # constructs callbacks / events / promises via the bridge view.
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
- module Bridge
21
- # Sentinel returned by `__js_set__` when a key is not a known DOM property
22
- # (so the JS host can keep it as a JS-side expando, preserving identity)
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
- # Raised in strict mode when JS errors were collected and not acknowledged.
27
- class JsError < StandardError
28
- attr_reader :causes
26
+ attr_reader :window, :runtime, :console
29
27
 
30
- def initialize(causes)
31
- @causes = causes
32
- super(build_message(causes))
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
- attr_reader :window, :runtime, :js_errors, :console
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
- @js_errors = []
70
+ @error_log = Js::ErrorLog.new(strict: strict)
82
71
  @console = []
83
- @acknowledged = 0
84
- @allow_errors = false
85
72
  @disposed = false
86
- @pending_navigation = nil
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
- @pending_navigation = {url: url.to_s, method: "GET", source: :visit, replace: replace}
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
- @pending_navigation = {
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
- prev = @allow_errors
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
- raise JsError, pending if @strict && !pending.empty?
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
- runtime.on_unhandled_rejection { |err| @js_errors << err }
272
- runtime.on_callback_error { |err| @js_errors << err } if runtime.respond_to?(:on_callback_error)
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) { @js_errors << 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) { @js_errors << 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) { @js_errors << 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: nav[:url], params: nav[:params],
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
- nav = @pending_navigation
382
- return unless nav
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 any JS error collected since the last
414
- # acknowledgement. Marks all current errors acknowledged so each is reported
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
- return if @allow_errors
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