ferrum 0.11 → 0.12

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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +178 -29
  4. data/lib/ferrum/browser/binary.rb +46 -0
  5. data/lib/ferrum/browser/client.rb +13 -12
  6. data/lib/ferrum/browser/command.rb +7 -8
  7. data/lib/ferrum/browser/options/base.rb +1 -7
  8. data/lib/ferrum/browser/options/chrome.rb +17 -11
  9. data/lib/ferrum/browser/options/firefox.rb +11 -4
  10. data/lib/ferrum/browser/process.rb +41 -35
  11. data/lib/ferrum/browser/subscriber.rb +1 -3
  12. data/lib/ferrum/browser/web_socket.rb +9 -12
  13. data/lib/ferrum/browser/xvfb.rb +4 -8
  14. data/lib/ferrum/browser.rb +44 -12
  15. data/lib/ferrum/context.rb +6 -2
  16. data/lib/ferrum/contexts.rb +10 -8
  17. data/lib/ferrum/cookies.rb +10 -9
  18. data/lib/ferrum/errors.rb +115 -0
  19. data/lib/ferrum/frame/runtime.rb +20 -17
  20. data/lib/ferrum/frame.rb +32 -24
  21. data/lib/ferrum/headers.rb +2 -2
  22. data/lib/ferrum/keyboard.rb +11 -11
  23. data/lib/ferrum/mouse.rb +8 -7
  24. data/lib/ferrum/network/auth_request.rb +7 -2
  25. data/lib/ferrum/network/exchange.rb +14 -10
  26. data/lib/ferrum/network/intercepted_request.rb +10 -8
  27. data/lib/ferrum/network/request.rb +5 -0
  28. data/lib/ferrum/network/response.rb +4 -4
  29. data/lib/ferrum/network.rb +124 -35
  30. data/lib/ferrum/node.rb +69 -23
  31. data/lib/ferrum/page/animation.rb +0 -1
  32. data/lib/ferrum/page/frames.rb +46 -20
  33. data/lib/ferrum/page/screenshot.rb +51 -65
  34. data/lib/ferrum/page/stream.rb +38 -0
  35. data/lib/ferrum/page/tracing.rb +71 -0
  36. data/lib/ferrum/page.rb +81 -36
  37. data/lib/ferrum/proxy.rb +58 -0
  38. data/lib/ferrum/{rbga.rb → rgba.rb} +4 -2
  39. data/lib/ferrum/target.rb +1 -0
  40. data/lib/ferrum/utils/attempt.rb +20 -0
  41. data/lib/ferrum/utils/elapsed_time.rb +27 -0
  42. data/lib/ferrum/utils/platform.rb +28 -0
  43. data/lib/ferrum/version.rb +1 -1
  44. data/lib/ferrum.rb +4 -146
  45. metadata +60 -51
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "cliver"
4
3
  require "net/http"
5
4
  require "json"
6
5
  require "addressable"
@@ -22,7 +21,6 @@ module Ferrum
22
21
  :default_user_agent, :browser_version, :protocol_version,
23
22
  :v8_version, :webkit_version, :xvfb
24
23
 
25
-
26
24
  extend Forwardable
27
25
  delegate path: :command
28
26
 
@@ -32,41 +30,50 @@ module Ferrum
32
30
 
33
31
  def self.process_killer(pid)
34
32
  proc do
35
- begin
36
- if Ferrum.windows?
33
+ if Utils::Platform.windows?
34
+ # Process.kill is unreliable on Windows
35
+ ::Process.kill("KILL", pid) unless system("taskkill /f /t /pid #{pid} >NUL 2>NUL")
36
+ else
37
+ ::Process.kill("USR1", pid)
38
+ start = Utils::ElapsedTime.monotonic_time
39
+ while ::Process.wait(pid, ::Process::WNOHANG).nil?
40
+ sleep(WAIT_KILLED)
41
+ next unless Utils::ElapsedTime.timeout?(start, KILL_TIMEOUT)
42
+
37
43
  ::Process.kill("KILL", pid)
38
- else
39
- ::Process.kill("USR1", pid)
40
- start = Ferrum.monotonic_time
41
- while ::Process.wait(pid, ::Process::WNOHANG).nil?
42
- sleep(WAIT_KILLED)
43
- next unless Ferrum.timeout?(start, KILL_TIMEOUT)
44
- ::Process.kill("KILL", pid)
45
- ::Process.wait(pid)
46
- break
47
- end
44
+ ::Process.wait(pid)
45
+ break
48
46
  end
49
- rescue Errno::ESRCH, Errno::ECHILD
50
47
  end
48
+ rescue Errno::ESRCH, Errno::ECHILD
49
+ # nop
51
50
  end
52
51
  end
53
52
 
54
53
  def self.directory_remover(path)
55
- proc { FileUtils.remove_entry(path) rescue Errno::ENOENT }
54
+ proc {
55
+ begin
56
+ FileUtils.remove_entry(path)
57
+ rescue StandardError
58
+ Errno::ENOENT
59
+ end
60
+ }
56
61
  end
57
62
 
58
63
  def initialize(options)
64
+ @pid = @xvfb = @user_data_dir = nil
65
+
59
66
  if options[:url]
60
67
  url = URI.join(options[:url].to_s, "/json/version")
61
68
  response = JSON.parse(::Net::HTTP.get(url))
62
- set_ws_url(response["webSocketDebuggerUrl"])
69
+ self.ws_url = response["webSocketDebuggerUrl"]
63
70
  parse_browser_versions
64
71
  return
65
72
  end
66
73
 
67
- @pid = @xvfb = @user_data_dir = nil
68
74
  @logger = options[:logger]
69
75
  @process_timeout = options.fetch(:process_timeout, PROCESS_TIMEOUT)
76
+ @env = Hash(options[:env])
70
77
 
71
78
  tmpdir = Dir.mktmpdir("ferrum_user_data_dir_")
72
79
  ObjectSpace.define_finalizer(self, self.class.directory_remover(tmpdir))
@@ -81,7 +88,7 @@ module Ferrum
81
88
  begin
82
89
  read_io, write_io = IO.pipe
83
90
  process_options = { in: File::NULL }
84
- process_options[:pgroup] = true unless Ferrum.windows?
91
+ process_options[:pgroup] = true unless Utils::Platform.windows?
85
92
  process_options[:out] = process_options[:err] = write_io
86
93
 
87
94
  if @command.xvfb?
@@ -89,7 +96,8 @@ module Ferrum
89
96
  ObjectSpace.define_finalizer(self, self.class.process_killer(@xvfb.pid))
90
97
  end
91
98
 
92
- @pid = ::Process.spawn(Hash(@xvfb&.to_env), *@command.to_a, process_options)
99
+ env = Hash(@xvfb&.to_env).merge(@env)
100
+ @pid = ::Process.spawn(env, *@command.to_a, process_options)
93
101
  ObjectSpace.define_finalizer(self, self.class.process_killer(@pid))
94
102
 
95
103
  parse_ws_url(read_io, @process_timeout)
@@ -128,29 +136,29 @@ module Ferrum
128
136
 
129
137
  def parse_ws_url(read_io, timeout)
130
138
  output = ""
131
- start = Ferrum.monotonic_time
139
+ start = Utils::ElapsedTime.monotonic_time
132
140
  max_time = start + timeout
133
- regexp = /DevTools listening on (ws:\/\/.*)/
134
- while (now = Ferrum.monotonic_time) < max_time
141
+ regexp = %r{DevTools listening on (ws://.*)}
142
+ while (now = Utils::ElapsedTime.monotonic_time) < max_time
135
143
  begin
136
144
  output += read_io.read_nonblock(512)
137
145
  rescue IO::WaitReadable
138
- IO.select([read_io], nil, nil, max_time - now)
146
+ read_io.wait_readable(max_time - now)
139
147
  else
140
148
  if output.match(regexp)
141
- set_ws_url(output.match(regexp)[1].strip)
149
+ self.ws_url = output.match(regexp)[1].strip
142
150
  break
143
151
  end
144
152
  end
145
153
  end
146
154
 
147
- unless ws_url
148
- @logger.puts(output) if @logger
149
- raise ProcessTimeoutError.new(timeout, output)
150
- end
155
+ return if ws_url
156
+
157
+ @logger&.puts(output)
158
+ raise ProcessTimeoutError.new(timeout, output)
151
159
  end
152
160
 
153
- def set_ws_url(url)
161
+ def ws_url=(url)
154
162
  @ws_url = Addressable::URI.parse(url)
155
163
  @host = @ws_url.host
156
164
  @port = @ws_url.port
@@ -171,11 +179,9 @@ module Ferrum
171
179
 
172
180
  def close_io(*ios)
173
181
  ios.each do |io|
174
- begin
175
- io.close unless io.closed?
176
- rescue IOError
177
- raise unless RUBY_ENGINE == "jruby"
178
- end
182
+ io.close unless io.closed?
183
+ rescue IOError
184
+ raise unless RUBY_ENGINE == "jruby"
179
185
  end
180
186
  end
181
187
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "concurrent-ruby"
4
-
5
3
  module Ferrum
6
4
  class Browser
7
5
  class Subscriber
@@ -29,7 +27,7 @@ module Ferrum
29
27
  method, params = message.values_at("method", "params")
30
28
  total = @on[method].size
31
29
  @on[method].each_with_index do |block, index|
32
- # If there are a few callback we provide current index and total
30
+ # In case of multiple callbacks we provide current index and total
33
31
  block.call(params, index, total)
34
32
  end
35
33
  end
@@ -21,9 +21,7 @@ module Ferrum
21
21
  @driver = ::WebSocket::Driver.client(self, max_length: max_receive_size)
22
22
  @messages = Queue.new
23
23
 
24
- if SKIP_LOGGING_SCREENSHOTS
25
- @screenshot_commands = Concurrent::Hash.new
26
- end
24
+ @screenshot_commands = Concurrent::Hash.new if SKIP_LOGGING_SCREENSHOTS
27
25
 
28
26
  @driver.on(:open, &method(:on_open))
29
27
  @driver.on(:message, &method(:on_message))
@@ -31,12 +29,13 @@ module Ferrum
31
29
 
32
30
  @thread = Thread.new do
33
31
  Thread.current.abort_on_exception = true
34
- if Thread.current.respond_to?(:report_on_exception=)
35
- Thread.current.report_on_exception = true
36
- end
32
+ Thread.current.report_on_exception = true if Thread.current.respond_to?(:report_on_exception=)
37
33
 
38
34
  begin
39
- while data = @sock.readpartial(512)
35
+ loop do
36
+ data = @sock.readpartial(512)
37
+ break unless data
38
+
40
39
  @driver.parse(data)
41
40
  end
42
41
  rescue EOFError, Errno::ECONNRESET, Errno::EPIPE
@@ -62,7 +61,7 @@ module Ferrum
62
61
  output.sub!(/{"data":"(.*)"}/, %("Set FERRUM_LOGGING_SCREENSHOTS=true to see screenshots in Base64"))
63
62
  end
64
63
 
65
- @logger&.puts(" ◀ #{Ferrum.elapsed_time} #{output}\n")
64
+ @logger&.puts(" ◀ #{Utils::ElapsedTime.elapsed_time} #{output}\n")
66
65
  end
67
66
 
68
67
  def on_close(_event)
@@ -71,13 +70,11 @@ module Ferrum
71
70
  end
72
71
 
73
72
  def send_message(data)
74
- if SKIP_LOGGING_SCREENSHOTS
75
- @screenshot_commands[data[:id]] = true
76
- end
73
+ @screenshot_commands[data[:id]] = true if SKIP_LOGGING_SCREENSHOTS
77
74
 
78
75
  json = data.to_json
79
76
  @driver.text(json)
80
- @logger&.puts("\n\n▶ #{Ferrum.elapsed_time} #{json}")
77
+ @logger&.puts("\n\n▶ #{Utils::ElapsedTime.elapsed_time} #{json}")
81
78
  end
82
79
 
83
80
  def write(data)
@@ -4,23 +4,19 @@ module Ferrum
4
4
  class Browser
5
5
  class Xvfb
6
6
  NOT_FOUND = "Could not find an executable for the Xvfb. Try to install " \
7
- "it with your package manager".freeze
7
+ "it with your package manager"
8
8
 
9
9
  def self.start(*args)
10
10
  new(*args).tap(&:start)
11
11
  end
12
12
 
13
- def self.xvfb_path
14
- Cliver.detect("Xvfb")
15
- end
16
-
17
13
  attr_reader :screen_size, :display_id, :pid
18
14
 
19
15
  def initialize(options)
20
- @path = self.class.xvfb_path
21
- raise Cliver::Dependency::NotFound.new(NOT_FOUND) unless @path
16
+ @path = Binary.find("Xvfb")
17
+ raise BinaryNotFoundError, NOT_FOUND unless @path
22
18
 
23
- @screen_size = options.fetch(:window_size, [1024, 768]).join("x") + "x24"
19
+ @screen_size = "#{options.fetch(:window_size, [1024, 768]).join('x')}x24"
24
20
  @display_id = (Time.now.to_f * 1000).to_i % 100_000_000
25
21
  end
26
22
 
@@ -3,10 +3,12 @@
3
3
  require "base64"
4
4
  require "forwardable"
5
5
  require "ferrum/page"
6
+ require "ferrum/proxy"
6
7
  require "ferrum/contexts"
7
8
  require "ferrum/browser/xvfb"
8
9
  require "ferrum/browser/process"
9
10
  require "ferrum/browser/client"
11
+ require "ferrum/browser/binary"
10
12
 
11
13
  module Ferrum
12
14
  class Browser
@@ -16,22 +18,23 @@ module Ferrum
16
18
 
17
19
  extend Forwardable
18
20
  delegate %i[default_context] => :contexts
19
- delegate %i[targets create_target create_page page pages windows] => :default_context
20
- delegate %i[go_to back forward refresh reload stop wait_for_reload
21
+ delegate %i[targets create_target page pages windows] => :default_context
22
+ delegate %i[go_to goto go back forward refresh reload stop wait_for_reload
21
23
  at_css at_xpath css xpath current_url current_title url title
22
- body doctype set_content
24
+ body doctype content=
23
25
  headers cookies network
24
26
  mouse keyboard
25
27
  screenshot pdf mhtml viewport_size
26
28
  frames frame_by main_frame
27
29
  evaluate evaluate_on evaluate_async execute evaluate_func
28
30
  add_script_tag add_style_tag bypass_csp
29
- on goto position position=
31
+ on position position=
30
32
  playback_rate playback_rate=] => :page
31
33
  delegate %i[default_user_agent] => :process
32
34
 
33
35
  attr_reader :client, :process, :contexts, :logger, :js_errors, :pending_connection_errors,
34
- :slowmo, :base_url, :options, :window_size, :ws_max_receive_size
36
+ :slowmo, :base_url, :options, :window_size, :ws_max_receive_size, :proxy_options,
37
+ :proxy_server
35
38
  attr_writer :timeout
36
39
 
37
40
  def initialize(options = nil)
@@ -45,16 +48,29 @@ module Ferrum
45
48
  @logger, @timeout, @ws_max_receive_size =
46
49
  @options.values_at(:logger, :timeout, :ws_max_receive_size)
47
50
  @js_errors = @options.fetch(:js_errors, false)
51
+
52
+ if @options[:proxy]
53
+ @proxy_options = @options[:proxy]
54
+
55
+ if @proxy_options[:server]
56
+ @proxy_server = Proxy.start(**@proxy_options.slice(:host, :port, :user, :password))
57
+ @proxy_options.merge!(host: @proxy_server.host, port: @proxy_server.port)
58
+ end
59
+
60
+ @options[:browser_options] ||= {}
61
+ address = "#{@proxy_options[:host]}:#{@proxy_options[:port]}"
62
+ @options[:browser_options].merge!("proxy-server" => address)
63
+ @options[:browser_options].merge!("proxy-bypass-list" => @proxy_options[:bypass]) if @proxy_options[:bypass]
64
+ end
65
+
48
66
  @pending_connection_errors = @options.fetch(:pending_connection_errors, true)
49
67
  @slowmo = @options[:slowmo].to_f
50
68
 
51
- if @options.key?(:base_url)
52
- self.base_url = @options[:base_url]
53
- end
69
+ self.base_url = @options[:base_url] if @options.key?(:base_url)
54
70
 
55
- if ENV["FERRUM_DEBUG"] && !@logger
56
- STDOUT.sync = true
57
- @logger = STDOUT
71
+ if ENV.fetch("FERRUM_DEBUG", nil) && !@logger
72
+ $stdout.sync = true
73
+ @logger = $stdout
58
74
  @options[:logger] = @logger
59
75
  end
60
76
 
@@ -72,6 +88,22 @@ module Ferrum
72
88
  @base_url = parsed
73
89
  end
74
90
 
91
+ def create_page(new_context: false)
92
+ page = if new_context
93
+ context = contexts.create
94
+ context.create_page
95
+ else
96
+ default_context.create_page
97
+ end
98
+
99
+ block_given? ? yield(page) : page
100
+ ensure
101
+ if block_given?
102
+ page.close
103
+ context.dispose if new_context
104
+ end
105
+ end
106
+
75
107
  def extensions
76
108
  @extensions ||= Array(@options[:extensions]).map do |ext|
77
109
  (ext.is_a?(Hash) && ext[:source]) || File.read(ext)
@@ -121,7 +153,7 @@ module Ferrum
121
153
  private
122
154
 
123
155
  def start
124
- Ferrum.started
156
+ Utils::ElapsedTime.start
125
157
  @process = Process.start(@options)
126
158
  @client = Client.new(self, @process.ws_url)
127
159
  @contexts = Contexts.new(self)
@@ -9,7 +9,9 @@ module Ferrum
9
9
  attr_reader :id, :targets
10
10
 
11
11
  def initialize(browser, contexts, id)
12
- @browser, @contexts, @id = browser, contexts, id
12
+ @id = id
13
+ @browser = browser
14
+ @contexts = contexts
13
15
  @targets = Concurrent::Map.new
14
16
  @pendings = Concurrent::MVar.new
15
17
  end
@@ -32,6 +34,7 @@ module Ferrum
32
34
  # usually is the last one.
33
35
  def windows(pos = nil, size = 1)
34
36
  raise ArgumentError if pos && !POSITION.include?(pos)
37
+
35
38
  windows = @targets.values.select(&:window?)
36
39
  windows = windows.send(pos, size) if pos
37
40
  windows.map(&:page)
@@ -47,6 +50,7 @@ module Ferrum
47
50
  url: "about:blank")
48
51
  target = @pendings.take(@browser.timeout)
49
52
  raise NoSuchTargetError unless target.is_a?(Target)
53
+
50
54
  @targets.put_if_absent(target.id, target)
51
55
  target
52
56
  end
@@ -72,7 +76,7 @@ module Ferrum
72
76
  @contexts.dispose(@id)
73
77
  end
74
78
 
75
- def has_target?(target_id)
79
+ def target?(target_id)
76
80
  !!@targets[target_id]
77
81
  end
78
82
 
@@ -19,7 +19,7 @@ module Ferrum
19
19
 
20
20
  def find_by(target_id:)
21
21
  context = nil
22
- @contexts.each_value { |c| context = c if c.has_target?(target_id) }
22
+ @contexts.each_value { |c| context = c if c.target?(target_id) }
23
23
  context
24
24
  end
25
25
 
@@ -41,7 +41,11 @@ module Ferrum
41
41
 
42
42
  def reset
43
43
  @default_context = nil
44
- @contexts.keys.each { |id| dispose(id) }
44
+ @contexts.each_key { |id| dispose(id) }
45
+ end
46
+
47
+ def size
48
+ @contexts.size
45
49
  end
46
50
 
47
51
  private
@@ -64,15 +68,13 @@ module Ferrum
64
68
  end
65
69
 
66
70
  @browser.client.on("Target.targetDestroyed") do |params|
67
- if context = find_by(target_id: params["targetId"])
68
- context.delete_target(params["targetId"])
69
- end
71
+ context = find_by(target_id: params["targetId"])
72
+ context&.delete_target(params["targetId"])
70
73
  end
71
74
 
72
75
  @browser.client.on("Target.targetCrashed") do |params|
73
- if context = find_by(target_id: params["targetId"])
74
- context.delete_target(params["targetId"])
75
- end
76
+ context = find_by(target_id: params["targetId"])
77
+ context&.delete_target(params["targetId"])
76
78
  end
77
79
  end
78
80
 
@@ -3,6 +3,8 @@
3
3
  module Ferrum
4
4
  class Cookies
5
5
  class Cookie
6
+ attr_reader :attributes
7
+
6
8
  def initialize(attributes)
7
9
  @attributes = attributes
8
10
  end
@@ -44,9 +46,7 @@ module Ferrum
44
46
  end
45
47
 
46
48
  def expires
47
- if @attributes["expires"] > 0
48
- Time.at(@attributes["expires"])
49
- end
49
+ Time.at(@attributes["expires"]) if @attributes["expires"].positive?
50
50
  end
51
51
  end
52
52
 
@@ -56,24 +56,25 @@ module Ferrum
56
56
 
57
57
  def all
58
58
  cookies = @page.command("Network.getAllCookies")["cookies"]
59
- cookies.map { |c| [c["name"], Cookie.new(c)] }.to_h
59
+ cookies.to_h { |c| [c["name"], Cookie.new(c)] }
60
60
  end
61
61
 
62
62
  def [](name)
63
63
  all[name]
64
64
  end
65
65
 
66
- def set(name: nil, value: nil, **options)
67
- cookie = options.dup
68
- cookie[:name] ||= name
69
- cookie[:value] ||= value
66
+ def set(options)
67
+ cookie = (
68
+ options.is_a?(Cookie) ? options.attributes : options
69
+ ).dup.transform_keys(&:to_sym)
70
+
70
71
  cookie[:domain] ||= default_domain
71
72
 
72
73
  cookie[:httpOnly] = cookie.delete(:httponly) if cookie.key?(:httponly)
73
74
  cookie[:sameSite] = cookie.delete(:samesite) if cookie.key?(:samesite)
74
75
 
75
76
  expires = cookie.delete(:expires).to_i
76
- cookie[:expires] = expires if expires > 0
77
+ cookie[:expires] = expires if expires.positive?
77
78
 
78
79
  @page.command("Network.setCookie", **cookie)["success"]
79
80
  end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ferrum
4
+ class Error < StandardError; end
5
+ class NoSuchPageError < Error; end
6
+ class NoSuchTargetError < Error; end
7
+ class NotImplementedError < Error; end
8
+ class BinaryNotFoundError < Error; end
9
+ class EmptyPathError < Error; end
10
+
11
+ class StatusError < Error
12
+ def initialize(url, message = nil)
13
+ super(message || "Request to #{url} failed to reach server, check DNS and server status")
14
+ end
15
+ end
16
+
17
+ class PendingConnectionsError < StatusError
18
+ attr_reader :pendings
19
+
20
+ def initialize(url, pendings = [])
21
+ @pendings = pendings
22
+
23
+ message = "Request to #{url} reached server, but there are still pending connections: #{pendings.join(', ')}"
24
+
25
+ super(url, message)
26
+ end
27
+ end
28
+
29
+ class TimeoutError < Error
30
+ def message
31
+ "Timed out waiting for response. It's possible that this happened " \
32
+ "because something took a very long time (for example a page load " \
33
+ "was slow). If so, setting the :timeout option to a higher value might " \
34
+ "help."
35
+ end
36
+ end
37
+
38
+ class ScriptTimeoutError < Error
39
+ def message
40
+ "Timed out waiting for evaluated script to return a value"
41
+ end
42
+ end
43
+
44
+ class ProcessTimeoutError < Error
45
+ attr_reader :output
46
+
47
+ def initialize(timeout, output)
48
+ @output = output
49
+ super("Browser did not produce websocket url within #{timeout} seconds, try to increase `:process_timeout`. See https://github.com/rubycdp/ferrum#customization")
50
+ end
51
+ end
52
+
53
+ class DeadBrowserError < Error
54
+ def initialize(message = "Browser is dead or given window is closed")
55
+ super
56
+ end
57
+ end
58
+
59
+ class NodeMovingError < Error
60
+ def initialize(node, prev, current)
61
+ @node = node
62
+ @prev = prev
63
+ @current = current
64
+ super(message)
65
+ end
66
+
67
+ def message
68
+ "#{@node.inspect} that you're trying to click is moving, hence " \
69
+ "we cannot. Previously it was at #{@prev.inspect} but now at " \
70
+ "#{@current.inspect}."
71
+ end
72
+ end
73
+
74
+ class CoordinatesNotFoundError < Error
75
+ def initialize(message = "Could not compute content quads")
76
+ super
77
+ end
78
+ end
79
+
80
+ class BrowserError < Error
81
+ attr_reader :response
82
+
83
+ def initialize(response)
84
+ @response = response
85
+ super(response["message"])
86
+ end
87
+
88
+ def code
89
+ response["code"]
90
+ end
91
+
92
+ def data
93
+ response["data"]
94
+ end
95
+ end
96
+
97
+ class NodeNotFoundError < BrowserError; end
98
+
99
+ class NoExecutionContextError < BrowserError
100
+ def initialize(response = nil)
101
+ response ||= { "message" => "There's no context available" }
102
+ super(response)
103
+ end
104
+ end
105
+
106
+ class JavaScriptError < BrowserError
107
+ attr_reader :class_name, :message, :stack_trace
108
+
109
+ def initialize(response, stack_trace = nil)
110
+ @class_name, @message = response.values_at("className", "description")
111
+ @stack_trace = stack_trace
112
+ super(response.merge("message" => @message))
113
+ end
114
+ end
115
+ end