terminus 0.3.0 → 0.4.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.
Files changed (44) hide show
  1. data/README.rdoc +25 -19
  2. data/bin/terminus +1 -1
  3. data/lib/capybara/driver/terminus.rb +28 -17
  4. data/lib/terminus.rb +29 -15
  5. data/lib/terminus/application.rb +5 -11
  6. data/lib/terminus/browser.rb +97 -44
  7. data/lib/terminus/client.rb +43 -0
  8. data/lib/terminus/client/browser.rb +30 -0
  9. data/lib/terminus/client/phantom.js +6 -0
  10. data/lib/terminus/client/phantomjs.rb +20 -0
  11. data/lib/terminus/connector.rb +9 -0
  12. data/lib/terminus/connector/server.rb +142 -0
  13. data/lib/terminus/connector/socket_handler.rb +72 -0
  14. data/lib/terminus/controller.rb +22 -5
  15. data/lib/terminus/host.rb +7 -2
  16. data/lib/terminus/node.rb +11 -5
  17. data/lib/terminus/proxy.rb +35 -1
  18. data/lib/terminus/proxy/driver_body.rb +26 -6
  19. data/lib/terminus/proxy/external.rb +9 -0
  20. data/lib/terminus/proxy/rewrite.rb +4 -2
  21. data/lib/terminus/public/compiled/terminus-min.js +3 -0
  22. data/lib/terminus/public/compiled/terminus.js +5270 -0
  23. data/lib/terminus/public/loader.js +1 -1
  24. data/lib/terminus/public/pathology.js +3174 -0
  25. data/lib/terminus/public/syn/browsers.js +2 -2
  26. data/lib/terminus/public/syn/drag/drag.js +3 -4
  27. data/lib/terminus/public/syn/key.js +130 -111
  28. data/lib/terminus/public/syn/mouse.js +2 -2
  29. data/lib/terminus/public/syn/synthetic.js +45 -34
  30. data/lib/terminus/public/terminus.js +183 -70
  31. data/lib/terminus/server.rb +6 -0
  32. data/lib/terminus/timeouts.rb +4 -2
  33. data/lib/terminus/views/bootstrap.erb +12 -27
  34. data/lib/terminus/views/index.erb +1 -1
  35. data/spec/reports/android.txt +875 -0
  36. data/spec/reports/chrome.txt +137 -8
  37. data/spec/reports/firefox.txt +137 -9
  38. data/spec/reports/opera.txt +142 -13
  39. data/spec/reports/phantomjs.txt +871 -0
  40. data/spec/reports/safari.txt +137 -8
  41. data/spec/spec_helper.rb +19 -17
  42. data/spec/terminus_driver_spec.rb +8 -6
  43. data/spec/terminus_session_spec.rb +4 -4
  44. metadata +209 -117
@@ -1,35 +1,41 @@
1
1
  = Terminus
2
2
 
3
- * http://terminus.jcoglan.com
4
-
5
- Terminus is an experimental Capybara driver implemented in client-side
6
- JavaScript. It lets you script your application in any browser on any device,
7
- without needing browser plugins. This allows several types of testing to be
8
- automated:
9
-
3
+ {Terminus}[http://terminus.jcoglan.com] is a {Capybara}[https://github.com/jnicklas/capybara]
4
+ driver for real browsers. It lets you control your application in any browser on
5
+ any device (including {PhantomJS}[http://phantomjs.org/]), without needing
6
+ browser plugins. This allows several types of testing to be automated:
7
+
10
8
  * Cross-browser testing
9
+ * Headless testing
11
10
  * Multi-browser interaction e.g. messaging apps
12
- * Testing on remote machines, phones, iPads etc.
11
+ * Testing on remote machines, phones, iPads etc
12
+
13
+
14
+ == Running the example
15
+
16
+ Install the dependencies and boot the Terminus server, then open
17
+ {http://localhost:70004/}[http://localhost:70004/] in your browser.
13
18
 
19
+ bundle install
20
+ bundle exec bin/terminus
14
21
 
15
- == Notes / to-do
22
+ With your browser open, start an IRB session and begin controlling the app:
16
23
 
17
- * Support IE, which has no built-in XPath engine for querying the DOM. I'm
18
- working on Pathology (see http://github.com/jcoglan/pathology) to try and fix
19
- this but it's currently not fast enough.
20
-
21
- * It's slow, especially at filling out forms. Use it to sanity-check your
22
- cross-browser code and JavaScript, not to test your whole app.
23
-
24
- * It can be a little brittle, and occasionally there seem to be race conditions
25
- when running the Capybara specs.
24
+ $ irb -r ./example/app
25
+ >> extend Capybara::DSL
26
+ >> visit '/'
27
+ >> click_link 'Sign up!'
28
+ >> fill_in 'Username', :with => 'jcoglan'
29
+ >> fill_in 'Password', :with => 'hello'
30
+ >> choose 'Web scale'
31
+ >> click_button 'Go!'
26
32
 
27
33
 
28
34
  == License
29
35
 
30
36
  (The MIT License)
31
37
 
32
- Copyright (c) 2010-2011 James Coglan
38
+ Copyright (c) 2010-2012 James Coglan
33
39
 
34
40
  Permission is hereby granted, free of charge, to any person obtaining a copy of
35
41
  this software and associated documentation files (the 'Software'), to deal in
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'rubygems'
4
4
  require 'oyster'
5
- require File.expand_path(File.dirname(__FILE__) + '/../lib/terminus')
5
+ require File.expand_path('../../lib/terminus', __FILE__)
6
6
 
7
7
  spec = Oyster.spec do
8
8
  name "terminus -- Control web browsers with Ruby"
@@ -1,24 +1,35 @@
1
1
  class Capybara::Driver::Terminus < Capybara::Driver::Base
2
2
  attr_reader :options
3
3
 
4
+ NULL_APP = lambda do |env|
5
+ [200, {'Content-Type' => 'text/html'}, ['']]
6
+ end
7
+
4
8
  def initialize(app = nil, options = {})
5
- raise ArgumentError.new if app.nil?
6
-
7
- @app = Terminus::Proxy[app]
9
+ @app = Terminus::Proxy[app || NULL_APP]
8
10
  @options = options
9
11
  @rack_server = Capybara::Server.new(@app)
10
12
 
11
13
  @rack_server.boot
14
+ sleep(0.1) until Terminus.server_running?(@rack_server)
12
15
  end
13
16
 
14
17
  def find(xpath)
15
18
  browser.find(xpath, self)
16
19
  end
17
20
 
21
+ def invalid_element_errors
22
+ [::Terminus::ObsoleteElementError]
23
+ end
24
+
18
25
  def visit(path)
19
26
  browser.visit(@rack_server.url(path))
20
27
  end
21
28
 
29
+ def wait?
30
+ true
31
+ end
32
+
22
33
  extend Forwardable
23
34
  def_delegators :browser, :body,
24
35
  :current_url,
@@ -35,18 +46,7 @@ class Capybara::Driver::Terminus < Capybara::Driver::Base
35
46
  yield
36
47
  Terminus.browser = current_browser
37
48
  end
38
-
39
- def within_frame(name)
40
- frame_src = browser.frame_src(name)
41
- frame = browser.frames.find do |frame|
42
- frame.current_url == frame_src or
43
- frame.current_path == frame_src
44
- end
45
- current_browser = browser
46
- Terminus.browser = frame
47
- yield
48
- Terminus.browser = current_browser
49
- end
49
+ alias :within_frame :within_window
50
50
 
51
51
  private
52
52
 
@@ -56,11 +56,22 @@ private
56
56
  end
57
57
  end
58
58
 
59
- # Capybara 0.3.9 looks in the Capybara::Driver namespace for
60
- # appropriate drivers. 0.4 uses this registration API instead.
59
+ # Capybara 0.3.9 looks in the Capybara::Driver namespace for appropriate
60
+ # drivers. 0.4 uses this registration API instead.
61
61
  if Capybara.respond_to?(:register_driver)
62
62
  Capybara.register_driver :terminus do |app|
63
63
  Capybara::Driver::Terminus.new(app)
64
64
  end
65
65
  end
66
66
 
67
+ # We use WEBrick to boot the test app, because if we use Thin (the default) the
68
+ # slow response used to test Ajax resynchronization blocks the event loop. This
69
+ # stops Terminus receiving messages and causes false positives: the client is
70
+ # not really waiting for Ajax to complete, it's just having its messages blocked
71
+ # because EventMachine is frozen.
72
+
73
+ Capybara.server do |app, port|
74
+ handler = Rack::Handler.get('webrick')
75
+ handler.run(app, :Port => port, :AccessLog => [], :Logger => WEBrick::Log::new(nil, 0))
76
+ end
77
+
@@ -1,17 +1,20 @@
1
+ require 'erb'
1
2
  require 'forwardable'
3
+ require 'http/parser'
4
+ require 'net/http'
5
+ require 'rbconfig'
6
+ require 'socket'
7
+ require 'stringio'
2
8
  require 'uri'
3
- require 'rack'
4
- require 'thin'
9
+
10
+ require 'capybara'
11
+ require 'childprocess'
12
+ require 'cookiejar'
5
13
  require 'eventmachine'
6
14
  require 'faye'
7
- require 'sinatra'
8
- require 'packr'
9
- require 'capybara'
10
15
  require 'rack-proxy'
11
16
  require 'useragent'
12
17
 
13
- Thin::Logging.silent = true
14
-
15
18
  module Terminus
16
19
  FAYE_MOUNT = '/messaging'
17
20
  DEFAULT_HOST = 'localhost'
@@ -19,9 +22,11 @@ module Terminus
19
22
  LOCALHOST = /^(localhost|0\.0\.0\.0|127\.0\.0\.1)$/
20
23
  RETRY_LIMIT = 3
21
24
 
22
- ROOT = File.expand_path(File.dirname(__FILE__))
25
+ ROOT = File.expand_path('..', __FILE__)
23
26
  autoload :Application, ROOT + '/terminus/application'
24
27
  autoload :Browser, ROOT + '/terminus/browser'
28
+ autoload :Client, ROOT + '/terminus/client'
29
+ autoload :Connector, ROOT + '/terminus/connector'
25
30
  autoload :Controller, ROOT + '/terminus/controller'
26
31
  autoload :Host, ROOT + '/terminus/host'
27
32
  autoload :Node, ROOT + '/terminus/node'
@@ -31,24 +36,23 @@ module Terminus
31
36
 
32
37
  require ROOT + '/capybara/driver/terminus'
33
38
 
39
+ class ObsoleteElementError < StandardError
40
+ end
41
+
34
42
  class << self
35
- attr_accessor :debug
43
+ attr_accessor :debug, :sockets
36
44
 
37
45
  def create(options = {})
38
46
  Server.new(options)
39
47
  end
40
48
 
41
- def driver_script(host = DEFAULT_HOST)
42
- Application.driver_script(host)
43
- end
44
-
45
49
  def endpoint(host = DEFAULT_HOST)
46
50
  "http://#{host}:#{port}#{FAYE_MOUNT}"
47
51
  end
48
52
 
49
53
  def ensure_reactor_running
50
54
  Thread.new { EM.run unless EM.reactor_running? }
51
- while not EM.reactor_running?; end
55
+ Thread.pass until EM.reactor_running?
52
56
  end
53
57
 
54
58
  def port
@@ -59,14 +63,24 @@ module Terminus
59
63
  @port = port.to_i
60
64
  end
61
65
 
66
+ def start_browser(options = {})
67
+ Client::Browser.start(options)
68
+ end
69
+
70
+ def start_phantomjs(options = {})
71
+ Client::PhantomJS.start(options)
72
+ end
73
+
62
74
  extend Forwardable
63
75
  def_delegators :controller, :browser,
64
76
  :browsers,
65
77
  :browser=,
78
+ :cookies,
66
79
  :ensure_browsers,
67
80
  :return_to_dock,
68
81
  :rewrite_local,
69
- :rewrite_remote
82
+ :rewrite_remote,
83
+ :server_running?
70
84
 
71
85
  private
72
86
 
@@ -1,24 +1,18 @@
1
+ require 'packr'
2
+ require 'sinatra'
3
+
1
4
  module Terminus
2
5
  class Application < Sinatra::Base
3
6
 
4
- ROOT = File.expand_path(File.dirname(__FILE__) + '/../')
7
+ ROOT = File.expand_path('../..', __FILE__)
5
8
 
6
9
  set :static, true
7
- set :public, ROOT + '/terminus/public'
8
- set :views, ROOT + '/terminus/views'
9
-
10
- def self.driver_script(host)
11
- %Q{<script type="text/javascript" src="http://#{host}:#{Terminus.port}/bootstrap.js"></script>}
12
- end
10
+ set :root, ROOT + '/terminus'
13
11
 
14
12
  helpers do
15
13
  def bootstrap
16
14
  Packr.pack(erb(:bootstrap), :shrink_vars => true)
17
15
  end
18
-
19
- def host
20
- "http://#{ env['HTTP_HOST'] }"
21
- end
22
16
  end
23
17
 
24
18
  get '/' do
@@ -2,14 +2,15 @@ module Terminus
2
2
  class Browser
3
3
 
4
4
  include Timeouts
5
+ attr_reader :connector
6
+ attr_writer :sockets
5
7
 
6
8
  extend Forwardable
7
- def_delegator :@user_agent, :browser, :name
8
9
  def_delegators :@user_agent, :os, :version
9
10
 
10
- def initialize(controller)
11
+ def initialize(controller, id)
11
12
  @controller = controller
12
- @attributes = {}
13
+ @attributes = {'id' => id}
13
14
  @docked = false
14
15
  @frames = Set.new
15
16
  @namespace = Faye::Namespace.new
@@ -25,20 +26,32 @@ module Terminus
25
26
  return false unless @user_agent
26
27
 
27
28
  params.all? do |name, value|
28
- property = __send__(name).to_s
29
- case value
30
- when Regexp then property =~ value
31
- when String then property == value
32
- end
29
+ property = __send__(name)
30
+ value === property
33
31
  end
34
32
  end
35
33
 
36
34
  def ask(command, retries = RETRY_LIMIT)
37
- id = tell(command)
38
- result_hash = wait_with_timeout(:result) { result(id) }
39
- result_hash[:value]
35
+ debug(:ask, id, command)
36
+ value = if @connector
37
+ message = Yajl::Encoder.encode('commandId' => '_', 'command' => command)
38
+ response = @connector.request(message)
39
+ if response.nil?
40
+ retries == false ? false : ask(command)
41
+ else
42
+ result_hash = Yajl::Parser.parse(response)
43
+ result_hash['value']
44
+ end
45
+ else
46
+ command_id = tell(command)
47
+ result_hash = wait_with_timeout(:result) { result(command_id) }
48
+ result_hash[:value]
49
+ end
50
+ debug(:val, id, command, value)
51
+ raise ObsoleteElementError if value.nil?
52
+ value
40
53
  rescue Timeouts::TimeoutError => e
41
- raise e if retries.zero?
54
+ raise e if retries == 1
42
55
  ask(command, retries - 1)
43
56
  end
44
57
 
@@ -47,12 +60,18 @@ module Terminus
47
60
  end
48
61
 
49
62
  def current_path
50
- return nil unless url = @attributes['url']
51
- URI.parse(url).path
63
+ URI.parse(current_url).path
52
64
  end
53
65
 
54
66
  def current_url
55
- @attributes['url'] || ''
67
+ url = @attributes['url']
68
+ return '' unless url
69
+ return url unless @connector
70
+ rewrite_local(ask([:current_url]))
71
+ end
72
+
73
+ def debug(*args)
74
+ p args if Terminus.debug
56
75
  end
57
76
 
58
77
  def docked?
@@ -64,7 +83,7 @@ module Terminus
64
83
  end
65
84
 
66
85
  def execute_script(expression)
67
- tell([:execute, expression])
86
+ @connector ? ask([:execute, expression]) : tell([:execute, expression])
68
87
  nil
69
88
  end
70
89
 
@@ -80,25 +99,32 @@ module Terminus
80
99
  @frames.to_a
81
100
  end
82
101
 
83
- def frame_src(name)
84
- ask([:frame_src, name])
85
- end
86
-
87
102
  def id
88
103
  @attributes['id']
89
104
  end
90
105
 
106
+ def infinite_redirect?
107
+ return @infinite_redirect unless @connector
108
+ evaluate_script('!!window.TERMINUS_INFINITE_REDIRECT')
109
+ end
110
+
111
+ def name
112
+ return 'PhantomJS' if @user_agent.to_str =~ /\bPhantomJS\b/
113
+ @user_agent.browser
114
+ end
115
+
91
116
  def page_id
92
117
  @attributes['page']
93
118
  end
94
119
 
95
120
  def ping!(message)
96
- p message if Terminus.debug
121
+ debug(:ping, id)
122
+ debug(:recv, message)
123
+
97
124
  remove_timeout(:dead)
98
125
  add_timeout(:dead, Timeouts::TIMEOUT) { drop_dead! }
99
126
 
100
- uri = @controller.rewrite_local(message['url'], @dock_host)
101
- message['url'] = uri.to_s
127
+ message['url'] = rewrite_local(message['url'])
102
128
 
103
129
  @attributes = @attributes.merge(message)
104
130
  @user_agent = UserAgent.parse(message['ua'])
@@ -106,18 +132,20 @@ module Terminus
106
132
 
107
133
  @infinite_redirect = message['infinite']
108
134
 
109
- if parent = message['parent']
110
- @parent = Terminus.browser(parent)
135
+ if id =~ /\//
136
+ @parent = Terminus.browser(id.gsub(/\/[^\/]+$/, ''))
111
137
  @parent.frame!(self) unless @parent == self
112
138
  end
113
139
 
140
+ start_connector if message['sockets'] and sockets?
141
+
114
142
  @ping = true
115
143
  end
116
144
 
117
145
  def reset!
118
146
  if url = @attributes['url']
119
147
  uri = URI.parse(url)
120
- visit("http://#{uri.host}:#{uri.port}")
148
+ visit("http://#{uri.host}:#{uri.port}/")
121
149
  end
122
150
  ask([:clear_cookies])
123
151
  @attributes.delete('url')
@@ -128,17 +156,22 @@ module Terminus
128
156
  end
129
157
 
130
158
  def result!(message)
131
- p message if Terminus.debug
132
- @results[message['commandId']] = message['result']
159
+ debug(:result, id, message['commandId'], message['result'])
160
+ @results[message['commandId']] = {:value => message['result']}
133
161
  end
134
162
 
135
163
  def result(id)
136
164
  return nil unless @results.has_key?(id)
137
- {:value => @results.delete(id)}
165
+ @results.delete(id)
138
166
  end
139
167
 
140
168
  def return_to_dock
141
- visit "http://#{@dock_host}:#{Terminus.port}/"
169
+ return unless @dock_host
170
+ visit("http://#{@dock_host}:#{Terminus.port}/")
171
+ end
172
+
173
+ def sockets?
174
+ @sockets.nil? ? Terminus.sockets != false : @sockets
142
175
  end
143
176
 
144
177
  def source
@@ -150,20 +183,27 @@ module Terminus
150
183
  end
151
184
 
152
185
  def tell(command)
153
- id = @namespace.generate
154
- p [id, command] if Terminus.debug
155
- messenger.publish(channel, 'command' => command, 'commandId' => id)
156
- id
186
+ command_id = @namespace.generate
187
+ debug(:tell, id, command, command_id)
188
+ messenger.publish(command_channel, 'command' => command, 'commandId' => command_id)
189
+ command_id
157
190
  end
158
191
 
159
192
  def visit(url, retries = RETRY_LIMIT)
160
193
  close_frames!
161
194
  uri = @controller.rewrite_remote(url, @dock_host)
162
195
  uri.host = @dock_host if uri.host =~ LOCALHOST
163
- tell([:visit, uri.to_s])
164
- wait_for_ping
165
196
 
166
- if @infinite_redirect
197
+ if @connector
198
+ ask([:visit, uri.to_s], false)
199
+ @connector.drain_socket
200
+ @attributes['url'] = rewrite_local(uri)
201
+ else
202
+ tell([:visit, uri.to_s])
203
+ wait_for_ping
204
+ end
205
+
206
+ if infinite_redirect?
167
207
  @infinite_redirect = nil
168
208
  raise Capybara::InfiniteRedirectError
169
209
  end
@@ -188,16 +228,21 @@ module Terminus
188
228
  def drop_dead!
189
229
  remove_timeout(:dead)
190
230
  close_frames!
231
+ @connector.close if @connector
191
232
  @dead = true
192
233
  @controller.drop_browser(self)
193
234
  end
194
235
 
195
236
  private
196
237
 
197
- def channel
238
+ def command_channel
198
239
  "/terminus/clients/#{id}"
199
240
  end
200
241
 
242
+ def socket_channel
243
+ "/terminus/sockets/#{id}"
244
+ end
245
+
201
246
  def close_frames!
202
247
  @frames.each { |frame| frame.drop_dead! }
203
248
  @frames = Set.new
@@ -205,12 +250,20 @@ module Terminus
205
250
 
206
251
  def detect_dock_host
207
252
  uri = URI.parse(@attributes['url'])
208
- if uri.port == Terminus.port
209
- @docked = true
210
- @dock_host = uri.host
211
- else
212
- @docked = false
213
- end
253
+ @docked = (uri.port == Terminus.port)
254
+ @dock_host = @attributes['host']
255
+ end
256
+
257
+ def rewrite_local(url)
258
+ @controller.rewrite_local(url.to_s, @dock_host).to_s
259
+ end
260
+
261
+ def start_connector
262
+ return if @connector or @dock_host.nil? or Terminus.browser != self
263
+ @connector = Connector::Server.new(self)
264
+ url = "ws://#{@dock_host}:#{@connector.port}/"
265
+ debug(:connect, id, url)
266
+ messenger.publish(socket_channel, 'url' => url)
214
267
  end
215
268
 
216
269
  def messenger