# frozen_string_literal: true require "set" require "singleton" module Coverband module Collectors ### # This class tracks view file usage via ActiveSupport::Notifications # # This is a port of Flatfoot, a project I open sourced years ago, # but am now rolling into Coverband # https://github.com/livingsocial/flatfoot ### class ViewTracker attr_accessor :target attr_reader :logger, :roots, :store, :ignore_patterns def initialize(options = {}) raise NotImplementedError, "View Tracker requires Rails 4 or greater" unless self.class.supported_version? raise "Coverband: view tracker initialized before configuration!" if !Coverband.configured? && ENV["COVERBAND_TEST"] == "test" @project_directory = File.expand_path(Coverband.configuration.root) @ignore_patterns = Coverband.configuration.ignore @store = options.fetch(:store) { Coverband.configuration.store } @logger = options.fetch(:logger) { Coverband.configuration.logger } @target = options.fetch(:target) { Dir.glob("#{@project_directory}/app/views/**/*.html.{erb,haml,slim}") } @roots = options.fetch(:roots) { Coverband.configuration.all_root_patterns } @roots = @roots.split(",") if @roots.is_a?(String) @one_time_timestamp = false @logged_views = Set.new @views_to_record = Set.new end def logged_views @logged_views.to_a end def views_to_record @views_to_record.to_a end ### # This method is called on every render call, so we try to reduce method calls # and ensure high performance ### def track_views(_name, _start, _finish, _id, payload) if (file = payload[:identifier]) if newly_seen_file?(file) @logged_views << file @views_to_record << file if track_file?(file) end end ### # Annoyingly while you get full path for templates # notifications only pass part of the path for layouts dropping any format info # such as .html.erb or .js.erb # http://edgeguides.rubyonrails.org/active_support_instrumentation.html#render_partial-action_view ### return unless (layout_file = payload[:layout]) return unless newly_seen_file?(layout_file) @logged_views << layout_file @views_to_record << layout_file if track_file?(layout_file, layout: true) end def used_views views = redis_store.hgetall(tracker_key) normalized_views = {} views.each_pair do |view, time| roots.each do |root| view = view.gsub(root, "") end normalized_views[view] = time end normalized_views end def all_views all_views = [] target.each do |view| roots.each do |root| view = view.gsub(root, "") end all_views << view end all_views.uniq end def unused_views(used_views = nil) recently_used_views = (used_views || self.used_views).keys unused_views = all_views - recently_used_views # since layouts don't include format we count them used if they match with ANY formats unused_views.reject { |view| view.match(/\/layouts\//) && recently_used_views.any? { |used_view| view.include?(used_view) } } end def as_json used_views = self.used_views { unused_views: unused_views(used_views), used_views: used_views }.to_json end def tracking_since if (tracking_time = redis_store.get(tracker_time_key)) Time.at(tracking_time.to_i).iso8601 else "N/A" end end def reset_recordings redis_store.del(tracker_key) redis_store.del(tracker_time_key) end def clear_file!(filename) return unless filename filename = "#{@project_directory}/#{filename}" redis_store.hdel(tracker_key, filename) @logged_views.delete(filename) end def report_views_tracked redis_store.set(tracker_time_key, Time.now.to_i) unless @one_time_timestamp || tracker_time_key_exists? @one_time_timestamp = true reported_time = Time.now.to_i @views_to_record.each do |file| redis_store.hset(tracker_key, file, reported_time) end @views_to_record.clear rescue => e # we don't want to raise errors if Coverband can't reach redis. # This is a nice to have not a bring the system down logger&.error "Coverband: view_tracker failed to store, error #{e.class.name}" end def self.supported_version? defined?(Rails) && defined?(Rails::VERSION) && Rails::VERSION::STRING.split(".").first.to_i >= 4 end protected def newly_seen_file?(file) !@logged_views.include?(file) end def track_file?(file, options = {}) (file.start_with?(@project_directory) || options[:layout]) && @ignore_patterns.none? { |pattern| file.include?(pattern) } end private def redis_store store.raw_store end def tracker_time_key_exists? if defined?(redis_store.exists?) redis_store.exists?(tracker_time_key) else redis_store.exists(tracker_time_key) end end def tracker_key "render_tracker_2" end def tracker_time_key "render_tracker_time" end end end end