capybara-lockstep 0.4.0 → 1.1.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,57 @@ 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.auto_synchronize(lazy: !script_may_navigate_away, log: "Synchronizing before script: #{script}")
59
+ end
60
+
61
+ super(script, *args, &block).tap do
62
+ if !Lockstep.synchronizing?
63
+ # We haven't yet synchronized with whatever changes the JavaScript
64
+ # did on the frontend.
65
+ Lockstep.synchronized = false
66
+ end
67
+ end
68
+ end
69
+ ruby2_keywords meth
70
+ end
71
+ prepend(mod)
72
+ end
73
+
74
+ end
75
+ end
76
+ end
77
+
78
+ Capybara::Session.class_eval do
79
+ extend Capybara::Lockstep::SynchronizeAroundScriptMethod
80
+
81
+ synchronize_around_script_method :execute_script
82
+ synchronize_around_script_method :evaluate_async_script
83
+ # Don't synchronize around evaluate_script. It calls execute_script
84
+ # internally and we don't want to synchronize multiple times.
85
+ end
86
+
34
87
  module Capybara
35
88
  module Lockstep
36
89
  module UnsychronizeAfter
@@ -38,9 +91,10 @@ module Capybara
38
91
  mod = Module.new do
39
92
  define_method meth do |*args, &block|
40
93
  super(*args, &block).tap do
41
- Capybara::Lockstep.synchronized = false
94
+ Lockstep.synchronized = false
42
95
  end
43
96
  end
97
+ ruby2_keywords meth
44
98
  end
45
99
  prepend(mod)
46
100
  end
@@ -87,10 +141,11 @@ end
87
141
  module Capybara
88
142
  module Lockstep
89
143
  module SynchronizeWithCatchUp
90
- def synchronize(*args, &block)
91
- # This method is called very frequently by capybara.
144
+ ruby2_keywords def synchronize(*args, &block)
145
+ # This method is called by Capybara before most interactions with
146
+ # the browser. It is a different method than Capybara::Lockstep.synchronize!
92
147
  # We use the { lazy } option to only synchronize when we're out of sync.
93
- Capybara::Lockstep.synchronize(lazy: true)
148
+ Capybara::Lockstep.auto_synchronize(lazy: true)
94
149
 
95
150
  super(*args, &block)
96
151
  end
@@ -10,48 +10,71 @@ module Capybara
10
10
  @timeout = seconds
11
11
  end
12
12
 
13
+ def timeout_with
14
+ @timeout_with.nil? ? :log : @timeout_with
15
+ end
16
+
17
+ def timeout_with=(action)
18
+ @timeout_with = action&.to_sym
19
+ end
20
+
13
21
  def debug?
14
22
  # @debug may also be a Logger object, so convert it to a boolean
15
23
  @debug.nil? ? false : !!@debug
16
24
  end
17
25
 
18
- def debug=(debug)
19
- @debug = debug
20
- if debug
21
- target_prose = (is_logger?(debug) ? 'Ruby logger' : 'STDOUT')
26
+ def debug=(value)
27
+ @debug = value
28
+ if value
29
+ target_prose = (is_logger?(value) ? 'Ruby logger' : 'STDOUT')
22
30
  log "Logging to #{target_prose} and browser console"
23
31
  end
24
32
 
25
- begin
26
- with_max_wait_time(2) do
27
- page.execute_script(<<~JS)
28
- if (window.CapybaraLockstep) {
29
- CapybaraLockstep.setDebug(#{debug.to_json})
30
- }
31
- JS
32
- end
33
- rescue StandardError => e
34
- log "#{e.class.name} while enabling logs in browser: #{e.message}"
35
- # Don't fail. The next page load will include the snippet with debugging enabled.
36
- end
33
+ send_config_to_browser(<<~JS)
34
+ CapybaraLockstep.debug = #{value.to_json}
35
+ JS
37
36
 
38
37
  @debug
39
38
  end
40
39
 
41
- def enabled?
40
+ def mode
42
41
  if javascript_driver?
43
- @enabled.nil? ? true : @enabled
42
+ @mode.nil? ? :auto : @mode
44
43
  else
45
- false
44
+ :off
46
45
  end
47
46
  end
48
47
 
48
+ def mode=(mode)
49
+ @mode = mode&.to_sym
50
+ end
51
+
49
52
  def enabled=(enabled)
50
- @enabled = enabled
53
+ case enabled
54
+ when true
55
+ log "Setting `Capybara::Lockstep.enabled = true` is deprecated. Set `Capybara::Lockstep.mode = :auto` instead."
56
+ self.mode = :auto
57
+ when false
58
+ log "Setting `Capybara::Lockstep.enabled = false` is deprecated. Set `Capybara::Lockstep.mode = :manual` or `Capybara::Lockstep.mode = :off` instead."
59
+ self.mode = :manual
60
+ when nil
61
+ # Reset to default
62
+ self.mode = nil
63
+ end
51
64
  end
52
65
 
53
- def disabled?
54
- !enabled?
66
+ def wait_tasks
67
+ @wait_tasks
68
+ end
69
+
70
+ def wait_tasks=(value)
71
+ @wait_tasks = value
72
+
73
+ send_config_to_browser(<<~JS)
74
+ CapybaraLockstep.waitTasks = #{value.to_json}
75
+ JS
76
+
77
+ @wait_tasks
55
78
  end
56
79
 
57
80
  private
@@ -60,6 +83,21 @@ module Capybara
60
83
  driver.is_a?(Capybara::Selenium::Driver)
61
84
  end
62
85
 
86
+ def send_config_to_browser(js)
87
+ begin
88
+ with_max_wait_time(2) do
89
+ page.execute_script(<<~JS)
90
+ if (window.CapybaraLockstep) {
91
+ #{js}
92
+ }
93
+ JS
94
+ end
95
+ rescue StandardError => e
96
+ log "#{e.class.name} while configuring capybara-lockstep in browser: #{e.message}"
97
+ # Don't fail. The next page load will include the snippet with the new config.
98
+ end
99
+ end
100
+
63
101
  end
64
102
  end
65
103
  end
@@ -1,6 +1,6 @@
1
1
  module Capybara
2
2
  module Lockstep
3
3
  class Error < StandardError; end
4
- class Busy < Error; end
4
+ class Timeout < Error; end
5
5
  end
6
6
  end
@@ -1,13 +1,23 @@
1
1
  window.CapybaraLockstep = (function() {
2
- let count = 0
3
- let idleCallbacks = []
4
- let debug = false
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
+ }
5
15
 
6
16
  function isIdle() {
7
17
  // Can't check for document.readyState or body.initializing here,
8
18
  // since the user might navigate away from the page before it finishes
9
19
  // initializing.
10
- return count === 0
20
+ return jobCount === 0
11
21
  }
12
22
 
13
23
  function isBusy() {
@@ -33,34 +43,43 @@ window.CapybaraLockstep = (function() {
33
43
  }
34
44
 
35
45
  function startWork(tag) {
36
- count++
46
+ jobCount++
37
47
  if (tag) {
38
- logNegative('Started work: %s [%d jobs]', tag, count)
48
+ logNegative('Started work: %s [%d jobs]', tag, jobCount)
39
49
  }
40
50
  }
41
51
 
42
52
  function startWorkUntil(promise, tag) {
43
53
  startWork(tag)
44
- promise.then(stopWork, stopWork)
54
+ let taggedStopWork = stopWork.bind(this, tag)
55
+ promise.then(taggedStopWork, taggedStopWork)
45
56
  }
46
57
 
47
- function startWorkForMicrotask(tag) {
48
- startWork(tag)
49
- Promise.resolve().then(stopWork.bind(this, tag))
58
+ function stopWork(tag) {
59
+ let tasksElapsed = 0
60
+
61
+ let check = function() {
62
+ if (tasksElapsed < waitTasks) {
63
+ tasksElapsed++
64
+ setTimeout(check)
65
+ } else {
66
+ stopWorkNow(tag)
67
+ }
68
+ }
69
+
70
+ check()
50
71
  }
51
72
 
52
- function stopWork(tag) {
53
- count--
73
+ function stopWorkNow(tag) {
74
+ jobCount--
54
75
 
55
76
  if (tag) {
56
- logPositive('Finished work: %s [%d jobs]', tag, count)
77
+ logPositive('Finished work: %s [%d jobs]', tag, jobCount)
57
78
  }
58
79
 
59
- if (isIdle()) {
60
- idleCallbacks.forEach(function(callback) {
61
- callback('Finished waiting for JavaScript')
62
- })
63
- idleCallbacks = []
80
+ let idleCallback
81
+ while (isIdle() && (idleCallback = idleCallbacks.shift())) {
82
+ idleCallback('Finished waiting for browser')
64
83
  }
65
84
  }
66
85
 
@@ -104,47 +123,32 @@ window.CapybaraLockstep = (function() {
104
123
  }
105
124
  }
106
125
 
107
- function trackInteraction() {
108
- // We already override all interaction methods in the Selenium browser nodes, so they
109
- // wait for an idle frame afterwards. However a test script might also dispatch synthetic
110
- // events with executate_script() to manipulate the browser in ways that are not possible
111
- // with the Capybara API. When we observe such an event we wait until the end of the microtask,
112
- // assuming any busy action will be queued by then.
113
- ['click', 'mousedown', 'keydown', 'change', 'input', 'submit', 'focusin', 'focusout', 'scroll'].forEach(function(eventType) {
114
- // Use { useCapture: true } so we get the event before another listener
115
- // can prevent it from bubbling up to the document.
116
- document.addEventListener(eventType, onInteraction, { capture: true, passive: true })
117
- })
118
- }
119
-
120
- function onInteraction(event) {
121
- // We wait until the end of this microtask, assuming that any callback that
122
- // would queue an AJAX request or load additional scripts will run by then.
123
- startWorkForMicrotask()
124
- }
125
-
126
- function trackDynamicScripts() {
126
+ function trackRemoteElements() {
127
127
  if (!window.MutationObserver) {
128
128
  return
129
129
  }
130
130
 
131
- // Dynamic imports or analytics snippets may insert a <script src>
132
- // tag that loads and executes additional JavaScript. We want to be isBusy()
131
+ // Dynamic imports or analytics snippets may insert a script element
132
+ // that loads and executes additional JavaScript. We want to be isBusy()
133
133
  // until such scripts have loaded or errored.
134
134
  let observer = new MutationObserver(onAnyElementChanged)
135
135
  observer.observe(document, { subtree: true, childList: true })
136
136
  }
137
137
 
138
138
  function trackJQuery() {
139
- // jQuery may be loaded after us, so we wait until DOMContentReady.
139
+ // CapybaraLockstep.track() is called as the first script in the head.
140
+ // jQuery will be loaded after us, so we wait until DOMContentReady.
140
141
  whenReady(function() {
141
- if (!window.jQuery) {
142
+ if (!window.jQuery || waitTasks > 0) {
142
143
  return
143
144
  }
144
145
 
145
146
  // Although $.ajax() uses XHR internally, it also uses $.Deferred() which does
146
147
  // not resolve in the next microtask but in the next *task* (it makes itself
147
148
  // async using setTimoeut()). Hence we need to wait for it in addition to XHR.
149
+ //
150
+ // If user code also uses $.Deferred(), it is also recommended to set
151
+ // CapybaraLockdown.waitTasks = 1 or higher.
148
152
  let oldAjax = window.jQuery.ajax
149
153
  window.jQuery.ajax = function() {
150
154
  let promise = oldAjax.apply(this, arguments)
@@ -154,56 +158,97 @@ window.CapybaraLockstep = (function() {
154
158
  })
155
159
  }
156
160
 
157
- let INITIALIZING_ATTRIBUTE = 'data-initializing'
161
+ function isRemoteScript(element) {
162
+ if (element.tagName === 'SCRIPT') {
163
+ let src = element.getAttribute('src')
164
+ let type = element.getAttribute('type')
158
165
 
159
- function trackHydration() {
160
- // Until we have a body on which we can observe [data-initializing]
161
- // we consider ourselves busy.
162
- startWork()
163
- whenReady(function() {
164
- stopWork()
165
- if (document.body.hasAttribute(INITIALIZING_ATTRIBUTE)) {
166
- startWork('Page initialization')
167
- let observer = new MutationObserver(onInitializingAttributeChanged)
168
- observer.observe(document.body, { attributes: true, attributeFilter: [INITIALIZING_ATTRIBUTE] })
169
- }
170
- })
166
+ return src && (!type || /javascript/i.test(type))
167
+ }
171
168
  }
172
169
 
173
- function onInitializingAttributeChanged() {
174
- if (!document.body.hasAttribute(INITIALIZING_ATTRIBUTE)) {
175
- stopWork('Page initialization')
170
+ function isRemoteImage(element) {
171
+ if (element.tagName === 'IMG' && !element.complete) {
172
+ let src = element.getAttribute('src')
173
+ let srcSet = element.getAttribute('srcset')
174
+
175
+ let localSrcPattern = /^data:/
176
+ let localSrcSetPattern = /(^|\s)data:/
177
+
178
+ let hasLocalSrc = src && localSrcPattern.test(src)
179
+ let hasLocalSrcSet = srcSet && localSrcSetPattern.test(srcSet)
180
+
181
+ return (src && !hasLocalSrc) || (srcSet && !hasLocalSrcSet)
176
182
  }
177
183
  }
178
184
 
179
- function isRemoteScript(node) {
180
- if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'SCRIPT') {
181
- let src = node.getAttribute('src')
182
- let type = node.getAttribute('type')
183
-
184
- return (src && (!type || /javascript/i.test(type)))
185
+ function isRemoteInlineFrame(element) {
186
+ if (element.tagName === 'IFRAME') {
187
+ let src = element.getAttribute('src')
188
+ let localSrcPattern = /^data:/
189
+ let hasLocalSrc = src && localSrcPattern.test(src)
190
+ return (src && !hasLocalSrc)
185
191
  }
186
192
  }
187
193
 
188
- function onRemoteScriptAdded(script) {
189
- let workTag = 'Remote script ' + script.getAttribute('src')
194
+ function trackRemoteElement(element, condition, workTag) {
195
+ if (!condition(element)) {
196
+ return
197
+ }
198
+
199
+ let stopped = false
200
+
190
201
  startWork(workTag)
191
- let taggedStopWork = stopWork.bind(this, workTag)
192
- // Chrome runs a remote <script> *before* the load event fires.
193
- script.addEventListener('load', taggedStopWork)
194
- script.addEventListener('error', taggedStopWork)
202
+
203
+ let doStop = function() {
204
+ stopped = true
205
+ element.removeEventListener('load', doStop)
206
+ element.removeEventListener('error', doStop)
207
+ stopWork(workTag)
208
+ }
209
+
210
+ let checkCondition = function() {
211
+ if (stopped) {
212
+ // A `load` or `error` event has fired.
213
+ // We can stop here. No need to schedule another check.
214
+ return
215
+ } else if (isDetached(element) || !condition(element)) {
216
+ // If it is detached or if its `[src]` attribute changes to a data: URL
217
+ // we may never get a `load` or `error` event.
218
+ doStop()
219
+ } else {
220
+ scheduleCheckCondition()
221
+ }
222
+ }
223
+
224
+ let scheduleCheckCondition = function() {
225
+ setTimeout(checkCondition, 200)
226
+ }
227
+
228
+ element.addEventListener('load', doStop)
229
+ element.addEventListener('error', doStop)
230
+
231
+ // We periodically check whether we still think the element will
232
+ // produce a `load` or `error` event.
233
+ scheduleCheckCondition()
195
234
  }
196
235
 
197
236
  function onAnyElementChanged(changes) {
198
237
  changes.forEach(function(change) {
199
238
  change.addedNodes.forEach(function(addedNode) {
200
- if (isRemoteScript(addedNode)) {
201
- onRemoteScriptAdded(addedNode)
239
+ if (addedNode.nodeType === Node.ELEMENT_NODE) {
240
+ trackRemoteElement(addedNode, isRemoteScript, 'Script')
241
+ trackRemoteElement(addedNode, isRemoteImage, 'Image')
242
+ trackRemoteElement(addedNode, isRemoteInlineFrame, 'Inline frame')
202
243
  }
203
244
  })
204
245
  })
205
246
  }
206
247
 
248
+ function isDetached(element) {
249
+ return !document.contains(element)
250
+ }
251
+
207
252
  function whenReady(callback) {
208
253
  // Values are "loading", "interactive" and "completed".
209
254
  // https://developer.mozilla.org/en-US/docs/Web/API/Document/readyState
@@ -214,13 +259,28 @@ window.CapybaraLockstep = (function() {
214
259
  }
215
260
  }
216
261
 
262
+ function trackOldUnpoly() {
263
+ // CapybaraLockstep.track() is called as the first script in the head.
264
+ // Unpoly will be loaded after us, so we wait until DOMContentReady.
265
+ whenReady(function() {
266
+ // Unpoly 0.x would wait one task after DOMContentLoaded before booting.
267
+ // There's a slim chance that Capybara can observe the page before compilers have run.
268
+ // Unpoly 1.0+ runs compilers on DOMContentLoaded, so there's no issue.
269
+ if (window.up?.version?.startsWith('0.')) {
270
+ startWork('Old Unpoly')
271
+ setTimeout(function () {
272
+ stopWork('Old Unpoly')
273
+ })
274
+ }
275
+ })
276
+ }
277
+
217
278
  function track() {
279
+ trackOldUnpoly()
218
280
  trackFetch()
219
281
  trackXHR()
220
- trackInteraction()
221
- trackDynamicScripts()
282
+ trackRemoteElements()
222
283
  trackJQuery()
223
- trackHydration()
224
284
  }
225
285
 
226
286
  function synchronize(callback) {
@@ -233,12 +293,14 @@ window.CapybaraLockstep = (function() {
233
293
 
234
294
  return {
235
295
  track: track,
296
+ isBusy: isBusy,
297
+ isIdle: isIdle,
236
298
  startWork: startWork,
237
299
  stopWork: stopWork,
238
300
  synchronize: synchronize,
239
- set debug(newDebug) {
240
- debug = newDebug
241
- }
301
+ reset: reset,
302
+ set debug(value) { debug = value },
303
+ set waitTasks(value) { waitTasks = value }
242
304
  }
243
305
  })()
244
306