capybara-simulated 0.8.0 → 0.10.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 +43 -18
- data/lib/capybara/simulated/asset_cache.rb +25 -12
- data/lib/capybara/simulated/browser.rb +4182 -338
- data/lib/capybara/simulated/driver.rb +205 -30
- data/lib/capybara/simulated/errors.rb +12 -0
- data/lib/capybara/simulated/js/bridge.bundle.js +14151 -3473
- data/lib/capybara/simulated/minitest.rb +22 -0
- data/lib/capybara/simulated/node.rb +18 -13
- data/lib/capybara/simulated/quickjs_runtime.rb +55 -10
- data/lib/capybara/simulated/runtime_shared.rb +71 -33
- data/lib/capybara/simulated/stack_resolver.rb +5 -0
- data/lib/capybara/simulated/trace.rb +38 -11
- data/lib/capybara/simulated/trace_persistence.rb +30 -4
- data/lib/capybara/simulated/trace_viewer.html +561 -207
- data/lib/capybara/simulated/v8_runtime.rb +265 -70
- data/lib/capybara/simulated/version.rb +1 -1
- data/lib/capybara/simulated/worker_runtime.rb +34 -11
- data/lib/capybara/simulated.rb +14 -0
- data/vendor/js/vendor.bundle.js +14 -14
- metadata +15 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'capybara/driver/base'
|
|
4
|
+
require 'capybara/server/animation_disabler'
|
|
4
5
|
require 'weakref'
|
|
5
6
|
require_relative 'browser'
|
|
6
7
|
require_relative 'node'
|
|
@@ -52,7 +53,10 @@ module Capybara
|
|
|
52
53
|
@@live.select!(&:weakref_alive?)
|
|
53
54
|
@@live.filter_map {|ref| ref.__getobj__ rescue nil }
|
|
54
55
|
}
|
|
55
|
-
|
|
56
|
+
# A DISPOSED driver is not live: its runtime context is gone, so calling into it raises
|
|
57
|
+
# (`undefined method 'call' for nil` out of `run_loop_step`). `dispose` deregisters, but the
|
|
58
|
+
# predicate is the belt — a WeakRef stays in the list until GC actually collects.
|
|
59
|
+
drivers.each {|d| yield d if d.owner_thread == thread && !d.disposed? }
|
|
56
60
|
end
|
|
57
61
|
|
|
58
62
|
# `viewport: [w, h]` and `user_agent:` (typically supplied via
|
|
@@ -64,13 +68,22 @@ module Capybara
|
|
|
64
68
|
# loads. The Browser tracks both as "defaults" so `reset!`
|
|
65
69
|
# (per-test teardown) restores them between specs.
|
|
66
70
|
def initialize(app, js_engine: nil, viewport: nil, user_agent: nil)
|
|
67
|
-
|
|
71
|
+
# `Capybara.disable_animation` is delivered to the real drivers by a SERVER
|
|
72
|
+
# middleware (session.rb adds AnimationDisabler to the Puma stack), which
|
|
73
|
+
# injects `animation-duration: 0s !important` CSS into every HTML response.
|
|
74
|
+
# This driver calls the Rack app in-process and never builds that server, so
|
|
75
|
+
# the same wrap happens here — an app suite that turns animations off
|
|
76
|
+
# (Discourse's rails_helper) must see the same 0s durations a real browser
|
|
77
|
+
# run sees, or every `await`-on-animation close path (FloatKit's menu) parks
|
|
78
|
+
# on a full-length animation-fallback timer no user action waits for.
|
|
79
|
+
@app = Capybara.disable_animation ? Capybara::Server::AnimationDisabler.new(app) : app
|
|
68
80
|
@js_engine = js_engine
|
|
69
81
|
# Cookies + localStorage are origin-shared across windows
|
|
70
82
|
# (real browser semantics), so we own the jars at the Driver
|
|
71
83
|
# level and inject them into every per-window Browser. Each
|
|
72
84
|
# Browser still has its own sessionStorage + DOM + JS VM.
|
|
73
85
|
@cookies = {}
|
|
86
|
+
@cookie_flags = {} # (host \0 name) => {secure: true} — attribute sidecar for the shared jar
|
|
74
87
|
@auth_cache = {}
|
|
75
88
|
@local_storage = {}
|
|
76
89
|
# Cache Storage (caches/Cache) is origin-shared like localStorage — owned at the
|
|
@@ -85,6 +98,13 @@ module Capybara
|
|
|
85
98
|
@browser = build_window_browser
|
|
86
99
|
@browser.window_handle = PRIMARY_HANDLE
|
|
87
100
|
@aux_windows = [] # [{handle:, browser:, name:, opener:}, …]
|
|
101
|
+
# Browsers whose WINDOW closed while they still host an active service-worker
|
|
102
|
+
# registration. A registration is profile-wide state independent of the document
|
|
103
|
+
# that created it (a page registers, closes, and the SW keeps controlling
|
|
104
|
+
# navigations elsewhere), so the Browser is parked — keeping its SW worker
|
|
105
|
+
# thread + scope registry alive for sw_navigation_fetch — instead of disposed.
|
|
106
|
+
# Reclaimed by reset_windows!.
|
|
107
|
+
@sw_parked = []
|
|
88
108
|
@active_handle = nil
|
|
89
109
|
@next_window_seq = 0
|
|
90
110
|
# Driver-level blob URL partition map: url => {browser:, site:}. A blob URL's
|
|
@@ -105,6 +125,7 @@ module Capybara
|
|
|
105
125
|
driver: self,
|
|
106
126
|
js_engine: @js_engine,
|
|
107
127
|
cookies: @cookies,
|
|
128
|
+
cookie_flags: @cookie_flags,
|
|
108
129
|
auth_cache: @auth_cache,
|
|
109
130
|
local_storage: @local_storage,
|
|
110
131
|
cache_storage: @cache_storage,
|
|
@@ -123,6 +144,15 @@ module Capybara
|
|
|
123
144
|
result
|
|
124
145
|
end
|
|
125
146
|
|
|
147
|
+
# Which JS engine is behind this driver (`:v8` / `:quickjs`), for a trace's metadata.
|
|
148
|
+
def js_engine = browser.js_engine
|
|
149
|
+
|
|
150
|
+
# The ACTIVE window's page, painted for the trace's final state (`TracePersistence`) —
|
|
151
|
+
# `current_browser`, like every other user-facing read here, not the primary `browser`: a
|
|
152
|
+
# test that ended inside `switch_to_window` would otherwise be handed a picture of the
|
|
153
|
+
# window it was not looking at.
|
|
154
|
+
def trace_screenshot = current_browser.trace_screenshot
|
|
155
|
+
|
|
126
156
|
def tracing? = !current_trace.nil?
|
|
127
157
|
def current_trace = browser.trace || browser.pending_trace
|
|
128
158
|
|
|
@@ -253,6 +283,16 @@ module Capybara
|
|
|
253
283
|
state
|
|
254
284
|
end
|
|
255
285
|
|
|
286
|
+
# Worker cross-thread work in flight in ANY window — the same aggregate scope
|
|
287
|
+
# as run_event_loop_frame's merged `async` (which folds every window in), so a
|
|
288
|
+
# caller distinguishing "waiting on a worker thread" from other async channels
|
|
289
|
+
# (the wpt_runner's clock-hold) sees a popup-hosted worker/SW round trip too,
|
|
290
|
+
# not just the active window's.
|
|
291
|
+
def worker_drive_pending?
|
|
292
|
+
return true if current_browser.worker_drive_pending?
|
|
293
|
+
@aux_windows.any? {|w| !w[:browser].equal?(current_browser) && w[:browser].worker_drive_pending? }
|
|
294
|
+
end
|
|
295
|
+
|
|
256
296
|
# Fold an aux window's frame state into the running aggregate: any window that
|
|
257
297
|
# progressed / has a queued rAF / has an async channel in flight keeps the
|
|
258
298
|
# whole loop live, and `next_timer` becomes the nearest pending timer across
|
|
@@ -285,6 +325,27 @@ module Capybara
|
|
|
285
325
|
browser.reset!
|
|
286
326
|
end
|
|
287
327
|
|
|
328
|
+
# Start the next fetches from a cold HTTP cache. `reset!` keeps what a persistent
|
|
329
|
+
# browser profile would — fresh `Cache-Control: immutable` responses, plus the
|
|
330
|
+
# still-fresh script / stylesheet sources and @font-face files — so a test whose
|
|
331
|
+
# app serves new bytes at a cacheable URL it already served (a stylesheet digested
|
|
332
|
+
# from a DB row that a rolled-back example reuses) asks for the cold cache a fresh
|
|
333
|
+
# Playwright / Cuprite context starts with. Process-wide: every session shares the
|
|
334
|
+
# one cache (`Capybara::Simulated.clear_http_cache` is the same call for a hook
|
|
335
|
+
# that runs before any session exists).
|
|
336
|
+
def clear_http_cache = Browser.clear_http_cache
|
|
337
|
+
|
|
338
|
+
# Join every window's background app-request threads (async <img> loads,
|
|
339
|
+
# keepalive fetches) without resetting anything else. The test harness calls
|
|
340
|
+
# this ahead of the app's own after-hooks: cleanup that bypasses
|
|
341
|
+
# ActiveRecord's per-connection lock (Discourse's mini_sql `DB.exec`) must
|
|
342
|
+
# not interleave with a still-running background request on the same raw
|
|
343
|
+
# socket — `reset!`'s drain alone runs after those hooks.
|
|
344
|
+
def drain_background_requests
|
|
345
|
+
@aux_windows.each {|w| w[:browser].drain_app_request_threads rescue nil }
|
|
346
|
+
browser.drain_app_request_threads
|
|
347
|
+
end
|
|
348
|
+
|
|
288
349
|
# Dispose every auxiliary window and return focus to the primary — a fresh
|
|
289
350
|
# browsing context has no sibling windows. Disposing each aux Browser tears
|
|
290
351
|
# down its worker / SSE / websocket threads and its V8 isolate eagerly; left
|
|
@@ -296,6 +357,8 @@ module Capybara
|
|
|
296
357
|
def reset_windows!
|
|
297
358
|
@aux_windows.each {|w| w[:browser].dispose rescue nil }
|
|
298
359
|
@aux_windows.clear
|
|
360
|
+
@sw_parked.each {|b| b.dispose rescue nil }
|
|
361
|
+
@sw_parked.clear
|
|
299
362
|
@active_handle = nil
|
|
300
363
|
@blob_partitions_lock.synchronize { @blob_partitions.clear }
|
|
301
364
|
end
|
|
@@ -310,9 +373,18 @@ module Capybara
|
|
|
310
373
|
# isolate per cross-origin file (hundreds over the suite); disposing here
|
|
311
374
|
# incrementally is what reset_windows! already does for aux windows.
|
|
312
375
|
def dispose
|
|
376
|
+
return if @disposed
|
|
377
|
+
@disposed = true
|
|
378
|
+
# Drop out of the live registry FIRST: everything below tears down the runtime this driver
|
|
379
|
+
# would be asked to step if `each_live_on_thread` still yielded it.
|
|
380
|
+
@@live_lock.synchronize { @@live.reject! {|ref| (ref.__getobj__ rescue nil).equal?(self) } }
|
|
313
381
|
reset_windows!
|
|
314
382
|
@browser.dispose rescue nil
|
|
315
383
|
end
|
|
384
|
+
|
|
385
|
+
# Has this driver been permanently dropped? (A `reset!` between examples does NOT set this —
|
|
386
|
+
# that rebuilds the page on a live runtime.)
|
|
387
|
+
def disposed? = @disposed == true
|
|
316
388
|
def go_back = current_browser.go_back
|
|
317
389
|
def go_forward = current_browser.go_forward
|
|
318
390
|
def reset_history! = current_browser.reset_history!
|
|
@@ -362,12 +434,23 @@ module Capybara
|
|
|
362
434
|
window_entries.find {|w| w[:handle] == handle }&.fetch(:browser)
|
|
363
435
|
end
|
|
364
436
|
|
|
437
|
+
# Same, but for the operations that ADDRESS a window rather than probe for one: a closed or
|
|
438
|
+
# unknown handle is Capybara's `WindowError`, never a silent fall back to the current window.
|
|
439
|
+
def window_browser!(handle)
|
|
440
|
+
window_browser(handle) or raise Capybara::WindowError, "Unknown window handle: #{handle}"
|
|
441
|
+
end
|
|
442
|
+
|
|
365
443
|
# Open (or, by `name`, reuse) an auxiliary window. `target="_blank"`
|
|
366
444
|
# clicks and `window.open` both land here. A non-empty `name` that
|
|
367
445
|
# matches an existing window navigates that window instead of opening a
|
|
368
446
|
# new one (HTML window-name targeting); `opener_handle` records the
|
|
369
447
|
# opener so the new window's `window.opener` resolves back to it.
|
|
370
|
-
|
|
448
|
+
# `defer_load`: the caller will fire the new window's `load` itself, one task
|
|
449
|
+
# later, so that a handler either side registers right after `window.open()` is
|
|
450
|
+
# in place first (platform-globals' `fireAuxLoadSoon`). ONLY the JS `window.open`
|
|
451
|
+
# path does that — a `target=_blank` click or `open_new_window` hands nobody a
|
|
452
|
+
# reference to hook, so their window announces its own load like any other.
|
|
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)
|
|
371
454
|
name = name.to_s
|
|
372
455
|
# A blob: URL opened from a different storage partition is forced noopener
|
|
373
456
|
# (cross-partition-navigation), overriding an explicit rel=opener — the new
|
|
@@ -379,7 +462,7 @@ module Capybara
|
|
|
379
462
|
opener_handle ||= handle_for(source) if opener && source
|
|
380
463
|
if !name.empty? && (existing = @aux_windows.find {|w| w[:name] == name })
|
|
381
464
|
if post
|
|
382
|
-
existing[:browser].navigate_post(url, post[:body], post[:content_type], referer: referrer)
|
|
465
|
+
existing[:browser].navigate_post(url, post[:body], post[:content_type], referer: referrer, initiator: source&.raw_current_url)
|
|
383
466
|
else
|
|
384
467
|
navigate_window(existing[:browser], url, source: source)
|
|
385
468
|
end
|
|
@@ -388,6 +471,7 @@ module Capybara
|
|
|
388
471
|
@next_window_seq += 1
|
|
389
472
|
handle = "csim-window-#{@next_window_seq}"
|
|
390
473
|
aux = build_window_browser
|
|
474
|
+
aux.defer_window_load = defer_load
|
|
391
475
|
aux.window_handle = handle
|
|
392
476
|
# Register BEFORE visiting: the opened document's own boot scripts read
|
|
393
477
|
# `window.opener`, which resolves through this entry — so the entry
|
|
@@ -397,7 +481,7 @@ module Capybara
|
|
|
397
481
|
if post
|
|
398
482
|
# A `<form target=_blank method=post>` loads the new window via POST,
|
|
399
483
|
# carrying the opener's URL as referrer (unless rel=noreferrer → '').
|
|
400
|
-
aux.navigate_post(url, post[:body], post[:content_type], referer: referrer)
|
|
484
|
+
aux.navigate_post(url, post[:body], post[:content_type], referer: referrer, initiator: source&.raw_current_url)
|
|
401
485
|
# A blob: URL isn't rack-navigable and its bytes live in the OPENER's
|
|
402
486
|
# isolate — load the document directly from a click-time snapshot (a
|
|
403
487
|
# deferred target=_blank nav may revoke the URL first) or, failing that,
|
|
@@ -405,7 +489,12 @@ module Capybara
|
|
|
405
489
|
elsif !(url.to_s.start_with?('blob:') && load_blob_into_window(aux, url, source, snapshot: blob_snapshot))
|
|
406
490
|
# A form submission carries a referrer (the opener's URL) unless the
|
|
407
491
|
# form opted out via rel=noreferrer (referrer: '').
|
|
408
|
-
|
|
492
|
+
# The OPENER's document is the navigation initiator — it seeds the popup
|
|
493
|
+
# load's Sec-Fetch-Site (the SameSite cookie gate reads it).
|
|
494
|
+
# The OPENER's document is the navigation initiator — it seeds the popup
|
|
495
|
+
# load's Sec-Fetch-Site (the SameSite cookie gate reads it). raw_current_url:
|
|
496
|
+
# the ticking `current_url` must not run re-entrantly inside window.open.
|
|
497
|
+
aux.visit(url, referer: referrer, initiator: source&.raw_current_url)
|
|
409
498
|
end
|
|
410
499
|
end
|
|
411
500
|
handle
|
|
@@ -425,7 +514,7 @@ module Capybara
|
|
|
425
514
|
|
|
426
515
|
# `window.open(url, name)` from the `opener` window's JS. Resolves the URL
|
|
427
516
|
# against the opener's document and records the opener relationship.
|
|
428
|
-
def open_window_from_js(opener_browser, url, name, opener_realm_id = 0)
|
|
517
|
+
def open_window_from_js(opener_browser, url, name, opener_realm_id = 0, about_base = nil, about_origin = nil)
|
|
429
518
|
resolved = url.to_s.empty? ? nil : opener_browser.resolve_document_url(url)
|
|
430
519
|
# Opening a blob: URL whose storage partition differs from the opener's
|
|
431
520
|
# top-level site is forced NOOPENER (cross-partition-navigation): the new
|
|
@@ -441,10 +530,10 @@ module Capybara
|
|
|
441
530
|
# cross-window scripting/adoption need no cross-isolate RPC. The opener's realm
|
|
442
531
|
# id wires the popup's window.opener. Falls through to the separate-VM aux path
|
|
443
532
|
# (cross-origin, or a URL we don't yet realm-load).
|
|
444
|
-
if (rid = opener_browser.open_window_realm(resolved, name: name, opener_realm_id: opener_realm_id))
|
|
533
|
+
if (rid = opener_browser.open_window_realm(resolved, name: name, opener_realm_id: opener_realm_id, about_base: about_base, about_origin: about_origin))
|
|
445
534
|
return rid
|
|
446
535
|
end
|
|
447
|
-
open_aux_window(resolved, name: name, opener_handle: handle_for(opener_browser), source: opener_browser)
|
|
536
|
+
open_aux_window(resolved, name: name, opener_handle: handle_for(opener_browser), source: opener_browser, defer_load: true)
|
|
448
537
|
end
|
|
449
538
|
|
|
450
539
|
# The storage-partition site a blob: URL was created in (its creator's top-level
|
|
@@ -518,11 +607,13 @@ module Capybara
|
|
|
518
607
|
false
|
|
519
608
|
end
|
|
520
609
|
|
|
521
|
-
# `targetWindow.postMessage(data,
|
|
522
|
-
# Browser, tagged with the source window's handle.
|
|
523
|
-
|
|
610
|
+
# `targetWindow.postMessage(data, targetOrigin)` — queue on the target window's
|
|
611
|
+
# Browser, tagged with the source window's handle. The targetOrigin travels with
|
|
612
|
+
# the message and gates delivery in the target VM (where its current origin is
|
|
613
|
+
# known); the sender's origin becomes the delivered event.origin.
|
|
614
|
+
def window_post_message(source_browser, target_handle, data, target_origin, sender_origin)
|
|
524
615
|
target = window_browser(target_handle) or return
|
|
525
|
-
target.enqueue_window_message(data,
|
|
616
|
+
target.enqueue_window_message(data, target_origin, sender_origin, handle_for(source_browser))
|
|
526
617
|
end
|
|
527
618
|
|
|
528
619
|
# `BroadcastChannel.postMessage` — deliver to every OTHER window's channels
|
|
@@ -554,7 +645,9 @@ module Capybara
|
|
|
554
645
|
end
|
|
555
646
|
end
|
|
556
647
|
|
|
557
|
-
|
|
648
|
+
# raw_: an identity read — this runs inside host-fn callbacks (a popup's boot
|
|
649
|
+
# script reading opener.location), where the ticking current_url must not re-enter.
|
|
650
|
+
def window_location(handle) = (window_browser(handle)&.raw_current_url).to_s
|
|
558
651
|
# A cross-window property read (`win.foo` / `win.document.foo`) — read the
|
|
559
652
|
# primitive off the target window's VM.
|
|
560
653
|
def window_read(handle, prop, doc: false)
|
|
@@ -623,7 +716,9 @@ module Capybara
|
|
|
623
716
|
if browser.equal?(current_browser)
|
|
624
717
|
browser.location_assign(url)
|
|
625
718
|
else
|
|
626
|
-
|
|
719
|
+
# A cross-window navigation's initiator is the SETTING window's document
|
|
720
|
+
# (seeds Sec-Fetch-Site → the SameSite cookie gate); raw_ — no re-entrant tick.
|
|
721
|
+
browser.visit(url, initiator: source&.raw_current_url)
|
|
627
722
|
end
|
|
628
723
|
end
|
|
629
724
|
|
|
@@ -634,18 +729,87 @@ module Capybara
|
|
|
634
729
|
def open_new_window(_kind = :tab)
|
|
635
730
|
open_aux_window('about:blank')
|
|
636
731
|
end
|
|
637
|
-
|
|
732
|
+
# Every window has its own viewport, so these address the window the
|
|
733
|
+
# handle names — not the active one. `Capybara::Window#resize_to` on a
|
|
734
|
+
# background window must resize THAT window and leave the current one
|
|
735
|
+
# (and Capybara's idea of which window is current) alone.
|
|
736
|
+
def window_size(handle)
|
|
737
|
+
b = window_browser!(handle)
|
|
738
|
+
[b.viewport_width, b.viewport_height]
|
|
739
|
+
end
|
|
638
740
|
def close_window(h)
|
|
639
741
|
return if h == PRIMARY_HANDLE
|
|
640
742
|
@aux_windows.reject! {|w|
|
|
641
743
|
next false unless w[:handle] == h
|
|
642
|
-
|
|
643
|
-
|
|
744
|
+
# HTML "close a browsing context": the teardown events (pagehide+unload,
|
|
745
|
+
# this window and every nested frame, parent-first) fire while the VM
|
|
746
|
+
# still works — a nested iframe's unload keepalive beacon depends on it.
|
|
747
|
+
w[:browser].fire_document_teardown if w[:browser].respond_to?(:fire_document_teardown)
|
|
748
|
+
if w[:browser].sw_registrations_active?
|
|
749
|
+
# Still hosting a live service-worker registration — park (see @sw_parked):
|
|
750
|
+
# tear down the document-scoped machinery, retire older parked browsers
|
|
751
|
+
# whose scopes this one re-registered (the newest registration for a scope
|
|
752
|
+
# is THE registration, as in a real profile), and keep the browser alive.
|
|
753
|
+
w[:browser].park_for_service_workers!
|
|
754
|
+
retire_shadowed_parked(w[:browser])
|
|
755
|
+
@sw_parked << w[:browser]
|
|
756
|
+
else
|
|
757
|
+
drop_blob_partitions_for(w[:browser]) # don't leave entries pointing at a disposed VM
|
|
758
|
+
w[:browser].dispose rescue nil
|
|
759
|
+
end
|
|
644
760
|
true
|
|
645
761
|
}
|
|
646
762
|
@active_handle = nil if @active_handle == h
|
|
647
763
|
end
|
|
648
764
|
|
|
765
|
+
# Dispose parked browsers whose live scopes are ALL re-registered by `newcomer` —
|
|
766
|
+
# their registrations are replaced, so keeping the old worker would let a stale
|
|
767
|
+
# SW shadow the new one in sw_navigation_fetch. A partially-overlapping parked
|
|
768
|
+
# browser stays (its non-shadowed scopes are still the live registrations; the
|
|
769
|
+
# newest-first search below picks the newcomer for the shared ones).
|
|
770
|
+
private def retire_shadowed_parked(newcomer)
|
|
771
|
+
fresh = newcomer.sw_live_scopes
|
|
772
|
+
@sw_parked.reject! {|b|
|
|
773
|
+
scopes = b.sw_live_scopes
|
|
774
|
+
next false if scopes.empty? || !(scopes - fresh).empty?
|
|
775
|
+
drop_blob_partitions_for(b)
|
|
776
|
+
b.dispose rescue nil
|
|
777
|
+
true
|
|
778
|
+
}
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
# Dispose parked browsers whose service workers have all died since parking
|
|
782
|
+
# (the SW unregistered itself / closed) — nothing routes to them anymore, and
|
|
783
|
+
# each pins a whole V8 isolate until reset otherwise.
|
|
784
|
+
private def sweep_dead_parked
|
|
785
|
+
@sw_parked.reject! {|b|
|
|
786
|
+
next false if b.sw_registrations_active?
|
|
787
|
+
drop_blob_partitions_for(b)
|
|
788
|
+
b.dispose rescue nil
|
|
789
|
+
true
|
|
790
|
+
}
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
# The fetch-event round-trip for a controlled NAVIGATION, resolved across the whole
|
|
794
|
+
# window set: a service-worker registration is profile-wide, so the Browser hosting
|
|
795
|
+
# the controlling SW may be any window's — or a parked one whose window already
|
|
796
|
+
# closed (searched newest-parked first: a re-registered scope's newest worker is
|
|
797
|
+
# the active registration). Returns the owner's respondWith wire hash, or nil
|
|
798
|
+
# (uncontrolled → load from the network).
|
|
799
|
+
def sw_navigation_fetch(url, **kw)
|
|
800
|
+
sweep_dead_parked
|
|
801
|
+
owner = ([@browser] + @aux_windows.map {|w| w[:browser] } + @sw_parked.reverse).find {|b|
|
|
802
|
+
b.sw_controls_navigation?(url)
|
|
803
|
+
}
|
|
804
|
+
return nil unless owner
|
|
805
|
+
|
|
806
|
+
resp = owner.service_worker_navigation_fetch(url, **kw)
|
|
807
|
+
# Nobody pumps a parked browser — drop what the handler queued for its dead
|
|
808
|
+
# clients so the outbox can't grow across a long test.
|
|
809
|
+
owner.drop_dead_letter_worker_messages if @sw_parked.include?(owner)
|
|
810
|
+
resp
|
|
811
|
+
end
|
|
812
|
+
|
|
649
813
|
# Drop every blob-partition entry created by `browser` — its isolate is being
|
|
650
814
|
# disposed, so the bytes are gone and the reference must not linger (a stale
|
|
651
815
|
# entry would pin the dead Browser and route blob_bytes_for to a dead VM).
|
|
@@ -655,19 +819,23 @@ module Capybara
|
|
|
655
819
|
end
|
|
656
820
|
end
|
|
657
821
|
def switch_to_window(h)
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
elsif @aux_windows.any? {|w| w[:handle] == h }
|
|
661
|
-
@active_handle = h
|
|
662
|
-
else
|
|
663
|
-
raise Capybara::WindowError, "Unknown window handle: #{h}"
|
|
664
|
-
end
|
|
822
|
+
window_browser!(h) # unknown / already-closed handle → WindowError
|
|
823
|
+
@active_handle = (h == PRIMARY_HANDLE ? nil : h)
|
|
665
824
|
end
|
|
666
|
-
def resize_window_to(
|
|
825
|
+
def resize_window_to(handle, w, h) = window_browser!(handle).set_viewport(w, h)
|
|
667
826
|
# Forem's ahoy-tracking spec calls `driver.resize(w, h)` directly
|
|
668
827
|
# rather than through `current_window.resize_to`.
|
|
669
828
|
def resize(w, h) = current_browser.set_viewport(w, h)
|
|
670
|
-
|
|
829
|
+
# Both restore the window to the display it lives on (`Browser#screen_size`), which is where
|
|
830
|
+
# it started — so they undo a `resize_to` rather than doing nothing. Coarse: we model no
|
|
831
|
+
# window chrome, so a maximized window and a fullscreen one end up the same size (a real
|
|
832
|
+
# browser's fullscreen is taller by the chrome it hides).
|
|
833
|
+
def maximize_window(handle) = restore_window_size(handle)
|
|
834
|
+
def fullscreen_window(handle) = restore_window_size(handle)
|
|
835
|
+
private def restore_window_size(handle)
|
|
836
|
+
b = window_browser!(handle)
|
|
837
|
+
b.set_viewport(*b.screen_size)
|
|
838
|
+
end
|
|
671
839
|
|
|
672
840
|
def evaluate_script(script, *args)
|
|
673
841
|
unwrap(current_browser.evaluate_script(script, args))
|
|
@@ -699,11 +867,18 @@ module Capybara
|
|
|
699
867
|
end
|
|
700
868
|
end
|
|
701
869
|
|
|
702
|
-
def invalid_element_errors = [Capybara::Simulated::StaleElement]
|
|
870
|
+
def invalid_element_errors = [Capybara::Simulated::StaleElement, Capybara::Simulated::ClickIntercepted]
|
|
703
871
|
def no_such_window_error = Capybara::WindowError
|
|
704
872
|
|
|
705
|
-
|
|
706
|
-
|
|
873
|
+
# A real raster of the laid-out page (see js/src/paint.js), not a serialization of it: the
|
|
874
|
+
# painter reads the same boxes every geometry query reads, so a screenshot can only show
|
|
875
|
+
# what the driver already believes. `full: true` paints the whole document rather than the
|
|
876
|
+
# viewport.
|
|
877
|
+
def save_screenshot(path, full: false, **_opts)
|
|
878
|
+
data = current_browser.screenshot_png(full: full)
|
|
879
|
+
raise Capybara::Simulated::ScreenshotFailed, 'screenshot: the page painted nothing' if data.nil?
|
|
880
|
+
|
|
881
|
+
File.binwrite(path, data)
|
|
707
882
|
path
|
|
708
883
|
end
|
|
709
884
|
|
|
@@ -10,11 +10,23 @@ module Capybara
|
|
|
10
10
|
# cached element.
|
|
11
11
|
class StaleElement < Capybara::ElementNotFound; end
|
|
12
12
|
|
|
13
|
+
# Raised when the click point's hit-test lands on an unrelated element
|
|
14
|
+
# painted over the target (WebDriver "element click intercepted") — a
|
|
15
|
+
# modal backdrop mid-exit, a full-page overlay. Listed as an
|
|
16
|
+
# `invalid_element_error`, so Capybara's `synchronize` retries the
|
|
17
|
+
# find+click until the obstruction is gone, exactly as it does for a
|
|
18
|
+
# real driver's ElementClickInterceptedError.
|
|
19
|
+
class ClickIntercepted < Capybara::ElementNotFound; end
|
|
20
|
+
|
|
13
21
|
# Raised by `switch_to_frame` when the active JS engine can't give the
|
|
14
22
|
# target `<iframe>` its own browsing context (a real per-frame realm).
|
|
15
23
|
# Only the V8 engine (rusty_racer) builds per-frame realms; under
|
|
16
24
|
# QuickJS the frame stays a same-realm fallback we can't route DOM ops
|
|
17
25
|
# into, so `within_frame` is unsupported there.
|
|
18
26
|
class FrameNotSupported < Capybara::NotSupportedByDriverError; end
|
|
27
|
+
|
|
28
|
+
# Raised by `save_screenshot` when the page could not be rastered — most often because
|
|
29
|
+
# `ruby-vips` (the rasteriser behind the whole canvas stack) isn't in the bundle.
|
|
30
|
+
class ScreenshotFailed < Capybara::CapybaraError; end
|
|
19
31
|
end
|
|
20
32
|
end
|