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.
- checksums.yaml +4 -4
- data/lib/undead.rb +1 -2
- data/lib/undead/agent.rb +6 -11
- data/lib/undead/browser.rb +68 -0
- data/lib/undead/client.rb +151 -0
- data/lib/undead/client/compiled/agent.js +594 -0
- data/lib/undead/client/compiled/browser.js +692 -0
- data/lib/undead/client/compiled/cmd.js +31 -0
- data/lib/undead/client/compiled/connection.js +25 -0
- data/lib/undead/client/compiled/main.js +229 -0
- data/lib/undead/client/compiled/node.js +187 -0
- data/lib/undead/client/compiled/web_page.js +589 -0
- data/lib/undead/command.rb +20 -0
- data/lib/undead/driver.rb +73 -0
- data/lib/undead/errors.rb +187 -0
- data/lib/undead/server.rb +44 -0
- data/lib/undead/utility.rb +7 -0
- data/lib/undead/version.rb +1 -1
- data/lib/undead/web_socket_server.rb +109 -0
- data/undead.gemspec +4 -4
- metadata +27 -11
@@ -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
|
data/lib/undead/version.rb
CHANGED
@@ -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
|