capybara-lightpanda 0.1.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 +7 -0
- data/CHANGELOG.md +50 -0
- data/LICENSE.txt +27 -0
- data/NOTICE.md +101 -0
- data/README.md +215 -0
- data/lib/capybara/lightpanda/binary.rb +190 -0
- data/lib/capybara/lightpanda/browser.rb +963 -0
- data/lib/capybara/lightpanda/client/subscriber.rb +44 -0
- data/lib/capybara/lightpanda/client/web_socket.rb +160 -0
- data/lib/capybara/lightpanda/client.rb +124 -0
- data/lib/capybara/lightpanda/cookies.rb +181 -0
- data/lib/capybara/lightpanda/driver.rb +252 -0
- data/lib/capybara/lightpanda/errors.rb +76 -0
- data/lib/capybara/lightpanda/frame.rb +33 -0
- data/lib/capybara/lightpanda/javascripts/index.js +1108 -0
- data/lib/capybara/lightpanda/keyboard.rb +142 -0
- data/lib/capybara/lightpanda/logger.rb +37 -0
- data/lib/capybara/lightpanda/network.rb +92 -0
- data/lib/capybara/lightpanda/node.rb +726 -0
- data/lib/capybara/lightpanda/options.rb +63 -0
- data/lib/capybara/lightpanda/process.rb +252 -0
- data/lib/capybara/lightpanda/utils/event.rb +37 -0
- data/lib/capybara/lightpanda/version.rb +7 -0
- data/lib/capybara/lightpanda/xpath_polyfill.rb +10 -0
- data/lib/capybara-lightpanda.rb +42 -0
- metadata +119 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "forwardable"
|
|
4
|
+
|
|
5
|
+
module Capybara
|
|
6
|
+
module Lightpanda
|
|
7
|
+
class Driver < ::Capybara::Driver::Base
|
|
8
|
+
extend Forwardable
|
|
9
|
+
|
|
10
|
+
attr_reader :app, :options
|
|
11
|
+
|
|
12
|
+
delegate %i[current_url title] => :browser
|
|
13
|
+
|
|
14
|
+
def initialize(app, options = {})
|
|
15
|
+
super()
|
|
16
|
+
@app = app
|
|
17
|
+
@options = options
|
|
18
|
+
@browser = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def browser
|
|
22
|
+
@browser = nil if @browser && !browser_alive?
|
|
23
|
+
@browser ||= Browser.new(@options)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def browser_alive?
|
|
27
|
+
@browser.client && !@browser.client.closed?
|
|
28
|
+
rescue StandardError
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def visit(url)
|
|
33
|
+
browser.go_to(url)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def go_back
|
|
37
|
+
browser.back
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def go_forward
|
|
41
|
+
browser.forward
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def refresh
|
|
45
|
+
browser.refresh
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def html
|
|
49
|
+
browser.body
|
|
50
|
+
end
|
|
51
|
+
alias body html
|
|
52
|
+
|
|
53
|
+
def active_element
|
|
54
|
+
oid = browser.active_element
|
|
55
|
+
oid && Node.new(self, oid)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Capybara's Session#send_keys routes to Driver#send_keys; Cuprite's pattern
|
|
59
|
+
# is to fan that out to whatever element currently has focus.
|
|
60
|
+
def send_keys(*keys)
|
|
61
|
+
active_element&.send_keys(*keys)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def find_xpath(selector)
|
|
65
|
+
object_ids = browser.find("xpath", selector)
|
|
66
|
+
object_ids.map { |oid| Node.new(self, oid) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def find_css(selector)
|
|
70
|
+
object_ids = browser.find("css", selector)
|
|
71
|
+
object_ids.map { |oid| Node.new(self, oid) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def evaluate_script(script, *args)
|
|
75
|
+
unwrap_script_result(browser.evaluate(script.strip, *native_args(args)))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def execute_script(script, *args)
|
|
79
|
+
browser.execute(script.strip, *native_args(args))
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def evaluate_async_script(script, *args)
|
|
84
|
+
unwrap_script_result(browser.evaluate_async(script.strip, *native_args(args)))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# -- Cookie Management --
|
|
88
|
+
|
|
89
|
+
def set_cookie(name, value, **options)
|
|
90
|
+
cookie_options = {}
|
|
91
|
+
cookie_options[:domain] = options[:domain] if options[:domain]
|
|
92
|
+
cookie_options[:path] = options[:path] if options[:path]
|
|
93
|
+
cookie_options[:secure] = options[:secure] if options.key?(:secure)
|
|
94
|
+
if options.key?(:httpOnly) || options.key?(:http_only)
|
|
95
|
+
cookie_options[:http_only] =
|
|
96
|
+
options[:httpOnly] || options[:http_only]
|
|
97
|
+
end
|
|
98
|
+
cookie_options[:expires] = options[:expires] if options[:expires]
|
|
99
|
+
|
|
100
|
+
browser.cookies.set(name: name, value: value, **cookie_options)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def clear_cookies
|
|
104
|
+
browser.cookies.clear
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def remove_cookie(name, **)
|
|
108
|
+
browser.cookies.remove(name: name, **)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# -- Frame Support --
|
|
112
|
+
# Passes Node objects (with remote_object_id) to Browser's frame stack.
|
|
113
|
+
# callFunctionOn on the iframe element scopes finding to its contentDocument.
|
|
114
|
+
|
|
115
|
+
def switch_to_frame(frame)
|
|
116
|
+
case frame
|
|
117
|
+
when :top
|
|
118
|
+
browser.clear_frames
|
|
119
|
+
when :parent
|
|
120
|
+
browser.pop_frame
|
|
121
|
+
when Node
|
|
122
|
+
browser.push_frame(frame)
|
|
123
|
+
else
|
|
124
|
+
# Capybara passes a Capybara::Node::Element; extract our driver Node
|
|
125
|
+
browser.push_frame(frame.base)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Capybara::Driver::Base falls back to running these via the top
|
|
130
|
+
# execution context, which always reports the parent document. Resolve
|
|
131
|
+
# them through the iframe element's contentWindow / contentDocument so
|
|
132
|
+
# they reflect the active frame.
|
|
133
|
+
def frame_url
|
|
134
|
+
frame = browser.frame_stack.last
|
|
135
|
+
return browser.current_url unless frame
|
|
136
|
+
|
|
137
|
+
browser.call_function_on(frame.remote_object_id,
|
|
138
|
+
"function() { return this.contentWindow.location.href }")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def frame_title
|
|
142
|
+
frame = browser.frame_stack.last
|
|
143
|
+
return browser.title unless frame
|
|
144
|
+
|
|
145
|
+
browser.call_function_on(frame.remote_object_id,
|
|
146
|
+
"function() { return this.contentDocument.title }")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# -- Modal/Dialog Support --
|
|
150
|
+
|
|
151
|
+
def accept_modal(type, **options, &block)
|
|
152
|
+
browser.accept_modal(type, text: options[:with])
|
|
153
|
+
block&.call
|
|
154
|
+
browser.find_modal(type,
|
|
155
|
+
text: options[:text],
|
|
156
|
+
wait: options.fetch(:wait, browser.options.timeout))
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def dismiss_modal(type, **options, &block)
|
|
160
|
+
browser.dismiss_modal(type)
|
|
161
|
+
block&.call
|
|
162
|
+
browser.find_modal(type,
|
|
163
|
+
text: options[:text],
|
|
164
|
+
wait: options.fetch(:wait, browser.options.timeout))
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# -- Screenshots --
|
|
168
|
+
# Lightpanda has no rendering engine so screenshots are blank,
|
|
169
|
+
# but we handle the call gracefully so Rails' before_teardown
|
|
170
|
+
# (screenshot on failure) doesn't raise NotSupportedByDriverError.
|
|
171
|
+
|
|
172
|
+
def save_screenshot(path, **_options)
|
|
173
|
+
browser.screenshot(path: path)
|
|
174
|
+
rescue BinaryError, BinaryNotFoundError
|
|
175
|
+
# Browser can't start (e.g., version too old) — don't crash teardown
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# -- Lifecycle --
|
|
180
|
+
|
|
181
|
+
def reset!
|
|
182
|
+
browser.clear_frames
|
|
183
|
+
browser.reset_modals
|
|
184
|
+
browser.cookies.clear
|
|
185
|
+
browser.go_to("about:blank")
|
|
186
|
+
rescue StandardError
|
|
187
|
+
@browser&.quit
|
|
188
|
+
@browser = nil
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def quit
|
|
192
|
+
@browser&.quit
|
|
193
|
+
@browser = nil
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def needs_server?
|
|
197
|
+
true
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def wait?
|
|
201
|
+
true
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Expanded error list for Capybara retry logic (Cuprite pattern).
|
|
205
|
+
def invalid_element_errors
|
|
206
|
+
[
|
|
207
|
+
NodeNotFoundError,
|
|
208
|
+
NoExecutionContextError,
|
|
209
|
+
ObsoleteNode,
|
|
210
|
+
MouseEventFailed,
|
|
211
|
+
]
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Pause execution for interactive debugging.
|
|
215
|
+
def pause
|
|
216
|
+
if $stdin.tty?
|
|
217
|
+
warn "\nPaused. Press Enter to continue."
|
|
218
|
+
$stdin.gets
|
|
219
|
+
else
|
|
220
|
+
warn "\nPaused. Send SIGCONT (kill -CONT #{::Process.pid}) to continue."
|
|
221
|
+
trap("CONT") {} # rubocop:disable Lint/EmptyBlock
|
|
222
|
+
::Process.kill("STOP", ::Process.pid)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
# Unwrap arguments before sending to the browser. Capybara::Node::Element wraps
|
|
229
|
+
# our Lightpanda::Node — pull `.base` out so `serialize_argument` can build
|
|
230
|
+
# `{objectId: …}` for the CDP payload. Cuprite's `native_args` pattern.
|
|
231
|
+
def native_args(args)
|
|
232
|
+
args.map { |a| a.is_a?(Capybara::Node::Element) ? a.base : a }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Walk through evaluate-script results turning DOM-node markers (the
|
|
236
|
+
# `{ "__lightpanda_node__" => "..." }` hashes produced by `Browser#unwrap_call_result`)
|
|
237
|
+
# into Lightpanda::Node instances so Capybara can wrap them as elements.
|
|
238
|
+
def unwrap_script_result(value)
|
|
239
|
+
case value
|
|
240
|
+
when Array then value.map { |v| unwrap_script_result(v) }
|
|
241
|
+
when Hash
|
|
242
|
+
if value.size == 1 && value.key?("__lightpanda_node__")
|
|
243
|
+
Node.new(self, value["__lightpanda_node__"])
|
|
244
|
+
else
|
|
245
|
+
value.transform_values { |v| unwrap_script_result(v) }
|
|
246
|
+
end
|
|
247
|
+
else value
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capybara
|
|
4
|
+
module Lightpanda
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
class ProcessTimeoutError < Error; end
|
|
8
|
+
class BinaryNotFoundError < Error; end
|
|
9
|
+
class BinaryError < Error; end
|
|
10
|
+
class UnsupportedPlatformError < Error; end
|
|
11
|
+
|
|
12
|
+
class DeadBrowserError < Error; end
|
|
13
|
+
class TimeoutError < Error; end
|
|
14
|
+
|
|
15
|
+
class BrowserError < Error
|
|
16
|
+
attr_reader :response
|
|
17
|
+
|
|
18
|
+
def initialize(response)
|
|
19
|
+
@response = response
|
|
20
|
+
super(response["message"])
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class JavaScriptError < Error
|
|
25
|
+
attr_reader :class_name, :message
|
|
26
|
+
|
|
27
|
+
def initialize(response)
|
|
28
|
+
@class_name = response.dig("exceptionDetails", "exception", "className")
|
|
29
|
+
@message = response.dig("exceptionDetails", "exception",
|
|
30
|
+
"description") || response.dig("exceptionDetails", "text")
|
|
31
|
+
|
|
32
|
+
super(@message)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class NodeNotFoundError < Error; end
|
|
37
|
+
class NoExecutionContextError < Error; end
|
|
38
|
+
|
|
39
|
+
class ObsoleteNode < Error
|
|
40
|
+
attr_reader :node
|
|
41
|
+
|
|
42
|
+
def initialize(node, message = nil)
|
|
43
|
+
@node = node
|
|
44
|
+
super(message || "Element is no longer attached to the DOM")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class MouseEventFailed < Error
|
|
49
|
+
attr_reader :node, :selector, :position
|
|
50
|
+
|
|
51
|
+
PATTERN = /at position \((\d+),\s*(\d+)\).*selector:\s*(.+)/i
|
|
52
|
+
|
|
53
|
+
def initialize(node, message = nil)
|
|
54
|
+
@node = node
|
|
55
|
+
if message && (match = message.match(PATTERN))
|
|
56
|
+
@position = { x: match[1].to_i, y: match[2].to_i }
|
|
57
|
+
@selector = match[3]
|
|
58
|
+
end
|
|
59
|
+
super(message || "Failed mouse event")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class InvalidSelector < Error
|
|
64
|
+
attr_reader :method, :selector
|
|
65
|
+
|
|
66
|
+
def initialize(message, method = nil, selector = nil)
|
|
67
|
+
@method = method
|
|
68
|
+
@selector = selector
|
|
69
|
+
super(message)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class NoSuchPageError < Error; end
|
|
74
|
+
class StatusError < Error; end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capybara
|
|
4
|
+
module Lightpanda
|
|
5
|
+
# Lightweight metadata view of a CDP frame, populated from
|
|
6
|
+
# Page.frameAttached / Page.frameNavigated / Page.frame{Started,Stopped}Loading
|
|
7
|
+
# events. Mirrors a subset of ferrum's Frame.
|
|
8
|
+
#
|
|
9
|
+
# NOTE: this is purely introspection — Lightpanda's frame loading events
|
|
10
|
+
# are not reliable enough to drive `wait_for_navigation` (#1801, #1832),
|
|
11
|
+
# so the gem still drives navigation waits via Page.loadEventFired with
|
|
12
|
+
# readyState polling. The frame map is useful for diagnostics, listing
|
|
13
|
+
# iframes, and resolving frame metadata (name/URL) without callFunctionOn.
|
|
14
|
+
class Frame
|
|
15
|
+
STATES = %i[started_loading navigated stopped_loading detached].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :id, :parent_id
|
|
18
|
+
attr_accessor :name, :url, :state
|
|
19
|
+
|
|
20
|
+
def initialize(id, parent_id = nil, name: nil, url: nil)
|
|
21
|
+
@id = id
|
|
22
|
+
@parent_id = parent_id
|
|
23
|
+
@name = name
|
|
24
|
+
@url = url
|
|
25
|
+
@state = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def main?
|
|
29
|
+
@parent_id.nil?
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|