capybara-lockstep 0.3.1 → 0.6.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 +4 -4
- data/Gemfile +2 -0
- data/Gemfile.lock +3 -1
- data/README.md +111 -57
- data/lib/capybara-lockstep/capybara_ext.rb +56 -5
- data/lib/capybara-lockstep/configuration.rb +44 -4
- data/lib/capybara-lockstep/helper.js +167 -72
- data/lib/capybara-lockstep/helper.rb +16 -1
- data/lib/capybara-lockstep/lockstep.rb +50 -14
- data/lib/capybara-lockstep/logging.rb +8 -2
- data/lib/capybara-lockstep/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ef423eecbf9d793e932d66b056feee733835efa0387ec6740fecb2975585f95e
|
4
|
+
data.tar.gz: 55e8872d7b892567797ded6fb67ed17500bc3daf29e11fbf4fee45588cdab9f2
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: cd14f06c4b820e5e0dda482438d282e7a856c790ef0952f44c28a6f18dd9fb863323f782b8e21d9846ec9660fcf804ea257e5c05640482e777408e71ab820ccd
|
7
|
+
data.tar.gz: dc64900722119cc945b6b22113b63d13706d0cfb2ae39e0b8411c62aad0cbfc716ff84433189521f685219ee85f48554852df1204ce0fad1fed4e7f19b1d3cf0
|
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.6.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
@@ -38,17 +38,13 @@ How capybara-lockstep helps
|
|
38
38
|
|
39
39
|
capybara-lockstep waits until the browser is idle before moving on to the next Capybara command. This greatly relieves the pressure on Capybara's retry logic.
|
40
40
|
|
41
|
-
Whenever Capybara visits a new URL:
|
41
|
+
Whenever Capybara visits a new URL or simulates a user interaction (clicking, typing, etc.):
|
42
42
|
|
43
43
|
- capybara-lockstep waits for all document resources to load.
|
44
44
|
- capybara-lockstep waits for client-side JavaScript to render or hydrate DOM elements.
|
45
45
|
- capybara-lockstep waits for any AJAX requests.
|
46
46
|
- capybara-lockstep waits for dynamically inserted `<script>`s to load (e.g. from [dynamic imports](https://webpack.js.org/guides/code-splitting/#dynamic-imports) or Analytics snippets).
|
47
|
-
|
48
|
-
Whenever Capybara simulates a user interaction (clicking, typing, etc.):
|
49
|
-
|
50
|
-
- capybara-lockstep waits for any AJAX requests.
|
51
|
-
- capybara-lockstep waits for dynamically inserted `<script>`s to load (e.g. from [dynamic imports](https://webpack.js.org/guides/code-splitting/#dynamic-imports) or Analytics snippets).
|
47
|
+
- capybara-lockstep waits for dynamically `<img>` or `<iframe>` elements to load.
|
52
48
|
|
53
49
|
|
54
50
|
Installation
|
@@ -102,9 +98,9 @@ If you're not using Rails you can `include Capybara::Lockstep::Helper` and acces
|
|
102
98
|
|
103
99
|
### Signaling the end of page initialization
|
104
100
|
|
105
|
-
Most web applications run some JavaScript after
|
101
|
+
Most web applications run some JavaScript after a document has initially loaded. Such JavaScript usually enhances existing DOM elements ("hydration") or renders additional element into the DOM.
|
106
102
|
|
107
|
-
capybara-lockstep
|
103
|
+
capybara-lockstep will synchronize more reliably if you signal when your JavaScript is done rendering the initial document. After the initial rendering, capybara-lockstep will automatically detect when the browser is busy, even if content is changed dynamically later.
|
108
104
|
|
109
105
|
To signal that JavaScript is still initializing, your application layouts should render the `<body>` element with an `[data-initializing]` attribute:
|
110
106
|
|
@@ -112,10 +108,14 @@ To signal that JavaScript is still initializing, your application layouts should
|
|
112
108
|
<body data-initializing>
|
113
109
|
```
|
114
110
|
|
115
|
-
Your application JavaScript should remove the `[data-initializing]` attribute when it is done
|
111
|
+
Your application JavaScript should remove the `[data-initializing]` attribute when it is done rendering the initial page.
|
116
112
|
|
117
113
|
More precisely, the attribute should be removed in the same [JavaScript task](https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/) ("tick") that will finish initializing. capybara-lockstep will assume that the page will be initialized by the end of this task.
|
118
114
|
|
115
|
+
**After the initial rendering, capybara-lockstep will automatically detect when the browser is busy, even if content is changed dynamically later. After the initial page load you no longer need to add or remove the `[data-initializing]` attribute.**
|
116
|
+
|
117
|
+
#### Example: Vanilla JS
|
118
|
+
|
119
119
|
If all your initializing JavaScript runs synchronously on `DOMContentLoaded`, you can remove `[data-initializing]` in an event handler:
|
120
120
|
|
121
121
|
```js
|
@@ -125,26 +125,42 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
125
125
|
})
|
126
126
|
```
|
127
127
|
|
128
|
-
If you
|
128
|
+
If you call libraries during initialization, you may need to check the library code to see whether it finishes synchronously or asynchronously. Ideally a library offers a callback to notify you when it is done rendering:
|
129
129
|
|
130
130
|
```js
|
131
131
|
document.addEventListener('DOMContentLoaded', function() {
|
132
|
-
|
133
|
-
|
134
|
-
|
132
|
+
Libary.initialize({
|
133
|
+
onFinished: function() {
|
134
|
+
document.body.removeAttribute('data-initializing')
|
135
|
+
})
|
135
136
|
})
|
137
|
+
setTimeout(function() { document.body.removeAttribute('data-initializing') })
|
136
138
|
})
|
137
139
|
```
|
138
140
|
|
139
|
-
|
141
|
+
When a library offers no such callback, but you see in its code that the library delays work for a task, you must also wait another task to remove `[data-initializing]`:
|
140
142
|
|
141
143
|
```js
|
142
144
|
document.addEventListener('DOMContentLoaded', function() {
|
143
|
-
Libary.
|
145
|
+
Libary.initialize()
|
144
146
|
setTimeout(function() { document.body.removeAttribute('data-initializing') })
|
145
147
|
})
|
146
148
|
```
|
147
149
|
|
150
|
+
If your initialization code lazy-loads another script, you should only remove `[data-initializing]` once that is done:
|
151
|
+
|
152
|
+
```js
|
153
|
+
document.addEventListener('DOMContentLoaded', function() {
|
154
|
+
import('huge-library').then(function({ HugeLibrary }) {
|
155
|
+
HugeLibrary.initialize()
|
156
|
+
document.body.removeAttribute('data-initializing')
|
157
|
+
})
|
158
|
+
})
|
159
|
+
```
|
160
|
+
|
161
|
+
|
162
|
+
#### Example: Unpoly
|
163
|
+
|
148
164
|
When you're using [Unpoly](https://unpoly.com/) initializing will usually happen synchronously in [compilers](https://unpoly.com/up.compiler). Hence a compiler is a good place to remove `[data-initializing]`:
|
149
165
|
|
150
166
|
```js
|
@@ -153,6 +169,8 @@ up.compiler('body', function(body) {
|
|
153
169
|
})
|
154
170
|
```
|
155
171
|
|
172
|
+
#### Example: AngularJS 1
|
173
|
+
|
156
174
|
When you're using [AngularJS 1](https://unpoly.com/) initializing will usually happen synchronously in [directives](https://docs.angularjs.org/guide/directive). Hence a directive is a good place to remove `[data-initializing]`:
|
157
175
|
|
158
176
|
```js
|
@@ -173,9 +191,9 @@ capybara-lockstep will automatically patch Capybara to wait for the browser afte
|
|
173
191
|
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
192
|
|
175
193
|
```text
|
176
|
-
[
|
177
|
-
[
|
178
|
-
[
|
194
|
+
[capybara-lockstep] Synchronizing
|
195
|
+
[capybara-lockstep] Finished waiting for JavaScript
|
196
|
+
[capybara-lockstep] Synchronized successfully
|
179
197
|
```
|
180
198
|
|
181
199
|
Note that you may see some failures from tests with wrong assertions, which sometimes passed due to lucky timing.
|
@@ -187,35 +205,57 @@ capybara-lockstep may or may not impact the runtime of your test suite. It depen
|
|
187
205
|
|
188
206
|
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.
|
189
207
|
|
190
|
-
In casual testing I experienced a
|
208
|
+
In casual testing I experienced a performance impact between +/- 10%.
|
191
209
|
|
192
210
|
|
193
211
|
## Debugging log
|
194
212
|
|
195
|
-
capybara-lockstep
|
213
|
+
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.
|
214
|
+
|
215
|
+
To enable the log, say this before or during a test:
|
196
216
|
|
197
217
|
```ruby
|
198
218
|
Capybara::Lockstep.debug = true
|
199
219
|
```
|
200
220
|
|
201
|
-
You should now see messages like this
|
221
|
+
You should now see messages like this on your standard output:
|
202
222
|
|
203
223
|
```
|
204
|
-
[
|
205
|
-
[
|
206
|
-
[
|
224
|
+
[capybara-lockstep] Synchronizing
|
225
|
+
[capybara-lockstep] Finished waiting for JavaScript
|
226
|
+
[capybara-lockstep] Synchronized successfully
|
207
227
|
```
|
208
228
|
|
229
|
+
You should also see messages like this in your browser's JavaScript console:
|
230
|
+
|
231
|
+
```
|
232
|
+
[capybara-lockstep] Started work: fetch /path [3 jobs]
|
233
|
+
[capybara-lockstep] Finished work: fetch /path [2 jobs]
|
234
|
+
```
|
235
|
+
|
236
|
+
|
237
|
+
### Using a logger
|
238
|
+
|
209
239
|
You may also configure logging to an existing logger object:
|
210
240
|
|
211
241
|
```ruby
|
212
242
|
Capybara::Lockstep.debug = Rails.logger
|
213
243
|
```
|
214
244
|
|
245
|
+
### Logging in the browser only
|
246
|
+
|
247
|
+
To enable logging in the browser console (but not STDOUT), include the snippet with `{ debug: true }`:
|
248
|
+
|
249
|
+
```
|
250
|
+
capybara_lockstep(debug: true)
|
251
|
+
```
|
252
|
+
|
215
253
|
|
216
254
|
## Disabling synchronization
|
217
255
|
|
218
|
-
|
256
|
+
Sometimes you want to disable browser synchronization, e.g. to observe a loading spinner during a long-running request.
|
257
|
+
|
258
|
+
To disable synchronization:
|
219
259
|
|
220
260
|
```ruby
|
221
261
|
begin
|
@@ -226,9 +266,11 @@ ensure
|
|
226
266
|
end
|
227
267
|
```
|
228
268
|
|
229
|
-
##
|
269
|
+
## Synchronization timeout
|
270
|
+
|
271
|
+
By default capybara-lockstep will wait `Capybara.default_max_wait_time` seconds for the page initialize and for JavaScript and AJAX request to finish.
|
230
272
|
|
231
|
-
|
273
|
+
When synchronization times out, capybara-lockstep will log but not raise an error.
|
232
274
|
|
233
275
|
You can configure a different timeout:
|
234
276
|
|
@@ -236,70 +278,82 @@ You can configure a different timeout:
|
|
236
278
|
Capybara::Lockstep.timeout = 5 # seconds
|
237
279
|
```
|
238
280
|
|
239
|
-
|
281
|
+
To revert to defaulting to `Capybara.default_max_wait_time`, set the timeout to `nil`:
|
240
282
|
|
241
|
-
|
283
|
+
```ruby
|
284
|
+
Capybara::Lockstep.timeout = nil
|
285
|
+
```
|
242
286
|
|
243
|
-
For additional edge cases you may interact with capybara-lockstep from your Ruby code.
|
244
287
|
|
288
|
+
## Manual synchronization
|
245
289
|
|
246
|
-
|
290
|
+
capybara-lockstep will automatically patch Capybara to wait for the browser after every command. **This should be enough for most test suites**.
|
247
291
|
|
248
|
-
|
292
|
+
For additional edge cases you may manually tell capybara-lockstep to wait. The following Ruby method will block until the browser is idle:
|
249
293
|
|
250
294
|
```ruby
|
251
295
|
Capybara::Lockstep.synchronize
|
252
296
|
```
|
253
297
|
|
254
|
-
|
298
|
+
You may also synchronize from your client-side JavaScript. The following will run the given callback once the browser is idle:
|
255
299
|
|
256
|
-
```
|
257
|
-
|
258
|
-
|
259
|
-
|
300
|
+
```js
|
301
|
+
CapybaraLockstep.synchronize(callback)
|
302
|
+
```
|
303
|
+
|
304
|
+
## Signaling asynchronous work
|
305
|
+
|
306
|
+
If for some reason you want capybara-lockstep to consider additional asynchronous work as "busy", you can do so:
|
307
|
+
|
308
|
+
```js
|
309
|
+
CapybaraLockstep.startWork('Eject warp core')
|
310
|
+
doAsynchronousWork().then(function() {
|
311
|
+
CapybaraLockstep.stopWork('Eject warp core')
|
312
|
+
})
|
313
|
+
```
|
314
|
+
|
315
|
+
The string argument is used for logging (when logging is enabled). It does **not** need to be unique per job. In this case you should see messages like this in your browser's JavaScript console:
|
316
|
+
|
317
|
+
```text
|
318
|
+
[capybara-lockstep] Started work: Eject warp core [1 jobs]
|
319
|
+
[capybara-lockstep] Finished work: Eject warp core [0 jobs]
|
260
320
|
```
|
261
321
|
|
262
|
-
|
322
|
+
You may omit the string argument, in which case nothing will be logged, but the work will still be tracked.
|
263
323
|
|
264
|
-
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**.
|
265
324
|
|
266
|
-
|
325
|
+
## Note on interacting with the JavaScript API
|
267
326
|
|
268
|
-
|
327
|
+
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.
|
269
328
|
|
270
329
|
```js
|
271
330
|
if (window.CapybaraLockstep) {
|
272
|
-
CapybaraLockstep
|
331
|
+
// interact with CapybaraLockstep
|
273
332
|
}
|
274
333
|
```
|
275
334
|
|
276
|
-
|
335
|
+
## Handling legacy promises
|
277
336
|
|
278
|
-
|
337
|
+
Legacy promise implementations (like jQuery's `$.Deferred` and AngularJS' `$q`) work using tasks instead of microtasks. Their AJAX implementations (like `$.ajax()` and `$http`) use these promises to signal that a request is done.
|
338
|
+
|
339
|
+
This means there is a time window in which all AJAX requests have finished, but their callbacks have not yet run:
|
279
340
|
|
280
341
|
```js
|
281
|
-
|
282
|
-
|
283
|
-
CapybaraLockstep.stopWork()
|
342
|
+
$.ajax('/foo').then(function() {
|
343
|
+
// This callback runs one task after the response was received
|
284
344
|
})
|
285
345
|
```
|
286
346
|
|
287
|
-
|
288
|
-
|
289
|
-
You can query capybara-lockstep whether it considers the browser to be busy or idle:
|
347
|
+
It is theoretically possible that your test will observe the browser in that window, and expect content that has not been rendered yet. This will usually be mitigated by Capybara's retry logic. **If** you think that this is an issue for your test suite, you can configure capybara-headless to wait additional tasks before it considers the browser to be idle:
|
290
348
|
|
291
349
|
```js
|
292
|
-
|
293
|
-
CapybaraLockstep.isIdle() // => true
|
350
|
+
Capybara:Lockstep.wait_tasks = 1
|
294
351
|
```
|
295
352
|
|
296
|
-
|
353
|
+
If you see longer `then()` chains in your code, you may need to configure a higher number of tasks to wait.
|
297
354
|
|
298
|
-
This will
|
355
|
+
This will have a negative performance impact on your test suite.
|
299
356
|
|
300
|
-
```js
|
301
|
-
CapybaraLockstep.synchronize(callback)
|
302
|
-
```
|
303
357
|
|
304
358
|
## Development
|
305
359
|
|
@@ -12,13 +12,18 @@ module Capybara
|
|
12
12
|
|
13
13
|
if visiting_remote_url
|
14
14
|
# We're about to leave this screen, killing all in-flight requests.
|
15
|
-
|
15
|
+
# Give pending form submissions etc. a chance to finish before we tear down
|
16
|
+
# the browser environment.
|
17
|
+
#
|
18
|
+
# We force a non-lazy synchronization so we pick up all client-side changes
|
19
|
+
# that have not been caused by Capybara commands.
|
20
|
+
Lockstep.synchronize(lazy: false)
|
16
21
|
end
|
17
22
|
|
18
23
|
super(*args, &block).tap do
|
19
24
|
if visiting_remote_url
|
20
|
-
#
|
21
|
-
|
25
|
+
# We haven't yet synchronized the new screen.
|
26
|
+
Lockstep.synchronized = false
|
22
27
|
end
|
23
28
|
end
|
24
29
|
end
|
@@ -26,11 +31,57 @@ module Capybara
|
|
26
31
|
end
|
27
32
|
end
|
28
33
|
|
29
|
-
|
30
34
|
Capybara::Session.class_eval do
|
31
35
|
prepend Capybara::Lockstep::VisitWithWaiting
|
32
36
|
end
|
33
37
|
|
38
|
+
module Capybara
|
39
|
+
module Lockstep
|
40
|
+
module SynchronizeAroundScriptMethod
|
41
|
+
|
42
|
+
def synchronize_around_script_method(meth)
|
43
|
+
mod = Module.new do
|
44
|
+
define_method meth do |script, *args, &block|
|
45
|
+
# Synchronization uses execute_script itself, so don't synchronize when
|
46
|
+
# we're already synchronizing.
|
47
|
+
if !Lockstep.synchronizing?
|
48
|
+
# It's generally a good idea to synchronize before a JavaScript wants
|
49
|
+
# to access or observe an earlier state change.
|
50
|
+
#
|
51
|
+
# In case the given script navigates away (with `location.href = url`,
|
52
|
+
# `history.back()`, etc.) we would kill all in-flight requests. For this case
|
53
|
+
# we force a non-lazy synchronization so we pick up all client-side changes
|
54
|
+
# that have not been caused by Capybara commands.
|
55
|
+
script_may_navigate_away = script =~ /\b(location|history)\b/
|
56
|
+
Lockstep.log "Synchronizing before script: #{script}"
|
57
|
+
Lockstep.synchronize(lazy: !script_may_navigate_away)
|
58
|
+
end
|
59
|
+
|
60
|
+
super(script, *args, &block).tap do
|
61
|
+
if !Lockstep.synchronizing?
|
62
|
+
# We haven't yet synchronized with whatever changes the JavaScript
|
63
|
+
# did on the frontend.
|
64
|
+
Lockstep.synchronized = false
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
prepend(mod)
|
70
|
+
end
|
71
|
+
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
Capybara::Session.class_eval do
|
77
|
+
extend Capybara::Lockstep::SynchronizeAroundScriptMethod
|
78
|
+
|
79
|
+
synchronize_around_script_method :execute_script
|
80
|
+
synchronize_around_script_method :evaluate_async_script
|
81
|
+
# Don't synchronize around evaluate_script. It calls execute_script
|
82
|
+
# internally and we don't want to synchronize multiple times.
|
83
|
+
end
|
84
|
+
|
34
85
|
module Capybara
|
35
86
|
module Lockstep
|
36
87
|
module UnsychronizeAfter
|
@@ -38,7 +89,7 @@ module Capybara
|
|
38
89
|
mod = Module.new do
|
39
90
|
define_method meth do |*args, &block|
|
40
91
|
super(*args, &block).tap do
|
41
|
-
|
92
|
+
Lockstep.synchronized = false
|
42
93
|
end
|
43
94
|
end
|
44
95
|
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,22 @@ 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
|
-
def debug=(
|
18
|
-
@debug =
|
18
|
+
def debug=(value)
|
19
|
+
@debug = value
|
20
|
+
if value
|
21
|
+
target_prose = (is_logger?(value) ? 'Ruby logger' : 'STDOUT')
|
22
|
+
log "Logging to #{target_prose} and browser console"
|
23
|
+
end
|
24
|
+
|
25
|
+
send_config_to_browser(<<~JS)
|
26
|
+
CapybaraLockstep.debug = #{value.to_json}
|
27
|
+
JS
|
28
|
+
|
29
|
+
@debug
|
19
30
|
end
|
20
31
|
|
21
32
|
def enabled?
|
@@ -30,6 +41,20 @@ module Capybara
|
|
30
41
|
@enabled = enabled
|
31
42
|
end
|
32
43
|
|
44
|
+
def wait_tasks
|
45
|
+
@wait_tasks
|
46
|
+
end
|
47
|
+
|
48
|
+
def wait_tasks=(value)
|
49
|
+
@wait_tasks = value
|
50
|
+
|
51
|
+
send_config_to_browser(<<~JS)
|
52
|
+
CapybaraLockstep.waitTasks = #{value.to_json}
|
53
|
+
JS
|
54
|
+
|
55
|
+
@wait_tasks
|
56
|
+
end
|
57
|
+
|
33
58
|
def disabled?
|
34
59
|
!enabled?
|
35
60
|
end
|
@@ -40,6 +65,21 @@ module Capybara
|
|
40
65
|
driver.is_a?(Capybara::Selenium::Driver)
|
41
66
|
end
|
42
67
|
|
68
|
+
def send_config_to_browser(js)
|
69
|
+
begin
|
70
|
+
with_max_wait_time(2) do
|
71
|
+
page.execute_script(<<~JS)
|
72
|
+
if (window.CapybaraLockstep) {
|
73
|
+
#{js}
|
74
|
+
}
|
75
|
+
JS
|
76
|
+
end
|
77
|
+
rescue StandardError => e
|
78
|
+
log "#{e.class.name} while configuring capybara-lockstep in browser: #{e.message}"
|
79
|
+
# Don't fail. The next page load will include the snippet with the new config.
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
43
83
|
end
|
44
84
|
end
|
45
85
|
end
|
@@ -1,45 +1,75 @@
|
|
1
1
|
window.CapybaraLockstep = (function() {
|
2
|
-
|
3
|
-
|
2
|
+
let jobCount = 0
|
3
|
+
let idleCallbacks = []
|
4
|
+
let debug = false
|
5
|
+
let waitTasks = 0
|
4
6
|
|
5
7
|
function isIdle() {
|
6
8
|
// Can't check for document.readyState or body.initializing here,
|
7
9
|
// since the user might navigate away from the page before it finishes
|
8
10
|
// initializing.
|
9
|
-
return
|
11
|
+
return jobCount === 0
|
10
12
|
}
|
11
13
|
|
12
14
|
function isBusy() {
|
13
15
|
return !isIdle()
|
14
16
|
}
|
15
17
|
|
16
|
-
function
|
17
|
-
|
18
|
+
function log(...args) {
|
19
|
+
if (debug) {
|
20
|
+
args[0] = '%c[capybara-lockstep] ' + args[0]
|
21
|
+
args.splice(1, 0, 'color: #666666')
|
22
|
+
console.log.apply(console, args)
|
23
|
+
}
|
18
24
|
}
|
19
25
|
|
20
|
-
function
|
21
|
-
|
22
|
-
|
26
|
+
function logPositive(...args) {
|
27
|
+
args[0] = '%c' + args[0]
|
28
|
+
log(args[0], 'color: #117722', ...args.slice(1))
|
23
29
|
}
|
24
30
|
|
25
|
-
function
|
26
|
-
|
27
|
-
|
31
|
+
function logNegative(...args) {
|
32
|
+
args[0] = '%c' + args[0]
|
33
|
+
log(args[0], 'color: #cc3311', ...args.slice(1))
|
28
34
|
}
|
29
35
|
|
30
|
-
function
|
31
|
-
|
32
|
-
|
36
|
+
function startWork(tag) {
|
37
|
+
jobCount++
|
38
|
+
if (tag) {
|
39
|
+
logNegative('Started work: %s [%d jobs]', tag, jobCount)
|
40
|
+
}
|
41
|
+
}
|
42
|
+
|
43
|
+
function startWorkUntil(promise, tag) {
|
44
|
+
startWork(tag)
|
45
|
+
promise.then(stopWork, stopWork)
|
33
46
|
}
|
34
47
|
|
35
|
-
function stopWork() {
|
36
|
-
|
48
|
+
function stopWork(tag) {
|
49
|
+
let tasksElapsed = 0
|
37
50
|
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
51
|
+
let check = function() {
|
52
|
+
if (tasksElapsed < waitTasks) {
|
53
|
+
tasksElapsed++
|
54
|
+
setTimeout(check)
|
55
|
+
} else {
|
56
|
+
stopWorkNow(tag)
|
57
|
+
}
|
58
|
+
}
|
59
|
+
|
60
|
+
check()
|
61
|
+
}
|
62
|
+
|
63
|
+
function stopWorkNow(tag) {
|
64
|
+
jobCount--
|
65
|
+
|
66
|
+
if (tag) {
|
67
|
+
logPositive('Finished work: %s [%d jobs]', tag, jobCount)
|
68
|
+
}
|
69
|
+
|
70
|
+
let idleCallback
|
71
|
+
while (isIdle() && (idleCallback = idleCallbacks.shift())) {
|
72
|
+
idleCallback('Finished waiting for browser')
|
43
73
|
}
|
44
74
|
}
|
45
75
|
|
@@ -48,29 +78,36 @@ window.CapybaraLockstep = (function() {
|
|
48
78
|
return
|
49
79
|
}
|
50
80
|
|
51
|
-
|
81
|
+
let oldFetch = window.fetch
|
52
82
|
window.fetch = function() {
|
53
|
-
|
54
|
-
startWorkUntil(promise)
|
83
|
+
let promise = oldFetch.apply(this, arguments)
|
84
|
+
startWorkUntil(promise, 'fetch ' + arguments[0])
|
55
85
|
return promise
|
56
86
|
}
|
57
87
|
}
|
58
88
|
|
59
89
|
function trackXHR() {
|
60
|
-
|
90
|
+
let oldOpen = XMLHttpRequest.prototype.open
|
91
|
+
let oldSend = XMLHttpRequest.prototype.send
|
92
|
+
|
93
|
+
XMLHttpRequest.prototype.open = function() {
|
94
|
+
this.capybaraLockstepURL = arguments[1]
|
95
|
+
return oldOpen.apply(this, arguments)
|
96
|
+
}
|
61
97
|
|
62
98
|
XMLHttpRequest.prototype.send = function() {
|
63
|
-
|
99
|
+
let workTag = 'XHR to '+ this.capybaraLockstepURL
|
100
|
+
startWork(workTag)
|
64
101
|
|
65
102
|
try {
|
66
103
|
this.addEventListener('readystatechange', function(event) {
|
67
|
-
if (this.readyState === 4) { stopWork() }
|
104
|
+
if (this.readyState === 4) { stopWork(workTag) }
|
68
105
|
}.bind(this))
|
69
106
|
return oldSend.apply(this, arguments)
|
70
107
|
} catch (e) {
|
71
108
|
// If we get a sync exception during request dispatch
|
72
109
|
// we assume the request never went out.
|
73
|
-
stopWork()
|
110
|
+
stopWork(workTag)
|
74
111
|
throw e
|
75
112
|
}
|
76
113
|
}
|
@@ -89,26 +126,17 @@ window.CapybaraLockstep = (function() {
|
|
89
126
|
})
|
90
127
|
}
|
91
128
|
|
92
|
-
function onInteraction() {
|
93
|
-
|
94
|
-
//
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
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)
|
129
|
+
function onInteraction(event) {
|
130
|
+
startWork()
|
131
|
+
// (1) We wait until the end of this microtask, assuming that any callback that
|
132
|
+
// would queue an AJAX request or load additional scripts will run by then.
|
133
|
+
// (2) For performance reasons we don't wait for `waitTasks` here.
|
134
|
+
// Whatever was queued by an event handler should call us again, and then
|
135
|
+
// we do wait for additional tasks.
|
136
|
+
Promise.resolve().then(stopWorkNow)
|
109
137
|
}
|
110
138
|
|
111
|
-
function
|
139
|
+
function trackRemoteElements() {
|
112
140
|
if (!window.MutationObserver) {
|
113
141
|
return
|
114
142
|
}
|
@@ -116,40 +144,43 @@ window.CapybaraLockstep = (function() {
|
|
116
144
|
// Dynamic imports or analytics snippets may insert a <script src>
|
117
145
|
// tag that loads and executes additional JavaScript. We want to be isBusy()
|
118
146
|
// until such scripts have loaded or errored.
|
119
|
-
|
147
|
+
let observer = new MutationObserver(onAnyElementChanged)
|
120
148
|
observer.observe(document, { subtree: true, childList: true })
|
121
149
|
}
|
122
150
|
|
123
151
|
function trackJQuery() {
|
124
152
|
// jQuery may be loaded after us, so we wait until DOMContentReady.
|
125
153
|
whenReady(function() {
|
126
|
-
if (!window.jQuery) {
|
154
|
+
if (!window.jQuery || waitTasks > 0) {
|
127
155
|
return
|
128
156
|
}
|
129
157
|
|
130
158
|
// Although $.ajax() uses XHR internally, it also uses $.Deferred() which does
|
131
159
|
// not resolve in the next microtask but in the next *task* (it makes itself
|
132
160
|
// async using setTimoeut()). Hence we need to wait for it in addition to XHR.
|
133
|
-
|
134
|
-
|
135
|
-
|
161
|
+
//
|
162
|
+
// If user code also uses $.Deferred(), it is also recommended to set
|
163
|
+
// CapybaraLockdown.waitTasks = 1 or higher.
|
164
|
+
let oldAjax = window.jQuery.ajax
|
165
|
+
window.jQuery.ajax = function() {
|
166
|
+
let promise = oldAjax.apply(this, arguments)
|
136
167
|
startWorkUntil(promise)
|
137
168
|
return promise
|
138
169
|
}
|
139
170
|
})
|
140
171
|
}
|
141
172
|
|
142
|
-
|
173
|
+
let INITIALIZING_ATTRIBUTE = 'data-initializing'
|
143
174
|
|
144
175
|
function trackHydration() {
|
145
176
|
// Until we have a body on which we can observe [data-initializing]
|
146
177
|
// we consider ourselves busy.
|
147
178
|
startWork()
|
148
179
|
whenReady(function() {
|
149
|
-
|
180
|
+
stopWorkNow()
|
150
181
|
if (document.body.hasAttribute(INITIALIZING_ATTRIBUTE)) {
|
151
|
-
startWork()
|
152
|
-
|
182
|
+
startWork('Page initialization')
|
183
|
+
let observer = new MutationObserver(onInitializingAttributeChanged)
|
153
184
|
observer.observe(document.body, { attributes: true, attributeFilter: [INITIALIZING_ATTRIBUTE] })
|
154
185
|
}
|
155
186
|
})
|
@@ -157,36 +188,101 @@ window.CapybaraLockstep = (function() {
|
|
157
188
|
|
158
189
|
function onInitializingAttributeChanged() {
|
159
190
|
if (!document.body.hasAttribute(INITIALIZING_ATTRIBUTE)) {
|
160
|
-
stopWork()
|
191
|
+
stopWork('Page initialization')
|
161
192
|
}
|
162
193
|
}
|
163
194
|
|
164
|
-
function isRemoteScript(
|
165
|
-
if (
|
166
|
-
|
167
|
-
|
195
|
+
function isRemoteScript(element) {
|
196
|
+
if (element.tagName === 'SCRIPT') {
|
197
|
+
let src = element.getAttribute('src')
|
198
|
+
let type = element.getAttribute('type')
|
168
199
|
|
169
|
-
return
|
200
|
+
return src && (!type || /javascript/i.test(type))
|
170
201
|
}
|
171
202
|
}
|
172
203
|
|
173
|
-
function
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
204
|
+
function isRemoteImage(element) {
|
205
|
+
if (element.tagName === 'IMG' && !element.complete) {
|
206
|
+
let src = element.getAttribute('src')
|
207
|
+
let srcSet = element.getAttribute('srcset')
|
208
|
+
|
209
|
+
let localSrcPattern = /^data:/
|
210
|
+
let localSrcSetPattern = /(^|\s)data:/
|
211
|
+
|
212
|
+
let hasLocalSrc = src && localSrcPattern.test(src)
|
213
|
+
let hasLocalSrcSet = srcSet && localSrcSetPattern.test(srcSet)
|
214
|
+
|
215
|
+
return (src && !hasLocalSrc) || (srcSet && !hasLocalSrcSet)
|
216
|
+
}
|
217
|
+
}
|
218
|
+
|
219
|
+
function isRemoteInlineFrame(element) {
|
220
|
+
if (element.tagName === 'IFRAME') {
|
221
|
+
let src = element.getAttribute('src')
|
222
|
+
let localSrcPattern = /^data:/
|
223
|
+
let hasLocalSrc = src && localSrcPattern.test(src)
|
224
|
+
return (src && !hasLocalSrc)
|
225
|
+
}
|
226
|
+
}
|
227
|
+
|
228
|
+
function trackRemoteElement(element, condition, workTag) {
|
229
|
+
if (!condition(element)) {
|
230
|
+
return
|
231
|
+
}
|
232
|
+
|
233
|
+
let stopped = false
|
234
|
+
|
235
|
+
startWork(workTag)
|
236
|
+
|
237
|
+
let doStop = function() {
|
238
|
+
stopped = true
|
239
|
+
element.removeEventListener('load', doStop)
|
240
|
+
element.removeEventListener('error', doStop)
|
241
|
+
stopWork(workTag)
|
242
|
+
}
|
243
|
+
|
244
|
+
let checkCondition = function() {
|
245
|
+
if (stopped) {
|
246
|
+
// A `load` or `error` event has fired.
|
247
|
+
// We can stop here. No need to schedule another check.
|
248
|
+
return
|
249
|
+
} else if (isDetached(element) || !condition(element)) {
|
250
|
+
// If it is detached or if its `[src]` attribute changes to a data: URL
|
251
|
+
// we may never get a `load` or `error` event.
|
252
|
+
doStop()
|
253
|
+
} else {
|
254
|
+
scheduleCheckCondition()
|
255
|
+
}
|
256
|
+
}
|
257
|
+
|
258
|
+
let scheduleCheckCondition = function() {
|
259
|
+
setTimeout(checkCondition, 200)
|
260
|
+
}
|
261
|
+
|
262
|
+
element.addEventListener('load', doStop)
|
263
|
+
element.addEventListener('error', doStop)
|
264
|
+
|
265
|
+
// We periodically check whether we still think the element will
|
266
|
+
// produce a `load` or `error` event.
|
267
|
+
scheduleCheckCondition()
|
178
268
|
}
|
179
269
|
|
180
270
|
function onAnyElementChanged(changes) {
|
181
271
|
changes.forEach(function(change) {
|
182
272
|
change.addedNodes.forEach(function(addedNode) {
|
183
|
-
if (
|
184
|
-
|
273
|
+
if (addedNode.nodeType === Node.ELEMENT_NODE) {
|
274
|
+
trackRemoteElement(addedNode, isRemoteScript, 'Script')
|
275
|
+
trackRemoteElement(addedNode, isRemoteImage, 'Image')
|
276
|
+
trackRemoteElement(addedNode, isRemoteInlineFrame, 'Inline frame')
|
185
277
|
}
|
186
278
|
})
|
187
279
|
})
|
188
280
|
}
|
189
281
|
|
282
|
+
function isDetached(element) {
|
283
|
+
return !document.contains(element)
|
284
|
+
}
|
285
|
+
|
190
286
|
function whenReady(callback) {
|
191
287
|
// Values are "loading", "interactive" and "completed".
|
192
288
|
// https://developer.mozilla.org/en-US/docs/Web/API/Document/readyState
|
@@ -201,8 +297,7 @@ window.CapybaraLockstep = (function() {
|
|
201
297
|
trackFetch()
|
202
298
|
trackXHR()
|
203
299
|
trackInteraction()
|
204
|
-
|
205
|
-
trackDynamicScripts()
|
300
|
+
trackRemoteElements()
|
206
301
|
trackJQuery()
|
207
302
|
trackHydration()
|
208
303
|
}
|
@@ -220,8 +315,8 @@ window.CapybaraLockstep = (function() {
|
|
220
315
|
startWork: startWork,
|
221
316
|
stopWork: stopWork,
|
222
317
|
synchronize: synchronize,
|
223
|
-
|
224
|
-
|
318
|
+
set debug(value) { debug = value },
|
319
|
+
set waitTasks(value) { waitTasks = value }
|
225
320
|
}
|
226
321
|
})()
|
227
322
|
|
@@ -17,7 +17,22 @@ module Capybara
|
|
17
17
|
tag_options[:nonce] = options.fetch(:nonce, true)
|
18
18
|
end
|
19
19
|
|
20
|
-
|
20
|
+
js = capybara_lockstep_js + capybara_lockstep_config_js(options)
|
21
|
+
javascript_tag(js, tag_options)
|
22
|
+
end
|
23
|
+
|
24
|
+
def capybara_lockstep_config_js(options = {})
|
25
|
+
js = ''
|
26
|
+
|
27
|
+
if (debug = options.fetch(:debug, Lockstep.debug?))
|
28
|
+
js += "\nCapybaraLockstep.debug = #{debug.to_json}"
|
29
|
+
end
|
30
|
+
|
31
|
+
if (wait_tasks = options.fetch(:wait_tasks, Lockstep.wait_tasks))
|
32
|
+
js += "\nCapybaraLockstep.waitTasks = #{wait_tasks.to_json}"
|
33
|
+
end
|
34
|
+
|
35
|
+
js
|
21
36
|
end
|
22
37
|
|
23
38
|
end
|
@@ -1,10 +1,16 @@
|
|
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
9
|
include Configuration
|
5
10
|
include Logging
|
6
11
|
|
7
|
-
attr_accessor :
|
12
|
+
attr_accessor :synchronizing
|
13
|
+
alias synchronizing? synchronizing
|
8
14
|
|
9
15
|
def synchronized?
|
10
16
|
value = page.instance_variable_get(:@lockstep_synchronized)
|
@@ -17,18 +23,24 @@ module Capybara
|
|
17
23
|
page.instance_variable_set(:@lockstep_synchronized, value)
|
18
24
|
end
|
19
25
|
|
20
|
-
ERROR_SNIPPET_MISSING = 'Cannot synchronize: Capybara::Lockstep JavaScript snippet is missing on page'
|
21
|
-
ERROR_PAGE_MISSING = 'Cannot synchronize before initial Capybara visit'
|
22
|
-
|
23
26
|
def synchronize(lazy: false)
|
24
|
-
if (lazy && synchronized?) ||
|
27
|
+
if (lazy && synchronized?) || synchronizing? || disabled?
|
25
28
|
return
|
26
29
|
end
|
27
30
|
|
28
|
-
|
31
|
+
synchronize_now
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def synchronize_now
|
37
|
+
self.synchronizing = true
|
38
|
+
self.synchronized = false
|
29
39
|
|
30
40
|
log 'Synchronizing'
|
31
41
|
|
42
|
+
start_time = current_seconds
|
43
|
+
|
32
44
|
begin
|
33
45
|
with_max_wait_time(timeout) do
|
34
46
|
message_from_js = evaluate_async_script(<<~JS)
|
@@ -53,26 +65,46 @@ module Capybara
|
|
53
65
|
case message_from_js
|
54
66
|
when ERROR_PAGE_MISSING
|
55
67
|
log(message_from_js)
|
56
|
-
self.synchronized = false
|
57
68
|
when ERROR_SNIPPET_MISSING
|
58
69
|
log(message_from_js)
|
59
|
-
self.synchronized = false
|
60
70
|
else
|
61
71
|
log message_from_js
|
62
|
-
|
72
|
+
end_time = current_seconds
|
73
|
+
ms_elapsed = ((end_time.to_f - start_time) * 1000).round
|
74
|
+
log "Synchronized successfully [#{ms_elapsed} ms]"
|
63
75
|
self.synchronized = true
|
64
76
|
end
|
65
77
|
end
|
78
|
+
rescue ::Selenium::WebDriver::Error::ScriptTimeoutError
|
79
|
+
log "Could not synchronize within #{timeout} seconds"
|
80
|
+
# Don't raise an error, this may happen if the server is slow to respond.
|
81
|
+
# We will retry on the next Capybara synchronize call.
|
82
|
+
rescue ::Selenium::WebDriver::Error::UnexpectedAlertOpenError
|
83
|
+
log ERROR_ALERT_OPEN
|
84
|
+
# Don't raise an error, this will happen in an innocent test.
|
85
|
+
# We will retry on the next Capybara synchronize call.
|
86
|
+
rescue ::Selenium::WebDriver::Error::JavascriptError => e
|
87
|
+
# When the URL changes while a script is running, my current selenium-webdriver
|
88
|
+
# raises a Selenium::WebDriver::Error::JavascriptError with the message:
|
89
|
+
# "javascript error: document unloaded while waiting for result".
|
90
|
+
# We will retry on the next Capybara synchronize call, by then we should see
|
91
|
+
# the new page.
|
92
|
+
if e.message.include?('unload')
|
93
|
+
log ERROR_NAVIGATED_AWAY
|
94
|
+
else
|
95
|
+
unhandled_synchronize_error(e)
|
96
|
+
end
|
66
97
|
rescue StandardError => e
|
67
|
-
|
68
|
-
@synchronized = false
|
69
|
-
raise e
|
98
|
+
unhandled_synchronize_error(e)
|
70
99
|
ensure
|
71
|
-
|
100
|
+
self.synchronizing = false
|
72
101
|
end
|
73
102
|
end
|
74
103
|
|
75
|
-
|
104
|
+
def unhandled_synchronize_error(e)
|
105
|
+
log "#{e.class.name} while synchronizing: #{e.message}"
|
106
|
+
raise e
|
107
|
+
end
|
76
108
|
|
77
109
|
def page
|
78
110
|
Capybara.current_session
|
@@ -96,6 +128,10 @@ module Capybara
|
|
96
128
|
# no-op
|
97
129
|
end
|
98
130
|
|
131
|
+
def current_seconds
|
132
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
133
|
+
end
|
134
|
+
|
99
135
|
end
|
100
136
|
|
101
137
|
end
|
@@ -3,8 +3,8 @@ module Capybara
|
|
3
3
|
module Logging
|
4
4
|
def log(message)
|
5
5
|
if debug? && message.present?
|
6
|
-
message = "[
|
7
|
-
if
|
6
|
+
message = "[capybara-lockstep] #{message}"
|
7
|
+
if is_logger?(@debug)
|
8
8
|
# If someone set Capybara::Lockstep to a logger, use that
|
9
9
|
@debug.debug(message)
|
10
10
|
else
|
@@ -13,6 +13,12 @@ module Capybara
|
|
13
13
|
end
|
14
14
|
end
|
15
15
|
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def is_logger?(object)
|
20
|
+
@debug.respond_to?(:debug)
|
21
|
+
end
|
16
22
|
end
|
17
23
|
end
|
18
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.6.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-10 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: capybara
|