Skip to content
This repository

Detect asset changes by saving source_digests in manifest.yml, and speed up non-digest assets #21

Closed
wants to merge 2 commits into from

13 participants

Nathan Broadbent Ryan Bigg Richard Schneeman Dmitry Vorotilin Jeremy Kemper Michael de Silva Lorenzo Manacorda Guillermo Iguaran Alessandro Morandi williscool Joshua Peek
Nathan Broadbent

This feature speeds up the asset pipeline by only recompiling assets whose dependencies have changed. It does this by changing manifest.yml to include :source_digests as well as the digests for compiled assets under the :digest_files key.

This pull request depends on two others: sprockets #367 and rails #7767 (guides update)

Source Digests

By computing source digests, we can quickly determine if the asset needs to be recompiled or not. If nothing has changed in the sources of an asset's dependencies, then compilation is skipped for that asset. Source digests are calculated by removing all processors except ERB, and disabling compression, so the contents of source files are concatenated and hashed. Please see pull request #367 on Sprockets for the required changes.

Generating Non-digest Assets

Instead of compiling twice for both digest and non-digest assets, non-digest assets are now generated by stripping the digests out of the already compiled css and js files. This means we don't have to process and compress the same files twice. I'm making the assumption that asset paths are the only differences between digest/non-digest assets, but please let me know if you can see any problems with this approach.

Rails 3.2.x Support

I've prepared patches for Rails 3.2.8 and Sprockets 2.1.3 on branches named rails3_assets_speedup. I've extracted these patches into a gem called turbo-sprockets-rails3, so that myself and others can start using the feature.
Do you think there's any chance of this feature making it into the Rails 3.2.9 release?

Tests

All the tests are passing for both sprockets and sprockets-rails. I've added one or two tests, but there already seemed to be decent coverage for the areas I was changing. Please let me know if more tests are needed.

Testing this pull request

You'll need to add the following lines to the Gemfile of your Rails 4 app:

gem 'sprockets',       github: 'ndbroadbent/sprockets',       branch: 'allow_assets_without_processing'
gem 'sprockets-rails', github: 'ndbroadbent/sprockets-rails', branch: 'dont_recompile_unchanged_assets'

You should also add the following lines to config/environments/production.rb to view the logs for rake assets:precompile:

config.log_level = :debug
config.logger = Logger.new(STDOUT)

Before / After

Here's some asset compilation times for a smallish Rails app.
(running time RAILS_ENV=production RAILS_GROUPS=assets rake assets:precompile)

Before:

asset:precompile: 26.993s

After:

Task Time
Initial asset:precompile 18.525s
assets:precompile:primary 9.772s
assets:precompile:nondigest 64ms
Unchanged assets 9.386s
assets:precompile:primary 877ms
assets:precompile:nondigest 91ms
One changed application.js dependency 12.386s
assets:precompile:primary 3.910s
assets:precompile:nondigest 48ms
Ryan Bigg
Collaborator

I :heart: you.

Nathan Broadbent

Thanks! :D

Jonathan Rochkind jrochkind referenced this pull request in capistrano/capistrano September 28, 2012
Closed

Per-server conditional asset precompilation #174

Richard Schneeman
Collaborator

:+1::heart:

Dmitry Vorotilin

Also, instead of compiling twice for both digest and non-digest assets, non-digest assets are now generated from digest assets, with digests stripped from the content of any css and js files. This means we don't have to process and compress the same files twice. I'm making the assumption that asset paths are the only differences between digest/non-digest assets, but please let me know if you can see any problems with this approach.

Yea, it seems good if it compiles non-digest assets just for 64ms, WDYT @guilleiguaran @josevalim ? Relating comment #8 (comment)

Nathan Broadbent Detect asset changes by adding source_digests to manifest.yml
This allows us to quickly determine if the asset needs to be
recompiled. If nothing has changed in the source assets,
then compilation is skipped.
Also, instead of compiling twice for non-digest assets,
digest assets are copied instead, and the known digests are
stripped from the body of css and js files.
The copied js/css files are also gzipped.
746d1ff
Jeremy Kemper
Owner

Made some comments on the backport to Rails 3-2-stable @ rails/rails#7866

Joshua Peek josh closed this October 17, 2012
Lorenzo Manacorda

Hey @josh, was this closed because it got merged, or for some other reason?

Guillermo Iguaran

@asymmetric was closed since it doesn't apply anymore with the new version of sprockets-rails

Alessandro Morandi

Sorry to reanimate a closed thread.
@guilleiguaran, when you say "doesn't apply anymore", does it mean it's already in the new version of sprockets-rails? Or is it incompatible?

I guess what I really want to know is: what's the status of the effort towards speeding up the pipeline? Is that being tracked anywhere? Is turbo-sprockets-rails3 the answer to that?

Richard Schneeman
Collaborator

I'm interested to know as well. We're in the vetting process of getting this behavior fully supported on the Heroku buildpack with the gem for now (heroku/heroku-buildpack-ruby#44), but will want Rails 4 asset caching to work out of the box preferably.

In my very un-professional benchmarks, this behavior dropped precompiling assets on the codetriage.com app from 20 seconds to 5 seconds. One of our biggest consistent complaints is deploy time, and asset compilation has been a huge bottleneck.

williscool

I'm interested in whether or not this was integrated as well

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Showing 2 unique commits by 1 author.

Sep 28, 2012
Nathan Broadbent Follow convention of using 'github:' syntax in Gemfile 782d897
Oct 06, 2012
Nathan Broadbent Detect asset changes by adding source_digests to manifest.yml
This allows us to quickly determine if the asset needs to be
recompiled. If nothing has changed in the source assets,
then compilation is skipped.
Also, instead of compiling twice for non-digest assets,
digest assets are copied instead, and the known digests are
stripped from the body of css and js files.
The copied js/css files are also gzipped.
746d1ff
This page is out of date. Refresh to see the latest.
7  Gemfile
@@ -3,9 +3,10 @@ source "http://rubygems.org"
3 3
 # Specify your gem's dependencies in sprockets-rails.gemspec
4 4
 gemspec
5 5
 
6  
-gem "rails", :git => "git://github.com/rails/rails"
7  
-gem 'activerecord-deprecated_finders', :git => 'git://github.com/rails/activerecord-deprecated_finders'
8  
-gem 'journey', :git => 'git://github.com/rails/journey'
  6
+gem 'rails',   github: 'rails/rails',   branch: 'master'
  7
+gem 'journey', github: 'rails/journey', branch: 'master'
  8
+gem 'activerecord-deprecated_finders',  github: 'rails/activerecord-deprecated_finders', branch: 'master'
  9
+
9 10
 gem "uglifier", :require => false
10 11
 gem "mocha", :require => false
11 12
 gem "jquery-rails"
43  lib/sprockets-rails/tasks/assets.rake
@@ -43,7 +43,8 @@ namespace :assets do
43 43
       config = ::Rails.application.config
44 44
       config.assets.compile = true
45 45
       config.assets.digest  = digest unless digest.nil?
46  
-      config.assets.digests = {}
  46
+      config.assets.digest_files   ||= {}
  47
+      config.assets.source_digests ||= {}
47 48
 
48 49
       original_assets = Sprockets::Rails::Bootstrap.original_assets
49 50
       env = if !config.assets.digest && original_assets
@@ -55,18 +56,38 @@ namespace :assets do
55 56
         Rails.application.assets
56 57
       end
57 58
 
58  
-      target   = File.join(::Rails.public_path, config.assets.prefix)
59  
-      compiler = Sprockets::Rails::StaticCompiler.new(env,
60  
-                                                      target,
61  
-                                                      config.assets.precompile,
62  
-                                                      :digest => config.assets.digest,
63  
-                                                      :manifest => digest.nil?)
64  
-      compiler.compile
  59
+      target = File.join(::Rails.public_path, config.assets.prefix)
  60
+
  61
+      # If processing non-digest assets, and compiled digest files are
  62
+      # present, then generate non-digest assets from existing assets.
  63
+      # It is assumed that `assets:precompile:nondigest` won't be run manually
  64
+      # if assets have been previously compiled with digests.
  65
+      if !config.assets.digest && config.assets.digest_files.any?
  66
+        generator = Sprockets::Rails::StaticNonDigestGenerator.new(env, target, config.assets.precompile,
  67
+          :digest_files => config.assets.digest_files)
  68
+        generator.generate
  69
+      else
  70
+        compiler = Sprockets::Rails::StaticCompiler.new(env, target, config.assets.precompile,
  71
+          :digest         => config.assets.digest,
  72
+          :manifest       => digest.nil?,
  73
+          :digest_files   => config.assets.digest_files,
  74
+          :source_digests => config.assets.source_digests)
  75
+        compiler.compile
  76
+      end
65 77
     end
66 78
 
67  
-    task :all do
68  
-      Rake::Task["assets:precompile:primary"].invoke
69  
-      Rake::Task["assets:precompile:nondigest"].invoke if ::Rails.application.config.assets.digest
  79
+    task :all => ["assets:cache:clean"] do
  80
+      internal_precompile
  81
+      if ::Rails.application.config.assets.digest
  82
+        internal_precompile(false)
  83
+
  84
+        # Other gems may want to add hooks to run after the 'assets:precompile:***' tasks.
  85
+        # Since we aren't running separate rake tasks anymore,
  86
+        # we need to manually invoke the extra actions.
  87
+        %w(primary nondigest).each do |asset_type|
  88
+          Rake::Task["assets:precompile:#{asset_type}"].actions[1..-1].each &:call
  89
+        end
  90
+      end
70 91
     end
71 92
 
72 93
     task :primary => ["assets:cache:clean"] do
10  lib/sprockets/rails/helpers/rails_helper.rb
@@ -11,7 +11,7 @@ def asset_paths
11 11
           @asset_paths ||= begin
12 12
             paths = RailsHelper::AssetPaths.new(config, controller)
13 13
             paths.asset_environment = asset_environment
14  
-            paths.asset_digests     = asset_digests
  14
+            paths.digest_files      = digest_files
15 15
             paths.compile_assets    = compile_assets?
16 16
             paths.digest_assets     = digest_assets?
17 17
             paths
@@ -96,8 +96,8 @@ def asset_prefix
96 96
           ::Rails.application.config.assets.prefix
97 97
         end
98 98
 
99  
-        def asset_digests
100  
-          ::Rails.application.config.assets.digests
  99
+        def digest_files
  100
+          ::Rails.application.config.assets.digest_files
101 101
         end
102 102
 
103 103
         def compile_assets?
@@ -116,7 +116,7 @@ def asset_environment
116 116
         end
117 117
 
118 118
         class AssetPaths < ::ActionView::AssetPaths #:nodoc:
119  
-          attr_accessor :asset_environment, :asset_prefix, :asset_digests, :compile_assets, :digest_assets
  119
+          attr_accessor :asset_environment, :asset_prefix, :digest_files, :compile_assets, :digest_assets
120 120
 
121 121
           class AssetNotPrecompiledError < StandardError; end
122 122
 
@@ -132,7 +132,7 @@ def asset_for(source, ext)
132 132
           end
133 133
 
134 134
           def digest_for(logical_path)
135  
-            if digest_assets && asset_digests && (digest = asset_digests[logical_path])
  135
+            if digest_assets && digest_files && (digest = digest_files[logical_path])
136 136
               return digest
137 137
             end
138 138
 
5  lib/sprockets/rails/railtie.rb
@@ -8,6 +8,7 @@ module Rails
8 8
     autoload :LazyCompressor, "sprockets/rails/compressors"
9 9
     autoload :NullCompressor, "sprockets/rails/compressors"
10 10
     autoload :StaticCompiler, "sprockets/rails/static_compiler"
  11
+    autoload :StaticNonDigestGenerator,  "sprockets/rails/static_non_digest_generator"
11 12
 
12 13
     # TODO: Get rid of config.assets.enabled
13 14
     class Railtie < ::Rails::Railtie
@@ -35,7 +36,9 @@ class Railtie < ::Rails::Railtie
35 36
 
36 37
         manifest_path = File.join(::Rails.public_path, config.assets.prefix, "manifest.yml")
37 38
         if File.exist?(manifest_path)
38  
-          config.assets.digests = YAML.load_file(manifest_path)
  39
+          manifest = YAML.load_file(manifest_path)
  40
+          config.assets.digest_files   = manifest[:digest_files]   || {}
  41
+          config.assets.source_digests = manifest[:source_digests] || {}
39 42
         end
40 43
 
41 44
         ActiveSupport.on_load(:action_view) do
55  lib/sprockets/rails/static_compiler.rb
@@ -12,16 +12,56 @@ def initialize(env, target, paths, options = {})
12 12
         @digest = options.fetch(:digest, true)
13 13
         @manifest = options.fetch(:manifest, true)
14 14
         @zip_files = options.delete(:zip_files) || /\.(?:css|html|js|svg|txt|xml)$/
  15
+
  16
+        @current_source_digests = options.fetch(:source_digests, {})
  17
+        @current_digest_files   = options.fetch(:digest_files,   {})
  18
+
  19
+        @digest_files   = {}
  20
+        @source_digests = {}
15 21
       end
16 22
 
17 23
       def compile
18  
-        manifest = {}
  24
+        start_time = Time.now.to_f
  25
+
19 26
         env.each_logical_path(paths) do |logical_path|
20  
-          if asset = env.find_asset(logical_path)
21  
-            manifest[logical_path] = write_asset(asset)
  27
+          # Fetch asset without any processing or compression,
  28
+          # to calculate a digest of the concatenated source files
  29
+          unprocessed_asset = env.find_asset(logical_path, :process => false)
  30
+
  31
+          @source_digests[logical_path] = unprocessed_asset.digest
  32
+
  33
+          # Recompile if digest has changed or compiled digest file is missing
  34
+          current_digest_file = @current_digest_files[logical_path]
  35
+          if @source_digests[logical_path] != @current_source_digests[logical_path] ||
  36
+             !(current_digest_file && File.exists?("#{@target}/#{current_digest_file}"))
  37
+
  38
+            if asset = env.find_asset(logical_path)
  39
+              @digest_files[logical_path] = write_asset(asset)
  40
+            end
  41
+
  42
+          else
  43
+            @digest_files[logical_path] = @current_digest_files[logical_path]
  44
+
  45
+            env.logger.debug "Not compiling #{logical_path}, sources digest has not changed " <<
  46
+                             "(#{@source_digests[logical_path][0...7]})"
22 47
           end
23 48
         end
24  
-        write_manifest(manifest) if @manifest
  49
+
  50
+        # Encode all filenames & digests as UTF-8. YAML dumps other string encodings as !binary.
  51
+        @source_digests = encode_hash_as_utf8 @source_digests
  52
+        @digest_files   = encode_hash_as_utf8 @digest_files
  53
+
  54
+        if @manifest
  55
+          write_manifest(source_digests: @source_digests, digest_files: @digest_files)
  56
+        end
  57
+
  58
+        # Update digests in Rails config. (Important for when :nondigest is run after :primary)
  59
+        config = ::Rails.application.config
  60
+        config.assets.digest_files   = @digest_files
  61
+        config.assets.source_digests = @source_digests
  62
+
  63
+        elapsed_time = ((Time.now.to_f - start_time) * 1000).to_i
  64
+        env.logger.debug "Processed #{'non-' unless @digest}digest assets in #{elapsed_time}ms"
25 65
       end
26 66
 
27 67
       def write_manifest(manifest)
@@ -43,6 +83,13 @@ def write_asset(asset)
43 83
       def path_for(asset)
44 84
         @digest ? asset.digest_path : asset.logical_path
45 85
       end
  86
+
  87
+
  88
+      private
  89
+
  90
+      def encode_hash_as_utf8(hash)
  91
+        hash.inject({}) {|h, (k, v)| h[k.encode("UTF-8")] = v.encode("UTF-8"); h }
  92
+      end
46 93
     end
47 94
   end
48 95
 end
91  lib/sprockets/rails/static_non_digest_generator.rb
... ...
@@ -0,0 +1,91 @@
  1
+require 'fileutils'
  2
+
  3
+module Sprockets
  4
+  module Rails
  5
+    class StaticNonDigestGenerator
  6
+
  7
+      DIGEST_REGEX = /-([0-9a-f]{32})/
  8
+
  9
+      attr_accessor :env, :target, :paths
  10
+
  11
+      def initialize(env, target, paths, options = {})
  12
+        @env = env
  13
+        @target = target
  14
+        @paths = paths
  15
+        @digest_files = options.fetch(:digest_files, {})
  16
+
  17
+        # Parse digests from digest_files hash
  18
+        @asset_digests = Hash[*@digest_files.map {|file, digest_file|
  19
+          [file, digest_file[DIGEST_REGEX, 1]]
  20
+        }.flatten]
  21
+      end
  22
+
  23
+
  24
+      # Generate non-digest assets by making a copy of the digest asset,
  25
+      # with digests stripped from js and css. The new files are also gzipped.
  26
+      # Other assets are copied verbatim.
  27
+      def generate
  28
+        start_time = Time.now.to_f
  29
+
  30
+        env.each_logical_path(paths) do |logical_path|
  31
+          unless digest_path = @digest_files[logical_path]
  32
+            # Fail if any digest files are missing
  33
+            raise "#{logical_path} is missing from :digest_files hash in manifest.yml!" <<
  34
+                  " Please run `rake assets:precompile` to recompile your assets with digests."
  35
+          end
  36
+
  37
+          abs_digest_path  = "#{@target}/#{digest_path}"
  38
+          abs_logical_path = "#{@target}/#{logical_path}"
  39
+
  40
+          # Remove known digests from css & js
  41
+          if digest_path.match(/\.(?:js|css)$/)
  42
+            asset_body = File.read(abs_digest_path)
  43
+
  44
+            # Find all hashes in the asset body with a leading '-'
  45
+            asset_body.gsub!(DIGEST_REGEX) do |match|
  46
+              # Only remove if known digest
  47
+              $1.in?(@asset_digests.values) ? '' : match
  48
+            end
  49
+
  50
+            # Write non-digest file
  51
+            File.open abs_logical_path, 'w' do |f|
  52
+              f.write asset_body
  53
+            end
  54
+
  55
+            # Also write gzipped asset
  56
+            File.open("#{abs_logical_path}.gz", 'wb') do |f|
  57
+              gz = Zlib::GzipWriter.new(f, Zlib::BEST_COMPRESSION)
  58
+              gz.write asset_body
  59
+              gz.close
  60
+            end
  61
+
  62
+            env.logger.debug "Stripped digests, copied to #{logical_path}, and created gzipped asset"
  63
+
  64
+          else
  65
+            # Otherwise, treat file as binary and copy it.
  66
+            # Ignore paths that have no digests, such as READMEs
  67
+            unless abs_digest_path == abs_logical_path
  68
+              FileUtils.cp_r abs_digest_path, abs_logical_path, :remove_destination => true
  69
+              env.logger.debug "Copied binary asset to #{logical_path}"
  70
+
  71
+              # Copy gzipped asset if exists
  72
+              if File.exist? "#{abs_digest_path}.gz"
  73
+                FileUtils.cp_r "#{abs_digest_path}.gz", "#{abs_logical_path}.gz", :remove_destination => true
  74
+                env.logger.debug "Copied gzipped asset to #{logical_path}.gz"
  75
+              end
  76
+            end
  77
+          end
  78
+
  79
+          mtime = File.mtime(abs_digest_path)
  80
+
  81
+          # Set modification and access times for generated files
  82
+          File.utime(mtime, mtime, abs_logical_path)
  83
+          File.utime(mtime, mtime, "#{abs_logical_path}.gz") if File.exist? "#{abs_logical_path}.gz"
  84
+        end
  85
+
  86
+        elapsed_time = ((Time.now.to_f - start_time) * 1000).to_i
  87
+        env.logger.debug "Generated non-digest assets in #{elapsed_time}ms"
  88
+      end
  89
+    end
  90
+  end
  91
+end
33  test/assets_test.rb
@@ -153,9 +153,14 @@ def assert_no_file_exists(filename)
153 153
       precompile!
154 154
       manifest = "#{app_path}/public/assets/manifest.yml"
155 155
 
156  
-      assets = YAML.load_file(manifest)
157  
-      assert_match(/application-([0-z]+)\.js/, assets["application.js"])
158  
-      assert_match(/application-([0-z]+)\.css/, assets["application.css"])
  156
+      digests = YAML.load_file(manifest)
  157
+      digest_files, source_digests = digests[:digest_files], digests[:source_digests]
  158
+
  159
+      assert_match(/application-([0-z]+)\.js/,  digest_files["application.js"])
  160
+      assert_match(/application-([0-z]+)\.css/, digest_files["application.css"])
  161
+
  162
+      assert_match(/[0-z]+/, source_digests["application.js"])
  163
+      assert_match(/[0-z]+/, source_digests["application.css"])
159 164
     end
160 165
 
161 166
     test "the manifest file should be saved by default in the same assets folder" do
@@ -167,8 +172,8 @@ def assert_no_file_exists(filename)
167 172
       precompile!
168 173
 
169 174
       manifest = "#{app_path}/public/x/manifest.yml"
170  
-      assets = YAML.load_file(manifest)
171  
-      assert_match(/application-([0-z]+)\.js/, assets["application.js"])
  175
+      digest_files = YAML.load_file(manifest)[:digest_files]
  176
+      assert_match(/application-([0-z]+)\.js/, digest_files["application.js"])
172 177
     end
173 178
 
174 179
     test "precompile does not append asset digests when config.assets.digest is false" do
@@ -183,9 +188,9 @@ def assert_no_file_exists(filename)
183 188
 
184 189
       manifest = "#{app_path}/public/assets/manifest.yml"
185 190
 
186  
-      assets = YAML.load_file(manifest)
187  
-      assert_equal "application.js", assets["application.js"]
188  
-      assert_equal "application.css", assets["application.css"]
  191
+      digest_files = YAML.load_file(manifest)[:digest_files]
  192
+      assert_equal "application.js", digest_files["application.js"]
  193
+      assert_equal "application.css", digest_files["application.css"]
189 194
     end
190 195
 
191 196
     test "assets do not require any assets group gem when manifest file is present" do
@@ -196,8 +201,8 @@ def assert_no_file_exists(filename)
196 201
       precompile!
197 202
 
198 203
       manifest = "#{app_path}/public/assets/manifest.yml"
199  
-      assets = YAML.load_file(manifest)
200  
-      asset_path = assets["application.js"]
  204
+      digest_files = YAML.load_file(manifest)[:digest_files]
  205
+      asset_path = digest_files["application.js"]
201 206
 
202 207
       require "#{app_path}/config/environment"
203 208
 
@@ -236,7 +241,7 @@ def show_detailed_exceptions?() true end
236 241
     test "assets raise AssetNotPrecompiledError when manifest file is present and requested file isn't precompiled if digest is disabled" do
237 242
       app_file "app/views/posts/index.html.erb", "<%= javascript_include_tag 'app' %>"
238 243
       add_to_config "config.assets.compile = false"
239  
-      add_to_config "config.assets.digests = false"
  244
+      add_to_config "config.assets.digest_files = false"
240 245
 
241 246
       app_file "config/routes.rb", <<-RUBY
242 247
         AppTemplate::Application.routes.draw do
@@ -286,9 +291,9 @@ def show_detailed_exceptions?() true end
286 291
       app_file "app/assets/images/rails.png", "image changed"
287 292
 
288 293
       precompile!
289  
-      assets = YAML.load_file(manifest)
  294
+      digest_files = YAML.load_file(manifest)[:digest_files]
290 295
 
291  
-      assert_not_equal asset_path, assets["application.css"]
  296
+      assert_not_equal asset_path, digest_files["application.css"]
292 297
     end
293 298
 
294 299
     test "precompile appends the md5 hash to files referenced with asset_path and run in production as default even using RAILS_GROUPS=assets" do
@@ -395,7 +400,7 @@ class ::PostsController < ActionController::Base ; end
395 400
       assert_no_match(/<script src="\/assets\/xmlhr-([0-z]+)\.js"><\/script>/, last_response.body)
396 401
     end
397 402
 
398  
-    test "assets aren't concatened when compile is true is on and debug_assets params is true" do
  403
+    test "assets aren't concatenated when compile is on and debug_assets params is true" do
399 404
       app_with_assets_in_view
400 405
       add_to_env_config "production", "config.assets.compile  = true"
401 406
       add_to_env_config "production", "config.assets.allow_debugging = true"
2  test/sprockets_helper_test.rb
@@ -351,7 +351,7 @@ def compute_host(source, request, options = {})
351 351
   end
352 352
 
353 353
   test "precedence of `config.digest = false` over manifest.yml asset digests" do
354  
-    Rails.application.config.assets.digests = {'logo.png' => 'logo-d1g3st.png'}
  354
+    Rails.application.config.assets.digest_files = {'logo.png' => 'logo-d1g3st.png'}
355 355
     @config.assets.digest = false
356 356
 
357 357
     assert_equal '/assets/logo.png',
Commit_comment_tip

Tip: You can add notes to lines in a file. Hover to the left of a line to make a note

Something went wrong with that request. Please try again.