capybara-lightpanda 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/CHANGELOG.md +37 -0
- data/README.md +14 -1
- data/lib/capybara/lightpanda/auto_scripts.rb +3 -1
- data/lib/capybara/lightpanda/binary.rb +70 -8
- data/lib/capybara/lightpanda/browser/console.rb +89 -7
- data/lib/capybara/lightpanda/browser/modals.rb +54 -3
- data/lib/capybara/lightpanda/browser/navigation.rb +1 -0
- data/lib/capybara/lightpanda/browser/selenium_compat.rb +124 -0
- data/lib/capybara/lightpanda/browser.rb +104 -4
- data/lib/capybara/lightpanda/downloads.rb +176 -0
- data/lib/capybara/lightpanda/driver.rb +109 -2
- data/lib/capybara/lightpanda/errors.rb +5 -0
- data/lib/capybara/lightpanda/javascripts/errors.js +74 -0
- data/lib/capybara/lightpanda/javascripts/predicates.js +19 -55
- data/lib/capybara/lightpanda/network.rb +36 -10
- data/lib/capybara/lightpanda/node.rb +177 -65
- data/lib/capybara/lightpanda/options.rb +44 -7
- data/lib/capybara/lightpanda/process.rb +151 -11
- data/lib/capybara/lightpanda/version.rb +1 -1
- data/lib/capybara-lightpanda.rb +1 -0
- metadata +5 -2
|
@@ -7,6 +7,7 @@ require_relative "browser/finder"
|
|
|
7
7
|
require_relative "browser/navigation"
|
|
8
8
|
require_relative "browser/modals"
|
|
9
9
|
require_relative "browser/console"
|
|
10
|
+
require_relative "browser/selenium_compat"
|
|
10
11
|
|
|
11
12
|
module Capybara
|
|
12
13
|
module Lightpanda
|
|
@@ -18,6 +19,7 @@ module Capybara
|
|
|
18
19
|
include Navigation
|
|
19
20
|
include Modals
|
|
20
21
|
include Console
|
|
22
|
+
include SeleniumCompat
|
|
21
23
|
|
|
22
24
|
attr_reader :options, :process, :client, :target_id, :session_id, :browser_context_id, :frame_stack
|
|
23
25
|
|
|
@@ -78,10 +80,17 @@ module Capybara
|
|
|
78
80
|
@process&.version
|
|
79
81
|
end
|
|
80
82
|
|
|
83
|
+
# Set on the nightly/dev channel only; nil for a tagged release, which
|
|
84
|
+
# carries no build counter. `release` is its mirror image — exactly one of
|
|
85
|
+
# the two is non-nil once the process has started.
|
|
81
86
|
def nightly_build
|
|
82
87
|
@process&.nightly_build
|
|
83
88
|
end
|
|
84
89
|
|
|
90
|
+
def release
|
|
91
|
+
@process&.release
|
|
92
|
+
end
|
|
93
|
+
|
|
85
94
|
def initialize(options = {})
|
|
86
95
|
@options = Options.new(options)
|
|
87
96
|
@process = nil
|
|
@@ -94,8 +103,12 @@ module Capybara
|
|
|
94
103
|
@modal_messages = []
|
|
95
104
|
@modal_messages_mutex = Mutex.new
|
|
96
105
|
@modal_handler_installed = false
|
|
106
|
+
@modal_armed = false
|
|
107
|
+
@unhandled_modal = nil
|
|
97
108
|
@console_logs = []
|
|
98
109
|
@console_logs_mutex = Mutex.new
|
|
110
|
+
@page_errors = []
|
|
111
|
+
@page_errors_mutex = Mutex.new
|
|
99
112
|
@frame_stack = []
|
|
100
113
|
@turbo_event = Utils::Event.new
|
|
101
114
|
@turbo_event.set
|
|
@@ -149,7 +162,23 @@ module Capybara
|
|
|
149
162
|
# clear_session_state's network.reset flipped @enabled back, so this
|
|
150
163
|
# re-subscribes on the fresh context.
|
|
151
164
|
network.enable
|
|
165
|
+
# Opt into file downloads when a destination exists (driver :save_path
|
|
166
|
+
# or Capybara.save_path). No-op otherwise. Must run after the context
|
|
167
|
+
# is loaded — Browser.setDownloadBehavior is a no-op without one.
|
|
168
|
+
downloads.enable(configured_download_path)
|
|
152
169
|
register_auto_scripts
|
|
170
|
+
set_viewport
|
|
171
|
+
# Subscribe to dialogs from the first page, not lazily on the first
|
|
172
|
+
# accept_modal — otherwise a dialog nobody pre-armed is invisible and
|
|
173
|
+
# the unhandled-modal check below has nothing to report.
|
|
174
|
+
prepare_modals
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Download destination: explicit :save_path option wins, else
|
|
178
|
+
# Capybara.save_path (Cuprite parity). nil => downloads stay off.
|
|
179
|
+
def configured_download_path
|
|
180
|
+
@options.save_path ||
|
|
181
|
+
(defined?(Capybara) && Capybara.respond_to?(:save_path) ? Capybara.save_path : nil)
|
|
153
182
|
end
|
|
154
183
|
|
|
155
184
|
# Wipe per-session state — cookies, storage, all targets — and start
|
|
@@ -193,14 +222,23 @@ module Capybara
|
|
|
193
222
|
def clear_session_state
|
|
194
223
|
@page_events_enabled = false
|
|
195
224
|
@modal_handler_installed = false
|
|
196
|
-
@modal_messages_mutex.synchronize
|
|
225
|
+
@modal_messages_mutex.synchronize do
|
|
226
|
+
@modal_messages.clear
|
|
227
|
+
@modal_armed = false
|
|
228
|
+
@unhandled_modal = nil
|
|
229
|
+
end
|
|
197
230
|
@console_logs_mutex.synchronize { @console_logs.clear }
|
|
231
|
+
@page_errors_mutex.synchronize { @page_errors.clear }
|
|
198
232
|
clear_frames
|
|
199
233
|
# Network#reset, not #clear: disposing the BrowserContext also
|
|
200
234
|
# destroyed the Network domain and its subscriptions, so we must
|
|
201
235
|
# flip @enabled back to false — otherwise the next #enable
|
|
202
236
|
# short-circuits and traffic tracking is silently dead.
|
|
203
237
|
@network&.reset
|
|
238
|
+
# Same contract for Downloads: the disposed context drops the
|
|
239
|
+
# download config + subscriptions, so reset re-arms create_page's
|
|
240
|
+
# downloads.enable on the fresh context.
|
|
241
|
+
@downloads&.reset
|
|
204
242
|
end
|
|
205
243
|
|
|
206
244
|
# Liveness of the CDP transport. Driver#browser checks this to decide
|
|
@@ -218,8 +256,12 @@ module Capybara
|
|
|
218
256
|
# instance leaves @enabled true and create_page's network.enable
|
|
219
257
|
# no-ops, silently killing status_code/traffic capture. Guarded on
|
|
220
258
|
# @client: with no client the handlers are already moot and
|
|
221
|
-
# unsubscribe would have nothing to detach from.
|
|
222
|
-
|
|
259
|
+
# unsubscribe would have nothing to detach from. Downloads carries the
|
|
260
|
+
# same subscription contract, so it resets under the same guard.
|
|
261
|
+
if @client
|
|
262
|
+
@network&.reset
|
|
263
|
+
@downloads&.reset
|
|
264
|
+
end
|
|
223
265
|
begin
|
|
224
266
|
@client&.close
|
|
225
267
|
rescue StandardError
|
|
@@ -325,7 +367,7 @@ module Capybara
|
|
|
325
367
|
# objectId, replaces input.files with a real FileList, and fires
|
|
326
368
|
# `input`/`change`. The submitted form then carries the bytes as
|
|
327
369
|
# multipart/form-data (PR #2654, build ≥6672) — both halves are needed,
|
|
328
|
-
#
|
|
370
|
+
# and both are guaranteed by the MINIMUM_NIGHTLY_BUILD floor. Paths are
|
|
329
371
|
# read off the machine running Lightpanda (local for the spawned process).
|
|
330
372
|
def set_file_input_files(remote_object_id, paths)
|
|
331
373
|
page_command("DOM.setFileInputFiles", objectId: remote_object_id, files: paths)
|
|
@@ -391,6 +433,7 @@ module Capybara
|
|
|
391
433
|
|
|
392
434
|
@default_context_event.wait(@options.timeout)
|
|
393
435
|
@turbo_event.wait(@options.timeout)
|
|
436
|
+
check_unhandled_modal!
|
|
394
437
|
end
|
|
395
438
|
|
|
396
439
|
def keyboard
|
|
@@ -401,6 +444,10 @@ module Capybara
|
|
|
401
444
|
@network ||= Network.new(self)
|
|
402
445
|
end
|
|
403
446
|
|
|
447
|
+
def downloads
|
|
448
|
+
@downloads ||= Downloads.new(self)
|
|
449
|
+
end
|
|
450
|
+
|
|
404
451
|
def cookies
|
|
405
452
|
@cookies ||= Cookies.new(self)
|
|
406
453
|
end
|
|
@@ -455,6 +502,53 @@ module Capybara
|
|
|
455
502
|
page_command("Page.addScriptToEvaluateOnNewDocument", source: AutoScripts::JS)
|
|
456
503
|
end
|
|
457
504
|
|
|
505
|
+
# Apply the `window_size` option as a JS-visible viewport: it drives
|
|
506
|
+
# window.innerWidth/innerHeight and the viewport `matchMedia` / `@media`
|
|
507
|
+
# evaluate against, so responsive branches resolve at the requested size.
|
|
508
|
+
# It is NOT layout — Lightpanda has no rendering engine, so nothing
|
|
509
|
+
# reflows and getBoundingClientRect stays synthetic.
|
|
510
|
+
#
|
|
511
|
+
# Re-applied on every create_page rather than once at connect. The
|
|
512
|
+
# override lives on the CDP-connection-scoped Browser upstream, so it
|
|
513
|
+
# already survives Driver#reset!'s disposeBrowserContext; re-sending is
|
|
514
|
+
# one idempotent call that also covers the reconnect path, where the
|
|
515
|
+
# connection (and therefore the override) is genuinely new.
|
|
516
|
+
#
|
|
517
|
+
# window_size is validated in Options#initialize, which runs before the
|
|
518
|
+
# process is spawned — a bad value must not leave an orphaned browser
|
|
519
|
+
# behind, and raising from here would (Browser#initialize starts the
|
|
520
|
+
# process before create_page ever runs).
|
|
521
|
+
#
|
|
522
|
+
# Public (declared below the private section via `public :set_viewport`)
|
|
523
|
+
# because Driver#resize_window_to drives it mid-test: with no arguments
|
|
524
|
+
# it re-applies the configured size, which is what create_page wants.
|
|
525
|
+
# SHARP EDGE, verified 2026-07-25 on nightly 8285: this updates
|
|
526
|
+
# window.innerWidth/innerHeight and what `matchMedia` reports
|
|
527
|
+
# immediately, but it does NOT re-resolve `@media` rules for the document
|
|
528
|
+
# already loaded. Lightpanda fixes the cascade when a document is parsed
|
|
529
|
+
# and nothing invalidates it on a metrics change, so a page loaded at
|
|
530
|
+
# 1920 keeps rendering its desktop branch even while `matchMedia`
|
|
531
|
+
# ("(max-width: 500px)") reports true. Navigating (Driver#visit) parses
|
|
532
|
+
# under the new metrics and resolves correctly.
|
|
533
|
+
#
|
|
534
|
+
# No workaround shipped on purpose. DOM/CSSOM mutations look like they
|
|
535
|
+
# force a re-cascade but don't survive scrutiny (the probe that suggested
|
|
536
|
+
# otherwise had been contaminated by the previous iteration's viewport),
|
|
537
|
+
# so anything here would be a guess dressed up as a fix. Driver's window
|
|
538
|
+
# methods document the resize-then-visit shape instead.
|
|
539
|
+
def set_viewport(width = nil, height = nil)
|
|
540
|
+
width, height = @options.window_size if width.nil? || height.nil?
|
|
541
|
+
page_command("Emulation.setDeviceMetricsOverride", width: width, height: height)
|
|
542
|
+
[width, height]
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# The viewport as the page currently sees it. Read back from JS rather
|
|
546
|
+
# than echoing what we last set, so it stays truthful if a page (or a
|
|
547
|
+
# direct Emulation call through #execute_cdp) moved it behind our back.
|
|
548
|
+
def viewport_size
|
|
549
|
+
evaluate("[window.innerWidth, window.innerHeight]")
|
|
550
|
+
end
|
|
551
|
+
|
|
458
552
|
# Track default-execution-context availability via Runtime events.
|
|
459
553
|
# Lightpanda destroys the V8 default context at navigation start (long
|
|
460
554
|
# before frameNavigated fires), then re-creates it once the new page
|
|
@@ -510,6 +604,12 @@ module Capybara
|
|
|
510
604
|
def monotonic_time
|
|
511
605
|
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
512
606
|
end
|
|
607
|
+
|
|
608
|
+
# Viewport control is API, not lifecycle: Driver#resize_window_to and
|
|
609
|
+
# #window_size call it mid-test. It lives up in the private section
|
|
610
|
+
# beside register_auto_scripts because create_page is its other caller
|
|
611
|
+
# and the two read together.
|
|
612
|
+
public :set_viewport, :viewport_size
|
|
513
613
|
end
|
|
514
614
|
end
|
|
515
615
|
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Capybara
|
|
6
|
+
module Lightpanda
|
|
7
|
+
# File-download tracker built on `Browser.setDownloadBehavior` (upstream PR
|
|
8
|
+
# #2722, build >= 7545, guaranteed by MINIMUM_NIGHTLY_BUILD). When a
|
|
9
|
+
# navigation response carries `Content-Disposition: attachment`, Lightpanda
|
|
10
|
+
# streams the body to disk under `downloadPath` and — with
|
|
11
|
+
# `eventsEnabled: true` — emits `Browser.downloadWillBegin` /
|
|
12
|
+
# `Browser.downloadProgress`. We mirror those into a completed-files list +
|
|
13
|
+
# a blocking `wait`, the way ferrum's Downloads does.
|
|
14
|
+
#
|
|
15
|
+
# IMPORTANT: the trigger is `Content-Disposition: attachment`, NOT MIME
|
|
16
|
+
# type. A `text/csv` (or any) response without that header is rendered as a
|
|
17
|
+
# normal navigation, not downloaded — which is why Capybara's MIME-triggered
|
|
18
|
+
# `:download` shared spec stays in capybara_skip.
|
|
19
|
+
#
|
|
20
|
+
# Structure deliberately mirrors Network (same browser ref, same mutex +
|
|
21
|
+
# subscribe/unsubscribe lifecycle, same reset-after-disposeBrowserContext
|
|
22
|
+
# contract) so the two trackers behave identically under create_page/reset.
|
|
23
|
+
class Downloads
|
|
24
|
+
attr_reader :browser, :path
|
|
25
|
+
|
|
26
|
+
# How long #wait gives a download to BEGIN (the downloadWillBegin frame
|
|
27
|
+
# can arrive a beat after the click that triggers it, once the click's
|
|
28
|
+
# own wait_for_idle has already returned). Once a download has begun we
|
|
29
|
+
# wait the full `timeout` for it to finish.
|
|
30
|
+
DOWNLOAD_BEGIN_GRACE = 1.0
|
|
31
|
+
|
|
32
|
+
def initialize(browser)
|
|
33
|
+
@browser = browser
|
|
34
|
+
@path = nil
|
|
35
|
+
@enabled = false
|
|
36
|
+
@mutex = Mutex.new
|
|
37
|
+
@pending = {} # guid => on-disk basename (from suggestedFilename)
|
|
38
|
+
@files = [] # absolute paths of completed downloads, in order
|
|
39
|
+
@started = 0 # monotonic count of downloads begun this session
|
|
40
|
+
@will_handler = nil
|
|
41
|
+
@progress_handler = nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Opt into downloads, writing completed files under `save_path`. No-op
|
|
45
|
+
# when `save_path` is blank — downloads then stay at Lightpanda's `deny`
|
|
46
|
+
# default. Connection-scoped (browser.command) like Network.enable;
|
|
47
|
+
# Browser#create_page calls this after the context is loaded (the CDP
|
|
48
|
+
# method is a no-op without one). Subscribe BEFORE the wire toggle so no
|
|
49
|
+
# event can slip past, and roll the handlers back if the command fails.
|
|
50
|
+
def enable(save_path)
|
|
51
|
+
return if @enabled || save_path.to_s.empty?
|
|
52
|
+
|
|
53
|
+
@path = File.expand_path(save_path.to_s)
|
|
54
|
+
FileUtils.mkdir_p(@path)
|
|
55
|
+
subscribe
|
|
56
|
+
begin
|
|
57
|
+
browser.command("Browser.setDownloadBehavior",
|
|
58
|
+
behavior: "allow", downloadPath: @path, eventsEnabled: true)
|
|
59
|
+
rescue StandardError
|
|
60
|
+
unsubscribe
|
|
61
|
+
@path = nil
|
|
62
|
+
raise
|
|
63
|
+
end
|
|
64
|
+
@enabled = true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Absolute paths of downloads completed this session (newest last).
|
|
68
|
+
def files
|
|
69
|
+
@mutex.synchronize { @files.dup }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Monotonic count of downloads that have begun this session. Used by
|
|
73
|
+
# #wait to detect "a download started since I was called" even after the
|
|
74
|
+
# event queue has drained and @pending is empty again.
|
|
75
|
+
def started_count
|
|
76
|
+
@mutex.synchronize { @started }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def in_progress?
|
|
80
|
+
@mutex.synchronize { @pending.any? }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Block until in-flight downloads finish, then return the completed-file
|
|
84
|
+
# list. Non-raising, matching Network#wait_for_idle.
|
|
85
|
+
#
|
|
86
|
+
# The triggering click returns (its wait_for_idle settles on the
|
|
87
|
+
# navigation's network response) slightly BEFORE the downloadWillBegin
|
|
88
|
+
# frame is processed, so a naive `wait until !in_progress?` would see an
|
|
89
|
+
# empty queue and return before the download even registered. Instead:
|
|
90
|
+
# give a download up to DOWNLOAD_BEGIN_GRACE to begin (tracked by the
|
|
91
|
+
# monotonic @started counter, immune to the queue already having drained),
|
|
92
|
+
# then wait up to `timeout` for every in-flight download to finish.
|
|
93
|
+
def wait(timeout: 5)
|
|
94
|
+
baseline = started_count
|
|
95
|
+
clock = -> { ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) }
|
|
96
|
+
hard_deadline = clock.call + timeout
|
|
97
|
+
begin_deadline = clock.call + [timeout, DOWNLOAD_BEGIN_GRACE].min
|
|
98
|
+
|
|
99
|
+
loop do
|
|
100
|
+
began = started_count > baseline
|
|
101
|
+
done = (began ? !in_progress? : clock.call >= begin_deadline) || clock.call >= hard_deadline
|
|
102
|
+
return files if done
|
|
103
|
+
|
|
104
|
+
sleep 0.02
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def clear
|
|
109
|
+
@mutex.synchronize do
|
|
110
|
+
@files.clear
|
|
111
|
+
@pending.clear
|
|
112
|
+
@started = 0
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Wipe local state after Target.disposeBrowserContext, which drops both
|
|
117
|
+
# the subscriptions and the per-connection download config — leaving
|
|
118
|
+
# @enabled true would no-op the next #enable. Mirrors Network#reset.
|
|
119
|
+
def reset
|
|
120
|
+
unsubscribe
|
|
121
|
+
clear
|
|
122
|
+
@enabled = false
|
|
123
|
+
@path = nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def subscribe
|
|
129
|
+
@will_handler = build_will_handler
|
|
130
|
+
@progress_handler = build_progress_handler
|
|
131
|
+
browser.on("Browser.downloadWillBegin", &@will_handler)
|
|
132
|
+
browser.on("Browser.downloadProgress", &@progress_handler)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# downloadWillBegin carries {guid, url, suggestedFilename}. behavior
|
|
136
|
+
# "allow" writes the file under the (path-stripped) suggested name, so
|
|
137
|
+
# remember guid => basename to resolve the absolute path on completion.
|
|
138
|
+
def build_will_handler
|
|
139
|
+
lambda do |params|
|
|
140
|
+
guid = params["guid"]
|
|
141
|
+
name = File.basename(params["suggestedFilename"].to_s)
|
|
142
|
+
next if guid.nil? || name.empty?
|
|
143
|
+
|
|
144
|
+
@mutex.synchronize do
|
|
145
|
+
@pending[guid] = name
|
|
146
|
+
@started += 1
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# downloadProgress carries {guid, state}. On "completed" the file is on
|
|
152
|
+
# disk at downloadPath/basename; "canceled" just drops the pending entry.
|
|
153
|
+
def build_progress_handler
|
|
154
|
+
lambda do |params|
|
|
155
|
+
guid = params["guid"]
|
|
156
|
+
case params["state"]
|
|
157
|
+
when "completed"
|
|
158
|
+
@mutex.synchronize do
|
|
159
|
+
name = @pending.delete(guid)
|
|
160
|
+
@files << File.join(@path, name) if name && @path
|
|
161
|
+
end
|
|
162
|
+
when "canceled"
|
|
163
|
+
@mutex.synchronize { @pending.delete(guid) }
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def unsubscribe
|
|
169
|
+
browser.off("Browser.downloadWillBegin", @will_handler) if @will_handler
|
|
170
|
+
browser.off("Browser.downloadProgress", @progress_handler) if @progress_handler
|
|
171
|
+
@will_handler = nil
|
|
172
|
+
@progress_handler = nil
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -117,6 +117,22 @@ module Capybara
|
|
|
117
117
|
network.wait_for_idle(timeout: timeout, connections: connections)
|
|
118
118
|
end
|
|
119
119
|
|
|
120
|
+
# -- Downloads --
|
|
121
|
+
# Files downloaded since the session started (absolute paths). Capture is
|
|
122
|
+
# on whenever a destination exists (the :save_path driver option, else
|
|
123
|
+
# Capybara.save_path) and the server sends `Content-Disposition:
|
|
124
|
+
# attachment`. Ferrum/Cuprite expose downloads on the driver too.
|
|
125
|
+
|
|
126
|
+
def downloads
|
|
127
|
+
browser.downloads.files
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Block until in-flight downloads finish (or timeout); returns the file
|
|
131
|
+
# list. Click the download trigger first, then call this.
|
|
132
|
+
def wait_for_download(timeout: 5)
|
|
133
|
+
browser.downloads.wait(timeout: timeout)
|
|
134
|
+
end
|
|
135
|
+
|
|
120
136
|
# -- Cookie Management --
|
|
121
137
|
|
|
122
138
|
def set_cookie(name, value, **options)
|
|
@@ -158,6 +174,83 @@ module Capybara
|
|
|
158
174
|
end
|
|
159
175
|
end
|
|
160
176
|
|
|
177
|
+
# -- Window Support --
|
|
178
|
+
# Single-window driver. Lightpanda's BrowserContext is 1:1:1 with
|
|
179
|
+
# Session/Page/target and rejects a second Target.createTarget with
|
|
180
|
+
# TargetAlreadyLoaded (upstream #1962, maintainer-owned), so there is
|
|
181
|
+
# exactly one window and its handle is the CDP target id.
|
|
182
|
+
#
|
|
183
|
+
# Resizing drives Emulation.setDeviceMetricsOverride, so it is real for
|
|
184
|
+
# window.innerWidth/innerHeight and for what `matchMedia` reports. Two
|
|
185
|
+
# limits worth knowing before writing a responsive spec:
|
|
186
|
+
#
|
|
187
|
+
# 1. It is not layout. Element geometry stays synthetic, so a resize
|
|
188
|
+
# changes which CSS branch applies, never where anything sits.
|
|
189
|
+
# 2. `@media` rules do NOT re-resolve for the document already on
|
|
190
|
+
# screen — Lightpanda fixes the cascade at parse time and a metrics
|
|
191
|
+
# change doesn't invalidate it. So resize, THEN visit:
|
|
192
|
+
#
|
|
193
|
+
# page.current_window.resize_to(375, 667)
|
|
194
|
+
# visit "/pricing" # parses under the new metrics
|
|
195
|
+
# assert_selector "#mobile-cta"
|
|
196
|
+
#
|
|
197
|
+
# Resizing without re-visiting leaves `matchMedia` and the rendered
|
|
198
|
+
# branch disagreeing. See Browser#set_viewport for the verification.
|
|
199
|
+
|
|
200
|
+
def current_window_handle
|
|
201
|
+
browser.target_id
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def window_handles
|
|
205
|
+
[browser.target_id]
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def switch_to_window(handle)
|
|
209
|
+
return if handle == browser.target_id
|
|
210
|
+
|
|
211
|
+
raise NoSuchPageError, "Window #{handle.inspect} does not exist. " \
|
|
212
|
+
"Lightpanda supports a single window per session."
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Capybara's Window#current? rescues this to decide whether a handle is
|
|
216
|
+
# still live, so it must be a class, not a raise.
|
|
217
|
+
def no_such_window_error
|
|
218
|
+
NoSuchPageError
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def window_size(handle)
|
|
222
|
+
assert_current_window!(handle)
|
|
223
|
+
browser.viewport_size
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def resize_window_to(handle, width, height)
|
|
227
|
+
assert_current_window!(handle)
|
|
228
|
+
browser.set_viewport(width, height)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# No window manager and no layout, so "as large as the screen" has no
|
|
232
|
+
# meaning beyond the configured size. Both reset to it rather than
|
|
233
|
+
# raising, because suites call maximize defensively in setup and a
|
|
234
|
+
# raise there would take out the example before it starts.
|
|
235
|
+
def maximize_window(handle)
|
|
236
|
+
assert_current_window!(handle)
|
|
237
|
+
browser.set_viewport
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
alias fullscreen_window maximize_window
|
|
241
|
+
|
|
242
|
+
def open_new_window(_kind = :tab)
|
|
243
|
+
raise Capybara::NotSupportedByDriverError,
|
|
244
|
+
"Lightpanda serves a single target per CDP connection (upstream lightpanda-io/browser#1962), " \
|
|
245
|
+
"so a second window cannot be opened. Drive the second page in its own Capybara session instead."
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def close_window(_handle)
|
|
249
|
+
raise Capybara::NotSupportedByDriverError,
|
|
250
|
+
"Lightpanda has a single window per session; closing it would end the session. " \
|
|
251
|
+
"Use Driver#reset! to start a fresh one."
|
|
252
|
+
end
|
|
253
|
+
|
|
161
254
|
# -- Modal/Dialog Support --
|
|
162
255
|
|
|
163
256
|
# find_modal owns the wait default (browser.options.timeout) — pass
|
|
@@ -196,8 +289,11 @@ module Capybara
|
|
|
196
289
|
alias render save_screenshot
|
|
197
290
|
|
|
198
291
|
# -- Headers (Cuprite-compatible driver surface) --
|
|
199
|
-
# Delegates to Network, which lazily enables the Network domain
|
|
200
|
-
#
|
|
292
|
+
# Delegates to Network, which lazily enables the Network domain. The
|
|
293
|
+
# overrides do NOT survive reset!: disposing the BrowserContext takes
|
|
294
|
+
# setExtraHTTPHeaders with it, so Network#reset drops its cached copy
|
|
295
|
+
# rather than report headers the browser stopped sending (pinned by
|
|
296
|
+
# network_test.rb "clears extra_headers"). Cuprite exposes these on the
|
|
201
297
|
# driver, and real suites call them there (page.driver.headers = ...).
|
|
202
298
|
|
|
203
299
|
def headers
|
|
@@ -273,6 +369,17 @@ module Capybara
|
|
|
273
369
|
|
|
274
370
|
private
|
|
275
371
|
|
|
372
|
+
# Every window method takes a handle because Capybara's Window objects
|
|
373
|
+
# carry one; with a single window the only valid value is the current
|
|
374
|
+
# target id. Reject anything else rather than silently operating on the
|
|
375
|
+
# wrong window.
|
|
376
|
+
def assert_current_window!(handle)
|
|
377
|
+
return if handle.nil? || handle == browser.target_id
|
|
378
|
+
|
|
379
|
+
raise NoSuchPageError, "Window #{handle.inspect} does not exist. " \
|
|
380
|
+
"Lightpanda supports a single window per session."
|
|
381
|
+
end
|
|
382
|
+
|
|
276
383
|
# Unwrap arguments before sending to the browser. Capybara::Node::Element wraps
|
|
277
384
|
# our Lightpanda::Node — pull `.base` out so `serialize_argument` can build
|
|
278
385
|
# `{objectId: …}` for the CDP payload. Cuprite's `native_args` pattern.
|
|
@@ -13,6 +13,11 @@ module Capybara
|
|
|
13
13
|
class UnsupportedPlatformError < Error; end
|
|
14
14
|
|
|
15
15
|
class TimeoutError < Error; end
|
|
16
|
+
# A JS dialog opened with no accept_modal/dismiss_modal pre-arm in flight
|
|
17
|
+
# and the driver was configured with `raise_on_unhandled_modal: true`.
|
|
18
|
+
# Lightpanda has already applied its silent default by the time this is
|
|
19
|
+
# raised (confirm → cancel, prompt → null, alert → dismissed).
|
|
20
|
+
class UnhandledModalError < Error; end
|
|
16
21
|
|
|
17
22
|
# Base class for any error originating from a CDP response or live browser
|
|
18
23
|
# state. Lets callers `rescue BrowserError` to catch the whole CDP family
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// --- Uncaught page errors ---
|
|
2
|
+
// Lightpanda implements no `Runtime.exceptionThrown` (upstream gap, wishlist
|
|
3
|
+
// B19), so an uncaught exception in page JS reaches no CDP client at all. It is
|
|
4
|
+
// invisible to console_logs, which only ever sees explicit console.* calls, so
|
|
5
|
+
// a handler that dies on a TypeError leaves no trace and the failure surfaces
|
|
6
|
+
// far away as ElementNotFound on whatever it was supposed to produce. That is
|
|
7
|
+
// the exact shape of the solidus taxon-tree failure in real-apps causes.yml.
|
|
8
|
+
//
|
|
9
|
+
// These listeners are passive — no preventDefault, so nothing the page would
|
|
10
|
+
// otherwise see is swallowed, and nothing that would have propagated stops
|
|
11
|
+
// propagating. They re-emit through the same console.debug sentinel channel
|
|
12
|
+
// turbo.js uses, and console.rb routes them into Browser#page_errors rather
|
|
13
|
+
// than #console_logs. Keeping the two apart is deliberate: console_logs stays
|
|
14
|
+
// "the console.* calls the page made", which is what Chrome models (an
|
|
15
|
+
// exception is exceptionThrown, not consoleAPICalled) and what Playwright and
|
|
16
|
+
// Puppeteer expose (pageerror, separate from console). Folding exceptions into
|
|
17
|
+
// console_logs would also break every suite already asserting that buffer is
|
|
18
|
+
// free of errors.
|
|
19
|
+
//
|
|
20
|
+
// Delete this file once upstream emits Runtime.exceptionThrown and the floor
|
|
21
|
+
// covers it: subscribe Browser::Console to that event and feed the same buffer.
|
|
22
|
+
function _reportPageError(fields) {
|
|
23
|
+
try {
|
|
24
|
+
console.debug('__lightpanda_page_error_' + JSON.stringify(fields));
|
|
25
|
+
} catch (e) {
|
|
26
|
+
// A field that won't serialize (a getter that throws, a cyclic `reason`)
|
|
27
|
+
// must not turn one page error into two, so report the kind and move on.
|
|
28
|
+
try {
|
|
29
|
+
console.debug('__lightpanda_page_error_' + JSON.stringify({
|
|
30
|
+
kind: fields && fields.kind ? fields.kind : 'error',
|
|
31
|
+
message: '(page error could not be serialized)'
|
|
32
|
+
}));
|
|
33
|
+
} catch (e2) {}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
window.addEventListener('error', function (e) {
|
|
38
|
+
// A failed <img>/<script>/<link> fetch fires `error` too, but on the element
|
|
39
|
+
// and without bubbling, so it shouldn't reach this listener. Guard anyway
|
|
40
|
+
// (Lightpanda's dispatch need not match Chrome's exactly) and identify those
|
|
41
|
+
// by their missing string `message` — a resource failure is a network fact
|
|
42
|
+
// and Network#traffic already carries it.
|
|
43
|
+
if (!e || typeof e.message !== 'string') return;
|
|
44
|
+
|
|
45
|
+
_reportPageError({
|
|
46
|
+
kind: 'error',
|
|
47
|
+
message: e.message,
|
|
48
|
+
url: typeof e.filename === 'string' ? e.filename : '',
|
|
49
|
+
line: typeof e.lineno === 'number' ? e.lineno : null,
|
|
50
|
+
column: typeof e.colno === 'number' ? e.colno : null,
|
|
51
|
+
// Cap the stack: it rides a console argument over the CDP WebSocket, and a
|
|
52
|
+
// deep recursion stack can be megabytes.
|
|
53
|
+
stack: e.error && typeof e.error.stack === 'string' ? e.error.stack.slice(0, 4000) : null
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
window.addEventListener('unhandledrejection', function (e) {
|
|
58
|
+
var reason = e ? e.reason : null;
|
|
59
|
+
var message;
|
|
60
|
+
try {
|
|
61
|
+
message = reason && reason.message ? String(reason.message) : String(reason);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
message = '(rejection reason could not be stringified)';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
_reportPageError({
|
|
67
|
+
kind: 'unhandledrejection',
|
|
68
|
+
message: message,
|
|
69
|
+
url: '',
|
|
70
|
+
line: null,
|
|
71
|
+
column: null,
|
|
72
|
+
stack: reason && typeof reason.stack === 'string' ? reason.stack.slice(0, 4000) : null
|
|
73
|
+
});
|
|
74
|
+
});
|