dommy 0.15.0 → 0.17.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 (207) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/animation.rb +1 -0
  3. data/lib/dommy/backend.rb +19 -15
  4. data/lib/dommy/blob.rb +23 -0
  5. data/lib/dommy/bridge/values.rb +29 -2
  6. data/lib/dommy/bridge/wire_tags.rb +8 -4
  7. data/lib/dommy/browser.rb +335 -52
  8. data/lib/dommy/character_data.rb +21 -40
  9. data/lib/dommy/class_list.rb +24 -9
  10. data/lib/dommy/cookie_jar.rb +408 -0
  11. data/lib/dommy/cookie_store.rb +105 -49
  12. data/lib/dommy/css.rb +34 -8
  13. data/lib/dommy/custom_elements.rb +362 -114
  14. data/lib/dommy/data_transfer.rb +22 -2
  15. data/lib/dommy/document.rb +1114 -206
  16. data/lib/dommy/dom_exception.rb +6 -0
  17. data/lib/dommy/dom_parser.rb +46 -14
  18. data/lib/dommy/dom_string_list.rb +36 -0
  19. data/lib/dommy/element.rb +228 -102
  20. data/lib/dommy/element_internals.rb +227 -0
  21. data/lib/dommy/encodings/legacy_multibyte.rb +800 -0
  22. data/lib/dommy/encodings/multibyte_overrides.rb +76 -0
  23. data/lib/dommy/encodings/prescan.rb +234 -0
  24. data/lib/dommy/encodings.rb +126 -52
  25. data/lib/dommy/event.rb +472 -163
  26. data/lib/dommy/event_source.rb +5 -30
  27. data/lib/dommy/fetch.rb +244 -53
  28. data/lib/dommy/file_reader.rb +6 -29
  29. data/lib/dommy/form_data.rb +62 -15
  30. data/lib/dommy/form_entry_list.rb +108 -139
  31. data/lib/dommy/fragment.rb +11 -10
  32. data/lib/dommy/history.rb +247 -90
  33. data/lib/dommy/html_canvas_element.rb +95 -42
  34. data/lib/dommy/html_collection.rb +49 -30
  35. data/lib/dommy/html_elements/base.rb +112 -69
  36. data/lib/dommy/html_elements/embedded.rb +349 -105
  37. data/lib/dommy/html_elements/forms/form.rb +407 -368
  38. data/lib/dommy/html_elements/forms/input.rb +336 -174
  39. data/lib/dommy/html_elements/forms/select.rb +90 -74
  40. data/lib/dommy/html_elements/interactive.rb +329 -111
  41. data/lib/dommy/html_elements/media.rb +71 -30
  42. data/lib/dommy/html_elements/metadata.rb +238 -71
  43. data/lib/dommy/html_elements/miscellaneous.rb +58 -14
  44. data/lib/dommy/html_elements/tables.rb +7 -9
  45. data/lib/dommy/html_elements.rb +23 -1
  46. data/lib/dommy/hyperlink_utils.rb +15 -4
  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 +17 -5
  50. data/lib/dommy/interaction/form_submission.rb +31 -43
  51. data/lib/dommy/interaction/key_sender.rb +101 -16
  52. data/lib/dommy/interaction.rb +1 -0
  53. data/lib/dommy/internal/accessibility_tree.rb +149 -150
  54. data/lib/dommy/internal/accessibility_visibility.rb +16 -17
  55. data/lib/dommy/internal/accessible_description.rb +1 -1
  56. data/lib/dommy/internal/accessible_name.rb +231 -253
  57. data/lib/dommy/internal/aria_role.rb +182 -195
  58. data/lib/dommy/internal/aria_snapshot.rb +35 -38
  59. data/lib/dommy/internal/aria_state.rb +102 -118
  60. data/lib/dommy/internal/autofill.rb +75 -75
  61. data/lib/dommy/internal/backend_prefilter.rb +126 -133
  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 +7 -43
  65. data/lib/dommy/internal/child_navigable.rb +158 -0
  66. data/lib/dommy/internal/child_node.rb +44 -41
  67. data/lib/dommy/internal/close_watcher_manager.rb +239 -0
  68. data/lib/dommy/internal/complex_match.rb +3 -1
  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/calc.rb +90 -71
  72. data/lib/dommy/internal/css/cascade.rb +72 -70
  73. data/lib/dommy/internal/css/cascaded_declarations.rb +4 -1
  74. data/lib/dommy/internal/css/color.rb +249 -187
  75. data/lib/dommy/internal/css/computed_style_builder.rb +43 -3
  76. data/lib/dommy/internal/css/computed_style_declaration.rb +50 -11
  77. data/lib/dommy/internal/css/counters.rb +67 -67
  78. data/lib/dommy/internal/css/custom_properties.rb +115 -103
  79. data/lib/dommy/internal/css/parser.rb +273 -257
  80. data/lib/dommy/internal/css/property_registry.rb +321 -302
  81. data/lib/dommy/internal/css/renderability.rb +39 -31
  82. data/lib/dommy/internal/css/rule_index.rb +151 -100
  83. data/lib/dommy/internal/css/rule_index_inputs.rb +135 -0
  84. data/lib/dommy/internal/css/selector_dependencies.rb +8 -2
  85. data/lib/dommy/internal/css/style_cache.rb +7 -4
  86. data/lib/dommy/internal/css/supports.rb +115 -113
  87. data/lib/dommy/internal/css/ua_stylesheet.rb +60 -24
  88. data/lib/dommy/internal/css_rule_text.rb +121 -119
  89. data/lib/dommy/internal/css_source.rb +1 -3
  90. data/lib/dommy/internal/css_syntax.rb +157 -92
  91. data/lib/dommy/internal/declarative_shadow_roots.rb +183 -0
  92. data/lib/dommy/internal/directionality.rb +120 -123
  93. data/lib/dommy/internal/document_generations.rb +55 -2
  94. data/lib/dommy/internal/document_interaction_state.rb +293 -9
  95. data/lib/dommy/internal/document_live_ranges.rb +26 -1
  96. data/lib/dommy/internal/dom_matching.rb +185 -184
  97. data/lib/dommy/internal/element_aria.rb +25 -19
  98. data/lib/dommy/internal/element_geometry.rb +8 -1
  99. data/lib/dommy/internal/element_matching.rb +71 -69
  100. data/lib/dommy/internal/element_popover.rb +226 -25
  101. data/lib/dommy/internal/element_shadow.rb +98 -39
  102. data/lib/dommy/internal/element_state.rb +264 -181
  103. data/lib/dommy/internal/element_top_layer.rb +15 -1
  104. data/lib/dommy/internal/enumerated_keyword_sets.rb +40 -0
  105. data/lib/dommy/internal/event_handler_tables.rb +241 -0
  106. data/lib/dommy/internal/event_handlers.rb +151 -0
  107. data/lib/dommy/internal/exception_report.rb +89 -87
  108. data/lib/dommy/internal/focusability.rb +267 -0
  109. data/lib/dommy/internal/form_associated_custom_elements.rb +127 -0
  110. data/lib/dommy/internal/global_functions.rb +49 -47
  111. data/lib/dommy/internal/html_or_svg_or_mathml_element.rb +34 -23
  112. data/lib/dommy/internal/html_serialization.rb +292 -0
  113. data/lib/dommy/internal/idl_reflection.rb +204 -0
  114. data/lib/dommy/internal/idl_reflection_table.rb +589 -0
  115. data/lib/dommy/internal/infra.rb +9 -0
  116. data/lib/dommy/internal/input_type.rb +76 -18
  117. data/lib/dommy/internal/insertion_point.rb +27 -3
  118. data/lib/dommy/internal/js_number.rb +62 -0
  119. data/lib/dommy/internal/light_dismiss.rb +176 -0
  120. data/lib/dommy/internal/literal_lookup.rb +75 -75
  121. data/lib/dommy/internal/mime_type.rb +55 -42
  122. data/lib/dommy/internal/mutation_coordinator.rb +140 -62
  123. data/lib/dommy/internal/mutation_observer_microtask.rb +60 -0
  124. data/lib/dommy/internal/namespaces.rb +51 -49
  125. data/lib/dommy/internal/node_adopter.rb +77 -5
  126. data/lib/dommy/internal/node_equality.rb +61 -59
  127. data/lib/dommy/internal/node_factory.rb +111 -7
  128. data/lib/dommy/internal/node_identity.rb +1 -1
  129. data/lib/dommy/internal/node_traversal.rb +22 -4
  130. data/lib/dommy/internal/node_wrapper_cache.rb +6 -38
  131. data/lib/dommy/internal/observer_options.rb +24 -14
  132. data/lib/dommy/internal/origin.rb +86 -0
  133. data/lib/dommy/internal/parent_node.rb +10 -8
  134. data/lib/dommy/internal/popover_invoker_element.rb +67 -0
  135. data/lib/dommy/internal/popover_stack.rb +120 -0
  136. data/lib/dommy/internal/post_insertion_steps.rb +141 -71
  137. data/lib/dommy/internal/referrer.rb +144 -0
  138. data/lib/dommy/internal/reflected_attributes.rb +128 -18
  139. data/lib/dommy/internal/rendered_text/collector.rb +23 -8
  140. data/lib/dommy/internal/rendered_text/computed_box.rb +3 -2
  141. data/lib/dommy/internal/rendered_text/fragment.rb +37 -34
  142. data/lib/dommy/internal/rendered_text/items.rb +7 -5
  143. data/lib/dommy/internal/selector_arguments.rb +2 -1
  144. data/lib/dommy/internal/selector_match.rb +1 -1
  145. data/lib/dommy/internal/selector_matcher.rb +361 -354
  146. data/lib/dommy/internal/selector_parser.rb +88 -63
  147. data/lib/dommy/internal/sequential_focus_navigation.rb +209 -0
  148. data/lib/dommy/internal/shadow_root_registry.rb +14 -0
  149. data/lib/dommy/internal/slot_assignment.rb +117 -0
  150. data/lib/dommy/internal/slot_signals.rb +125 -0
  151. data/lib/dommy/internal/template_content_registry.rb +7 -1
  152. data/lib/dommy/internal/text_selection.rb +185 -32
  153. data/lib/dommy/internal/toggle_events.rb +6 -4
  154. data/lib/dommy/internal/unsafe_html.rb +109 -0
  155. data/lib/dommy/internal/url_parser.rb +89 -37
  156. data/lib/dommy/internal/user_activation.rb +192 -0
  157. data/lib/dommy/internal/web_idl.rb +176 -38
  158. data/lib/dommy/internal/window_bound.rb +14 -0
  159. data/lib/dommy/internal/window_constructors.rb +29 -6
  160. data/lib/dommy/internal/xml_serialization.rb +298 -296
  161. data/lib/dommy/js/constructor_resolver.rb +18 -0
  162. data/lib/dommy/js/custom_element_bridge.rb +57 -82
  163. data/lib/dommy/js/dom_interfaces.rb +63 -16
  164. data/lib/dommy/js/host_bridge.rb +212 -41
  165. data/lib/dommy/js/host_callbacks.rb +7 -0
  166. data/lib/dommy/js/host_runtime.js +2494 -448
  167. data/lib/dommy/js/import_map.rb +1 -1
  168. data/lib/dommy/js/marshaller.rb +13 -4
  169. data/lib/dommy/js/module_loader.rb +37 -6
  170. data/lib/dommy/js/module_preload.rb +107 -29
  171. data/lib/dommy/js/observable_runtime.js +295 -128
  172. data/lib/dommy/js/script_boot.rb +197 -115
  173. data/lib/dommy/js/serialized_record.rb +32 -0
  174. data/lib/dommy/js/webidl_event_handlers.js +45 -0
  175. data/lib/dommy/js/webidl_members.js +196 -0
  176. data/lib/dommy/js/webidl_signatures.js +2084 -0
  177. data/lib/dommy/js/webidl_tables.js +47 -100
  178. data/lib/dommy/js/window_wiring.rb +9 -5
  179. data/lib/dommy/location.rb +129 -76
  180. data/lib/dommy/media_query_list.rb +5 -13
  181. data/lib/dommy/message_channel.rb +139 -64
  182. data/lib/dommy/mutation_observer.rb +27 -29
  183. data/lib/dommy/navigation.rb +59 -9
  184. data/lib/dommy/navigator.rb +135 -6
  185. data/lib/dommy/node.rb +23 -11
  186. data/lib/dommy/notification.rb +10 -1
  187. data/lib/dommy/promise.rb +20 -0
  188. data/lib/dommy/range.rb +25 -37
  189. data/lib/dommy/resources.rb +38 -3
  190. data/lib/dommy/scheduler.rb +176 -48
  191. data/lib/dommy/shadow_root.rb +141 -19
  192. data/lib/dommy/storage.rb +135 -25
  193. data/lib/dommy/streams.rb +18 -0
  194. data/lib/dommy/structured_data.rb +45 -0
  195. data/lib/dommy/style_declaration.rb +31 -4
  196. data/lib/dommy/text_codec.rb +9 -19
  197. data/lib/dommy/tree_walker.rb +78 -79
  198. data/lib/dommy/url.rb +11 -4
  199. data/lib/dommy/validity_state.rb +109 -107
  200. data/lib/dommy/version.rb +1 -1
  201. data/lib/dommy/web_socket.rb +6 -28
  202. data/lib/dommy/window.rb +833 -103
  203. data/lib/dommy/worker.rb +5 -28
  204. data/lib/dommy/xml_http_request.rb +43 -46
  205. data/lib/dommy.rb +52 -3
  206. metadata +45 -6
  207. data/lib/dommy/internal/cookie_jar.rb +0 -29
data/lib/dommy/browser.rb CHANGED
@@ -58,8 +58,11 @@ module Dommy
58
58
  browser
59
59
  end
60
60
 
61
+ # `encoding` is the encoding the page's bytes were decoded with
62
+ # (Encodings.decode_document), when it came from bytes; HTML given as a
63
+ # string makes a UTF-8 document.
61
64
  def initialize(html, url: "http://localhost/", resources: nil, execute_scripts: true, strict: true, settle: true,
62
- wasm_memory_shim: false, backend: nil, navigable: false, same_origin: false)
65
+ wasm_memory_shim: false, backend: nil, navigable: false, same_origin: false, encoding: nil)
63
66
  @resources = resources
64
67
  @same_origin = same_origin
65
68
  @backend = backend
@@ -71,21 +74,50 @@ module Dommy
71
74
  @console = []
72
75
  @disposed = false
73
76
  @pending_navigations = []
77
+ @popups = []
74
78
  @runtime = nil
75
-
76
- @window = Dommy.parse(html)
79
+ # One browsing session: every window it shows (and their frames) shares
80
+ # localStorage per origin and sessionStorage per origin.
81
+ @storage_provider = StorageProvider.new
82
+ # One cookie store for the session: document.cookie, cookieStore, and
83
+ # the Cookie / Set-Cookie of every request it sends through resources.
84
+ @cookie_jar = CookieJar.new
85
+ @before_unload_handler = nil
86
+
87
+ @window = Dommy.parse(html, encoding: encoding)
77
88
  @window.location.__internal_set_url__(url) if url
78
- install_runtime(@window)
89
+ @window.storage_provider = @storage_provider
90
+ @window.cookie_jar = @cookie_jar
79
91
 
80
92
  if navigable
81
- @fetcher = Navigation::Fetcher.new(@resources, same_origin: @same_origin)
93
+ @fetcher = navigation_fetcher
82
94
  @history = Navigation::JointHistory.new
83
95
  @window.navigation_delegate = self
84
96
  @history.push(current_url, window: @window, windex: @window.history.__internal_index__)
97
+ install_history_sync(@window)
85
98
  end
99
+ install_runtime(@window)
86
100
  check_js_errors!
87
101
  end
88
102
 
103
+ # The provider of this browser's Web Storage areas (shared by every window
104
+ # it shows); see Dommy::StorageProvider.
105
+ attr_reader :storage_provider
106
+
107
+ # This browser's cookies (a Dommy::CookieJar): the one store its pages'
108
+ # document.cookie and cookieStore use and its requests send and fill.
109
+ attr_reader :cookie_jar
110
+
111
+ # Install a handler for a page that asks to confirm leaving it (a canceled
112
+ # `beforeunload`, or one whose returnValue is set): called with the window
113
+ # and the BeforeUnloadEvent, its truthy answer lets the navigation proceed.
114
+ # Without one the navigation always proceeds — a headless page has no sticky
115
+ # user activation, so a browser would not prompt either.
116
+ def on_before_unload(&block)
117
+ @before_unload_handler = block
118
+ self
119
+ end
120
+
89
121
  def document = @window.document
90
122
 
91
123
  # Current document HTML (serialized).
@@ -117,8 +149,8 @@ module Dommy
117
149
  # Move back / forward one entry in the joint history. A same-document target
118
150
  # (the entry's window is still live) traverses in place (popstate); a
119
151
  # document-boundary target is re-fetched (no bfcache — D2).
120
- def back = traverse(-1)
121
- def forward = traverse(1)
152
+ def back = traverse_now(-1)
153
+ def forward = traverse_now(1)
122
154
 
123
155
  # --- NavigationDelegate port (see Dommy::Navigation) ---
124
156
 
@@ -138,7 +170,7 @@ module Dommy
138
170
  navigate_frame(frame,
139
171
  {method: method, url: url, params: params, body: body, enctype: enctype, headers: headers},
140
172
  resolve_against_current(url.to_s))
141
- @window.scheduler.queue_microtask(proc { frame.dispatch_event(Dommy::Event.new("load")) })
173
+ @window.scheduler.queue_microtask(proc { frame.__internal_run_iframe_load_event_steps__ })
142
174
  return nil
143
175
  end
144
176
 
@@ -164,6 +196,109 @@ module Dommy
164
196
  end
165
197
 
166
198
  def traverse(_delta) = nil
199
+
200
+ def history_length = @browser.history_length
201
+
202
+ # The frames of the document loaded into this frame load the same way.
203
+ def load_frame(frame, **nav) = @browser.load_frame(frame, **nav)
204
+
205
+ # A popup opened from inside the frame is the browser's too.
206
+ def open_window(**opts) = @browser.open_window(**opts)
207
+ end
208
+
209
+ # The navigation delegate of an auxiliary window this browser opened
210
+ # (window.open): its navigations load into it, keeping the Window the
211
+ # opener holds (Dommy has no WindowProxy, so the document is swapped
212
+ # under it), and its close() closes it.
213
+ class PopupDelegate
214
+ def initialize(browser, popup)
215
+ @browser = browser
216
+ @popup = popup
217
+ end
218
+
219
+ def navigate(url:, source:, method: "GET", body: nil, params: nil, enctype: nil, target: nil, headers: {}, replace: false)
220
+ @browser.__internal_load_popup__(@popup, {url: url, method: method, body: body, params: params,
221
+ enctype: enctype, headers: headers})
222
+ end
223
+
224
+ def traverse(_delta) = nil
225
+
226
+ def close_window = @browser.__internal_close_popup__(@popup)
227
+
228
+ def find_window(name) = @browser.find_window(name)
229
+
230
+ def load_frame(frame, **nav) = @browser.load_frame(frame, **nav)
231
+
232
+ def open_window(**opts) = @browser.open_window(**opts)
233
+ end
234
+
235
+ # The auxiliary windows the page opened with window.open and has not
236
+ # closed, oldest first.
237
+ def popups = @popups.dup
238
+
239
+ # NavigationDelegate `open_window`: window.open asked for a new top-level
240
+ # browsing context. It is created at once with the initial about:blank
241
+ # (whose origin is the opener's) and navigated to `url` from a task; it
242
+ # shares this browser's cookies and localStorage, and starts its
243
+ # sessionStorage as a copy of this one's. Window#window_open sets its
244
+ # opener and name.
245
+ def open_window(url:, target:, features: "")
246
+ return nil if @disposed
247
+
248
+ popup = Dommy.parse(Internal::ChildNavigable::BLANK_HTML)
249
+ popup.location.__internal_set_url__("about:blank")
250
+ popup.__internal_initial_about_blank__ = true
251
+ popup.storage_provider = @storage_provider.new_session
252
+ popup.cookie_jar = @cookie_jar
253
+ popup.navigation_delegate = PopupDelegate.new(self, popup)
254
+ @popups << popup
255
+ unless Internal::ChildNavigable.matches_about_blank?(url)
256
+ @window.scheduler.set_timeout(proc { __internal_load_popup__(popup, {url: url, method: "GET"}) }, 0)
257
+ end
258
+ popup
259
+ end
260
+
261
+ # Load a navigation into a popup: fetch it, give the popup's Window the
262
+ # document, and fire its load. Nothing happens when nothing serves it.
263
+ def __internal_load_popup__(popup, nav)
264
+ return nil if @disposed || @resources.nil? || !@popups.include?(popup)
265
+
266
+ url = nav[:url].to_s
267
+ @fetcher ||= navigation_fetcher
268
+ response, final_url = begin
269
+ @fetcher.request(method: nav[:method] || "GET", url: url, params: nav[:params],
270
+ body: nav[:body], enctype: nav[:enctype], headers: nav[:headers] || {})
271
+ rescue StandardError
272
+ [nil, url]
273
+ end
274
+ return nil unless response && document_response?(response)
275
+
276
+ loaded = Internal::ChildNavigable.window_for_response(response.body, response_header(response, "content-type"), final_url)
277
+ popup.__internal_adopt_document_of__(loaded)
278
+ popup.navigation_delegate = PopupDelegate.new(self, popup)
279
+ document = popup.document
280
+ document.__internal_update_readiness__("loading")
281
+ document.__internal_finish_loading__
282
+ nil
283
+ end
284
+
285
+ # NavigationDelegate `find_window`: the top-level window (this browser's
286
+ # page or a popup) or a frame in one, whose target name is `name`.
287
+ def find_window(name)
288
+ ([@window] + @popups).each do |top|
289
+ return top if top.name == name
290
+
291
+ found = top.__internal_find_descendant_window__(name)
292
+ return found if found
293
+ end
294
+ nil
295
+ end
296
+
297
+ # A popup's close(): it leaves the browser, closed.
298
+ def __internal_close_popup__(popup)
299
+ @popups.delete(popup)
300
+ popup.__internal_discard__
301
+ nil
167
302
  end
168
303
 
169
304
  def frame_navigation_delegate(frame) = FrameNavigationDelegate.new(self, frame)
@@ -179,26 +314,49 @@ module Dommy
179
314
  nav[:url].to_s
180
315
  end
181
316
  navigate_frame(frame, nav, resolved)
182
- frame.dispatch_event(Dommy::Event.new("load"))
317
+ frame.__internal_run_iframe_load_event_steps__
183
318
  nil
184
319
  end
185
320
 
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.
321
+ # NavigationDelegate `load_frame`: an iframe's child navigable is navigating
322
+ # to `url` (its `src`, or a navigation from inside it) — fetch it through
323
+ # this browser's resources and answer the document's Window, or nil when
324
+ # nothing serves it (the frame then keeps its document). The frame installs
325
+ # it and fires its own `load`.
326
+ def load_frame(frame, url:, method: "GET", body: nil, params: nil, enctype: nil, headers: {}, **)
327
+ return nil if @disposed || @resources.nil?
328
+
329
+ frame_window_for(frame, {url: url, method: method, body: body, params: params, enctype: enctype, headers: headers}, url.to_s)
330
+ end
331
+
332
+ # A history traversal by `delta` that the PAGE asked for (`history.go(n)`
333
+ # past its own document's entries): like a navigation it is a task, so it
334
+ # is recorded and performed at the next drain boundary.
189
335
  def traverse(delta)
190
- return self unless @navigable
336
+ @pending_navigations << {traverse: delta.to_i} if @navigable
337
+ nil
338
+ end
191
339
 
192
- entry = delta.negative? ? @history.back : @history.forward
193
- return self unless entry
340
+ # `history.length`: the joint session history's size (nil when this
341
+ # browser keeps none).
342
+ def history_length = (@history.length if @navigable)
194
343
 
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
344
+ # `window.stop()`: drop the navigations the page asked for and that have not
345
+ # been performed yet.
346
+ def stop
347
+ @pending_navigations.clear
348
+ nil
349
+ end
350
+
351
+ # A Ruby-initiated traversal (back / forward) runs immediately: a
352
+ # same-document target (its window is still live) traverses in place
353
+ # (popstate); a document-boundary target is re-fetched.
354
+ def traverse_now(delta)
355
+ return self unless @navigable
356
+
357
+ perform_traversal!(delta)
358
+ @runtime.drain_microtasks
359
+ check_js_errors!
202
360
  self
203
361
  end
204
362
 
@@ -221,6 +379,7 @@ module Dommy
221
379
  # `setTimeout(300)` — use `advance_time(300)` for debounce/throttle.
222
380
  def settle
223
381
  @runtime.settle
382
+ drive_popups(0)
224
383
  flush_navigation!
225
384
  check_js_errors!
226
385
  self
@@ -229,6 +388,7 @@ module Dommy
229
388
  # Advance virtual time by `ms`, running timers that come due, then settle.
230
389
  def advance_time(ms)
231
390
  @window.scheduler.advance_time(ms)
391
+ drive_popups(ms)
232
392
  @runtime.drain_microtasks
233
393
  flush_navigation!
234
394
  check_js_errors!
@@ -240,6 +400,7 @@ module Dommy
240
400
  # land before the next line, then enforce strict mode.
241
401
  def after_interaction
242
402
  @runtime.drain_microtasks
403
+ drive_popups(0)
243
404
  flush_navigation!
244
405
  check_js_errors!
245
406
  end
@@ -319,7 +480,10 @@ module Dommy
319
480
  # Opt-in WPT scaffolding (common/sab.js derives SharedArrayBuffer through
320
481
  # WebAssembly.Memory); off by default so real pages don't see the shim.
321
482
  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
483
+ if @resources
484
+ window.globals["__fetch_handler__"] =
485
+ Resources::FetchHandler.new(@resources, cookie_jar: @cookie_jar, origin: -> { window.origin })
486
+ end
323
487
  @runtime = runtime
324
488
  doc = window.document
325
489
  # An `on*` attribute that arrived after boot (a cloned template, an
@@ -327,7 +491,7 @@ module Dommy
327
491
  # Installed whenever a runtime is attached — an embedder that drives script
328
492
  # boot itself (`execute_scripts: false`) still needs inline handlers wired.
329
493
  doc.inline_handler_wirer = lambda do
330
- Js::ScriptBoot.wire_inline_handlers(runtime, on_error: ->(e) { @error_log.record(e) })
494
+ Js::ScriptBoot.wire_inline_handlers(runtime, document: doc, on_error: ->(e) { @error_log.record(e) })
331
495
  end
332
496
  return unless @execute_scripts
333
497
 
@@ -348,6 +512,16 @@ module Dommy
348
512
  runtime.settle if @settle_after_boot
349
513
  end
350
514
 
515
+ # A popup has no realm of its own; the tasks queued on its window (a
516
+ # close(), its timers) run as the browser's time moves.
517
+ def drive_popups(ms)
518
+ @popups.dup.each { |popup| popup.scheduler.advance_time(ms) }
519
+ end
520
+
521
+ def navigation_fetcher
522
+ Navigation::Fetcher.new(@resources, same_origin: @same_origin, cookie_jar: @cookie_jar)
523
+ end
524
+
351
525
  # Route an unhandled promise rejection through the page's own
352
526
  # `unhandledrejection` handling first. The engine decides WHEN a rejection
353
527
  # counts as unhandled (see Window#__internal_report_rejection__).
@@ -371,22 +545,36 @@ module Dommy
371
545
  frame = resolve_target_frame(nav[:target])
372
546
  return navigate_frame(frame, nav, resolved) if frame
373
547
 
548
+ # The outgoing page may ask to confirm leaving (beforeunload).
549
+ return unless unloading_allowed?(@window)
550
+
374
551
  response, final_url = @fetcher.request(
375
552
  method: nav[:method] || "GET", url: resolved, params: nav[:params],
376
553
  body: nav[:body], enctype: nav[:enctype], headers: nav[:headers] || {}
377
554
  )
378
555
  return unless response&.success? && document_response?(response)
379
556
 
557
+ old_window = @window
558
+ referrer = referrer_for(nav[:source], old_window.location.href, final_url)
380
559
  # Fire the outgoing document's unload sequence while its realm is still
381
560
  # alive, then surface any of its errors before the realm is torn down.
382
- fire_unload(@window)
561
+ fire_unload(old_window)
383
562
  check_js_errors!
384
563
 
385
- new_window = Dommy.parse(response.body)
564
+ # HTML's <meta charset> prescan is for an HTML document (one that names
565
+ # no type is taken for one); an XML type's encoding is not a <meta>'s.
566
+ content_type = response_header(response, "content-type")
567
+ type = Internal::MimeType.essence(content_type)
568
+ text, encoding = Encodings.decode_document(response.body, content_type, html: type.empty? || type == "text/html")
569
+ new_window = Dommy.parse(text, encoding: encoding)
386
570
  new_window.location.__internal_set_url__(final_url)
571
+ new_window.document.__internal_set_last_modified__(response_header(response, "last-modified"))
572
+ new_window.document.__internal_referrer__ = referrer if referrer
387
573
  new_window.navigation_delegate = self
574
+ new_window.storage_provider = @storage_provider
575
+ new_window.cookie_jar = @cookie_jar
576
+ old_window.__internal_discard__
388
577
  @window = new_window
389
- install_runtime(new_window)
390
578
 
391
579
  windex = new_window.history.__internal_index__
392
580
  if rebind || nav[:replace]
@@ -394,10 +582,82 @@ module Dommy
394
582
  else
395
583
  @history.push(final_url, window: new_window, windex: windex)
396
584
  end
585
+ # The joint history knows the new page before its scripts boot, so a
586
+ # pushState / history.length during boot sees the right session.
587
+ install_history_sync(new_window)
588
+ install_runtime(new_window)
397
589
 
398
590
  follow_meta_refresh!(refresh_depth)
399
591
  end
400
592
 
593
+ # Fire `beforeunload` at the outgoing window; a page that cancels it (or
594
+ # sets returnValue) is asked about through the on_before_unload handler, if
595
+ # one is installed.
596
+ def unloading_allowed?(window)
597
+ event = BeforeUnloadEvent.new("beforeunload", "cancelable" => true).__internal_mark_trusted__
598
+ not_canceled = window.dispatch_event(event)
599
+ return true if not_canceled && event.return_value.to_s.empty?
600
+ return true unless @before_unload_handler
601
+
602
+ @before_unload_handler.call(window, event) ? true : false
603
+ end
604
+
605
+ # The `document.referrer` a navigation hands the new document: the page
606
+ # that started it (links, forms, script), under the default
607
+ # strict-origin-when-cross-origin policy — the full URL (without fragment
608
+ # or credentials) within an origin, only the origin across origins, nothing
609
+ # on an https -> http downgrade. Ruby-initiated visits and traversals carry
610
+ # none.
611
+ def referrer_for(source, from_url, to_url)
612
+ return nil unless %i[link form location window_open].include?(source)
613
+
614
+ from = URL.new(from_url.to_s)
615
+ to = URL.new(to_url.to_s)
616
+ return nil unless %w[http: https:].include?(from.protocol)
617
+ return "" if from.protocol == "https:" && to.protocol == "http:"
618
+ return "#{from.origin}/" unless from.origin == to.origin
619
+
620
+ from.hash = ""
621
+ from.username = ""
622
+ from.password = ""
623
+ from.href
624
+ rescue StandardError
625
+ nil
626
+ end
627
+
628
+ # A traversal by `delta` of the joint history: within the live window's own
629
+ # entries it moves that window's history (popstate); across a document
630
+ # boundary the target entry's URL is re-fetched (no bfcache).
631
+ def perform_traversal!(delta)
632
+ entry = @history.go(delta)
633
+ return unless entry
634
+
635
+ if entry.window && entry.window.equal?(@window)
636
+ @window.history.__internal_go_to__(entry.windex)
637
+ else
638
+ perform_navigation!({url: entry.url, method: "GET", source: :traverse}, rebind: true)
639
+ end
640
+ end
641
+
642
+ # Mirror the page's same-document history changes (pushState, fragment
643
+ # navigations, its own back/forward within the document) into the joint
644
+ # history, so Browser#back and history.length see them. Guarded against a
645
+ # navigated-away window.
646
+ def install_history_sync(window)
647
+ window.history.__internal_on_change__ = lambda do |kind, url|
648
+ next unless window.equal?(@window)
649
+
650
+ case kind
651
+ when :push
652
+ @history.push(url, window: window, windex: window.history.__internal_index__)
653
+ when :replace
654
+ @history.rebind_current(url: url, window: window, windex: window.history.__internal_index__)
655
+ when :traverse
656
+ @history.sync_to(window, window.history.__internal_index__)
657
+ end
658
+ end
659
+ end
660
+
401
661
  # The reserved browsing-context keywords; anything else names an iframe.
402
662
  RESERVED_TARGETS = %w[_self _top _parent _blank].freeze
403
663
 
@@ -417,32 +677,43 @@ module Dommy
417
677
  # it, install the response document as the frame's content, and fire the
418
678
  # frame's `load`. The top-level window and joint history are left alone.
419
679
  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?
680
+ # The frame makes about:, data: and blob: documents itself and asks
681
+ # this browser (#load_frame) for the rest.
682
+ sub_window = frame.__internal_child_document_for__(resolved_url, nil, nav.except(:url))
683
+ return nil unless sub_window
425
684
 
426
- sub_window = frame_document_for(response)
427
- sub_window.location.__internal_set_url__(final_url)
428
685
  # 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)
686
+ sub_window.navigation_delegate = frame_navigation_delegate(frame) if sub_window.navigation_delegate.is_a?(Navigation::NullDelegate)
433
687
  frame.__internal_set_content_document__(sub_window.document)
434
688
  nil
435
689
  end
436
690
 
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)
691
+ # Fetch a frame navigation and build the Window of the document it
692
+ # answers. nil when nothing (successful) serves the URL.
693
+ def frame_window_for(frame, nav, resolved_url)
694
+ @fetcher ||= navigation_fetcher
695
+ response, final_url = begin
696
+ @fetcher.request(
697
+ method: nav[:method] || "GET", url: resolved_url, params: nav[:params],
698
+ body: nav[:body], enctype: nav[:enctype], headers: nav[:headers] || {}
699
+ )
700
+ rescue StandardError
701
+ # A resources adapter that cannot serve the request at all fails the
702
+ # navigation like a network error.
703
+ [nil, resolved_url]
704
+ end
705
+ # Nothing answered: a network error, which shows an error page (and
706
+ # still fires the frame's load). A response of any status is a document.
707
+ return Internal::ChildNavigable.error_window(final_url || resolved_url) unless response
442
708
 
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
709
+ sub_window = Internal::ChildNavigable.window_for_response(
710
+ response.body, response_header(response, "content-type"), final_url
711
+ )
712
+ sub_window.document.__internal_set_last_modified__(response_header(response, "last-modified"))
713
+ # A nested realm needs the seeded constructors to run the response's
714
+ # scripts; a runtime that cannot expose them simply runs without them.
715
+ @runtime.expose_constructors_on(sub_window) if @runtime.respond_to?(:expose_constructors_on)
716
+ sub_window
446
717
  end
447
718
 
448
719
  # If the freshly loaded document asks for an immediate `<meta http-equiv=
@@ -489,24 +760,36 @@ module Dommy
489
760
 
490
761
  navs = @pending_navigations
491
762
  @pending_navigations = []
492
- navs.each { |nav| perform_navigation!(nav) }
763
+ navs.each do |nav|
764
+ nav.key?(:traverse) ? perform_traversal!(nav[:traverse]) : perform_navigation!(nav)
765
+ end
493
766
  end
494
767
 
768
+ # Unload the outgoing document: `pagehide` (a PageTransitionEvent, persisted
769
+ # false), then `unload` — both trusted and targeted at the document.
495
770
  def fire_unload(window)
496
- window.dispatch_event(Dommy::Event.new("pagehide"))
497
- window.dispatch_event(Dommy::Event.new("unload"))
771
+ document = window.document
772
+ window.dispatch_event(document.__internal_page_transition_event__("pagehide"))
773
+ unload = Dommy::Event.new("unload")
774
+ unload.__internal_set_legacy_target_override__(document)
775
+ window.dispatch_event(unload.__internal_mark_trusted__)
498
776
  end
499
777
 
500
778
  # Only HTML/XML responses replace the document; other content types (a JSON
501
779
  # API hit, an image) leave the current page. A response with no Content-Type
502
780
  # is treated as a document (fixtures commonly omit it).
503
781
  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 : ""
782
+ content_type = response_header(response, "content-type").to_s.downcase
507
783
  content_type.empty? || content_type.include?("html") || content_type.include?("xml")
508
784
  end
509
785
 
786
+ # A response header's value by case-insensitive name, or nil.
787
+ def response_header(response, name)
788
+ headers = response.headers || {}
789
+ key = headers.keys.find { |k| k.to_s.casecmp?(name) }
790
+ key && headers[key]
791
+ end
792
+
510
793
  def submit_button?(button)
511
794
  if button.tag_name == "BUTTON"
512
795
  button.type == "submit"
@@ -89,9 +89,10 @@ module Dommy
89
89
  @__node__.content
90
90
  end
91
91
 
92
+ # Assigning `data`, `nodeValue` or `textContent` is "replace data" over
93
+ # the whole node, so a live range boundary inside it clamps to the start
94
+ # rather than dangling past the end.
92
95
  def data=(value)
93
- # Assigning `data` is "replace data" over the whole node, so a live range
94
- # boundary inside it clamps to the start rather than dangling past the end.
95
96
  old_length = utf16_length(@__node__.content)
96
97
  write_data(value)
97
98
  @document.__internal_ranges_replaced_data__(self, 0, old_length, utf16_length(@__node__.content))
@@ -102,7 +103,7 @@ module Dommy
102
103
  end
103
104
 
104
105
  def node_value=(value)
105
- write_data(value)
106
+ self.data = value
106
107
  end
107
108
 
108
109
  def text_content
@@ -110,9 +111,16 @@ module Dommy
110
111
  end
111
112
 
112
113
  def text_content=(value)
113
- write_data(value)
114
+ self.data = value
115
+ end
116
+
117
+ # Node.isConnected: whether the shadow-including root is a Document.
118
+ def is_connected?
119
+ Internal::NodeTraversal.connected?(@__node__, @document)
114
120
  end
115
121
 
122
+ alias connected? is_connected?
123
+
116
124
  def remove
117
125
  @document.remove_node_with_notify(@__node__)
118
126
  nil
@@ -178,29 +186,13 @@ module Dommy
178
186
  def substring_data(offset, count)
179
187
  s = @__node__.content
180
188
  len = utf16_length(s)
181
- o = to_uint32(offset)
189
+ o = Internal::WebIDL.unsigned_long(offset)
182
190
  raise DOMException::IndexSizeError, "offset out of bounds" if o > len
183
191
 
184
- c = [to_uint32(count), len - o].min
192
+ c = [Internal::WebIDL.unsigned_long(count), len - o].min
185
193
  utf16_slice(s, o, c)
186
194
  end
187
195
 
188
- # ECMAScript ToUint32 — WebIDL `unsigned long` conversion for a data offset
189
- # or count: ToNumber (a non-numeric string is NaN), truncate toward zero,
190
- # then take modulo 2**32 (so -1 wraps to 4294967295, 0x100000000+2 to 2).
191
- def to_uint32(value)
192
- num =
193
- case value
194
- when Integer then value
195
- when Numeric then value
196
- when nil then 0 # JS null -> 0
197
- else Float(value.to_s) rescue Float::NAN
198
- end
199
- return 0 unless num.respond_to?(:finite?) ? num.finite? : true
200
-
201
- num.to_i % (2**32)
202
- end
203
-
204
196
  def append_data(value)
205
197
  write_data(@__node__.content + dom_string(value))
206
198
  end
@@ -222,10 +214,10 @@ module Dommy
222
214
  def replace_data(offset, count, value)
223
215
  s = @__node__.content
224
216
  len = utf16_length(s)
225
- o = to_uint32(offset)
217
+ o = Internal::WebIDL.unsigned_long(offset)
226
218
  raise DOMException::IndexSizeError, "offset out of bounds" if o > len
227
219
 
228
- c = [to_uint32(count), len - o].min
220
+ c = [Internal::WebIDL.unsigned_long(count), len - o].min
229
221
  inserted = dom_string(value)
230
222
  write_data(utf16_slice(s, 0, o) + inserted + utf16_slice(s, o + c, len - (o + c)))
231
223
  # Live ranges whose boundary sits in (or past) the replaced run follow it.
@@ -256,6 +248,8 @@ module Dommy
256
248
  next_sibling
257
249
  when "previousSibling"
258
250
  previous_sibling
251
+ when "isConnected"
252
+ is_connected?
259
253
  when "childNodes"
260
254
  # CharacterData is a leaf node: childNodes is always an empty (but
261
255
  # present and iterable) NodeList, and firstChild/lastChild are null.
@@ -275,25 +269,13 @@ module Dommy
275
269
  # no `slot` attribute, so it targets the default (unnamed) slot of its parent
276
270
  # element's shadow tree; a closed shadow tree hides the assignment (null).
277
271
  def assigned_slot
278
- parent = @__node__.parent
279
- return nil unless parent&.element?
280
-
281
- host = @document.wrap_node(parent)
282
- return nil unless host.respond_to?(:shadow_root)
283
-
284
- sr = host.shadow_root
285
- return nil unless sr
286
- return nil if sr.__js_get__("mode") == "closed"
287
-
288
- sr.query_selector_all("slot").find do |slot|
289
- (slot.respond_to?(:name) ? slot.name.to_s : "") == ""
290
- end
272
+ Internal::SlotAssignment.find_slot(@document, @__node__, open: true)
291
273
  end
292
274
 
293
275
  def __js_set__(key, value)
294
276
  case key
295
277
  when "textContent", "data", "nodeValue"
296
- write_data(value)
278
+ self.data = value
297
279
  end
298
280
 
299
281
  nil
@@ -312,8 +294,7 @@ module Dommy
312
294
  false
313
295
  when "contains"
314
296
  # A leaf node contains only itself (no descendants).
315
- args[0].respond_to?(:__dommy_backend_node__) &&
316
- args[0].__dommy_backend_node__ == @__node__
297
+ args[0].is_a?(Node) && args[0].__dommy_backend_node__ == @__node__
317
298
  when "appendChild"
318
299
  append_child(args[0])
319
300
  when "insertBefore"