polyrun 2.1.2 → 2.2.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/CHANGELOG.md +17 -1
- data/CONTRIBUTING.md +23 -0
- data/docs/SETUP_PROFILE.md +29 -0
- data/ext/polyrun_coverage_merge/coverage_merge.c +492 -0
- data/ext/polyrun_coverage_merge/extconf.rb +3 -0
- data/lib/polyrun/benchmark/profile.rb +191 -0
- data/lib/polyrun/benchmark/report.rb +87 -0
- data/lib/polyrun/cli/benchmark_commands.rb +66 -0
- data/lib/polyrun/cli/coverage_commands.rb +4 -4
- data/lib/polyrun/cli/coverage_merge_io.rb +30 -4
- data/lib/polyrun/cli/failure_commands.rb +10 -3
- data/lib/polyrun/cli/help.rb +14 -7
- data/lib/polyrun/cli/report_commands.rb +25 -11
- data/lib/polyrun/cli/run_queue_command.rb +5 -32
- data/lib/polyrun/cli/run_shards_parallel_children.rb +7 -0
- data/lib/polyrun/cli/run_shards_parallel_wait.rb +3 -0
- data/lib/polyrun/cli/run_shards_plan_options.rb +1 -1
- data/lib/polyrun/cli/run_shards_run.rb +1 -0
- data/lib/polyrun/cli/run_shards_worker_interrupt.rb +9 -1
- data/lib/polyrun/cli/spec_quality_commands.rb +16 -7
- data/lib/polyrun/cli.rb +8 -1
- data/lib/polyrun/coverage/example_diff.rb +127 -60
- data/lib/polyrun/coverage/example_diff_snapshot.rb +44 -0
- data/lib/polyrun/coverage/example_diff_track_filter.rb +51 -0
- data/lib/polyrun/coverage/file_stats_report.rb +36 -0
- data/lib/polyrun/coverage/formatter.rb +22 -1
- data/lib/polyrun/coverage/merge/formatters.rb +11 -3
- data/lib/polyrun/coverage/merge_merge_two.rb +112 -63
- data/lib/polyrun/coverage/merge_native.rb +37 -0
- data/lib/polyrun/coverage/reporting.rb +1 -1
- data/lib/polyrun/export/csv.rb +25 -0
- data/lib/polyrun/export/markdown.rb +47 -0
- data/lib/polyrun/hooks/shell_runner.rb +33 -0
- data/lib/polyrun/hooks.rb +4 -7
- data/lib/polyrun/partition/paths_build.rb +8 -0
- data/lib/polyrun/queue/worker_loop.rb +39 -0
- data/lib/polyrun/reporting/failure_merge.rb +48 -12
- data/lib/polyrun/reporting/junit.rb +8 -7
- data/lib/polyrun/reporting/junit_report.rb +87 -0
- data/lib/polyrun/rspec/example_debug.rb +158 -0
- data/lib/polyrun/rspec/example_debug_instrumentation.rb +73 -0
- data/lib/polyrun/rspec/sharded_formatter_compat.rb +57 -0
- data/lib/polyrun/rspec.rb +34 -0
- data/lib/polyrun/spec_quality/profile.rb +24 -12
- data/lib/polyrun/spec_quality/report.rb +93 -0
- data/lib/polyrun/spec_quality.rb +19 -11
- data/lib/polyrun/timing/summary.rb +55 -5
- data/lib/polyrun/version.rb +1 -1
- data/lib/polyrun/worker_output.rb +159 -0
- data/lib/polyrun/worker_output_forwarders.rb +90 -0
- data/polyrun.gemspec +8 -1
- data/sig/polyrun/rspec.rbs +10 -0
- data/sig/polyrun/worker_output.rbs +11 -0
- metadata +65 -2
|
@@ -14,26 +14,26 @@ module Polyrun
|
|
|
14
14
|
module Junit
|
|
15
15
|
module_function
|
|
16
16
|
|
|
17
|
-
def write_from_json_file(json_path, output_path:)
|
|
17
|
+
def write_from_json_file(json_path, output_path:, format: "xml")
|
|
18
18
|
data = JSON.parse(File.read(json_path))
|
|
19
|
-
write_from_hash(data, output_path: output_path)
|
|
19
|
+
write_from_hash(data, output_path: output_path, format: format)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
# Merge several RSpec JSON outputs (parallel shards) by concatenating +examples+.
|
|
23
|
-
def merge_rspec_json_files(paths, output_path:)
|
|
23
|
+
def merge_rspec_json_files(paths, output_path:, format: "xml")
|
|
24
24
|
merged = {"examples" => []}
|
|
25
25
|
paths.each do |p|
|
|
26
26
|
data = JSON.parse(File.read(p))
|
|
27
27
|
merged["examples"].concat(data["examples"] || [])
|
|
28
28
|
end
|
|
29
29
|
merged["summary"] = {"summary_line" => "merged #{paths.size} RSpec JSON file(s)"}
|
|
30
|
-
write_from_hash(merged, output_path: output_path)
|
|
30
|
+
write_from_hash(merged, output_path: output_path, format: format)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
def write_from_hash(data, output_path:)
|
|
33
|
+
def write_from_hash(data, output_path:, format: "xml")
|
|
34
34
|
doc = parse_input(data)
|
|
35
|
-
|
|
36
|
-
File.write(output_path,
|
|
35
|
+
body = render(doc, format: format)
|
|
36
|
+
File.write(output_path, body)
|
|
37
37
|
output_path
|
|
38
38
|
end
|
|
39
39
|
|
|
@@ -123,3 +123,4 @@ module Polyrun
|
|
|
123
123
|
end
|
|
124
124
|
|
|
125
125
|
require_relative "junit_emit"
|
|
126
|
+
require_relative "junit_report"
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
require_relative "../export/csv"
|
|
4
|
+
require_relative "../export/markdown"
|
|
5
|
+
|
|
6
|
+
module Polyrun
|
|
7
|
+
module Reporting
|
|
8
|
+
module Junit
|
|
9
|
+
CSV_HEADERS = %w[classname name status time_seconds failure_message].freeze
|
|
10
|
+
MARKDOWN_HEADERS = %w[classname name status time_seconds].freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def render(doc, format: "xml")
|
|
15
|
+
case format.to_s.downcase
|
|
16
|
+
when "xml" then emit_xml(doc)
|
|
17
|
+
when "csv" then emit_csv(doc)
|
|
18
|
+
when "markdown", "md" then emit_markdown(doc)
|
|
19
|
+
else
|
|
20
|
+
raise Polyrun::Error, "report-junit: unknown format #{format.inspect} (use xml, csv, or markdown)"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def emit_csv(doc)
|
|
25
|
+
rows = Array(doc["testcases"]).map { |testcase| csv_row(testcase) }
|
|
26
|
+
Export::Csv.generate(CSV_HEADERS, rows)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def csv_row(testcase)
|
|
30
|
+
testcase = testcase.transform_keys(&:to_s)
|
|
31
|
+
failure = testcase["failure"] || {}
|
|
32
|
+
failure = failure.transform_keys(&:to_s) if failure.is_a?(Hash)
|
|
33
|
+
[
|
|
34
|
+
testcase["classname"],
|
|
35
|
+
testcase["name"],
|
|
36
|
+
status_of(testcase),
|
|
37
|
+
format_float(testcase["time"] || 0),
|
|
38
|
+
failure["message"]
|
|
39
|
+
]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def emit_markdown(doc)
|
|
43
|
+
cases = Array(doc["testcases"])
|
|
44
|
+
sections = [
|
|
45
|
+
markdown_summary_section(doc, cases),
|
|
46
|
+
markdown_testcases_section(cases)
|
|
47
|
+
]
|
|
48
|
+
failure_section = markdown_failures_section(cases)
|
|
49
|
+
sections << failure_section if failure_section
|
|
50
|
+
Export::Markdown.document(doc["name"].to_s, sections)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def markdown_summary_section(doc, cases)
|
|
54
|
+
total_time = cases.sum { |testcase| (testcase["time"] || testcase[:time] || 0).to_f }
|
|
55
|
+
summary_rows = [
|
|
56
|
+
["tests", cases.size],
|
|
57
|
+
["failures", cases.count { |testcase| status_of(testcase) == "failed" }],
|
|
58
|
+
["errors", cases.count { |testcase| status_of(testcase) == "error" }],
|
|
59
|
+
["skipped", cases.count { |testcase| %w[pending skipped].include?(status_of(testcase)) }],
|
|
60
|
+
["time_seconds", format_float(total_time)],
|
|
61
|
+
["hostname", doc["hostname"]]
|
|
62
|
+
]
|
|
63
|
+
{heading: "Summary", headers: %w[metric value], rows: summary_rows}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def markdown_testcases_section(cases)
|
|
67
|
+
testcase_rows = cases.map do |testcase|
|
|
68
|
+
testcase = testcase.transform_keys(&:to_s)
|
|
69
|
+
[testcase["classname"], testcase["name"], status_of(testcase), format_float(testcase["time"] || 0)]
|
|
70
|
+
end
|
|
71
|
+
{heading: "Test cases", headers: MARKDOWN_HEADERS, rows: testcase_rows}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def markdown_failures_section(cases)
|
|
75
|
+
failed = cases.select { |testcase| %w[failed error].include?(status_of(testcase)) }
|
|
76
|
+
return if failed.empty?
|
|
77
|
+
|
|
78
|
+
failure_rows = failed.map do |testcase|
|
|
79
|
+
testcase = testcase.transform_keys(&:to_s)
|
|
80
|
+
failure = (testcase["failure"] || {}).transform_keys(&:to_s)
|
|
81
|
+
[testcase["name"], status_of(testcase), failure["message"], failure["body"]]
|
|
82
|
+
end
|
|
83
|
+
{heading: "Failures", headers: %w[name status message body], rows: failure_rows}
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
require "logger"
|
|
2
|
+
require "timeout"
|
|
3
|
+
require_relative "example_debug_instrumentation"
|
|
4
|
+
|
|
5
|
+
module Polyrun
|
|
6
|
+
module RSpec
|
|
7
|
+
# Per-example RSpec debugging for local investigation (SQL, TracePoint, timeouts).
|
|
8
|
+
#
|
|
9
|
+
# +POLYRUN_EXAMPLE_DEBUG=1+ enables installers; +DEBUG=1+ / +POLYRUN_DEBUG=1+ trace orchestration only.
|
|
10
|
+
# +POLYRUN_DEBUG_SQL=1+ or legacy +DEBUG_SQL=1+ logs mutating SQL.
|
|
11
|
+
# +POLYRUN_DEBUG_TRACE=1+ or legacy +DEBUG_TRACE=1+ traces :call/:raise under the app root.
|
|
12
|
+
# +DEBUG_LOG_LEVEL+ accepts Ruby Logger severities as integers (0 debug … 4 fatal) or names.
|
|
13
|
+
module ExampleDebug
|
|
14
|
+
LOG_LEVEL_BY_NAME = {
|
|
15
|
+
"debug" => Logger::DEBUG,
|
|
16
|
+
"info" => Logger::INFO,
|
|
17
|
+
"warn" => Logger::WARN,
|
|
18
|
+
"error" => Logger::ERROR,
|
|
19
|
+
"fatal" => Logger::FATAL
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def enabled?
|
|
25
|
+
truthy?(ENV["POLYRUN_EXAMPLE_DEBUG"])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def sql_enabled?
|
|
29
|
+
enabled? && (truthy?(ENV["POLYRUN_DEBUG_SQL"]) || truthy?(ENV["DEBUG_SQL"]))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def trace_enabled?
|
|
33
|
+
enabled? && (truthy?(ENV["POLYRUN_DEBUG_TRACE"]) || truthy?(ENV["DEBUG_TRACE"]))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def prosopite_enabled?
|
|
37
|
+
enabled? && truthy?(ENV["DEBUG_PROSOPITE"])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def print_spec_enabled?
|
|
41
|
+
enabled? && truthy?(ENV["DEBUG_PRINT_SPEC"])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def example_timeout_disabled?
|
|
45
|
+
enabled?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def log_level
|
|
49
|
+
parse_log_level(ENV.fetch("DEBUG_LOG_LEVEL", "debug"))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def rails_log_level
|
|
53
|
+
case log_level
|
|
54
|
+
when Logger::DEBUG then :debug
|
|
55
|
+
when Logger::INFO then :info
|
|
56
|
+
when Logger::WARN then :warn
|
|
57
|
+
when Logger::ERROR then :error
|
|
58
|
+
else :fatal
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def parse_log_level(raw)
|
|
63
|
+
normalized = raw.to_s.strip.downcase
|
|
64
|
+
return LOG_LEVEL_BY_NAME.fetch(normalized) if LOG_LEVEL_BY_NAME.key?(normalized)
|
|
65
|
+
|
|
66
|
+
Integer(normalized)
|
|
67
|
+
rescue ArgumentError
|
|
68
|
+
Logger::DEBUG
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def install!(rspec_config: fetch_rspec_configuration!)
|
|
72
|
+
install_spec_path_helpers!(rspec_config)
|
|
73
|
+
install_sql_debug!(rspec_config) if sql_enabled?
|
|
74
|
+
install_trace_debug!(rspec_config) if trace_enabled?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def install_rails_logging!(rspec_config: fetch_rspec_configuration!)
|
|
78
|
+
return unless enabled?
|
|
79
|
+
return unless defined?(Rails)
|
|
80
|
+
|
|
81
|
+
rspec_config.before do |example|
|
|
82
|
+
group = example.metadata[:example_group]
|
|
83
|
+
Polyrun::Log.puts "\n\nRunning #{group[:file_path]}:#{group[:line_number]}"
|
|
84
|
+
|
|
85
|
+
level = ExampleDebug.log_level
|
|
86
|
+
Rails.logger.level = level
|
|
87
|
+
if defined?(ActiveRecord::Base)
|
|
88
|
+
ar_logger = Logger.new(Polyrun::Log.stdout)
|
|
89
|
+
ar_logger.level = level
|
|
90
|
+
ActiveRecord::Base.logger = ar_logger
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def install_example_timeout!(
|
|
96
|
+
rspec_config,
|
|
97
|
+
seconds: ENV.fetch("RSPEC_EXAMPLE_TIMEOUT_SEC", "30").to_f
|
|
98
|
+
)
|
|
99
|
+
return if seconds <= 0
|
|
100
|
+
return if example_timeout_disabled?
|
|
101
|
+
|
|
102
|
+
rspec_config.around(:each) do |example|
|
|
103
|
+
if example.metadata[:benchmark] || example.metadata[:slow]
|
|
104
|
+
example.run
|
|
105
|
+
else
|
|
106
|
+
Timeout.timeout(seconds) { example.run }
|
|
107
|
+
end
|
|
108
|
+
rescue Timeout::Error
|
|
109
|
+
raise "Example timed out after #{seconds}s (#{example.location})"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def install_prosopite!(rspec_config: fetch_rspec_configuration!)
|
|
114
|
+
return unless prosopite_enabled?
|
|
115
|
+
return unless defined?(Prosopite) && defined?(Rails)
|
|
116
|
+
|
|
117
|
+
log_path = Rails.root.join("tmp", "prosopite_#{Time.current.strftime("%Y%m%d_%H%M%S")}.log")
|
|
118
|
+
Prosopite.custom_logger = Logger.new(log_path)
|
|
119
|
+
|
|
120
|
+
rspec_config.before { Prosopite.scan }
|
|
121
|
+
rspec_config.after { Prosopite.finish }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def install_spec_path_helpers!(rspec_config)
|
|
125
|
+
rspec_config.before do |example|
|
|
126
|
+
group = example.metadata[:example_group]
|
|
127
|
+
spec_file_path = group[:file_path]
|
|
128
|
+
|
|
129
|
+
define_singleton_method(:spec_dirname) { File.dirname(spec_file_path) }
|
|
130
|
+
define_singleton_method(:spec_basename) { File.basename(spec_file_path) }
|
|
131
|
+
|
|
132
|
+
if ExampleDebug.print_spec_enabled?
|
|
133
|
+
Polyrun::Log.puts "\nRunning #{spec_file_path}:#{group[:line_number]}\n"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
rspec_config.after do |example|
|
|
138
|
+
next unless ExampleDebug.print_spec_enabled?
|
|
139
|
+
|
|
140
|
+
group = example.metadata[:example_group]
|
|
141
|
+
Polyrun::Log.puts "\nFinished #{group[:file_path]}:#{group[:line_number]}\n"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def fetch_rspec_configuration!
|
|
146
|
+
require "rspec/core"
|
|
147
|
+
::RSpec.configuration
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def truthy?(value)
|
|
151
|
+
return false if value.nil?
|
|
152
|
+
|
|
153
|
+
%w[1 true yes on].include?(value.to_s.strip.downcase)
|
|
154
|
+
end
|
|
155
|
+
private_class_method :truthy?, :parse_log_level, :fetch_rspec_configuration!
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module Polyrun
|
|
2
|
+
module RSpec
|
|
3
|
+
module ExampleDebug
|
|
4
|
+
SKIPPED_SQL_PREFIX = /\A(?:SELECT|SET|SHOW|BEGIN|COMMIT|ROLLBACK|RELEASE|SAVEPOINT)/
|
|
5
|
+
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def install_sql_debug!(rspec_config, io: Polyrun::Log.stdout)
|
|
9
|
+
return unless defined?(ActiveSupport::Notifications)
|
|
10
|
+
|
|
11
|
+
rspec_config.around do |example|
|
|
12
|
+
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
|
|
13
|
+
payload = event.payload[:sql]
|
|
14
|
+
next unless loggable_sql?(payload)
|
|
15
|
+
|
|
16
|
+
line = sql_with_interpolated_binds(payload, event.payload[:type_casted_binds])
|
|
17
|
+
io.puts "+ #{line}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
example.run
|
|
21
|
+
ensure
|
|
22
|
+
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def install_trace_debug!(rspec_config, root: trace_root, io: Polyrun::Log.stdout)
|
|
27
|
+
require "pp"
|
|
28
|
+
root_path = File.expand_path(root.to_s)
|
|
29
|
+
trace = TracePoint.new do |trace_point|
|
|
30
|
+
if trace_point.event == :call && trace_point.path.to_s.start_with?(root_path)
|
|
31
|
+
io.puts PP.pp({event: :call, path: "#{trace_point.path}:#{trace_point.lineno}"}, +"")
|
|
32
|
+
elsif trace_point.event == :raise
|
|
33
|
+
io.puts PP.pp(
|
|
34
|
+
{
|
|
35
|
+
event: :raise,
|
|
36
|
+
raised_exception: trace_point.raised_exception,
|
|
37
|
+
path: "#{trace_point.path}:#{trace_point.lineno}",
|
|
38
|
+
method_id: trace_point.method_id
|
|
39
|
+
},
|
|
40
|
+
+""
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
rspec_config.around do |example|
|
|
46
|
+
trace.enable
|
|
47
|
+
example.run
|
|
48
|
+
ensure
|
|
49
|
+
trace.disable
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def loggable_sql?(sql)
|
|
54
|
+
!sql.to_s.match?(SKIPPED_SQL_PREFIX)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def sql_with_interpolated_binds(sql, binds)
|
|
58
|
+
output = sql.to_s.dup
|
|
59
|
+
Array(binds).each_with_index do |bind, index|
|
|
60
|
+
output = output.gsub("$#{index + 1}", "'#{bind}'")
|
|
61
|
+
end
|
|
62
|
+
output
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def trace_root
|
|
66
|
+
return Rails.root.to_s if defined?(Rails)
|
|
67
|
+
|
|
68
|
+
Dir.pwd
|
|
69
|
+
end
|
|
70
|
+
private_class_method :trace_root
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module Polyrun
|
|
2
|
+
module RSpec
|
|
3
|
+
# Formatter tweaks for progress formatters under +POLYRUN_SHARD_*+ workers.
|
|
4
|
+
module ShardedFormatterCompat
|
|
5
|
+
TextFormatterSilencer = Module.new do
|
|
6
|
+
def seed(_notification)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def dump_summary(_notification)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def dump_pending(_notification)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
FuubarSeedSilencer = Module.new do
|
|
17
|
+
def seed(_notification)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def install!(rspec_config: fetch_rspec_configuration!)
|
|
24
|
+
return unless sharded_worker?
|
|
25
|
+
|
|
26
|
+
rspec_config.silence_filter_announcements = true
|
|
27
|
+
silence_text_formatter_noise!
|
|
28
|
+
silence_fuubar_seed! if defined?(Fuubar)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def sharded_worker?
|
|
32
|
+
!ENV["POLYRUN_SHARD_TOTAL"].to_s.strip.empty?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def silence_text_formatter_noise!
|
|
36
|
+
require "rspec/core/formatters/base_text_formatter"
|
|
37
|
+
klass = ::RSpec::Core::Formatters::BaseTextFormatter
|
|
38
|
+
return if klass.ancestors.include?(TextFormatterSilencer)
|
|
39
|
+
|
|
40
|
+
klass.prepend(TextFormatterSilencer)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def silence_fuubar_seed!
|
|
44
|
+
return unless defined?(Fuubar)
|
|
45
|
+
return if Fuubar.ancestors.include?(FuubarSeedSilencer)
|
|
46
|
+
|
|
47
|
+
Fuubar.prepend(FuubarSeedSilencer)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def fetch_rspec_configuration!
|
|
51
|
+
require "rspec/core"
|
|
52
|
+
::RSpec.configuration
|
|
53
|
+
end
|
|
54
|
+
private_class_method :fetch_rspec_configuration!
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/polyrun/rspec.rb
CHANGED
|
@@ -72,5 +72,39 @@ module Polyrun
|
|
|
72
72
|
|
|
73
73
|
Polyrun::WorkerPing.ensure_interval_ping_thread!
|
|
74
74
|
end
|
|
75
|
+
|
|
76
|
+
def install_example_debug!(rspec_config: nil)
|
|
77
|
+
require_relative "rspec/example_debug"
|
|
78
|
+
Polyrun::RSpec::ExampleDebug.install!(rspec_config: rspec_config || fetch_rspec_configuration!)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def install_example_timeout!(rspec_config: nil, seconds: ENV.fetch("RSPEC_EXAMPLE_TIMEOUT_SEC", "30").to_f)
|
|
82
|
+
require_relative "rspec/example_debug"
|
|
83
|
+
Polyrun::RSpec::ExampleDebug.install_example_timeout!(
|
|
84
|
+
rspec_config || fetch_rspec_configuration!,
|
|
85
|
+
seconds: seconds
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def install_example_rails_logging!(rspec_config: nil)
|
|
90
|
+
require_relative "rspec/example_debug"
|
|
91
|
+
Polyrun::RSpec::ExampleDebug.install_rails_logging!(rspec_config: rspec_config || fetch_rspec_configuration!)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def install_example_prosopite!(rspec_config: nil)
|
|
95
|
+
require_relative "rspec/example_debug"
|
|
96
|
+
Polyrun::RSpec::ExampleDebug.install_prosopite!(rspec_config: rspec_config || fetch_rspec_configuration!)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def install_sharded_formatter_compat!(rspec_config: nil)
|
|
100
|
+
require_relative "rspec/sharded_formatter_compat"
|
|
101
|
+
Polyrun::RSpec::ShardedFormatterCompat.install!(rspec_config: rspec_config || fetch_rspec_configuration!)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def fetch_rspec_configuration!
|
|
105
|
+
require "rspec/core"
|
|
106
|
+
::RSpec.configuration
|
|
107
|
+
end
|
|
108
|
+
private_class_method :fetch_rspec_configuration!
|
|
75
109
|
end
|
|
76
110
|
end
|
|
@@ -4,18 +4,30 @@ module Polyrun
|
|
|
4
4
|
module Profile
|
|
5
5
|
module_function
|
|
6
6
|
|
|
7
|
-
def snapshot
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
7
|
+
def snapshot(dimensions: %w[cpu mem io wall])
|
|
8
|
+
dims = enabled_dimensions(dimensions)
|
|
9
|
+
capture_all = dims.empty?
|
|
10
|
+
out = {}
|
|
11
|
+
|
|
12
|
+
if capture_all || dims.include?("cpu") || dims.include?("wall")
|
|
13
|
+
cpu = Process.times
|
|
14
|
+
out["cpu_user"] = cpu.utime
|
|
15
|
+
out["cpu_system"] = cpu.stime
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
if capture_all || dims.include?("mem")
|
|
19
|
+
gc = GC.stat
|
|
20
|
+
out["gc_allocated"] = gc[:total_allocated_objects]
|
|
21
|
+
out["gc_heap_live"] = gc[:heap_live_slots]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
if capture_all || dims.include?("io")
|
|
25
|
+
io = read_proc_io
|
|
26
|
+
out["io_read_bytes"] = io[:read_bytes]
|
|
27
|
+
out["io_write_bytes"] = io[:write_bytes]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
out
|
|
19
31
|
end
|
|
20
32
|
|
|
21
33
|
def diff(before, after)
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
# rubocop:disable Polyrun/FileLength, Metrics/ModuleLength -- report analysis + text formatting
|
|
2
|
+
require_relative "../export/csv"
|
|
3
|
+
require_relative "../export/markdown"
|
|
4
|
+
|
|
2
5
|
module Polyrun
|
|
3
6
|
module SpecQuality
|
|
4
7
|
# Human-readable spec quality report from merged JSON.
|
|
@@ -55,6 +58,96 @@ module Polyrun
|
|
|
55
58
|
lines.join("\n") + "\n"
|
|
56
59
|
end
|
|
57
60
|
|
|
61
|
+
EXAMPLE_CSV_HEADERS = %w[example unique_lines line_churn max_line_churn shard_index].freeze
|
|
62
|
+
|
|
63
|
+
def format_csv(merged, cfg: {}, top: 30, profile: nil, plan_shards: nil)
|
|
64
|
+
rows = merged.fetch("examples", {}).map do |location, row|
|
|
65
|
+
[
|
|
66
|
+
location,
|
|
67
|
+
row["unique_lines"],
|
|
68
|
+
row["line_churn"],
|
|
69
|
+
row["max_line_churn"],
|
|
70
|
+
row["polyrun_shard_index"]
|
|
71
|
+
]
|
|
72
|
+
end.sort_by { |row| [-row[2].to_i, row[0].to_s] }
|
|
73
|
+
rows = rows.first(top) if top
|
|
74
|
+
Export::Csv.generate(EXAMPLE_CSV_HEADERS, rows)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def format_markdown(merged, cfg: {}, top: 30, profile: nil, plan_shards: nil)
|
|
78
|
+
analysis = analyze(merged, cfg, plan_shards: plan_shards)
|
|
79
|
+
sections = markdown_sections(analysis, top).compact
|
|
80
|
+
Export::Markdown.document("Polyrun spec quality report", sections)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def markdown_sections(analysis, top)
|
|
84
|
+
[
|
|
85
|
+
markdown_shard_section(analysis[:shard_summary]),
|
|
86
|
+
markdown_zero_hit_section(analysis[:zero_hit], top),
|
|
87
|
+
markdown_hot_lines_section(analysis[:hot_lines], top),
|
|
88
|
+
markdown_churn_section(analysis[:line_churn], top)
|
|
89
|
+
]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def markdown_shard_section(shard_summary)
|
|
93
|
+
shard_rows = (shard_summary || {}).sort_by { |shard, _| shard.to_s }.map do |shard, stats|
|
|
94
|
+
[shard, stats["examples"], stats["zero_hit"], stats["line_churn"]]
|
|
95
|
+
end
|
|
96
|
+
return if shard_rows.empty?
|
|
97
|
+
|
|
98
|
+
{
|
|
99
|
+
heading: "Shard attribution",
|
|
100
|
+
headers: %w[shard examples zero_hit line_churn],
|
|
101
|
+
rows: shard_rows
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def markdown_zero_hit_section(zero_hit, top)
|
|
106
|
+
zero_rows = zero_hit.keys.sort.first(top).zip
|
|
107
|
+
{
|
|
108
|
+
heading: "Zero production lines",
|
|
109
|
+
headers: %w[example],
|
|
110
|
+
rows: zero_rows.empty? ? [["(none)"]] : zero_rows
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def markdown_hot_lines_section(hot_lines, top)
|
|
115
|
+
hot_rows = hot_lines.first(top).map do |line, stats|
|
|
116
|
+
[line, stats["example_count"], stats["total_hits"]]
|
|
117
|
+
end
|
|
118
|
+
{
|
|
119
|
+
heading: "Hot lines",
|
|
120
|
+
headers: %w[line example_count total_hits],
|
|
121
|
+
rows: hot_rows.empty? ? [["(none)", 0, 0]] : hot_rows
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def markdown_churn_section(churn_rows, top)
|
|
126
|
+
rows = churn_rows.first(top).map do |location, row|
|
|
127
|
+
[location, row["line_churn"], row["max_line_churn"]]
|
|
128
|
+
end
|
|
129
|
+
{
|
|
130
|
+
heading: "Per-example line churn",
|
|
131
|
+
headers: %w[example line_churn max_line_churn],
|
|
132
|
+
rows: rows.empty? ? [["(none)", 0, 0]] : rows
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def render(merged, format: "text", **kwargs)
|
|
137
|
+
case format.to_s.downcase
|
|
138
|
+
when "text", "console", "txt"
|
|
139
|
+
format_report(merged, **kwargs)
|
|
140
|
+
when "json"
|
|
141
|
+
JSON.pretty_generate(analyze(merged, kwargs[:cfg] || {}, plan_shards: kwargs[:plan_shards]))
|
|
142
|
+
when "csv"
|
|
143
|
+
format_csv(merged, **kwargs)
|
|
144
|
+
when "markdown", "md"
|
|
145
|
+
format_markdown(merged, **kwargs)
|
|
146
|
+
else
|
|
147
|
+
raise Polyrun::Error, "report-spec-quality: unknown format #{format.inspect} (use text, json, csv, or markdown)"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
58
151
|
def gate_violations(merged, cfg = {})
|
|
59
152
|
cfg = default_cfg(cfg)
|
|
60
153
|
analysis = analyze(merged, cfg)
|
data/lib/polyrun/spec_quality.rb
CHANGED
|
@@ -58,8 +58,12 @@ module Polyrun
|
|
|
58
58
|
@current = {
|
|
59
59
|
location: normalize_location(location),
|
|
60
60
|
wall_start: wall_start || Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
|
61
|
-
coverage_before: Coverage::ExampleDiff.peek_blob
|
|
62
|
-
|
|
61
|
+
coverage_before: Coverage::ExampleDiff.peek_blob(
|
|
62
|
+
root: @config["root"],
|
|
63
|
+
track_under: @config["track_under"],
|
|
64
|
+
ignore_paths: @config["ignore_paths"]
|
|
65
|
+
),
|
|
66
|
+
profile_before: Profile.snapshot(dimensions: @config["profile"]),
|
|
63
67
|
sql_count: 0,
|
|
64
68
|
sql_fingerprints: Hash.new(0),
|
|
65
69
|
factory_counts: {}
|
|
@@ -78,16 +82,9 @@ module Polyrun
|
|
|
78
82
|
loc = location || cur[:location]
|
|
79
83
|
return if loc.nil? || loc.to_s.empty?
|
|
80
84
|
|
|
81
|
-
|
|
82
|
-
delta = Coverage::ExampleDiff.diff(cur[:coverage_before], after_cov)
|
|
83
|
-
delta = Coverage::ExampleDiff.apply_track_under(
|
|
84
|
-
delta,
|
|
85
|
-
root: @config["root"],
|
|
86
|
-
track_under: @config["track_under"],
|
|
87
|
-
ignore_paths: @config["ignore_paths"]
|
|
88
|
-
)
|
|
85
|
+
delta = coverage_delta_for_example(cur)
|
|
89
86
|
|
|
90
|
-
profile_after = Profile.snapshot
|
|
87
|
+
profile_after = Profile.snapshot(dimensions: @config["profile"])
|
|
91
88
|
profile_delta = Profile.diff(cur[:profile_before], profile_after)
|
|
92
89
|
wall = Process.clock_gettime(Process::CLOCK_MONOTONIC) - cur[:wall_start]
|
|
93
90
|
profile_delta["wall"] = wall
|
|
@@ -169,6 +166,17 @@ module Polyrun
|
|
|
169
166
|
Polyrun::Partition::TimingKeys.canonical_file_path(File.expand_path(s, root))
|
|
170
167
|
end
|
|
171
168
|
|
|
169
|
+
def coverage_delta_for_example(cur)
|
|
170
|
+
after_source = Coverage::ExampleDiff.coverage_active? ? ::Coverage.peek_result : {}
|
|
171
|
+
Coverage::ExampleDiff.diff(
|
|
172
|
+
cur[:coverage_before],
|
|
173
|
+
after_source,
|
|
174
|
+
root: @config["root"],
|
|
175
|
+
track_under: @config["track_under"],
|
|
176
|
+
ignore_paths: @config["ignore_paths"]
|
|
177
|
+
)
|
|
178
|
+
end
|
|
179
|
+
|
|
172
180
|
def build_row(cur, location, delta, profile_delta, factory_counts)
|
|
173
181
|
profile = Profile.slice_profile(profile_delta, @config["profile"])
|
|
174
182
|
repeated_sql = cur[:sql_fingerprints].select { |_sql, n| n >= min_query_count }.transform_keys(&:to_s)
|