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 +4 -4
- data/Gemfile +2 -0
- data/Gemfile.lock +3 -1
- data/README.md +59 -55
- data/lib/capybara-lockstep.rb +1 -1
- data/lib/capybara-lockstep/capybara_ext.rb +33 -27
- data/lib/capybara-lockstep/configuration.rb +26 -2
- data/lib/capybara-lockstep/helper.js +92 -52
- data/lib/capybara-lockstep/helper.rb +7 -1
- data/lib/capybara-lockstep/lockstep.rb +88 -121
- data/lib/capybara-lockstep/logging.rb +24 -0
- data/lib/capybara-lockstep/version.rb +1 -1
- metadata +3 -3
- data/lib/capybara-lockstep/patiently.rb +0 -58
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 783200fa3cbde0166b11c213bb677250021185b7c9898a3fd95594dd8433b89a
|
4
|
+
data.tar.gz: 77efe617f3ce7f138da78e8b9db031b9a8f75f07d3afe8379ae903266a9fa389
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 6ec80d343f193b6b5a166bae1bf5ab6be2e7ae2e3c82d903596e4c604985d96382cce5012df5abdb714da614d8741f062386d60a47a1a3340734c22e46814949
|
7
|
+
data.tar.gz: 02e870bc8b8377be23e0ec97086219c7fd75a098f6f4eeda953dac86b7eb3a957d93984fb26265e8322c21989daa28cf4713ee3dc1772cd652f493e7999b6b57
|
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
capybara-lockstep (0.
|
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
|
-
|
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
|
190
|
+
In casual testing I experienced a performance impact between +/- 10%.
|
187
191
|
|
188
192
|
|
189
193
|
## Debugging log
|
190
194
|
|
191
|
-
capybara-lockstep
|
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
|
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
|
-
[
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
##
|
262
|
+
## Manual synchronization
|
238
263
|
|
239
|
-
capybara-lockstep
|
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
|
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
|
-
|
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
|
-
|
247
|
-
CapybaraLockstep.startWork()
|
248
|
-
}
|
275
|
+
CapybaraLockstep.synchronize(callback)
|
249
276
|
```
|
250
277
|
|
251
|
-
|
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
|
-
|
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
|
-
```
|
267
|
-
|
268
|
-
|
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
|
-
|
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
|
-
##
|
299
|
+
## Note on interacting with the JavaScript API
|
278
300
|
|
279
|
-
capybara-lockstep
|
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
|
-
|
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).
|
data/lib/capybara-lockstep.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
-
|
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
|
32
|
-
def
|
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.
|
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::
|
69
|
+
extend Capybara::Lockstep::UnsychronizeAfter
|
66
70
|
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
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
|
-
|
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
|
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
|
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
|
-
|
3
|
-
|
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
|
17
|
-
|
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
|
21
|
-
|
22
|
-
|
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
|
26
|
-
|
27
|
-
|
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
|
31
|
-
|
32
|
-
|
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
|
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('
|
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
|
-
|
72
|
+
let oldFetch = window.fetch
|
52
73
|
window.fetch = function() {
|
53
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
134
|
-
jQuery.ajax = function
|
135
|
-
|
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
|
-
|
145
|
-
|
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
|
-
|
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',
|
155
|
-
script.addEventListener('error',
|
193
|
+
script.addEventListener('load', taggedStopWork)
|
194
|
+
script.addEventListener('error', taggedStopWork)
|
156
195
|
}
|
157
196
|
|
158
|
-
function
|
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
|
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
|
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
|
-
|
200
|
-
|
201
|
-
|
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
|
-
|
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
|
-
|
8
|
-
|
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
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
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
|
53
|
-
|
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
|
73
|
-
|
74
|
-
return
|
26
|
+
def synchronize(lazy: false)
|
27
|
+
if (lazy && synchronized?) || synchronizing? || disabled?
|
28
|
+
return
|
75
29
|
end
|
76
30
|
|
77
|
-
|
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
|
102
|
-
|
103
|
-
|
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
|
107
|
-
|
108
|
-
|
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
|
148
|
-
|
149
|
-
|
150
|
-
|
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
|
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.
|
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-
|
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/
|
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
|