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,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "profiler/test_runner/discovery"
|
|
4
|
+
require "profiler/test_runner/runner"
|
|
5
|
+
|
|
6
|
+
module Profiler
|
|
7
|
+
module Api
|
|
8
|
+
class TestRunnerController < ApplicationController
|
|
9
|
+
include ActionController::Live
|
|
10
|
+
|
|
11
|
+
skip_before_action :verify_authenticity_token
|
|
12
|
+
|
|
13
|
+
def files
|
|
14
|
+
framework = params[:framework]
|
|
15
|
+
tree = Profiler::TestRunner::Discovery.files(framework: framework)
|
|
16
|
+
frameworks = Profiler::TestRunner::Discovery.frameworks
|
|
17
|
+
|
|
18
|
+
render json: {
|
|
19
|
+
frameworks: frameworks,
|
|
20
|
+
tree: tree
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def create
|
|
25
|
+
files = Array(params[:files])
|
|
26
|
+
framework = params[:framework] || detect_framework
|
|
27
|
+
|
|
28
|
+
if files.empty?
|
|
29
|
+
return render json: { error: "No files selected" }, status: :unprocessable_entity
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Validate paths are within Rails root (prevent path traversal)
|
|
33
|
+
root = defined?(Rails) ? Rails.root.to_s : Dir.pwd
|
|
34
|
+
files.each do |f|
|
|
35
|
+
expanded = File.expand_path(File.join(root, f))
|
|
36
|
+
unless expanded.start_with?(root)
|
|
37
|
+
return render json: { error: "Invalid file path: #{f}" }, status: :unprocessable_entity
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
run = Profiler::TestRunner::Runner.start(files: files, framework: framework)
|
|
42
|
+
render json: run.to_h, status: :created
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def show
|
|
46
|
+
run = Profiler::TestRunner.run_store.find(params[:id])
|
|
47
|
+
return render json: { error: "Run not found" }, status: :not_found unless run
|
|
48
|
+
|
|
49
|
+
render json: run.to_h
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# SSE endpoint — streams output chunks as server-sent events.
|
|
53
|
+
# Replaces polling for live test output in the frontend.
|
|
54
|
+
def stream
|
|
55
|
+
run = Profiler::TestRunner.run_store.find(params[:id])
|
|
56
|
+
unless run
|
|
57
|
+
render json: { error: "Run not found" }, status: :not_found
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
response.headers["Content-Type"] = "text/event-stream"
|
|
62
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
63
|
+
response.headers["X-Accel-Buffering"] = "no"
|
|
64
|
+
|
|
65
|
+
sse = SSE.new(response.stream, retry: 1000, event: "output")
|
|
66
|
+
position = 0
|
|
67
|
+
|
|
68
|
+
begin
|
|
69
|
+
loop do
|
|
70
|
+
result = Profiler::TestRunner.run_store.wait_for_output(
|
|
71
|
+
params[:id], position: position, timeout: 15
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
result[:chunks].each do |chunk|
|
|
75
|
+
sse.write({ chunk: chunk })
|
|
76
|
+
end
|
|
77
|
+
position = result[:position]
|
|
78
|
+
|
|
79
|
+
if result[:finished]
|
|
80
|
+
current_run = Profiler::TestRunner.run_store.find(params[:id])
|
|
81
|
+
sse.write(
|
|
82
|
+
{ status: result[:status], exit_code: current_run&.exit_code },
|
|
83
|
+
event: "done"
|
|
84
|
+
)
|
|
85
|
+
break
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
rescue ActionController::Live::ClientDisconnected, IOError
|
|
89
|
+
# Client navigated away — normal exit
|
|
90
|
+
ensure
|
|
91
|
+
sse.close
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def destroy
|
|
96
|
+
killed = Profiler::TestRunner::Runner.kill(params[:id])
|
|
97
|
+
if killed
|
|
98
|
+
head :no_content
|
|
99
|
+
else
|
|
100
|
+
render json: { error: "Run not found or not running" }, status: :not_found
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def detect_framework
|
|
107
|
+
if Profiler::TestRunner::Discovery.rspec_available?
|
|
108
|
+
"rspec"
|
|
109
|
+
else
|
|
110
|
+
"minitest"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module Api
|
|
5
|
+
class TestsController < ApplicationController
|
|
6
|
+
skip_before_action :verify_authenticity_token
|
|
7
|
+
|
|
8
|
+
def index
|
|
9
|
+
limit = (params[:limit] || 50).to_i
|
|
10
|
+
offset = (params[:offset] || 0).to_i
|
|
11
|
+
all = Profiler.storage.list(limit: 1000, offset: 0)
|
|
12
|
+
tests = all.select { |p| p.profile_type == "test" }
|
|
13
|
+
page = tests.drop(offset).first(limit + 1)
|
|
14
|
+
render json: {
|
|
15
|
+
profiles: page.first(limit).map(&:to_h),
|
|
16
|
+
limit: limit,
|
|
17
|
+
offset: offset,
|
|
18
|
+
has_more: page.size > limit
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def show
|
|
23
|
+
profile = Profiler.storage.load(params[:id])
|
|
24
|
+
|
|
25
|
+
unless profile && profile.profile_type == "test"
|
|
26
|
+
return render json: { error: "Test profile not found" }, status: :not_found
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
render json: profile.to_h
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def destroy
|
|
33
|
+
profile = Profiler.storage.load(params[:id])
|
|
34
|
+
return render json: { error: "Test profile not found" }, status: :not_found unless profile&.profile_type == "test"
|
|
35
|
+
|
|
36
|
+
Profiler.storage.delete(params[:id])
|
|
37
|
+
head :no_content
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def clear
|
|
41
|
+
Profiler.storage.clear(type: "test")
|
|
42
|
+
head :no_content
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<div id="profiler-test-runner"></div>
|
data/config/routes.rb
CHANGED
|
@@ -22,6 +22,8 @@ Profiler::Engine.routes.draw do
|
|
|
22
22
|
get "assets/profiler.js", to: "assets#main_js"
|
|
23
23
|
get "assets/profiler.css", to: "assets#main_css"
|
|
24
24
|
|
|
25
|
+
get "test_runner", to: "test_runner#index"
|
|
26
|
+
|
|
25
27
|
namespace :api do
|
|
26
28
|
resources :profiles, only: [:index, :show, :destroy] do
|
|
27
29
|
collection { delete :clear }
|
|
@@ -32,6 +34,9 @@ Profiler::Engine.routes.draw do
|
|
|
32
34
|
resources :console, only: [:index, :show, :destroy] do
|
|
33
35
|
collection { delete :clear }
|
|
34
36
|
end
|
|
37
|
+
resources :tests, only: [:index, :show, :destroy] do
|
|
38
|
+
collection { delete :clear }
|
|
39
|
+
end
|
|
35
40
|
resources :outbound_http, only: [:index]
|
|
36
41
|
get "toolbar/:token", to: "toolbar#show"
|
|
37
42
|
post "ajax/link", to: "ajax#link"
|
|
@@ -40,5 +45,10 @@ Profiler::Engine.routes.draw do
|
|
|
40
45
|
resource :env_vars, only: [:show, :update], controller: "env_vars"
|
|
41
46
|
delete "env_vars/reset", to: "env_vars#reset_override"
|
|
42
47
|
delete "env_vars/reset_all", to: "env_vars#reset_all"
|
|
48
|
+
get "test_runner/files", to: "test_runner#files"
|
|
49
|
+
post "test_runner/runs", to: "test_runner#create"
|
|
50
|
+
get "test_runner/runs/:id", to: "test_runner#show", as: :test_runner_run
|
|
51
|
+
get "test_runner/runs/:id/stream", to: "test_runner#stream", as: :test_runner_run_stream
|
|
52
|
+
delete "test_runner/runs/:id", to: "test_runner#destroy"
|
|
43
53
|
end
|
|
44
54
|
end
|
|
@@ -39,7 +39,7 @@ module Profiler
|
|
|
39
39
|
|
|
40
40
|
# Skip schema queries and internal Rails queries
|
|
41
41
|
next if payload[:name] == "SCHEMA"
|
|
42
|
-
next if payload[:sql] =~ /^(BEGIN|COMMIT|ROLLBACK|SAVEPOINT)/i
|
|
42
|
+
next if payload[:sql] =~ /^(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)/i
|
|
43
43
|
|
|
44
44
|
query = Models::SqlQuery.new(
|
|
45
45
|
sql: payload[:sql],
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_collector"
|
|
4
|
+
|
|
5
|
+
module Profiler
|
|
6
|
+
module Collectors
|
|
7
|
+
class TestCollector < BaseCollector
|
|
8
|
+
def initialize(profile, test_name:, test_file:, test_line:, framework:)
|
|
9
|
+
super(profile)
|
|
10
|
+
@test_name = test_name
|
|
11
|
+
@test_file = test_file
|
|
12
|
+
@test_line = test_line
|
|
13
|
+
@framework = framework
|
|
14
|
+
@status = "running"
|
|
15
|
+
@exception_message = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def icon
|
|
19
|
+
"🧪"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def priority
|
|
23
|
+
5
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def tab_config
|
|
27
|
+
{
|
|
28
|
+
key: "test",
|
|
29
|
+
label: "Test",
|
|
30
|
+
icon: icon,
|
|
31
|
+
priority: priority,
|
|
32
|
+
enabled: true,
|
|
33
|
+
default_active: true
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def update_status(status, exception_message = nil)
|
|
38
|
+
@status = status
|
|
39
|
+
@exception_message = exception_message
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def update_extra(assertions: nil, skip_reason: nil)
|
|
43
|
+
@assertions = assertions
|
|
44
|
+
@skip_reason = skip_reason
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def collect
|
|
48
|
+
store_data({
|
|
49
|
+
test_name: @test_name,
|
|
50
|
+
test_file: @test_file,
|
|
51
|
+
test_line: @test_line,
|
|
52
|
+
framework: @framework.to_s,
|
|
53
|
+
status: @status,
|
|
54
|
+
exception_message: @exception_message,
|
|
55
|
+
assertions: @assertions,
|
|
56
|
+
skip_reason: @skip_reason
|
|
57
|
+
})
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def has_data?
|
|
61
|
+
true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def toolbar_summary
|
|
65
|
+
color = case @status
|
|
66
|
+
when "passed" then "green"
|
|
67
|
+
when "failed" then "red"
|
|
68
|
+
when "pending" then "orange"
|
|
69
|
+
else "gray"
|
|
70
|
+
end
|
|
71
|
+
{ text: @status, color: color }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Profiler
|
|
4
4
|
class Configuration
|
|
5
|
-
attr_accessor :enabled, :
|
|
5
|
+
attr_accessor :enabled, :storage_options, :collectors,
|
|
6
6
|
:skip_paths, :slow_query_threshold, :max_queries_warning,
|
|
7
7
|
:track_memory, :memory_warning_threshold,
|
|
8
8
|
:mcp_enabled, :mcp_transport, :mcp_port,
|
|
@@ -12,6 +12,7 @@ module Profiler
|
|
|
12
12
|
:track_http, :slow_http_threshold, :http_skip_hosts,
|
|
13
13
|
:track_jobs,
|
|
14
14
|
:track_console,
|
|
15
|
+
:track_tests,
|
|
15
16
|
:track_mailers, :capture_mail_body, :sanitize_mailer_recipients, :mailer_skip_actions,
|
|
16
17
|
:compress_bodies, :compress_body_threshold
|
|
17
18
|
|
|
@@ -44,6 +45,7 @@ module Profiler
|
|
|
44
45
|
@http_skip_hosts = []
|
|
45
46
|
@track_jobs = true
|
|
46
47
|
@track_console = true
|
|
48
|
+
@track_tests = false
|
|
47
49
|
@track_mailers = true
|
|
48
50
|
@capture_mail_body = false
|
|
49
51
|
@sanitize_mailer_recipients = false
|
|
@@ -72,6 +74,15 @@ module Profiler
|
|
|
72
74
|
end
|
|
73
75
|
end
|
|
74
76
|
|
|
77
|
+
def storage
|
|
78
|
+
@storage
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def storage=(value)
|
|
82
|
+
@storage = value
|
|
83
|
+
@storage_backend = nil
|
|
84
|
+
end
|
|
85
|
+
|
|
75
86
|
def storage_backend
|
|
76
87
|
@storage_backend ||= build_storage_backend
|
|
77
88
|
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module MCP
|
|
5
|
+
module Resources
|
|
6
|
+
class FailingTests
|
|
7
|
+
def self.call
|
|
8
|
+
profiles = Profiler.storage.list(limit: 500)
|
|
9
|
+
failing = profiles.select do |p|
|
|
10
|
+
next false unless p.profile_type == "test"
|
|
11
|
+
test_data = p.collector_data("test") || {}
|
|
12
|
+
test_data["status"] == "failed"
|
|
13
|
+
end.first(20)
|
|
14
|
+
|
|
15
|
+
data = failing.map do |profile|
|
|
16
|
+
test_data = profile.collector_data("test") || {}
|
|
17
|
+
db_data = profile.collector_data("database") || {}
|
|
18
|
+
|
|
19
|
+
{
|
|
20
|
+
token: profile.token,
|
|
21
|
+
test_name: test_data["test_name"] || profile.path,
|
|
22
|
+
framework: test_data["framework"],
|
|
23
|
+
file: "#{test_data["test_file"]}:#{test_data["test_line"]}",
|
|
24
|
+
exception: test_data["exception_message"],
|
|
25
|
+
duration_ms: profile.duration&.round(2),
|
|
26
|
+
query_count: db_data["total_queries"].to_i,
|
|
27
|
+
timestamp: profile.started_at&.iso8601
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
uri: "profiler://failing-tests",
|
|
33
|
+
mimeType: "application/json",
|
|
34
|
+
text: JSON.pretty_generate({ total: data.size, failing_tests: data })
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module MCP
|
|
5
|
+
module Resources
|
|
6
|
+
class SlowTests
|
|
7
|
+
def self.call
|
|
8
|
+
profiles = Profiler.storage.list(limit: 500)
|
|
9
|
+
tests = profiles.select { |p| p.profile_type == "test" }
|
|
10
|
+
.sort_by { |p| -p.duration }
|
|
11
|
+
.first(10)
|
|
12
|
+
|
|
13
|
+
data = tests.map do |profile|
|
|
14
|
+
test_data = profile.collector_data("test") || {}
|
|
15
|
+
db_data = profile.collector_data("database") || {}
|
|
16
|
+
queries = db_data["queries"] || []
|
|
17
|
+
n1 = queries.group_by { |q| normalize_sql(q["sql"].to_s) }.any? { |_, qs| qs.size >= 3 }
|
|
18
|
+
|
|
19
|
+
{
|
|
20
|
+
token: profile.token,
|
|
21
|
+
test_name: test_data["test_name"] || profile.path,
|
|
22
|
+
status: test_data["status"],
|
|
23
|
+
framework: test_data["framework"],
|
|
24
|
+
file: test_data["test_file"],
|
|
25
|
+
duration_ms: profile.duration&.round(2),
|
|
26
|
+
query_count: db_data["total_queries"].to_i,
|
|
27
|
+
n1_detected: n1,
|
|
28
|
+
timestamp: profile.started_at&.iso8601
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
{
|
|
33
|
+
uri: "profiler://slow-tests",
|
|
34
|
+
mimeType: "application/json",
|
|
35
|
+
text: JSON.pretty_generate({ total: data.size, slow_tests: data })
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.normalize_sql(sql)
|
|
40
|
+
sql.gsub(/\$\d+/, "?").gsub(/\b\d+\b/, "?").gsub(/'[^']*'/, "?").gsub(/"[^"]*"/, "?").strip
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/profiler/mcp/server.rb
CHANGED
|
@@ -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
|
|
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"
|
|
285
|
-
"profiler://slow-queries"
|
|
286
|
-
"profiler://n1-patterns"
|
|
287
|
-
"profiler://recent-jobs"
|
|
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
|