rails-profiler 0.25.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/assets/builds/profiler.css +24 -0
- data/app/assets/builds/profiler.js +739 -31
- data/app/controllers/profiler/api/test_runner_controller.rb +115 -0
- data/app/controllers/profiler/api/tests_controller.rb +46 -0
- data/app/controllers/profiler/test_runner_controller.rb +11 -0
- data/app/views/profiler/test_runner/index.html.erb +1 -0
- data/config/routes.rb +10 -0
- data/lib/profiler/collectors/database_collector.rb +1 -1
- data/lib/profiler/collectors/test_collector.rb +75 -0
- data/lib/profiler/configuration.rb +12 -1
- data/lib/profiler/mcp/resources/failing_tests.rb +40 -0
- data/lib/profiler/mcp/resources/slow_tests.rb +45 -0
- data/lib/profiler/mcp/server.rb +77 -6
- data/lib/profiler/mcp/tools/get_test_profile_detail.rb +126 -0
- data/lib/profiler/mcp/tools/query_test_profiles.rb +109 -0
- data/lib/profiler/mcp/tools/run_tests.rb +112 -0
- data/lib/profiler/railtie.rb +13 -1
- data/lib/profiler/test_helpers/minitest_support.rb +39 -0
- data/lib/profiler/test_helpers/reporter.rb +121 -0
- data/lib/profiler/test_helpers/rspec_support.rb +33 -0
- data/lib/profiler/test_profiler.rb +140 -0
- data/lib/profiler/test_runner/discovery.rb +57 -0
- data/lib/profiler/test_runner/run_store.rb +120 -0
- data/lib/profiler/test_runner/runner.rb +106 -0
- data/lib/profiler/version.rb +1 -1
- metadata +19 -2
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class QueryTestProfiles
|
|
7
|
+
ALL_FIELDS = %w[time test_name status duration queries n1 token].freeze
|
|
8
|
+
|
|
9
|
+
def self.call(params)
|
|
10
|
+
limit = params["limit"]&.to_i || 20
|
|
11
|
+
fetch_size = [limit * 5, 500].min
|
|
12
|
+
profiles = Profiler.storage.list(limit: fetch_size)
|
|
13
|
+
|
|
14
|
+
tests = profiles.select { |p| p.profile_type == "test" }
|
|
15
|
+
|
|
16
|
+
if params["test_name"]
|
|
17
|
+
term = params["test_name"].downcase
|
|
18
|
+
tests = tests.select do |p|
|
|
19
|
+
test_data = p.collector_data("test")
|
|
20
|
+
(test_data&.dig("test_name") || p.path).to_s.downcase.include?(term)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
if params["status"]
|
|
25
|
+
tests = tests.select do |p|
|
|
26
|
+
test_data = p.collector_data("test")
|
|
27
|
+
test_data && test_data["status"] == params["status"]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
if params["min_duration"]
|
|
32
|
+
min_ms = params["min_duration"].to_f
|
|
33
|
+
tests = tests.select { |p| p.duration >= min_ms }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if params["cursor"]
|
|
37
|
+
cutoff = Time.parse(params["cursor"]) rescue nil
|
|
38
|
+
tests = tests.select { |p| p.started_at < cutoff } if cutoff
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
tests = tests.first(limit)
|
|
42
|
+
fields = params["fields"]&.map(&:to_s)
|
|
43
|
+
|
|
44
|
+
[{ type: "text", text: format_tests_table(tests, fields, limit) }]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def self.format_tests_table(tests, fields, limit)
|
|
50
|
+
return "No test profiles found matching the criteria." if tests.empty?
|
|
51
|
+
|
|
52
|
+
fields ||= ALL_FIELDS
|
|
53
|
+
fields = fields & ALL_FIELDS
|
|
54
|
+
|
|
55
|
+
lines = []
|
|
56
|
+
lines << "# Test Profiles\n"
|
|
57
|
+
lines << "Found #{tests.size} tests:\n"
|
|
58
|
+
|
|
59
|
+
header = fields.map { |f| f.split("_").map(&:capitalize).join(" ") }.join(" | ")
|
|
60
|
+
separator = fields.map { "------" }.join("|")
|
|
61
|
+
lines << "| #{header} |"
|
|
62
|
+
lines << "|#{separator}|"
|
|
63
|
+
|
|
64
|
+
tests.each do |profile|
|
|
65
|
+
test_data = profile.collector_data("test") || {}
|
|
66
|
+
db_data = profile.collector_data("database") || {}
|
|
67
|
+
queries = db_data["queries"] || []
|
|
68
|
+
n1_count = count_n1_patterns(queries)
|
|
69
|
+
|
|
70
|
+
row = fields.map do |f|
|
|
71
|
+
case f
|
|
72
|
+
when "time" then profile.started_at.strftime("%H:%M:%S")
|
|
73
|
+
when "test_name" then (test_data["test_name"] || profile.path).to_s.then { |n| n.length > 60 ? n[0, 57] + "..." : n }
|
|
74
|
+
when "status" then test_data["status"] || "-"
|
|
75
|
+
when "duration" then "#{profile.duration.round(2)}ms"
|
|
76
|
+
when "queries" then db_data["total_queries"].to_i.to_s
|
|
77
|
+
when "n1" then n1_count > 0 ? "⚠ #{n1_count}" : "✓"
|
|
78
|
+
when "token" then profile.token.to_s
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
lines << "| #{row.join(' | ')} |"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
if tests.size == limit
|
|
85
|
+
lines << ""
|
|
86
|
+
lines << "*Next cursor: #{tests.last.started_at.iso8601}*"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
lines.join("\n")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.count_n1_patterns(queries)
|
|
93
|
+
return 0 if queries.size < 3
|
|
94
|
+
|
|
95
|
+
queries.group_by { |q| normalize_sql(q["sql"].to_s) }
|
|
96
|
+
.count { |_, qs| qs.size >= 3 }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.normalize_sql(sql)
|
|
100
|
+
sql.gsub(/\$\d+/, "?")
|
|
101
|
+
.gsub(/\b\d+\b/, "?")
|
|
102
|
+
.gsub(/'[^']*'/, "?")
|
|
103
|
+
.gsub(/"[^"]*"/, "?")
|
|
104
|
+
.strip
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../test_runner/discovery"
|
|
4
|
+
require_relative "../../test_runner/run_store"
|
|
5
|
+
require_relative "../../test_runner/runner"
|
|
6
|
+
|
|
7
|
+
module Profiler
|
|
8
|
+
module MCP
|
|
9
|
+
module Tools
|
|
10
|
+
class RunTests
|
|
11
|
+
DEFAULT_TIMEOUT = 120
|
|
12
|
+
DEFAULT_MAX_OUTPUT = 4000
|
|
13
|
+
POLL_TIMEOUT = 10 # seconds per wait_for_output call
|
|
14
|
+
|
|
15
|
+
def self.call(params)
|
|
16
|
+
files = Array(params["files"])
|
|
17
|
+
framework = params["framework"]&.to_s
|
|
18
|
+
timeout_secs = (params["timeout_seconds"] || DEFAULT_TIMEOUT).to_i
|
|
19
|
+
max_output = (params["max_output"] || DEFAULT_MAX_OUTPUT).to_i
|
|
20
|
+
|
|
21
|
+
# Auto-detect framework if not provided
|
|
22
|
+
framework ||= begin
|
|
23
|
+
available = Profiler::TestRunner::Discovery.frameworks.map(&:to_s)
|
|
24
|
+
available.first || "rspec"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# If no files specified, discover all for the given framework
|
|
28
|
+
if files.empty?
|
|
29
|
+
tree = Profiler::TestRunner::Discovery.files(framework: framework.to_sym)
|
|
30
|
+
files = tree.flat_map { |dir| dir[:files].map { |f| f[:path] } }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
if files.empty?
|
|
34
|
+
return [{ type: "text", text: "No test files found for framework '#{framework}'." }]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
run_started_at = Time.now
|
|
38
|
+
run = Profiler::TestRunner::Runner.start(files: files, framework: framework)
|
|
39
|
+
|
|
40
|
+
output_pos = 0
|
|
41
|
+
deadline = Time.now + timeout_secs
|
|
42
|
+
timed_out = false
|
|
43
|
+
|
|
44
|
+
until Profiler::TestRunner::RunStore::TERMINAL_STATUSES.include?(run.status)
|
|
45
|
+
remaining = (deadline - Time.now).to_i
|
|
46
|
+
if remaining <= 0
|
|
47
|
+
timed_out = true
|
|
48
|
+
break
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
wait = [POLL_TIMEOUT, remaining].min
|
|
52
|
+
result = Profiler::TestRunner.run_store.wait_for_output(run.id, position: output_pos, timeout: wait)
|
|
53
|
+
output_pos = result[:position]
|
|
54
|
+
break if result[:finished]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
full_output = run.output_lines.join
|
|
58
|
+
tail_output = full_output.length > max_output ? "…(truncated)\n" + full_output[-(max_output)..] : full_output
|
|
59
|
+
|
|
60
|
+
# Collect test profiles created during this run
|
|
61
|
+
profile_tokens = collect_run_profiles(run_started_at)
|
|
62
|
+
|
|
63
|
+
[{ type: "text", text: format_result(run, tail_output, timed_out, profile_tokens, files, framework) }]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def self.collect_run_profiles(since)
|
|
69
|
+
Profiler.storage.list(limit: 500).select do |p|
|
|
70
|
+
p.profile_type == "test" && p.started_at && p.started_at >= since
|
|
71
|
+
end.map(&:token)
|
|
72
|
+
rescue
|
|
73
|
+
[]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.format_result(run, output, timed_out, profile_tokens, files, framework)
|
|
77
|
+
lines = []
|
|
78
|
+
lines << "# Test Run #{timed_out ? "(timed out)" : ""}\n"
|
|
79
|
+
|
|
80
|
+
lines << "## Summary"
|
|
81
|
+
lines << "| Field | Value |"
|
|
82
|
+
lines << "|-------|-------|"
|
|
83
|
+
lines << "| Run ID | `#{run.id}` |"
|
|
84
|
+
lines << "| Framework | #{framework} |"
|
|
85
|
+
lines << "| Files | #{files.size} |"
|
|
86
|
+
lines << "| Status | **#{run.status}** |"
|
|
87
|
+
lines << "| Exit code | #{run.exit_code.inspect} |"
|
|
88
|
+
lines << "| Duration | #{run.to_h[:duration]&.round(0)}ms |"
|
|
89
|
+
|
|
90
|
+
if timed_out
|
|
91
|
+
lines << ""
|
|
92
|
+
lines << "> ⚠ Timed out — run is still in progress. Use run ID `#{run.id}` to check later."
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
lines << "\n## Output (last #{output.length} chars)"
|
|
96
|
+
lines << "```"
|
|
97
|
+
lines << output.strip
|
|
98
|
+
lines << "```"
|
|
99
|
+
|
|
100
|
+
if profile_tokens.any?
|
|
101
|
+
lines << "\n## Test Profiles Created (#{profile_tokens.size})"
|
|
102
|
+
lines << "Use `get_test_profile` with any of these tokens for detailed SQL/cache/exception data:"
|
|
103
|
+
profile_tokens.first(10).each { |t| lines << "- `#{t}`" }
|
|
104
|
+
lines << "- _(#{profile_tokens.size - 10} more…)_" if profile_tokens.size > 10
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
lines.join("\n")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
data/lib/profiler/railtie.rb
CHANGED
|
@@ -14,8 +14,9 @@ module Profiler
|
|
|
14
14
|
# Set default configuration for Rails environment
|
|
15
15
|
Profiler.configure do |config|
|
|
16
16
|
config.enabled = Rails.env.development? || Rails.env.test?
|
|
17
|
-
config.storage = Rails.env.development? ? :file : :memory
|
|
17
|
+
config.storage = (Rails.env.development? || Rails.env.test?) ? :file : :memory
|
|
18
18
|
config.tmp_path = Rails.root.join("tmp", "rails-profiler")
|
|
19
|
+
config.track_tests = Rails.env.test?
|
|
19
20
|
end
|
|
20
21
|
end
|
|
21
22
|
|
|
@@ -56,6 +57,17 @@ module Profiler
|
|
|
56
57
|
end
|
|
57
58
|
end
|
|
58
59
|
|
|
60
|
+
initializer "profiler.setup_test_profiler" do
|
|
61
|
+
next unless Profiler.configuration.enabled && Profiler.configuration.track_tests
|
|
62
|
+
|
|
63
|
+
require_relative "test_profiler"
|
|
64
|
+
require_relative "test_helpers/rspec_support"
|
|
65
|
+
require_relative "test_helpers/minitest_support"
|
|
66
|
+
require_relative "test_runner/discovery"
|
|
67
|
+
require_relative "test_runner/run_store"
|
|
68
|
+
require_relative "test_runner/runner"
|
|
69
|
+
end
|
|
70
|
+
|
|
59
71
|
initializer "profiler.setup_job_instrumentation" do
|
|
60
72
|
next unless Profiler.configuration.enabled && Profiler.configuration.track_jobs
|
|
61
73
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_profiler"
|
|
4
|
+
require_relative "reporter"
|
|
5
|
+
|
|
6
|
+
module Profiler
|
|
7
|
+
module TestHelpers
|
|
8
|
+
# Minitest integration for the profiler.
|
|
9
|
+
#
|
|
10
|
+
# Usage in test_helper.rb:
|
|
11
|
+
#
|
|
12
|
+
# require 'profiler/test_helpers/minitest_support'
|
|
13
|
+
# Profiler::TestHelpers::MinitestSupport.install
|
|
14
|
+
module MinitestSupport
|
|
15
|
+
def self.install
|
|
16
|
+
require "minitest"
|
|
17
|
+
|
|
18
|
+
Minitest::Test.prepend(RunWrapper)
|
|
19
|
+
|
|
20
|
+
Minitest.after_run do
|
|
21
|
+
Profiler::TestHelpers::Reporter.print
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
module RunWrapper
|
|
26
|
+
def run
|
|
27
|
+
Profiler::TestProfiler.profile(
|
|
28
|
+
test_name: "#{self.class.name}##{name}",
|
|
29
|
+
test_file: self.class.instance_method(name).source_location&.first || "",
|
|
30
|
+
test_line: self.class.instance_method(name).source_location&.last || 0,
|
|
31
|
+
framework: :minitest
|
|
32
|
+
) { super }
|
|
33
|
+
rescue NameError
|
|
34
|
+
super
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module TestHelpers
|
|
5
|
+
module Reporter
|
|
6
|
+
WIDTH = 68
|
|
7
|
+
|
|
8
|
+
def self.print
|
|
9
|
+
return unless Profiler.enabled?
|
|
10
|
+
|
|
11
|
+
profiles = Profiler.storage.list(limit: 1000).select { |p| p.profile_type == "test" }
|
|
12
|
+
return if profiles.empty?
|
|
13
|
+
|
|
14
|
+
passed = profiles.count { |p| test_status(p) == "passed" }
|
|
15
|
+
failed = profiles.count { |p| test_status(p) == "failed" }
|
|
16
|
+
pending = profiles.count { |p| test_status(p) == "pending" }
|
|
17
|
+
|
|
18
|
+
total_queries = profiles.sum { |p| (p.collector_data("database") || {})["total_queries"].to_i }
|
|
19
|
+
n1_profiles = profiles.select { |p| has_n1?(p) }
|
|
20
|
+
total_ms = profiles.sum(&:duration).round(0).to_i
|
|
21
|
+
|
|
22
|
+
lines = []
|
|
23
|
+
lines << ""
|
|
24
|
+
lines << cyan("┌─ Profiler Test Report " + "─" * (WIDTH - 23) + "┐")
|
|
25
|
+
lines << cyan("│") + " #{profiles.size} tests · #{passed} passed · #{failed} failed · #{pending} pending" +
|
|
26
|
+
pad_to(WIDTH - 1, "#{profiles.size} tests · #{passed} passed · #{failed} failed · #{pending} pending") +
|
|
27
|
+
cyan("│")
|
|
28
|
+
lines << cyan("│") + " Total: #{total_ms}ms · #{total_queries} queries · #{n1_profiles.size} N+1 detected" +
|
|
29
|
+
pad_to(WIDTH - 1, "Total: #{total_ms}ms · #{total_queries} queries · #{n1_profiles.size} N+1 detected") +
|
|
30
|
+
cyan("│")
|
|
31
|
+
|
|
32
|
+
# Slowest tests
|
|
33
|
+
slowest = profiles.sort_by { |p| -p.duration }.first(5)
|
|
34
|
+
lines << cyan("├─ Slowest tests " + "─" * (WIDTH - 17) + "┤")
|
|
35
|
+
slowest.each_with_index do |p, i|
|
|
36
|
+
test_data = p.collector_data("test") || {}
|
|
37
|
+
db_data = p.collector_data("database") || {}
|
|
38
|
+
name = truncate(test_data["test_name"] || p.path, 44)
|
|
39
|
+
queries = db_data["total_queries"].to_i
|
|
40
|
+
n1_flag = has_n1?(p) ? " #{red("⚠ N+1")}" : ""
|
|
41
|
+
stats_raw = "#{p.duration.round(0).to_i}ms #{queries}q"
|
|
42
|
+
pad = [0, WIDTH - 6 - name.length - stats_raw.length].max
|
|
43
|
+
lines << cyan("│") + " #{i + 1}. #{name}#{" " * pad}#{stats_raw}#{n1_flag}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# N+1 patterns
|
|
47
|
+
if n1_profiles.any?
|
|
48
|
+
lines << cyan("├─ N+1 patterns " + "─" * (WIDTH - 16) + "┤")
|
|
49
|
+
n1_profiles.first(3).each do |p|
|
|
50
|
+
test_data = p.collector_data("test") || {}
|
|
51
|
+
db_data = p.collector_data("database") || {}
|
|
52
|
+
queries = db_data["queries"] || []
|
|
53
|
+
pattern = top_n1_pattern(queries)
|
|
54
|
+
name = truncate(test_data["test_name"] || p.path, WIDTH - 4)
|
|
55
|
+
lines << cyan("│") + " #{yellow("▸")} #{yellow(truncate(pattern.to_s, WIDTH - 6))}"
|
|
56
|
+
lines << cyan("│") + " → #{name}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Failed tests
|
|
61
|
+
failed_profiles = profiles.select { |p| test_status(p) == "failed" }
|
|
62
|
+
if failed_profiles.any?
|
|
63
|
+
lines << cyan("├─ Failed tests " + "─" * (WIDTH - 15) + "┤")
|
|
64
|
+
failed_profiles.first(5).each do |p|
|
|
65
|
+
test_data = p.collector_data("test") || {}
|
|
66
|
+
name = test_data["test_name"] || p.path
|
|
67
|
+
exception = test_data["exception_message"]
|
|
68
|
+
lines << cyan("│") + " #{red("✗")} #{truncate(name, WIDTH - 5)}"
|
|
69
|
+
lines << cyan("│") + " #{truncate(exception.to_s, WIDTH - 6)}" if exception
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
lines << cyan("└" + "─" * WIDTH + "┘")
|
|
74
|
+
lines << ""
|
|
75
|
+
|
|
76
|
+
$stdout.puts lines.join("\n")
|
|
77
|
+
rescue => e
|
|
78
|
+
warn "Profiler Reporter: failed to generate report: #{e.message}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.has_n1?(profile)
|
|
82
|
+
db_data = profile.collector_data("database") || {}
|
|
83
|
+
queries = db_data["queries"] || []
|
|
84
|
+
return false if queries.size < 3
|
|
85
|
+
|
|
86
|
+
queries.group_by { |q| normalize_sql(q["sql"].to_s) }.any? { |_, qs| qs.size >= 3 }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.test_status(profile)
|
|
90
|
+
(profile.collector_data("test") || {})["status"] || "passed"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.top_n1_pattern(queries)
|
|
94
|
+
return "" if queries.size < 3
|
|
95
|
+
|
|
96
|
+
queries.group_by { |q| normalize_sql(q["sql"].to_s) }
|
|
97
|
+
.select { |_, qs| qs.size >= 3 }
|
|
98
|
+
.max_by { |_, qs| qs.size }
|
|
99
|
+
&.first || ""
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.normalize_sql(sql)
|
|
103
|
+
sql.gsub(/\$\d+/, "?").gsub(/\b\d+\b/, "?").gsub(/'[^']*'/, "?").gsub(/"[^"]*"/, "?").strip
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.truncate(str, max)
|
|
107
|
+
str = str.to_s
|
|
108
|
+
str.length > max ? str[0, max - 3] + "..." : str
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def self.pad_to(width, str)
|
|
112
|
+
visible = str.gsub(/\e\[[0-9;]*m/, "")
|
|
113
|
+
" " * [0, width - visible.length - 1].max
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def self.cyan(str) = "\e[36m#{str}\e[0m"
|
|
117
|
+
def self.yellow(str) = "\e[33m#{str}\e[0m"
|
|
118
|
+
def self.red(str) = "\e[31m#{str}\e[0m"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_profiler"
|
|
4
|
+
require_relative "reporter"
|
|
5
|
+
|
|
6
|
+
module Profiler
|
|
7
|
+
module TestHelpers
|
|
8
|
+
# RSpec integration for the profiler.
|
|
9
|
+
#
|
|
10
|
+
# Usage in spec_helper.rb:
|
|
11
|
+
#
|
|
12
|
+
# require 'profiler/test_helpers/rspec_support'
|
|
13
|
+
# RSpec.configure do |config|
|
|
14
|
+
# Profiler::TestHelpers::RSpecSupport.install(config)
|
|
15
|
+
# end
|
|
16
|
+
module RSpecSupport
|
|
17
|
+
def self.install(config)
|
|
18
|
+
config.around(:each) do |example|
|
|
19
|
+
Profiler::TestProfiler.profile(
|
|
20
|
+
test_name: example.full_description,
|
|
21
|
+
test_file: example.file_path,
|
|
22
|
+
test_line: example.line_number,
|
|
23
|
+
framework: :rspec
|
|
24
|
+
) { example.run }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
config.after(:suite) do
|
|
28
|
+
Profiler::TestHelpers::Reporter.print
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "models/profile"
|
|
4
|
+
require_relative "current_context"
|
|
5
|
+
require_relative "collectors/test_collector"
|
|
6
|
+
require_relative "collectors/database_collector"
|
|
7
|
+
require_relative "collectors/cache_collector"
|
|
8
|
+
require_relative "collectors/exception_collector"
|
|
9
|
+
require_relative "collectors/env_collector"
|
|
10
|
+
require_relative "collectors/flamegraph_collector"
|
|
11
|
+
|
|
12
|
+
module Profiler
|
|
13
|
+
class TestProfiler
|
|
14
|
+
TEST_COLLECTOR_CLASSES = [
|
|
15
|
+
Collectors::DatabaseCollector,
|
|
16
|
+
Collectors::CacheCollector,
|
|
17
|
+
Collectors::ExceptionCollector,
|
|
18
|
+
Collectors::EnvCollector,
|
|
19
|
+
Collectors::FlameGraphCollector
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
def self.profile(test_name:, test_file:, test_line:, framework:, &block)
|
|
23
|
+
return block.call unless Profiler.enabled?
|
|
24
|
+
|
|
25
|
+
new(
|
|
26
|
+
test_name: test_name,
|
|
27
|
+
test_file: test_file,
|
|
28
|
+
test_line: test_line,
|
|
29
|
+
framework: framework
|
|
30
|
+
).run(&block)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(test_name:, test_file:, test_line:, framework:)
|
|
34
|
+
@test_name = test_name
|
|
35
|
+
@test_file = test_file
|
|
36
|
+
@test_line = test_line
|
|
37
|
+
@framework = framework
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def run(&block)
|
|
41
|
+
profile = Models::Profile.new
|
|
42
|
+
profile.profile_type = "test"
|
|
43
|
+
profile.path = @test_file
|
|
44
|
+
profile.method = "TEST"
|
|
45
|
+
|
|
46
|
+
test_collector = Collectors::TestCollector.new(
|
|
47
|
+
profile,
|
|
48
|
+
test_name: @test_name,
|
|
49
|
+
test_file: @test_file,
|
|
50
|
+
test_line: @test_line,
|
|
51
|
+
framework: @framework
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
collectors = [test_collector] + TEST_COLLECTOR_CLASSES.map { |klass| klass.new(profile) }
|
|
55
|
+
collectors.each { |c| c.subscribe if c.respond_to?(:subscribe) }
|
|
56
|
+
|
|
57
|
+
exception_collector = collectors.find { |c| c.is_a?(Collectors::ExceptionCollector) }
|
|
58
|
+
|
|
59
|
+
memory_before = current_memory if Profiler.configuration.track_memory
|
|
60
|
+
|
|
61
|
+
test_status = "passed"
|
|
62
|
+
error_message = nil
|
|
63
|
+
|
|
64
|
+
previous_token = Profiler::CurrentContext.token
|
|
65
|
+
Profiler::CurrentContext.token = profile.token
|
|
66
|
+
|
|
67
|
+
begin
|
|
68
|
+
result = block.call
|
|
69
|
+
|
|
70
|
+
# Minitest: Test#run catches assertion errors internally and returns self.
|
|
71
|
+
# Failures don't propagate — detect them from the result object.
|
|
72
|
+
if result.respond_to?(:passed?)
|
|
73
|
+
unless result.passed?
|
|
74
|
+
test_status = result.skipped? ? "pending" : "failed"
|
|
75
|
+
msg = result.failure&.message.to_s
|
|
76
|
+
error_message = msg.empty? ? nil : msg
|
|
77
|
+
end
|
|
78
|
+
test_collector.update_extra(
|
|
79
|
+
assertions: result.respond_to?(:assertions) ? result.assertions : nil,
|
|
80
|
+
skip_reason: (result.skipped? && result.failure) ? result.failure.message : nil
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# RSpec: example.run catches errors internally and stores them in execution_result.
|
|
85
|
+
if result.respond_to?(:execution_result)
|
|
86
|
+
er = result.execution_result
|
|
87
|
+
rspec_status = er.status&.to_s
|
|
88
|
+
if rspec_status && rspec_status != "passed" && test_status == "passed"
|
|
89
|
+
test_status = rspec_status
|
|
90
|
+
msg = er.exception&.message.to_s
|
|
91
|
+
error_message = msg.empty? ? nil : msg
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
result
|
|
96
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
97
|
+
# Capture all exceptions (including test failures which may subclass Exception)
|
|
98
|
+
test_status = "failed"
|
|
99
|
+
error_message = "#{e.class}: #{e.message}"
|
|
100
|
+
exception_collector&.capture(e) if e.is_a?(StandardError)
|
|
101
|
+
raise
|
|
102
|
+
ensure
|
|
103
|
+
Profiler::CurrentContext.token = previous_token
|
|
104
|
+
|
|
105
|
+
if Profiler.configuration.track_memory
|
|
106
|
+
profile.memory = current_memory - memory_before
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
test_collector.update_status(test_status, error_message)
|
|
110
|
+
profile.finish(test_status == "passed" ? 200 : 500)
|
|
111
|
+
|
|
112
|
+
collectors.each do |collector|
|
|
113
|
+
begin
|
|
114
|
+
collector.collect if collector.respond_to?(:collect)
|
|
115
|
+
profile.add_collector_metadata(collector)
|
|
116
|
+
rescue => e
|
|
117
|
+
warn "Profiler TestProfiler: Collector #{collector.class} failed: #{e.message}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
Profiler.storage.save(profile.token, profile)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def current_memory
|
|
128
|
+
return 0 unless defined?(GC.stat)
|
|
129
|
+
|
|
130
|
+
stats = GC.stat
|
|
131
|
+
if stats.key?(:total_allocated_size)
|
|
132
|
+
stats[:total_allocated_size]
|
|
133
|
+
elsif stats.key?(:total_allocated_objects)
|
|
134
|
+
stats[:total_allocated_objects] * 40
|
|
135
|
+
else
|
|
136
|
+
0
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module TestRunner
|
|
5
|
+
class Discovery
|
|
6
|
+
SPEC_GLOB = "spec/**/*_spec.rb"
|
|
7
|
+
TEST_GLOB = "test/**/*_test.rb"
|
|
8
|
+
|
|
9
|
+
def self.frameworks
|
|
10
|
+
frameworks = []
|
|
11
|
+
frameworks << :rspec if rspec_available?
|
|
12
|
+
frameworks << :minitest if minitest_available?
|
|
13
|
+
frameworks
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.files(framework: nil)
|
|
17
|
+
root = defined?(Rails) ? Rails.root.to_s : Dir.pwd
|
|
18
|
+
result = {}
|
|
19
|
+
|
|
20
|
+
globs = case framework&.to_sym
|
|
21
|
+
when :rspec then [SPEC_GLOB]
|
|
22
|
+
when :minitest then [TEST_GLOB]
|
|
23
|
+
else [SPEC_GLOB, TEST_GLOB]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
globs.each do |glob|
|
|
27
|
+
Dir.glob(File.join(root, glob)).each do |path|
|
|
28
|
+
relative = path.sub("#{root}/", "")
|
|
29
|
+
parts = relative.split("/")
|
|
30
|
+
dir = parts[0..-2].join("/")
|
|
31
|
+
result[dir] ||= []
|
|
32
|
+
result[dir] << { path: relative, name: parts.last }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
result.map do |dir, files|
|
|
37
|
+
{
|
|
38
|
+
directory: dir,
|
|
39
|
+
files: files.sort_by { |f| f[:name] }
|
|
40
|
+
}
|
|
41
|
+
end.sort_by { |d| d[:directory] }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.rspec_available?
|
|
45
|
+
defined?(RSpec) || Gem.loaded_specs.key?("rspec-core")
|
|
46
|
+
rescue
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.minitest_available?
|
|
51
|
+
defined?(Minitest) || Gem.loaded_specs.key?("minitest")
|
|
52
|
+
rescue
|
|
53
|
+
false
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|