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.
@@ -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 a self-targeted navigation (a link / form submit whose default
507
- # action loads a new document) all route into the frame — the frame's
508
- # realm is rebuilt from the fetched document, leaving the top page
509
- # untouched (see `navigate_frame`). Out of scope: `_top` navigates the
510
- # main page (correct), but a `_parent` target from a frame nested ≥2
511
- # levels navigates the main page rather than the intermediate frame, and
512
- # cross-origin frame locality is resolved against the main page's origin.
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. `_top`
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 self-targeted link navigates the FRAME, not the
851
- # top page: fetch + rebuild this frame's realm. A pure-fragment link
852
- # is already handled in-realm by the frame's own location JS.
853
- if @current_realm_id && frame_self_target?(target)
854
- unless pure_fragment_navigation?(url)
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 the frame itself
2078
- # navigates the FRAME, not the top page.
2079
- in_frame = !!@current_realm_id && frame_self_target?(spec['target'])
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
- in_frame ? navigate_frame(uri.to_s) : navigate(uri.to_s)
2084
- elsif in_frame
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 = @file_picks && @file_picks[fi['handle'].to_i] || []
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 the active frame's realm and rebuild it from `html`, then
3458
- # re-point the iframe element at the new realm. The iframe lives in the
3459
- # PARENT realm, so the rebind host fn runs there.
3460
- def reload_current_frame_realm(url, html, content_type)
3461
- entry = @frame_stack.last
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[:realm_id] = new_id
3469
- @current_realm_id = new_id
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