percy-capybara 0.3.0 → 0.4.0

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
  SHA1:
3
- metadata.gz: f6b29e232904e95b181e02e0381bd459dfd09a49
4
- data.tar.gz: e6c802f2770ba1ab8201838b315945adc3b54cf3
3
+ metadata.gz: 0373826717cf4a83f083c9d8d71d92dfea1b595c
4
+ data.tar.gz: e32236928c98afe4613579a5a9db5869ea506f90
5
5
  SHA512:
6
- metadata.gz: 634f54da9d0ca8f752dc4c3f22fe151072b6064cd8045cb4a7ae280e624dd1090fa5591c898fa57ef83eec37b92049ecafaf44adcd9ef4e84d5af2a0b1b326b7
7
- data.tar.gz: 997c23094a26023115a2a3245d3429329829e9136c84f3370aa9d54c75e40f6ccc8a233ab8078ac1acfbbb07bcb764443a832bee2ae3c6e15b316bb1aad83506
6
+ metadata.gz: bbc8b68ae8f7df03bc575f64cd6efa4c9bcb7bfbde16e478735e57d43b1e5d3bb64bd7e380db3025f81bc8c3bfdd7d8f0283b21da075258f98357ac94c9a15d9
7
+ data.tar.gz: 053c1512eba240ff18904cb7790f5aa8093cc8356cfe086665be2b241ec9291679d2fb2d69a09ed790e055ee8f075ba652be3a26bed671ad23ffb2ec90eac5b6
@@ -25,8 +25,8 @@ module Percy
25
25
  # multi-process tests where a single build must be created before forking.
26
26
  #
27
27
  # @see Percy::Capybara::Client::Builds#initialize_build
28
- def self.initialize_build
29
- capybara_client.initialize_build
28
+ def self.initialize_build(options = {})
29
+ capybara_client.initialize_build(options)
30
30
  end
31
31
 
32
32
  # Finalize the current build.
@@ -1,5 +1,7 @@
1
1
  require 'percy/capybara/client/builds'
2
2
  require 'percy/capybara/client/snapshots'
3
+ require 'percy/capybara/loaders/native_loader'
4
+ require 'percy/capybara/loaders/sprockets_loader'
3
5
 
4
6
  module Percy
5
7
  module Capybara
@@ -13,15 +15,33 @@ module Percy
13
15
 
14
16
  attr_reader :client
15
17
 
18
+ attr_accessor :sprockets_environment
19
+ attr_accessor :sprockets_options
20
+
16
21
  def initialize(options = {})
17
22
  @client = options[:client] || Percy.client
18
23
  @enabled = options[:enabled]
24
+
25
+ if defined?(Rails)
26
+ @sprockets_environment = options[:sprockets_environment] || Rails.application.assets
27
+ @sprockets_options = options[:sprockets_options] || Rails.application.config.assets
28
+ end
19
29
  end
20
30
 
21
31
  def enabled?
22
32
  # Only enable if in supported CI environment or local dev with PERCY_ENABLE=1.
23
33
  @enabled ||= !Percy::Client::Environment.current_ci.nil? || ENV['PERCY_ENABLE'] == '1'
24
34
  end
35
+
36
+ def initialize_loader(options = {})
37
+ if sprockets_environment && sprockets_options
38
+ options[:sprockets_environment] = sprockets_environment
39
+ options[:sprockets_options] = sprockets_options
40
+ Percy::Capybara::Loaders::SprocketsLoader.new(options)
41
+ else
42
+ Percy::Capybara::Loaders::NativeLoader.new(options)
43
+ end
44
+ end
25
45
  end
26
46
  end
27
47
  end
@@ -2,12 +2,32 @@ module Percy
2
2
  module Capybara
3
3
  class Client
4
4
  module Builds
5
- def current_build
5
+ def current_build(options = {})
6
6
  return if !enabled? # Silently skip if the client is disabled.
7
- @current_build ||= client.create_build(client.config.repo)
7
+ @current_build ||= client.create_build(client.config.repo, options)
8
8
  end
9
9
  alias_method :initialize_build, :current_build
10
10
 
11
+ def upload_missing_build_resources(build_resources)
12
+ # Upload any missing build resources.
13
+ new_build_resources = current_build['data'] &&
14
+ current_build['data']['relationships'] &&
15
+ current_build['data']['relationships']['missing-resources'] &&
16
+ current_build['data']['relationships']['missing-resources']['data']
17
+ return 0 if !new_build_resources
18
+
19
+ if !new_build_resources.empty?
20
+ puts "[percy] Uploading #{new_build_resources.length} new resources..."
21
+ end
22
+ new_build_resources.each do |missing_resource|
23
+ sha = missing_resource['id']
24
+ resource = build_resources.find { |r| r.sha == sha }
25
+ content = resource.content || File.read(resource.path)
26
+ client.upload_resource(current_build['data']['id'], content)
27
+ end
28
+ new_build_resources.length
29
+ end
30
+
11
31
  def build_initialized?
12
32
  !!@current_build
13
33
  end
@@ -2,48 +2,13 @@ require 'set'
2
2
  require 'faraday'
3
3
  require 'httpclient'
4
4
  require 'digest'
5
+ require 'uri'
6
+ require 'pathname'
5
7
 
6
8
  module Percy
7
9
  module Capybara
8
10
  class Client
9
11
  module Snapshots
10
- # Modified version of Diego Perini's URL regex. https://gist.github.com/dperini/729294
11
- URL_REGEX = Regexp.new(
12
- # protocol identifier
13
- "(?:(?:https?:)?//)" +
14
- "(" +
15
- # IP address exclusion
16
- # private & local networks
17
- "(?!(?:10|127)(?:\\.\\d{1,3}){3})" +
18
- "(?!(?:169\\.254|192\\.168)(?:\\.\\d{1,3}){2})" +
19
- "(?!172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})" +
20
- # IP address dotted notation octets
21
- # excludes loopback network 0.0.0.0
22
- # excludes reserved space >= 224.0.0.0
23
- # excludes network & broacast addresses
24
- # (first & last IP address of each class)
25
- "(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])" +
26
- "(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}" +
27
- "(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))" +
28
- "|" +
29
- # host name
30
- "(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)" +
31
- # domain name
32
- "(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*" +
33
- ")" +
34
- # port number
35
- "(?::\\d{2,5})?" +
36
- # resource path
37
- "(?:/[^\\s\"']*)?"
38
- )
39
- PATH_REGEX = /\A\/[^\\s\"']*/
40
- DATA_URL_REGEX = /\Adata:/
41
- LOCAL_HOSTNAMES = [
42
- 'localhost',
43
- '127.0.0.1',
44
- '0.0.0.0',
45
- ].freeze
46
-
47
12
  # Takes a snapshot of the given page HTML and its assets.
48
13
  #
49
14
  # @param [Capybara::Session] page The Capybara page to snapshot.
@@ -54,11 +19,22 @@ module Percy
54
19
  def snapshot(page, options = {})
55
20
  return if !enabled? # Silently skip if the client is disabled.
56
21
  name = options[:name]
22
+ loader = initialize_loader(page: page)
23
+
24
+ # If this is the first snapshot, create the build and upload build resources.
25
+ if !build_initialized?
26
+ build_resources = loader.build_resources
27
+ initialize_build(resources: build_resources)
28
+ upload_missing_build_resources(build_resources)
29
+ end
30
+
57
31
  current_build_id = current_build['data']['id']
58
- resource_map = _find_resources(page)
59
- snapshot = client.create_snapshot(current_build_id, resource_map.values, name: name)
32
+ resources = loader.snapshot_resources
33
+ resource_map = {}
34
+ resources.each { |r| resource_map[r.sha] = r }
60
35
 
61
- # Upload the content for any missing resources.
36
+ # Create the snapshot and upload any missing snapshot resources.
37
+ snapshot = client.create_snapshot(current_build_id, resources, name: name)
62
38
  snapshot['data']['relationships']['missing-resources']['data'].each do |missing_resource|
63
39
  sha = missing_resource['id']
64
40
  client.upload_resource(current_build_id, resource_map[sha].content)
@@ -69,204 +45,6 @@ module Percy
69
45
 
70
46
  true
71
47
  end
72
-
73
- # @private
74
- def _find_resources(page)
75
- resource_map = {}
76
- resources = []
77
- resources << _get_root_html_resource(page)
78
- resources += _get_css_resources(page)
79
- resources += _get_image_resources(page)
80
- resources.each { |resource| resource_map[resource.sha] = resource }
81
- resource_map
82
- end
83
- private :_find_resources
84
-
85
- # @private
86
- def _get_root_html_resource(page)
87
- # Primary HTML.
88
- script = <<-JS
89
- var htmlElement = document.getElementsByTagName('html')[0];
90
- return htmlElement.outerHTML;
91
- JS
92
- html = _evaluate_script(page, script)
93
- resource_url = page.current_url
94
- Percy::Client::Resource.new(
95
- resource_url, is_root: true, mimetype: 'text/html', content: html)
96
- end
97
- private :_get_root_html_resource
98
-
99
- # @private
100
- def _get_css_resources(page)
101
- resources = []
102
- # Find all CSS resources.
103
- # http://www.quirksmode.org/dom/w3c_css.html#access
104
- script = <<-JS
105
- function findStylesRecursively(stylesheet, css_urls) {
106
- if (stylesheet.href) { // Skip stylesheet without hrefs (inline stylesheets).
107
- css_urls.push(stylesheet.href);
108
-
109
- // Remote stylesheet rules cannot be accessed because of the same-origin policy.
110
- // Unfortunately, if you touch .cssRules in Selenium, it throws a JavascriptError
111
- // with 'The operation is insecure'. To work around this, skip reading rules of
112
- // remote stylesheets but still include them for fetching.
113
- //
114
- // TODO: If a remote stylesheet has an @import, it will be missing because we don't
115
- // notice it here. We could potentially recursively fetch remote imports in
116
- // ruby-land below.
117
- var parser = document.createElement('a');
118
- parser.href = stylesheet.href;
119
- if (parser.host != window.location.host) {
120
- return;
121
- }
122
- }
123
- for (var i = 0; i < stylesheet.cssRules.length; i++) {
124
- var rule = stylesheet.cssRules[i];
125
- // Depth-first search, handle recursive @imports.
126
- if (rule.styleSheet) {
127
- findStylesRecursively(rule.styleSheet, css_urls);
128
- }
129
- }
130
- }
131
-
132
- var css_urls = [];
133
- for (var i = 0; i < document.styleSheets.length; i++) {
134
- findStylesRecursively(document.styleSheets[i], css_urls);
135
- }
136
- return css_urls;
137
- JS
138
- resource_urls = _evaluate_script(page, script)
139
-
140
- resource_urls.each do |url|
141
- next if !_should_include_url?(url)
142
- response = _fetch_resource_url(url)
143
- next if !response
144
- sha = Digest::SHA256.hexdigest(response.body)
145
- resources << Percy::Client::Resource.new(
146
- url, mimetype: 'text/css', content: response.body)
147
- end
148
- resources
149
- end
150
- private :_get_css_resources
151
-
152
- # @private
153
- def _get_image_resources(page)
154
- resources = []
155
- image_urls = Set.new
156
-
157
- # Find all image tags on the page.
158
- page.all('img').each do |image_element|
159
- srcs = []
160
- srcs << image_element[:src] if !image_element[:src].nil?
161
-
162
- srcset_raw_urls = image_element[:srcset] || ''
163
- temp_urls = srcset_raw_urls.split(',')
164
- temp_urls.each do |temp_url|
165
- srcs << temp_url.split(' ').first
166
- end
167
-
168
- srcs.each do |url|
169
- image_urls << url
170
- end
171
- end
172
-
173
- # Find all CSS-loaded images which set a background-image.
174
- script = <<-JS
175
- var raw_image_urls = [];
176
-
177
- var tags = document.getElementsByTagName('*');
178
- var el;
179
- var rawValue;
180
-
181
- for (var i = 0; i < tags.length; i++) {
182
- el = tags[i];
183
- if (el.currentStyle) {
184
- rawValue = el.currentStyle['backgroundImage'];
185
- } else if (window.getComputedStyle) {
186
- rawValue = window.getComputedStyle(el).getPropertyValue('background-image');
187
- }
188
- if (!rawValue || rawValue === "none") {
189
- continue;
190
- } else {
191
- raw_image_urls.push(rawValue);
192
- }
193
- }
194
- return raw_image_urls;
195
- JS
196
- raw_image_urls = _evaluate_script(page, script)
197
- raw_image_urls.each do |raw_image_url|
198
- temp_urls = raw_image_url.scan(/url\(["']?(.*?)["']?\)/)
199
- # background-image can accept multiple url()s, so temp_urls is an array of URLs.
200
- temp_urls.each do |temp_url|
201
- url = temp_url[0]
202
- image_urls << url
203
- end
204
- end
205
-
206
- image_urls.each do |image_url|
207
- # If url references are blank, browsers will often fill them with the current page's
208
- # URL, which makes no sense and will never be renderable. Strip these.
209
- next if image_url == page.current_url || image_url.strip.empty?
210
-
211
- # Make the resource URL absolute to the current page. If it is already absolute, this
212
- # will have no effect.
213
- resource_url = URI.join(page.current_url, image_url).to_s
214
-
215
- next if !_should_include_url?(resource_url)
216
-
217
- # Fetch the images.
218
- # TODO(fotinakis): this can be pretty inefficient for image-heavy pages because the
219
- # browser has already loaded them once and this fetch cannot easily leverage the
220
- # browser's cache. However, often these images are probably local resources served by a
221
- # development server, so it may not be so bad. Re-evaluate if this becomes an issue.
222
- response = _fetch_resource_url(resource_url)
223
- next if !response
224
-
225
- sha = Digest::SHA256.hexdigest(response.body)
226
- resources << Percy::Client::Resource.new(
227
- resource_url, mimetype: response.content_type, content: response.body)
228
- end
229
- resources
230
- end
231
- private :_get_image_resources
232
-
233
- # @private
234
- def _should_include_url?(url)
235
- # It is a URL or a path, but not a data URI.
236
- url_match = URL_REGEX.match(url)
237
- data_url_match = DATA_URL_REGEX.match(url)
238
- result = (url_match || PATH_REGEX.match(url)) && !data_url_match
239
-
240
- # Is not a remote URL.
241
- if url_match && !data_url_match
242
- host = url_match[1]
243
- result = LOCAL_HOSTNAMES.include?(host)
244
- end
245
-
246
- !!result
247
- end
248
-
249
- # @private
250
- def _fetch_resource_url(url)
251
- response = Percy::Capybara::HttpFetcher.fetch(url)
252
- if !response
253
- STDERR.puts "[percy] Warning: failed to fetch page resource, this might be a bug: #{url}"
254
- return nil
255
- end
256
- response
257
- end
258
- private :_fetch_resource_url
259
-
260
- # @private
261
- def _evaluate_script(page, script)
262
- script = <<-JS
263
- (function() {
264
- #{script}
265
- })();
266
- JS
267
- page.evaluate_script(script)
268
- end
269
- private :_evaluate_script
270
48
  end
271
49
  end
272
50
  end
@@ -0,0 +1,71 @@
1
+ module Percy
2
+ module Capybara
3
+ module Loaders
4
+ class BaseLoader
5
+ # Modified version of Diego Perini's URL regex. https://gist.github.com/dperini/729294
6
+ URL_REGEX = Regexp.new(
7
+ # protocol identifier
8
+ "((?:https?:)?//)" +
9
+ "(" +
10
+ # IP address exclusion
11
+ # private & local networks
12
+ "(?!(?:10|127)(?:\\.\\d{1,3}){3})" +
13
+ "(?!(?:169\\.254|192\\.168)(?:\\.\\d{1,3}){2})" +
14
+ "(?!172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})" +
15
+ # IP address dotted notation octets
16
+ # excludes loopback network 0.0.0.0
17
+ # excludes reserved space >= 224.0.0.0
18
+ # excludes network & broacast addresses
19
+ # (first & last IP address of each class)
20
+ "(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])" +
21
+ "(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}" +
22
+ "(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))" +
23
+ "|" +
24
+ # host name
25
+ "(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)" +
26
+ # domain name
27
+ "(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*" +
28
+ ")" +
29
+ # port number
30
+ "(:\\d{2,5})?" +
31
+ # resource path
32
+ "(/[^\\s\"']*)?"
33
+ )
34
+
35
+ attr_reader :page
36
+
37
+ # @param [Capybara::Session] page The Capybara page.
38
+ def initialize(options = {})
39
+ @page = options[:page]
40
+ end
41
+
42
+ def build_resources
43
+ raise NotImplementedError.new('subclass must implement abstract method')
44
+ end
45
+
46
+ def snapshot_resources
47
+ raise NotImplementedError.new('subclass must implement abstract method')
48
+ end
49
+
50
+ # @private
51
+ def root_html_resource
52
+ Percy::Client::Resource.new(
53
+ current_path, is_root: true, mimetype: 'text/html', content: page.html)
54
+ end
55
+
56
+ # Transformed version of the current URL to be a relative path.
57
+ # This important because Rack::Test uses "www.example.com" as the actual current URL,
58
+ # which would force Percy to actually render example.com instead of the page. By always
59
+ # using a URL path as the resource URL, we guarantee that Percy will render what it's given.
60
+ #
61
+ # @private
62
+ def current_path
63
+ current_url = page.current_url
64
+ url_match = URL_REGEX.match(current_url)
65
+ return url_match[4] if url_match
66
+ current_url
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end