capybara-lockstep 0.2.3 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aa8ccde381bb125f66222689d93c93008ec2137cfabc664a6ce83ff0701cd7a8
4
- data.tar.gz: 7485b88104f0c4b43387f252e600ad7dc23a917eac28914249b8f279d5ab9df3
3
+ metadata.gz: 783200fa3cbde0166b11c213bb677250021185b7c9898a3fd95594dd8433b89a
4
+ data.tar.gz: 77efe617f3ce7f138da78e8b9db031b9a8f75f07d3afe8379ae903266a9fa389
5
5
  SHA512:
6
- metadata.gz: 19f52ab1ca0e9f30fe8be0e791b66bcefe9992d958a104a30c602987e310363587f075f7af19bebf8248e76836a008601c700092bc3b1d53fff3fda5b764dcdc
7
- data.tar.gz: e6380494b6940092842f87e54c898e6a2da9311aa2fd1951a602fb193fb44c7ee6f96907704d80382bd1cc2ccc31a7e0b433e27da8906f889aa6f7178fcd2dff
6
+ metadata.gz: 6ec80d343f193b6b5a166bae1bf5ab6be2e7ae2e3c82d903596e4c604985d96382cce5012df5abdb714da614d8741f062386d60a47a1a3340734c22e46814949
7
+ data.tar.gz: 02e870bc8b8377be23e0ec97086219c7fd75a098f6f4eeda953dac86b7eb3a957d93984fb26265e8322c21989daa28cf4713ee3dc1772cd652f493e7999b6b57
data/Gemfile CHANGED
@@ -8,3 +8,5 @@ gemspec
8
8
  gem "rake", "~> 13.0"
9
9
 
10
10
  gem "rspec", "~> 3.0"
11
+
12
+ gem 'byebug'
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- capybara-lockstep (0.2.3)
4
+ capybara-lockstep (0.4.0)
5
5
  activesupport (>= 3.2)
6
6
  capybara (>= 2.0)
7
7
  selenium-webdriver (>= 3)
@@ -17,6 +17,7 @@ GEM
17
17
  zeitwerk (~> 2.3)
18
18
  addressable (2.7.0)
19
19
  public_suffix (>= 2.0.2, < 5.0)
20
+ byebug (11.1.3)
20
21
  capybara (3.35.3)
21
22
  addressable
22
23
  mini_mime (>= 0.1.3)
@@ -70,6 +71,7 @@ PLATFORMS
70
71
  ruby
71
72
 
72
73
  DEPENDENCIES
74
+ byebug
73
75
  capybara-lockstep!
74
76
  rake (~> 13.0)
75
77
  rspec (~> 3.0)
data/README.md CHANGED
@@ -170,9 +170,13 @@ app.directive('body', function() {
170
170
 
171
171
  capybara-lockstep will automatically patch Capybara to wait for the browser after every command.
172
172
 
173
- Run your test suite to see if integration was successful and whether stability improves.
173
+ Run your test suite to see if integration was successful and whether stability improves. During validation we recommend to activate `Capybara::Lockstep.debug = true` in your `spec_helper.rb` (RSpec) or `env.rb` (Cucumber). You should see messages like this in your console:
174
174
 
175
- When you run into issues or don't see an effect, try activating `Capybara::Lockstep.debug = true` in your `spec_helper.rb` (RSpec) or `env.rb` (Cucumber).
175
+ ```text
176
+ [capybara-lockstep] Synchronizing
177
+ [capybara-lockstep] Finished waiting for JavaScript
178
+ [capybara-lockstep] Synchronized successfully
179
+ ```
176
180
 
177
181
  Note that you may see some failures from tests with wrong assertions, which sometimes passed due to lucky timing.
178
182
 
@@ -183,23 +187,37 @@ capybara-lockstep may or may not impact the runtime of your test suite. It depen
183
187
 
184
188
  While waiting for the browser to be idle does take a few milliseconds, Capybara no longer needs to retry failed commands. You will also save time from not needing to re-run failed tests.
185
189
 
186
- In casual testing I experienced a negative performance impact between 0% and 10%.
190
+ In casual testing I experienced a performance impact between +/- 10%.
187
191
 
188
192
 
189
193
  ## Debugging log
190
194
 
191
- capybara-lockstep can print to the console whenever it waits for the browser. To enable the log:
195
+ You can enable extensive logging. This is useful to see whether capybara-lockstep has an effect on your tests, or to debug why synchronization is taking too long.
196
+
197
+ To enable the log, say this before or during a test:
192
198
 
193
199
  ```ruby
194
200
  Capybara::Lockstep.debug = true
195
201
  ```
196
202
 
197
- You should now see messages like this during your test runs:
203
+ You should now see messages like this on your standard output:
204
+
205
+ ```
206
+ [capybara-lockstep] Synchronizing
207
+ [capybara-lockstep] Finished waiting for JavaScript
208
+ [capybara-lockstep] Synchronized successfully
209
+ ```
210
+
211
+ You should also see messages like this in your browser's JavaScript console:
198
212
 
199
213
  ```
200
- [Capybara::Lockstep] JavaScript or AJAX requests are running
214
+ [capybara-lockstep] Started work: fetch /path [3 jobs]
215
+ [capybara-lockstep] Finished work: fetch /path [2 jobs]
201
216
  ```
202
217
 
218
+
219
+ ### Using a logger
220
+
203
221
  You may also configure logging to an existing logger object:
204
222
 
205
223
  ```ruby
@@ -209,7 +227,9 @@ Capybara::Lockstep.debug = Rails.logger
209
227
 
210
228
  ## Disabling synchronization
211
229
 
212
- If for some reason you want to disable browser synchronization for a while, you can do it like this:
230
+ Sometimes you want to disable browser synchronization, e.g. to observe a loading spinner during a long-running request.
231
+
232
+ To disable synchronization:
213
233
 
214
234
  ```ruby
215
235
  begin
@@ -220,10 +240,11 @@ ensure
220
240
  end
221
241
  ```
222
242
 
243
+ ## Synchronization timeout
223
244
 
224
- ## Timeout
245
+ By default capybara-lockstep will wait `Capybara.default_max_wait_time` seconds for the page initialize and for JavaScript and AJAX request to finish.
225
246
 
226
- By default capybara-lockstep will wait up to 10 seconds for the page initialize and for JavaScript and AJAX request to finish.
247
+ When synchronization times out, capybara-lockstep will log but not raise an error.
227
248
 
228
249
  You can configure a different timeout:
229
250
 
@@ -231,94 +252,77 @@ You can configure a different timeout:
231
252
  Capybara::Lockstep.timeout = 5 # seconds
232
253
  ```
233
254
 
255
+ To revert to defaulting to `Capybara.default_max_wait_time`, set the timeout to `nil`:
234
256
 
257
+ ```ruby
258
+ Capybara::Lockstep.timeout = nil
259
+ ```
235
260
 
236
261
 
237
- ## JavaScript API
262
+ ## Manual synchronization
238
263
 
239
- capybara-lockstep already hooks into [many JavaScript APIs](#how-capybara-lockstep-helps) like `XMLHttpRequest` or `fetch()` to mark the browser as "busy" until their work finishes. **This should be enough for most test suites**.
264
+ capybara-lockstep will automatically patch Capybara to wait for the browser after every command. **This should be enough for most test suites**.
240
265
 
241
- For additional edge cases you may interact with capybara-lockstep from your own JavaScripts.
266
+ For additional edge cases you may manually tell capybara-lockstep to wait. The following Ruby method will block until the browser is idle:
242
267
 
243
- Note that when you only load the JavaScript snippet in tests you need check before calling any API functions:
268
+ ```ruby
269
+ Capybara::Lockstep.synchronize
270
+ ```
271
+
272
+ You may also synchronize from your client-side JavaScript. The following will run the given callback once the browser is idle:
244
273
 
245
274
  ```js
246
- if (window.CapybaraLockstep) {
247
- CapybaraLockstep.startWork()
248
- }
275
+ CapybaraLockstep.synchronize(callback)
249
276
  ```
250
277
 
251
- ### Signaling asynchronous work
278
+ ## Signaling asynchronous work
252
279
 
253
280
  If for some reason you want capybara-lockstep to consider additional asynchronous work as "busy", you can do so:
254
281
 
255
282
  ```js
256
- CapybaraLockstep.startWork()
283
+ CapybaraLockstep.startWork('Eject warp core')
257
284
  doAsynchronousWork().then(function() {
258
- CapybaraLockstep.stopWork()
285
+ CapybaraLockstep.stopWork('Eject warp core')
259
286
  })
260
287
  ```
261
288
 
262
- ### Checking if the browser is busy
263
-
264
- You can query capybara-lockstep whether it considers the browser to be busy or idle:
289
+ The string argument is used for logging (when logging is enabled). In this case you should see messages like this in your browser's JavaScript console:
265
290
 
266
- ```js
267
- CapybaraLockstep.isBusy() // => false
268
- CapybaraLockstep.isIdle() // => true
291
+ ```text
292
+ [capybara-lockstep] Started work: Eject warp core [1 jobs]
293
+ [capybara-lockstep] Finished work: Eject warp core [0 jobs]
269
294
  ```
270
295
 
271
- ### Waiting until the browser is idle
296
+ You may omit the string argument, in which case nothing will be logged, but the work will still be tracked.
272
297
 
273
- ```js
274
- CapybaraLockstep.awaitIdle(callback)
275
- ```
276
298
 
277
- ## Ruby API
299
+ ## Note on interacting with the JavaScript API
278
300
 
279
- capybara-lockstep will automatically patch Capybara to wait for the browser after every command. **This should be enough for most test suites**.
280
-
281
- For additional edge cases you may interact with capybara-lockstep from your Ruby code.
282
-
283
-
284
- ### Waiting until the browser is idle
285
-
286
- This will block until the document was loaded and the DOM has been hydrated:
301
+ If you only load capybara-lockstep in tests you, should check for the `CapybaraLockstep` global to be defined before you interact with the JavaScript API.
287
302
 
288
- ```ruby
289
- Capybara::Lockstep.await_initialized
290
- ```
291
-
292
- This will block while the browser is busy with JavaScript and AJAX requests:
293
-
294
- ```ruby
295
- Capybara::Lockstep.await_idle
296
303
  ```
297
-
298
- ### Checking if the browser is busy
299
-
300
- You can query capybara-lockstep whether it considers the browser to be busy or idle:
301
-
302
- ```ruby
303
- Capybara::Lockstep.idle? # => true
304
- Capybara::Lockstep.busy? # => false
304
+ if (window.CapybaraLockstep) {
305
+ // interact with CapybaraLockstep
306
+ }
305
307
  ```
306
308
 
307
-
308
309
  ## Development
309
310
 
310
311
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
311
312
 
312
313
  To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
313
314
 
315
+
314
316
  ## Contributing
315
317
 
316
318
  Pull requests are welcome on GitHub at <https://github.com/makandra/capybara-lockstep>.
317
319
 
320
+
318
321
  ## License
319
322
 
320
323
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
321
324
 
325
+
322
326
  ## Credits
323
327
 
324
328
  Henning Koch ([@triskweline](https://twitter.com/triskweline)) from [makandra](https://makandra.com).
@@ -10,8 +10,8 @@ end
10
10
 
11
11
  require_relative 'capybara-lockstep/version'
12
12
  require_relative 'capybara-lockstep/errors'
13
- require_relative 'capybara-lockstep/patiently'
14
13
  require_relative 'capybara-lockstep/configuration'
14
+ require_relative 'capybara-lockstep/logging'
15
15
  require_relative 'capybara-lockstep/lockstep'
16
16
  require_relative 'capybara-lockstep/capybara_ext'
17
17
  require_relative 'capybara-lockstep/helper'
@@ -2,18 +2,23 @@ module Capybara
2
2
  module Lockstep
3
3
  module VisitWithWaiting
4
4
  def visit(*args, &block)
5
- visiting_remote_url = !args[0].start_with?('data:')
5
+ url = args[0]
6
+ # Some of our apps have a Cucumber step that changes drivers mid-scenario.
7
+ # It works by creating a new Capybara session and re-visits the URL from the
8
+ # previous session. If this happens before a URL is ever loaded,
9
+ # it re-visits the URL "data:", which will never "finish" initializing.
10
+ # Also when opening a new tab via Capybara, the initial URL is about:blank.
11
+ visiting_remote_url = !(url.start_with?('data:') || url.start_with?('about:'))
6
12
 
7
- Capybara::Lockstep.catch_up
13
+ if visiting_remote_url
14
+ # We're about to leave this screen, killing all in-flight requests.
15
+ Capybara::Lockstep.synchronize
16
+ end
8
17
 
9
18
  super(*args, &block).tap do
10
- # There is a step that changes drivers mid-scenario.
11
- # It works by creating a new Capybara session and re-visits the
12
- # URL from the previous session. If this happens before a URL is ever
13
- # loaded, it re-visits the URL "data:", which will never "finish"
14
- # initializing.
15
19
  if visiting_remote_url
16
- Capybara::Lockstep.await_initialized
20
+ # puts "After visit: unsynchronizing"
21
+ Capybara::Lockstep.synchronized = false
17
22
  end
18
23
  end
19
24
  end
@@ -28,13 +33,12 @@ end
28
33
 
29
34
  module Capybara
30
35
  module Lockstep
31
- module AwaitIdle
32
- def await_idle(meth)
36
+ module UnsychronizeAfter
37
+ def unsychronize_after(meth)
33
38
  mod = Module.new do
34
39
  define_method meth do |*args, &block|
35
- Capybara::Lockstep.catch_up
36
40
  super(*args, &block).tap do
37
- Capybara::Lockstep.await_idle
41
+ Capybara::Lockstep.synchronized = false
38
42
  end
39
43
  end
40
44
  end
@@ -62,21 +66,21 @@ end
62
66
 
63
67
  node_classes.each do |node_class|
64
68
  node_class.class_eval do
65
- extend Capybara::Lockstep::AwaitIdle
69
+ extend Capybara::Lockstep::UnsychronizeAfter
66
70
 
67
- await_idle :set
68
- await_idle :select_option
69
- await_idle :unselect_option
70
- await_idle :click
71
- await_idle :right_click
72
- await_idle :double_click
73
- await_idle :send_keys
74
- await_idle :hover
75
- await_idle :drag_to
76
- await_idle :drop
77
- await_idle :scroll_by
78
- await_idle :scroll_to
79
- await_idle :trigger
71
+ unsychronize_after :set
72
+ unsychronize_after :select_option
73
+ unsychronize_after :unselect_option
74
+ unsychronize_after :click
75
+ unsychronize_after :right_click
76
+ unsychronize_after :double_click
77
+ unsychronize_after :send_keys
78
+ unsychronize_after :hover
79
+ unsychronize_after :drag_to
80
+ unsychronize_after :drop
81
+ unsychronize_after :scroll_by
82
+ unsychronize_after :scroll_to
83
+ unsychronize_after :trigger
80
84
  end
81
85
  end
82
86
 
@@ -84,7 +88,9 @@ module Capybara
84
88
  module Lockstep
85
89
  module SynchronizeWithCatchUp
86
90
  def synchronize(*args, &block)
87
- Capybara::Lockstep.catch_up
91
+ # This method is called very frequently by capybara.
92
+ # We use the { lazy } option to only synchronize when we're out of sync.
93
+ Capybara::Lockstep.synchronize(lazy: true)
88
94
 
89
95
  super(*args, &block)
90
96
  end
@@ -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,31 @@ 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
18
  def debug=(debug)
18
19
  @debug = debug
20
+ if debug
21
+ target_prose = (is_logger?(debug) ? 'Ruby logger' : 'STDOUT')
22
+ log "Logging to #{target_prose} and browser console"
23
+ end
24
+
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
37
+
38
+ @debug
19
39
  end
20
40
 
21
41
  def enabled?
@@ -30,6 +50,10 @@ module Capybara
30
50
  @enabled = enabled
31
51
  end
32
52
 
53
+ def disabled?
54
+ !enabled?
55
+ end
56
+
33
57
  private
34
58
 
35
59
  def javascript_driver?
@@ -1,6 +1,7 @@
1
1
  window.CapybaraLockstep = (function() {
2
- var count = 0
3
- var idleCallbacks = []
2
+ let count = 0
3
+ let idleCallbacks = []
4
+ let debug = false
4
5
 
5
6
  function isIdle() {
6
7
  // Can't check for document.readyState or body.initializing here,
@@ -13,31 +14,51 @@ window.CapybaraLockstep = (function() {
13
14
  return !isIdle()
14
15
  }
15
16
 
16
- function startWork() {
17
- count++
17
+ function log(...args) {
18
+ if (debug) {
19
+ args[0] = '%c[capybara-lockstep] ' + args[0]
20
+ args.splice(1, 0, 'color: #666666')
21
+ console.log.apply(console, args)
22
+ }
18
23
  }
19
24
 
20
- function startWorkUntil(promise) {
21
- startWork()
22
- promise.then(stopWork, stopWork)
25
+ function logPositive(...args) {
26
+ args[0] = '%c' + args[0]
27
+ log(args[0], 'color: #117722', ...args.slice(1))
23
28
  }
24
29
 
25
- function startWorkForTime(time) {
26
- startWork()
27
- setTimeout(stopWork, time)
30
+ function logNegative(...args) {
31
+ args[0] = '%c' + args[0]
32
+ log(args[0], 'color: #cc3311', ...args.slice(1))
28
33
  }
29
34
 
30
- function startWorkForMicrotask() {
31
- startWork()
32
- Promise.resolve().then(stopWork)
35
+ function startWork(tag) {
36
+ count++
37
+ if (tag) {
38
+ logNegative('Started work: %s [%d jobs]', tag, count)
39
+ }
33
40
  }
34
41
 
35
- function stopWork() {
42
+ function startWorkUntil(promise, tag) {
43
+ startWork(tag)
44
+ promise.then(stopWork, stopWork)
45
+ }
46
+
47
+ function startWorkForMicrotask(tag) {
48
+ startWork(tag)
49
+ Promise.resolve().then(stopWork.bind(this, tag))
50
+ }
51
+
52
+ function stopWork(tag) {
36
53
  count--
37
54
 
55
+ if (tag) {
56
+ logPositive('Finished work: %s [%d jobs]', tag, count)
57
+ }
58
+
38
59
  if (isIdle()) {
39
60
  idleCallbacks.forEach(function(callback) {
40
- callback('JavaScript has finished')
61
+ callback('Finished waiting for JavaScript')
41
62
  })
42
63
  idleCallbacks = []
43
64
  }
@@ -48,29 +69,36 @@ window.CapybaraLockstep = (function() {
48
69
  return
49
70
  }
50
71
 
51
- var oldFetch = window.fetch
72
+ let oldFetch = window.fetch
52
73
  window.fetch = function() {
53
- var promise = oldFetch.apply(this, arguments)
54
- startWorkUntil(promise)
74
+ let promise = oldFetch.apply(this, arguments)
75
+ startWorkUntil(promise, 'fetch ' + arguments[0])
55
76
  return promise
56
77
  }
57
78
  }
58
79
 
59
80
  function trackXHR() {
60
- var oldSend = XMLHttpRequest.prototype.send
81
+ let oldOpen = XMLHttpRequest.prototype.open
82
+ let oldSend = XMLHttpRequest.prototype.send
83
+
84
+ XMLHttpRequest.prototype.open = function() {
85
+ this.capybaraLockstepURL = arguments[1]
86
+ return oldOpen.apply(this, arguments)
87
+ }
61
88
 
62
89
  XMLHttpRequest.prototype.send = function() {
63
- startWork()
90
+ let workTag = 'XHR to '+ this.capybaraLockstepURL
91
+ startWork(workTag)
64
92
 
65
93
  try {
66
94
  this.addEventListener('readystatechange', function(event) {
67
- if (this.readyState === 4) { stopWork() }
95
+ if (this.readyState === 4) { stopWork(workTag) }
68
96
  }.bind(this))
69
97
  return oldSend.apply(this, arguments)
70
98
  } catch (e) {
71
99
  // If we get a sync exception during request dispatch
72
100
  // we assume the request never went out.
73
- stopWork()
101
+ stopWork(workTag)
74
102
  throw e
75
103
  }
76
104
  }
@@ -89,25 +117,12 @@ window.CapybaraLockstep = (function() {
89
117
  })
90
118
  }
91
119
 
92
- function onInteraction() {
120
+ function onInteraction(event) {
93
121
  // We wait until the end of this microtask, assuming that any callback that
94
122
  // would queue an AJAX request or load additional scripts will run by then.
95
123
  startWorkForMicrotask()
96
124
  }
97
125
 
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)
109
- }
110
-
111
126
  function trackDynamicScripts() {
112
127
  if (!window.MutationObserver) {
113
128
  return
@@ -116,7 +131,7 @@ window.CapybaraLockstep = (function() {
116
131
  // Dynamic imports or analytics snippets may insert a <script src>
117
132
  // tag that loads and executes additional JavaScript. We want to be isBusy()
118
133
  // until such scripts have loaded or errored.
119
- var observer = new MutationObserver(onMutated)
134
+ let observer = new MutationObserver(onAnyElementChanged)
120
135
  observer.observe(document, { subtree: true, childList: true })
121
136
  }
122
137
 
@@ -130,32 +145,56 @@ window.CapybaraLockstep = (function() {
130
145
  // Although $.ajax() uses XHR internally, it also uses $.Deferred() which does
131
146
  // not resolve in the next microtask but in the next *task* (it makes itself
132
147
  // 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)
148
+ let oldAjax = window.jQuery.ajax
149
+ window.jQuery.ajax = function() {
150
+ let promise = oldAjax.apply(this, arguments)
136
151
  startWorkUntil(promise)
137
152
  return promise
138
153
  }
139
154
  })
140
155
  }
141
156
 
157
+ let INITIALIZING_ATTRIBUTE = 'data-initializing'
158
+
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
+ })
171
+ }
172
+
173
+ function onInitializingAttributeChanged() {
174
+ if (!document.body.hasAttribute(INITIALIZING_ATTRIBUTE)) {
175
+ stopWork('Page initialization')
176
+ }
177
+ }
178
+
142
179
  function isRemoteScript(node) {
143
180
  if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'SCRIPT') {
144
- var src = node.getAttribute('src')
145
- var type = node.getAttribute('type')
181
+ let src = node.getAttribute('src')
182
+ let type = node.getAttribute('type')
146
183
 
147
184
  return (src && (!type || /javascript/i.test(type)))
148
185
  }
149
186
  }
150
187
 
151
188
  function onRemoteScriptAdded(script) {
152
- startWork()
189
+ let workTag = 'Remote script ' + script.getAttribute('src')
190
+ startWork(workTag)
191
+ let taggedStopWork = stopWork.bind(this, workTag)
153
192
  // Chrome runs a remote <script> *before* the load event fires.
154
- script.addEventListener('load', stopWork)
155
- script.addEventListener('error', stopWork)
193
+ script.addEventListener('load', taggedStopWork)
194
+ script.addEventListener('error', taggedStopWork)
156
195
  }
157
196
 
158
- function onMutated(changes) {
197
+ function onAnyElementChanged(changes) {
159
198
  changes.forEach(function(change) {
160
199
  change.addedNodes.forEach(function(addedNode) {
161
200
  if (isRemoteScript(addedNode)) {
@@ -168,7 +207,7 @@ window.CapybaraLockstep = (function() {
168
207
  function whenReady(callback) {
169
208
  // Values are "loading", "interactive" and "completed".
170
209
  // https://developer.mozilla.org/en-US/docs/Web/API/Document/readyState
171
- if (document.readyState != 'loading') {
210
+ if (document.readyState !== 'loading') {
172
211
  callback()
173
212
  } else {
174
213
  document.addEventListener('DOMContentLoaded', callback)
@@ -179,12 +218,12 @@ window.CapybaraLockstep = (function() {
179
218
  trackFetch()
180
219
  trackXHR()
181
220
  trackInteraction()
182
- trackHistory()
183
221
  trackDynamicScripts()
184
222
  trackJQuery()
223
+ trackHydration()
185
224
  }
186
225
 
187
- function awaitIdle(callback) {
226
+ function synchronize(callback) {
188
227
  if (isIdle()) {
189
228
  callback()
190
229
  } else {
@@ -196,9 +235,10 @@ window.CapybaraLockstep = (function() {
196
235
  track: track,
197
236
  startWork: startWork,
198
237
  stopWork: stopWork,
199
- awaitIdle: awaitIdle,
200
- isIdle: isIdle,
201
- isBusy: isBusy
238
+ synchronize: synchronize,
239
+ set debug(newDebug) {
240
+ debug = newDebug
241
+ }
202
242
  }
203
243
  })()
204
244
 
@@ -17,7 +17,13 @@ module Capybara
17
17
  tag_options[:nonce] = options.fetch(:nonce, true)
18
18
  end
19
19
 
20
- javascript_tag(capybara_lockstep_js, tag_options)
20
+ full_js = capybara_lockstep_js
21
+
22
+ if (debug = options.fetch(:debug, Lockstep.debug?))
23
+ full_js += "\nCapybaraLockstep.debug = #{debug.to_json}"
24
+ end
25
+
26
+ javascript_tag(full_js, tag_options)
21
27
  end
22
28
 
23
29
  end
@@ -1,131 +1,105 @@
1
1
  module Capybara
2
2
  module Lockstep
3
+ ERROR_SNIPPET_MISSING = 'Cannot synchronize: capybara-lockstep JavaScript snippet is missing'
4
+ ERROR_PAGE_MISSING = 'Cannot synchronize before initial Capybara visit'
5
+ ERROR_ALERT_OPEN = 'Cannot synchronize while an alert is open'
6
+ ERROR_NAVIGATED_AWAY = "Browser navigated away while synchronizing"
7
+
3
8
  class << self
4
- include Patiently
5
9
  include Configuration
10
+ include Logging
6
11
 
7
- def await_idle
8
- @delay_await_idle = false
9
- return unless enabled?
10
-
11
- with_max_wait_time(timeout) do
12
- message_from_js = evaluate_async_script(<<~JS)
13
- let done = arguments[0]
14
- if (window.CapybaraLockstep) {
15
- CapybaraLockstep.awaitIdle(done)
16
- } else {
17
- done('Cannot synchronize: Capybara::Lockstep was not included in page')
18
- }
19
- JS
20
- log(message_from_js)
21
- end
22
- rescue ::Selenium::WebDriver::Error::UnexpectedAlertOpenError
23
- log 'Cannot synchronize: Alert is open'
24
- @delay_await_idle = true
25
- end
26
-
27
- def await_initialized
28
- @delay_await_initialized = false
29
- @delay_await_idle = false # since we're also waiting for idle
30
- return unless enabled?
31
-
32
- # We're retrying the initialize check every few ms.
33
- # Don't clutter the log with dozens of identical messages.
34
- last_logged_reason = nil
35
-
36
- patiently(timeout) do
37
- if (reason = initialize_reason)
38
- if reason != last_logged_reason
39
- log(reason)
40
- last_logged_reason = reason
41
- end
12
+ attr_accessor :synchronizing
13
+ alias synchronizing? synchronizing
42
14
 
43
- # Raise an exception that will be retried by `patiently`
44
- raise Busy, reason
45
- end
46
- end
47
- rescue ::Selenium::WebDriver::Error::UnexpectedAlertOpenError
48
- log 'Cannot synchronize: Alert is open'
49
- @delay_await_initialized = true
15
+ def synchronized?
16
+ value = page.instance_variable_get(:@lockstep_synchronized)
17
+ # We consider a new Capybara session to be synchronized.
18
+ # This will be set to false after our first visit().
19
+ value.nil? ? true : value
50
20
  end
51
21
 
52
- def catch_up
53
- return if @catching_up
54
-
55
- begin
56
- @catching_up = true
57
- if @delay_await_initialized
58
- log 'Retrying synchronization'
59
- await_initialized
60
- # elsif browser_made_full_page_load?
61
- # log 'Browser loaded new page'
62
- # await_initialized
63
- elsif @delay_await_idle
64
- log 'Retrying synchronization'
65
- await_idle
66
- end
67
- ensure
68
- @catching_up = false
69
- end
22
+ def synchronized=(value)
23
+ page.instance_variable_set(:@lockstep_synchronized, value)
70
24
  end
71
25
 
72
- def idle?
73
- unless enabled?
74
- return true
26
+ def synchronize(lazy: false)
27
+ if (lazy && synchronized?) || synchronizing? || disabled?
28
+ return
75
29
  end
76
30
 
77
- result = execute_script(<<~JS)
78
- if (window.CapybaraLockstep) {
79
- return CapybaraLockstep.isIdle()
80
- } else {
81
- return 'Cannot check busy state: Capybara::Lockstep was not included in page'
82
- }
83
- JS
84
-
85
- if result.is_a?(String)
86
- log(result)
87
- # When the snippet is missing we assume that the browser is idle.
88
- # Otherwise we would wait forever.
89
- true
90
- else
91
- result
92
- end
93
- end
94
-
95
- def busy?
96
- !idle?
31
+ synchronize_now
97
32
  end
98
33
 
99
34
  private
100
35
 
101
- def browser_made_full_page_load?
102
- # Page change without visit()
103
- page.has_css?('body[data-hydrating]')
36
+ def synchronize_now
37
+ self.synchronizing = true
38
+ self.synchronized = false
39
+
40
+ log 'Synchronizing'
41
+
42
+ begin
43
+ with_max_wait_time(timeout) do
44
+ message_from_js = evaluate_async_script(<<~JS)
45
+ let done = arguments[0]
46
+ let synchronize = () => {
47
+ if (window.CapybaraLockstep) {
48
+ CapybaraLockstep.synchronize(done)
49
+ } else {
50
+ done(#{ERROR_SNIPPET_MISSING.to_json})
51
+ }
52
+ }
53
+ let protocol = location.protocol
54
+ if (protocol === 'data:' || protocol == 'about:') {
55
+ done(#{ERROR_PAGE_MISSING.to_json})
56
+ } else if (document.readyState === 'complete') {
57
+ synchronize()
58
+ } else {
59
+ window.addEventListener('load', synchronize)
60
+ }
61
+ JS
62
+
63
+ case message_from_js
64
+ when ERROR_PAGE_MISSING
65
+ log(message_from_js)
66
+ when ERROR_SNIPPET_MISSING
67
+ log(message_from_js)
68
+ else
69
+ log message_from_js
70
+ log "Synchronized successfully"
71
+ self.synchronized = true
72
+ end
73
+ end
74
+ rescue ::Selenium::WebDriver::Error::ScriptTimeoutError
75
+ log "Could not synchronize within #{timeout} seconds"
76
+ # Don't raise an error, this may happen if the server is slow to respond.
77
+ # We will retry on the next Capybara synchronize call.
78
+ rescue ::Selenium::WebDriver::Error::UnexpectedAlertOpenError
79
+ log ERROR_ALERT_OPEN
80
+ # Don't raise an error, this will happen in an innocent test.
81
+ # We will retry on the next Capybara synchronize call.
82
+ rescue ::Selenium::WebDriver::Error::JavascriptError => e
83
+ # When the URL changes while a script is running, my current selenium-webdriver
84
+ # raises a Selenium::WebDriver::Error::JavascriptError with the message:
85
+ # "javascript error: document unloaded while waiting for result".
86
+ # We will retry on the next Capybara synchronize call, by then we should see
87
+ # the new page.
88
+ if e.message.include?('unload')
89
+ log ERROR_NAVIGATED_AWAY
90
+ else
91
+ unhandled_synchronize_error(e)
92
+ end
93
+ rescue StandardError => e
94
+ unhandled_synchronize_error(e)
95
+ ensure
96
+ self.synchronizing = false
97
+ end
104
98
  end
105
99
 
106
- def initialize_reason
107
- execute_script(<<~JS)
108
- if (location.href.indexOf('data:') == 0) {
109
- return 'Requesting initial page'
110
- }
111
-
112
- if (document.readyState !== "complete") {
113
- return 'Document is loading'
114
- }
115
-
116
- // The application layouts render a <body data-initializing>.
117
- // The [data-initializing] attribute is removed by an Angular directive or Unpoly compiler (frontend).
118
- // to signal that all elements have been activated.
119
- if (document.querySelector('body[data-initializing]')) {
120
- return 'DOM is being hydrated'
121
- }
122
-
123
- if (window.CapybaraLockstep && CapybaraLockstep.isBusy()) {
124
- return 'JavaScript or AJAX requests are running'
125
- }
126
-
127
- return false
128
- JS
100
+ def unhandled_synchronize_error(e)
101
+ log "#{e.class.name} while synchronizing: #{e.message}"
102
+ raise e
129
103
  end
130
104
 
131
105
  def page
@@ -144,17 +118,10 @@ module Capybara
144
118
  end
145
119
  end
146
120
 
147
- def log(message)
148
- if debug? && message.present?
149
- message = "[Capybara::Lockstep] #{message}"
150
- if @debug.respond_to?(:debug)
151
- # If someone set Capybara::Lockstep to a logger, use that
152
- @debug.debug(message)
153
- else
154
- # Otherwise print to STDOUT
155
- puts message
156
- end
157
- end
121
+ def ignoring_alerts(&block)
122
+ block.call
123
+ rescue ::Selenium::WebDriver::Error::UnexpectedAlertOpenError
124
+ # no-op
158
125
  end
159
126
 
160
127
  end
@@ -0,0 +1,24 @@
1
+ module Capybara
2
+ module Lockstep
3
+ module Logging
4
+ def log(message)
5
+ if debug? && message.present?
6
+ message = "[capybara-lockstep] #{message}"
7
+ if is_logger?(@debug)
8
+ # If someone set Capybara::Lockstep to a logger, use that
9
+ @debug.debug(message)
10
+ else
11
+ # Otherwise print to STDOUT
12
+ puts message
13
+ end
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def is_logger?(object)
20
+ @debug.respond_to?(:debug)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -1,5 +1,5 @@
1
1
  module Capybara
2
2
  module Lockstep
3
- VERSION = "0.2.3"
3
+ VERSION = "0.4.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: capybara-lockstep
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Henning Koch
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-03-03 00:00:00.000000000 Z
11
+ date: 2021-03-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: capybara
@@ -77,7 +77,7 @@ files:
77
77
  - lib/capybara-lockstep/helper.js
78
78
  - lib/capybara-lockstep/helper.rb
79
79
  - lib/capybara-lockstep/lockstep.rb
80
- - lib/capybara-lockstep/patiently.rb
80
+ - lib/capybara-lockstep/logging.rb
81
81
  - lib/capybara-lockstep/version.rb
82
82
  homepage: https://github.com/makandra/capybara-lockstep
83
83
  licenses:
@@ -1,58 +0,0 @@
1
- module Capybara
2
- module Lockstep
3
- # Ported from https://github.com/makandra/spreewald/blob/master/lib/spreewald_support/tolerance_for_selenium_sync_issues.rb
4
- module Patiently
5
-
6
- RETRY_ERRORS = %w[
7
- Capybara::Lockstep::Busy
8
- Capybara::ElementNotFound
9
- Spec::Expectations::ExpectationNotMetError
10
- RSpec::Expectations::ExpectationNotMetError
11
- Minitest::Assertion
12
- Capybara::Poltergeist::ClickFailed
13
- Capybara::ExpectationNotMet
14
- Selenium::WebDriver::Error::StaleElementReferenceError
15
- Selenium::WebDriver::Error::NoAlertPresentError
16
- Selenium::WebDriver::Error::ElementNotVisibleError
17
- Selenium::WebDriver::Error::NoSuchFrameError
18
- Selenium::WebDriver::Error::NoAlertPresentError
19
- Selenium::WebDriver::Error::JavascriptError
20
- Selenium::WebDriver::Error::UnknownError
21
- Selenium::WebDriver::Error::NoSuchAlertError
22
- ]
23
-
24
- # evaluate_script latency is ~ 0.025s
25
- WAIT_PERIOD = 0.03
26
-
27
- def patiently(timeout = Capybara.default_max_wait_time, &block)
28
- started = monotonic_time
29
- tries = 0
30
- begin
31
- tries += 1
32
- block.call
33
- rescue Exception => e
34
- raise e unless retryable_error?(e)
35
- raise e if (monotonic_time - started > timeout && tries >= 2)
36
- sleep(WAIT_PERIOD)
37
- if monotonic_time == started
38
- raise Capybara::FrozenInTime, "time appears to be frozen, Capybara does not work with libraries which freeze time, consider using time travelling instead"
39
- end
40
- retry
41
- end
42
- end
43
-
44
- private
45
-
46
- def monotonic_time
47
- # We use the system clock (i.e. seconds since boot) to calculate the time,
48
- # because Time.now may be frozen
49
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
50
- end
51
-
52
- def retryable_error?(e)
53
- RETRY_ERRORS.include?(e.class.name)
54
- end
55
-
56
- end
57
- end
58
- end