capybara-simulated 0.1.1 → 0.2.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.
@@ -53,6 +53,11 @@ module Capybara
53
53
 
54
54
  attr_writer :timers_active
55
55
 
56
+ # The Driver's handle for the window this Browser backs (set right after
57
+ # construction). Lets host fns name the source window of a cross-window
58
+ # `postMessage` / `window.open` so the Driver can route to the target.
59
+ attr_accessor :window_handle
60
+
56
61
  # Sticky window after timers finish: keep polling? true so a
57
62
  # setTimeout firing mid-loop doesn't drop Capybara's synchronize
58
63
  # before its own default_max_wait_time kicks in. Counted in poll
@@ -221,6 +226,13 @@ module Capybara
221
226
  @find_cache_ctx = nil
222
227
  @find_cache_value = nil
223
228
  @document_handle = 0
229
+ # `within_frame` state. `@current_realm_id` is the V8 context id of the
230
+ # active frame realm (nil = the main document); `@frame_stack` records
231
+ # the enclosing realms so `switch_to_frame(:parent)` can pop one level.
232
+ # DOM / node / query ops route through `dom_call`, which dispatches to
233
+ # this realm. nil is the steady state, so the routing is one nil-check.
234
+ @current_realm_id = nil
235
+ @frame_stack = []
224
236
  @last_tick_ts = Process.clock_gettime(Process::CLOCK_MONOTONIC)
225
237
  @polling_grace = nil
226
238
  @last_polled_gen = nil
@@ -295,6 +307,11 @@ module Capybara
295
307
  @transfer_buffer_lock = Mutex.new
296
308
  @transfer_buffers = {}
297
309
  @transfer_buffer_seq = 0
310
+ # Cross-window `postMessage` inbox. Another window's `target.postMessage`
311
+ # routes through the Driver and lands here; this window drains it into a
312
+ # `message` event the next time it's active and settles/ticks. Plain
313
+ # array (same thread — windows aren't background-threaded like workers).
314
+ @window_inbox = []
298
315
  end
299
316
 
300
317
  # Worker thread polling and termination intervals — split so a
@@ -351,8 +368,28 @@ module Capybara
351
368
 
352
369
  def resolve_visit_url(url)
353
370
  s = url.to_s
371
+ # `about:blank` (and other authority-less schemes) have no `//`, so the
372
+ # `scheme://` test below would treat them as relative paths and prepend
373
+ # the host root. `navigate` handles `about:blank` specially — pass it
374
+ # through untouched (open_new_window opens an about:blank tab).
375
+ return s if s.match?(/\Aabout:/i)
354
376
  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
377
+ # Strip path/query/fragment off the current URL to get the origin
378
+ # root. An opaque or host-less current URL (e.g. `about:blank` in a
379
+ # freshly-opened window) can't yield an origin — fall back to the
380
+ # default host so a subsequent relative `visit` still resolves.
381
+ host_root =
382
+ begin
383
+ u = URI.parse(@current_url.to_s)
384
+ if u.opaque || u.host.nil?
385
+ @default_host
386
+ else
387
+ u.path = ''; u.query = nil; u.fragment = nil
388
+ u.to_s
389
+ end
390
+ rescue URI::InvalidURIError
391
+ @default_host
392
+ end
356
393
  host_root = host_root.sub(/\/+$/, '')
357
394
  s = "/#{s}" unless s.start_with?('/')
358
395
  s = "#{host_root}#{s}"
@@ -429,11 +466,128 @@ module Capybara
429
466
  @recent_urls_last_push_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
430
467
  end
431
468
 
469
+ attr_reader :current_realm_id
470
+
471
+ # DOM / node / query host-fn dispatch. Inside a `within_frame` block it
472
+ # routes to the active frame realm's context; otherwise straight to the
473
+ # main context. Handle integers are per-realm (each realm is a full
474
+ # bridge with its own registry), so an op on a frame node must run in the
475
+ # realm the handle came from — which, per Capybara's within_frame
476
+ # contract, is the current realm for the block's duration. Hot path:
477
+ # `@current_realm_id` is nil outside frames, so this is one nil-check over
478
+ # a direct `@runtime.call`.
479
+ def dom_call(name, *args)
480
+ return @runtime.call(name, *args) if @current_realm_id.nil?
481
+ # The active frame's realm was torn down mid-block (the iframe was
482
+ # removed or re-navigated). Surface a stale element so Capybara
483
+ # retries / reports, rather than letting realm_call fall back to the
484
+ # main context where this frame handle would mis-resolve.
485
+ unless @runtime.frame_realm_alive?(@current_realm_id)
486
+ raise Capybara::Simulated::StaleElement,
487
+ "frame browsing context #{@current_realm_id} was torn down (frame removed or re-navigated)"
488
+ end
489
+ @runtime.realm_call(@current_realm_id, name, *args)
490
+ end
491
+
492
+ # Root for a context-less find: the active frame's document (handle 0 ⇒
493
+ # the realm's own `globalThis.document`) when in a frame, else the main
494
+ # document handle.
495
+ def current_document_handle
496
+ @current_realm_id ? 0 : @document_handle
497
+ end
498
+
499
+ # Capybara `switch_to_frame`. `target` is an `<iframe>` handle in the
500
+ # CURRENT realm, or `:parent` / `:top`. Entering builds (or reuses) the
501
+ # frame's V8 realm and routes subsequent DOM ops there; `:parent` pops one
502
+ # level, `:top` returns to the main document. Frame switches invalidate
503
+ # the find cache (its keys aren't realm-qualified, and a switch is rare).
504
+ #
505
+ # 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.
513
+ def switch_to_frame(target)
514
+ invalidate_find_cache
515
+ case target
516
+ when :parent
517
+ @frame_stack.pop
518
+ @current_realm_id = @frame_stack.last && @frame_stack.last[:realm_id]
519
+ when :top
520
+ reset_frame_scope
521
+ else
522
+ # Per-frame realms are a V8-engine feature; QuickJS has no nested
523
+ # browsing context to route into. Distinguish that (unsupported
524
+ # engine) from a frame that simply failed to build (below), so the
525
+ # error doesn't misattribute a load failure to the engine.
526
+ unless @runtime.respond_to?(:realm_call)
527
+ raise Capybara::Simulated::FrameNotSupported,
528
+ 'within_frame needs a per-frame browsing context, which only the ' \
529
+ 'V8 (rusty_racer) engine provides; QuickJS keeps a same-realm fallback.'
530
+ end
531
+ parent_realm = @current_realm_id
532
+ tick_real_time
533
+ rid = dom_call('__csimEnsureFrameRealm', target.to_i).to_i
534
+ if rid.zero?
535
+ raise Capybara::Simulated::StaleElement,
536
+ "could not enter frame ##{target} (not a frame element, or its document failed to load)"
537
+ end
538
+ # Record the iframe handle + the realm it lives in so a frame-scoped
539
+ # navigation can rebuild this exact frame (`reload_current_frame_realm`).
540
+ @frame_stack.push({realm_id: rid, iframe_handle: target.to_i, parent_realm_id: parent_realm})
541
+ @current_realm_id = rid
542
+ # Let the freshly built realm's inline scripts / load handlers settle
543
+ # so a find immediately inside the block sees the loaded document.
544
+ settle
545
+ end
546
+ end
547
+
548
+ # Return DOM-op routing to the main document and drop any frame stack.
549
+ # Called by `switch_to_frame(:top)`, per-test `reset!`, and every full
550
+ # page (re)build (which disposes all frame realms) — anything that
551
+ # invalidates the active `within_frame` scope.
552
+ def reset_frame_scope
553
+ @current_realm_id = nil
554
+ @frame_stack.clear
555
+ end
556
+
557
+ # The active browsing context's own URL: the frame document's URL inside
558
+ # a `within_frame` block, else the main page URL. Used to resolve a
559
+ # frame-relative navigation and to set its request referrer, so
560
+ # `resolve_against_current` / `pure_fragment_navigation?` work the same
561
+ # whether the navigation originates in the main page or a frame.
562
+ def current_browsing_context_url
563
+ return @current_url unless @current_realm_id
564
+ href = dom_call('__csimLocationHref').to_s
565
+ href.empty? ? @current_url : href
566
+ end
567
+
568
+ # Public entry for the Driver to resolve a `window.open` / cross-window
569
+ # `location` URL against THIS window's document (the internal resolver is
570
+ # private). Honours `<base href>` like the page's own links do.
571
+ def resolve_document_url(url)
572
+ resolve_against_current(url, use_base: true)
573
+ end
574
+
575
+ # 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.
581
+ def frame_self_target?(target)
582
+ t = target.to_s.downcase
583
+ t.empty? || t == '_self'
584
+ end
585
+
432
586
  def find_css(css, context_handle = nil)
433
587
  s = css.to_s
434
588
  return find_xpath(s, context_handle) if xpath_shaped?(s)
435
589
  find_with_timer_fallback(:css, s, context_handle) do
436
- @runtime.call('__csimQuery', context_handle || @document_handle, s).to_a
590
+ dom_call('__csimQuery', context_handle || current_document_handle, s).to_a
437
591
  rescue StandardError => e
438
592
  # Invalid selector → empty result. Callers that genuinely
439
593
  # need the throw go through `evaluate_script`.
@@ -445,7 +599,7 @@ module Capybara
445
599
  def find_first_css(css, context_handle = nil)
446
600
  s = css.to_s
447
601
  find_with_timer_fallback(:css_first, s, context_handle) do
448
- h = @runtime.call('__csimQueryOne', context_handle || @document_handle, s).to_i
602
+ h = dom_call('__csimQueryOne', context_handle || current_document_handle, s).to_i
449
603
  h.zero? ? nil : h
450
604
  rescue StandardError => e
451
605
  raise unless syntax_or_invalid_selector_error?(e)
@@ -481,7 +635,7 @@ module Capybara
481
635
  def find_xpath(xpath, context_handle = nil)
482
636
  xpath_str = xpath.to_s
483
637
  find_with_timer_fallback(:xpath, xpath_str, context_handle) do
484
- @runtime.call('__csimEvaluateXPath', xpath_str, context_handle || 0).to_a
638
+ dom_call('__csimEvaluateXPath', xpath_str, context_handle || 0).to_a
485
639
  end
486
640
  end
487
641
 
@@ -535,7 +689,7 @@ module Capybara
535
689
  # drain that channel, without paying an unconditional drain on
536
690
  # timer-driven runloop pages.
537
691
  def async_io_pending?
538
- worker_pending? || event_source_pending? || hijack_fetch_pending?
692
+ worker_pending? || event_source_pending? || hijack_fetch_pending? || window_message_pending?
539
693
  end
540
694
 
541
695
  # Single-slot cache for the most recent find_xpath / find_css /
@@ -569,23 +723,23 @@ module Capybara
569
723
  @find_cache_dirty = true
570
724
  end
571
725
 
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
726
+ def text(handle) = dom_call('__csimText', handle).to_s
727
+ def tag(handle) = dom_call('__csimTag', handle).to_s
728
+ def attr(handle, name) = dom_call('__csimAttr', handle, name.to_s)
729
+ def inner_html(handle) = dom_call('__csimInnerHTML', handle).to_s
730
+ def outer_html(handle) = dom_call('__csimOuterHTML', handle).to_s
577
731
  def file_input?(handle)
578
732
  tag(handle) == 'input' && attr(handle, 'type').to_s.downcase == 'file'
579
733
  end
580
- def visible?(handle) = @runtime.call('__csimVisible', handle) ? true : false
734
+ def visible?(handle) = dom_call('__csimVisible', handle) ? true : false
581
735
 
582
736
  # Capybara::Driver::Node surface — Node calls `check_stale`
583
737
  # before each read, and that advances the virtual clock.
584
738
  def all_text(handle) = text(handle)
585
- def visible_text(handle) = @runtime.call('__csimVisibleText', handle).to_s
739
+ def visible_text(handle) = dom_call('__csimVisibleText', handle).to_s
586
740
  def tag_name(handle) = tag(handle)
587
- def value(handle) = @runtime.call('__csimValue', handle)
588
- def disabled?(handle) = @runtime.call('__csimDisabled', handle)
741
+ def value(handle) = dom_call('__csimValue', handle)
742
+ def disabled?(handle) = dom_call('__csimDisabled', handle)
589
743
  # HTML spec: `<option>.selected` IDL is true when the `selected`
590
744
  # *attribute* is set OR when no sibling option has `selected` and
591
745
  # this is the first non-disabled option of a single-select
@@ -595,28 +749,28 @@ module Capybara
595
749
  # `<option selected>` reports no selected options and the matcher
596
750
  # fails even though the first option *is* the currently chosen
597
751
  # one in real browsers.
598
- def option_selected?(h) = !!@runtime.call('__csimOptionSelected', h)
752
+ def option_selected?(h) = !!dom_call('__csimOptionSelected', h)
599
753
  def shadow_root_handle(handle)
600
- h = @runtime.call('__csimShadowRoot', handle).to_i
754
+ h = dom_call('__csimShadowRoot', handle).to_i
601
755
  h.zero? ? nil : h
602
756
  end
603
757
  def computed_style(handle, names)
604
758
  tick_real_time
605
- result = @runtime.call('__csimComputedStyle', handle, names.map(&:to_s))
759
+ result = dom_call('__csimComputedStyle', handle, names.map(&:to_s))
606
760
  return names.to_h {|n| [n, ''] } unless result.is_a?(Hash)
607
761
  result.transform_keys(&:to_s)
608
762
  end
609
- def node_path(handle) = @runtime.call('__csimNodePath', handle).to_s
763
+ def node_path(handle) = dom_call('__csimNodePath', handle).to_s
610
764
 
611
765
  def lookup_node(handle)
612
- handle if @runtime.call('__csimAlive', handle)
766
+ handle if dom_call('__csimAlive', handle)
613
767
  end
614
768
 
615
769
  def check_stale(handle, initial, gen = nil)
616
- return if initial && (gen.nil? || gen == @context_gen) && @runtime.call('__csimAlive', handle)
770
+ return if initial && (gen.nil? || gen == @context_gen) && dom_call('__csimAlive', handle)
617
771
 
618
772
  tick_real_time
619
- return if initial && (gen.nil? || gen == @context_gen) && @runtime.call('__csimAlive', handle)
773
+ return if initial && (gen.nil? || gen == @context_gen) && dom_call('__csimAlive', handle)
620
774
 
621
775
  raise Capybara::Simulated::StaleElement, "Element with handle #{handle} is no longer attached to the document"
622
776
  end
@@ -630,7 +784,7 @@ module Capybara
630
784
  # silently no-op (or, in the case of `__csimClickResolve`,
631
785
  # dispatch on a detached node whose listeners no longer matter).
632
786
  def ensure_alive_after_tick(handle)
633
- return if @runtime.call('__csimAlive', handle)
787
+ return if dom_call('__csimAlive', handle)
634
788
  raise Capybara::Simulated::StaleElement, "Element with handle #{handle} is no longer attached to the document"
635
789
  end
636
790
 
@@ -646,11 +800,11 @@ module Capybara
646
800
  # Wall-sleep between mousedown and mouseup so click handlers
647
801
  # reading `Date.now()` see the elapsed gap (selenium parity).
648
802
  init['mouseDownOnly'] = true
649
- partial = @runtime.call('__csimClickResolve', handle, init)
803
+ partial = dom_call('__csimClickResolve', handle, init)
650
804
  sleep delay
651
- @runtime.call('__csimClickFinish', handle, partial.is_a?(Hash) ? partial['base'] : init)
805
+ dom_call('__csimClickFinish', handle, partial.is_a?(Hash) ? partial['base'] : init)
652
806
  else
653
- @runtime.call('__csimClickResolve', handle, init)
807
+ dom_call('__csimClickResolve', handle, init)
654
808
  end
655
809
  unless action.is_a?(Hash)
656
810
  settle
@@ -693,11 +847,21 @@ module Capybara
693
847
  when 'navigate'
694
848
  url = action['url'].to_s
695
849
  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)
855
+ tick_real_time
856
+ navigate_frame(resolve_against_current(url, use_base: true))
857
+ end
696
858
  # `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)
859
+ # in a new browsing context (its own Browser/VM); the primary
860
+ # stays put (per HTML spec original window is unaffected). No
861
+ # `opener_handle` is passed: modern browsers default `target=_blank`
862
+ # to `noopener` (so `window.opener` is null), unlike JS `window.open`
863
+ # which keeps the opener — see `open_window_from_js`.
864
+ elsif !target.empty? && !%w[_self _top _parent].include?(target.downcase) && @driver.respond_to?(:open_aux_window)
701
865
  @driver.open_aux_window(resolve_against_current(url, use_base: true))
702
866
  # In-page anchor links (`#frag` / current-page + `#frag`) move
703
867
  # the hash but don't fetch a new document. Pure-fragment also
@@ -772,10 +936,11 @@ module Capybara
772
936
 
773
937
  def pure_fragment_navigation?(url)
774
938
  return true if url.start_with?('#')
775
- return false if @current_url.nil?
939
+ doc_url = current_browsing_context_url
940
+ return false if doc_url.nil?
776
941
  target = resolve_against_current(url)
777
942
  a = URI.parse(target)
778
- b = URI.parse(@current_url)
943
+ b = URI.parse(doc_url)
779
944
  # Same-document iff everything but the fragment matches AND the
780
945
  # fragment actually changes — `a.fragment != b.fragment` covers
781
946
  # both adding/changing a fragment and *clearing* one (target has
@@ -854,14 +1019,14 @@ module Capybara
854
1019
  'lastModified' => stat ? (stat.mtime.to_f * 1000).to_i : 0
855
1020
  }
856
1021
  }
857
- @runtime.call('__csimSetFiles', handle, file_infos)
1022
+ dom_call('__csimSetFiles', handle, file_infos)
858
1023
  # Mirror real browser: <input type=file>.value reflects only
859
1024
  # the filename of the first chosen file (security-faked path).
860
1025
  # __csimSetValue dispatches input + change synchronously.
861
1026
  js_value = paths.first ? File.basename(paths.first) : ''
862
- @runtime.call('__csimSetValue', handle, js_value)
1027
+ dom_call('__csimSetValue', handle, js_value)
863
1028
  else
864
- @runtime.call('__csimSetValue', handle, coerced)
1029
+ dom_call('__csimSetValue', handle, coerced)
865
1030
  end
866
1031
  drain_after_user_action
867
1032
  end
@@ -919,10 +1084,10 @@ module Capybara
919
1084
  invalidate_find_cache
920
1085
  ensure_alive_after_tick(handle)
921
1086
  init = {'bubbles' => true, 'cancelable' => true, 'button' => 2, 'which' => 3}.merge(click_event_init(handle, keys, opts))
922
- @runtime.call('__csimDispatchEvent', handle, 'mousedown', init)
1087
+ dom_call('__csimDispatchEvent', handle, 'mousedown', init)
923
1088
  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)
1089
+ dom_call('__csimDispatchEvent', handle, 'mouseup', init)
1090
+ dom_call('__csimDispatchEvent', handle, 'contextmenu', init)
926
1091
  end
927
1092
 
928
1093
  # HTML5 drag-and-drop simulation. Capybara routes `Element#drop`
@@ -934,7 +1099,7 @@ module Capybara
934
1099
  invalidate_find_cache
935
1100
  ensure_alive_after_tick(handle)
936
1101
  items = args.flat_map {|arg| drop_items(arg) }
937
- @runtime.call('__csimDropOnto', handle, items)
1102
+ dom_call('__csimDropOnto', handle, items)
938
1103
  end
939
1104
 
940
1105
  # Element-to-element drag. Capybara's `Element#drag_to(target,
@@ -950,7 +1115,7 @@ module Capybara
950
1115
  invalidate_find_cache
951
1116
  ensure_alive_after_tick(source_handle)
952
1117
  ensure_alive_after_tick(target_handle)
953
- @runtime.call('__csimDragOnto', source_handle, target_handle)
1118
+ dom_call('__csimDragOnto', source_handle, target_handle)
954
1119
  drain_after_user_action
955
1120
  end
956
1121
  def drop_items(arg)
@@ -975,14 +1140,14 @@ module Capybara
975
1140
  # UI Events spec: two full mousedown→mouseup→click chains
976
1141
  # before the trailing `dblclick`. Jspreadsheet (table-builder's
977
1142
  # `.jss_worksheet`) enters edit mode on the inner mousedown.
978
- 2.times { @runtime.call('__csimClickResolve', handle, opts) }
1143
+ 2.times { dom_call('__csimClickResolve', handle, opts) }
979
1144
  init = {'bubbles' => true, 'cancelable' => true}.merge(click_event_init(handle, keys, opts))
980
- @runtime.call('__csimDispatchEvent', handle, 'dblclick', init)
1145
+ dom_call('__csimDispatchEvent', handle, 'dblclick', init)
981
1146
  # Real browsers' default-action on dblclick selects the word
982
1147
  # under the cursor — ProseMirror / Tiptap "paste URL over
983
1148
  # selection wraps with link" tests rely on the word being
984
1149
  # selected before the paste.
985
- @runtime.call('__csimSelectWordAt', handle)
1150
+ dom_call('__csimSelectWordAt', handle)
986
1151
  settle
987
1152
  end
988
1153
 
@@ -1015,7 +1180,7 @@ module Capybara
1015
1180
  has_xy = opts[:x] || opts[:y]
1016
1181
  center = opts[:offset] == :center || !has_xy
1017
1182
  if has_xy || center
1018
- rect = @runtime.call('__csimElementRect', handle)
1183
+ rect = dom_call('__csimElementRect', handle)
1019
1184
  base_x = rect['x'].to_f + (center ? rect['width'].to_f / 2.0 : 0.0)
1020
1185
  base_y = rect['y'].to_f + (center ? rect['height'].to_f / 2.0 : 0.0)
1021
1186
  out['clientX'] = base_x + opts[:x].to_f
@@ -1038,14 +1203,14 @@ module Capybara
1038
1203
  # double-eval recursion the inlined `globalThis.document.
1039
1204
  # _hoverElement = ...` triggered (the eval string ran inside
1040
1205
  # a fresh microtask that re-entered the hover listeners).
1041
- @runtime.call('__csimSetHover', handle)
1206
+ dom_call('__csimSetHover', handle)
1042
1207
  end
1043
1208
 
1044
1209
  def dispatch_event(handle, type, init = {})
1045
1210
  tick_real_time
1046
1211
  invalidate_find_cache
1047
1212
  ensure_alive_after_tick(handle)
1048
- @runtime.call('__csimDispatchEvent', handle, type.to_s, init)
1213
+ dom_call('__csimDispatchEvent', handle, type.to_s, init)
1049
1214
  end
1050
1215
 
1051
1216
  # Capybara's `send_keys` accepts Strings and Symbols (special
@@ -1097,20 +1262,20 @@ module Capybara
1097
1262
  # before the next char arrives. Plain `<input>` / `<textarea>`
1098
1263
  # don't need this — keep the single batched call there.
1099
1264
  has_multichar_text = atoms.any? {|a| a['kind'] == 'text' && a['value'].to_s.length > 1 }
1100
- if has_multichar_text && @runtime.call('__csimIsContentEditable', handle)
1265
+ if has_multichar_text && dom_call('__csimIsContentEditable', handle)
1101
1266
  per_char = atoms.flat_map {|a|
1102
1267
  next a unless a['kind'] == 'text' && a['value'].to_s.length > 1
1103
1268
  a['value'].to_s.each_char.map {|c| {'kind' => 'text', 'value' => c} }
1104
1269
  }
1105
1270
  head, *tail = per_char
1106
- @runtime.call('__csimSendKeys', handle, [head])
1271
+ dom_call('__csimSendKeys', handle, [head])
1107
1272
  tail.each {|atom|
1108
1273
  tick_real_time
1109
- @runtime.call('__csimSendKeys', handle, [atom])
1274
+ dom_call('__csimSendKeys', handle, [atom])
1110
1275
  settle
1111
1276
  }
1112
1277
  else
1113
- @runtime.call('__csimSendKeys', handle, atoms)
1278
+ dom_call('__csimSendKeys', handle, atoms)
1114
1279
  end
1115
1280
  drain_after_user_action
1116
1281
  end
@@ -1119,7 +1284,7 @@ module Capybara
1119
1284
  mark_action_baseline
1120
1285
  tick_real_time
1121
1286
  invalidate_find_cache
1122
- @runtime.call('__csimSelectOption', handle)
1287
+ dom_call('__csimSelectOption', handle)
1123
1288
  tick_real_time
1124
1289
  drain_after_user_action
1125
1290
  end
@@ -1133,11 +1298,11 @@ module Capybara
1133
1298
  # the JS side whether the option's parent select is `multiple`
1134
1299
  # before issuing the unselect; the answer doubles as the
1135
1300
  # "found the right ancestor" check.
1136
- info = @runtime.call('__csimOptionContext', handle)
1301
+ info = dom_call('__csimOptionContext', handle)
1137
1302
  if info.is_a?(Hash) && info['hasSelect'] && !info['multiple']
1138
1303
  raise Capybara::UnselectNotAllowed, 'Cannot unselect option from single select box.'
1139
1304
  end
1140
- @runtime.call('__csimUnselectOption', handle)
1305
+ dom_call('__csimUnselectOption', handle)
1141
1306
  tick_real_time
1142
1307
  drain_after_user_action
1143
1308
  end
@@ -1208,8 +1373,9 @@ module Capybara
1208
1373
  deliver_event_source_events
1209
1374
  deliver_worker_messages
1210
1375
  deliver_hijacked_fetches
1376
+ deliver_window_messages
1211
1377
  break if @runtime.settle_gen > start_gen
1212
- break unless @timers_active || event_source_pending? || worker_pending? || hijack_fetch_pending?
1378
+ break unless @timers_active || event_source_pending? || worker_pending? || hijack_fetch_pending? || window_message_pending?
1213
1379
  # ONE event-loop step replaces the old drain_microtasks(4)+drain_timers(32)
1214
1380
  # pair: it fires due timers, runs a per-task microtask checkpoint (so
1215
1381
  # chained .then / MutationObserver delivery interleave spec-correctly),
@@ -1221,13 +1387,14 @@ module Capybara
1221
1387
  deliver_event_source_events
1222
1388
  deliver_worker_messages
1223
1389
  deliver_hijacked_fetches
1390
+ deliver_window_messages
1224
1391
  break if @runtime.settle_gen > start_gen
1225
1392
  # No progress this iter (no DOM/URL change observed) — the
1226
1393
  # remaining timers are queued for the future; bail and let
1227
1394
  # Capybara's wall-clock-driven poll loop drive the next tick
1228
1395
  # via `tick_real_time`. SSE / Worker channels keep us in
1229
1396
  # 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?
1397
+ break if @runtime.settle_gen == prev_gen && !@runtime.has_ready_timer? && !event_source_pending? && !worker_pending? && !hijack_fetch_pending? && !window_message_pending?
1231
1398
  prev_gen = @runtime.settle_gen
1232
1399
  end
1233
1400
  @find_cache_dirty = true
@@ -1285,7 +1452,7 @@ module Capybara
1285
1452
  def submit_form(handle)
1286
1453
  tick_real_time
1287
1454
  invalidate_find_cache
1288
- form_handle = @runtime.call('__csimAncestorForm', handle).to_i
1455
+ form_handle = dom_call('__csimAncestorForm', handle).to_i
1289
1456
  return if form_handle.zero?
1290
1457
  submit_form_handle(form_handle, nil)
1291
1458
  end
@@ -1295,9 +1462,11 @@ module Capybara
1295
1462
  @runtime.call('__csimDocumentTitle').to_s
1296
1463
  end
1297
1464
 
1465
+ # `page.html` inside a `within_frame` block returns the frame document's
1466
+ # source (Selenium parity), so route through the active realm.
1298
1467
  def html
1299
1468
  tick_real_time
1300
- @runtime.call('__csimDocumentHtml').to_s
1469
+ dom_call('__csimDocumentHtml').to_s
1301
1470
  end
1302
1471
 
1303
1472
  def status_code = (@last_response_status || 200)
@@ -1449,7 +1618,7 @@ module Capybara
1449
1618
  end
1450
1619
  def active_element_handle
1451
1620
  tick_real_time
1452
- h = @runtime.call('__csimActiveElement').to_i
1621
+ h = dom_call('__csimActiveElement').to_i
1453
1622
  h.zero? ? nil : h
1454
1623
  end
1455
1624
  # Session-level keystroke. Tab / shift-tab cycle focus; everything
@@ -1467,12 +1636,12 @@ module Capybara
1467
1636
  Array(keys).each do |k|
1468
1637
  sym = k.is_a?(Symbol) ? k : (k.respond_to?(:to_sym) ? k.to_sym : nil)
1469
1638
  if sym == :tab || sym == :backtab
1470
- @runtime.call('__csimAdvanceFocus', sym == :backtab)
1639
+ dom_call('__csimAdvanceFocus', sym == :backtab)
1471
1640
  elsif sym && MODIFIER_KEY_NAMES.include?(sym)
1472
1641
  held << sym
1473
1642
  else
1474
1643
  handle = active_element_handle
1475
- handle = @document_handle if handle.nil? || handle.zero?
1644
+ handle = current_document_handle if handle.nil? || handle.zero?
1476
1645
  atom = held.empty? ? k : (held + [k])
1477
1646
  send_keys(handle, [atom])
1478
1647
  end
@@ -1582,7 +1751,7 @@ module Capybara
1582
1751
  # description Proc).
1583
1752
  def describe_node_handle(handle)
1584
1753
  return "handle=#{handle}" if handle.nil? || handle.zero?
1585
- info = @runtime.call('__csimDescribeNode', handle)
1754
+ info = dom_call('__csimDescribeNode', handle)
1586
1755
  return "handle=#{handle}" unless info.is_a?(Hash)
1587
1756
  s = info['tag'].to_s
1588
1757
  s += "##{info['id']}" unless info['id'].to_s.empty?
@@ -1597,7 +1766,9 @@ module Capybara
1597
1766
  # to be active.
1598
1767
  tick_real_time
1599
1768
  invalidate_find_cache
1600
- result = @runtime.call('__csimEvalScript', code.to_s, marshal_args(args || []))
1769
+ # Routes to the active frame realm inside `within_frame` (Selenium
1770
+ # parity: `evaluate_script` runs in the current browsing context).
1771
+ result = dom_call('__csimEvalScript', code.to_s, marshal_args(args || []))
1601
1772
  drain_pending_navigation
1602
1773
  result
1603
1774
  end
@@ -1609,7 +1780,7 @@ module Capybara
1609
1780
  def execute_script(code, args = [])
1610
1781
  tick_real_time
1611
1782
  invalidate_find_cache
1612
- @runtime.call('__csimExecScript', code.to_s, marshal_args(args || []))
1783
+ dom_call('__csimExecScript', code.to_s, marshal_args(args || []))
1613
1784
  drain_pending_navigation
1614
1785
  nil
1615
1786
  end
@@ -1666,14 +1837,17 @@ module Capybara
1666
1837
  def evaluate_async_script(code, args = [])
1667
1838
  tick_real_time
1668
1839
  invalidate_find_cache
1669
- @runtime.call('__evalAsyncScript', code.to_s, marshal_args(args || []))
1840
+ # Runs in the active frame realm inside `within_frame` (Selenium
1841
+ # parity), same as evaluate_script; the result slot is realm-local so
1842
+ # the poll below must read from the same realm.
1843
+ dom_call('__evalAsyncScript', code.to_s, marshal_args(args || []))
1670
1844
  # Pump virtual time so any setTimeout-driven completion lands.
1671
1845
  # Capybara's polling can't help here — we're inside one session
1672
1846
  # call, not a retry loop.
1673
1847
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) +
1674
1848
  Capybara.default_max_wait_time.to_f
1675
1849
  loop do
1676
- result = @runtime.call('__pollAsyncResult')
1850
+ result = dom_call('__pollAsyncResult')
1677
1851
  return result['value'] if result.is_a?(Hash) && result.key?('value')
1678
1852
  break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
1679
1853
  sleep 0.01
@@ -1712,7 +1886,7 @@ module Capybara
1712
1886
  # Background-thread work (workers, EventSource, MessageBus
1713
1887
  # long-poll) keeps the settle loop alive even when settle_gen
1714
1888
  # is otherwise idle.
1715
- return true if worker_pending? || event_source_pending? || hijack_fetch_pending?
1889
+ return true if worker_pending? || event_source_pending? || hijack_fetch_pending? || window_message_pending?
1716
1890
  if @timers_active
1717
1891
  gen = @runtime.settle_gen
1718
1892
  if @last_polled_gen.nil? || gen != @last_polled_gen
@@ -1743,7 +1917,7 @@ module Capybara
1743
1917
  # `Kernel#sleep`) and by `Playwright::Page#wait_for_timeout` to step a
1744
1918
  # precise virtual duration.
1745
1919
  def tick_real_time(step_ms: nil)
1746
- return unless @timers_active || worker_pending? || event_source_pending? || hijack_fetch_pending?
1920
+ return unless @timers_active || worker_pending? || event_source_pending? || hijack_fetch_pending? || window_message_pending?
1747
1921
  # Re-entrancy guard. Capybara's `Result#each` triggers nested
1748
1922
  # finds (visible? per element); the outermost tick has already
1749
1923
  # advanced the clock, the inner calls would only re-drain
@@ -1772,6 +1946,7 @@ module Capybara
1772
1946
  @find_cache_dirty = true if deliver_worker_messages > 0
1773
1947
  @find_cache_dirty = true if deliver_event_source_events > 0
1774
1948
  @find_cache_dirty = true if deliver_hijacked_fetches > 0
1949
+ @find_cache_dirty = true if deliver_window_messages > 0
1775
1950
  ensure
1776
1951
  @ticking = false
1777
1952
  end
@@ -1875,7 +2050,7 @@ module Capybara
1875
2050
  # drives the Rack app via `navigate` (for GET) or a POST.
1876
2051
  def submit_form_handle(form_handle, submitter_handle)
1877
2052
  invalidate_find_cache
1878
- spec = @runtime.call('__csimFormSerialize', form_handle, submitter_handle || 0)
2053
+ spec = dom_call('__csimFormSerialize', form_handle, submitter_handle || 0)
1879
2054
  return unless spec.is_a?(Hash)
1880
2055
  action = spec['action'].to_s
1881
2056
  method = spec['method'].to_s.upcase
@@ -1898,11 +2073,16 @@ module Capybara
1898
2073
  end
1899
2074
  URI.encode_www_form(fields)
1900
2075
  end
1901
- action_url = action.empty? ? (@current_url || @default_host) : resolve_against_current(action)
2076
+ 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'])
1902
2080
  if method == 'GET'
1903
2081
  uri = URI.parse(action_url)
1904
2082
  uri.query = body unless body.empty?
1905
- navigate(uri.to_s)
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)
1906
2086
  else
1907
2087
  navigate_post(action_url, body, content_type || enctype)
1908
2088
  end
@@ -2001,6 +2181,10 @@ module Capybara
2001
2181
  end
2002
2182
  @current_url = nil
2003
2183
  @document_handle = 0
2184
+ # A test may leave a frame switched-to without switching back
2185
+ # (Capybara's reset_session spec covers exactly this); start the
2186
+ # next test back on the main document.
2187
+ reset_frame_scope
2004
2188
  @history.clear
2005
2189
  @history_idx = -1
2006
2190
  @file_picks = {} if @file_picks
@@ -2019,6 +2203,7 @@ module Capybara
2019
2203
  reset_event_sources
2020
2204
  reset_hijacked_fetches
2021
2205
  reset_workers
2206
+ @window_inbox.clear
2022
2207
  @blob_registry_lock.synchronize { @blob_registry.clear }
2023
2208
  # Drop volatile entries from the class-level HTTP asset cache
2024
2209
  # so test-local DB state (TranslationOverride, etc.) reaches
@@ -2559,6 +2744,50 @@ module Capybara
2559
2744
 
2560
2745
  def worker_pending? = !@worker_outbox.empty? || @worker_in_flight > 0
2561
2746
 
2747
+ # ── Cross-window messaging (window.open / opener / postMessage) ──
2748
+ # Each window is a separate Browser/VM/isolate, so a reference to another
2749
+ # window can only be a proxy that forwards through the Driver. These
2750
+ # forward host-fn calls (invoked from THIS window's VM) to the Driver,
2751
+ # which routes to the target window's Browser.
2752
+
2753
+ # `window.open(url, name)` from JS — returns the new (or reused, by name)
2754
+ # window's handle, or nil. The URL is resolved against THIS document so a
2755
+ # relative `window.open('/x')` targets the right origin/path.
2756
+ def open_child_window(url, name)
2757
+ return nil unless @driver.respond_to?(:open_window_from_js)
2758
+ @driver.open_window_from_js(self, url.to_s, name.to_s)
2759
+ end
2760
+
2761
+ # `targetWindow.postMessage(data, origin)` — route to the target window's
2762
+ # inbox, tagged with this window as the source.
2763
+ def post_message_to_window(target_handle, data, origin)
2764
+ return unless @driver.respond_to?(:window_post_message)
2765
+ @driver.window_post_message(self, target_handle.to_s, data, origin.to_s)
2766
+ end
2767
+
2768
+ def window_location_of(handle) = @driver.respond_to?(:window_location) ? @driver.window_location(handle.to_s).to_s : ''
2769
+ def set_window_location(handle, url) = (@driver.window_set_location(handle.to_s, url.to_s) if @driver.respond_to?(:window_set_location))
2770
+ def window_closed?(handle) = @driver.respond_to?(:window_closed?) ? @driver.window_closed?(handle.to_s) : true
2771
+ def close_child_window(handle) = (@driver.close_window(handle.to_s) if @driver.respond_to?(:close_window))
2772
+ def opener_handle = @driver.respond_to?(:opener_handle_of) ? @driver.opener_handle_of(self) : nil
2773
+
2774
+ # Queue a cross-window message for delivery into THIS window's VM (called
2775
+ # by the Driver on the target Browser). Delivered as a `message` event the
2776
+ # next time this window settles / ticks.
2777
+ def enqueue_window_message(data, origin, source_handle)
2778
+ @window_inbox << {'data' => data, 'origin' => origin.to_s, 'sourceHandle' => source_handle.to_s}
2779
+ end
2780
+
2781
+ def window_message_pending? = !@window_inbox.empty?
2782
+
2783
+ # Fire queued cross-window messages as `message` events on window.
2784
+ def deliver_window_messages
2785
+ return 0 if @window_inbox.empty?
2786
+ events = @window_inbox.slice!(0, @window_inbox.length)
2787
+ @runtime.call('__csim_deliverWindowMessages', events)
2788
+ events.size
2789
+ end
2790
+
2562
2791
  # ── Image decode (libvips) ─────────────────────────────────────
2563
2792
  #
2564
2793
  # Called by the JS bridge whenever a Canvas / OffscreenCanvas
@@ -3169,6 +3398,95 @@ module Capybara
3169
3398
 
3170
3399
  # Fetch via the Rack app and hand the body to V8 for parsing.
3171
3400
  # Only follows 3xx redirects up to a small depth.
3401
+ # ── Frame-scoped navigation ─────────────────────────────────
3402
+ # A self-targeted link click / form submit INSIDE a `within_frame` block
3403
+ # navigates just that frame: fetch the document and rebuild the frame's
3404
+ # own realm, leaving the top page (its URL, history, status) untouched.
3405
+ # Mirrors `navigate` / `navigate_post`'s fetch + redirect-follow but
3406
+ # terminates in `reload_current_frame_realm` instead of a main-page boot.
3407
+
3408
+ def navigate_frame(url, depth: 0)
3409
+ raise 'too many redirects' if depth > 10
3410
+ invalidate_find_cache
3411
+ if url.to_s.match?(%r{\Aabout:blank(?:[?#]|\z)}i)
3412
+ reload_current_frame_realm('about:blank', '', 'text/html')
3413
+ return
3414
+ end
3415
+ env = Rack::MockRequest.env_for(url, method: 'GET')
3416
+ apply_default_request_env(env, referer: current_browsing_context_url)
3417
+ status, headers, body = dispatch_rack_or_http(url, env, method: 'GET')
3418
+ merge_set_cookie(headers)
3419
+ if (loc = redirect_location(status, headers))
3420
+ next_url = carry_fragment(url, resolve_against_current(loc))
3421
+ body.close if body.respond_to?(:close)
3422
+ return navigate_frame(next_url, depth: depth + 1)
3423
+ end
3424
+ if download_response?(headers)
3425
+ save_downloaded_response(url, headers, body)
3426
+ return
3427
+ end
3428
+ reload_current_frame_realm(url.to_s, read_rack_body(body), response_content_type(headers))
3429
+ end
3430
+
3431
+ def navigate_frame_post(url, body, content_type, depth: 0)
3432
+ raise 'too many redirects' if depth > 10
3433
+ invalidate_find_cache
3434
+ env = Rack::MockRequest.env_for(url, method: 'POST', input: body)
3435
+ env['CONTENT_TYPE'] = content_type.to_s.empty? ? 'application/x-www-form-urlencoded' : content_type
3436
+ env['CONTENT_LENGTH'] = body.bytesize.to_s
3437
+ apply_default_request_env(env, referer: current_browsing_context_url)
3438
+ status, headers, resp_body = dispatch_rack_or_http(url, env, method: 'POST', body: body)
3439
+ merge_set_cookie(headers)
3440
+ if (loc = redirect_location(status, headers))
3441
+ next_url = resolve_against_current(loc)
3442
+ resp_body.close if resp_body.respond_to?(:close)
3443
+ # 301/302/303 → GET; 307/308 preserve method + body (same as navigate_post).
3444
+ if [307, 308].include?(status)
3445
+ return navigate_frame_post(next_url, body, content_type, depth: depth + 1)
3446
+ else
3447
+ return navigate_frame(next_url, depth: depth + 1)
3448
+ end
3449
+ end
3450
+ if download_response?(headers)
3451
+ save_downloaded_response(url, headers, resp_body)
3452
+ return
3453
+ 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
3462
+ return unless entry
3463
+ old_id = entry[:realm_id]
3464
+ parent = entry[:parent_realm_id]
3465
+ new_id = @runtime.reload_frame_realm(old_id, parent.to_i, url, RuntimeShared.utf8_text(html), content_type).to_i
3466
+ return if new_id.zero?
3467
+ rebind_frame_realm(parent, entry[:iframe_handle], old_id, new_id)
3468
+ entry[:realm_id] = new_id
3469
+ @current_realm_id = new_id
3470
+ invalidate_find_cache
3471
+ settle
3472
+ end
3473
+
3474
+ def rebind_frame_realm(parent_realm_id, iframe_handle, old_id, new_id)
3475
+ if parent_realm_id.nil? || parent_realm_id.zero?
3476
+ @runtime.call('__csimRebindFrameRealm', iframe_handle, old_id, new_id)
3477
+ else
3478
+ @runtime.realm_call(parent_realm_id, '__csimRebindFrameRealm', iframe_handle, old_id, new_id)
3479
+ end
3480
+ end
3481
+
3482
+ # Response content-type, defaulting to text/html. Header values can be a
3483
+ # bare string or a one-element array (Rack 3 tuple form).
3484
+ def response_content_type(headers)
3485
+ ct = headers.find {|k, _| k.to_s.downcase == 'content-type' }&.last
3486
+ ct = ct.first if ct.is_a?(Array)
3487
+ ct.to_s.empty? ? 'text/html' : ct.to_s
3488
+ end
3489
+
3172
3490
  def navigate(url, depth: 0, referer: @current_url, from_history: false)
3173
3491
  raise 'too many redirects' if depth > 10
3174
3492
  invalidate_find_cache
@@ -3194,6 +3512,18 @@ module Capybara
3194
3512
  prior_navigating = @navigating
3195
3513
  @navigating = true unless from_history
3196
3514
  begin
3515
+ # `about:blank` names an empty, network-less document — there's
3516
+ # nothing to fetch. Rack::MockRequest.env_for can't parse the `about:`
3517
+ # scheme (no host/path → nil[]); route straight to an empty document.
3518
+ # `location = 'about:blank'` and navigating an iframe to about:blank
3519
+ # both land here. Mirrors rack_fetch's non-http(s) guard. (Narrow to
3520
+ # about:blank specifically — about:srcdoc carries its own markup.)
3521
+ if url.to_s.match?(%r{\Aabout:blank(?:[?#]|\z)}i)
3522
+ @current_url = url.to_s
3523
+ record_response(200, {'content-type' => 'text/html'})
3524
+ boot_response_into_ctx('')
3525
+ return
3526
+ end
3197
3527
  record_history({method: :get, url: url}) unless from_history || depth > 0
3198
3528
  env = Rack::MockRequest.env_for(url, method: 'GET')
3199
3529
  apply_default_request_env(env, referer: referer)
@@ -3259,6 +3589,9 @@ module Capybara
3259
3589
  # finish before the next document loads; this is the in-process analogue.
3260
3590
  flush_outgoing_page_init if @timers_active
3261
3591
  @runtime.rebuild_ctx
3592
+ # A full page (re)build disposes every frame realm, so any active
3593
+ # `within_frame` scope is now stale — fall back to the main document.
3594
+ reset_frame_scope
3262
3595
  reset_timer_state
3263
3596
  # The DOCUMENT is text; the Rack body arrives BINARY-tagged (see
3264
3597
  # `RuntimeShared.utf8_text`). Charset-header-driven decode is the
@@ -3509,6 +3842,9 @@ module Capybara
3509
3842
 
3510
3843
  def resolve_against_current(url, use_base: false)
3511
3844
  return url if url =~ %r{\A[a-z]+://}i
3845
+ # Inside a `within_frame` block the "current document" is the frame's,
3846
+ # so links / form actions resolve against the frame's URL + <base href>.
3847
+ doc_url = current_browsing_context_url || @default_host
3512
3848
  base =
3513
3849
  if use_base && (bh = base_href) && !bh.empty?
3514
3850
  # The document's `<base href>` takes precedence over the
@@ -3516,17 +3852,19 @@ module Capybara
3516
3852
  # being resolved — HTML's base-tag semantics. `visit` skips
3517
3853
  # this branch so an address-bar navigation reaches the URL
3518
3854
  # the test typed.
3519
- URI.join(@current_url || @default_host, bh).to_s
3855
+ URI.join(doc_url, bh).to_s
3520
3856
  else
3521
- @current_url || @default_host
3857
+ doc_url
3522
3858
  end
3523
3859
  URI.join(base, url.to_s).to_s
3524
3860
  rescue URI::InvalidURIError, URI::BadURIError
3525
3861
  url
3526
3862
  end
3527
3863
 
3864
+ # The active document's `<base href>` — routed to the current frame realm
3865
+ # inside `within_frame`, else the main document.
3528
3866
  def base_href
3529
- @runtime.call('__csimBaseHref').to_s
3867
+ dom_call('__csimBaseHref').to_s
3530
3868
  end
3531
3869
 
3532
3870
  def carry_fragment(from_url, to_url)