dommy 0.13.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 (155) 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 +14 -11
  10. data/lib/dommy/bridge.rb +22 -250
  11. data/lib/dommy/browser.rb +112 -11
  12. data/lib/dommy/character_data.rb +539 -0
  13. data/lib/dommy/class_list.rb +311 -0
  14. data/lib/dommy/crypto.rb +12 -1
  15. data/lib/dommy/css.rb +12 -0
  16. data/lib/dommy/data_transfer.rb +106 -0
  17. data/lib/dommy/document.rb +307 -369
  18. data/lib/dommy/dom_parser.rb +3 -0
  19. data/lib/dommy/element.rb +199 -1817
  20. data/lib/dommy/encodings/tables.rb +4 -4
  21. data/lib/dommy/encodings.rb +58 -27
  22. data/lib/dommy/event.rb +227 -1
  23. data/lib/dommy/event_source.rb +41 -0
  24. data/lib/dommy/form_data.rb +6 -64
  25. data/lib/dommy/form_entry_list.rb +227 -0
  26. data/lib/dommy/fragment.rb +320 -0
  27. data/lib/dommy/history.rb +3 -0
  28. data/lib/dommy/html_collection.rb +18 -8
  29. data/lib/dommy/html_elements/base.rb +338 -0
  30. data/lib/dommy/html_elements/embedded.rb +237 -0
  31. data/lib/dommy/html_elements/forms/form.rb +1088 -0
  32. data/lib/dommy/html_elements/forms/input.rb +642 -0
  33. data/lib/dommy/html_elements/forms/select.rb +536 -0
  34. data/lib/dommy/html_elements/forms.rb +8 -0
  35. data/lib/dommy/html_elements/interactive.rb +382 -0
  36. data/lib/dommy/html_elements/media.rb +329 -0
  37. data/lib/dommy/html_elements/metadata.rb +353 -0
  38. data/lib/dommy/html_elements/miscellaneous.rb +77 -0
  39. data/lib/dommy/html_elements/tables.rb +468 -0
  40. data/lib/dommy/html_elements.rb +11 -5322
  41. data/lib/dommy/hyperlink_utils.rb +171 -0
  42. data/lib/dommy/interaction/debug.rb +7 -6
  43. data/lib/dommy/interaction/dom_summary.rb +65 -40
  44. data/lib/dommy/interaction/driver.rb +29 -115
  45. data/lib/dommy/interaction/event_synthesis.rb +97 -4
  46. data/lib/dommy/interaction/field_interactor.rb +1 -13
  47. data/lib/dommy/interaction/form_submission.rb +89 -167
  48. data/lib/dommy/interaction/key_sender.rb +121 -0
  49. data/lib/dommy/interaction/mime_types.rb +25 -0
  50. data/lib/dommy/interaction.rb +2 -0
  51. data/lib/dommy/internal/accessibility_tree.rb +32 -9
  52. data/lib/dommy/internal/accessibility_visibility.rb +44 -0
  53. data/lib/dommy/internal/accessible_name.rb +85 -65
  54. data/lib/dommy/internal/aria_role.rb +21 -6
  55. data/lib/dommy/internal/aria_snapshot.rb +5 -0
  56. data/lib/dommy/internal/aria_state.rb +24 -5
  57. data/lib/dommy/internal/autofill.rb +141 -0
  58. data/lib/dommy/internal/backend_prefilter.rb +170 -0
  59. data/lib/dommy/internal/bounded_cache.rb +50 -0
  60. data/lib/dommy/internal/child_node.rb +9 -34
  61. data/lib/dommy/internal/css/calc.rb +143 -145
  62. data/lib/dommy/internal/css/cascade.rb +38 -393
  63. data/lib/dommy/internal/css/cascaded_declarations.rb +245 -0
  64. data/lib/dommy/internal/css/color.rb +61 -34
  65. data/lib/dommy/internal/css/computed_style_builder.rb +205 -0
  66. data/lib/dommy/internal/css/computed_style_declaration.rb +20 -4
  67. data/lib/dommy/internal/css/counters.rb +60 -37
  68. data/lib/dommy/internal/css/custom_properties.rb +87 -39
  69. data/lib/dommy/internal/css/media_query.rb +228 -203
  70. data/lib/dommy/internal/css/parser.rb +29 -12
  71. data/lib/dommy/internal/css/property_registry.rb +32 -56
  72. data/lib/dommy/internal/css/renderability.rb +61 -0
  73. data/lib/dommy/internal/css/rule_index.rb +17 -140
  74. data/lib/dommy/internal/css/selector_dependencies.rb +162 -0
  75. data/lib/dommy/internal/css/style_cache.rb +61 -0
  76. data/lib/dommy/internal/css/ua_stylesheet.rb +40 -1
  77. data/lib/dommy/internal/css/value_tokens.rb +60 -0
  78. data/lib/dommy/internal/directionality.rb +188 -0
  79. data/lib/dommy/internal/document_generations.rb +169 -0
  80. data/lib/dommy/internal/document_interaction_state.rb +47 -0
  81. data/lib/dommy/internal/document_live_ranges.rb +97 -0
  82. data/lib/dommy/internal/dom_matching.rb +28 -12
  83. data/lib/dommy/internal/element_aria.rb +163 -0
  84. data/lib/dommy/internal/element_geometry.rb +69 -0
  85. data/lib/dommy/internal/element_matching.rb +5 -12
  86. data/lib/dommy/internal/element_shadow.rb +117 -0
  87. data/lib/dommy/internal/element_state.rb +232 -0
  88. data/lib/dommy/internal/element_tasks.rb +19 -0
  89. data/lib/dommy/internal/element_top_layer.rb +63 -0
  90. data/lib/dommy/internal/enumerated_keyword_sets.rb +53 -0
  91. data/lib/dommy/internal/exception_report.rb +1 -1
  92. data/lib/dommy/internal/input_type.rb +280 -0
  93. data/lib/dommy/internal/insertion_point.rb +62 -0
  94. data/lib/dommy/internal/mime_type.rb +74 -0
  95. data/lib/dommy/internal/mutation_coordinator.rb +57 -198
  96. data/lib/dommy/internal/namespaces.rb +41 -43
  97. data/lib/dommy/internal/node_adopter.rb +84 -77
  98. data/lib/dommy/internal/node_factory.rb +157 -0
  99. data/lib/dommy/internal/node_identity.rb +31 -0
  100. data/lib/dommy/internal/node_wrapper_cache.rb +61 -207
  101. data/lib/dommy/internal/observer_options.rb +93 -0
  102. data/lib/dommy/internal/parent_node.rb +27 -27
  103. data/lib/dommy/internal/post_insertion_steps.rb +213 -0
  104. data/lib/dommy/internal/reflected_attributes.rb +425 -13
  105. data/lib/dommy/internal/rendered_text/collector.rb +221 -0
  106. data/lib/dommy/internal/rendered_text/computed_box.rb +39 -0
  107. data/lib/dommy/internal/rendered_text/fragment.rb +55 -0
  108. data/lib/dommy/internal/rendered_text/items.rb +57 -0
  109. data/lib/dommy/internal/rendered_text/whitespace_collapser.rb +132 -0
  110. data/lib/dommy/internal/selector_arguments.rb +94 -0
  111. data/lib/dommy/internal/selector_index.rb +3 -1
  112. data/lib/dommy/internal/selector_matcher.rb +23 -383
  113. data/lib/dommy/internal/selector_parser.rb +2 -37
  114. data/lib/dommy/internal/template_content_registry.rb +112 -66
  115. data/lib/dommy/internal/text_flattening.rb +20 -0
  116. data/lib/dommy/internal/text_selection.rb +104 -0
  117. data/lib/dommy/internal/toggle_events.rb +56 -0
  118. data/lib/dommy/internal/toggle_task_tracker.rb +57 -0
  119. data/lib/dommy/internal/url_parser.rb +722 -569
  120. data/lib/dommy/internal/url_record_accessors.rb +90 -0
  121. data/lib/dommy/internal/window_constructors.rb +190 -0
  122. data/lib/dommy/internal/xml_serialization.rb +168 -80
  123. data/lib/dommy/js/crossing_profile.rb +75 -0
  124. data/lib/dommy/js/dom_interfaces.rb +22 -2
  125. data/lib/dommy/js/host_bridge.rb +43 -187
  126. data/lib/dommy/js/host_callbacks.rb +96 -0
  127. data/lib/dommy/js/host_runtime.js +840 -763
  128. data/lib/dommy/js/import_map.rb +10 -1
  129. data/lib/dommy/js/marshaller.rb +94 -65
  130. data/lib/dommy/js/observable_runtime.js +125 -146
  131. data/lib/dommy/js/runtime.rb +10 -43
  132. data/lib/dommy/js/runtime_registry.rb +46 -0
  133. data/lib/dommy/js/script_boot.rb +38 -73
  134. data/lib/dommy/js/webidl_tables.js +619 -0
  135. data/lib/dommy/js/window_wiring.rb +69 -0
  136. data/lib/dommy/location.rb +152 -103
  137. data/lib/dommy/mutation_observer.rb +3 -57
  138. data/lib/dommy/navigation.rb +65 -12
  139. data/lib/dommy/node.rb +46 -20
  140. data/lib/dommy/promise.rb +58 -4
  141. data/lib/dommy/resources.rb +19 -1
  142. data/lib/dommy/shadow_root.rb +11 -5
  143. data/lib/dommy/style_declaration.rb +284 -0
  144. data/lib/dommy/svg_elements.rb +1 -45
  145. data/lib/dommy/url.rb +20 -69
  146. data/lib/dommy/url_pattern/regexp_translator.rb +1 -6
  147. data/lib/dommy/url_pattern/tokenizer.rb +4 -2
  148. data/lib/dommy/validity_state.rb +383 -0
  149. data/lib/dommy/version.rb +1 -1
  150. data/lib/dommy/window.rb +30 -166
  151. data/lib/dommy/xml_http_request.rb +35 -4
  152. data/lib/dommy.rb +23 -3
  153. metadata +74 -7
  154. data/lib/dommy/bridge/constructor_registry.rb +0 -28
  155. 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,246 +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
- # `stack_frames` are the value's JS stack, when the tag that crossed
61
- # carried one. Ruby cannot reach through a ref to read `.stack`, so an
62
- # error-shaped value brings its frames with it (see host_runtime.js
63
- # tagValue) and reporting uses them for the source position.
64
- attr_reader :stack_frames
65
-
66
- # The value's JS constructor name, when the tag carried one.
67
- attr_reader :js_name
68
-
69
- def initialize(ref, label = nil, stack_frames = nil, js_name = nil)
70
- @ref = ref
71
- @label = label
72
- @stack_frames = stack_frames
73
- @js_name = js_name
74
- end
75
-
76
- def to_s = (@label || "[object]").to_s
77
- def inspect = "#<Dommy::Bridge::JSValue #{to_s}>"
78
- end
79
-
80
- # Raised by a host method that must throw an ARBITRARY value back to JS —
81
- # not a DOMException/Error, but e.g. `signal.throwIfAborted()` throwing the
82
- # exact abort reason (a string, number, or opaque JSValue). The bridge
83
- # re-throws the wrapped value verbatim (identity preserved). Subclasses
84
- # RuntimeError (with the value's string form as the message) so standalone
85
- # CRuby callers still see a normal `raise`-able error.
86
- class ThrowValue < RuntimeError
87
- attr_reader :value
88
-
89
- # `message` overrides the value's string form, for a caller that can say
90
- # more about it than `to_s` can (a JS error's kind, say).
91
- def initialize(value, message = nil)
92
- @value = value
93
- super(message || value.to_s)
94
- end
95
- end
96
-
97
- # A byte buffer that crosses the JS boundary as a `Uint8Array` (rather than a
98
- # plain Array). Wrap a host method's byte-array result in this so JS sees a
99
- # real typed array — e.g. `TextEncoder#encode`, `Blob#arrayBuffer`. The
100
- # reverse direction (a JS ArrayBuffer/TypedArray argument) arrives as a
101
- # `Bytes` too. It subclasses Array so plain-Array callers (and `== [..]`
102
- # comparisons) keep working; only the bridge treats it specially.
103
- class Bytes < ::Array
104
- def initialize(bytes = [])
105
- super()
106
- concat(Array(bytes).map { |b| b.to_i & 0xFF })
107
- end
108
-
109
- alias bytes to_a
110
- def pack_bytes = pack("C*")
111
- end
112
-
113
- # Like `Bytes`, but crosses the JS boundary as a bare `ArrayBuffer` rather
114
- # than a `Uint8Array` view. Use this for the spec methods whose return type
115
- # is `ArrayBuffer` — `Response`/`Blob`/`FileReader`/`XMLHttpRequest`'s
116
- # `arrayBuffer`. Subclasses `Bytes` so Ruby-level `== [..]` comparisons still
117
- # hold; only the bridge distinguishes it (and must check it before `Bytes`).
118
- class ArrayBuffer < Bytes
119
- end
120
-
121
- # A Ruby-side signal that the JS boundary should surface a JS `TypeError`
122
- # (not a `DOMException`). Some WebIDL operations — notably the `URL`
123
- # constructor and its `href` setter — throw `TypeError` on failure rather
124
- # than a DOMException; raising this lets a host bridge rethrow the correct
125
- # JS error type, while Ruby callers can still rescue it like any other
126
- # error. Kept distinct from Ruby's built-in `::TypeError` so a host can map
127
- # only deliberate, spec-mandated TypeErrors (and not mask genuine Ruby type
128
- # bugs) across the boundary.
129
- class TypeError < ::StandardError; end
130
-
131
- # Like `Bridge::TypeError`, but for spec-mandated `RangeError`s (e.g. the
132
- # `Response` constructor rejecting a status outside 200–599). A host bridge
133
- # rethrows a real JS `RangeError`; kept distinct from Ruby's `::RangeError`.
134
- class RangeError < ::StandardError; end
135
-
136
- # Wraps an external callback handle (registered in a host-side
137
- # callback table) so the JS bridge can resolve / invoke it. The
138
- # external host that creates these is responsible for honoring
139
- # `invoke_callback(callback_id, args)`.
140
- #
141
- # The `__callback_id__` key on this object exposes the integer
142
- # id to JS-side code that needs to round-trip it (e.g. for
143
- # release / introspection).
144
- class Callback
145
- ID_KEY = "__callback_id__"
146
-
147
- def initialize(host, callback_id)
148
- @host = host
149
- @callback_id = callback_id
150
- @props = {}
151
- end
152
-
153
- def __js_get__(key)
154
- if key == ID_KEY
155
- @props.fetch(key, @callback_id)
156
- else
157
- @props[key]
158
- end
159
- end
160
-
161
- def __js_set__(key, value)
162
- @props[key] = value
163
- nil
164
- end
165
-
166
- def __js_call__(method, args)
167
- case method
168
- when "call"
169
- @host.invoke_callback(@callback_id, args)
170
- end
171
- end
172
- end
173
-
174
- # Block-as-constructor adapter — invoking `__js_new__(args)`
175
- # calls the wrapped block with `args` and returns whatever the
176
- # block produces. Used by Window to wire up `new Event(init)`,
177
- # `new CustomEvent(init)`, etc. without hand-rolling a class
178
- # for each constructor.
179
- class Constructor
180
- def initialize(&block)
181
- @block = block
182
- @class_methods = {}
183
- end
184
-
185
- def __js_new__(args)
186
- @block.call(args)
187
- end
188
-
189
- # Register a class-level method (e.g. `URL.createObjectURL`)
190
- # that JS bridges resolve via `__js_call__` on the constructor
191
- # itself. Returns self for chaining.
192
- def define_class_method(name, &block)
193
- @class_methods[name.to_s] = block
194
- self
195
- end
196
-
197
- def __js_call__(method, args)
198
- handler = @class_methods[method.to_s]
199
- handler&.call(args)
200
- end
201
-
202
- # Names of the registered class-level (static) methods, so a JS host can
203
- # expose them on the constructor function (e.g. `URL.createObjectURL`).
204
- def __js_class_method_names__
205
- @class_methods.keys
206
- end
207
- end
208
-
209
- # `JS.global[:Promise]` view. Implements the `resolve` / `reject`
210
- # class methods plus `new Promise(executor)` via `__js_new__`.
211
- class PromiseConstructor
212
- def initialize(window)
213
- @window = window
214
- end
215
-
216
- def __js_call__(method, args)
217
- case method
218
- when "resolve"
219
- PromiseValue.resolve(@window, args[0])
220
- when "reject"
221
- PromiseValue.reject(@window, args[0])
222
- end
223
- end
224
-
225
- # `new Promise(executor)` — runs executor synchronously with
226
- # (resolve, reject) callbacks.
227
- def __js_new__(args)
228
- executor = args[0]
229
- promise = PromiseValue.new(@window)
230
- resolve = PromiseSettler.new(promise, fulfilled: true)
231
- reject = PromiseSettler.new(promise, fulfilled: false)
232
- if executor.respond_to?(:__js_call__)
233
- executor.__js_call__("call", [resolve, reject])
234
- elsif executor.respond_to?(:call)
235
- executor.call(resolve, reject)
236
- end
237
-
238
- promise
239
- end
240
- end
241
-
242
- # Adapter so a Ruby-side executor can deliver resolve/reject
243
- # through the same `__js_call__("call", args)` interface that
244
- # the scheduler and JS bridge use for callbacks.
245
- class PromiseSettler
246
- def initialize(promise, fulfilled:)
247
- @promise = promise
248
- @fulfilled = fulfilled
249
- end
250
-
251
- def __js_call__(_method, args)
252
- if @fulfilled
253
- @promise.fulfill(args[0])
254
- else
255
- @promise.reject(args[0])
256
- end
257
-
258
- nil
259
- end
260
- end
261
- end
25
+ #
26
+ # The pieces are one file each, by what they are — see the requires below.
27
+ module Bridge; end
262
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
@@ -70,7 +70,7 @@ module Dommy
70
70
  @error_log = Js::ErrorLog.new(strict: strict)
71
71
  @console = []
72
72
  @disposed = false
73
- @pending_navigation = nil
73
+ @pending_navigations = []
74
74
  @runtime = nil
75
75
 
76
76
  @window = Dommy.parse(html)
@@ -104,7 +104,7 @@ module Dommy
104
104
  def visit(url, replace: false)
105
105
  raise "browser is not navigable (use Browser.visit or navigable: true)" unless @navigable
106
106
 
107
- @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}
108
108
  flush_navigation!
109
109
  self
110
110
  end
@@ -128,14 +128,61 @@ module Dommy
128
128
  # inside a script), record it and perform the fetch + swap at the next drain
129
129
  # boundary (settle / after_interaction / advance_time). Ruby-initiated visits
130
130
  # flush immediately since no JS is on the stack.
131
- def navigate(url:, source:, method: "GET", body: nil, params: nil, enctype: nil, headers: {}, replace: false)
132
- @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 << {
133
146
  url: url, method: method, body: body, params: params, enctype: enctype,
134
- headers: headers, replace: replace, source: source
147
+ target: target, headers: headers, replace: replace, source: source
135
148
  }
136
149
  nil
137
150
  end
138
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
+
139
186
  # A cross-document history traversal. Ruby-initiated (back / forward), so it
140
187
  # runs immediately: a same-document target (its window is still live)
141
188
  # traverses in place (popstate); a document-boundary target is re-fetched.
@@ -316,8 +363,16 @@ module Dommy
316
363
  MAX_META_REFRESHES = 20
317
364
 
318
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
+
319
374
  response, final_url = @fetcher.request(
320
- method: nav[:method] || "GET", url: nav[:url], params: nav[:params],
375
+ method: nav[:method] || "GET", url: resolved, params: nav[:params],
321
376
  body: nav[:body], enctype: nav[:enctype], headers: nav[:headers] || {}
322
377
  )
323
378
  return unless response&.success? && document_response?(response)
@@ -343,6 +398,53 @@ module Dommy
343
398
  follow_meta_refresh!(refresh_depth)
344
399
  end
345
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
+
346
448
  # If the freshly loaded document asks for an immediate `<meta http-equiv=
347
449
  # refresh>`, follow it (as a replace, like a redirect), capped so a page that
348
450
  # refreshes to itself can't loop forever.
@@ -383,12 +485,11 @@ module Dommy
383
485
  # the swap never runs with the outgoing realm's JS on the stack.
384
486
  def flush_navigation!
385
487
  return unless @navigable
488
+ return if @pending_navigations.empty?
386
489
 
387
- nav = @pending_navigation
388
- return unless nav
389
-
390
- @pending_navigation = nil
391
- perform_navigation!(nav)
490
+ navs = @pending_navigations
491
+ @pending_navigations = []
492
+ navs.each { |nav| perform_navigation!(nav) }
392
493
  end
393
494
 
394
495
  def fire_unload(window)