diff --git a/lib/coverband.rb b/lib/coverband.rb index ab8ccca8..7c357e83 100644 --- a/lib/coverband.rb +++ b/lib/coverband.rb @@ -16,6 +16,7 @@ require "coverband/adapters/null_store" require "coverband/utils/file_hasher" require "coverband/collectors/coverage" +require "coverband/collectors/abstract_tracker" require "coverband/collectors/view_tracker" require "coverband/collectors/view_tracker_service" require "coverband/collectors/route_tracker" diff --git a/lib/coverband/at_exit.rb b/lib/coverband/at_exit.rb index 837de5c0..0464572a 100644 --- a/lib/coverband/at_exit.rb +++ b/lib/coverband/at_exit.rb @@ -22,7 +22,8 @@ def self.register Coverband.report_coverage # to ensure we track mailer views we now need to report views tracking # at exit as well for rake tasks and background tasks that can trigger email - Coverband.configuration.view_tracker&.report_views_tracked + Coverband.configuration.view_tracker&.save_report + Coverband.configuration.translations_tracker&.save_report end end end diff --git a/lib/coverband/collectors/abstract_tracker.rb b/lib/coverband/collectors/abstract_tracker.rb new file mode 100644 index 00000000..1dcd562a --- /dev/null +++ b/lib/coverband/collectors/abstract_tracker.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require "set" +require "singleton" + +module Coverband + module Collectors + ### + # This abstract class makes it easy to track any used/unused with timestamp set of usage + ### + class AbstractTracker + REPORT_ROUTE = "/" + TITLE = "abstract" + + attr_accessor :target + attr_reader :logger, :store, :ignore_patterns + + def initialize(options = {}) + raise NotImplementedError, "#{self.class.name} requires a newer version of Rails" unless self.class.supported_version? + raise "Coverband: #{self.class.name} initialized before configuration!" if !Coverband.configured? && ENV["COVERBAND_TEST"] == "test" + + @ignore_patterns = Coverband.configuration.ignore + @store = options.fetch(:store) { Coverband.configuration.store } + @logger = options.fetch(:logger) { Coverband.configuration.logger } + @target = options.fetch(:target) do + concrete_target + end + + @one_time_timestamp = false + + @logged_keys = Set.new + @keys_to_record = Set.new + end + + def logged_keys + @logged_keys.to_a + end + + def keys_to_record + @keys_to_record.to_a + end + + ### + # This method is called on every translation usage + ### + def track_key(key) + if key + if newly_seen_key?(key) + @logged_keys << key + @keys_to_record << key if track_key?(key) + end + end + end + + def used_keys + redis_store.hgetall(tracker_key) + end + + def all_keys + target.uniq + end + + def unused_keys(used_keys = nil) + recently_used_keys = (used_keys || self.used_keys).keys + all_keys.reject { |k| recently_used_keys.include?(k.to_s) } + end + + def as_json + used_keys = self.used_keys + { + unused_keys: unused_keys(used_keys), + used_keys: used_keys + }.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_key!(key) + return unless key + puts "#{tracker_key} key #{key}" + redis_store.hdel(tracker_key, key) + @logged_keys.delete(key) + end + + def save_report + 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 + @keys_to_record.to_a.each do |key| + redis_store.hset(tracker_key, key.to_s, reported_time) + end + @keys_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: #{self.class.name} failed to store, error #{e.class.name} info #{e.message}" + end + + # This is the basic rails version supported, if there is something more unique over ride in subclass + def self.supported_version? + defined?(Rails) && defined?(Rails::VERSION) && Rails::VERSION::STRING.split(".").first.to_i >= 5 + end + + def route + self.class::REPORT_ROUTE + end + + def title + self.class::TITLE + end + + protected + + def newly_seen_key?(key) + !@logged_keys.include?(key) + end + + def track_key?(key, options = {}) + @ignore_patterns.none? { |pattern| key.to_s.include?(pattern) } + end + + private + + def concrete_target + raise "subclass must implement" + end + + 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 + "#{class_key}_tracker" + end + + def tracker_time_key + "#{class_key}_tracker_time" + end + + def class_key + @class_key ||= self.class.name.split("::").last + end + end + end +end diff --git a/lib/coverband/collectors/route_tracker.rb b/lib/coverband/collectors/route_tracker.rb index eb77a4d1..e70d409c 100644 --- a/lib/coverband/collectors/route_tracker.rb +++ b/lib/coverband/collectors/route_tracker.rb @@ -8,51 +8,23 @@ module Collectors ### # This class tracks route usage via ActiveSupport::Notifications ### - class RouteTracker - attr_accessor :target - attr_reader :logger, :store, :ignore_patterns + class RouteTracker < AbstractTracker + REPORT_ROUTE = "routes_tracker" + TITLE = "Routes" def initialize(options = {}) - raise NotImplementedError, "Route Tracker requires Rails 4 or greater" unless self.class.supported_version? - raise "Coverband: route tracker initialized before configuration!" if !Coverband.configured? && ENV["COVERBAND_TEST"] == "test" - - @ignore_patterns = Coverband.configuration.ignore - @store = options.fetch(:store) { Coverband.configuration.store } - @logger = options.fetch(:logger) { Coverband.configuration.logger } - @target = options.fetch(:target) do - if defined?(Rails.application) - Rails.application.routes.routes.map do |route| - { - controller: route.defaults[:controller], - action: route.defaults[:action], - url_path: route.path.spec.to_s.gsub("(.:format)", ""), - verb: route.verb - } - end - else - [] - end + if Rails&.respond_to?(:version) && Gem::Version.new(Rails.version) >= Gem::Version.new("6.0.0") && Gem::Version.new(Rails.version) < Gem::Version.new("7.1.0") + require_relative "../utils/rails6_ext" end - @one_time_timestamp = false - - @logged_routes = Set.new - @routes_to_record = Set.new - end - - def logged_routes - @logged_routes.to_a - end - - def routes_to_record - @routes_to_record.to_a + super end ### # This method is called on every routing call, so we try to reduce method calls # and ensure high performance ### - def track_routes(_name, _start, _finish, _id, payload) + def track_key(payload) route = if payload[:request] { controller: nil, @@ -69,104 +41,53 @@ def track_routes(_name, _start, _finish, _id, payload) } end if route - if newly_seen_route?(route) - @logged_routes << route - @routes_to_record << route if track_route?(route) + if newly_seen_key?(route) + @logged_keys << route + @keys_to_record << route if track_key?(route) end end end - def used_routes - redis_store.hgetall(tracker_key) - end - - def all_routes - target.uniq + def self.supported_version? + defined?(Rails) && defined?(Rails::VERSION) && Rails::VERSION::STRING.split(".").first.to_i >= 6 end - def unused_routes(used_routes = nil) - recently_used_routes = (used_routes || self.used_routes).keys + def unused_keys(used_keys = nil) + recently_used_routes = (used_keys || self.used_keys).keys # NOTE: we match with or without path to handle paths with named params like `/user/:user_id` to used routes filling with all the variable named paths - all_routes.reject { |r| recently_used_routes.include?(r.to_s) || recently_used_routes.include?(r.merge(url_path: nil).to_s) } - end - - def as_json - used_routes = self.used_routes - { - unused_routes: unused_routes(used_routes), - used_routes: used_routes - }.to_json + all_keys.reject { |r| recently_used_routes.include?(r.to_s) || recently_used_routes.include?(r.merge(url_path: nil).to_s) } end - def tracking_since - if (tracking_time = redis_store.get(tracker_time_key)) - Time.at(tracking_time.to_i).iso8601 - else - "N/A" + def railtie! + ActiveSupport::Notifications.subscribe("start_processing.action_controller") do |name, start, finish, id, payload| + Coverband.configuration.route_tracker.track_key(payload) end - end - - def reset_recordings - redis_store.del(tracker_key) - redis_store.del(tracker_time_key) - end - - def clear_route!(route) - return unless route - redis_store.hdel(tracker_key, route) - @logged_routes.delete(route) - end - - def report_routes_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 - @routes_to_record.to_a.each do |route| - redis_store.hset(tracker_key, route.to_s, reported_time) + # NOTE: This event was instrumented in Aug 10th 2022, but didn't make the 7.0.4 release and should be in the next release + # https://github.com/rails/rails/pull/43755 + # Automatic tracking of redirects isn't avaible before Rails 7.1.0 (currently tested against the 7.1.0.alpha) + # We could consider back porting or patching a solution that works on previous Rails versions + ActiveSupport::Notifications.subscribe("redirect.action_dispatch") do |name, start, finish, id, payload| + Coverband.configuration.route_tracker.track_key(payload) end - @routes_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: route_tracker failed to store, error #{e.class.name} info #{e.message}" - end - - def self.supported_version? - defined?(Rails) && defined?(Rails::VERSION) && Rails::VERSION::STRING.split(".").first.to_i >= 4 - end - - protected - - def newly_seen_route?(route) - !@logged_routes.include?(route) - end - - def track_route?(route, options = {}) - @ignore_patterns.none? { |pattern| route.to_s.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) + def concrete_target + if defined?(Rails.application) + Rails.application.routes.routes.map do |route| + { + controller: route.defaults[:controller], + action: route.defaults[:action], + url_path: route.path.spec.to_s.gsub("(.:format)", ""), + verb: route.verb + } + end else - redis_store.exists(tracker_time_key) + [] end end - - def tracker_key - "route_tracker_2" - end - - def tracker_time_key - "route_tracker_time" - end end end end diff --git a/lib/coverband/collectors/translation_tracker.rb b/lib/coverband/collectors/translation_tracker.rb index b6e29b9e..6c4050b2 100644 --- a/lib/coverband/collectors/translation_tracker.rb +++ b/lib/coverband/collectors/translation_tracker.rb @@ -20,129 +20,30 @@ def lookup(locale, key, scope = [], options = {}) ### # This class tracks translation usage via I18n::Backend ### - class TranslationTracker - attr_accessor :target - attr_reader :logger, :store, :ignore_patterns + class TranslationTracker < AbstractTracker + REPORT_ROUTE = "translations_tracker" + TITLE = "Translations" - def initialize(options = {}) - raise NotImplementedError, "#{self.class.name} requires Rails 4 or greater" unless self.class.supported_version? - raise "Coverband: #{self.class.name} initialized before configuration!" if !Coverband.configured? && ENV["COVERBAND_TEST"] == "test" - - @ignore_patterns = Coverband.configuration.ignore - @store = options.fetch(:store) { Coverband.configuration.store } - @logger = options.fetch(:logger) { Coverband.configuration.logger } - @target = options.fetch(:target) do - if defined?(Rails.application) - # I18n.eager_load! - # I18n.backend.send(:translations) - app_translation_keys = [] - app_translation_files = ::I18n.load_path.select { |f| f.match(/config\/locales/) } - app_translation_files.each do |file| - app_translation_keys += flatten_hash(YAML.load_file(file)).keys - end - app_translation_keys.uniq - else - [] - end - end - - @one_time_timestamp = false - - @logged_keys = Set.new - @keys_to_record = Set.new + def railtie! + # plugin to i18n + ::I18n::Backend::Simple.send :include, ::Coverband::Collectors::I18n::KeyRegistry end - def logged_keys - @logged_keys.to_a - end - - def keys_to_record - @keys_to_record.to_a - end + private - ### - # This method is called on every translation usage - ### - def track_key(key) - if key - if newly_seen_key?(key) - @logged_keys << key - @keys_to_record << key if track_key?(key) + def concrete_target + if defined?(Rails.application) + app_translation_keys = [] + app_translation_files = ::I18n.load_path.select { |f| f.match(/config\/locales/) } + app_translation_files.each do |file| + app_translation_keys += flatten_hash(YAML.load_file(file)).keys end - end - end - - def used_keys - redis_store.hgetall(tracker_key) - end - - def all_keys - target.uniq - end - - def unused_keys(used_keys = nil) - recently_used_keys = (used_keys || self.used_keys).keys - all_keys.reject { |k| recently_used_keys.include?(k.to_s) } - end - - def as_json - used_keys = self.used_keys - { - unused_keys: unused_keys(used_keys), - used_keys: used_keys - }.to_json - end - - def tracking_since - if (tracking_time = redis_store.get(tracker_time_key)) - Time.at(tracking_time.to_i).iso8601 + app_translation_keys.uniq else - "N/A" + [] end end - def reset_recordings - redis_store.del(tracker_key) - redis_store.del(tracker_time_key) - end - - def clear_key!(key) - return unless key - - redis_store.hdel(tracker_key, key) - @logged_keys.delete(key) - end - - def save_report - 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 - @keys_to_record.to_a.each do |key| - redis_store.hset(tracker_key, key.to_s, reported_time) - end - @keys_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: #{self.class.name} failed to store, error #{e.class.name} info #{e.message}" - end - - def self.supported_version? - defined?(Rails) && defined?(Rails::VERSION) && Rails::VERSION::STRING.split(".").first.to_i >= 4 - end - - protected - - def newly_seen_key?(key) - !@logged_keys.include?(key) - end - - def track_key?(key, options = {}) - @ignore_patterns.none? { |pattern| key.to_s.include?(pattern) } - end - - private - def flatten_hash(hash) hash.each_with_object({}) do |(k, v), h| if v.is_a? Hash @@ -154,30 +55,6 @@ def flatten_hash(hash) end end end - - 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 - "#{class_key}_tracker" - end - - def tracker_time_key - "#{class_key}_tracker_time" - end - - def class_key - @class_key ||= self.class.name.split("::").last - end end end end diff --git a/lib/coverband/collectors/view_tracker.rb b/lib/coverband/collectors/view_tracker.rb index 61775c2a..09979af8 100644 --- a/lib/coverband/collectors/view_tracker.rb +++ b/lib/coverband/collectors/view_tracker.rb @@ -12,45 +12,35 @@ module Collectors # but am now rolling into Coverband # https://github.com/livingsocial/flatfoot ### - class ViewTracker - attr_accessor :target - attr_reader :logger, :roots, :store, :ignore_patterns + class ViewTracker < AbstractTracker + attr_reader :roots - 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" + REPORT_ROUTE = "views_tracker" + TITLE = "Views" + def initialize(options = {}) @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 + super end - def views_to_record - @views_to_record.to_a + def railtie! + ActiveSupport::Notifications.subscribe(/render_(template|partial|collection).action_view/) do |name, start, finish, id, payload| + Coverband.configuration.view_tracker.track_key(payload) unless name.include?("!") + end 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) + def track_key(payload) if (file = payload[:identifier]) - if newly_seen_file?(file) - @logged_views << file - @views_to_record << file if track_file?(file) + if newly_seen_key?(file) + @logged_keys << file + @keys_to_record << file if track_file?(file) end end @@ -61,13 +51,13 @@ def track_views(_name, _start, _finish, _id, payload) # 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) + return unless newly_seen_key?(layout_file) - @logged_views << layout_file - @views_to_record << layout_file if track_file?(layout_file, layout: true) + @logged_keys << layout_file + @keys_to_record << layout_file if track_file?(layout_file, layout: true) end - def used_views + def used_keys views = redis_store.hgetall(tracker_key) normalized_views = {} views.each_pair do |view, time| @@ -79,7 +69,7 @@ def used_views normalized_views end - def all_views + def all_keys all_views = [] target.each do |view| roots.each do |root| @@ -90,92 +80,35 @@ def all_views 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 + def unused_keys(used_views = nil) + recently_used_views = (used_keys || used_keys).keys + unused_views = all_keys - 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) + def clear_key!(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.to_a.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 + @logged_keys.delete(filename) end - protected - - def newly_seen_file?(file) - !@logged_views.include?(file) - end + private 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) + def concrete_target + if defined?(Rails.application) + Dir.glob("#{@project_directory}/app/views/**/*.html.{erb,haml,slim}") 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 diff --git a/lib/coverband/collectors/view_tracker_service.rb b/lib/coverband/collectors/view_tracker_service.rb index aafdfdb0..1e3996cf 100644 --- a/lib/coverband/collectors/view_tracker_service.rb +++ b/lib/coverband/collectors/view_tracker_service.rb @@ -6,7 +6,7 @@ module Collectors # This class extends view tracker to support web service reporting ### class ViewTrackerService < ViewTracker - def report_views_tracked + def save_report reported_time = Time.now.to_i if @views_to_record.any? relative_views = @views_to_record.map! do |view| diff --git a/lib/coverband/configuration.rb b/lib/coverband/configuration.rb index 33e9eaaf..76276a4a 100644 --- a/lib/coverband/configuration.rb +++ b/lib/coverband/configuration.rb @@ -10,7 +10,8 @@ class Configuration :simulate_oneshot_lines_coverage, :view_tracker, :defer_eager_loading_data, :track_routes, :route_tracker, - :track_translations, :translations_tracker + :track_translations, :translations_tracker, + :trackers attr_writer :logger, :s3_region, :s3_bucket, :s3_access_key_id, :s3_secret_access_key, :password, :api_key, :service_url, :coverband_timeout, :service_dev_mode, :service_test_mode, :process_type, :track_views, :redis_url, @@ -95,6 +96,8 @@ def reset @redis_ttl = 2_592_000 # in seconds. Default is 30 days. @reporting_wiggle = nil + @trackers = [] + # TODO: these are deprecated @s3_region = nil @s3_bucket = nil @@ -104,6 +107,31 @@ def reset @gem_details = false end + def railtie! + if Coverband.configuration.track_routes + Coverband.configuration.route_tracker = Coverband::Collectors::RouteTracker.new + trackers << Coverband.configuration.route_tracker + end + + if Coverband.configuration.track_translations + Coverband.configuration.translations_tracker = Coverband::Collectors::TranslationTracker.new + trackers << Coverband.configuration.translations_tracker + end + + if Coverband.configuration.track_views + Coverband.configuration.view_tracker = if Coverband.coverband_service? + Coverband::Collectors::ViewTrackerService.new + else + Coverband::Collectors::ViewTracker.new + end + trackers << Coverband.configuration.view_tracker + end + trackers.each { |tracker| tracker.railtie! } + rescue Redis::CannotConnectError => error + Coverband.configuration.logger.info "Redis is not available (#{error}), Coverband not configured" + Coverband.configuration.logger.info "If this is a setup task like assets:precompile feel free to ignore" + end + def logger @logger ||= if defined?(Rails.logger) && Rails.logger Rails.logger diff --git a/lib/coverband/integrations/background.rb b/lib/coverband/integrations/background.rb index 8d6d3ae6..d7de3fc4 100644 --- a/lib/coverband/integrations/background.rb +++ b/lib/coverband/integrations/background.rb @@ -38,9 +38,7 @@ def self.start # if deferred is set also sleep frst to spread load sleep(sleep_seconds.to_i) if Coverband.configuration.defer_eager_loading_data? Coverband.report_coverage - Coverband.configuration.view_tracker&.report_views_tracked - Coverband.configuration.route_tracker&.report_routes_tracked - Coverband.configuration.translations_tracker&.save_report + Coverband.configuration.trackers.each { |tracker| tracker.save_report } if Coverband.configuration.verbose logger.debug("Coverband: background reporting coverage (#{Coverband.configuration.store.type}). Sleeping #{sleep_seconds}s") end diff --git a/lib/coverband/reporters/web.rb b/lib/coverband/reporters/web.rb index f0db3580..80676ef5 100644 --- a/lib/coverband/reporters/web.rb +++ b/lib/coverband/reporters/web.rb @@ -36,51 +36,49 @@ def call(env) return [401, {"www-authenticate" => 'Basic realm=""'}, [""]] unless check_auth request_path_info = request.path_info == "" ? "/" : request.path_info - if request.post? - case request_path_info - when %r{\/clear_route_tracking_route} - clear_route_tracking_route - when %r{\/clear_route_tracking} - clear_route_tracking - when %r{\/clear_translation_tracking_key} - clear_route_translation_key - when %r{\/clear_translation_tracking} - clear_translation_tracking - when %r{\/clear_view_tracking_file} - clear_view_tracking_file - when %r{\/clear_view_tracking} - clear_view_tracking - when %r{\/clear_file} - clear_file - when %r{\/clear} - clear - else - [404, {"Content-Type" => "text/html"}, ["404 error!"]] + tracker_route = false + Coverband.configuration.trackers.each do |tracker| + if request_path_info.match(tracker.class::REPORT_ROUTE) + tracker_route = true + if request_path_info =~ %r{\/clear_.*_key} + return clear_abstract_tracking_key(tracker) + elsif request_path_info =~ %r{\/clear_.*} + return clear_abstract_tracking(tracker) + else + return [200, {"Content-Type" => "text/html"}, [display_abstract_tracker(tracker)]] + end end - else - case request_path_info - when /.*\.(css|js|gif|png)/ - @static.call(env) - when %r{\/settings} - [200, {"Content-Type" => "text/html"}, [settings]] - when %r{\/view_tracker_data} - [200, {"Content-Type" => "text/json"}, [view_tracker_data]] - when %r{\/view_tracker} - [200, {"Content-Type" => "text/html"}, [view_tracker]] - when %r{\/route_tracker} - [200, {"Content-Type" => "text/html"}, [route_tracker]] - when %r{\/translations_tracker} - [200, {"Content-Type" => "text/html"}, [translations_tracker]] - when %r{\/enriched_debug_data} - [200, {"Content-Type" => "text/json"}, [enriched_debug_data]] - when %r{\/debug_data} - [200, {"Content-Type" => "text/json"}, [debug_data]] - when %r{\/load_file_details} - [200, {"Content-Type" => "text/json"}, [load_file_details]] - when %r{\/$} - [200, {"Content-Type" => "text/html"}, [index]] + end + + unless tracker_route + if request.post? + case request_path_info + when %r{\/clear_file} + clear_file + when %r{\/clear} + clear + else + [404, {"Content-Type" => "text/html"}, ["404 error!"]] + end else - [404, {"Content-Type" => "text/html"}, ["404 error!"]] + case request_path_info + when /.*\.(css|js|gif|png)/ + @static.call(env) + when %r{\/settings} + [200, {"Content-Type" => "text/html"}, [settings]] + when %r{\/view_tracker_data} + [200, {"Content-Type" => "text/json"}, [view_tracker_data]] + when %r{\/enriched_debug_data} + [200, {"Content-Type" => "text/json"}, [enriched_debug_data]] + when %r{\/debug_data} + [200, {"Content-Type" => "text/json"}, [debug_data]] + when %r{\/load_file_details} + [200, {"Content-Type" => "text/json"}, [load_file_details]] + when %r{\/$} + [200, {"Content-Type" => "text/html"}, [index]] + else + [404, {"Content-Type" => "text/html"}, ["404 error!"]] + end end end end @@ -99,32 +97,17 @@ def settings Coverband::Utils::HTMLFormatter.new(nil, base_path: base_path).format_settings! end - def view_tracker - notice = "Notice: #{Rack::Utils.escape_html(request.params["notice"])}
" - notice = request.params["notice"] ? notice : "" - Coverband::Utils::HTMLFormatter.new(nil, - notice: notice, - base_path: base_path).format_view_tracker! - end - - def route_tracker - notice = "Notice: #{Rack::Utils.escape_html(request.params["notice"])}
" - notice = request.params["notice"] ? notice : "" - Coverband::Utils::HTMLFormatter.new(nil, - notice: notice, - base_path: base_path).format_route_tracker! - end - - def translations_tracker + def display_abstract_tracker(tracker) notice = "Notice: #{Rack::Utils.escape_html(request.params["notice"])}
" notice = request.params["notice"] ? notice : "" Coverband::Utils::HTMLFormatter.new(nil, + tracker: tracker, notice: notice, - base_path: base_path).format_translations_tracker! + base_path: base_path).format_abstract_tracker! end def view_tracker_data - Coverband::Collectors::ViewTracker.new(store: Coverband.configuration.store).as_json + Coverband::Collectors::ViewTracker.new.as_json end def debug_data @@ -168,73 +151,25 @@ def clear_file [302, {"Location" => "#{base_path}?notice=#{notice}"}, []] end - def clear_view_tracking - if Coverband.configuration.web_enable_clear - tracker = Coverband::Collectors::ViewTracker.new(store: Coverband.configuration.store) - tracker.reset_recordings - notice = "view tracking reset" - else - notice = "web_enable_clear isn't enabled in your configuration" - end - [302, {"Location" => "#{base_path}/view_tracker?notice=#{notice}"}, []] - end - - def clear_view_tracking_file - if Coverband.configuration.web_enable_clear - tracker = Coverband::Collectors::ViewTracker.new(store: Coverband.configuration.store) - filename = request.params["filename"] - tracker.clear_file!(filename) - notice = "coverage for file #{filename} cleared" - else - notice = "web_enable_clear isn't enabled in your configuration" - end - [302, {"Location" => "#{base_path}/view_tracker?notice=#{notice}"}, []] - end - - def clear_route_tracking - if Coverband.configuration.web_enable_clear - tracker = Coverband::Collectors::RouteTracker.new(store: Coverband.configuration.store) - tracker.reset_recordings - notice = "route tracking reset" - else - notice = "web_enable_clear isn't enabled in your configuration" - end - [302, {"Location" => "#{base_path}/route_tracker?notice=#{notice}"}, []] - end - - def clear_route_tracking_route - if Coverband.configuration.web_enable_clear - tracker = Coverband::Collectors::RouteTracker.new(store: Coverband.configuration.store) - route = request.params["route"] - tracker.clear_route!(route) - notice = "coverage for route #{route} cleared" - else - notice = "web_enable_clear isn't enabled in your configuration" - end - [302, {"Location" => "#{base_path}/route_tracker?notice=#{notice}"}, []] - end - - def clear_translation_tracking + def clear_abstract_tracking(tracker) if Coverband.configuration.web_enable_clear - tracker = Coverband::Collectors::TranslationTracker.new(store: Coverband.configuration.store) tracker.reset_recordings - notice = "translation tracking reset" + notice = "#{tracker.title} tracking reset" else notice = "web_enable_clear isn't enabled in your configuration" end - [302, {"Location" => "#{base_path}/translations_tracker?notice=#{notice}"}, []] + [302, {"Location" => "#{base_path}/#{tracker.route}?notice=#{notice}"}, []] end - def clear_translation_tracking_key + def clear_abstract_tracking_key(tracker) if Coverband.configuration.web_enable_clear - tracker = Coverband::Collectors::TranslationTracker.new(store: Coverband.configuration.store) key = request.params["key"] tracker.clear_key!(key) - notice = "coverage for route #{key} cleared" + notice = "coverage for #{tracker.title} #{key} cleared" else notice = "web_enable_clear isn't enabled in your configuration" end - [302, {"Location" => "#{base_path}/translations_tracker?notice=#{notice}"}, []] + [302, {"Location" => "#{base_path}/#{tracker.route}?notice=#{notice}"}, []] end private diff --git a/lib/coverband/utils/html_formatter.rb b/lib/coverband/utils/html_formatter.rb index f900e2bb..08f65434 100644 --- a/lib/coverband/utils/html_formatter.rb +++ b/lib/coverband/utils/html_formatter.rb @@ -13,11 +13,12 @@ module Coverband module Utils class HTMLFormatter - attr_reader :notice, :base_path + attr_reader :notice, :base_path, :tracker def initialize(report, options = {}) @notice = options.fetch(:notice, nil) @base_path = options.fetch(:base_path, "./") + @tracker = options.fetch(:tracker, nil) @coverage_result = Coverband::Utils::Results.new(report) if report end @@ -33,16 +34,8 @@ def format_settings! format_settings end - def format_view_tracker! - format_view_tracker - end - - def format_route_tracker! - format_route_tracker - end - - def format_translations_tracker! - format_translations_tracker + def format_abstract_tracker! + template("abstract_tracker").result(binding) end def format_source_file!(filename) @@ -61,18 +54,6 @@ def format_settings template("settings").result(binding) end - def format_view_tracker - template("view_tracker").result(binding) - end - - def format_route_tracker - template("route_tracker").result(binding) - end - - def format_translations_tracker - template("translations_tracker").result(binding) - end - def format(result) Dir[File.join(File.dirname(__FILE__), "../../../public/*")].each do |path| FileUtils.cp_r(path, asset_output_path) diff --git a/lib/coverband/utils/railtie.rb b/lib/coverband/utils/railtie.rb index 632b5e29..9f42559e 100644 --- a/lib/coverband/utils/railtie.rb +++ b/lib/coverband/utils/railtie.rb @@ -27,51 +27,7 @@ class Railtie < Rails::Railtie Coverband.runtime_coverage! end - begin - if Coverband.configuration.track_routes - if Gem::Version.new(Rails.version) >= Gem::Version.new("6.0.0") && Gem::Version.new(Rails.version) < Gem::Version.new("7.1.0") - require_relative "rails6_ext" - end - - Coverband.configuration.route_tracker = Coverband::Collectors::RouteTracker.new - - ActiveSupport::Notifications.subscribe("start_processing.action_controller") do |name, start, finish, id, payload| - Coverband.configuration.route_tracker.track_routes(name, start, finish, id, payload) - end - - # NOTE: This event was instrumented in Aug 10th 2022, but didn't make the 7.0.4 release and should be in the next release - # https://github.com/rails/rails/pull/43755 - # Automatic tracking of redirects isn't avaible before Rails 7.1.0 (currently tested against the 7.1.0.alpha) - # We could consider back porting or patching a solution that works on previous Rails versions - ActiveSupport::Notifications.subscribe("redirect.action_dispatch") do |name, start, finish, id, payload| - Coverband.configuration.route_tracker.track_routes(name, start, finish, id, payload) - end - end - - if Coverband.configuration.track_translations - Coverband.configuration.translations_tracker = Coverband::Collectors::TranslationTracker.new - - # plugin to i18n - I18n::Backend::Simple.send :include, Coverband::Collectors::I18n::KeyRegistry - end - - if Coverband.configuration.track_views - COVERBAND_VIEW_TRACKER = if Coverband.coverband_service? - Coverband::Collectors::ViewTrackerService.new - else - Coverband::Collectors::ViewTracker.new - end - - Coverband.configuration.view_tracker = COVERBAND_VIEW_TRACKER - - ActiveSupport::Notifications.subscribe(/render_(template|partial|collection).action_view/) do |name, start, finish, id, payload| - COVERBAND_VIEW_TRACKER.track_views(name, start, finish, id, payload) unless name.include?("!") - end - end - rescue Redis::CannotConnectError => error - Coverband.configuration.logger.info "Redis is not available (#{error}), Coverband not configured" - Coverband.configuration.logger.info "If this is a setup task like assets:precompile feel free to ignore" - end + Coverband.configuration.railtie! end config.before_configuration do diff --git a/lib/coverband/version.rb b/lib/coverband/version.rb index 887ffc89..fe101668 100644 --- a/lib/coverband/version.rb +++ b/lib/coverband/version.rb @@ -5,5 +5,5 @@ # use format "4.2.1.rc.1" ~> 4.2.1.rc to prerelease versions like v4.2.1.rc.2 and v4.2.1.rc.3 ### module Coverband - VERSION = "5.2.6.rc.1" + VERSION = "5.2.6.rc.2" end diff --git a/test/coverband/collectors/route_tracker_test.rb b/test/coverband/collectors/route_tracker_test.rb index 8d6852ce..be31b0d4 100644 --- a/test/coverband/collectors/route_tracker_test.rb +++ b/test/coverband/collectors/route_tracker_test.rb @@ -23,7 +23,7 @@ def setup assert_equal nil, tracker.target.first assert !tracker.store.nil? assert_equal [], tracker.target - assert_equal [], tracker.logged_routes + assert_equal [], tracker.logged_keys end test "track redirect routes" do @@ -35,9 +35,9 @@ def setup payload = { request: Payload.new("path", "GET") } - tracker.track_routes("name", "start", "finish", "id", payload) - tracker.report_routes_tracked - assert_equal [route_hash], tracker.logged_routes + tracker.track_key(payload) + tracker.save_report + assert_equal [route_hash], tracker.logged_keys end test "track controller routes" do @@ -52,9 +52,9 @@ def setup path: "path", method: "GET" } - tracker.track_routes("name", "start", "finish", "id", payload) - tracker.report_routes_tracked - assert_equal [route_hash], tracker.logged_routes + tracker.track_key(payload) + tracker.save_report + assert_equal [route_hash], tracker.logged_keys end test "report used routes" do @@ -68,9 +68,9 @@ def setup path: "path", method: "GET" } - tracker.track_routes("name", "start", "finish", "id", payload) - tracker.report_routes_tracked - assert_equal [route_hash.to_s], tracker.used_routes.keys + tracker.track_key(payload) + tracker.save_report + assert_equal [route_hash.to_s], tracker.used_keys.keys end test "report unused routes" do @@ -97,9 +97,9 @@ def setup path: "path", method: "GET" } - tracker.track_routes("name", "start", "finish", "id", payload) - tracker.report_routes_tracked - assert_equal [app_routes.first], tracker.unused_routes + tracker.track_key(payload) + tracker.save_report + assert_equal [app_routes.first], tracker.unused_keys end test "report unused routes pulls out parameterized routes" do @@ -120,9 +120,9 @@ def setup path: "some/controller/123", method: "GET" } - tracker.track_routes("name", "start", "finish", "id", payload) - tracker.report_routes_tracked - assert_equal [], tracker.unused_routes + tracker.track_key(payload) + tracker.save_report + assert_equal [], tracker.unused_keys end test "reset store" do @@ -135,9 +135,9 @@ def setup method: "GET" } store.raw_store.expects(:del).with(tracker_key) - store.raw_store.expects(:del).with("route_tracker_time") + store.raw_store.expects(:del).with("RouteTracker_tracker_time") tracker = Coverband::Collectors::RouteTracker.new(store: store, roots: "dir") - tracker.track_routes("name", "start", "finish", "id", payload) + tracker.track_key(payload) tracker.reset_recordings end @@ -152,10 +152,10 @@ def setup path: "path", method: "GET" } - tracker.track_routes("name", "start", "finish", "id", payload) - tracker.report_routes_tracked - assert_equal [route_hash.to_s], tracker.used_routes.keys - tracker.clear_route!(route_hash.to_s) + tracker.track_key(payload) + tracker.save_report + assert_equal [route_hash.to_s], tracker.used_keys.keys + tracker.clear_key!(route_hash.to_s) assert_equal [], tracker.store.raw_store.hgetall(tracker_key).keys end diff --git a/test/coverband/collectors/view_tracker_test.rb b/test/coverband/collectors/view_tracker_test.rb index 87abb470..960156fb 100644 --- a/test/coverband/collectors/view_tracker_test.rb +++ b/test/coverband/collectors/view_tracker_test.rb @@ -4,7 +4,7 @@ class ViewTrackerTest < Minitest::Test def tracker_key - "render_tracker_2" + "ViewTracker_tracker" end def setup @@ -18,7 +18,7 @@ def setup assert_equal "dir", tracker.roots.first assert !tracker.store.nil? assert_equal [], tracker.target - assert_equal [], tracker.logged_views + assert_equal [], tracker.logged_keys end test "track partials" do @@ -27,9 +27,9 @@ def setup file_path = "#{File.expand_path(Coverband.configuration.root)}/file" store.raw_store.expects(:hset).with(tracker_key, file_path, anything) tracker = Coverband::Collectors::ViewTracker.new(store: store, roots: "dir") - tracker.track_views("name", "start", "finish", "id", identifier: file_path) - tracker.report_views_tracked - assert_equal [file_path], tracker.logged_views + tracker.track_key(identifier: file_path) + tracker.save_report + assert_equal [file_path], tracker.logged_keys end test "track partials that include the word vendor in the path" do @@ -37,9 +37,9 @@ def setup store = fake_store file_path = "#{File.expand_path(Coverband.configuration.root)}/vendor_relations/file" tracker = Coverband::Collectors::ViewTracker.new(store: store, roots: "dir") - tracker.track_views("name", "start", "finish", "id", identifier: file_path) - tracker.report_views_tracked - assert_equal [file_path], tracker.used_views.keys + tracker.track_key(identifier: file_path) + tracker.save_report + assert_equal [file_path], tracker.used_keys.keys end test "track partials that include the word _mailer in the path" do @@ -47,9 +47,9 @@ def setup store = fake_store file_path = "#{File.expand_path(Coverband.configuration.root)}/_mailer/file" tracker = Coverband::Collectors::ViewTracker.new(store: store, roots: "dir") - tracker.track_views("name", "start", "finish", "id", identifier: file_path) - tracker.report_views_tracked - assert_equal [file_path], tracker.used_views.keys + tracker.track_key(identifier: file_path) + tracker.save_report + assert_equal [file_path], tracker.used_keys.keys end test "ignore partials that include the folder vendor in the path" do @@ -57,9 +57,9 @@ def setup store = fake_store file_path = "#{File.expand_path(Coverband.configuration.root)}/vendor/file" tracker = Coverband::Collectors::ViewTracker.new(store: store, roots: "dir") - tracker.track_views("name", "start", "finish", "id", identifier: file_path) - tracker.report_views_tracked - assert_equal({}, tracker.used_views) + tracker.track_key(identifier: file_path) + tracker.save_report + assert_equal({}, tracker.used_keys) end test "track layouts" do @@ -68,9 +68,9 @@ def setup file_path = "#{File.expand_path(Coverband.configuration.root)}/layout" store.raw_store.expects(:hset).with(tracker_key, file_path, anything) tracker = Coverband::Collectors::ViewTracker.new(store: store, roots: "dir") - tracker.track_views("name", "start", "finish", "id", layout: file_path) - tracker.report_views_tracked - assert_equal [file_path], tracker.logged_views + tracker.track_key(layout: file_path) + tracker.save_report + assert_equal [file_path], tracker.logged_keys end test "report used partials" do @@ -78,9 +78,9 @@ def setup store = fake_store file_path = "#{File.expand_path(Coverband.configuration.root)}/file" tracker = Coverband::Collectors::ViewTracker.new(store: store, roots: "dir") - tracker.track_views("name", "start", "finish", "id", identifier: file_path) - tracker.report_views_tracked - assert_equal [file_path], tracker.used_views.keys + tracker.track_key(identifier: file_path) + tracker.save_report + assert_equal [file_path], tracker.used_keys.keys end test "report unused partials" do @@ -89,30 +89,30 @@ def setup file_path = "#{File.expand_path(Coverband.configuration.root)}/file" target = [file_path, "not_used"] tracker = Coverband::Collectors::ViewTracker.new(store: store, roots: "dir", target: target) - tracker.track_views("name", "start", "finish", "id", identifier: file_path) - tracker.report_views_tracked - assert_equal ["not_used"], tracker.unused_views + tracker.track_key(identifier: file_path) + tracker.save_report + assert_equal ["not_used"], tracker.unused_keys end test "reset store" do Coverband::Collectors::ViewTracker.expects(:supported_version?).returns(true) store = fake_store store.raw_store.expects(:del).with(tracker_key) - store.raw_store.expects(:del).with("render_tracker_time") + store.raw_store.expects(:del).with("ViewTracker_tracker_time") tracker = Coverband::Collectors::ViewTracker.new(store: store, roots: "dir") - tracker.track_views("name", "start", "finish", "id", identifier: "file") + tracker.track_key(identifier: "file") tracker.reset_recordings end - test "clear_file" do + test "clear_key" do Coverband::Collectors::ViewTracker.expects(:supported_version?).returns(true) store = fake_store file_path = "#{File.expand_path(Coverband.configuration.root)}/file" store.raw_store.expects(:hdel).with(tracker_key, file_path) tracker = Coverband::Collectors::ViewTracker.new(store: store, roots: "dir") - tracker.track_views("name", "start", "finish", "id", identifier: file_path) - tracker.clear_file!("file") - assert_equal [], tracker.logged_views + tracker.track_key(identifier: file_path) + tracker.clear_key!("file") + assert_equal [], tracker.logged_keys end protected diff --git a/test/forked/rails_full_stack_views_test.rb b/test/forked/rails_full_stack_views_test.rb index a40a36ae..46884956 100644 --- a/test/forked/rails_full_stack_views_test.rb +++ b/test/forked/rails_full_stack_views_test.rb @@ -22,35 +22,35 @@ def teardown visit "/dummy_view/show" assert_content("I am no dummy view tracker text") Coverband.report_coverage - Coverband.configuration.view_tracker&.report_views_tracked - Coverband.configuration.route_tracker&.report_routes_tracked - visit "/coverage/view_tracker" + Coverband.configuration.view_tracker&.save_report + Coverband.configuration.route_tracker&.save_report + visit "/coverage/views_tracker" assert_content("Used Views: (1)") assert_content("Unused Views: (2)") - assert_selector("li.used-views", text: "dummy_view/show.html.erb") - assert_selector("li.unused-views", text: "dummy_view/show_haml.html.haml") - assert_selector("li.unused-views", text: "dummy_view/show_slim.html.slim") + assert_selector("li.used-keys", text: "dummy_view/show.html.erb") + assert_selector("li.unused-keys", text: "dummy_view/show_haml.html.haml") + assert_selector("li.unused-keys", text: "dummy_view/show_slim.html.slim") - visit "/coverage/route_tracker" + visit "/coverage/routes_tracker" assert_content("Used Routes: (1)") assert_content("Unused Routes: (5)") visit "/dummy_view/show_haml" assert_content("I am haml text") Coverband.report_coverage - Coverband.configuration.view_tracker&.report_views_tracked - visit "/coverage/view_tracker" + Coverband.configuration.view_tracker&.save_report + visit "/coverage/views_tracker" assert_content("Used Views: (2)") assert_content("Unused Views: (1)") - assert_selector("li.used-views", text: "dummy_view/show_haml.html.haml") + assert_selector("li.used-keys", text: "dummy_view/show_haml.html.haml") visit "/dummy_view/show_slim" assert_content("I am slim text") Coverband.report_coverage - Coverband.configuration.view_tracker&.report_views_tracked - visit "/coverage/view_tracker" + Coverband.configuration.view_tracker&.save_report + visit "/coverage/views_tracker" assert_content("Used Views: (3)") assert_content("Unused Views: (0)") - assert_selector("li.used-views", text: "dummy_view/show_slim.html.slim") + assert_selector("li.used-keys", text: "dummy_view/show_slim.html.slim") end end diff --git a/test/forked/rails_route_tracker_stack_test.rb b/test/forked/rails_route_tracker_stack_test.rb index 59d72c34..e5c7da5a 100644 --- a/test/forked/rails_route_tracker_stack_test.rb +++ b/test/forked/rails_route_tracker_stack_test.rb @@ -17,7 +17,7 @@ def teardown output = `sleep 7 && curl http://localhost:9999/dummy_view/show` assert output.match(/rendered view/) assert output.match(/I am no dummy view tracker text/) - output = `sleep 2 && curl http://localhost:9999/coverage/route_tracker` + output = `sleep 2 && curl http://localhost:9999/coverage/routes_tracker` assert output.match(/Used Routes: \(1\)/) assert output.match(/dummy_view\/show/) assert output.match(/GET/) diff --git a/test/forked/rails_view_tracker_stack_test.rb b/test/forked/rails_view_tracker_stack_test.rb index 812aa900..5ff90c03 100644 --- a/test/forked/rails_view_tracker_stack_test.rb +++ b/test/forked/rails_view_tracker_stack_test.rb @@ -17,7 +17,7 @@ def teardown output = `sleep 7 && curl http://localhost:9999/dummy_view/show` assert output.match(/rendered view/) assert output.match(/I am no dummy view tracker text/) - output = `sleep 2 && curl http://localhost:9999/coverage/view_tracker` + output = `sleep 2 && curl http://localhost:9999/coverage/views_tracker` assert output.match(/Used Views: \(1\)/) assert output.match(/dummy_view\/show/) end diff --git a/views/translations_tracker.erb b/views/abstract_tracker.erb similarity index 66% rename from views/translations_tracker.erb rename to views/abstract_tracker.erb index d2bdf0b5..8dec2f34 100644 --- a/views/translations_tracker.erb +++ b/views/abstract_tracker.erb @@ -11,31 +11,30 @@
- <%= display_nav(active_link: 'translations_tracker') %> + <%= display_nav(active_link: tracker.route) %>
- <% tracker = Coverband::Collectors::TranslationTracker.new(store: Coverband.configuration.store) %>

<% if Coverband.configuration.web_enable_clear %> - <%= button("#{base_path}clear_translation_tracking", 'reset translation tracker', delete: true) %> + <%= button("#{base_path}clear_#{tracker.route}", "reset #{tracker.title} tracker", delete: true) %> <% end %>

-

Unused Translations: (<%= tracker.unused_keys.length %>)

-

These Translations have not been rendered since recording started at <%= tracker.tracking_since %>

+

Unused <%= tracker.title %>: (<%= tracker.unused_keys.length %>)

+

These <%= tracker.title %> have not been rendered since recording started at <%= tracker.tracking_since %>

-

Used Translations: (<%= tracker.used_keys.length %>)

-

These Translations have been rendered at least once

+

Used <%= tracker.title %>: (<%= tracker.used_keys.length %>)

+

These <%= tracker.title %> have been rendered at least once