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