poltergeist 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/CHANGELOG.md ADDED
File without changes
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2011 Jonathan Leighton
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # Poltergeist - A PhantomJS driver for Capybara #
2
+
3
+ Version: 0.1.0
4
+
5
+ Poltergeist is a driver for [Capybara](https://github.com/jnicklas/capybara). It allows you to
6
+ run your Capybara tests on a headless [WebKit](http://webkit.org) browser,
7
+ provided by [PhantomJS](http://www.phantomjs.org/).
8
+
9
+ ## Installation ##
10
+
11
+ Add `poltergeist` to your Gemfile, and add `Capybara.javascript_driver = :poltergeist`
12
+ in your test setup.
13
+
14
+ You will also need PhantomJS 1.3+ on your system.
15
+ [Here's how to do that](http://code.google.com/p/phantomjs/wiki/BuildInstructions).
16
+
17
+ Currently PhantomJS is not 'truly headless', so to run it on a continuous integration
18
+ server you will need to use [Xvfb](http://en.wikipedia.org/wiki/Xvfb). You can either use the
19
+ [headless gem](https://github.com/leonid-shevtsov/headless) for this,
20
+ or make sure that Xvfb is running and the `DISPLAY` environment variable is set.
21
+
22
+ ## What's supported? ##
23
+
24
+ Poltergeist supports basically everything that is supported by the stock Selenium driver,
25
+ including Javascript, drag-and-drop, etc.
26
+
27
+ Additionally, you can grab screenshots of the page at any point by calling
28
+ `page.driver.render('/path/to/file.png')` (this works the same way as the PhantomJS
29
+ render feature, so you can specify other extensions like `.pdf`, `.gif`, etc.)
30
+
31
+ ## Customisation ##
32
+
33
+ You can customise the way that Capybara sets up Poltegeist via the following code in your
34
+ test setup:
35
+
36
+ Capybara.register_driver :poltergeist do |app|
37
+ Capybara::Poltergeist::Driver.new(app, options)
38
+ end
39
+
40
+ `options` is a hash of options. The following options are supported:
41
+
42
+ * `:phantomjs` (String) - A custom path to the phantomjs executable
43
+ * `:debug` (Boolean) - When true, debug output is logged to `STDERR`
44
+ * `:logger` (Object responding to `puts`) - When present, debug output is written to this object
45
+
46
+ ## Bugs ##
47
+
48
+ Please file bug reports on Github and include example code to reproduce the problem wherever
49
+ possible. (Tests are even better.)
50
+
51
+ ## Why not use [capybara-webkit](https://github.com/thoughtbot/capybara-webkit)? ##
52
+
53
+ If capybara-webkit works for you, then by all means carry on using it.
54
+
55
+ However, I have had some trouble with it, and Poltergeist basically started
56
+ as an experiment to see whether a PhantomJS driver was possible. (It turned out it
57
+ was, but only thanks to some new features in the recent 1.3.0 release.)
58
+
59
+ In the long term, I think having a PhantomJS driver makes sense, because that allows
60
+ PhantomJS to concentrate on being an awesome headless browser, while the capybara driver
61
+ (Poltergeist) is able to be the minimal amount of glue code necessary to drive the
62
+ browser.
63
+
64
+ ## License ##
65
+
66
+ Copyright (c) 2011 Jonathan Leighton
67
+
68
+ Permission is hereby granted, free of charge, to any person obtaining
69
+ a copy of this software and associated documentation files (the
70
+ "Software"), to deal in the Software without restriction, including
71
+ without limitation the rights to use, copy, modify, merge, publish,
72
+ distribute, sublicense, and/or sell copies of the Software, and to
73
+ permit persons to whom the Software is furnished to do so, subject to
74
+ the following conditions:
75
+
76
+ The above copyright notice and this permission notice shall be
77
+ included in all copies or substantial portions of the Software.
78
+
79
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
80
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
81
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
82
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
83
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
84
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
85
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,130 @@
1
+ require 'json'
2
+
3
+ module Capybara::Poltergeist
4
+ class Browser
5
+ attr_reader :options, :server, :client
6
+
7
+ def initialize(options = {})
8
+ @options = options
9
+ @server = Server.new
10
+ @client = Client.new(server.port, options[:phantomjs])
11
+ end
12
+
13
+ def restart
14
+ server.restart
15
+ client.restart
16
+ end
17
+
18
+ def visit(url, attributes = {})
19
+ command 'visit', url
20
+ end
21
+
22
+ def current_url
23
+ command 'current_url'
24
+ end
25
+
26
+ def body
27
+ command 'body'
28
+ end
29
+
30
+ def source
31
+ command 'source'
32
+ end
33
+
34
+ def find(selector, id = nil)
35
+ command 'find', selector, id
36
+ end
37
+
38
+ def text(id)
39
+ command 'text', id
40
+ end
41
+
42
+ def attribute(id, name)
43
+ command 'attribute', id, name
44
+ end
45
+
46
+ def value(id)
47
+ command 'value', id
48
+ end
49
+
50
+ def set(id, value)
51
+ command 'set', id, value
52
+ end
53
+
54
+ def select_file(id, value)
55
+ command 'select_file', id, value
56
+ end
57
+
58
+ def tag_name(id)
59
+ command('tag_name', id).downcase
60
+ end
61
+
62
+ def visible?(id)
63
+ command 'visible', id
64
+ end
65
+
66
+ def evaluate(script)
67
+ command 'evaluate', script
68
+ end
69
+
70
+ def execute(script)
71
+ command 'execute', script
72
+ end
73
+
74
+ def within_frame(id, &block)
75
+ command 'push_frame', id
76
+ yield
77
+ command 'pop_frame'
78
+ end
79
+
80
+ def reset
81
+ visit('about:blank')
82
+ end
83
+
84
+ def click(id)
85
+ command 'click', id
86
+ end
87
+
88
+ def drag(id, other_id)
89
+ command 'drag', id, other_id
90
+ end
91
+
92
+ def select(id, value)
93
+ command 'select', id, value
94
+ end
95
+
96
+ def trigger(id, event)
97
+ command 'trigger', id, event
98
+ end
99
+
100
+ def reset
101
+ command 'reset'
102
+ end
103
+
104
+ def render(path)
105
+ command 'render', path
106
+ end
107
+
108
+ def logger
109
+ options[:logger]
110
+ end
111
+
112
+ def log(message)
113
+ logger.puts message if logger
114
+ end
115
+
116
+ def command(name, *args)
117
+ message = { 'name' => name, 'args' => args }
118
+ log message.inspect
119
+
120
+ json = JSON.parse(server.send(JSON.generate(message)))
121
+ log json.inspect
122
+
123
+ if json['error']
124
+ raise BrowserError.new(json['error'])
125
+ else
126
+ json['response']
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,175 @@
1
+ # This is injected into each page that is loaded
2
+
3
+ class PoltergeistAgent
4
+ constructor: ->
5
+ @elements = []
6
+ @nodes = {}
7
+ @windows = []
8
+ this.pushWindow(window)
9
+
10
+ pushWindow: (new_window) ->
11
+ @windows.push(new_window)
12
+
13
+ @window = new_window
14
+ @document = @window.document
15
+
16
+ popWindow: ->
17
+ @windows.pop()
18
+
19
+ @window = @windows[@windows.length - 1]
20
+ @document = @window.document
21
+
22
+ pushFrame: (id) ->
23
+ this.pushWindow @document.getElementById(id).contentWindow
24
+
25
+ popFrame: ->
26
+ this.popWindow()
27
+
28
+ currentUrl: ->
29
+ window.location.toString()
30
+
31
+ find: (selector, id) ->
32
+ context = if id? then @elements[id] else @document
33
+ results = @document.evaluate(selector, context, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
34
+ ids = []
35
+
36
+ for i in [0...results.snapshotLength]
37
+ ids.push(this.register(results.snapshotItem(i)))
38
+
39
+ ids
40
+
41
+ register: (element) ->
42
+ @elements.push(element)
43
+ @elements.length - 1
44
+
45
+ documentSize: ->
46
+ height: @document.documentElement.scrollHeight,
47
+ width: @document.documentElement.scrollWidth
48
+
49
+ get: (id) ->
50
+ @nodes[id] or= new PoltergeistAgent.Node(this, @elements[id])
51
+
52
+ nodeCall: (id, name, arguments) ->
53
+ node = this.get(id)
54
+ node[name].apply(node, arguments)
55
+
56
+ class PoltergeistAgent.Node
57
+ @EVENTS = {
58
+ FOCUS: ['blur', 'focus', 'focusin', 'focusout'],
59
+ MOUSE: ['click', 'dblclick', 'mousedown', 'mouseenter', 'mouseleave', 'mousemove',
60
+ 'mouseover', 'mouseout', 'mouseup']
61
+ }
62
+
63
+ constructor: (@agent, @element) ->
64
+
65
+ parentId: ->
66
+ @agent.register(@element.parentNode)
67
+
68
+ isObsolete: ->
69
+ obsolete = (element) =>
70
+ if element.parentNode?
71
+ if element.parentNode == @agent.document
72
+ false
73
+ else
74
+ obsolete element.parentNode
75
+ else
76
+ true
77
+ obsolete @element
78
+
79
+ changed: ->
80
+ event = document.createEvent('HTMLEvents')
81
+ event.initEvent("change", true, false)
82
+ @element.dispatchEvent(event)
83
+
84
+ text: ->
85
+ @element.textContent
86
+
87
+ getAttribute: (name) ->
88
+ if name == 'checked' || name == 'selected'
89
+ @element[name]
90
+ else
91
+ @element.getAttribute(name)
92
+
93
+ value: ->
94
+ if @element.tagName == 'SELECT' && @element.multiple
95
+ option.value for option in @element.children when option.selected
96
+ else
97
+ @element.value
98
+
99
+ set: (value) ->
100
+ if (@element.maxLength >= 0)
101
+ value = value.substr(0, @element.maxLength)
102
+
103
+ @element.value = value
104
+ this.changed()
105
+
106
+ isMultiple: ->
107
+ @element.multiple
108
+
109
+ setAttribute: (name, value) ->
110
+ @element.setAttribute(name, value)
111
+
112
+ removeAttribute: (name) ->
113
+ @element.removeAttribute(name)
114
+
115
+ select: (value) ->
116
+ if value == false && !@element.parentNode.multiple
117
+ false
118
+ else
119
+ @element.selected = value
120
+ this.changed()
121
+ true
122
+
123
+ tagName: ->
124
+ @element.tagName
125
+
126
+ elementVisible: (element) ->
127
+
128
+ isVisible: (id) ->
129
+ visible = (element) ->
130
+ if @window.getComputedStyle(element).display == 'none'
131
+ false
132
+ else if element.parentElement
133
+ visible element.parentElement
134
+ else
135
+ true
136
+ visible @element
137
+
138
+ position: (id) ->
139
+ pos = (element) ->
140
+ x = element.offsetLeft
141
+ y = element.offsetTop
142
+
143
+ if element.offsetParent
144
+ parentPos = pos(element.offsetParent)
145
+
146
+ x += parentPos.x
147
+ y += parentPos.y
148
+
149
+ { x: x, y: y }
150
+ pos @element
151
+
152
+ trigger: (name) ->
153
+ if Node.EVENTS.MOUSE.indexOf(name) != -1
154
+ event = document.createEvent('MouseEvent')
155
+ event.initMouseEvent(
156
+ name, true, true, @agent.window, 0, 0, 0, 0, 0,
157
+ false, false, false, false, 0, null
158
+ )
159
+ else if Node.EVENTS.FOCUS.indexOf(name) != -1
160
+ event = document.createEvent('HTMLEvents')
161
+ event.initEvent(name, true, true)
162
+ else
163
+ throw "Unknown event"
164
+
165
+ @element.dispatchEvent(event)
166
+
167
+ window.__poltergeist = new PoltergeistAgent
168
+
169
+ document.addEventListener(
170
+ 'DOMContentLoaded',
171
+ -> console.log('__DOMContentLoaded')
172
+ )
173
+
174
+ # Important to return true here - Phantom seems to choke otherwise
175
+ true
@@ -0,0 +1,121 @@
1
+ class Poltergeist.Browser
2
+ constructor: (@owner) ->
3
+ @awaiting_response = false
4
+ this.resetPage()
5
+
6
+ resetPage: ->
7
+ @page.release() if @page?
8
+
9
+ @page = new Poltergeist.WebPage
10
+ @page.onLoadFinished = (status) =>
11
+ if @awaiting_response
12
+ @owner.sendResponse(status)
13
+ @awaiting_response = false
14
+
15
+ visit: (url) ->
16
+ @awaiting_response = true
17
+ @page.open(url)
18
+
19
+ current_url: ->
20
+ @owner.sendResponse @page.currentUrl()
21
+
22
+ body: ->
23
+ @owner.sendResponse @page.content()
24
+
25
+ source: ->
26
+ @owner.sendResponse @page.source()
27
+
28
+ find: (selector, id) ->
29
+ @owner.sendResponse @page.find(selector, id)
30
+
31
+ text: (id) ->
32
+ @owner.sendResponse @page.get(id).text()
33
+
34
+ attribute: (id, name) ->
35
+ @owner.sendResponse @page.get(id).getAttribute(name)
36
+
37
+ value: (id) ->
38
+ @owner.sendResponse @page.get(id).value()
39
+
40
+ set: (id, value) ->
41
+ @page.get(id).set(value)
42
+ @owner.sendResponse(true)
43
+
44
+ # PhantomJS only allows us to reference the element by CSS selector, not XPath,
45
+ # so we have to add an attribute to the element to identify it, then remove it
46
+ # afterwards.
47
+ #
48
+ # PhantomJS does not support multiple-file inputs, so we have to blatently cheat
49
+ # by temporarily changing it to a single-file input. This obviously could break
50
+ # things in various ways, which is not ideal, but it works in the simplest case.
51
+ select_file: (id, value) ->
52
+ element = @page.get(id)
53
+
54
+ multiple = element.isMultiple()
55
+
56
+ element.removeAttribute('multiple') if multiple
57
+ element.setAttribute('_poltergeist_selected', '')
58
+
59
+ @page.uploadFile('[_poltergeist_selected]', value)
60
+
61
+ element.removeAttribute('_poltergeist_selected')
62
+ element.setAttribute('multiple', 'multiple') if multiple
63
+
64
+ @owner.sendResponse(true)
65
+
66
+ select: (id, value) ->
67
+ @owner.sendResponse @page.get(id).select(value)
68
+
69
+ tag_name: (id) ->
70
+ @owner.sendResponse @page.get(id).tagName()
71
+
72
+ visible: (id) ->
73
+ @owner.sendResponse @page.get(id).isVisible()
74
+
75
+ evaluate: (script) ->
76
+ @owner.sendResponse @page.evaluate("function() { return #{script} }")
77
+
78
+ execute: (script) ->
79
+ @page.execute("function() { return #{script} }")
80
+ @owner.sendResponse(true)
81
+
82
+ push_frame: (id) ->
83
+ @page.pushFrame(id)
84
+ @owner.sendResponse(true)
85
+
86
+ pop_frame: ->
87
+ @page.popFrame()
88
+ @owner.sendResponse(true)
89
+
90
+ click: (id) ->
91
+ # Detect if the click event triggers a page load. If it does, don't send
92
+ # a response here, because the response will be sent once the page has loaded.
93
+ @page.onLoadStarted = => @awaiting_response = true
94
+
95
+ @page.get(id).click()
96
+
97
+ # Use a timeout in order to let the stack clear, so that the @page.onLoadStarted
98
+ # callback can (possibly) fire, before we decide whether to send a response.
99
+ setTimeout(
100
+ =>
101
+ @page.onLoadStarted = null
102
+ @owner.sendResponse(true) unless @awaiting_response
103
+ ,
104
+ 0
105
+ )
106
+
107
+ drag: (id, other_id) ->
108
+ @page.get(id).dragTo(@page.get(other_id))
109
+ @owner.sendResponse(true)
110
+
111
+ trigger: (id, event) ->
112
+ @page.get(id).trigger(event)
113
+ @owner.sendResponse(event)
114
+
115
+ reset: ->
116
+ this.resetPage()
117
+ @owner.sendResponse(true)
118
+
119
+ render: (path) ->
120
+ @page.render(path)
121
+ @owner.sendResponse(true)