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.
- checksums.yaml +4 -4
- data/README.md +75 -9
- data/lib/capybara/simulated/browser.rb +410 -72
- data/lib/capybara/simulated/driver.rb +92 -13
- data/lib/capybara/simulated/errors.rb +7 -0
- data/lib/capybara/simulated/js/bridge.bundle.js +402 -51
- data/lib/capybara/simulated/node.rb +19 -3
- data/lib/capybara/simulated/runtime_shared.rb +10 -0
- data/lib/capybara/simulated/v8_runtime.rb +64 -9
- data/lib/capybara/simulated/version.rb +1 -1
- metadata +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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) =
|
|
573
|
-
def tag(handle) =
|
|
574
|
-
def attr(handle, name) =
|
|
575
|
-
def inner_html(handle) =
|
|
576
|
-
def outer_html(handle) =
|
|
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) =
|
|
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) =
|
|
739
|
+
def visible_text(handle) = dom_call('__csimVisibleText', handle).to_s
|
|
586
740
|
def tag_name(handle) = tag(handle)
|
|
587
|
-
def value(handle) =
|
|
588
|
-
def disabled?(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) =
|
|
752
|
+
def option_selected?(h) = !!dom_call('__csimOptionSelected', h)
|
|
599
753
|
def shadow_root_handle(handle)
|
|
600
|
-
h =
|
|
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 =
|
|
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) =
|
|
763
|
+
def node_path(handle) = dom_call('__csimNodePath', handle).to_s
|
|
610
764
|
|
|
611
765
|
def lookup_node(handle)
|
|
612
|
-
handle if
|
|
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) &&
|
|
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) &&
|
|
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
|
|
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 =
|
|
803
|
+
partial = dom_call('__csimClickResolve', handle, init)
|
|
650
804
|
sleep delay
|
|
651
|
-
|
|
805
|
+
dom_call('__csimClickFinish', handle, partial.is_a?(Hash) ? partial['base'] : init)
|
|
652
806
|
else
|
|
653
|
-
|
|
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
|
|
698
|
-
#
|
|
699
|
-
#
|
|
700
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1027
|
+
dom_call('__csimSetValue', handle, js_value)
|
|
863
1028
|
else
|
|
864
|
-
|
|
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
|
-
|
|
1087
|
+
dom_call('__csimDispatchEvent', handle, 'mousedown', init)
|
|
923
1088
|
sleep opts[:delay].to_f if opts[:delay].to_f > 0
|
|
924
|
-
|
|
925
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
1143
|
+
2.times { dom_call('__csimClickResolve', handle, opts) }
|
|
979
1144
|
init = {'bubbles' => true, 'cancelable' => true}.merge(click_event_init(handle, keys, opts))
|
|
980
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
1271
|
+
dom_call('__csimSendKeys', handle, [head])
|
|
1107
1272
|
tail.each {|atom|
|
|
1108
1273
|
tick_real_time
|
|
1109
|
-
|
|
1274
|
+
dom_call('__csimSendKeys', handle, [atom])
|
|
1110
1275
|
settle
|
|
1111
1276
|
}
|
|
1112
1277
|
else
|
|
1113
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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? ? (
|
|
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(
|
|
3855
|
+
URI.join(doc_url, bh).to_s
|
|
3520
3856
|
else
|
|
3521
|
-
|
|
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
|
-
|
|
3867
|
+
dom_call('__csimBaseHref').to_s
|
|
3530
3868
|
end
|
|
3531
3869
|
|
|
3532
3870
|
def carry_fragment(from_url, to_url)
|