capybara-lockstep 0.3.2 → 0.7.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/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