capybara-simulated 0.5.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.
@@ -97,6 +97,16 @@ module Capybara
97
97
  # green at 100 across gem 1579, WPT 660, Forem, Avo, :464 passing). Clamped
98
98
  # >=1 so a `CSIM_POLL_TICK_STEP_MS=0` misconfig can't freeze the fixed-step path.
99
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
100
110
  # Horizon-gated fast-forward: when the page is observably idle (no timer due
101
111
  # now, no background IO) but a timer is parked within this horizon, jump the
102
112
  # virtual clock straight to it instead of waiting ~delay/step polls. A timer
@@ -116,6 +126,11 @@ module Capybara
116
126
  # timer/microtask storm so one settle iter returns to Ruby; large enough
117
127
  # for the heaviest legit chain (Mastodon hydrate, Turbo stream batch).
118
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
119
134
  # Post-user-action virtual-clock advance. Default 0 — the
120
135
  # wall-sync model (each tick_real_time advances by the wall
121
136
  # ms elapsed since the last tick) lets Capybara's outer poll
@@ -167,9 +182,10 @@ module Capybara
167
182
  Rack::Mime.mime_type(File.extname(path.to_s), '')
168
183
  end
169
184
 
170
- 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)
171
186
  @app = app
172
187
  @driver = driver
188
+ @all_hosts_local_override = all_hosts_local
173
189
  @runtime = build_runtime(js_engine)
174
190
  # Per-poll clock decisions cached at construction (CLAUDE.md rule 3 — the
175
191
  # runtime type + env are fixed for the session): the wall-sync escape
@@ -218,6 +234,18 @@ module Capybara
218
234
  # `Capybara.app_host` / `server_host` / `server_port` on every
219
235
  # rack call (CLAUDE.md: cache env decisions at construction).
220
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
221
249
  # Handle IDs are per-Context integer sequences: a handle from
222
250
  # a pre-rebuild context could collide with a fresh node's id
223
251
  # in the new context. Node captures this on construction;
@@ -403,8 +431,8 @@ module Capybara
403
431
 
404
432
  # Address-bar navigation: no Referer, and relative paths resolve
405
433
  # against the host root (not the current page's directory).
406
- def visit(url)
407
- navigate(resolve_visit_url(url), referer: nil)
434
+ def visit(url, referer: nil)
435
+ navigate(resolve_visit_url(url), referer: referer)
408
436
  end
409
437
 
410
438
  URL_UNSAFE_CHARS = %r{[^!*'();:@&=+$,/?#\[\]A-Za-z0-9\-._~%]}n.freeze
@@ -915,12 +943,13 @@ module Capybara
915
943
  end
916
944
  # `target="_blank"` (or any non-_self/_top/_parent name) opens
917
945
  # in a new browsing context (its own Browser/VM); the primary
918
- # stays put (per HTML spec — original window is unaffected). No
919
- # `opener_handle` is passed: modern browsers default `target=_blank`
920
- # to `noopener` (so `window.opener` is null), unlike JS `window.open`
921
- # which keeps the opener see `open_window_from_js`.
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.
922
951
  elsif !target.empty? && !%w[_self _top _parent].include?(target.downcase) && @driver.respond_to?(:open_aux_window)
923
- @driver.open_aux_window(resolve_against_current(url, use_base: true), source: self, blob_snapshot: action['blob'])
952
+ @driver.open_aux_window(resolve_against_current(url, use_base: true), source: self, opener: !!action['opener'], blob_snapshot: action['blob'])
924
953
  # In-page anchor links (`#frag` / current-page + `#frag`) move
925
954
  # the hash but don't fetch a new document. Pure-fragment also
926
955
  # short-circuits the `<a>`s test fixtures use as click sinks.
@@ -966,7 +995,7 @@ module Capybara
966
995
  elsif @current_url != submit_baseline_url
967
996
  # Already navigated; nothing more to do.
968
997
  else
969
- submit_form_handle(action['formHandle'], action['submitter'])
998
+ submit_form_handle(action['formHandle'], action['submitter'], action['entryList'])
970
999
  end
971
1000
  when 'download'
972
1001
  download_link(resolve_against_current(action['url'].to_s), action['filename'].to_s)
@@ -1035,7 +1064,7 @@ module Capybara
1035
1064
  # `attach_file` hands us a Pathname (or Array of Pathnames);
1036
1065
  # the marshaller rejects non-primitive types. Coerce to a path-list
1037
1066
  # form V8 can hold — the actual multipart upload happens later
1038
- # in `build_multipart_body` during form submission.
1067
+ # in `encode_entry_list` during form submission.
1039
1068
  coerced = coerce_set_value(value)
1040
1069
  # For date/time-shaped inputs we need the type-specific
1041
1070
  # string. Probe the handle's `type` and re-format Date / Time
@@ -1375,7 +1404,7 @@ module Capybara
1375
1404
  def consume_pending_form_submit
1376
1405
  pending = @runtime.call('__csimTakePendingFormSubmit')
1377
1406
  return unless pending.is_a?(Hash) && pending['formHandle']
1378
- submit_form_handle(pending['formHandle'].to_i, pending['submitterHandle'])
1407
+ submit_form_handle(pending['formHandle'].to_i, pending['submitterHandle'], pending['entryList'])
1379
1408
  end
1380
1409
 
1381
1410
  # Pin the URL the page is at as a user action BEGINS — the FIRST line of
@@ -1475,7 +1504,9 @@ module Capybara
1475
1504
  url = pending['url'].to_s
1476
1505
  target = pending['target'].to_s
1477
1506
  if !target.empty? && !%w[_self _top _parent].include?(target.downcase) && @driver.respond_to?(:open_aux_window)
1478
- @driver.open_aux_window(resolve_against_current(url, use_base: true), source: self, blob_snapshot: pending['blob'])
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'])
1479
1510
  elsif pure_fragment_navigation?(url)
1480
1511
  update_current_hash(url)
1481
1512
  else
@@ -1607,11 +1638,34 @@ module Capybara
1607
1638
  def go_back ; history_go(-1, force: true) ; end
1608
1639
  def go_forward ; history_go(+1, force: true) ; end
1609
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
+
1610
1659
  # Move through the history stack by `delta`. Per HTML spec, a
1611
1660
  # same-document traversal (within a chain of pushState entries
1612
1661
  # rooted at a single navigation) updates `location` and fires
1613
1662
  # `popstate` with the entry's state — no full reload. A cross-
1614
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).
1615
1669
  def history_go(delta, force: false)
1616
1670
  delta = delta.to_i
1617
1671
  return if delta == 0
@@ -1626,10 +1680,13 @@ module Capybara
1626
1680
  @current_url = entry[:url]
1627
1681
  @runtime.call('__csimUpdateLocation', @current_url)
1628
1682
  @runtime.call('__csimDispatchPopState', entry[:state])
1683
+ :same_document
1629
1684
  elsif force
1630
- # Ruby-driven (`page.go_back`) no live JS call to interrupt,
1631
- # 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.
1632
1688
  perform_history_traverse(target)
1689
+ :cross_document
1633
1690
  else
1634
1691
  # JS-driven (`history.back()` from a page handler): replaying
1635
1692
  # the history entry synchronously would call `rebuild_ctx`
@@ -1639,6 +1696,7 @@ module Capybara
1639
1696
  # and drain after the call returns — mirrors
1640
1697
  # `location_assign` / `location_reload`.
1641
1698
  @pending_history_traverse = target
1699
+ :cross_document
1642
1700
  end
1643
1701
  end
1644
1702
 
@@ -1649,8 +1707,29 @@ module Capybara
1649
1707
  end
1650
1708
 
1651
1709
  private def perform_history_traverse(target)
1710
+ capture_outgoing_form_state
1652
1711
  @history_idx = target
1653
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
1654
1733
  end
1655
1734
 
1656
1735
  # Same-document = every entry between `from` and `to` (inclusive)
@@ -1993,22 +2072,54 @@ module Capybara
1993
2072
  @last_tick_ts = now
1994
2073
  effective_step = step_ms || horizon_fast_forward_step
1995
2074
  if @timers_active && effective_step > 0
1996
- r = @runtime.run_loop_step(effective_step)
1997
- # `dirtied` (settleGen changed) catches a render-phase rAF / microtask-
1998
- # delivered MutationObserver that mutated the DOM without firing a timer
1999
- # (fired == 0) a fired-count-only test would leave a stale find cache.
2000
- @find_cache_dirty = true if r['dirtied'] || r['fired'].to_i > 0
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
2001
2116
  end
2002
2117
  # Pull any pending Worker / EventSource messages into JS
2003
2118
  # state. Without this, `evaluate_script` after kicking off
2004
2119
  # a worker round-trip would see stale state — the inbox
2005
2120
  # outbox only drains during `settle`, which doesn't run
2006
2121
  # for direct `execute_script` / `evaluate_script` calls.
2007
- @find_cache_dirty = true if deliver_worker_messages > 0
2008
- @find_cache_dirty = true if deliver_event_source_events > 0
2009
- @find_cache_dirty = true if deliver_hijacked_fetches > 0
2010
- @find_cache_dirty = true if deliver_window_messages > 0
2011
- @find_cache_dirty = true if deliver_websocket_events > 0
2122
+ drain_async_channels
2012
2123
  ensure
2013
2124
  @ticking = false
2014
2125
  end
@@ -2032,6 +2143,142 @@ module Capybara
2032
2143
  consume_pending_download
2033
2144
  end
2034
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
+
2035
2282
  # This tick's deterministic virtual-clock advance (ms). Default is the fixed
2036
2283
  # `POLL_TICK_STEP_MS` — never wall-derived, so per-poll JS/Ruby/GC cost cannot
2037
2284
  # shift WHEN a timer fires (the wall-sync↔perf coupling this replaces). When
@@ -2040,6 +2287,11 @@ module Capybara
2040
2287
  # it — but only after the transient-guard window so pre-debounce states are
2041
2288
  # still observed across several polls. `FF_HORIZON_MS=0` ⇒ pure fixed-step.
2042
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
2043
2295
  # Escape hatch to the legacy wall-sync clock (virtual advance = real
2044
2296
  # wall-elapsed per poll). The deterministic model decouples perf from
2045
2297
  # timing but can't match a real browser's wall-proportional cadence for
@@ -2064,6 +2316,7 @@ module Capybara
2064
2316
  # (2) Runnable now → fixed step, reset guard (not a quiet pre-debounce window).
2065
2317
  if delay.zero?
2066
2318
  @ff_transient_polls = 0
2319
+ @page_runnable_now = true
2067
2320
  return POLL_TICK_STEP_MS
2068
2321
  end
2069
2322
  # (3) Nothing parked → nothing to fast-forward to.
@@ -2109,95 +2362,161 @@ module Capybara
2109
2362
  attr_reader :context_gen
2110
2363
 
2111
2364
  # Pulls the serialised form-state out of JS, encodes it, and
2112
- # drives the Rack app via `navigate` (for GET) or a POST.
2113
- def submit_form_handle(form_handle, submitter_handle)
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)
2114
2370
  invalidate_find_cache
2115
2371
  spec = dom_call('__csimFormSerialize', form_handle, submitter_handle || 0)
2116
2372
  return unless spec.is_a?(Hash)
2117
2373
  action = spec['action'].to_s
2118
2374
  method = spec['method'].to_s.upcase
2119
2375
  method = 'GET' if method.empty?
2120
- fields = (spec['fields'] || []).map {|pair| [pair[0].to_s, pair[1].to_s] }
2121
- file_inputs = spec['fileInputs'] || []
2122
- enctype = spec['enctype'].to_s
2123
- multipart = enctype.start_with?('multipart/form-data')
2124
- content_type = nil
2125
- body =
2126
- if multipart
2127
- built = build_multipart_body(fields, file_inputs)
2128
- content_type = built[:content_type]
2129
- built[:body]
2130
- else
2131
- # Non-multipart: file inputs contribute the filename only.
2132
- file_inputs.each do |fi|
2133
- picks = @file_picks && @file_picks[fi['handle'].to_i] || []
2134
- fields << [fi['name'].to_s, picks.first ? File.basename(picks.first) : '']
2135
- end
2136
- URI.encode_www_form(fields)
2137
- end
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)
2138
2378
  action_url = action.empty? ? (current_browsing_context_url || @default_host) : resolve_against_current(action)
2139
2379
  # A form submitted inside a frame whose target is that frame (self, or a
2140
2380
  # `_parent` of a ≥2-deep frame) navigates the FRAME, not the top page.
2141
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
2142
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')
2143
2408
  uri = URI.parse(action_url)
2144
- uri.query = body unless body.empty?
2145
- frame_entry ? navigate_frame(uri.to_s, entry: frame_entry) : navigate(uri.to_s)
2146
- elsif frame_entry
2147
- navigate_frame_post(action_url, body, content_type || enctype, entry: frame_entry)
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
2148
2421
  else
2149
- 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
2150
2432
  end
2151
2433
  end
2152
2434
 
2153
- def build_multipart_body(fields, file_inputs)
2154
- boundary = "csim-#{SecureRandom.hex(8)}"
2155
- body = String.new.force_encoding(Encoding::ASCII_8BIT)
2156
- fields.each do |name, value|
2157
- append_multipart_part(body, boundary, name, value.to_s)
2158
- end
2159
- file_inputs.each do |fi|
2160
- picks = file_pick_paths(fi)
2161
- if picks.empty?
2162
- append_multipart_part(body, boundary, fi['name'].to_s, '', filename: '')
2163
- else
2164
- picks.each do |path|
2165
- append_multipart_part(body, boundary, fi['name'].to_s, File.binread(path),
2166
- filename: File.basename(path),
2167
- content_type: Rack::Mime.mime_type(File.extname(path)))
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)
2168
2466
  end
2169
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
2170
2477
  end
2171
- body << "--#{boundary}--\r\n"
2172
- {content_type: "multipart/form-data; boundary=#{boundary}", body: body}
2173
2478
  end
2174
2479
 
2175
- # The on-disk paths backing a file input's current selection. Each
2176
- # selected File reports its host-backed source (`handle`/`index` the
2177
- # `@file_picks` slot recorded at `attach_file` time); this resolves bytes
2178
- # even when JS moved a File onto a different input (`input.files =
2179
- # dataTransfer.files`), whose own handle was never attached to. Falls back
2180
- # to the input's own handle for older serializer payloads.
2181
- #
2182
- # Only host-backed Files (from `attach_file`) resolve here; a purely
2183
- # in-memory `new File(['bytes'], …)` assigned via JS has no `@file_picks`
2184
- # slot, so a CLASSIC (non-Turbo) submit drops its bytes — the fetch/XHR
2185
- # path serializes those in JS (`serializeMultipart` → `blobBytes`) and is
2186
- # unaffected. This matches the pre-existing behaviour and covers every
2187
- # realistic upload (host-backed file submitted through Turbo or a plain
2188
- # form).
2189
- def file_pick_paths(fi)
2190
- refs = fi['files']
2191
- if refs.is_a?(Array) && !refs.empty?
2192
- refs.filter_map {|ref|
2193
- handle = ref['handle']
2194
- next if handle.nil?
2195
- picks = @file_picks && @file_picks[handle.to_i]
2196
- picks && picks[ref['index'].to_i]
2197
- }
2198
- else
2199
- (@file_picks && @file_picks[fi['handle'].to_i]) || []
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
2200
2518
  end
2519
+ entries
2201
2520
  end
2202
2521
 
2203
2522
  def append_multipart_part(body, boundary, name, content, filename: nil, content_type: nil)
@@ -2211,14 +2530,17 @@ module Capybara
2211
2530
  body << "\r\n"
2212
2531
  end
2213
2532
 
2214
- 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)
2215
2534
  raise 'too many redirects' if depth > 10
2216
2535
  invalidate_find_cache
2217
- 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
2218
2540
  env = Rack::MockRequest.env_for(url, method: 'POST', input: body)
2219
2541
  env['CONTENT_TYPE'] = content_type.empty? ? 'application/x-www-form-urlencoded' : content_type
2220
2542
  env['CONTENT_LENGTH'] = body.bytesize.to_s
2221
- apply_default_request_env(env, referer: @current_url)
2543
+ apply_default_request_env(env, referer: referer)
2222
2544
  status, headers, resp_body = dispatch_rack_or_http(url, env, method: 'POST', body: body)
2223
2545
  merge_set_cookie(headers)
2224
2546
  if (loc = redirect_location(status, headers))
@@ -2386,7 +2708,14 @@ module Capybara
2386
2708
  # Returns nil on 4xx / fetch failure so the JS caller skips it exactly as the
2387
2709
  # old `__rackFetch` branch did.
2388
2710
  def external_asset_source(url)
2389
- 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)
2390
2719
  return nil unless key.is_a?(String)
2391
2720
  @@asset_src_lock.synchronize do
2392
2721
  if (e = @@asset_src[key])
@@ -3064,28 +3393,48 @@ module Capybara
3064
3393
  # worker's `__csim_workerPostMessage` host fn closes over its
3065
3394
  # handle and routes outgoing messages onto a shared outbox the
3066
3395
  # main settle drains.
3067
- def worker_spawn(url, shared: false)
3396
+ def worker_spawn(url, shared: false, service: false)
3068
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
3069
3407
  inbox = Thread::Queue.new
3070
3408
  outbox = @worker_outbox
3071
3409
  engine_class = @runtime.class
3072
- target = resolve_against_current(url.to_s)
3073
3410
  # Resolve the worker script body on the main thread before
3074
3411
  # handing off to the worker. `blob:` URLs need the main VM's
3075
3412
  # blob registry; calling into the main runtime from a
3076
3413
  # non-owning thread SEGVs (V8 isolates are thread-
3077
3414
  # bound; quickjs.rb's VM is similarly per-thread).
3078
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?
3079
3419
  # Pending until the worker's initial script has run (see @worker_initializing).
3080
3420
  @worker_init_lock.synchronize { @worker_initializing += 1 }
3081
3421
  thread = Thread.new do
3082
3422
  Thread.current.report_on_exception = false
3083
- run_worker(handle, target, body, inbox, outbox, engine_class, shared: shared)
3423
+ run_worker(handle, target, body, inbox, outbox, engine_class, shared: shared, service: service)
3084
3424
  end
3085
3425
  @workers[handle] = {thread: thread, inbox: inbox}
3086
3426
  handle
3087
3427
  end
3088
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
+
3089
3438
  def worker_post_to_worker(handle, data)
3090
3439
  w = @workers[handle.to_i]
3091
3440
  return unless w
@@ -3137,9 +3486,32 @@ module Capybara
3137
3486
  # `window.open(url, name)` from JS — returns the new (or reused, by name)
3138
3487
  # window's handle, or nil. The URL is resolved against THIS document so a
3139
3488
  # relative `window.open('/x')` targets the right origin/path.
3140
- def open_child_window(url, name)
3489
+ def open_child_window(url, name, opener_realm_id = 0)
3141
3490
  return nil unless @driver.respond_to?(:open_window_from_js)
3142
- @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)
3143
3515
  end
3144
3516
 
3145
3517
  # `targetWindow.postMessage(data, origin)` — route to the target window's
@@ -3154,6 +3526,41 @@ module Capybara
3154
3526
  # route to the Driver, which reads a PRIMITIVE off the target window's VM.
3155
3527
  def window_get(handle, prop) = (@driver.respond_to?(:window_read) ? @driver.window_read(handle.to_s, prop.to_s, doc: false) : nil)
3156
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
3157
3564
  # Read a primitive property off THIS window's globalThis / document — called
3158
3565
  # by the Driver to serve another window's cross-window proxy read.
3159
3566
  def read_property(prop, doc: false)
@@ -3162,9 +3569,13 @@ module Capybara
3162
3569
  nil
3163
3570
  end
3164
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)
3165
3573
  def window_closed?(handle) = @driver.respond_to?(:window_closed?) ? @driver.window_closed?(handle.to_s) : true
3166
3574
  def close_child_window(handle) = (@driver.close_window(handle.to_s) if @driver.respond_to?(:close_window))
3167
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)
3168
3579
 
3169
3580
  # Queue a cross-window message for delivery into THIS window's VM (called
3170
3581
  # by the Driver on the target Browser). Delivered as a `message` event the
@@ -3177,9 +3588,13 @@ module Capybara
3177
3588
  # cross-window event channels share these drain/pending hooks.
3178
3589
  def window_message_pending? = !@window_inbox.empty? || !@broadcast_inbox.empty?
3179
3590
 
3180
- # A BroadcastChannel message from another window, queued for delivery to
3181
- # this window's channels with the same name.
3182
- def enqueue_broadcast(name, data) = (@broadcast_inbox << {'name' => name.to_s, 'data' => data})
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
3183
3598
 
3184
3599
  # Fire queued cross-window messages (postMessage + BroadcastChannel).
3185
3600
  def deliver_window_messages
@@ -3191,16 +3606,39 @@ module Capybara
3191
3606
  end
3192
3607
  unless @broadcast_inbox.empty?
3193
3608
  events = @broadcast_inbox.slice!(0, @broadcast_inbox.length)
3194
- @runtime.call('__csim_deliverBroadcasts', events)
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
3195
3624
  n += events.size
3196
3625
  end
3197
3626
  n
3198
3627
  end
3199
3628
 
3200
- # `BroadcastChannel.postMessage` in THIS window — fan out to every OTHER
3201
- # window's matching channels (same-window delivery happens in-VM).
3202
- def broadcast_to_windows(name, data)
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)
3203
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
3204
3642
  end
3205
3643
 
3206
3644
  # ── Image decode (libvips) ─────────────────────────────────────
@@ -3281,11 +3719,64 @@ module Capybara
3281
3719
  # that context would wrongly revoke a now-page-owned URL.
3282
3720
  if key then @blob_owners[url.to_s] = key else @blob_owners.delete(url.to_s) end
3283
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)
3284
3727
  nil
3285
3728
  end
3286
3729
 
3287
3730
  def blob_resolve(url)
3288
- @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
+ ''
3289
3780
  end
3290
3781
 
3291
3782
  # WHATWG URL "domain to ASCII" — the JS tr46 stub delegates non-ASCII / xn--
@@ -3295,10 +3786,25 @@ module Capybara
3295
3786
  # VerifyDnsLength off) — empty middle labels (`x..y`) and `_`/etc. are
3296
3787
  # allowed, matching whatwg-url's `domainToASCII(domain, false)`.
3297
3788
  def domain_to_ascii(domain)
3298
- URI::IDNA.whatwg_to_ascii(domain.to_s, be_strict: false)
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)
3299
3804
  rescue URI::IDNA::Error
3300
- nil # a genuine IDNA failure (bad punycode / disallowed codepoint) — let
3301
- # whatwg-url report "domain to ASCII failed". Non-IDNA errors propagate.
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.
3302
3808
  end
3303
3809
 
3304
3810
  # WHATWG URL "domain to Unicode" — best-effort (never fails the parse per
@@ -3334,20 +3840,62 @@ module Capybara
3334
3840
  # type (url-charset) is preserved so it can override <meta charset>.
3335
3841
  ct = "#{ct};charset=utf-8" unless ct.downcase.include?('charset')
3336
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 }
3337
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
3338
3855
  end
3339
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.)
3340
3863
  def blob_unregister(url)
3341
- @blob_registry_lock.synchronize { @blob_registry.delete(url.to_s); @blob_owners.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)
3342
3870
  nil
3343
3871
  end
3344
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
+
3345
3889
  # Revoke every blob URL owned by a context that's going away (its blob URL
3346
3890
  # store is part of the global being torn down).
3347
3891
  def revoke_owned_blobs(key)
3348
- @blob_registry_lock.synchronize do
3892
+ revoked = @blob_registry_lock.synchronize do
3349
3893
  urls = @blob_owners.select {|_url, owner| owner == key }.keys
3350
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) }
3351
3899
  end
3352
3900
  end
3353
3901
  # Keys are normalized with `.to_i` on BOTH sides (register tags
@@ -3473,6 +4021,17 @@ module Capybara
3473
4021
  }.freeze
3474
4022
  private_constant :MIME_TO_VIPS_EXT
3475
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
+
3476
4035
  def encode_image(pixels_ref, width, height, mime_type = 'image/png', quality = 90)
3477
4036
  host_image_op('encode_image') {
3478
4037
  require 'vips' unless defined?(Vips)
@@ -3483,7 +4042,7 @@ module Capybara
3483
4042
  img = Vips::Image.new_from_memory_copy(raw, w, h, 4, :uchar)
3484
4043
  ext = MIME_TO_VIPS_EXT[mime_type.to_s.downcase] || '.png'
3485
4044
  opts = (ext == '.jpg' || ext == '.webp') ? {Q: quality.to_i} : {}
3486
- {'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]}
3487
4046
  }
3488
4047
  end
3489
4048
 
@@ -3493,7 +4052,7 @@ module Capybara
3493
4052
  # `build_worker` factory, evaluates the worker script, then
3494
4053
  # loops draining microtasks + timers + inbox until `:terminate`
3495
4054
  # lands or an exception propagates.
3496
- private def run_worker(handle, url, body, inbox, outbox, engine_class, shared: false)
4055
+ private def run_worker(handle, url, body, inbox, outbox, engine_class, shared: false, service: false)
3497
4056
  # Release the spawn-time `@worker_initializing` count exactly once, however
3498
4057
  # this method exits (normal start, `self.close()`, or an exception), so
3499
4058
  # worker_pending? doesn't stay stuck true forever.
@@ -3518,6 +4077,9 @@ module Capybara
3518
4077
  # resolve chunks against the worker's own origin rather than
3519
4078
  # the snapshot-time `http://placeholder/`.
3520
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
3521
4083
  rt.eval(body)
3522
4084
  rt.drain_microtasks
3523
4085
  # A SharedWorker fires `connect` AFTER its script set `self.onconnect`; the
@@ -3531,16 +4093,25 @@ module Capybara
3531
4093
  release_init.call
3532
4094
  # A worker that called `self.close()` in its top-level script stops here —
3533
4095
  # the script ran (and may have posted), but no further messages are pulled.
3534
- unless rt.eval('!!globalThis.__csimWorkerClosed')
4096
+ unless rt.call('__csimWorkerClosedRead')
3535
4097
  loop do
3536
4098
  msg = pop_with_timeout(inbox, WORKER_POLL_INTERVAL)
3537
4099
  break if msg == :terminate
3538
- if msg
3539
- rt.call('__csim_workerOnMessage', msg)
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
3540
4111
  rt.drain_microtasks
3541
- rt.drain_timers if rt.has_ready_timer?
3542
- break if rt.eval('!!globalThis.__csimWorkerClosed')
4112
+ rt.drain_timers
3543
4113
  end
4114
+ break if rt.call('__csimWorkerClosedRead')
3544
4115
  end
3545
4116
  end
3546
4117
  rescue StandardError => e
@@ -3558,6 +4129,13 @@ module Capybara
3558
4129
  private def fetch_worker_script(url)
3559
4130
  u = url.to_s
3560
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
3561
4139
  b64 = @runtime.call('__csimReadBlobBase64', u)
3562
4140
  return nil unless b64
3563
4141
  return Base64.decode64(b64.to_s)
@@ -3940,6 +4518,9 @@ module Capybara
3940
4518
  (@pending_frame_nav ||= {})[realm_id] = url.to_s
3941
4519
  end
3942
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
3943
4524
  return if @pending_frame_nav.nil? || @pending_frame_nav.empty?
3944
4525
  navs = @pending_frame_nav
3945
4526
  @pending_frame_nav = nil
@@ -3963,6 +4544,44 @@ module Capybara
3963
4544
  log_console('warn', "frame self-navigation failed: #{e.message}")
3964
4545
  end
3965
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
3966
4585
  # Mirror of `location_assign`'s deferral for `location.reload()`:
3967
4586
  # the JS call lands here from `__locationReload`; running
3968
4587
  # `browser.refresh` directly would `navigate` (rebuilding the
@@ -4029,11 +4648,103 @@ module Capybara
4029
4648
  sub = @runtime.realm_call(realm_id, '__csimTakePendingFormSubmit')
4030
4649
  next unless sub.is_a?(Hash) && sub['formHandle']
4031
4650
  invalidate_find_cache
4032
- submit_form_in_realm(realm_id, sub['formHandle'], sub['submitterHandle'])
4651
+ submit_form_in_realm(realm_id, sub['formHandle'], sub['submitterHandle'], sub['entryList'])
4033
4652
  rescue StandardError => e
4034
4653
  log_console('warn', "nested-context form submission failed: #{e.message}")
4035
4654
  end
4036
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
4037
4748
  # Serialize + route a form submitted inside frame realm `realm_id`. We
4038
4749
  # serialize in the INITIATING realm (so shadow-tree controls are excluded
4039
4750
  # and relative URLs resolve against that document), then route by target:
@@ -4044,23 +4755,25 @@ module Capybara
4044
4755
  # GET fully supported. POST to a self frame needs the entered stack
4045
4756
  # (navigate_frame_post); POST-to-named and other targets from a nested
4046
4757
  # context aren't modeled (no in-scope need) — logged rather than dropped.
4047
- def submit_form_in_realm(realm_id, form_handle, submitter_handle)
4758
+ def submit_form_in_realm(realm_id, form_handle, submitter_handle, entry_list = nil)
4048
4759
  spec = @runtime.realm_call(realm_id, '__csimFormSerialize', form_handle, submitter_handle || 0)
4049
4760
  return unless spec.is_a?(Hash)
4050
4761
  method = spec['method'].to_s.upcase
4051
4762
  method = 'GET' if method.empty?
4052
4763
  target = spec['target'].to_s
4053
4764
  action = spec['action'].to_s
4054
- fields = (spec['fields'] || []).map {|pair| [pair[0].to_s, pair[1].to_s] }
4055
- # Non-multipart file inputs contribute the filename only (mirror submit_form_handle's GET path).
4056
- (spec['fileInputs'] || []).each do |fi|
4057
- picks = @file_picks && @file_picks[fi['handle'].to_i] || []
4058
- fields << [fi['name'].to_s, picks.first ? File.basename(picks.first) : '']
4059
- end
4060
- body = URI.encode_www_form(fields)
4061
- get_url = form_get_url(action, body)
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)
4062
4770
  if frame_self_target?(target)
4063
- navigate_realm_self(realm_id, get_url, action, method, body, spec['enctype'].to_s)
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
4064
4777
  elsif %w[_parent _top _blank].include?(target.downcase)
4065
4778
  log_console('warn', "nested-context form submit (target=#{target.inspect}) is not modeled")
4066
4779
  elsif method == 'GET'
@@ -4078,30 +4791,103 @@ module Capybara
4078
4791
  # URL's query with the serialized entry list (dropping any pre-existing
4079
4792
  # query), preserving a trailing #fragment. String-based so it works on the
4080
4793
  # raw (possibly relative) action attribute without URI.parse fragility;
4081
- # the absolute equivalent of submit_form_handle's `uri.query = body`.
4794
+ # the absolute equivalent of submit_form_handle's `uri.query = body`. An
4795
+ # EMPTY entry list still clears the query (→ `action?`), matching browsers.
4082
4796
  def form_get_url(action, body)
4083
- return action if body.empty?
4084
4797
  base, _hash, frag = action.partition('#')
4085
4798
  path = base.split('?', 2).first
4086
4799
  url = "#{path}?#{body}"
4087
4800
  frag.empty? ? url : "#{url}##{frag}"
4088
4801
  end
4089
- # Navigate the initiating frame realm itself (a self-targeted form submit).
4090
- def navigate_realm_self(realm_id, get_url, action, method, body, enctype)
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)
4091
4806
  entry = @frame_stack.find {|e| e[:realm_id] == realm_id }
4092
- if method == 'GET'
4093
- if entry
4094
- navigate_frame(resolve_against_current(get_url), entry: entry)
4095
- else
4096
- # A frame reached via contentWindow (not on the entered stack): its
4097
- # owning iframe lives in the parent document re-navigate by realm id
4098
- # (relative get_url resolves against the frame's base on rebuild).
4099
- @runtime.call('__csimNavigateFrameByRealm', realm_id, get_url)
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)
4100
4844
  end
4101
- elsif entry
4102
- navigate_frame_post(resolve_against_current(action), body, enctype, entry: entry)
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)
4103
4889
  else
4104
- log_console('warn', "nested-context self-form POST (realm #{realm_id}) is not modeled")
4890
+ @runtime.realm_call(parent_realm_id, fn, *args)
4105
4891
  end
4106
4892
  end
4107
4893
  def drain_pending_navigation
@@ -4109,6 +4895,7 @@ module Capybara
4109
4895
  consume_pending_frame_nav
4110
4896
  consume_pending_frame_submit
4111
4897
  consume_pending_frame_reload
4898
+ consume_pending_frame_traverse
4112
4899
  consume_pending_reload
4113
4900
  consume_pending_history_traverse
4114
4901
  consume_pending_aux_window
@@ -4404,7 +5191,10 @@ module Capybara
4404
5191
  boot_response_into_ctx('')
4405
5192
  return
4406
5193
  end
4407
- 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
4408
5198
  env = Rack::MockRequest.env_for(url, method: 'GET')
4409
5199
  apply_default_request_env(env, referer: referer)
4410
5200
  status, headers, body = dispatch_rack_or_http(url, env, method: 'GET')
@@ -4471,7 +5261,9 @@ module Capybara
4471
5261
  @runtime.rebuild_ctx
4472
5262
  # A full page (re)build disposes every frame realm, so any active
4473
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.
4474
5265
  reset_frame_scope
5266
+ @frame_histories = nil
4475
5267
  reset_timer_state
4476
5268
  # The response content type drives both the parser choice (XML vs HTML —
4477
5269
  # XHTML/XML/SVG parse case-sensitively, no html/head/body skeleton,
@@ -4518,6 +5310,22 @@ module Capybara
4518
5310
  end
4519
5311
  opts['userAgent'] = @default_user_agent if @default_user_agent
4520
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
4521
5329
  @polling_grace = POST_NAV_POLL_GRACE_POLLS
4522
5330
  end
4523
5331
 
@@ -4638,10 +5446,20 @@ module Capybara
4638
5446
  net_http_fetch(url, env, method: method, body: body) || @app.call(env)
4639
5447
  end
4640
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
+
4641
5458
  # Path-only or fragment-only URLs are always against the current
4642
5459
  # origin. For absolute URLs, compare host:port to the cached
4643
5460
  # parsed @current_url (or default_host on first navigate).
4644
5461
  def url_is_local?(url)
5462
+ return true if @all_hosts_local
4645
5463
  s = url.to_s
4646
5464
  return true if s.empty? || s.start_with?('/', '#', '?')
4647
5465
  uri = safe_uri(s)
@@ -4794,7 +5612,7 @@ module Capybara
4794
5612
  # within the `record_action` block, which handles begin/finish
4795
5613
  # step bookkeeping + on-failure DOM snapshot.
4796
5614
  module RecordedActions
4797
- def visit(url)
5615
+ def visit(url, referer: nil)
4798
5616
  record_action(:visit, "visit #{url}") { super }
4799
5617
  end
4800
5618
  def refresh