capybara-lockstep 0.3.0 → 0.5.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 +4 -4
- data/README.md +114 -54
- 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 +42 -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: 29e90825f3d0526be3d7780f7a291eddb9094b271f53ea5dcd4331a4123cda01
|
4
|
+
data.tar.gz: 1985baa9704e28b4a0b8a38c53af53b133adc68db14b5ec1fc613cd2adc0f21c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 761be246131d0fce67dd2fb4d3ac8c7bddb0775e477ebe02b7c36139dc7e6152c0dea579cd02343b2e7f5ba3dd85ce2127fc21b323e1216584e3ebd16867a145
|
7
|
+
data.tar.gz: c0c4ad6b8bb83bf6493276bc2388446a3f6ff07b6ae8c924189851e77e9f767a551ce85ba9f04c6ebe285c0904fe3eb44ed1761f704e0ebaee1a7a638c49e26e
|
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.5.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)
|
@@ -31,10 +32,8 @@ GEM
|
|
31
32
|
i18n (1.8.9)
|
32
33
|
concurrent-ruby (~> 1.0)
|
33
34
|
mini_mime (1.0.2)
|
34
|
-
mini_portile2 (2.5.0)
|
35
35
|
minitest (5.14.4)
|
36
|
-
nokogiri (1.11.1)
|
37
|
-
mini_portile2 (~> 2.5.0)
|
36
|
+
nokogiri (1.11.1-x86_64-linux)
|
38
37
|
racc (~> 1.4)
|
39
38
|
public_suffix (4.0.6)
|
40
39
|
racc (1.5.2)
|
@@ -70,6 +69,7 @@ PLATFORMS
|
|
70
69
|
ruby
|
71
70
|
|
72
71
|
DEPENDENCIES
|
72
|
+
byebug
|
73
73
|
capybara-lockstep!
|
74
74
|
rake (~> 13.0)
|
75
75
|
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
|
@@ -170,9 +188,13 @@ app.directive('body', function() {
|
|
170
188
|
|
171
189
|
capybara-lockstep will automatically patch Capybara to wait for the browser after every command.
|
172
190
|
|
173
|
-
Run your test suite to see if integration was successful and whether stability improves.
|
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
|
194
|
+
[capybara-lockstep] Synchronizing
|
195
|
+
[capybara-lockstep] Finished waiting for JavaScript
|
196
|
+
[capybara-lockstep] Synchronized successfully
|
197
|
+
```
|
176
198
|
|
177
199
|
Note that you may see some failures from tests with wrong assertions, which sometimes passed due to lucky timing.
|
178
200
|
|
@@ -183,33 +205,57 @@ capybara-lockstep may or may not impact the runtime of your test suite. It depen
|
|
183
205
|
|
184
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.
|
185
207
|
|
186
|
-
In casual testing I experienced a
|
208
|
+
In casual testing I experienced a performance impact between +/- 10%.
|
187
209
|
|
188
210
|
|
189
211
|
## Debugging log
|
190
212
|
|
191
|
-
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:
|
192
216
|
|
193
217
|
```ruby
|
194
218
|
Capybara::Lockstep.debug = true
|
195
219
|
```
|
196
220
|
|
197
|
-
You should now see messages like this
|
221
|
+
You should now see messages like this on your standard output:
|
198
222
|
|
199
223
|
```
|
200
|
-
[
|
224
|
+
[capybara-lockstep] Synchronizing
|
225
|
+
[capybara-lockstep] Finished waiting for JavaScript
|
226
|
+
[capybara-lockstep] Synchronized successfully
|
201
227
|
```
|
202
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
|
+
|
203
239
|
You may also configure logging to an existing logger object:
|
204
240
|
|
205
241
|
```ruby
|
206
242
|
Capybara::Lockstep.debug = Rails.logger
|
207
243
|
```
|
208
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
|
+
|
209
253
|
|
210
254
|
## Disabling synchronization
|
211
255
|
|
212
|
-
|
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:
|
213
259
|
|
214
260
|
```ruby
|
215
261
|
begin
|
@@ -220,9 +266,11 @@ ensure
|
|
220
266
|
end
|
221
267
|
```
|
222
268
|
|
223
|
-
##
|
269
|
+
## Synchronization timeout
|
224
270
|
|
225
|
-
By default capybara-lockstep will wait
|
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.
|
272
|
+
|
273
|
+
When synchronization times out, capybara-lockstep will log but not raise an error.
|
226
274
|
|
227
275
|
You can configure a different timeout:
|
228
276
|
|
@@ -230,70 +278,82 @@ You can configure a different timeout:
|
|
230
278
|
Capybara::Lockstep.timeout = 5 # seconds
|
231
279
|
```
|
232
280
|
|
233
|
-
|
281
|
+
To revert to defaulting to `Capybara.default_max_wait_time`, set the timeout to `nil`:
|
234
282
|
|
235
|
-
|
283
|
+
```ruby
|
284
|
+
Capybara::Lockstep.timeout = nil
|
285
|
+
```
|
236
286
|
|
237
|
-
For additional edge cases you may interact with capybara-lockstep from your Ruby code.
|
238
287
|
|
288
|
+
## Manual synchronization
|
239
289
|
|
240
|
-
|
290
|
+
capybara-lockstep will automatically patch Capybara to wait for the browser after every command. **This should be enough for most test suites**.
|
241
291
|
|
242
|
-
|
292
|
+
For additional edge cases you may manually tell capybara-lockstep to wait. The following Ruby method will block until the browser is idle:
|
243
293
|
|
244
294
|
```ruby
|
245
295
|
Capybara::Lockstep.synchronize
|
246
296
|
```
|
247
297
|
|
248
|
-
|
298
|
+
You may also synchronize from your client-side JavaScript. The following will run the given callback once the browser is idle:
|
249
299
|
|
250
|
-
```
|
251
|
-
|
252
|
-
|
253
|
-
|
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]
|
254
320
|
```
|
255
321
|
|
256
|
-
|
322
|
+
You may omit the string argument, in which case nothing will be logged, but the work will still be tracked.
|
257
323
|
|
258
|
-
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**.
|
259
324
|
|
260
|
-
|
325
|
+
## Note on interacting with the JavaScript API
|
261
326
|
|
262
|
-
|
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.
|
263
328
|
|
264
329
|
```js
|
265
330
|
if (window.CapybaraLockstep) {
|
266
|
-
CapybaraLockstep
|
331
|
+
// interact with CapybaraLockstep
|
267
332
|
}
|
268
333
|
```
|
269
334
|
|
270
|
-
|
335
|
+
## Handling legacy promises
|
271
336
|
|
272
|
-
|
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:
|
273
340
|
|
274
341
|
```js
|
275
|
-
|
276
|
-
|
277
|
-
CapybaraLockstep.stopWork()
|
342
|
+
$.ajax('/foo').then(function() {
|
343
|
+
// This callback runs one task after the response was received
|
278
344
|
})
|
279
345
|
```
|
280
346
|
|
281
|
-
|
282
|
-
|
283
|
-
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:
|
284
348
|
|
285
349
|
```js
|
286
|
-
|
287
|
-
CapybaraLockstep.isIdle() // => true
|
350
|
+
Capybara:Lockstep.wait_tasks = 1
|
288
351
|
```
|
289
352
|
|
290
|
-
|
353
|
+
If you see longer `then()` chains in your code, you may need to configure a higher number of tasks to wait.
|
291
354
|
|
292
|
-
This will
|
355
|
+
This will have a negative performance impact on your test suite.
|
293
356
|
|
294
|
-
```js
|
295
|
-
CapybaraLockstep.synchronize(callback)
|
296
|
-
```
|
297
357
|
|
298
358
|
## Development
|
299
359
|
|
@@ -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 JavaScript')
|
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,15 +23,19 @@ 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
|
|
@@ -53,26 +63,44 @@ module Capybara
|
|
53
63
|
case message_from_js
|
54
64
|
when ERROR_PAGE_MISSING
|
55
65
|
log(message_from_js)
|
56
|
-
self.synchronized = false
|
57
66
|
when ERROR_SNIPPET_MISSING
|
58
67
|
log(message_from_js)
|
59
|
-
self.synchronized = false
|
60
68
|
else
|
61
69
|
log message_from_js
|
62
|
-
log "Synchronized
|
70
|
+
log "Synchronized successfully"
|
63
71
|
self.synchronized = true
|
64
72
|
end
|
65
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
|
66
93
|
rescue StandardError => e
|
67
|
-
|
68
|
-
@synchronized = false
|
69
|
-
raise e
|
94
|
+
unhandled_synchronize_error(e)
|
70
95
|
ensure
|
71
|
-
|
96
|
+
self.synchronizing = false
|
72
97
|
end
|
73
98
|
end
|
74
99
|
|
75
|
-
|
100
|
+
def unhandled_synchronize_error(e)
|
101
|
+
log "#{e.class.name} while synchronizing: #{e.message}"
|
102
|
+
raise e
|
103
|
+
end
|
76
104
|
|
77
105
|
def page
|
78
106
|
Capybara.current_session
|
@@ -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.5.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-09 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: capybara
|