dommy 0.8.1 → 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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -3
  3. data/lib/dommy/animation.rb +4 -0
  4. data/lib/dommy/attr.rb +57 -13
  5. data/lib/dommy/backend/makiri_adapter.rb +364 -0
  6. data/lib/dommy/backend.rb +143 -33
  7. data/lib/dommy/blob.rb +50 -6
  8. data/lib/dommy/bridge.rb +11 -0
  9. data/lib/dommy/browser.rb +420 -0
  10. data/lib/dommy/callable_invoker.rb +6 -2
  11. data/lib/dommy/compression_streams.rb +4 -0
  12. data/lib/dommy/crypto.rb +4 -0
  13. data/lib/dommy/css.rb +487 -50
  14. data/lib/dommy/custom_elements.rb +2 -2
  15. data/lib/dommy/data_transfer.rb +2 -0
  16. data/lib/dommy/data_uri.rb +35 -0
  17. data/lib/dommy/deferred_response.rb +59 -0
  18. data/lib/dommy/document.rb +901 -275
  19. data/lib/dommy/dom_exception.rb +2 -0
  20. data/lib/dommy/dom_parser.rb +7 -17
  21. data/lib/dommy/element.rb +940 -353
  22. data/lib/dommy/event.rb +529 -17
  23. data/lib/dommy/fetch.rb +577 -53
  24. data/lib/dommy/form_data.rb +2 -0
  25. data/lib/dommy/history.rb +26 -3
  26. data/lib/dommy/html_canvas_element.rb +230 -0
  27. data/lib/dommy/html_collection.rb +150 -27
  28. data/lib/dommy/html_elements.rb +1968 -282
  29. data/lib/dommy/interaction/debug.rb +35 -0
  30. data/lib/dommy/interaction/dom_summary.rb +131 -0
  31. data/lib/dommy/interaction/driver.rb +385 -0
  32. data/lib/dommy/interaction/event_synthesis.rb +164 -0
  33. data/lib/dommy/interaction/field_interactor.rb +177 -0
  34. data/lib/dommy/interaction/form_submission.rb +268 -0
  35. data/lib/dommy/interaction/locator.rb +158 -0
  36. data/lib/dommy/interaction/role_query.rb +58 -0
  37. data/lib/dommy/interaction.rb +32 -0
  38. data/lib/dommy/internal/accessibility_tree.rb +215 -0
  39. data/lib/dommy/internal/accessible_description.rb +38 -0
  40. data/lib/dommy/internal/accessible_name.rb +301 -0
  41. data/lib/dommy/internal/aria_role.rb +252 -0
  42. data/lib/dommy/internal/aria_snapshot.rb +64 -0
  43. data/lib/dommy/internal/aria_state.rb +151 -0
  44. data/lib/dommy/internal/child_node.rb +199 -0
  45. data/lib/dommy/internal/css/calc.rb +242 -0
  46. data/lib/dommy/internal/css/cascade.rb +472 -0
  47. data/lib/dommy/internal/css/color.rb +381 -0
  48. data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
  49. data/lib/dommy/internal/css/counters.rb +227 -0
  50. data/lib/dommy/internal/css/custom_properties.rb +183 -0
  51. data/lib/dommy/internal/css/media_query.rb +302 -0
  52. data/lib/dommy/internal/css/parser.rb +265 -0
  53. data/lib/dommy/internal/css/property_registry.rb +512 -0
  54. data/lib/dommy/internal/css/rule_index.rb +494 -0
  55. data/lib/dommy/internal/css/supports.rb +158 -0
  56. data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
  57. data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
  58. data/lib/dommy/internal/css_rule_text.rb +160 -0
  59. data/lib/dommy/internal/dom_matching.rb +80 -9
  60. data/lib/dommy/internal/element_matching.rb +109 -0
  61. data/lib/dommy/internal/global_functions.rb +55 -9
  62. data/lib/dommy/internal/mutation_coordinator.rb +122 -11
  63. data/lib/dommy/internal/namespaces.rb +49 -5
  64. data/lib/dommy/internal/node_wrapper_cache.rb +217 -38
  65. data/lib/dommy/internal/observer_manager.rb +6 -0
  66. data/lib/dommy/internal/observer_matcher.rb +6 -8
  67. data/lib/dommy/internal/parent_node.rb +98 -57
  68. data/lib/dommy/internal/selector_ast.rb +124 -0
  69. data/lib/dommy/internal/selector_index.rb +146 -0
  70. data/lib/dommy/internal/selector_matcher.rb +845 -0
  71. data/lib/dommy/internal/selector_parser.rb +321 -132
  72. data/lib/dommy/internal/shadow_root_registry.rb +9 -2
  73. data/lib/dommy/internal/template_content_registry.rb +26 -18
  74. data/lib/dommy/internal/xml_serialization.rb +344 -0
  75. data/lib/dommy/intersection_observer.rb +2 -0
  76. data/lib/dommy/js/bridge_conformance.rb +80 -0
  77. data/lib/dommy/js/constructor_resolver.rb +44 -0
  78. data/lib/dommy/js/custom_element_bridge.rb +101 -0
  79. data/lib/dommy/js/dom_interfaces.rb +177 -0
  80. data/lib/dommy/js/handle_table.rb +60 -0
  81. data/lib/dommy/js/host_bridge.rb +538 -0
  82. data/lib/dommy/js/host_runtime.js +2357 -0
  83. data/lib/dommy/js/import_map.rb +58 -0
  84. data/lib/dommy/js/marshaller.rb +240 -0
  85. data/lib/dommy/js/module_loader.rb +99 -0
  86. data/lib/dommy/js/observable_runtime.js +742 -0
  87. data/lib/dommy/js/runtime.rb +115 -0
  88. data/lib/dommy/js/script_boot.rb +301 -0
  89. data/lib/dommy/js/wire_tags.rb +62 -0
  90. data/lib/dommy/location.rb +78 -13
  91. data/lib/dommy/media_query_list.rb +50 -14
  92. data/lib/dommy/message_channel.rb +22 -6
  93. data/lib/dommy/minitest/assertions.rb +27 -0
  94. data/lib/dommy/mutation_observer.rb +89 -5
  95. data/lib/dommy/navigation.rb +263 -0
  96. data/lib/dommy/navigator.rb +34 -2
  97. data/lib/dommy/node.rb +198 -35
  98. data/lib/dommy/notification.rb +2 -0
  99. data/lib/dommy/parser.rb +1 -1
  100. data/lib/dommy/performance.rb +21 -1
  101. data/lib/dommy/promise.rb +94 -10
  102. data/lib/dommy/range.rb +187 -33
  103. data/lib/dommy/resources.rb +178 -0
  104. data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
  105. data/lib/dommy/scheduler.rb +165 -13
  106. data/lib/dommy/screen.rb +91 -0
  107. data/lib/dommy/shadow_root.rb +109 -13
  108. data/lib/dommy/storage.rb +63 -11
  109. data/lib/dommy/streams.rb +6 -0
  110. data/lib/dommy/text_codec.rb +7 -1
  111. data/lib/dommy/tree_walker.rb +51 -46
  112. data/lib/dommy/url.rb +17 -3
  113. data/lib/dommy/version.rb +1 -1
  114. data/lib/dommy/web_socket.rb +45 -2
  115. data/lib/dommy/window.rb +322 -15
  116. data/lib/dommy/worker.rb +8 -4
  117. data/lib/dommy/xml_http_request.rb +76 -9
  118. data/lib/dommy.rb +38 -1
  119. metadata +82 -14
  120. data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
  121. data/lib/dommy/backend/nokolexbor_adapter.rb +0 -117
@@ -0,0 +1,420 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ # A lightweight test browser: parse HTML, build window/document, run its
5
+ # classic `<script>` tags (inline + external via a resources adapter), fire
6
+ # DOMContentLoaded/load, and collect JS errors / console output. For
7
+ # standalone HTML + JS (bundled SPA, fixture HTML); the Rack/Rails entry point
8
+ # is `Dommy::Rack::Session` (a later phase).
9
+ #
10
+ # Dommy::Browser.open(html, resources: Dommy::Resources.static("/app.js" => "...")) do |b|
11
+ # b.settle
12
+ # b.evaluate('document.querySelector("h1").textContent')
13
+ # end
14
+ #
15
+ # JS errors are not swallowed: in strict mode (default) any unhandled rejection
16
+ # or uncaught script error fails at the next checkpoint (after boot, after
17
+ # `settle`, at dispose). Wrap intentional errors in `allow_js_errors { … }`.
18
+ class Browser
19
+ # Capybara-vocabulary finding / scoping / field interaction / click /
20
+ # matchers come from the shared interaction layer; each interaction's events
21
+ # are dispatched Ruby-side (synchronously invoking JS handlers), then
22
+ # `after_interaction` drains the runtime's microtasks so promise reactions
23
+ # settle before the next line.
24
+ include Dommy::Interaction::Driver
25
+
26
+ # Raised in strict mode when JS errors were collected and not acknowledged.
27
+ class JsError < StandardError
28
+ attr_reader :causes
29
+
30
+ def initialize(causes)
31
+ @causes = causes
32
+ super(build_message(causes))
33
+ end
34
+
35
+ private
36
+
37
+ def build_message(causes)
38
+ lines = causes.map { |e| " #{e.class}: #{e.message}" }
39
+ "#{causes.length} uncaught JS error(s):\n#{lines.join("\n")}"
40
+ end
41
+ end
42
+
43
+ attr_reader :window, :runtime, :js_errors, :console
44
+
45
+ # Build a browser and (unless `execute_scripts: false`) boot its scripts. In
46
+ # block form the browser is yielded and disposed afterward, returning the
47
+ # block value.
48
+ def self.open(html, **opts)
49
+ browser = new(html, **opts)
50
+ return browser unless block_given?
51
+
52
+ begin
53
+ yield browser
54
+ ensure
55
+ browser.dispose
56
+ end
57
+ end
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
+
71
+ def initialize(html, url: "http://localhost/", resources: nil, execute_scripts: true, strict: true, settle: true,
72
+ wasm_memory_shim: false, backend: nil, navigable: false, same_origin: false)
73
+ @resources = resources
74
+ @same_origin = same_origin
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
81
+ @js_errors = []
82
+ @console = []
83
+ @acknowledged = 0
84
+ @allow_errors = false
85
+ @disposed = false
86
+ @pending_navigation = nil
87
+ @runtime = nil
88
+
89
+ @window = Dommy.parse(html)
90
+ @window.location.__internal_set_url__(url) if url
91
+ install_runtime(@window)
92
+
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__)
98
+ end
99
+ check_js_errors!
100
+ end
101
+
102
+ def document = @window.document
103
+
104
+ # Current document HTML (serialized).
105
+ def html = @window.document.document_element&.outer_html
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
+
171
+ # Evaluate an expression / statement body and return the decoded value.
172
+ def evaluate(js)
173
+ result = @runtime.evaluate(js)
174
+ check_js_errors!
175
+ result
176
+ end
177
+
178
+ # Run JS for side effects.
179
+ def execute(js)
180
+ @runtime.execute(js)
181
+ check_js_errors!
182
+ nil
183
+ end
184
+
185
+ # Settle the work ready at the current virtual time: drain microtasks, run
186
+ # due-now timers, flush requestAnimationFrame. Does NOT fire a future
187
+ # `setTimeout(300)` — use `advance_time(300)` for debounce/throttle.
188
+ def settle
189
+ @runtime.settle
190
+ flush_navigation!
191
+ check_js_errors!
192
+ self
193
+ end
194
+
195
+ # Advance virtual time by `ms`, running timers that come due, then settle.
196
+ def advance_time(ms)
197
+ @window.scheduler.advance_time(ms)
198
+ @runtime.drain_microtasks
199
+ flush_navigation!
200
+ check_js_errors!
201
+ self
202
+ end
203
+
204
+ # An interaction's events have been dispatched (Ruby-side, synchronously
205
+ # invoking JS handlers); drain the runtime's microtasks so promise reactions
206
+ # land before the next line, then enforce strict mode.
207
+ def after_interaction
208
+ @runtime.drain_microtasks
209
+ flush_navigation!
210
+ check_js_errors!
211
+ end
212
+
213
+ # Click a submit-capable button. The button's click event fires (JS may
214
+ # handle / preventDefault it); if it is an un-prevented submit button, the
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.
218
+ def click_button(locator)
219
+ button = finder.find_button(locator)
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)
224
+ after_interaction
225
+ button
226
+ end
227
+
228
+ # Click a link, firing its click event so SPA JS (Turbo, React Router, …)
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.
232
+ def click_link(locator)
233
+ link = finder.find_link(locator)
234
+ Dommy::Interaction::EventSynthesis.click(link)
235
+ after_interaction
236
+ link
237
+ end
238
+
239
+ # Suppress strict-mode failure for JS errors raised inside the block (they
240
+ # stay collected in #js_errors for inspection). For tests that expect errors.
241
+ def allow_js_errors
242
+ prev = @allow_errors
243
+ @allow_errors = true
244
+ yield
245
+ ensure
246
+ @allow_errors = prev
247
+ @acknowledged = @js_errors.length
248
+ end
249
+
250
+ def dispose
251
+ return if @disposed
252
+
253
+ @disposed = true
254
+ pending = unacknowledged
255
+ @runtime&.dispose
256
+ raise JsError, pending if @strict && !pending.empty?
257
+ end
258
+
259
+ private
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
+
396
+ def unacknowledged = @js_errors[@acknowledged..] || []
397
+
398
+ def submit_button?(button)
399
+ if button.tag_name == "BUTTON"
400
+ button.type == "submit"
401
+ else
402
+ %w[submit image].include?(button.type)
403
+ end
404
+ end
405
+
406
+ # In strict mode, fail on any JS error collected since the last
407
+ # acknowledgement. Marks all current errors acknowledged so each is reported
408
+ # at most once.
409
+ def check_js_errors!
410
+ return if @allow_errors
411
+ return unless @strict
412
+
413
+ pending = unacknowledged
414
+ return if pending.empty?
415
+
416
+ @acknowledged = @js_errors.length
417
+ raise JsError, pending
418
+ end
419
+ end
420
+ end
@@ -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
@@ -44,6 +44,8 @@ module Dommy
44
44
  @readable
45
45
  when "writable"
46
46
  @writable
47
+ else
48
+ Bridge::ABSENT
47
49
  end
48
50
  end
49
51
 
@@ -111,6 +113,8 @@ module Dommy
111
113
  @readable
112
114
  when "writable"
113
115
  @writable
116
+ else
117
+ Bridge::ABSENT
114
118
  end
115
119
  end
116
120
 
data/lib/dommy/crypto.rb CHANGED
@@ -59,6 +59,8 @@ module Dommy
59
59
  case key
60
60
  when "subtle"
61
61
  subtle
62
+ else
63
+ Bridge::ABSENT
62
64
  end
63
65
  end
64
66
 
@@ -396,6 +398,8 @@ module Dommy
396
398
  {"name" => @algorithm_name, "hash" => {"name" => @hash_name}}
397
399
  when "usages"
398
400
  @usages
401
+ else
402
+ Bridge::ABSENT
399
403
  end
400
404
  end
401
405
  end