capybara-simulated 0.7.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.
@@ -196,7 +196,7 @@ module Capybara
196
196
  Rack::Mime.mime_type(File.extname(path.to_s), '')
197
197
  end
198
198
 
199
- 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)
200
200
  @app = app
201
201
  @driver = driver
202
202
  @all_hosts_local_override = all_hosts_local
@@ -239,7 +239,16 @@ module Capybara
239
239
  # see the same auth state and storage as the primary. Tests
240
240
  # without a Driver (gem-internal callers) get fresh jars.
241
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 || {}
242
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 || {}
243
252
  @session_storage = {}
244
253
  @sticky_headers = {}
245
254
  @timers_active = false
@@ -279,6 +288,9 @@ module Capybara
279
288
  @current_realm_id = nil
280
289
  @frame_stack = []
281
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
282
294
  @polling_grace = nil
283
295
  @last_polled_gen = nil
284
296
  @idle_settle_polls = 0
@@ -332,6 +344,13 @@ module Capybara
332
344
  @websocket_sockets = {} # id → csim's socket end (main thread owns this hash)
333
345
  @websocket_app_sockets = {} # id → the app's hijack end (closed on teardown)
334
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
335
354
  # All frame writes (the reader thread's pong replies + the main thread's
336
355
  # send/close) go through one socket; serialise them so two threads can't
337
356
  # interleave bytes into a corrupt frame.
@@ -355,10 +374,73 @@ module Capybara
355
374
  @worker_seq = 0
356
375
  @workers = {}
357
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
358
381
  # Outstanding posts-to-worker; `polling?` stays true while > 0
359
382
  # so long-running compute (e.g. mozjpeg over an 8900×8900 frame)
360
383
  # isn't starved by the settle_gen idle gate.
361
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 = {}
362
444
  # Workers whose initial script hasn't finished running yet. A worker that
363
445
  # posts immediately on spawn (no main->worker message first) would leave
364
446
  # `@worker_in_flight` at 0, so `worker_pending?` would be false in the gap
@@ -366,6 +448,16 @@ module Capybara
366
448
  # stop waiting before the message lands. Count spawned-but-not-initialised
367
449
  # workers so the async drain holds until the initial script has run.
368
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
369
461
  @worker_init_lock = Mutex.new
370
462
  # Cross-isolate `blob:` store. Worker isolates can't see the
371
463
  # main scope's `__csimBlobs` Map, so we mirror bytes here and
@@ -382,6 +474,10 @@ module Capybara
382
474
  @transfer_buffer_lock = Mutex.new
383
475
  @transfer_buffers = {}
384
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 = {}
385
481
  # Zero-copy postMessage transfer tokens (rusty_racer >= 0.1.6
386
482
  # `RustyRacer.transferOut`): a buffer in a `postMessage` transfer list
387
483
  # crosses isolates by token (no byte copy), its source detached. A token
@@ -399,13 +495,52 @@ module Capybara
399
495
  # Cross-window BroadcastChannel messages from OTHER windows, delivered to
400
496
  # this window's matching channels on settle. [{name, data}] (same thread).
401
497
  @broadcast_inbox = []
402
- 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
403
515
 
404
516
  # Worker thread polling and termination intervals — split so a
405
517
  # tuning change to one doesn't accidentally rebind the other.
406
- WORKER_POLL_INTERVAL = 0.05
407
- WORKER_TERMINATE_GRACE = 0.05
408
- 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
409
544
 
410
545
  # `js_engine` picks the JS runtime: `:v8` (rusty_racer, fastest
411
546
  # per-spec) or `:quickjs` (quickjs.rb, smaller per-VM footprint —
@@ -611,7 +746,7 @@ module Capybara
611
746
  # browsing context to route into. Distinguish that (unsupported
612
747
  # engine) from a frame that simply failed to build (below), so the
613
748
  # error doesn't misattribute a load failure to the engine.
614
- unless @runtime.respond_to?(:realm_call)
749
+ unless @runtime.supports_frames?
615
750
  raise Capybara::Simulated::FrameNotSupported,
616
751
  'within_frame needs a per-frame browsing context, which only the ' \
617
752
  'V8 (rusty_racer) engine provides; QuickJS keeps a same-realm fallback.'
@@ -740,6 +875,9 @@ module Capybara
740
875
 
741
876
  def find_with_timer_fallback(kind, arg, ctx)
742
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
743
881
  result = cached_find(kind, arg, ctx) { yield }
744
882
  # An empty result is the wait-for-it case: Capybara is retrying for
745
883
  # an element that hasn't appeared yet. Re-tick so the next poll
@@ -773,8 +911,20 @@ module Capybara
773
911
  # this above one Ruby boundary so a single visit+find pair
774
912
  # doesn't accidentally tick.
775
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.
776
925
  def timer_wait_elapsed?
777
- @timers_active &&
926
+ @pre_tick_armed &&
927
+ @timers_active &&
778
928
  (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_tick_ts) >= FIND_PRE_TICK_MIN_S
779
929
  end
780
930
 
@@ -831,6 +981,30 @@ module Capybara
831
981
  tag(handle) == 'input' && attr(handle, 'type').to_s.downcase == 'file'
832
982
  end
833
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
834
1008
 
835
1009
  # Capybara::Driver::Node surface — Node calls `check_stale`
836
1010
  # before each read, and that advances the virtual clock.
@@ -1021,7 +1195,8 @@ module Capybara
1021
1195
  env = Rack::MockRequest.env_for(url, method: 'GET')
1022
1196
  env['HTTP_USER_AGENT'] = @default_user_agent || USER_AGENT
1023
1197
  env['REMOTE_ADDR'] = self.class.remote_addr_for(env['HTTP_HOST'] || env['SERVER_NAME'])
1024
- 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?
1025
1200
  env['HTTP_REFERER'] = @current_url unless @current_url.nil? || @current_url.empty?
1026
1201
  status, headers, body = @app.call(env)
1027
1202
  return unless status.to_i == 200
@@ -1206,19 +1381,30 @@ module Capybara
1206
1381
  end
1207
1382
 
1208
1383
  # Element-to-element drag. Capybara's `Element#drag_to(target,
1209
- # delay:)` lands here. Fires the HTML5 drag event sequence on
1210
- # the source / target pair (mousedown dragstart → dragenter →
1211
- # dragover drop dragend) with a shared DataTransfer. Discourse
1212
- # sidebar reorder + Avo Sortable-shaped widgets read the
1213
- # `event.offsetY` to decide "above vs below"; without a layout
1214
- # engine we report 0, which routes drops above the target.
1215
- 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)
1216
1393
  mark_action_baseline
1217
1394
  tick_real_time
1218
1395
  invalidate_find_cache
1219
1396
  ensure_alive_after_tick(source_handle)
1220
1397
  ensure_alive_after_tick(target_handle)
1221
- 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')
1222
1408
  drain_after_user_action
1223
1409
  end
1224
1410
  def drop_items(arg)
@@ -1261,7 +1447,8 @@ module Capybara
1261
1447
  alt: 'altKey',
1262
1448
  option: 'altKey',
1263
1449
  meta: 'metaKey',
1264
- command: 'metaKey'
1450
+ command: 'metaKey',
1451
+ cmd: 'metaKey'
1265
1452
  }.freeze
1266
1453
  MODIFIER_KEY_NAMES = MODIFIER_KEYS.keys.to_set.freeze
1267
1454
  def modifier_flags(keys)
@@ -1271,19 +1458,26 @@ module Capybara
1271
1458
  }
1272
1459
  end
1273
1460
 
1274
- # Resolve click offset against the element's CSS-declared box.
1275
- # `opts[:offset] == :center` means "x/y is relative to the
1276
- # element's centre" (Capybara's w3c_click_offset semantics);
1277
- # otherwise the offset is relative to the top-left. We don't run
1278
- # a real layout engine `__csimElementRect` reads
1279
- # top / left / width / height from the cascade so tests that
1280
- # 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.
1281
1475
  def click_event_init(handle, keys, opts)
1282
1476
  out = modifier_flags(keys)
1283
1477
  has_xy = opts[:x] || opts[:y]
1284
1478
  center = opts[:offset] == :center || !has_xy
1285
1479
  if has_xy || center
1286
- rect = dom_call('__csimElementRect', handle)
1480
+ rect = dom_call('__csimRect', handle)
1287
1481
  base_x = rect['x'].to_f + (center ? rect['width'].to_f / 2.0 : 0.0)
1288
1482
  base_y = rect['y'].to_f + (center ? rect['height'].to_f / 2.0 : 0.0)
1289
1483
  out['clientX'] = base_x + opts[:x].to_f
@@ -1472,6 +1666,7 @@ module Capybara
1472
1666
  def settle
1473
1667
  start_gen = @runtime.settle_gen
1474
1668
  prev_gen = start_gen
1669
+ worker_wait_deadline = nil
1475
1670
  SETTLE_MAX_ITER.times do
1476
1671
  deliver_event_source_events
1477
1672
  deliver_worker_messages
@@ -1494,6 +1689,28 @@ module Capybara
1494
1689
  deliver_window_messages
1495
1690
  deliver_websocket_events
1496
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
1497
1714
  # No progress this iter (no DOM/URL change observed) — the
1498
1715
  # remaining timers are queued for the future; bail and let
1499
1716
  # Capybara's wall-clock-driven poll loop drive the next tick
@@ -2204,6 +2421,17 @@ module Capybara
2204
2421
  # caller's concern — read it separately with `peek_script` (clock-free).
2205
2422
  def run_event_loop_frame(frame_ms)
2206
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? }
2207
2435
  loop do
2208
2436
  r = @runtime.run_loop_step(0) # run only what's due NOW + microtasks + render; no clock advance
2209
2437
  progressed = step_and_drain_progressed(r)
@@ -2211,6 +2439,43 @@ module Capybara
2211
2439
  break unless progressed
2212
2440
  break if turns >= EVENT_LOOP_QUIESCENCE_CAP
2213
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
+
2214
2479
  # Phase 2 — advance one real frame so the next batch of timers becomes due.
2215
2480
  # Its work counts toward `progressed` too: a timer that first comes due in
2216
2481
  # this advance (e.g. a `setTimeout(…, 8)` firing mid-frame) and the nav hop
@@ -2219,7 +2484,9 @@ module Capybara
2219
2484
  probe = dom_call('__csimEventLoopProbe')
2220
2485
  {
2221
2486
  'raf' => !!probe['raf'],
2222
- '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?,
2223
2490
  # ms until the nearest scheduled timer (-1 = none). Lets a caller keep
2224
2491
  # advancing while a near-future `setTimeout` is parked (a `step_timeout`-
2225
2492
  # style wait) instead of declaring the page idle — see `__csimEventLoopProbe`.
@@ -2230,6 +2497,96 @@ module Capybara
2230
2497
  }
2231
2498
  end
2232
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
+
2233
2590
  # Drain the Ruby-side async / navigation / form-submit / download chains a
2234
2591
  # `run_loop_step` (passed as `r`) may have queued, and report whether this
2235
2592
  # step+drain made observable progress. Shared by both phases of
@@ -2371,6 +2728,9 @@ module Capybara
2371
2728
  @last_polled_gen = nil
2372
2729
  @idle_settle_polls = 0
2373
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
2374
2734
  @context_gen += 1
2375
2735
  end
2376
2736
 
@@ -2389,7 +2749,7 @@ module Capybara
2389
2749
  method = spec['method'].to_s.upcase
2390
2750
  method = 'GET' if method.empty?
2391
2751
  enctype = spec['enctype'].to_s.empty? ? 'application/x-www-form-urlencoded' : spec['enctype'].to_s.downcase
2392
- entries = entry_list.is_a?(Array) ? entry_list : entries_from_spec(spec)
2752
+ entries = entry_list.is_a?(Array) ? entry_list : (spec['entries'] || [])
2393
2753
  action_url = action.empty? ? (current_browsing_context_url || @default_host) : resolve_against_current(action)
2394
2754
  # A form submitted inside a frame whose target is that frame (self, or a
2395
2755
  # `_parent` of a ≥2-deep frame) navigates the FRAME, not the top page.
@@ -2517,35 +2877,6 @@ module Capybara
2517
2877
  picks && picks[entry['index'].to_i]
2518
2878
  end
2519
2879
 
2520
- # Build the entry list from the form's own controls, for triggers that didn't
2521
- # construct one in JS (the Enter implicit-submit path). Mirrors the JS FormData
2522
- # construction: non-file fields in tree order, then each file input's selection
2523
- # (one empty entry when nothing is picked). A selected File reports its
2524
- # host-backed source (`handle`/`index`); the older payload shape with no per-File
2525
- # refs falls back to the input's own handle slot.
2526
- def entries_from_spec(spec)
2527
- entries = (spec['fields'] || []).map {|pair| {'name' => pair[0].to_s, 'value' => pair[1].to_s} }
2528
- (spec['fileInputs'] || []).each do |fi|
2529
- name = fi['name'].to_s
2530
- refs = fi['files']
2531
- if refs.is_a?(Array) && !refs.empty?
2532
- refs.each {|ref|
2533
- entries << {'name' => name, 'file' => true, 'filename' => ref['name'].to_s, 'handle' => ref['handle'], 'index' => ref['index']}
2534
- }
2535
- else
2536
- picks = (@file_picks && @file_picks[fi['handle'].to_i]) || []
2537
- if picks.empty?
2538
- entries << {'name' => name, 'file' => true, 'filename' => '', 'handle' => nil, 'index' => nil}
2539
- else
2540
- picks.each_index {|i|
2541
- entries << {'name' => name, 'file' => true, 'filename' => File.basename(picks[i]), 'handle' => fi['handle'], 'index' => i}
2542
- }
2543
- end
2544
- end
2545
- end
2546
- entries
2547
- end
2548
-
2549
2880
  def append_multipart_part(body, boundary, name, content, filename: nil, content_type: nil)
2550
2881
  body << "--#{boundary}\r\n"
2551
2882
  disposition = %[form-data; name="#{name}"]
@@ -2569,7 +2900,7 @@ module Capybara
2569
2900
  env['CONTENT_LENGTH'] = body.bytesize.to_s
2570
2901
  apply_default_request_env(env, referer: referer)
2571
2902
  status, headers, resp_body = dispatch_rack_or_http(url, env, method: 'POST', body: body)
2572
- merge_set_cookie(headers)
2903
+ merge_set_cookie(headers, url)
2573
2904
  if (loc = redirect_location(status, headers))
2574
2905
  next_url = resolve_against_current(loc)
2575
2906
  resp_body.close if resp_body.respond_to?(:close)
@@ -2598,7 +2929,9 @@ module Capybara
2598
2929
 
2599
2930
  def reset!
2600
2931
  @cookies.clear
2932
+ @auth_cache.clear
2601
2933
  @local_storage.clear
2934
+ @cache_storage.clear
2602
2935
  @session_storage.clear
2603
2936
  @sticky_headers.clear
2604
2937
  # The driver-side resize buffer has to clear too — without
@@ -2650,6 +2983,11 @@ module Capybara
2650
2983
  reset_websockets
2651
2984
  @window_inbox.clear
2652
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
2653
2991
  # Free any zero-copy transfer backing stores that went unimported
2654
2992
  # (worker killed before draining its inbox, etc.) before the rebuild.
2655
2993
  drop_pending_transfers
@@ -2683,6 +3021,8 @@ module Capybara
2683
3021
  reset_websockets
2684
3022
  @window_inbox.clear
2685
3023
  @broadcast_inbox.clear
3024
+ @bc_registry.clear
3025
+ @bc_queue.clear
2686
3026
  # Dispose the JS runtime/isolate itself — for an auxiliary window this
2687
3027
  # Browser is the isolate's last owner, but V8Runtime registers every
2688
3028
  # isolate in a process-wide `@@live` set (for at_exit cleanup), which
@@ -2734,6 +3074,27 @@ module Capybara
2734
3074
  @@asset_src_lock = Mutex.new
2735
3075
  ASSET_SRC_MAX = 4096
2736
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
+
2737
3098
  # Body of an external durably-cacheable asset (classic script or stylesheet),
2738
3099
  # served from the cross-visit cache when still fresh, else fetched (which
2739
3100
  # read-throughs the per-visit asset cache) and cached iff durably cacheable.
@@ -2908,12 +3269,10 @@ module Capybara
2908
3269
  'Cache-Control: no-store',
2909
3270
  'Connection: keep-alive'
2910
3271
  ]
2911
- # Forward the host-cookie jar so the streaming server can
2912
- # authenticate the user the same way the browser would. The
2913
- # jar is a flat name=value map (no per-host scoping); reuse
2914
- # the canonical `document_cookie` serialiser the Rack path
2915
- # uses, so we don't drift if its format changes.
2916
- 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))
2917
3276
  lines << "Cookie: #{cookies}" unless cookies.empty?
2918
3277
  socket.write(lines.join("\r\n") << "\r\n\r\n")
2919
3278
  socket.flush
@@ -3011,6 +3370,10 @@ module Capybara
3011
3370
  env['HTTP_CONNECTION'] = 'Upgrade'
3012
3371
  env['HTTP_SEC_WEBSOCKET_KEY'] = key
3013
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
3014
3377
  list = Array(protocols).map(&:to_s).reject(&:empty?)
3015
3378
  env['HTTP_SEC_WEBSOCKET_PROTOCOL'] = list.join(', ') unless list.empty?
3016
3379
  env['rack.hijack?'] = true
@@ -3030,7 +3393,7 @@ module Capybara
3030
3393
  queue = @websocket_queue
3031
3394
  @websocket_threads[id] = Thread.new do
3032
3395
  Thread.current.report_on_exception = false
3033
- run_websocket_reader(id, csim_io, accept, queue)
3396
+ run_websocket_reader(id, csim_io, accept, queue, target)
3034
3397
  end
3035
3398
  id
3036
3399
  rescue StandardError => e
@@ -3059,24 +3422,58 @@ module Capybara
3059
3422
  nil
3060
3423
  end
3061
3424
 
3062
- def ws_close(id, code = 1000, reason = '')
3425
+ def ws_close(id, code = nil, reason = '')
3063
3426
  sock = @websocket_sockets[id.to_i] or return
3064
3427
  # Send the close frame and let the close HANDSHAKE complete: the server
3065
3428
  # replies with its own close frame, which the reader thread surfaces as
3066
3429
  # the `__close` event (carrying the agreed code) before tearing the
3067
3430
  # socket down in its `ensure`. Force teardown is `reset_websockets`'s job.
3068
- 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
3069
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
3070
3439
  nil
3071
3440
  end
3072
3441
 
3073
- 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?)
3074
3455
 
3075
3456
  def deliver_websocket_events
3076
- return 0 if @websocket_threads.empty? && @websocket_queue.empty?
3077
- 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))
3078
3463
  return 0 if events.empty?
3079
- @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?
3080
3477
  events.size
3081
3478
  end
3082
3479
 
@@ -3091,23 +3488,36 @@ module Capybara
3091
3488
  @websocket_sockets.clear
3092
3489
  @websocket_app_sockets.clear
3093
3490
  @websocket_queue.clear
3491
+ @websocket_queue_head = nil
3492
+ @ws_close_pending = 0
3493
+ @ws_close_wait_deadline = nil
3094
3494
  end
3095
3495
 
3096
3496
  # Background-thread frame reader: verify the 101 handshake, then loop
3097
3497
  # decoding server→client frames into queue events until close / EOF.
3098
- private def run_websocket_reader(id, sock, expected_accept, queue)
3099
- 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)
3100
3500
  unless ok
3101
3501
  queue << {id: id, type: '__error', message: 'websocket handshake failed'}
3102
3502
  return
3103
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?
3104
3507
  # Carry the negotiated subprotocol — Action Cable's client closes the
3105
3508
  # connection in its `onopen` unless `webSocket.protocol` is one it knows
3106
3509
  # (`actioncable-v1-json`).
3107
3510
  queue << {id: id, type: '__open', protocol: protocol}
3108
3511
  loop do
3109
3512
  frame = ws_read_message(sock, queue, id)
3110
- 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
3111
3521
  opcode, payload = frame
3112
3522
  if opcode == :close
3113
3523
  code = payload.bytesize >= 2 ? payload[0, 2].unpack1('n') : 1005
@@ -3139,9 +3549,10 @@ module Capybara
3139
3549
  # is set (Action Cable's client requires `actioncable-v1-json`).
3140
3550
  private def ws_read_handshake(sock, expected_accept)
3141
3551
  status = sock.gets
3142
- 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
3143
3553
  accept_ok = false
3144
3554
  protocol = nil
3555
+ cookies = []
3145
3556
  while (line = sock.gets)
3146
3557
  line = line.chomp
3147
3558
  break if line.empty?
@@ -3154,13 +3565,18 @@ module Capybara
3154
3565
  # JS Uint8Array — the protocol must reach JS as a real string so
3155
3566
  # `webSocket.protocol` compares equal to `actioncable-v1-json`.
3156
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?
3157
3572
  end
3158
- [accept_ok, protocol]
3573
+ [accept_ok, protocol, cookies]
3159
3574
  end
3160
3575
 
3161
3576
  # Read one complete message (reassembling continuation frames), handling
3162
3577
  # interleaved control frames inline. Returns `[opcode, payload]` (opcode
3163
- # 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.
3164
3580
  private def ws_read_message(sock, queue, id)
3165
3581
  data = +''.b
3166
3582
  msg_opcode = nil
@@ -3193,7 +3609,8 @@ module Capybara
3193
3609
  when 0x9 then ws_write_frame(sock, 0xA, payload); next # ping → pong
3194
3610
  when 0xA then next # pong → ignore
3195
3611
  when 0x0 then data << payload # continuation
3196
- 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
3197
3614
  end
3198
3615
  return [msg_opcode || opcode, data] if fin
3199
3616
  end
@@ -3425,7 +3842,7 @@ module Capybara
3425
3842
  # worker's `__csim_workerPostMessage` host fn closes over its
3426
3843
  # handle and routes outgoing messages onto a shared outbox the
3427
3844
  # main settle drains.
3428
- def worker_spawn(url, shared: false, service: false)
3845
+ def worker_spawn(url, shared: false, service: false, creator_key: nil)
3429
3846
  handle = (@worker_seq += 1)
3430
3847
  target = resolve_against_current(url.to_s)
3431
3848
  # A worker script from a blob: URL in a DIFFERENT storage partition than this
@@ -3452,7 +3869,7 @@ module Capybara
3452
3869
  @worker_init_lock.synchronize { @worker_initializing += 1 }
3453
3870
  thread = Thread.new do
3454
3871
  Thread.current.report_on_exception = false
3455
- 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)
3456
3873
  end
3457
3874
  @workers[handle] = {thread: thread, inbox: inbox}
3458
3875
  handle
@@ -3474,6 +3891,353 @@ module Capybara
3474
3891
  w[:inbox] << data.to_s
3475
3892
  end
3476
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
+
3477
4241
  def worker_terminate(handle)
3478
4242
  w = @workers.delete(handle.to_i)
3479
4243
  return unless w
@@ -3488,26 +4252,159 @@ module Capybara
3488
4252
  w[:thread].kill
3489
4253
  w[:thread].join(WORKER_TERMINATE_GRACE)
3490
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)
3491
4288
  # A blocked worker that never returned messages leaves
3492
4289
  # `@worker_in_flight` permanently > 0; reset when no workers
3493
4290
  # remain so `polling?` can short-circuit again.
3494
- @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?
3495
4292
  # The worker is gone — revoke the blob URLs it created.
3496
4293
  revoke_worker_blobs(handle.to_i)
3497
4294
  end
3498
4295
 
3499
4296
  def deliver_worker_messages
3500
- 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?
3501
4300
  events = drain_queue(@worker_outbox)
4301
+ events.unshift(head) if head
3502
4302
  return 0 if events.empty?
3503
- # `__error` postbacks don't correspond to a prior post, so
3504
- # bottom out at zero.
3505
- @worker_in_flight = [0, @worker_in_flight - events.size].max
3506
- @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?
3507
4392
  events.size
3508
4393
  end
3509
4394
 
3510
- 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
3511
4408
 
3512
4409
  # ── Cross-window messaging (window.open / opener / postMessage) ──
3513
4410
  # Each window is a separate Browser/VM/isolate, so a reference to another
@@ -3618,14 +4515,32 @@ module Capybara
3618
4515
 
3619
4516
  # Covers both cross-window postMessage AND BroadcastChannel — the two
3620
4517
  # cross-window event channels share these drain/pending hooks.
3621
- 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?
3622
4519
 
3623
4520
  # A BroadcastChannel message queued for delivery to this Browser's channels.
3624
4521
  # `source_realm_id` is the posting realm's context id within THIS isolate (0 =
3625
4522
  # main), or nil when the post came from ANOTHER isolate (the Driver's cross-
3626
4523
  # window fanout) — a nil source matches no local realm, so it reaches every one.
3627
- def enqueue_broadcast(name, data, source_realm_id = nil)
3628
- @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}
3629
4544
  end
3630
4545
 
3631
4546
  # Fire queued cross-window messages (postMessage + BroadcastChannel).
@@ -3655,6 +4570,26 @@ module Capybara
3655
4570
  end
3656
4571
  n += events.size
3657
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?
3658
4593
  n
3659
4594
  end
3660
4595
 
@@ -3664,13 +4599,107 @@ module Capybara
3664
4599
  # fanout goes through the Driver; the same-ISOLATE fanout (main ↔ sibling realms,
3665
4600
  # sibling ↔ sibling) is queued here and delivered per-realm by
3666
4601
  # `deliver_window_messages`, which skips the posting realm.
3667
- def broadcast_to_windows(name, data, source_realm_id = 0)
3668
- @driver.broadcast_channel(self, name.to_s, data) if @driver.respond_to?(:broadcast_channel)
3669
- # Only queue for same-isolate delivery when sibling realms exist — a single-
3670
- # realm page already delivered to itself in-VM, so this stays zero-overhead.
3671
- if @runtime.respond_to?(:frame_realm_ids) && @runtime.frame_realm_ids.any?
3672
- 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
3673
4701
  end
4702
+ n
3674
4703
  end
3675
4704
 
3676
4705
  # ── Image decode (libvips) ─────────────────────────────────────
@@ -3687,29 +4716,210 @@ module Capybara
3687
4716
  # 8900×8900 frames Discourse uploads exercise. Optional
3688
4717
  # `max_w`/`max_h` lets the caller pre-shrink for cheap OCR-style
3689
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.
3690
4813
  def decode_image(b64_bytes, max_w = nil, max_h = nil)
3691
- host_image_op('decode_image') {
3692
- require 'vips' unless defined?(Vips)
3693
- bytes = Base64.decode64(b64_bytes.to_s)
3694
- # `access: :sequential` keeps libvips from applying the
3695
- # source's ICC profile mid-stream (changes RGBA values by ±2
3696
- # vs raw decode). `colourspace('srgb')` is the same ICC
3697
- # transform Chrome's createImageBitmap runs, but rounding
3698
- # differs by a few ulp; only convert when libvips reports
3699
- # a non-sRGB interpretation, otherwise trust the bytes.
3700
- img = Vips::Image.new_from_buffer(bytes, '', access: :sequential)
3701
- img = img.colourspace('srgb') unless img.interpretation == :srgb || img.interpretation == :rgb
3702
- img = img.bandjoin(255) if img.bands < 4
3703
- if max_w && max_h && max_w.to_i > 0 && max_h.to_i > 0 &&
3704
- (img.width > max_w.to_i || img.height > max_h.to_i)
3705
- shrink_x = img.width.to_f / max_w.to_i
3706
- shrink_y = img.height.to_f / max_h.to_i
3707
- shrink = [shrink_x, shrink_y].max
3708
- 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
3709
4866
  end
3710
- raw = img.write_to_memory
3711
- {'width' => img.width, 'height' => img.height, 'refId' => transfer_buffer_stash(raw)}
3712
- }
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
3713
4923
  end
3714
4924
 
3715
4925
  private def host_image_op(name)
@@ -3719,6 +4929,346 @@ module Capybara
3719
4929
  nil
3720
4930
  end
3721
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
+
3722
5272
  def reset_workers
3723
5273
  @workers.each_value do |w|
3724
5274
  w[:inbox] << :terminate
@@ -3726,7 +5276,21 @@ module Capybara
3726
5276
  end
3727
5277
  @workers.clear
3728
5278
  @worker_outbox.clear
3729
- @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
3730
5294
  @transfer_buffer_lock.synchronize {
3731
5295
  @transfer_buffers.clear
3732
5296
  @transfer_buffer_seq = 0
@@ -3776,26 +5340,11 @@ module Capybara
3776
5340
  # host's last two dot-labels — correct for the single-label public suffixes our
3777
5341
  # in-process hosts use (web-platform.test / not-web-platform.test / *.com); a
3778
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.
3779
5346
  def blob_partition_site
3780
- # A blob: document's location is `blob:<innerURL>` (e.g.
3781
- # `blob:https://host:port/uuid`) — strip the prefix so the site derives from
3782
- # the inner origin, not the opaque blob: scheme. data:/about: have no host →
3783
- # '' (an opaque origin, never same-partition with a real one).
3784
- u = URI.parse(@current_url.to_s.sub(/\Ablob:/, ''))
3785
- host = u.host.to_s
3786
- return '' if host.empty?
3787
- labels = host.split('.')
3788
- # An IP literal (v4 dotted / v6 bracketed) or a ≤2-label host IS its own
3789
- # registrable domain; otherwise approximate eTLD+1 as the last two labels
3790
- # (no Public Suffix List — correct for the single-label TLDs our hosts use).
3791
- regd = if host.start_with?('[') || host.match?(/\A\d+(\.\d+){3}\z/) || labels.length <= 2
3792
- host
3793
- else
3794
- labels.last(2).join('.')
3795
- end
3796
- "#{u.scheme}://#{regd}"
3797
- rescue URI::Error
3798
- ''
5347
+ registrable_site(@current_url) || ''
3799
5348
  end
3800
5349
 
3801
5350
  # WHATWG URL "domain to ASCII" — the JS tr46 stub delegates non-ASCII / xn--
@@ -3978,7 +5527,9 @@ module Capybara
3978
5527
  # Called from the JS bridge when a `<video>` element's `src` is
3979
5528
  # assigned a `blob:` URL. ffprobe extracts dimensions + duration,
3980
5529
  # ffmpeg extracts the first frame as raw RGBA. JS caches both so
3981
- # `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.
3982
5533
  def decode_video_frame(b64_bytes)
3983
5534
  host_image_op('decode_video_frame') {
3984
5535
  bytes = Base64.decode64(b64_bytes.to_s)
@@ -4001,6 +5552,22 @@ module Capybara
4001
5552
  }
4002
5553
  end
4003
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
+
4004
5571
  private def ffprobe_stream(path)
4005
5572
  json = IO.popen(
4006
5573
  ['ffprobe', '-v', 'error', '-select_streams', 'v:0',
@@ -4071,7 +5638,7 @@ module Capybara
4071
5638
  # `build_worker` factory, evaluates the worker script, then
4072
5639
  # loops draining microtasks + timers + inbox until `:terminate`
4073
5640
  # lands or an exception propagates.
4074
- 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)
4075
5642
  # Release the spawn-time `@worker_initializing` count exactly once, however
4076
5643
  # this method exits (normal start, `self.close()`, or an exception), so
4077
5644
  # worker_pending? doesn't stay stuck true forever.
@@ -4081,6 +5648,11 @@ module Capybara
4081
5648
  initializing = false
4082
5649
  @worker_init_lock.synchronize { @worker_initializing -= 1 }
4083
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
4084
5656
  # Tag this thread so blob URLs created by the worker's script are owned by
4085
5657
  # this handle and revoked on terminate (see blob_register / revoke_worker_blobs).
4086
5658
  Thread.current[:csim_worker_handle] = handle
@@ -4090,17 +5662,83 @@ module Capybara
4090
5662
  # BINARY-tagged (see `RuntimeShared.utf8_text`).
4091
5663
  body = RuntimeShared.utf8_text(body)
4092
5664
  post_back = ->(data) { outbox << {handle: handle, kind: 'message', data: data.to_s} }
4093
- 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};")
4094
5697
  # Set the worker's `self.location.href` so webpack /
4095
5698
  # rollup public-path derivation + `new URL(rel, import.meta.url)`
4096
5699
  # resolve chunks against the worker's own origin rather than
4097
5700
  # the snapshot-time `http://placeholder/`.
4098
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
4099
5715
  # A service worker runs in a ServiceWorkerGlobalScope: adjust the worker scope
4100
5716
  # (no blob-URL minting; SW lifecycle stubs) BEFORE its script runs.
4101
5717
  rt.eval('__csim_installServiceWorkerScope();') if service
4102
5718
  rt.eval(body)
4103
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
4104
5742
  # A SharedWorker fires `connect` AFTER its script set `self.onconnect`; the
4105
5743
  # connect handler's port post lands in the outbox before release_init, so
4106
5744
  # worker_pending? stays true until it's delivered.
@@ -4116,26 +5754,119 @@ module Capybara
4116
5754
  loop do
4117
5755
  msg = pop_with_timeout(inbox, WORKER_POLL_INTERVAL)
4118
5756
  break if msg == :terminate
4119
- rt.call('__csim_workerOnMessage', msg) if msg
4120
- # Drive the worker's OWN event loop each tick: a message handler OR an
4121
- # AUTONOMOUS loop (the dispatcher executor-worker's receive→fetch→setTimeout
4122
- # retry, which has no inbox message) may have pending timers. Drain ~one poll
4123
- # interval (WorkerRuntime#drain_timers advances the worker clock a step) so
4124
- # they progress; worker http fetch is setTimeout(0)+__rackFetch, resolved on
4125
- # this thread by the drain. Gated on a PENDING timer (any, not just due-now
4126
- # the clock must advance to fire a future randomDelay) so an idle
4127
- # message-driven worker with no timers stays lazy. Host CALLS, not string
4128
- # `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).
4129
5853
  if rt.call('__nextTimerDelay').to_f >= 0
4130
5854
  rt.drain_microtasks
4131
5855
  rt.drain_timers
4132
5856
  end
5857
+ if busy_held
5858
+ @worker_init_lock.synchronize { @worker_busy -= 1 }
5859
+ busy_held = false
5860
+ end
4133
5861
  break if rt.call('__csimWorkerClosedRead')
4134
5862
  end
4135
5863
  end
4136
5864
  rescue StandardError => e
4137
5865
  outbox << {handle: handle, kind: '__error', message: "#{e.class}: #{e.message}"}
4138
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
4139
5870
  release_init.call # guarantee the init count is released on an early raise
4140
5871
  rt&.dispose
4141
5872
  end
@@ -4156,7 +5887,12 @@ module Capybara
4156
5887
  return data[:bytes]
4157
5888
  end
4158
5889
  b64 = @runtime.call('__csimReadBlobBase64', u)
4159
- 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?
4160
5896
  return Base64.decode64(b64.to_s)
4161
5897
  end
4162
5898
  # `data:[<mediatype>][;base64],<data>` worker scripts (a worker created
@@ -4180,6 +5916,24 @@ module Capybara
4180
5916
  end
4181
5917
  end
4182
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
+
4183
5937
  # `Thread::Queue#pop(timeout:)` blocks releasing the GVL — fine
4184
5938
  # because the worker thread has nothing else to do while idle,
4185
5939
  # and `worker_post_to_worker` wakes the wait immediately.
@@ -4330,7 +6084,7 @@ module Capybara
4330
6084
  # isn't http(s) (data: / mailto: / about:) plus pseudo-tokens
4331
6085
  # like V8's `<snapshot>` that sourcemap libraries pull out of
4332
6086
  # error stacks and feed straight to `fetch()` / `xhr.open()`.
4333
- 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')
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)
4334
6088
  # NB: a relative fetch/XHR URL is resolved against the document's API base URL
4335
6089
  # at OPEN time (XHR open() / fetch()), in JS, NOT here — resolving at send time
4336
6090
  # would wrongly pick up a `<base href>` inserted after open() (open-url-base
@@ -4350,7 +6104,14 @@ module Capybara
4350
6104
  # internal asset GET) pass nil → no CORS and no mode semantics. The document's
4351
6105
  # origin is the request's origin; a different target origin is cross-origin.
4352
6106
  cors = cors_mode == 'cors'
4353
- req_origin = cors ? url_origin(@current_url) : nil
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)
4354
6115
  # Fetch request "mode" (fetch threads it; XHR is always 'cors'; a non-fetch/xhr
4355
6116
  # caller passes nil → no mode semantics, a plain 'basic' response). `no-cors`
4356
6117
  # filters a cross-origin response to opaque; `same-origin` makes a cross-origin
@@ -4363,6 +6124,11 @@ module Capybara
4363
6124
  # (form submission) or a nil-mode internal caller gets a plain readable response.
4364
6125
  doc_origin = %w[cors no-cors same-origin].include?(cors_mode) ? url_origin(@current_url) : nil
4365
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
4366
6132
  # A request is "credentialed" (cookies + the credentialed CORS check) only in
4367
6133
  # `include` mode; `same-origin` (default) and `omit` are uncredentialed for the
4368
6134
  # CORS check, while the cookie decision below distinguishes all three.
@@ -4380,12 +6146,27 @@ module Capybara
4380
6146
  body = Base64.decode64(body.to_s)
4381
6147
  headers = headers.reject {|k, _| k == 'X-Csim-Body-B64' }
4382
6148
  end
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
4383
6158
  # The request's origin starts as the document origin; a cross-origin REDIRECT
4384
6159
  # taints it to an opaque origin (serialized "null") per Fetch "HTTP-redirect
4385
6160
  # fetch". `effective_origin` IS that origin — it's what the Origin header
4386
6161
  # carries and what the CORS check / preflight compare against from that hop on
4387
- # ('null' once tainted, so the server must then allow 'null' or '*').
4388
- effective_origin = req_origin
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
4389
6170
  # An author conditional (If-None-Match / …) means the caller is doing its own
4390
6171
  # revalidation, so the UA cache must step aside (computed once — the headers
4391
6172
  # carrying it survive every redirect hop unchanged).
@@ -4399,6 +6180,15 @@ module Capybara
4399
6180
  # document URL ("client"); an empty referrer means no-referrer (compute_referrer
4400
6181
  # maps a blank source to nil).
4401
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)
4402
6192
  (MAX_FETCH_REDIRECTS + 1).times do
4403
6193
  t0 = @trace && Process.clock_gettime(Process::CLOCK_MONOTONIC)
4404
6194
  # Cross-origin-ness for the request mode/type, latched across hops. Computed
@@ -4449,10 +6239,12 @@ module Capybara
4449
6239
 
4450
6240
  env = Rack::MockRequest.env_for(target, method: method, input: body || '')
4451
6241
  env['REQUEST_METHOD'] = method # env_for upcases the method; restore the exact case (open-method-case-sensitive)
4452
- # A GET/HEAD request carries no body, so it sends no Content-Length (env_for
4453
- # always sets it to the input bytesize, i.e. 0). A POST/PUT with an empty body
4454
- # keeps Content-Length: 0 (send-entity-body-none / -empty).
4455
- env.delete('CONTENT_LENGTH') if %w[GET HEAD].include?(method.to_s.upcase)
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)
4456
6248
  apply_request_headers(env, headers) if headers
4457
6249
  apply_request_headers(env, @@asset_cache.revalidation_headers(cache_entry)) if cache_entry
4458
6250
  # The Referer follows the request's Referrer-Policy (a redirect response can
@@ -4473,6 +6265,18 @@ module Capybara
4473
6265
  hop_cross_origin = !!(doc_origin && (effective_origin == 'null' || url_origin(target) != doc_origin))
4474
6266
  send_cookies = credentials == 'include' || (credentials != 'omit' && !hop_cross_origin)
4475
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
4476
6280
  # A CORS request to a URL carrying credentials (`user:pass@`) is a network
4477
6281
  # error (access-control-and-redirects "user info" subtest).
4478
6282
  return nil if cross_origin && url_has_userinfo?(target)
@@ -4491,14 +6295,55 @@ module Capybara
4491
6295
  if cross_origin || (req_origin && !%w[GET HEAD].include?(method.to_s.upcase))
4492
6296
  env['HTTP_ORIGIN'] = effective_origin
4493
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
4494
6311
  env.merge!(env_extras) if env_extras
4495
6312
  status, resp_headers, resp_body = dispatch_rack_or_http(target, env, method: method, body: body)
4496
- 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
4497
6338
  if status == 304 && cache_entry
4498
6339
  trace_network(method, target, cache_entry.status, headers, body, cache_entry.headers, nil, t0, false)
4499
6340
  resp_body.close if resp_body.respond_to?(:close)
4500
6341
  @@asset_cache.refresh(cache_entry, resp_headers)
4501
- 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)
4502
6347
  end
4503
6348
  # Fetch "CORS check" runs on EVERY cross-origin response — including a 3xx the
4504
6349
  # UA is about to follow (a redirect whose response lacks a valid Access-Control
@@ -4554,6 +6399,12 @@ module Capybara
4554
6399
  # hop out of a same-origin request keeps the real origin (redirect-origin
4555
6400
  # "same origin to other origin" sends the document origin, not null).
4556
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
4557
6408
  target = carry_fragment(target, next_url)
4558
6409
  if bad_port?(target) # a redirect to a blocked port is a network error too
4559
6410
  resp_body.close if resp_body.respond_to?(:close)
@@ -4601,7 +6452,14 @@ module Capybara
4601
6452
  # -Headers (`*` = all). content-type stays safelisted, so response decoding is
4602
6453
  # unaffected. (Filtered for script exposure only — trace / set-cookie / cache see
4603
6454
  # the full set.) The CORS check itself already ran above (incl. on 3xx hops).
4604
- exposed_headers = cross_origin ? cors_exposed_headers(resp_headers) : resp_headers
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
4605
6463
  trace_network(method, target, status, headers, body, resp_headers, body_str, t0, false)
4606
6464
  # A no-store request must not write the cache (RFC 9111 §5.2.1.5); a request carrying
4607
6465
  # the author's own conditional bypasses the UA cache entirely (read AND write) — it's
@@ -4611,7 +6469,7 @@ module Capybara
4611
6469
  # A no-cors cross-origin response is OPAQUE: status 0, empty body, no exposed
4612
6470
  # headers, empty URL (cors-basic "Opaque filter"). Otherwise the type is 'cors'
4613
6471
  # for a cross-origin (CORS-allowed) response, else 'basic'.
4614
- return response_hash(0, {}, '', '', false, type: 'opaque', body_null: true) if no_cors_mode && crossed
6472
+ return response_hash(0, {}, '', '', false, type: 'opaque', body_null: true, opaque_render: body_str) if no_cors_mode && crossed
4615
6473
  return response_hash(status, exposed_headers, body_str, target, redirected, type: crossed ? 'cors' : 'basic', body_null: null_body)
4616
6474
  end
4617
6475
  raise StandardError, "[capybara-simulated] fetch exceeded #{MAX_FETCH_REDIRECTS} redirects"
@@ -4717,7 +6575,7 @@ module Capybara
4717
6575
  # text body when `body_b64` is absent.
4718
6576
  TEXT_CONTENT_TYPE_PREFIXES = %w[text/ application/json application/javascript application/ecmascript application/xml image/svg+xml].freeze
4719
6577
 
4720
- def response_hash(status, headers, body, url, redirected, type: 'basic', body_null: false)
6578
+ def response_hash(status, headers, body, url, redirected, type: 'basic', body_null: false, opaque_render: nil)
4721
6579
  raw = body.to_s
4722
6580
  hdrs = stringify(headers)
4723
6581
  # A NUL in a header value is not a valid HTTP message; a real server can't
@@ -4775,6 +6633,15 @@ module Capybara
4775
6633
  # (decodeResponseBytes). `ascii_only?` is a cheap C-level scan, so the dominant
4776
6634
  # pure-ASCII app JSON/HTML traffic keeps the fast path and pays no base64.
4777
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?
4778
6645
  out
4779
6646
  end
4780
6647
 
@@ -4936,6 +6803,14 @@ module Capybara
4936
6803
  entry = @frame_stack.find {|e| e[:realm_id] == realm_id }
4937
6804
  if entry
4938
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)
4939
6814
  else
4940
6815
  @runtime.call('__csimNavigateFrameByRealm', realm_id, url)
4941
6816
  end
@@ -5018,8 +6893,19 @@ module Capybara
5018
6893
  # JS-side, reusing the retained content so blob bytes survive a revoke.
5019
6894
  entry = @frame_stack.find {|e| e[:realm_id] == realm_id }
5020
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)
5021
6897
  if entry && !url.empty?
5022
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)
5023
6909
  else
5024
6910
  @runtime.call('__csimReloadFrameByRealm', realm_id)
5025
6911
  end
@@ -5086,8 +6972,9 @@ module Capybara
5086
6972
  return if h.nil?
5087
6973
  target = h[:idx] + delta
5088
6974
  return if target.negative? || target >= h[:entries].size
5089
- # Snapshot the entry we're leaving so a later forward traversal restores it.
5090
- 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
5091
6978
  reload_frame_to_entry(realm_id, h[:entries][target])
5092
6979
  h[:idx] = target # advance only after the rebuild succeeds
5093
6980
  end
@@ -5097,52 +6984,110 @@ module Capybara
5097
6984
  # into the frame form-submission paths; frame navigations driven by
5098
6985
  # `location.href` / link clicks aren't recorded yet (history.back there falls
5099
6986
  # through to the top document, as before).
5100
- 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)
5101
6990
  return if realm_id.nil? || realm_id.zero?
5102
6991
  parent = @runtime.frame_realm_parent(realm_id)
5103
6992
  handle = frame_container_handle(realm_id, parent)
5104
6993
  return if handle.zero?
5105
6994
  h = (@frame_histories ||= {})[[parent, handle]] ||= {entries: [], idx: -1}
5106
- outgoing = frame_history_entry(realm_id)
5107
6995
  if h[:idx] >= 0
5108
- h[:entries][h[:idx]] = outgoing
6996
+ h[:entries][h[:idx]] = snapshot_outgoing_entry(realm_id, h[:entries][h[:idx]])
5109
6997
  else
5110
- h[:entries] << outgoing
6998
+ h[:entries] << frame_history_entry(realm_id)
5111
6999
  h[:idx] = 0
5112
7000
  end
5113
7001
  h[:entries] = h[:entries][0..h[:idx]]
5114
- 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
5115
7005
  h[:idx] = h[:entries].size - 1
5116
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
5117
7016
  # The history entry for the document currently loaded in `realm_id`: its URL
5118
7017
  # plus a snapshot of its form-control state.
5119
7018
  def frame_history_entry(realm_id)
5120
7019
  {url: frame_realm_url(realm_id), form_state: capture_frame_form_state(realm_id)}
5121
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
5122
7028
  def frame_realm_url(realm_id)
5123
7029
  return nil unless @runtime.frame_realm_alive?(realm_id)
5124
7030
  @runtime.realm_call(realm_id, '__csimLocationHref').to_s
5125
7031
  rescue StandardError
5126
7032
  nil
5127
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
5128
7043
  def capture_frame_form_state(realm_id)
5129
7044
  return nil unless @runtime.frame_realm_alive?(realm_id)
5130
7045
  @runtime.realm_call(realm_id, '__csimCaptureFormState')
5131
7046
  rescue StandardError
5132
7047
  nil
5133
7048
  end
5134
- # Re-fetch a history entry's URL and rebuild the frame realm from it, then
5135
- # restore the entry's captured form state (before the element load fires).
5136
- 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)
5137
7055
  url = entry[:url].to_s
5138
7056
  return if url.empty?
5139
- 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
5140
7085
  apply_default_request_env(env, referer: current_browsing_context_url)
5141
- status, headers, body = dispatch_rack_or_http(url, env, method: 'GET')
5142
- 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)
5143
7088
  return if download_response?(headers)
5144
- html = read_rack_body(body)
5145
- 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)
5146
7091
  end
5147
7092
  # Serialize + route a form submitted inside frame realm `realm_id`. We
5148
7093
  # serialize in the INITIATING realm (so shadow-tree controls are excluded
@@ -5162,7 +7107,7 @@ module Capybara
5162
7107
  target = spec['target'].to_s
5163
7108
  action = spec['action'].to_s
5164
7109
  enctype = spec['enctype'].to_s.empty? ? 'application/x-www-form-urlencoded' : spec['enctype'].to_s.downcase
5165
- entries = entry_list.is_a?(Array) ? entry_list : entries_from_spec(spec)
7110
+ entries = entry_list.is_a?(Array) ? entry_list : (spec['entries'] || [])
5166
7111
  # GET → urlencoded query (enctype ignored); POST → enctype-encoded body.
5167
7112
  get_query, = encode_entry_list(entries, 'application/x-www-form-urlencoded')
5168
7113
  get_url = form_get_url(action, get_query)
@@ -5200,20 +7145,63 @@ module Capybara
5200
7145
  end
5201
7146
  # A self-targeted GET form submit in the initiating frame realm: navigate
5202
7147
  # that frame to the action URL (query already mutated in).
5203
- def navigate_realm_self_get(realm_id, get_url)
5204
- 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
5205
7155
  entry = @frame_stack.find {|e| e[:realm_id] == realm_id }
5206
- if entry
5207
- navigate_frame(resolve_against_current(get_url), entry: entry)
5208
- else
5209
- # A frame reached via contentWindow (not on the entered stack): its
5210
- # owning iframe lives in its PARENT realm's document (the main realm for
5211
- # a top-level frame, an intermediate realm for a nested one), so route
5212
- # the by-realm src-reassignment therenot unconditionally to main.
5213
- # (relative get_url resolves against the frame's base on rebuild).
5214
- parent = @runtime.frame_realm_parent(realm_id)
5215
- 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)
5216
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))
5217
7205
  end
5218
7206
  # A self-targeted POST form submit in the initiating frame realm. POST the
5219
7207
  # entity body to the action URL, then rebuild that frame's realm from the
@@ -5221,25 +7209,55 @@ module Capybara
5221
7209
  # a frame reached via contentWindow has no stack entry, so rebuild it by
5222
7210
  # realm id (recovering its container element + parent realm) and fire the
5223
7211
  # iframe element's load event the GET/src path would.
5224
- 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)
5225
7213
  raise 'too many redirects' if depth > 10
5226
- 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
5227
7220
  entry = @frame_stack.find {|e| e[:realm_id] == realm_id }
5228
7221
  return navigate_frame_post(url, body, content_type, entry: entry) if entry
5229
7222
  invalidate_find_cache
5230
- env = Rack::MockRequest.env_for(url, method: 'POST', input: body)
5231
- env['CONTENT_TYPE'] = content_type.to_s.empty? ? 'application/x-www-form-urlencoded' : content_type
5232
- env['CONTENT_LENGTH'] = body.bytesize.to_s
5233
- apply_default_request_env(env, referer: current_browsing_context_url)
5234
- status, headers, resp_body = dispatch_rack_or_http(url, env, method: 'POST', body: body)
5235
- 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)
5236
7252
  if (loc = redirect_location(status, headers))
5237
7253
  next_url = resolve_against_current(loc)
5238
7254
  resp_body.close if resp_body.respond_to?(:close)
5239
7255
  # 307/308 preserve method + body; 301/302/303 → GET the frame (routed
5240
- # 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?).
5241
7258
  if [307, 308].include?(status)
5242
- 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))
5243
7261
  end
5244
7262
  parent = @runtime.frame_realm_parent(realm_id)
5245
7263
  return frame_realm_host_call(parent, '__csimNavigateFrameByRealm', realm_id, next_url)
@@ -5248,8 +7266,16 @@ module Capybara
5248
7266
  save_downloaded_response(url, headers, resp_body)
5249
7267
  return
5250
7268
  end
7269
+ tag_frame_entry_post(realm_id, body, content_type) if depth.zero?
5251
7270
  reload_frame_realm_by_id(realm_id, url.to_s, read_rack_body(resp_body), response_content_type(headers))
5252
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
5253
7279
  # Rebuild a frame realm reached via contentWindow (no @frame_stack entry):
5254
7280
  # recover its container element handle + parent realm, swap in a fresh realm
5255
7281
  # built from `html`, re-point the iframe at it, and fire the element load.
@@ -5357,24 +7383,60 @@ module Capybara
5357
7383
  def history_length
5358
7384
  [@history.size, 1].max
5359
7385
  end
5360
- # `document.cookie` is TEXT; jar entries parsed out of Rack's Set-Cookie
5361
- # headers can carry the BINARY tag, which would make the joined string
5362
- # cross into JS as a Uint8Array (`document.cookie.match is not a
5363
- # 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
+
5364
7424
  def document_cookie
5365
- RuntimeShared.utf8_text(@cookies.map {|k, v| "#{k}=#{v}" }.join('; '))
7425
+ cookie_header_for(document_cookie_host)
5366
7426
  end
5367
7427
  def current_referer ; @current_referer.to_s ; end
5368
7428
  def write_document_cookie(s)
5369
7429
  return if s.nil? || s.empty?
7430
+ host = document_cookie_host or return
5370
7431
  name, rest = s.split('=', 2)
5371
7432
  return if name.nil? || name.empty?
5372
7433
  parts = (rest || '').split(';').map(&:strip)
5373
7434
  value = parts.shift.to_s
7435
+ jar = (@cookies[host] ||= {})
5374
7436
  if cookie_deletion?(parts)
5375
- @cookies.delete(name.strip)
7437
+ jar.delete(name.strip)
5376
7438
  else
5377
- @cookies[name.strip] = value
7439
+ jar[name.strip] = value
5378
7440
  end
5379
7441
  end
5380
7442
 
@@ -5389,9 +7451,25 @@ module Capybara
5389
7451
  def storage_get(kind, key)
5390
7452
  store(kind)[key.to_s]
5391
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.
5392
7463
  def storage_set(kind, key, value)
5393
- store(kind)[key.to_s] = value.to_s
5394
- 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
5395
7473
  end
5396
7474
  def storage_remove(kind, key)
5397
7475
  store(kind).delete(key.to_s)
@@ -5410,6 +7488,62 @@ module Capybara
5410
7488
  private def store(kind)
5411
7489
  kind.to_s == 'session' ? @session_storage : @local_storage
5412
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
5413
7547
  # Push a one-shot handler onto the modal-dialog stack — the next
5414
7548
  # modal that fires consumes the topmost handler. Block exit pops
5415
7549
  # in case the dialog never fired.
@@ -5459,7 +7593,7 @@ module Capybara
5459
7593
  env = Rack::MockRequest.env_for(url, method: 'GET')
5460
7594
  apply_default_request_env(env, referer: current_browsing_context_url)
5461
7595
  status, headers, body = dispatch_rack_or_http(url, env, method: 'GET')
5462
- merge_set_cookie(headers)
7596
+ merge_set_cookie(headers, url)
5463
7597
  if (loc = redirect_location(status, headers))
5464
7598
  next_url = carry_fragment(url, resolve_against_current(loc))
5465
7599
  body.close if body.respond_to?(:close)
@@ -5480,7 +7614,7 @@ module Capybara
5480
7614
  env['CONTENT_LENGTH'] = body.bytesize.to_s
5481
7615
  apply_default_request_env(env, referer: current_browsing_context_url)
5482
7616
  status, headers, resp_body = dispatch_rack_or_http(url, env, method: 'POST', body: body)
5483
- merge_set_cookie(headers)
7617
+ merge_set_cookie(headers, url)
5484
7618
  if (loc = redirect_location(status, headers))
5485
7619
  next_url = resolve_against_current(loc)
5486
7620
  resp_body.close if resp_body.respond_to?(:close)
@@ -5597,7 +7731,7 @@ module Capybara
5597
7731
  env = Rack::MockRequest.env_for(url, method: 'GET')
5598
7732
  apply_default_request_env(env, referer: referer)
5599
7733
  status, headers, body = dispatch_rack_or_http(url, env, method: 'GET')
5600
- merge_set_cookie(headers)
7734
+ merge_set_cookie(headers, url)
5601
7735
  if (loc = redirect_location(status, headers))
5602
7736
  next_url = resolve_against_current(loc)
5603
7737
  # Per RFC 7231: if the original request URL had a fragment
@@ -5825,8 +7959,147 @@ module Capybara
5825
7959
  # server can negotiate — HTML-only routes still pick html,
5826
7960
  # both-available pick the first registered.
5827
7961
  env['HTTP_ACCEPT'] ||= DEFAULT_HTTP_ACCEPT
5828
- env['HTTP_REFERER'] = referer unless referer.nil? || referer.empty?
5829
- 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
5830
8103
  end
5831
8104
 
5832
8105
  # Cross-host hop (e.g. Discourse's `discourse_connect` flow
@@ -5911,9 +8184,22 @@ module Capybara
5911
8184
  nil
5912
8185
  end
5913
8186
 
5914
- 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)
5915
8199
  sc = headers['set-cookie'] || headers['Set-Cookie']
5916
8200
  return if sc.nil? || sc.empty?
8201
+ host = cookie_host(url) || document_cookie_host or return
8202
+ jar = (@cookies[host] ||= {})
5917
8203
  # Rack 2 returns multiple Set-Cookie headers as a single
5918
8204
  # newline-separated string; Rack 3 returns an Array. Treat both
5919
8205
  # uniformly — splitting first means the second cookie in a
@@ -5926,9 +8212,9 @@ module Capybara
5926
8212
  name, value = pair.split('=', 2)
5927
8213
  next if name.nil? || name.empty?
5928
8214
  if cookie_deletion?(parts)
5929
- @cookies.delete(name.strip)
8215
+ jar.delete(name.strip)
5930
8216
  else
5931
- @cookies[name.strip] = value.to_s.strip
8217
+ jar[name.strip] = value.to_s.strip
5932
8218
  end
5933
8219
  }
5934
8220
  end
@@ -6026,19 +8312,49 @@ module Capybara
6026
8312
  # preflight whose Access-Control-Allow-Methods / -Headers carries a malformed value.
6027
8313
  HTTP_TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/.freeze
6028
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
6029
8344
 
6030
- # The sorted, lowercased author header names that are NOT CORS-safelisted (a
6031
- # non-safe Content-Type counts). These are echoed in Access-Control-Request-Headers
6032
- # for the preflight and must be covered by Access-Control-Allow-Headers.
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.
6033
8350
  def cors_unsafe_headers(headers)
6034
8351
  (headers || {}).filter_map {|k, v|
6035
8352
  name = k.to_s.downcase
6036
8353
  next if name.start_with?('x-csim') || name == 'content-length'
6037
- if name == 'content-type'
6038
- essence = v.to_s.split(';', 2).first.to_s.strip.downcase
6039
- CORS_SAFELISTED_CTYPES.include?(essence) ? nil : name
8354
+ if CORS_SAFELISTED_HEADERS.include?(name)
8355
+ cors_safelisted_request_header?(name, v) ? nil : name
6040
8356
  else
6041
- CORS_SAFELISTED_HEADERS.include?(name) ? nil : name
8357
+ name
6042
8358
  end
6043
8359
  }.uniq.sort
6044
8360
  end
@@ -6161,6 +8477,8 @@ module Capybara
6161
8477
  env['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'] = unsafe.join(',') unless unsafe.empty?
6162
8478
  status, ph, pbody = dispatch_rack_or_http(target, env, method: 'OPTIONS', body: nil)
6163
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
6164
8482
  return nil unless (200..299).include?(status.to_i)
6165
8483
  acao = cors_header(ph, 'access-control-allow-origin')
6166
8484
  # A credentialed preflight can't be allowed by the wildcard origin and must carry
@@ -6186,19 +8504,29 @@ module Capybara
6186
8504
 
6187
8505
  # The response headers a cross-origin "cors" response exposes to getResponseHeader /
6188
8506
  # getAllResponseHeaders: the CORS-safelisted set plus any named in Access-Control
6189
- # -Expose-Headers (`*` exposes all only valid without credentials, which these
6190
- # cases don't use).
6191
- def cors_exposed_headers(headers)
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)
6192
8518
  # set-cookie / set-cookie2 are forbidden response-header names — NEVER exposed to
6193
- # script, even under `Access-Control-Expose-Headers: *`. x-csim-status-text is our
6194
- # internal reason-phrase sentinel (response_hash lifts it into statusText, which
6195
- # IS exposed cross-origin, then strips it from the script-visible map), so it must
6196
- # survive the filter.
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.
6197
8523
  forbidden = %w[set-cookie set-cookie2]
6198
8524
  expose = cors_list(cors_header(headers, 'access-control-expose-headers')).map(&:downcase)
6199
- return headers.reject {|k, _| forbidden.include?(k.to_s.downcase) } if expose.include?('*')
8525
+ if !credentialed && expose.include?('*')
8526
+ return headers.reject {|k, _| forbidden.include?(k.to_s.downcase) }
8527
+ end
6200
8528
  allowed = CORS_SAFELISTED_RESPONSE_HEADERS + expose + ['x-csim-status-text']
6201
- headers.select {|k, _| allowed.include?(k.to_s.downcase) }
8529
+ headers.select {|k, _| allowed.include?(k.to_s.downcase) && !forbidden.include?(k.to_s.downcase) }
6202
8530
  end
6203
8531
 
6204
8532
  # Case-insensitive response-header lookup + comma-list split for the CORS checks.