happo 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -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>
@@ -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
+ }
@@ -0,0 +1,8 @@
1
+ body {
2
+ background-color: #f0f0f0;
3
+ font-family: helvetica, arial;
4
+ }
5
+
6
+ form {
7
+ display: inline-block;
8
+ }
@@ -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
@@ -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