capybara-lockstep 0.3.2 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
data/Rakefile CHANGED
@@ -6,3 +6,11 @@ require "rspec/core/rake_task"
6
6
  RSpec::Core::RakeTask.new(:spec)
7
7
 
8
8
  task default: :spec
9
+ require 'jasmine'
10
+ load 'jasmine/tasks/jasmine.rake'
11
+
12
+ begin
13
+ require 'gemika/tasks'
14
+ rescue LoadError
15
+ puts 'Run `gem install gemika` for additional tasks'
16
+ end
@@ -27,6 +27,7 @@ Gem::Specification.new do |spec|
27
27
  spec.add_dependency "capybara", ">= 2.0"
28
28
  spec.add_dependency "selenium-webdriver", ">= 3"
29
29
  spec.add_dependency "activesupport", ">= 3.2"
30
+ spec.add_dependency "ruby2_keywords"
30
31
 
31
32
  # For more information and examples about making a new gem, checkout our
32
33
  # guide at: https://bundler.io/guides/creating_gem.html
@@ -1,7 +1,9 @@
1
+ require 'ruby2_keywords'
2
+
1
3
  module Capybara
2
4
  module Lockstep
3
5
  module VisitWithWaiting
4
- def visit(*args, &block)
6
+ ruby2_keywords def visit(*args, &block)
5
7
  url = args[0]
6
8
  # Some of our apps have a Cucumber step that changes drivers mid-scenario.
7
9
  # It works by creating a new Capybara session and re-visits the URL from the
@@ -12,13 +14,18 @@ module Capybara
12
14
 
13
15
  if visiting_remote_url
14
16
  # We're about to leave this screen, killing all in-flight requests.
15
- Capybara::Lockstep.synchronize
17
+ # Give pending form submissions etc. a chance to finish before we tear down
18
+ # the browser environment.
19
+ #
20
+ # We force a non-lazy synchronization so we pick up all client-side changes
21
+ # that have not been caused by Capybara commands.
22
+ Lockstep.synchronize(lazy: false)
16
23
  end
17
24
 
18
25
  super(*args, &block).tap do
19
26
  if visiting_remote_url
20
- # puts "After visit: unsynchronizing"
21
- Capybara::Lockstep.synchronized = false
27
+ # We haven't yet synchronized the new screen.
28
+ Lockstep.synchronized = false
22
29
  end
23
30
  end
24
31
  end
@@ -26,11 +33,58 @@ module Capybara
26
33
  end
27
34
  end
28
35
 
29
-
30
36
  Capybara::Session.class_eval do
31
37
  prepend Capybara::Lockstep::VisitWithWaiting
32
38
  end
33
39
 
40
+ module Capybara
41
+ module Lockstep
42
+ module SynchronizeAroundScriptMethod
43
+
44
+ def synchronize_around_script_method(meth)
45
+ mod = Module.new do
46
+ define_method meth do |script, *args, &block|
47
+ # Synchronization uses execute_script itself, so don't synchronize when
48
+ # we're already synchronizing.
49
+ if !Lockstep.synchronizing?
50
+ # It's generally a good idea to synchronize before a JavaScript wants
51
+ # to access or observe an earlier state change.
52
+ #
53
+ # In case the given script navigates away (with `location.href = url`,
54
+ # `history.back()`, etc.) we would kill all in-flight requests. For this case
55
+ # we force a non-lazy synchronization so we pick up all client-side changes
56
+ # that have not been caused by Capybara commands.
57
+ script_may_navigate_away = script =~ /\b(location|history)\b/
58
+ Lockstep.log "Synchronizing before script: #{script}"
59
+ Lockstep.synchronize(lazy: !script_may_navigate_away)
60
+ end
61
+
62
+ super(script, *args, &block).tap do
63
+ if !Lockstep.synchronizing?
64
+ # We haven't yet synchronized with whatever changes the JavaScript
65
+ # did on the frontend.
66
+ Lockstep.synchronized = false
67
+ end
68
+ end
69
+ end
70
+ ruby2_keywords meth
71
+ end
72
+ prepend(mod)
73
+ end
74
+
75
+ end
76
+ end
77
+ end
78
+
79
+ Capybara::Session.class_eval do
80
+ extend Capybara::Lockstep::SynchronizeAroundScriptMethod
81
+
82
+ synchronize_around_script_method :execute_script
83
+ synchronize_around_script_method :evaluate_async_script
84
+ # Don't synchronize around evaluate_script. It calls execute_script
85
+ # internally and we don't want to synchronize multiple times.
86
+ end
87
+
34
88
  module Capybara
35
89
  module Lockstep
36
90
  module UnsychronizeAfter
@@ -38,9 +92,10 @@ module Capybara
38
92
  mod = Module.new do
39
93
  define_method meth do |*args, &block|
40
94
  super(*args, &block).tap do
41
- Capybara::Lockstep.synchronized = false
95
+ Lockstep.synchronized = false
42
96
  end
43
97
  end
98
+ ruby2_keywords meth
44
99
  end
45
100
  prepend(mod)
46
101
  end
@@ -87,7 +142,7 @@ end
87
142
  module Capybara
88
143
  module Lockstep
89
144
  module SynchronizeWithCatchUp
90
- def synchronize(*args, &block)
145
+ ruby2_keywords def synchronize(*args, &block)
91
146
  # This method is called very frequently by capybara.
92
147
  # We use the { lazy } option to only synchronize when we're out of sync.
93
148
  Capybara::Lockstep.synchronize(lazy: true)
@@ -3,7 +3,7 @@ module Capybara
3
3
  module Configuration
4
4
 
5
5
  def timeout
6
- @timeout || 10
6
+ @timeout.nil? ? Capybara.default_max_wait_time : @timeout
7
7
  end
8
8
 
9
9
  def timeout=(seconds)
@@ -11,11 +11,22 @@ module Capybara
11
11
  end
12
12
 
13
13
  def debug?
14
- @debug.nil? ? false : @debug
14
+ # @debug may also be a Logger object, so convert it to a boolean
15
+ @debug.nil? ? false : !!@debug
15
16
  end
16
17
 
17
- def debug=(debug)
18
- @debug = debug
18
+ def debug=(value)
19
+ @debug = value
20
+ if value
21
+ target_prose = (is_logger?(value) ? 'Ruby logger' : 'STDOUT')
22
+ log "Logging to #{target_prose} and browser console"
23
+ end
24
+
25
+ send_config_to_browser(<<~JS)
26
+ CapybaraLockstep.debug = #{value.to_json}
27
+ JS
28
+
29
+ @debug
19
30
  end
20
31
 
21
32
  def enabled?
@@ -30,6 +41,20 @@ module Capybara
30
41
  @enabled = enabled
31
42
  end
32
43
 
44
+ def wait_tasks
45
+ @wait_tasks
46
+ end
47
+
48
+ def wait_tasks=(value)
49
+ @wait_tasks = value
50
+
51
+ send_config_to_browser(<<~JS)
52
+ CapybaraLockstep.waitTasks = #{value.to_json}
53
+ JS
54
+
55
+ @wait_tasks
56
+ end
57
+
33
58
  def disabled?
34
59
  !enabled?
35
60
  end
@@ -40,6 +65,21 @@ module Capybara
40
65
  driver.is_a?(Capybara::Selenium::Driver)
41
66
  end
42
67
 
68
+ def send_config_to_browser(js)
69
+ begin
70
+ with_max_wait_time(2) do
71
+ page.execute_script(<<~JS)
72
+ if (window.CapybaraLockstep) {
73
+ #{js}
74
+ }
75
+ JS
76
+ end
77
+ rescue StandardError => e
78
+ log "#{e.class.name} while configuring capybara-lockstep in browser: #{e.message}"
79
+ # Don't fail. The next page load will include the snippet with the new config.
80
+ end
81
+ end
82
+
43
83
  end
44
84
  end
45
85
  end
@@ -1,45 +1,85 @@
1
1
  window.CapybaraLockstep = (function() {
2
- var count = 0
3
- var idleCallbacks = []
2
+ // State and configuration
3
+ let debug
4
+ let jobCount
5
+ let idleCallbacks
6
+ let waitTasks
7
+ reset()
8
+
9
+ function reset() {
10
+ jobCount = 0
11
+ idleCallbacks = []
12
+ waitTasks = 0
13
+ debug = false
14
+ }
4
15
 
5
16
  function isIdle() {
6
17
  // Can't check for document.readyState or body.initializing here,
7
18
  // since the user might navigate away from the page before it finishes
8
19
  // initializing.
9
- return count === 0
20
+ return jobCount === 0
10
21
  }
11
22
 
12
23
  function isBusy() {
13
24
  return !isIdle()
14
25
  }
15
26
 
16
- function startWork() {
17
- count++
27
+ function log(...args) {
28
+ if (debug) {
29
+ args[0] = '%c[capybara-lockstep] ' + args[0]
30
+ args.splice(1, 0, 'color: #666666')
31
+ console.log.apply(console, args)
32
+ }
18
33
  }
19
34
 
20
- function startWorkUntil(promise) {
21
- startWork()
22
- promise.then(stopWork, stopWork)
35
+ function logPositive(...args) {
36
+ args[0] = '%c' + args[0]
37
+ log(args[0], 'color: #117722', ...args.slice(1))
23
38
  }
24
39
 
25
- function startWorkForTime(time) {
26
- startWork()
27
- setTimeout(stopWork, time)
40
+ function logNegative(...args) {
41
+ args[0] = '%c' + args[0]
42
+ log(args[0], 'color: #cc3311', ...args.slice(1))
28
43
  }
29
44
 
30
- function startWorkForMicrotask() {
31
- startWork()
32
- Promise.resolve().then(stopWork)
45
+ function startWork(tag) {
46
+ jobCount++
47
+ if (tag) {
48
+ logNegative('Started work: %s [%d jobs]', tag, jobCount)
49
+ }
50
+ }
51
+
52
+ function startWorkUntil(promise, tag) {
53
+ startWork(tag)
54
+ let taggedStopWork = stopWork.bind(this, tag)
55
+ promise.then(taggedStopWork, taggedStopWork)
33
56
  }
34
57
 
35
- function stopWork() {
36
- count--
58
+ function stopWork(tag) {
59
+ let tasksElapsed = 0
37
60
 
38
- if (isIdle()) {
39
- idleCallbacks.forEach(function(callback) {
40
- callback('Finished waiting for JavaScript')
41
- })
42
- idleCallbacks = []
61
+ let check = function() {
62
+ if (tasksElapsed < waitTasks) {
63
+ tasksElapsed++
64
+ setTimeout(check)
65
+ } else {
66
+ stopWorkNow(tag)
67
+ }
68
+ }
69
+
70
+ check()
71
+ }
72
+
73
+ function stopWorkNow(tag) {
74
+ jobCount--
75
+
76
+ if (tag) {
77
+ logPositive('Finished work: %s [%d jobs]', tag, jobCount)
78
+ }
79
+
80
+ let idleCallback
81
+ while (isIdle() && (idleCallback = idleCallbacks.shift())) {
82
+ idleCallback('Finished waiting for browser')
43
83
  }
44
84
  }
45
85
 
@@ -48,29 +88,36 @@ window.CapybaraLockstep = (function() {
48
88
  return
49
89
  }
50
90
 
51
- var oldFetch = window.fetch
91
+ let oldFetch = window.fetch
52
92
  window.fetch = function() {
53
- var promise = oldFetch.apply(this, arguments)
54
- startWorkUntil(promise)
93
+ let promise = oldFetch.apply(this, arguments)
94
+ startWorkUntil(promise, 'fetch ' + arguments[0])
55
95
  return promise
56
96
  }
57
97
  }
58
98
 
59
99
  function trackXHR() {
60
- var oldSend = XMLHttpRequest.prototype.send
100
+ let oldOpen = XMLHttpRequest.prototype.open
101
+ let oldSend = XMLHttpRequest.prototype.send
102
+
103
+ XMLHttpRequest.prototype.open = function() {
104
+ this.capybaraLockstepURL = arguments[1]
105
+ return oldOpen.apply(this, arguments)
106
+ }
61
107
 
62
108
  XMLHttpRequest.prototype.send = function() {
63
- startWork()
109
+ let workTag = 'XHR to '+ this.capybaraLockstepURL
110
+ startWork(workTag)
64
111
 
65
112
  try {
66
113
  this.addEventListener('readystatechange', function(event) {
67
- if (this.readyState === 4) { stopWork() }
114
+ if (this.readyState === 4) { stopWork(workTag) }
68
115
  }.bind(this))
69
116
  return oldSend.apply(this, arguments)
70
117
  } catch (e) {
71
118
  // If we get a sync exception during request dispatch
72
119
  // we assume the request never went out.
73
- stopWork()
120
+ stopWork(workTag)
74
121
  throw e
75
122
  }
76
123
  }
@@ -89,26 +136,17 @@ window.CapybaraLockstep = (function() {
89
136
  })
90
137
  }
91
138
 
92
- function onInteraction() {
93
- // We wait until the end of this microtask, assuming that any callback that
94
- // would queue an AJAX request or load additional scripts will run by then.
95
- startWorkForMicrotask()
96
- }
97
-
98
- function trackHistory() {
99
- ['popstate'].forEach(function(eventType) {
100
- document.addEventListener(eventType, onHistoryEvent)
101
- })
102
- }
103
-
104
- function onHistoryEvent() {
105
- // After calling history.back() or history.forward() the popstate event will *not*
106
- // fire synchronously. It will also not fire in the next task. Chrome sometimes fires
107
- // it after 10ms, but sometimes it takes longer.
108
- startWorkForTime(100)
139
+ function onInteraction(event) {
140
+ startWork()
141
+ // (1) We wait until the end of this microtask, assuming that any callback that
142
+ // would queue an AJAX request or load additional scripts will run by then.
143
+ // (2) For performance reasons we don't wait for `waitTasks` here.
144
+ // Whatever was queued by an event handler should call us again, and then
145
+ // we do wait for additional tasks.
146
+ Promise.resolve().then(stopWorkNow)
109
147
  }
110
148
 
111
- function trackDynamicScripts() {
149
+ function trackRemoteElements() {
112
150
  if (!window.MutationObserver) {
113
151
  return
114
152
  }
@@ -116,40 +154,43 @@ window.CapybaraLockstep = (function() {
116
154
  // Dynamic imports or analytics snippets may insert a <script src>
117
155
  // tag that loads and executes additional JavaScript. We want to be isBusy()
118
156
  // until such scripts have loaded or errored.
119
- var observer = new MutationObserver(onAnyElementChanged)
157
+ let observer = new MutationObserver(onAnyElementChanged)
120
158
  observer.observe(document, { subtree: true, childList: true })
121
159
  }
122
160
 
123
161
  function trackJQuery() {
124
162
  // jQuery may be loaded after us, so we wait until DOMContentReady.
125
163
  whenReady(function() {
126
- if (!window.jQuery) {
164
+ if (!window.jQuery || waitTasks > 0) {
127
165
  return
128
166
  }
129
167
 
130
168
  // Although $.ajax() uses XHR internally, it also uses $.Deferred() which does
131
169
  // not resolve in the next microtask but in the next *task* (it makes itself
132
170
  // async using setTimoeut()). Hence we need to wait for it in addition to XHR.
133
- var oldAjax = jQuery.ajax
134
- jQuery.ajax = function () {
135
- var promise = oldAjax.apply(this, arguments)
171
+ //
172
+ // If user code also uses $.Deferred(), it is also recommended to set
173
+ // CapybaraLockdown.waitTasks = 1 or higher.
174
+ let oldAjax = window.jQuery.ajax
175
+ window.jQuery.ajax = function() {
176
+ let promise = oldAjax.apply(this, arguments)
136
177
  startWorkUntil(promise)
137
178
  return promise
138
179
  }
139
180
  })
140
181
  }
141
182
 
142
- var INITIALIZING_ATTRIBUTE = 'data-initializing'
183
+ let INITIALIZING_ATTRIBUTE = 'data-initializing'
143
184
 
144
185
  function trackHydration() {
145
186
  // Until we have a body on which we can observe [data-initializing]
146
187
  // we consider ourselves busy.
147
188
  startWork()
148
189
  whenReady(function() {
149
- stopWork()
190
+ stopWorkNow()
150
191
  if (document.body.hasAttribute(INITIALIZING_ATTRIBUTE)) {
151
- startWork()
152
- var observer = new MutationObserver(onInitializingAttributeChanged)
192
+ startWork('Page initialization')
193
+ let observer = new MutationObserver(onInitializingAttributeChanged)
153
194
  observer.observe(document.body, { attributes: true, attributeFilter: [INITIALIZING_ATTRIBUTE] })
154
195
  }
155
196
  })
@@ -157,36 +198,101 @@ window.CapybaraLockstep = (function() {
157
198
 
158
199
  function onInitializingAttributeChanged() {
159
200
  if (!document.body.hasAttribute(INITIALIZING_ATTRIBUTE)) {
160
- stopWork()
201
+ stopWork('Page initialization')
161
202
  }
162
203
  }
163
204
 
164
- function isRemoteScript(node) {
165
- if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'SCRIPT') {
166
- var src = node.getAttribute('src')
167
- var type = node.getAttribute('type')
205
+ function isRemoteScript(element) {
206
+ if (element.tagName === 'SCRIPT') {
207
+ let src = element.getAttribute('src')
208
+ let type = element.getAttribute('type')
168
209
 
169
- return (src && (!type || /javascript/i.test(type)))
210
+ return src && (!type || /javascript/i.test(type))
170
211
  }
171
212
  }
172
213
 
173
- function onRemoteScriptAdded(script) {
174
- startWork()
175
- // Chrome runs a remote <script> *before* the load event fires.
176
- script.addEventListener('load', stopWork)
177
- script.addEventListener('error', stopWork)
214
+ function isRemoteImage(element) {
215
+ if (element.tagName === 'IMG' && !element.complete) {
216
+ let src = element.getAttribute('src')
217
+ let srcSet = element.getAttribute('srcset')
218
+
219
+ let localSrcPattern = /^data:/
220
+ let localSrcSetPattern = /(^|\s)data:/
221
+
222
+ let hasLocalSrc = src && localSrcPattern.test(src)
223
+ let hasLocalSrcSet = srcSet && localSrcSetPattern.test(srcSet)
224
+
225
+ return (src && !hasLocalSrc) || (srcSet && !hasLocalSrcSet)
226
+ }
227
+ }
228
+
229
+ function isRemoteInlineFrame(element) {
230
+ if (element.tagName === 'IFRAME') {
231
+ let src = element.getAttribute('src')
232
+ let localSrcPattern = /^data:/
233
+ let hasLocalSrc = src && localSrcPattern.test(src)
234
+ return (src && !hasLocalSrc)
235
+ }
236
+ }
237
+
238
+ function trackRemoteElement(element, condition, workTag) {
239
+ if (!condition(element)) {
240
+ return
241
+ }
242
+
243
+ let stopped = false
244
+
245
+ startWork(workTag)
246
+
247
+ let doStop = function() {
248
+ stopped = true
249
+ element.removeEventListener('load', doStop)
250
+ element.removeEventListener('error', doStop)
251
+ stopWork(workTag)
252
+ }
253
+
254
+ let checkCondition = function() {
255
+ if (stopped) {
256
+ // A `load` or `error` event has fired.
257
+ // We can stop here. No need to schedule another check.
258
+ return
259
+ } else if (isDetached(element) || !condition(element)) {
260
+ // If it is detached or if its `[src]` attribute changes to a data: URL
261
+ // we may never get a `load` or `error` event.
262
+ doStop()
263
+ } else {
264
+ scheduleCheckCondition()
265
+ }
266
+ }
267
+
268
+ let scheduleCheckCondition = function() {
269
+ setTimeout(checkCondition, 200)
270
+ }
271
+
272
+ element.addEventListener('load', doStop)
273
+ element.addEventListener('error', doStop)
274
+
275
+ // We periodically check whether we still think the element will
276
+ // produce a `load` or `error` event.
277
+ scheduleCheckCondition()
178
278
  }
179
279
 
180
280
  function onAnyElementChanged(changes) {
181
281
  changes.forEach(function(change) {
182
282
  change.addedNodes.forEach(function(addedNode) {
183
- if (isRemoteScript(addedNode)) {
184
- onRemoteScriptAdded(addedNode)
283
+ if (addedNode.nodeType === Node.ELEMENT_NODE) {
284
+ trackRemoteElement(addedNode, isRemoteScript, 'Script')
285
+ trackRemoteElement(addedNode, isRemoteImage, 'Image')
286
+ trackRemoteElement(addedNode, isRemoteInlineFrame, 'Inline frame')
185
287
  }
186
288
  })
187
289
  })
188
290
  }
189
291
 
292
+ function isDetached(element) {
293
+ return !document.contains(element)
294
+ }
295
+
190
296
  function whenReady(callback) {
191
297
  // Values are "loading", "interactive" and "completed".
192
298
  // https://developer.mozilla.org/en-US/docs/Web/API/Document/readyState
@@ -201,8 +307,7 @@ window.CapybaraLockstep = (function() {
201
307
  trackFetch()
202
308
  trackXHR()
203
309
  trackInteraction()
204
- trackHistory()
205
- trackDynamicScripts()
310
+ trackRemoteElements()
206
311
  trackJQuery()
207
312
  trackHydration()
208
313
  }
@@ -217,11 +322,14 @@ window.CapybaraLockstep = (function() {
217
322
 
218
323
  return {
219
324
  track: track,
325
+ isBusy: isBusy,
326
+ isIdle: isIdle,
220
327
  startWork: startWork,
221
328
  stopWork: stopWork,
222
329
  synchronize: synchronize,
223
- isIdle: isIdle,
224
- isBusy: isBusy
330
+ reset: reset,
331
+ set debug(value) { debug = value },
332
+ set waitTasks(value) { waitTasks = value }
225
333
  }
226
334
  })()
227
335