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.
@@ -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
- drivers.each {|d| yield d if d.owner_thread == thread }
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
- @app = app
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
- def open_aux_window(url = nil, name: nil, opener_handle: nil, source: nil, blob_snapshot: nil, post: nil, opener: false, referrer: nil)
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
- aux.visit(url, referer: referrer)
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, origin)` — queue on the target window's
522
- # Browser, tagged with the source window's handle.
523
- def window_post_message(source_browser, target_handle, data, _origin)
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, _origin, handle_for(source_browser))
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
- def window_location(handle) = (window_browser(handle)&.current_url).to_s
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
- browser.visit(url)
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
- def window_size(_) = [current_browser.viewport_width, current_browser.viewport_height]
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
- drop_blob_partitions_for(w[:browser]) # don't leave entries pointing at a disposed VM
643
- w[:browser].dispose rescue nil
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
- if h == PRIMARY_HANDLE
659
- @active_handle = nil
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(_, w, h) = current_browser.set_viewport(w, h)
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
- def maximize_window(_) ; nil ; end
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
- def save_screenshot(path, **_opts)
706
- File.write(path, current_browser.html.to_s)
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