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.
- checksums.yaml +4 -4
- data/README.md +12 -10
- data/lib/capybara/simulated/browser.rb +3077 -245
- data/lib/capybara/simulated/driver.rb +85 -15
- data/lib/capybara/simulated/js/bridge.bundle.js +33716 -19156
- data/lib/capybara/simulated/node.rb +33 -7
- data/lib/capybara/simulated/quickjs_runtime.rb +74 -17
- data/lib/capybara/simulated/runtime_shared.rb +453 -34
- data/lib/capybara/simulated/v8_runtime.rb +134 -10
- data/lib/capybara/simulated/version.rb +1 -1
- metadata +2 -2
|
@@ -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
|
-
|
|
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
|
|
407
|
-
|
|
408
|
-
|
|
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.
|
|
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
|
-
@
|
|
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
|
-
|
|
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:
|
|
1210
|
-
#
|
|
1211
|
-
#
|
|
1212
|
-
#
|
|
1213
|
-
#
|
|
1214
|
-
#
|
|
1215
|
-
|
|
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
|
-
|
|
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
|
|
1275
|
-
# `
|
|
1276
|
-
#
|
|
1277
|
-
#
|
|
1278
|
-
#
|
|
1279
|
-
#
|
|
1280
|
-
#
|
|
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('
|
|
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
|
-
|
|
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 ||
|
|
1648
|
-
def viewport_height ; @viewport_height ||
|
|
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
|
-
|
|
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 :
|
|
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
|
|
2912
|
-
# authenticate the user the same way the browser would
|
|
2913
|
-
#
|
|
2914
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
3504
|
-
#
|
|
3505
|
-
|
|
3506
|
-
|
|
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(
|
|
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
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
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
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
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
|
|
3711
|
-
|
|
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('&', '&').gsub('<', '<').gsub('>', '>')
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4120
|
-
#
|
|
4121
|
-
#
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
4453
|
-
#
|
|
4454
|
-
#
|
|
4455
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]] =
|
|
7469
|
+
h[:entries][h[:idx]] = snapshot_outgoing_entry(realm_id, h[:entries][h[:idx]])
|
|
5109
7470
|
else
|
|
5110
|
-
h[:entries] <<
|
|
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
|
-
|
|
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
|
-
#
|
|
5136
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
5145
|
-
reload_frame_realm_by_id(realm_id, url, html, response_content_type(headers), restore_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 :
|
|
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
|
-
|
|
5204
|
-
|
|
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
|
-
|
|
5208
|
-
|
|
5209
|
-
|
|
5210
|
-
|
|
5211
|
-
|
|
5212
|
-
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5231
|
-
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
|
|
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
|
-
#
|
|
5361
|
-
#
|
|
5362
|
-
#
|
|
5363
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
7941
|
+
jar.delete(name.strip)
|
|
5376
7942
|
else
|
|
5377
|
-
|
|
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)
|
|
5394
|
-
|
|
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
|
|
5829
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8719
|
+
jar.delete(name.strip)
|
|
5930
8720
|
else
|
|
5931
|
-
|
|
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
|
|
6031
|
-
#
|
|
6032
|
-
#
|
|
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
|
|
6038
|
-
|
|
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
|
-
|
|
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
|
|
6190
|
-
#
|
|
6191
|
-
|
|
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
|
|
6194
|
-
#
|
|
6195
|
-
#
|
|
6196
|
-
#
|
|
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
|
-
|
|
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.
|