dommy 0.9.0 → 0.10.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/attr.rb +46 -8
  3. data/lib/dommy/backend/makiri_adapter.rb +34 -0
  4. data/lib/dommy/backend.rb +29 -0
  5. data/lib/dommy/blob.rb +48 -6
  6. data/lib/dommy/browser.rb +239 -36
  7. data/lib/dommy/callable_invoker.rb +6 -2
  8. data/lib/dommy/document.rb +525 -57
  9. data/lib/dommy/element.rb +479 -239
  10. data/lib/dommy/event.rb +296 -15
  11. data/lib/dommy/fetch.rb +431 -25
  12. data/lib/dommy/history.rb +24 -3
  13. data/lib/dommy/html_collection.rb +145 -21
  14. data/lib/dommy/html_elements.rb +1675 -266
  15. data/lib/dommy/interaction/driver.rb +155 -14
  16. data/lib/dommy/interaction/event_synthesis.rb +115 -7
  17. data/lib/dommy/interaction/field_interactor.rb +60 -0
  18. data/lib/dommy/internal/child_node.rb +199 -0
  19. data/lib/dommy/internal/css/cascade.rb +44 -2
  20. data/lib/dommy/internal/global_functions.rb +23 -10
  21. data/lib/dommy/internal/mutation_coordinator.rb +27 -7
  22. data/lib/dommy/internal/node_wrapper_cache.rb +56 -14
  23. data/lib/dommy/internal/observer_manager.rb +6 -0
  24. data/lib/dommy/internal/observer_matcher.rb +6 -8
  25. data/lib/dommy/internal/parent_node.rb +37 -73
  26. data/lib/dommy/internal/selector_matcher.rb +89 -0
  27. data/lib/dommy/internal/selector_parser.rb +44 -7
  28. data/lib/dommy/js/custom_element_bridge.rb +13 -2
  29. data/lib/dommy/js/dom_interfaces.rb +19 -4
  30. data/lib/dommy/js/host_bridge.rb +21 -0
  31. data/lib/dommy/js/host_runtime.js +926 -64
  32. data/lib/dommy/js/script_boot.rb +80 -0
  33. data/lib/dommy/location.rb +76 -13
  34. data/lib/dommy/mutation_observer.rb +3 -4
  35. data/lib/dommy/navigation.rb +263 -0
  36. data/lib/dommy/node.rb +180 -27
  37. data/lib/dommy/range.rb +15 -3
  38. data/lib/dommy/scheduler.rb +16 -0
  39. data/lib/dommy/shadow_root.rb +33 -0
  40. data/lib/dommy/storage.rb +61 -10
  41. data/lib/dommy/tree_walker.rb +18 -36
  42. data/lib/dommy/url.rb +4 -2
  43. data/lib/dommy/version.rb +1 -1
  44. data/lib/dommy/web_socket.rb +45 -2
  45. data/lib/dommy/window.rb +126 -7
  46. data/lib/dommy/xml_http_request.rb +30 -4
  47. data/lib/dommy.rb +2 -0
  48. metadata +6 -10
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ab840b2c863131b5f20d7be40f28028bec227fb490688b4b0f26157058c0ea68
4
- data.tar.gz: a87acf4824167a08eae696de0ffe504cdf808d9085176e084b3e4f3d225e5c7a
3
+ metadata.gz: 52933caebd00a8d1b7dcfa14421116be45617e04d53ee6f8d8771c5fc5ccefdc
4
+ data.tar.gz: da2372f52dac931a6e7ad4316ec4cb15efb0effa799229eae7bde1435b8aa3c8
5
5
  SHA512:
6
- metadata.gz: db29082c6b8b686eb762aa53f3ae213b40da69210732c99a53f15a1f18318a326fd6bcf4c65fcfa4abc27dc41bebff0b16164682b7d18e7fed7a44415a9bb7e6
7
- data.tar.gz: 71669401a09832106ec301e0b2d4747fb7782b2ac2514fbf4cfbf625358695f0c498561cbd3efb565b7b2abbaf43b4b2068dbe5f1831523a74ec25dc2c2e1435
6
+ metadata.gz: e05358f558f5e84fe6da7e7f705dbdc5bdd178646ef71a3ee9311a9d05893320cda1c0e6ca3596aa05e7e389e9112e96c8d44b640b7dea55480066af40e65eb7
7
+ data.tar.gz: ac0dea6fecd2fe7624f71cdef6f9eab786be60a584a4307d0fcac66540002de7cc1cc52812a638bfc8bb35afb53dd836ed7ebb2b007fee8fabfd172e62e8850a
data/lib/dommy/attr.rb CHANGED
@@ -22,9 +22,12 @@ module Dommy
22
22
  nil
23
23
  end
24
24
 
25
- def initialize(name, owner: nil, value: "", namespace_uri: nil, prefix: nil, local_name: nil)
25
+ def initialize(name, owner: nil, value: "", namespace_uri: nil, prefix: nil, local_name: nil, document: nil)
26
26
  qname = name.to_s
27
27
  @owner = owner
28
+ # The node document (for baseURI/ownerDocument when detached from an
29
+ # element). Owned attrs derive it from their owner instead.
30
+ @document = document
28
31
  @detached_value = value.to_s
29
32
  if namespace_uri && !namespace_uri.to_s.empty?
30
33
  # Namespaced attributes preserve case and carry prefix / localName.
@@ -50,6 +53,22 @@ module Dommy
50
53
  @owner
51
54
  end
52
55
 
56
+ # Node.baseURI — the node document's base URL. Derived from the owner
57
+ # element when attached, else the document the attr was created in.
58
+ def base_uri
59
+ return @owner.base_uri if @owner.respond_to?(:base_uri)
60
+
61
+ @document&.base_uri
62
+ end
63
+
64
+ # Node.ownerDocument — the owner element's current document when attached (so
65
+ # it follows the element across adoptNode), else the creation document.
66
+ def owner_document
67
+ return @owner.document if @owner.respond_to?(:document)
68
+
69
+ @document
70
+ end
71
+
53
72
  def value
54
73
  if @owner
55
74
  if @namespace_uri
@@ -89,6 +108,8 @@ module Dommy
89
108
  value
90
109
  when "ownerElement"
91
110
  @owner
111
+ when "ownerDocument"
112
+ owner_document
92
113
  when "localName"
93
114
  @local_name
94
115
  when "namespaceURI"
@@ -97,6 +118,8 @@ module Dommy
97
118
  @prefix
98
119
  when "nodeType"
99
120
  2
121
+ when "baseURI"
122
+ base_uri
100
123
  when "specified"
101
124
  # Legacy/useless attribute — always true (WHATWG DOM).
102
125
  true
@@ -119,12 +142,20 @@ module Dommy
119
142
  include Bridge::Methods
120
143
  js_methods %w[cloneNode isSameNode getRootNode hasChildNodes normalize compareDocumentPosition
121
144
  appendChild insertBefore removeChild replaceChild
145
+ lookupNamespaceURI lookupPrefix isDefaultNamespace
122
146
  addEventListener removeEventListener dispatchEvent]
123
147
  def __js_call__(method, args)
124
148
  case method
149
+ when "lookupNamespaceURI"
150
+ lookup_namespace_uri(args[0])
151
+ when "lookupPrefix"
152
+ lookup_prefix(args[0])
153
+ when "isDefaultNamespace"
154
+ is_default_namespace(args[0])
125
155
  when "cloneNode"
126
156
  Attr.new(@name, owner: nil, value: value,
127
- namespace_uri: @namespace_uri, prefix: @prefix, local_name: @local_name)
157
+ namespace_uri: @namespace_uri, prefix: @prefix, local_name: @local_name,
158
+ document: @document || (@owner.respond_to?(:document) ? @owner.document : nil))
128
159
  when "isSameNode"
129
160
  is_same_node(args[0])
130
161
  when "getRootNode"
@@ -209,7 +240,9 @@ module Dommy
209
240
  end
210
241
 
211
242
  def get_named_item(name)
212
- key = name.to_s.downcase
243
+ # getNamedItem / getAttribute lowercase the qualified name only for an HTML
244
+ # element in an HTML document; other elements match case-sensitively.
245
+ key = @element.__internal_normalize_attr_key__(name)
213
246
  node = Backend.attribute_nodes(@element.__dommy_backend_node__).find do |a|
214
247
  Backend.attribute_ns_info(a)[:qualified_name] == key
215
248
  end
@@ -221,7 +254,7 @@ module Dommy
221
254
  end
222
255
 
223
256
  def remove_named_item(name)
224
- key = name.to_s.downcase
257
+ key = @element.__internal_normalize_attr_key__(name)
225
258
  node = Backend.attribute_nodes(@element.__dommy_backend_node__).find do |a|
226
259
  Backend.attribute_ns_info(a)[:qualified_name] == key
227
260
  end
@@ -329,12 +362,17 @@ module Dommy
329
362
  end
330
363
  end
331
364
 
332
- # WebIDL "supported property names" for NamedNodeMap: the qualified name of
333
- # each attribute, in order (the indexed names are reflected separately).
365
+ # WebIDL "supported property names" for NamedNodeMap: each attribute's
366
+ # qualified name, in order with duplicates omitted (the indexed names are
367
+ # reflected separately). For an HTML element in an HTML document, names
368
+ # containing an ASCII upper alpha are excluded (they can't be reached by the
369
+ # case-insensitive named getter).
334
370
  def __js_named_props__
335
- Backend.attribute_nodes(@element.__dommy_backend_node__).map do |a|
371
+ names = Backend.attribute_nodes(@element.__dommy_backend_node__).map do |a|
336
372
  Backend.attribute_ns_info(a)[:qualified_name]
337
- end
373
+ end.uniq
374
+ names.reject! { |n| n.match?(/[A-Z]/) } unless @element.__internal_case_sensitive_attribute_names__?
375
+ names
338
376
  end
339
377
 
340
378
  include Bridge::Methods
@@ -21,6 +21,7 @@ module Dommy
21
21
  CDATASection = ::Makiri::CDATASection
22
22
  ProcessingInstruction = ::Makiri::ProcessingInstruction
23
23
  DocumentFragment = ::Makiri::DocumentFragment
24
+ DocumentType = ::Makiri::DocumentType
24
25
  Node = ::Makiri::Node
25
26
 
26
27
  # A minimal namespace wrapper exposing the same `href` API that Nokogiri's
@@ -168,6 +169,39 @@ module Dommy
168
169
  doc.create_element(name)
169
170
  end
170
171
 
172
+ # An XML-backed document rejects a qualified name that DOM allows (e.g.
173
+ # "f}oo" — an invalid char in the local part), so createElementNS uses
174
+ # Makiri's loose creator, which builds it verbatim (case/prefix preserved).
175
+ # nil for a non-XML backend → the caller uses the strict #create_element.
176
+ def create_element_loose(qualified_name, prefix, local, namespace, doc)
177
+ return nil unless doc.is_a?(::Makiri::XML::Document) && doc.respond_to?(:create_loose_dom_element)
178
+
179
+ doc.create_loose_dom_element(qualified_name, prefix, local, namespace)
180
+ end
181
+
182
+ # A detached DocumentType node owned by `doc`, for
183
+ # DOMImplementation.createDocumentType. Only the HTML backend ships the
184
+ # factory (`create_document_type`); nil signals the caller to fall back to a
185
+ # synthetic (non-tree) DocumentType. Raises ArgumentError for a name the
186
+ # factory rejects.
187
+ def create_document_type(name, public_id, system_id, doc)
188
+ return nil unless doc.respond_to?(:create_document_type)
189
+
190
+ doc.create_document_type(name.to_s, public_id.to_s, system_id.to_s)
191
+ end
192
+
193
+ # The parsed document's DocumentType node (`<!DOCTYPE …>`), or nil when the
194
+ # document declares none.
195
+ def internal_subset(doc)
196
+ doc.respond_to?(:internal_subset) ? doc.internal_subset : nil
197
+ end
198
+
199
+ # The backend class for a DocumentType node, so the wrapper routes it to
200
+ # Dommy::DocumentType (node-backed).
201
+ def document_type_class
202
+ ::Makiri::DocumentType
203
+ end
204
+
171
205
  def create_text(content, doc)
172
206
  doc.create_text_node(content)
173
207
  end
data/lib/dommy/backend.rb CHANGED
@@ -130,6 +130,35 @@ module Dommy
130
130
  current.create_element(name, doc)
131
131
  end
132
132
 
133
+ # Create a namespaced element permitting a DOM-valid qualified name that a
134
+ # strict XML backend would reject (an internal invalid char like "f}oo"),
135
+ # preserving case/prefix. Returns nil when the backend has no loose path
136
+ # (fall back to #create_element); raises ArgumentError for a genuinely
137
+ # invalid name (the caller maps it to InvalidCharacterError).
138
+ def create_element_loose(qualified_name, prefix, local, namespace, doc)
139
+ current.create_element_loose(qualified_name, prefix, local, namespace, doc)
140
+ end
141
+
142
+ # A detached DocumentType node owned by `doc` (for
143
+ # DOMImplementation.createDocumentType). Returns nil when the backend has no
144
+ # doctype factory (the caller falls back to a synthetic DocumentType); raises
145
+ # ArgumentError for a name the factory rejects (the caller then also falls
146
+ # back, since createDocumentType is permissive).
147
+ def create_document_type(name, public_id, system_id, doc)
148
+ current.respond_to?(:create_document_type) ? current.create_document_type(name, public_id, system_id, doc) : nil
149
+ end
150
+
151
+ # The parsed document's DocumentType node, or nil when it declares none.
152
+ def internal_subset(doc)
153
+ current.respond_to?(:internal_subset) ? current.internal_subset(doc) : nil
154
+ end
155
+
156
+ # The backend class for a DocumentType node (nil if unsupported), so the
157
+ # wrapper cache can route it to Dommy::DocumentType.
158
+ def document_type_class
159
+ current.respond_to?(:document_type_class) ? current.document_type_class : nil
160
+ end
161
+
133
162
  def create_text(content, doc)
134
163
  current.create_text(content, doc)
135
164
  end
data/lib/dommy/blob.rb CHANGED
@@ -21,19 +21,26 @@ module Dommy
21
21
  # Promises (they need a scheduler). A window-less Blob falls back to a
22
22
  # synchronous result, which `await` still handles.
23
23
  def initialize(parts = [], options = {}, window = nil)
24
+ # WebIDL: an omitted / `undefined` blobParts argument defaults to an empty
25
+ # sequence (`new Blob()` / `new Blob(undefined)` is a zero-length Blob), so
26
+ # it must not be coerced to the string "undefined".
27
+ parts = [] if parts.nil? || (defined?(Bridge::UNDEFINED) && parts.equal?(Bridge::UNDEFINED))
24
28
  parts = [parts] unless parts.is_a?(Array)
25
29
  @data = collect_bytes(parts)
26
30
  @size = @data.bytesize
27
- raw_type = options["type"] || options[:type] || ""
28
- @type = raw_type.to_s.downcase
31
+ raw_type = (options["type"] || options[:type] || "").to_s
32
+ # A type string with any code point outside U+0020..U+007E is discarded
33
+ # (→ ""); otherwise it is ASCII-lowercased (FileAPI "parse a MIME type"
34
+ # gate, applied to both the constructor and slice's contentType).
35
+ @type = raw_type.match?(/[^ -~]/) ? "" : raw_type.downcase
29
36
  @window = window
30
37
  end
31
38
 
32
39
  # Return a new Blob over a byte range of this one.
33
40
  # Negative indices are treated as offsets from the end (per spec).
34
41
  def slice(start = 0, last = @size, content_type = "")
35
- s = clamp_index(start.to_i, @size)
36
- e = clamp_index(last.to_i, @size)
42
+ s = clamp_index(clamp_long_long(start), @size)
43
+ e = clamp_index(clamp_long_long(last), @size)
37
44
  e = s if e < s
38
45
  Blob.new([@data.byteslice(s, e - s) || ""], {"type" => content_type.to_s}, @window)
39
46
  end
@@ -51,6 +58,13 @@ module Dommy
51
58
  Bridge::ArrayBuffer.new(@data.bytes)
52
59
  end
53
60
 
61
+ # Read the bytes as a Uint8Array (the spec return type). The DOM spec returns
62
+ # a Promise<Uint8Array>; Dommy is synchronous. `Bridge::Bytes` crosses the JS
63
+ # boundary as a Uint8Array (vs `ArrayBuffer` for #array_buffer).
64
+ def bytes
65
+ Bridge::Bytes.new(@data.bytes)
66
+ end
67
+
54
68
  # Raw binary bytes (Ruby ASCII-8BIT string). Used by FormData /
55
69
  # fetch when serializing multipart bodies.
56
70
  def __dommy_bytes__
@@ -71,17 +85,30 @@ module Dommy
71
85
  # Methods routed through __js_call__ (keep in sync with its when-arms).
72
86
  # File < Blob inherits these (it adds only properties).
73
87
  include Bridge::Methods
74
- js_methods %w[slice text arrayBuffer]
88
+ js_methods %w[slice text arrayBuffer bytes]
75
89
  def __js_call__(method, args)
76
90
  case method
77
91
  when "slice"
78
- slice(args[0] || 0, args[1] || @size, args[2] || "")
92
+ # An omitted / `undefined` start|end uses the default (0 / size); map
93
+ # UNDEFINED to nil so the `|| default` fallbacks apply (a bare UNDEFINED
94
+ # is truthy and has no #to_i). contentType is a plain DOMString: omitted
95
+ # / undefined → "" (default), but an explicit JS null coerces to "null".
96
+ a = args.map { |v| v.equal?(Bridge::UNDEFINED) ? nil : v }
97
+ ctype = if args.length < 3 || args[2].equal?(Bridge::UNDEFINED)
98
+ ""
99
+ else
100
+ args[2].nil? ? "null" : args[2].to_s
101
+ end
102
+ slice(a[0] || 0, a[1] || @size, ctype)
79
103
  when "text"
80
104
  # WHATWG: Blob.text() returns a Promise<string>.
81
105
  promise_or_value(text)
82
106
  when "arrayBuffer"
83
107
  # WHATWG: Blob.arrayBuffer() returns a Promise<ArrayBuffer>.
84
108
  promise_or_value(array_buffer)
109
+ when "bytes"
110
+ # WHATWG: Blob.bytes() returns a Promise<Uint8Array>.
111
+ promise_or_value(bytes)
85
112
  end
86
113
  end
87
114
 
@@ -115,6 +142,21 @@ module Dommy
115
142
  idx = length + idx if idx.negative?
116
143
  idx.clamp(0, length)
117
144
  end
145
+
146
+ # WebIDL `[Clamp] long long` conversion of a slice bound: a fractional value
147
+ # rounds to the nearest integer, ties to even (banker's rounding) — so
148
+ # `slice(1.5)` starts at 2 and `slice(3.5)` at 4, per the [Clamp] extended
149
+ # attribute on Blob.slice.
150
+ def clamp_long_long(value)
151
+ return value if value.is_a?(Integer)
152
+
153
+ f = value.to_f
154
+ return 0 if f.nan?
155
+
156
+ f.round(half: :even)
157
+ rescue StandardError
158
+ 0
159
+ end
118
160
  end
119
161
 
120
162
  # `File` — Blob with a filename and an optional last-modified
data/lib/dommy/browser.rb CHANGED
@@ -56,47 +56,45 @@ module Dommy
56
56
  end
57
57
  end
58
58
 
59
+ # Start a navigable session by fetching the initial document from
60
+ # `resources`, rather than passing literal HTML. Links / forms / location
61
+ # then perform real cross-document navigation (fetch → replace Window + JS
62
+ # realm), with the browser handle (history, resources, error log) surviving.
63
+ # Dommy::Browser.visit("http://localhost/", resources: my_resources)
64
+ def self.visit(url, resources:, **opts)
65
+ browser = new("<!doctype html><html><head></head><body></body></html>",
66
+ url: "about:blank", resources: resources, navigable: true, **opts)
67
+ browser.visit(url, replace: true)
68
+ browser
69
+ end
70
+
59
71
  def initialize(html, url: "http://localhost/", resources: nil, execute_scripts: true, strict: true, settle: true,
60
- wasm_memory_shim: false, backend: nil)
72
+ wasm_memory_shim: false, backend: nil, navigable: false, same_origin: false)
61
73
  @resources = resources
74
+ @same_origin = same_origin
62
75
  @strict = strict
76
+ @backend = backend
77
+ @execute_scripts = execute_scripts
78
+ @settle_after_boot = settle
79
+ @wasm_memory_shim = wasm_memory_shim
80
+ @navigable = navigable
63
81
  @js_errors = []
64
82
  @console = []
65
83
  @acknowledged = 0
66
84
  @allow_errors = false
67
85
  @disposed = false
86
+ @pending_navigation = nil
87
+ @runtime = nil
68
88
 
69
89
  @window = Dommy.parse(html)
70
90
  @window.location.__internal_set_url__(url) if url
91
+ install_runtime(@window)
71
92
 
72
- # The JS engine is pluggable: `backend:` selects a registered runtime
73
- # (nil → the configured default, QuickJS when dommy-js-quickjs is loaded).
74
- @runtime = Js.build_runtime(backend)
75
- @runtime.on_unhandled_rejection { |err| @js_errors << err }
76
- @runtime.on_callback_error { |err| @js_errors << err } if @runtime.respond_to?(:on_callback_error)
77
- @runtime.on_log { |log| @console << log }
78
- @runtime.define_host_object("document", @window.document)
79
- @runtime.install_window(@window)
80
- @runtime.install_browser_globals
81
- # Opt-in WPT scaffolding (common/sab.js derives SharedArrayBuffer through
82
- # WebAssembly.Memory); off by default so real pages don't see the shim.
83
- @runtime.install_wasm_memory_shim if wasm_memory_shim && @runtime.respond_to?(:install_wasm_memory_shim)
84
- @window.globals["__fetch_handler__"] = Resources::FetchHandler.new(@resources) if @resources
85
-
86
- if execute_scripts
87
- doc = @window.document
88
- # Dynamically-inserted `<script src>` (webpack/Vite on-demand chunks)
89
- # fetch + run through the same resources adapter, after boot.
90
- doc.external_script_runner = lambda do |element, src|
91
- Js::ScriptBoot.run_external_script(@runtime, doc, element, src,
92
- resources: @resources, on_error: ->(e) { @js_errors << e })
93
- end
94
- Js::ScriptBoot.run_document_scripts(
95
- @runtime, doc, resources: @resources, on_error: ->(e) { @js_errors << e }
96
- )
97
- # Leave the page in a ready state: run on-load promises, due-now timers,
98
- # and rAF (not future timers). `settle: false` observes it mid-flight.
99
- @runtime.settle if settle
93
+ if navigable
94
+ @fetcher = Navigation::Fetcher.new(@resources, same_origin: @same_origin)
95
+ @history = Navigation::JointHistory.new
96
+ @window.navigation_delegate = self
97
+ @history.push(current_url, window: @window, windex: @window.history.__internal_index__)
100
98
  end
101
99
  check_js_errors!
102
100
  end
@@ -106,6 +104,70 @@ module Dommy
106
104
  # Current document HTML (serialized).
107
105
  def html = @window.document.document_element&.outer_html
108
106
 
107
+ # The current document's URL (the address bar).
108
+ def current_url = @window.location.href
109
+
110
+ # The joint (tab) history of a navigable browser, or nil for a plain
111
+ # single-document browser.
112
+ attr_reader :history
113
+
114
+ # Programmatically navigate to `url` (a Ruby-initiated visit). Only
115
+ # meaningful for a navigable browser; performs the fetch + document swap
116
+ # immediately (there is no JS on the stack).
117
+ def visit(url, replace: false)
118
+ raise "browser is not navigable (use Browser.visit or navigable: true)" unless @navigable
119
+
120
+ @pending_navigation = {url: url.to_s, method: "GET", source: :visit, replace: replace}
121
+ flush_navigation!
122
+ self
123
+ end
124
+
125
+ # Reload the current document (re-fetch, replace the current history entry).
126
+ def reload
127
+ visit(current_url, replace: true)
128
+ end
129
+
130
+ # Move back / forward one entry in the joint history. A same-document target
131
+ # (the entry's window is still live) traverses in place (popstate); a
132
+ # document-boundary target is re-fetched (no bfcache — D2).
133
+ def back = traverse(-1)
134
+ def forward = traverse(1)
135
+
136
+ # --- NavigationDelegate port (see Dommy::Navigation) ---
137
+
138
+ # A cross-document navigation intent (link / form / location). Navigation is
139
+ # a task: rather than swap the Window + JS realm synchronously (which may run
140
+ # while the outgoing realm's JS is still on the stack — e.g. `location.href =`
141
+ # inside a script), record it and perform the fetch + swap at the next drain
142
+ # boundary (settle / after_interaction / advance_time). Ruby-initiated visits
143
+ # flush immediately since no JS is on the stack.
144
+ def navigate(url:, source:, method: "GET", body: nil, params: nil, enctype: nil, headers: {}, replace: false)
145
+ @pending_navigation = {
146
+ url: url, method: method, body: body, params: params, enctype: enctype,
147
+ headers: headers, replace: replace, source: source
148
+ }
149
+ nil
150
+ end
151
+
152
+ # A cross-document history traversal. Ruby-initiated (back / forward), so it
153
+ # runs immediately: a same-document target (its window is still live)
154
+ # traverses in place (popstate); a document-boundary target is re-fetched.
155
+ def traverse(delta)
156
+ return self unless @navigable
157
+
158
+ entry = delta.negative? ? @history.back : @history.forward
159
+ return self unless entry
160
+
161
+ if entry.window && entry.window.equal?(@window)
162
+ @window.history.__internal_go_to__(entry.windex)
163
+ @runtime.drain_microtasks
164
+ check_js_errors!
165
+ else
166
+ perform_navigation!({url: entry.url, method: "GET", source: :traverse}, rebind: true)
167
+ end
168
+ self
169
+ end
170
+
109
171
  # Evaluate an expression / statement body and return the decoded value.
110
172
  def evaluate(js)
111
173
  result = @runtime.evaluate(js)
@@ -125,6 +187,7 @@ module Dommy
125
187
  # `setTimeout(300)` — use `advance_time(300)` for debounce/throttle.
126
188
  def settle
127
189
  @runtime.settle
190
+ flush_navigation!
128
191
  check_js_errors!
129
192
  self
130
193
  end
@@ -133,6 +196,7 @@ module Dommy
133
196
  def advance_time(ms)
134
197
  @window.scheduler.advance_time(ms)
135
198
  @runtime.drain_microtasks
199
+ flush_navigation!
136
200
  check_js_errors!
137
201
  self
138
202
  end
@@ -142,25 +206,29 @@ module Dommy
142
206
  # land before the next line, then enforce strict mode.
143
207
  def after_interaction
144
208
  @runtime.drain_microtasks
209
+ flush_navigation!
145
210
  check_js_errors!
146
211
  end
147
212
 
148
213
  # Click a submit-capable button. The button's click event fires (JS may
149
214
  # handle / preventDefault it); if it is an un-prevented submit button, the
150
- # form's `submit` event is dispatched too (a SPA's JS handles it). Real
151
- # navigation on an un-prevented submit is a Session concern (out of scope).
215
+ # owning form's submission algorithm runs (a real SubmitEvent a SPA can
216
+ # intercept, then the delegate navigation). In a navigable browser that
217
+ # follows the submit for real; otherwise the delegate just records it.
152
218
  def click_button(locator)
153
219
  button = finder.find_button(locator)
154
- prevented = Dommy::Interaction::EventSynthesis.click(button)
155
- if !prevented && submit_button?(button) && (form = finder.form_for(button))
156
- form.dispatch_event(Dommy::Event.new("submit", "bubbles" => true, "cancelable" => true))
157
- end
220
+ # An un-prevented click runs the button's activation behavior — a submit
221
+ # button submits its owning form (real SubmitEvent + delegate navigation),
222
+ # so a navigable browser follows the submit for real.
223
+ Dommy::Interaction::EventSynthesis.click(button)
158
224
  after_interaction
159
225
  button
160
226
  end
161
227
 
162
228
  # Click a link, firing its click event so SPA JS (Turbo, React Router, …)
163
- # can intercept. Real navigation on an un-prevented click is out of scope.
229
+ # can intercept. An un-prevented click runs the anchor's activation behavior
230
+ # (follow-the-hyperlink); in a navigable browser that navigates for real,
231
+ # otherwise the delegate records it.
164
232
  def click_link(locator)
165
233
  link = finder.find_link(locator)
166
234
  Dommy::Interaction::EventSynthesis.click(link)
@@ -190,6 +258,141 @@ module Dommy
190
258
 
191
259
  private
192
260
 
261
+ # Build a fresh JS realm for `window`, wire error/console/fetch/external-
262
+ # script seams, and boot its `<script>` tags. Disposes the previous realm
263
+ # first (a no-op on the initial load), so a navigation tears the outgoing
264
+ # realm — and with it every pending timer / microtask on the old Window's
265
+ # scheduler — down before the new page runs.
266
+ def install_runtime(window)
267
+ @runtime&.dispose
268
+ # The JS engine is pluggable: `@backend` selects a registered runtime
269
+ # (nil → the configured default, QuickJS when dommy-js-quickjs is loaded).
270
+ runtime = Js.build_runtime(@backend)
271
+ runtime.on_unhandled_rejection { |err| @js_errors << err }
272
+ runtime.on_callback_error { |err| @js_errors << err } if runtime.respond_to?(:on_callback_error)
273
+ runtime.on_log { |log| @console << log }
274
+ runtime.define_host_object("document", window.document)
275
+ runtime.install_window(window)
276
+ runtime.install_browser_globals
277
+ # Opt-in WPT scaffolding (common/sab.js derives SharedArrayBuffer through
278
+ # WebAssembly.Memory); off by default so real pages don't see the shim.
279
+ runtime.install_wasm_memory_shim if @wasm_memory_shim && runtime.respond_to?(:install_wasm_memory_shim)
280
+ window.globals["__fetch_handler__"] = Resources::FetchHandler.new(@resources) if @resources
281
+ @runtime = runtime
282
+ return unless @execute_scripts
283
+
284
+ doc = window.document
285
+ # Dynamically-inserted `<script src>` (webpack/Vite on-demand chunks)
286
+ # fetch + run through the same resources adapter, after boot.
287
+ doc.external_script_runner = lambda do |element, src|
288
+ Js::ScriptBoot.run_external_script(runtime, doc, element, src,
289
+ resources: @resources, on_error: ->(e) { @js_errors << e })
290
+ end
291
+ Js::ScriptBoot.run_document_scripts(
292
+ runtime, doc, resources: @resources, on_error: ->(e) { @js_errors << e }
293
+ )
294
+ # Leave the page in a ready state: run on-load promises, due-now timers,
295
+ # and rAF (not future timers). `settle: false` observes it mid-flight.
296
+ runtime.settle if @settle_after_boot
297
+ end
298
+
299
+ # Perform a recorded navigation: fetch the target (following redirects),
300
+ # fire the old document's unload, then replace the Window + JS realm with the
301
+ # freshly parsed document and update the joint history. A network miss or a
302
+ # non-document response leaves the current page in place.
303
+ MAX_META_REFRESHES = 20
304
+
305
+ def perform_navigation!(nav, rebind: false, refresh_depth: 0)
306
+ response, final_url = @fetcher.request(
307
+ method: nav[:method] || "GET", url: nav[:url], params: nav[:params],
308
+ body: nav[:body], enctype: nav[:enctype], headers: nav[:headers] || {}
309
+ )
310
+ return unless response&.success? && document_response?(response)
311
+
312
+ # Fire the outgoing document's unload sequence while its realm is still
313
+ # alive, then surface any of its errors before the realm is torn down.
314
+ fire_unload(@window)
315
+ check_js_errors!
316
+
317
+ new_window = Dommy.parse(response.body)
318
+ new_window.location.__internal_set_url__(final_url)
319
+ new_window.navigation_delegate = self
320
+ @window = new_window
321
+ install_runtime(new_window)
322
+
323
+ windex = new_window.history.__internal_index__
324
+ if rebind || nav[:replace]
325
+ @history.rebind_current(url: final_url, window: new_window, windex: windex)
326
+ else
327
+ @history.push(final_url, window: new_window, windex: windex)
328
+ end
329
+
330
+ follow_meta_refresh!(refresh_depth)
331
+ end
332
+
333
+ # If the freshly loaded document asks for an immediate `<meta http-equiv=
334
+ # refresh>`, follow it (as a replace, like a redirect), capped so a page that
335
+ # refreshes to itself can't loop forever.
336
+ def follow_meta_refresh!(depth)
337
+ return if depth >= MAX_META_REFRESHES
338
+
339
+ target = meta_refresh_target(@window.document)
340
+ return unless target
341
+
342
+ perform_navigation!({url: target, method: "GET", source: :meta_refresh},
343
+ rebind: true, refresh_depth: depth + 1)
344
+ end
345
+
346
+ # The resolved URL a `<meta http-equiv="refresh" content="0; url=…">` points
347
+ # at, or nil when the document has none (or a refresh with no URL, which just
348
+ # reloads and is left alone to avoid a busy loop).
349
+ def meta_refresh_target(document)
350
+ document.query_selector_all("meta").each do |meta|
351
+ next unless meta.get_attribute("http-equiv").to_s.casecmp?("refresh")
352
+
353
+ _delay, separator, rest = meta.get_attribute("content").to_s.partition(";")
354
+ next if separator.empty?
355
+
356
+ url = rest.strip.sub(/\Aurl\s*=\s*/i, "").gsub(/\A["']|["']\z/, "").strip
357
+ return resolve_against_current(url) unless url.empty?
358
+ end
359
+ nil
360
+ end
361
+
362
+ def resolve_against_current(url)
363
+ URI.join(current_url, url).to_s
364
+ rescue URI::InvalidURIError
365
+ url
366
+ end
367
+
368
+ # Perform a pending navigation recorded by the delegate (JS-initiated
369
+ # location.href= / form submit / link click). Called at drain boundaries so
370
+ # the swap never runs with the outgoing realm's JS on the stack.
371
+ def flush_navigation!
372
+ return unless @navigable
373
+
374
+ nav = @pending_navigation
375
+ return unless nav
376
+
377
+ @pending_navigation = nil
378
+ perform_navigation!(nav)
379
+ end
380
+
381
+ def fire_unload(window)
382
+ window.dispatch_event(Dommy::Event.new("pagehide"))
383
+ window.dispatch_event(Dommy::Event.new("unload"))
384
+ end
385
+
386
+ # Only HTML/XML responses replace the document; other content types (a JSON
387
+ # API hit, an image) leave the current page. A response with no Content-Type
388
+ # is treated as a document (fixtures commonly omit it).
389
+ def document_response?(response)
390
+ headers = response.headers || {}
391
+ key = headers.keys.find { |k| k.to_s.casecmp?("content-type") }
392
+ content_type = key ? headers[key].to_s.downcase : ""
393
+ content_type.empty? || content_type.include?("html") || content_type.include?("xml")
394
+ end
395
+
193
396
  def unacknowledged = @js_errors[@acknowledged..] || []
194
397
 
195
398
  def submit_button?(button)
@@ -22,12 +22,16 @@ module Dommy
22
22
 
23
23
  # Invoke a DOM event listener per the EventTarget rule: an object with
24
24
  # `handle_event`, else a Ruby callable, else a JS-bridged callable (tried in
25
- # that order).
26
- def invoke_listener(listener, event)
25
+ # that order). A JS function listener's `this` must be the event's
26
+ # currentTarget (the node the listener is attached to), so pass it through
27
+ # when the bridge supports an explicit receiver.
28
+ def invoke_listener(listener, event, current_target = nil)
27
29
  if listener.respond_to?(:handle_event)
28
30
  listener.handle_event(event)
29
31
  elsif listener.respond_to?(:call) && !listener.is_a?(Module)
30
32
  listener.call(event)
33
+ elsif listener.respond_to?(:__js_call_with_this__)
34
+ listener.__js_call_with_this__([event], current_target)
31
35
  elsif listener.respond_to?(:__js_call__)
32
36
  listener.__js_call__("call", [event])
33
37
  end