lightpanda 0.0.1 → 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,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "capybara"
4
+
5
+ module Lightpanda
6
+ module Capybara
7
+ class Driver < ::Capybara::Driver::Base
8
+ attr_reader :app, :options
9
+
10
+ def initialize(app, options = {})
11
+ super()
12
+
13
+ @app = app
14
+ @options = options
15
+ @browser = nil
16
+ end
17
+
18
+ def browser
19
+ @browser ||= Lightpanda::Browser.new(@options)
20
+ end
21
+
22
+ def visit(url)
23
+ browser.go_to(url)
24
+ end
25
+
26
+ def current_url
27
+ browser.current_url
28
+ end
29
+
30
+ def html
31
+ browser.body
32
+ end
33
+ alias body html
34
+
35
+ def title
36
+ browser.title
37
+ end
38
+
39
+ def find_xpath(selector)
40
+ nodes = browser.evaluate(<<~JS)
41
+ (function() {
42
+ var result = document.evaluate(
43
+ #{selector.inspect},
44
+ document,
45
+ null,
46
+ XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
47
+ null
48
+ );
49
+ var nodes = [];
50
+ for (var i = 0; i < result.snapshotLength; i++) {
51
+ nodes.push(result.snapshotItem(i));
52
+ }
53
+ return nodes;
54
+ })()
55
+ JS
56
+
57
+ wrap_nodes(nodes || [])
58
+ end
59
+
60
+ def find_css(selector)
61
+ count = browser.evaluate("document.querySelectorAll(#{selector.inspect}).length")
62
+
63
+ return [] if count.nil? || count.zero?
64
+
65
+ (0...count).map do |index|
66
+ Node.new(self, { selector: selector, index: index }, index)
67
+ end
68
+ end
69
+
70
+ def evaluate_script(script, *_args)
71
+ browser.evaluate(script)
72
+ end
73
+
74
+ def execute_script(script, *_args)
75
+ browser.execute(script)
76
+ nil
77
+ end
78
+
79
+ def reset!
80
+ browser.go_to("about:blank")
81
+ rescue StandardError
82
+ nil
83
+ end
84
+
85
+ def quit
86
+ @browser&.quit
87
+ @browser = nil
88
+ end
89
+
90
+ def needs_server?
91
+ true
92
+ end
93
+
94
+ def wait?
95
+ true
96
+ end
97
+
98
+ def invalid_element_errors
99
+ [Lightpanda::NodeNotFoundError, Lightpanda::NoExecutionContextError]
100
+ end
101
+
102
+ private
103
+
104
+ def wrap_nodes(nodes)
105
+ return [] unless nodes.is_a?(Array)
106
+
107
+ nodes.map.with_index do |node_data, index|
108
+ Node.new(self, node_data, index)
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lightpanda
4
+ module Capybara
5
+ class Node < ::Capybara::Driver::Node
6
+ attr_reader :selector_info
7
+
8
+ def initialize(driver, native, _index)
9
+ super(driver, native)
10
+
11
+ @selector_info = native # { selector: "h1", index: 0 }
12
+ end
13
+
14
+ def text
15
+ evaluate_on("this.textContent")
16
+ end
17
+
18
+ def visible_text
19
+ evaluate_on("this.innerText")
20
+ end
21
+
22
+ def [](name)
23
+ evaluate_on("this.getAttribute(#{name.to_s.inspect})")
24
+ end
25
+
26
+ def value
27
+ evaluate_on(<<~JS)
28
+ (function(el) {
29
+ if (el.tagName === 'SELECT' && el.multiple) {
30
+ return Array.from(el.selectedOptions).map(o => o.value);
31
+ }
32
+ return el.value;
33
+ })(this)
34
+ JS
35
+ end
36
+
37
+ def style(styles)
38
+ styles.each_with_object({}) do |style, result|
39
+ result[style] = evaluate_on("window.getComputedStyle(this).#{style}")
40
+ end
41
+ end
42
+
43
+ def click(_keys = [], **_options)
44
+ evaluate_on("this.click()")
45
+ end
46
+
47
+ def right_click(_keys = [], **_options)
48
+ evaluate_on(<<~JS)
49
+ this.dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true}))
50
+ JS
51
+ end
52
+
53
+ def double_click(_keys = [], **_options)
54
+ evaluate_on(<<~JS)
55
+ this.dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true}))
56
+ JS
57
+ end
58
+
59
+ def hover
60
+ evaluate_on(<<~JS)
61
+ this.dispatchEvent(new MouseEvent('mouseover', {bubbles: true, cancelable: true}))
62
+ JS
63
+ end
64
+
65
+ def set(value, **_options)
66
+ if tag_name == "input"
67
+ type = self["type"]
68
+ case type
69
+ when "checkbox", "radio"
70
+ if value
71
+ evaluate_on("this.checked = true; this.dispatchEvent(new Event('change', {bubbles: true}))")
72
+ else
73
+ evaluate_on("this.checked = false; this.dispatchEvent(new Event('change', {bubbles: true}))")
74
+ end
75
+ when "file"
76
+ raise NotImplementedError, "File inputs need special handling via CDP. File uploads not yet supported"
77
+ else
78
+ set_text_value(value)
79
+ end
80
+ elsif tag_name == "textarea"
81
+ set_text_value(value)
82
+ elsif self["contenteditable"]
83
+ evaluate_on("this.innerHTML = #{value.to_s.inspect}")
84
+ end
85
+ end
86
+
87
+ def select_option
88
+ evaluate_on(<<~JS)
89
+ this.selected = true;
90
+ var event = new Event('change', {bubbles: true});
91
+ this.parentElement.dispatchEvent(event);
92
+ JS
93
+ end
94
+
95
+ def unselect_option
96
+ select = find_xpath("./ancestor::select")[0]
97
+
98
+ if select && !select["multiple"]
99
+ raise ::Capybara::UnselectNotAllowed, "Cannot unselect option from single select"
100
+ end
101
+
102
+ evaluate_on(<<~JS)
103
+ this.selected = false;
104
+ var event = new Event('change', {bubbles: true});
105
+ this.parentElement.dispatchEvent(event);
106
+ JS
107
+ end
108
+
109
+ def send_keys(*args)
110
+ args.each do |key|
111
+ next unless key.is_a?(String)
112
+
113
+ evaluate_on(<<~JS)
114
+ this.focus();
115
+ this.value += #{key.inspect};
116
+ this.dispatchEvent(new Event('input', {bubbles: true}));
117
+ JS
118
+ end
119
+ end
120
+
121
+ def tag_name
122
+ evaluate_on("this.tagName.toLowerCase()")
123
+ end
124
+
125
+ def visible?
126
+ selector = @selector_info[:selector]
127
+ index = @selector_info[:index]
128
+
129
+ js = <<~JS
130
+ (function() {
131
+ var el = document.querySelectorAll(#{selector.inspect})[#{index}];
132
+ if (!el) return false;
133
+ var style = window.getComputedStyle(el);
134
+ var isVisible = style.display !== 'none' &&
135
+ style.visibility !== 'hidden' &&
136
+ el.offsetParent !== null;
137
+ return isVisible;
138
+ })()
139
+ JS
140
+
141
+ driver.browser.evaluate(js)
142
+ end
143
+
144
+ def checked?
145
+ evaluate_on("this.checked")
146
+ end
147
+
148
+ def selected?
149
+ evaluate_on("this.selected")
150
+ end
151
+
152
+ def disabled?
153
+ evaluate_on("this.disabled")
154
+ end
155
+
156
+ def readonly?
157
+ evaluate_on("this.readOnly")
158
+ end
159
+
160
+ def multiple?
161
+ evaluate_on("this.multiple")
162
+ end
163
+
164
+ def path
165
+ evaluate_on(<<~JS)
166
+ (function(el) {
167
+ if (!el) return '';
168
+ var path = [];
169
+
170
+ while (el && el.nodeType === Node.ELEMENT_NODE) {
171
+ var selector = el.nodeName.toLowerCase();
172
+ if (el.id) {
173
+ selector += '#' + el.id;
174
+ path.unshift(selector);
175
+ break;
176
+ } else {
177
+ var sibling = el;
178
+ var nth = 1;
179
+ while (sibling = sibling.previousElementSibling) {
180
+ if (sibling.nodeName.toLowerCase() === selector) nth++;
181
+ }
182
+ if (nth > 1) selector += ':nth-of-type(' + nth + ')';
183
+ }
184
+ path.unshift(selector);
185
+ el = el.parentNode;
186
+ }
187
+ return path.join(' > ');
188
+ })(this)
189
+ JS
190
+ end
191
+
192
+ def find_xpath(selector)
193
+ driver.find_xpath(selector)
194
+ end
195
+
196
+ def find_css(selector)
197
+ count = evaluate_on("this.querySelectorAll(#{selector.inspect}).length")
198
+
199
+ return [] if count.nil? || count.zero?
200
+
201
+ (0...count).map do |idx|
202
+ child_selector = "#{element_selector} #{selector}"
203
+
204
+ Node.new(driver, { selector: child_selector, index: idx }, idx)
205
+ end
206
+ end
207
+
208
+ def ==(other)
209
+ other.is_a?(self.class) && selector_info == other.selector_info
210
+ end
211
+
212
+ private
213
+
214
+ def element_selector
215
+ if @selector_info.is_a?(Hash)
216
+ selector = @selector_info[:selector]
217
+ index = @selector_info[:index]
218
+
219
+ index.positive? ? "#{selector}:nth-of-type(#{index + 1})" : selector
220
+ else
221
+ "*"
222
+ end
223
+ end
224
+
225
+ def set_text_value(value) # rubocop:disable Naming/AccessorMethodName
226
+ evaluate_on(<<~JS)
227
+ this.focus();
228
+ this.value = #{value.to_s.inspect};
229
+ this.dispatchEvent(new Event('input', {bubbles: true}));
230
+ this.dispatchEvent(new Event('change', {bubbles: true}));
231
+ JS
232
+ end
233
+
234
+ def evaluate_on(expression)
235
+ selector = @selector_info[:selector]
236
+ index = @selector_info[:index]
237
+
238
+ expr = expression.strip
239
+ expr = "return #{expr}" unless expr.start_with?("return ") || expr.include?("\n")
240
+
241
+ full_expression = <<~JS
242
+ (function() {
243
+ var elements = document.querySelectorAll(#{selector.inspect});
244
+ var el = elements[#{index}];
245
+ if (!el) return null;
246
+ return (function() { #{expr} }).call(el);
247
+ })()
248
+ JS
249
+
250
+ driver.browser.evaluate(full_expression)
251
+ end
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "capybara"
4
+ require "lightpanda"
5
+
6
+ require_relative "capybara/driver"
7
+ require_relative "capybara/node"
8
+
9
+ module Lightpanda
10
+ module Capybara
11
+ class << self
12
+ def configure
13
+ yield(configuration) if block_given?
14
+ end
15
+
16
+ def configuration
17
+ @configuration ||= Configuration.new
18
+ end
19
+ end
20
+
21
+ class Configuration
22
+ attr_accessor :host, :port, :timeout, :headless, :browser_path
23
+
24
+ def initialize
25
+ @host = "127.0.0.1"
26
+ @port = 9222
27
+ @timeout = 5
28
+ @headless = true
29
+ @browser_path = nil
30
+ end
31
+
32
+ def to_h
33
+ {
34
+ host: host,
35
+ port: port,
36
+ timeout: timeout,
37
+ browser_path: browser_path
38
+ }
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ Capybara.register_driver(:lightpanda) do |app|
45
+ Lightpanda::Capybara::Driver.new(app, Lightpanda::Capybara.configuration.to_h)
46
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lightpanda
4
+ class Client
5
+ class Subscriber
6
+ def initialize
7
+ @subscriptions = Hash.new { |h, k| h[k] = [] }
8
+ @mutex = Mutex.new
9
+ end
10
+
11
+ def subscribe(event, &block)
12
+ @mutex.synchronize do
13
+ @subscriptions[event] << block
14
+ end
15
+ end
16
+
17
+ def unsubscribe(event, block = nil)
18
+ @mutex.synchronize do
19
+ if block
20
+ @subscriptions[event].delete(block)
21
+ else
22
+ @subscriptions.delete(event)
23
+ end
24
+ end
25
+ end
26
+
27
+ def dispatch(event, params)
28
+ callbacks = @mutex.synchronize { @subscriptions[event].dup }
29
+
30
+ callbacks.each { |callback| callback.call(params) }
31
+ end
32
+
33
+ def subscribed?(event)
34
+ @mutex.synchronize { @subscriptions.key?(event) && @subscriptions[event].any? }
35
+ end
36
+
37
+ def clear
38
+ @mutex.synchronize { @subscriptions.clear }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+ require "websocket/driver"
6
+
7
+ module Lightpanda
8
+ class Client
9
+ class WebSocket
10
+ attr_reader :url, :messages
11
+
12
+ def initialize(url, options)
13
+ @url = url
14
+ @options = options
15
+ @socket = nil
16
+ @driver = nil
17
+ @thread = nil
18
+ @status = :closed
19
+ @messages = Queue.new
20
+
21
+ connect
22
+ end
23
+
24
+ def send_message(message)
25
+ raise DeadBrowserError, "WebSocket is not open" unless @status == :open
26
+
27
+ @driver.text(message)
28
+ end
29
+
30
+ def close
31
+ return if @status == :closed
32
+
33
+ @status = :closing
34
+ @driver&.close
35
+ @thread&.kill
36
+ @socket&.close
37
+ @status = :closed
38
+ end
39
+
40
+ def closed?
41
+ @status == :closed
42
+ end
43
+
44
+ def open?
45
+ @status == :open
46
+ end
47
+
48
+ def write(data)
49
+ @socket.write(data)
50
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError
51
+ @status = :closed
52
+ end
53
+
54
+ private
55
+
56
+ def connect
57
+ uri = URI.parse(@url)
58
+
59
+ @socket = connect_with_retry(uri.host, uri.port)
60
+ @driver = ::WebSocket::Driver.client(self)
61
+
62
+ setup_callbacks
63
+
64
+ @driver.start
65
+
66
+ read_handshake_response
67
+ start_reader_thread
68
+ end
69
+
70
+ def connect_with_retry(host, port, retries: 10, delay: 0.1)
71
+ retries.times do |i|
72
+ return TCPSocket.new(host, port)
73
+ rescue Errno::ECONNREFUSED
74
+ raise if i == retries - 1
75
+
76
+ sleep delay
77
+ end
78
+ end
79
+
80
+ def setup_callbacks
81
+ @driver.on(:open) do
82
+ @status = :open
83
+ end
84
+
85
+ @driver.on(:message) do |event|
86
+ message = parse_message(event.data)
87
+ @messages << message if message
88
+ end
89
+
90
+ @driver.on(:close) do
91
+ @status = :closed
92
+ end
93
+
94
+ @driver.on(:error) do |event|
95
+ @status = :error
96
+
97
+ raise DeadBrowserError, "WebSocket error: #{event.message}"
98
+ end
99
+ end
100
+
101
+ def start_reader_thread
102
+ @thread = Thread.new do
103
+ Thread.current.abort_on_exception = true
104
+
105
+ loop do
106
+ break if @status == :closed || @status == :closing
107
+
108
+ begin
109
+ next unless @socket.wait_readable(0.1)
110
+
111
+ data = @socket.readpartial(4096)
112
+ @driver.parse(data)
113
+ rescue IOError
114
+ @status = :closed
115
+ break
116
+ end
117
+ end
118
+ end
119
+ end
120
+
121
+ def read_handshake_response
122
+ started_at = Time.now
123
+
124
+ while @status != :open && Time.now - started_at < @options.timeout
125
+ next unless @socket.wait_readable(0.1)
126
+
127
+ begin
128
+ data = @socket.readpartial(4096)
129
+ @driver.parse(data)
130
+ rescue EOFError
131
+ raise DeadBrowserError, "Connection closed during handshake"
132
+ end
133
+ end
134
+
135
+ raise TimeoutError, "WebSocket connection timeout" unless @status == :open
136
+ end
137
+
138
+ def parse_message(data)
139
+ JSON.parse(data)
140
+ rescue JSON::ParserError => e
141
+ warn "Failed to parse WebSocket message: #{e.message}"
142
+
143
+ nil
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "concurrent-ruby"
5
+
6
+ require_relative "client/web_socket"
7
+ require_relative "client/subscriber"
8
+
9
+ module Lightpanda
10
+ class Client
11
+ attr_reader :ws_url, :options
12
+
13
+ def initialize(ws_url, options)
14
+ @ws_url = ws_url
15
+ @options = options
16
+ @ws = WebSocket.new(ws_url, options)
17
+ @command_id = 0
18
+ @pendings = Concurrent::Hash.new
19
+ @subscriber = Subscriber.new
20
+ @mutex = Mutex.new
21
+
22
+ start_message_thread
23
+ end
24
+
25
+ def command(method, params = {}, async: false, session_id: nil)
26
+ message = build_message(method, params, session_id: session_id)
27
+
28
+ if async
29
+ @ws.send_message(JSON.generate(message))
30
+ return true
31
+ end
32
+
33
+ pending = Concurrent::IVar.new
34
+ @pendings[message[:id]] = pending
35
+
36
+ @ws.send_message(JSON.generate(message))
37
+
38
+ response = pending.value!(@options.timeout)
39
+ raise TimeoutError, "Command #{method} timed out after #{@options.timeout}s" if response.nil?
40
+
41
+ handle_error(response) if response["error"]
42
+
43
+ response["result"]
44
+ ensure
45
+ @pendings.delete(message[:id]) if message
46
+ end
47
+
48
+ def on(event, &)
49
+ @subscriber.subscribe(event, &)
50
+ end
51
+
52
+ def off(event, block = nil)
53
+ @subscriber.unsubscribe(event, block)
54
+ end
55
+
56
+ def close
57
+ @running = false
58
+ @message_thread&.kill
59
+ @ws&.close
60
+ @subscriber.clear
61
+ @pendings.clear
62
+ end
63
+
64
+ def closed?
65
+ @ws.closed?
66
+ end
67
+
68
+ private
69
+
70
+ def build_message(method, params, session_id: nil)
71
+ id = next_command_id
72
+ message = { id: id, method: method, params: params }
73
+ message[:sessionId] = session_id if session_id
74
+
75
+ message
76
+ end
77
+
78
+ def next_command_id
79
+ @mutex.synchronize { @command_id += 1 }
80
+ end
81
+
82
+ def start_message_thread
83
+ @running = true
84
+
85
+ @message_thread = Thread.new do
86
+ Thread.current.abort_on_exception = true
87
+
88
+ while @running && !@ws.closed?
89
+ message = @ws.messages.pop
90
+ next unless message
91
+
92
+ handle_message(message)
93
+ end
94
+ end
95
+ end
96
+
97
+ def handle_message(message)
98
+ if message["id"]
99
+ pending = @pendings[message["id"]]
100
+ pending&.set(message)
101
+ elsif message["method"]
102
+ @subscriber.dispatch(message["method"], message["params"])
103
+ end
104
+ end
105
+
106
+ def handle_error(response)
107
+ error = response["error"]
108
+ message = error["message"]
109
+ error["code"]
110
+
111
+ case message
112
+ when /No node with given id found/i
113
+ raise NodeNotFoundError, message
114
+ when /Cannot find context with specified id/i, /Execution context was destroyed/i
115
+ raise NoExecutionContextError, message
116
+ else
117
+ raise BrowserError, error
118
+ end
119
+ end
120
+ end
121
+ end