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.
@@ -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
 
@@ -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