happo 1.0.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 +7 -0
- data/bin/happo +60 -0
- data/lib/happo/action.rb +33 -0
- data/lib/happo/diffs.html.erb +29 -0
- data/lib/happo/logger.rb +40 -0
- data/lib/happo/public/happo-runner.js +223 -0
- data/lib/happo/public/happo-styles.css +8 -0
- data/lib/happo/runner.rb +215 -0
- data/lib/happo/server.rb +68 -0
- data/lib/happo/snapshot_comparer.rb +91 -0
- data/lib/happo/snapshot_comparison_image/after.rb +21 -0
- data/lib/happo/snapshot_comparison_image/base.rb +108 -0
- data/lib/happo/snapshot_comparison_image/before.rb +21 -0
- data/lib/happo/snapshot_comparison_image/gutter.rb +34 -0
- data/lib/happo/snapshot_comparison_image/overlayed.rb +88 -0
- data/lib/happo/uploader.rb +70 -0
- data/lib/happo/utils.rb +81 -0
- data/lib/happo/version.rb +4 -0
- data/lib/happo/views/debug.erb +29 -0
- data/lib/happo/views/index.erb +27 -0
- data/lib/happo/views/review.erb +37 -0
- data/lib/happo.rb +6 -0
- metadata +191 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 73690fa08fbd1338fc72d65f287329375b2a57ef
|
4
|
+
data.tar.gz: 8ff2614642f52154b7dbf7faccb75f01d98acdd5
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: aa3ca88236618c91bad015f6d44188bf8799a1b5952d1f1e2c10745d2fcf425911605d089785bd6f1527d0b242615200d85ea7e076c4942379ef2ba8f8d6489c
|
7
|
+
data.tar.gz: ad36897001417b3dfe313e2c6ebf3448b9a6c42064c23ccf14a571c16c290505ec4d8ae2387aa4c7642b99e27431eb11eb74f1c7098e1d85232cbed65c01a203
|
data/bin/happo
ADDED
@@ -0,0 +1,60 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'happo'
|
4
|
+
require 'fileutils'
|
5
|
+
|
6
|
+
help_text = <<-EOS
|
7
|
+
Commands:
|
8
|
+
run (default)
|
9
|
+
debug
|
10
|
+
review
|
11
|
+
clean
|
12
|
+
approve
|
13
|
+
reject
|
14
|
+
upload_diffs
|
15
|
+
--help
|
16
|
+
--version
|
17
|
+
EOS
|
18
|
+
|
19
|
+
action = ARGV[0] || 'run'
|
20
|
+
case action
|
21
|
+
when 'run'
|
22
|
+
Thread.abort_on_exception = true
|
23
|
+
Thread.new do
|
24
|
+
require 'happo/runner'
|
25
|
+
exit
|
26
|
+
end
|
27
|
+
require 'happo/server'
|
28
|
+
|
29
|
+
when 'debug'
|
30
|
+
system 'open', Happo::Utils.construct_url('/debug')
|
31
|
+
require 'happo/server'
|
32
|
+
|
33
|
+
when 'review'
|
34
|
+
system 'open', Happo::Utils.construct_url('/review')
|
35
|
+
require 'happo/server'
|
36
|
+
|
37
|
+
when 'clean'
|
38
|
+
if File.directory? Happo::Utils.config['snapshots_folder']
|
39
|
+
FileUtils.remove_entry_secure Happo::Utils.config['snapshots_folder']
|
40
|
+
end
|
41
|
+
|
42
|
+
when 'approve', 'reject'
|
43
|
+
example_description = ARGV[1]
|
44
|
+
abort 'Missing example description' unless example_description
|
45
|
+
viewport_name = ARGV[2]
|
46
|
+
abort 'Missing viewport name' unless viewport_name
|
47
|
+
Happo::Action.new(example_description, viewport_name).send(action)
|
48
|
+
|
49
|
+
when 'upload_diffs'
|
50
|
+
# `upload_diffs` returns a URL to a static html file
|
51
|
+
puts Happo::Uploader.new.upload_diffs
|
52
|
+
|
53
|
+
when '--version'
|
54
|
+
puts "happo version #{Happo::VERSION}"
|
55
|
+
|
56
|
+
when '--help'
|
57
|
+
puts help_text
|
58
|
+
else
|
59
|
+
abort "Unknown action \"#{action}\"\n\n#{help_text}"
|
60
|
+
end
|
data/lib/happo/action.rb
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
require 'happo/utils'
|
2
|
+
require 'fileutils'
|
3
|
+
|
4
|
+
module Happo
|
5
|
+
class Action
|
6
|
+
def initialize(example_description, viewport_name)
|
7
|
+
@example_description = example_description
|
8
|
+
@viewport_name = viewport_name
|
9
|
+
end
|
10
|
+
|
11
|
+
def approve
|
12
|
+
diff_path = Happo::Utils.path_to(
|
13
|
+
@example_description, @viewport_name, 'diff.png')
|
14
|
+
baseline_path = Happo::Utils.path_to(
|
15
|
+
@example_description, @viewport_name, 'baseline.png')
|
16
|
+
candidate_path = Happo::Utils.path_to(
|
17
|
+
@example_description, @viewport_name, 'candidate.png')
|
18
|
+
|
19
|
+
FileUtils.rm(diff_path, force: true)
|
20
|
+
FileUtils.mv(candidate_path, baseline_path) if File.exist? candidate_path
|
21
|
+
end
|
22
|
+
|
23
|
+
def reject
|
24
|
+
diff_path = Happo::Utils.path_to(
|
25
|
+
@example_description, @viewport_name, 'diff.png')
|
26
|
+
candidate_path = Happo::Utils.path_to(
|
27
|
+
@example_description, @viewport_name, 'candidate.png')
|
28
|
+
|
29
|
+
FileUtils.rm(diff_path, force: true)
|
30
|
+
FileUtils.rm(candidate_path, force: true)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
<% require 'rack' %>
|
2
|
+
<!DOCTYPE html>
|
3
|
+
<html>
|
4
|
+
<head>
|
5
|
+
<title>Happo diffs</title>
|
6
|
+
</head>
|
7
|
+
<body>
|
8
|
+
<h1>Happo diffs</h1>
|
9
|
+
<p>File generated: <%= Time.now %></p>
|
10
|
+
|
11
|
+
<h2>Diffs</h2>
|
12
|
+
<% diff_images.each do |diff| %>
|
13
|
+
<h3>
|
14
|
+
<%= Rack::Utils.escape_html(diff[:description]) %> @ <%= diff[:viewport] %>
|
15
|
+
</h3>
|
16
|
+
<p><img src="<%= diff[:url] %>"></p>
|
17
|
+
<% end %>
|
18
|
+
|
19
|
+
<hr>
|
20
|
+
|
21
|
+
<h2>New examples</h2>
|
22
|
+
<% new_images.each do |image| %>
|
23
|
+
<h3>
|
24
|
+
<%= Rack::Utils.escape_html(image[:description]) %> @ <%= image[:viewport] %>
|
25
|
+
</h3>
|
26
|
+
<p><img src="<%= image[:url] %>"></p>
|
27
|
+
<% end %>
|
28
|
+
</body>
|
29
|
+
</html>
|
data/lib/happo/logger.rb
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
module Happo
|
2
|
+
# Used for all CLI output
|
3
|
+
class Logger
|
4
|
+
# @param out [IO] the output destination
|
5
|
+
def initialize(out = STDOUT)
|
6
|
+
@out = out
|
7
|
+
end
|
8
|
+
|
9
|
+
# Print the specified output
|
10
|
+
# @param str [String] the output to send
|
11
|
+
# @param newline [Boolean] whether to append a newline
|
12
|
+
def log(str, newline = true)
|
13
|
+
@out.print(str)
|
14
|
+
@out.print("\n") if newline
|
15
|
+
end
|
16
|
+
|
17
|
+
# Mark the string in cyan
|
18
|
+
# @param str [String] the str to format
|
19
|
+
def cyan(str)
|
20
|
+
color(36, str)
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
# Whether this logger is outputting to a TTY
|
26
|
+
#
|
27
|
+
# @return [Boolean]
|
28
|
+
def tty?
|
29
|
+
@out.respond_to?(:tty?) && @out.tty?
|
30
|
+
end
|
31
|
+
|
32
|
+
# Mark the string in a color
|
33
|
+
# @see http://ascii-table.com/ansi-escape-sequences.php
|
34
|
+
# @param color_code [Number] the ANSI color code
|
35
|
+
# @param str [String] the str to format
|
36
|
+
def color(color_code, str)
|
37
|
+
tty? ? str : "\033[#{color_code}m#{str}\033[0m"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,223 @@
|
|
1
|
+
'use strict';
|
2
|
+
|
3
|
+
window.happo = {
|
4
|
+
defined: {},
|
5
|
+
fdefined: [],
|
6
|
+
currentRenderedElement: undefined,
|
7
|
+
errors: [],
|
8
|
+
|
9
|
+
define: function(description, func, options) {
|
10
|
+
// Make sure we don't have a duplicate description
|
11
|
+
if (this.defined[description]) {
|
12
|
+
throw 'Error while defining "' + description +
|
13
|
+
'": Duplicate description detected'
|
14
|
+
}
|
15
|
+
this.defined[description] = {
|
16
|
+
description: description,
|
17
|
+
func: func,
|
18
|
+
options: options || {}
|
19
|
+
};
|
20
|
+
},
|
21
|
+
|
22
|
+
fdefine: function(description, func, options) {
|
23
|
+
this.define(description, func, options); // add the example
|
24
|
+
this.fdefined.push(description);
|
25
|
+
},
|
26
|
+
|
27
|
+
/**
|
28
|
+
* @return {Array.<Object>}
|
29
|
+
*/
|
30
|
+
getAllExamples: function() {
|
31
|
+
var descriptions = this.fdefined.length ?
|
32
|
+
this.fdefined :
|
33
|
+
Object.keys(this.defined);
|
34
|
+
|
35
|
+
return descriptions.map(function(description) {
|
36
|
+
var example = this.defined[description];
|
37
|
+
// We return a subset of the properties of an example (only those relevant
|
38
|
+
// for happo_runner.rb).
|
39
|
+
return {
|
40
|
+
description: example.description,
|
41
|
+
options: example.options,
|
42
|
+
};
|
43
|
+
}.bind(this));
|
44
|
+
},
|
45
|
+
|
46
|
+
handleError: function(currentExample, error) {
|
47
|
+
console.error(error.stack);
|
48
|
+
return {
|
49
|
+
description: currentExample.description,
|
50
|
+
error: error.message
|
51
|
+
};
|
52
|
+
},
|
53
|
+
|
54
|
+
/**
|
55
|
+
* @param {Function} func The happo.describe function from the current
|
56
|
+
* example being rendered. This function takes a callback as an argument
|
57
|
+
* that is called when it is done.
|
58
|
+
* @return {Promise}
|
59
|
+
*/
|
60
|
+
tryAsync: function(func) {
|
61
|
+
return new Promise(function(resolve, reject) {
|
62
|
+
// Safety valve: if the function does not finish after 3s, then something
|
63
|
+
// went haywire and we need to move on.
|
64
|
+
var timeout = setTimeout(function() {
|
65
|
+
reject(new Error('Async callback was not invoked within timeout.'));
|
66
|
+
}, 3000);
|
67
|
+
|
68
|
+
// This function is called by the example when it is done executing.
|
69
|
+
var doneCallback = function(elem) {
|
70
|
+
clearTimeout(timeout);
|
71
|
+
|
72
|
+
if (!arguments.length) {
|
73
|
+
return reject(new Error(
|
74
|
+
'The async done callback expects the rendered element as an ' +
|
75
|
+
'argument, but there were no arguments.'
|
76
|
+
));
|
77
|
+
}
|
78
|
+
|
79
|
+
resolve(elem);
|
80
|
+
};
|
81
|
+
|
82
|
+
func(doneCallback);
|
83
|
+
});
|
84
|
+
},
|
85
|
+
|
86
|
+
/**
|
87
|
+
* Clean up the DOM for a rendered element that has already been processed.
|
88
|
+
* This can be overridden by consumers to define their own clean out method,
|
89
|
+
* which can allow for this to be used to unmount React components, for
|
90
|
+
* example.
|
91
|
+
*
|
92
|
+
* @param {Object} renderedElement
|
93
|
+
*/
|
94
|
+
cleanOutElement: function(renderedElement) {
|
95
|
+
renderedElement.parentNode.removeChild(renderedElement);
|
96
|
+
},
|
97
|
+
|
98
|
+
/**
|
99
|
+
* This function is called from Ruby asynchronously. Therefore, we need to
|
100
|
+
* call doneFunc when the method has completed so that Ruby knows to continue.
|
101
|
+
*
|
102
|
+
* @param {String} exampleDescription
|
103
|
+
* @param {Function} doneFunc injected by driver.execute_async_script in
|
104
|
+
* happo/runner.rb
|
105
|
+
*/
|
106
|
+
renderExample: function(exampleDescription, doneFunc) {
|
107
|
+
try {
|
108
|
+
var currentExample = this.defined[exampleDescription];
|
109
|
+
if (!currentExample) {
|
110
|
+
throw new Error(
|
111
|
+
'No example found with description "' + exampleDescription + '"');
|
112
|
+
}
|
113
|
+
|
114
|
+
// Clear out the body of the document
|
115
|
+
if (this.currentRenderedElement) {
|
116
|
+
this.cleanOutElement(this.currentRenderedElement);
|
117
|
+
}
|
118
|
+
while (document.body.firstChild) {
|
119
|
+
document.body.removeChild(document.body.firstChild);
|
120
|
+
}
|
121
|
+
|
122
|
+
var func = currentExample.func;
|
123
|
+
if (func.length) {
|
124
|
+
// The function takes an argument, which is a callback that is called
|
125
|
+
// once it is done executing. This can be used to write functions that
|
126
|
+
// have asynchronous code in them.
|
127
|
+
this.tryAsync(func).then(function(elem) {
|
128
|
+
doneFunc(this.processElem(currentExample, elem));
|
129
|
+
}.bind(this)).catch(function(error) {
|
130
|
+
doneFunc(this.handleError(currentExample, error));
|
131
|
+
}.bind(this));
|
132
|
+
} else {
|
133
|
+
// The function does not take an argument, so we can run it
|
134
|
+
// synchronously.
|
135
|
+
var result = func();
|
136
|
+
|
137
|
+
if (result instanceof Promise) {
|
138
|
+
// The function returned a promise, so we need to wait for it to
|
139
|
+
// resolve before proceeding.
|
140
|
+
result.then(function(elem) {
|
141
|
+
doneFunc(this.processElem(currentExample, elem));
|
142
|
+
}.bind(this)).catch(function(error) {
|
143
|
+
doneFunc(this.handleError(currentExample, error));
|
144
|
+
}.bind(this));
|
145
|
+
} else {
|
146
|
+
// The function did not return a promise, so we assume it gave us an
|
147
|
+
// element that we can process immediately.
|
148
|
+
doneFunc(this.processElem(currentExample, result));
|
149
|
+
}
|
150
|
+
}
|
151
|
+
} catch (error) {
|
152
|
+
doneFunc(this.handleError(currentExample, error));
|
153
|
+
}
|
154
|
+
},
|
155
|
+
|
156
|
+
processElem: function(currentExample, elem) {
|
157
|
+
try {
|
158
|
+
this.currentRenderedElement = elem;
|
159
|
+
|
160
|
+
var rect;
|
161
|
+
if (currentExample.options.snapshotEntireScreen) {
|
162
|
+
rect = {
|
163
|
+
width: window.innerWidth,
|
164
|
+
height: window.innerHeight,
|
165
|
+
top: 0,
|
166
|
+
left: 0,
|
167
|
+
};
|
168
|
+
} else {
|
169
|
+
// We use elem.getBoundingClientRect() instead of offsetTop and its ilk
|
170
|
+
// because elem.getBoundingClientRect() is more accurate and it also
|
171
|
+
// takes CSS transformations and other things of that nature into
|
172
|
+
// account whereas offsetTop and company do not.
|
173
|
+
//
|
174
|
+
// Note that this method returns floats, so we need to round those off
|
175
|
+
// to integers before returning.
|
176
|
+
rect = elem.getBoundingClientRect();
|
177
|
+
}
|
178
|
+
|
179
|
+
return {
|
180
|
+
description: currentExample.description,
|
181
|
+
width: Math.ceil(rect.width),
|
182
|
+
height: Math.ceil(rect.height),
|
183
|
+
top: Math.floor(rect.top),
|
184
|
+
left: Math.floor(rect.left),
|
185
|
+
};
|
186
|
+
} catch (error) {
|
187
|
+
return this.handleError(currentExample, error);
|
188
|
+
}
|
189
|
+
}
|
190
|
+
};
|
191
|
+
|
192
|
+
window.addEventListener('load', function() {
|
193
|
+
var matches = window.location.search.match(/description=([^&]*)/);
|
194
|
+
if (!matches) {
|
195
|
+
return;
|
196
|
+
}
|
197
|
+
var example = decodeURIComponent(matches[1]);
|
198
|
+
window.happo.renderExample(example, function() {});
|
199
|
+
});
|
200
|
+
|
201
|
+
// We need to redefine a few global functions that halt execution. Without this,
|
202
|
+
// there's a chance that the Ruby code can't communicate with the browser.
|
203
|
+
window.alert = function(message) {
|
204
|
+
console.log('`window.alert` called', message);
|
205
|
+
};
|
206
|
+
|
207
|
+
window.confirm = function(message) {
|
208
|
+
console.log('`window.confirm` called', message);
|
209
|
+
return true;
|
210
|
+
};
|
211
|
+
|
212
|
+
window.prompt = function(message, value) {
|
213
|
+
console.log('`window.prompt` called', message, value);
|
214
|
+
return null;
|
215
|
+
};
|
216
|
+
|
217
|
+
window.onerror = function(message, url, lineNumber) {
|
218
|
+
window.happo.errors.push({
|
219
|
+
message: message,
|
220
|
+
url: url,
|
221
|
+
lineNumber: lineNumber
|
222
|
+
});
|
223
|
+
}
|
data/lib/happo/runner.rb
ADDED
@@ -0,0 +1,215 @@
|
|
1
|
+
require 'selenium-webdriver'
|
2
|
+
require 'oily_png'
|
3
|
+
require 'happo'
|
4
|
+
require 'fileutils'
|
5
|
+
require 'yaml'
|
6
|
+
|
7
|
+
def resolve_viewports(example)
|
8
|
+
configured_viewports = Happo::Utils.config['viewports']
|
9
|
+
|
10
|
+
viewports =
|
11
|
+
example['options']['viewports'] || [configured_viewports.first.first]
|
12
|
+
|
13
|
+
viewports.map do |viewport|
|
14
|
+
configured_viewports[viewport].merge('name' => viewport)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def init_driver
|
19
|
+
tries = 0
|
20
|
+
begin
|
21
|
+
driver = Selenium::WebDriver.for Happo::Utils.config['driver'].to_sym
|
22
|
+
rescue Selenium::WebDriver::Error::WebDriverError => e
|
23
|
+
# "unable to obtain stable firefox connection in 60 seconds"
|
24
|
+
#
|
25
|
+
# This seems to happen sporadically for some versions of Firefox, so we want
|
26
|
+
# to retry a couple of times it in case it will work the second time around.
|
27
|
+
tries += 1
|
28
|
+
retry if tries <= 3
|
29
|
+
raise e
|
30
|
+
end
|
31
|
+
|
32
|
+
driver.manage.timeouts.script_timeout = 3 # move to config?
|
33
|
+
|
34
|
+
driver
|
35
|
+
end
|
36
|
+
|
37
|
+
log = Happo::Logger.new(STDOUT)
|
38
|
+
driver = init_driver
|
39
|
+
|
40
|
+
begin
|
41
|
+
driver.navigate.to Happo::Utils.construct_url('/')
|
42
|
+
|
43
|
+
# Check for errors during startup
|
44
|
+
errors = driver.execute_script('return window.happo.errors;')
|
45
|
+
unless errors.empty?
|
46
|
+
fail "JavaScript errors found during initialization: \n#{errors.inspect}"
|
47
|
+
end
|
48
|
+
|
49
|
+
# Initialize a hash to store a summary of the results from the run
|
50
|
+
result_summary = {
|
51
|
+
new_examples: [],
|
52
|
+
diff_examples: [],
|
53
|
+
okay_examples: []
|
54
|
+
}
|
55
|
+
|
56
|
+
all_examples = driver.execute_script('return window.happo.getAllExamples()')
|
57
|
+
|
58
|
+
# To avoid the overhead of resizing the window all the time, we are going to
|
59
|
+
# render all examples for each given viewport size all in one go.
|
60
|
+
examples_by_viewport = {}
|
61
|
+
|
62
|
+
all_examples.each do |example|
|
63
|
+
viewports = resolve_viewports(example)
|
64
|
+
|
65
|
+
viewports.each do |viewport|
|
66
|
+
examples_by_viewport[viewport['name']] ||= {}
|
67
|
+
examples_by_viewport[viewport['name']][:viewport] ||= viewport
|
68
|
+
examples_by_viewport[viewport['name']][:examples] ||= []
|
69
|
+
|
70
|
+
examples_by_viewport[viewport['name']][:examples] << example
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
examples_by_viewport.each do |_, example_by_viewport|
|
75
|
+
viewport = example_by_viewport[:viewport]
|
76
|
+
examples = example_by_viewport[:examples]
|
77
|
+
|
78
|
+
log.log "#{viewport['name']} (#{viewport['width']}x#{viewport['height']})"
|
79
|
+
|
80
|
+
# Resize window to the right size before rendering
|
81
|
+
driver.manage.window.resize_to(viewport['width'], viewport['height'])
|
82
|
+
|
83
|
+
examples.each do |example|
|
84
|
+
if example == examples.last
|
85
|
+
log.log '└─ ', false
|
86
|
+
else
|
87
|
+
log.log '├─ ', false
|
88
|
+
end
|
89
|
+
description = example['description']
|
90
|
+
log.log " #{description} ", false
|
91
|
+
|
92
|
+
log.log '.', false
|
93
|
+
|
94
|
+
# Render the example
|
95
|
+
|
96
|
+
# WebDriver's `execute_async_script` takes a string that is executed in
|
97
|
+
# the context of a function. `execute_async_script` injects a callback
|
98
|
+
# function as this function's argument here. WebDriver will wait until
|
99
|
+
# this callback is called (if it is passed a value it will pass that
|
100
|
+
# through to Rubyland), or until WebDriver's `script_timeout` is reached,
|
101
|
+
# before continuing. Since we don't define the signature of this function,
|
102
|
+
# we can't name the argument so we access it using JavaScript's magic
|
103
|
+
# arguments object and pass it down to `renderExample()` which calls it
|
104
|
+
# when it is done--either synchronously if our example doesn't take an
|
105
|
+
# argument, or asynchronously via the Promise and `done` callback if it
|
106
|
+
# does.
|
107
|
+
script = <<-EOS
|
108
|
+
var doneFunc = arguments[arguments.length - 1];
|
109
|
+
window.happo.renderExample(arguments[0], doneFunc);
|
110
|
+
EOS
|
111
|
+
rendered = driver.execute_async_script(script, description)
|
112
|
+
log.log '.', false
|
113
|
+
|
114
|
+
if rendered['error']
|
115
|
+
fail <<-EOS
|
116
|
+
Error while rendering "#{description}" @#{viewport['name']}:
|
117
|
+
#{rendered['error']}
|
118
|
+
Debug by pointing your browser to
|
119
|
+
#{Happo::Utils.construct_url('/', description: description)}
|
120
|
+
EOS
|
121
|
+
end
|
122
|
+
|
123
|
+
# Crop the screenshot to the size of the rendered element
|
124
|
+
screenshot = ChunkyPNG::Image.from_blob(driver.screenshot_as(:png))
|
125
|
+
log.log '.', false
|
126
|
+
|
127
|
+
# In our JavaScript we are rounding up, which can sometimes give us a
|
128
|
+
# dimensions that are larger than the screenshot dimensions. We need to
|
129
|
+
# guard against that here.
|
130
|
+
crop_width = [
|
131
|
+
[rendered['width'], 1].max,
|
132
|
+
screenshot.width - rendered['left']
|
133
|
+
].min
|
134
|
+
crop_height = [
|
135
|
+
[rendered['height'], 1].max,
|
136
|
+
screenshot.height - rendered['top']
|
137
|
+
].min
|
138
|
+
|
139
|
+
if crop_width < screenshot.width || crop_height < screenshot.height
|
140
|
+
screenshot.crop!(rendered['left'],
|
141
|
+
rendered['top'],
|
142
|
+
crop_width,
|
143
|
+
crop_height)
|
144
|
+
log.log '.', false
|
145
|
+
end
|
146
|
+
|
147
|
+
# Run the diff if needed
|
148
|
+
baseline_path = Happo::Utils.path_to(
|
149
|
+
description, viewport['name'], 'baseline.png')
|
150
|
+
|
151
|
+
if File.exist? baseline_path
|
152
|
+
# A baseline image exists, so we want to compare the new snapshot
|
153
|
+
# against the baseline.
|
154
|
+
comparison = Happo::SnapshotComparer.new(
|
155
|
+
ChunkyPNG::Image.from_file(baseline_path),
|
156
|
+
screenshot
|
157
|
+
).compare!
|
158
|
+
log.log '.', false
|
159
|
+
|
160
|
+
if comparison[:diff_image]
|
161
|
+
# There was a visual difference between the new snapshot and the
|
162
|
+
# baseline, so we want to write the diff image and the new snapshot
|
163
|
+
# image to disk. This will allow it to be reviewed by someone.
|
164
|
+
diff_path = Happo::Utils.path_to(
|
165
|
+
description, viewport['name'], 'diff.png')
|
166
|
+
comparison[:diff_image].save(diff_path, :fast_rgba)
|
167
|
+
log.log '.', false
|
168
|
+
|
169
|
+
candidate_path = Happo::Utils.path_to(
|
170
|
+
description, viewport['name'], 'candidate.png')
|
171
|
+
screenshot.save(candidate_path, :fast_rgba)
|
172
|
+
log.log '.', false
|
173
|
+
|
174
|
+
percent = comparison[:diff_in_percent].round(1)
|
175
|
+
log.log log.cyan(" #{percent}% (#{candidate_path})")
|
176
|
+
result_summary[:diff_examples] << {
|
177
|
+
description: description,
|
178
|
+
viewport: viewport['name']
|
179
|
+
}
|
180
|
+
else
|
181
|
+
# No visual difference was found, so we don't need to do any more
|
182
|
+
# work.
|
183
|
+
log.log ' No diff.'
|
184
|
+
result_summary[:okay_examples] << {
|
185
|
+
description: description,
|
186
|
+
viewport: viewport['name']
|
187
|
+
}
|
188
|
+
end
|
189
|
+
else
|
190
|
+
# There was no baseline image yet, so we want to start by saving a new
|
191
|
+
# baseline image.
|
192
|
+
|
193
|
+
# Create the folder structure if it doesn't already exist
|
194
|
+
unless File.directory?(dirname = File.dirname(baseline_path))
|
195
|
+
FileUtils.mkdir_p(dirname)
|
196
|
+
end
|
197
|
+
screenshot.save(baseline_path, :fast_rgba)
|
198
|
+
log.log '.', false
|
199
|
+
log.log " First snapshot created (#{baseline_path})"
|
200
|
+
result_summary[:new_examples] << {
|
201
|
+
description: description,
|
202
|
+
viewport: viewport['name']
|
203
|
+
}
|
204
|
+
end
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
result_summary_file = File.join(Happo::Utils.config['snapshots_folder'],
|
209
|
+
'result_summary.yaml')
|
210
|
+
File.open(result_summary_file, 'w') do |file|
|
211
|
+
file.write result_summary.to_yaml
|
212
|
+
end
|
213
|
+
ensure
|
214
|
+
driver.quit
|
215
|
+
end
|
data/lib/happo/server.rb
ADDED
@@ -0,0 +1,68 @@
|
|
1
|
+
require 'sinatra/base'
|
2
|
+
require 'yaml'
|
3
|
+
|
4
|
+
module Happo
|
5
|
+
class Server < Sinatra::Base
|
6
|
+
configure do
|
7
|
+
enable :static
|
8
|
+
set :port, Happo::Utils.config['port']
|
9
|
+
end
|
10
|
+
|
11
|
+
helpers do
|
12
|
+
def h(text)
|
13
|
+
Rack::Utils.escape_html(text)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
get '/' do
|
18
|
+
@config = Happo::Utils.config
|
19
|
+
erb :index
|
20
|
+
end
|
21
|
+
|
22
|
+
get '/debug' do
|
23
|
+
@config = Happo::Utils.config
|
24
|
+
erb :debug
|
25
|
+
end
|
26
|
+
|
27
|
+
get '/review' do
|
28
|
+
@snapshots = Happo::Utils.current_snapshots
|
29
|
+
erb :review
|
30
|
+
end
|
31
|
+
|
32
|
+
get '/resource' do
|
33
|
+
file = params[:file]
|
34
|
+
if file.start_with? 'http'
|
35
|
+
redirect file
|
36
|
+
else
|
37
|
+
send_file file
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
get '/*' do
|
42
|
+
config = Happo::Utils.config
|
43
|
+
file = params[:splat].first
|
44
|
+
if File.exist?(file)
|
45
|
+
send_file file
|
46
|
+
else
|
47
|
+
config['public_directories'].each do |pub_dir|
|
48
|
+
filepath = File.join(Dir.pwd, pub_dir, file)
|
49
|
+
if File.exist?(filepath)
|
50
|
+
send_file filepath
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
post '/reject' do
|
57
|
+
Happo::Action.new(params[:description], params[:viewport]).reject
|
58
|
+
redirect back
|
59
|
+
end
|
60
|
+
|
61
|
+
post '/approve' do
|
62
|
+
Happo::Action.new(params[:description], params[:viewport]).approve
|
63
|
+
redirect back
|
64
|
+
end
|
65
|
+
|
66
|
+
run!
|
67
|
+
end
|
68
|
+
end
|