lightpanda 0.0.1 → 0.1.1

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,120 @@
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 = nil if @browser && !browser_alive?
20
+ @browser ||= Lightpanda::Browser.new(@options)
21
+ end
22
+
23
+ def browser_alive?
24
+ @browser.client && !@browser.client.closed?
25
+ rescue StandardError
26
+ false
27
+ end
28
+
29
+ def visit(url)
30
+ browser.go_to(url)
31
+ end
32
+
33
+ def current_url
34
+ browser.current_url
35
+ end
36
+
37
+ def html
38
+ browser.body
39
+ end
40
+ alias body html
41
+
42
+ def title
43
+ browser.title
44
+ end
45
+
46
+ def find_xpath(selector)
47
+ nodes = browser.evaluate(<<~JS)
48
+ (function() {
49
+ var result = document.evaluate(
50
+ #{selector.inspect},
51
+ document,
52
+ null,
53
+ XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
54
+ null
55
+ );
56
+ var nodes = [];
57
+ for (var i = 0; i < result.snapshotLength; i++) {
58
+ nodes.push(result.snapshotItem(i));
59
+ }
60
+ return nodes;
61
+ })()
62
+ JS
63
+
64
+ wrap_nodes(nodes || [])
65
+ end
66
+
67
+ def find_css(selector)
68
+ count = browser.evaluate("document.querySelectorAll(#{selector.inspect}).length")
69
+
70
+ return [] if count.nil? || count.zero?
71
+
72
+ (0...count).map do |index|
73
+ Node.new(self, { selector: selector, index: index }, index)
74
+ end
75
+ end
76
+
77
+ def evaluate_script(script, *_args)
78
+ browser.evaluate(script)
79
+ end
80
+
81
+ def execute_script(script, *_args)
82
+ browser.execute(script)
83
+ nil
84
+ end
85
+
86
+ def reset!
87
+ browser.go_to("about:blank")
88
+ rescue StandardError
89
+ nil
90
+ end
91
+
92
+ def quit
93
+ @browser&.quit
94
+ @browser = nil
95
+ end
96
+
97
+ def needs_server?
98
+ true
99
+ end
100
+
101
+ def wait?
102
+ true
103
+ end
104
+
105
+ def invalid_element_errors
106
+ [Lightpanda::NodeNotFoundError, Lightpanda::NoExecutionContextError]
107
+ end
108
+
109
+ private
110
+
111
+ def wrap_nodes(nodes)
112
+ return [] unless nodes.is_a?(Array)
113
+
114
+ nodes.map.with_index do |node_data, index|
115
+ Node.new(self, node_data, index)
116
+ end
117
+ end
118
+ end
119
+ end
120
+ 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