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,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "logger"
4
+
5
+ module Capybara
6
+ module Lightpanda
7
+ class Options
8
+ DEFAULT_TIMEOUT = ENV.fetch("LIGHTPANDA_DEFAULT_TIMEOUT", 15).to_i
9
+ DEFAULT_PROCESS_TIMEOUT = ENV.fetch("LIGHTPANDA_PROCESS_TIMEOUT", 10).to_i
10
+ DEFAULT_HOST = "127.0.0.1"
11
+ DEFAULT_PORT = 9222
12
+ DEFAULT_WINDOW_SIZE = [1024, 768].freeze
13
+
14
+ attr_accessor :host, :port, :timeout, :process_timeout, :window_size, :browser_path, :headless, :logger
15
+ attr_writer :ws_url
16
+
17
+ def initialize(options = {})
18
+ @host = options.fetch(:host, DEFAULT_HOST)
19
+ @port = options.fetch(:port, DEFAULT_PORT)
20
+ @timeout = options.fetch(:timeout, DEFAULT_TIMEOUT)
21
+ @process_timeout = options.fetch(:process_timeout, DEFAULT_PROCESS_TIMEOUT)
22
+ @window_size = options.fetch(:window_size, DEFAULT_WINDOW_SIZE)
23
+ @browser_path = options[:browser_path]
24
+ @headless = options.fetch(:headless, true)
25
+ @ws_url = options[:ws_url]
26
+ @logger = parse_logger(options[:logger])
27
+ end
28
+
29
+ def ws_url
30
+ @ws_url || "ws://#{host}:#{port}/"
31
+ end
32
+
33
+ def ws_url?
34
+ !@ws_url.nil?
35
+ end
36
+
37
+ def to_h
38
+ h = {
39
+ host: host,
40
+ port: port,
41
+ timeout: timeout,
42
+ process_timeout: process_timeout,
43
+ window_size: window_size,
44
+ browser_path: browser_path,
45
+ headless: headless,
46
+ logger: logger,
47
+ }
48
+ h[:ws_url] = @ws_url if @ws_url
49
+ h
50
+ end
51
+
52
+ private
53
+
54
+ def parse_logger(logger)
55
+ return logger if logger.is_a?(Capybara::Lightpanda::Logger)
56
+ return Capybara::Lightpanda::Logger.new(logger) if logger
57
+ return Capybara::Lightpanda::Logger.new($stdout.tap { |s| s.sync = true }) if ENV["LIGHTPANDA_DEBUG"]
58
+
59
+ Capybara::Lightpanda::Logger.new
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Lightpanda
5
+ class Process
6
+ READY_PATTERN = /server running.*address=(\d+\.\d+\.\d+\.\d+:\d+)/
7
+ ADDRESS_IN_USE_PATTERN = /err=AddressInUse/
8
+
9
+ # First nightly with Page.addScriptToEvaluateOnNewDocument (PR #1993, merged 2026-03-30).
10
+ # The gem relies on this for XPath polyfill auto-injection.
11
+ MINIMUM_NIGHTLY_BUILD = 5267
12
+
13
+ attr_reader :pid, :ws_url, :version, :nightly_build
14
+
15
+ def initialize(options)
16
+ @options = options
17
+ @pid = nil
18
+ @ws_url = nil
19
+ @version = nil
20
+ @nightly_build = nil
21
+ @stdout_r = nil
22
+ @stdout_w = nil
23
+ @stderr_r = nil
24
+ @stderr_w = nil
25
+ end
26
+
27
+ def start
28
+ binary_path = @options.browser_path || Binary.find_or_download
29
+
30
+ raise BinaryNotFoundError, "Lightpanda binary not found" unless binary_path
31
+
32
+ check_minimum_version(binary_path)
33
+ attempt_start(binary_path)
34
+ rescue ProcessTimeoutError => e
35
+ raise unless e.message.include?("already in use")
36
+
37
+ kill_process_on_port(@options.port)
38
+ attempt_start(binary_path)
39
+ end
40
+
41
+ def stop
42
+ return unless @pid
43
+
44
+ begin
45
+ ::Process.kill("TERM", -@pid) # Kill process group
46
+ rescue Errno::ESRCH, Errno::EPERM
47
+ # Process group already dead, try direct
48
+ begin
49
+ ::Process.kill("TERM", @pid)
50
+ rescue Errno::ESRCH
51
+ # Process already dead
52
+ end
53
+ end
54
+
55
+ begin
56
+ ::Process.wait(@pid)
57
+ rescue Errno::ECHILD
58
+ # Already reaped
59
+ end
60
+
61
+ cleanup_pipes
62
+ @pid = nil
63
+ end
64
+
65
+ def alive?
66
+ return false unless @pid
67
+
68
+ ::Process.kill(0, @pid)
69
+ true
70
+ rescue Errno::ESRCH, Errno::EPERM
71
+ false
72
+ end
73
+
74
+ private
75
+
76
+ def check_minimum_version(binary_path)
77
+ stdout, = Open3.capture3(binary_path, "version")
78
+ @version = stdout.strip
79
+ @nightly_build = @version[/nightly\.(\d+)/, 1]&.to_i
80
+
81
+ return if @nightly_build && @nightly_build >= MINIMUM_NIGHTLY_BUILD
82
+
83
+ raise BinaryError,
84
+ "Lightpanda #{@version} is too old. " \
85
+ "This gem requires nightly build >= #{MINIMUM_NIGHTLY_BUILD} " \
86
+ "(Page.addScriptToEvaluateOnNewDocument support). " \
87
+ "Update: curl -sL https://github.com/lightpanda-io/browser/releases/download/nightly/" \
88
+ "#{Binary.platform_binary} -o #{binary_path} && chmod +x #{binary_path}"
89
+ rescue Errno::ENOENT
90
+ # Binary not runnable — let attempt_start handle it
91
+ end
92
+
93
+ def attempt_start(binary_path)
94
+ @stdout_r, @stdout_w = IO.pipe
95
+ @stderr_r, @stderr_w = IO.pipe
96
+
97
+ @pid = spawn_process(binary_path)
98
+ register_finalizer(@pid)
99
+
100
+ @stdout_w.close
101
+ @stderr_w.close
102
+
103
+ wait_for_ready
104
+
105
+ # Drain stderr/stdout to prevent pipe buffer from filling up
106
+ # and blocking the Lightpanda process
107
+ start_drain_thread
108
+ end
109
+
110
+ def start_drain_thread
111
+ @drain_thread = Thread.new do
112
+ ios = [@stdout_r, @stderr_r].compact
113
+ loop do
114
+ ready = IO.select(ios, nil, nil, 0.5)
115
+ next unless ready
116
+
117
+ ready[0].each do |io|
118
+ io.read_nonblock(4096)
119
+ rescue IO::WaitReadable
120
+ # No data
121
+ rescue EOFError
122
+ ios.delete(io)
123
+ end
124
+
125
+ break if ios.empty?
126
+ rescue IOError
127
+ break
128
+ end
129
+ end
130
+ end
131
+
132
+ def spawn_process(binary_path)
133
+ args = build_args
134
+
135
+ ::Process.spawn(
136
+ { "LIGHTPANDA_DISABLE_TELEMETRY" => "true" },
137
+ binary_path, *args,
138
+ out: @stdout_w,
139
+ err: @stderr_w,
140
+ pgroup: true
141
+ )
142
+ end
143
+
144
+ def build_args
145
+ [
146
+ "serve",
147
+ "--host",
148
+ @options.host.to_s,
149
+ "--port",
150
+ @options.port.to_s,
151
+ "--log_level",
152
+ "info",
153
+ ]
154
+ end
155
+
156
+ def wait_for_ready
157
+ started_at = Time.now
158
+ output = +""
159
+
160
+ catch(:ready) do
161
+ while Time.now - started_at < @options.process_timeout
162
+ ready = IO.select([@stdout_r, @stderr_r], nil, nil, 0.1)
163
+
164
+ next unless ready
165
+
166
+ ready[0].each do |io|
167
+ chunk = io.read_nonblock(1024)
168
+ output << chunk
169
+
170
+ if (match = output.match(READY_PATTERN))
171
+ @ws_url = "ws://#{match[1]}/"
172
+ throw(:ready)
173
+ end
174
+
175
+ if output.match?(ADDRESS_IN_USE_PATTERN)
176
+ cleanup_failed_process
177
+ raise ProcessTimeoutError,
178
+ "Lightpanda failed to start: port #{@options.port} is already in use"
179
+ end
180
+ rescue IO::WaitReadable
181
+ # No data available yet
182
+ rescue EOFError
183
+ # Pipe closed
184
+ end
185
+ end
186
+
187
+ stop
188
+
189
+ raise ProcessTimeoutError,
190
+ "Lightpanda failed to start within #{@options.process_timeout} seconds.\nOutput: #{output}"
191
+ end
192
+ end
193
+
194
+ def cleanup_failed_process
195
+ return unless @pid
196
+
197
+ begin
198
+ ::Process.wait(@pid, ::Process::WNOHANG)
199
+ rescue Errno::ECHILD
200
+ nil
201
+ end
202
+
203
+ cleanup_pipes
204
+ @pid = nil
205
+ end
206
+
207
+ def kill_process_on_port(port)
208
+ port = port.to_i
209
+ return if port <= 0
210
+
211
+ pids = `lsof -ti tcp:#{port} 2>/dev/null`.strip
212
+ return if pids.empty?
213
+
214
+ pids.split("\n").each do |pid_str|
215
+ pid = pid_str.strip.to_i
216
+ next if pid <= 0
217
+
218
+ ::Process.kill("TERM", pid)
219
+ rescue Errno::ESRCH, Errno::EPERM
220
+ nil
221
+ end
222
+
223
+ sleep 0.5
224
+ end
225
+
226
+ # Class method so the finalizer proc doesn't capture `self` (which
227
+ # would prevent GC from ever running the finalizer).
228
+ class << self
229
+ private
230
+
231
+ def weak_kill(pid)
232
+ proc do
233
+ ::Process.kill("TERM", -pid)
234
+ ::Process.wait(pid)
235
+ rescue Errno::ESRCH, Errno::ECHILD, Errno::EPERM
236
+ nil
237
+ end
238
+ end
239
+ end
240
+
241
+ def register_finalizer(pid)
242
+ ObjectSpace.define_finalizer(self, self.class.send(:weak_kill, pid))
243
+ end
244
+
245
+ def cleanup_pipes
246
+ [@stdout_r, @stdout_w, @stderr_r, @stderr_w].each do |pipe|
247
+ pipe&.close unless pipe&.closed?
248
+ end
249
+ end
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent-ruby"
4
+
5
+ module Capybara
6
+ module Lightpanda
7
+ module Utils
8
+ # Concurrent::Event with an iteration counter so callers can detect
9
+ # that the event was reset (e.g. a new navigation started) while they
10
+ # were waiting on it. Mirrors ferrum's Utils::Event.
11
+ #
12
+ # The base Concurrent::Event allows wait/set/reset cycles, but a wait
13
+ # that returns true after a reset → set is indistinguishable from one
14
+ # that returned true on the original set. The iteration counter,
15
+ # bumped on every reset, lets callers compare before and after to
16
+ # tell whether the event was raced by a reset.
17
+ class Event < Concurrent::Event
18
+ def initialize
19
+ super
20
+ @iteration = 0
21
+ end
22
+
23
+ def iteration
24
+ synchronize { @iteration }
25
+ end
26
+
27
+ def reset
28
+ synchronize do
29
+ @iteration += 1
30
+ @set = false if @set
31
+ @iteration
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Lightpanda
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Lightpanda
5
+ module XPathPolyfill
6
+ JS_PATH = File.expand_path("javascripts/index.js", __dir__).freeze
7
+ JS = File.read(JS_PATH).freeze
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "capybara"
4
+
5
+ require_relative "capybara/lightpanda/version"
6
+ require_relative "capybara/lightpanda/errors"
7
+ require_relative "capybara/lightpanda/logger"
8
+ require_relative "capybara/lightpanda/options"
9
+ require_relative "capybara/lightpanda/binary"
10
+ require_relative "capybara/lightpanda/process"
11
+ require_relative "capybara/lightpanda/utils/event"
12
+ require_relative "capybara/lightpanda/client"
13
+ require_relative "capybara/lightpanda/network"
14
+ require_relative "capybara/lightpanda/cookies"
15
+ require_relative "capybara/lightpanda/keyboard"
16
+ require_relative "capybara/lightpanda/frame"
17
+ require_relative "capybara/lightpanda/browser"
18
+ require_relative "capybara/lightpanda/xpath_polyfill"
19
+ require_relative "capybara/lightpanda/node"
20
+ require_relative "capybara/lightpanda/driver"
21
+
22
+ module Capybara
23
+ module Lightpanda
24
+ class << self
25
+ def configure
26
+ yield(options) if block_given?
27
+ end
28
+
29
+ def options
30
+ @options ||= Options.new
31
+ end
32
+
33
+ def reset_options!
34
+ @options = nil
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ Capybara.register_driver(:lightpanda) do |app|
41
+ Capybara::Lightpanda::Driver.new(app, Capybara::Lightpanda.options.to_h)
42
+ end
metadata ADDED
@@ -0,0 +1,119 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: capybara-lightpanda
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Navid Emad
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: capybara
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '3.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '5'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '3.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '5'
32
+ - !ruby/object:Gem::Dependency
33
+ name: concurrent-ruby
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: '1.3'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: '1.3'
46
+ - !ruby/object:Gem::Dependency
47
+ name: websocket-driver
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '0.8'
53
+ type: :runtime
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '0.8'
60
+ description: A Capybara driver for Lightpanda, the fast headless browser built in
61
+ Zig. Provides a production-ready driver with XPath polyfill, reliable navigation,
62
+ and cookie management — ready for real-world Rails test suites.
63
+ email:
64
+ - design.navid@gmail.com
65
+ executables: []
66
+ extensions: []
67
+ extra_rdoc_files: []
68
+ files:
69
+ - CHANGELOG.md
70
+ - LICENSE.txt
71
+ - NOTICE.md
72
+ - README.md
73
+ - lib/capybara-lightpanda.rb
74
+ - lib/capybara/lightpanda/binary.rb
75
+ - lib/capybara/lightpanda/browser.rb
76
+ - lib/capybara/lightpanda/client.rb
77
+ - lib/capybara/lightpanda/client/subscriber.rb
78
+ - lib/capybara/lightpanda/client/web_socket.rb
79
+ - lib/capybara/lightpanda/cookies.rb
80
+ - lib/capybara/lightpanda/driver.rb
81
+ - lib/capybara/lightpanda/errors.rb
82
+ - lib/capybara/lightpanda/frame.rb
83
+ - lib/capybara/lightpanda/javascripts/index.js
84
+ - lib/capybara/lightpanda/keyboard.rb
85
+ - lib/capybara/lightpanda/logger.rb
86
+ - lib/capybara/lightpanda/network.rb
87
+ - lib/capybara/lightpanda/node.rb
88
+ - lib/capybara/lightpanda/options.rb
89
+ - lib/capybara/lightpanda/process.rb
90
+ - lib/capybara/lightpanda/utils/event.rb
91
+ - lib/capybara/lightpanda/version.rb
92
+ - lib/capybara/lightpanda/xpath_polyfill.rb
93
+ homepage: https://navidemad.github.io/capybara-lightpanda
94
+ licenses:
95
+ - MIT
96
+ metadata:
97
+ source_code_uri: https://github.com/navidemad/capybara-lightpanda
98
+ changelog_uri: https://github.com/navidemad/capybara-lightpanda/releases
99
+ bug_tracker_uri: https://github.com/navidemad/capybara-lightpanda/issues
100
+ documentation_uri: https://navidemad.github.io/capybara-lightpanda
101
+ rubygems_mfa_required: 'true'
102
+ rdoc_options: []
103
+ require_paths:
104
+ - lib
105
+ required_ruby_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '3.3'
110
+ required_rubygems_version: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - ">="
113
+ - !ruby/object:Gem::Version
114
+ version: '0'
115
+ requirements: []
116
+ rubygems_version: 4.0.6
117
+ specification_version: 4
118
+ summary: Capybara driver for the Lightpanda headless browser
119
+ test_files: []