capybara-lockstep 0.3.0 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e993a9ec82edc1bbea78694f16ae5b3e171255db9eb7e14064922b153d1838d
4
- data.tar.gz: 1086e7149ca202b105fd0472c839451845079e05c777a0dba839483fe05c473b
3
+ metadata.gz: 29e90825f3d0526be3d7780f7a291eddb9094b271f53ea5dcd4331a4123cda01
4
+ data.tar.gz: 1985baa9704e28b4a0b8a38c53af53b133adc68db14b5ec1fc613cd2adc0f21c
5
5
  SHA512:
6
- metadata.gz: e54e30ae12effa97dcdbd73d0f07b465b90270efc3ddd3ad83fcde7a4d305164ac0380f7ab3128a37870e78862483659eb01ff6bcdf8fade32cd52c3024d5c31
7
- data.tar.gz: c067bf3f9e0be7d6a84668ee7412ea7d93d450f109e8018940e72392277bcc0f5d0ef92717a1692dee8e0443509bc9317a7b403620b6eb62bdb78539ac036098
6
+ metadata.gz: 761be246131d0fce67dd2fb4d3ac8c7bddb0775e477ebe02b7c36139dc7e6152c0dea579cd02343b2e7f5ba3dd85ce2127fc21b323e1216584e3ebd16867a145
7
+ data.tar.gz: c0c4ad6b8bb83bf6493276bc2388446a3f6ff07b6ae8c924189851e77e9f767a551ce85ba9f04c6ebe285c0904fe3eb44ed1761f704e0ebaee1a7a638c49e26e
data/Gemfile CHANGED
@@ -8,3 +8,5 @@ gemspec
8
8
  gem "rake", "~> 13.0"
9
9
 
10
10
  gem "rspec", "~> 3.0"
11
+
12
+ gem 'byebug'
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- capybara-lockstep (0.3.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 the document was loaded. This JavaScript enhances existing DOM elements ("hydration") or renders additional element into the DOM.
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 needs to know when your JavaScript is done hydrating and rendering, so it can automatically wait for initialization after every Capybara `visit()`.
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 hydrating and rendering.
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 do any asynchronous initialization work (like lazy-loading another script) you should only remove `[data-initializing]` once that is done:
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
- import('huge-library').then(function({ hugeLibrary }) {
133
- hugeLibrary.initialize()
134
- document.body.removeAttribute('data-initializing')
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
- If you call libraries during initialization, you may need to check the library code to see whether it finishes synchronously or asynchronously. E.g. if you discover that a library delays work for a task, you must also wait another task to remove `[data-initializing]`:
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.doWorkInNextTask()
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
- When you run into issues or don't see an effect, try activating `Capybara::Lockstep.debug = true` in your `spec_helper.rb` (RSpec) or `env.rb` (Cucumber).
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 negative performance impact between 0% and 10%.
208
+ In casual testing I experienced a performance impact between +/- 10%.
187
209
 
188
210
 
189
211
  ## Debugging log
190
212
 
191
- capybara-lockstep can print to the console whenever it waits for the browser. To enable the log:
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 during your test runs:
221
+ You should now see messages like this on your standard output:
198
222
 
199
223
  ```
200
- [Capybara::Lockstep] JavaScript or AJAX requests are running
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
- If for some reason you want to disable browser synchronization for a while, you can do it like this:
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
- ## Timeout
269
+ ## Synchronization timeout
224
270
 
225
- By default capybara-lockstep will wait up to 10 seconds for the page initialize and for JavaScript and AJAX request to finish.
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
- ## Ruby API
281
+ To revert to defaulting to `Capybara.default_max_wait_time`, set the timeout to `nil`:
234
282
 
235
- capybara-lockstep will automatically patch Capybara to wait for the browser after every command. **This should be enough for most test suites**.
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
- ### Waiting until the browser is idle
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
- This will block until the document was loaded, the DOM has been hydrated and all AJAX requests have concluded:
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
- An example use case is a Cucumber step that explicitely waits for JavaScript to finish, in the rare occasion where capybara-lockstep hasn't picked up an event or request:
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
- ```gherkin
251
- When 'I wait for the page to load' do
252
- Capybara::Lockstep.synchronize
253
- end
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
- ## JavaScript API
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
- For additional edge cases you may interact with capybara-lockstep from your own JavaScripts.
325
+ ## Note on interacting with the JavaScript API
261
326
 
262
- Note that when you only load the JavaScript snippet in tests you need check before calling any API functions:
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.startWork()
331
+ // interact with CapybaraLockstep
267
332
  }
268
333
  ```
269
334
 
270
- ### Signaling asynchronous work
335
+ ## Handling legacy promises
271
336
 
272
- If for some reason you want capybara-lockstep to consider additional asynchronous work as "busy", you can do so:
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
- CapybaraLockstep.startWork()
276
- doAsynchronousWork().then(function() {
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
- ### Checking if the browser is busy
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
- CapybaraLockstep.isBusy() // => false
287
- CapybaraLockstep.isIdle() // => true
350
+ Capybara:Lockstep.wait_tasks = 1
288
351
  ```
289
352
 
290
- ### Waiting until the browser is idle
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 run the given callback once the browser is considered to be idle:
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 || 10
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.nil? ? false : @debug
14
+ # @debug may also be a Logger object, so convert it to a boolean
15
+ @debug.nil? ? false : !!@debug
15
16
  end
16
17
 
17
- def debug=(debug)
18
- @debug = 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
- var count = 0
3
- var idleCallbacks = []
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 count === 0
11
+ return jobCount === 0
10
12
  }
11
13
 
12
14
  function isBusy() {
13
15
  return !isIdle()
14
16
  }
15
17
 
16
- function startWork() {
17
- count++
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 startWorkUntil(promise) {
21
- startWork()
22
- promise.then(stopWork, stopWork)
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 startWorkForTime(time) {
26
- startWork()
27
- setTimeout(stopWork, time)
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 startWorkForMicrotask() {
31
- startWork()
32
- Promise.resolve().then(stopWork)
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
- count--
48
+ function stopWork(tag) {
49
+ let tasksElapsed = 0
37
50
 
38
- if (isIdle()) {
39
- idleCallbacks.forEach(function(callback) {
40
- callback('Finished waiting for JavaScript')
41
- })
42
- idleCallbacks = []
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
- var oldFetch = window.fetch
81
+ let oldFetch = window.fetch
52
82
  window.fetch = function() {
53
- var promise = oldFetch.apply(this, arguments)
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
- var oldSend = XMLHttpRequest.prototype.send
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
- startWork()
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
- // We wait until the end of this microtask, assuming that any callback that
94
- // would queue an AJAX request or load additional scripts will run by then.
95
- startWorkForMicrotask()
96
- }
97
-
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)
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 trackDynamicScripts() {
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
- var observer = new MutationObserver(onAnyElementChanged)
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
- var oldAjax = jQuery.ajax
134
- jQuery.ajax = function () {
135
- var promise = oldAjax.apply(this, arguments)
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
- var INITIALIZING_ATTRIBUTE = 'data-initializing'
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
- stopWork()
180
+ stopWorkNow()
150
181
  if (document.body.hasAttribute(INITIALIZING_ATTRIBUTE)) {
151
- startWork()
152
- var observer = new MutationObserver(onInitializingAttributeChanged)
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(node) {
165
- if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'SCRIPT') {
166
- var src = node.getAttribute('src')
167
- var type = node.getAttribute('type')
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 (src && (!type || /javascript/i.test(type)))
200
+ return src && (!type || /javascript/i.test(type))
170
201
  }
171
202
  }
172
203
 
173
- function onRemoteScriptAdded(script) {
174
- startWork()
175
- // Chrome runs a remote <script> *before* the load event fires.
176
- script.addEventListener('load', stopWork)
177
- script.addEventListener('error', stopWork)
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 (isRemoteScript(addedNode)) {
184
- onRemoteScriptAdded(addedNode)
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
- trackHistory()
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
- isIdle: isIdle,
224
- isBusy: isBusy
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
- javascript_tag(capybara_lockstep_js, tag_options)
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 :synchronized
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?) || @synchronizing || disabled?
27
+ if (lazy && synchronized?) || synchronizing? || disabled?
25
28
  return
26
29
  end
27
30
 
28
- @synchronizing = true
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 sucessfully"
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
- log "#{e.class.name} while synchronizing: #{e.message}"
68
- @synchronized = false
69
- raise e
94
+ unhandled_synchronize_error(e)
70
95
  ensure
71
- @synchronizing = false
96
+ self.synchronizing = false
72
97
  end
73
98
  end
74
99
 
75
- private
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 = "[Capybara::Lockstep] #{message}"
7
- if @debug.respond_to?(:debug)
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
@@ -1,5 +1,5 @@
1
1
  module Capybara
2
2
  module Lockstep
3
- VERSION = "0.3.0"
3
+ VERSION = "0.5.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: capybara-lockstep
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.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-04 00:00:00.000000000 Z
11
+ date: 2021-03-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: capybara