capybara-lockstep 1.3.0 → 2.0.0.rc1

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: 5496cc79e785bf2ce69669e7a8a6d8ae6f862289f5151487c27a33caa20aa9f7
4
- data.tar.gz: 635cb396a2ceccbf550a44b2331b3cf1a2340f555557c3eac9440cc6231f5033
3
+ metadata.gz: f264c637e6de9b4a6ab18e1e33ebf3c81fe46a9a20173b5c4c8097f5be4d8ae6
4
+ data.tar.gz: f48886c96cbdfe898d5245a85955b76a5caee1c64662e4e12823427b7cb6c883
5
5
  SHA512:
6
- metadata.gz: e69a2f28e913cef9913825fe1b2594bda44ad31af54a1041978adcd437e8f17518d91d8b3b4e4c0dc71f4259780427288db9ba474f39607e39f60e362a143a9f
7
- data.tar.gz: 2bb6122c49734c852b3ed64486cbe00dc0c10d51d9de8311c7c11ce5aff85482b97105e2d8c4d6acccfb70efbd2f6855526c8a7d58e36e10237e49a15b746af8
6
+ metadata.gz: c959856af42047611c8c90b4b9c2de6a0a201ba9bff3f5ae9f2bc0a1e32026999283253c6d7ace66305364c57550dc20b5ee131251dcbf80cec707b47994bb30
7
+ data.tar.gz: be4f33cb2e453d1bf830a75d70a55ffdb43c243ccf0f9d0513bf578b88d0aec6fca135cb4c16f6f13096edd52d44c190794f480ba35140ec965c32e09ddafa6f
@@ -22,12 +22,12 @@ jobs:
22
22
  gemfile: Gemfile
23
23
  - ruby: 2.7.2
24
24
  gemfile: Gemfile
25
- - ruby: 3.0.1
25
+ - ruby: 3.2.0
26
26
  gemfile: Gemfile
27
27
  env:
28
28
  BUNDLE_GEMFILE: "${{ matrix.gemfile }}"
29
29
  steps:
30
- - uses: actions/checkout@v2
30
+ - uses: actions/checkout@v3
31
31
  - name: Install Chrome
32
32
  uses: browser-actions/setup-chrome@latest
33
33
  - name: Show Chrome version
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 2.7.2
1
+ 3.2.0
data/CHANGELOG.md CHANGED
@@ -3,6 +3,33 @@ All notable changes to this project will be documented in this file.
3
3
  This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
4
4
 
5
5
 
6
+ # 2.0.0-rc1
7
+
8
+ - We now synchronize before a user interaction. Previously we only synchronized before an observation. This could lead to race conditions when a test chained multiple interactions without [making an observation in between](https://makandracards.com/makandra/47336-fixing-flaky-e2e-tests#section-interleave-actions-and-expectations).
9
+ - We now synchronize after a user interaction (e.g. after a click). Previously we only synchronized before an observation. This could lead to race conditions when a test made assertions without going to Capybara, e.g. by accessing the database or global state variables.
10
+ - When a job ends (e.g. an AJAX request finishes) we now wait for one [JavaScript task](https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/). This gives event listeners more time to schedule new async work.
11
+ - We now wait one JavaScript task after `touchstart`, `mousedown`, `click` and `keydown` events. This gives event listeners more time to schedule async work after a user interaction.
12
+ - You can now wait while the backend server is busy, by using `Capybara::Lockstep::Middleware` in your Rails or Rack app. We previously only waited for AJAX requests on the client, but using the middleware addresses some additional edge cases. For example, the middleware detects requests that were aborted on the frontend, but are still being processed by the backend.
13
+ - You can signal async work from the backend, e.g. for background jobs. Note that you don't need to signal work for the regular request/response cycle, as this is detected automatically.
14
+
15
+
16
+ ## 1.3.1 - 2023-10-25
17
+
18
+ Now synchronizes before and after `evaluate_script`.
19
+
20
+ Previously we only synchronized around `execute_script` and `evaluate_async_script`.
21
+
22
+
23
+ ## 1.3.0 - 2023-01-10
24
+
25
+ You can configure a proc to run after successful synchronization:
26
+
27
+ ```ruby
28
+ Capybara::Lockstep.after_synchronize do
29
+ puts "Synchronized!"
30
+ end
31
+ ````
32
+
6
33
  ## 1.2.1 - 2022-09-12
7
34
 
8
35
  - Synchronize with pages constructed from non-empty [data URLs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URLs)
data/Gemfile CHANGED
@@ -13,6 +13,9 @@ gem 'thin' # ruby 3 does not include a webserver
13
13
  gem 'chrome_remote'
14
14
 
15
15
  gem 'byebug'
16
- gem 'gemika'
16
+ gem 'gemika', '>= 0.8.1'
17
17
 
18
18
  gem 'activesupport', '~> 6.0'
19
+
20
+ gem 'capybara', '= 3.36.0' # last version compatible with Ruby < 2.7, which is in our test matrix
21
+ gem 'selenium-webdriver', '=4.1.0' # last version compatible with Ruby < 2.7, which is in our test matrix
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- capybara-lockstep (1.3.0)
4
+ capybara-lockstep (2.0.0.rc1)
5
5
  activesupport (>= 3.2)
6
6
  capybara (>= 2.0)
7
7
  ruby2_keywords
@@ -19,7 +19,7 @@ GEM
19
19
  addressable (2.8.1)
20
20
  public_suffix (>= 2.0.2, < 6.0)
21
21
  byebug (11.1.3)
22
- capybara (3.37.1)
22
+ capybara (3.36.0)
23
23
  addressable
24
24
  matrix
25
25
  mini_mime (>= 0.1.3)
@@ -35,7 +35,7 @@ GEM
35
35
  daemons (1.3.1)
36
36
  diff-lcs (1.3)
37
37
  eventmachine (1.2.7)
38
- gemika (0.6.0)
38
+ gemika (0.8.1)
39
39
  i18n (1.12.0)
40
40
  concurrent-ruby (~> 1.0)
41
41
  jasmine (3.6.0)
@@ -46,8 +46,10 @@ GEM
46
46
  jasmine-core (3.6.0)
47
47
  matrix (0.4.2)
48
48
  mini_mime (1.1.2)
49
+ mini_portile2 (2.8.1)
49
50
  minitest (5.16.3)
50
- nokogiri (1.13.8-x86_64-linux)
51
+ nokogiri (1.13.8)
52
+ mini_portile2 (~> 2.8.0)
51
53
  racc (~> 1.4)
52
54
  phantomjs (2.1.1.0)
53
55
  public_suffix (5.0.0)
@@ -73,18 +75,16 @@ GEM
73
75
  rspec-support (3.7.0)
74
76
  ruby2_keywords (0.0.5)
75
77
  rubyzip (2.3.2)
76
- selenium-webdriver (4.4.0)
78
+ selenium-webdriver (4.1.0)
77
79
  childprocess (>= 0.5, < 5.0)
78
80
  rexml (~> 3.2, >= 3.2.5)
79
- rubyzip (>= 1.2.2, < 3.0)
80
- websocket (~> 1.0)
81
+ rubyzip (>= 1.2.2)
81
82
  thin (1.8.0)
82
83
  daemons (~> 1.0, >= 1.0.9)
83
84
  eventmachine (~> 1.0, >= 1.0.4)
84
85
  rack (>= 1, < 3)
85
86
  tzinfo (2.0.5)
86
87
  concurrent-ruby (~> 1.0)
87
- websocket (1.2.9)
88
88
  websocket-driver (0.7.3)
89
89
  websocket-extensions (>= 0.1.0)
90
90
  websocket-extensions (0.1.5)
@@ -98,12 +98,14 @@ PLATFORMS
98
98
  DEPENDENCIES
99
99
  activesupport (~> 6.0)
100
100
  byebug
101
+ capybara (= 3.36.0)
101
102
  capybara-lockstep!
102
103
  chrome_remote
103
- gemika
104
+ gemika (>= 0.8.1)
104
105
  jasmine
105
106
  rake (~> 13.0)
106
107
  rspec (~> 3.0)
108
+ selenium-webdriver (= 4.1.0)
107
109
  thin
108
110
 
109
111
  BUNDLED WITH
@@ -2,13 +2,27 @@ require 'ruby2_keywords'
2
2
 
3
3
  module Capybara
4
4
  module Lockstep
5
- module UnsychronizeAfter
6
- def unsychronize_after(meth)
5
+ module SynchronizeMacros
6
+
7
+ def synchronize_before(meth, lazy:)
8
+ mod = Module.new do
9
+ define_method meth do |*args, &block|
10
+ Lockstep.auto_synchronize(lazy: lazy, log: "Synchronizing before ##{meth}")
11
+ super(*args, &block)
12
+ end
13
+
14
+ ruby2_keywords meth
15
+ end
16
+
17
+ prepend(mod)
18
+ end
19
+
20
+ def synchronize_after(meth)
7
21
  mod = Module.new do
8
22
  define_method meth do |*args, &block|
9
23
  super(*args, &block)
10
24
  ensure
11
- Lockstep.synchronized = false
25
+ Lockstep.auto_synchronize
12
26
  end
13
27
 
14
28
  ruby2_keywords meth
@@ -16,18 +30,13 @@ module Capybara
16
30
 
17
31
  prepend(mod)
18
32
  end
19
- end
20
- end
21
- end
22
33
 
23
- module Capybara
24
- module Lockstep
25
- module SynchronizeBefore
26
- def synchronize_before(meth, lazy:)
34
+ def unsynchronize_after(meth)
27
35
  mod = Module.new do
28
36
  define_method meth do |*args, &block|
29
- Lockstep.auto_synchronize(lazy: lazy, log: "Synchronizing before ##{meth}")
30
37
  super(*args, &block)
38
+ ensure
39
+ Lockstep.unsynchronize
31
40
  end
32
41
 
33
42
  ruby2_keywords meth
@@ -35,32 +44,32 @@ module Capybara
35
44
 
36
45
  prepend(mod)
37
46
  end
47
+
38
48
  end
39
49
  end
40
50
  end
41
51
 
42
52
  Capybara::Session.class_eval do
43
- extend Capybara::Lockstep::SynchronizeBefore
44
- extend Capybara::Lockstep::UnsychronizeAfter
53
+ extend Capybara::Lockstep::SynchronizeMacros
45
54
 
46
55
  synchronize_before :html, lazy: true # wait until running JavaScript has updated the DOM
47
56
 
48
57
  synchronize_before :current_url, lazy: true # wait until running JavaScript has updated the URL
49
58
 
50
59
  synchronize_before :refresh, lazy: false # wait until running JavaScript has updated the URL
51
- unsychronize_after :refresh # new document is no longer synchronized
60
+ unsynchronize_after :refresh # new document is no longer synchronized
52
61
 
53
62
  synchronize_before :go_back, lazy: false # wait until running JavaScript has updated the URL
54
- unsychronize_after :go_back # new document is no longer synchronized
63
+ unsynchronize_after :go_back # new document is no longer synchronized
55
64
 
56
65
  synchronize_before :go_forward, lazy: false # wait until running JavaScript has updated the URL
57
- unsychronize_after :go_forward # new document is no longer synchronized
66
+ unsynchronize_after :go_forward # new document is no longer synchronized
58
67
 
59
68
  synchronize_before :switch_to_frame, lazy: true # wait until the current frame is done processing
60
- unsychronize_after :switch_to_frame # now that we've switched into the new frame, we don't know the document's synchronization state.
69
+ unsynchronize_after :switch_to_frame # now that we've switched into the new frame, we don't know the document's synchronization state.
61
70
 
62
71
  synchronize_before :switch_to_window, lazy: true # wait until the current frame is done processing
63
- unsychronize_after :switch_to_window # now that we've switched to the new window, we don't know the document's synchronization state.
72
+ unsynchronize_after :switch_to_window # now that we've switched to the new window, we don't know the document's synchronization state.
64
73
  end
65
74
 
66
75
  module Capybara
@@ -88,7 +97,7 @@ module Capybara
88
97
  super(*args, &block).tap do
89
98
  if visiting_real_url
90
99
  # We haven't yet synchronized the new screen.
91
- Lockstep.synchronized = false
100
+ Lockstep.unsynchronize
92
101
  end
93
102
  end
94
103
  end
@@ -128,7 +137,7 @@ module Capybara
128
137
  if !Lockstep.synchronizing?
129
138
  # We haven't yet synchronized with whatever changes the JavaScript
130
139
  # did on the frontend.
131
- Lockstep.synchronized = false
140
+ Lockstep.unsynchronize
132
141
  end
133
142
  end
134
143
 
@@ -145,9 +154,8 @@ Capybara::Session.class_eval do
145
154
  extend Capybara::Lockstep::SynchronizeAroundScriptMethod
146
155
 
147
156
  synchronize_around_script_method :execute_script
157
+ synchronize_around_script_method :evaluate_script
148
158
  synchronize_around_script_method :evaluate_async_script
149
- # Don't synchronize around evaluate_script. It calls execute_script
150
- # internally and we don't want to synchronize multiple times.
151
159
  end
152
160
 
153
161
  # Capybara 3 has driver-specific Node classes which sometimes
@@ -168,21 +176,59 @@ end
168
176
 
169
177
  node_classes.each do |node_class|
170
178
  node_class.class_eval do
171
- extend Capybara::Lockstep::UnsychronizeAfter
172
-
173
- unsychronize_after :set
174
- unsychronize_after :select_option
175
- unsychronize_after :unselect_option
176
- unsychronize_after :click
177
- unsychronize_after :right_click
178
- unsychronize_after :double_click
179
- unsychronize_after :send_keys
180
- unsychronize_after :hover
181
- unsychronize_after :drag_to
182
- unsychronize_after :drop
183
- unsychronize_after :scroll_by
184
- unsychronize_after :scroll_to
185
- unsychronize_after :trigger
179
+ extend Capybara::Lockstep::SynchronizeMacros
180
+
181
+ synchronize_before :set, lazy: true
182
+ unsynchronize_after :set
183
+ synchronize_after :set
184
+
185
+ synchronize_before :select_option, lazy: true
186
+ unsynchronize_after :select_option
187
+ synchronize_after :select_option
188
+
189
+ synchronize_before :unselect_option, lazy: true
190
+ unsynchronize_after :unselect_option
191
+ synchronize_after :unselect_option
192
+
193
+ synchronize_before :click, lazy: true
194
+ unsynchronize_after :click
195
+ synchronize_after :click
196
+
197
+ synchronize_before :right_click, lazy: true
198
+ unsynchronize_after :right_click
199
+ synchronize_after :right_click
200
+
201
+ synchronize_before :double_click, lazy: true
202
+ unsynchronize_after :double_click
203
+ synchronize_after :double_click
204
+
205
+ synchronize_before :send_keys, lazy: true
206
+ unsynchronize_after :send_keys
207
+ synchronize_after :send_keys
208
+
209
+ synchronize_before :hover, lazy: true
210
+ unsynchronize_after :hover
211
+ synchronize_after :hover
212
+
213
+ synchronize_before :drag_to, lazy: true
214
+ unsynchronize_after :drag_to
215
+ synchronize_after :drag_to
216
+
217
+ synchronize_before :drop, lazy: true
218
+ unsynchronize_after :drop
219
+ synchronize_after :drop
220
+
221
+ synchronize_before :scroll_by, lazy: true
222
+ unsynchronize_after :scroll_by
223
+ synchronize_after :scroll_by
224
+
225
+ synchronize_before :scroll_to, lazy: true
226
+ unsynchronize_after :scroll_to
227
+ synchronize_after :scroll_to
228
+
229
+ synchronize_before :trigger, lazy: true
230
+ unsynchronize_after :trigger
231
+ synchronize_after :trigger
186
232
  end
187
233
  end
188
234
 
@@ -0,0 +1,133 @@
1
+ module Capybara
2
+ module Lockstep
3
+ class Client
4
+ include Logging
5
+ include PageAccess
6
+
7
+ ERROR_SNIPPET_MISSING = 'Cannot synchronize: capybara-lockstep JavaScript snippet is missing'
8
+ ERROR_PAGE_MISSING = 'Cannot synchronize with empty page'
9
+ ERROR_ALERT_OPEN = 'Cannot synchronize while an alert is open'
10
+ ERROR_NAVIGATED_AWAY = "Browser navigated away while synchronizing"
11
+
12
+ SYNCHRONIZED_IVAR = :@lockstep_synchronized_client
13
+
14
+ def synchronized?
15
+ # The synchronized flag is per-session (page == Capybara.current_session).
16
+ # This enables tests that use more than one browser, e.g. to test multi-user interaction:
17
+ # https://makandracards.com/makandra/474480-how-to-make-a-cucumber-test-work-with-multiple-browser-sessions
18
+ #
19
+ # Ideally the synchronized flag would also be per-tab, per-frame and per-document.
20
+ # We haven't found a way to patch this into Capybara, as there does not seem to be
21
+ # a persistent object representing a document. Capybara::Node::Document just seems to
22
+ # be a proxy accessing whatever is the current document. The way we work around this
23
+ # is that we synchronize before switching tabs or frames.
24
+ value = page.instance_variable_get(SYNCHRONIZED_IVAR)
25
+
26
+ # We consider a new Capybara session to be synchronized.
27
+ # This will be set to false after our first visit().
28
+ value.nil? ? true : value
29
+ end
30
+
31
+ def synchronized=(value)
32
+ page.instance_variable_set(SYNCHRONIZED_IVAR, value)
33
+ end
34
+
35
+ def synchronize
36
+ # If synchronization fails below we consider us unsynchronized after.
37
+ self.synchronized = false
38
+
39
+ # Running the synchronization script while an alert is open would close the alert,
40
+ # most likely causing subsequent expectations to fail.
41
+ if alert_present?
42
+ log ERROR_ALERT_OPEN
43
+ # Don't raise an error, this will happen in an innocent test.
44
+ # We will retry on the next Capybara synchronize call.
45
+ return
46
+ end
47
+
48
+ start_time = Util.current_seconds
49
+
50
+ begin
51
+ Util.with_max_wait_time(timeout) do
52
+ message_from_js = evaluate_async_script(<<~JS)
53
+ let done = arguments[0]
54
+ let synchronize = () => {
55
+ if (window.CapybaraLockstep) {
56
+ CapybaraLockstep.synchronize(done)
57
+ } else {
58
+ done(#{ERROR_SNIPPET_MISSING.to_json})
59
+ }
60
+ }
61
+ const emptyDataURL = /^data:[^,]*,?$/
62
+ if (emptyDataURL.test(location.href) || location.protocol === 'about:') {
63
+ done(#{ERROR_PAGE_MISSING.to_json})
64
+ } else if (document.readyState === 'complete') {
65
+ // WebDriver always waits for the `load` event after a visit(),
66
+ // unless a different page load strategy was configured.
67
+ synchronize()
68
+ } else {
69
+ window.addEventListener('load', synchronize)
70
+ }
71
+ JS
72
+
73
+ case message_from_js
74
+ when ERROR_PAGE_MISSING
75
+ log(message_from_js)
76
+ when ERROR_SNIPPET_MISSING
77
+ log(message_from_js)
78
+ else
79
+ log message_from_js
80
+ end_time = Util.current_seconds
81
+ ms_elapsed = ((end_time.to_f - start_time) * 1000).round
82
+ log "Synchronized client successfully [#{ms_elapsed} ms]"
83
+ self.synchronized = true
84
+ end
85
+ end
86
+ rescue ::Selenium::WebDriver::Error::ScriptTimeoutError
87
+ timeout_message = "Could not synchronize client within #{timeout} seconds"
88
+ log timeout_message
89
+ if timeout_with == :error
90
+ raise Timeout, timeout_message
91
+ else
92
+ # Don't raise an error, this may happen if the server is slow to respond.
93
+ # We will retry on the next Capybara synchronize call.
94
+ end
95
+ rescue ::Selenium::WebDriver::Error::UnexpectedAlertOpenError
96
+ log ERROR_ALERT_OPEN
97
+ # Don't raise an error, this will happen in an innocent test.
98
+ # We will retry on the next Capybara synchronize call.
99
+ rescue ::Selenium::WebDriver::Error::JavascriptError => e
100
+ # When the URL changes while a script is running, my current selenium-webdriver
101
+ # raises a Selenium::WebDriver::Error::JavascriptError with the message:
102
+ # "javascript error: document unloaded while waiting for result".
103
+ # We will retry on the next Capybara synchronize call, by then we should see
104
+ # the new page.
105
+ if e.message.include?('unload')
106
+ log ERROR_NAVIGATED_AWAY
107
+ else
108
+ unhandled_synchronize_error(e)
109
+ end
110
+ rescue StandardError => e
111
+ unhandled_synchronize_error(e)
112
+ end
113
+ end
114
+
115
+ private
116
+
117
+ def unhandled_synchronize_error(e)
118
+ Lockstep.log "#{e.class.name} while synchronizing: #{e.message}"
119
+ raise e
120
+ end
121
+
122
+ def timeout
123
+ Lockstep.timeout
124
+ end
125
+
126
+ def timeout_with
127
+ Lockstep.timeout_with
128
+ end
129
+
130
+ end
131
+ end
132
+ end
133
+
@@ -18,9 +18,13 @@ module Capybara
18
18
  @timeout_with = action&.to_sym
19
19
  end
20
20
 
21
+ def debug
22
+ @debug.nil? ? false : @debug
23
+ end
24
+
21
25
  def debug?
22
26
  # @debug may also be a Logger object, so convert it to a boolean
23
- @debug.nil? ? false : !!@debug
27
+ !!debug
24
28
  end
25
29
 
26
30
  def debug=(value)
@@ -93,13 +97,11 @@ module Capybara
93
97
  end
94
98
  end
95
99
 
96
- def javascript_driver?
97
- driver.is_a?(Capybara::Selenium::Driver)
98
- end
99
-
100
100
  def send_config_to_browser(js)
101
+ return unless javascript_driver?
102
+
101
103
  begin
102
- with_max_wait_time(2) do
104
+ Util.with_max_wait_time(2) do
103
105
  page.execute_script(<<~JS)
104
106
  if (window.CapybaraLockstep) {
105
107
  #{js}
@@ -11,7 +11,7 @@ window.CapybaraLockstep = (function() {
11
11
  jobCount = 0
12
12
  idleCallbacks = []
13
13
  finishedWorkTags = []
14
- waitTasks = 0
14
+ waitTasks = 1
15
15
  debug = false
16
16
  }
17
17
 
@@ -58,18 +58,7 @@ window.CapybaraLockstep = (function() {
58
58
  }
59
59
 
60
60
  function stopWork(tag) {
61
- let tasksElapsed = 0
62
-
63
- let check = function() {
64
- if (tasksElapsed < waitTasks) {
65
- tasksElapsed++
66
- setTimeout(check)
67
- } else {
68
- stopWorkNow(tag)
69
- }
70
- }
71
-
72
- check()
61
+ afterWaitTasks(stopWorkNow.bind(this, tag))
73
62
  }
74
63
 
75
64
  function stopWorkNow(tag) {
@@ -266,6 +255,14 @@ window.CapybaraLockstep = (function() {
266
255
  }
267
256
  }
268
257
 
258
+ function afterWaitTasks(fn, tasksLeft = waitTasks) {
259
+ if (tasksLeft > 0) {
260
+ afterWaitTasks(fn, tasksLeft - 1)
261
+ } else {
262
+ fn()
263
+ }
264
+ }
265
+
269
266
  function trackOldUnpoly() {
270
267
  // CapybaraLockstep.track() is called as the first script in the head.
271
268
  // Unpoly will be loaded after us, so we wait until DOMContentReady.
@@ -282,12 +279,28 @@ window.CapybaraLockstep = (function() {
282
279
  })
283
280
  }
284
281
 
282
+ function trackInteraction(eventType) {
283
+ document.addEventListener(eventType, function() {
284
+ // Only litter the log with interaction events if we're actually going
285
+ // to be busy for at least 1 task.
286
+ if (waitTasks > 0) {
287
+ let tag = eventType + ' listeners'
288
+ startWork(tag)
289
+ stopWork(tag)
290
+ }
291
+ })
292
+ }
293
+
285
294
  function track() {
286
295
  trackOldUnpoly()
287
296
  trackFetch()
288
297
  trackXHR()
289
298
  trackRemoteElements()
290
299
  trackJQuery()
300
+ trackInteraction('touchstart')
301
+ trackInteraction('mousedown')
302
+ trackInteraction('click')
303
+ trackInteraction('keydown')
291
304
  }
292
305
 
293
306
  function synchronize(callback) {
@@ -1,39 +1,31 @@
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 with empty page'
5
- ERROR_ALERT_OPEN = 'Cannot synchronize while an alert is open'
6
- ERROR_NAVIGATED_AWAY = "Browser navigated away while synchronizing"
7
-
8
3
  class << self
9
4
  include Configuration
10
5
  include Logging
6
+ include PageAccess
11
7
 
12
8
  attr_accessor :synchronizing
13
9
  alias synchronizing? synchronizing
14
10
 
15
- def synchronized?
16
- # The synchronized flag is per-session (page == Capybara.current_session).
17
- # This enables tests that use more than one browser, e.g. to test multi-user interaction:
18
- # https://makandracards.com/makandra/474480-how-to-make-a-cucumber-test-work-with-multiple-browser-sessions
19
- #
20
- # Ideally the synchronized flag would also be per-tab, per-frame and per-document.
21
- # We haven't found a way to patch this into Capybara, as there does not seem to be
22
- # a persistent object representing a document. Capybara::Node::Document just seems to
23
- # be a proxy accessing whatever is the current document. The way we work around this
24
- # is that we synchronize before switching tabs or frames.
25
- value = page.instance_variable_get(:@lockstep_synchronized)
26
-
27
- # We consider a new Capybara session to be synchronized.
28
- # This will be set to false after our first visit().
29
- value.nil? ? true : value
11
+ def unsynchronize
12
+ client.synchronized = false
30
13
  end
31
14
 
32
- def synchronized=(value)
33
- page.instance_variable_set(:@lockstep_synchronized, value)
15
+ # Automatic synchronization from within the capybara-lockstep should always call #auto_synchronize.
16
+ # This only synchronizes IFF in :auto mode, i.e. the user has not explicitly disabled automatic syncing.
17
+ # The :auto mode has nothing to do with the { lazy } option.
18
+ def auto_synchronize(**options)
19
+ if mode == :auto
20
+ synchronize(**options)
21
+ end
34
22
  end
35
23
 
36
- def synchronize(lazy: false, log: nil)
24
+ def synchronize(lazy: false, log: 'Synchronizing')
25
+ if synchronizing? || mode == :off
26
+ return
27
+ end
28
+
37
29
  # The { lazy } option is a performance optimization that will prevent capybara-lockstep
38
30
  # from synchronizing multiple times in expressions like `page.find('.foo').find('.bar')`.
39
31
  # The { lazy } option has nothing todo with :auto mode.
@@ -48,135 +40,38 @@ module Capybara
48
40
  # thinks we're in sync or not. This always makes an execute_script() rountrip, but picks up
49
41
  # non-lazy synchronization so we pick up client-side work that have not been caused
50
42
  # by Capybara commands.
51
- if (lazy && synchronized?) || synchronizing? || mode == :off
52
- return
53
- end
54
-
55
- synchronize_now(log: log)
56
-
57
- run_after_synchronize_callbacks
58
- end
59
-
60
- # Automatic synchronization from within the capybara-lockstep should always call #auto_synchronize.
61
- # This only synchronizes IFF in :auto mode, i.e. the user has not explicitly disabled automatic syncing.
62
- # The :auto mode has nothing to do with the { lazy } option.
63
- def auto_synchronize(**options)
64
- if mode == :auto
65
- synchronize(**options)
66
- end
67
- end
68
-
69
- private
70
-
71
- def synchronize_now(log: 'Synchronizing')
72
- self.synchronizing = true
73
- self.synchronized = false
74
-
75
- self.log(log)
76
-
77
- start_time = current_seconds
43
+ will_synchronize_client = !(lazy && client.synchronized?)
78
44
 
79
45
  begin
80
- with_max_wait_time(timeout) do
81
- message_from_js = evaluate_async_script(<<~JS)
82
- let done = arguments[0]
83
- let synchronize = () => {
84
- if (window.CapybaraLockstep) {
85
- CapybaraLockstep.synchronize(done)
86
- } else {
87
- done(#{ERROR_SNIPPET_MISSING.to_json})
88
- }
89
- }
90
- const emptyDataURL = /^data:[^,]*,?$/
91
- if (emptyDataURL.test(location.href) || location.protocol === 'about:') {
92
- done(#{ERROR_PAGE_MISSING.to_json})
93
- } else if (document.readyState === 'complete') {
94
- // WebDriver always waits for the `load` event after a visit(),
95
- // unless a different page load strategy was configured.
96
- synchronize()
97
- } else {
98
- window.addEventListener('load', synchronize)
99
- }
100
- JS
101
-
102
- case message_from_js
103
- when ERROR_PAGE_MISSING
104
- log(message_from_js)
105
- when ERROR_SNIPPET_MISSING
106
- log(message_from_js)
107
- else
108
- log message_from_js
109
- end_time = current_seconds
110
- ms_elapsed = ((end_time.to_f - start_time) * 1000).round
111
- log "Synchronized successfully [#{ms_elapsed} ms]"
112
- self.synchronized = true
113
- end
114
- end
115
- rescue ::Selenium::WebDriver::Error::ScriptTimeoutError
116
- timeout_message = "Could not synchronize within #{timeout} seconds"
117
- log timeout_message
118
- if timeout_with == :error
119
- raise Timeout, timeout_message
120
- else
121
- # Don't raise an error, this may happen if the server is slow to respond.
122
- # We will retry on the next Capybara synchronize call.
46
+ # Synchronizing the server is free, so we ignore { lazy } and do it every time.
47
+ server.synchronize
48
+
49
+ if will_synchronize_client
50
+ self.log(log)
51
+ self.synchronizing = true
52
+ client.synchronize
53
+ # Synchronizing the server is free, so we ignore { lazy } and do it every time.
54
+ server.synchronize
123
55
  end
124
- rescue ::Selenium::WebDriver::Error::UnexpectedAlertOpenError
125
- log ERROR_ALERT_OPEN
126
- # Don't raise an error, this will happen in an innocent test.
127
- # We will retry on the next Capybara synchronize call.
128
- rescue ::Selenium::WebDriver::Error::JavascriptError => e
129
- # When the URL changes while a script is running, my current selenium-webdriver
130
- # raises a Selenium::WebDriver::Error::JavascriptError with the message:
131
- # "javascript error: document unloaded while waiting for result".
132
- # We will retry on the next Capybara synchronize call, by then we should see
133
- # the new page.
134
- if e.message.include?('unload')
135
- log ERROR_NAVIGATED_AWAY
136
- else
137
- unhandled_synchronize_error(e)
138
- end
139
- rescue StandardError => e
140
- unhandled_synchronize_error(e)
56
+ ensure
57
+ self.synchronizing = false
141
58
  end
142
59
 
143
- ensure
144
- self.synchronizing = false
145
- end
146
-
147
- def unhandled_synchronize_error(e)
148
- log "#{e.class.name} while synchronizing: #{e.message}"
149
- raise e
150
- end
151
-
152
- def page
153
- Capybara.current_session
154
- end
155
-
156
- delegate :evaluate_script, :evaluate_async_script, :execute_script, :driver, to: :page
157
-
158
- def with_max_wait_time(seconds, &block)
159
- old_max_wait_time = Capybara.default_max_wait_time
160
- Capybara.default_max_wait_time = seconds
161
- begin
162
- block.call
163
- ensure
164
- Capybara.default_max_wait_time = old_max_wait_time
60
+ if will_synchronize_client
61
+ run_after_synchronize_callbacks
165
62
  end
166
63
  end
167
64
 
168
- def ignoring_alerts(&block)
169
- block.call
170
- rescue ::Selenium::WebDriver::Error::UnexpectedAlertOpenError
171
- # no-op
65
+ delegate :start_work, :stop_work, to: :server
66
+
67
+ def server
68
+ @server ||= Server.new
172
69
  end
173
70
 
174
- def current_seconds
175
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
71
+ def client
72
+ @client ||= Client.new
176
73
  end
177
74
 
178
75
  end
179
-
180
76
  end
181
77
  end
182
-
@@ -2,11 +2,11 @@ module Capybara
2
2
  module Lockstep
3
3
  module Logging
4
4
  def log(message)
5
- if debug? && message.present?
5
+ if Lockstep.debug? && message.present?
6
6
  message = "[capybara-lockstep] #{message}"
7
- if is_logger?(@debug)
8
- # If someone set Capybara::Lockstep to a logger, use that
9
- @debug.debug(message)
7
+ if is_logger?(Lockstep.debug)
8
+ # If someone set Capybara::Lockstep.debug to a logger, use that
9
+ Lockstep.debug(message)
10
10
  else
11
11
  # Otherwise print to STDOUT
12
12
  puts message
@@ -17,8 +17,8 @@ module Capybara
17
17
  private
18
18
 
19
19
  def is_logger?(object)
20
- @debug.respond_to?(:debug)
20
+ object.respond_to?(:debug)
21
21
  end
22
22
  end
23
23
  end
24
- end
24
+ end
@@ -0,0 +1,22 @@
1
+ module Capybara
2
+ module Lockstep
3
+ class Middleware
4
+
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+
9
+ def call(env)
10
+ tag = "Server request for #{env['PATH_INFO'] || 'unknown path'}"
11
+ Lockstep.start_work(tag)
12
+
13
+ begin
14
+ @app.call(env)
15
+ ensure
16
+ Lockstep.stop_work(tag)
17
+ end
18
+ end
19
+
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,28 @@
1
+ module Capybara
2
+ module Lockstep
3
+ module PageAccess
4
+ def page
5
+ Capybara.current_session
6
+ end
7
+
8
+ delegate :evaluate_script, :evaluate_async_script, :execute_script, :driver, to: :page
9
+
10
+ def javascript_driver?
11
+ driver.is_a?(Capybara::Selenium::Driver)
12
+ end
13
+
14
+ def alert_present?
15
+ # Chrome 54 and/or Chromedriver 2.24 introduced a breaking change on how
16
+ # accessing browser logs work.
17
+ #
18
+ # Apparently, while an alert/confirm is open, Chrome will block any requests
19
+ # to its `getLog` API. This causes Selenium to time out with a `Net::ReadTimeout` error
20
+ page.driver.browser.switch_to.alert
21
+ true
22
+ rescue Capybara::NotSupportedByDriverError, ::Selenium::WebDriver::Error::NoSuchAlertError
23
+ false
24
+ end
25
+
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,46 @@
1
+ module Capybara
2
+ module Lockstep
3
+ class Server
4
+ include Logging
5
+
6
+ def initialize
7
+ @job_count = 0
8
+ @job_count_mutex = Mutex.new
9
+ @idle_condition = ConditionVariable.new
10
+ end
11
+
12
+ attr_accessor :job_count
13
+
14
+ def start_work(tag)
15
+ job_count_mutex.synchronize do
16
+ self.job_count += 1
17
+ log("Started server work: #{tag} [#{job_count} server jobs]") if tag
18
+ end
19
+ end
20
+
21
+ def stop_work(tag)
22
+ job_count_mutex.synchronize do
23
+ self.job_count -= 1
24
+ log("Stopped server work: #{tag} [#{job_count} server jobs]") if tag
25
+
26
+ if job_count == 0
27
+ idle_condition.broadcast
28
+ end
29
+ end
30
+ end
31
+
32
+ def synchronize
33
+ job_count_mutex.synchronize do
34
+ if job_count > 0
35
+ idle_condition.wait(job_count_mutex)
36
+ end
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :job_count_mutex, :idle_condition
43
+
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,21 @@
1
+ module Capybara
2
+ module Lockstep
3
+ module Util
4
+ class << self
5
+ def with_max_wait_time(seconds, &block)
6
+ old_max_wait_time = Capybara.default_max_wait_time
7
+ Capybara.default_max_wait_time = seconds
8
+ begin
9
+ block.call
10
+ ensure
11
+ Capybara.default_max_wait_time = old_max_wait_time
12
+ end
13
+ end
14
+
15
+ def current_seconds
16
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -1,5 +1,5 @@
1
1
  module Capybara
2
2
  module Lockstep
3
- VERSION = "1.3.0"
3
+ VERSION = "2.0.0.rc1"
4
4
  end
5
5
  end
@@ -10,8 +10,13 @@ end
10
10
 
11
11
  require_relative 'capybara-lockstep/version'
12
12
  require_relative 'capybara-lockstep/errors'
13
+ require_relative 'capybara-lockstep/util'
13
14
  require_relative 'capybara-lockstep/configuration'
14
15
  require_relative 'capybara-lockstep/logging'
16
+ require_relative 'capybara-lockstep/page_access'
15
17
  require_relative 'capybara-lockstep/lockstep'
16
18
  require_relative 'capybara-lockstep/capybara_ext'
17
19
  require_relative 'capybara-lockstep/helper'
20
+ require_relative 'capybara-lockstep/server'
21
+ require_relative 'capybara-lockstep/client'
22
+ require_relative 'capybara-lockstep/middleware'
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: 1.3.0
4
+ version: 2.0.0.rc1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Henning Koch
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-01-10 00:00:00.000000000 Z
11
+ date: 2023-11-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: capybara
@@ -88,12 +88,17 @@ files:
88
88
  - capybara-lockstep.gemspec
89
89
  - lib/capybara-lockstep.rb
90
90
  - lib/capybara-lockstep/capybara_ext.rb
91
+ - lib/capybara-lockstep/client.rb
91
92
  - lib/capybara-lockstep/configuration.rb
92
93
  - lib/capybara-lockstep/errors.rb
93
94
  - lib/capybara-lockstep/helper.js
94
95
  - lib/capybara-lockstep/helper.rb
95
96
  - lib/capybara-lockstep/lockstep.rb
96
97
  - lib/capybara-lockstep/logging.rb
98
+ - lib/capybara-lockstep/middleware.rb
99
+ - lib/capybara-lockstep/page_access.rb
100
+ - lib/capybara-lockstep/server.rb
101
+ - lib/capybara-lockstep/util.rb
97
102
  - lib/capybara-lockstep/version.rb
98
103
  homepage: https://github.com/makandra/capybara-lockstep
99
104
  licenses:
@@ -115,11 +120,11 @@ required_ruby_version: !ruby/object:Gem::Requirement
115
120
  version: 2.4.0
116
121
  required_rubygems_version: !ruby/object:Gem::Requirement
117
122
  requirements:
118
- - - ">="
123
+ - - ">"
119
124
  - !ruby/object:Gem::Version
120
- version: '0'
125
+ version: 1.3.1
121
126
  requirements: []
122
- rubygems_version: 3.2.6
127
+ rubygems_version: 3.4.3
123
128
  signing_key:
124
129
  specification_version: 4
125
130
  summary: Synchronize Capybara commands with client-side JavaScript and AJAX requests