poltergeist 0.4.0 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
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()