capybara-simulated 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -134,6 +134,11 @@ module Capybara
134
134
  # debounce fires" tests (Discourse refetchForSearch / doubled-filter, Avo
135
135
  # filters) still observe the intermediate state across several polls.
136
136
  FF_TRANSIENT_GUARD_POLLS = (ENV['CSIM_FF_TRANSIENT_GUARD_POLLS'] || '6').to_i
137
+ # The display the window lives on. Mirrors the JS-side `screen` /
138
+ # initial `innerWidth` / `innerHeight` (js/src/platform-globals.js)
139
+ # — the window starts filling it, `resize_to` moves the viewport
140
+ # off it, and `maximize` / `fullscreen` restore it.
141
+ SCREEN_SIZE = [1024, 768].freeze
137
142
  SETTLE_DRAIN_MS = 32
138
143
  SETTLE_MAX_ITER = 10
139
144
  # Per-`run_loop_step` task cap (its `maxIter`). Bounds a self-rescheduling
@@ -196,7 +201,7 @@ module Capybara
196
201
  Rack::Mime.mime_type(File.extname(path.to_s), '')
197
202
  end
198
203
 
199
- def initialize(app, driver: nil, js_engine: nil, cookies: nil, local_storage: nil, all_hosts_local: nil)
204
+ def initialize(app, driver: nil, js_engine: nil, cookies: nil, auth_cache: nil, local_storage: nil, cache_storage: nil, all_hosts_local: nil)
200
205
  @app = app
201
206
  @driver = driver
202
207
  @all_hosts_local_override = all_hosts_local
@@ -239,7 +244,16 @@ module Capybara
239
244
  # see the same auth state and storage as the primary. Tests
240
245
  # without a Driver (gem-internal callers) get fresh jars.
241
246
  @cookies = cookies || {}
247
+ # HTTP Basic-auth credential cache, keyed by target origin: once credentials authenticate an
248
+ # origin, the UA sends them pre-emptively for later credentialed requests to it (RFC 7617
249
+ # §2.2), so a Basic-auth resource loads without re-challenging. Session-scoped (cleared on
250
+ # reset) and Driver-injected like the cookie jar, so target=_blank aux windows share one
251
+ # session's auth state (a real browser shares the HTTP auth cache across a session's tabs).
252
+ @auth_cache = auth_cache || {}
242
253
  @local_storage = local_storage || {}
254
+ # Cache Storage is origin-shared like localStorage (the Driver owns the store
255
+ # and injects it into every window Browser), origin-partitioned within.
256
+ @cache_storage = cache_storage || {}
243
257
  @session_storage = {}
244
258
  @sticky_headers = {}
245
259
  @timers_active = false
@@ -279,6 +293,9 @@ module Capybara
279
293
  @current_realm_id = nil
280
294
  @frame_stack = []
281
295
  @last_tick_ts = Process.clock_gettime(Process::CLOCK_MONOTONIC)
296
+ # The first find of a navigation observes the current DOM without a pre-tick (see
297
+ # timer_wait_elapsed?); armed by the first find, disarmed by reset_timer_state.
298
+ @pre_tick_armed = false
282
299
  @polling_grace = nil
283
300
  @last_polled_gen = nil
284
301
  @idle_settle_polls = 0
@@ -332,6 +349,13 @@ module Capybara
332
349
  @websocket_sockets = {} # id → csim's socket end (main thread owns this hash)
333
350
  @websocket_app_sockets = {} # id → the app's hijack end (closed on teardown)
334
351
  @websocket_queue = Thread::Queue.new
352
+ @websocket_queue_head = nil # one-slot buffer for an event hold_for_ws_close parked ahead of the queue
353
+ # In-flight `ws.close()` handshakes: hold the virtual clock until the reader surfaces the
354
+ # server's echoed close frame (a `__close` event) so a test awaiting `onclose` can't have
355
+ # its virtual-timeout outrun that real off-thread reply. Counted in ws_close, cleared as
356
+ # deliver_websocket_events delivers each terminal event. See hold_for_ws_close.
357
+ @ws_close_pending = 0
358
+ @ws_close_wait_deadline = nil
335
359
  # All frame writes (the reader thread's pong replies + the main thread's
336
360
  # send/close) go through one socket; serialise them so two threads can't
337
361
  # interleave bytes into a corrupt frame.
@@ -355,10 +379,75 @@ module Capybara
355
379
  @worker_seq = 0
356
380
  @workers = {}
357
381
  @worker_outbox = Thread::Queue.new
382
+ # One-slot head buffer for the settle wait: an event popped while blocking on the outbox
383
+ # is parked here (a push-back would reorder it behind concurrent worker pushes) and
384
+ # consumed first by the next deliver_worker_messages.
385
+ @worker_outbox_head = nil
358
386
  # Outstanding posts-to-worker; `polling?` stays true while > 0
359
387
  # so long-running compute (e.g. mozjpeg over an 8900×8900 frame)
360
388
  # isn't starved by the settle_gen idle gate.
361
389
  @worker_in_flight = 0
390
+ # BroadcastChannel posts pushed to worker inboxes that the worker hasn't yet processed. Kept
391
+ # SEPARATE from @worker_in_flight: a broadcast is fire-and-forget (a listen-only worker never
392
+ # replies), so it must not be "answered" by an unrelated postMessage reply. The worker acks
393
+ # each broadcast it delivers (a `bcack` outbox event), which decrements this; `worker_pending?`
394
+ # stays true until then so settle waits for the delivery.
395
+ @worker_broadcast_pending = 0
396
+ # Client → service-worker messages awaiting the worker's ack (it processed the inbound
397
+ # `message`). A SW `postMessage` produces no 1:1 reply (the SW replies via client.postMessage,
398
+ # a separate outbox event), so — like broadcasts — it needs its own pending tally, or a
399
+ # listen-only SW would leave settle perpetually non-idle.
400
+ @sw_message_pending = 0
401
+ # Controlled-client fetches awaiting the SW's respondWith (released by a `fetch_response`).
402
+ @sw_fetch_pending = 0
403
+ # Streaming respondWith bodies still open (head delivered, terminal frame not yet), keyed by
404
+ # the emitting worker handle → the [realm_id, fetch_id] frames it opened. Lets worker_terminate
405
+ # release + error a stream its worker died mid-flight, instead of stranding @sw_fetch_pending.
406
+ @sw_open_streams = Hash.new {|h, k| h[k] = {} }
407
+ # Deadline (CLOCK_MONOTONIC) capping how long the event-loop drain holds the virtual clock
408
+ # for an outstanding SW-side fetch (see run_event_loop_frame). Shared across frames so a
409
+ # stuck fetch costs the budget ONCE, not per frame; reset on each delivered reply so the next
410
+ # fetch in a sequence waits afresh.
411
+ @sw_fetch_wait_deadline = nil
412
+ # Same budget for a pending SW message swack / broadcast ack (drain_pending_message_reply);
413
+ # separate from the fetch deadline so a message wait and a fetch hold don't share a spent
414
+ # budget. Reset once no message/broadcast reply is outstanding.
415
+ @sw_msg_wait_deadline = nil
416
+ # SW navigation-interception state. `@sw_registrations` mirrors scope-href → active
417
+ # worker handle (from the client lifecycle) so a navigation fetched Ruby-side — before
418
+ # the destination realm's JS exists — can find its controlling SW; it survives the
419
+ # per-visit rebuild_ctx (unlike the per-realm JS registrations Map). A navigation fetch
420
+ # is awaited SYNCHRONOUSLY on `@sw_nav_outbox` (a dedicated queue, off the general outbox)
421
+ # keyed by a NEGATIVE `@sw_nav_seq` id so it never mixes with client-fetch replies.
422
+ @sw_registrations = {}
423
+ # Navigation Preload state, per active-worker HANDLE (the registration's active worker — the
424
+ # client's `registration.active._handle` and the worker's own `__csimWorkerHandle` are the
425
+ # same id, so both isolates key here identically). {enabled:, header:}; absent → the spec
426
+ # default {false, 'true'}. Read at navigation time to decide whether to issue the parallel
427
+ # preload request (see service_worker_navigation_fetch), and by the NavigationPreloadManager.
428
+ # EARNED GAP: the spec keeps this per-REGISTRATION (it survives a SW update); keying by the
429
+ # active worker's handle means an update — which mints a fresh handle — resets it to default.
430
+ # No vendored subtest enables preload then updates the worker, so handle-keying (which needs no
431
+ # scope plumbing to the worker isolate) is the simpler load-bearing choice.
432
+ @sw_navpreload = {}
433
+ # clients.claim() events that arrived before their scope was mirrored into @sw_registrations
434
+ # (activate→claim() races the client-side lifecycle) — buffered here, flushed by sw_register_scope.
435
+ @sw_pending_claims = []
436
+ @sw_nav_outbox = Thread::Queue.new
437
+ @sw_nav_seq = 0
438
+ # Service-worker Client registry: realm id → {handle, rec} for every
439
+ # controlled frame/window client, mirrored into the SW's clientsById so
440
+ # matchAll / getClientByURL see the real set. `@sw_realm_controller` records
441
+ # each realm's controller so an opaque child (about:blank / srcdoc) inherits
442
+ # it. Both keyed by realm id, cleared when the last worker exits.
443
+ @sw_clients = {}
444
+ @sw_realm_controller = {}
445
+ # The realm holding the focus chain (`note_focused_realm`); nil until a first focus.
446
+ @focused_realm_id = nil
447
+ # Cross-isolate MessagePort channels: channel id → {realm:, sw:} endpoints. A port
448
+ # transferred between a client realm and a worker/SW isolate registers both ends here;
449
+ # the browser relays each side's postMessage to the other. Cleared with the workers.
450
+ @port_channels = {}
362
451
  # Workers whose initial script hasn't finished running yet. A worker that
363
452
  # posts immediately on spawn (no main->worker message first) would leave
364
453
  # `@worker_in_flight` at 0, so `worker_pending?` would be false in the gap
@@ -366,6 +455,16 @@ module Capybara
366
455
  # stop waiting before the message lands. Count spawned-but-not-initialised
367
456
  # workers so the async drain holds until the initial script has run.
368
457
  @worker_initializing = 0
458
+ # Worker threads actively PROCESSING a plain postMessage (dequeued, not yet back at
459
+ # the idle poll). `@worker_in_flight` counts posted messages minus delivered replies,
460
+ # but one request yields MANY replies (progress updates + the final resolve), so it
461
+ # under-counts to 0 mid-handshake and `worker_pending?` would go false while the worker
462
+ # is still working — settle then breaks and abandons a multi-round protocol
463
+ # (Tesseract's createWorker load→loadLanguage→initialize→recognize, each a round-trip
464
+ # gated on a slow synchronous WASM step). The SW / broadcast message kinds have their
465
+ # own pending counters, so only the postMessage branch bumps this. Held under
466
+ # @worker_init_lock alongside @worker_initializing.
467
+ @worker_busy = 0
369
468
  @worker_init_lock = Mutex.new
370
469
  # Cross-isolate `blob:` store. Worker isolates can't see the
371
470
  # main scope's `__csimBlobs` Map, so we mirror bytes here and
@@ -382,6 +481,10 @@ module Capybara
382
481
  @transfer_buffer_lock = Mutex.new
383
482
  @transfer_buffers = {}
384
483
  @transfer_buffer_seq = 0
484
+ # Per-font ascent/descent probe cache for canvas text (render_text is
485
+ # worker-reachable via OffscreenCanvas, like decode_image).
486
+ @font_vmetrics_lock = Mutex.new
487
+ @font_vmetrics = {}
385
488
  # Zero-copy postMessage transfer tokens (rusty_racer >= 0.1.6
386
489
  # `RustyRacer.transferOut`): a buffer in a `postMessage` transfer list
387
490
  # crosses isolates by token (no byte copy), its source detached. A token
@@ -399,13 +502,52 @@ module Capybara
399
502
  # Cross-window BroadcastChannel messages from OTHER windows, delivered to
400
503
  # this window's matching channels on settle. [{name, data}] (same thread).
401
504
  @broadcast_inbox = []
402
- end
505
+ # Storage `storage` events queued for the OTHER same-origin documents (a change
506
+ # fires at every same-origin document EXCEPT the one that made it), delivered on
507
+ # settle. [{kind, key, old, new, url, source}] (same thread).
508
+ @storage_inbox = []
509
+ # BroadcastChannel isolate-wide registry + global ordered delivery queue (the multi-realm
510
+ # path — see broadcast_to_windows / bc_post). `@bc_registry` is keyed by [realm_id, local_id]
511
+ # → {seq, name, origin_key, closed}; `@bc_seq` is the isolate-wide creation counter that
512
+ # orders delivery "oldest channel first"; `@bc_queue` is the FIFO of pending {realm_id,
513
+ # local_id, data, origin} deliveries drained in order by deliver_window_messages.
514
+ @bc_seq = 0
515
+ @bc_registry = {}
516
+ @bc_queue = []
517
+ end
518
+
519
+ # Max BroadcastChannel deliveries drained per `deliver_broadcast_queue` call — a safety bound on a
520
+ # pathological mutual re-post loop (see there). Far above any real fan-out (the ordering test is ~14).
521
+ BROADCAST_DRAIN_CAP = 100_000
403
522
 
404
523
  # Worker thread polling and termination intervals — split so a
405
524
  # 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
525
+ WORKER_POLL_INTERVAL = 0.05
526
+ # Max wall time settle blocks on a worker thread's outbox per call while it processes an
527
+ # inbound message / fetch (releasing the GVL so it runs). Bounded so a genuinely stuck worker
528
+ # can't hang settle — the outer poll loop re-drives across calls.
529
+ WORKER_ROUND_TRIP_BUDGET = 1.0
530
+ WORKER_TERMINATE_GRACE = 0.05
531
+ # Max timer-draining rounds `drive_worker_to_quiescence` runs before yielding back
532
+ # to the poll loop. A message handler's async bring-up (Emscripten WASM init) settles
533
+ # in a handful of microtask/timer rounds; the cap only bites a worker that keeps
534
+ # rescheduling timers (a setInterval), which the poll loop then continues to advance.
535
+ WORKER_QUIESCE_MAX_ROUNDS = 256
536
+ # Per-frame GVL yield (run_event_loop_frame) while a worker thread is alive, so it gets a clean
537
+ # slice for cross-isolate work (transferIn / message replies) instead of being starved by the
538
+ # phase-1 spin. 0.3ms is the empirical floor for a deterministic cross-isolate transfer reply;
539
+ # 0.5ms adds margin for machine variance while staying cheap (only paid on worker/SW files).
540
+ WORKER_GVL_YIELD = 0.0005
541
+ # Client-realm handler for each streaming respondWith frame kind (see deliver_worker_messages
542
+ # + sw-client.js). `fr_start` builds a ReadableStream-backed Response; `fr_chunk` enqueues;
543
+ # `fr_close` / `fr_error` close / error the body stream.
544
+ STREAM_FRAME_FNS = {
545
+ 'fr_start' => '__csim_swFetchStreamStart',
546
+ 'fr_chunk' => '__csim_swFetchStreamChunk',
547
+ 'fr_close' => '__csim_swFetchStreamClose',
548
+ 'fr_error' => '__csim_swFetchStreamError'
549
+ }.freeze
550
+ private_constant :WORKER_POLL_INTERVAL, :WORKER_ROUND_TRIP_BUDGET, :WORKER_TERMINATE_GRACE, :WORKER_GVL_YIELD, :WORKER_QUIESCE_MAX_ROUNDS, :STREAM_FRAME_FNS
409
551
 
410
552
  # `js_engine` picks the JS runtime: `:v8` (rusty_racer, fastest
411
553
  # per-spec) or `:quickjs` (quickjs.rb, smaller per-VM footprint —
@@ -611,7 +753,7 @@ module Capybara
611
753
  # browsing context to route into. Distinguish that (unsupported
612
754
  # engine) from a frame that simply failed to build (below), so the
613
755
  # error doesn't misattribute a load failure to the engine.
614
- unless @runtime.respond_to?(:realm_call)
756
+ unless @runtime.supports_frames?
615
757
  raise Capybara::Simulated::FrameNotSupported,
616
758
  'within_frame needs a per-frame browsing context, which only the ' \
617
759
  'V8 (rusty_racer) engine provides; QuickJS keeps a same-realm fallback.'
@@ -740,6 +882,9 @@ module Capybara
740
882
 
741
883
  def find_with_timer_fallback(kind, arg, ctx)
742
884
  tick_real_time if timer_wait_elapsed?
885
+ # After the first find of a navigation, a later find IS Capybara retrying — arm the pre-tick
886
+ # so subsequent polls advance the clock (a timer-driven element / removal the test awaits).
887
+ @pre_tick_armed = true
743
888
  result = cached_find(kind, arg, ctx) { yield }
744
889
  # An empty result is the wait-for-it case: Capybara is retrying for
745
890
  # an element that hasn't appeared yet. Re-tick so the next poll
@@ -773,8 +918,20 @@ module Capybara
773
918
  # this above one Ruby boundary so a single visit+find pair
774
919
  # doesn't accidentally tick.
775
920
  FIND_PRE_TICK_MIN_S = 0.05
921
+ # Whether a find should advance the clock BEFORE reading the DOM. The FIRST find after a
922
+ # navigation observes the current (pre-timer) DOM unconditionally — the "query the DOM before
923
+ # advancing pending timers" contract — so it never pre-ticks; only a LATER find (Capybara
924
+ # retrying because the element wasn't there yet) does, gated on the tick FREQUENCY. Anchoring
925
+ # the first-find exemption on a flag (not the wall clock) keeps it deterministic: a >50 ms wall
926
+ # gap between the navigation and the first find under full-suite load must NOT fire a parked
927
+ # setTimeout(0) the page just scheduled (smoke_spec "queries the current DOM …").
928
+ # NOTE: the same first-find contract after a USER ACTION (click/fill) is still gated only on the
929
+ # 50 ms wall clock — @pre_tick_armed is disarmed by reset_timer_state (navigation) alone. Actions
930
+ # keep the wall gate because the post-action pre-tick timing is tuned against the debounce-
931
+ # between-actions app cases (Avo actions_spec:464); no action-path flake has surfaced there.
776
932
  def timer_wait_elapsed?
777
- @timers_active &&
933
+ @pre_tick_armed &&
934
+ @timers_active &&
778
935
  (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_tick_ts) >= FIND_PRE_TICK_MIN_S
779
936
  end
780
937
 
@@ -831,6 +988,39 @@ module Capybara
831
988
  tag(handle) == 'input' && attr(handle, 'type').to_s.downcase == 'file'
832
989
  end
833
990
  def visible?(handle) = dom_call('__csimVisible', handle) ? true : false
991
+ # `obscured?` — coarse occlusion / hit-test in JS (layout.js). Non-visible / out-of-viewport /
992
+ # click-point-lands-on-another-element → obscured.
993
+ def obscured?(handle) = dom_call('__csimObscured', handle) ? true : false
994
+ # `rect` — the element's coarse border-box from the layout engine, as the full 8-field box.
995
+ # Two key styles on purpose: Capybara's spatial `Rectangle` reads STRING keys
996
+ # (`position['top']`); Discourse's `wait_for_animation` reads the SYMBOL key (`rect[:x]`).
997
+ def rect(handle)
998
+ r = dom_call('__csimRect', handle)
999
+ x = (r['x'] || 0).to_f
1000
+ y = (r['y'] || 0).to_f
1001
+ w = (r['width'] || 0).to_f
1002
+ h = (r['height'] || 0).to_f
1003
+ {
1004
+ x:, y:, width: w, height: h, top: y, left: x, bottom: y + h, right: x + w,
1005
+ 'x' => x, 'y' => y, 'width' => w, 'height' => h, 'top' => y, 'left' => x, 'bottom' => y + h, 'right' => x + w
1006
+ }
1007
+ end
1008
+ # `scroll_to` — drive a real scroll offset in the layout engine (layout.applyScrollTo). `target`
1009
+ # is a target element's handle (or nil); `pos` a keyword (`:top`/`:bottom`/`:center`); `x`/`y`
1010
+ # an explicit coordinate. Symbols are stringified for the JS side.
1011
+ def scroll_to(handle, target = nil, pos = nil, x = nil, y = nil)
1012
+ dom_call('__csimScrollTo', handle, target, pos&.to_s, x, y)
1013
+ nil
1014
+ end
1015
+
1016
+ # Capybara's `scroll_to(:current, offset: [dx, dy])` routes here — a scroll relative to the
1017
+ # element's current offset, clamped to its scrollable range like a browser does.
1018
+ def scroll_by(handle, dx, dy)
1019
+ tick_real_time
1020
+ ensure_alive_after_tick(handle)
1021
+ dom_call('__csimScrollBy', handle, dx.to_f, dy.to_f)
1022
+ settle
1023
+ end
834
1024
 
835
1025
  # Capybara::Driver::Node surface — Node calls `check_stale`
836
1026
  # before each read, and that advances the virtual clock.
@@ -1021,7 +1211,8 @@ module Capybara
1021
1211
  env = Rack::MockRequest.env_for(url, method: 'GET')
1022
1212
  env['HTTP_USER_AGENT'] = @default_user_agent || USER_AGENT
1023
1213
  env['REMOTE_ADDR'] = self.class.remote_addr_for(env['HTTP_HOST'] || env['SERVER_NAME'])
1024
- env['HTTP_COOKIE'] = document_cookie unless @cookies.empty?
1214
+ ck = cookie_header_for(env_cookie_host(env))
1215
+ env['HTTP_COOKIE'] = ck unless ck.empty?
1025
1216
  env['HTTP_REFERER'] = @current_url unless @current_url.nil? || @current_url.empty?
1026
1217
  status, headers, body = @app.call(env)
1027
1218
  return unless status.to_i == 200
@@ -1206,19 +1397,30 @@ module Capybara
1206
1397
  end
1207
1398
 
1208
1399
  # 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)
1400
+ # drop_modifiers:, html5:, delay:)` lands here; the sequencing lives
1401
+ # in `drag.js` (pointer-driven vs HTML5, decided from the source's
1402
+ # mousedown). `drop_modifiers` are held down from `dragenter` on, the
1403
+ # way a user pressing a key mid-drag produces. `delay` is a
1404
+ # real-browser pacing knob our dispatch is synchronous, so the page
1405
+ # sees each step in order without it. Discourse sidebar reorder + Avo
1406
+ # Sortable-shaped widgets read `event.offsetY` to decide "above vs
1407
+ # below"; we report 0, which routes drops above the target.
1408
+ def drag_to(source_handle, target_handle, html5: nil, drop_modifiers: [], **_opts)
1216
1409
  mark_action_baseline
1217
1410
  tick_real_time
1218
1411
  invalidate_find_cache
1219
1412
  ensure_alive_after_tick(source_handle)
1220
1413
  ensure_alive_after_tick(target_handle)
1221
- dom_call('__csimDragOnto', source_handle, target_handle)
1414
+ # Staged with a settle between each step: a real drag spans several frames, and libraries
1415
+ # use that gap (SortableJS applies its ghost class from a `setTimeout` scheduled in
1416
+ # `dragstart` and never reaches its reorder logic if `dragover` lands in the same turn).
1417
+ # This is what the reference driver's `delay:` between steps buys.
1418
+ dom_call('__csimDragBegin', source_handle, target_handle,
1419
+ {'html5' => html5, 'modifiers' => modifier_flags(drop_modifiers)})
1420
+ settle
1421
+ dom_call('__csimDragMove')
1422
+ settle
1423
+ dom_call('__csimDragFinish')
1222
1424
  drain_after_user_action
1223
1425
  end
1224
1426
  def drop_items(arg)
@@ -1261,7 +1463,8 @@ module Capybara
1261
1463
  alt: 'altKey',
1262
1464
  option: 'altKey',
1263
1465
  meta: 'metaKey',
1264
- command: 'metaKey'
1466
+ command: 'metaKey',
1467
+ cmd: 'metaKey'
1265
1468
  }.freeze
1266
1469
  MODIFIER_KEY_NAMES = MODIFIER_KEYS.keys.to_set.freeze
1267
1470
  def modifier_flags(keys)
@@ -1271,19 +1474,26 @@ module Capybara
1271
1474
  }
1272
1475
  end
1273
1476
 
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.
1477
+ # Resolve the click point against the element's laid-out box — the
1478
+ # same geometry `rect` / `obscured?` / `drag_to` and the page's own
1479
+ # `getBoundingClientRect` read, so a click lands where the page
1480
+ # believes the element is. `opts[:offset] == :center` means x/y are
1481
+ # relative to the element's centre (Capybara's w3c_click_offset
1482
+ # semantics); otherwise they're relative to its top-left, so the
1483
+ # point is the element's own origin plus the offset — including the
1484
+ # 8px body margin a real page has (confirmed in Chrome).
1485
+ #
1486
+ # This is exactly what the unified geometry buys: Capybara's own
1487
+ # click-offset fixture logs `event.clientX - this.getBoundingClientRect()
1488
+ # .left`, which only comes back as the requested offset when the
1489
+ # pointer we synthesize and the rect the page measures are the same
1490
+ # geometry. Two sources disagree by the element's position.
1281
1491
  def click_event_init(handle, keys, opts)
1282
1492
  out = modifier_flags(keys)
1283
1493
  has_xy = opts[:x] || opts[:y]
1284
1494
  center = opts[:offset] == :center || !has_xy
1285
1495
  if has_xy || center
1286
- rect = dom_call('__csimElementRect', handle)
1496
+ rect = dom_call('__csimRect', handle)
1287
1497
  base_x = rect['x'].to_f + (center ? rect['width'].to_f / 2.0 : 0.0)
1288
1498
  base_y = rect['y'].to_f + (center ? rect['height'].to_f / 2.0 : 0.0)
1289
1499
  out['clientX'] = base_x + opts[:x].to_f
@@ -1472,6 +1682,7 @@ module Capybara
1472
1682
  def settle
1473
1683
  start_gen = @runtime.settle_gen
1474
1684
  prev_gen = start_gen
1685
+ worker_wait_deadline = nil
1475
1686
  SETTLE_MAX_ITER.times do
1476
1687
  deliver_event_source_events
1477
1688
  deliver_worker_messages
@@ -1494,6 +1705,28 @@ module Capybara
1494
1705
  deliver_window_messages
1495
1706
  deliver_websocket_events
1496
1707
  break if @runtime.settle_gen > start_gen
1708
+ # A background worker thread owes us a CONTRACTUAL reply (a swack / bcack /
1709
+ # fetch_response is posted under `ensure`, so it always comes) but hasn't posted it
1710
+ # yet. With no timer, `run_loop_step(0)` returns instantly, so busy-spinning the
1711
+ # remaining iterations would STARVE the worker thread of the GVL and it would never
1712
+ # process its inbox. Block briefly on the outbox instead: this releases the GVL (the
1713
+ # worker runs) and wakes the instant it posts. The popped event is parked in
1714
+ # (via `park_worker_reply`, which parks it in `@worker_outbox_head` — NOT pushed back,
1715
+ # which would reorder it behind anything the worker enqueued in the meantime — so the next
1716
+ # deliver drains it first). The budget is shared across the whole settle call, and
1717
+ # exhausting it bails to Capybara's outer poll loop — a genuinely stuck worker must not pin
1718
+ # every find's settle for SETTLE_MAX_ITER budgets. Gated on `worker_reply_pending?`, NOT
1719
+ # `worker_pending?`: `@worker_in_flight` (plain postMessage — a listen-only worker never
1720
+ # replies) and `@worker_initializing` have no matching reply, and blocking on them would
1721
+ # tax every settle on such pages with the full budget.
1722
+ if worker_reply_pending? && @worker_outbox.empty? && @worker_outbox_head.nil?
1723
+ worker_wait_deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + WORKER_ROUND_TRIP_BUDGET
1724
+ park_worker_reply(worker_wait_deadline) while worker_reply_pending? &&
1725
+ @worker_outbox.empty? && @worker_outbox_head.nil? &&
1726
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) < worker_wait_deadline
1727
+ next if @worker_outbox_head || !@worker_outbox.empty?
1728
+ break
1729
+ end
1497
1730
  # No progress this iter (no DOM/URL change observed) — the
1498
1731
  # remaining timers are queued for the future; bail and let
1499
1732
  # Capybara's wall-clock-driven poll loop drive the next tick
@@ -1624,7 +1857,11 @@ module Capybara
1624
1857
  @viewport_width = w.to_i
1625
1858
  @viewport_height = h.to_i
1626
1859
  invalidate_find_cache
1627
- @runtime.eval("globalThis.innerWidth = #{@viewport_width}; globalThis.innerHeight = #{@viewport_height};")
1860
+ # One slot for the viewport (`__csimViewport`): `innerWidth` / `innerHeight` are
1861
+ # `[Replaceable]` accessors over it, the `@media` cascade and the layout engine read it
1862
+ # directly, and the setter re-pushes every live frame's content box — a frame lays out
1863
+ # against its container, which just changed size too.
1864
+ @runtime.eval("globalThis.__csimSetViewport(#{@viewport_width}, #{@viewport_height});")
1628
1865
  # Recompute the cascade `@media` rules against the new
1629
1866
  # viewport so visibility checks (Capybara `visible?`,
1630
1867
  # `getComputedStyle().display`) re-reflect mobile-breakpoint
@@ -1644,8 +1881,13 @@ module Capybara
1644
1881
  @runtime.eval("try { (globalThis.dispatchEvent || function(){})(new Event('resize')); } catch (_) {}")
1645
1882
  nil
1646
1883
  end
1647
- def viewport_width ; @viewport_width || 1024 ; end
1648
- def viewport_height ; @viewport_height || 768 ; end
1884
+ def viewport_width ; @viewport_width || SCREEN_SIZE[0] ; end
1885
+ def viewport_height ; @viewport_height || SCREEN_SIZE[1] ; end
1886
+ # What `maximize` / `fullscreen` restore. A driver configured with a viewport is a mobile
1887
+ # session (`default_viewport`, the same channel `reset!` uses to keep it mobile across
1888
+ # resets), and maximizing must not silently promote it to desktop — its "display" is the
1889
+ # viewport it was built with.
1890
+ def screen_size ; @default_viewport || SCREEN_SIZE ; end
1649
1891
  # Capybara-initiated `page.go_back` runs from Ruby, not inside a
1650
1892
  # JS call, so it's safe to rebuild the Context synchronously. The
1651
1893
  # `force:` flag bypasses the deferral that `history_go` uses to
@@ -2204,6 +2446,17 @@ module Capybara
2204
2446
  # caller's concern — read it separately with `peek_script` (clock-free).
2205
2447
  def run_event_loop_frame(frame_ms)
2206
2448
  turns = 0
2449
+ # Give any live worker/SW thread a clean GVL slice before the phase-1 quiescence loop
2450
+ # monopolises it. That loop spins `run_loop_step(0)` holding the GVL, which STARVES a
2451
+ # worker mid-flight — in particular a CROSS-ISOLATE zero-copy transfer (`RustyRacer.
2452
+ # transferIn` over a SendBackingStore) that a `worker.postMessage(view, [view.buffer])`
2453
+ # reply must complete on the worker thread. Under starvation transferIn fails, the SW's
2454
+ # message handler throws on the null result, and no reply is posted → the client's
2455
+ # `onmessage` never fires and the drain force-timeouts it (postmessage.https transferable
2456
+ # subtests; the whole SW→client message reply cluster). A brief `sleep` releases the GVL
2457
+ # so the worker runs (Thread.pass does NOT hand it over); gated on a live worker so
2458
+ # worker-free files pay nothing.
2459
+ sleep(WORKER_GVL_YIELD) if @workers.any? {|_, w| w[:thread]&.alive? }
2207
2460
  loop do
2208
2461
  r = @runtime.run_loop_step(0) # run only what's due NOW + microtasks + render; no clock advance
2209
2462
  progressed = step_and_drain_progressed(r)
@@ -2211,6 +2464,43 @@ module Capybara
2211
2464
  break unless progressed
2212
2465
  break if turns >= EVENT_LOOP_QUIESCENCE_CAP
2213
2466
  end
2467
+
2468
+ # Interlude — hold the virtual clock while a controlled-client fetch is awaiting the
2469
+ # service worker's `respondWith`. The SW does the real request off-thread (a live network
2470
+ # hop, an in-VM handler) and its reply is delivered Ruby-side, invisible to the JS event-loop
2471
+ # probe. Advancing the clock now (phase 2) would let a caller's virtual-timeout outrun that
2472
+ # off-thread work and mark the still-pending fetch as timed-out before the reply lands. So
2473
+ # block briefly on the worker outbox — releasing the GVL so the worker runs, exactly like
2474
+ # `settle` — and deliver the reply at the current instant, WITHOUT advancing the clock, then
2475
+ # keep pumping. A fetch that never replies is bounded by `@sw_fetch_wait_deadline` (and, past
2476
+ # that, the caller's own max-steps backstop), so it can't wedge the drain.
2477
+ if @sw_fetch_pending.positive? && (held = hold_for_sw_fetch(turns))
2478
+ return held
2479
+ end
2480
+ @sw_fetch_wait_deadline = nil unless @sw_fetch_pending.positive?
2481
+
2482
+ # Same interlude for an in-flight `ws.close()` handshake: the reader thread surfaces the
2483
+ # server's echoed close frame as `__close` — real off-thread work the JS event-loop probe
2484
+ # can't see. Advancing the clock now would let a test's virtual-timeout ("onclose should
2485
+ # fire") outrun it. Block briefly on the WS queue (GVL released, like settle) so the reader
2486
+ # runs, deliver at the current instant WITHOUT advancing time, then keep pumping. Bounded by
2487
+ # a deadline so a peer that never replies (→ EOF 1006) can't wedge the drain.
2488
+ if @ws_close_pending.positive? && (held = hold_for_ws_close(turns))
2489
+ return held
2490
+ end
2491
+ @ws_close_wait_deadline = nil unless @ws_close_pending.positive?
2492
+
2493
+ # Same interlude for a pending SW message swack / broadcast ack — but WITHOUT holding
2494
+ # the clock. Its reply is the same kind of cross-isolate transfer completed on the worker
2495
+ # thread (a `worker.postMessage(view, [view.buffer])` reply zero-copies via transferIn),
2496
+ # so we block briefly on the outbox to give a loaded runner's worker real GVL time to post
2497
+ # it, then fall through to phase 2 — a message reply is delivered at the current instant by
2498
+ # the drain below and the client-side lifecycle / nav timers its test then waits on advance
2499
+ # via the clock, so (unlike a fetch) we must NOT hold: holding regressed about-blank-
2500
+ # replacement. See drain_pending_message_reply.
2501
+ drain_pending_message_reply if worker_message_reply_pending?
2502
+ @sw_msg_wait_deadline = nil unless worker_message_reply_pending?
2503
+
2214
2504
  # Phase 2 — advance one real frame so the next batch of timers becomes due.
2215
2505
  # Its work counts toward `progressed` too: a timer that first comes due in
2216
2506
  # this advance (e.g. a `setTimeout(…, 8)` firing mid-frame) and the nav hop
@@ -2219,7 +2509,9 @@ module Capybara
2219
2509
  probe = dom_call('__csimEventLoopProbe')
2220
2510
  {
2221
2511
  'raf' => !!probe['raf'],
2222
- 'async' => !!probe['async'],
2512
+ # A live WS reader counts as async so the drain loop yields the GVL to it each frame — see
2513
+ # websocket_reader_active?. Without it a binary echo can be starved past the idle-bail.
2514
+ 'async' => !!probe['async'] || websocket_reader_active?,
2223
2515
  # ms until the nearest scheduled timer (-1 = none). Lets a caller keep
2224
2516
  # advancing while a near-future `setTimeout` is parked (a `step_timeout`-
2225
2517
  # style wait) instead of declaring the page idle — see `__csimEventLoopProbe`.
@@ -2230,6 +2522,96 @@ module Capybara
2230
2522
  }
2231
2523
  end
2232
2524
 
2525
+ # Hold the virtual clock for one frame while a controlled-client fetch awaits the SW's
2526
+ # respondWith. Blocks briefly on the worker outbox (GVL released) up to a budget shared across
2527
+ # frames; on a reply, delivers it at the current instant (a zero-advance `run_loop_step`) and
2528
+ # returns the frame's loop-state so the caller keeps pumping without advancing time. Returns
2529
+ # nil once the budget is spent, so the caller falls through to a normal frame (advancing the
2530
+ # clock) and a genuinely stuck fetch can't wedge the drain. Reports the REAL nearest timer —
2531
+ # holding the clock doesn't hide a parked timer, we simply haven't advanced to it yet.
2532
+ private def hold_for_sw_fetch(turns)
2533
+ @sw_fetch_wait_deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + WORKER_ROUND_TRIP_BUDGET
2534
+ park_worker_reply(@sw_fetch_wait_deadline)
2535
+ reply_ready = @worker_outbox_head || !@worker_outbox.empty?
2536
+ # A delivered reply refreshes the budget so the NEXT fetch in a sequence waits afresh instead
2537
+ # of inheriting a spent deadline (which would abandon it to a premature timeout).
2538
+ @sw_fetch_wait_deadline = nil if reply_ready
2539
+ return nil unless reply_ready || Process.clock_gettime(Process::CLOCK_MONOTONIC) < @sw_fetch_wait_deadline
2540
+
2541
+ held_progressed = reply_ready && step_and_drain_progressed(@runtime.run_loop_step(0))
2542
+ probe = dom_call('__csimEventLoopProbe')
2543
+ {
2544
+ 'raf' => !!probe['raf'],
2545
+ 'async' => true,
2546
+ 'next_timer' => probe['nextTimer'].to_f,
2547
+ 'progressed' => turns > 1 || held_progressed
2548
+ }
2549
+ end
2550
+
2551
+ # Hold the virtual clock while a `ws.close()` handshake completes. Mirrors hold_for_sw_fetch,
2552
+ # but the reply arrives on the WS queue (the reader thread), not the worker outbox: park
2553
+ # briefly on that queue (GVL released, so the reader runs) and, once a frame is ready, deliver
2554
+ # it at the current instant (`run_loop_step(0)` → deliver_websocket_events clears the counter)
2555
+ # WITHOUT advancing time. Returns the frame-probe hash to hold; nil only once the deadline is
2556
+ # spent with nothing delivered, so the caller falls through to phase 2 and the clock resumes.
2557
+ private def hold_for_ws_close(turns)
2558
+ @ws_close_wait_deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + WORKER_ROUND_TRIP_BUDGET
2559
+ if @websocket_queue_head.nil? && @websocket_queue.empty? && Process.clock_gettime(Process::CLOCK_MONOTONIC) < @ws_close_wait_deadline
2560
+ # Pop one event to block until the reader produces something, then park it in the one-slot
2561
+ # HEAD buffer — NOT pushed back onto the tail, which would reorder it behind anything the
2562
+ # reader enqueued in the meantime. deliver_websocket_events drains the head first. Mirrors
2563
+ # park_worker_reply.
2564
+ @websocket_queue_head = pop_with_timeout(@websocket_queue, WORKER_POLL_INTERVAL)
2565
+ end
2566
+ ready = !@websocket_queue_head.nil? || !@websocket_queue.empty?
2567
+ # A delivered frame refreshes the budget so the NEXT close in a sequence waits afresh.
2568
+ @ws_close_wait_deadline = nil if ready
2569
+ return nil unless ready || Process.clock_gettime(Process::CLOCK_MONOTONIC) < @ws_close_wait_deadline
2570
+
2571
+ held_progressed = ready && step_and_drain_progressed(@runtime.run_loop_step(0))
2572
+ probe = dom_call('__csimEventLoopProbe')
2573
+ {
2574
+ 'raf' => !!probe['raf'],
2575
+ 'async' => true,
2576
+ 'next_timer' => probe['nextTimer'].to_f,
2577
+ 'progressed' => turns > 1 || held_progressed
2578
+ }
2579
+ end
2580
+
2581
+ # Park briefly (GVL released) for an outstanding SW message swack / broadcast ack and deliver
2582
+ # it at the current instant, then RETURN — the caller proceeds to phase 2 and advances the
2583
+ # clock. Unlike `hold_for_sw_fetch` this does NOT hold time: a message/broadcast reply's test
2584
+ # advances its client-side lifecycle / nav timers via the clock, so holding on it deadlocks
2585
+ # (regressed about-blank-replacement). The only thing missing under load is worker GVL time for
2586
+ # the cross-isolate transferable reply to complete — a fixed micro-sleep isn't enough margin on
2587
+ # a loaded runner (postmessage.https transferable subtests), so we block on the outbox exactly
2588
+ # like the fetch hold / `settle`. Budget shared across frames (a genuinely stuck reply pays it
2589
+ # once — the spent deadline stays in the past, so later frames don't re-block and the clock runs
2590
+ # free until the reply lands or the test times out); reset in `run_event_loop_frame` once no
2591
+ # message/broadcast reply is outstanding, so the next one waits afresh.
2592
+ private def drain_pending_message_reply
2593
+ @sw_msg_wait_deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + WORKER_ROUND_TRIP_BUDGET
2594
+ park_worker_reply(@sw_msg_wait_deadline) while worker_message_reply_pending? &&
2595
+ @worker_outbox.empty? && @worker_outbox_head.nil? &&
2596
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) < @sw_msg_wait_deadline
2597
+ # `deliver_worker_messages` (inside step_and_drain) refreshes @sw_msg_wait_deadline whenever it
2598
+ # delivers a swack/bcack, so the NEXT reply in a sequence waits afresh — and it does so on
2599
+ # WHICHEVER drain path delivered the reply (this one, hold_for_sw_fetch's outbox drain, or
2600
+ # settle), which resetting only here would miss when a co-pending fetch hold delivers it.
2601
+ step_and_drain_progressed(@runtime.run_loop_step(0)) if @worker_outbox_head || !@worker_outbox.empty?
2602
+ end
2603
+
2604
+ # Block up to one poll interval for a worker reply, parking it in the one-slot head buffer —
2605
+ # NOT pushed back, which would reorder it behind anything the worker enqueued meanwhile. The
2606
+ # `pop_with_timeout` releases the GVL so the worker thread runs and wakes us the instant it
2607
+ # posts. No-op if a reply is already buffered or the budget is spent. Shared by `settle` and
2608
+ # the SW-fetch hold in `run_event_loop_frame`.
2609
+ private def park_worker_reply(deadline)
2610
+ return unless @worker_outbox.empty? && @worker_outbox_head.nil? &&
2611
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
2612
+ @worker_outbox_head = pop_with_timeout(@worker_outbox, WORKER_POLL_INTERVAL)
2613
+ end
2614
+
2233
2615
  # Drain the Ruby-side async / navigation / form-submit / download chains a
2234
2616
  # `run_loop_step` (passed as `r`) may have queued, and report whether this
2235
2617
  # step+drain made observable progress. Shared by both phases of
@@ -2371,6 +2753,9 @@ module Capybara
2371
2753
  @last_polled_gen = nil
2372
2754
  @idle_settle_polls = 0
2373
2755
  @ff_transient_polls = 0
2756
+ # Disarm the find pre-tick so the FIRST find after this navigation reads the current DOM
2757
+ # without advancing timers (see timer_wait_elapsed?), independent of wall-clock timing.
2758
+ @pre_tick_armed = false
2374
2759
  @context_gen += 1
2375
2760
  end
2376
2761
 
@@ -2389,7 +2774,7 @@ module Capybara
2389
2774
  method = spec['method'].to_s.upcase
2390
2775
  method = 'GET' if method.empty?
2391
2776
  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)
2777
+ entries = entry_list.is_a?(Array) ? entry_list : (spec['entries'] || [])
2393
2778
  action_url = action.empty? ? (current_browsing_context_url || @default_host) : resolve_against_current(action)
2394
2779
  # A form submitted inside a frame whose target is that frame (self, or a
2395
2780
  # `_parent` of a ≥2-deep frame) navigates the FRAME, not the top page.
@@ -2517,35 +2902,6 @@ module Capybara
2517
2902
  picks && picks[entry['index'].to_i]
2518
2903
  end
2519
2904
 
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
2905
  def append_multipart_part(body, boundary, name, content, filename: nil, content_type: nil)
2550
2906
  body << "--#{boundary}\r\n"
2551
2907
  disposition = %[form-data; name="#{name}"]
@@ -2569,7 +2925,7 @@ module Capybara
2569
2925
  env['CONTENT_LENGTH'] = body.bytesize.to_s
2570
2926
  apply_default_request_env(env, referer: referer)
2571
2927
  status, headers, resp_body = dispatch_rack_or_http(url, env, method: 'POST', body: body)
2572
- merge_set_cookie(headers)
2928
+ merge_set_cookie(headers, url)
2573
2929
  if (loc = redirect_location(status, headers))
2574
2930
  next_url = resolve_against_current(loc)
2575
2931
  resp_body.close if resp_body.respond_to?(:close)
@@ -2598,7 +2954,9 @@ module Capybara
2598
2954
 
2599
2955
  def reset!
2600
2956
  @cookies.clear
2957
+ @auth_cache.clear
2601
2958
  @local_storage.clear
2959
+ @cache_storage.clear
2602
2960
  @session_storage.clear
2603
2961
  @sticky_headers.clear
2604
2962
  # The driver-side resize buffer has to clear too — without
@@ -2650,6 +3008,11 @@ module Capybara
2650
3008
  reset_websockets
2651
3009
  @window_inbox.clear
2652
3010
  @broadcast_inbox.clear
3011
+ # The BroadcastChannel registry + ordered queue are per-page (the rebuilt VM has no live channels
3012
+ # and restarts the realm/local id counters); a stale entry would misroute a later post.
3013
+ @bc_registry.clear
3014
+ @bc_queue.clear
3015
+ @bc_seq = 0
2653
3016
  # Free any zero-copy transfer backing stores that went unimported
2654
3017
  # (worker killed before draining its inbox, etc.) before the rebuild.
2655
3018
  drop_pending_transfers
@@ -2683,6 +3046,8 @@ module Capybara
2683
3046
  reset_websockets
2684
3047
  @window_inbox.clear
2685
3048
  @broadcast_inbox.clear
3049
+ @bc_registry.clear
3050
+ @bc_queue.clear
2686
3051
  # Dispose the JS runtime/isolate itself — for an auxiliary window this
2687
3052
  # Browser is the isolate's last owner, but V8Runtime registers every
2688
3053
  # isolate in a process-wide `@@live` set (for at_exit cleanup), which
@@ -2734,6 +3099,27 @@ module Capybara
2734
3099
  @@asset_src_lock = Mutex.new
2735
3100
  ASSET_SRC_MAX = 4096
2736
3101
 
3102
+ # Decoded-image cache: resolved-URL => {'width'=>, 'height'=>, 'bytes'=> packed
3103
+ # RGBA String}. Decoding an image (libvips) is the expensive step, so — like the
3104
+ # V8 bytecode cache and the script/stylesheet source cache above — we keep the
3105
+ # decoded pixels and reuse them for every `<img>` sharing a src, across elements
3106
+ # AND visits. Same content-stability assumption as `@@asset_src` (a URL's bytes
3107
+ # are stable within a process; content-hashed / data: URLs that dominate satisfy
3108
+ # it); size-capped so an app cycling through many distinct images can't grow it
3109
+ # without bound.
3110
+ @@image_cache = {}
3111
+ @@image_cache_lock = Mutex.new
3112
+ IMAGE_CACHE_MAX = 512
3113
+
3114
+ # Cross-visit cache of @font-face font files, resolved-url → on-disk path (or nil
3115
+ # when the fetch failed). The bytes are written to a process-lifetime temp file so
3116
+ # pango/fontconfig (via `Vips::Image.text fontfile:`) can read them by path. Font
3117
+ # URLs are content-stable app assets, so caching across the per-visit VM rebuild
3118
+ # avoids re-fetching CanvasTest.ttf & friends on every visit.
3119
+ @@font_file_cache = {}
3120
+ @@font_file_lock = Mutex.new
3121
+ @@font_files = [] # pins the Tempfiles for the PROCESS (the cache is cross-visit)
3122
+
2737
3123
  # Body of an external durably-cacheable asset (classic script or stylesheet),
2738
3124
  # served from the cross-visit cache when still fresh, else fetched (which
2739
3125
  # read-throughs the per-visit asset cache) and cached iff durably cacheable.
@@ -2908,12 +3294,10 @@ module Capybara
2908
3294
  'Cache-Control: no-store',
2909
3295
  'Connection: keep-alive'
2910
3296
  ]
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
3297
+ # Forward the streaming host's cookie jar so the server can
3298
+ # authenticate the user the same way the browser would — scoped to
3299
+ # the EventSource target's host, like every other request.
3300
+ cookies = cookie_header_for(cookie_host(uri))
2917
3301
  lines << "Cookie: #{cookies}" unless cookies.empty?
2918
3302
  socket.write(lines.join("\r\n") << "\r\n\r\n")
2919
3303
  socket.flush
@@ -3011,6 +3395,10 @@ module Capybara
3011
3395
  env['HTTP_CONNECTION'] = 'Upgrade'
3012
3396
  env['HTTP_SEC_WEBSOCKET_KEY'] = key
3013
3397
  env['HTTP_SEC_WEBSOCKET_VERSION'] = '13'
3398
+ # The opening handshake always carries the initiating document's origin (the UA owns this
3399
+ # header) — server handlers echo it back (websockets/opening-handshake origin test).
3400
+ doc_origin = url_origin(@current_url)
3401
+ env['HTTP_ORIGIN'] = doc_origin if doc_origin
3014
3402
  list = Array(protocols).map(&:to_s).reject(&:empty?)
3015
3403
  env['HTTP_SEC_WEBSOCKET_PROTOCOL'] = list.join(', ') unless list.empty?
3016
3404
  env['rack.hijack?'] = true
@@ -3030,7 +3418,7 @@ module Capybara
3030
3418
  queue = @websocket_queue
3031
3419
  @websocket_threads[id] = Thread.new do
3032
3420
  Thread.current.report_on_exception = false
3033
- run_websocket_reader(id, csim_io, accept, queue)
3421
+ run_websocket_reader(id, csim_io, accept, queue, target)
3034
3422
  end
3035
3423
  id
3036
3424
  rescue StandardError => e
@@ -3059,24 +3447,58 @@ module Capybara
3059
3447
  nil
3060
3448
  end
3061
3449
 
3062
- def ws_close(id, code = 1000, reason = '')
3450
+ def ws_close(id, code = nil, reason = '')
3063
3451
  sock = @websocket_sockets[id.to_i] or return
3064
3452
  # Send the close frame and let the close HANDSHAKE complete: the server
3065
3453
  # replies with its own close frame, which the reader thread surfaces as
3066
3454
  # the `__close` event (carrying the agreed code) before tearing the
3067
3455
  # socket down in its `ensure`. Force teardown is `reset_websockets`'s job.
3068
- payload = [code.to_i].pack('n') + reason.to_s.b
3456
+ # A nil code sends a BODYLESS close frame — `ws.close()` with no argument
3457
+ # closes with no status, which the peer echoes and the reader reports as
3458
+ # code 1005 (NO_STATUS), not 1000.
3459
+ payload = code.nil? ? ''.b : [code.to_i].pack('n') + reason.to_s.b
3069
3460
  ws_write_frame(sock, 0x8, payload) rescue nil
3461
+ # A close handshake is now in flight — hold the clock until the reader's `__close` lands
3462
+ # (bounded by the deadline in hold_for_ws_close if the peer never replies → EOF 1006).
3463
+ @ws_close_pending += 1
3070
3464
  nil
3071
3465
  end
3072
3466
 
3073
- def websocket_pending? = !@websocket_queue.empty?
3467
+ # Pending WS work = queued reader events OR an in-flight `ws.close()` whose `__close` hasn't
3468
+ # landed yet. Counting the latter keeps every advance path (settle, tick_real_time, and
3469
+ # crucially horizon_fast_forward_step, which would otherwise jump straight to a test's pending
3470
+ # timeout timer) from racing past the close handshake before hold_for_ws_close delivers it.
3471
+ def websocket_pending? = !@websocket_queue_head.nil? || !@websocket_queue.empty? || @ws_close_pending.positive?
3472
+
3473
+ # A live reader thread = an open WS connection whose server may still surface an echo / push /
3474
+ # close frame off-thread — pending async work the JS event-loop probe can't see. The event-loop
3475
+ # frame reports it as `async` so the WPT drain loop yields the GVL each frame (its
3476
+ # `sleep(0.001) if async`), feeding the reader instead of spinning it into starvation — a
3477
+ # binary echo that only lands AFTER the idle-bail is exactly the flake this prevents. Zero-alloc
3478
+ # on the common no-WS path (the `empty?` short-circuit); a handful of threads otherwise.
3479
+ def websocket_reader_active? = !@websocket_threads.empty? && @websocket_threads.each_value.any?(&:alive?)
3074
3480
 
3075
3481
  def deliver_websocket_events
3076
- return 0 if @websocket_threads.empty? && @websocket_queue.empty?
3077
- events = drain_queue(@websocket_queue)
3482
+ return 0 if @websocket_threads.empty? && @websocket_queue_head.nil? && @websocket_queue.empty?
3483
+ # The head slot (parked by hold_for_ws_close) is delivered ahead of the queue to preserve
3484
+ # reader order.
3485
+ events = [@websocket_queue_head].compact
3486
+ @websocket_queue_head = nil
3487
+ events.concat(drain_queue(@websocket_queue))
3078
3488
  return 0 if events.empty?
3079
- @runtime.call('__csim_deliverWebSocketEvents', events)
3489
+ # `__setcookie` is handled Ruby-side (store the handshake cookie in the jar) and NOT
3490
+ # forwarded to JS; the reader queues it before `__open`, so document.cookie sees it in onopen.
3491
+ js_events = events.reject do |e|
3492
+ if e[:type] == '__setcookie'
3493
+ merge_set_cookie({'set-cookie' => e[:cookies]}, e[:url])
3494
+ true
3495
+ end
3496
+ end
3497
+ @runtime.call('__csim_deliverWebSocketEvents', js_events) unless js_events.empty?
3498
+ # A terminal event (`__close` / `__error`) completes a close handshake — release the clock
3499
+ # hold. Clamp: a server-INITIATED close arrives without a matching ws_close increment.
3500
+ terminals = events.count {|e| e[:type] == '__close' || e[:type] == '__error' }
3501
+ @ws_close_pending = [@ws_close_pending - terminals, 0].max if terminals.positive?
3080
3502
  events.size
3081
3503
  end
3082
3504
 
@@ -3091,23 +3513,36 @@ module Capybara
3091
3513
  @websocket_sockets.clear
3092
3514
  @websocket_app_sockets.clear
3093
3515
  @websocket_queue.clear
3516
+ @websocket_queue_head = nil
3517
+ @ws_close_pending = 0
3518
+ @ws_close_wait_deadline = nil
3094
3519
  end
3095
3520
 
3096
3521
  # Background-thread frame reader: verify the 101 handshake, then loop
3097
3522
  # 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)
3523
+ private def run_websocket_reader(id, sock, expected_accept, queue, target)
3524
+ ok, protocol, cookies = ws_read_handshake(sock, expected_accept)
3100
3525
  unless ok
3101
3526
  queue << {id: id, type: '__error', message: 'websocket handshake failed'}
3102
3527
  return
3103
3528
  end
3529
+ # Store any handshake-response cookies (the main thread does the store) BEFORE `open` fires,
3530
+ # so a document.cookie read in `onopen` sees them.
3531
+ queue << {id: id, type: '__setcookie', cookies: cookies, url: target} unless cookies.empty?
3104
3532
  # Carry the negotiated subprotocol — Action Cable's client closes the
3105
3533
  # connection in its `onopen` unless `webSocket.protocol` is one it knows
3106
3534
  # (`actioncable-v1-json`).
3107
3535
  queue << {id: id, type: '__open', protocol: protocol}
3108
3536
  loop do
3109
3537
  frame = ws_read_message(sock, queue, id)
3110
- break if frame.nil? # EOF
3538
+ if frame.nil? # TCP closed with no close frame → abnormal
3539
+ queue << {id: id, type: '__close', code: 1006, reason: ''}
3540
+ break
3541
+ end
3542
+ if frame == :protocol_error # reserved opcode / malformed frame → fail the connection
3543
+ queue << {id: id, type: '__error', message: 'protocol error'}
3544
+ break
3545
+ end
3111
3546
  opcode, payload = frame
3112
3547
  if opcode == :close
3113
3548
  code = payload.bytesize >= 2 ? payload[0, 2].unpack1('n') : 1005
@@ -3139,9 +3574,10 @@ module Capybara
3139
3574
  # is set (Action Cable's client requires `actioncable-v1-json`).
3140
3575
  private def ws_read_handshake(sock, expected_accept)
3141
3576
  status = sock.gets
3142
- return [false, nil] unless status && status =~ %r{\AHTTP/1\.1 101}i
3577
+ return [false, nil, []] unless status && status =~ %r{\AHTTP/1\.1 101}i
3143
3578
  accept_ok = false
3144
3579
  protocol = nil
3580
+ cookies = []
3145
3581
  while (line = sock.gets)
3146
3582
  line = line.chomp
3147
3583
  break if line.empty?
@@ -3154,13 +3590,18 @@ module Capybara
3154
3590
  # JS Uint8Array — the protocol must reach JS as a real string so
3155
3591
  # `webSocket.protocol` compares equal to `actioncable-v1-json`.
3156
3592
  protocol = RuntimeShared.utf8_text(val) if key == 'sec-websocket-protocol' && !val.empty?
3593
+ # Cookies the server sets on the handshake response are stored in the jar (the main thread
3594
+ # does the actual store — see deliver_websocket_events), so document.cookie / the next
3595
+ # request's Cookie header reflect them.
3596
+ cookies << RuntimeShared.utf8_text(val) if key == 'set-cookie' && !val.empty?
3157
3597
  end
3158
- [accept_ok, protocol]
3598
+ [accept_ok, protocol, cookies]
3159
3599
  end
3160
3600
 
3161
3601
  # Read one complete message (reassembling continuation frames), handling
3162
3602
  # interleaved control frames inline. Returns `[opcode, payload]` (opcode
3163
- # 0x1 text / 0x2 binary, or `:close`), or nil on EOF.
3603
+ # 0x1 text / 0x2 binary), `[:close, payload]`, `:protocol_error` (a reserved
3604
+ # opcode — the connection must be failed), or nil on EOF.
3164
3605
  private def ws_read_message(sock, queue, id)
3165
3606
  data = +''.b
3166
3607
  msg_opcode = nil
@@ -3193,7 +3634,8 @@ module Capybara
3193
3634
  when 0x9 then ws_write_frame(sock, 0xA, payload); next # ping → pong
3194
3635
  when 0xA then next # pong → ignore
3195
3636
  when 0x0 then data << payload # continuation
3196
- else msg_opcode = opcode; data << payload # 0x1 text / 0x2 binary
3637
+ when 0x1, 0x2 then msg_opcode = opcode; data << payload # text / binary
3638
+ else return :protocol_error # reserved opcode (0x3-7, 0xB-F) → fail
3197
3639
  end
3198
3640
  return [msg_opcode || opcode, data] if fin
3199
3641
  end
@@ -3425,7 +3867,7 @@ module Capybara
3425
3867
  # worker's `__csim_workerPostMessage` host fn closes over its
3426
3868
  # handle and routes outgoing messages onto a shared outbox the
3427
3869
  # main settle drains.
3428
- def worker_spawn(url, shared: false, service: false)
3870
+ def worker_spawn(url, shared: false, service: false, creator_key: nil, realm_id: 0, controller_handle: 0)
3429
3871
  handle = (@worker_seq += 1)
3430
3872
  target = resolve_against_current(url.to_s)
3431
3873
  # A worker script from a blob: URL in a DIFFERENT storage partition than this
@@ -3450,11 +3892,37 @@ module Capybara
3450
3892
  return worker_fail(handle, 'Worker script could not be loaded') if target.start_with?('blob:') && body.to_s.empty?
3451
3893
  # Pending until the worker's initial script has run (see @worker_initializing).
3452
3894
  @worker_init_lock.synchronize { @worker_initializing += 1 }
3895
+ # A SERVICE worker may call `clients.matchAll()` while its script is still EVALUATING —
3896
+ # before install, before any inbox drain — so its mirror has to be populated before the
3897
+ # script runs. Snapshot it here, on the main thread that owns the registry, and let
3898
+ # run_worker inject it pre-eval. The FOCUS chain rides along for the same reason
3899
+ # `seed_client_mirror` pairs the two: `focused` is browser state the worker isolate cannot
3900
+ # ask for, so without it an early `matchAll` reports every client unfocused and returns
3901
+ # them in creation rather than focus-first order.
3902
+ seed = service ? {clients: sw_client_records_for(handle), focused: focused_client_ids} : nil
3453
3903
  thread = Thread.new do
3454
3904
  Thread.current.report_on_exception = false
3455
- run_worker(handle, target, body, inbox, outbox, engine_class, shared: shared, service: service)
3905
+ run_worker(
3906
+ handle, target, body, inbox, outbox, engine_class,
3907
+ shared: shared,
3908
+ service: service,
3909
+ creator_key: creator_key,
3910
+ seed: seed
3911
+ )
3912
+ end
3913
+ # `service:` marks a SERVICE worker. The client mirror is pushed to every service worker
3914
+ # (a client belongs to the ORIGIN; `controlled` only says whether a given worker controls
3915
+ # it), and a dedicated/shared worker has no client registry to push into.
3916
+ # `realm:` is the browsing context that created this worker — a dedicated worker belongs to
3917
+ # it and is terminated when it is discarded (terminate_realm_workers).
3918
+ @workers[handle] = {thread: thread, inbox: inbox, service: service, realm: realm_id.to_i}
3919
+ # A dedicated / shared worker is a client of its ORIGIN — type 'worker' / 'sharedworker',
3920
+ # frameType 'none' — whether or not a service worker's scope covers its script; only the
3921
+ # `controlled` flag turns on that scope match, exactly as it does for a browsing context.
3922
+ # A service worker is not itself a client of anything.
3923
+ unless service
3924
+ sw_note_worker_client(handle, target, shared, worker_controller_handle(target, shared, controller_handle))
3456
3925
  end
3457
- @workers[handle] = {thread: thread, inbox: inbox}
3458
3926
  handle
3459
3927
  end
3460
3928
 
@@ -3470,14 +3938,694 @@ module Capybara
3470
3938
  def worker_post_to_worker(handle, data)
3471
3939
  w = @workers[handle.to_i]
3472
3940
  return unless w
3941
+ # Counted globally (what settle reads) AND per worker, so a worker that dies still owing
3942
+ # replies can hand back exactly what it holds. A LISTEN-ONLY worker never answers at all,
3943
+ # so without the per-worker tally its share is only released by the reset that fires when
3944
+ # the LAST worker goes — which never happens while a service worker is registered.
3473
3945
  @worker_in_flight += 1
3946
+ w[:in_flight] = w[:in_flight].to_i + 1
3474
3947
  w[:inbox] << data.to_s
3475
3948
  end
3476
3949
 
3950
+ # `ServiceWorker.postMessage` from a client window → deliver to the SW's `message` event with
3951
+ # `source` = the posting client. Tracked in @sw_message_pending (released by the worker's
3952
+ # `swack`) so settle waits for the SW to process it and any client.postMessage reply.
3953
+ def service_worker_post_message(handle, data, client_id = nil, client_url = nil)
3954
+ w = @workers[handle.to_i]
3955
+ return unless w
3956
+ @sw_message_pending += 1
3957
+ w[:inbox] << {kind: 'sw_message', data: data.to_s, client: client_id, url: client_url}
3958
+ end
3959
+
3960
+ # A controlled client's fetch → the controlling SW's `fetch` event. Tracked in
3961
+ # @sw_fetch_pending (released by the `fetch_response`) so settle waits for the SW's
3962
+ # respondWith. If the handle is dead, return false so the client falls back to the network.
3963
+ def service_worker_controller_fetch(handle, req_json, fetch_id, realm_id = 0)
3964
+ w = @workers[handle.to_i]
3965
+ return false unless w
3966
+ # Resolve BEFORE bumping the pending counter: a raise here must not strand @sw_fetch_pending
3967
+ # (settle would then block for the full round-trip budget with no fetch ever queued to answer).
3968
+ req = resolve_sw_fetch_referrer(req_json.to_s)
3969
+ @sw_fetch_pending += 1
3970
+ # fetch ids are per-realm (so they collide across realms) — carry the ORIGINATING realm so
3971
+ # the response is delivered back to it, not the main realm (realm 0 = main/top window).
3972
+ w[:inbox] << {kind: 'fetch', req:, fetch_id: fetch_id.to_i, realm_id: realm_id.to_i}
3973
+ true
3974
+ end
3975
+
3976
+ # A controlled client cancelled a streaming respondWith body (`response.body.cancel()` or an
3977
+ # AbortController abort): route the cancel to the worker that owns this [realm, fetch] stream so
3978
+ # it cancels the reader it's draining — firing the SW source stream's `cancel()`. @sw_open_streams
3979
+ # maps the stream to its emitting worker; the worker's terminal frame still clears the counter.
3980
+ def sw_stream_cancel(fetch_id, realm_id)
3981
+ key = [realm_id.to_i, fetch_id.to_i]
3982
+ handle, = @sw_open_streams.find {|_h, streams| streams.key?(key) }
3983
+ return unless handle && (w = @workers[handle])
3984
+ w[:inbox] << {kind: 'fetch_cancel', fetch_id: fetch_id.to_i}
3985
+ end
3986
+
3987
+ # Resolve a controlled fetch's referrer the way the network hop would (compute_referrer
3988
+ # applies the request's Referrer-Policy to its referrer source), so the SW's
3989
+ # `event.request.referrer` matches a real browser's. The client sends the referrer SOURCE
3990
+ # (`referrerSource`, its document URL for the `about:client` default); we replace it with the
3991
+ # policy-resolved value under `referrer` (nil / stripped → '', the no-referrer state). No
3992
+ # source (older payload / navigation request) → passed through untouched.
3993
+ private def resolve_sw_fetch_referrer(req_json)
3994
+ req = JSON.parse(req_json)
3995
+ return req_json unless req.is_a?(Hash) && req.key?('referrerSource')
3996
+
3997
+ req['referrer'] = compute_referrer(req['referrerPolicy'], req.delete('referrerSource'), req['url']).to_s
3998
+ JSON.generate(req)
3999
+ rescue JSON::ParserError
4000
+ req_json
4001
+ end
4002
+
4003
+ # A SW `fetch` event's respondWith result. A NAVIGATION fetch (negative id — see
4004
+ # service_worker_navigation_fetch) is awaited SYNCHRONOUSLY on a dedicated queue, off the
4005
+ # general outbox, so it never interleaves with the client-fetch / message reply protocol;
4006
+ # a client fetch (positive id) rides the outbox as before, tagged with the originating realm.
4007
+ private def sw_deliver_fetch_response(handle, fetch_id, resp, outbox, realm_id = 0)
4008
+ if fetch_id.negative?
4009
+ @sw_nav_outbox << {fetch_id: fetch_id, resp: resp}
4010
+ else
4011
+ outbox << {handle: handle, kind: 'fetch_response', fetch_id: fetch_id, resp: resp, realm_id: realm_id}
4012
+ end
4013
+ end
4014
+
4015
+ # The active worker handle at an EXACT scope (0 if none) — see __csim_swActiveHandleForScope.
4016
+ def sw_active_handle_for_scope(scope) = @sw_registrations[scope.to_s].to_i
4017
+
4018
+ # Does `handle` still control any client? HTML's "try activate" holds an installed worker in
4019
+ # the WAITING slot for exactly as long as the outgoing worker has controllees — that is what
4020
+ # makes `registration.waiting` non-null, which is how every "a new version is available"
4021
+ # banner detects an update.
4022
+ def sw_worker_controls_clients?(handle)
4023
+ h = handle.to_i
4024
+ return false if h.zero?
4025
+
4026
+ @sw_clients.any? {|_id, entry| entry[:handle] == h }
4027
+ end
4028
+
4029
+ # Mirror a registration's active-worker handle into Ruby, keyed by its (serialized) scope.
4030
+ # Emitted by the client lifecycle at activation; survives rebuild_ctx so a navigation can
4031
+ # find its controlling SW even after the destination realm's JS was rebuilt.
4032
+ def sw_register_scope(scope, handle)
4033
+ # A service worker we haven't seen before starts with an EMPTY client mirror, and every
4034
+ # context that already existed is one of its clients (a client belongs to the origin).
4035
+ # Gating on "this handle is new" rather than on the registry being empty is what makes a
4036
+ # SECOND registration see them too.
4037
+ fresh = !@sw_registrations.value?(handle.to_i)
4038
+ @sw_registrations[scope.to_s] = handle.to_i
4039
+ seed_client_mirror(handle.to_i) if fresh
4040
+ # Flush any clients.claim() that arrived before this scope was mirrored (a worker's
4041
+ # `activate → clients.claim()` fires decoupled from the client-side lifecycle that populates
4042
+ # @sw_registrations, so the claim can be drained first — see the claim handler above).
4043
+ if @sw_pending_claims.any? {|e| e[:handle].to_i == handle.to_i }
4044
+ flush, @sw_pending_claims = @sw_pending_claims.partition {|e| e[:handle].to_i == handle.to_i }
4045
+ flush.each {|e| broadcast_claim(e[:handle], e[:has_fetch], scope.to_s) }
4046
+ end
4047
+ nil
4048
+ end
4049
+
4050
+ # Every known client as `handle` sees it — `controlled` is per-worker, so it is decided here
4051
+ # rather than at each call site.
4052
+ private def sw_client_records_for(handle)
4053
+ @sw_clients.each_value.map {|entry| entry[:rec].merge('controlled' => entry[:handle] == handle) }
4054
+ end
4055
+
4056
+ # Fill a newly-registered service worker's client mirror. Two sources, because a client's
4057
+ # record has two possible authors: a browsing context describes ITSELF (only the realm knows
4058
+ # its URL / frame type / controller), while a worker client has no such voice and is only in
4059
+ # the host registry. Replay the registry first, then ask the realms — a realm's own report
4060
+ # simply refreshes its record.
4061
+ private def seed_client_mirror(handle)
4062
+ if (w = @workers[handle])
4063
+ sw_client_records_for(handle).each do |rec|
4064
+ w[:inbox] << {kind: 'client_register', client: rec}
4065
+ end
4066
+ focused = focused_client_ids
4067
+ w[:inbox] << {kind: 'client_focus', ids: focused} if focused.any?
4068
+ end
4069
+ request_client_reports
4070
+ end
4071
+
4072
+ # Ask every live browsing context to announce itself as a service-worker client. Each realm
4073
+ # reports its own URL / frame type / controller (js/src/sw-client.js), so nothing here has
4074
+ # to model what a realm is — the same broadcast shape as a claim.
4075
+ private def request_client_reports = broadcast_to_realms('__csim_swReportClient')
4076
+
4077
+ # Call a host fn in EVERY live browsing context — the main realm and each frame/window realm.
4078
+ # Service-worker registration state is per-realm (each has its own registration objects), so
4079
+ # anything that changes it has to reach all of them.
4080
+ private def broadcast_to_realms(fn, *args)
4081
+ @runtime.call(fn, *args) rescue nil
4082
+ return nil unless @runtime.respond_to?(:frame_realm_ids)
4083
+
4084
+ @runtime.frame_realm_ids.each do |rid|
4085
+ @runtime.realm_call(rid, fn, *args) if @runtime.frame_realm_alive?(rid)
4086
+ rescue StandardError
4087
+ nil
4088
+ end
4089
+ nil
4090
+ end
4091
+
4092
+ # Deliver a clients.claim() to EVERY in-scope client: broadcast to the main realm AND every
4093
+ # frame realm; each self-checks whether its own document is in the claiming registration's
4094
+ # scope (__csim_swClaimClient) so no realm→URL map is needed here. has_fetch = the SW's
4095
+ # install-time fetch-listener snapshot, so a claimed client routes its fetches.
4096
+ private def broadcast_claim(handle, has_fetch, scope)
4097
+ script_url = @workers.dig(handle.to_i, :script_url).to_s
4098
+ # The authoritative registration scope set — the claim's longest-registration-wins check runs
4099
+ # against this, not a realm-local map that can lag under load (claim-not-using-registration).
4100
+ all_scopes = @sw_registrations.keys
4101
+ @runtime.call('__csim_swClaimClient', handle, has_fetch, script_url, scope, all_scopes)
4102
+ @runtime.frame_realm_ids.each do |rid|
4103
+ @runtime.realm_call(rid, '__csim_swClaimClient', handle, has_fetch, script_url, scope, all_scopes) if @runtime.frame_realm_alive?(rid)
4104
+ end
4105
+ nil
4106
+ end
4107
+ # Navigation Preload state for a registration's active worker (keyed by its handle). Returns the
4108
+ # spec default {enabled:false, headerValue:'true'} when never set. Read by the client- and
4109
+ # worker-side NavigationPreloadManager (getState) and at navigation time (nav_preload_enabled?).
4110
+ def nav_preload_state(handle)
4111
+ st = @sw_navpreload[handle.to_i] || {}
4112
+ {'enabled' => st.fetch(:enabled, false), 'headerValue' => st.fetch(:header, 'true')}
4113
+ end
4114
+
4115
+ # Update the state for a worker handle. A nil `enabled` / `header` leaves that field unchanged
4116
+ # (enable/disable set only enabled; setHeaderValue sets only the header — the JS side has already
4117
+ # validated the header value and String()-ified it). The InvalidStateError "no active worker"
4118
+ # gate lives in the JS manager (a null handle never reaches here).
4119
+ def nav_preload_set(handle, enabled, header)
4120
+ st = (@sw_navpreload[handle.to_i] ||= {})
4121
+ st[:enabled] = !!enabled unless enabled.nil?
4122
+ st[:header] = header.to_s unless header.nil?
4123
+ nil
4124
+ end
4125
+
4126
+ # Whether the registration whose active worker controls `url` has navigation preload enabled —
4127
+ # gates the parallel preload request during a navigation.
4128
+ def nav_preload_enabled?(handle)
4129
+ handle && @sw_navpreload.dig(handle.to_i, :enabled) ? true : false
4130
+ end
4131
+
4132
+ def sw_unregister_scope(scope)
4133
+ @sw_registrations.delete(scope.to_s)
4134
+ nil
4135
+ end
4136
+
4137
+ # The registration handle controlling `url` — the one whose serialized scope is the longest
4138
+ # prefix of `url` (spec "Match Service Worker Registration"; the scope embeds the origin, so
4139
+ # a cross-origin scope can't prefix-match). nil when no registration's scope matches.
4140
+ private def sw_scope_match(url)
4141
+ u = url.to_s
4142
+ best = nil
4143
+ best_len = -1
4144
+ @sw_registrations.each do |scope, handle|
4145
+ next unless u.start_with?(scope) && scope.length > best_len
4146
+ best = [handle, scope]
4147
+ best_len = scope.length
4148
+ end
4149
+ best
4150
+ end
4151
+
4152
+ # The controller for a freshly-built frame realm at `url`, for wiring its
4153
+ # `navigator.serviceWorker.controller`. Returns [handle, has_fetch, script_url] or nil.
4154
+ # Unlike the navigation variant this keeps a controller whose fetch-handler snapshot is
4155
+ # still UNKNOWN (nil, racing the SW's initial eval) — resolved to `true` here so the frame
4156
+ # is controlled and routes; a controlled subresource fetch simply falls through to the
4157
+ # network if no handler materializes. Only a KNOWN-false (messaging/push-only) SW skips.
4158
+ def sw_client_controller_for(url)
4159
+ match = sw_scope_match(url) or return nil
4160
+ handle, scope = match
4161
+ w = @workers[handle] or return nil
4162
+ return nil unless w[:thread]&.alive?
4163
+
4164
+ [handle, w[:has_fetch] != false, w[:script_url].to_s, scope]
4165
+ end
4166
+
4167
+ # The handle of the service worker controlling a newly spawned worker, or nil.
4168
+ #
4169
+ # A worker with a REAL script URL is matched against registration scopes like any other
4170
+ # client, whatever its creator is doing: clients-matchall-client-types creates its dedicated
4171
+ # worker from the (out-of-scope, uncontrolled) top-level page and still expects a plain
4172
+ # `matchAll({type: 'worker'})` to return it.
4173
+ #
4174
+ # A blob: / data: script URL is OPAQUE — a UUID no scope could ever cover — so such a worker
4175
+ # takes its creator's controller instead (clients-matchall-blob-url-worker: controlled when
4176
+ # an in-scope frame creates it, uncontrolled when an out-of-scope page does). That has to be
4177
+ # the creating realm's LIVE controller, handed over at `new Worker(…)` time: control usually
4178
+ # arrives after load via `clients.claim()`, so a controller snapshotted when the realm was
4179
+ # BUILT is stale by then, and would report such a worker uncontrolled.
4180
+ private def worker_controller_handle(url, shared, creator_controller)
4181
+ return sw_client_controller_for(url)&.first if url.to_s.match?(%r{\Ahttps?://}i)
4182
+ # A SHARED worker has no single creating context to inherit from.
4183
+ return nil if shared
4184
+
4185
+ h = creator_controller.to_i
4186
+ h.zero? || !@workers[h] ? nil : h
4187
+ end
4188
+
4189
+ # Terminate every dedicated / shared worker a discarded browsing context created. A worker
4190
+ # is owned by its creating context: when that context goes away the worker is terminated,
4191
+ # so it must stop being a service-worker client too (worker_terminate unregisters it).
4192
+ # Without this a frame's worker outlives its frame — a leaked thread AND a leaked client.
4193
+ def terminate_realm_workers(realm_id)
4194
+ rid = realm_id.to_i
4195
+ return nil if rid.zero?
4196
+
4197
+ @workers.select {|_h, w| w[:realm] == rid && !w[:service] }.each do |handle, w|
4198
+ # Everything `worker_terminate` does EXCEPT waiting for the thread. That wait is two
4199
+ # blocking joins with a `Thread#kill` between them, and this runs on the frame-disposal
4200
+ # path — which a frame-heavy app takes on every navigation, so a join here is a
4201
+ # per-navigation stall on the main thread (rule 3). Asking the worker to stop is enough:
4202
+ # it breaks its own poll loop and the thread exits on its own.
4203
+ # The reap must still happen, and happen HERE: it releases the reply-pending counters the
4204
+ # worker still holds and resets them once the last worker is gone, without which
4205
+ # `polling?` stays true for the rest of the session.
4206
+ @workers.delete(handle)
4207
+ detach_worker(handle, w)
4208
+ # Racing the still-live thread is benign: a fallback reply it also answers is dropped
4209
+ # (the client's pending-fetch entry is one-shot), and the only real cost is that a blob
4210
+ # URL minted in the moment between the revoke and the thread noticing `:terminate` can
4211
+ # leak — far cheaper than stalling every navigation.
4212
+ reap_worker(handle, w)
4213
+ end
4214
+ nil
4215
+ end
4216
+
4217
+ # The controller an OPAQUE child browsing context (about:blank / srcdoc)
4218
+ # inherits from its creator. An about:blank document has no URL to scope-match,
4219
+ # so it's controlled by its parent's active service worker (HTML "create and
4220
+ # initialize a Document" inherits the creator's controller). Keyed by the
4221
+ # parent frame realm's id, recorded when that realm was wired (below).
4222
+ def sw_inherited_controller_for(parent_realm_id)
4223
+ return nil if parent_realm_id.nil? || parent_realm_id.to_i.zero?
4224
+ ctrl = @sw_realm_controller[parent_realm_id.to_i]
4225
+ return nil unless ctrl && @workers[ctrl[0]]&.dig(:thread)&.alive?
4226
+
4227
+ ctrl
4228
+ end
4229
+
4230
+ # Remember a frame/window realm's controller so its OWN opaque children can
4231
+ # inherit it (sw_inherited_controller_for). Set at frame-realm build for both
4232
+ # a scope-matched and an inherited controller, so inheritance chains through
4233
+ # nested about:blank frames.
4234
+ def sw_note_realm_controller(realm_id, ctrl)
4235
+ @sw_realm_controller[realm_id.to_i] = ctrl
4236
+ nil
4237
+ end
4238
+
4239
+ # Every live service worker's handle. A service-worker client belongs to the ORIGIN, not
4240
+ # to one registration — `matchAll({includeUncontrolled: true})` must see contexts this
4241
+ # worker doesn't control — so the client mirror goes to all of them.
4242
+ private def sw_worker_handles = @workers.select {|_h, w| w[:service] }.keys
4243
+
4244
+ # Mirror a browsing context into every service worker's client set. `controller_handle` is
4245
+ # the worker that CONTROLS it, or nil for an uncontrolled context — and `controlled` is
4246
+ # per-worker, since a client controlled by worker A is genuinely uncontrolled from B's
4247
+ # point of view. Reported by the realm itself (js/src/sw-client.js) at document load and
4248
+ # whenever control is installed, because only the realm knows its own URL and frame type.
4249
+ # The client id is realm-scoped and stable for the realm's life. Pushed to the SW inbox,
4250
+ # which is FIFO, so it precedes any later message that matchAll's it.
4251
+ def sw_note_client(realm_id, url, frame_type, controller_handle = nil)
4252
+ note_client(sw_client_id(realm_id), url, 'window', frame_type, controller_handle)
4253
+ end
4254
+
4255
+ # A dedicated / shared WORKER that a service worker controls is a client too — with no
4256
+ # visibilityState or focus (those are WindowClient's), `frameType` 'none', and its script
4257
+ # URL. Keyed by worker handle, which outlives nothing else and is unique per worker.
4258
+ def sw_note_worker_client(handle, url, shared, controller_handle)
4259
+ note_client(sw_worker_client_id(handle), url, shared ? 'sharedworker' : 'worker', 'none', controller_handle)
4260
+ end
4261
+
4262
+ private def note_client(client_id, url, type, frame_type, controller_handle)
4263
+ ctrl = controller_handle.to_i
4264
+ ctrl = nil if ctrl.zero?
4265
+ rec = {'id' => client_id, 'url' => url.to_s, 'type' => type.to_s, 'frameType' => frame_type.to_s}
4266
+ @sw_clients[client_id] = {handle: ctrl, rec: rec}
4267
+ focused = focused_client_ids
4268
+ sw_worker_handles.each do |h|
4269
+ w = @workers[h] or next
4270
+ # A re-registration with the same id refreshes the record, so a client that CHANGES
4271
+ # controller needs no explicit removal: every worker is told, and the one that lost it
4272
+ # simply learns `controlled` is now false.
4273
+ w[:inbox] << {kind: 'client_register', client: rec.merge('controlled' => h == ctrl)}
4274
+ # A client arriving after focus already moved needs the current id too — `client_focus`
4275
+ # is only pushed on CHANGE, so this worker would otherwise never learn about it.
4276
+ w[:inbox] << {kind: 'client_focus', ids: focused} if focused.any?
4277
+ end
4278
+ nil
4279
+ end
4280
+
4281
+ # Navigate a client's browsing context on behalf of `WindowClient.navigate()`, then answer the
4282
+ # worker waiting on it. The reply carries where the client ENDED UP:
4283
+ # url — the final URL, when the result is same-origin (the promise resolves with the client)
4284
+ # '' — the result is CROSS-ORIGIN, which the spec resolves with null rather than handing
4285
+ # back a client this worker has no business seeing
4286
+ # error — the navigation was refused (mixed content, or a context we can't navigate), a
4287
+ # TypeError rejection
4288
+ # MIXED CONTENT is checked here rather than JS-side because "is this a secure context" is the
4289
+ # host's knowledge: an https client may not be navigated to http.
4290
+ # Queued, not performed here: `deliver_worker_messages` runs inside the `@ticking` guard, and
4291
+ # navigating rebuilds a realm (a top-level one rebuilds the whole context). Doing that
4292
+ # mid-drain would pull the rug from under the rest of the batch — the sw_msgs / claims /
4293
+ # fetch_resps still to be delivered would address realms that no longer exist — and from any
4294
+ # node handle an in-flight find is holding. `drain_pending_navigation` is where every other
4295
+ # navigation intent lands, well clear of the V8 call we are inside.
4296
+ def sw_navigate_client(handle, client_id, url, nav_id)
4297
+ (@sw_pending_client_navs ||= []) << {handle: handle.to_i, client: client_id.to_s, url: url.to_s, nav_id: nav_id.to_i}
4298
+ nil
4299
+ end
4300
+
4301
+ def consume_pending_sw_client_nav
4302
+ return if @sw_pending_client_navs.nil? || @sw_pending_client_navs.empty?
4303
+
4304
+ navs = @sw_pending_client_navs
4305
+ @sw_pending_client_navs = nil
4306
+ navs.each {|e| perform_sw_client_navigate(e[:handle], e[:client], e[:url], e[:nav_id]) }
4307
+ nil
4308
+ end
4309
+
4310
+ private def perform_sw_client_navigate(handle, client_id, url, nav_id)
4311
+ realm_id = sw_client_realm(client_id)
4312
+ return sw_navigate_reply(handle, nav_id, '', '', 'the client is not a navigable browsing context') if realm_id.nil?
4313
+
4314
+ from = client_realm_url(realm_id)
4315
+ return sw_navigate_reply(handle, nav_id, '', '', 'mixed content is not allowed') if mixed_content_navigation?(from, url)
4316
+
4317
+ # Identify the browsing CONTEXT before navigating — a frame navigation rebuilds its realm,
4318
+ # so the realm id is not stable across it, but the iframe element that owns it is.
4319
+ parent = realm_id.zero? ? nil : @runtime.frame_realm_parent(realm_id)
4320
+ container = realm_id.zero? ? nil : frame_container_handle(realm_id, parent)
4321
+ navigate_client_realm(realm_id, url)
4322
+ landed_realm = realm_id.zero? ? 0 : (realm_for_container(parent, container) || realm_id)
4323
+ landed = client_realm_url(landed_realm)
4324
+ # A cross-origin result is reported as "no client": the spec resolves navigate() with null
4325
+ # rather than handing back a client this worker has no business seeing.
4326
+ origin = url_origin(landed)
4327
+ return sw_navigate_reply(handle, nav_id, '', '', nil) if landed.empty? || origin.nil? || origin != url_origin(from)
4328
+
4329
+ # The client id is realm-derived, so the rebuild MOVED it. Reply with the id of the context
4330
+ # as it is NOW — handing back the pre-navigation id would give the worker a client whose
4331
+ # postMessage is silently dropped and whose focus() would point the focus chain at a
4332
+ # discarded realm (see the sw_clientid_model note).
4333
+ sw_navigate_reply(handle, nav_id, landed, sw_client_id(landed_realm), nil)
4334
+ rescue StandardError => e
4335
+ sw_navigate_reply(handle, nav_id, '', '', "navigation failed: #{e.message}")
4336
+ end
4337
+
4338
+ private def sw_navigate_reply(handle, nav_id, url, client_id, error)
4339
+ w = @workers[handle.to_i] or return nil
4340
+ w[:inbox] << {kind: 'client_navigate_result', nav_id: nav_id.to_i, url: url.to_s, client: client_id.to_s, error: error.to_s}
4341
+ nil
4342
+ end
4343
+
4344
+ # The realm currently backing a browsing context, named by the iframe element that owns it.
4345
+ # The element outlives every realm rebuild a navigation causes, so it — not the realm id —
4346
+ # is what identifies the context across one.
4347
+ private def realm_for_container(parent, container)
4348
+ return nil if container.nil? || container.zero? || !@runtime.respond_to?(:frame_realm_ids)
4349
+
4350
+ @runtime.frame_realm_ids.find do |rid|
4351
+ @runtime.frame_realm_alive?(rid) && frame_container_handle(rid, parent) == container
4352
+ rescue StandardError
4353
+ false
4354
+ end
4355
+ end
4356
+
4357
+ private def client_realm_url(realm_id)
4358
+ (realm_id.to_i.zero? ? @current_url : frame_realm_url(realm_id)).to_s
4359
+ end
4360
+
4361
+ # An https document may not be navigated to an http one (mixed content); the reverse, and
4362
+ # any non-http(s) scheme, is not this check's business.
4363
+ private def mixed_content_navigation?(from, to)
4364
+ from.to_s.downcase.start_with?('https://') && to.to_s.downcase.start_with?('http://')
4365
+ end
4366
+
4367
+ # Re-navigate the browsing context behind a client id — the main window or a frame realm.
4368
+ private def navigate_client_realm(realm_id, url)
4369
+ return visit(url) if realm_id.zero?
4370
+
4371
+ navigate_realm_self_get(realm_id, url, record: false)
4372
+ end
4373
+
4374
+ # Drop a client whose realm was disposed (frame navigated away / removed) so
4375
+ # matchAll stops returning a dead client. No-op for an unregistered realm.
4376
+ def sw_unregister_client(realm_id)
4377
+ @sw_realm_controller.delete(realm_id.to_i)
4378
+ unregister_client(sw_client_id(realm_id))
4379
+ end
4380
+
4381
+ private def unregister_client(client_id)
4382
+ @sw_clients.delete(client_id) or return nil
4383
+
4384
+ sw_worker_handles.each do |h|
4385
+ w = @workers[h] or next
4386
+ w[:inbox] << {kind: 'client_unregister', id: client_id}
4387
+ end
4388
+ # Losing a controllee can be what finally lets a worker parked in `waiting` activate (the
4389
+ # non-skipWaiting half of "try activate"). Gated on a realm having actually parked one, so
4390
+ # the ordinary client-churn path stays free of a per-unregister broadcast (rule 3).
4391
+ broadcast_to_realms('__csim_swTryActivate') if @sw_activation_parked
4392
+ nil
4393
+ end
4394
+
4395
+ # A realm parked a worker in the waiting slot. Recorded so `unregister_client` knows whether
4396
+ # a try-activate broadcast could possibly matter.
4397
+ def sw_note_activation_parked
4398
+ @sw_activation_parked = true
4399
+ nil
4400
+ end
4401
+
4402
+ # A browsing context was discarded. HTML hands focus back to the top-level traversable
4403
+ # when the focused navigable goes away, so a realm that held the focus chain must not
4404
+ # keep it — otherwise `WindowClient.focused` stays true for a client that no longer exists.
4405
+ def note_realm_discarded(realm_id)
4406
+ note_focused_realm(0) if @focused_realm_id == realm_id.to_i
4407
+ nil
4408
+ end
4409
+
4410
+ # The focused BROWSING CONTEXT (HTML "focused area of the top-level traversable"):
4411
+ # the realm whose document owns the focus chain. Reported by the realm that commits a
4412
+ # focus — focusing an <iframe> hands focus to its NESTED context, so that realm is
4413
+ # reported rather than the container's. Feeds `WindowClient.focused`, which is why the
4414
+ # change is mirrored into every SW that holds a client (they can't query the browser).
4415
+ def note_focused_realm(realm_id)
4416
+ rid = realm_id.to_i
4417
+ return nil if @focused_realm_id == rid
4418
+
4419
+ @focused_realm_id = rid
4420
+ ids = focused_client_ids
4421
+ sw_worker_handles.each do |handle|
4422
+ w = @workers[handle] or next
4423
+ w[:inbox] << {kind: 'client_focus', ids: ids}
4424
+ end
4425
+ nil
4426
+ end
4427
+
4428
+ # Every client id that counts as focused: the focused context AND its ANCESTORS.
4429
+ # `WindowClient.focused` follows `document.hasFocus()`, which is true for the whole chain
4430
+ # up from the focused frame — a page containing the focused iframe is itself focused
4431
+ # (clients-matchall-include-uncontrolled expects the top-level window and the focused
4432
+ # nested frame to BOTH report true). Not a single winner, despite the name of the field.
4433
+ def focused_client_ids
4434
+ return [] if @focused_realm_id.nil?
4435
+
4436
+ ids = []
4437
+ rid = @focused_realm_id
4438
+ 16.times do
4439
+ ids << sw_client_id(rid)
4440
+ # A top-level browsing context ends the chain: the main realm, and an auxiliary window
4441
+ # (whose OPENER is not its ancestor — `document.hasFocus()` is false in the opener while
4442
+ # the popup holds the focus, so its client must not be dragged in).
4443
+ break if top_level_realm?(rid)
4444
+
4445
+ rid = @runtime.respond_to?(:frame_realm_parent) ? @runtime.frame_realm_parent(rid).to_i : 0
4446
+ end
4447
+ ids.uniq
4448
+ end
4449
+
4450
+ # A realm with no parent NAVIGABLE. Without the runtime's window/frame maps (QuickJS has no
4451
+ # realms at all) only the main realm can be one.
4452
+ private def top_level_realm?(realm_id)
4453
+ return true if realm_id.to_i.zero?
4454
+
4455
+ @runtime.respond_to?(:top_level_realm?) ? @runtime.top_level_realm?(realm_id) : true
4456
+ end
4457
+
4458
+ # A worker's service-worker Client id. Distinct from the realm ids below so the
4459
+ # client-message router can tell a worker client from a browsing context.
4460
+ def sw_worker_client_id(handle) = "client-worker-#{handle.to_i}"
4461
+
4462
+ # A realm's service-worker Client id. The MAIN realm (id 0) is 'client-window'; every
4463
+ # other realm is `client-<realm>`. The same two spellings are produced JS-side by
4464
+ # sw-client.js's clientId() and the FetchEvent clientKey (js/src/workers.js), and read
4465
+ # back by the client-message router below — so they must be minted in exactly one place.
4466
+ def sw_client_id(realm_id) = realm_id.to_i.zero? ? 'client-window' : "client-#{realm_id.to_i}"
4467
+
4468
+ # The realm a client id names — the inverse of `sw_client_id`, nil for an unrecognized id.
4469
+ def sw_client_realm(client_id)
4470
+ id = client_id.to_s
4471
+ return 0 if id == 'client-window'
4472
+
4473
+ (m = /\Aclient-(\d+)\z/.match(id)) ? m[1].to_i : nil
4474
+ end
4475
+
4476
+ # The worker handle a client id names, or nil when the id is not a worker client's.
4477
+ def sw_client_worker(client_id)
4478
+ (m = /\Aclient-worker-(\d+)\z/.match(client_id.to_s)) ? m[1].to_i : nil
4479
+ end
4480
+
4481
+ # ── Cross-isolate MessagePort channel relay (client realm ↔ worker/SW isolate) ──
4482
+ # Each endpoint self-registers when it (de)serializes the transferred port.
4483
+ def port_channel_endpoint_realm(channel, realm_id)
4484
+ ch = (@port_channels[channel.to_s] ||= {})
4485
+ ch[:realm] = realm_id.to_i
4486
+ # Flush anything the worker posted before this endpoint was known (deliver_worker_messages).
4487
+ if (pending = ch.delete(:pending_realm))
4488
+ pending.each {|d| deliver_port_to_realm(realm_id.to_i, channel.to_s, d) }
4489
+ end
4490
+ nil
4491
+ end
4492
+ # Deliver a channel message into a client realm's endpoint port (realm 0 = the main realm).
4493
+ private def deliver_port_to_realm(rid, channel, data)
4494
+ if rid.zero?
4495
+ @runtime.call('__csimPortChannelDeliver', channel, data)
4496
+ elsif @runtime.frame_realm_alive?(rid)
4497
+ @runtime.realm_call(rid, '__csimPortChannelDeliver', channel, data)
4498
+ end
4499
+ end
4500
+ def port_channel_endpoint_sw(channel, handle)
4501
+ ch = (@port_channels[channel.to_s] ||= {})
4502
+ ch[:sw] = handle.to_i
4503
+ # Flush anything the client posted before this endpoint was known (see client_port_post).
4504
+ if (pending = ch.delete(:pending_sw)) && (w = @workers[handle.to_i])
4505
+ pending.each {|d| @sw_message_pending += 1; w[:inbox] << {kind: 'port_msg', channel: channel.to_s, data: d} }
4506
+ end
4507
+ nil
4508
+ end
4509
+ # A client-realm port posts to its remote (worker/SW) peer: relay to the isolate's inbox.
4510
+ # Counted like an sw_message so settle waits for the worker to process it (and any reply it
4511
+ # posts straight back on the same or another channel). A message posted BEFORE the peer endpoint
4512
+ # is registered (a port used right after transfer, before the worker decoded it — the Comlink
4513
+ # handshake) is BUFFERED on the channel and flushed by port_channel_endpoint_sw, per HTML's port
4514
+ # message queue, rather than dropped.
4515
+ def client_port_post(channel, data)
4516
+ ch = (@port_channels[channel.to_s] ||= {})
4517
+ handle = ch[:sw]
4518
+ if handle && (w = @workers[handle])
4519
+ @sw_message_pending += 1
4520
+ w[:inbox] << {kind: 'port_msg', channel: channel.to_s, data: data.to_s}
4521
+ else
4522
+ (ch[:pending_sw] ||= []) << data.to_s
4523
+ end
4524
+ nil
4525
+ end
4526
+
4527
+ # The active fetch-handling worker controlling a navigation to `url`. nil when uncontrolled
4528
+ # or the SW is known to have no fetch listener (→ load from the network).
4529
+ private def sw_controller_for_navigation(url)
4530
+ match = sw_scope_match(url) or return nil
4531
+ handle = match[0]
4532
+ w = @workers[handle] or return nil
4533
+ # has_fetch is published from the worker thread AFTER its initial eval, so a navigation
4534
+ # into a freshly-activated registration can read it as nil (unknown) — race-prone for the
4535
+ # 2nd+ registration, whose initial load fires before the publish. Treat unknown as "maybe":
4536
+ # route through and let the fetch dispatch fall through if the SW turns out to have no
4537
+ # handler. Skip only a KNOWN-false (messaging/push-only) SW, or one whose thread is already
4538
+ # DEAD (crashed during eval / closed) — routing there would just stall the nav for the full
4539
+ # round-trip budget with no one to answer.
4540
+ return nil if w[:has_fetch] == false || !w[:thread]&.alive?
4541
+
4542
+ handle
4543
+ end
4544
+
4545
+ # Route a navigation request (document / iframe load) to its controlling SW's `fetch`
4546
+ # event and BLOCK for the respondWith result. Mirrors settle's bounded wait: the main
4547
+ # thread releases the GVL on the dedicated `@sw_nav_outbox`, so the worker thread runs the
4548
+ # handler and posts back. Navigation ids are NEGATIVE so the response is delivered on that
4549
+ # queue (sw_deliver_fetch_response), not the general outbox. Returns the parsed response
4550
+ # hash (SW served the document), or nil to load from the network (no controller, no
4551
+ # respondWith, network error, or the SW didn't answer within the round-trip budget).
4552
+ 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)
4553
+ handle = sw_controller_for_navigation(url) or return nil
4554
+ w = @workers[handle] or return nil
4555
+ fetch_id = (@sw_nav_seq -= 1)
4556
+ # Navigation Preload: when the controlling registration has it enabled, issue the parallel
4557
+ # preload request NOW (main thread, before dispatching the event) and hand the response to the
4558
+ # SW as `event.preloadResponse`. GET only (the feature does not support other methods). The SW
4559
+ # that serves `respondWith(event.preloadResponse)` echoes this response back — the server is hit
4560
+ # exactly once. (EARNED GAP: if the SW instead FALLS THROUGH, this method returns nil and the
4561
+ # caller re-fetches from the network — a second hit; the spec reuses the preload response for
4562
+ # the fall-through. No vendored subtest enables preload then falls through.)
4563
+ preload = if method.to_s.upcase == 'GET' && nav_preload_enabled?(handle)
4564
+ navigation_preload_response(
4565
+ url,
4566
+ referrer_source,
4567
+ referrer_policy,
4568
+ site_seed,
4569
+ origin_null,
4570
+ nav_preload_state(handle)['headerValue']
4571
+ )
4572
+ end
4573
+ # A form submission navigates with the form's method + encoded body (a POST nav the SW reads
4574
+ # via `event.request.text()`); the Content-Type the form's enctype implies rides its headers.
4575
+ 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'}
4576
+ headers['Content-Type'] = content_type.to_s unless content_type.to_s.empty?
4577
+ req = JSON.generate(
4578
+ method: method.to_s.empty? ? 'GET' : method.to_s.upcase,
4579
+ url: url.to_s,
4580
+ # The Accept header Fetch inserts for a navigation request (destination 'document').
4581
+ headers: headers,
4582
+ body_b64: body_b64.to_s,
4583
+ mode: 'navigate',
4584
+ destination: 'document',
4585
+ isReloadNavigation: is_reload,
4586
+ isHistoryNavigation: is_history,
4587
+ # A reload navigation revalidates (cache mode 'no-cache'); a fresh/history load is 'default'.
4588
+ cache: is_reload ? 'no-cache' : 'default',
4589
+ # The navigation's referrer is the initiating document, resolved under ITS Referrer-Policy
4590
+ # (the document default absent a meta/header — strict-origin-when-cross-origin — so a
4591
+ # same-origin nav keeps the full URL). A SW that re-issues the request
4592
+ # (`fetch(event.request)`) computes Sec-Fetch-Site / Origin against this referrer's origin.
4593
+ referrer: compute_referrer(referrer_policy, referrer_source, url.to_s).to_s,
4594
+ # `event.request.referrerPolicy` reflects the RESOLVED policy: a navigation with no explicit
4595
+ # meta/header policy uses the document default (strict-origin-when-cross-origin), never the
4596
+ # empty string a bare Request would carry (fetch-event-referrer-policy "default referrer policy").
4597
+ referrerPolicy: referrer_policy.to_s.empty? ? 'strict-origin-when-cross-origin' : referrer_policy.to_s,
4598
+ # A passthrough `fetch(event.request)` re-fetch reports the navigation's OWN request
4599
+ # metadata to the server, independent of the referrer (which Referrer-Policy may reduce):
4600
+ # the initiator origin (the navigating frame's — the request's origin for the Origin
4601
+ # header) and the redirect chain's latched Sec-Fetch-Site seed / Origin taint accumulated
4602
+ # by the network hops before the SW intercepted the final URL.
4603
+ initiator: url_origin(referrer_source),
4604
+ siteSeed: site_seed,
4605
+ originNull: origin_null,
4606
+ # The Navigation Preload response (nil unless preload is enabled), surfaced to the handler
4607
+ # as `event.preloadResponse`.
4608
+ preloadResponse: preload
4609
+ )
4610
+ w[:inbox] << {kind: 'fetch', req:, fetch_id:}
4611
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + WORKER_ROUND_TRIP_BUDGET
4612
+ while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
4613
+ ev = pop_with_timeout(@sw_nav_outbox, WORKER_POLL_INTERVAL) or next
4614
+ next unless ev[:fetch_id] == fetch_id # discard a stale response from a timed-out nav
4615
+
4616
+ resp = JSON.parse(ev[:resp])
4617
+ return nil if resp['fallthrough'] # no respondWith → load from the network
4618
+ return {'networkError' => true} if resp['networkError'] # respondWith(Response.error()) → failed navigation
4619
+
4620
+ return resp
4621
+ end
4622
+ nil # SW never answered within budget → load from the network
4623
+ end
4624
+
3477
4625
  def worker_terminate(handle)
3478
4626
  w = @workers.delete(handle.to_i)
3479
4627
  return unless w
3480
- w[:inbox] << :terminate
4628
+ detach_worker(handle.to_i, w)
3481
4629
  # Most clean shutdowns are <10 ms; the kill is the fallback
3482
4630
  # for blocked workers. Join again AFTER the kill so the thread is actually
3483
4631
  # dead before we revoke its URLs — `Thread#kill` is async, and a worker
@@ -3488,26 +4636,213 @@ module Capybara
3488
4636
  w[:thread].kill
3489
4637
  w[:thread].join(WORKER_TERMINATE_GRACE)
3490
4638
  end
4639
+ reap_worker(handle.to_i, w)
4640
+ end
4641
+
4642
+ # Ask a worker to stop and stop treating it as a client. Split out of `worker_terminate` so the
4643
+ # realm-disposal path can do it WITHOUT the thread joins below (see terminate_realm_workers).
4644
+ # A dedicated / shared worker is a service-worker client (worker_spawn registers it), and a
4645
+ # terminated one must stop showing up in matchAll — the same leak sw_unregister_client
4646
+ # prevents for a disposed realm. A no-op for a SERVICE worker, which is never a client.
4647
+ private def detach_worker(handle, w)
4648
+ unregister_client(sw_worker_client_id(handle))
4649
+ w[:inbox] << :terminate
4650
+ end
4651
+
4652
+ # Everything that must happen once a worker is out of `@workers`, whether or not we waited for
4653
+ # its thread: drain what it will never answer, release the counters it still holds, and revoke
4654
+ # what it created. Load-bearing — the counter reset at the end only fires when the LAST worker
4655
+ # goes, so skipping this path leaves `polling?` stuck true for the rest of the session and
4656
+ # every later negative assertion burns the full wait.
4657
+ private def reap_worker(handle, w)
4658
+ # Hand back every plain postMessage this worker still owes a reply for — queued OR already
4659
+ # consumed by a listen-only handler that will never answer. Only a reply releases these,
4660
+ # and a dead worker sends none; leaving them counted pins `worker_pending?` (and so
4661
+ # `polling?`) true for the rest of the session, making every later negative assertion wait
4662
+ # out the full timeout.
4663
+ @worker_in_flight = [0, @worker_in_flight - w[:in_flight].to_i].max
4664
+ w[:in_flight] = 0
4665
+ # The dead worker never answers what was still queued in its inbox: post the matching
4666
+ # fallback replies so the reply-pending counters drain (a controlled fetch falls back to
4667
+ # the network) instead of taxing every later settle's bounded wait. Mid-dispatch deaths
4668
+ # are covered by the `ensure` acks in run_worker; only a respondWith parked on a worker
4669
+ # timer that never fires can still strand a fetch (the client promise then just never
4670
+ # settles, like a real dead SW).
4671
+ until w[:inbox].empty?
4672
+ msg = begin
4673
+ w[:inbox].pop(true)
4674
+ rescue ThreadError
4675
+ break
4676
+ end
4677
+ next unless msg.is_a?(Hash)
4678
+ case msg[:kind]
4679
+ when 'sw_message', 'port_msg' then @worker_outbox << {handle: handle.to_i, kind: 'swack'}
4680
+ when 'broadcast' then @worker_outbox << {handle: handle.to_i, kind: 'bcack'}
4681
+ when 'fetch' then sw_deliver_fetch_response(handle.to_i, msg[:fetch_id].to_i, '{"fallthrough":true}', @worker_outbox, msg[:realm_id].to_i)
4682
+ end
4683
+ end
4684
+ # A streaming respondWith whose worker died mid-body never emits its terminal frame: error the
4685
+ # client's stream (so a pending body read rejects, not hangs) and release the @sw_fetch_pending
4686
+ # each open stream still holds, so settle can reach idle.
4687
+ open = @sw_open_streams.delete(handle.to_i)
4688
+ if open&.any?
4689
+ open.each_key do |realm_id, fetch_id|
4690
+ @runtime.realm_call(realm_id, '__csim_swFetchStreamError', fetch_id)
4691
+ end
4692
+ @sw_fetch_pending = [0, @sw_fetch_pending - open.size].max
4693
+ end
4694
+ # Drop any navigation scope mirrored to this now-dead worker so a later navigation
4695
+ # doesn't route to it (it falls through to the network instead).
4696
+ @sw_registrations.reject! {|_scope, h| h == handle.to_i }
4697
+ @sw_navpreload.delete(handle.to_i)
3491
4698
  # A blocked worker that never returned messages leaves
3492
4699
  # `@worker_in_flight` permanently > 0; reset when no workers
3493
4700
  # remain so `polling?` can short-circuit again.
3494
- @worker_in_flight = 0 if @workers.empty?
4701
+ (@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
4702
  # The worker is gone — revoke the blob URLs it created.
3496
4703
  revoke_worker_blobs(handle.to_i)
3497
4704
  end
3498
4705
 
3499
4706
  def deliver_worker_messages
3500
- return 0 if @workers.empty? && @worker_outbox.empty?
4707
+ head = @worker_outbox_head
4708
+ @worker_outbox_head = nil
4709
+ return 0 if head.nil? && @workers.empty? && @worker_outbox.empty?
3501
4710
  events = drain_queue(@worker_outbox)
4711
+ events.unshift(head) if head
3502
4712
  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)
4713
+ # A worker-originated BroadcastChannel post ('broadcast') is fanned out on the main thread and
4714
+ # is NOT a reply. A 'bcack' acknowledges a broadcast the worker just delivered → release one
4715
+ # broadcast-pending. Everything else ('message'/'__error') is a postMessage reply.
4716
+ broadcasts, rest0 = events.partition {|e| e[:kind] == 'broadcast' }
4717
+ port_ends, rest0b = rest0.partition {|e| e[:kind] == 'port_endpoint' }
4718
+ port_msgs, rest0c = rest0b.partition {|e| e[:kind] == 'port_msg' }
4719
+ sw_focuses, rest0c2 = rest0c.partition {|e| e[:kind] == 'sw_client_focus' }
4720
+ sw_navs, rest0c3 = rest0c2.partition {|e| e[:kind] == 'sw_client_navigate' }
4721
+ skip_waits, rest0d = rest0c3.partition {|e| e[:kind] == 'sw_skip_waiting' }
4722
+ sw_msgs, rest1 = rest0d.partition {|e| e[:kind] == 'sw_client_msg' }
4723
+ swacks, rest2 = rest1.partition {|e| e[:kind] == 'swack' }
4724
+ claims, rest3 = rest2.partition {|e| e[:kind] == 'sw_claim' }
4725
+ fetch_resps, rest4 = rest3.partition {|e| e[:kind] == 'fetch_response' }
4726
+ stream_frames, rest4b = rest4.partition {|e| e[:kind].to_s.start_with?('fr_') }
4727
+ acks, msgs = rest4b.partition {|e| e[:kind] == 'bcack' }
4728
+ broadcasts.each {|e| broadcast_to_windows(e[:name], e[:data], nil, e[:origin], from_worker: e[:handle]) }
4729
+ # A worker/SW registering its end of a cross-isolate MessagePort channel — record it BEFORE
4730
+ # the message events below, so a port message carried in the same drain can already route.
4731
+ port_ends.each {|e| port_channel_endpoint_sw(e[:channel], e[:handle]) }
4732
+ # `WindowClient.focus()` — the worker asked to move the focus chain to a client. Applied
4733
+ # BEFORE the messages below, so a SW that focuses a client and then reports its own
4734
+ # matchAll() in the same turn sees the move it just made.
4735
+ sw_focuses.each do |e|
4736
+ rid = sw_client_realm(e[:client]) and note_focused_realm(rid)
4737
+ end
4738
+ # `WindowClient.navigate()` — only QUEUED here (see sw_navigate_client). It must not run
4739
+ # before the sw_msgs / claims / fetch_resps below: the worker emitted those FIRST, and a
4740
+ # navigation discards the realm they address. Queuing preserves the worker's own ordering
4741
+ # and keeps the realm rebuild out of the `@ticking` guard.
4742
+ sw_navs.each {|e| sw_navigate_client(e[:handle], e[:client], e[:url], e[:nav_id]) }
4743
+ # `skipWaiting()` — release a worker parked in the waiting slot. Broadcast, because the
4744
+ # registration objects holding that parked continuation are per-realm.
4745
+ skip_waits.each {|e| broadcast_to_realms('__csim_swSkipWaiting', e[:handle]) }
4746
+ # A worker/SW port → its remote (client-realm) peer: relay to that realm's channel endpoint.
4747
+ # If the client hasn't registered its endpoint yet (it decodes the transferred port in the
4748
+ # sw_client_msg processed just below), BUFFER until port_channel_endpoint_realm flushes.
4749
+ port_msgs.each do |e|
4750
+ ch = (@port_channels[e[:channel].to_s] ||= {})
4751
+ rid = ch[:realm]
4752
+ if rid.nil?
4753
+ (ch[:pending_realm] ||= []) << e[:data]
4754
+ else
4755
+ deliver_port_to_realm(rid, e[:channel].to_s, e[:data])
4756
+ end
4757
+ end
4758
+ # A service worker → client message: deliver to the POSTING client's realm. The client id
4759
+ # encodes it — `client-<realm>` for a frame/window realm, 'client-window' for the main realm.
4760
+ # A `client.postMessage` to a controlled IFRAME must reach THAT frame's navigator.service-
4761
+ # Worker, not the top window (postmessage-to-client). `e[:handle]` is the SENDING worker, so
4762
+ # the client's message `source` is exact. A message to a DISCARDED frame realm is dropped
4763
+ # (matching a real browser — a message to a gone client is discarded, not misrouted to top).
4764
+ sw_msgs.each do |e|
4765
+ rid = sw_client_realm(e[:client])
4766
+ if (wh = sw_client_worker(e[:client]))
4767
+ # A worker CLIENT (a dedicated/shared worker the SW controls) — deliver to its own
4768
+ # isolate's `navigator.serviceWorker`, not to a browsing context, and not to the
4769
+ # worker's creator-facing `self.onmessage` (which is where a bare 'message' would land).
4770
+ (w = @workers[wh]) && w[:inbox] << {kind: 'sw_client_message', data: e[:data], handle: e[:handle]}
4771
+ elsif rid.nil?
4772
+ # An id naming nothing we know. Dropping matches a real browser (a message to a gone
4773
+ # client is discarded); delivering it to the main realm would MISROUTE it.
4774
+ nil
4775
+ elsif rid.zero?
4776
+ @runtime.call('__csim_swDeliverClientMessage', e[:data], e[:handle])
4777
+ elsif @runtime.frame_realm_alive?(rid)
4778
+ @runtime.realm_call(rid, '__csim_swDeliverClientMessage', e[:data], e[:handle])
4779
+ end
4780
+ end
4781
+ # clients.claim(): the claiming worker takes control of EVERY in-scope client — including
4782
+ # ones that never register()'d (an iframe built before the SW existed). See broadcast_claim.
4783
+ # Process LONGEST scope first: when nested-scope workers claim in the same drain, the deeper
4784
+ # registration must be installed before the shallower one runs, or a client the shallow claim
4785
+ # transiently seizes would fire a spurious extra controllerchange (a reload-on-controllerchange
4786
+ # page would double-fire). A claim whose scope isn't mirrored into @sw_registrations yet — the
4787
+ # worker fires activate→claim() decoupled from the CLIENT-side lifecycle that populates it — is
4788
+ # BUFFERED and flushed by sw_register_scope, so an `activate → clients.claim()` isn't lost.
4789
+ claims.map {|e| [e, @sw_registrations.key(e[:handle].to_i)] }
4790
+ .sort_by {|_e, scope| -(scope ? scope.length : -1) }
4791
+ .each do |e, scope|
4792
+ if scope
4793
+ broadcast_claim(e[:handle], e[:has_fetch], scope)
4794
+ else
4795
+ @sw_pending_claims << e
4796
+ end
4797
+ end
4798
+ # A controlled fetch's respondWith result → resolve the pending client fetch in the
4799
+ # realm that issued it (fetch ids are per-realm, so realm_id disambiguates collisions).
4800
+ fetch_resps.each {|e| @runtime.realm_call(e[:realm_id].to_i, '__csim_swControllerFetchResponse', e[:fetch_id], e[:resp]) }
4801
+ # Streaming respondWith frames — deliver IN EMISSION ORDER (per fetch id: start → chunk* →
4802
+ # close/error) so the client reassembles the body ReadableStream correctly. The request's
4803
+ # @sw_fetch_pending was counted at fetch time and clears on the terminal frame.
4804
+ stream_frames.each do |e|
4805
+ fn = STREAM_FRAME_FNS[e[:kind]]
4806
+ @runtime.realm_call(e[:realm_id].to_i, fn, e[:fetch_id], e[:payload]) if fn
4807
+ # Track a stream's open span (head → terminal) per emitting worker, so worker_terminate
4808
+ # can release + error a body its worker died mid-stream.
4809
+ key = [e[:realm_id].to_i, e[:fetch_id].to_i]
4810
+ if e[:kind] == 'fr_start' then @sw_open_streams[e[:handle].to_i][key] = true
4811
+ else @sw_open_streams[e[:handle].to_i].delete(key)
4812
+ end
4813
+ end
4814
+ stream_terminals = stream_frames.count {|e| e[:kind] == 'fr_close' || e[:kind] == 'fr_error' }
4815
+ @sw_fetch_pending = [0, @sw_fetch_pending - fetch_resps.size - stream_terminals].max
4816
+ @worker_broadcast_pending = [0, @worker_broadcast_pending - acks.size].max
4817
+ @sw_message_pending = [0, @sw_message_pending - swacks.size].max
4818
+ # A delivered swack/bcack refreshes the message-wait budget (drain_pending_message_reply), so
4819
+ # the next reply in a sequence waits afresh instead of inheriting a spent deadline. Done here,
4820
+ # at the single delivery point, so it fires no matter which drain path delivered the reply —
4821
+ # including hold_for_sw_fetch's outbox drain when a fetch co-pends (which would otherwise
4822
+ # strand an expired deadline and re-starve the next transferable reply under load).
4823
+ @sw_msg_wait_deadline = nil if acks.size.positive? || swacks.size.positive?
4824
+ # `__error` postbacks don't correspond to a prior post, so bottom out at zero.
4825
+ @worker_in_flight = [0, @worker_in_flight - msgs.size].max
4826
+ # Mirror the release onto the answering worker's own tally, so what `reap_worker` hands
4827
+ # back when it dies is exactly what it still owes.
4828
+ msgs.each {|e| (w = @workers[e[:handle].to_i]) && (w[:in_flight] = [0, w[:in_flight].to_i - 1].max) }
4829
+ @runtime.call('__csim_deliverWorkerMessages', msgs) unless msgs.empty?
3507
4830
  events.size
3508
4831
  end
3509
4832
 
3510
- def worker_pending? = !@worker_outbox.empty? || @worker_in_flight > 0 || @worker_init_lock.synchronize { @worker_initializing } > 0
4833
+ 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
4834
+
4835
+ # The subset of worker pendings whose outbox reply is CONTRACTUAL (bcack / swack /
4836
+ # fetch_response are posted under `ensure`, so they arrive even when the worker-side
4837
+ # handler raises). Safe for settle to block a bounded wait on — unlike @worker_in_flight
4838
+ # (a plain postMessage that a listen-only worker never answers) or @worker_initializing.
4839
+ def worker_reply_pending? = @worker_broadcast_pending > 0 || @sw_message_pending > 0 || @sw_fetch_pending > 0
4840
+
4841
+ # The message/broadcast subset of `worker_reply_pending?` — a swack (client→SW postMessage /
4842
+ # cross-isolate port message) or a bcack (BroadcastChannel post). `run_event_loop_frame` waits
4843
+ # on these WITHOUT holding the clock (drain_pending_message_reply); the SW-fetch pending is held
4844
+ # separately (hold_for_sw_fetch), so it's deliberately excluded here.
4845
+ def worker_message_reply_pending? = @worker_broadcast_pending > 0 || @sw_message_pending > 0
3511
4846
 
3512
4847
  # ── Cross-window messaging (window.open / opener / postMessage) ──
3513
4848
  # Each window is a separate Browser/VM/isolate, so a reference to another
@@ -3518,9 +4853,9 @@ module Capybara
3518
4853
  # `window.open(url, name)` from JS — returns the new (or reused, by name)
3519
4854
  # window's handle, or nil. The URL is resolved against THIS document so a
3520
4855
  # relative `window.open('/x')` targets the right origin/path.
3521
- def open_child_window(url, name, opener_realm_id = 0)
4856
+ def open_child_window(url, name, opener_realm_id = 0, about_base = nil, about_origin = nil)
3522
4857
  return nil unless @driver.respond_to?(:open_window_from_js)
3523
- @driver.open_window_from_js(self, url.to_s, name.to_s, opener_realm_id.to_i)
4858
+ @driver.open_window_from_js(self, url.to_s, name.to_s, opener_realm_id.to_i, about_base.to_s, about_origin.to_s)
3524
4859
  end
3525
4860
 
3526
4861
  # A `target=_blank`/named link/area activation from a frame or window realm in
@@ -3540,10 +4875,13 @@ module Capybara
3540
4875
  # works (dom/nodes/remove-and-adopt-thcrash). Returns nil to fall back to the
3541
4876
  # separate-VM aux-window path. First stage: about:blank only (a non-blank
3542
4877
  # same-origin URL still takes the aux path until realm URL-loading lands).
3543
- def open_window_realm(url, name: nil, opener_realm_id: 0)
4878
+ def open_window_realm(url, name: nil, opener_realm_id: 0, about_base: nil, about_origin: nil)
3544
4879
  return nil unless @runtime.respond_to?(:create_window_realm)
3545
4880
  return nil unless url.nil?
3546
- @runtime.create_window_realm('', '', 'text/html', window_name: name, opener_id: opener_realm_id)
4881
+ @runtime.create_window_realm(
4882
+ '', '', 'text/html',
4883
+ window_name: name, opener_id: opener_realm_id, about_base: about_base, about_origin: about_origin
4884
+ )
3547
4885
  end
3548
4886
 
3549
4887
  # `targetWindow.postMessage(data, origin)` — route to the target window's
@@ -3618,14 +4956,32 @@ module Capybara
3618
4956
 
3619
4957
  # Covers both cross-window postMessage AND BroadcastChannel — the two
3620
4958
  # cross-window event channels share these drain/pending hooks.
3621
- def window_message_pending? = !@window_inbox.empty? || !@broadcast_inbox.empty?
4959
+ def window_message_pending? = !@window_inbox.empty? || !@broadcast_inbox.empty? || !@storage_inbox.empty? || !@bc_queue.empty?
3622
4960
 
3623
4961
  # A BroadcastChannel message queued for delivery to this Browser's channels.
3624
4962
  # `source_realm_id` is the posting realm's context id within THIS isolate (0 =
3625
4963
  # main), or nil when the post came from ANOTHER isolate (the Driver's cross-
3626
4964
  # 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}
4965
+ def enqueue_broadcast(name, data, source_realm_id = nil, origin = nil)
4966
+ @broadcast_inbox << {'name' => name.to_s, 'data' => data, 'source' => source_realm_id, 'origin' => origin}
4967
+ end
4968
+
4969
+ # A Storage change (setItem/removeItem/clear) in realm `source_realm_id`: fire a `storage`
4970
+ # event at every OTHER same-origin document. Within this isolate that's the other realms
4971
+ # (main + frames — a window's documents share both storage areas); localStorage ALSO spans
4972
+ # separate same-origin windows (the Driver shares its jar), so fan the change out to them —
4973
+ # sessionStorage is per-browsing-context and never crosses windows.
4974
+ def storage_changed(kind, key, old, new, url, source_realm_id)
4975
+ enqueue_storage_event(kind, key, old, new, url, source_realm_id)
4976
+ @driver.storage_broadcast(self, kind, key, old, new, url) if kind == 'local' && @driver.respond_to?(:storage_broadcast)
4977
+ nil
4978
+ end
4979
+
4980
+ # Queue a storage event for THIS window's documents. `source_realm_id` is the changing realm
4981
+ # within this isolate (skipped at delivery); nil (a cross-window fan-out) matches no realm, so
4982
+ # it reaches every one.
4983
+ def enqueue_storage_event(kind, key, old, new, url, source_realm_id = nil)
4984
+ @storage_inbox << {'kind' => kind.to_s, 'key' => key, 'old' => old, 'new' => new, 'url' => url.to_s, 'source' => source_realm_id}
3629
4985
  end
3630
4986
 
3631
4987
  # Fire queued cross-window messages (postMessage + BroadcastChannel).
@@ -3655,6 +5011,26 @@ module Capybara
3655
5011
  end
3656
5012
  n += events.size
3657
5013
  end
5014
+ unless @storage_inbox.empty?
5015
+ events = @storage_inbox.slice!(0, @storage_inbox.length)
5016
+ # The `storage` event fires at every same-origin document EXCEPT the one that changed
5017
+ # the area — deliver to the main realm (0) and every live frame realm, skipping the
5018
+ # source realm. A nil source (a cross-window fan-out) is excluded from no realm.
5019
+ realm_ids = @runtime.respond_to?(:frame_realm_ids) ? @runtime.frame_realm_ids : []
5020
+ [0, *realm_ids].each do |target_id|
5021
+ batch = events.reject {|e| e['source'] == target_id }
5022
+ next if batch.empty?
5023
+ if target_id.zero?
5024
+ @runtime.call('__csim_deliverStorageEvents', batch)
5025
+ elsif @runtime.frame_realm_alive?(target_id)
5026
+ @runtime.realm_call(target_id, '__csim_deliverStorageEvents', batch)
5027
+ end
5028
+ end
5029
+ n += events.size
5030
+ end
5031
+ # Drain the ordered BroadcastChannel queue LAST, so a `message`/`storage` handler above that
5032
+ # re-posts (multi-realm mode → bc_post) is picked up in this same pass.
5033
+ n += deliver_broadcast_queue unless @bc_queue.empty?
3658
5034
  n
3659
5035
  end
3660
5036
 
@@ -3664,13 +5040,107 @@ module Capybara
3664
5040
  # fanout goes through the Driver; the same-ISOLATE fanout (main ↔ sibling realms,
3665
5041
  # sibling ↔ sibling) is queued here and delivered per-realm by
3666
5042
  # `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)
5043
+ # Fan a BroadcastChannel post out to every OTHER same-origin browsing context. Called on the
5044
+ # MAIN thread either directly from a main/frame-realm post (`source_realm_id` = the poster's
5045
+ # realm, `from_worker` nil), or from `deliver_worker_messages` for a WORKER-originated post
5046
+ # (`from_worker` = the posting worker's handle, `source_realm_id` nil).
5047
+ def broadcast_to_windows(name, data, source_realm_id = 0, origin = nil, from_worker: nil)
5048
+ broadcast_external(name, data, origin, from_worker: from_worker)
5049
+ # Same-isolate main + frame realms (LEGACY single-realm / worker-inbound path — the multi-realm
5050
+ # main-thread post goes through `bc_post`'s ordered queue instead). The poster already delivered
5051
+ # to itself in-VM, so this only reaches the OTHER realms — queue whenever one exists. A WORKER
5052
+ # post reaches realm 0 and every frame (a separate isolate delivered to none in-VM). A FRAME post
5053
+ # always reaches main realm 0 — even when the posting frame's own realm isn't yet recorded in
5054
+ # `frame_realms` (a BroadcastChannel posted SYNCHRONOUSLY during the frame's initial script runs
5055
+ # before the realm is registered, so `has_frames` can be false though a valid target — main —
5056
+ # exists). A MAIN post reaches the frames only when some are registered.
5057
+ has_frames = @runtime.respond_to?(:frame_realm_ids) && @runtime.frame_realm_ids.any?
5058
+ from_frame = !from_worker && !source_realm_id.nil? && source_realm_id != 0
5059
+ enqueue_broadcast(name, data, source_realm_id, origin) if has_frames || from_worker || from_frame
5060
+ end
5061
+
5062
+ # Fan a BroadcastChannel post out beyond this isolate's main-thread realms: OTHER top-level windows
5063
+ # (separate isolates, via the Driver) and every live WORKER (separate isolate, via its thread-safe
5064
+ # inbox, except the posting worker). Shared by the legacy `broadcast_to_windows` and the ordered
5065
+ # `bc_post` — a same-isolate ordered post still reaches workers and other windows. `origin` here is
5066
+ # the SCOPING origin KEY (opaque token / tuple origin), which the worker + cross-window sides match on.
5067
+ def broadcast_external(name, data, origin, from_worker: nil)
5068
+ @driver.broadcast_channel(self, name.to_s, data, origin) if @driver.respond_to?(:broadcast_channel)
5069
+ # Skip a worker whose thread has already exited (self-close / termination in flight): it will
5070
+ # never drain its inbox, so pushing would leak. Counted in a dedicated pending tally so `settle`
5071
+ # waits until the worker acks the delivery (a `bcack`).
5072
+ @workers.each do |h, w|
5073
+ next if h == from_worker || !w[:thread].alive?
5074
+ @worker_broadcast_pending += 1
5075
+ w[:inbox] << {kind: 'broadcast', name: name.to_s, data: data, origin: origin}
5076
+ end
5077
+ end
5078
+
5079
+ # ── BroadcastChannel isolate-wide ordered delivery (multi-realm path) ──
5080
+ # A channel registers on construction with the isolate-wide creation counter, so delivery can be
5081
+ # ordered "oldest channel first" across realms.
5082
+ def bc_register(realm_id, local_id, name, origin_key)
5083
+ @bc_seq += 1
5084
+ @bc_registry[[realm_id.to_i, local_id.to_i]] = {seq: @bc_seq, name: name.to_s, origin_key: origin_key, closed: false}
5085
+ nil
5086
+ end
5087
+
5088
+ def bc_unregister(realm_id, local_id)
5089
+ @bc_registry.delete([realm_id.to_i, local_id.to_i])
5090
+ nil
5091
+ end
5092
+
5093
+ # Is another same-isolate realm (a frame / same-isolate window) live? Only then does a post use the
5094
+ # ordered registry; a single-realm page keeps the in-VM microtask path (zero behaviour change).
5095
+ def bc_siblings_exist? = @runtime.respond_to?(:frame_realm_ids) && @runtime.frame_realm_ids.any?
5096
+
5097
+ # A main-thread BroadcastChannel post in multi-realm mode. Snapshot the eligible target channels
5098
+ # (same name + origin, still open, excluding the poster) at POST TIME, ordered by creation seq, and
5099
+ # queue one ordered delivery each — so a channel created AFTER this post (higher seq / not yet
5100
+ # registered) never receives it, and cross-realm targets interleave with same-realm ones by
5101
+ # creation order (broadcastchannel/ordering). Also fans out to workers + other windows.
5102
+ def bc_post(realm_id, local_id, name, origin_key, data, origin)
5103
+ realm_id = realm_id.to_i
5104
+ local_id = local_id.to_i
5105
+ name = name.to_s
5106
+ # Snapshot ALL matching open channels — do NOT gate on `frame_realm_alive?` here: a channel that
5107
+ # posts SYNCHRONOUSLY during its frame's initial script (opaque-origin's data: iframes) runs in a
5108
+ # realm not yet registered in `frame_realms`, so a same-realm sibling would be wrongly excluded.
5109
+ # `deliver_broadcast_queue` re-checks realm liveness at delivery time (by then it's registered),
5110
+ # and a genuinely-dead realm's stale entry just yields a skipped delivery.
5111
+ targets = @bc_registry.reject {|(rid, lid), e|
5112
+ e[:closed] || e[:name] != name || e[:origin_key] != origin_key ||
5113
+ (rid == realm_id && lid == local_id)
5114
+ }.sort_by {|_k, e| e[:seq] }
5115
+ # `origin` (serialized, e.g. "null") is the MessageEvent.origin the same-isolate delivery
5116
+ # exposes; `origin_key` (e.g. "opaque:…") is the SCOPING token workers / other windows match on.
5117
+ targets.each {|(rid, lid), _e| @bc_queue << {realm_id: rid, local_id: lid, data: data, origin: origin} }
5118
+ broadcast_external(name, data, origin_key)
5119
+ nil
5120
+ end
5121
+
5122
+ # Drain the ordered BroadcastChannel queue, delivering one message to one channel at a time in
5123
+ # creation order. A handler's synchronous re-post appends to `@bc_queue` (via bc_post), so the loop
5124
+ # keeps draining those in order too — reproducing the single global task queue the spec describes.
5125
+ # Bounded per call (like every drain loop here): a pathological mutual re-post between two realms
5126
+ # would otherwise spin forever holding the GVL. The cap leaves any remainder queued
5127
+ # (`window_message_pending?` keeps the loop live), so a runaway is bounded by the runner's
5128
+ # force-timeout across frames rather than hanging uninterruptibly in one.
5129
+ def deliver_broadcast_queue
5130
+ n = 0
5131
+ until @bc_queue.empty?
5132
+ break if n >= BROADCAST_DRAIN_CAP
5133
+ item = @bc_queue.shift
5134
+ e = @bc_registry[[item[:realm_id], item[:local_id]]]
5135
+ next if e.nil? || e[:closed] # closed after being queued → gets nothing
5136
+ if item[:realm_id].zero?
5137
+ @runtime.call('__csim_bcDeliverOne', item[:local_id], item[:data], item[:origin])
5138
+ elsif @runtime.frame_realm_alive?(item[:realm_id])
5139
+ @runtime.realm_call(item[:realm_id], '__csim_bcDeliverOne', item[:local_id], item[:data], item[:origin])
5140
+ end
5141
+ n += 1
3673
5142
  end
5143
+ n
3674
5144
  end
3675
5145
 
3676
5146
  # ── Image decode (libvips) ─────────────────────────────────────
@@ -3687,29 +5157,210 @@ module Capybara
3687
5157
  # 8900×8900 frames Discourse uploads exercise. Optional
3688
5158
  # `max_w`/`max_h` lets the caller pre-shrink for cheap OCR-style
3689
5159
  # "downscale before pixel-touch" flows.
5160
+ # Load an image resource for an `<img>` (or a pattern/drawImage source):
5161
+ # resolve the URL against the current document, fetch the bytes, and decode
5162
+ # them to an RGBA buffer via libvips. Returns `{width, height, refId}` (the
5163
+ # raw pixels ride the transfer registry, like decode_image) or nil when the
5164
+ # fetch or decode fails (a broken image → the `<img>` fires `error`).
5165
+ # Fetch + decode an `<img>` resource to an RGBA bitmap for the drawImage /
5166
+ # createPattern surface, memoized by resolved URL (see `@@image_cache`). Returns
5167
+ # {'width','height','refId'} — a FRESH transfer stash per call, since
5168
+ # `fetchTransfer` consumes the registry entry — or nil when the resource can't be
5169
+ # fetched or decoded (the caller fires `error`). A scheme with no host-side reader
5170
+ # yet (blob:, whose bytes live in the VM) returns {'unsupported' => true} so the
5171
+ # caller stays inert rather than reporting a spuriously-broken image.
5172
+ # `cors` (a `crossorigin` <img>) fetches under CORS: a cross-origin response without a
5173
+ # matching Access-Control-Allow-Origin fails the load. `credentials` is 'include' for
5174
+ # crossorigin="use-credentials", 'same-origin' (uncredentialed cross-origin) otherwise.
5175
+ def load_image(url, cors = false, credentials = 'same-origin')
5176
+ key = resolve_against_current(url.to_s)
5177
+ return nil unless key.is_a?(String)
5178
+ entry = cached_image(key, cors, credentials)
5179
+ return {'unsupported' => true} if entry == :unsupported
5180
+ # A valid zero-area image: complete + not broken, but no pixels. rsvg throws
5181
+ # before dimensions can be read, so the intrinsic size collapses to 0×0 (a
5182
+ # browser would keep the non-zero axis, e.g. 0×100 → naturalHeight 100) — a minor
5183
+ # divergence, immaterial to createPattern / drawImage, which both need a
5184
+ # non-zero area.
5185
+ tainted = image_tainted?(key, cors)
5186
+ return {'zeroSize' => true, 'width' => 0, 'height' => 0, 'tainted' => tainted} if entry == :zero_size
5187
+ return nil unless entry
5188
+ r = {'width' => entry['width'], 'height' => entry['height'], 'refId' => transfer_buffer_stash(entry['bytes']), 'colorSpace' => entry['colorSpace'], 'tainted' => tainted}
5189
+ r['refIdP3'] = transfer_buffer_stash(entry['bytesP3']) if entry['bytesP3']
5190
+ r
5191
+ end
5192
+
5193
+ # Whether a successfully-loaded image taints a canvas it's drawn into: its bytes came
5194
+ # cross-origin without CORS approval — i.e. a no-cors http(s) load from a different origin
5195
+ # than the document. A CORS load that reached here passed the Access-Control check (so it's
5196
+ # origin-clean), and same-origin / data: images are always clean. An opaque-origin document
5197
+ # (data:/srcdoc/sandboxed — nil origin) is cross-origin to every http(s) image, so any such
5198
+ # image taints it.
5199
+ private def image_tainted?(key, cors)
5200
+ return false if cors || !key.match?(%r{\Ahttps?://}i)
5201
+ img = url_origin(key)
5202
+ return false unless img
5203
+ doc = url_origin(@current_url)
5204
+ doc.nil? || doc != img
5205
+ end
5206
+
5207
+ # A decoded-image cache entry for `key`, decoding + caching on a miss.
5208
+ # :unsupported for a scheme we can't fetch host-side; nil on fetch/decode failure.
5209
+ # A CORS load caches under a key tagged with the mode, the credentials, AND the requesting
5210
+ # document origin — its success/failure is origin-dependent (a response's ACAO may allow one
5211
+ # origin and not another), and @@image_cache is process-wide (shared across documents /
5212
+ # sessions), so an origin-blind key would serve one origin's CORS success to another origin
5213
+ # whose load should fail. A no-cors load is origin-independent (it always reads the bytes),
5214
+ # so it keeps the bare URL key and shares the cache with the decode / canvas loaders.
5215
+ private def cached_image(key, cors = false, credentials = 'same-origin')
5216
+ cache_key = cors ? "cors:#{credentials}:#{url_origin(@current_url)}:#{key}" : key
5217
+ cached = @@image_cache_lock.synchronize { @@image_cache[cache_key] }
5218
+ return cached if cached
5219
+ bytes = image_source_bytes(key, cors, credentials)
5220
+ return bytes if bytes == :unsupported
5221
+ return nil unless bytes
5222
+ entry = decode_or_nil(bytes)
5223
+ # nil (broken) and :zero_size (valid but zero-area) both carry no bitmap to cache.
5224
+ return entry unless entry.is_a?(Hash)
5225
+ @@image_cache_lock.synchronize do
5226
+ @@image_cache.clear if @@image_cache.size >= IMAGE_CACHE_MAX
5227
+ @@image_cache[cache_key] = entry
5228
+ end
5229
+ entry
5230
+ end
5231
+
5232
+ # Raw (encoded) bytes for an image URL: `data:` decoded inline, http(s) via a
5233
+ # binary-safe fetch (the raw bytes ride `body_b64`; the text body would mangle
5234
+ # non-ASCII image bytes). A `cors` load threads 'cors' mode + credentials into the
5235
+ # fetch, so rack_fetch's Access-Control enforcement rejects (→ nil) a cross-origin
5236
+ # response with no matching ACAO. :unsupported for a scheme with no host-side reader,
5237
+ # nil for a missing / failed / CORS-rejected / empty resource.
5238
+ private def image_source_bytes(key, cors = false, credentials = 'same-origin')
5239
+ if key.start_with?('data:')
5240
+ bytes = decode_data_url_body(key)
5241
+ bytes.empty? ? nil : bytes
5242
+ elsif key.match?(%r{\Ahttps?://}i)
5243
+ result = rack_fetch('GET', key, '', {}, 'follow', cors ? 'cors' : nil, credentials: credentials)
5244
+ return nil unless result && result['status'].to_i < 400
5245
+ bytes = result['body_b64'] ? Base64.decode64(result['body_b64']) : result['body'].to_s.b
5246
+ bytes.empty? ? nil : bytes
5247
+ else
5248
+ :unsupported
5249
+ end
5250
+ end
5251
+
5252
+ # Decode a base64-encoded image (createImageBitmap's blob path), optionally
5253
+ # downscaled to fit within (max_w, max_h) via its resize options.
3690
5254
  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
5255
+ entry = decode_or_nil(Base64.decode64(b64_bytes.to_s), max_w, max_h)
5256
+ # nil (broken) or :zero_size — createImageBitmap of either rejects (a zero-area
5257
+ # source is an InvalidStateError), so surface nil for the caller to reject on.
5258
+ return nil unless entry.is_a?(Hash)
5259
+ r = {'width' => entry['width'], 'height' => entry['height'], 'refId' => transfer_buffer_stash(entry['bytes']), 'colorSpace' => entry['colorSpace']}
5260
+ r['refIdP3'] = transfer_buffer_stash(entry['bytesP3']) if entry['bytesP3']
5261
+ r
5262
+ end
5263
+
5264
+ # Decode an encoded image (PNG/JPEG/GIF/WEBP/SVG/…) to a packed RGBA bitmap via
5265
+ # libvips, optionally downscaled to fit within (max_w, max_h). `access:
5266
+ # :sequential` keeps libvips from applying the source ICC profile mid-stream (it
5267
+ # shifts RGBA by ±2 vs a raw decode). Returns {'width','height','bytes','colorSpace'};
5268
+ # `colorSpace` ('srgb' | 'display-p3') tells drawImage which space the bytes are in
5269
+ # so it can convert into the destination canvas's colour space.
5270
+ #
5271
+ # Detection is by the profile's description text (both Display-P3 and the sRGB /
5272
+ # Adobe profiles carry a readable name). A plain (unprofiled) OR sRGB-profiled RGB
5273
+ # image is trusted as sRGB verbatim — the common case (incl. photos exported as
5274
+ # sRGB) stays byte-identical. A Display-P3 image keeps its raw bytes: a Display-P3
5275
+ # PNG already stores P3-encoded values, so we only TAG it and let drawImage do the
5276
+ # gamut conversion. Adobe-RGB and CMYK aren't among the two predefined canvas colour
5277
+ # spaces, so they're ICC-transformed to sRGB (their wide gamut is lost — a documented
5278
+ # gap). Any other profiled non-RGB source (grayscale / Lab) is colour-converted to
5279
+ # sRGB so it lands as packed RGB, never kept raw.
5280
+ private def decode_rgba(bytes, max_w = nil, max_h = nil)
5281
+ require 'vips' unless defined?(Vips)
5282
+ img = Vips::Image.new_from_buffer(bytes, '', access: :sequential)
5283
+ # RGB (incl. 16-bit `rgb16`); a non-RGB profiled source is colour-converted below.
5284
+ rgb = %i[srgb rgb rgb16].include?(img.interpretation)
5285
+ color_space = 'srgb'
5286
+ p3_img = nil # a second, Display-P3 rendering for a wide-gamut source (see below)
5287
+ if img.get_fields.include?('icc-profile-data')
5288
+ icc = img.get('icc-profile-data')
5289
+ if rgb && (icc.include?('Display P3') || icc.include?('DCI-P3'))
5290
+ color_space = 'display-p3' # wide-gamut RGB: raw bytes are already P3-encoded
5291
+ elsif img.interpretation == :cmyk || icc.include?('Adobe')
5292
+ # A wide-gamut (Adobe-RGB / CMYK) profile can't be represented by a single
5293
+ # buffer: an sRGB canvas needs the colour CLIPPED to sRGB, a Display-P3 canvas
5294
+ # needs it PRESERVED in P3 (and those differ — ICC gamut-mapping to sRGB isn't a
5295
+ # matrix clip of the P3 value). So decode BOTH renderings via libvips' built-in
5296
+ # profiles; drawImage picks by the destination canvas's colour space. icc_transform
5297
+ # needs random access, so re-decode without `access: :sequential`.
5298
+ base = Vips::Image.new_from_buffer(bytes, '')
5299
+ begin
5300
+ img = base.icc_transform('srgb', embedded: true)
5301
+ p3_img = base.icc_transform('p3', embedded: true)
5302
+ rescue StandardError
5303
+ img = img.colourspace('srgb'); p3_img = nil
5304
+ end
5305
+ elsif !rgb
5306
+ img = img.colourspace('srgb') # profiled grayscale / Lab → packed sRGB RGB
3709
5307
  end
3710
- raw = img.write_to_memory
3711
- {'width' => img.width, 'height' => img.height, 'refId' => transfer_buffer_stash(raw)}
3712
- }
5308
+ # else: an sRGB-profiled (or other) RGB image → trust the raw bytes as sRGB.
5309
+ elsif !rgb
5310
+ img = img.colourspace('srgb')
5311
+ end
5312
+ pack = lambda do |i|
5313
+ i = i.cast('uchar', shift: true) if i.format == :ushort # 16-bit source → 8-bit, scaled
5314
+ i = i.bandjoin(255) if i.bands < 4
5315
+ i
5316
+ end
5317
+ img = pack.call(img)
5318
+ p3_img = pack.call(p3_img) if p3_img
5319
+ if max_w && max_h && max_w.to_i > 0 && max_h.to_i > 0 &&
5320
+ (img.width > max_w.to_i || img.height > max_h.to_i)
5321
+ shrink = [img.width.to_f / max_w.to_i, img.height.to_f / max_h.to_i].max
5322
+ if shrink > 1
5323
+ img = img.resize(1.0 / shrink)
5324
+ p3_img = p3_img.resize(1.0 / shrink) if p3_img
5325
+ end
5326
+ end
5327
+ out = {'width' => img.width, 'height' => img.height, 'bytes' => img.write_to_memory, 'colorSpace' => color_space}
5328
+ # The P3 rendering is best-effort: libvips is lazy, so an ICC fault surfaces only
5329
+ # here at sink evaluation — it must not break the already-rendered sRGB image, just
5330
+ # drop the wide-gamut variant (that image then won't preserve wide colours in a P3 canvas).
5331
+ if p3_img
5332
+ begin
5333
+ out['bytesP3'] = p3_img.write_to_memory
5334
+ rescue StandardError
5335
+ nil
5336
+ end
5337
+ end
5338
+ out
5339
+ end
5340
+
5341
+ # `decode_rgba` guarded. Outcomes:
5342
+ # Hash — a decoded {'width','height','bytes'} bitmap
5343
+ # :zero_size — a VALID image with a non-positive intrinsic dimension (rsvg refuses
5344
+ # to rasterize an SVG whose width|height is 0). Browsers still load it,
5345
+ # reporting a zero-area bitmap, so this is "available but empty"
5346
+ # (bad usability → createPattern null / drawImage no-op), NOT broken.
5347
+ # nil — an undecodable / corrupt body: a normal "broken image" outcome
5348
+ # (the caller fires `error` / rejects the ImageBitmap promise)
5349
+ # A Vips::Error is a quiet outcome, not stderr noise; genuine host faults (missing
5350
+ # libvips, OOM) still warn via `host_image_op`. The `bad dimensions` signal is the
5351
+ # rsvg loader's own diagnostic for a non-positive canvas (libvips-version-coupled
5352
+ # text; a corrupt raster / malformed SVG raises a different, non-matching message);
5353
+ # it also covers a fully DIMENSIONLESS SVG that a browser would instead render at
5354
+ # the 300×150 CSS default — a bounded, documented divergence, still a net
5355
+ # improvement over the old nil→broken→InvalidStateError.
5356
+ private def decode_or_nil(bytes, max_w = nil, max_h = nil)
5357
+ require 'vips' unless defined?(Vips)
5358
+ decode_rgba(bytes, max_w, max_h)
5359
+ rescue Vips::Error => e
5360
+ e.message.include?('bad dimensions') ? :zero_size : nil
5361
+ rescue LoadError, StandardError => e
5362
+ warn "[capybara-simulated] image decode failed: #{e.class}: #{e.message[0, 200]}"
5363
+ nil
3713
5364
  end
3714
5365
 
3715
5366
  private def host_image_op(name)
@@ -3719,6 +5370,346 @@ module Capybara
3719
5370
  nil
3720
5371
  end
3721
5372
 
5373
+ # Render a line of text to a coverage mask via libvips (pango / fontconfig),
5374
+ # backing the canvas `fillText` / `measureText` surface with real system-font
5375
+ # glyphs and metrics — no bundled font, so any installed family works. `font`
5376
+ # is a pango font string ("Sans Bold 16"); at dpi 72 the point size equals CSS
5377
+ # px. Returns `{width, height, xoffset, yoffset, ascent, descent[, refId]}`:
5378
+ # the image is cropped to the INK box, and (xoffset, yoffset) locate that box
5379
+ # within the logical layout, so the JS side can place the alphabetic baseline.
5380
+ # `measure_only` skips rasterizing the mask (the lazy image already knows its
5381
+ # dimensions) — the cheap path for `measureText`.
5382
+ def render_text(text, font, measure_only = false, font_url = nil, kerning = nil)
5383
+ host_image_op('render_text') {
5384
+ require 'vips' unless defined?(Vips)
5385
+ pango = font.to_s.empty? ? 'Sans 10' : font.to_s
5386
+ fontfile = font_url && !font_url.to_s.empty? ? font_file_for(font_url) : nil
5387
+ # Ascent/descent are properties of the FONT, not the variant: probe them with a
5388
+ # small-caps-stripped description so the descender probe ('gjpqy') isn't rendered
5389
+ # as (descenderless) small capitals, which would collapse the reported descent.
5390
+ asc, desc = font_vmetrics(pango.sub(/ Small-Caps\b/i, ''), fontfile)
5391
+ # NUL takes up no space and would abort the pango render; drop it so a lone
5392
+ # "\0" measures/draws as empty rather than falling back to a fabricated width.
5393
+ str = text.to_s.delete("\u0000")
5394
+ return {'width' => 0, 'advance' => 0, 'height' => 0, 'xoffset' => 0, 'yoffset' => 0, 'ascent' => asc, 'descent' => desc} if str.empty?
5395
+
5396
+ # `Vips::Image.text` parses Pango markup — canvas text is always literal,
5397
+ # so escape the markup metacharacters (an unescaped `&`/`<` would raise,
5398
+ # silently dropping the text; `<b>…` would wrongly render as bold).
5399
+ markup = str.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;')
5400
+ # `fontKerning = 'none'` disables the OpenType `kern` feature via a Pango markup
5401
+ # span, so a system font's rendered advance widens to the un-kerned width. ('auto'
5402
+ # / 'normal' leave pango's default kerning on.) A downloaded font's advance comes
5403
+ # from hmtx and is unaffected.
5404
+ markup = %(<span font_features="kern=0">#{markup}</span>) if kerning == 'none'
5405
+ # Render the whole line at its natural width; the caller condenses it
5406
+ # horizontally to honor canvas maxWidth (pango `width:` would word-WRAP,
5407
+ # which the canvas text algorithm never does). An @font-face family loads its
5408
+ # own font via `fontfile:` so pango resolves it (vips needs fontconfig support).
5409
+ img = text_image(markup, pango, fontfile)
5410
+ # `width` is the INK width (vips crops to it); `advance` is the pen movement
5411
+ # (`measureText().width`), which a downloaded font's hmtx gives exactly and
5412
+ # which falls back to the ink width for a system font we can't parse — or for a
5413
+ # string whose codepoints the (BMP) cmap doesn't map yet still renders ink
5414
+ # (astral / symbol-cmap), where a computed 0 advance would be wrong.
5415
+ adv = fontfile && font_advance_px(pango, fontfile, str)
5416
+ advance = adv && adv > 0 ? adv : img.width
5417
+ em_asc, em_desc = fontfile ? font_em_vmetrics(pango, fontfile) : nil
5418
+ res = {
5419
+ 'width' => img.width,
5420
+ 'advance' => advance,
5421
+ 'height' => img.height,
5422
+ 'xoffset' => img.get('xoffset'),
5423
+ 'yoffset' => img.get('yoffset'),
5424
+ 'ascent' => asc,
5425
+ 'descent' => desc,
5426
+ 'emAscent' => em_asc || asc,
5427
+ 'emDescent' => em_desc || desc
5428
+ }
5429
+ res.merge!(font_base_metrics(pango, fontfile) || {}) if fontfile # BASE-table baselines, when present
5430
+ unless measure_only
5431
+ img = img.cast('uchar') unless img.format == :uchar
5432
+ res['refId'] = transfer_buffer_stash(img.write_to_memory)
5433
+ end
5434
+ res
5435
+ }
5436
+ end
5437
+
5438
+ # `Vips::Image.text` with the optional `fontfile:` (an @font-face's downloaded
5439
+ # font), which pango loads so it can resolve that family. Passing a nil fontfile
5440
+ # would raise, so branch — a system-font family resolves through fontconfig.
5441
+ private def text_image(markup, pango, fontfile)
5442
+ if fontfile
5443
+ Vips::Image.text(markup, font: pango, fontfile: fontfile, dpi: 72)
5444
+ else
5445
+ Vips::Image.text(markup, font: pango, dpi: 72)
5446
+ end
5447
+ end
5448
+
5449
+ # Ascent (baseline offset from the logical top) and descent for a pango font,
5450
+ # probed once and cached. A no-descender cap/ascender string's ink bottom is
5451
+ # the baseline (ascent); a descender string's ink bottom minus that is the
5452
+ # descent. dpi 72 keeps units in CSS px. `fontfile` (an @font-face font) is part
5453
+ # of the cache key so a downloaded family's metrics don't collide with a system one.
5454
+ private def font_vmetrics(pango, fontfile = nil)
5455
+ key = fontfile ? "#{pango}\0#{fontfile}" : pango
5456
+ cached = @font_vmetrics_lock.synchronize { @font_vmetrics[key] }
5457
+ return cached if cached
5458
+
5459
+ # A downloaded @font-face carries its own typographic metrics, and pango lays
5460
+ # it out on those, so the baseline (ascent) comes from the font's OS/2 typo
5461
+ # ascender/descender scaled to the pixel size — the ink-string heuristic below
5462
+ # is only a fallback for the ambient system font, whose file we don't have.
5463
+ asc, desc = font_typo_vmetrics(pango, fontfile) if fontfile
5464
+ unless asc
5465
+ asc = begin
5466
+ r = text_image('Mbdfhklt', pango, fontfile)
5467
+ r.get('yoffset') + r.height
5468
+ rescue StandardError
5469
+ 10
5470
+ end
5471
+ desc = begin
5472
+ r = text_image('gjpqy', pango, fontfile)
5473
+ [(r.get('yoffset') + r.height) - asc, 0].max
5474
+ rescue StandardError
5475
+ (asc * 0.25).round
5476
+ end
5477
+ end
5478
+ @font_vmetrics_lock.synchronize { @font_vmetrics[key] ||= [asc, desc] }
5479
+ end
5480
+
5481
+ # [ascent, descent] in px from a font file's OS/2 typographic metrics scaled to
5482
+ # the pango string's point size (dpi 72 → px), or nil if it can't be read. This is
5483
+ # the FONT bounding box / baseline value (fontBoundingBoxAscent), typo-ascender
5484
+ # over unitsPerEm.
5485
+ private def font_typo_vmetrics(pango, fontfile)
5486
+ m = font_typo_units(fontfile) or return nil
5487
+ upm, ta, td = m
5488
+ size = font_size_of(pango)
5489
+ [(ta * size / upm).round, [(-td * size / upm).round, 0].max]
5490
+ end
5491
+
5492
+ # [emHeightAscent, emHeightDescent] in px: the em square (= font size) split by the
5493
+ # baseline at the typo ascender:descender ratio — NOT normalized by unitsPerEm, so a
5494
+ # font whose ascender+descender ≠ em still fills the em (e.g. descent-0 → all ascent).
5495
+ private def font_em_vmetrics(pango, fontfile)
5496
+ m = font_typo_units(fontfile) or return nil
5497
+ _upm, ta, td = m
5498
+ span = ta + (-td)
5499
+ return nil unless span.positive?
5500
+ size = font_size_of(pango)
5501
+ ea = size * ta / span.to_f
5502
+ [ea.round, (size - ea).round]
5503
+ end
5504
+
5505
+ # The point size in a pango font string ("CanvasTest 40" → 40); 10 as a fallback.
5506
+ # The size is a whitespace-separated trailing token, so a family that itself ends
5507
+ # in a digit ("B612") without a size isn't misread as one.
5508
+ private def font_size_of(pango)
5509
+ size = pango.to_s[/\s(\d+(?:\.\d+)?)\s*\z/, 1].to_f
5510
+ size.positive? ? size : 10.0
5511
+ end
5512
+
5513
+ # [unitsPerEm, sTypoAscender, sTypoDescender] from a TrueType/OpenType file's
5514
+ # `head` + `OS/2` tables, or nil.
5515
+ private def font_typo_units(fontfile)
5516
+ g = font_glyph_data(fontfile) or return nil
5517
+ [g[:upm], g[:typo_asc], g[:typo_desc]]
5518
+ end
5519
+
5520
+ # The advance width (pen movement) of `text` in the font, in px at the pango
5521
+ # string's size — the value `measureText().width` reports. vips crops to ink, so
5522
+ # the advance (which includes side bearings) comes from the font's own hmtx table.
5523
+ # nil if the font can't be parsed.
5524
+ private def font_advance_px(pango, fontfile, text)
5525
+ g = font_glyph_data(fontfile) or return nil
5526
+ size = font_size_of(pango)
5527
+ units = text.to_s.each_char.sum do |ch|
5528
+ gid = g[:cmap][ch.ord]
5529
+ next 0 unless gid # a codepoint the font doesn't map (null, control) advances nothing
5530
+ g[:advances][gid] || g[:advances].last || 0
5531
+ end
5532
+ units * size / g[:upm]
5533
+ end
5534
+
5535
+ # Parse the glyph tables a canvas text metric needs out of a TrueType/OpenType
5536
+ # file, memoized per path: unitsPerEm + typo metrics (head / OS/2), the per-glyph
5537
+ # advance widths (hmtx), and a Unicode → glyph-id map (cmap format 4). nil when a
5538
+ # required table is missing or malformed.
5539
+ private def font_glyph_data(fontfile)
5540
+ (@font_glyph ||= {})
5541
+ return @font_glyph[fontfile] if @font_glyph.key?(fontfile)
5542
+ @font_glyph[fontfile] = parse_font_glyph_data(fontfile)
5543
+ end
5544
+
5545
+ private def parse_font_glyph_data(fontfile)
5546
+ data = File.binread(fontfile)
5547
+ n = data[4, 2].unpack1('n')
5548
+ tabs = {}
5549
+ 12.step(12 + (n - 1) * 16, 16) { |o| tabs[data[o, 4]] = data[o + 8, 4].unpack1('N') }
5550
+ head = tabs['head']; os2 = tabs['OS/2']; hhea = tabs['hhea']; hmtx = tabs['hmtx']; cmap = tabs['cmap']
5551
+ return nil unless head && hhea && hmtx && cmap
5552
+ upm = data[head + 18, 2].unpack1('n')
5553
+ return nil unless upm.positive?
5554
+ num_h = data[hhea + 34, 2].unpack1('n')
5555
+ advances = (0...num_h).map { |i| data[hmtx + i * 4, 2].unpack1('n') }
5556
+ {
5557
+ upm: upm,
5558
+ typo_asc: s16(data[(os2 || hhea) + (os2 ? 68 : 4), 2]),
5559
+ typo_desc: s16(data[(os2 || hhea) + (os2 ? 70 : 6), 2]),
5560
+ advances: advances,
5561
+ # A malformed cmap shouldn't discard the (already-read) upm / advances / typo
5562
+ # metrics, so isolate its parse — an empty map just means advances fall back.
5563
+ cmap: (parse_cmap4(data, cmap) rescue {}),
5564
+ # Optional horizontal-baseline coordinates ({tag => font units}) from the `BASE`
5565
+ # table — the alphabetic / hanging / ideographic baselines measureText reports.
5566
+ base: (tabs['BASE'] ? (parse_base_table(data, tabs['BASE']) rescue {}) : {}),
5567
+ }
5568
+ rescue StandardError
5569
+ nil
5570
+ end
5571
+
5572
+ # Horizontal-axis baseline coordinates ({"hang"/"ideo"/"romn"/… => font units},
5573
+ # relative to the script's default baseline) from the first BASE-table script's
5574
+ # BaseValues. Empty when the table is absent or has no coordinates.
5575
+ private def parse_base_table(data, base_off)
5576
+ horiz_rel = data[base_off + 4, 2].unpack1('n') # horizAxisOffset (0 = none)
5577
+ return {} if horiz_rel.zero?
5578
+ horiz = base_off + horiz_rel
5579
+ tag_list = horiz + data[horiz, 2].unpack1('n') # baseTagList (rel. to axis)
5580
+ script_list = horiz + data[horiz + 2, 2].unpack1('n') # baseScriptList (rel. to axis)
5581
+ ntags = data[tag_list, 2].unpack1('n')
5582
+ nscript = data[script_list, 2].unpack1('n')
5583
+ # A count read past a truncated table is nil; `(0...nil)` is an ENDLESS range that
5584
+ # would loop forever (never raising, so the caller's `rescue {}` can't save it).
5585
+ return {} if ntags.nil? || nscript.nil? || nscript.zero?
5586
+ tags = (0...ntags).map { |i| data[tag_list + 2 + i * 4, 4] }
5587
+ # Prefer the DFLT / latn script's baselines; else the first record.
5588
+ recs = (0...nscript).map { |i| o = script_list + 2 + i * 6; [data[o, 4], script_list + data[o + 4, 2].unpack1('n')] }
5589
+ _tag, s_off = recs.find { |t, _| t == 'DFLT' || t == 'latn' } || recs.first
5590
+ bv_rel = data[s_off, 2].unpack1('n') # baseValuesOffset (0 = none)
5591
+ return {} if bv_rel.zero?
5592
+ bv = s_off + bv_rel
5593
+ ncoord = data[bv + 2, 2].unpack1('n')
5594
+ out = {}
5595
+ tags.each_with_index do |t, i|
5596
+ break if i >= ncoord
5597
+ co = bv + data[bv + 4 + i * 2, 2].unpack1('n')
5598
+ out[t] = s16(data[co + 2, 2]) if [1, 2, 3].include?(data[co, 2].unpack1('n')) # BaseCoord formats
5599
+ end
5600
+ out
5601
+ end
5602
+
5603
+ # The alphabetic / hanging / ideographic baselines (px at the pango size) from the
5604
+ # font's BASE table, or nil when the font has none — then the caller heuristically
5605
+ # derives them from the vertical metrics instead.
5606
+ private def font_base_metrics(pango, fontfile)
5607
+ g = font_glyph_data(fontfile) or return nil
5608
+ base = g[:base]
5609
+ return nil if base.nil? || base.empty?
5610
+ scale = font_size_of(pango) / g[:upm].to_f
5611
+ romn = base['romn'] || 0 # the alphabetic baseline = the reference
5612
+ {
5613
+ 'alphabeticBaseline' => 0.0,
5614
+ 'hangingBaseline' => ((base['hang'] || romn) - romn) * scale,
5615
+ 'ideographicBaseline' => ((base['ideo'] || romn) - romn) * scale,
5616
+ }
5617
+ end
5618
+
5619
+ # A Unicode → glyph-id map from the first format-4 `cmap` subtable (the standard
5620
+ # BMP Unicode encoding), as {codepoint => glyph}. Empty when none is present.
5621
+ private def parse_cmap4(data, cmap)
5622
+ ntab = data[cmap + 2, 2].unpack1('n')
5623
+ return {} if ntab.nil? # a count read past a truncated table → don't loop `(0...nil)` forever
5624
+ # Prefer a Unicode BMP subtable — (3,1) Windows Unicode or (0,*) Unicode — over a
5625
+ # (3,0) Symbol map (which shadows ASCII into the 0xF000 PUA); fall back to any
5626
+ # format-4 table only if no Unicode one is present.
5627
+ best = nil; best_rank = -1
5628
+ (0...ntab).each do |i|
5629
+ rec = cmap + 4 + i * 8
5630
+ pid = data[rec, 2].unpack1('n'); eid = data[rec + 2, 2].unpack1('n')
5631
+ off = data[rec + 4, 4].unpack1('N')
5632
+ next unless data[cmap + off, 2].unpack1('n') == 4
5633
+ rank = pid == 3 && eid == 1 ? 3 : pid.zero? ? 2 : pid == 3 && eid.zero? ? 0 : 1
5634
+ if rank > best_rank then best_rank = rank; best = cmap + off end
5635
+ end
5636
+ sub = best
5637
+ return {} unless sub
5638
+ segx2 = data[sub + 6, 2].unpack1('n'); segc = segx2 / 2
5639
+ endc = sub + 14
5640
+ startc = endc + segx2 + 2
5641
+ iddelta = startc + segx2
5642
+ idrange = iddelta + segx2
5643
+ map = {}
5644
+ (0...segc).each do |s|
5645
+ e = data[endc + s * 2, 2].unpack1('n')
5646
+ st = data[startc + s * 2, 2].unpack1('n')
5647
+ delta = data[iddelta + s * 2, 2].unpack1('n')
5648
+ ro = data[idrange + s * 2, 2].unpack1('n')
5649
+ (st..e).each do |c|
5650
+ next if c == 0xFFFF
5651
+ gid = if ro.zero?
5652
+ (c + delta) & 0xFFFF
5653
+ else
5654
+ gi = idrange + s * 2 + ro + (c - st) * 2
5655
+ g = data[gi, 2].unpack1('n')
5656
+ g.zero? ? 0 : (g + delta) & 0xFFFF
5657
+ end
5658
+ map[c] = gid if gid != 0
5659
+ end
5660
+ end
5661
+ map
5662
+ end
5663
+
5664
+ # A big-endian signed 16-bit value.
5665
+ private def s16(bytes)
5666
+ v = bytes.unpack1('n')
5667
+ v >= 0x8000 ? v - 0x10000 : v
5668
+ end
5669
+
5670
+ # Resolve an @font-face src URL to an on-disk font file pango can load, fetching
5671
+ # the bytes through the Rack app (binary-safe) once and caching the temp path for
5672
+ # the process. Returns nil when the fetch fails.
5673
+ def font_file_for(url)
5674
+ key = resolve_against_current(url.to_s)
5675
+ return nil unless key.is_a?(String)
5676
+ @@font_file_lock.synchronize { return @@font_file_cache[key] if @@font_file_cache.key?(key) }
5677
+ path = build_font_file(key)
5678
+ @@font_file_lock.synchronize { @@font_file_cache[key] = path }
5679
+ path
5680
+ end
5681
+
5682
+ private def build_font_file(key)
5683
+ bytes = font_source_bytes(key)
5684
+ return nil unless bytes && !bytes.empty?
5685
+ require 'tempfile'
5686
+ # Keep the Tempfile object alive for the process so its file isn't reaped while
5687
+ # fontconfig may still read it; the cache holds the path.
5688
+ file = Tempfile.new(['csim-font', File.extname(key)[0, 5]])
5689
+ file.binmode
5690
+ file.write(bytes)
5691
+ file.flush
5692
+ # Pin the handle for the whole process: the path is cached in the CLASS-level
5693
+ # @@font_file_cache and outlives the per-visit Browser that first built it, so
5694
+ # the Tempfile must not be finalized (and its file unlinked) with that instance.
5695
+ @@font_file_lock.synchronize { @@font_files << file }
5696
+ file.path
5697
+ end
5698
+
5699
+ # Raw font bytes for a URL: `data:` inline, http(s) via a binary-safe Rack fetch
5700
+ # (the raw bytes ride `body_b64`; a text body would mangle the font's non-ASCII).
5701
+ private def font_source_bytes(key)
5702
+ if key.start_with?('data:')
5703
+ bytes = decode_data_url_body(key)
5704
+ bytes.empty? ? nil : bytes
5705
+ elsif key.match?(%r{\Ahttps?://}i)
5706
+ result = rack_fetch('GET', key, '', {}, 'follow')
5707
+ return nil unless result && result['status'].to_i < 400
5708
+ bytes = result['body_b64'] ? Base64.decode64(result['body_b64']) : result['body'].to_s.b
5709
+ bytes.empty? ? nil : bytes
5710
+ end
5711
+ end
5712
+
3722
5713
  def reset_workers
3723
5714
  @workers.each_value do |w|
3724
5715
  w[:inbox] << :terminate
@@ -3726,7 +5717,22 @@ module Capybara
3726
5717
  end
3727
5718
  @workers.clear
3728
5719
  @worker_outbox.clear
3729
- @worker_in_flight = 0
5720
+ @worker_outbox_head = nil
5721
+ @worker_in_flight = 0
5722
+ @worker_broadcast_pending = 0
5723
+ @sw_message_pending = 0
5724
+ @sw_fetch_pending = 0
5725
+ @sw_open_streams.clear
5726
+ @sw_fetch_wait_deadline = nil
5727
+ @sw_msg_wait_deadline = nil
5728
+ @sw_registrations.clear
5729
+ @sw_navpreload = {}
5730
+ @sw_pending_claims = []
5731
+ @sw_clients = {}
5732
+ @sw_realm_controller = {}
5733
+ @focused_realm_id = nil
5734
+ @port_channels = {}
5735
+ @sw_nav_outbox.clear
3730
5736
  @transfer_buffer_lock.synchronize {
3731
5737
  @transfer_buffers.clear
3732
5738
  @transfer_buffer_seq = 0
@@ -3776,26 +5782,11 @@ module Capybara
3776
5782
  # host's last two dot-labels — correct for the single-label public suffixes our
3777
5783
  # in-process hosts use (web-platform.test / not-web-platform.test / *.com); a
3778
5784
  # full Public Suffix List isn't warranted here.
5785
+ # This document's partition "site" (scheme + registrable domain) for Blob-URL / storage
5786
+ # partitioning. A blob: document derives from its inner origin; data:/about: (no host) →
5787
+ # '' (an opaque origin, never same-partition with a real one). See registrable_site.
3779
5788
  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
- ''
5789
+ registrable_site(@current_url) || ''
3799
5790
  end
3800
5791
 
3801
5792
  # WHATWG URL "domain to ASCII" — the JS tr46 stub delegates non-ASCII / xn--
@@ -3978,7 +5969,9 @@ module Capybara
3978
5969
  # Called from the JS bridge when a `<video>` element's `src` is
3979
5970
  # assigned a `blob:` URL. ffprobe extracts dimensions + duration,
3980
5971
  # ffmpeg extracts the first frame as raw RGBA. JS caches both so
3981
- # `canvas.drawImage(video, …)` blits like any ImageBitmap.
5972
+ # `canvas.drawImage(video, …)` blits like any ImageBitmap. A `<video src>` that
5973
+ # points at a served file (http / relative) or a `data:` URL resolves its bytes
5974
+ # the same way — see `video_bytes_b64` below.
3982
5975
  def decode_video_frame(b64_bytes)
3983
5976
  host_image_op('decode_video_frame') {
3984
5977
  bytes = Base64.decode64(b64_bytes.to_s)
@@ -4001,6 +5994,22 @@ module Capybara
4001
5994
  }
4002
5995
  end
4003
5996
 
5997
+ # Fetch a media resource (http / relative URL) and return its bytes base64-encoded,
5998
+ # so a `<video src>` pointing at a served file decodes the same way a blob: / data:
5999
+ # source does. Binary stays Ruby-side; only ASCII base64 crosses into V8. Returns
6000
+ # nil when the fetch fails.
6001
+ def video_bytes_b64(url)
6002
+ result = rack_fetch('GET', url, '', {}, 'follow')
6003
+ return nil unless result && result['status'].to_i < 400
6004
+ # The RAW bytes ride `body_b64` (see response_hash) for any non-ASCII response;
6005
+ # the text `body` field is a UTF-8 re-decode that corrupts binary media. Fall
6006
+ # back to base64-of-body only for a pure-ASCII response (byte-identical there).
6007
+ b64 = result['body_b64']
6008
+ return b64 unless b64.nil? || b64.empty?
6009
+ body = result['body'].to_s
6010
+ body.empty? ? nil : [body].pack('m0')
6011
+ end
6012
+
4004
6013
  private def ffprobe_stream(path)
4005
6014
  json = IO.popen(
4006
6015
  ['ffprobe', '-v', 'error', '-select_streams', 'v:0',
@@ -4071,7 +6080,7 @@ module Capybara
4071
6080
  # `build_worker` factory, evaluates the worker script, then
4072
6081
  # loops draining microtasks + timers + inbox until `:terminate`
4073
6082
  # lands or an exception propagates.
4074
- private def run_worker(handle, url, body, inbox, outbox, engine_class, shared: false, service: false)
6083
+ private def run_worker(handle, url, body, inbox, outbox, engine_class, shared: false, service: false, creator_key: nil, seed: nil)
4075
6084
  # Release the spawn-time `@worker_initializing` count exactly once, however
4076
6085
  # this method exits (normal start, `self.close()`, or an exception), so
4077
6086
  # worker_pending? doesn't stay stuck true forever.
@@ -4081,6 +6090,11 @@ module Capybara
4081
6090
  initializing = false
4082
6091
  @worker_init_lock.synchronize { @worker_initializing -= 1 }
4083
6092
  end
6093
+ # True while THIS thread holds a `@worker_busy` increment for a message it's
6094
+ # mid-handling; balanced in the `ensure` so an exception between the bump and
6095
+ # the matching decrement can't strand the counter (which would pin
6096
+ # `worker_pending?` true forever).
6097
+ busy_held = false
4084
6098
  # Tag this thread so blob URLs created by the worker's script are owned by
4085
6099
  # this handle and revoked on terminate (see blob_register / revoke_worker_blobs).
4086
6100
  Thread.current[:csim_worker_handle] = handle
@@ -4090,17 +6104,99 @@ module Capybara
4090
6104
  # BINARY-tagged (see `RuntimeShared.utf8_text`).
4091
6105
  body = RuntimeShared.utf8_text(body)
4092
6106
  post_back = ->(data) { outbox << {handle: handle, kind: 'message', data: data.to_s} }
4093
- rt = engine_class.build_worker(self, post_back)
6107
+ # A worker's BroadcastChannel post rides the same thread-safe outbox; the main thread fans
6108
+ # it out to the main + frame realms + OTHER workers (see deliver_worker_messages).
6109
+ broadcast_out = ->(name, data, origin) { outbox << {handle: handle, kind: 'broadcast', name: name.to_s, data: data, origin: origin} }
6110
+ # Service-worker → main-thread signals ride the outbox (delivered by deliver_worker_messages):
6111
+ # client.postMessage, clients.claim (→ set the client's controller), and a controlled fetch's
6112
+ # respondWith result. `sw_has_fetch` is snapshotted after the SW script's initial run (the
6113
+ # spec records fetch-handler presence at install time) so a claim can tell the client
6114
+ # whether intercepting is worth the cross-isolate round-trip at all.
6115
+ sw_has_fetch = false
6116
+ sw_hooks = {
6117
+ post_to_client: ->(client_id, data) { outbox << {handle: handle, kind: 'sw_client_msg', client: client_id, data: data.to_s} },
6118
+ # WindowClient.focus() — moving the focus chain is cross-realm browser state, so the
6119
+ # worker asks rather than does. Delivered by deliver_worker_messages, which echoes the
6120
+ # move back to every SW as a `client_focus`.
6121
+ focus_client: ->(client_id) { outbox << {handle: handle, kind: 'sw_client_focus', client: client_id} },
6122
+ # WindowClient.navigate() — like focus_client, the browser owns the act; unlike it, the
6123
+ # worker is waiting on the OUTCOME (final URL / cross-origin / refusal), so the reply
6124
+ # comes back on this worker's inbox keyed by nav_id.
6125
+ navigate_client: ->(client_id, url, nav_id) { outbox << {handle: handle, kind: 'sw_client_navigate', client: client_id, url: url.to_s, nav_id: nav_id.to_i} },
6126
+ claim: -> { outbox << {handle: handle, kind: 'sw_claim', has_fetch: sw_has_fetch} },
6127
+ # skipWaiting() — the waiting slot is client-side, so the request rides the outbox.
6128
+ skip_waiting: -> { outbox << {handle: handle, kind: 'sw_skip_waiting'} },
6129
+ fetch_respond: ->(fetch_id, resp, realm_id) { sw_deliver_fetch_response(handle, fetch_id.to_i, resp.to_s, outbox, realm_id.to_i) },
6130
+ # A streaming respondWith frame (start / chunk / close / error) for a controlled client's
6131
+ # fetch — rides the outbox in emission order so the client realm reassembles the body
6132
+ # stream incrementally (deliver_worker_messages). The request's @sw_fetch_pending stays up
6133
+ # for the whole stream and clears on the terminal (close / error) frame.
6134
+ 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} },
6135
+ # Cross-isolate MessagePort channel: this worker's port endpoint + its outbound messages
6136
+ # ride the outbox (delivered by deliver_worker_messages → the peer client realm).
6137
+ port_endpoint: ->(channel) { outbox << {handle: handle, kind: 'port_endpoint', channel: channel.to_s} },
6138
+ port_post: ->(channel, data) { outbox << {handle: handle, kind: 'port_msg', channel: channel.to_s, data: data.to_s} }
6139
+ }
6140
+ rt = engine_class.build_worker(self, post_back, broadcast_out, sw_hooks)
6141
+ # A worker isolate loads the same snapshot as the main realm, so its `console.*`
6142
+ # is a no-op until `traceActive` is set (console.js). The main realm turns it on
6143
+ # from `CSIM_CONSOLE_STDERR`; without this a worker's console — and anything routed
6144
+ # through it, e.g. an Emscripten `printErr` — is silently dropped during debugging.
6145
+ rt.call('__csimSetTraceActive', true) if CONSOLE_STDERR
6146
+ # This worker's handle — the JS keys cross-isolate MessagePort channel ids on it so they
6147
+ # never collide with another isolate's (see __csim_installWorkerScope's allocator).
6148
+ rt.eval("globalThis.__csimWorkerHandle = #{handle.to_i};")
4094
6149
  # Set the worker's `self.location.href` so webpack /
4095
6150
  # rollup public-path derivation + `new URL(rel, import.meta.url)`
4096
6151
  # resolve chunks against the worker's own origin rather than
4097
6152
  # the snapshot-time `http://placeholder/`.
4098
6153
  rt.eval("globalThis.__csimUpdateLocation(#{JSON.generate(url.to_s)});")
6154
+ # A worker's BroadcastChannel origin KEY (its agent-cluster identity). A blob: worker
6155
+ # INHERITS the creating context's origin (the blob URL carries no real origin of its own);
6156
+ # a data: worker gets a FRESH opaque origin, unique per worker, so it never cross-talks with
6157
+ # its creator or a sibling data: worker. An http(s) worker leaves this unset — the JS derives
6158
+ # its key from `location.origin` (the script's own origin, same as the creator when
6159
+ # same-origin). See `__csimBcOriginKey`.
6160
+ worker_origin_key =
6161
+ if url.to_s.start_with?('blob:')
6162
+ creator_key
6163
+ elsif url.to_s.start_with?('data:')
6164
+ "opaque:worker#{handle}"
6165
+ end
6166
+ rt.eval("globalThis.__csimOriginKey = #{JSON.generate(worker_origin_key)};") if worker_origin_key
4099
6167
  # A service worker runs in a ServiceWorkerGlobalScope: adjust the worker scope
4100
6168
  # (no blob-URL minting; SW lifecycle stubs) BEFORE its script runs.
4101
6169
  rt.eval('__csim_installServiceWorkerScope();') if service
6170
+ # Seed the client mirror BEFORE the script evaluates: `clients.matchAll()` at top level is a
6171
+ # real pattern (clients-matchall-on-evaluation), and an empty mirror there returns nothing.
6172
+ if seed
6173
+ seed[:clients].each {|rec| rt.call('__csim_swRegisterClient', rec) }
6174
+ rt.call('__csim_swNoteFocusedClient', seed[:focused]) if seed[:focused].any?
6175
+ end
4102
6176
  rt.eval(body)
4103
6177
  rt.drain_microtasks
6178
+ # Drive the service worker's lifecycle: fire `install`, then `activate`, draining each
6179
+ # phase's `waitUntil` promises before the next (the client-side ServiceWorker object plays
6180
+ # out the observable installing→installed→activating→activated timeline; here we run the
6181
+ # worker's OWN install/activate handlers so their side effects — caching, importScripts —
6182
+ # execute). See sw-client.js + __csim_swFireLifecycleEvent.
6183
+ if service
6184
+ sw_has_fetch = !!rt.call('__csim_swHasFetchListener')
6185
+ # Publish the fetch-handler snapshot + script URL on the worker record so a NAVIGATION
6186
+ # into this SW's scope can decide (Ruby-side) whether routing through it is worthwhile,
6187
+ # and a freshly-built frame client can mint a `controller` naming this script.
6188
+ if (w = @workers[handle])
6189
+ w[:has_fetch] = sw_has_fetch
6190
+ w[:script_url] = url.to_s
6191
+ end
6192
+ %w[install activate].each do |phase|
6193
+ rt.eval("globalThis.__csim_swFireLifecycleEvent(#{JSON.generate(phase)});")
6194
+ # Drain microtasks AND timers: a `waitUntil` promise may settle off a
6195
+ # setTimeout (e.g. a delayed cache warm-up), so advance the worker clock too.
6196
+ rt.drain_microtasks
6197
+ rt.drain_timers
6198
+ end
6199
+ end
4104
6200
  # A SharedWorker fires `connect` AFTER its script set `self.onconnect`; the
4105
6201
  # connect handler's port post lands in the outbox before release_init, so
4106
6202
  # worker_pending? stays true until it's delivered.
@@ -4116,26 +6212,134 @@ module Capybara
4116
6212
  loop do
4117
6213
  msg = pop_with_timeout(inbox, WORKER_POLL_INTERVAL)
4118
6214
  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).
6215
+ # A main-side BroadcastChannel post to this worker arrives as a {kind:'broadcast'} hash;
6216
+ # deliver it to the worker's channels (the receiver's own origin gate drops cross-origin).
6217
+ # A plain string is a postMessage to the worker.
6218
+ if msg.is_a?(Hash) && msg[:kind] == 'broadcast'
6219
+ # Ack so the main thread releases the broadcast-pending it counted for this delivery
6220
+ # (a listen-only worker never posts back — the ack is the only signal it processed
6221
+ # it). Under `ensure`: the ack is CONTRACTUAL (settle's bounded wait relies on it
6222
+ # see worker_reply_pending?), so a raising channel handler must not leak it.
6223
+ begin
6224
+ rt.call('__csim_deliverBroadcasts', [{'name' => msg[:name], 'data' => msg[:data], 'origin' => msg[:origin]}])
6225
+ ensure
6226
+ outbox << {handle: handle, kind: 'bcack'}
6227
+ end
6228
+ elsif msg.is_a?(Hash) && msg[:kind] == 'sw_message'
6229
+ # A client → service-worker postMessage: dispatch a `message` event with source = the
6230
+ # posting client. Ack AFTER the dispatch — the outbox is FIFO, so a handler's
6231
+ # synchronous `client.postMessage` reply (`sw_client_msg`) is guaranteed to precede
6232
+ # the `swack`; acking first opens a window where the main thread sees the pending
6233
+ # count hit zero (worker_pending? false) while the handler is still running, and the
6234
+ # virtual clock fast-forwards past the caller's timeout before the reply lands. The
6235
+ # `ensure` keeps a raising handler from leaking the counter and hanging settle.
6236
+ begin
6237
+ rt.call('__csim_swClientMessage', msg[:data], msg[:client], msg[:url])
6238
+ ensure
6239
+ outbox << {handle: handle, kind: 'swack'}
6240
+ end
6241
+ elsif msg.is_a?(Hash) && msg[:kind] == 'port_msg'
6242
+ # A client-realm port → its remote peer in THIS worker: deliver to the channel endpoint
6243
+ # port. Counted like an sw_message (client_port_post incremented @sw_message_pending), so
6244
+ # ack AFTER dispatch under `ensure` — a synchronous reply the port handler posts back
6245
+ # (another port_msg on the outbox) is FIFO-guaranteed to precede this swack.
6246
+ begin
6247
+ rt.call('__csimPortChannelDeliver', msg[:channel], msg[:data])
6248
+ ensure
6249
+ outbox << {handle: handle, kind: 'swack'}
6250
+ end
6251
+ elsif msg.is_a?(Hash) && msg[:kind] == 'client_register'
6252
+ # A controlled client (frame/window realm) came into existence: mirror it into the
6253
+ # SW's clientsById so matchAll/getClientByURL see it. Fire-and-forget (no reply /
6254
+ # pending counter): the inbox is FIFO, so it's processed before any later message
6255
+ # whose handler matchAll's the client.
6256
+ rt.call('__csim_swRegisterClient', msg[:client])
6257
+ elsif msg.is_a?(Hash) && msg[:kind] == 'client_focus'
6258
+ # The focus chain moved: `WindowClient.focused` is per-browsing-context state the
6259
+ # worker isolate can't read, so the browser pushes the focused client's id on every
6260
+ # change (and once at registration, for a worker that started after the move).
6261
+ rt.call('__csim_swNoteFocusedClient', msg[:ids])
6262
+ elsif msg.is_a?(Hash) && msg[:kind] == 'client_navigate_result'
6263
+ # The outcome of a WindowClient.navigate() this worker is awaiting — settles the
6264
+ # promise it is holding (js/src/workers.js __csim_swClientNavigateResult).
6265
+ rt.call('__csim_swClientNavigateResult', msg[:nav_id], msg[:url], msg[:client], msg[:error])
6266
+ elsif msg.is_a?(Hash) && msg[:kind] == 'sw_client_message'
6267
+ # A service worker → THIS worker, which is one of its clients: `client.postMessage`
6268
+ # targets the client's `navigator.serviceWorker` 'message' event, which a worker
6269
+ # isolate has just like a document (WorkerNavigator.serviceWorker). Fire-and-forget,
6270
+ # like the register/focus mirrors — the SW's send is not awaiting a reply.
6271
+ rt.call('__csim_swDeliverClientMessage', msg[:data], msg[:handle])
6272
+ elsif msg.is_a?(Hash) && msg[:kind] == 'client_unregister'
6273
+ # The client's realm was disposed — drop it so matchAll stops returning a dead client.
6274
+ rt.call('__csim_swUnregisterClient', msg[:id])
6275
+ elsif msg.is_a?(Hash) && msg[:kind] == 'fetch'
6276
+ # A controlled client's fetch: dispatch a `fetch` event. The SW's respondWith result
6277
+ # (or a fall-through / network-error marker) is posted back as a `fetch_response`
6278
+ # outbox event, which releases the @sw_fetch_pending counted for this request. A
6279
+ # synchronous respondWith posts during the dispatch; an async one posts under the
6280
+ # drain. If the dispatch itself dies (engine raise, Thread#kill) the JS side can
6281
+ # never post — fall the client back to the network so the counter drains (a
6282
+ # duplicate response is harmless: the client's pendingFetch entry is one-shot).
6283
+ dispatched = false
6284
+ begin
6285
+ rt.call('__csim_swDispatchFetch', msg[:req], msg[:fetch_id], msg[:realm_id])
6286
+ dispatched = true
6287
+ ensure
6288
+ sw_deliver_fetch_response(handle, msg[:fetch_id].to_i, '{"fallthrough":true}', outbox, msg[:realm_id].to_i) unless dispatched
6289
+ end
6290
+ elsif msg.is_a?(Hash) && msg[:kind] == 'fetch_cancel'
6291
+ # The client cancelled this streaming respondWith body — cancel the reader
6292
+ # streamDeliver is draining, firing the SW source stream's `cancel()`. Fire-and-forget:
6293
+ # the reader's cancellation resolves its read as done, emitting the terminal frame that
6294
+ # clears @sw_fetch_pending; no ack/counter of its own.
6295
+ rt.call('__csim_swStreamCancel', msg[:fetch_id])
6296
+ elsif msg
6297
+ # Mark BUSY for the whole span of this postMessage handler. Unlike the SW /
6298
+ # broadcast branches above (each tracked by its own pending counter),
6299
+ # `@worker_in_flight` under-counts a multi-reply handshake to 0 mid-flight (one
6300
+ # request → many progress replies + a final resolve), so `worker_pending?` would
6301
+ # go false while the worker is still working and settle would abandon the
6302
+ # protocol between replies. Keep it true until the handler returns.
6303
+ @worker_init_lock.synchronize { @worker_busy += 1 }
6304
+ busy_held = true
6305
+ # A plain worker postMessage handler can start a multi-stage async bring-up
6306
+ # that alternates microtasks and timers — most sharply Emscripten's WASM
6307
+ # runtime init (addRunDependency → read the binary on a setTimeout(0) →
6308
+ # WebAssembly.instantiate → removeRunDependency → run() → onRuntimeInitialized
6309
+ # → the module factory's `.then`). Draining once leaves the microtask layers a
6310
+ # fired timer queued *after the last timer* stranded, so the factory promise
6311
+ # never settles (Tesseract hangs at "initializing tesseract"). Run the worker's
6312
+ # own event loop to quiescence instead of a single gated tick.
6313
+ rt.call('__csim_workerOnMessage', msg)
6314
+ drive_worker_to_quiescence(rt)
6315
+ end
6316
+ # Drive the worker's OWN event loop each tick: an AUTONOMOUS loop (the dispatcher
6317
+ # executor-worker's receive→fetch→setTimeout retry, which has no inbox message)
6318
+ # may have pending timers. Drain ~one poll interval (WorkerRuntime#drain_timers
6319
+ # advances the worker clock a step) so they progress; worker http fetch is
6320
+ # setTimeout(0)+__rackFetch, resolved on this thread by the drain. Gated on a
6321
+ # PENDING timer (any, not just due-now — the clock must advance to fire a future
6322
+ # randomDelay) so an idle message-driven worker with no timers stays lazy. A
6323
+ # regular postMessage already drove itself to quiescence above (no timer left),
6324
+ # so this is a no-op for it. Host CALLS, not string `eval`, keep the per-tick
6325
+ # cost off the V8 compile path (rule 3).
4129
6326
  if rt.call('__nextTimerDelay').to_f >= 0
4130
6327
  rt.drain_microtasks
4131
6328
  rt.drain_timers
4132
6329
  end
6330
+ if busy_held
6331
+ @worker_init_lock.synchronize { @worker_busy -= 1 }
6332
+ busy_held = false
6333
+ end
4133
6334
  break if rt.call('__csimWorkerClosedRead')
4134
6335
  end
4135
6336
  end
4136
6337
  rescue StandardError => e
4137
6338
  outbox << {handle: handle, kind: '__error', message: "#{e.class}: #{e.message}"}
4138
6339
  ensure
6340
+ # A raise between the busy bump and its matching decrement would strand the
6341
+ # counter; balance it here so worker_pending? can't stick true after this thread dies.
6342
+ @worker_init_lock.synchronize { @worker_busy -= 1 } if busy_held
4139
6343
  release_init.call # guarantee the init count is released on an early raise
4140
6344
  rt&.dispose
4141
6345
  end
@@ -4156,7 +6360,12 @@ module Capybara
4156
6360
  return data[:bytes]
4157
6361
  end
4158
6362
  b64 = @runtime.call('__csimReadBlobBase64', u)
4159
- return nil unless b64
6363
+ # A blob created INSIDE a frame realm lives in that realm's in-VM store, which the main
6364
+ # runtime's `__csimReadBlobBase64` above can't see. But createObjectURL also registered its
6365
+ # bytes in the cross-realm `@blob_registry` (crossCtx, since a frame realm is multi-realm),
6366
+ # so fall back to it — this is what makes `new Worker(blobURL)` work from a data: iframe.
6367
+ b64 = blob_resolve(u) if b64.nil? || b64.to_s.empty?
6368
+ return nil if b64.nil? || b64.to_s.empty?
4160
6369
  return Base64.decode64(b64.to_s)
4161
6370
  end
4162
6371
  # `data:[<mediatype>][;base64],<data>` worker scripts (a worker created
@@ -4180,6 +6389,24 @@ module Capybara
4180
6389
  end
4181
6390
  end
4182
6391
 
6392
+ # Run a worker isolate's own event loop until it goes idle: drain microtasks,
6393
+ # then — if a timer is pending — advance the worker clock to fire it and loop, so
6394
+ # the microtasks that timer's callback queues get drained in turn. A single
6395
+ # `drain_microtasks; drain_timers` pair strands whatever the last-fired timer
6396
+ # queued, which is exactly how Emscripten's WASM bring-up stalls
6397
+ # (`removeRunDependency` runs only after the binary-read setTimeout, and its
6398
+ # `run()` → `onRuntimeInitialized` continuation is a bare microtask with no further
6399
+ # timer to re-trigger a gated drain). Bounded by WORKER_QUIESCE_MAX_ROUNDS so a
6400
+ # self-perpetuating timer (setInterval) yields back to the poll loop rather than
6401
+ # pinning the thread.
6402
+ private def drive_worker_to_quiescence(rt)
6403
+ WORKER_QUIESCE_MAX_ROUNDS.times do
6404
+ rt.drain_microtasks
6405
+ break if rt.call('__nextTimerDelay').to_f < 0
6406
+ rt.drain_timers
6407
+ end
6408
+ end
6409
+
4183
6410
  # `Thread::Queue#pop(timeout:)` blocks releasing the GVL — fine
4184
6411
  # because the worker thread has nothing else to do while idle,
4185
6412
  # and `worker_post_to_worker` wakes the wait immediately.
@@ -4330,7 +6557,7 @@ module Capybara
4330
6557
  # isn't http(s) (data: / mailto: / about:) plus pseudo-tokens
4331
6558
  # like V8's `<snapshot>` that sourcemap libraries pull out of
4332
6559
  # 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')
6560
+ 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
6561
  # NB: a relative fetch/XHR URL is resolved against the document's API base URL
4335
6562
  # at OPEN time (XHR open() / fetch()), in JS, NOT here — resolving at send time
4336
6563
  # would wrongly pick up a `<base href>` inserted after open() (open-url-base
@@ -4350,7 +6577,14 @@ module Capybara
4350
6577
  # internal asset GET) pass nil → no CORS and no mode semantics. The document's
4351
6578
  # origin is the request's origin; a different target origin is cross-origin.
4352
6579
  cors = cors_mode == 'cors'
4353
- req_origin = cors ? url_origin(@current_url) : nil
6580
+ # The request's origin (document origin) for EVERY fetch mode — Fetch appends an
6581
+ # Origin header to every non-GET/HEAD request regardless of mode (a same-origin or
6582
+ # no-cors POST/PUT still carries it, for the server's CSRF/Origin check). CORS
6583
+ # enforcement itself stays gated on `cors` below; a nil-mode internal caller
6584
+ # (navigation / asset GET) has no origin semantics. An explicit `initiator` (a SW
6585
+ # re-issuing a navigation via `fetch(event.request)`) is the request's origin for
6586
+ # ALL modes — so a passthrough 'navigate'-mode POST still carries its Origin.
6587
+ req_origin = initiator || (%w[cors no-cors same-origin].include?(cors_mode) ? url_origin(@current_url) : nil)
4354
6588
  # Fetch request "mode" (fetch threads it; XHR is always 'cors'; a non-fetch/xhr
4355
6589
  # caller passes nil → no mode semantics, a plain 'basic' response). `no-cors`
4356
6590
  # filters a cross-origin response to opaque; `same-origin` makes a cross-origin
@@ -4363,6 +6597,11 @@ module Capybara
4363
6597
  # (form submission) or a nil-mode internal caller gets a plain readable response.
4364
6598
  doc_origin = %w[cors no-cors same-origin].include?(cors_mode) ? url_origin(@current_url) : nil
4365
6599
  crossed = false
6600
+ # Sec-Fetch-Site latches the widest initiator↔hop relationship across the redirect chain
6601
+ # (like the navigation path), computed vs the request's referrer-source origin below. A SW
6602
+ # re-fetch seeds it with the widened site the network hops accumulated BEFORE the SW
6603
+ # intercepted the final hop (a same-site redirect the passthrough must keep reporting).
6604
+ sec_site = site_seed
4366
6605
  # A request is "credentialed" (cookies + the credentialed CORS check) only in
4367
6606
  # `include` mode; `same-origin` (default) and `omit` are uncredentialed for the
4368
6607
  # CORS check, while the cookie decision below distinguishes all three.
@@ -4380,12 +6619,27 @@ module Capybara
4380
6619
  body = Base64.decode64(body.to_s)
4381
6620
  headers = headers.reject {|k, _| k == 'X-Csim-Body-B64' }
4382
6621
  end
6622
+ # CHALLENGE credentials for transparent HTTP Basic auth — set by the XHR authentication path
6623
+ # (open() user/password / URL userinfo), NOT a raw setRequestHeader('Authorization'). They are
6624
+ # NOT sent proactively; a 401 "Basic" challenge triggers a single re-send with them (below).
6625
+ # Strip the marker so it never reaches the server.
6626
+ challenge_authz = nil
6627
+ if headers.is_a?(Hash) && (mk = headers.keys.find {|k| k.to_s.casecmp?('x-csim-auth-challenge') })
6628
+ challenge_authz = 'Basic ' + headers[mk].to_s
6629
+ headers = headers.reject {|k, _| k == mk }
6630
+ end
4383
6631
  # The request's origin starts as the document origin; a cross-origin REDIRECT
4384
6632
  # taints it to an opaque origin (serialized "null") per Fetch "HTTP-redirect
4385
6633
  # fetch". `effective_origin` IS that origin — it's what the Origin header
4386
6634
  # 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
6635
+ # ('null' once tainted, so the server must then allow 'null' or '*'). A SW re-fetch whose
6636
+ # navigation ALREADY crossed origin via a network redirect starts tainted (origin_null).
6637
+ effective_origin = origin_null ? 'null' : req_origin
6638
+ # Virtual server delay (a handler's `time.sleep`, see wpt_py_handler.py) accumulated across
6639
+ # EVERY sub-request this fetch makes — the CORS preflight AND every redirect hop — since the
6640
+ # `timeout` a client applies spans them all and a redirect/preflight must not reset it
6641
+ # (timeout-multiple-fetches). Reset per fetch; the final response carries the total.
6642
+ @fetch_server_delay_ms = 0
4389
6643
  # An author conditional (If-None-Match / …) means the caller is doing its own
4390
6644
  # revalidation, so the UA cache must step aside (computed once — the headers
4391
6645
  # carrying it survive every redirect hop unchanged).
@@ -4399,6 +6653,15 @@ module Capybara
4399
6653
  # document URL ("client"); an empty referrer means no-referrer (compute_referrer
4400
6654
  # maps a blank source to nil).
4401
6655
  ref_source = referrer.nil? ? @current_url : referrer
6656
+ # The request's INITIATOR origin for Sec-Fetch-Site — captured ONCE (loop-invariant), before
6657
+ # the per-hop referrer reassignment (5927 below) degrades ref_source, and independent of
6658
+ # Referrer-Policy: the initiator is the referrer's origin (a SW's `fetch(event.request)`
6659
+ # carries the navigating frame's origin here, so a cross-origin passthrough is same-/cross-site
6660
+ # correctly), falling back to the document origin when the referrer was policy-emptied — never
6661
+ # 'none' for a request that has a real initiator. An explicit `initiator` is authoritative:
6662
+ # it survives the referrer reset a `new Request(event.request, init)` performs (referrer →
6663
+ # about:client), so a SW's change-request re-fetch is same-origin to the SW's own script.
6664
+ sec_initiator = initiator || url_origin(ref_source) || url_origin(@current_url)
4402
6665
  (MAX_FETCH_REDIRECTS + 1).times do
4403
6666
  t0 = @trace && Process.clock_gettime(Process::CLOCK_MONOTONIC)
4404
6667
  # Cross-origin-ness for the request mode/type, latched across hops. Computed
@@ -4449,10 +6712,12 @@ module Capybara
4449
6712
 
4450
6713
  env = Rack::MockRequest.env_for(target, method: method, input: body || '')
4451
6714
  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)
6715
+ # env_for always sets Content-Length to the input bytesize (0 for an empty body).
6716
+ # Fetch adds Content-Length: 0 for a bodyless request ONLY when the method is
6717
+ # POST or PUT; GET/HEAD and every other method (incl. a custom one like `Chicken`)
6718
+ # send no Content-Length when the body is empty (send-entity-body-none;
6719
+ # request-headers custom-method). A non-empty body keeps its real length.
6720
+ env.delete('CONTENT_LENGTH') if body.to_s.empty? && !%w[POST PUT].include?(method.to_s.upcase)
4456
6721
  apply_request_headers(env, headers) if headers
4457
6722
  apply_request_headers(env, @@asset_cache.revalidation_headers(cache_entry)) if cache_entry
4458
6723
  # The Referer follows the request's Referrer-Policy (a redirect response can
@@ -4473,6 +6738,18 @@ module Capybara
4473
6738
  hop_cross_origin = !!(doc_origin && (effective_origin == 'null' || url_origin(target) != doc_origin))
4474
6739
  send_cookies = credentials == 'include' || (credentials != 'omit' && !hop_cross_origin)
4475
6740
  env.delete('HTTP_COOKIE') unless send_cookies
6741
+ # HTTP auth caching (RFC 7617 §2.2): once credentials succeed for an origin (cached below),
6742
+ # the UA sends them pre-emptively for later credentialed requests to it — so a Basic-auth
6743
+ # resource loads without a fresh 401 challenge (the login helper authenticates first, then
6744
+ # the guarded image/XHR requests carry the cached header). Gated on the same credential
6745
+ # decision as cookies; the caller's own Authorization (an explicit user:pass) always wins.
6746
+ # Skip the pre-emptive cache when THIS request brought its own challenge credentials (open()
6747
+ # user/pass) — those must win over a cached session, so the request goes out unauthenticated
6748
+ # and the 401-retry below applies the caller's credentials, not a stale cached pair
6749
+ # (send-authentication-competing-names-passwords).
6750
+ if send_cookies && !env.key?('HTTP_AUTHORIZATION') && !challenge_authz && (cached = @auth_cache[url_origin(target)])
6751
+ env['HTTP_AUTHORIZATION'] = cached
6752
+ end
4476
6753
  # A CORS request to a URL carrying credentials (`user:pass@`) is a network
4477
6754
  # error (access-control-and-redirects "user info" subtest).
4478
6755
  return nil if cross_origin && url_has_userinfo?(target)
@@ -4491,14 +6768,55 @@ module Capybara
4491
6768
  if cross_origin || (req_origin && !%w[GET HEAD].include?(method.to_s.upcase))
4492
6769
  env['HTTP_ORIGIN'] = effective_origin
4493
6770
  end
6771
+ # Fetch-Metadata request headers — emitted for every fetch/XHR/SW request (a mode is set;
6772
+ # the nil-mode internal callers — ESM / asset GET / beacon — are left alone). Sec-Fetch-Site
6773
+ # widens across the redirect chain vs the loop-invariant `sec_initiator` (see above). -Mode
6774
+ # is the request mode (a SW's `fetch(event.request)` re-issues a 'navigate'-mode request,
6775
+ # `new Request(…,{mode})` a 'same-origin' one); -Dest is 'empty' for a script-initiated fetch
6776
+ # — the only navigate-mode path here (a navigation emits its own 'iframe'/'document' dest +
6777
+ # -User via navigation_request_headers, never reaching rack_fetch).
6778
+ if cors_mode
6779
+ sec_site = widen_sec_fetch_site(sec_site, sec_fetch_site(sec_initiator, target))
6780
+ env['HTTP_SEC_FETCH_SITE'] = sec_site
6781
+ env['HTTP_SEC_FETCH_MODE'] = cors_mode
6782
+ env['HTTP_SEC_FETCH_DEST'] = 'empty'
6783
+ end
4494
6784
  env.merge!(env_extras) if env_extras
4495
6785
  status, resp_headers, resp_body = dispatch_rack_or_http(target, env, method: method, body: body)
4496
- merge_set_cookie(resp_headers)
6786
+ @fetch_server_delay_ms += server_delay_ms_of(resp_headers)
6787
+ # Transparent HTTP Basic auth (RFC 7617): a request carrying CHALLENGE credentials (open()
6788
+ # user/pass / URL userinfo) that gets a 401 "Basic" challenge — and hasn't already sent an
6789
+ # Authorization (an explicit setRequestHeader / a pre-emptively-attached cached credential) —
6790
+ # is re-sent ONCE with them; only the authenticated response reaches script, the 401 never does
6791
+ # (send-authentication-basic / -existing-session). `omit` sends no credentials at all.
6792
+ if challenge_authz && status.to_i == 401 && credentials != 'omit' &&
6793
+ !env.key?('HTTP_AUTHORIZATION') && www_authenticate_basic?(resp_headers)
6794
+ resp_body.close if resp_body.respond_to?(:close)
6795
+ env['HTTP_AUTHORIZATION'] = challenge_authz
6796
+ status, resp_headers, resp_body = dispatch_rack_or_http(target, env, method: method, body: body)
6797
+ end
6798
+ # Fetch credentials mode "omit" ignores credentials the response sends back too —
6799
+ # its Set-Cookie is dropped, not stored (cors-cookies / credentials "omit mode").
6800
+ merge_set_cookie(resp_headers, target) unless credentials == 'omit'
6801
+ # Cache the credentials this origin ACCEPTED (AUTHENTICATION credentials — a pre-emptively
6802
+ # attached cached credential, or the challenge credential the 401-retry above just supplied —
6803
+ # that weren't rejected with a 401), for the pre-emptive send above. `omit` neither sends nor
6804
+ # caches. Only the request's OWN origin is cached: Authorization is stripped on a cross-origin
6805
+ # redirect hop (above), so origin A's credentials can't seed origin B's cache. (A non-2xx
6806
+ # same-origin response — an opaque status-0 no-cors fetch, a same-origin 3xx — still
6807
+ # establishes the credentials for THAT origin, so the gate is "not a 401", not "is a 2xx".)
6808
+ if challenge_authz && credentials != 'omit' && status.to_i != 401
6809
+ @auth_cache[url_origin(target)] = env['HTTP_AUTHORIZATION'] || challenge_authz
6810
+ end
4497
6811
  if status == 304 && cache_entry
4498
6812
  trace_network(method, target, cache_entry.status, headers, body, cache_entry.headers, nil, t0, false)
4499
6813
  resp_body.close if resp_body.respond_to?(:close)
4500
6814
  @@asset_cache.refresh(cache_entry, resp_headers)
4501
- return response_hash(cache_entry.status, cache_entry.headers, cache_entry.body, target, redirected)
6815
+ # The cache stores the RAW response headers, so a cross-origin cached entry must
6816
+ # be re-filtered through the CORS exposed-header set on the way back to script —
6817
+ # a 304 revalidation must not leak headers the original cross-origin fetch hid.
6818
+ cached_headers = cross_origin ? cors_exposed_headers(cache_entry.headers, with_credentials) : cache_entry.headers
6819
+ return response_hash(cache_entry.status, cached_headers, cache_entry.body, target, redirected)
4502
6820
  end
4503
6821
  # Fetch "CORS check" runs on EVERY cross-origin response — including a 3xx the
4504
6822
  # UA is about to follow (a redirect whose response lacks a valid Access-Control
@@ -4554,6 +6872,12 @@ module Capybara
4554
6872
  # hop out of a same-origin request keeps the real origin (redirect-origin
4555
6873
  # "same origin to other origin" sends the document origin, not null).
4556
6874
  effective_origin = 'null' if cors && crossed && url_origin(next_url) != url_origin(target)
6875
+ # Fetch "HTTP-redirect fetch": a CROSS-ORIGIN redirect strips the request's
6876
+ # `Authorization` — credentials sent to the first origin must not be replayed to a
6877
+ # different one (nor seed that origin's auth cache below).
6878
+ if url_origin(next_url) != url_origin(target) && headers.is_a?(Hash)
6879
+ headers = headers.reject {|k, _| k.to_s.casecmp?('authorization') }
6880
+ end
4557
6881
  target = carry_fragment(target, next_url)
4558
6882
  if bad_port?(target) # a redirect to a blocked port is a network error too
4559
6883
  resp_body.close if resp_body.respond_to?(:close)
@@ -4601,7 +6925,14 @@ module Capybara
4601
6925
  # -Headers (`*` = all). content-type stays safelisted, so response decoding is
4602
6926
  # unaffected. (Filtered for script exposure only — trace / set-cookie / cache see
4603
6927
  # 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
6928
+ # The virtual server delay is EPHEMERAL processing time (accumulated across the preflight +
6929
+ # every redirect hop): the client reads the TOTAL to time its async deferral, but it must
6930
+ # never be cached or replayed on a cache hit — strip it from the traced/stored response and
6931
+ # expose the total to the CLIENT only (added after CORS filtering, so it always survives).
6932
+ total_delay = @fetch_server_delay_ms.to_i
6933
+ resp_headers = resp_headers.reject {|k, _| k.to_s.casecmp?('x-csim-server-delay-ms') }
6934
+ exposed_headers = cross_origin ? cors_exposed_headers(resp_headers, with_credentials) : resp_headers
6935
+ exposed_headers = exposed_headers.merge('X-Csim-Server-Delay-Ms' => total_delay.to_s) if total_delay > 0
4605
6936
  trace_network(method, target, status, headers, body, resp_headers, body_str, t0, false)
4606
6937
  # A no-store request must not write the cache (RFC 9111 §5.2.1.5); a request carrying
4607
6938
  # the author's own conditional bypasses the UA cache entirely (read AND write) — it's
@@ -4611,7 +6942,7 @@ module Capybara
4611
6942
  # A no-cors cross-origin response is OPAQUE: status 0, empty body, no exposed
4612
6943
  # headers, empty URL (cors-basic "Opaque filter"). Otherwise the type is 'cors'
4613
6944
  # 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
6945
+ return response_hash(0, {}, '', '', false, type: 'opaque', body_null: true, opaque_render: body_str) if no_cors_mode && crossed
4615
6946
  return response_hash(status, exposed_headers, body_str, target, redirected, type: crossed ? 'cors' : 'basic', body_null: null_body)
4616
6947
  end
4617
6948
  raise StandardError, "[capybara-simulated] fetch exceeded #{MAX_FETCH_REDIRECTS} redirects"
@@ -4717,7 +7048,7 @@ module Capybara
4717
7048
  # text body when `body_b64` is absent.
4718
7049
  TEXT_CONTENT_TYPE_PREFIXES = %w[text/ application/json application/javascript application/ecmascript application/xml image/svg+xml].freeze
4719
7050
 
4720
- def response_hash(status, headers, body, url, redirected, type: 'basic', body_null: false)
7051
+ def response_hash(status, headers, body, url, redirected, type: 'basic', body_null: false, opaque_render: nil)
4721
7052
  raw = body.to_s
4722
7053
  hdrs = stringify(headers)
4723
7054
  # A NUL in a header value is not a valid HTTP message; a real server can't
@@ -4775,6 +7106,15 @@ module Capybara
4775
7106
  # (decodeResponseBytes). `ascii_only?` is a cheap C-level scan, so the dominant
4776
7107
  # pure-ASCII app JSON/HTML traffic keeps the fast path and pays no base64.
4777
7108
  out['body_b64'] = Base64.strict_encode64(raw) unless is_text && raw.ascii_only?
7109
+ # An OPAQUE (no-cors cross-origin) response hides its body from every script-visible read
7110
+ # (body/body_b64 are empty). But the bytes are still needed to RENDER an <img> the response
7111
+ # backs (a cross-origin image displays, merely canvas-tainting) — carry them on a private
7112
+ # side channel the image decode path reads, never a public body accessor. Attached to EVERY
7113
+ # opaque response, not just image requests: this is `rack_fetch`, which has no request
7114
+ # destination (a SW's own no-cors `fetch()` doesn't know its eventual consumer is an <img>),
7115
+ # so the choice is made client-side. The bytes are already in memory (`body_str`); the added
7116
+ # cost is one base64 per opaque response, off any hot path.
7117
+ out['opaque_render_b64'] = Base64.strict_encode64(opaque_render) if opaque_render && !opaque_render.empty?
4778
7118
  out
4779
7119
  end
4780
7120
 
@@ -4936,6 +7276,14 @@ module Capybara
4936
7276
  entry = @frame_stack.find {|e| e[:realm_id] == realm_id }
4937
7277
  if entry
4938
7278
  navigate_frame(url, entry: entry)
7279
+ elsif url.match?(%r{\Ahttps?://}i)
7280
+ # An absolute http(s) self-nav (`self.location = …` / link click) is fetched Ruby-side so
7281
+ # it carries correct navigation request headers (Referer under policy / Sec-Fetch);
7282
+ # non-http(s) and relative URLs stay on the JS src-reassignment path via
7283
+ # navigate_realm_self_get. `record: false` — a location/link frame nav isn't history-
7284
+ # recorded yet (that's a form-submission-only path), and must not push where a
7285
+ # location.replace should overwrite.
7286
+ navigate_realm_self_get(realm_id, url, record: false)
4939
7287
  else
4940
7288
  @runtime.call('__csimNavigateFrameByRealm', realm_id, url)
4941
7289
  end
@@ -5018,8 +7366,19 @@ module Capybara
5018
7366
  # JS-side, reusing the retained content so blob bytes survive a revoke.
5019
7367
  entry = @frame_stack.find {|e| e[:realm_id] == realm_id }
5020
7368
  url = entry && @runtime.frame_realm_alive?(realm_id) ? @runtime.realm_call(realm_id, '__csimLocationHref').to_s : ''
7369
+ cur = current_frame_history_entry(realm_id)
5021
7370
  if entry && !url.empty?
5022
7371
  navigate_frame(url, entry: entry)
7372
+ elsif cur && cur[:method] == 'POST'
7373
+ # Reloading a document reached by POST re-POSTS it (isReloadNavigation) with the recorded
7374
+ # body, rather than the JS reload path's GET refetch of the frame's src.
7375
+ navigate_realm_self_post(realm_id, cur[:url], cur[:body], cur[:content_type], is_reload: true)
7376
+ elsif cur && cur[:url].to_s.match?(%r{\Ahttps?://}i)
7377
+ # Reload the CURRENT history entry (isReloadNavigation), not the frame's `src`: after a
7378
+ # back/forward the src is stale, so `history.go(0)` / `location.reload()` must refetch the
7379
+ # entry's URL. (A blob:/data:/srcdoc frame keeps the JS reload path — it reuses retained
7380
+ # bytes a URL refetch can't reproduce.)
7381
+ reload_frame_to_entry(realm_id, cur, is_reload: true, is_history: false)
5023
7382
  else
5024
7383
  @runtime.call('__csimReloadFrameByRealm', realm_id)
5025
7384
  end
@@ -5086,8 +7445,9 @@ module Capybara
5086
7445
  return if h.nil?
5087
7446
  target = h[:idx] + delta
5088
7447
  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
7448
+ # Snapshot the entry we're leaving so a later forward traversal restores it (keeping how it
7449
+ # was reached, so a POST entry re-POSTs when traversed back to).
7450
+ h[:entries][h[:idx]] = snapshot_outgoing_entry(realm_id, h[:entries][h[:idx]]) if h[:idx] >= 0
5091
7451
  reload_frame_to_entry(realm_id, h[:entries][target])
5092
7452
  h[:idx] = target # advance only after the rebuild succeeds
5093
7453
  end
@@ -5097,52 +7457,110 @@ module Capybara
5097
7457
  # into the frame form-submission paths; frame navigations driven by
5098
7458
  # `location.href` / link clicks aren't recorded yet (history.back there falls
5099
7459
  # through to the top document, as before).
5100
- def record_frame_nav(realm_id, new_url)
7460
+ # `post` (a {body, content_type}) records that this entry was reached by a POST submission,
7461
+ # so a later reload / history traversal re-POSTs it (with the body) rather than GET-ing the URL.
7462
+ def record_frame_nav(realm_id, new_url, post: nil)
5101
7463
  return if realm_id.nil? || realm_id.zero?
5102
7464
  parent = @runtime.frame_realm_parent(realm_id)
5103
7465
  handle = frame_container_handle(realm_id, parent)
5104
7466
  return if handle.zero?
5105
7467
  h = (@frame_histories ||= {})[[parent, handle]] ||= {entries: [], idx: -1}
5106
- outgoing = frame_history_entry(realm_id)
5107
7468
  if h[:idx] >= 0
5108
- h[:entries][h[:idx]] = outgoing
7469
+ h[:entries][h[:idx]] = snapshot_outgoing_entry(realm_id, h[:entries][h[:idx]])
5109
7470
  else
5110
- h[:entries] << outgoing
7471
+ h[:entries] << frame_history_entry(realm_id)
5111
7472
  h[:idx] = 0
5112
7473
  end
5113
7474
  h[:entries] = h[:entries][0..h[:idx]]
5114
- h[:entries] << {url: new_url.to_s, form_state: nil}
7475
+ entry = {url: new_url.to_s, form_state: nil}
7476
+ entry.merge!(method: 'POST', body: post[:body], content_type: post[:content_type]) if post
7477
+ h[:entries] << entry
5115
7478
  h[:idx] = h[:entries].size - 1
5116
7479
  end
7480
+ # The frame's CURRENT history entry (the loaded document), or nil — read by a reload to decide
7481
+ # whether to re-POST.
7482
+ def current_frame_history_entry(realm_id)
7483
+ parent = @runtime.frame_realm_parent(realm_id)
7484
+ handle = frame_container_handle(realm_id, parent)
7485
+ return nil if handle.zero?
7486
+ h = (@frame_histories || {})[[parent, handle]]
7487
+ h && h[:idx] >= 0 ? h[:entries][h[:idx]] : nil
7488
+ end
5117
7489
  # The history entry for the document currently loaded in `realm_id`: its URL
5118
7490
  # plus a snapshot of its form-control state.
5119
7491
  def frame_history_entry(realm_id)
5120
7492
  {url: frame_realm_url(realm_id), form_state: capture_frame_form_state(realm_id)}
5121
7493
  end
7494
+ # Refresh the outgoing entry's url + form-state snapshot while PRESERVING how it was reached
7495
+ # (a POST entry's method / body / content_type) — leaving a document doesn't change the request
7496
+ # that loaded it, so a later traversal back re-POSTs rather than GET-ing.
7497
+ def snapshot_outgoing_entry(realm_id, prev)
7498
+ snap = frame_history_entry(realm_id)
7499
+ prev ? prev.merge(snap) : snap
7500
+ end
5122
7501
  def frame_realm_url(realm_id)
5123
7502
  return nil unless @runtime.frame_realm_alive?(realm_id)
5124
7503
  @runtime.realm_call(realm_id, '__csimLocationHref').to_s
5125
7504
  rescue StandardError
5126
7505
  nil
5127
7506
  end
7507
+ # A frame document's referrer policy (its last valid `<meta name="referrer">`), read from
7508
+ # the live realm to compute a self-navigation's Referer under the initiating document's
7509
+ # policy. '' when the realm is gone / has no meta → the platform default applies.
7510
+ def frame_document_referrer_policy(realm_id)
7511
+ return '' unless @runtime.frame_realm_alive?(realm_id)
7512
+ @runtime.realm_call(realm_id, '__csimDocumentReferrerPolicy').to_s
7513
+ rescue StandardError
7514
+ ''
7515
+ end
5128
7516
  def capture_frame_form_state(realm_id)
5129
7517
  return nil unless @runtime.frame_realm_alive?(realm_id)
5130
7518
  @runtime.realm_call(realm_id, '__csimCaptureFormState')
5131
7519
  rescue StandardError
5132
7520
  nil
5133
7521
  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)
7522
+ # Re-fetch a history entry's URL and rebuild the frame realm from it, then restore the entry's
7523
+ # captured form state (before the element load fires). Used for a history traversal
7524
+ # (isHistoryNavigation) AND for a reload of the current entry (isReloadNavigation — e.g.
7525
+ # `history.go(0)` / `location.reload()` after a back/forward, where the frame's `src` is stale
7526
+ # and only the current entry names the right URL).
7527
+ def reload_frame_to_entry(realm_id, entry, is_reload: false, is_history: true)
5137
7528
  url = entry[:url].to_s
5138
7529
  return if url.empty?
5139
- env = Rack::MockRequest.env_for(url, method: 'GET')
7530
+ # An entry reached by a POST submission re-POSTS (with the recorded body); a normal entry
7531
+ # re-GETs. The method drives both the SW fetch event and the network fallback.
7532
+ is_post = entry[:method] == 'POST'
7533
+ body = entry[:body].to_s
7534
+ post_args = is_post ? {method: 'POST', body_b64: Base64.strict_encode64(body), content_type: entry[:content_type]} : {}
7535
+ # A history TRAVERSAL restores the entry's persisted form state (bfcache); a RELOAD gives a
7536
+ # fresh document, so it must NOT restore the (possibly stale) snapshot the entry was left with.
7537
+ restore = is_reload ? nil : entry[:form_state]
7538
+ # A traversal / reload is a navigation: route it through the controlling SW's fetch event
7539
+ # first — the refetch happens here, Ruby-side, bypassing the __csimFrameWindow interception
7540
+ # the initial load uses. A respondWith serves the document; a network error fails the
7541
+ # navigation; nil falls through to the network below.
7542
+ if (sw = service_worker_navigation_fetch(url, is_reload: is_reload, is_history: is_history,
7543
+ referrer_source: frame_realm_url(realm_id), referrer_policy: frame_document_referrer_policy(realm_id),
7544
+ **post_args))
7545
+ return if sw['networkError']
7546
+
7547
+ # Raw decoded bytes (like read_rack_body's byte-tagged output); reload_frame_realm_by_id
7548
+ # does the single utf8_text re-tag, matching the network path below.
7549
+ reload_frame_realm_by_id(realm_id, url, Base64.decode64(sw['body_b64'].to_s),
7550
+ response_content_type(sw['headers'] || {}), restore_state: restore)
7551
+ return
7552
+ end
7553
+ env = Rack::MockRequest.env_for(url, method: is_post ? 'POST' : 'GET', input: is_post ? body : '')
7554
+ if is_post
7555
+ env['CONTENT_TYPE'] = entry[:content_type].to_s.empty? ? 'application/x-www-form-urlencoded' : entry[:content_type]
7556
+ env['CONTENT_LENGTH'] = body.bytesize.to_s
7557
+ end
5140
7558
  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)
7559
+ status, headers, resp_body = dispatch_rack_or_http(url, env, method: is_post ? 'POST' : 'GET', body: is_post ? body : nil)
7560
+ merge_set_cookie(headers, url)
5143
7561
  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])
7562
+ html = read_rack_body(resp_body)
7563
+ reload_frame_realm_by_id(realm_id, url, html, response_content_type(headers), restore_state: restore)
5146
7564
  end
5147
7565
  # Serialize + route a form submitted inside frame realm `realm_id`. We
5148
7566
  # serialize in the INITIATING realm (so shadow-tree controls are excluded
@@ -5162,7 +7580,7 @@ module Capybara
5162
7580
  target = spec['target'].to_s
5163
7581
  action = spec['action'].to_s
5164
7582
  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)
7583
+ entries = entry_list.is_a?(Array) ? entry_list : (spec['entries'] || [])
5166
7584
  # GET → urlencoded query (enctype ignored); POST → enctype-encoded body.
5167
7585
  get_query, = encode_entry_list(entries, 'application/x-www-form-urlencoded')
5168
7586
  get_url = form_get_url(action, get_query)
@@ -5200,20 +7618,63 @@ module Capybara
5200
7618
  end
5201
7619
  # A self-targeted GET form submit in the initiating frame realm: navigate
5202
7620
  # 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)
7621
+ # `record: false` for a `location.href=` / link-click self-nav — those frame navigations are
7622
+ # deliberately NOT recorded in frame history yet (see record_frame_nav; history.back there falls
7623
+ # through to the top document), and recording them here would push an entry where a
7624
+ # `location.replace` must overwrite. A form GET submission (the default) IS a history push.
7625
+ 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)
7626
+ raise 'too many redirects' if depth > 10
7627
+ record_frame_nav(realm_id, get_url) if record && depth.zero? && !is_reload && !is_history
5205
7628
  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)
7629
+ return navigate_frame(resolve_against_current(get_url), entry: entry) if entry
7630
+ # A frame reached via contentWindow (not on the entered stack). An ABSOLUTE http(s) target is
7631
+ # fetched Ruby-side (like navigate_realm_self_post) so the navigation carries correct request
7632
+ # headers a Referer under the initiating document's Referrer-Policy, no Origin (GET), the
7633
+ # Fetch-Metadata triple. A non-http(s) target (data:/blob:/javascript:/about:blank) or a
7634
+ # relative one stays on the JS src-reassignment path, which owns those schemes and resolves a
7635
+ # relative URL against the frame's base on rebuild routed through the frame's PARENT realm
7636
+ # (where the owning iframe lives), not unconditionally to main.
7637
+ parent = @runtime.frame_realm_parent(realm_id)
7638
+ unless get_url.is_a?(String) && get_url.match?(%r{\Ahttps?://}i)
7639
+ return frame_realm_host_call(parent, '__csimNavigateFrameByRealm', realm_id, get_url)
5216
7640
  end
7641
+ # The realm may have been disposed earlier in THIS drain batch — an ancestor frame that also
7642
+ # self-navigated discarded this descendant (dispose_frame_realm_tree). Bail before issuing a
7643
+ # network fetch whose response (reload_frame_realm_by_id) would find no container and be thrown
7644
+ # away — the fetch's cookie / server side effects would fire for a navigation that never commits.
7645
+ return unless @runtime.frame_realm_alive?(realm_id)
7646
+ invalidate_find_cache
7647
+ # A controlled navigation goes to the SW's fetch event first (mode 'navigate'); respondWith
7648
+ # serves the document, a network error fails it, nil falls through to the network GET below.
7649
+ if (sw = service_worker_navigation_fetch(get_url, method: 'GET', is_reload: is_reload, is_history: is_history,
7650
+ referrer_source: frame_realm_url(realm_id), referrer_policy: frame_document_referrer_policy(realm_id),
7651
+ site_seed: site_seed, origin_null: origin_null))
7652
+ return if sw['networkError']
7653
+
7654
+ return reload_frame_realm_by_id(realm_id, get_url, Base64.decode64(sw['body_b64'].to_s), response_content_type(sw['headers'] || {}))
7655
+ end
7656
+ initiator = frame_realm_url(realm_id)
7657
+ site = widen_sec_fetch_site(site_seed, sec_fetch_site(initiator, get_url))
7658
+ status, headers, resp_body = dispatch_navigation_request(
7659
+ get_url,
7660
+ method: 'GET',
7661
+ initiator: initiator,
7662
+ referrer_policy: frame_document_referrer_policy(realm_id),
7663
+ site: site,
7664
+ origin_null: origin_null
7665
+ )
7666
+ if (loc = redirect_location(status, headers))
7667
+ next_url = resolve_against(loc, get_url)
7668
+ resp_body.close if resp_body.respond_to?(:close)
7669
+ # Latch the redirect chain's Fetch-Metadata: Sec-Fetch-Site widens to include this hop, and
7670
+ # a form POST's Origin taints to 'null' per redirect_taints_origin? (moot for GET — no Origin).
7671
+ return navigate_realm_self_get(realm_id, next_url, depth: depth + 1, is_reload: is_reload, is_history: is_history, record: record,
7672
+ site_seed: site, origin_null: redirect_taints_origin?(origin_null, initiator, get_url, next_url))
7673
+ end
7674
+ if download_response?(headers)
7675
+ return save_downloaded_response(get_url, headers, resp_body)
7676
+ end
7677
+ reload_frame_realm_by_id(realm_id, get_url, read_rack_body(resp_body), response_content_type(headers))
5217
7678
  end
5218
7679
  # A self-targeted POST form submit in the initiating frame realm. POST the
5219
7680
  # entity body to the action URL, then rebuild that frame's realm from the
@@ -5221,25 +7682,55 @@ module Capybara
5221
7682
  # a frame reached via contentWindow has no stack entry, so rebuild it by
5222
7683
  # realm id (recovering its container element + parent realm) and fire the
5223
7684
  # iframe element's load event the GET/src path would.
5224
- def navigate_realm_self_post(realm_id, url, body, content_type, depth: 0)
7685
+ 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
7686
  raise 'too many redirects' if depth > 10
5226
- record_frame_nav(realm_id, url) if depth.zero?
7687
+ # A reload / history traversal RE-POSTS an existing entry — it doesn't push a new one; only
7688
+ # a fresh submission records history. The POST method/body is tagged onto the entry AFTER a
7689
+ # DIRECT (non-redirect) response (tag_frame_entry_post below): a POST that redirects (the
7690
+ # Post/Redirect/Get pattern) resolves to a GET document, so its entry must NOT re-POST on a
7691
+ # later reload / back — only a directly-served POST does.
7692
+ record_frame_nav(realm_id, url) if depth.zero? && !is_reload && !is_history
5227
7693
  entry = @frame_stack.find {|e| e[:realm_id] == realm_id }
5228
7694
  return navigate_frame_post(url, body, content_type, entry: entry) if entry
5229
7695
  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)
7696
+ # A controlled POST navigation goes to the SW's fetch event first (mode 'navigate', method
7697
+ # POST — the SW reads the body via event.request.text()); respondWith serves the document,
7698
+ # a network error fails it, nil falls through to the network POST below.
7699
+ 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,
7700
+ referrer_source: frame_realm_url(realm_id), referrer_policy: frame_document_referrer_policy(realm_id),
7701
+ site_seed: site_seed, origin_null: origin_null))
7702
+ return if sw['networkError']
7703
+
7704
+ tag_frame_entry_post(realm_id, body, content_type) if depth.zero?
7705
+ reload_frame_realm_by_id(realm_id, url.to_s, Base64.decode64(sw['body_b64'].to_s), response_content_type(sw['headers'] || {}))
7706
+ return
7707
+ end
7708
+ # The initiator is the FRAME's own document (still alive — the rebuild is below), not the
7709
+ # top document `current_browsing_context_url` returns for a non-entered frame. A form POST
7710
+ # navigation carries that document's Origin + a Referer under its Referrer-Policy + the
7711
+ # Fetch-Metadata triple (Sec-Fetch-Dest 'iframe' for a subframe).
7712
+ initiator = frame_realm_url(realm_id)
7713
+ site = widen_sec_fetch_site(site_seed, sec_fetch_site(initiator, url.to_s))
7714
+ status, headers, resp_body = dispatch_navigation_request(
7715
+ url,
7716
+ method: 'POST',
7717
+ initiator: initiator,
7718
+ referrer_policy: frame_document_referrer_policy(realm_id),
7719
+ site: site,
7720
+ origin_null: origin_null,
7721
+ body: body,
7722
+ content_type: content_type
7723
+ )
7724
+ merge_set_cookie(headers, url)
5236
7725
  if (loc = redirect_location(status, headers))
5237
7726
  next_url = resolve_against_current(loc)
5238
7727
  resp_body.close if resp_body.respond_to?(:close)
5239
7728
  # 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).
7729
+ # through the realm that OWNS the iframe, as in navigate_realm_self_get). Latch the
7730
+ # redirect chain's Sec-Fetch-Site (widened) + Origin taint (redirect_taints_origin?).
5241
7731
  if [307, 308].include?(status)
5242
- return navigate_realm_self_post(realm_id, next_url, body, content_type, depth: depth + 1)
7732
+ return navigate_realm_self_post(realm_id, next_url, body, content_type, depth: depth + 1, is_reload: is_reload, is_history: is_history,
7733
+ site_seed: site, origin_null: redirect_taints_origin?(origin_null, initiator, url.to_s, next_url))
5243
7734
  end
5244
7735
  parent = @runtime.frame_realm_parent(realm_id)
5245
7736
  return frame_realm_host_call(parent, '__csimNavigateFrameByRealm', realm_id, next_url)
@@ -5248,8 +7739,16 @@ module Capybara
5248
7739
  save_downloaded_response(url, headers, resp_body)
5249
7740
  return
5250
7741
  end
7742
+ tag_frame_entry_post(realm_id, body, content_type) if depth.zero?
5251
7743
  reload_frame_realm_by_id(realm_id, url.to_s, read_rack_body(resp_body), response_content_type(headers))
5252
7744
  end
7745
+ # Tag the frame's current history entry as reached by a POST (with its body), so a later
7746
+ # reload / history traversal re-POSTS it. Applied only on a DIRECT response (not a redirect),
7747
+ # so a Post/Redirect/Get entry stays a GET.
7748
+ def tag_frame_entry_post(realm_id, body, content_type)
7749
+ cur = current_frame_history_entry(realm_id)
7750
+ cur.merge!(method: 'POST', body: body, content_type: content_type) if cur
7751
+ end
5253
7752
  # Rebuild a frame realm reached via contentWindow (no @frame_stack entry):
5254
7753
  # recover its container element handle + parent realm, swap in a fresh realm
5255
7754
  # built from `html`, re-point the iframe at it, and fire the element load.
@@ -5290,6 +7789,7 @@ module Capybara
5290
7789
  end
5291
7790
  end
5292
7791
  def drain_pending_navigation
7792
+ consume_pending_sw_client_nav
5293
7793
  consume_pending_location
5294
7794
  consume_pending_frame_nav
5295
7795
  consume_pending_frame_submit
@@ -5327,7 +7827,9 @@ module Capybara
5327
7827
  # in place rather than appending. Both the state and (when given)
5328
7828
  # the URL are mirrored on Ruby's slot so a subsequent back to
5329
7829
  # this entry restores the same state.
5330
- def history_state(url, state = nil)
7830
+ def history_state(url, state = nil, realm_id = 0)
7831
+ return note_frame_same_document_url(realm_id.to_i, url) unless realm_id.to_i.zero?
7832
+
5331
7833
  if url
5332
7834
  resolved = resolve_against_current(url.to_s)
5333
7835
  record_url_transition(resolved)
@@ -5345,36 +7847,100 @@ module Capybara
5345
7847
  # Mirror that on the Ruby side so `Capybara#go_back` traverses
5346
7848
  # within the pushState chain (fires `popstate`) and only crosses
5347
7849
  # to a real reload when the back hits a `:visit` boundary.
5348
- def history_push(url, state = nil)
7850
+ def history_push(url, state = nil, realm_id = 0)
7851
+ return note_frame_same_document_url(realm_id.to_i, url) unless realm_id.to_i.zero?
7852
+
5349
7853
  resolved = resolve_against_current(url.to_s)
5350
7854
  record_url_transition(resolved)
5351
7855
  @current_url = resolved
5352
7856
  record_history({method: :get, url: resolved, state: state, kind: :push_state})
5353
7857
  end
5354
7858
 
7859
+ # A SAME-DOCUMENT URL change (pushState / replaceState / a fragment navigation) made by a
7860
+ # NESTED browsing context. It belongs to that frame's own session history — mirroring it onto
7861
+ # the top document's would make `current_url` report a URL no window is at, which is what an
7862
+ # iframe'd SPA does on every navigation. Recorded as the current entry's URL (rather than a
7863
+ # new entry) so a later `location.reload()` refetches the pushState'd URL, not the stale
7864
+ # `src`; in-frame same-document TRAVERSAL over such entries is still unmodelled.
7865
+ # A same-isolate window realm (a popup) has no iframe container and so no entry to update —
7866
+ # its handle is 0, and not touching the top history is already the fix there.
7867
+ private def note_frame_same_document_url(realm_id, url)
7868
+ return nil if url.nil? || realm_id.zero?
7869
+
7870
+ parent = @runtime.frame_realm_parent(realm_id)
7871
+ handle = frame_container_handle(realm_id, parent)
7872
+ return nil if handle.zero?
7873
+
7874
+ h = (@frame_histories ||= {})[[parent, handle]] ||= {entries: [], idx: -1}
7875
+ if h[:idx].negative?
7876
+ # Seed entry 0 from the document as it is NOW — this runs before the location update,
7877
+ # so it still reads the URL the frame was loaded at.
7878
+ h[:entries] << frame_history_entry(realm_id)
7879
+ h[:idx] = 0
7880
+ end
7881
+ h[:entries][h[:idx]] = (h[:entries][h[:idx]] || {}).merge(url: url.to_s)
7882
+ nil
7883
+ end
7884
+
5355
7885
  # Total history entries (after forward-tail truncation), surfaced
5356
7886
  # to JS `history.length` via the `__historyLength` host fn.
5357
7887
  def history_length
5358
7888
  [@history.size, 1].max
5359
7889
  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.
7890
+ # The host a cookie is scoped to for `url`. RFC 6265 cookies are keyed by host
7891
+ # (not scheme/port), so cross-host requests never see each other's cookies while
7892
+ # a same-origin flow behaves exactly like a single jar. nil when the URL carries
7893
+ # no host (about:blank / data: / a relative current_url before the first navigate).
7894
+ def cookie_host(url)
7895
+ h = safe_uri(url.to_s)&.host
7896
+ h && !h.empty? ? h.downcase : nil
7897
+ end
7898
+
7899
+ # The host cookies attach to for a request built into `env` — the target server
7900
+ # (SERVER_NAME / HTTP_HOST), NOT the current document, so a cross-origin fetch sends
7901
+ # the TARGET's cookies rather than leaking the document's (cors-cookies). Strips the
7902
+ # port while preserving an IPv6 bracket-literal (`[::1]`) so the key matches what
7903
+ # `cookie_host` derives from the URL via `URI#host`.
7904
+ def env_cookie_host(env)
7905
+ h = (env['HTTP_HOST'] || env['SERVER_NAME']).to_s
7906
+ h = h.start_with?('[') ? h[/\A\[[^\]]*\]/].to_s : h.split(':', 2).first
7907
+ h && !h.empty? ? h.downcase : nil
7908
+ end
7909
+
7910
+ # The `Cookie` request-header value for a request to `host`: that host's jar,
7911
+ # serialized `name=value; …`. (Domain-attribute subdomain sharing isn't modelled —
7912
+ # the app suites are single-host; cross-host ISOLATION is what matters here.)
7913
+ #
7914
+ # TEXT, not binary: jar entries parsed out of Rack's Set-Cookie headers can carry the
7915
+ # BINARY tag, which would make the joined string cross into JS as a Uint8Array
7916
+ # (`document.cookie.match is not a function`). Cookies are ASCII per RFC 6265.
7917
+ def cookie_header_for(host)
7918
+ jar = host && @cookies[host]
7919
+ return '' if jar.nil? || jar.empty?
7920
+ RuntimeShared.utf8_text(jar.map {|k, v| "#{k}=#{v}" }.join('; '))
7921
+ end
7922
+
7923
+ # `document.cookie` reads/writes the CURRENT document's host jar.
7924
+ def document_cookie_host
7925
+ cookie_host(current_browsing_context_url) || cookie_host(@default_host)
7926
+ end
7927
+
5364
7928
  def document_cookie
5365
- RuntimeShared.utf8_text(@cookies.map {|k, v| "#{k}=#{v}" }.join('; '))
7929
+ cookie_header_for(document_cookie_host)
5366
7930
  end
5367
7931
  def current_referer ; @current_referer.to_s ; end
5368
7932
  def write_document_cookie(s)
5369
7933
  return if s.nil? || s.empty?
7934
+ host = document_cookie_host or return
5370
7935
  name, rest = s.split('=', 2)
5371
7936
  return if name.nil? || name.empty?
5372
7937
  parts = (rest || '').split(';').map(&:strip)
5373
7938
  value = parts.shift.to_s
7939
+ jar = (@cookies[host] ||= {})
5374
7940
  if cookie_deletion?(parts)
5375
- @cookies.delete(name.strip)
7941
+ jar.delete(name.strip)
5376
7942
  else
5377
- @cookies[name.strip] = value
7943
+ jar[name.strip] = value
5378
7944
  end
5379
7945
  end
5380
7946
 
@@ -5389,9 +7955,25 @@ module Capybara
5389
7955
  def storage_get(kind, key)
5390
7956
  store(kind)[key.to_s]
5391
7957
  end
7958
+ # Per-area storage quota (Chrome / Firefox both cap a localStorage / sessionStorage area at
7959
+ # ~5 MiB per origin). Without it, the WPT quota tests — which `setItem` in a `while (true)` loop
7960
+ # until QuotaExceededError — write unbounded gigabytes and OOM the process.
7961
+ STORAGE_QUOTA_BYTES = 5 * 1024 * 1024
7962
+
7963
+ # Returns true when stored, false when the (key, value) would exceed the area's quota — the JS
7964
+ # shim turns a false into a QuotaExceededError and does NOT store (WHATWG "setItem" step). The
7965
+ # size is summed from the store each call (localStorage is shared across same-origin windows, so
7966
+ # a per-Browser running total would drift); replacing a key frees its old bytes first.
5392
7967
  def storage_set(kind, key, value)
5393
- store(kind)[key.to_s] = value.to_s
5394
- nil
7968
+ st = store(kind)
7969
+ key = key.to_s
7970
+ value = value.to_s
7971
+ used = st.sum {|k, v| k.bytesize + v.bytesize }
7972
+ used -= key.bytesize + st[key].bytesize if st.key?(key)
7973
+ return false if used + key.bytesize + value.bytesize > STORAGE_QUOTA_BYTES
7974
+
7975
+ st[key] = value
7976
+ true
5395
7977
  end
5396
7978
  def storage_remove(kind, key)
5397
7979
  store(kind).delete(key.to_s)
@@ -5410,6 +7992,62 @@ module Capybara
5410
7992
  private def store(kind)
5411
7993
  kind.to_s == 'session' ? @session_storage : @local_storage
5412
7994
  end
7995
+
7996
+ # Cache Storage backing — origin-partitioned dumb store; the JS side owns the spec
7997
+ # matching (cache-storage.js). `@cache_storage` is
7998
+ # origin => {seq:, names: {name => cache_id}, caches: {cache_id => {seq:, entries:}}}
7999
+ # The name→id indirection models the spec's "dooms, but does not delete immediately":
8000
+ # `caches.delete(name)` unmaps the name, but a Cache handle already bound to the id
8001
+ # keeps operating on its own storage (a fresh `open(name)` gets a new id / empty cache).
8002
+ # Each entry is `{id:, meta:, response:}` — `meta` the parsed request metadata the
8003
+ # matcher needs ({url, method, headers, vary}), `response` an opaque serialized-Response
8004
+ # JSON blob. Each host fn runs a single read-modify-write under the GVL, so concurrent
8005
+ # access from a service-worker thread stays atomic without a lock (localStorage
8006
+ # precedent). A doomed cache's storage lingers until `reset!` (per-test) frees it — a
8007
+ # bounded leak we accept rather than refcount handles across the JS boundary.
8008
+ def cache_storage_open(origin, name)
8009
+ store = (@cache_storage[origin.to_s] ||= {seq: 0, names: {}, caches: {}})
8010
+ id = (store[:names][name.to_s] ||= (store[:seq] += 1))
8011
+ store[:caches][id] ||= {seq: 0, entries: []}
8012
+ id
8013
+ end
8014
+ def cache_storage_has(origin, name)
8015
+ @cache_storage.dig(origin.to_s, :names)&.key?(name.to_s) || false
8016
+ end
8017
+ def cache_storage_delete(origin, name)
8018
+ names = @cache_storage.dig(origin.to_s, :names) or return false
8019
+ !names.delete(name.to_s).nil?
8020
+ end
8021
+ def cache_storage_keys(origin)
8022
+ (@cache_storage.dig(origin.to_s, :names) || {}).keys
8023
+ end
8024
+ def cache_entries(origin, cache_id)
8025
+ cache = cache_for(origin, cache_id) or return nil
8026
+ JSON.generate(cache[:entries].map {|e| {id: e[:id]}.merge(e[:meta]) })
8027
+ end
8028
+ def cache_entry_response(origin, cache_id, entry_id)
8029
+ cache = cache_for(origin, cache_id) or return nil
8030
+ entry = cache[:entries].find {|e| e[:id] == entry_id.to_i } or return nil
8031
+ entry[:response]
8032
+ end
8033
+ def cache_put(origin, cache_id, delete_ids_json, meta_json, response_json)
8034
+ cache = cache_for(origin, cache_id) or return nil
8035
+ ids = JSON.parse(delete_ids_json).map(&:to_i)
8036
+ cache[:entries].reject! {|e| ids.include?(e[:id]) } unless ids.empty?
8037
+ cache[:entries] << {id: (cache[:seq] += 1), meta: JSON.parse(meta_json), response: response_json.to_s}
8038
+ nil
8039
+ end
8040
+ def cache_delete_entries(origin, cache_id, ids_json)
8041
+ cache = cache_for(origin, cache_id) or return 0
8042
+ ids = JSON.parse(ids_json).map(&:to_i)
8043
+ before = cache[:entries].size
8044
+ cache[:entries].reject! {|e| ids.include?(e[:id]) }
8045
+ before - cache[:entries].size
8046
+ end
8047
+ # The cache hash ({seq:, entries:}) bound to a Cache handle's id, or nil if it's gone.
8048
+ private def cache_for(origin, cache_id)
8049
+ @cache_storage.dig(origin.to_s, :caches, cache_id.to_i)
8050
+ end
5413
8051
  # Push a one-shot handler onto the modal-dialog stack — the next
5414
8052
  # modal that fires consumes the topmost handler. Block exit pops
5415
8053
  # in case the dialog never fired.
@@ -5459,7 +8097,7 @@ module Capybara
5459
8097
  env = Rack::MockRequest.env_for(url, method: 'GET')
5460
8098
  apply_default_request_env(env, referer: current_browsing_context_url)
5461
8099
  status, headers, body = dispatch_rack_or_http(url, env, method: 'GET')
5462
- merge_set_cookie(headers)
8100
+ merge_set_cookie(headers, url)
5463
8101
  if (loc = redirect_location(status, headers))
5464
8102
  next_url = carry_fragment(url, resolve_against_current(loc))
5465
8103
  body.close if body.respond_to?(:close)
@@ -5480,7 +8118,7 @@ module Capybara
5480
8118
  env['CONTENT_LENGTH'] = body.bytesize.to_s
5481
8119
  apply_default_request_env(env, referer: current_browsing_context_url)
5482
8120
  status, headers, resp_body = dispatch_rack_or_http(url, env, method: 'POST', body: body)
5483
- merge_set_cookie(headers)
8121
+ merge_set_cookie(headers, url)
5484
8122
  if (loc = redirect_location(status, headers))
5485
8123
  next_url = resolve_against_current(loc)
5486
8124
  resp_body.close if resp_body.respond_to?(:close)
@@ -5597,7 +8235,7 @@ module Capybara
5597
8235
  env = Rack::MockRequest.env_for(url, method: 'GET')
5598
8236
  apply_default_request_env(env, referer: referer)
5599
8237
  status, headers, body = dispatch_rack_or_http(url, env, method: 'GET')
5600
- merge_set_cookie(headers)
8238
+ merge_set_cookie(headers, url)
5601
8239
  if (loc = redirect_location(status, headers))
5602
8240
  next_url = resolve_against_current(loc)
5603
8241
  # Per RFC 7231: if the original request URL had a fragment
@@ -5825,8 +8463,147 @@ module Capybara
5825
8463
  # server can negotiate — HTML-only routes still pick html,
5826
8464
  # both-available pick the first registered.
5827
8465
  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?
8466
+ env['HTTP_REFERER'] = referer unless referer.nil? || referer.empty?
8467
+ # Attach the TARGET host's cookies (not the document's) SERVER_NAME is the
8468
+ # request's host — so a cross-origin request carries the right jar or none.
8469
+ ck = cookie_header_for(env_cookie_host(env))
8470
+ if ck.empty?
8471
+ env.delete('HTTP_COOKIE')
8472
+ else
8473
+ env['HTTP_COOKIE'] = ck
8474
+ end
8475
+ end
8476
+
8477
+ # Referer / Origin / Fetch-Metadata for a NAVIGATION request (a document load — form
8478
+ # submit, location set, link activation). Unlike a fetch/XHR (which carries a per-request
8479
+ # policy through rack_fetch), a navigation's request headers derive from the INITIATING
8480
+ # document: its Referrer-Policy (compute_referrer), its origin (an `Origin` header — sent
8481
+ # only on an unsafe method, i.e. a form POST, never a GET/HEAD navigation), and the
8482
+ # Fetch-Metadata triple. `dest` is 'document' for a top-level nav, 'iframe' for a subframe.
8483
+ # `site_override` / `origin_null` carry redirect-chain latched state (navigate_realm_self_*
8484
+ # thread it through each hop): Sec-Fetch-Site is the WIDEST initiator↔url relationship over the
8485
+ # whole chain (a same-site redirect keeps 'same-site' even when the final hop is same-origin),
8486
+ # and a form POST's Origin becomes the opaque 'null' once any hop has crossed origin.
8487
+ def navigation_request_headers(env, method:, initiator_url:, target:, dest:, referrer_policy: nil, user_activated: false, site_override: nil, origin_null: false)
8488
+ apply_default_request_env(env, referer: compute_referrer(referrer_policy, initiator_url, target))
8489
+ # Origin is appended to every non-GET/HEAD request (a form POST carries it; a GET navigation
8490
+ # does not). From a KNOWN initiating document it's that document's origin serialization, or
8491
+ # the literal 'null' when that origin is opaque (an about:blank / data: frame) or has been
8492
+ # tainted by a cross-origin redirect — matching the fetch path's `effective_origin`
8493
+ # convention. Omitted only when the initiator is unknown (a disposed realm → nil url).
8494
+ if initiator_url && !initiator_url.to_s.empty? && !%w[GET HEAD].include?(method.to_s.upcase)
8495
+ env['HTTP_ORIGIN'] = (!origin_null && url_origin(initiator_url)) || 'null'
8496
+ end
8497
+ env['HTTP_SEC_FETCH_SITE'] = site_override || sec_fetch_site(initiator_url, target)
8498
+ env['HTTP_SEC_FETCH_MODE'] = 'navigate'
8499
+ env['HTTP_SEC_FETCH_DEST'] = dest.to_s
8500
+ env['HTTP_SEC_FETCH_USER'] = '?1' if user_activated
8501
+ end
8502
+
8503
+ # Build + dispatch a navigation request (a frame/document load) — the shared core of the GET
8504
+ # self-nav, the POST self-nav, and the navigation-preload request, so all three send the SAME
8505
+ # Referer / Origin / Fetch-Metadata (dest 'iframe') and can't silently diverge. `site` is the
8506
+ # caller's already-widened Sec-Fetch-Site (threaded across the redirect chain it owns);
8507
+ # `extra_headers` carries any request-specific CGI header (the preload marker). Returns
8508
+ # [status, headers, resp_body] — the caller handles redirects / reload / the preload wire.
8509
+ def dispatch_navigation_request(url, method:, initiator:, referrer_policy:, site:, origin_null:, body: nil, content_type: nil, extra_headers: nil)
8510
+ env = Rack::MockRequest.env_for(url, method: method, input: body || '')
8511
+ if content_type
8512
+ env['CONTENT_TYPE'] = content_type.to_s.empty? ? 'application/x-www-form-urlencoded' : content_type
8513
+ env['CONTENT_LENGTH'] = body.to_s.bytesize.to_s
8514
+ end
8515
+ navigation_request_headers(
8516
+ env,
8517
+ method: method,
8518
+ initiator_url: initiator,
8519
+ target: url.to_s,
8520
+ dest: 'iframe',
8521
+ referrer_policy: referrer_policy,
8522
+ site_override: site,
8523
+ origin_null: origin_null
8524
+ )
8525
+ extra_headers&.each {|k, v| env[k] = v }
8526
+ status, headers, resp_body = dispatch_rack_or_http(url, env, method: method, body: body)
8527
+ merge_set_cookie(headers, url)
8528
+ [status, headers, resp_body]
8529
+ end
8530
+
8531
+ # The Navigation Preload request: a parallel GET the browser issues for a navigation whose
8532
+ # controlling SW has preload enabled, exposed to the SW as `event.preloadResponse`. It IS a
8533
+ # navigation request (dest 'iframe', mode 'navigate', the frame's Referer / Sec-Fetch-Site — so
8534
+ # the server sees exactly what a no-SW navigation would) plus the `Service-Worker-Navigation-
8535
+ # Preload` header carrying the registration's header value. The SW intercepts the FINAL URL (a
8536
+ # pre-SW network redirect already happened + widened `site_seed`), so this is a single hop — no
8537
+ # redirect loop. Returns the response wire hash for the fetch-event JSON, or nil on a hard error.
8538
+ def navigation_preload_response(url, referrer_source, referrer_policy, site_seed, origin_null, header_value)
8539
+ site = widen_sec_fetch_site(site_seed, sec_fetch_site(referrer_source, url))
8540
+ status, headers, resp_body = dispatch_navigation_request(
8541
+ url,
8542
+ method: 'GET',
8543
+ initiator: referrer_source,
8544
+ referrer_policy: referrer_policy,
8545
+ site: site,
8546
+ origin_null: origin_null,
8547
+ extra_headers: {'HTTP_SERVICE_WORKER_NAVIGATION_PRELOAD' => header_value.to_s}
8548
+ )
8549
+ {
8550
+ 'status' => status,
8551
+ 'statusText' => RuntimeShared.utf8_text(Rack::Utils::HTTP_STATUS_CODES[status.to_i] || ''),
8552
+ 'headers' => headers.to_h,
8553
+ # The UA transparently decodes a Content-Encoding'd body (gzip/deflate) before the SW's
8554
+ # `event.preloadResponse.text()` sees it — the header stays, the bytes are inflated (as the
8555
+ # regular fetch path does).
8556
+ 'body_b64' => Base64.strict_encode64(decode_content_encoding(read_rack_body(resp_body), headers))
8557
+ }
8558
+ rescue StandardError
8559
+ nil
8560
+ end
8561
+
8562
+ # Fetch-Metadata `Sec-Fetch-Site`: the relationship of a request's INITIATOR to its target.
8563
+ # no initiator (a direct address-bar load) → 'none'; same (scheme,host,port) → 'same-origin';
8564
+ # same registrable site (eTLD+1) → 'same-site'; else → 'cross-site'.
8565
+ def sec_fetch_site(initiator_url, target_url)
8566
+ io = url_origin(initiator_url)
8567
+ return 'none' if io.nil?
8568
+ return 'same-origin' if io == url_origin(target_url)
8569
+ is = registrable_site(initiator_url)
8570
+ ts = registrable_site(target_url)
8571
+ is && ts && is == ts ? 'same-site' : 'cross-site'
8572
+ end
8573
+
8574
+ SEC_FETCH_SITE_RANK = {'same-origin' => 0, 'same-site' => 1, 'cross-site' => 2, 'none' => 2}.freeze
8575
+ private_constant :SEC_FETCH_SITE_RANK
8576
+ # The wider (more distant) of two Sec-Fetch-Site values — used to latch the value across a
8577
+ # redirect chain (same-origin < same-site < cross-site). `nil` seed → the other value.
8578
+ def widen_sec_fetch_site(a, b)
8579
+ return b if a.nil?
8580
+ SEC_FETCH_SITE_RANK[b].to_i > SEC_FETCH_SITE_RANK[a].to_i ? b : a
8581
+ end
8582
+
8583
+ # Fetch "HTTP-redirect fetch": a request's Origin opaques to 'null' once it follows a
8584
+ # cross-origin redirect WHILE ALREADY off the initiator's origin — i.e. the current URL is
8585
+ # cross-origin to BOTH the redirect target and the initiator. The FIRST cross-origin hop (still
8586
+ # on the initiator's origin) keeps the real Origin; a later hop that redirects same-origin-to-
8587
+ # current keeps it too. Monotonic: once opaque, an opaque origin is same-origin with nothing, so
8588
+ # it stays null.
8589
+ def redirect_taints_origin?(already_null, initiator_url, current_url, location_url)
8590
+ already_null ||
8591
+ (url_origin(current_url) != url_origin(location_url) && url_origin(initiator_url) != url_origin(current_url))
8592
+ end
8593
+
8594
+ # scheme + registrable domain (approx eTLD+1) of a URL — the "site" a Sec-Fetch-Site /
8595
+ # same-site comparison uses. Last-two-labels approximation (no Public Suffix List — correct
8596
+ # for the single-label TLDs our hosts use); an IP literal / ≤2-label host is its own site.
8597
+ # A `blob:` URL derives from its inner origin. nil for a hostless / non-http(s) URL.
8598
+ def registrable_site(url)
8599
+ u = URI.parse(url.to_s.sub(/\Ablob:/, ''))
8600
+ host = u.host.to_s
8601
+ return nil if host.empty? || !u.scheme&.match?(/\Ahttps?\z/i)
8602
+ labels = host.split('.')
8603
+ regd = host.start_with?('[') || host.match?(/\A\d+(\.\d+){3}\z/) || labels.length <= 2 ? host : labels.last(2).join('.')
8604
+ "#{u.scheme}://#{regd}"
8605
+ rescue URI::Error
8606
+ nil
5830
8607
  end
5831
8608
 
5832
8609
  # Cross-host hop (e.g. Discourse's `discourse_connect` flow
@@ -5911,9 +8688,22 @@ module Capybara
5911
8688
  nil
5912
8689
  end
5913
8690
 
5914
- def merge_set_cookie(headers)
8691
+ # Store a response's Set-Cookie headers under the RESPONDING host's jar (`url` is
8692
+ # the hop that produced `headers`). A cross-origin hop therefore writes its own
8693
+ # host's jar, never the document's (cors-cookies isolation).
8694
+ # True if the response carries a `WWW-Authenticate: Basic …` challenge — the only scheme the
8695
+ # transparent-auth retry in rack_fetch answers.
8696
+ def www_authenticate_basic?(headers)
8697
+ return false unless headers.is_a?(Hash)
8698
+ headers.each {|k, v| return true if k.to_s.casecmp?('www-authenticate') && v.to_s.strip.downcase.start_with?('basic') }
8699
+ false
8700
+ end
8701
+
8702
+ def merge_set_cookie(headers, url)
5915
8703
  sc = headers['set-cookie'] || headers['Set-Cookie']
5916
8704
  return if sc.nil? || sc.empty?
8705
+ host = cookie_host(url) || document_cookie_host or return
8706
+ jar = (@cookies[host] ||= {})
5917
8707
  # Rack 2 returns multiple Set-Cookie headers as a single
5918
8708
  # newline-separated string; Rack 3 returns an Array. Treat both
5919
8709
  # uniformly — splitting first means the second cookie in a
@@ -5926,9 +8716,9 @@ module Capybara
5926
8716
  name, value = pair.split('=', 2)
5927
8717
  next if name.nil? || name.empty?
5928
8718
  if cookie_deletion?(parts)
5929
- @cookies.delete(name.strip)
8719
+ jar.delete(name.strip)
5930
8720
  else
5931
- @cookies[name.strip] = value.to_s.strip
8721
+ jar[name.strip] = value.to_s.strip
5932
8722
  end
5933
8723
  }
5934
8724
  end
@@ -6026,19 +8816,49 @@ module Capybara
6026
8816
  # preflight whose Access-Control-Allow-Methods / -Headers carries a malformed value.
6027
8817
  HTTP_TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/.freeze
6028
8818
  CORS_SAFELISTED_CTYPES = %w[application/x-www-form-urlencoded multipart/form-data text/plain].freeze
8819
+ # Fetch "CORS-unsafe request-header byte" — a byte in a header value that forces a
8820
+ # safelisted-name header out of the safelisted set (so a preflight becomes necessary):
8821
+ # a control byte other than HT (0x09), DEL, or one of the delimiters "(),:<>?@[\]{}.
8822
+ CORS_UNSAFE_VALUE_BYTE = /[\x00-\x08\x0a-\x1f\x7f"():<>?@\[\\\]{}]/n.freeze
8823
+ # accept-language / content-language values are further restricted: only digits,
8824
+ # ASCII letters, space, and `*,-.;=` keep them safelisted.
8825
+ CORS_LANGUAGE_VALUE = /\A[0-9A-Za-z *,\-.;=]*\z/n.freeze
8826
+
8827
+ # Fetch "CORS-safelisted request-header": a (name, value) whose value keeps the
8828
+ # request "simple" (no preflight). All four names cap the value at 128 bytes; each
8829
+ # then constrains which bytes the value may contain (a `"` in Accept, a control byte
8830
+ # in Content-Language, an over-long text/plain Content-Type all force a preflight —
8831
+ # cors-preflight-not-cors-safelisted).
8832
+ def cors_safelisted_request_header?(name, value)
8833
+ v = value.to_s.b
8834
+ return false if v.bytesize > 128
8835
+ case name
8836
+ when 'accept'
8837
+ !v.match?(CORS_UNSAFE_VALUE_BYTE)
8838
+ when 'accept-language', 'content-language'
8839
+ v.match?(CORS_LANGUAGE_VALUE)
8840
+ when 'content-type'
8841
+ return false if v.match?(CORS_UNSAFE_VALUE_BYTE)
8842
+ essence = v.split(';', 2).first.to_s.strip.downcase
8843
+ CORS_SAFELISTED_CTYPES.include?(essence)
8844
+ else
8845
+ false
8846
+ end
8847
+ end
6029
8848
 
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.
8849
+ # The sorted, lowercased author header names that are NOT CORS-safelisted. A
8850
+ # safelisted NAME still counts as unsafe when its VALUE fails the safelisting (an
8851
+ # unsafe byte / over-128-byte length / non-safelisted Content-Type essence). These
8852
+ # are echoed in Access-Control-Request-Headers for the preflight and must be covered
8853
+ # by Access-Control-Allow-Headers.
6033
8854
  def cors_unsafe_headers(headers)
6034
8855
  (headers || {}).filter_map {|k, v|
6035
8856
  name = k.to_s.downcase
6036
8857
  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
8858
+ if CORS_SAFELISTED_HEADERS.include?(name)
8859
+ cors_safelisted_request_header?(name, v) ? nil : name
6040
8860
  else
6041
- CORS_SAFELISTED_HEADERS.include?(name) ? nil : name
8861
+ name
6042
8862
  end
6043
8863
  }.uniq.sort
6044
8864
  end
@@ -6161,6 +8981,8 @@ module Capybara
6161
8981
  env['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'] = unsafe.join(',') unless unsafe.empty?
6162
8982
  status, ph, pbody = dispatch_rack_or_http(target, env, method: 'OPTIONS', body: nil)
6163
8983
  pbody.close if pbody.respond_to?(:close)
8984
+ # A slow preflight counts toward the client's timeout too (timeout-multiple-fetches).
8985
+ @fetch_server_delay_ms += server_delay_ms_of(ph) if @fetch_server_delay_ms
6164
8986
  return nil unless (200..299).include?(status.to_i)
6165
8987
  acao = cors_header(ph, 'access-control-allow-origin')
6166
8988
  # A credentialed preflight can't be allowed by the wildcard origin and must carry
@@ -6186,19 +9008,29 @@ module Capybara
6186
9008
 
6187
9009
  # The response headers a cross-origin "cors" response exposes to getResponseHeader /
6188
9010
  # 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)
9011
+ # -Expose-Headers. `*` exposes every header, but ONLY for a non-credentialed response;
9012
+ # with credentials the wildcard loses its meaning and matches a header literally named
9013
+ # `*` (cors-expose-star "only matches literally").
9014
+ # The virtual server delay a response carries (X-Csim-Server-Delay-Ms, ms), 0 if none.
9015
+ def server_delay_ms_of(headers)
9016
+ return 0 unless headers.is_a?(Hash)
9017
+ pair = headers.find {|k, _| k.to_s.casecmp?('x-csim-server-delay-ms') }
9018
+ pair ? pair.last.to_i : 0
9019
+ end
9020
+
9021
+ def cors_exposed_headers(headers, credentialed = false)
6192
9022
  # 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.
9023
+ # script, even when explicitly named in Access-Control-Expose-Headers or covered by
9024
+ # `*` (cors-filtering "header is forbidden"). x-csim-status-text is our internal
9025
+ # reason-phrase sentinel (response_hash lifts it into statusText, which IS exposed
9026
+ # cross-origin, then strips it from the script-visible map), so it must survive.
6197
9027
  forbidden = %w[set-cookie set-cookie2]
6198
9028
  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?('*')
9029
+ if !credentialed && expose.include?('*')
9030
+ return headers.reject {|k, _| forbidden.include?(k.to_s.downcase) }
9031
+ end
6200
9032
  allowed = CORS_SAFELISTED_RESPONSE_HEADERS + expose + ['x-csim-status-text']
6201
- headers.select {|k, _| allowed.include?(k.to_s.downcase) }
9033
+ headers.select {|k, _| allowed.include?(k.to_s.downcase) && !forbidden.include?(k.to_s.downcase) }
6202
9034
  end
6203
9035
 
6204
9036
  # Case-insensitive response-header lookup + comma-list split for the CORS checks.