capybara-simulated 0.9.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 +38 -14
- data/lib/capybara/simulated/asset_cache.rb +25 -12
- data/lib/capybara/simulated/browser.rb +3665 -325
- data/lib/capybara/simulated/driver.rb +163 -17
- data/lib/capybara/simulated/errors.rb +12 -0
- data/lib/capybara/simulated/js/bridge.bundle.js +10665 -2705
- data/lib/capybara/simulated/minitest.rb +22 -0
- data/lib/capybara/simulated/node.rb +10 -13
- data/lib/capybara/simulated/quickjs_runtime.rb +33 -2
- data/lib/capybara/simulated/runtime_shared.rb +46 -9
- 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 +199 -53
- 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'
|
|
@@ -67,13 +68,22 @@ module Capybara
|
|
|
67
68
|
# loads. The Browser tracks both as "defaults" so `reset!`
|
|
68
69
|
# (per-test teardown) restores them between specs.
|
|
69
70
|
def initialize(app, js_engine: nil, viewport: nil, user_agent: nil)
|
|
70
|
-
|
|
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
|
|
71
80
|
@js_engine = js_engine
|
|
72
81
|
# Cookies + localStorage are origin-shared across windows
|
|
73
82
|
# (real browser semantics), so we own the jars at the Driver
|
|
74
83
|
# level and inject them into every per-window Browser. Each
|
|
75
84
|
# Browser still has its own sessionStorage + DOM + JS VM.
|
|
76
85
|
@cookies = {}
|
|
86
|
+
@cookie_flags = {} # (host \0 name) => {secure: true} — attribute sidecar for the shared jar
|
|
77
87
|
@auth_cache = {}
|
|
78
88
|
@local_storage = {}
|
|
79
89
|
# Cache Storage (caches/Cache) is origin-shared like localStorage — owned at the
|
|
@@ -88,6 +98,13 @@ module Capybara
|
|
|
88
98
|
@browser = build_window_browser
|
|
89
99
|
@browser.window_handle = PRIMARY_HANDLE
|
|
90
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 = []
|
|
91
108
|
@active_handle = nil
|
|
92
109
|
@next_window_seq = 0
|
|
93
110
|
# Driver-level blob URL partition map: url => {browser:, site:}. A blob URL's
|
|
@@ -108,6 +125,7 @@ module Capybara
|
|
|
108
125
|
driver: self,
|
|
109
126
|
js_engine: @js_engine,
|
|
110
127
|
cookies: @cookies,
|
|
128
|
+
cookie_flags: @cookie_flags,
|
|
111
129
|
auth_cache: @auth_cache,
|
|
112
130
|
local_storage: @local_storage,
|
|
113
131
|
cache_storage: @cache_storage,
|
|
@@ -126,6 +144,15 @@ module Capybara
|
|
|
126
144
|
result
|
|
127
145
|
end
|
|
128
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
|
+
|
|
129
156
|
def tracing? = !current_trace.nil?
|
|
130
157
|
def current_trace = browser.trace || browser.pending_trace
|
|
131
158
|
|
|
@@ -256,6 +283,16 @@ module Capybara
|
|
|
256
283
|
state
|
|
257
284
|
end
|
|
258
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
|
+
|
|
259
296
|
# Fold an aux window's frame state into the running aggregate: any window that
|
|
260
297
|
# progressed / has a queued rAF / has an async channel in flight keeps the
|
|
261
298
|
# whole loop live, and `next_timer` becomes the nearest pending timer across
|
|
@@ -288,6 +325,27 @@ module Capybara
|
|
|
288
325
|
browser.reset!
|
|
289
326
|
end
|
|
290
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
|
+
|
|
291
349
|
# Dispose every auxiliary window and return focus to the primary — a fresh
|
|
292
350
|
# browsing context has no sibling windows. Disposing each aux Browser tears
|
|
293
351
|
# down its worker / SSE / websocket threads and its V8 isolate eagerly; left
|
|
@@ -299,6 +357,8 @@ module Capybara
|
|
|
299
357
|
def reset_windows!
|
|
300
358
|
@aux_windows.each {|w| w[:browser].dispose rescue nil }
|
|
301
359
|
@aux_windows.clear
|
|
360
|
+
@sw_parked.each {|b| b.dispose rescue nil }
|
|
361
|
+
@sw_parked.clear
|
|
302
362
|
@active_handle = nil
|
|
303
363
|
@blob_partitions_lock.synchronize { @blob_partitions.clear }
|
|
304
364
|
end
|
|
@@ -385,7 +445,12 @@ module Capybara
|
|
|
385
445
|
# matches an existing window navigates that window instead of opening a
|
|
386
446
|
# new one (HTML window-name targeting); `opener_handle` records the
|
|
387
447
|
# opener so the new window's `window.opener` resolves back to it.
|
|
388
|
-
|
|
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)
|
|
389
454
|
name = name.to_s
|
|
390
455
|
# A blob: URL opened from a different storage partition is forced noopener
|
|
391
456
|
# (cross-partition-navigation), overriding an explicit rel=opener — the new
|
|
@@ -397,7 +462,7 @@ module Capybara
|
|
|
397
462
|
opener_handle ||= handle_for(source) if opener && source
|
|
398
463
|
if !name.empty? && (existing = @aux_windows.find {|w| w[:name] == name })
|
|
399
464
|
if post
|
|
400
|
-
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)
|
|
401
466
|
else
|
|
402
467
|
navigate_window(existing[:browser], url, source: source)
|
|
403
468
|
end
|
|
@@ -406,6 +471,7 @@ module Capybara
|
|
|
406
471
|
@next_window_seq += 1
|
|
407
472
|
handle = "csim-window-#{@next_window_seq}"
|
|
408
473
|
aux = build_window_browser
|
|
474
|
+
aux.defer_window_load = defer_load
|
|
409
475
|
aux.window_handle = handle
|
|
410
476
|
# Register BEFORE visiting: the opened document's own boot scripts read
|
|
411
477
|
# `window.opener`, which resolves through this entry — so the entry
|
|
@@ -415,7 +481,7 @@ module Capybara
|
|
|
415
481
|
if post
|
|
416
482
|
# A `<form target=_blank method=post>` loads the new window via POST,
|
|
417
483
|
# carrying the opener's URL as referrer (unless rel=noreferrer → '').
|
|
418
|
-
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)
|
|
419
485
|
# A blob: URL isn't rack-navigable and its bytes live in the OPENER's
|
|
420
486
|
# isolate — load the document directly from a click-time snapshot (a
|
|
421
487
|
# deferred target=_blank nav may revoke the URL first) or, failing that,
|
|
@@ -423,7 +489,12 @@ module Capybara
|
|
|
423
489
|
elsif !(url.to_s.start_with?('blob:') && load_blob_into_window(aux, url, source, snapshot: blob_snapshot))
|
|
424
490
|
# A form submission carries a referrer (the opener's URL) unless the
|
|
425
491
|
# form opted out via rel=noreferrer (referrer: '').
|
|
426
|
-
|
|
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)
|
|
427
498
|
end
|
|
428
499
|
end
|
|
429
500
|
handle
|
|
@@ -462,7 +533,7 @@ module Capybara
|
|
|
462
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))
|
|
463
534
|
return rid
|
|
464
535
|
end
|
|
465
|
-
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)
|
|
466
537
|
end
|
|
467
538
|
|
|
468
539
|
# The storage-partition site a blob: URL was created in (its creator's top-level
|
|
@@ -536,11 +607,13 @@ module Capybara
|
|
|
536
607
|
false
|
|
537
608
|
end
|
|
538
609
|
|
|
539
|
-
# `targetWindow.postMessage(data,
|
|
540
|
-
# Browser, tagged with the source window's handle.
|
|
541
|
-
|
|
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)
|
|
542
615
|
target = window_browser(target_handle) or return
|
|
543
|
-
target.enqueue_window_message(data,
|
|
616
|
+
target.enqueue_window_message(data, target_origin, sender_origin, handle_for(source_browser))
|
|
544
617
|
end
|
|
545
618
|
|
|
546
619
|
# `BroadcastChannel.postMessage` — deliver to every OTHER window's channels
|
|
@@ -572,7 +645,9 @@ module Capybara
|
|
|
572
645
|
end
|
|
573
646
|
end
|
|
574
647
|
|
|
575
|
-
|
|
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
|
|
576
651
|
# A cross-window property read (`win.foo` / `win.document.foo`) — read the
|
|
577
652
|
# primitive off the target window's VM.
|
|
578
653
|
def window_read(handle, prop, doc: false)
|
|
@@ -641,7 +716,9 @@ module Capybara
|
|
|
641
716
|
if browser.equal?(current_browser)
|
|
642
717
|
browser.location_assign(url)
|
|
643
718
|
else
|
|
644
|
-
|
|
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)
|
|
645
722
|
end
|
|
646
723
|
end
|
|
647
724
|
|
|
@@ -664,13 +741,75 @@ module Capybara
|
|
|
664
741
|
return if h == PRIMARY_HANDLE
|
|
665
742
|
@aux_windows.reject! {|w|
|
|
666
743
|
next false unless w[:handle] == h
|
|
667
|
-
|
|
668
|
-
|
|
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
|
|
669
760
|
true
|
|
670
761
|
}
|
|
671
762
|
@active_handle = nil if @active_handle == h
|
|
672
763
|
end
|
|
673
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
|
+
|
|
674
813
|
# Drop every blob-partition entry created by `browser` — its isolate is being
|
|
675
814
|
# disposed, so the bytes are gone and the reference must not linger (a stale
|
|
676
815
|
# entry would pin the dead Browser and route blob_bytes_for to a dead VM).
|
|
@@ -728,11 +867,18 @@ module Capybara
|
|
|
728
867
|
end
|
|
729
868
|
end
|
|
730
869
|
|
|
731
|
-
def invalid_element_errors = [Capybara::Simulated::StaleElement]
|
|
870
|
+
def invalid_element_errors = [Capybara::Simulated::StaleElement, Capybara::Simulated::ClickIntercepted]
|
|
732
871
|
def no_such_window_error = Capybara::WindowError
|
|
733
872
|
|
|
734
|
-
|
|
735
|
-
|
|
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)
|
|
736
882
|
path
|
|
737
883
|
end
|
|
738
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
|