dommy 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/animation.rb +1 -0
  3. data/lib/dommy/backend.rb +3 -0
  4. data/lib/dommy/blob.rb +6 -0
  5. data/lib/dommy/browser.rb +12 -4
  6. data/lib/dommy/character_data.rb +1 -13
  7. data/lib/dommy/cookie_store.rb +2 -1
  8. data/lib/dommy/document.rb +67 -30
  9. data/lib/dommy/element.rb +11 -0
  10. data/lib/dommy/element_internals.rb +12 -0
  11. data/lib/dommy/encodings/legacy_multibyte.rb +800 -0
  12. data/lib/dommy/encodings/multibyte_overrides.rb +76 -0
  13. data/lib/dommy/encodings/prescan.rb +234 -0
  14. data/lib/dommy/encodings.rb +121 -57
  15. data/lib/dommy/event.rb +107 -54
  16. data/lib/dommy/event_source.rb +1 -0
  17. data/lib/dommy/fetch.rb +244 -53
  18. data/lib/dommy/file_reader.rb +1 -0
  19. data/lib/dommy/form_data.rb +22 -9
  20. data/lib/dommy/form_entry_list.rb +3 -3
  21. data/lib/dommy/html_canvas_element.rb +85 -12
  22. data/lib/dommy/html_elements/base.rb +1 -0
  23. data/lib/dommy/html_elements/forms/input.rb +3 -0
  24. data/lib/dommy/html_elements/interactive.rb +56 -48
  25. data/lib/dommy/html_elements/metadata.rb +23 -0
  26. data/lib/dommy/interaction/form_submission.rb +24 -42
  27. data/lib/dommy/internal/accessibility_tree.rb +149 -162
  28. data/lib/dommy/internal/accessibility_visibility.rb +16 -17
  29. data/lib/dommy/internal/accessible_name.rb +231 -246
  30. data/lib/dommy/internal/aria_role.rb +182 -195
  31. data/lib/dommy/internal/aria_snapshot.rb +35 -38
  32. data/lib/dommy/internal/aria_state.rb +102 -118
  33. data/lib/dommy/internal/autofill.rb +75 -75
  34. data/lib/dommy/internal/backend_prefilter.rb +127 -130
  35. data/lib/dommy/internal/child_navigable.rb +86 -83
  36. data/lib/dommy/internal/close_watcher_manager.rb +1 -0
  37. data/lib/dommy/internal/css/ancestor_filter.rb +64 -64
  38. data/lib/dommy/internal/css/calc.rb +90 -71
  39. data/lib/dommy/internal/css/cascade.rb +69 -67
  40. data/lib/dommy/internal/css/color.rb +249 -187
  41. data/lib/dommy/internal/css/computed_style_builder.rb +34 -0
  42. data/lib/dommy/internal/css/computed_style_declaration.rb +50 -11
  43. data/lib/dommy/internal/css/counters.rb +67 -67
  44. data/lib/dommy/internal/css/custom_properties.rb +114 -114
  45. data/lib/dommy/internal/css/parser.rb +277 -239
  46. data/lib/dommy/internal/css/property_registry.rb +321 -302
  47. data/lib/dommy/internal/css/renderability.rb +37 -35
  48. data/lib/dommy/internal/css/rule_index.rb +2 -2
  49. data/lib/dommy/internal/css/rule_index_inputs.rb +2 -2
  50. data/lib/dommy/internal/css/supports.rb +115 -113
  51. data/lib/dommy/internal/css/ua_stylesheet.rb +55 -53
  52. data/lib/dommy/internal/css_rule_text.rb +121 -119
  53. data/lib/dommy/internal/css_source.rb +1 -3
  54. data/lib/dommy/internal/css_syntax.rb +157 -92
  55. data/lib/dommy/internal/declarative_shadow_roots.rb +1 -0
  56. data/lib/dommy/internal/directionality.rb +120 -123
  57. data/lib/dommy/internal/document_live_ranges.rb +26 -1
  58. data/lib/dommy/internal/dom_matching.rb +185 -184
  59. data/lib/dommy/internal/element_geometry.rb +8 -1
  60. data/lib/dommy/internal/element_matching.rb +71 -69
  61. data/lib/dommy/internal/element_shadow.rb +1 -13
  62. data/lib/dommy/internal/element_state.rb +255 -265
  63. data/lib/dommy/internal/event_handlers.rb +66 -64
  64. data/lib/dommy/internal/exception_report.rb +88 -86
  65. data/lib/dommy/internal/focusability.rb +205 -205
  66. data/lib/dommy/internal/global_functions.rb +49 -47
  67. data/lib/dommy/internal/idl_reflection.rb +99 -97
  68. data/lib/dommy/internal/js_number.rb +35 -33
  69. data/lib/dommy/internal/light_dismiss.rb +130 -132
  70. data/lib/dommy/internal/literal_lookup.rb +75 -75
  71. data/lib/dommy/internal/mime_type.rb +54 -49
  72. data/lib/dommy/internal/mutation_coordinator.rb +46 -0
  73. data/lib/dommy/internal/mutation_observer_microtask.rb +60 -0
  74. data/lib/dommy/internal/namespaces.rb +51 -49
  75. data/lib/dommy/internal/node_adopter.rb +25 -1
  76. data/lib/dommy/internal/node_equality.rb +61 -59
  77. data/lib/dommy/internal/post_insertion_steps.rb +1 -0
  78. data/lib/dommy/internal/referrer.rb +144 -0
  79. data/lib/dommy/internal/rendered_text/collector.rb +22 -3
  80. data/lib/dommy/internal/rendered_text/computed_box.rb +3 -2
  81. data/lib/dommy/internal/rendered_text/fragment.rb +37 -38
  82. data/lib/dommy/internal/rendered_text/items.rb +7 -5
  83. data/lib/dommy/internal/selector_matcher.rb +361 -359
  84. data/lib/dommy/internal/selector_parser.rb +74 -62
  85. data/lib/dommy/internal/sequential_focus_navigation.rb +150 -151
  86. data/lib/dommy/internal/shadow_root_registry.rb +7 -0
  87. data/lib/dommy/internal/slot_assignment.rb +117 -0
  88. data/lib/dommy/internal/slot_signals.rb +125 -0
  89. data/lib/dommy/internal/unsafe_html.rb +79 -77
  90. data/lib/dommy/internal/url_parser.rb +5 -29
  91. data/lib/dommy/internal/web_idl.rb +158 -153
  92. data/lib/dommy/internal/window_bound.rb +14 -0
  93. data/lib/dommy/internal/window_constructors.rb +3 -0
  94. data/lib/dommy/internal/xml_serialization.rb +298 -296
  95. data/lib/dommy/js/dom_interfaces.rb +13 -1
  96. data/lib/dommy/js/host_bridge.rb +23 -1
  97. data/lib/dommy/js/host_callbacks.rb +7 -0
  98. data/lib/dommy/js/host_runtime.js +625 -61
  99. data/lib/dommy/js/import_map.rb +1 -1
  100. data/lib/dommy/js/observable_runtime.js +295 -128
  101. data/lib/dommy/js/webidl_members.js +1 -1
  102. data/lib/dommy/js/webidl_signatures.js +35 -6
  103. data/lib/dommy/media_query_list.rb +1 -0
  104. data/lib/dommy/message_channel.rb +2 -0
  105. data/lib/dommy/mutation_observer.rb +25 -29
  106. data/lib/dommy/navigator.rb +4 -1
  107. data/lib/dommy/node.rb +4 -0
  108. data/lib/dommy/notification.rb +1 -0
  109. data/lib/dommy/promise.rb +12 -0
  110. data/lib/dommy/range.rb +20 -3
  111. data/lib/dommy/shadow_root.rb +25 -1
  112. data/lib/dommy/streams.rb +18 -0
  113. data/lib/dommy/style_declaration.rb +15 -4
  114. data/lib/dommy/text_codec.rb +7 -5
  115. data/lib/dommy/tree_walker.rb +68 -61
  116. data/lib/dommy/version.rb +1 -1
  117. data/lib/dommy/web_socket.rb +1 -0
  118. data/lib/dommy/window.rb +22 -4
  119. data/lib/dommy/worker.rb +1 -0
  120. data/lib/dommy/xml_http_request.rb +12 -2
  121. data/lib/dommy.rb +17 -2
  122. metadata +11 -3
data/lib/dommy/fetch.rb CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  require "json"
4
4
  require "securerandom"
5
+ require "weakref"
5
6
  require_relative "data_uri"
7
+ require_relative "internal/referrer"
6
8
 
7
9
  module Dommy
8
10
  # `fetch` polyfill. No real network — instead resolves a response
@@ -36,8 +38,30 @@ module Dommy
36
38
  # Per Fetch, the request URL is resolved against the document base URL up
37
39
  # front; the handler, stub maps, and response.url all see the absolute
38
40
  # URL (no per-handler resolution).
39
- url = @window.__internal_resolve_url__(args[0].to_s)
40
- init = normalize_init(args[1] || {})
41
+ input, init = args
42
+ # fetch(request, init) is a fetch of `new Request(request, init)`: the
43
+ # request's URL, and its members as far as `init` leaves them.
44
+ body_source = nil
45
+ if input.is_a?(Request)
46
+ given = init.is_a?(Hash) ? init.transform_keys(&:to_s) : {}
47
+ # A null (or undefined) body in `init` leaves the request's own.
48
+ given.delete("body") if given["body"].nil? || given["body"].equal?(Bridge::UNDEFINED)
49
+ body_source = input unless given.key?("body")
50
+ init = input.__internal_init__.merge(given)
51
+ input = input.url
52
+ end
53
+ url = @window.__internal_resolve_url__(input.to_s, utf8: true)
54
+ init = normalize_init(init || {})
55
+ begin
56
+ add_referer(url, init)
57
+ rescue Bridge::TypeError => e
58
+ return PromiseValue.reject(@window, e)
59
+ end
60
+ # Sending the request's body uses it up, as reading it would — once the
61
+ # new Request's members are valid, as its constructor takes it last.
62
+ if body_source && !body_source.__internal_take_body__
63
+ return PromiseValue.reject(@window, Bridge::TypeError.new("Failed to execute 'fetch': Request body is already used"))
64
+ end
41
65
  # A cross-origin request always carries an Origin header (even for GET,
42
66
  # which normalize_init omits it for same-origin GETs).
43
67
  if cross_origin?(url) && !header?(init["headers"], "origin")
@@ -53,6 +77,16 @@ module Dommy
53
77
  @window.globals["__last_body__"] = init["body"] if init.is_a?(Hash)
54
78
 
55
79
  promise = PromiseValue.new(@window)
80
+ # An aborted signal rejects the fetch with its reason: before anything is
81
+ # sent when it already is, else whenever it aborts until the response
82
+ # arrives (the request has reached the app by then, as it would a server).
83
+ if (signal = request_signal(init))
84
+ return promise.reject(signal.reason) if signal.aborted?
85
+
86
+ abort_fetch = proc { promise.reject(signal.reason) }
87
+ signal.__internal_add_abort_algorithm__(abort_fetch)
88
+ promise.__internal_when_settled__ { signal.__internal_remove_abort_algorithm__(abort_fetch) }
89
+ end
56
90
  # `mode: "same-origin"` forbids a cross-origin request — it is a network
57
91
  # error before any fetch happens.
58
92
  if (init["mode"] || "cors").to_s == "same-origin" && cross_origin?(url)
@@ -270,8 +304,11 @@ module Dommy
270
304
  end
271
305
 
272
306
  def deliver_entry(promise, entry, url, init)
307
+ # Aborted while the response was on its way.
308
+ return if promise.settled?
309
+
273
310
  if entry.nil?
274
- promise.fulfill(Response.new(@window, body: "not found", status: 404, status_text: "Not Found", type: "basic"))
311
+ fulfill_response(promise, Response.new(@window, body: "not found", status: 404, status_text: "Not Found", type: "basic"), init)
275
312
  return
276
313
  end
277
314
 
@@ -292,13 +329,30 @@ module Dommy
292
329
  if (delay = entry["delay"])
293
330
  install_delayed_resolve(promise, body, status, status_text, headers, init, delay)
294
331
  else
295
- promise.fulfill(
332
+ fulfill_response(
333
+ promise,
296
334
  Response.new(@window, body: body, status: status, status_text: status_text,
297
- headers: headers, url: response_url, redirected: redirected, type: entry["type"] || "basic")
335
+ headers: headers, url: response_url, redirected: redirected, type: entry["type"] || "basic"),
336
+ init
298
337
  )
299
338
  end
300
339
  end
301
340
 
341
+ # The fetch's AbortSignal (`init.signal`, a Request's own), or nil.
342
+ def request_signal(init)
343
+ signal = init["signal"]
344
+ signal if signal.is_a?(AbortSignal)
345
+ end
346
+
347
+ # Fulfill the fetch with its response, whose body an abort of the signal
348
+ # errors from then on (Fetch's "abort the fetch() call" with a response).
349
+ def fulfill_response(promise, response, init)
350
+ if (signal = request_signal(init))
351
+ response.__internal_follow_abort__(signal)
352
+ end
353
+ promise.fulfill(response)
354
+ end
355
+
302
356
  # Resolve the response entry for a request: a `__fetch_handler__`
303
357
  # callable gets first refusal; a nil from it (or no handler) falls
304
358
  # through to the stub maps. Each spec file installs its stub under
@@ -378,6 +432,19 @@ module Dommy
378
432
  headers
379
433
  end
380
434
 
435
+ # The Referer the page's request carries (Referrer Policy), in place of
436
+ # any the caller gave: a forbidden request header is the browser's to set.
437
+ def add_referer(url, init)
438
+ headers = init["headers"]
439
+ headers.delete_if { |name, _| name.to_s.casecmp?("referer") }
440
+ referer = Internal::Referrer.header_value(
441
+ @window, url,
442
+ referrer: Internal::Referrer.request_referrer(@window, init["referrer"]),
443
+ policy: Internal::Referrer.request_policy(init["referrerPolicy"])
444
+ )
445
+ headers["Referer"] = referer if referer
446
+ end
447
+
381
448
  def request_origin
382
449
  @window.location.__js_get__("origin").to_s
383
450
  rescue StandardError
@@ -417,7 +484,7 @@ module Dommy
417
484
  end
418
485
 
419
486
  def safe_content_type?(value)
420
- CORS_SAFE_CONTENT_TYPES.include?(value.to_s.split(";").first.to_s.strip.downcase)
487
+ CORS_SAFE_CONTENT_TYPES.include?(Internal::MimeType.essence(value))
421
488
  end
422
489
 
423
490
  # Run the CORS preflight (an OPTIONS carrying Access-Control-Request-Method /
@@ -528,32 +595,22 @@ module Dommy
528
595
  nil
529
596
  end
530
597
 
598
+ # Fulfill after a simulated `delay`. An abort before then rejects the fetch
599
+ # (see #__js_call__) and clears the timer, so it does not linger in the
600
+ # scheduler's queue.
531
601
  def install_delayed_resolve(promise, body, status, status_text, headers, init, delay_ms)
532
- # AbortController cancellation: when init.signal is present and
533
- # `.abort()` fires before the timer, reject with an AbortError.
534
- # The timer is cleared in that path so it doesn't leak through
535
- # the test scheduler's drain loop.
536
- cancelled = [false]
602
+ signal = request_signal(init)
603
+ clear_timer = nil
537
604
  timer_id = @window.scheduler.set_timeout(
538
605
  lambda do |*_args|
539
- next if cancelled[0]
540
-
541
- promise.fulfill(Response.new(@window, body: body, status: status, status_text: status_text, headers: headers, type: "basic"))
606
+ signal&.__internal_remove_abort_algorithm__(clear_timer)
607
+ fulfill_response(promise, Response.new(@window, body: body, status: status, status_text: status_text, headers: headers, type: "basic"), init)
542
608
  end,
543
609
  delay_ms.to_i
544
610
  )
545
- signal = init.is_a?(Hash) ? init["signal"] : nil
546
- return unless signal.is_a?(AbortSignal)
547
-
548
- window_ref = @window
549
- abort_cb = lambda do |*_args|
550
- cancelled[0] = true
551
- window_ref.scheduler.clear_timeout(timer_id)
552
- err = ErrorValue.new("aborted", name: "AbortError")
553
- promise.reject(err)
554
- end
555
-
556
- signal.__js_call__("addEventListener", ["abort", abort_cb])
611
+ scheduler = @window.scheduler
612
+ clear_timer = proc { scheduler.clear_timeout(timer_id) }
613
+ signal&.__internal_add_abort_algorithm__(clear_timer)
557
614
  end
558
615
  end
559
616
 
@@ -613,10 +670,7 @@ module Dommy
613
670
 
614
671
  # `Request` polyfill — minimal Fetch API Request object so callers
615
672
  # constructing `new Request(url, init)` get a value with `.url`,
616
- # `.method`, `.headers`, `.body`. The stub-based `fetch` doesn't
617
- # consume it directly (it still takes `(url, init)`), but having
618
- # Request available means JS code that constructs one before
619
- # passing to fetch keeps working.
673
+ # `.method`, `.headers`, `.body`, which `fetch(request)` sends.
620
674
  class Request
621
675
  attr_reader :url, :method, :body
622
676
 
@@ -650,9 +704,11 @@ module Dommy
650
704
  # `request.signal.removeEventListener` crash.
651
705
  sig = opts["signal"] || opts[:signal]
652
706
  @signal = sig.is_a?(AbortSignal) ? sig : AbortSignal.new
707
+ @referrer = Internal::Referrer.request_referrer(window, opts["referrer"] || opts[:referrer])
708
+ @referrer_policy = Internal::Referrer.request_policy(opts["referrerPolicy"] || opts[:referrerPolicy])
653
709
  end
654
710
 
655
- attr_reader :headers, :credentials, :mode, :cache, :redirect, :signal
711
+ attr_reader :headers, :credentials, :mode, :cache, :redirect, :signal, :referrer, :referrer_policy
656
712
 
657
713
  def __js_get__(key)
658
714
  case key
@@ -664,6 +720,8 @@ module Dommy
664
720
  @headers
665
721
  when "body"
666
722
  @body
723
+ when "bodyUsed"
724
+ @body_used == true
667
725
  when "credentials"
668
726
  @credentials
669
727
  when "mode"
@@ -674,6 +732,10 @@ module Dommy
674
732
  @redirect
675
733
  when "signal"
676
734
  @signal
735
+ when "referrer"
736
+ @referrer
737
+ when "referrerPolicy"
738
+ @referrer_policy
677
739
  else
678
740
  Bridge::ABSENT
679
741
  end
@@ -685,25 +747,14 @@ module Dommy
685
747
  def __js_call__(method, _args)
686
748
  case method
687
749
  when "clone"
688
- Request.new(
689
- @url,
690
- {
691
- "method" => @method,
692
- "body" => @body,
693
- "headers" => @headers.to_h,
694
- "credentials" => @credentials,
695
- "mode" => @mode,
696
- "cache" => @cache,
697
- "redirect" => @redirect,
698
- "signal" => @signal
699
- },
700
- @window
701
- )
750
+ raise Bridge::TypeError, "Failed to execute 'clone' on 'Request': Request body is already used" if @body_used
751
+
752
+ Request.new(@url, __internal_init__, @window)
702
753
  when "text"
703
754
  consume_body { immediate(Response.utf8_decode(@body_bytes)) }
704
755
  when "json"
705
756
  consume_body do
706
- immediate(JSON.parse(Response.utf8_decode(@body_bytes)))
757
+ immediate(JSON.parse(Response.utf8_decode(@body_bytes), **Response::JSON_OPTIONS))
707
758
  rescue JSON::ParserError => e
708
759
  rejected(ErrorValue.new("JSON parse: #{e.message}"))
709
760
  end
@@ -719,19 +770,49 @@ module Dommy
719
770
  end
720
771
  end
721
772
 
773
+ # fetch(request) sends the body: false when it was already used, else it
774
+ # is used now. A request with no body has nothing to use up.
775
+ def __internal_take_body__
776
+ return true unless body?
777
+ return false if @body_used
778
+
779
+ @body_used = true
780
+ end
781
+
782
+ # The request as a `RequestInit`: what `clone()` builds a copy from, and
783
+ # what `fetch(request, init)` starts from before `init` overrides it.
784
+ def __internal_init__
785
+ {
786
+ "method" => @method,
787
+ "body" => @body,
788
+ "headers" => @headers.to_h,
789
+ "credentials" => @credentials,
790
+ "mode" => @mode,
791
+ "cache" => @cache,
792
+ "redirect" => @redirect,
793
+ "signal" => @signal,
794
+ "referrer" => @referrer,
795
+ "referrerPolicy" => @referrer_policy
796
+ }
797
+ end
798
+
722
799
  private
723
800
 
724
801
  # WHATWG Body: the body can be consumed once. A second consume rejects rather
725
- # than throwing synchronously.
802
+ # than throwing synchronously. A request with no body has nothing to use
803
+ # up: reading it leaves `bodyUsed` false, and it can be read and cloned
804
+ # again.
726
805
  def consume_body
727
806
  if @body_used
728
807
  return rejected(ErrorValue.new("Failed to read body: body stream already read", name: "TypeError"))
729
808
  end
730
809
 
731
- @body_used = true
810
+ @body_used = true if body?
732
811
  yield
733
812
  end
734
813
 
814
+ def body? = !@body.nil? && !@body.equal?(Bridge::UNDEFINED)
815
+
735
816
  def immediate(value)
736
817
  @window ? PromiseValue.resolve(@window, value) : value
737
818
  end
@@ -772,6 +853,7 @@ module Dommy
772
853
  @type = type
773
854
  @has_body = has_body ? true : false
774
855
  @body_used = false
856
+ @body_error = nil
775
857
  @body_stream = nil
776
858
  end
777
859
 
@@ -909,6 +991,10 @@ module Dommy
909
991
  end
910
992
  end
911
993
 
994
+ # ECMAScript's JSON.parse, which a page's JSON goes through, keeps the last
995
+ # of duplicate keys; json 3 rejects them unless told otherwise.
996
+ JSON_OPTIONS = {allow_duplicate_key: true}.freeze
997
+
912
998
  # WHATWG "UTF-8 decode" for a body's text(): interpret the raw bytes as
913
999
  # UTF-8, replacing any ill-formed sequence with U+FFFD, and drop a single
914
1000
  # leading byte-order mark (U+FEFF). Well-formed UTF-8 (the common case) is
@@ -1002,6 +1088,68 @@ module Dommy
1002
1088
  Bridge::UNHANDLED
1003
1089
  end
1004
1090
 
1091
+ # The response's fetch can still be aborted through `signal` (Fetch's
1092
+ # "abort the fetch() call" with a response): a body not yet read errors
1093
+ # with the abort reason, for a consume method and for a reader of `body`
1094
+ # alike. The signal lets go once the body is read — by a consume method,
1095
+ # or a reader taking it to its end — and holds the response and its body
1096
+ # stream only weakly until then, so a long-lived signal does not keep a
1097
+ # response nobody reads (`fetch(url, {signal}).then(r => r.ok)`).
1098
+ def __internal_follow_abort__(signal)
1099
+ return unless body_readable?
1100
+
1101
+ @abort_signal = signal
1102
+ @abort_body = BodyAbort.new(self, signal)
1103
+ signal.__internal_remove_abort_algorithms_if__ { |algorithm| algorithm.is_a?(BodyAbort) && algorithm.gone? }
1104
+ signal.__internal_add_abort_algorithm__(@abort_body)
1105
+ end
1106
+
1107
+ # The abort algorithm a Response leaves on its fetch's signal. It errors
1108
+ # the body when the response is still around, else the body stream a
1109
+ # reader may still hold; once both have been collected there is nothing
1110
+ # left to abort, and the signal drops it the next time it takes one.
1111
+ class BodyAbort
1112
+ def initialize(response, signal)
1113
+ @response = WeakRef.new(response)
1114
+ @stream = nil
1115
+ @signal = signal
1116
+ end
1117
+
1118
+ def stream=(stream)
1119
+ @stream = WeakRef.new(stream)
1120
+ end
1121
+
1122
+ def gone?
1123
+ !alive(@response) && !alive(@stream)
1124
+ end
1125
+
1126
+ # The stream was read to its end or errored: nothing left to abort.
1127
+ def release
1128
+ @signal.__internal_remove_abort_algorithm__(self)
1129
+ end
1130
+
1131
+ def call
1132
+ if (response = alive(@response))
1133
+ response.__internal_abort_body__(@signal.reason)
1134
+ elsif (stream = alive(@stream)) && stream.state == :readable
1135
+ stream.__internal_error__(@signal.reason)
1136
+ end
1137
+ end
1138
+
1139
+ private
1140
+
1141
+ def alive(ref)
1142
+ ref&.weakref_alive? ? ref.__getobj__ : nil
1143
+ rescue WeakRef::RefError
1144
+ nil
1145
+ end
1146
+ end
1147
+
1148
+ # Fetch "error response's body" from the signal's abort algorithm.
1149
+ def __internal_abort_body__(reason)
1150
+ abort_body(reason)
1151
+ end
1152
+
1005
1153
  include Bridge::Methods
1006
1154
  include FormDataBody
1007
1155
  js_methods %w[text json arrayBuffer blob formData clone]
@@ -1011,7 +1159,7 @@ module Dommy
1011
1159
  consume_body { immediate(Response.utf8_decode(@body)) }
1012
1160
  when "json"
1013
1161
  consume_body do
1014
- immediate(JSON.parse(scrub_lone_surrogates(@body)))
1162
+ immediate(JSON.parse(scrub_lone_surrogates(@body), **Response::JSON_OPTIONS))
1015
1163
  rescue JSON::ParserError => e
1016
1164
  rejected(ErrorValue.new("JSON parse: #{e.message}"))
1017
1165
  end
@@ -1039,16 +1187,37 @@ module Dommy
1039
1187
  end
1040
1188
 
1041
1189
  # Run a body-consume (text/json/arrayBuffer/blob), rejecting if the body was
1042
- # already used (spec returns a rejected promise, not a synchronous throw).
1190
+ # already used (spec returns a rejected promise, not a synchronous throw),
1191
+ # or with the abort reason when its fetch was aborted before it was read.
1043
1192
  def consume_body
1044
1193
  if body_used?
1045
1194
  return rejected(ErrorValue.new("Failed to read body: body stream already read", name: "TypeError"))
1046
1195
  end
1047
1196
 
1048
- @body_used = true
1197
+ # A null body (not an empty one) has nothing to use up.
1198
+ @body_used = true if @has_body
1199
+ @abort_signal&.__internal_remove_abort_algorithm__(@abort_body)
1200
+ return rejected(@body_error) if @body_error
1201
+
1049
1202
  yield
1050
1203
  end
1051
1204
 
1205
+ # Fetch "error response's body" when it is readable: all of a body is
1206
+ # there at once, so it is readable while a nonempty one is unread — an
1207
+ # empty one is already closed, as is a stream read to its end.
1208
+ def abort_body(reason)
1209
+ return if @body_used || !body_readable?
1210
+
1211
+ @body_error = reason
1212
+ @body_stream&.__internal_error__(reason)
1213
+ end
1214
+
1215
+ def body_readable?
1216
+ return false unless @has_body
1217
+
1218
+ @body_stream ? @body_stream.state == :readable : !@body.empty?
1219
+ end
1220
+
1052
1221
  # Lazily build + memoize the body ReadableStream (a single Uint8Array chunk
1053
1222
  # then close), or nil when there is no body.
1054
1223
  def body_stream
@@ -1058,18 +1227,34 @@ module Dommy
1058
1227
  stream = ReadableStream.new(@window)
1059
1228
  stream.__internal_enqueue__(Bridge::Bytes.new(@body.bytes)) unless @body.empty?
1060
1229
  stream.__internal_close__
1230
+ stream.__internal_error__(@body_error) if @body_error
1231
+ follow_abort_through(stream)
1061
1232
  stream
1062
1233
  end
1063
1234
  end
1064
1235
 
1236
+ # A reader can outlive the response, so the abort algorithm also reaches
1237
+ # the stream; a reader taking it to its end leaves the signal nothing to
1238
+ # abort. The stream's callback is the algorithm's own method, not a block
1239
+ # here, which would hold the response for as long as the stream.
1240
+ def follow_abort_through(stream)
1241
+ return unless @abort_body
1242
+
1243
+ @abort_body.stream = stream
1244
+ stream.__internal_when_done__(&@abort_body.method(:release))
1245
+ end
1246
+
1065
1247
  # WHATWG: clone throws if the body is already disturbed/locked; otherwise the
1066
- # clone is an independent Response over the same bytes.
1248
+ # clone is an independent Response over the same bytes. Its body is a tee
1249
+ # of this one's, so an abort of the fetch errors it too: the clone of a
1250
+ # body already errored is errored, and one made before the abort follows
1251
+ # the same signal.
1067
1252
  def clone_response
1068
1253
  if body_used?
1069
1254
  raise Bridge::TypeError, "Failed to execute 'clone' on 'Response': Response body is already used."
1070
1255
  end
1071
1256
 
1072
- Response.new(
1257
+ copy = Response.new(
1073
1258
  @window,
1074
1259
  body: @body,
1075
1260
  status: @status,
@@ -1080,6 +1265,12 @@ module Dommy
1080
1265
  type: @type,
1081
1266
  has_body: @has_body
1082
1267
  )
1268
+ if @body_error
1269
+ copy.__internal_abort_body__(@body_error)
1270
+ elsif @abort_signal
1271
+ copy.__internal_follow_abort__(@abort_signal)
1272
+ end
1273
+ copy
1083
1274
  end
1084
1275
 
1085
1276
  # A run of one or more adjacent `\uXXXX` JSON escapes.
@@ -9,6 +9,7 @@ module Dommy
9
9
  # Spec: https://w3c.github.io/FileAPI/#APIASynch
10
10
  class FileReader
11
11
  include EventTarget
12
+ include Internal::WindowBound
12
13
  include Internal::EventHandlers::IdlAttributeBridge
13
14
 
14
15
  EMPTY = 0
@@ -65,12 +65,17 @@ module Dommy
65
65
  copy
66
66
  end
67
67
 
68
- def append(name, value, filename = nil)
68
+ # A filename left out, as opposed to one given as nil (JS null) or
69
+ # undefined: WebIDL picks an overload by how many arguments a call passed.
70
+ NO_FILENAME = Object.new.freeze
71
+ private_constant :NO_FILENAME
72
+
73
+ def append(name, value, filename = NO_FILENAME)
69
74
  @pairs << [name.to_s, entry_value(value, filename)]
70
75
  nil
71
76
  end
72
77
 
73
- def set(name, value, filename = nil)
78
+ def set(name, value, filename = NO_FILENAME)
74
79
  key = name.to_s
75
80
  v = entry_value(value, filename)
76
81
  replaced = false
@@ -160,9 +165,9 @@ module Dommy
160
165
  def __js_call__(method, args)
161
166
  case method
162
167
  when "append"
163
- append(args[0], args[1], args[2])
168
+ append(args[0], args[1], args.fetch(2, NO_FILENAME))
164
169
  when "set"
165
- set(args[0], args[1], args[2])
170
+ set(args[0], args[1], args.fetch(2, NO_FILENAME))
166
171
  when "get"
167
172
  get(args[0])
168
173
  when "getAll"
@@ -186,14 +191,22 @@ module Dommy
186
191
 
187
192
  # XHR's "create an entry": a Blob that is not a File becomes a File named
188
193
  # "blob", and a File given a filename one of that name, so every file
189
- # entry is a File with a name; anything else is a string.
194
+ # entry is a File with a name; anything else is a string. A call with a
195
+ # third argument, even undefined, can only be the (name, Blob, filename)
196
+ # overload, so a string value with one is a TypeError. The filename is
197
+ # optional: undefined is its absence, null the string "null".
190
198
  def entry_value(value, filename)
191
- return value.nil? ? "" : value.to_s unless value.is_a?(Blob)
199
+ unless value.is_a?(Blob)
200
+ raise Bridge::TypeError, "parameter 2 is not of type 'Blob'." unless filename.equal?(NO_FILENAME)
201
+
202
+ return Internal::WebIDL.dom_string(value)
203
+ end
192
204
 
193
- filename = nil if filename.equal?(Bridge::UNDEFINED)
194
- return value if value.is_a?(File) && filename.nil?
205
+ return value if value.is_a?(File) && default_filename?(filename)
195
206
 
196
- value.__internal_file_named__(filename.nil? ? "blob" : filename.to_s)
207
+ value.__internal_file_named__(default_filename?(filename) ? "blob" : Internal::WebIDL.dom_string(filename))
197
208
  end
209
+
210
+ def default_filename?(filename) = filename.equal?(NO_FILENAME) || filename.equal?(Bridge::UNDEFINED)
198
211
  end
199
212
  end
@@ -7,9 +7,9 @@ module Dommy
7
7
  # (Dommy::Interaction::FormSubmission) and `new FormData(form)` build on this,
8
8
  # so the two paths collect and fire identically.
9
9
  class FormEntryList
10
- # @param encoding [Encoding] the submission encoding, used for a
10
+ # @param encoding [String] the submission encoding's name, used for a
11
11
  # hidden `_charset_`. `new FormData(form)` uses UTF-8.
12
- def initialize(form, submitter: nil, encoding: Encoding::UTF_8)
12
+ def initialize(form, submitter: nil, encoding: "UTF-8")
13
13
  @form = form
14
14
  @submitter = submitter
15
15
  @encoding = encoding
@@ -115,7 +115,7 @@ module Dommy
115
115
  collect_file(el, name, data)
116
116
  when "hidden"
117
117
  # A hidden `_charset_` reports the submission encoding.
118
- data.append(name, name.casecmp?("_charset_") ? @encoding.name : el.value.to_s)
118
+ data.append(name, name.casecmp?("_charset_") ? @encoding : el.value.to_s)
119
119
  else
120
120
  data.append(name, el.value.to_s)
121
121
  end