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,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Lightpanda
5
+ class Client
6
+ class Subscriber
7
+ def initialize
8
+ @subscriptions = Hash.new { |h, k| h[k] = [] }
9
+ @mutex = Mutex.new
10
+ end
11
+
12
+ def subscribe(event, &block)
13
+ @mutex.synchronize do
14
+ @subscriptions[event] << block
15
+ end
16
+ end
17
+
18
+ def unsubscribe(event, block = nil)
19
+ @mutex.synchronize do
20
+ if block
21
+ @subscriptions[event].delete(block)
22
+ else
23
+ @subscriptions.delete(event)
24
+ end
25
+ end
26
+ end
27
+
28
+ def dispatch(event, params)
29
+ callbacks = @mutex.synchronize { @subscriptions[event].dup }
30
+
31
+ callbacks.each { |callback| callback.call(params) }
32
+ end
33
+
34
+ def subscribed?(event)
35
+ @mutex.synchronize { @subscriptions.key?(event) && @subscriptions[event].any? }
36
+ end
37
+
38
+ def clear
39
+ @mutex.synchronize { @subscriptions.clear }
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+ require "websocket/driver"
6
+
7
+ module Capybara
8
+ module Lightpanda
9
+ class Client
10
+ class WebSocket
11
+ attr_reader :url, :messages
12
+
13
+ def initialize(url, options)
14
+ @url = url
15
+ @options = options
16
+ @logger = options.logger
17
+ @socket = nil
18
+ @driver = nil
19
+ @thread = nil
20
+ @status = :closed
21
+ @messages = Queue.new
22
+ @driver_mutex = Mutex.new
23
+
24
+ connect
25
+ end
26
+
27
+ def send_message(message)
28
+ raise DeadBrowserError, "WebSocket is not open" unless @status == :open
29
+
30
+ @logger&.puts("\n\n▶ #{@logger.elapsed_time} #{message}")
31
+ @driver_mutex.synchronize { @driver.text(message) }
32
+ end
33
+
34
+ def close
35
+ return if @status == :closed
36
+
37
+ @status = :closing
38
+ @messages.close
39
+ @driver&.close
40
+ @thread&.join(1) || @thread&.kill
41
+ @socket&.close
42
+ @status = :closed
43
+ end
44
+
45
+ def closed?
46
+ @status == :closed
47
+ end
48
+
49
+ def open?
50
+ @status == :open
51
+ end
52
+
53
+ def write(data)
54
+ @socket.write(data)
55
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError
56
+ @status = :closed
57
+ @messages.close
58
+ end
59
+
60
+ private
61
+
62
+ def connect
63
+ uri = URI.parse(@url)
64
+
65
+ @socket = connect_with_retry(uri.host, uri.port)
66
+ @driver = ::WebSocket::Driver.client(self)
67
+
68
+ setup_callbacks
69
+
70
+ @driver.start
71
+
72
+ read_handshake_response
73
+ start_reader_thread
74
+ end
75
+
76
+ def connect_with_retry(host, port, retries: 10, delay: 0.1)
77
+ retries.times do |i|
78
+ return TCPSocket.new(host, port)
79
+ rescue Errno::ECONNREFUSED
80
+ raise if i == retries - 1
81
+
82
+ sleep delay
83
+ end
84
+ end
85
+
86
+ def setup_callbacks
87
+ @driver.on(:open) do
88
+ @status = :open
89
+ end
90
+
91
+ @driver.on(:message) do |event|
92
+ @logger&.puts(" ◀ #{@logger.elapsed_time} #{event.data}\n")
93
+ message = parse_message(event.data)
94
+ @messages << message if message
95
+ rescue ClosedQueueError
96
+ # Queue was closed during shutdown
97
+ end
98
+
99
+ @driver.on(:close) do
100
+ @status = :closed
101
+ @messages.close
102
+ end
103
+
104
+ @driver.on(:error) do |event|
105
+ @status = :error
106
+ @messages.close
107
+
108
+ raise DeadBrowserError, "WebSocket error: #{event.message}"
109
+ end
110
+ end
111
+
112
+ def start_reader_thread
113
+ @thread = Thread.new do
114
+ Thread.current.abort_on_exception = true
115
+
116
+ loop do
117
+ break if @status == :closed || @status == :closing
118
+
119
+ begin
120
+ next unless @socket.wait_readable(0.1)
121
+
122
+ data = @socket.readpartial(4096)
123
+ @driver_mutex.synchronize { @driver.parse(data) }
124
+ rescue Errno::ECONNRESET, Errno::EPIPE, IOError
125
+ @status = :closed
126
+ @messages.close
127
+ break
128
+ end
129
+ end
130
+ end
131
+ end
132
+
133
+ def read_handshake_response
134
+ started_at = Time.now
135
+
136
+ while @status != :open && Time.now - started_at < @options.timeout
137
+ next unless @socket.wait_readable(0.1)
138
+
139
+ begin
140
+ data = @socket.readpartial(4096)
141
+ @driver.parse(data)
142
+ rescue EOFError
143
+ raise DeadBrowserError, "Connection closed during handshake"
144
+ end
145
+ end
146
+
147
+ raise TimeoutError, "WebSocket connection timeout" unless @status == :open
148
+ end
149
+
150
+ def parse_message(data)
151
+ JSON.parse(data, max_nesting: false)
152
+ rescue JSON::ParserError => e
153
+ warn "Failed to parse WebSocket message: #{e.message}"
154
+
155
+ nil
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,124 @@
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 Capybara
10
+ module Lightpanda
11
+ class Client
12
+ attr_reader :ws_url, :options
13
+
14
+ def initialize(ws_url, options)
15
+ @ws_url = ws_url
16
+ @options = options
17
+ @ws = WebSocket.new(ws_url, options)
18
+ @command_id = 0
19
+ @pendings = Concurrent::Hash.new
20
+ @subscriber = Subscriber.new
21
+ @mutex = Mutex.new
22
+
23
+ start_message_thread
24
+ end
25
+
26
+ def command(method, params = {}, async: false, session_id: nil, timeout: nil)
27
+ message = build_message(method, params, session_id: session_id)
28
+
29
+ if async
30
+ @ws.send_message(JSON.generate(message))
31
+ return true
32
+ end
33
+
34
+ pending = Concurrent::IVar.new
35
+ @pendings[message[:id]] = pending
36
+
37
+ @ws.send_message(JSON.generate(message))
38
+
39
+ effective_timeout = timeout || @options.timeout
40
+ response = pending.value!(effective_timeout)
41
+
42
+ if response.nil?
43
+ raise DeadBrowserError, "Browser closed during #{method}" if @ws.closed?
44
+
45
+ raise TimeoutError, "Command #{method} timed out after #{effective_timeout}s"
46
+ end
47
+
48
+ handle_error(response) if response["error"]
49
+
50
+ response["result"]
51
+ ensure
52
+ @pendings.delete(message[:id]) if message
53
+ end
54
+
55
+ def on(event, &)
56
+ @subscriber.subscribe(event, &)
57
+ end
58
+
59
+ def off(event, block = nil)
60
+ @subscriber.unsubscribe(event, block)
61
+ end
62
+
63
+ def close
64
+ @ws&.close
65
+ @message_thread&.join(1) || @message_thread&.kill
66
+ @subscriber.clear
67
+ @pendings.clear
68
+ end
69
+
70
+ def closed?
71
+ @ws.closed?
72
+ end
73
+
74
+ private
75
+
76
+ def build_message(method, params, session_id: nil)
77
+ id = next_command_id
78
+ message = { id: id, method: method, params: params }
79
+ message[:sessionId] = session_id if session_id
80
+
81
+ message
82
+ end
83
+
84
+ def next_command_id
85
+ @mutex.synchronize { @command_id += 1 }
86
+ end
87
+
88
+ def start_message_thread
89
+ @message_thread = Thread.new do
90
+ Thread.current.abort_on_exception = true
91
+
92
+ while (message = @ws.messages.pop)
93
+ handle_message(message)
94
+ end
95
+ end
96
+ end
97
+
98
+ def handle_message(message)
99
+ if message["id"]
100
+ pending = @pendings[message["id"]]
101
+ pending&.set(message)
102
+ elsif message["method"]
103
+ @subscriber.dispatch(message["method"], message["params"])
104
+ end
105
+ end
106
+
107
+ def handle_error(response)
108
+ error = response["error"]
109
+ message = error["message"]
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,
115
+ /Execution context was destroyed/i,
116
+ /Cannot find default execution context/i
117
+ raise NoExecutionContextError, message
118
+ else
119
+ raise BrowserError, error
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Capybara
6
+ module Lightpanda
7
+ class Cookies
8
+ # Typed wrapper around a CDP cookie hash so callers don't have to remember
9
+ # the camelCase keys (`httpOnly`, `sameSite`, …) the CDP returns. Mirrors
10
+ # ferrum's Cookies::Cookie. `attributes` exposes the raw hash for callers
11
+ # that still need it (e.g. YAML serialization in store/load).
12
+ class Cookie
13
+ attr_reader :attributes
14
+
15
+ def initialize(attributes)
16
+ @attributes = attributes
17
+ end
18
+
19
+ def name = attributes["name"]
20
+ def value = attributes["value"]
21
+ def domain = attributes["domain"]
22
+ def path = attributes["path"]
23
+ def samesite = attributes["sameSite"]
24
+ def size = attributes["size"]
25
+ def secure? = attributes["secure"]
26
+ def httponly? = attributes["httpOnly"]
27
+ def session? = attributes["session"]
28
+
29
+ alias same_site samesite
30
+ alias http_only? httponly?
31
+
32
+ # Time when the cookie expires, or nil for session cookies (CDP reports
33
+ # session cookies with `expires: -1`).
34
+ def expires
35
+ exp = attributes["expires"]
36
+ Time.at(exp) if exp.is_a?(Numeric) && exp.positive?
37
+ end
38
+
39
+ def ==(other)
40
+ other.is_a?(self.class) && other.attributes == attributes
41
+ end
42
+
43
+ alias eql? ==
44
+
45
+ def hash
46
+ attributes.hash
47
+ end
48
+
49
+ def to_h
50
+ attributes
51
+ end
52
+ end
53
+
54
+ attr_reader :browser
55
+
56
+ def initialize(browser)
57
+ @browser = browser
58
+ end
59
+
60
+ def all
61
+ result = browser.command("Network.getCookies")
62
+ (result["cookies"] || []).map { |c| Cookie.new(c) }
63
+ end
64
+
65
+ def get(name)
66
+ all.find { |cookie| cookie.name == name }
67
+ end
68
+
69
+ def set(name:, value:, domain: nil, path: "/", secure: false, http_only: false, expires: nil)
70
+ params = {
71
+ name: name,
72
+ value: value,
73
+ path: path,
74
+ secure: secure,
75
+ httpOnly: http_only,
76
+ }
77
+
78
+ params[:domain] = domain if domain
79
+ params[:expires] = expires.to_i if expires
80
+
81
+ browser.command("Network.setCookie", **params)
82
+ end
83
+
84
+ def remove(name:, domain: nil, path: "/")
85
+ params = { name: name, path: path }
86
+ params[:domain] = domain if domain
87
+
88
+ browser.command("Network.deleteCookies", **params)
89
+ end
90
+
91
+ # Lightpanda gotchas observed on current nightly:
92
+ # * `Network.clearBrowserCookies` raises `InvalidParams` (so it does NOT
93
+ # clear anything despite the upstream PR #1821 / >= v0.2.6 note).
94
+ # * `Network.getCookies` (no `urls` param) is scoped to the CURRENT
95
+ # page's origin — cookies set on previously-visited domains are
96
+ # invisible from a different page.
97
+ # * `Network.getCookies` on `about:blank` raises `InvalidDomain`.
98
+ #
99
+ # To honor Capybara's `reset_session! removes ALL cookies` contract
100
+ # across multiple test domains (e.g. `localhost` AND `127.0.0.1`), we
101
+ # iterate every origin Browser has navigated to and per-origin call
102
+ # `Network.getCookies(urls: [origin])` then `Network.deleteCookies(url:)`.
103
+ # The bulk-clear call is still attempted first as a fast path / future-
104
+ # proofing for when upstream fixes it.
105
+ def clear
106
+ begin
107
+ browser.command("Network.clearBrowserCookies")
108
+ rescue BrowserError, TimeoutError, StandardError
109
+ # InvalidParams on current nightly; pre-v0.2.6 used to crash the
110
+ # WebSocket. Either way, fall through to per-origin sweep.
111
+ end
112
+
113
+ sweep_visited_origins
114
+ end
115
+
116
+ # Persist all current cookies to a YAML file (ferrum parity).
117
+ # Returns the number of bytes written.
118
+ def store(path = "cookies.yml")
119
+ File.write(path, all.map(&:to_h).to_yaml)
120
+ end
121
+
122
+ # Load cookies from a YAML file produced by `store` and re-set them.
123
+ # CDP requires either domain or url for each cookie; entries from `store`
124
+ # already include domain, so they round-trip cleanly. Returns true on
125
+ # success (intentionally not a predicate — mirrors ferrum's API).
126
+ def load(path = "cookies.yml") # rubocop:disable Naming/PredicateMethod
127
+ cookies = YAML.load_file(path)
128
+ cookies.each { |c| restore_cookie(c) }
129
+ true
130
+ end
131
+
132
+ private
133
+
134
+ def sweep_visited_origins
135
+ origins = browser.visited_origins.to_a
136
+ return if origins.empty?
137
+
138
+ result = browser.command("Network.getCookies", urls: origins)
139
+ cookies = result["cookies"] || []
140
+ cookies.each do |cookie|
141
+ # CDP needs either domain or url; build a url from the cookie's
142
+ # own domain+path so we don't mismatch (e.g. cookie domain `.x.test`
143
+ # against an origin we tracked as `https://x.test:443`).
144
+ url = cookie_url(cookie)
145
+ params = { name: cookie["name"] }
146
+ params[:url] = url if url
147
+ params[:domain] = cookie["domain"] unless url
148
+ browser.command("Network.deleteCookies", **params)
149
+ end
150
+ rescue StandardError
151
+ # Connection lost or origin no longer valid; nothing more to do.
152
+ end
153
+
154
+ def cookie_url(cookie)
155
+ domain = cookie["domain"].to_s.sub(/\A\./, "")
156
+ return nil if domain.empty?
157
+
158
+ scheme = cookie["secure"] ? "https" : "http"
159
+ path = cookie["path"] || "/"
160
+ "#{scheme}://#{domain}#{path}"
161
+ end
162
+
163
+ # set() takes keyword args, but YAML round-trips give us a hash with the
164
+ # raw CDP keys (camelCase). Normalize and forward.
165
+ def restore_cookie(hash)
166
+ attrs = hash.transform_keys(&:to_s)
167
+ params = {
168
+ name: attrs["name"],
169
+ value: attrs["value"],
170
+ path: attrs["path"] || "/",
171
+ secure: attrs["secure"] || false,
172
+ http_only: attrs["httpOnly"] || false,
173
+ }
174
+ params[:domain] = attrs["domain"] if attrs["domain"]
175
+ exp = attrs["expires"]
176
+ params[:expires] = Time.at(exp) if exp.is_a?(Numeric) && exp.positive?
177
+ set(**params)
178
+ end
179
+ end
180
+ end
181
+ end