capybara-simulated 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +4 -3
- data/lib/capybara/simulated/browser.rb +2562 -234
- data/lib/capybara/simulated/driver.rb +43 -2
- data/lib/capybara/simulated/js/bridge.bundle.js +30437 -18595
- data/lib/capybara/simulated/node.rb +25 -7
- data/lib/capybara/simulated/quickjs_runtime.rb +52 -9
- data/lib/capybara/simulated/runtime_shared.rb +425 -7
- data/lib/capybara/simulated/v8_runtime.rb +81 -6
- data/lib/capybara/simulated/version.rb +1 -1
- metadata +2 -2
|
@@ -196,7 +196,7 @@ module Capybara
|
|
|
196
196
|
Rack::Mime.mime_type(File.extname(path.to_s), '')
|
|
197
197
|
end
|
|
198
198
|
|
|
199
|
-
def initialize(app, driver: nil, js_engine: nil, cookies: nil, local_storage: nil, all_hosts_local: nil)
|
|
199
|
+
def initialize(app, driver: nil, js_engine: nil, cookies: nil, auth_cache: nil, local_storage: nil, cache_storage: nil, all_hosts_local: nil)
|
|
200
200
|
@app = app
|
|
201
201
|
@driver = driver
|
|
202
202
|
@all_hosts_local_override = all_hosts_local
|
|
@@ -239,7 +239,16 @@ module Capybara
|
|
|
239
239
|
# see the same auth state and storage as the primary. Tests
|
|
240
240
|
# without a Driver (gem-internal callers) get fresh jars.
|
|
241
241
|
@cookies = cookies || {}
|
|
242
|
+
# HTTP Basic-auth credential cache, keyed by target origin: once credentials authenticate an
|
|
243
|
+
# origin, the UA sends them pre-emptively for later credentialed requests to it (RFC 7617
|
|
244
|
+
# §2.2), so a Basic-auth resource loads without re-challenging. Session-scoped (cleared on
|
|
245
|
+
# reset) and Driver-injected like the cookie jar, so target=_blank aux windows share one
|
|
246
|
+
# session's auth state (a real browser shares the HTTP auth cache across a session's tabs).
|
|
247
|
+
@auth_cache = auth_cache || {}
|
|
242
248
|
@local_storage = local_storage || {}
|
|
249
|
+
# Cache Storage is origin-shared like localStorage (the Driver owns the store
|
|
250
|
+
# and injects it into every window Browser), origin-partitioned within.
|
|
251
|
+
@cache_storage = cache_storage || {}
|
|
243
252
|
@session_storage = {}
|
|
244
253
|
@sticky_headers = {}
|
|
245
254
|
@timers_active = false
|
|
@@ -279,6 +288,9 @@ module Capybara
|
|
|
279
288
|
@current_realm_id = nil
|
|
280
289
|
@frame_stack = []
|
|
281
290
|
@last_tick_ts = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
291
|
+
# The first find of a navigation observes the current DOM without a pre-tick (see
|
|
292
|
+
# timer_wait_elapsed?); armed by the first find, disarmed by reset_timer_state.
|
|
293
|
+
@pre_tick_armed = false
|
|
282
294
|
@polling_grace = nil
|
|
283
295
|
@last_polled_gen = nil
|
|
284
296
|
@idle_settle_polls = 0
|
|
@@ -332,6 +344,13 @@ module Capybara
|
|
|
332
344
|
@websocket_sockets = {} # id → csim's socket end (main thread owns this hash)
|
|
333
345
|
@websocket_app_sockets = {} # id → the app's hijack end (closed on teardown)
|
|
334
346
|
@websocket_queue = Thread::Queue.new
|
|
347
|
+
@websocket_queue_head = nil # one-slot buffer for an event hold_for_ws_close parked ahead of the queue
|
|
348
|
+
# In-flight `ws.close()` handshakes: hold the virtual clock until the reader surfaces the
|
|
349
|
+
# server's echoed close frame (a `__close` event) so a test awaiting `onclose` can't have
|
|
350
|
+
# its virtual-timeout outrun that real off-thread reply. Counted in ws_close, cleared as
|
|
351
|
+
# deliver_websocket_events delivers each terminal event. See hold_for_ws_close.
|
|
352
|
+
@ws_close_pending = 0
|
|
353
|
+
@ws_close_wait_deadline = nil
|
|
335
354
|
# All frame writes (the reader thread's pong replies + the main thread's
|
|
336
355
|
# send/close) go through one socket; serialise them so two threads can't
|
|
337
356
|
# interleave bytes into a corrupt frame.
|
|
@@ -355,10 +374,73 @@ module Capybara
|
|
|
355
374
|
@worker_seq = 0
|
|
356
375
|
@workers = {}
|
|
357
376
|
@worker_outbox = Thread::Queue.new
|
|
377
|
+
# One-slot head buffer for the settle wait: an event popped while blocking on the outbox
|
|
378
|
+
# is parked here (a push-back would reorder it behind concurrent worker pushes) and
|
|
379
|
+
# consumed first by the next deliver_worker_messages.
|
|
380
|
+
@worker_outbox_head = nil
|
|
358
381
|
# Outstanding posts-to-worker; `polling?` stays true while > 0
|
|
359
382
|
# so long-running compute (e.g. mozjpeg over an 8900×8900 frame)
|
|
360
383
|
# isn't starved by the settle_gen idle gate.
|
|
361
384
|
@worker_in_flight = 0
|
|
385
|
+
# BroadcastChannel posts pushed to worker inboxes that the worker hasn't yet processed. Kept
|
|
386
|
+
# SEPARATE from @worker_in_flight: a broadcast is fire-and-forget (a listen-only worker never
|
|
387
|
+
# replies), so it must not be "answered" by an unrelated postMessage reply. The worker acks
|
|
388
|
+
# each broadcast it delivers (a `bcack` outbox event), which decrements this; `worker_pending?`
|
|
389
|
+
# stays true until then so settle waits for the delivery.
|
|
390
|
+
@worker_broadcast_pending = 0
|
|
391
|
+
# Client → service-worker messages awaiting the worker's ack (it processed the inbound
|
|
392
|
+
# `message`). A SW `postMessage` produces no 1:1 reply (the SW replies via client.postMessage,
|
|
393
|
+
# a separate outbox event), so — like broadcasts — it needs its own pending tally, or a
|
|
394
|
+
# listen-only SW would leave settle perpetually non-idle.
|
|
395
|
+
@sw_message_pending = 0
|
|
396
|
+
# Controlled-client fetches awaiting the SW's respondWith (released by a `fetch_response`).
|
|
397
|
+
@sw_fetch_pending = 0
|
|
398
|
+
# Streaming respondWith bodies still open (head delivered, terminal frame not yet), keyed by
|
|
399
|
+
# the emitting worker handle → the [realm_id, fetch_id] frames it opened. Lets worker_terminate
|
|
400
|
+
# release + error a stream its worker died mid-flight, instead of stranding @sw_fetch_pending.
|
|
401
|
+
@sw_open_streams = Hash.new {|h, k| h[k] = {} }
|
|
402
|
+
# Deadline (CLOCK_MONOTONIC) capping how long the event-loop drain holds the virtual clock
|
|
403
|
+
# for an outstanding SW-side fetch (see run_event_loop_frame). Shared across frames so a
|
|
404
|
+
# stuck fetch costs the budget ONCE, not per frame; reset on each delivered reply so the next
|
|
405
|
+
# fetch in a sequence waits afresh.
|
|
406
|
+
@sw_fetch_wait_deadline = nil
|
|
407
|
+
# Same budget for a pending SW message swack / broadcast ack (drain_pending_message_reply);
|
|
408
|
+
# separate from the fetch deadline so a message wait and a fetch hold don't share a spent
|
|
409
|
+
# budget. Reset once no message/broadcast reply is outstanding.
|
|
410
|
+
@sw_msg_wait_deadline = nil
|
|
411
|
+
# SW navigation-interception state. `@sw_registrations` mirrors scope-href → active
|
|
412
|
+
# worker handle (from the client lifecycle) so a navigation fetched Ruby-side — before
|
|
413
|
+
# the destination realm's JS exists — can find its controlling SW; it survives the
|
|
414
|
+
# per-visit rebuild_ctx (unlike the per-realm JS registrations Map). A navigation fetch
|
|
415
|
+
# is awaited SYNCHRONOUSLY on `@sw_nav_outbox` (a dedicated queue, off the general outbox)
|
|
416
|
+
# keyed by a NEGATIVE `@sw_nav_seq` id so it never mixes with client-fetch replies.
|
|
417
|
+
@sw_registrations = {}
|
|
418
|
+
# Navigation Preload state, per active-worker HANDLE (the registration's active worker — the
|
|
419
|
+
# client's `registration.active._handle` and the worker's own `__csimWorkerHandle` are the
|
|
420
|
+
# same id, so both isolates key here identically). {enabled:, header:}; absent → the spec
|
|
421
|
+
# default {false, 'true'}. Read at navigation time to decide whether to issue the parallel
|
|
422
|
+
# preload request (see service_worker_navigation_fetch), and by the NavigationPreloadManager.
|
|
423
|
+
# EARNED GAP: the spec keeps this per-REGISTRATION (it survives a SW update); keying by the
|
|
424
|
+
# active worker's handle means an update — which mints a fresh handle — resets it to default.
|
|
425
|
+
# No vendored subtest enables preload then updates the worker, so handle-keying (which needs no
|
|
426
|
+
# scope plumbing to the worker isolate) is the simpler load-bearing choice.
|
|
427
|
+
@sw_navpreload = {}
|
|
428
|
+
# clients.claim() events that arrived before their scope was mirrored into @sw_registrations
|
|
429
|
+
# (activate→claim() races the client-side lifecycle) — buffered here, flushed by sw_register_scope.
|
|
430
|
+
@sw_pending_claims = []
|
|
431
|
+
@sw_nav_outbox = Thread::Queue.new
|
|
432
|
+
@sw_nav_seq = 0
|
|
433
|
+
# Service-worker Client registry: realm id → {handle, rec} for every
|
|
434
|
+
# controlled frame/window client, mirrored into the SW's clientsById so
|
|
435
|
+
# matchAll / getClientByURL see the real set. `@sw_realm_controller` records
|
|
436
|
+
# each realm's controller so an opaque child (about:blank / srcdoc) inherits
|
|
437
|
+
# it. Both keyed by realm id, cleared when the last worker exits.
|
|
438
|
+
@sw_clients = {}
|
|
439
|
+
@sw_realm_controller = {}
|
|
440
|
+
# Cross-isolate MessagePort channels: channel id → {realm:, sw:} endpoints. A port
|
|
441
|
+
# transferred between a client realm and a worker/SW isolate registers both ends here;
|
|
442
|
+
# the browser relays each side's postMessage to the other. Cleared with the workers.
|
|
443
|
+
@port_channels = {}
|
|
362
444
|
# Workers whose initial script hasn't finished running yet. A worker that
|
|
363
445
|
# posts immediately on spawn (no main->worker message first) would leave
|
|
364
446
|
# `@worker_in_flight` at 0, so `worker_pending?` would be false in the gap
|
|
@@ -366,6 +448,16 @@ module Capybara
|
|
|
366
448
|
# stop waiting before the message lands. Count spawned-but-not-initialised
|
|
367
449
|
# workers so the async drain holds until the initial script has run.
|
|
368
450
|
@worker_initializing = 0
|
|
451
|
+
# Worker threads actively PROCESSING a plain postMessage (dequeued, not yet back at
|
|
452
|
+
# the idle poll). `@worker_in_flight` counts posted messages minus delivered replies,
|
|
453
|
+
# but one request yields MANY replies (progress updates + the final resolve), so it
|
|
454
|
+
# under-counts to 0 mid-handshake and `worker_pending?` would go false while the worker
|
|
455
|
+
# is still working — settle then breaks and abandons a multi-round protocol
|
|
456
|
+
# (Tesseract's createWorker load→loadLanguage→initialize→recognize, each a round-trip
|
|
457
|
+
# gated on a slow synchronous WASM step). The SW / broadcast message kinds have their
|
|
458
|
+
# own pending counters, so only the postMessage branch bumps this. Held under
|
|
459
|
+
# @worker_init_lock alongside @worker_initializing.
|
|
460
|
+
@worker_busy = 0
|
|
369
461
|
@worker_init_lock = Mutex.new
|
|
370
462
|
# Cross-isolate `blob:` store. Worker isolates can't see the
|
|
371
463
|
# main scope's `__csimBlobs` Map, so we mirror bytes here and
|
|
@@ -382,6 +474,10 @@ module Capybara
|
|
|
382
474
|
@transfer_buffer_lock = Mutex.new
|
|
383
475
|
@transfer_buffers = {}
|
|
384
476
|
@transfer_buffer_seq = 0
|
|
477
|
+
# Per-font ascent/descent probe cache for canvas text (render_text is
|
|
478
|
+
# worker-reachable via OffscreenCanvas, like decode_image).
|
|
479
|
+
@font_vmetrics_lock = Mutex.new
|
|
480
|
+
@font_vmetrics = {}
|
|
385
481
|
# Zero-copy postMessage transfer tokens (rusty_racer >= 0.1.6
|
|
386
482
|
# `RustyRacer.transferOut`): a buffer in a `postMessage` transfer list
|
|
387
483
|
# crosses isolates by token (no byte copy), its source detached. A token
|
|
@@ -399,13 +495,52 @@ module Capybara
|
|
|
399
495
|
# Cross-window BroadcastChannel messages from OTHER windows, delivered to
|
|
400
496
|
# this window's matching channels on settle. [{name, data}] (same thread).
|
|
401
497
|
@broadcast_inbox = []
|
|
402
|
-
|
|
498
|
+
# Storage `storage` events queued for the OTHER same-origin documents (a change
|
|
499
|
+
# fires at every same-origin document EXCEPT the one that made it), delivered on
|
|
500
|
+
# settle. [{kind, key, old, new, url, source}] (same thread).
|
|
501
|
+
@storage_inbox = []
|
|
502
|
+
# BroadcastChannel isolate-wide registry + global ordered delivery queue (the multi-realm
|
|
503
|
+
# path — see broadcast_to_windows / bc_post). `@bc_registry` is keyed by [realm_id, local_id]
|
|
504
|
+
# → {seq, name, origin_key, closed}; `@bc_seq` is the isolate-wide creation counter that
|
|
505
|
+
# orders delivery "oldest channel first"; `@bc_queue` is the FIFO of pending {realm_id,
|
|
506
|
+
# local_id, data, origin} deliveries drained in order by deliver_window_messages.
|
|
507
|
+
@bc_seq = 0
|
|
508
|
+
@bc_registry = {}
|
|
509
|
+
@bc_queue = []
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# Max BroadcastChannel deliveries drained per `deliver_broadcast_queue` call — a safety bound on a
|
|
513
|
+
# pathological mutual re-post loop (see there). Far above any real fan-out (the ordering test is ~14).
|
|
514
|
+
BROADCAST_DRAIN_CAP = 100_000
|
|
403
515
|
|
|
404
516
|
# Worker thread polling and termination intervals — split so a
|
|
405
517
|
# tuning change to one doesn't accidentally rebind the other.
|
|
406
|
-
WORKER_POLL_INTERVAL
|
|
407
|
-
|
|
408
|
-
|
|
518
|
+
WORKER_POLL_INTERVAL = 0.05
|
|
519
|
+
# Max wall time settle blocks on a worker thread's outbox per call while it processes an
|
|
520
|
+
# inbound message / fetch (releasing the GVL so it runs). Bounded so a genuinely stuck worker
|
|
521
|
+
# can't hang settle — the outer poll loop re-drives across calls.
|
|
522
|
+
WORKER_ROUND_TRIP_BUDGET = 1.0
|
|
523
|
+
WORKER_TERMINATE_GRACE = 0.05
|
|
524
|
+
# Max timer-draining rounds `drive_worker_to_quiescence` runs before yielding back
|
|
525
|
+
# to the poll loop. A message handler's async bring-up (Emscripten WASM init) settles
|
|
526
|
+
# in a handful of microtask/timer rounds; the cap only bites a worker that keeps
|
|
527
|
+
# rescheduling timers (a setInterval), which the poll loop then continues to advance.
|
|
528
|
+
WORKER_QUIESCE_MAX_ROUNDS = 256
|
|
529
|
+
# Per-frame GVL yield (run_event_loop_frame) while a worker thread is alive, so it gets a clean
|
|
530
|
+
# slice for cross-isolate work (transferIn / message replies) instead of being starved by the
|
|
531
|
+
# phase-1 spin. 0.3ms is the empirical floor for a deterministic cross-isolate transfer reply;
|
|
532
|
+
# 0.5ms adds margin for machine variance while staying cheap (only paid on worker/SW files).
|
|
533
|
+
WORKER_GVL_YIELD = 0.0005
|
|
534
|
+
# Client-realm handler for each streaming respondWith frame kind (see deliver_worker_messages
|
|
535
|
+
# + sw-client.js). `fr_start` builds a ReadableStream-backed Response; `fr_chunk` enqueues;
|
|
536
|
+
# `fr_close` / `fr_error` close / error the body stream.
|
|
537
|
+
STREAM_FRAME_FNS = {
|
|
538
|
+
'fr_start' => '__csim_swFetchStreamStart',
|
|
539
|
+
'fr_chunk' => '__csim_swFetchStreamChunk',
|
|
540
|
+
'fr_close' => '__csim_swFetchStreamClose',
|
|
541
|
+
'fr_error' => '__csim_swFetchStreamError'
|
|
542
|
+
}.freeze
|
|
543
|
+
private_constant :WORKER_POLL_INTERVAL, :WORKER_ROUND_TRIP_BUDGET, :WORKER_TERMINATE_GRACE, :WORKER_GVL_YIELD, :WORKER_QUIESCE_MAX_ROUNDS, :STREAM_FRAME_FNS
|
|
409
544
|
|
|
410
545
|
# `js_engine` picks the JS runtime: `:v8` (rusty_racer, fastest
|
|
411
546
|
# per-spec) or `:quickjs` (quickjs.rb, smaller per-VM footprint —
|
|
@@ -611,7 +746,7 @@ module Capybara
|
|
|
611
746
|
# browsing context to route into. Distinguish that (unsupported
|
|
612
747
|
# engine) from a frame that simply failed to build (below), so the
|
|
613
748
|
# error doesn't misattribute a load failure to the engine.
|
|
614
|
-
unless @runtime.
|
|
749
|
+
unless @runtime.supports_frames?
|
|
615
750
|
raise Capybara::Simulated::FrameNotSupported,
|
|
616
751
|
'within_frame needs a per-frame browsing context, which only the ' \
|
|
617
752
|
'V8 (rusty_racer) engine provides; QuickJS keeps a same-realm fallback.'
|
|
@@ -740,6 +875,9 @@ module Capybara
|
|
|
740
875
|
|
|
741
876
|
def find_with_timer_fallback(kind, arg, ctx)
|
|
742
877
|
tick_real_time if timer_wait_elapsed?
|
|
878
|
+
# After the first find of a navigation, a later find IS Capybara retrying — arm the pre-tick
|
|
879
|
+
# so subsequent polls advance the clock (a timer-driven element / removal the test awaits).
|
|
880
|
+
@pre_tick_armed = true
|
|
743
881
|
result = cached_find(kind, arg, ctx) { yield }
|
|
744
882
|
# An empty result is the wait-for-it case: Capybara is retrying for
|
|
745
883
|
# an element that hasn't appeared yet. Re-tick so the next poll
|
|
@@ -773,8 +911,20 @@ module Capybara
|
|
|
773
911
|
# this above one Ruby boundary so a single visit+find pair
|
|
774
912
|
# doesn't accidentally tick.
|
|
775
913
|
FIND_PRE_TICK_MIN_S = 0.05
|
|
914
|
+
# Whether a find should advance the clock BEFORE reading the DOM. The FIRST find after a
|
|
915
|
+
# navigation observes the current (pre-timer) DOM unconditionally — the "query the DOM before
|
|
916
|
+
# advancing pending timers" contract — so it never pre-ticks; only a LATER find (Capybara
|
|
917
|
+
# retrying because the element wasn't there yet) does, gated on the tick FREQUENCY. Anchoring
|
|
918
|
+
# the first-find exemption on a flag (not the wall clock) keeps it deterministic: a >50 ms wall
|
|
919
|
+
# gap between the navigation and the first find under full-suite load must NOT fire a parked
|
|
920
|
+
# setTimeout(0) the page just scheduled (smoke_spec "queries the current DOM …").
|
|
921
|
+
# NOTE: the same first-find contract after a USER ACTION (click/fill) is still gated only on the
|
|
922
|
+
# 50 ms wall clock — @pre_tick_armed is disarmed by reset_timer_state (navigation) alone. Actions
|
|
923
|
+
# keep the wall gate because the post-action pre-tick timing is tuned against the debounce-
|
|
924
|
+
# between-actions app cases (Avo actions_spec:464); no action-path flake has surfaced there.
|
|
776
925
|
def timer_wait_elapsed?
|
|
777
|
-
@
|
|
926
|
+
@pre_tick_armed &&
|
|
927
|
+
@timers_active &&
|
|
778
928
|
(Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_tick_ts) >= FIND_PRE_TICK_MIN_S
|
|
779
929
|
end
|
|
780
930
|
|
|
@@ -831,6 +981,30 @@ module Capybara
|
|
|
831
981
|
tag(handle) == 'input' && attr(handle, 'type').to_s.downcase == 'file'
|
|
832
982
|
end
|
|
833
983
|
def visible?(handle) = dom_call('__csimVisible', handle) ? true : false
|
|
984
|
+
# `obscured?` — coarse occlusion / hit-test in JS (layout.js). Non-visible / out-of-viewport /
|
|
985
|
+
# click-point-lands-on-another-element → obscured.
|
|
986
|
+
def obscured?(handle) = dom_call('__csimObscured', handle) ? true : false
|
|
987
|
+
# `rect` — the element's coarse border-box from the layout engine, as the full 8-field box.
|
|
988
|
+
# Two key styles on purpose: Capybara's spatial `Rectangle` reads STRING keys
|
|
989
|
+
# (`position['top']`); Discourse's `wait_for_animation` reads the SYMBOL key (`rect[:x]`).
|
|
990
|
+
def rect(handle)
|
|
991
|
+
r = dom_call('__csimRect', handle)
|
|
992
|
+
x = (r['x'] || 0).to_f
|
|
993
|
+
y = (r['y'] || 0).to_f
|
|
994
|
+
w = (r['width'] || 0).to_f
|
|
995
|
+
h = (r['height'] || 0).to_f
|
|
996
|
+
{
|
|
997
|
+
x:, y:, width: w, height: h, top: y, left: x, bottom: y + h, right: x + w,
|
|
998
|
+
'x' => x, 'y' => y, 'width' => w, 'height' => h, 'top' => y, 'left' => x, 'bottom' => y + h, 'right' => x + w
|
|
999
|
+
}
|
|
1000
|
+
end
|
|
1001
|
+
# `scroll_to` — drive a real scroll offset in the layout engine (layout.applyScrollTo). `target`
|
|
1002
|
+
# is a target element's handle (or nil); `pos` a keyword (`:top`/`:bottom`/`:center`); `x`/`y`
|
|
1003
|
+
# an explicit coordinate. Symbols are stringified for the JS side.
|
|
1004
|
+
def scroll_to(handle, target = nil, pos = nil, x = nil, y = nil)
|
|
1005
|
+
dom_call('__csimScrollTo', handle, target, pos&.to_s, x, y)
|
|
1006
|
+
nil
|
|
1007
|
+
end
|
|
834
1008
|
|
|
835
1009
|
# Capybara::Driver::Node surface — Node calls `check_stale`
|
|
836
1010
|
# before each read, and that advances the virtual clock.
|
|
@@ -1021,7 +1195,8 @@ module Capybara
|
|
|
1021
1195
|
env = Rack::MockRequest.env_for(url, method: 'GET')
|
|
1022
1196
|
env['HTTP_USER_AGENT'] = @default_user_agent || USER_AGENT
|
|
1023
1197
|
env['REMOTE_ADDR'] = self.class.remote_addr_for(env['HTTP_HOST'] || env['SERVER_NAME'])
|
|
1024
|
-
|
|
1198
|
+
ck = cookie_header_for(env_cookie_host(env))
|
|
1199
|
+
env['HTTP_COOKIE'] = ck unless ck.empty?
|
|
1025
1200
|
env['HTTP_REFERER'] = @current_url unless @current_url.nil? || @current_url.empty?
|
|
1026
1201
|
status, headers, body = @app.call(env)
|
|
1027
1202
|
return unless status.to_i == 200
|
|
@@ -1206,19 +1381,30 @@ module Capybara
|
|
|
1206
1381
|
end
|
|
1207
1382
|
|
|
1208
1383
|
# Element-to-element drag. Capybara's `Element#drag_to(target,
|
|
1209
|
-
# delay:
|
|
1210
|
-
#
|
|
1211
|
-
#
|
|
1212
|
-
#
|
|
1213
|
-
#
|
|
1214
|
-
#
|
|
1215
|
-
|
|
1384
|
+
# drop_modifiers:, html5:, delay:)` lands here; the sequencing lives
|
|
1385
|
+
# in `drag.js` (pointer-driven vs HTML5, decided from the source's
|
|
1386
|
+
# mousedown). `drop_modifiers` are held down from `dragenter` on, the
|
|
1387
|
+
# way a user pressing a key mid-drag produces. `delay` is a
|
|
1388
|
+
# real-browser pacing knob — our dispatch is synchronous, so the page
|
|
1389
|
+
# sees each step in order without it. Discourse sidebar reorder + Avo
|
|
1390
|
+
# Sortable-shaped widgets read `event.offsetY` to decide "above vs
|
|
1391
|
+
# below"; we report 0, which routes drops above the target.
|
|
1392
|
+
def drag_to(source_handle, target_handle, html5: nil, drop_modifiers: [], **_opts)
|
|
1216
1393
|
mark_action_baseline
|
|
1217
1394
|
tick_real_time
|
|
1218
1395
|
invalidate_find_cache
|
|
1219
1396
|
ensure_alive_after_tick(source_handle)
|
|
1220
1397
|
ensure_alive_after_tick(target_handle)
|
|
1221
|
-
|
|
1398
|
+
# Staged with a settle between each step: a real drag spans several frames, and libraries
|
|
1399
|
+
# use that gap (SortableJS applies its ghost class from a `setTimeout` scheduled in
|
|
1400
|
+
# `dragstart` and never reaches its reorder logic if `dragover` lands in the same turn).
|
|
1401
|
+
# This is what the reference driver's `delay:` between steps buys.
|
|
1402
|
+
dom_call('__csimDragBegin', source_handle, target_handle,
|
|
1403
|
+
{'html5' => html5, 'modifiers' => modifier_flags(drop_modifiers)})
|
|
1404
|
+
settle
|
|
1405
|
+
dom_call('__csimDragMove')
|
|
1406
|
+
settle
|
|
1407
|
+
dom_call('__csimDragFinish')
|
|
1222
1408
|
drain_after_user_action
|
|
1223
1409
|
end
|
|
1224
1410
|
def drop_items(arg)
|
|
@@ -1261,7 +1447,8 @@ module Capybara
|
|
|
1261
1447
|
alt: 'altKey',
|
|
1262
1448
|
option: 'altKey',
|
|
1263
1449
|
meta: 'metaKey',
|
|
1264
|
-
command: 'metaKey'
|
|
1450
|
+
command: 'metaKey',
|
|
1451
|
+
cmd: 'metaKey'
|
|
1265
1452
|
}.freeze
|
|
1266
1453
|
MODIFIER_KEY_NAMES = MODIFIER_KEYS.keys.to_set.freeze
|
|
1267
1454
|
def modifier_flags(keys)
|
|
@@ -1271,19 +1458,26 @@ module Capybara
|
|
|
1271
1458
|
}
|
|
1272
1459
|
end
|
|
1273
1460
|
|
|
1274
|
-
# Resolve click
|
|
1275
|
-
# `
|
|
1276
|
-
#
|
|
1277
|
-
#
|
|
1278
|
-
#
|
|
1279
|
-
#
|
|
1280
|
-
#
|
|
1461
|
+
# Resolve the click point against the element's laid-out box — the
|
|
1462
|
+
# same geometry `rect` / `obscured?` / `drag_to` and the page's own
|
|
1463
|
+
# `getBoundingClientRect` read, so a click lands where the page
|
|
1464
|
+
# believes the element is. `opts[:offset] == :center` means x/y are
|
|
1465
|
+
# relative to the element's centre (Capybara's w3c_click_offset
|
|
1466
|
+
# semantics); otherwise they're relative to its top-left, so the
|
|
1467
|
+
# point is the element's own origin plus the offset — including the
|
|
1468
|
+
# 8px body margin a real page has (confirmed in Chrome).
|
|
1469
|
+
#
|
|
1470
|
+
# This is exactly what the unified geometry buys: Capybara's own
|
|
1471
|
+
# click-offset fixture logs `event.clientX - this.getBoundingClientRect()
|
|
1472
|
+
# .left`, which only comes back as the requested offset when the
|
|
1473
|
+
# pointer we synthesize and the rect the page measures are the same
|
|
1474
|
+
# geometry. Two sources disagree by the element's position.
|
|
1281
1475
|
def click_event_init(handle, keys, opts)
|
|
1282
1476
|
out = modifier_flags(keys)
|
|
1283
1477
|
has_xy = opts[:x] || opts[:y]
|
|
1284
1478
|
center = opts[:offset] == :center || !has_xy
|
|
1285
1479
|
if has_xy || center
|
|
1286
|
-
rect = dom_call('
|
|
1480
|
+
rect = dom_call('__csimRect', handle)
|
|
1287
1481
|
base_x = rect['x'].to_f + (center ? rect['width'].to_f / 2.0 : 0.0)
|
|
1288
1482
|
base_y = rect['y'].to_f + (center ? rect['height'].to_f / 2.0 : 0.0)
|
|
1289
1483
|
out['clientX'] = base_x + opts[:x].to_f
|
|
@@ -1472,6 +1666,7 @@ module Capybara
|
|
|
1472
1666
|
def settle
|
|
1473
1667
|
start_gen = @runtime.settle_gen
|
|
1474
1668
|
prev_gen = start_gen
|
|
1669
|
+
worker_wait_deadline = nil
|
|
1475
1670
|
SETTLE_MAX_ITER.times do
|
|
1476
1671
|
deliver_event_source_events
|
|
1477
1672
|
deliver_worker_messages
|
|
@@ -1494,6 +1689,28 @@ module Capybara
|
|
|
1494
1689
|
deliver_window_messages
|
|
1495
1690
|
deliver_websocket_events
|
|
1496
1691
|
break if @runtime.settle_gen > start_gen
|
|
1692
|
+
# A background worker thread owes us a CONTRACTUAL reply (a swack / bcack /
|
|
1693
|
+
# fetch_response is posted under `ensure`, so it always comes) but hasn't posted it
|
|
1694
|
+
# yet. With no timer, `run_loop_step(0)` returns instantly, so busy-spinning the
|
|
1695
|
+
# remaining iterations would STARVE the worker thread of the GVL and it would never
|
|
1696
|
+
# process its inbox. Block briefly on the outbox instead: this releases the GVL (the
|
|
1697
|
+
# worker runs) and wakes the instant it posts. The popped event is parked in
|
|
1698
|
+
# (via `park_worker_reply`, which parks it in `@worker_outbox_head` — NOT pushed back,
|
|
1699
|
+
# which would reorder it behind anything the worker enqueued in the meantime — so the next
|
|
1700
|
+
# deliver drains it first). The budget is shared across the whole settle call, and
|
|
1701
|
+
# exhausting it bails to Capybara's outer poll loop — a genuinely stuck worker must not pin
|
|
1702
|
+
# every find's settle for SETTLE_MAX_ITER budgets. Gated on `worker_reply_pending?`, NOT
|
|
1703
|
+
# `worker_pending?`: `@worker_in_flight` (plain postMessage — a listen-only worker never
|
|
1704
|
+
# replies) and `@worker_initializing` have no matching reply, and blocking on them would
|
|
1705
|
+
# tax every settle on such pages with the full budget.
|
|
1706
|
+
if worker_reply_pending? && @worker_outbox.empty? && @worker_outbox_head.nil?
|
|
1707
|
+
worker_wait_deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + WORKER_ROUND_TRIP_BUDGET
|
|
1708
|
+
park_worker_reply(worker_wait_deadline) while worker_reply_pending? &&
|
|
1709
|
+
@worker_outbox.empty? && @worker_outbox_head.nil? &&
|
|
1710
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) < worker_wait_deadline
|
|
1711
|
+
next if @worker_outbox_head || !@worker_outbox.empty?
|
|
1712
|
+
break
|
|
1713
|
+
end
|
|
1497
1714
|
# No progress this iter (no DOM/URL change observed) — the
|
|
1498
1715
|
# remaining timers are queued for the future; bail and let
|
|
1499
1716
|
# Capybara's wall-clock-driven poll loop drive the next tick
|
|
@@ -2204,6 +2421,17 @@ module Capybara
|
|
|
2204
2421
|
# caller's concern — read it separately with `peek_script` (clock-free).
|
|
2205
2422
|
def run_event_loop_frame(frame_ms)
|
|
2206
2423
|
turns = 0
|
|
2424
|
+
# Give any live worker/SW thread a clean GVL slice before the phase-1 quiescence loop
|
|
2425
|
+
# monopolises it. That loop spins `run_loop_step(0)` holding the GVL, which STARVES a
|
|
2426
|
+
# worker mid-flight — in particular a CROSS-ISOLATE zero-copy transfer (`RustyRacer.
|
|
2427
|
+
# transferIn` over a SendBackingStore) that a `worker.postMessage(view, [view.buffer])`
|
|
2428
|
+
# reply must complete on the worker thread. Under starvation transferIn fails, the SW's
|
|
2429
|
+
# message handler throws on the null result, and no reply is posted → the client's
|
|
2430
|
+
# `onmessage` never fires and the drain force-timeouts it (postmessage.https transferable
|
|
2431
|
+
# subtests; the whole SW→client message reply cluster). A brief `sleep` releases the GVL
|
|
2432
|
+
# so the worker runs (Thread.pass does NOT hand it over); gated on a live worker so
|
|
2433
|
+
# worker-free files pay nothing.
|
|
2434
|
+
sleep(WORKER_GVL_YIELD) if @workers.any? {|_, w| w[:thread]&.alive? }
|
|
2207
2435
|
loop do
|
|
2208
2436
|
r = @runtime.run_loop_step(0) # run only what's due NOW + microtasks + render; no clock advance
|
|
2209
2437
|
progressed = step_and_drain_progressed(r)
|
|
@@ -2211,6 +2439,43 @@ module Capybara
|
|
|
2211
2439
|
break unless progressed
|
|
2212
2440
|
break if turns >= EVENT_LOOP_QUIESCENCE_CAP
|
|
2213
2441
|
end
|
|
2442
|
+
|
|
2443
|
+
# Interlude — hold the virtual clock while a controlled-client fetch is awaiting the
|
|
2444
|
+
# service worker's `respondWith`. The SW does the real request off-thread (a live network
|
|
2445
|
+
# hop, an in-VM handler) and its reply is delivered Ruby-side, invisible to the JS event-loop
|
|
2446
|
+
# probe. Advancing the clock now (phase 2) would let a caller's virtual-timeout outrun that
|
|
2447
|
+
# off-thread work and mark the still-pending fetch as timed-out before the reply lands. So
|
|
2448
|
+
# block briefly on the worker outbox — releasing the GVL so the worker runs, exactly like
|
|
2449
|
+
# `settle` — and deliver the reply at the current instant, WITHOUT advancing the clock, then
|
|
2450
|
+
# keep pumping. A fetch that never replies is bounded by `@sw_fetch_wait_deadline` (and, past
|
|
2451
|
+
# that, the caller's own max-steps backstop), so it can't wedge the drain.
|
|
2452
|
+
if @sw_fetch_pending.positive? && (held = hold_for_sw_fetch(turns))
|
|
2453
|
+
return held
|
|
2454
|
+
end
|
|
2455
|
+
@sw_fetch_wait_deadline = nil unless @sw_fetch_pending.positive?
|
|
2456
|
+
|
|
2457
|
+
# Same interlude for an in-flight `ws.close()` handshake: the reader thread surfaces the
|
|
2458
|
+
# server's echoed close frame as `__close` — real off-thread work the JS event-loop probe
|
|
2459
|
+
# can't see. Advancing the clock now would let a test's virtual-timeout ("onclose should
|
|
2460
|
+
# fire") outrun it. Block briefly on the WS queue (GVL released, like settle) so the reader
|
|
2461
|
+
# runs, deliver at the current instant WITHOUT advancing time, then keep pumping. Bounded by
|
|
2462
|
+
# a deadline so a peer that never replies (→ EOF 1006) can't wedge the drain.
|
|
2463
|
+
if @ws_close_pending.positive? && (held = hold_for_ws_close(turns))
|
|
2464
|
+
return held
|
|
2465
|
+
end
|
|
2466
|
+
@ws_close_wait_deadline = nil unless @ws_close_pending.positive?
|
|
2467
|
+
|
|
2468
|
+
# Same interlude for a pending SW message swack / broadcast ack — but WITHOUT holding
|
|
2469
|
+
# the clock. Its reply is the same kind of cross-isolate transfer completed on the worker
|
|
2470
|
+
# thread (a `worker.postMessage(view, [view.buffer])` reply zero-copies via transferIn),
|
|
2471
|
+
# so we block briefly on the outbox to give a loaded runner's worker real GVL time to post
|
|
2472
|
+
# it, then fall through to phase 2 — a message reply is delivered at the current instant by
|
|
2473
|
+
# the drain below and the client-side lifecycle / nav timers its test then waits on advance
|
|
2474
|
+
# via the clock, so (unlike a fetch) we must NOT hold: holding regressed about-blank-
|
|
2475
|
+
# replacement. See drain_pending_message_reply.
|
|
2476
|
+
drain_pending_message_reply if worker_message_reply_pending?
|
|
2477
|
+
@sw_msg_wait_deadline = nil unless worker_message_reply_pending?
|
|
2478
|
+
|
|
2214
2479
|
# Phase 2 — advance one real frame so the next batch of timers becomes due.
|
|
2215
2480
|
# Its work counts toward `progressed` too: a timer that first comes due in
|
|
2216
2481
|
# this advance (e.g. a `setTimeout(…, 8)` firing mid-frame) and the nav hop
|
|
@@ -2219,7 +2484,9 @@ module Capybara
|
|
|
2219
2484
|
probe = dom_call('__csimEventLoopProbe')
|
|
2220
2485
|
{
|
|
2221
2486
|
'raf' => !!probe['raf'],
|
|
2222
|
-
|
|
2487
|
+
# A live WS reader counts as async so the drain loop yields the GVL to it each frame — see
|
|
2488
|
+
# websocket_reader_active?. Without it a binary echo can be starved past the idle-bail.
|
|
2489
|
+
'async' => !!probe['async'] || websocket_reader_active?,
|
|
2223
2490
|
# ms until the nearest scheduled timer (-1 = none). Lets a caller keep
|
|
2224
2491
|
# advancing while a near-future `setTimeout` is parked (a `step_timeout`-
|
|
2225
2492
|
# style wait) instead of declaring the page idle — see `__csimEventLoopProbe`.
|
|
@@ -2230,6 +2497,96 @@ module Capybara
|
|
|
2230
2497
|
}
|
|
2231
2498
|
end
|
|
2232
2499
|
|
|
2500
|
+
# Hold the virtual clock for one frame while a controlled-client fetch awaits the SW's
|
|
2501
|
+
# respondWith. Blocks briefly on the worker outbox (GVL released) up to a budget shared across
|
|
2502
|
+
# frames; on a reply, delivers it at the current instant (a zero-advance `run_loop_step`) and
|
|
2503
|
+
# returns the frame's loop-state so the caller keeps pumping without advancing time. Returns
|
|
2504
|
+
# nil once the budget is spent, so the caller falls through to a normal frame (advancing the
|
|
2505
|
+
# clock) and a genuinely stuck fetch can't wedge the drain. Reports the REAL nearest timer —
|
|
2506
|
+
# holding the clock doesn't hide a parked timer, we simply haven't advanced to it yet.
|
|
2507
|
+
private def hold_for_sw_fetch(turns)
|
|
2508
|
+
@sw_fetch_wait_deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + WORKER_ROUND_TRIP_BUDGET
|
|
2509
|
+
park_worker_reply(@sw_fetch_wait_deadline)
|
|
2510
|
+
reply_ready = @worker_outbox_head || !@worker_outbox.empty?
|
|
2511
|
+
# A delivered reply refreshes the budget so the NEXT fetch in a sequence waits afresh instead
|
|
2512
|
+
# of inheriting a spent deadline (which would abandon it to a premature timeout).
|
|
2513
|
+
@sw_fetch_wait_deadline = nil if reply_ready
|
|
2514
|
+
return nil unless reply_ready || Process.clock_gettime(Process::CLOCK_MONOTONIC) < @sw_fetch_wait_deadline
|
|
2515
|
+
|
|
2516
|
+
held_progressed = reply_ready && step_and_drain_progressed(@runtime.run_loop_step(0))
|
|
2517
|
+
probe = dom_call('__csimEventLoopProbe')
|
|
2518
|
+
{
|
|
2519
|
+
'raf' => !!probe['raf'],
|
|
2520
|
+
'async' => true,
|
|
2521
|
+
'next_timer' => probe['nextTimer'].to_f,
|
|
2522
|
+
'progressed' => turns > 1 || held_progressed
|
|
2523
|
+
}
|
|
2524
|
+
end
|
|
2525
|
+
|
|
2526
|
+
# Hold the virtual clock while a `ws.close()` handshake completes. Mirrors hold_for_sw_fetch,
|
|
2527
|
+
# but the reply arrives on the WS queue (the reader thread), not the worker outbox: park
|
|
2528
|
+
# briefly on that queue (GVL released, so the reader runs) and, once a frame is ready, deliver
|
|
2529
|
+
# it at the current instant (`run_loop_step(0)` → deliver_websocket_events clears the counter)
|
|
2530
|
+
# WITHOUT advancing time. Returns the frame-probe hash to hold; nil only once the deadline is
|
|
2531
|
+
# spent with nothing delivered, so the caller falls through to phase 2 and the clock resumes.
|
|
2532
|
+
private def hold_for_ws_close(turns)
|
|
2533
|
+
@ws_close_wait_deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + WORKER_ROUND_TRIP_BUDGET
|
|
2534
|
+
if @websocket_queue_head.nil? && @websocket_queue.empty? && Process.clock_gettime(Process::CLOCK_MONOTONIC) < @ws_close_wait_deadline
|
|
2535
|
+
# Pop one event to block until the reader produces something, then park it in the one-slot
|
|
2536
|
+
# HEAD buffer — NOT pushed back onto the tail, which would reorder it behind anything the
|
|
2537
|
+
# reader enqueued in the meantime. deliver_websocket_events drains the head first. Mirrors
|
|
2538
|
+
# park_worker_reply.
|
|
2539
|
+
@websocket_queue_head = pop_with_timeout(@websocket_queue, WORKER_POLL_INTERVAL)
|
|
2540
|
+
end
|
|
2541
|
+
ready = !@websocket_queue_head.nil? || !@websocket_queue.empty?
|
|
2542
|
+
# A delivered frame refreshes the budget so the NEXT close in a sequence waits afresh.
|
|
2543
|
+
@ws_close_wait_deadline = nil if ready
|
|
2544
|
+
return nil unless ready || Process.clock_gettime(Process::CLOCK_MONOTONIC) < @ws_close_wait_deadline
|
|
2545
|
+
|
|
2546
|
+
held_progressed = ready && step_and_drain_progressed(@runtime.run_loop_step(0))
|
|
2547
|
+
probe = dom_call('__csimEventLoopProbe')
|
|
2548
|
+
{
|
|
2549
|
+
'raf' => !!probe['raf'],
|
|
2550
|
+
'async' => true,
|
|
2551
|
+
'next_timer' => probe['nextTimer'].to_f,
|
|
2552
|
+
'progressed' => turns > 1 || held_progressed
|
|
2553
|
+
}
|
|
2554
|
+
end
|
|
2555
|
+
|
|
2556
|
+
# Park briefly (GVL released) for an outstanding SW message swack / broadcast ack and deliver
|
|
2557
|
+
# it at the current instant, then RETURN — the caller proceeds to phase 2 and advances the
|
|
2558
|
+
# clock. Unlike `hold_for_sw_fetch` this does NOT hold time: a message/broadcast reply's test
|
|
2559
|
+
# advances its client-side lifecycle / nav timers via the clock, so holding on it deadlocks
|
|
2560
|
+
# (regressed about-blank-replacement). The only thing missing under load is worker GVL time for
|
|
2561
|
+
# the cross-isolate transferable reply to complete — a fixed micro-sleep isn't enough margin on
|
|
2562
|
+
# a loaded runner (postmessage.https transferable subtests), so we block on the outbox exactly
|
|
2563
|
+
# like the fetch hold / `settle`. Budget shared across frames (a genuinely stuck reply pays it
|
|
2564
|
+
# once — the spent deadline stays in the past, so later frames don't re-block and the clock runs
|
|
2565
|
+
# free until the reply lands or the test times out); reset in `run_event_loop_frame` once no
|
|
2566
|
+
# message/broadcast reply is outstanding, so the next one waits afresh.
|
|
2567
|
+
private def drain_pending_message_reply
|
|
2568
|
+
@sw_msg_wait_deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + WORKER_ROUND_TRIP_BUDGET
|
|
2569
|
+
park_worker_reply(@sw_msg_wait_deadline) while worker_message_reply_pending? &&
|
|
2570
|
+
@worker_outbox.empty? && @worker_outbox_head.nil? &&
|
|
2571
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) < @sw_msg_wait_deadline
|
|
2572
|
+
# `deliver_worker_messages` (inside step_and_drain) refreshes @sw_msg_wait_deadline whenever it
|
|
2573
|
+
# delivers a swack/bcack, so the NEXT reply in a sequence waits afresh — and it does so on
|
|
2574
|
+
# WHICHEVER drain path delivered the reply (this one, hold_for_sw_fetch's outbox drain, or
|
|
2575
|
+
# settle), which resetting only here would miss when a co-pending fetch hold delivers it.
|
|
2576
|
+
step_and_drain_progressed(@runtime.run_loop_step(0)) if @worker_outbox_head || !@worker_outbox.empty?
|
|
2577
|
+
end
|
|
2578
|
+
|
|
2579
|
+
# Block up to one poll interval for a worker reply, parking it in the one-slot head buffer —
|
|
2580
|
+
# NOT pushed back, which would reorder it behind anything the worker enqueued meanwhile. The
|
|
2581
|
+
# `pop_with_timeout` releases the GVL so the worker thread runs and wakes us the instant it
|
|
2582
|
+
# posts. No-op if a reply is already buffered or the budget is spent. Shared by `settle` and
|
|
2583
|
+
# the SW-fetch hold in `run_event_loop_frame`.
|
|
2584
|
+
private def park_worker_reply(deadline)
|
|
2585
|
+
return unless @worker_outbox.empty? && @worker_outbox_head.nil? &&
|
|
2586
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
2587
|
+
@worker_outbox_head = pop_with_timeout(@worker_outbox, WORKER_POLL_INTERVAL)
|
|
2588
|
+
end
|
|
2589
|
+
|
|
2233
2590
|
# Drain the Ruby-side async / navigation / form-submit / download chains a
|
|
2234
2591
|
# `run_loop_step` (passed as `r`) may have queued, and report whether this
|
|
2235
2592
|
# step+drain made observable progress. Shared by both phases of
|
|
@@ -2371,6 +2728,9 @@ module Capybara
|
|
|
2371
2728
|
@last_polled_gen = nil
|
|
2372
2729
|
@idle_settle_polls = 0
|
|
2373
2730
|
@ff_transient_polls = 0
|
|
2731
|
+
# Disarm the find pre-tick so the FIRST find after this navigation reads the current DOM
|
|
2732
|
+
# without advancing timers (see timer_wait_elapsed?), independent of wall-clock timing.
|
|
2733
|
+
@pre_tick_armed = false
|
|
2374
2734
|
@context_gen += 1
|
|
2375
2735
|
end
|
|
2376
2736
|
|
|
@@ -2389,7 +2749,7 @@ module Capybara
|
|
|
2389
2749
|
method = spec['method'].to_s.upcase
|
|
2390
2750
|
method = 'GET' if method.empty?
|
|
2391
2751
|
enctype = spec['enctype'].to_s.empty? ? 'application/x-www-form-urlencoded' : spec['enctype'].to_s.downcase
|
|
2392
|
-
entries = entry_list.is_a?(Array) ? entry_list :
|
|
2752
|
+
entries = entry_list.is_a?(Array) ? entry_list : (spec['entries'] || [])
|
|
2393
2753
|
action_url = action.empty? ? (current_browsing_context_url || @default_host) : resolve_against_current(action)
|
|
2394
2754
|
# A form submitted inside a frame whose target is that frame (self, or a
|
|
2395
2755
|
# `_parent` of a ≥2-deep frame) navigates the FRAME, not the top page.
|
|
@@ -2517,35 +2877,6 @@ module Capybara
|
|
|
2517
2877
|
picks && picks[entry['index'].to_i]
|
|
2518
2878
|
end
|
|
2519
2879
|
|
|
2520
|
-
# Build the entry list from the form's own controls, for triggers that didn't
|
|
2521
|
-
# construct one in JS (the Enter implicit-submit path). Mirrors the JS FormData
|
|
2522
|
-
# construction: non-file fields in tree order, then each file input's selection
|
|
2523
|
-
# (one empty entry when nothing is picked). A selected File reports its
|
|
2524
|
-
# host-backed source (`handle`/`index`); the older payload shape with no per-File
|
|
2525
|
-
# refs falls back to the input's own handle slot.
|
|
2526
|
-
def entries_from_spec(spec)
|
|
2527
|
-
entries = (spec['fields'] || []).map {|pair| {'name' => pair[0].to_s, 'value' => pair[1].to_s} }
|
|
2528
|
-
(spec['fileInputs'] || []).each do |fi|
|
|
2529
|
-
name = fi['name'].to_s
|
|
2530
|
-
refs = fi['files']
|
|
2531
|
-
if refs.is_a?(Array) && !refs.empty?
|
|
2532
|
-
refs.each {|ref|
|
|
2533
|
-
entries << {'name' => name, 'file' => true, 'filename' => ref['name'].to_s, 'handle' => ref['handle'], 'index' => ref['index']}
|
|
2534
|
-
}
|
|
2535
|
-
else
|
|
2536
|
-
picks = (@file_picks && @file_picks[fi['handle'].to_i]) || []
|
|
2537
|
-
if picks.empty?
|
|
2538
|
-
entries << {'name' => name, 'file' => true, 'filename' => '', 'handle' => nil, 'index' => nil}
|
|
2539
|
-
else
|
|
2540
|
-
picks.each_index {|i|
|
|
2541
|
-
entries << {'name' => name, 'file' => true, 'filename' => File.basename(picks[i]), 'handle' => fi['handle'], 'index' => i}
|
|
2542
|
-
}
|
|
2543
|
-
end
|
|
2544
|
-
end
|
|
2545
|
-
end
|
|
2546
|
-
entries
|
|
2547
|
-
end
|
|
2548
|
-
|
|
2549
2880
|
def append_multipart_part(body, boundary, name, content, filename: nil, content_type: nil)
|
|
2550
2881
|
body << "--#{boundary}\r\n"
|
|
2551
2882
|
disposition = %[form-data; name="#{name}"]
|
|
@@ -2569,7 +2900,7 @@ module Capybara
|
|
|
2569
2900
|
env['CONTENT_LENGTH'] = body.bytesize.to_s
|
|
2570
2901
|
apply_default_request_env(env, referer: referer)
|
|
2571
2902
|
status, headers, resp_body = dispatch_rack_or_http(url, env, method: 'POST', body: body)
|
|
2572
|
-
merge_set_cookie(headers)
|
|
2903
|
+
merge_set_cookie(headers, url)
|
|
2573
2904
|
if (loc = redirect_location(status, headers))
|
|
2574
2905
|
next_url = resolve_against_current(loc)
|
|
2575
2906
|
resp_body.close if resp_body.respond_to?(:close)
|
|
@@ -2598,7 +2929,9 @@ module Capybara
|
|
|
2598
2929
|
|
|
2599
2930
|
def reset!
|
|
2600
2931
|
@cookies.clear
|
|
2932
|
+
@auth_cache.clear
|
|
2601
2933
|
@local_storage.clear
|
|
2934
|
+
@cache_storage.clear
|
|
2602
2935
|
@session_storage.clear
|
|
2603
2936
|
@sticky_headers.clear
|
|
2604
2937
|
# The driver-side resize buffer has to clear too — without
|
|
@@ -2650,6 +2983,11 @@ module Capybara
|
|
|
2650
2983
|
reset_websockets
|
|
2651
2984
|
@window_inbox.clear
|
|
2652
2985
|
@broadcast_inbox.clear
|
|
2986
|
+
# The BroadcastChannel registry + ordered queue are per-page (the rebuilt VM has no live channels
|
|
2987
|
+
# and restarts the realm/local id counters); a stale entry would misroute a later post.
|
|
2988
|
+
@bc_registry.clear
|
|
2989
|
+
@bc_queue.clear
|
|
2990
|
+
@bc_seq = 0
|
|
2653
2991
|
# Free any zero-copy transfer backing stores that went unimported
|
|
2654
2992
|
# (worker killed before draining its inbox, etc.) before the rebuild.
|
|
2655
2993
|
drop_pending_transfers
|
|
@@ -2683,6 +3021,8 @@ module Capybara
|
|
|
2683
3021
|
reset_websockets
|
|
2684
3022
|
@window_inbox.clear
|
|
2685
3023
|
@broadcast_inbox.clear
|
|
3024
|
+
@bc_registry.clear
|
|
3025
|
+
@bc_queue.clear
|
|
2686
3026
|
# Dispose the JS runtime/isolate itself — for an auxiliary window this
|
|
2687
3027
|
# Browser is the isolate's last owner, but V8Runtime registers every
|
|
2688
3028
|
# isolate in a process-wide `@@live` set (for at_exit cleanup), which
|
|
@@ -2734,6 +3074,27 @@ module Capybara
|
|
|
2734
3074
|
@@asset_src_lock = Mutex.new
|
|
2735
3075
|
ASSET_SRC_MAX = 4096
|
|
2736
3076
|
|
|
3077
|
+
# Decoded-image cache: resolved-URL => {'width'=>, 'height'=>, 'bytes'=> packed
|
|
3078
|
+
# RGBA String}. Decoding an image (libvips) is the expensive step, so — like the
|
|
3079
|
+
# V8 bytecode cache and the script/stylesheet source cache above — we keep the
|
|
3080
|
+
# decoded pixels and reuse them for every `<img>` sharing a src, across elements
|
|
3081
|
+
# AND visits. Same content-stability assumption as `@@asset_src` (a URL's bytes
|
|
3082
|
+
# are stable within a process; content-hashed / data: URLs that dominate satisfy
|
|
3083
|
+
# it); size-capped so an app cycling through many distinct images can't grow it
|
|
3084
|
+
# without bound.
|
|
3085
|
+
@@image_cache = {}
|
|
3086
|
+
@@image_cache_lock = Mutex.new
|
|
3087
|
+
IMAGE_CACHE_MAX = 512
|
|
3088
|
+
|
|
3089
|
+
# Cross-visit cache of @font-face font files, resolved-url → on-disk path (or nil
|
|
3090
|
+
# when the fetch failed). The bytes are written to a process-lifetime temp file so
|
|
3091
|
+
# pango/fontconfig (via `Vips::Image.text fontfile:`) can read them by path. Font
|
|
3092
|
+
# URLs are content-stable app assets, so caching across the per-visit VM rebuild
|
|
3093
|
+
# avoids re-fetching CanvasTest.ttf & friends on every visit.
|
|
3094
|
+
@@font_file_cache = {}
|
|
3095
|
+
@@font_file_lock = Mutex.new
|
|
3096
|
+
@@font_files = [] # pins the Tempfiles for the PROCESS (the cache is cross-visit)
|
|
3097
|
+
|
|
2737
3098
|
# Body of an external durably-cacheable asset (classic script or stylesheet),
|
|
2738
3099
|
# served from the cross-visit cache when still fresh, else fetched (which
|
|
2739
3100
|
# read-throughs the per-visit asset cache) and cached iff durably cacheable.
|
|
@@ -2908,12 +3269,10 @@ module Capybara
|
|
|
2908
3269
|
'Cache-Control: no-store',
|
|
2909
3270
|
'Connection: keep-alive'
|
|
2910
3271
|
]
|
|
2911
|
-
# Forward the host
|
|
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
|
|
3272
|
+
# Forward the streaming host's cookie jar so the server can
|
|
3273
|
+
# authenticate the user the same way the browser would — scoped to
|
|
3274
|
+
# the EventSource target's host, like every other request.
|
|
3275
|
+
cookies = cookie_header_for(cookie_host(uri))
|
|
2917
3276
|
lines << "Cookie: #{cookies}" unless cookies.empty?
|
|
2918
3277
|
socket.write(lines.join("\r\n") << "\r\n\r\n")
|
|
2919
3278
|
socket.flush
|
|
@@ -3011,6 +3370,10 @@ module Capybara
|
|
|
3011
3370
|
env['HTTP_CONNECTION'] = 'Upgrade'
|
|
3012
3371
|
env['HTTP_SEC_WEBSOCKET_KEY'] = key
|
|
3013
3372
|
env['HTTP_SEC_WEBSOCKET_VERSION'] = '13'
|
|
3373
|
+
# The opening handshake always carries the initiating document's origin (the UA owns this
|
|
3374
|
+
# header) — server handlers echo it back (websockets/opening-handshake origin test).
|
|
3375
|
+
doc_origin = url_origin(@current_url)
|
|
3376
|
+
env['HTTP_ORIGIN'] = doc_origin if doc_origin
|
|
3014
3377
|
list = Array(protocols).map(&:to_s).reject(&:empty?)
|
|
3015
3378
|
env['HTTP_SEC_WEBSOCKET_PROTOCOL'] = list.join(', ') unless list.empty?
|
|
3016
3379
|
env['rack.hijack?'] = true
|
|
@@ -3030,7 +3393,7 @@ module Capybara
|
|
|
3030
3393
|
queue = @websocket_queue
|
|
3031
3394
|
@websocket_threads[id] = Thread.new do
|
|
3032
3395
|
Thread.current.report_on_exception = false
|
|
3033
|
-
run_websocket_reader(id, csim_io, accept, queue)
|
|
3396
|
+
run_websocket_reader(id, csim_io, accept, queue, target)
|
|
3034
3397
|
end
|
|
3035
3398
|
id
|
|
3036
3399
|
rescue StandardError => e
|
|
@@ -3059,24 +3422,58 @@ module Capybara
|
|
|
3059
3422
|
nil
|
|
3060
3423
|
end
|
|
3061
3424
|
|
|
3062
|
-
def ws_close(id, code =
|
|
3425
|
+
def ws_close(id, code = nil, reason = '')
|
|
3063
3426
|
sock = @websocket_sockets[id.to_i] or return
|
|
3064
3427
|
# Send the close frame and let the close HANDSHAKE complete: the server
|
|
3065
3428
|
# replies with its own close frame, which the reader thread surfaces as
|
|
3066
3429
|
# the `__close` event (carrying the agreed code) before tearing the
|
|
3067
3430
|
# socket down in its `ensure`. Force teardown is `reset_websockets`'s job.
|
|
3068
|
-
|
|
3431
|
+
# A nil code sends a BODYLESS close frame — `ws.close()` with no argument
|
|
3432
|
+
# closes with no status, which the peer echoes and the reader reports as
|
|
3433
|
+
# code 1005 (NO_STATUS), not 1000.
|
|
3434
|
+
payload = code.nil? ? ''.b : [code.to_i].pack('n') + reason.to_s.b
|
|
3069
3435
|
ws_write_frame(sock, 0x8, payload) rescue nil
|
|
3436
|
+
# A close handshake is now in flight — hold the clock until the reader's `__close` lands
|
|
3437
|
+
# (bounded by the deadline in hold_for_ws_close if the peer never replies → EOF 1006).
|
|
3438
|
+
@ws_close_pending += 1
|
|
3070
3439
|
nil
|
|
3071
3440
|
end
|
|
3072
3441
|
|
|
3073
|
-
|
|
3442
|
+
# Pending WS work = queued reader events OR an in-flight `ws.close()` whose `__close` hasn't
|
|
3443
|
+
# landed yet. Counting the latter keeps every advance path (settle, tick_real_time, and
|
|
3444
|
+
# crucially horizon_fast_forward_step, which would otherwise jump straight to a test's pending
|
|
3445
|
+
# timeout timer) from racing past the close handshake before hold_for_ws_close delivers it.
|
|
3446
|
+
def websocket_pending? = !@websocket_queue_head.nil? || !@websocket_queue.empty? || @ws_close_pending.positive?
|
|
3447
|
+
|
|
3448
|
+
# A live reader thread = an open WS connection whose server may still surface an echo / push /
|
|
3449
|
+
# close frame off-thread — pending async work the JS event-loop probe can't see. The event-loop
|
|
3450
|
+
# frame reports it as `async` so the WPT drain loop yields the GVL each frame (its
|
|
3451
|
+
# `sleep(0.001) if async`), feeding the reader instead of spinning it into starvation — a
|
|
3452
|
+
# binary echo that only lands AFTER the idle-bail is exactly the flake this prevents. Zero-alloc
|
|
3453
|
+
# on the common no-WS path (the `empty?` short-circuit); a handful of threads otherwise.
|
|
3454
|
+
def websocket_reader_active? = !@websocket_threads.empty? && @websocket_threads.each_value.any?(&:alive?)
|
|
3074
3455
|
|
|
3075
3456
|
def deliver_websocket_events
|
|
3076
|
-
return 0 if @websocket_threads.empty? && @websocket_queue.empty?
|
|
3077
|
-
|
|
3457
|
+
return 0 if @websocket_threads.empty? && @websocket_queue_head.nil? && @websocket_queue.empty?
|
|
3458
|
+
# The head slot (parked by hold_for_ws_close) is delivered ahead of the queue to preserve
|
|
3459
|
+
# reader order.
|
|
3460
|
+
events = [@websocket_queue_head].compact
|
|
3461
|
+
@websocket_queue_head = nil
|
|
3462
|
+
events.concat(drain_queue(@websocket_queue))
|
|
3078
3463
|
return 0 if events.empty?
|
|
3079
|
-
|
|
3464
|
+
# `__setcookie` is handled Ruby-side (store the handshake cookie in the jar) and NOT
|
|
3465
|
+
# forwarded to JS; the reader queues it before `__open`, so document.cookie sees it in onopen.
|
|
3466
|
+
js_events = events.reject do |e|
|
|
3467
|
+
if e[:type] == '__setcookie'
|
|
3468
|
+
merge_set_cookie({'set-cookie' => e[:cookies]}, e[:url])
|
|
3469
|
+
true
|
|
3470
|
+
end
|
|
3471
|
+
end
|
|
3472
|
+
@runtime.call('__csim_deliverWebSocketEvents', js_events) unless js_events.empty?
|
|
3473
|
+
# A terminal event (`__close` / `__error`) completes a close handshake — release the clock
|
|
3474
|
+
# hold. Clamp: a server-INITIATED close arrives without a matching ws_close increment.
|
|
3475
|
+
terminals = events.count {|e| e[:type] == '__close' || e[:type] == '__error' }
|
|
3476
|
+
@ws_close_pending = [@ws_close_pending - terminals, 0].max if terminals.positive?
|
|
3080
3477
|
events.size
|
|
3081
3478
|
end
|
|
3082
3479
|
|
|
@@ -3091,23 +3488,36 @@ module Capybara
|
|
|
3091
3488
|
@websocket_sockets.clear
|
|
3092
3489
|
@websocket_app_sockets.clear
|
|
3093
3490
|
@websocket_queue.clear
|
|
3491
|
+
@websocket_queue_head = nil
|
|
3492
|
+
@ws_close_pending = 0
|
|
3493
|
+
@ws_close_wait_deadline = nil
|
|
3094
3494
|
end
|
|
3095
3495
|
|
|
3096
3496
|
# Background-thread frame reader: verify the 101 handshake, then loop
|
|
3097
3497
|
# decoding server→client frames into queue events until close / EOF.
|
|
3098
|
-
private def run_websocket_reader(id, sock, expected_accept, queue)
|
|
3099
|
-
ok, protocol = ws_read_handshake(sock, expected_accept)
|
|
3498
|
+
private def run_websocket_reader(id, sock, expected_accept, queue, target)
|
|
3499
|
+
ok, protocol, cookies = ws_read_handshake(sock, expected_accept)
|
|
3100
3500
|
unless ok
|
|
3101
3501
|
queue << {id: id, type: '__error', message: 'websocket handshake failed'}
|
|
3102
3502
|
return
|
|
3103
3503
|
end
|
|
3504
|
+
# Store any handshake-response cookies (the main thread does the store) BEFORE `open` fires,
|
|
3505
|
+
# so a document.cookie read in `onopen` sees them.
|
|
3506
|
+
queue << {id: id, type: '__setcookie', cookies: cookies, url: target} unless cookies.empty?
|
|
3104
3507
|
# Carry the negotiated subprotocol — Action Cable's client closes the
|
|
3105
3508
|
# connection in its `onopen` unless `webSocket.protocol` is one it knows
|
|
3106
3509
|
# (`actioncable-v1-json`).
|
|
3107
3510
|
queue << {id: id, type: '__open', protocol: protocol}
|
|
3108
3511
|
loop do
|
|
3109
3512
|
frame = ws_read_message(sock, queue, id)
|
|
3110
|
-
|
|
3513
|
+
if frame.nil? # TCP closed with no close frame → abnormal
|
|
3514
|
+
queue << {id: id, type: '__close', code: 1006, reason: ''}
|
|
3515
|
+
break
|
|
3516
|
+
end
|
|
3517
|
+
if frame == :protocol_error # reserved opcode / malformed frame → fail the connection
|
|
3518
|
+
queue << {id: id, type: '__error', message: 'protocol error'}
|
|
3519
|
+
break
|
|
3520
|
+
end
|
|
3111
3521
|
opcode, payload = frame
|
|
3112
3522
|
if opcode == :close
|
|
3113
3523
|
code = payload.bytesize >= 2 ? payload[0, 2].unpack1('n') : 1005
|
|
@@ -3139,9 +3549,10 @@ module Capybara
|
|
|
3139
3549
|
# is set (Action Cable's client requires `actioncable-v1-json`).
|
|
3140
3550
|
private def ws_read_handshake(sock, expected_accept)
|
|
3141
3551
|
status = sock.gets
|
|
3142
|
-
return [false, nil] unless status && status =~ %r{\AHTTP/1\.1 101}i
|
|
3552
|
+
return [false, nil, []] unless status && status =~ %r{\AHTTP/1\.1 101}i
|
|
3143
3553
|
accept_ok = false
|
|
3144
3554
|
protocol = nil
|
|
3555
|
+
cookies = []
|
|
3145
3556
|
while (line = sock.gets)
|
|
3146
3557
|
line = line.chomp
|
|
3147
3558
|
break if line.empty?
|
|
@@ -3154,13 +3565,18 @@ module Capybara
|
|
|
3154
3565
|
# JS Uint8Array — the protocol must reach JS as a real string so
|
|
3155
3566
|
# `webSocket.protocol` compares equal to `actioncable-v1-json`.
|
|
3156
3567
|
protocol = RuntimeShared.utf8_text(val) if key == 'sec-websocket-protocol' && !val.empty?
|
|
3568
|
+
# Cookies the server sets on the handshake response are stored in the jar (the main thread
|
|
3569
|
+
# does the actual store — see deliver_websocket_events), so document.cookie / the next
|
|
3570
|
+
# request's Cookie header reflect them.
|
|
3571
|
+
cookies << RuntimeShared.utf8_text(val) if key == 'set-cookie' && !val.empty?
|
|
3157
3572
|
end
|
|
3158
|
-
[accept_ok, protocol]
|
|
3573
|
+
[accept_ok, protocol, cookies]
|
|
3159
3574
|
end
|
|
3160
3575
|
|
|
3161
3576
|
# Read one complete message (reassembling continuation frames), handling
|
|
3162
3577
|
# interleaved control frames inline. Returns `[opcode, payload]` (opcode
|
|
3163
|
-
# 0x1 text / 0x2 binary,
|
|
3578
|
+
# 0x1 text / 0x2 binary), `[:close, payload]`, `:protocol_error` (a reserved
|
|
3579
|
+
# opcode — the connection must be failed), or nil on EOF.
|
|
3164
3580
|
private def ws_read_message(sock, queue, id)
|
|
3165
3581
|
data = +''.b
|
|
3166
3582
|
msg_opcode = nil
|
|
@@ -3193,7 +3609,8 @@ module Capybara
|
|
|
3193
3609
|
when 0x9 then ws_write_frame(sock, 0xA, payload); next # ping → pong
|
|
3194
3610
|
when 0xA then next # pong → ignore
|
|
3195
3611
|
when 0x0 then data << payload # continuation
|
|
3196
|
-
|
|
3612
|
+
when 0x1, 0x2 then msg_opcode = opcode; data << payload # text / binary
|
|
3613
|
+
else return :protocol_error # reserved opcode (0x3-7, 0xB-F) → fail
|
|
3197
3614
|
end
|
|
3198
3615
|
return [msg_opcode || opcode, data] if fin
|
|
3199
3616
|
end
|
|
@@ -3425,7 +3842,7 @@ module Capybara
|
|
|
3425
3842
|
# worker's `__csim_workerPostMessage` host fn closes over its
|
|
3426
3843
|
# handle and routes outgoing messages onto a shared outbox the
|
|
3427
3844
|
# main settle drains.
|
|
3428
|
-
def worker_spawn(url, shared: false, service: false)
|
|
3845
|
+
def worker_spawn(url, shared: false, service: false, creator_key: nil)
|
|
3429
3846
|
handle = (@worker_seq += 1)
|
|
3430
3847
|
target = resolve_against_current(url.to_s)
|
|
3431
3848
|
# A worker script from a blob: URL in a DIFFERENT storage partition than this
|
|
@@ -3452,7 +3869,7 @@ module Capybara
|
|
|
3452
3869
|
@worker_init_lock.synchronize { @worker_initializing += 1 }
|
|
3453
3870
|
thread = Thread.new do
|
|
3454
3871
|
Thread.current.report_on_exception = false
|
|
3455
|
-
run_worker(handle, target, body, inbox, outbox, engine_class, shared: shared, service: service)
|
|
3872
|
+
run_worker(handle, target, body, inbox, outbox, engine_class, shared: shared, service: service, creator_key: creator_key)
|
|
3456
3873
|
end
|
|
3457
3874
|
@workers[handle] = {thread: thread, inbox: inbox}
|
|
3458
3875
|
handle
|
|
@@ -3474,6 +3891,353 @@ module Capybara
|
|
|
3474
3891
|
w[:inbox] << data.to_s
|
|
3475
3892
|
end
|
|
3476
3893
|
|
|
3894
|
+
# `ServiceWorker.postMessage` from a client window → deliver to the SW's `message` event with
|
|
3895
|
+
# `source` = the posting client. Tracked in @sw_message_pending (released by the worker's
|
|
3896
|
+
# `swack`) so settle waits for the SW to process it and any client.postMessage reply.
|
|
3897
|
+
def service_worker_post_message(handle, data, client_id = nil, client_url = nil)
|
|
3898
|
+
w = @workers[handle.to_i]
|
|
3899
|
+
return unless w
|
|
3900
|
+
@sw_message_pending += 1
|
|
3901
|
+
w[:inbox] << {kind: 'sw_message', data: data.to_s, client: client_id, url: client_url}
|
|
3902
|
+
end
|
|
3903
|
+
|
|
3904
|
+
# A controlled client's fetch → the controlling SW's `fetch` event. Tracked in
|
|
3905
|
+
# @sw_fetch_pending (released by the `fetch_response`) so settle waits for the SW's
|
|
3906
|
+
# respondWith. If the handle is dead, return false so the client falls back to the network.
|
|
3907
|
+
def service_worker_controller_fetch(handle, req_json, fetch_id, realm_id = 0)
|
|
3908
|
+
w = @workers[handle.to_i]
|
|
3909
|
+
return false unless w
|
|
3910
|
+
# Resolve BEFORE bumping the pending counter: a raise here must not strand @sw_fetch_pending
|
|
3911
|
+
# (settle would then block for the full round-trip budget with no fetch ever queued to answer).
|
|
3912
|
+
req = resolve_sw_fetch_referrer(req_json.to_s)
|
|
3913
|
+
@sw_fetch_pending += 1
|
|
3914
|
+
# fetch ids are per-realm (so they collide across realms) — carry the ORIGINATING realm so
|
|
3915
|
+
# the response is delivered back to it, not the main realm (realm 0 = main/top window).
|
|
3916
|
+
w[:inbox] << {kind: 'fetch', req:, fetch_id: fetch_id.to_i, realm_id: realm_id.to_i}
|
|
3917
|
+
true
|
|
3918
|
+
end
|
|
3919
|
+
|
|
3920
|
+
# A controlled client cancelled a streaming respondWith body (`response.body.cancel()` or an
|
|
3921
|
+
# AbortController abort): route the cancel to the worker that owns this [realm, fetch] stream so
|
|
3922
|
+
# it cancels the reader it's draining — firing the SW source stream's `cancel()`. @sw_open_streams
|
|
3923
|
+
# maps the stream to its emitting worker; the worker's terminal frame still clears the counter.
|
|
3924
|
+
def sw_stream_cancel(fetch_id, realm_id)
|
|
3925
|
+
key = [realm_id.to_i, fetch_id.to_i]
|
|
3926
|
+
handle, = @sw_open_streams.find {|_h, streams| streams.key?(key) }
|
|
3927
|
+
return unless handle && (w = @workers[handle])
|
|
3928
|
+
w[:inbox] << {kind: 'fetch_cancel', fetch_id: fetch_id.to_i}
|
|
3929
|
+
end
|
|
3930
|
+
|
|
3931
|
+
# Resolve a controlled fetch's referrer the way the network hop would (compute_referrer
|
|
3932
|
+
# applies the request's Referrer-Policy to its referrer source), so the SW's
|
|
3933
|
+
# `event.request.referrer` matches a real browser's. The client sends the referrer SOURCE
|
|
3934
|
+
# (`referrerSource`, its document URL for the `about:client` default); we replace it with the
|
|
3935
|
+
# policy-resolved value under `referrer` (nil / stripped → '', the no-referrer state). No
|
|
3936
|
+
# source (older payload / navigation request) → passed through untouched.
|
|
3937
|
+
private def resolve_sw_fetch_referrer(req_json)
|
|
3938
|
+
req = JSON.parse(req_json)
|
|
3939
|
+
return req_json unless req.is_a?(Hash) && req.key?('referrerSource')
|
|
3940
|
+
|
|
3941
|
+
req['referrer'] = compute_referrer(req['referrerPolicy'], req.delete('referrerSource'), req['url']).to_s
|
|
3942
|
+
JSON.generate(req)
|
|
3943
|
+
rescue JSON::ParserError
|
|
3944
|
+
req_json
|
|
3945
|
+
end
|
|
3946
|
+
|
|
3947
|
+
# A SW `fetch` event's respondWith result. A NAVIGATION fetch (negative id — see
|
|
3948
|
+
# service_worker_navigation_fetch) is awaited SYNCHRONOUSLY on a dedicated queue, off the
|
|
3949
|
+
# general outbox, so it never interleaves with the client-fetch / message reply protocol;
|
|
3950
|
+
# a client fetch (positive id) rides the outbox as before, tagged with the originating realm.
|
|
3951
|
+
private def sw_deliver_fetch_response(handle, fetch_id, resp, outbox, realm_id = 0)
|
|
3952
|
+
if fetch_id.negative?
|
|
3953
|
+
@sw_nav_outbox << {fetch_id: fetch_id, resp: resp}
|
|
3954
|
+
else
|
|
3955
|
+
outbox << {handle: handle, kind: 'fetch_response', fetch_id: fetch_id, resp: resp, realm_id: realm_id}
|
|
3956
|
+
end
|
|
3957
|
+
end
|
|
3958
|
+
|
|
3959
|
+
# The active worker handle at an EXACT scope (0 if none) — see __csim_swActiveHandleForScope.
|
|
3960
|
+
def sw_active_handle_for_scope(scope) = @sw_registrations[scope.to_s].to_i
|
|
3961
|
+
|
|
3962
|
+
# Mirror a registration's active-worker handle into Ruby, keyed by its (serialized) scope.
|
|
3963
|
+
# Emitted by the client lifecycle at activation; survives rebuild_ctx so a navigation can
|
|
3964
|
+
# find its controlling SW even after the destination realm's JS was rebuilt.
|
|
3965
|
+
def sw_register_scope(scope, handle)
|
|
3966
|
+
@sw_registrations[scope.to_s] = handle.to_i
|
|
3967
|
+
# Flush any clients.claim() that arrived before this scope was mirrored (a worker's
|
|
3968
|
+
# `activate → clients.claim()` fires decoupled from the client-side lifecycle that populates
|
|
3969
|
+
# @sw_registrations, so the claim can be drained first — see the claim handler above).
|
|
3970
|
+
if @sw_pending_claims.any? {|e| e[:handle].to_i == handle.to_i }
|
|
3971
|
+
flush, @sw_pending_claims = @sw_pending_claims.partition {|e| e[:handle].to_i == handle.to_i }
|
|
3972
|
+
flush.each {|e| broadcast_claim(e[:handle], e[:has_fetch], scope.to_s) }
|
|
3973
|
+
end
|
|
3974
|
+
nil
|
|
3975
|
+
end
|
|
3976
|
+
|
|
3977
|
+
# Deliver a clients.claim() to EVERY in-scope client: broadcast to the main realm AND every
|
|
3978
|
+
# frame realm; each self-checks whether its own document is in the claiming registration's
|
|
3979
|
+
# scope (__csim_swClaimClient) so no realm→URL map is needed here. has_fetch = the SW's
|
|
3980
|
+
# install-time fetch-listener snapshot, so a claimed client routes its fetches.
|
|
3981
|
+
private def broadcast_claim(handle, has_fetch, scope)
|
|
3982
|
+
script_url = @workers.dig(handle.to_i, :script_url).to_s
|
|
3983
|
+
# The authoritative registration scope set — the claim's longest-registration-wins check runs
|
|
3984
|
+
# against this, not a realm-local map that can lag under load (claim-not-using-registration).
|
|
3985
|
+
all_scopes = @sw_registrations.keys
|
|
3986
|
+
@runtime.call('__csim_swClaimClient', handle, has_fetch, script_url, scope, all_scopes)
|
|
3987
|
+
@runtime.frame_realm_ids.each do |rid|
|
|
3988
|
+
@runtime.realm_call(rid, '__csim_swClaimClient', handle, has_fetch, script_url, scope, all_scopes) if @runtime.frame_realm_alive?(rid)
|
|
3989
|
+
end
|
|
3990
|
+
nil
|
|
3991
|
+
end
|
|
3992
|
+
# Navigation Preload state for a registration's active worker (keyed by its handle). Returns the
|
|
3993
|
+
# spec default {enabled:false, headerValue:'true'} when never set. Read by the client- and
|
|
3994
|
+
# worker-side NavigationPreloadManager (getState) and at navigation time (nav_preload_enabled?).
|
|
3995
|
+
def nav_preload_state(handle)
|
|
3996
|
+
st = @sw_navpreload[handle.to_i] || {}
|
|
3997
|
+
{'enabled' => st.fetch(:enabled, false), 'headerValue' => st.fetch(:header, 'true')}
|
|
3998
|
+
end
|
|
3999
|
+
|
|
4000
|
+
# Update the state for a worker handle. A nil `enabled` / `header` leaves that field unchanged
|
|
4001
|
+
# (enable/disable set only enabled; setHeaderValue sets only the header — the JS side has already
|
|
4002
|
+
# validated the header value and String()-ified it). The InvalidStateError "no active worker"
|
|
4003
|
+
# gate lives in the JS manager (a null handle never reaches here).
|
|
4004
|
+
def nav_preload_set(handle, enabled, header)
|
|
4005
|
+
st = (@sw_navpreload[handle.to_i] ||= {})
|
|
4006
|
+
st[:enabled] = !!enabled unless enabled.nil?
|
|
4007
|
+
st[:header] = header.to_s unless header.nil?
|
|
4008
|
+
nil
|
|
4009
|
+
end
|
|
4010
|
+
|
|
4011
|
+
# Whether the registration whose active worker controls `url` has navigation preload enabled —
|
|
4012
|
+
# gates the parallel preload request during a navigation.
|
|
4013
|
+
def nav_preload_enabled?(handle)
|
|
4014
|
+
handle && @sw_navpreload.dig(handle.to_i, :enabled) ? true : false
|
|
4015
|
+
end
|
|
4016
|
+
|
|
4017
|
+
def sw_unregister_scope(scope)
|
|
4018
|
+
@sw_registrations.delete(scope.to_s)
|
|
4019
|
+
nil
|
|
4020
|
+
end
|
|
4021
|
+
|
|
4022
|
+
# The registration handle controlling `url` — the one whose serialized scope is the longest
|
|
4023
|
+
# prefix of `url` (spec "Match Service Worker Registration"; the scope embeds the origin, so
|
|
4024
|
+
# a cross-origin scope can't prefix-match). nil when no registration's scope matches.
|
|
4025
|
+
private def sw_scope_match(url)
|
|
4026
|
+
u = url.to_s
|
|
4027
|
+
best = nil
|
|
4028
|
+
best_len = -1
|
|
4029
|
+
@sw_registrations.each do |scope, handle|
|
|
4030
|
+
next unless u.start_with?(scope) && scope.length > best_len
|
|
4031
|
+
best = [handle, scope]
|
|
4032
|
+
best_len = scope.length
|
|
4033
|
+
end
|
|
4034
|
+
best
|
|
4035
|
+
end
|
|
4036
|
+
|
|
4037
|
+
# The controller for a freshly-built frame realm at `url`, for wiring its
|
|
4038
|
+
# `navigator.serviceWorker.controller`. Returns [handle, has_fetch, script_url] or nil.
|
|
4039
|
+
# Unlike the navigation variant this keeps a controller whose fetch-handler snapshot is
|
|
4040
|
+
# still UNKNOWN (nil, racing the SW's initial eval) — resolved to `true` here so the frame
|
|
4041
|
+
# is controlled and routes; a controlled subresource fetch simply falls through to the
|
|
4042
|
+
# network if no handler materializes. Only a KNOWN-false (messaging/push-only) SW skips.
|
|
4043
|
+
def sw_client_controller_for(url)
|
|
4044
|
+
match = sw_scope_match(url) or return nil
|
|
4045
|
+
handle, scope = match
|
|
4046
|
+
w = @workers[handle] or return nil
|
|
4047
|
+
return nil unless w[:thread]&.alive?
|
|
4048
|
+
|
|
4049
|
+
[handle, w[:has_fetch] != false, w[:script_url].to_s, scope]
|
|
4050
|
+
end
|
|
4051
|
+
|
|
4052
|
+
# The controller an OPAQUE child browsing context (about:blank / srcdoc)
|
|
4053
|
+
# inherits from its creator. An about:blank document has no URL to scope-match,
|
|
4054
|
+
# so it's controlled by its parent's active service worker (HTML "create and
|
|
4055
|
+
# initialize a Document" inherits the creator's controller). Keyed by the
|
|
4056
|
+
# parent frame realm's id, recorded when that realm was wired (below).
|
|
4057
|
+
def sw_inherited_controller_for(parent_realm_id)
|
|
4058
|
+
return nil if parent_realm_id.nil? || parent_realm_id.to_i.zero?
|
|
4059
|
+
ctrl = @sw_realm_controller[parent_realm_id.to_i]
|
|
4060
|
+
return nil unless ctrl && @workers[ctrl[0]]&.dig(:thread)&.alive?
|
|
4061
|
+
|
|
4062
|
+
ctrl
|
|
4063
|
+
end
|
|
4064
|
+
|
|
4065
|
+
# Remember a frame/window realm's controller so its OWN opaque children can
|
|
4066
|
+
# inherit it (sw_inherited_controller_for). Set at frame-realm build for both
|
|
4067
|
+
# a scope-matched and an inherited controller, so inheritance chains through
|
|
4068
|
+
# nested about:blank frames.
|
|
4069
|
+
def sw_note_realm_controller(realm_id, ctrl)
|
|
4070
|
+
@sw_realm_controller[realm_id.to_i] = ctrl
|
|
4071
|
+
nil
|
|
4072
|
+
end
|
|
4073
|
+
|
|
4074
|
+
# Register a controlled client (a frame/window realm) with its controlling SW
|
|
4075
|
+
# so `clients.matchAll()` / `getClientByURL` reflect the real client set — not
|
|
4076
|
+
# only clients that happened to postMessage the worker. The client id is
|
|
4077
|
+
# realm-scoped (`client-<realm>`), stable for the realm's life. Pushed to the
|
|
4078
|
+
# SW inbox (processed FIFO, so it precedes any later message that matchAll's it).
|
|
4079
|
+
def sw_register_client(realm_id, url, type, frame_type, handle)
|
|
4080
|
+
w = @workers[handle.to_i] or return
|
|
4081
|
+
rec = {'id' => "client-#{realm_id.to_i}", 'url' => url.to_s, 'type' => type.to_s, 'frameType' => frame_type.to_s}
|
|
4082
|
+
@sw_clients[realm_id.to_i] = {handle: handle.to_i, rec: rec}
|
|
4083
|
+
w[:inbox] << {kind: 'client_register', client: rec}
|
|
4084
|
+
nil
|
|
4085
|
+
end
|
|
4086
|
+
|
|
4087
|
+
# Drop a client whose realm was disposed (frame navigated away / removed) so
|
|
4088
|
+
# matchAll stops returning a dead client. No-op for an unregistered realm.
|
|
4089
|
+
def sw_unregister_client(realm_id)
|
|
4090
|
+
@sw_realm_controller.delete(realm_id.to_i)
|
|
4091
|
+
entry = @sw_clients.delete(realm_id.to_i) or return
|
|
4092
|
+
w = @workers[entry[:handle]] or return
|
|
4093
|
+
w[:inbox] << {kind: 'client_unregister', id: entry[:rec]['id']}
|
|
4094
|
+
nil
|
|
4095
|
+
end
|
|
4096
|
+
|
|
4097
|
+
# ── Cross-isolate MessagePort channel relay (client realm ↔ worker/SW isolate) ──
|
|
4098
|
+
# Each endpoint self-registers when it (de)serializes the transferred port.
|
|
4099
|
+
def port_channel_endpoint_realm(channel, realm_id)
|
|
4100
|
+
ch = (@port_channels[channel.to_s] ||= {})
|
|
4101
|
+
ch[:realm] = realm_id.to_i
|
|
4102
|
+
# Flush anything the worker posted before this endpoint was known (deliver_worker_messages).
|
|
4103
|
+
if (pending = ch.delete(:pending_realm))
|
|
4104
|
+
pending.each {|d| deliver_port_to_realm(realm_id.to_i, channel.to_s, d) }
|
|
4105
|
+
end
|
|
4106
|
+
nil
|
|
4107
|
+
end
|
|
4108
|
+
# Deliver a channel message into a client realm's endpoint port (realm 0 = the main realm).
|
|
4109
|
+
private def deliver_port_to_realm(rid, channel, data)
|
|
4110
|
+
if rid.zero?
|
|
4111
|
+
@runtime.call('__csimPortChannelDeliver', channel, data)
|
|
4112
|
+
elsif @runtime.frame_realm_alive?(rid)
|
|
4113
|
+
@runtime.realm_call(rid, '__csimPortChannelDeliver', channel, data)
|
|
4114
|
+
end
|
|
4115
|
+
end
|
|
4116
|
+
def port_channel_endpoint_sw(channel, handle)
|
|
4117
|
+
ch = (@port_channels[channel.to_s] ||= {})
|
|
4118
|
+
ch[:sw] = handle.to_i
|
|
4119
|
+
# Flush anything the client posted before this endpoint was known (see client_port_post).
|
|
4120
|
+
if (pending = ch.delete(:pending_sw)) && (w = @workers[handle.to_i])
|
|
4121
|
+
pending.each {|d| @sw_message_pending += 1; w[:inbox] << {kind: 'port_msg', channel: channel.to_s, data: d} }
|
|
4122
|
+
end
|
|
4123
|
+
nil
|
|
4124
|
+
end
|
|
4125
|
+
# A client-realm port posts to its remote (worker/SW) peer: relay to the isolate's inbox.
|
|
4126
|
+
# Counted like an sw_message so settle waits for the worker to process it (and any reply it
|
|
4127
|
+
# posts straight back on the same or another channel). A message posted BEFORE the peer endpoint
|
|
4128
|
+
# is registered (a port used right after transfer, before the worker decoded it — the Comlink
|
|
4129
|
+
# handshake) is BUFFERED on the channel and flushed by port_channel_endpoint_sw, per HTML's port
|
|
4130
|
+
# message queue, rather than dropped.
|
|
4131
|
+
def client_port_post(channel, data)
|
|
4132
|
+
ch = (@port_channels[channel.to_s] ||= {})
|
|
4133
|
+
handle = ch[:sw]
|
|
4134
|
+
if handle && (w = @workers[handle])
|
|
4135
|
+
@sw_message_pending += 1
|
|
4136
|
+
w[:inbox] << {kind: 'port_msg', channel: channel.to_s, data: data.to_s}
|
|
4137
|
+
else
|
|
4138
|
+
(ch[:pending_sw] ||= []) << data.to_s
|
|
4139
|
+
end
|
|
4140
|
+
nil
|
|
4141
|
+
end
|
|
4142
|
+
|
|
4143
|
+
# The active fetch-handling worker controlling a navigation to `url`. nil when uncontrolled
|
|
4144
|
+
# or the SW is known to have no fetch listener (→ load from the network).
|
|
4145
|
+
private def sw_controller_for_navigation(url)
|
|
4146
|
+
match = sw_scope_match(url) or return nil
|
|
4147
|
+
handle = match[0]
|
|
4148
|
+
w = @workers[handle] or return nil
|
|
4149
|
+
# has_fetch is published from the worker thread AFTER its initial eval, so a navigation
|
|
4150
|
+
# into a freshly-activated registration can read it as nil (unknown) — race-prone for the
|
|
4151
|
+
# 2nd+ registration, whose initial load fires before the publish. Treat unknown as "maybe":
|
|
4152
|
+
# route through and let the fetch dispatch fall through if the SW turns out to have no
|
|
4153
|
+
# handler. Skip only a KNOWN-false (messaging/push-only) SW, or one whose thread is already
|
|
4154
|
+
# DEAD (crashed during eval / closed) — routing there would just stall the nav for the full
|
|
4155
|
+
# round-trip budget with no one to answer.
|
|
4156
|
+
return nil if w[:has_fetch] == false || !w[:thread]&.alive?
|
|
4157
|
+
|
|
4158
|
+
handle
|
|
4159
|
+
end
|
|
4160
|
+
|
|
4161
|
+
# Route a navigation request (document / iframe load) to its controlling SW's `fetch`
|
|
4162
|
+
# event and BLOCK for the respondWith result. Mirrors settle's bounded wait: the main
|
|
4163
|
+
# thread releases the GVL on the dedicated `@sw_nav_outbox`, so the worker thread runs the
|
|
4164
|
+
# handler and posts back. Navigation ids are NEGATIVE so the response is delivered on that
|
|
4165
|
+
# queue (sw_deliver_fetch_response), not the general outbox. Returns the parsed response
|
|
4166
|
+
# hash (SW served the document), or nil to load from the network (no controller, no
|
|
4167
|
+
# respondWith, network error, or the SW didn't answer within the round-trip budget).
|
|
4168
|
+
def service_worker_navigation_fetch(url, is_reload: false, is_history: false, referrer_source: nil, referrer_policy: nil, method: 'GET', body_b64: '', content_type: nil, site_seed: nil, origin_null: false)
|
|
4169
|
+
handle = sw_controller_for_navigation(url) or return nil
|
|
4170
|
+
w = @workers[handle] or return nil
|
|
4171
|
+
fetch_id = (@sw_nav_seq -= 1)
|
|
4172
|
+
# Navigation Preload: when the controlling registration has it enabled, issue the parallel
|
|
4173
|
+
# preload request NOW (main thread, before dispatching the event) and hand the response to the
|
|
4174
|
+
# SW as `event.preloadResponse`. GET only (the feature does not support other methods). The SW
|
|
4175
|
+
# that serves `respondWith(event.preloadResponse)` echoes this response back — the server is hit
|
|
4176
|
+
# exactly once. (EARNED GAP: if the SW instead FALLS THROUGH, this method returns nil and the
|
|
4177
|
+
# caller re-fetches from the network — a second hit; the spec reuses the preload response for
|
|
4178
|
+
# the fall-through. No vendored subtest enables preload then falls through.)
|
|
4179
|
+
preload = if method.to_s.upcase == 'GET' && nav_preload_enabled?(handle)
|
|
4180
|
+
navigation_preload_response(
|
|
4181
|
+
url,
|
|
4182
|
+
referrer_source,
|
|
4183
|
+
referrer_policy,
|
|
4184
|
+
site_seed,
|
|
4185
|
+
origin_null,
|
|
4186
|
+
nav_preload_state(handle)['headerValue']
|
|
4187
|
+
)
|
|
4188
|
+
end
|
|
4189
|
+
# A form submission navigates with the form's method + encoded body (a POST nav the SW reads
|
|
4190
|
+
# via `event.request.text()`); the Content-Type the form's enctype implies rides its headers.
|
|
4191
|
+
headers = {'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7'}
|
|
4192
|
+
headers['Content-Type'] = content_type.to_s unless content_type.to_s.empty?
|
|
4193
|
+
req = JSON.generate(
|
|
4194
|
+
method: method.to_s.empty? ? 'GET' : method.to_s.upcase,
|
|
4195
|
+
url: url.to_s,
|
|
4196
|
+
# The Accept header Fetch inserts for a navigation request (destination 'document').
|
|
4197
|
+
headers: headers,
|
|
4198
|
+
body_b64: body_b64.to_s,
|
|
4199
|
+
mode: 'navigate',
|
|
4200
|
+
destination: 'document',
|
|
4201
|
+
isReloadNavigation: is_reload,
|
|
4202
|
+
isHistoryNavigation: is_history,
|
|
4203
|
+
# A reload navigation revalidates (cache mode 'no-cache'); a fresh/history load is 'default'.
|
|
4204
|
+
cache: is_reload ? 'no-cache' : 'default',
|
|
4205
|
+
# The navigation's referrer is the initiating document, resolved under ITS Referrer-Policy
|
|
4206
|
+
# (the document default absent a meta/header — strict-origin-when-cross-origin — so a
|
|
4207
|
+
# same-origin nav keeps the full URL). A SW that re-issues the request
|
|
4208
|
+
# (`fetch(event.request)`) computes Sec-Fetch-Site / Origin against this referrer's origin.
|
|
4209
|
+
referrer: compute_referrer(referrer_policy, referrer_source, url.to_s).to_s,
|
|
4210
|
+
# `event.request.referrerPolicy` reflects the RESOLVED policy: a navigation with no explicit
|
|
4211
|
+
# meta/header policy uses the document default (strict-origin-when-cross-origin), never the
|
|
4212
|
+
# empty string a bare Request would carry (fetch-event-referrer-policy "default referrer policy").
|
|
4213
|
+
referrerPolicy: referrer_policy.to_s.empty? ? 'strict-origin-when-cross-origin' : referrer_policy.to_s,
|
|
4214
|
+
# A passthrough `fetch(event.request)` re-fetch reports the navigation's OWN request
|
|
4215
|
+
# metadata to the server, independent of the referrer (which Referrer-Policy may reduce):
|
|
4216
|
+
# the initiator origin (the navigating frame's — the request's origin for the Origin
|
|
4217
|
+
# header) and the redirect chain's latched Sec-Fetch-Site seed / Origin taint accumulated
|
|
4218
|
+
# by the network hops before the SW intercepted the final URL.
|
|
4219
|
+
initiator: url_origin(referrer_source),
|
|
4220
|
+
siteSeed: site_seed,
|
|
4221
|
+
originNull: origin_null,
|
|
4222
|
+
# The Navigation Preload response (nil unless preload is enabled), surfaced to the handler
|
|
4223
|
+
# as `event.preloadResponse`.
|
|
4224
|
+
preloadResponse: preload
|
|
4225
|
+
)
|
|
4226
|
+
w[:inbox] << {kind: 'fetch', req:, fetch_id:}
|
|
4227
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + WORKER_ROUND_TRIP_BUDGET
|
|
4228
|
+
while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
4229
|
+
ev = pop_with_timeout(@sw_nav_outbox, WORKER_POLL_INTERVAL) or next
|
|
4230
|
+
next unless ev[:fetch_id] == fetch_id # discard a stale response from a timed-out nav
|
|
4231
|
+
|
|
4232
|
+
resp = JSON.parse(ev[:resp])
|
|
4233
|
+
return nil if resp['fallthrough'] # no respondWith → load from the network
|
|
4234
|
+
return {'networkError' => true} if resp['networkError'] # respondWith(Response.error()) → failed navigation
|
|
4235
|
+
|
|
4236
|
+
return resp
|
|
4237
|
+
end
|
|
4238
|
+
nil # SW never answered within budget → load from the network
|
|
4239
|
+
end
|
|
4240
|
+
|
|
3477
4241
|
def worker_terminate(handle)
|
|
3478
4242
|
w = @workers.delete(handle.to_i)
|
|
3479
4243
|
return unless w
|
|
@@ -3488,26 +4252,159 @@ module Capybara
|
|
|
3488
4252
|
w[:thread].kill
|
|
3489
4253
|
w[:thread].join(WORKER_TERMINATE_GRACE)
|
|
3490
4254
|
end
|
|
4255
|
+
# The dead worker never answers what was still queued in its inbox: post the matching
|
|
4256
|
+
# fallback replies so the reply-pending counters drain (a controlled fetch falls back to
|
|
4257
|
+
# the network) instead of taxing every later settle's bounded wait. Mid-dispatch deaths
|
|
4258
|
+
# are covered by the `ensure` acks in run_worker; only a respondWith parked on a worker
|
|
4259
|
+
# timer that never fires can still strand a fetch (the client promise then just never
|
|
4260
|
+
# settles, like a real dead SW).
|
|
4261
|
+
until w[:inbox].empty?
|
|
4262
|
+
msg = begin
|
|
4263
|
+
w[:inbox].pop(true)
|
|
4264
|
+
rescue ThreadError
|
|
4265
|
+
break
|
|
4266
|
+
end
|
|
4267
|
+
next unless msg.is_a?(Hash)
|
|
4268
|
+
case msg[:kind]
|
|
4269
|
+
when 'sw_message', 'port_msg' then @worker_outbox << {handle: handle.to_i, kind: 'swack'}
|
|
4270
|
+
when 'broadcast' then @worker_outbox << {handle: handle.to_i, kind: 'bcack'}
|
|
4271
|
+
when 'fetch' then sw_deliver_fetch_response(handle.to_i, msg[:fetch_id].to_i, '{"fallthrough":true}', @worker_outbox, msg[:realm_id].to_i)
|
|
4272
|
+
end
|
|
4273
|
+
end
|
|
4274
|
+
# A streaming respondWith whose worker died mid-body never emits its terminal frame: error the
|
|
4275
|
+
# client's stream (so a pending body read rejects, not hangs) and release the @sw_fetch_pending
|
|
4276
|
+
# each open stream still holds, so settle can reach idle.
|
|
4277
|
+
open = @sw_open_streams.delete(handle.to_i)
|
|
4278
|
+
if open&.any?
|
|
4279
|
+
open.each_key do |realm_id, fetch_id|
|
|
4280
|
+
@runtime.realm_call(realm_id, '__csim_swFetchStreamError', fetch_id)
|
|
4281
|
+
end
|
|
4282
|
+
@sw_fetch_pending = [0, @sw_fetch_pending - open.size].max
|
|
4283
|
+
end
|
|
4284
|
+
# Drop any navigation scope mirrored to this now-dead worker so a later navigation
|
|
4285
|
+
# doesn't route to it (it falls through to the network instead).
|
|
4286
|
+
@sw_registrations.reject! {|_scope, h| h == handle.to_i }
|
|
4287
|
+
@sw_navpreload.delete(handle.to_i)
|
|
3491
4288
|
# A blocked worker that never returned messages leaves
|
|
3492
4289
|
# `@worker_in_flight` permanently > 0; reset when no workers
|
|
3493
4290
|
# remain so `polling?` can short-circuit again.
|
|
3494
|
-
@worker_in_flight = 0 if @workers.empty?
|
|
4291
|
+
(@worker_in_flight = 0; @worker_broadcast_pending = 0; @sw_message_pending = 0; @sw_fetch_pending = 0; @sw_fetch_wait_deadline = nil; @sw_msg_wait_deadline = nil; @sw_clients = {}; @sw_realm_controller = {}; @port_channels = {}; @sw_pending_claims = []; @sw_navpreload = {}; @sw_open_streams.clear) if @workers.empty?
|
|
3495
4292
|
# The worker is gone — revoke the blob URLs it created.
|
|
3496
4293
|
revoke_worker_blobs(handle.to_i)
|
|
3497
4294
|
end
|
|
3498
4295
|
|
|
3499
4296
|
def deliver_worker_messages
|
|
3500
|
-
|
|
4297
|
+
head = @worker_outbox_head
|
|
4298
|
+
@worker_outbox_head = nil
|
|
4299
|
+
return 0 if head.nil? && @workers.empty? && @worker_outbox.empty?
|
|
3501
4300
|
events = drain_queue(@worker_outbox)
|
|
4301
|
+
events.unshift(head) if head
|
|
3502
4302
|
return 0 if events.empty?
|
|
3503
|
-
#
|
|
3504
|
-
#
|
|
3505
|
-
|
|
3506
|
-
|
|
4303
|
+
# A worker-originated BroadcastChannel post ('broadcast') is fanned out on the main thread and
|
|
4304
|
+
# is NOT a reply. A 'bcack' acknowledges a broadcast the worker just delivered → release one
|
|
4305
|
+
# broadcast-pending. Everything else ('message'/'__error') is a postMessage reply.
|
|
4306
|
+
broadcasts, rest0 = events.partition {|e| e[:kind] == 'broadcast' }
|
|
4307
|
+
port_ends, rest0b = rest0.partition {|e| e[:kind] == 'port_endpoint' }
|
|
4308
|
+
port_msgs, rest0c = rest0b.partition {|e| e[:kind] == 'port_msg' }
|
|
4309
|
+
sw_msgs, rest1 = rest0c.partition {|e| e[:kind] == 'sw_client_msg' }
|
|
4310
|
+
swacks, rest2 = rest1.partition {|e| e[:kind] == 'swack' }
|
|
4311
|
+
claims, rest3 = rest2.partition {|e| e[:kind] == 'sw_claim' }
|
|
4312
|
+
fetch_resps, rest4 = rest3.partition {|e| e[:kind] == 'fetch_response' }
|
|
4313
|
+
stream_frames, rest4b = rest4.partition {|e| e[:kind].to_s.start_with?('fr_') }
|
|
4314
|
+
acks, msgs = rest4b.partition {|e| e[:kind] == 'bcack' }
|
|
4315
|
+
broadcasts.each {|e| broadcast_to_windows(e[:name], e[:data], nil, e[:origin], from_worker: e[:handle]) }
|
|
4316
|
+
# A worker/SW registering its end of a cross-isolate MessagePort channel — record it BEFORE
|
|
4317
|
+
# the message events below, so a port message carried in the same drain can already route.
|
|
4318
|
+
port_ends.each {|e| port_channel_endpoint_sw(e[:channel], e[:handle]) }
|
|
4319
|
+
# A worker/SW port → its remote (client-realm) peer: relay to that realm's channel endpoint.
|
|
4320
|
+
# If the client hasn't registered its endpoint yet (it decodes the transferred port in the
|
|
4321
|
+
# sw_client_msg processed just below), BUFFER until port_channel_endpoint_realm flushes.
|
|
4322
|
+
port_msgs.each do |e|
|
|
4323
|
+
ch = (@port_channels[e[:channel].to_s] ||= {})
|
|
4324
|
+
rid = ch[:realm]
|
|
4325
|
+
if rid.nil?
|
|
4326
|
+
(ch[:pending_realm] ||= []) << e[:data]
|
|
4327
|
+
else
|
|
4328
|
+
deliver_port_to_realm(rid, e[:channel].to_s, e[:data])
|
|
4329
|
+
end
|
|
4330
|
+
end
|
|
4331
|
+
# A service worker → client message: deliver to the POSTING client's realm. The client id
|
|
4332
|
+
# encodes it — `client-<realm>` for a frame/window realm, 'client-window' for the main realm.
|
|
4333
|
+
# A `client.postMessage` to a controlled IFRAME must reach THAT frame's navigator.service-
|
|
4334
|
+
# Worker, not the top window (postmessage-to-client). `e[:handle]` is the SENDING worker, so
|
|
4335
|
+
# the client's message `source` is exact. A message to a DISCARDED frame realm is dropped
|
|
4336
|
+
# (matching a real browser — a message to a gone client is discarded, not misrouted to top).
|
|
4337
|
+
sw_msgs.each do |e|
|
|
4338
|
+
m = /\Aclient-(\d+)\z/.match(e[:client].to_s)
|
|
4339
|
+
if m
|
|
4340
|
+
rid = m[1].to_i
|
|
4341
|
+
@runtime.realm_call(rid, '__csim_swDeliverClientMessage', e[:data], e[:handle]) if @runtime.frame_realm_alive?(rid)
|
|
4342
|
+
else
|
|
4343
|
+
@runtime.call('__csim_swDeliverClientMessage', e[:data], e[:handle])
|
|
4344
|
+
end
|
|
4345
|
+
end
|
|
4346
|
+
# clients.claim(): the claiming worker takes control of EVERY in-scope client — including
|
|
4347
|
+
# ones that never register()'d (an iframe built before the SW existed). See broadcast_claim.
|
|
4348
|
+
# Process LONGEST scope first: when nested-scope workers claim in the same drain, the deeper
|
|
4349
|
+
# registration must be installed before the shallower one runs, or a client the shallow claim
|
|
4350
|
+
# transiently seizes would fire a spurious extra controllerchange (a reload-on-controllerchange
|
|
4351
|
+
# page would double-fire). A claim whose scope isn't mirrored into @sw_registrations yet — the
|
|
4352
|
+
# worker fires activate→claim() decoupled from the CLIENT-side lifecycle that populates it — is
|
|
4353
|
+
# BUFFERED and flushed by sw_register_scope, so an `activate → clients.claim()` isn't lost.
|
|
4354
|
+
claims.map {|e| [e, @sw_registrations.key(e[:handle].to_i)] }
|
|
4355
|
+
.sort_by {|_e, scope| -(scope ? scope.length : -1) }
|
|
4356
|
+
.each do |e, scope|
|
|
4357
|
+
if scope
|
|
4358
|
+
broadcast_claim(e[:handle], e[:has_fetch], scope)
|
|
4359
|
+
else
|
|
4360
|
+
@sw_pending_claims << e
|
|
4361
|
+
end
|
|
4362
|
+
end
|
|
4363
|
+
# A controlled fetch's respondWith result → resolve the pending client fetch in the
|
|
4364
|
+
# realm that issued it (fetch ids are per-realm, so realm_id disambiguates collisions).
|
|
4365
|
+
fetch_resps.each {|e| @runtime.realm_call(e[:realm_id].to_i, '__csim_swControllerFetchResponse', e[:fetch_id], e[:resp]) }
|
|
4366
|
+
# Streaming respondWith frames — deliver IN EMISSION ORDER (per fetch id: start → chunk* →
|
|
4367
|
+
# close/error) so the client reassembles the body ReadableStream correctly. The request's
|
|
4368
|
+
# @sw_fetch_pending was counted at fetch time and clears on the terminal frame.
|
|
4369
|
+
stream_frames.each do |e|
|
|
4370
|
+
fn = STREAM_FRAME_FNS[e[:kind]]
|
|
4371
|
+
@runtime.realm_call(e[:realm_id].to_i, fn, e[:fetch_id], e[:payload]) if fn
|
|
4372
|
+
# Track a stream's open span (head → terminal) per emitting worker, so worker_terminate
|
|
4373
|
+
# can release + error a body its worker died mid-stream.
|
|
4374
|
+
key = [e[:realm_id].to_i, e[:fetch_id].to_i]
|
|
4375
|
+
if e[:kind] == 'fr_start' then @sw_open_streams[e[:handle].to_i][key] = true
|
|
4376
|
+
else @sw_open_streams[e[:handle].to_i].delete(key)
|
|
4377
|
+
end
|
|
4378
|
+
end
|
|
4379
|
+
stream_terminals = stream_frames.count {|e| e[:kind] == 'fr_close' || e[:kind] == 'fr_error' }
|
|
4380
|
+
@sw_fetch_pending = [0, @sw_fetch_pending - fetch_resps.size - stream_terminals].max
|
|
4381
|
+
@worker_broadcast_pending = [0, @worker_broadcast_pending - acks.size].max
|
|
4382
|
+
@sw_message_pending = [0, @sw_message_pending - swacks.size].max
|
|
4383
|
+
# A delivered swack/bcack refreshes the message-wait budget (drain_pending_message_reply), so
|
|
4384
|
+
# the next reply in a sequence waits afresh instead of inheriting a spent deadline. Done here,
|
|
4385
|
+
# at the single delivery point, so it fires no matter which drain path delivered the reply —
|
|
4386
|
+
# including hold_for_sw_fetch's outbox drain when a fetch co-pends (which would otherwise
|
|
4387
|
+
# strand an expired deadline and re-starve the next transferable reply under load).
|
|
4388
|
+
@sw_msg_wait_deadline = nil if acks.size.positive? || swacks.size.positive?
|
|
4389
|
+
# `__error` postbacks don't correspond to a prior post, so bottom out at zero.
|
|
4390
|
+
@worker_in_flight = [0, @worker_in_flight - msgs.size].max
|
|
4391
|
+
@runtime.call('__csim_deliverWorkerMessages', msgs) unless msgs.empty?
|
|
3507
4392
|
events.size
|
|
3508
4393
|
end
|
|
3509
4394
|
|
|
3510
|
-
def worker_pending? = !@worker_outbox.empty? || @worker_in_flight > 0 || @worker_init_lock.synchronize { @worker_initializing } > 0
|
|
4395
|
+
def worker_pending? = !@worker_outbox.empty? || !@worker_outbox_head.nil? || @worker_in_flight > 0 || @worker_broadcast_pending > 0 || @sw_message_pending > 0 || @sw_fetch_pending > 0 || @worker_init_lock.synchronize { @worker_initializing + @worker_busy } > 0
|
|
4396
|
+
|
|
4397
|
+
# The subset of worker pendings whose outbox reply is CONTRACTUAL (bcack / swack /
|
|
4398
|
+
# fetch_response are posted under `ensure`, so they arrive even when the worker-side
|
|
4399
|
+
# handler raises). Safe for settle to block a bounded wait on — unlike @worker_in_flight
|
|
4400
|
+
# (a plain postMessage that a listen-only worker never answers) or @worker_initializing.
|
|
4401
|
+
def worker_reply_pending? = @worker_broadcast_pending > 0 || @sw_message_pending > 0 || @sw_fetch_pending > 0
|
|
4402
|
+
|
|
4403
|
+
# The message/broadcast subset of `worker_reply_pending?` — a swack (client→SW postMessage /
|
|
4404
|
+
# cross-isolate port message) or a bcack (BroadcastChannel post). `run_event_loop_frame` waits
|
|
4405
|
+
# on these WITHOUT holding the clock (drain_pending_message_reply); the SW-fetch pending is held
|
|
4406
|
+
# separately (hold_for_sw_fetch), so it's deliberately excluded here.
|
|
4407
|
+
def worker_message_reply_pending? = @worker_broadcast_pending > 0 || @sw_message_pending > 0
|
|
3511
4408
|
|
|
3512
4409
|
# ── Cross-window messaging (window.open / opener / postMessage) ──
|
|
3513
4410
|
# Each window is a separate Browser/VM/isolate, so a reference to another
|
|
@@ -3618,14 +4515,32 @@ module Capybara
|
|
|
3618
4515
|
|
|
3619
4516
|
# Covers both cross-window postMessage AND BroadcastChannel — the two
|
|
3620
4517
|
# cross-window event channels share these drain/pending hooks.
|
|
3621
|
-
def window_message_pending? = !@window_inbox.empty? || !@broadcast_inbox.empty?
|
|
4518
|
+
def window_message_pending? = !@window_inbox.empty? || !@broadcast_inbox.empty? || !@storage_inbox.empty? || !@bc_queue.empty?
|
|
3622
4519
|
|
|
3623
4520
|
# A BroadcastChannel message queued for delivery to this Browser's channels.
|
|
3624
4521
|
# `source_realm_id` is the posting realm's context id within THIS isolate (0 =
|
|
3625
4522
|
# main), or nil when the post came from ANOTHER isolate (the Driver's cross-
|
|
3626
4523
|
# window fanout) — a nil source matches no local realm, so it reaches every one.
|
|
3627
|
-
def enqueue_broadcast(name, data, source_realm_id = nil)
|
|
3628
|
-
@broadcast_inbox << {'name' => name.to_s, 'data' => data, 'source' => source_realm_id}
|
|
4524
|
+
def enqueue_broadcast(name, data, source_realm_id = nil, origin = nil)
|
|
4525
|
+
@broadcast_inbox << {'name' => name.to_s, 'data' => data, 'source' => source_realm_id, 'origin' => origin}
|
|
4526
|
+
end
|
|
4527
|
+
|
|
4528
|
+
# A Storage change (setItem/removeItem/clear) in realm `source_realm_id`: fire a `storage`
|
|
4529
|
+
# event at every OTHER same-origin document. Within this isolate that's the other realms
|
|
4530
|
+
# (main + frames — a window's documents share both storage areas); localStorage ALSO spans
|
|
4531
|
+
# separate same-origin windows (the Driver shares its jar), so fan the change out to them —
|
|
4532
|
+
# sessionStorage is per-browsing-context and never crosses windows.
|
|
4533
|
+
def storage_changed(kind, key, old, new, url, source_realm_id)
|
|
4534
|
+
enqueue_storage_event(kind, key, old, new, url, source_realm_id)
|
|
4535
|
+
@driver.storage_broadcast(self, kind, key, old, new, url) if kind == 'local' && @driver.respond_to?(:storage_broadcast)
|
|
4536
|
+
nil
|
|
4537
|
+
end
|
|
4538
|
+
|
|
4539
|
+
# Queue a storage event for THIS window's documents. `source_realm_id` is the changing realm
|
|
4540
|
+
# within this isolate (skipped at delivery); nil (a cross-window fan-out) matches no realm, so
|
|
4541
|
+
# it reaches every one.
|
|
4542
|
+
def enqueue_storage_event(kind, key, old, new, url, source_realm_id = nil)
|
|
4543
|
+
@storage_inbox << {'kind' => kind.to_s, 'key' => key, 'old' => old, 'new' => new, 'url' => url.to_s, 'source' => source_realm_id}
|
|
3629
4544
|
end
|
|
3630
4545
|
|
|
3631
4546
|
# Fire queued cross-window messages (postMessage + BroadcastChannel).
|
|
@@ -3655,6 +4570,26 @@ module Capybara
|
|
|
3655
4570
|
end
|
|
3656
4571
|
n += events.size
|
|
3657
4572
|
end
|
|
4573
|
+
unless @storage_inbox.empty?
|
|
4574
|
+
events = @storage_inbox.slice!(0, @storage_inbox.length)
|
|
4575
|
+
# The `storage` event fires at every same-origin document EXCEPT the one that changed
|
|
4576
|
+
# the area — deliver to the main realm (0) and every live frame realm, skipping the
|
|
4577
|
+
# source realm. A nil source (a cross-window fan-out) is excluded from no realm.
|
|
4578
|
+
realm_ids = @runtime.respond_to?(:frame_realm_ids) ? @runtime.frame_realm_ids : []
|
|
4579
|
+
[0, *realm_ids].each do |target_id|
|
|
4580
|
+
batch = events.reject {|e| e['source'] == target_id }
|
|
4581
|
+
next if batch.empty?
|
|
4582
|
+
if target_id.zero?
|
|
4583
|
+
@runtime.call('__csim_deliverStorageEvents', batch)
|
|
4584
|
+
elsif @runtime.frame_realm_alive?(target_id)
|
|
4585
|
+
@runtime.realm_call(target_id, '__csim_deliverStorageEvents', batch)
|
|
4586
|
+
end
|
|
4587
|
+
end
|
|
4588
|
+
n += events.size
|
|
4589
|
+
end
|
|
4590
|
+
# Drain the ordered BroadcastChannel queue LAST, so a `message`/`storage` handler above that
|
|
4591
|
+
# re-posts (multi-realm mode → bc_post) is picked up in this same pass.
|
|
4592
|
+
n += deliver_broadcast_queue unless @bc_queue.empty?
|
|
3658
4593
|
n
|
|
3659
4594
|
end
|
|
3660
4595
|
|
|
@@ -3664,13 +4599,107 @@ module Capybara
|
|
|
3664
4599
|
# fanout goes through the Driver; the same-ISOLATE fanout (main ↔ sibling realms,
|
|
3665
4600
|
# sibling ↔ sibling) is queued here and delivered per-realm by
|
|
3666
4601
|
# `deliver_window_messages`, which skips the posting realm.
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
4602
|
+
# Fan a BroadcastChannel post out to every OTHER same-origin browsing context. Called on the
|
|
4603
|
+
# MAIN thread — either directly from a main/frame-realm post (`source_realm_id` = the poster's
|
|
4604
|
+
# realm, `from_worker` nil), or from `deliver_worker_messages` for a WORKER-originated post
|
|
4605
|
+
# (`from_worker` = the posting worker's handle, `source_realm_id` nil).
|
|
4606
|
+
def broadcast_to_windows(name, data, source_realm_id = 0, origin = nil, from_worker: nil)
|
|
4607
|
+
broadcast_external(name, data, origin, from_worker: from_worker)
|
|
4608
|
+
# Same-isolate main + frame realms (LEGACY single-realm / worker-inbound path — the multi-realm
|
|
4609
|
+
# main-thread post goes through `bc_post`'s ordered queue instead). The poster already delivered
|
|
4610
|
+
# to itself in-VM, so this only reaches the OTHER realms — queue whenever one exists. A WORKER
|
|
4611
|
+
# post reaches realm 0 and every frame (a separate isolate delivered to none in-VM). A FRAME post
|
|
4612
|
+
# always reaches main realm 0 — even when the posting frame's own realm isn't yet recorded in
|
|
4613
|
+
# `frame_realms` (a BroadcastChannel posted SYNCHRONOUSLY during the frame's initial script runs
|
|
4614
|
+
# before the realm is registered, so `has_frames` can be false though a valid target — main —
|
|
4615
|
+
# exists). A MAIN post reaches the frames only when some are registered.
|
|
4616
|
+
has_frames = @runtime.respond_to?(:frame_realm_ids) && @runtime.frame_realm_ids.any?
|
|
4617
|
+
from_frame = !from_worker && !source_realm_id.nil? && source_realm_id != 0
|
|
4618
|
+
enqueue_broadcast(name, data, source_realm_id, origin) if has_frames || from_worker || from_frame
|
|
4619
|
+
end
|
|
4620
|
+
|
|
4621
|
+
# Fan a BroadcastChannel post out beyond this isolate's main-thread realms: OTHER top-level windows
|
|
4622
|
+
# (separate isolates, via the Driver) and every live WORKER (separate isolate, via its thread-safe
|
|
4623
|
+
# inbox, except the posting worker). Shared by the legacy `broadcast_to_windows` and the ordered
|
|
4624
|
+
# `bc_post` — a same-isolate ordered post still reaches workers and other windows. `origin` here is
|
|
4625
|
+
# the SCOPING origin KEY (opaque token / tuple origin), which the worker + cross-window sides match on.
|
|
4626
|
+
def broadcast_external(name, data, origin, from_worker: nil)
|
|
4627
|
+
@driver.broadcast_channel(self, name.to_s, data, origin) if @driver.respond_to?(:broadcast_channel)
|
|
4628
|
+
# Skip a worker whose thread has already exited (self-close / termination in flight): it will
|
|
4629
|
+
# never drain its inbox, so pushing would leak. Counted in a dedicated pending tally so `settle`
|
|
4630
|
+
# waits until the worker acks the delivery (a `bcack`).
|
|
4631
|
+
@workers.each do |h, w|
|
|
4632
|
+
next if h == from_worker || !w[:thread].alive?
|
|
4633
|
+
@worker_broadcast_pending += 1
|
|
4634
|
+
w[:inbox] << {kind: 'broadcast', name: name.to_s, data: data, origin: origin}
|
|
4635
|
+
end
|
|
4636
|
+
end
|
|
4637
|
+
|
|
4638
|
+
# ── BroadcastChannel isolate-wide ordered delivery (multi-realm path) ──
|
|
4639
|
+
# A channel registers on construction with the isolate-wide creation counter, so delivery can be
|
|
4640
|
+
# ordered "oldest channel first" across realms.
|
|
4641
|
+
def bc_register(realm_id, local_id, name, origin_key)
|
|
4642
|
+
@bc_seq += 1
|
|
4643
|
+
@bc_registry[[realm_id.to_i, local_id.to_i]] = {seq: @bc_seq, name: name.to_s, origin_key: origin_key, closed: false}
|
|
4644
|
+
nil
|
|
4645
|
+
end
|
|
4646
|
+
|
|
4647
|
+
def bc_unregister(realm_id, local_id)
|
|
4648
|
+
@bc_registry.delete([realm_id.to_i, local_id.to_i])
|
|
4649
|
+
nil
|
|
4650
|
+
end
|
|
4651
|
+
|
|
4652
|
+
# Is another same-isolate realm (a frame / same-isolate window) live? Only then does a post use the
|
|
4653
|
+
# ordered registry; a single-realm page keeps the in-VM microtask path (zero behaviour change).
|
|
4654
|
+
def bc_siblings_exist? = @runtime.respond_to?(:frame_realm_ids) && @runtime.frame_realm_ids.any?
|
|
4655
|
+
|
|
4656
|
+
# A main-thread BroadcastChannel post in multi-realm mode. Snapshot the eligible target channels
|
|
4657
|
+
# (same name + origin, still open, excluding the poster) at POST TIME, ordered by creation seq, and
|
|
4658
|
+
# queue one ordered delivery each — so a channel created AFTER this post (higher seq / not yet
|
|
4659
|
+
# registered) never receives it, and cross-realm targets interleave with same-realm ones by
|
|
4660
|
+
# creation order (broadcastchannel/ordering). Also fans out to workers + other windows.
|
|
4661
|
+
def bc_post(realm_id, local_id, name, origin_key, data, origin)
|
|
4662
|
+
realm_id = realm_id.to_i
|
|
4663
|
+
local_id = local_id.to_i
|
|
4664
|
+
name = name.to_s
|
|
4665
|
+
# Snapshot ALL matching open channels — do NOT gate on `frame_realm_alive?` here: a channel that
|
|
4666
|
+
# posts SYNCHRONOUSLY during its frame's initial script (opaque-origin's data: iframes) runs in a
|
|
4667
|
+
# realm not yet registered in `frame_realms`, so a same-realm sibling would be wrongly excluded.
|
|
4668
|
+
# `deliver_broadcast_queue` re-checks realm liveness at delivery time (by then it's registered),
|
|
4669
|
+
# and a genuinely-dead realm's stale entry just yields a skipped delivery.
|
|
4670
|
+
targets = @bc_registry.reject {|(rid, lid), e|
|
|
4671
|
+
e[:closed] || e[:name] != name || e[:origin_key] != origin_key ||
|
|
4672
|
+
(rid == realm_id && lid == local_id)
|
|
4673
|
+
}.sort_by {|_k, e| e[:seq] }
|
|
4674
|
+
# `origin` (serialized, e.g. "null") is the MessageEvent.origin the same-isolate delivery
|
|
4675
|
+
# exposes; `origin_key` (e.g. "opaque:…") is the SCOPING token workers / other windows match on.
|
|
4676
|
+
targets.each {|(rid, lid), _e| @bc_queue << {realm_id: rid, local_id: lid, data: data, origin: origin} }
|
|
4677
|
+
broadcast_external(name, data, origin_key)
|
|
4678
|
+
nil
|
|
4679
|
+
end
|
|
4680
|
+
|
|
4681
|
+
# Drain the ordered BroadcastChannel queue, delivering one message to one channel at a time in
|
|
4682
|
+
# creation order. A handler's synchronous re-post appends to `@bc_queue` (via bc_post), so the loop
|
|
4683
|
+
# keeps draining those in order too — reproducing the single global task queue the spec describes.
|
|
4684
|
+
# Bounded per call (like every drain loop here): a pathological mutual re-post between two realms
|
|
4685
|
+
# would otherwise spin forever holding the GVL. The cap leaves any remainder queued
|
|
4686
|
+
# (`window_message_pending?` keeps the loop live), so a runaway is bounded by the runner's
|
|
4687
|
+
# force-timeout across frames rather than hanging uninterruptibly in one.
|
|
4688
|
+
def deliver_broadcast_queue
|
|
4689
|
+
n = 0
|
|
4690
|
+
until @bc_queue.empty?
|
|
4691
|
+
break if n >= BROADCAST_DRAIN_CAP
|
|
4692
|
+
item = @bc_queue.shift
|
|
4693
|
+
e = @bc_registry[[item[:realm_id], item[:local_id]]]
|
|
4694
|
+
next if e.nil? || e[:closed] # closed after being queued → gets nothing
|
|
4695
|
+
if item[:realm_id].zero?
|
|
4696
|
+
@runtime.call('__csim_bcDeliverOne', item[:local_id], item[:data], item[:origin])
|
|
4697
|
+
elsif @runtime.frame_realm_alive?(item[:realm_id])
|
|
4698
|
+
@runtime.realm_call(item[:realm_id], '__csim_bcDeliverOne', item[:local_id], item[:data], item[:origin])
|
|
4699
|
+
end
|
|
4700
|
+
n += 1
|
|
3673
4701
|
end
|
|
4702
|
+
n
|
|
3674
4703
|
end
|
|
3675
4704
|
|
|
3676
4705
|
# ── Image decode (libvips) ─────────────────────────────────────
|
|
@@ -3687,29 +4716,210 @@ module Capybara
|
|
|
3687
4716
|
# 8900×8900 frames Discourse uploads exercise. Optional
|
|
3688
4717
|
# `max_w`/`max_h` lets the caller pre-shrink for cheap OCR-style
|
|
3689
4718
|
# "downscale before pixel-touch" flows.
|
|
4719
|
+
# Load an image resource for an `<img>` (or a pattern/drawImage source):
|
|
4720
|
+
# resolve the URL against the current document, fetch the bytes, and decode
|
|
4721
|
+
# them to an RGBA buffer via libvips. Returns `{width, height, refId}` (the
|
|
4722
|
+
# raw pixels ride the transfer registry, like decode_image) or nil when the
|
|
4723
|
+
# fetch or decode fails (a broken image → the `<img>` fires `error`).
|
|
4724
|
+
# Fetch + decode an `<img>` resource to an RGBA bitmap for the drawImage /
|
|
4725
|
+
# createPattern surface, memoized by resolved URL (see `@@image_cache`). Returns
|
|
4726
|
+
# {'width','height','refId'} — a FRESH transfer stash per call, since
|
|
4727
|
+
# `fetchTransfer` consumes the registry entry — or nil when the resource can't be
|
|
4728
|
+
# fetched or decoded (the caller fires `error`). A scheme with no host-side reader
|
|
4729
|
+
# yet (blob:, whose bytes live in the VM) returns {'unsupported' => true} so the
|
|
4730
|
+
# caller stays inert rather than reporting a spuriously-broken image.
|
|
4731
|
+
# `cors` (a `crossorigin` <img>) fetches under CORS: a cross-origin response without a
|
|
4732
|
+
# matching Access-Control-Allow-Origin fails the load. `credentials` is 'include' for
|
|
4733
|
+
# crossorigin="use-credentials", 'same-origin' (uncredentialed cross-origin) otherwise.
|
|
4734
|
+
def load_image(url, cors = false, credentials = 'same-origin')
|
|
4735
|
+
key = resolve_against_current(url.to_s)
|
|
4736
|
+
return nil unless key.is_a?(String)
|
|
4737
|
+
entry = cached_image(key, cors, credentials)
|
|
4738
|
+
return {'unsupported' => true} if entry == :unsupported
|
|
4739
|
+
# A valid zero-area image: complete + not broken, but no pixels. rsvg throws
|
|
4740
|
+
# before dimensions can be read, so the intrinsic size collapses to 0×0 (a
|
|
4741
|
+
# browser would keep the non-zero axis, e.g. 0×100 → naturalHeight 100) — a minor
|
|
4742
|
+
# divergence, immaterial to createPattern / drawImage, which both need a
|
|
4743
|
+
# non-zero area.
|
|
4744
|
+
tainted = image_tainted?(key, cors)
|
|
4745
|
+
return {'zeroSize' => true, 'width' => 0, 'height' => 0, 'tainted' => tainted} if entry == :zero_size
|
|
4746
|
+
return nil unless entry
|
|
4747
|
+
r = {'width' => entry['width'], 'height' => entry['height'], 'refId' => transfer_buffer_stash(entry['bytes']), 'colorSpace' => entry['colorSpace'], 'tainted' => tainted}
|
|
4748
|
+
r['refIdP3'] = transfer_buffer_stash(entry['bytesP3']) if entry['bytesP3']
|
|
4749
|
+
r
|
|
4750
|
+
end
|
|
4751
|
+
|
|
4752
|
+
# Whether a successfully-loaded image taints a canvas it's drawn into: its bytes came
|
|
4753
|
+
# cross-origin without CORS approval — i.e. a no-cors http(s) load from a different origin
|
|
4754
|
+
# than the document. A CORS load that reached here passed the Access-Control check (so it's
|
|
4755
|
+
# origin-clean), and same-origin / data: images are always clean. An opaque-origin document
|
|
4756
|
+
# (data:/srcdoc/sandboxed — nil origin) is cross-origin to every http(s) image, so any such
|
|
4757
|
+
# image taints it.
|
|
4758
|
+
private def image_tainted?(key, cors)
|
|
4759
|
+
return false if cors || !key.match?(%r{\Ahttps?://}i)
|
|
4760
|
+
img = url_origin(key)
|
|
4761
|
+
return false unless img
|
|
4762
|
+
doc = url_origin(@current_url)
|
|
4763
|
+
doc.nil? || doc != img
|
|
4764
|
+
end
|
|
4765
|
+
|
|
4766
|
+
# A decoded-image cache entry for `key`, decoding + caching on a miss.
|
|
4767
|
+
# :unsupported for a scheme we can't fetch host-side; nil on fetch/decode failure.
|
|
4768
|
+
# A CORS load caches under a key tagged with the mode, the credentials, AND the requesting
|
|
4769
|
+
# document origin — its success/failure is origin-dependent (a response's ACAO may allow one
|
|
4770
|
+
# origin and not another), and @@image_cache is process-wide (shared across documents /
|
|
4771
|
+
# sessions), so an origin-blind key would serve one origin's CORS success to another origin
|
|
4772
|
+
# whose load should fail. A no-cors load is origin-independent (it always reads the bytes),
|
|
4773
|
+
# so it keeps the bare URL key and shares the cache with the decode / canvas loaders.
|
|
4774
|
+
private def cached_image(key, cors = false, credentials = 'same-origin')
|
|
4775
|
+
cache_key = cors ? "cors:#{credentials}:#{url_origin(@current_url)}:#{key}" : key
|
|
4776
|
+
cached = @@image_cache_lock.synchronize { @@image_cache[cache_key] }
|
|
4777
|
+
return cached if cached
|
|
4778
|
+
bytes = image_source_bytes(key, cors, credentials)
|
|
4779
|
+
return bytes if bytes == :unsupported
|
|
4780
|
+
return nil unless bytes
|
|
4781
|
+
entry = decode_or_nil(bytes)
|
|
4782
|
+
# nil (broken) and :zero_size (valid but zero-area) both carry no bitmap to cache.
|
|
4783
|
+
return entry unless entry.is_a?(Hash)
|
|
4784
|
+
@@image_cache_lock.synchronize do
|
|
4785
|
+
@@image_cache.clear if @@image_cache.size >= IMAGE_CACHE_MAX
|
|
4786
|
+
@@image_cache[cache_key] = entry
|
|
4787
|
+
end
|
|
4788
|
+
entry
|
|
4789
|
+
end
|
|
4790
|
+
|
|
4791
|
+
# Raw (encoded) bytes for an image URL: `data:` decoded inline, http(s) via a
|
|
4792
|
+
# binary-safe fetch (the raw bytes ride `body_b64`; the text body would mangle
|
|
4793
|
+
# non-ASCII image bytes). A `cors` load threads 'cors' mode + credentials into the
|
|
4794
|
+
# fetch, so rack_fetch's Access-Control enforcement rejects (→ nil) a cross-origin
|
|
4795
|
+
# response with no matching ACAO. :unsupported for a scheme with no host-side reader,
|
|
4796
|
+
# nil for a missing / failed / CORS-rejected / empty resource.
|
|
4797
|
+
private def image_source_bytes(key, cors = false, credentials = 'same-origin')
|
|
4798
|
+
if key.start_with?('data:')
|
|
4799
|
+
bytes = decode_data_url_body(key)
|
|
4800
|
+
bytes.empty? ? nil : bytes
|
|
4801
|
+
elsif key.match?(%r{\Ahttps?://}i)
|
|
4802
|
+
result = rack_fetch('GET', key, '', {}, 'follow', cors ? 'cors' : nil, credentials: credentials)
|
|
4803
|
+
return nil unless result && result['status'].to_i < 400
|
|
4804
|
+
bytes = result['body_b64'] ? Base64.decode64(result['body_b64']) : result['body'].to_s.b
|
|
4805
|
+
bytes.empty? ? nil : bytes
|
|
4806
|
+
else
|
|
4807
|
+
:unsupported
|
|
4808
|
+
end
|
|
4809
|
+
end
|
|
4810
|
+
|
|
4811
|
+
# Decode a base64-encoded image (createImageBitmap's blob path), optionally
|
|
4812
|
+
# downscaled to fit within (max_w, max_h) via its resize options.
|
|
3690
4813
|
def decode_image(b64_bytes, max_w = nil, max_h = nil)
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
4814
|
+
entry = decode_or_nil(Base64.decode64(b64_bytes.to_s), max_w, max_h)
|
|
4815
|
+
# nil (broken) or :zero_size — createImageBitmap of either rejects (a zero-area
|
|
4816
|
+
# source is an InvalidStateError), so surface nil for the caller to reject on.
|
|
4817
|
+
return nil unless entry.is_a?(Hash)
|
|
4818
|
+
r = {'width' => entry['width'], 'height' => entry['height'], 'refId' => transfer_buffer_stash(entry['bytes']), 'colorSpace' => entry['colorSpace']}
|
|
4819
|
+
r['refIdP3'] = transfer_buffer_stash(entry['bytesP3']) if entry['bytesP3']
|
|
4820
|
+
r
|
|
4821
|
+
end
|
|
4822
|
+
|
|
4823
|
+
# Decode an encoded image (PNG/JPEG/GIF/WEBP/SVG/…) to a packed RGBA bitmap via
|
|
4824
|
+
# libvips, optionally downscaled to fit within (max_w, max_h). `access:
|
|
4825
|
+
# :sequential` keeps libvips from applying the source ICC profile mid-stream (it
|
|
4826
|
+
# shifts RGBA by ±2 vs a raw decode). Returns {'width','height','bytes','colorSpace'};
|
|
4827
|
+
# `colorSpace` ('srgb' | 'display-p3') tells drawImage which space the bytes are in
|
|
4828
|
+
# so it can convert into the destination canvas's colour space.
|
|
4829
|
+
#
|
|
4830
|
+
# Detection is by the profile's description text (both Display-P3 and the sRGB /
|
|
4831
|
+
# Adobe profiles carry a readable name). A plain (unprofiled) OR sRGB-profiled RGB
|
|
4832
|
+
# image is trusted as sRGB verbatim — the common case (incl. photos exported as
|
|
4833
|
+
# sRGB) stays byte-identical. A Display-P3 image keeps its raw bytes: a Display-P3
|
|
4834
|
+
# PNG already stores P3-encoded values, so we only TAG it and let drawImage do the
|
|
4835
|
+
# gamut conversion. Adobe-RGB and CMYK aren't among the two predefined canvas colour
|
|
4836
|
+
# spaces, so they're ICC-transformed to sRGB (their wide gamut is lost — a documented
|
|
4837
|
+
# gap). Any other profiled non-RGB source (grayscale / Lab) is colour-converted to
|
|
4838
|
+
# sRGB so it lands as packed RGB, never kept raw.
|
|
4839
|
+
private def decode_rgba(bytes, max_w = nil, max_h = nil)
|
|
4840
|
+
require 'vips' unless defined?(Vips)
|
|
4841
|
+
img = Vips::Image.new_from_buffer(bytes, '', access: :sequential)
|
|
4842
|
+
# RGB (incl. 16-bit `rgb16`); a non-RGB profiled source is colour-converted below.
|
|
4843
|
+
rgb = %i[srgb rgb rgb16].include?(img.interpretation)
|
|
4844
|
+
color_space = 'srgb'
|
|
4845
|
+
p3_img = nil # a second, Display-P3 rendering for a wide-gamut source (see below)
|
|
4846
|
+
if img.get_fields.include?('icc-profile-data')
|
|
4847
|
+
icc = img.get('icc-profile-data')
|
|
4848
|
+
if rgb && (icc.include?('Display P3') || icc.include?('DCI-P3'))
|
|
4849
|
+
color_space = 'display-p3' # wide-gamut RGB: raw bytes are already P3-encoded
|
|
4850
|
+
elsif img.interpretation == :cmyk || icc.include?('Adobe')
|
|
4851
|
+
# A wide-gamut (Adobe-RGB / CMYK) profile can't be represented by a single
|
|
4852
|
+
# buffer: an sRGB canvas needs the colour CLIPPED to sRGB, a Display-P3 canvas
|
|
4853
|
+
# needs it PRESERVED in P3 (and those differ — ICC gamut-mapping to sRGB isn't a
|
|
4854
|
+
# matrix clip of the P3 value). So decode BOTH renderings via libvips' built-in
|
|
4855
|
+
# profiles; drawImage picks by the destination canvas's colour space. icc_transform
|
|
4856
|
+
# needs random access, so re-decode without `access: :sequential`.
|
|
4857
|
+
base = Vips::Image.new_from_buffer(bytes, '')
|
|
4858
|
+
begin
|
|
4859
|
+
img = base.icc_transform('srgb', embedded: true)
|
|
4860
|
+
p3_img = base.icc_transform('p3', embedded: true)
|
|
4861
|
+
rescue StandardError
|
|
4862
|
+
img = img.colourspace('srgb'); p3_img = nil
|
|
4863
|
+
end
|
|
4864
|
+
elsif !rgb
|
|
4865
|
+
img = img.colourspace('srgb') # profiled grayscale / Lab → packed sRGB RGB
|
|
3709
4866
|
end
|
|
3710
|
-
raw
|
|
3711
|
-
|
|
3712
|
-
|
|
4867
|
+
# else: an sRGB-profiled (or other) RGB image → trust the raw bytes as sRGB.
|
|
4868
|
+
elsif !rgb
|
|
4869
|
+
img = img.colourspace('srgb')
|
|
4870
|
+
end
|
|
4871
|
+
pack = lambda do |i|
|
|
4872
|
+
i = i.cast('uchar', shift: true) if i.format == :ushort # 16-bit source → 8-bit, scaled
|
|
4873
|
+
i = i.bandjoin(255) if i.bands < 4
|
|
4874
|
+
i
|
|
4875
|
+
end
|
|
4876
|
+
img = pack.call(img)
|
|
4877
|
+
p3_img = pack.call(p3_img) if p3_img
|
|
4878
|
+
if max_w && max_h && max_w.to_i > 0 && max_h.to_i > 0 &&
|
|
4879
|
+
(img.width > max_w.to_i || img.height > max_h.to_i)
|
|
4880
|
+
shrink = [img.width.to_f / max_w.to_i, img.height.to_f / max_h.to_i].max
|
|
4881
|
+
if shrink > 1
|
|
4882
|
+
img = img.resize(1.0 / shrink)
|
|
4883
|
+
p3_img = p3_img.resize(1.0 / shrink) if p3_img
|
|
4884
|
+
end
|
|
4885
|
+
end
|
|
4886
|
+
out = {'width' => img.width, 'height' => img.height, 'bytes' => img.write_to_memory, 'colorSpace' => color_space}
|
|
4887
|
+
# The P3 rendering is best-effort: libvips is lazy, so an ICC fault surfaces only
|
|
4888
|
+
# here at sink evaluation — it must not break the already-rendered sRGB image, just
|
|
4889
|
+
# drop the wide-gamut variant (that image then won't preserve wide colours in a P3 canvas).
|
|
4890
|
+
if p3_img
|
|
4891
|
+
begin
|
|
4892
|
+
out['bytesP3'] = p3_img.write_to_memory
|
|
4893
|
+
rescue StandardError
|
|
4894
|
+
nil
|
|
4895
|
+
end
|
|
4896
|
+
end
|
|
4897
|
+
out
|
|
4898
|
+
end
|
|
4899
|
+
|
|
4900
|
+
# `decode_rgba` guarded. Outcomes:
|
|
4901
|
+
# Hash — a decoded {'width','height','bytes'} bitmap
|
|
4902
|
+
# :zero_size — a VALID image with a non-positive intrinsic dimension (rsvg refuses
|
|
4903
|
+
# to rasterize an SVG whose width|height is 0). Browsers still load it,
|
|
4904
|
+
# reporting a zero-area bitmap, so this is "available but empty"
|
|
4905
|
+
# (bad usability → createPattern null / drawImage no-op), NOT broken.
|
|
4906
|
+
# nil — an undecodable / corrupt body: a normal "broken image" outcome
|
|
4907
|
+
# (the caller fires `error` / rejects the ImageBitmap promise)
|
|
4908
|
+
# A Vips::Error is a quiet outcome, not stderr noise; genuine host faults (missing
|
|
4909
|
+
# libvips, OOM) still warn via `host_image_op`. The `bad dimensions` signal is the
|
|
4910
|
+
# rsvg loader's own diagnostic for a non-positive canvas (libvips-version-coupled
|
|
4911
|
+
# text; a corrupt raster / malformed SVG raises a different, non-matching message);
|
|
4912
|
+
# it also covers a fully DIMENSIONLESS SVG that a browser would instead render at
|
|
4913
|
+
# the 300×150 CSS default — a bounded, documented divergence, still a net
|
|
4914
|
+
# improvement over the old nil→broken→InvalidStateError.
|
|
4915
|
+
private def decode_or_nil(bytes, max_w = nil, max_h = nil)
|
|
4916
|
+
require 'vips' unless defined?(Vips)
|
|
4917
|
+
decode_rgba(bytes, max_w, max_h)
|
|
4918
|
+
rescue Vips::Error => e
|
|
4919
|
+
e.message.include?('bad dimensions') ? :zero_size : nil
|
|
4920
|
+
rescue LoadError, StandardError => e
|
|
4921
|
+
warn "[capybara-simulated] image decode failed: #{e.class}: #{e.message[0, 200]}"
|
|
4922
|
+
nil
|
|
3713
4923
|
end
|
|
3714
4924
|
|
|
3715
4925
|
private def host_image_op(name)
|
|
@@ -3719,6 +4929,346 @@ module Capybara
|
|
|
3719
4929
|
nil
|
|
3720
4930
|
end
|
|
3721
4931
|
|
|
4932
|
+
# Render a line of text to a coverage mask via libvips (pango / fontconfig),
|
|
4933
|
+
# backing the canvas `fillText` / `measureText` surface with real system-font
|
|
4934
|
+
# glyphs and metrics — no bundled font, so any installed family works. `font`
|
|
4935
|
+
# is a pango font string ("Sans Bold 16"); at dpi 72 the point size equals CSS
|
|
4936
|
+
# px. Returns `{width, height, xoffset, yoffset, ascent, descent[, refId]}`:
|
|
4937
|
+
# the image is cropped to the INK box, and (xoffset, yoffset) locate that box
|
|
4938
|
+
# within the logical layout, so the JS side can place the alphabetic baseline.
|
|
4939
|
+
# `measure_only` skips rasterizing the mask (the lazy image already knows its
|
|
4940
|
+
# dimensions) — the cheap path for `measureText`.
|
|
4941
|
+
def render_text(text, font, measure_only = false, font_url = nil, kerning = nil)
|
|
4942
|
+
host_image_op('render_text') {
|
|
4943
|
+
require 'vips' unless defined?(Vips)
|
|
4944
|
+
pango = font.to_s.empty? ? 'Sans 10' : font.to_s
|
|
4945
|
+
fontfile = font_url && !font_url.to_s.empty? ? font_file_for(font_url) : nil
|
|
4946
|
+
# Ascent/descent are properties of the FONT, not the variant: probe them with a
|
|
4947
|
+
# small-caps-stripped description so the descender probe ('gjpqy') isn't rendered
|
|
4948
|
+
# as (descenderless) small capitals, which would collapse the reported descent.
|
|
4949
|
+
asc, desc = font_vmetrics(pango.sub(/ Small-Caps\b/i, ''), fontfile)
|
|
4950
|
+
# NUL takes up no space and would abort the pango render; drop it so a lone
|
|
4951
|
+
# "\0" measures/draws as empty rather than falling back to a fabricated width.
|
|
4952
|
+
str = text.to_s.delete("\u0000")
|
|
4953
|
+
return {'width' => 0, 'advance' => 0, 'height' => 0, 'xoffset' => 0, 'yoffset' => 0, 'ascent' => asc, 'descent' => desc} if str.empty?
|
|
4954
|
+
|
|
4955
|
+
# `Vips::Image.text` parses Pango markup — canvas text is always literal,
|
|
4956
|
+
# so escape the markup metacharacters (an unescaped `&`/`<` would raise,
|
|
4957
|
+
# silently dropping the text; `<b>…` would wrongly render as bold).
|
|
4958
|
+
markup = str.gsub('&', '&').gsub('<', '<').gsub('>', '>')
|
|
4959
|
+
# `fontKerning = 'none'` disables the OpenType `kern` feature via a Pango markup
|
|
4960
|
+
# span, so a system font's rendered advance widens to the un-kerned width. ('auto'
|
|
4961
|
+
# / 'normal' leave pango's default kerning on.) A downloaded font's advance comes
|
|
4962
|
+
# from hmtx and is unaffected.
|
|
4963
|
+
markup = %(<span font_features="kern=0">#{markup}</span>) if kerning == 'none'
|
|
4964
|
+
# Render the whole line at its natural width; the caller condenses it
|
|
4965
|
+
# horizontally to honor canvas maxWidth (pango `width:` would word-WRAP,
|
|
4966
|
+
# which the canvas text algorithm never does). An @font-face family loads its
|
|
4967
|
+
# own font via `fontfile:` so pango resolves it (vips needs fontconfig support).
|
|
4968
|
+
img = text_image(markup, pango, fontfile)
|
|
4969
|
+
# `width` is the INK width (vips crops to it); `advance` is the pen movement
|
|
4970
|
+
# (`measureText().width`), which a downloaded font's hmtx gives exactly and
|
|
4971
|
+
# which falls back to the ink width for a system font we can't parse — or for a
|
|
4972
|
+
# string whose codepoints the (BMP) cmap doesn't map yet still renders ink
|
|
4973
|
+
# (astral / symbol-cmap), where a computed 0 advance would be wrong.
|
|
4974
|
+
adv = fontfile && font_advance_px(pango, fontfile, str)
|
|
4975
|
+
advance = adv && adv > 0 ? adv : img.width
|
|
4976
|
+
em_asc, em_desc = fontfile ? font_em_vmetrics(pango, fontfile) : nil
|
|
4977
|
+
res = {
|
|
4978
|
+
'width' => img.width,
|
|
4979
|
+
'advance' => advance,
|
|
4980
|
+
'height' => img.height,
|
|
4981
|
+
'xoffset' => img.get('xoffset'),
|
|
4982
|
+
'yoffset' => img.get('yoffset'),
|
|
4983
|
+
'ascent' => asc,
|
|
4984
|
+
'descent' => desc,
|
|
4985
|
+
'emAscent' => em_asc || asc,
|
|
4986
|
+
'emDescent' => em_desc || desc
|
|
4987
|
+
}
|
|
4988
|
+
res.merge!(font_base_metrics(pango, fontfile) || {}) if fontfile # BASE-table baselines, when present
|
|
4989
|
+
unless measure_only
|
|
4990
|
+
img = img.cast('uchar') unless img.format == :uchar
|
|
4991
|
+
res['refId'] = transfer_buffer_stash(img.write_to_memory)
|
|
4992
|
+
end
|
|
4993
|
+
res
|
|
4994
|
+
}
|
|
4995
|
+
end
|
|
4996
|
+
|
|
4997
|
+
# `Vips::Image.text` with the optional `fontfile:` (an @font-face's downloaded
|
|
4998
|
+
# font), which pango loads so it can resolve that family. Passing a nil fontfile
|
|
4999
|
+
# would raise, so branch — a system-font family resolves through fontconfig.
|
|
5000
|
+
private def text_image(markup, pango, fontfile)
|
|
5001
|
+
if fontfile
|
|
5002
|
+
Vips::Image.text(markup, font: pango, fontfile: fontfile, dpi: 72)
|
|
5003
|
+
else
|
|
5004
|
+
Vips::Image.text(markup, font: pango, dpi: 72)
|
|
5005
|
+
end
|
|
5006
|
+
end
|
|
5007
|
+
|
|
5008
|
+
# Ascent (baseline offset from the logical top) and descent for a pango font,
|
|
5009
|
+
# probed once and cached. A no-descender cap/ascender string's ink bottom is
|
|
5010
|
+
# the baseline (ascent); a descender string's ink bottom minus that is the
|
|
5011
|
+
# descent. dpi 72 keeps units in CSS px. `fontfile` (an @font-face font) is part
|
|
5012
|
+
# of the cache key so a downloaded family's metrics don't collide with a system one.
|
|
5013
|
+
private def font_vmetrics(pango, fontfile = nil)
|
|
5014
|
+
key = fontfile ? "#{pango}\0#{fontfile}" : pango
|
|
5015
|
+
cached = @font_vmetrics_lock.synchronize { @font_vmetrics[key] }
|
|
5016
|
+
return cached if cached
|
|
5017
|
+
|
|
5018
|
+
# A downloaded @font-face carries its own typographic metrics, and pango lays
|
|
5019
|
+
# it out on those, so the baseline (ascent) comes from the font's OS/2 typo
|
|
5020
|
+
# ascender/descender scaled to the pixel size — the ink-string heuristic below
|
|
5021
|
+
# is only a fallback for the ambient system font, whose file we don't have.
|
|
5022
|
+
asc, desc = font_typo_vmetrics(pango, fontfile) if fontfile
|
|
5023
|
+
unless asc
|
|
5024
|
+
asc = begin
|
|
5025
|
+
r = text_image('Mbdfhklt', pango, fontfile)
|
|
5026
|
+
r.get('yoffset') + r.height
|
|
5027
|
+
rescue StandardError
|
|
5028
|
+
10
|
|
5029
|
+
end
|
|
5030
|
+
desc = begin
|
|
5031
|
+
r = text_image('gjpqy', pango, fontfile)
|
|
5032
|
+
[(r.get('yoffset') + r.height) - asc, 0].max
|
|
5033
|
+
rescue StandardError
|
|
5034
|
+
(asc * 0.25).round
|
|
5035
|
+
end
|
|
5036
|
+
end
|
|
5037
|
+
@font_vmetrics_lock.synchronize { @font_vmetrics[key] ||= [asc, desc] }
|
|
5038
|
+
end
|
|
5039
|
+
|
|
5040
|
+
# [ascent, descent] in px from a font file's OS/2 typographic metrics scaled to
|
|
5041
|
+
# the pango string's point size (dpi 72 → px), or nil if it can't be read. This is
|
|
5042
|
+
# the FONT bounding box / baseline value (fontBoundingBoxAscent), typo-ascender
|
|
5043
|
+
# over unitsPerEm.
|
|
5044
|
+
private def font_typo_vmetrics(pango, fontfile)
|
|
5045
|
+
m = font_typo_units(fontfile) or return nil
|
|
5046
|
+
upm, ta, td = m
|
|
5047
|
+
size = font_size_of(pango)
|
|
5048
|
+
[(ta * size / upm).round, [(-td * size / upm).round, 0].max]
|
|
5049
|
+
end
|
|
5050
|
+
|
|
5051
|
+
# [emHeightAscent, emHeightDescent] in px: the em square (= font size) split by the
|
|
5052
|
+
# baseline at the typo ascender:descender ratio — NOT normalized by unitsPerEm, so a
|
|
5053
|
+
# font whose ascender+descender ≠ em still fills the em (e.g. descent-0 → all ascent).
|
|
5054
|
+
private def font_em_vmetrics(pango, fontfile)
|
|
5055
|
+
m = font_typo_units(fontfile) or return nil
|
|
5056
|
+
_upm, ta, td = m
|
|
5057
|
+
span = ta + (-td)
|
|
5058
|
+
return nil unless span.positive?
|
|
5059
|
+
size = font_size_of(pango)
|
|
5060
|
+
ea = size * ta / span.to_f
|
|
5061
|
+
[ea.round, (size - ea).round]
|
|
5062
|
+
end
|
|
5063
|
+
|
|
5064
|
+
# The point size in a pango font string ("CanvasTest 40" → 40); 10 as a fallback.
|
|
5065
|
+
# The size is a whitespace-separated trailing token, so a family that itself ends
|
|
5066
|
+
# in a digit ("B612") without a size isn't misread as one.
|
|
5067
|
+
private def font_size_of(pango)
|
|
5068
|
+
size = pango.to_s[/\s(\d+(?:\.\d+)?)\s*\z/, 1].to_f
|
|
5069
|
+
size.positive? ? size : 10.0
|
|
5070
|
+
end
|
|
5071
|
+
|
|
5072
|
+
# [unitsPerEm, sTypoAscender, sTypoDescender] from a TrueType/OpenType file's
|
|
5073
|
+
# `head` + `OS/2` tables, or nil.
|
|
5074
|
+
private def font_typo_units(fontfile)
|
|
5075
|
+
g = font_glyph_data(fontfile) or return nil
|
|
5076
|
+
[g[:upm], g[:typo_asc], g[:typo_desc]]
|
|
5077
|
+
end
|
|
5078
|
+
|
|
5079
|
+
# The advance width (pen movement) of `text` in the font, in px at the pango
|
|
5080
|
+
# string's size — the value `measureText().width` reports. vips crops to ink, so
|
|
5081
|
+
# the advance (which includes side bearings) comes from the font's own hmtx table.
|
|
5082
|
+
# nil if the font can't be parsed.
|
|
5083
|
+
private def font_advance_px(pango, fontfile, text)
|
|
5084
|
+
g = font_glyph_data(fontfile) or return nil
|
|
5085
|
+
size = font_size_of(pango)
|
|
5086
|
+
units = text.to_s.each_char.sum do |ch|
|
|
5087
|
+
gid = g[:cmap][ch.ord]
|
|
5088
|
+
next 0 unless gid # a codepoint the font doesn't map (null, control) advances nothing
|
|
5089
|
+
g[:advances][gid] || g[:advances].last || 0
|
|
5090
|
+
end
|
|
5091
|
+
units * size / g[:upm]
|
|
5092
|
+
end
|
|
5093
|
+
|
|
5094
|
+
# Parse the glyph tables a canvas text metric needs out of a TrueType/OpenType
|
|
5095
|
+
# file, memoized per path: unitsPerEm + typo metrics (head / OS/2), the per-glyph
|
|
5096
|
+
# advance widths (hmtx), and a Unicode → glyph-id map (cmap format 4). nil when a
|
|
5097
|
+
# required table is missing or malformed.
|
|
5098
|
+
private def font_glyph_data(fontfile)
|
|
5099
|
+
(@font_glyph ||= {})
|
|
5100
|
+
return @font_glyph[fontfile] if @font_glyph.key?(fontfile)
|
|
5101
|
+
@font_glyph[fontfile] = parse_font_glyph_data(fontfile)
|
|
5102
|
+
end
|
|
5103
|
+
|
|
5104
|
+
private def parse_font_glyph_data(fontfile)
|
|
5105
|
+
data = File.binread(fontfile)
|
|
5106
|
+
n = data[4, 2].unpack1('n')
|
|
5107
|
+
tabs = {}
|
|
5108
|
+
12.step(12 + (n - 1) * 16, 16) { |o| tabs[data[o, 4]] = data[o + 8, 4].unpack1('N') }
|
|
5109
|
+
head = tabs['head']; os2 = tabs['OS/2']; hhea = tabs['hhea']; hmtx = tabs['hmtx']; cmap = tabs['cmap']
|
|
5110
|
+
return nil unless head && hhea && hmtx && cmap
|
|
5111
|
+
upm = data[head + 18, 2].unpack1('n')
|
|
5112
|
+
return nil unless upm.positive?
|
|
5113
|
+
num_h = data[hhea + 34, 2].unpack1('n')
|
|
5114
|
+
advances = (0...num_h).map { |i| data[hmtx + i * 4, 2].unpack1('n') }
|
|
5115
|
+
{
|
|
5116
|
+
upm: upm,
|
|
5117
|
+
typo_asc: s16(data[(os2 || hhea) + (os2 ? 68 : 4), 2]),
|
|
5118
|
+
typo_desc: s16(data[(os2 || hhea) + (os2 ? 70 : 6), 2]),
|
|
5119
|
+
advances: advances,
|
|
5120
|
+
# A malformed cmap shouldn't discard the (already-read) upm / advances / typo
|
|
5121
|
+
# metrics, so isolate its parse — an empty map just means advances fall back.
|
|
5122
|
+
cmap: (parse_cmap4(data, cmap) rescue {}),
|
|
5123
|
+
# Optional horizontal-baseline coordinates ({tag => font units}) from the `BASE`
|
|
5124
|
+
# table — the alphabetic / hanging / ideographic baselines measureText reports.
|
|
5125
|
+
base: (tabs['BASE'] ? (parse_base_table(data, tabs['BASE']) rescue {}) : {}),
|
|
5126
|
+
}
|
|
5127
|
+
rescue StandardError
|
|
5128
|
+
nil
|
|
5129
|
+
end
|
|
5130
|
+
|
|
5131
|
+
# Horizontal-axis baseline coordinates ({"hang"/"ideo"/"romn"/… => font units},
|
|
5132
|
+
# relative to the script's default baseline) from the first BASE-table script's
|
|
5133
|
+
# BaseValues. Empty when the table is absent or has no coordinates.
|
|
5134
|
+
private def parse_base_table(data, base_off)
|
|
5135
|
+
horiz_rel = data[base_off + 4, 2].unpack1('n') # horizAxisOffset (0 = none)
|
|
5136
|
+
return {} if horiz_rel.zero?
|
|
5137
|
+
horiz = base_off + horiz_rel
|
|
5138
|
+
tag_list = horiz + data[horiz, 2].unpack1('n') # baseTagList (rel. to axis)
|
|
5139
|
+
script_list = horiz + data[horiz + 2, 2].unpack1('n') # baseScriptList (rel. to axis)
|
|
5140
|
+
ntags = data[tag_list, 2].unpack1('n')
|
|
5141
|
+
nscript = data[script_list, 2].unpack1('n')
|
|
5142
|
+
# A count read past a truncated table is nil; `(0...nil)` is an ENDLESS range that
|
|
5143
|
+
# would loop forever (never raising, so the caller's `rescue {}` can't save it).
|
|
5144
|
+
return {} if ntags.nil? || nscript.nil? || nscript.zero?
|
|
5145
|
+
tags = (0...ntags).map { |i| data[tag_list + 2 + i * 4, 4] }
|
|
5146
|
+
# Prefer the DFLT / latn script's baselines; else the first record.
|
|
5147
|
+
recs = (0...nscript).map { |i| o = script_list + 2 + i * 6; [data[o, 4], script_list + data[o + 4, 2].unpack1('n')] }
|
|
5148
|
+
_tag, s_off = recs.find { |t, _| t == 'DFLT' || t == 'latn' } || recs.first
|
|
5149
|
+
bv_rel = data[s_off, 2].unpack1('n') # baseValuesOffset (0 = none)
|
|
5150
|
+
return {} if bv_rel.zero?
|
|
5151
|
+
bv = s_off + bv_rel
|
|
5152
|
+
ncoord = data[bv + 2, 2].unpack1('n')
|
|
5153
|
+
out = {}
|
|
5154
|
+
tags.each_with_index do |t, i|
|
|
5155
|
+
break if i >= ncoord
|
|
5156
|
+
co = bv + data[bv + 4 + i * 2, 2].unpack1('n')
|
|
5157
|
+
out[t] = s16(data[co + 2, 2]) if [1, 2, 3].include?(data[co, 2].unpack1('n')) # BaseCoord formats
|
|
5158
|
+
end
|
|
5159
|
+
out
|
|
5160
|
+
end
|
|
5161
|
+
|
|
5162
|
+
# The alphabetic / hanging / ideographic baselines (px at the pango size) from the
|
|
5163
|
+
# font's BASE table, or nil when the font has none — then the caller heuristically
|
|
5164
|
+
# derives them from the vertical metrics instead.
|
|
5165
|
+
private def font_base_metrics(pango, fontfile)
|
|
5166
|
+
g = font_glyph_data(fontfile) or return nil
|
|
5167
|
+
base = g[:base]
|
|
5168
|
+
return nil if base.nil? || base.empty?
|
|
5169
|
+
scale = font_size_of(pango) / g[:upm].to_f
|
|
5170
|
+
romn = base['romn'] || 0 # the alphabetic baseline = the reference
|
|
5171
|
+
{
|
|
5172
|
+
'alphabeticBaseline' => 0.0,
|
|
5173
|
+
'hangingBaseline' => ((base['hang'] || romn) - romn) * scale,
|
|
5174
|
+
'ideographicBaseline' => ((base['ideo'] || romn) - romn) * scale,
|
|
5175
|
+
}
|
|
5176
|
+
end
|
|
5177
|
+
|
|
5178
|
+
# A Unicode → glyph-id map from the first format-4 `cmap` subtable (the standard
|
|
5179
|
+
# BMP Unicode encoding), as {codepoint => glyph}. Empty when none is present.
|
|
5180
|
+
private def parse_cmap4(data, cmap)
|
|
5181
|
+
ntab = data[cmap + 2, 2].unpack1('n')
|
|
5182
|
+
return {} if ntab.nil? # a count read past a truncated table → don't loop `(0...nil)` forever
|
|
5183
|
+
# Prefer a Unicode BMP subtable — (3,1) Windows Unicode or (0,*) Unicode — over a
|
|
5184
|
+
# (3,0) Symbol map (which shadows ASCII into the 0xF000 PUA); fall back to any
|
|
5185
|
+
# format-4 table only if no Unicode one is present.
|
|
5186
|
+
best = nil; best_rank = -1
|
|
5187
|
+
(0...ntab).each do |i|
|
|
5188
|
+
rec = cmap + 4 + i * 8
|
|
5189
|
+
pid = data[rec, 2].unpack1('n'); eid = data[rec + 2, 2].unpack1('n')
|
|
5190
|
+
off = data[rec + 4, 4].unpack1('N')
|
|
5191
|
+
next unless data[cmap + off, 2].unpack1('n') == 4
|
|
5192
|
+
rank = pid == 3 && eid == 1 ? 3 : pid.zero? ? 2 : pid == 3 && eid.zero? ? 0 : 1
|
|
5193
|
+
if rank > best_rank then best_rank = rank; best = cmap + off end
|
|
5194
|
+
end
|
|
5195
|
+
sub = best
|
|
5196
|
+
return {} unless sub
|
|
5197
|
+
segx2 = data[sub + 6, 2].unpack1('n'); segc = segx2 / 2
|
|
5198
|
+
endc = sub + 14
|
|
5199
|
+
startc = endc + segx2 + 2
|
|
5200
|
+
iddelta = startc + segx2
|
|
5201
|
+
idrange = iddelta + segx2
|
|
5202
|
+
map = {}
|
|
5203
|
+
(0...segc).each do |s|
|
|
5204
|
+
e = data[endc + s * 2, 2].unpack1('n')
|
|
5205
|
+
st = data[startc + s * 2, 2].unpack1('n')
|
|
5206
|
+
delta = data[iddelta + s * 2, 2].unpack1('n')
|
|
5207
|
+
ro = data[idrange + s * 2, 2].unpack1('n')
|
|
5208
|
+
(st..e).each do |c|
|
|
5209
|
+
next if c == 0xFFFF
|
|
5210
|
+
gid = if ro.zero?
|
|
5211
|
+
(c + delta) & 0xFFFF
|
|
5212
|
+
else
|
|
5213
|
+
gi = idrange + s * 2 + ro + (c - st) * 2
|
|
5214
|
+
g = data[gi, 2].unpack1('n')
|
|
5215
|
+
g.zero? ? 0 : (g + delta) & 0xFFFF
|
|
5216
|
+
end
|
|
5217
|
+
map[c] = gid if gid != 0
|
|
5218
|
+
end
|
|
5219
|
+
end
|
|
5220
|
+
map
|
|
5221
|
+
end
|
|
5222
|
+
|
|
5223
|
+
# A big-endian signed 16-bit value.
|
|
5224
|
+
private def s16(bytes)
|
|
5225
|
+
v = bytes.unpack1('n')
|
|
5226
|
+
v >= 0x8000 ? v - 0x10000 : v
|
|
5227
|
+
end
|
|
5228
|
+
|
|
5229
|
+
# Resolve an @font-face src URL to an on-disk font file pango can load, fetching
|
|
5230
|
+
# the bytes through the Rack app (binary-safe) once and caching the temp path for
|
|
5231
|
+
# the process. Returns nil when the fetch fails.
|
|
5232
|
+
def font_file_for(url)
|
|
5233
|
+
key = resolve_against_current(url.to_s)
|
|
5234
|
+
return nil unless key.is_a?(String)
|
|
5235
|
+
@@font_file_lock.synchronize { return @@font_file_cache[key] if @@font_file_cache.key?(key) }
|
|
5236
|
+
path = build_font_file(key)
|
|
5237
|
+
@@font_file_lock.synchronize { @@font_file_cache[key] = path }
|
|
5238
|
+
path
|
|
5239
|
+
end
|
|
5240
|
+
|
|
5241
|
+
private def build_font_file(key)
|
|
5242
|
+
bytes = font_source_bytes(key)
|
|
5243
|
+
return nil unless bytes && !bytes.empty?
|
|
5244
|
+
require 'tempfile'
|
|
5245
|
+
# Keep the Tempfile object alive for the process so its file isn't reaped while
|
|
5246
|
+
# fontconfig may still read it; the cache holds the path.
|
|
5247
|
+
file = Tempfile.new(['csim-font', File.extname(key)[0, 5]])
|
|
5248
|
+
file.binmode
|
|
5249
|
+
file.write(bytes)
|
|
5250
|
+
file.flush
|
|
5251
|
+
# Pin the handle for the whole process: the path is cached in the CLASS-level
|
|
5252
|
+
# @@font_file_cache and outlives the per-visit Browser that first built it, so
|
|
5253
|
+
# the Tempfile must not be finalized (and its file unlinked) with that instance.
|
|
5254
|
+
@@font_file_lock.synchronize { @@font_files << file }
|
|
5255
|
+
file.path
|
|
5256
|
+
end
|
|
5257
|
+
|
|
5258
|
+
# Raw font bytes for a URL: `data:` inline, http(s) via a binary-safe Rack fetch
|
|
5259
|
+
# (the raw bytes ride `body_b64`; a text body would mangle the font's non-ASCII).
|
|
5260
|
+
private def font_source_bytes(key)
|
|
5261
|
+
if key.start_with?('data:')
|
|
5262
|
+
bytes = decode_data_url_body(key)
|
|
5263
|
+
bytes.empty? ? nil : bytes
|
|
5264
|
+
elsif key.match?(%r{\Ahttps?://}i)
|
|
5265
|
+
result = rack_fetch('GET', key, '', {}, 'follow')
|
|
5266
|
+
return nil unless result && result['status'].to_i < 400
|
|
5267
|
+
bytes = result['body_b64'] ? Base64.decode64(result['body_b64']) : result['body'].to_s.b
|
|
5268
|
+
bytes.empty? ? nil : bytes
|
|
5269
|
+
end
|
|
5270
|
+
end
|
|
5271
|
+
|
|
3722
5272
|
def reset_workers
|
|
3723
5273
|
@workers.each_value do |w|
|
|
3724
5274
|
w[:inbox] << :terminate
|
|
@@ -3726,7 +5276,21 @@ module Capybara
|
|
|
3726
5276
|
end
|
|
3727
5277
|
@workers.clear
|
|
3728
5278
|
@worker_outbox.clear
|
|
3729
|
-
@
|
|
5279
|
+
@worker_outbox_head = nil
|
|
5280
|
+
@worker_in_flight = 0
|
|
5281
|
+
@worker_broadcast_pending = 0
|
|
5282
|
+
@sw_message_pending = 0
|
|
5283
|
+
@sw_fetch_pending = 0
|
|
5284
|
+
@sw_open_streams.clear
|
|
5285
|
+
@sw_fetch_wait_deadline = nil
|
|
5286
|
+
@sw_msg_wait_deadline = nil
|
|
5287
|
+
@sw_registrations.clear
|
|
5288
|
+
@sw_navpreload = {}
|
|
5289
|
+
@sw_pending_claims = []
|
|
5290
|
+
@sw_clients = {}
|
|
5291
|
+
@sw_realm_controller = {}
|
|
5292
|
+
@port_channels = {}
|
|
5293
|
+
@sw_nav_outbox.clear
|
|
3730
5294
|
@transfer_buffer_lock.synchronize {
|
|
3731
5295
|
@transfer_buffers.clear
|
|
3732
5296
|
@transfer_buffer_seq = 0
|
|
@@ -3776,26 +5340,11 @@ module Capybara
|
|
|
3776
5340
|
# host's last two dot-labels — correct for the single-label public suffixes our
|
|
3777
5341
|
# in-process hosts use (web-platform.test / not-web-platform.test / *.com); a
|
|
3778
5342
|
# full Public Suffix List isn't warranted here.
|
|
5343
|
+
# This document's partition "site" (scheme + registrable domain) for Blob-URL / storage
|
|
5344
|
+
# partitioning. A blob: document derives from its inner origin; data:/about: (no host) →
|
|
5345
|
+
# '' (an opaque origin, never same-partition with a real one). See registrable_site.
|
|
3779
5346
|
def blob_partition_site
|
|
3780
|
-
|
|
3781
|
-
# `blob:https://host:port/uuid`) — strip the prefix so the site derives from
|
|
3782
|
-
# the inner origin, not the opaque blob: scheme. data:/about: have no host →
|
|
3783
|
-
# '' (an opaque origin, never same-partition with a real one).
|
|
3784
|
-
u = URI.parse(@current_url.to_s.sub(/\Ablob:/, ''))
|
|
3785
|
-
host = u.host.to_s
|
|
3786
|
-
return '' if host.empty?
|
|
3787
|
-
labels = host.split('.')
|
|
3788
|
-
# An IP literal (v4 dotted / v6 bracketed) or a ≤2-label host IS its own
|
|
3789
|
-
# registrable domain; otherwise approximate eTLD+1 as the last two labels
|
|
3790
|
-
# (no Public Suffix List — correct for the single-label TLDs our hosts use).
|
|
3791
|
-
regd = if host.start_with?('[') || host.match?(/\A\d+(\.\d+){3}\z/) || labels.length <= 2
|
|
3792
|
-
host
|
|
3793
|
-
else
|
|
3794
|
-
labels.last(2).join('.')
|
|
3795
|
-
end
|
|
3796
|
-
"#{u.scheme}://#{regd}"
|
|
3797
|
-
rescue URI::Error
|
|
3798
|
-
''
|
|
5347
|
+
registrable_site(@current_url) || ''
|
|
3799
5348
|
end
|
|
3800
5349
|
|
|
3801
5350
|
# WHATWG URL "domain to ASCII" — the JS tr46 stub delegates non-ASCII / xn--
|
|
@@ -3978,7 +5527,9 @@ module Capybara
|
|
|
3978
5527
|
# Called from the JS bridge when a `<video>` element's `src` is
|
|
3979
5528
|
# assigned a `blob:` URL. ffprobe extracts dimensions + duration,
|
|
3980
5529
|
# ffmpeg extracts the first frame as raw RGBA. JS caches both so
|
|
3981
|
-
# `canvas.drawImage(video, …)` blits like any ImageBitmap.
|
|
5530
|
+
# `canvas.drawImage(video, …)` blits like any ImageBitmap. A `<video src>` that
|
|
5531
|
+
# points at a served file (http / relative) or a `data:` URL resolves its bytes
|
|
5532
|
+
# the same way — see `video_bytes_b64` below.
|
|
3982
5533
|
def decode_video_frame(b64_bytes)
|
|
3983
5534
|
host_image_op('decode_video_frame') {
|
|
3984
5535
|
bytes = Base64.decode64(b64_bytes.to_s)
|
|
@@ -4001,6 +5552,22 @@ module Capybara
|
|
|
4001
5552
|
}
|
|
4002
5553
|
end
|
|
4003
5554
|
|
|
5555
|
+
# Fetch a media resource (http / relative URL) and return its bytes base64-encoded,
|
|
5556
|
+
# so a `<video src>` pointing at a served file decodes the same way a blob: / data:
|
|
5557
|
+
# source does. Binary stays Ruby-side; only ASCII base64 crosses into V8. Returns
|
|
5558
|
+
# nil when the fetch fails.
|
|
5559
|
+
def video_bytes_b64(url)
|
|
5560
|
+
result = rack_fetch('GET', url, '', {}, 'follow')
|
|
5561
|
+
return nil unless result && result['status'].to_i < 400
|
|
5562
|
+
# The RAW bytes ride `body_b64` (see response_hash) for any non-ASCII response;
|
|
5563
|
+
# the text `body` field is a UTF-8 re-decode that corrupts binary media. Fall
|
|
5564
|
+
# back to base64-of-body only for a pure-ASCII response (byte-identical there).
|
|
5565
|
+
b64 = result['body_b64']
|
|
5566
|
+
return b64 unless b64.nil? || b64.empty?
|
|
5567
|
+
body = result['body'].to_s
|
|
5568
|
+
body.empty? ? nil : [body].pack('m0')
|
|
5569
|
+
end
|
|
5570
|
+
|
|
4004
5571
|
private def ffprobe_stream(path)
|
|
4005
5572
|
json = IO.popen(
|
|
4006
5573
|
['ffprobe', '-v', 'error', '-select_streams', 'v:0',
|
|
@@ -4071,7 +5638,7 @@ module Capybara
|
|
|
4071
5638
|
# `build_worker` factory, evaluates the worker script, then
|
|
4072
5639
|
# loops draining microtasks + timers + inbox until `:terminate`
|
|
4073
5640
|
# lands or an exception propagates.
|
|
4074
|
-
private def run_worker(handle, url, body, inbox, outbox, engine_class, shared: false, service: false)
|
|
5641
|
+
private def run_worker(handle, url, body, inbox, outbox, engine_class, shared: false, service: false, creator_key: nil)
|
|
4075
5642
|
# Release the spawn-time `@worker_initializing` count exactly once, however
|
|
4076
5643
|
# this method exits (normal start, `self.close()`, or an exception), so
|
|
4077
5644
|
# worker_pending? doesn't stay stuck true forever.
|
|
@@ -4081,6 +5648,11 @@ module Capybara
|
|
|
4081
5648
|
initializing = false
|
|
4082
5649
|
@worker_init_lock.synchronize { @worker_initializing -= 1 }
|
|
4083
5650
|
end
|
|
5651
|
+
# True while THIS thread holds a `@worker_busy` increment for a message it's
|
|
5652
|
+
# mid-handling; balanced in the `ensure` so an exception between the bump and
|
|
5653
|
+
# the matching decrement can't strand the counter (which would pin
|
|
5654
|
+
# `worker_pending?` true forever).
|
|
5655
|
+
busy_held = false
|
|
4084
5656
|
# Tag this thread so blob URLs created by the worker's script are owned by
|
|
4085
5657
|
# this handle and revoked on terminate (see blob_register / revoke_worker_blobs).
|
|
4086
5658
|
Thread.current[:csim_worker_handle] = handle
|
|
@@ -4090,17 +5662,83 @@ module Capybara
|
|
|
4090
5662
|
# BINARY-tagged (see `RuntimeShared.utf8_text`).
|
|
4091
5663
|
body = RuntimeShared.utf8_text(body)
|
|
4092
5664
|
post_back = ->(data) { outbox << {handle: handle, kind: 'message', data: data.to_s} }
|
|
4093
|
-
|
|
5665
|
+
# A worker's BroadcastChannel post rides the same thread-safe outbox; the main thread fans
|
|
5666
|
+
# it out to the main + frame realms + OTHER workers (see deliver_worker_messages).
|
|
5667
|
+
broadcast_out = ->(name, data, origin) { outbox << {handle: handle, kind: 'broadcast', name: name.to_s, data: data, origin: origin} }
|
|
5668
|
+
# Service-worker → main-thread signals ride the outbox (delivered by deliver_worker_messages):
|
|
5669
|
+
# client.postMessage, clients.claim (→ set the client's controller), and a controlled fetch's
|
|
5670
|
+
# respondWith result. `sw_has_fetch` is snapshotted after the SW script's initial run (the
|
|
5671
|
+
# spec records fetch-handler presence at install time) so a claim can tell the client
|
|
5672
|
+
# whether intercepting is worth the cross-isolate round-trip at all.
|
|
5673
|
+
sw_has_fetch = false
|
|
5674
|
+
sw_hooks = {
|
|
5675
|
+
post_to_client: ->(client_id, data) { outbox << {handle: handle, kind: 'sw_client_msg', client: client_id, data: data.to_s} },
|
|
5676
|
+
claim: -> { outbox << {handle: handle, kind: 'sw_claim', has_fetch: sw_has_fetch} },
|
|
5677
|
+
fetch_respond: ->(fetch_id, resp, realm_id) { sw_deliver_fetch_response(handle, fetch_id.to_i, resp.to_s, outbox, realm_id.to_i) },
|
|
5678
|
+
# A streaming respondWith frame (start / chunk / close / error) for a controlled client's
|
|
5679
|
+
# fetch — rides the outbox in emission order so the client realm reassembles the body
|
|
5680
|
+
# stream incrementally (deliver_worker_messages). The request's @sw_fetch_pending stays up
|
|
5681
|
+
# for the whole stream and clears on the terminal (close / error) frame.
|
|
5682
|
+
fetch_stream: ->(fetch_id, kind, payload, realm_id) { outbox << {handle: handle, kind: "fr_#{kind}", fetch_id: fetch_id.to_i, payload: payload.to_s, realm_id: realm_id.to_i} },
|
|
5683
|
+
# Cross-isolate MessagePort channel: this worker's port endpoint + its outbound messages
|
|
5684
|
+
# ride the outbox (delivered by deliver_worker_messages → the peer client realm).
|
|
5685
|
+
port_endpoint: ->(channel) { outbox << {handle: handle, kind: 'port_endpoint', channel: channel.to_s} },
|
|
5686
|
+
port_post: ->(channel, data) { outbox << {handle: handle, kind: 'port_msg', channel: channel.to_s, data: data.to_s} }
|
|
5687
|
+
}
|
|
5688
|
+
rt = engine_class.build_worker(self, post_back, broadcast_out, sw_hooks)
|
|
5689
|
+
# A worker isolate loads the same snapshot as the main realm, so its `console.*`
|
|
5690
|
+
# is a no-op until `traceActive` is set (console.js). The main realm turns it on
|
|
5691
|
+
# from `CSIM_CONSOLE_STDERR`; without this a worker's console — and anything routed
|
|
5692
|
+
# through it, e.g. an Emscripten `printErr` — is silently dropped during debugging.
|
|
5693
|
+
rt.call('__csimSetTraceActive', true) if CONSOLE_STDERR
|
|
5694
|
+
# This worker's handle — the JS keys cross-isolate MessagePort channel ids on it so they
|
|
5695
|
+
# never collide with another isolate's (see __csim_installWorkerScope's allocator).
|
|
5696
|
+
rt.eval("globalThis.__csimWorkerHandle = #{handle.to_i};")
|
|
4094
5697
|
# Set the worker's `self.location.href` so webpack /
|
|
4095
5698
|
# rollup public-path derivation + `new URL(rel, import.meta.url)`
|
|
4096
5699
|
# resolve chunks against the worker's own origin rather than
|
|
4097
5700
|
# the snapshot-time `http://placeholder/`.
|
|
4098
5701
|
rt.eval("globalThis.__csimUpdateLocation(#{JSON.generate(url.to_s)});")
|
|
5702
|
+
# A worker's BroadcastChannel origin KEY (its agent-cluster identity). A blob: worker
|
|
5703
|
+
# INHERITS the creating context's origin (the blob URL carries no real origin of its own);
|
|
5704
|
+
# a data: worker gets a FRESH opaque origin, unique per worker, so it never cross-talks with
|
|
5705
|
+
# its creator or a sibling data: worker. An http(s) worker leaves this unset — the JS derives
|
|
5706
|
+
# its key from `location.origin` (the script's own origin, same as the creator when
|
|
5707
|
+
# same-origin). See `__csimBcOriginKey`.
|
|
5708
|
+
worker_origin_key =
|
|
5709
|
+
if url.to_s.start_with?('blob:')
|
|
5710
|
+
creator_key
|
|
5711
|
+
elsif url.to_s.start_with?('data:')
|
|
5712
|
+
"opaque:worker#{handle}"
|
|
5713
|
+
end
|
|
5714
|
+
rt.eval("globalThis.__csimOriginKey = #{JSON.generate(worker_origin_key)};") if worker_origin_key
|
|
4099
5715
|
# A service worker runs in a ServiceWorkerGlobalScope: adjust the worker scope
|
|
4100
5716
|
# (no blob-URL minting; SW lifecycle stubs) BEFORE its script runs.
|
|
4101
5717
|
rt.eval('__csim_installServiceWorkerScope();') if service
|
|
4102
5718
|
rt.eval(body)
|
|
4103
5719
|
rt.drain_microtasks
|
|
5720
|
+
# Drive the service worker's lifecycle: fire `install`, then `activate`, draining each
|
|
5721
|
+
# phase's `waitUntil` promises before the next (the client-side ServiceWorker object plays
|
|
5722
|
+
# out the observable installing→installed→activating→activated timeline; here we run the
|
|
5723
|
+
# worker's OWN install/activate handlers so their side effects — caching, importScripts —
|
|
5724
|
+
# execute). See sw-client.js + __csim_swFireLifecycleEvent.
|
|
5725
|
+
if service
|
|
5726
|
+
sw_has_fetch = !!rt.call('__csim_swHasFetchListener')
|
|
5727
|
+
# Publish the fetch-handler snapshot + script URL on the worker record so a NAVIGATION
|
|
5728
|
+
# into this SW's scope can decide (Ruby-side) whether routing through it is worthwhile,
|
|
5729
|
+
# and a freshly-built frame client can mint a `controller` naming this script.
|
|
5730
|
+
if (w = @workers[handle])
|
|
5731
|
+
w[:has_fetch] = sw_has_fetch
|
|
5732
|
+
w[:script_url] = url.to_s
|
|
5733
|
+
end
|
|
5734
|
+
%w[install activate].each do |phase|
|
|
5735
|
+
rt.eval("globalThis.__csim_swFireLifecycleEvent(#{JSON.generate(phase)});")
|
|
5736
|
+
# Drain microtasks AND timers: a `waitUntil` promise may settle off a
|
|
5737
|
+
# setTimeout (e.g. a delayed cache warm-up), so advance the worker clock too.
|
|
5738
|
+
rt.drain_microtasks
|
|
5739
|
+
rt.drain_timers
|
|
5740
|
+
end
|
|
5741
|
+
end
|
|
4104
5742
|
# A SharedWorker fires `connect` AFTER its script set `self.onconnect`; the
|
|
4105
5743
|
# connect handler's port post lands in the outbox before release_init, so
|
|
4106
5744
|
# worker_pending? stays true until it's delivered.
|
|
@@ -4116,26 +5754,119 @@ module Capybara
|
|
|
4116
5754
|
loop do
|
|
4117
5755
|
msg = pop_with_timeout(inbox, WORKER_POLL_INTERVAL)
|
|
4118
5756
|
break if msg == :terminate
|
|
4119
|
-
|
|
4120
|
-
#
|
|
4121
|
-
#
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
5757
|
+
# A main-side BroadcastChannel post to this worker arrives as a {kind:'broadcast'} hash;
|
|
5758
|
+
# deliver it to the worker's channels (the receiver's own origin gate drops cross-origin).
|
|
5759
|
+
# A plain string is a postMessage to the worker.
|
|
5760
|
+
if msg.is_a?(Hash) && msg[:kind] == 'broadcast'
|
|
5761
|
+
# Ack so the main thread releases the broadcast-pending it counted for this delivery
|
|
5762
|
+
# (a listen-only worker never posts back — the ack is the only signal it processed
|
|
5763
|
+
# it). Under `ensure`: the ack is CONTRACTUAL (settle's bounded wait relies on it —
|
|
5764
|
+
# see worker_reply_pending?), so a raising channel handler must not leak it.
|
|
5765
|
+
begin
|
|
5766
|
+
rt.call('__csim_deliverBroadcasts', [{'name' => msg[:name], 'data' => msg[:data], 'origin' => msg[:origin]}])
|
|
5767
|
+
ensure
|
|
5768
|
+
outbox << {handle: handle, kind: 'bcack'}
|
|
5769
|
+
end
|
|
5770
|
+
elsif msg.is_a?(Hash) && msg[:kind] == 'sw_message'
|
|
5771
|
+
# A client → service-worker postMessage: dispatch a `message` event with source = the
|
|
5772
|
+
# posting client. Ack AFTER the dispatch — the outbox is FIFO, so a handler's
|
|
5773
|
+
# synchronous `client.postMessage` reply (`sw_client_msg`) is guaranteed to precede
|
|
5774
|
+
# the `swack`; acking first opens a window where the main thread sees the pending
|
|
5775
|
+
# count hit zero (worker_pending? false) while the handler is still running, and the
|
|
5776
|
+
# virtual clock fast-forwards past the caller's timeout before the reply lands. The
|
|
5777
|
+
# `ensure` keeps a raising handler from leaking the counter and hanging settle.
|
|
5778
|
+
begin
|
|
5779
|
+
rt.call('__csim_swClientMessage', msg[:data], msg[:client], msg[:url])
|
|
5780
|
+
ensure
|
|
5781
|
+
outbox << {handle: handle, kind: 'swack'}
|
|
5782
|
+
end
|
|
5783
|
+
elsif msg.is_a?(Hash) && msg[:kind] == 'port_msg'
|
|
5784
|
+
# A client-realm port → its remote peer in THIS worker: deliver to the channel endpoint
|
|
5785
|
+
# port. Counted like an sw_message (client_port_post incremented @sw_message_pending), so
|
|
5786
|
+
# ack AFTER dispatch under `ensure` — a synchronous reply the port handler posts back
|
|
5787
|
+
# (another port_msg on the outbox) is FIFO-guaranteed to precede this swack.
|
|
5788
|
+
begin
|
|
5789
|
+
rt.call('__csimPortChannelDeliver', msg[:channel], msg[:data])
|
|
5790
|
+
ensure
|
|
5791
|
+
outbox << {handle: handle, kind: 'swack'}
|
|
5792
|
+
end
|
|
5793
|
+
elsif msg.is_a?(Hash) && msg[:kind] == 'client_register'
|
|
5794
|
+
# A controlled client (frame/window realm) came into existence: mirror it into the
|
|
5795
|
+
# SW's clientsById so matchAll/getClientByURL see it. Fire-and-forget (no reply /
|
|
5796
|
+
# pending counter): the inbox is FIFO, so it's processed before any later message
|
|
5797
|
+
# whose handler matchAll's the client.
|
|
5798
|
+
rt.call('__csim_swRegisterClient', msg[:client])
|
|
5799
|
+
elsif msg.is_a?(Hash) && msg[:kind] == 'client_unregister'
|
|
5800
|
+
# The client's realm was disposed — drop it so matchAll stops returning a dead client.
|
|
5801
|
+
rt.call('__csim_swUnregisterClient', msg[:id])
|
|
5802
|
+
elsif msg.is_a?(Hash) && msg[:kind] == 'fetch'
|
|
5803
|
+
# A controlled client's fetch: dispatch a `fetch` event. The SW's respondWith result
|
|
5804
|
+
# (or a fall-through / network-error marker) is posted back as a `fetch_response`
|
|
5805
|
+
# outbox event, which releases the @sw_fetch_pending counted for this request. A
|
|
5806
|
+
# synchronous respondWith posts during the dispatch; an async one posts under the
|
|
5807
|
+
# drain. If the dispatch itself dies (engine raise, Thread#kill) the JS side can
|
|
5808
|
+
# never post — fall the client back to the network so the counter drains (a
|
|
5809
|
+
# duplicate response is harmless: the client's pendingFetch entry is one-shot).
|
|
5810
|
+
dispatched = false
|
|
5811
|
+
begin
|
|
5812
|
+
rt.call('__csim_swDispatchFetch', msg[:req], msg[:fetch_id], msg[:realm_id])
|
|
5813
|
+
dispatched = true
|
|
5814
|
+
ensure
|
|
5815
|
+
sw_deliver_fetch_response(handle, msg[:fetch_id].to_i, '{"fallthrough":true}', outbox, msg[:realm_id].to_i) unless dispatched
|
|
5816
|
+
end
|
|
5817
|
+
elsif msg.is_a?(Hash) && msg[:kind] == 'fetch_cancel'
|
|
5818
|
+
# The client cancelled this streaming respondWith body — cancel the reader
|
|
5819
|
+
# streamDeliver is draining, firing the SW source stream's `cancel()`. Fire-and-forget:
|
|
5820
|
+
# the reader's cancellation resolves its read as done, emitting the terminal frame that
|
|
5821
|
+
# clears @sw_fetch_pending; no ack/counter of its own.
|
|
5822
|
+
rt.call('__csim_swStreamCancel', msg[:fetch_id])
|
|
5823
|
+
elsif msg
|
|
5824
|
+
# Mark BUSY for the whole span of this postMessage handler. Unlike the SW /
|
|
5825
|
+
# broadcast branches above (each tracked by its own pending counter),
|
|
5826
|
+
# `@worker_in_flight` under-counts a multi-reply handshake to 0 mid-flight (one
|
|
5827
|
+
# request → many progress replies + a final resolve), so `worker_pending?` would
|
|
5828
|
+
# go false while the worker is still working and settle would abandon the
|
|
5829
|
+
# protocol between replies. Keep it true until the handler returns.
|
|
5830
|
+
@worker_init_lock.synchronize { @worker_busy += 1 }
|
|
5831
|
+
busy_held = true
|
|
5832
|
+
# A plain worker postMessage handler can start a multi-stage async bring-up
|
|
5833
|
+
# that alternates microtasks and timers — most sharply Emscripten's WASM
|
|
5834
|
+
# runtime init (addRunDependency → read the binary on a setTimeout(0) →
|
|
5835
|
+
# WebAssembly.instantiate → removeRunDependency → run() → onRuntimeInitialized
|
|
5836
|
+
# → the module factory's `.then`). Draining once leaves the microtask layers a
|
|
5837
|
+
# fired timer queued *after the last timer* stranded, so the factory promise
|
|
5838
|
+
# never settles (Tesseract hangs at "initializing tesseract"). Run the worker's
|
|
5839
|
+
# own event loop to quiescence instead of a single gated tick.
|
|
5840
|
+
rt.call('__csim_workerOnMessage', msg)
|
|
5841
|
+
drive_worker_to_quiescence(rt)
|
|
5842
|
+
end
|
|
5843
|
+
# Drive the worker's OWN event loop each tick: an AUTONOMOUS loop (the dispatcher
|
|
5844
|
+
# executor-worker's receive→fetch→setTimeout retry, which has no inbox message)
|
|
5845
|
+
# may have pending timers. Drain ~one poll interval (WorkerRuntime#drain_timers
|
|
5846
|
+
# advances the worker clock a step) so they progress; worker http fetch is
|
|
5847
|
+
# setTimeout(0)+__rackFetch, resolved on this thread by the drain. Gated on a
|
|
5848
|
+
# PENDING timer (any, not just due-now — the clock must advance to fire a future
|
|
5849
|
+
# randomDelay) so an idle message-driven worker with no timers stays lazy. A
|
|
5850
|
+
# regular postMessage already drove itself to quiescence above (no timer left),
|
|
5851
|
+
# so this is a no-op for it. Host CALLS, not string `eval`, keep the per-tick
|
|
5852
|
+
# cost off the V8 compile path (rule 3).
|
|
4129
5853
|
if rt.call('__nextTimerDelay').to_f >= 0
|
|
4130
5854
|
rt.drain_microtasks
|
|
4131
5855
|
rt.drain_timers
|
|
4132
5856
|
end
|
|
5857
|
+
if busy_held
|
|
5858
|
+
@worker_init_lock.synchronize { @worker_busy -= 1 }
|
|
5859
|
+
busy_held = false
|
|
5860
|
+
end
|
|
4133
5861
|
break if rt.call('__csimWorkerClosedRead')
|
|
4134
5862
|
end
|
|
4135
5863
|
end
|
|
4136
5864
|
rescue StandardError => e
|
|
4137
5865
|
outbox << {handle: handle, kind: '__error', message: "#{e.class}: #{e.message}"}
|
|
4138
5866
|
ensure
|
|
5867
|
+
# A raise between the busy bump and its matching decrement would strand the
|
|
5868
|
+
# counter; balance it here so worker_pending? can't stick true after this thread dies.
|
|
5869
|
+
@worker_init_lock.synchronize { @worker_busy -= 1 } if busy_held
|
|
4139
5870
|
release_init.call # guarantee the init count is released on an early raise
|
|
4140
5871
|
rt&.dispose
|
|
4141
5872
|
end
|
|
@@ -4156,7 +5887,12 @@ module Capybara
|
|
|
4156
5887
|
return data[:bytes]
|
|
4157
5888
|
end
|
|
4158
5889
|
b64 = @runtime.call('__csimReadBlobBase64', u)
|
|
4159
|
-
|
|
5890
|
+
# A blob created INSIDE a frame realm lives in that realm's in-VM store, which the main
|
|
5891
|
+
# runtime's `__csimReadBlobBase64` above can't see. But createObjectURL also registered its
|
|
5892
|
+
# bytes in the cross-realm `@blob_registry` (crossCtx, since a frame realm is multi-realm),
|
|
5893
|
+
# so fall back to it — this is what makes `new Worker(blobURL)` work from a data: iframe.
|
|
5894
|
+
b64 = blob_resolve(u) if b64.nil? || b64.to_s.empty?
|
|
5895
|
+
return nil if b64.nil? || b64.to_s.empty?
|
|
4160
5896
|
return Base64.decode64(b64.to_s)
|
|
4161
5897
|
end
|
|
4162
5898
|
# `data:[<mediatype>][;base64],<data>` worker scripts (a worker created
|
|
@@ -4180,6 +5916,24 @@ module Capybara
|
|
|
4180
5916
|
end
|
|
4181
5917
|
end
|
|
4182
5918
|
|
|
5919
|
+
# Run a worker isolate's own event loop until it goes idle: drain microtasks,
|
|
5920
|
+
# then — if a timer is pending — advance the worker clock to fire it and loop, so
|
|
5921
|
+
# the microtasks that timer's callback queues get drained in turn. A single
|
|
5922
|
+
# `drain_microtasks; drain_timers` pair strands whatever the last-fired timer
|
|
5923
|
+
# queued, which is exactly how Emscripten's WASM bring-up stalls
|
|
5924
|
+
# (`removeRunDependency` runs only after the binary-read setTimeout, and its
|
|
5925
|
+
# `run()` → `onRuntimeInitialized` continuation is a bare microtask with no further
|
|
5926
|
+
# timer to re-trigger a gated drain). Bounded by WORKER_QUIESCE_MAX_ROUNDS so a
|
|
5927
|
+
# self-perpetuating timer (setInterval) yields back to the poll loop rather than
|
|
5928
|
+
# pinning the thread.
|
|
5929
|
+
private def drive_worker_to_quiescence(rt)
|
|
5930
|
+
WORKER_QUIESCE_MAX_ROUNDS.times do
|
|
5931
|
+
rt.drain_microtasks
|
|
5932
|
+
break if rt.call('__nextTimerDelay').to_f < 0
|
|
5933
|
+
rt.drain_timers
|
|
5934
|
+
end
|
|
5935
|
+
end
|
|
5936
|
+
|
|
4183
5937
|
# `Thread::Queue#pop(timeout:)` blocks releasing the GVL — fine
|
|
4184
5938
|
# because the worker thread has nothing else to do while idle,
|
|
4185
5939
|
# and `worker_post_to_worker` wakes the wait immediately.
|
|
@@ -4330,7 +6084,7 @@ module Capybara
|
|
|
4330
6084
|
# isn't http(s) (data: / mailto: / about:) plus pseudo-tokens
|
|
4331
6085
|
# like V8's `<snapshot>` that sourcemap libraries pull out of
|
|
4332
6086
|
# error stacks and feed straight to `fetch()` / `xhr.open()`.
|
|
4333
|
-
def rack_fetch(method, url, body, headers, redirect_mode, cors_mode = nil, credentials: 'same-origin', env_extras: nil, referrer_policy: nil, referrer: nil, cache_mode: 'default')
|
|
6087
|
+
def rack_fetch(method, url, body, headers, redirect_mode, cors_mode = nil, credentials: 'same-origin', env_extras: nil, referrer_policy: nil, referrer: nil, cache_mode: 'default', initiator: nil, site_seed: nil, origin_null: false)
|
|
4334
6088
|
# NB: a relative fetch/XHR URL is resolved against the document's API base URL
|
|
4335
6089
|
# at OPEN time (XHR open() / fetch()), in JS, NOT here — resolving at send time
|
|
4336
6090
|
# would wrongly pick up a `<base href>` inserted after open() (open-url-base
|
|
@@ -4350,7 +6104,14 @@ module Capybara
|
|
|
4350
6104
|
# internal asset GET) pass nil → no CORS and no mode semantics. The document's
|
|
4351
6105
|
# origin is the request's origin; a different target origin is cross-origin.
|
|
4352
6106
|
cors = cors_mode == 'cors'
|
|
4353
|
-
|
|
6107
|
+
# The request's origin (document origin) for EVERY fetch mode — Fetch appends an
|
|
6108
|
+
# Origin header to every non-GET/HEAD request regardless of mode (a same-origin or
|
|
6109
|
+
# no-cors POST/PUT still carries it, for the server's CSRF/Origin check). CORS
|
|
6110
|
+
# enforcement itself stays gated on `cors` below; a nil-mode internal caller
|
|
6111
|
+
# (navigation / asset GET) has no origin semantics. An explicit `initiator` (a SW
|
|
6112
|
+
# re-issuing a navigation via `fetch(event.request)`) is the request's origin for
|
|
6113
|
+
# ALL modes — so a passthrough 'navigate'-mode POST still carries its Origin.
|
|
6114
|
+
req_origin = initiator || (%w[cors no-cors same-origin].include?(cors_mode) ? url_origin(@current_url) : nil)
|
|
4354
6115
|
# Fetch request "mode" (fetch threads it; XHR is always 'cors'; a non-fetch/xhr
|
|
4355
6116
|
# caller passes nil → no mode semantics, a plain 'basic' response). `no-cors`
|
|
4356
6117
|
# filters a cross-origin response to opaque; `same-origin` makes a cross-origin
|
|
@@ -4363,6 +6124,11 @@ module Capybara
|
|
|
4363
6124
|
# (form submission) or a nil-mode internal caller gets a plain readable response.
|
|
4364
6125
|
doc_origin = %w[cors no-cors same-origin].include?(cors_mode) ? url_origin(@current_url) : nil
|
|
4365
6126
|
crossed = false
|
|
6127
|
+
# Sec-Fetch-Site latches the widest initiator↔hop relationship across the redirect chain
|
|
6128
|
+
# (like the navigation path), computed vs the request's referrer-source origin below. A SW
|
|
6129
|
+
# re-fetch seeds it with the widened site the network hops accumulated BEFORE the SW
|
|
6130
|
+
# intercepted the final hop (a same-site redirect the passthrough must keep reporting).
|
|
6131
|
+
sec_site = site_seed
|
|
4366
6132
|
# A request is "credentialed" (cookies + the credentialed CORS check) only in
|
|
4367
6133
|
# `include` mode; `same-origin` (default) and `omit` are uncredentialed for the
|
|
4368
6134
|
# CORS check, while the cookie decision below distinguishes all three.
|
|
@@ -4380,12 +6146,27 @@ module Capybara
|
|
|
4380
6146
|
body = Base64.decode64(body.to_s)
|
|
4381
6147
|
headers = headers.reject {|k, _| k == 'X-Csim-Body-B64' }
|
|
4382
6148
|
end
|
|
6149
|
+
# CHALLENGE credentials for transparent HTTP Basic auth — set by the XHR authentication path
|
|
6150
|
+
# (open() user/password / URL userinfo), NOT a raw setRequestHeader('Authorization'). They are
|
|
6151
|
+
# NOT sent proactively; a 401 "Basic" challenge triggers a single re-send with them (below).
|
|
6152
|
+
# Strip the marker so it never reaches the server.
|
|
6153
|
+
challenge_authz = nil
|
|
6154
|
+
if headers.is_a?(Hash) && (mk = headers.keys.find {|k| k.to_s.casecmp?('x-csim-auth-challenge') })
|
|
6155
|
+
challenge_authz = 'Basic ' + headers[mk].to_s
|
|
6156
|
+
headers = headers.reject {|k, _| k == mk }
|
|
6157
|
+
end
|
|
4383
6158
|
# The request's origin starts as the document origin; a cross-origin REDIRECT
|
|
4384
6159
|
# taints it to an opaque origin (serialized "null") per Fetch "HTTP-redirect
|
|
4385
6160
|
# fetch". `effective_origin` IS that origin — it's what the Origin header
|
|
4386
6161
|
# carries and what the CORS check / preflight compare against from that hop on
|
|
4387
|
-
# ('null' once tainted, so the server must then allow 'null' or '*').
|
|
4388
|
-
|
|
6162
|
+
# ('null' once tainted, so the server must then allow 'null' or '*'). A SW re-fetch whose
|
|
6163
|
+
# navigation ALREADY crossed origin via a network redirect starts tainted (origin_null).
|
|
6164
|
+
effective_origin = origin_null ? 'null' : req_origin
|
|
6165
|
+
# Virtual server delay (a handler's `time.sleep`, see wpt_py_handler.py) accumulated across
|
|
6166
|
+
# EVERY sub-request this fetch makes — the CORS preflight AND every redirect hop — since the
|
|
6167
|
+
# `timeout` a client applies spans them all and a redirect/preflight must not reset it
|
|
6168
|
+
# (timeout-multiple-fetches). Reset per fetch; the final response carries the total.
|
|
6169
|
+
@fetch_server_delay_ms = 0
|
|
4389
6170
|
# An author conditional (If-None-Match / …) means the caller is doing its own
|
|
4390
6171
|
# revalidation, so the UA cache must step aside (computed once — the headers
|
|
4391
6172
|
# carrying it survive every redirect hop unchanged).
|
|
@@ -4399,6 +6180,15 @@ module Capybara
|
|
|
4399
6180
|
# document URL ("client"); an empty referrer means no-referrer (compute_referrer
|
|
4400
6181
|
# maps a blank source to nil).
|
|
4401
6182
|
ref_source = referrer.nil? ? @current_url : referrer
|
|
6183
|
+
# The request's INITIATOR origin for Sec-Fetch-Site — captured ONCE (loop-invariant), before
|
|
6184
|
+
# the per-hop referrer reassignment (5927 below) degrades ref_source, and independent of
|
|
6185
|
+
# Referrer-Policy: the initiator is the referrer's origin (a SW's `fetch(event.request)`
|
|
6186
|
+
# carries the navigating frame's origin here, so a cross-origin passthrough is same-/cross-site
|
|
6187
|
+
# correctly), falling back to the document origin when the referrer was policy-emptied — never
|
|
6188
|
+
# 'none' for a request that has a real initiator. An explicit `initiator` is authoritative:
|
|
6189
|
+
# it survives the referrer reset a `new Request(event.request, init)` performs (referrer →
|
|
6190
|
+
# about:client), so a SW's change-request re-fetch is same-origin to the SW's own script.
|
|
6191
|
+
sec_initiator = initiator || url_origin(ref_source) || url_origin(@current_url)
|
|
4402
6192
|
(MAX_FETCH_REDIRECTS + 1).times do
|
|
4403
6193
|
t0 = @trace && Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
4404
6194
|
# Cross-origin-ness for the request mode/type, latched across hops. Computed
|
|
@@ -4449,10 +6239,12 @@ module Capybara
|
|
|
4449
6239
|
|
|
4450
6240
|
env = Rack::MockRequest.env_for(target, method: method, input: body || '')
|
|
4451
6241
|
env['REQUEST_METHOD'] = method # env_for upcases the method; restore the exact case (open-method-case-sensitive)
|
|
4452
|
-
#
|
|
4453
|
-
#
|
|
4454
|
-
#
|
|
4455
|
-
|
|
6242
|
+
# env_for always sets Content-Length to the input bytesize (0 for an empty body).
|
|
6243
|
+
# Fetch adds Content-Length: 0 for a bodyless request ONLY when the method is
|
|
6244
|
+
# POST or PUT; GET/HEAD and every other method (incl. a custom one like `Chicken`)
|
|
6245
|
+
# send no Content-Length when the body is empty (send-entity-body-none;
|
|
6246
|
+
# request-headers custom-method). A non-empty body keeps its real length.
|
|
6247
|
+
env.delete('CONTENT_LENGTH') if body.to_s.empty? && !%w[POST PUT].include?(method.to_s.upcase)
|
|
4456
6248
|
apply_request_headers(env, headers) if headers
|
|
4457
6249
|
apply_request_headers(env, @@asset_cache.revalidation_headers(cache_entry)) if cache_entry
|
|
4458
6250
|
# The Referer follows the request's Referrer-Policy (a redirect response can
|
|
@@ -4473,6 +6265,18 @@ module Capybara
|
|
|
4473
6265
|
hop_cross_origin = !!(doc_origin && (effective_origin == 'null' || url_origin(target) != doc_origin))
|
|
4474
6266
|
send_cookies = credentials == 'include' || (credentials != 'omit' && !hop_cross_origin)
|
|
4475
6267
|
env.delete('HTTP_COOKIE') unless send_cookies
|
|
6268
|
+
# HTTP auth caching (RFC 7617 §2.2): once credentials succeed for an origin (cached below),
|
|
6269
|
+
# the UA sends them pre-emptively for later credentialed requests to it — so a Basic-auth
|
|
6270
|
+
# resource loads without a fresh 401 challenge (the login helper authenticates first, then
|
|
6271
|
+
# the guarded image/XHR requests carry the cached header). Gated on the same credential
|
|
6272
|
+
# decision as cookies; the caller's own Authorization (an explicit user:pass) always wins.
|
|
6273
|
+
# Skip the pre-emptive cache when THIS request brought its own challenge credentials (open()
|
|
6274
|
+
# user/pass) — those must win over a cached session, so the request goes out unauthenticated
|
|
6275
|
+
# and the 401-retry below applies the caller's credentials, not a stale cached pair
|
|
6276
|
+
# (send-authentication-competing-names-passwords).
|
|
6277
|
+
if send_cookies && !env.key?('HTTP_AUTHORIZATION') && !challenge_authz && (cached = @auth_cache[url_origin(target)])
|
|
6278
|
+
env['HTTP_AUTHORIZATION'] = cached
|
|
6279
|
+
end
|
|
4476
6280
|
# A CORS request to a URL carrying credentials (`user:pass@`) is a network
|
|
4477
6281
|
# error (access-control-and-redirects "user info" subtest).
|
|
4478
6282
|
return nil if cross_origin && url_has_userinfo?(target)
|
|
@@ -4491,14 +6295,55 @@ module Capybara
|
|
|
4491
6295
|
if cross_origin || (req_origin && !%w[GET HEAD].include?(method.to_s.upcase))
|
|
4492
6296
|
env['HTTP_ORIGIN'] = effective_origin
|
|
4493
6297
|
end
|
|
6298
|
+
# Fetch-Metadata request headers — emitted for every fetch/XHR/SW request (a mode is set;
|
|
6299
|
+
# the nil-mode internal callers — ESM / asset GET / beacon — are left alone). Sec-Fetch-Site
|
|
6300
|
+
# widens across the redirect chain vs the loop-invariant `sec_initiator` (see above). -Mode
|
|
6301
|
+
# is the request mode (a SW's `fetch(event.request)` re-issues a 'navigate'-mode request,
|
|
6302
|
+
# `new Request(…,{mode})` a 'same-origin' one); -Dest is 'empty' for a script-initiated fetch
|
|
6303
|
+
# — the only navigate-mode path here (a navigation emits its own 'iframe'/'document' dest +
|
|
6304
|
+
# -User via navigation_request_headers, never reaching rack_fetch).
|
|
6305
|
+
if cors_mode
|
|
6306
|
+
sec_site = widen_sec_fetch_site(sec_site, sec_fetch_site(sec_initiator, target))
|
|
6307
|
+
env['HTTP_SEC_FETCH_SITE'] = sec_site
|
|
6308
|
+
env['HTTP_SEC_FETCH_MODE'] = cors_mode
|
|
6309
|
+
env['HTTP_SEC_FETCH_DEST'] = 'empty'
|
|
6310
|
+
end
|
|
4494
6311
|
env.merge!(env_extras) if env_extras
|
|
4495
6312
|
status, resp_headers, resp_body = dispatch_rack_or_http(target, env, method: method, body: body)
|
|
4496
|
-
|
|
6313
|
+
@fetch_server_delay_ms += server_delay_ms_of(resp_headers)
|
|
6314
|
+
# Transparent HTTP Basic auth (RFC 7617): a request carrying CHALLENGE credentials (open()
|
|
6315
|
+
# user/pass / URL userinfo) that gets a 401 "Basic" challenge — and hasn't already sent an
|
|
6316
|
+
# Authorization (an explicit setRequestHeader / a pre-emptively-attached cached credential) —
|
|
6317
|
+
# is re-sent ONCE with them; only the authenticated response reaches script, the 401 never does
|
|
6318
|
+
# (send-authentication-basic / -existing-session). `omit` sends no credentials at all.
|
|
6319
|
+
if challenge_authz && status.to_i == 401 && credentials != 'omit' &&
|
|
6320
|
+
!env.key?('HTTP_AUTHORIZATION') && www_authenticate_basic?(resp_headers)
|
|
6321
|
+
resp_body.close if resp_body.respond_to?(:close)
|
|
6322
|
+
env['HTTP_AUTHORIZATION'] = challenge_authz
|
|
6323
|
+
status, resp_headers, resp_body = dispatch_rack_or_http(target, env, method: method, body: body)
|
|
6324
|
+
end
|
|
6325
|
+
# Fetch credentials mode "omit" ignores credentials the response sends back too —
|
|
6326
|
+
# its Set-Cookie is dropped, not stored (cors-cookies / credentials "omit mode").
|
|
6327
|
+
merge_set_cookie(resp_headers, target) unless credentials == 'omit'
|
|
6328
|
+
# Cache the credentials this origin ACCEPTED (AUTHENTICATION credentials — a pre-emptively
|
|
6329
|
+
# attached cached credential, or the challenge credential the 401-retry above just supplied —
|
|
6330
|
+
# that weren't rejected with a 401), for the pre-emptive send above. `omit` neither sends nor
|
|
6331
|
+
# caches. Only the request's OWN origin is cached: Authorization is stripped on a cross-origin
|
|
6332
|
+
# redirect hop (above), so origin A's credentials can't seed origin B's cache. (A non-2xx
|
|
6333
|
+
# same-origin response — an opaque status-0 no-cors fetch, a same-origin 3xx — still
|
|
6334
|
+
# establishes the credentials for THAT origin, so the gate is "not a 401", not "is a 2xx".)
|
|
6335
|
+
if challenge_authz && credentials != 'omit' && status.to_i != 401
|
|
6336
|
+
@auth_cache[url_origin(target)] = env['HTTP_AUTHORIZATION'] || challenge_authz
|
|
6337
|
+
end
|
|
4497
6338
|
if status == 304 && cache_entry
|
|
4498
6339
|
trace_network(method, target, cache_entry.status, headers, body, cache_entry.headers, nil, t0, false)
|
|
4499
6340
|
resp_body.close if resp_body.respond_to?(:close)
|
|
4500
6341
|
@@asset_cache.refresh(cache_entry, resp_headers)
|
|
4501
|
-
|
|
6342
|
+
# The cache stores the RAW response headers, so a cross-origin cached entry must
|
|
6343
|
+
# be re-filtered through the CORS exposed-header set on the way back to script —
|
|
6344
|
+
# a 304 revalidation must not leak headers the original cross-origin fetch hid.
|
|
6345
|
+
cached_headers = cross_origin ? cors_exposed_headers(cache_entry.headers, with_credentials) : cache_entry.headers
|
|
6346
|
+
return response_hash(cache_entry.status, cached_headers, cache_entry.body, target, redirected)
|
|
4502
6347
|
end
|
|
4503
6348
|
# Fetch "CORS check" runs on EVERY cross-origin response — including a 3xx the
|
|
4504
6349
|
# UA is about to follow (a redirect whose response lacks a valid Access-Control
|
|
@@ -4554,6 +6399,12 @@ module Capybara
|
|
|
4554
6399
|
# hop out of a same-origin request keeps the real origin (redirect-origin
|
|
4555
6400
|
# "same origin to other origin" sends the document origin, not null).
|
|
4556
6401
|
effective_origin = 'null' if cors && crossed && url_origin(next_url) != url_origin(target)
|
|
6402
|
+
# Fetch "HTTP-redirect fetch": a CROSS-ORIGIN redirect strips the request's
|
|
6403
|
+
# `Authorization` — credentials sent to the first origin must not be replayed to a
|
|
6404
|
+
# different one (nor seed that origin's auth cache below).
|
|
6405
|
+
if url_origin(next_url) != url_origin(target) && headers.is_a?(Hash)
|
|
6406
|
+
headers = headers.reject {|k, _| k.to_s.casecmp?('authorization') }
|
|
6407
|
+
end
|
|
4557
6408
|
target = carry_fragment(target, next_url)
|
|
4558
6409
|
if bad_port?(target) # a redirect to a blocked port is a network error too
|
|
4559
6410
|
resp_body.close if resp_body.respond_to?(:close)
|
|
@@ -4601,7 +6452,14 @@ module Capybara
|
|
|
4601
6452
|
# -Headers (`*` = all). content-type stays safelisted, so response decoding is
|
|
4602
6453
|
# unaffected. (Filtered for script exposure only — trace / set-cookie / cache see
|
|
4603
6454
|
# the full set.) The CORS check itself already ran above (incl. on 3xx hops).
|
|
4604
|
-
|
|
6455
|
+
# The virtual server delay is EPHEMERAL processing time (accumulated across the preflight +
|
|
6456
|
+
# every redirect hop): the client reads the TOTAL to time its async deferral, but it must
|
|
6457
|
+
# never be cached or replayed on a cache hit — strip it from the traced/stored response and
|
|
6458
|
+
# expose the total to the CLIENT only (added after CORS filtering, so it always survives).
|
|
6459
|
+
total_delay = @fetch_server_delay_ms.to_i
|
|
6460
|
+
resp_headers = resp_headers.reject {|k, _| k.to_s.casecmp?('x-csim-server-delay-ms') }
|
|
6461
|
+
exposed_headers = cross_origin ? cors_exposed_headers(resp_headers, with_credentials) : resp_headers
|
|
6462
|
+
exposed_headers = exposed_headers.merge('X-Csim-Server-Delay-Ms' => total_delay.to_s) if total_delay > 0
|
|
4605
6463
|
trace_network(method, target, status, headers, body, resp_headers, body_str, t0, false)
|
|
4606
6464
|
# A no-store request must not write the cache (RFC 9111 §5.2.1.5); a request carrying
|
|
4607
6465
|
# the author's own conditional bypasses the UA cache entirely (read AND write) — it's
|
|
@@ -4611,7 +6469,7 @@ module Capybara
|
|
|
4611
6469
|
# A no-cors cross-origin response is OPAQUE: status 0, empty body, no exposed
|
|
4612
6470
|
# headers, empty URL (cors-basic "Opaque filter"). Otherwise the type is 'cors'
|
|
4613
6471
|
# for a cross-origin (CORS-allowed) response, else 'basic'.
|
|
4614
|
-
return response_hash(0, {}, '', '', false, type: 'opaque', body_null: true) if no_cors_mode && crossed
|
|
6472
|
+
return response_hash(0, {}, '', '', false, type: 'opaque', body_null: true, opaque_render: body_str) if no_cors_mode && crossed
|
|
4615
6473
|
return response_hash(status, exposed_headers, body_str, target, redirected, type: crossed ? 'cors' : 'basic', body_null: null_body)
|
|
4616
6474
|
end
|
|
4617
6475
|
raise StandardError, "[capybara-simulated] fetch exceeded #{MAX_FETCH_REDIRECTS} redirects"
|
|
@@ -4717,7 +6575,7 @@ module Capybara
|
|
|
4717
6575
|
# text body when `body_b64` is absent.
|
|
4718
6576
|
TEXT_CONTENT_TYPE_PREFIXES = %w[text/ application/json application/javascript application/ecmascript application/xml image/svg+xml].freeze
|
|
4719
6577
|
|
|
4720
|
-
def response_hash(status, headers, body, url, redirected, type: 'basic', body_null: false)
|
|
6578
|
+
def response_hash(status, headers, body, url, redirected, type: 'basic', body_null: false, opaque_render: nil)
|
|
4721
6579
|
raw = body.to_s
|
|
4722
6580
|
hdrs = stringify(headers)
|
|
4723
6581
|
# A NUL in a header value is not a valid HTTP message; a real server can't
|
|
@@ -4775,6 +6633,15 @@ module Capybara
|
|
|
4775
6633
|
# (decodeResponseBytes). `ascii_only?` is a cheap C-level scan, so the dominant
|
|
4776
6634
|
# pure-ASCII app JSON/HTML traffic keeps the fast path and pays no base64.
|
|
4777
6635
|
out['body_b64'] = Base64.strict_encode64(raw) unless is_text && raw.ascii_only?
|
|
6636
|
+
# An OPAQUE (no-cors cross-origin) response hides its body from every script-visible read
|
|
6637
|
+
# (body/body_b64 are empty). But the bytes are still needed to RENDER an <img> the response
|
|
6638
|
+
# backs (a cross-origin image displays, merely canvas-tainting) — carry them on a private
|
|
6639
|
+
# side channel the image decode path reads, never a public body accessor. Attached to EVERY
|
|
6640
|
+
# opaque response, not just image requests: this is `rack_fetch`, which has no request
|
|
6641
|
+
# destination (a SW's own no-cors `fetch()` doesn't know its eventual consumer is an <img>),
|
|
6642
|
+
# so the choice is made client-side. The bytes are already in memory (`body_str`); the added
|
|
6643
|
+
# cost is one base64 per opaque response, off any hot path.
|
|
6644
|
+
out['opaque_render_b64'] = Base64.strict_encode64(opaque_render) if opaque_render && !opaque_render.empty?
|
|
4778
6645
|
out
|
|
4779
6646
|
end
|
|
4780
6647
|
|
|
@@ -4936,6 +6803,14 @@ module Capybara
|
|
|
4936
6803
|
entry = @frame_stack.find {|e| e[:realm_id] == realm_id }
|
|
4937
6804
|
if entry
|
|
4938
6805
|
navigate_frame(url, entry: entry)
|
|
6806
|
+
elsif url.match?(%r{\Ahttps?://}i)
|
|
6807
|
+
# An absolute http(s) self-nav (`self.location = …` / link click) is fetched Ruby-side so
|
|
6808
|
+
# it carries correct navigation request headers (Referer under policy / Sec-Fetch);
|
|
6809
|
+
# non-http(s) and relative URLs stay on the JS src-reassignment path via
|
|
6810
|
+
# navigate_realm_self_get. `record: false` — a location/link frame nav isn't history-
|
|
6811
|
+
# recorded yet (that's a form-submission-only path), and must not push where a
|
|
6812
|
+
# location.replace should overwrite.
|
|
6813
|
+
navigate_realm_self_get(realm_id, url, record: false)
|
|
4939
6814
|
else
|
|
4940
6815
|
@runtime.call('__csimNavigateFrameByRealm', realm_id, url)
|
|
4941
6816
|
end
|
|
@@ -5018,8 +6893,19 @@ module Capybara
|
|
|
5018
6893
|
# JS-side, reusing the retained content so blob bytes survive a revoke.
|
|
5019
6894
|
entry = @frame_stack.find {|e| e[:realm_id] == realm_id }
|
|
5020
6895
|
url = entry && @runtime.frame_realm_alive?(realm_id) ? @runtime.realm_call(realm_id, '__csimLocationHref').to_s : ''
|
|
6896
|
+
cur = current_frame_history_entry(realm_id)
|
|
5021
6897
|
if entry && !url.empty?
|
|
5022
6898
|
navigate_frame(url, entry: entry)
|
|
6899
|
+
elsif cur && cur[:method] == 'POST'
|
|
6900
|
+
# Reloading a document reached by POST re-POSTS it (isReloadNavigation) with the recorded
|
|
6901
|
+
# body, rather than the JS reload path's GET refetch of the frame's src.
|
|
6902
|
+
navigate_realm_self_post(realm_id, cur[:url], cur[:body], cur[:content_type], is_reload: true)
|
|
6903
|
+
elsif cur && cur[:url].to_s.match?(%r{\Ahttps?://}i)
|
|
6904
|
+
# Reload the CURRENT history entry (isReloadNavigation), not the frame's `src`: after a
|
|
6905
|
+
# back/forward the src is stale, so `history.go(0)` / `location.reload()` must refetch the
|
|
6906
|
+
# entry's URL. (A blob:/data:/srcdoc frame keeps the JS reload path — it reuses retained
|
|
6907
|
+
# bytes a URL refetch can't reproduce.)
|
|
6908
|
+
reload_frame_to_entry(realm_id, cur, is_reload: true, is_history: false)
|
|
5023
6909
|
else
|
|
5024
6910
|
@runtime.call('__csimReloadFrameByRealm', realm_id)
|
|
5025
6911
|
end
|
|
@@ -5086,8 +6972,9 @@ module Capybara
|
|
|
5086
6972
|
return if h.nil?
|
|
5087
6973
|
target = h[:idx] + delta
|
|
5088
6974
|
return if target.negative? || target >= h[:entries].size
|
|
5089
|
-
# Snapshot the entry we're leaving so a later forward traversal restores it
|
|
5090
|
-
|
|
6975
|
+
# Snapshot the entry we're leaving so a later forward traversal restores it (keeping how it
|
|
6976
|
+
# was reached, so a POST entry re-POSTs when traversed back to).
|
|
6977
|
+
h[:entries][h[:idx]] = snapshot_outgoing_entry(realm_id, h[:entries][h[:idx]]) if h[:idx] >= 0
|
|
5091
6978
|
reload_frame_to_entry(realm_id, h[:entries][target])
|
|
5092
6979
|
h[:idx] = target # advance only after the rebuild succeeds
|
|
5093
6980
|
end
|
|
@@ -5097,52 +6984,110 @@ module Capybara
|
|
|
5097
6984
|
# into the frame form-submission paths; frame navigations driven by
|
|
5098
6985
|
# `location.href` / link clicks aren't recorded yet (history.back there falls
|
|
5099
6986
|
# through to the top document, as before).
|
|
5100
|
-
|
|
6987
|
+
# `post` (a {body, content_type}) records that this entry was reached by a POST submission,
|
|
6988
|
+
# so a later reload / history traversal re-POSTs it (with the body) rather than GET-ing the URL.
|
|
6989
|
+
def record_frame_nav(realm_id, new_url, post: nil)
|
|
5101
6990
|
return if realm_id.nil? || realm_id.zero?
|
|
5102
6991
|
parent = @runtime.frame_realm_parent(realm_id)
|
|
5103
6992
|
handle = frame_container_handle(realm_id, parent)
|
|
5104
6993
|
return if handle.zero?
|
|
5105
6994
|
h = (@frame_histories ||= {})[[parent, handle]] ||= {entries: [], idx: -1}
|
|
5106
|
-
outgoing = frame_history_entry(realm_id)
|
|
5107
6995
|
if h[:idx] >= 0
|
|
5108
|
-
h[:entries][h[:idx]] =
|
|
6996
|
+
h[:entries][h[:idx]] = snapshot_outgoing_entry(realm_id, h[:entries][h[:idx]])
|
|
5109
6997
|
else
|
|
5110
|
-
h[:entries] <<
|
|
6998
|
+
h[:entries] << frame_history_entry(realm_id)
|
|
5111
6999
|
h[:idx] = 0
|
|
5112
7000
|
end
|
|
5113
7001
|
h[:entries] = h[:entries][0..h[:idx]]
|
|
5114
|
-
|
|
7002
|
+
entry = {url: new_url.to_s, form_state: nil}
|
|
7003
|
+
entry.merge!(method: 'POST', body: post[:body], content_type: post[:content_type]) if post
|
|
7004
|
+
h[:entries] << entry
|
|
5115
7005
|
h[:idx] = h[:entries].size - 1
|
|
5116
7006
|
end
|
|
7007
|
+
# The frame's CURRENT history entry (the loaded document), or nil — read by a reload to decide
|
|
7008
|
+
# whether to re-POST.
|
|
7009
|
+
def current_frame_history_entry(realm_id)
|
|
7010
|
+
parent = @runtime.frame_realm_parent(realm_id)
|
|
7011
|
+
handle = frame_container_handle(realm_id, parent)
|
|
7012
|
+
return nil if handle.zero?
|
|
7013
|
+
h = (@frame_histories || {})[[parent, handle]]
|
|
7014
|
+
h && h[:idx] >= 0 ? h[:entries][h[:idx]] : nil
|
|
7015
|
+
end
|
|
5117
7016
|
# The history entry for the document currently loaded in `realm_id`: its URL
|
|
5118
7017
|
# plus a snapshot of its form-control state.
|
|
5119
7018
|
def frame_history_entry(realm_id)
|
|
5120
7019
|
{url: frame_realm_url(realm_id), form_state: capture_frame_form_state(realm_id)}
|
|
5121
7020
|
end
|
|
7021
|
+
# Refresh the outgoing entry's url + form-state snapshot while PRESERVING how it was reached
|
|
7022
|
+
# (a POST entry's method / body / content_type) — leaving a document doesn't change the request
|
|
7023
|
+
# that loaded it, so a later traversal back re-POSTs rather than GET-ing.
|
|
7024
|
+
def snapshot_outgoing_entry(realm_id, prev)
|
|
7025
|
+
snap = frame_history_entry(realm_id)
|
|
7026
|
+
prev ? prev.merge(snap) : snap
|
|
7027
|
+
end
|
|
5122
7028
|
def frame_realm_url(realm_id)
|
|
5123
7029
|
return nil unless @runtime.frame_realm_alive?(realm_id)
|
|
5124
7030
|
@runtime.realm_call(realm_id, '__csimLocationHref').to_s
|
|
5125
7031
|
rescue StandardError
|
|
5126
7032
|
nil
|
|
5127
7033
|
end
|
|
7034
|
+
# A frame document's referrer policy (its last valid `<meta name="referrer">`), read from
|
|
7035
|
+
# the live realm to compute a self-navigation's Referer under the initiating document's
|
|
7036
|
+
# policy. '' when the realm is gone / has no meta → the platform default applies.
|
|
7037
|
+
def frame_document_referrer_policy(realm_id)
|
|
7038
|
+
return '' unless @runtime.frame_realm_alive?(realm_id)
|
|
7039
|
+
@runtime.realm_call(realm_id, '__csimDocumentReferrerPolicy').to_s
|
|
7040
|
+
rescue StandardError
|
|
7041
|
+
''
|
|
7042
|
+
end
|
|
5128
7043
|
def capture_frame_form_state(realm_id)
|
|
5129
7044
|
return nil unless @runtime.frame_realm_alive?(realm_id)
|
|
5130
7045
|
@runtime.realm_call(realm_id, '__csimCaptureFormState')
|
|
5131
7046
|
rescue StandardError
|
|
5132
7047
|
nil
|
|
5133
7048
|
end
|
|
5134
|
-
# Re-fetch a history entry's URL and rebuild the frame realm from it, then
|
|
5135
|
-
#
|
|
5136
|
-
|
|
7049
|
+
# Re-fetch a history entry's URL and rebuild the frame realm from it, then restore the entry's
|
|
7050
|
+
# captured form state (before the element load fires). Used for a history traversal
|
|
7051
|
+
# (isHistoryNavigation) AND for a reload of the current entry (isReloadNavigation — e.g.
|
|
7052
|
+
# `history.go(0)` / `location.reload()` after a back/forward, where the frame's `src` is stale
|
|
7053
|
+
# and only the current entry names the right URL).
|
|
7054
|
+
def reload_frame_to_entry(realm_id, entry, is_reload: false, is_history: true)
|
|
5137
7055
|
url = entry[:url].to_s
|
|
5138
7056
|
return if url.empty?
|
|
5139
|
-
|
|
7057
|
+
# An entry reached by a POST submission re-POSTS (with the recorded body); a normal entry
|
|
7058
|
+
# re-GETs. The method drives both the SW fetch event and the network fallback.
|
|
7059
|
+
is_post = entry[:method] == 'POST'
|
|
7060
|
+
body = entry[:body].to_s
|
|
7061
|
+
post_args = is_post ? {method: 'POST', body_b64: Base64.strict_encode64(body), content_type: entry[:content_type]} : {}
|
|
7062
|
+
# A history TRAVERSAL restores the entry's persisted form state (bfcache); a RELOAD gives a
|
|
7063
|
+
# fresh document, so it must NOT restore the (possibly stale) snapshot the entry was left with.
|
|
7064
|
+
restore = is_reload ? nil : entry[:form_state]
|
|
7065
|
+
# A traversal / reload is a navigation: route it through the controlling SW's fetch event
|
|
7066
|
+
# first — the refetch happens here, Ruby-side, bypassing the __csimFrameWindow interception
|
|
7067
|
+
# the initial load uses. A respondWith serves the document; a network error fails the
|
|
7068
|
+
# navigation; nil falls through to the network below.
|
|
7069
|
+
if (sw = service_worker_navigation_fetch(url, is_reload: is_reload, is_history: is_history,
|
|
7070
|
+
referrer_source: frame_realm_url(realm_id), referrer_policy: frame_document_referrer_policy(realm_id),
|
|
7071
|
+
**post_args))
|
|
7072
|
+
return if sw['networkError']
|
|
7073
|
+
|
|
7074
|
+
# Raw decoded bytes (like read_rack_body's byte-tagged output); reload_frame_realm_by_id
|
|
7075
|
+
# does the single utf8_text re-tag, matching the network path below.
|
|
7076
|
+
reload_frame_realm_by_id(realm_id, url, Base64.decode64(sw['body_b64'].to_s),
|
|
7077
|
+
response_content_type(sw['headers'] || {}), restore_state: restore)
|
|
7078
|
+
return
|
|
7079
|
+
end
|
|
7080
|
+
env = Rack::MockRequest.env_for(url, method: is_post ? 'POST' : 'GET', input: is_post ? body : '')
|
|
7081
|
+
if is_post
|
|
7082
|
+
env['CONTENT_TYPE'] = entry[:content_type].to_s.empty? ? 'application/x-www-form-urlencoded' : entry[:content_type]
|
|
7083
|
+
env['CONTENT_LENGTH'] = body.bytesize.to_s
|
|
7084
|
+
end
|
|
5140
7085
|
apply_default_request_env(env, referer: current_browsing_context_url)
|
|
5141
|
-
status, headers,
|
|
5142
|
-
merge_set_cookie(headers)
|
|
7086
|
+
status, headers, resp_body = dispatch_rack_or_http(url, env, method: is_post ? 'POST' : 'GET', body: is_post ? body : nil)
|
|
7087
|
+
merge_set_cookie(headers, url)
|
|
5143
7088
|
return if download_response?(headers)
|
|
5144
|
-
html = read_rack_body(
|
|
5145
|
-
reload_frame_realm_by_id(realm_id, url, html, response_content_type(headers), restore_state:
|
|
7089
|
+
html = read_rack_body(resp_body)
|
|
7090
|
+
reload_frame_realm_by_id(realm_id, url, html, response_content_type(headers), restore_state: restore)
|
|
5146
7091
|
end
|
|
5147
7092
|
# Serialize + route a form submitted inside frame realm `realm_id`. We
|
|
5148
7093
|
# serialize in the INITIATING realm (so shadow-tree controls are excluded
|
|
@@ -5162,7 +7107,7 @@ module Capybara
|
|
|
5162
7107
|
target = spec['target'].to_s
|
|
5163
7108
|
action = spec['action'].to_s
|
|
5164
7109
|
enctype = spec['enctype'].to_s.empty? ? 'application/x-www-form-urlencoded' : spec['enctype'].to_s.downcase
|
|
5165
|
-
entries = entry_list.is_a?(Array) ? entry_list :
|
|
7110
|
+
entries = entry_list.is_a?(Array) ? entry_list : (spec['entries'] || [])
|
|
5166
7111
|
# GET → urlencoded query (enctype ignored); POST → enctype-encoded body.
|
|
5167
7112
|
get_query, = encode_entry_list(entries, 'application/x-www-form-urlencoded')
|
|
5168
7113
|
get_url = form_get_url(action, get_query)
|
|
@@ -5200,20 +7145,63 @@ module Capybara
|
|
|
5200
7145
|
end
|
|
5201
7146
|
# A self-targeted GET form submit in the initiating frame realm: navigate
|
|
5202
7147
|
# that frame to the action URL (query already mutated in).
|
|
5203
|
-
|
|
5204
|
-
|
|
7148
|
+
# `record: false` for a `location.href=` / link-click self-nav — those frame navigations are
|
|
7149
|
+
# deliberately NOT recorded in frame history yet (see record_frame_nav; history.back there falls
|
|
7150
|
+
# through to the top document), and recording them here would push an entry where a
|
|
7151
|
+
# `location.replace` must overwrite. A form GET submission (the default) IS a history push.
|
|
7152
|
+
def navigate_realm_self_get(realm_id, get_url, depth: 0, is_reload: false, is_history: false, record: true, site_seed: nil, origin_null: false)
|
|
7153
|
+
raise 'too many redirects' if depth > 10
|
|
7154
|
+
record_frame_nav(realm_id, get_url) if record && depth.zero? && !is_reload && !is_history
|
|
5205
7155
|
entry = @frame_stack.find {|e| e[:realm_id] == realm_id }
|
|
5206
|
-
if entry
|
|
5207
|
-
|
|
5208
|
-
|
|
5209
|
-
|
|
5210
|
-
|
|
5211
|
-
|
|
5212
|
-
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
7156
|
+
return navigate_frame(resolve_against_current(get_url), entry: entry) if entry
|
|
7157
|
+
# A frame reached via contentWindow (not on the entered stack). An ABSOLUTE http(s) target is
|
|
7158
|
+
# fetched Ruby-side (like navigate_realm_self_post) so the navigation carries correct request
|
|
7159
|
+
# headers — a Referer under the initiating document's Referrer-Policy, no Origin (GET), the
|
|
7160
|
+
# Fetch-Metadata triple. A non-http(s) target (data:/blob:/javascript:/about:blank) or a
|
|
7161
|
+
# relative one stays on the JS src-reassignment path, which owns those schemes and resolves a
|
|
7162
|
+
# relative URL against the frame's base on rebuild — routed through the frame's PARENT realm
|
|
7163
|
+
# (where the owning iframe lives), not unconditionally to main.
|
|
7164
|
+
parent = @runtime.frame_realm_parent(realm_id)
|
|
7165
|
+
unless get_url.is_a?(String) && get_url.match?(%r{\Ahttps?://}i)
|
|
7166
|
+
return frame_realm_host_call(parent, '__csimNavigateFrameByRealm', realm_id, get_url)
|
|
5216
7167
|
end
|
|
7168
|
+
# The realm may have been disposed earlier in THIS drain batch — an ancestor frame that also
|
|
7169
|
+
# self-navigated discarded this descendant (dispose_frame_realm_tree). Bail before issuing a
|
|
7170
|
+
# network fetch whose response (reload_frame_realm_by_id) would find no container and be thrown
|
|
7171
|
+
# away — the fetch's cookie / server side effects would fire for a navigation that never commits.
|
|
7172
|
+
return unless @runtime.frame_realm_alive?(realm_id)
|
|
7173
|
+
invalidate_find_cache
|
|
7174
|
+
# A controlled navigation goes to the SW's fetch event first (mode 'navigate'); respondWith
|
|
7175
|
+
# serves the document, a network error fails it, nil falls through to the network GET below.
|
|
7176
|
+
if (sw = service_worker_navigation_fetch(get_url, method: 'GET', is_reload: is_reload, is_history: is_history,
|
|
7177
|
+
referrer_source: frame_realm_url(realm_id), referrer_policy: frame_document_referrer_policy(realm_id),
|
|
7178
|
+
site_seed: site_seed, origin_null: origin_null))
|
|
7179
|
+
return if sw['networkError']
|
|
7180
|
+
|
|
7181
|
+
return reload_frame_realm_by_id(realm_id, get_url, Base64.decode64(sw['body_b64'].to_s), response_content_type(sw['headers'] || {}))
|
|
7182
|
+
end
|
|
7183
|
+
initiator = frame_realm_url(realm_id)
|
|
7184
|
+
site = widen_sec_fetch_site(site_seed, sec_fetch_site(initiator, get_url))
|
|
7185
|
+
status, headers, resp_body = dispatch_navigation_request(
|
|
7186
|
+
get_url,
|
|
7187
|
+
method: 'GET',
|
|
7188
|
+
initiator: initiator,
|
|
7189
|
+
referrer_policy: frame_document_referrer_policy(realm_id),
|
|
7190
|
+
site: site,
|
|
7191
|
+
origin_null: origin_null
|
|
7192
|
+
)
|
|
7193
|
+
if (loc = redirect_location(status, headers))
|
|
7194
|
+
next_url = resolve_against(loc, get_url)
|
|
7195
|
+
resp_body.close if resp_body.respond_to?(:close)
|
|
7196
|
+
# Latch the redirect chain's Fetch-Metadata: Sec-Fetch-Site widens to include this hop, and
|
|
7197
|
+
# a form POST's Origin taints to 'null' per redirect_taints_origin? (moot for GET — no Origin).
|
|
7198
|
+
return navigate_realm_self_get(realm_id, next_url, depth: depth + 1, is_reload: is_reload, is_history: is_history, record: record,
|
|
7199
|
+
site_seed: site, origin_null: redirect_taints_origin?(origin_null, initiator, get_url, next_url))
|
|
7200
|
+
end
|
|
7201
|
+
if download_response?(headers)
|
|
7202
|
+
return save_downloaded_response(get_url, headers, resp_body)
|
|
7203
|
+
end
|
|
7204
|
+
reload_frame_realm_by_id(realm_id, get_url, read_rack_body(resp_body), response_content_type(headers))
|
|
5217
7205
|
end
|
|
5218
7206
|
# A self-targeted POST form submit in the initiating frame realm. POST the
|
|
5219
7207
|
# entity body to the action URL, then rebuild that frame's realm from the
|
|
@@ -5221,25 +7209,55 @@ module Capybara
|
|
|
5221
7209
|
# a frame reached via contentWindow has no stack entry, so rebuild it by
|
|
5222
7210
|
# realm id (recovering its container element + parent realm) and fire the
|
|
5223
7211
|
# iframe element's load event the GET/src path would.
|
|
5224
|
-
def navigate_realm_self_post(realm_id, url, body, content_type, depth: 0)
|
|
7212
|
+
def navigate_realm_self_post(realm_id, url, body, content_type, depth: 0, is_reload: false, is_history: false, site_seed: nil, origin_null: false)
|
|
5225
7213
|
raise 'too many redirects' if depth > 10
|
|
5226
|
-
|
|
7214
|
+
# A reload / history traversal RE-POSTS an existing entry — it doesn't push a new one; only
|
|
7215
|
+
# a fresh submission records history. The POST method/body is tagged onto the entry AFTER a
|
|
7216
|
+
# DIRECT (non-redirect) response (tag_frame_entry_post below): a POST that redirects (the
|
|
7217
|
+
# Post/Redirect/Get pattern) resolves to a GET document, so its entry must NOT re-POST on a
|
|
7218
|
+
# later reload / back — only a directly-served POST does.
|
|
7219
|
+
record_frame_nav(realm_id, url) if depth.zero? && !is_reload && !is_history
|
|
5227
7220
|
entry = @frame_stack.find {|e| e[:realm_id] == realm_id }
|
|
5228
7221
|
return navigate_frame_post(url, body, content_type, entry: entry) if entry
|
|
5229
7222
|
invalidate_find_cache
|
|
5230
|
-
|
|
5231
|
-
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
|
|
7223
|
+
# A controlled POST navigation goes to the SW's fetch event first (mode 'navigate', method
|
|
7224
|
+
# POST — the SW reads the body via event.request.text()); respondWith serves the document,
|
|
7225
|
+
# a network error fails it, nil falls through to the network POST below.
|
|
7226
|
+
if (sw = service_worker_navigation_fetch(url, method: 'POST', body_b64: Base64.strict_encode64(body.to_s), content_type: content_type, is_reload: is_reload, is_history: is_history,
|
|
7227
|
+
referrer_source: frame_realm_url(realm_id), referrer_policy: frame_document_referrer_policy(realm_id),
|
|
7228
|
+
site_seed: site_seed, origin_null: origin_null))
|
|
7229
|
+
return if sw['networkError']
|
|
7230
|
+
|
|
7231
|
+
tag_frame_entry_post(realm_id, body, content_type) if depth.zero?
|
|
7232
|
+
reload_frame_realm_by_id(realm_id, url.to_s, Base64.decode64(sw['body_b64'].to_s), response_content_type(sw['headers'] || {}))
|
|
7233
|
+
return
|
|
7234
|
+
end
|
|
7235
|
+
# The initiator is the FRAME's own document (still alive — the rebuild is below), not the
|
|
7236
|
+
# top document `current_browsing_context_url` returns for a non-entered frame. A form POST
|
|
7237
|
+
# navigation carries that document's Origin + a Referer under its Referrer-Policy + the
|
|
7238
|
+
# Fetch-Metadata triple (Sec-Fetch-Dest 'iframe' for a subframe).
|
|
7239
|
+
initiator = frame_realm_url(realm_id)
|
|
7240
|
+
site = widen_sec_fetch_site(site_seed, sec_fetch_site(initiator, url.to_s))
|
|
7241
|
+
status, headers, resp_body = dispatch_navigation_request(
|
|
7242
|
+
url,
|
|
7243
|
+
method: 'POST',
|
|
7244
|
+
initiator: initiator,
|
|
7245
|
+
referrer_policy: frame_document_referrer_policy(realm_id),
|
|
7246
|
+
site: site,
|
|
7247
|
+
origin_null: origin_null,
|
|
7248
|
+
body: body,
|
|
7249
|
+
content_type: content_type
|
|
7250
|
+
)
|
|
7251
|
+
merge_set_cookie(headers, url)
|
|
5236
7252
|
if (loc = redirect_location(status, headers))
|
|
5237
7253
|
next_url = resolve_against_current(loc)
|
|
5238
7254
|
resp_body.close if resp_body.respond_to?(:close)
|
|
5239
7255
|
# 307/308 preserve method + body; 301/302/303 → GET the frame (routed
|
|
5240
|
-
# through the realm that OWNS the iframe, as in navigate_realm_self_get).
|
|
7256
|
+
# through the realm that OWNS the iframe, as in navigate_realm_self_get). Latch the
|
|
7257
|
+
# redirect chain's Sec-Fetch-Site (widened) + Origin taint (redirect_taints_origin?).
|
|
5241
7258
|
if [307, 308].include?(status)
|
|
5242
|
-
return navigate_realm_self_post(realm_id, next_url, body, content_type, depth: depth + 1
|
|
7259
|
+
return navigate_realm_self_post(realm_id, next_url, body, content_type, depth: depth + 1, is_reload: is_reload, is_history: is_history,
|
|
7260
|
+
site_seed: site, origin_null: redirect_taints_origin?(origin_null, initiator, url.to_s, next_url))
|
|
5243
7261
|
end
|
|
5244
7262
|
parent = @runtime.frame_realm_parent(realm_id)
|
|
5245
7263
|
return frame_realm_host_call(parent, '__csimNavigateFrameByRealm', realm_id, next_url)
|
|
@@ -5248,8 +7266,16 @@ module Capybara
|
|
|
5248
7266
|
save_downloaded_response(url, headers, resp_body)
|
|
5249
7267
|
return
|
|
5250
7268
|
end
|
|
7269
|
+
tag_frame_entry_post(realm_id, body, content_type) if depth.zero?
|
|
5251
7270
|
reload_frame_realm_by_id(realm_id, url.to_s, read_rack_body(resp_body), response_content_type(headers))
|
|
5252
7271
|
end
|
|
7272
|
+
# Tag the frame's current history entry as reached by a POST (with its body), so a later
|
|
7273
|
+
# reload / history traversal re-POSTS it. Applied only on a DIRECT response (not a redirect),
|
|
7274
|
+
# so a Post/Redirect/Get entry stays a GET.
|
|
7275
|
+
def tag_frame_entry_post(realm_id, body, content_type)
|
|
7276
|
+
cur = current_frame_history_entry(realm_id)
|
|
7277
|
+
cur.merge!(method: 'POST', body: body, content_type: content_type) if cur
|
|
7278
|
+
end
|
|
5253
7279
|
# Rebuild a frame realm reached via contentWindow (no @frame_stack entry):
|
|
5254
7280
|
# recover its container element handle + parent realm, swap in a fresh realm
|
|
5255
7281
|
# built from `html`, re-point the iframe at it, and fire the element load.
|
|
@@ -5357,24 +7383,60 @@ module Capybara
|
|
|
5357
7383
|
def history_length
|
|
5358
7384
|
[@history.size, 1].max
|
|
5359
7385
|
end
|
|
5360
|
-
#
|
|
5361
|
-
#
|
|
5362
|
-
#
|
|
5363
|
-
#
|
|
7386
|
+
# The host a cookie is scoped to for `url`. RFC 6265 cookies are keyed by host
|
|
7387
|
+
# (not scheme/port), so cross-host requests never see each other's cookies while
|
|
7388
|
+
# a same-origin flow behaves exactly like a single jar. nil when the URL carries
|
|
7389
|
+
# no host (about:blank / data: / a relative current_url before the first navigate).
|
|
7390
|
+
def cookie_host(url)
|
|
7391
|
+
h = safe_uri(url.to_s)&.host
|
|
7392
|
+
h && !h.empty? ? h.downcase : nil
|
|
7393
|
+
end
|
|
7394
|
+
|
|
7395
|
+
# The host cookies attach to for a request built into `env` — the target server
|
|
7396
|
+
# (SERVER_NAME / HTTP_HOST), NOT the current document, so a cross-origin fetch sends
|
|
7397
|
+
# the TARGET's cookies rather than leaking the document's (cors-cookies). Strips the
|
|
7398
|
+
# port while preserving an IPv6 bracket-literal (`[::1]`) so the key matches what
|
|
7399
|
+
# `cookie_host` derives from the URL via `URI#host`.
|
|
7400
|
+
def env_cookie_host(env)
|
|
7401
|
+
h = (env['HTTP_HOST'] || env['SERVER_NAME']).to_s
|
|
7402
|
+
h = h.start_with?('[') ? h[/\A\[[^\]]*\]/].to_s : h.split(':', 2).first
|
|
7403
|
+
h && !h.empty? ? h.downcase : nil
|
|
7404
|
+
end
|
|
7405
|
+
|
|
7406
|
+
# The `Cookie` request-header value for a request to `host`: that host's jar,
|
|
7407
|
+
# serialized `name=value; …`. (Domain-attribute subdomain sharing isn't modelled —
|
|
7408
|
+
# the app suites are single-host; cross-host ISOLATION is what matters here.)
|
|
7409
|
+
#
|
|
7410
|
+
# TEXT, not binary: jar entries parsed out of Rack's Set-Cookie headers can carry the
|
|
7411
|
+
# BINARY tag, which would make the joined string cross into JS as a Uint8Array
|
|
7412
|
+
# (`document.cookie.match is not a function`). Cookies are ASCII per RFC 6265.
|
|
7413
|
+
def cookie_header_for(host)
|
|
7414
|
+
jar = host && @cookies[host]
|
|
7415
|
+
return '' if jar.nil? || jar.empty?
|
|
7416
|
+
RuntimeShared.utf8_text(jar.map {|k, v| "#{k}=#{v}" }.join('; '))
|
|
7417
|
+
end
|
|
7418
|
+
|
|
7419
|
+
# `document.cookie` reads/writes the CURRENT document's host jar.
|
|
7420
|
+
def document_cookie_host
|
|
7421
|
+
cookie_host(current_browsing_context_url) || cookie_host(@default_host)
|
|
7422
|
+
end
|
|
7423
|
+
|
|
5364
7424
|
def document_cookie
|
|
5365
|
-
|
|
7425
|
+
cookie_header_for(document_cookie_host)
|
|
5366
7426
|
end
|
|
5367
7427
|
def current_referer ; @current_referer.to_s ; end
|
|
5368
7428
|
def write_document_cookie(s)
|
|
5369
7429
|
return if s.nil? || s.empty?
|
|
7430
|
+
host = document_cookie_host or return
|
|
5370
7431
|
name, rest = s.split('=', 2)
|
|
5371
7432
|
return if name.nil? || name.empty?
|
|
5372
7433
|
parts = (rest || '').split(';').map(&:strip)
|
|
5373
7434
|
value = parts.shift.to_s
|
|
7435
|
+
jar = (@cookies[host] ||= {})
|
|
5374
7436
|
if cookie_deletion?(parts)
|
|
5375
|
-
|
|
7437
|
+
jar.delete(name.strip)
|
|
5376
7438
|
else
|
|
5377
|
-
|
|
7439
|
+
jar[name.strip] = value
|
|
5378
7440
|
end
|
|
5379
7441
|
end
|
|
5380
7442
|
|
|
@@ -5389,9 +7451,25 @@ module Capybara
|
|
|
5389
7451
|
def storage_get(kind, key)
|
|
5390
7452
|
store(kind)[key.to_s]
|
|
5391
7453
|
end
|
|
7454
|
+
# Per-area storage quota (Chrome / Firefox both cap a localStorage / sessionStorage area at
|
|
7455
|
+
# ~5 MiB per origin). Without it, the WPT quota tests — which `setItem` in a `while (true)` loop
|
|
7456
|
+
# until QuotaExceededError — write unbounded gigabytes and OOM the process.
|
|
7457
|
+
STORAGE_QUOTA_BYTES = 5 * 1024 * 1024
|
|
7458
|
+
|
|
7459
|
+
# Returns true when stored, false when the (key, value) would exceed the area's quota — the JS
|
|
7460
|
+
# shim turns a false into a QuotaExceededError and does NOT store (WHATWG "setItem" step). The
|
|
7461
|
+
# size is summed from the store each call (localStorage is shared across same-origin windows, so
|
|
7462
|
+
# a per-Browser running total would drift); replacing a key frees its old bytes first.
|
|
5392
7463
|
def storage_set(kind, key, value)
|
|
5393
|
-
store(kind)
|
|
5394
|
-
|
|
7464
|
+
st = store(kind)
|
|
7465
|
+
key = key.to_s
|
|
7466
|
+
value = value.to_s
|
|
7467
|
+
used = st.sum {|k, v| k.bytesize + v.bytesize }
|
|
7468
|
+
used -= key.bytesize + st[key].bytesize if st.key?(key)
|
|
7469
|
+
return false if used + key.bytesize + value.bytesize > STORAGE_QUOTA_BYTES
|
|
7470
|
+
|
|
7471
|
+
st[key] = value
|
|
7472
|
+
true
|
|
5395
7473
|
end
|
|
5396
7474
|
def storage_remove(kind, key)
|
|
5397
7475
|
store(kind).delete(key.to_s)
|
|
@@ -5410,6 +7488,62 @@ module Capybara
|
|
|
5410
7488
|
private def store(kind)
|
|
5411
7489
|
kind.to_s == 'session' ? @session_storage : @local_storage
|
|
5412
7490
|
end
|
|
7491
|
+
|
|
7492
|
+
# Cache Storage backing — origin-partitioned dumb store; the JS side owns the spec
|
|
7493
|
+
# matching (cache-storage.js). `@cache_storage` is
|
|
7494
|
+
# origin => {seq:, names: {name => cache_id}, caches: {cache_id => {seq:, entries:}}}
|
|
7495
|
+
# The name→id indirection models the spec's "dooms, but does not delete immediately":
|
|
7496
|
+
# `caches.delete(name)` unmaps the name, but a Cache handle already bound to the id
|
|
7497
|
+
# keeps operating on its own storage (a fresh `open(name)` gets a new id / empty cache).
|
|
7498
|
+
# Each entry is `{id:, meta:, response:}` — `meta` the parsed request metadata the
|
|
7499
|
+
# matcher needs ({url, method, headers, vary}), `response` an opaque serialized-Response
|
|
7500
|
+
# JSON blob. Each host fn runs a single read-modify-write under the GVL, so concurrent
|
|
7501
|
+
# access from a service-worker thread stays atomic without a lock (localStorage
|
|
7502
|
+
# precedent). A doomed cache's storage lingers until `reset!` (per-test) frees it — a
|
|
7503
|
+
# bounded leak we accept rather than refcount handles across the JS boundary.
|
|
7504
|
+
def cache_storage_open(origin, name)
|
|
7505
|
+
store = (@cache_storage[origin.to_s] ||= {seq: 0, names: {}, caches: {}})
|
|
7506
|
+
id = (store[:names][name.to_s] ||= (store[:seq] += 1))
|
|
7507
|
+
store[:caches][id] ||= {seq: 0, entries: []}
|
|
7508
|
+
id
|
|
7509
|
+
end
|
|
7510
|
+
def cache_storage_has(origin, name)
|
|
7511
|
+
@cache_storage.dig(origin.to_s, :names)&.key?(name.to_s) || false
|
|
7512
|
+
end
|
|
7513
|
+
def cache_storage_delete(origin, name)
|
|
7514
|
+
names = @cache_storage.dig(origin.to_s, :names) or return false
|
|
7515
|
+
!names.delete(name.to_s).nil?
|
|
7516
|
+
end
|
|
7517
|
+
def cache_storage_keys(origin)
|
|
7518
|
+
(@cache_storage.dig(origin.to_s, :names) || {}).keys
|
|
7519
|
+
end
|
|
7520
|
+
def cache_entries(origin, cache_id)
|
|
7521
|
+
cache = cache_for(origin, cache_id) or return nil
|
|
7522
|
+
JSON.generate(cache[:entries].map {|e| {id: e[:id]}.merge(e[:meta]) })
|
|
7523
|
+
end
|
|
7524
|
+
def cache_entry_response(origin, cache_id, entry_id)
|
|
7525
|
+
cache = cache_for(origin, cache_id) or return nil
|
|
7526
|
+
entry = cache[:entries].find {|e| e[:id] == entry_id.to_i } or return nil
|
|
7527
|
+
entry[:response]
|
|
7528
|
+
end
|
|
7529
|
+
def cache_put(origin, cache_id, delete_ids_json, meta_json, response_json)
|
|
7530
|
+
cache = cache_for(origin, cache_id) or return nil
|
|
7531
|
+
ids = JSON.parse(delete_ids_json).map(&:to_i)
|
|
7532
|
+
cache[:entries].reject! {|e| ids.include?(e[:id]) } unless ids.empty?
|
|
7533
|
+
cache[:entries] << {id: (cache[:seq] += 1), meta: JSON.parse(meta_json), response: response_json.to_s}
|
|
7534
|
+
nil
|
|
7535
|
+
end
|
|
7536
|
+
def cache_delete_entries(origin, cache_id, ids_json)
|
|
7537
|
+
cache = cache_for(origin, cache_id) or return 0
|
|
7538
|
+
ids = JSON.parse(ids_json).map(&:to_i)
|
|
7539
|
+
before = cache[:entries].size
|
|
7540
|
+
cache[:entries].reject! {|e| ids.include?(e[:id]) }
|
|
7541
|
+
before - cache[:entries].size
|
|
7542
|
+
end
|
|
7543
|
+
# The cache hash ({seq:, entries:}) bound to a Cache handle's id, or nil if it's gone.
|
|
7544
|
+
private def cache_for(origin, cache_id)
|
|
7545
|
+
@cache_storage.dig(origin.to_s, :caches, cache_id.to_i)
|
|
7546
|
+
end
|
|
5413
7547
|
# Push a one-shot handler onto the modal-dialog stack — the next
|
|
5414
7548
|
# modal that fires consumes the topmost handler. Block exit pops
|
|
5415
7549
|
# in case the dialog never fired.
|
|
@@ -5459,7 +7593,7 @@ module Capybara
|
|
|
5459
7593
|
env = Rack::MockRequest.env_for(url, method: 'GET')
|
|
5460
7594
|
apply_default_request_env(env, referer: current_browsing_context_url)
|
|
5461
7595
|
status, headers, body = dispatch_rack_or_http(url, env, method: 'GET')
|
|
5462
|
-
merge_set_cookie(headers)
|
|
7596
|
+
merge_set_cookie(headers, url)
|
|
5463
7597
|
if (loc = redirect_location(status, headers))
|
|
5464
7598
|
next_url = carry_fragment(url, resolve_against_current(loc))
|
|
5465
7599
|
body.close if body.respond_to?(:close)
|
|
@@ -5480,7 +7614,7 @@ module Capybara
|
|
|
5480
7614
|
env['CONTENT_LENGTH'] = body.bytesize.to_s
|
|
5481
7615
|
apply_default_request_env(env, referer: current_browsing_context_url)
|
|
5482
7616
|
status, headers, resp_body = dispatch_rack_or_http(url, env, method: 'POST', body: body)
|
|
5483
|
-
merge_set_cookie(headers)
|
|
7617
|
+
merge_set_cookie(headers, url)
|
|
5484
7618
|
if (loc = redirect_location(status, headers))
|
|
5485
7619
|
next_url = resolve_against_current(loc)
|
|
5486
7620
|
resp_body.close if resp_body.respond_to?(:close)
|
|
@@ -5597,7 +7731,7 @@ module Capybara
|
|
|
5597
7731
|
env = Rack::MockRequest.env_for(url, method: 'GET')
|
|
5598
7732
|
apply_default_request_env(env, referer: referer)
|
|
5599
7733
|
status, headers, body = dispatch_rack_or_http(url, env, method: 'GET')
|
|
5600
|
-
merge_set_cookie(headers)
|
|
7734
|
+
merge_set_cookie(headers, url)
|
|
5601
7735
|
if (loc = redirect_location(status, headers))
|
|
5602
7736
|
next_url = resolve_against_current(loc)
|
|
5603
7737
|
# Per RFC 7231: if the original request URL had a fragment
|
|
@@ -5825,8 +7959,147 @@ module Capybara
|
|
|
5825
7959
|
# server can negotiate — HTML-only routes still pick html,
|
|
5826
7960
|
# both-available pick the first registered.
|
|
5827
7961
|
env['HTTP_ACCEPT'] ||= DEFAULT_HTTP_ACCEPT
|
|
5828
|
-
env['HTTP_REFERER'] = referer
|
|
5829
|
-
|
|
7962
|
+
env['HTTP_REFERER'] = referer unless referer.nil? || referer.empty?
|
|
7963
|
+
# Attach the TARGET host's cookies (not the document's) — SERVER_NAME is the
|
|
7964
|
+
# request's host — so a cross-origin request carries the right jar or none.
|
|
7965
|
+
ck = cookie_header_for(env_cookie_host(env))
|
|
7966
|
+
if ck.empty?
|
|
7967
|
+
env.delete('HTTP_COOKIE')
|
|
7968
|
+
else
|
|
7969
|
+
env['HTTP_COOKIE'] = ck
|
|
7970
|
+
end
|
|
7971
|
+
end
|
|
7972
|
+
|
|
7973
|
+
# Referer / Origin / Fetch-Metadata for a NAVIGATION request (a document load — form
|
|
7974
|
+
# submit, location set, link activation). Unlike a fetch/XHR (which carries a per-request
|
|
7975
|
+
# policy through rack_fetch), a navigation's request headers derive from the INITIATING
|
|
7976
|
+
# document: its Referrer-Policy (compute_referrer), its origin (an `Origin` header — sent
|
|
7977
|
+
# only on an unsafe method, i.e. a form POST, never a GET/HEAD navigation), and the
|
|
7978
|
+
# Fetch-Metadata triple. `dest` is 'document' for a top-level nav, 'iframe' for a subframe.
|
|
7979
|
+
# `site_override` / `origin_null` carry redirect-chain latched state (navigate_realm_self_*
|
|
7980
|
+
# thread it through each hop): Sec-Fetch-Site is the WIDEST initiator↔url relationship over the
|
|
7981
|
+
# whole chain (a same-site redirect keeps 'same-site' even when the final hop is same-origin),
|
|
7982
|
+
# and a form POST's Origin becomes the opaque 'null' once any hop has crossed origin.
|
|
7983
|
+
def navigation_request_headers(env, method:, initiator_url:, target:, dest:, referrer_policy: nil, user_activated: false, site_override: nil, origin_null: false)
|
|
7984
|
+
apply_default_request_env(env, referer: compute_referrer(referrer_policy, initiator_url, target))
|
|
7985
|
+
# Origin is appended to every non-GET/HEAD request (a form POST carries it; a GET navigation
|
|
7986
|
+
# does not). From a KNOWN initiating document it's that document's origin serialization, or
|
|
7987
|
+
# the literal 'null' when that origin is opaque (an about:blank / data: frame) or has been
|
|
7988
|
+
# tainted by a cross-origin redirect — matching the fetch path's `effective_origin`
|
|
7989
|
+
# convention. Omitted only when the initiator is unknown (a disposed realm → nil url).
|
|
7990
|
+
if initiator_url && !initiator_url.to_s.empty? && !%w[GET HEAD].include?(method.to_s.upcase)
|
|
7991
|
+
env['HTTP_ORIGIN'] = (!origin_null && url_origin(initiator_url)) || 'null'
|
|
7992
|
+
end
|
|
7993
|
+
env['HTTP_SEC_FETCH_SITE'] = site_override || sec_fetch_site(initiator_url, target)
|
|
7994
|
+
env['HTTP_SEC_FETCH_MODE'] = 'navigate'
|
|
7995
|
+
env['HTTP_SEC_FETCH_DEST'] = dest.to_s
|
|
7996
|
+
env['HTTP_SEC_FETCH_USER'] = '?1' if user_activated
|
|
7997
|
+
end
|
|
7998
|
+
|
|
7999
|
+
# Build + dispatch a navigation request (a frame/document load) — the shared core of the GET
|
|
8000
|
+
# self-nav, the POST self-nav, and the navigation-preload request, so all three send the SAME
|
|
8001
|
+
# Referer / Origin / Fetch-Metadata (dest 'iframe') and can't silently diverge. `site` is the
|
|
8002
|
+
# caller's already-widened Sec-Fetch-Site (threaded across the redirect chain it owns);
|
|
8003
|
+
# `extra_headers` carries any request-specific CGI header (the preload marker). Returns
|
|
8004
|
+
# [status, headers, resp_body] — the caller handles redirects / reload / the preload wire.
|
|
8005
|
+
def dispatch_navigation_request(url, method:, initiator:, referrer_policy:, site:, origin_null:, body: nil, content_type: nil, extra_headers: nil)
|
|
8006
|
+
env = Rack::MockRequest.env_for(url, method: method, input: body || '')
|
|
8007
|
+
if content_type
|
|
8008
|
+
env['CONTENT_TYPE'] = content_type.to_s.empty? ? 'application/x-www-form-urlencoded' : content_type
|
|
8009
|
+
env['CONTENT_LENGTH'] = body.to_s.bytesize.to_s
|
|
8010
|
+
end
|
|
8011
|
+
navigation_request_headers(
|
|
8012
|
+
env,
|
|
8013
|
+
method: method,
|
|
8014
|
+
initiator_url: initiator,
|
|
8015
|
+
target: url.to_s,
|
|
8016
|
+
dest: 'iframe',
|
|
8017
|
+
referrer_policy: referrer_policy,
|
|
8018
|
+
site_override: site,
|
|
8019
|
+
origin_null: origin_null
|
|
8020
|
+
)
|
|
8021
|
+
extra_headers&.each {|k, v| env[k] = v }
|
|
8022
|
+
status, headers, resp_body = dispatch_rack_or_http(url, env, method: method, body: body)
|
|
8023
|
+
merge_set_cookie(headers, url)
|
|
8024
|
+
[status, headers, resp_body]
|
|
8025
|
+
end
|
|
8026
|
+
|
|
8027
|
+
# The Navigation Preload request: a parallel GET the browser issues for a navigation whose
|
|
8028
|
+
# controlling SW has preload enabled, exposed to the SW as `event.preloadResponse`. It IS a
|
|
8029
|
+
# navigation request (dest 'iframe', mode 'navigate', the frame's Referer / Sec-Fetch-Site — so
|
|
8030
|
+
# the server sees exactly what a no-SW navigation would) plus the `Service-Worker-Navigation-
|
|
8031
|
+
# Preload` header carrying the registration's header value. The SW intercepts the FINAL URL (a
|
|
8032
|
+
# pre-SW network redirect already happened + widened `site_seed`), so this is a single hop — no
|
|
8033
|
+
# redirect loop. Returns the response wire hash for the fetch-event JSON, or nil on a hard error.
|
|
8034
|
+
def navigation_preload_response(url, referrer_source, referrer_policy, site_seed, origin_null, header_value)
|
|
8035
|
+
site = widen_sec_fetch_site(site_seed, sec_fetch_site(referrer_source, url))
|
|
8036
|
+
status, headers, resp_body = dispatch_navigation_request(
|
|
8037
|
+
url,
|
|
8038
|
+
method: 'GET',
|
|
8039
|
+
initiator: referrer_source,
|
|
8040
|
+
referrer_policy: referrer_policy,
|
|
8041
|
+
site: site,
|
|
8042
|
+
origin_null: origin_null,
|
|
8043
|
+
extra_headers: {'HTTP_SERVICE_WORKER_NAVIGATION_PRELOAD' => header_value.to_s}
|
|
8044
|
+
)
|
|
8045
|
+
{
|
|
8046
|
+
'status' => status,
|
|
8047
|
+
'statusText' => RuntimeShared.utf8_text(Rack::Utils::HTTP_STATUS_CODES[status.to_i] || ''),
|
|
8048
|
+
'headers' => headers.to_h,
|
|
8049
|
+
# The UA transparently decodes a Content-Encoding'd body (gzip/deflate) before the SW's
|
|
8050
|
+
# `event.preloadResponse.text()` sees it — the header stays, the bytes are inflated (as the
|
|
8051
|
+
# regular fetch path does).
|
|
8052
|
+
'body_b64' => Base64.strict_encode64(decode_content_encoding(read_rack_body(resp_body), headers))
|
|
8053
|
+
}
|
|
8054
|
+
rescue StandardError
|
|
8055
|
+
nil
|
|
8056
|
+
end
|
|
8057
|
+
|
|
8058
|
+
# Fetch-Metadata `Sec-Fetch-Site`: the relationship of a request's INITIATOR to its target.
|
|
8059
|
+
# no initiator (a direct address-bar load) → 'none'; same (scheme,host,port) → 'same-origin';
|
|
8060
|
+
# same registrable site (eTLD+1) → 'same-site'; else → 'cross-site'.
|
|
8061
|
+
def sec_fetch_site(initiator_url, target_url)
|
|
8062
|
+
io = url_origin(initiator_url)
|
|
8063
|
+
return 'none' if io.nil?
|
|
8064
|
+
return 'same-origin' if io == url_origin(target_url)
|
|
8065
|
+
is = registrable_site(initiator_url)
|
|
8066
|
+
ts = registrable_site(target_url)
|
|
8067
|
+
is && ts && is == ts ? 'same-site' : 'cross-site'
|
|
8068
|
+
end
|
|
8069
|
+
|
|
8070
|
+
SEC_FETCH_SITE_RANK = {'same-origin' => 0, 'same-site' => 1, 'cross-site' => 2, 'none' => 2}.freeze
|
|
8071
|
+
private_constant :SEC_FETCH_SITE_RANK
|
|
8072
|
+
# The wider (more distant) of two Sec-Fetch-Site values — used to latch the value across a
|
|
8073
|
+
# redirect chain (same-origin < same-site < cross-site). `nil` seed → the other value.
|
|
8074
|
+
def widen_sec_fetch_site(a, b)
|
|
8075
|
+
return b if a.nil?
|
|
8076
|
+
SEC_FETCH_SITE_RANK[b].to_i > SEC_FETCH_SITE_RANK[a].to_i ? b : a
|
|
8077
|
+
end
|
|
8078
|
+
|
|
8079
|
+
# Fetch "HTTP-redirect fetch": a request's Origin opaques to 'null' once it follows a
|
|
8080
|
+
# cross-origin redirect WHILE ALREADY off the initiator's origin — i.e. the current URL is
|
|
8081
|
+
# cross-origin to BOTH the redirect target and the initiator. The FIRST cross-origin hop (still
|
|
8082
|
+
# on the initiator's origin) keeps the real Origin; a later hop that redirects same-origin-to-
|
|
8083
|
+
# current keeps it too. Monotonic: once opaque, an opaque origin is same-origin with nothing, so
|
|
8084
|
+
# it stays null.
|
|
8085
|
+
def redirect_taints_origin?(already_null, initiator_url, current_url, location_url)
|
|
8086
|
+
already_null ||
|
|
8087
|
+
(url_origin(current_url) != url_origin(location_url) && url_origin(initiator_url) != url_origin(current_url))
|
|
8088
|
+
end
|
|
8089
|
+
|
|
8090
|
+
# scheme + registrable domain (approx eTLD+1) of a URL — the "site" a Sec-Fetch-Site /
|
|
8091
|
+
# same-site comparison uses. Last-two-labels approximation (no Public Suffix List — correct
|
|
8092
|
+
# for the single-label TLDs our hosts use); an IP literal / ≤2-label host is its own site.
|
|
8093
|
+
# A `blob:` URL derives from its inner origin. nil for a hostless / non-http(s) URL.
|
|
8094
|
+
def registrable_site(url)
|
|
8095
|
+
u = URI.parse(url.to_s.sub(/\Ablob:/, ''))
|
|
8096
|
+
host = u.host.to_s
|
|
8097
|
+
return nil if host.empty? || !u.scheme&.match?(/\Ahttps?\z/i)
|
|
8098
|
+
labels = host.split('.')
|
|
8099
|
+
regd = host.start_with?('[') || host.match?(/\A\d+(\.\d+){3}\z/) || labels.length <= 2 ? host : labels.last(2).join('.')
|
|
8100
|
+
"#{u.scheme}://#{regd}"
|
|
8101
|
+
rescue URI::Error
|
|
8102
|
+
nil
|
|
5830
8103
|
end
|
|
5831
8104
|
|
|
5832
8105
|
# Cross-host hop (e.g. Discourse's `discourse_connect` flow
|
|
@@ -5911,9 +8184,22 @@ module Capybara
|
|
|
5911
8184
|
nil
|
|
5912
8185
|
end
|
|
5913
8186
|
|
|
5914
|
-
|
|
8187
|
+
# Store a response's Set-Cookie headers under the RESPONDING host's jar (`url` is
|
|
8188
|
+
# the hop that produced `headers`). A cross-origin hop therefore writes its own
|
|
8189
|
+
# host's jar, never the document's (cors-cookies isolation).
|
|
8190
|
+
# True if the response carries a `WWW-Authenticate: Basic …` challenge — the only scheme the
|
|
8191
|
+
# transparent-auth retry in rack_fetch answers.
|
|
8192
|
+
def www_authenticate_basic?(headers)
|
|
8193
|
+
return false unless headers.is_a?(Hash)
|
|
8194
|
+
headers.each {|k, v| return true if k.to_s.casecmp?('www-authenticate') && v.to_s.strip.downcase.start_with?('basic') }
|
|
8195
|
+
false
|
|
8196
|
+
end
|
|
8197
|
+
|
|
8198
|
+
def merge_set_cookie(headers, url)
|
|
5915
8199
|
sc = headers['set-cookie'] || headers['Set-Cookie']
|
|
5916
8200
|
return if sc.nil? || sc.empty?
|
|
8201
|
+
host = cookie_host(url) || document_cookie_host or return
|
|
8202
|
+
jar = (@cookies[host] ||= {})
|
|
5917
8203
|
# Rack 2 returns multiple Set-Cookie headers as a single
|
|
5918
8204
|
# newline-separated string; Rack 3 returns an Array. Treat both
|
|
5919
8205
|
# uniformly — splitting first means the second cookie in a
|
|
@@ -5926,9 +8212,9 @@ module Capybara
|
|
|
5926
8212
|
name, value = pair.split('=', 2)
|
|
5927
8213
|
next if name.nil? || name.empty?
|
|
5928
8214
|
if cookie_deletion?(parts)
|
|
5929
|
-
|
|
8215
|
+
jar.delete(name.strip)
|
|
5930
8216
|
else
|
|
5931
|
-
|
|
8217
|
+
jar[name.strip] = value.to_s.strip
|
|
5932
8218
|
end
|
|
5933
8219
|
}
|
|
5934
8220
|
end
|
|
@@ -6026,19 +8312,49 @@ module Capybara
|
|
|
6026
8312
|
# preflight whose Access-Control-Allow-Methods / -Headers carries a malformed value.
|
|
6027
8313
|
HTTP_TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/.freeze
|
|
6028
8314
|
CORS_SAFELISTED_CTYPES = %w[application/x-www-form-urlencoded multipart/form-data text/plain].freeze
|
|
8315
|
+
# Fetch "CORS-unsafe request-header byte" — a byte in a header value that forces a
|
|
8316
|
+
# safelisted-name header out of the safelisted set (so a preflight becomes necessary):
|
|
8317
|
+
# a control byte other than HT (0x09), DEL, or one of the delimiters "(),:<>?@[\]{}.
|
|
8318
|
+
CORS_UNSAFE_VALUE_BYTE = /[\x00-\x08\x0a-\x1f\x7f"():<>?@\[\\\]{}]/n.freeze
|
|
8319
|
+
# accept-language / content-language values are further restricted: only digits,
|
|
8320
|
+
# ASCII letters, space, and `*,-.;=` keep them safelisted.
|
|
8321
|
+
CORS_LANGUAGE_VALUE = /\A[0-9A-Za-z *,\-.;=]*\z/n.freeze
|
|
8322
|
+
|
|
8323
|
+
# Fetch "CORS-safelisted request-header": a (name, value) whose value keeps the
|
|
8324
|
+
# request "simple" (no preflight). All four names cap the value at 128 bytes; each
|
|
8325
|
+
# then constrains which bytes the value may contain (a `"` in Accept, a control byte
|
|
8326
|
+
# in Content-Language, an over-long text/plain Content-Type all force a preflight —
|
|
8327
|
+
# cors-preflight-not-cors-safelisted).
|
|
8328
|
+
def cors_safelisted_request_header?(name, value)
|
|
8329
|
+
v = value.to_s.b
|
|
8330
|
+
return false if v.bytesize > 128
|
|
8331
|
+
case name
|
|
8332
|
+
when 'accept'
|
|
8333
|
+
!v.match?(CORS_UNSAFE_VALUE_BYTE)
|
|
8334
|
+
when 'accept-language', 'content-language'
|
|
8335
|
+
v.match?(CORS_LANGUAGE_VALUE)
|
|
8336
|
+
when 'content-type'
|
|
8337
|
+
return false if v.match?(CORS_UNSAFE_VALUE_BYTE)
|
|
8338
|
+
essence = v.split(';', 2).first.to_s.strip.downcase
|
|
8339
|
+
CORS_SAFELISTED_CTYPES.include?(essence)
|
|
8340
|
+
else
|
|
8341
|
+
false
|
|
8342
|
+
end
|
|
8343
|
+
end
|
|
6029
8344
|
|
|
6030
|
-
# The sorted, lowercased author header names that are NOT CORS-safelisted
|
|
6031
|
-
#
|
|
6032
|
-
#
|
|
8345
|
+
# The sorted, lowercased author header names that are NOT CORS-safelisted. A
|
|
8346
|
+
# safelisted NAME still counts as unsafe when its VALUE fails the safelisting (an
|
|
8347
|
+
# unsafe byte / over-128-byte length / non-safelisted Content-Type essence). These
|
|
8348
|
+
# are echoed in Access-Control-Request-Headers for the preflight and must be covered
|
|
8349
|
+
# by Access-Control-Allow-Headers.
|
|
6033
8350
|
def cors_unsafe_headers(headers)
|
|
6034
8351
|
(headers || {}).filter_map {|k, v|
|
|
6035
8352
|
name = k.to_s.downcase
|
|
6036
8353
|
next if name.start_with?('x-csim') || name == 'content-length'
|
|
6037
|
-
if name
|
|
6038
|
-
|
|
6039
|
-
CORS_SAFELISTED_CTYPES.include?(essence) ? nil : name
|
|
8354
|
+
if CORS_SAFELISTED_HEADERS.include?(name)
|
|
8355
|
+
cors_safelisted_request_header?(name, v) ? nil : name
|
|
6040
8356
|
else
|
|
6041
|
-
|
|
8357
|
+
name
|
|
6042
8358
|
end
|
|
6043
8359
|
}.uniq.sort
|
|
6044
8360
|
end
|
|
@@ -6161,6 +8477,8 @@ module Capybara
|
|
|
6161
8477
|
env['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'] = unsafe.join(',') unless unsafe.empty?
|
|
6162
8478
|
status, ph, pbody = dispatch_rack_or_http(target, env, method: 'OPTIONS', body: nil)
|
|
6163
8479
|
pbody.close if pbody.respond_to?(:close)
|
|
8480
|
+
# A slow preflight counts toward the client's timeout too (timeout-multiple-fetches).
|
|
8481
|
+
@fetch_server_delay_ms += server_delay_ms_of(ph) if @fetch_server_delay_ms
|
|
6164
8482
|
return nil unless (200..299).include?(status.to_i)
|
|
6165
8483
|
acao = cors_header(ph, 'access-control-allow-origin')
|
|
6166
8484
|
# A credentialed preflight can't be allowed by the wildcard origin and must carry
|
|
@@ -6186,19 +8504,29 @@ module Capybara
|
|
|
6186
8504
|
|
|
6187
8505
|
# The response headers a cross-origin "cors" response exposes to getResponseHeader /
|
|
6188
8506
|
# getAllResponseHeaders: the CORS-safelisted set plus any named in Access-Control
|
|
6189
|
-
# -Expose-Headers
|
|
6190
|
-
#
|
|
6191
|
-
|
|
8507
|
+
# -Expose-Headers. `*` exposes every header, but ONLY for a non-credentialed response;
|
|
8508
|
+
# with credentials the wildcard loses its meaning and matches a header literally named
|
|
8509
|
+
# `*` (cors-expose-star "only matches literally").
|
|
8510
|
+
# The virtual server delay a response carries (X-Csim-Server-Delay-Ms, ms), 0 if none.
|
|
8511
|
+
def server_delay_ms_of(headers)
|
|
8512
|
+
return 0 unless headers.is_a?(Hash)
|
|
8513
|
+
pair = headers.find {|k, _| k.to_s.casecmp?('x-csim-server-delay-ms') }
|
|
8514
|
+
pair ? pair.last.to_i : 0
|
|
8515
|
+
end
|
|
8516
|
+
|
|
8517
|
+
def cors_exposed_headers(headers, credentialed = false)
|
|
6192
8518
|
# set-cookie / set-cookie2 are forbidden response-header names — NEVER exposed to
|
|
6193
|
-
# script, even
|
|
6194
|
-
#
|
|
6195
|
-
#
|
|
6196
|
-
#
|
|
8519
|
+
# script, even when explicitly named in Access-Control-Expose-Headers or covered by
|
|
8520
|
+
# `*` (cors-filtering "header is forbidden"). x-csim-status-text is our internal
|
|
8521
|
+
# reason-phrase sentinel (response_hash lifts it into statusText, which IS exposed
|
|
8522
|
+
# cross-origin, then strips it from the script-visible map), so it must survive.
|
|
6197
8523
|
forbidden = %w[set-cookie set-cookie2]
|
|
6198
8524
|
expose = cors_list(cors_header(headers, 'access-control-expose-headers')).map(&:downcase)
|
|
6199
|
-
|
|
8525
|
+
if !credentialed && expose.include?('*')
|
|
8526
|
+
return headers.reject {|k, _| forbidden.include?(k.to_s.downcase) }
|
|
8527
|
+
end
|
|
6200
8528
|
allowed = CORS_SAFELISTED_RESPONSE_HEADERS + expose + ['x-csim-status-text']
|
|
6201
|
-
headers.select {|k, _| allowed.include?(k.to_s.downcase) }
|
|
8529
|
+
headers.select {|k, _| allowed.include?(k.to_s.downcase) && !forbidden.include?(k.to_s.downcase) }
|
|
6202
8530
|
end
|
|
6203
8531
|
|
|
6204
8532
|
# Case-insensitive response-header lookup + comma-list split for the CORS checks.
|