capybara-lockstep 0.2.3 → 0.4.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.
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