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.
- checksums.yaml +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +262 -0
- data/Rakefile +2 -0
- data/bin/kontrast +111 -0
- data/lib/kontrast.rb +97 -0
- data/lib/kontrast/config/template.rb +27 -0
- data/lib/kontrast/configuration.rb +124 -0
- data/lib/kontrast/exceptions.rb +5 -0
- data/lib/kontrast/gallery/template.erb +76 -0
- data/lib/kontrast/gallery_creator.rb +164 -0
- data/lib/kontrast/image_handler.rb +119 -0
- data/lib/kontrast/runner.rb +141 -0
- data/lib/kontrast/selenium_handler.rb +84 -0
- data/lib/kontrast/test_builder.rb +25 -0
- data/lib/kontrast/version.rb +3 -0
- data/spec/configuration_spec.rb +53 -0
- data/spec/gallery_creator_spec.rb +105 -0
- data/spec/image_handler_spec.rb +52 -0
- data/spec/runner_spec.rb +37 -0
- data/spec/spec_helper.rb +44 -0
- data/spec/test_builder_spec.rb +52 -0
- metadata +202 -0
@@ -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,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
|