dommy 0.8.0 → 0.9.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 (115) 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 +11 -5
  5. data/lib/dommy/backend/makiri_adapter.rb +330 -0
  6. data/lib/dommy/backend.rb +114 -33
  7. data/lib/dommy/blob.rb +2 -0
  8. data/lib/dommy/bridge.rb +11 -0
  9. data/lib/dommy/browser.rb +217 -0
  10. data/lib/dommy/compression_streams.rb +4 -0
  11. data/lib/dommy/crypto.rb +4 -0
  12. data/lib/dommy/css.rb +487 -50
  13. data/lib/dommy/custom_elements.rb +2 -2
  14. data/lib/dommy/data_transfer.rb +2 -0
  15. data/lib/dommy/data_uri.rb +35 -0
  16. data/lib/dommy/deferred_response.rb +59 -0
  17. data/lib/dommy/document.rb +386 -228
  18. data/lib/dommy/dom_exception.rb +2 -0
  19. data/lib/dommy/dom_parser.rb +7 -17
  20. data/lib/dommy/element.rb +502 -155
  21. data/lib/dommy/event.rb +240 -9
  22. data/lib/dommy/fetch.rb +152 -34
  23. data/lib/dommy/form_data.rb +2 -0
  24. data/lib/dommy/history.rb +2 -0
  25. data/lib/dommy/html_canvas_element.rb +230 -0
  26. data/lib/dommy/html_collection.rb +5 -6
  27. data/lib/dommy/html_elements.rb +304 -27
  28. data/lib/dommy/interaction/debug.rb +35 -0
  29. data/lib/dommy/interaction/dom_summary.rb +131 -0
  30. data/lib/dommy/interaction/driver.rb +244 -0
  31. data/lib/dommy/interaction/event_synthesis.rb +56 -0
  32. data/lib/dommy/interaction/field_interactor.rb +117 -0
  33. data/lib/dommy/interaction/form_submission.rb +268 -0
  34. data/lib/dommy/interaction/locator.rb +158 -0
  35. data/lib/dommy/interaction/role_query.rb +58 -0
  36. data/lib/dommy/interaction.rb +32 -0
  37. data/lib/dommy/internal/accessibility_tree.rb +215 -0
  38. data/lib/dommy/internal/accessible_description.rb +38 -0
  39. data/lib/dommy/internal/accessible_name.rb +301 -0
  40. data/lib/dommy/internal/aria_role.rb +252 -0
  41. data/lib/dommy/internal/aria_snapshot.rb +64 -0
  42. data/lib/dommy/internal/aria_state.rb +151 -0
  43. data/lib/dommy/internal/css/calc.rb +242 -0
  44. data/lib/dommy/internal/css/cascade.rb +430 -0
  45. data/lib/dommy/internal/css/color.rb +381 -0
  46. data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
  47. data/lib/dommy/internal/css/counters.rb +227 -0
  48. data/lib/dommy/internal/css/custom_properties.rb +183 -0
  49. data/lib/dommy/internal/css/media_query.rb +302 -0
  50. data/lib/dommy/internal/css/parser.rb +265 -0
  51. data/lib/dommy/internal/css/property_registry.rb +512 -0
  52. data/lib/dommy/internal/css/rule_index.rb +494 -0
  53. data/lib/dommy/internal/css/supports.rb +158 -0
  54. data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
  55. data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
  56. data/lib/dommy/internal/css_rule_text.rb +160 -0
  57. data/lib/dommy/internal/dom_matching.rb +80 -9
  58. data/lib/dommy/internal/element_matching.rb +109 -0
  59. data/lib/dommy/internal/global_functions.rb +33 -0
  60. data/lib/dommy/internal/mutation_coordinator.rb +95 -4
  61. data/lib/dommy/internal/namespaces.rb +49 -5
  62. data/lib/dommy/internal/node_wrapper_cache.rb +163 -26
  63. data/lib/dommy/internal/parent_node.rb +82 -5
  64. data/lib/dommy/internal/selector_ast.rb +124 -0
  65. data/lib/dommy/internal/selector_index.rb +146 -0
  66. data/lib/dommy/internal/selector_matcher.rb +756 -0
  67. data/lib/dommy/internal/selector_parser.rb +283 -131
  68. data/lib/dommy/internal/shadow_root_registry.rb +9 -2
  69. data/lib/dommy/internal/template_content_registry.rb +26 -18
  70. data/lib/dommy/internal/xml_serialization.rb +344 -0
  71. data/lib/dommy/intersection_observer.rb +2 -0
  72. data/lib/dommy/js/bridge_conformance.rb +80 -0
  73. data/lib/dommy/js/constructor_resolver.rb +44 -0
  74. data/lib/dommy/js/custom_element_bridge.rb +90 -0
  75. data/lib/dommy/js/dom_interfaces.rb +162 -0
  76. data/lib/dommy/js/handle_table.rb +60 -0
  77. data/lib/dommy/js/host_bridge.rb +517 -0
  78. data/lib/dommy/js/host_runtime.js +1495 -0
  79. data/lib/dommy/js/import_map.rb +58 -0
  80. data/lib/dommy/js/marshaller.rb +240 -0
  81. data/lib/dommy/js/module_loader.rb +99 -0
  82. data/lib/dommy/js/observable_runtime.js +742 -0
  83. data/lib/dommy/js/runtime.rb +115 -0
  84. data/lib/dommy/js/script_boot.rb +221 -0
  85. data/lib/dommy/js/wire_tags.rb +62 -0
  86. data/lib/dommy/location.rb +2 -0
  87. data/lib/dommy/media_query_list.rb +50 -14
  88. data/lib/dommy/message_channel.rb +22 -6
  89. data/lib/dommy/minitest/assertions.rb +27 -0
  90. data/lib/dommy/mutation_observer.rb +89 -4
  91. data/lib/dommy/navigator.rb +34 -2
  92. data/lib/dommy/node.rb +24 -14
  93. data/lib/dommy/notification.rb +2 -0
  94. data/lib/dommy/parser.rb +1 -1
  95. data/lib/dommy/performance.rb +21 -1
  96. data/lib/dommy/promise.rb +94 -10
  97. data/lib/dommy/range.rb +173 -31
  98. data/lib/dommy/resources.rb +178 -0
  99. data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
  100. data/lib/dommy/scheduler.rb +149 -13
  101. data/lib/dommy/screen.rb +91 -0
  102. data/lib/dommy/shadow_root.rb +76 -13
  103. data/lib/dommy/storage.rb +2 -1
  104. data/lib/dommy/streams.rb +6 -0
  105. data/lib/dommy/text_codec.rb +7 -1
  106. data/lib/dommy/tree_walker.rb +33 -10
  107. data/lib/dommy/url.rb +13 -1
  108. data/lib/dommy/version.rb +1 -1
  109. data/lib/dommy/window.rb +199 -11
  110. data/lib/dommy/worker.rb +8 -4
  111. data/lib/dommy/xml_http_request.rb +47 -6
  112. data/lib/dommy.rb +36 -1
  113. metadata +96 -10
  114. data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
  115. data/lib/dommy/backend/nokolexbor_adapter.rb +0 -117
data/lib/dommy/event.rb CHANGED
@@ -130,7 +130,11 @@ module Dommy
130
130
  end
131
131
  end
132
132
 
133
- CallableInvoker.invoke_listener(entry.listener, event)
133
+ if entry.passive?
134
+ event.__internal_run_passive__ { invoke_listener_isolated(entry.listener, event) }
135
+ else
136
+ invoke_listener_isolated(entry.listener, event)
137
+ end
134
138
 
135
139
  break if event.immediate_propagation_stopped?
136
140
  end
@@ -138,6 +142,36 @@ module Dommy
138
142
  nil
139
143
  end
140
144
 
145
+ # Run one listener, isolating a throw so it can't escape the dispatch.
146
+ # WHATWG "inner invoke": if a listener's callback throws, the exception is
147
+ # *reported* (to the global error handler) and event dispatch continues with
148
+ # the remaining listeners — a broken handler must not derail the others or
149
+ # abort whatever Ruby drove the dispatch (a synthetic click from the host).
150
+ # The engine already swallows this for a JS-initiated dispatchEvent
151
+ # (HostBridge#invoke_callback, raising:false), but that has proven
152
+ # engine/Ruby-version-dependent — on some QuickJS/Ruby combinations a JS
153
+ # listener's throw surfaces as a Ruby exception here — so guard the
154
+ # host-initiated path too, mirroring MutationObserver#flush.
155
+ def invoke_listener_isolated(listener, event)
156
+ CallableInvoker.invoke_listener(listener, event)
157
+ rescue StandardError => e
158
+ __dommy_dump_event_failure__(event, listener, e) if ENV["DOMMY_EVENT_DEBUG"]
159
+ nil
160
+ end
161
+
162
+ # Diagnostic only (DOMMY_EVENT_DEBUG=<file>): when a listener throws, append
163
+ # the event type + error to a log file so an otherwise-unreproducible page
164
+ # failure can be traced to the handler that choked.
165
+ def __dommy_dump_event_failure__(event, listener, error)
166
+ path = ENV["DOMMY_EVENT_DEBUG"]
167
+ line = "=== event listener raised on #{event.type}: #{error.class}: #{error.message.to_s[0, 200]} " \
168
+ "(listener=#{listener.class})\n"
169
+ # `::File` — inside `module Dommy`, bare `File` is Dommy's DOM File.
170
+ ::File.write(path, line, mode: "a")
171
+ rescue StandardError
172
+ nil
173
+ end
174
+
141
175
  # The next target up the propagation path. The default (no parent) suits
142
176
  # EventTargets that aren't tree nodes (AbortSignal, XHR, …); Element /
143
177
  # Document / ShadowRoot override it to walk the node tree.
@@ -163,6 +197,17 @@ module Dommy
163
197
  def capture?
164
198
  EventTarget.capture_flag(options)
165
199
  end
200
+
201
+ # `{ passive: true }` — the listener promises not to call preventDefault,
202
+ # so the event's preventDefault() is neutralized while it runs.
203
+ def passive?
204
+ case options
205
+ when Hash
206
+ EventTarget.js_truthy?(options.key?("passive") ? options["passive"] : options[:passive])
207
+ else
208
+ false
209
+ end
210
+ end
166
211
  end
167
212
 
168
213
  # The capture flag for an addEventListener/removeEventListener options
@@ -184,6 +229,9 @@ module Dommy
184
229
  return false if value.nil? || value == false
185
230
  return false if defined?(Bridge::UNDEFINED) && value.equal?(Bridge::UNDEFINED)
186
231
  return false if value.is_a?(Numeric) && (value.zero? || (value.respond_to?(:nan?) && value.nan?))
232
+ # The bridge marshals JS NaN as the symbol :NaN (it has no Ruby Float
233
+ # equivalent that survives the round-trip), which ToBoolean treats as false.
234
+ return false if value == :NaN
187
235
  return false if value == ""
188
236
 
189
237
  true
@@ -286,6 +334,9 @@ module Dommy
286
334
  @default_prevented = false
287
335
  @propagation_stopped = false
288
336
  @immediate_propagation_stopped = false
337
+ # Set while a passive listener runs, so preventDefault() is a no-op (per
338
+ # the passive listener flag in the DOM spec).
339
+ @in_passive_listener = false
289
340
  @target = nil
290
341
  @current_target = nil
291
342
  @event_phase = NONE
@@ -322,6 +373,16 @@ module Dommy
322
373
  @immediate_propagation_stopped
323
374
  end
324
375
 
376
+ # Run a block with the passive-listener flag set, so any preventDefault()
377
+ # inside it is neutralized. Restores the prior flag afterward.
378
+ def __internal_run_passive__
379
+ previous = @in_passive_listener
380
+ @in_passive_listener = true
381
+ yield
382
+ ensure
383
+ @in_passive_listener = previous
384
+ end
385
+
325
386
  def __internal_prepare_for_dispatch__(target)
326
387
  @target ||= target
327
388
  end
@@ -373,14 +434,23 @@ module Dommy
373
434
  @time_stamp
374
435
  when "cancelBubble"
375
436
  @propagation_stopped
437
+ when "immediatePropagationStopped"
438
+ # Non-standard, but real frameworks feature-detect it: Stimulus's
439
+ # extendEvent does `"immediatePropagationStopped" in event` and, when
440
+ # present, reads the flag directly instead of installing its own
441
+ # tracking shim. Expose the live flag so that path stays correct (it
442
+ # also keeps `"immediatePropagationStopped" in event` from being a
443
+ # present-but-undefined no-op).
444
+ @immediate_propagation_stopped
376
445
  when "eventPhase"
377
446
  event_phase
378
447
  else
379
- # An unknown property reads back as JS `undefined`, not `null` — e.g. a
380
- # non-dictionary member passed to the constructor (`new Event("x", {sweet:
381
- # 1}).sweet`) is not reflected on the event. Genuinely-null DOM attributes
382
- # (target/currentTarget/…) are explicit cases above and still return nil.
383
- Bridge::UNDEFINED
448
+ # An unknown property is genuinely absent: JS `undefined` and
449
+ # `"x" in event` false — e.g. a non-dictionary member passed to the
450
+ # constructor (`new Event("x", {sweet: 1}).sweet`) is not reflected on the
451
+ # event. Genuinely-null DOM attributes (target/currentTarget/…) are
452
+ # explicit cases above and still return nil.
453
+ Bridge::ABSENT
384
454
  end
385
455
  end
386
456
 
@@ -406,7 +476,10 @@ module Dommy
406
476
  def __js_call__(method, args)
407
477
  case method
408
478
  when "preventDefault"
409
- @default_prevented = true if @cancelable
479
+ # A passive listener's preventDefault() is a no-op (DOM "passive listener
480
+ # flag"), so the default action proceeds — what Stimulus's :passive
481
+ # action option relies on.
482
+ @default_prevented = true if @cancelable && !@in_passive_listener
410
483
  nil
411
484
  when "stopPropagation"
412
485
  @propagation_stopped = true
@@ -531,6 +604,60 @@ module Dommy
531
604
  end
532
605
  end
533
606
 
607
+ # `ErrorEvent` — the event interface for an uncaught error (fired at
608
+ # `window.onerror`). Error-reporting code constructs it directly
609
+ # (`new ErrorEvent("error", { message, error, … })`); without the constructor
610
+ # that call is "not a function" and takes the whole app down before it renders
611
+ # (x.com's client bootstrap does exactly this).
612
+ class ErrorEvent < Event
613
+ attr_reader :message, :filename, :lineno, :colno, :error
614
+
615
+ def initialize(type, init = nil)
616
+ super
617
+ @message = read_init(init, "message") || ""
618
+ @filename = read_init(init, "filename") || ""
619
+ @lineno = read_init(init, "lineno") || 0
620
+ @colno = read_init(init, "colno") || 0
621
+ @error = read_init(init, "error")
622
+ end
623
+
624
+ def __js_get__(key)
625
+ case key
626
+ when "message" then @message
627
+ when "filename" then @filename
628
+ when "lineno" then @lineno
629
+ when "colno" then @colno
630
+ when "error" then @error
631
+ else super
632
+ end
633
+ end
634
+ end
635
+
636
+ # `PromiseRejectionEvent` — fired as `unhandledrejection` / `rejectionhandled`
637
+ # when a promise rejects with no handler (`event.promise` / `event.reason`).
638
+ # Critically, its mere existence is what Promise feature-detection (core-js
639
+ # et al.) checks: without it, libraries decide the native Promise is
640
+ # incomplete and swap in a polyfill whose microtask queue the host scheduler
641
+ # cannot flush — silently starving every `.then`/await and hanging SPA
642
+ # hydration (this is why note.com rendered only a shell).
643
+ class PromiseRejectionEvent < Event
644
+ attr_reader :promise, :reason
645
+
646
+ def initialize(type, init = nil)
647
+ super
648
+ @promise = read_init(init, "promise")
649
+ @reason = read_init(init, "reason")
650
+ end
651
+
652
+ def __js_get__(key)
653
+ case key
654
+ when "promise" then @promise
655
+ when "reason" then @reason
656
+ else super
657
+ end
658
+ end
659
+ end
660
+
534
661
  class MouseEvent < Event
535
662
  def initialize(type, init = nil)
536
663
  super
@@ -771,6 +898,8 @@ module Dommy
771
898
  @rotation_angle
772
899
  when "force"
773
900
  @force
901
+ else
902
+ Bridge::ABSENT
774
903
  end
775
904
  end
776
905
  end
@@ -1028,8 +1157,18 @@ module Dommy
1028
1157
  return composite
1029
1158
  end
1030
1159
 
1160
+ composite.__internal_make_dependent__
1031
1161
  list.each do |sig|
1032
- sig.add_event_listener("abort", proc { composite.__internal_mark_aborted__(sig.reason) })
1162
+ # A plain source signal links directly; a composite is flattened to its
1163
+ # own (already-flat) source signals, so the dependency graph stays one
1164
+ # level deep and abort order is well-defined.
1165
+ sources = sig.__internal_dependent__? ? sig.__internal_source_signals__ : [sig]
1166
+ sources.each do |source|
1167
+ next if composite.__internal_source_signals__.include?(source)
1168
+
1169
+ composite.__internal_add_source__(source)
1170
+ source.__internal_add_dependent__(composite)
1171
+ end
1033
1172
  end
1034
1173
 
1035
1174
  composite
@@ -1038,6 +1177,17 @@ module Dommy
1038
1177
  def initialize
1039
1178
  @aborted = false
1040
1179
  @reason = nil
1180
+ # The WHATWG "dependent signal" model backing AbortSignal.any: a composite
1181
+ # signal flattens to its original (non-dependent) source signals, and each
1182
+ # source holds an ordered list of the composites depending on it — so abort
1183
+ # propagation fires in source-then-dependents order, not via event chaining.
1184
+ @dependent = false
1185
+ @source_signals = []
1186
+ @dependent_signals = []
1187
+ # WHATWG "abort algorithms": callbacks run synchronously during abort,
1188
+ # BEFORE the "abort" event — so a consumer (e.g. an Observable subscription)
1189
+ # can tear down ahead of any externally-registered abort listener.
1190
+ @abort_algorithms = []
1041
1191
  end
1042
1192
 
1043
1193
  # Background-thread timeout used by `AbortSignal.timeout` when no
@@ -1084,6 +1234,8 @@ module Dommy
1084
1234
  @aborted ? @reason : Bridge::UNDEFINED
1085
1235
  when "onabort"
1086
1236
  @onabort_handler
1237
+ else
1238
+ Bridge::ABSENT
1087
1239
  end
1088
1240
  end
1089
1241
 
@@ -1101,7 +1253,7 @@ module Dommy
1101
1253
  end
1102
1254
 
1103
1255
  include Bridge::Methods
1104
- js_methods %w[addEventListener removeEventListener dispatchEvent throwIfAborted]
1256
+ js_methods %w[addEventListener removeEventListener dispatchEvent throwIfAborted __internalAddAbortAlgorithm]
1105
1257
  def __js_call__(method, args)
1106
1258
  case method
1107
1259
  when "addEventListener"
@@ -1112,22 +1264,101 @@ module Dommy
1112
1264
  dispatch_event(args[0])
1113
1265
  when "throwIfAborted"
1114
1266
  throw_if_aborted
1267
+ when "__internalAddAbortAlgorithm"
1268
+ __internal_add_abort_algorithm__(args[0])
1115
1269
  end
1116
1270
  end
1117
1271
 
1272
+ # Register an abort algorithm (WHATWG "add an algorithm to a signal"): it runs
1273
+ # synchronously when the signal aborts, before the "abort" event. A no-op once
1274
+ # already aborted — the caller handles that case itself. Exposed to the JS
1275
+ # Observable polyfill so a subscription's consumer-abort fires ahead of any
1276
+ # external abort listener (the spec's downstream-before-upstream ordering).
1277
+ def __internal_add_abort_algorithm__(callback)
1278
+ @abort_algorithms << callback unless @aborted
1279
+ nil
1280
+ end
1281
+
1118
1282
  # Sentinel meaning "no reason argument was supplied" — distinct from an
1119
1283
  # explicit `null` reason (`abort(null)` keeps reason null, but `abort()` /
1120
1284
  # `abort(undefined)` default to a fresh AbortError).
1121
1285
  NO_REASON = Object.new
1122
1286
 
1287
+ # WHATWG "signal abort": set the reason, then propagate to dependent signals
1288
+ # — firing "abort" at this signal FIRST and then at each dependent in the
1289
+ # order it was registered (the reasons are all stamped before any event
1290
+ # fires, so a handler observing a sibling sees it already aborted).
1123
1291
  def __internal_mark_aborted__(reason = NO_REASON)
1124
1292
  return if @aborted
1125
1293
 
1126
1294
  @aborted = true
1127
1295
  no_reason = reason.equal?(NO_REASON) || (defined?(Bridge::UNDEFINED) && reason.equal?(Bridge::UNDEFINED))
1128
1296
  @reason = no_reason ? DOMException::AbortError.new("signal is aborted without reason") : reason
1297
+
1298
+ dependents_to_abort = []
1299
+ @dependent_signals.each do |dependent|
1300
+ next if dependent.aborted?
1301
+
1302
+ dependent.__internal_set_reason__(@reason)
1303
+ dependents_to_abort << dependent
1304
+ end
1305
+
1306
+ __internal_run_abort_steps__
1307
+ dependents_to_abort.each(&:__internal_run_abort_steps__)
1308
+ end
1309
+
1310
+ # ----- dependent-signal plumbing (package-private, used by .any) -----
1311
+
1312
+ def __internal_make_dependent__
1313
+ @dependent = true
1314
+ end
1315
+
1316
+ def __internal_dependent__?
1317
+ @dependent
1318
+ end
1319
+
1320
+ def __internal_source_signals__
1321
+ @source_signals
1322
+ end
1323
+
1324
+ def __internal_add_source__(source)
1325
+ @source_signals << source
1326
+ end
1327
+
1328
+ def __internal_add_dependent__(dependent)
1329
+ @dependent_signals << dependent
1330
+ end
1331
+
1332
+ # Mark aborted with a reason WITHOUT firing — the orchestrating source stamps
1333
+ # every dependent's reason up front, then fires the events in order.
1334
+ def __internal_set_reason__(reason)
1335
+ @aborted = true
1336
+ @reason = reason
1337
+ end
1338
+
1339
+ # The spec's "abort steps": run the registered abort algorithms first
1340
+ # (synchronously, ahead of any listener), then fire the trusted "abort" event.
1341
+ def __internal_run_abort_steps__
1342
+ algorithms = @abort_algorithms
1343
+ @abort_algorithms = []
1344
+ algorithms.each { |algo| invoke_abort_algorithm(algo) }
1129
1345
  dispatch_event(Event.new("abort", "bubbles" => false, "cancelable" => false).__internal_mark_trusted__)
1130
1346
  end
1347
+
1348
+ # Run one abort algorithm (a JS callback over the bridge, or a Ruby proc).
1349
+ # A throw is swallowed deliberately: WHATWG runs every abort algorithm and
1350
+ # then fires the "abort" event regardless, so one algorithm's failure must
1351
+ # not abort the run. (The sole caller — the Observable polyfill — already
1352
+ # catches inside its own teardown, so this is a defensive backstop.)
1353
+ def invoke_abort_algorithm(algo)
1354
+ if algo.respond_to?(:__js_call__)
1355
+ algo.__js_call__("call", [])
1356
+ elsif algo.respond_to?(:call)
1357
+ algo.call
1358
+ end
1359
+ rescue StandardError
1360
+ nil
1361
+ end
1131
1362
  end
1132
1363
 
1133
1364
  class AbortController
data/lib/dommy/fetch.rb CHANGED
@@ -2,17 +2,26 @@
2
2
 
3
3
  require "json"
4
4
  require "securerandom"
5
+ require_relative "data_uri"
5
6
 
6
7
  module Dommy
7
- # `fetch` polyfill. No real network — instead consults
8
- # `JS.global[:__fetchy_stub__]` (a Hash{url => entry}) installed by
9
- # the test. Mirrors the same fixture protocol that `test_fetchy.rb`'s
10
- # JavaScript installer uses, so tests don't need a JS engine to drive
11
- # the stub.
8
+ # `fetch` polyfill. No real network — instead resolves a response
9
+ # *entry* and synthesizes a Response from it. Entries come from, in
10
+ # order:
12
11
  #
13
- # Each entry in the stub hash supports:
12
+ # 1. `window.globals["__fetch_handler__"]` a callable
13
+ # `call(url, init) -> entry-or-nil`. This is the seam host
14
+ # environments use to serve real requests (e.g. dommy-rack's
15
+ # NetworkBridge routes same-origin URLs to the Rack app).
16
+ # Returning nil falls through to the stub maps.
17
+ # 2. `JS.global[:__fetchy_stub__]` (a Hash{url => entry})
18
+ # installed by the test. Mirrors the same fixture protocol that
19
+ # `test_fetchy.rb`'s JavaScript installer uses, so tests don't
20
+ # need a JS engine to drive the stub.
21
+ #
22
+ # Each entry supports:
14
23
  # "status" / "statusText" / "body" / "contentType" /
15
- # "headers" (Hash) / "delay" (ms)
24
+ # "headers" (Hash) / "url" / "redirected" / "delay" (ms)
16
25
  # plus AbortController signal propagation when `init[:signal]` is
17
26
  # passed.
18
27
  class FetchFn
@@ -24,17 +33,12 @@ module Dommy
24
33
  # `__js_call__("fetch", ...)` or as a callable handle. Both routes
25
34
  # delegate to `call(args)` so behavior is identical.
26
35
  def __js_call__(_method, args)
27
- url = args[0].to_s
36
+ # Per Fetch, the request URL is resolved against the document base URL up
37
+ # front; the handler, stub maps, and response.url all see the absolute
38
+ # URL (no per-handler resolution).
39
+ url = @window.__internal_resolve_url__(args[0].to_s)
28
40
  init = normalize_init(args[1] || {})
29
41
 
30
- # Each spec file installs its stub under its own global name.
31
- # `test_fetchy.rb` uses `__fetchy_stub__`; `test_resource*.rb`
32
- # use `__resource_fetch_stub__` and `__inject_fetch_stub__`.
33
- # Check them in order — only one should be set at a time.
34
- stub_map = @window.globals["__fetchy_stub__"] ||
35
- @window.globals["__resource_fetch_stub__"] ||
36
- @window.globals["__inject_fetch_stub__"] ||
37
- {}
38
42
  # `js_eval`'s JS installer increments these globals; mirror so
39
43
  # specs that probe `__fetch_count__` / `__last_url__` / etc.
40
44
  # observe the same state shape they'd see from a real injector.
@@ -43,13 +47,43 @@ module Dommy
43
47
  @window.globals["__last_init__"] = init
44
48
  @window.globals["__last_body__"] = init["body"] if init.is_a?(Hash)
45
49
 
46
- entry = stub_map[url] if stub_map.is_a?(Hash)
47
50
  promise = PromiseValue.new(@window)
51
+ result = resolve_entry(url, init)
52
+ # A handler may answer asynchronously (live network off-thread): it returns
53
+ # a deferred whose response arrives later and is applied on the page thread
54
+ # (via the scheduler inbox). The sync path (stubs / cache / data:) resolves
55
+ # the promise inline, exactly as before.
56
+ if result.respond_to?(:on_complete)
57
+ result.on_complete { |entry| fulfill_from_entry(promise, entry, url, init) }
58
+ else
59
+ fulfill_from_entry(promise, result, url, init)
60
+ end
61
+ promise
62
+ end
63
+
64
+ private
48
65
 
66
+ # Resolve `promise` from a response entry (nil -> 404), honoring a simulated
67
+ # `delay`. Used both for a synchronous entry and for an async one delivered
68
+ # later on the page thread.
69
+ def fulfill_from_entry(promise, entry, url, init)
70
+ # WHATWG: a fetch is resolved by a networking *task*, never inline during
71
+ # the fetch() call — so the initiating script runs to completion and its
72
+ # microtask checkpoint happens first. Defer the fulfillment onto the
73
+ # scheduler as a task so the synchronous data path gets the same event-loop
74
+ # semantics as the async DeferredResponse path. Without a scheduler (rare
75
+ # embedder), fall back to inline.
76
+ if (sched = @window.respond_to?(:scheduler) ? @window.scheduler : nil)
77
+ sched.set_timeout(proc { deliver_entry(promise, entry, url, init) }, 0)
78
+ else
79
+ deliver_entry(promise, entry, url, init)
80
+ end
81
+ end
82
+
83
+ def deliver_entry(promise, entry, url, init)
49
84
  if entry.nil?
50
- response = Response.new(@window, body: "not found", status: 404, status_text: "Not Found", type: "basic")
51
- promise.fulfill(response)
52
- return promise
85
+ promise.fulfill(Response.new(@window, body: "not found", status: 404, status_text: "Not Found", type: "basic"))
86
+ return
53
87
  end
54
88
 
55
89
  body = entry["body"]
@@ -58,14 +92,15 @@ module Dommy
58
92
  content_type = entry["contentType"] || "text/plain"
59
93
  headers = entry["headers"] || {"Content-Type" => content_type}
60
94
  # Simulate a followed redirect: `[:url]` overrides the response URL (the
61
- # final location) and `[:redirected]` flags it, so consumers that branch
62
- # on `response.redirected` / `response.url` (e.g. Turbo updating history to
63
- # the redirected location) see a realistic response.
95
+ # final location) and `[:redirected]` flags it, so consumers that branch on
96
+ # `response.redirected` / `response.url` (e.g. Turbo updating history to the
97
+ # redirected location) see a realistic response.
64
98
  response_url = entry["url"] || url
65
99
  redirected = entry["redirected"] ? true : false
66
100
 
67
- delay = entry["delay"]
68
- if delay
101
+ __dommy_dump_fetch__(url, init, status, headers, body) if ENV["DOMMY_FETCH_DEBUG"]
102
+
103
+ if (delay = entry["delay"])
69
104
  install_delayed_resolve(promise, body, status, status_text, headers, init, delay)
70
105
  else
71
106
  promise.fulfill(
@@ -73,11 +108,36 @@ module Dommy
73
108
  headers: headers, url: response_url, redirected: redirected, type: "basic")
74
109
  )
75
110
  end
76
-
77
- promise
78
111
  end
79
112
 
80
- private
113
+ # Resolve the response entry for a request: a `__fetch_handler__`
114
+ # callable gets first refusal; a nil from it (or no handler) falls
115
+ # through to the stub maps. Each spec file installs its stub under
116
+ # its own global name — `test_fetchy.rb` uses `__fetchy_stub__`;
117
+ # `test_resource*.rb` use `__resource_fetch_stub__` and
118
+ # `__inject_fetch_stub__`. Checked in order; only one should be set
119
+ # at a time.
120
+ def resolve_entry(url, init)
121
+ if (decoded = DataUri.parse(url))
122
+ return {"body" => decoded[:body], "status" => 200, "statusText" => "OK",
123
+ "contentType" => decoded[:content_type]}
124
+ end
125
+
126
+ handler = @window.globals["__fetch_handler__"]
127
+ if handler.respond_to?(:call)
128
+ entry = handler.call(url, init)
129
+ return entry if entry
130
+ end
131
+
132
+ stub_map = @window.globals["__fetchy_stub__"] ||
133
+ @window.globals["__resource_fetch_stub__"] ||
134
+ @window.globals["__inject_fetch_stub__"]
135
+ return nil unless stub_map.is_a?(Hash)
136
+
137
+ # The URL is now absolute; a stub keyed by a path ("/api") still matches
138
+ # its resolved form ("http://host/api").
139
+ stub_map[url] || stub_map[@window.__internal_url_path__(url)]
140
+ end
81
141
 
82
142
  # Coerce `init` into a Hash with string keys so the rest of the
83
143
  # pipeline (and the `__last_init__` globals) sees a uniform shape.
@@ -97,6 +157,33 @@ module Dommy
97
157
  h
98
158
  end
99
159
 
160
+ # Diagnostic only (DOMMY_FETCH_DEBUG=<file>): append a record of what the
161
+ # page's fetch() received, so a response that an app's data layer can't
162
+ # consume (e.g. Apollo's "link chain completed without emitting a value",
163
+ # #95) can be traced to the actual request/response bytes Dommy handed it.
164
+ # Every fetch is logged compactly; a GraphQL request also dumps full bodies
165
+ # (that is where the emission breaks), which is the exchange we need to see.
166
+ def __dommy_dump_fetch__(url, init, status, headers, body)
167
+ path = ENV["DOMMY_FETCH_DEBUG"]
168
+ graphql = url.include?("graphql")
169
+ req_headers = init.is_a?(Hash) ? init["headers"] : nil
170
+ req_body = init.is_a?(Hash) ? init["body"] : nil
171
+ content_type = headers.is_a?(Hash) ? headers.find { |k, _| k.to_s.downcase == "content-type" }&.last : nil
172
+ method = (init.is_a?(Hash) ? (init["method"] || "GET") : "GET").to_s.upcase
173
+ cap = graphql ? 8000 : 300
174
+
175
+ lines = ["=== fetch #{graphql ? "[graphql] " : ""}#{method} #{url}"]
176
+ lines << "> req-headers: #{req_headers.inspect}" if graphql && req_headers
177
+ lines << "> req-body: #{req_body.to_s[0, cap]}" if req_body
178
+ lines << "< #{status} #{content_type} (#{body.to_s.bytesize} bytes)"
179
+ lines << "< res-headers: #{headers.inspect}" if graphql
180
+ lines << "< res-body: #{body.to_s[0, cap]}"
181
+ # `::File` — inside `module Dommy`, bare `File` resolves to Dommy's DOM File.
182
+ ::File.write(path, "#{lines.join("\n")}\n\n", mode: "a")
183
+ rescue StandardError
184
+ nil
185
+ end
186
+
100
187
  def install_delayed_resolve(promise, body, status, status_text, headers, init, delay_ms)
101
188
  # AbortController cancellation: when init.signal is present and
102
189
  # `.abort()` fires before the timer, reject with an AbortError.
@@ -146,9 +233,16 @@ module Dommy
146
233
  @mode = (opts["mode"] || opts[:mode] || "cors").to_s
147
234
  @cache = (opts["cache"] || opts[:cache] || "default").to_s
148
235
  @redirect = (opts["redirect"] || opts[:redirect] || "follow").to_s
236
+ # WHATWG: a Request ALWAYS has an associated signal (an AbortSignal). Use a
237
+ # provided signal when present (so `request.signal` is the caller's own
238
+ # controller signal — react-router add/removeEventListener's it directly),
239
+ # else a fresh, never-aborted one. Never undefined, or consumers that read
240
+ # `request.signal.removeEventListener` crash.
241
+ sig = opts["signal"] || opts[:signal]
242
+ @signal = sig.respond_to?(:__js_call__) ? sig : AbortSignal.new
149
243
  end
150
244
 
151
- attr_reader :headers, :credentials, :mode, :cache, :redirect
245
+ attr_reader :headers, :credentials, :mode, :cache, :redirect, :signal
152
246
 
153
247
  def __js_get__(key)
154
248
  case key
@@ -168,6 +262,10 @@ module Dommy
168
262
  @cache
169
263
  when "redirect"
170
264
  @redirect
265
+ when "signal"
266
+ @signal
267
+ else
268
+ Bridge::ABSENT
171
269
  end
172
270
  end
173
271
 
@@ -184,7 +282,8 @@ module Dommy
184
282
  "credentials" => @credentials,
185
283
  "mode" => @mode,
186
284
  "cache" => @cache,
187
- "redirect" => @redirect
285
+ "redirect" => @redirect,
286
+ "signal" => @signal
188
287
  )
189
288
  end
190
289
  end
@@ -202,13 +301,20 @@ module Dommy
202
301
  # Redirect statuses accepted by `Response.redirect(url, status)`.
203
302
  REDIRECT_STATUSES = [301, 302, 303, 307, 308].freeze
204
303
 
304
+ # Forbidden response-header names (WHATWG Fetch): never exposed on a
305
+ # Response's Headers. `Set-Cookie` is handled by the network layer's cookie
306
+ # jar, not JS — and a real server often sends MULTIPLE Set-Cookie headers
307
+ # folded into one newline-joined value, which is an invalid Headers value and
308
+ # used to crash Response construction (e.g. doubleclick's IDE+test_cookie).
309
+ FORBIDDEN_RESPONSE_HEADERS = %w[set-cookie set-cookie2].freeze
310
+
205
311
  def initialize(window, body:, status: 200, status_text: "", headers: nil, url: "",
206
312
  redirected: false, type: "default", has_body: true)
207
313
  @window = window
208
314
  @body = body.to_s
209
315
  @status = status
210
316
  @status_text = status_text.to_s
211
- @headers = Headers.new(headers || {})
317
+ @headers = Headers.new(strip_forbidden_headers(headers))
212
318
  @url = url.to_s
213
319
  @redirected = redirected ? true : false
214
320
  @type = type
@@ -217,6 +323,16 @@ module Dommy
217
323
  @body_stream = nil
218
324
  end
219
325
 
326
+ # Drop forbidden response headers (Set-Cookie/Set-Cookie2) before they reach
327
+ # the Headers object. Only a Hash (the network path) carries them; a Headers
328
+ # or nil passes through unchanged.
329
+ def strip_forbidden_headers(headers)
330
+ return {} if headers.nil?
331
+ return headers unless headers.is_a?(Hash)
332
+
333
+ headers.reject { |name, _| FORBIDDEN_RESPONSE_HEADERS.include?(name.to_s.downcase) }
334
+ end
335
+
220
336
  # WHATWG `new Response(body, init)`. Validates the status (200–599, else a
221
337
  # RangeError; a null-body status 204/205/304 with a body is a TypeError),
222
338
  # defaults statusText to "" and status to 200, accepts `init.headers` as a
@@ -414,6 +530,8 @@ module Dommy
414
530
  body_stream
415
531
  when "bodyUsed"
416
532
  body_used?
533
+ else
534
+ Bridge::ABSENT
417
535
  end
418
536
  end
419
537
 
@@ -622,7 +740,7 @@ module Dommy
622
740
  def fill(init)
623
741
  case init
624
742
  when Headers
625
- init.__raw_pairs__.each { |name, value| append_value(name, value) }
743
+ init.__internal_raw_pairs__.each { |name, value| append_value(name, value) }
626
744
  when Array
627
745
  init.each do |pair|
628
746
  unless pair.is_a?(Array) && pair.length == 2
@@ -639,7 +757,7 @@ module Dommy
639
757
 
640
758
  # Internal: a copy of the raw [name, value] pairs — lets one Headers be
641
759
  # filled from another without losing duplicates or split Set-Cookie values.
642
- def __raw_pairs__
760
+ def __internal_raw_pairs__
643
761
  @list.map(&:dup)
644
762
  end
645
763
 
@@ -653,7 +771,7 @@ module Dommy
653
771
  end
654
772
 
655
773
  def __js_get__(_key)
656
- nil
774
+ Bridge::ABSENT # Headers exposes only methods; any property read is absent
657
775
  end
658
776
 
659
777
  def __js_set__(_key, _value)
@@ -105,6 +105,8 @@ module Dommy
105
105
  case key
106
106
  when "size", "length"
107
107
  size
108
+ else
109
+ Bridge::ABSENT
108
110
  end
109
111
  end
110
112