Module: Profiler::TestHelpers::Reporter

Defined in:
lib/profiler/test_helpers/reporter.rb

Constant Summary collapse

WIDTH =
68

Class Method Summary collapse

Class Method Details

.cyan(str) ⇒ Object



116
# File 'lib/profiler/test_helpers/reporter.rb', line 116

def self.cyan(str)    = "\e[36m#{str}\e[0m"

.has_n1?(profile) ⇒ Boolean

Returns:

  • (Boolean)


81
82
83
84
85
86
87
# File 'lib/profiler/test_helpers/reporter.rb', line 81

def self.has_n1?(profile)
  db_data = profile.collector_data("database") || {}
  queries = db_data["queries"] || []
  return false if queries.size < 3

  queries.group_by { |q| normalize_sql(q["sql"].to_s) }.any? { |_, qs| qs.size >= 3 }
end

.normalize_sql(sql) ⇒ Object



102
103
104
# File 'lib/profiler/test_helpers/reporter.rb', line 102

def self.normalize_sql(sql)
  sql.gsub(/\$\d+/, "?").gsub(/\b\d+\b/, "?").gsub(/'[^']*'/, "?").gsub(/"[^"]*"/, "?").strip
end

.pad_to(width, str) ⇒ Object



111
112
113
114
# File 'lib/profiler/test_helpers/reporter.rb', line 111

def self.pad_to(width, str)
  visible = str.gsub(/\e\[[0-9;]*m/, "")
  " " * [0, width - visible.length - 1].max
end


8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'lib/profiler/test_helpers/reporter.rb', line 8

def self.print
  return unless Profiler.enabled?

  profiles = Profiler.storage.list(limit: 1000).select { |p| p.profile_type == "test" }
  return if profiles.empty?

  passed  = profiles.count { |p| test_status(p) == "passed" }
  failed  = profiles.count { |p| test_status(p) == "failed" }
  pending = profiles.count { |p| test_status(p) == "pending" }

  total_queries = profiles.sum { |p| (p.collector_data("database") || {})["total_queries"].to_i }
  n1_profiles   = profiles.select { |p| has_n1?(p) }
  total_ms      = profiles.sum(&:duration).round(0).to_i

  lines = []
  lines << ""
  lines << cyan("┌─ Profiler Test Report " + "" * (WIDTH - 23) + "")
  lines << cyan("") + " #{profiles.size} tests · #{passed} passed · #{failed} failed · #{pending} pending" +
           pad_to(WIDTH - 1, "#{profiles.size} tests · #{passed} passed · #{failed} failed · #{pending} pending") +
           cyan("")
  lines << cyan("") + " Total: #{total_ms}ms · #{total_queries} queries · #{n1_profiles.size} N+1 detected" +
           pad_to(WIDTH - 1, "Total: #{total_ms}ms · #{total_queries} queries · #{n1_profiles.size} N+1 detected") +
           cyan("")

  # Slowest tests
  slowest = profiles.sort_by { |p| -p.duration }.first(5)
  lines << cyan("├─ Slowest tests " + "" * (WIDTH - 17) + "")
  slowest.each_with_index do |p, i|
    test_data = p.collector_data("test") || {}
    db_data   = p.collector_data("database") || {}
    name      = truncate(test_data["test_name"] || p.path, 44)
    queries   = db_data["total_queries"].to_i
    n1_flag   = has_n1?(p) ? " #{red("⚠ N+1")}" : ""
    stats_raw = "#{p.duration.round(0).to_i}ms  #{queries}q"
    pad       = [0, WIDTH - 6 - name.length - stats_raw.length].max
    lines << cyan("") + "  #{i + 1}. #{name}#{" " * pad}#{stats_raw}#{n1_flag}"
  end

  # N+1 patterns
  if n1_profiles.any?
    lines << cyan("├─ N+1 patterns " + "" * (WIDTH - 16) + "")
    n1_profiles.first(3).each do |p|
      test_data = p.collector_data("test") || {}
      db_data   = p.collector_data("database") || {}
      queries   = db_data["queries"] || []
      pattern   = top_n1_pattern(queries)
      name      = truncate(test_data["test_name"] || p.path, WIDTH - 4)
      lines << cyan("") + "  #{yellow("")} #{yellow(truncate(pattern.to_s, WIDTH - 6))}"
      lines << cyan("") + "#{name}"
    end
  end

  # Failed tests
  failed_profiles = profiles.select { |p| test_status(p) == "failed" }
  if failed_profiles.any?
    lines << cyan("├─ Failed tests " + "" * (WIDTH - 15) + "")
    failed_profiles.first(5).each do |p|
      test_data = p.collector_data("test") || {}
      name      = test_data["test_name"] || p.path
      exception = test_data["exception_message"]
      lines << cyan("") + "  #{red("")} #{truncate(name, WIDTH - 5)}"
      lines << cyan("") + "    #{truncate(exception.to_s, WIDTH - 6)}" if exception
    end
  end

  lines << cyan("" + "" * WIDTH + "")
  lines << ""

  $stdout.puts lines.join("\n")
rescue => e
  warn "Profiler Reporter: failed to generate report: #{e.message}"
end

.red(str) ⇒ Object



118
# File 'lib/profiler/test_helpers/reporter.rb', line 118

def self.red(str)     = "\e[31m#{str}\e[0m"

.test_status(profile) ⇒ Object



89
90
91
# File 'lib/profiler/test_helpers/reporter.rb', line 89

def self.test_status(profile)
  (profile.collector_data("test") || {})["status"] || "passed"
end

.top_n1_pattern(queries) ⇒ Object



93
94
95
96
97
98
99
100
# File 'lib/profiler/test_helpers/reporter.rb', line 93

def self.top_n1_pattern(queries)
  return "" if queries.size < 3

  queries.group_by { |q| normalize_sql(q["sql"].to_s) }
         .select { |_, qs| qs.size >= 3 }
         .max_by { |_, qs| qs.size }
         &.first || ""
end

.truncate(str, max) ⇒ Object



106
107
108
109
# File 'lib/profiler/test_helpers/reporter.rb', line 106

def self.truncate(str, max)
  str = str.to_s
  str.length > max ? str[0, max - 3] + "..." : str
end

.yellow(str) ⇒ Object



117
# File 'lib/profiler/test_helpers/reporter.rb', line 117

def self.yellow(str)  = "\e[33m#{str}\e[0m"