Class: Capybara::Simulated::Driver

Inherits:
Driver::Base
  • Object
show all
Defined in:
lib/capybara/simulated/driver.rb

Defined Under Namespace

Classes: FakePlaywrightLocator, FakePlaywrightPage

Constant Summary collapse

PRIMARY_HANDLE =

Per-window Browser/VM. open_aux_window creates a fresh Browser sharing the Driver's cookie + localStorage jars (origin-shared in real browsers) and visits the target URL; switch_to_window flips @active_handle so subsequent driver ops route through current_browser. sessionStorage + DOM + history + the JS VM stay per-window.

'csim-window-0'
@@live_lock =
Mutex.new
@@live =

[WeakRef] — dead refs filtered on read.

[]

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(app, js_engine: nil, viewport: nil, user_agent: nil) ⇒ Driver

viewport: [w, h] and user_agent: (typically supplied via Capybara.register_driver) force the JS-side innerWidth/innerHeight and navigator.userAgent (plus HTTP_USER_AGENT on Rack requests) before the first navigate, so matchMedia / mobile-breakpoint branches and server-side UA-based mobile detection both resolve before any document loads. The Browser tracks both as "defaults" so reset! (per-test teardown) restores them between specs.



70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/capybara/simulated/driver.rb', line 70

def initialize(app, js_engine: nil, viewport: nil, user_agent: nil)
  # `Capybara.disable_animation` is delivered to the real drivers by a SERVER
  # middleware (session.rb adds AnimationDisabler to the Puma stack), which
  # injects `animation-duration: 0s !important` CSS into every HTML response.
  # This driver calls the Rack app in-process and never builds that server, so
  # the same wrap happens here — an app suite that turns animations off
  # (Discourse's rails_helper) must see the same 0s durations a real browser
  # run sees, or every `await`-on-animation close path (FloatKit's menu) parks
  # on a full-length animation-fallback timer no user action waits for.
  @app             = Capybara.disable_animation ? Capybara::Server::AnimationDisabler.new(app) : app
  @js_engine       = js_engine
  # Cookies + localStorage are origin-shared across windows
  # (real browser semantics), so we own the jars at the Driver
  # level and inject them into every per-window Browser. Each
  # Browser still has its own sessionStorage + DOM + JS VM.
  @cookies         = {}
  @cookie_flags    = {}   # (host \0 name) => {secure: true} — attribute sidecar for the shared jar
  @auth_cache      = {}
  @local_storage   = {}
  # Cache Storage (caches/Cache) is origin-shared like localStorage — owned at the
  # Driver level and injected, so a service worker and every same-origin window see
  # the same caches (partitioned by origin key within the store).
  @cache_storage   = {}
  # Capture the universal-server flag ONCE, at session construction — the WPT
  # runner sets CSIM_LOCAL_ALL_HOSTS only while building the session, then
  # restores it. Every window (incl. aux windows opened later) inherits this so
  # cross-origin iframes eager-build consistently across the whole session.
  @all_hosts_local = ENV['CSIM_LOCAL_ALL_HOSTS'] == '1'
  @browser         = build_window_browser
  @browser.window_handle = PRIMARY_HANDLE
  @aux_windows     = []  # [{handle:, browser:, name:, opener:}, …]
  # Browsers whose WINDOW closed while they still host an active service-worker
  # registration. A registration is profile-wide state independent of the document
  # that created it (a page registers, closes, and the SW keeps controlling
  # navigations elsewhere), so the Browser is parked — keeping its SW worker
  # thread + scope registry alive for sw_navigation_fetch — instead of disposed.
  # Reclaimed by reset_windows!.
  @sw_parked       = []
  @active_handle   = nil
  @next_window_seq = 0
  # Driver-level blob URL partition map: url => {browser:, site:}. A blob URL's
  # storage partition is its creating context's top-level SITE; another window
  # can resolve the blob only from the same partition (and same origin, which
  # the blob: URL embeds). Bytes aren't copied here — they're read back from the
  # creating Browser's own store, so this stays a light reference map.
  @blob_partitions      = {}
  @blob_partitions_lock = Mutex.new
  @owner_thread    = Thread.current
  @@live_lock.synchronize { @@live << WeakRef.new(self) }
  @browser.default_viewport   = viewport   if viewport
  @browser.default_user_agent = user_agent if user_agent
end

Instance Attribute Details

#appObject (readonly)

Returns the value of attribute app.



46
47
48
# File 'lib/capybara/simulated/driver.rb', line 46

def app
  @app
end

#browserObject (readonly)

Returns the value of attribute browser.



159
160
161
# File 'lib/capybara/simulated/driver.rb', line 159

def browser
  @browser
end

#owner_threadObject (readonly)

Returns the value of attribute owner_thread.



46
47
48
# File 'lib/capybara/simulated/driver.rb', line 46

def owner_thread
  @owner_thread
end

Class Method Details

.each_live_on_thread(thread) ⇒ Object



51
52
53
54
55
56
57
58
59
60
# File 'lib/capybara/simulated/driver.rb', line 51

def self.each_live_on_thread(thread)
  drivers = @@live_lock.synchronize {
    @@live.select!(&:weakref_alive?)
    @@live.filter_map {|ref| ref.__getobj__ rescue nil }
  }
  # A DISPOSED driver is not live: its runtime context is gone, so calling into it raises
  # (`undefined method 'call' for nil` out of `run_loop_step`). `dispose` deregisters, but the
  # predicate is the belt — a WeakRef stays in the list until GC actually collects.
  drivers.each {|d| yield d if d.owner_thread == thread && !d.disposed? }
end

Instance Method Details

#accept_modal(type, **options, &block) ⇒ Object



913
# File 'lib/capybara/simulated/driver.rb', line 913

def accept_modal(type, **options, &block) = run_modal(type, accept: true, **options, &block)

#active_elementObject



885
886
887
888
# File 'lib/capybara/simulated/driver.rb', line 885

def active_element
  handle = current_browser.active_element_handle
  handle ? Node.new(self, handle) : nil
end

#blob_bytes_for(url, accessor) ⇒ Object

Resolve a blob: URL's bytes from whichever Browser created it (the bytes live in the creator's isolate, not necessarily the navigator's). Used to load a blob document into a TOP-LEVEL window (window.open / a window navigation): that new context is the blob's own partition, so no partition gate applies here — the cross-partition rule for windows is the noopener severing above, and for nested frames it is enforced at the frame-navigation site. Falls back to the accessor's own store for an unpartitioned URL (worker / legacy path).



561
562
563
564
565
# File 'lib/capybara/simulated/driver.rb', line 561

def blob_bytes_for(url, accessor)
  entry = @blob_partitions_lock.synchronize { @blob_partitions[url.to_s] }
  creator = entry ? entry[:browser] : accessor
  creator.respond_to?(:read_blob_for_window) ? creator.read_blob_for_window(url) : nil
end

#blob_partition_site_of(url) ⇒ Object

The storage-partition site a blob: URL was created in (its creator's top-level site), or nil for an unknown / revoked / unpartitioned URL.



541
542
543
544
# File 'lib/capybara/simulated/driver.rb', line 541

def blob_partition_site_of(url)
  e = @blob_partitions_lock.synchronize { @blob_partitions[url.to_s] }
  e && e[:site]
end

#broadcast_channel(source_browser, name, data, origin = nil) ⇒ Object

BroadcastChannel.postMessage — deliver to every OTHER window's channels with the same name (same-window delivery is handled in-VM by the sender).



621
622
623
624
625
626
627
628
629
630
631
632
633
634
# File 'lib/capybara/simulated/driver.rb', line 621

def broadcast_channel(source_browser, name, data, origin = nil)
  # An opaque origin is unique to its own agent cluster; its key is a token ('opaque:…') minted
  # per-realm and therefore only unique WITHIN one isolate — two unrelated opaque contexts in
  # DIFFERENT windows could mint the same token. A BroadcastChannel never bridges two distinct
  # opaque origins, and no opaque origin spans separate top-level windows here, so a cross-
  # WINDOW post from an opaque origin reaches no one: drop it rather than risk a cross-isolate
  # token collision. (Same-isolate opaque peers are reached in-VM / via enqueue_broadcast; an
  # inherited-origin blob worker via its own inbox — neither goes through this cross-window path.)
  return if origin.to_s.start_with?('opaque:')
  window_entries.each do |w|
    next if w[:browser].equal?(source_browser)
    w[:browser].enqueue_broadcast(name, data, nil, origin)
  end
end

#clear_http_cacheObject

Start the next fetches from a cold HTTP cache. reset! keeps what a persistent browser profile would — fresh Cache-Control: immutable responses, plus the still-fresh script / stylesheet sources and @font-face files — so a test whose app serves new bytes at a cacheable URL it already served (a stylesheet digested from a DB row that a rolled-back example reuses) asks for the cold cache a fresh Playwright / Cuprite context starts with. Process-wide: every session shares the one cache (Capybara::Simulated.clear_http_cache is the same call for a hook that runs before any session exists).



336
# File 'lib/capybara/simulated/driver.rb', line 336

def clear_http_cache = Browser.clear_http_cache

#close_window(h) ⇒ Object



740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
# File 'lib/capybara/simulated/driver.rb', line 740

def close_window(h)
  return if h == PRIMARY_HANDLE
  @aux_windows.reject! {|w|
    next false unless w[:handle] == h
    # HTML "close a browsing context": the teardown events (pagehide+unload,
    # this window and every nested frame, parent-first) fire while the VM
    # still works — a nested iframe's unload keepalive beacon depends on it.
    w[:browser].fire_document_teardown if w[:browser].respond_to?(:fire_document_teardown)
    if w[:browser].sw_registrations_active?
      # Still hosting a live service-worker registration — park (see @sw_parked):
      # tear down the document-scoped machinery, retire older parked browsers
      # whose scopes this one re-registered (the newest registration for a scope
      # is THE registration, as in a real profile), and keep the browser alive.
      w[:browser].park_for_service_workers!
      retire_shadowed_parked(w[:browser])
      @sw_parked << w[:browser]
    else
      drop_blob_partitions_for(w[:browser])   # don't leave entries pointing at a disposed VM
      w[:browser].dispose rescue nil
    end
    true
  }
  @active_handle = nil if @active_handle == h
end

#cross_partition_blob?(url, accessor) ⇒ Boolean

Is this blob: URL in a different storage partition than accessor's top-level site? Unknown blobs (no entry) are treated as same-partition (no extra gating beyond the existing same-origin behaviour).

Returns:

  • (Boolean)


549
550
551
552
# File 'lib/capybara/simulated/driver.rb', line 549

def cross_partition_blob?(url, accessor)
  site = blob_partition_site_of(url)
  !site.nil? && site != accessor.blob_partition_site
end

#current_browserObject

Active window's Browser. Primary by default; switches when the test calls switch_to_window(aux_handle). Every DOM / URL / JS-touching driver method routes through here so per-window state (DOM, sessionStorage, history) stays window-scoped.



165
166
167
168
169
# File 'lib/capybara/simulated/driver.rb', line 165

def current_browser
  return @browser unless @active_handle
  w = @aux_windows.find {|win| win[:handle] == @active_handle }
  w ? w[:browser] : @browser
end

#current_traceObject



157
# File 'lib/capybara/simulated/driver.rb', line 157

def current_trace = browser.trace || browser.pending_trace

#current_urlObject



391
# File 'lib/capybara/simulated/driver.rb', line 391

def current_url          = current_browser.current_url || ''

#current_window_handleObject



422
# File 'lib/capybara/simulated/driver.rb', line 422

def current_window_handle    = @active_handle || PRIMARY_HANDLE

#dismiss_modal(type, **options, &block) ⇒ Object



914
# File 'lib/capybara/simulated/driver.rb', line 914

def dismiss_modal(type, **options, &block) = run_modal(type, accept: false, **options, &block)

#disposeObject

Full teardown of the whole driver: aux windows AND the primary browser's V8 isolate. reset_windows! deliberately keeps the primary alive (the per-test reset path rebuilds only its page); this is for permanently DROPPING a session. A caller that nils its session without this leaks the primary isolate — with its heap, canvas pixel buffers, and worker threads — into V8Runtime's process-wide @@live until at_exit. The WPT runner recycles the cross-origin session per .sub./.https. file, so that leak is ~one isolate per cross-origin file (hundreds over the suite); disposing here incrementally is what reset_windows! already does for aux windows.



375
376
377
378
379
380
381
382
383
# File 'lib/capybara/simulated/driver.rb', line 375

def dispose
  return if @disposed
  @disposed = true
  # Drop out of the live registry FIRST: everything below tears down the runtime this driver
  # would be asked to step if `each_live_on_thread` still yielded it.
  @@live_lock.synchronize { @@live.reject! {|ref| (ref.__getobj__ rescue nil).equal?(self) } }
  reset_windows!
  @browser.dispose rescue nil
end

#disposed?Boolean

Has this driver been permanently dropped? (A reset! between examples does NOT set this — that rebuilds the page on a live runtime.)

Returns:

  • (Boolean)


387
# File 'lib/capybara/simulated/driver.rb', line 387

def disposed? = @disposed == true

#drain_background_requestsObject

Join every window's background app-request threads (async loads, keepalive fetches) without resetting anything else. The test harness calls this ahead of the app's own after-hooks: cleanup that bypasses ActiveRecord's per-connection lock (Discourse's mini_sql DB.exec) must not interleave with a still-running background request on the same raw socket — reset!'s drain alone runs after those hooks.



344
345
346
347
# File 'lib/capybara/simulated/driver.rb', line 344

def drain_background_requests
  @aux_windows.each {|w| w[:browser].drain_app_request_threads rescue nil }
  browser.drain_app_request_threads
end

#evaluate_async_script(script, *args) ⇒ Object



855
856
857
# File 'lib/capybara/simulated/driver.rb', line 855

def evaluate_async_script(script, *args)
  unwrap(current_browser.evaluate_async_script(script, args))
end

#evaluate_script(script, *args) ⇒ Object



840
841
842
# File 'lib/capybara/simulated/driver.rb', line 840

def evaluate_script(script, *args)
  unwrap(current_browser.evaluate_script(script, args))
end

#execute_script(script, *args) ⇒ Object

Capybara's execute_script contract is "run it, discard the return". Route through a no-return JS path so a script that returns a non-marshallable value (jQuery $('…').text('…') returns a chainable jQuery object that the engine's value filter recurses into until it stack-overflows) doesn't blow up on the way back.



850
851
852
853
# File 'lib/capybara/simulated/driver.rb', line 850

def execute_script(script, *args)
  current_browser.execute_script(script, args)
  nil
end

#find_css(query, **_) ⇒ Object



402
403
404
# File 'lib/capybara/simulated/driver.rb', line 402

def find_css(query, **_)
  current_browser.find_css(query).map {|id| Node.new(self, id) }
end

#find_xpath(query, **_) ⇒ Object



398
399
400
# File 'lib/capybara/simulated/driver.rb', line 398

def find_xpath(query, **_)
  current_browser.find_xpath(query).map {|id| Node.new(self, id) }
end

#fire_aux_window_load(handle) ⇒ Object

Cross-window remote-ref RPC: route a node/object proxy op to the window that owns the ref (handle), executing in that window's VM.



659
# File 'lib/capybara/simulated/driver.rb', line 659

def fire_aux_window_load(handle)             = ((b = window_browser(handle)) && b.fire_own_window_load)

#fullscreen_window(handle) ⇒ Object



834
# File 'lib/capybara/simulated/driver.rb', line 834

def fullscreen_window(handle) = restore_window_size(handle)

#go_backObject



388
# File 'lib/capybara/simulated/driver.rb', line 388

def go_back              = current_browser.go_back

#go_forwardObject



389
# File 'lib/capybara/simulated/driver.rb', line 389

def go_forward           = current_browser.go_forward

#header(name, value) ⇒ Object



396
# File 'lib/capybara/simulated/driver.rb', line 396

def header(name, value)  = current_browser.set_header(name, value)

#htmlObject



392
# File 'lib/capybara/simulated/driver.rb', line 392

def html                 = current_browser.html

#invalid_element_errorsObject



870
# File 'lib/capybara/simulated/driver.rb', line 870

def invalid_element_errors = [Capybara::Simulated::StaleElement, Capybara::Simulated::ClickIntercepted]

#javascript_enabled?Boolean

Returns:

  • (Boolean)


172
# File 'lib/capybara/simulated/driver.rb', line 172

def javascript_enabled? = true

#js_engineObject

Which JS engine is behind this driver (:v8 / :quickjs), for a trace's metadata.



148
# File 'lib/capybara/simulated/driver.rb', line 148

def js_engine = browser.js_engine

#maximize_window(handle) ⇒ Object

Both restore the window to the display it lives on (Browser#screen_size), which is where it started — so they undo a resize_to rather than doing nothing. Coarse: we model no window chrome, so a maximized window and a fullscreen one end up the same size (a real browser's fullscreen is taller by the chrome it hides).



833
# File 'lib/capybara/simulated/driver.rb', line 833

def maximize_window(handle)   = restore_window_size(handle)

#needs_server?Boolean

Returns:

  • (Boolean)


171
# File 'lib/capybara/simulated/driver.rb', line 171

def needs_server?       = false

#no_such_window_errorObject



871
# File 'lib/capybara/simulated/driver.rb', line 871

def no_such_window_error   = Capybara::WindowError

#open_aux_window(url = nil, name: nil, opener_handle: nil, source: nil, blob_snapshot: nil, post: nil, opener: false, referrer: nil, defer_load: false) ⇒ Object

Open (or, by name, reuse) an auxiliary window. target="_blank" clicks and window.open both land here. A non-empty name that matches an existing window navigates that window instead of opening a new one (HTML window-name targeting); opener_handle records the opener so the new window's window.opener resolves back to it. defer_load: the caller will fire the new window's load itself, one task later, so that a handler either side registers right after window.open() is in place first (platform-globals' fireAuxLoadSoon). ONLY the JS window.open path does that — a target=_blank click or open_new_window hands nobody a reference to hook, so their window announces its own load like any other.



453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
# File 'lib/capybara/simulated/driver.rb', line 453

def open_aux_window(url = nil, name: nil, opener_handle: nil, source: nil, blob_snapshot: nil, post: nil, opener: false, referrer: nil, defer_load: false)
  name = name.to_s
  # A blob: URL opened from a different storage partition is forced noopener
  # (cross-partition-navigation), overriding an explicit rel=opener — the new
  # top-level window is the blob's own partition (so the blob still loads), but
  # the opener relationship is severed.
  opener = false if opener && url.to_s.start_with?('blob:') && source && cross_partition_blob?(url, source)
  # A `<form target>` keeps its opener by default (unlike a `target=_blank`
  # LINK, which is noopener) — resolve the opener handle from the source.
  opener_handle ||= handle_for(source) if opener && source
  if !name.empty? && (existing = @aux_windows.find {|w| w[:name] == name })
    if post
      existing[:browser].navigate_post(url, post[:body], post[:content_type], referer: referrer, initiator: source&.raw_current_url)
    else
      navigate_window(existing[:browser], url, source: source)
    end
    return existing[:handle]
  end
  @next_window_seq += 1
  handle = "csim-window-#{@next_window_seq}"
  aux = build_window_browser
  aux.defer_window_load = defer_load
  aux.window_handle = handle
  # Register BEFORE visiting: the opened document's own boot scripts read
  # `window.opener`, which resolves through this entry — so the entry
  # (with its opener) must exist before `visit` runs those scripts.
  @aux_windows << {handle: handle, browser: aux, name: name, opener: opener_handle}
  if url && !url.empty?
    if post
      # A `<form target=_blank method=post>` loads the new window via POST,
      # carrying the opener's URL as referrer (unless rel=noreferrer → '').
      aux.navigate_post(url, post[:body], post[:content_type], referer: referrer, initiator: source&.raw_current_url)
    # A blob: URL isn't rack-navigable and its bytes live in the OPENER's
    # isolate — load the document directly from a click-time snapshot (a
    # deferred target=_blank nav may revoke the URL first) or, failing that,
    # the opener's blob store.
    elsif !(url.to_s.start_with?('blob:') && load_blob_into_window(aux, url, source, snapshot: blob_snapshot))
      # A form submission carries a referrer (the opener's URL) unless the
      # form opted out via rel=noreferrer (referrer: '').
      # The OPENER's document is the navigation initiator — it seeds the popup
      # load's Sec-Fetch-Site (the SameSite cookie gate reads it).
      # The OPENER's document is the navigation initiator — it seeds the popup
      # load's Sec-Fetch-Site (the SameSite cookie gate reads it). raw_current_url:
      # the ticking `current_url` must not run re-entrantly inside window.open.
      aux.visit(url, referer: referrer, initiator: source&.raw_current_url)
    end
  end
  handle
rescue StandardError => e
  # Aux window URL-load failure (binary content, network error, …)
  # shouldn't tear down the test — the handle is already recorded so
  # `window_opened_by` succeeds; within_window assertions on
  # `current_url` may still pass through whatever `visit` managed to set
  # before raising.
  warn "[csim] open_aux_window(#{url.inspect}) raised: #{e.class}: #{e.message[0, 200]}"
  handle
end

#open_new_window(_kind = :tab) ⇒ Object

Capybara Session#open_new_window(:tab) entry point — opens at about:blank (so current_url/title match a real new tab) and the test then switch_to_window + visits the real URL. We don't distinguish :tab from :window (no window-chrome semantics here).



729
730
731
# File 'lib/capybara/simulated/driver.rb', line 729

def open_new_window(_kind = :tab)
  open_aux_window('about:blank')
end

#open_window_from_js(opener_browser, url, name, opener_realm_id = 0, about_base = nil, about_origin = nil) ⇒ Object

window.open(url, name) from the opener window's JS. Resolves the URL against the opener's document and records the opener relationship.



517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
# File 'lib/capybara/simulated/driver.rb', line 517

def open_window_from_js(opener_browser, url, name, opener_realm_id = 0, about_base = nil, about_origin = nil)
  resolved = url.to_s.empty? ? nil : opener_browser.resolve_document_url(url)
  # Opening a blob: URL whose storage partition differs from the opener's
  # top-level site is forced NOOPENER (cross-partition-navigation): the new
  # auxiliary window is its own top-level context in the blob's partition, so the
  # blob still loads — but there is no opener relationship and `window.open`
  # returns null. Same-partition keeps the normal opener.
  if resolved.to_s.start_with?('blob:') && cross_partition_blob?(resolved, opener_browser)
    open_aux_window(resolved, name: name, source: opener_browser)   # no opener_handle ⇒ window.opener null
    return nil                                                      # window.open(...) === null
  end
  # Same-origin window → a realm in the opener's isolate (shared heap); the
  # returned realm-id context becomes a native WindowProxy on the JS side, so
  # cross-window scripting/adoption need no cross-isolate RPC. The opener's realm
  # id wires the popup's window.opener. Falls through to the separate-VM aux path
  # (cross-origin, or a URL we don't yet realm-load).
  if (rid = opener_browser.open_window_realm(resolved, name: name, opener_realm_id: opener_realm_id, about_base: about_base, about_origin: about_origin))
    return rid
  end
  open_aux_window(resolved, name: name, opener_handle: handle_for(opener_browser), source: opener_browser, defer_load: true)
end

#opener_handle_of(browser) ⇒ Object



695
696
697
698
# File 'lib/capybara/simulated/driver.rb', line 695

def opener_handle_of(browser)
  handle = handle_for(browser)
  window_entries.find {|w| w[:handle] == handle }&.fetch(:opener)
end

#peek_script(expr) ⇒ Object

Clock-free read of a JS expression in the active browsing context (no virtual-time advance, unlike evaluate_script) — for polling page state between event-loop frames without perturbing the clock.



319
# File 'lib/capybara/simulated/driver.rb', line 319

def peek_script(expr) = current_browser.peek_script(expr)

#refreshObject



322
# File 'lib/capybara/simulated/driver.rb', line 322

def refresh              = current_browser.refresh

#register_blob_partition(url, browser, site) ⇒ Object

Record / drop a blob URL's storage partition (called by Browser#blob_register / #blob_unregister). site is the creating context's top-level site.



569
570
571
# File 'lib/capybara/simulated/driver.rb', line 569

def register_blob_partition(url, browser, site)
  @blob_partitions_lock.synchronize { @blob_partitions[url.to_s] = {browser: browser, site: site.to_s} }
end

#reset!Object



323
324
325
326
# File 'lib/capybara/simulated/driver.rb', line 323

def reset!
  reset_windows!
  browser.reset!
end

#reset_history!Object



390
# File 'lib/capybara/simulated/driver.rb', line 390

def reset_history!       = current_browser.reset_history!

#reset_windows!Object

Dispose every auxiliary window and return focus to the primary — a fresh browsing context has no sibling windows. Disposing each aux Browser tears down its worker / SSE / websocket threads and its V8 isolate eagerly; left alone they pile into V8Runtime's process-wide @@live set and are only reclaimed by the at_exit hook, which on a long-lived multi-file session (the WPT runner) means a slow — sometimes minutes-long — process exit. Split out of reset! so a caller can drop windows WITHOUT resetting the primary's page state (the WPT runner rebuilds the primary itself, per file, via visit).



357
358
359
360
361
362
363
364
# File 'lib/capybara/simulated/driver.rb', line 357

def reset_windows!
  @aux_windows.each {|w| w[:browser].dispose rescue nil }
  @aux_windows.clear
  @sw_parked.each {|b| b.dispose rescue nil }
  @sw_parked.clear
  @active_handle = nil
  @blob_partitions_lock.synchronize { @blob_partitions.clear }
end

#resize(w, h) ⇒ Object

Forem's ahoy-tracking spec calls driver.resize(w, h) directly rather than through current_window.resize_to.



828
829
830
831
832
# File 'lib/capybara/simulated/driver.rb', line 828

def resize(w, h) = current_browser.set_viewport(w, h)
# Both restore the window to the display it lives on (`Browser#screen_size`), which is where
# it started — so they undo a `resize_to` rather than doing nothing. Coarse: we model no
# window chrome, so a maximized window and a fullscreen one end up the same size (a real
# browser's fullscreen is taller by the chrome it hides).

#resize_window_to(handle, w, h) ⇒ Object



825
826
827
# File 'lib/capybara/simulated/driver.rb', line 825

def resize_window_to(handle, w, h) = window_browser!(handle).set_viewport(w, h)
# Forem's ahoy-tracking spec calls `driver.resize(w, h)` directly
# rather than through `current_window.resize_to`.

#response_headersObject



395
# File 'lib/capybara/simulated/driver.rb', line 395

def response_headers     = current_browser.response_headers

#revoke_blob_partitioned(url, source) ⇒ Object

A user URL.revokeObjectURL(url) from source. Storage-partitioned: a revoke from a different top-level site than the blob's is a NO-OP (cross-partition.https "shouldn't be revocable from a cross-partition iframe/worker"). A same-partition revoke drops the Driver entry AND invalidates the blob in the CREATOR's isolate (the blob may have been created in another window), so every window stops resolving it. Returns false when vetoed (caller leaves its local copy intact).



583
584
585
586
587
588
589
590
# File 'lib/capybara/simulated/driver.rb', line 583

def revoke_blob_partitioned(url, source)
  entry = @blob_partitions_lock.synchronize { @blob_partitions[url.to_s] }
  return false if entry && entry[:site] != source.blob_partition_site
  @blob_partitions_lock.synchronize { @blob_partitions.delete(url.to_s) }
  creator = entry && entry[:browser]
  creator.drop_local_blob(url.to_s) if creator && creator.respond_to?(:drop_local_blob) && !creator.equal?(source)
  true
end

#run_event_loop_frame(frame_ms) ⇒ Object

Run one real-cadence event-loop frame and return the loop's observable state. Drives "advance the page one frame" without the full poll tick evaluate_script would incur per read; the wpt_runner uses it to drain a page to completion at browser cadence.

EVERY live window steps, not just the active one: an auxiliary window is a separate VM, but the cross-context orchestration the dispatcher framework builds on (a popup running an executor that polls a shared queue while the opener waits) needs those background windows to make progress autonomously. The active window's state leads; each aux window folds its progress / raf / async / nearest-timer in so the caller keeps pumping while any window works.



267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
# File 'lib/capybara/simulated/driver.rb', line 267

def run_event_loop_frame(frame_ms)
  state = current_browser.run_event_loop_frame(frame_ms)
  # Iterate a SNAPSHOT: a window's drain can open or close windows mid-loop (a
  # popup spawning another, or window.close disposing one). Re-check each window
  # is still open before stepping it, and isolate a per-window failure so one
  # dead/half-torn-down VM can't abort the whole frame pump.
  @aux_windows.dup.each do |w|
    b = w[:browser]
    next if b.equal?(current_browser)
    next unless @aux_windows.include?(w)   # closed earlier this loop → skip
    begin
      state = merge_frame_state(state, b.run_event_loop_frame(frame_ms))
    rescue StandardError
      next
    end
  end
  state
end

#save_screenshot(path, full: false, **_opts) ⇒ Object

A real raster of the laid-out page (see js/src/paint.js), not a serialization of it: the painter reads the same boxes every geometry query reads, so a screenshot can only show what the driver already believes. full: true paints the whole document rather than the viewport.



877
878
879
880
881
882
883
# File 'lib/capybara/simulated/driver.rb', line 877

def save_screenshot(path, full: false, **_opts)
  data = current_browser.screenshot_png(full: full)
  raise Capybara::Simulated::ScreenshotFailed, 'screenshot: the page painted nothing' if data.nil?

  File.binwrite(path, data)
  path
end

#send_keys(*keys) ⇒ Object



899
900
901
902
903
904
905
906
907
908
909
910
911
# File 'lib/capybara/simulated/driver.rb', line 899

def send_keys(*keys)
  # Selenium contract: top-level modifier symbols (`send_keys(
  # :shift, :enter)`) press the modifier *and hold it* over the
  # following key, releasing at the end of the call. Nested
  # arrays (`send_keys([:control, "/"])`) are chords — modifiers
  # combined with the final key in one press. Pass the whole
  # batch to `Browser#send_session_keys` in one call so the
  # JS-side handler can build a `combo` atom from the held
  # modifiers + the next key. Iterating per-key would split the
  # chord across calls and drop the modifier flags.
  current_browser.send_session_keys(keys)
  nil
end

#set_geolocation(latitude: nil, longitude: nil, accuracy: 10, denied: false, **rest) ⇒ Object

CDP-ish geolocation override (Capybara driver-level API).

page.driver.set_geolocation(latitude: 35.6, longitude: 139.7)
page.driver.set_geolocation(denied: true)  # PERMISSION_DENIED
page.driver.set_geolocation                # clear -> POSITION_UNAVAILABLE


895
896
897
# File 'lib/capybara/simulated/driver.rb', line 895

def set_geolocation(latitude: nil, longitude: nil, accuracy: 10, denied: false, **rest)
  current_browser.set_geolocation(latitude: latitude, longitude: longitude, accuracy: accuracy, denied: denied, **rest)
end

#start_tracing(**metadata) ⇒ Object

Per-test trace recording. Mirrors capybara-playwright-driver's start_tracing / stop_tracing shape so suites can swap drivers without rewriting hooks.



138
# File 'lib/capybara/simulated/driver.rb', line 138

def start_tracing(**) = browser.start_trace()

#status_codeObject



394
# File 'lib/capybara/simulated/driver.rb', line 394

def status_code          = current_browser.status_code

#stop_tracing(path: nil) ⇒ Object



140
141
142
143
144
145
# File 'lib/capybara/simulated/driver.rb', line 140

def stop_tracing(path: nil)
  active = current_trace or return nil
  result = path ? browser.finish_trace_to(path, active) : active
  browser.clear_trace!
  result
end

#storage_broadcast(source_browser, kind, key, old, new, url) ⇒ Object

A localStorage change fans out to every OTHER window (localStorage spans same-origin browsing contexts). Every window shares the Driver's one @local_storage jar, so all windows are same-origin peers here (cross-origin storage partitioning is a separate backlog item); a nil source realm reaches each target's every realm. sessionStorage is per-context and never reaches this path.



641
642
643
644
645
646
# File 'lib/capybara/simulated/driver.rb', line 641

def storage_broadcast(source_browser, kind, key, old, new, url)
  window_entries.each do |w|
    next if w[:browser].equal?(source_browser)
    w[:browser].enqueue_storage_event(kind, key, old, new, url, nil)
  end
end

#sw_navigation_fetch(url, **kw) ⇒ Object

The fetch-event round-trip for a controlled NAVIGATION, resolved across the whole window set: a service-worker registration is profile-wide, so the Browser hosting the controlling SW may be any window's — or a parked one whose window already closed (searched newest-parked first: a re-registered scope's newest worker is the active registration). Returns the owner's respondWith wire hash, or nil (uncontrolled → load from the network).



799
800
801
802
803
804
805
806
807
808
809
810
811
# File 'lib/capybara/simulated/driver.rb', line 799

def sw_navigation_fetch(url, **kw)
  sweep_dead_parked
  owner = ([@browser] + @aux_windows.map {|w| w[:browser] } + @sw_parked.reverse).find {|b|
    b.sw_controls_navigation?(url)
  }
  return nil unless owner

  resp = owner.service_worker_navigation_fetch(url, **kw)
  # Nobody pumps a parked browser — drop what the handler queued for its dead
  # clients so the outbox can't grow across a long test.
  owner.drop_dead_letter_worker_messages if @sw_parked.include?(owner)
  resp
end

#switch_to_frame(frame) ⇒ Object

Capybara within_frame / switch_to_frame. frame is the iframe Capybara::Node::Element (its .native is our driver Node), or the :parent / :top symbols. The block's finds + actions then route into the frame's own V8 realm via the Browser's @current_realm_id.



410
411
412
413
# File 'lib/capybara/simulated/driver.rb', line 410

def switch_to_frame(frame)
  target = frame.is_a?(Symbol) ? frame : frame.native.handle_id
  current_browser.switch_to_frame(target)
end

#switch_to_window(h) ⇒ Object



821
822
823
824
# File 'lib/capybara/simulated/driver.rb', line 821

def switch_to_window(h)
  window_browser!(h)   # unknown / already-closed handle → WindowError
  @active_handle = (h == PRIMARY_HANDLE ? nil : h)
end

#titleObject



393
# File 'lib/capybara/simulated/driver.rb', line 393

def title                = current_browser.title

#trace_screenshotObject

The ACTIVE window's page, painted for the trace's final state (TracePersistence) — current_browser, like every other user-facing read here, not the primary browser: a test that ended inside switch_to_window would otherwise be handed a picture of the window it was not looking at.



154
# File 'lib/capybara/simulated/driver.rb', line 154

def trace_screenshot = current_browser.trace_screenshot

#tracing?Boolean

Returns:

  • (Boolean)


156
# File 'lib/capybara/simulated/driver.rb', line 156

def tracing?      = !current_trace.nil?

#unregister_blob_partition(url) ⇒ Object



573
574
575
# File 'lib/capybara/simulated/driver.rb', line 573

def unregister_blob_partition(url)
  @blob_partitions_lock.synchronize { @blob_partitions.delete(url.to_s) }
end

#visit(path) ⇒ Object



321
# File 'lib/capybara/simulated/driver.rb', line 321

def visit(path)          = current_browser.visit(path)

#wait?Boolean

Dynamic wait?: only poll when there's pending timer work that real-time advancement could resolve. With no timers queued, polling can't change anything, so we fail fast via the wait? = false synchronize path.

Returns:

  • (Boolean)


254
# File 'lib/capybara/simulated/driver.rb', line 254

def wait?               = current_browser.polling?

#window_browser(handle) ⇒ Object

The Browser backing a handle, or nil if the window is closed/unknown.



433
434
435
# File 'lib/capybara/simulated/driver.rb', line 433

def window_browser(handle)
  window_entries.find {|w| w[:handle] == handle }&.fetch(:browser)
end

#window_browser!(handle) ⇒ Object

Same, but for the operations that ADDRESS a window rather than probe for one: a closed or unknown handle is Capybara's WindowError, never a silent fall back to the current window.



439
440
441
# File 'lib/capybara/simulated/driver.rb', line 439

def window_browser!(handle)
  window_browser(handle) or raise Capybara::WindowError, "Unknown window handle: #{handle}"
end

#window_closed?(handle) ⇒ Boolean

Returns:

  • (Boolean)


694
# File 'lib/capybara/simulated/driver.rb', line 694

def window_closed?(handle)         = window_browser(handle).nil?

#window_handlesObject



423
424
425
# File 'lib/capybara/simulated/driver.rb', line 423

def window_handles
  [PRIMARY_HANDLE] + @aux_windows.map {|w| w[:handle] }
end

#window_history_go(handle, delta) ⇒ Object

A cross-window w.history.back()/forward()/go(n): traverse the target window's history. The opener's VM is the one executing, so a non-active target can rebuild eagerly (like navigate_window); an active target (e.g. opener.history.back() from an aux) defers to avoid tearing down the running VM mid-call. Returns true when the traversal crossed a document boundary — the JS proxy then fires the target's deferred load (the same deferral as navAux) so the restored page's window.onload runs after the opener's current task. False for a same-document (pushState) traversal — popstate already fired — or a no-op.



681
682
683
684
685
686
687
688
689
690
691
692
693
# File 'lib/capybara/simulated/driver.rb', line 681

def window_history_go(handle, delta)
  b = window_browser(handle) or return false
  if b.equal?(current_browser)
    # `opener.history.back()` targeting the active window: defer (can't
    # rebuild the running VM mid-call) and return false — the active
    # window's load fires through its own navigation path when the pending
    # traversal drains, NOT via the aux-load deferral the caller would run.
    b.history_go(delta)
    false
  else
    b.history_go(delta, force: true) == :cross_document
  end
end

#window_location(handle) ⇒ Object

raw_: an identity read — this runs inside host-fn callbacks (a popup's boot script reading opener.location), where the ticking current_url must not re-enter.



650
651
652
# File 'lib/capybara/simulated/driver.rb', line 650

def window_location(handle)        = (window_browser(handle)&.raw_current_url).to_s
# A cross-window property read (`win.foo` / `win.document.foo`) — read the
# primitive off the target window's VM.

#window_post_message(source_browser, target_handle, data, target_origin, sender_origin) ⇒ Object

targetWindow.postMessage(data, targetOrigin) — queue on the target window's Browser, tagged with the source window's handle. The targetOrigin travels with the message and gates delivery in the target VM (where its current origin is known); the sender's origin becomes the delivered event.origin.



614
615
616
617
# File 'lib/capybara/simulated/driver.rb', line 614

def window_post_message(source_browser, target_handle, data, target_origin, sender_origin)
  target = window_browser(target_handle) or return
  target.enqueue_window_message(data, target_origin, sender_origin, handle_for(source_browser))
end

#window_read(handle, prop, doc: false) ⇒ Object

A cross-window property read (win.foo / win.document.foo) — read the primitive off the target window's VM.



653
654
655
656
# File 'lib/capybara/simulated/driver.rb', line 653

def window_read(handle, prop, doc: false)
  b = window_browser(handle) or return nil
  b.read_property(prop, doc: doc)
end

#window_ref_call(handle, id, method, args) ⇒ Object



662
# File 'lib/capybara/simulated/driver.rb', line 662

def window_ref_call(handle, id, method, args) = (b = window_browser(handle)) ? b.remote_ref_call(id, method, args) : nil

#window_ref_get(handle, id, prop) ⇒ Object



660
# File 'lib/capybara/simulated/driver.rb', line 660

def window_ref_get(handle, id, prop)         = (b = window_browser(handle)) ? b.remote_ref_get(id, prop) : nil

#window_ref_set(handle, id, prop, value) ⇒ Object



661
# File 'lib/capybara/simulated/driver.rb', line 661

def window_ref_set(handle, id, prop, value)  = ((b = window_browser(handle)) && b.remote_ref_set(id, prop, value))

#window_set_location(handle, url) ⇒ Object



663
664
665
666
667
668
669
670
671
# File 'lib/capybara/simulated/driver.rb', line 663

def window_set_location(handle, url)
  b = window_browser(handle) or return
  # Per HTML, `w.location = url` parses `url` relative to the ENTRY settings
  # object — the document of the script doing the assignment (the active
  # window) — NOT the target window's current document. So a cross-window
  # `w.location.href = 'resources/x.html'` resolves against the opener's
  # base, not the aux's (which would double a shared path segment).
  navigate_window(b, current_browser.resolve_document_url(url), source: current_browser)
end

#window_size(handle) ⇒ Object

Every window has its own viewport, so these address the window the handle names — not the active one. Capybara::Window#resize_to on a background window must resize THAT window and leave the current one (and Capybara's idea of which window is current) alone.



736
737
738
739
# File 'lib/capybara/simulated/driver.rb', line 736

def window_size(handle)
  b = window_browser!(handle)
  [b.viewport_width, b.viewport_height]
end

#with_playwright_page {|FakePlaywrightPage.new(current_browser)| ... } ⇒ Object

Playwright-driver compatibility shim. Discourse's system-spec before(:each) calls page.driver.with_playwright_page to install a JS-console logger, apply a CDP setTimezoneOverride, and (in dev_tools_spec) evaluate window.enableDevTools(). Yield a FakePlaywrightPage that delegates evaluate(js) to our JS engine and silently no-ops every other Playwright-only method via method_missing → self. Chained accessors like pw.context.new_cdp_session(pw).send_message("…") therefore propagate as a no-op rather than NoMethodError, while pw.evaluate("…") runs the JS where it matters.



184
185
186
# File 'lib/capybara/simulated/driver.rb', line 184

def with_playwright_page
  yield FakePlaywrightPage.new(current_browser) if block_given?
end

#worker_drive_pending?Boolean

Worker cross-thread work in flight in ANY window — the same aggregate scope as run_event_loop_frame's merged async (which folds every window in), so a caller distinguishing "waiting on a worker thread" from other async channels (the wpt_runner's clock-hold) sees a popup-hosted worker/SW round trip too, not just the active window's.

Returns:

  • (Boolean)


291
292
293
294
# File 'lib/capybara/simulated/driver.rb', line 291

def worker_drive_pending?
  return true if current_browser.worker_drive_pending?
  @aux_windows.any? {|w| !w[:browser].equal?(current_browser) && w[:browser].worker_drive_pending? }
end