percy-capybara 5.0.1.pre.1 → 5.0.1.pre.3

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: b09892a41c8741ae0fc2df13506fea8cf230eed9378c88959e162d472e45920a
4
- data.tar.gz: ad15f33efc210f01b3c41bb22105ccd1d09b789ab1ab17aa598c9184ddcd8b1f
3
+ metadata.gz: 55183f20f35bdf96129984ea3eafb21f484bf7d8abd648a07a5da0f9cbed8b20
4
+ data.tar.gz: 9f2338e88239dcb83767bfa8c0edac9a8e0cbe311c341fa2a43f0540dee295d7
5
5
  SHA512:
6
- metadata.gz: cd030fd64faa05dd1bb59d75003615280ad8aa53eec6d295843b2b750b9ae298a4ee2171ffd291c3571af57a9feff127c45a2ea6deb02b77fc78bdcb6ab0ecd3
7
- data.tar.gz: cda536b6071654fe1be5e359bf72c737c303fdb23822f863796d6163959ca5b5e4e4b609a4f5d2348111147f050c9323e5e49be517fa67a38d189f04aa3067e4
6
+ metadata.gz: 5638fbbfc7505c07fa32a638a415f8ff0dc46ffcdb4d74695800158e8ee85ec011eea3cee429495baf24f34aba256b370e2cc73f0c7c6369a72a143ee638780f
7
+ data.tar.gz: 51aa201e617f53d1e75592cb64f4920f7c1717966e5f990b12c977b819fca7a37a7a812e7e100c727f1d96fd61da63800ea3666f99d21e1e17bb1920a8b60581
@@ -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,6 +28,9 @@ 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)
25
35
 
26
36
  # Readiness gate -- runs before serialize when CLI supports it.
@@ -29,8 +39,14 @@ module PercyCapybara
29
39
  # older CLIs that lack waitForReady.
30
40
  readiness_diagnostics = wait_for_ready(page, options)
31
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
+
32
48
  dom_snapshot = page
33
- .evaluate_script("(function() { return PercyDOM.serialize(#{options.to_json}) })()")
49
+ .evaluate_script("(function() { return PercyDOM.serialize(#{merged_options.to_json}) })()")
34
50
 
35
51
  if readiness_diagnostics && dom_snapshot.is_a?(Hash)
36
52
  dom_snapshot['readiness_diagnostics'] = readiness_diagnostics
@@ -44,7 +60,16 @@ module PercyCapybara
44
60
  environment_info: ENV_INFO,
45
61
  **options,)
46
62
 
47
- 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']
48
73
  raise StandardError, data['error']
49
74
  end
50
75
  rescue StandardError => e
@@ -54,6 +79,135 @@ module PercyCapybara
54
79
  end
55
80
  end
56
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
+
57
211
  # Determine if the Percy server is running, caching the result so it is only checked once
58
212
  private def percy_enabled?
59
213
  return @percy_enabled unless @percy_enabled.nil?
@@ -77,6 +231,8 @@ module PercyCapybara
77
231
  return false
78
232
  end
79
233
 
234
+ body = response.body.to_s.empty? ? {} : JSON.parse(response.body)
235
+ @cli_config = body['config'] || {}
80
236
  @percy_enabled = true
81
237
  true
82
238
  rescue StandardError => e
@@ -129,6 +285,24 @@ module PercyCapybara
129
285
  end
130
286
  end
131
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
+
132
306
  private def log(msg)
133
307
  puts "#{PERCY_LABEL} #{msg}"
134
308
  end
data/lib/percy/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module PercyCapybara
2
- VERSION = '5.0.1.pre.1'.freeze
2
+ VERSION = '5.0.1.pre.3'.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.1.pre.1
4
+ version: 5.0.1.pre.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Perceptual Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-04 00:00:00.000000000 Z
11
+ date: 2026-07-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: capybara