capybara-lightpanda 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/CHANGELOG.md +62 -0
- data/README.md +14 -1
- data/lib/capybara/lightpanda/auto_scripts.rb +30 -2
- data/lib/capybara/lightpanda/binary.rb +72 -54
- data/lib/capybara/lightpanda/browser/console.rb +190 -0
- data/lib/capybara/lightpanda/browser/finder.rb +196 -0
- data/lib/capybara/lightpanda/browser/modals.rb +150 -0
- data/lib/capybara/lightpanda/browser/navigation.rb +186 -0
- data/lib/capybara/lightpanda/browser/runtime.rb +258 -0
- data/lib/capybara/lightpanda/browser/selenium_compat.rb +124 -0
- data/lib/capybara/lightpanda/browser.rb +158 -813
- data/lib/capybara/lightpanda/client/subscriber.rb +2 -0
- data/lib/capybara/lightpanda/client/web_socket.rb +19 -21
- data/lib/capybara/lightpanda/client.rb +5 -4
- data/lib/capybara/lightpanda/downloads.rb +176 -0
- data/lib/capybara/lightpanda/driver.rb +124 -32
- data/lib/capybara/lightpanda/errors.rb +24 -10
- data/lib/capybara/lightpanda/javascripts/attach.js +16 -0
- data/lib/capybara/lightpanda/javascripts/banner.js +15 -0
- data/lib/capybara/lightpanda/javascripts/errors.js +74 -0
- data/lib/capybara/lightpanda/javascripts/predicates.js +116 -0
- data/lib/capybara/lightpanda/javascripts/turbo.js +67 -0
- data/lib/capybara/lightpanda/keyboard.rb +23 -19
- data/lib/capybara/lightpanda/network.rb +103 -18
- data/lib/capybara/lightpanda/node.rb +200 -95
- data/lib/capybara/lightpanda/options.rb +44 -3
- data/lib/capybara/lightpanda/process.rb +154 -29
- data/lib/capybara/lightpanda/version.rb +1 -1
- data/lib/capybara-lightpanda.rb +1 -0
- metadata +14 -3
- data/lib/capybara/lightpanda/javascripts/index.js +0 -226
|
@@ -4,6 +4,8 @@ require "json"
|
|
|
4
4
|
require "socket"
|
|
5
5
|
require "websocket/driver"
|
|
6
6
|
|
|
7
|
+
require_relative "../utils/attempt"
|
|
8
|
+
|
|
7
9
|
module Capybara
|
|
8
10
|
module Lightpanda
|
|
9
11
|
class Client
|
|
@@ -36,7 +38,7 @@ module Capybara
|
|
|
36
38
|
|
|
37
39
|
@status = :closing
|
|
38
40
|
@messages.close
|
|
39
|
-
@driver&.close
|
|
41
|
+
@driver_mutex.synchronize { @driver&.close }
|
|
40
42
|
@thread&.join(1) || @thread&.kill
|
|
41
43
|
@socket&.close
|
|
42
44
|
@status = :closed
|
|
@@ -46,15 +48,10 @@ module Capybara
|
|
|
46
48
|
@status == :closed || @status == :error
|
|
47
49
|
end
|
|
48
50
|
|
|
49
|
-
def open?
|
|
50
|
-
@status == :open
|
|
51
|
-
end
|
|
52
|
-
|
|
53
51
|
def write(data)
|
|
54
52
|
@socket.write(data)
|
|
55
53
|
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
56
|
-
|
|
57
|
-
@messages.close
|
|
54
|
+
mark_dead
|
|
58
55
|
end
|
|
59
56
|
|
|
60
57
|
private
|
|
@@ -73,13 +70,9 @@ module Capybara
|
|
|
73
70
|
start_reader_thread
|
|
74
71
|
end
|
|
75
72
|
|
|
76
|
-
def connect_with_retry(host, port
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
rescue Errno::ECONNREFUSED
|
|
80
|
-
raise if i == retries - 1
|
|
81
|
-
|
|
82
|
-
sleep delay
|
|
73
|
+
def connect_with_retry(host, port)
|
|
74
|
+
Utils::Attempt.with_retry(errors: Errno::ECONNREFUSED, max: 10, wait: 0.1) do
|
|
75
|
+
TCPSocket.new(host, port)
|
|
83
76
|
end
|
|
84
77
|
end
|
|
85
78
|
|
|
@@ -97,8 +90,7 @@ module Capybara
|
|
|
97
90
|
end
|
|
98
91
|
|
|
99
92
|
@driver.on(:close) do
|
|
100
|
-
|
|
101
|
-
@messages.close
|
|
93
|
+
mark_dead
|
|
102
94
|
end
|
|
103
95
|
|
|
104
96
|
@driver.on(:error) do |event|
|
|
@@ -109,8 +101,7 @@ module Capybara
|
|
|
109
101
|
# process. Mark the connection dead and let Client#command
|
|
110
102
|
# surface DeadBrowserError on its next dispatch via closed?.
|
|
111
103
|
@logger&.puts("✗ WebSocket error: #{event.message}")
|
|
112
|
-
|
|
113
|
-
@messages.close
|
|
104
|
+
mark_dead(:error)
|
|
114
105
|
end
|
|
115
106
|
end
|
|
116
107
|
|
|
@@ -127,8 +118,7 @@ module Capybara
|
|
|
127
118
|
data = @socket.readpartial(4096)
|
|
128
119
|
@driver_mutex.synchronize { @driver.parse(data) }
|
|
129
120
|
rescue Errno::ECONNRESET, Errno::EPIPE, IOError
|
|
130
|
-
|
|
131
|
-
@messages.close
|
|
121
|
+
mark_dead
|
|
132
122
|
break
|
|
133
123
|
end
|
|
134
124
|
end
|
|
@@ -144,7 +134,7 @@ module Capybara
|
|
|
144
134
|
begin
|
|
145
135
|
data = @socket.readpartial(4096)
|
|
146
136
|
@driver.parse(data)
|
|
147
|
-
rescue EOFError
|
|
137
|
+
rescue Errno::ECONNRESET, Errno::EPIPE, IOError # IOError covers EOFError
|
|
148
138
|
raise DeadBrowserError, "Connection closed during handshake"
|
|
149
139
|
end
|
|
150
140
|
end
|
|
@@ -154,6 +144,14 @@ module Capybara
|
|
|
154
144
|
raise TimeoutError, "WebSocket handshake timed out after #{@options.handshake_timeout}s"
|
|
155
145
|
end
|
|
156
146
|
|
|
147
|
+
# Single home for the "dead implies queue closed" invariant: every
|
|
148
|
+
# path that gives up on the connection must close @messages so the
|
|
149
|
+
# Client message thread's blocking pop returns.
|
|
150
|
+
def mark_dead(status = :closed)
|
|
151
|
+
@status = status
|
|
152
|
+
@messages.close
|
|
153
|
+
end
|
|
154
|
+
|
|
157
155
|
def parse_message(data)
|
|
158
156
|
JSON.parse(data, max_nesting: false)
|
|
159
157
|
rescue JSON::ParserError => e
|
|
@@ -9,10 +9,7 @@ require_relative "client/subscriber"
|
|
|
9
9
|
module Capybara
|
|
10
10
|
module Lightpanda
|
|
11
11
|
class Client
|
|
12
|
-
attr_reader :ws_url, :options
|
|
13
|
-
|
|
14
12
|
def initialize(ws_url, options)
|
|
15
|
-
@ws_url = ws_url
|
|
16
13
|
@options = options
|
|
17
14
|
@ws = WebSocket.new(ws_url, options)
|
|
18
15
|
@command_id = 0
|
|
@@ -118,7 +115,11 @@ module Capybara
|
|
|
118
115
|
def handle_message(message)
|
|
119
116
|
if message["id"]
|
|
120
117
|
pending = @pendings[message["id"]]
|
|
121
|
-
|
|
118
|
+
# try_set, not set: a duplicate frame for an already-answered id
|
|
119
|
+
# (Lightpanda emits occasional malformed/duplicate frames — see
|
|
120
|
+
# upstream-wishlist.md A41) would raise MultipleAssignmentError on
|
|
121
|
+
# this thread, and abort_on_exception would kill the whole process.
|
|
122
|
+
pending&.try_set(message)
|
|
122
123
|
elsif message["method"]
|
|
123
124
|
@subscriber.dispatch(message["method"], message["params"])
|
|
124
125
|
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
|
|
@@ -10,7 +10,7 @@ module Capybara
|
|
|
10
10
|
|
|
11
11
|
attr_reader :app, :options
|
|
12
12
|
|
|
13
|
-
delegate %i[current_url title status_code response_headers] => :browser
|
|
13
|
+
delegate %i[current_url title status_code response_headers frame_url frame_title] => :browser
|
|
14
14
|
|
|
15
15
|
def initialize(app, options = {})
|
|
16
16
|
super()
|
|
@@ -26,9 +26,7 @@ module Capybara
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def browser_alive?
|
|
29
|
-
|
|
30
|
-
rescue StandardError
|
|
31
|
-
false
|
|
29
|
+
!@browser.nil? && @browser.alive?
|
|
32
30
|
end
|
|
33
31
|
|
|
34
32
|
# Escape hatch to the underlying Browser for callers that need raw CDP
|
|
@@ -119,6 +117,22 @@ module Capybara
|
|
|
119
117
|
network.wait_for_idle(timeout: timeout, connections: connections)
|
|
120
118
|
end
|
|
121
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
|
+
|
|
122
136
|
# -- Cookie Management --
|
|
123
137
|
|
|
124
138
|
def set_cookie(name, value, **options)
|
|
@@ -160,42 +174,97 @@ module Capybara
|
|
|
160
174
|
end
|
|
161
175
|
end
|
|
162
176
|
|
|
163
|
-
#
|
|
164
|
-
#
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
170
230
|
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
173
238
|
end
|
|
174
239
|
|
|
175
|
-
|
|
176
|
-
frame = browser.frame_stack.last
|
|
177
|
-
return browser.title unless frame
|
|
240
|
+
alias fullscreen_window maximize_window
|
|
178
241
|
|
|
179
|
-
|
|
180
|
-
|
|
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."
|
|
181
252
|
end
|
|
182
253
|
|
|
183
254
|
# -- Modal/Dialog Support --
|
|
184
255
|
|
|
256
|
+
# find_modal owns the wait default (browser.options.timeout) — pass
|
|
257
|
+
# wait only when the caller overrode it.
|
|
185
258
|
def accept_modal(type, **options, &block)
|
|
186
259
|
browser.accept_modal(type, text: options[:with])
|
|
187
260
|
block&.call
|
|
188
|
-
browser.find_modal(type,
|
|
189
|
-
text: options[:text],
|
|
190
|
-
wait: options.fetch(:wait, browser.options.timeout))
|
|
261
|
+
browser.find_modal(type, **{ text: options[:text], wait: options[:wait] }.compact)
|
|
191
262
|
end
|
|
192
263
|
|
|
193
264
|
def dismiss_modal(type, **options, &block)
|
|
194
265
|
browser.dismiss_modal(type)
|
|
195
266
|
block&.call
|
|
196
|
-
browser.find_modal(type,
|
|
197
|
-
text: options[:text],
|
|
198
|
-
wait: options.fetch(:wait, browser.options.timeout))
|
|
267
|
+
browser.find_modal(type, **{ text: options[:text], wait: options[:wait] }.compact)
|
|
199
268
|
end
|
|
200
269
|
|
|
201
270
|
# -- Screenshots --
|
|
@@ -220,8 +289,11 @@ module Capybara
|
|
|
220
289
|
alias render save_screenshot
|
|
221
290
|
|
|
222
291
|
# -- Headers (Cuprite-compatible driver surface) --
|
|
223
|
-
# Delegates to Network, which lazily enables the Network domain
|
|
224
|
-
#
|
|
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
|
|
225
297
|
# driver, and real suites call them there (page.driver.headers = ...).
|
|
226
298
|
|
|
227
299
|
def headers
|
|
@@ -241,12 +313,20 @@ module Capybara
|
|
|
241
313
|
# Thin Cuprite-style wrapper. The interesting work — disposing the
|
|
242
314
|
# BrowserContext (cookies, storage, all targets) and starting a fresh
|
|
243
315
|
# one — happens in Browser#reset.
|
|
316
|
+
#
|
|
317
|
+
# Rescue is the gem hierarchy plus raw IO escapees only — NOT
|
|
318
|
+
# StandardError: reset! runs between every test, so a blanket rescue
|
|
319
|
+
# turns programmer errors (e.g. a NoMethodError in browser.rb) into a
|
|
320
|
+
# silent quit-and-respawn on every example with zero signal. The warn
|
|
321
|
+
# keeps repeated respawns visible.
|
|
244
322
|
def reset!
|
|
245
323
|
browser.reset
|
|
246
|
-
|
|
247
|
-
|
|
324
|
+
rescue Error, SystemCallError, IOError => e
|
|
325
|
+
warn "[capybara-lightpanda] reset! failed (#{e.class}: #{e.message}); respawning browser"
|
|
248
326
|
@browser&.quit
|
|
249
327
|
@browser = nil
|
|
328
|
+
ensure
|
|
329
|
+
@started = false
|
|
250
330
|
end
|
|
251
331
|
|
|
252
332
|
def quit
|
|
@@ -289,6 +369,17 @@ module Capybara
|
|
|
289
369
|
|
|
290
370
|
private
|
|
291
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
|
+
|
|
292
383
|
# Unwrap arguments before sending to the browser. Capybara::Node::Element wraps
|
|
293
384
|
# our Lightpanda::Node — pull `.base` out so `serialize_argument` can build
|
|
294
385
|
# `{objectId: …}` for the CDP payload. Cuprite's `native_args` pattern.
|
|
@@ -315,14 +406,15 @@ module Capybara
|
|
|
315
406
|
end
|
|
316
407
|
|
|
317
408
|
# Walk through evaluate-script results turning DOM-node markers (the
|
|
318
|
-
# `{
|
|
319
|
-
# into Lightpanda::Node instances so
|
|
409
|
+
# `{ Browser::NODE_MARKER => "..." }` hashes produced by
|
|
410
|
+
# `Browser#unwrap_call_result`) into Lightpanda::Node instances so
|
|
411
|
+
# Capybara can wrap them as elements.
|
|
320
412
|
def unwrap_script_result(value)
|
|
321
413
|
case value
|
|
322
414
|
when Array then value.map { |v| unwrap_script_result(v) }
|
|
323
415
|
when Hash
|
|
324
|
-
if value.size == 1 && value.key?(
|
|
325
|
-
Node.new(self, value[
|
|
416
|
+
if value.size == 1 && value.key?(Browser::NODE_MARKER)
|
|
417
|
+
Node.new(self, value[Browser::NODE_MARKER])
|
|
326
418
|
else
|
|
327
419
|
value.transform_values { |v| unwrap_script_result(v) }
|
|
328
420
|
end
|
|
@@ -5,11 +5,19 @@ module Capybara
|
|
|
5
5
|
class Error < StandardError; end
|
|
6
6
|
|
|
7
7
|
class ProcessTimeoutError < Error; end
|
|
8
|
+
# Subclass so external `rescue ProcessTimeoutError` keeps catching it;
|
|
9
|
+
# Process#start dispatches on the class instead of message matching.
|
|
10
|
+
class PortInUseError < ProcessTimeoutError; end
|
|
8
11
|
class BinaryNotFoundError < Error; end
|
|
9
12
|
class BinaryError < Error; end
|
|
10
13
|
class UnsupportedPlatformError < Error; end
|
|
11
14
|
|
|
12
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
|
|
13
21
|
|
|
14
22
|
# Base class for any error originating from a CDP response or live browser
|
|
15
23
|
# state. Lets callers `rescue BrowserError` to catch the whole CDP family
|
|
@@ -88,6 +96,22 @@ module Capybara
|
|
|
88
96
|
end
|
|
89
97
|
end
|
|
90
98
|
|
|
99
|
+
class InvalidSelector < Error
|
|
100
|
+
attr_reader :method, :selector
|
|
101
|
+
|
|
102
|
+
def initialize(message, method = nil, selector = nil)
|
|
103
|
+
@method = method
|
|
104
|
+
@selector = selector
|
|
105
|
+
super(message)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# --- Cuprite/Ferrum drop-in compatibility surface ---
|
|
110
|
+
# This gem never raises the three classes below (no coordinate-based
|
|
111
|
+
# mouse path, no multi-page API, no page-status tracking). They mirror
|
|
112
|
+
# the peer taxonomy (Cuprite's MouseEventFailed, Ferrum's
|
|
113
|
+
# NoSuchPageError/StatusError) so suites migrating from those drivers
|
|
114
|
+
# don't NameError on rescue lists that reference them.
|
|
91
115
|
class MouseEventFailed < BrowserError
|
|
92
116
|
attr_reader :node, :selector, :position
|
|
93
117
|
|
|
@@ -103,16 +127,6 @@ module Capybara
|
|
|
103
127
|
end
|
|
104
128
|
end
|
|
105
129
|
|
|
106
|
-
class InvalidSelector < Error
|
|
107
|
-
attr_reader :method, :selector
|
|
108
|
-
|
|
109
|
-
def initialize(message, method = nil, selector = nil)
|
|
110
|
-
@method = method
|
|
111
|
-
@selector = selector
|
|
112
|
-
super(message)
|
|
113
|
-
end
|
|
114
|
-
end
|
|
115
|
-
|
|
116
130
|
class NoSuchPageError < Error; end
|
|
117
131
|
class StatusError < Error; end
|
|
118
132
|
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// --- Public surface ---
|
|
2
|
+
// The one place that names window._lightpanda. References the function/var
|
|
3
|
+
// declarations from turbo.js and predicates.js (hoisted into the same IIFE
|
|
4
|
+
// scope by AutoScripts). Runs last in the concatenation order.
|
|
5
|
+
window._lightpanda = {
|
|
6
|
+
turbo: {
|
|
7
|
+
pending: function() { return _pendingTurboOps; },
|
|
8
|
+
idle: function() { return _pendingTurboOps <= 0; }
|
|
9
|
+
},
|
|
10
|
+
|
|
11
|
+
isVisible: isVisible,
|
|
12
|
+
isObscured: isObscured,
|
|
13
|
+
isDisabled: isDisabled,
|
|
14
|
+
isContentEditable: isContentEditable,
|
|
15
|
+
visibleText: visibleText
|
|
16
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// _lightpanda auto-injected bundle. Assembled by AutoScripts (auto_scripts.rb)
|
|
2
|
+
// from the sibling source files in this directory and registered once per
|
|
3
|
+
// session via Page.addScriptToEvaluateOnNewDocument, so it runs in every
|
|
4
|
+
// document — top frame and every iframe.
|
|
5
|
+
//
|
|
6
|
+
// Each source file is a plain sequence of declarations/statements: no IIFE,
|
|
7
|
+
// no `export`/`import`/`require`. AutoScripts wraps them in the IIFE and the
|
|
8
|
+
// `window._lightpanda` idempotency guard. Keeping the files free of module
|
|
9
|
+
// syntax means they parse identically as a classic browser script and as a
|
|
10
|
+
// `new Function(...)` body — which is exactly how the Bun harness (test/js/)
|
|
11
|
+
// loads predicates.js to test them without a build step.
|
|
12
|
+
//
|
|
13
|
+
// Concatenation order (set in auto_scripts.rb): banner, turbo, predicates,
|
|
14
|
+
// attach. `attach.js` reads the names defined by `turbo.js`/`predicates.js`,
|
|
15
|
+
// so it must come last.
|