capybara-simulated 0.6.0 → 0.8.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.
@@ -9,11 +9,13 @@ require 'net/http'
9
9
  require 'openssl'
10
10
  require 'rack/mock'
11
11
  require 'securerandom'
12
+ require 'set'
12
13
  require 'socket'
13
14
  require 'thread'
14
15
  require 'time'
15
16
  require 'uri'
16
17
  require 'uri/idna' # WHATWG/UTS46 domain-to-ASCII/Unicode (uri-idna gem)
18
+ require 'zlib'
17
19
  require_relative 'asset_cache'
18
20
  require_relative 'errors'
19
21
  require_relative 'stack_resolver'
@@ -54,6 +56,18 @@ module Capybara
54
56
  # `Last-Modified` per RFC 9111.
55
57
  @@asset_cache = AssetCache.new
56
58
 
59
+ # Opt-in: capture each request's author header names verbatim on the Rack env
60
+ # (`csim.raw_request_headers`) so the WPT .py-handler harness can replay them with
61
+ # exact casing / token chars (inspect-headers / echo-headers). OFF for real app
62
+ # traffic — nothing there consumes the list, so it would only allocate per request.
63
+ @@capture_raw_request_headers = false
64
+ def self.capture_raw_request_headers
65
+ @@capture_raw_request_headers
66
+ end
67
+ def self.capture_raw_request_headers=(v)
68
+ @@capture_raw_request_headers = v
69
+ end
70
+
57
71
  attr_writer :timers_active
58
72
 
59
73
  # The Driver's handle for the window this Browser backs (set right after
@@ -182,7 +196,7 @@ module Capybara
182
196
  Rack::Mime.mime_type(File.extname(path.to_s), '')
183
197
  end
184
198
 
185
- def initialize(app, driver: nil, js_engine: nil, cookies: nil, local_storage: nil, all_hosts_local: nil)
199
+ def initialize(app, driver: nil, js_engine: nil, cookies: nil, auth_cache: nil, local_storage: nil, cache_storage: nil, all_hosts_local: nil)
186
200
  @app = app
187
201
  @driver = driver
188
202
  @all_hosts_local_override = all_hosts_local
@@ -225,7 +239,16 @@ module Capybara
225
239
  # see the same auth state and storage as the primary. Tests
226
240
  # without a Driver (gem-internal callers) get fresh jars.
227
241
  @cookies = cookies || {}
242
+ # HTTP Basic-auth credential cache, keyed by target origin: once credentials authenticate an
243
+ # origin, the UA sends them pre-emptively for later credentialed requests to it (RFC 7617
244
+ # §2.2), so a Basic-auth resource loads without re-challenging. Session-scoped (cleared on
245
+ # reset) and Driver-injected like the cookie jar, so target=_blank aux windows share one
246
+ # session's auth state (a real browser shares the HTTP auth cache across a session's tabs).
247
+ @auth_cache = auth_cache || {}
228
248
  @local_storage = local_storage || {}
249
+ # Cache Storage is origin-shared like localStorage (the Driver owns the store
250
+ # and injects it into every window Browser), origin-partitioned within.
251
+ @cache_storage = cache_storage || {}
229
252
  @session_storage = {}
230
253
  @sticky_headers = {}
231
254
  @timers_active = false
@@ -265,12 +288,16 @@ module Capybara
265
288
  @current_realm_id = nil
266
289
  @frame_stack = []
267
290
  @last_tick_ts = Process.clock_gettime(Process::CLOCK_MONOTONIC)
291
+ # The first find of a navigation observes the current DOM without a pre-tick (see
292
+ # timer_wait_elapsed?); armed by the first find, disarmed by reset_timer_state.
293
+ @pre_tick_armed = false
268
294
  @polling_grace = nil
269
295
  @last_polled_gen = nil
270
296
  @idle_settle_polls = 0
271
297
  @ticking = false
272
298
  @history = []
273
299
  @history_idx = -1
300
+ @cors_preflight_cache = {}
274
301
  @modal_handlers = []
275
302
  # Geolocation override (CDP-ish). nil = no override configured →
276
303
  # navigator.geolocation reports POSITION_UNAVAILABLE. Ruby-backed so
@@ -317,6 +344,13 @@ module Capybara
317
344
  @websocket_sockets = {} # id → csim's socket end (main thread owns this hash)
318
345
  @websocket_app_sockets = {} # id → the app's hijack end (closed on teardown)
319
346
  @websocket_queue = Thread::Queue.new
347
+ @websocket_queue_head = nil # one-slot buffer for an event hold_for_ws_close parked ahead of the queue
348
+ # In-flight `ws.close()` handshakes: hold the virtual clock until the reader surfaces the
349
+ # server's echoed close frame (a `__close` event) so a test awaiting `onclose` can't have
350
+ # its virtual-timeout outrun that real off-thread reply. Counted in ws_close, cleared as
351
+ # deliver_websocket_events delivers each terminal event. See hold_for_ws_close.
352
+ @ws_close_pending = 0
353
+ @ws_close_wait_deadline = nil
320
354
  # All frame writes (the reader thread's pong replies + the main thread's
321
355
  # send/close) go through one socket; serialise them so two threads can't
322
356
  # interleave bytes into a corrupt frame.
@@ -340,10 +374,73 @@ module Capybara
340
374
  @worker_seq = 0
341
375
  @workers = {}
342
376
  @worker_outbox = Thread::Queue.new
377
+ # One-slot head buffer for the settle wait: an event popped while blocking on the outbox
378
+ # is parked here (a push-back would reorder it behind concurrent worker pushes) and
379
+ # consumed first by the next deliver_worker_messages.
380
+ @worker_outbox_head = nil
343
381
  # Outstanding posts-to-worker; `polling?` stays true while > 0
344
382
  # so long-running compute (e.g. mozjpeg over an 8900×8900 frame)
345
383
  # isn't starved by the settle_gen idle gate.
346
384
  @worker_in_flight = 0
385
+ # BroadcastChannel posts pushed to worker inboxes that the worker hasn't yet processed. Kept
386
+ # SEPARATE from @worker_in_flight: a broadcast is fire-and-forget (a listen-only worker never
387
+ # replies), so it must not be "answered" by an unrelated postMessage reply. The worker acks
388
+ # each broadcast it delivers (a `bcack` outbox event), which decrements this; `worker_pending?`
389
+ # stays true until then so settle waits for the delivery.
390
+ @worker_broadcast_pending = 0
391
+ # Client → service-worker messages awaiting the worker's ack (it processed the inbound
392
+ # `message`). A SW `postMessage` produces no 1:1 reply (the SW replies via client.postMessage,
393
+ # a separate outbox event), so — like broadcasts — it needs its own pending tally, or a
394
+ # listen-only SW would leave settle perpetually non-idle.
395
+ @sw_message_pending = 0
396
+ # Controlled-client fetches awaiting the SW's respondWith (released by a `fetch_response`).
397
+ @sw_fetch_pending = 0
398
+ # Streaming respondWith bodies still open (head delivered, terminal frame not yet), keyed by
399
+ # the emitting worker handle → the [realm_id, fetch_id] frames it opened. Lets worker_terminate
400
+ # release + error a stream its worker died mid-flight, instead of stranding @sw_fetch_pending.
401
+ @sw_open_streams = Hash.new {|h, k| h[k] = {} }
402
+ # Deadline (CLOCK_MONOTONIC) capping how long the event-loop drain holds the virtual clock
403
+ # for an outstanding SW-side fetch (see run_event_loop_frame). Shared across frames so a
404
+ # stuck fetch costs the budget ONCE, not per frame; reset on each delivered reply so the next
405
+ # fetch in a sequence waits afresh.
406
+ @sw_fetch_wait_deadline = nil
407
+ # Same budget for a pending SW message swack / broadcast ack (drain_pending_message_reply);
408
+ # separate from the fetch deadline so a message wait and a fetch hold don't share a spent
409
+ # budget. Reset once no message/broadcast reply is outstanding.
410
+ @sw_msg_wait_deadline = nil
411
+ # SW navigation-interception state. `@sw_registrations` mirrors scope-href → active
412
+ # worker handle (from the client lifecycle) so a navigation fetched Ruby-side — before
413
+ # the destination realm's JS exists — can find its controlling SW; it survives the
414
+ # per-visit rebuild_ctx (unlike the per-realm JS registrations Map). A navigation fetch
415
+ # is awaited SYNCHRONOUSLY on `@sw_nav_outbox` (a dedicated queue, off the general outbox)
416
+ # keyed by a NEGATIVE `@sw_nav_seq` id so it never mixes with client-fetch replies.
417
+ @sw_registrations = {}
418
+ # Navigation Preload state, per active-worker HANDLE (the registration's active worker — the
419
+ # client's `registration.active._handle` and the worker's own `__csimWorkerHandle` are the
420
+ # same id, so both isolates key here identically). {enabled:, header:}; absent → the spec
421
+ # default {false, 'true'}. Read at navigation time to decide whether to issue the parallel
422
+ # preload request (see service_worker_navigation_fetch), and by the NavigationPreloadManager.
423
+ # EARNED GAP: the spec keeps this per-REGISTRATION (it survives a SW update); keying by the
424
+ # active worker's handle means an update — which mints a fresh handle — resets it to default.
425
+ # No vendored subtest enables preload then updates the worker, so handle-keying (which needs no
426
+ # scope plumbing to the worker isolate) is the simpler load-bearing choice.
427
+ @sw_navpreload = {}
428
+ # clients.claim() events that arrived before their scope was mirrored into @sw_registrations
429
+ # (activate→claim() races the client-side lifecycle) — buffered here, flushed by sw_register_scope.
430
+ @sw_pending_claims = []
431
+ @sw_nav_outbox = Thread::Queue.new
432
+ @sw_nav_seq = 0
433
+ # Service-worker Client registry: realm id → {handle, rec} for every
434
+ # controlled frame/window client, mirrored into the SW's clientsById so
435
+ # matchAll / getClientByURL see the real set. `@sw_realm_controller` records
436
+ # each realm's controller so an opaque child (about:blank / srcdoc) inherits
437
+ # it. Both keyed by realm id, cleared when the last worker exits.
438
+ @sw_clients = {}
439
+ @sw_realm_controller = {}
440
+ # Cross-isolate MessagePort channels: channel id → {realm:, sw:} endpoints. A port
441
+ # transferred between a client realm and a worker/SW isolate registers both ends here;
442
+ # the browser relays each side's postMessage to the other. Cleared with the workers.
443
+ @port_channels = {}
347
444
  # Workers whose initial script hasn't finished running yet. A worker that
348
445
  # posts immediately on spawn (no main->worker message first) would leave
349
446
  # `@worker_in_flight` at 0, so `worker_pending?` would be false in the gap
@@ -351,6 +448,16 @@ module Capybara
351
448
  # stop waiting before the message lands. Count spawned-but-not-initialised
352
449
  # workers so the async drain holds until the initial script has run.
353
450
  @worker_initializing = 0
451
+ # Worker threads actively PROCESSING a plain postMessage (dequeued, not yet back at
452
+ # the idle poll). `@worker_in_flight` counts posted messages minus delivered replies,
453
+ # but one request yields MANY replies (progress updates + the final resolve), so it
454
+ # under-counts to 0 mid-handshake and `worker_pending?` would go false while the worker
455
+ # is still working — settle then breaks and abandons a multi-round protocol
456
+ # (Tesseract's createWorker load→loadLanguage→initialize→recognize, each a round-trip
457
+ # gated on a slow synchronous WASM step). The SW / broadcast message kinds have their
458
+ # own pending counters, so only the postMessage branch bumps this. Held under
459
+ # @worker_init_lock alongside @worker_initializing.
460
+ @worker_busy = 0
354
461
  @worker_init_lock = Mutex.new
355
462
  # Cross-isolate `blob:` store. Worker isolates can't see the
356
463
  # main scope's `__csimBlobs` Map, so we mirror bytes here and
@@ -367,6 +474,10 @@ module Capybara
367
474
  @transfer_buffer_lock = Mutex.new
368
475
  @transfer_buffers = {}
369
476
  @transfer_buffer_seq = 0
477
+ # Per-font ascent/descent probe cache for canvas text (render_text is
478
+ # worker-reachable via OffscreenCanvas, like decode_image).
479
+ @font_vmetrics_lock = Mutex.new
480
+ @font_vmetrics = {}
370
481
  # Zero-copy postMessage transfer tokens (rusty_racer >= 0.1.6
371
482
  # `RustyRacer.transferOut`): a buffer in a `postMessage` transfer list
372
483
  # crosses isolates by token (no byte copy), its source detached. A token
@@ -384,13 +495,52 @@ module Capybara
384
495
  # Cross-window BroadcastChannel messages from OTHER windows, delivered to
385
496
  # this window's matching channels on settle. [{name, data}] (same thread).
386
497
  @broadcast_inbox = []
387
- end
498
+ # Storage `storage` events queued for the OTHER same-origin documents (a change
499
+ # fires at every same-origin document EXCEPT the one that made it), delivered on
500
+ # settle. [{kind, key, old, new, url, source}] (same thread).
501
+ @storage_inbox = []
502
+ # BroadcastChannel isolate-wide registry + global ordered delivery queue (the multi-realm
503
+ # path — see broadcast_to_windows / bc_post). `@bc_registry` is keyed by [realm_id, local_id]
504
+ # → {seq, name, origin_key, closed}; `@bc_seq` is the isolate-wide creation counter that
505
+ # orders delivery "oldest channel first"; `@bc_queue` is the FIFO of pending {realm_id,
506
+ # local_id, data, origin} deliveries drained in order by deliver_window_messages.
507
+ @bc_seq = 0
508
+ @bc_registry = {}
509
+ @bc_queue = []
510
+ end
511
+
512
+ # Max BroadcastChannel deliveries drained per `deliver_broadcast_queue` call — a safety bound on a
513
+ # pathological mutual re-post loop (see there). Far above any real fan-out (the ordering test is ~14).
514
+ BROADCAST_DRAIN_CAP = 100_000
388
515
 
389
516
  # Worker thread polling and termination intervals — split so a
390
517
  # tuning change to one doesn't accidentally rebind the other.
391
- WORKER_POLL_INTERVAL = 0.05
392
- WORKER_TERMINATE_GRACE = 0.05
393
- private_constant :WORKER_POLL_INTERVAL, :WORKER_TERMINATE_GRACE
518
+ WORKER_POLL_INTERVAL = 0.05
519
+ # Max wall time settle blocks on a worker thread's outbox per call while it processes an
520
+ # inbound message / fetch (releasing the GVL so it runs). Bounded so a genuinely stuck worker
521
+ # can't hang settle — the outer poll loop re-drives across calls.
522
+ WORKER_ROUND_TRIP_BUDGET = 1.0
523
+ WORKER_TERMINATE_GRACE = 0.05
524
+ # Max timer-draining rounds `drive_worker_to_quiescence` runs before yielding back
525
+ # to the poll loop. A message handler's async bring-up (Emscripten WASM init) settles
526
+ # in a handful of microtask/timer rounds; the cap only bites a worker that keeps
527
+ # rescheduling timers (a setInterval), which the poll loop then continues to advance.
528
+ WORKER_QUIESCE_MAX_ROUNDS = 256
529
+ # Per-frame GVL yield (run_event_loop_frame) while a worker thread is alive, so it gets a clean
530
+ # slice for cross-isolate work (transferIn / message replies) instead of being starved by the
531
+ # phase-1 spin. 0.3ms is the empirical floor for a deterministic cross-isolate transfer reply;
532
+ # 0.5ms adds margin for machine variance while staying cheap (only paid on worker/SW files).
533
+ WORKER_GVL_YIELD = 0.0005
534
+ # Client-realm handler for each streaming respondWith frame kind (see deliver_worker_messages
535
+ # + sw-client.js). `fr_start` builds a ReadableStream-backed Response; `fr_chunk` enqueues;
536
+ # `fr_close` / `fr_error` close / error the body stream.
537
+ STREAM_FRAME_FNS = {
538
+ 'fr_start' => '__csim_swFetchStreamStart',
539
+ 'fr_chunk' => '__csim_swFetchStreamChunk',
540
+ 'fr_close' => '__csim_swFetchStreamClose',
541
+ 'fr_error' => '__csim_swFetchStreamError'
542
+ }.freeze
543
+ private_constant :WORKER_POLL_INTERVAL, :WORKER_ROUND_TRIP_BUDGET, :WORKER_TERMINATE_GRACE, :WORKER_GVL_YIELD, :WORKER_QUIESCE_MAX_ROUNDS, :STREAM_FRAME_FNS
394
544
 
395
545
  # `js_engine` picks the JS runtime: `:v8` (rusty_racer, fastest
396
546
  # per-spec) or `:quickjs` (quickjs.rb, smaller per-VM footprint —
@@ -596,7 +746,7 @@ module Capybara
596
746
  # browsing context to route into. Distinguish that (unsupported
597
747
  # engine) from a frame that simply failed to build (below), so the
598
748
  # error doesn't misattribute a load failure to the engine.
599
- unless @runtime.respond_to?(:realm_call)
749
+ unless @runtime.supports_frames?
600
750
  raise Capybara::Simulated::FrameNotSupported,
601
751
  'within_frame needs a per-frame browsing context, which only the ' \
602
752
  'V8 (rusty_racer) engine provides; QuickJS keeps a same-realm fallback.'
@@ -725,6 +875,9 @@ module Capybara
725
875
 
726
876
  def find_with_timer_fallback(kind, arg, ctx)
727
877
  tick_real_time if timer_wait_elapsed?
878
+ # After the first find of a navigation, a later find IS Capybara retrying — arm the pre-tick
879
+ # so subsequent polls advance the clock (a timer-driven element / removal the test awaits).
880
+ @pre_tick_armed = true
728
881
  result = cached_find(kind, arg, ctx) { yield }
729
882
  # An empty result is the wait-for-it case: Capybara is retrying for
730
883
  # an element that hasn't appeared yet. Re-tick so the next poll
@@ -758,8 +911,20 @@ module Capybara
758
911
  # this above one Ruby boundary so a single visit+find pair
759
912
  # doesn't accidentally tick.
760
913
  FIND_PRE_TICK_MIN_S = 0.05
914
+ # Whether a find should advance the clock BEFORE reading the DOM. The FIRST find after a
915
+ # navigation observes the current (pre-timer) DOM unconditionally — the "query the DOM before
916
+ # advancing pending timers" contract — so it never pre-ticks; only a LATER find (Capybara
917
+ # retrying because the element wasn't there yet) does, gated on the tick FREQUENCY. Anchoring
918
+ # the first-find exemption on a flag (not the wall clock) keeps it deterministic: a >50 ms wall
919
+ # gap between the navigation and the first find under full-suite load must NOT fire a parked
920
+ # setTimeout(0) the page just scheduled (smoke_spec "queries the current DOM …").
921
+ # NOTE: the same first-find contract after a USER ACTION (click/fill) is still gated only on the
922
+ # 50 ms wall clock — @pre_tick_armed is disarmed by reset_timer_state (navigation) alone. Actions
923
+ # keep the wall gate because the post-action pre-tick timing is tuned against the debounce-
924
+ # between-actions app cases (Avo actions_spec:464); no action-path flake has surfaced there.
761
925
  def timer_wait_elapsed?
762
- @timers_active &&
926
+ @pre_tick_armed &&
927
+ @timers_active &&
763
928
  (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_tick_ts) >= FIND_PRE_TICK_MIN_S
764
929
  end
765
930
 
@@ -816,6 +981,30 @@ module Capybara
816
981
  tag(handle) == 'input' && attr(handle, 'type').to_s.downcase == 'file'
817
982
  end
818
983
  def visible?(handle) = dom_call('__csimVisible', handle) ? true : false
984
+ # `obscured?` — coarse occlusion / hit-test in JS (layout.js). Non-visible / out-of-viewport /
985
+ # click-point-lands-on-another-element → obscured.
986
+ def obscured?(handle) = dom_call('__csimObscured', handle) ? true : false
987
+ # `rect` — the element's coarse border-box from the layout engine, as the full 8-field box.
988
+ # Two key styles on purpose: Capybara's spatial `Rectangle` reads STRING keys
989
+ # (`position['top']`); Discourse's `wait_for_animation` reads the SYMBOL key (`rect[:x]`).
990
+ def rect(handle)
991
+ r = dom_call('__csimRect', handle)
992
+ x = (r['x'] || 0).to_f
993
+ y = (r['y'] || 0).to_f
994
+ w = (r['width'] || 0).to_f
995
+ h = (r['height'] || 0).to_f
996
+ {
997
+ x:, y:, width: w, height: h, top: y, left: x, bottom: y + h, right: x + w,
998
+ 'x' => x, 'y' => y, 'width' => w, 'height' => h, 'top' => y, 'left' => x, 'bottom' => y + h, 'right' => x + w
999
+ }
1000
+ end
1001
+ # `scroll_to` — drive a real scroll offset in the layout engine (layout.applyScrollTo). `target`
1002
+ # is a target element's handle (or nil); `pos` a keyword (`:top`/`:bottom`/`:center`); `x`/`y`
1003
+ # an explicit coordinate. Symbols are stringified for the JS side.
1004
+ def scroll_to(handle, target = nil, pos = nil, x = nil, y = nil)
1005
+ dom_call('__csimScrollTo', handle, target, pos&.to_s, x, y)
1006
+ nil
1007
+ end
819
1008
 
820
1009
  # Capybara::Driver::Node surface — Node calls `check_stale`
821
1010
  # before each read, and that advances the virtual clock.
@@ -1006,7 +1195,8 @@ module Capybara
1006
1195
  env = Rack::MockRequest.env_for(url, method: 'GET')
1007
1196
  env['HTTP_USER_AGENT'] = @default_user_agent || USER_AGENT
1008
1197
  env['REMOTE_ADDR'] = self.class.remote_addr_for(env['HTTP_HOST'] || env['SERVER_NAME'])
1009
- env['HTTP_COOKIE'] = document_cookie unless @cookies.empty?
1198
+ ck = cookie_header_for(env_cookie_host(env))
1199
+ env['HTTP_COOKIE'] = ck unless ck.empty?
1010
1200
  env['HTTP_REFERER'] = @current_url unless @current_url.nil? || @current_url.empty?
1011
1201
  status, headers, body = @app.call(env)
1012
1202
  return unless status.to_i == 200
@@ -1191,19 +1381,30 @@ module Capybara
1191
1381
  end
1192
1382
 
1193
1383
  # Element-to-element drag. Capybara's `Element#drag_to(target,
1194
- # delay:)` lands here. Fires the HTML5 drag event sequence on
1195
- # the source / target pair (mousedown dragstart → dragenter →
1196
- # dragover drop dragend) with a shared DataTransfer. Discourse
1197
- # sidebar reorder + Avo Sortable-shaped widgets read the
1198
- # `event.offsetY` to decide "above vs below"; without a layout
1199
- # engine we report 0, which routes drops above the target.
1200
- def drag_to(source_handle, target_handle, **_opts)
1384
+ # drop_modifiers:, html5:, delay:)` lands here; the sequencing lives
1385
+ # in `drag.js` (pointer-driven vs HTML5, decided from the source's
1386
+ # mousedown). `drop_modifiers` are held down from `dragenter` on, the
1387
+ # way a user pressing a key mid-drag produces. `delay` is a
1388
+ # real-browser pacing knob our dispatch is synchronous, so the page
1389
+ # sees each step in order without it. Discourse sidebar reorder + Avo
1390
+ # Sortable-shaped widgets read `event.offsetY` to decide "above vs
1391
+ # below"; we report 0, which routes drops above the target.
1392
+ def drag_to(source_handle, target_handle, html5: nil, drop_modifiers: [], **_opts)
1201
1393
  mark_action_baseline
1202
1394
  tick_real_time
1203
1395
  invalidate_find_cache
1204
1396
  ensure_alive_after_tick(source_handle)
1205
1397
  ensure_alive_after_tick(target_handle)
1206
- dom_call('__csimDragOnto', source_handle, target_handle)
1398
+ # Staged with a settle between each step: a real drag spans several frames, and libraries
1399
+ # use that gap (SortableJS applies its ghost class from a `setTimeout` scheduled in
1400
+ # `dragstart` and never reaches its reorder logic if `dragover` lands in the same turn).
1401
+ # This is what the reference driver's `delay:` between steps buys.
1402
+ dom_call('__csimDragBegin', source_handle, target_handle,
1403
+ {'html5' => html5, 'modifiers' => modifier_flags(drop_modifiers)})
1404
+ settle
1405
+ dom_call('__csimDragMove')
1406
+ settle
1407
+ dom_call('__csimDragFinish')
1207
1408
  drain_after_user_action
1208
1409
  end
1209
1410
  def drop_items(arg)
@@ -1246,7 +1447,8 @@ module Capybara
1246
1447
  alt: 'altKey',
1247
1448
  option: 'altKey',
1248
1449
  meta: 'metaKey',
1249
- command: 'metaKey'
1450
+ command: 'metaKey',
1451
+ cmd: 'metaKey'
1250
1452
  }.freeze
1251
1453
  MODIFIER_KEY_NAMES = MODIFIER_KEYS.keys.to_set.freeze
1252
1454
  def modifier_flags(keys)
@@ -1256,19 +1458,26 @@ module Capybara
1256
1458
  }
1257
1459
  end
1258
1460
 
1259
- # Resolve click offset against the element's CSS-declared box.
1260
- # `opts[:offset] == :center` means "x/y is relative to the
1261
- # element's centre" (Capybara's w3c_click_offset semantics);
1262
- # otherwise the offset is relative to the top-left. We don't run
1263
- # a real layout engine `__csimElementRect` reads
1264
- # top / left / width / height from the cascade so tests that
1265
- # declare those values via CSS see honest coordinates.
1461
+ # Resolve the click point against the element's laid-out box — the
1462
+ # same geometry `rect` / `obscured?` / `drag_to` and the page's own
1463
+ # `getBoundingClientRect` read, so a click lands where the page
1464
+ # believes the element is. `opts[:offset] == :center` means x/y are
1465
+ # relative to the element's centre (Capybara's w3c_click_offset
1466
+ # semantics); otherwise they're relative to its top-left, so the
1467
+ # point is the element's own origin plus the offset — including the
1468
+ # 8px body margin a real page has (confirmed in Chrome).
1469
+ #
1470
+ # This is exactly what the unified geometry buys: Capybara's own
1471
+ # click-offset fixture logs `event.clientX - this.getBoundingClientRect()
1472
+ # .left`, which only comes back as the requested offset when the
1473
+ # pointer we synthesize and the rect the page measures are the same
1474
+ # geometry. Two sources disagree by the element's position.
1266
1475
  def click_event_init(handle, keys, opts)
1267
1476
  out = modifier_flags(keys)
1268
1477
  has_xy = opts[:x] || opts[:y]
1269
1478
  center = opts[:offset] == :center || !has_xy
1270
1479
  if has_xy || center
1271
- rect = dom_call('__csimElementRect', handle)
1480
+ rect = dom_call('__csimRect', handle)
1272
1481
  base_x = rect['x'].to_f + (center ? rect['width'].to_f / 2.0 : 0.0)
1273
1482
  base_y = rect['y'].to_f + (center ? rect['height'].to_f / 2.0 : 0.0)
1274
1483
  out['clientX'] = base_x + opts[:x].to_f
@@ -1457,6 +1666,7 @@ module Capybara
1457
1666
  def settle
1458
1667
  start_gen = @runtime.settle_gen
1459
1668
  prev_gen = start_gen
1669
+ worker_wait_deadline = nil
1460
1670
  SETTLE_MAX_ITER.times do
1461
1671
  deliver_event_source_events
1462
1672
  deliver_worker_messages
@@ -1479,6 +1689,28 @@ module Capybara
1479
1689
  deliver_window_messages
1480
1690
  deliver_websocket_events
1481
1691
  break if @runtime.settle_gen > start_gen
1692
+ # A background worker thread owes us a CONTRACTUAL reply (a swack / bcack /
1693
+ # fetch_response is posted under `ensure`, so it always comes) but hasn't posted it
1694
+ # yet. With no timer, `run_loop_step(0)` returns instantly, so busy-spinning the
1695
+ # remaining iterations would STARVE the worker thread of the GVL and it would never
1696
+ # process its inbox. Block briefly on the outbox instead: this releases the GVL (the
1697
+ # worker runs) and wakes the instant it posts. The popped event is parked in
1698
+ # (via `park_worker_reply`, which parks it in `@worker_outbox_head` — NOT pushed back,
1699
+ # which would reorder it behind anything the worker enqueued in the meantime — so the next
1700
+ # deliver drains it first). The budget is shared across the whole settle call, and
1701
+ # exhausting it bails to Capybara's outer poll loop — a genuinely stuck worker must not pin
1702
+ # every find's settle for SETTLE_MAX_ITER budgets. Gated on `worker_reply_pending?`, NOT
1703
+ # `worker_pending?`: `@worker_in_flight` (plain postMessage — a listen-only worker never
1704
+ # replies) and `@worker_initializing` have no matching reply, and blocking on them would
1705
+ # tax every settle on such pages with the full budget.
1706
+ if worker_reply_pending? && @worker_outbox.empty? && @worker_outbox_head.nil?
1707
+ worker_wait_deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + WORKER_ROUND_TRIP_BUDGET
1708
+ park_worker_reply(worker_wait_deadline) while worker_reply_pending? &&
1709
+ @worker_outbox.empty? && @worker_outbox_head.nil? &&
1710
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) < worker_wait_deadline
1711
+ next if @worker_outbox_head || !@worker_outbox.empty?
1712
+ break
1713
+ end
1482
1714
  # No progress this iter (no DOM/URL change observed) — the
1483
1715
  # remaining timers are queued for the future; bail and let
1484
1716
  # Capybara's wall-clock-driven poll loop drive the next tick
@@ -2189,6 +2421,17 @@ module Capybara
2189
2421
  # caller's concern — read it separately with `peek_script` (clock-free).
2190
2422
  def run_event_loop_frame(frame_ms)
2191
2423
  turns = 0
2424
+ # Give any live worker/SW thread a clean GVL slice before the phase-1 quiescence loop
2425
+ # monopolises it. That loop spins `run_loop_step(0)` holding the GVL, which STARVES a
2426
+ # worker mid-flight — in particular a CROSS-ISOLATE zero-copy transfer (`RustyRacer.
2427
+ # transferIn` over a SendBackingStore) that a `worker.postMessage(view, [view.buffer])`
2428
+ # reply must complete on the worker thread. Under starvation transferIn fails, the SW's
2429
+ # message handler throws on the null result, and no reply is posted → the client's
2430
+ # `onmessage` never fires and the drain force-timeouts it (postmessage.https transferable
2431
+ # subtests; the whole SW→client message reply cluster). A brief `sleep` releases the GVL
2432
+ # so the worker runs (Thread.pass does NOT hand it over); gated on a live worker so
2433
+ # worker-free files pay nothing.
2434
+ sleep(WORKER_GVL_YIELD) if @workers.any? {|_, w| w[:thread]&.alive? }
2192
2435
  loop do
2193
2436
  r = @runtime.run_loop_step(0) # run only what's due NOW + microtasks + render; no clock advance
2194
2437
  progressed = step_and_drain_progressed(r)
@@ -2196,6 +2439,43 @@ module Capybara
2196
2439
  break unless progressed
2197
2440
  break if turns >= EVENT_LOOP_QUIESCENCE_CAP
2198
2441
  end
2442
+
2443
+ # Interlude — hold the virtual clock while a controlled-client fetch is awaiting the
2444
+ # service worker's `respondWith`. The SW does the real request off-thread (a live network
2445
+ # hop, an in-VM handler) and its reply is delivered Ruby-side, invisible to the JS event-loop
2446
+ # probe. Advancing the clock now (phase 2) would let a caller's virtual-timeout outrun that
2447
+ # off-thread work and mark the still-pending fetch as timed-out before the reply lands. So
2448
+ # block briefly on the worker outbox — releasing the GVL so the worker runs, exactly like
2449
+ # `settle` — and deliver the reply at the current instant, WITHOUT advancing the clock, then
2450
+ # keep pumping. A fetch that never replies is bounded by `@sw_fetch_wait_deadline` (and, past
2451
+ # that, the caller's own max-steps backstop), so it can't wedge the drain.
2452
+ if @sw_fetch_pending.positive? && (held = hold_for_sw_fetch(turns))
2453
+ return held
2454
+ end
2455
+ @sw_fetch_wait_deadline = nil unless @sw_fetch_pending.positive?
2456
+
2457
+ # Same interlude for an in-flight `ws.close()` handshake: the reader thread surfaces the
2458
+ # server's echoed close frame as `__close` — real off-thread work the JS event-loop probe
2459
+ # can't see. Advancing the clock now would let a test's virtual-timeout ("onclose should
2460
+ # fire") outrun it. Block briefly on the WS queue (GVL released, like settle) so the reader
2461
+ # runs, deliver at the current instant WITHOUT advancing time, then keep pumping. Bounded by
2462
+ # a deadline so a peer that never replies (→ EOF 1006) can't wedge the drain.
2463
+ if @ws_close_pending.positive? && (held = hold_for_ws_close(turns))
2464
+ return held
2465
+ end
2466
+ @ws_close_wait_deadline = nil unless @ws_close_pending.positive?
2467
+
2468
+ # Same interlude for a pending SW message swack / broadcast ack — but WITHOUT holding
2469
+ # the clock. Its reply is the same kind of cross-isolate transfer completed on the worker
2470
+ # thread (a `worker.postMessage(view, [view.buffer])` reply zero-copies via transferIn),
2471
+ # so we block briefly on the outbox to give a loaded runner's worker real GVL time to post
2472
+ # it, then fall through to phase 2 — a message reply is delivered at the current instant by
2473
+ # the drain below and the client-side lifecycle / nav timers its test then waits on advance
2474
+ # via the clock, so (unlike a fetch) we must NOT hold: holding regressed about-blank-
2475
+ # replacement. See drain_pending_message_reply.
2476
+ drain_pending_message_reply if worker_message_reply_pending?
2477
+ @sw_msg_wait_deadline = nil unless worker_message_reply_pending?
2478
+
2199
2479
  # Phase 2 — advance one real frame so the next batch of timers becomes due.
2200
2480
  # Its work counts toward `progressed` too: a timer that first comes due in
2201
2481
  # this advance (e.g. a `setTimeout(…, 8)` firing mid-frame) and the nav hop
@@ -2204,7 +2484,9 @@ module Capybara
2204
2484
  probe = dom_call('__csimEventLoopProbe')
2205
2485
  {
2206
2486
  'raf' => !!probe['raf'],
2207
- 'async' => !!probe['async'],
2487
+ # A live WS reader counts as async so the drain loop yields the GVL to it each frame — see
2488
+ # websocket_reader_active?. Without it a binary echo can be starved past the idle-bail.
2489
+ 'async' => !!probe['async'] || websocket_reader_active?,
2208
2490
  # ms until the nearest scheduled timer (-1 = none). Lets a caller keep
2209
2491
  # advancing while a near-future `setTimeout` is parked (a `step_timeout`-
2210
2492
  # style wait) instead of declaring the page idle — see `__csimEventLoopProbe`.
@@ -2215,6 +2497,96 @@ module Capybara
2215
2497
  }
2216
2498
  end
2217
2499
 
2500
+ # Hold the virtual clock for one frame while a controlled-client fetch awaits the SW's
2501
+ # respondWith. Blocks briefly on the worker outbox (GVL released) up to a budget shared across
2502
+ # frames; on a reply, delivers it at the current instant (a zero-advance `run_loop_step`) and
2503
+ # returns the frame's loop-state so the caller keeps pumping without advancing time. Returns
2504
+ # nil once the budget is spent, so the caller falls through to a normal frame (advancing the
2505
+ # clock) and a genuinely stuck fetch can't wedge the drain. Reports the REAL nearest timer —
2506
+ # holding the clock doesn't hide a parked timer, we simply haven't advanced to it yet.
2507
+ private def hold_for_sw_fetch(turns)
2508
+ @sw_fetch_wait_deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + WORKER_ROUND_TRIP_BUDGET
2509
+ park_worker_reply(@sw_fetch_wait_deadline)
2510
+ reply_ready = @worker_outbox_head || !@worker_outbox.empty?
2511
+ # A delivered reply refreshes the budget so the NEXT fetch in a sequence waits afresh instead
2512
+ # of inheriting a spent deadline (which would abandon it to a premature timeout).
2513
+ @sw_fetch_wait_deadline = nil if reply_ready
2514
+ return nil unless reply_ready || Process.clock_gettime(Process::CLOCK_MONOTONIC) < @sw_fetch_wait_deadline
2515
+
2516
+ held_progressed = reply_ready && step_and_drain_progressed(@runtime.run_loop_step(0))
2517
+ probe = dom_call('__csimEventLoopProbe')
2518
+ {
2519
+ 'raf' => !!probe['raf'],
2520
+ 'async' => true,
2521
+ 'next_timer' => probe['nextTimer'].to_f,
2522
+ 'progressed' => turns > 1 || held_progressed
2523
+ }
2524
+ end
2525
+
2526
+ # Hold the virtual clock while a `ws.close()` handshake completes. Mirrors hold_for_sw_fetch,
2527
+ # but the reply arrives on the WS queue (the reader thread), not the worker outbox: park
2528
+ # briefly on that queue (GVL released, so the reader runs) and, once a frame is ready, deliver
2529
+ # it at the current instant (`run_loop_step(0)` → deliver_websocket_events clears the counter)
2530
+ # WITHOUT advancing time. Returns the frame-probe hash to hold; nil only once the deadline is
2531
+ # spent with nothing delivered, so the caller falls through to phase 2 and the clock resumes.
2532
+ private def hold_for_ws_close(turns)
2533
+ @ws_close_wait_deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + WORKER_ROUND_TRIP_BUDGET
2534
+ if @websocket_queue_head.nil? && @websocket_queue.empty? && Process.clock_gettime(Process::CLOCK_MONOTONIC) < @ws_close_wait_deadline
2535
+ # Pop one event to block until the reader produces something, then park it in the one-slot
2536
+ # HEAD buffer — NOT pushed back onto the tail, which would reorder it behind anything the
2537
+ # reader enqueued in the meantime. deliver_websocket_events drains the head first. Mirrors
2538
+ # park_worker_reply.
2539
+ @websocket_queue_head = pop_with_timeout(@websocket_queue, WORKER_POLL_INTERVAL)
2540
+ end
2541
+ ready = !@websocket_queue_head.nil? || !@websocket_queue.empty?
2542
+ # A delivered frame refreshes the budget so the NEXT close in a sequence waits afresh.
2543
+ @ws_close_wait_deadline = nil if ready
2544
+ return nil unless ready || Process.clock_gettime(Process::CLOCK_MONOTONIC) < @ws_close_wait_deadline
2545
+
2546
+ held_progressed = ready && step_and_drain_progressed(@runtime.run_loop_step(0))
2547
+ probe = dom_call('__csimEventLoopProbe')
2548
+ {
2549
+ 'raf' => !!probe['raf'],
2550
+ 'async' => true,
2551
+ 'next_timer' => probe['nextTimer'].to_f,
2552
+ 'progressed' => turns > 1 || held_progressed
2553
+ }
2554
+ end
2555
+
2556
+ # Park briefly (GVL released) for an outstanding SW message swack / broadcast ack and deliver
2557
+ # it at the current instant, then RETURN — the caller proceeds to phase 2 and advances the
2558
+ # clock. Unlike `hold_for_sw_fetch` this does NOT hold time: a message/broadcast reply's test
2559
+ # advances its client-side lifecycle / nav timers via the clock, so holding on it deadlocks
2560
+ # (regressed about-blank-replacement). The only thing missing under load is worker GVL time for
2561
+ # the cross-isolate transferable reply to complete — a fixed micro-sleep isn't enough margin on
2562
+ # a loaded runner (postmessage.https transferable subtests), so we block on the outbox exactly
2563
+ # like the fetch hold / `settle`. Budget shared across frames (a genuinely stuck reply pays it
2564
+ # once — the spent deadline stays in the past, so later frames don't re-block and the clock runs
2565
+ # free until the reply lands or the test times out); reset in `run_event_loop_frame` once no
2566
+ # message/broadcast reply is outstanding, so the next one waits afresh.
2567
+ private def drain_pending_message_reply
2568
+ @sw_msg_wait_deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + WORKER_ROUND_TRIP_BUDGET
2569
+ park_worker_reply(@sw_msg_wait_deadline) while worker_message_reply_pending? &&
2570
+ @worker_outbox.empty? && @worker_outbox_head.nil? &&
2571
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) < @sw_msg_wait_deadline
2572
+ # `deliver_worker_messages` (inside step_and_drain) refreshes @sw_msg_wait_deadline whenever it
2573
+ # delivers a swack/bcack, so the NEXT reply in a sequence waits afresh — and it does so on
2574
+ # WHICHEVER drain path delivered the reply (this one, hold_for_sw_fetch's outbox drain, or
2575
+ # settle), which resetting only here would miss when a co-pending fetch hold delivers it.
2576
+ step_and_drain_progressed(@runtime.run_loop_step(0)) if @worker_outbox_head || !@worker_outbox.empty?
2577
+ end
2578
+
2579
+ # Block up to one poll interval for a worker reply, parking it in the one-slot head buffer —
2580
+ # NOT pushed back, which would reorder it behind anything the worker enqueued meanwhile. The
2581
+ # `pop_with_timeout` releases the GVL so the worker thread runs and wakes us the instant it
2582
+ # posts. No-op if a reply is already buffered or the budget is spent. Shared by `settle` and
2583
+ # the SW-fetch hold in `run_event_loop_frame`.
2584
+ private def park_worker_reply(deadline)
2585
+ return unless @worker_outbox.empty? && @worker_outbox_head.nil? &&
2586
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
2587
+ @worker_outbox_head = pop_with_timeout(@worker_outbox, WORKER_POLL_INTERVAL)
2588
+ end
2589
+
2218
2590
  # Drain the Ruby-side async / navigation / form-submit / download chains a
2219
2591
  # `run_loop_step` (passed as `r`) may have queued, and report whether this
2220
2592
  # step+drain made observable progress. Shared by both phases of
@@ -2356,6 +2728,9 @@ module Capybara
2356
2728
  @last_polled_gen = nil
2357
2729
  @idle_settle_polls = 0
2358
2730
  @ff_transient_polls = 0
2731
+ # Disarm the find pre-tick so the FIRST find after this navigation reads the current DOM
2732
+ # without advancing timers (see timer_wait_elapsed?), independent of wall-clock timing.
2733
+ @pre_tick_armed = false
2359
2734
  @context_gen += 1
2360
2735
  end
2361
2736
 
@@ -2374,7 +2749,7 @@ module Capybara
2374
2749
  method = spec['method'].to_s.upcase
2375
2750
  method = 'GET' if method.empty?
2376
2751
  enctype = spec['enctype'].to_s.empty? ? 'application/x-www-form-urlencoded' : spec['enctype'].to_s.downcase
2377
- entries = entry_list.is_a?(Array) ? entry_list : entries_from_spec(spec)
2752
+ entries = entry_list.is_a?(Array) ? entry_list : (spec['entries'] || [])
2378
2753
  action_url = action.empty? ? (current_browsing_context_url || @default_host) : resolve_against_current(action)
2379
2754
  # A form submitted inside a frame whose target is that frame (self, or a
2380
2755
  # `_parent` of a ≥2-deep frame) navigates the FRAME, not the top page.
@@ -2468,7 +2843,13 @@ module Capybara
2468
2843
  body << "--#{boundary}--\r\n"
2469
2844
  [body, "multipart/form-data; boundary=#{boundary}"]
2470
2845
  else
2471
- pairs = entries.map {|e| [e['name'].to_s, e['file'] ? e['filename'].to_s : e['value'].to_s] }
2846
+ # The urlencoded / text-plain encoders normalize CR/LF CRLF in each entry's
2847
+ # name and value (a file entry's filename is the value) — the entry list itself
2848
+ # stays raw, so normalization lives here, matching the JS encoders and real
2849
+ # browsers (newline-normalization.html).
2850
+ pairs = entries.map {|e|
2851
+ [normalize_form_newlines(e['name']), normalize_form_newlines(e['file'] ? e['filename'] : e['value'])]
2852
+ }
2472
2853
  if enctype == 'text/plain'
2473
2854
  [pairs.map {|name, value| "#{name}=#{value}\r\n" }.join, 'text/plain']
2474
2855
  else
@@ -2477,6 +2858,12 @@ module Capybara
2477
2858
  end
2478
2859
  end
2479
2860
 
2861
+ # HTML form-submission newline normalization: every lone CR, lone LF, and CRLF in an
2862
+ # entry name/value becomes a CRLF (the JS encoders' `normalizeNL` counterpart).
2863
+ def normalize_form_newlines(s)
2864
+ s.to_s.gsub(/\r\n?|\n/, "\r\n")
2865
+ end
2866
+
2480
2867
  # Resolve a threaded file entry's on-disk path via the `@file_picks` slot
2481
2868
  # recorded at `attach_file` time (handle/index). nil for a purely in-memory
2482
2869
  # `new File(['bytes'], …)` (no slot) — a CLASSIC (non-Turbo) submit then
@@ -2490,35 +2877,6 @@ module Capybara
2490
2877
  picks && picks[entry['index'].to_i]
2491
2878
  end
2492
2879
 
2493
- # Build the entry list from the form's own controls, for triggers that didn't
2494
- # construct one in JS (the Enter implicit-submit path). Mirrors the JS FormData
2495
- # construction: non-file fields in tree order, then each file input's selection
2496
- # (one empty entry when nothing is picked). A selected File reports its
2497
- # host-backed source (`handle`/`index`); the older payload shape with no per-File
2498
- # refs falls back to the input's own handle slot.
2499
- def entries_from_spec(spec)
2500
- entries = (spec['fields'] || []).map {|pair| {'name' => pair[0].to_s, 'value' => pair[1].to_s} }
2501
- (spec['fileInputs'] || []).each do |fi|
2502
- name = fi['name'].to_s
2503
- refs = fi['files']
2504
- if refs.is_a?(Array) && !refs.empty?
2505
- refs.each {|ref|
2506
- entries << {'name' => name, 'file' => true, 'filename' => ref['name'].to_s, 'handle' => ref['handle'], 'index' => ref['index']}
2507
- }
2508
- else
2509
- picks = (@file_picks && @file_picks[fi['handle'].to_i]) || []
2510
- if picks.empty?
2511
- entries << {'name' => name, 'file' => true, 'filename' => '', 'handle' => nil, 'index' => nil}
2512
- else
2513
- picks.each_index {|i|
2514
- entries << {'name' => name, 'file' => true, 'filename' => File.basename(picks[i]), 'handle' => fi['handle'], 'index' => i}
2515
- }
2516
- end
2517
- end
2518
- end
2519
- entries
2520
- end
2521
-
2522
2880
  def append_multipart_part(body, boundary, name, content, filename: nil, content_type: nil)
2523
2881
  body << "--#{boundary}\r\n"
2524
2882
  disposition = %[form-data; name="#{name}"]
@@ -2542,7 +2900,7 @@ module Capybara
2542
2900
  env['CONTENT_LENGTH'] = body.bytesize.to_s
2543
2901
  apply_default_request_env(env, referer: referer)
2544
2902
  status, headers, resp_body = dispatch_rack_or_http(url, env, method: 'POST', body: body)
2545
- merge_set_cookie(headers)
2903
+ merge_set_cookie(headers, url)
2546
2904
  if (loc = redirect_location(status, headers))
2547
2905
  next_url = resolve_against_current(loc)
2548
2906
  resp_body.close if resp_body.respond_to?(:close)
@@ -2571,7 +2929,9 @@ module Capybara
2571
2929
 
2572
2930
  def reset!
2573
2931
  @cookies.clear
2932
+ @auth_cache.clear
2574
2933
  @local_storage.clear
2934
+ @cache_storage.clear
2575
2935
  @session_storage.clear
2576
2936
  @sticky_headers.clear
2577
2937
  # The driver-side resize buffer has to clear too — without
@@ -2599,6 +2959,11 @@ module Capybara
2599
2959
  reset_frame_scope
2600
2960
  @history.clear
2601
2961
  @history_idx = -1
2962
+ @cors_preflight_cache = {} # CORS-preflight cache is per browsing context
2963
+ # A JS-driven history.back()/go() that scheduled a deferred traverse but
2964
+ # never drained (the page navigated away first) must not survive the reset
2965
+ # — otherwise the stale target replays against the NEXT page's fresh history.
2966
+ @pending_history_traverse = nil
2602
2967
  @file_picks = {} if @file_picks
2603
2968
  # Hand the live trace off to `@pending_trace` so an after-hook
2604
2969
  # running after `reset_session!` (Capybara's per-test teardown
@@ -2618,6 +2983,11 @@ module Capybara
2618
2983
  reset_websockets
2619
2984
  @window_inbox.clear
2620
2985
  @broadcast_inbox.clear
2986
+ # The BroadcastChannel registry + ordered queue are per-page (the rebuilt VM has no live channels
2987
+ # and restarts the realm/local id counters); a stale entry would misroute a later post.
2988
+ @bc_registry.clear
2989
+ @bc_queue.clear
2990
+ @bc_seq = 0
2621
2991
  # Free any zero-copy transfer backing stores that went unimported
2622
2992
  # (worker killed before draining its inbox, etc.) before the rebuild.
2623
2993
  drop_pending_transfers
@@ -2651,6 +3021,8 @@ module Capybara
2651
3021
  reset_websockets
2652
3022
  @window_inbox.clear
2653
3023
  @broadcast_inbox.clear
3024
+ @bc_registry.clear
3025
+ @bc_queue.clear
2654
3026
  # Dispose the JS runtime/isolate itself — for an auxiliary window this
2655
3027
  # Browser is the isolate's last owner, but V8Runtime registers every
2656
3028
  # isolate in a process-wide `@@live` set (for at_exit cleanup), which
@@ -2702,6 +3074,27 @@ module Capybara
2702
3074
  @@asset_src_lock = Mutex.new
2703
3075
  ASSET_SRC_MAX = 4096
2704
3076
 
3077
+ # Decoded-image cache: resolved-URL => {'width'=>, 'height'=>, 'bytes'=> packed
3078
+ # RGBA String}. Decoding an image (libvips) is the expensive step, so — like the
3079
+ # V8 bytecode cache and the script/stylesheet source cache above — we keep the
3080
+ # decoded pixels and reuse them for every `<img>` sharing a src, across elements
3081
+ # AND visits. Same content-stability assumption as `@@asset_src` (a URL's bytes
3082
+ # are stable within a process; content-hashed / data: URLs that dominate satisfy
3083
+ # it); size-capped so an app cycling through many distinct images can't grow it
3084
+ # without bound.
3085
+ @@image_cache = {}
3086
+ @@image_cache_lock = Mutex.new
3087
+ IMAGE_CACHE_MAX = 512
3088
+
3089
+ # Cross-visit cache of @font-face font files, resolved-url → on-disk path (or nil
3090
+ # when the fetch failed). The bytes are written to a process-lifetime temp file so
3091
+ # pango/fontconfig (via `Vips::Image.text fontfile:`) can read them by path. Font
3092
+ # URLs are content-stable app assets, so caching across the per-visit VM rebuild
3093
+ # avoids re-fetching CanvasTest.ttf & friends on every visit.
3094
+ @@font_file_cache = {}
3095
+ @@font_file_lock = Mutex.new
3096
+ @@font_files = [] # pins the Tempfiles for the PROCESS (the cache is cross-visit)
3097
+
2705
3098
  # Body of an external durably-cacheable asset (classic script or stylesheet),
2706
3099
  # served from the cross-visit cache when still fresh, else fetched (which
2707
3100
  # read-throughs the per-visit asset cache) and cached iff durably cacheable.
@@ -2876,12 +3269,10 @@ module Capybara
2876
3269
  'Cache-Control: no-store',
2877
3270
  'Connection: keep-alive'
2878
3271
  ]
2879
- # Forward the host-cookie jar so the streaming server can
2880
- # authenticate the user the same way the browser would. The
2881
- # jar is a flat name=value map (no per-host scoping); reuse
2882
- # the canonical `document_cookie` serialiser the Rack path
2883
- # uses, so we don't drift if its format changes.
2884
- cookies = document_cookie
3272
+ # Forward the streaming host's cookie jar so the server can
3273
+ # authenticate the user the same way the browser would — scoped to
3274
+ # the EventSource target's host, like every other request.
3275
+ cookies = cookie_header_for(cookie_host(uri))
2885
3276
  lines << "Cookie: #{cookies}" unless cookies.empty?
2886
3277
  socket.write(lines.join("\r\n") << "\r\n\r\n")
2887
3278
  socket.flush
@@ -2979,6 +3370,10 @@ module Capybara
2979
3370
  env['HTTP_CONNECTION'] = 'Upgrade'
2980
3371
  env['HTTP_SEC_WEBSOCKET_KEY'] = key
2981
3372
  env['HTTP_SEC_WEBSOCKET_VERSION'] = '13'
3373
+ # The opening handshake always carries the initiating document's origin (the UA owns this
3374
+ # header) — server handlers echo it back (websockets/opening-handshake origin test).
3375
+ doc_origin = url_origin(@current_url)
3376
+ env['HTTP_ORIGIN'] = doc_origin if doc_origin
2982
3377
  list = Array(protocols).map(&:to_s).reject(&:empty?)
2983
3378
  env['HTTP_SEC_WEBSOCKET_PROTOCOL'] = list.join(', ') unless list.empty?
2984
3379
  env['rack.hijack?'] = true
@@ -2998,7 +3393,7 @@ module Capybara
2998
3393
  queue = @websocket_queue
2999
3394
  @websocket_threads[id] = Thread.new do
3000
3395
  Thread.current.report_on_exception = false
3001
- run_websocket_reader(id, csim_io, accept, queue)
3396
+ run_websocket_reader(id, csim_io, accept, queue, target)
3002
3397
  end
3003
3398
  id
3004
3399
  rescue StandardError => e
@@ -3027,24 +3422,58 @@ module Capybara
3027
3422
  nil
3028
3423
  end
3029
3424
 
3030
- def ws_close(id, code = 1000, reason = '')
3425
+ def ws_close(id, code = nil, reason = '')
3031
3426
  sock = @websocket_sockets[id.to_i] or return
3032
3427
  # Send the close frame and let the close HANDSHAKE complete: the server
3033
3428
  # replies with its own close frame, which the reader thread surfaces as
3034
3429
  # the `__close` event (carrying the agreed code) before tearing the
3035
3430
  # socket down in its `ensure`. Force teardown is `reset_websockets`'s job.
3036
- payload = [code.to_i].pack('n') + reason.to_s.b
3431
+ # A nil code sends a BODYLESS close frame — `ws.close()` with no argument
3432
+ # closes with no status, which the peer echoes and the reader reports as
3433
+ # code 1005 (NO_STATUS), not 1000.
3434
+ payload = code.nil? ? ''.b : [code.to_i].pack('n') + reason.to_s.b
3037
3435
  ws_write_frame(sock, 0x8, payload) rescue nil
3436
+ # A close handshake is now in flight — hold the clock until the reader's `__close` lands
3437
+ # (bounded by the deadline in hold_for_ws_close if the peer never replies → EOF 1006).
3438
+ @ws_close_pending += 1
3038
3439
  nil
3039
3440
  end
3040
3441
 
3041
- def websocket_pending? = !@websocket_queue.empty?
3442
+ # Pending WS work = queued reader events OR an in-flight `ws.close()` whose `__close` hasn't
3443
+ # landed yet. Counting the latter keeps every advance path (settle, tick_real_time, and
3444
+ # crucially horizon_fast_forward_step, which would otherwise jump straight to a test's pending
3445
+ # timeout timer) from racing past the close handshake before hold_for_ws_close delivers it.
3446
+ def websocket_pending? = !@websocket_queue_head.nil? || !@websocket_queue.empty? || @ws_close_pending.positive?
3447
+
3448
+ # A live reader thread = an open WS connection whose server may still surface an echo / push /
3449
+ # close frame off-thread — pending async work the JS event-loop probe can't see. The event-loop
3450
+ # frame reports it as `async` so the WPT drain loop yields the GVL each frame (its
3451
+ # `sleep(0.001) if async`), feeding the reader instead of spinning it into starvation — a
3452
+ # binary echo that only lands AFTER the idle-bail is exactly the flake this prevents. Zero-alloc
3453
+ # on the common no-WS path (the `empty?` short-circuit); a handful of threads otherwise.
3454
+ def websocket_reader_active? = !@websocket_threads.empty? && @websocket_threads.each_value.any?(&:alive?)
3042
3455
 
3043
3456
  def deliver_websocket_events
3044
- return 0 if @websocket_threads.empty? && @websocket_queue.empty?
3045
- events = drain_queue(@websocket_queue)
3457
+ return 0 if @websocket_threads.empty? && @websocket_queue_head.nil? && @websocket_queue.empty?
3458
+ # The head slot (parked by hold_for_ws_close) is delivered ahead of the queue to preserve
3459
+ # reader order.
3460
+ events = [@websocket_queue_head].compact
3461
+ @websocket_queue_head = nil
3462
+ events.concat(drain_queue(@websocket_queue))
3046
3463
  return 0 if events.empty?
3047
- @runtime.call('__csim_deliverWebSocketEvents', events)
3464
+ # `__setcookie` is handled Ruby-side (store the handshake cookie in the jar) and NOT
3465
+ # forwarded to JS; the reader queues it before `__open`, so document.cookie sees it in onopen.
3466
+ js_events = events.reject do |e|
3467
+ if e[:type] == '__setcookie'
3468
+ merge_set_cookie({'set-cookie' => e[:cookies]}, e[:url])
3469
+ true
3470
+ end
3471
+ end
3472
+ @runtime.call('__csim_deliverWebSocketEvents', js_events) unless js_events.empty?
3473
+ # A terminal event (`__close` / `__error`) completes a close handshake — release the clock
3474
+ # hold. Clamp: a server-INITIATED close arrives without a matching ws_close increment.
3475
+ terminals = events.count {|e| e[:type] == '__close' || e[:type] == '__error' }
3476
+ @ws_close_pending = [@ws_close_pending - terminals, 0].max if terminals.positive?
3048
3477
  events.size
3049
3478
  end
3050
3479
 
@@ -3059,23 +3488,36 @@ module Capybara
3059
3488
  @websocket_sockets.clear
3060
3489
  @websocket_app_sockets.clear
3061
3490
  @websocket_queue.clear
3491
+ @websocket_queue_head = nil
3492
+ @ws_close_pending = 0
3493
+ @ws_close_wait_deadline = nil
3062
3494
  end
3063
3495
 
3064
3496
  # Background-thread frame reader: verify the 101 handshake, then loop
3065
3497
  # decoding server→client frames into queue events until close / EOF.
3066
- private def run_websocket_reader(id, sock, expected_accept, queue)
3067
- ok, protocol = ws_read_handshake(sock, expected_accept)
3498
+ private def run_websocket_reader(id, sock, expected_accept, queue, target)
3499
+ ok, protocol, cookies = ws_read_handshake(sock, expected_accept)
3068
3500
  unless ok
3069
3501
  queue << {id: id, type: '__error', message: 'websocket handshake failed'}
3070
3502
  return
3071
3503
  end
3504
+ # Store any handshake-response cookies (the main thread does the store) BEFORE `open` fires,
3505
+ # so a document.cookie read in `onopen` sees them.
3506
+ queue << {id: id, type: '__setcookie', cookies: cookies, url: target} unless cookies.empty?
3072
3507
  # Carry the negotiated subprotocol — Action Cable's client closes the
3073
3508
  # connection in its `onopen` unless `webSocket.protocol` is one it knows
3074
3509
  # (`actioncable-v1-json`).
3075
3510
  queue << {id: id, type: '__open', protocol: protocol}
3076
3511
  loop do
3077
3512
  frame = ws_read_message(sock, queue, id)
3078
- break if frame.nil? # EOF
3513
+ if frame.nil? # TCP closed with no close frame → abnormal
3514
+ queue << {id: id, type: '__close', code: 1006, reason: ''}
3515
+ break
3516
+ end
3517
+ if frame == :protocol_error # reserved opcode / malformed frame → fail the connection
3518
+ queue << {id: id, type: '__error', message: 'protocol error'}
3519
+ break
3520
+ end
3079
3521
  opcode, payload = frame
3080
3522
  if opcode == :close
3081
3523
  code = payload.bytesize >= 2 ? payload[0, 2].unpack1('n') : 1005
@@ -3107,9 +3549,10 @@ module Capybara
3107
3549
  # is set (Action Cable's client requires `actioncable-v1-json`).
3108
3550
  private def ws_read_handshake(sock, expected_accept)
3109
3551
  status = sock.gets
3110
- return [false, nil] unless status && status =~ %r{\AHTTP/1\.1 101}i
3552
+ return [false, nil, []] unless status && status =~ %r{\AHTTP/1\.1 101}i
3111
3553
  accept_ok = false
3112
3554
  protocol = nil
3555
+ cookies = []
3113
3556
  while (line = sock.gets)
3114
3557
  line = line.chomp
3115
3558
  break if line.empty?
@@ -3122,13 +3565,18 @@ module Capybara
3122
3565
  # JS Uint8Array — the protocol must reach JS as a real string so
3123
3566
  # `webSocket.protocol` compares equal to `actioncable-v1-json`.
3124
3567
  protocol = RuntimeShared.utf8_text(val) if key == 'sec-websocket-protocol' && !val.empty?
3568
+ # Cookies the server sets on the handshake response are stored in the jar (the main thread
3569
+ # does the actual store — see deliver_websocket_events), so document.cookie / the next
3570
+ # request's Cookie header reflect them.
3571
+ cookies << RuntimeShared.utf8_text(val) if key == 'set-cookie' && !val.empty?
3125
3572
  end
3126
- [accept_ok, protocol]
3573
+ [accept_ok, protocol, cookies]
3127
3574
  end
3128
3575
 
3129
3576
  # Read one complete message (reassembling continuation frames), handling
3130
3577
  # interleaved control frames inline. Returns `[opcode, payload]` (opcode
3131
- # 0x1 text / 0x2 binary, or `:close`), or nil on EOF.
3578
+ # 0x1 text / 0x2 binary), `[:close, payload]`, `:protocol_error` (a reserved
3579
+ # opcode — the connection must be failed), or nil on EOF.
3132
3580
  private def ws_read_message(sock, queue, id)
3133
3581
  data = +''.b
3134
3582
  msg_opcode = nil
@@ -3161,7 +3609,8 @@ module Capybara
3161
3609
  when 0x9 then ws_write_frame(sock, 0xA, payload); next # ping → pong
3162
3610
  when 0xA then next # pong → ignore
3163
3611
  when 0x0 then data << payload # continuation
3164
- else msg_opcode = opcode; data << payload # 0x1 text / 0x2 binary
3612
+ when 0x1, 0x2 then msg_opcode = opcode; data << payload # text / binary
3613
+ else return :protocol_error # reserved opcode (0x3-7, 0xB-F) → fail
3165
3614
  end
3166
3615
  return [msg_opcode || opcode, data] if fin
3167
3616
  end
@@ -3393,7 +3842,7 @@ module Capybara
3393
3842
  # worker's `__csim_workerPostMessage` host fn closes over its
3394
3843
  # handle and routes outgoing messages onto a shared outbox the
3395
3844
  # main settle drains.
3396
- def worker_spawn(url, shared: false, service: false)
3845
+ def worker_spawn(url, shared: false, service: false, creator_key: nil)
3397
3846
  handle = (@worker_seq += 1)
3398
3847
  target = resolve_against_current(url.to_s)
3399
3848
  # A worker script from a blob: URL in a DIFFERENT storage partition than this
@@ -3420,7 +3869,7 @@ module Capybara
3420
3869
  @worker_init_lock.synchronize { @worker_initializing += 1 }
3421
3870
  thread = Thread.new do
3422
3871
  Thread.current.report_on_exception = false
3423
- run_worker(handle, target, body, inbox, outbox, engine_class, shared: shared, service: service)
3872
+ run_worker(handle, target, body, inbox, outbox, engine_class, shared: shared, service: service, creator_key: creator_key)
3424
3873
  end
3425
3874
  @workers[handle] = {thread: thread, inbox: inbox}
3426
3875
  handle
@@ -3442,6 +3891,353 @@ module Capybara
3442
3891
  w[:inbox] << data.to_s
3443
3892
  end
3444
3893
 
3894
+ # `ServiceWorker.postMessage` from a client window → deliver to the SW's `message` event with
3895
+ # `source` = the posting client. Tracked in @sw_message_pending (released by the worker's
3896
+ # `swack`) so settle waits for the SW to process it and any client.postMessage reply.
3897
+ def service_worker_post_message(handle, data, client_id = nil, client_url = nil)
3898
+ w = @workers[handle.to_i]
3899
+ return unless w
3900
+ @sw_message_pending += 1
3901
+ w[:inbox] << {kind: 'sw_message', data: data.to_s, client: client_id, url: client_url}
3902
+ end
3903
+
3904
+ # A controlled client's fetch → the controlling SW's `fetch` event. Tracked in
3905
+ # @sw_fetch_pending (released by the `fetch_response`) so settle waits for the SW's
3906
+ # respondWith. If the handle is dead, return false so the client falls back to the network.
3907
+ def service_worker_controller_fetch(handle, req_json, fetch_id, realm_id = 0)
3908
+ w = @workers[handle.to_i]
3909
+ return false unless w
3910
+ # Resolve BEFORE bumping the pending counter: a raise here must not strand @sw_fetch_pending
3911
+ # (settle would then block for the full round-trip budget with no fetch ever queued to answer).
3912
+ req = resolve_sw_fetch_referrer(req_json.to_s)
3913
+ @sw_fetch_pending += 1
3914
+ # fetch ids are per-realm (so they collide across realms) — carry the ORIGINATING realm so
3915
+ # the response is delivered back to it, not the main realm (realm 0 = main/top window).
3916
+ w[:inbox] << {kind: 'fetch', req:, fetch_id: fetch_id.to_i, realm_id: realm_id.to_i}
3917
+ true
3918
+ end
3919
+
3920
+ # A controlled client cancelled a streaming respondWith body (`response.body.cancel()` or an
3921
+ # AbortController abort): route the cancel to the worker that owns this [realm, fetch] stream so
3922
+ # it cancels the reader it's draining — firing the SW source stream's `cancel()`. @sw_open_streams
3923
+ # maps the stream to its emitting worker; the worker's terminal frame still clears the counter.
3924
+ def sw_stream_cancel(fetch_id, realm_id)
3925
+ key = [realm_id.to_i, fetch_id.to_i]
3926
+ handle, = @sw_open_streams.find {|_h, streams| streams.key?(key) }
3927
+ return unless handle && (w = @workers[handle])
3928
+ w[:inbox] << {kind: 'fetch_cancel', fetch_id: fetch_id.to_i}
3929
+ end
3930
+
3931
+ # Resolve a controlled fetch's referrer the way the network hop would (compute_referrer
3932
+ # applies the request's Referrer-Policy to its referrer source), so the SW's
3933
+ # `event.request.referrer` matches a real browser's. The client sends the referrer SOURCE
3934
+ # (`referrerSource`, its document URL for the `about:client` default); we replace it with the
3935
+ # policy-resolved value under `referrer` (nil / stripped → '', the no-referrer state). No
3936
+ # source (older payload / navigation request) → passed through untouched.
3937
+ private def resolve_sw_fetch_referrer(req_json)
3938
+ req = JSON.parse(req_json)
3939
+ return req_json unless req.is_a?(Hash) && req.key?('referrerSource')
3940
+
3941
+ req['referrer'] = compute_referrer(req['referrerPolicy'], req.delete('referrerSource'), req['url']).to_s
3942
+ JSON.generate(req)
3943
+ rescue JSON::ParserError
3944
+ req_json
3945
+ end
3946
+
3947
+ # A SW `fetch` event's respondWith result. A NAVIGATION fetch (negative id — see
3948
+ # service_worker_navigation_fetch) is awaited SYNCHRONOUSLY on a dedicated queue, off the
3949
+ # general outbox, so it never interleaves with the client-fetch / message reply protocol;
3950
+ # a client fetch (positive id) rides the outbox as before, tagged with the originating realm.
3951
+ private def sw_deliver_fetch_response(handle, fetch_id, resp, outbox, realm_id = 0)
3952
+ if fetch_id.negative?
3953
+ @sw_nav_outbox << {fetch_id: fetch_id, resp: resp}
3954
+ else
3955
+ outbox << {handle: handle, kind: 'fetch_response', fetch_id: fetch_id, resp: resp, realm_id: realm_id}
3956
+ end
3957
+ end
3958
+
3959
+ # The active worker handle at an EXACT scope (0 if none) — see __csim_swActiveHandleForScope.
3960
+ def sw_active_handle_for_scope(scope) = @sw_registrations[scope.to_s].to_i
3961
+
3962
+ # Mirror a registration's active-worker handle into Ruby, keyed by its (serialized) scope.
3963
+ # Emitted by the client lifecycle at activation; survives rebuild_ctx so a navigation can
3964
+ # find its controlling SW even after the destination realm's JS was rebuilt.
3965
+ def sw_register_scope(scope, handle)
3966
+ @sw_registrations[scope.to_s] = handle.to_i
3967
+ # Flush any clients.claim() that arrived before this scope was mirrored (a worker's
3968
+ # `activate → clients.claim()` fires decoupled from the client-side lifecycle that populates
3969
+ # @sw_registrations, so the claim can be drained first — see the claim handler above).
3970
+ if @sw_pending_claims.any? {|e| e[:handle].to_i == handle.to_i }
3971
+ flush, @sw_pending_claims = @sw_pending_claims.partition {|e| e[:handle].to_i == handle.to_i }
3972
+ flush.each {|e| broadcast_claim(e[:handle], e[:has_fetch], scope.to_s) }
3973
+ end
3974
+ nil
3975
+ end
3976
+
3977
+ # Deliver a clients.claim() to EVERY in-scope client: broadcast to the main realm AND every
3978
+ # frame realm; each self-checks whether its own document is in the claiming registration's
3979
+ # scope (__csim_swClaimClient) so no realm→URL map is needed here. has_fetch = the SW's
3980
+ # install-time fetch-listener snapshot, so a claimed client routes its fetches.
3981
+ private def broadcast_claim(handle, has_fetch, scope)
3982
+ script_url = @workers.dig(handle.to_i, :script_url).to_s
3983
+ # The authoritative registration scope set — the claim's longest-registration-wins check runs
3984
+ # against this, not a realm-local map that can lag under load (claim-not-using-registration).
3985
+ all_scopes = @sw_registrations.keys
3986
+ @runtime.call('__csim_swClaimClient', handle, has_fetch, script_url, scope, all_scopes)
3987
+ @runtime.frame_realm_ids.each do |rid|
3988
+ @runtime.realm_call(rid, '__csim_swClaimClient', handle, has_fetch, script_url, scope, all_scopes) if @runtime.frame_realm_alive?(rid)
3989
+ end
3990
+ nil
3991
+ end
3992
+ # Navigation Preload state for a registration's active worker (keyed by its handle). Returns the
3993
+ # spec default {enabled:false, headerValue:'true'} when never set. Read by the client- and
3994
+ # worker-side NavigationPreloadManager (getState) and at navigation time (nav_preload_enabled?).
3995
+ def nav_preload_state(handle)
3996
+ st = @sw_navpreload[handle.to_i] || {}
3997
+ {'enabled' => st.fetch(:enabled, false), 'headerValue' => st.fetch(:header, 'true')}
3998
+ end
3999
+
4000
+ # Update the state for a worker handle. A nil `enabled` / `header` leaves that field unchanged
4001
+ # (enable/disable set only enabled; setHeaderValue sets only the header — the JS side has already
4002
+ # validated the header value and String()-ified it). The InvalidStateError "no active worker"
4003
+ # gate lives in the JS manager (a null handle never reaches here).
4004
+ def nav_preload_set(handle, enabled, header)
4005
+ st = (@sw_navpreload[handle.to_i] ||= {})
4006
+ st[:enabled] = !!enabled unless enabled.nil?
4007
+ st[:header] = header.to_s unless header.nil?
4008
+ nil
4009
+ end
4010
+
4011
+ # Whether the registration whose active worker controls `url` has navigation preload enabled —
4012
+ # gates the parallel preload request during a navigation.
4013
+ def nav_preload_enabled?(handle)
4014
+ handle && @sw_navpreload.dig(handle.to_i, :enabled) ? true : false
4015
+ end
4016
+
4017
+ def sw_unregister_scope(scope)
4018
+ @sw_registrations.delete(scope.to_s)
4019
+ nil
4020
+ end
4021
+
4022
+ # The registration handle controlling `url` — the one whose serialized scope is the longest
4023
+ # prefix of `url` (spec "Match Service Worker Registration"; the scope embeds the origin, so
4024
+ # a cross-origin scope can't prefix-match). nil when no registration's scope matches.
4025
+ private def sw_scope_match(url)
4026
+ u = url.to_s
4027
+ best = nil
4028
+ best_len = -1
4029
+ @sw_registrations.each do |scope, handle|
4030
+ next unless u.start_with?(scope) && scope.length > best_len
4031
+ best = [handle, scope]
4032
+ best_len = scope.length
4033
+ end
4034
+ best
4035
+ end
4036
+
4037
+ # The controller for a freshly-built frame realm at `url`, for wiring its
4038
+ # `navigator.serviceWorker.controller`. Returns [handle, has_fetch, script_url] or nil.
4039
+ # Unlike the navigation variant this keeps a controller whose fetch-handler snapshot is
4040
+ # still UNKNOWN (nil, racing the SW's initial eval) — resolved to `true` here so the frame
4041
+ # is controlled and routes; a controlled subresource fetch simply falls through to the
4042
+ # network if no handler materializes. Only a KNOWN-false (messaging/push-only) SW skips.
4043
+ def sw_client_controller_for(url)
4044
+ match = sw_scope_match(url) or return nil
4045
+ handle, scope = match
4046
+ w = @workers[handle] or return nil
4047
+ return nil unless w[:thread]&.alive?
4048
+
4049
+ [handle, w[:has_fetch] != false, w[:script_url].to_s, scope]
4050
+ end
4051
+
4052
+ # The controller an OPAQUE child browsing context (about:blank / srcdoc)
4053
+ # inherits from its creator. An about:blank document has no URL to scope-match,
4054
+ # so it's controlled by its parent's active service worker (HTML "create and
4055
+ # initialize a Document" inherits the creator's controller). Keyed by the
4056
+ # parent frame realm's id, recorded when that realm was wired (below).
4057
+ def sw_inherited_controller_for(parent_realm_id)
4058
+ return nil if parent_realm_id.nil? || parent_realm_id.to_i.zero?
4059
+ ctrl = @sw_realm_controller[parent_realm_id.to_i]
4060
+ return nil unless ctrl && @workers[ctrl[0]]&.dig(:thread)&.alive?
4061
+
4062
+ ctrl
4063
+ end
4064
+
4065
+ # Remember a frame/window realm's controller so its OWN opaque children can
4066
+ # inherit it (sw_inherited_controller_for). Set at frame-realm build for both
4067
+ # a scope-matched and an inherited controller, so inheritance chains through
4068
+ # nested about:blank frames.
4069
+ def sw_note_realm_controller(realm_id, ctrl)
4070
+ @sw_realm_controller[realm_id.to_i] = ctrl
4071
+ nil
4072
+ end
4073
+
4074
+ # Register a controlled client (a frame/window realm) with its controlling SW
4075
+ # so `clients.matchAll()` / `getClientByURL` reflect the real client set — not
4076
+ # only clients that happened to postMessage the worker. The client id is
4077
+ # realm-scoped (`client-<realm>`), stable for the realm's life. Pushed to the
4078
+ # SW inbox (processed FIFO, so it precedes any later message that matchAll's it).
4079
+ def sw_register_client(realm_id, url, type, frame_type, handle)
4080
+ w = @workers[handle.to_i] or return
4081
+ rec = {'id' => "client-#{realm_id.to_i}", 'url' => url.to_s, 'type' => type.to_s, 'frameType' => frame_type.to_s}
4082
+ @sw_clients[realm_id.to_i] = {handle: handle.to_i, rec: rec}
4083
+ w[:inbox] << {kind: 'client_register', client: rec}
4084
+ nil
4085
+ end
4086
+
4087
+ # Drop a client whose realm was disposed (frame navigated away / removed) so
4088
+ # matchAll stops returning a dead client. No-op for an unregistered realm.
4089
+ def sw_unregister_client(realm_id)
4090
+ @sw_realm_controller.delete(realm_id.to_i)
4091
+ entry = @sw_clients.delete(realm_id.to_i) or return
4092
+ w = @workers[entry[:handle]] or return
4093
+ w[:inbox] << {kind: 'client_unregister', id: entry[:rec]['id']}
4094
+ nil
4095
+ end
4096
+
4097
+ # ── Cross-isolate MessagePort channel relay (client realm ↔ worker/SW isolate) ──
4098
+ # Each endpoint self-registers when it (de)serializes the transferred port.
4099
+ def port_channel_endpoint_realm(channel, realm_id)
4100
+ ch = (@port_channels[channel.to_s] ||= {})
4101
+ ch[:realm] = realm_id.to_i
4102
+ # Flush anything the worker posted before this endpoint was known (deliver_worker_messages).
4103
+ if (pending = ch.delete(:pending_realm))
4104
+ pending.each {|d| deliver_port_to_realm(realm_id.to_i, channel.to_s, d) }
4105
+ end
4106
+ nil
4107
+ end
4108
+ # Deliver a channel message into a client realm's endpoint port (realm 0 = the main realm).
4109
+ private def deliver_port_to_realm(rid, channel, data)
4110
+ if rid.zero?
4111
+ @runtime.call('__csimPortChannelDeliver', channel, data)
4112
+ elsif @runtime.frame_realm_alive?(rid)
4113
+ @runtime.realm_call(rid, '__csimPortChannelDeliver', channel, data)
4114
+ end
4115
+ end
4116
+ def port_channel_endpoint_sw(channel, handle)
4117
+ ch = (@port_channels[channel.to_s] ||= {})
4118
+ ch[:sw] = handle.to_i
4119
+ # Flush anything the client posted before this endpoint was known (see client_port_post).
4120
+ if (pending = ch.delete(:pending_sw)) && (w = @workers[handle.to_i])
4121
+ pending.each {|d| @sw_message_pending += 1; w[:inbox] << {kind: 'port_msg', channel: channel.to_s, data: d} }
4122
+ end
4123
+ nil
4124
+ end
4125
+ # A client-realm port posts to its remote (worker/SW) peer: relay to the isolate's inbox.
4126
+ # Counted like an sw_message so settle waits for the worker to process it (and any reply it
4127
+ # posts straight back on the same or another channel). A message posted BEFORE the peer endpoint
4128
+ # is registered (a port used right after transfer, before the worker decoded it — the Comlink
4129
+ # handshake) is BUFFERED on the channel and flushed by port_channel_endpoint_sw, per HTML's port
4130
+ # message queue, rather than dropped.
4131
+ def client_port_post(channel, data)
4132
+ ch = (@port_channels[channel.to_s] ||= {})
4133
+ handle = ch[:sw]
4134
+ if handle && (w = @workers[handle])
4135
+ @sw_message_pending += 1
4136
+ w[:inbox] << {kind: 'port_msg', channel: channel.to_s, data: data.to_s}
4137
+ else
4138
+ (ch[:pending_sw] ||= []) << data.to_s
4139
+ end
4140
+ nil
4141
+ end
4142
+
4143
+ # The active fetch-handling worker controlling a navigation to `url`. nil when uncontrolled
4144
+ # or the SW is known to have no fetch listener (→ load from the network).
4145
+ private def sw_controller_for_navigation(url)
4146
+ match = sw_scope_match(url) or return nil
4147
+ handle = match[0]
4148
+ w = @workers[handle] or return nil
4149
+ # has_fetch is published from the worker thread AFTER its initial eval, so a navigation
4150
+ # into a freshly-activated registration can read it as nil (unknown) — race-prone for the
4151
+ # 2nd+ registration, whose initial load fires before the publish. Treat unknown as "maybe":
4152
+ # route through and let the fetch dispatch fall through if the SW turns out to have no
4153
+ # handler. Skip only a KNOWN-false (messaging/push-only) SW, or one whose thread is already
4154
+ # DEAD (crashed during eval / closed) — routing there would just stall the nav for the full
4155
+ # round-trip budget with no one to answer.
4156
+ return nil if w[:has_fetch] == false || !w[:thread]&.alive?
4157
+
4158
+ handle
4159
+ end
4160
+
4161
+ # Route a navigation request (document / iframe load) to its controlling SW's `fetch`
4162
+ # event and BLOCK for the respondWith result. Mirrors settle's bounded wait: the main
4163
+ # thread releases the GVL on the dedicated `@sw_nav_outbox`, so the worker thread runs the
4164
+ # handler and posts back. Navigation ids are NEGATIVE so the response is delivered on that
4165
+ # queue (sw_deliver_fetch_response), not the general outbox. Returns the parsed response
4166
+ # hash (SW served the document), or nil to load from the network (no controller, no
4167
+ # respondWith, network error, or the SW didn't answer within the round-trip budget).
4168
+ def service_worker_navigation_fetch(url, is_reload: false, is_history: false, referrer_source: nil, referrer_policy: nil, method: 'GET', body_b64: '', content_type: nil, site_seed: nil, origin_null: false)
4169
+ handle = sw_controller_for_navigation(url) or return nil
4170
+ w = @workers[handle] or return nil
4171
+ fetch_id = (@sw_nav_seq -= 1)
4172
+ # Navigation Preload: when the controlling registration has it enabled, issue the parallel
4173
+ # preload request NOW (main thread, before dispatching the event) and hand the response to the
4174
+ # SW as `event.preloadResponse`. GET only (the feature does not support other methods). The SW
4175
+ # that serves `respondWith(event.preloadResponse)` echoes this response back — the server is hit
4176
+ # exactly once. (EARNED GAP: if the SW instead FALLS THROUGH, this method returns nil and the
4177
+ # caller re-fetches from the network — a second hit; the spec reuses the preload response for
4178
+ # the fall-through. No vendored subtest enables preload then falls through.)
4179
+ preload = if method.to_s.upcase == 'GET' && nav_preload_enabled?(handle)
4180
+ navigation_preload_response(
4181
+ url,
4182
+ referrer_source,
4183
+ referrer_policy,
4184
+ site_seed,
4185
+ origin_null,
4186
+ nav_preload_state(handle)['headerValue']
4187
+ )
4188
+ end
4189
+ # A form submission navigates with the form's method + encoded body (a POST nav the SW reads
4190
+ # via `event.request.text()`); the Content-Type the form's enctype implies rides its headers.
4191
+ headers = {'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7'}
4192
+ headers['Content-Type'] = content_type.to_s unless content_type.to_s.empty?
4193
+ req = JSON.generate(
4194
+ method: method.to_s.empty? ? 'GET' : method.to_s.upcase,
4195
+ url: url.to_s,
4196
+ # The Accept header Fetch inserts for a navigation request (destination 'document').
4197
+ headers: headers,
4198
+ body_b64: body_b64.to_s,
4199
+ mode: 'navigate',
4200
+ destination: 'document',
4201
+ isReloadNavigation: is_reload,
4202
+ isHistoryNavigation: is_history,
4203
+ # A reload navigation revalidates (cache mode 'no-cache'); a fresh/history load is 'default'.
4204
+ cache: is_reload ? 'no-cache' : 'default',
4205
+ # The navigation's referrer is the initiating document, resolved under ITS Referrer-Policy
4206
+ # (the document default absent a meta/header — strict-origin-when-cross-origin — so a
4207
+ # same-origin nav keeps the full URL). A SW that re-issues the request
4208
+ # (`fetch(event.request)`) computes Sec-Fetch-Site / Origin against this referrer's origin.
4209
+ referrer: compute_referrer(referrer_policy, referrer_source, url.to_s).to_s,
4210
+ # `event.request.referrerPolicy` reflects the RESOLVED policy: a navigation with no explicit
4211
+ # meta/header policy uses the document default (strict-origin-when-cross-origin), never the
4212
+ # empty string a bare Request would carry (fetch-event-referrer-policy "default referrer policy").
4213
+ referrerPolicy: referrer_policy.to_s.empty? ? 'strict-origin-when-cross-origin' : referrer_policy.to_s,
4214
+ # A passthrough `fetch(event.request)` re-fetch reports the navigation's OWN request
4215
+ # metadata to the server, independent of the referrer (which Referrer-Policy may reduce):
4216
+ # the initiator origin (the navigating frame's — the request's origin for the Origin
4217
+ # header) and the redirect chain's latched Sec-Fetch-Site seed / Origin taint accumulated
4218
+ # by the network hops before the SW intercepted the final URL.
4219
+ initiator: url_origin(referrer_source),
4220
+ siteSeed: site_seed,
4221
+ originNull: origin_null,
4222
+ # The Navigation Preload response (nil unless preload is enabled), surfaced to the handler
4223
+ # as `event.preloadResponse`.
4224
+ preloadResponse: preload
4225
+ )
4226
+ w[:inbox] << {kind: 'fetch', req:, fetch_id:}
4227
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + WORKER_ROUND_TRIP_BUDGET
4228
+ while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
4229
+ ev = pop_with_timeout(@sw_nav_outbox, WORKER_POLL_INTERVAL) or next
4230
+ next unless ev[:fetch_id] == fetch_id # discard a stale response from a timed-out nav
4231
+
4232
+ resp = JSON.parse(ev[:resp])
4233
+ return nil if resp['fallthrough'] # no respondWith → load from the network
4234
+ return {'networkError' => true} if resp['networkError'] # respondWith(Response.error()) → failed navigation
4235
+
4236
+ return resp
4237
+ end
4238
+ nil # SW never answered within budget → load from the network
4239
+ end
4240
+
3445
4241
  def worker_terminate(handle)
3446
4242
  w = @workers.delete(handle.to_i)
3447
4243
  return unless w
@@ -3456,26 +4252,159 @@ module Capybara
3456
4252
  w[:thread].kill
3457
4253
  w[:thread].join(WORKER_TERMINATE_GRACE)
3458
4254
  end
4255
+ # The dead worker never answers what was still queued in its inbox: post the matching
4256
+ # fallback replies so the reply-pending counters drain (a controlled fetch falls back to
4257
+ # the network) instead of taxing every later settle's bounded wait. Mid-dispatch deaths
4258
+ # are covered by the `ensure` acks in run_worker; only a respondWith parked on a worker
4259
+ # timer that never fires can still strand a fetch (the client promise then just never
4260
+ # settles, like a real dead SW).
4261
+ until w[:inbox].empty?
4262
+ msg = begin
4263
+ w[:inbox].pop(true)
4264
+ rescue ThreadError
4265
+ break
4266
+ end
4267
+ next unless msg.is_a?(Hash)
4268
+ case msg[:kind]
4269
+ when 'sw_message', 'port_msg' then @worker_outbox << {handle: handle.to_i, kind: 'swack'}
4270
+ when 'broadcast' then @worker_outbox << {handle: handle.to_i, kind: 'bcack'}
4271
+ when 'fetch' then sw_deliver_fetch_response(handle.to_i, msg[:fetch_id].to_i, '{"fallthrough":true}', @worker_outbox, msg[:realm_id].to_i)
4272
+ end
4273
+ end
4274
+ # A streaming respondWith whose worker died mid-body never emits its terminal frame: error the
4275
+ # client's stream (so a pending body read rejects, not hangs) and release the @sw_fetch_pending
4276
+ # each open stream still holds, so settle can reach idle.
4277
+ open = @sw_open_streams.delete(handle.to_i)
4278
+ if open&.any?
4279
+ open.each_key do |realm_id, fetch_id|
4280
+ @runtime.realm_call(realm_id, '__csim_swFetchStreamError', fetch_id)
4281
+ end
4282
+ @sw_fetch_pending = [0, @sw_fetch_pending - open.size].max
4283
+ end
4284
+ # Drop any navigation scope mirrored to this now-dead worker so a later navigation
4285
+ # doesn't route to it (it falls through to the network instead).
4286
+ @sw_registrations.reject! {|_scope, h| h == handle.to_i }
4287
+ @sw_navpreload.delete(handle.to_i)
3459
4288
  # A blocked worker that never returned messages leaves
3460
4289
  # `@worker_in_flight` permanently > 0; reset when no workers
3461
4290
  # remain so `polling?` can short-circuit again.
3462
- @worker_in_flight = 0 if @workers.empty?
4291
+ (@worker_in_flight = 0; @worker_broadcast_pending = 0; @sw_message_pending = 0; @sw_fetch_pending = 0; @sw_fetch_wait_deadline = nil; @sw_msg_wait_deadline = nil; @sw_clients = {}; @sw_realm_controller = {}; @port_channels = {}; @sw_pending_claims = []; @sw_navpreload = {}; @sw_open_streams.clear) if @workers.empty?
3463
4292
  # The worker is gone — revoke the blob URLs it created.
3464
4293
  revoke_worker_blobs(handle.to_i)
3465
4294
  end
3466
4295
 
3467
4296
  def deliver_worker_messages
3468
- return 0 if @workers.empty? && @worker_outbox.empty?
4297
+ head = @worker_outbox_head
4298
+ @worker_outbox_head = nil
4299
+ return 0 if head.nil? && @workers.empty? && @worker_outbox.empty?
3469
4300
  events = drain_queue(@worker_outbox)
4301
+ events.unshift(head) if head
3470
4302
  return 0 if events.empty?
3471
- # `__error` postbacks don't correspond to a prior post, so
3472
- # bottom out at zero.
3473
- @worker_in_flight = [0, @worker_in_flight - events.size].max
3474
- @runtime.call('__csim_deliverWorkerMessages', events)
4303
+ # A worker-originated BroadcastChannel post ('broadcast') is fanned out on the main thread and
4304
+ # is NOT a reply. A 'bcack' acknowledges a broadcast the worker just delivered → release one
4305
+ # broadcast-pending. Everything else ('message'/'__error') is a postMessage reply.
4306
+ broadcasts, rest0 = events.partition {|e| e[:kind] == 'broadcast' }
4307
+ port_ends, rest0b = rest0.partition {|e| e[:kind] == 'port_endpoint' }
4308
+ port_msgs, rest0c = rest0b.partition {|e| e[:kind] == 'port_msg' }
4309
+ sw_msgs, rest1 = rest0c.partition {|e| e[:kind] == 'sw_client_msg' }
4310
+ swacks, rest2 = rest1.partition {|e| e[:kind] == 'swack' }
4311
+ claims, rest3 = rest2.partition {|e| e[:kind] == 'sw_claim' }
4312
+ fetch_resps, rest4 = rest3.partition {|e| e[:kind] == 'fetch_response' }
4313
+ stream_frames, rest4b = rest4.partition {|e| e[:kind].to_s.start_with?('fr_') }
4314
+ acks, msgs = rest4b.partition {|e| e[:kind] == 'bcack' }
4315
+ broadcasts.each {|e| broadcast_to_windows(e[:name], e[:data], nil, e[:origin], from_worker: e[:handle]) }
4316
+ # A worker/SW registering its end of a cross-isolate MessagePort channel — record it BEFORE
4317
+ # the message events below, so a port message carried in the same drain can already route.
4318
+ port_ends.each {|e| port_channel_endpoint_sw(e[:channel], e[:handle]) }
4319
+ # A worker/SW port → its remote (client-realm) peer: relay to that realm's channel endpoint.
4320
+ # If the client hasn't registered its endpoint yet (it decodes the transferred port in the
4321
+ # sw_client_msg processed just below), BUFFER until port_channel_endpoint_realm flushes.
4322
+ port_msgs.each do |e|
4323
+ ch = (@port_channels[e[:channel].to_s] ||= {})
4324
+ rid = ch[:realm]
4325
+ if rid.nil?
4326
+ (ch[:pending_realm] ||= []) << e[:data]
4327
+ else
4328
+ deliver_port_to_realm(rid, e[:channel].to_s, e[:data])
4329
+ end
4330
+ end
4331
+ # A service worker → client message: deliver to the POSTING client's realm. The client id
4332
+ # encodes it — `client-<realm>` for a frame/window realm, 'client-window' for the main realm.
4333
+ # A `client.postMessage` to a controlled IFRAME must reach THAT frame's navigator.service-
4334
+ # Worker, not the top window (postmessage-to-client). `e[:handle]` is the SENDING worker, so
4335
+ # the client's message `source` is exact. A message to a DISCARDED frame realm is dropped
4336
+ # (matching a real browser — a message to a gone client is discarded, not misrouted to top).
4337
+ sw_msgs.each do |e|
4338
+ m = /\Aclient-(\d+)\z/.match(e[:client].to_s)
4339
+ if m
4340
+ rid = m[1].to_i
4341
+ @runtime.realm_call(rid, '__csim_swDeliverClientMessage', e[:data], e[:handle]) if @runtime.frame_realm_alive?(rid)
4342
+ else
4343
+ @runtime.call('__csim_swDeliverClientMessage', e[:data], e[:handle])
4344
+ end
4345
+ end
4346
+ # clients.claim(): the claiming worker takes control of EVERY in-scope client — including
4347
+ # ones that never register()'d (an iframe built before the SW existed). See broadcast_claim.
4348
+ # Process LONGEST scope first: when nested-scope workers claim in the same drain, the deeper
4349
+ # registration must be installed before the shallower one runs, or a client the shallow claim
4350
+ # transiently seizes would fire a spurious extra controllerchange (a reload-on-controllerchange
4351
+ # page would double-fire). A claim whose scope isn't mirrored into @sw_registrations yet — the
4352
+ # worker fires activate→claim() decoupled from the CLIENT-side lifecycle that populates it — is
4353
+ # BUFFERED and flushed by sw_register_scope, so an `activate → clients.claim()` isn't lost.
4354
+ claims.map {|e| [e, @sw_registrations.key(e[:handle].to_i)] }
4355
+ .sort_by {|_e, scope| -(scope ? scope.length : -1) }
4356
+ .each do |e, scope|
4357
+ if scope
4358
+ broadcast_claim(e[:handle], e[:has_fetch], scope)
4359
+ else
4360
+ @sw_pending_claims << e
4361
+ end
4362
+ end
4363
+ # A controlled fetch's respondWith result → resolve the pending client fetch in the
4364
+ # realm that issued it (fetch ids are per-realm, so realm_id disambiguates collisions).
4365
+ fetch_resps.each {|e| @runtime.realm_call(e[:realm_id].to_i, '__csim_swControllerFetchResponse', e[:fetch_id], e[:resp]) }
4366
+ # Streaming respondWith frames — deliver IN EMISSION ORDER (per fetch id: start → chunk* →
4367
+ # close/error) so the client reassembles the body ReadableStream correctly. The request's
4368
+ # @sw_fetch_pending was counted at fetch time and clears on the terminal frame.
4369
+ stream_frames.each do |e|
4370
+ fn = STREAM_FRAME_FNS[e[:kind]]
4371
+ @runtime.realm_call(e[:realm_id].to_i, fn, e[:fetch_id], e[:payload]) if fn
4372
+ # Track a stream's open span (head → terminal) per emitting worker, so worker_terminate
4373
+ # can release + error a body its worker died mid-stream.
4374
+ key = [e[:realm_id].to_i, e[:fetch_id].to_i]
4375
+ if e[:kind] == 'fr_start' then @sw_open_streams[e[:handle].to_i][key] = true
4376
+ else @sw_open_streams[e[:handle].to_i].delete(key)
4377
+ end
4378
+ end
4379
+ stream_terminals = stream_frames.count {|e| e[:kind] == 'fr_close' || e[:kind] == 'fr_error' }
4380
+ @sw_fetch_pending = [0, @sw_fetch_pending - fetch_resps.size - stream_terminals].max
4381
+ @worker_broadcast_pending = [0, @worker_broadcast_pending - acks.size].max
4382
+ @sw_message_pending = [0, @sw_message_pending - swacks.size].max
4383
+ # A delivered swack/bcack refreshes the message-wait budget (drain_pending_message_reply), so
4384
+ # the next reply in a sequence waits afresh instead of inheriting a spent deadline. Done here,
4385
+ # at the single delivery point, so it fires no matter which drain path delivered the reply —
4386
+ # including hold_for_sw_fetch's outbox drain when a fetch co-pends (which would otherwise
4387
+ # strand an expired deadline and re-starve the next transferable reply under load).
4388
+ @sw_msg_wait_deadline = nil if acks.size.positive? || swacks.size.positive?
4389
+ # `__error` postbacks don't correspond to a prior post, so bottom out at zero.
4390
+ @worker_in_flight = [0, @worker_in_flight - msgs.size].max
4391
+ @runtime.call('__csim_deliverWorkerMessages', msgs) unless msgs.empty?
3475
4392
  events.size
3476
4393
  end
3477
4394
 
3478
- def worker_pending? = !@worker_outbox.empty? || @worker_in_flight > 0 || @worker_init_lock.synchronize { @worker_initializing } > 0
4395
+ def worker_pending? = !@worker_outbox.empty? || !@worker_outbox_head.nil? || @worker_in_flight > 0 || @worker_broadcast_pending > 0 || @sw_message_pending > 0 || @sw_fetch_pending > 0 || @worker_init_lock.synchronize { @worker_initializing + @worker_busy } > 0
4396
+
4397
+ # The subset of worker pendings whose outbox reply is CONTRACTUAL (bcack / swack /
4398
+ # fetch_response are posted under `ensure`, so they arrive even when the worker-side
4399
+ # handler raises). Safe for settle to block a bounded wait on — unlike @worker_in_flight
4400
+ # (a plain postMessage that a listen-only worker never answers) or @worker_initializing.
4401
+ def worker_reply_pending? = @worker_broadcast_pending > 0 || @sw_message_pending > 0 || @sw_fetch_pending > 0
4402
+
4403
+ # The message/broadcast subset of `worker_reply_pending?` — a swack (client→SW postMessage /
4404
+ # cross-isolate port message) or a bcack (BroadcastChannel post). `run_event_loop_frame` waits
4405
+ # on these WITHOUT holding the clock (drain_pending_message_reply); the SW-fetch pending is held
4406
+ # separately (hold_for_sw_fetch), so it's deliberately excluded here.
4407
+ def worker_message_reply_pending? = @worker_broadcast_pending > 0 || @sw_message_pending > 0
3479
4408
 
3480
4409
  # ── Cross-window messaging (window.open / opener / postMessage) ──
3481
4410
  # Each window is a separate Browser/VM/isolate, so a reference to another
@@ -3586,14 +4515,32 @@ module Capybara
3586
4515
 
3587
4516
  # Covers both cross-window postMessage AND BroadcastChannel — the two
3588
4517
  # cross-window event channels share these drain/pending hooks.
3589
- def window_message_pending? = !@window_inbox.empty? || !@broadcast_inbox.empty?
4518
+ def window_message_pending? = !@window_inbox.empty? || !@broadcast_inbox.empty? || !@storage_inbox.empty? || !@bc_queue.empty?
3590
4519
 
3591
4520
  # A BroadcastChannel message queued for delivery to this Browser's channels.
3592
4521
  # `source_realm_id` is the posting realm's context id within THIS isolate (0 =
3593
4522
  # main), or nil when the post came from ANOTHER isolate (the Driver's cross-
3594
4523
  # window fanout) — a nil source matches no local realm, so it reaches every one.
3595
- def enqueue_broadcast(name, data, source_realm_id = nil)
3596
- @broadcast_inbox << {'name' => name.to_s, 'data' => data, 'source' => source_realm_id}
4524
+ def enqueue_broadcast(name, data, source_realm_id = nil, origin = nil)
4525
+ @broadcast_inbox << {'name' => name.to_s, 'data' => data, 'source' => source_realm_id, 'origin' => origin}
4526
+ end
4527
+
4528
+ # A Storage change (setItem/removeItem/clear) in realm `source_realm_id`: fire a `storage`
4529
+ # event at every OTHER same-origin document. Within this isolate that's the other realms
4530
+ # (main + frames — a window's documents share both storage areas); localStorage ALSO spans
4531
+ # separate same-origin windows (the Driver shares its jar), so fan the change out to them —
4532
+ # sessionStorage is per-browsing-context and never crosses windows.
4533
+ def storage_changed(kind, key, old, new, url, source_realm_id)
4534
+ enqueue_storage_event(kind, key, old, new, url, source_realm_id)
4535
+ @driver.storage_broadcast(self, kind, key, old, new, url) if kind == 'local' && @driver.respond_to?(:storage_broadcast)
4536
+ nil
4537
+ end
4538
+
4539
+ # Queue a storage event for THIS window's documents. `source_realm_id` is the changing realm
4540
+ # within this isolate (skipped at delivery); nil (a cross-window fan-out) matches no realm, so
4541
+ # it reaches every one.
4542
+ def enqueue_storage_event(kind, key, old, new, url, source_realm_id = nil)
4543
+ @storage_inbox << {'kind' => kind.to_s, 'key' => key, 'old' => old, 'new' => new, 'url' => url.to_s, 'source' => source_realm_id}
3597
4544
  end
3598
4545
 
3599
4546
  # Fire queued cross-window messages (postMessage + BroadcastChannel).
@@ -3623,6 +4570,26 @@ module Capybara
3623
4570
  end
3624
4571
  n += events.size
3625
4572
  end
4573
+ unless @storage_inbox.empty?
4574
+ events = @storage_inbox.slice!(0, @storage_inbox.length)
4575
+ # The `storage` event fires at every same-origin document EXCEPT the one that changed
4576
+ # the area — deliver to the main realm (0) and every live frame realm, skipping the
4577
+ # source realm. A nil source (a cross-window fan-out) is excluded from no realm.
4578
+ realm_ids = @runtime.respond_to?(:frame_realm_ids) ? @runtime.frame_realm_ids : []
4579
+ [0, *realm_ids].each do |target_id|
4580
+ batch = events.reject {|e| e['source'] == target_id }
4581
+ next if batch.empty?
4582
+ if target_id.zero?
4583
+ @runtime.call('__csim_deliverStorageEvents', batch)
4584
+ elsif @runtime.frame_realm_alive?(target_id)
4585
+ @runtime.realm_call(target_id, '__csim_deliverStorageEvents', batch)
4586
+ end
4587
+ end
4588
+ n += events.size
4589
+ end
4590
+ # Drain the ordered BroadcastChannel queue LAST, so a `message`/`storage` handler above that
4591
+ # re-posts (multi-realm mode → bc_post) is picked up in this same pass.
4592
+ n += deliver_broadcast_queue unless @bc_queue.empty?
3626
4593
  n
3627
4594
  end
3628
4595
 
@@ -3632,13 +4599,107 @@ module Capybara
3632
4599
  # fanout goes through the Driver; the same-ISOLATE fanout (main ↔ sibling realms,
3633
4600
  # sibling ↔ sibling) is queued here and delivered per-realm by
3634
4601
  # `deliver_window_messages`, which skips the posting realm.
3635
- def broadcast_to_windows(name, data, source_realm_id = 0)
3636
- @driver.broadcast_channel(self, name.to_s, data) if @driver.respond_to?(:broadcast_channel)
3637
- # Only queue for same-isolate delivery when sibling realms exist — a single-
3638
- # realm page already delivered to itself in-VM, so this stays zero-overhead.
3639
- if @runtime.respond_to?(:frame_realm_ids) && @runtime.frame_realm_ids.any?
3640
- enqueue_broadcast(name, data, source_realm_id.to_i)
4602
+ # Fan a BroadcastChannel post out to every OTHER same-origin browsing context. Called on the
4603
+ # MAIN thread either directly from a main/frame-realm post (`source_realm_id` = the poster's
4604
+ # realm, `from_worker` nil), or from `deliver_worker_messages` for a WORKER-originated post
4605
+ # (`from_worker` = the posting worker's handle, `source_realm_id` nil).
4606
+ def broadcast_to_windows(name, data, source_realm_id = 0, origin = nil, from_worker: nil)
4607
+ broadcast_external(name, data, origin, from_worker: from_worker)
4608
+ # Same-isolate main + frame realms (LEGACY single-realm / worker-inbound path — the multi-realm
4609
+ # main-thread post goes through `bc_post`'s ordered queue instead). The poster already delivered
4610
+ # to itself in-VM, so this only reaches the OTHER realms — queue whenever one exists. A WORKER
4611
+ # post reaches realm 0 and every frame (a separate isolate delivered to none in-VM). A FRAME post
4612
+ # always reaches main realm 0 — even when the posting frame's own realm isn't yet recorded in
4613
+ # `frame_realms` (a BroadcastChannel posted SYNCHRONOUSLY during the frame's initial script runs
4614
+ # before the realm is registered, so `has_frames` can be false though a valid target — main —
4615
+ # exists). A MAIN post reaches the frames only when some are registered.
4616
+ has_frames = @runtime.respond_to?(:frame_realm_ids) && @runtime.frame_realm_ids.any?
4617
+ from_frame = !from_worker && !source_realm_id.nil? && source_realm_id != 0
4618
+ enqueue_broadcast(name, data, source_realm_id, origin) if has_frames || from_worker || from_frame
4619
+ end
4620
+
4621
+ # Fan a BroadcastChannel post out beyond this isolate's main-thread realms: OTHER top-level windows
4622
+ # (separate isolates, via the Driver) and every live WORKER (separate isolate, via its thread-safe
4623
+ # inbox, except the posting worker). Shared by the legacy `broadcast_to_windows` and the ordered
4624
+ # `bc_post` — a same-isolate ordered post still reaches workers and other windows. `origin` here is
4625
+ # the SCOPING origin KEY (opaque token / tuple origin), which the worker + cross-window sides match on.
4626
+ def broadcast_external(name, data, origin, from_worker: nil)
4627
+ @driver.broadcast_channel(self, name.to_s, data, origin) if @driver.respond_to?(:broadcast_channel)
4628
+ # Skip a worker whose thread has already exited (self-close / termination in flight): it will
4629
+ # never drain its inbox, so pushing would leak. Counted in a dedicated pending tally so `settle`
4630
+ # waits until the worker acks the delivery (a `bcack`).
4631
+ @workers.each do |h, w|
4632
+ next if h == from_worker || !w[:thread].alive?
4633
+ @worker_broadcast_pending += 1
4634
+ w[:inbox] << {kind: 'broadcast', name: name.to_s, data: data, origin: origin}
4635
+ end
4636
+ end
4637
+
4638
+ # ── BroadcastChannel isolate-wide ordered delivery (multi-realm path) ──
4639
+ # A channel registers on construction with the isolate-wide creation counter, so delivery can be
4640
+ # ordered "oldest channel first" across realms.
4641
+ def bc_register(realm_id, local_id, name, origin_key)
4642
+ @bc_seq += 1
4643
+ @bc_registry[[realm_id.to_i, local_id.to_i]] = {seq: @bc_seq, name: name.to_s, origin_key: origin_key, closed: false}
4644
+ nil
4645
+ end
4646
+
4647
+ def bc_unregister(realm_id, local_id)
4648
+ @bc_registry.delete([realm_id.to_i, local_id.to_i])
4649
+ nil
4650
+ end
4651
+
4652
+ # Is another same-isolate realm (a frame / same-isolate window) live? Only then does a post use the
4653
+ # ordered registry; a single-realm page keeps the in-VM microtask path (zero behaviour change).
4654
+ def bc_siblings_exist? = @runtime.respond_to?(:frame_realm_ids) && @runtime.frame_realm_ids.any?
4655
+
4656
+ # A main-thread BroadcastChannel post in multi-realm mode. Snapshot the eligible target channels
4657
+ # (same name + origin, still open, excluding the poster) at POST TIME, ordered by creation seq, and
4658
+ # queue one ordered delivery each — so a channel created AFTER this post (higher seq / not yet
4659
+ # registered) never receives it, and cross-realm targets interleave with same-realm ones by
4660
+ # creation order (broadcastchannel/ordering). Also fans out to workers + other windows.
4661
+ def bc_post(realm_id, local_id, name, origin_key, data, origin)
4662
+ realm_id = realm_id.to_i
4663
+ local_id = local_id.to_i
4664
+ name = name.to_s
4665
+ # Snapshot ALL matching open channels — do NOT gate on `frame_realm_alive?` here: a channel that
4666
+ # posts SYNCHRONOUSLY during its frame's initial script (opaque-origin's data: iframes) runs in a
4667
+ # realm not yet registered in `frame_realms`, so a same-realm sibling would be wrongly excluded.
4668
+ # `deliver_broadcast_queue` re-checks realm liveness at delivery time (by then it's registered),
4669
+ # and a genuinely-dead realm's stale entry just yields a skipped delivery.
4670
+ targets = @bc_registry.reject {|(rid, lid), e|
4671
+ e[:closed] || e[:name] != name || e[:origin_key] != origin_key ||
4672
+ (rid == realm_id && lid == local_id)
4673
+ }.sort_by {|_k, e| e[:seq] }
4674
+ # `origin` (serialized, e.g. "null") is the MessageEvent.origin the same-isolate delivery
4675
+ # exposes; `origin_key` (e.g. "opaque:…") is the SCOPING token workers / other windows match on.
4676
+ targets.each {|(rid, lid), _e| @bc_queue << {realm_id: rid, local_id: lid, data: data, origin: origin} }
4677
+ broadcast_external(name, data, origin_key)
4678
+ nil
4679
+ end
4680
+
4681
+ # Drain the ordered BroadcastChannel queue, delivering one message to one channel at a time in
4682
+ # creation order. A handler's synchronous re-post appends to `@bc_queue` (via bc_post), so the loop
4683
+ # keeps draining those in order too — reproducing the single global task queue the spec describes.
4684
+ # Bounded per call (like every drain loop here): a pathological mutual re-post between two realms
4685
+ # would otherwise spin forever holding the GVL. The cap leaves any remainder queued
4686
+ # (`window_message_pending?` keeps the loop live), so a runaway is bounded by the runner's
4687
+ # force-timeout across frames rather than hanging uninterruptibly in one.
4688
+ def deliver_broadcast_queue
4689
+ n = 0
4690
+ until @bc_queue.empty?
4691
+ break if n >= BROADCAST_DRAIN_CAP
4692
+ item = @bc_queue.shift
4693
+ e = @bc_registry[[item[:realm_id], item[:local_id]]]
4694
+ next if e.nil? || e[:closed] # closed after being queued → gets nothing
4695
+ if item[:realm_id].zero?
4696
+ @runtime.call('__csim_bcDeliverOne', item[:local_id], item[:data], item[:origin])
4697
+ elsif @runtime.frame_realm_alive?(item[:realm_id])
4698
+ @runtime.realm_call(item[:realm_id], '__csim_bcDeliverOne', item[:local_id], item[:data], item[:origin])
4699
+ end
4700
+ n += 1
3641
4701
  end
4702
+ n
3642
4703
  end
3643
4704
 
3644
4705
  # ── Image decode (libvips) ─────────────────────────────────────
@@ -3655,29 +4716,210 @@ module Capybara
3655
4716
  # 8900×8900 frames Discourse uploads exercise. Optional
3656
4717
  # `max_w`/`max_h` lets the caller pre-shrink for cheap OCR-style
3657
4718
  # "downscale before pixel-touch" flows.
4719
+ # Load an image resource for an `<img>` (or a pattern/drawImage source):
4720
+ # resolve the URL against the current document, fetch the bytes, and decode
4721
+ # them to an RGBA buffer via libvips. Returns `{width, height, refId}` (the
4722
+ # raw pixels ride the transfer registry, like decode_image) or nil when the
4723
+ # fetch or decode fails (a broken image → the `<img>` fires `error`).
4724
+ # Fetch + decode an `<img>` resource to an RGBA bitmap for the drawImage /
4725
+ # createPattern surface, memoized by resolved URL (see `@@image_cache`). Returns
4726
+ # {'width','height','refId'} — a FRESH transfer stash per call, since
4727
+ # `fetchTransfer` consumes the registry entry — or nil when the resource can't be
4728
+ # fetched or decoded (the caller fires `error`). A scheme with no host-side reader
4729
+ # yet (blob:, whose bytes live in the VM) returns {'unsupported' => true} so the
4730
+ # caller stays inert rather than reporting a spuriously-broken image.
4731
+ # `cors` (a `crossorigin` <img>) fetches under CORS: a cross-origin response without a
4732
+ # matching Access-Control-Allow-Origin fails the load. `credentials` is 'include' for
4733
+ # crossorigin="use-credentials", 'same-origin' (uncredentialed cross-origin) otherwise.
4734
+ def load_image(url, cors = false, credentials = 'same-origin')
4735
+ key = resolve_against_current(url.to_s)
4736
+ return nil unless key.is_a?(String)
4737
+ entry = cached_image(key, cors, credentials)
4738
+ return {'unsupported' => true} if entry == :unsupported
4739
+ # A valid zero-area image: complete + not broken, but no pixels. rsvg throws
4740
+ # before dimensions can be read, so the intrinsic size collapses to 0×0 (a
4741
+ # browser would keep the non-zero axis, e.g. 0×100 → naturalHeight 100) — a minor
4742
+ # divergence, immaterial to createPattern / drawImage, which both need a
4743
+ # non-zero area.
4744
+ tainted = image_tainted?(key, cors)
4745
+ return {'zeroSize' => true, 'width' => 0, 'height' => 0, 'tainted' => tainted} if entry == :zero_size
4746
+ return nil unless entry
4747
+ r = {'width' => entry['width'], 'height' => entry['height'], 'refId' => transfer_buffer_stash(entry['bytes']), 'colorSpace' => entry['colorSpace'], 'tainted' => tainted}
4748
+ r['refIdP3'] = transfer_buffer_stash(entry['bytesP3']) if entry['bytesP3']
4749
+ r
4750
+ end
4751
+
4752
+ # Whether a successfully-loaded image taints a canvas it's drawn into: its bytes came
4753
+ # cross-origin without CORS approval — i.e. a no-cors http(s) load from a different origin
4754
+ # than the document. A CORS load that reached here passed the Access-Control check (so it's
4755
+ # origin-clean), and same-origin / data: images are always clean. An opaque-origin document
4756
+ # (data:/srcdoc/sandboxed — nil origin) is cross-origin to every http(s) image, so any such
4757
+ # image taints it.
4758
+ private def image_tainted?(key, cors)
4759
+ return false if cors || !key.match?(%r{\Ahttps?://}i)
4760
+ img = url_origin(key)
4761
+ return false unless img
4762
+ doc = url_origin(@current_url)
4763
+ doc.nil? || doc != img
4764
+ end
4765
+
4766
+ # A decoded-image cache entry for `key`, decoding + caching on a miss.
4767
+ # :unsupported for a scheme we can't fetch host-side; nil on fetch/decode failure.
4768
+ # A CORS load caches under a key tagged with the mode, the credentials, AND the requesting
4769
+ # document origin — its success/failure is origin-dependent (a response's ACAO may allow one
4770
+ # origin and not another), and @@image_cache is process-wide (shared across documents /
4771
+ # sessions), so an origin-blind key would serve one origin's CORS success to another origin
4772
+ # whose load should fail. A no-cors load is origin-independent (it always reads the bytes),
4773
+ # so it keeps the bare URL key and shares the cache with the decode / canvas loaders.
4774
+ private def cached_image(key, cors = false, credentials = 'same-origin')
4775
+ cache_key = cors ? "cors:#{credentials}:#{url_origin(@current_url)}:#{key}" : key
4776
+ cached = @@image_cache_lock.synchronize { @@image_cache[cache_key] }
4777
+ return cached if cached
4778
+ bytes = image_source_bytes(key, cors, credentials)
4779
+ return bytes if bytes == :unsupported
4780
+ return nil unless bytes
4781
+ entry = decode_or_nil(bytes)
4782
+ # nil (broken) and :zero_size (valid but zero-area) both carry no bitmap to cache.
4783
+ return entry unless entry.is_a?(Hash)
4784
+ @@image_cache_lock.synchronize do
4785
+ @@image_cache.clear if @@image_cache.size >= IMAGE_CACHE_MAX
4786
+ @@image_cache[cache_key] = entry
4787
+ end
4788
+ entry
4789
+ end
4790
+
4791
+ # Raw (encoded) bytes for an image URL: `data:` decoded inline, http(s) via a
4792
+ # binary-safe fetch (the raw bytes ride `body_b64`; the text body would mangle
4793
+ # non-ASCII image bytes). A `cors` load threads 'cors' mode + credentials into the
4794
+ # fetch, so rack_fetch's Access-Control enforcement rejects (→ nil) a cross-origin
4795
+ # response with no matching ACAO. :unsupported for a scheme with no host-side reader,
4796
+ # nil for a missing / failed / CORS-rejected / empty resource.
4797
+ private def image_source_bytes(key, cors = false, credentials = 'same-origin')
4798
+ if key.start_with?('data:')
4799
+ bytes = decode_data_url_body(key)
4800
+ bytes.empty? ? nil : bytes
4801
+ elsif key.match?(%r{\Ahttps?://}i)
4802
+ result = rack_fetch('GET', key, '', {}, 'follow', cors ? 'cors' : nil, credentials: credentials)
4803
+ return nil unless result && result['status'].to_i < 400
4804
+ bytes = result['body_b64'] ? Base64.decode64(result['body_b64']) : result['body'].to_s.b
4805
+ bytes.empty? ? nil : bytes
4806
+ else
4807
+ :unsupported
4808
+ end
4809
+ end
4810
+
4811
+ # Decode a base64-encoded image (createImageBitmap's blob path), optionally
4812
+ # downscaled to fit within (max_w, max_h) via its resize options.
3658
4813
  def decode_image(b64_bytes, max_w = nil, max_h = nil)
3659
- host_image_op('decode_image') {
3660
- require 'vips' unless defined?(Vips)
3661
- bytes = Base64.decode64(b64_bytes.to_s)
3662
- # `access: :sequential` keeps libvips from applying the
3663
- # source's ICC profile mid-stream (changes RGBA values by ±2
3664
- # vs raw decode). `colourspace('srgb')` is the same ICC
3665
- # transform Chrome's createImageBitmap runs, but rounding
3666
- # differs by a few ulp; only convert when libvips reports
3667
- # a non-sRGB interpretation, otherwise trust the bytes.
3668
- img = Vips::Image.new_from_buffer(bytes, '', access: :sequential)
3669
- img = img.colourspace('srgb') unless img.interpretation == :srgb || img.interpretation == :rgb
3670
- img = img.bandjoin(255) if img.bands < 4
3671
- if max_w && max_h && max_w.to_i > 0 && max_h.to_i > 0 &&
3672
- (img.width > max_w.to_i || img.height > max_h.to_i)
3673
- shrink_x = img.width.to_f / max_w.to_i
3674
- shrink_y = img.height.to_f / max_h.to_i
3675
- shrink = [shrink_x, shrink_y].max
3676
- img = img.resize(1.0 / shrink) if shrink > 1
4814
+ entry = decode_or_nil(Base64.decode64(b64_bytes.to_s), max_w, max_h)
4815
+ # nil (broken) or :zero_size — createImageBitmap of either rejects (a zero-area
4816
+ # source is an InvalidStateError), so surface nil for the caller to reject on.
4817
+ return nil unless entry.is_a?(Hash)
4818
+ r = {'width' => entry['width'], 'height' => entry['height'], 'refId' => transfer_buffer_stash(entry['bytes']), 'colorSpace' => entry['colorSpace']}
4819
+ r['refIdP3'] = transfer_buffer_stash(entry['bytesP3']) if entry['bytesP3']
4820
+ r
4821
+ end
4822
+
4823
+ # Decode an encoded image (PNG/JPEG/GIF/WEBP/SVG/…) to a packed RGBA bitmap via
4824
+ # libvips, optionally downscaled to fit within (max_w, max_h). `access:
4825
+ # :sequential` keeps libvips from applying the source ICC profile mid-stream (it
4826
+ # shifts RGBA by ±2 vs a raw decode). Returns {'width','height','bytes','colorSpace'};
4827
+ # `colorSpace` ('srgb' | 'display-p3') tells drawImage which space the bytes are in
4828
+ # so it can convert into the destination canvas's colour space.
4829
+ #
4830
+ # Detection is by the profile's description text (both Display-P3 and the sRGB /
4831
+ # Adobe profiles carry a readable name). A plain (unprofiled) OR sRGB-profiled RGB
4832
+ # image is trusted as sRGB verbatim — the common case (incl. photos exported as
4833
+ # sRGB) stays byte-identical. A Display-P3 image keeps its raw bytes: a Display-P3
4834
+ # PNG already stores P3-encoded values, so we only TAG it and let drawImage do the
4835
+ # gamut conversion. Adobe-RGB and CMYK aren't among the two predefined canvas colour
4836
+ # spaces, so they're ICC-transformed to sRGB (their wide gamut is lost — a documented
4837
+ # gap). Any other profiled non-RGB source (grayscale / Lab) is colour-converted to
4838
+ # sRGB so it lands as packed RGB, never kept raw.
4839
+ private def decode_rgba(bytes, max_w = nil, max_h = nil)
4840
+ require 'vips' unless defined?(Vips)
4841
+ img = Vips::Image.new_from_buffer(bytes, '', access: :sequential)
4842
+ # RGB (incl. 16-bit `rgb16`); a non-RGB profiled source is colour-converted below.
4843
+ rgb = %i[srgb rgb rgb16].include?(img.interpretation)
4844
+ color_space = 'srgb'
4845
+ p3_img = nil # a second, Display-P3 rendering for a wide-gamut source (see below)
4846
+ if img.get_fields.include?('icc-profile-data')
4847
+ icc = img.get('icc-profile-data')
4848
+ if rgb && (icc.include?('Display P3') || icc.include?('DCI-P3'))
4849
+ color_space = 'display-p3' # wide-gamut RGB: raw bytes are already P3-encoded
4850
+ elsif img.interpretation == :cmyk || icc.include?('Adobe')
4851
+ # A wide-gamut (Adobe-RGB / CMYK) profile can't be represented by a single
4852
+ # buffer: an sRGB canvas needs the colour CLIPPED to sRGB, a Display-P3 canvas
4853
+ # needs it PRESERVED in P3 (and those differ — ICC gamut-mapping to sRGB isn't a
4854
+ # matrix clip of the P3 value). So decode BOTH renderings via libvips' built-in
4855
+ # profiles; drawImage picks by the destination canvas's colour space. icc_transform
4856
+ # needs random access, so re-decode without `access: :sequential`.
4857
+ base = Vips::Image.new_from_buffer(bytes, '')
4858
+ begin
4859
+ img = base.icc_transform('srgb', embedded: true)
4860
+ p3_img = base.icc_transform('p3', embedded: true)
4861
+ rescue StandardError
4862
+ img = img.colourspace('srgb'); p3_img = nil
4863
+ end
4864
+ elsif !rgb
4865
+ img = img.colourspace('srgb') # profiled grayscale / Lab → packed sRGB RGB
3677
4866
  end
3678
- raw = img.write_to_memory
3679
- {'width' => img.width, 'height' => img.height, 'refId' => transfer_buffer_stash(raw)}
3680
- }
4867
+ # else: an sRGB-profiled (or other) RGB image → trust the raw bytes as sRGB.
4868
+ elsif !rgb
4869
+ img = img.colourspace('srgb')
4870
+ end
4871
+ pack = lambda do |i|
4872
+ i = i.cast('uchar', shift: true) if i.format == :ushort # 16-bit source → 8-bit, scaled
4873
+ i = i.bandjoin(255) if i.bands < 4
4874
+ i
4875
+ end
4876
+ img = pack.call(img)
4877
+ p3_img = pack.call(p3_img) if p3_img
4878
+ if max_w && max_h && max_w.to_i > 0 && max_h.to_i > 0 &&
4879
+ (img.width > max_w.to_i || img.height > max_h.to_i)
4880
+ shrink = [img.width.to_f / max_w.to_i, img.height.to_f / max_h.to_i].max
4881
+ if shrink > 1
4882
+ img = img.resize(1.0 / shrink)
4883
+ p3_img = p3_img.resize(1.0 / shrink) if p3_img
4884
+ end
4885
+ end
4886
+ out = {'width' => img.width, 'height' => img.height, 'bytes' => img.write_to_memory, 'colorSpace' => color_space}
4887
+ # The P3 rendering is best-effort: libvips is lazy, so an ICC fault surfaces only
4888
+ # here at sink evaluation — it must not break the already-rendered sRGB image, just
4889
+ # drop the wide-gamut variant (that image then won't preserve wide colours in a P3 canvas).
4890
+ if p3_img
4891
+ begin
4892
+ out['bytesP3'] = p3_img.write_to_memory
4893
+ rescue StandardError
4894
+ nil
4895
+ end
4896
+ end
4897
+ out
4898
+ end
4899
+
4900
+ # `decode_rgba` guarded. Outcomes:
4901
+ # Hash — a decoded {'width','height','bytes'} bitmap
4902
+ # :zero_size — a VALID image with a non-positive intrinsic dimension (rsvg refuses
4903
+ # to rasterize an SVG whose width|height is 0). Browsers still load it,
4904
+ # reporting a zero-area bitmap, so this is "available but empty"
4905
+ # (bad usability → createPattern null / drawImage no-op), NOT broken.
4906
+ # nil — an undecodable / corrupt body: a normal "broken image" outcome
4907
+ # (the caller fires `error` / rejects the ImageBitmap promise)
4908
+ # A Vips::Error is a quiet outcome, not stderr noise; genuine host faults (missing
4909
+ # libvips, OOM) still warn via `host_image_op`. The `bad dimensions` signal is the
4910
+ # rsvg loader's own diagnostic for a non-positive canvas (libvips-version-coupled
4911
+ # text; a corrupt raster / malformed SVG raises a different, non-matching message);
4912
+ # it also covers a fully DIMENSIONLESS SVG that a browser would instead render at
4913
+ # the 300×150 CSS default — a bounded, documented divergence, still a net
4914
+ # improvement over the old nil→broken→InvalidStateError.
4915
+ private def decode_or_nil(bytes, max_w = nil, max_h = nil)
4916
+ require 'vips' unless defined?(Vips)
4917
+ decode_rgba(bytes, max_w, max_h)
4918
+ rescue Vips::Error => e
4919
+ e.message.include?('bad dimensions') ? :zero_size : nil
4920
+ rescue LoadError, StandardError => e
4921
+ warn "[capybara-simulated] image decode failed: #{e.class}: #{e.message[0, 200]}"
4922
+ nil
3681
4923
  end
3682
4924
 
3683
4925
  private def host_image_op(name)
@@ -3687,6 +4929,346 @@ module Capybara
3687
4929
  nil
3688
4930
  end
3689
4931
 
4932
+ # Render a line of text to a coverage mask via libvips (pango / fontconfig),
4933
+ # backing the canvas `fillText` / `measureText` surface with real system-font
4934
+ # glyphs and metrics — no bundled font, so any installed family works. `font`
4935
+ # is a pango font string ("Sans Bold 16"); at dpi 72 the point size equals CSS
4936
+ # px. Returns `{width, height, xoffset, yoffset, ascent, descent[, refId]}`:
4937
+ # the image is cropped to the INK box, and (xoffset, yoffset) locate that box
4938
+ # within the logical layout, so the JS side can place the alphabetic baseline.
4939
+ # `measure_only` skips rasterizing the mask (the lazy image already knows its
4940
+ # dimensions) — the cheap path for `measureText`.
4941
+ def render_text(text, font, measure_only = false, font_url = nil, kerning = nil)
4942
+ host_image_op('render_text') {
4943
+ require 'vips' unless defined?(Vips)
4944
+ pango = font.to_s.empty? ? 'Sans 10' : font.to_s
4945
+ fontfile = font_url && !font_url.to_s.empty? ? font_file_for(font_url) : nil
4946
+ # Ascent/descent are properties of the FONT, not the variant: probe them with a
4947
+ # small-caps-stripped description so the descender probe ('gjpqy') isn't rendered
4948
+ # as (descenderless) small capitals, which would collapse the reported descent.
4949
+ asc, desc = font_vmetrics(pango.sub(/ Small-Caps\b/i, ''), fontfile)
4950
+ # NUL takes up no space and would abort the pango render; drop it so a lone
4951
+ # "\0" measures/draws as empty rather than falling back to a fabricated width.
4952
+ str = text.to_s.delete("\u0000")
4953
+ return {'width' => 0, 'advance' => 0, 'height' => 0, 'xoffset' => 0, 'yoffset' => 0, 'ascent' => asc, 'descent' => desc} if str.empty?
4954
+
4955
+ # `Vips::Image.text` parses Pango markup — canvas text is always literal,
4956
+ # so escape the markup metacharacters (an unescaped `&`/`<` would raise,
4957
+ # silently dropping the text; `<b>…` would wrongly render as bold).
4958
+ markup = str.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;')
4959
+ # `fontKerning = 'none'` disables the OpenType `kern` feature via a Pango markup
4960
+ # span, so a system font's rendered advance widens to the un-kerned width. ('auto'
4961
+ # / 'normal' leave pango's default kerning on.) A downloaded font's advance comes
4962
+ # from hmtx and is unaffected.
4963
+ markup = %(<span font_features="kern=0">#{markup}</span>) if kerning == 'none'
4964
+ # Render the whole line at its natural width; the caller condenses it
4965
+ # horizontally to honor canvas maxWidth (pango `width:` would word-WRAP,
4966
+ # which the canvas text algorithm never does). An @font-face family loads its
4967
+ # own font via `fontfile:` so pango resolves it (vips needs fontconfig support).
4968
+ img = text_image(markup, pango, fontfile)
4969
+ # `width` is the INK width (vips crops to it); `advance` is the pen movement
4970
+ # (`measureText().width`), which a downloaded font's hmtx gives exactly and
4971
+ # which falls back to the ink width for a system font we can't parse — or for a
4972
+ # string whose codepoints the (BMP) cmap doesn't map yet still renders ink
4973
+ # (astral / symbol-cmap), where a computed 0 advance would be wrong.
4974
+ adv = fontfile && font_advance_px(pango, fontfile, str)
4975
+ advance = adv && adv > 0 ? adv : img.width
4976
+ em_asc, em_desc = fontfile ? font_em_vmetrics(pango, fontfile) : nil
4977
+ res = {
4978
+ 'width' => img.width,
4979
+ 'advance' => advance,
4980
+ 'height' => img.height,
4981
+ 'xoffset' => img.get('xoffset'),
4982
+ 'yoffset' => img.get('yoffset'),
4983
+ 'ascent' => asc,
4984
+ 'descent' => desc,
4985
+ 'emAscent' => em_asc || asc,
4986
+ 'emDescent' => em_desc || desc
4987
+ }
4988
+ res.merge!(font_base_metrics(pango, fontfile) || {}) if fontfile # BASE-table baselines, when present
4989
+ unless measure_only
4990
+ img = img.cast('uchar') unless img.format == :uchar
4991
+ res['refId'] = transfer_buffer_stash(img.write_to_memory)
4992
+ end
4993
+ res
4994
+ }
4995
+ end
4996
+
4997
+ # `Vips::Image.text` with the optional `fontfile:` (an @font-face's downloaded
4998
+ # font), which pango loads so it can resolve that family. Passing a nil fontfile
4999
+ # would raise, so branch — a system-font family resolves through fontconfig.
5000
+ private def text_image(markup, pango, fontfile)
5001
+ if fontfile
5002
+ Vips::Image.text(markup, font: pango, fontfile: fontfile, dpi: 72)
5003
+ else
5004
+ Vips::Image.text(markup, font: pango, dpi: 72)
5005
+ end
5006
+ end
5007
+
5008
+ # Ascent (baseline offset from the logical top) and descent for a pango font,
5009
+ # probed once and cached. A no-descender cap/ascender string's ink bottom is
5010
+ # the baseline (ascent); a descender string's ink bottom minus that is the
5011
+ # descent. dpi 72 keeps units in CSS px. `fontfile` (an @font-face font) is part
5012
+ # of the cache key so a downloaded family's metrics don't collide with a system one.
5013
+ private def font_vmetrics(pango, fontfile = nil)
5014
+ key = fontfile ? "#{pango}\0#{fontfile}" : pango
5015
+ cached = @font_vmetrics_lock.synchronize { @font_vmetrics[key] }
5016
+ return cached if cached
5017
+
5018
+ # A downloaded @font-face carries its own typographic metrics, and pango lays
5019
+ # it out on those, so the baseline (ascent) comes from the font's OS/2 typo
5020
+ # ascender/descender scaled to the pixel size — the ink-string heuristic below
5021
+ # is only a fallback for the ambient system font, whose file we don't have.
5022
+ asc, desc = font_typo_vmetrics(pango, fontfile) if fontfile
5023
+ unless asc
5024
+ asc = begin
5025
+ r = text_image('Mbdfhklt', pango, fontfile)
5026
+ r.get('yoffset') + r.height
5027
+ rescue StandardError
5028
+ 10
5029
+ end
5030
+ desc = begin
5031
+ r = text_image('gjpqy', pango, fontfile)
5032
+ [(r.get('yoffset') + r.height) - asc, 0].max
5033
+ rescue StandardError
5034
+ (asc * 0.25).round
5035
+ end
5036
+ end
5037
+ @font_vmetrics_lock.synchronize { @font_vmetrics[key] ||= [asc, desc] }
5038
+ end
5039
+
5040
+ # [ascent, descent] in px from a font file's OS/2 typographic metrics scaled to
5041
+ # the pango string's point size (dpi 72 → px), or nil if it can't be read. This is
5042
+ # the FONT bounding box / baseline value (fontBoundingBoxAscent), typo-ascender
5043
+ # over unitsPerEm.
5044
+ private def font_typo_vmetrics(pango, fontfile)
5045
+ m = font_typo_units(fontfile) or return nil
5046
+ upm, ta, td = m
5047
+ size = font_size_of(pango)
5048
+ [(ta * size / upm).round, [(-td * size / upm).round, 0].max]
5049
+ end
5050
+
5051
+ # [emHeightAscent, emHeightDescent] in px: the em square (= font size) split by the
5052
+ # baseline at the typo ascender:descender ratio — NOT normalized by unitsPerEm, so a
5053
+ # font whose ascender+descender ≠ em still fills the em (e.g. descent-0 → all ascent).
5054
+ private def font_em_vmetrics(pango, fontfile)
5055
+ m = font_typo_units(fontfile) or return nil
5056
+ _upm, ta, td = m
5057
+ span = ta + (-td)
5058
+ return nil unless span.positive?
5059
+ size = font_size_of(pango)
5060
+ ea = size * ta / span.to_f
5061
+ [ea.round, (size - ea).round]
5062
+ end
5063
+
5064
+ # The point size in a pango font string ("CanvasTest 40" → 40); 10 as a fallback.
5065
+ # The size is a whitespace-separated trailing token, so a family that itself ends
5066
+ # in a digit ("B612") without a size isn't misread as one.
5067
+ private def font_size_of(pango)
5068
+ size = pango.to_s[/\s(\d+(?:\.\d+)?)\s*\z/, 1].to_f
5069
+ size.positive? ? size : 10.0
5070
+ end
5071
+
5072
+ # [unitsPerEm, sTypoAscender, sTypoDescender] from a TrueType/OpenType file's
5073
+ # `head` + `OS/2` tables, or nil.
5074
+ private def font_typo_units(fontfile)
5075
+ g = font_glyph_data(fontfile) or return nil
5076
+ [g[:upm], g[:typo_asc], g[:typo_desc]]
5077
+ end
5078
+
5079
+ # The advance width (pen movement) of `text` in the font, in px at the pango
5080
+ # string's size — the value `measureText().width` reports. vips crops to ink, so
5081
+ # the advance (which includes side bearings) comes from the font's own hmtx table.
5082
+ # nil if the font can't be parsed.
5083
+ private def font_advance_px(pango, fontfile, text)
5084
+ g = font_glyph_data(fontfile) or return nil
5085
+ size = font_size_of(pango)
5086
+ units = text.to_s.each_char.sum do |ch|
5087
+ gid = g[:cmap][ch.ord]
5088
+ next 0 unless gid # a codepoint the font doesn't map (null, control) advances nothing
5089
+ g[:advances][gid] || g[:advances].last || 0
5090
+ end
5091
+ units * size / g[:upm]
5092
+ end
5093
+
5094
+ # Parse the glyph tables a canvas text metric needs out of a TrueType/OpenType
5095
+ # file, memoized per path: unitsPerEm + typo metrics (head / OS/2), the per-glyph
5096
+ # advance widths (hmtx), and a Unicode → glyph-id map (cmap format 4). nil when a
5097
+ # required table is missing or malformed.
5098
+ private def font_glyph_data(fontfile)
5099
+ (@font_glyph ||= {})
5100
+ return @font_glyph[fontfile] if @font_glyph.key?(fontfile)
5101
+ @font_glyph[fontfile] = parse_font_glyph_data(fontfile)
5102
+ end
5103
+
5104
+ private def parse_font_glyph_data(fontfile)
5105
+ data = File.binread(fontfile)
5106
+ n = data[4, 2].unpack1('n')
5107
+ tabs = {}
5108
+ 12.step(12 + (n - 1) * 16, 16) { |o| tabs[data[o, 4]] = data[o + 8, 4].unpack1('N') }
5109
+ head = tabs['head']; os2 = tabs['OS/2']; hhea = tabs['hhea']; hmtx = tabs['hmtx']; cmap = tabs['cmap']
5110
+ return nil unless head && hhea && hmtx && cmap
5111
+ upm = data[head + 18, 2].unpack1('n')
5112
+ return nil unless upm.positive?
5113
+ num_h = data[hhea + 34, 2].unpack1('n')
5114
+ advances = (0...num_h).map { |i| data[hmtx + i * 4, 2].unpack1('n') }
5115
+ {
5116
+ upm: upm,
5117
+ typo_asc: s16(data[(os2 || hhea) + (os2 ? 68 : 4), 2]),
5118
+ typo_desc: s16(data[(os2 || hhea) + (os2 ? 70 : 6), 2]),
5119
+ advances: advances,
5120
+ # A malformed cmap shouldn't discard the (already-read) upm / advances / typo
5121
+ # metrics, so isolate its parse — an empty map just means advances fall back.
5122
+ cmap: (parse_cmap4(data, cmap) rescue {}),
5123
+ # Optional horizontal-baseline coordinates ({tag => font units}) from the `BASE`
5124
+ # table — the alphabetic / hanging / ideographic baselines measureText reports.
5125
+ base: (tabs['BASE'] ? (parse_base_table(data, tabs['BASE']) rescue {}) : {}),
5126
+ }
5127
+ rescue StandardError
5128
+ nil
5129
+ end
5130
+
5131
+ # Horizontal-axis baseline coordinates ({"hang"/"ideo"/"romn"/… => font units},
5132
+ # relative to the script's default baseline) from the first BASE-table script's
5133
+ # BaseValues. Empty when the table is absent or has no coordinates.
5134
+ private def parse_base_table(data, base_off)
5135
+ horiz_rel = data[base_off + 4, 2].unpack1('n') # horizAxisOffset (0 = none)
5136
+ return {} if horiz_rel.zero?
5137
+ horiz = base_off + horiz_rel
5138
+ tag_list = horiz + data[horiz, 2].unpack1('n') # baseTagList (rel. to axis)
5139
+ script_list = horiz + data[horiz + 2, 2].unpack1('n') # baseScriptList (rel. to axis)
5140
+ ntags = data[tag_list, 2].unpack1('n')
5141
+ nscript = data[script_list, 2].unpack1('n')
5142
+ # A count read past a truncated table is nil; `(0...nil)` is an ENDLESS range that
5143
+ # would loop forever (never raising, so the caller's `rescue {}` can't save it).
5144
+ return {} if ntags.nil? || nscript.nil? || nscript.zero?
5145
+ tags = (0...ntags).map { |i| data[tag_list + 2 + i * 4, 4] }
5146
+ # Prefer the DFLT / latn script's baselines; else the first record.
5147
+ recs = (0...nscript).map { |i| o = script_list + 2 + i * 6; [data[o, 4], script_list + data[o + 4, 2].unpack1('n')] }
5148
+ _tag, s_off = recs.find { |t, _| t == 'DFLT' || t == 'latn' } || recs.first
5149
+ bv_rel = data[s_off, 2].unpack1('n') # baseValuesOffset (0 = none)
5150
+ return {} if bv_rel.zero?
5151
+ bv = s_off + bv_rel
5152
+ ncoord = data[bv + 2, 2].unpack1('n')
5153
+ out = {}
5154
+ tags.each_with_index do |t, i|
5155
+ break if i >= ncoord
5156
+ co = bv + data[bv + 4 + i * 2, 2].unpack1('n')
5157
+ out[t] = s16(data[co + 2, 2]) if [1, 2, 3].include?(data[co, 2].unpack1('n')) # BaseCoord formats
5158
+ end
5159
+ out
5160
+ end
5161
+
5162
+ # The alphabetic / hanging / ideographic baselines (px at the pango size) from the
5163
+ # font's BASE table, or nil when the font has none — then the caller heuristically
5164
+ # derives them from the vertical metrics instead.
5165
+ private def font_base_metrics(pango, fontfile)
5166
+ g = font_glyph_data(fontfile) or return nil
5167
+ base = g[:base]
5168
+ return nil if base.nil? || base.empty?
5169
+ scale = font_size_of(pango) / g[:upm].to_f
5170
+ romn = base['romn'] || 0 # the alphabetic baseline = the reference
5171
+ {
5172
+ 'alphabeticBaseline' => 0.0,
5173
+ 'hangingBaseline' => ((base['hang'] || romn) - romn) * scale,
5174
+ 'ideographicBaseline' => ((base['ideo'] || romn) - romn) * scale,
5175
+ }
5176
+ end
5177
+
5178
+ # A Unicode → glyph-id map from the first format-4 `cmap` subtable (the standard
5179
+ # BMP Unicode encoding), as {codepoint => glyph}. Empty when none is present.
5180
+ private def parse_cmap4(data, cmap)
5181
+ ntab = data[cmap + 2, 2].unpack1('n')
5182
+ return {} if ntab.nil? # a count read past a truncated table → don't loop `(0...nil)` forever
5183
+ # Prefer a Unicode BMP subtable — (3,1) Windows Unicode or (0,*) Unicode — over a
5184
+ # (3,0) Symbol map (which shadows ASCII into the 0xF000 PUA); fall back to any
5185
+ # format-4 table only if no Unicode one is present.
5186
+ best = nil; best_rank = -1
5187
+ (0...ntab).each do |i|
5188
+ rec = cmap + 4 + i * 8
5189
+ pid = data[rec, 2].unpack1('n'); eid = data[rec + 2, 2].unpack1('n')
5190
+ off = data[rec + 4, 4].unpack1('N')
5191
+ next unless data[cmap + off, 2].unpack1('n') == 4
5192
+ rank = pid == 3 && eid == 1 ? 3 : pid.zero? ? 2 : pid == 3 && eid.zero? ? 0 : 1
5193
+ if rank > best_rank then best_rank = rank; best = cmap + off end
5194
+ end
5195
+ sub = best
5196
+ return {} unless sub
5197
+ segx2 = data[sub + 6, 2].unpack1('n'); segc = segx2 / 2
5198
+ endc = sub + 14
5199
+ startc = endc + segx2 + 2
5200
+ iddelta = startc + segx2
5201
+ idrange = iddelta + segx2
5202
+ map = {}
5203
+ (0...segc).each do |s|
5204
+ e = data[endc + s * 2, 2].unpack1('n')
5205
+ st = data[startc + s * 2, 2].unpack1('n')
5206
+ delta = data[iddelta + s * 2, 2].unpack1('n')
5207
+ ro = data[idrange + s * 2, 2].unpack1('n')
5208
+ (st..e).each do |c|
5209
+ next if c == 0xFFFF
5210
+ gid = if ro.zero?
5211
+ (c + delta) & 0xFFFF
5212
+ else
5213
+ gi = idrange + s * 2 + ro + (c - st) * 2
5214
+ g = data[gi, 2].unpack1('n')
5215
+ g.zero? ? 0 : (g + delta) & 0xFFFF
5216
+ end
5217
+ map[c] = gid if gid != 0
5218
+ end
5219
+ end
5220
+ map
5221
+ end
5222
+
5223
+ # A big-endian signed 16-bit value.
5224
+ private def s16(bytes)
5225
+ v = bytes.unpack1('n')
5226
+ v >= 0x8000 ? v - 0x10000 : v
5227
+ end
5228
+
5229
+ # Resolve an @font-face src URL to an on-disk font file pango can load, fetching
5230
+ # the bytes through the Rack app (binary-safe) once and caching the temp path for
5231
+ # the process. Returns nil when the fetch fails.
5232
+ def font_file_for(url)
5233
+ key = resolve_against_current(url.to_s)
5234
+ return nil unless key.is_a?(String)
5235
+ @@font_file_lock.synchronize { return @@font_file_cache[key] if @@font_file_cache.key?(key) }
5236
+ path = build_font_file(key)
5237
+ @@font_file_lock.synchronize { @@font_file_cache[key] = path }
5238
+ path
5239
+ end
5240
+
5241
+ private def build_font_file(key)
5242
+ bytes = font_source_bytes(key)
5243
+ return nil unless bytes && !bytes.empty?
5244
+ require 'tempfile'
5245
+ # Keep the Tempfile object alive for the process so its file isn't reaped while
5246
+ # fontconfig may still read it; the cache holds the path.
5247
+ file = Tempfile.new(['csim-font', File.extname(key)[0, 5]])
5248
+ file.binmode
5249
+ file.write(bytes)
5250
+ file.flush
5251
+ # Pin the handle for the whole process: the path is cached in the CLASS-level
5252
+ # @@font_file_cache and outlives the per-visit Browser that first built it, so
5253
+ # the Tempfile must not be finalized (and its file unlinked) with that instance.
5254
+ @@font_file_lock.synchronize { @@font_files << file }
5255
+ file.path
5256
+ end
5257
+
5258
+ # Raw font bytes for a URL: `data:` inline, http(s) via a binary-safe Rack fetch
5259
+ # (the raw bytes ride `body_b64`; a text body would mangle the font's non-ASCII).
5260
+ private def font_source_bytes(key)
5261
+ if key.start_with?('data:')
5262
+ bytes = decode_data_url_body(key)
5263
+ bytes.empty? ? nil : bytes
5264
+ elsif key.match?(%r{\Ahttps?://}i)
5265
+ result = rack_fetch('GET', key, '', {}, 'follow')
5266
+ return nil unless result && result['status'].to_i < 400
5267
+ bytes = result['body_b64'] ? Base64.decode64(result['body_b64']) : result['body'].to_s.b
5268
+ bytes.empty? ? nil : bytes
5269
+ end
5270
+ end
5271
+
3690
5272
  def reset_workers
3691
5273
  @workers.each_value do |w|
3692
5274
  w[:inbox] << :terminate
@@ -3694,7 +5276,21 @@ module Capybara
3694
5276
  end
3695
5277
  @workers.clear
3696
5278
  @worker_outbox.clear
3697
- @worker_in_flight = 0
5279
+ @worker_outbox_head = nil
5280
+ @worker_in_flight = 0
5281
+ @worker_broadcast_pending = 0
5282
+ @sw_message_pending = 0
5283
+ @sw_fetch_pending = 0
5284
+ @sw_open_streams.clear
5285
+ @sw_fetch_wait_deadline = nil
5286
+ @sw_msg_wait_deadline = nil
5287
+ @sw_registrations.clear
5288
+ @sw_navpreload = {}
5289
+ @sw_pending_claims = []
5290
+ @sw_clients = {}
5291
+ @sw_realm_controller = {}
5292
+ @port_channels = {}
5293
+ @sw_nav_outbox.clear
3698
5294
  @transfer_buffer_lock.synchronize {
3699
5295
  @transfer_buffers.clear
3700
5296
  @transfer_buffer_seq = 0
@@ -3728,26 +5324,13 @@ module Capybara
3728
5324
  end
3729
5325
 
3730
5326
  def blob_resolve(url)
3731
- local = @blob_registry_lock.synchronize { @blob_registry[url.to_s] }
3732
- return local if local
3733
- # Not in this window's registry: a blob created in ANOTHER window/isolate is
3734
- # fetchable only from the SAME storage partition (cross-partition.https
3735
- # "fetched from a same-partition iframe" succeeds; a cross-partition fetch
3736
- # fails). Resolve the bytes cross-isolate from the creator and hand them back
3737
- # as base64 (resolveBlobBytes decodes them). The cross-isolate read enters the
3738
- # creator's V8 isolate, so only do it on the MAIN thread — a worker thread
3739
- # can't safely enter another isolate (so a same-partition WORKER fetch of a
3740
- # blob owned elsewhere isn't supported; a cross-partition one correctly fails).
3741
- # CAVEAT: bytes-only — resolveBlobBytes types a host-resolved blob as
3742
- # application/octet-stream (the cross-window path loses the Blob's `type`); no
3743
- # in-scope test reads the type on this path, and carrying it would change the
3744
- # __csim_blobResolve string protocol.
3745
- return nil unless @driver.respond_to?(:blob_partition_site_of) && @driver.respond_to?(:blob_bytes_for)
3746
- site = @driver.blob_partition_site_of(url.to_s)
3747
- return nil if site.nil? || site != blob_partition_site # unknown / revoked / cross-partition
3748
- return nil if Thread.current[:csim_worker_handle]
3749
- data = @driver.blob_bytes_for(url.to_s, self)
3750
- data && Base64.strict_encode64(data[:bytes].to_s.b)
5327
+ # A same-partition blob created in another window/isolate is spec-fetchable
5328
+ # cross-window, but resolving its bytes means a real-time cross-isolate read
5329
+ # (+ worker round-trips for the worker variants) that races the per-example
5330
+ # timeout under suite load — flaky in the gate. So we only resolve a blob from
5331
+ # THIS window's registry; the cross-window same-partition fetch is a backlog
5332
+ # item (cross-partition.https "fetched from a same-partition {iframe,worker}").
5333
+ @blob_registry_lock.synchronize { @blob_registry[url.to_s] }
3751
5334
  end
3752
5335
 
3753
5336
  # The SITE (scheme + registrable domain) of this window's top-level document.
@@ -3757,26 +5340,11 @@ module Capybara
3757
5340
  # host's last two dot-labels — correct for the single-label public suffixes our
3758
5341
  # in-process hosts use (web-platform.test / not-web-platform.test / *.com); a
3759
5342
  # full Public Suffix List isn't warranted here.
5343
+ # This document's partition "site" (scheme + registrable domain) for Blob-URL / storage
5344
+ # partitioning. A blob: document derives from its inner origin; data:/about: (no host) →
5345
+ # '' (an opaque origin, never same-partition with a real one). See registrable_site.
3760
5346
  def blob_partition_site
3761
- # A blob: document's location is `blob:<innerURL>` (e.g.
3762
- # `blob:https://host:port/uuid`) — strip the prefix so the site derives from
3763
- # the inner origin, not the opaque blob: scheme. data:/about: have no host →
3764
- # '' (an opaque origin, never same-partition with a real one).
3765
- u = URI.parse(@current_url.to_s.sub(/\Ablob:/, ''))
3766
- host = u.host.to_s
3767
- return '' if host.empty?
3768
- labels = host.split('.')
3769
- # An IP literal (v4 dotted / v6 bracketed) or a ≤2-label host IS its own
3770
- # registrable domain; otherwise approximate eTLD+1 as the last two labels
3771
- # (no Public Suffix List — correct for the single-label TLDs our hosts use).
3772
- regd = if host.start_with?('[') || host.match?(/\A\d+(\.\d+){3}\z/) || labels.length <= 2
3773
- host
3774
- else
3775
- labels.last(2).join('.')
3776
- end
3777
- "#{u.scheme}://#{regd}"
3778
- rescue URI::Error
3779
- ''
5347
+ registrable_site(@current_url) || ''
3780
5348
  end
3781
5349
 
3782
5350
  # WHATWG URL "domain to ASCII" — the JS tr46 stub delegates non-ASCII / xn--
@@ -3959,7 +5527,9 @@ module Capybara
3959
5527
  # Called from the JS bridge when a `<video>` element's `src` is
3960
5528
  # assigned a `blob:` URL. ffprobe extracts dimensions + duration,
3961
5529
  # ffmpeg extracts the first frame as raw RGBA. JS caches both so
3962
- # `canvas.drawImage(video, …)` blits like any ImageBitmap.
5530
+ # `canvas.drawImage(video, …)` blits like any ImageBitmap. A `<video src>` that
5531
+ # points at a served file (http / relative) or a `data:` URL resolves its bytes
5532
+ # the same way — see `video_bytes_b64` below.
3963
5533
  def decode_video_frame(b64_bytes)
3964
5534
  host_image_op('decode_video_frame') {
3965
5535
  bytes = Base64.decode64(b64_bytes.to_s)
@@ -3982,6 +5552,22 @@ module Capybara
3982
5552
  }
3983
5553
  end
3984
5554
 
5555
+ # Fetch a media resource (http / relative URL) and return its bytes base64-encoded,
5556
+ # so a `<video src>` pointing at a served file decodes the same way a blob: / data:
5557
+ # source does. Binary stays Ruby-side; only ASCII base64 crosses into V8. Returns
5558
+ # nil when the fetch fails.
5559
+ def video_bytes_b64(url)
5560
+ result = rack_fetch('GET', url, '', {}, 'follow')
5561
+ return nil unless result && result['status'].to_i < 400
5562
+ # The RAW bytes ride `body_b64` (see response_hash) for any non-ASCII response;
5563
+ # the text `body` field is a UTF-8 re-decode that corrupts binary media. Fall
5564
+ # back to base64-of-body only for a pure-ASCII response (byte-identical there).
5565
+ b64 = result['body_b64']
5566
+ return b64 unless b64.nil? || b64.empty?
5567
+ body = result['body'].to_s
5568
+ body.empty? ? nil : [body].pack('m0')
5569
+ end
5570
+
3985
5571
  private def ffprobe_stream(path)
3986
5572
  json = IO.popen(
3987
5573
  ['ffprobe', '-v', 'error', '-select_streams', 'v:0',
@@ -4052,7 +5638,7 @@ module Capybara
4052
5638
  # `build_worker` factory, evaluates the worker script, then
4053
5639
  # loops draining microtasks + timers + inbox until `:terminate`
4054
5640
  # lands or an exception propagates.
4055
- private def run_worker(handle, url, body, inbox, outbox, engine_class, shared: false, service: false)
5641
+ private def run_worker(handle, url, body, inbox, outbox, engine_class, shared: false, service: false, creator_key: nil)
4056
5642
  # Release the spawn-time `@worker_initializing` count exactly once, however
4057
5643
  # this method exits (normal start, `self.close()`, or an exception), so
4058
5644
  # worker_pending? doesn't stay stuck true forever.
@@ -4062,6 +5648,11 @@ module Capybara
4062
5648
  initializing = false
4063
5649
  @worker_init_lock.synchronize { @worker_initializing -= 1 }
4064
5650
  end
5651
+ # True while THIS thread holds a `@worker_busy` increment for a message it's
5652
+ # mid-handling; balanced in the `ensure` so an exception between the bump and
5653
+ # the matching decrement can't strand the counter (which would pin
5654
+ # `worker_pending?` true forever).
5655
+ busy_held = false
4065
5656
  # Tag this thread so blob URLs created by the worker's script are owned by
4066
5657
  # this handle and revoked on terminate (see blob_register / revoke_worker_blobs).
4067
5658
  Thread.current[:csim_worker_handle] = handle
@@ -4071,17 +5662,83 @@ module Capybara
4071
5662
  # BINARY-tagged (see `RuntimeShared.utf8_text`).
4072
5663
  body = RuntimeShared.utf8_text(body)
4073
5664
  post_back = ->(data) { outbox << {handle: handle, kind: 'message', data: data.to_s} }
4074
- rt = engine_class.build_worker(self, post_back)
5665
+ # A worker's BroadcastChannel post rides the same thread-safe outbox; the main thread fans
5666
+ # it out to the main + frame realms + OTHER workers (see deliver_worker_messages).
5667
+ broadcast_out = ->(name, data, origin) { outbox << {handle: handle, kind: 'broadcast', name: name.to_s, data: data, origin: origin} }
5668
+ # Service-worker → main-thread signals ride the outbox (delivered by deliver_worker_messages):
5669
+ # client.postMessage, clients.claim (→ set the client's controller), and a controlled fetch's
5670
+ # respondWith result. `sw_has_fetch` is snapshotted after the SW script's initial run (the
5671
+ # spec records fetch-handler presence at install time) so a claim can tell the client
5672
+ # whether intercepting is worth the cross-isolate round-trip at all.
5673
+ sw_has_fetch = false
5674
+ sw_hooks = {
5675
+ post_to_client: ->(client_id, data) { outbox << {handle: handle, kind: 'sw_client_msg', client: client_id, data: data.to_s} },
5676
+ claim: -> { outbox << {handle: handle, kind: 'sw_claim', has_fetch: sw_has_fetch} },
5677
+ fetch_respond: ->(fetch_id, resp, realm_id) { sw_deliver_fetch_response(handle, fetch_id.to_i, resp.to_s, outbox, realm_id.to_i) },
5678
+ # A streaming respondWith frame (start / chunk / close / error) for a controlled client's
5679
+ # fetch — rides the outbox in emission order so the client realm reassembles the body
5680
+ # stream incrementally (deliver_worker_messages). The request's @sw_fetch_pending stays up
5681
+ # for the whole stream and clears on the terminal (close / error) frame.
5682
+ fetch_stream: ->(fetch_id, kind, payload, realm_id) { outbox << {handle: handle, kind: "fr_#{kind}", fetch_id: fetch_id.to_i, payload: payload.to_s, realm_id: realm_id.to_i} },
5683
+ # Cross-isolate MessagePort channel: this worker's port endpoint + its outbound messages
5684
+ # ride the outbox (delivered by deliver_worker_messages → the peer client realm).
5685
+ port_endpoint: ->(channel) { outbox << {handle: handle, kind: 'port_endpoint', channel: channel.to_s} },
5686
+ port_post: ->(channel, data) { outbox << {handle: handle, kind: 'port_msg', channel: channel.to_s, data: data.to_s} }
5687
+ }
5688
+ rt = engine_class.build_worker(self, post_back, broadcast_out, sw_hooks)
5689
+ # A worker isolate loads the same snapshot as the main realm, so its `console.*`
5690
+ # is a no-op until `traceActive` is set (console.js). The main realm turns it on
5691
+ # from `CSIM_CONSOLE_STDERR`; without this a worker's console — and anything routed
5692
+ # through it, e.g. an Emscripten `printErr` — is silently dropped during debugging.
5693
+ rt.call('__csimSetTraceActive', true) if CONSOLE_STDERR
5694
+ # This worker's handle — the JS keys cross-isolate MessagePort channel ids on it so they
5695
+ # never collide with another isolate's (see __csim_installWorkerScope's allocator).
5696
+ rt.eval("globalThis.__csimWorkerHandle = #{handle.to_i};")
4075
5697
  # Set the worker's `self.location.href` so webpack /
4076
5698
  # rollup public-path derivation + `new URL(rel, import.meta.url)`
4077
5699
  # resolve chunks against the worker's own origin rather than
4078
5700
  # the snapshot-time `http://placeholder/`.
4079
5701
  rt.eval("globalThis.__csimUpdateLocation(#{JSON.generate(url.to_s)});")
5702
+ # A worker's BroadcastChannel origin KEY (its agent-cluster identity). A blob: worker
5703
+ # INHERITS the creating context's origin (the blob URL carries no real origin of its own);
5704
+ # a data: worker gets a FRESH opaque origin, unique per worker, so it never cross-talks with
5705
+ # its creator or a sibling data: worker. An http(s) worker leaves this unset — the JS derives
5706
+ # its key from `location.origin` (the script's own origin, same as the creator when
5707
+ # same-origin). See `__csimBcOriginKey`.
5708
+ worker_origin_key =
5709
+ if url.to_s.start_with?('blob:')
5710
+ creator_key
5711
+ elsif url.to_s.start_with?('data:')
5712
+ "opaque:worker#{handle}"
5713
+ end
5714
+ rt.eval("globalThis.__csimOriginKey = #{JSON.generate(worker_origin_key)};") if worker_origin_key
4080
5715
  # A service worker runs in a ServiceWorkerGlobalScope: adjust the worker scope
4081
5716
  # (no blob-URL minting; SW lifecycle stubs) BEFORE its script runs.
4082
5717
  rt.eval('__csim_installServiceWorkerScope();') if service
4083
5718
  rt.eval(body)
4084
5719
  rt.drain_microtasks
5720
+ # Drive the service worker's lifecycle: fire `install`, then `activate`, draining each
5721
+ # phase's `waitUntil` promises before the next (the client-side ServiceWorker object plays
5722
+ # out the observable installing→installed→activating→activated timeline; here we run the
5723
+ # worker's OWN install/activate handlers so their side effects — caching, importScripts —
5724
+ # execute). See sw-client.js + __csim_swFireLifecycleEvent.
5725
+ if service
5726
+ sw_has_fetch = !!rt.call('__csim_swHasFetchListener')
5727
+ # Publish the fetch-handler snapshot + script URL on the worker record so a NAVIGATION
5728
+ # into this SW's scope can decide (Ruby-side) whether routing through it is worthwhile,
5729
+ # and a freshly-built frame client can mint a `controller` naming this script.
5730
+ if (w = @workers[handle])
5731
+ w[:has_fetch] = sw_has_fetch
5732
+ w[:script_url] = url.to_s
5733
+ end
5734
+ %w[install activate].each do |phase|
5735
+ rt.eval("globalThis.__csim_swFireLifecycleEvent(#{JSON.generate(phase)});")
5736
+ # Drain microtasks AND timers: a `waitUntil` promise may settle off a
5737
+ # setTimeout (e.g. a delayed cache warm-up), so advance the worker clock too.
5738
+ rt.drain_microtasks
5739
+ rt.drain_timers
5740
+ end
5741
+ end
4085
5742
  # A SharedWorker fires `connect` AFTER its script set `self.onconnect`; the
4086
5743
  # connect handler's port post lands in the outbox before release_init, so
4087
5744
  # worker_pending? stays true until it's delivered.
@@ -4097,26 +5754,119 @@ module Capybara
4097
5754
  loop do
4098
5755
  msg = pop_with_timeout(inbox, WORKER_POLL_INTERVAL)
4099
5756
  break if msg == :terminate
4100
- rt.call('__csim_workerOnMessage', msg) if msg
4101
- # Drive the worker's OWN event loop each tick: a message handler OR an
4102
- # AUTONOMOUS loop (the dispatcher executor-worker's receive→fetch→setTimeout
4103
- # retry, which has no inbox message) may have pending timers. Drain ~one poll
4104
- # interval (WorkerRuntime#drain_timers advances the worker clock a step) so
4105
- # they progress; worker http fetch is setTimeout(0)+__rackFetch, resolved on
4106
- # this thread by the drain. Gated on a PENDING timer (any, not just due-now
4107
- # the clock must advance to fire a future randomDelay) so an idle
4108
- # message-driven worker with no timers stays lazy. Host CALLS, not string
4109
- # `eval`, keep the per-tick cost off the V8 compile path (rule 3).
5757
+ # A main-side BroadcastChannel post to this worker arrives as a {kind:'broadcast'} hash;
5758
+ # deliver it to the worker's channels (the receiver's own origin gate drops cross-origin).
5759
+ # A plain string is a postMessage to the worker.
5760
+ if msg.is_a?(Hash) && msg[:kind] == 'broadcast'
5761
+ # Ack so the main thread releases the broadcast-pending it counted for this delivery
5762
+ # (a listen-only worker never posts back — the ack is the only signal it processed
5763
+ # it). Under `ensure`: the ack is CONTRACTUAL (settle's bounded wait relies on it
5764
+ # see worker_reply_pending?), so a raising channel handler must not leak it.
5765
+ begin
5766
+ rt.call('__csim_deliverBroadcasts', [{'name' => msg[:name], 'data' => msg[:data], 'origin' => msg[:origin]}])
5767
+ ensure
5768
+ outbox << {handle: handle, kind: 'bcack'}
5769
+ end
5770
+ elsif msg.is_a?(Hash) && msg[:kind] == 'sw_message'
5771
+ # A client → service-worker postMessage: dispatch a `message` event with source = the
5772
+ # posting client. Ack AFTER the dispatch — the outbox is FIFO, so a handler's
5773
+ # synchronous `client.postMessage` reply (`sw_client_msg`) is guaranteed to precede
5774
+ # the `swack`; acking first opens a window where the main thread sees the pending
5775
+ # count hit zero (worker_pending? false) while the handler is still running, and the
5776
+ # virtual clock fast-forwards past the caller's timeout before the reply lands. The
5777
+ # `ensure` keeps a raising handler from leaking the counter and hanging settle.
5778
+ begin
5779
+ rt.call('__csim_swClientMessage', msg[:data], msg[:client], msg[:url])
5780
+ ensure
5781
+ outbox << {handle: handle, kind: 'swack'}
5782
+ end
5783
+ elsif msg.is_a?(Hash) && msg[:kind] == 'port_msg'
5784
+ # A client-realm port → its remote peer in THIS worker: deliver to the channel endpoint
5785
+ # port. Counted like an sw_message (client_port_post incremented @sw_message_pending), so
5786
+ # ack AFTER dispatch under `ensure` — a synchronous reply the port handler posts back
5787
+ # (another port_msg on the outbox) is FIFO-guaranteed to precede this swack.
5788
+ begin
5789
+ rt.call('__csimPortChannelDeliver', msg[:channel], msg[:data])
5790
+ ensure
5791
+ outbox << {handle: handle, kind: 'swack'}
5792
+ end
5793
+ elsif msg.is_a?(Hash) && msg[:kind] == 'client_register'
5794
+ # A controlled client (frame/window realm) came into existence: mirror it into the
5795
+ # SW's clientsById so matchAll/getClientByURL see it. Fire-and-forget (no reply /
5796
+ # pending counter): the inbox is FIFO, so it's processed before any later message
5797
+ # whose handler matchAll's the client.
5798
+ rt.call('__csim_swRegisterClient', msg[:client])
5799
+ elsif msg.is_a?(Hash) && msg[:kind] == 'client_unregister'
5800
+ # The client's realm was disposed — drop it so matchAll stops returning a dead client.
5801
+ rt.call('__csim_swUnregisterClient', msg[:id])
5802
+ elsif msg.is_a?(Hash) && msg[:kind] == 'fetch'
5803
+ # A controlled client's fetch: dispatch a `fetch` event. The SW's respondWith result
5804
+ # (or a fall-through / network-error marker) is posted back as a `fetch_response`
5805
+ # outbox event, which releases the @sw_fetch_pending counted for this request. A
5806
+ # synchronous respondWith posts during the dispatch; an async one posts under the
5807
+ # drain. If the dispatch itself dies (engine raise, Thread#kill) the JS side can
5808
+ # never post — fall the client back to the network so the counter drains (a
5809
+ # duplicate response is harmless: the client's pendingFetch entry is one-shot).
5810
+ dispatched = false
5811
+ begin
5812
+ rt.call('__csim_swDispatchFetch', msg[:req], msg[:fetch_id], msg[:realm_id])
5813
+ dispatched = true
5814
+ ensure
5815
+ sw_deliver_fetch_response(handle, msg[:fetch_id].to_i, '{"fallthrough":true}', outbox, msg[:realm_id].to_i) unless dispatched
5816
+ end
5817
+ elsif msg.is_a?(Hash) && msg[:kind] == 'fetch_cancel'
5818
+ # The client cancelled this streaming respondWith body — cancel the reader
5819
+ # streamDeliver is draining, firing the SW source stream's `cancel()`. Fire-and-forget:
5820
+ # the reader's cancellation resolves its read as done, emitting the terminal frame that
5821
+ # clears @sw_fetch_pending; no ack/counter of its own.
5822
+ rt.call('__csim_swStreamCancel', msg[:fetch_id])
5823
+ elsif msg
5824
+ # Mark BUSY for the whole span of this postMessage handler. Unlike the SW /
5825
+ # broadcast branches above (each tracked by its own pending counter),
5826
+ # `@worker_in_flight` under-counts a multi-reply handshake to 0 mid-flight (one
5827
+ # request → many progress replies + a final resolve), so `worker_pending?` would
5828
+ # go false while the worker is still working and settle would abandon the
5829
+ # protocol between replies. Keep it true until the handler returns.
5830
+ @worker_init_lock.synchronize { @worker_busy += 1 }
5831
+ busy_held = true
5832
+ # A plain worker postMessage handler can start a multi-stage async bring-up
5833
+ # that alternates microtasks and timers — most sharply Emscripten's WASM
5834
+ # runtime init (addRunDependency → read the binary on a setTimeout(0) →
5835
+ # WebAssembly.instantiate → removeRunDependency → run() → onRuntimeInitialized
5836
+ # → the module factory's `.then`). Draining once leaves the microtask layers a
5837
+ # fired timer queued *after the last timer* stranded, so the factory promise
5838
+ # never settles (Tesseract hangs at "initializing tesseract"). Run the worker's
5839
+ # own event loop to quiescence instead of a single gated tick.
5840
+ rt.call('__csim_workerOnMessage', msg)
5841
+ drive_worker_to_quiescence(rt)
5842
+ end
5843
+ # Drive the worker's OWN event loop each tick: an AUTONOMOUS loop (the dispatcher
5844
+ # executor-worker's receive→fetch→setTimeout retry, which has no inbox message)
5845
+ # may have pending timers. Drain ~one poll interval (WorkerRuntime#drain_timers
5846
+ # advances the worker clock a step) so they progress; worker http fetch is
5847
+ # setTimeout(0)+__rackFetch, resolved on this thread by the drain. Gated on a
5848
+ # PENDING timer (any, not just due-now — the clock must advance to fire a future
5849
+ # randomDelay) so an idle message-driven worker with no timers stays lazy. A
5850
+ # regular postMessage already drove itself to quiescence above (no timer left),
5851
+ # so this is a no-op for it. Host CALLS, not string `eval`, keep the per-tick
5852
+ # cost off the V8 compile path (rule 3).
4110
5853
  if rt.call('__nextTimerDelay').to_f >= 0
4111
5854
  rt.drain_microtasks
4112
5855
  rt.drain_timers
4113
5856
  end
5857
+ if busy_held
5858
+ @worker_init_lock.synchronize { @worker_busy -= 1 }
5859
+ busy_held = false
5860
+ end
4114
5861
  break if rt.call('__csimWorkerClosedRead')
4115
5862
  end
4116
5863
  end
4117
5864
  rescue StandardError => e
4118
5865
  outbox << {handle: handle, kind: '__error', message: "#{e.class}: #{e.message}"}
4119
5866
  ensure
5867
+ # A raise between the busy bump and its matching decrement would strand the
5868
+ # counter; balance it here so worker_pending? can't stick true after this thread dies.
5869
+ @worker_init_lock.synchronize { @worker_busy -= 1 } if busy_held
4120
5870
  release_init.call # guarantee the init count is released on an early raise
4121
5871
  rt&.dispose
4122
5872
  end
@@ -4137,7 +5887,12 @@ module Capybara
4137
5887
  return data[:bytes]
4138
5888
  end
4139
5889
  b64 = @runtime.call('__csimReadBlobBase64', u)
4140
- return nil unless b64
5890
+ # A blob created INSIDE a frame realm lives in that realm's in-VM store, which the main
5891
+ # runtime's `__csimReadBlobBase64` above can't see. But createObjectURL also registered its
5892
+ # bytes in the cross-realm `@blob_registry` (crossCtx, since a frame realm is multi-realm),
5893
+ # so fall back to it — this is what makes `new Worker(blobURL)` work from a data: iframe.
5894
+ b64 = blob_resolve(u) if b64.nil? || b64.to_s.empty?
5895
+ return nil if b64.nil? || b64.to_s.empty?
4141
5896
  return Base64.decode64(b64.to_s)
4142
5897
  end
4143
5898
  # `data:[<mediatype>][;base64],<data>` worker scripts (a worker created
@@ -4161,6 +5916,24 @@ module Capybara
4161
5916
  end
4162
5917
  end
4163
5918
 
5919
+ # Run a worker isolate's own event loop until it goes idle: drain microtasks,
5920
+ # then — if a timer is pending — advance the worker clock to fire it and loop, so
5921
+ # the microtasks that timer's callback queues get drained in turn. A single
5922
+ # `drain_microtasks; drain_timers` pair strands whatever the last-fired timer
5923
+ # queued, which is exactly how Emscripten's WASM bring-up stalls
5924
+ # (`removeRunDependency` runs only after the binary-read setTimeout, and its
5925
+ # `run()` → `onRuntimeInitialized` continuation is a bare microtask with no further
5926
+ # timer to re-trigger a gated drain). Bounded by WORKER_QUIESCE_MAX_ROUNDS so a
5927
+ # self-perpetuating timer (setInterval) yields back to the poll loop rather than
5928
+ # pinning the thread.
5929
+ private def drive_worker_to_quiescence(rt)
5930
+ WORKER_QUIESCE_MAX_ROUNDS.times do
5931
+ rt.drain_microtasks
5932
+ break if rt.call('__nextTimerDelay').to_f < 0
5933
+ rt.drain_timers
5934
+ end
5935
+ end
5936
+
4164
5937
  # `Thread::Queue#pop(timeout:)` blocks releasing the GVL — fine
4165
5938
  # because the worker thread has nothing else to do while idle,
4166
5939
  # and `worker_post_to_worker` wakes the wait immediately.
@@ -4225,15 +5998,146 @@ module Capybara
4225
5998
  end
4226
5999
  end
4227
6000
 
6001
+ # Fetch caps a request at 20 redirects: the 21st is a network error (redirect-count).
6002
+ # The loop below runs one iteration PER dispatch, so it needs 20 redirect hops plus
6003
+ # the final response — MAX_FETCH_REDIRECTS + 1 iterations — to let exactly 20 succeed.
4228
6004
  MAX_FETCH_REDIRECTS = 20
6005
+ # Request cache modes that never READ the store (always hit the network), and modes that
6006
+ # serve a STORED response even when stale. Frozen so the hot rack_fetch path allocates no
6007
+ # throwaway arrays per hop (perf).
6008
+ CACHE_MODES_SKIP_READ = %w[no-store reload].freeze
6009
+ CACHE_MODES_SERVE_STALE = %w[force-cache only-if-cached].freeze
6010
+ # Fetch "bad port" blocklist (https://fetch.spec.whatwg.org/#port-blocking) —
6011
+ # ports tied to non-HTTP protocols a request must never reach. Frozen Set for
6012
+ # O(1) membership on the rack_fetch path.
6013
+ BAD_PORTS = Set[
6014
+ 0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77,
6015
+ 79, 87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135,
6016
+ 137, 139, 143, 161, 179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531,
6017
+ 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, 995, 1719, 1720,
6018
+ 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668,
6019
+ 6669, 6679, 6697, 10080
6020
+ ].freeze
6021
+
6022
+ REFERRER_POLICIES = %w[
6023
+ no-referrer no-referrer-when-downgrade origin origin-when-cross-origin
6024
+ same-origin strict-origin strict-origin-when-cross-origin unsafe-url
6025
+ ].freeze
6026
+
6027
+ # The `Referer` value a request carries under a Referrer-Policy — nil = send none
6028
+ # (https://w3c.github.io/webappsec-referrer-policy/#determine-requests-referrer).
6029
+ # `referrer_url` is the request's referrer (the initiating document); `target_url`
6030
+ # its destination. "full" is the referrer stripped of fragment + credentials;
6031
+ # "origin" is scheme://host[:port]/. An empty / unknown policy → the default
6032
+ # (strict-origin-when-cross-origin).
6033
+ def compute_referrer(policy, referrer_url, target_url)
6034
+ return nil if referrer_url.nil? || referrer_url.to_s.empty?
6035
+ policy = 'strict-origin-when-cross-origin' unless REFERRER_POLICIES.include?(policy)
6036
+ return nil if policy == 'no-referrer'
6037
+ # The referrer is almost always the (constant) document URL — memoise its parse
6038
+ # so the rack_fetch hot path doesn't re-parse it per request (rule 3).
6039
+ ref = parse_referrer_url(referrer_url)
6040
+ return nil unless ref && %w[http https].include?(ref.scheme)
6041
+ full = -> { u = ref.dup; u.fragment = nil; u.password = nil; u.user = nil; u.to_s }
6042
+ origin_only = -> {
6043
+ default_port = ref.scheme == 'https' ? 443 : 80
6044
+ port = ref.port && ref.port != default_port ? ":#{ref.port}" : ''
6045
+ "#{ref.scheme}://#{ref.host}#{port}/"
6046
+ }
6047
+ return full.call if policy == 'unsafe-url'
6048
+ return origin_only.call if policy == 'origin'
6049
+ # The remaining policies need the target to know same-origin / downgrade.
6050
+ tgt = (URI.parse(target_url) rescue nil)
6051
+ return nil unless tgt
6052
+ same_origin = ref.scheme == tgt.scheme && ref.host == tgt.host && ref.port == tgt.port
6053
+ downgrade = ref.scheme == 'https' && tgt.scheme == 'http'
6054
+ case policy
6055
+ when 'origin-when-cross-origin' then same_origin ? full.call : origin_only.call
6056
+ when 'same-origin' then same_origin ? full.call : nil
6057
+ when 'strict-origin' then downgrade ? nil : origin_only.call
6058
+ when 'no-referrer-when-downgrade' then downgrade ? nil : full.call
6059
+ when 'strict-origin-when-cross-origin' then same_origin ? full.call : (downgrade ? nil : origin_only.call)
6060
+ end
6061
+ end
6062
+
6063
+ # Parse a referrer URL, memoising the last one (the referrer is the document URL
6064
+ # for nearly every request, so this caches across the whole page's subresources).
6065
+ def parse_referrer_url(url)
6066
+ return @referrer_parsed if defined?(@referrer_parsed_for) && @referrer_parsed_for == url
6067
+ @referrer_parsed_for = url
6068
+ @referrer_parsed = (URI.parse(url) rescue nil)
6069
+ end
6070
+
6071
+ # Whether a request to `url_str` must be blocked as a Fetch "bad port". Cheap
6072
+ # pre-gate: only URLs whose authority carries an explicit `:<digit>` are parsed
6073
+ # (the vast majority don't), so the rack_fetch hot path — every asset / xhr /
6074
+ # fetch, cache hits included — skips URI.parse entirely.
6075
+ def bad_port?(url_str)
6076
+ return false unless url_str =~ %r{\A[a-z]+://[^/]*:\d}i
6077
+ port = URI.parse(url_str).port
6078
+ port && BAD_PORTS.include?(port)
6079
+ rescue URI::Error
6080
+ false
6081
+ end
6082
+
4229
6083
  # URLs we won't even try to route through Rack: anything that
4230
6084
  # isn't http(s) (data: / mailto: / about:) plus pseudo-tokens
4231
6085
  # like V8's `<snapshot>` that sourcemap libraries pull out of
4232
6086
  # error stacks and feed straight to `fetch()` / `xhr.open()`.
4233
- def rack_fetch(method, url, body, headers, redirect_mode, env_extras: nil)
6087
+ def rack_fetch(method, url, body, headers, redirect_mode, cors_mode = nil, credentials: 'same-origin', env_extras: nil, referrer_policy: nil, referrer: nil, cache_mode: 'default', initiator: nil, site_seed: nil, origin_null: false)
6088
+ # NB: a relative fetch/XHR URL is resolved against the document's API base URL
6089
+ # at OPEN time (XHR open() / fetch()), in JS, NOT here — resolving at send time
6090
+ # would wrongly pick up a `<base href>` inserted after open() (open-url-base
6091
+ # -inserted-after-open). So this resolves only against the document URL.
4234
6092
  target = resolve_against_current(url.to_s)
4235
6093
  return nil unless target.is_a?(String) && target.match?(%r{\Ahttps?://}i)
4236
- method = (method || 'GET').to_s.upcase
6094
+ # Fetch "port blocking" (https://fetch.spec.whatwg.org/#port-blocking): a
6095
+ # request to a blocked port is a network error before any connection —
6096
+ # fetch() rejects with TypeError, a sync XHR throws NetworkError
6097
+ # (request-bad-port). Re-checked per redirect hop below ("HTTP-redirect fetch"
6098
+ # re-runs the block), so a 3xx Location to a bad port is refused too.
6099
+ return nil if bad_port?(target)
6100
+ # CORS enforcement (preflight + Access-Control checks) applies only to cors_mode
6101
+ # 'cors' — sent by XHR and by fetch()'s default mode. fetch() also threads
6102
+ # 'no-cors' / 'same-origin' (mode semantics below), and a form-submission
6103
+ # navigation threads 'navigate'; other callers (sendBeacon, ESM, workers, the
6104
+ # internal asset GET) pass nil → no CORS and no mode semantics. The document's
6105
+ # origin is the request's origin; a different target origin is cross-origin.
6106
+ cors = cors_mode == 'cors'
6107
+ # The request's origin (document origin) for EVERY fetch mode — Fetch appends an
6108
+ # Origin header to every non-GET/HEAD request regardless of mode (a same-origin or
6109
+ # no-cors POST/PUT still carries it, for the server's CSRF/Origin check). CORS
6110
+ # enforcement itself stays gated on `cors` below; a nil-mode internal caller
6111
+ # (navigation / asset GET) has no origin semantics. An explicit `initiator` (a SW
6112
+ # re-issuing a navigation via `fetch(event.request)`) is the request's origin for
6113
+ # ALL modes — so a passthrough 'navigate'-mode POST still carries its Origin.
6114
+ req_origin = initiator || (%w[cors no-cors same-origin].include?(cors_mode) ? url_origin(@current_url) : nil)
6115
+ # Fetch request "mode" (fetch threads it; XHR is always 'cors'; a non-fetch/xhr
6116
+ # caller passes nil → no mode semantics, a plain 'basic' response). `no-cors`
6117
+ # filters a cross-origin response to opaque; `same-origin` makes a cross-origin
6118
+ # request a network error. `doc_origin` detects cross-origin for the response
6119
+ # TYPE regardless of whether CORS enforcement (cors) runs; `crossed` latches once
6120
+ # any hop leaves the document origin.
6121
+ no_cors_mode = cors_mode == 'no-cors'
6122
+ same_origin_mode = cors_mode == 'same-origin'
6123
+ # Only the real fetch request modes carry cross-origin semantics; a 'navigate'
6124
+ # (form submission) or a nil-mode internal caller gets a plain readable response.
6125
+ doc_origin = %w[cors no-cors same-origin].include?(cors_mode) ? url_origin(@current_url) : nil
6126
+ crossed = false
6127
+ # Sec-Fetch-Site latches the widest initiator↔hop relationship across the redirect chain
6128
+ # (like the navigation path), computed vs the request's referrer-source origin below. A SW
6129
+ # re-fetch seeds it with the widened site the network hops accumulated BEFORE the SW
6130
+ # intercepted the final hop (a same-site redirect the passthrough must keep reporting).
6131
+ sec_site = site_seed
6132
+ # A request is "credentialed" (cookies + the credentialed CORS check) only in
6133
+ # `include` mode; `same-origin` (default) and `omit` are uncredentialed for the
6134
+ # CORS check, while the cookie decision below distinguishes all three.
6135
+ with_credentials = credentials == 'include'
6136
+ # Use the method's case AS GIVEN: the JS callers already applied the spec
6137
+ # normalization (XHR open() / Fetch upper-case the known methods, preserving
6138
+ # an unknown method's case — open-method-case-sensitive). Upper-casing here
6139
+ # would clobber a custom method like `xUNIcorn`.
6140
+ method = (method || 'GET').to_s
4237
6141
  redirected = false
4238
6142
  # JS-side base64-encodes Blob/File bodies (raw bytes survive
4239
6143
  # the engine's UTF-8 string boundary that way); decode before
@@ -4242,48 +6146,331 @@ module Capybara
4242
6146
  body = Base64.decode64(body.to_s)
4243
6147
  headers = headers.reject {|k, _| k == 'X-Csim-Body-B64' }
4244
6148
  end
4245
- MAX_FETCH_REDIRECTS.times do
6149
+ # CHALLENGE credentials for transparent HTTP Basic auth — set by the XHR authentication path
6150
+ # (open() user/password / URL userinfo), NOT a raw setRequestHeader('Authorization'). They are
6151
+ # NOT sent proactively; a 401 "Basic" challenge triggers a single re-send with them (below).
6152
+ # Strip the marker so it never reaches the server.
6153
+ challenge_authz = nil
6154
+ if headers.is_a?(Hash) && (mk = headers.keys.find {|k| k.to_s.casecmp?('x-csim-auth-challenge') })
6155
+ challenge_authz = 'Basic ' + headers[mk].to_s
6156
+ headers = headers.reject {|k, _| k == mk }
6157
+ end
6158
+ # The request's origin starts as the document origin; a cross-origin REDIRECT
6159
+ # taints it to an opaque origin (serialized "null") per Fetch "HTTP-redirect
6160
+ # fetch". `effective_origin` IS that origin — it's what the Origin header
6161
+ # carries and what the CORS check / preflight compare against from that hop on
6162
+ # ('null' once tainted, so the server must then allow 'null' or '*'). A SW re-fetch whose
6163
+ # navigation ALREADY crossed origin via a network redirect starts tainted (origin_null).
6164
+ effective_origin = origin_null ? 'null' : req_origin
6165
+ # Virtual server delay (a handler's `time.sleep`, see wpt_py_handler.py) accumulated across
6166
+ # EVERY sub-request this fetch makes — the CORS preflight AND every redirect hop — since the
6167
+ # `timeout` a client applies spans them all and a redirect/preflight must not reset it
6168
+ # (timeout-multiple-fetches). Reset per fetch; the final response carries the total.
6169
+ @fetch_server_delay_ms = 0
6170
+ # An author conditional (If-None-Match / …) means the caller is doing its own
6171
+ # revalidation, so the UA cache must step aside (computed once — the headers
6172
+ # carrying it survive every redirect hop unchanged).
6173
+ skip_cache = request_has_conditional_headers?(headers)
6174
+ ref_policy = referrer_policy # may be overridden per hop by a response Referrer-Policy
6175
+ # The referrer is stripped PROGRESSIVELY: each hop applies its (possibly
6176
+ # overridden) policy to the referrer the PREVIOUS hop sent, not to the original
6177
+ # document — so once a hop reduces it to an origin (or drops it), a later, laxer
6178
+ # policy can't widen it back (redirect-referrer-override). The initial source is
6179
+ # the request's referrer: an explicit `init.referrer` URL when given, else the
6180
+ # document URL ("client"); an empty referrer means no-referrer (compute_referrer
6181
+ # maps a blank source to nil).
6182
+ ref_source = referrer.nil? ? @current_url : referrer
6183
+ # The request's INITIATOR origin for Sec-Fetch-Site — captured ONCE (loop-invariant), before
6184
+ # the per-hop referrer reassignment (5927 below) degrades ref_source, and independent of
6185
+ # Referrer-Policy: the initiator is the referrer's origin (a SW's `fetch(event.request)`
6186
+ # carries the navigating frame's origin here, so a cross-origin passthrough is same-/cross-site
6187
+ # correctly), falling back to the document origin when the referrer was policy-emptied — never
6188
+ # 'none' for a request that has a real initiator. An explicit `initiator` is authoritative:
6189
+ # it survives the referrer reset a `new Request(event.request, init)` performs (referrer →
6190
+ # about:client), so a SW's change-request re-fetch is same-origin to the SW's own script.
6191
+ sec_initiator = initiator || url_origin(ref_source) || url_origin(@current_url)
6192
+ (MAX_FETCH_REDIRECTS + 1).times do
4246
6193
  t0 = @trace && Process.clock_gettime(Process::CLOCK_MONOTONIC)
4247
- # GET-only cache shortcut (RFC 9111). Fresh hit skip @app.call
4248
- # entirely; stale-but-revalidatable fall through with conditional
4249
- # headers added so the server can return 304.
4250
- cache_entry = method == 'GET' ? @@asset_cache.lookup(target) : nil
4251
- if cache_entry&.fresh?
4252
- # Cached static asset log headers/type/size but skip the (boring) body.
6194
+ # Cross-origin-ness for the request mode/type, latched across hops. Computed
6195
+ # BEFORE the cache so a cross-origin request never takes the cache fast path
6196
+ # (which would bypass the opaque filter / same-origin-mode error / cors type).
6197
+ crossed ||= !!(doc_origin && (effective_origin == 'null' || url_origin(target) != doc_origin))
6198
+ return nil if same_origin_mode && crossed # 'same-origin' mode forbids a cross-origin hop
6199
+ # HTTP cache (RFC 9111 + Fetch "HTTP-network-or-cache fetch"), gated by the request's
6200
+ # cache MODE. GET-only, same-origin (a cross-origin hop always redispatches so the mode
6201
+ # filtering below runs), and stepped aside when the author sent their own conditional.
6202
+ # - no-store / reload : never read the store — always hit the network, no conditional
6203
+ # - force-cache / only-if-cached : serve a stored response even when STALE, no revalidation
6204
+ # (only-if-cached with nothing stored is a network error)
6205
+ # - no-cache : always revalidate, even a fresh entry
6206
+ # - default : serve fresh; revalidate stale (fall through with conditionals)
6207
+ read_cache = method == 'GET' && !skip_cache && !crossed && !CACHE_MODES_SKIP_READ.include?(cache_mode)
6208
+ cache_entry = read_cache ? @@asset_cache.lookup(target) : nil
6209
+ serve_stored = cache_entry &&
6210
+ (CACHE_MODES_SERVE_STALE.include?(cache_mode) || (cache_entry.fresh? && cache_mode != 'no-cache'))
6211
+ if serve_stored
6212
+ if REDIRECT_STATUSES.include?(cache_entry.status.to_i)
6213
+ # A cached REDIRECT obeys the redirect mode exactly like a fresh one: `error` is a
6214
+ # network error, `manual` is an opaque-redirect, and `follow` follows it THROUGH
6215
+ # the cache — resolve the Location and continue so the next hop serves the cached
6216
+ # target (request-cache "uses cached … redirects"). only-if-cached / force-cache
6217
+ # reach this only same-origin GET (read_cache excludes cross-origin), so there's no
6218
+ # method rewrite / origin taint.
6219
+ raise StandardError, '[capybara-simulated] fetch: redirect blocked by redirect=error mode' if redirect_mode == 'error'
6220
+ if redirect_mode != 'follow'
6221
+ return response_hash(0, {}, '', target, false, type: 'opaqueredirect', body_null: true)
6222
+ end
6223
+ if (loc = redirect_location(cache_entry.status, cache_entry.headers))
6224
+ trace_network(method, target, cache_entry.status, headers, body, cache_entry.headers, nil, t0, true)
6225
+ redirected = true
6226
+ next_url = resolve_against(loc, target)
6227
+ return nil unless next_url.to_s.match?(%r{\Ahttps?://}i)
6228
+ target = carry_fragment(target, next_url)
6229
+ return nil if bad_port?(target) # a cached redirect to a blocked port is still a network error
6230
+ next
6231
+ end
6232
+ end
6233
+ # Cached asset — log headers/type/size but skip the (boring) body.
4253
6234
  trace_network(method, target, cache_entry.status, headers, body, cache_entry.headers, nil, t0, false)
4254
6235
  return response_hash(cache_entry.status, cache_entry.headers, cache_entry.body, target, redirected)
4255
6236
  end
6237
+ # only-if-cached forbids the network: no usable stored response → a network error.
6238
+ return nil if cache_mode == 'only-if-cached'
4256
6239
 
4257
6240
  env = Rack::MockRequest.env_for(target, method: method, input: body || '')
6241
+ env['REQUEST_METHOD'] = method # env_for upcases the method; restore the exact case (open-method-case-sensitive)
6242
+ # env_for always sets Content-Length to the input bytesize (0 for an empty body).
6243
+ # Fetch adds Content-Length: 0 for a bodyless request ONLY when the method is
6244
+ # POST or PUT; GET/HEAD and every other method (incl. a custom one like `Chicken`)
6245
+ # send no Content-Length when the body is empty (send-entity-body-none;
6246
+ # request-headers custom-method). A non-empty body keeps its real length.
6247
+ env.delete('CONTENT_LENGTH') if body.to_s.empty? && !%w[POST PUT].include?(method.to_s.upcase)
4258
6248
  apply_request_headers(env, headers) if headers
4259
6249
  apply_request_headers(env, @@asset_cache.revalidation_headers(cache_entry)) if cache_entry
4260
- apply_default_request_env(env, referer: @current_url, force: false)
6250
+ # The Referer follows the request's Referrer-Policy (a redirect response can
6251
+ # override the policy for the next hop — see below). `hop_referer` also becomes
6252
+ # the source the NEXT hop strips from.
6253
+ hop_referer = compute_referrer(ref_policy, ref_source, target)
6254
+ apply_default_request_env(env, referer: hop_referer, force: false)
6255
+ # Whether this hop is cross-origin (cors only): a tainted (opaque) origin is
6256
+ # cross-origin to every real target; otherwise compare the target to the
6257
+ # document origin. Drives the Origin header, preflight, and the CORS check.
6258
+ cross_origin = cors && (effective_origin == 'null' || url_origin(target) != req_origin)
6259
+ # Fetch credentials mode decides cookie attachment, independent of the CORS
6260
+ # mode: `omit` never sends them; `include` always does; `same-origin` (default)
6261
+ # sends them only to a same-origin target — so an uncredentialed cross-origin
6262
+ # hop (cors OR no-cors) must not leak the document's cookies
6263
+ # (cors-redirect-credentials / cors-cookies). A navigation / internal caller has
6264
+ # no doc_origin, so it counts as same-origin and keeps them.
6265
+ hop_cross_origin = !!(doc_origin && (effective_origin == 'null' || url_origin(target) != doc_origin))
6266
+ send_cookies = credentials == 'include' || (credentials != 'omit' && !hop_cross_origin)
6267
+ env.delete('HTTP_COOKIE') unless send_cookies
6268
+ # HTTP auth caching (RFC 7617 §2.2): once credentials succeed for an origin (cached below),
6269
+ # the UA sends them pre-emptively for later credentialed requests to it — so a Basic-auth
6270
+ # resource loads without a fresh 401 challenge (the login helper authenticates first, then
6271
+ # the guarded image/XHR requests carry the cached header). Gated on the same credential
6272
+ # decision as cookies; the caller's own Authorization (an explicit user:pass) always wins.
6273
+ # Skip the pre-emptive cache when THIS request brought its own challenge credentials (open()
6274
+ # user/pass) — those must win over a cached session, so the request goes out unauthenticated
6275
+ # and the 401-retry below applies the caller's credentials, not a stale cached pair
6276
+ # (send-authentication-competing-names-passwords).
6277
+ if send_cookies && !env.key?('HTTP_AUTHORIZATION') && !challenge_authz && (cached = @auth_cache[url_origin(target)])
6278
+ env['HTTP_AUTHORIZATION'] = cached
6279
+ end
6280
+ # A CORS request to a URL carrying credentials (`user:pass@`) is a network
6281
+ # error (access-control-and-redirects "user info" subtest).
6282
+ return nil if cross_origin && url_has_userinfo?(target)
6283
+ # CORS-preflight, re-evaluated PER HOP: a cross-origin non-simple request (a
6284
+ # non-safelisted method / header / Content-Type) must pass an OPTIONS preflight
6285
+ # first — so a same-origin request redirected cross-origin to an unsafe resource
6286
+ # is preflighted on the NEW origin (send-redirect-to-cors), not just an initially
6287
+ # cross-origin one (access-control-basic-get-fail-non-simple / preflight-*).
6288
+ if cross_origin && cors_unsafe_request?(method, headers)
6289
+ return nil unless cors_preflight_ok?(target, method, headers, effective_origin, with_credentials, hop_referer)
6290
+ end
6291
+ # Send the (effective) Origin — the UA owns this header — on a cors request when
6292
+ # the hop is cross-origin OR the method is not GET/HEAD (Fetch appends Origin to
6293
+ # every non-GET/HEAD request, so a same-origin POST carries it too). After a
6294
+ # cross-origin redirect the origin is the opaque "null".
6295
+ if cross_origin || (req_origin && !%w[GET HEAD].include?(method.to_s.upcase))
6296
+ env['HTTP_ORIGIN'] = effective_origin
6297
+ end
6298
+ # Fetch-Metadata request headers — emitted for every fetch/XHR/SW request (a mode is set;
6299
+ # the nil-mode internal callers — ESM / asset GET / beacon — are left alone). Sec-Fetch-Site
6300
+ # widens across the redirect chain vs the loop-invariant `sec_initiator` (see above). -Mode
6301
+ # is the request mode (a SW's `fetch(event.request)` re-issues a 'navigate'-mode request,
6302
+ # `new Request(…,{mode})` a 'same-origin' one); -Dest is 'empty' for a script-initiated fetch
6303
+ # — the only navigate-mode path here (a navigation emits its own 'iframe'/'document' dest +
6304
+ # -User via navigation_request_headers, never reaching rack_fetch).
6305
+ if cors_mode
6306
+ sec_site = widen_sec_fetch_site(sec_site, sec_fetch_site(sec_initiator, target))
6307
+ env['HTTP_SEC_FETCH_SITE'] = sec_site
6308
+ env['HTTP_SEC_FETCH_MODE'] = cors_mode
6309
+ env['HTTP_SEC_FETCH_DEST'] = 'empty'
6310
+ end
4261
6311
  env.merge!(env_extras) if env_extras
4262
6312
  status, resp_headers, resp_body = dispatch_rack_or_http(target, env, method: method, body: body)
4263
- merge_set_cookie(resp_headers)
6313
+ @fetch_server_delay_ms += server_delay_ms_of(resp_headers)
6314
+ # Transparent HTTP Basic auth (RFC 7617): a request carrying CHALLENGE credentials (open()
6315
+ # user/pass / URL userinfo) that gets a 401 "Basic" challenge — and hasn't already sent an
6316
+ # Authorization (an explicit setRequestHeader / a pre-emptively-attached cached credential) —
6317
+ # is re-sent ONCE with them; only the authenticated response reaches script, the 401 never does
6318
+ # (send-authentication-basic / -existing-session). `omit` sends no credentials at all.
6319
+ if challenge_authz && status.to_i == 401 && credentials != 'omit' &&
6320
+ !env.key?('HTTP_AUTHORIZATION') && www_authenticate_basic?(resp_headers)
6321
+ resp_body.close if resp_body.respond_to?(:close)
6322
+ env['HTTP_AUTHORIZATION'] = challenge_authz
6323
+ status, resp_headers, resp_body = dispatch_rack_or_http(target, env, method: method, body: body)
6324
+ end
6325
+ # Fetch credentials mode "omit" ignores credentials the response sends back too —
6326
+ # its Set-Cookie is dropped, not stored (cors-cookies / credentials "omit mode").
6327
+ merge_set_cookie(resp_headers, target) unless credentials == 'omit'
6328
+ # Cache the credentials this origin ACCEPTED (AUTHENTICATION credentials — a pre-emptively
6329
+ # attached cached credential, or the challenge credential the 401-retry above just supplied —
6330
+ # that weren't rejected with a 401), for the pre-emptive send above. `omit` neither sends nor
6331
+ # caches. Only the request's OWN origin is cached: Authorization is stripped on a cross-origin
6332
+ # redirect hop (above), so origin A's credentials can't seed origin B's cache. (A non-2xx
6333
+ # same-origin response — an opaque status-0 no-cors fetch, a same-origin 3xx — still
6334
+ # establishes the credentials for THAT origin, so the gate is "not a 401", not "is a 2xx".)
6335
+ if challenge_authz && credentials != 'omit' && status.to_i != 401
6336
+ @auth_cache[url_origin(target)] = env['HTTP_AUTHORIZATION'] || challenge_authz
6337
+ end
4264
6338
  if status == 304 && cache_entry
4265
6339
  trace_network(method, target, cache_entry.status, headers, body, cache_entry.headers, nil, t0, false)
4266
6340
  resp_body.close if resp_body.respond_to?(:close)
4267
6341
  @@asset_cache.refresh(cache_entry, resp_headers)
4268
- return response_hash(cache_entry.status, cache_entry.headers, cache_entry.body, target, redirected)
6342
+ # The cache stores the RAW response headers, so a cross-origin cached entry must
6343
+ # be re-filtered through the CORS exposed-header set on the way back to script —
6344
+ # a 304 revalidation must not leak headers the original cross-origin fetch hid.
6345
+ cached_headers = cross_origin ? cors_exposed_headers(cache_entry.headers, with_credentials) : cache_entry.headers
6346
+ return response_hash(cache_entry.status, cached_headers, cache_entry.body, target, redirected)
6347
+ end
6348
+ # Fetch "CORS check" runs on EVERY cross-origin response — including a 3xx the
6349
+ # UA is about to follow (a redirect whose response lacks a valid Access-Control
6350
+ # -Allow-Origin is itself a network error: access-control-and-redirects). A
6351
+ # credentialed request additionally forbids `*` and needs Allow-Credentials.
6352
+ if cross_origin && !cors_response_ok?(resp_headers, effective_origin, with_credentials)
6353
+ resp_body.close if resp_body.respond_to?(:close)
6354
+ return nil
4269
6355
  end
4270
- if redirect_mode != 'manual' && (loc = redirect_location(status, resp_headers))
6356
+ # A redirect-status response in a NON-follow mode is handled without following,
6357
+ # keyed on the status ALONE (the Location is never parsed): `error` is a network
6358
+ # error; `manual` is an opaque-redirect filtered response (status 0, empty
6359
+ # statusText/headers, the ORIGINAL request URL, type 'opaqueredirect'). The CORS
6360
+ # check above runs first, so a cross-origin redirect that fails CORS is a network
6361
+ # error either way (redirect-mode / -location).
6362
+ if redirect_mode != 'follow' && REDIRECT_STATUSES.include?(status.to_i)
6363
+ resp_body.close if resp_body.respond_to?(:close)
4271
6364
  raise StandardError, '[capybara-simulated] fetch: redirect blocked by redirect=error mode' if redirect_mode == 'error'
6365
+ # A no-cors request may not even opaquely expose a CROSS-origin redirect — a
6366
+ # no-cors non-follow redirect to a cross-origin target is a network error,
6367
+ # while a same-origin one still yields an opaque-redirect.
6368
+ return nil if no_cors_mode && crossed
6369
+ return response_hash(0, {}, '', target, false, type: 'opaqueredirect', body_null: true)
6370
+ end
6371
+ if (loc = redirect_location(status, resp_headers))
4272
6372
  # Log this hop (3xx) before method/body are rewritten for the next.
4273
6373
  trace_network(method, target, status, headers, body, resp_headers, nil, t0, true)
6374
+ # Cache the redirect itself (a cacheable 3xx with freshness) BEFORE following it —
6375
+ # the follow does `next`, which would otherwise skip the store below — so a later
6376
+ # only-if-cached / force-cache request can follow the redirect chain from the cache
6377
+ # (request-cache "uses cached … redirects"). Same store gate as the terminal hop.
6378
+ @@asset_cache.store(target, status, resp_headers, '') if method == 'GET' && cache_mode != 'no-store' && !skip_cache
4274
6379
  redirected = true
4275
- preserve = [307, 308].include?(status)
6380
+ ref_source = hop_referer # the next hop strips from what THIS hop sent
6381
+ # A redirect response's Referrer-Policy overrides the policy for the next hop
6382
+ # (redirect-referrer-override): the last valid token of the header wins.
6383
+ if (rp = resp_headers['referrer-policy'] || resp_headers['Referrer-Policy'])
6384
+ tok = Array(rp).join(',').split(',').map(&:strip).reverse.find {|t| REFERRER_POLICIES.include?(t) }
6385
+ ref_policy = tok if tok
6386
+ end
4276
6387
  next_url = resolve_against(loc, target)
6388
+ # The UA only follows http(s) redirects: a Location that resolves to a
6389
+ # non-HTTP(S) URL (data:, an `invalidurl:` scheme, …) is a network error
6390
+ # (redirect-location data/invalid in follow mode).
6391
+ unless next_url.to_s.match?(%r{\Ahttps?://}i)
6392
+ resp_body.close if resp_body.respond_to?(:close)
6393
+ return nil
6394
+ end
6395
+ # A cross-origin redirect taints the request's origin to opaque ("null") only
6396
+ # once the request was ALREADY cross-origin (response tainting "cors", i.e.
6397
+ # `crossed`) and the hop changes origin — so a subsequent hop sends Origin: null
6398
+ # and the CORS check demands the server allow "null"/"*". The FIRST cross-origin
6399
+ # hop out of a same-origin request keeps the real origin (redirect-origin
6400
+ # "same origin to other origin" sends the document origin, not null).
6401
+ effective_origin = 'null' if cors && crossed && url_origin(next_url) != url_origin(target)
6402
+ # Fetch "HTTP-redirect fetch": a CROSS-ORIGIN redirect strips the request's
6403
+ # `Authorization` — credentials sent to the first origin must not be replayed to a
6404
+ # different one (nor seed that origin's auth cache below).
6405
+ if url_origin(next_url) != url_origin(target) && headers.is_a?(Hash)
6406
+ headers = headers.reject {|k, _| k.to_s.casecmp?('authorization') }
6407
+ end
4277
6408
  target = carry_fragment(target, next_url)
4278
- method = 'GET' unless preserve
4279
- body = nil unless preserve
6409
+ if bad_port?(target) # a redirect to a blocked port is a network error too
6410
+ resp_body.close if resp_body.respond_to?(:close)
6411
+ return nil
6412
+ end
6413
+ # Fetch "HTTP-redirect fetch": the method changes to GET (dropping the
6414
+ # body + its Content-* headers) ONLY for 301/302 of a POST, or 303 of a
6415
+ # non-GET/HEAD. Otherwise method, body, and headers are preserved — so a
6416
+ # GET/HEAD redirected via 301/302/303 keeps its method and Content-Type,
6417
+ # and 307/308 always preserve (xhr send-redirect basics).
6418
+ up = method.to_s.upcase
6419
+ if ([301, 302].include?(status) && up == 'POST') || (status == 303 && !%w[GET HEAD].include?(up))
6420
+ method = 'GET'
6421
+ body = nil
6422
+ headers = headers.reject {|k, _| REDIRECT_DROPPED_HEADERS.include?(k.to_s.downcase) } if headers.is_a?(Hash)
6423
+ end
4280
6424
  resp_body.close if resp_body.respond_to?(:close)
4281
6425
  next
4282
6426
  end
6427
+ # A follow-mode redirect whose Location header IS present but EMPTY parses to the
6428
+ # request URL — a self-redirect that would loop until the redirect limit trips a
6429
+ # network error. redirect_location returns nil for it (empty ⇒ no followable
6430
+ # target, so navigation keeps rendering the 3xx), so recognize it here and fail
6431
+ # directly — fetch-only (redirect-empty-location follow mode).
6432
+ if REDIRECT_STATUSES.include?(status.to_i)
6433
+ raw_loc = resp_headers['location'] || resp_headers['Location']
6434
+ raw_loc = raw_loc.first if raw_loc.is_a?(Array)
6435
+ if raw_loc && raw_loc.to_s.empty?
6436
+ resp_body.close if resp_body.respond_to?(:close)
6437
+ return nil
6438
+ end
6439
+ end
4283
6440
  body_str = read_rack_body(resp_body)
6441
+ # A HEAD response, and a null-body status (204/205/304), have NO body — the UA
6442
+ # discards whatever the server sent and exposes response.body as null
6443
+ # (response-method HEAD; response-null-body). `null_body` flags it so the JS
6444
+ # Response reports a null body + empty text.
6445
+ null_body = method.to_s.upcase == 'HEAD' || NULL_BODY_STATUSES.include?(status.to_i)
6446
+ body_str = '' if null_body
6447
+ # The UA transparently decodes a Content-Encoding'd body (gzip/deflate); the
6448
+ # header stays, the bytes are inflated (response-data-gzip / -deflate).
6449
+ body_str = decode_content_encoding(body_str, resp_headers)
6450
+ # A cross-origin response only EXPOSES (getResponseHeader / getAllResponseHeaders)
6451
+ # the CORS-safelisted response headers plus those named in Access-Control-Expose
6452
+ # -Headers (`*` = all). content-type stays safelisted, so response decoding is
6453
+ # unaffected. (Filtered for script exposure only — trace / set-cookie / cache see
6454
+ # the full set.) The CORS check itself already ran above (incl. on 3xx hops).
6455
+ # The virtual server delay is EPHEMERAL processing time (accumulated across the preflight +
6456
+ # every redirect hop): the client reads the TOTAL to time its async deferral, but it must
6457
+ # never be cached or replayed on a cache hit — strip it from the traced/stored response and
6458
+ # expose the total to the CLIENT only (added after CORS filtering, so it always survives).
6459
+ total_delay = @fetch_server_delay_ms.to_i
6460
+ resp_headers = resp_headers.reject {|k, _| k.to_s.casecmp?('x-csim-server-delay-ms') }
6461
+ exposed_headers = cross_origin ? cors_exposed_headers(resp_headers, with_credentials) : resp_headers
6462
+ exposed_headers = exposed_headers.merge('X-Csim-Server-Delay-Ms' => total_delay.to_s) if total_delay > 0
4284
6463
  trace_network(method, target, status, headers, body, resp_headers, body_str, t0, false)
4285
- @@asset_cache.store(target, status, resp_headers, body_str) if method == 'GET'
4286
- return response_hash(status, resp_headers, body_str, target, redirected)
6464
+ # A no-store request must not write the cache (RFC 9111 §5.2.1.5); a request carrying
6465
+ # the author's own conditional bypasses the UA cache entirely (read AND write) it's
6466
+ # "treated similarly to no-store" (request-cache-default-conditional). Every other mode
6467
+ # (incl. reload, which refreshes it) stores a cacheable GET response.
6468
+ @@asset_cache.store(target, status, resp_headers, body_str) if method == 'GET' && cache_mode != 'no-store' && !skip_cache
6469
+ # A no-cors cross-origin response is OPAQUE: status 0, empty body, no exposed
6470
+ # headers, empty URL (cors-basic "Opaque filter"). Otherwise the type is 'cors'
6471
+ # for a cross-origin (CORS-allowed) response, else 'basic'.
6472
+ return response_hash(0, {}, '', '', false, type: 'opaque', body_null: true, opaque_render: body_str) if no_cors_mode && crossed
6473
+ return response_hash(status, exposed_headers, body_str, target, redirected, type: crossed ? 'cors' : 'basic', body_null: null_body)
4287
6474
  end
4288
6475
  raise StandardError, "[capybara-simulated] fetch exceeded #{MAX_FETCH_REDIRECTS} redirects"
4289
6476
  rescue StandardError => e
@@ -4304,7 +6491,7 @@ module Capybara
4304
6491
  return unless @trace
4305
6492
  ct = resp_headers && (resp_headers['content-type'] || resp_headers['Content-Type'])
4306
6493
  ct = ct.first if ct.is_a?(Array) # Rack 3 permits array-valued header fields
4307
- ct = ct.split(';', 2).first.strip if ct.is_a?(String)
6494
+ ct = ct.split(';', 2).first&.strip if ct.is_a?(String) # "" → split is [] → first is nil
4308
6495
  size = if resp_body
4309
6496
  resp_body.bytesize
4310
6497
  elsif (cl = resp_headers && (resp_headers['content-length'] || resp_headers['Content-Length']))
@@ -4345,7 +6532,13 @@ module Capybara
4345
6532
 
4346
6533
  def normalize_trace_headers(headers)
4347
6534
  return nil unless headers
4348
- headers.each_with_object({}) {|(k, v), out| out[k.to_s] = v.is_a?(Array) ? v.join(', ') : v.to_s }
6535
+ headers.each_with_object({}) do |(k, v), out|
6536
+ # `x-csim-status-text` is an internal sentinel carrying the HTTP reason
6537
+ # phrase (response_hash lifts it into statusText); it's never a real wire
6538
+ # header, so keep it out of the trace.
6539
+ next if k.to_s.downcase == 'x-csim-status-text'
6540
+ out[k.to_s] = v.is_a?(Array) ? v.join(', ') : v.to_s
6541
+ end
4349
6542
  end
4350
6543
 
4351
6544
  # CGI convention: `Content-Type` and `Content-Length` land in env
@@ -4356,7 +6549,14 @@ module Capybara
4356
6549
  # `@rails/request.js` never deserialise and the server reads an
4357
6550
  # empty params hash.
4358
6551
  def apply_request_headers(env, headers)
6552
+ # Preserve the author's exact header names (casing + token chars) alongside the
6553
+ # CGI-mangled HTTP_* keys: the Rack env upcases names and drops non-alphanumerics
6554
+ # (Status-URI → HTTP_STATUS_URI, a tchar-only name → an unrecoverable key), but a
6555
+ # .py echo handler (inspect-headers / echo-headers) reports the names verbatim.
6556
+ # run_py_handler reads this side list to emit the original names.
6557
+ raw = (env['csim.raw_request_headers'] ||= []) if @@capture_raw_request_headers
4359
6558
  headers.each {|k, v|
6559
+ raw << [k.to_s, v.to_s] if raw
4360
6560
  name = k.to_s.upcase.tr('-', '_')
4361
6561
  case name
4362
6562
  when 'CONTENT_TYPE', 'CONTENT_LENGTH' then env[name] = v.to_s
@@ -4375,9 +6575,13 @@ module Capybara
4375
6575
  # text body when `body_b64` is absent.
4376
6576
  TEXT_CONTENT_TYPE_PREFIXES = %w[text/ application/json application/javascript application/ecmascript application/xml image/svg+xml].freeze
4377
6577
 
4378
- def response_hash(status, headers, body, url, redirected)
6578
+ def response_hash(status, headers, body, url, redirected, type: 'basic', body_null: false, opaque_render: nil)
4379
6579
  raw = body.to_s
4380
6580
  hdrs = stringify(headers)
6581
+ # A NUL in a header value is not a valid HTTP message; a real server can't
6582
+ # put it on the wire, so the fetch is a network error (nil → status 0 / a
6583
+ # thrown NetworkError for a sync XHR). See headers-normalize-response.
6584
+ return nil if hdrs.any? {|_, v| v.include?("\u0000") }
4381
6585
  is_text = text_response?(hdrs)
4382
6586
  # `body` crosses as TEXT — `responseText` semantics: the bytes decoded
4383
6587
  # as UTF-8 with invalid sequences replaced (a leading BOM selects the
@@ -4392,18 +6596,52 @@ module Capybara
4392
6596
  else
4393
6597
  RuntimeShared.utf8_text(raw)
4394
6598
  end
6599
+ # statusText = the HTTP reason phrase: a custom one carried on the internal
6600
+ # x-csim-status-text header (status.py), else the status code's standard
6601
+ # reason (xhr status/statusText tests). Strip the internal header either way.
6602
+ custom_reason = hdrs.delete('x-csim-status-text')
6603
+ # Rack::Utils::HTTP_STATUS_CODES values are ASCII-8BIT (binary) strings — the V8
6604
+ # bridge marshals a binary string as a byte array, not a JS string, so statusText
6605
+ # would arrive as [79,75] instead of "OK" (abort-during-loading reads statusText
6606
+ # on a static-file response). utf8_text re-tags + scrubs to a clean JS string, the
6607
+ # same path the body and every header value already take.
6608
+ reason = RuntimeShared.utf8_text(custom_reason || Rack::Utils::HTTP_STATUS_CODES[status.to_i] || '')
6609
+ # HTTP/2 has no reason phrase, so statusText is always the empty string there (a WPT
6610
+ # `.h2` test document's fetches run over h2). We don't model the h2 transport, so key
6611
+ # off the document URL — the same signal WPT uses to serve the resource over h2
6612
+ # (fetch/xhr status.h2 "statusText over H2 … should be the empty string").
6613
+ reason = '' if @current_url.to_s.include?('.h2.')
4395
6614
  out = {
4396
6615
  'status' => status,
6616
+ 'statusText' => reason,
4397
6617
  'headers' => hdrs,
4398
6618
  'body' => text,
4399
6619
  'url' => url,
4400
6620
  'redirected' => redirected,
4401
- 'type' => 'basic'
6621
+ 'type' => type
4402
6622
  }
6623
+ out['body_null'] = true if body_null # null-body status / HEAD → response.body is null
4403
6624
  # The BOM-detected encoding (if any) — a frame load pins its document's
4404
6625
  # characterSet to it (see __csimFrameWindow); highest-precedence signal.
4405
6626
  out['charset'] = bom_charset if bom_charset
4406
- out['body_b64'] = Base64.strict_encode64(raw) unless is_text
6627
+ # Hand the raw bytes to the (XHR) client UNLESS the response is pure-ASCII text.
6628
+ # ASCII decodes identically under every encoding — so responseText is already
6629
+ # correct from the UTF-8 `body`, and it round-trips byte-for-byte as an
6630
+ # ArrayBuffer/Blob. Any NON-ASCII body needs the bytes: a non-UTF-8 charset or an
6631
+ # XML-prolog / <meta charset>-sniffed encoding (responseText), or multibyte UTF-8
6632
+ # read as arraybuffer/blob — the client decodes them with the final encoding
6633
+ # (decodeResponseBytes). `ascii_only?` is a cheap C-level scan, so the dominant
6634
+ # pure-ASCII app JSON/HTML traffic keeps the fast path and pays no base64.
6635
+ out['body_b64'] = Base64.strict_encode64(raw) unless is_text && raw.ascii_only?
6636
+ # An OPAQUE (no-cors cross-origin) response hides its body from every script-visible read
6637
+ # (body/body_b64 are empty). But the bytes are still needed to RENDER an <img> the response
6638
+ # backs (a cross-origin image displays, merely canvas-tainting) — carry them on a private
6639
+ # side channel the image decode path reads, never a public body accessor. Attached to EVERY
6640
+ # opaque response, not just image requests: this is `rack_fetch`, which has no request
6641
+ # destination (a SW's own no-cors `fetch()` doesn't know its eventual consumer is an <img>),
6642
+ # so the choice is made client-side. The bytes are already in memory (`body_str`); the added
6643
+ # cost is one base64 per opaque response, off any hot path.
6644
+ out['opaque_render_b64'] = Base64.strict_encode64(opaque_render) if opaque_render && !opaque_render.empty?
4407
6645
  out
4408
6646
  end
4409
6647
 
@@ -4479,6 +6717,34 @@ module Capybara
4479
6717
  buf
4480
6718
  end
4481
6719
 
6720
+ # Transparently decode a Content-Encoding'd response body (HTTP "content coding"):
6721
+ # gzip / x-gzip via Zlib.gunzip; deflate via zlib-wrapped inflate, falling back to
6722
+ # raw DEFLATE (the "deflate" coding is ambiguously used for both). Unknown codings
6723
+ # (e.g. br) and malformed data are left untouched — best-effort, like a browser that
6724
+ # would error, but we keep the bytes so the caller still sees a response.
6725
+ def decode_content_encoding(body, headers)
6726
+ return body if body.nil? || body.empty?
6727
+ raw = headers.find {|k, _| k.to_s.downcase == 'content-encoding' }&.last
6728
+ enc = (raw.is_a?(Array) ? raw.join(',') : raw.to_s).strip.downcase # Rack 3 may hand the value as an array
6729
+ # The decoded bytes re-enter the UTF-8 text pipeline the same as an
6730
+ # un-encoded body (read_rack_body yields UTF-8), so re-tag them — Zlib
6731
+ # output is ASCII-8BIT, which would otherwise marshal to V8 as a byte array.
6732
+ decoded =
6733
+ case enc
6734
+ when 'gzip', 'x-gzip' then Zlib.gunzip(body.b)
6735
+ when 'deflate'
6736
+ begin
6737
+ Zlib::Inflate.inflate(body.b)
6738
+ rescue Zlib::Error
6739
+ Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(body.b) # raw (header-less) DEFLATE
6740
+ end
6741
+ else return body
6742
+ end
6743
+ decoded.force_encoding('UTF-8')
6744
+ rescue Zlib::Error
6745
+ body
6746
+ end
6747
+
4482
6748
  # Defer the navigation: doing it from inside the running V8 call
4483
6749
  # would dispose the Context mid-call. tick_real_time drains
4484
6750
  # after the call returns. Same pattern as `__csimPendingFormSubmit`.
@@ -4537,6 +6803,14 @@ module Capybara
4537
6803
  entry = @frame_stack.find {|e| e[:realm_id] == realm_id }
4538
6804
  if entry
4539
6805
  navigate_frame(url, entry: entry)
6806
+ elsif url.match?(%r{\Ahttps?://}i)
6807
+ # An absolute http(s) self-nav (`self.location = …` / link click) is fetched Ruby-side so
6808
+ # it carries correct navigation request headers (Referer under policy / Sec-Fetch);
6809
+ # non-http(s) and relative URLs stay on the JS src-reassignment path via
6810
+ # navigate_realm_self_get. `record: false` — a location/link frame nav isn't history-
6811
+ # recorded yet (that's a form-submission-only path), and must not push where a
6812
+ # location.replace should overwrite.
6813
+ navigate_realm_self_get(realm_id, url, record: false)
4540
6814
  else
4541
6815
  @runtime.call('__csimNavigateFrameByRealm', realm_id, url)
4542
6816
  end
@@ -4619,8 +6893,19 @@ module Capybara
4619
6893
  # JS-side, reusing the retained content so blob bytes survive a revoke.
4620
6894
  entry = @frame_stack.find {|e| e[:realm_id] == realm_id }
4621
6895
  url = entry && @runtime.frame_realm_alive?(realm_id) ? @runtime.realm_call(realm_id, '__csimLocationHref').to_s : ''
6896
+ cur = current_frame_history_entry(realm_id)
4622
6897
  if entry && !url.empty?
4623
6898
  navigate_frame(url, entry: entry)
6899
+ elsif cur && cur[:method] == 'POST'
6900
+ # Reloading a document reached by POST re-POSTS it (isReloadNavigation) with the recorded
6901
+ # body, rather than the JS reload path's GET refetch of the frame's src.
6902
+ navigate_realm_self_post(realm_id, cur[:url], cur[:body], cur[:content_type], is_reload: true)
6903
+ elsif cur && cur[:url].to_s.match?(%r{\Ahttps?://}i)
6904
+ # Reload the CURRENT history entry (isReloadNavigation), not the frame's `src`: after a
6905
+ # back/forward the src is stale, so `history.go(0)` / `location.reload()` must refetch the
6906
+ # entry's URL. (A blob:/data:/srcdoc frame keeps the JS reload path — it reuses retained
6907
+ # bytes a URL refetch can't reproduce.)
6908
+ reload_frame_to_entry(realm_id, cur, is_reload: true, is_history: false)
4624
6909
  else
4625
6910
  @runtime.call('__csimReloadFrameByRealm', realm_id)
4626
6911
  end
@@ -4687,8 +6972,9 @@ module Capybara
4687
6972
  return if h.nil?
4688
6973
  target = h[:idx] + delta
4689
6974
  return if target.negative? || target >= h[:entries].size
4690
- # Snapshot the entry we're leaving so a later forward traversal restores it.
4691
- h[:entries][h[:idx]] = frame_history_entry(realm_id) if h[:idx] >= 0
6975
+ # Snapshot the entry we're leaving so a later forward traversal restores it (keeping how it
6976
+ # was reached, so a POST entry re-POSTs when traversed back to).
6977
+ h[:entries][h[:idx]] = snapshot_outgoing_entry(realm_id, h[:entries][h[:idx]]) if h[:idx] >= 0
4692
6978
  reload_frame_to_entry(realm_id, h[:entries][target])
4693
6979
  h[:idx] = target # advance only after the rebuild succeeds
4694
6980
  end
@@ -4698,52 +6984,110 @@ module Capybara
4698
6984
  # into the frame form-submission paths; frame navigations driven by
4699
6985
  # `location.href` / link clicks aren't recorded yet (history.back there falls
4700
6986
  # through to the top document, as before).
4701
- def record_frame_nav(realm_id, new_url)
6987
+ # `post` (a {body, content_type}) records that this entry was reached by a POST submission,
6988
+ # so a later reload / history traversal re-POSTs it (with the body) rather than GET-ing the URL.
6989
+ def record_frame_nav(realm_id, new_url, post: nil)
4702
6990
  return if realm_id.nil? || realm_id.zero?
4703
6991
  parent = @runtime.frame_realm_parent(realm_id)
4704
6992
  handle = frame_container_handle(realm_id, parent)
4705
6993
  return if handle.zero?
4706
6994
  h = (@frame_histories ||= {})[[parent, handle]] ||= {entries: [], idx: -1}
4707
- outgoing = frame_history_entry(realm_id)
4708
6995
  if h[:idx] >= 0
4709
- h[:entries][h[:idx]] = outgoing
6996
+ h[:entries][h[:idx]] = snapshot_outgoing_entry(realm_id, h[:entries][h[:idx]])
4710
6997
  else
4711
- h[:entries] << outgoing
6998
+ h[:entries] << frame_history_entry(realm_id)
4712
6999
  h[:idx] = 0
4713
7000
  end
4714
7001
  h[:entries] = h[:entries][0..h[:idx]]
4715
- h[:entries] << {url: new_url.to_s, form_state: nil}
7002
+ entry = {url: new_url.to_s, form_state: nil}
7003
+ entry.merge!(method: 'POST', body: post[:body], content_type: post[:content_type]) if post
7004
+ h[:entries] << entry
4716
7005
  h[:idx] = h[:entries].size - 1
4717
7006
  end
7007
+ # The frame's CURRENT history entry (the loaded document), or nil — read by a reload to decide
7008
+ # whether to re-POST.
7009
+ def current_frame_history_entry(realm_id)
7010
+ parent = @runtime.frame_realm_parent(realm_id)
7011
+ handle = frame_container_handle(realm_id, parent)
7012
+ return nil if handle.zero?
7013
+ h = (@frame_histories || {})[[parent, handle]]
7014
+ h && h[:idx] >= 0 ? h[:entries][h[:idx]] : nil
7015
+ end
4718
7016
  # The history entry for the document currently loaded in `realm_id`: its URL
4719
7017
  # plus a snapshot of its form-control state.
4720
7018
  def frame_history_entry(realm_id)
4721
7019
  {url: frame_realm_url(realm_id), form_state: capture_frame_form_state(realm_id)}
4722
7020
  end
7021
+ # Refresh the outgoing entry's url + form-state snapshot while PRESERVING how it was reached
7022
+ # (a POST entry's method / body / content_type) — leaving a document doesn't change the request
7023
+ # that loaded it, so a later traversal back re-POSTs rather than GET-ing.
7024
+ def snapshot_outgoing_entry(realm_id, prev)
7025
+ snap = frame_history_entry(realm_id)
7026
+ prev ? prev.merge(snap) : snap
7027
+ end
4723
7028
  def frame_realm_url(realm_id)
4724
7029
  return nil unless @runtime.frame_realm_alive?(realm_id)
4725
7030
  @runtime.realm_call(realm_id, '__csimLocationHref').to_s
4726
7031
  rescue StandardError
4727
7032
  nil
4728
7033
  end
7034
+ # A frame document's referrer policy (its last valid `<meta name="referrer">`), read from
7035
+ # the live realm to compute a self-navigation's Referer under the initiating document's
7036
+ # policy. '' when the realm is gone / has no meta → the platform default applies.
7037
+ def frame_document_referrer_policy(realm_id)
7038
+ return '' unless @runtime.frame_realm_alive?(realm_id)
7039
+ @runtime.realm_call(realm_id, '__csimDocumentReferrerPolicy').to_s
7040
+ rescue StandardError
7041
+ ''
7042
+ end
4729
7043
  def capture_frame_form_state(realm_id)
4730
7044
  return nil unless @runtime.frame_realm_alive?(realm_id)
4731
7045
  @runtime.realm_call(realm_id, '__csimCaptureFormState')
4732
7046
  rescue StandardError
4733
7047
  nil
4734
7048
  end
4735
- # Re-fetch a history entry's URL and rebuild the frame realm from it, then
4736
- # restore the entry's captured form state (before the element load fires).
4737
- def reload_frame_to_entry(realm_id, entry)
7049
+ # Re-fetch a history entry's URL and rebuild the frame realm from it, then restore the entry's
7050
+ # captured form state (before the element load fires). Used for a history traversal
7051
+ # (isHistoryNavigation) AND for a reload of the current entry (isReloadNavigation — e.g.
7052
+ # `history.go(0)` / `location.reload()` after a back/forward, where the frame's `src` is stale
7053
+ # and only the current entry names the right URL).
7054
+ def reload_frame_to_entry(realm_id, entry, is_reload: false, is_history: true)
4738
7055
  url = entry[:url].to_s
4739
7056
  return if url.empty?
4740
- env = Rack::MockRequest.env_for(url, method: 'GET')
7057
+ # An entry reached by a POST submission re-POSTS (with the recorded body); a normal entry
7058
+ # re-GETs. The method drives both the SW fetch event and the network fallback.
7059
+ is_post = entry[:method] == 'POST'
7060
+ body = entry[:body].to_s
7061
+ post_args = is_post ? {method: 'POST', body_b64: Base64.strict_encode64(body), content_type: entry[:content_type]} : {}
7062
+ # A history TRAVERSAL restores the entry's persisted form state (bfcache); a RELOAD gives a
7063
+ # fresh document, so it must NOT restore the (possibly stale) snapshot the entry was left with.
7064
+ restore = is_reload ? nil : entry[:form_state]
7065
+ # A traversal / reload is a navigation: route it through the controlling SW's fetch event
7066
+ # first — the refetch happens here, Ruby-side, bypassing the __csimFrameWindow interception
7067
+ # the initial load uses. A respondWith serves the document; a network error fails the
7068
+ # navigation; nil falls through to the network below.
7069
+ if (sw = service_worker_navigation_fetch(url, is_reload: is_reload, is_history: is_history,
7070
+ referrer_source: frame_realm_url(realm_id), referrer_policy: frame_document_referrer_policy(realm_id),
7071
+ **post_args))
7072
+ return if sw['networkError']
7073
+
7074
+ # Raw decoded bytes (like read_rack_body's byte-tagged output); reload_frame_realm_by_id
7075
+ # does the single utf8_text re-tag, matching the network path below.
7076
+ reload_frame_realm_by_id(realm_id, url, Base64.decode64(sw['body_b64'].to_s),
7077
+ response_content_type(sw['headers'] || {}), restore_state: restore)
7078
+ return
7079
+ end
7080
+ env = Rack::MockRequest.env_for(url, method: is_post ? 'POST' : 'GET', input: is_post ? body : '')
7081
+ if is_post
7082
+ env['CONTENT_TYPE'] = entry[:content_type].to_s.empty? ? 'application/x-www-form-urlencoded' : entry[:content_type]
7083
+ env['CONTENT_LENGTH'] = body.bytesize.to_s
7084
+ end
4741
7085
  apply_default_request_env(env, referer: current_browsing_context_url)
4742
- status, headers, body = dispatch_rack_or_http(url, env, method: 'GET')
4743
- merge_set_cookie(headers)
7086
+ status, headers, resp_body = dispatch_rack_or_http(url, env, method: is_post ? 'POST' : 'GET', body: is_post ? body : nil)
7087
+ merge_set_cookie(headers, url)
4744
7088
  return if download_response?(headers)
4745
- html = read_rack_body(body)
4746
- reload_frame_realm_by_id(realm_id, url, html, response_content_type(headers), restore_state: entry[:form_state])
7089
+ html = read_rack_body(resp_body)
7090
+ reload_frame_realm_by_id(realm_id, url, html, response_content_type(headers), restore_state: restore)
4747
7091
  end
4748
7092
  # Serialize + route a form submitted inside frame realm `realm_id`. We
4749
7093
  # serialize in the INITIATING realm (so shadow-tree controls are excluded
@@ -4763,7 +7107,7 @@ module Capybara
4763
7107
  target = spec['target'].to_s
4764
7108
  action = spec['action'].to_s
4765
7109
  enctype = spec['enctype'].to_s.empty? ? 'application/x-www-form-urlencoded' : spec['enctype'].to_s.downcase
4766
- entries = entry_list.is_a?(Array) ? entry_list : entries_from_spec(spec)
7110
+ entries = entry_list.is_a?(Array) ? entry_list : (spec['entries'] || [])
4767
7111
  # GET → urlencoded query (enctype ignored); POST → enctype-encoded body.
4768
7112
  get_query, = encode_entry_list(entries, 'application/x-www-form-urlencoded')
4769
7113
  get_url = form_get_url(action, get_query)
@@ -4801,20 +7145,63 @@ module Capybara
4801
7145
  end
4802
7146
  # A self-targeted GET form submit in the initiating frame realm: navigate
4803
7147
  # that frame to the action URL (query already mutated in).
4804
- def navigate_realm_self_get(realm_id, get_url)
4805
- record_frame_nav(realm_id, get_url)
7148
+ # `record: false` for a `location.href=` / link-click self-nav — those frame navigations are
7149
+ # deliberately NOT recorded in frame history yet (see record_frame_nav; history.back there falls
7150
+ # through to the top document), and recording them here would push an entry where a
7151
+ # `location.replace` must overwrite. A form GET submission (the default) IS a history push.
7152
+ def navigate_realm_self_get(realm_id, get_url, depth: 0, is_reload: false, is_history: false, record: true, site_seed: nil, origin_null: false)
7153
+ raise 'too many redirects' if depth > 10
7154
+ record_frame_nav(realm_id, get_url) if record && depth.zero? && !is_reload && !is_history
4806
7155
  entry = @frame_stack.find {|e| e[:realm_id] == realm_id }
4807
- if entry
4808
- navigate_frame(resolve_against_current(get_url), entry: entry)
4809
- else
4810
- # A frame reached via contentWindow (not on the entered stack): its
4811
- # owning iframe lives in its PARENT realm's document (the main realm for
4812
- # a top-level frame, an intermediate realm for a nested one), so route
4813
- # the by-realm src-reassignment therenot unconditionally to main.
4814
- # (relative get_url resolves against the frame's base on rebuild).
4815
- parent = @runtime.frame_realm_parent(realm_id)
4816
- frame_realm_host_call(parent, '__csimNavigateFrameByRealm', realm_id, get_url)
7156
+ return navigate_frame(resolve_against_current(get_url), entry: entry) if entry
7157
+ # A frame reached via contentWindow (not on the entered stack). An ABSOLUTE http(s) target is
7158
+ # fetched Ruby-side (like navigate_realm_self_post) so the navigation carries correct request
7159
+ # headers a Referer under the initiating document's Referrer-Policy, no Origin (GET), the
7160
+ # Fetch-Metadata triple. A non-http(s) target (data:/blob:/javascript:/about:blank) or a
7161
+ # relative one stays on the JS src-reassignment path, which owns those schemes and resolves a
7162
+ # relative URL against the frame's base on rebuild routed through the frame's PARENT realm
7163
+ # (where the owning iframe lives), not unconditionally to main.
7164
+ parent = @runtime.frame_realm_parent(realm_id)
7165
+ unless get_url.is_a?(String) && get_url.match?(%r{\Ahttps?://}i)
7166
+ return frame_realm_host_call(parent, '__csimNavigateFrameByRealm', realm_id, get_url)
4817
7167
  end
7168
+ # The realm may have been disposed earlier in THIS drain batch — an ancestor frame that also
7169
+ # self-navigated discarded this descendant (dispose_frame_realm_tree). Bail before issuing a
7170
+ # network fetch whose response (reload_frame_realm_by_id) would find no container and be thrown
7171
+ # away — the fetch's cookie / server side effects would fire for a navigation that never commits.
7172
+ return unless @runtime.frame_realm_alive?(realm_id)
7173
+ invalidate_find_cache
7174
+ # A controlled navigation goes to the SW's fetch event first (mode 'navigate'); respondWith
7175
+ # serves the document, a network error fails it, nil falls through to the network GET below.
7176
+ if (sw = service_worker_navigation_fetch(get_url, method: 'GET', is_reload: is_reload, is_history: is_history,
7177
+ referrer_source: frame_realm_url(realm_id), referrer_policy: frame_document_referrer_policy(realm_id),
7178
+ site_seed: site_seed, origin_null: origin_null))
7179
+ return if sw['networkError']
7180
+
7181
+ return reload_frame_realm_by_id(realm_id, get_url, Base64.decode64(sw['body_b64'].to_s), response_content_type(sw['headers'] || {}))
7182
+ end
7183
+ initiator = frame_realm_url(realm_id)
7184
+ site = widen_sec_fetch_site(site_seed, sec_fetch_site(initiator, get_url))
7185
+ status, headers, resp_body = dispatch_navigation_request(
7186
+ get_url,
7187
+ method: 'GET',
7188
+ initiator: initiator,
7189
+ referrer_policy: frame_document_referrer_policy(realm_id),
7190
+ site: site,
7191
+ origin_null: origin_null
7192
+ )
7193
+ if (loc = redirect_location(status, headers))
7194
+ next_url = resolve_against(loc, get_url)
7195
+ resp_body.close if resp_body.respond_to?(:close)
7196
+ # Latch the redirect chain's Fetch-Metadata: Sec-Fetch-Site widens to include this hop, and
7197
+ # a form POST's Origin taints to 'null' per redirect_taints_origin? (moot for GET — no Origin).
7198
+ return navigate_realm_self_get(realm_id, next_url, depth: depth + 1, is_reload: is_reload, is_history: is_history, record: record,
7199
+ site_seed: site, origin_null: redirect_taints_origin?(origin_null, initiator, get_url, next_url))
7200
+ end
7201
+ if download_response?(headers)
7202
+ return save_downloaded_response(get_url, headers, resp_body)
7203
+ end
7204
+ reload_frame_realm_by_id(realm_id, get_url, read_rack_body(resp_body), response_content_type(headers))
4818
7205
  end
4819
7206
  # A self-targeted POST form submit in the initiating frame realm. POST the
4820
7207
  # entity body to the action URL, then rebuild that frame's realm from the
@@ -4822,25 +7209,55 @@ module Capybara
4822
7209
  # a frame reached via contentWindow has no stack entry, so rebuild it by
4823
7210
  # realm id (recovering its container element + parent realm) and fire the
4824
7211
  # iframe element's load event the GET/src path would.
4825
- def navigate_realm_self_post(realm_id, url, body, content_type, depth: 0)
7212
+ def navigate_realm_self_post(realm_id, url, body, content_type, depth: 0, is_reload: false, is_history: false, site_seed: nil, origin_null: false)
4826
7213
  raise 'too many redirects' if depth > 10
4827
- record_frame_nav(realm_id, url) if depth.zero?
7214
+ # A reload / history traversal RE-POSTS an existing entry — it doesn't push a new one; only
7215
+ # a fresh submission records history. The POST method/body is tagged onto the entry AFTER a
7216
+ # DIRECT (non-redirect) response (tag_frame_entry_post below): a POST that redirects (the
7217
+ # Post/Redirect/Get pattern) resolves to a GET document, so its entry must NOT re-POST on a
7218
+ # later reload / back — only a directly-served POST does.
7219
+ record_frame_nav(realm_id, url) if depth.zero? && !is_reload && !is_history
4828
7220
  entry = @frame_stack.find {|e| e[:realm_id] == realm_id }
4829
7221
  return navigate_frame_post(url, body, content_type, entry: entry) if entry
4830
7222
  invalidate_find_cache
4831
- env = Rack::MockRequest.env_for(url, method: 'POST', input: body)
4832
- env['CONTENT_TYPE'] = content_type.to_s.empty? ? 'application/x-www-form-urlencoded' : content_type
4833
- env['CONTENT_LENGTH'] = body.bytesize.to_s
4834
- apply_default_request_env(env, referer: current_browsing_context_url)
4835
- status, headers, resp_body = dispatch_rack_or_http(url, env, method: 'POST', body: body)
4836
- merge_set_cookie(headers)
7223
+ # A controlled POST navigation goes to the SW's fetch event first (mode 'navigate', method
7224
+ # POST — the SW reads the body via event.request.text()); respondWith serves the document,
7225
+ # a network error fails it, nil falls through to the network POST below.
7226
+ if (sw = service_worker_navigation_fetch(url, method: 'POST', body_b64: Base64.strict_encode64(body.to_s), content_type: content_type, is_reload: is_reload, is_history: is_history,
7227
+ referrer_source: frame_realm_url(realm_id), referrer_policy: frame_document_referrer_policy(realm_id),
7228
+ site_seed: site_seed, origin_null: origin_null))
7229
+ return if sw['networkError']
7230
+
7231
+ tag_frame_entry_post(realm_id, body, content_type) if depth.zero?
7232
+ reload_frame_realm_by_id(realm_id, url.to_s, Base64.decode64(sw['body_b64'].to_s), response_content_type(sw['headers'] || {}))
7233
+ return
7234
+ end
7235
+ # The initiator is the FRAME's own document (still alive — the rebuild is below), not the
7236
+ # top document `current_browsing_context_url` returns for a non-entered frame. A form POST
7237
+ # navigation carries that document's Origin + a Referer under its Referrer-Policy + the
7238
+ # Fetch-Metadata triple (Sec-Fetch-Dest 'iframe' for a subframe).
7239
+ initiator = frame_realm_url(realm_id)
7240
+ site = widen_sec_fetch_site(site_seed, sec_fetch_site(initiator, url.to_s))
7241
+ status, headers, resp_body = dispatch_navigation_request(
7242
+ url,
7243
+ method: 'POST',
7244
+ initiator: initiator,
7245
+ referrer_policy: frame_document_referrer_policy(realm_id),
7246
+ site: site,
7247
+ origin_null: origin_null,
7248
+ body: body,
7249
+ content_type: content_type
7250
+ )
7251
+ merge_set_cookie(headers, url)
4837
7252
  if (loc = redirect_location(status, headers))
4838
7253
  next_url = resolve_against_current(loc)
4839
7254
  resp_body.close if resp_body.respond_to?(:close)
4840
7255
  # 307/308 preserve method + body; 301/302/303 → GET the frame (routed
4841
- # through the realm that OWNS the iframe, as in navigate_realm_self_get).
7256
+ # through the realm that OWNS the iframe, as in navigate_realm_self_get). Latch the
7257
+ # redirect chain's Sec-Fetch-Site (widened) + Origin taint (redirect_taints_origin?).
4842
7258
  if [307, 308].include?(status)
4843
- return navigate_realm_self_post(realm_id, next_url, body, content_type, depth: depth + 1)
7259
+ return navigate_realm_self_post(realm_id, next_url, body, content_type, depth: depth + 1, is_reload: is_reload, is_history: is_history,
7260
+ site_seed: site, origin_null: redirect_taints_origin?(origin_null, initiator, url.to_s, next_url))
4844
7261
  end
4845
7262
  parent = @runtime.frame_realm_parent(realm_id)
4846
7263
  return frame_realm_host_call(parent, '__csimNavigateFrameByRealm', realm_id, next_url)
@@ -4849,8 +7266,16 @@ module Capybara
4849
7266
  save_downloaded_response(url, headers, resp_body)
4850
7267
  return
4851
7268
  end
7269
+ tag_frame_entry_post(realm_id, body, content_type) if depth.zero?
4852
7270
  reload_frame_realm_by_id(realm_id, url.to_s, read_rack_body(resp_body), response_content_type(headers))
4853
7271
  end
7272
+ # Tag the frame's current history entry as reached by a POST (with its body), so a later
7273
+ # reload / history traversal re-POSTS it. Applied only on a DIRECT response (not a redirect),
7274
+ # so a Post/Redirect/Get entry stays a GET.
7275
+ def tag_frame_entry_post(realm_id, body, content_type)
7276
+ cur = current_frame_history_entry(realm_id)
7277
+ cur.merge!(method: 'POST', body: body, content_type: content_type) if cur
7278
+ end
4854
7279
  # Rebuild a frame realm reached via contentWindow (no @frame_stack entry):
4855
7280
  # recover its container element handle + parent realm, swap in a fresh realm
4856
7281
  # built from `html`, re-point the iframe at it, and fire the element load.
@@ -4958,24 +7383,60 @@ module Capybara
4958
7383
  def history_length
4959
7384
  [@history.size, 1].max
4960
7385
  end
4961
- # `document.cookie` is TEXT; jar entries parsed out of Rack's Set-Cookie
4962
- # headers can carry the BINARY tag, which would make the joined string
4963
- # cross into JS as a Uint8Array (`document.cookie.match is not a
4964
- # function`). Cookies are ASCII per RFC 6265.
7386
+ # The host a cookie is scoped to for `url`. RFC 6265 cookies are keyed by host
7387
+ # (not scheme/port), so cross-host requests never see each other's cookies while
7388
+ # a same-origin flow behaves exactly like a single jar. nil when the URL carries
7389
+ # no host (about:blank / data: / a relative current_url before the first navigate).
7390
+ def cookie_host(url)
7391
+ h = safe_uri(url.to_s)&.host
7392
+ h && !h.empty? ? h.downcase : nil
7393
+ end
7394
+
7395
+ # The host cookies attach to for a request built into `env` — the target server
7396
+ # (SERVER_NAME / HTTP_HOST), NOT the current document, so a cross-origin fetch sends
7397
+ # the TARGET's cookies rather than leaking the document's (cors-cookies). Strips the
7398
+ # port while preserving an IPv6 bracket-literal (`[::1]`) so the key matches what
7399
+ # `cookie_host` derives from the URL via `URI#host`.
7400
+ def env_cookie_host(env)
7401
+ h = (env['HTTP_HOST'] || env['SERVER_NAME']).to_s
7402
+ h = h.start_with?('[') ? h[/\A\[[^\]]*\]/].to_s : h.split(':', 2).first
7403
+ h && !h.empty? ? h.downcase : nil
7404
+ end
7405
+
7406
+ # The `Cookie` request-header value for a request to `host`: that host's jar,
7407
+ # serialized `name=value; …`. (Domain-attribute subdomain sharing isn't modelled —
7408
+ # the app suites are single-host; cross-host ISOLATION is what matters here.)
7409
+ #
7410
+ # TEXT, not binary: jar entries parsed out of Rack's Set-Cookie headers can carry the
7411
+ # BINARY tag, which would make the joined string cross into JS as a Uint8Array
7412
+ # (`document.cookie.match is not a function`). Cookies are ASCII per RFC 6265.
7413
+ def cookie_header_for(host)
7414
+ jar = host && @cookies[host]
7415
+ return '' if jar.nil? || jar.empty?
7416
+ RuntimeShared.utf8_text(jar.map {|k, v| "#{k}=#{v}" }.join('; '))
7417
+ end
7418
+
7419
+ # `document.cookie` reads/writes the CURRENT document's host jar.
7420
+ def document_cookie_host
7421
+ cookie_host(current_browsing_context_url) || cookie_host(@default_host)
7422
+ end
7423
+
4965
7424
  def document_cookie
4966
- RuntimeShared.utf8_text(@cookies.map {|k, v| "#{k}=#{v}" }.join('; '))
7425
+ cookie_header_for(document_cookie_host)
4967
7426
  end
4968
7427
  def current_referer ; @current_referer.to_s ; end
4969
7428
  def write_document_cookie(s)
4970
7429
  return if s.nil? || s.empty?
7430
+ host = document_cookie_host or return
4971
7431
  name, rest = s.split('=', 2)
4972
7432
  return if name.nil? || name.empty?
4973
7433
  parts = (rest || '').split(';').map(&:strip)
4974
7434
  value = parts.shift.to_s
7435
+ jar = (@cookies[host] ||= {})
4975
7436
  if cookie_deletion?(parts)
4976
- @cookies.delete(name.strip)
7437
+ jar.delete(name.strip)
4977
7438
  else
4978
- @cookies[name.strip] = value
7439
+ jar[name.strip] = value
4979
7440
  end
4980
7441
  end
4981
7442
 
@@ -4990,9 +7451,25 @@ module Capybara
4990
7451
  def storage_get(kind, key)
4991
7452
  store(kind)[key.to_s]
4992
7453
  end
7454
+ # Per-area storage quota (Chrome / Firefox both cap a localStorage / sessionStorage area at
7455
+ # ~5 MiB per origin). Without it, the WPT quota tests — which `setItem` in a `while (true)` loop
7456
+ # until QuotaExceededError — write unbounded gigabytes and OOM the process.
7457
+ STORAGE_QUOTA_BYTES = 5 * 1024 * 1024
7458
+
7459
+ # Returns true when stored, false when the (key, value) would exceed the area's quota — the JS
7460
+ # shim turns a false into a QuotaExceededError and does NOT store (WHATWG "setItem" step). The
7461
+ # size is summed from the store each call (localStorage is shared across same-origin windows, so
7462
+ # a per-Browser running total would drift); replacing a key frees its old bytes first.
4993
7463
  def storage_set(kind, key, value)
4994
- store(kind)[key.to_s] = value.to_s
4995
- nil
7464
+ st = store(kind)
7465
+ key = key.to_s
7466
+ value = value.to_s
7467
+ used = st.sum {|k, v| k.bytesize + v.bytesize }
7468
+ used -= key.bytesize + st[key].bytesize if st.key?(key)
7469
+ return false if used + key.bytesize + value.bytesize > STORAGE_QUOTA_BYTES
7470
+
7471
+ st[key] = value
7472
+ true
4996
7473
  end
4997
7474
  def storage_remove(kind, key)
4998
7475
  store(kind).delete(key.to_s)
@@ -5011,6 +7488,62 @@ module Capybara
5011
7488
  private def store(kind)
5012
7489
  kind.to_s == 'session' ? @session_storage : @local_storage
5013
7490
  end
7491
+
7492
+ # Cache Storage backing — origin-partitioned dumb store; the JS side owns the spec
7493
+ # matching (cache-storage.js). `@cache_storage` is
7494
+ # origin => {seq:, names: {name => cache_id}, caches: {cache_id => {seq:, entries:}}}
7495
+ # The name→id indirection models the spec's "dooms, but does not delete immediately":
7496
+ # `caches.delete(name)` unmaps the name, but a Cache handle already bound to the id
7497
+ # keeps operating on its own storage (a fresh `open(name)` gets a new id / empty cache).
7498
+ # Each entry is `{id:, meta:, response:}` — `meta` the parsed request metadata the
7499
+ # matcher needs ({url, method, headers, vary}), `response` an opaque serialized-Response
7500
+ # JSON blob. Each host fn runs a single read-modify-write under the GVL, so concurrent
7501
+ # access from a service-worker thread stays atomic without a lock (localStorage
7502
+ # precedent). A doomed cache's storage lingers until `reset!` (per-test) frees it — a
7503
+ # bounded leak we accept rather than refcount handles across the JS boundary.
7504
+ def cache_storage_open(origin, name)
7505
+ store = (@cache_storage[origin.to_s] ||= {seq: 0, names: {}, caches: {}})
7506
+ id = (store[:names][name.to_s] ||= (store[:seq] += 1))
7507
+ store[:caches][id] ||= {seq: 0, entries: []}
7508
+ id
7509
+ end
7510
+ def cache_storage_has(origin, name)
7511
+ @cache_storage.dig(origin.to_s, :names)&.key?(name.to_s) || false
7512
+ end
7513
+ def cache_storage_delete(origin, name)
7514
+ names = @cache_storage.dig(origin.to_s, :names) or return false
7515
+ !names.delete(name.to_s).nil?
7516
+ end
7517
+ def cache_storage_keys(origin)
7518
+ (@cache_storage.dig(origin.to_s, :names) || {}).keys
7519
+ end
7520
+ def cache_entries(origin, cache_id)
7521
+ cache = cache_for(origin, cache_id) or return nil
7522
+ JSON.generate(cache[:entries].map {|e| {id: e[:id]}.merge(e[:meta]) })
7523
+ end
7524
+ def cache_entry_response(origin, cache_id, entry_id)
7525
+ cache = cache_for(origin, cache_id) or return nil
7526
+ entry = cache[:entries].find {|e| e[:id] == entry_id.to_i } or return nil
7527
+ entry[:response]
7528
+ end
7529
+ def cache_put(origin, cache_id, delete_ids_json, meta_json, response_json)
7530
+ cache = cache_for(origin, cache_id) or return nil
7531
+ ids = JSON.parse(delete_ids_json).map(&:to_i)
7532
+ cache[:entries].reject! {|e| ids.include?(e[:id]) } unless ids.empty?
7533
+ cache[:entries] << {id: (cache[:seq] += 1), meta: JSON.parse(meta_json), response: response_json.to_s}
7534
+ nil
7535
+ end
7536
+ def cache_delete_entries(origin, cache_id, ids_json)
7537
+ cache = cache_for(origin, cache_id) or return 0
7538
+ ids = JSON.parse(ids_json).map(&:to_i)
7539
+ before = cache[:entries].size
7540
+ cache[:entries].reject! {|e| ids.include?(e[:id]) }
7541
+ before - cache[:entries].size
7542
+ end
7543
+ # The cache hash ({seq:, entries:}) bound to a Cache handle's id, or nil if it's gone.
7544
+ private def cache_for(origin, cache_id)
7545
+ @cache_storage.dig(origin.to_s, :caches, cache_id.to_i)
7546
+ end
5014
7547
  # Push a one-shot handler onto the modal-dialog stack — the next
5015
7548
  # modal that fires consumes the topmost handler. Block exit pops
5016
7549
  # in case the dialog never fired.
@@ -5060,7 +7593,7 @@ module Capybara
5060
7593
  env = Rack::MockRequest.env_for(url, method: 'GET')
5061
7594
  apply_default_request_env(env, referer: current_browsing_context_url)
5062
7595
  status, headers, body = dispatch_rack_or_http(url, env, method: 'GET')
5063
- merge_set_cookie(headers)
7596
+ merge_set_cookie(headers, url)
5064
7597
  if (loc = redirect_location(status, headers))
5065
7598
  next_url = carry_fragment(url, resolve_against_current(loc))
5066
7599
  body.close if body.respond_to?(:close)
@@ -5081,7 +7614,7 @@ module Capybara
5081
7614
  env['CONTENT_LENGTH'] = body.bytesize.to_s
5082
7615
  apply_default_request_env(env, referer: current_browsing_context_url)
5083
7616
  status, headers, resp_body = dispatch_rack_or_http(url, env, method: 'POST', body: body)
5084
- merge_set_cookie(headers)
7617
+ merge_set_cookie(headers, url)
5085
7618
  if (loc = redirect_location(status, headers))
5086
7619
  next_url = resolve_against_current(loc)
5087
7620
  resp_body.close if resp_body.respond_to?(:close)
@@ -5198,7 +7731,7 @@ module Capybara
5198
7731
  env = Rack::MockRequest.env_for(url, method: 'GET')
5199
7732
  apply_default_request_env(env, referer: referer)
5200
7733
  status, headers, body = dispatch_rack_or_http(url, env, method: 'GET')
5201
- merge_set_cookie(headers)
7734
+ merge_set_cookie(headers, url)
5202
7735
  if (loc = redirect_location(status, headers))
5203
7736
  next_url = resolve_against_current(loc)
5204
7737
  # Per RFC 7231: if the original request URL had a fragment
@@ -5426,8 +7959,147 @@ module Capybara
5426
7959
  # server can negotiate — HTML-only routes still pick html,
5427
7960
  # both-available pick the first registered.
5428
7961
  env['HTTP_ACCEPT'] ||= DEFAULT_HTTP_ACCEPT
5429
- env['HTTP_REFERER'] = referer unless referer.nil? || referer.empty?
5430
- env['HTTP_COOKIE'] = document_cookie unless @cookies.empty?
7962
+ env['HTTP_REFERER'] = referer unless referer.nil? || referer.empty?
7963
+ # Attach the TARGET host's cookies (not the document's) SERVER_NAME is the
7964
+ # request's host — so a cross-origin request carries the right jar or none.
7965
+ ck = cookie_header_for(env_cookie_host(env))
7966
+ if ck.empty?
7967
+ env.delete('HTTP_COOKIE')
7968
+ else
7969
+ env['HTTP_COOKIE'] = ck
7970
+ end
7971
+ end
7972
+
7973
+ # Referer / Origin / Fetch-Metadata for a NAVIGATION request (a document load — form
7974
+ # submit, location set, link activation). Unlike a fetch/XHR (which carries a per-request
7975
+ # policy through rack_fetch), a navigation's request headers derive from the INITIATING
7976
+ # document: its Referrer-Policy (compute_referrer), its origin (an `Origin` header — sent
7977
+ # only on an unsafe method, i.e. a form POST, never a GET/HEAD navigation), and the
7978
+ # Fetch-Metadata triple. `dest` is 'document' for a top-level nav, 'iframe' for a subframe.
7979
+ # `site_override` / `origin_null` carry redirect-chain latched state (navigate_realm_self_*
7980
+ # thread it through each hop): Sec-Fetch-Site is the WIDEST initiator↔url relationship over the
7981
+ # whole chain (a same-site redirect keeps 'same-site' even when the final hop is same-origin),
7982
+ # and a form POST's Origin becomes the opaque 'null' once any hop has crossed origin.
7983
+ def navigation_request_headers(env, method:, initiator_url:, target:, dest:, referrer_policy: nil, user_activated: false, site_override: nil, origin_null: false)
7984
+ apply_default_request_env(env, referer: compute_referrer(referrer_policy, initiator_url, target))
7985
+ # Origin is appended to every non-GET/HEAD request (a form POST carries it; a GET navigation
7986
+ # does not). From a KNOWN initiating document it's that document's origin serialization, or
7987
+ # the literal 'null' when that origin is opaque (an about:blank / data: frame) or has been
7988
+ # tainted by a cross-origin redirect — matching the fetch path's `effective_origin`
7989
+ # convention. Omitted only when the initiator is unknown (a disposed realm → nil url).
7990
+ if initiator_url && !initiator_url.to_s.empty? && !%w[GET HEAD].include?(method.to_s.upcase)
7991
+ env['HTTP_ORIGIN'] = (!origin_null && url_origin(initiator_url)) || 'null'
7992
+ end
7993
+ env['HTTP_SEC_FETCH_SITE'] = site_override || sec_fetch_site(initiator_url, target)
7994
+ env['HTTP_SEC_FETCH_MODE'] = 'navigate'
7995
+ env['HTTP_SEC_FETCH_DEST'] = dest.to_s
7996
+ env['HTTP_SEC_FETCH_USER'] = '?1' if user_activated
7997
+ end
7998
+
7999
+ # Build + dispatch a navigation request (a frame/document load) — the shared core of the GET
8000
+ # self-nav, the POST self-nav, and the navigation-preload request, so all three send the SAME
8001
+ # Referer / Origin / Fetch-Metadata (dest 'iframe') and can't silently diverge. `site` is the
8002
+ # caller's already-widened Sec-Fetch-Site (threaded across the redirect chain it owns);
8003
+ # `extra_headers` carries any request-specific CGI header (the preload marker). Returns
8004
+ # [status, headers, resp_body] — the caller handles redirects / reload / the preload wire.
8005
+ def dispatch_navigation_request(url, method:, initiator:, referrer_policy:, site:, origin_null:, body: nil, content_type: nil, extra_headers: nil)
8006
+ env = Rack::MockRequest.env_for(url, method: method, input: body || '')
8007
+ if content_type
8008
+ env['CONTENT_TYPE'] = content_type.to_s.empty? ? 'application/x-www-form-urlencoded' : content_type
8009
+ env['CONTENT_LENGTH'] = body.to_s.bytesize.to_s
8010
+ end
8011
+ navigation_request_headers(
8012
+ env,
8013
+ method: method,
8014
+ initiator_url: initiator,
8015
+ target: url.to_s,
8016
+ dest: 'iframe',
8017
+ referrer_policy: referrer_policy,
8018
+ site_override: site,
8019
+ origin_null: origin_null
8020
+ )
8021
+ extra_headers&.each {|k, v| env[k] = v }
8022
+ status, headers, resp_body = dispatch_rack_or_http(url, env, method: method, body: body)
8023
+ merge_set_cookie(headers, url)
8024
+ [status, headers, resp_body]
8025
+ end
8026
+
8027
+ # The Navigation Preload request: a parallel GET the browser issues for a navigation whose
8028
+ # controlling SW has preload enabled, exposed to the SW as `event.preloadResponse`. It IS a
8029
+ # navigation request (dest 'iframe', mode 'navigate', the frame's Referer / Sec-Fetch-Site — so
8030
+ # the server sees exactly what a no-SW navigation would) plus the `Service-Worker-Navigation-
8031
+ # Preload` header carrying the registration's header value. The SW intercepts the FINAL URL (a
8032
+ # pre-SW network redirect already happened + widened `site_seed`), so this is a single hop — no
8033
+ # redirect loop. Returns the response wire hash for the fetch-event JSON, or nil on a hard error.
8034
+ def navigation_preload_response(url, referrer_source, referrer_policy, site_seed, origin_null, header_value)
8035
+ site = widen_sec_fetch_site(site_seed, sec_fetch_site(referrer_source, url))
8036
+ status, headers, resp_body = dispatch_navigation_request(
8037
+ url,
8038
+ method: 'GET',
8039
+ initiator: referrer_source,
8040
+ referrer_policy: referrer_policy,
8041
+ site: site,
8042
+ origin_null: origin_null,
8043
+ extra_headers: {'HTTP_SERVICE_WORKER_NAVIGATION_PRELOAD' => header_value.to_s}
8044
+ )
8045
+ {
8046
+ 'status' => status,
8047
+ 'statusText' => RuntimeShared.utf8_text(Rack::Utils::HTTP_STATUS_CODES[status.to_i] || ''),
8048
+ 'headers' => headers.to_h,
8049
+ # The UA transparently decodes a Content-Encoding'd body (gzip/deflate) before the SW's
8050
+ # `event.preloadResponse.text()` sees it — the header stays, the bytes are inflated (as the
8051
+ # regular fetch path does).
8052
+ 'body_b64' => Base64.strict_encode64(decode_content_encoding(read_rack_body(resp_body), headers))
8053
+ }
8054
+ rescue StandardError
8055
+ nil
8056
+ end
8057
+
8058
+ # Fetch-Metadata `Sec-Fetch-Site`: the relationship of a request's INITIATOR to its target.
8059
+ # no initiator (a direct address-bar load) → 'none'; same (scheme,host,port) → 'same-origin';
8060
+ # same registrable site (eTLD+1) → 'same-site'; else → 'cross-site'.
8061
+ def sec_fetch_site(initiator_url, target_url)
8062
+ io = url_origin(initiator_url)
8063
+ return 'none' if io.nil?
8064
+ return 'same-origin' if io == url_origin(target_url)
8065
+ is = registrable_site(initiator_url)
8066
+ ts = registrable_site(target_url)
8067
+ is && ts && is == ts ? 'same-site' : 'cross-site'
8068
+ end
8069
+
8070
+ SEC_FETCH_SITE_RANK = {'same-origin' => 0, 'same-site' => 1, 'cross-site' => 2, 'none' => 2}.freeze
8071
+ private_constant :SEC_FETCH_SITE_RANK
8072
+ # The wider (more distant) of two Sec-Fetch-Site values — used to latch the value across a
8073
+ # redirect chain (same-origin < same-site < cross-site). `nil` seed → the other value.
8074
+ def widen_sec_fetch_site(a, b)
8075
+ return b if a.nil?
8076
+ SEC_FETCH_SITE_RANK[b].to_i > SEC_FETCH_SITE_RANK[a].to_i ? b : a
8077
+ end
8078
+
8079
+ # Fetch "HTTP-redirect fetch": a request's Origin opaques to 'null' once it follows a
8080
+ # cross-origin redirect WHILE ALREADY off the initiator's origin — i.e. the current URL is
8081
+ # cross-origin to BOTH the redirect target and the initiator. The FIRST cross-origin hop (still
8082
+ # on the initiator's origin) keeps the real Origin; a later hop that redirects same-origin-to-
8083
+ # current keeps it too. Monotonic: once opaque, an opaque origin is same-origin with nothing, so
8084
+ # it stays null.
8085
+ def redirect_taints_origin?(already_null, initiator_url, current_url, location_url)
8086
+ already_null ||
8087
+ (url_origin(current_url) != url_origin(location_url) && url_origin(initiator_url) != url_origin(current_url))
8088
+ end
8089
+
8090
+ # scheme + registrable domain (approx eTLD+1) of a URL — the "site" a Sec-Fetch-Site /
8091
+ # same-site comparison uses. Last-two-labels approximation (no Public Suffix List — correct
8092
+ # for the single-label TLDs our hosts use); an IP literal / ≤2-label host is its own site.
8093
+ # A `blob:` URL derives from its inner origin. nil for a hostless / non-http(s) URL.
8094
+ def registrable_site(url)
8095
+ u = URI.parse(url.to_s.sub(/\Ablob:/, ''))
8096
+ host = u.host.to_s
8097
+ return nil if host.empty? || !u.scheme&.match?(/\Ahttps?\z/i)
8098
+ labels = host.split('.')
8099
+ regd = host.start_with?('[') || host.match?(/\A\d+(\.\d+){3}\z/) || labels.length <= 2 ? host : labels.last(2).join('.')
8100
+ "#{u.scheme}://#{regd}"
8101
+ rescue URI::Error
8102
+ nil
5431
8103
  end
5432
8104
 
5433
8105
  # Cross-host hop (e.g. Discourse's `discourse_connect` flow
@@ -5512,9 +8184,22 @@ module Capybara
5512
8184
  nil
5513
8185
  end
5514
8186
 
5515
- def merge_set_cookie(headers)
8187
+ # Store a response's Set-Cookie headers under the RESPONDING host's jar (`url` is
8188
+ # the hop that produced `headers`). A cross-origin hop therefore writes its own
8189
+ # host's jar, never the document's (cors-cookies isolation).
8190
+ # True if the response carries a `WWW-Authenticate: Basic …` challenge — the only scheme the
8191
+ # transparent-auth retry in rack_fetch answers.
8192
+ def www_authenticate_basic?(headers)
8193
+ return false unless headers.is_a?(Hash)
8194
+ headers.each {|k, v| return true if k.to_s.casecmp?('www-authenticate') && v.to_s.strip.downcase.start_with?('basic') }
8195
+ false
8196
+ end
8197
+
8198
+ def merge_set_cookie(headers, url)
5516
8199
  sc = headers['set-cookie'] || headers['Set-Cookie']
5517
8200
  return if sc.nil? || sc.empty?
8201
+ host = cookie_host(url) || document_cookie_host or return
8202
+ jar = (@cookies[host] ||= {})
5518
8203
  # Rack 2 returns multiple Set-Cookie headers as a single
5519
8204
  # newline-separated string; Rack 3 returns an Array. Treat both
5520
8205
  # uniformly — splitting first means the second cookie in a
@@ -5527,9 +8212,9 @@ module Capybara
5527
8212
  name, value = pair.split('=', 2)
5528
8213
  next if name.nil? || name.empty?
5529
8214
  if cookie_deletion?(parts)
5530
- @cookies.delete(name.strip)
8215
+ jar.delete(name.strip)
5531
8216
  else
5532
- @cookies[name.strip] = value.to_s.strip
8217
+ jar[name.strip] = value.to_s.strip
5533
8218
  end
5534
8219
  }
5535
8220
  end
@@ -5555,18 +8240,40 @@ module Capybara
5555
8240
  end
5556
8241
 
5557
8242
  # Header names/values are TEXT (RFC 9110: field values are ASCII); Rack
5558
- # hands them over BINARY-tagged (see `RuntimeShared.utf8_text`).
8243
+ # hands them over BINARY-tagged (see `RuntimeShared.utf8_text`). Per-value HTTP
8244
+ # -whitespace normalization happens upstream, BEFORE duplicate values are
8245
+ # combined (WptRunner.combine_headers) — not here, where a combined value like
8246
+ # `", "` (two empty fields) would wrongly lose its trailing space. An
8247
+ # Array-valued header (a Rack app emitting a repeated field) is combined with
8248
+ # `, ` — the WHATWG "combine" separator getAllResponseHeaders exposes, matching
8249
+ # both real browsers and the harness's combine_headers.
5559
8250
  def stringify(headers)
5560
8251
  out = {}
5561
8252
  headers.each do |k, v|
5562
- out[k.to_s] = RuntimeShared.utf8_text(v.is_a?(Array) ? v.join(',') : v.to_s)
8253
+ out[k.to_s] = RuntimeShared.utf8_text(v.is_a?(Array) ? v.join(', ') : v.to_s)
5563
8254
  end
5564
8255
  out
5565
8256
  end
5566
8257
 
8258
+ # The Fetch "redirect status" set — ONLY these are followed. 300 (multiple
8259
+ # choice), 304 (not modified), 305/306 (deprecated) are NOT redirects: the 3xx
8260
+ # response is returned to the caller as-is (xhr send-redirect basics).
8261
+ REDIRECT_STATUSES = [301, 302, 303, 307, 308].freeze
8262
+ # Statuses whose response has no body (Fetch "null body status") — the body is dropped
8263
+ # and response.body is null (response-null-body). (101 is unreachable here.)
8264
+ NULL_BODY_STATUSES = [204, 205, 304].freeze
8265
+ # Request-body headers removed when a redirect nulls the body (method → GET).
8266
+ REDIRECT_DROPPED_HEADERS = %w[content-encoding content-language content-location content-type content-length].freeze
5567
8267
  def redirect_location(status, headers)
5568
- return nil unless (300..399).include?(status.to_i)
5569
- headers['location'] || headers['Location']
8268
+ return nil unless REDIRECT_STATUSES.include?(status.to_i)
8269
+ loc = headers['location'] || headers['Location']
8270
+ loc = loc.first if loc.is_a?(Array) # Rack 3 permits array-valued header fields
8271
+ # A blank (or absent) Location has no FOLLOWABLE target: an empty value parses back
8272
+ # to the current URL, so following it would just self-redirect. Return nil so a
8273
+ # caller renders the 3xx as-is rather than looping — the several navigation handlers
8274
+ # rely on this. (The fetch redirect loop recognizes a present-but-empty Location
8275
+ # separately and turns it into a network error per Fetch — see rack_fetch.)
8276
+ loc unless loc.to_s.empty?
5570
8277
  end
5571
8278
 
5572
8279
  def resolve_against_current(url, use_base: false)
@@ -5596,6 +8303,260 @@ module Capybara
5596
8303
  dom_call('__csimBaseHref').to_s
5597
8304
  end
5598
8305
 
8306
+ # Fetch "CORS-safelisted method" / "…request-header" / "…Content-Type". A request
8307
+ # is "simple" (no preflight) iff its method is safelisted AND every author header is
8308
+ # safelisted (Content-Type only for a urlencoded / multipart / text/plain value).
8309
+ CORS_SAFELISTED_METHODS = %w[GET HEAD POST].freeze
8310
+ CORS_SAFELISTED_HEADERS = %w[accept accept-language content-language content-type].freeze
8311
+ # RFC 7230 `token` (tchar+) — a valid HTTP method / field-name. Used to reject a
8312
+ # preflight whose Access-Control-Allow-Methods / -Headers carries a malformed value.
8313
+ HTTP_TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/.freeze
8314
+ CORS_SAFELISTED_CTYPES = %w[application/x-www-form-urlencoded multipart/form-data text/plain].freeze
8315
+ # Fetch "CORS-unsafe request-header byte" — a byte in a header value that forces a
8316
+ # safelisted-name header out of the safelisted set (so a preflight becomes necessary):
8317
+ # a control byte other than HT (0x09), DEL, or one of the delimiters "(),:<>?@[\]{}.
8318
+ CORS_UNSAFE_VALUE_BYTE = /[\x00-\x08\x0a-\x1f\x7f"():<>?@\[\\\]{}]/n.freeze
8319
+ # accept-language / content-language values are further restricted: only digits,
8320
+ # ASCII letters, space, and `*,-.;=` keep them safelisted.
8321
+ CORS_LANGUAGE_VALUE = /\A[0-9A-Za-z *,\-.;=]*\z/n.freeze
8322
+
8323
+ # Fetch "CORS-safelisted request-header": a (name, value) whose value keeps the
8324
+ # request "simple" (no preflight). All four names cap the value at 128 bytes; each
8325
+ # then constrains which bytes the value may contain (a `"` in Accept, a control byte
8326
+ # in Content-Language, an over-long text/plain Content-Type all force a preflight —
8327
+ # cors-preflight-not-cors-safelisted).
8328
+ def cors_safelisted_request_header?(name, value)
8329
+ v = value.to_s.b
8330
+ return false if v.bytesize > 128
8331
+ case name
8332
+ when 'accept'
8333
+ !v.match?(CORS_UNSAFE_VALUE_BYTE)
8334
+ when 'accept-language', 'content-language'
8335
+ v.match?(CORS_LANGUAGE_VALUE)
8336
+ when 'content-type'
8337
+ return false if v.match?(CORS_UNSAFE_VALUE_BYTE)
8338
+ essence = v.split(';', 2).first.to_s.strip.downcase
8339
+ CORS_SAFELISTED_CTYPES.include?(essence)
8340
+ else
8341
+ false
8342
+ end
8343
+ end
8344
+
8345
+ # The sorted, lowercased author header names that are NOT CORS-safelisted. A
8346
+ # safelisted NAME still counts as unsafe when its VALUE fails the safelisting (an
8347
+ # unsafe byte / over-128-byte length / non-safelisted Content-Type essence). These
8348
+ # are echoed in Access-Control-Request-Headers for the preflight and must be covered
8349
+ # by Access-Control-Allow-Headers.
8350
+ def cors_unsafe_headers(headers)
8351
+ (headers || {}).filter_map {|k, v|
8352
+ name = k.to_s.downcase
8353
+ next if name.start_with?('x-csim') || name == 'content-length'
8354
+ if CORS_SAFELISTED_HEADERS.include?(name)
8355
+ cors_safelisted_request_header?(name, v) ? nil : name
8356
+ else
8357
+ name
8358
+ end
8359
+ }.uniq.sort
8360
+ end
8361
+
8362
+ def cors_unsafe_request?(method, headers)
8363
+ !CORS_SAFELISTED_METHODS.include?(method.to_s.upcase) || !cors_unsafe_headers(headers).empty?
8364
+ end
8365
+
8366
+ # Fetch "CORS check" on a cross-origin response: it must allow the request's
8367
+ # (effective) origin via Access-Control-Allow-Origin. A NON-credentialed request
8368
+ # accepts `*` or the exact origin; a CREDENTIALED one (withCredentials) forbids
8369
+ # `*` — the ACAO must be the exact origin AND Access-Control-Allow-Credentials
8370
+ # must be `true` (access-control-and-redirects-async-same-origin credentials cases).
8371
+ def cors_response_ok?(resp_headers, origin, credentialed)
8372
+ acao = cors_header(resp_headers, 'access-control-allow-origin')
8373
+ return false if acao.nil?
8374
+ if credentialed
8375
+ return false unless acao == origin
8376
+ cors_header(resp_headers, 'access-control-allow-credentials').to_s.downcase == 'true'
8377
+ else
8378
+ acao == '*' || acao == origin
8379
+ end
8380
+ end
8381
+
8382
+ # Whether a URL carries userinfo (`user[:password]@`). A CORS request to such a
8383
+ # URL is a network error (access-control-and-redirects "user info" subtest).
8384
+ def url_has_userinfo?(url)
8385
+ u = URI.parse(url.to_s)
8386
+ !u.userinfo.to_s.empty?
8387
+ rescue URI::InvalidURIError
8388
+ false
8389
+ end
8390
+
8391
+ # An author-set conditional header means the CALLER is doing its own revalidation,
8392
+ # so the UA cache must step aside: the request reaches the origin and the server's
8393
+ # own 304/200 decision is returned (send-conditional), not a cached hit.
8394
+ CONDITIONAL_REQUEST_HEADERS = %w[if-none-match if-modified-since if-match if-unmodified-since if-range].freeze
8395
+ def request_has_conditional_headers?(headers)
8396
+ headers.is_a?(Hash) && headers.any? {|k, _| CONDITIONAL_REQUEST_HEADERS.include?(k.to_s.downcase) }
8397
+ end
8398
+
8399
+ # Run the CORS preflight unless a cached result already covers this request (Fetch
8400
+ # "CORS-preflight cache"): a prior preflight to the same (origin, url) within its
8401
+ # Access-Control-Max-Age that allows this method + headers lets the actual request
8402
+ # skip the OPTIONS (access-control-basic-allow-preflight-cache). Returns false (=
8403
+ # network error) only when a fresh preflight is needed AND fails.
8404
+ def cors_preflight_ok?(target, method, headers, req_origin, credentialed, referer)
8405
+ return true if cors_preflight_cached?(target, req_origin, method, headers, credentialed)
8406
+ result = cors_run_preflight(target, method, headers, req_origin, credentialed, referer)
8407
+ return false unless result
8408
+ # Cache the grant for Max-Age seconds so a covered follow-up skips the preflight.
8409
+ # The key is (origin, url, credentialed): a credentialed grant (ACAO echoing the
8410
+ # origin, no `*` matching) can't cover an uncredentialed follow-up or vice versa,
8411
+ # so the two are cached apart. Expiry uses the REAL monotonic clock (not the
8412
+ # virtual one), so a test that virtual-sleeps past Max-Age to force a re-preflight
8413
+ # isn't caught yet.
8414
+ @cors_preflight_cache[[req_origin, target, credentialed]] = result.merge(stored_at: Process.clock_gettime(Process::CLOCK_MONOTONIC)) if result[:max_age].positive?
8415
+ true
8416
+ end
8417
+
8418
+ # Whether a cached preflight grant covers this request (not expired + method/headers
8419
+ # allowed). A method/header the cache doesn't cover — or an expired entry — forces a
8420
+ # fresh preflight (cache-invalidation-by-method / -header / -timeout).
8421
+ def cors_preflight_cached?(target, req_origin, method, headers, credentialed)
8422
+ entry = @cors_preflight_cache[[req_origin, target, credentialed]]
8423
+ return false unless entry
8424
+ return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) - entry[:stored_at] >= entry[:max_age]
8425
+ cors_grant_allows?(entry[:methods], entry[:headers], method, cors_unsafe_headers(headers), credentialed)
8426
+ end
8427
+
8428
+ # `Authorization` is Fetch's sole "CORS non-wildcard request-header name": a preflight
8429
+ # `Access-Control-Allow-Headers: *` never covers it — it must be listed by name — even
8430
+ # for an uncredentialed request (cors-preflight "authorization not covered by wildcard").
8431
+ CORS_NON_WILDCARD_REQUEST_HEADERS = %w[authorization].freeze
8432
+
8433
+ # Does a preflight grant (its Access-Control-Allow-Methods / -Headers) cover this
8434
+ # request: the method is allowed / `*` / CORS-safelisted, and every unsafe header is
8435
+ # allowed / `*`. Shared by the fresh-preflight accept check and the cache-hit check.
8436
+ # For a CREDENTIALED request the wildcard loses its meaning — Fetch's "CORS-preflight
8437
+ # fetch" matches `*` against no method/header when credentials mode is include, so a
8438
+ # non-listed method or unsafe header is rejected (cors-preflight-star credentialed).
8439
+ def cors_grant_allows?(allow_methods, allow_headers, method, unsafe_headers, credentialed = false)
8440
+ # The method match is byte-CASE-SENSITIVE (Fetch normalizes the request method but
8441
+ # compares it verbatim against Access-Control-Allow-Methods): `delete` in the grant
8442
+ # does not cover a `DELETE` request. Safelisted GET/HEAD/POST pass regardless
8443
+ # (they're always normalized to upper-case) (cors-preflight-star method-case).
8444
+ m = method.to_s
8445
+ method_ok = allow_methods.include?(m) || CORS_SAFELISTED_METHODS.include?(m) || (!credentialed && allow_methods.include?('*'))
8446
+ return false unless method_ok
8447
+ wildcard_headers = !credentialed && allow_headers.include?('*')
8448
+ unsafe_headers.all? {|h|
8449
+ allow_headers.include?(h) || (wildcard_headers && !CORS_NON_WILDCARD_REQUEST_HEADERS.include?(h))
8450
+ }
8451
+ end
8452
+
8453
+ # Fetch "CORS-preflight fetch": send an OPTIONS with Access-Control-Request-Method
8454
+ # / -Headers + Origin; on success (ok-status, ACAO match, and the grant covers the
8455
+ # method + unsafe headers) return the grant {methods, headers, max_age} for the
8456
+ # cache, else nil. A credentialed preflight additionally requires the response to
8457
+ # allow credentials (ACAC:true) and forbids `*` in the origin/method/header grants.
8458
+ def cors_run_preflight(target, method, headers, req_origin, credentialed, referer)
8459
+ unsafe = cors_unsafe_headers(headers)
8460
+ env = Rack::MockRequest.env_for(target, method: 'OPTIONS')
8461
+ env['REQUEST_METHOD'] = 'OPTIONS'
8462
+ # The preflight's Referer is the request's referrer under its referrer policy —
8463
+ # the SAME value the actual request sends (computed by the caller), not the raw
8464
+ # document URL (cors-preflight-referrer).
8465
+ apply_default_request_env(env, referer: referer, force: false)
8466
+ # A CORS-preflight is a fetch, so it carries fetch's default `Accept: */*` (NOT
8467
+ # the navigation Accept apply_default_request_env sets) — some handlers reject a
8468
+ # preflight whose Accept isn't */* (preflight.py).
8469
+ env['HTTP_ACCEPT'] = '*/*'
8470
+ # A CORS-preflight is always uncredentialed — it carries no cookies, even when the
8471
+ # actual request that follows is credentialed.
8472
+ env.delete('HTTP_COOKIE')
8473
+ env['HTTP_ORIGIN'] = req_origin
8474
+ # Access-Control-Request-Method carries the request's (already-normalized) method
8475
+ # VERBATIM — `patch` stays `patch`, matching the byte-case-sensitive grant check.
8476
+ env['HTTP_ACCESS_CONTROL_REQUEST_METHOD'] = method.to_s
8477
+ env['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'] = unsafe.join(',') unless unsafe.empty?
8478
+ status, ph, pbody = dispatch_rack_or_http(target, env, method: 'OPTIONS', body: nil)
8479
+ pbody.close if pbody.respond_to?(:close)
8480
+ # A slow preflight counts toward the client's timeout too (timeout-multiple-fetches).
8481
+ @fetch_server_delay_ms += server_delay_ms_of(ph) if @fetch_server_delay_ms
8482
+ return nil unless (200..299).include?(status.to_i)
8483
+ acao = cors_header(ph, 'access-control-allow-origin')
8484
+ # A credentialed preflight can't be allowed by the wildcard origin and must carry
8485
+ # Access-Control-Allow-Credentials: true (cors-preflight-star credentialed).
8486
+ return nil unless credentialed ? acao == req_origin : (acao == '*' || acao == req_origin)
8487
+ return nil if credentialed && cors_header(ph, 'access-control-allow-credentials') != 'true'
8488
+ allow_methods = cors_list(cors_header(ph, 'access-control-allow-methods'))
8489
+ allow_headers = cors_list(cors_header(ph, 'access-control-allow-headers')).map(&:downcase)
8490
+ # Fetch "extract header list values" fails when a grant contains a malformed token
8491
+ # (`Access-Control-Allow-Methods: Bad value` — a space isn't a tchar), and a failed
8492
+ # extraction is a network error (cors-preflight-response-validation). Methods and
8493
+ # header names are both HTTP tokens; `*` is a valid tchar so the wildcard passes.
8494
+ return nil unless (allow_methods + allow_headers).all? {|t| t.match?(HTTP_TOKEN) }
8495
+ return nil unless cors_grant_allows?(allow_methods, allow_headers, method, unsafe, credentialed)
8496
+ {methods: allow_methods, headers: allow_headers, max_age: cors_header(ph, 'access-control-max-age').to_i}
8497
+ end
8498
+
8499
+ # Fetch "CORS-safelisted response-header name" — always exposed to script for a
8500
+ # cross-origin response, without being listed in Access-Control-Expose-Headers.
8501
+ CORS_SAFELISTED_RESPONSE_HEADERS = %w[
8502
+ cache-control content-language content-length content-type expires last-modified pragma
8503
+ ].freeze
8504
+
8505
+ # The response headers a cross-origin "cors" response exposes to getResponseHeader /
8506
+ # getAllResponseHeaders: the CORS-safelisted set plus any named in Access-Control
8507
+ # -Expose-Headers. `*` exposes every header, but ONLY for a non-credentialed response;
8508
+ # with credentials the wildcard loses its meaning and matches a header literally named
8509
+ # `*` (cors-expose-star "only matches literally").
8510
+ # The virtual server delay a response carries (X-Csim-Server-Delay-Ms, ms), 0 if none.
8511
+ def server_delay_ms_of(headers)
8512
+ return 0 unless headers.is_a?(Hash)
8513
+ pair = headers.find {|k, _| k.to_s.casecmp?('x-csim-server-delay-ms') }
8514
+ pair ? pair.last.to_i : 0
8515
+ end
8516
+
8517
+ def cors_exposed_headers(headers, credentialed = false)
8518
+ # set-cookie / set-cookie2 are forbidden response-header names — NEVER exposed to
8519
+ # script, even when explicitly named in Access-Control-Expose-Headers or covered by
8520
+ # `*` (cors-filtering "header is forbidden"). x-csim-status-text is our internal
8521
+ # reason-phrase sentinel (response_hash lifts it into statusText, which IS exposed
8522
+ # cross-origin, then strips it from the script-visible map), so it must survive.
8523
+ forbidden = %w[set-cookie set-cookie2]
8524
+ expose = cors_list(cors_header(headers, 'access-control-expose-headers')).map(&:downcase)
8525
+ if !credentialed && expose.include?('*')
8526
+ return headers.reject {|k, _| forbidden.include?(k.to_s.downcase) }
8527
+ end
8528
+ allowed = CORS_SAFELISTED_RESPONSE_HEADERS + expose + ['x-csim-status-text']
8529
+ headers.select {|k, _| allowed.include?(k.to_s.downcase) && !forbidden.include?(k.to_s.downcase) }
8530
+ end
8531
+
8532
+ # Case-insensitive response-header lookup + comma-list split for the CORS checks.
8533
+ def cors_header(headers, name)
8534
+ pair = headers.find {|k, _| k.to_s.downcase == name }
8535
+ pair&.last.to_s
8536
+ end
8537
+
8538
+ def cors_list(value)
8539
+ value.to_s.split(',').map(&:strip).reject(&:empty?)
8540
+ end
8541
+
8542
+ # The origin of a URL — `scheme://host[:port]` with the default port (80/443)
8543
+ # elided — for the CORS same/cross-origin comparison. nil for a non-http(s) or
8544
+ # unparseable URL (about:blank / data: / a relative current_url) so CORS never
8545
+ # treats those as a comparable origin.
8546
+ def url_origin(url)
8547
+ u = URI.parse(url.to_s)
8548
+ return nil unless u.scheme && u.host && u.scheme.match?(/\Ahttps?\z/i)
8549
+ # An origin is (scheme, host, port) compared case-insensitively on scheme+host —
8550
+ # so canonicalize both to lowercase, else http://Example.com vs http://example.com
8551
+ # would mis-classify a same-origin request as cross-origin.
8552
+ scheme = u.scheme.downcase
8553
+ default = scheme == 'https' ? 443 : 80
8554
+ port = u.port && u.port != default ? ":#{u.port}" : ''
8555
+ "#{scheme}://#{u.host.downcase}#{port}"
8556
+ rescue URI::InvalidURIError
8557
+ nil
8558
+ end
8559
+
5599
8560
  def carry_fragment(from_url, to_url)
5600
8561
  from = URI.parse(from_url.to_s)
5601
8562
  to = URI.parse(to_url.to_s)