require 'find' require 'digest' require 'uri' require 'thread/pool' Thread.abort_on_exception = true Thread::Pool.abort_on_exception = true module Percy class Cli class SnapshotRunner attr_reader :client # Static resource types that an HTML file might load and that we want to upload for rendering. STATIC_RESOURCE_EXTENSIONS = [ '.css', '.js', '.jpg', '.jpeg', '.gif', '.ico', '.png', '.bmp', '.pict', '.tif', '.tiff', '.ttf', '.eot', '.woff', '.otf', '.svg', '.svgz', '.webp', '.ps', ].freeze DEFAULT_SNAPSHOTS_REGEX = /\.(html|htm)$/ MAX_FILESIZE_BYTES = 15 * 1024**2 # 15 MB. def initialize @client = Percy::Client.new(client_info: "percy-cli/#{VERSION}", environment_info: '') end def run(root_dir, options = {}) repo = options[:repo] || Percy.config.repo root_dir = File.expand_path(File.absolute_path(root_dir)) strip_prefix = File.expand_path(File.absolute_path(options[:strip_prefix] || root_dir)) num_threads = options[:threads] || 10 snapshot_limit = options[:snapshot_limit] baseurl = options[:baseurl] || '/' enable_javascript = !!options[:enable_javascript] include_all = !!options[:include_all] widths = options[:widths].map { |w| Integer(w) } raise ArgumentError, 'baseurl must start with /' if baseurl[0] != '/' base_resource_options = {strip_prefix: strip_prefix, baseurl: baseurl} # Find all the static files in the given root directory. root_paths = _find_root_paths(root_dir, snapshots_regex: options[:snapshots_regex]) resource_paths = _find_resource_paths(root_dir, include_all: include_all) root_resources = _list_resources(root_paths, base_resource_options.merge(is_root: true)) build_resources = _list_resources(resource_paths, base_resource_options) all_resources = root_resources + build_resources if root_resources.empty? say 'No root resource files found. Are there HTML files in the given directory?' exit(-1) end build_resources.each do |resource| Percy.logger.debug { "Found build resource: #{resource.resource_url}" } end build = _rescue_connection_failures do say 'Creating build...' build = client.create_build(repo, resources: build_resources) say 'Uploading build resources...' _upload_missing_resources(build, build, all_resources, num_threads: num_threads) build end return if _failed? # Upload a snapshot for every root resource, and associate the build_resources. output_lock = Mutex.new snapshot_thread_pool = Thread.pool(num_threads) total = snapshot_limit ? [root_resources.length, snapshot_limit].min : root_resources.length root_resources.each_with_index do |root_resource, i| break if snapshot_limit && i + 1 > snapshot_limit snapshot_thread_pool.process do output_lock.synchronize do say "Uploading snapshot (#{i + 1}/#{total}): #{root_resource.resource_url}" end _rescue_connection_failures do snapshot = client.create_snapshot( build['data']['id'], [root_resource], enable_javascript: enable_javascript, widths: widths, ) _upload_missing_resources(build, snapshot, all_resources, num_threads: num_threads) client.finalize_snapshot(snapshot['data']['id']) end end end snapshot_thread_pool.wait snapshot_thread_pool.shutdown # Finalize the build. say 'Finalizing build...' _rescue_connection_failures { client.finalize_build(build['data']['id']) } return if _failed? say 'Done! Percy is now processing, you can view the visual diffs here:' say build['data']['attributes']['web-url'] end def _failed? !!@failed end def _rescue_connection_failures raise ArgumentError, 'block is requried' unless block_given? begin yield rescue Percy::Client::ServerError, # Rescue server errors. Percy::Client::UnauthorizedError, # Rescue unauthorized errors (no auth creds setup). Percy::Client::PaymentRequiredError, # Rescue quota exceeded errors. Percy::Client::ConflictError, # Rescue project disabled errors and others. Percy::Client::ConnectionFailed, # Rescue some networking errors. Percy::Client::TimeoutError => e Percy.logger.error(e) @failed = true nil end end def _find_root_paths(dir_path, options = {}) _find_files(dir_path).select { |path| _include_root_path?(path, options) } end def _find_resource_paths(dir_path, options = {}) _find_files(dir_path).select { |path| _include_resource_path?(path, options) } end def _list_resources(paths, options = {}) strip_prefix = File.expand_path(options[:strip_prefix]) baseurl = options[:baseurl] resources = [] # Strip trailing slash from strip_prefix. strip_prefix = strip_prefix[0..-2] if strip_prefix[-1] == '/' paths.each do |path| sha = Digest::SHA256.hexdigest(File.read(path)) next if File.size(path) > MAX_FILESIZE_BYTES resource_url = URI.escape(File.join(baseurl, path.sub(strip_prefix, ''))) resources << Percy::Client::Resource.new( resource_url, sha: sha, is_root: options[:is_root], path: path, ) end resources end # Uploads missing resources either for a build or snapshot. def _upload_missing_resources(build, obj, potential_resources, options = {}) # Upload the content for any missing resources. missing_resources = obj['data']['relationships']['missing-resources']['data'] bar = Commander::UI::ProgressBar.new( missing_resources.length, title: 'Uploading resources...', format: ':title |:progress_bar| :percent_complete% complete - :resource_url', width: 20, complete_message: nil, ) output_lock = Mutex.new uploader_thread_pool = Thread.pool(options[:num_threads] || 10) missing_resources.each do |missing_resource| uploader_thread_pool.process do missing_resource_sha = missing_resource['id'] resource = potential_resources.find { |r| r.sha == missing_resource_sha } output_lock.synchronize do bar.increment resource_url: resource.resource_url end # Remote resources are stored in 'content', local resources are # read from the filesystem. content = resource.content || File.read(resource.path.to_s) client.upload_resource(build['data']['id'], content) end end uploader_thread_pool.wait uploader_thread_pool.shutdown end # A file find method that follows directory and file symlinks. def _find_files(*paths) paths.flatten! paths.map! { |p| Pathname.new(p) } files = paths.select(&:file?) (paths - files).each do |dir| files << _find_files(dir.children) end files.flatten.map(&:to_s) end def _include_resource_path?(path, options) # Skip git files. return false if path =~ /\/\.git\// return true if options[:include_all] STATIC_RESOURCE_EXTENSIONS.include?(File.extname(path)) end def _include_root_path?(path, options) # Skip git files. return false if path =~ /\/\.git\// # Skip files that don't match the snapshots_regex. snapshots_regex = options[:snapshots_regex] || DEFAULT_SNAPSHOTS_REGEX path.match(snapshots_regex) end end end end