percy-capybara 0.3.0 → 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|