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 +4 -4
- data/.rubocop.yml +5 -0
- data/Gemfile +0 -1
- data/Gemfile.lock +1 -3
- data/README.md +13 -1
- data/lib/ci_helper/commands/check_spec_suffixes.rb +5 -8
- data/lib/ci_helper/commands/run_specs.rb +85 -6
- data/lib/ci_helper/commands.rb +1 -1
- data/lib/ci_helper/tools/spec_timings_recorder.rb +30 -0
- data/lib/ci_helper/version.rb +1 -1
- data/lib/tasks/sequel_management.rake +2 -2
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: acb764a2dd918b19d7aa39e23f3f2cf3f38dbcf1e37d67029487b29e3fe609db
|
|
4
|
+
data.tar.gz: b0a66151d11632ad06a22367110f9520dcc5f4c13ab51dcb95b03309ce1b2bb1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cc0f16f1a115ebb6feef700a5f12f9f02c02eb036001e3305770ac58a075240d972d4c58475f5722b00448a5b2756fe5b79887f3c7e75c9a4aba28ff758322f3
|
|
7
|
+
data.tar.gz: 5dfe278d4742215228137a499f725a83446b54e0bb645c088a99baa8f4d1f509a93aba29d50256b68ab78849a514ddf465fe37a9ce38dd09e92819b1183f524a
|
data/.rubocop.yml
CHANGED
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
ci-helper (0.
|
|
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.
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
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)
|
data/lib/ci_helper/commands.rb
CHANGED
|
@@ -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
|
data/lib/ci_helper/version.rb
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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
|