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.
- checksums.yaml +4 -4
- data/LICENSE.txt +21 -0
- data/README.md +173 -17
- data/lib/lightpanda/binary.rb +158 -0
- data/lib/lightpanda/browser.rb +212 -0
- data/lib/lightpanda/capybara/driver.rb +113 -0
- data/lib/lightpanda/capybara/node.rb +254 -0
- data/lib/lightpanda/capybara.rb +46 -0
- data/lib/lightpanda/client/subscriber.rb +42 -0
- data/lib/lightpanda/client/web_socket.rb +147 -0
- data/lib/lightpanda/client.rb +121 -0
- data/lib/lightpanda/cookies.rb +47 -0
- data/lib/lightpanda/errors.rb +40 -0
- data/lib/lightpanda/network.rb +75 -0
- data/lib/lightpanda/options.rb +46 -0
- data/lib/lightpanda/process.rb +118 -0
- data/lib/lightpanda/version.rb +1 -1
- data/lib/lightpanda.rb +13 -2
- metadata +15 -1
|
@@ -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
|