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.
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)