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.
@@ -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: nil)
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). No
919
- # `opener_handle` is passed: modern browsers default `target=_blank`
920
- # to `noopener` (so `window.opener` is null), unlike JS `window.open`
921
- # which keeps the opener see `open_window_from_js`.
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 `build_multipart_body` during form submission.
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
- @driver.open_aux_window(resolve_against_current(url, use_base: true), source: self, blob_snapshot: pending['blob'])
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`) no live JS call to interrupt,
1631
- # safe to rebuild the Context synchronously.
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
- r = @runtime.run_loop_step(effective_step)
1997
- # `dirtied` (settleGen changed) catches a render-phase rAF / microtask-
1998
- # delivered MutationObserver that mutated the DOM without firing a timer
1999
- # (fired == 0) a fired-count-only test would leave a stale find cache.
2000
- @find_cache_dirty = true if r['dirtied'] || r['fired'].to_i > 0
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
- @find_cache_dirty = true if deliver_worker_messages > 0
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
- def submit_form_handle(form_handle, submitter_handle)
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
- fields = (spec['fields'] || []).map {|pair| [pair[0].to_s, pair[1].to_s] }
2121
- file_inputs = spec['fileInputs'] || []
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
- uri.query = body unless body.empty?
2145
- frame_entry ? navigate_frame(uri.to_s, entry: frame_entry) : navigate(uri.to_s)
2146
- elsif frame_entry
2147
- navigate_frame_post(action_url, body, content_type || enctype, entry: frame_entry)
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
- navigate_post(action_url, body, content_type || enctype)
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
- def build_multipart_body(fields, file_inputs)
2154
- boundary = "csim-#{SecureRandom.hex(8)}"
2155
- body = String.new.force_encoding(Encoding::ASCII_8BIT)
2156
- fields.each do |name, value|
2157
- append_multipart_part(body, boundary, name, value.to_s)
2158
- end
2159
- file_inputs.each do |fi|
2160
- picks = file_pick_paths(fi)
2161
- if picks.empty?
2162
- append_multipart_part(body, boundary, fi['name'].to_s, '', filename: '')
2163
- else
2164
- picks.each do |path|
2165
- append_multipart_part(body, boundary, fi['name'].to_s, File.binread(path),
2166
- filename: File.basename(path),
2167
- content_type: Rack::Mime.mime_type(File.extname(path)))
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
- # The on-disk paths backing a file input's current selection. Each
2176
- # selected File reports its host-backed source (`handle`/`index` → the
2177
- # `@file_picks` slot recorded at `attach_file` time); this resolves bytes
2178
- # even when JS moved a File onto a different input (`input.files =
2179
- # dataTransfer.files`), whose own handle was never attached to. Falls back
2180
- # to the input's own handle for older serializer payloads.
2181
- #
2182
- # Only host-backed Files (from `attach_file`) resolve here; a purely
2183
- # in-memory `new File(['bytes'], …)` assigned via JS has no `@file_picks`
2184
- # slot, so a CLASSIC (non-Turbo) submit drops its bytes the fetch/XHR
2185
- # path serializes those in JS (`serializeMultipart` → `blobBytes`) and is
2186
- # unaffected. This matches the pre-existing behaviour and covers every
2187
- # realistic upload (host-backed file submitted through Turbo or a plain
2188
- # form).
2189
- def file_pick_paths(fi)
2190
- refs = fi['files']
2191
- if refs.is_a?(Array) && !refs.empty?
2192
- refs.filter_map {|ref|
2193
- handle = ref['handle']
2194
- next if handle.nil?
2195
- picks = @file_picks && @file_picks[handle.to_i]
2196
- picks && picks[ref['index'].to_i]
2197
- }
2198
- else
2199
- (@file_picks && @file_picks[fi['handle'].to_i]) || []
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
- record_history({method: :post, url: url, body: body, content_type: content_type}) unless from_history || depth > 0
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: @current_url)
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
- key = resolve_against_current(url.to_s)
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 from another window, queued for delivery to
3181
- # this window's channels with the same name.
3182
- def enqueue_broadcast(name, data) = (@broadcast_inbox << {'name' => name.to_s, 'data' => data})
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
- @runtime.call('__csim_deliverBroadcasts', events)
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
- # window's matching channels (same-window delivery happens in-VM).
3202
- def broadcast_to_windows(name, data)
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
- URI::IDNA.whatwg_to_ascii(domain.to_s, be_strict: false)
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 codepoint) — let
3301
- # whatwg-url report "domain to ASCII failed". Non-IDNA errors propagate.
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
- @blob_registry_lock.synchronize { @blob_registry.delete(url.to_s); @blob_owners.delete(url.to_s) }
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.eval('!!globalThis.__csimWorkerClosed')
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
- rt.call('__csim_workerOnMessage', msg)
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 if rt.has_ready_timer?
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
- method = (method || 'GET').to_s.upcase
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
- MAX_FETCH_REDIRECTS.times do
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
- # GET-only cache shortcut (RFC 9111). Fresh hit skip @app.call
3670
- # entirely; stale-but-revalidatable fall through with conditional
3671
- # headers added so the server can return 304.
3672
- cache_entry = method == 'GET' ? @@asset_cache.lookup(target) : nil
3673
- if cache_entry&.fresh?
3674
- # Cached static asset log headers/type/size but skip the (boring) body.
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
- apply_default_request_env(env, referer: @current_url, force: false)
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
- if redirect_mode != 'manual' && (loc = redirect_location(status, resp_headers))
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
- preserve = [307, 308].include?(status)
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
- method = 'GET' unless preserve
3701
- body = nil unless preserve
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
- @@asset_cache.store(target, status, resp_headers, body_str) if method == 'GET'
3708
- return response_hash(status, resp_headers, body_str, target, redirected)
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.strip if ct.is_a?(String)
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({}) {|(k, v), out| out[k.to_s] = v.is_a?(Array) ? v.join(', ') : v.to_s }
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' => 'basic'
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
- out['body_b64'] = Base64.strict_encode64(raw) unless is_text
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
- fields = (spec['fields'] || []).map {|pair| [pair[0].to_s, pair[1].to_s] }
4055
- # Non-multipart file inputs contribute the filename only (mirror submit_form_handle's GET path).
4056
- (spec['fileInputs'] || []).each do |fi|
4057
- picks = @file_picks && @file_picks[fi['handle'].to_i] || []
4058
- fields << [fi['name'].to_s, picks.first ? File.basename(picks.first) : '']
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
- navigate_realm_self(realm_id, get_url, action, method, body, spec['enctype'].to_s)
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
- # Navigate the initiating frame realm itself (a self-targeted form submit).
4090
- def navigate_realm_self(realm_id, get_url, action, method, body, enctype)
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 method == 'GET'
4093
- if entry
4094
- navigate_frame(resolve_against_current(get_url), entry: entry)
4095
- else
4096
- # A frame reached via contentWindow (not on the entered stack): its
4097
- # owning iframe lives in the parent document re-navigate by realm id
4098
- # (relative get_url resolves against the frame's base on rebuild).
4099
- @runtime.call('__csimNavigateFrameByRealm', realm_id, get_url)
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
- elsif entry
4102
- navigate_frame_post(resolve_against_current(action), body, enctype, entry: entry)
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
- log_console('warn', "nested-context self-form POST (realm #{realm_id}) is not modeled")
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
- record_history({method: :get, url: url}) unless from_history || depth > 0
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 (300..399).include?(status.to_i)
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