kontrast 0.2.1

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,27 @@
1
+ # This should get you up and running
2
+ # Check out the full list of config options at:
3
+ # https://github.com/harrystech/kontrast
4
+
5
+ Kontrast.configure do |config|
6
+ # This is the address of your local server
7
+ config.test_domain = "http://localhost:3000"
8
+
9
+ # This is the address of your production server
10
+ config.production_domain = "http://www.example.com"
11
+
12
+ # Set this to true if you want Kontrast to return
13
+ # an exit code of 1 if any diffs are found
14
+ config.fail_build = false
15
+
16
+ # These tests will open in a 1280px-wide browser window
17
+ config.pages(1280) do |page|
18
+ page.home "/"
19
+ page.about "/about"
20
+ end
21
+
22
+ # These tests will open in a 320px-wide browser window
23
+ config.pages(320) do |page|
24
+ page.home "/"
25
+ page.about "/about"
26
+ end
27
+ end
@@ -0,0 +1,124 @@
1
+ module Kontrast
2
+ class << self
3
+ attr_accessor :configuration, :test_suite
4
+
5
+ def configure
6
+ self.configuration ||= Configuration.new
7
+ yield(configuration)
8
+ end
9
+
10
+ def tests
11
+ self.test_suite ||= TestBuilder.new
12
+ end
13
+ end
14
+
15
+ class Configuration
16
+ attr_accessor :run_parallel, :total_nodes, :current_node
17
+ attr_accessor :_before_run, :_after_run, :_before_gallery, :_after_gallery, :_before_screenshot, :_after_screenshot
18
+ attr_accessor :distortion_metric, :highlight_color, :lowlight_color
19
+ attr_accessor :local_path, :remote_path, :aws_bucket, :aws_key, :aws_secret
20
+ attr_accessor :test_domain, :production_domain
21
+ attr_accessor :browser_driver, :browser_profile
22
+ attr_accessor :fail_build
23
+
24
+ def initialize
25
+ # Set defaults
26
+ @browser_driver = "firefox"
27
+ @browser_profile = {}
28
+
29
+ @run_parallel = false
30
+ @total_nodes = 1
31
+ @current_node = 0
32
+
33
+ @distortion_metric = "MeanAbsoluteErrorMetric"
34
+ @highlight_color = "blue"
35
+ @lowlight_color = "rgba(255, 255, 255, 0.3)"
36
+
37
+ @fail_build = false
38
+ end
39
+
40
+ def validate
41
+ # Check that Kontrast has everything it needs to proceed
42
+ check_nil_vars(["test_domain", "production_domain"])
43
+ if Kontrast.test_suite.nil?
44
+ raise ConfigurationException.new("Kontrast has no tests to run.")
45
+ end
46
+
47
+ # If remote, check for more options
48
+ if @run_parallel
49
+ check_nil_vars(["aws_bucket", "aws_key", "aws_secret"])
50
+ check_nil_vars(["local_path", "remote_path"])
51
+
52
+ # Make sure total nodes is >= 1 so we don't get divide by 0 errors
53
+ if @total_nodes < 1
54
+ raise ConfigurationException.new("total_nodes cannot be less than 1.")
55
+ end
56
+ end
57
+ end
58
+
59
+ def pages(width)
60
+ if !block_given?
61
+ raise ConfigurationException.new("You must pass a block to the pages config option.")
62
+ end
63
+ Kontrast.tests.add_width(width)
64
+ yield(Kontrast.tests)
65
+ end
66
+
67
+ def before_run(&block)
68
+ if block_given?
69
+ @_before_run = block
70
+ else
71
+ @_before_run.call if @_before_run
72
+ end
73
+ end
74
+
75
+ def after_run(&block)
76
+ if block_given?
77
+ @_after_run = block
78
+ else
79
+ @_after_run.call if @_after_run
80
+ end
81
+ end
82
+
83
+ def before_gallery(&block)
84
+ if block_given?
85
+ @_before_gallery = block
86
+ else
87
+ @_before_gallery.call if @_before_gallery
88
+ end
89
+ end
90
+
91
+ def after_gallery(diffs = nil, gallery = nil, &block)
92
+ if block_given?
93
+ @_after_gallery = block
94
+ else
95
+ @_after_gallery.call(diffs, gallery) if @_after_gallery
96
+ end
97
+ end
98
+
99
+ def before_screenshot(test_driver = nil, production_driver = nil, current_test = nil, &block)
100
+ if block_given?
101
+ @_before_screenshot = block
102
+ else
103
+ @_before_screenshot.call(test_driver, production_driver, current_test) if @_before_screenshot
104
+ end
105
+ end
106
+
107
+ def after_screenshot(test_driver = nil, production_driver = nil, current_test = nil, &block)
108
+ if block_given?
109
+ @_after_screenshot = block
110
+ else
111
+ @_after_screenshot.call(test_driver, production_driver, current_test) if @_after_screenshot
112
+ end
113
+ end
114
+
115
+ private
116
+ def check_nil_vars(vars)
117
+ vars.each do |var|
118
+ if instance_variable_get("@#{var}").nil?
119
+ raise ConfigurationException.new("Kontrast config is missing the #{var} option.")
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,5 @@
1
+ module Kontrast
2
+ class ConfigurationException < Exception; end
3
+ class GalleryException < Exception; end
4
+ class RunnerException < Exception; end
5
+ end
@@ -0,0 +1,76 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css">
5
+ <style type="text/css">
6
+ .short-screenshot {
7
+ height: 200px;
8
+ width: 200px;
9
+ }
10
+ </style>
11
+ </head>
12
+ <body>
13
+ <div class="container">
14
+ <div class="row page-header">
15
+ <h1>Kontrast Gallery</h1>
16
+ </div>
17
+ <div class="row">
18
+ <div class="col-lg-2">
19
+ <div class="panel">
20
+ <div class="panel-heading">Screenshots:</div>
21
+ <ul class="list-group list-group-flush">
22
+ <% directories.keys.sort.each do |size| %>
23
+ <li class="list-group-item"><strong><%=size%></strong></li>
24
+ <% directories[size].keys.each do |name| %>
25
+ <li class="list-group-item"><a href="#<%= "#{size}_#{name}" %>"><%=name%></a></li>
26
+ <% end %>
27
+ <% end %>
28
+ </ul>
29
+ </div>
30
+ </div>
31
+ <div class="col-lg-10">
32
+ <% if diffs.any? %>
33
+ <div class="row">
34
+ <div class="alert alert-warning" role="alert">
35
+ Tests with diffs:
36
+ <ul>
37
+ <% diffs.each do |test, diff| %>
38
+ <li>
39
+ <a href="#<%= test %>"><%= test %></a>
40
+ </li>
41
+ <% end %>
42
+ </ul>
43
+ </div>
44
+ </div>
45
+ <% end %>
46
+
47
+ <% directories.keys.sort.each do |size| %>
48
+ <div class="row">
49
+ <a name="<%= size %>"></a>
50
+ <h2><%= size %></h2>
51
+ </div>
52
+
53
+ <% directories[size].each do |comparison| %>
54
+ <div id="<%= "#{size}_#{comparison.first}" %>" class="row">
55
+ <div class="row">
56
+ <h2><%= comparison.first %></h2>
57
+ </div>
58
+ <% comparison.last[:variants].each do |file| %>
59
+ <div class="col-lg-3">
60
+ <a href="<%=file[:image]%>">
61
+ <img class="short-screenshot img-thumbnail" src="<%=file[:thumb]%>">
62
+ </a>
63
+ <p class="text-center"><%=file[:domain]%></p>
64
+ <% if file[:diff_amt] %>
65
+ <p class="text-center text-muted"><%=file[:diff_amt]%></p>
66
+ <% end %>
67
+ </div>
68
+ <% end %>
69
+ </div>
70
+ <% end %>
71
+ <% end %>
72
+ </div>
73
+ </div>
74
+ </div>
75
+ </body>
76
+ </html>
@@ -0,0 +1,164 @@
1
+ require "erb"
2
+ require "json"
3
+
4
+ module Kontrast
5
+ class GalleryCreator
6
+ def initialize(path)
7
+ @path = path || Kontrast.path
8
+ end
9
+
10
+ # This gets run only once per suite. It collects the manifests from all nodes
11
+ # and uses them to generate a nice gallery of images.
12
+ def create_gallery(output_dir)
13
+ begin
14
+ @gallery_dir = FileUtils.mkdir_p("#{output_dir}/gallery").join('')
15
+ rescue Exception => e
16
+ raise GalleryException.new("An unexpected error occurred while trying to create the gallery's output directory: #{e.inspect}")
17
+ end
18
+
19
+ # Get and parse manifests
20
+ parsed_manifests = parse_manifests(get_manifests)
21
+ files = parsed_manifests[:files]
22
+ diffs = parsed_manifests[:diffs]
23
+
24
+ # Generate HTML
25
+ html = generate_html(files, diffs)
26
+
27
+ # Write file
28
+ File.open("#{@gallery_dir}/gallery.html", 'w') do |outf|
29
+ outf.write(html)
30
+ end
31
+
32
+ # Upload file
33
+ if Kontrast.configuration.run_parallel
34
+ Kontrast.fog.directories.get(Kontrast.configuration.aws_bucket).files.create(
35
+ key: "#{Kontrast.configuration.remote_path}/gallery/gallery.html",
36
+ body: File.open("#{@gallery_dir}/gallery.html")
37
+ )
38
+ end
39
+
40
+ # Return diffs and gallery path
41
+ return {
42
+ diffs: diffs,
43
+ path: "#{@gallery_dir}/gallery.html"
44
+ }
45
+ end
46
+
47
+ def generate_html(files, diffs)
48
+ # Template variables
49
+ directories = parse_directories(files, diffs)
50
+
51
+ # HTML
52
+ template = File.read(Kontrast.root + '/lib/kontrast/gallery/template.erb')
53
+ return ERB.new(template).result(binding)
54
+ end
55
+
56
+ def get_manifests
57
+ if Kontrast.configuration.run_parallel
58
+ # Download manifests
59
+ files = Kontrast.fog.directories.get(Kontrast.configuration.aws_bucket, prefix: "#{Kontrast.configuration.remote_path}/manifest").files
60
+ files.each do |file|
61
+ filename = "#{@path}/" + file.key.split('/').last
62
+ File.open(filename, 'w') do |local_file|
63
+ local_file.write(file.body)
64
+ end
65
+ end
66
+ end
67
+ manifest_files = Dir["#{@path}/manifest_*.json"]
68
+ return manifest_files
69
+ end
70
+
71
+ def parse_manifests(manifest_files)
72
+ files = []
73
+ diffs = {}
74
+ manifest_files.each do |manifest|
75
+ manifest = JSON.parse(File.read(manifest))
76
+ files.concat(manifest['files'])
77
+ diffs.merge!(manifest["diffs"])
78
+ end
79
+
80
+ return {
81
+ files: files,
82
+ diffs: diffs
83
+ }
84
+ end
85
+
86
+ # This function just turns the list of files and diffs into a hash that the gallery
87
+ # creator can insert into a template. See an example of the created hash below.
88
+ def parse_directories(files, diffs)
89
+ files.sort!
90
+
91
+ dirs = {}
92
+ directories = files.map { |f| f.split('/').first }.uniq
93
+
94
+ # Get all sizes
95
+ sizes = directories.map { |d| d.split('_').first }
96
+ sizes.each { |size|
97
+ dirs[size] = {}
98
+
99
+ # Get all directories for this size
100
+ tests_for_size = directories.select { |d| d.index(size + "_") == 0 }
101
+ tests_for_size.each do |dir|
102
+ array = dir.split('_')
103
+ array.delete_at(0)
104
+ test_name = array.join('_')
105
+ dirs[size][test_name] = {
106
+ variants: []
107
+ }
108
+ end
109
+ }
110
+
111
+ # This determines where to display images from in the gallery
112
+ if Kontrast.configuration.run_parallel
113
+ # Build the remote path to S3
114
+ base_path = "https://#{Kontrast.configuration.aws_bucket}.s3.amazonaws.com/#{Kontrast.configuration.remote_path}"
115
+ else
116
+ base_path = ".."
117
+ end
118
+
119
+ # Fill in the files as variants
120
+ directories.each do |directory|
121
+ array = directory.split('_')
122
+ size = array.first
123
+ array.delete_at(0)
124
+ test_name = array.join('_')
125
+
126
+ # Add variations
127
+ ['test', 'production', 'diff'].each_with_index do |type, i|
128
+ dirs[size][test_name][:variants] << {
129
+ image: "#{base_path}/#{size}_#{test_name}/" + type + ".png",
130
+ thumb: "#{base_path}/#{size}_#{test_name}/" + type + "_thumb.png",
131
+ domain: type
132
+ }
133
+ if type == 'diff'
134
+ dirs[size][test_name][:variants][i][:diff_amt] = (diffs["#{size}_#{test_name}"]) ? diffs["#{size}_#{test_name}"]["diff"] : 0
135
+ end
136
+ end
137
+ end
138
+
139
+ return dirs
140
+
141
+ # For reference
142
+ # gallery_format = {
143
+ # "1080" => {
144
+ # "name" => {
145
+ # variants: [{
146
+ # image: "full_img_src",
147
+ # thumb: "thumb_src",
148
+ # domain: "production"
149
+ # }, {
150
+ # image: "foo_src",
151
+ # thumb: "thumb_src",
152
+ # domain: "test"
153
+ # }, {
154
+ # image: "diff_src",
155
+ # thumb: "diff_thumb_src",
156
+ # domain: "diff",
157
+ # size: 0.1
158
+ # }]
159
+ # }
160
+ # }
161
+ # }
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,119 @@
1
+ require "RMagick"
2
+ require "workers"
3
+
4
+ module Kontrast
5
+ class ImageHandler
6
+ include Magick
7
+ attr_reader :diffs, :path
8
+
9
+ def initialize
10
+ @path = Kontrast.path
11
+
12
+ # This is where failed diffs will be stored
13
+ @diffs = {}
14
+ end
15
+
16
+ # In order for images to be diff'ed, they need to have the same dimensions
17
+ def crop_images(width, name)
18
+ # Load images
19
+ test_image = Image.read("#{@path}/#{width}_#{name}/test.png").first
20
+ production_image = Image.read("#{@path}/#{width}_#{name}/production.png").first
21
+
22
+ # Let's not do anything if the images are already the same size
23
+ return if test_image.rows == production_image.rows
24
+
25
+ # Get max height of both images
26
+ max_height = [test_image.rows, production_image.rows].max
27
+
28
+ # Crop
29
+ Workers.map([test_image, production_image]) do |image|
30
+ image.extent(width, max_height).write(image.filename)
31
+ end
32
+ end
33
+
34
+ # Uses the compare_channel function to highlight the differences between two images
35
+ # Docs: http://www.imagemagick.org/RMagick/doc/image1.html#compare_channel
36
+ def diff_images(width, name)
37
+ # Load images
38
+ test_image = Image.read("#{@path}/#{width}_#{name}/test.png").first
39
+ production_image = Image.read("#{@path}/#{width}_#{name}/production.png").first
40
+
41
+ # Compare and save diff
42
+ diff = test_image.compare_channel(production_image, Magick.const_get(Kontrast.configuration.distortion_metric)) do |options|
43
+ options.highlight_color = Kontrast.configuration.highlight_color
44
+ options.lowlight_color = Kontrast.configuration.lowlight_color
45
+ end
46
+ diff.first.write("#{@path}/#{width}_#{name}/diff.png")
47
+
48
+ # If the images are different, let the class know about it so that it gets added to the manifest
49
+ if diff.last > 0
50
+ @diffs["#{width}_#{name}"] = {
51
+ width: width,
52
+ name: name,
53
+ diff: diff.last
54
+ }
55
+ end
56
+ end
57
+
58
+ # For the gallery. Not sure if this is really necessary.
59
+ def create_thumbnails(width, name)
60
+ # Load images
61
+ test_image = Image.read("#{@path}/#{width}_#{name}/test.png").first
62
+ production_image = Image.read("#{@path}/#{width}_#{name}/production.png").first
63
+ diff_image = Image.read("#{@path}/#{width}_#{name}/diff.png").first
64
+
65
+ # Crop images
66
+ Workers.map([test_image, production_image, diff_image]) do |image|
67
+ filename = image.filename.split('/').last.split('.').first + "_thumb"
68
+ image.resize_to_fill(200, 200, NorthGravity).write("#{@path}/#{width}_#{name}/#{filename}.png")
69
+ end
70
+ end
71
+
72
+ # We upload the images per test
73
+ def upload_images(width, name)
74
+ Workers.map(Dir.entries("#{@path}/#{width}_#{name}")) do |file|
75
+ next if ['.', '..'].include?(file)
76
+ Kontrast.fog.directories.get(Kontrast.configuration.aws_bucket).files.create(
77
+ key: "#{Kontrast.configuration.remote_path}/#{width}_#{name}/#{file}",
78
+ body: File.open("#{@path}/#{width}_#{name}/#{file}"),
79
+ public: true
80
+ )
81
+ end
82
+ end
83
+
84
+ # The manifest is a per-node .json file that is used to create the gallery
85
+ # without having to download all assets from S3 to the test environment
86
+ def create_manifest(current_node, build = nil)
87
+ # Set up structure
88
+ manifest = {
89
+ diffs: @diffs,
90
+ files: []
91
+ }
92
+
93
+ # Dump directories
94
+ Dir.foreach(@path) do |subdir|
95
+ next if ['.', '..'].include?(subdir)
96
+ next if subdir.index('manifest_')
97
+ Dir.foreach("#{@path}/#{subdir}") do |img|
98
+ next if ['.', '..'].include?(img)
99
+ manifest[:files] << "#{subdir}/#{img}"
100
+ end
101
+ end
102
+
103
+ if Kontrast.configuration.run_parallel
104
+ # Upload manifest
105
+ Kontrast.fog.directories.get(Kontrast.configuration.aws_bucket).files.create(
106
+ key: "#{build}/manifest_#{current_node}.json",
107
+ body: manifest.to_json
108
+ )
109
+ else
110
+ # Write manifest
111
+ File.open("#{@path}/manifest_#{current_node}.json", 'w') do |outf|
112
+ outf.write(manifest.to_json)
113
+ end
114
+ end
115
+
116
+ return manifest
117
+ end
118
+ end
119
+ end