dommy 0.14.0 → 0.16.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 (198) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +15 -1
  3. data/lib/dommy/attr.rb +56 -32
  4. data/lib/dommy/backend.rb +251 -208
  5. data/lib/dommy/blob.rb +24 -0
  6. data/lib/dommy/bridge/methods.rb +12 -2
  7. data/lib/dommy/bridge/values.rb +29 -2
  8. data/lib/dommy/bridge/wire_tags.rb +8 -4
  9. data/lib/dommy/browser.rb +328 -53
  10. data/lib/dommy/callable_invoker.rb +27 -31
  11. data/lib/dommy/character_data.rb +21 -28
  12. data/lib/dommy/class_list.rb +55 -23
  13. data/lib/dommy/cookie_jar.rb +408 -0
  14. data/lib/dommy/cookie_store.rb +104 -49
  15. data/lib/dommy/css.rb +37 -8
  16. data/lib/dommy/custom_elements.rb +360 -116
  17. data/lib/dommy/data_transfer.rb +22 -2
  18. data/lib/dommy/document.rb +1258 -346
  19. data/lib/dommy/dom_exception.rb +6 -0
  20. data/lib/dommy/dom_parser.rb +46 -14
  21. data/lib/dommy/dom_string_list.rb +36 -0
  22. data/lib/dommy/element.rb +408 -569
  23. data/lib/dommy/element_internals.rb +215 -0
  24. data/lib/dommy/encodings.rb +10 -0
  25. data/lib/dommy/event.rb +382 -148
  26. data/lib/dommy/event_source.rb +8 -34
  27. data/lib/dommy/fetch.rb +8 -12
  28. data/lib/dommy/file_reader.rb +5 -29
  29. data/lib/dommy/form_data.rb +53 -16
  30. data/lib/dommy/form_entry_list.rb +107 -138
  31. data/lib/dommy/fragment.rb +14 -16
  32. data/lib/dommy/history.rb +247 -90
  33. data/lib/dommy/html_canvas_element.rb +15 -35
  34. data/lib/dommy/html_collection.rb +76 -50
  35. data/lib/dommy/html_elements/base.rb +377 -71
  36. data/lib/dommy/html_elements/embedded.rb +350 -106
  37. data/lib/dommy/html_elements/forms/form.rb +424 -383
  38. data/lib/dommy/html_elements/forms/input.rb +351 -189
  39. data/lib/dommy/html_elements/forms/select.rb +97 -81
  40. data/lib/dommy/html_elements/interactive.rb +285 -72
  41. data/lib/dommy/html_elements/media.rb +73 -32
  42. data/lib/dommy/html_elements/metadata.rb +226 -78
  43. data/lib/dommy/html_elements/miscellaneous.rb +58 -14
  44. data/lib/dommy/html_elements/tables.rb +20 -42
  45. data/lib/dommy/html_elements.rb +28 -3
  46. data/lib/dommy/hyperlink_utils.rb +19 -8
  47. data/lib/dommy/interaction/drag_and_drop.rb +187 -0
  48. data/lib/dommy/interaction/event_synthesis.rb +182 -24
  49. data/lib/dommy/interaction/field_interactor.rb +18 -6
  50. data/lib/dommy/interaction/form_submission.rb +12 -6
  51. data/lib/dommy/interaction/key_sender.rb +102 -17
  52. data/lib/dommy/interaction.rb +1 -0
  53. data/lib/dommy/internal/accessibility_tree.rb +19 -7
  54. data/lib/dommy/internal/accessibility_visibility.rb +2 -2
  55. data/lib/dommy/internal/accessible_description.rb +3 -3
  56. data/lib/dommy/internal/accessible_name.rb +33 -45
  57. data/lib/dommy/internal/an_plus_b.rb +125 -0
  58. data/lib/dommy/internal/aria_role.rb +27 -18
  59. data/lib/dommy/internal/aria_state.rb +7 -7
  60. data/lib/dommy/internal/autofill.rb +2 -2
  61. data/lib/dommy/internal/backend_prefilter.rb +21 -18
  62. data/lib/dommy/internal/ce_reactions.rb +285 -0
  63. data/lib/dommy/internal/ce_reactions_table.rb +296 -0
  64. data/lib/dommy/internal/child_list.rb +39 -0
  65. data/lib/dommy/internal/child_navigable.rb +155 -0
  66. data/lib/dommy/internal/child_node.rb +44 -41
  67. data/lib/dommy/internal/close_watcher_manager.rb +238 -0
  68. data/lib/dommy/internal/complex_match.rb +129 -0
  69. data/lib/dommy/internal/constraint_validation.rb +130 -0
  70. data/lib/dommy/internal/css/ancestor_filter.rb +107 -0
  71. data/lib/dommy/internal/css/cascade.rb +7 -7
  72. data/lib/dommy/internal/css/cascaded_declarations.rb +14 -20
  73. data/lib/dommy/internal/css/computed_style_builder.rb +30 -3
  74. data/lib/dommy/internal/css/custom_properties.rb +63 -80
  75. data/lib/dommy/internal/css/parser.rb +53 -63
  76. data/lib/dommy/internal/css/renderability.rb +6 -0
  77. data/lib/dommy/internal/css/rule_index.rb +170 -108
  78. data/lib/dommy/internal/css/rule_index_inputs.rb +135 -0
  79. data/lib/dommy/internal/css/selector_dependencies.rb +8 -2
  80. data/lib/dommy/internal/css/style_cache.rb +9 -6
  81. data/lib/dommy/internal/css/ua_stylesheet.rb +37 -2
  82. data/lib/dommy/internal/css_source.rb +223 -0
  83. data/lib/dommy/internal/css_syntax.rb +153 -0
  84. data/lib/dommy/internal/declarative_shadow_roots.rb +182 -0
  85. data/lib/dommy/internal/directionality.rb +2 -2
  86. data/lib/dommy/internal/document_generations.rb +91 -18
  87. data/lib/dommy/internal/document_interaction_state.rb +293 -9
  88. data/lib/dommy/internal/dom_matching.rb +7 -7
  89. data/lib/dommy/internal/element_aria.rb +65 -23
  90. data/lib/dommy/internal/element_css_inline_style.rb +26 -0
  91. data/lib/dommy/internal/element_editing.rb +41 -0
  92. data/lib/dommy/internal/element_nonce.rb +28 -0
  93. data/lib/dommy/internal/element_popover.rb +310 -0
  94. data/lib/dommy/internal/element_shadow.rb +107 -33
  95. data/lib/dommy/internal/element_state.rb +131 -26
  96. data/lib/dommy/internal/element_top_layer.rb +21 -51
  97. data/lib/dommy/internal/enumerated_keyword_sets.rb +40 -0
  98. data/lib/dommy/internal/event_handler_tables.rb +241 -0
  99. data/lib/dommy/internal/event_handlers.rb +149 -0
  100. data/lib/dommy/internal/exception_report.rb +1 -1
  101. data/lib/dommy/internal/focusability.rb +267 -0
  102. data/lib/dommy/internal/form_associated_custom_elements.rb +127 -0
  103. data/lib/dommy/internal/html_or_svg_or_mathml_element.rb +86 -0
  104. data/lib/dommy/internal/html_serialization.rb +292 -0
  105. data/lib/dommy/internal/idl_reflection.rb +202 -0
  106. data/lib/dommy/internal/idl_reflection_table.rb +589 -0
  107. data/lib/dommy/internal/infra.rb +36 -0
  108. data/lib/dommy/internal/input_type.rb +90 -20
  109. data/lib/dommy/internal/insertion_point.rb +27 -3
  110. data/lib/dommy/internal/js_number.rb +60 -0
  111. data/lib/dommy/internal/light_dismiss.rb +178 -0
  112. data/lib/dommy/internal/literal_lookup.rb +103 -0
  113. data/lib/dommy/internal/mime_type.rb +8 -0
  114. data/lib/dommy/internal/mutation_coordinator.rb +94 -60
  115. data/lib/dommy/internal/node_adopter.rb +165 -97
  116. data/lib/dommy/internal/node_factory.rb +111 -49
  117. data/lib/dommy/internal/node_identity.rb +1 -1
  118. data/lib/dommy/internal/node_traversal.rb +30 -13
  119. data/lib/dommy/internal/node_wrapper_cache.rb +34 -148
  120. data/lib/dommy/internal/observable_callback.rb +2 -5
  121. data/lib/dommy/internal/observer_options.rb +24 -14
  122. data/lib/dommy/internal/origin.rb +86 -0
  123. data/lib/dommy/internal/parent_node.rb +12 -10
  124. data/lib/dommy/internal/popover_invoker_element.rb +67 -0
  125. data/lib/dommy/internal/popover_stack.rb +120 -0
  126. data/lib/dommy/internal/post_insertion_steps.rb +146 -77
  127. data/lib/dommy/internal/reflected_attributes.rb +173 -43
  128. data/lib/dommy/internal/rendered_text/collector.rb +12 -9
  129. data/lib/dommy/internal/rendered_text/fragment.rb +6 -2
  130. data/lib/dommy/internal/selector_arguments.rb +2 -16
  131. data/lib/dommy/internal/selector_index.rb +19 -10
  132. data/lib/dommy/internal/selector_match.rb +190 -0
  133. data/lib/dommy/internal/selector_matcher.rb +91 -256
  134. data/lib/dommy/internal/selector_parser.rb +176 -100
  135. data/lib/dommy/internal/sequential_focus_navigation.rb +210 -0
  136. data/lib/dommy/internal/shadow_root_registry.rb +14 -6
  137. data/lib/dommy/internal/sibling_positions.rb +59 -0
  138. data/lib/dommy/internal/svg_uri_reference.rb +33 -0
  139. data/lib/dommy/internal/template_content_registry.rb +11 -5
  140. data/lib/dommy/internal/text_selection.rb +185 -32
  141. data/lib/dommy/internal/toggle_events.rb +6 -4
  142. data/lib/dommy/internal/unsafe_html.rb +107 -0
  143. data/lib/dommy/internal/url_parser.rb +84 -8
  144. data/lib/dommy/internal/user_activation.rb +192 -0
  145. data/lib/dommy/internal/web_idl.rb +160 -16
  146. data/lib/dommy/internal/window_constructors.rb +34 -14
  147. data/lib/dommy/internal/xml_serialization.rb +9 -45
  148. data/lib/dommy/js/constructor_resolver.rb +18 -0
  149. data/lib/dommy/js/custom_element_bridge.rb +57 -82
  150. data/lib/dommy/js/dom_interfaces.rb +51 -15
  151. data/lib/dommy/js/host_bridge.rb +189 -40
  152. data/lib/dommy/js/host_callbacks.rb +10 -0
  153. data/lib/dommy/js/host_runtime.js +1921 -416
  154. data/lib/dommy/js/marshaller.rb +13 -4
  155. data/lib/dommy/js/module_loader.rb +46 -5
  156. data/lib/dommy/js/module_preload.rb +194 -0
  157. data/lib/dommy/js/runtime.rb +9 -0
  158. data/lib/dommy/js/script_boot.rb +200 -114
  159. data/lib/dommy/js/serialized_record.rb +32 -0
  160. data/lib/dommy/js/webidl_event_handlers.js +45 -0
  161. data/lib/dommy/js/webidl_members.js +196 -0
  162. data/lib/dommy/js/webidl_signatures.js +2055 -0
  163. data/lib/dommy/js/webidl_tables.js +72 -101
  164. data/lib/dommy/js/window_wiring.rb +9 -5
  165. data/lib/dommy/location.rb +129 -76
  166. data/lib/dommy/mathml_elements.rb +13 -0
  167. data/lib/dommy/media_query_list.rb +4 -13
  168. data/lib/dommy/message_channel.rb +137 -64
  169. data/lib/dommy/mutation_observer.rb +3 -7
  170. data/lib/dommy/navigation.rb +61 -11
  171. data/lib/dommy/navigator.rb +132 -10
  172. data/lib/dommy/node.rb +81 -44
  173. data/lib/dommy/notification.rb +10 -6
  174. data/lib/dommy/parser.rb +5 -16
  175. data/lib/dommy/promise.rb +20 -26
  176. data/lib/dommy/range.rb +20 -47
  177. data/lib/dommy/resources.rb +38 -3
  178. data/lib/dommy/scheduler.rb +176 -48
  179. data/lib/dommy/shadow_root.rb +125 -25
  180. data/lib/dommy/storage.rb +135 -25
  181. data/lib/dommy/streams.rb +2 -6
  182. data/lib/dommy/structured_data.rb +45 -0
  183. data/lib/dommy/style_declaration.rb +38 -6
  184. data/lib/dommy/svg_elements.rb +25 -11
  185. data/lib/dommy/text_codec.rb +2 -14
  186. data/lib/dommy/tree_walker.rb +12 -26
  187. data/lib/dommy/url.rb +12 -9
  188. data/lib/dommy/url_pattern.rb +1 -9
  189. data/lib/dommy/validity_state.rb +109 -107
  190. data/lib/dommy/version.rb +1 -1
  191. data/lib/dommy/web_socket.rb +10 -33
  192. data/lib/dommy/window.rb +817 -105
  193. data/lib/dommy/worker.rb +4 -28
  194. data/lib/dommy/xml_http_request.rb +32 -45
  195. data/lib/dommy.rb +43 -10
  196. metadata +52 -5
  197. data/lib/dommy/backend/makiri_adapter.rb +0 -408
  198. data/lib/dommy/internal/cookie_jar.rb +0 -29
@@ -3,8 +3,8 @@
3
3
  module Dommy
4
4
  module Bridge
5
5
  # The Ruby shapes a JS value takes while it is on this side: an opaque
6
- # reference, and the two byte buffers that cross as a typed array or a bare
7
- # ArrayBuffer.
6
+ # reference (a Date one carrying its time value), and the two byte buffers
7
+ # that cross as a typed array or a bare ArrayBuffer.
8
8
 
9
9
  # An opaque handle to a JS-side value that Ruby only stores and hands back
10
10
  # (an AbortSignal's reason, a CustomEvent's detail). A non-plain JS object
@@ -35,6 +35,33 @@ module Dommy
35
35
  def inspect = "#<Dommy::Bridge::JSValue #{to_s}>"
36
36
  end
37
37
 
38
+ # A JS Date that crossed into Ruby. It is still an opaque reference, so
39
+ # handing it back returns the same Date, but it also carries its time value
40
+ # (ms since the epoch, NaN for an invalid date) — the one thing a host API
41
+ # taking a Date (`input.valueAsDate = d`) needs to read off it. A Date the
42
+ # host creates crosses from a Ruby ::Time instead.
43
+ class Date < JSValue
44
+ # The largest time value a JS Date can hold, either side of the epoch.
45
+ MAX_TIME_VALUE = 8_640_000_000_000_000
46
+
47
+ # The time value a JS Date made from `time` holds — ECMAScript's TimeClip:
48
+ # whole milliseconds truncated toward zero (0.1 ms before the epoch is 0,
49
+ # not -1), and NaN beyond MAX_TIME_VALUE.
50
+ def self.time_value_of(time)
51
+ ms = (time.to_r * 1000).truncate
52
+ ms.abs > MAX_TIME_VALUE ? ::Float::NAN : ms.to_f
53
+ end
54
+
55
+ attr_reader :time_value
56
+
57
+ def initialize(ref, time_value)
58
+ super(ref, nil, nil, "Date")
59
+ @time_value = time_value
60
+ end
61
+
62
+ def inspect = "#<Dommy::Bridge::Date #{time_value}>"
63
+ end
64
+
38
65
  # A byte buffer that crosses the JS boundary as a `Uint8Array` (rather than a
39
66
  # plain Array). Wrap a host method's byte-array result in this so JS sees a
40
67
  # real typed array — e.g. `TextEncoder#encode`, `Blob#arrayBuffer`. The
@@ -25,10 +25,6 @@ module Dommy
25
25
  # method set) instead of a `__rb_host_describe` round trip per new proxy —
26
26
  # the dominant overhead when JS traverses/builds many nodes.
27
27
  INTERFACE = "__rb_if"
28
- # The custom-element tag of a handle whose node is a registered custom
29
- # element (so makeProxy upgrades it), carried per-instance since it is the
30
- # one part of a describe that is NOT per-interface.
31
- CUSTOM_ELEMENT = "__rb_ce"
32
28
  # A live JS function that crossed into Ruby, referenced by callback id.
33
29
  CALLBACK = "__rb_callback"
34
30
  # An opaque JS value referenced by its id in the JS-side `jsRefs` table
@@ -58,6 +54,10 @@ module Dommy
58
54
  BYTES = "__rb_bytes"
59
55
  # A byte buffer crossing as a bare JS ArrayBuffer.
60
56
  ARRAY_BUFFER = "__rb_arraybuffer"
57
+ # A JS Date's time value (ms since the epoch). Host -> JS it builds a new
58
+ # Date; JS -> host it decorates a JS_REF, so the Date keeps its identity
59
+ # and the host can still read the time (see Dommy::Bridge::Date).
60
+ DATE = "__rb_date"
61
61
  # A host-raised DOMException/TypeError/RangeError, re-thrown JS-side.
62
62
  EXCEPTION = "__rb_exception__"
63
63
  # A host-created native JS error (TypeError/RangeError) crossing as a VALUE
@@ -68,6 +68,10 @@ module Dommy
68
68
  THROW = "__rb_throw__"
69
69
  # A callback whose JS invocation threw (the thrown value is carried here).
70
70
  CALLBACK_THREW = "__rb_cb_threw__"
71
+ # A structured-serialization record the JS realm holds (by id), crossing
72
+ # as an opaque Dommy::Js::SerializedRecord (see host_runtime.js
73
+ # serializedTag).
74
+ SERIALIZED = "__rb_serialized"
71
75
  end
72
76
  end
73
77
  end
data/lib/dommy/browser.rb CHANGED
@@ -71,21 +71,50 @@ module Dommy
71
71
  @console = []
72
72
  @disposed = false
73
73
  @pending_navigations = []
74
+ @popups = []
74
75
  @runtime = nil
76
+ # One browsing session: every window it shows (and their frames) shares
77
+ # localStorage per origin and sessionStorage per origin.
78
+ @storage_provider = StorageProvider.new
79
+ # One cookie store for the session: document.cookie, cookieStore, and
80
+ # the Cookie / Set-Cookie of every request it sends through resources.
81
+ @cookie_jar = CookieJar.new
82
+ @before_unload_handler = nil
75
83
 
76
84
  @window = Dommy.parse(html)
77
85
  @window.location.__internal_set_url__(url) if url
78
- install_runtime(@window)
86
+ @window.storage_provider = @storage_provider
87
+ @window.cookie_jar = @cookie_jar
79
88
 
80
89
  if navigable
81
- @fetcher = Navigation::Fetcher.new(@resources, same_origin: @same_origin)
90
+ @fetcher = navigation_fetcher
82
91
  @history = Navigation::JointHistory.new
83
92
  @window.navigation_delegate = self
84
93
  @history.push(current_url, window: @window, windex: @window.history.__internal_index__)
94
+ install_history_sync(@window)
85
95
  end
96
+ install_runtime(@window)
86
97
  check_js_errors!
87
98
  end
88
99
 
100
+ # The provider of this browser's Web Storage areas (shared by every window
101
+ # it shows); see Dommy::StorageProvider.
102
+ attr_reader :storage_provider
103
+
104
+ # This browser's cookies (a Dommy::CookieJar): the one store its pages'
105
+ # document.cookie and cookieStore use and its requests send and fill.
106
+ attr_reader :cookie_jar
107
+
108
+ # Install a handler for a page that asks to confirm leaving it (a canceled
109
+ # `beforeunload`, or one whose returnValue is set): called with the window
110
+ # and the BeforeUnloadEvent, its truthy answer lets the navigation proceed.
111
+ # Without one the navigation always proceeds — a headless page has no sticky
112
+ # user activation, so a browser would not prompt either.
113
+ def on_before_unload(&block)
114
+ @before_unload_handler = block
115
+ self
116
+ end
117
+
89
118
  def document = @window.document
90
119
 
91
120
  # Current document HTML (serialized).
@@ -117,8 +146,8 @@ module Dommy
117
146
  # Move back / forward one entry in the joint history. A same-document target
118
147
  # (the entry's window is still live) traverses in place (popstate); a
119
148
  # document-boundary target is re-fetched (no bfcache — D2).
120
- def back = traverse(-1)
121
- def forward = traverse(1)
149
+ def back = traverse_now(-1)
150
+ def forward = traverse_now(1)
122
151
 
123
152
  # --- NavigationDelegate port (see Dommy::Navigation) ---
124
153
 
@@ -138,7 +167,7 @@ module Dommy
138
167
  navigate_frame(frame,
139
168
  {method: method, url: url, params: params, body: body, enctype: enctype, headers: headers},
140
169
  resolve_against_current(url.to_s))
141
- @window.scheduler.queue_microtask(proc { frame.dispatch_event(Dommy::Event.new("load")) })
170
+ @window.scheduler.queue_microtask(proc { frame.__internal_run_iframe_load_event_steps__ })
142
171
  return nil
143
172
  end
144
173
 
@@ -164,6 +193,109 @@ module Dommy
164
193
  end
165
194
 
166
195
  def traverse(_delta) = nil
196
+
197
+ def history_length = @browser.history_length
198
+
199
+ # The frames of the document loaded into this frame load the same way.
200
+ def load_frame(frame, **nav) = @browser.load_frame(frame, **nav)
201
+
202
+ # A popup opened from inside the frame is the browser's too.
203
+ def open_window(**opts) = @browser.open_window(**opts)
204
+ end
205
+
206
+ # The navigation delegate of an auxiliary window this browser opened
207
+ # (window.open): its navigations load into it, keeping the Window the
208
+ # opener holds (Dommy has no WindowProxy, so the document is swapped
209
+ # under it), and its close() closes it.
210
+ class PopupDelegate
211
+ def initialize(browser, popup)
212
+ @browser = browser
213
+ @popup = popup
214
+ end
215
+
216
+ def navigate(url:, source:, method: "GET", body: nil, params: nil, enctype: nil, target: nil, headers: {}, replace: false)
217
+ @browser.__internal_load_popup__(@popup, {url: url, method: method, body: body, params: params,
218
+ enctype: enctype, headers: headers})
219
+ end
220
+
221
+ def traverse(_delta) = nil
222
+
223
+ def close_window = @browser.__internal_close_popup__(@popup)
224
+
225
+ def find_window(name) = @browser.find_window(name)
226
+
227
+ def load_frame(frame, **nav) = @browser.load_frame(frame, **nav)
228
+
229
+ def open_window(**opts) = @browser.open_window(**opts)
230
+ end
231
+
232
+ # The auxiliary windows the page opened with window.open and has not
233
+ # closed, oldest first.
234
+ def popups = @popups.dup
235
+
236
+ # NavigationDelegate `open_window`: window.open asked for a new top-level
237
+ # browsing context. It is created at once with the initial about:blank
238
+ # (whose origin is the opener's) and navigated to `url` from a task; it
239
+ # shares this browser's cookies and localStorage, and starts its
240
+ # sessionStorage as a copy of this one's. Window#window_open sets its
241
+ # opener and name.
242
+ def open_window(url:, target:, features: "")
243
+ return nil if @disposed
244
+
245
+ popup = Dommy.parse(Internal::ChildNavigable::BLANK_HTML)
246
+ popup.location.__internal_set_url__("about:blank")
247
+ popup.__internal_initial_about_blank__ = true
248
+ popup.storage_provider = @storage_provider.new_session
249
+ popup.cookie_jar = @cookie_jar
250
+ popup.navigation_delegate = PopupDelegate.new(self, popup)
251
+ @popups << popup
252
+ unless Internal::ChildNavigable.matches_about_blank?(url)
253
+ @window.scheduler.set_timeout(proc { __internal_load_popup__(popup, {url: url, method: "GET"}) }, 0)
254
+ end
255
+ popup
256
+ end
257
+
258
+ # Load a navigation into a popup: fetch it, give the popup's Window the
259
+ # document, and fire its load. Nothing happens when nothing serves it.
260
+ def __internal_load_popup__(popup, nav)
261
+ return nil if @disposed || @resources.nil? || !@popups.include?(popup)
262
+
263
+ url = nav[:url].to_s
264
+ @fetcher ||= navigation_fetcher
265
+ response, final_url = begin
266
+ @fetcher.request(method: nav[:method] || "GET", url: url, params: nav[:params],
267
+ body: nav[:body], enctype: nav[:enctype], headers: nav[:headers] || {})
268
+ rescue StandardError
269
+ [nil, url]
270
+ end
271
+ return nil unless response && document_response?(response)
272
+
273
+ loaded = Internal::ChildNavigable.window_for_response(response.body, response_header(response, "content-type"), final_url)
274
+ popup.__internal_adopt_document_of__(loaded)
275
+ popup.navigation_delegate = PopupDelegate.new(self, popup)
276
+ document = popup.document
277
+ document.__internal_update_readiness__("loading")
278
+ document.__internal_finish_loading__
279
+ nil
280
+ end
281
+
282
+ # NavigationDelegate `find_window`: the top-level window (this browser's
283
+ # page or a popup) or a frame in one, whose target name is `name`.
284
+ def find_window(name)
285
+ ([@window] + @popups).each do |top|
286
+ return top if top.name == name
287
+
288
+ found = top.__internal_find_descendant_window__(name)
289
+ return found if found
290
+ end
291
+ nil
292
+ end
293
+
294
+ # A popup's close(): it leaves the browser, closed.
295
+ def __internal_close_popup__(popup)
296
+ @popups.delete(popup)
297
+ popup.__internal_discard__
298
+ nil
167
299
  end
168
300
 
169
301
  def frame_navigation_delegate(frame) = FrameNavigationDelegate.new(self, frame)
@@ -179,26 +311,49 @@ module Dommy
179
311
  nav[:url].to_s
180
312
  end
181
313
  navigate_frame(frame, nav, resolved)
182
- frame.dispatch_event(Dommy::Event.new("load"))
314
+ frame.__internal_run_iframe_load_event_steps__
183
315
  nil
184
316
  end
185
317
 
186
- # A cross-document history traversal. Ruby-initiated (back / forward), so it
187
- # runs immediately: a same-document target (its window is still live)
188
- # traverses in place (popstate); a document-boundary target is re-fetched.
318
+ # NavigationDelegate `load_frame`: an iframe's child navigable is navigating
319
+ # to `url` (its `src`, or a navigation from inside it) — fetch it through
320
+ # this browser's resources and answer the document's Window, or nil when
321
+ # nothing serves it (the frame then keeps its document). The frame installs
322
+ # it and fires its own `load`.
323
+ def load_frame(frame, url:, method: "GET", body: nil, params: nil, enctype: nil, headers: {}, **)
324
+ return nil if @disposed || @resources.nil?
325
+
326
+ frame_window_for(frame, {url: url, method: method, body: body, params: params, enctype: enctype, headers: headers}, url.to_s)
327
+ end
328
+
329
+ # A history traversal by `delta` that the PAGE asked for (`history.go(n)`
330
+ # past its own document's entries): like a navigation it is a task, so it
331
+ # is recorded and performed at the next drain boundary.
189
332
  def traverse(delta)
190
- return self unless @navigable
333
+ @pending_navigations << {traverse: delta.to_i} if @navigable
334
+ nil
335
+ end
191
336
 
192
- entry = delta.negative? ? @history.back : @history.forward
193
- return self unless entry
337
+ # `history.length`: the joint session history's size (nil when this
338
+ # browser keeps none).
339
+ def history_length = (@history.length if @navigable)
194
340
 
195
- if entry.window && entry.window.equal?(@window)
196
- @window.history.__internal_go_to__(entry.windex)
197
- @runtime.drain_microtasks
198
- check_js_errors!
199
- else
200
- perform_navigation!({url: entry.url, method: "GET", source: :traverse}, rebind: true)
201
- end
341
+ # `window.stop()`: drop the navigations the page asked for and that have not
342
+ # been performed yet.
343
+ def stop
344
+ @pending_navigations.clear
345
+ nil
346
+ end
347
+
348
+ # A Ruby-initiated traversal (back / forward) runs immediately: a
349
+ # same-document target (its window is still live) traverses in place
350
+ # (popstate); a document-boundary target is re-fetched.
351
+ def traverse_now(delta)
352
+ return self unless @navigable
353
+
354
+ perform_traversal!(delta)
355
+ @runtime.drain_microtasks
356
+ check_js_errors!
202
357
  self
203
358
  end
204
359
 
@@ -221,6 +376,7 @@ module Dommy
221
376
  # `setTimeout(300)` — use `advance_time(300)` for debounce/throttle.
222
377
  def settle
223
378
  @runtime.settle
379
+ drive_popups(0)
224
380
  flush_navigation!
225
381
  check_js_errors!
226
382
  self
@@ -229,6 +385,7 @@ module Dommy
229
385
  # Advance virtual time by `ms`, running timers that come due, then settle.
230
386
  def advance_time(ms)
231
387
  @window.scheduler.advance_time(ms)
388
+ drive_popups(ms)
232
389
  @runtime.drain_microtasks
233
390
  flush_navigation!
234
391
  check_js_errors!
@@ -240,6 +397,7 @@ module Dommy
240
397
  # land before the next line, then enforce strict mode.
241
398
  def after_interaction
242
399
  @runtime.drain_microtasks
400
+ drive_popups(0)
243
401
  flush_navigation!
244
402
  check_js_errors!
245
403
  end
@@ -298,7 +456,7 @@ module Dommy
298
456
  @runtime&.dispose
299
457
  # The JS engine is pluggable: `@backend` selects a registered runtime
300
458
  # (nil → the configured default, QuickJS when dommy-js-quickjs is loaded).
301
- runtime = Js.build_runtime(@backend)
459
+ runtime = Js::ModulePreload.build_runtime(window.document, @backend)
302
460
  # Every uncaught error the page produces goes through the window's
303
461
  # "report an exception" / "notify about rejected promises" funnel, and only
304
462
  # what the page left unhandled lands here. A page that installs its own
@@ -319,7 +477,10 @@ module Dommy
319
477
  # Opt-in WPT scaffolding (common/sab.js derives SharedArrayBuffer through
320
478
  # WebAssembly.Memory); off by default so real pages don't see the shim.
321
479
  runtime.install_wasm_memory_shim if @wasm_memory_shim && runtime.respond_to?(:install_wasm_memory_shim)
322
- window.globals["__fetch_handler__"] = Resources::FetchHandler.new(@resources) if @resources
480
+ if @resources
481
+ window.globals["__fetch_handler__"] =
482
+ Resources::FetchHandler.new(@resources, cookie_jar: @cookie_jar, origin: -> { window.origin })
483
+ end
323
484
  @runtime = runtime
324
485
  doc = window.document
325
486
  # An `on*` attribute that arrived after boot (a cloned template, an
@@ -327,7 +488,7 @@ module Dommy
327
488
  # Installed whenever a runtime is attached — an embedder that drives script
328
489
  # boot itself (`execute_scripts: false`) still needs inline handlers wired.
329
490
  doc.inline_handler_wirer = lambda do
330
- Js::ScriptBoot.wire_inline_handlers(runtime, on_error: ->(e) { @error_log.record(e) })
491
+ Js::ScriptBoot.wire_inline_handlers(runtime, document: doc, on_error: ->(e) { @error_log.record(e) })
331
492
  end
332
493
  return unless @execute_scripts
333
494
 
@@ -348,6 +509,16 @@ module Dommy
348
509
  runtime.settle if @settle_after_boot
349
510
  end
350
511
 
512
+ # A popup has no realm of its own; the tasks queued on its window (a
513
+ # close(), its timers) run as the browser's time moves.
514
+ def drive_popups(ms)
515
+ @popups.dup.each { |popup| popup.scheduler.advance_time(ms) }
516
+ end
517
+
518
+ def navigation_fetcher
519
+ Navigation::Fetcher.new(@resources, same_origin: @same_origin, cookie_jar: @cookie_jar)
520
+ end
521
+
351
522
  # Route an unhandled promise rejection through the page's own
352
523
  # `unhandledrejection` handling first. The engine decides WHEN a rejection
353
524
  # counts as unhandled (see Window#__internal_report_rejection__).
@@ -371,22 +542,31 @@ module Dommy
371
542
  frame = resolve_target_frame(nav[:target])
372
543
  return navigate_frame(frame, nav, resolved) if frame
373
544
 
545
+ # The outgoing page may ask to confirm leaving (beforeunload).
546
+ return unless unloading_allowed?(@window)
547
+
374
548
  response, final_url = @fetcher.request(
375
549
  method: nav[:method] || "GET", url: resolved, params: nav[:params],
376
550
  body: nav[:body], enctype: nav[:enctype], headers: nav[:headers] || {}
377
551
  )
378
552
  return unless response&.success? && document_response?(response)
379
553
 
554
+ old_window = @window
555
+ referrer = referrer_for(nav[:source], old_window.location.href, final_url)
380
556
  # Fire the outgoing document's unload sequence while its realm is still
381
557
  # alive, then surface any of its errors before the realm is torn down.
382
- fire_unload(@window)
558
+ fire_unload(old_window)
383
559
  check_js_errors!
384
560
 
385
- new_window = Dommy.parse(response.body)
561
+ new_window = Dommy.parse(Encodings.decode_document(response.body, response_header(response, "content-type")))
386
562
  new_window.location.__internal_set_url__(final_url)
563
+ new_window.document.__internal_set_last_modified__(response_header(response, "last-modified"))
564
+ new_window.document.__internal_referrer__ = referrer if referrer
387
565
  new_window.navigation_delegate = self
566
+ new_window.storage_provider = @storage_provider
567
+ new_window.cookie_jar = @cookie_jar
568
+ old_window.__internal_discard__
388
569
  @window = new_window
389
- install_runtime(new_window)
390
570
 
391
571
  windex = new_window.history.__internal_index__
392
572
  if rebind || nav[:replace]
@@ -394,10 +574,82 @@ module Dommy
394
574
  else
395
575
  @history.push(final_url, window: new_window, windex: windex)
396
576
  end
577
+ # The joint history knows the new page before its scripts boot, so a
578
+ # pushState / history.length during boot sees the right session.
579
+ install_history_sync(new_window)
580
+ install_runtime(new_window)
397
581
 
398
582
  follow_meta_refresh!(refresh_depth)
399
583
  end
400
584
 
585
+ # Fire `beforeunload` at the outgoing window; a page that cancels it (or
586
+ # sets returnValue) is asked about through the on_before_unload handler, if
587
+ # one is installed.
588
+ def unloading_allowed?(window)
589
+ event = BeforeUnloadEvent.new("beforeunload", "cancelable" => true).__internal_mark_trusted__
590
+ not_canceled = window.dispatch_event(event)
591
+ return true if not_canceled && event.return_value.to_s.empty?
592
+ return true unless @before_unload_handler
593
+
594
+ @before_unload_handler.call(window, event) ? true : false
595
+ end
596
+
597
+ # The `document.referrer` a navigation hands the new document: the page
598
+ # that started it (links, forms, script), under the default
599
+ # strict-origin-when-cross-origin policy — the full URL (without fragment
600
+ # or credentials) within an origin, only the origin across origins, nothing
601
+ # on an https -> http downgrade. Ruby-initiated visits and traversals carry
602
+ # none.
603
+ def referrer_for(source, from_url, to_url)
604
+ return nil unless %i[link form location window_open].include?(source)
605
+
606
+ from = URL.new(from_url.to_s)
607
+ to = URL.new(to_url.to_s)
608
+ return nil unless %w[http: https:].include?(from.protocol)
609
+ return "" if from.protocol == "https:" && to.protocol == "http:"
610
+ return "#{from.origin}/" unless from.origin == to.origin
611
+
612
+ from.hash = ""
613
+ from.username = ""
614
+ from.password = ""
615
+ from.href
616
+ rescue StandardError
617
+ nil
618
+ end
619
+
620
+ # A traversal by `delta` of the joint history: within the live window's own
621
+ # entries it moves that window's history (popstate); across a document
622
+ # boundary the target entry's URL is re-fetched (no bfcache).
623
+ def perform_traversal!(delta)
624
+ entry = @history.go(delta)
625
+ return unless entry
626
+
627
+ if entry.window && entry.window.equal?(@window)
628
+ @window.history.__internal_go_to__(entry.windex)
629
+ else
630
+ perform_navigation!({url: entry.url, method: "GET", source: :traverse}, rebind: true)
631
+ end
632
+ end
633
+
634
+ # Mirror the page's same-document history changes (pushState, fragment
635
+ # navigations, its own back/forward within the document) into the joint
636
+ # history, so Browser#back and history.length see them. Guarded against a
637
+ # navigated-away window.
638
+ def install_history_sync(window)
639
+ window.history.__internal_on_change__ = lambda do |kind, url|
640
+ next unless window.equal?(@window)
641
+
642
+ case kind
643
+ when :push
644
+ @history.push(url, window: window, windex: window.history.__internal_index__)
645
+ when :replace
646
+ @history.rebind_current(url: url, window: window, windex: window.history.__internal_index__)
647
+ when :traverse
648
+ @history.sync_to(window, window.history.__internal_index__)
649
+ end
650
+ end
651
+ end
652
+
401
653
  # The reserved browsing-context keywords; anything else names an iframe.
402
654
  RESERVED_TARGETS = %w[_self _top _parent _blank].freeze
403
655
 
@@ -409,7 +661,7 @@ module Dommy
409
661
  return nil if name.empty? || RESERVED_TARGETS.include?(name.downcase)
410
662
 
411
663
  @window.document.query_selector_all("iframe").find do |frame|
412
- frame.get_attribute("name").to_s == name
664
+ frame.__internal_attribute_value__("name").to_s == name
413
665
  end
414
666
  end
415
667
 
@@ -417,32 +669,43 @@ module Dommy
417
669
  # it, install the response document as the frame's content, and fire the
418
670
  # frame's `load`. The top-level window and joint history are left alone.
419
671
  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?
672
+ # The frame makes about:, data: and blob: documents itself and asks
673
+ # this browser (#load_frame) for the rest.
674
+ sub_window = frame.__internal_child_document_for__(resolved_url, nil, nav.except(:url))
675
+ return nil unless sub_window
425
676
 
426
- sub_window = frame_document_for(response)
427
- sub_window.location.__internal_set_url__(final_url)
428
677
  # 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)
678
+ sub_window.navigation_delegate = frame_navigation_delegate(frame) if sub_window.navigation_delegate.is_a?(Navigation::NullDelegate)
433
679
  frame.__internal_set_content_document__(sub_window.document)
434
680
  nil
435
681
  end
436
682
 
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)
683
+ # Fetch a frame navigation and build the Window of the document it
684
+ # answers. nil when nothing (successful) serves the URL.
685
+ def frame_window_for(frame, nav, resolved_url)
686
+ @fetcher ||= navigation_fetcher
687
+ response, final_url = begin
688
+ @fetcher.request(
689
+ method: nav[:method] || "GET", url: resolved_url, params: nav[:params],
690
+ body: nav[:body], enctype: nav[:enctype], headers: nav[:headers] || {}
691
+ )
692
+ rescue StandardError
693
+ # A resources adapter that cannot serve the request at all fails the
694
+ # navigation like a network error.
695
+ [nil, resolved_url]
696
+ end
697
+ # Nothing answered: a network error, which shows an error page (and
698
+ # still fires the frame's load). A response of any status is a document.
699
+ return Internal::ChildNavigable.error_window(final_url || resolved_url) unless response
442
700
 
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
701
+ sub_window = Internal::ChildNavigable.window_for_response(
702
+ response.body, response_header(response, "content-type"), final_url
703
+ )
704
+ sub_window.document.__internal_set_last_modified__(response_header(response, "last-modified"))
705
+ # A nested realm needs the seeded constructors to run the response's
706
+ # scripts; a runtime that cannot expose them simply runs without them.
707
+ @runtime.expose_constructors_on(sub_window) if @runtime.respond_to?(:expose_constructors_on)
708
+ sub_window
446
709
  end
447
710
 
448
711
  # If the freshly loaded document asks for an immediate `<meta http-equiv=
@@ -463,9 +726,9 @@ module Dommy
463
726
  # reloads and is left alone to avoid a busy loop).
464
727
  def meta_refresh_target(document)
465
728
  document.query_selector_all("meta").each do |meta|
466
- next unless meta.get_attribute("http-equiv").to_s.casecmp?("refresh")
729
+ next unless meta.__internal_attribute_value__("http-equiv").to_s.casecmp?("refresh")
467
730
 
468
- _delay, separator, rest = meta.get_attribute("content").to_s.partition(";")
731
+ _delay, separator, rest = meta.__internal_attribute_value__("content").to_s.partition(";")
469
732
  next if separator.empty?
470
733
 
471
734
  url = rest.strip.sub(/\Aurl\s*=\s*/i, "").gsub(/\A["']|["']\z/, "").strip
@@ -489,24 +752,36 @@ module Dommy
489
752
 
490
753
  navs = @pending_navigations
491
754
  @pending_navigations = []
492
- navs.each { |nav| perform_navigation!(nav) }
755
+ navs.each do |nav|
756
+ nav.key?(:traverse) ? perform_traversal!(nav[:traverse]) : perform_navigation!(nav)
757
+ end
493
758
  end
494
759
 
760
+ # Unload the outgoing document: `pagehide` (a PageTransitionEvent, persisted
761
+ # false), then `unload` — both trusted and targeted at the document.
495
762
  def fire_unload(window)
496
- window.dispatch_event(Dommy::Event.new("pagehide"))
497
- window.dispatch_event(Dommy::Event.new("unload"))
763
+ document = window.document
764
+ window.dispatch_event(document.__internal_page_transition_event__("pagehide"))
765
+ unload = Dommy::Event.new("unload")
766
+ unload.__internal_set_target__(document)
767
+ window.dispatch_event(unload.__internal_mark_trusted__)
498
768
  end
499
769
 
500
770
  # Only HTML/XML responses replace the document; other content types (a JSON
501
771
  # API hit, an image) leave the current page. A response with no Content-Type
502
772
  # is treated as a document (fixtures commonly omit it).
503
773
  def document_response?(response)
504
- headers = response.headers || {}
505
- key = headers.keys.find { |k| k.to_s.casecmp?("content-type") }
506
- content_type = key ? headers[key].to_s.downcase : ""
774
+ content_type = response_header(response, "content-type").to_s.downcase
507
775
  content_type.empty? || content_type.include?("html") || content_type.include?("xml")
508
776
  end
509
777
 
778
+ # A response header's value by case-insensitive name, or nil.
779
+ def response_header(response, name)
780
+ headers = response.headers || {}
781
+ key = headers.keys.find { |k| k.to_s.casecmp?(name) }
782
+ key && headers[key]
783
+ end
784
+
510
785
  def submit_button?(button)
511
786
  if button.tag_name == "BUTTON"
512
787
  button.type == "submit"