capybara-simulated 0.2.0 → 0.4.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 +71 -128
- data/lib/capybara/simulated/browser.rb +442 -48
- data/lib/capybara/simulated/js/bridge.bundle.js +302 -44
- data/lib/capybara/simulated/runtime_shared.rb +5 -0
- data/lib/capybara/simulated/v8_runtime.rb +11 -4
- data/lib/capybara/simulated/version.rb +1 -1
- metadata +1 -1
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
require 'base64'
|
|
4
4
|
require 'date'
|
|
5
|
+
require 'digest'
|
|
5
6
|
require 'fileutils'
|
|
6
7
|
require 'json'
|
|
7
8
|
require 'net/http'
|
|
8
9
|
require 'openssl'
|
|
9
10
|
require 'rack/mock'
|
|
11
|
+
require 'securerandom'
|
|
10
12
|
require 'socket'
|
|
11
13
|
require 'thread'
|
|
12
14
|
require 'time'
|
|
@@ -273,6 +275,23 @@ module Capybara
|
|
|
273
275
|
@event_source_seq = 0
|
|
274
276
|
@event_source_threads = {}
|
|
275
277
|
@event_source_queue = Thread::Queue.new
|
|
278
|
+
# WebSocket — per-Browser handle counter, background frame-reader
|
|
279
|
+
# threads, the csim-side socket end of each connection (for writing
|
|
280
|
+
# client→server frames), and a Queue of lifecycle / message events
|
|
281
|
+
# awaiting delivery into the VM. Same model as SSE: the reader thread
|
|
282
|
+
# does the blocking socket read; the main thread drains the Queue in
|
|
283
|
+
# `settle` and dispatches via `__csim_deliverWebSocketEvents`. The
|
|
284
|
+
# connection rides the in-process `rack.hijack` socket Action Cable
|
|
285
|
+
# (and any Rack WebSocket middleware) takes over.
|
|
286
|
+
@websocket_seq = 0
|
|
287
|
+
@websocket_threads = {}
|
|
288
|
+
@websocket_sockets = {} # id → csim's socket end (main thread owns this hash)
|
|
289
|
+
@websocket_app_sockets = {} # id → the app's hijack end (closed on teardown)
|
|
290
|
+
@websocket_queue = Thread::Queue.new
|
|
291
|
+
# All frame writes (the reader thread's pong replies + the main thread's
|
|
292
|
+
# send/close) go through one socket; serialise them so two threads can't
|
|
293
|
+
# interleave bytes into a corrupt frame.
|
|
294
|
+
@websocket_write_lock = Mutex.new
|
|
276
295
|
# Hijacked-XHR delivery — per-Browser handle counter,
|
|
277
296
|
# background threads, and a Queue of completed responses for
|
|
278
297
|
# Rack calls where the middleware used `rack.hijack` to hold
|
|
@@ -307,6 +326,15 @@ module Capybara
|
|
|
307
326
|
@transfer_buffer_lock = Mutex.new
|
|
308
327
|
@transfer_buffers = {}
|
|
309
328
|
@transfer_buffer_seq = 0
|
|
329
|
+
# Zero-copy postMessage transfer tokens (rusty_racer >= 0.1.6
|
|
330
|
+
# `RustyRacer.transferOut`): a buffer in a `postMessage` transfer list
|
|
331
|
+
# crosses isolates by token (no byte copy), its source detached. A token
|
|
332
|
+
# parked but never imported pins its backing store PROCESS-WIDE, so we
|
|
333
|
+
# record every issued token (reported from JS, possibly on a worker
|
|
334
|
+
# thread — hence the lock) and `transferDrop` the lot on `reset!`
|
|
335
|
+
# (idempotent: an already-imported token no-ops).
|
|
336
|
+
@transfer_tokens = []
|
|
337
|
+
@transfer_tokens_lock = Mutex.new
|
|
310
338
|
# Cross-window `postMessage` inbox. Another window's `target.postMessage`
|
|
311
339
|
# routes through the Driver and lands here; this window drains it into a
|
|
312
340
|
# `message` event the next time it's active and settles/ticks. Plain
|
|
@@ -503,13 +531,14 @@ module Capybara
|
|
|
503
531
|
# the find cache (its keys aren't realm-qualified, and a switch is rare).
|
|
504
532
|
#
|
|
505
533
|
# Scope: finds, reads, interactions (click/fill_in/…), evaluate_script,
|
|
506
|
-
# and
|
|
507
|
-
#
|
|
508
|
-
#
|
|
509
|
-
#
|
|
510
|
-
#
|
|
511
|
-
#
|
|
512
|
-
#
|
|
534
|
+
# and navigation (a link / form submit whose default action loads a new
|
|
535
|
+
# document) all route into the frame — the target frame's realm is rebuilt
|
|
536
|
+
# from the fetched document, leaving the top page untouched (see
|
|
537
|
+
# `navigate_frame` / `frame_nav_target_entry`). A `_parent`-targeted link
|
|
538
|
+
# or form from a frame nested ≥2 levels rebuilds the intermediate parent
|
|
539
|
+
# frame; `_top` (and a one-level `_parent`, whose parent is the top
|
|
540
|
+
# context) navigate the main page. Cross-origin frame locality is resolved
|
|
541
|
+
# against the main page's origin.
|
|
513
542
|
def switch_to_frame(target)
|
|
514
543
|
invalidate_find_cache
|
|
515
544
|
case target
|
|
@@ -573,16 +602,27 @@ module Capybara
|
|
|
573
602
|
end
|
|
574
603
|
|
|
575
604
|
# Does a link/form `target` load into the CURRENT frame? Empty or `_self`
|
|
576
|
-
# do; `_top` / `_blank` / `_parent` / a named context do not.
|
|
577
|
-
# correctly navigates the main page (it falls through to `navigate`).
|
|
578
|
-
# `_parent` from a frame nested ≥2 levels would ideally navigate the
|
|
579
|
-
# intermediate parent frame, not the top page — that ancestor-targeted
|
|
580
|
-
# case isn't modelled yet (rare); it currently navigates the main page.
|
|
605
|
+
# do; `_top` / `_blank` / `_parent` / a named context do not.
|
|
581
606
|
def frame_self_target?(target)
|
|
582
607
|
t = target.to_s.downcase
|
|
583
608
|
t.empty? || t == '_self'
|
|
584
609
|
end
|
|
585
610
|
|
|
611
|
+
# Resolve a link/form `target` to the frame stack entry its navigation
|
|
612
|
+
# should rebuild, or nil when it targets the top page / a new context
|
|
613
|
+
# (the caller then falls through to a full-page `navigate` or aux window).
|
|
614
|
+
# Only meaningful inside a frame (`@current_realm_id` set):
|
|
615
|
+
# - `''` / `_self` → the current frame.
|
|
616
|
+
# - `_parent` → the intermediate parent frame, but only when nested ≥2
|
|
617
|
+
# levels deep; at one level the parent IS the top browsing context, so
|
|
618
|
+
# it returns nil and the full-page path handles it (same as `_top`).
|
|
619
|
+
def frame_nav_target_entry(target)
|
|
620
|
+
return nil unless @current_realm_id
|
|
621
|
+
return @frame_stack.last if frame_self_target?(target)
|
|
622
|
+
return @frame_stack[-2] if target.to_s.downcase == '_parent' && @frame_stack.size >= 2
|
|
623
|
+
nil
|
|
624
|
+
end
|
|
625
|
+
|
|
586
626
|
def find_css(css, context_handle = nil)
|
|
587
627
|
s = css.to_s
|
|
588
628
|
return find_xpath(s, context_handle) if xpath_shaped?(s)
|
|
@@ -689,7 +729,7 @@ module Capybara
|
|
|
689
729
|
# drain that channel, without paying an unconditional drain on
|
|
690
730
|
# timer-driven runloop pages.
|
|
691
731
|
def async_io_pending?
|
|
692
|
-
worker_pending? || event_source_pending? || hijack_fetch_pending? || window_message_pending?
|
|
732
|
+
worker_pending? || event_source_pending? || hijack_fetch_pending? || window_message_pending? || websocket_pending?
|
|
693
733
|
end
|
|
694
734
|
|
|
695
735
|
# Single-slot cache for the most recent find_xpath / find_css /
|
|
@@ -847,13 +887,14 @@ module Capybara
|
|
|
847
887
|
when 'navigate'
|
|
848
888
|
url = action['url'].to_s
|
|
849
889
|
target = action['target'].to_s
|
|
850
|
-
# Inside a frame, a
|
|
851
|
-
#
|
|
852
|
-
#
|
|
853
|
-
|
|
854
|
-
|
|
890
|
+
# Inside a frame, a frame-targeted link (self, or `_parent` of a
|
|
891
|
+
# ≥2-deep frame) navigates that FRAME, not the top page: fetch +
|
|
892
|
+
# rebuild its realm. A self-targeted pure-fragment link is already
|
|
893
|
+
# handled in-realm by the frame's own location JS, so skip it.
|
|
894
|
+
if (frame_entry = frame_nav_target_entry(target))
|
|
895
|
+
unless frame_entry.equal?(@frame_stack.last) && pure_fragment_navigation?(url)
|
|
855
896
|
tick_real_time
|
|
856
|
-
navigate_frame(resolve_against_current(url, use_base: true))
|
|
897
|
+
navigate_frame(resolve_against_current(url, use_base: true), entry: frame_entry)
|
|
857
898
|
end
|
|
858
899
|
# `target="_blank"` (or any non-_self/_top/_parent name) opens
|
|
859
900
|
# in a new browsing context (its own Browser/VM); the primary
|
|
@@ -1374,8 +1415,9 @@ module Capybara
|
|
|
1374
1415
|
deliver_worker_messages
|
|
1375
1416
|
deliver_hijacked_fetches
|
|
1376
1417
|
deliver_window_messages
|
|
1418
|
+
deliver_websocket_events
|
|
1377
1419
|
break if @runtime.settle_gen > start_gen
|
|
1378
|
-
break unless @timers_active || event_source_pending? || worker_pending? || hijack_fetch_pending? || window_message_pending?
|
|
1420
|
+
break unless @timers_active || event_source_pending? || worker_pending? || hijack_fetch_pending? || window_message_pending? || websocket_pending?
|
|
1379
1421
|
# ONE event-loop step replaces the old drain_microtasks(4)+drain_timers(32)
|
|
1380
1422
|
# pair: it fires due timers, runs a per-task microtask checkpoint (so
|
|
1381
1423
|
# chained .then / MutationObserver delivery interleave spec-correctly),
|
|
@@ -1388,13 +1430,14 @@ module Capybara
|
|
|
1388
1430
|
deliver_worker_messages
|
|
1389
1431
|
deliver_hijacked_fetches
|
|
1390
1432
|
deliver_window_messages
|
|
1433
|
+
deliver_websocket_events
|
|
1391
1434
|
break if @runtime.settle_gen > start_gen
|
|
1392
1435
|
# No progress this iter (no DOM/URL change observed) — the
|
|
1393
1436
|
# remaining timers are queued for the future; bail and let
|
|
1394
1437
|
# Capybara's wall-clock-driven poll loop drive the next tick
|
|
1395
1438
|
# via `tick_real_time`. SSE / Worker channels keep us in
|
|
1396
1439
|
# the loop as long as background threads have data queued.
|
|
1397
|
-
break if @runtime.settle_gen == prev_gen && !@runtime.has_ready_timer? && !event_source_pending? && !worker_pending? && !hijack_fetch_pending? && !window_message_pending?
|
|
1440
|
+
break if @runtime.settle_gen == prev_gen && !@runtime.has_ready_timer? && !event_source_pending? && !worker_pending? && !hijack_fetch_pending? && !window_message_pending? && !websocket_pending?
|
|
1398
1441
|
prev_gen = @runtime.settle_gen
|
|
1399
1442
|
end
|
|
1400
1443
|
@find_cache_dirty = true
|
|
@@ -1886,7 +1929,7 @@ module Capybara
|
|
|
1886
1929
|
# Background-thread work (workers, EventSource, MessageBus
|
|
1887
1930
|
# long-poll) keeps the settle loop alive even when settle_gen
|
|
1888
1931
|
# is otherwise idle.
|
|
1889
|
-
return true if worker_pending? || event_source_pending? || hijack_fetch_pending? || window_message_pending?
|
|
1932
|
+
return true if worker_pending? || event_source_pending? || hijack_fetch_pending? || window_message_pending? || websocket_pending?
|
|
1890
1933
|
if @timers_active
|
|
1891
1934
|
gen = @runtime.settle_gen
|
|
1892
1935
|
if @last_polled_gen.nil? || gen != @last_polled_gen
|
|
@@ -1917,7 +1960,7 @@ module Capybara
|
|
|
1917
1960
|
# `Kernel#sleep`) and by `Playwright::Page#wait_for_timeout` to step a
|
|
1918
1961
|
# precise virtual duration.
|
|
1919
1962
|
def tick_real_time(step_ms: nil)
|
|
1920
|
-
return unless @timers_active || worker_pending? || event_source_pending? || hijack_fetch_pending? || window_message_pending?
|
|
1963
|
+
return unless @timers_active || worker_pending? || event_source_pending? || hijack_fetch_pending? || window_message_pending? || websocket_pending?
|
|
1921
1964
|
# Re-entrancy guard. Capybara's `Result#each` triggers nested
|
|
1922
1965
|
# finds (visible? per element); the outermost tick has already
|
|
1923
1966
|
# advanced the clock, the inner calls would only re-drain
|
|
@@ -1947,6 +1990,7 @@ module Capybara
|
|
|
1947
1990
|
@find_cache_dirty = true if deliver_event_source_events > 0
|
|
1948
1991
|
@find_cache_dirty = true if deliver_hijacked_fetches > 0
|
|
1949
1992
|
@find_cache_dirty = true if deliver_window_messages > 0
|
|
1993
|
+
@find_cache_dirty = true if deliver_websocket_events > 0
|
|
1950
1994
|
ensure
|
|
1951
1995
|
@ticking = false
|
|
1952
1996
|
end
|
|
@@ -1990,7 +2034,7 @@ module Capybara
|
|
|
1990
2034
|
end
|
|
1991
2035
|
# (1) Background async (cheap Ruby-side checks, no V8 crossing) we must let
|
|
1992
2036
|
# land before jumping the clock: advance one fixed step, reset the guard.
|
|
1993
|
-
if worker_pending? || event_source_pending? || hijack_fetch_pending?
|
|
2037
|
+
if worker_pending? || event_source_pending? || hijack_fetch_pending? || websocket_pending?
|
|
1994
2038
|
@ff_transient_polls = 0
|
|
1995
2039
|
return POLL_TICK_STEP_MS
|
|
1996
2040
|
end
|
|
@@ -2074,15 +2118,15 @@ module Capybara
|
|
|
2074
2118
|
URI.encode_www_form(fields)
|
|
2075
2119
|
end
|
|
2076
2120
|
action_url = action.empty? ? (current_browsing_context_url || @default_host) : resolve_against_current(action)
|
|
2077
|
-
# A form submitted inside a frame whose target is
|
|
2078
|
-
# navigates the FRAME, not the top page.
|
|
2079
|
-
|
|
2121
|
+
# A form submitted inside a frame whose target is that frame (self, or a
|
|
2122
|
+
# `_parent` of a ≥2-deep frame) navigates the FRAME, not the top page.
|
|
2123
|
+
frame_entry = frame_nav_target_entry(spec['target'])
|
|
2080
2124
|
if method == 'GET'
|
|
2081
2125
|
uri = URI.parse(action_url)
|
|
2082
2126
|
uri.query = body unless body.empty?
|
|
2083
|
-
|
|
2084
|
-
elsif
|
|
2085
|
-
navigate_frame_post(action_url, body, content_type || enctype)
|
|
2127
|
+
frame_entry ? navigate_frame(uri.to_s, entry: frame_entry) : navigate(uri.to_s)
|
|
2128
|
+
elsif frame_entry
|
|
2129
|
+
navigate_frame_post(action_url, body, content_type || enctype, entry: frame_entry)
|
|
2086
2130
|
else
|
|
2087
2131
|
navigate_post(action_url, body, content_type || enctype)
|
|
2088
2132
|
end
|
|
@@ -2095,7 +2139,7 @@ module Capybara
|
|
|
2095
2139
|
append_multipart_part(body, boundary, name, value.to_s)
|
|
2096
2140
|
end
|
|
2097
2141
|
file_inputs.each do |fi|
|
|
2098
|
-
picks =
|
|
2142
|
+
picks = file_pick_paths(fi)
|
|
2099
2143
|
if picks.empty?
|
|
2100
2144
|
append_multipart_part(body, boundary, fi['name'].to_s, '', filename: '')
|
|
2101
2145
|
else
|
|
@@ -2110,6 +2154,34 @@ module Capybara
|
|
|
2110
2154
|
{content_type: "multipart/form-data; boundary=#{boundary}", body: body}
|
|
2111
2155
|
end
|
|
2112
2156
|
|
|
2157
|
+
# The on-disk paths backing a file input's current selection. Each
|
|
2158
|
+
# selected File reports its host-backed source (`handle`/`index` → the
|
|
2159
|
+
# `@file_picks` slot recorded at `attach_file` time); this resolves bytes
|
|
2160
|
+
# even when JS moved a File onto a different input (`input.files =
|
|
2161
|
+
# dataTransfer.files`), whose own handle was never attached to. Falls back
|
|
2162
|
+
# to the input's own handle for older serializer payloads.
|
|
2163
|
+
#
|
|
2164
|
+
# Only host-backed Files (from `attach_file`) resolve here; a purely
|
|
2165
|
+
# in-memory `new File(['bytes'], …)` assigned via JS has no `@file_picks`
|
|
2166
|
+
# slot, so a CLASSIC (non-Turbo) submit drops its bytes — the fetch/XHR
|
|
2167
|
+
# path serializes those in JS (`serializeMultipart` → `blobBytes`) and is
|
|
2168
|
+
# unaffected. This matches the pre-existing behaviour and covers every
|
|
2169
|
+
# realistic upload (host-backed file submitted through Turbo or a plain
|
|
2170
|
+
# form).
|
|
2171
|
+
def file_pick_paths(fi)
|
|
2172
|
+
refs = fi['files']
|
|
2173
|
+
if refs.is_a?(Array) && !refs.empty?
|
|
2174
|
+
refs.filter_map {|ref|
|
|
2175
|
+
handle = ref['handle']
|
|
2176
|
+
next if handle.nil?
|
|
2177
|
+
picks = @file_picks && @file_picks[handle.to_i]
|
|
2178
|
+
picks && picks[ref['index'].to_i]
|
|
2179
|
+
}
|
|
2180
|
+
else
|
|
2181
|
+
(@file_picks && @file_picks[fi['handle'].to_i]) || []
|
|
2182
|
+
end
|
|
2183
|
+
end
|
|
2184
|
+
|
|
2113
2185
|
def append_multipart_part(body, boundary, name, content, filename: nil, content_type: nil)
|
|
2114
2186
|
body << "--#{boundary}\r\n"
|
|
2115
2187
|
disposition = %[form-data; name="#{name}"]
|
|
@@ -2203,7 +2275,11 @@ module Capybara
|
|
|
2203
2275
|
reset_event_sources
|
|
2204
2276
|
reset_hijacked_fetches
|
|
2205
2277
|
reset_workers
|
|
2278
|
+
reset_websockets
|
|
2206
2279
|
@window_inbox.clear
|
|
2280
|
+
# Free any zero-copy transfer backing stores that went unimported
|
|
2281
|
+
# (worker killed before draining its inbox, etc.) before the rebuild.
|
|
2282
|
+
drop_pending_transfers
|
|
2207
2283
|
@blob_registry_lock.synchronize { @blob_registry.clear }
|
|
2208
2284
|
# Drop volatile entries from the class-level HTTP asset cache
|
|
2209
2285
|
# so test-local DB state (TranslationOverride, etc.) reaches
|
|
@@ -2220,6 +2296,23 @@ module Capybara
|
|
|
2220
2296
|
invalidate_find_cache
|
|
2221
2297
|
end
|
|
2222
2298
|
|
|
2299
|
+
# Tear down an auxiliary window's Browser when its window closes (the
|
|
2300
|
+
# Driver calls this on close_window / reset!). Releases what a bare GC of
|
|
2301
|
+
# the isolate would NOT: live background threads (worker / SSE / hijacked-
|
|
2302
|
+
# fetch / WebSocket readers) and any parked zero-copy transfer backing
|
|
2303
|
+
# stores this window issued (the transfer registry is process-wide). Runs
|
|
2304
|
+
# while the runtime is still alive so the transferDrop call lands.
|
|
2305
|
+
def dispose
|
|
2306
|
+
drop_pending_transfers
|
|
2307
|
+
reset_workers
|
|
2308
|
+
reset_event_sources
|
|
2309
|
+
reset_hijacked_fetches
|
|
2310
|
+
reset_websockets
|
|
2311
|
+
@window_inbox.clear
|
|
2312
|
+
rescue StandardError
|
|
2313
|
+
nil
|
|
2314
|
+
end
|
|
2315
|
+
|
|
2223
2316
|
# ── Host-fn callbacks invoked by bridge.js ──────────────────
|
|
2224
2317
|
|
|
2225
2318
|
def rack_fetch_body(url)
|
|
@@ -2499,6 +2592,260 @@ module Capybara
|
|
|
2499
2592
|
@event_source_queue.clear
|
|
2500
2593
|
end
|
|
2501
2594
|
|
|
2595
|
+
# ── WebSocket (RFC6455 over in-process rack.hijack) ────────────
|
|
2596
|
+
#
|
|
2597
|
+
# A real browser's WebSocket connects, upgrades, and stays open for
|
|
2598
|
+
# bidirectional framing. Action Cable (and any Rack WebSocket
|
|
2599
|
+
# middleware) handles the upgrade by HIJACKING the connection and
|
|
2600
|
+
# speaking frames over that socket — the same in-process `rack.hijack`
|
|
2601
|
+
# mechanism the long-poll path already uses, but bidirectional. So we:
|
|
2602
|
+
# 1. build the upgrade request as a Rack env (the server reads the
|
|
2603
|
+
# handshake from the env, like websocket-driver's rack helper),
|
|
2604
|
+
# 2. hand the app a `Socket.pair` end via `rack.hijack` and call it,
|
|
2605
|
+
# 3. the app writes the 101 + frames to its end; we read/write ours.
|
|
2606
|
+
# The frame reader runs on a background thread (engine access stays on
|
|
2607
|
+
# the main thread — events drain into the VM at `settle`, like SSE).
|
|
2608
|
+
WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
2609
|
+
private_constant :WS_GUID
|
|
2610
|
+
|
|
2611
|
+
def ws_open(url, protocols = nil)
|
|
2612
|
+
id = (@websocket_seq += 1)
|
|
2613
|
+
# ws:// → http://, wss:// → https:// for the Rack env; resolve relative
|
|
2614
|
+
# against the current document (Action Cable's consumer builds an
|
|
2615
|
+
# absolute ws URL, but be tolerant).
|
|
2616
|
+
http_url = url.to_s.sub(/\Awss/i, 'https').sub(/\Aws/i, 'http')
|
|
2617
|
+
target = resolve_against_current(http_url)
|
|
2618
|
+
key = SecureRandom.base64(16)
|
|
2619
|
+
csim_io, app_io = Socket.pair(:UNIX, :STREAM, 0)
|
|
2620
|
+
env = Rack::MockRequest.env_for(target, method: 'GET')
|
|
2621
|
+
apply_default_request_env(env, referer: @current_url)
|
|
2622
|
+
env['HTTP_UPGRADE'] = 'websocket'
|
|
2623
|
+
env['HTTP_CONNECTION'] = 'Upgrade'
|
|
2624
|
+
env['HTTP_SEC_WEBSOCKET_KEY'] = key
|
|
2625
|
+
env['HTTP_SEC_WEBSOCKET_VERSION'] = '13'
|
|
2626
|
+
list = Array(protocols).map(&:to_s).reject(&:empty?)
|
|
2627
|
+
env['HTTP_SEC_WEBSOCKET_PROTOCOL'] = list.join(', ') unless list.empty?
|
|
2628
|
+
env['rack.hijack?'] = true
|
|
2629
|
+
env['rack.hijack'] = -> { app_io }
|
|
2630
|
+
env['rack.hijack_io'] = app_io
|
|
2631
|
+
# The app hijacks + writes the 101 (synchronously, or on its own event
|
|
2632
|
+
# loop thread — Action Cable handles the upgrade on a separate thread, so
|
|
2633
|
+
# `@app.call` may return before the handshake bytes appear; the reader
|
|
2634
|
+
# blocks until they do). Run it on the main thread like the long-poll
|
|
2635
|
+
# hijack so we don't race a second concurrent `@app.call`. (No handshake
|
|
2636
|
+
# timeout: a server that never writes the 101 leaks the reader+socket
|
|
2637
|
+
# until `reset_websockets` — acceptable, real servers always respond.)
|
|
2638
|
+
@app.call(env)
|
|
2639
|
+
@websocket_sockets[id] = csim_io
|
|
2640
|
+
@websocket_app_sockets[id] = app_io
|
|
2641
|
+
accept = Digest::SHA1.base64digest(key + WS_GUID)
|
|
2642
|
+
queue = @websocket_queue
|
|
2643
|
+
@websocket_threads[id] = Thread.new do
|
|
2644
|
+
Thread.current.report_on_exception = false
|
|
2645
|
+
run_websocket_reader(id, csim_io, accept, queue)
|
|
2646
|
+
end
|
|
2647
|
+
id
|
|
2648
|
+
rescue StandardError => e
|
|
2649
|
+
# Nothing was registered for cleanup yet — close both pair ends here so
|
|
2650
|
+
# a failed upgrade (mis-routed URL, app error) doesn't leak fds.
|
|
2651
|
+
csim_io.close rescue nil
|
|
2652
|
+
app_io.close rescue nil
|
|
2653
|
+
@websocket_queue << {id: id, type: '__error', message: "#{e.class}: #{e.message}"}
|
|
2654
|
+
id
|
|
2655
|
+
end
|
|
2656
|
+
|
|
2657
|
+
# `binary` is set by the JS side (it knows whether `send` was given a
|
|
2658
|
+
# string or an ArrayBuffer/view) → opcode 0x2 vs the text 0x1. Action
|
|
2659
|
+
# Cable is text-only (JSON). `b64` is set when the bytes arrived base64-
|
|
2660
|
+
# encoded (the QuickJS binary path — raw bytes ≥0x80 don't survive its
|
|
2661
|
+
# host boundary); decode before framing.
|
|
2662
|
+
def ws_send(id, data, binary = false, b64 = false)
|
|
2663
|
+
sock = @websocket_sockets[id.to_i] or return
|
|
2664
|
+
if binary
|
|
2665
|
+
ws_write_frame(sock, 0x2, b64 ? Base64.decode64(data.to_s) : data.to_s.b)
|
|
2666
|
+
else
|
|
2667
|
+
ws_write_frame(sock, 0x1, data.to_s.b)
|
|
2668
|
+
end
|
|
2669
|
+
nil
|
|
2670
|
+
rescue StandardError
|
|
2671
|
+
nil
|
|
2672
|
+
end
|
|
2673
|
+
|
|
2674
|
+
def ws_close(id, code = 1000, reason = '')
|
|
2675
|
+
sock = @websocket_sockets[id.to_i] or return
|
|
2676
|
+
# Send the close frame and let the close HANDSHAKE complete: the server
|
|
2677
|
+
# replies with its own close frame, which the reader thread surfaces as
|
|
2678
|
+
# the `__close` event (carrying the agreed code) before tearing the
|
|
2679
|
+
# socket down in its `ensure`. Force teardown is `reset_websockets`'s job.
|
|
2680
|
+
payload = [code.to_i].pack('n') + reason.to_s.b
|
|
2681
|
+
ws_write_frame(sock, 0x8, payload) rescue nil
|
|
2682
|
+
nil
|
|
2683
|
+
end
|
|
2684
|
+
|
|
2685
|
+
def websocket_pending? = !@websocket_queue.empty?
|
|
2686
|
+
|
|
2687
|
+
def deliver_websocket_events
|
|
2688
|
+
return 0 if @websocket_threads.empty? && @websocket_queue.empty?
|
|
2689
|
+
events = drain_queue(@websocket_queue)
|
|
2690
|
+
return 0 if events.empty?
|
|
2691
|
+
@runtime.call('__csim_deliverWebSocketEvents', events)
|
|
2692
|
+
events.size
|
|
2693
|
+
end
|
|
2694
|
+
|
|
2695
|
+
def reset_websockets
|
|
2696
|
+
@websocket_threads.each_value(&:kill)
|
|
2697
|
+
@websocket_threads.clear
|
|
2698
|
+
# Close BOTH pair ends: csim's read/write end and the app's hijack end
|
|
2699
|
+
# (the app may abandon its end without closing it — e.g. its connection
|
|
2700
|
+
# thread was just killed), so neither leaks across tests.
|
|
2701
|
+
@websocket_sockets.each_value {|s| s.close rescue nil }
|
|
2702
|
+
@websocket_app_sockets.each_value {|s| s.close rescue nil }
|
|
2703
|
+
@websocket_sockets.clear
|
|
2704
|
+
@websocket_app_sockets.clear
|
|
2705
|
+
@websocket_queue.clear
|
|
2706
|
+
end
|
|
2707
|
+
|
|
2708
|
+
# Background-thread frame reader: verify the 101 handshake, then loop
|
|
2709
|
+
# decoding server→client frames into queue events until close / EOF.
|
|
2710
|
+
private def run_websocket_reader(id, sock, expected_accept, queue)
|
|
2711
|
+
ok, protocol = ws_read_handshake(sock, expected_accept)
|
|
2712
|
+
unless ok
|
|
2713
|
+
queue << {id: id, type: '__error', message: 'websocket handshake failed'}
|
|
2714
|
+
return
|
|
2715
|
+
end
|
|
2716
|
+
# Carry the negotiated subprotocol — Action Cable's client closes the
|
|
2717
|
+
# connection in its `onopen` unless `webSocket.protocol` is one it knows
|
|
2718
|
+
# (`actioncable-v1-json`).
|
|
2719
|
+
queue << {id: id, type: '__open', protocol: protocol}
|
|
2720
|
+
loop do
|
|
2721
|
+
frame = ws_read_message(sock, queue, id)
|
|
2722
|
+
break if frame.nil? # EOF
|
|
2723
|
+
opcode, payload = frame
|
|
2724
|
+
if opcode == :close
|
|
2725
|
+
code = payload.bytesize >= 2 ? payload[0, 2].unpack1('n') : 1005
|
|
2726
|
+
reason = payload.bytesize > 2 ? RuntimeShared.utf8_text(payload[2..]) : ''
|
|
2727
|
+
queue << {id: id, type: '__close', code: code, reason: reason}
|
|
2728
|
+
break
|
|
2729
|
+
end
|
|
2730
|
+
# Binary frames cross to JS as raw bytes (wrap_binary) tagged so the
|
|
2731
|
+
# JS side decodes them per `binaryType`; text is UTF-8.
|
|
2732
|
+
if opcode == 0x2
|
|
2733
|
+
queue << {id: id, type: 'message', binary: true, data: @runtime.wrap_binary(payload)}
|
|
2734
|
+
else
|
|
2735
|
+
queue << {id: id, type: 'message', data: RuntimeShared.utf8_text(payload)}
|
|
2736
|
+
end
|
|
2737
|
+
end
|
|
2738
|
+
rescue StandardError => e
|
|
2739
|
+
queue << {id: id, type: '__close', code: 1006, reason: e.message.to_s[0, 120]}
|
|
2740
|
+
ensure
|
|
2741
|
+
# Only the socket (a local) is closed here — the `@websocket_*` hashes
|
|
2742
|
+
# are mutated solely on the main thread (`reset_websockets`) to avoid a
|
|
2743
|
+
# cross-thread Hash race; a closed entry just no-ops on the next access.
|
|
2744
|
+
sock.close rescue nil
|
|
2745
|
+
end
|
|
2746
|
+
|
|
2747
|
+
# Read + validate the 101 Switching Protocols response (status line +
|
|
2748
|
+
# headers up to the blank line). Returns `[accept_ok, negotiated_protocol]`
|
|
2749
|
+
# — the accept hash must match the handshake key, and the negotiated
|
|
2750
|
+
# `Sec-WebSocket-Protocol` (nil if none) is surfaced so `webSocket.protocol`
|
|
2751
|
+
# is set (Action Cable's client requires `actioncable-v1-json`).
|
|
2752
|
+
private def ws_read_handshake(sock, expected_accept)
|
|
2753
|
+
status = sock.gets
|
|
2754
|
+
return [false, nil] unless status && status =~ %r{\AHTTP/1\.1 101}i
|
|
2755
|
+
accept_ok = false
|
|
2756
|
+
protocol = nil
|
|
2757
|
+
while (line = sock.gets)
|
|
2758
|
+
line = line.chomp
|
|
2759
|
+
break if line.empty?
|
|
2760
|
+
k, v = line.split(':', 2)
|
|
2761
|
+
next unless k
|
|
2762
|
+
key = k.strip.downcase
|
|
2763
|
+
val = v.to_s.strip
|
|
2764
|
+
accept_ok = true if key == 'sec-websocket-accept' && val == expected_accept
|
|
2765
|
+
# utf8_text: socket reads are BINARY, and a BINARY string marshals to a
|
|
2766
|
+
# JS Uint8Array — the protocol must reach JS as a real string so
|
|
2767
|
+
# `webSocket.protocol` compares equal to `actioncable-v1-json`.
|
|
2768
|
+
protocol = RuntimeShared.utf8_text(val) if key == 'sec-websocket-protocol' && !val.empty?
|
|
2769
|
+
end
|
|
2770
|
+
[accept_ok, protocol]
|
|
2771
|
+
end
|
|
2772
|
+
|
|
2773
|
+
# Read one complete message (reassembling continuation frames), handling
|
|
2774
|
+
# interleaved control frames inline. Returns `[opcode, payload]` (opcode
|
|
2775
|
+
# 0x1 text / 0x2 binary, or `:close`), or nil on EOF.
|
|
2776
|
+
private def ws_read_message(sock, queue, id)
|
|
2777
|
+
data = +''.b
|
|
2778
|
+
msg_opcode = nil
|
|
2779
|
+
loop do
|
|
2780
|
+
hdr = ws_read_n(sock, 2) or return nil
|
|
2781
|
+
b0, b1 = hdr.bytes
|
|
2782
|
+
fin = (b0 & 0x80) != 0
|
|
2783
|
+
opcode = b0 & 0x0f
|
|
2784
|
+
masked = (b1 & 0x80) != 0
|
|
2785
|
+
len = b1 & 0x7f
|
|
2786
|
+
if len == 126
|
|
2787
|
+
ext = ws_read_n(sock, 2) or return nil
|
|
2788
|
+
len = ext.unpack1('n')
|
|
2789
|
+
elsif len == 127
|
|
2790
|
+
ext = ws_read_n(sock, 8) or return nil
|
|
2791
|
+
len = ext.unpack1('Q>')
|
|
2792
|
+
end
|
|
2793
|
+
mask = nil
|
|
2794
|
+
if masked
|
|
2795
|
+
mask = ws_read_n(sock, 4) or return nil
|
|
2796
|
+
end
|
|
2797
|
+
if len.zero?
|
|
2798
|
+
payload = ''.b
|
|
2799
|
+
else
|
|
2800
|
+
payload = ws_read_n(sock, len) or return nil
|
|
2801
|
+
end
|
|
2802
|
+
payload = ws_mask(payload, mask) if mask # server frames shouldn't be masked, but be defensive
|
|
2803
|
+
case opcode
|
|
2804
|
+
when 0x8 then return [:close, payload]
|
|
2805
|
+
when 0x9 then ws_write_frame(sock, 0xA, payload); next # ping → pong
|
|
2806
|
+
when 0xA then next # pong → ignore
|
|
2807
|
+
when 0x0 then data << payload # continuation
|
|
2808
|
+
else msg_opcode = opcode; data << payload # 0x1 text / 0x2 binary
|
|
2809
|
+
end
|
|
2810
|
+
return [msg_opcode || opcode, data] if fin
|
|
2811
|
+
end
|
|
2812
|
+
end
|
|
2813
|
+
|
|
2814
|
+
# Read exactly `n` bytes, or nil if the stream ends first (EOF, or a short
|
|
2815
|
+
# read on a mid-frame close). `IO#read(n)` blocks for n bytes on a stream
|
|
2816
|
+
# socket and only returns nil / fewer at EOF, so a nil-or-short result is
|
|
2817
|
+
# a closed/broken connection — bail and let the caller surface a close.
|
|
2818
|
+
private def ws_read_n(sock, n)
|
|
2819
|
+
buf = sock.read(n)
|
|
2820
|
+
buf if buf && buf.bytesize == n
|
|
2821
|
+
end
|
|
2822
|
+
|
|
2823
|
+
# Write one frame. Client→server frames MUST be masked (RFC6455 §5.3);
|
|
2824
|
+
# csim is always the client, so every frame it writes is masked. Holds the
|
|
2825
|
+
# per-connection write lock so the reader thread's pong and the main
|
|
2826
|
+
# thread's send/close can't interleave bytes on the shared socket.
|
|
2827
|
+
private def ws_write_frame(sock, opcode, payload)
|
|
2828
|
+
payload = payload.to_s.b
|
|
2829
|
+
len = payload.bytesize
|
|
2830
|
+
out = [0x80 | opcode].pack('C')
|
|
2831
|
+
if len < 126
|
|
2832
|
+
out << [0x80 | len].pack('C')
|
|
2833
|
+
elsif len < 65_536
|
|
2834
|
+
out << [0x80 | 126, len].pack('Cn')
|
|
2835
|
+
else
|
|
2836
|
+
out << [0x80 | 127, len].pack('CQ>')
|
|
2837
|
+
end
|
|
2838
|
+
key = SecureRandom.random_bytes(4)
|
|
2839
|
+
out << key
|
|
2840
|
+
out << ws_mask(payload, key)
|
|
2841
|
+
@websocket_write_lock.synchronize { sock.write(out) }
|
|
2842
|
+
end
|
|
2843
|
+
|
|
2844
|
+
private def ws_mask(payload, key)
|
|
2845
|
+
kb = key.bytes
|
|
2846
|
+
payload.bytes.each_with_index.map {|byte, i| byte ^ kb[i & 3] }.pack('C*')
|
|
2847
|
+
end
|
|
2848
|
+
|
|
2502
2849
|
# ── Hijack-aware async XHR ─────────────────────────────────────
|
|
2503
2850
|
#
|
|
2504
2851
|
# Real browsers' long-poll keeps the request socket open across
|
|
@@ -2883,6 +3230,25 @@ module Capybara
|
|
|
2883
3230
|
@transfer_buffer_lock.synchronize { @transfer_buffers.delete(id.to_i) }
|
|
2884
3231
|
end
|
|
2885
3232
|
|
|
3233
|
+
# JS reports each zero-copy transfer token it mints (`RustyRacer.transferOut`)
|
|
3234
|
+
# so we can release any that go unimported. Callable from a worker thread.
|
|
3235
|
+
def transfer_token_issued(token)
|
|
3236
|
+
t = token.to_i
|
|
3237
|
+
@transfer_tokens_lock.synchronize { @transfer_tokens << t } if t > 0
|
|
3238
|
+
nil
|
|
3239
|
+
end
|
|
3240
|
+
|
|
3241
|
+
# Release every outstanding transfer token's backing store. The transfer
|
|
3242
|
+
# registry is process-wide (it bridges isolates) and survives isolate
|
|
3243
|
+
# teardown, so an unimported token would leak across the whole run; drop
|
|
3244
|
+
# them on `reset!` via the (still-live) main context — `transferDrop` is
|
|
3245
|
+
# idempotent, so dropping already-imported tokens is a harmless no-op.
|
|
3246
|
+
def drop_pending_transfers
|
|
3247
|
+
toks = @transfer_tokens_lock.synchronize { ts = @transfer_tokens; @transfer_tokens = []; ts }
|
|
3248
|
+
return if toks.empty?
|
|
3249
|
+
@runtime.call('__csimTransferDropAll', toks) rescue nil
|
|
3250
|
+
end
|
|
3251
|
+
|
|
2886
3252
|
# Wraps the raw bytes in whatever binary shape the ACTIVE runtime can
|
|
2887
3253
|
# marshal to a JS Uint8Array (V8: the BINARY-tagged string itself —
|
|
2888
3254
|
# tag-driven marshalling crosses it as a Uint8Array; QuickJS: base64
|
|
@@ -3247,6 +3613,12 @@ module Capybara
|
|
|
3247
3613
|
# the page, discarding all JS state.
|
|
3248
3614
|
if pure_fragment_navigation?(url)
|
|
3249
3615
|
update_current_hash(url)
|
|
3616
|
+
elsif @current_realm_id
|
|
3617
|
+
# A JS-driven `location.*` from inside a `within_frame` block
|
|
3618
|
+
# navigates the FRAME, not the top page (same as a self-targeted
|
|
3619
|
+
# link/form there). Gated on the realm, so the main-page path is
|
|
3620
|
+
# untouched.
|
|
3621
|
+
navigate_frame(url)
|
|
3250
3622
|
else
|
|
3251
3623
|
navigate(url)
|
|
3252
3624
|
end
|
|
@@ -3405,11 +3777,11 @@ module Capybara
|
|
|
3405
3777
|
# Mirrors `navigate` / `navigate_post`'s fetch + redirect-follow but
|
|
3406
3778
|
# terminates in `reload_current_frame_realm` instead of a main-page boot.
|
|
3407
3779
|
|
|
3408
|
-
def navigate_frame(url, depth: 0)
|
|
3780
|
+
def navigate_frame(url, depth: 0, entry: @frame_stack.last)
|
|
3409
3781
|
raise 'too many redirects' if depth > 10
|
|
3410
3782
|
invalidate_find_cache
|
|
3411
3783
|
if url.to_s.match?(%r{\Aabout:blank(?:[?#]|\z)}i)
|
|
3412
|
-
reload_current_frame_realm('about:blank', '', 'text/html')
|
|
3784
|
+
reload_current_frame_realm('about:blank', '', 'text/html', entry: entry)
|
|
3413
3785
|
return
|
|
3414
3786
|
end
|
|
3415
3787
|
env = Rack::MockRequest.env_for(url, method: 'GET')
|
|
@@ -3419,16 +3791,16 @@ module Capybara
|
|
|
3419
3791
|
if (loc = redirect_location(status, headers))
|
|
3420
3792
|
next_url = carry_fragment(url, resolve_against_current(loc))
|
|
3421
3793
|
body.close if body.respond_to?(:close)
|
|
3422
|
-
return navigate_frame(next_url, depth: depth + 1)
|
|
3794
|
+
return navigate_frame(next_url, depth: depth + 1, entry: entry)
|
|
3423
3795
|
end
|
|
3424
3796
|
if download_response?(headers)
|
|
3425
3797
|
save_downloaded_response(url, headers, body)
|
|
3426
3798
|
return
|
|
3427
3799
|
end
|
|
3428
|
-
reload_current_frame_realm(url.to_s, read_rack_body(body), response_content_type(headers))
|
|
3800
|
+
reload_current_frame_realm(url.to_s, read_rack_body(body), response_content_type(headers), entry: entry)
|
|
3429
3801
|
end
|
|
3430
3802
|
|
|
3431
|
-
def navigate_frame_post(url, body, content_type, depth: 0)
|
|
3803
|
+
def navigate_frame_post(url, body, content_type, depth: 0, entry: @frame_stack.last)
|
|
3432
3804
|
raise 'too many redirects' if depth > 10
|
|
3433
3805
|
invalidate_find_cache
|
|
3434
3806
|
env = Rack::MockRequest.env_for(url, method: 'POST', input: body)
|
|
@@ -3442,31 +3814,53 @@ module Capybara
|
|
|
3442
3814
|
resp_body.close if resp_body.respond_to?(:close)
|
|
3443
3815
|
# 301/302/303 → GET; 307/308 preserve method + body (same as navigate_post).
|
|
3444
3816
|
if [307, 308].include?(status)
|
|
3445
|
-
return navigate_frame_post(next_url, body, content_type, depth: depth + 1)
|
|
3817
|
+
return navigate_frame_post(next_url, body, content_type, depth: depth + 1, entry: entry)
|
|
3446
3818
|
else
|
|
3447
|
-
return navigate_frame(next_url, depth: depth + 1)
|
|
3819
|
+
return navigate_frame(next_url, depth: depth + 1, entry: entry)
|
|
3448
3820
|
end
|
|
3449
3821
|
end
|
|
3450
3822
|
if download_response?(headers)
|
|
3451
3823
|
save_downloaded_response(url, headers, resp_body)
|
|
3452
3824
|
return
|
|
3453
3825
|
end
|
|
3454
|
-
reload_current_frame_realm(url.to_s, read_rack_body(resp_body), response_content_type(headers))
|
|
3455
|
-
end
|
|
3456
|
-
|
|
3457
|
-
# Tear down
|
|
3458
|
-
#
|
|
3459
|
-
#
|
|
3460
|
-
|
|
3461
|
-
|
|
3826
|
+
reload_current_frame_realm(url.to_s, read_rack_body(resp_body), response_content_type(headers), entry: entry)
|
|
3827
|
+
end
|
|
3828
|
+
|
|
3829
|
+
# Tear down a frame's realm and rebuild it from `html`, then re-point the
|
|
3830
|
+
# iframe element at the new realm. The iframe lives in the PARENT realm, so
|
|
3831
|
+
# the rebind host fn runs there. `entry` defaults to the active frame; a
|
|
3832
|
+
# `_parent`-targeted navigation passes an ancestor entry instead — every
|
|
3833
|
+
# frame below it in the stack is destroyed along with the ancestor's old
|
|
3834
|
+
# document, so we dispose those realms and leave `@current_realm_id` on the
|
|
3835
|
+
# (now-gone) current frame, surfacing StaleElement for the rest of the open
|
|
3836
|
+
# `within_frame` block. Its `ensure` pops back to `entry`, whose `realm_id`
|
|
3837
|
+
# we've updated to the rebuilt realm.
|
|
3838
|
+
#
|
|
3839
|
+
# Teardown reaches the realms on the entered `@frame_stack` (the ones a
|
|
3840
|
+
# find could route into). Like the self-nav path, descendant realms of the
|
|
3841
|
+
# rebuilt frame that were entered-then-popped earlier (so they no longer
|
|
3842
|
+
# sit on the stack) aren't disposed here — they linger, unreferenced and
|
|
3843
|
+
# un-stepped, until the next full-page rebuild's `dispose_frame_realms`. A
|
|
3844
|
+
# bounded per-test leak, only reachable by re-entering a sibling subframe
|
|
3845
|
+
# before an ancestor `_parent` nav; not worth a JS descendant walk on this
|
|
3846
|
+
# path's perf budget.
|
|
3847
|
+
def reload_current_frame_realm(url, html, content_type, entry: @frame_stack.last)
|
|
3462
3848
|
return unless entry
|
|
3463
3849
|
old_id = entry[:realm_id]
|
|
3464
3850
|
parent = entry[:parent_realm_id]
|
|
3465
3851
|
new_id = @runtime.reload_frame_realm(old_id, parent.to_i, url, RuntimeShared.utf8_text(html), content_type).to_i
|
|
3466
3852
|
return if new_id.zero?
|
|
3467
3853
|
rebind_frame_realm(parent, entry[:iframe_handle], old_id, new_id)
|
|
3468
|
-
entry
|
|
3469
|
-
|
|
3854
|
+
if entry.equal?(@frame_stack.last)
|
|
3855
|
+
entry[:realm_id] = new_id
|
|
3856
|
+
@current_realm_id = new_id
|
|
3857
|
+
else
|
|
3858
|
+
# Match by object identity (the branch was chosen by `equal?`); index
|
|
3859
|
+
# by `==` could collide if two entries were ever structurally equal.
|
|
3860
|
+
idx = @frame_stack.index {|e| e.equal?(entry) }
|
|
3861
|
+
@frame_stack[(idx + 1)..].each {|descendant| @runtime.dispose_frame_realm(descendant[:realm_id]) }
|
|
3862
|
+
entry[:realm_id] = new_id
|
|
3863
|
+
end
|
|
3470
3864
|
invalidate_find_cache
|
|
3471
3865
|
settle
|
|
3472
3866
|
end
|