capybara-lockstep 0.3.1 → 0.6.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: c4c87e5cabd504735dfc07b20021d109d65b74aed53ec173f390c07a2dc6086a
4
- data.tar.gz: 2c8f7f1520282284f3b6eb93ddfa0d6109628ee36721c3ba90eba9f05fe565eb
3
+ metadata.gz: ef423eecbf9d793e932d66b056feee733835efa0387ec6740fecb2975585f95e
4
+ data.tar.gz: 55e8872d7b892567797ded6fb67ed17500bc3daf29e11fbf4fee45588cdab9f2
5
5
  SHA512:
6
- metadata.gz: 0742d9204b35230452220c6cebd3020999f1531530c201180c31ccff3add24973764a232333de5585e3d1180b220535542e1e2c8a04b55b51c5bd388d6578f17
7
- data.tar.gz: 93915d9472fe5af20a864de61dc765a0eea708a80cf69bed9d0ccde54c0310e0d0c7767b9bc4a39af1fd4d8270e2a8cd3eea0982e6a0a8a512d09a829ac1f291
6
+ metadata.gz: cd14f06c4b820e5e0dda482438d282e7a856c790ef0952f44c28a6f18dd9fb863323f782b8e21d9846ec9660fcf804ea257e5c05640482e777408e71ab820ccd
7
+ data.tar.gz: dc64900722119cc945b6b22113b63d13706d0cfb2ae39e0b8411c62aad0cbfc716ff84433189521f685219ee85f48554852df1204ce0fad1fed4e7f19b1d3cf0
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.1)
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 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
@@ -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
- [Capybara::Lockstep] Synchronizing
177
- [Capybara::Lockstep] Finished waiting for JavaScript
178
- [Capybara::Lockstep] Synchronized successfully
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 negative performance impact between 0% and 10%.
208
+ In casual testing I experienced a performance impact between +/- 10%.
191
209
 
192
210
 
193
211
  ## Debugging log
194
212
 
195
- 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:
196
216
 
197
217
  ```ruby
198
218
  Capybara::Lockstep.debug = true
199
219
  ```
200
220
 
201
- You should now see messages like this during your test runs:
221
+ You should now see messages like this on your standard output:
202
222
 
203
223
  ```
204
- [Capybara::Lockstep] Synchronizing
205
- [Capybara::Lockstep] Finished waiting for JavaScript
206
- [Capybara::Lockstep] Synchronized successfully
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
- 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:
219
259
 
220
260
  ```ruby
221
261
  begin
@@ -226,9 +266,11 @@ ensure
226
266
  end
227
267
  ```
228
268
 
229
- ## Timeout
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
- By default capybara-lockstep will wait up to 10 seconds for the page initialize and for JavaScript and AJAX request to finish.
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
- ## Ruby API
281
+ To revert to defaulting to `Capybara.default_max_wait_time`, set the timeout to `nil`:
240
282
 
241
- 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
+ ```
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
- ### 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**.
247
291
 
248
- 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:
249
293
 
250
294
  ```ruby
251
295
  Capybara::Lockstep.synchronize
252
296
  ```
253
297
 
254
- 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:
255
299
 
256
- ```gherkin
257
- When 'I wait for the page to load' do
258
- Capybara::Lockstep.synchronize
259
- 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]
260
320
  ```
261
321
 
262
- ## JavaScript API
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
- For additional edge cases you may interact with capybara-lockstep from your own JavaScripts.
325
+ ## Note on interacting with the JavaScript API
267
326
 
268
- 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.
269
328
 
270
329
  ```js
271
330
  if (window.CapybaraLockstep) {
272
- CapybaraLockstep.startWork()
331
+ // interact with CapybaraLockstep
273
332
  }
274
333
  ```
275
334
 
276
- ### Signaling asynchronous work
335
+ ## Handling legacy promises
277
336
 
278
- 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:
279
340
 
280
341
  ```js
281
- CapybaraLockstep.startWork()
282
- doAsynchronousWork().then(function() {
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
- ### Checking if the browser is busy
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
- CapybaraLockstep.isBusy() // => false
293
- CapybaraLockstep.isIdle() // => true
350
+ Capybara:Lockstep.wait_tasks = 1
294
351
  ```
295
352
 
296
- ### 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.
297
354
 
298
- 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.
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
- Capybara::Lockstep.synchronize
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
- # puts "After visit: unsynchronizing"
21
- Capybara::Lockstep.synchronized = false
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
- Capybara::Lockstep.synchronized = false
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 || 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 browser')
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,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?) || @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
 
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
- log "Synchronized successfully"
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
- log "#{e.class.name} while synchronizing: #{e.message}"
68
- @synchronized = false
69
- raise e
98
+ unhandled_synchronize_error(e)
70
99
  ensure
71
- @synchronizing = false
100
+ self.synchronizing = false
72
101
  end
73
102
  end
74
103
 
75
- private
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 = "[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.1"
3
+ VERSION = "0.6.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.1
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-04 00:00:00.000000000 Z
11
+ date: 2021-03-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: capybara