capybara-simulated 0.1.1 → 0.3.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'
@@ -53,6 +55,11 @@ module Capybara
53
55
 
54
56
  attr_writer :timers_active
55
57
 
58
+ # The Driver's handle for the window this Browser backs (set right after
59
+ # construction). Lets host fns name the source window of a cross-window
60
+ # `postMessage` / `window.open` so the Driver can route to the target.
61
+ attr_accessor :window_handle
62
+
56
63
  # Sticky window after timers finish: keep polling? true so a
57
64
  # setTimeout firing mid-loop doesn't drop Capybara's synchronize
58
65
  # before its own default_max_wait_time kicks in. Counted in poll
@@ -221,6 +228,13 @@ module Capybara
221
228
  @find_cache_ctx = nil
222
229
  @find_cache_value = nil
223
230
  @document_handle = 0
231
+ # `within_frame` state. `@current_realm_id` is the V8 context id of the
232
+ # active frame realm (nil = the main document); `@frame_stack` records
233
+ # the enclosing realms so `switch_to_frame(:parent)` can pop one level.
234
+ # DOM / node / query ops route through `dom_call`, which dispatches to
235
+ # this realm. nil is the steady state, so the routing is one nil-check.
236
+ @current_realm_id = nil
237
+ @frame_stack = []
224
238
  @last_tick_ts = Process.clock_gettime(Process::CLOCK_MONOTONIC)
225
239
  @polling_grace = nil
226
240
  @last_polled_gen = nil
@@ -261,6 +275,23 @@ module Capybara
261
275
  @event_source_seq = 0
262
276
  @event_source_threads = {}
263
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
264
295
  # Hijacked-XHR delivery — per-Browser handle counter,
265
296
  # background threads, and a Queue of completed responses for
266
297
  # Rack calls where the middleware used `rack.hijack` to hold
@@ -295,6 +326,20 @@ module Capybara
295
326
  @transfer_buffer_lock = Mutex.new
296
327
  @transfer_buffers = {}
297
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
338
+ # Cross-window `postMessage` inbox. Another window's `target.postMessage`
339
+ # routes through the Driver and lands here; this window drains it into a
340
+ # `message` event the next time it's active and settles/ticks. Plain
341
+ # array (same thread — windows aren't background-threaded like workers).
342
+ @window_inbox = []
298
343
  end
299
344
 
300
345
  # Worker thread polling and termination intervals — split so a
@@ -351,8 +396,28 @@ module Capybara
351
396
 
352
397
  def resolve_visit_url(url)
353
398
  s = url.to_s
399
+ # `about:blank` (and other authority-less schemes) have no `//`, so the
400
+ # `scheme://` test below would treat them as relative paths and prepend
401
+ # the host root. `navigate` handles `about:blank` specially — pass it
402
+ # through untouched (open_new_window opens an about:blank tab).
403
+ return s if s.match?(/\Aabout:/i)
354
404
  unless s =~ %r{\A[a-z]+://}i
355
- host_root = (begin URI.parse(@current_url) rescue nil end)&.tap {|u| u.path = ''; u.query = nil; u.fragment = nil }&.to_s || @default_host
405
+ # Strip path/query/fragment off the current URL to get the origin
406
+ # root. An opaque or host-less current URL (e.g. `about:blank` in a
407
+ # freshly-opened window) can't yield an origin — fall back to the
408
+ # default host so a subsequent relative `visit` still resolves.
409
+ host_root =
410
+ begin
411
+ u = URI.parse(@current_url.to_s)
412
+ if u.opaque || u.host.nil?
413
+ @default_host
414
+ else
415
+ u.path = ''; u.query = nil; u.fragment = nil
416
+ u.to_s
417
+ end
418
+ rescue URI::InvalidURIError
419
+ @default_host
420
+ end
356
421
  host_root = host_root.sub(/\/+$/, '')
357
422
  s = "/#{s}" unless s.start_with?('/')
358
423
  s = "#{host_root}#{s}"
@@ -429,11 +494,128 @@ module Capybara
429
494
  @recent_urls_last_push_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
430
495
  end
431
496
 
497
+ attr_reader :current_realm_id
498
+
499
+ # DOM / node / query host-fn dispatch. Inside a `within_frame` block it
500
+ # routes to the active frame realm's context; otherwise straight to the
501
+ # main context. Handle integers are per-realm (each realm is a full
502
+ # bridge with its own registry), so an op on a frame node must run in the
503
+ # realm the handle came from — which, per Capybara's within_frame
504
+ # contract, is the current realm for the block's duration. Hot path:
505
+ # `@current_realm_id` is nil outside frames, so this is one nil-check over
506
+ # a direct `@runtime.call`.
507
+ def dom_call(name, *args)
508
+ return @runtime.call(name, *args) if @current_realm_id.nil?
509
+ # The active frame's realm was torn down mid-block (the iframe was
510
+ # removed or re-navigated). Surface a stale element so Capybara
511
+ # retries / reports, rather than letting realm_call fall back to the
512
+ # main context where this frame handle would mis-resolve.
513
+ unless @runtime.frame_realm_alive?(@current_realm_id)
514
+ raise Capybara::Simulated::StaleElement,
515
+ "frame browsing context #{@current_realm_id} was torn down (frame removed or re-navigated)"
516
+ end
517
+ @runtime.realm_call(@current_realm_id, name, *args)
518
+ end
519
+
520
+ # Root for a context-less find: the active frame's document (handle 0 ⇒
521
+ # the realm's own `globalThis.document`) when in a frame, else the main
522
+ # document handle.
523
+ def current_document_handle
524
+ @current_realm_id ? 0 : @document_handle
525
+ end
526
+
527
+ # Capybara `switch_to_frame`. `target` is an `<iframe>` handle in the
528
+ # CURRENT realm, or `:parent` / `:top`. Entering builds (or reuses) the
529
+ # frame's V8 realm and routes subsequent DOM ops there; `:parent` pops one
530
+ # level, `:top` returns to the main document. Frame switches invalidate
531
+ # the find cache (its keys aren't realm-qualified, and a switch is rare).
532
+ #
533
+ # Scope: finds, reads, interactions (click/fill_in/…), evaluate_script,
534
+ # and a self-targeted navigation (a link / form submit whose default
535
+ # action loads a new document) all route into the frame — the frame's
536
+ # realm is rebuilt from the fetched document, leaving the top page
537
+ # untouched (see `navigate_frame`). Out of scope: `_top` navigates the
538
+ # main page (correct), but a `_parent` target from a frame nested ≥2
539
+ # levels navigates the main page rather than the intermediate frame, and
540
+ # cross-origin frame locality is resolved against the main page's origin.
541
+ def switch_to_frame(target)
542
+ invalidate_find_cache
543
+ case target
544
+ when :parent
545
+ @frame_stack.pop
546
+ @current_realm_id = @frame_stack.last && @frame_stack.last[:realm_id]
547
+ when :top
548
+ reset_frame_scope
549
+ else
550
+ # Per-frame realms are a V8-engine feature; QuickJS has no nested
551
+ # browsing context to route into. Distinguish that (unsupported
552
+ # engine) from a frame that simply failed to build (below), so the
553
+ # error doesn't misattribute a load failure to the engine.
554
+ unless @runtime.respond_to?(:realm_call)
555
+ raise Capybara::Simulated::FrameNotSupported,
556
+ 'within_frame needs a per-frame browsing context, which only the ' \
557
+ 'V8 (rusty_racer) engine provides; QuickJS keeps a same-realm fallback.'
558
+ end
559
+ parent_realm = @current_realm_id
560
+ tick_real_time
561
+ rid = dom_call('__csimEnsureFrameRealm', target.to_i).to_i
562
+ if rid.zero?
563
+ raise Capybara::Simulated::StaleElement,
564
+ "could not enter frame ##{target} (not a frame element, or its document failed to load)"
565
+ end
566
+ # Record the iframe handle + the realm it lives in so a frame-scoped
567
+ # navigation can rebuild this exact frame (`reload_current_frame_realm`).
568
+ @frame_stack.push({realm_id: rid, iframe_handle: target.to_i, parent_realm_id: parent_realm})
569
+ @current_realm_id = rid
570
+ # Let the freshly built realm's inline scripts / load handlers settle
571
+ # so a find immediately inside the block sees the loaded document.
572
+ settle
573
+ end
574
+ end
575
+
576
+ # Return DOM-op routing to the main document and drop any frame stack.
577
+ # Called by `switch_to_frame(:top)`, per-test `reset!`, and every full
578
+ # page (re)build (which disposes all frame realms) — anything that
579
+ # invalidates the active `within_frame` scope.
580
+ def reset_frame_scope
581
+ @current_realm_id = nil
582
+ @frame_stack.clear
583
+ end
584
+
585
+ # The active browsing context's own URL: the frame document's URL inside
586
+ # a `within_frame` block, else the main page URL. Used to resolve a
587
+ # frame-relative navigation and to set its request referrer, so
588
+ # `resolve_against_current` / `pure_fragment_navigation?` work the same
589
+ # whether the navigation originates in the main page or a frame.
590
+ def current_browsing_context_url
591
+ return @current_url unless @current_realm_id
592
+ href = dom_call('__csimLocationHref').to_s
593
+ href.empty? ? @current_url : href
594
+ end
595
+
596
+ # Public entry for the Driver to resolve a `window.open` / cross-window
597
+ # `location` URL against THIS window's document (the internal resolver is
598
+ # private). Honours `<base href>` like the page's own links do.
599
+ def resolve_document_url(url)
600
+ resolve_against_current(url, use_base: true)
601
+ end
602
+
603
+ # Does a link/form `target` load into the CURRENT frame? Empty or `_self`
604
+ # do; `_top` / `_blank` / `_parent` / a named context do not. `_top`
605
+ # correctly navigates the main page (it falls through to `navigate`).
606
+ # `_parent` from a frame nested ≥2 levels would ideally navigate the
607
+ # intermediate parent frame, not the top page — that ancestor-targeted
608
+ # case isn't modelled yet (rare); it currently navigates the main page.
609
+ def frame_self_target?(target)
610
+ t = target.to_s.downcase
611
+ t.empty? || t == '_self'
612
+ end
613
+
432
614
  def find_css(css, context_handle = nil)
433
615
  s = css.to_s
434
616
  return find_xpath(s, context_handle) if xpath_shaped?(s)
435
617
  find_with_timer_fallback(:css, s, context_handle) do
436
- @runtime.call('__csimQuery', context_handle || @document_handle, s).to_a
618
+ dom_call('__csimQuery', context_handle || current_document_handle, s).to_a
437
619
  rescue StandardError => e
438
620
  # Invalid selector → empty result. Callers that genuinely
439
621
  # need the throw go through `evaluate_script`.
@@ -445,7 +627,7 @@ module Capybara
445
627
  def find_first_css(css, context_handle = nil)
446
628
  s = css.to_s
447
629
  find_with_timer_fallback(:css_first, s, context_handle) do
448
- h = @runtime.call('__csimQueryOne', context_handle || @document_handle, s).to_i
630
+ h = dom_call('__csimQueryOne', context_handle || current_document_handle, s).to_i
449
631
  h.zero? ? nil : h
450
632
  rescue StandardError => e
451
633
  raise unless syntax_or_invalid_selector_error?(e)
@@ -481,7 +663,7 @@ module Capybara
481
663
  def find_xpath(xpath, context_handle = nil)
482
664
  xpath_str = xpath.to_s
483
665
  find_with_timer_fallback(:xpath, xpath_str, context_handle) do
484
- @runtime.call('__csimEvaluateXPath', xpath_str, context_handle || 0).to_a
666
+ dom_call('__csimEvaluateXPath', xpath_str, context_handle || 0).to_a
485
667
  end
486
668
  end
487
669
 
@@ -535,7 +717,7 @@ module Capybara
535
717
  # drain that channel, without paying an unconditional drain on
536
718
  # timer-driven runloop pages.
537
719
  def async_io_pending?
538
- worker_pending? || event_source_pending? || hijack_fetch_pending?
720
+ worker_pending? || event_source_pending? || hijack_fetch_pending? || window_message_pending? || websocket_pending?
539
721
  end
540
722
 
541
723
  # Single-slot cache for the most recent find_xpath / find_css /
@@ -569,23 +751,23 @@ module Capybara
569
751
  @find_cache_dirty = true
570
752
  end
571
753
 
572
- def text(handle) = @runtime.call('__csimText', handle).to_s
573
- def tag(handle) = @runtime.call('__csimTag', handle).to_s
574
- def attr(handle, name) = @runtime.call('__csimAttr', handle, name.to_s)
575
- def inner_html(handle) = @runtime.call('__csimInnerHTML', handle).to_s
576
- def outer_html(handle) = @runtime.call('__csimOuterHTML', handle).to_s
754
+ def text(handle) = dom_call('__csimText', handle).to_s
755
+ def tag(handle) = dom_call('__csimTag', handle).to_s
756
+ def attr(handle, name) = dom_call('__csimAttr', handle, name.to_s)
757
+ def inner_html(handle) = dom_call('__csimInnerHTML', handle).to_s
758
+ def outer_html(handle) = dom_call('__csimOuterHTML', handle).to_s
577
759
  def file_input?(handle)
578
760
  tag(handle) == 'input' && attr(handle, 'type').to_s.downcase == 'file'
579
761
  end
580
- def visible?(handle) = @runtime.call('__csimVisible', handle) ? true : false
762
+ def visible?(handle) = dom_call('__csimVisible', handle) ? true : false
581
763
 
582
764
  # Capybara::Driver::Node surface — Node calls `check_stale`
583
765
  # before each read, and that advances the virtual clock.
584
766
  def all_text(handle) = text(handle)
585
- def visible_text(handle) = @runtime.call('__csimVisibleText', handle).to_s
767
+ def visible_text(handle) = dom_call('__csimVisibleText', handle).to_s
586
768
  def tag_name(handle) = tag(handle)
587
- def value(handle) = @runtime.call('__csimValue', handle)
588
- def disabled?(handle) = @runtime.call('__csimDisabled', handle)
769
+ def value(handle) = dom_call('__csimValue', handle)
770
+ def disabled?(handle) = dom_call('__csimDisabled', handle)
589
771
  # HTML spec: `<option>.selected` IDL is true when the `selected`
590
772
  # *attribute* is set OR when no sibling option has `selected` and
591
773
  # this is the first non-disabled option of a single-select
@@ -595,28 +777,28 @@ module Capybara
595
777
  # `<option selected>` reports no selected options and the matcher
596
778
  # fails even though the first option *is* the currently chosen
597
779
  # one in real browsers.
598
- def option_selected?(h) = !!@runtime.call('__csimOptionSelected', h)
780
+ def option_selected?(h) = !!dom_call('__csimOptionSelected', h)
599
781
  def shadow_root_handle(handle)
600
- h = @runtime.call('__csimShadowRoot', handle).to_i
782
+ h = dom_call('__csimShadowRoot', handle).to_i
601
783
  h.zero? ? nil : h
602
784
  end
603
785
  def computed_style(handle, names)
604
786
  tick_real_time
605
- result = @runtime.call('__csimComputedStyle', handle, names.map(&:to_s))
787
+ result = dom_call('__csimComputedStyle', handle, names.map(&:to_s))
606
788
  return names.to_h {|n| [n, ''] } unless result.is_a?(Hash)
607
789
  result.transform_keys(&:to_s)
608
790
  end
609
- def node_path(handle) = @runtime.call('__csimNodePath', handle).to_s
791
+ def node_path(handle) = dom_call('__csimNodePath', handle).to_s
610
792
 
611
793
  def lookup_node(handle)
612
- handle if @runtime.call('__csimAlive', handle)
794
+ handle if dom_call('__csimAlive', handle)
613
795
  end
614
796
 
615
797
  def check_stale(handle, initial, gen = nil)
616
- return if initial && (gen.nil? || gen == @context_gen) && @runtime.call('__csimAlive', handle)
798
+ return if initial && (gen.nil? || gen == @context_gen) && dom_call('__csimAlive', handle)
617
799
 
618
800
  tick_real_time
619
- return if initial && (gen.nil? || gen == @context_gen) && @runtime.call('__csimAlive', handle)
801
+ return if initial && (gen.nil? || gen == @context_gen) && dom_call('__csimAlive', handle)
620
802
 
621
803
  raise Capybara::Simulated::StaleElement, "Element with handle #{handle} is no longer attached to the document"
622
804
  end
@@ -630,7 +812,7 @@ module Capybara
630
812
  # silently no-op (or, in the case of `__csimClickResolve`,
631
813
  # dispatch on a detached node whose listeners no longer matter).
632
814
  def ensure_alive_after_tick(handle)
633
- return if @runtime.call('__csimAlive', handle)
815
+ return if dom_call('__csimAlive', handle)
634
816
  raise Capybara::Simulated::StaleElement, "Element with handle #{handle} is no longer attached to the document"
635
817
  end
636
818
 
@@ -646,11 +828,11 @@ module Capybara
646
828
  # Wall-sleep between mousedown and mouseup so click handlers
647
829
  # reading `Date.now()` see the elapsed gap (selenium parity).
648
830
  init['mouseDownOnly'] = true
649
- partial = @runtime.call('__csimClickResolve', handle, init)
831
+ partial = dom_call('__csimClickResolve', handle, init)
650
832
  sleep delay
651
- @runtime.call('__csimClickFinish', handle, partial.is_a?(Hash) ? partial['base'] : init)
833
+ dom_call('__csimClickFinish', handle, partial.is_a?(Hash) ? partial['base'] : init)
652
834
  else
653
- @runtime.call('__csimClickResolve', handle, init)
835
+ dom_call('__csimClickResolve', handle, init)
654
836
  end
655
837
  unless action.is_a?(Hash)
656
838
  settle
@@ -693,11 +875,21 @@ module Capybara
693
875
  when 'navigate'
694
876
  url = action['url'].to_s
695
877
  target = action['target'].to_s
878
+ # Inside a frame, a self-targeted link navigates the FRAME, not the
879
+ # top page: fetch + rebuild this frame's realm. A pure-fragment link
880
+ # is already handled in-realm by the frame's own location JS.
881
+ if @current_realm_id && frame_self_target?(target)
882
+ unless pure_fragment_navigation?(url)
883
+ tick_real_time
884
+ navigate_frame(resolve_against_current(url, use_base: true))
885
+ end
696
886
  # `target="_blank"` (or any non-_self/_top/_parent name) opens
697
- # in a new browsing context. URL-only multi-window mode
698
- # records the URL against a fresh aux handle; the primary
699
- # stays put (per HTML spec original window is unaffected).
700
- if !target.empty? && !%w[_self _top _parent].include?(target.downcase) && @driver.respond_to?(:open_aux_window)
887
+ # in a new browsing context (its own Browser/VM); the primary
888
+ # stays put (per HTML spec original window is unaffected). No
889
+ # `opener_handle` is passed: modern browsers default `target=_blank`
890
+ # to `noopener` (so `window.opener` is null), unlike JS `window.open`
891
+ # which keeps the opener — see `open_window_from_js`.
892
+ elsif !target.empty? && !%w[_self _top _parent].include?(target.downcase) && @driver.respond_to?(:open_aux_window)
701
893
  @driver.open_aux_window(resolve_against_current(url, use_base: true))
702
894
  # In-page anchor links (`#frag` / current-page + `#frag`) move
703
895
  # the hash but don't fetch a new document. Pure-fragment also
@@ -772,10 +964,11 @@ module Capybara
772
964
 
773
965
  def pure_fragment_navigation?(url)
774
966
  return true if url.start_with?('#')
775
- return false if @current_url.nil?
967
+ doc_url = current_browsing_context_url
968
+ return false if doc_url.nil?
776
969
  target = resolve_against_current(url)
777
970
  a = URI.parse(target)
778
- b = URI.parse(@current_url)
971
+ b = URI.parse(doc_url)
779
972
  # Same-document iff everything but the fragment matches AND the
780
973
  # fragment actually changes — `a.fragment != b.fragment` covers
781
974
  # both adding/changing a fragment and *clearing* one (target has
@@ -854,14 +1047,14 @@ module Capybara
854
1047
  'lastModified' => stat ? (stat.mtime.to_f * 1000).to_i : 0
855
1048
  }
856
1049
  }
857
- @runtime.call('__csimSetFiles', handle, file_infos)
1050
+ dom_call('__csimSetFiles', handle, file_infos)
858
1051
  # Mirror real browser: <input type=file>.value reflects only
859
1052
  # the filename of the first chosen file (security-faked path).
860
1053
  # __csimSetValue dispatches input + change synchronously.
861
1054
  js_value = paths.first ? File.basename(paths.first) : ''
862
- @runtime.call('__csimSetValue', handle, js_value)
1055
+ dom_call('__csimSetValue', handle, js_value)
863
1056
  else
864
- @runtime.call('__csimSetValue', handle, coerced)
1057
+ dom_call('__csimSetValue', handle, coerced)
865
1058
  end
866
1059
  drain_after_user_action
867
1060
  end
@@ -919,10 +1112,10 @@ module Capybara
919
1112
  invalidate_find_cache
920
1113
  ensure_alive_after_tick(handle)
921
1114
  init = {'bubbles' => true, 'cancelable' => true, 'button' => 2, 'which' => 3}.merge(click_event_init(handle, keys, opts))
922
- @runtime.call('__csimDispatchEvent', handle, 'mousedown', init)
1115
+ dom_call('__csimDispatchEvent', handle, 'mousedown', init)
923
1116
  sleep opts[:delay].to_f if opts[:delay].to_f > 0
924
- @runtime.call('__csimDispatchEvent', handle, 'mouseup', init)
925
- @runtime.call('__csimDispatchEvent', handle, 'contextmenu', init)
1117
+ dom_call('__csimDispatchEvent', handle, 'mouseup', init)
1118
+ dom_call('__csimDispatchEvent', handle, 'contextmenu', init)
926
1119
  end
927
1120
 
928
1121
  # HTML5 drag-and-drop simulation. Capybara routes `Element#drop`
@@ -934,7 +1127,7 @@ module Capybara
934
1127
  invalidate_find_cache
935
1128
  ensure_alive_after_tick(handle)
936
1129
  items = args.flat_map {|arg| drop_items(arg) }
937
- @runtime.call('__csimDropOnto', handle, items)
1130
+ dom_call('__csimDropOnto', handle, items)
938
1131
  end
939
1132
 
940
1133
  # Element-to-element drag. Capybara's `Element#drag_to(target,
@@ -950,7 +1143,7 @@ module Capybara
950
1143
  invalidate_find_cache
951
1144
  ensure_alive_after_tick(source_handle)
952
1145
  ensure_alive_after_tick(target_handle)
953
- @runtime.call('__csimDragOnto', source_handle, target_handle)
1146
+ dom_call('__csimDragOnto', source_handle, target_handle)
954
1147
  drain_after_user_action
955
1148
  end
956
1149
  def drop_items(arg)
@@ -975,14 +1168,14 @@ module Capybara
975
1168
  # UI Events spec: two full mousedown→mouseup→click chains
976
1169
  # before the trailing `dblclick`. Jspreadsheet (table-builder's
977
1170
  # `.jss_worksheet`) enters edit mode on the inner mousedown.
978
- 2.times { @runtime.call('__csimClickResolve', handle, opts) }
1171
+ 2.times { dom_call('__csimClickResolve', handle, opts) }
979
1172
  init = {'bubbles' => true, 'cancelable' => true}.merge(click_event_init(handle, keys, opts))
980
- @runtime.call('__csimDispatchEvent', handle, 'dblclick', init)
1173
+ dom_call('__csimDispatchEvent', handle, 'dblclick', init)
981
1174
  # Real browsers' default-action on dblclick selects the word
982
1175
  # under the cursor — ProseMirror / Tiptap "paste URL over
983
1176
  # selection wraps with link" tests rely on the word being
984
1177
  # selected before the paste.
985
- @runtime.call('__csimSelectWordAt', handle)
1178
+ dom_call('__csimSelectWordAt', handle)
986
1179
  settle
987
1180
  end
988
1181
 
@@ -1015,7 +1208,7 @@ module Capybara
1015
1208
  has_xy = opts[:x] || opts[:y]
1016
1209
  center = opts[:offset] == :center || !has_xy
1017
1210
  if has_xy || center
1018
- rect = @runtime.call('__csimElementRect', handle)
1211
+ rect = dom_call('__csimElementRect', handle)
1019
1212
  base_x = rect['x'].to_f + (center ? rect['width'].to_f / 2.0 : 0.0)
1020
1213
  base_y = rect['y'].to_f + (center ? rect['height'].to_f / 2.0 : 0.0)
1021
1214
  out['clientX'] = base_x + opts[:x].to_f
@@ -1038,14 +1231,14 @@ module Capybara
1038
1231
  # double-eval recursion the inlined `globalThis.document.
1039
1232
  # _hoverElement = ...` triggered (the eval string ran inside
1040
1233
  # a fresh microtask that re-entered the hover listeners).
1041
- @runtime.call('__csimSetHover', handle)
1234
+ dom_call('__csimSetHover', handle)
1042
1235
  end
1043
1236
 
1044
1237
  def dispatch_event(handle, type, init = {})
1045
1238
  tick_real_time
1046
1239
  invalidate_find_cache
1047
1240
  ensure_alive_after_tick(handle)
1048
- @runtime.call('__csimDispatchEvent', handle, type.to_s, init)
1241
+ dom_call('__csimDispatchEvent', handle, type.to_s, init)
1049
1242
  end
1050
1243
 
1051
1244
  # Capybara's `send_keys` accepts Strings and Symbols (special
@@ -1097,20 +1290,20 @@ module Capybara
1097
1290
  # before the next char arrives. Plain `<input>` / `<textarea>`
1098
1291
  # don't need this — keep the single batched call there.
1099
1292
  has_multichar_text = atoms.any? {|a| a['kind'] == 'text' && a['value'].to_s.length > 1 }
1100
- if has_multichar_text && @runtime.call('__csimIsContentEditable', handle)
1293
+ if has_multichar_text && dom_call('__csimIsContentEditable', handle)
1101
1294
  per_char = atoms.flat_map {|a|
1102
1295
  next a unless a['kind'] == 'text' && a['value'].to_s.length > 1
1103
1296
  a['value'].to_s.each_char.map {|c| {'kind' => 'text', 'value' => c} }
1104
1297
  }
1105
1298
  head, *tail = per_char
1106
- @runtime.call('__csimSendKeys', handle, [head])
1299
+ dom_call('__csimSendKeys', handle, [head])
1107
1300
  tail.each {|atom|
1108
1301
  tick_real_time
1109
- @runtime.call('__csimSendKeys', handle, [atom])
1302
+ dom_call('__csimSendKeys', handle, [atom])
1110
1303
  settle
1111
1304
  }
1112
1305
  else
1113
- @runtime.call('__csimSendKeys', handle, atoms)
1306
+ dom_call('__csimSendKeys', handle, atoms)
1114
1307
  end
1115
1308
  drain_after_user_action
1116
1309
  end
@@ -1119,7 +1312,7 @@ module Capybara
1119
1312
  mark_action_baseline
1120
1313
  tick_real_time
1121
1314
  invalidate_find_cache
1122
- @runtime.call('__csimSelectOption', handle)
1315
+ dom_call('__csimSelectOption', handle)
1123
1316
  tick_real_time
1124
1317
  drain_after_user_action
1125
1318
  end
@@ -1133,11 +1326,11 @@ module Capybara
1133
1326
  # the JS side whether the option's parent select is `multiple`
1134
1327
  # before issuing the unselect; the answer doubles as the
1135
1328
  # "found the right ancestor" check.
1136
- info = @runtime.call('__csimOptionContext', handle)
1329
+ info = dom_call('__csimOptionContext', handle)
1137
1330
  if info.is_a?(Hash) && info['hasSelect'] && !info['multiple']
1138
1331
  raise Capybara::UnselectNotAllowed, 'Cannot unselect option from single select box.'
1139
1332
  end
1140
- @runtime.call('__csimUnselectOption', handle)
1333
+ dom_call('__csimUnselectOption', handle)
1141
1334
  tick_real_time
1142
1335
  drain_after_user_action
1143
1336
  end
@@ -1208,8 +1401,10 @@ module Capybara
1208
1401
  deliver_event_source_events
1209
1402
  deliver_worker_messages
1210
1403
  deliver_hijacked_fetches
1404
+ deliver_window_messages
1405
+ deliver_websocket_events
1211
1406
  break if @runtime.settle_gen > start_gen
1212
- break unless @timers_active || event_source_pending? || worker_pending? || hijack_fetch_pending?
1407
+ break unless @timers_active || event_source_pending? || worker_pending? || hijack_fetch_pending? || window_message_pending? || websocket_pending?
1213
1408
  # ONE event-loop step replaces the old drain_microtasks(4)+drain_timers(32)
1214
1409
  # pair: it fires due timers, runs a per-task microtask checkpoint (so
1215
1410
  # chained .then / MutationObserver delivery interleave spec-correctly),
@@ -1221,13 +1416,15 @@ module Capybara
1221
1416
  deliver_event_source_events
1222
1417
  deliver_worker_messages
1223
1418
  deliver_hijacked_fetches
1419
+ deliver_window_messages
1420
+ deliver_websocket_events
1224
1421
  break if @runtime.settle_gen > start_gen
1225
1422
  # No progress this iter (no DOM/URL change observed) — the
1226
1423
  # remaining timers are queued for the future; bail and let
1227
1424
  # Capybara's wall-clock-driven poll loop drive the next tick
1228
1425
  # via `tick_real_time`. SSE / Worker channels keep us in
1229
1426
  # the loop as long as background threads have data queued.
1230
- break if @runtime.settle_gen == prev_gen && !@runtime.has_ready_timer? && !event_source_pending? && !worker_pending? && !hijack_fetch_pending?
1427
+ break if @runtime.settle_gen == prev_gen && !@runtime.has_ready_timer? && !event_source_pending? && !worker_pending? && !hijack_fetch_pending? && !window_message_pending? && !websocket_pending?
1231
1428
  prev_gen = @runtime.settle_gen
1232
1429
  end
1233
1430
  @find_cache_dirty = true
@@ -1285,7 +1482,7 @@ module Capybara
1285
1482
  def submit_form(handle)
1286
1483
  tick_real_time
1287
1484
  invalidate_find_cache
1288
- form_handle = @runtime.call('__csimAncestorForm', handle).to_i
1485
+ form_handle = dom_call('__csimAncestorForm', handle).to_i
1289
1486
  return if form_handle.zero?
1290
1487
  submit_form_handle(form_handle, nil)
1291
1488
  end
@@ -1295,9 +1492,11 @@ module Capybara
1295
1492
  @runtime.call('__csimDocumentTitle').to_s
1296
1493
  end
1297
1494
 
1495
+ # `page.html` inside a `within_frame` block returns the frame document's
1496
+ # source (Selenium parity), so route through the active realm.
1298
1497
  def html
1299
1498
  tick_real_time
1300
- @runtime.call('__csimDocumentHtml').to_s
1499
+ dom_call('__csimDocumentHtml').to_s
1301
1500
  end
1302
1501
 
1303
1502
  def status_code = (@last_response_status || 200)
@@ -1449,7 +1648,7 @@ module Capybara
1449
1648
  end
1450
1649
  def active_element_handle
1451
1650
  tick_real_time
1452
- h = @runtime.call('__csimActiveElement').to_i
1651
+ h = dom_call('__csimActiveElement').to_i
1453
1652
  h.zero? ? nil : h
1454
1653
  end
1455
1654
  # Session-level keystroke. Tab / shift-tab cycle focus; everything
@@ -1467,12 +1666,12 @@ module Capybara
1467
1666
  Array(keys).each do |k|
1468
1667
  sym = k.is_a?(Symbol) ? k : (k.respond_to?(:to_sym) ? k.to_sym : nil)
1469
1668
  if sym == :tab || sym == :backtab
1470
- @runtime.call('__csimAdvanceFocus', sym == :backtab)
1669
+ dom_call('__csimAdvanceFocus', sym == :backtab)
1471
1670
  elsif sym && MODIFIER_KEY_NAMES.include?(sym)
1472
1671
  held << sym
1473
1672
  else
1474
1673
  handle = active_element_handle
1475
- handle = @document_handle if handle.nil? || handle.zero?
1674
+ handle = current_document_handle if handle.nil? || handle.zero?
1476
1675
  atom = held.empty? ? k : (held + [k])
1477
1676
  send_keys(handle, [atom])
1478
1677
  end
@@ -1582,7 +1781,7 @@ module Capybara
1582
1781
  # description Proc).
1583
1782
  def describe_node_handle(handle)
1584
1783
  return "handle=#{handle}" if handle.nil? || handle.zero?
1585
- info = @runtime.call('__csimDescribeNode', handle)
1784
+ info = dom_call('__csimDescribeNode', handle)
1586
1785
  return "handle=#{handle}" unless info.is_a?(Hash)
1587
1786
  s = info['tag'].to_s
1588
1787
  s += "##{info['id']}" unless info['id'].to_s.empty?
@@ -1597,7 +1796,9 @@ module Capybara
1597
1796
  # to be active.
1598
1797
  tick_real_time
1599
1798
  invalidate_find_cache
1600
- result = @runtime.call('__csimEvalScript', code.to_s, marshal_args(args || []))
1799
+ # Routes to the active frame realm inside `within_frame` (Selenium
1800
+ # parity: `evaluate_script` runs in the current browsing context).
1801
+ result = dom_call('__csimEvalScript', code.to_s, marshal_args(args || []))
1601
1802
  drain_pending_navigation
1602
1803
  result
1603
1804
  end
@@ -1609,7 +1810,7 @@ module Capybara
1609
1810
  def execute_script(code, args = [])
1610
1811
  tick_real_time
1611
1812
  invalidate_find_cache
1612
- @runtime.call('__csimExecScript', code.to_s, marshal_args(args || []))
1813
+ dom_call('__csimExecScript', code.to_s, marshal_args(args || []))
1613
1814
  drain_pending_navigation
1614
1815
  nil
1615
1816
  end
@@ -1666,14 +1867,17 @@ module Capybara
1666
1867
  def evaluate_async_script(code, args = [])
1667
1868
  tick_real_time
1668
1869
  invalidate_find_cache
1669
- @runtime.call('__evalAsyncScript', code.to_s, marshal_args(args || []))
1870
+ # Runs in the active frame realm inside `within_frame` (Selenium
1871
+ # parity), same as evaluate_script; the result slot is realm-local so
1872
+ # the poll below must read from the same realm.
1873
+ dom_call('__evalAsyncScript', code.to_s, marshal_args(args || []))
1670
1874
  # Pump virtual time so any setTimeout-driven completion lands.
1671
1875
  # Capybara's polling can't help here — we're inside one session
1672
1876
  # call, not a retry loop.
1673
1877
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) +
1674
1878
  Capybara.default_max_wait_time.to_f
1675
1879
  loop do
1676
- result = @runtime.call('__pollAsyncResult')
1880
+ result = dom_call('__pollAsyncResult')
1677
1881
  return result['value'] if result.is_a?(Hash) && result.key?('value')
1678
1882
  break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
1679
1883
  sleep 0.01
@@ -1712,7 +1916,7 @@ module Capybara
1712
1916
  # Background-thread work (workers, EventSource, MessageBus
1713
1917
  # long-poll) keeps the settle loop alive even when settle_gen
1714
1918
  # is otherwise idle.
1715
- return true if worker_pending? || event_source_pending? || hijack_fetch_pending?
1919
+ return true if worker_pending? || event_source_pending? || hijack_fetch_pending? || window_message_pending? || websocket_pending?
1716
1920
  if @timers_active
1717
1921
  gen = @runtime.settle_gen
1718
1922
  if @last_polled_gen.nil? || gen != @last_polled_gen
@@ -1743,7 +1947,7 @@ module Capybara
1743
1947
  # `Kernel#sleep`) and by `Playwright::Page#wait_for_timeout` to step a
1744
1948
  # precise virtual duration.
1745
1949
  def tick_real_time(step_ms: nil)
1746
- return unless @timers_active || worker_pending? || event_source_pending? || hijack_fetch_pending?
1950
+ return unless @timers_active || worker_pending? || event_source_pending? || hijack_fetch_pending? || window_message_pending? || websocket_pending?
1747
1951
  # Re-entrancy guard. Capybara's `Result#each` triggers nested
1748
1952
  # finds (visible? per element); the outermost tick has already
1749
1953
  # advanced the clock, the inner calls would only re-drain
@@ -1772,6 +1976,8 @@ module Capybara
1772
1976
  @find_cache_dirty = true if deliver_worker_messages > 0
1773
1977
  @find_cache_dirty = true if deliver_event_source_events > 0
1774
1978
  @find_cache_dirty = true if deliver_hijacked_fetches > 0
1979
+ @find_cache_dirty = true if deliver_window_messages > 0
1980
+ @find_cache_dirty = true if deliver_websocket_events > 0
1775
1981
  ensure
1776
1982
  @ticking = false
1777
1983
  end
@@ -1815,7 +2021,7 @@ module Capybara
1815
2021
  end
1816
2022
  # (1) Background async (cheap Ruby-side checks, no V8 crossing) we must let
1817
2023
  # land before jumping the clock: advance one fixed step, reset the guard.
1818
- if worker_pending? || event_source_pending? || hijack_fetch_pending?
2024
+ if worker_pending? || event_source_pending? || hijack_fetch_pending? || websocket_pending?
1819
2025
  @ff_transient_polls = 0
1820
2026
  return POLL_TICK_STEP_MS
1821
2027
  end
@@ -1875,7 +2081,7 @@ module Capybara
1875
2081
  # drives the Rack app via `navigate` (for GET) or a POST.
1876
2082
  def submit_form_handle(form_handle, submitter_handle)
1877
2083
  invalidate_find_cache
1878
- spec = @runtime.call('__csimFormSerialize', form_handle, submitter_handle || 0)
2084
+ spec = dom_call('__csimFormSerialize', form_handle, submitter_handle || 0)
1879
2085
  return unless spec.is_a?(Hash)
1880
2086
  action = spec['action'].to_s
1881
2087
  method = spec['method'].to_s.upcase
@@ -1898,11 +2104,16 @@ module Capybara
1898
2104
  end
1899
2105
  URI.encode_www_form(fields)
1900
2106
  end
1901
- action_url = action.empty? ? (@current_url || @default_host) : resolve_against_current(action)
2107
+ action_url = action.empty? ? (current_browsing_context_url || @default_host) : resolve_against_current(action)
2108
+ # A form submitted inside a frame whose target is the frame itself
2109
+ # navigates the FRAME, not the top page.
2110
+ in_frame = !!@current_realm_id && frame_self_target?(spec['target'])
1902
2111
  if method == 'GET'
1903
2112
  uri = URI.parse(action_url)
1904
2113
  uri.query = body unless body.empty?
1905
- navigate(uri.to_s)
2114
+ in_frame ? navigate_frame(uri.to_s) : navigate(uri.to_s)
2115
+ elsif in_frame
2116
+ navigate_frame_post(action_url, body, content_type || enctype)
1906
2117
  else
1907
2118
  navigate_post(action_url, body, content_type || enctype)
1908
2119
  end
@@ -2001,6 +2212,10 @@ module Capybara
2001
2212
  end
2002
2213
  @current_url = nil
2003
2214
  @document_handle = 0
2215
+ # A test may leave a frame switched-to without switching back
2216
+ # (Capybara's reset_session spec covers exactly this); start the
2217
+ # next test back on the main document.
2218
+ reset_frame_scope
2004
2219
  @history.clear
2005
2220
  @history_idx = -1
2006
2221
  @file_picks = {} if @file_picks
@@ -2019,6 +2234,11 @@ module Capybara
2019
2234
  reset_event_sources
2020
2235
  reset_hijacked_fetches
2021
2236
  reset_workers
2237
+ reset_websockets
2238
+ @window_inbox.clear
2239
+ # Free any zero-copy transfer backing stores that went unimported
2240
+ # (worker killed before draining its inbox, etc.) before the rebuild.
2241
+ drop_pending_transfers
2022
2242
  @blob_registry_lock.synchronize { @blob_registry.clear }
2023
2243
  # Drop volatile entries from the class-level HTTP asset cache
2024
2244
  # so test-local DB state (TranslationOverride, etc.) reaches
@@ -2035,6 +2255,23 @@ module Capybara
2035
2255
  invalidate_find_cache
2036
2256
  end
2037
2257
 
2258
+ # Tear down an auxiliary window's Browser when its window closes (the
2259
+ # Driver calls this on close_window / reset!). Releases what a bare GC of
2260
+ # the isolate would NOT: live background threads (worker / SSE / hijacked-
2261
+ # fetch / WebSocket readers) and any parked zero-copy transfer backing
2262
+ # stores this window issued (the transfer registry is process-wide). Runs
2263
+ # while the runtime is still alive so the transferDrop call lands.
2264
+ def dispose
2265
+ drop_pending_transfers
2266
+ reset_workers
2267
+ reset_event_sources
2268
+ reset_hijacked_fetches
2269
+ reset_websockets
2270
+ @window_inbox.clear
2271
+ rescue StandardError
2272
+ nil
2273
+ end
2274
+
2038
2275
  # ── Host-fn callbacks invoked by bridge.js ──────────────────
2039
2276
 
2040
2277
  def rack_fetch_body(url)
@@ -2314,6 +2551,254 @@ module Capybara
2314
2551
  @event_source_queue.clear
2315
2552
  end
2316
2553
 
2554
+ # ── WebSocket (RFC6455 over in-process rack.hijack) ────────────
2555
+ #
2556
+ # A real browser's WebSocket connects, upgrades, and stays open for
2557
+ # bidirectional framing. Action Cable (and any Rack WebSocket
2558
+ # middleware) handles the upgrade by HIJACKING the connection and
2559
+ # speaking frames over that socket — the same in-process `rack.hijack`
2560
+ # mechanism the long-poll path already uses, but bidirectional. So we:
2561
+ # 1. build the upgrade request as a Rack env (the server reads the
2562
+ # handshake from the env, like websocket-driver's rack helper),
2563
+ # 2. hand the app a `Socket.pair` end via `rack.hijack` and call it,
2564
+ # 3. the app writes the 101 + frames to its end; we read/write ours.
2565
+ # The frame reader runs on a background thread (engine access stays on
2566
+ # the main thread — events drain into the VM at `settle`, like SSE).
2567
+ WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
2568
+ private_constant :WS_GUID
2569
+
2570
+ def ws_open(url, protocols = nil)
2571
+ id = (@websocket_seq += 1)
2572
+ # ws:// → http://, wss:// → https:// for the Rack env; resolve relative
2573
+ # against the current document (Action Cable's consumer builds an
2574
+ # absolute ws URL, but be tolerant).
2575
+ http_url = url.to_s.sub(/\Awss/i, 'https').sub(/\Aws/i, 'http')
2576
+ target = resolve_against_current(http_url)
2577
+ key = SecureRandom.base64(16)
2578
+ csim_io, app_io = Socket.pair(:UNIX, :STREAM, 0)
2579
+ env = Rack::MockRequest.env_for(target, method: 'GET')
2580
+ apply_default_request_env(env, referer: @current_url)
2581
+ env['HTTP_UPGRADE'] = 'websocket'
2582
+ env['HTTP_CONNECTION'] = 'Upgrade'
2583
+ env['HTTP_SEC_WEBSOCKET_KEY'] = key
2584
+ env['HTTP_SEC_WEBSOCKET_VERSION'] = '13'
2585
+ list = Array(protocols).map(&:to_s).reject(&:empty?)
2586
+ env['HTTP_SEC_WEBSOCKET_PROTOCOL'] = list.join(', ') unless list.empty?
2587
+ env['rack.hijack?'] = true
2588
+ env['rack.hijack'] = -> { app_io }
2589
+ env['rack.hijack_io'] = app_io
2590
+ # The app hijacks + writes the 101 (synchronously, or on its own event
2591
+ # loop thread — Action Cable handles the upgrade on a separate thread, so
2592
+ # `@app.call` may return before the handshake bytes appear; the reader
2593
+ # blocks until they do). Run it on the main thread like the long-poll
2594
+ # hijack so we don't race a second concurrent `@app.call`. (No handshake
2595
+ # timeout: a server that never writes the 101 leaks the reader+socket
2596
+ # until `reset_websockets` — acceptable, real servers always respond.)
2597
+ @app.call(env)
2598
+ @websocket_sockets[id] = csim_io
2599
+ @websocket_app_sockets[id] = app_io
2600
+ accept = Digest::SHA1.base64digest(key + WS_GUID)
2601
+ queue = @websocket_queue
2602
+ @websocket_threads[id] = Thread.new do
2603
+ Thread.current.report_on_exception = false
2604
+ run_websocket_reader(id, csim_io, accept, queue)
2605
+ end
2606
+ id
2607
+ rescue StandardError => e
2608
+ # Nothing was registered for cleanup yet — close both pair ends here so
2609
+ # a failed upgrade (mis-routed URL, app error) doesn't leak fds.
2610
+ csim_io.close rescue nil
2611
+ app_io.close rescue nil
2612
+ @websocket_queue << {id: id, type: '__error', message: "#{e.class}: #{e.message}"}
2613
+ id
2614
+ end
2615
+
2616
+ # `binary` is set by the JS side (it knows whether `send` was given a
2617
+ # string or an ArrayBuffer/view) → opcode 0x2 vs the text 0x1. Action
2618
+ # Cable is text-only (JSON). The payload's bytes are written as-is.
2619
+ def ws_send(id, data, binary = false)
2620
+ sock = @websocket_sockets[id.to_i] or return
2621
+ ws_write_frame(sock, binary ? 0x2 : 0x1, data.to_s.b)
2622
+ nil
2623
+ rescue StandardError
2624
+ nil
2625
+ end
2626
+
2627
+ def ws_close(id, code = 1000, reason = '')
2628
+ sock = @websocket_sockets[id.to_i] or return
2629
+ # Send the close frame and let the close HANDSHAKE complete: the server
2630
+ # replies with its own close frame, which the reader thread surfaces as
2631
+ # the `__close` event (carrying the agreed code) before tearing the
2632
+ # socket down in its `ensure`. Force teardown is `reset_websockets`'s job.
2633
+ payload = [code.to_i].pack('n') + reason.to_s.b
2634
+ ws_write_frame(sock, 0x8, payload) rescue nil
2635
+ nil
2636
+ end
2637
+
2638
+ def websocket_pending? = !@websocket_queue.empty?
2639
+
2640
+ def deliver_websocket_events
2641
+ return 0 if @websocket_threads.empty? && @websocket_queue.empty?
2642
+ events = drain_queue(@websocket_queue)
2643
+ return 0 if events.empty?
2644
+ @runtime.call('__csim_deliverWebSocketEvents', events)
2645
+ events.size
2646
+ end
2647
+
2648
+ def reset_websockets
2649
+ @websocket_threads.each_value(&:kill)
2650
+ @websocket_threads.clear
2651
+ # Close BOTH pair ends: csim's read/write end and the app's hijack end
2652
+ # (the app may abandon its end without closing it — e.g. its connection
2653
+ # thread was just killed), so neither leaks across tests.
2654
+ @websocket_sockets.each_value {|s| s.close rescue nil }
2655
+ @websocket_app_sockets.each_value {|s| s.close rescue nil }
2656
+ @websocket_sockets.clear
2657
+ @websocket_app_sockets.clear
2658
+ @websocket_queue.clear
2659
+ end
2660
+
2661
+ # Background-thread frame reader: verify the 101 handshake, then loop
2662
+ # decoding server→client frames into queue events until close / EOF.
2663
+ private def run_websocket_reader(id, sock, expected_accept, queue)
2664
+ ok, protocol = ws_read_handshake(sock, expected_accept)
2665
+ unless ok
2666
+ queue << {id: id, type: '__error', message: 'websocket handshake failed'}
2667
+ return
2668
+ end
2669
+ # Carry the negotiated subprotocol — Action Cable's client closes the
2670
+ # connection in its `onopen` unless `webSocket.protocol` is one it knows
2671
+ # (`actioncable-v1-json`).
2672
+ queue << {id: id, type: '__open', protocol: protocol}
2673
+ loop do
2674
+ frame = ws_read_message(sock, queue, id)
2675
+ break if frame.nil? # EOF
2676
+ opcode, payload = frame
2677
+ if opcode == :close
2678
+ code = payload.bytesize >= 2 ? payload[0, 2].unpack1('n') : 1005
2679
+ reason = payload.bytesize > 2 ? RuntimeShared.utf8_text(payload[2..]) : ''
2680
+ queue << {id: id, type: '__close', code: code, reason: reason}
2681
+ break
2682
+ end
2683
+ # Binary frames cross to JS as raw bytes (wrap_binary) tagged so the
2684
+ # JS side decodes them per `binaryType`; text is UTF-8.
2685
+ if opcode == 0x2
2686
+ queue << {id: id, type: 'message', binary: true, data: @runtime.wrap_binary(payload)}
2687
+ else
2688
+ queue << {id: id, type: 'message', data: RuntimeShared.utf8_text(payload)}
2689
+ end
2690
+ end
2691
+ rescue StandardError => e
2692
+ queue << {id: id, type: '__close', code: 1006, reason: e.message.to_s[0, 120]}
2693
+ ensure
2694
+ # Only the socket (a local) is closed here — the `@websocket_*` hashes
2695
+ # are mutated solely on the main thread (`reset_websockets`) to avoid a
2696
+ # cross-thread Hash race; a closed entry just no-ops on the next access.
2697
+ sock.close rescue nil
2698
+ end
2699
+
2700
+ # Read + validate the 101 Switching Protocols response (status line +
2701
+ # headers up to the blank line). Returns `[accept_ok, negotiated_protocol]`
2702
+ # — the accept hash must match the handshake key, and the negotiated
2703
+ # `Sec-WebSocket-Protocol` (nil if none) is surfaced so `webSocket.protocol`
2704
+ # is set (Action Cable's client requires `actioncable-v1-json`).
2705
+ private def ws_read_handshake(sock, expected_accept)
2706
+ status = sock.gets
2707
+ return [false, nil] unless status && status =~ %r{\AHTTP/1\.1 101}i
2708
+ accept_ok = false
2709
+ protocol = nil
2710
+ while (line = sock.gets)
2711
+ line = line.chomp
2712
+ break if line.empty?
2713
+ k, v = line.split(':', 2)
2714
+ next unless k
2715
+ key = k.strip.downcase
2716
+ val = v.to_s.strip
2717
+ accept_ok = true if key == 'sec-websocket-accept' && val == expected_accept
2718
+ # utf8_text: socket reads are BINARY, and a BINARY string marshals to a
2719
+ # JS Uint8Array — the protocol must reach JS as a real string so
2720
+ # `webSocket.protocol` compares equal to `actioncable-v1-json`.
2721
+ protocol = RuntimeShared.utf8_text(val) if key == 'sec-websocket-protocol' && !val.empty?
2722
+ end
2723
+ [accept_ok, protocol]
2724
+ end
2725
+
2726
+ # Read one complete message (reassembling continuation frames), handling
2727
+ # interleaved control frames inline. Returns `[opcode, payload]` (opcode
2728
+ # 0x1 text / 0x2 binary, or `:close`), or nil on EOF.
2729
+ private def ws_read_message(sock, queue, id)
2730
+ data = +''.b
2731
+ msg_opcode = nil
2732
+ loop do
2733
+ hdr = ws_read_n(sock, 2) or return nil
2734
+ b0, b1 = hdr.bytes
2735
+ fin = (b0 & 0x80) != 0
2736
+ opcode = b0 & 0x0f
2737
+ masked = (b1 & 0x80) != 0
2738
+ len = b1 & 0x7f
2739
+ if len == 126
2740
+ ext = ws_read_n(sock, 2) or return nil
2741
+ len = ext.unpack1('n')
2742
+ elsif len == 127
2743
+ ext = ws_read_n(sock, 8) or return nil
2744
+ len = ext.unpack1('Q>')
2745
+ end
2746
+ mask = nil
2747
+ if masked
2748
+ mask = ws_read_n(sock, 4) or return nil
2749
+ end
2750
+ if len.zero?
2751
+ payload = ''.b
2752
+ else
2753
+ payload = ws_read_n(sock, len) or return nil
2754
+ end
2755
+ payload = ws_mask(payload, mask) if mask # server frames shouldn't be masked, but be defensive
2756
+ case opcode
2757
+ when 0x8 then return [:close, payload]
2758
+ when 0x9 then ws_write_frame(sock, 0xA, payload); next # ping → pong
2759
+ when 0xA then next # pong → ignore
2760
+ when 0x0 then data << payload # continuation
2761
+ else msg_opcode = opcode; data << payload # 0x1 text / 0x2 binary
2762
+ end
2763
+ return [msg_opcode || opcode, data] if fin
2764
+ end
2765
+ end
2766
+
2767
+ # Read exactly `n` bytes, or nil if the stream ends first (EOF, or a short
2768
+ # read on a mid-frame close). `IO#read(n)` blocks for n bytes on a stream
2769
+ # socket and only returns nil / fewer at EOF, so a nil-or-short result is
2770
+ # a closed/broken connection — bail and let the caller surface a close.
2771
+ private def ws_read_n(sock, n)
2772
+ buf = sock.read(n)
2773
+ buf if buf && buf.bytesize == n
2774
+ end
2775
+
2776
+ # Write one frame. Client→server frames MUST be masked (RFC6455 §5.3);
2777
+ # csim is always the client, so every frame it writes is masked. Holds the
2778
+ # per-connection write lock so the reader thread's pong and the main
2779
+ # thread's send/close can't interleave bytes on the shared socket.
2780
+ private def ws_write_frame(sock, opcode, payload)
2781
+ payload = payload.to_s.b
2782
+ len = payload.bytesize
2783
+ out = [0x80 | opcode].pack('C')
2784
+ if len < 126
2785
+ out << [0x80 | len].pack('C')
2786
+ elsif len < 65_536
2787
+ out << [0x80 | 126, len].pack('Cn')
2788
+ else
2789
+ out << [0x80 | 127, len].pack('CQ>')
2790
+ end
2791
+ key = SecureRandom.random_bytes(4)
2792
+ out << key
2793
+ out << ws_mask(payload, key)
2794
+ @websocket_write_lock.synchronize { sock.write(out) }
2795
+ end
2796
+
2797
+ private def ws_mask(payload, key)
2798
+ kb = key.bytes
2799
+ payload.bytes.each_with_index.map {|byte, i| byte ^ kb[i & 3] }.pack('C*')
2800
+ end
2801
+
2317
2802
  # ── Hijack-aware async XHR ─────────────────────────────────────
2318
2803
  #
2319
2804
  # Real browsers' long-poll keeps the request socket open across
@@ -2559,6 +3044,50 @@ module Capybara
2559
3044
 
2560
3045
  def worker_pending? = !@worker_outbox.empty? || @worker_in_flight > 0
2561
3046
 
3047
+ # ── Cross-window messaging (window.open / opener / postMessage) ──
3048
+ # Each window is a separate Browser/VM/isolate, so a reference to another
3049
+ # window can only be a proxy that forwards through the Driver. These
3050
+ # forward host-fn calls (invoked from THIS window's VM) to the Driver,
3051
+ # which routes to the target window's Browser.
3052
+
3053
+ # `window.open(url, name)` from JS — returns the new (or reused, by name)
3054
+ # window's handle, or nil. The URL is resolved against THIS document so a
3055
+ # relative `window.open('/x')` targets the right origin/path.
3056
+ def open_child_window(url, name)
3057
+ return nil unless @driver.respond_to?(:open_window_from_js)
3058
+ @driver.open_window_from_js(self, url.to_s, name.to_s)
3059
+ end
3060
+
3061
+ # `targetWindow.postMessage(data, origin)` — route to the target window's
3062
+ # inbox, tagged with this window as the source.
3063
+ def post_message_to_window(target_handle, data, origin)
3064
+ return unless @driver.respond_to?(:window_post_message)
3065
+ @driver.window_post_message(self, target_handle.to_s, data, origin.to_s)
3066
+ end
3067
+
3068
+ def window_location_of(handle) = @driver.respond_to?(:window_location) ? @driver.window_location(handle.to_s).to_s : ''
3069
+ def set_window_location(handle, url) = (@driver.window_set_location(handle.to_s, url.to_s) if @driver.respond_to?(:window_set_location))
3070
+ def window_closed?(handle) = @driver.respond_to?(:window_closed?) ? @driver.window_closed?(handle.to_s) : true
3071
+ def close_child_window(handle) = (@driver.close_window(handle.to_s) if @driver.respond_to?(:close_window))
3072
+ def opener_handle = @driver.respond_to?(:opener_handle_of) ? @driver.opener_handle_of(self) : nil
3073
+
3074
+ # Queue a cross-window message for delivery into THIS window's VM (called
3075
+ # by the Driver on the target Browser). Delivered as a `message` event the
3076
+ # next time this window settles / ticks.
3077
+ def enqueue_window_message(data, origin, source_handle)
3078
+ @window_inbox << {'data' => data, 'origin' => origin.to_s, 'sourceHandle' => source_handle.to_s}
3079
+ end
3080
+
3081
+ def window_message_pending? = !@window_inbox.empty?
3082
+
3083
+ # Fire queued cross-window messages as `message` events on window.
3084
+ def deliver_window_messages
3085
+ return 0 if @window_inbox.empty?
3086
+ events = @window_inbox.slice!(0, @window_inbox.length)
3087
+ @runtime.call('__csim_deliverWindowMessages', events)
3088
+ events.size
3089
+ end
3090
+
2562
3091
  # ── Image decode (libvips) ─────────────────────────────────────
2563
3092
  #
2564
3093
  # Called by the JS bridge whenever a Canvas / OffscreenCanvas
@@ -2654,6 +3183,25 @@ module Capybara
2654
3183
  @transfer_buffer_lock.synchronize { @transfer_buffers.delete(id.to_i) }
2655
3184
  end
2656
3185
 
3186
+ # JS reports each zero-copy transfer token it mints (`RustyRacer.transferOut`)
3187
+ # so we can release any that go unimported. Callable from a worker thread.
3188
+ def transfer_token_issued(token)
3189
+ t = token.to_i
3190
+ @transfer_tokens_lock.synchronize { @transfer_tokens << t } if t > 0
3191
+ nil
3192
+ end
3193
+
3194
+ # Release every outstanding transfer token's backing store. The transfer
3195
+ # registry is process-wide (it bridges isolates) and survives isolate
3196
+ # teardown, so an unimported token would leak across the whole run; drop
3197
+ # them on `reset!` via the (still-live) main context — `transferDrop` is
3198
+ # idempotent, so dropping already-imported tokens is a harmless no-op.
3199
+ def drop_pending_transfers
3200
+ toks = @transfer_tokens_lock.synchronize { ts = @transfer_tokens; @transfer_tokens = []; ts }
3201
+ return if toks.empty?
3202
+ @runtime.call('__csimTransferDropAll', toks) rescue nil
3203
+ end
3204
+
2657
3205
  # Wraps the raw bytes in whatever binary shape the ACTIVE runtime can
2658
3206
  # marshal to a JS Uint8Array (V8: the BINARY-tagged string itself —
2659
3207
  # tag-driven marshalling crosses it as a Uint8Array; QuickJS: base64
@@ -3169,6 +3717,95 @@ module Capybara
3169
3717
 
3170
3718
  # Fetch via the Rack app and hand the body to V8 for parsing.
3171
3719
  # Only follows 3xx redirects up to a small depth.
3720
+ # ── Frame-scoped navigation ─────────────────────────────────
3721
+ # A self-targeted link click / form submit INSIDE a `within_frame` block
3722
+ # navigates just that frame: fetch the document and rebuild the frame's
3723
+ # own realm, leaving the top page (its URL, history, status) untouched.
3724
+ # Mirrors `navigate` / `navigate_post`'s fetch + redirect-follow but
3725
+ # terminates in `reload_current_frame_realm` instead of a main-page boot.
3726
+
3727
+ def navigate_frame(url, depth: 0)
3728
+ raise 'too many redirects' if depth > 10
3729
+ invalidate_find_cache
3730
+ if url.to_s.match?(%r{\Aabout:blank(?:[?#]|\z)}i)
3731
+ reload_current_frame_realm('about:blank', '', 'text/html')
3732
+ return
3733
+ end
3734
+ env = Rack::MockRequest.env_for(url, method: 'GET')
3735
+ apply_default_request_env(env, referer: current_browsing_context_url)
3736
+ status, headers, body = dispatch_rack_or_http(url, env, method: 'GET')
3737
+ merge_set_cookie(headers)
3738
+ if (loc = redirect_location(status, headers))
3739
+ next_url = carry_fragment(url, resolve_against_current(loc))
3740
+ body.close if body.respond_to?(:close)
3741
+ return navigate_frame(next_url, depth: depth + 1)
3742
+ end
3743
+ if download_response?(headers)
3744
+ save_downloaded_response(url, headers, body)
3745
+ return
3746
+ end
3747
+ reload_current_frame_realm(url.to_s, read_rack_body(body), response_content_type(headers))
3748
+ end
3749
+
3750
+ def navigate_frame_post(url, body, content_type, depth: 0)
3751
+ raise 'too many redirects' if depth > 10
3752
+ invalidate_find_cache
3753
+ env = Rack::MockRequest.env_for(url, method: 'POST', input: body)
3754
+ env['CONTENT_TYPE'] = content_type.to_s.empty? ? 'application/x-www-form-urlencoded' : content_type
3755
+ env['CONTENT_LENGTH'] = body.bytesize.to_s
3756
+ apply_default_request_env(env, referer: current_browsing_context_url)
3757
+ status, headers, resp_body = dispatch_rack_or_http(url, env, method: 'POST', body: body)
3758
+ merge_set_cookie(headers)
3759
+ if (loc = redirect_location(status, headers))
3760
+ next_url = resolve_against_current(loc)
3761
+ resp_body.close if resp_body.respond_to?(:close)
3762
+ # 301/302/303 → GET; 307/308 preserve method + body (same as navigate_post).
3763
+ if [307, 308].include?(status)
3764
+ return navigate_frame_post(next_url, body, content_type, depth: depth + 1)
3765
+ else
3766
+ return navigate_frame(next_url, depth: depth + 1)
3767
+ end
3768
+ end
3769
+ if download_response?(headers)
3770
+ save_downloaded_response(url, headers, resp_body)
3771
+ return
3772
+ end
3773
+ reload_current_frame_realm(url.to_s, read_rack_body(resp_body), response_content_type(headers))
3774
+ end
3775
+
3776
+ # Tear down the active frame's realm and rebuild it from `html`, then
3777
+ # re-point the iframe element at the new realm. The iframe lives in the
3778
+ # PARENT realm, so the rebind host fn runs there.
3779
+ def reload_current_frame_realm(url, html, content_type)
3780
+ entry = @frame_stack.last
3781
+ return unless entry
3782
+ old_id = entry[:realm_id]
3783
+ parent = entry[:parent_realm_id]
3784
+ new_id = @runtime.reload_frame_realm(old_id, parent.to_i, url, RuntimeShared.utf8_text(html), content_type).to_i
3785
+ return if new_id.zero?
3786
+ rebind_frame_realm(parent, entry[:iframe_handle], old_id, new_id)
3787
+ entry[:realm_id] = new_id
3788
+ @current_realm_id = new_id
3789
+ invalidate_find_cache
3790
+ settle
3791
+ end
3792
+
3793
+ def rebind_frame_realm(parent_realm_id, iframe_handle, old_id, new_id)
3794
+ if parent_realm_id.nil? || parent_realm_id.zero?
3795
+ @runtime.call('__csimRebindFrameRealm', iframe_handle, old_id, new_id)
3796
+ else
3797
+ @runtime.realm_call(parent_realm_id, '__csimRebindFrameRealm', iframe_handle, old_id, new_id)
3798
+ end
3799
+ end
3800
+
3801
+ # Response content-type, defaulting to text/html. Header values can be a
3802
+ # bare string or a one-element array (Rack 3 tuple form).
3803
+ def response_content_type(headers)
3804
+ ct = headers.find {|k, _| k.to_s.downcase == 'content-type' }&.last
3805
+ ct = ct.first if ct.is_a?(Array)
3806
+ ct.to_s.empty? ? 'text/html' : ct.to_s
3807
+ end
3808
+
3172
3809
  def navigate(url, depth: 0, referer: @current_url, from_history: false)
3173
3810
  raise 'too many redirects' if depth > 10
3174
3811
  invalidate_find_cache
@@ -3194,6 +3831,18 @@ module Capybara
3194
3831
  prior_navigating = @navigating
3195
3832
  @navigating = true unless from_history
3196
3833
  begin
3834
+ # `about:blank` names an empty, network-less document — there's
3835
+ # nothing to fetch. Rack::MockRequest.env_for can't parse the `about:`
3836
+ # scheme (no host/path → nil[]); route straight to an empty document.
3837
+ # `location = 'about:blank'` and navigating an iframe to about:blank
3838
+ # both land here. Mirrors rack_fetch's non-http(s) guard. (Narrow to
3839
+ # about:blank specifically — about:srcdoc carries its own markup.)
3840
+ if url.to_s.match?(%r{\Aabout:blank(?:[?#]|\z)}i)
3841
+ @current_url = url.to_s
3842
+ record_response(200, {'content-type' => 'text/html'})
3843
+ boot_response_into_ctx('')
3844
+ return
3845
+ end
3197
3846
  record_history({method: :get, url: url}) unless from_history || depth > 0
3198
3847
  env = Rack::MockRequest.env_for(url, method: 'GET')
3199
3848
  apply_default_request_env(env, referer: referer)
@@ -3259,6 +3908,9 @@ module Capybara
3259
3908
  # finish before the next document loads; this is the in-process analogue.
3260
3909
  flush_outgoing_page_init if @timers_active
3261
3910
  @runtime.rebuild_ctx
3911
+ # A full page (re)build disposes every frame realm, so any active
3912
+ # `within_frame` scope is now stale — fall back to the main document.
3913
+ reset_frame_scope
3262
3914
  reset_timer_state
3263
3915
  # The DOCUMENT is text; the Rack body arrives BINARY-tagged (see
3264
3916
  # `RuntimeShared.utf8_text`). Charset-header-driven decode is the
@@ -3509,6 +4161,9 @@ module Capybara
3509
4161
 
3510
4162
  def resolve_against_current(url, use_base: false)
3511
4163
  return url if url =~ %r{\A[a-z]+://}i
4164
+ # Inside a `within_frame` block the "current document" is the frame's,
4165
+ # so links / form actions resolve against the frame's URL + <base href>.
4166
+ doc_url = current_browsing_context_url || @default_host
3512
4167
  base =
3513
4168
  if use_base && (bh = base_href) && !bh.empty?
3514
4169
  # The document's `<base href>` takes precedence over the
@@ -3516,17 +4171,19 @@ module Capybara
3516
4171
  # being resolved — HTML's base-tag semantics. `visit` skips
3517
4172
  # this branch so an address-bar navigation reaches the URL
3518
4173
  # the test typed.
3519
- URI.join(@current_url || @default_host, bh).to_s
4174
+ URI.join(doc_url, bh).to_s
3520
4175
  else
3521
- @current_url || @default_host
4176
+ doc_url
3522
4177
  end
3523
4178
  URI.join(base, url.to_s).to_s
3524
4179
  rescue URI::InvalidURIError, URI::BadURIError
3525
4180
  url
3526
4181
  end
3527
4182
 
4183
+ # The active document's `<base href>` — routed to the current frame realm
4184
+ # inside `within_frame`, else the main document.
3528
4185
  def base_href
3529
- @runtime.call('__csimBaseHref').to_s
4186
+ dom_call('__csimBaseHref').to_s
3530
4187
  end
3531
4188
 
3532
4189
  def carry_fragment(from_url, to_url)