poltergeist 0.4.0 → 0.5.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/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # Poltergeist - A PhantomJS driver for Capybara #
2
2
 
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
 
5
5
  [![Build Status](https://secure.travis-ci.org/jonleighton/poltergeist.png)](http://travis-ci.org/jonleighton/poltergeist)
6
+ [![Dependency Status](https://gemnasium.com/jonleighton/poltergeist.png)](https://gemnasium.com/jonleighton/poltergeist)
6
7
 
7
8
  Poltergeist is a driver for [Capybara](https://github.com/jnicklas/capybara). It allows you to
8
9
  run your Capybara tests on a headless [WebKit](http://webkit.org) browser,
@@ -10,7 +11,7 @@ provided by [PhantomJS](http://www.phantomjs.org/).
10
11
 
11
12
  ## Installation ##
12
13
 
13
- Add `poltergeist` to your Gemfile, and add in your test setup add:
14
+ Add `poltergeist` to your Gemfile, and in your test setup add:
14
15
 
15
16
  ``` ruby
16
17
  require 'capybara/poltergeist'
@@ -172,6 +173,51 @@ makes debugging easier). Running `rake autocompile` will watch the
172
173
 
173
174
  ## Changes ##
174
175
 
176
+ ### 0.5.0 ###
177
+
178
+ #### Features ####
179
+
180
+ * Detect if clicking an element will fail. If the click will actually
181
+ hit another element (because that element is in front of the one we
182
+ want to click), the user will now see an exception explaining what
183
+ happened and which element would actually be targeted by the click. This
184
+ should aid debugging. [Issue #25]
185
+
186
+ * Click elements at their middle position rather than the top-left.
187
+ This is presumed to be more likely to succeed because the top-left
188
+ may be obscured by overlapping elements, negative margins, etc. [Issue #26]
189
+
190
+ * Add experimental support for using the remote WebKit web inspector.
191
+ This will only work with PhantomJS 1.5, which is not yet released,
192
+ so it won't be officially supported by Poltergeist until 1.5 is
193
+ released. [Issue #31]
194
+
195
+ * Add `page.driver.quit` method. If you spawn additional Capybara
196
+ sessions, you might want to use this to reap the child phantomjs
197
+ process. [Issue #24]
198
+
199
+ * Errors produced by Javascript on the page will now generate an
200
+ exception within Ruby. [Issue #27]
201
+
202
+ #### Bug fixes ####
203
+
204
+ * Fix bug where we could end up interacting with an obsolete element. [Issue #30]
205
+
206
+ * Raise an suitable error if PhantomJS returns a non-zero exit status.
207
+ Previously a version error would be raised, indicating that the
208
+ PhantomJS version was too old when in fact it did not start at all. [Issue #23]
209
+
210
+ * Ensure the `:timeout` option is actually used. [Issue #36]
211
+
212
+ * Nodes need to know which page they are associated with. Before this,
213
+ if Javascript caused a new page to load, existing node references
214
+ would be wrong, but wouldn't raise an ObsoleteNode error. [Issue #39]
215
+
216
+ * In some circumstances, we could end up missing an inline element
217
+ when attempting to click it. This is due to the use of
218
+ `getBoundingClientRect()`. We're now using `getClientRects()` to
219
+ address this.
220
+
175
221
  ### 0.4.0 ###
176
222
 
177
223
  * Element click position is now calculated using the native
@@ -9,6 +9,9 @@ module Capybara
9
9
  autoload :Server, 'capybara/poltergeist/server'
10
10
  autoload :WebSocketServer, 'capybara/poltergeist/web_socket_server'
11
11
  autoload :Client, 'capybara/poltergeist/client'
12
+ autoload :Util, 'capybara/poltergeist/util'
13
+ autoload :Inspector, 'capybara/poltergeist/inspector'
14
+ autoload :Spawn, 'capybara/poltergeist/spawn'
12
15
 
13
16
  require 'capybara/poltergeist/errors'
14
17
  end
@@ -1,23 +1,13 @@
1
- require 'json'
1
+ require 'multi_json'
2
2
 
3
3
  module Capybara::Poltergeist
4
4
  class Browser
5
- attr_reader :options, :server, :client
5
+ attr_reader :server, :client, :logger
6
6
 
7
- DEFAULT_TIMEOUT = 30
8
-
9
- def initialize(options = {})
10
- @options = options
11
- @server = Server.new(options.fetch(:timeout, DEFAULT_TIMEOUT))
12
- @client = Client.start(server.port, options[:phantomjs])
13
- end
14
-
15
- def timeout
16
- server.timeout
17
- end
18
-
19
- def timeout=(sec)
20
- server.timeout = sec
7
+ def initialize(server, client, logger = nil)
8
+ @server = server
9
+ @client = client
10
+ @logger = logger
21
11
  end
22
12
 
23
13
  def restart
@@ -25,7 +15,7 @@ module Capybara::Poltergeist
25
15
  client.restart
26
16
  end
27
17
 
28
- def visit(url, attributes = {})
18
+ def visit(url)
29
19
  command 'visit', url
30
20
  end
31
21
 
@@ -41,36 +31,41 @@ module Capybara::Poltergeist
41
31
  command 'source'
42
32
  end
43
33
 
44
- def find(selector, id = nil)
45
- command 'find', selector, id
34
+ def find(selector)
35
+ result = command('find', selector)
36
+ result['ids'].map { |id| [result['page_id'], id] }
46
37
  end
47
38
 
48
- def text(id)
49
- command 'text', id
39
+ def find_within(page_id, id, selector)
40
+ command 'find_within', page_id, id, selector
50
41
  end
51
42
 
52
- def attribute(id, name)
53
- command 'attribute', id, name
43
+ def text(page_id, id)
44
+ command 'text', page_id, id
54
45
  end
55
46
 
56
- def value(id)
57
- command 'value', id
47
+ def attribute(page_id, id, name)
48
+ command 'attribute', page_id, id, name.to_s
58
49
  end
59
50
 
60
- def set(id, value)
61
- command 'set', id, value
51
+ def value(page_id, id)
52
+ command 'value', page_id, id
62
53
  end
63
54
 
64
- def select_file(id, value)
65
- command 'select_file', id, value
55
+ def set(page_id, id, value)
56
+ command 'set', page_id, id, value
66
57
  end
67
58
 
68
- def tag_name(id)
69
- command('tag_name', id).downcase
59
+ def select_file(page_id, id, value)
60
+ command 'select_file', page_id, id, value
70
61
  end
71
62
 
72
- def visible?(id)
73
- command 'visible', id
63
+ def tag_name(page_id, id)
64
+ command('tag_name', page_id, id).downcase
65
+ end
66
+
67
+ def visible?(page_id, id)
68
+ command 'visible', page_id, id
74
69
  end
75
70
 
76
71
  def evaluate(script)
@@ -87,24 +82,20 @@ module Capybara::Poltergeist
87
82
  command 'pop_frame'
88
83
  end
89
84
 
90
- def reset
91
- visit('about:blank')
85
+ def click(page_id, id)
86
+ command 'click', page_id, id
92
87
  end
93
88
 
94
- def click(id)
95
- command 'click', id
89
+ def drag(page_id, id, other_id)
90
+ command 'drag', page_id, id, other_id
96
91
  end
97
92
 
98
- def drag(id, other_id)
99
- command 'drag', id, other_id
93
+ def select(page_id, id, value)
94
+ command 'select', page_id, id, value
100
95
  end
101
96
 
102
- def select(id, value)
103
- command 'select', id, value
104
- end
105
-
106
- def trigger(id, event)
107
- command 'trigger', id, event
97
+ def trigger(page_id, id, event)
98
+ command 'trigger', page_id, id, event.to_s
108
99
  end
109
100
 
110
101
  def reset
@@ -119,23 +110,19 @@ module Capybara::Poltergeist
119
110
  command 'resize', width, height
120
111
  end
121
112
 
122
- def logger
123
- options[:logger]
124
- end
125
-
126
- def log(message)
127
- logger.puts message if logger
128
- end
129
-
130
113
  def command(name, *args)
131
114
  message = { 'name' => name, 'args' => args }
132
115
  log message.inspect
133
116
 
134
- json = JSON.parse(server.send(JSON.generate(message)))
117
+ json = MultiJson.decode(server.send(MultiJson.encode(message)))
135
118
  log json.inspect
136
119
 
137
120
  if json['error']
138
- raise BrowserError.new(json['error'])
121
+ if json['error']['name'] == 'Poltergeist.JavascriptError'
122
+ raise JavascriptError.new(json['error'])
123
+ else
124
+ raise BrowserError.new(json['error'])
125
+ end
139
126
  else
140
127
  json['response']
141
128
  end
@@ -144,5 +131,11 @@ module Capybara::Poltergeist
144
131
  restart
145
132
  raise
146
133
  end
134
+
135
+ private
136
+
137
+ def log(message)
138
+ logger.puts message if logger
139
+ end
147
140
  end
148
141
  end
@@ -1,11 +1,8 @@
1
- require 'sfl'
2
-
3
1
  module Capybara::Poltergeist
4
2
  class Client
5
3
  PHANTOMJS_SCRIPT = File.expand_path('../client/compiled/main.js', __FILE__)
6
- PHANTOMJS_VERSION = "1.4.1"
7
-
8
- attr_reader :pid, :port, :path
4
+ PHANTOMJS_VERSION = '1.4.1'
5
+ PHANTOMJS_NAME = 'phantomjs'
9
6
 
10
7
  def self.start(*args)
11
8
  client = new(*args)
@@ -13,19 +10,33 @@ module Capybara::Poltergeist
13
10
  client
14
11
  end
15
12
 
16
- def initialize(port, path = nil)
17
- @port = port
18
- @path = path || 'phantomjs'
19
- at_exit { stop }
13
+ attr_reader :pid, :port, :path, :inspector
14
+
15
+ def initialize(port, inspector = nil, path = nil)
16
+ @port = port
17
+ @inspector = inspector
18
+ @path = path || PHANTOMJS_NAME
19
+
20
+ pid = Process.pid
21
+ at_exit { stop if Process.pid == pid }
20
22
  end
21
23
 
22
24
  def start
23
25
  check_phantomjs_version
24
- @pid = Kernel.spawn("#{path} #{PHANTOMJS_SCRIPT} #{port}")
26
+ @pid = Spawn.spawn(*command)
25
27
  end
26
28
 
27
29
  def stop
28
- Process.kill('TERM', pid) if pid
30
+ if pid
31
+ begin
32
+ Process.kill('TERM', pid)
33
+ Process.wait(pid)
34
+ rescue Errno::ESRCH, Errno::ECHILD
35
+ # Zed's dead, baby
36
+ end
37
+
38
+ @pid = nil
39
+ end
29
40
  end
30
41
 
31
42
  def restart
@@ -33,15 +44,34 @@ module Capybara::Poltergeist
33
44
  start
34
45
  end
35
46
 
47
+ def command
48
+ @command ||= begin
49
+ parts = [path]
50
+
51
+ if inspector
52
+ parts << "--remote-debugger-port=#{inspector.port}"
53
+ parts << "--remote-debugger-autorun=yes"
54
+ end
55
+
56
+ parts << PHANTOMJS_SCRIPT
57
+ parts << port
58
+ parts
59
+ end
60
+ end
61
+
36
62
  private
37
63
 
38
64
  def check_phantomjs_version
39
65
  return if @phantomjs_version_checked
40
66
 
41
67
  version = `#{path} --version`.chomp
42
- if version < PHANTOMJS_VERSION
68
+
69
+ if $? != 0
70
+ raise PhantomJSFailed.new($?)
71
+ elsif version < PHANTOMJS_VERSION
43
72
  raise PhantomJSTooOld.new(version)
44
73
  end
74
+
45
75
  @phantomjs_version_checked = true
46
76
  end
47
77
  end
@@ -7,18 +7,28 @@ class PoltergeistAgent
7
7
  @windows = []
8
8
  this.pushWindow(window)
9
9
 
10
+ externalCall: (name, arguments) ->
11
+ try
12
+ { value: this[name].apply(this, arguments) }
13
+ catch error
14
+ { error: error.toString() }
15
+
10
16
  pushWindow: (new_window) ->
11
17
  @windows.push(new_window)
12
18
 
13
19
  @window = new_window
14
20
  @document = @window.document
15
21
 
22
+ null
23
+
16
24
  popWindow: ->
17
25
  @windows.pop()
18
26
 
19
27
  @window = @windows[@windows.length - 1]
20
28
  @document = @window.document
21
29
 
30
+ null
31
+
22
32
  pushFrame: (id) ->
23
33
  this.pushWindow @document.getElementById(id).contentWindow
24
34
 
@@ -28,9 +38,8 @@ class PoltergeistAgent
28
38
  currentUrl: ->
29
39
  window.location.toString()
30
40
 
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)
41
+ find: (selector, within = @document) ->
42
+ results = @document.evaluate(selector, within, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
34
43
  ids = []
35
44
 
36
45
  for i in [0...results.snapshotLength]
@@ -51,8 +60,12 @@ class PoltergeistAgent
51
60
 
52
61
  nodeCall: (id, name, arguments) ->
53
62
  node = this.get(id)
63
+ throw new PoltergeistAgent.ObsoleteNode if node.isObsolete()
54
64
  node[name].apply(node, arguments)
55
65
 
66
+ class PoltergeistAgent.ObsoleteNode
67
+ toString: -> "PoltergeistAgent.ObsoleteNode"
68
+
56
69
  class PoltergeistAgent.Node
57
70
  @EVENTS = {
58
71
  FOCUS: ['blur', 'focus', 'focusin', 'focusout'],
@@ -65,6 +78,9 @@ class PoltergeistAgent.Node
65
78
  parentId: ->
66
79
  @agent.register(@element.parentNode)
67
80
 
81
+ find: (selector) ->
82
+ @agent.find(selector, @element)
83
+
68
84
  isObsolete: ->
69
85
  obsolete = (element) =>
70
86
  if element.parentNode?
@@ -151,8 +167,16 @@ class PoltergeistAgent.Node
151
167
  true
152
168
 
153
169
  position: ->
154
- rect = @element.getBoundingClientRect()
155
- { top: rect.top, left: rect.left }
170
+ rect = @element.getClientRects()[0]
171
+
172
+ {
173
+ top: rect.top,
174
+ right: rect.right,
175
+ left: rect.left,
176
+ bottom: rect.bottom,
177
+ width: rect.width,
178
+ height: rect.height
179
+ }
156
180
 
157
181
  trigger: (name) ->
158
182
  if Node.EVENTS.MOUSE.indexOf(name) != -1
@@ -169,6 +193,25 @@ class PoltergeistAgent.Node
169
193
 
170
194
  @element.dispatchEvent(event)
171
195
 
196
+ clickTest: (x, y) ->
197
+ el = origEl = document.elementFromPoint(x, y)
198
+
199
+ while el
200
+ if el == @element
201
+ return { status: 'success' }
202
+ else
203
+ el = el.parentNode
204
+
205
+ { status: 'failure', selector: origEl && this.getSelector(origEl) }
206
+
207
+ getSelector: (el) ->
208
+ selector = if el.tagName != 'HTML' then this.getSelector(el.parentNode) + ' ' else ''
209
+ selector += el.tagName.toLowerCase()
210
+ selector += "##{el.id}" if el.id
211
+ for className in el.classList
212
+ selector += ".#{className}"
213
+ selector
214
+
172
215
  window.__poltergeist = new PoltergeistAgent
173
216
 
174
217
  document.addEventListener(
@@ -1,11 +1,12 @@
1
1
  class Poltergeist.Browser
2
2
  constructor: (@owner) ->
3
- @state = 'default'
3
+ @state = 'default'
4
+ @page_id = 0
5
+
4
6
  this.resetPage()
5
7
 
6
8
  resetPage: ->
7
9
  @page.release() if @page?
8
-
9
10
  @page = new Poltergeist.WebPage
10
11
 
11
12
  @page.onLoadStarted = =>
@@ -13,37 +14,58 @@ class Poltergeist.Browser
13
14
 
14
15
  @page.onLoadFinished = (status) =>
15
16
  if @state == 'loading'
16
- @owner.sendResponse(status)
17
+ this.sendResponse(status)
17
18
  @state = 'default'
18
19
 
20
+ @page.onInitialized = =>
21
+ @page_id += 1
22
+
23
+ sendResponse: (response) ->
24
+ errors = @page.errors()
25
+
26
+ if errors.length > 0
27
+ @page.clearErrors()
28
+ @owner.sendError(new Poltergeist.JavascriptError(errors))
29
+ else
30
+ @owner.sendResponse(response)
31
+
32
+ node: (page_id, id) ->
33
+ if page_id == @page_id
34
+ @page.get(id)
35
+ else
36
+ throw new Poltergeist.ObsoleteNode
37
+
19
38
  visit: (url) ->
20
39
  @state = 'loading'
21
40
  @page.open(url)
22
41
 
23
42
  current_url: ->
24
- @owner.sendResponse @page.currentUrl()
43
+ this.sendResponse @page.currentUrl()
25
44
 
26
45
  body: ->
27
- @owner.sendResponse @page.content()
46
+ this.sendResponse @page.content()
28
47
 
29
48
  source: ->
30
- @owner.sendResponse @page.source()
49
+ this.sendResponse @page.source()
50
+
51
+ find: (selector) ->
52
+ this.sendResponse(page_id: @page_id, ids: @page.find(selector))
31
53
 
32
- find: (selector, id) ->
33
- @owner.sendResponse @page.find(selector, id)
54
+ find_within: (page_id, id, selector) ->
55
+ this.sendResponse this.node(page_id, id).find(selector)
34
56
 
35
- text: (id) ->
36
- @owner.sendResponse @page.get(id).text()
57
+ text: (page_id, id) ->
58
+ this.sendResponse this.node(page_id, id).text()
37
59
 
38
- attribute: (id, name) ->
39
- @owner.sendResponse @page.get(id).getAttribute(name)
60
+ attribute: (page_id, id, name) ->
61
+ this.sendResponse this.node(page_id, id).getAttribute(name)
40
62
 
41
- value: (id) ->
42
- @owner.sendResponse @page.get(id).value()
63
+ value: (page_id, id) ->
64
+ this.sendResponse this.node(page_id, id).value()
43
65
 
44
- set: (id, value) ->
45
- @page.get(id).set(value)
46
- @owner.sendResponse(true)
66
+ set: (page_id, id, value) ->
67
+ this.node(page_id, id).set(value)
68
+ this.sendResponse(true)
47
69
 
48
70
  # PhantomJS only allows us to reference the element by CSS selector, not XPath,
49
71
  # so we have to add an attribute to the element to identify it, then remove it
@@ -52,8 +74,8 @@ class Poltergeist.Browser
52
74
  # PhantomJS does not support multiple-file inputs, so we have to blatently cheat
53
75
  # by temporarily changing it to a single-file input. This obviously could break
54
76
  # things in various ways, which is not ideal, but it works in the simplest case.
55
- select_file: (id, value) ->
56
- element = @page.get(id)
77
+ select_file: (page_id, id, value) ->
78
+ element = this.node(page_id, id)
57
79
 
58
80
  multiple = element.isMultiple()
59
81
 
@@ -65,38 +87,38 @@ class Poltergeist.Browser
65
87
  element.removeAttribute('_poltergeist_selected')
66
88
  element.setAttribute('multiple', 'multiple') if multiple
67
89
 
68
- @owner.sendResponse(true)
90
+ this.sendResponse(true)
69
91
 
70
- select: (id, value) ->
71
- @owner.sendResponse @page.get(id).select(value)
92
+ select: (page_id, id, value) ->
93
+ this.sendResponse this.node(page_id, id).select(value)
72
94
 
73
- tag_name: (id) ->
74
- @owner.sendResponse @page.get(id).tagName()
95
+ tag_name: (page_id, id) ->
96
+ this.sendResponse this.node(page_id, id).tagName()
75
97
 
76
- visible: (id) ->
77
- @owner.sendResponse @page.get(id).isVisible()
98
+ visible: (page_id, id) ->
99
+ this.sendResponse this.node(page_id, id).isVisible()
78
100
 
79
101
  evaluate: (script) ->
80
- @owner.sendResponse JSON.parse(@page.evaluate("function() { return JSON.stringify(#{script}) }"))
102
+ this.sendResponse JSON.parse(@page.evaluate("function() { return JSON.stringify(#{script}) }"))
81
103
 
82
104
  execute: (script) ->
83
105
  @page.execute("function() { #{script} }")
84
- @owner.sendResponse(true)
106
+ this.sendResponse(true)
85
107
 
86
108
  push_frame: (id) ->
87
109
  @page.pushFrame(id)
88
- @owner.sendResponse(true)
110
+ this.sendResponse(true)
89
111
 
90
112
  pop_frame: ->
91
113
  @page.popFrame()
92
- @owner.sendResponse(true)
114
+ this.sendResponse(true)
93
115
 
94
- click: (id) ->
116
+ click: (page_id, id) ->
95
117
  # If the click event triggers onLoadStarted, we will transition to the 'loading'
96
118
  # state and wait for onLoadFinished before sending a response.
97
119
  @state = 'clicked'
98
120
 
99
- @page.get(id).click()
121
+ this.node(page_id, id).click()
100
122
 
101
123
  # Use a timeout in order to let the stack clear, so that the @page.onLoadStarted
102
124
  # callback can (possibly) fire, before we decide whether to send a response.
@@ -104,22 +126,22 @@ class Poltergeist.Browser
104
126
  =>
105
127
  if @state == 'clicked'
106
128
  @state = 'default'
107
- @owner.sendResponse(true)
129
+ this.sendResponse(true)
108
130
  ,
109
131
  10
110
132
  )
111
133
 
112
- drag: (id, other_id) ->
113
- @page.get(id).dragTo(@page.get(other_id))
114
- @owner.sendResponse(true)
134
+ drag: (page_id, id, other_id) ->
135
+ this.node(page_id, id).dragTo(@page.get(other_id))
136
+ this.sendResponse(true)
115
137
 
116
- trigger: (id, event) ->
117
- @page.get(id).trigger(event)
118
- @owner.sendResponse(event)
138
+ trigger: (page_id, id, event) ->
139
+ this.node(page_id, id).trigger(event)
140
+ this.sendResponse(event)
119
141
 
120
142
  reset: ->
121
143
  this.resetPage()
122
- @owner.sendResponse(true)
144
+ this.sendResponse(true)
123
145
 
124
146
  render: (path, full) ->
125
147
  dimensions = @page.validatedDimensions()
@@ -135,11 +157,11 @@ class Poltergeist.Browser
135
157
  @page.setClipRect(left: 0, top: 0, width: viewport.width, height: viewport.height)
136
158
  @page.render(path)
137
159
 
138
- @owner.sendResponse(true)
160
+ this.sendResponse(true)
139
161
 
140
162
  resize: (width, height) ->
141
163
  @page.setViewportSize(width: width, height: height)
142
- @owner.sendResponse(true)
164
+ this.sendResponse(true)
143
165
 
144
166
  exit: ->
145
167
  phantom.exit()