capybara-simulated 0.4.0 → 0.6.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.
@@ -13,6 +13,7 @@ require 'socket'
13
13
  require 'thread'
14
14
  require 'time'
15
15
  require 'uri'
16
+ require 'uri/idna' # WHATWG/UTS46 domain-to-ASCII/Unicode (uri-idna gem)
16
17
  require_relative 'asset_cache'
17
18
  require_relative 'errors'
18
19
  require_relative 'stack_resolver'
@@ -96,6 +97,16 @@ module Capybara
96
97
  # green at 100 across gem 1579, WPT 660, Forem, Avo, :464 passing). Clamped
97
98
  # >=1 so a `CSIM_POLL_TICK_STEP_MS=0` misconfig can't freeze the fixed-step path.
98
99
  POLL_TICK_STEP_MS = [(ENV['CSIM_POLL_TICK_STEP_MS'] || '100').to_i, 1].max
100
+ # One animation frame (~60 Hz, whole ms — `run_loop_step` truncates). When a
101
+ # poll advances the clock while the page has work runnable NOW (a rAF chain or
102
+ # a timer burst), `tick_real_time` runs the advance in chunks this size so the
103
+ # page's rendering runs at real-browser cadence (one render phase per frame),
104
+ # not one `POLL_TICK_STEP_MS` super-frame — the same model the WPT drain uses.
105
+ FRAME_STEP_MS = 16
106
+ # Per-poll task-iteration cap, mirroring `RuntimeShared#run_loop_step`'s own
107
+ # default — shared across the frame chunks of one poll so sub-stepping keeps
108
+ # the same per-poll ceiling the single-step path had.
109
+ RUN_LOOP_MAX_ITER = 10_000
99
110
  # Horizon-gated fast-forward: when the page is observably idle (no timer due
100
111
  # now, no background IO) but a timer is parked within this horizon, jump the
101
112
  # virtual clock straight to it instead of waiting ~delay/step polls. A timer
@@ -115,6 +126,11 @@ module Capybara
115
126
  # timer/microtask storm so one settle iter returns to Ruby; large enough
116
127
  # for the heaviest legit chain (Mastodon hydrate, Turbo stream batch).
117
128
  SETTLE_MAX_ITER_TASKS = 256
129
+ # Backstop for the post-boot deferred-external-script drain: a dynamically
130
+ # inserted external <script> runs async (setTimeout 0), so an app's chunk
131
+ # loader chains many of them at boot. We pump only ALREADY-due tasks until the
132
+ # pending count hits 0; this caps a pathological self-injecting loader.
133
+ BOOT_SCRIPT_DRAIN_MAX_ITER = 300
118
134
  # Post-user-action virtual-clock advance. Default 0 — the
119
135
  # wall-sync model (each tick_real_time advances by the wall
120
136
  # ms elapsed since the last tick) lets Capybara's outer poll
@@ -166,9 +182,10 @@ module Capybara
166
182
  Rack::Mime.mime_type(File.extname(path.to_s), '')
167
183
  end
168
184
 
169
- def initialize(app, driver: nil, js_engine: nil, cookies: nil, local_storage: nil)
185
+ def initialize(app, driver: nil, js_engine: nil, cookies: nil, local_storage: nil, all_hosts_local: nil)
170
186
  @app = app
171
187
  @driver = driver
188
+ @all_hosts_local_override = all_hosts_local
172
189
  @runtime = build_runtime(js_engine)
173
190
  # Per-poll clock decisions cached at construction (CLAUDE.md rule 3 — the
174
191
  # runtime type + env are fixed for the session): the wall-sync escape
@@ -217,6 +234,18 @@ module Capybara
217
234
  # `Capybara.app_host` / `server_host` / `server_port` on every
218
235
  # rack call (CLAUDE.md: cache env decisions at construction).
219
236
  @default_host = self.class.default_host
237
+ # The WPT runner serves EVERY host through one in-process Rack app (the
238
+ # wptserve catch-all), so cross-origin test fixtures are genuinely local
239
+ # there. Setting this makes `url_is_local?` true for all hosts, so a
240
+ # cross-origin iframe eager-builds + is served by @app.call directly
241
+ # (no failing net_http_fetch). Apps leave it off: an external embed stays
242
+ # non-local → lazy → no @app.call side effect (extra visit / log row).
243
+ # Universal-server context (every host served in-process → cross-origin frames
244
+ # eager-build). The Driver captures this at session-construction time (when the
245
+ # WPT runner has the env set) and passes it to EVERY window it builds, so an aux
246
+ # window opened LATER — after the runner restored the env — still inherits it.
247
+ # nil override (a stand-alone Browser) falls back to the live env check.
248
+ @all_hosts_local = @all_hosts_local_override.nil? ? (ENV['CSIM_LOCAL_ALL_HOSTS'] == '1') : @all_hosts_local_override
220
249
  # Handle IDs are per-Context integer sequences: a handle from
221
250
  # a pre-rebuild context could collide with a fresh node's id
222
251
  # in the new context. Node captures this on construction;
@@ -315,11 +344,23 @@ module Capybara
315
344
  # so long-running compute (e.g. mozjpeg over an 8900×8900 frame)
316
345
  # isn't starved by the settle_gen idle gate.
317
346
  @worker_in_flight = 0
347
+ # Workers whose initial script hasn't finished running yet. A worker that
348
+ # posts immediately on spawn (no main->worker message first) would leave
349
+ # `@worker_in_flight` at 0, so `worker_pending?` would be false in the gap
350
+ # between spawn and that first post — and settle / tick_real_time would
351
+ # stop waiting before the message lands. Count spawned-but-not-initialised
352
+ # workers so the async drain holds until the initial script has run.
353
+ @worker_initializing = 0
354
+ @worker_init_lock = Mutex.new
318
355
  # Cross-isolate `blob:` store. Worker isolates can't see the
319
356
  # main scope's `__csimBlobs` Map, so we mirror bytes here and
320
357
  # workers resolve them through a host fn.
321
358
  @blob_registry = {}
322
359
  @blob_registry_lock = Mutex.new
360
+ # url => owning worker handle, for blob URLs created INSIDE a worker. A
361
+ # worker's blob URL store dies with it, so terminating the worker revokes
362
+ # them (url-lifetime "Terminating worker revokes its URLs").
363
+ @blob_owners = {}
323
364
  # Postmessage transferable-buffer store. Large Uint8Array /
324
365
  # ArrayBuffer payloads cross isolates as a Ruby-side byte ID
325
366
  # rather than a JSON base64 string, so peak JS heap stays flat.
@@ -340,6 +381,9 @@ module Capybara
340
381
  # `message` event the next time it's active and settles/ticks. Plain
341
382
  # array (same thread — windows aren't background-threaded like workers).
342
383
  @window_inbox = []
384
+ # Cross-window BroadcastChannel messages from OTHER windows, delivered to
385
+ # this window's matching channels on settle. [{name, data}] (same thread).
386
+ @broadcast_inbox = []
343
387
  end
344
388
 
345
389
  # Worker thread polling and termination intervals — split so a
@@ -387,8 +431,8 @@ module Capybara
387
431
 
388
432
  # Address-bar navigation: no Referer, and relative paths resolve
389
433
  # against the host root (not the current page's directory).
390
- def visit(url)
391
- navigate(resolve_visit_url(url), referer: nil)
434
+ def visit(url, referer: nil)
435
+ navigate(resolve_visit_url(url), referer: referer)
392
436
  end
393
437
 
394
438
  URL_UNSAFE_CHARS = %r{[^!*'();:@&=+$,/?#\[\]A-Za-z0-9\-._~%]}n.freeze
@@ -881,6 +925,7 @@ module Capybara
881
925
  # downloads from `click_link` complete inside the click
882
926
  # action.
883
927
  consume_pending_location
928
+ consume_pending_frame_nav
884
929
  return
885
930
  end
886
931
  case action['kind']
@@ -898,12 +943,13 @@ module Capybara
898
943
  end
899
944
  # `target="_blank"` (or any non-_self/_top/_parent name) opens
900
945
  # in a new browsing context (its own Browser/VM); the primary
901
- # stays put (per HTML spec — original window is unaffected). No
902
- # `opener_handle` is passed: modern browsers default `target=_blank`
903
- # to `noopener` (so `window.opener` is null), unlike JS `window.open`
904
- # which keeps the opener see `open_window_from_js`.
946
+ # stays put (per HTML spec — original window is unaffected). A
947
+ # `target=_blank` link defaults to `noopener` (window.opener null) unless
948
+ # `rel=opener` (carried as `action['opener']`); open_aux_window forces
949
+ # noopener anyway for a cross-partition blob. Matches the scripted
950
+ # click / dispatchEvent activation paths.
905
951
  elsif !target.empty? && !%w[_self _top _parent].include?(target.downcase) && @driver.respond_to?(:open_aux_window)
906
- @driver.open_aux_window(resolve_against_current(url, use_base: true))
952
+ @driver.open_aux_window(resolve_against_current(url, use_base: true), source: self, opener: !!action['opener'], blob_snapshot: action['blob'])
907
953
  # In-page anchor links (`#frag` / current-page + `#frag`) move
908
954
  # the hash but don't fetch a new document. Pure-fragment also
909
955
  # short-circuits the `<a>`s test fixtures use as click sinks.
@@ -943,12 +989,13 @@ module Capybara
943
989
  # pending; if `@current_url` already changed mid-drain (the
944
990
  # navigate landed during a timer fire), skip the form submit
945
991
  # entirely — its form handle is in a stale VM by now.
992
+ consume_pending_frame_nav
946
993
  if @pending_location
947
994
  consume_pending_location
948
995
  elsif @current_url != submit_baseline_url
949
996
  # Already navigated; nothing more to do.
950
997
  else
951
- submit_form_handle(action['formHandle'], action['submitter'])
998
+ submit_form_handle(action['formHandle'], action['submitter'], action['entryList'])
952
999
  end
953
1000
  when 'download'
954
1001
  download_link(resolve_against_current(action['url'].to_s), action['filename'].to_s)
@@ -1017,7 +1064,7 @@ module Capybara
1017
1064
  # `attach_file` hands us a Pathname (or Array of Pathnames);
1018
1065
  # the marshaller rejects non-primitive types. Coerce to a path-list
1019
1066
  # form V8 can hold — the actual multipart upload happens later
1020
- # in `build_multipart_body` during form submission.
1067
+ # in `encode_entry_list` during form submission.
1021
1068
  coerced = coerce_set_value(value)
1022
1069
  # For date/time-shaped inputs we need the type-specific
1023
1070
  # string. Probe the handle's `type` and re-format Date / Time
@@ -1357,7 +1404,7 @@ module Capybara
1357
1404
  def consume_pending_form_submit
1358
1405
  pending = @runtime.call('__csimTakePendingFormSubmit')
1359
1406
  return unless pending.is_a?(Hash) && pending['formHandle']
1360
- submit_form_handle(pending['formHandle'].to_i, pending['submitterHandle'])
1407
+ submit_form_handle(pending['formHandle'].to_i, pending['submitterHandle'], pending['entryList'])
1361
1408
  end
1362
1409
 
1363
1410
  # Pin the URL the page is at as a user action BEGINS — the FIRST line of
@@ -1457,7 +1504,9 @@ module Capybara
1457
1504
  url = pending['url'].to_s
1458
1505
  target = pending['target'].to_s
1459
1506
  if !target.empty? && !%w[_self _top _parent].include?(target.downcase) && @driver.respond_to?(:open_aux_window)
1460
- @driver.open_aux_window(resolve_against_current(url, use_base: true))
1507
+ # `opener` reflects rel=opener (a bare target=_blank link is noopener);
1508
+ # open_aux_window forces noopener anyway for a cross-partition blob.
1509
+ @driver.open_aux_window(resolve_against_current(url, use_base: true), source: self, opener: !!pending['opener'], blob_snapshot: pending['blob'])
1461
1510
  elsif pure_fragment_navigation?(url)
1462
1511
  update_current_hash(url)
1463
1512
  else
@@ -1589,11 +1638,34 @@ module Capybara
1589
1638
  def go_back ; history_go(-1, force: true) ; end
1590
1639
  def go_forward ; history_go(+1, force: true) ; end
1591
1640
 
1641
+ # Reset the session history to empty WITHOUT the full `reset!` (cookies /
1642
+ # storage / viewport / frame scope stay put). A single session that visits
1643
+ # many documents in sequence — the WPT conformance runner reuses one for
1644
+ # the whole 1645-file suite — otherwise accumulates every prior visit's
1645
+ # history entry, so a document that calls `history.back()` (e.g. a bfcache
1646
+ # round-trip test) traverses the SHARED history back into the PREVIOUS
1647
+ # document and re-runs it. Clearing history per visit confines each
1648
+ # document's back / forward to its own navigations, matching a real
1649
+ # browser's fresh-browsing-context isolation, so results don't depend on
1650
+ # visit order. Not wired into `visit` itself — a normal session keeps
1651
+ # cross-`visit` back-navigation (Selenium parity); only callers that want
1652
+ # per-document isolation invoke it.
1653
+ def reset_history!
1654
+ @history.clear
1655
+ @history_idx = -1
1656
+ @pending_history_traverse = nil
1657
+ end
1658
+
1592
1659
  # Move through the history stack by `delta`. Per HTML spec, a
1593
1660
  # same-document traversal (within a chain of pushState entries
1594
1661
  # rooted at a single navigation) updates `location` and fires
1595
1662
  # `popstate` with the entry's state — no full reload. A cross-
1596
1663
  # document traversal replays the entry (full navigate / re-POST).
1664
+ # Returns the traversal kind so a cross-window caller
1665
+ # (`window_history_go`) knows whether to fire the target window's
1666
+ # deferred `load`: `:same_document` (pushState traversal — popstate
1667
+ # already fired, no load), `:cross_document` (full document replay —
1668
+ # load follows), or `nil` (no-op: zero delta / out of range).
1597
1669
  def history_go(delta, force: false)
1598
1670
  delta = delta.to_i
1599
1671
  return if delta == 0
@@ -1608,10 +1680,13 @@ module Capybara
1608
1680
  @current_url = entry[:url]
1609
1681
  @runtime.call('__csimUpdateLocation', @current_url)
1610
1682
  @runtime.call('__csimDispatchPopState', entry[:state])
1683
+ :same_document
1611
1684
  elsif force
1612
- # Ruby-driven (`page.go_back`) no live JS call to interrupt,
1613
- # safe to rebuild the Context synchronously.
1685
+ # Ruby-driven (`page.go_back`), or a non-active window driven by its
1686
+ # opener (`w.history.back()`) no live JS call on THIS window's
1687
+ # isolate to interrupt, safe to rebuild the Context synchronously.
1614
1688
  perform_history_traverse(target)
1689
+ :cross_document
1615
1690
  else
1616
1691
  # JS-driven (`history.back()` from a page handler): replaying
1617
1692
  # the history entry synchronously would call `rebuild_ctx`
@@ -1621,6 +1696,7 @@ module Capybara
1621
1696
  # and drain after the call returns — mirrors
1622
1697
  # `location_assign` / `location_reload`.
1623
1698
  @pending_history_traverse = target
1699
+ :cross_document
1624
1700
  end
1625
1701
  end
1626
1702
 
@@ -1631,8 +1707,29 @@ module Capybara
1631
1707
  end
1632
1708
 
1633
1709
  private def perform_history_traverse(target)
1710
+ capture_outgoing_form_state
1634
1711
  @history_idx = target
1635
1712
  replay_history_entry(@history[target])
1713
+ restore_form_state(@history[target])
1714
+ end
1715
+
1716
+ # Snapshot the OUTGOING document's form-control state into the history entry
1717
+ # we are leaving, so a later back/forward traversal to it restores the
1718
+ # values the user/script had set (HTML "persisted user state"), not the
1719
+ # markup defaults. Captured while the outgoing VM is still live — before
1720
+ # record_history advances the index or boot rebuilds the context.
1721
+ def capture_outgoing_form_state
1722
+ return if @history_idx < 0 || (entry = @history[@history_idx]).nil?
1723
+ state = (@runtime.call('__csimCaptureFormState') rescue nil)
1724
+ entry[:form_state] = state if state
1725
+ end
1726
+
1727
+ # Re-apply a history entry's captured form state after its document has been
1728
+ # rebuilt. The JS setters set the dirty value flag WITHOUT firing
1729
+ # input/change, so a restored value doesn't look like a fresh user edit.
1730
+ def restore_form_state(entry)
1731
+ return unless entry && (state = entry[:form_state])
1732
+ @runtime.call('__csimRestoreFormState', state) rescue nil
1636
1733
  end
1637
1734
 
1638
1735
  # Same-document = every entry between `from` and `to` (inclusive)
@@ -1786,7 +1883,7 @@ module Capybara
1786
1883
  @stack_resolver ||= StackResolver.new(self)
1787
1884
  end
1788
1885
 
1789
- def log_network(method, url, status) = @trace&.log_network(method, url, status)
1886
+ def log_network(method, url, status, **extra) = @trace&.log_network(method, url, status, **extra)
1790
1887
 
1791
1888
  # `tag#id.class` short description of the handle, for trace
1792
1889
  # `description` fields. One V8 round-trip; only paid when a step
@@ -1975,22 +2072,54 @@ module Capybara
1975
2072
  @last_tick_ts = now
1976
2073
  effective_step = step_ms || horizon_fast_forward_step
1977
2074
  if @timers_active && effective_step > 0
1978
- r = @runtime.run_loop_step(effective_step)
1979
- # `dirtied` (settleGen changed) catches a render-phase rAF / microtask-
1980
- # delivered MutationObserver that mutated the DOM without firing a timer
1981
- # (fired == 0) a fired-count-only test would leave a stale find cache.
1982
- @find_cache_dirty = true if r['dirtied'] || r['fired'].to_i > 0
2075
+ # When the page has work runnable NOW (a rAF chain / timer burst — set
2076
+ # by `horizon_fast_forward_step`), run the poll's worth of virtual time
2077
+ # in frame-sized chunks so the page renders at real-browser cadence
2078
+ # rather than one `POLL_TICK_STEP_MS` super-frame. The `step_ms` path
2079
+ # (explicit `sleep` / `wait_for_timeout`) and idle/parked/fast-forward
2080
+ # polls keep the single step — nothing is rendering frame-by-frame, so
2081
+ # sub-stepping would only spin empty render phases. The tail-jump bails
2082
+ # the moment a frame goes quiet, so a chain that settles early (or the
2083
+ # rare quiet sub-step) doesn't pay for the unused remainder.
2084
+ if step_ms.nil? && @page_runnable_now && effective_step > FRAME_STEP_MS
2085
+ remaining = effective_step
2086
+ # Share ONE task-iteration budget across the chunks so the per-poll
2087
+ # cap matches the single-step path (each `run_loop_step` otherwise
2088
+ # gets a fresh `RUN_LOOP_MAX_ITER`, so an always-due `setInterval(0)`
2089
+ # busy loop could run N× the work). Exhausting it ends the poll —
2090
+ # the clock is stuck on that loop either way.
2091
+ iter_budget = RUN_LOOP_MAX_ITER
2092
+ while remaining > 0 && iter_budget > 0
2093
+ chunk = remaining < FRAME_STEP_MS ? remaining : FRAME_STEP_MS
2094
+ r = @runtime.run_loop_step(chunk, iter_budget)
2095
+ @find_cache_dirty = true if r['dirtied'] || r['fired'].to_i > 0
2096
+ remaining -= chunk
2097
+ iter_budget -= r['fired'].to_i
2098
+ # Idle frame → nothing left to render frame-by-frame this poll: jump
2099
+ # the remaining advance in one step (fires any timer parked within
2100
+ # it). A still-queued rAF (`r['raf']`) is NOT idle — a non-mutating
2101
+ # animation chain fires no timer and dirties nothing yet keeps
2102
+ # rendering, so keep sub-stepping it at frame cadence.
2103
+ if remaining > 0 && r['fired'].to_i.zero? && !r['dirtied'] && !r['raf']
2104
+ r = @runtime.run_loop_step(remaining, iter_budget)
2105
+ @find_cache_dirty = true if r['dirtied'] || r['fired'].to_i > 0
2106
+ break
2107
+ end
2108
+ end
2109
+ else
2110
+ r = @runtime.run_loop_step(effective_step)
2111
+ # `dirtied` (settleGen changed) catches a render-phase rAF / microtask-
2112
+ # delivered MutationObserver that mutated the DOM without firing a timer
2113
+ # (fired == 0) — a fired-count-only test would leave a stale find cache.
2114
+ @find_cache_dirty = true if r['dirtied'] || r['fired'].to_i > 0
2115
+ end
1983
2116
  end
1984
2117
  # Pull any pending Worker / EventSource messages into JS
1985
2118
  # state. Without this, `evaluate_script` after kicking off
1986
2119
  # a worker round-trip would see stale state — the inbox
1987
2120
  # outbox only drains during `settle`, which doesn't run
1988
2121
  # for direct `execute_script` / `evaluate_script` calls.
1989
- @find_cache_dirty = true if deliver_worker_messages > 0
1990
- @find_cache_dirty = true if deliver_event_source_events > 0
1991
- @find_cache_dirty = true if deliver_hijacked_fetches > 0
1992
- @find_cache_dirty = true if deliver_window_messages > 0
1993
- @find_cache_dirty = true if deliver_websocket_events > 0
2122
+ drain_async_channels
1994
2123
  ensure
1995
2124
  @ticking = false
1996
2125
  end
@@ -2014,6 +2143,142 @@ module Capybara
2014
2143
  consume_pending_download
2015
2144
  end
2016
2145
 
2146
+ # Backstop on the per-frame quiescence loop — caps the event-loop turns
2147
+ # processed at a single instant so a `setInterval(0)` (always-due) busy loop
2148
+ # advances a frame and continues, rather than spinning forever. Generous:
2149
+ # real frames here run hundreds of microtask/rAF turns (e.g. ~80 sequential
2150
+ # rAF promise_tests, each ~2 render turns).
2151
+ EVENT_LOOP_QUIESCENCE_CAP = 512
2152
+
2153
+ # Run ONE real-cadence event-loop frame and report the loop's observable
2154
+ # state. A general primitive — it models a browser animation frame and knows
2155
+ # nothing about any particular test harness; the WPT runner is its first
2156
+ # caller (driving a page to completion one frame at a time). It advances the
2157
+ # same virtual clock as the Capybara poll path: `tick_real_time` keeps its own
2158
+ # per-poll BUDGET (a ~100 ms `horizon_fast_forward` step, tuned for app
2159
+ # debounce observation) but, when the page has work runnable now, spends that
2160
+ # budget in `FRAME_STEP_MS` chunks — the same frame cadence this primitive
2161
+ # uses — so a page renders frame-by-frame regardless of which path drives it.
2162
+ #
2163
+ # A real browser processes EVERYTHING ready at the current instant within a
2164
+ # single animation frame — microtasks, timers due now (incl. newly scheduled
2165
+ # `setTimeout(0)`), the render-phase rAF callbacks, and the navigation /
2166
+ # form-submit / worker chains they trigger — and only THEN advances ~16.67 ms
2167
+ # to the next frame. Modelling that is essential: a multi-hop chain (a form →
2168
+ # iframe-rebuild → onload → next-submit sequence, or N sequential rAF
2169
+ # `promise_test`s) must complete inside a frame. Advancing the clock per host
2170
+ # round-trip instead (the old WPT runner did ~3 `evaluate_script`s/frame, each
2171
+ # a full ~100 ms `tick_real_time` poll tick) runs it ~20× real cadence, so a
2172
+ # page that needs many frames trips a wall-clock-budget harness timeout long
2173
+ # before its queue drains. Driving the loop here keeps real cadence (one frame
2174
+ # interval per frame) while still completing the chains.
2175
+ #
2176
+ # Phase 1 — quiescence at the CURRENT virtual time: repeatedly run the loop
2177
+ # with a ZERO advance (`run_loop_step(0)` fires only timers due now + the
2178
+ # microtask checkpoints + the render phase) and drain the Ruby-side async /
2179
+ # nav / form-submit / download chains they queue, until a turn makes no
2180
+ # observable progress (or the backstop caps a busy loop). Phase 2 — advance
2181
+ # exactly one frame so the next batch of timers comes due.
2182
+ #
2183
+ # Returns the loop state: `progressed` (this frame did real work — drives a
2184
+ # caller's idle detection), `raf` (an animation frame is queued), `async` (a
2185
+ # non-timer background channel — worker / SSE / hijacked fetch — is in
2186
+ # flight), and `next_timer` (ms to the nearest scheduled timer, -1 = none, so
2187
+ # a caller can tell a page PARKED on a near-future `setTimeout` from an idle
2188
+ # one). Whether the page reached some application-level "done" state is the
2189
+ # caller's concern — read it separately with `peek_script` (clock-free).
2190
+ def run_event_loop_frame(frame_ms)
2191
+ turns = 0
2192
+ loop do
2193
+ r = @runtime.run_loop_step(0) # run only what's due NOW + microtasks + render; no clock advance
2194
+ progressed = step_and_drain_progressed(r)
2195
+ turns += 1
2196
+ break unless progressed
2197
+ break if turns >= EVENT_LOOP_QUIESCENCE_CAP
2198
+ end
2199
+ # Phase 2 — advance one real frame so the next batch of timers becomes due.
2200
+ # Its work counts toward `progressed` too: a timer that first comes due in
2201
+ # this advance (e.g. a `setTimeout(…, 8)` firing mid-frame) and the nav hop
2202
+ # it queues are real progress, so a caller mustn't read the frame as idle.
2203
+ frame_progressed = step_and_drain_progressed(@runtime.run_loop_step(frame_ms))
2204
+ probe = dom_call('__csimEventLoopProbe')
2205
+ {
2206
+ 'raf' => !!probe['raf'],
2207
+ 'async' => !!probe['async'],
2208
+ # ms until the nearest scheduled timer (-1 = none). Lets a caller keep
2209
+ # advancing while a near-future `setTimeout` is parked (a `step_timeout`-
2210
+ # style wait) instead of declaring the page idle — see `__csimEventLoopProbe`.
2211
+ 'next_timer' => probe['nextTimer'].to_f,
2212
+ # `turns > 1` ⇒ phase 1's quiescence did work (the trailing no-progress
2213
+ # turn that ends the loop is the +1); OR phase 2's advance did.
2214
+ 'progressed' => turns > 1 || frame_progressed
2215
+ }
2216
+ end
2217
+
2218
+ # Drain the Ruby-side async / navigation / form-submit / download chains a
2219
+ # `run_loop_step` (passed as `r`) may have queued, and report whether this
2220
+ # step+drain made observable progress. Shared by both phases of
2221
+ # `run_event_loop_frame`.
2222
+ #
2223
+ # A pending Ruby-side navigation/submit/reload intent counts as progress:
2224
+ # draining it rebuilds a child frame realm and fires that iframe's `onload`
2225
+ # synchronously, whose handler can queue the NEXT hop (submit-entity-body's
2226
+ # `run_simple_test` chain: form.submit() → realm rebuild → onload → next
2227
+ # form.submit()). That work happens entirely in the CHILD realm, so it bumps
2228
+ # the child's `settleGen`, never the main realm's `settle_gen` we sample, and
2229
+ # fires no main-realm timer. Snapshot the intent BEFORE draining: this call
2230
+ # consumes one hop and the onload re-queues the next, which the following
2231
+ # call sees — so the quiescence loop self-terminates when the chain ends.
2232
+ private def step_and_drain_progressed(r)
2233
+ pulled = drain_async_channels
2234
+ invalidate_find_cache
2235
+ drained_nav = pending_nav_intent?
2236
+ drain_pending_navigation
2237
+ consume_pending_form_submit
2238
+ consume_pending_download
2239
+ # `r['dirtied']` already covers settleGen changes DURING the step; compare
2240
+ # the post-drain gen against the step's post-step gen (`r['gen']`, free —
2241
+ # no extra crossing) to also catch a main-realm change the drains caused.
2242
+ r['fired'].to_i.positive? || r['dirtied'] || pulled || drained_nav ||
2243
+ @runtime.settle_gen != r['gen'].to_i
2244
+ end
2245
+
2246
+ # Clock-FREE read of a JS expression in the active browsing context. Unlike
2247
+ # `evaluate_script` (which ticks `tick_real_time` first), this is a bare
2248
+ # `dom_call` and advances no virtual time — so a caller polling page state
2249
+ # once per `run_event_loop_frame` (e.g. the WPT runner checking its harness's
2250
+ # completion sentinel) doesn't perturb the frame cadence the loop maintains.
2251
+ def peek_script(expr)
2252
+ dom_call('__csimEvalScript', expr.to_s, marshal_args([]))
2253
+ end
2254
+
2255
+ # Any Ruby-side navigation intent queued and waiting for `drain_pending_navigation`
2256
+ # to act on it — the same set that method drains (location / frame nav / frame
2257
+ # submit / frame reload / reload / history traverse). The quiescence loop treats
2258
+ # a queued intent as progress because draining it does cross-realm work (rebuild
2259
+ # a child frame realm + fire its `onload`) that the main-realm `settleGen` /
2260
+ # fired-timer signals can't see. Aux-window opens are intentionally excluded:
2261
+ # they build a separate Browser, not a hop in a same-page chain.
2262
+ private def pending_nav_intent?
2263
+ !@pending_location.nil? ||
2264
+ @pending_reload ||
2265
+ !@pending_history_traverse.nil? ||
2266
+ !(@pending_frame_nav || {}).empty? ||
2267
+ !(@pending_frame_submit || []).empty? ||
2268
+ !(@pending_frame_reload || []).empty?
2269
+ end
2270
+
2271
+ # Pull every background async channel (Worker / EventSource / hijacked fetch
2272
+ # / postMessage / WebSocket) into JS state, marking the find cache dirty if
2273
+ # any delivered. Shared by `tick_real_time` and `run_event_loop_frame`.
2274
+ # Returns true if any channel delivered.
2275
+ private def drain_async_channels
2276
+ n = deliver_worker_messages + deliver_event_source_events + deliver_hijacked_fetches +
2277
+ deliver_window_messages + deliver_websocket_events
2278
+ @find_cache_dirty = true if n.positive?
2279
+ n.positive?
2280
+ end
2281
+
2017
2282
  # This tick's deterministic virtual-clock advance (ms). Default is the fixed
2018
2283
  # `POLL_TICK_STEP_MS` — never wall-derived, so per-poll JS/Ruby/GC cost cannot
2019
2284
  # shift WHEN a timer fires (the wall-sync↔perf coupling this replaces). When
@@ -2022,6 +2287,11 @@ module Capybara
2022
2287
  # it — but only after the transient-guard window so pre-debounce states are
2023
2288
  # still observed across several polls. `FF_HORIZON_MS=0` ⇒ pure fixed-step.
2024
2289
  def horizon_fast_forward_step
2290
+ # Whether the page has work runnable at the CURRENT instant (a rAF or a
2291
+ # due-now timer). Only then does `tick_real_time` sub-step the advance into
2292
+ # frames — an idle/parked poll has nothing to render frame-by-frame, so it
2293
+ # stays a single step (no extra render phases on the common idle-wait poll).
2294
+ @page_runnable_now = false
2025
2295
  # Escape hatch to the legacy wall-sync clock (virtual advance = real
2026
2296
  # wall-elapsed per poll). The deterministic model decouples perf from
2027
2297
  # timing but can't match a real browser's wall-proportional cadence for
@@ -2046,6 +2316,7 @@ module Capybara
2046
2316
  # (2) Runnable now → fixed step, reset guard (not a quiet pre-debounce window).
2047
2317
  if delay.zero?
2048
2318
  @ff_transient_polls = 0
2319
+ @page_runnable_now = true
2049
2320
  return POLL_TICK_STEP_MS
2050
2321
  end
2051
2322
  # (3) Nothing parked → nothing to fast-forward to.
@@ -2091,95 +2362,161 @@ module Capybara
2091
2362
  attr_reader :context_gen
2092
2363
 
2093
2364
  # Pulls the serialised form-state out of JS, encodes it, and
2094
- # drives the Rack app via `navigate` (for GET) or a POST.
2095
- def submit_form_handle(form_handle, submitter_handle)
2365
+ # drives the Rack app via `navigate` (for GET) or a POST. `entry_list` is the
2366
+ # list JS already constructed (post-`formdata`, so a handler's append/delete
2367
+ # is honoured); when absent (the Enter implicit-submit path) we build it from
2368
+ # the form's own controls.
2369
+ def submit_form_handle(form_handle, submitter_handle, entry_list = nil)
2096
2370
  invalidate_find_cache
2097
2371
  spec = dom_call('__csimFormSerialize', form_handle, submitter_handle || 0)
2098
2372
  return unless spec.is_a?(Hash)
2099
2373
  action = spec['action'].to_s
2100
2374
  method = spec['method'].to_s.upcase
2101
2375
  method = 'GET' if method.empty?
2102
- fields = (spec['fields'] || []).map {|pair| [pair[0].to_s, pair[1].to_s] }
2103
- file_inputs = spec['fileInputs'] || []
2104
- enctype = spec['enctype'].to_s
2105
- multipart = enctype.start_with?('multipart/form-data')
2106
- content_type = nil
2107
- body =
2108
- if multipart
2109
- built = build_multipart_body(fields, file_inputs)
2110
- content_type = built[:content_type]
2111
- built[:body]
2112
- else
2113
- # Non-multipart: file inputs contribute the filename only.
2114
- file_inputs.each do |fi|
2115
- picks = @file_picks && @file_picks[fi['handle'].to_i] || []
2116
- fields << [fi['name'].to_s, picks.first ? File.basename(picks.first) : '']
2117
- end
2118
- URI.encode_www_form(fields)
2119
- end
2376
+ enctype = spec['enctype'].to_s.empty? ? 'application/x-www-form-urlencoded' : spec['enctype'].to_s.downcase
2377
+ entries = entry_list.is_a?(Array) ? entry_list : entries_from_spec(spec)
2120
2378
  action_url = action.empty? ? (current_browsing_context_url || @default_host) : resolve_against_current(action)
2121
2379
  # A form submitted inside a frame whose target is that frame (self, or a
2122
2380
  # `_parent` of a ≥2-deep frame) navigates the FRAME, not the top page.
2123
2381
  frame_entry = frame_nav_target_entry(spec['target'])
2382
+ # A non-frame named target (`_blank`, or a window name that isn't a frame)
2383
+ # submits into a NEW/named browsing context — open (or reuse) an aux window,
2384
+ # mirroring the link `target=_blank` branch. `_blank` is always a fresh window;
2385
+ # a named target that matches THIS window's own `window.name` navigates in
2386
+ # place (HTML named-context targeting), so it isn't a new window.
2387
+ target = spec['target'].to_s
2388
+ named_target = frame_entry.nil? && !target.empty? &&
2389
+ !%w[_self _top _parent].include?(target.downcase) &&
2390
+ @driver.respond_to?(:open_aux_window)
2391
+ own_name = named_target ? (@runtime.call('__csimReadWindowProp', false, 'name').to_s rescue '') : ''
2392
+ new_window = named_target && (target.casecmp?('_blank') || target != own_name)
2393
+ window_name = target.casecmp?('_blank') ? '' : target
2394
+ # Opener exposure for a `<form target>` new context, per the link-relation
2395
+ # model: `noopener`/`noreferrer` always drop the opener; `target=_blank`
2396
+ # ALSO defaults to noopener unless `rel=opener` opts back in (a named target
2397
+ # keeps its opener by default). `noreferrer` additionally empties the referrer.
2398
+ rel_tokens = spec['rel'].to_s.downcase.split(/\s+/)
2399
+ no_referrer = rel_tokens.include?('noreferrer')
2400
+ keep_opener = !no_referrer && !rel_tokens.include?('noopener') &&
2401
+ (target.downcase != '_blank' || rel_tokens.include?('opener'))
2402
+ referrer = no_referrer ? '' : (@current_url || '')
2403
+ # Opening a new top-level browsing context consumes transient user activation.
2404
+ @runtime.call('__csimConsumeTransientActivation') if new_window rescue nil
2124
2405
  if method == 'GET'
2406
+ # GET ignores enctype: the entry list is always the urlencoded query.
2407
+ query, = encode_entry_list(entries, 'application/x-www-form-urlencoded')
2125
2408
  uri = URI.parse(action_url)
2126
- uri.query = body unless body.empty?
2127
- frame_entry ? navigate_frame(uri.to_s, entry: frame_entry) : navigate(uri.to_s)
2128
- elsif frame_entry
2129
- navigate_frame_post(action_url, body, content_type || enctype, entry: frame_entry)
2409
+ # HTML "mutate action URL" for GET: SET the query to the entry list
2410
+ # unconditionally an empty list clears any query the action already
2411
+ # carried (browsers navigate to `action?`), it isn't preserved.
2412
+ uri.query = query
2413
+ if new_window
2414
+ @driver.open_aux_window(uri.to_s, name: window_name, source: self,
2415
+ opener: keep_opener, referrer: referrer)
2416
+ elsif frame_entry
2417
+ navigate_frame(uri.to_s, entry: frame_entry)
2418
+ else
2419
+ navigate(uri.to_s)
2420
+ end
2130
2421
  else
2131
- navigate_post(action_url, body, content_type || enctype)
2422
+ body, content_type = encode_entry_list(entries, enctype)
2423
+ if new_window
2424
+ @driver.open_aux_window(action_url, name: window_name, source: self,
2425
+ opener: keep_opener, referrer: referrer,
2426
+ post: {body: body, content_type: content_type})
2427
+ elsif frame_entry
2428
+ navigate_frame_post(action_url, body, content_type, entry: frame_entry)
2429
+ else
2430
+ navigate_post(action_url, body, content_type)
2431
+ end
2132
2432
  end
2133
2433
  end
2134
2434
 
2135
- def build_multipart_body(fields, file_inputs)
2136
- boundary = "csim-#{SecureRandom.hex(8)}"
2137
- body = String.new.force_encoding(Encoding::ASCII_8BIT)
2138
- fields.each do |name, value|
2139
- append_multipart_part(body, boundary, name, value.to_s)
2140
- end
2141
- file_inputs.each do |fi|
2142
- picks = file_pick_paths(fi)
2143
- if picks.empty?
2144
- append_multipart_part(body, boundary, fi['name'].to_s, '', filename: '')
2145
- else
2146
- picks.each do |path|
2147
- append_multipart_part(body, boundary, fi['name'].to_s, File.binread(path),
2148
- filename: File.basename(path),
2149
- content_type: Rack::Mime.mime_type(File.extname(path)))
2435
+ # HTML "encode the entry list" by enctype → [body, exact Content-Type]. The
2436
+ # Content-Type is sent verbatim (no charset suffix), which the spec's
2437
+ # form-submission resources compare exactly. text/plain is `name=value\r\n`
2438
+ # per entry (NOT urlencoded); urlencoded (and GET) merge each file entry as
2439
+ # its bare filename. `entries` is the ordered entry list — string
2440
+ # {'name','value'} or file {'name','file'=>true,'filename','handle','index'}
2441
+ # entries; a file's bytes resolve through the `@file_picks` slot.
2442
+ def encode_entry_list(entries, enctype)
2443
+ if enctype.start_with?('multipart/form-data')
2444
+ boundary = "csim-#{SecureRandom.hex(8)}"
2445
+ body = String.new.force_encoding(Encoding::ASCII_8BIT)
2446
+ entries.each do |e|
2447
+ if e['file']
2448
+ path = entry_file_path(e)
2449
+ if path
2450
+ append_multipart_part(body, boundary, e['name'].to_s, File.binread(path),
2451
+ filename: File.basename(path),
2452
+ content_type: Rack::Mime.mime_type(File.extname(path)))
2453
+ elsif e['b64']
2454
+ # An in-memory `new File([…])` has no on-disk slot; its bytes are
2455
+ # carried base64-encoded from the VM. Decode them for the part body.
2456
+ content = e['b64'].to_s.unpack1('m')
2457
+ ct = e['type'].to_s
2458
+ ct = 'application/octet-stream' if ct.empty?
2459
+ append_multipart_part(body, boundary, e['name'].to_s, content,
2460
+ filename: e['filename'].to_s, content_type: ct)
2461
+ else
2462
+ append_multipart_part(body, boundary, e['name'].to_s, '', filename: e['filename'].to_s)
2463
+ end
2464
+ else
2465
+ append_multipart_part(body, boundary, e['name'].to_s, e['value'].to_s)
2150
2466
  end
2151
2467
  end
2468
+ body << "--#{boundary}--\r\n"
2469
+ [body, "multipart/form-data; boundary=#{boundary}"]
2470
+ else
2471
+ pairs = entries.map {|e| [e['name'].to_s, e['file'] ? e['filename'].to_s : e['value'].to_s] }
2472
+ if enctype == 'text/plain'
2473
+ [pairs.map {|name, value| "#{name}=#{value}\r\n" }.join, 'text/plain']
2474
+ else
2475
+ [URI.encode_www_form(pairs), 'application/x-www-form-urlencoded']
2476
+ end
2152
2477
  end
2153
- body << "--#{boundary}--\r\n"
2154
- {content_type: "multipart/form-data; boundary=#{boundary}", body: body}
2155
2478
  end
2156
2479
 
2157
- # The on-disk paths backing a file input's current selection. Each
2158
- # selected File reports its host-backed source (`handle`/`index` the
2159
- # `@file_picks` slot recorded at `attach_file` time); this resolves bytes
2160
- # even when JS moved a File onto a different input (`input.files =
2161
- # dataTransfer.files`), whose own handle was never attached to. Falls back
2162
- # to the input's own handle for older serializer payloads.
2163
- #
2164
- # Only host-backed Files (from `attach_file`) resolve here; a purely
2165
- # in-memory `new File(['bytes'], …)` assigned via JS has no `@file_picks`
2166
- # slot, so a CLASSIC (non-Turbo) submit drops its bytes — the fetch/XHR
2167
- # path serializes those in JS (`serializeMultipart` → `blobBytes`) and is
2168
- # unaffected. This matches the pre-existing behaviour and covers every
2169
- # realistic upload (host-backed file submitted through Turbo or a plain
2170
- # form).
2171
- def file_pick_paths(fi)
2172
- refs = fi['files']
2173
- if refs.is_a?(Array) && !refs.empty?
2174
- refs.filter_map {|ref|
2175
- handle = ref['handle']
2176
- next if handle.nil?
2177
- picks = @file_picks && @file_picks[handle.to_i]
2178
- picks && picks[ref['index'].to_i]
2179
- }
2180
- else
2181
- (@file_picks && @file_picks[fi['handle'].to_i]) || []
2480
+ # Resolve a threaded file entry's on-disk path via the `@file_picks` slot
2481
+ # recorded at `attach_file` time (handle/index). nil for a purely in-memory
2482
+ # `new File(['bytes'], …)` (no slot) a CLASSIC (non-Turbo) submit then
2483
+ # drops its bytes, while the fetch/XHR path serializes them in JS
2484
+ # (`serializeMultipart` `blobBytes`). This covers every realistic upload
2485
+ # (a host-backed file submitted through Turbo or a plain form).
2486
+ def entry_file_path(entry)
2487
+ handle = entry['handle']
2488
+ return nil if handle.nil?
2489
+ picks = @file_picks && @file_picks[handle.to_i]
2490
+ picks && picks[entry['index'].to_i]
2491
+ end
2492
+
2493
+ # Build the entry list from the form's own controls, for triggers that didn't
2494
+ # construct one in JS (the Enter implicit-submit path). Mirrors the JS FormData
2495
+ # construction: non-file fields in tree order, then each file input's selection
2496
+ # (one empty entry when nothing is picked). A selected File reports its
2497
+ # host-backed source (`handle`/`index`); the older payload shape with no per-File
2498
+ # refs falls back to the input's own handle slot.
2499
+ def entries_from_spec(spec)
2500
+ entries = (spec['fields'] || []).map {|pair| {'name' => pair[0].to_s, 'value' => pair[1].to_s} }
2501
+ (spec['fileInputs'] || []).each do |fi|
2502
+ name = fi['name'].to_s
2503
+ refs = fi['files']
2504
+ if refs.is_a?(Array) && !refs.empty?
2505
+ refs.each {|ref|
2506
+ entries << {'name' => name, 'file' => true, 'filename' => ref['name'].to_s, 'handle' => ref['handle'], 'index' => ref['index']}
2507
+ }
2508
+ else
2509
+ picks = (@file_picks && @file_picks[fi['handle'].to_i]) || []
2510
+ if picks.empty?
2511
+ entries << {'name' => name, 'file' => true, 'filename' => '', 'handle' => nil, 'index' => nil}
2512
+ else
2513
+ picks.each_index {|i|
2514
+ entries << {'name' => name, 'file' => true, 'filename' => File.basename(picks[i]), 'handle' => fi['handle'], 'index' => i}
2515
+ }
2516
+ end
2517
+ end
2182
2518
  end
2519
+ entries
2183
2520
  end
2184
2521
 
2185
2522
  def append_multipart_part(body, boundary, name, content, filename: nil, content_type: nil)
@@ -2193,14 +2530,17 @@ module Capybara
2193
2530
  body << "\r\n"
2194
2531
  end
2195
2532
 
2196
- def navigate_post(url, body, content_type, depth: 0, from_history: false)
2533
+ def navigate_post(url, body, content_type, depth: 0, from_history: false, referer: @current_url)
2197
2534
  raise 'too many redirects' if depth > 10
2198
2535
  invalidate_find_cache
2199
- record_history({method: :post, url: url, body: body, content_type: content_type}) unless from_history || depth > 0
2536
+ unless from_history || depth > 0
2537
+ capture_outgoing_form_state
2538
+ record_history({method: :post, url: url, body: body, content_type: content_type})
2539
+ end
2200
2540
  env = Rack::MockRequest.env_for(url, method: 'POST', input: body)
2201
2541
  env['CONTENT_TYPE'] = content_type.empty? ? 'application/x-www-form-urlencoded' : content_type
2202
2542
  env['CONTENT_LENGTH'] = body.bytesize.to_s
2203
- apply_default_request_env(env, referer: @current_url)
2543
+ apply_default_request_env(env, referer: referer)
2204
2544
  status, headers, resp_body = dispatch_rack_or_http(url, env, method: 'POST', body: body)
2205
2545
  merge_set_cookie(headers)
2206
2546
  if (loc = redirect_location(status, headers))
@@ -2277,10 +2617,11 @@ module Capybara
2277
2617
  reset_workers
2278
2618
  reset_websockets
2279
2619
  @window_inbox.clear
2620
+ @broadcast_inbox.clear
2280
2621
  # Free any zero-copy transfer backing stores that went unimported
2281
2622
  # (worker killed before draining its inbox, etc.) before the rebuild.
2282
2623
  drop_pending_transfers
2283
- @blob_registry_lock.synchronize { @blob_registry.clear }
2624
+ @blob_registry_lock.synchronize { @blob_registry.clear; @blob_owners.clear }
2284
2625
  # Drop volatile entries from the class-level HTTP asset cache
2285
2626
  # so test-local DB state (TranslationOverride, etc.) reaches
2286
2627
  # the app on subsequent visits. Fingerprinted assets
@@ -2309,6 +2650,14 @@ module Capybara
2309
2650
  reset_hijacked_fetches
2310
2651
  reset_websockets
2311
2652
  @window_inbox.clear
2653
+ @broadcast_inbox.clear
2654
+ # Dispose the JS runtime/isolate itself — for an auxiliary window this
2655
+ # Browser is the isolate's last owner, but V8Runtime registers every
2656
+ # isolate in a process-wide `@@live` set (for at_exit cleanup), which
2657
+ # pins it past a bare GC. Without this, each closed window leaked a live
2658
+ # V8 isolate (RSS climbed across a long suite). Only reached on teardown,
2659
+ # never on the per-test `reset!` path (which keeps the runtime).
2660
+ @runtime.dispose if @runtime.respond_to?(:dispose)
2312
2661
  rescue StandardError
2313
2662
  nil
2314
2663
  end
@@ -2359,7 +2708,14 @@ module Capybara
2359
2708
  # Returns nil on 4xx / fetch failure so the JS caller skips it exactly as the
2360
2709
  # old `__rackFetch` branch did.
2361
2710
  def external_asset_source(url)
2362
- key = resolve_against_current(url.to_s)
2711
+ # A blob:/data:/about: document's location can't anchor an absolute-path
2712
+ # `src=/common/…` (URI.join on a `blob:` URL yields nothing usable), but its
2713
+ # `<base href>` points at a real http(s) origin — so for THOSE documents
2714
+ # resolve against `<base href>` (HTML base-tag semantics) and the script loads.
2715
+ # Ordinary http(s) pages keep the document-URL base so the hot path skips the
2716
+ # `base_href` dom_call (CLAUDE.md rule 3).
2717
+ needs_base = @current_url.to_s.start_with?('blob:', 'data:', 'about:')
2718
+ key = resolve_against_current(url.to_s, use_base: needs_base)
2363
2719
  return nil unless key.is_a?(String)
2364
2720
  @@asset_src_lock.synchronize do
2365
2721
  if (e = @@asset_src[key])
@@ -3037,26 +3393,48 @@ module Capybara
3037
3393
  # worker's `__csim_workerPostMessage` host fn closes over its
3038
3394
  # handle and routes outgoing messages onto a shared outbox the
3039
3395
  # main settle drains.
3040
- def worker_spawn(url)
3396
+ def worker_spawn(url, shared: false, service: false)
3041
3397
  handle = (@worker_seq += 1)
3398
+ target = resolve_against_current(url.to_s)
3399
+ # A worker script from a blob: URL in a DIFFERENT storage partition than this
3400
+ # context can't be created (cross-partition-worker-creation): the worker would
3401
+ # run in the creating context's partition, not the blob's. Deliver an error so
3402
+ # Worker/SharedWorker fires `onerror`, and spawn no thread. The handle is still
3403
+ # >0 so the JS registers the worker and the queued __error reaches it.
3404
+ if target.start_with?('blob:') && @driver.respond_to?(:cross_partition_blob?) && @driver.cross_partition_blob?(target, self)
3405
+ return worker_fail(handle, 'Worker creation from a cross-partition blob URL is blocked')
3406
+ end
3042
3407
  inbox = Thread::Queue.new
3043
3408
  outbox = @worker_outbox
3044
3409
  engine_class = @runtime.class
3045
- target = resolve_against_current(url.to_s)
3046
3410
  # Resolve the worker script body on the main thread before
3047
3411
  # handing off to the worker. `blob:` URLs need the main VM's
3048
3412
  # blob registry; calling into the main runtime from a
3049
3413
  # non-owning thread SEGVs (V8 isolates are thread-
3050
3414
  # bound; quickjs.rb's VM is similarly per-thread).
3051
3415
  body = fetch_worker_script(target)
3416
+ # A blob: worker script that didn't resolve (revoked / unavailable) fails the
3417
+ # same way — fire onerror rather than spawn a worker that runs nothing.
3418
+ return worker_fail(handle, 'Worker script could not be loaded') if target.start_with?('blob:') && body.to_s.empty?
3419
+ # Pending until the worker's initial script has run (see @worker_initializing).
3420
+ @worker_init_lock.synchronize { @worker_initializing += 1 }
3052
3421
  thread = Thread.new do
3053
3422
  Thread.current.report_on_exception = false
3054
- run_worker(handle, target, body, inbox, outbox, engine_class)
3423
+ run_worker(handle, target, body, inbox, outbox, engine_class, shared: shared, service: service)
3055
3424
  end
3056
3425
  @workers[handle] = {thread: thread, inbox: inbox}
3057
3426
  handle
3058
3427
  end
3059
3428
 
3429
+ # Fail a worker that can't be created (blocked / unloadable script): queue an
3430
+ # error event so the JS Worker/SharedWorker fires `onerror`, spawn no thread,
3431
+ # and return the (still >0) handle so the JS registers the worker and the error
3432
+ # reaches it.
3433
+ private def worker_fail(handle, message)
3434
+ @worker_outbox << {handle: handle, kind: '__error', message: message}
3435
+ handle
3436
+ end
3437
+
3060
3438
  def worker_post_to_worker(handle, data)
3061
3439
  w = @workers[handle.to_i]
3062
3440
  return unless w
@@ -3069,13 +3447,21 @@ module Capybara
3069
3447
  return unless w
3070
3448
  w[:inbox] << :terminate
3071
3449
  # Most clean shutdowns are <10 ms; the kill is the fallback
3072
- # for blocked workers.
3450
+ # for blocked workers. Join again AFTER the kill so the thread is actually
3451
+ # dead before we revoke its URLs — `Thread#kill` is async, and a worker
3452
+ # still running a `createObjectURL` could otherwise re-register a URL after
3453
+ # the revoke and leak it.
3073
3454
  w[:thread].join(WORKER_TERMINATE_GRACE)
3074
- w[:thread].kill if w[:thread].alive?
3455
+ if w[:thread].alive?
3456
+ w[:thread].kill
3457
+ w[:thread].join(WORKER_TERMINATE_GRACE)
3458
+ end
3075
3459
  # A blocked worker that never returned messages leaves
3076
3460
  # `@worker_in_flight` permanently > 0; reset when no workers
3077
3461
  # remain so `polling?` can short-circuit again.
3078
3462
  @worker_in_flight = 0 if @workers.empty?
3463
+ # The worker is gone — revoke the blob URLs it created.
3464
+ revoke_worker_blobs(handle.to_i)
3079
3465
  end
3080
3466
 
3081
3467
  def deliver_worker_messages
@@ -3089,7 +3475,7 @@ module Capybara
3089
3475
  events.size
3090
3476
  end
3091
3477
 
3092
- def worker_pending? = !@worker_outbox.empty? || @worker_in_flight > 0
3478
+ def worker_pending? = !@worker_outbox.empty? || @worker_in_flight > 0 || @worker_init_lock.synchronize { @worker_initializing } > 0
3093
3479
 
3094
3480
  # ── Cross-window messaging (window.open / opener / postMessage) ──
3095
3481
  # Each window is a separate Browser/VM/isolate, so a reference to another
@@ -3100,9 +3486,32 @@ module Capybara
3100
3486
  # `window.open(url, name)` from JS — returns the new (or reused, by name)
3101
3487
  # window's handle, or nil. The URL is resolved against THIS document so a
3102
3488
  # relative `window.open('/x')` targets the right origin/path.
3103
- def open_child_window(url, name)
3489
+ def open_child_window(url, name, opener_realm_id = 0)
3104
3490
  return nil unless @driver.respond_to?(:open_window_from_js)
3105
- @driver.open_window_from_js(self, url.to_s, name.to_s)
3491
+ @driver.open_window_from_js(self, url.to_s, name.to_s, opener_realm_id.to_i)
3492
+ end
3493
+
3494
+ # A `target=_blank`/named link/area activation from a frame or window realm in
3495
+ # THIS browser opens a new top-level auxiliary window. `opener` reflects
3496
+ # rel=opener (a bare target=_blank is noopener); the Driver forces noopener for
3497
+ # a cross-partition blob: target. `blob` is an optional click-time blob snapshot.
3498
+ def open_aux_from_realm(url, opener, blob)
3499
+ return unless @driver.respond_to?(:open_aux_window)
3500
+ snap = blob.is_a?(Hash) ? blob : nil
3501
+ @driver.open_aux_window(resolve_document_url(url.to_s), source: self, opener: !!opener, blob_snapshot: snap)
3502
+ end
3503
+
3504
+ # Open a SAME-ORIGIN auxiliary window as a realm in THIS browser's isolate
3505
+ # (shared heap) rather than a separate Browser/VM, returning the new realm's
3506
+ # context id for `window.open` to wrap in a NATIVE WindowProxy — so
3507
+ # `popup.document` is a real same-isolate Document and cross-window adoptNode
3508
+ # works (dom/nodes/remove-and-adopt-thcrash). Returns nil to fall back to the
3509
+ # separate-VM aux-window path. First stage: about:blank only (a non-blank
3510
+ # same-origin URL still takes the aux path until realm URL-loading lands).
3511
+ def open_window_realm(url, name: nil, opener_realm_id: 0)
3512
+ return nil unless @runtime.respond_to?(:create_window_realm)
3513
+ return nil unless url.nil?
3514
+ @runtime.create_window_realm('', '', 'text/html', window_name: name, opener_id: opener_realm_id)
3106
3515
  end
3107
3516
 
3108
3517
  # `targetWindow.postMessage(data, origin)` — route to the target window's
@@ -3113,10 +3522,60 @@ module Capybara
3113
3522
  end
3114
3523
 
3115
3524
  def window_location_of(handle) = @driver.respond_to?(:window_location) ? @driver.window_location(handle.to_s).to_s : ''
3525
+ # Cross-window property reads (a WindowProxy `win.foo` / `win.document.foo`):
3526
+ # route to the Driver, which reads a PRIMITIVE off the target window's VM.
3527
+ def window_get(handle, prop) = (@driver.respond_to?(:window_read) ? @driver.window_read(handle.to_s, prop.to_s, doc: false) : nil)
3528
+ def window_doc_get(handle, prop) = (@driver.respond_to?(:window_read) ? @driver.window_read(handle.to_s, prop.to_s, doc: true) : nil)
3529
+ # Cross-window remote-ref RPC — SOURCE side: forward a node/object proxy op to
3530
+ # the target window's Browser via the Driver.
3531
+ 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)
3532
+ 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))
3533
+ 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)
3534
+ # TARGET side: execute the op against THIS window's VM (the Driver calls these
3535
+ # on the resolved target Browser).
3536
+ def remote_ref_get(id, prop) = @runtime.call('__csimRemoteRefGet', id, prop.to_s)
3537
+ def remote_ref_set(id, prop, value)
3538
+ @runtime.call('__csimRemoteRefSet', id, prop.to_s, value)
3539
+ # A cross-isolate property set can queue a navigation in THIS (target)
3540
+ # window — `w.location.href = …` / `w.location = …`. Drain it (and any other
3541
+ # pending action it triggered) so the non-active window actions it now.
3542
+ drain_pending_after_remote_ref
3543
+ nil
3544
+ end
3545
+ def remote_ref_call(id, method, args)
3546
+ result = @runtime.call('__csimRemoteRefCall', id, method.to_s, args || [])
3547
+ # A cross-isolate call can queue a pending action in THIS (target) window —
3548
+ # `w.form.submit()` (form submit), `w.history.back()` (history traverse),
3549
+ # `w.location.assign()` (navigation). Drain them so the non-active window
3550
+ # actions them (they would otherwise wait for a Capybara action on it).
3551
+ drain_pending_after_remote_ref
3552
+ result
3553
+ end
3554
+ # Drain every deferred action a cross-isolate operation may have queued in this
3555
+ # window: form submission, plain navigation, same-document history traversal
3556
+ # (history.back/forward/go — restores the previous entry + fires load), and
3557
+ # child-frame navigation. Each consume is a no-op when nothing is pending.
3558
+ def drain_pending_after_remote_ref
3559
+ consume_pending_form_submit
3560
+ consume_pending_navigation
3561
+ consume_pending_history_traverse
3562
+ consume_pending_frame_nav
3563
+ end
3564
+ # Read a primitive property off THIS window's globalThis / document — called
3565
+ # by the Driver to serve another window's cross-window proxy read.
3566
+ def read_property(prop, doc: false)
3567
+ @runtime.call('__csimReadWindowProp', doc, prop.to_s)
3568
+ rescue StandardError
3569
+ nil
3570
+ end
3116
3571
  def set_window_location(handle, url) = (@driver.window_set_location(handle.to_s, url.to_s) if @driver.respond_to?(:window_set_location))
3572
+ def window_history_go(handle, delta) = (@driver.respond_to?(:window_history_go) ? @driver.window_history_go(handle.to_s, delta.to_i) : false)
3117
3573
  def window_closed?(handle) = @driver.respond_to?(:window_closed?) ? @driver.window_closed?(handle.to_s) : true
3118
3574
  def close_child_window(handle) = (@driver.close_window(handle.to_s) if @driver.respond_to?(:close_window))
3119
3575
  def opener_handle = @driver.respond_to?(:opener_handle_of) ? @driver.opener_handle_of(self) : nil
3576
+ # Fire an aux window's own window `load` (called by its opener, deferred).
3577
+ def fire_aux_window_load(handle) = (@driver.fire_aux_window_load(handle.to_s) if @driver.respond_to?(:fire_aux_window_load))
3578
+ def fire_own_window_load = (@runtime.call('__csimFireWindowLoad') rescue nil)
3120
3579
 
3121
3580
  # Queue a cross-window message for delivery into THIS window's VM (called
3122
3581
  # by the Driver on the target Browser). Delivered as a `message` event the
@@ -3125,14 +3584,61 @@ module Capybara
3125
3584
  @window_inbox << {'data' => data, 'origin' => origin.to_s, 'sourceHandle' => source_handle.to_s}
3126
3585
  end
3127
3586
 
3128
- def window_message_pending? = !@window_inbox.empty?
3587
+ # Covers both cross-window postMessage AND BroadcastChannel — the two
3588
+ # cross-window event channels share these drain/pending hooks.
3589
+ def window_message_pending? = !@window_inbox.empty? || !@broadcast_inbox.empty?
3129
3590
 
3130
- # Fire queued cross-window messages as `message` events on window.
3591
+ # A BroadcastChannel message queued for delivery to this Browser's channels.
3592
+ # `source_realm_id` is the posting realm's context id within THIS isolate (0 =
3593
+ # main), or nil when the post came from ANOTHER isolate (the Driver's cross-
3594
+ # window fanout) — a nil source matches no local realm, so it reaches every one.
3595
+ def enqueue_broadcast(name, data, source_realm_id = nil)
3596
+ @broadcast_inbox << {'name' => name.to_s, 'data' => data, 'source' => source_realm_id}
3597
+ end
3598
+
3599
+ # Fire queued cross-window messages (postMessage + BroadcastChannel).
3131
3600
  def deliver_window_messages
3132
- return 0 if @window_inbox.empty?
3133
- events = @window_inbox.slice!(0, @window_inbox.length)
3134
- @runtime.call('__csim_deliverWindowMessages', events)
3135
- events.size
3601
+ n = 0
3602
+ unless @window_inbox.empty?
3603
+ events = @window_inbox.slice!(0, @window_inbox.length)
3604
+ @runtime.call('__csim_deliverWindowMessages', events)
3605
+ n += events.size
3606
+ end
3607
+ unless @broadcast_inbox.empty?
3608
+ events = @broadcast_inbox.slice!(0, @broadcast_inbox.length)
3609
+ # A BroadcastChannel reaches every same-origin browsing context EXCEPT the
3610
+ # poster. Within this isolate each browsing context is a realm, so deliver to
3611
+ # the main realm (0) and every live frame/window realm, skipping the realm
3612
+ # that posted (it already delivered to itself in-VM via `_bcChannels`). A nil
3613
+ # source (cross-isolate) is excluded from no realm.
3614
+ realm_ids = @runtime.respond_to?(:frame_realm_ids) ? @runtime.frame_realm_ids : []
3615
+ [0, *realm_ids].each do |target_id|
3616
+ batch = events.reject {|e| e['source'] == target_id }
3617
+ next if batch.empty?
3618
+ if target_id.zero?
3619
+ @runtime.call('__csim_deliverBroadcasts', batch)
3620
+ elsif @runtime.frame_realm_alive?(target_id)
3621
+ @runtime.realm_call(target_id, '__csim_deliverBroadcasts', batch)
3622
+ end
3623
+ end
3624
+ n += events.size
3625
+ end
3626
+ n
3627
+ end
3628
+
3629
+ # `BroadcastChannel.postMessage` in THIS window — fan out to every OTHER same-
3630
+ # origin browsing context's matching channels (same-realm delivery happens
3631
+ # in-VM). `source_realm_id` is the posting realm's context id. The cross-ISOLATE
3632
+ # fanout goes through the Driver; the same-ISOLATE fanout (main ↔ sibling realms,
3633
+ # sibling ↔ sibling) is queued here and delivered per-realm by
3634
+ # `deliver_window_messages`, which skips the posting realm.
3635
+ def broadcast_to_windows(name, data, source_realm_id = 0)
3636
+ @driver.broadcast_channel(self, name.to_s, data) if @driver.respond_to?(:broadcast_channel)
3637
+ # Only queue for same-isolate delivery when sibling realms exist — a single-
3638
+ # realm page already delivered to itself in-VM, so this stays zero-overhead.
3639
+ if @runtime.respond_to?(:frame_realm_ids) && @runtime.frame_realm_ids.any?
3640
+ enqueue_broadcast(name, data, source_realm_id.to_i)
3641
+ end
3136
3642
  end
3137
3643
 
3138
3644
  # ── Image decode (libvips) ─────────────────────────────────────
@@ -3195,20 +3701,208 @@ module Capybara
3195
3701
  }
3196
3702
  end
3197
3703
 
3198
- def blob_register(url, body_b64)
3199
- @blob_registry_lock.synchronize { @blob_registry[url.to_s] = body_b64.to_s }
3704
+ def blob_register(url, body_b64, owner_realm = nil)
3705
+ # Tag the creating context so the URL is revoked when that context goes
3706
+ # away: a WORKER (separate thread, tagged via Thread.current) when it
3707
+ # terminates ("Terminating worker"), or a FRAME REALM (owner_realm passed
3708
+ # from JS createObjectURL) when the iframe is removed ("Removing an
3709
+ # iframe"). Namespaced ('w:' / 'r:') so a worker handle and a realm id
3710
+ # never collide. Main-realm blobs (no owner) live until clear_volatile.
3711
+ worker = Thread.current[:csim_worker_handle]
3712
+ key = if worker then "w:#{worker}"
3713
+ elsif owner_realm && owner_realm.to_i != 0 then "r:#{owner_realm.to_i}"
3714
+ end
3715
+ @blob_registry_lock.synchronize do
3716
+ @blob_registry[url.to_s] = body_b64.to_s
3717
+ # Keep ownership in sync both ways: a (re-)registration with no owner
3718
+ # (main thread / main realm) must DROP any prior owner, else revoking
3719
+ # that context would wrongly revoke a now-page-owned URL.
3720
+ if key then @blob_owners[url.to_s] = key else @blob_owners.delete(url.to_s) end
3721
+ end
3722
+ # Record the blob's STORAGE PARTITION (this window's top-level site) in the
3723
+ # Driver-level store so another window can resolve it only from the same
3724
+ # partition (blob URL partitioning). Keyed by the creating Browser so the
3725
+ # bytes are read back from wherever they live (the creator's isolate).
3726
+ @driver.register_blob_partition(url.to_s, self, blob_partition_site) if @driver.respond_to?(:register_blob_partition)
3200
3727
  nil
3201
3728
  end
3202
3729
 
3203
3730
  def blob_resolve(url)
3204
- @blob_registry_lock.synchronize { @blob_registry[url.to_s] }
3731
+ local = @blob_registry_lock.synchronize { @blob_registry[url.to_s] }
3732
+ return local if local
3733
+ # Not in this window's registry: a blob created in ANOTHER window/isolate is
3734
+ # fetchable only from the SAME storage partition (cross-partition.https
3735
+ # "fetched from a same-partition iframe" succeeds; a cross-partition fetch
3736
+ # fails). Resolve the bytes cross-isolate from the creator and hand them back
3737
+ # as base64 (resolveBlobBytes decodes them). The cross-isolate read enters the
3738
+ # creator's V8 isolate, so only do it on the MAIN thread — a worker thread
3739
+ # can't safely enter another isolate (so a same-partition WORKER fetch of a
3740
+ # blob owned elsewhere isn't supported; a cross-partition one correctly fails).
3741
+ # CAVEAT: bytes-only — resolveBlobBytes types a host-resolved blob as
3742
+ # application/octet-stream (the cross-window path loses the Blob's `type`); no
3743
+ # in-scope test reads the type on this path, and carrying it would change the
3744
+ # __csim_blobResolve string protocol.
3745
+ return nil unless @driver.respond_to?(:blob_partition_site_of) && @driver.respond_to?(:blob_bytes_for)
3746
+ site = @driver.blob_partition_site_of(url.to_s)
3747
+ return nil if site.nil? || site != blob_partition_site # unknown / revoked / cross-partition
3748
+ return nil if Thread.current[:csim_worker_handle]
3749
+ data = @driver.blob_bytes_for(url.to_s, self)
3750
+ data && Base64.strict_encode64(data[:bytes].to_s.b)
3751
+ end
3752
+
3753
+ # The SITE (scheme + registrable domain) of this window's top-level document.
3754
+ # Storage partitioning keys a blob URL on its creator's top-level site, so a
3755
+ # same-origin iframe embedded in a cross-site top-level context is a DIFFERENT
3756
+ # partition and can't reach the blob. Registrable domain is approximated as the
3757
+ # host's last two dot-labels — correct for the single-label public suffixes our
3758
+ # in-process hosts use (web-platform.test / not-web-platform.test / *.com); a
3759
+ # full Public Suffix List isn't warranted here.
3760
+ def blob_partition_site
3761
+ # A blob: document's location is `blob:<innerURL>` (e.g.
3762
+ # `blob:https://host:port/uuid`) — strip the prefix so the site derives from
3763
+ # the inner origin, not the opaque blob: scheme. data:/about: have no host →
3764
+ # '' (an opaque origin, never same-partition with a real one).
3765
+ u = URI.parse(@current_url.to_s.sub(/\Ablob:/, ''))
3766
+ host = u.host.to_s
3767
+ return '' if host.empty?
3768
+ labels = host.split('.')
3769
+ # An IP literal (v4 dotted / v6 bracketed) or a ≤2-label host IS its own
3770
+ # registrable domain; otherwise approximate eTLD+1 as the last two labels
3771
+ # (no Public Suffix List — correct for the single-label TLDs our hosts use).
3772
+ regd = if host.start_with?('[') || host.match?(/\A\d+(\.\d+){3}\z/) || labels.length <= 2
3773
+ host
3774
+ else
3775
+ labels.last(2).join('.')
3776
+ end
3777
+ "#{u.scheme}://#{regd}"
3778
+ rescue URI::Error
3779
+ ''
3780
+ end
3781
+
3782
+ # WHATWG URL "domain to ASCII" — the JS tr46 stub delegates non-ASCII / xn--
3783
+ # hosts here (the ASCII fast path stays in-VM). Returns the punycode form, or
3784
+ # nil on an IDNA failure (so whatwg-url reports "domain to ASCII failed").
3785
+ # `be_strict: false` is the URL parser's mode (UseSTD3ASCIIRules and
3786
+ # VerifyDnsLength off) — empty middle labels (`x..y`) and `_`/etc. are
3787
+ # allowed, matching whatwg-url's `domainToASCII(domain, false)`.
3788
+ def domain_to_ascii(domain)
3789
+ d = domain.to_s
3790
+ # An all-ASCII domain needs no IDNA mapping: WHATWG "domain to ASCII"
3791
+ # (beStrict false) keeps it verbatim and only ASCII-lowercases it — including
3792
+ # an `xn--` A-label whose punycode doesn't decode to a valid UTS46 label
3793
+ # (`xn--pokxncvks` → disallowed U+3253…, or the bare `xn--`). Browsers (and the
3794
+ # WPT urltestdata) keep those A-labels as-is; uri-idna RE-validates the decoded
3795
+ # label and raises, which would wrongly fail the parse. So route only domains
3796
+ # with non-ASCII codepoints (the ones that actually need punycode) through
3797
+ # uri-idna. Forbidden host code points in an ASCII host are caught separately
3798
+ # by whatwg-url's host parser, not here. (Residual: a host MIXING a non-ASCII
3799
+ # label with a non-decodable `xn--` label still routes through uri-idna and
3800
+ # fails — a narrow per-label gap no current test hits; the all-ASCII fast path
3801
+ # covers every observed case.)
3802
+ return d.downcase if d.ascii_only?
3803
+ URI::IDNA.whatwg_to_ascii(d, be_strict: false)
3804
+ rescue URI::IDNA::Error
3805
+ nil # a genuine IDNA failure on a non-ASCII host (bad punycode / disallowed
3806
+ # codepoint) — let whatwg-url report "domain to ASCII failed". Non-IDNA
3807
+ # errors propagate.
3808
+ end
3809
+
3810
+ # WHATWG URL "domain to Unicode" — best-effort (never fails the parse per
3811
+ # spec), so on an IDNA error fall back to the input domain (unlike to_ascii,
3812
+ # which signals failure with nil — the asymmetry is intentional).
3813
+ def domain_to_unicode(domain)
3814
+ URI::IDNA.whatwg_to_unicode(domain.to_s, be_strict: false)
3815
+ rescue URI::IDNA::Error
3816
+ domain.to_s
3817
+ end
3818
+
3819
+ # Read a blob URL's bytes + content type from THIS window's VM (its local
3820
+ # blob store) — the Driver uses it to load a blob: document into a fresh aux
3821
+ # window opened by this window. Returns {bytes:, type:} or nil.
3822
+ def read_blob_for_window(url)
3823
+ r = @runtime.call('__csimReadBlobForWindow', url.to_s)
3824
+ return nil unless r.is_a?(Hash) && r['b64']
3825
+ { bytes: Base64.decode64(r['b64'].to_s), type: r['type'].to_s }
3826
+ rescue StandardError
3827
+ nil
3205
3828
  end
3206
3829
 
3830
+ # Load a blob: document (bytes from the opener) as THIS window's top-level
3831
+ # document — for `window.open(blobURL)` / a blob: aux-window navigation,
3832
+ # where the blob isn't rack-navigable and lives in the opener's isolate.
3833
+ def boot_blob_document(url, bytes, content_type)
3834
+ @current_url = url.to_s
3835
+ ct = content_type.to_s.empty? ? 'text/html' : content_type.to_s
3836
+ # Blob string parts are UTF-8-encoded; when the Blob type carries no
3837
+ # charset, decode the document as UTF-8 (not the windows-1252 HTML locale
3838
+ # default, which is an HTTP concept that doesn't apply to in-memory blobs —
3839
+ # matches the iframe blob: path's decodeBlobBody). A charset in the Blob
3840
+ # type (url-charset) is preserved so it can override <meta charset>.
3841
+ ct = "#{ct};charset=utf-8" unless ct.downcase.include?('charset')
3842
+ record_response(200, {'content-type' => ct})
3843
+ b64 = Base64.strict_encode64(bytes.to_s.b)
3844
+ # Make the blob URL fetchable from the document we're about to load — a blob:
3845
+ # document that fetches itself (or a media `src` first-party load) snapshots
3846
+ # the bytes SYNCHRONOUSLY at fetch() time, which runs DURING boot below, so the
3847
+ # bytes must be registered in this window's @blob_registry FIRST. No partition
3848
+ # entry — the blob keeps its original storage partition; this only makes it
3849
+ # first-party-fetchable in the window it was navigated into.
3850
+ @blob_registry_lock.synchronize { @blob_registry[url.to_s] = b64 }
3851
+ boot_response_into_ctx(bytes)
3852
+ # Adopt into the in-VM store too, so a LATER resolve keeps the correct content
3853
+ # type (the @blob_registry b64 path resolves as application/octet-stream).
3854
+ @runtime.call('__csimAdoptBlobBytes', url.to_s, b64, content_type.to_s) rescue nil
3855
+ end
3856
+
3857
+ # A user-initiated `URL.revokeObjectURL`. Storage-partitioned + cross-isolate:
3858
+ # the Driver vetoes a cross-partition revoke (a same-origin but cross-top-level-
3859
+ # site context can't revoke the blob — cross-partition.https) and, for a
3860
+ # same-partition revoke, invalidates the blob in the CREATOR's isolate too so
3861
+ # every window stops resolving it (the blob may have been created in another
3862
+ # window). (Context-teardown revokes go through revoke_owned_blobs, not here.)
3207
3863
  def blob_unregister(url)
3208
- @blob_registry_lock.synchronize { @blob_registry.delete(url.to_s) }
3864
+ if @driver.respond_to?(:revoke_blob_partitioned)
3865
+ return nil unless @driver.revoke_blob_partitioned(url.to_s, self)
3866
+ elsif @driver.respond_to?(:unregister_blob_partition)
3867
+ @driver.unregister_blob_partition(url.to_s)
3868
+ end
3869
+ drop_local_blob(url)
3209
3870
  nil
3210
3871
  end
3211
3872
 
3873
+ # Forget a blob URL in THIS isolate: its validity marker / bytes in
3874
+ # @blob_registry and its in-VM store entry. Called for a local revoke and, via
3875
+ # the Driver, when another same-partition window revokes a blob this isolate
3876
+ # created. The @blob_registry removal is the AUTHORITATIVE invalidation
3877
+ # (resolveBlobBytes gates on it cross-realm); the in-VM `__csimDropBlob` is a
3878
+ # same-thread V8 call, so it's skipped on a worker thread — a worker's
3879
+ # `revokeObjectURL` forwards here on the WORKER thread, and calling a
3880
+ # thread-confined isolate from a non-owning thread SEGVs (V8/quickjs isolates
3881
+ # are thread-bound). The stale in-VM entry is harmless: resolveBlobBytes returns
3882
+ # null once the registry marker is gone.
3883
+ def drop_local_blob(url)
3884
+ @blob_registry_lock.synchronize { @blob_registry.delete(url.to_s); @blob_owners.delete(url.to_s) }
3885
+ return if Thread.current[:csim_worker_handle] # worker thread: the registry removal above is enough
3886
+ @runtime.call('__csimDropBlob', url.to_s) rescue nil
3887
+ end
3888
+
3889
+ # Revoke every blob URL owned by a context that's going away (its blob URL
3890
+ # store is part of the global being torn down).
3891
+ def revoke_owned_blobs(key)
3892
+ revoked = @blob_registry_lock.synchronize do
3893
+ urls = @blob_owners.select {|_url, owner| owner == key }.keys
3894
+ urls.each {|url| @blob_registry.delete(url); @blob_owners.delete(url) }
3895
+ urls
3896
+ end
3897
+ if @driver.respond_to?(:unregister_blob_partition)
3898
+ revoked.each {|url| @driver.unregister_blob_partition(url) }
3899
+ end
3900
+ end
3901
+ # Keys are normalized with `.to_i` on BOTH sides (register tags
3902
+ # "r:#{owner_realm.to_i}") so a marshalled Float/String id still matches.
3903
+ def revoke_worker_blobs(handle) = revoke_owned_blobs("w:#{handle.to_i}")
3904
+ def revoke_realm_blobs(realm_id) = revoke_owned_blobs("r:#{realm_id.to_i}")
3905
+
3212
3906
  # ── postMessage transferable-buffer registry ───────────────────
3213
3907
  #
3214
3908
  # Large Uint8Array / ArrayBuffer payloads cross isolates by ID;
@@ -3327,6 +4021,17 @@ module Capybara
3327
4021
  }.freeze
3328
4022
  private_constant :MIME_TO_VIPS_EXT
3329
4023
 
4024
+ # The canonical MIME for each format we actually encode. An unsupported
4025
+ # request type maps to '.png' below, so the encoded format (and the type
4026
+ # we report back to the canvas) is image/png — matching the toBlob /
4027
+ # toDataURL "unsupported type falls back to image/png" rule.
4028
+ EXT_TO_MIME = {
4029
+ '.jpg' => 'image/jpeg',
4030
+ '.webp' => 'image/webp',
4031
+ '.png' => 'image/png'
4032
+ }.freeze
4033
+ private_constant :EXT_TO_MIME
4034
+
3330
4035
  def encode_image(pixels_ref, width, height, mime_type = 'image/png', quality = 90)
3331
4036
  host_image_op('encode_image') {
3332
4037
  require 'vips' unless defined?(Vips)
@@ -3337,7 +4042,7 @@ module Capybara
3337
4042
  img = Vips::Image.new_from_memory_copy(raw, w, h, 4, :uchar)
3338
4043
  ext = MIME_TO_VIPS_EXT[mime_type.to_s.downcase] || '.png'
3339
4044
  opts = (ext == '.jpg' || ext == '.webp') ? {Q: quality.to_i} : {}
3340
- {'refId' => transfer_buffer_stash(img.write_to_buffer(ext, **opts))}
4045
+ {'refId' => transfer_buffer_stash(img.write_to_buffer(ext, **opts)), 'mime' => EXT_TO_MIME[ext]}
3341
4046
  }
3342
4047
  end
3343
4048
 
@@ -3347,7 +4052,20 @@ module Capybara
3347
4052
  # `build_worker` factory, evaluates the worker script, then
3348
4053
  # loops draining microtasks + timers + inbox until `:terminate`
3349
4054
  # lands or an exception propagates.
3350
- private def run_worker(handle, url, body, inbox, outbox, engine_class)
4055
+ private def run_worker(handle, url, body, inbox, outbox, engine_class, shared: false, service: false)
4056
+ # Release the spawn-time `@worker_initializing` count exactly once, however
4057
+ # this method exits (normal start, `self.close()`, or an exception), so
4058
+ # worker_pending? doesn't stay stuck true forever.
4059
+ initializing = true
4060
+ release_init = lambda do
4061
+ next unless initializing
4062
+ initializing = false
4063
+ @worker_init_lock.synchronize { @worker_initializing -= 1 }
4064
+ end
4065
+ # Tag this thread so blob URLs created by the worker's script are owned by
4066
+ # this handle and revoked on terminate (see blob_register / revoke_worker_blobs).
4067
+ Thread.current[:csim_worker_handle] = handle
4068
+ rt = nil
3351
4069
  raise "worker script not found: #{url}" unless body
3352
4070
  # The worker SCRIPT is text; the Rack-fetched body arrives
3353
4071
  # BINARY-tagged (see `RuntimeShared.utf8_text`).
@@ -3359,17 +4077,47 @@ module Capybara
3359
4077
  # resolve chunks against the worker's own origin rather than
3360
4078
  # the snapshot-time `http://placeholder/`.
3361
4079
  rt.eval("globalThis.__csimUpdateLocation(#{JSON.generate(url.to_s)});")
4080
+ # A service worker runs in a ServiceWorkerGlobalScope: adjust the worker scope
4081
+ # (no blob-URL minting; SW lifecycle stubs) BEFORE its script runs.
4082
+ rt.eval('__csim_installServiceWorkerScope();') if service
3362
4083
  rt.eval(body)
3363
- loop do
3364
- msg = pop_with_timeout(inbox, WORKER_POLL_INTERVAL)
3365
- break if msg == :terminate
3366
- rt.call('__csim_workerOnMessage', msg) if msg
4084
+ rt.drain_microtasks
4085
+ # A SharedWorker fires `connect` AFTER its script set `self.onconnect`; the
4086
+ # connect handler's port post lands in the outbox before release_init, so
4087
+ # worker_pending? stays true until it's delivered.
4088
+ if shared
4089
+ rt.eval('typeof __csimFireSharedWorkerConnect === "function" && __csimFireSharedWorkerConnect();')
3367
4090
  rt.drain_microtasks
3368
- rt.drain_timers if rt.has_ready_timer?
4091
+ end
4092
+ # Initial script has run (and any immediate postMessage is in the outbox).
4093
+ release_init.call
4094
+ # A worker that called `self.close()` in its top-level script stops here —
4095
+ # the script ran (and may have posted), but no further messages are pulled.
4096
+ unless rt.call('__csimWorkerClosedRead')
4097
+ loop do
4098
+ msg = pop_with_timeout(inbox, WORKER_POLL_INTERVAL)
4099
+ break if msg == :terminate
4100
+ rt.call('__csim_workerOnMessage', msg) if msg
4101
+ # Drive the worker's OWN event loop each tick: a message handler OR an
4102
+ # AUTONOMOUS loop (the dispatcher executor-worker's receive→fetch→setTimeout
4103
+ # retry, which has no inbox message) may have pending timers. Drain ~one poll
4104
+ # interval (WorkerRuntime#drain_timers advances the worker clock a step) so
4105
+ # they progress; worker http fetch is setTimeout(0)+__rackFetch, resolved on
4106
+ # this thread by the drain. Gated on a PENDING timer (any, not just due-now —
4107
+ # the clock must advance to fire a future randomDelay) so an idle
4108
+ # message-driven worker with no timers stays lazy. Host CALLS, not string
4109
+ # `eval`, keep the per-tick cost off the V8 compile path (rule 3).
4110
+ if rt.call('__nextTimerDelay').to_f >= 0
4111
+ rt.drain_microtasks
4112
+ rt.drain_timers
4113
+ end
4114
+ break if rt.call('__csimWorkerClosedRead')
4115
+ end
3369
4116
  end
3370
4117
  rescue StandardError => e
3371
4118
  outbox << {handle: handle, kind: '__error', message: "#{e.class}: #{e.message}"}
3372
4119
  ensure
4120
+ release_init.call # guarantee the init count is released on an early raise
3373
4121
  rt&.dispose
3374
4122
  end
3375
4123
 
@@ -3379,10 +4127,38 @@ module Capybara
3379
4127
  # circuit to the JS-side blob registry instead. Http(s) URLs
3380
4128
  # fall through to the regular Rack path.
3381
4129
  private def fetch_worker_script(url)
3382
- return rack_fetch_body(url) unless url.to_s.start_with?('blob:')
3383
- b64 = @runtime.call('__csimReadBlobBase64', url)
3384
- return nil unless b64
3385
- Base64.decode64(b64.to_s)
4130
+ u = url.to_s
4131
+ if u.start_with?('blob:')
4132
+ # Resolve via the Driver's partition store so a SAME-partition blob created
4133
+ # in ANOTHER window/isolate (the cross-partition-worker-creation test creates
4134
+ # the blob in the opener and the worker in a same-site iframe) is readable.
4135
+ # Falls back to this realm's own store when there's no Driver entry.
4136
+ if @driver.respond_to?(:blob_bytes_for) && (data = @driver.blob_bytes_for(u, self))
4137
+ return data[:bytes]
4138
+ end
4139
+ b64 = @runtime.call('__csimReadBlobBase64', u)
4140
+ return nil unless b64
4141
+ return Base64.decode64(b64.to_s)
4142
+ end
4143
+ # `data:[<mediatype>][;base64],<data>` worker scripts (a worker created
4144
+ # from a data: URL — its origin is opaque, so its blob: URLs serialize
4145
+ # with a 'null' origin). Decode inline; Rack can't serve a data: URL.
4146
+ return decode_data_url_body(u) if u.start_with?('data:')
4147
+ rack_fetch_body(u)
4148
+ end
4149
+
4150
+ # The decoded body of a `data:[<mediatype>][;base64],<data>` URL (RFC 2397):
4151
+ # base64-decoded when the `;base64` flag is present, else percent-decoded.
4152
+ private def decode_data_url_body(url)
4153
+ comma = url.index(',')
4154
+ return '' unless comma
4155
+ meta = url[5...comma]
4156
+ payload = url[(comma + 1)..]
4157
+ if meta =~ /;base64\s*\z/i
4158
+ Base64.decode64(payload)
4159
+ else
4160
+ CGI.unescape(payload)
4161
+ end
3386
4162
  end
3387
4163
 
3388
4164
  # `Thread::Queue#pop(timeout:)` blocks releasing the GVL — fine
@@ -3467,12 +4243,14 @@ module Capybara
3467
4243
  headers = headers.reject {|k, _| k == 'X-Csim-Body-B64' }
3468
4244
  end
3469
4245
  MAX_FETCH_REDIRECTS.times do
4246
+ t0 = @trace && Process.clock_gettime(Process::CLOCK_MONOTONIC)
3470
4247
  # GET-only cache shortcut (RFC 9111). Fresh hit → skip @app.call
3471
4248
  # entirely; stale-but-revalidatable → fall through with conditional
3472
4249
  # headers added so the server can return 304.
3473
4250
  cache_entry = method == 'GET' ? @@asset_cache.lookup(target) : nil
3474
4251
  if cache_entry&.fresh?
3475
- log_network(method, target, cache_entry.status)
4252
+ # Cached static asset — log headers/type/size but skip the (boring) body.
4253
+ trace_network(method, target, cache_entry.status, headers, body, cache_entry.headers, nil, t0, false)
3476
4254
  return response_hash(cache_entry.status, cache_entry.headers, cache_entry.body, target, redirected)
3477
4255
  end
3478
4256
 
@@ -3483,14 +4261,16 @@ module Capybara
3483
4261
  env.merge!(env_extras) if env_extras
3484
4262
  status, resp_headers, resp_body = dispatch_rack_or_http(target, env, method: method, body: body)
3485
4263
  merge_set_cookie(resp_headers)
3486
- log_network(method, target, status)
3487
4264
  if status == 304 && cache_entry
4265
+ trace_network(method, target, cache_entry.status, headers, body, cache_entry.headers, nil, t0, false)
3488
4266
  resp_body.close if resp_body.respond_to?(:close)
3489
4267
  @@asset_cache.refresh(cache_entry, resp_headers)
3490
4268
  return response_hash(cache_entry.status, cache_entry.headers, cache_entry.body, target, redirected)
3491
4269
  end
3492
4270
  if redirect_mode != 'manual' && (loc = redirect_location(status, resp_headers))
3493
4271
  raise StandardError, '[capybara-simulated] fetch: redirect blocked by redirect=error mode' if redirect_mode == 'error'
4272
+ # Log this hop (3xx) before method/body are rewritten for the next.
4273
+ trace_network(method, target, status, headers, body, resp_headers, nil, t0, true)
3494
4274
  redirected = true
3495
4275
  preserve = [307, 308].include?(status)
3496
4276
  next_url = resolve_against(loc, target)
@@ -3501,6 +4281,7 @@ module Capybara
3501
4281
  next
3502
4282
  end
3503
4283
  body_str = read_rack_body(resp_body)
4284
+ trace_network(method, target, status, headers, body, resp_headers, body_str, t0, false)
3504
4285
  @@asset_cache.store(target, status, resp_headers, body_str) if method == 'GET'
3505
4286
  return response_hash(status, resp_headers, body_str, target, redirected)
3506
4287
  end
@@ -3510,6 +4291,63 @@ module Capybara
3510
4291
  nil
3511
4292
  end
3512
4293
 
4294
+ # Cap per-body capture so one big asset/response can't bloat the
4295
+ # trace. Generous (this is a local debugging artifact).
4296
+ NETWORK_BODY_CAP = 256 * 1024
4297
+
4298
+ # Enriched network log for the trace: response content-type / byte
4299
+ # size / elapsed ms / redirect flag, plus request + response headers
4300
+ # and bodies (devtools-style). No-ops — and skips all the lookups —
4301
+ # unless a trace is recording, so the fetch hot path is unaffected
4302
+ # when tracing is off.
4303
+ def trace_network(method, url, status, req_headers, req_body, resp_headers, resp_body, t0, redirected)
4304
+ return unless @trace
4305
+ ct = resp_headers && (resp_headers['content-type'] || resp_headers['Content-Type'])
4306
+ ct = ct.first if ct.is_a?(Array) # Rack 3 permits array-valued header fields
4307
+ ct = ct.split(';', 2).first.strip if ct.is_a?(String)
4308
+ size = if resp_body
4309
+ resp_body.bytesize
4310
+ elsif (cl = resp_headers && (resp_headers['content-length'] || resp_headers['Content-Length']))
4311
+ (cl.is_a?(Array) ? cl.first : cl).to_i
4312
+ end
4313
+ log_network(method, url, status,
4314
+ content_type: (ct if ct.is_a?(String)),
4315
+ size: size,
4316
+ duration_ms: (t0 && ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round),
4317
+ redirected: (redirected || nil),
4318
+ request_headers: normalize_trace_headers(req_headers),
4319
+ request_body: (req_body && !req_body.to_s.empty? ? cap_trace_body(req_body) : nil),
4320
+ response_headers: normalize_trace_headers(resp_headers),
4321
+ response_body: (resp_body ? cap_trace_body(resp_body) : nil))
4322
+ rescue StandardError => e
4323
+ # A trace-logging bug must NEVER break the real fetch: rack_fetch's
4324
+ # own `rescue StandardError` would otherwise swallow it and return
4325
+ # nil, so the asset (e.g. jQuery) silently fails to load. Drop the
4326
+ # log entry instead.
4327
+ warn "capybara-simulated: trace network log failed: #{e.class}: #{e.message}"
4328
+ end
4329
+
4330
+ # JSON-safe body for the trace: binary (non-UTF-8) bodies become a
4331
+ # placeholder rather than mojibake, and long bodies are truncated
4332
+ # (scrubbed so a mid-codepoint cut can't yield invalid UTF-8).
4333
+ #
4334
+ # Rack response bodies are ASCII-8BIT (BINARY); reinterpret the bytes as
4335
+ # UTF-8 up front and keep working in UTF-8 throughout. Otherwise a body
4336
+ # whose bytes ARE valid UTF-8 but stays BINARY-tagged would flow out of
4337
+ # here still BINARY, and the first concat with a UTF-8 string (the
4338
+ # truncation marker here, or the trace-buffer / JSON serialization
4339
+ # downstream) raises Encoding::CompatibilityError on any byte ≥ 0x80.
4340
+ def cap_trace_body(body)
4341
+ s = body.to_s.dup.force_encoding('UTF-8')
4342
+ return "[binary, #{s.bytesize} bytes]" unless s.valid_encoding?
4343
+ s.bytesize > NETWORK_BODY_CAP ? (s.byteslice(0, NETWORK_BODY_CAP).scrub + "\n…[truncated, #{s.bytesize} bytes total]") : s
4344
+ end
4345
+
4346
+ def normalize_trace_headers(headers)
4347
+ return nil unless headers
4348
+ headers.each_with_object({}) {|(k, v), out| out[k.to_s] = v.is_a?(Array) ? v.join(', ') : v.to_s }
4349
+ end
4350
+
3513
4351
  # CGI convention: `Content-Type` and `Content-Length` land in env
3514
4352
  # *without* the HTTP_ prefix. Rails / Rack params parsing reads
3515
4353
  # `CONTENT_TYPE` and dispatches JSON / multipart parsers off it;
@@ -3546,7 +4384,14 @@ module Capybara
3546
4384
  # encoding per the HTML "decode" algorithm and is removed). The real
3547
4385
  # bytes for binary consumers ride `body_b64`; the Rack body arrives
3548
4386
  # BINARY-tagged (see `RuntimeShared.utf8_text`).
3549
- text = RuntimeShared.utf8_text(is_text ? decode_response_bom(raw) : raw)
4387
+ bom_charset = nil
4388
+ text =
4389
+ if is_text
4390
+ decoded, bom_charset = decode_response_bom(raw)
4391
+ RuntimeShared.utf8_text(decoded)
4392
+ else
4393
+ RuntimeShared.utf8_text(raw)
4394
+ end
3550
4395
  out = {
3551
4396
  'status' => status,
3552
4397
  'headers' => hdrs,
@@ -3555,29 +4400,66 @@ module Capybara
3555
4400
  'redirected' => redirected,
3556
4401
  'type' => 'basic'
3557
4402
  }
4403
+ # The BOM-detected encoding (if any) — a frame load pins its document's
4404
+ # characterSet to it (see __csimFrameWindow); highest-precedence signal.
4405
+ out['charset'] = bom_charset if bom_charset
3558
4406
  out['body_b64'] = Base64.strict_encode64(raw) unless is_text
3559
4407
  out
3560
4408
  end
3561
4409
 
3562
- # Strip + decode a single leading byte-order mark, mapping the body to a
3563
- # UTF-8 Ruby string. No BOM → return the bytes untouched (the hot path:
3564
- # just a 2–3 byte prefix check). One BOM is consumed; any further BOMs are
3565
- # ordinary U+FEFF characters in the decoded text (per spec the parser does
3566
- # not strip them again).
4410
+ # Strip + decode a single leading byte-order mark, returning
4411
+ # `[utf8_text, charset]` `charset` is the BOM-selected Encoding-standard
4412
+ # name (highest-precedence encoding signal) or nil when there's no BOM (the
4413
+ # hot path: just a 2–3 byte prefix check). One BOM is consumed; any further
4414
+ # BOMs are ordinary U+FEFF characters in the decoded text (per spec the
4415
+ # parser does not strip them again).
4416
+ # An XML-family document (XHTML / SVG / application+text/xml). Its encoding
4417
+ # default is UTF-8 — the windows-1252 locale default is HTML-only.
4418
+ def xml_content_type?(content_type)
4419
+ mime = content_type.to_s.split(';', 2).first.to_s.strip.downcase
4420
+ mime.end_with?('+xml') || mime == 'application/xml' || mime == 'text/xml'
4421
+ end
4422
+
4423
+ # Does the response carry an explicit encoding signal (so the default
4424
+ # windows-1252 decode must NOT apply)? A `charset=` in the Content-Type, or
4425
+ # a `<meta charset>` / `<meta http-equiv=content-type … charset=…>` in the
4426
+ # HTML prescan window (the first 1024 bytes, per the HTML sniffing algorithm).
4427
+ # The `charset` must start a real attribute / content-charset (preceded by
4428
+ # whitespace, a quote, or `;`), so hyphenated look-alikes — `data-charset=`,
4429
+ # `accept-charset=` — don't false-trigger the signal.
4430
+ def html_charset_signal?(content_type, raw)
4431
+ return true if /;\s*charset\s*=/i.match?(content_type.to_s)
4432
+ head = raw.to_s.b[0, 1024].to_s
4433
+ /<meta\b[^>]*[\s"';]charset\s*=/i.match?(head)
4434
+ end
4435
+
4436
+ # Decode bytes as windows-1252 (the HTML locale-default encoding) to a UTF-8
4437
+ # Ruby string. Replaces undefined slots rather than raising.
4438
+ def decode_windows1252(s)
4439
+ s.to_s.b.dup.force_encoding(Encoding::WINDOWS_1252)
4440
+ .encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
4441
+ rescue StandardError
4442
+ RuntimeShared.utf8_text(s)
4443
+ end
4444
+
3567
4445
  def decode_response_bom(s)
3568
4446
  b = s.b
3569
4447
  if b.start_with?("\xEF\xBB\xBF".b)
3570
- b.byteslice(3..).force_encoding(Encoding::UTF_8)
4448
+ [b.byteslice(3..).force_encoding(Encoding::UTF_8), 'UTF-8']
3571
4449
  elsif b.start_with?("\xFF\xFE".b) || b.start_with?("\xFE\xFF".b)
3572
4450
  # Generic UTF-16: the BOM picks endianness and is dropped by the decoder.
3573
4451
  # Replace malformed units rather than raising (a truncated/odd-length
3574
4452
  # body still yields readable UTF-8 instead of falling back to raw bytes).
3575
- b.force_encoding(Encoding::UTF_16).encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
4453
+ # A UTF-32LE BOM (FF FE 00 00) is matched here as UTF-16LE too — which is
4454
+ # exactly what browsers do (UTF-32 unsupported; the leading FF FE is read
4455
+ # as the UTF-16LE BOM).
4456
+ charset = b.start_with?("\xFF\xFE".b) ? 'UTF-16LE' : 'UTF-16BE'
4457
+ [b.force_encoding(Encoding::UTF_16).encode(Encoding::UTF_8, invalid: :replace, undef: :replace), charset]
3576
4458
  else
3577
- s
4459
+ [s, nil]
3578
4460
  end
3579
4461
  rescue StandardError
3580
- s
4462
+ [s, nil]
3581
4463
  end
3582
4464
 
3583
4465
  def text_response?(headers)
@@ -3623,6 +4505,83 @@ module Capybara
3623
4505
  navigate(url)
3624
4506
  end
3625
4507
  end
4508
+ # A nested browsing context navigating its OWN `location` (the frame's
4509
+ # `location.href`/assign/replace/`location=`, incl. cross-frame
4510
+ # `iframe.contentWindow.location.href = …`). `realm_id` is the frame's realm.
4511
+ # Deferred like location_assign: applying it re-navigates the owning iframe,
4512
+ # which disposes that realm — illegal while the frame's location setter is
4513
+ # still on the V8 stack — so we stash and drain from `tick_real_time`.
4514
+ def frame_navigate_self(url, realm_id)
4515
+ return if realm_id.nil? || realm_id.zero?
4516
+ # Keyed by realm id (last URL wins per frame) so two different frames each
4517
+ # navigating in one turn both apply — a single slot would drop one.
4518
+ (@pending_frame_nav ||= {})[realm_id] = url.to_s
4519
+ end
4520
+ def consume_pending_frame_nav
4521
+ # Window-realm self-navs are realm navs too — drain them at every frame-nav
4522
+ # drain point (before the frame-nav early-return so a window-only nav lands).
4523
+ consume_pending_window_nav
4524
+ return if @pending_frame_nav.nil? || @pending_frame_nav.empty?
4525
+ navs = @pending_frame_nav
4526
+ @pending_frame_nav = nil
4527
+ navs.each do |realm_id, url|
4528
+ invalidate_find_cache
4529
+ # If this frame is on the entered `within_frame` stack, navigate it
4530
+ # through `navigate_frame` — it does the full fetch (redirects /
4531
+ # downloads / cookies) AND updates `@frame_stack` / `@current_realm_id`
4532
+ # so the enclosing `within_frame` block sees the new document. Otherwise
4533
+ # (a parent's `iframe.contentWindow.location.href = …`) re-navigate the
4534
+ # owning iframe by realm id via the src-reassignment path. Top-level
4535
+ # frames live in the main document; a nested non-entered frame's element
4536
+ # is in its parent realm's DOM (not yet routed — documented gap).
4537
+ entry = @frame_stack.find {|e| e[:realm_id] == realm_id }
4538
+ if entry
4539
+ navigate_frame(url, entry: entry)
4540
+ else
4541
+ @runtime.call('__csimNavigateFrameByRealm', realm_id, url)
4542
+ end
4543
+ rescue StandardError => e
4544
+ log_console('warn', "frame self-navigation failed: #{e.message}")
4545
+ end
4546
+ end
4547
+ # A same-origin WINDOW realm (window.open in this isolate) navigating itself
4548
+ # via `win.location = …`. Like frame_navigate_self, defer (the call lands here
4549
+ # from the realm's own location setter, mid-flight) and drain after the action.
4550
+ # A blob: URL is resolved to bytes NOW — before the opener's typical immediate
4551
+ # `revokeObjectURL` — since the realm reload happens later (url-in-tags-revoke).
4552
+ def window_realm_navigate_self(url, realm_id)
4553
+ return if realm_id.nil? || realm_id.zero?
4554
+ spec = {url: url.to_s}
4555
+ if url.to_s.start_with?('blob:') && (b = read_blob_for_window(url.to_s))
4556
+ # The blob's bytes arrive BINARY-tagged (Base64-decoded). __csimLoadDocument
4557
+ # HTML-parses TEXT, and a BINARY string marshals to V8 as a Uint8Array (not a
4558
+ # String), which `String(...)`s to comma-joined digits — a script-less doc. Decode
4559
+ # to UTF-8 text like every other load path (see RuntimeShared.utf8_text).
4560
+ spec[:body] = RuntimeShared.utf8_text(b[:bytes])
4561
+ spec[:ctype] = b[:type].to_s.empty? ? 'text/html' : b[:type]
4562
+ end
4563
+ (@pending_window_nav ||= {})[realm_id] = spec
4564
+ end
4565
+ def consume_pending_window_nav
4566
+ return if @pending_window_nav.nil? || @pending_window_nav.empty?
4567
+ navs = @pending_window_nav
4568
+ @pending_window_nav = nil
4569
+ navs.each do |realm_id, spec|
4570
+ invalidate_find_cache
4571
+ body, ctype = spec[:body], spec[:ctype]
4572
+ # http(s) / relative URL window-realm nav (rack fetch) is not modeled yet —
4573
+ # only the blob/in-memory document case (which pre-resolved bytes above)
4574
+ # loads here. Warn rather than silently drop so an unsupported popup
4575
+ # navigation is diagnosable instead of looking like a frozen about:blank.
4576
+ if body.nil?
4577
+ log_console('warn', "window-realm navigation to #{spec[:url]} not modeled (only blob: documents load); ignoring")
4578
+ next
4579
+ end
4580
+ @runtime.reload_window_realm(realm_id, spec[:url], body.to_s, ctype.to_s)
4581
+ rescue StandardError => e
4582
+ log_console('warn', "window realm self-navigation failed: #{e.message}")
4583
+ end
4584
+ end
3626
4585
  # Mirror of `location_assign`'s deferral for `location.reload()`:
3627
4586
  # the JS call lands here from `__locationReload`; running
3628
4587
  # `browser.refresh` directly would `navigate` (rebuilding the
@@ -3635,10 +4594,324 @@ module Capybara
3635
4594
  @pending_reload = false
3636
4595
  refresh
3637
4596
  end
4597
+ # `frame.contentWindow.location.reload()` from a nested browsing context.
4598
+ # Like `frame_navigate_self`, the JS side flags the initiating realm here
4599
+ # and we defer (so the child realm isn't disposed mid-reload()). Keyed by
4600
+ # realm id so two frames reloading in one turn both apply.
4601
+ def frame_reload_self(realm_id)
4602
+ return if realm_id.nil? || realm_id.zero?
4603
+ (@pending_frame_reload ||= []) << realm_id
4604
+ end
4605
+ def consume_pending_frame_reload
4606
+ return if @pending_frame_reload.nil? || @pending_frame_reload.empty?
4607
+ realm_ids = @pending_frame_reload.uniq
4608
+ @pending_frame_reload = nil
4609
+ realm_ids.each do |realm_id|
4610
+ invalidate_find_cache
4611
+ # An entered `within_frame` frame reloads through `navigate_frame` (keeps
4612
+ # the frame stack in sync) — re-fetching its current document URL, which
4613
+ # we read from the still-alive realm. (A blob: URL entered this way is
4614
+ # re-fetched through Rack and so does NOT reuse retained bytes — reloading
4615
+ # an *entered* revoked-blob frame is an accepted gap; the common parent-
4616
+ # held path below reuses bytes via reloadFrame.) Otherwise (a parent's
4617
+ # `iframe.contentWindow.location.reload()`, empty href, or a realm torn
4618
+ # down between flag and drain) re-navigate the owning iframe by realm id
4619
+ # JS-side, reusing the retained content so blob bytes survive a revoke.
4620
+ entry = @frame_stack.find {|e| e[:realm_id] == realm_id }
4621
+ url = entry && @runtime.frame_realm_alive?(realm_id) ? @runtime.realm_call(realm_id, '__csimLocationHref').to_s : ''
4622
+ if entry && !url.empty?
4623
+ navigate_frame(url, entry: entry)
4624
+ else
4625
+ @runtime.call('__csimReloadFrameByRealm', realm_id)
4626
+ end
4627
+ rescue StandardError => e
4628
+ log_console('warn', "frame self-reload failed: #{e.message}")
4629
+ end
4630
+ end
4631
+ # A <form> submitted from INSIDE a nested browsing context (a frame realm
4632
+ # reached via `contentWindow`, not an entered `within_frame` block). The
4633
+ # pending-submit slot lives on the initiating realm's globalThis, which no
4634
+ # top-page drain reads, so the JS side flags the realm here (mirrors
4635
+ # `frame_navigate_self`). Keyed by realm id; deferred + drained from
4636
+ # `drain_pending_navigation` so we never serialize/navigate while the
4637
+ # form's `submit()` is still on the V8 stack.
4638
+ def frame_submit_self(realm_id)
4639
+ return if realm_id.nil? || realm_id.zero?
4640
+ (@pending_frame_submit ||= []) << realm_id
4641
+ end
4642
+ def consume_pending_frame_submit
4643
+ return if @pending_frame_submit.nil? || @pending_frame_submit.empty?
4644
+ realm_ids = @pending_frame_submit.uniq
4645
+ @pending_frame_submit = nil
4646
+ realm_ids.each do |realm_id|
4647
+ next unless @runtime.frame_realm_alive?(realm_id)
4648
+ sub = @runtime.realm_call(realm_id, '__csimTakePendingFormSubmit')
4649
+ next unless sub.is_a?(Hash) && sub['formHandle']
4650
+ invalidate_find_cache
4651
+ submit_form_in_realm(realm_id, sub['formHandle'], sub['submitterHandle'], sub['entryList'])
4652
+ rescue StandardError => e
4653
+ log_console('warn', "nested-context form submission failed: #{e.message}")
4654
+ end
4655
+ end
4656
+ # ── Frame session history ──────────────────────────────────────────────
4657
+ # A nested browsing context (iframe) keeps its OWN back/forward history of
4658
+ # the documents it navigates through, with each entry's form-control state
4659
+ # captured for restoration (HTML "persisted user state" / bfcache). Keyed by
4660
+ # [parent realm, iframe element handle] — stable across the frame-realm
4661
+ # rebuilds a navigation triggers (the element outlives its realm).
4662
+ #
4663
+ # `iframe.contentWindow.history.back()` runs while the frame realm is on the
4664
+ # V8 stack, so (like frame_navigate_self) DEFER the traversal and drain it
4665
+ # after the call returns — rebuilding the realm inline would terminate it.
4666
+ def frame_history_go(realm_id, delta)
4667
+ return if realm_id.nil? || realm_id.zero?
4668
+ @pending_frame_traverse = {realm_id: realm_id, delta: delta.to_i}
4669
+ end
4670
+ def consume_pending_frame_traverse
4671
+ return if @pending_frame_traverse.nil?
4672
+ pt = @pending_frame_traverse
4673
+ @pending_frame_traverse = nil
4674
+ invalidate_find_cache
4675
+ perform_frame_traverse(pt[:realm_id], pt[:delta])
4676
+ rescue StandardError => e
4677
+ log_console('warn', "frame history traversal failed: #{e.message}")
4678
+ end
4679
+ def perform_frame_traverse(realm_id, delta)
4680
+ # The traversal was deferred; the frame may have been disposed (a competing
4681
+ # nav) between flag and drain — drop it gracefully.
4682
+ return unless @runtime.frame_realm_alive?(realm_id)
4683
+ parent = @runtime.frame_realm_parent(realm_id)
4684
+ handle = frame_container_handle(realm_id, parent)
4685
+ return if handle.zero?
4686
+ h = (@frame_histories ||= {})[[parent, handle]]
4687
+ return if h.nil?
4688
+ target = h[:idx] + delta
4689
+ return if target.negative? || target >= h[:entries].size
4690
+ # Snapshot the entry we're leaving so a later forward traversal restores it.
4691
+ h[:entries][h[:idx]] = frame_history_entry(realm_id) if h[:idx] >= 0
4692
+ reload_frame_to_entry(realm_id, h[:entries][target])
4693
+ h[:idx] = target # advance only after the rebuild succeeds
4694
+ end
4695
+ # Record a frame navigation away from `realm_id` to `new_url`: snapshot the
4696
+ # OUTGOING document (URL + form state) into the current entry — seeding entry
4697
+ # 0 the first time — then drop any forward tail and push the new entry. Hooked
4698
+ # into the frame form-submission paths; frame navigations driven by
4699
+ # `location.href` / link clicks aren't recorded yet (history.back there falls
4700
+ # through to the top document, as before).
4701
+ def record_frame_nav(realm_id, new_url)
4702
+ return if realm_id.nil? || realm_id.zero?
4703
+ parent = @runtime.frame_realm_parent(realm_id)
4704
+ handle = frame_container_handle(realm_id, parent)
4705
+ return if handle.zero?
4706
+ h = (@frame_histories ||= {})[[parent, handle]] ||= {entries: [], idx: -1}
4707
+ outgoing = frame_history_entry(realm_id)
4708
+ if h[:idx] >= 0
4709
+ h[:entries][h[:idx]] = outgoing
4710
+ else
4711
+ h[:entries] << outgoing
4712
+ h[:idx] = 0
4713
+ end
4714
+ h[:entries] = h[:entries][0..h[:idx]]
4715
+ h[:entries] << {url: new_url.to_s, form_state: nil}
4716
+ h[:idx] = h[:entries].size - 1
4717
+ end
4718
+ # The history entry for the document currently loaded in `realm_id`: its URL
4719
+ # plus a snapshot of its form-control state.
4720
+ def frame_history_entry(realm_id)
4721
+ {url: frame_realm_url(realm_id), form_state: capture_frame_form_state(realm_id)}
4722
+ end
4723
+ def frame_realm_url(realm_id)
4724
+ return nil unless @runtime.frame_realm_alive?(realm_id)
4725
+ @runtime.realm_call(realm_id, '__csimLocationHref').to_s
4726
+ rescue StandardError
4727
+ nil
4728
+ end
4729
+ def capture_frame_form_state(realm_id)
4730
+ return nil unless @runtime.frame_realm_alive?(realm_id)
4731
+ @runtime.realm_call(realm_id, '__csimCaptureFormState')
4732
+ rescue StandardError
4733
+ nil
4734
+ end
4735
+ # Re-fetch a history entry's URL and rebuild the frame realm from it, then
4736
+ # restore the entry's captured form state (before the element load fires).
4737
+ def reload_frame_to_entry(realm_id, entry)
4738
+ url = entry[:url].to_s
4739
+ return if url.empty?
4740
+ env = Rack::MockRequest.env_for(url, method: 'GET')
4741
+ apply_default_request_env(env, referer: current_browsing_context_url)
4742
+ status, headers, body = dispatch_rack_or_http(url, env, method: 'GET')
4743
+ merge_set_cookie(headers)
4744
+ return if download_response?(headers)
4745
+ html = read_rack_body(body)
4746
+ reload_frame_realm_by_id(realm_id, url, html, response_content_type(headers), restore_state: entry[:form_state])
4747
+ end
4748
+ # Serialize + route a form submitted inside frame realm `realm_id`. We
4749
+ # serialize in the INITIATING realm (so shadow-tree controls are excluded
4750
+ # and relative URLs resolve against that document), then route by target:
4751
+ # - a NAMED frame within that context (a sibling iframe) — reassign its src;
4752
+ # - self / _self / '' — navigate the initiating frame itself, same as a
4753
+ # self-targeted link there (within_frame → navigate_frame; a frame
4754
+ # reached via contentWindow → re-navigate its owning iframe by realm id).
4755
+ # GET fully supported. POST to a self frame needs the entered stack
4756
+ # (navigate_frame_post); POST-to-named and other targets from a nested
4757
+ # context aren't modeled (no in-scope need) — logged rather than dropped.
4758
+ def submit_form_in_realm(realm_id, form_handle, submitter_handle, entry_list = nil)
4759
+ spec = @runtime.realm_call(realm_id, '__csimFormSerialize', form_handle, submitter_handle || 0)
4760
+ return unless spec.is_a?(Hash)
4761
+ method = spec['method'].to_s.upcase
4762
+ method = 'GET' if method.empty?
4763
+ target = spec['target'].to_s
4764
+ action = spec['action'].to_s
4765
+ enctype = spec['enctype'].to_s.empty? ? 'application/x-www-form-urlencoded' : spec['enctype'].to_s.downcase
4766
+ entries = entry_list.is_a?(Array) ? entry_list : entries_from_spec(spec)
4767
+ # GET → urlencoded query (enctype ignored); POST → enctype-encoded body.
4768
+ get_query, = encode_entry_list(entries, 'application/x-www-form-urlencoded')
4769
+ get_url = form_get_url(action, get_query)
4770
+ if frame_self_target?(target)
4771
+ if method == 'GET'
4772
+ navigate_realm_self_get(realm_id, get_url)
4773
+ else
4774
+ body, content_type = encode_entry_list(entries, enctype)
4775
+ navigate_realm_self_post(realm_id, resolve_against_current(action), body, content_type)
4776
+ end
4777
+ elsif %w[_parent _top _blank].include?(target.downcase)
4778
+ log_console('warn', "nested-context form submit (target=#{target.inspect}) is not modeled")
4779
+ elsif method == 'GET'
4780
+ # Named sibling frame, GET. realm_call returns false when no frame of
4781
+ # that name exists in the initiating document (e.g. it lives in an
4782
+ # ancestor/top context, which HTML target resolution would reach but
4783
+ # we don't); surface it rather than dropping silently.
4784
+ found = @runtime.realm_call(realm_id, '__csimNavigateNamedFrame', target, get_url)
4785
+ log_console('warn', "nested-context form submit: no frame named #{target.inspect} in the submitting document") unless found
4786
+ else
4787
+ log_console('warn', "nested-context form submit (target=#{target.inspect}, method=POST) is not modeled")
4788
+ end
4789
+ end
4790
+ # HTML form-submission "mutate action URL" for GET: REPLACE the action
4791
+ # URL's query with the serialized entry list (dropping any pre-existing
4792
+ # query), preserving a trailing #fragment. String-based so it works on the
4793
+ # raw (possibly relative) action attribute without URI.parse fragility;
4794
+ # the absolute equivalent of submit_form_handle's `uri.query = body`. An
4795
+ # EMPTY entry list still clears the query (→ `action?`), matching browsers.
4796
+ def form_get_url(action, body)
4797
+ base, _hash, frag = action.partition('#')
4798
+ path = base.split('?', 2).first
4799
+ url = "#{path}?#{body}"
4800
+ frag.empty? ? url : "#{url}##{frag}"
4801
+ end
4802
+ # A self-targeted GET form submit in the initiating frame realm: navigate
4803
+ # that frame to the action URL (query already mutated in).
4804
+ def navigate_realm_self_get(realm_id, get_url)
4805
+ record_frame_nav(realm_id, get_url)
4806
+ entry = @frame_stack.find {|e| e[:realm_id] == realm_id }
4807
+ if entry
4808
+ navigate_frame(resolve_against_current(get_url), entry: entry)
4809
+ else
4810
+ # A frame reached via contentWindow (not on the entered stack): its
4811
+ # owning iframe lives in its PARENT realm's document (the main realm for
4812
+ # a top-level frame, an intermediate realm for a nested one), so route
4813
+ # the by-realm src-reassignment there — not unconditionally to main.
4814
+ # (relative get_url resolves against the frame's base on rebuild).
4815
+ parent = @runtime.frame_realm_parent(realm_id)
4816
+ frame_realm_host_call(parent, '__csimNavigateFrameByRealm', realm_id, get_url)
4817
+ end
4818
+ end
4819
+ # A self-targeted POST form submit in the initiating frame realm. POST the
4820
+ # entity body to the action URL, then rebuild that frame's realm from the
4821
+ # response. An ENTERED frame (on @frame_stack) reuses navigate_frame_post;
4822
+ # a frame reached via contentWindow has no stack entry, so rebuild it by
4823
+ # realm id (recovering its container element + parent realm) and fire the
4824
+ # iframe element's load event the GET/src path would.
4825
+ def navigate_realm_self_post(realm_id, url, body, content_type, depth: 0)
4826
+ raise 'too many redirects' if depth > 10
4827
+ record_frame_nav(realm_id, url) if depth.zero?
4828
+ entry = @frame_stack.find {|e| e[:realm_id] == realm_id }
4829
+ return navigate_frame_post(url, body, content_type, entry: entry) if entry
4830
+ invalidate_find_cache
4831
+ env = Rack::MockRequest.env_for(url, method: 'POST', input: body)
4832
+ env['CONTENT_TYPE'] = content_type.to_s.empty? ? 'application/x-www-form-urlencoded' : content_type
4833
+ env['CONTENT_LENGTH'] = body.bytesize.to_s
4834
+ apply_default_request_env(env, referer: current_browsing_context_url)
4835
+ status, headers, resp_body = dispatch_rack_or_http(url, env, method: 'POST', body: body)
4836
+ merge_set_cookie(headers)
4837
+ if (loc = redirect_location(status, headers))
4838
+ next_url = resolve_against_current(loc)
4839
+ resp_body.close if resp_body.respond_to?(:close)
4840
+ # 307/308 preserve method + body; 301/302/303 → GET the frame (routed
4841
+ # through the realm that OWNS the iframe, as in navigate_realm_self_get).
4842
+ if [307, 308].include?(status)
4843
+ return navigate_realm_self_post(realm_id, next_url, body, content_type, depth: depth + 1)
4844
+ end
4845
+ parent = @runtime.frame_realm_parent(realm_id)
4846
+ return frame_realm_host_call(parent, '__csimNavigateFrameByRealm', realm_id, next_url)
4847
+ end
4848
+ if download_response?(headers)
4849
+ save_downloaded_response(url, headers, resp_body)
4850
+ return
4851
+ end
4852
+ reload_frame_realm_by_id(realm_id, url.to_s, read_rack_body(resp_body), response_content_type(headers))
4853
+ end
4854
+ # Rebuild a frame realm reached via contentWindow (no @frame_stack entry):
4855
+ # recover its container element handle + parent realm, swap in a fresh realm
4856
+ # built from `html`, re-point the iframe at it, and fire the element load.
4857
+ def reload_frame_realm_by_id(realm_id, url, html, content_type, restore_state: nil)
4858
+ parent = @runtime.frame_realm_parent(realm_id)
4859
+ handle = frame_container_handle(realm_id, parent)
4860
+ return if handle.zero?
4861
+ new_id = @runtime.reload_frame_realm(realm_id, parent.to_i, url, RuntimeShared.utf8_text(html), content_type).to_i
4862
+ return if new_id.zero?
4863
+ begin
4864
+ rebind_frame_realm(parent, handle, realm_id, new_id)
4865
+ # Restore captured form state (history traversal) BEFORE the element load
4866
+ # fires, so the restored values are in place by the time the parent's
4867
+ # `iframe.onload` handler — and any assertion after it — runs.
4868
+ @runtime.realm_call(new_id, '__csimRestoreFormState', restore_state) if restore_state
4869
+ frame_realm_host_call(parent, '__csimFireFrameElementLoad', handle)
4870
+ rescue StandardError
4871
+ # The element rebind/load failed — don't strand the freshly built realm
4872
+ # (it's no longer referenced by any iframe), then surface the error.
4873
+ @runtime.dispose_frame_realm(new_id)
4874
+ raise
4875
+ end
4876
+ invalidate_find_cache
4877
+ settle
4878
+ new_id
4879
+ end
4880
+ # The iframe/frame element handle that owns `realm_id`, found in the document
4881
+ # of its parent realm (main realm for a top-level frame).
4882
+ def frame_container_handle(realm_id, parent)
4883
+ frame_realm_host_call(parent, '__csimGetFrameHandle', realm_id).to_i
4884
+ end
4885
+ # Call a host fn in the realm that OWNS an iframe (main realm for 0/nil).
4886
+ def frame_realm_host_call(parent_realm_id, fn, *args)
4887
+ if parent_realm_id.nil? || parent_realm_id.zero?
4888
+ @runtime.call(fn, *args)
4889
+ else
4890
+ @runtime.realm_call(parent_realm_id, fn, *args)
4891
+ end
4892
+ end
3638
4893
  def drain_pending_navigation
3639
4894
  consume_pending_location
4895
+ consume_pending_frame_nav
4896
+ consume_pending_frame_submit
4897
+ consume_pending_frame_reload
4898
+ consume_pending_frame_traverse
3640
4899
  consume_pending_reload
3641
4900
  consume_pending_history_traverse
4901
+ consume_pending_aux_window
4902
+ end
4903
+
4904
+ # A script-driven `anchor.click()` / `target=_blank` navigation with no
4905
+ # Capybara action behind it (e.g. a WPT test) — open the aux window from the
4906
+ # event-loop drain. Safe mid-call (builds a separate Browser). Same-window /
4907
+ # frame navs are left untouched (handled by drain_after_user_action).
4908
+ def consume_pending_aux_window
4909
+ pending = @runtime.call('__csimTakePendingAuxWindow')
4910
+ return unless pending.is_a?(Hash) && pending['url'] && @driver.respond_to?(:open_aux_window)
4911
+ @driver.open_aux_window(resolve_against_current(pending['url'].to_s, use_base: true),
4912
+ source: self, blob_snapshot: pending['blob'])
4913
+ rescue StandardError => e
4914
+ log_console('warn', "aux-window open failed: #{e.message}")
3642
4915
  end
3643
4916
  # POST-after-POST resubmits with the original body; GET-after-GET
3644
4917
  # is just a re-GET. Replay the current history entry.
@@ -3918,7 +5191,10 @@ module Capybara
3918
5191
  boot_response_into_ctx('')
3919
5192
  return
3920
5193
  end
3921
- record_history({method: :get, url: url}) unless from_history || depth > 0
5194
+ unless from_history || depth > 0
5195
+ capture_outgoing_form_state
5196
+ record_history({method: :get, url: url})
5197
+ end
3922
5198
  env = Rack::MockRequest.env_for(url, method: 'GET')
3923
5199
  apply_default_request_env(env, referer: referer)
3924
5200
  status, headers, body = dispatch_rack_or_http(url, env, method: 'GET')
@@ -3985,13 +5261,35 @@ module Capybara
3985
5261
  @runtime.rebuild_ctx
3986
5262
  # A full page (re)build disposes every frame realm, so any active
3987
5263
  # `within_frame` scope is now stale — fall back to the main document.
5264
+ # Per-frame session histories are scoped to this document tree; drop them.
3988
5265
  reset_frame_scope
5266
+ @frame_histories = nil
3989
5267
  reset_timer_state
3990
- # The DOCUMENT is text; the Rack body arrives BINARY-tagged (see
3991
- # `RuntimeShared.utf8_text`). Charset-header-driven decode is the
3992
- # fuller story; UTF-8 + scrub matches observable browser behavior
3993
- # for the suites we run.
3994
- html = RuntimeShared.utf8_text(html)
5268
+ # The response content type drives both the parser choice (XML vs HTML —
5269
+ # XHTML/XML/SVG parse case-sensitively, no html/head/body skeleton,
5270
+ # `isHtmlDocument` false) and the encoding's HTTP-charset signal.
5271
+ ct = (@last_response_headers || {}).find {|k, _| k.to_s.downcase == 'content-type' }&.last
5272
+ ct = ct.first if ct.is_a?(Array)
5273
+ # HTML document encoding sniffing (the body arrives BINARY-tagged; see
5274
+ # `RuntimeShared.utf8_text`). A leading BOM wins (over <meta charset>) and
5275
+ # is stripped. Otherwise, for an HTML document with NO encoding signal — no
5276
+ # charset in the Content-Type AND no <meta charset> in the prescan — the
5277
+ # locale default is windows-1252 and the bytes decode as such; there is NO
5278
+ # UTF-8 sniffing (WPT encoding/sniffing). A declared charset keeps the
5279
+ # UTF-8 + scrub path (the JS side reports it from the meta; a declared
5280
+ # non-UTF-8 multibyte charset is still UTF-8-decoded — legacy multibyte
5281
+ # tables are out of scope). The windows-1252 default is HTML-only: an XML
5282
+ # document (XHTML/SVG/application+text/xml) defaults to UTF-8, and an empty
5283
+ # body (about:blank, a blank 200) stays UTF-8 too.
5284
+ decoded, doc_charset = decode_response_bom(html)
5285
+ if doc_charset
5286
+ html = RuntimeShared.utf8_text(decoded)
5287
+ elsif html.to_s.empty? || xml_content_type?(ct) || html_charset_signal?(ct, html)
5288
+ html = RuntimeShared.utf8_text(html)
5289
+ else
5290
+ html = decode_windows1252(html)
5291
+ doc_charset = 'windows-1252'
5292
+ end
3995
5293
  opts = {
3996
5294
  'traceActive' => !@trace.nil?,
3997
5295
  'timezone' => ENV['TZ'].to_s,
@@ -3999,18 +5297,35 @@ module Capybara
3999
5297
  'url' => @current_url.to_s,
4000
5298
  'html' => html
4001
5299
  }
4002
- # Carry the response content type so the JS side can pick the XML vs
4003
- # HTML parser (XHTML / XML / SVG documents parse case-sensitively, with
4004
- # no html/head/body skeleton, and report `isHtmlDocument` false).
4005
- ct = (@last_response_headers || {}).find {|k, _| k.to_s.downcase == 'content-type' }&.last
4006
- ct = ct.first if ct.is_a?(Array)
4007
5300
  opts['contentType'] = ct.to_s if ct && !ct.to_s.empty?
5301
+ # The detected document encoding pins document.characterSet (over meta).
5302
+ opts['charset'] = doc_charset if doc_charset
5303
+ # `document.lastModified` reflects the response Last-Modified header (parsed
5304
+ # to local time); absent → the current time (handled JS-side).
5305
+ lm = response_headers['Last-Modified'] # response_headers normalizes keys to Capitalized-Dash form
5306
+ opts['lastModified'] = lm if lm && !lm.to_s.empty?
4008
5307
  if @viewport_width && @viewport_height
4009
5308
  opts['viewportW'] = @viewport_width
4010
5309
  opts['viewportH'] = @viewport_height
4011
5310
  end
4012
5311
  opts['userAgent'] = @default_user_agent if @default_user_agent
4013
5312
  @document_handle = @runtime.call('__csimBootContext', opts).to_i
5313
+ # Drain the app's deferred external-script (chunk) boot chain to quiescence.
5314
+ # A dynamically-inserted external <script> runs async (setTimeout 0; HTML
5315
+ # "prepare the script" force-async), so a module/chunk loader's scripts
5316
+ # haven't executed when boot returns — leaving the page half-booted, where a
5317
+ # negative assertion (have_no_*) passes early or a reactive control (a toggle
5318
+ # whose handler a chunk wires up) does nothing. `run_loop_step(0)` fires only
5319
+ # ALREADY-due timers (the setTimeout(0) chunks + their .then chains, which
5320
+ # may insert further due-now chunks), NOT delayed app timers — so the lazy
5321
+ # wall-sync clock is preserved (smoke_spec "queries DOM before advancing
5322
+ # pending timers"). Bounded by the finite pending-script count.
5323
+ if @runtime.respond_to?(:run_loop_step)
5324
+ BOOT_SCRIPT_DRAIN_MAX_ITER.times do
5325
+ break if @runtime.call('__csimPendingExternalScriptCount').to_i.zero?
5326
+ @runtime.run_loop_step(0, SETTLE_MAX_ITER_TASKS, yield_on_gen: false)
5327
+ end
5328
+ end
4014
5329
  @polling_grace = POST_NAV_POLL_GRACE_POLLS
4015
5330
  end
4016
5331
 
@@ -4025,9 +5340,12 @@ module Capybara
4025
5340
  # timer can't abort loading the next page (the page it would affect is
4026
5341
  # being discarded on the very next line).
4027
5342
  def flush_outgoing_page_init
4028
- saved_location = @pending_location
4029
- saved_reload = @pending_reload
4030
- saved_traverse = @pending_history_traverse
5343
+ saved_location = @pending_location
5344
+ saved_reload = @pending_reload
5345
+ saved_traverse = @pending_history_traverse
5346
+ saved_frame_nav = @pending_frame_nav
5347
+ saved_frame_submit = @pending_frame_submit
5348
+ saved_frame_reload = @pending_frame_reload
4031
5349
  begin
4032
5350
  @runtime.run_loop_step(0, SETTLE_MAX_ITER_TASKS, yield_on_gen: false)
4033
5351
  rescue StandardError
@@ -4036,6 +5354,13 @@ module Capybara
4036
5354
  @pending_location = saved_location
4037
5355
  @pending_reload = saved_reload
4038
5356
  @pending_history_traverse = saved_traverse
5357
+ # Don't let a frame-nav / frame-submit / frame-reload intent stashed by
5358
+ # an outgoing-page timer leak into the fresh page — its realm id belongs
5359
+ # to the discarded page (and a reused context id could mis-fire against
5360
+ # an unrelated realm on the new page).
5361
+ @pending_frame_nav = saved_frame_nav
5362
+ @pending_frame_submit = saved_frame_submit
5363
+ @pending_frame_reload = saved_frame_reload
4039
5364
  end
4040
5365
  end
4041
5366
 
@@ -4121,10 +5446,20 @@ module Capybara
4121
5446
  net_http_fetch(url, env, method: method, body: body) || @app.call(env)
4122
5447
  end
4123
5448
 
5449
+ # Whether this is a universal-server context (the WPT runner's wptserve shim
5450
+ # serves every host in-process). Gates cross-origin eager frame building: in
5451
+ # such a context a cross-origin iframe's content IS served locally, so it
5452
+ # eager-builds; an ordinary app leaves cross-origin frames lazy (= baseline),
5453
+ # so an external embed isn't eager-fetched. A simple flag, NOT per-URL —
5454
+ # url_is_local? compares only host:port (ignoring scheme) and treats a missing
5455
+ # ref origin as local, which would eager-build frames the baseline left lazy.
5456
+ def all_hosts_local? = @all_hosts_local
5457
+
4124
5458
  # Path-only or fragment-only URLs are always against the current
4125
5459
  # origin. For absolute URLs, compare host:port to the cached
4126
5460
  # parsed @current_url (or default_host on first navigate).
4127
5461
  def url_is_local?(url)
5462
+ return true if @all_hosts_local
4128
5463
  s = url.to_s
4129
5464
  return true if s.empty? || s.start_with?('/', '#', '?')
4130
5465
  uri = safe_uri(s)
@@ -4277,7 +5612,7 @@ module Capybara
4277
5612
  # within the `record_action` block, which handles begin/finish
4278
5613
  # step bookkeeping + on-failure DOM snapshot.
4279
5614
  module RecordedActions
4280
- def visit(url)
5615
+ def visit(url, referer: nil)
4281
5616
  record_action(:visit, "visit #{url}") { super }
4282
5617
  end
4283
5618
  def refresh