undead 0.1.1 → 0.2.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,20 @@
1
+ require "json"
2
+ require "securerandom"
3
+
4
+ module Undead
5
+ class Command
6
+ attr_reader :id
7
+ attr_reader :name
8
+ attr_accessor :args
9
+
10
+ def initialize(name, *args)
11
+ @id = SecureRandom.uuid
12
+ @name = name
13
+ @args = args
14
+ end
15
+
16
+ def message
17
+ JSON.dump({ 'id' => @id, 'name' => @name, 'args' => @args })
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,73 @@
1
+ require "undead/browser"
2
+ require "undead/client"
3
+ require "undead/server"
4
+
5
+ module Undead
6
+ class Driver
7
+ DEFAULT_TIMEOUT = 30
8
+
9
+ attr_accessor :options
10
+
11
+ def initialize(options = {})
12
+ @options = options
13
+ headers = options.fetch(:headers, {})
14
+ end
15
+
16
+ def browser
17
+ @browser ||= begin
18
+ browser = Undead::Browser.new(server, client, logger)
19
+ browser.js_errors = options[:js_errors] if options.key?(:js_errors)
20
+ browser.debug = true if options[:debug]
21
+ browser
22
+ end
23
+ end
24
+
25
+ def server
26
+ @server ||= Undead::Server.new(options[:port], options.fetch(:timeout) { DEFAULT_TIMEOUT })
27
+ end
28
+
29
+ def client
30
+ @client ||= Undead::Client.start(server,
31
+ :path => options[:phantomjs],
32
+ :window_size => options[:window_size],
33
+ :phantomjs_options => phantomjs_options,
34
+ :phantomjs_logger => phantomjs_logger
35
+ )
36
+ end
37
+
38
+ def phantomjs_options
39
+ list = options[:phantomjs_options] || []
40
+
41
+ # PhantomJS defaults to only using SSLv3, which since POODLE (Oct 2014)
42
+ # many sites have dropped from their supported protocols (eg PayPal,
43
+ # Braintree).
44
+ list += ["--ignore-ssl-errors=yes"] unless list.grep(/ignore-ssl-errors/).any?
45
+ list += ["--ssl-protocol=TLSv1"] unless list.grep(/ssl-protocol/).any?
46
+ list
47
+ end
48
+
49
+ def logger
50
+ options[:logger] || (options[:debug] && STDERR)
51
+ end
52
+
53
+ def phantomjs_logger
54
+ options.fetch(:phantomjs_logger, nil)
55
+ end
56
+
57
+ def headers
58
+ browser.get_headers
59
+ end
60
+
61
+ def headers=(headers)
62
+ browser.set_headers(headers)
63
+ end
64
+
65
+ def visit(url)
66
+ browser.visit(url)
67
+ end
68
+
69
+ def html
70
+ browser.body
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,187 @@
1
+ module Undead
2
+ class Error < StandardError; end
3
+ class NoSuchWindowError < Error; end
4
+
5
+ class ClientError < Error
6
+ attr_reader :response
7
+
8
+ def initialize(response)
9
+ @response = response
10
+ end
11
+ end
12
+
13
+ class JSErrorItem
14
+ attr_reader :message, :stack
15
+
16
+ def initialize(message, stack)
17
+ @message = message
18
+ @stack = stack
19
+ end
20
+
21
+ def to_s
22
+ [message, stack].join("\n")
23
+ end
24
+ end
25
+
26
+ class BrowserError < ClientError
27
+ def name
28
+ response['name']
29
+ end
30
+
31
+ def error_parameters
32
+ response['args'].join("\n")
33
+ end
34
+
35
+ def message
36
+ "There was an error inside the PhantomJS portion of Poltergeist. " \
37
+ "If this is the error returned, and not the cause of a more detailed error response, " \
38
+ "this is probably a bug, so please report it. " \
39
+ "\n\n#{name}: #{error_parameters}"
40
+ end
41
+ end
42
+
43
+ class JavascriptError < ClientError
44
+ def javascript_errors
45
+ response['args'].first.map { |data| JSErrorItem.new(data['message'], data['stack']) }
46
+ end
47
+
48
+ def message
49
+ "One or more errors were raised in the Javascript code on the page. " \
50
+ "If you don't care about these errors, you can ignore them by " \
51
+ "setting js_errors: false in your Poltergeist configuration (see " \
52
+ "documentation for details)." \
53
+ "\n\n#{javascript_errors.map(&:to_s).join("\n")}"
54
+ end
55
+ end
56
+
57
+ class StatusFailError < ClientError
58
+ def url
59
+ response['args'].first
60
+ end
61
+
62
+ def details
63
+ response['args'][1]
64
+ end
65
+
66
+ def message
67
+ msg = "Request to '#{url}' failed to reach server, check DNS and/or server status"
68
+ msg += " - #{details}" if details
69
+ msg
70
+ end
71
+ end
72
+
73
+ class FrameNotFound < ClientError
74
+ def name
75
+ response['args'].first
76
+ end
77
+
78
+ def message
79
+ "The frame '#{name}' was not found."
80
+ end
81
+ end
82
+
83
+ class InvalidSelector < ClientError
84
+ def method
85
+ response['args'][0]
86
+ end
87
+
88
+ def selector
89
+ response['args'][1]
90
+ end
91
+
92
+ def message
93
+ "The browser raised a syntax error while trying to evaluate " \
94
+ "#{method} selector #{selector.inspect}"
95
+ end
96
+ end
97
+
98
+ class NodeError < ClientError
99
+ attr_reader :node
100
+
101
+ def initialize(node, response)
102
+ @node = node
103
+ super(response)
104
+ end
105
+ end
106
+
107
+ class ObsoleteNode < NodeError
108
+ def message
109
+ "The element you are trying to interact with is either not part of the DOM, or is " \
110
+ "not currently visible on the page (perhaps display: none is set). " \
111
+ "It's possible the element has been replaced by another element and you meant to interact with " \
112
+ "the new element. If so you need to do a new 'find' in order to get a reference to the " \
113
+ "new element."
114
+ end
115
+ end
116
+
117
+ class MouseEventFailed < NodeError
118
+ def name
119
+ response['args'][0]
120
+ end
121
+
122
+ def selector
123
+ response['args'][1]
124
+ end
125
+
126
+ def position
127
+ [response['args'][2]['x'], response['args'][2]['y']]
128
+ end
129
+
130
+ def message
131
+ "Firing a #{name} at co-ordinates [#{position.join(', ')}] failed. Poltergeist detected " \
132
+ "another element with CSS selector '#{selector}' at this position. " \
133
+ "It may be overlapping the element you are trying to interact with. " \
134
+ "If you don't care about overlapping elements, try using node.trigger('#{name}')."
135
+ end
136
+ end
137
+
138
+ class TimeoutError < Error
139
+ def initialize(message)
140
+ @message = message
141
+ end
142
+
143
+ def message
144
+ "Timed out waiting for response to #{@message}. It's possible that this happened " \
145
+ "because something took a very long time (for example a page load was slow). " \
146
+ "If so, setting the Poltergeist :timeout option to a higher value will help " \
147
+ "(see the docs for details). If increasing the timeout does not help, this is " \
148
+ "probably a bug in Poltergeist - please report it to the issue tracker."
149
+ end
150
+ end
151
+
152
+ class DeadClient < Error
153
+ def initialize(message)
154
+ @message = message
155
+ end
156
+
157
+ def message
158
+ "PhantomJS client died while processing #{@message}"
159
+ end
160
+ end
161
+
162
+ class PhantomJSTooOld < Error
163
+ def self.===(other)
164
+ if Cliver::Dependency::VersionMismatch === other
165
+ warn "#{name} exception has been deprecated in favor of using the " +
166
+ "cliver gem for command-line dependency detection. Please " +
167
+ "handle Cliver::Dependency::VersionMismatch instead."
168
+ true
169
+ else
170
+ super
171
+ end
172
+ end
173
+ end
174
+
175
+ class PhantomJSFailed < Error
176
+ def self.===(other)
177
+ if Cliver::Dependency::NotMet === other
178
+ warn "#{name} exception has been deprecated in favor of using the " +
179
+ "cliver gem for command-line dependency detection. Please " +
180
+ "handle Cliver::Dependency::NotMet instead."
181
+ true
182
+ else
183
+ super
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,44 @@
1
+ require "undead/errors"
2
+ require "undead/web_socket_server"
3
+
4
+ module Undead
5
+ class Server
6
+ attr_reader :socket, :fixed_port, :timeout
7
+
8
+ def initialize(fixed_port = nil, timeout = nil)
9
+ @fixed_port = fixed_port
10
+ @timeout = timeout
11
+ start
12
+ end
13
+
14
+ def port
15
+ @socket.port
16
+ end
17
+
18
+ def timeout=(sec)
19
+ @timeout = @socket.timeout = sec
20
+ end
21
+
22
+ def start
23
+ @socket = Undead::WebSocketServer.new(fixed_port, timeout)
24
+ end
25
+
26
+ def stop
27
+ @socket.close
28
+ end
29
+
30
+ def restart
31
+ stop
32
+ start
33
+ end
34
+
35
+ def send(command)
36
+ receive_timeout = nil # default
37
+ if command.name == 'visit'
38
+ command.args.push(timeout) # set the client set visit timeout parameter
39
+ receive_timeout = timeout + 5 # Add a couple of seconds to let the client timeout first
40
+ end
41
+ @socket.send(command.id, command.message, receive_timeout) or raise Undead::DeadClient.new(command.message)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,7 @@
1
+ module Undead
2
+ class << self
3
+ def windows?
4
+ RbConfig::CONFIG["host_os"] =~ /mingw|mswin|cygwin/
5
+ end
6
+ end
7
+ end
@@ -1,3 +1,3 @@
1
1
  module Undead
2
- VERSION = "0.1.1"
2
+ VERSION = "0.2.0"
3
3
  end
@@ -0,0 +1,109 @@
1
+ require "json"
2
+ require "socket"
3
+ require "websocket/driver"
4
+
5
+ module Undead
6
+ # This is a 'custom' Web Socket server that is designed to be synchronous. What
7
+ # this means is that it sends a message, and then waits for a response. It does
8
+ # not expect to receive a message at any other time than right after it has sent
9
+ # a message. So it is basically operating a request/response cycle (which is not
10
+ # how Web Sockets are usually used, but it's what we want here, as we want to
11
+ # send a message to PhantomJS and then wait for it to respond).
12
+ class WebSocketServer
13
+ # How much to try to read from the socket at once (it's kinda arbitrary because we
14
+ # just keep reading until we've received a full frame)
15
+ RECV_SIZE = 1024
16
+
17
+ # How many seconds to try to bind to the port for before failing
18
+ BIND_TIMEOUT = 5
19
+
20
+ HOST = '127.0.0.1'
21
+
22
+ attr_reader :port, :driver, :socket, :server
23
+ attr_accessor :timeout
24
+
25
+ def initialize(port = nil, timeout = nil)
26
+ @timeout = timeout
27
+ @server = start_server(port)
28
+ @receive_mutex = Mutex.new
29
+ end
30
+
31
+ def start_server(port)
32
+ time = Time.now
33
+
34
+ begin
35
+ TCPServer.open(HOST, port || 0).tap do |server|
36
+ @port = server.addr[1]
37
+ end
38
+ rescue Errno::EADDRINUSE
39
+ if (Time.now - time) < BIND_TIMEOUT
40
+ sleep(0.01)
41
+ retry
42
+ else
43
+ raise
44
+ end
45
+ end
46
+ end
47
+
48
+ def connected?
49
+ !socket.nil?
50
+ end
51
+
52
+ # Accept a client on the TCP server socket, then receive its initial HTTP request
53
+ # and use that to initialize a Web Socket.
54
+ def accept
55
+ @socket = server.accept
56
+ @messages = {}
57
+
58
+ @driver = ::WebSocket::Driver.server(self)
59
+ @driver.on(:connect) { |event| @driver.start }
60
+ @driver.on(:message) do |event|
61
+ command_id = JSON.load(event.data)['command_id']
62
+ @messages[command_id] = event.data
63
+ end
64
+ end
65
+
66
+ def write(data)
67
+ @socket.write(data)
68
+ end
69
+
70
+ # Block until the next message is available from the Web Socket.
71
+ # Raises Errno::EWOULDBLOCK if timeout is reached.
72
+ def receive(cmd_id, receive_timeout=nil)
73
+ receive_timeout ||= timeout
74
+ start = Time.now
75
+
76
+ until @messages.has_key?(cmd_id)
77
+ raise Errno::EWOULDBLOCK if (Time.now - start) >= receive_timeout
78
+ if @receive_mutex.try_lock
79
+ begin
80
+ IO.select([socket], [], [], receive_timeout) or raise Errno::EWOULDBLOCK
81
+ data = socket.recv(RECV_SIZE)
82
+ break if data.empty?
83
+ driver.parse(data)
84
+ ensure
85
+ @receive_mutex.unlock
86
+ end
87
+ else
88
+ sleep(0.05)
89
+ end
90
+ end
91
+ @messages.delete(cmd_id)
92
+ end
93
+
94
+ # Send a message and block until there is a response
95
+ def send(cmd_id, message, accept_timeout=nil)
96
+ accept unless connected?
97
+ driver.text(message)
98
+ receive(cmd_id, accept_timeout)
99
+ rescue Errno::EWOULDBLOCK
100
+ raise TimeoutError.new(message)
101
+ end
102
+
103
+ # Closing sockets separately as `close_read`, `close_write`
104
+ # causes IO mistakes on JRuby, using just `close` fixes that.
105
+ def close
106
+ [server, socket].compact.each(&:close)
107
+ end
108
+ end
109
+ end