happo 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|