capybara-simulated 0.5.0 → 0.7.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 +58 -218
- data/lib/capybara/simulated/asset_cache.rb +15 -5
- data/lib/capybara/simulated/browser.rb +1623 -172
- data/lib/capybara/simulated/driver.rb +233 -18
- data/lib/capybara/simulated/js/bridge.bundle.js +18753 -12419
- data/lib/capybara/simulated/quickjs_runtime.rb +17 -6
- data/lib/capybara/simulated/runtime_shared.rb +33 -6
- data/lib/capybara/simulated/v8_runtime.rb +157 -32
- data/lib/capybara/simulated/version.rb +1 -1
- data/vendor/js/vendor.bundle.js +13 -9
- metadata +1 -1
|
@@ -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
|
|
@@ -97,6 +111,16 @@ module Capybara
|
|
|
97
111
|
# green at 100 across gem 1579, WPT 660, Forem, Avo, :464 passing). Clamped
|
|
98
112
|
# >=1 so a `CSIM_POLL_TICK_STEP_MS=0` misconfig can't freeze the fixed-step path.
|
|
99
113
|
POLL_TICK_STEP_MS = [(ENV['CSIM_POLL_TICK_STEP_MS'] || '100').to_i, 1].max
|
|
114
|
+
# One animation frame (~60 Hz, whole ms — `run_loop_step` truncates). When a
|
|
115
|
+
# poll advances the clock while the page has work runnable NOW (a rAF chain or
|
|
116
|
+
# a timer burst), `tick_real_time` runs the advance in chunks this size so the
|
|
117
|
+
# page's rendering runs at real-browser cadence (one render phase per frame),
|
|
118
|
+
# not one `POLL_TICK_STEP_MS` super-frame — the same model the WPT drain uses.
|
|
119
|
+
FRAME_STEP_MS = 16
|
|
120
|
+
# Per-poll task-iteration cap, mirroring `RuntimeShared#run_loop_step`'s own
|
|
121
|
+
# default — shared across the frame chunks of one poll so sub-stepping keeps
|
|
122
|
+
# the same per-poll ceiling the single-step path had.
|
|
123
|
+
RUN_LOOP_MAX_ITER = 10_000
|
|
100
124
|
# Horizon-gated fast-forward: when the page is observably idle (no timer due
|
|
101
125
|
# now, no background IO) but a timer is parked within this horizon, jump the
|
|
102
126
|
# virtual clock straight to it instead of waiting ~delay/step polls. A timer
|
|
@@ -116,6 +140,11 @@ module Capybara
|
|
|
116
140
|
# timer/microtask storm so one settle iter returns to Ruby; large enough
|
|
117
141
|
# for the heaviest legit chain (Mastodon hydrate, Turbo stream batch).
|
|
118
142
|
SETTLE_MAX_ITER_TASKS = 256
|
|
143
|
+
# Backstop for the post-boot deferred-external-script drain: a dynamically
|
|
144
|
+
# inserted external <script> runs async (setTimeout 0), so an app's chunk
|
|
145
|
+
# loader chains many of them at boot. We pump only ALREADY-due tasks until the
|
|
146
|
+
# pending count hits 0; this caps a pathological self-injecting loader.
|
|
147
|
+
BOOT_SCRIPT_DRAIN_MAX_ITER = 300
|
|
119
148
|
# Post-user-action virtual-clock advance. Default 0 — the
|
|
120
149
|
# wall-sync model (each tick_real_time advances by the wall
|
|
121
150
|
# ms elapsed since the last tick) lets Capybara's outer poll
|
|
@@ -167,9 +196,10 @@ module Capybara
|
|
|
167
196
|
Rack::Mime.mime_type(File.extname(path.to_s), '')
|
|
168
197
|
end
|
|
169
198
|
|
|
170
|
-
def initialize(app, driver: nil, js_engine: nil, cookies: nil, local_storage: nil)
|
|
199
|
+
def initialize(app, driver: nil, js_engine: nil, cookies: nil, local_storage: nil, all_hosts_local: nil)
|
|
171
200
|
@app = app
|
|
172
201
|
@driver = driver
|
|
202
|
+
@all_hosts_local_override = all_hosts_local
|
|
173
203
|
@runtime = build_runtime(js_engine)
|
|
174
204
|
# Per-poll clock decisions cached at construction (CLAUDE.md rule 3 — the
|
|
175
205
|
# runtime type + env are fixed for the session): the wall-sync escape
|
|
@@ -218,6 +248,18 @@ module Capybara
|
|
|
218
248
|
# `Capybara.app_host` / `server_host` / `server_port` on every
|
|
219
249
|
# rack call (CLAUDE.md: cache env decisions at construction).
|
|
220
250
|
@default_host = self.class.default_host
|
|
251
|
+
# The WPT runner serves EVERY host through one in-process Rack app (the
|
|
252
|
+
# wptserve catch-all), so cross-origin test fixtures are genuinely local
|
|
253
|
+
# there. Setting this makes `url_is_local?` true for all hosts, so a
|
|
254
|
+
# cross-origin iframe eager-builds + is served by @app.call directly
|
|
255
|
+
# (no failing net_http_fetch). Apps leave it off: an external embed stays
|
|
256
|
+
# non-local → lazy → no @app.call side effect (extra visit / log row).
|
|
257
|
+
# Universal-server context (every host served in-process → cross-origin frames
|
|
258
|
+
# eager-build). The Driver captures this at session-construction time (when the
|
|
259
|
+
# WPT runner has the env set) and passes it to EVERY window it builds, so an aux
|
|
260
|
+
# window opened LATER — after the runner restored the env — still inherits it.
|
|
261
|
+
# nil override (a stand-alone Browser) falls back to the live env check.
|
|
262
|
+
@all_hosts_local = @all_hosts_local_override.nil? ? (ENV['CSIM_LOCAL_ALL_HOSTS'] == '1') : @all_hosts_local_override
|
|
221
263
|
# Handle IDs are per-Context integer sequences: a handle from
|
|
222
264
|
# a pre-rebuild context could collide with a fresh node's id
|
|
223
265
|
# in the new context. Node captures this on construction;
|
|
@@ -243,6 +285,7 @@ module Capybara
|
|
|
243
285
|
@ticking = false
|
|
244
286
|
@history = []
|
|
245
287
|
@history_idx = -1
|
|
288
|
+
@cors_preflight_cache = {}
|
|
246
289
|
@modal_handlers = []
|
|
247
290
|
# Geolocation override (CDP-ish). nil = no override configured →
|
|
248
291
|
# navigator.geolocation reports POSITION_UNAVAILABLE. Ruby-backed so
|
|
@@ -403,8 +446,8 @@ module Capybara
|
|
|
403
446
|
|
|
404
447
|
# Address-bar navigation: no Referer, and relative paths resolve
|
|
405
448
|
# against the host root (not the current page's directory).
|
|
406
|
-
def visit(url)
|
|
407
|
-
navigate(resolve_visit_url(url), referer:
|
|
449
|
+
def visit(url, referer: nil)
|
|
450
|
+
navigate(resolve_visit_url(url), referer: referer)
|
|
408
451
|
end
|
|
409
452
|
|
|
410
453
|
URL_UNSAFE_CHARS = %r{[^!*'();:@&=+$,/?#\[\]A-Za-z0-9\-._~%]}n.freeze
|
|
@@ -915,12 +958,13 @@ module Capybara
|
|
|
915
958
|
end
|
|
916
959
|
# `target="_blank"` (or any non-_self/_top/_parent name) opens
|
|
917
960
|
# in a new browsing context (its own Browser/VM); the primary
|
|
918
|
-
# stays put (per HTML spec — original window is unaffected).
|
|
919
|
-
# `
|
|
920
|
-
#
|
|
921
|
-
#
|
|
961
|
+
# stays put (per HTML spec — original window is unaffected). A
|
|
962
|
+
# `target=_blank` link defaults to `noopener` (window.opener null) unless
|
|
963
|
+
# `rel=opener` (carried as `action['opener']`); open_aux_window forces
|
|
964
|
+
# noopener anyway for a cross-partition blob. Matches the scripted
|
|
965
|
+
# click / dispatchEvent activation paths.
|
|
922
966
|
elsif !target.empty? && !%w[_self _top _parent].include?(target.downcase) && @driver.respond_to?(:open_aux_window)
|
|
923
|
-
@driver.open_aux_window(resolve_against_current(url, use_base: true), source: self, blob_snapshot: action['blob'])
|
|
967
|
+
@driver.open_aux_window(resolve_against_current(url, use_base: true), source: self, opener: !!action['opener'], blob_snapshot: action['blob'])
|
|
924
968
|
# In-page anchor links (`#frag` / current-page + `#frag`) move
|
|
925
969
|
# the hash but don't fetch a new document. Pure-fragment also
|
|
926
970
|
# short-circuits the `<a>`s test fixtures use as click sinks.
|
|
@@ -966,7 +1010,7 @@ module Capybara
|
|
|
966
1010
|
elsif @current_url != submit_baseline_url
|
|
967
1011
|
# Already navigated; nothing more to do.
|
|
968
1012
|
else
|
|
969
|
-
submit_form_handle(action['formHandle'], action['submitter'])
|
|
1013
|
+
submit_form_handle(action['formHandle'], action['submitter'], action['entryList'])
|
|
970
1014
|
end
|
|
971
1015
|
when 'download'
|
|
972
1016
|
download_link(resolve_against_current(action['url'].to_s), action['filename'].to_s)
|
|
@@ -1035,7 +1079,7 @@ module Capybara
|
|
|
1035
1079
|
# `attach_file` hands us a Pathname (or Array of Pathnames);
|
|
1036
1080
|
# the marshaller rejects non-primitive types. Coerce to a path-list
|
|
1037
1081
|
# form V8 can hold — the actual multipart upload happens later
|
|
1038
|
-
# in `
|
|
1082
|
+
# in `encode_entry_list` during form submission.
|
|
1039
1083
|
coerced = coerce_set_value(value)
|
|
1040
1084
|
# For date/time-shaped inputs we need the type-specific
|
|
1041
1085
|
# string. Probe the handle's `type` and re-format Date / Time
|
|
@@ -1375,7 +1419,7 @@ module Capybara
|
|
|
1375
1419
|
def consume_pending_form_submit
|
|
1376
1420
|
pending = @runtime.call('__csimTakePendingFormSubmit')
|
|
1377
1421
|
return unless pending.is_a?(Hash) && pending['formHandle']
|
|
1378
|
-
submit_form_handle(pending['formHandle'].to_i, pending['submitterHandle'])
|
|
1422
|
+
submit_form_handle(pending['formHandle'].to_i, pending['submitterHandle'], pending['entryList'])
|
|
1379
1423
|
end
|
|
1380
1424
|
|
|
1381
1425
|
# Pin the URL the page is at as a user action BEGINS — the FIRST line of
|
|
@@ -1475,7 +1519,9 @@ module Capybara
|
|
|
1475
1519
|
url = pending['url'].to_s
|
|
1476
1520
|
target = pending['target'].to_s
|
|
1477
1521
|
if !target.empty? && !%w[_self _top _parent].include?(target.downcase) && @driver.respond_to?(:open_aux_window)
|
|
1478
|
-
|
|
1522
|
+
# `opener` reflects rel=opener (a bare target=_blank link is noopener);
|
|
1523
|
+
# open_aux_window forces noopener anyway for a cross-partition blob.
|
|
1524
|
+
@driver.open_aux_window(resolve_against_current(url, use_base: true), source: self, opener: !!pending['opener'], blob_snapshot: pending['blob'])
|
|
1479
1525
|
elsif pure_fragment_navigation?(url)
|
|
1480
1526
|
update_current_hash(url)
|
|
1481
1527
|
else
|
|
@@ -1607,11 +1653,34 @@ module Capybara
|
|
|
1607
1653
|
def go_back ; history_go(-1, force: true) ; end
|
|
1608
1654
|
def go_forward ; history_go(+1, force: true) ; end
|
|
1609
1655
|
|
|
1656
|
+
# Reset the session history to empty WITHOUT the full `reset!` (cookies /
|
|
1657
|
+
# storage / viewport / frame scope stay put). A single session that visits
|
|
1658
|
+
# many documents in sequence — the WPT conformance runner reuses one for
|
|
1659
|
+
# the whole 1645-file suite — otherwise accumulates every prior visit's
|
|
1660
|
+
# history entry, so a document that calls `history.back()` (e.g. a bfcache
|
|
1661
|
+
# round-trip test) traverses the SHARED history back into the PREVIOUS
|
|
1662
|
+
# document and re-runs it. Clearing history per visit confines each
|
|
1663
|
+
# document's back / forward to its own navigations, matching a real
|
|
1664
|
+
# browser's fresh-browsing-context isolation, so results don't depend on
|
|
1665
|
+
# visit order. Not wired into `visit` itself — a normal session keeps
|
|
1666
|
+
# cross-`visit` back-navigation (Selenium parity); only callers that want
|
|
1667
|
+
# per-document isolation invoke it.
|
|
1668
|
+
def reset_history!
|
|
1669
|
+
@history.clear
|
|
1670
|
+
@history_idx = -1
|
|
1671
|
+
@pending_history_traverse = nil
|
|
1672
|
+
end
|
|
1673
|
+
|
|
1610
1674
|
# Move through the history stack by `delta`. Per HTML spec, a
|
|
1611
1675
|
# same-document traversal (within a chain of pushState entries
|
|
1612
1676
|
# rooted at a single navigation) updates `location` and fires
|
|
1613
1677
|
# `popstate` with the entry's state — no full reload. A cross-
|
|
1614
1678
|
# document traversal replays the entry (full navigate / re-POST).
|
|
1679
|
+
# Returns the traversal kind so a cross-window caller
|
|
1680
|
+
# (`window_history_go`) knows whether to fire the target window's
|
|
1681
|
+
# deferred `load`: `:same_document` (pushState traversal — popstate
|
|
1682
|
+
# already fired, no load), `:cross_document` (full document replay —
|
|
1683
|
+
# load follows), or `nil` (no-op: zero delta / out of range).
|
|
1615
1684
|
def history_go(delta, force: false)
|
|
1616
1685
|
delta = delta.to_i
|
|
1617
1686
|
return if delta == 0
|
|
@@ -1626,10 +1695,13 @@ module Capybara
|
|
|
1626
1695
|
@current_url = entry[:url]
|
|
1627
1696
|
@runtime.call('__csimUpdateLocation', @current_url)
|
|
1628
1697
|
@runtime.call('__csimDispatchPopState', entry[:state])
|
|
1698
|
+
:same_document
|
|
1629
1699
|
elsif force
|
|
1630
|
-
# Ruby-driven (`page.go_back`)
|
|
1631
|
-
#
|
|
1700
|
+
# Ruby-driven (`page.go_back`), or a non-active window driven by its
|
|
1701
|
+
# opener (`w.history.back()`) — no live JS call on THIS window's
|
|
1702
|
+
# isolate to interrupt, safe to rebuild the Context synchronously.
|
|
1632
1703
|
perform_history_traverse(target)
|
|
1704
|
+
:cross_document
|
|
1633
1705
|
else
|
|
1634
1706
|
# JS-driven (`history.back()` from a page handler): replaying
|
|
1635
1707
|
# the history entry synchronously would call `rebuild_ctx`
|
|
@@ -1639,6 +1711,7 @@ module Capybara
|
|
|
1639
1711
|
# and drain after the call returns — mirrors
|
|
1640
1712
|
# `location_assign` / `location_reload`.
|
|
1641
1713
|
@pending_history_traverse = target
|
|
1714
|
+
:cross_document
|
|
1642
1715
|
end
|
|
1643
1716
|
end
|
|
1644
1717
|
|
|
@@ -1649,8 +1722,29 @@ module Capybara
|
|
|
1649
1722
|
end
|
|
1650
1723
|
|
|
1651
1724
|
private def perform_history_traverse(target)
|
|
1725
|
+
capture_outgoing_form_state
|
|
1652
1726
|
@history_idx = target
|
|
1653
1727
|
replay_history_entry(@history[target])
|
|
1728
|
+
restore_form_state(@history[target])
|
|
1729
|
+
end
|
|
1730
|
+
|
|
1731
|
+
# Snapshot the OUTGOING document's form-control state into the history entry
|
|
1732
|
+
# we are leaving, so a later back/forward traversal to it restores the
|
|
1733
|
+
# values the user/script had set (HTML "persisted user state"), not the
|
|
1734
|
+
# markup defaults. Captured while the outgoing VM is still live — before
|
|
1735
|
+
# record_history advances the index or boot rebuilds the context.
|
|
1736
|
+
def capture_outgoing_form_state
|
|
1737
|
+
return if @history_idx < 0 || (entry = @history[@history_idx]).nil?
|
|
1738
|
+
state = (@runtime.call('__csimCaptureFormState') rescue nil)
|
|
1739
|
+
entry[:form_state] = state if state
|
|
1740
|
+
end
|
|
1741
|
+
|
|
1742
|
+
# Re-apply a history entry's captured form state after its document has been
|
|
1743
|
+
# rebuilt. The JS setters set the dirty value flag WITHOUT firing
|
|
1744
|
+
# input/change, so a restored value doesn't look like a fresh user edit.
|
|
1745
|
+
def restore_form_state(entry)
|
|
1746
|
+
return unless entry && (state = entry[:form_state])
|
|
1747
|
+
@runtime.call('__csimRestoreFormState', state) rescue nil
|
|
1654
1748
|
end
|
|
1655
1749
|
|
|
1656
1750
|
# Same-document = every entry between `from` and `to` (inclusive)
|
|
@@ -1993,22 +2087,54 @@ module Capybara
|
|
|
1993
2087
|
@last_tick_ts = now
|
|
1994
2088
|
effective_step = step_ms || horizon_fast_forward_step
|
|
1995
2089
|
if @timers_active && effective_step > 0
|
|
1996
|
-
|
|
1997
|
-
# `
|
|
1998
|
-
#
|
|
1999
|
-
#
|
|
2000
|
-
|
|
2090
|
+
# When the page has work runnable NOW (a rAF chain / timer burst — set
|
|
2091
|
+
# by `horizon_fast_forward_step`), run the poll's worth of virtual time
|
|
2092
|
+
# in frame-sized chunks so the page renders at real-browser cadence
|
|
2093
|
+
# rather than one `POLL_TICK_STEP_MS` super-frame. The `step_ms` path
|
|
2094
|
+
# (explicit `sleep` / `wait_for_timeout`) and idle/parked/fast-forward
|
|
2095
|
+
# polls keep the single step — nothing is rendering frame-by-frame, so
|
|
2096
|
+
# sub-stepping would only spin empty render phases. The tail-jump bails
|
|
2097
|
+
# the moment a frame goes quiet, so a chain that settles early (or the
|
|
2098
|
+
# rare quiet sub-step) doesn't pay for the unused remainder.
|
|
2099
|
+
if step_ms.nil? && @page_runnable_now && effective_step > FRAME_STEP_MS
|
|
2100
|
+
remaining = effective_step
|
|
2101
|
+
# Share ONE task-iteration budget across the chunks so the per-poll
|
|
2102
|
+
# cap matches the single-step path (each `run_loop_step` otherwise
|
|
2103
|
+
# gets a fresh `RUN_LOOP_MAX_ITER`, so an always-due `setInterval(0)`
|
|
2104
|
+
# busy loop could run N× the work). Exhausting it ends the poll —
|
|
2105
|
+
# the clock is stuck on that loop either way.
|
|
2106
|
+
iter_budget = RUN_LOOP_MAX_ITER
|
|
2107
|
+
while remaining > 0 && iter_budget > 0
|
|
2108
|
+
chunk = remaining < FRAME_STEP_MS ? remaining : FRAME_STEP_MS
|
|
2109
|
+
r = @runtime.run_loop_step(chunk, iter_budget)
|
|
2110
|
+
@find_cache_dirty = true if r['dirtied'] || r['fired'].to_i > 0
|
|
2111
|
+
remaining -= chunk
|
|
2112
|
+
iter_budget -= r['fired'].to_i
|
|
2113
|
+
# Idle frame → nothing left to render frame-by-frame this poll: jump
|
|
2114
|
+
# the remaining advance in one step (fires any timer parked within
|
|
2115
|
+
# it). A still-queued rAF (`r['raf']`) is NOT idle — a non-mutating
|
|
2116
|
+
# animation chain fires no timer and dirties nothing yet keeps
|
|
2117
|
+
# rendering, so keep sub-stepping it at frame cadence.
|
|
2118
|
+
if remaining > 0 && r['fired'].to_i.zero? && !r['dirtied'] && !r['raf']
|
|
2119
|
+
r = @runtime.run_loop_step(remaining, iter_budget)
|
|
2120
|
+
@find_cache_dirty = true if r['dirtied'] || r['fired'].to_i > 0
|
|
2121
|
+
break
|
|
2122
|
+
end
|
|
2123
|
+
end
|
|
2124
|
+
else
|
|
2125
|
+
r = @runtime.run_loop_step(effective_step)
|
|
2126
|
+
# `dirtied` (settleGen changed) catches a render-phase rAF / microtask-
|
|
2127
|
+
# delivered MutationObserver that mutated the DOM without firing a timer
|
|
2128
|
+
# (fired == 0) — a fired-count-only test would leave a stale find cache.
|
|
2129
|
+
@find_cache_dirty = true if r['dirtied'] || r['fired'].to_i > 0
|
|
2130
|
+
end
|
|
2001
2131
|
end
|
|
2002
2132
|
# Pull any pending Worker / EventSource messages into JS
|
|
2003
2133
|
# state. Without this, `evaluate_script` after kicking off
|
|
2004
2134
|
# a worker round-trip would see stale state — the inbox
|
|
2005
2135
|
# outbox only drains during `settle`, which doesn't run
|
|
2006
2136
|
# for direct `execute_script` / `evaluate_script` calls.
|
|
2007
|
-
|
|
2008
|
-
@find_cache_dirty = true if deliver_event_source_events > 0
|
|
2009
|
-
@find_cache_dirty = true if deliver_hijacked_fetches > 0
|
|
2010
|
-
@find_cache_dirty = true if deliver_window_messages > 0
|
|
2011
|
-
@find_cache_dirty = true if deliver_websocket_events > 0
|
|
2137
|
+
drain_async_channels
|
|
2012
2138
|
ensure
|
|
2013
2139
|
@ticking = false
|
|
2014
2140
|
end
|
|
@@ -2032,6 +2158,142 @@ module Capybara
|
|
|
2032
2158
|
consume_pending_download
|
|
2033
2159
|
end
|
|
2034
2160
|
|
|
2161
|
+
# Backstop on the per-frame quiescence loop — caps the event-loop turns
|
|
2162
|
+
# processed at a single instant so a `setInterval(0)` (always-due) busy loop
|
|
2163
|
+
# advances a frame and continues, rather than spinning forever. Generous:
|
|
2164
|
+
# real frames here run hundreds of microtask/rAF turns (e.g. ~80 sequential
|
|
2165
|
+
# rAF promise_tests, each ~2 render turns).
|
|
2166
|
+
EVENT_LOOP_QUIESCENCE_CAP = 512
|
|
2167
|
+
|
|
2168
|
+
# Run ONE real-cadence event-loop frame and report the loop's observable
|
|
2169
|
+
# state. A general primitive — it models a browser animation frame and knows
|
|
2170
|
+
# nothing about any particular test harness; the WPT runner is its first
|
|
2171
|
+
# caller (driving a page to completion one frame at a time). It advances the
|
|
2172
|
+
# same virtual clock as the Capybara poll path: `tick_real_time` keeps its own
|
|
2173
|
+
# per-poll BUDGET (a ~100 ms `horizon_fast_forward` step, tuned for app
|
|
2174
|
+
# debounce observation) but, when the page has work runnable now, spends that
|
|
2175
|
+
# budget in `FRAME_STEP_MS` chunks — the same frame cadence this primitive
|
|
2176
|
+
# uses — so a page renders frame-by-frame regardless of which path drives it.
|
|
2177
|
+
#
|
|
2178
|
+
# A real browser processes EVERYTHING ready at the current instant within a
|
|
2179
|
+
# single animation frame — microtasks, timers due now (incl. newly scheduled
|
|
2180
|
+
# `setTimeout(0)`), the render-phase rAF callbacks, and the navigation /
|
|
2181
|
+
# form-submit / worker chains they trigger — and only THEN advances ~16.67 ms
|
|
2182
|
+
# to the next frame. Modelling that is essential: a multi-hop chain (a form →
|
|
2183
|
+
# iframe-rebuild → onload → next-submit sequence, or N sequential rAF
|
|
2184
|
+
# `promise_test`s) must complete inside a frame. Advancing the clock per host
|
|
2185
|
+
# round-trip instead (the old WPT runner did ~3 `evaluate_script`s/frame, each
|
|
2186
|
+
# a full ~100 ms `tick_real_time` poll tick) runs it ~20× real cadence, so a
|
|
2187
|
+
# page that needs many frames trips a wall-clock-budget harness timeout long
|
|
2188
|
+
# before its queue drains. Driving the loop here keeps real cadence (one frame
|
|
2189
|
+
# interval per frame) while still completing the chains.
|
|
2190
|
+
#
|
|
2191
|
+
# Phase 1 — quiescence at the CURRENT virtual time: repeatedly run the loop
|
|
2192
|
+
# with a ZERO advance (`run_loop_step(0)` fires only timers due now + the
|
|
2193
|
+
# microtask checkpoints + the render phase) and drain the Ruby-side async /
|
|
2194
|
+
# nav / form-submit / download chains they queue, until a turn makes no
|
|
2195
|
+
# observable progress (or the backstop caps a busy loop). Phase 2 — advance
|
|
2196
|
+
# exactly one frame so the next batch of timers comes due.
|
|
2197
|
+
#
|
|
2198
|
+
# Returns the loop state: `progressed` (this frame did real work — drives a
|
|
2199
|
+
# caller's idle detection), `raf` (an animation frame is queued), `async` (a
|
|
2200
|
+
# non-timer background channel — worker / SSE / hijacked fetch — is in
|
|
2201
|
+
# flight), and `next_timer` (ms to the nearest scheduled timer, -1 = none, so
|
|
2202
|
+
# a caller can tell a page PARKED on a near-future `setTimeout` from an idle
|
|
2203
|
+
# one). Whether the page reached some application-level "done" state is the
|
|
2204
|
+
# caller's concern — read it separately with `peek_script` (clock-free).
|
|
2205
|
+
def run_event_loop_frame(frame_ms)
|
|
2206
|
+
turns = 0
|
|
2207
|
+
loop do
|
|
2208
|
+
r = @runtime.run_loop_step(0) # run only what's due NOW + microtasks + render; no clock advance
|
|
2209
|
+
progressed = step_and_drain_progressed(r)
|
|
2210
|
+
turns += 1
|
|
2211
|
+
break unless progressed
|
|
2212
|
+
break if turns >= EVENT_LOOP_QUIESCENCE_CAP
|
|
2213
|
+
end
|
|
2214
|
+
# Phase 2 — advance one real frame so the next batch of timers becomes due.
|
|
2215
|
+
# Its work counts toward `progressed` too: a timer that first comes due in
|
|
2216
|
+
# this advance (e.g. a `setTimeout(…, 8)` firing mid-frame) and the nav hop
|
|
2217
|
+
# it queues are real progress, so a caller mustn't read the frame as idle.
|
|
2218
|
+
frame_progressed = step_and_drain_progressed(@runtime.run_loop_step(frame_ms))
|
|
2219
|
+
probe = dom_call('__csimEventLoopProbe')
|
|
2220
|
+
{
|
|
2221
|
+
'raf' => !!probe['raf'],
|
|
2222
|
+
'async' => !!probe['async'],
|
|
2223
|
+
# ms until the nearest scheduled timer (-1 = none). Lets a caller keep
|
|
2224
|
+
# advancing while a near-future `setTimeout` is parked (a `step_timeout`-
|
|
2225
|
+
# style wait) instead of declaring the page idle — see `__csimEventLoopProbe`.
|
|
2226
|
+
'next_timer' => probe['nextTimer'].to_f,
|
|
2227
|
+
# `turns > 1` ⇒ phase 1's quiescence did work (the trailing no-progress
|
|
2228
|
+
# turn that ends the loop is the +1); OR phase 2's advance did.
|
|
2229
|
+
'progressed' => turns > 1 || frame_progressed
|
|
2230
|
+
}
|
|
2231
|
+
end
|
|
2232
|
+
|
|
2233
|
+
# Drain the Ruby-side async / navigation / form-submit / download chains a
|
|
2234
|
+
# `run_loop_step` (passed as `r`) may have queued, and report whether this
|
|
2235
|
+
# step+drain made observable progress. Shared by both phases of
|
|
2236
|
+
# `run_event_loop_frame`.
|
|
2237
|
+
#
|
|
2238
|
+
# A pending Ruby-side navigation/submit/reload intent counts as progress:
|
|
2239
|
+
# draining it rebuilds a child frame realm and fires that iframe's `onload`
|
|
2240
|
+
# synchronously, whose handler can queue the NEXT hop (submit-entity-body's
|
|
2241
|
+
# `run_simple_test` chain: form.submit() → realm rebuild → onload → next
|
|
2242
|
+
# form.submit()). That work happens entirely in the CHILD realm, so it bumps
|
|
2243
|
+
# the child's `settleGen`, never the main realm's `settle_gen` we sample, and
|
|
2244
|
+
# fires no main-realm timer. Snapshot the intent BEFORE draining: this call
|
|
2245
|
+
# consumes one hop and the onload re-queues the next, which the following
|
|
2246
|
+
# call sees — so the quiescence loop self-terminates when the chain ends.
|
|
2247
|
+
private def step_and_drain_progressed(r)
|
|
2248
|
+
pulled = drain_async_channels
|
|
2249
|
+
invalidate_find_cache
|
|
2250
|
+
drained_nav = pending_nav_intent?
|
|
2251
|
+
drain_pending_navigation
|
|
2252
|
+
consume_pending_form_submit
|
|
2253
|
+
consume_pending_download
|
|
2254
|
+
# `r['dirtied']` already covers settleGen changes DURING the step; compare
|
|
2255
|
+
# the post-drain gen against the step's post-step gen (`r['gen']`, free —
|
|
2256
|
+
# no extra crossing) to also catch a main-realm change the drains caused.
|
|
2257
|
+
r['fired'].to_i.positive? || r['dirtied'] || pulled || drained_nav ||
|
|
2258
|
+
@runtime.settle_gen != r['gen'].to_i
|
|
2259
|
+
end
|
|
2260
|
+
|
|
2261
|
+
# Clock-FREE read of a JS expression in the active browsing context. Unlike
|
|
2262
|
+
# `evaluate_script` (which ticks `tick_real_time` first), this is a bare
|
|
2263
|
+
# `dom_call` and advances no virtual time — so a caller polling page state
|
|
2264
|
+
# once per `run_event_loop_frame` (e.g. the WPT runner checking its harness's
|
|
2265
|
+
# completion sentinel) doesn't perturb the frame cadence the loop maintains.
|
|
2266
|
+
def peek_script(expr)
|
|
2267
|
+
dom_call('__csimEvalScript', expr.to_s, marshal_args([]))
|
|
2268
|
+
end
|
|
2269
|
+
|
|
2270
|
+
# Any Ruby-side navigation intent queued and waiting for `drain_pending_navigation`
|
|
2271
|
+
# to act on it — the same set that method drains (location / frame nav / frame
|
|
2272
|
+
# submit / frame reload / reload / history traverse). The quiescence loop treats
|
|
2273
|
+
# a queued intent as progress because draining it does cross-realm work (rebuild
|
|
2274
|
+
# a child frame realm + fire its `onload`) that the main-realm `settleGen` /
|
|
2275
|
+
# fired-timer signals can't see. Aux-window opens are intentionally excluded:
|
|
2276
|
+
# they build a separate Browser, not a hop in a same-page chain.
|
|
2277
|
+
private def pending_nav_intent?
|
|
2278
|
+
!@pending_location.nil? ||
|
|
2279
|
+
@pending_reload ||
|
|
2280
|
+
!@pending_history_traverse.nil? ||
|
|
2281
|
+
!(@pending_frame_nav || {}).empty? ||
|
|
2282
|
+
!(@pending_frame_submit || []).empty? ||
|
|
2283
|
+
!(@pending_frame_reload || []).empty?
|
|
2284
|
+
end
|
|
2285
|
+
|
|
2286
|
+
# Pull every background async channel (Worker / EventSource / hijacked fetch
|
|
2287
|
+
# / postMessage / WebSocket) into JS state, marking the find cache dirty if
|
|
2288
|
+
# any delivered. Shared by `tick_real_time` and `run_event_loop_frame`.
|
|
2289
|
+
# Returns true if any channel delivered.
|
|
2290
|
+
private def drain_async_channels
|
|
2291
|
+
n = deliver_worker_messages + deliver_event_source_events + deliver_hijacked_fetches +
|
|
2292
|
+
deliver_window_messages + deliver_websocket_events
|
|
2293
|
+
@find_cache_dirty = true if n.positive?
|
|
2294
|
+
n.positive?
|
|
2295
|
+
end
|
|
2296
|
+
|
|
2035
2297
|
# This tick's deterministic virtual-clock advance (ms). Default is the fixed
|
|
2036
2298
|
# `POLL_TICK_STEP_MS` — never wall-derived, so per-poll JS/Ruby/GC cost cannot
|
|
2037
2299
|
# shift WHEN a timer fires (the wall-sync↔perf coupling this replaces). When
|
|
@@ -2040,6 +2302,11 @@ module Capybara
|
|
|
2040
2302
|
# it — but only after the transient-guard window so pre-debounce states are
|
|
2041
2303
|
# still observed across several polls. `FF_HORIZON_MS=0` ⇒ pure fixed-step.
|
|
2042
2304
|
def horizon_fast_forward_step
|
|
2305
|
+
# Whether the page has work runnable at the CURRENT instant (a rAF or a
|
|
2306
|
+
# due-now timer). Only then does `tick_real_time` sub-step the advance into
|
|
2307
|
+
# frames — an idle/parked poll has nothing to render frame-by-frame, so it
|
|
2308
|
+
# stays a single step (no extra render phases on the common idle-wait poll).
|
|
2309
|
+
@page_runnable_now = false
|
|
2043
2310
|
# Escape hatch to the legacy wall-sync clock (virtual advance = real
|
|
2044
2311
|
# wall-elapsed per poll). The deterministic model decouples perf from
|
|
2045
2312
|
# timing but can't match a real browser's wall-proportional cadence for
|
|
@@ -2064,6 +2331,7 @@ module Capybara
|
|
|
2064
2331
|
# (2) Runnable now → fixed step, reset guard (not a quiet pre-debounce window).
|
|
2065
2332
|
if delay.zero?
|
|
2066
2333
|
@ff_transient_polls = 0
|
|
2334
|
+
@page_runnable_now = true
|
|
2067
2335
|
return POLL_TICK_STEP_MS
|
|
2068
2336
|
end
|
|
2069
2337
|
# (3) Nothing parked → nothing to fast-forward to.
|
|
@@ -2109,95 +2377,173 @@ module Capybara
|
|
|
2109
2377
|
attr_reader :context_gen
|
|
2110
2378
|
|
|
2111
2379
|
# Pulls the serialised form-state out of JS, encodes it, and
|
|
2112
|
-
# drives the Rack app via `navigate` (for GET) or a POST.
|
|
2113
|
-
|
|
2380
|
+
# drives the Rack app via `navigate` (for GET) or a POST. `entry_list` is the
|
|
2381
|
+
# list JS already constructed (post-`formdata`, so a handler's append/delete
|
|
2382
|
+
# is honoured); when absent (the Enter implicit-submit path) we build it from
|
|
2383
|
+
# the form's own controls.
|
|
2384
|
+
def submit_form_handle(form_handle, submitter_handle, entry_list = nil)
|
|
2114
2385
|
invalidate_find_cache
|
|
2115
2386
|
spec = dom_call('__csimFormSerialize', form_handle, submitter_handle || 0)
|
|
2116
2387
|
return unless spec.is_a?(Hash)
|
|
2117
2388
|
action = spec['action'].to_s
|
|
2118
2389
|
method = spec['method'].to_s.upcase
|
|
2119
2390
|
method = 'GET' if method.empty?
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
enctype = spec['enctype'].to_s
|
|
2123
|
-
multipart = enctype.start_with?('multipart/form-data')
|
|
2124
|
-
content_type = nil
|
|
2125
|
-
body =
|
|
2126
|
-
if multipart
|
|
2127
|
-
built = build_multipart_body(fields, file_inputs)
|
|
2128
|
-
content_type = built[:content_type]
|
|
2129
|
-
built[:body]
|
|
2130
|
-
else
|
|
2131
|
-
# Non-multipart: file inputs contribute the filename only.
|
|
2132
|
-
file_inputs.each do |fi|
|
|
2133
|
-
picks = @file_picks && @file_picks[fi['handle'].to_i] || []
|
|
2134
|
-
fields << [fi['name'].to_s, picks.first ? File.basename(picks.first) : '']
|
|
2135
|
-
end
|
|
2136
|
-
URI.encode_www_form(fields)
|
|
2137
|
-
end
|
|
2391
|
+
enctype = spec['enctype'].to_s.empty? ? 'application/x-www-form-urlencoded' : spec['enctype'].to_s.downcase
|
|
2392
|
+
entries = entry_list.is_a?(Array) ? entry_list : entries_from_spec(spec)
|
|
2138
2393
|
action_url = action.empty? ? (current_browsing_context_url || @default_host) : resolve_against_current(action)
|
|
2139
2394
|
# A form submitted inside a frame whose target is that frame (self, or a
|
|
2140
2395
|
# `_parent` of a ≥2-deep frame) navigates the FRAME, not the top page.
|
|
2141
2396
|
frame_entry = frame_nav_target_entry(spec['target'])
|
|
2397
|
+
# A non-frame named target (`_blank`, or a window name that isn't a frame)
|
|
2398
|
+
# submits into a NEW/named browsing context — open (or reuse) an aux window,
|
|
2399
|
+
# mirroring the link `target=_blank` branch. `_blank` is always a fresh window;
|
|
2400
|
+
# a named target that matches THIS window's own `window.name` navigates in
|
|
2401
|
+
# place (HTML named-context targeting), so it isn't a new window.
|
|
2402
|
+
target = spec['target'].to_s
|
|
2403
|
+
named_target = frame_entry.nil? && !target.empty? &&
|
|
2404
|
+
!%w[_self _top _parent].include?(target.downcase) &&
|
|
2405
|
+
@driver.respond_to?(:open_aux_window)
|
|
2406
|
+
own_name = named_target ? (@runtime.call('__csimReadWindowProp', false, 'name').to_s rescue '') : ''
|
|
2407
|
+
new_window = named_target && (target.casecmp?('_blank') || target != own_name)
|
|
2408
|
+
window_name = target.casecmp?('_blank') ? '' : target
|
|
2409
|
+
# Opener exposure for a `<form target>` new context, per the link-relation
|
|
2410
|
+
# model: `noopener`/`noreferrer` always drop the opener; `target=_blank`
|
|
2411
|
+
# ALSO defaults to noopener unless `rel=opener` opts back in (a named target
|
|
2412
|
+
# keeps its opener by default). `noreferrer` additionally empties the referrer.
|
|
2413
|
+
rel_tokens = spec['rel'].to_s.downcase.split(/\s+/)
|
|
2414
|
+
no_referrer = rel_tokens.include?('noreferrer')
|
|
2415
|
+
keep_opener = !no_referrer && !rel_tokens.include?('noopener') &&
|
|
2416
|
+
(target.downcase != '_blank' || rel_tokens.include?('opener'))
|
|
2417
|
+
referrer = no_referrer ? '' : (@current_url || '')
|
|
2418
|
+
# Opening a new top-level browsing context consumes transient user activation.
|
|
2419
|
+
@runtime.call('__csimConsumeTransientActivation') if new_window rescue nil
|
|
2142
2420
|
if method == 'GET'
|
|
2421
|
+
# GET ignores enctype: the entry list is always the urlencoded query.
|
|
2422
|
+
query, = encode_entry_list(entries, 'application/x-www-form-urlencoded')
|
|
2143
2423
|
uri = URI.parse(action_url)
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2424
|
+
# HTML "mutate action URL" for GET: SET the query to the entry list
|
|
2425
|
+
# unconditionally — an empty list clears any query the action already
|
|
2426
|
+
# carried (browsers navigate to `action?`), it isn't preserved.
|
|
2427
|
+
uri.query = query
|
|
2428
|
+
if new_window
|
|
2429
|
+
@driver.open_aux_window(uri.to_s, name: window_name, source: self,
|
|
2430
|
+
opener: keep_opener, referrer: referrer)
|
|
2431
|
+
elsif frame_entry
|
|
2432
|
+
navigate_frame(uri.to_s, entry: frame_entry)
|
|
2433
|
+
else
|
|
2434
|
+
navigate(uri.to_s)
|
|
2435
|
+
end
|
|
2148
2436
|
else
|
|
2149
|
-
|
|
2437
|
+
body, content_type = encode_entry_list(entries, enctype)
|
|
2438
|
+
if new_window
|
|
2439
|
+
@driver.open_aux_window(action_url, name: window_name, source: self,
|
|
2440
|
+
opener: keep_opener, referrer: referrer,
|
|
2441
|
+
post: {body: body, content_type: content_type})
|
|
2442
|
+
elsif frame_entry
|
|
2443
|
+
navigate_frame_post(action_url, body, content_type, entry: frame_entry)
|
|
2444
|
+
else
|
|
2445
|
+
navigate_post(action_url, body, content_type)
|
|
2446
|
+
end
|
|
2150
2447
|
end
|
|
2151
2448
|
end
|
|
2152
2449
|
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2450
|
+
# HTML "encode the entry list" by enctype → [body, exact Content-Type]. The
|
|
2451
|
+
# Content-Type is sent verbatim (no charset suffix), which the spec's
|
|
2452
|
+
# form-submission resources compare exactly. text/plain is `name=value\r\n`
|
|
2453
|
+
# per entry (NOT urlencoded); urlencoded (and GET) merge each file entry as
|
|
2454
|
+
# its bare filename. `entries` is the ordered entry list — string
|
|
2455
|
+
# {'name','value'} or file {'name','file'=>true,'filename','handle','index'}
|
|
2456
|
+
# entries; a file's bytes resolve through the `@file_picks` slot.
|
|
2457
|
+
def encode_entry_list(entries, enctype)
|
|
2458
|
+
if enctype.start_with?('multipart/form-data')
|
|
2459
|
+
boundary = "csim-#{SecureRandom.hex(8)}"
|
|
2460
|
+
body = String.new.force_encoding(Encoding::ASCII_8BIT)
|
|
2461
|
+
entries.each do |e|
|
|
2462
|
+
if e['file']
|
|
2463
|
+
path = entry_file_path(e)
|
|
2464
|
+
if path
|
|
2465
|
+
append_multipart_part(body, boundary, e['name'].to_s, File.binread(path),
|
|
2466
|
+
filename: File.basename(path),
|
|
2467
|
+
content_type: Rack::Mime.mime_type(File.extname(path)))
|
|
2468
|
+
elsif e['b64']
|
|
2469
|
+
# An in-memory `new File([…])` has no on-disk slot; its bytes are
|
|
2470
|
+
# carried base64-encoded from the VM. Decode them for the part body.
|
|
2471
|
+
content = e['b64'].to_s.unpack1('m')
|
|
2472
|
+
ct = e['type'].to_s
|
|
2473
|
+
ct = 'application/octet-stream' if ct.empty?
|
|
2474
|
+
append_multipart_part(body, boundary, e['name'].to_s, content,
|
|
2475
|
+
filename: e['filename'].to_s, content_type: ct)
|
|
2476
|
+
else
|
|
2477
|
+
append_multipart_part(body, boundary, e['name'].to_s, '', filename: e['filename'].to_s)
|
|
2478
|
+
end
|
|
2479
|
+
else
|
|
2480
|
+
append_multipart_part(body, boundary, e['name'].to_s, e['value'].to_s)
|
|
2168
2481
|
end
|
|
2169
2482
|
end
|
|
2483
|
+
body << "--#{boundary}--\r\n"
|
|
2484
|
+
[body, "multipart/form-data; boundary=#{boundary}"]
|
|
2485
|
+
else
|
|
2486
|
+
# The urlencoded / text-plain encoders normalize CR/LF → CRLF in each entry's
|
|
2487
|
+
# name and value (a file entry's filename is the value) — the entry list itself
|
|
2488
|
+
# stays raw, so normalization lives here, matching the JS encoders and real
|
|
2489
|
+
# browsers (newline-normalization.html).
|
|
2490
|
+
pairs = entries.map {|e|
|
|
2491
|
+
[normalize_form_newlines(e['name']), normalize_form_newlines(e['file'] ? e['filename'] : e['value'])]
|
|
2492
|
+
}
|
|
2493
|
+
if enctype == 'text/plain'
|
|
2494
|
+
[pairs.map {|name, value| "#{name}=#{value}\r\n" }.join, 'text/plain']
|
|
2495
|
+
else
|
|
2496
|
+
[URI.encode_www_form(pairs), 'application/x-www-form-urlencoded']
|
|
2497
|
+
end
|
|
2170
2498
|
end
|
|
2171
|
-
body << "--#{boundary}--\r\n"
|
|
2172
|
-
{content_type: "multipart/form-data; boundary=#{boundary}", body: body}
|
|
2173
2499
|
end
|
|
2174
2500
|
|
|
2175
|
-
#
|
|
2176
|
-
#
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
#
|
|
2182
|
-
#
|
|
2183
|
-
#
|
|
2184
|
-
#
|
|
2185
|
-
#
|
|
2186
|
-
#
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2501
|
+
# HTML form-submission newline normalization: every lone CR, lone LF, and CRLF in an
|
|
2502
|
+
# entry name/value becomes a CRLF (the JS encoders' `normalizeNL` counterpart).
|
|
2503
|
+
def normalize_form_newlines(s)
|
|
2504
|
+
s.to_s.gsub(/\r\n?|\n/, "\r\n")
|
|
2505
|
+
end
|
|
2506
|
+
|
|
2507
|
+
# Resolve a threaded file entry's on-disk path via the `@file_picks` slot
|
|
2508
|
+
# recorded at `attach_file` time (handle/index). nil for a purely in-memory
|
|
2509
|
+
# `new File(['bytes'], …)` (no slot) — a CLASSIC (non-Turbo) submit then
|
|
2510
|
+
# drops its bytes, while the fetch/XHR path serializes them in JS
|
|
2511
|
+
# (`serializeMultipart` → `blobBytes`). This covers every realistic upload
|
|
2512
|
+
# (a host-backed file submitted through Turbo or a plain form).
|
|
2513
|
+
def entry_file_path(entry)
|
|
2514
|
+
handle = entry['handle']
|
|
2515
|
+
return nil if handle.nil?
|
|
2516
|
+
picks = @file_picks && @file_picks[handle.to_i]
|
|
2517
|
+
picks && picks[entry['index'].to_i]
|
|
2518
|
+
end
|
|
2519
|
+
|
|
2520
|
+
# Build the entry list from the form's own controls, for triggers that didn't
|
|
2521
|
+
# construct one in JS (the Enter implicit-submit path). Mirrors the JS FormData
|
|
2522
|
+
# construction: non-file fields in tree order, then each file input's selection
|
|
2523
|
+
# (one empty entry when nothing is picked). A selected File reports its
|
|
2524
|
+
# host-backed source (`handle`/`index`); the older payload shape with no per-File
|
|
2525
|
+
# refs falls back to the input's own handle slot.
|
|
2526
|
+
def entries_from_spec(spec)
|
|
2527
|
+
entries = (spec['fields'] || []).map {|pair| {'name' => pair[0].to_s, 'value' => pair[1].to_s} }
|
|
2528
|
+
(spec['fileInputs'] || []).each do |fi|
|
|
2529
|
+
name = fi['name'].to_s
|
|
2530
|
+
refs = fi['files']
|
|
2531
|
+
if refs.is_a?(Array) && !refs.empty?
|
|
2532
|
+
refs.each {|ref|
|
|
2533
|
+
entries << {'name' => name, 'file' => true, 'filename' => ref['name'].to_s, 'handle' => ref['handle'], 'index' => ref['index']}
|
|
2534
|
+
}
|
|
2535
|
+
else
|
|
2536
|
+
picks = (@file_picks && @file_picks[fi['handle'].to_i]) || []
|
|
2537
|
+
if picks.empty?
|
|
2538
|
+
entries << {'name' => name, 'file' => true, 'filename' => '', 'handle' => nil, 'index' => nil}
|
|
2539
|
+
else
|
|
2540
|
+
picks.each_index {|i|
|
|
2541
|
+
entries << {'name' => name, 'file' => true, 'filename' => File.basename(picks[i]), 'handle' => fi['handle'], 'index' => i}
|
|
2542
|
+
}
|
|
2543
|
+
end
|
|
2544
|
+
end
|
|
2200
2545
|
end
|
|
2546
|
+
entries
|
|
2201
2547
|
end
|
|
2202
2548
|
|
|
2203
2549
|
def append_multipart_part(body, boundary, name, content, filename: nil, content_type: nil)
|
|
@@ -2211,14 +2557,17 @@ module Capybara
|
|
|
2211
2557
|
body << "\r\n"
|
|
2212
2558
|
end
|
|
2213
2559
|
|
|
2214
|
-
def navigate_post(url, body, content_type, depth: 0, from_history: false)
|
|
2560
|
+
def navigate_post(url, body, content_type, depth: 0, from_history: false, referer: @current_url)
|
|
2215
2561
|
raise 'too many redirects' if depth > 10
|
|
2216
2562
|
invalidate_find_cache
|
|
2217
|
-
|
|
2563
|
+
unless from_history || depth > 0
|
|
2564
|
+
capture_outgoing_form_state
|
|
2565
|
+
record_history({method: :post, url: url, body: body, content_type: content_type})
|
|
2566
|
+
end
|
|
2218
2567
|
env = Rack::MockRequest.env_for(url, method: 'POST', input: body)
|
|
2219
2568
|
env['CONTENT_TYPE'] = content_type.empty? ? 'application/x-www-form-urlencoded' : content_type
|
|
2220
2569
|
env['CONTENT_LENGTH'] = body.bytesize.to_s
|
|
2221
|
-
apply_default_request_env(env, referer:
|
|
2570
|
+
apply_default_request_env(env, referer: referer)
|
|
2222
2571
|
status, headers, resp_body = dispatch_rack_or_http(url, env, method: 'POST', body: body)
|
|
2223
2572
|
merge_set_cookie(headers)
|
|
2224
2573
|
if (loc = redirect_location(status, headers))
|
|
@@ -2277,6 +2626,11 @@ module Capybara
|
|
|
2277
2626
|
reset_frame_scope
|
|
2278
2627
|
@history.clear
|
|
2279
2628
|
@history_idx = -1
|
|
2629
|
+
@cors_preflight_cache = {} # CORS-preflight cache is per browsing context
|
|
2630
|
+
# A JS-driven history.back()/go() that scheduled a deferred traverse but
|
|
2631
|
+
# never drained (the page navigated away first) must not survive the reset
|
|
2632
|
+
# — otherwise the stale target replays against the NEXT page's fresh history.
|
|
2633
|
+
@pending_history_traverse = nil
|
|
2280
2634
|
@file_picks = {} if @file_picks
|
|
2281
2635
|
# Hand the live trace off to `@pending_trace` so an after-hook
|
|
2282
2636
|
# running after `reset_session!` (Capybara's per-test teardown
|
|
@@ -2386,7 +2740,14 @@ module Capybara
|
|
|
2386
2740
|
# Returns nil on 4xx / fetch failure so the JS caller skips it exactly as the
|
|
2387
2741
|
# old `__rackFetch` branch did.
|
|
2388
2742
|
def external_asset_source(url)
|
|
2389
|
-
|
|
2743
|
+
# A blob:/data:/about: document's location can't anchor an absolute-path
|
|
2744
|
+
# `src=/common/…` (URI.join on a `blob:` URL yields nothing usable), but its
|
|
2745
|
+
# `<base href>` points at a real http(s) origin — so for THOSE documents
|
|
2746
|
+
# resolve against `<base href>` (HTML base-tag semantics) and the script loads.
|
|
2747
|
+
# Ordinary http(s) pages keep the document-URL base so the hot path skips the
|
|
2748
|
+
# `base_href` dom_call (CLAUDE.md rule 3).
|
|
2749
|
+
needs_base = @current_url.to_s.start_with?('blob:', 'data:', 'about:')
|
|
2750
|
+
key = resolve_against_current(url.to_s, use_base: needs_base)
|
|
2390
2751
|
return nil unless key.is_a?(String)
|
|
2391
2752
|
@@asset_src_lock.synchronize do
|
|
2392
2753
|
if (e = @@asset_src[key])
|
|
@@ -3064,28 +3425,48 @@ module Capybara
|
|
|
3064
3425
|
# worker's `__csim_workerPostMessage` host fn closes over its
|
|
3065
3426
|
# handle and routes outgoing messages onto a shared outbox the
|
|
3066
3427
|
# main settle drains.
|
|
3067
|
-
def worker_spawn(url, shared: false)
|
|
3428
|
+
def worker_spawn(url, shared: false, service: false)
|
|
3068
3429
|
handle = (@worker_seq += 1)
|
|
3430
|
+
target = resolve_against_current(url.to_s)
|
|
3431
|
+
# A worker script from a blob: URL in a DIFFERENT storage partition than this
|
|
3432
|
+
# context can't be created (cross-partition-worker-creation): the worker would
|
|
3433
|
+
# run in the creating context's partition, not the blob's. Deliver an error so
|
|
3434
|
+
# Worker/SharedWorker fires `onerror`, and spawn no thread. The handle is still
|
|
3435
|
+
# >0 so the JS registers the worker and the queued __error reaches it.
|
|
3436
|
+
if target.start_with?('blob:') && @driver.respond_to?(:cross_partition_blob?) && @driver.cross_partition_blob?(target, self)
|
|
3437
|
+
return worker_fail(handle, 'Worker creation from a cross-partition blob URL is blocked')
|
|
3438
|
+
end
|
|
3069
3439
|
inbox = Thread::Queue.new
|
|
3070
3440
|
outbox = @worker_outbox
|
|
3071
3441
|
engine_class = @runtime.class
|
|
3072
|
-
target = resolve_against_current(url.to_s)
|
|
3073
3442
|
# Resolve the worker script body on the main thread before
|
|
3074
3443
|
# handing off to the worker. `blob:` URLs need the main VM's
|
|
3075
3444
|
# blob registry; calling into the main runtime from a
|
|
3076
3445
|
# non-owning thread SEGVs (V8 isolates are thread-
|
|
3077
3446
|
# bound; quickjs.rb's VM is similarly per-thread).
|
|
3078
3447
|
body = fetch_worker_script(target)
|
|
3448
|
+
# A blob: worker script that didn't resolve (revoked / unavailable) fails the
|
|
3449
|
+
# same way — fire onerror rather than spawn a worker that runs nothing.
|
|
3450
|
+
return worker_fail(handle, 'Worker script could not be loaded') if target.start_with?('blob:') && body.to_s.empty?
|
|
3079
3451
|
# Pending until the worker's initial script has run (see @worker_initializing).
|
|
3080
3452
|
@worker_init_lock.synchronize { @worker_initializing += 1 }
|
|
3081
3453
|
thread = Thread.new do
|
|
3082
3454
|
Thread.current.report_on_exception = false
|
|
3083
|
-
run_worker(handle, target, body, inbox, outbox, engine_class, shared: shared)
|
|
3455
|
+
run_worker(handle, target, body, inbox, outbox, engine_class, shared: shared, service: service)
|
|
3084
3456
|
end
|
|
3085
3457
|
@workers[handle] = {thread: thread, inbox: inbox}
|
|
3086
3458
|
handle
|
|
3087
3459
|
end
|
|
3088
3460
|
|
|
3461
|
+
# Fail a worker that can't be created (blocked / unloadable script): queue an
|
|
3462
|
+
# error event so the JS Worker/SharedWorker fires `onerror`, spawn no thread,
|
|
3463
|
+
# and return the (still >0) handle so the JS registers the worker and the error
|
|
3464
|
+
# reaches it.
|
|
3465
|
+
private def worker_fail(handle, message)
|
|
3466
|
+
@worker_outbox << {handle: handle, kind: '__error', message: message}
|
|
3467
|
+
handle
|
|
3468
|
+
end
|
|
3469
|
+
|
|
3089
3470
|
def worker_post_to_worker(handle, data)
|
|
3090
3471
|
w = @workers[handle.to_i]
|
|
3091
3472
|
return unless w
|
|
@@ -3137,9 +3518,32 @@ module Capybara
|
|
|
3137
3518
|
# `window.open(url, name)` from JS — returns the new (or reused, by name)
|
|
3138
3519
|
# window's handle, or nil. The URL is resolved against THIS document so a
|
|
3139
3520
|
# relative `window.open('/x')` targets the right origin/path.
|
|
3140
|
-
def open_child_window(url, name)
|
|
3521
|
+
def open_child_window(url, name, opener_realm_id = 0)
|
|
3141
3522
|
return nil unless @driver.respond_to?(:open_window_from_js)
|
|
3142
|
-
@driver.open_window_from_js(self, url.to_s, name.to_s)
|
|
3523
|
+
@driver.open_window_from_js(self, url.to_s, name.to_s, opener_realm_id.to_i)
|
|
3524
|
+
end
|
|
3525
|
+
|
|
3526
|
+
# A `target=_blank`/named link/area activation from a frame or window realm in
|
|
3527
|
+
# THIS browser opens a new top-level auxiliary window. `opener` reflects
|
|
3528
|
+
# rel=opener (a bare target=_blank is noopener); the Driver forces noopener for
|
|
3529
|
+
# a cross-partition blob: target. `blob` is an optional click-time blob snapshot.
|
|
3530
|
+
def open_aux_from_realm(url, opener, blob)
|
|
3531
|
+
return unless @driver.respond_to?(:open_aux_window)
|
|
3532
|
+
snap = blob.is_a?(Hash) ? blob : nil
|
|
3533
|
+
@driver.open_aux_window(resolve_document_url(url.to_s), source: self, opener: !!opener, blob_snapshot: snap)
|
|
3534
|
+
end
|
|
3535
|
+
|
|
3536
|
+
# Open a SAME-ORIGIN auxiliary window as a realm in THIS browser's isolate
|
|
3537
|
+
# (shared heap) rather than a separate Browser/VM, returning the new realm's
|
|
3538
|
+
# context id for `window.open` to wrap in a NATIVE WindowProxy — so
|
|
3539
|
+
# `popup.document` is a real same-isolate Document and cross-window adoptNode
|
|
3540
|
+
# works (dom/nodes/remove-and-adopt-thcrash). Returns nil to fall back to the
|
|
3541
|
+
# separate-VM aux-window path. First stage: about:blank only (a non-blank
|
|
3542
|
+
# same-origin URL still takes the aux path until realm URL-loading lands).
|
|
3543
|
+
def open_window_realm(url, name: nil, opener_realm_id: 0)
|
|
3544
|
+
return nil unless @runtime.respond_to?(:create_window_realm)
|
|
3545
|
+
return nil unless url.nil?
|
|
3546
|
+
@runtime.create_window_realm('', '', 'text/html', window_name: name, opener_id: opener_realm_id)
|
|
3143
3547
|
end
|
|
3144
3548
|
|
|
3145
3549
|
# `targetWindow.postMessage(data, origin)` — route to the target window's
|
|
@@ -3154,6 +3558,41 @@ module Capybara
|
|
|
3154
3558
|
# route to the Driver, which reads a PRIMITIVE off the target window's VM.
|
|
3155
3559
|
def window_get(handle, prop) = (@driver.respond_to?(:window_read) ? @driver.window_read(handle.to_s, prop.to_s, doc: false) : nil)
|
|
3156
3560
|
def window_doc_get(handle, prop) = (@driver.respond_to?(:window_read) ? @driver.window_read(handle.to_s, prop.to_s, doc: true) : nil)
|
|
3561
|
+
# Cross-window remote-ref RPC — SOURCE side: forward a node/object proxy op to
|
|
3562
|
+
# the target window's Browser via the Driver.
|
|
3563
|
+
def window_ref_get(handle, id, prop) = (@driver.respond_to?(:window_ref_get) ? @driver.window_ref_get(handle.to_s, id, prop.to_s) : nil)
|
|
3564
|
+
def window_ref_set(handle, id, prop, value) = (@driver.window_ref_set(handle.to_s, id, prop.to_s, value) if @driver.respond_to?(:window_ref_set))
|
|
3565
|
+
def window_ref_call(handle, id, method, args) = (@driver.respond_to?(:window_ref_call) ? @driver.window_ref_call(handle.to_s, id, method.to_s, args) : nil)
|
|
3566
|
+
# TARGET side: execute the op against THIS window's VM (the Driver calls these
|
|
3567
|
+
# on the resolved target Browser).
|
|
3568
|
+
def remote_ref_get(id, prop) = @runtime.call('__csimRemoteRefGet', id, prop.to_s)
|
|
3569
|
+
def remote_ref_set(id, prop, value)
|
|
3570
|
+
@runtime.call('__csimRemoteRefSet', id, prop.to_s, value)
|
|
3571
|
+
# A cross-isolate property set can queue a navigation in THIS (target)
|
|
3572
|
+
# window — `w.location.href = …` / `w.location = …`. Drain it (and any other
|
|
3573
|
+
# pending action it triggered) so the non-active window actions it now.
|
|
3574
|
+
drain_pending_after_remote_ref
|
|
3575
|
+
nil
|
|
3576
|
+
end
|
|
3577
|
+
def remote_ref_call(id, method, args)
|
|
3578
|
+
result = @runtime.call('__csimRemoteRefCall', id, method.to_s, args || [])
|
|
3579
|
+
# A cross-isolate call can queue a pending action in THIS (target) window —
|
|
3580
|
+
# `w.form.submit()` (form submit), `w.history.back()` (history traverse),
|
|
3581
|
+
# `w.location.assign()` (navigation). Drain them so the non-active window
|
|
3582
|
+
# actions them (they would otherwise wait for a Capybara action on it).
|
|
3583
|
+
drain_pending_after_remote_ref
|
|
3584
|
+
result
|
|
3585
|
+
end
|
|
3586
|
+
# Drain every deferred action a cross-isolate operation may have queued in this
|
|
3587
|
+
# window: form submission, plain navigation, same-document history traversal
|
|
3588
|
+
# (history.back/forward/go — restores the previous entry + fires load), and
|
|
3589
|
+
# child-frame navigation. Each consume is a no-op when nothing is pending.
|
|
3590
|
+
def drain_pending_after_remote_ref
|
|
3591
|
+
consume_pending_form_submit
|
|
3592
|
+
consume_pending_navigation
|
|
3593
|
+
consume_pending_history_traverse
|
|
3594
|
+
consume_pending_frame_nav
|
|
3595
|
+
end
|
|
3157
3596
|
# Read a primitive property off THIS window's globalThis / document — called
|
|
3158
3597
|
# by the Driver to serve another window's cross-window proxy read.
|
|
3159
3598
|
def read_property(prop, doc: false)
|
|
@@ -3162,9 +3601,13 @@ module Capybara
|
|
|
3162
3601
|
nil
|
|
3163
3602
|
end
|
|
3164
3603
|
def set_window_location(handle, url) = (@driver.window_set_location(handle.to_s, url.to_s) if @driver.respond_to?(:window_set_location))
|
|
3604
|
+
def window_history_go(handle, delta) = (@driver.respond_to?(:window_history_go) ? @driver.window_history_go(handle.to_s, delta.to_i) : false)
|
|
3165
3605
|
def window_closed?(handle) = @driver.respond_to?(:window_closed?) ? @driver.window_closed?(handle.to_s) : true
|
|
3166
3606
|
def close_child_window(handle) = (@driver.close_window(handle.to_s) if @driver.respond_to?(:close_window))
|
|
3167
3607
|
def opener_handle = @driver.respond_to?(:opener_handle_of) ? @driver.opener_handle_of(self) : nil
|
|
3608
|
+
# Fire an aux window's own window `load` (called by its opener, deferred).
|
|
3609
|
+
def fire_aux_window_load(handle) = (@driver.fire_aux_window_load(handle.to_s) if @driver.respond_to?(:fire_aux_window_load))
|
|
3610
|
+
def fire_own_window_load = (@runtime.call('__csimFireWindowLoad') rescue nil)
|
|
3168
3611
|
|
|
3169
3612
|
# Queue a cross-window message for delivery into THIS window's VM (called
|
|
3170
3613
|
# by the Driver on the target Browser). Delivered as a `message` event the
|
|
@@ -3177,9 +3620,13 @@ module Capybara
|
|
|
3177
3620
|
# cross-window event channels share these drain/pending hooks.
|
|
3178
3621
|
def window_message_pending? = !@window_inbox.empty? || !@broadcast_inbox.empty?
|
|
3179
3622
|
|
|
3180
|
-
# A BroadcastChannel message
|
|
3181
|
-
#
|
|
3182
|
-
|
|
3623
|
+
# A BroadcastChannel message queued for delivery to this Browser's channels.
|
|
3624
|
+
# `source_realm_id` is the posting realm's context id within THIS isolate (0 =
|
|
3625
|
+
# main), or nil when the post came from ANOTHER isolate (the Driver's cross-
|
|
3626
|
+
# window fanout) — a nil source matches no local realm, so it reaches every one.
|
|
3627
|
+
def enqueue_broadcast(name, data, source_realm_id = nil)
|
|
3628
|
+
@broadcast_inbox << {'name' => name.to_s, 'data' => data, 'source' => source_realm_id}
|
|
3629
|
+
end
|
|
3183
3630
|
|
|
3184
3631
|
# Fire queued cross-window messages (postMessage + BroadcastChannel).
|
|
3185
3632
|
def deliver_window_messages
|
|
@@ -3191,16 +3638,39 @@ module Capybara
|
|
|
3191
3638
|
end
|
|
3192
3639
|
unless @broadcast_inbox.empty?
|
|
3193
3640
|
events = @broadcast_inbox.slice!(0, @broadcast_inbox.length)
|
|
3194
|
-
|
|
3641
|
+
# A BroadcastChannel reaches every same-origin browsing context EXCEPT the
|
|
3642
|
+
# poster. Within this isolate each browsing context is a realm, so deliver to
|
|
3643
|
+
# the main realm (0) and every live frame/window realm, skipping the realm
|
|
3644
|
+
# that posted (it already delivered to itself in-VM via `_bcChannels`). A nil
|
|
3645
|
+
# source (cross-isolate) is excluded from no realm.
|
|
3646
|
+
realm_ids = @runtime.respond_to?(:frame_realm_ids) ? @runtime.frame_realm_ids : []
|
|
3647
|
+
[0, *realm_ids].each do |target_id|
|
|
3648
|
+
batch = events.reject {|e| e['source'] == target_id }
|
|
3649
|
+
next if batch.empty?
|
|
3650
|
+
if target_id.zero?
|
|
3651
|
+
@runtime.call('__csim_deliverBroadcasts', batch)
|
|
3652
|
+
elsif @runtime.frame_realm_alive?(target_id)
|
|
3653
|
+
@runtime.realm_call(target_id, '__csim_deliverBroadcasts', batch)
|
|
3654
|
+
end
|
|
3655
|
+
end
|
|
3195
3656
|
n += events.size
|
|
3196
3657
|
end
|
|
3197
3658
|
n
|
|
3198
3659
|
end
|
|
3199
3660
|
|
|
3200
|
-
# `BroadcastChannel.postMessage` in THIS window — fan out to every OTHER
|
|
3201
|
-
#
|
|
3202
|
-
|
|
3661
|
+
# `BroadcastChannel.postMessage` in THIS window — fan out to every OTHER same-
|
|
3662
|
+
# origin browsing context's matching channels (same-realm delivery happens
|
|
3663
|
+
# in-VM). `source_realm_id` is the posting realm's context id. The cross-ISOLATE
|
|
3664
|
+
# fanout goes through the Driver; the same-ISOLATE fanout (main ↔ sibling realms,
|
|
3665
|
+
# sibling ↔ sibling) is queued here and delivered per-realm by
|
|
3666
|
+
# `deliver_window_messages`, which skips the posting realm.
|
|
3667
|
+
def broadcast_to_windows(name, data, source_realm_id = 0)
|
|
3203
3668
|
@driver.broadcast_channel(self, name.to_s, data) if @driver.respond_to?(:broadcast_channel)
|
|
3669
|
+
# Only queue for same-isolate delivery when sibling realms exist — a single-
|
|
3670
|
+
# realm page already delivered to itself in-VM, so this stays zero-overhead.
|
|
3671
|
+
if @runtime.respond_to?(:frame_realm_ids) && @runtime.frame_realm_ids.any?
|
|
3672
|
+
enqueue_broadcast(name, data, source_realm_id.to_i)
|
|
3673
|
+
end
|
|
3204
3674
|
end
|
|
3205
3675
|
|
|
3206
3676
|
# ── Image decode (libvips) ─────────────────────────────────────
|
|
@@ -3281,13 +3751,53 @@ module Capybara
|
|
|
3281
3751
|
# that context would wrongly revoke a now-page-owned URL.
|
|
3282
3752
|
if key then @blob_owners[url.to_s] = key else @blob_owners.delete(url.to_s) end
|
|
3283
3753
|
end
|
|
3754
|
+
# Record the blob's STORAGE PARTITION (this window's top-level site) in the
|
|
3755
|
+
# Driver-level store so another window can resolve it only from the same
|
|
3756
|
+
# partition (blob URL partitioning). Keyed by the creating Browser so the
|
|
3757
|
+
# bytes are read back from wherever they live (the creator's isolate).
|
|
3758
|
+
@driver.register_blob_partition(url.to_s, self, blob_partition_site) if @driver.respond_to?(:register_blob_partition)
|
|
3284
3759
|
nil
|
|
3285
3760
|
end
|
|
3286
3761
|
|
|
3287
3762
|
def blob_resolve(url)
|
|
3763
|
+
# A same-partition blob created in another window/isolate is spec-fetchable
|
|
3764
|
+
# cross-window, but resolving its bytes means a real-time cross-isolate read
|
|
3765
|
+
# (+ worker round-trips for the worker variants) that races the per-example
|
|
3766
|
+
# timeout under suite load — flaky in the gate. So we only resolve a blob from
|
|
3767
|
+
# THIS window's registry; the cross-window same-partition fetch is a backlog
|
|
3768
|
+
# item (cross-partition.https "fetched from a same-partition {iframe,worker}").
|
|
3288
3769
|
@blob_registry_lock.synchronize { @blob_registry[url.to_s] }
|
|
3289
3770
|
end
|
|
3290
3771
|
|
|
3772
|
+
# The SITE (scheme + registrable domain) of this window's top-level document.
|
|
3773
|
+
# Storage partitioning keys a blob URL on its creator's top-level site, so a
|
|
3774
|
+
# same-origin iframe embedded in a cross-site top-level context is a DIFFERENT
|
|
3775
|
+
# partition and can't reach the blob. Registrable domain is approximated as the
|
|
3776
|
+
# host's last two dot-labels — correct for the single-label public suffixes our
|
|
3777
|
+
# in-process hosts use (web-platform.test / not-web-platform.test / *.com); a
|
|
3778
|
+
# full Public Suffix List isn't warranted here.
|
|
3779
|
+
def blob_partition_site
|
|
3780
|
+
# A blob: document's location is `blob:<innerURL>` (e.g.
|
|
3781
|
+
# `blob:https://host:port/uuid`) — strip the prefix so the site derives from
|
|
3782
|
+
# the inner origin, not the opaque blob: scheme. data:/about: have no host →
|
|
3783
|
+
# '' (an opaque origin, never same-partition with a real one).
|
|
3784
|
+
u = URI.parse(@current_url.to_s.sub(/\Ablob:/, ''))
|
|
3785
|
+
host = u.host.to_s
|
|
3786
|
+
return '' if host.empty?
|
|
3787
|
+
labels = host.split('.')
|
|
3788
|
+
# An IP literal (v4 dotted / v6 bracketed) or a ≤2-label host IS its own
|
|
3789
|
+
# registrable domain; otherwise approximate eTLD+1 as the last two labels
|
|
3790
|
+
# (no Public Suffix List — correct for the single-label TLDs our hosts use).
|
|
3791
|
+
regd = if host.start_with?('[') || host.match?(/\A\d+(\.\d+){3}\z/) || labels.length <= 2
|
|
3792
|
+
host
|
|
3793
|
+
else
|
|
3794
|
+
labels.last(2).join('.')
|
|
3795
|
+
end
|
|
3796
|
+
"#{u.scheme}://#{regd}"
|
|
3797
|
+
rescue URI::Error
|
|
3798
|
+
''
|
|
3799
|
+
end
|
|
3800
|
+
|
|
3291
3801
|
# WHATWG URL "domain to ASCII" — the JS tr46 stub delegates non-ASCII / xn--
|
|
3292
3802
|
# hosts here (the ASCII fast path stays in-VM). Returns the punycode form, or
|
|
3293
3803
|
# nil on an IDNA failure (so whatwg-url reports "domain to ASCII failed").
|
|
@@ -3295,10 +3805,25 @@ module Capybara
|
|
|
3295
3805
|
# VerifyDnsLength off) — empty middle labels (`x..y`) and `_`/etc. are
|
|
3296
3806
|
# allowed, matching whatwg-url's `domainToASCII(domain, false)`.
|
|
3297
3807
|
def domain_to_ascii(domain)
|
|
3298
|
-
|
|
3808
|
+
d = domain.to_s
|
|
3809
|
+
# An all-ASCII domain needs no IDNA mapping: WHATWG "domain to ASCII"
|
|
3810
|
+
# (beStrict false) keeps it verbatim and only ASCII-lowercases it — including
|
|
3811
|
+
# an `xn--` A-label whose punycode doesn't decode to a valid UTS46 label
|
|
3812
|
+
# (`xn--pokxncvks` → disallowed U+3253…, or the bare `xn--`). Browsers (and the
|
|
3813
|
+
# WPT urltestdata) keep those A-labels as-is; uri-idna RE-validates the decoded
|
|
3814
|
+
# label and raises, which would wrongly fail the parse. So route only domains
|
|
3815
|
+
# with non-ASCII codepoints (the ones that actually need punycode) through
|
|
3816
|
+
# uri-idna. Forbidden host code points in an ASCII host are caught separately
|
|
3817
|
+
# by whatwg-url's host parser, not here. (Residual: a host MIXING a non-ASCII
|
|
3818
|
+
# label with a non-decodable `xn--` label still routes through uri-idna and
|
|
3819
|
+
# fails — a narrow per-label gap no current test hits; the all-ASCII fast path
|
|
3820
|
+
# covers every observed case.)
|
|
3821
|
+
return d.downcase if d.ascii_only?
|
|
3822
|
+
URI::IDNA.whatwg_to_ascii(d, be_strict: false)
|
|
3299
3823
|
rescue URI::IDNA::Error
|
|
3300
|
-
nil # a genuine IDNA failure (bad punycode / disallowed
|
|
3301
|
-
# whatwg-url report "domain to ASCII failed". Non-IDNA
|
|
3824
|
+
nil # a genuine IDNA failure on a non-ASCII host (bad punycode / disallowed
|
|
3825
|
+
# codepoint) — let whatwg-url report "domain to ASCII failed". Non-IDNA
|
|
3826
|
+
# errors propagate.
|
|
3302
3827
|
end
|
|
3303
3828
|
|
|
3304
3829
|
# WHATWG URL "domain to Unicode" — best-effort (never fails the parse per
|
|
@@ -3334,20 +3859,62 @@ module Capybara
|
|
|
3334
3859
|
# type (url-charset) is preserved so it can override <meta charset>.
|
|
3335
3860
|
ct = "#{ct};charset=utf-8" unless ct.downcase.include?('charset')
|
|
3336
3861
|
record_response(200, {'content-type' => ct})
|
|
3862
|
+
b64 = Base64.strict_encode64(bytes.to_s.b)
|
|
3863
|
+
# Make the blob URL fetchable from the document we're about to load — a blob:
|
|
3864
|
+
# document that fetches itself (or a media `src` first-party load) snapshots
|
|
3865
|
+
# the bytes SYNCHRONOUSLY at fetch() time, which runs DURING boot below, so the
|
|
3866
|
+
# bytes must be registered in this window's @blob_registry FIRST. No partition
|
|
3867
|
+
# entry — the blob keeps its original storage partition; this only makes it
|
|
3868
|
+
# first-party-fetchable in the window it was navigated into.
|
|
3869
|
+
@blob_registry_lock.synchronize { @blob_registry[url.to_s] = b64 }
|
|
3337
3870
|
boot_response_into_ctx(bytes)
|
|
3871
|
+
# Adopt into the in-VM store too, so a LATER resolve keeps the correct content
|
|
3872
|
+
# type (the @blob_registry b64 path resolves as application/octet-stream).
|
|
3873
|
+
@runtime.call('__csimAdoptBlobBytes', url.to_s, b64, content_type.to_s) rescue nil
|
|
3338
3874
|
end
|
|
3339
3875
|
|
|
3876
|
+
# A user-initiated `URL.revokeObjectURL`. Storage-partitioned + cross-isolate:
|
|
3877
|
+
# the Driver vetoes a cross-partition revoke (a same-origin but cross-top-level-
|
|
3878
|
+
# site context can't revoke the blob — cross-partition.https) and, for a
|
|
3879
|
+
# same-partition revoke, invalidates the blob in the CREATOR's isolate too so
|
|
3880
|
+
# every window stops resolving it (the blob may have been created in another
|
|
3881
|
+
# window). (Context-teardown revokes go through revoke_owned_blobs, not here.)
|
|
3340
3882
|
def blob_unregister(url)
|
|
3341
|
-
|
|
3883
|
+
if @driver.respond_to?(:revoke_blob_partitioned)
|
|
3884
|
+
return nil unless @driver.revoke_blob_partitioned(url.to_s, self)
|
|
3885
|
+
elsif @driver.respond_to?(:unregister_blob_partition)
|
|
3886
|
+
@driver.unregister_blob_partition(url.to_s)
|
|
3887
|
+
end
|
|
3888
|
+
drop_local_blob(url)
|
|
3342
3889
|
nil
|
|
3343
3890
|
end
|
|
3344
3891
|
|
|
3892
|
+
# Forget a blob URL in THIS isolate: its validity marker / bytes in
|
|
3893
|
+
# @blob_registry and its in-VM store entry. Called for a local revoke and, via
|
|
3894
|
+
# the Driver, when another same-partition window revokes a blob this isolate
|
|
3895
|
+
# created. The @blob_registry removal is the AUTHORITATIVE invalidation
|
|
3896
|
+
# (resolveBlobBytes gates on it cross-realm); the in-VM `__csimDropBlob` is a
|
|
3897
|
+
# same-thread V8 call, so it's skipped on a worker thread — a worker's
|
|
3898
|
+
# `revokeObjectURL` forwards here on the WORKER thread, and calling a
|
|
3899
|
+
# thread-confined isolate from a non-owning thread SEGVs (V8/quickjs isolates
|
|
3900
|
+
# are thread-bound). The stale in-VM entry is harmless: resolveBlobBytes returns
|
|
3901
|
+
# null once the registry marker is gone.
|
|
3902
|
+
def drop_local_blob(url)
|
|
3903
|
+
@blob_registry_lock.synchronize { @blob_registry.delete(url.to_s); @blob_owners.delete(url.to_s) }
|
|
3904
|
+
return if Thread.current[:csim_worker_handle] # worker thread: the registry removal above is enough
|
|
3905
|
+
@runtime.call('__csimDropBlob', url.to_s) rescue nil
|
|
3906
|
+
end
|
|
3907
|
+
|
|
3345
3908
|
# Revoke every blob URL owned by a context that's going away (its blob URL
|
|
3346
3909
|
# store is part of the global being torn down).
|
|
3347
3910
|
def revoke_owned_blobs(key)
|
|
3348
|
-
@blob_registry_lock.synchronize do
|
|
3911
|
+
revoked = @blob_registry_lock.synchronize do
|
|
3349
3912
|
urls = @blob_owners.select {|_url, owner| owner == key }.keys
|
|
3350
3913
|
urls.each {|url| @blob_registry.delete(url); @blob_owners.delete(url) }
|
|
3914
|
+
urls
|
|
3915
|
+
end
|
|
3916
|
+
if @driver.respond_to?(:unregister_blob_partition)
|
|
3917
|
+
revoked.each {|url| @driver.unregister_blob_partition(url) }
|
|
3351
3918
|
end
|
|
3352
3919
|
end
|
|
3353
3920
|
# Keys are normalized with `.to_i` on BOTH sides (register tags
|
|
@@ -3473,6 +4040,17 @@ module Capybara
|
|
|
3473
4040
|
}.freeze
|
|
3474
4041
|
private_constant :MIME_TO_VIPS_EXT
|
|
3475
4042
|
|
|
4043
|
+
# The canonical MIME for each format we actually encode. An unsupported
|
|
4044
|
+
# request type maps to '.png' below, so the encoded format (and the type
|
|
4045
|
+
# we report back to the canvas) is image/png — matching the toBlob /
|
|
4046
|
+
# toDataURL "unsupported type falls back to image/png" rule.
|
|
4047
|
+
EXT_TO_MIME = {
|
|
4048
|
+
'.jpg' => 'image/jpeg',
|
|
4049
|
+
'.webp' => 'image/webp',
|
|
4050
|
+
'.png' => 'image/png'
|
|
4051
|
+
}.freeze
|
|
4052
|
+
private_constant :EXT_TO_MIME
|
|
4053
|
+
|
|
3476
4054
|
def encode_image(pixels_ref, width, height, mime_type = 'image/png', quality = 90)
|
|
3477
4055
|
host_image_op('encode_image') {
|
|
3478
4056
|
require 'vips' unless defined?(Vips)
|
|
@@ -3483,7 +4061,7 @@ module Capybara
|
|
|
3483
4061
|
img = Vips::Image.new_from_memory_copy(raw, w, h, 4, :uchar)
|
|
3484
4062
|
ext = MIME_TO_VIPS_EXT[mime_type.to_s.downcase] || '.png'
|
|
3485
4063
|
opts = (ext == '.jpg' || ext == '.webp') ? {Q: quality.to_i} : {}
|
|
3486
|
-
{'refId' => transfer_buffer_stash(img.write_to_buffer(ext, **opts))}
|
|
4064
|
+
{'refId' => transfer_buffer_stash(img.write_to_buffer(ext, **opts)), 'mime' => EXT_TO_MIME[ext]}
|
|
3487
4065
|
}
|
|
3488
4066
|
end
|
|
3489
4067
|
|
|
@@ -3493,7 +4071,7 @@ module Capybara
|
|
|
3493
4071
|
# `build_worker` factory, evaluates the worker script, then
|
|
3494
4072
|
# loops draining microtasks + timers + inbox until `:terminate`
|
|
3495
4073
|
# lands or an exception propagates.
|
|
3496
|
-
private def run_worker(handle, url, body, inbox, outbox, engine_class, shared: false)
|
|
4074
|
+
private def run_worker(handle, url, body, inbox, outbox, engine_class, shared: false, service: false)
|
|
3497
4075
|
# Release the spawn-time `@worker_initializing` count exactly once, however
|
|
3498
4076
|
# this method exits (normal start, `self.close()`, or an exception), so
|
|
3499
4077
|
# worker_pending? doesn't stay stuck true forever.
|
|
@@ -3518,6 +4096,9 @@ module Capybara
|
|
|
3518
4096
|
# resolve chunks against the worker's own origin rather than
|
|
3519
4097
|
# the snapshot-time `http://placeholder/`.
|
|
3520
4098
|
rt.eval("globalThis.__csimUpdateLocation(#{JSON.generate(url.to_s)});")
|
|
4099
|
+
# A service worker runs in a ServiceWorkerGlobalScope: adjust the worker scope
|
|
4100
|
+
# (no blob-URL minting; SW lifecycle stubs) BEFORE its script runs.
|
|
4101
|
+
rt.eval('__csim_installServiceWorkerScope();') if service
|
|
3521
4102
|
rt.eval(body)
|
|
3522
4103
|
rt.drain_microtasks
|
|
3523
4104
|
# A SharedWorker fires `connect` AFTER its script set `self.onconnect`; the
|
|
@@ -3531,16 +4112,25 @@ module Capybara
|
|
|
3531
4112
|
release_init.call
|
|
3532
4113
|
# A worker that called `self.close()` in its top-level script stops here —
|
|
3533
4114
|
# the script ran (and may have posted), but no further messages are pulled.
|
|
3534
|
-
unless rt.
|
|
4115
|
+
unless rt.call('__csimWorkerClosedRead')
|
|
3535
4116
|
loop do
|
|
3536
4117
|
msg = pop_with_timeout(inbox, WORKER_POLL_INTERVAL)
|
|
3537
4118
|
break if msg == :terminate
|
|
3538
|
-
if msg
|
|
3539
|
-
|
|
4119
|
+
rt.call('__csim_workerOnMessage', msg) if msg
|
|
4120
|
+
# Drive the worker's OWN event loop each tick: a message handler OR an
|
|
4121
|
+
# AUTONOMOUS loop (the dispatcher executor-worker's receive→fetch→setTimeout
|
|
4122
|
+
# retry, which has no inbox message) may have pending timers. Drain ~one poll
|
|
4123
|
+
# interval (WorkerRuntime#drain_timers advances the worker clock a step) so
|
|
4124
|
+
# they progress; worker http fetch is setTimeout(0)+__rackFetch, resolved on
|
|
4125
|
+
# this thread by the drain. Gated on a PENDING timer (any, not just due-now —
|
|
4126
|
+
# the clock must advance to fire a future randomDelay) so an idle
|
|
4127
|
+
# message-driven worker with no timers stays lazy. Host CALLS, not string
|
|
4128
|
+
# `eval`, keep the per-tick cost off the V8 compile path (rule 3).
|
|
4129
|
+
if rt.call('__nextTimerDelay').to_f >= 0
|
|
3540
4130
|
rt.drain_microtasks
|
|
3541
|
-
rt.drain_timers
|
|
3542
|
-
break if rt.eval('!!globalThis.__csimWorkerClosed')
|
|
4131
|
+
rt.drain_timers
|
|
3543
4132
|
end
|
|
4133
|
+
break if rt.call('__csimWorkerClosedRead')
|
|
3544
4134
|
end
|
|
3545
4135
|
end
|
|
3546
4136
|
rescue StandardError => e
|
|
@@ -3558,6 +4148,13 @@ module Capybara
|
|
|
3558
4148
|
private def fetch_worker_script(url)
|
|
3559
4149
|
u = url.to_s
|
|
3560
4150
|
if u.start_with?('blob:')
|
|
4151
|
+
# Resolve via the Driver's partition store so a SAME-partition blob created
|
|
4152
|
+
# in ANOTHER window/isolate (the cross-partition-worker-creation test creates
|
|
4153
|
+
# the blob in the opener and the worker in a same-site iframe) is readable.
|
|
4154
|
+
# Falls back to this realm's own store when there's no Driver entry.
|
|
4155
|
+
if @driver.respond_to?(:blob_bytes_for) && (data = @driver.blob_bytes_for(u, self))
|
|
4156
|
+
return data[:bytes]
|
|
4157
|
+
end
|
|
3561
4158
|
b64 = @runtime.call('__csimReadBlobBase64', u)
|
|
3562
4159
|
return nil unless b64
|
|
3563
4160
|
return Base64.decode64(b64.to_s)
|
|
@@ -3647,15 +4244,134 @@ module Capybara
|
|
|
3647
4244
|
end
|
|
3648
4245
|
end
|
|
3649
4246
|
|
|
4247
|
+
# Fetch caps a request at 20 redirects: the 21st is a network error (redirect-count).
|
|
4248
|
+
# The loop below runs one iteration PER dispatch, so it needs 20 redirect hops plus
|
|
4249
|
+
# the final response — MAX_FETCH_REDIRECTS + 1 iterations — to let exactly 20 succeed.
|
|
3650
4250
|
MAX_FETCH_REDIRECTS = 20
|
|
4251
|
+
# Request cache modes that never READ the store (always hit the network), and modes that
|
|
4252
|
+
# serve a STORED response even when stale. Frozen so the hot rack_fetch path allocates no
|
|
4253
|
+
# throwaway arrays per hop (perf).
|
|
4254
|
+
CACHE_MODES_SKIP_READ = %w[no-store reload].freeze
|
|
4255
|
+
CACHE_MODES_SERVE_STALE = %w[force-cache only-if-cached].freeze
|
|
4256
|
+
# Fetch "bad port" blocklist (https://fetch.spec.whatwg.org/#port-blocking) —
|
|
4257
|
+
# ports tied to non-HTTP protocols a request must never reach. Frozen Set for
|
|
4258
|
+
# O(1) membership on the rack_fetch path.
|
|
4259
|
+
BAD_PORTS = Set[
|
|
4260
|
+
0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77,
|
|
4261
|
+
79, 87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135,
|
|
4262
|
+
137, 139, 143, 161, 179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531,
|
|
4263
|
+
532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, 995, 1719, 1720,
|
|
4264
|
+
1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668,
|
|
4265
|
+
6669, 6679, 6697, 10080
|
|
4266
|
+
].freeze
|
|
4267
|
+
|
|
4268
|
+
REFERRER_POLICIES = %w[
|
|
4269
|
+
no-referrer no-referrer-when-downgrade origin origin-when-cross-origin
|
|
4270
|
+
same-origin strict-origin strict-origin-when-cross-origin unsafe-url
|
|
4271
|
+
].freeze
|
|
4272
|
+
|
|
4273
|
+
# The `Referer` value a request carries under a Referrer-Policy — nil = send none
|
|
4274
|
+
# (https://w3c.github.io/webappsec-referrer-policy/#determine-requests-referrer).
|
|
4275
|
+
# `referrer_url` is the request's referrer (the initiating document); `target_url`
|
|
4276
|
+
# its destination. "full" is the referrer stripped of fragment + credentials;
|
|
4277
|
+
# "origin" is scheme://host[:port]/. An empty / unknown policy → the default
|
|
4278
|
+
# (strict-origin-when-cross-origin).
|
|
4279
|
+
def compute_referrer(policy, referrer_url, target_url)
|
|
4280
|
+
return nil if referrer_url.nil? || referrer_url.to_s.empty?
|
|
4281
|
+
policy = 'strict-origin-when-cross-origin' unless REFERRER_POLICIES.include?(policy)
|
|
4282
|
+
return nil if policy == 'no-referrer'
|
|
4283
|
+
# The referrer is almost always the (constant) document URL — memoise its parse
|
|
4284
|
+
# so the rack_fetch hot path doesn't re-parse it per request (rule 3).
|
|
4285
|
+
ref = parse_referrer_url(referrer_url)
|
|
4286
|
+
return nil unless ref && %w[http https].include?(ref.scheme)
|
|
4287
|
+
full = -> { u = ref.dup; u.fragment = nil; u.password = nil; u.user = nil; u.to_s }
|
|
4288
|
+
origin_only = -> {
|
|
4289
|
+
default_port = ref.scheme == 'https' ? 443 : 80
|
|
4290
|
+
port = ref.port && ref.port != default_port ? ":#{ref.port}" : ''
|
|
4291
|
+
"#{ref.scheme}://#{ref.host}#{port}/"
|
|
4292
|
+
}
|
|
4293
|
+
return full.call if policy == 'unsafe-url'
|
|
4294
|
+
return origin_only.call if policy == 'origin'
|
|
4295
|
+
# The remaining policies need the target to know same-origin / downgrade.
|
|
4296
|
+
tgt = (URI.parse(target_url) rescue nil)
|
|
4297
|
+
return nil unless tgt
|
|
4298
|
+
same_origin = ref.scheme == tgt.scheme && ref.host == tgt.host && ref.port == tgt.port
|
|
4299
|
+
downgrade = ref.scheme == 'https' && tgt.scheme == 'http'
|
|
4300
|
+
case policy
|
|
4301
|
+
when 'origin-when-cross-origin' then same_origin ? full.call : origin_only.call
|
|
4302
|
+
when 'same-origin' then same_origin ? full.call : nil
|
|
4303
|
+
when 'strict-origin' then downgrade ? nil : origin_only.call
|
|
4304
|
+
when 'no-referrer-when-downgrade' then downgrade ? nil : full.call
|
|
4305
|
+
when 'strict-origin-when-cross-origin' then same_origin ? full.call : (downgrade ? nil : origin_only.call)
|
|
4306
|
+
end
|
|
4307
|
+
end
|
|
4308
|
+
|
|
4309
|
+
# Parse a referrer URL, memoising the last one (the referrer is the document URL
|
|
4310
|
+
# for nearly every request, so this caches across the whole page's subresources).
|
|
4311
|
+
def parse_referrer_url(url)
|
|
4312
|
+
return @referrer_parsed if defined?(@referrer_parsed_for) && @referrer_parsed_for == url
|
|
4313
|
+
@referrer_parsed_for = url
|
|
4314
|
+
@referrer_parsed = (URI.parse(url) rescue nil)
|
|
4315
|
+
end
|
|
4316
|
+
|
|
4317
|
+
# Whether a request to `url_str` must be blocked as a Fetch "bad port". Cheap
|
|
4318
|
+
# pre-gate: only URLs whose authority carries an explicit `:<digit>` are parsed
|
|
4319
|
+
# (the vast majority don't), so the rack_fetch hot path — every asset / xhr /
|
|
4320
|
+
# fetch, cache hits included — skips URI.parse entirely.
|
|
4321
|
+
def bad_port?(url_str)
|
|
4322
|
+
return false unless url_str =~ %r{\A[a-z]+://[^/]*:\d}i
|
|
4323
|
+
port = URI.parse(url_str).port
|
|
4324
|
+
port && BAD_PORTS.include?(port)
|
|
4325
|
+
rescue URI::Error
|
|
4326
|
+
false
|
|
4327
|
+
end
|
|
4328
|
+
|
|
3651
4329
|
# URLs we won't even try to route through Rack: anything that
|
|
3652
4330
|
# isn't http(s) (data: / mailto: / about:) plus pseudo-tokens
|
|
3653
4331
|
# like V8's `<snapshot>` that sourcemap libraries pull out of
|
|
3654
4332
|
# error stacks and feed straight to `fetch()` / `xhr.open()`.
|
|
3655
|
-
def rack_fetch(method, url, body, headers, redirect_mode, env_extras: nil)
|
|
4333
|
+
def rack_fetch(method, url, body, headers, redirect_mode, cors_mode = nil, credentials: 'same-origin', env_extras: nil, referrer_policy: nil, referrer: nil, cache_mode: 'default')
|
|
4334
|
+
# NB: a relative fetch/XHR URL is resolved against the document's API base URL
|
|
4335
|
+
# at OPEN time (XHR open() / fetch()), in JS, NOT here — resolving at send time
|
|
4336
|
+
# would wrongly pick up a `<base href>` inserted after open() (open-url-base
|
|
4337
|
+
# -inserted-after-open). So this resolves only against the document URL.
|
|
3656
4338
|
target = resolve_against_current(url.to_s)
|
|
3657
4339
|
return nil unless target.is_a?(String) && target.match?(%r{\Ahttps?://}i)
|
|
3658
|
-
|
|
4340
|
+
# Fetch "port blocking" (https://fetch.spec.whatwg.org/#port-blocking): a
|
|
4341
|
+
# request to a blocked port is a network error before any connection —
|
|
4342
|
+
# fetch() rejects with TypeError, a sync XHR throws NetworkError
|
|
4343
|
+
# (request-bad-port). Re-checked per redirect hop below ("HTTP-redirect fetch"
|
|
4344
|
+
# re-runs the block), so a 3xx Location to a bad port is refused too.
|
|
4345
|
+
return nil if bad_port?(target)
|
|
4346
|
+
# CORS enforcement (preflight + Access-Control checks) applies only to cors_mode
|
|
4347
|
+
# 'cors' — sent by XHR and by fetch()'s default mode. fetch() also threads
|
|
4348
|
+
# 'no-cors' / 'same-origin' (mode semantics below), and a form-submission
|
|
4349
|
+
# navigation threads 'navigate'; other callers (sendBeacon, ESM, workers, the
|
|
4350
|
+
# internal asset GET) pass nil → no CORS and no mode semantics. The document's
|
|
4351
|
+
# origin is the request's origin; a different target origin is cross-origin.
|
|
4352
|
+
cors = cors_mode == 'cors'
|
|
4353
|
+
req_origin = cors ? url_origin(@current_url) : nil
|
|
4354
|
+
# Fetch request "mode" (fetch threads it; XHR is always 'cors'; a non-fetch/xhr
|
|
4355
|
+
# caller passes nil → no mode semantics, a plain 'basic' response). `no-cors`
|
|
4356
|
+
# filters a cross-origin response to opaque; `same-origin` makes a cross-origin
|
|
4357
|
+
# request a network error. `doc_origin` detects cross-origin for the response
|
|
4358
|
+
# TYPE regardless of whether CORS enforcement (cors) runs; `crossed` latches once
|
|
4359
|
+
# any hop leaves the document origin.
|
|
4360
|
+
no_cors_mode = cors_mode == 'no-cors'
|
|
4361
|
+
same_origin_mode = cors_mode == 'same-origin'
|
|
4362
|
+
# Only the real fetch request modes carry cross-origin semantics; a 'navigate'
|
|
4363
|
+
# (form submission) or a nil-mode internal caller gets a plain readable response.
|
|
4364
|
+
doc_origin = %w[cors no-cors same-origin].include?(cors_mode) ? url_origin(@current_url) : nil
|
|
4365
|
+
crossed = false
|
|
4366
|
+
# A request is "credentialed" (cookies + the credentialed CORS check) only in
|
|
4367
|
+
# `include` mode; `same-origin` (default) and `omit` are uncredentialed for the
|
|
4368
|
+
# CORS check, while the cookie decision below distinguishes all three.
|
|
4369
|
+
with_credentials = credentials == 'include'
|
|
4370
|
+
# Use the method's case AS GIVEN: the JS callers already applied the spec
|
|
4371
|
+
# normalization (XHR open() / Fetch upper-case the known methods, preserving
|
|
4372
|
+
# an unknown method's case — open-method-case-sensitive). Upper-casing here
|
|
4373
|
+
# would clobber a custom method like `xUNIcorn`.
|
|
4374
|
+
method = (method || 'GET').to_s
|
|
3659
4375
|
redirected = false
|
|
3660
4376
|
# JS-side base64-encodes Blob/File bodies (raw bytes survive
|
|
3661
4377
|
# the engine's UTF-8 string boundary that way); decode before
|
|
@@ -3664,22 +4380,117 @@ module Capybara
|
|
|
3664
4380
|
body = Base64.decode64(body.to_s)
|
|
3665
4381
|
headers = headers.reject {|k, _| k == 'X-Csim-Body-B64' }
|
|
3666
4382
|
end
|
|
3667
|
-
|
|
4383
|
+
# The request's origin starts as the document origin; a cross-origin REDIRECT
|
|
4384
|
+
# taints it to an opaque origin (serialized "null") per Fetch "HTTP-redirect
|
|
4385
|
+
# fetch". `effective_origin` IS that origin — it's what the Origin header
|
|
4386
|
+
# carries and what the CORS check / preflight compare against from that hop on
|
|
4387
|
+
# ('null' once tainted, so the server must then allow 'null' or '*').
|
|
4388
|
+
effective_origin = req_origin
|
|
4389
|
+
# An author conditional (If-None-Match / …) means the caller is doing its own
|
|
4390
|
+
# revalidation, so the UA cache must step aside (computed once — the headers
|
|
4391
|
+
# carrying it survive every redirect hop unchanged).
|
|
4392
|
+
skip_cache = request_has_conditional_headers?(headers)
|
|
4393
|
+
ref_policy = referrer_policy # may be overridden per hop by a response Referrer-Policy
|
|
4394
|
+
# The referrer is stripped PROGRESSIVELY: each hop applies its (possibly
|
|
4395
|
+
# overridden) policy to the referrer the PREVIOUS hop sent, not to the original
|
|
4396
|
+
# document — so once a hop reduces it to an origin (or drops it), a later, laxer
|
|
4397
|
+
# policy can't widen it back (redirect-referrer-override). The initial source is
|
|
4398
|
+
# the request's referrer: an explicit `init.referrer` URL when given, else the
|
|
4399
|
+
# document URL ("client"); an empty referrer means no-referrer (compute_referrer
|
|
4400
|
+
# maps a blank source to nil).
|
|
4401
|
+
ref_source = referrer.nil? ? @current_url : referrer
|
|
4402
|
+
(MAX_FETCH_REDIRECTS + 1).times do
|
|
3668
4403
|
t0 = @trace && Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
3669
|
-
#
|
|
3670
|
-
#
|
|
3671
|
-
#
|
|
3672
|
-
|
|
3673
|
-
if
|
|
3674
|
-
|
|
4404
|
+
# Cross-origin-ness for the request mode/type, latched across hops. Computed
|
|
4405
|
+
# BEFORE the cache so a cross-origin request never takes the cache fast path
|
|
4406
|
+
# (which would bypass the opaque filter / same-origin-mode error / cors type).
|
|
4407
|
+
crossed ||= !!(doc_origin && (effective_origin == 'null' || url_origin(target) != doc_origin))
|
|
4408
|
+
return nil if same_origin_mode && crossed # 'same-origin' mode forbids a cross-origin hop
|
|
4409
|
+
# HTTP cache (RFC 9111 + Fetch "HTTP-network-or-cache fetch"), gated by the request's
|
|
4410
|
+
# cache MODE. GET-only, same-origin (a cross-origin hop always redispatches so the mode
|
|
4411
|
+
# filtering below runs), and stepped aside when the author sent their own conditional.
|
|
4412
|
+
# - no-store / reload : never read the store — always hit the network, no conditional
|
|
4413
|
+
# - force-cache / only-if-cached : serve a stored response even when STALE, no revalidation
|
|
4414
|
+
# (only-if-cached with nothing stored is a network error)
|
|
4415
|
+
# - no-cache : always revalidate, even a fresh entry
|
|
4416
|
+
# - default : serve fresh; revalidate stale (fall through with conditionals)
|
|
4417
|
+
read_cache = method == 'GET' && !skip_cache && !crossed && !CACHE_MODES_SKIP_READ.include?(cache_mode)
|
|
4418
|
+
cache_entry = read_cache ? @@asset_cache.lookup(target) : nil
|
|
4419
|
+
serve_stored = cache_entry &&
|
|
4420
|
+
(CACHE_MODES_SERVE_STALE.include?(cache_mode) || (cache_entry.fresh? && cache_mode != 'no-cache'))
|
|
4421
|
+
if serve_stored
|
|
4422
|
+
if REDIRECT_STATUSES.include?(cache_entry.status.to_i)
|
|
4423
|
+
# A cached REDIRECT obeys the redirect mode exactly like a fresh one: `error` is a
|
|
4424
|
+
# network error, `manual` is an opaque-redirect, and `follow` follows it THROUGH
|
|
4425
|
+
# the cache — resolve the Location and continue so the next hop serves the cached
|
|
4426
|
+
# target (request-cache "uses cached … redirects"). only-if-cached / force-cache
|
|
4427
|
+
# reach this only same-origin GET (read_cache excludes cross-origin), so there's no
|
|
4428
|
+
# method rewrite / origin taint.
|
|
4429
|
+
raise StandardError, '[capybara-simulated] fetch: redirect blocked by redirect=error mode' if redirect_mode == 'error'
|
|
4430
|
+
if redirect_mode != 'follow'
|
|
4431
|
+
return response_hash(0, {}, '', target, false, type: 'opaqueredirect', body_null: true)
|
|
4432
|
+
end
|
|
4433
|
+
if (loc = redirect_location(cache_entry.status, cache_entry.headers))
|
|
4434
|
+
trace_network(method, target, cache_entry.status, headers, body, cache_entry.headers, nil, t0, true)
|
|
4435
|
+
redirected = true
|
|
4436
|
+
next_url = resolve_against(loc, target)
|
|
4437
|
+
return nil unless next_url.to_s.match?(%r{\Ahttps?://}i)
|
|
4438
|
+
target = carry_fragment(target, next_url)
|
|
4439
|
+
return nil if bad_port?(target) # a cached redirect to a blocked port is still a network error
|
|
4440
|
+
next
|
|
4441
|
+
end
|
|
4442
|
+
end
|
|
4443
|
+
# Cached asset — log headers/type/size but skip the (boring) body.
|
|
3675
4444
|
trace_network(method, target, cache_entry.status, headers, body, cache_entry.headers, nil, t0, false)
|
|
3676
4445
|
return response_hash(cache_entry.status, cache_entry.headers, cache_entry.body, target, redirected)
|
|
3677
4446
|
end
|
|
4447
|
+
# only-if-cached forbids the network: no usable stored response → a network error.
|
|
4448
|
+
return nil if cache_mode == 'only-if-cached'
|
|
3678
4449
|
|
|
3679
4450
|
env = Rack::MockRequest.env_for(target, method: method, input: body || '')
|
|
4451
|
+
env['REQUEST_METHOD'] = method # env_for upcases the method; restore the exact case (open-method-case-sensitive)
|
|
4452
|
+
# A GET/HEAD request carries no body, so it sends no Content-Length (env_for
|
|
4453
|
+
# always sets it to the input bytesize, i.e. 0). A POST/PUT with an empty body
|
|
4454
|
+
# keeps Content-Length: 0 (send-entity-body-none / -empty).
|
|
4455
|
+
env.delete('CONTENT_LENGTH') if %w[GET HEAD].include?(method.to_s.upcase)
|
|
3680
4456
|
apply_request_headers(env, headers) if headers
|
|
3681
4457
|
apply_request_headers(env, @@asset_cache.revalidation_headers(cache_entry)) if cache_entry
|
|
3682
|
-
|
|
4458
|
+
# The Referer follows the request's Referrer-Policy (a redirect response can
|
|
4459
|
+
# override the policy for the next hop — see below). `hop_referer` also becomes
|
|
4460
|
+
# the source the NEXT hop strips from.
|
|
4461
|
+
hop_referer = compute_referrer(ref_policy, ref_source, target)
|
|
4462
|
+
apply_default_request_env(env, referer: hop_referer, force: false)
|
|
4463
|
+
# Whether this hop is cross-origin (cors only): a tainted (opaque) origin is
|
|
4464
|
+
# cross-origin to every real target; otherwise compare the target to the
|
|
4465
|
+
# document origin. Drives the Origin header, preflight, and the CORS check.
|
|
4466
|
+
cross_origin = cors && (effective_origin == 'null' || url_origin(target) != req_origin)
|
|
4467
|
+
# Fetch credentials mode decides cookie attachment, independent of the CORS
|
|
4468
|
+
# mode: `omit` never sends them; `include` always does; `same-origin` (default)
|
|
4469
|
+
# sends them only to a same-origin target — so an uncredentialed cross-origin
|
|
4470
|
+
# hop (cors OR no-cors) must not leak the document's cookies
|
|
4471
|
+
# (cors-redirect-credentials / cors-cookies). A navigation / internal caller has
|
|
4472
|
+
# no doc_origin, so it counts as same-origin and keeps them.
|
|
4473
|
+
hop_cross_origin = !!(doc_origin && (effective_origin == 'null' || url_origin(target) != doc_origin))
|
|
4474
|
+
send_cookies = credentials == 'include' || (credentials != 'omit' && !hop_cross_origin)
|
|
4475
|
+
env.delete('HTTP_COOKIE') unless send_cookies
|
|
4476
|
+
# A CORS request to a URL carrying credentials (`user:pass@`) is a network
|
|
4477
|
+
# error (access-control-and-redirects "user info" subtest).
|
|
4478
|
+
return nil if cross_origin && url_has_userinfo?(target)
|
|
4479
|
+
# CORS-preflight, re-evaluated PER HOP: a cross-origin non-simple request (a
|
|
4480
|
+
# non-safelisted method / header / Content-Type) must pass an OPTIONS preflight
|
|
4481
|
+
# first — so a same-origin request redirected cross-origin to an unsafe resource
|
|
4482
|
+
# is preflighted on the NEW origin (send-redirect-to-cors), not just an initially
|
|
4483
|
+
# cross-origin one (access-control-basic-get-fail-non-simple / preflight-*).
|
|
4484
|
+
if cross_origin && cors_unsafe_request?(method, headers)
|
|
4485
|
+
return nil unless cors_preflight_ok?(target, method, headers, effective_origin, with_credentials, hop_referer)
|
|
4486
|
+
end
|
|
4487
|
+
# Send the (effective) Origin — the UA owns this header — on a cors request when
|
|
4488
|
+
# the hop is cross-origin OR the method is not GET/HEAD (Fetch appends Origin to
|
|
4489
|
+
# every non-GET/HEAD request, so a same-origin POST carries it too). After a
|
|
4490
|
+
# cross-origin redirect the origin is the opaque "null".
|
|
4491
|
+
if cross_origin || (req_origin && !%w[GET HEAD].include?(method.to_s.upcase))
|
|
4492
|
+
env['HTTP_ORIGIN'] = effective_origin
|
|
4493
|
+
end
|
|
3683
4494
|
env.merge!(env_extras) if env_extras
|
|
3684
4495
|
status, resp_headers, resp_body = dispatch_rack_or_http(target, env, method: method, body: body)
|
|
3685
4496
|
merge_set_cookie(resp_headers)
|
|
@@ -3689,23 +4500,119 @@ module Capybara
|
|
|
3689
4500
|
@@asset_cache.refresh(cache_entry, resp_headers)
|
|
3690
4501
|
return response_hash(cache_entry.status, cache_entry.headers, cache_entry.body, target, redirected)
|
|
3691
4502
|
end
|
|
3692
|
-
|
|
4503
|
+
# Fetch "CORS check" runs on EVERY cross-origin response — including a 3xx the
|
|
4504
|
+
# UA is about to follow (a redirect whose response lacks a valid Access-Control
|
|
4505
|
+
# -Allow-Origin is itself a network error: access-control-and-redirects). A
|
|
4506
|
+
# credentialed request additionally forbids `*` and needs Allow-Credentials.
|
|
4507
|
+
if cross_origin && !cors_response_ok?(resp_headers, effective_origin, with_credentials)
|
|
4508
|
+
resp_body.close if resp_body.respond_to?(:close)
|
|
4509
|
+
return nil
|
|
4510
|
+
end
|
|
4511
|
+
# A redirect-status response in a NON-follow mode is handled without following,
|
|
4512
|
+
# keyed on the status ALONE (the Location is never parsed): `error` is a network
|
|
4513
|
+
# error; `manual` is an opaque-redirect filtered response (status 0, empty
|
|
4514
|
+
# statusText/headers, the ORIGINAL request URL, type 'opaqueredirect'). The CORS
|
|
4515
|
+
# check above runs first, so a cross-origin redirect that fails CORS is a network
|
|
4516
|
+
# error either way (redirect-mode / -location).
|
|
4517
|
+
if redirect_mode != 'follow' && REDIRECT_STATUSES.include?(status.to_i)
|
|
4518
|
+
resp_body.close if resp_body.respond_to?(:close)
|
|
3693
4519
|
raise StandardError, '[capybara-simulated] fetch: redirect blocked by redirect=error mode' if redirect_mode == 'error'
|
|
4520
|
+
# A no-cors request may not even opaquely expose a CROSS-origin redirect — a
|
|
4521
|
+
# no-cors non-follow redirect to a cross-origin target is a network error,
|
|
4522
|
+
# while a same-origin one still yields an opaque-redirect.
|
|
4523
|
+
return nil if no_cors_mode && crossed
|
|
4524
|
+
return response_hash(0, {}, '', target, false, type: 'opaqueredirect', body_null: true)
|
|
4525
|
+
end
|
|
4526
|
+
if (loc = redirect_location(status, resp_headers))
|
|
3694
4527
|
# Log this hop (3xx) before method/body are rewritten for the next.
|
|
3695
4528
|
trace_network(method, target, status, headers, body, resp_headers, nil, t0, true)
|
|
4529
|
+
# Cache the redirect itself (a cacheable 3xx with freshness) BEFORE following it —
|
|
4530
|
+
# the follow does `next`, which would otherwise skip the store below — so a later
|
|
4531
|
+
# only-if-cached / force-cache request can follow the redirect chain from the cache
|
|
4532
|
+
# (request-cache "uses cached … redirects"). Same store gate as the terminal hop.
|
|
4533
|
+
@@asset_cache.store(target, status, resp_headers, '') if method == 'GET' && cache_mode != 'no-store' && !skip_cache
|
|
3696
4534
|
redirected = true
|
|
3697
|
-
|
|
4535
|
+
ref_source = hop_referer # the next hop strips from what THIS hop sent
|
|
4536
|
+
# A redirect response's Referrer-Policy overrides the policy for the next hop
|
|
4537
|
+
# (redirect-referrer-override): the last valid token of the header wins.
|
|
4538
|
+
if (rp = resp_headers['referrer-policy'] || resp_headers['Referrer-Policy'])
|
|
4539
|
+
tok = Array(rp).join(',').split(',').map(&:strip).reverse.find {|t| REFERRER_POLICIES.include?(t) }
|
|
4540
|
+
ref_policy = tok if tok
|
|
4541
|
+
end
|
|
3698
4542
|
next_url = resolve_against(loc, target)
|
|
4543
|
+
# The UA only follows http(s) redirects: a Location that resolves to a
|
|
4544
|
+
# non-HTTP(S) URL (data:, an `invalidurl:` scheme, …) is a network error
|
|
4545
|
+
# (redirect-location data/invalid in follow mode).
|
|
4546
|
+
unless next_url.to_s.match?(%r{\Ahttps?://}i)
|
|
4547
|
+
resp_body.close if resp_body.respond_to?(:close)
|
|
4548
|
+
return nil
|
|
4549
|
+
end
|
|
4550
|
+
# A cross-origin redirect taints the request's origin to opaque ("null") only
|
|
4551
|
+
# once the request was ALREADY cross-origin (response tainting "cors", i.e.
|
|
4552
|
+
# `crossed`) and the hop changes origin — so a subsequent hop sends Origin: null
|
|
4553
|
+
# and the CORS check demands the server allow "null"/"*". The FIRST cross-origin
|
|
4554
|
+
# hop out of a same-origin request keeps the real origin (redirect-origin
|
|
4555
|
+
# "same origin to other origin" sends the document origin, not null).
|
|
4556
|
+
effective_origin = 'null' if cors && crossed && url_origin(next_url) != url_origin(target)
|
|
3699
4557
|
target = carry_fragment(target, next_url)
|
|
3700
|
-
|
|
3701
|
-
|
|
4558
|
+
if bad_port?(target) # a redirect to a blocked port is a network error too
|
|
4559
|
+
resp_body.close if resp_body.respond_to?(:close)
|
|
4560
|
+
return nil
|
|
4561
|
+
end
|
|
4562
|
+
# Fetch "HTTP-redirect fetch": the method changes to GET (dropping the
|
|
4563
|
+
# body + its Content-* headers) ONLY for 301/302 of a POST, or 303 of a
|
|
4564
|
+
# non-GET/HEAD. Otherwise method, body, and headers are preserved — so a
|
|
4565
|
+
# GET/HEAD redirected via 301/302/303 keeps its method and Content-Type,
|
|
4566
|
+
# and 307/308 always preserve (xhr send-redirect basics).
|
|
4567
|
+
up = method.to_s.upcase
|
|
4568
|
+
if ([301, 302].include?(status) && up == 'POST') || (status == 303 && !%w[GET HEAD].include?(up))
|
|
4569
|
+
method = 'GET'
|
|
4570
|
+
body = nil
|
|
4571
|
+
headers = headers.reject {|k, _| REDIRECT_DROPPED_HEADERS.include?(k.to_s.downcase) } if headers.is_a?(Hash)
|
|
4572
|
+
end
|
|
3702
4573
|
resp_body.close if resp_body.respond_to?(:close)
|
|
3703
4574
|
next
|
|
3704
4575
|
end
|
|
4576
|
+
# A follow-mode redirect whose Location header IS present but EMPTY parses to the
|
|
4577
|
+
# request URL — a self-redirect that would loop until the redirect limit trips a
|
|
4578
|
+
# network error. redirect_location returns nil for it (empty ⇒ no followable
|
|
4579
|
+
# target, so navigation keeps rendering the 3xx), so recognize it here and fail
|
|
4580
|
+
# directly — fetch-only (redirect-empty-location follow mode).
|
|
4581
|
+
if REDIRECT_STATUSES.include?(status.to_i)
|
|
4582
|
+
raw_loc = resp_headers['location'] || resp_headers['Location']
|
|
4583
|
+
raw_loc = raw_loc.first if raw_loc.is_a?(Array)
|
|
4584
|
+
if raw_loc && raw_loc.to_s.empty?
|
|
4585
|
+
resp_body.close if resp_body.respond_to?(:close)
|
|
4586
|
+
return nil
|
|
4587
|
+
end
|
|
4588
|
+
end
|
|
3705
4589
|
body_str = read_rack_body(resp_body)
|
|
4590
|
+
# A HEAD response, and a null-body status (204/205/304), have NO body — the UA
|
|
4591
|
+
# discards whatever the server sent and exposes response.body as null
|
|
4592
|
+
# (response-method HEAD; response-null-body). `null_body` flags it so the JS
|
|
4593
|
+
# Response reports a null body + empty text.
|
|
4594
|
+
null_body = method.to_s.upcase == 'HEAD' || NULL_BODY_STATUSES.include?(status.to_i)
|
|
4595
|
+
body_str = '' if null_body
|
|
4596
|
+
# The UA transparently decodes a Content-Encoding'd body (gzip/deflate); the
|
|
4597
|
+
# header stays, the bytes are inflated (response-data-gzip / -deflate).
|
|
4598
|
+
body_str = decode_content_encoding(body_str, resp_headers)
|
|
4599
|
+
# A cross-origin response only EXPOSES (getResponseHeader / getAllResponseHeaders)
|
|
4600
|
+
# the CORS-safelisted response headers plus those named in Access-Control-Expose
|
|
4601
|
+
# -Headers (`*` = all). content-type stays safelisted, so response decoding is
|
|
4602
|
+
# unaffected. (Filtered for script exposure only — trace / set-cookie / cache see
|
|
4603
|
+
# the full set.) The CORS check itself already ran above (incl. on 3xx hops).
|
|
4604
|
+
exposed_headers = cross_origin ? cors_exposed_headers(resp_headers) : resp_headers
|
|
3706
4605
|
trace_network(method, target, status, headers, body, resp_headers, body_str, t0, false)
|
|
3707
|
-
|
|
3708
|
-
|
|
4606
|
+
# A no-store request must not write the cache (RFC 9111 §5.2.1.5); a request carrying
|
|
4607
|
+
# the author's own conditional bypasses the UA cache entirely (read AND write) — it's
|
|
4608
|
+
# "treated similarly to no-store" (request-cache-default-conditional). Every other mode
|
|
4609
|
+
# (incl. reload, which refreshes it) stores a cacheable GET response.
|
|
4610
|
+
@@asset_cache.store(target, status, resp_headers, body_str) if method == 'GET' && cache_mode != 'no-store' && !skip_cache
|
|
4611
|
+
# A no-cors cross-origin response is OPAQUE: status 0, empty body, no exposed
|
|
4612
|
+
# headers, empty URL (cors-basic "Opaque filter"). Otherwise the type is 'cors'
|
|
4613
|
+
# for a cross-origin (CORS-allowed) response, else 'basic'.
|
|
4614
|
+
return response_hash(0, {}, '', '', false, type: 'opaque', body_null: true) if no_cors_mode && crossed
|
|
4615
|
+
return response_hash(status, exposed_headers, body_str, target, redirected, type: crossed ? 'cors' : 'basic', body_null: null_body)
|
|
3709
4616
|
end
|
|
3710
4617
|
raise StandardError, "[capybara-simulated] fetch exceeded #{MAX_FETCH_REDIRECTS} redirects"
|
|
3711
4618
|
rescue StandardError => e
|
|
@@ -3726,7 +4633,7 @@ module Capybara
|
|
|
3726
4633
|
return unless @trace
|
|
3727
4634
|
ct = resp_headers && (resp_headers['content-type'] || resp_headers['Content-Type'])
|
|
3728
4635
|
ct = ct.first if ct.is_a?(Array) # Rack 3 permits array-valued header fields
|
|
3729
|
-
ct = ct.split(';', 2).first
|
|
4636
|
+
ct = ct.split(';', 2).first&.strip if ct.is_a?(String) # "" → split is [] → first is nil
|
|
3730
4637
|
size = if resp_body
|
|
3731
4638
|
resp_body.bytesize
|
|
3732
4639
|
elsif (cl = resp_headers && (resp_headers['content-length'] || resp_headers['Content-Length']))
|
|
@@ -3767,7 +4674,13 @@ module Capybara
|
|
|
3767
4674
|
|
|
3768
4675
|
def normalize_trace_headers(headers)
|
|
3769
4676
|
return nil unless headers
|
|
3770
|
-
headers.each_with_object({})
|
|
4677
|
+
headers.each_with_object({}) do |(k, v), out|
|
|
4678
|
+
# `x-csim-status-text` is an internal sentinel carrying the HTTP reason
|
|
4679
|
+
# phrase (response_hash lifts it into statusText); it's never a real wire
|
|
4680
|
+
# header, so keep it out of the trace.
|
|
4681
|
+
next if k.to_s.downcase == 'x-csim-status-text'
|
|
4682
|
+
out[k.to_s] = v.is_a?(Array) ? v.join(', ') : v.to_s
|
|
4683
|
+
end
|
|
3771
4684
|
end
|
|
3772
4685
|
|
|
3773
4686
|
# CGI convention: `Content-Type` and `Content-Length` land in env
|
|
@@ -3778,7 +4691,14 @@ module Capybara
|
|
|
3778
4691
|
# `@rails/request.js` never deserialise and the server reads an
|
|
3779
4692
|
# empty params hash.
|
|
3780
4693
|
def apply_request_headers(env, headers)
|
|
4694
|
+
# Preserve the author's exact header names (casing + token chars) alongside the
|
|
4695
|
+
# CGI-mangled HTTP_* keys: the Rack env upcases names and drops non-alphanumerics
|
|
4696
|
+
# (Status-URI → HTTP_STATUS_URI, a tchar-only name → an unrecoverable key), but a
|
|
4697
|
+
# .py echo handler (inspect-headers / echo-headers) reports the names verbatim.
|
|
4698
|
+
# run_py_handler reads this side list to emit the original names.
|
|
4699
|
+
raw = (env['csim.raw_request_headers'] ||= []) if @@capture_raw_request_headers
|
|
3781
4700
|
headers.each {|k, v|
|
|
4701
|
+
raw << [k.to_s, v.to_s] if raw
|
|
3782
4702
|
name = k.to_s.upcase.tr('-', '_')
|
|
3783
4703
|
case name
|
|
3784
4704
|
when 'CONTENT_TYPE', 'CONTENT_LENGTH' then env[name] = v.to_s
|
|
@@ -3797,9 +4717,13 @@ module Capybara
|
|
|
3797
4717
|
# text body when `body_b64` is absent.
|
|
3798
4718
|
TEXT_CONTENT_TYPE_PREFIXES = %w[text/ application/json application/javascript application/ecmascript application/xml image/svg+xml].freeze
|
|
3799
4719
|
|
|
3800
|
-
def response_hash(status, headers, body, url, redirected)
|
|
4720
|
+
def response_hash(status, headers, body, url, redirected, type: 'basic', body_null: false)
|
|
3801
4721
|
raw = body.to_s
|
|
3802
4722
|
hdrs = stringify(headers)
|
|
4723
|
+
# A NUL in a header value is not a valid HTTP message; a real server can't
|
|
4724
|
+
# put it on the wire, so the fetch is a network error (nil → status 0 / a
|
|
4725
|
+
# thrown NetworkError for a sync XHR). See headers-normalize-response.
|
|
4726
|
+
return nil if hdrs.any? {|_, v| v.include?("\u0000") }
|
|
3803
4727
|
is_text = text_response?(hdrs)
|
|
3804
4728
|
# `body` crosses as TEXT — `responseText` semantics: the bytes decoded
|
|
3805
4729
|
# as UTF-8 with invalid sequences replaced (a leading BOM selects the
|
|
@@ -3814,18 +4738,43 @@ module Capybara
|
|
|
3814
4738
|
else
|
|
3815
4739
|
RuntimeShared.utf8_text(raw)
|
|
3816
4740
|
end
|
|
4741
|
+
# statusText = the HTTP reason phrase: a custom one carried on the internal
|
|
4742
|
+
# x-csim-status-text header (status.py), else the status code's standard
|
|
4743
|
+
# reason (xhr status/statusText tests). Strip the internal header either way.
|
|
4744
|
+
custom_reason = hdrs.delete('x-csim-status-text')
|
|
4745
|
+
# Rack::Utils::HTTP_STATUS_CODES values are ASCII-8BIT (binary) strings — the V8
|
|
4746
|
+
# bridge marshals a binary string as a byte array, not a JS string, so statusText
|
|
4747
|
+
# would arrive as [79,75] instead of "OK" (abort-during-loading reads statusText
|
|
4748
|
+
# on a static-file response). utf8_text re-tags + scrubs to a clean JS string, the
|
|
4749
|
+
# same path the body and every header value already take.
|
|
4750
|
+
reason = RuntimeShared.utf8_text(custom_reason || Rack::Utils::HTTP_STATUS_CODES[status.to_i] || '')
|
|
4751
|
+
# HTTP/2 has no reason phrase, so statusText is always the empty string there (a WPT
|
|
4752
|
+
# `.h2` test document's fetches run over h2). We don't model the h2 transport, so key
|
|
4753
|
+
# off the document URL — the same signal WPT uses to serve the resource over h2
|
|
4754
|
+
# (fetch/xhr status.h2 "statusText over H2 … should be the empty string").
|
|
4755
|
+
reason = '' if @current_url.to_s.include?('.h2.')
|
|
3817
4756
|
out = {
|
|
3818
4757
|
'status' => status,
|
|
4758
|
+
'statusText' => reason,
|
|
3819
4759
|
'headers' => hdrs,
|
|
3820
4760
|
'body' => text,
|
|
3821
4761
|
'url' => url,
|
|
3822
4762
|
'redirected' => redirected,
|
|
3823
|
-
'type' =>
|
|
4763
|
+
'type' => type
|
|
3824
4764
|
}
|
|
4765
|
+
out['body_null'] = true if body_null # null-body status / HEAD → response.body is null
|
|
3825
4766
|
# The BOM-detected encoding (if any) — a frame load pins its document's
|
|
3826
4767
|
# characterSet to it (see __csimFrameWindow); highest-precedence signal.
|
|
3827
4768
|
out['charset'] = bom_charset if bom_charset
|
|
3828
|
-
|
|
4769
|
+
# Hand the raw bytes to the (XHR) client UNLESS the response is pure-ASCII text.
|
|
4770
|
+
# ASCII decodes identically under every encoding — so responseText is already
|
|
4771
|
+
# correct from the UTF-8 `body`, and it round-trips byte-for-byte as an
|
|
4772
|
+
# ArrayBuffer/Blob. Any NON-ASCII body needs the bytes: a non-UTF-8 charset or an
|
|
4773
|
+
# XML-prolog / <meta charset>-sniffed encoding (responseText), or multibyte UTF-8
|
|
4774
|
+
# read as arraybuffer/blob — the client decodes them with the final encoding
|
|
4775
|
+
# (decodeResponseBytes). `ascii_only?` is a cheap C-level scan, so the dominant
|
|
4776
|
+
# pure-ASCII app JSON/HTML traffic keeps the fast path and pays no base64.
|
|
4777
|
+
out['body_b64'] = Base64.strict_encode64(raw) unless is_text && raw.ascii_only?
|
|
3829
4778
|
out
|
|
3830
4779
|
end
|
|
3831
4780
|
|
|
@@ -3901,6 +4850,34 @@ module Capybara
|
|
|
3901
4850
|
buf
|
|
3902
4851
|
end
|
|
3903
4852
|
|
|
4853
|
+
# Transparently decode a Content-Encoding'd response body (HTTP "content coding"):
|
|
4854
|
+
# gzip / x-gzip via Zlib.gunzip; deflate via zlib-wrapped inflate, falling back to
|
|
4855
|
+
# raw DEFLATE (the "deflate" coding is ambiguously used for both). Unknown codings
|
|
4856
|
+
# (e.g. br) and malformed data are left untouched — best-effort, like a browser that
|
|
4857
|
+
# would error, but we keep the bytes so the caller still sees a response.
|
|
4858
|
+
def decode_content_encoding(body, headers)
|
|
4859
|
+
return body if body.nil? || body.empty?
|
|
4860
|
+
raw = headers.find {|k, _| k.to_s.downcase == 'content-encoding' }&.last
|
|
4861
|
+
enc = (raw.is_a?(Array) ? raw.join(',') : raw.to_s).strip.downcase # Rack 3 may hand the value as an array
|
|
4862
|
+
# The decoded bytes re-enter the UTF-8 text pipeline the same as an
|
|
4863
|
+
# un-encoded body (read_rack_body yields UTF-8), so re-tag them — Zlib
|
|
4864
|
+
# output is ASCII-8BIT, which would otherwise marshal to V8 as a byte array.
|
|
4865
|
+
decoded =
|
|
4866
|
+
case enc
|
|
4867
|
+
when 'gzip', 'x-gzip' then Zlib.gunzip(body.b)
|
|
4868
|
+
when 'deflate'
|
|
4869
|
+
begin
|
|
4870
|
+
Zlib::Inflate.inflate(body.b)
|
|
4871
|
+
rescue Zlib::Error
|
|
4872
|
+
Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(body.b) # raw (header-less) DEFLATE
|
|
4873
|
+
end
|
|
4874
|
+
else return body
|
|
4875
|
+
end
|
|
4876
|
+
decoded.force_encoding('UTF-8')
|
|
4877
|
+
rescue Zlib::Error
|
|
4878
|
+
body
|
|
4879
|
+
end
|
|
4880
|
+
|
|
3904
4881
|
# Defer the navigation: doing it from inside the running V8 call
|
|
3905
4882
|
# would dispose the Context mid-call. tick_real_time drains
|
|
3906
4883
|
# after the call returns. Same pattern as `__csimPendingFormSubmit`.
|
|
@@ -3940,6 +4917,9 @@ module Capybara
|
|
|
3940
4917
|
(@pending_frame_nav ||= {})[realm_id] = url.to_s
|
|
3941
4918
|
end
|
|
3942
4919
|
def consume_pending_frame_nav
|
|
4920
|
+
# Window-realm self-navs are realm navs too — drain them at every frame-nav
|
|
4921
|
+
# drain point (before the frame-nav early-return so a window-only nav lands).
|
|
4922
|
+
consume_pending_window_nav
|
|
3943
4923
|
return if @pending_frame_nav.nil? || @pending_frame_nav.empty?
|
|
3944
4924
|
navs = @pending_frame_nav
|
|
3945
4925
|
@pending_frame_nav = nil
|
|
@@ -3963,6 +4943,44 @@ module Capybara
|
|
|
3963
4943
|
log_console('warn', "frame self-navigation failed: #{e.message}")
|
|
3964
4944
|
end
|
|
3965
4945
|
end
|
|
4946
|
+
# A same-origin WINDOW realm (window.open in this isolate) navigating itself
|
|
4947
|
+
# via `win.location = …`. Like frame_navigate_self, defer (the call lands here
|
|
4948
|
+
# from the realm's own location setter, mid-flight) and drain after the action.
|
|
4949
|
+
# A blob: URL is resolved to bytes NOW — before the opener's typical immediate
|
|
4950
|
+
# `revokeObjectURL` — since the realm reload happens later (url-in-tags-revoke).
|
|
4951
|
+
def window_realm_navigate_self(url, realm_id)
|
|
4952
|
+
return if realm_id.nil? || realm_id.zero?
|
|
4953
|
+
spec = {url: url.to_s}
|
|
4954
|
+
if url.to_s.start_with?('blob:') && (b = read_blob_for_window(url.to_s))
|
|
4955
|
+
# The blob's bytes arrive BINARY-tagged (Base64-decoded). __csimLoadDocument
|
|
4956
|
+
# HTML-parses TEXT, and a BINARY string marshals to V8 as a Uint8Array (not a
|
|
4957
|
+
# String), which `String(...)`s to comma-joined digits — a script-less doc. Decode
|
|
4958
|
+
# to UTF-8 text like every other load path (see RuntimeShared.utf8_text).
|
|
4959
|
+
spec[:body] = RuntimeShared.utf8_text(b[:bytes])
|
|
4960
|
+
spec[:ctype] = b[:type].to_s.empty? ? 'text/html' : b[:type]
|
|
4961
|
+
end
|
|
4962
|
+
(@pending_window_nav ||= {})[realm_id] = spec
|
|
4963
|
+
end
|
|
4964
|
+
def consume_pending_window_nav
|
|
4965
|
+
return if @pending_window_nav.nil? || @pending_window_nav.empty?
|
|
4966
|
+
navs = @pending_window_nav
|
|
4967
|
+
@pending_window_nav = nil
|
|
4968
|
+
navs.each do |realm_id, spec|
|
|
4969
|
+
invalidate_find_cache
|
|
4970
|
+
body, ctype = spec[:body], spec[:ctype]
|
|
4971
|
+
# http(s) / relative URL window-realm nav (rack fetch) is not modeled yet —
|
|
4972
|
+
# only the blob/in-memory document case (which pre-resolved bytes above)
|
|
4973
|
+
# loads here. Warn rather than silently drop so an unsupported popup
|
|
4974
|
+
# navigation is diagnosable instead of looking like a frozen about:blank.
|
|
4975
|
+
if body.nil?
|
|
4976
|
+
log_console('warn', "window-realm navigation to #{spec[:url]} not modeled (only blob: documents load); ignoring")
|
|
4977
|
+
next
|
|
4978
|
+
end
|
|
4979
|
+
@runtime.reload_window_realm(realm_id, spec[:url], body.to_s, ctype.to_s)
|
|
4980
|
+
rescue StandardError => e
|
|
4981
|
+
log_console('warn', "window realm self-navigation failed: #{e.message}")
|
|
4982
|
+
end
|
|
4983
|
+
end
|
|
3966
4984
|
# Mirror of `location_assign`'s deferral for `location.reload()`:
|
|
3967
4985
|
# the JS call lands here from `__locationReload`; running
|
|
3968
4986
|
# `browser.refresh` directly would `navigate` (rebuilding the
|
|
@@ -4029,11 +5047,103 @@ module Capybara
|
|
|
4029
5047
|
sub = @runtime.realm_call(realm_id, '__csimTakePendingFormSubmit')
|
|
4030
5048
|
next unless sub.is_a?(Hash) && sub['formHandle']
|
|
4031
5049
|
invalidate_find_cache
|
|
4032
|
-
submit_form_in_realm(realm_id, sub['formHandle'], sub['submitterHandle'])
|
|
5050
|
+
submit_form_in_realm(realm_id, sub['formHandle'], sub['submitterHandle'], sub['entryList'])
|
|
4033
5051
|
rescue StandardError => e
|
|
4034
5052
|
log_console('warn', "nested-context form submission failed: #{e.message}")
|
|
4035
5053
|
end
|
|
4036
5054
|
end
|
|
5055
|
+
# ── Frame session history ──────────────────────────────────────────────
|
|
5056
|
+
# A nested browsing context (iframe) keeps its OWN back/forward history of
|
|
5057
|
+
# the documents it navigates through, with each entry's form-control state
|
|
5058
|
+
# captured for restoration (HTML "persisted user state" / bfcache). Keyed by
|
|
5059
|
+
# [parent realm, iframe element handle] — stable across the frame-realm
|
|
5060
|
+
# rebuilds a navigation triggers (the element outlives its realm).
|
|
5061
|
+
#
|
|
5062
|
+
# `iframe.contentWindow.history.back()` runs while the frame realm is on the
|
|
5063
|
+
# V8 stack, so (like frame_navigate_self) DEFER the traversal and drain it
|
|
5064
|
+
# after the call returns — rebuilding the realm inline would terminate it.
|
|
5065
|
+
def frame_history_go(realm_id, delta)
|
|
5066
|
+
return if realm_id.nil? || realm_id.zero?
|
|
5067
|
+
@pending_frame_traverse = {realm_id: realm_id, delta: delta.to_i}
|
|
5068
|
+
end
|
|
5069
|
+
def consume_pending_frame_traverse
|
|
5070
|
+
return if @pending_frame_traverse.nil?
|
|
5071
|
+
pt = @pending_frame_traverse
|
|
5072
|
+
@pending_frame_traverse = nil
|
|
5073
|
+
invalidate_find_cache
|
|
5074
|
+
perform_frame_traverse(pt[:realm_id], pt[:delta])
|
|
5075
|
+
rescue StandardError => e
|
|
5076
|
+
log_console('warn', "frame history traversal failed: #{e.message}")
|
|
5077
|
+
end
|
|
5078
|
+
def perform_frame_traverse(realm_id, delta)
|
|
5079
|
+
# The traversal was deferred; the frame may have been disposed (a competing
|
|
5080
|
+
# nav) between flag and drain — drop it gracefully.
|
|
5081
|
+
return unless @runtime.frame_realm_alive?(realm_id)
|
|
5082
|
+
parent = @runtime.frame_realm_parent(realm_id)
|
|
5083
|
+
handle = frame_container_handle(realm_id, parent)
|
|
5084
|
+
return if handle.zero?
|
|
5085
|
+
h = (@frame_histories ||= {})[[parent, handle]]
|
|
5086
|
+
return if h.nil?
|
|
5087
|
+
target = h[:idx] + delta
|
|
5088
|
+
return if target.negative? || target >= h[:entries].size
|
|
5089
|
+
# Snapshot the entry we're leaving so a later forward traversal restores it.
|
|
5090
|
+
h[:entries][h[:idx]] = frame_history_entry(realm_id) if h[:idx] >= 0
|
|
5091
|
+
reload_frame_to_entry(realm_id, h[:entries][target])
|
|
5092
|
+
h[:idx] = target # advance only after the rebuild succeeds
|
|
5093
|
+
end
|
|
5094
|
+
# Record a frame navigation away from `realm_id` to `new_url`: snapshot the
|
|
5095
|
+
# OUTGOING document (URL + form state) into the current entry — seeding entry
|
|
5096
|
+
# 0 the first time — then drop any forward tail and push the new entry. Hooked
|
|
5097
|
+
# into the frame form-submission paths; frame navigations driven by
|
|
5098
|
+
# `location.href` / link clicks aren't recorded yet (history.back there falls
|
|
5099
|
+
# through to the top document, as before).
|
|
5100
|
+
def record_frame_nav(realm_id, new_url)
|
|
5101
|
+
return if realm_id.nil? || realm_id.zero?
|
|
5102
|
+
parent = @runtime.frame_realm_parent(realm_id)
|
|
5103
|
+
handle = frame_container_handle(realm_id, parent)
|
|
5104
|
+
return if handle.zero?
|
|
5105
|
+
h = (@frame_histories ||= {})[[parent, handle]] ||= {entries: [], idx: -1}
|
|
5106
|
+
outgoing = frame_history_entry(realm_id)
|
|
5107
|
+
if h[:idx] >= 0
|
|
5108
|
+
h[:entries][h[:idx]] = outgoing
|
|
5109
|
+
else
|
|
5110
|
+
h[:entries] << outgoing
|
|
5111
|
+
h[:idx] = 0
|
|
5112
|
+
end
|
|
5113
|
+
h[:entries] = h[:entries][0..h[:idx]]
|
|
5114
|
+
h[:entries] << {url: new_url.to_s, form_state: nil}
|
|
5115
|
+
h[:idx] = h[:entries].size - 1
|
|
5116
|
+
end
|
|
5117
|
+
# The history entry for the document currently loaded in `realm_id`: its URL
|
|
5118
|
+
# plus a snapshot of its form-control state.
|
|
5119
|
+
def frame_history_entry(realm_id)
|
|
5120
|
+
{url: frame_realm_url(realm_id), form_state: capture_frame_form_state(realm_id)}
|
|
5121
|
+
end
|
|
5122
|
+
def frame_realm_url(realm_id)
|
|
5123
|
+
return nil unless @runtime.frame_realm_alive?(realm_id)
|
|
5124
|
+
@runtime.realm_call(realm_id, '__csimLocationHref').to_s
|
|
5125
|
+
rescue StandardError
|
|
5126
|
+
nil
|
|
5127
|
+
end
|
|
5128
|
+
def capture_frame_form_state(realm_id)
|
|
5129
|
+
return nil unless @runtime.frame_realm_alive?(realm_id)
|
|
5130
|
+
@runtime.realm_call(realm_id, '__csimCaptureFormState')
|
|
5131
|
+
rescue StandardError
|
|
5132
|
+
nil
|
|
5133
|
+
end
|
|
5134
|
+
# Re-fetch a history entry's URL and rebuild the frame realm from it, then
|
|
5135
|
+
# restore the entry's captured form state (before the element load fires).
|
|
5136
|
+
def reload_frame_to_entry(realm_id, entry)
|
|
5137
|
+
url = entry[:url].to_s
|
|
5138
|
+
return if url.empty?
|
|
5139
|
+
env = Rack::MockRequest.env_for(url, method: 'GET')
|
|
5140
|
+
apply_default_request_env(env, referer: current_browsing_context_url)
|
|
5141
|
+
status, headers, body = dispatch_rack_or_http(url, env, method: 'GET')
|
|
5142
|
+
merge_set_cookie(headers)
|
|
5143
|
+
return if download_response?(headers)
|
|
5144
|
+
html = read_rack_body(body)
|
|
5145
|
+
reload_frame_realm_by_id(realm_id, url, html, response_content_type(headers), restore_state: entry[:form_state])
|
|
5146
|
+
end
|
|
4037
5147
|
# Serialize + route a form submitted inside frame realm `realm_id`. We
|
|
4038
5148
|
# serialize in the INITIATING realm (so shadow-tree controls are excluded
|
|
4039
5149
|
# and relative URLs resolve against that document), then route by target:
|
|
@@ -4044,23 +5154,25 @@ module Capybara
|
|
|
4044
5154
|
# GET fully supported. POST to a self frame needs the entered stack
|
|
4045
5155
|
# (navigate_frame_post); POST-to-named and other targets from a nested
|
|
4046
5156
|
# context aren't modeled (no in-scope need) — logged rather than dropped.
|
|
4047
|
-
def submit_form_in_realm(realm_id, form_handle, submitter_handle)
|
|
5157
|
+
def submit_form_in_realm(realm_id, form_handle, submitter_handle, entry_list = nil)
|
|
4048
5158
|
spec = @runtime.realm_call(realm_id, '__csimFormSerialize', form_handle, submitter_handle || 0)
|
|
4049
5159
|
return unless spec.is_a?(Hash)
|
|
4050
5160
|
method = spec['method'].to_s.upcase
|
|
4051
5161
|
method = 'GET' if method.empty?
|
|
4052
5162
|
target = spec['target'].to_s
|
|
4053
5163
|
action = spec['action'].to_s
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
(
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
end
|
|
4060
|
-
body = URI.encode_www_form(fields)
|
|
4061
|
-
get_url = form_get_url(action, body)
|
|
5164
|
+
enctype = spec['enctype'].to_s.empty? ? 'application/x-www-form-urlencoded' : spec['enctype'].to_s.downcase
|
|
5165
|
+
entries = entry_list.is_a?(Array) ? entry_list : entries_from_spec(spec)
|
|
5166
|
+
# GET → urlencoded query (enctype ignored); POST → enctype-encoded body.
|
|
5167
|
+
get_query, = encode_entry_list(entries, 'application/x-www-form-urlencoded')
|
|
5168
|
+
get_url = form_get_url(action, get_query)
|
|
4062
5169
|
if frame_self_target?(target)
|
|
4063
|
-
|
|
5170
|
+
if method == 'GET'
|
|
5171
|
+
navigate_realm_self_get(realm_id, get_url)
|
|
5172
|
+
else
|
|
5173
|
+
body, content_type = encode_entry_list(entries, enctype)
|
|
5174
|
+
navigate_realm_self_post(realm_id, resolve_against_current(action), body, content_type)
|
|
5175
|
+
end
|
|
4064
5176
|
elsif %w[_parent _top _blank].include?(target.downcase)
|
|
4065
5177
|
log_console('warn', "nested-context form submit (target=#{target.inspect}) is not modeled")
|
|
4066
5178
|
elsif method == 'GET'
|
|
@@ -4078,30 +5190,103 @@ module Capybara
|
|
|
4078
5190
|
# URL's query with the serialized entry list (dropping any pre-existing
|
|
4079
5191
|
# query), preserving a trailing #fragment. String-based so it works on the
|
|
4080
5192
|
# raw (possibly relative) action attribute without URI.parse fragility;
|
|
4081
|
-
# the absolute equivalent of submit_form_handle's `uri.query = body`.
|
|
5193
|
+
# the absolute equivalent of submit_form_handle's `uri.query = body`. An
|
|
5194
|
+
# EMPTY entry list still clears the query (→ `action?`), matching browsers.
|
|
4082
5195
|
def form_get_url(action, body)
|
|
4083
|
-
return action if body.empty?
|
|
4084
5196
|
base, _hash, frag = action.partition('#')
|
|
4085
5197
|
path = base.split('?', 2).first
|
|
4086
5198
|
url = "#{path}?#{body}"
|
|
4087
5199
|
frag.empty? ? url : "#{url}##{frag}"
|
|
4088
5200
|
end
|
|
4089
|
-
#
|
|
4090
|
-
|
|
5201
|
+
# A self-targeted GET form submit in the initiating frame realm: navigate
|
|
5202
|
+
# that frame to the action URL (query already mutated in).
|
|
5203
|
+
def navigate_realm_self_get(realm_id, get_url)
|
|
5204
|
+
record_frame_nav(realm_id, get_url)
|
|
4091
5205
|
entry = @frame_stack.find {|e| e[:realm_id] == realm_id }
|
|
4092
|
-
if
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
5206
|
+
if entry
|
|
5207
|
+
navigate_frame(resolve_against_current(get_url), entry: entry)
|
|
5208
|
+
else
|
|
5209
|
+
# A frame reached via contentWindow (not on the entered stack): its
|
|
5210
|
+
# owning iframe lives in its PARENT realm's document (the main realm for
|
|
5211
|
+
# a top-level frame, an intermediate realm for a nested one), so route
|
|
5212
|
+
# the by-realm src-reassignment there — not unconditionally to main.
|
|
5213
|
+
# (relative get_url resolves against the frame's base on rebuild).
|
|
5214
|
+
parent = @runtime.frame_realm_parent(realm_id)
|
|
5215
|
+
frame_realm_host_call(parent, '__csimNavigateFrameByRealm', realm_id, get_url)
|
|
5216
|
+
end
|
|
5217
|
+
end
|
|
5218
|
+
# A self-targeted POST form submit in the initiating frame realm. POST the
|
|
5219
|
+
# entity body to the action URL, then rebuild that frame's realm from the
|
|
5220
|
+
# response. An ENTERED frame (on @frame_stack) reuses navigate_frame_post;
|
|
5221
|
+
# a frame reached via contentWindow has no stack entry, so rebuild it by
|
|
5222
|
+
# realm id (recovering its container element + parent realm) and fire the
|
|
5223
|
+
# iframe element's load event the GET/src path would.
|
|
5224
|
+
def navigate_realm_self_post(realm_id, url, body, content_type, depth: 0)
|
|
5225
|
+
raise 'too many redirects' if depth > 10
|
|
5226
|
+
record_frame_nav(realm_id, url) if depth.zero?
|
|
5227
|
+
entry = @frame_stack.find {|e| e[:realm_id] == realm_id }
|
|
5228
|
+
return navigate_frame_post(url, body, content_type, entry: entry) if entry
|
|
5229
|
+
invalidate_find_cache
|
|
5230
|
+
env = Rack::MockRequest.env_for(url, method: 'POST', input: body)
|
|
5231
|
+
env['CONTENT_TYPE'] = content_type.to_s.empty? ? 'application/x-www-form-urlencoded' : content_type
|
|
5232
|
+
env['CONTENT_LENGTH'] = body.bytesize.to_s
|
|
5233
|
+
apply_default_request_env(env, referer: current_browsing_context_url)
|
|
5234
|
+
status, headers, resp_body = dispatch_rack_or_http(url, env, method: 'POST', body: body)
|
|
5235
|
+
merge_set_cookie(headers)
|
|
5236
|
+
if (loc = redirect_location(status, headers))
|
|
5237
|
+
next_url = resolve_against_current(loc)
|
|
5238
|
+
resp_body.close if resp_body.respond_to?(:close)
|
|
5239
|
+
# 307/308 preserve method + body; 301/302/303 → GET the frame (routed
|
|
5240
|
+
# through the realm that OWNS the iframe, as in navigate_realm_self_get).
|
|
5241
|
+
if [307, 308].include?(status)
|
|
5242
|
+
return navigate_realm_self_post(realm_id, next_url, body, content_type, depth: depth + 1)
|
|
4100
5243
|
end
|
|
4101
|
-
|
|
4102
|
-
|
|
5244
|
+
parent = @runtime.frame_realm_parent(realm_id)
|
|
5245
|
+
return frame_realm_host_call(parent, '__csimNavigateFrameByRealm', realm_id, next_url)
|
|
5246
|
+
end
|
|
5247
|
+
if download_response?(headers)
|
|
5248
|
+
save_downloaded_response(url, headers, resp_body)
|
|
5249
|
+
return
|
|
5250
|
+
end
|
|
5251
|
+
reload_frame_realm_by_id(realm_id, url.to_s, read_rack_body(resp_body), response_content_type(headers))
|
|
5252
|
+
end
|
|
5253
|
+
# Rebuild a frame realm reached via contentWindow (no @frame_stack entry):
|
|
5254
|
+
# recover its container element handle + parent realm, swap in a fresh realm
|
|
5255
|
+
# built from `html`, re-point the iframe at it, and fire the element load.
|
|
5256
|
+
def reload_frame_realm_by_id(realm_id, url, html, content_type, restore_state: nil)
|
|
5257
|
+
parent = @runtime.frame_realm_parent(realm_id)
|
|
5258
|
+
handle = frame_container_handle(realm_id, parent)
|
|
5259
|
+
return if handle.zero?
|
|
5260
|
+
new_id = @runtime.reload_frame_realm(realm_id, parent.to_i, url, RuntimeShared.utf8_text(html), content_type).to_i
|
|
5261
|
+
return if new_id.zero?
|
|
5262
|
+
begin
|
|
5263
|
+
rebind_frame_realm(parent, handle, realm_id, new_id)
|
|
5264
|
+
# Restore captured form state (history traversal) BEFORE the element load
|
|
5265
|
+
# fires, so the restored values are in place by the time the parent's
|
|
5266
|
+
# `iframe.onload` handler — and any assertion after it — runs.
|
|
5267
|
+
@runtime.realm_call(new_id, '__csimRestoreFormState', restore_state) if restore_state
|
|
5268
|
+
frame_realm_host_call(parent, '__csimFireFrameElementLoad', handle)
|
|
5269
|
+
rescue StandardError
|
|
5270
|
+
# The element rebind/load failed — don't strand the freshly built realm
|
|
5271
|
+
# (it's no longer referenced by any iframe), then surface the error.
|
|
5272
|
+
@runtime.dispose_frame_realm(new_id)
|
|
5273
|
+
raise
|
|
5274
|
+
end
|
|
5275
|
+
invalidate_find_cache
|
|
5276
|
+
settle
|
|
5277
|
+
new_id
|
|
5278
|
+
end
|
|
5279
|
+
# The iframe/frame element handle that owns `realm_id`, found in the document
|
|
5280
|
+
# of its parent realm (main realm for a top-level frame).
|
|
5281
|
+
def frame_container_handle(realm_id, parent)
|
|
5282
|
+
frame_realm_host_call(parent, '__csimGetFrameHandle', realm_id).to_i
|
|
5283
|
+
end
|
|
5284
|
+
# Call a host fn in the realm that OWNS an iframe (main realm for 0/nil).
|
|
5285
|
+
def frame_realm_host_call(parent_realm_id, fn, *args)
|
|
5286
|
+
if parent_realm_id.nil? || parent_realm_id.zero?
|
|
5287
|
+
@runtime.call(fn, *args)
|
|
4103
5288
|
else
|
|
4104
|
-
|
|
5289
|
+
@runtime.realm_call(parent_realm_id, fn, *args)
|
|
4105
5290
|
end
|
|
4106
5291
|
end
|
|
4107
5292
|
def drain_pending_navigation
|
|
@@ -4109,6 +5294,7 @@ module Capybara
|
|
|
4109
5294
|
consume_pending_frame_nav
|
|
4110
5295
|
consume_pending_frame_submit
|
|
4111
5296
|
consume_pending_frame_reload
|
|
5297
|
+
consume_pending_frame_traverse
|
|
4112
5298
|
consume_pending_reload
|
|
4113
5299
|
consume_pending_history_traverse
|
|
4114
5300
|
consume_pending_aux_window
|
|
@@ -4404,7 +5590,10 @@ module Capybara
|
|
|
4404
5590
|
boot_response_into_ctx('')
|
|
4405
5591
|
return
|
|
4406
5592
|
end
|
|
4407
|
-
|
|
5593
|
+
unless from_history || depth > 0
|
|
5594
|
+
capture_outgoing_form_state
|
|
5595
|
+
record_history({method: :get, url: url})
|
|
5596
|
+
end
|
|
4408
5597
|
env = Rack::MockRequest.env_for(url, method: 'GET')
|
|
4409
5598
|
apply_default_request_env(env, referer: referer)
|
|
4410
5599
|
status, headers, body = dispatch_rack_or_http(url, env, method: 'GET')
|
|
@@ -4471,7 +5660,9 @@ module Capybara
|
|
|
4471
5660
|
@runtime.rebuild_ctx
|
|
4472
5661
|
# A full page (re)build disposes every frame realm, so any active
|
|
4473
5662
|
# `within_frame` scope is now stale — fall back to the main document.
|
|
5663
|
+
# Per-frame session histories are scoped to this document tree; drop them.
|
|
4474
5664
|
reset_frame_scope
|
|
5665
|
+
@frame_histories = nil
|
|
4475
5666
|
reset_timer_state
|
|
4476
5667
|
# The response content type drives both the parser choice (XML vs HTML —
|
|
4477
5668
|
# XHTML/XML/SVG parse case-sensitively, no html/head/body skeleton,
|
|
@@ -4518,6 +5709,22 @@ module Capybara
|
|
|
4518
5709
|
end
|
|
4519
5710
|
opts['userAgent'] = @default_user_agent if @default_user_agent
|
|
4520
5711
|
@document_handle = @runtime.call('__csimBootContext', opts).to_i
|
|
5712
|
+
# Drain the app's deferred external-script (chunk) boot chain to quiescence.
|
|
5713
|
+
# A dynamically-inserted external <script> runs async (setTimeout 0; HTML
|
|
5714
|
+
# "prepare the script" force-async), so a module/chunk loader's scripts
|
|
5715
|
+
# haven't executed when boot returns — leaving the page half-booted, where a
|
|
5716
|
+
# negative assertion (have_no_*) passes early or a reactive control (a toggle
|
|
5717
|
+
# whose handler a chunk wires up) does nothing. `run_loop_step(0)` fires only
|
|
5718
|
+
# ALREADY-due timers (the setTimeout(0) chunks + their .then chains, which
|
|
5719
|
+
# may insert further due-now chunks), NOT delayed app timers — so the lazy
|
|
5720
|
+
# wall-sync clock is preserved (smoke_spec "queries DOM before advancing
|
|
5721
|
+
# pending timers"). Bounded by the finite pending-script count.
|
|
5722
|
+
if @runtime.respond_to?(:run_loop_step)
|
|
5723
|
+
BOOT_SCRIPT_DRAIN_MAX_ITER.times do
|
|
5724
|
+
break if @runtime.call('__csimPendingExternalScriptCount').to_i.zero?
|
|
5725
|
+
@runtime.run_loop_step(0, SETTLE_MAX_ITER_TASKS, yield_on_gen: false)
|
|
5726
|
+
end
|
|
5727
|
+
end
|
|
4521
5728
|
@polling_grace = POST_NAV_POLL_GRACE_POLLS
|
|
4522
5729
|
end
|
|
4523
5730
|
|
|
@@ -4638,10 +5845,20 @@ module Capybara
|
|
|
4638
5845
|
net_http_fetch(url, env, method: method, body: body) || @app.call(env)
|
|
4639
5846
|
end
|
|
4640
5847
|
|
|
5848
|
+
# Whether this is a universal-server context (the WPT runner's wptserve shim
|
|
5849
|
+
# serves every host in-process). Gates cross-origin eager frame building: in
|
|
5850
|
+
# such a context a cross-origin iframe's content IS served locally, so it
|
|
5851
|
+
# eager-builds; an ordinary app leaves cross-origin frames lazy (= baseline),
|
|
5852
|
+
# so an external embed isn't eager-fetched. A simple flag, NOT per-URL —
|
|
5853
|
+
# url_is_local? compares only host:port (ignoring scheme) and treats a missing
|
|
5854
|
+
# ref origin as local, which would eager-build frames the baseline left lazy.
|
|
5855
|
+
def all_hosts_local? = @all_hosts_local
|
|
5856
|
+
|
|
4641
5857
|
# Path-only or fragment-only URLs are always against the current
|
|
4642
5858
|
# origin. For absolute URLs, compare host:port to the cached
|
|
4643
5859
|
# parsed @current_url (or default_host on first navigate).
|
|
4644
5860
|
def url_is_local?(url)
|
|
5861
|
+
return true if @all_hosts_local
|
|
4645
5862
|
s = url.to_s
|
|
4646
5863
|
return true if s.empty? || s.start_with?('/', '#', '?')
|
|
4647
5864
|
uri = safe_uri(s)
|
|
@@ -4737,18 +5954,40 @@ module Capybara
|
|
|
4737
5954
|
end
|
|
4738
5955
|
|
|
4739
5956
|
# Header names/values are TEXT (RFC 9110: field values are ASCII); Rack
|
|
4740
|
-
# hands them over BINARY-tagged (see `RuntimeShared.utf8_text`).
|
|
5957
|
+
# hands them over BINARY-tagged (see `RuntimeShared.utf8_text`). Per-value HTTP
|
|
5958
|
+
# -whitespace normalization happens upstream, BEFORE duplicate values are
|
|
5959
|
+
# combined (WptRunner.combine_headers) — not here, where a combined value like
|
|
5960
|
+
# `", "` (two empty fields) would wrongly lose its trailing space. An
|
|
5961
|
+
# Array-valued header (a Rack app emitting a repeated field) is combined with
|
|
5962
|
+
# `, ` — the WHATWG "combine" separator getAllResponseHeaders exposes, matching
|
|
5963
|
+
# both real browsers and the harness's combine_headers.
|
|
4741
5964
|
def stringify(headers)
|
|
4742
5965
|
out = {}
|
|
4743
5966
|
headers.each do |k, v|
|
|
4744
|
-
out[k.to_s] = RuntimeShared.utf8_text(v.is_a?(Array) ? v.join(',') : v.to_s)
|
|
5967
|
+
out[k.to_s] = RuntimeShared.utf8_text(v.is_a?(Array) ? v.join(', ') : v.to_s)
|
|
4745
5968
|
end
|
|
4746
5969
|
out
|
|
4747
5970
|
end
|
|
4748
5971
|
|
|
5972
|
+
# The Fetch "redirect status" set — ONLY these are followed. 300 (multiple
|
|
5973
|
+
# choice), 304 (not modified), 305/306 (deprecated) are NOT redirects: the 3xx
|
|
5974
|
+
# response is returned to the caller as-is (xhr send-redirect basics).
|
|
5975
|
+
REDIRECT_STATUSES = [301, 302, 303, 307, 308].freeze
|
|
5976
|
+
# Statuses whose response has no body (Fetch "null body status") — the body is dropped
|
|
5977
|
+
# and response.body is null (response-null-body). (101 is unreachable here.)
|
|
5978
|
+
NULL_BODY_STATUSES = [204, 205, 304].freeze
|
|
5979
|
+
# Request-body headers removed when a redirect nulls the body (method → GET).
|
|
5980
|
+
REDIRECT_DROPPED_HEADERS = %w[content-encoding content-language content-location content-type content-length].freeze
|
|
4749
5981
|
def redirect_location(status, headers)
|
|
4750
|
-
return nil unless
|
|
4751
|
-
headers['location'] || headers['Location']
|
|
5982
|
+
return nil unless REDIRECT_STATUSES.include?(status.to_i)
|
|
5983
|
+
loc = headers['location'] || headers['Location']
|
|
5984
|
+
loc = loc.first if loc.is_a?(Array) # Rack 3 permits array-valued header fields
|
|
5985
|
+
# A blank (or absent) Location has no FOLLOWABLE target: an empty value parses back
|
|
5986
|
+
# to the current URL, so following it would just self-redirect. Return nil so a
|
|
5987
|
+
# caller renders the 3xx as-is rather than looping — the several navigation handlers
|
|
5988
|
+
# rely on this. (The fetch redirect loop recognizes a present-but-empty Location
|
|
5989
|
+
# separately and turns it into a network error per Fetch — see rack_fetch.)
|
|
5990
|
+
loc unless loc.to_s.empty?
|
|
4752
5991
|
end
|
|
4753
5992
|
|
|
4754
5993
|
def resolve_against_current(url, use_base: false)
|
|
@@ -4778,6 +6017,218 @@ module Capybara
|
|
|
4778
6017
|
dom_call('__csimBaseHref').to_s
|
|
4779
6018
|
end
|
|
4780
6019
|
|
|
6020
|
+
# Fetch "CORS-safelisted method" / "…request-header" / "…Content-Type". A request
|
|
6021
|
+
# is "simple" (no preflight) iff its method is safelisted AND every author header is
|
|
6022
|
+
# safelisted (Content-Type only for a urlencoded / multipart / text/plain value).
|
|
6023
|
+
CORS_SAFELISTED_METHODS = %w[GET HEAD POST].freeze
|
|
6024
|
+
CORS_SAFELISTED_HEADERS = %w[accept accept-language content-language content-type].freeze
|
|
6025
|
+
# RFC 7230 `token` (tchar+) — a valid HTTP method / field-name. Used to reject a
|
|
6026
|
+
# preflight whose Access-Control-Allow-Methods / -Headers carries a malformed value.
|
|
6027
|
+
HTTP_TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/.freeze
|
|
6028
|
+
CORS_SAFELISTED_CTYPES = %w[application/x-www-form-urlencoded multipart/form-data text/plain].freeze
|
|
6029
|
+
|
|
6030
|
+
# The sorted, lowercased author header names that are NOT CORS-safelisted (a
|
|
6031
|
+
# non-safe Content-Type counts). These are echoed in Access-Control-Request-Headers
|
|
6032
|
+
# for the preflight and must be covered by Access-Control-Allow-Headers.
|
|
6033
|
+
def cors_unsafe_headers(headers)
|
|
6034
|
+
(headers || {}).filter_map {|k, v|
|
|
6035
|
+
name = k.to_s.downcase
|
|
6036
|
+
next if name.start_with?('x-csim') || name == 'content-length'
|
|
6037
|
+
if name == 'content-type'
|
|
6038
|
+
essence = v.to_s.split(';', 2).first.to_s.strip.downcase
|
|
6039
|
+
CORS_SAFELISTED_CTYPES.include?(essence) ? nil : name
|
|
6040
|
+
else
|
|
6041
|
+
CORS_SAFELISTED_HEADERS.include?(name) ? nil : name
|
|
6042
|
+
end
|
|
6043
|
+
}.uniq.sort
|
|
6044
|
+
end
|
|
6045
|
+
|
|
6046
|
+
def cors_unsafe_request?(method, headers)
|
|
6047
|
+
!CORS_SAFELISTED_METHODS.include?(method.to_s.upcase) || !cors_unsafe_headers(headers).empty?
|
|
6048
|
+
end
|
|
6049
|
+
|
|
6050
|
+
# Fetch "CORS check" on a cross-origin response: it must allow the request's
|
|
6051
|
+
# (effective) origin via Access-Control-Allow-Origin. A NON-credentialed request
|
|
6052
|
+
# accepts `*` or the exact origin; a CREDENTIALED one (withCredentials) forbids
|
|
6053
|
+
# `*` — the ACAO must be the exact origin AND Access-Control-Allow-Credentials
|
|
6054
|
+
# must be `true` (access-control-and-redirects-async-same-origin credentials cases).
|
|
6055
|
+
def cors_response_ok?(resp_headers, origin, credentialed)
|
|
6056
|
+
acao = cors_header(resp_headers, 'access-control-allow-origin')
|
|
6057
|
+
return false if acao.nil?
|
|
6058
|
+
if credentialed
|
|
6059
|
+
return false unless acao == origin
|
|
6060
|
+
cors_header(resp_headers, 'access-control-allow-credentials').to_s.downcase == 'true'
|
|
6061
|
+
else
|
|
6062
|
+
acao == '*' || acao == origin
|
|
6063
|
+
end
|
|
6064
|
+
end
|
|
6065
|
+
|
|
6066
|
+
# Whether a URL carries userinfo (`user[:password]@`). A CORS request to such a
|
|
6067
|
+
# URL is a network error (access-control-and-redirects "user info" subtest).
|
|
6068
|
+
def url_has_userinfo?(url)
|
|
6069
|
+
u = URI.parse(url.to_s)
|
|
6070
|
+
!u.userinfo.to_s.empty?
|
|
6071
|
+
rescue URI::InvalidURIError
|
|
6072
|
+
false
|
|
6073
|
+
end
|
|
6074
|
+
|
|
6075
|
+
# An author-set conditional header means the CALLER is doing its own revalidation,
|
|
6076
|
+
# so the UA cache must step aside: the request reaches the origin and the server's
|
|
6077
|
+
# own 304/200 decision is returned (send-conditional), not a cached hit.
|
|
6078
|
+
CONDITIONAL_REQUEST_HEADERS = %w[if-none-match if-modified-since if-match if-unmodified-since if-range].freeze
|
|
6079
|
+
def request_has_conditional_headers?(headers)
|
|
6080
|
+
headers.is_a?(Hash) && headers.any? {|k, _| CONDITIONAL_REQUEST_HEADERS.include?(k.to_s.downcase) }
|
|
6081
|
+
end
|
|
6082
|
+
|
|
6083
|
+
# Run the CORS preflight unless a cached result already covers this request (Fetch
|
|
6084
|
+
# "CORS-preflight cache"): a prior preflight to the same (origin, url) within its
|
|
6085
|
+
# Access-Control-Max-Age that allows this method + headers lets the actual request
|
|
6086
|
+
# skip the OPTIONS (access-control-basic-allow-preflight-cache). Returns false (=
|
|
6087
|
+
# network error) only when a fresh preflight is needed AND fails.
|
|
6088
|
+
def cors_preflight_ok?(target, method, headers, req_origin, credentialed, referer)
|
|
6089
|
+
return true if cors_preflight_cached?(target, req_origin, method, headers, credentialed)
|
|
6090
|
+
result = cors_run_preflight(target, method, headers, req_origin, credentialed, referer)
|
|
6091
|
+
return false unless result
|
|
6092
|
+
# Cache the grant for Max-Age seconds so a covered follow-up skips the preflight.
|
|
6093
|
+
# The key is (origin, url, credentialed): a credentialed grant (ACAO echoing the
|
|
6094
|
+
# origin, no `*` matching) can't cover an uncredentialed follow-up or vice versa,
|
|
6095
|
+
# so the two are cached apart. Expiry uses the REAL monotonic clock (not the
|
|
6096
|
+
# virtual one), so a test that virtual-sleeps past Max-Age to force a re-preflight
|
|
6097
|
+
# isn't caught yet.
|
|
6098
|
+
@cors_preflight_cache[[req_origin, target, credentialed]] = result.merge(stored_at: Process.clock_gettime(Process::CLOCK_MONOTONIC)) if result[:max_age].positive?
|
|
6099
|
+
true
|
|
6100
|
+
end
|
|
6101
|
+
|
|
6102
|
+
# Whether a cached preflight grant covers this request (not expired + method/headers
|
|
6103
|
+
# allowed). A method/header the cache doesn't cover — or an expired entry — forces a
|
|
6104
|
+
# fresh preflight (cache-invalidation-by-method / -header / -timeout).
|
|
6105
|
+
def cors_preflight_cached?(target, req_origin, method, headers, credentialed)
|
|
6106
|
+
entry = @cors_preflight_cache[[req_origin, target, credentialed]]
|
|
6107
|
+
return false unless entry
|
|
6108
|
+
return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) - entry[:stored_at] >= entry[:max_age]
|
|
6109
|
+
cors_grant_allows?(entry[:methods], entry[:headers], method, cors_unsafe_headers(headers), credentialed)
|
|
6110
|
+
end
|
|
6111
|
+
|
|
6112
|
+
# `Authorization` is Fetch's sole "CORS non-wildcard request-header name": a preflight
|
|
6113
|
+
# `Access-Control-Allow-Headers: *` never covers it — it must be listed by name — even
|
|
6114
|
+
# for an uncredentialed request (cors-preflight "authorization not covered by wildcard").
|
|
6115
|
+
CORS_NON_WILDCARD_REQUEST_HEADERS = %w[authorization].freeze
|
|
6116
|
+
|
|
6117
|
+
# Does a preflight grant (its Access-Control-Allow-Methods / -Headers) cover this
|
|
6118
|
+
# request: the method is allowed / `*` / CORS-safelisted, and every unsafe header is
|
|
6119
|
+
# allowed / `*`. Shared by the fresh-preflight accept check and the cache-hit check.
|
|
6120
|
+
# For a CREDENTIALED request the wildcard loses its meaning — Fetch's "CORS-preflight
|
|
6121
|
+
# fetch" matches `*` against no method/header when credentials mode is include, so a
|
|
6122
|
+
# non-listed method or unsafe header is rejected (cors-preflight-star credentialed).
|
|
6123
|
+
def cors_grant_allows?(allow_methods, allow_headers, method, unsafe_headers, credentialed = false)
|
|
6124
|
+
# The method match is byte-CASE-SENSITIVE (Fetch normalizes the request method but
|
|
6125
|
+
# compares it verbatim against Access-Control-Allow-Methods): `delete` in the grant
|
|
6126
|
+
# does not cover a `DELETE` request. Safelisted GET/HEAD/POST pass regardless
|
|
6127
|
+
# (they're always normalized to upper-case) (cors-preflight-star method-case).
|
|
6128
|
+
m = method.to_s
|
|
6129
|
+
method_ok = allow_methods.include?(m) || CORS_SAFELISTED_METHODS.include?(m) || (!credentialed && allow_methods.include?('*'))
|
|
6130
|
+
return false unless method_ok
|
|
6131
|
+
wildcard_headers = !credentialed && allow_headers.include?('*')
|
|
6132
|
+
unsafe_headers.all? {|h|
|
|
6133
|
+
allow_headers.include?(h) || (wildcard_headers && !CORS_NON_WILDCARD_REQUEST_HEADERS.include?(h))
|
|
6134
|
+
}
|
|
6135
|
+
end
|
|
6136
|
+
|
|
6137
|
+
# Fetch "CORS-preflight fetch": send an OPTIONS with Access-Control-Request-Method
|
|
6138
|
+
# / -Headers + Origin; on success (ok-status, ACAO match, and the grant covers the
|
|
6139
|
+
# method + unsafe headers) return the grant {methods, headers, max_age} for the
|
|
6140
|
+
# cache, else nil. A credentialed preflight additionally requires the response to
|
|
6141
|
+
# allow credentials (ACAC:true) and forbids `*` in the origin/method/header grants.
|
|
6142
|
+
def cors_run_preflight(target, method, headers, req_origin, credentialed, referer)
|
|
6143
|
+
unsafe = cors_unsafe_headers(headers)
|
|
6144
|
+
env = Rack::MockRequest.env_for(target, method: 'OPTIONS')
|
|
6145
|
+
env['REQUEST_METHOD'] = 'OPTIONS'
|
|
6146
|
+
# The preflight's Referer is the request's referrer under its referrer policy —
|
|
6147
|
+
# the SAME value the actual request sends (computed by the caller), not the raw
|
|
6148
|
+
# document URL (cors-preflight-referrer).
|
|
6149
|
+
apply_default_request_env(env, referer: referer, force: false)
|
|
6150
|
+
# A CORS-preflight is a fetch, so it carries fetch's default `Accept: */*` (NOT
|
|
6151
|
+
# the navigation Accept apply_default_request_env sets) — some handlers reject a
|
|
6152
|
+
# preflight whose Accept isn't */* (preflight.py).
|
|
6153
|
+
env['HTTP_ACCEPT'] = '*/*'
|
|
6154
|
+
# A CORS-preflight is always uncredentialed — it carries no cookies, even when the
|
|
6155
|
+
# actual request that follows is credentialed.
|
|
6156
|
+
env.delete('HTTP_COOKIE')
|
|
6157
|
+
env['HTTP_ORIGIN'] = req_origin
|
|
6158
|
+
# Access-Control-Request-Method carries the request's (already-normalized) method
|
|
6159
|
+
# VERBATIM — `patch` stays `patch`, matching the byte-case-sensitive grant check.
|
|
6160
|
+
env['HTTP_ACCESS_CONTROL_REQUEST_METHOD'] = method.to_s
|
|
6161
|
+
env['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'] = unsafe.join(',') unless unsafe.empty?
|
|
6162
|
+
status, ph, pbody = dispatch_rack_or_http(target, env, method: 'OPTIONS', body: nil)
|
|
6163
|
+
pbody.close if pbody.respond_to?(:close)
|
|
6164
|
+
return nil unless (200..299).include?(status.to_i)
|
|
6165
|
+
acao = cors_header(ph, 'access-control-allow-origin')
|
|
6166
|
+
# A credentialed preflight can't be allowed by the wildcard origin and must carry
|
|
6167
|
+
# Access-Control-Allow-Credentials: true (cors-preflight-star credentialed).
|
|
6168
|
+
return nil unless credentialed ? acao == req_origin : (acao == '*' || acao == req_origin)
|
|
6169
|
+
return nil if credentialed && cors_header(ph, 'access-control-allow-credentials') != 'true'
|
|
6170
|
+
allow_methods = cors_list(cors_header(ph, 'access-control-allow-methods'))
|
|
6171
|
+
allow_headers = cors_list(cors_header(ph, 'access-control-allow-headers')).map(&:downcase)
|
|
6172
|
+
# Fetch "extract header list values" fails when a grant contains a malformed token
|
|
6173
|
+
# (`Access-Control-Allow-Methods: Bad value` — a space isn't a tchar), and a failed
|
|
6174
|
+
# extraction is a network error (cors-preflight-response-validation). Methods and
|
|
6175
|
+
# header names are both HTTP tokens; `*` is a valid tchar so the wildcard passes.
|
|
6176
|
+
return nil unless (allow_methods + allow_headers).all? {|t| t.match?(HTTP_TOKEN) }
|
|
6177
|
+
return nil unless cors_grant_allows?(allow_methods, allow_headers, method, unsafe, credentialed)
|
|
6178
|
+
{methods: allow_methods, headers: allow_headers, max_age: cors_header(ph, 'access-control-max-age').to_i}
|
|
6179
|
+
end
|
|
6180
|
+
|
|
6181
|
+
# Fetch "CORS-safelisted response-header name" — always exposed to script for a
|
|
6182
|
+
# cross-origin response, without being listed in Access-Control-Expose-Headers.
|
|
6183
|
+
CORS_SAFELISTED_RESPONSE_HEADERS = %w[
|
|
6184
|
+
cache-control content-language content-length content-type expires last-modified pragma
|
|
6185
|
+
].freeze
|
|
6186
|
+
|
|
6187
|
+
# The response headers a cross-origin "cors" response exposes to getResponseHeader /
|
|
6188
|
+
# getAllResponseHeaders: the CORS-safelisted set plus any named in Access-Control
|
|
6189
|
+
# -Expose-Headers (`*` exposes all — only valid without credentials, which these
|
|
6190
|
+
# cases don't use).
|
|
6191
|
+
def cors_exposed_headers(headers)
|
|
6192
|
+
# set-cookie / set-cookie2 are forbidden response-header names — NEVER exposed to
|
|
6193
|
+
# script, even under `Access-Control-Expose-Headers: *`. x-csim-status-text is our
|
|
6194
|
+
# internal reason-phrase sentinel (response_hash lifts it into statusText, which
|
|
6195
|
+
# IS exposed cross-origin, then strips it from the script-visible map), so it must
|
|
6196
|
+
# survive the filter.
|
|
6197
|
+
forbidden = %w[set-cookie set-cookie2]
|
|
6198
|
+
expose = cors_list(cors_header(headers, 'access-control-expose-headers')).map(&:downcase)
|
|
6199
|
+
return headers.reject {|k, _| forbidden.include?(k.to_s.downcase) } if expose.include?('*')
|
|
6200
|
+
allowed = CORS_SAFELISTED_RESPONSE_HEADERS + expose + ['x-csim-status-text']
|
|
6201
|
+
headers.select {|k, _| allowed.include?(k.to_s.downcase) }
|
|
6202
|
+
end
|
|
6203
|
+
|
|
6204
|
+
# Case-insensitive response-header lookup + comma-list split for the CORS checks.
|
|
6205
|
+
def cors_header(headers, name)
|
|
6206
|
+
pair = headers.find {|k, _| k.to_s.downcase == name }
|
|
6207
|
+
pair&.last.to_s
|
|
6208
|
+
end
|
|
6209
|
+
|
|
6210
|
+
def cors_list(value)
|
|
6211
|
+
value.to_s.split(',').map(&:strip).reject(&:empty?)
|
|
6212
|
+
end
|
|
6213
|
+
|
|
6214
|
+
# The origin of a URL — `scheme://host[:port]` with the default port (80/443)
|
|
6215
|
+
# elided — for the CORS same/cross-origin comparison. nil for a non-http(s) or
|
|
6216
|
+
# unparseable URL (about:blank / data: / a relative current_url) so CORS never
|
|
6217
|
+
# treats those as a comparable origin.
|
|
6218
|
+
def url_origin(url)
|
|
6219
|
+
u = URI.parse(url.to_s)
|
|
6220
|
+
return nil unless u.scheme && u.host && u.scheme.match?(/\Ahttps?\z/i)
|
|
6221
|
+
# An origin is (scheme, host, port) compared case-insensitively on scheme+host —
|
|
6222
|
+
# so canonicalize both to lowercase, else http://Example.com vs http://example.com
|
|
6223
|
+
# would mis-classify a same-origin request as cross-origin.
|
|
6224
|
+
scheme = u.scheme.downcase
|
|
6225
|
+
default = scheme == 'https' ? 443 : 80
|
|
6226
|
+
port = u.port && u.port != default ? ":#{u.port}" : ''
|
|
6227
|
+
"#{scheme}://#{u.host.downcase}#{port}"
|
|
6228
|
+
rescue URI::InvalidURIError
|
|
6229
|
+
nil
|
|
6230
|
+
end
|
|
6231
|
+
|
|
4781
6232
|
def carry_fragment(from_url, to_url)
|
|
4782
6233
|
from = URI.parse(from_url.to_s)
|
|
4783
6234
|
to = URI.parse(to_url.to_s)
|
|
@@ -4794,7 +6245,7 @@ module Capybara
|
|
|
4794
6245
|
# within the `record_action` block, which handles begin/finish
|
|
4795
6246
|
# step bookkeeping + on-failure DOM snapshot.
|
|
4796
6247
|
module RecordedActions
|
|
4797
|
-
def visit(url)
|
|
6248
|
+
def visit(url, referer: nil)
|
|
4798
6249
|
record_action(:visit, "visit #{url}") { super }
|
|
4799
6250
|
end
|
|
4800
6251
|
def refresh
|