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.
- checksums.yaml +4 -4
- data/README.md +58 -218
- data/lib/capybara/simulated/browser.rb +966 -148
- data/lib/capybara/simulated/driver.rb +220 -17
- data/lib/capybara/simulated/js/bridge.bundle.js +5384 -921
- data/lib/capybara/simulated/quickjs_runtime.rb +17 -6
- data/lib/capybara/simulated/runtime_shared.rb +32 -5
- data/lib/capybara/simulated/v8_runtime.rb +157 -32
- data/lib/capybara/simulated/version.rb +1 -1
- data/vendor/js/vendor.bundle.js +12 -9
- metadata +1 -1
|
@@ -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:
|
|
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).
|
|
919
|
-
# `
|
|
920
|
-
#
|
|
921
|
-
#
|
|
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 `
|
|
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
|
-
|
|
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`)
|
|
1631
|
-
#
|
|
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
|
-
|
|
1997
|
-
# `
|
|
1998
|
-
#
|
|
1999
|
-
#
|
|
2000
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2121
|
-
|
|
2122
|
-
enctype = spec['enctype'].to_s
|
|
2123
|
-
multipart = enctype.start_with?('multipart/form-data')
|
|
2124
|
-
content_type = nil
|
|
2125
|
-
body =
|
|
2126
|
-
if multipart
|
|
2127
|
-
built = build_multipart_body(fields, file_inputs)
|
|
2128
|
-
content_type = built[:content_type]
|
|
2129
|
-
built[:body]
|
|
2130
|
-
else
|
|
2131
|
-
# Non-multipart: file inputs contribute the filename only.
|
|
2132
|
-
file_inputs.each do |fi|
|
|
2133
|
-
picks = @file_picks && @file_picks[fi['handle'].to_i] || []
|
|
2134
|
-
fields << [fi['name'].to_s, picks.first ? File.basename(picks.first) : '']
|
|
2135
|
-
end
|
|
2136
|
-
URI.encode_www_form(fields)
|
|
2137
|
-
end
|
|
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
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
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
|
-
#
|
|
2176
|
-
#
|
|
2177
|
-
#
|
|
2178
|
-
#
|
|
2179
|
-
#
|
|
2180
|
-
#
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
# form
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
(
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
3181
|
-
#
|
|
3182
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
3202
|
-
|
|
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
|
-
|
|
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
|
|
3301
|
-
# whatwg-url report "domain to ASCII failed". Non-IDNA
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
4055
|
-
|
|
4056
|
-
(
|
|
4057
|
-
|
|
4058
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
4090
|
-
|
|
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
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
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
|
-
|
|
4102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|