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.
Files changed (105) hide show
  1. checksums.yaml +7 -0
  2. data/CODE_OF_CONDUCT.md +31 -0
  3. data/CONTRIBUTING.md +84 -0
  4. data/LICENSE +21 -0
  5. data/README.md +140 -0
  6. data/SECURITY.md +27 -0
  7. data/bin/polyrun +6 -0
  8. data/docs/SETUP_PROFILE.md +106 -0
  9. data/lib/polyrun/cli/coverage_commands.rb +150 -0
  10. data/lib/polyrun/cli/coverage_merge_io.rb +124 -0
  11. data/lib/polyrun/cli/database_commands.rb +149 -0
  12. data/lib/polyrun/cli/env_commands.rb +43 -0
  13. data/lib/polyrun/cli/helpers.rb +113 -0
  14. data/lib/polyrun/cli/init_command.rb +99 -0
  15. data/lib/polyrun/cli/plan_command.rb +134 -0
  16. data/lib/polyrun/cli/prepare_command.rb +71 -0
  17. data/lib/polyrun/cli/prepare_recipe.rb +77 -0
  18. data/lib/polyrun/cli/queue_command.rb +101 -0
  19. data/lib/polyrun/cli/quick_command.rb +13 -0
  20. data/lib/polyrun/cli/report_commands.rb +94 -0
  21. data/lib/polyrun/cli/run_shards_command.rb +88 -0
  22. data/lib/polyrun/cli/run_shards_plan_boot_phases.rb +91 -0
  23. data/lib/polyrun/cli/run_shards_plan_options.rb +45 -0
  24. data/lib/polyrun/cli/run_shards_planning.rb +124 -0
  25. data/lib/polyrun/cli/run_shards_run.rb +168 -0
  26. data/lib/polyrun/cli/start_bootstrap.rb +99 -0
  27. data/lib/polyrun/cli/timing_command.rb +31 -0
  28. data/lib/polyrun/cli.rb +184 -0
  29. data/lib/polyrun/config.rb +61 -0
  30. data/lib/polyrun/coverage/cobertura_zero_lines.rb +32 -0
  31. data/lib/polyrun/coverage/collector.rb +184 -0
  32. data/lib/polyrun/coverage/collector_finish.rb +95 -0
  33. data/lib/polyrun/coverage/filter.rb +22 -0
  34. data/lib/polyrun/coverage/formatter.rb +115 -0
  35. data/lib/polyrun/coverage/merge/formatters.rb +181 -0
  36. data/lib/polyrun/coverage/merge/formatters_html.rb +55 -0
  37. data/lib/polyrun/coverage/merge.rb +127 -0
  38. data/lib/polyrun/coverage/merge_fragment_meta.rb +47 -0
  39. data/lib/polyrun/coverage/merge_merge_two.rb +117 -0
  40. data/lib/polyrun/coverage/rails.rb +128 -0
  41. data/lib/polyrun/coverage/reporting.rb +41 -0
  42. data/lib/polyrun/coverage/result.rb +18 -0
  43. data/lib/polyrun/coverage/track_files.rb +141 -0
  44. data/lib/polyrun/data/cached_fixtures.rb +122 -0
  45. data/lib/polyrun/data/factory_counts.rb +35 -0
  46. data/lib/polyrun/data/factory_instrumentation.rb +50 -0
  47. data/lib/polyrun/data/fixtures.rb +68 -0
  48. data/lib/polyrun/data/parallel_provisioning.rb +93 -0
  49. data/lib/polyrun/data/snapshot.rb +84 -0
  50. data/lib/polyrun/database/clone_shards.rb +81 -0
  51. data/lib/polyrun/database/provision.rb +72 -0
  52. data/lib/polyrun/database/shard.rb +63 -0
  53. data/lib/polyrun/database/url_builder/connection/infer.rb +49 -0
  54. data/lib/polyrun/database/url_builder/connection/url_builders.rb +43 -0
  55. data/lib/polyrun/database/url_builder/connection.rb +191 -0
  56. data/lib/polyrun/database/url_builder/template_prepare.rb +21 -0
  57. data/lib/polyrun/database/url_builder.rb +160 -0
  58. data/lib/polyrun/debug.rb +81 -0
  59. data/lib/polyrun/env/ci.rb +65 -0
  60. data/lib/polyrun/log.rb +70 -0
  61. data/lib/polyrun/minitest.rb +17 -0
  62. data/lib/polyrun/partition/constraints.rb +69 -0
  63. data/lib/polyrun/partition/hrw.rb +33 -0
  64. data/lib/polyrun/partition/min_heap.rb +64 -0
  65. data/lib/polyrun/partition/paths.rb +28 -0
  66. data/lib/polyrun/partition/paths_build.rb +128 -0
  67. data/lib/polyrun/partition/plan.rb +189 -0
  68. data/lib/polyrun/partition/plan_lpt.rb +49 -0
  69. data/lib/polyrun/partition/plan_sharding.rb +48 -0
  70. data/lib/polyrun/partition/stable_shuffle.rb +18 -0
  71. data/lib/polyrun/prepare/artifacts.rb +40 -0
  72. data/lib/polyrun/prepare/assets.rb +57 -0
  73. data/lib/polyrun/queue/file_store.rb +199 -0
  74. data/lib/polyrun/queue/file_store_pending.rb +48 -0
  75. data/lib/polyrun/quick/assertions.rb +32 -0
  76. data/lib/polyrun/quick/errors.rb +6 -0
  77. data/lib/polyrun/quick/example_group.rb +66 -0
  78. data/lib/polyrun/quick/example_runner.rb +93 -0
  79. data/lib/polyrun/quick/matchers.rb +156 -0
  80. data/lib/polyrun/quick/reporter.rb +42 -0
  81. data/lib/polyrun/quick/runner.rb +180 -0
  82. data/lib/polyrun/quick.rb +1 -0
  83. data/lib/polyrun/railtie.rb +7 -0
  84. data/lib/polyrun/reporting/junit.rb +125 -0
  85. data/lib/polyrun/reporting/junit_emit.rb +58 -0
  86. data/lib/polyrun/reporting/rspec_junit.rb +39 -0
  87. data/lib/polyrun/rspec.rb +15 -0
  88. data/lib/polyrun/templates/POLYRUN.md +45 -0
  89. data/lib/polyrun/templates/ci_matrix.polyrun.yml +14 -0
  90. data/lib/polyrun/templates/minimal_gem.polyrun.yml +13 -0
  91. data/lib/polyrun/templates/rails_prepare.polyrun.yml +31 -0
  92. data/lib/polyrun/timing/merge.rb +35 -0
  93. data/lib/polyrun/timing/summary.rb +25 -0
  94. data/lib/polyrun/version.rb +3 -0
  95. data/lib/polyrun.rb +58 -0
  96. data/polyrun.gemspec +37 -0
  97. data/sig/polyrun/cli.rbs +6 -0
  98. data/sig/polyrun/config.rbs +20 -0
  99. data/sig/polyrun/debug.rbs +12 -0
  100. data/sig/polyrun/log.rbs +12 -0
  101. data/sig/polyrun/minitest.rbs +5 -0
  102. data/sig/polyrun/quick.rbs +19 -0
  103. data/sig/polyrun/rspec.rbs +5 -0
  104. data/sig/polyrun.rbs +11 -0
  105. 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
@@ -0,0 +1,3 @@
1
+ module Polyrun
2
+ VERSION = "1.0.0"
3
+ 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