kontrast 0.2.1

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