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.
@@ -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