poltergeist 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,102 @@
1
+ class Poltergeist.WebPage
2
+ @CALLBACKS = ['onAlert', 'onConsoleMessage', 'onLoadFinished', 'onInitialized',
3
+ 'onLoadStarted', 'onResourceRequested', 'onResourceReceived']
4
+ @DELEGATES = ['open', 'sendEvent', 'uploadFile', 'release', 'render']
5
+ @COMMANDS = ['currentUrl', 'find', 'nodeCall', 'pushFrame', 'popFrame', 'documentSize']
6
+
7
+ constructor: ->
8
+ @native = require('webpage').create()
9
+ @nodes = {}
10
+ @_source = ""
11
+
12
+ for callback in WebPage.CALLBACKS
13
+ this.bindCallback(callback)
14
+
15
+ this.injectAgent()
16
+
17
+ for command in @COMMANDS
18
+ do (command) =>
19
+ this.prototype[command] =
20
+ (arguments...) -> this.runCommand(command, arguments)
21
+
22
+ for delegate in @DELEGATES
23
+ do (delegate) =>
24
+ this.prototype[delegate] =
25
+ -> @native[delegate].apply(@native, arguments)
26
+
27
+ onInitializedNative: ->
28
+ @_source = null
29
+ this.injectAgent()
30
+ this.setScrollPosition({ left: 0, top: 0})
31
+
32
+ injectAgent: ->
33
+ if this.evaluate(-> typeof __poltergeist) == "undefined"
34
+ @native.injectJs('agent.js')
35
+
36
+ onConsoleMessageNative: (message) ->
37
+ if message == '__DOMContentLoaded'
38
+ @_source = @native.content
39
+ false
40
+
41
+ onLoadFinishedNative: ->
42
+ @_source or= @native.content
43
+
44
+ onConsoleMessage: (message) ->
45
+ console.log(message)
46
+
47
+ content: ->
48
+ @native.content
49
+
50
+ source: ->
51
+ @_source
52
+
53
+ viewportSize: ->
54
+ @native.viewportSize
55
+
56
+ scrollPosition: ->
57
+ @native.scrollPosition
58
+
59
+ setScrollPosition: (pos) ->
60
+ @native.scrollPosition = pos
61
+
62
+ viewport: ->
63
+ scroll = this.scrollPosition()
64
+ size = this.viewportSize()
65
+
66
+ top: scroll.top, bottom: scroll.top + size.height,
67
+ left: scroll.left, right: scroll.left + size.width,
68
+ width: size.width, height: size.height
69
+
70
+ get: (id) ->
71
+ @nodes[id] or= new Poltergeist.Node(this, id)
72
+
73
+ evaluate: (fn, args...) ->
74
+ @native.evaluate("function() { return #{this.stringifyCall(fn, args)} }")
75
+
76
+ execute: (fn, args...) ->
77
+ @native.evaluate("function() { #{this.stringifyCall(fn, args)} }")
78
+
79
+ stringifyCall: (fn, args) ->
80
+ if args.length == 0
81
+ "(#{fn.toString()})()"
82
+ else
83
+ # The JSON.stringify happens twice because the second time we are essentially
84
+ # escaping the string.
85
+ "(#{fn.toString()}).apply(this, JSON.parse(#{JSON.stringify(JSON.stringify(args))}))"
86
+
87
+ # For some reason phantomjs seems to have trouble with doing 'fat arrow' binding here,
88
+ # hence the 'that' closure.
89
+ bindCallback: (name) ->
90
+ that = this
91
+ @native[name] = ->
92
+ if that[name + 'Native']? # For internal callbacks
93
+ result = that[name + 'Native'].apply(that, arguments)
94
+
95
+ if result != false && that[name]? # For externally set callbacks
96
+ that[name].apply(that, arguments)
97
+
98
+ runCommand: (name, arguments) ->
99
+ this.evaluate(
100
+ (name, arguments) -> __poltergeist[name].apply(__poltergeist, arguments),
101
+ name, arguments
102
+ )
@@ -0,0 +1,57 @@
1
+ require 'open3'
2
+
3
+ module Capybara::Poltergeist
4
+ class Client
5
+ PHANTOM_SCRIPT = File.expand_path('../client/compiled/main.js', __FILE__)
6
+
7
+ attr_reader :pid, :port, :path
8
+
9
+ def initialize(port, path = nil)
10
+ @port = port
11
+ @path = path || 'phantomjs'
12
+
13
+ start
14
+ at_exit { stop }
15
+ end
16
+
17
+ def start
18
+ @pid = Process.fork do
19
+ Open3.popen3("#{path} #{PHANTOM_SCRIPT} #{port}") do |stdin, stdout, stderr|
20
+ loop do
21
+ select = IO.select([stdout, stderr])
22
+ stream = select.first.first
23
+
24
+ break if stream.eof?
25
+
26
+ if stream == stdout
27
+ STDOUT.puts stdout.readline
28
+ elsif stream == stderr
29
+ line = stderr.readline
30
+
31
+ # QtWebkit seems to throw this error all the time when using WebSockets, but
32
+ # it doesn't appear to actually stop anything working, so filter it out.
33
+ #
34
+ # This isn't the nicest solution I know :( Hopefully it will be fixed in
35
+ # QtWebkit (if you search for this string, you'll see it's been reported in
36
+ # various places).
37
+ unless line.include?('WebCore::SocketStreamHandlePrivate::socketSentData()')
38
+ STDERR.puts line
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ def stop
47
+ Process.kill('TERM', pid)
48
+ rescue Errno::ESRCH
49
+ # Bovvered, I ain't
50
+ end
51
+
52
+ def restart
53
+ stop
54
+ start
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,85 @@
1
+ module Capybara::Poltergeist
2
+ class Driver < Capybara::Driver::Base
3
+ attr_reader :app, :server, :browser, :options
4
+
5
+ def initialize(app, options = {})
6
+ @app = app
7
+ @options = options
8
+ @server = Capybara::Server.new(app)
9
+ @browser = nil
10
+
11
+ @server.boot if Capybara.run_server
12
+ end
13
+
14
+ def browser
15
+ @browser ||= Browser.new(
16
+ :logger => logger,
17
+ :phantomjs => options[:phantomjs]
18
+ )
19
+ end
20
+
21
+ def restart
22
+ browser.restart
23
+ end
24
+
25
+ # logger should be an object that responds to puts, or nil
26
+ def logger
27
+ options[:logger] || (options[:debug] && STDERR)
28
+ end
29
+
30
+ def visit(path, attributes = {})
31
+ browser.visit(url(path), attributes)
32
+ end
33
+
34
+ def current_url
35
+ browser.current_url
36
+ end
37
+
38
+ def body
39
+ browser.body
40
+ end
41
+
42
+ def source
43
+ browser.source
44
+ end
45
+
46
+ def find(selector)
47
+ browser.find(selector).map { |node| Capybara::Poltergeist::Node.new(self, node) }
48
+ end
49
+
50
+ def evaluate_script(script)
51
+ browser.evaluate(script)
52
+ end
53
+
54
+ def execute_script(script)
55
+ browser.execute(script)
56
+ nil
57
+ end
58
+
59
+ def within_frame(id, &block)
60
+ browser.within_frame(id, &block)
61
+ end
62
+
63
+ def reset!
64
+ browser.reset
65
+ end
66
+
67
+ def render(path)
68
+ browser.render(path)
69
+ end
70
+
71
+ def wait?
72
+ true
73
+ end
74
+
75
+ def invalid_element_errors
76
+ [Capybara::Poltergeist::ObsoleteNode]
77
+ end
78
+
79
+ private
80
+
81
+ def url(path)
82
+ server.url(path)
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,26 @@
1
+ module Capybara
2
+ module Poltergeist
3
+ class Error < StandardError
4
+ end
5
+
6
+ class BrowserError < Error
7
+ attr_reader :text
8
+
9
+ def initialize(text)
10
+ @text = text
11
+ end
12
+
13
+ def message
14
+ "Received error from PhantomJS client: #{text}"
15
+ end
16
+ end
17
+
18
+ class ObsoleteNode < Error
19
+ attr_reader :node
20
+
21
+ def initialize(node)
22
+ @node = node
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,92 @@
1
+ module Capybara::Poltergeist
2
+ class Node < Capybara::Driver::Node
3
+ alias id native
4
+
5
+ def browser
6
+ driver.browser
7
+ end
8
+
9
+ def command(name, *args)
10
+ browser.send(name, id, *args)
11
+ rescue BrowserError => error
12
+ if error.text == 'Poltergeist.ObsoleteNode'
13
+ raise ObsoleteNode.new(self)
14
+ else
15
+ raise error
16
+ end
17
+ end
18
+
19
+ def find(selector)
20
+ browser.find(selector, id).map { |node| self.class.new(driver, node) }
21
+ end
22
+
23
+ def text
24
+ command :text
25
+ end
26
+
27
+ def [](name)
28
+ command :attribute, name
29
+ end
30
+
31
+ def value
32
+ command :value
33
+ end
34
+
35
+ def set(value)
36
+ if tag_name == 'input'
37
+ type = self[:type]
38
+
39
+ if type == 'radio'
40
+ click
41
+ elsif type == 'checkbox'
42
+ if value && !checked? || !value && checked?
43
+ click
44
+ end
45
+ elsif type == 'file'
46
+ command :select_file, value
47
+ else
48
+ command :set, value
49
+ end
50
+ elsif tag_name == 'textarea'
51
+ command :set, value
52
+ end
53
+ end
54
+
55
+ def select_option
56
+ command :select, true
57
+ end
58
+
59
+ def unselect_option
60
+ command(:select, false) or
61
+ raise(Capybara::UnselectNotAllowed, "Cannot unselect option from single select box.")
62
+ end
63
+
64
+ def tag_name
65
+ @tag_name ||= command(:tag_name)
66
+ end
67
+
68
+ def visible?
69
+ command :visible?
70
+ end
71
+
72
+ def checked?
73
+ self[:checked]
74
+ end
75
+
76
+ def selected?
77
+ self[:selected]
78
+ end
79
+
80
+ def click
81
+ command :click
82
+ end
83
+
84
+ def drag_to(other)
85
+ command :drag, other.id
86
+ end
87
+
88
+ def trigger(event)
89
+ command :trigger, event
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,35 @@
1
+ module Capybara::Poltergeist
2
+ class Server
3
+ attr_reader :port
4
+
5
+ def initialize
6
+ @port = find_available_port
7
+ start
8
+ end
9
+
10
+ def start
11
+ server_manager.start(port)
12
+ end
13
+
14
+ def restart
15
+ server_manager.restart(port)
16
+ end
17
+
18
+ def send(message)
19
+ server_manager.send(port, message)
20
+ end
21
+
22
+ private
23
+
24
+ def server_manager
25
+ ServerManager.instance
26
+ end
27
+
28
+ def find_available_port
29
+ server = TCPServer.new('127.0.0.1', 0)
30
+ server.addr[1]
31
+ ensure
32
+ server.close if server
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,118 @@
1
+ require 'em-websocket'
2
+ require 'timeout'
3
+ require 'singleton'
4
+
5
+ module Capybara::Poltergeist
6
+ # The reason for the lolzy thread code is because the EM reactor blocks the thread, so
7
+ # we have to put it in its own thread.
8
+ #
9
+ # The reason we are using EM, is because it has a WebSocket library. If there's a decent
10
+ # WebSocket library that doesn't require an event loop, we can use that.
11
+ class ServerManager
12
+ include Singleton
13
+
14
+ TIMEOUT = 30
15
+
16
+ class TimeoutError < StandardError
17
+ def initialize(message)
18
+ super "Server timed out waiting for response to #{@message}"
19
+ end
20
+ end
21
+
22
+ attr_reader :sockets
23
+
24
+ def initialize
25
+ @instruction = nil
26
+ @response = nil
27
+ @sockets = {}
28
+ @waiting = false
29
+
30
+ @main = Thread.current
31
+ @thread = Thread.new { start_event_loop }
32
+ @thread.abort_on_exception = true
33
+ end
34
+
35
+ def start(port)
36
+ thread_execute { start_websocket_server(port) }
37
+ end
38
+
39
+ # This isn't a 'proper' restart. It's more like 'wait for the client to connect again'.
40
+ def restart(port)
41
+ sockets[port] = nil
42
+ @thread.run
43
+ end
44
+
45
+ def send(port, message)
46
+ @message = nil
47
+
48
+ Timeout.timeout(TIMEOUT, TimeoutError.new(message)) do
49
+ # Ensure there is a socket before trying to send a message on it.
50
+ Thread.pass until sockets[port]
51
+
52
+ # Send the message
53
+ thread_execute { sockets[port].send(message) }
54
+
55
+ # Wait for the response message
56
+ Thread.pass until @message
57
+ end
58
+
59
+ @message
60
+ end
61
+
62
+ def thread_execute(&instruction)
63
+ # Ensure that the thread is waiting for an instruction before we wake it up
64
+ # to receive the instruction
65
+ Thread.pass until @waiting
66
+
67
+ @instruction = instruction
68
+ @waiting = false
69
+
70
+ # Bring the EM thread out of its sleep so that it can execute the instruction.
71
+ @thread.run
72
+ end
73
+
74
+ def start_event_loop
75
+ EM.run { await_instruction }
76
+ end
77
+
78
+ def start_websocket_server(port)
79
+ EventMachine.start_server('127.0.0.1', port, EventMachine::WebSocket::Connection, {}) do |socket|
80
+ socket.onopen do
81
+ connection_opened(port, socket)
82
+ end
83
+
84
+ socket.onmessage do |message|
85
+ message_received(message)
86
+ end
87
+ end
88
+ end
89
+
90
+ def connection_opened(port, socket)
91
+ sockets[port] = socket
92
+ await_instruction
93
+ end
94
+
95
+ def message_received(message)
96
+ @message = message
97
+ await_instruction
98
+ end
99
+
100
+ # Stop the thread so that it can be manually scheduled by the parent once there is
101
+ # something to do
102
+ def await_instruction
103
+ # Sleep this thread. The main thread will wake us up when there is an instruction
104
+ # to perform.
105
+ @waiting = true
106
+ Thread.stop
107
+
108
+ # Main thread has woken us up, so execute the current instruction.
109
+ if @instruction
110
+ @instruction.call
111
+ @instruction = nil
112
+ end
113
+
114
+ # Continue execution of the thread until a socket callback fires, which will
115
+ # trigger then method again and send us back to sleep.
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,5 @@
1
+ module Capybara
2
+ module Poltergeist
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,20 @@
1
+ require 'capybara'
2
+
3
+ module Capybara
4
+ module Poltergeist
5
+ autoload :Driver, 'capybara/poltergeist/driver'
6
+ autoload :Browser, 'capybara/poltergeist/browser'
7
+ autoload :Node, 'capybara/poltergeist/node'
8
+ autoload :ServerManager, 'capybara/poltergeist/server_manager'
9
+ autoload :Server, 'capybara/poltergeist/server'
10
+ autoload :Client, 'capybara/poltergeist/client'
11
+
12
+ autoload :Error, 'capybara/poltergeist/errors'
13
+ autoload :BrowserError, 'capybara/poltergeist/errors'
14
+ autoload :ObsoleteNode, 'capybara/poltergeist/errors'
15
+ end
16
+ end
17
+
18
+ Capybara.register_driver :poltergeist do |app|
19
+ Capybara::Poltergeist::Driver.new(app)
20
+ end
metadata ADDED
@@ -0,0 +1,102 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: poltergeist
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Jon Leighton
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2011-10-27 00:00:00.000000000Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: capybara
16
+ requirement: &20250700 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: 1.1.0
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: *20250700
25
+ - !ruby/object:Gem::Dependency
26
+ name: em-websocket
27
+ requirement: &20220020 !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - ~>
31
+ - !ruby/object:Gem::Version
32
+ version: 0.3.1
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: *20220020
36
+ - !ruby/object:Gem::Dependency
37
+ name: json
38
+ requirement: &20216800 !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - ~>
42
+ - !ruby/object:Gem::Version
43
+ version: '1.6'
44
+ type: :runtime
45
+ prerelease: false
46
+ version_requirements: *20216800
47
+ description: PhantomJS driver for Capybara
48
+ email:
49
+ - j@jonathanleighton.com
50
+ executables: []
51
+ extensions: []
52
+ extra_rdoc_files: []
53
+ files:
54
+ - lib/capybara/poltergeist.rb
55
+ - lib/capybara/poltergeist/client.rb
56
+ - lib/capybara/poltergeist/server.rb
57
+ - lib/capybara/poltergeist/browser.rb
58
+ - lib/capybara/poltergeist/errors.rb
59
+ - lib/capybara/poltergeist/version.rb
60
+ - lib/capybara/poltergeist/driver.rb
61
+ - lib/capybara/poltergeist/client/connection.coffee
62
+ - lib/capybara/poltergeist/client/node.coffee
63
+ - lib/capybara/poltergeist/client/compiled/agent.js
64
+ - lib/capybara/poltergeist/client/compiled/browser.js
65
+ - lib/capybara/poltergeist/client/compiled/main.js
66
+ - lib/capybara/poltergeist/client/compiled/node.js
67
+ - lib/capybara/poltergeist/client/compiled/connection.js
68
+ - lib/capybara/poltergeist/client/compiled/web_page.js
69
+ - lib/capybara/poltergeist/client/main.coffee
70
+ - lib/capybara/poltergeist/client/web_page.coffee
71
+ - lib/capybara/poltergeist/client/browser.coffee
72
+ - lib/capybara/poltergeist/client/agent.coffee
73
+ - lib/capybara/poltergeist/server_manager.rb
74
+ - lib/capybara/poltergeist/node.rb
75
+ - LICENSE
76
+ - README.md
77
+ - CHANGELOG.md
78
+ homepage: http://github.com/jonleighton/poltergeist
79
+ licenses: []
80
+ post_install_message:
81
+ rdoc_options: []
82
+ require_paths:
83
+ - lib
84
+ required_ruby_version: !ruby/object:Gem::Requirement
85
+ none: false
86
+ requirements:
87
+ - - ! '>='
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ required_rubygems_version: !ruby/object:Gem::Requirement
91
+ none: false
92
+ requirements:
93
+ - - ! '>='
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ requirements: []
97
+ rubyforge_project:
98
+ rubygems_version: 1.8.6
99
+ signing_key:
100
+ specification_version: 3
101
+ summary: PhantomJS driver for Capybara
102
+ test_files: []