percy-capybara 5.0.0 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 45a545941cfa1347d8912e8d94218005a87662f1c07d7633696ed56ba130a319
4
- data.tar.gz: e0796a2c700b35b7710c79979ea1d12c136335fce31b3d12c1e68630bd56ba88
3
+ metadata.gz: 7db2c14fc57a6c57c3b6622525553787384946fb9391a2f725cb2200798973a7
4
+ data.tar.gz: ac4086661c230766111c2a15ffebf403941f4e42349a2a550b90c2becfd7ecc9
5
5
  SHA512:
6
- metadata.gz: 6cb49cda9b878968ffb3225c032c5a49e8c8fc712c9598c0c654362dad01a46d8d1f1808a4bef1cc3c2a6593b5da28f0deb7e1484a00416f2aa2ba9d7eaaf0f2
7
- data.tar.gz: 45bd57d38c20a550fe956c4b99b13b4d271113b5a8e14ef5b9e832facf36fe2be09b60e8a6ca5539ee54bde4b5885e67fdc6315d33cb75e46abf2e19e92b5757
6
+ metadata.gz: 6233ec353b93414b17a01300f2c3fdcbe5350a4e9a847c52e1a115da333cf9dbf96c8647540c64ddaeb7cb7cb9913594f16682c77e46e59f76f606b103200ff8
7
+ data.tar.gz: 44951b8de50525cd017ecc705a02ee57d6faf93f2fa36c174bdadb126ab66f1260191ca53abf046e7dda2ed8aa61961c202441f2aec9c4f3901b63fe70823a0e
data/README.md CHANGED
@@ -66,7 +66,7 @@ $ percy exec -- [test command]
66
66
  `page.snapshot(name[, options])`
67
67
 
68
68
  - `name` (**required**) - The snapshot name; must be unique to each snapshot
69
- - `options` - [See per-snapshot configuration options](https://docs.percy.io/docs/cli-configuration#per-snapshot-configuration)
69
+ - `options` - [See per-snapshot configuration options](https://www.browserstack.com/docs/percy/take-percy-snapshots/overview#per-snapshot-configuration)
70
70
 
71
71
  ## Upgrading
72
72
 
@@ -1,3 +1,4 @@
1
+ require 'json'
1
2
  require 'net/http'
2
3
  require 'uri'
3
4
  require 'capybara/dsl'
@@ -14,6 +15,12 @@ module PercyCapybara
14
15
  private_constant :CLIENT_INFO
15
16
  private_constant :ENV_INFO
16
17
 
18
+ UNSUPPORTED_IFRAME_SRCS = %w[
19
+ about:blank about:srcdoc javascript: data: blob: vbscript: chrome: chrome-extension:
20
+ ].freeze
21
+
22
+ private_constant :UNSUPPORTED_IFRAME_SRCS
23
+
17
24
  # Take a DOM snapshot and post it to the snapshot endpoint
18
25
  def percy_snapshot(name, options = {})
19
26
  return unless percy_enabled?
@@ -21,9 +28,29 @@ module PercyCapybara
21
28
  page = Capybara.current_session
22
29
 
23
30
  begin
31
+ percy_dom_script = fetch_percy_dom
32
+ page.evaluate_script(percy_dom_script)
33
+ dom_snapshot = get_serialized_dom(page, options, percy_dom_script)
24
34
  page.evaluate_script(fetch_percy_dom)
35
+
36
+ # Readiness gate -- runs before serialize when CLI supports it.
37
+ # Uses evaluate_async_script with a callback signal so the SDK can block
38
+ # on PercyDOM.waitForReady. In-browser typeof guard makes this a no-op on
39
+ # older CLIs that lack waitForReady.
40
+ readiness_diagnostics = wait_for_ready(page, options)
41
+
42
+ # Merge .percy.yml config options with snapshot options (snapshot options take priority).
43
+ # Deep merge with consistent string keys at all levels: nested Hashes merge
44
+ # recursively, per-call wins at leaves, arrays/scalars replace.
45
+ config_options = @cli_config&.dig('snapshot') || {}
46
+ merged_options = deep_merge_options(config_options, deep_stringify(options))
47
+
25
48
  dom_snapshot = page
26
- .evaluate_script("(function() { return PercyDOM.serialize(#{options.to_json}) })()")
49
+ .evaluate_script("(function() { return PercyDOM.serialize(#{merged_options.to_json}) })()")
50
+
51
+ if readiness_diagnostics && dom_snapshot.is_a?(Hash)
52
+ dom_snapshot['readiness_diagnostics'] = readiness_diagnostics
53
+ end
27
54
 
28
55
  response = fetch('percy/snapshot',
29
56
  name: name,
@@ -33,7 +60,16 @@ module PercyCapybara
33
60
  environment_info: ENV_INFO,
34
61
  **options,)
35
62
 
36
- unless response.body.to_json['success']
63
+ data =
64
+ begin
65
+ JSON.parse(response.body)
66
+ rescue JSON::ParserError => e
67
+ log("Could not parse snapshot response for '#{name}': invalid JSON")
68
+ if PERCY_DEBUG then log(e) end
69
+ return
70
+ end
71
+
72
+ unless data['success']
37
73
  raise StandardError, data['error']
38
74
  end
39
75
  rescue StandardError => e
@@ -43,6 +79,135 @@ module PercyCapybara
43
79
  end
44
80
  end
45
81
 
82
+ private def get_serialized_dom(page, options, percy_dom_script)
83
+ dom_snapshot = page
84
+ .evaluate_script("(function() { return PercyDOM.serialize(#{options.to_json}) })()")
85
+
86
+ driver = page.driver.browser
87
+ begin
88
+ page_origin = get_origin(page.current_url)
89
+ iframes = driver.find_elements(:tag_name, 'iframe')
90
+ log("Found #{iframes.length} total iframe(s) on page") if PERCY_DEBUG
91
+
92
+ processed_frames = []
93
+ iframes.each do |frame|
94
+ frame_src = frame.attribute('src')
95
+
96
+ if unsupported_iframe_src?(frame_src)
97
+ log("Skipping unsupported iframe src: #{frame_src}") if PERCY_DEBUG && frame_src
98
+ next
99
+ end
100
+
101
+ begin
102
+ frame_origin = get_origin(URI.join(page.current_url, frame_src).to_s)
103
+ rescue StandardError => e
104
+ log("Skipping iframe with invalid URL \"#{frame_src}\": #{e}") if PERCY_DEBUG
105
+ next
106
+ end
107
+
108
+ if frame_origin == page_origin
109
+ log("Skipping same-origin iframe: #{frame_src}") if PERCY_DEBUG
110
+ next
111
+ end
112
+
113
+ # Pre-flight check: avoid the switch/serialize round-trip if there's no
114
+ # data-percy-element-id, since process_frame would discard the snapshot
115
+ # anyway and we'd silently waste a frame switch.
116
+ unless frame.attribute('data-percy-element-id')
117
+ log("Skipping iframe #{frame_src}: no data-percy-element-id found") if PERCY_DEBUG
118
+ next
119
+ end
120
+
121
+ result = process_frame(driver, frame, options, percy_dom_script)
122
+ processed_frames << result if result
123
+ end
124
+
125
+ if processed_frames.any?
126
+ dom_snapshot['corsIframes'] = processed_frames
127
+ log("Captured #{processed_frames.length} cross-origin iframe(s)")
128
+ end
129
+ rescue StandardError => e
130
+ log("Failed to process cross-origin iframes: #{e}") if PERCY_DEBUG
131
+ begin
132
+ driver.switch_to.default_content
133
+ rescue StandardError
134
+ nil
135
+ end
136
+ end
137
+
138
+ dom_snapshot
139
+ end
140
+
141
+ private def unsupported_iframe_src?(src)
142
+ return true if src.nil? || src.empty?
143
+
144
+ UNSUPPORTED_IFRAME_SRCS.any? { |prefix| src == prefix || src.start_with?(prefix) }
145
+ end
146
+
147
+ private def get_origin(url)
148
+ uri = URI.parse(url)
149
+ raise URI::InvalidURIError, "no host in #{url}" if uri.host.nil?
150
+
151
+ default_ports = {'http' => 80, 'https' => 443}
152
+ netloc = uri.host.to_s
153
+ netloc += ":#{uri.port}" if uri.port && uri.port != default_ports[uri.scheme]
154
+ "#{uri.scheme}://#{netloc}"
155
+ end
156
+
157
+ private def process_frame(driver, frame_element, options, percy_dom_script)
158
+ frame_url = frame_element.attribute('src') || 'unknown-src'
159
+ log("Processing cross-origin iframe: #{frame_url}") if PERCY_DEBUG
160
+
161
+ begin
162
+ driver.switch_to.frame(frame_element)
163
+ begin
164
+ driver.execute_script(percy_dom_script)
165
+ iframe_options = options.merge('enableJavaScript' => true)
166
+ iframe_snapshot =
167
+ driver.execute_script("return PercyDOM.serialize(#{iframe_options.to_json})")
168
+ log("Serialized cross-origin iframe: #{frame_url}") if PERCY_DEBUG
169
+ rescue StandardError => e
170
+ log("Failed to serialize cross-origin iframe #{frame_url}: #{e}") if PERCY_DEBUG
171
+ return nil
172
+ ensure
173
+ begin
174
+ driver.switch_to.default_content
175
+ rescue StandardError
176
+ begin
177
+ driver.switch_to.parent_frame
178
+ rescue StandardError
179
+ nil
180
+ end
181
+ end
182
+ end
183
+ rescue StandardError => e
184
+ log("Failed to switch to frame #{frame_url}: #{e}") if PERCY_DEBUG
185
+ begin
186
+ driver.switch_to.default_content
187
+ rescue StandardError
188
+ nil
189
+ end
190
+ return nil
191
+ end
192
+
193
+ percy_element_id = frame_element.attribute('data-percy-element-id')
194
+ unless percy_element_id
195
+ log("Skipping frame #{frame_url}: no data-percy-element-id found") if PERCY_DEBUG
196
+ return nil
197
+ end
198
+
199
+ if PERCY_DEBUG
200
+ log("Successfully captured cross-origin iframe: #{frame_url} " \
201
+ "(percyElementId: #{percy_element_id})")
202
+ end
203
+
204
+ {
205
+ 'iframeData' => {'percyElementId' => percy_element_id},
206
+ 'iframeSnapshot' => iframe_snapshot,
207
+ 'frameUrl' => frame_url,
208
+ }
209
+ end
210
+
46
211
  # Determine if the Percy server is running, caching the result so it is only checked once
47
212
  private def percy_enabled?
48
213
  return @percy_enabled unless @percy_enabled.nil?
@@ -55,7 +220,7 @@ module PercyCapybara
55
220
  log('You may be using @percy/agent ' \
56
221
  'which is no longer supported by this SDK. ' \
57
222
  'Please uninstall @percy/agent and install @percy/cli instead. ' \
58
- 'https://docs.percy.io/docs/migrating-to-percy-cli')
223
+ 'https://www.browserstack.com/docs/percy/migration/migrate-to-cli')
59
224
  @percy_enabled = false
60
225
  return false
61
226
  end
@@ -66,6 +231,8 @@ module PercyCapybara
66
231
  return false
67
232
  end
68
233
 
234
+ body = response.body.to_s.empty? ? {} : JSON.parse(response.body)
235
+ @cli_config = body['config'] || {}
69
236
  @percy_enabled = true
70
237
  true
71
238
  rescue StandardError => e
@@ -85,6 +252,57 @@ module PercyCapybara
85
252
  @percy_dom = response.body
86
253
  end
87
254
 
255
+ # Readiness gate: runs PercyDOM.waitForReady before serialize.
256
+ #
257
+ # Returns diagnostics to attach to the domSnapshot, or nil.
258
+ # Config precedence: options[:readiness] / options['readiness'] > {} (the
259
+ # CLI applies its balanced preset default when passed {}). preset='disabled'
260
+ # skips the script entirely. Opt-in: only runs when the caller explicitly
261
+ # passes a `readiness` option -- keeps non-opting tests insulated from
262
+ # Capybara drivers that don't implement evaluate_async_script.
263
+ # Any StandardError is caught at debug level.
264
+ private def wait_for_ready(page, options)
265
+ return nil unless options.key?(:readiness) || options.key?('readiness')
266
+
267
+ readiness_config = options[:readiness] || options['readiness'] || {}
268
+ return nil if readiness_config.is_a?(Hash) && (
269
+ readiness_config[:preset] == 'disabled' || readiness_config['preset'] == 'disabled'
270
+ )
271
+
272
+ begin
273
+ page.evaluate_async_script(<<~JS)
274
+ var cfg = #{readiness_config.to_json};
275
+ var done = arguments[arguments.length - 1];
276
+ try {
277
+ if (typeof PercyDOM !== 'undefined' && typeof PercyDOM.waitForReady === 'function') {
278
+ PercyDOM.waitForReady(cfg).then(function(r){ done(r); }).catch(function(){ done(); });
279
+ } else { done(); }
280
+ } catch (e) { done(); }
281
+ JS
282
+ rescue StandardError => e
283
+ if PERCY_DEBUG then log("waitForReady failed, proceeding to serialize: #{e}") end
284
+ nil
285
+ end
286
+ end
287
+
288
+ # Recursively stringify all Hash keys so per-call options match the
289
+ # string-keyed config parsed from .percy.yml JSON at every nesting level.
290
+ private def deep_stringify(obj)
291
+ case obj
292
+ when Hash then obj.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify(v) }
293
+ when Array then obj.map { |e| deep_stringify(e) }
294
+ else obj
295
+ end
296
+ end
297
+
298
+ # Deep-merge two string-keyed Hashes: nested Hashes merge recursively,
299
+ # per-call (override) wins at the leaves, arrays/scalars replace.
300
+ private def deep_merge_options(base, override)
301
+ base.merge(override) do |_key, old_val, new_val|
302
+ old_val.is_a?(Hash) && new_val.is_a?(Hash) ? deep_merge_options(old_val, new_val) : new_val
303
+ end
304
+ end
305
+
88
306
  private def log(msg)
89
307
  puts "#{PERCY_LABEL} #{msg}"
90
308
  end
data/lib/percy/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module PercyCapybara
2
- VERSION = '5.0.0'.freeze
2
+ VERSION = '5.0.1'.freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: percy-capybara
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.0.0
4
+ version: 5.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Perceptual Inc.
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-07-15 00:00:00.000000000 Z
11
+ date: 2026-07-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: capybara
@@ -30,14 +30,28 @@ dependencies:
30
30
  requirements:
31
31
  - - ">="
32
32
  - !ruby/object:Gem::Version
33
- version: 4.0.0.beta1
33
+ version: 4.0.0
34
34
  type: :development
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
- version: 4.0.0.beta1
40
+ version: 4.0.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: geckodriver-bin
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 0.28.0
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 0.28.0
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: bundler
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -84,16 +98,16 @@ dependencies:
84
98
  name: capybara
85
99
  requirement: !ruby/object:Gem::Requirement
86
100
  requirements:
87
- - - "~>"
101
+ - - ">="
88
102
  - !ruby/object:Gem::Version
89
- version: '3.31'
103
+ version: '3'
90
104
  type: :development
91
105
  prerelease: false
92
106
  version_requirements: !ruby/object:Gem::Requirement
93
107
  requirements:
94
- - - "~>"
108
+ - - ">="
95
109
  - !ruby/object:Gem::Version
96
- version: '3.31'
110
+ version: '3'
97
111
  - !ruby/object:Gem::Dependency
98
112
  name: percy-style
99
113
  requirement: !ruby/object:Gem::Requirement
@@ -125,7 +139,7 @@ licenses:
125
139
  metadata:
126
140
  bug_tracker_uri: https://github.com/percy/percy-capybara/issues
127
141
  source_code_uri: https://github.com/percy/percy-capybara
128
- post_install_message:
142
+ post_install_message:
129
143
  rdoc_options: []
130
144
  require_paths:
131
145
  - lib
@@ -140,8 +154,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
140
154
  - !ruby/object:Gem::Version
141
155
  version: '0'
142
156
  requirements: []
143
- rubygems_version: 3.0.3
144
- signing_key:
157
+ rubygems_version: 3.0.3.1
158
+ signing_key:
145
159
  specification_version: 4
146
160
  summary: Percy visual testing for Capybara
147
161
  test_files: []