dommy 0.15.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 (171) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/backend.rb +16 -15
  3. data/lib/dommy/blob.rb +17 -0
  4. data/lib/dommy/bridge/values.rb +29 -2
  5. data/lib/dommy/bridge/wire_tags.rb +8 -4
  6. data/lib/dommy/browser.rb +324 -49
  7. data/lib/dommy/character_data.rb +20 -27
  8. data/lib/dommy/class_list.rb +24 -9
  9. data/lib/dommy/cookie_jar.rb +408 -0
  10. data/lib/dommy/cookie_store.rb +104 -49
  11. data/lib/dommy/css.rb +34 -8
  12. data/lib/dommy/custom_elements.rb +362 -114
  13. data/lib/dommy/data_transfer.rb +22 -2
  14. data/lib/dommy/document.rb +1071 -200
  15. data/lib/dommy/dom_exception.rb +6 -0
  16. data/lib/dommy/dom_parser.rb +46 -14
  17. data/lib/dommy/dom_string_list.rb +36 -0
  18. data/lib/dommy/element.rb +217 -102
  19. data/lib/dommy/element_internals.rb +215 -0
  20. data/lib/dommy/encodings.rb +10 -0
  21. data/lib/dommy/event.rb +378 -122
  22. data/lib/dommy/event_source.rb +4 -30
  23. data/lib/dommy/file_reader.rb +5 -29
  24. data/lib/dommy/form_data.rb +40 -6
  25. data/lib/dommy/form_entry_list.rb +106 -137
  26. data/lib/dommy/fragment.rb +11 -10
  27. data/lib/dommy/history.rb +247 -90
  28. data/lib/dommy/html_canvas_element.rb +15 -35
  29. data/lib/dommy/html_collection.rb +49 -30
  30. data/lib/dommy/html_elements/base.rb +111 -69
  31. data/lib/dommy/html_elements/embedded.rb +349 -105
  32. data/lib/dommy/html_elements/forms/form.rb +407 -368
  33. data/lib/dommy/html_elements/forms/input.rb +333 -174
  34. data/lib/dommy/html_elements/forms/select.rb +90 -74
  35. data/lib/dommy/html_elements/interactive.rb +274 -64
  36. data/lib/dommy/html_elements/media.rb +71 -30
  37. data/lib/dommy/html_elements/metadata.rb +215 -71
  38. data/lib/dommy/html_elements/miscellaneous.rb +58 -14
  39. data/lib/dommy/html_elements/tables.rb +7 -9
  40. data/lib/dommy/html_elements.rb +23 -1
  41. data/lib/dommy/hyperlink_utils.rb +15 -4
  42. data/lib/dommy/interaction/drag_and_drop.rb +187 -0
  43. data/lib/dommy/interaction/event_synthesis.rb +182 -24
  44. data/lib/dommy/interaction/field_interactor.rb +17 -5
  45. data/lib/dommy/interaction/form_submission.rb +10 -4
  46. data/lib/dommy/interaction/key_sender.rb +101 -16
  47. data/lib/dommy/interaction.rb +1 -0
  48. data/lib/dommy/internal/accessibility_tree.rb +15 -3
  49. data/lib/dommy/internal/accessible_description.rb +1 -1
  50. data/lib/dommy/internal/accessible_name.rb +10 -17
  51. data/lib/dommy/internal/aria_role.rb +1 -1
  52. data/lib/dommy/internal/autofill.rb +2 -2
  53. data/lib/dommy/internal/backend_prefilter.rb +1 -5
  54. data/lib/dommy/internal/ce_reactions.rb +285 -0
  55. data/lib/dommy/internal/ce_reactions_table.rb +296 -0
  56. data/lib/dommy/internal/child_list.rb +7 -43
  57. data/lib/dommy/internal/child_navigable.rb +155 -0
  58. data/lib/dommy/internal/child_node.rb +44 -41
  59. data/lib/dommy/internal/close_watcher_manager.rb +238 -0
  60. data/lib/dommy/internal/complex_match.rb +3 -1
  61. data/lib/dommy/internal/constraint_validation.rb +130 -0
  62. data/lib/dommy/internal/css/ancestor_filter.rb +107 -0
  63. data/lib/dommy/internal/css/cascade.rb +7 -7
  64. data/lib/dommy/internal/css/cascaded_declarations.rb +4 -1
  65. data/lib/dommy/internal/css/computed_style_builder.rb +9 -3
  66. data/lib/dommy/internal/css/custom_properties.rb +41 -29
  67. data/lib/dommy/internal/css/parser.rb +0 -22
  68. data/lib/dommy/internal/css/renderability.rb +6 -0
  69. data/lib/dommy/internal/css/rule_index.rb +149 -98
  70. data/lib/dommy/internal/css/rule_index_inputs.rb +135 -0
  71. data/lib/dommy/internal/css/selector_dependencies.rb +8 -2
  72. data/lib/dommy/internal/css/style_cache.rb +7 -4
  73. data/lib/dommy/internal/css/ua_stylesheet.rb +34 -0
  74. data/lib/dommy/internal/declarative_shadow_roots.rb +182 -0
  75. data/lib/dommy/internal/document_generations.rb +55 -2
  76. data/lib/dommy/internal/document_interaction_state.rb +293 -9
  77. data/lib/dommy/internal/dom_matching.rb +2 -2
  78. data/lib/dommy/internal/element_aria.rb +25 -19
  79. data/lib/dommy/internal/element_popover.rb +226 -25
  80. data/lib/dommy/internal/element_shadow.rb +97 -26
  81. data/lib/dommy/internal/element_state.rb +98 -5
  82. data/lib/dommy/internal/element_top_layer.rb +15 -1
  83. data/lib/dommy/internal/enumerated_keyword_sets.rb +40 -0
  84. data/lib/dommy/internal/event_handler_tables.rb +241 -0
  85. data/lib/dommy/internal/event_handlers.rb +149 -0
  86. data/lib/dommy/internal/exception_report.rb +1 -1
  87. data/lib/dommy/internal/focusability.rb +267 -0
  88. data/lib/dommy/internal/form_associated_custom_elements.rb +127 -0
  89. data/lib/dommy/internal/html_or_svg_or_mathml_element.rb +34 -23
  90. data/lib/dommy/internal/html_serialization.rb +292 -0
  91. data/lib/dommy/internal/idl_reflection.rb +202 -0
  92. data/lib/dommy/internal/idl_reflection_table.rb +589 -0
  93. data/lib/dommy/internal/infra.rb +9 -0
  94. data/lib/dommy/internal/input_type.rb +76 -18
  95. data/lib/dommy/internal/insertion_point.rb +27 -3
  96. data/lib/dommy/internal/js_number.rb +60 -0
  97. data/lib/dommy/internal/light_dismiss.rb +178 -0
  98. data/lib/dommy/internal/mime_type.rb +8 -0
  99. data/lib/dommy/internal/mutation_coordinator.rb +94 -62
  100. data/lib/dommy/internal/node_adopter.rb +52 -4
  101. data/lib/dommy/internal/node_factory.rb +111 -7
  102. data/lib/dommy/internal/node_identity.rb +1 -1
  103. data/lib/dommy/internal/node_traversal.rb +22 -4
  104. data/lib/dommy/internal/node_wrapper_cache.rb +6 -38
  105. data/lib/dommy/internal/observer_options.rb +24 -14
  106. data/lib/dommy/internal/origin.rb +86 -0
  107. data/lib/dommy/internal/parent_node.rb +10 -8
  108. data/lib/dommy/internal/popover_invoker_element.rb +67 -0
  109. data/lib/dommy/internal/popover_stack.rb +120 -0
  110. data/lib/dommy/internal/post_insertion_steps.rb +140 -71
  111. data/lib/dommy/internal/reflected_attributes.rb +128 -18
  112. data/lib/dommy/internal/rendered_text/collector.rb +1 -5
  113. data/lib/dommy/internal/rendered_text/fragment.rb +6 -2
  114. data/lib/dommy/internal/selector_arguments.rb +2 -1
  115. data/lib/dommy/internal/selector_match.rb +1 -1
  116. data/lib/dommy/internal/selector_matcher.rb +11 -6
  117. data/lib/dommy/internal/selector_parser.rb +14 -1
  118. data/lib/dommy/internal/sequential_focus_navigation.rb +210 -0
  119. data/lib/dommy/internal/shadow_root_registry.rb +7 -0
  120. data/lib/dommy/internal/template_content_registry.rb +7 -1
  121. data/lib/dommy/internal/text_selection.rb +185 -32
  122. data/lib/dommy/internal/toggle_events.rb +6 -4
  123. data/lib/dommy/internal/unsafe_html.rb +107 -0
  124. data/lib/dommy/internal/url_parser.rb +84 -8
  125. data/lib/dommy/internal/user_activation.rb +192 -0
  126. data/lib/dommy/internal/web_idl.rb +149 -16
  127. data/lib/dommy/internal/window_constructors.rb +26 -6
  128. data/lib/dommy/internal/xml_serialization.rb +3 -3
  129. data/lib/dommy/js/constructor_resolver.rb +18 -0
  130. data/lib/dommy/js/custom_element_bridge.rb +57 -82
  131. data/lib/dommy/js/dom_interfaces.rb +50 -15
  132. data/lib/dommy/js/host_bridge.rb +189 -40
  133. data/lib/dommy/js/host_runtime.js +1897 -415
  134. data/lib/dommy/js/marshaller.rb +13 -4
  135. data/lib/dommy/js/module_loader.rb +37 -6
  136. data/lib/dommy/js/module_preload.rb +107 -29
  137. data/lib/dommy/js/script_boot.rb +197 -115
  138. data/lib/dommy/js/serialized_record.rb +32 -0
  139. data/lib/dommy/js/webidl_event_handlers.js +45 -0
  140. data/lib/dommy/js/webidl_members.js +196 -0
  141. data/lib/dommy/js/webidl_signatures.js +2055 -0
  142. data/lib/dommy/js/webidl_tables.js +47 -100
  143. data/lib/dommy/js/window_wiring.rb +9 -5
  144. data/lib/dommy/location.rb +129 -76
  145. data/lib/dommy/media_query_list.rb +4 -13
  146. data/lib/dommy/message_channel.rb +137 -64
  147. data/lib/dommy/mutation_observer.rb +2 -0
  148. data/lib/dommy/navigation.rb +59 -9
  149. data/lib/dommy/navigator.rb +131 -5
  150. data/lib/dommy/node.rb +19 -11
  151. data/lib/dommy/notification.rb +9 -1
  152. data/lib/dommy/promise.rb +8 -0
  153. data/lib/dommy/range.rb +5 -34
  154. data/lib/dommy/resources.rb +38 -3
  155. data/lib/dommy/scheduler.rb +176 -48
  156. data/lib/dommy/shadow_root.rb +117 -19
  157. data/lib/dommy/storage.rb +135 -25
  158. data/lib/dommy/structured_data.rb +45 -0
  159. data/lib/dommy/style_declaration.rb +16 -0
  160. data/lib/dommy/text_codec.rb +2 -14
  161. data/lib/dommy/tree_walker.rb +10 -18
  162. data/lib/dommy/url.rb +11 -4
  163. data/lib/dommy/validity_state.rb +109 -107
  164. data/lib/dommy/version.rb +1 -1
  165. data/lib/dommy/web_socket.rb +5 -28
  166. data/lib/dommy/window.rb +811 -99
  167. data/lib/dommy/worker.rb +4 -28
  168. data/lib/dommy/xml_http_request.rb +32 -45
  169. data/lib/dommy.rb +35 -1
  170. metadata +35 -4
  171. data/lib/dommy/internal/cookie_jar.rb +0 -29
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
@@ -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
 
@@ -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=
@@ -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"
@@ -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.
@@ -293,7 +287,7 @@ module Dommy
293
287
  def __js_set__(key, value)
294
288
  case key
295
289
  when "textContent", "data", "nodeValue"
296
- write_data(value)
290
+ self.data = value
297
291
  end
298
292
 
299
293
  nil
@@ -312,8 +306,7 @@ module Dommy
312
306
  false
313
307
  when "contains"
314
308
  # A leaf node contains only itself (no descendants).
315
- args[0].respond_to?(:__dommy_backend_node__) &&
316
- args[0].__dommy_backend_node__ == @__node__
309
+ args[0].is_a?(Node) && args[0].__dommy_backend_node__ == @__node__
317
310
  when "appendChild"
318
311
  append_child(args[0])
319
312
  when "insertBefore"