capybara-lockstep 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: cccdbf8f6eb6582b123a366febeb67f863ac3801cf91f42f7a48f685e96d1860
4
+ data.tar.gz: a3683b8f821c8dd706eff573fb9c0e5aa730a29dd9205d2441a3ab5a52d8f0b8
5
+ SHA512:
6
+ metadata.gz: e9fc97b54f2b797809c85d45d87b1d34a687ae18617bbf17bdad57d060da96eb876283610e5442cebf876ba520333e501ca635bd48436870359e0b806cfeabc0
7
+ data.tar.gz: 157ae6e39f58f9437f0121fc3836d2bf3ac0c767b3dff2ff59f5f9b04516fdf4586df04ecc0bbd9c021615551878735edb910fdb393b005cfda59f906832b2f2
data/.gitignore ADDED
@@ -0,0 +1,11 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ # rspec failure tracking
11
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 2.7.2
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in capybara-lockstep.gemspec
6
+ gemspec
7
+
8
+ gem "rake", "~> 13.0"
9
+
10
+ gem "rspec", "~> 3.0"
data/Gemfile.lock ADDED
@@ -0,0 +1,75 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ capybara-lockstep (0.2.1)
5
+ activesupport (>= 3.2)
6
+ capybara (>= 2.0)
7
+ selenium-webdriver (>= 3)
8
+
9
+ GEM
10
+ remote: https://rubygems.org/
11
+ specs:
12
+ activesupport (5.2.4.3)
13
+ concurrent-ruby (~> 1.0, >= 1.0.2)
14
+ i18n (>= 0.7, < 2)
15
+ minitest (~> 5.1)
16
+ tzinfo (~> 1.1)
17
+ addressable (2.7.0)
18
+ public_suffix (>= 2.0.2, < 5.0)
19
+ capybara (3.35.3)
20
+ addressable
21
+ mini_mime (>= 0.1.3)
22
+ nokogiri (~> 1.8)
23
+ rack (>= 1.6.0)
24
+ rack-test (>= 0.6.3)
25
+ regexp_parser (>= 1.5, < 3.0)
26
+ xpath (~> 3.2)
27
+ childprocess (3.0.0)
28
+ concurrent-ruby (1.1.7)
29
+ diff-lcs (1.3)
30
+ i18n (1.8.2)
31
+ concurrent-ruby (~> 1.0)
32
+ mini_mime (1.0.2)
33
+ minitest (5.14.1)
34
+ nokogiri (1.11.1-x86_64-linux)
35
+ racc (~> 1.4)
36
+ public_suffix (4.0.6)
37
+ racc (1.5.2)
38
+ rack (2.2.3)
39
+ rack-test (1.1.0)
40
+ rack (>= 1.0, < 3)
41
+ rake (13.0.1)
42
+ regexp_parser (2.1.1)
43
+ rspec (3.7.0)
44
+ rspec-core (~> 3.7.0)
45
+ rspec-expectations (~> 3.7.0)
46
+ rspec-mocks (~> 3.7.0)
47
+ rspec-core (3.7.0)
48
+ rspec-support (~> 3.7.0)
49
+ rspec-expectations (3.7.0)
50
+ diff-lcs (>= 1.2.0, < 2.0)
51
+ rspec-support (~> 3.7.0)
52
+ rspec-mocks (3.7.0)
53
+ diff-lcs (>= 1.2.0, < 2.0)
54
+ rspec-support (~> 3.7.0)
55
+ rspec-support (3.7.0)
56
+ rubyzip (1.3.0)
57
+ selenium-webdriver (3.142.7)
58
+ childprocess (>= 0.5, < 4.0)
59
+ rubyzip (>= 1.2.2)
60
+ thread_safe (0.3.6)
61
+ tzinfo (1.2.7)
62
+ thread_safe (~> 0.1)
63
+ xpath (3.2.0)
64
+ nokogiri (~> 1.8)
65
+
66
+ PLATFORMS
67
+ ruby
68
+
69
+ DEPENDENCIES
70
+ capybara-lockstep!
71
+ rake (~> 13.0)
72
+ rspec (~> 3.0)
73
+
74
+ BUNDLED WITH
75
+ 2.2.12
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2021 Henning Koch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,324 @@
1
+ # capybara-lockstep
2
+
3
+ This Ruby gem synchronizes [Capybara](https://github.com/teamcapybara/capybara) commands with client-side JavaScript and AJAX requests. This greatly improves the stability of a full-stack integration test suite, even if that suite has timing issues.
4
+
5
+
6
+ Why are tests flaky?
7
+ --------------------
8
+
9
+ A naively written integration test will have [race conditions](https://makandracards.com/makandra/47336-fixing-flaky-integration-tests) between the test script and the controlled browser. How often these timing issues will fail your test depends on luck and your machine's performance. You may not see these issues for years until a colleague runs your suite on their new laptop.
10
+
11
+ Here is a typical example for a test that will fail with unlucky timing:
12
+
13
+ ```ruby
14
+ scenario 'User sends a tweet' do
15
+ visit '/'
16
+ click_link 'New tweet' # opens form in a modal dialog
17
+ fill_in 'text', with: 'My first tweet'
18
+ click_button 'Send tweet'
19
+ visit '/timeline'
20
+ expect(page).to have_css('.tweet', text: 'My first tweet')
21
+ end
22
+ ```
23
+
24
+ This test has four timing issues that may cause it to fail:
25
+
26
+ 1. We click on the "New tweet" button, but the the JS event handler to open the tweet form wasn't registered yet.
27
+ 2. We start filling in the form, but it wasn't loaded yet.
28
+ 3. After sending the tweet we immediately navigate away, killing the form submission request that is still in flight. Hence the tweet will never appear in the next step.
29
+ 4. We look for the new tweet, but the timeline wasn't loaded yet.
30
+
31
+ Capybara will retry individual commands or expectations when they fail. However, only issues **2** and **4** can be healed by retrying.
32
+
33
+ While it is [possible](https://makandracards.com/makandra/47336-fixing-flaky-integration-tests) to remove most of the timing issues above, it requires skill and discipline. capybara-lockstep fixes issues **1**, **2**, **3** and **4** without any changes to the test code.
34
+
35
+
36
+ How capybara-lockstep helps
37
+ ---------------------------
38
+
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
+
41
+ Whenever Capybara visits a new URL:
42
+
43
+ - capybara-lockstep waits for all document resources to load.
44
+ - capybara-lockstep waits for client-side JavaScript to render or hydrate DOM elements.
45
+ - capybara-lockstep waits for any AJAX requests.
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).
52
+
53
+
54
+ Installation
55
+ ------------
56
+
57
+ ### Prerequisites
58
+
59
+ Check if your application satisfies all requirements for capybara-lockstep:
60
+
61
+ - Capybara 2 or higher.
62
+ - Your Capybara driver must use [selenium-webdriver](https://rubygems.org/gems/selenium-webdriver/). capybara-headless deactivates itself for any other driver.
63
+ - This gem was only tested with a Selenium-controlled Chrome browser. [Chrome in headless mode](https://makandracards.com/makandra/492109-running-capybara-tests-in-headless-chrome) is recommended, but not required.
64
+ - This gem was only tested with Rails, but there's no Rails dependency.
65
+
66
+
67
+ ### Installing the Ruby gem
68
+
69
+ Assuming that you're using Rails Add this line to your application's `Gemfile`:
70
+
71
+ ```ruby
72
+ group :test do
73
+ gem 'capybara-lockstep'
74
+ end
75
+ ```
76
+
77
+ And then execute:
78
+
79
+ ```bash
80
+ $ bundle install
81
+ ```
82
+
83
+ If you're not using Rails you should also `require 'capybara-lockstep'` in your `spec_helper.rb` (RSpec) or `env.rb` (Cucumber).
84
+
85
+
86
+ ### Including the JavaScript snippet
87
+
88
+ capybara-lockstep requires a JavaScript snippet to be embedded by the application under test. If that snippet is missing on a screen, capybara-lockstep will not be able to synchronize with the browser. In that case the test will continue without synchronization.
89
+
90
+ If you're using Rails you can use the `capybara_lockstep` helper to insert the snippet into your application layouts:
91
+
92
+ ```erb
93
+ <%= capybara_lockstep if Rails.env.test? %>
94
+ ```
95
+
96
+ Ideally the snippet should be included in the `<head>` before any other `<script>` tags. If that's impractical you will also see some benefit if you insert it later.
97
+
98
+ If you have a strict [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP), the `capybara_lockstep` helper will insert a CSP nonce by default. You can also pass a `:nonce` option.
99
+
100
+ If you're not using Rails you can `include Capybara::Lockstep::Helper` and access the JavaScript with `capybara_lockstep_script`.
101
+
102
+
103
+ ### Signaling the end of page initialization
104
+
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.
106
+
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()`.
108
+
109
+ To signal that JavaScript is still initializing, your application layouts should render the `<body>` element with an `[data-initializing]` attribute:
110
+
111
+ ```html
112
+ <body data-initializing>
113
+ ```
114
+
115
+ Your application JavaScript should remove the `[data-initializing]` attribute when it is done hydrating and rendering.
116
+
117
+ 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
+
119
+ If all your initializing JavaScript runs synchronously on `DOMContentLoaded`, you can remove `[data-initializing]` in an event handler:
120
+
121
+ ```js
122
+ document.addEventListener('DOMContentLoaded', function() {
123
+ // Initialize the page here
124
+ document.body.removeAttribute('data-initializing')
125
+ })
126
+ ```
127
+
128
+ If you do any asynchronous initialization work (like lazy-loading another script) you should only remove `[data-initializing]` once that is done:
129
+
130
+ ```js
131
+ document.addEventListener('DOMContentLoaded', function() {
132
+ import('huge-library').then(function({ hugeLibrary }) {
133
+ hugeLibrary.initialize()
134
+ document.body.removeAttribute('data-initializing')
135
+ })
136
+ })
137
+ ```
138
+
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]`:
140
+
141
+ ```js
142
+ document.addEventListener('DOMContentLoaded', function() {
143
+ Libary.doWorkInNextTask()
144
+ setTimeout(function() { document.body.removeAttribute('data-initializing') })
145
+ })
146
+ ```
147
+
148
+ 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
+
150
+ ```js
151
+ up.compiler('body', function(body) {
152
+ body.removeAttribute('data-initializing')
153
+ })
154
+ ```
155
+
156
+ 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
+
158
+ ```js
159
+ app.directive('body', function() {
160
+ return {
161
+ restrict: 'E',
162
+ link: function() {
163
+ document.body.removeAttribute('data-initializing')
164
+ }
165
+ }
166
+ })
167
+ ```
168
+
169
+ ### Verify successful integration
170
+
171
+ capybara-lockstep will automatically patch Capybara to wait for the browser after every command.
172
+
173
+ Run your test suite to see if integration was successful and whether stability improves.
174
+
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).
176
+
177
+ Note that you may see some failures from tests with wrong assertions, which sometimes passed due to lucky timing.
178
+
179
+
180
+ ## Performance impact
181
+
182
+ capybara-lockstep may or may not impact the runtime of your test suite. It depends on your particular tests and how many flaky tests you're seeing in the first place.
183
+
184
+ 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
+
186
+ In casual testing I experienced a negative performance impact between 0% and 10%.
187
+
188
+
189
+ ## Debugging log
190
+
191
+ capybara-lockstep can print to the console whenever it waits for the browser. To enable the log:
192
+
193
+ ```ruby
194
+ Capybara::Lockstep.debug = true
195
+ ```
196
+
197
+ You should now see messages like this during your test runs:
198
+
199
+ ```
200
+ [Capybara::Lockstep] JavaScript or AJAX requests are running
201
+ ```
202
+
203
+ You may also configure logging to an existing logger object:
204
+
205
+ ```ruby
206
+ Capybara::Lockstep.debug = Rails.logger
207
+ ```
208
+
209
+
210
+ ## Disabling synchronization
211
+
212
+ If for some reason you want to disable browser synchronization for a while, you can do it like this:
213
+
214
+ ```ruby
215
+ begin
216
+ Capybara::Lockstep.enabled = false
217
+ do_unsynchronized_work
218
+ ensure
219
+ Capybara::Lockstep.enabled = true
220
+ end
221
+ ```
222
+
223
+
224
+ ## Timeout
225
+
226
+ By default capybara-lockstep will wait up to 10 seconds for the page initialize and for JavaScript and AJAX request to finish.
227
+
228
+ You can configure a different timeout:
229
+
230
+ ```ruby
231
+ Capybara::Lockstep.timeout = 5 # seconds
232
+ ```
233
+
234
+
235
+
236
+
237
+ ## JavaScript API
238
+
239
+ 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**.
240
+
241
+ For additional edge cases you may interact with capybara-lockstep from your own JavaScripts.
242
+
243
+ Note that when you only load the JavaScript snippet in tests you need check before calling any API functions:
244
+
245
+ ```js
246
+ if (window.CapybaraLockstep) {
247
+ CapybaraLockstep.startWork()
248
+ }
249
+ ```
250
+
251
+ ### Signaling asynchronous work
252
+
253
+ If for some reason you want capybara-lockstep to consider additional asynchronous work as "busy", you can do so:
254
+
255
+ ```js
256
+ CapybaraLockstep.startWork()
257
+ doAsynchronousWork().then(function() {
258
+ CapybaraLockstep.stopWork()
259
+ })
260
+ ```
261
+
262
+ ### Checking if the browser is busy
263
+
264
+ You can query capybara-lockstep whether it considers the browser to be busy or idle:
265
+
266
+ ```js
267
+ CapybaraLockstep.isBusy() // => false
268
+ CapybaraLockstep.isIdle() // => true
269
+ ```
270
+
271
+ ### Waiting until the browser is idle
272
+
273
+ ```js
274
+ CapybaraLockstep.awaitIdle(callback)
275
+ ```
276
+
277
+ ## Ruby API
278
+
279
+ capybara-lockstep will automatically patch Capybara to wait for the browser after every command. **This should be enough for most test suites**.
280
+
281
+ For additional edge cases you may interact with capybara-lockstep from your Ruby code.
282
+
283
+
284
+ ### Waiting until the browser is idle
285
+
286
+ This will block until the document was loaded and the DOM has been hydrated:
287
+
288
+ ```ruby
289
+ Capybara::Lockstep.await_initialized
290
+ ```
291
+
292
+ This will block while the browser is busy with JavaScript and AJAX requests:
293
+
294
+ ```ruby
295
+ Capybara::Lockstep.await_idle
296
+ ```
297
+
298
+ ### Checking if the browser is busy
299
+
300
+ You can query capybara-lockstep whether it considers the browser to be busy or idle:
301
+
302
+ ```ruby
303
+ Capybara::Lockstep.idle? # => true
304
+ Capybara::Lockstep.busy? # => false
305
+ ```
306
+
307
+
308
+ ## Development
309
+
310
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
311
+
312
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
313
+
314
+ ## Contributing
315
+
316
+ Pull requests are welcome on GitHub at <https://github.com/makandra/capistrano-lockstep>.
317
+
318
+ ## License
319
+
320
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
321
+
322
+ ## Credits
323
+
324
+ Henning Koch ([@triskweline](https://twitter.com/triskweline)) from [makandra](https://makandra.com).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "capybara-lockstep"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require "irb"
15
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,33 @@
1
+ require_relative "lib/capybara-lockstep/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "capybara-lockstep"
5
+ spec.version = Capybara::Lockstep::VERSION
6
+ spec.authors = ["Henning Koch"]
7
+ spec.email = ["henning.koch@makandra.de"]
8
+
9
+ spec.summary = "Synchronize Capybara commands with client-side JavaScript and AJAX requests"
10
+ spec.homepage = "https://rubygems.org/gems/capybara-lockstep"
11
+ spec.license = "MIT"
12
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.4.0")
13
+
14
+ spec.metadata["homepage_uri"] = spec.homepage
15
+ spec.metadata["source_code_uri"] = spec.homepage
16
+
17
+ # Specify which files should be added to the gem when it is released.
18
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
19
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
20
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
21
+ end
22
+ spec.bindir = "exe"
23
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
24
+ spec.require_paths = ["lib"]
25
+
26
+ # Uncomment to register a new dependency of your gem
27
+ spec.add_dependency "capybara", ">= 2.0"
28
+ spec.add_dependency "selenium-webdriver", ">= 3"
29
+ spec.add_dependency "activesupport", ">= 3.2"
30
+
31
+ # For more information and examples about making a new gem, checkout our
32
+ # guide at: https://bundler.io/guides/creating_gem.html
33
+ end
@@ -0,0 +1,17 @@
1
+ require 'capybara'
2
+ require 'selenium-webdriver'
3
+ require 'active_support/core_ext/object/blank'
4
+ require 'active_support/core_ext/module/delegation'
5
+
6
+ module Capybara
7
+ module Lockstep
8
+ end
9
+ end
10
+
11
+ require_relative 'capybara-lockstep/version'
12
+ require_relative 'capybara-lockstep/errors'
13
+ require_relative 'capybara-lockstep/patiently'
14
+ require_relative 'capybara-lockstep/configuration'
15
+ require_relative 'capybara-lockstep/lockstep'
16
+ require_relative 'capybara-lockstep/capybara_ext'
17
+ require_relative 'capybara-lockstep/helper'
@@ -0,0 +1,71 @@
1
+ module Capybara
2
+ module Lockstep
3
+ module VisitWithWaiting
4
+ def visit(*args, **kwargs, &block)
5
+ super(*args, **kwargs, &block).tap do
6
+ # There is a step that changes drivers mid-scenario.
7
+ # It works by creating a new Capybara session and re-visits the
8
+ # URL from the previous session. If this happens before a URL is ever
9
+ # loaded, it re-visits the URL "data:", which will never "finish"
10
+ # initializing.
11
+ unless args[0].start_with?('data:')
12
+ Capybara::Lockstep.await_initialized
13
+ end
14
+ end
15
+ end
16
+ end
17
+
18
+ module AwaitIdle
19
+ def await_idle(meth)
20
+ mod = Module.new do
21
+ define_method meth do |*args, &block|
22
+ super(*args, &block).tap do
23
+ Capybara::Lockstep.await_idle
24
+ end
25
+ end
26
+ end
27
+ prepend(mod)
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ Capybara::Session.class_eval do
34
+ prepend Capybara::Lockstep::VisitWithWaiting
35
+ end
36
+
37
+ # Capybara 3 has driver-specific Node classes which sometimes
38
+ # super to Capybara::Selenium::Node, but not always.
39
+ node_classes = [
40
+ (Capybara::Selenium::ChromeNode if defined?(Capybara::Selenium::ChromeNode)),
41
+ (Capybara::Selenium::FirefoxNode if defined?(Capybara::Selenium::FirefoxNode)),
42
+ (Capybara::Selenium::SafariNode if defined?(Capybara::Selenium::SafariNode)),
43
+ (Capybara::Selenium::EdgeNode if defined?(Capybara::Selenium::EdgeNode)),
44
+ (Capybara::Selenium::IENode if defined?(Capybara::Selenium::IENode)),
45
+ ].compact
46
+
47
+ if node_classes.empty?
48
+ # Capybara 2 has no driver-specific Node implementations,
49
+ # so we patch the shared base class.
50
+ node_classes = [Capybara::Selenium::Node]
51
+ end
52
+
53
+ node_classes.each do |node_class|
54
+ node_class.class_eval do
55
+ extend Capybara::Lockstep::AwaitIdle
56
+
57
+ await_idle :set
58
+ await_idle :select_option
59
+ await_idle :unselect_option
60
+ await_idle :click
61
+ await_idle :right_click
62
+ await_idle :double_click
63
+ await_idle :send_keys
64
+ await_idle :hover
65
+ await_idle :drag_to
66
+ await_idle :drop
67
+ await_idle :scroll_by
68
+ await_idle :scroll_to
69
+ await_idle :trigger
70
+ end
71
+ end
@@ -0,0 +1,41 @@
1
+ module Capybara
2
+ module Lockstep
3
+ module Configuration
4
+
5
+ def timeout
6
+ @timeout || 10
7
+ end
8
+
9
+ def timeout=(seconds)
10
+ @timeout = seconds
11
+ end
12
+
13
+ def debug?
14
+ @debug.nil? ? false : @debug
15
+ end
16
+
17
+ def debug=(debug)
18
+ @debug = debug
19
+ end
20
+
21
+ def enabled?
22
+ if javascript_driver?
23
+ @enabled.nil? ? true : @enabled
24
+ else
25
+ false
26
+ end
27
+ end
28
+
29
+ def enabled=(enabled)
30
+ @enabled = enabled
31
+ end
32
+
33
+ private
34
+
35
+ def javascript_driver?
36
+ driver.is_a?(Capybara::Selenium::Driver)
37
+ end
38
+
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,6 @@
1
+ module Capybara
2
+ module Lockstep
3
+ class Error < StandardError; end
4
+ class Busy < Error; end
5
+ end
6
+ end
@@ -0,0 +1,205 @@
1
+ window.CapybaraLockstep = (function() {
2
+ var count = 0
3
+ var idleCallbacks = []
4
+
5
+ function isIdle() {
6
+ // Can't check for document.readyState or body.initializing here,
7
+ // since the user might navigate away from the page before it finishes
8
+ // initializing.
9
+ return count === 0
10
+ }
11
+
12
+ function isBusy() {
13
+ return !isIdle()
14
+ }
15
+
16
+ function startWork() {
17
+ count++
18
+ }
19
+
20
+ function startWorkUntil(promise) {
21
+ startWork()
22
+ promise.then(stopWork, stopWork)
23
+ }
24
+
25
+ function startWorkForTime(time) {
26
+ startWork()
27
+ setTimeout(stopWork, time)
28
+ }
29
+
30
+ function startWorkForMicrotask() {
31
+ startWork()
32
+ Promise.resolve().then(stopWork)
33
+ }
34
+
35
+ function stopWork() {
36
+ count--
37
+
38
+ if (isIdle()) {
39
+ idleCallbacks.forEach(function(callback) {
40
+ callback('JavaScript has finished')
41
+ })
42
+ idleCallbacks = []
43
+ }
44
+ }
45
+
46
+ function trackFetch() {
47
+ if (!window.fetch) {
48
+ return
49
+ }
50
+
51
+ var oldFetch = window.fetch
52
+ window.fetch = function() {
53
+ var promise = oldFetch.apply(this, arguments)
54
+ startWorkUntil(promise)
55
+ return promise
56
+ }
57
+ }
58
+
59
+ function trackXHR() {
60
+ var oldSend = XMLHttpRequest.prototype.send
61
+
62
+ XMLHttpRequest.prototype.send = function() {
63
+ startWork()
64
+
65
+ try {
66
+ this.addEventListener('readystatechange', function(event) {
67
+ if (this.readyState === 4) { stopWork() }
68
+ }.bind(this))
69
+ return oldSend.apply(this, arguments)
70
+ } catch (e) {
71
+ // If we get a sync exception during request dispatch
72
+ // we assume the request never went out.
73
+ stopWork()
74
+ throw e
75
+ }
76
+ }
77
+ }
78
+
79
+ function trackInteraction() {
80
+ // We already override all interaction methods in the Selenium browser nodes, so they
81
+ // wait for an idle frame afterwards. However a test script might also dispatch synthetic
82
+ // events with executate_script() to manipulate the browser in ways that are not possible
83
+ // with the Capybara API. When we observe such an event we wait until the end of the microtask,
84
+ // assuming any busy action will be queued by then.
85
+ ['click', 'mousedown', 'keydown', 'change', 'input', 'submit', 'focusin', 'focusout', 'scroll'].forEach(function(eventType) {
86
+ // Use { useCapture: true } so we get the event before another listener
87
+ // can prevent it from bubbling up to the document.
88
+ document.addEventListener(eventType, onInteraction, { capture: true, passive: true })
89
+ })
90
+ }
91
+
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)
109
+ }
110
+
111
+ function trackDynamicScripts() {
112
+ if (!window.MutationObserver) {
113
+ return
114
+ }
115
+
116
+ // Dynamic imports or analytics snippets may insert a <script src>
117
+ // tag that loads and executes additional JavaScript. We want to be isBusy()
118
+ // until such scripts have loaded or errored.
119
+ var observer = new MutationObserver(onMutated)
120
+ observer.observe(document, { subtree: true, childList: true })
121
+ }
122
+
123
+ function trackJQuery() {
124
+ // jQuery may be loaded after us, so we wait until DOMContentReady.
125
+ whenReady(function() {
126
+ if (!window.jQuery) {
127
+ return
128
+ }
129
+
130
+ // Although $.ajax() uses XHR internally, it also uses $.Deferred() which does
131
+ // not resolve in the next microtask but in the next *task* (it makes itself
132
+ // 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)
136
+ startWorkUntil(promise)
137
+ return promise
138
+ }
139
+ })
140
+ }
141
+
142
+ function isRemoteScript(node) {
143
+ if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'SCRIPT') {
144
+ var src = node.getAttribute('src')
145
+ var type = node.getAttribute('type')
146
+
147
+ return (src && (!type || /javascript/i.test(type)))
148
+ }
149
+ }
150
+
151
+ function onRemoteScriptAdded(script) {
152
+ startWork()
153
+ // Chrome runs a remote <script> *before* the load event fires.
154
+ script.addEventListener('load', stopWork)
155
+ script.addEventListener('error', stopWork)
156
+ }
157
+
158
+ function onMutated(changes) {
159
+ changes.forEach(function(change) {
160
+ change.addedNodes.forEach(function(addedNode) {
161
+ if (isRemoteScript(addedNode)) {
162
+ onRemoteScriptAdded(addedNode)
163
+ }
164
+ })
165
+ })
166
+ }
167
+
168
+ function whenReady(callback) {
169
+ // Values are "loading", "interactive" and "completed".
170
+ // https://developer.mozilla.org/en-US/docs/Web/API/Document/readyState
171
+ if (document.readyState != 'loading') {
172
+ callback()
173
+ } else {
174
+ document.addEventListener('DOMContentLoaded', callback)
175
+ }
176
+ }
177
+
178
+ function track() {
179
+ trackFetch()
180
+ trackXHR()
181
+ trackInteraction()
182
+ trackHistory()
183
+ trackDynamicScripts()
184
+ trackJQuery()
185
+ }
186
+
187
+ function awaitIdle(callback) {
188
+ if (isIdle()) {
189
+ callback()
190
+ } else {
191
+ idleCallbacks.push(callback)
192
+ }
193
+ }
194
+
195
+ return {
196
+ track: track,
197
+ startWork: startWork,
198
+ stopWork: stopWork,
199
+ awaitIdle: awaitIdle,
200
+ isIdle: isIdle,
201
+ isBusy: isBusy
202
+ }
203
+ })()
204
+
205
+ CapybaraLockstep.track()
@@ -0,0 +1,29 @@
1
+ module Capybara
2
+ module Lockstep
3
+ module Helper
4
+
5
+ JS_PATH = File.expand_path('../helper.js', __FILE__)
6
+ JS = IO.read(JS_PATH)
7
+
8
+ def capybara_lockstep_js
9
+ JS
10
+ end
11
+
12
+ def capybara_lockstep(options = {})
13
+ tag_options = {}
14
+
15
+ # Add a CSRF nonce if supported by our Rails version
16
+ if Rails.version >= '5'
17
+ tag_options[:nonce] = options.fetch(:nonce, true)
18
+ end
19
+
20
+ javascript_tag(capybara_lockstep_js, tag_options)
21
+ end
22
+
23
+ end
24
+ end
25
+ end
26
+
27
+ if defined?(ActionView::Base)
28
+ ActionView::Base.send :include, Capybara::Lockstep::Helper
29
+ end
@@ -0,0 +1,141 @@
1
+ module Capybara
2
+ module Lockstep
3
+ class << self
4
+ include Patiently
5
+ include Configuration
6
+
7
+ def await_idle
8
+ return unless enabled?
9
+
10
+ ignoring_alerts do
11
+ # evaluate_async_script also times out after Capybara.default_max_wait_time
12
+ with_max_wait_time(timeout) do
13
+ message_from_js = evaluate_async_script(<<~JS)
14
+ let done = arguments[0]
15
+ if (window.CapybaraLockstep) {
16
+ CapybaraLockstep.awaitIdle(done)
17
+ } else {
18
+ done('Cannot synchronize: Capybara::Lockstep was not included in page')
19
+ }
20
+ JS
21
+ log(message_from_js)
22
+ end
23
+ end
24
+ end
25
+
26
+ def await_initialized
27
+ return unless enabled?
28
+
29
+ # We're retrying the initialize check every few ms.
30
+ # Don't clutter the log with dozens of identical messages.
31
+ last_logged_reason = nil
32
+
33
+ patiently(timeout) do
34
+ if (reason = initialize_reason)
35
+ if reason != last_logged_reason
36
+ log(reason)
37
+ last_logged_reason = reason
38
+ end
39
+
40
+ # Raise an exception that will be retried by `patiently`
41
+ raise Busy, reason
42
+ end
43
+ end
44
+ end
45
+
46
+ def idle?
47
+ unless enabled?
48
+ return true
49
+ end
50
+
51
+ result = execute_script(<<~JS)
52
+ if (window.CapybaraLockstep) {
53
+ return CapybaraLockstep.isIdle()
54
+ } else {
55
+ return 'Cannot check busy state: Capybara::Lockstep was not included in page'
56
+ }
57
+ JS
58
+
59
+ if result.is_a?(String)
60
+ log(result)
61
+ # When the snippet is missing we assume that the browser is idle.
62
+ # Otherwise we would wait forever.
63
+ true
64
+ else
65
+ result
66
+ end
67
+ end
68
+
69
+ def busy?
70
+ !idle?
71
+ end
72
+
73
+ private
74
+
75
+ def initialize_reason
76
+ ignoring_alerts do
77
+ execute_script(<<~JS)
78
+ if (location.href.indexOf('data:') == 0) {
79
+ return 'Requesting initial page'
80
+ }
81
+
82
+ if (document.readyState !== "complete") {
83
+ return 'Document is loading'
84
+ }
85
+
86
+ // The application layouts render a <body data-initializing>.
87
+ // The [data-initializing] attribute is removed by an Angular directive or Unpoly compiler (frontend).
88
+ // to signal that all elements have been activated.
89
+ if (document.querySelector('body[data-initializing]')) {
90
+ return 'DOM is being hydrated'
91
+ }
92
+
93
+ if (window.CapybaraLockstep && CapybaraLockstep.isBusy()) {
94
+ return 'JavaScript or AJAX requests are running'
95
+ }
96
+
97
+ return false
98
+ JS
99
+ end
100
+ end
101
+
102
+ def page
103
+ Capybara.current_session
104
+ end
105
+
106
+ delegate :evaluate_script, :evaluate_async_script, :execute_script, :driver, to: :page
107
+
108
+ def ignoring_alerts(&block)
109
+ block.call
110
+ rescue ::Selenium::WebDriver::Error::UnexpectedAlertOpenError
111
+ # noop
112
+ end
113
+
114
+ def with_max_wait_time(seconds, &block)
115
+ old_max_wait_time = Capybara.default_max_wait_time
116
+ Capybara.default_max_wait_time = seconds
117
+ begin
118
+ block.call
119
+ ensure
120
+ Capybara.default_max_wait_time = old_max_wait_time
121
+ end
122
+ end
123
+
124
+ def log(message)
125
+ if debug? && message.present?
126
+ message = "[Capybara::Lockstep] #{message}"
127
+ if @debug.respond_to?(:debug)
128
+ # If someone set Capybara::Lockstep to a logger, use that
129
+ @debug.debug(message)
130
+ else
131
+ # Otherwise print to STDOUT
132
+ puts message
133
+ end
134
+ end
135
+ end
136
+
137
+ end
138
+
139
+ end
140
+ end
141
+
@@ -0,0 +1,58 @@
1
+ module Capybara
2
+ module Lockstep
3
+ # Ported from https://github.com/makandra/spreewald/blob/master/lib/spreewald_support/tolerance_for_selenium_sync_issues.rb
4
+ module Patiently
5
+
6
+ RETRY_ERRORS = %w[
7
+ Capybara::Lockstep::Busy
8
+ Capybara::ElementNotFound
9
+ Spec::Expectations::ExpectationNotMetError
10
+ RSpec::Expectations::ExpectationNotMetError
11
+ Minitest::Assertion
12
+ Capybara::Poltergeist::ClickFailed
13
+ Capybara::ExpectationNotMet
14
+ Selenium::WebDriver::Error::StaleElementReferenceError
15
+ Selenium::WebDriver::Error::NoAlertPresentError
16
+ Selenium::WebDriver::Error::ElementNotVisibleError
17
+ Selenium::WebDriver::Error::NoSuchFrameError
18
+ Selenium::WebDriver::Error::NoAlertPresentError
19
+ Selenium::WebDriver::Error::JavascriptError
20
+ Selenium::WebDriver::Error::UnknownError
21
+ Selenium::WebDriver::Error::NoSuchAlertError
22
+ ]
23
+
24
+ # evaluate_script latency is ~ 0.025s
25
+ WAIT_PERIOD = 0.03
26
+
27
+ def patiently(timeout = Capybara.default_max_wait_time, &block)
28
+ started = monotonic_time
29
+ tries = 0
30
+ begin
31
+ tries += 1
32
+ block.call
33
+ rescue Exception => e
34
+ raise e unless retryable_error?(e)
35
+ raise e if (monotonic_time - started > timeout && tries >= 2)
36
+ sleep(WAIT_PERIOD)
37
+ if monotonic_time == started
38
+ raise Capybara::FrozenInTime, "time appears to be frozen, Capybara does not work with libraries which freeze time, consider using time travelling instead"
39
+ end
40
+ retry
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def monotonic_time
47
+ # We use the system clock (i.e. seconds since boot) to calculate the time,
48
+ # because Time.now may be frozen
49
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
50
+ end
51
+
52
+ def retryable_error?(e)
53
+ RETRY_ERRORS.include?(e.class.name)
54
+ end
55
+
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,5 @@
1
+ module Capybara
2
+ module Lockstep
3
+ VERSION = "0.2.1"
4
+ end
5
+ end
metadata ADDED
@@ -0,0 +1,107 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: capybara-lockstep
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.1
5
+ platform: ruby
6
+ authors:
7
+ - Henning Koch
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2021-03-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: capybara
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: selenium-webdriver
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '3'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '3'
41
+ - !ruby/object:Gem::Dependency
42
+ name: activesupport
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '3.2'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '3.2'
55
+ description:
56
+ email:
57
+ - henning.koch@makandra.de
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - ".gitignore"
63
+ - ".rspec"
64
+ - ".ruby-version"
65
+ - Gemfile
66
+ - Gemfile.lock
67
+ - LICENSE.txt
68
+ - README.md
69
+ - Rakefile
70
+ - bin/console
71
+ - bin/setup
72
+ - capybara-lockstep.gemspec
73
+ - lib/capybara-lockstep.rb
74
+ - lib/capybara-lockstep/capybara_ext.rb
75
+ - lib/capybara-lockstep/configuration.rb
76
+ - lib/capybara-lockstep/errors.rb
77
+ - lib/capybara-lockstep/helper.js
78
+ - lib/capybara-lockstep/helper.rb
79
+ - lib/capybara-lockstep/lockstep.rb
80
+ - lib/capybara-lockstep/patiently.rb
81
+ - lib/capybara-lockstep/version.rb
82
+ homepage: https://rubygems.org/gems/capybara-lockstep
83
+ licenses:
84
+ - MIT
85
+ metadata:
86
+ homepage_uri: https://rubygems.org/gems/capybara-lockstep
87
+ source_code_uri: https://rubygems.org/gems/capybara-lockstep
88
+ post_install_message:
89
+ rdoc_options: []
90
+ require_paths:
91
+ - lib
92
+ required_ruby_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: 2.4.0
97
+ required_rubygems_version: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
102
+ requirements: []
103
+ rubygems_version: 3.2.6
104
+ signing_key:
105
+ specification_version: 4
106
+ summary: Synchronize Capybara commands with client-side JavaScript and AJAX requests
107
+ test_files: []