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 +4 -4
- data/lib/percy/capybara.rb +2 -2
- data/lib/percy/capybara/client.rb +20 -0
- data/lib/percy/capybara/client/builds.rb +22 -2
- data/lib/percy/capybara/client/snapshots.rb +16 -238
- data/lib/percy/capybara/loaders/base_loader.rb +71 -0
- data/lib/percy/capybara/loaders/native_loader.rb +209 -0
- data/lib/percy/capybara/loaders/sprockets_loader.rb +50 -0
- data/lib/percy/capybara/rspec.rb +5 -0
- data/lib/percy/capybara/version.rb +1 -1
- data/percy-capybara.gemspec +2 -1
- data/spec/lib/percy/capybara/client/builds_spec.rb +50 -1
- data/spec/lib/percy/capybara/client/snapshots_spec.rb +31 -220
- data/spec/lib/percy/capybara/client/testdata/index.html +1 -1
- data/spec/lib/percy/capybara/client_spec.rb +13 -0
- data/spec/lib/percy/capybara/loaders/base_loader_spec.rb +52 -0
- data/spec/lib/percy/capybara/loaders/native_loader_spec.rb +224 -0
- data/spec/lib/percy/capybara/loaders/sprockets_loader_spec.rb +58 -0
- data/spec/lib/percy/capybara_spec.rb +0 -1
- data/spec/spec_helper.rb +26 -0
- data/spec/support/test_helpers.rb +21 -0
- metadata +30 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 0373826717cf4a83f083c9d8d71d92dfea1b595c
|
4
|
+
data.tar.gz: e32236928c98afe4613579a5a9db5869ea506f90
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: bbc8b68ae8f7df03bc575f64cd6efa4c9bcb7bfbde16e478735e57d43b1e5d3bb64bd7e380db3025f81bc8c3bfdd7d8f0283b21da075258f98357ac94c9a15d9
|
7
|
+
data.tar.gz: 053c1512eba240ff18904cb7790f5aa8093cc8356cfe086665be2b241ec9291679d2fb2d69a09ed790e055ee8f075ba652be3a26bed671ad23ffb2ec90eac5b6
|
data/lib/percy/capybara.rb
CHANGED
@@ -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
|
-
|
59
|
-
|
32
|
+
resources = loader.snapshot_resources
|
33
|
+
resource_map = {}
|
34
|
+
resources.each { |r| resource_map[r.sha] = r }
|
60
35
|
|
61
|
-
#
|
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
|