rails-profiler 0.24.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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/profiler.css +24 -0
  3. data/app/assets/builds/profiler.js +994 -26
  4. data/app/controllers/profiler/api/console_controller.rb +46 -0
  5. data/app/controllers/profiler/api/profiles_controller.rb +1 -1
  6. data/app/controllers/profiler/api/test_runner_controller.rb +115 -0
  7. data/app/controllers/profiler/api/tests_controller.rb +46 -0
  8. data/app/controllers/profiler/test_runner_controller.rb +11 -0
  9. data/app/views/profiler/test_runner/index.html.erb +1 -0
  10. data/config/routes.rb +13 -0
  11. data/lib/profiler/collectors/console_collector.rb +57 -0
  12. data/lib/profiler/collectors/database_collector.rb +1 -1
  13. data/lib/profiler/collectors/test_collector.rb +75 -0
  14. data/lib/profiler/configuration.rb +14 -1
  15. data/lib/profiler/console_profiler.rb +102 -0
  16. data/lib/profiler/instrumentation/irb_instrumentation.rb +21 -0
  17. data/lib/profiler/mcp/resources/failing_tests.rb +40 -0
  18. data/lib/profiler/mcp/resources/slow_tests.rb +45 -0
  19. data/lib/profiler/mcp/server.rb +77 -6
  20. data/lib/profiler/mcp/tools/get_test_profile_detail.rb +126 -0
  21. data/lib/profiler/mcp/tools/query_test_profiles.rb +109 -0
  22. data/lib/profiler/mcp/tools/run_tests.rb +112 -0
  23. data/lib/profiler/railtie.rb +21 -1
  24. data/lib/profiler/test_helpers/minitest_support.rb +39 -0
  25. data/lib/profiler/test_helpers/reporter.rb +121 -0
  26. data/lib/profiler/test_helpers/rspec_support.rb +33 -0
  27. data/lib/profiler/test_profiler.rb +140 -0
  28. data/lib/profiler/test_runner/discovery.rb +57 -0
  29. data/lib/profiler/test_runner/run_store.rb +120 -0
  30. data/lib/profiler/test_runner/runner.rb +106 -0
  31. data/lib/profiler/version.rb +1 -1
  32. metadata +23 -2
@@ -71,6 +71,9 @@ module Profiler
71
71
  require_relative "tools/get_profile_http"
72
72
  require_relative "tools/query_jobs"
73
73
  require_relative "tools/query_mailers"
74
+ require_relative "tools/query_test_profiles"
75
+ require_relative "tools/get_test_profile_detail"
76
+ require_relative "tools/run_tests"
74
77
  require_relative "tools/clear_profiles"
75
78
  require_relative "tools/list_env_vars"
76
79
  require_relative "tools/set_env_var"
@@ -203,12 +206,64 @@ module Profiler
203
206
  },
204
207
  handler: Tools::QueryMailers
205
208
  ),
209
+ define_tool(
210
+ name: "query_test_profiles",
211
+ description: "Search and filter test profiles (RSpec/Minitest) by test name, status, or duration.",
212
+ input_schema: {
213
+ properties: {
214
+ test_name: { type: "string", description: "Filter by test name (partial match)" },
215
+ status: { type: "string", description: "Filter by status: 'passed', 'failed', or 'pending'" },
216
+ min_duration: { type: "number", description: "Minimum duration in milliseconds" },
217
+ limit: { type: "number", description: "Maximum number of results (default 20)" },
218
+ fields: { type: "array", items: { type: "string" }, description: "Columns to include. Valid values: time, test_name, status, duration, queries, n1, token. Omit for all." },
219
+ cursor: { type: "string", description: "Pagination cursor: ISO8601 timestamp of the last item seen." }
220
+ }
221
+ },
222
+ handler: Tools::QueryTestProfiles
223
+ ),
224
+ define_tool(
225
+ name: "get_test_profile",
226
+ description: "Get detailed data for a test profile: metadata, SQL queries, N+1 patterns, cache, exception. Use 'latest' as token for the most recent test.",
227
+ input_schema: {
228
+ properties: {
229
+ token: { type: "string", description: "Test profile token, or 'latest' for the most recent test profile (required)" }
230
+ },
231
+ required: ["token"]
232
+ },
233
+ handler: Tools::GetTestProfileDetail
234
+ ),
235
+ define_tool(
236
+ name: "run_tests",
237
+ description: "Run test files and wait for results. Returns output, status, duration, and tokens of test profiles created. Synchronous with configurable timeout (default 120s).",
238
+ input_schema: {
239
+ properties: {
240
+ files: {
241
+ type: "array",
242
+ items: { type: "string" },
243
+ description: "Relative paths of test files to run (e.g. ['spec/models/user_spec.rb']). Omit to run all discovered tests."
244
+ },
245
+ framework: {
246
+ type: "string",
247
+ description: "Test framework: 'rspec' or 'minitest'. Auto-detected if omitted."
248
+ },
249
+ timeout_seconds: {
250
+ type: "number",
251
+ description: "Maximum seconds to wait for tests to finish (default: 120)."
252
+ },
253
+ max_output: {
254
+ type: "number",
255
+ description: "Maximum characters of output to return (tail). Default: 4000."
256
+ }
257
+ }
258
+ },
259
+ handler: Tools::RunTests
260
+ ),
206
261
  define_tool(
207
262
  name: "clear_profiles",
208
- description: "Clear profiler history. Omit type to clear everything, or pass 'http'/'job' to clear only requests or jobs.",
263
+ description: "Clear profiler history. Omit type to clear everything, or pass 'http'/'job'/'test' to clear only that type.",
209
264
  input_schema: {
210
265
  properties: {
211
- type: { type: "string", description: "Optional: 'http' to clear only requests, 'job' to clear only jobs" }
266
+ type: { type: "string", description: "Optional: 'http' to clear only requests, 'job' to clear only jobs, 'test' to clear only test profiles" }
212
267
  }
213
268
  },
214
269
  handler: Tools::ClearProfiles
@@ -279,12 +334,16 @@ module Profiler
279
334
  require_relative "resources/slow_queries"
280
335
  require_relative "resources/n1_patterns"
281
336
  require_relative "resources/recent_jobs"
337
+ require_relative "resources/slow_tests"
338
+ require_relative "resources/failing_tests"
282
339
 
283
340
  handlers = {
284
- "profiler://recent" => Resources::RecentRequests,
285
- "profiler://slow-queries" => Resources::SlowQueries,
286
- "profiler://n1-patterns" => Resources::N1Patterns,
287
- "profiler://recent-jobs" => Resources::RecentJobs
341
+ "profiler://recent" => Resources::RecentRequests,
342
+ "profiler://slow-queries" => Resources::SlowQueries,
343
+ "profiler://n1-patterns" => Resources::N1Patterns,
344
+ "profiler://recent-jobs" => Resources::RecentJobs,
345
+ "profiler://slow-tests" => Resources::SlowTests,
346
+ "profiler://failing-tests" => Resources::FailingTests
288
347
  }
289
348
 
290
349
  resources = [
@@ -311,6 +370,18 @@ module Profiler
311
370
  name: "Recent Jobs",
312
371
  description: "List of recently profiled background jobs",
313
372
  mime_type: "application/json"
373
+ ),
374
+ ::MCP::Resource.new(
375
+ uri: "profiler://slow-tests",
376
+ name: "Slow Tests",
377
+ description: "Top 10 slowest test profiles with query counts and N+1 detection",
378
+ mime_type: "application/json"
379
+ ),
380
+ ::MCP::Resource.new(
381
+ uri: "profiler://failing-tests",
382
+ name: "Failing Tests",
383
+ description: "Recent test profiles with status 'failed', including exception messages",
384
+ mime_type: "application/json"
314
385
  )
315
386
  ]
316
387
 
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module MCP
5
+ module Tools
6
+ class GetTestProfileDetail
7
+ def self.call(params)
8
+ token = params["token"]
9
+ unless token
10
+ return [{ type: "text", text: "Error: token parameter is required" }]
11
+ end
12
+
13
+ profile = if token == "latest"
14
+ profiles = Profiler.storage.list(limit: 200)
15
+ profiles.find { |p| p.profile_type == "test" }
16
+ else
17
+ Profiler.storage.load(token)
18
+ end
19
+
20
+ unless profile && profile.profile_type == "test"
21
+ return [{ type: "text", text: "Test profile not found: #{token}" }]
22
+ end
23
+
24
+ [{ type: "text", text: format_test_detail(profile) }]
25
+ end
26
+
27
+ private
28
+
29
+ def self.format_test_detail(profile)
30
+ test_data = profile.collector_data("test") || {}
31
+ db_data = profile.collector_data("database") || {}
32
+ cache_data = profile.collector_data("cache") || {}
33
+ exc_data = profile.collector_data("exception") || {}
34
+
35
+ queries = db_data["queries"] || []
36
+ n1_count = count_n1_patterns(queries)
37
+
38
+ lines = []
39
+ lines << "# Test Profile Detail\n"
40
+
41
+ # Overview
42
+ lines << "## Overview"
43
+ lines << "| Field | Value |"
44
+ lines << "|-------|-------|"
45
+ lines << "| Token | `#{profile.token}` |"
46
+ lines << "| Test name | #{test_data["test_name"] || profile.path} |"
47
+ lines << "| Status | #{test_data["status"] || "unknown"} |"
48
+ lines << "| Framework | #{test_data["framework"]} |"
49
+ lines << "| File | #{test_data["test_file"]}:#{test_data["test_line"]} |"
50
+ lines << "| Duration | #{profile.duration&.round(2)}ms |"
51
+ lines << "| Assertions | #{test_data["assertions"] || "-"} |"
52
+ lines << "| Memory delta | #{profile.memory ? "#{(profile.memory.to_f / 1024 / 1024).round(2)} MB" : "-"} |"
53
+ lines << "| Time | #{profile.started_at&.strftime("%H:%M:%S")} |"
54
+
55
+ # Exception / skip
56
+ if test_data["exception_message"]
57
+ lines << "\n## Exception"
58
+ lines << "```\n#{test_data["exception_message"]}\n```"
59
+ elsif test_data["skip_reason"]
60
+ lines << "\n## Skip reason"
61
+ lines << test_data["skip_reason"].to_s
62
+ end
63
+
64
+ # Unhandled exception (ExceptionCollector)
65
+ if exc_data["exception_class"]
66
+ lines << "\n## Unhandled Exception"
67
+ lines << "**#{exc_data["exception_class"]}**: #{exc_data["exception_message"]}"
68
+ if (backtrace = exc_data["backtrace"]).is_a?(Array) && backtrace.any?
69
+ lines << "\n```"
70
+ backtrace.first(5).each { |l| lines << l }
71
+ lines << "```"
72
+ end
73
+ end
74
+
75
+ # Database
76
+ lines << "\n## Database (#{db_data["total_queries"].to_i} queries · #{db_data["total_duration"].to_f.round(2)}ms · #{n1_count} N+1 patterns)"
77
+ if queries.any?
78
+ slow_threshold = Profiler.configuration.slow_query_threshold
79
+ slow = queries.select { |q| q["duration"].to_f >= slow_threshold }
80
+ show = slow.any? ? slow.first(10) : queries.first(10)
81
+ caption = slow.any? ? "Slowest queries:" : "First queries:"
82
+ lines << caption
83
+ lines << "| # | Duration | SQL |"
84
+ lines << "|---|----------|-----|"
85
+ show.each_with_index do |q, i|
86
+ sql = q["sql"].to_s.gsub("|", "\\|").then { |s| s.length > 100 ? s[0, 97] + "..." : s }
87
+ lines << "| #{i + 1} | #{q["duration"].to_f.round(2)}ms | `#{sql}` |"
88
+ end
89
+
90
+ if n1_count > 0
91
+ lines << "\n### N+1 Patterns Detected"
92
+ queries.group_by { |q| normalize_sql(q["sql"].to_s) }
93
+ .select { |_, qs| qs.size >= 3 }
94
+ .each do |pattern, qs|
95
+ lines << "- `#{pattern[0, 120]}` (×#{qs.size})"
96
+ end
97
+ end
98
+ else
99
+ lines << "_No SQL queries recorded._"
100
+ end
101
+
102
+ # Cache
103
+ total_cache = cache_data["total_reads"].to_i + cache_data["total_writes"].to_i + cache_data["total_deletes"].to_i
104
+ if total_cache > 0
105
+ lines << "\n## Cache (#{total_cache} operations)"
106
+ lines << "- Reads: #{cache_data["total_reads"].to_i}"
107
+ lines << "- Writes: #{cache_data["total_writes"].to_i}"
108
+ lines << "- Deletes: #{cache_data["total_deletes"].to_i}"
109
+ lines << "- Misses: #{cache_data["total_misses"].to_i}"
110
+ end
111
+
112
+ lines.join("\n")
113
+ end
114
+
115
+ def self.count_n1_patterns(queries)
116
+ return 0 if queries.size < 3
117
+ queries.group_by { |q| normalize_sql(q["sql"].to_s) }.count { |_, qs| qs.size >= 3 }
118
+ end
119
+
120
+ def self.normalize_sql(sql)
121
+ sql.gsub(/\$\d+/, "?").gsub(/\b\d+\b/, "?").gsub(/'[^']*'/, "?").gsub(/"[^"]*"/, "?").strip
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -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
@@ -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
 
@@ -81,6 +93,14 @@ module Profiler
81
93
  end
82
94
  end
83
95
 
96
+ console do
97
+ next unless Profiler.configuration.enabled && Profiler.configuration.track_console
98
+
99
+ require_relative "console_profiler"
100
+ require_relative "instrumentation/irb_instrumentation"
101
+ IRB::Context.prepend(Profiler::Instrumentation::IrbInstrumentation)
102
+ end
103
+
84
104
  rake_tasks do
85
105
  load "profiler/tasks/profiler.rake"
86
106
  end
@@ -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