polyrun 1.0.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 +7 -0
- data/CODE_OF_CONDUCT.md +31 -0
- data/CONTRIBUTING.md +84 -0
- data/LICENSE +21 -0
- data/README.md +140 -0
- data/SECURITY.md +27 -0
- data/bin/polyrun +6 -0
- data/docs/SETUP_PROFILE.md +106 -0
- data/lib/polyrun/cli/coverage_commands.rb +150 -0
- data/lib/polyrun/cli/coverage_merge_io.rb +124 -0
- data/lib/polyrun/cli/database_commands.rb +149 -0
- data/lib/polyrun/cli/env_commands.rb +43 -0
- data/lib/polyrun/cli/helpers.rb +113 -0
- data/lib/polyrun/cli/init_command.rb +99 -0
- data/lib/polyrun/cli/plan_command.rb +134 -0
- data/lib/polyrun/cli/prepare_command.rb +71 -0
- data/lib/polyrun/cli/prepare_recipe.rb +77 -0
- data/lib/polyrun/cli/queue_command.rb +101 -0
- data/lib/polyrun/cli/quick_command.rb +13 -0
- data/lib/polyrun/cli/report_commands.rb +94 -0
- data/lib/polyrun/cli/run_shards_command.rb +88 -0
- data/lib/polyrun/cli/run_shards_plan_boot_phases.rb +91 -0
- data/lib/polyrun/cli/run_shards_plan_options.rb +45 -0
- data/lib/polyrun/cli/run_shards_planning.rb +124 -0
- data/lib/polyrun/cli/run_shards_run.rb +168 -0
- data/lib/polyrun/cli/start_bootstrap.rb +99 -0
- data/lib/polyrun/cli/timing_command.rb +31 -0
- data/lib/polyrun/cli.rb +184 -0
- data/lib/polyrun/config.rb +61 -0
- data/lib/polyrun/coverage/cobertura_zero_lines.rb +32 -0
- data/lib/polyrun/coverage/collector.rb +184 -0
- data/lib/polyrun/coverage/collector_finish.rb +95 -0
- data/lib/polyrun/coverage/filter.rb +22 -0
- data/lib/polyrun/coverage/formatter.rb +115 -0
- data/lib/polyrun/coverage/merge/formatters.rb +181 -0
- data/lib/polyrun/coverage/merge/formatters_html.rb +55 -0
- data/lib/polyrun/coverage/merge.rb +127 -0
- data/lib/polyrun/coverage/merge_fragment_meta.rb +47 -0
- data/lib/polyrun/coverage/merge_merge_two.rb +117 -0
- data/lib/polyrun/coverage/rails.rb +128 -0
- data/lib/polyrun/coverage/reporting.rb +41 -0
- data/lib/polyrun/coverage/result.rb +18 -0
- data/lib/polyrun/coverage/track_files.rb +141 -0
- data/lib/polyrun/data/cached_fixtures.rb +122 -0
- data/lib/polyrun/data/factory_counts.rb +35 -0
- data/lib/polyrun/data/factory_instrumentation.rb +50 -0
- data/lib/polyrun/data/fixtures.rb +68 -0
- data/lib/polyrun/data/parallel_provisioning.rb +93 -0
- data/lib/polyrun/data/snapshot.rb +84 -0
- data/lib/polyrun/database/clone_shards.rb +81 -0
- data/lib/polyrun/database/provision.rb +72 -0
- data/lib/polyrun/database/shard.rb +63 -0
- data/lib/polyrun/database/url_builder/connection/infer.rb +49 -0
- data/lib/polyrun/database/url_builder/connection/url_builders.rb +43 -0
- data/lib/polyrun/database/url_builder/connection.rb +191 -0
- data/lib/polyrun/database/url_builder/template_prepare.rb +21 -0
- data/lib/polyrun/database/url_builder.rb +160 -0
- data/lib/polyrun/debug.rb +81 -0
- data/lib/polyrun/env/ci.rb +65 -0
- data/lib/polyrun/log.rb +70 -0
- data/lib/polyrun/minitest.rb +17 -0
- data/lib/polyrun/partition/constraints.rb +69 -0
- data/lib/polyrun/partition/hrw.rb +33 -0
- data/lib/polyrun/partition/min_heap.rb +64 -0
- data/lib/polyrun/partition/paths.rb +28 -0
- data/lib/polyrun/partition/paths_build.rb +128 -0
- data/lib/polyrun/partition/plan.rb +189 -0
- data/lib/polyrun/partition/plan_lpt.rb +49 -0
- data/lib/polyrun/partition/plan_sharding.rb +48 -0
- data/lib/polyrun/partition/stable_shuffle.rb +18 -0
- data/lib/polyrun/prepare/artifacts.rb +40 -0
- data/lib/polyrun/prepare/assets.rb +57 -0
- data/lib/polyrun/queue/file_store.rb +199 -0
- data/lib/polyrun/queue/file_store_pending.rb +48 -0
- data/lib/polyrun/quick/assertions.rb +32 -0
- data/lib/polyrun/quick/errors.rb +6 -0
- data/lib/polyrun/quick/example_group.rb +66 -0
- data/lib/polyrun/quick/example_runner.rb +93 -0
- data/lib/polyrun/quick/matchers.rb +156 -0
- data/lib/polyrun/quick/reporter.rb +42 -0
- data/lib/polyrun/quick/runner.rb +180 -0
- data/lib/polyrun/quick.rb +1 -0
- data/lib/polyrun/railtie.rb +7 -0
- data/lib/polyrun/reporting/junit.rb +125 -0
- data/lib/polyrun/reporting/junit_emit.rb +58 -0
- data/lib/polyrun/reporting/rspec_junit.rb +39 -0
- data/lib/polyrun/rspec.rb +15 -0
- data/lib/polyrun/templates/POLYRUN.md +45 -0
- data/lib/polyrun/templates/ci_matrix.polyrun.yml +14 -0
- data/lib/polyrun/templates/minimal_gem.polyrun.yml +13 -0
- data/lib/polyrun/templates/rails_prepare.polyrun.yml +31 -0
- data/lib/polyrun/timing/merge.rb +35 -0
- data/lib/polyrun/timing/summary.rb +25 -0
- data/lib/polyrun/version.rb +3 -0
- data/lib/polyrun.rb +58 -0
- data/polyrun.gemspec +37 -0
- data/sig/polyrun/cli.rbs +6 -0
- data/sig/polyrun/config.rbs +20 -0
- data/sig/polyrun/debug.rbs +12 -0
- data/sig/polyrun/log.rbs +12 -0
- data/sig/polyrun/minitest.rbs +5 -0
- data/sig/polyrun/quick.rbs +19 -0
- data/sig/polyrun/rspec.rbs +5 -0
- data/sig/polyrun.rbs +11 -0
- metadata +288 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
require "pathname"
|
|
2
|
+
|
|
3
|
+
require_relative "assertions"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "example_group"
|
|
6
|
+
require_relative "example_runner"
|
|
7
|
+
require_relative "reporter"
|
|
8
|
+
|
|
9
|
+
module Polyrun
|
|
10
|
+
# Micro test runner: nested +describe+, +it+ / +test+, +before+ / +after+, +let+ / +let!+,
|
|
11
|
+
# +expect().to+ matchers, optional +Polyrun::Quick.capybara!+ when the +capybara+ gem is loaded.
|
|
12
|
+
#
|
|
13
|
+
# Run: +polyrun quick+ or +polyrun quick spec/foo.rb+
|
|
14
|
+
#
|
|
15
|
+
# Coverage: when +POLYRUN_COVERAGE=1+ or (+config/polyrun_coverage.yml+ and +POLYRUN_QUICK_COVERAGE=1+), starts
|
|
16
|
+
# {Polyrun::Coverage::Rails} before loading quick files so stdlib +Coverage+ records them.
|
|
17
|
+
module Quick
|
|
18
|
+
module DSL
|
|
19
|
+
def describe(name, &block)
|
|
20
|
+
Quick.describe(name, &block)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class Collector
|
|
25
|
+
attr_reader :groups
|
|
26
|
+
|
|
27
|
+
def initialize
|
|
28
|
+
@groups = []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def register(group)
|
|
32
|
+
@groups << group
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# rubocop:disable ThreadSafety/ClassAndModuleAttributes, ThreadSafety/ClassInstanceVariable
|
|
37
|
+
class << self
|
|
38
|
+
attr_accessor :collector
|
|
39
|
+
|
|
40
|
+
def capybara!
|
|
41
|
+
@capybara_enabled = true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def capybara?
|
|
45
|
+
@capybara_enabled == true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def reset_capybara_flag!
|
|
49
|
+
@capybara_enabled = false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def describe(name, &block)
|
|
53
|
+
group = ExampleGroup.new(name)
|
|
54
|
+
group.instance_eval(&block) if block
|
|
55
|
+
(collector || raise(Error, "Polyrun::Quick.describe used outside polyrun quick")).register(group)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
# rubocop:enable ThreadSafety/ClassAndModuleAttributes, ThreadSafety/ClassInstanceVariable
|
|
59
|
+
|
|
60
|
+
class Runner
|
|
61
|
+
def self.run(paths:, out: $stdout, err: $stderr, verbose: false)
|
|
62
|
+
new(out: out, err: err, verbose: verbose).run(paths)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def initialize(out: $stdout, err: $stderr, verbose: false)
|
|
66
|
+
@out = out
|
|
67
|
+
@err = err
|
|
68
|
+
@verbose = verbose
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def run(paths)
|
|
72
|
+
Quick.reset_capybara_flag!
|
|
73
|
+
|
|
74
|
+
files = expand_paths(paths)
|
|
75
|
+
if files.empty?
|
|
76
|
+
Polyrun::Log.warn "polyrun quick: no files (pass paths or add Quick files under spec/ or test/, e.g. spec/polyrun_quick/**/*.rb or spec/**/*.rb excluding *_spec.rb / *_test.rb)"
|
|
77
|
+
return 2
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
quick_start_coverage_if_configured!
|
|
81
|
+
|
|
82
|
+
collector = load_quick_files!(files)
|
|
83
|
+
return 1 unless collector
|
|
84
|
+
|
|
85
|
+
reporter = Reporter.new(@out, @err, @verbose)
|
|
86
|
+
run_examples!(collector, reporter)
|
|
87
|
+
reporter.summary
|
|
88
|
+
ensure
|
|
89
|
+
Quick.collector = nil
|
|
90
|
+
Quick.reset_capybara_flag!
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def load_quick_files!(files)
|
|
94
|
+
collector = Collector.new
|
|
95
|
+
Quick.collector = collector
|
|
96
|
+
|
|
97
|
+
files.each do |path|
|
|
98
|
+
code = File.read(path)
|
|
99
|
+
loader = Object.new
|
|
100
|
+
loader.extend(DSL)
|
|
101
|
+
loader.instance_eval(code, path, 1)
|
|
102
|
+
rescue SyntaxError, StandardError => e
|
|
103
|
+
Polyrun::Log.warn "polyrun quick: failed to load #{path}: #{e.class}: #{e.message}"
|
|
104
|
+
Quick.collector = nil
|
|
105
|
+
return nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
Quick.collector = nil
|
|
109
|
+
collector
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def run_examples!(collector, reporter)
|
|
113
|
+
collector.groups.each do |root|
|
|
114
|
+
root.each_example_with_ancestors do |chain, desc, block|
|
|
115
|
+
inner = chain.last
|
|
116
|
+
example_runner = ExampleRunner.new(reporter)
|
|
117
|
+
example_runner.run(
|
|
118
|
+
group_name: inner.full_name,
|
|
119
|
+
description: desc,
|
|
120
|
+
ancestor_chain: chain,
|
|
121
|
+
block: block
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def quick_start_coverage_if_configured!
|
|
128
|
+
return unless Polyrun::Coverage::Collector.coverage_requested_for_quick?(Dir.pwd)
|
|
129
|
+
return if Polyrun::Coverage::Collector.started?
|
|
130
|
+
|
|
131
|
+
require_relative "../coverage/rails"
|
|
132
|
+
Polyrun::Coverage::Rails.start!(
|
|
133
|
+
root: File.expand_path(Dir.pwd),
|
|
134
|
+
meta: {"command_name" => "polyrun quick"}
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def expand_paths(paths)
|
|
139
|
+
return default_globs if paths.nil? || paths.empty?
|
|
140
|
+
|
|
141
|
+
paths.flat_map do |p|
|
|
142
|
+
expanded = File.expand_path(p)
|
|
143
|
+
if File.directory?(expanded)
|
|
144
|
+
Dir.glob(File.join(expanded, "**", "*.rb")).sort
|
|
145
|
+
elsif /[*?\[]/.match?(p)
|
|
146
|
+
Dir.glob(File.expand_path(p)).sort
|
|
147
|
+
elsif File.file?(expanded)
|
|
148
|
+
[expanded]
|
|
149
|
+
else
|
|
150
|
+
[]
|
|
151
|
+
end
|
|
152
|
+
end.uniq
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def default_globs
|
|
156
|
+
base = File.expand_path(Dir.pwd)
|
|
157
|
+
globs = [
|
|
158
|
+
File.join(base, "spec", "polyrun_quick", "**", "*.rb"),
|
|
159
|
+
File.join(base, "test", "polyrun_quick", "**", "*.rb"),
|
|
160
|
+
File.join(base, "spec", "**", "*.rb"),
|
|
161
|
+
File.join(base, "test", "**", "*.rb")
|
|
162
|
+
]
|
|
163
|
+
globs.flat_map { |g| Dir.glob(g) }.uniq.reject { |p| default_quick_exclude?(p, base) }.sort
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Omit RSpec/Minitest files and common helpers so +polyrun quick+ with no args does not load normal suites.
|
|
167
|
+
def default_quick_exclude?(path, base)
|
|
168
|
+
rel = Pathname.new(path).relative_path_from(Pathname.new(base)).to_s
|
|
169
|
+
parts = rel.split(File::SEPARATOR)
|
|
170
|
+
bn = File.basename(path)
|
|
171
|
+
return true if bn.end_with?("_spec.rb", "_test.rb")
|
|
172
|
+
return true if %w[spec_helper.rb rails_helper.rb test_helper.rb].include?(bn)
|
|
173
|
+
return true if parts[0] == "spec" && %w[support fixtures factories].include?(parts[1])
|
|
174
|
+
return true if parts[0] == "test" && %w[support fixtures].include?(parts[1])
|
|
175
|
+
|
|
176
|
+
false
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
require_relative "quick/runner"
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
module Polyrun
|
|
2
|
+
# Optional Rails integration. Coverage must still be started from +spec_helper.rb+ (before the app loads) via
|
|
3
|
+
# {Polyrun::Coverage::Rails.start!}; this railtie only registers the gem with Rails.
|
|
4
|
+
class Railtie < ::Rails::Railtie
|
|
5
|
+
railtie_name :polyrun
|
|
6
|
+
end
|
|
7
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
require "cgi"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module Polyrun
|
|
5
|
+
module Reporting
|
|
6
|
+
# JUnit XML for CI (replaces rspec_junit_formatter) — stdlib only.
|
|
7
|
+
#
|
|
8
|
+
# Input JSON may be:
|
|
9
|
+
# - **RSpec JSON** — output of +rspec --format json --out rspec.json+ (+examples+ array).
|
|
10
|
+
# - **Polyrun canonical** — +{ "name" => "...", "testcases" => [ ... ] }+ (see +emit_xml+).
|
|
11
|
+
#
|
|
12
|
+
# Each testcase hash supports: +classname+, +name+, +time+, +status+ (+passed+, +failed+, +pending+/+skipped+),
|
|
13
|
+
# and optional +failure+ => +{ "message" => "...", "body" => "..." }+.
|
|
14
|
+
module Junit
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def write_from_json_file(json_path, output_path:)
|
|
18
|
+
data = JSON.parse(File.read(json_path))
|
|
19
|
+
write_from_hash(data, output_path: output_path)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Merge several RSpec JSON outputs (parallel shards) by concatenating +examples+.
|
|
23
|
+
def merge_rspec_json_files(paths, output_path:)
|
|
24
|
+
merged = {"examples" => []}
|
|
25
|
+
paths.each do |p|
|
|
26
|
+
data = JSON.parse(File.read(p))
|
|
27
|
+
merged["examples"].concat(data["examples"] || [])
|
|
28
|
+
end
|
|
29
|
+
merged["summary"] = {"summary_line" => "merged #{paths.size} RSpec JSON file(s)"}
|
|
30
|
+
write_from_hash(merged, output_path: output_path)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def write_from_hash(data, output_path:)
|
|
34
|
+
doc = parse_input(data)
|
|
35
|
+
xml = emit_xml(doc)
|
|
36
|
+
File.write(output_path, xml)
|
|
37
|
+
output_path
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def parse_input(data)
|
|
41
|
+
raise Polyrun::Error, "JUnit input must be a Hash" unless data.is_a?(Hash)
|
|
42
|
+
|
|
43
|
+
if data["examples"].is_a?(Array)
|
|
44
|
+
from_rspec_json(data)
|
|
45
|
+
elsif data["testcases"].is_a?(Array)
|
|
46
|
+
from_polyrun_hash(data)
|
|
47
|
+
else
|
|
48
|
+
raise Polyrun::Error,
|
|
49
|
+
'JUnit input: expected top-level "examples" (RSpec JSON) or "testcases" (Polyrun schema)'
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def from_rspec_json(data)
|
|
54
|
+
cases = []
|
|
55
|
+
data["examples"].each do |ex|
|
|
56
|
+
next unless ex.is_a?(Hash)
|
|
57
|
+
|
|
58
|
+
cases << junit_rspec_example_to_case(ex)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
name = (data.dig("summary", "summary_line") || data["name"] || "RSpec").to_s
|
|
62
|
+
from_polyrun_hash("name" => name, "hostname" => hostname, "testcases" => cases)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def junit_rspec_example_to_case(ex)
|
|
66
|
+
status = (ex["status"] || "unknown").to_s
|
|
67
|
+
file = ex["file_path"].to_s.sub(%r{\A\./}, "")
|
|
68
|
+
tc = {
|
|
69
|
+
"classname" => file.empty? ? "rspec" : file,
|
|
70
|
+
"name" => (ex["full_description"] || ex["description"] || ex["id"]).to_s,
|
|
71
|
+
"time" => (ex["run_time"] || ex["time"] || 0).to_f,
|
|
72
|
+
"status" => status
|
|
73
|
+
}
|
|
74
|
+
if status == "failed"
|
|
75
|
+
tc["failure"] = junit_rspec_failure_hash(ex)
|
|
76
|
+
end
|
|
77
|
+
tc
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def junit_rspec_failure_hash(ex)
|
|
81
|
+
e = ex["exception"]
|
|
82
|
+
if e.is_a?(Hash)
|
|
83
|
+
{
|
|
84
|
+
"message" => e["message"].to_s,
|
|
85
|
+
"body" => Array(e["backtrace"]).join("\n")
|
|
86
|
+
}
|
|
87
|
+
else
|
|
88
|
+
{"message" => "failed", "body" => ex.inspect}
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def from_polyrun_hash(data)
|
|
93
|
+
{
|
|
94
|
+
"name" => (data["name"] || data["testsuite_name"] || "tests").to_s,
|
|
95
|
+
"hostname" => (data["hostname"] || hostname).to_s,
|
|
96
|
+
"testcases" => Array(data["testcases"])
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def hostname
|
|
101
|
+
require "socket"
|
|
102
|
+
Socket.gethostname
|
|
103
|
+
rescue
|
|
104
|
+
"localhost"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def status_of(c)
|
|
108
|
+
s = (c["status"] || c[:status] || "passed").to_s
|
|
109
|
+
return "skipped" if s == "pending"
|
|
110
|
+
|
|
111
|
+
s
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def format_float(x)
|
|
115
|
+
format("%.6f", x.to_f)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def esc(s)
|
|
119
|
+
CGI.escapeHTML(s.to_s)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
require_relative "junit_emit"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module Polyrun
|
|
2
|
+
module Reporting
|
|
3
|
+
module Junit
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
# +doc+ is +{ "name", "hostname", "testcases" => [ ... ] }+
|
|
7
|
+
def emit_xml(doc)
|
|
8
|
+
cases = doc["testcases"] || []
|
|
9
|
+
lines = []
|
|
10
|
+
lines << junit_xml_header(doc, cases)
|
|
11
|
+
cases.each do |c|
|
|
12
|
+
lines << junit_xml_testcase_line(c)
|
|
13
|
+
end
|
|
14
|
+
lines << %(</testsuite>)
|
|
15
|
+
lines << %(</testsuites>)
|
|
16
|
+
lines.join("\n") + "\n"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def junit_xml_header(doc, cases)
|
|
20
|
+
total_time = cases.sum { |c| (c["time"] || c[:time] || 0).to_f }
|
|
21
|
+
failures = cases.count { |c| status_of(c) == "failed" }
|
|
22
|
+
errors = cases.count { |c| status_of(c) == "error" }
|
|
23
|
+
skipped = cases.count { |c| %w[pending skipped].include?(status_of(c)) }
|
|
24
|
+
tests = cases.size
|
|
25
|
+
lines = []
|
|
26
|
+
lines << %(<?xml version="1.0" encoding="UTF-8"?>)
|
|
27
|
+
lines << %(<testsuites name="#{esc(doc["name"])}">)
|
|
28
|
+
lines << %(<testsuite name="#{esc(doc["name"])}" tests="#{tests}" failures="#{failures}" errors="#{errors}" skipped="#{skipped}" time="#{format_float(total_time)}" hostname="#{esc(doc["hostname"])}">)
|
|
29
|
+
lines.join("\n")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def junit_xml_testcase_line(c)
|
|
33
|
+
c = c.transform_keys(&:to_s)
|
|
34
|
+
classname = c["classname"].to_s
|
|
35
|
+
name = c["name"].to_s
|
|
36
|
+
time = (c["time"] || 0).to_f
|
|
37
|
+
lines = []
|
|
38
|
+
lines << %(<testcase classname="#{esc(classname)}" name="#{esc(name)}" file="#{esc(c["file"] || classname)}" line="#{esc(c["line"] || "")}" time="#{format_float(time)}">)
|
|
39
|
+
case status_of(c)
|
|
40
|
+
when "failed", "error"
|
|
41
|
+
lines << junit_xml_failure_body(c)
|
|
42
|
+
when "pending", "skipped"
|
|
43
|
+
lines << %(<skipped/>)
|
|
44
|
+
end
|
|
45
|
+
lines << %(</testcase>)
|
|
46
|
+
lines.join("\n")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def junit_xml_failure_body(c)
|
|
50
|
+
f = c["failure"] || {}
|
|
51
|
+
fm = f["message"] || f[:message] || status_of(c)
|
|
52
|
+
fb = f["body"] || f[:body] || ""
|
|
53
|
+
tag = (status_of(c) == "error") ? "error" : "failure"
|
|
54
|
+
%(<#{tag} message="#{esc(fm)}">#{esc(fb)}</#{tag}>)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Polyrun
|
|
4
|
+
module Reporting
|
|
5
|
+
# CI: emit JUnit XML from RSpec's JSON formatter output (replaces +rspec_junit_formatter+).
|
|
6
|
+
#
|
|
7
|
+
# require "polyrun/reporting/rspec_junit"
|
|
8
|
+
# Polyrun::Reporting::RspecJunit.install!(only_if: -> { ENV["CI"] })
|
|
9
|
+
#
|
|
10
|
+
# Ensure +.rspec+ or CLI keeps a human formatter (e.g. documentation) in addition to JSON.
|
|
11
|
+
module RspecJunit
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def install!(json_path: "rspec.json", junit_path: "coverage/junit-coverage.xml", only_if: nil)
|
|
15
|
+
pred = only_if || -> { ENV["CI"] }
|
|
16
|
+
return unless pred.call
|
|
17
|
+
|
|
18
|
+
json_abs = File.expand_path(json_path)
|
|
19
|
+
FileUtils.mkdir_p(File.dirname(json_abs))
|
|
20
|
+
|
|
21
|
+
require "rspec/core"
|
|
22
|
+
require "rspec/core/formatters/json_formatter"
|
|
23
|
+
|
|
24
|
+
RSpec.configure do |config|
|
|
25
|
+
config.add_formatter RSpec::Core::Formatters::JsonFormatter, json_abs
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
at_exit do
|
|
29
|
+
next unless pred.call
|
|
30
|
+
|
|
31
|
+
FileUtils.mkdir_p(File.dirname(File.expand_path(junit_path)))
|
|
32
|
+
if File.file?(json_abs)
|
|
33
|
+
Junit.write_from_json_file(json_abs, output_path: junit_path)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
require_relative "../polyrun"
|
|
2
|
+
|
|
3
|
+
module Polyrun
|
|
4
|
+
# Optional RSpec wiring (require +polyrun/rspec+ explicitly).
|
|
5
|
+
module RSpec
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# Registers +before(:suite)+ to run {Data::ParallelProvisioning.run_suite_hooks!}.
|
|
9
|
+
def install_parallel_provisioning!(rspec_config)
|
|
10
|
+
rspec_config.before(:suite) do
|
|
11
|
+
Polyrun::Data::ParallelProvisioning.run_suite_hooks!
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Polyrun in this project
|
|
2
|
+
|
|
3
|
+
This repo uses [Polyrun](https://github.com/amkisko/polyrun.rb) for parallel RSpec, merged coverage, and optional CI report formats.
|
|
4
|
+
|
|
5
|
+
## Setup profile
|
|
6
|
+
|
|
7
|
+
Fill in and keep updated: dimensions are summarized in Polyrun’s [SETUP_PROFILE checklist](https://github.com/amkisko/polyrun.rb/blob/main/docs/SETUP_PROFILE.md) (project type, DB, prepare, CI model).
|
|
8
|
+
|
|
9
|
+
## Canonical commands
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bundle exec polyrun -c polyrun.yml build-paths
|
|
13
|
+
bundle exec polyrun -c polyrun.yml parallel-rspec --workers 5
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Adjust `--workers` or use `bin/rspec_parallel` if your repo provides a wrapper.
|
|
17
|
+
|
|
18
|
+
## CI model (choose one and match your workflows)
|
|
19
|
+
|
|
20
|
+
### Model A — single CI job, N worker processes on one runner
|
|
21
|
+
|
|
22
|
+
- Run `polyrun -c polyrun.yml parallel-rspec --workers N` (or `polyrun start`).
|
|
23
|
+
- Merge coverage in the same job (or a follow-up step) from `coverage/polyrun-fragment-*.json`.
|
|
24
|
+
|
|
25
|
+
### Model B — matrix: one shard per job
|
|
26
|
+
|
|
27
|
+
- Matrix sets `POLYRUN_SHARD_INDEX` and `POLYRUN_SHARD_TOTAL` explicitly (many runners do not set `CI_NODE_*` by default).
|
|
28
|
+
- Each job runs `polyrun build-paths`, `polyrun plan`, then `bundle exec rspec` for that shard only (see `bin/polyrun-rspec` or `bin/rspec_ci_shard` patterns).
|
|
29
|
+
- Upload `coverage/polyrun-fragment-<shard>.json` per job; a `merge-coverage` job downloads all fragments and merges.
|
|
30
|
+
|
|
31
|
+
Do not combine Model A and Model B in one workflow without a documented reason (nested parallelism and duplicate merges).
|
|
32
|
+
|
|
33
|
+
## Configuration contract
|
|
34
|
+
|
|
35
|
+
- `polyrun.yml` — partition, optional `prepare`, optional `databases`. This file is the source of truth for shard indices and paths.
|
|
36
|
+
- Adapters — thin scripts (`bin/rspec_parallel`, `bin/polyrun-rspec`, `database.yml` ERB, prepare scripts) must match `polyrun.yml`.
|
|
37
|
+
|
|
38
|
+
## Coverage
|
|
39
|
+
|
|
40
|
+
- `spec/spec_helper.rb`: `require "polyrun"` and collector or Rails helper as appropriate.
|
|
41
|
+
- Fragments: `coverage/polyrun-fragment-<shard>.json` → `polyrun merge-coverage` → `polyrun report-coverage` / `report-junit` for CI.
|
|
42
|
+
|
|
43
|
+
## Further reading
|
|
44
|
+
|
|
45
|
+
- Polyrun README: partition, prepare, databases, merge-coverage, `Polyrun::Env::Ci`
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Polyrun — partition contract for CI matrix (one job per POLYRUN_SHARD_INDEX).
|
|
2
|
+
# Each matrix row: set POLYRUN_SHARD_INDEX and POLYRUN_SHARD_TOTAL; run build-paths, plan, rspec.
|
|
3
|
+
# A separate CI job downloads coverage/polyrun-fragment-*.json and runs merge-coverage.
|
|
4
|
+
# Do not use parallel-rspec with multiple workers inside the same matrix row unless you intend nested parallelism.
|
|
5
|
+
# See: docs/SETUP_PROFILE.md
|
|
6
|
+
|
|
7
|
+
partition:
|
|
8
|
+
paths_file: spec/spec_paths.txt
|
|
9
|
+
shard_total: 5
|
|
10
|
+
shard_index: 0
|
|
11
|
+
strategy: round_robin
|
|
12
|
+
paths_build:
|
|
13
|
+
all_glob: spec/**/*_spec.rb
|
|
14
|
+
stages: []
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Polyrun — minimal gem/library layout (no Rails prepare, no databases: block).
|
|
2
|
+
# Fill in paths_build.stages if you need regex or substring ordering for slow specs.
|
|
3
|
+
# Next: spec_helper — require "polyrun" + Polyrun::Coverage::Collector (or your collector config)
|
|
4
|
+
# See: docs/SETUP_PROFILE.md
|
|
5
|
+
|
|
6
|
+
partition:
|
|
7
|
+
paths_file: spec/spec_paths.txt
|
|
8
|
+
shard_total: 5
|
|
9
|
+
shard_index: 0
|
|
10
|
+
strategy: round_robin
|
|
11
|
+
paths_build:
|
|
12
|
+
all_glob: spec/**/*_spec.rb
|
|
13
|
+
stages: []
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Polyrun — Rails app with one-shot prepare before parallel workers.
|
|
2
|
+
# Adjust prepare.command to your repo (assets, Playwright, webapp build, etc.).
|
|
3
|
+
# Gate per-worker duplicate work in application code when POLYRUN_SHARD_TOTAL > 1.
|
|
4
|
+
# See: docs/SETUP_PROFILE.md
|
|
5
|
+
|
|
6
|
+
partition:
|
|
7
|
+
paths_file: spec/spec_paths.txt
|
|
8
|
+
shard_total: 5
|
|
9
|
+
shard_index: 0
|
|
10
|
+
strategy: round_robin
|
|
11
|
+
paths_build:
|
|
12
|
+
all_glob: spec/**/*_spec.rb
|
|
13
|
+
stages: []
|
|
14
|
+
|
|
15
|
+
prepare:
|
|
16
|
+
recipe: shell
|
|
17
|
+
rails_root: .
|
|
18
|
+
command: bundle exec ruby bin/test_prepare.rb
|
|
19
|
+
|
|
20
|
+
# Uncomment and edit when using Postgres template + per-shard DB names from polyrun env:
|
|
21
|
+
# databases:
|
|
22
|
+
# template_db: my_app_template
|
|
23
|
+
# shard_db_pattern: "my_app_test_%{shard}"
|
|
24
|
+
# postgresql:
|
|
25
|
+
# host: localhost
|
|
26
|
+
# username: postgres
|
|
27
|
+
# connections:
|
|
28
|
+
# - name: analytics
|
|
29
|
+
# template_db: my_app_analytics_template # second migrate + CREATE … TEMPLATE for parallel tests
|
|
30
|
+
# shard_db_pattern: "my_app_analytics_test_%{shard}"
|
|
31
|
+
# env_key: ANALYTICS_DATABASE_URL # must match database.yml / Rails multi-db
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
require_relative "../debug"
|
|
4
|
+
|
|
5
|
+
module Polyrun
|
|
6
|
+
module Timing
|
|
7
|
+
# Merges per-shard timing JSON files (spec2 §2.4): path => wall seconds (float).
|
|
8
|
+
# Disjoint suites: values merged by taking the maximum per path when duplicates appear.
|
|
9
|
+
module Merge
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def merge_files(paths)
|
|
13
|
+
merged = {}
|
|
14
|
+
paths.each do |p|
|
|
15
|
+
data = JSON.parse(File.read(p))
|
|
16
|
+
next unless data.is_a?(Hash)
|
|
17
|
+
|
|
18
|
+
data.each do |file, sec|
|
|
19
|
+
f = file.to_s
|
|
20
|
+
t = sec.to_f
|
|
21
|
+
merged[f] = merged.key?(f) ? [merged[f], t].max : t
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
merged
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def merge_and_write(paths, output_path)
|
|
28
|
+
Polyrun::Debug.log_kv(merge_timing: "merge_and_write", input_count: paths.size, output_path: output_path)
|
|
29
|
+
merged = Polyrun::Debug.time("Timing::Merge.merge_files") { merge_files(paths) }
|
|
30
|
+
Polyrun::Debug.time("Timing::Merge.write JSON") { File.write(output_path, JSON.pretty_generate(merged)) }
|
|
31
|
+
merged
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Polyrun
|
|
2
|
+
module Timing
|
|
3
|
+
# Human-readable slow-file list from merged timing JSON (per-file cost).
|
|
4
|
+
module Summary
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
# +merged+ is path (String) => seconds (Float), as produced by +Timing::Merge.merge_files+.
|
|
8
|
+
def format_slow_files(merged, top: 30, title: "Polyrun slowest files (by wall time, seconds)")
|
|
9
|
+
return "#{title}\n (no data)\n" if merged.nil? || merged.empty?
|
|
10
|
+
|
|
11
|
+
pairs = merged.sort_by { |_, sec| -sec.to_f }.first(Integer(top))
|
|
12
|
+
lines = [title, ""]
|
|
13
|
+
pairs.each_with_index do |(path, sec), i|
|
|
14
|
+
lines << format(" %2d. %s %.4f", i + 1, path, sec.to_f)
|
|
15
|
+
end
|
|
16
|
+
lines.join("\n") + "\n"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def write_file(merged, path, **kwargs)
|
|
20
|
+
File.write(path, format_slow_files(merged, **kwargs))
|
|
21
|
+
path
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/polyrun.rb
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
require_relative "polyrun/version"
|
|
2
|
+
require_relative "polyrun/log"
|
|
3
|
+
require_relative "polyrun/debug"
|
|
4
|
+
require_relative "polyrun/config"
|
|
5
|
+
require_relative "polyrun/coverage/merge"
|
|
6
|
+
require_relative "polyrun/coverage/filter"
|
|
7
|
+
require_relative "polyrun/coverage/result"
|
|
8
|
+
require_relative "polyrun/coverage/formatter"
|
|
9
|
+
require_relative "polyrun/coverage/collector"
|
|
10
|
+
require_relative "polyrun/coverage/reporting"
|
|
11
|
+
require_relative "polyrun/coverage/rails"
|
|
12
|
+
require_relative "polyrun/partition/plan"
|
|
13
|
+
require_relative "polyrun/partition/paths"
|
|
14
|
+
require_relative "polyrun/partition/paths_build"
|
|
15
|
+
require_relative "polyrun/queue/file_store"
|
|
16
|
+
require_relative "polyrun/data/fixtures"
|
|
17
|
+
require_relative "polyrun/data/cached_fixtures"
|
|
18
|
+
require_relative "polyrun/data/parallel_provisioning"
|
|
19
|
+
require_relative "polyrun/data/factory_instrumentation"
|
|
20
|
+
require_relative "polyrun/data/snapshot"
|
|
21
|
+
require_relative "polyrun/data/factory_counts"
|
|
22
|
+
require_relative "polyrun/prepare/assets"
|
|
23
|
+
require_relative "polyrun/prepare/artifacts"
|
|
24
|
+
require_relative "polyrun/database/shard"
|
|
25
|
+
require_relative "polyrun/database/url_builder"
|
|
26
|
+
require_relative "polyrun/database/provision"
|
|
27
|
+
require_relative "polyrun/database/clone_shards"
|
|
28
|
+
require_relative "polyrun/env/ci"
|
|
29
|
+
require_relative "polyrun/timing/merge"
|
|
30
|
+
require_relative "polyrun/timing/summary"
|
|
31
|
+
require_relative "polyrun/reporting/junit"
|
|
32
|
+
# RSpec JSON formatter + JUnit is opt-in: require "polyrun/reporting/rspec_junit" (loads RSpec only inside RspecJunit.install!).
|
|
33
|
+
require_relative "polyrun/cli"
|
|
34
|
+
|
|
35
|
+
if defined?(Rails::Railtie)
|
|
36
|
+
require_relative "polyrun/railtie"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
module Polyrun
|
|
40
|
+
class Error < StandardError; end
|
|
41
|
+
|
|
42
|
+
# Delegate to {Polyrun::Log} for swappable stderr/stdout (CLI and library messages).
|
|
43
|
+
def self.stderr=(io)
|
|
44
|
+
Log.stderr = io
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.stderr
|
|
48
|
+
Log.stderr
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.stdout=(io)
|
|
52
|
+
Log.stdout = io
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.stdout
|
|
56
|
+
Log.stdout
|
|
57
|
+
end
|
|
58
|
+
end
|