dommy 0.12.0 → 0.13.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6306b86751026b1b48072c497fd34cd008c5b0bfe1838ae62ab3896d89a2aa3f
4
- data.tar.gz: 817d56deed527b5f2718242937bb1a30a7d22738ca8f09276750505cf9368b5a
3
+ metadata.gz: 64a22d9ba17102a03881baedbda66b5236b8c4499140d779e4c4acba86583241
4
+ data.tar.gz: e111a114c5a7ae9485a3e99af339e70a59dce6b9a83a837639ac6564873a8d4e
5
5
  SHA512:
6
- metadata.gz: bfce9a98c80d522db534045b6ee378d0bdfb8e1f03646173e4fc74fd5e8ed46e35e723fe4ce5f7828cdab9d71b6e567c2022ad63d01f2f70af8a0baf0acd34cf
7
- data.tar.gz: 6b0aa54054201df72970198f18450268d53cbe434b3da15bd9b49cd68eb98b9d94ae1acf80d40b668706052385fa8597e4580b683c472760ee970a10ee13d2f4
6
+ metadata.gz: 67c651bd499d2cd0d6cf1fb862b11a9f0f6b68ba1814abe40732b990e5b66b9b5f0494fd33cb989e4b7a5f64faa3a8a152e8979231f4c082f32187999188030a
7
+ data.tar.gz: e7e8ee9194687b9fe6be28646e9f80d32631f3cf78e5b9fefbab5fd913b9a1d6116ab819bae3a54030f2fefc6e4fa8e478c8ab2667cc086e009c5ebb1a7c9481
data/lib/dommy/bridge.rb CHANGED
@@ -57,9 +57,20 @@ module Dommy
57
57
  class JSValue
58
58
  attr_reader :ref
59
59
 
60
- def initialize(ref, label = nil)
60
+ # `stack_frames` are the value's JS stack, when the tag that crossed
61
+ # carried one. Ruby cannot reach through a ref to read `.stack`, so an
62
+ # error-shaped value brings its frames with it (see host_runtime.js
63
+ # tagValue) and reporting uses them for the source position.
64
+ attr_reader :stack_frames
65
+
66
+ # The value's JS constructor name, when the tag carried one.
67
+ attr_reader :js_name
68
+
69
+ def initialize(ref, label = nil, stack_frames = nil, js_name = nil)
61
70
  @ref = ref
62
71
  @label = label
72
+ @stack_frames = stack_frames
73
+ @js_name = js_name
63
74
  end
64
75
 
65
76
  def to_s = (@label || "[object]").to_s
@@ -75,9 +86,11 @@ module Dommy
75
86
  class ThrowValue < RuntimeError
76
87
  attr_reader :value
77
88
 
78
- def initialize(value)
89
+ # `message` overrides the value's string form, for a caller that can say
90
+ # more about it than `to_s` can (a JS error's kind, say).
91
+ def initialize(value, message = nil)
79
92
  @value = value
80
- super(value.to_s)
93
+ super(message || value.to_s)
81
94
  end
82
95
  end
83
96
 
data/lib/dommy/browser.rb CHANGED
@@ -23,24 +23,14 @@ module Dommy
23
23
  # settle before the next line.
24
24
  include Dommy::Interaction::Driver
25
25
 
26
- # Raised in strict mode when JS errors were collected and not acknowledged.
27
- class JsError < StandardError
28
- attr_reader :causes
26
+ attr_reader :window, :runtime, :console
29
27
 
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
28
+ # The ledger of errors the page left unhandled (`Dommy::Js::ErrorLog`);
29
+ # shared with the other hosts so strict mode behaves the same everywhere.
30
+ attr_reader :error_log
42
31
 
43
- attr_reader :window, :runtime, :js_errors, :console
32
+ # The errors themselves, as a browser console's scrollback.
33
+ def js_errors = @error_log.errors
44
34
 
45
35
  # Build a browser and (unless `execute_scripts: false`) boot its scripts. In
46
36
  # block form the browser is yielded and disposed afterward, returning the
@@ -72,16 +62,13 @@ module Dommy
72
62
  wasm_memory_shim: false, backend: nil, navigable: false, same_origin: false)
73
63
  @resources = resources
74
64
  @same_origin = same_origin
75
- @strict = strict
76
65
  @backend = backend
77
66
  @execute_scripts = execute_scripts
78
67
  @settle_after_boot = settle
79
68
  @wasm_memory_shim = wasm_memory_shim
80
69
  @navigable = navigable
81
- @js_errors = []
70
+ @error_log = Js::ErrorLog.new(strict: strict)
82
71
  @console = []
83
- @acknowledged = 0
84
- @allow_errors = false
85
72
  @disposed = false
86
73
  @pending_navigation = nil
87
74
  @runtime = nil
@@ -238,22 +225,19 @@ module Dommy
238
225
 
239
226
  # Suppress strict-mode failure for JS errors raised inside the block (they
240
227
  # 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
228
+ def allow_js_errors(&block)
229
+ @error_log.allow(&block)
248
230
  end
249
231
 
232
+ # Tear the realm down, then fail on anything the page left unhandled and
233
+ # nobody has reported. Disposing first means the failure cannot leave a live
234
+ # VM behind.
250
235
  def dispose
251
236
  return if @disposed
252
237
 
253
238
  @disposed = true
254
- pending = unacknowledged
255
239
  @runtime&.dispose
256
- raise JsError, pending if @strict && !pending.empty?
240
+ @error_log.check!(context: current_url)
257
241
  end
258
242
 
259
243
  private
@@ -268,8 +252,19 @@ module Dommy
268
252
  # The JS engine is pluggable: `@backend` selects a registered runtime
269
253
  # (nil → the configured default, QuickJS when dommy-js-quickjs is loaded).
270
254
  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)
255
+ # Every uncaught error the page produces goes through the window's
256
+ # "report an exception" / "notify about rejected promises" funnel, and only
257
+ # what the page left unhandled lands here. A page that installs its own
258
+ # `window.onerror` / `unhandledrejection` handler and cancels the event
259
+ # suppresses the failure, exactly as it would in a browser.
260
+ window.__internal_on_unhandled_error__ { |err| @error_log.record(err) }
261
+ # A rejection the page handled after we reported it is retracted, so it
262
+ # stops failing anything (WHATWG `rejectionhandled`).
263
+ window.__internal_on_rejection_handled__ { |record| @error_log.retract(record) }
264
+ runtime.on_unhandled_rejection { |err| report_rejection(window, err) }
265
+ if runtime.respond_to?(:on_callback_error)
266
+ runtime.on_callback_error { |err| Internal::ExceptionReport.report_at(window, err) }
267
+ end
273
268
  runtime.on_log { |log| @console << log }
274
269
  runtime.define_host_object("document", window.document)
275
270
  runtime.install_window(window)
@@ -285,24 +280,35 @@ module Dommy
285
280
  # Installed whenever a runtime is attached — an embedder that drives script
286
281
  # boot itself (`execute_scripts: false`) still needs inline handlers wired.
287
282
  doc.inline_handler_wirer = lambda do
288
- Js::ScriptBoot.wire_inline_handlers(runtime, on_error: ->(e) { @js_errors << e })
283
+ Js::ScriptBoot.wire_inline_handlers(runtime, on_error: ->(e) { @error_log.record(e) })
289
284
  end
290
285
  return unless @execute_scripts
291
286
 
287
+ # `on_error:` here is only the windowless fallback — a booted document has
288
+ # a window, so a throwing script reports through it (see ScriptBoot).
289
+
292
290
  # Dynamically-inserted `<script src>` (webpack/Vite on-demand chunks)
293
291
  # fetch + run through the same resources adapter, after boot.
294
292
  doc.external_script_runner = lambda do |element, src|
295
293
  Js::ScriptBoot.run_external_script(runtime, doc, element, src,
296
- resources: @resources, on_error: ->(e) { @js_errors << e })
294
+ resources: @resources, on_error: ->(e) { @error_log.record(e) })
297
295
  end
298
296
  Js::ScriptBoot.run_document_scripts(
299
- runtime, doc, resources: @resources, on_error: ->(e) { @js_errors << e }
297
+ runtime, doc, resources: @resources, on_error: ->(e) { @error_log.record(e) }
300
298
  )
301
299
  # Leave the page in a ready state: run on-load promises, due-now timers,
302
300
  # and rAF (not future timers). `settle: false` observes it mid-flight.
303
301
  runtime.settle if @settle_after_boot
304
302
  end
305
303
 
304
+ # Route an unhandled promise rejection through the page's own
305
+ # `unhandledrejection` handling first. The engine decides WHEN a rejection
306
+ # counts as unhandled (see Window#__internal_report_rejection__).
307
+ def report_rejection(window, error)
308
+ value = Internal::ExceptionReport.error_value(error)
309
+ window.__internal_report_rejection__(value, host_error: error)
310
+ end
311
+
306
312
  # Perform a recorded navigation: fetch the target (following redirects),
307
313
  # fire the old document's unload, then replace the Window + JS realm with the
308
314
  # freshly parsed document and update the joint history. A network miss or a
@@ -400,8 +406,6 @@ module Dommy
400
406
  content_type.empty? || content_type.include?("html") || content_type.include?("xml")
401
407
  end
402
408
 
403
- def unacknowledged = @js_errors[@acknowledged..] || []
404
-
405
409
  def submit_button?(button)
406
410
  if button.tag_name == "BUTTON"
407
411
  button.type == "submit"
@@ -410,18 +414,10 @@ module Dommy
410
414
  end
411
415
  end
412
416
 
413
- # In strict mode, fail on any JS error collected since the last
414
- # acknowledgement. Marks all current errors acknowledged so each is reported
415
- # at most once.
417
+ # In strict mode, fail on anything the page left unhandled since the last
418
+ # checkpoint. The ledger drains itself, so each error is reported once.
416
419
  def check_js_errors!
417
- return if @allow_errors
418
- return unless @strict
419
-
420
- pending = unacknowledged
421
- return if pending.empty?
422
-
423
- @acknowledged = @js_errors.length
424
- raise JsError, pending
420
+ @error_log.check!(context: current_url)
425
421
  end
426
422
  end
427
423
  end
@@ -20,6 +20,22 @@ module Dommy
20
20
  end
21
21
  end
22
22
 
23
+ # Invoke `callback` so a JS throw SURFACES as a Ruby exception instead of
24
+ # being swallowed by the bridge. WHATWG requires a task's exception to be
25
+ # reported at the global ("report an exception") rather than silently
26
+ # dropped, so the entry points that own a task — a timer / rAF callback —
27
+ # invoke this form and report whatever comes out. The plain `invoke` keeps
28
+ # the swallowing behavior for callers that handle their own errors.
29
+ def invoke_raising(callback, *args)
30
+ return if callback.nil?
31
+
32
+ if callback.respond_to?(:__js_invoke__)
33
+ callback.__js_invoke__(args, raising: true)
34
+ else
35
+ invoke(callback, *args)
36
+ end
37
+ end
38
+
23
39
  # Invoke a DOM event listener per the EventTarget rule: an object with
24
40
  # `handle_event`, else a Ruby callable, else a JS-bridged callable (tried in
25
41
  # that order). A JS function listener's `this` must be the event's
@@ -10,43 +10,18 @@ module Dommy
10
10
  # finalizes and emits the compressed/decompressed bytes downstream.
11
11
  #
12
12
  # Spec: https://wicg.github.io/compression/
13
- class CompressionStream
13
+ class CompressionStream < TransformStream
14
14
  SUPPORTED = %w[gzip deflate deflate-raw].freeze
15
15
 
16
- attr_reader :readable, :writable
17
-
18
16
  def initialize(window, format)
19
17
  raise ArgumentError, "unsupported format #{format.inspect}" unless SUPPORTED.include?(format.to_s)
20
18
 
21
- @buffer = +""
19
+ buffer = +""
22
20
  compressor = build_compressor(format.to_s)
23
-
24
- @readable = ReadableStream.new(window)
25
- controller = TransformStreamDefaultController.new(@readable)
26
-
27
- @writable = WritableStream.new(
28
- window,
29
- {
30
- "write" => proc { |chunk| @buffer << coerce(chunk) },
31
- "close" => proc do
32
- compressed = compressor.call(@buffer)
33
- controller.enqueue(compressed)
34
- @readable.__internal_close__
35
- end,
36
- "abort" => proc { |r| @readable.__internal_error__(r) }
37
- }
38
- )
39
- end
40
-
41
- def __js_get__(key)
42
- case key
43
- when "readable"
44
- @readable
45
- when "writable"
46
- @writable
47
- else
48
- Bridge::ABSENT
49
- end
21
+ super(window, {
22
+ "transform" => proc { |chunk, _controller| buffer << coerce(chunk); nil },
23
+ "flush" => proc { |controller| controller.enqueue(compressor.call(buffer)); nil }
24
+ })
50
25
  end
51
26
 
52
27
  private
@@ -79,43 +54,18 @@ module Dommy
79
54
  end
80
55
  end
81
56
 
82
- class DecompressionStream
57
+ class DecompressionStream < TransformStream
83
58
  SUPPORTED = %w[gzip deflate deflate-raw].freeze
84
59
 
85
- attr_reader :readable, :writable
86
-
87
60
  def initialize(window, format)
88
61
  raise ArgumentError, "unsupported format #{format.inspect}" unless SUPPORTED.include?(format.to_s)
89
62
 
90
- @buffer = +""
63
+ buffer = +""
91
64
  decompressor = build_decompressor(format.to_s)
92
-
93
- @readable = ReadableStream.new(window)
94
- controller = TransformStreamDefaultController.new(@readable)
95
-
96
- @writable = WritableStream.new(
97
- window,
98
- {
99
- "write" => proc { |chunk| @buffer << coerce(chunk) },
100
- "close" => proc do
101
- plain = decompressor.call(@buffer)
102
- controller.enqueue(plain)
103
- @readable.__internal_close__
104
- end,
105
- "abort" => proc { |r| @readable.__internal_error__(r) }
106
- }
107
- )
108
- end
109
-
110
- def __js_get__(key)
111
- case key
112
- when "readable"
113
- @readable
114
- when "writable"
115
- @writable
116
- else
117
- Bridge::ABSENT
118
- end
65
+ super(window, {
66
+ "transform" => proc { |chunk, _controller| buffer << coerce(chunk); nil },
67
+ "flush" => proc { |controller| controller.enqueue(decompressor.call(buffer)); nil }
68
+ })
119
69
  end
120
70
 
121
71
  private
@@ -810,11 +810,11 @@ module Dommy
810
810
  href = base_el["href"].to_s
811
811
  return doc_url if href.empty?
812
812
 
813
- begin
814
- URI.join(doc_url.to_s.empty? ? "about:blank" : doc_url, href).to_s
815
- rescue URI::InvalidURIError
816
- doc_url
817
- end
813
+ # HTML "document base URL": the frozen base URL is the href parsed
814
+ # against the document's URL; one that does not parse falls back.
815
+ URL.new(href, doc_url.to_s.empty? ? nil : doc_url).href
816
+ rescue Bridge::TypeError
817
+ doc_url
818
818
  end
819
819
 
820
820
  # `document.domain` — host portion of the URL. Real browsers
data/lib/dommy/element.rb CHANGED
@@ -2623,9 +2623,8 @@ module Dommy
2623
2623
  # so `a.href = "http://example.org/?ä"` reads back percent-encoded — which is
2624
2624
  # what the URL parser produces and what `URI.join` does not.
2625
2625
  def resolve_url(raw)
2626
- win = @document.default_view
2627
- base = win&.location ? win.location.href : ""
2628
- base = nil if base.to_s.empty?
2626
+ base = @document.base_uri.to_s
2627
+ base = nil if base.empty?
2629
2628
  Internal::UrlParser.serialize(Internal::UrlParser.parse(raw.to_s, base))
2630
2629
  rescue Internal::UrlParser::Failure
2631
2630
  raw.to_s