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,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lightpanda
4
+ class Cookies
5
+ attr_reader :browser
6
+
7
+ def initialize(browser)
8
+ @browser = browser
9
+ end
10
+
11
+ def all
12
+ result = browser.command("Network.getAllCookies")
13
+
14
+ result["cookies"] || []
15
+ end
16
+
17
+ def get(name)
18
+ all.find { |cookie| cookie["name"] == name }
19
+ end
20
+
21
+ def set(name:, value:, domain: nil, path: "/", secure: false, http_only: false, expires: nil)
22
+ params = {
23
+ name: name,
24
+ value: value,
25
+ path: path,
26
+ secure: secure,
27
+ httpOnly: http_only
28
+ }
29
+
30
+ params[:domain] = domain if domain
31
+ params[:expires] = expires.to_i if expires
32
+
33
+ browser.command("Network.setCookie", **params)
34
+ end
35
+
36
+ def remove(name:, domain: nil, path: "/")
37
+ params = { name: name, path: path }
38
+ params[:domain] = domain if domain
39
+
40
+ browser.command("Network.deleteCookies", **params)
41
+ end
42
+
43
+ def clear
44
+ browser.command("Network.clearBrowserCookies")
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lightpanda
4
+ class Error < StandardError; end
5
+
6
+ class ProcessTimeoutError < Error; end
7
+ class BinaryNotFoundError < Error; end
8
+ class BinaryError < Error; end
9
+ class UnsupportedPlatformError < Error; end
10
+
11
+ class DeadBrowserError < Error; end
12
+ class TimeoutError < Error; end
13
+
14
+ class BrowserError < Error
15
+ attr_reader :response
16
+
17
+ def initialize(response)
18
+ @response = response
19
+ super(response["message"])
20
+ end
21
+ end
22
+
23
+ class JavaScriptError < Error
24
+ attr_reader :class_name, :message
25
+
26
+ def initialize(response)
27
+ @class_name = response.dig("exceptionDetails", "exception", "className")
28
+ @message = response.dig("exceptionDetails", "exception",
29
+ "description") || response.dig("exceptionDetails", "text")
30
+
31
+ super(@message)
32
+ end
33
+ end
34
+
35
+ class NodeNotFoundError < Error; end
36
+ class NoExecutionContextError < Error; end
37
+
38
+ class NoSuchPageError < Error; end
39
+ class StatusError < Error; end
40
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lightpanda
4
+ class Network
5
+ attr_reader :browser
6
+
7
+ def initialize(browser)
8
+ @browser = browser
9
+ @traffic = []
10
+ @enabled = false
11
+ end
12
+
13
+ def enable
14
+ return if @enabled
15
+
16
+ browser.command("Network.enable")
17
+ subscribe
18
+ @enabled = true
19
+ end
20
+
21
+ def disable
22
+ return unless @enabled
23
+
24
+ browser.command("Network.disable")
25
+ @enabled = false
26
+ end
27
+
28
+ def traffic
29
+ @traffic.dup
30
+ end
31
+
32
+ def clear
33
+ @traffic.clear
34
+ end
35
+
36
+ def wait_for_idle(timeout: 5, connections: 0) # rubocop:disable Naming/PredicateMethod
37
+ started_at = Time.now
38
+
39
+ while Time.now - started_at < timeout
40
+ pending = @traffic.count { |t| t[:response].nil? }
41
+ return true if pending <= connections
42
+
43
+ sleep 0.1
44
+ end
45
+
46
+ false
47
+ end
48
+
49
+ private
50
+
51
+ def subscribe
52
+ browser.on("Network.requestWillBeSent") do |params|
53
+ @traffic << {
54
+ request_id: params["requestId"],
55
+ url: params.dig("request", "url"),
56
+ method: params.dig("request", "method"),
57
+ timestamp: params["timestamp"],
58
+ response: nil
59
+ }
60
+ end
61
+
62
+ browser.on("Network.responseReceived") do |params|
63
+ request = @traffic.find { |t| t[:request_id] == params["requestId"] }
64
+
65
+ next unless request
66
+
67
+ request[:response] = {
68
+ status: params.dig("response", "status"),
69
+ headers: params.dig("response", "headers"),
70
+ mime_type: params.dig("response", "mimeType")
71
+ }
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lightpanda
4
+ class Options
5
+ DEFAULT_TIMEOUT = ENV.fetch("LIGHTPANDA_DEFAULT_TIMEOUT", 5).to_i
6
+ DEFAULT_PROCESS_TIMEOUT = ENV.fetch("LIGHTPANDA_PROCESS_TIMEOUT", 10).to_i
7
+ DEFAULT_HOST = "127.0.0.1"
8
+ DEFAULT_PORT = 9222
9
+ DEFAULT_WINDOW_SIZE = [1024, 768].freeze
10
+
11
+ attr_accessor :host, :port, :timeout, :process_timeout, :window_size, :browser_path, :headless
12
+ attr_writer :ws_url
13
+
14
+ def initialize(options = {})
15
+ @host = options.fetch(:host, DEFAULT_HOST)
16
+ @port = options.fetch(:port, DEFAULT_PORT)
17
+ @timeout = options.fetch(:timeout, DEFAULT_TIMEOUT)
18
+ @process_timeout = options.fetch(:process_timeout, DEFAULT_PROCESS_TIMEOUT)
19
+ @window_size = options.fetch(:window_size, DEFAULT_WINDOW_SIZE)
20
+ @browser_path = options[:browser_path]
21
+ @headless = options.fetch(:headless, true)
22
+ @ws_url = options[:ws_url]
23
+ end
24
+
25
+ def ws_url
26
+ @ws_url || "ws://#{host}:#{port}/"
27
+ end
28
+
29
+ def ws_url?
30
+ !@ws_url.nil?
31
+ end
32
+
33
+ def to_h
34
+ {
35
+ host: host,
36
+ port: port,
37
+ timeout: timeout,
38
+ process_timeout: process_timeout,
39
+ window_size: window_size,
40
+ browser_path: browser_path,
41
+ headless: headless,
42
+ ws_url: ws_url
43
+ }
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lightpanda
4
+ class Process
5
+ READY_PATTERN = /server running.*address=(\d+\.\d+\.\d+\.\d+:\d+)/
6
+
7
+ attr_reader :pid, :ws_url
8
+
9
+ def initialize(options)
10
+ @options = options
11
+ @pid = nil
12
+ @ws_url = nil
13
+ @stdout_r = nil
14
+ @stdout_w = nil
15
+ @stderr_r = nil
16
+ @stderr_w = nil
17
+ end
18
+
19
+ def start
20
+ binary_path = @options.browser_path || Binary.find_or_download
21
+
22
+ raise BinaryNotFoundError, "Lightpanda binary not found" unless binary_path
23
+
24
+ @stdout_r, @stdout_w = IO.pipe
25
+ @stderr_r, @stderr_w = IO.pipe
26
+
27
+ @pid = spawn_process(binary_path)
28
+
29
+ @stdout_w.close
30
+ @stderr_w.close
31
+
32
+ wait_for_ready
33
+ end
34
+
35
+ def stop
36
+ return unless @pid
37
+
38
+ begin
39
+ ::Process.kill("TERM", @pid)
40
+ ::Process.wait(@pid)
41
+ rescue Errno::ESRCH, Errno::ECHILD
42
+ # Process already dead
43
+ end
44
+
45
+ cleanup_pipes
46
+ @pid = nil
47
+ end
48
+
49
+ def alive?
50
+ return false unless @pid
51
+
52
+ ::Process.kill(0, @pid)
53
+ true
54
+ rescue Errno::ESRCH, Errno::EPERM
55
+ false
56
+ end
57
+
58
+ private
59
+
60
+ def spawn_process(binary_path)
61
+ args = build_args
62
+
63
+ ::Process.spawn(
64
+ binary_path, *args,
65
+ out: @stdout_w,
66
+ err: @stderr_w,
67
+ pgroup: true
68
+ )
69
+ end
70
+
71
+ def build_args
72
+ %W[
73
+ serve
74
+ --host #{@options.host}
75
+ --port #{@options.port}
76
+ --log_level info
77
+ ]
78
+ end
79
+
80
+ def wait_for_ready
81
+ started_at = Time.now
82
+ output = +""
83
+
84
+ catch(:ready) do
85
+ while Time.now - started_at < @options.process_timeout
86
+ ready = IO.select([@stdout_r, @stderr_r], nil, nil, 0.1)
87
+
88
+ next unless ready
89
+
90
+ ready[0].each do |io|
91
+ chunk = io.read_nonblock(1024)
92
+ output << chunk
93
+
94
+ if (match = output.match(READY_PATTERN))
95
+ @ws_url = "ws://#{match[1]}/"
96
+ throw(:ready)
97
+ end
98
+ rescue IO::WaitReadable
99
+ # No data available yet
100
+ rescue EOFError
101
+ # Pipe closed
102
+ end
103
+ end
104
+
105
+ stop
106
+
107
+ raise ProcessTimeoutError,
108
+ "Lightpanda failed to start within #{@options.process_timeout} seconds.\nOutput: #{output}"
109
+ end
110
+ end
111
+
112
+ def cleanup_pipes
113
+ [@stdout_r, @stdout_w, @stderr_r, @stderr_w].each do |pipe|
114
+ pipe&.close unless pipe&.closed?
115
+ end
116
+ end
117
+ end
118
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lightpanda
4
- VERSION = "0.0.1"
4
+ VERSION = "0.1.0"
5
5
  end
data/lib/lightpanda.rb CHANGED
@@ -1,8 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "lightpanda/version"
4
+ require_relative "lightpanda/errors"
5
+ require_relative "lightpanda/options"
6
+ require_relative "lightpanda/binary"
7
+ require_relative "lightpanda/process"
8
+ require_relative "lightpanda/client"
9
+ require_relative "lightpanda/network"
10
+ require_relative "lightpanda/cookies"
11
+ require_relative "lightpanda/browser"
4
12
 
5
13
  module Lightpanda
6
- class Error < StandardError; end
7
- # Your code goes here...
14
+ class << self
15
+ def new(**options)
16
+ Browser.new(**options)
17
+ end
18
+ end
8
19
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lightpanda
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marco Roth
@@ -60,8 +60,22 @@ executables: []
60
60
  extensions: []
61
61
  extra_rdoc_files: []
62
62
  files:
63
+ - LICENSE.txt
63
64
  - README.md
64
65
  - lib/lightpanda.rb
66
+ - lib/lightpanda/binary.rb
67
+ - lib/lightpanda/browser.rb
68
+ - lib/lightpanda/capybara.rb
69
+ - lib/lightpanda/capybara/driver.rb
70
+ - lib/lightpanda/capybara/node.rb
71
+ - lib/lightpanda/client.rb
72
+ - lib/lightpanda/client/subscriber.rb
73
+ - lib/lightpanda/client/web_socket.rb
74
+ - lib/lightpanda/cookies.rb
75
+ - lib/lightpanda/errors.rb
76
+ - lib/lightpanda/network.rb
77
+ - lib/lightpanda/options.rb
78
+ - lib/lightpanda/process.rb
65
79
  - lib/lightpanda/version.rb
66
80
  homepage: https://github.com/marcoroth/lightpanda-ruby
67
81
  licenses: