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.
@@ -0,0 +1,91 @@
1
+ require 'oily_png'
2
+ require 'diff-lcs'
3
+ require_relative 'snapshot_comparison_image/base'
4
+ require_relative 'snapshot_comparison_image/gutter'
5
+ require_relative 'snapshot_comparison_image/before'
6
+ require_relative 'snapshot_comparison_image/overlayed'
7
+ require_relative 'snapshot_comparison_image/after'
8
+
9
+ module Happo
10
+ # This class is responsible for comparing two Snapshots and generating a diff.
11
+ class SnapshotComparer
12
+ # @param png_before [ChunkyPNG::Image]
13
+ # @param png_after [ChunkyPNG::Image]
14
+ def initialize(png_before, png_after)
15
+ @png_after = png_after
16
+ @png_before = png_before
17
+ end
18
+
19
+ # @return [Hash]
20
+ def compare!
21
+ no_diff = {
22
+ diff_in_percent: 0,
23
+ diff_image: nil,
24
+ }
25
+
26
+ # If these images are totally identical, we don't need to do any more
27
+ # work.
28
+ return no_diff if @png_before == @png_after
29
+
30
+ array_before = to_array_of_arrays(@png_before)
31
+ array_after = to_array_of_arrays(@png_after)
32
+
33
+ # If the arrays of arrays of colors are identical, we don't need to do any
34
+ # more work. This might happen if some of the headers are different.
35
+ return no_diff if array_before == array_after
36
+
37
+ sdiff = Diff::LCS.sdiff(array_before, array_after)
38
+ number_of_different_rows = 0
39
+
40
+ sprite, all_comparisons = initialize_comparison_images(
41
+ [@png_after.width, @png_before.width].max, sdiff.size)
42
+
43
+ sdiff.each_with_index do |row, y|
44
+ # each row is a Diff::LCS::ContextChange instance
45
+ all_comparisons.each { |image| image.render_row(y, row) }
46
+ number_of_different_rows += 1 unless row.unchanged?
47
+ end
48
+
49
+ percent_changed = number_of_different_rows.to_f / sdiff.size * 100
50
+ {
51
+ diff_in_percent: percent_changed,
52
+ diff_image: (sprite if percent_changed > 0),
53
+ }
54
+ end
55
+
56
+ private
57
+
58
+ # @param [ChunkyPNG::Image]
59
+ # @return [Array<Array<Integer>>]
60
+ def to_array_of_arrays(chunky_png)
61
+ array_of_arrays = []
62
+ chunky_png.height.times do |y|
63
+ array_of_arrays << chunky_png.row(y)
64
+ end
65
+ array_of_arrays
66
+ end
67
+
68
+ # @param canvas [ChunkyPNG::Image] The output image to draw pixels on
69
+ # @return [Array<SnapshotComparisonImage>]
70
+ def initialize_comparison_images(width, height)
71
+ gutter_width = Happo::SnapshotComparisonImage::Gutter::WIDTH
72
+ total_width = (width * 3) + (gutter_width * 3)
73
+
74
+ sprite = ChunkyPNG::Image.new(total_width, height)
75
+ offset, comparison_images = 0, []
76
+ comparison_images << Happo::SnapshotComparisonImage::Gutter.new(offset, sprite)
77
+ offset += gutter_width
78
+ comparison_images << Happo::SnapshotComparisonImage::Before.new(offset, sprite)
79
+ offset += width
80
+ comparison_images << Happo::SnapshotComparisonImage::Gutter.new(offset, sprite)
81
+ offset += gutter_width
82
+ comparison_images << Happo::SnapshotComparisonImage::Overlayed.new(offset, sprite)
83
+ offset += width
84
+ comparison_images << Happo::SnapshotComparisonImage::Gutter.new(offset, sprite)
85
+ offset += gutter_width
86
+ comparison_images << Happo::SnapshotComparisonImage::After.new(offset, sprite)
87
+
88
+ [sprite, comparison_images]
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,21 @@
1
+ module Happo
2
+ module SnapshotComparisonImage
3
+ # This subclass of `SnapshotComparisonImage` knows how to draw the
4
+ # representation of the "after" image.
5
+ class After < SnapshotComparisonImage::Base
6
+ # @param y [Integer]
7
+ # @param row [Diff::LCS:ContextChange]
8
+ def render_changed_row(y, row)
9
+ render_added_row(y, row)
10
+ end
11
+
12
+ # @param y [Integer]
13
+ # @param row [Diff::LCS:ContextChange]
14
+ def render_added_row(y, row)
15
+ row.new_element.each_with_index do |pixel_after, x|
16
+ render_pixel(x, y, pixel_after)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,108 @@
1
+ require 'chunky_png'
2
+
3
+ module Happo
4
+ module SnapshotComparisonImage
5
+ # This model represents a "comparison image". Basically it's just a wrapper
6
+ # around a ChunkyPNG image with some nice methods to make life easier in the
7
+ # world of diffs.
8
+ #
9
+ # This model is never persisted.
10
+ class Base
11
+ include ChunkyPNG::Color
12
+
13
+ BASE_OPACITY = 0.1
14
+ BASE_ALPHA = (255 * BASE_OPACITY).round
15
+ BASE_DIFF_ALPHA = BASE_ALPHA * 2
16
+
17
+ MAGENTA = ChunkyPNG::Color.from_hex '#b33682'
18
+ RED = ChunkyPNG::Color.from_hex '#dc322f'
19
+ GREEN = ChunkyPNG::Color.from_hex '#859900'
20
+
21
+ # @param offset [Integer] the x-offset that this comparison image should
22
+ # use when rendering on the canvas image.
23
+ # @param canvas [ChunkyPNG::Image] The canvas image to render pixels on.
24
+ def initialize(offset, canvas)
25
+ @offset = offset
26
+ @canvas = canvas
27
+ end
28
+
29
+ # @param y [Integer]
30
+ # @param row [Diff::LCS:ContextChange]
31
+ def render_row(y, row)
32
+ if row.unchanged?
33
+ render_unchanged_row(y, row)
34
+ elsif row.deleting?
35
+ render_deleted_row(y, row)
36
+ elsif row.adding?
37
+ render_added_row(y, row)
38
+ else # changing?
39
+ render_changed_row(y, row)
40
+ end
41
+ end
42
+
43
+ # @param y [Integer]
44
+ # @param row [Diff::LCS:ContextChange]
45
+ def render_unchanged_row(y, row)
46
+ row.new_element.each_with_index do |pixel, x|
47
+ # Render the unchanged pixel as-is
48
+ render_pixel(x, y, pixel)
49
+ end
50
+ end
51
+
52
+ # @param y [Integer]
53
+ # @param row [Diff::LCS:ContextChange]
54
+ def render_changed_row(y, row)
55
+ # no default implementation
56
+ end
57
+
58
+ # @param y [Integer]
59
+ # @param row [Diff::LCS:ContextChange]
60
+ def render_added_row(y, row)
61
+ # no default implementation
62
+ end
63
+
64
+ # @param y [Integer]
65
+ # @param row [Diff::LCS:ContextChange]
66
+ def render_deleted_row(y, row)
67
+ # no default implementation
68
+ end
69
+
70
+ # Compute a score that represents the difference between 2 pixels
71
+ #
72
+ # This method simply takes the Euclidean distance between the RGBA channels
73
+ # of 2 colors over the maximum possible Euclidean distance. This gives us a
74
+ # percentage of how different the two colors are.
75
+ #
76
+ # Although it would be more perceptually accurate to calculate a proper
77
+ # Delta E in Lab colorspace, we probably don't need perceptual accuracy for
78
+ # this application, and it is nice to avoid the overhead of converting RGBA
79
+ # to Lab.
80
+ #
81
+ # @param pixel_after [Integer]
82
+ # @param pixel_before [Integer]
83
+ # @return [Float] number between 0 and 1 where 1 is completely different
84
+ # and 0 is no difference
85
+ def pixel_diff_score(pixel_after, pixel_before)
86
+ ChunkyPNG::Color::euclidean_distance_rgba(pixel_after, pixel_before) /
87
+ ChunkyPNG::Color::MAX_EUCLIDEAN_DISTANCE_RGBA
88
+ end
89
+
90
+ # @param diff_score [Float]
91
+ # @return [Integer] a number between 0 and 255 that represents the alpha
92
+ # channel of of the difference
93
+ def diff_alpha(diff_score)
94
+ (BASE_DIFF_ALPHA + ((255 - BASE_DIFF_ALPHA) * diff_score)).round
95
+ end
96
+
97
+ # Renders a pixel on the specified x and y position. Uses the offset that
98
+ # the comparison image has been configured with.
99
+ #
100
+ # @param x [Integer]
101
+ # @param y [Integer]
102
+ # @param pixel [Integer]
103
+ def render_pixel(x, y, pixel)
104
+ @canvas.set_pixel(x + @offset, y, pixel)
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,21 @@
1
+ module Happo
2
+ module SnapshotComparisonImage
3
+ # This subclass of `SnapshotComparisonImage` knows how to draw the
4
+ # representation of the "before" image.
5
+ class Before < SnapshotComparisonImage::Base
6
+ # @param y [Integer]
7
+ # @param row [Diff::LCS:ContextChange]
8
+ def render_changed_row(y, row)
9
+ render_deleted_row(y, row)
10
+ end
11
+
12
+ # @param y [Integer]
13
+ # @param row [Diff::LCS:ContextChange]
14
+ def render_deleted_row(y, row)
15
+ row.old_element.each_with_index do |pixel_before, x|
16
+ render_pixel(x, y, pixel_before)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,34 @@
1
+ module Happo
2
+ module SnapshotComparisonImage
3
+ # This class renders a gutter-column with a color representing the type of
4
+ # change that has happened.
5
+ class Gutter < SnapshotComparisonImage::Base
6
+ WIDTH = 10
7
+ GRAY = ChunkyPNG::Color.from_hex '#cccccc'
8
+
9
+ def render_row(y, row)
10
+ WIDTH.times do |x|
11
+ render_pixel(x, y, gutter_color(row))
12
+ end
13
+ # render a two-pixel empty column
14
+ 2.times do |x|
15
+ render_pixel(WIDTH - 1 - x, y, WHITE)
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def gutter_color(row)
22
+ if row.unchanged?
23
+ WHITE
24
+ elsif row.deleting?
25
+ RED
26
+ elsif row.adding?
27
+ GREEN
28
+ else # changed?
29
+ GRAY
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,88 @@
1
+ module Happo
2
+ module SnapshotComparisonImage
3
+ # This subclass of `SnapshotComparisonImage` knows how to overlay the
4
+ # after-image on top of the before-image, and render the difference in a
5
+ # scaled magenta color.
6
+ class Overlayed < SnapshotComparisonImage::Base
7
+ WHITE_OVERLAY = ChunkyPNG::Color.fade(WHITE, 1 - BASE_ALPHA)
8
+
9
+ # @param offset [Integer]
10
+ # @param canvas [ChunkyPNG::Image]
11
+ # @see SnapshotComparisonImage::Base
12
+ def initialize(offset, canvas)
13
+ @diff_pixels = {}
14
+ @faded_pixels = {}
15
+ super
16
+ end
17
+
18
+ # @param y [Integer]
19
+ # @param row [Diff::LCS:ContextChange]
20
+ def render_unchanged_row(y, row)
21
+ # Render translucent original pixels
22
+ row.new_element.each_with_index do |pixel, x|
23
+ render_faded_pixel(x, y, pixel)
24
+ end
25
+ end
26
+
27
+ # @param y [Integer]
28
+ # @param row [Diff::LCS:ContextChange]
29
+ def render_deleted_row(y, row)
30
+ row.old_element.each_with_index do |pixel_before, x|
31
+ render_faded_magenta_pixel(TRANSPARENT, pixel_before, x, y)
32
+ end
33
+ end
34
+
35
+ # @param y [Integer]
36
+ # @param row [Diff::LCS:ContextChange]
37
+ def render_added_row(y, row)
38
+ row.new_element.each_with_index do |pixel_after, x|
39
+ render_faded_magenta_pixel(pixel_after, TRANSPARENT, x, y)
40
+ end
41
+ end
42
+
43
+ # @param y [Integer]
44
+ # @param row [Diff::LCS:ContextChange]
45
+ def render_changed_row(y, row)
46
+ row.old_element.zip(row.new_element).each_with_index do |pixels, x|
47
+ pixel_before, pixel_after = pixels
48
+ render_faded_magenta_pixel(
49
+ pixel_after || TRANSPARENT,
50
+ pixel_before || TRANSPARENT,
51
+ x, y)
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ # @param pixel_after [Integer]
58
+ # @param pixel_before [Integer]
59
+ # @param x [Integer]
60
+ # @param y [Integer]
61
+ def render_faded_magenta_pixel(pixel_after, pixel_before, x, y)
62
+ score = pixel_diff_score(pixel_after, pixel_before)
63
+ if score > 0
64
+ render_diff_pixel(x, y, score)
65
+ else
66
+ render_faded_pixel(x, y, pixel_after)
67
+ end
68
+ end
69
+
70
+ # @param x [Integer]
71
+ # @param y [Integer]
72
+ # @param score [Float]
73
+ def render_diff_pixel(x, y, score)
74
+ @diff_pixels[score] ||= compose_quick(fade(MAGENTA, diff_alpha(score)),
75
+ WHITE)
76
+ render_pixel(x, y, @diff_pixels[score])
77
+ end
78
+
79
+ # @param x [Integer]
80
+ # @param y [Integer]
81
+ # @param pixel [Integer]
82
+ def render_faded_pixel(x, y, pixel)
83
+ @faded_pixels[pixel] ||= compose_quick(WHITE_OVERLAY, pixel)
84
+ render_pixel(x, y, @faded_pixels[pixel])
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,70 @@
1
+ require 's3'
2
+ require 'securerandom'
3
+
4
+ module Happo
5
+ class Uploader
6
+ def initialize
7
+ @s3_access_key_id = Happo::Utils.config['s3_access_key_id']
8
+ @s3_secret_access_key = Happo::Utils.config['s3_secret_access_key']
9
+ @s3_bucket_name = Happo::Utils.config['s3_bucket_name']
10
+ end
11
+
12
+ def upload_diffs
13
+ result_summary = YAML.load(File.read(File.join(
14
+ Happo::Utils.config['snapshots_folder'], 'result_summary.yaml')))
15
+
16
+ return [] if result_summary[:diff_examples].empty? &&
17
+ result_summary[:new_examples].empty?
18
+
19
+ bucket = find_or_build_bucket
20
+ dir = SecureRandom.uuid
21
+
22
+ diff_images = result_summary[:diff_examples].map do |diff|
23
+ image = bucket.objects.build(
24
+ "#{dir}/#{diff[:description]}_#{diff[:viewport]}.png")
25
+ image.content = open(Happo::Utils.path_to(diff[:description],
26
+ diff[:viewport],
27
+ 'diff.png'))
28
+ image.content_type = 'image/png'
29
+ image.save
30
+ diff[:url] = image.url
31
+ diff
32
+ end
33
+
34
+ new_images = result_summary[:new_examples].map do |example|
35
+ image = bucket.objects.build(
36
+ "#{dir}/#{example[:description]}_#{example[:viewport]}.png")
37
+ image.content = open(Happo::Utils.path_to(example[:description],
38
+ example[:viewport],
39
+ 'baseline.png'))
40
+ image.content_type = 'image/png'
41
+ image.save
42
+ example[:url] = image.url
43
+ example
44
+ end
45
+
46
+ html = bucket.objects.build("#{dir}/index.html")
47
+ path = File.expand_path(
48
+ File.join(File.dirname(__FILE__), 'diffs.html.erb'))
49
+ html.content = ERB.new(File.read(path)).result(binding)
50
+ html.content_type = 'text/html'
51
+ html.save
52
+ html.url
53
+ end
54
+
55
+ private
56
+
57
+ def find_or_build_bucket
58
+ service = S3::Service.new(access_key_id: @s3_access_key_id,
59
+ secret_access_key: @s3_secret_access_key)
60
+ bucket = service.buckets.find(@s3_bucket_name)
61
+
62
+ if bucket.nil?
63
+ bucket = service.buckets.build(@s3_bucket_name)
64
+ bucket.save(location: :us)
65
+ end
66
+
67
+ bucket
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,81 @@
1
+ require 'yaml'
2
+ require 'erb'
3
+ require 'uri'
4
+ require 'base64'
5
+
6
+ module Happo
7
+ class Utils
8
+ def self.config
9
+ @@config ||= {
10
+ 'snapshots_folder' => './snapshots',
11
+ 'source_files' => [],
12
+ 'stylesheets' => [],
13
+ 'public_directories' => [],
14
+ 'port' => 4567,
15
+ 'driver' => :firefox,
16
+ 'viewports' => {
17
+ 'large' => {
18
+ 'width' => 1024,
19
+ 'height' => 768
20
+ },
21
+ 'medium' => {
22
+ 'width' => 640,
23
+ 'height' => 888
24
+ },
25
+ 'small' => {
26
+ 'width' => 320,
27
+ 'height' => 444
28
+ }
29
+ }
30
+ }.merge(config_from_file)
31
+ end
32
+
33
+ def self.config_from_file
34
+ config_file_name = ENV['HAPPO_CONFIG_FILE'] || '.happo.yaml'
35
+ YAML.load(ERB.new(File.read(config_file_name)).result)
36
+ end
37
+
38
+ def self.normalize_description(description)
39
+ Base64.strict_encode64(description).strip
40
+ end
41
+
42
+ def self.path_to(description, viewport_name, file_name)
43
+ File.join(
44
+ config['snapshots_folder'],
45
+ normalize_description(description),
46
+ "@#{viewport_name}",
47
+ file_name
48
+ )
49
+ end
50
+
51
+ def self.construct_url(absolute_path, params = {})
52
+ query = URI.encode_www_form(params) unless params.empty?
53
+
54
+ URI::HTTP.build(host: 'localhost',
55
+ port: config['port'],
56
+ path: absolute_path,
57
+ query: query).to_s
58
+ end
59
+
60
+ def self.current_snapshots
61
+ prepare_file = lambda do |file|
62
+ viewport_dir = File.expand_path('..', file)
63
+ description_dir = File.expand_path('..', viewport_dir)
64
+ {
65
+ description: Base64.decode64(File.basename(description_dir)),
66
+ viewport: File.basename(viewport_dir).sub('@', ''),
67
+ file: file
68
+ }
69
+ end
70
+
71
+ snapshots_folder = Happo::Utils.config['snapshots_folder']
72
+ diff_files = Dir.glob("#{snapshots_folder}/**/diff.png")
73
+ baselines = Dir.glob("#{snapshots_folder}/**/baseline.png")
74
+
75
+ {
76
+ diffs: diff_files.map(&prepare_file),
77
+ baselines: baselines.map(&prepare_file)
78
+ }
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,4 @@
1
+ # Defines the gem version.
2
+ module Happo
3
+ VERSION = '1.0.0'
4
+ end
@@ -0,0 +1,29 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Happo Debug Tool</title>
5
+ <link rel="stylesheet" href="/happo-styles.css"></link>
6
+ <script src="/happo-runner.js"></script>
7
+ <% @config['source_files'].each do |source_file| %>
8
+ <script src="/resource?file=<%= ERB::Util.url_encode(source_file) %>"></script>
9
+ <% end %>
10
+ <script src="https://code.jquery.com/jquery-2.1.4.min.js"></script>
11
+ </head>
12
+ <body>
13
+ <h1>Happo Debug Tool</h1>
14
+ <p>Click on an item to render that example in isolation.</p>
15
+ <script>
16
+ (function() {
17
+ var ul = $('<ul>');
18
+ $('body').append(ul);
19
+ $.each(happo.defined, function(_, example) {
20
+ ul.append($('<li>').append(
21
+ $('<a>', {
22
+ href: '/?description=' + encodeURIComponent(example.description)
23
+ }).text(example.description)
24
+ ));
25
+ });
26
+ }());
27
+ </script>
28
+ </body>
29
+ </html>
@@ -0,0 +1,27 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Happo</title>
5
+ <style type="text/css">
6
+ * {
7
+ -webkit-transition: none !important;
8
+ -moz-transition: none !important;
9
+ transition: none !important;
10
+ -webkit-animation-duration: 0 !important;
11
+ -moz-animation-duration: 0s !important;
12
+ animation-duration: 0s !important;
13
+ }
14
+ </style>
15
+
16
+ <% @config['stylesheets'].each do |stylesheet| %>
17
+ <link rel="stylesheet" type="text/css"
18
+ href="/resource?file=<%= ERB::Util.url_encode(stylesheet) %>">
19
+ <% end %>
20
+ <script src="/happo-runner.js"></script>
21
+ <% @config['source_files'].each do |source_file| %>
22
+ <script src="/resource?file=<%= ERB::Util.url_encode(source_file) %>"></script>
23
+ <% end %>
24
+ </head>
25
+ <body style="background-color: #fff; margin: 0; pointer-events: none;">
26
+ </body>
27
+ </html>
@@ -0,0 +1,37 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Happo Review Tool</title>
5
+ <link rel="stylesheet" href="/happo-styles.css"></link>
6
+ </head>
7
+ <body>
8
+ <h1>Happo Review Tool</h1>
9
+ <h2>DIFFS</h2>
10
+ <% @snapshots[:diffs].each do |diff| %>
11
+ <h3>
12
+ <%= h diff[:description] %> @ <%= diff[:viewport] %>
13
+ </h3>
14
+ <p><img src="/resource?file=<%= ERB::Util.url_encode(diff[:file]) %>"></p>
15
+ <form style="display: inline-block"
16
+ action="/approve?description=<%= diff[:description] %>&viewport=<%= diff[:viewport] %>"
17
+ method="POST">
18
+ <button type="submit">Approve</button>
19
+ </form>
20
+ <form style="display: inline-block"
21
+ action="/reject?description=<%= diff[:description] %>&viewport=<%= diff[:viewport] %>"
22
+ method="POST">
23
+ <button type="submit">Reject</button>
24
+ </form>
25
+ <% end %>
26
+
27
+ <hr>
28
+
29
+ <h2>BASELINES</h2>
30
+ <% @snapshots[:baselines].each do |baseline| %>
31
+ <h3>
32
+ <%= h baseline[:description] %> @ <%= baseline[:viewport] %>
33
+ </h3>
34
+ <p><img src="/resource?file=<%= ERB::Util.url_encode(baseline[:file]) %>"></p>
35
+ <% end %>
36
+ </body>
37
+ </html>
data/lib/happo.rb ADDED
@@ -0,0 +1,6 @@
1
+ require 'happo/utils'
2
+ require 'happo/action'
3
+ require 'happo/uploader'
4
+ require 'happo/version'
5
+ require 'happo/logger'
6
+ require 'happo/snapshot_comparer'