ci-helper 0.10.2 → 0.11.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: adbebe67fd86fd92ee630a21009a3c42fe6d4be32d9ebb773539cc92964bab77
4
- data.tar.gz: ce5e94594b8392d5f0d7b1de9f4fd5c548d5598c0097192a98eb6718f8478493
3
+ metadata.gz: acb764a2dd918b19d7aa39e23f3f2cf3f38dbcf1e37d67029487b29e3fe609db
4
+ data.tar.gz: b0a66151d11632ad06a22367110f9520dcc5f4c13ab51dcb95b03309ce1b2bb1
5
5
  SHA512:
6
- metadata.gz: b98ad7e1d9b1cfa5a740c01ad854c697b65d41e070dbb132b5da44af2a9ada24140edbf80dca852fd9e0e3132a9ec911c8902dcf319d3120a16e2995eda810b1
7
- data.tar.gz: 7eb7b22ffacf1b8f0951c8b116edfa67ccd2f55c244626e6d2484332c3dc4de891a7eff91bf0008cf3893be3f9474305076952b1c5ad6c5ce3b70b4b95c0a5fd
6
+ metadata.gz: cc0f16f1a115ebb6feef700a5f12f9f02c02eb036001e3305770ac58a075240d972d4c58475f5722b00448a5b2756fe5b79887f3c7e75c9a4aba28ff758322f3
7
+ data.tar.gz: 5dfe278d4742215228137a499f725a83446b54e0bb645c088a99baa8f4d1f509a93aba29d50256b68ab78849a514ddf465fe37a9ce38dd09e92819b1183f524a
data/.rubocop.yml CHANGED
@@ -3,8 +3,13 @@ inherit_gem:
3
3
 
4
4
  AllCops:
5
5
  DisplayCopNames: true
6
+ NewCops: enable
6
7
  TargetRubyVersion: 3.2
7
8
 
9
+ Style/HashSyntax:
10
+ EnforcedStyle: ruby19
11
+ EnforcedShorthandSyntax: always
12
+
8
13
  Naming/MethodParameterName:
9
14
  AllowedNames: ["x", "y", "z"]
10
15
 
data/Gemfile CHANGED
@@ -7,7 +7,6 @@ gemspec
7
7
  gem "benchmark"
8
8
  gem "bundler"
9
9
  gem "bundler-audit"
10
- gem "ostruct"
11
10
  gem "pry"
12
11
  gem "rake"
13
12
  gem "rspec"
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- ci-helper (0.10.2)
4
+ ci-helper (0.11.0)
5
5
  colorize (~> 1.1)
6
6
  dry-inflector (~> 1.0)
7
7
  umbrellio-sequel-plugins (~> 0.14)
@@ -48,7 +48,6 @@ GEM
48
48
  minitest (6.0.5)
49
49
  drb (~> 2.0)
50
50
  prism (~> 1.5)
51
- ostruct (0.6.3)
52
51
  parallel (1.28.0)
53
52
  parser (3.3.11.1)
54
53
  ast (~> 2.4.1)
@@ -153,7 +152,6 @@ DEPENDENCIES
153
152
  bundler
154
153
  bundler-audit
155
154
  ci-helper!
156
- ostruct
157
155
  pry
158
156
  rake
159
157
  rspec
data/README.md CHANGED
@@ -55,7 +55,7 @@ List of available commands:
55
55
  * **RubocopLint** — executes rubocop linter. Does not accept flags.
56
56
  * **RunSpecs** — executes `rspec` in project root.
57
57
  Accepted flags: `--node-index`, `--node-total`, `--with-database`, `--with-clickhouse`,
58
- `--split-resultset`.
58
+ `--split-resultset`, `--timings-file`, `--timings-out`, `--timings-split-threshold`.
59
59
  * `--node-index` — if you run specs in parallel in CI, then you might use this flag.
60
60
  * `--node-total` — if you run specs in parallel in CI, then you might use this flag.
61
61
  * `--with-database` — if you want to prepare database before executing specs,
@@ -65,6 +65,18 @@ List of available commands:
65
65
  * `--split-resultset` — if you run specs in parallel in CI,
66
66
  then you might use this flag to `true`. If this flag set to true,
67
67
  final `.resultset.json` will be renamed to `resultset.#{node_index}.json`
68
+ * `--timings-file` — path to a JSON file with previously recorded example timings
69
+ (`{"./spec/a_spec.rb[1:1]": 0.42, ...}`). When the file is present and non-empty,
70
+ parallel nodes pack whole spec files by total recorded time (greedy LPT) instead of
71
+ splitting examples evenly by count; files heavier than the split threshold are packed
72
+ example by example, and examples missing from the file get the median recorded time.
73
+ If the file is missing or unreadable, the count-based split is used.
74
+ `spec/heavy_specs.yml` is ignored in this mode.
75
+ * `--timings-out` — path to write this run's per-example timings as a flat JSON map
76
+ (the `--timings-file` format). Merge the files from all nodes to build the timings
77
+ input for future runs.
78
+ * `--timings-split-threshold` — total seconds above which a single spec file is split
79
+ example by example between nodes (default 30).
68
80
  * **CheckSpecSuffixes** — checks specs in the spec subdirectories for `_spec` suffix,
69
81
  by default ignores directories `support`, `factories` and files with `context` suffix.
70
82
  Accepted flags: `--extra-paths`, `--ignored-paths`.
@@ -4,7 +4,7 @@ module CIHelper
4
4
  module Commands
5
5
  class CheckSpecSuffixes < BaseCommand
6
6
  def call
7
- paths = target_paths.reject { |path| path.end_with?("_spec.rb") }
7
+ paths = target_paths.grep_v(/_spec\.rb\z/)
8
8
  fail!("Detected specs without _spec suffix: #{paths.join(" ")}") if paths.any?
9
9
  0
10
10
  end
@@ -16,16 +16,13 @@ module CIHelper
16
16
  end
17
17
 
18
18
  def spec_paths
19
- base_paths.select do |path|
20
- next if path.start_with?("spec/support")
21
- next if path.start_with?("spec/factories")
22
- next if path.end_with?("context.rb")
23
- true
24
- end
19
+ base_paths
20
+ .grep_v(%r{\A(?:spec/support|spec/factories)})
21
+ .grep_v(/context\.rb\z/)
25
22
  end
26
23
 
27
24
  def base_paths
28
- Dir["spec/*/**/*.rb"]
25
+ path.glob("spec/*/**/*.rb").map { |file| file.relative_path_from(path).to_s }
29
26
  end
30
27
 
31
28
  def extra_paths
@@ -1,11 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
3
4
  require "json"
4
5
  require "tmpdir"
5
6
 
6
7
  module CIHelper
7
8
  module Commands
8
9
  class RunSpecs < BaseCommand
10
+ DEFAULT_SPLIT_THRESHOLD = 30.0
11
+ TIMINGS_RECORDER_PATH = File.expand_path("../tools/spec_timings_recorder.rb", __dir__)
12
+
9
13
  def call
10
14
  return if all_spec_files.empty?
11
15
 
@@ -15,7 +19,7 @@ module CIHelper
15
19
  specs_to_run = job_count == 1 ? job_files : job_examples
16
20
  return 0 if specs_to_run.empty?
17
21
 
18
- execute("bundle exec rspec #{Shellwords.join(specs_to_run)}")
22
+ run_rspec!(specs_to_run)
19
23
  return 0 unless split_resultset?
20
24
 
21
25
  execute("mv coverage/.resultset.json coverage/resultset.#{job_index}.json")
@@ -33,19 +37,93 @@ module CIHelper
33
37
 
34
38
  def job_files
35
39
  heavy_files, std_files = all_spec_files.partition { |f| heavy?(relative(f)) }
36
- sorted_std = std_files.map { |f| [f.size, relative(f)] }.sort.map(&:last)
40
+ sorted_std = std_files.sort_by { |f| [f.size, relative(f)] }.map { |f| relative(f) }
37
41
  distribute(sorted_std + heavy_files)
38
42
  end
39
43
 
40
44
  def job_examples
45
+ timings.empty? ? counted_job_examples : timed_job_examples
46
+ end
47
+
48
+ def counted_job_examples
41
49
  heavy, std = example_ids.partition do |id|
42
50
  heavy?(id.split("[", 2).first.delete_prefix("./"))
43
51
  end
44
52
  distribute(std.sort + heavy.sort)
45
53
  end
46
54
 
47
- def example_ids
55
+ # Greedy LPT packing: files are weighted by the sum of their examples'
56
+ # timings (unknown examples get the median), files heavier than the split
57
+ # threshold are packed example by example. Deterministic on identical
58
+ # input, so every parallel node computes the same partition.
59
+ def timed_job_examples
60
+ loads = Array.new(job_count, 0.0)
61
+ own_items = []
62
+
63
+ weighted_items.sort_by { |item, weight| [-weight, item] }.each do |item, weight|
64
+ node = loads.each_with_index.min_by { |load, index| [load, index] }.last
65
+ loads[node] += weight
66
+ own_items << item if node == job_index - 1
67
+ end
68
+
69
+ own_items.sort
70
+ end
71
+
72
+ def weighted_items
73
+ example_ids.group_by { |id| id.split("[", 2).first }.flat_map do |file, ids|
74
+ weights = ids.map { |id| [id, timings.fetch(id, default_example_weight)] }
75
+ total = weights.sum(&:last)
76
+ total > split_threshold ? weights : [[file, total]]
77
+ end
78
+ end
79
+
80
+ def timings
81
+ @timings ||=
82
+ begin
83
+ parsed = options[:timings_file] ? JSON.parse(File.read(options[:timings_file])) : {}
84
+ parsed.is_a?(Hash) ? parsed.select { |_id, time| time.is_a?(Numeric) } : {}
85
+ rescue SystemCallError, JSON::ParserError
86
+ {}
87
+ end
88
+ end
89
+
90
+ def default_example_weight
91
+ @default_example_weight ||= timings.values.sort[timings.size / 2]
92
+ end
93
+
94
+ def split_threshold
95
+ @split_threshold ||= Float(options[:timings_split_threshold] || DEFAULT_SPLIT_THRESHOLD)
96
+ end
97
+
98
+ def timings_out
99
+ options[:timings_out]
100
+ end
101
+
102
+ # Runs rspec, additionally recording per-example timings when --timings-out is set.
103
+ # The recorder is wired in through --require rather than --format: --format would replace
104
+ # the formatters the suite configures via .rspec, and even an added formatter would occupy
105
+ # the formatter list and suppress rspec's default progress output. It runs from an
106
+ # after(:suite) hook instead, so it writes even when examples fail and touches no formatter.
107
+ def run_rspec!(specs_to_run)
108
+ return execute("bundle exec rspec #{Shellwords.join(specs_to_run)}") unless timings_out
109
+
110
+ FileUtils.mkdir_p(File.dirname(timings_out))
48
111
  Dir.mktmpdir do |dir|
112
+ setup = File.join(dir, "ci_helper_timings_setup.rb")
113
+ File.write(setup, timings_recorder_setup)
114
+ execute("bundle exec rspec #{Shellwords.join(["--require", setup, *specs_to_run])}")
115
+ end
116
+ end
117
+
118
+ def timings_recorder_setup
119
+ <<~RUBY
120
+ require #{TIMINGS_RECORDER_PATH.inspect}
121
+ CIHelper::Tools::SpecTimingsRecorder.install(#{timings_out.inspect})
122
+ RUBY
123
+ end
124
+
125
+ def example_ids
126
+ @example_ids ||= Dir.mktmpdir do |dir|
49
127
  output_file = File.join(dir, "rspec_examples.json")
50
128
  begin
51
129
  execute(
@@ -60,9 +138,10 @@ module CIHelper
60
138
  end
61
139
 
62
140
  def distribute(items)
63
- items.reverse.select.with_index do |_item, index|
64
- (index % job_count) == (job_index - 1)
65
- end
141
+ reversed = items.reverse
142
+ selected_indices =
143
+ (job_index.pred..).step(job_count).take_while { |index| index < reversed.length }
144
+ selected_indices.map { |index| reversed[index] }
66
145
  end
67
146
 
68
147
  def heavy?(relative_path)
@@ -65,7 +65,7 @@ module CIHelper
65
65
  end
66
66
 
67
67
  def fail!(message, output: nil)
68
- raise Error.new(message, output: output)
68
+ raise Error.new(message, output:)
69
69
  end
70
70
 
71
71
  def boolean_option?(key)
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module CIHelper
6
+ module Tools
7
+ # Records each example's run time to a flat JSON map ({ "./spec/a_spec.rb[1:1]" => 0.42 }),
8
+ # the format RunSpecs consumes via --timings-file. RunSpecs wires it in through rspec's
9
+ # --require. It collects timings from an after(:suite) hook, so it writes even when examples
10
+ # fail; and, unlike a formatter, it never joins the formatter list, so rspec still installs
11
+ # the suite's own formatters — including its default progress formatter when none is set.
12
+ module SpecTimingsRecorder
13
+ def self.install(path)
14
+ RSpec.configure do |config|
15
+ config.after(:suite) { File.write(path, JSON.dump(SpecTimingsRecorder.collect)) }
16
+ end
17
+ end
18
+
19
+ # A node runs only the example ids it was given, but rspec loads the whole file, so
20
+ # all_examples also holds examples filtered out on this node; those never ran and have a
21
+ # nil run_time. Skip them, otherwise they land as 0.0 and can overwrite real timings on merge.
22
+ def self.collect
23
+ RSpec.world.all_examples.each_with_object({}) do |example, timings|
24
+ run_time = example.execution_result.run_time
25
+ timings[example.id] = run_time.to_f if run_time
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CIHelper
4
- VERSION = "0.10.2"
4
+ VERSION = "0.11.0"
5
5
  end
@@ -103,7 +103,7 @@ class SequelManagement
103
103
  if error.message.include?("does not exist in the filesystem")
104
104
  logger.info error.message
105
105
  else
106
- raise error
106
+ raise
107
107
  end
108
108
  end
109
109
  end
@@ -115,7 +115,7 @@ class SequelManagement
115
115
  if error.message.include?("does not exist in the filesystem")
116
116
  logger.info error.message
117
117
  else
118
- raise error
118
+ raise
119
119
  end
120
120
  end
121
121
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ci-helper
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.2
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - JustAnotherDude
@@ -89,6 +89,7 @@ files:
89
89
  - lib/ci_helper/railtie.rb
90
90
  - lib/ci_helper/tools/colorize.rb
91
91
  - lib/ci_helper/tools/inflector.rb
92
+ - lib/ci_helper/tools/spec_timings_recorder.rb
92
93
  - lib/ci_helper/version.rb
93
94
  - lib/tasks/sequel_management.rake
94
95
  homepage: https://github.com/umbrellio/ci_helper