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 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