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