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