kontrast 0.2.1 → 0.6.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +135 -96
  3. data/bin/kontrast +9 -5
  4. data/lib/kontrast.rb +17 -5
  5. data/lib/kontrast/api_client.rb +56 -0
  6. data/lib/kontrast/api_endpoint_comparator.rb +126 -0
  7. data/lib/kontrast/api_endpoint_runner.rb +99 -0
  8. data/lib/kontrast/api_endpoint_test.rb +8 -0
  9. data/lib/kontrast/configuration.rb +36 -7
  10. data/lib/kontrast/exceptions.rb +2 -1
  11. data/lib/kontrast/gallery/template.erb +152 -21
  12. data/lib/kontrast/gallery_creator.rb +105 -47
  13. data/lib/kontrast/global_runner.rb +121 -0
  14. data/lib/kontrast/image_helper.rb +63 -0
  15. data/lib/kontrast/image_uploader.rb +18 -0
  16. data/lib/kontrast/page_comparator.rb +46 -0
  17. data/lib/kontrast/page_runner.rb +95 -0
  18. data/lib/kontrast/page_test.rb +32 -0
  19. data/lib/kontrast/selenium_handler.rb +18 -7
  20. data/lib/kontrast/spec.rb +21 -0
  21. data/lib/kontrast/spec_builder.rb +54 -0
  22. data/lib/kontrast/test.rb +27 -0
  23. data/lib/kontrast/test_builder.rb +25 -9
  24. data/lib/kontrast/test_suite.rb +42 -0
  25. data/lib/kontrast/thumbnail_creator.rb +18 -0
  26. data/lib/kontrast/version.rb +1 -1
  27. data/spec/api_endpoint_comparator_spec.rb +125 -0
  28. data/spec/configuration_spec.rb +19 -0
  29. data/spec/gallery_creator_spec.rb +26 -20
  30. data/spec/{image_handler_spec.rb → global_runner_spec.rb} +17 -12
  31. data/spec/page_comparator_spec.rb +31 -0
  32. data/spec/page_runner_spec.rb +45 -0
  33. data/spec/spec_builder_spec.rb +32 -0
  34. data/spec/support/fixtures/image.jpg +0 -0
  35. data/spec/support/fixtures/image_clone.jpg +0 -0
  36. data/spec/support/fixtures/img1.jpg +0 -0
  37. data/spec/support/fixtures/img2.jpg +0 -0
  38. data/spec/support/fixtures/other_image.jpg +0 -0
  39. data/spec/test_builder_spec.rb +6 -3
  40. data/spec/test_spec.rb +53 -0
  41. data/spec/test_suite_spec.rb +56 -0
  42. metadata +91 -30
  43. data/lib/kontrast/image_handler.rb +0 -119
  44. data/lib/kontrast/runner.rb +0 -141
  45. data/spec/runner_spec.rb +0 -37
@@ -0,0 +1,56 @@
1
+ require 'faraday'
2
+
3
+ module Kontrast
4
+ class ApiClient
5
+
6
+ attr_reader :responses, :env
7
+ attr_writer :headers
8
+
9
+ def initialize(env, host, app_id, app_secret, headers: {})
10
+ @env = env
11
+ @host = host
12
+ @app_id = app_id
13
+ @app_secret = app_secret
14
+ @connection = nil
15
+ @responses = {}
16
+ @headers = headers
17
+ end
18
+
19
+ def fetch(path, save_file: false, folder_name: "")
20
+ response = connection.get(path) do |req|
21
+ req.headers['Authorization'] = "Bearer #{token}"
22
+ req.headers.merge!(@headers)
23
+ end
24
+ data = JSON.parse(response.body)
25
+ if save_file
26
+ open(File.join(Kontrast.path, folder_name, "#{@env}.json"), 'wb') do |file|
27
+ file << JSON.pretty_generate(data)
28
+ end
29
+ end
30
+ @responses[path] = data
31
+ return data
32
+ end
33
+
34
+ def token
35
+ return @token || fetch_token
36
+ end
37
+
38
+ def fetch_token
39
+ return @token if !@token.nil? && @token != ''
40
+
41
+ response = connection.post(Kontrast.configuration.oauth_token_url, {
42
+ grant_type: 'client_credentials',
43
+ client_id: @app_id,
44
+ client_secret: @app_secret,
45
+ })
46
+ @token = Kontrast.configuration.oauth_token_from_response.call(response.body)
47
+ end
48
+
49
+ def connection
50
+ @connection ||= Faraday.new(url: @host) do |faraday|
51
+ faraday.request :url_encoded
52
+ faraday.adapter Faraday.default_adapter
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,126 @@
1
+ require "workers"
2
+ require "kontrast/api_client"
3
+
4
+ module Kontrast
5
+ class ApiEndpointComparator
6
+ attr_reader :diffs, :prod_client, :test_client
7
+
8
+ def initialize
9
+
10
+ @prod_client = Kontrast::ApiClient.new(
11
+ 'production',
12
+ Kontrast.configuration.production_domain,
13
+ Kontrast.configuration.production_oauth_app_uid,
14
+ Kontrast.configuration.production_oauth_app_secret,
15
+ )
16
+
17
+ test_oauth_app = Kontrast.configuration.test_oauth_app_proc.call
18
+ @test_client = Kontrast::ApiClient.new(
19
+ 'test',
20
+ Kontrast.configuration.test_domain,
21
+ test_oauth_app.uid,
22
+ test_oauth_app.secret,
23
+ )
24
+
25
+ @image_index = 0
26
+
27
+ @result = {}
28
+
29
+ # This is where failed diffs will be stored
30
+ @diffs = {}
31
+ end
32
+
33
+ def diff(test)
34
+ @image_index = 0
35
+ @prod_client.headers = test.headers
36
+ @test_client.headers = test.headers
37
+
38
+ # Create the folder
39
+ FileUtils.mkdir_p(File.join(Kontrast.path, test.to_s))
40
+
41
+ Workers.map([@test_client, @prod_client]) do |client|
42
+ client.fetch(test.path, save_file: true, folder_name: test.to_s)
43
+ end
44
+
45
+ @diffs[test.to_s] = {images: []}
46
+ if !compare(@prod_client.responses[test.path], @test_client.responses[test.path], test)
47
+ @diffs[test.to_s].merge!({
48
+ type: 'api_endpoint',
49
+ name: test.name,
50
+ diff: 1,
51
+ })
52
+ else
53
+ # Clear the diff
54
+ @diffs.delete test.to_s
55
+ end
56
+ end
57
+
58
+ def compare(prod_data, test_data, test, key: nil)
59
+
60
+ if prod_data == test_data
61
+ return true
62
+ elsif is_image_string?(prod_data, key)
63
+ # If it's an image, we need to compare both files
64
+ if compare_images(prod_data, test_data, test)
65
+ return true
66
+ else
67
+ diff_details = { index: @image_index - 1 }
68
+ @diffs[test.to_s][:images] << diff_details
69
+ return false
70
+ end
71
+ elsif prod_data.is_a?(Hash)
72
+ return false if prod_data.keys != test_data.keys
73
+
74
+ return prod_data.map do |key, value|
75
+ compare(prod_data[key], test_data[key], test, key: key)
76
+ end.all?
77
+ elsif prod_data.is_a?(Array) # Make it more generic?
78
+ return false if prod_data.length != test_data.length
79
+
80
+ return prod_data.map.with_index do |value, i|
81
+ compare(prod_data[i], test_data[i], test)
82
+ end.all?
83
+ else
84
+ return false
85
+ end
86
+ end
87
+
88
+ def is_image_string?(image_string, key)
89
+ # Either a URL or a local path
90
+ if !key.nil? && key != ''
91
+ return key.match(/(image|url)/) && image_string.is_a?(String)
92
+ else
93
+ return image_string.is_a?(String) && image_string.match(/^(http|\/)/)
94
+ end
95
+ end
96
+
97
+ def compare_images(prod_image, test_image, test)
98
+ images = [
99
+ {'env' => 'production', 'image' => prod_image},
100
+ {'env' => 'test', 'image' => test_image},
101
+ ]
102
+ files = Workers.map(images) do |image|
103
+ load_image_file(image['image'], test, image['env'])
104
+ end
105
+
106
+ image_helper = Kontrast::ImageHelper.new(files[0].path, files[1].path)
107
+
108
+ diff = image_helper.compare(test.to_s, "diff_#{@image_index}.png")
109
+
110
+ @image_index += 1
111
+ return diff == 0
112
+ end
113
+
114
+ def load_image_file(image, test, prefix)
115
+ if image.start_with?('http')
116
+ extension = image.split('.')[-1]
117
+ file_name = "#{prefix}_#{@image_index}.#{extension}"
118
+ open(File.join(Kontrast.path, test.to_s, file_name), 'wb') do |file|
119
+ file << open(image).read
120
+ end
121
+ else
122
+ File.new(image)
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,99 @@
1
+ module Kontrast
2
+ class ApiEndpointRunner
3
+ include ImageUploader
4
+ include ThumbnailCreator
5
+
6
+ attr_accessor :diffs
7
+
8
+ def initialize
9
+ @api_diff_comparator = ApiEndpointComparator.new
10
+ @diffs = {}
11
+ end
12
+
13
+ def run(total_nodes, current_node)
14
+ # Assign tests and run them
15
+ suite = split_run(total_nodes, current_node)
16
+ parallel_run(suite, current_node)
17
+ end
18
+
19
+ # Given the total number of nodes and the index of the current node,
20
+ # we determine which tests the current node will run
21
+ def split_run(total_nodes, current_node)
22
+ test_suite = Kontrast.api_endpoint_test_suite
23
+ if test_suite.nil?
24
+ return []
25
+ end
26
+
27
+ # Load lazy tests
28
+ # Some tests are lazy loaded from the initializer
29
+ # In that case, we stored a block instead of adding a test to the
30
+ # suite when reading the initializer
31
+ # We need to execute the block, this will add the test to the suite
32
+ # This is needed for tests that are dynamically defined: like,
33
+ # get all the product pages in the DB and create a test for each
34
+ # one.
35
+ test_suite.lazy_tests.each do |lazy_test|
36
+ Kontrast.api_endpoint_test_builder.prefix = lazy_test.prefix
37
+ Kontrast.api_endpoint_test_builder.headers = lazy_test.headers
38
+ lazy_test.block.call(Kontrast.api_endpoint_test_builder)
39
+ end
40
+ tests_to_run = []
41
+
42
+ index = 0
43
+ test_suite.tests.each do |test|
44
+ if index % total_nodes == current_node
45
+ tests_to_run << test
46
+ end
47
+ index += 1
48
+ end
49
+
50
+ return tests_to_run
51
+ end
52
+
53
+ # Runs tests
54
+ def parallel_run(suite, current_node)
55
+
56
+ # Run per-page tasks
57
+ suite.each do |test|
58
+ begin
59
+ print "Processing #{test.name} @ #{test.prefix}... "
60
+
61
+ # Download the json file
62
+ # Create the diff hash, there
63
+ @api_diff_comparator.diff(test)
64
+
65
+ # Create thumbnails for gallery
66
+ print "Creating thumbnails... "
67
+ images = Dir.entries(File.join(Kontrast.path, test.to_s)).reject { |file_name|
68
+ ['.', '..'].include?(file_name) || file_name.include?('.json')
69
+ }
70
+
71
+ create_thumbnails(test, images)
72
+
73
+ # Upload to S3
74
+ if Kontrast.configuration.run_parallel
75
+ print "Uploading... "
76
+ upload_images(test)
77
+ end
78
+
79
+ puts "\n", ("=" * 85)
80
+ rescue Net::ReadTimeout => e
81
+ puts "Test timed out. Message: #{e.inspect}"
82
+ if Kontrast.configuration.fail_build
83
+ raise e
84
+ end
85
+ rescue StandardError => e
86
+ puts "Exception: #{e.inspect}"
87
+ puts e.backtrace.inspect
88
+ if Kontrast.configuration.fail_build
89
+ raise e
90
+ end
91
+ end
92
+ end
93
+ ensure
94
+ # We need the diff at the runner level to create the manifest
95
+ @diffs = @api_diff_comparator.diffs
96
+ end
97
+
98
+ end
99
+ end
@@ -0,0 +1,8 @@
1
+ module Kontrast
2
+ class ApiEndpointTest < Test
3
+ # We need this class because we want each type of test to have its own
4
+ # class.
5
+ # This one doesn't need anything that is not already in the parent Test
6
+ # class, but PageTest does.
7
+ end
8
+ end
@@ -1,14 +1,26 @@
1
1
  module Kontrast
2
2
  class << self
3
- attr_accessor :configuration, :test_suite
3
+ attr_accessor :configuration, :page_builder, :api_endpoint_builder
4
4
 
5
5
  def configure
6
6
  self.configuration ||= Configuration.new
7
7
  yield(configuration)
8
8
  end
9
9
 
10
- def tests
11
- self.test_suite ||= TestBuilder.new
10
+ def page_test_builder
11
+ self.page_builder ||= TestBuilder.new
12
+ end
13
+
14
+ def api_endpoint_test_builder
15
+ self.api_endpoint_builder ||= TestBuilder.new
16
+ end
17
+
18
+ def page_test_suite
19
+ self.page_builder ? self.page_builder.suite : nil
20
+ end
21
+
22
+ def api_endpoint_test_suite
23
+ self.api_endpoint_builder ? self.api_endpoint_builder.suite : nil
12
24
  end
13
25
  end
14
26
 
@@ -20,6 +32,10 @@ module Kontrast
20
32
  attr_accessor :test_domain, :production_domain
21
33
  attr_accessor :browser_driver, :browser_profile
22
34
  attr_accessor :fail_build
35
+ attr_accessor :workers_pool_size
36
+ attr_accessor :production_oauth_app_uid, :production_oauth_app_secret,
37
+ :test_oauth_app_uid, :test_oauth_app_secret, :oauth_token_url,
38
+ :oauth_token_from_response, :test_oauth_app_proc
23
39
 
24
40
  def initialize
25
41
  # Set defaults
@@ -40,7 +56,7 @@ module Kontrast
40
56
  def validate
41
57
  # Check that Kontrast has everything it needs to proceed
42
58
  check_nil_vars(["test_domain", "production_domain"])
43
- if Kontrast.test_suite.nil?
59
+ if Kontrast.page_test_suite.nil? && Kontrast.api_endpoint_test_suite.nil?
44
60
  raise ConfigurationException.new("Kontrast has no tests to run.")
45
61
  end
46
62
 
@@ -56,12 +72,21 @@ module Kontrast
56
72
  end
57
73
  end
58
74
 
59
- def pages(width)
75
+ def pages(width, url_params = {})
60
76
  if !block_given?
61
77
  raise ConfigurationException.new("You must pass a block to the pages config option.")
62
78
  end
63
- Kontrast.tests.add_width(width)
64
- yield(Kontrast.tests)
79
+ Kontrast.page_test_builder.prefix = width
80
+ Kontrast.page_test_builder.url_params = url_params
81
+ yield(Kontrast.page_test_builder)
82
+ end
83
+
84
+ def api_endpoints(group_name)
85
+ if !block_given?
86
+ raise ConfigurationException.new("You must pass a block to the api_endpoints config option.")
87
+ end
88
+ Kontrast.api_endpoint_test_builder.prefix = group_name
89
+ yield(Kontrast.api_endpoint_test_builder)
65
90
  end
66
91
 
67
92
  def before_run(&block)
@@ -112,6 +137,10 @@ module Kontrast
112
137
  end
113
138
  end
114
139
 
140
+ def workers_pool_size
141
+ @workers_pool_size || 5
142
+ end
143
+
115
144
  private
116
145
  def check_nil_vars(vars)
117
146
  vars.each do |var|
@@ -2,4 +2,5 @@ module Kontrast
2
2
  class ConfigurationException < Exception; end
3
3
  class GalleryException < Exception; end
4
4
  class RunnerException < Exception; end
5
- end
5
+ class TestSuiteException < Exception; end
6
+ end
@@ -2,11 +2,18 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css">
5
+ <script type="text/javascript" src="https://code.jquery.com/jquery-2.1.4.min.js"></script>
6
+ <script type="text/javascript" src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/js/bootstrap.min.js"></script>
5
7
  <style type="text/css">
6
8
  .short-screenshot {
7
9
  height: 200px;
8
10
  width: 200px;
9
11
  }
12
+ #diffs {
13
+ position: fixed;
14
+ z-index: 1;
15
+ margin-left: 33%;
16
+ }
10
17
  </style>
11
18
  </head>
12
19
  <body>
@@ -19,10 +26,10 @@
19
26
  <div class="panel">
20
27
  <div class="panel-heading">Screenshots:</div>
21
28
  <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>
29
+ <% groups.sort.each do |group| %>
30
+ <li class="list-group-item"><strong><%=group%></strong></li>
31
+ <% without_diffs[group].keys.each do |name| %>
32
+ <li class="list-group-item"><a href="#<%= "#{group}_#{name}" %>-nondiff"><%=name%></a></li>
26
33
  <% end %>
27
34
  <% end %>
28
35
  </ul>
@@ -31,12 +38,12 @@
31
38
  <div class="col-lg-10">
32
39
  <% if diffs.any? %>
33
40
  <div class="row">
34
- <div class="alert alert-warning" role="alert">
41
+ <div id="diffs" class="alert alert-warning" role="alert">
35
42
  Tests with diffs:
36
43
  <ul>
37
44
  <% diffs.each do |test, diff| %>
38
45
  <li>
39
- <a href="#<%= test %>"><%= test %></a>
46
+ <a href="#<%= test %>-diff"><%= test %></a>
40
47
  </li>
41
48
  <% end %>
42
49
  </ul>
@@ -44,25 +51,149 @@
44
51
  </div>
45
52
  <% end %>
46
53
 
47
- <% directories.keys.sort.each do |size| %>
54
+ <h1>Diffs</h1>
55
+ <% with_diffs.keys.sort.each do |group| %>
56
+ <div class="row">
57
+ <a name="<%= group %>"></a>
58
+ <h2><%= group %></h2>
59
+ </div>
60
+
61
+ <% with_diffs[group].each do |test_name, variants| %>
62
+ <div id="<%= "#{group}_#{test_name}" %>-diff" class="row">
63
+ <div class="row">
64
+ <h2><%= test_name %></h2>
65
+
66
+ <% if variants.first[:type] == 'page' %>
67
+ <% variants.each do |variant| %>
68
+ <div class="col-lg-3">
69
+ <a href="<%=variant[:image]%>">
70
+ <img class="short-screenshot img-thumbnail" src="<%=variant[:thumb]%>">
71
+ </a>
72
+ <p class="text-center"><%=variant[:domain]%></p>
73
+ <% if variant[:diff_amt] %>
74
+ <p class="text-center text-muted"><%=variant[:diff_amt]%></p>
75
+ <% end %>
76
+ </div>
77
+ <% end %>
78
+ <% elsif variants.first[:type] == 'api_endpoint' %>
79
+ <% variants.each do |variant| %>
80
+ <div class="col-lg-3">
81
+ <% if variant[:domain] == 'diff' %>
82
+ <a>
83
+ <img class="short-screenshot img-thumbnail" src="http://dummyimage.com/200x200/?text=N/A">
84
+ </a>
85
+ <% else %>
86
+ <a href="<%=variant[:file]%>">
87
+ <img class="short-screenshot img-thumbnail" src="http://dummyimage.com/200x200/?text=JSON">
88
+ </a>
89
+ <% end %>
90
+ <p class="text-center"><%=variant[:domain]%></p>
91
+ </div>
92
+ <% end %>
93
+ <% end %>
94
+ </div>
95
+ <% if variants.first[:type] == 'api_endpoint' && variants.first[:images].any? %>
96
+ <h3>Images</h3>
97
+ <% div_id = ["images", test_name, SecureRandom.hex(6)].join('-') %>
98
+ <a class="btn btn-primary" role="button" data-toggle="collapse" href="#<%= div_id %>">
99
+ Expand / Collapse
100
+ </a>
101
+ <div class="collapse" id="<%= div_id %>">
102
+ <% variants.first[:images].each_with_index do |image, i| %>
103
+ <div class="row">
104
+ <% second_image = variants[1][:images][i] %>
105
+ <% diff_image = variants[-1][:images][i] %>
106
+ <div class="col-lg-3">
107
+ <a href="<%= image[:image] %>">
108
+ <img class="short-screenshot img-thumbnail" src="<%=image[:thumb]%>">
109
+ </a>
110
+ </div>
111
+ <div class="col-lg-3">
112
+ <a href="<%=second_image[:image]%>">
113
+ <img class="short-screenshot img-thumbnail" src="<%=second_image[:thumb]%>">
114
+ </a>
115
+ </div>
116
+ <div class="col-lg-3">
117
+ <a href="<%=diff_image[:image]%>">
118
+ <img class="short-screenshot img-thumbnail" src="<%=diff_image[:thumb]%>">
119
+ </a>
120
+ </div>
121
+ </div>
122
+ <% end %>
123
+ </div>
124
+ <% end %>
125
+ </div>
126
+ <% end %>
127
+ <% end %>
128
+
129
+ <h1>Non-diffs</h1>
130
+ <% without_diffs.keys.sort.each do |group| %>
48
131
  <div class="row">
49
- <a name="<%= size %>"></a>
50
- <h2><%= size %></h2>
132
+ <a name="<%= group %>"></a>
133
+ <h2><%= group %></h2>
51
134
  </div>
52
135
 
53
- <% directories[size].each do |comparison| %>
54
- <div id="<%= "#{size}_#{comparison.first}" %>" class="row">
136
+ <% without_diffs[group].each do |test_name, variants| %>
137
+ <div id="<%= "#{group}_#{test_name}" %>-nondiff" class="row">
55
138
  <div class="row">
56
- <h2><%= comparison.first %></h2>
139
+ <h2><%= test_name %></h2>
140
+
141
+ <% if variants.first[:type] == 'page' %>
142
+ <% variants.each do |variant| %>
143
+ <div class="col-lg-3">
144
+ <a href="<%=variant[:image]%>">
145
+ <img class="short-screenshot img-thumbnail" src="<%=variant[:thumb]%>">
146
+ </a>
147
+ <p class="text-center"><%=variant[:domain]%></p>
148
+ <% if variant[:diff_amt] %>
149
+ <p class="text-center text-muted"><%=variant[:diff_amt]%></p>
150
+ <% end %>
151
+ </div>
152
+ <% end %>
153
+ <% elsif variants.first[:type] == 'api_endpoint' %>
154
+ <% variants.each do |variant| %>
155
+ <div class="col-lg-3">
156
+ <% if variant[:domain] == 'diff' %>
157
+ <a href="<%=variant[:file]%>">
158
+ <img class="short-screenshot img-thumbnail" src="http://dummyimage.com/200x200/?text=N/A">
159
+ </a>
160
+ <% else %>
161
+ <a href="<%=variant[:file]%>">
162
+ <img class="short-screenshot img-thumbnail" src="http://dummyimage.com/200x200/?text=JSON">
163
+ </a>
164
+ <% end %>
165
+ <p class="text-center"><%=variant[:domain]%></p>
166
+ </div>
167
+ <% end %>
168
+ <% end %>
57
169
  </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>
170
+
171
+ <% if variants.first[:type] == 'api_endpoint' && variants.first[:images].any? %>
172
+ <% div_id = ["images", test_name, SecureRandom.hex(6)].join('-') %>
173
+ <a class="btn btn-primary" role="button" data-toggle="collapse" href="#<%= div_id %>">
174
+ Expand / Collapse
175
+ </a>
176
+ <div class="collapse" id="<%= div_id %>">
177
+ <% variants.first[:images].each_with_index do |image, i| %>
178
+ <div class="row">
179
+ <% second_image = variants[1][:images][i] %>
180
+ <% diff_image = variants[-1][:images][i] %>
181
+ <div class="col-lg-3">
182
+ <a href="<%= image[:image] %>">
183
+ <img class="short-screenshot img-thumbnail" src="<%=image[:thumb]%>">
184
+ </a>
185
+ </div>
186
+ <div class="col-lg-3">
187
+ <a href="<%=second_image[:image]%>">
188
+ <img class="short-screenshot img-thumbnail" src="<%=second_image[:thumb]%>">
189
+ </a>
190
+ </div>
191
+ <div class="col-lg-3">
192
+ <a href="<%=diff_image[:image]%>">
193
+ <img class="short-screenshot img-thumbnail" src="<%=diff_image[:thumb]%>">
194
+ </a>
195
+ </div>
196
+ </div>
66
197
  <% end %>
67
198
  </div>
68
199
  <% end %>
@@ -73,4 +204,4 @@
73
204
  </div>
74
205
  </div>
75
206
  </body>
76
- </html>
207
+ </html>