capybara-lockstep 0.4.0 → 1.1.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,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