rspec-tracer 1.2.3 → 2.0.0.pre.2
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 +384 -67
- data/README.md +454 -429
- data/bin/rspec-tracer +15 -0
- data/lib/rspec_tracer/cache/Rakefile +43 -0
- data/lib/rspec_tracer/cli/cache_clear.rb +111 -0
- data/lib/rspec_tracer/cli/cache_info.rb +104 -0
- data/lib/rspec_tracer/cli/doctor.rb +284 -0
- data/lib/rspec_tracer/cli/explain.rb +158 -0
- data/lib/rspec_tracer/cli/report_open.rb +82 -0
- data/lib/rspec_tracer/cli.rb +116 -0
- data/lib/rspec_tracer/configuration.rb +1196 -3
- data/lib/rspec_tracer/engine.rb +1168 -0
- data/lib/rspec_tracer/example.rb +141 -11
- data/lib/rspec_tracer/filter.rb +35 -0
- data/lib/rspec_tracer/line_stub.rb +61 -0
- data/lib/rspec_tracer/load_config.rb +2 -2
- data/lib/rspec_tracer/logger.rb +15 -0
- data/lib/rspec_tracer/rails/README.md +78 -0
- data/lib/rspec_tracer/rails/i18n_tracking.rb +137 -0
- data/lib/rspec_tracer/rails/notifications.rb +263 -0
- data/lib/rspec_tracer/rails/preset.rb +94 -0
- data/lib/rspec_tracer/rails/railtie.rb +22 -0
- data/lib/rspec_tracer/rails.rb +15 -0
- data/lib/rspec_tracer/remote_cache/README.md +140 -0
- data/lib/rspec_tracer/remote_cache/Rakefile +35 -11
- data/lib/rspec_tracer/remote_cache/archive.rb +137 -0
- data/lib/rspec_tracer/remote_cache/backend.rb +73 -0
- data/lib/rspec_tracer/remote_cache/git_ancestry.rb +241 -0
- data/lib/rspec_tracer/remote_cache/local_fs_backend.rb +439 -0
- data/lib/rspec_tracer/remote_cache/redis_backend.rb +554 -0
- data/lib/rspec_tracer/remote_cache/s3_backend.rb +712 -0
- data/lib/rspec_tracer/remote_cache/user_tasks.rb +436 -0
- data/lib/rspec_tracer/remote_cache/validator.rb +40 -62
- data/lib/rspec_tracer/remote_cache.rb +22 -0
- data/lib/rspec_tracer/reporters/README.md +103 -0
- data/lib/rspec_tracer/reporters/base.rb +87 -0
- data/lib/rspec_tracer/reporters/coverage_json_reporter.rb +338 -0
- data/lib/rspec_tracer/reporters/html/.gitignore +19 -0
- data/lib/rspec_tracer/reporters/html/.prettierignore +4 -0
- data/lib/rspec_tracer/reporters/html/.prettierrc.json +9 -0
- data/lib/rspec_tracer/reporters/html/README.md +80 -0
- data/lib/rspec_tracer/reporters/html/dist/assets/index.css +2 -0
- data/lib/rspec_tracer/reporters/html/dist/assets/index.js +1 -0
- data/lib/rspec_tracer/reporters/html/dist/index.html +24 -0
- data/lib/rspec_tracer/reporters/html/eslint.config.js +62 -0
- data/lib/rspec_tracer/reporters/html/package-lock.json +4941 -0
- data/lib/rspec_tracer/reporters/html/package.json +29 -0
- data/lib/rspec_tracer/reporters/html/src/app.jsx +130 -0
- data/lib/rspec_tracer/reporters/html/src/components/AllExamples.jsx +86 -0
- data/lib/rspec_tracer/reporters/html/src/components/DuplicateExamples.jsx +68 -0
- data/lib/rspec_tracer/reporters/html/src/components/ExamplesDependency.jsx +78 -0
- data/lib/rspec_tracer/reporters/html/src/components/FilesDependency.jsx +72 -0
- data/lib/rspec_tracer/reporters/html/src/components/FlakyExamples.jsx +42 -0
- data/lib/rspec_tracer/reporters/html/src/components/ReportTable.jsx +131 -0
- data/lib/rspec_tracer/reporters/html/src/components/SearchBar.jsx +19 -0
- data/lib/rspec_tracer/reporters/html/src/index.html +23 -0
- data/lib/rspec_tracer/reporters/html/src/main.jsx +37 -0
- data/lib/rspec_tracer/reporters/html/src/styles.css +434 -0
- data/lib/rspec_tracer/reporters/html/vite.config.js +42 -0
- data/lib/rspec_tracer/reporters/html_reporter.rb +266 -0
- data/lib/rspec_tracer/reporters/json_reporter.rb +88 -0
- data/lib/rspec_tracer/reporters/payload_builder.rb +235 -0
- data/lib/rspec_tracer/reporters/registry.rb +120 -0
- data/lib/rspec_tracer/reporters/terminal_reporter.rb +264 -0
- data/lib/rspec_tracer/rspec/README.md +73 -0
- data/lib/rspec_tracer/rspec/installation.rb +97 -0
- data/lib/rspec_tracer/rspec/metadata.rb +96 -0
- data/lib/rspec_tracer/rspec/parallel_tests.rb +459 -0
- data/lib/rspec_tracer/rspec/reporter_hook.rb +84 -0
- data/lib/rspec_tracer/rspec/runner_hook.rb +239 -0
- data/lib/rspec_tracer/source_file.rb +24 -7
- data/lib/rspec_tracer/storage/README.md +35 -0
- data/lib/rspec_tracer/storage/backend.rb +130 -0
- data/lib/rspec_tracer/storage/json_backend.rb +884 -0
- data/lib/rspec_tracer/storage/lazy_snapshot.rb +65 -0
- data/lib/rspec_tracer/storage/schema.rb +50 -0
- data/lib/rspec_tracer/storage/serializer/json.rb +41 -0
- data/lib/rspec_tracer/storage/serializer/msgpack.rb +167 -0
- data/lib/rspec_tracer/storage/snapshot.rb +141 -0
- data/lib/rspec_tracer/storage/sqlite_backend.rb +693 -0
- data/lib/rspec_tracer/time_formatter.rb +37 -18
- data/lib/rspec_tracer/tracker/README.md +36 -0
- data/lib/rspec_tracer/tracker/coverage_adapter.rb +174 -0
- data/lib/rspec_tracer/tracker/declared_globs.rb +100 -0
- data/lib/rspec_tracer/tracker/dependency_graph.rb +134 -0
- data/lib/rspec_tracer/tracker/env_matcher.rb +127 -0
- data/lib/rspec_tracer/tracker/env_snapshot.rb +77 -0
- data/lib/rspec_tracer/tracker/example_registry.rb +153 -0
- data/lib/rspec_tracer/tracker/file_digest.rb +61 -0
- data/lib/rspec_tracer/tracker/filter.rb +127 -0
- data/lib/rspec_tracer/tracker/input.rb +99 -0
- data/lib/rspec_tracer/tracker/io_hooks/file.rb +55 -0
- data/lib/rspec_tracer/tracker/io_hooks/io.rb +24 -0
- data/lib/rspec_tracer/tracker/io_hooks/json.rb +23 -0
- data/lib/rspec_tracer/tracker/io_hooks/kernel.rb +26 -0
- data/lib/rspec_tracer/tracker/io_hooks/yaml.rb +38 -0
- data/lib/rspec_tracer/tracker/io_hooks.rb +195 -0
- data/lib/rspec_tracer/tracker/loaded_files_tracker.rb +295 -0
- data/lib/rspec_tracer/tracker/new_file_detector.rb +62 -0
- data/lib/rspec_tracer/tracker/whole_suite_invalidators.rb +96 -0
- data/lib/rspec_tracer/version.rb +4 -1
- data/lib/rspec_tracer.rb +231 -491
- metadata +94 -43
- data/lib/rspec_tracer/cache.rb +0 -207
- data/lib/rspec_tracer/coverage_merger.rb +0 -42
- data/lib/rspec_tracer/coverage_reporter.rb +0 -187
- data/lib/rspec_tracer/coverage_writer.rb +0 -58
- data/lib/rspec_tracer/html_reporter/Rakefile +0 -18
- data/lib/rspec_tracer/html_reporter/assets/javascripts/application.js +0 -56
- data/lib/rspec_tracer/html_reporter/assets/javascripts/libraries/jquery.js +0 -10881
- data/lib/rspec_tracer/html_reporter/assets/javascripts/plugins/datatables.js +0 -15381
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/application.css +0 -196
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/datatables.css +0 -459
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/jquery-ui.css +0 -436
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/print.css +0 -92
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/reset.css +0 -265
- data/lib/rspec_tracer/html_reporter/public/application.css +0 -5
- data/lib/rspec_tracer/html_reporter/public/application.js +0 -6
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc_disabled.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_both.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc_disabled.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/favicon.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/loading.gif +0 -0
- data/lib/rspec_tracer/html_reporter/reporter.rb +0 -242
- data/lib/rspec_tracer/html_reporter/views/duplicate_examples.erb +0 -34
- data/lib/rspec_tracer/html_reporter/views/examples.erb +0 -58
- data/lib/rspec_tracer/html_reporter/views/examples_dependency.erb +0 -36
- data/lib/rspec_tracer/html_reporter/views/files_dependency.erb +0 -36
- data/lib/rspec_tracer/html_reporter/views/flaky_examples.erb +0 -38
- data/lib/rspec_tracer/html_reporter/views/layout.erb +0 -38
- data/lib/rspec_tracer/remote_cache/aws.rb +0 -176
- data/lib/rspec_tracer/remote_cache/cache.rb +0 -75
- data/lib/rspec_tracer/remote_cache/repo.rb +0 -210
- data/lib/rspec_tracer/report_generator.rb +0 -158
- data/lib/rspec_tracer/report_merger.rb +0 -68
- data/lib/rspec_tracer/report_writer.rb +0 -141
- data/lib/rspec_tracer/reporter.rb +0 -204
- data/lib/rspec_tracer/rspec_reporter.rb +0 -41
- data/lib/rspec_tracer/rspec_runner.rb +0 -56
- data/lib/rspec_tracer/ruby_coverage.rb +0 -9
- data/lib/rspec_tracer/runner.rb +0 -278
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module RSpecTracer
|
|
7
|
+
# Internal RSpec — see {RSpecTracer} for the user-facing surface.
|
|
8
|
+
# @api private
|
|
9
|
+
module RSpec
|
|
10
|
+
# parallel_tests orchestration for the v2 engine.
|
|
11
|
+
#
|
|
12
|
+
# 1.x scattered the parallel-worker glue across `lib/rspec_tracer.rb`
|
|
13
|
+
# (`parallel_tests_setup`, `track_parallel_tests_test_env_number`,
|
|
14
|
+
# `run_parallel_tests_exit_tasks`, `merge_parallel_tests_reports`,
|
|
15
|
+
# `parallel_tests_last_process?`, etc). 2.0 collapses them here and
|
|
16
|
+
# rewires the snapshot merge onto `Storage::JsonBackend#merge_from_peers`
|
|
17
|
+
# so any storage backend (including SQLite) gets the merge for free.
|
|
18
|
+
#
|
|
19
|
+
# Responsibilities:
|
|
20
|
+
# - Detect `TEST_ENV_NUMBER` + `PARALLEL_TEST_GROUPS` env vars.
|
|
21
|
+
# - Maintain the shared `rspec_tracer.lock` file that records the
|
|
22
|
+
# highest TEST_ENV_NUMBER seen (last-process detection).
|
|
23
|
+
# - Decide the narrator: first process by env convention. Log
|
|
24
|
+
# rollup lines only on the narrator unless
|
|
25
|
+
# `RSPEC_TRACER_VERBOSE=true`.
|
|
26
|
+
# - On the last process, merge per-worker snapshots +
|
|
27
|
+
# coverage.json, purge `parallel_tests_N/` directories.
|
|
28
|
+
#
|
|
29
|
+
# Graceful degradation: every merge / cleanup step rescues
|
|
30
|
+
# StandardError and logs - a partial or corrupt peer cache must
|
|
31
|
+
# never propagate a non-zero exit into the user's test run.
|
|
32
|
+
module ParallelTests
|
|
33
|
+
# Internal constant.
|
|
34
|
+
# @api private
|
|
35
|
+
LOCK_ENCODING = 'UTF-8'
|
|
36
|
+
|
|
37
|
+
# Per-worker boot/done breadcrumbs written to each worker's
|
|
38
|
+
# `parallel_tests_N/` cache dir. The elected worker uses these
|
|
39
|
+
# at finalize time to verify every booted peer has reached the
|
|
40
|
+
# end of its at_exit before merge + purge:
|
|
41
|
+
#
|
|
42
|
+
# .boot — written at setup! time (very early, before any
|
|
43
|
+
# cache write). Source-of-truth for "this worker
|
|
44
|
+
# ever booted past RSpecTracer.start".
|
|
45
|
+
# .done — written at finalize entry, AFTER per-worker
|
|
46
|
+
# run_finalize + emit_coverage_json. Must be the
|
|
47
|
+
# last write our code does into parallel_tests_N/
|
|
48
|
+
# on the worker side - the elected reads its
|
|
49
|
+
# presence as "this peer is fully flushed".
|
|
50
|
+
#
|
|
51
|
+
# Verification path: see `wait_for_peer_done_markers!`. Without
|
|
52
|
+
# this the elected trusted only `wait_for_other_processes_to_finish`'s
|
|
53
|
+
# pid-file barrier, which observed evidence on GHA Linux x86_64
|
|
54
|
+
# showed could return before a sibling had flushed - leaving a
|
|
55
|
+
# straggler `parallel_tests_N/` after purge (failing
|
|
56
|
+
# spec/integration/parallel_tests_spec.rb:88 intermittently).
|
|
57
|
+
BOOT_MARKER_FILENAME = '.rspec_tracer_boot'
|
|
58
|
+
# Internal constant.
|
|
59
|
+
# @api private
|
|
60
|
+
DONE_MARKER_FILENAME = '.rspec_tracer_done'
|
|
61
|
+
|
|
62
|
+
# Bound on the elected worker's wait for missing .done markers.
|
|
63
|
+
# 5s comfortably exceeds the at_exit tail of any well-behaved
|
|
64
|
+
# peer; on timeout we log + proceed (graceful degradation: a
|
|
65
|
+
# truly-crashed peer must not pin the elected forever).
|
|
66
|
+
PEER_DONE_DEADLINE_SECONDS = 5
|
|
67
|
+
|
|
68
|
+
# Internal helper for the tracer pipeline.
|
|
69
|
+
# @api private
|
|
70
|
+
def self.active?
|
|
71
|
+
return false if ::ENV.fetch('TEST_ENV_NUMBER', nil).nil?
|
|
72
|
+
return false if ::ENV.fetch('PARALLEL_TEST_GROUPS', nil).nil?
|
|
73
|
+
|
|
74
|
+
true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Narrator = first process. TEST_ENV_NUMBER is either '' or '1'
|
|
78
|
+
# for the first worker under parallel_tests; otherwise '2', '3',
|
|
79
|
+
# etc. When the gem is not running under parallel_tests, the
|
|
80
|
+
# single process is its own narrator.
|
|
81
|
+
def self.narrator?
|
|
82
|
+
return true unless active?
|
|
83
|
+
|
|
84
|
+
value = ::ENV.fetch('TEST_ENV_NUMBER', '').to_s
|
|
85
|
+
value.empty? || value == '1'
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Internal helper for the tracer pipeline.
|
|
89
|
+
# @api private
|
|
90
|
+
def self.verbose?
|
|
91
|
+
::ENV.fetch('RSPEC_TRACER_VERBOSE', nil) == 'true'
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# True iff this worker should emit rollup log lines. Per-example
|
|
95
|
+
# RSpec output (dots, failures, durations) is unaffected - that's
|
|
96
|
+
# RSpec's own Reporter, not this module.
|
|
97
|
+
def self.log_rollups?
|
|
98
|
+
verbose? || narrator?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Called from RSpecTracer.start when parallel_tests is active.
|
|
102
|
+
# Writes this worker's TEST_ENV_NUMBER into the shared lock file
|
|
103
|
+
# under an exclusive lock so the max-seen value ends up correct
|
|
104
|
+
# regardless of worker boot order, then drops the .boot
|
|
105
|
+
# breadcrumb so the elected worker can enumerate "every peer
|
|
106
|
+
# that booted" at finalize time.
|
|
107
|
+
def self.setup!
|
|
108
|
+
return false unless active?
|
|
109
|
+
|
|
110
|
+
require 'parallel_tests' unless defined?(::ParallelTests)
|
|
111
|
+
track_test_env_number!
|
|
112
|
+
touch_boot!
|
|
113
|
+
true
|
|
114
|
+
rescue LoadError => e
|
|
115
|
+
RSpecTracer.logger.error("Failed to load parallel_tests gem (#{e.class}: #{e.message})")
|
|
116
|
+
false
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Write `parallel_tests_N/.rspec_tracer_boot` with this worker's
|
|
120
|
+
# pid + TEST_ENV_NUMBER + timestamp. Source-of-truth for "this
|
|
121
|
+
# worker booted past RSpecTracer.start", consumed by the elected
|
|
122
|
+
# worker's finalize-time peer enumeration. Idempotent: a re-run
|
|
123
|
+
# of setup! overwrites with current values.
|
|
124
|
+
def self.touch_boot!
|
|
125
|
+
::FileUtils.mkdir_p(RSpecTracer.cache_path)
|
|
126
|
+
::File.write(
|
|
127
|
+
::File.join(RSpecTracer.cache_path, BOOT_MARKER_FILENAME),
|
|
128
|
+
::JSON.generate(
|
|
129
|
+
pid: ::Process.pid,
|
|
130
|
+
test_env_number: ::ENV.fetch('TEST_ENV_NUMBER', ''),
|
|
131
|
+
started_at: ::Time.now.utc.iso8601
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
rescue StandardError => e
|
|
135
|
+
RSpecTracer.logger.warn(
|
|
136
|
+
"RSpec tracer: failed to write boot marker (#{e.class}: #{e.message})"
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Called from at_exit after the per-worker snapshot has been
|
|
141
|
+
# persisted. Every worker drops its `.done` marker as the very
|
|
142
|
+
# first step here so the elected worker's verification loop can
|
|
143
|
+
# observe it; non-elected workers then return. The elected
|
|
144
|
+
# worker waits for every booted peer's `.done` to appear,
|
|
145
|
+
# orchestrates the snapshot + coverage merge, emits the merged
|
|
146
|
+
# reporters, and purges per-worker dirs.
|
|
147
|
+
#
|
|
148
|
+
# `touch_done!` MUST stay the last write our code performs into
|
|
149
|
+
# `parallel_tests_N/` — anything written later would land after
|
|
150
|
+
# the elected has decided it's safe to purge, leaving stragglers.
|
|
151
|
+
def self.finalize!
|
|
152
|
+
return false unless active?
|
|
153
|
+
|
|
154
|
+
touch_done!
|
|
155
|
+
|
|
156
|
+
return false unless last_process?
|
|
157
|
+
|
|
158
|
+
::ParallelTests.wait_for_other_processes_to_finish if defined?(::ParallelTests)
|
|
159
|
+
|
|
160
|
+
# Belt-and-suspenders barrier: pid-file said everyone's done,
|
|
161
|
+
# but observed CI evidence (GHA Linux x86_64, Ruby 3.4 cells)
|
|
162
|
+
# caught a sibling's `parallel_tests_N/` reappearing post-purge
|
|
163
|
+
# — i.e., the pid signal returned while a peer hadn't fully
|
|
164
|
+
# flushed yet. Cross-check via the .boot/.done filesystem
|
|
165
|
+
# markers before declaring the peer set stable.
|
|
166
|
+
wait_for_peer_done_markers!
|
|
167
|
+
|
|
168
|
+
merge_snapshot!
|
|
169
|
+
merge_coverage! unless RSpecTracer.simplecov?
|
|
170
|
+
# Emit terminal/JSON/HTML reporters ONCE at the merged top-level
|
|
171
|
+
# location BEFORE purge_worker_dirs! removes the per-worker
|
|
172
|
+
# `parallel_tests_N` dirs. Earlier behavior had each worker emit
|
|
173
|
+
# reports into its `rspec_tracer_report/parallel_tests_N` dir
|
|
174
|
+
# and the purge then deleted them, leaving the user with zero
|
|
175
|
+
# usable output. Now reporters consume the just-merged
|
|
176
|
+
# top-level snapshot.
|
|
177
|
+
emit_merged_reporters!
|
|
178
|
+
purge_worker_dirs!
|
|
179
|
+
remove_lock_file!
|
|
180
|
+
true
|
|
181
|
+
rescue StandardError => e
|
|
182
|
+
RSpecTracer.logger.warn(
|
|
183
|
+
"RSpec tracer: parallel_tests finalize failed (#{e.class}: #{e.message})"
|
|
184
|
+
)
|
|
185
|
+
false
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Drop `parallel_tests_N/.rspec_tracer_done` as a flush-complete
|
|
189
|
+
# signal for the elected worker's verification loop. The cache
|
|
190
|
+
# dir already exists by this point (run_finalize mkdir_p's it
|
|
191
|
+
# earlier in the at_exit chain); the explicit mkdir_p here is
|
|
192
|
+
# belt-and-suspenders for the no-examples / early-return paths.
|
|
193
|
+
# Graceful-degradation rescue keeps a marker-write failure from
|
|
194
|
+
# propagating into the user's exit status.
|
|
195
|
+
def self.touch_done!
|
|
196
|
+
::FileUtils.mkdir_p(RSpecTracer.cache_path)
|
|
197
|
+
::File.write(
|
|
198
|
+
::File.join(RSpecTracer.cache_path, DONE_MARKER_FILENAME),
|
|
199
|
+
::Time.now.utc.iso8601
|
|
200
|
+
)
|
|
201
|
+
rescue StandardError => e
|
|
202
|
+
RSpecTracer.logger.warn(
|
|
203
|
+
"RSpec tracer: failed to write done marker (#{e.class}: #{e.message})"
|
|
204
|
+
)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Block until every peer that wrote `.boot` has also written
|
|
208
|
+
# `.done`, or the deadline elapses. Polled at 50ms — fine
|
|
209
|
+
# enough that the typical "barrier returned a tick early" case
|
|
210
|
+
# closes within one or two polls, coarse enough not to dominate
|
|
211
|
+
# CPU.
|
|
212
|
+
#
|
|
213
|
+
# On timeout we log a warn and proceed: a peer that never wrote
|
|
214
|
+
# `.done` either crashed (then its dir is orphan content; the
|
|
215
|
+
# subsequent `purge_worker_dirs!` cleans it) or is genuinely
|
|
216
|
+
# hung (the elected can't fix that — we choose merge correctness
|
|
217
|
+
# over indefinite wait).
|
|
218
|
+
def self.wait_for_peer_done_markers!
|
|
219
|
+
base_dir = ::File.dirname(RSpecTracer.cache_path)
|
|
220
|
+
deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + PEER_DONE_DEADLINE_SECONDS
|
|
221
|
+
|
|
222
|
+
loop do
|
|
223
|
+
missing = peer_dirs_missing_done(base_dir)
|
|
224
|
+
return if missing.empty?
|
|
225
|
+
|
|
226
|
+
if ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) >= deadline
|
|
227
|
+
RSpecTracer.logger.warn(
|
|
228
|
+
'RSpec tracer: peers booted without finishing within ' \
|
|
229
|
+
"#{PEER_DONE_DEADLINE_SECONDS}s: #{missing.inspect}; " \
|
|
230
|
+
'proceeding (peer dirs will be purged regardless of completion state)'
|
|
231
|
+
)
|
|
232
|
+
return
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
sleep 0.05
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Set difference of `.boot`-bearing peer dirs and `.done`-bearing
|
|
240
|
+
# peer dirs under `base_dir`. A returned entry means "this peer
|
|
241
|
+
# registered but hasn't signaled completion yet" — either still
|
|
242
|
+
# mid-flush, or crashed.
|
|
243
|
+
def self.peer_dirs_missing_done(base_dir)
|
|
244
|
+
boot_dirs = peer_dirs_with_marker(base_dir, BOOT_MARKER_FILENAME)
|
|
245
|
+
done_dirs = peer_dirs_with_marker(base_dir, DONE_MARKER_FILENAME)
|
|
246
|
+
boot_dirs - done_dirs
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Internal helper for the tracer pipeline.
|
|
250
|
+
# @api private
|
|
251
|
+
def self.peer_dirs_with_marker(base_dir, marker_filename)
|
|
252
|
+
paths = ::Dir.glob(::File.join(base_dir, 'parallel_tests_*', marker_filename))
|
|
253
|
+
paths.map { |path| ::File.dirname(path) }
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Emit reporters against the merged top-level snapshot
|
|
257
|
+
# so the user gets one terminal summary + one JSON report + one
|
|
258
|
+
# HTML report at the canonical (non-`parallel_tests_N`) path.
|
|
259
|
+
# Wrapped in its own rescue so a failed reporter never blocks
|
|
260
|
+
# purge / lock cleanup downstream.
|
|
261
|
+
def self.emit_merged_reporters!
|
|
262
|
+
return unless RSpecTracer.storage_backend == :json
|
|
263
|
+
|
|
264
|
+
base_dir = ::File.dirname(RSpecTracer.cache_path)
|
|
265
|
+
merged_snapshot = load_merged_snapshot(base_dir)
|
|
266
|
+
return if merged_snapshot.nil?
|
|
267
|
+
|
|
268
|
+
top_report_dir = ::File.dirname(RSpecTracer.report_path)
|
|
269
|
+
::FileUtils.mkdir_p(top_report_dir)
|
|
270
|
+
|
|
271
|
+
RSpecTracer::Reporters::Registry.emit_all(
|
|
272
|
+
configuration: RSpecTracer,
|
|
273
|
+
snapshot: merged_snapshot,
|
|
274
|
+
report_dir: top_report_dir,
|
|
275
|
+
run_metadata: build_merged_run_metadata(base_dir)
|
|
276
|
+
)
|
|
277
|
+
rescue StandardError => e
|
|
278
|
+
RSpecTracer.logger.warn(
|
|
279
|
+
"RSpec tracer: merged reporter emission failed (#{e.class}: #{e.message})"
|
|
280
|
+
)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Internal helper for the tracer pipeline.
|
|
284
|
+
# @api private
|
|
285
|
+
def self.load_merged_snapshot(base_dir)
|
|
286
|
+
backend = RSpecTracer::Storage::JsonBackend.new(
|
|
287
|
+
cache_path: base_dir,
|
|
288
|
+
logger: RSpecTracer.logger,
|
|
289
|
+
retention_local_count: RSpecTracer.cache_retention_local_count,
|
|
290
|
+
serializer: RSpecTracer.storage_backend_opts[:serializer] || :json
|
|
291
|
+
)
|
|
292
|
+
backend.load_graph(schema_version: RSpecTracer::Storage::Schema::CURRENT)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Internal helper for the tracer pipeline.
|
|
296
|
+
# @api private
|
|
297
|
+
def self.build_merged_run_metadata(base_dir)
|
|
298
|
+
{
|
|
299
|
+
pid: Process.pid,
|
|
300
|
+
run_time: nil,
|
|
301
|
+
started_at: nil,
|
|
302
|
+
cache_path: base_dir,
|
|
303
|
+
parallel_tests: true,
|
|
304
|
+
rails: RSpecTracer.rails?
|
|
305
|
+
}
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Internal helper for the tracer pipeline.
|
|
309
|
+
# @api private
|
|
310
|
+
def self.track_test_env_number!
|
|
311
|
+
::File.open(RSpecTracer.lock_file, ::File::RDWR | ::File::CREAT, 0o644) do |f|
|
|
312
|
+
f.flock(::File::LOCK_EX)
|
|
313
|
+
|
|
314
|
+
test_num = [f.read.to_i, ::ENV.fetch('TEST_ENV_NUMBER').to_i].max
|
|
315
|
+
|
|
316
|
+
f.rewind
|
|
317
|
+
f.write("#{test_num}\n")
|
|
318
|
+
f.flush
|
|
319
|
+
f.truncate(f.pos)
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Elects the worker that performs the per-run merge. Delegates to
|
|
324
|
+
# `::ParallelTests.first_process?`, which returns true iff
|
|
325
|
+
# `TEST_ENV_NUMBER.to_i <= 1` -- i.e. for exactly one worker
|
|
326
|
+
# (TEST_ENV_NUMBER == '' or '1'), regardless of how many workers
|
|
327
|
+
# were actually spawned vs. how many CPUs the runner reports.
|
|
328
|
+
#
|
|
329
|
+
# Two historical approaches do NOT work here:
|
|
330
|
+
#
|
|
331
|
+
# 1. The 1.x lock-file scheme (each worker wrote its
|
|
332
|
+
# TEST_ENV_NUMBER to `rspec_tracer.lock` at RSpecTracer.start
|
|
333
|
+
# time; last_process? picked the max) deadlocked under CI:
|
|
334
|
+
# worker 1 could finish its examples before worker 2 even
|
|
335
|
+
# loaded spec_helper, observe itself as the max, and enter
|
|
336
|
+
# `wait_for_other_processes_to_finish` concurrently with
|
|
337
|
+
# worker 2's own self-election -- both workers spun on each
|
|
338
|
+
# other's pid.
|
|
339
|
+
#
|
|
340
|
+
# 2. `::ParallelTests.last_process?` compares TEST_ENV_NUMBER
|
|
341
|
+
# against PARALLEL_TEST_GROUPS. parallel_rspec's CLI sets
|
|
342
|
+
# PARALLEL_TEST_GROUPS to the CPU-based *intended* process
|
|
343
|
+
# count, NOT the actual worker count -- so when fewer specs
|
|
344
|
+
# than CPUs are present, no TEST_ENV_NUMBER ever matches
|
|
345
|
+
# PARALLEL_TEST_GROUPS and the merge is silently skipped.
|
|
346
|
+
#
|
|
347
|
+
# `first_process?` avoids both: it is immutable across worker
|
|
348
|
+
# lifetime (set by the parent at spawn) and identifies exactly
|
|
349
|
+
# one worker regardless of CPU count. The elected worker still
|
|
350
|
+
# calls `wait_for_other_processes_to_finish` before merging, so
|
|
351
|
+
# peer caches are guaranteed on disk by merge time.
|
|
352
|
+
def self.last_process?
|
|
353
|
+
return false unless active?
|
|
354
|
+
return false unless defined?(::ParallelTests)
|
|
355
|
+
|
|
356
|
+
::ParallelTests.first_process?
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Internal helper for the tracer pipeline.
|
|
360
|
+
# @api private
|
|
361
|
+
def self.remove_lock_file!
|
|
362
|
+
::FileUtils.rm_f(RSpecTracer.lock_file)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# parallel_tests sets `PARALLEL_TEST_GROUPS = num_processes.to_s`
|
|
366
|
+
# for each child, where `num_processes` is the user-requested
|
|
367
|
+
# process count (Parallel.processor_count by default) - NOT the
|
|
368
|
+
# number of workers actually spawned. When `num_processes` and
|
|
369
|
+
# the spawned-worker count diverge (e.g. when the spec count caps
|
|
370
|
+
# the partition below the CPU count, or when shared-runner
|
|
371
|
+
# cgroup throttling shifts the visible CPU count between when
|
|
372
|
+
# the parent computed `num_processes` and the spec count is
|
|
373
|
+
# observed), iterating `1..ENV['PARALLEL_TEST_GROUPS']` either
|
|
374
|
+
# over-iterates (cheap; rm_rf on a non-existent path is a no-op)
|
|
375
|
+
# or UNDER-iterates (expensive; merge skips peers + purge leaves
|
|
376
|
+
# `parallel_tests_N` stragglers behind, breaking the integration
|
|
377
|
+
# spec at `spec/integration/parallel_tests_spec.rb:88`).
|
|
378
|
+
#
|
|
379
|
+
# Glob the actual filesystem state rather than reconstructing dir
|
|
380
|
+
# names from an env var with surprising semantics. The directory
|
|
381
|
+
# IS the source of truth for which workers ran. The wait at
|
|
382
|
+
# `finalize!` (`wait_for_other_processes_to_finish`) guarantees
|
|
383
|
+
# every other worker's at_exit has flushed its `parallel_tests_N`
|
|
384
|
+
# tree before this method runs, so the glob captures every peer.
|
|
385
|
+
def self.peer_paths_for(base_dir)
|
|
386
|
+
::Dir.glob(::File.join(base_dir, 'parallel_tests_*')).select do |path|
|
|
387
|
+
::File.directory?(path)
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Merge the per-worker v2 snapshots into the top-level cache.
|
|
392
|
+
# SqliteBackend has no merge surface (single-file, latest-run
|
|
393
|
+
# only); the elected worker persists its own run via Engine
|
|
394
|
+
# finalize and the per-worker files accumulate next to it
|
|
395
|
+
# untouched. The JSON merge path stays authoritative for the
|
|
396
|
+
# default `:json` backend which is what parallel_tests fixtures
|
|
397
|
+
# exercise in CI.
|
|
398
|
+
def self.merge_snapshot!
|
|
399
|
+
return unless RSpecTracer.storage_backend == :json
|
|
400
|
+
|
|
401
|
+
base_dir = ::File.dirname(RSpecTracer.cache_path)
|
|
402
|
+
peer_paths = peer_paths_for(base_dir)
|
|
403
|
+
return if peer_paths.empty?
|
|
404
|
+
|
|
405
|
+
starting = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
406
|
+
|
|
407
|
+
top = RSpecTracer::Storage::JsonBackend.new(
|
|
408
|
+
cache_path: base_dir, logger: RSpecTracer.logger,
|
|
409
|
+
retention_local_count: RSpecTracer.cache_retention_local_count,
|
|
410
|
+
warn_per_file_mb: RSpecTracer.cache_size_warn_per_file_mb,
|
|
411
|
+
warn_total_mb: RSpecTracer.cache_size_warn_total_mb,
|
|
412
|
+
serializer: RSpecTracer.storage_backend_opts[:serializer] || :json
|
|
413
|
+
)
|
|
414
|
+
top.merge_from_peers(peer_paths, schema_version: RSpecTracer::Storage::Schema::CURRENT)
|
|
415
|
+
|
|
416
|
+
ending = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
417
|
+
elapsed = RSpecTracer::TimeFormatter.format_time(ending - starting)
|
|
418
|
+
|
|
419
|
+
RSpecTracer.logger.debug("RSpec tracer merged parallel tests reports (took #{elapsed})") if log_rollups?
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# Merge per-worker coverage.json files into a top-level coverage.json.
|
|
423
|
+
# Routed through Reporters::CoverageJsonReporter.merge_parallel
|
|
424
|
+
# (replaces the legacy CoverageMerger + CoverageWriter pair).
|
|
425
|
+
def self.merge_coverage!
|
|
426
|
+
base_dir = ::File.dirname(RSpecTracer.coverage_path)
|
|
427
|
+
peer_paths = peer_paths_for(base_dir)
|
|
428
|
+
return if peer_paths.empty?
|
|
429
|
+
|
|
430
|
+
starting = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
431
|
+
|
|
432
|
+
RSpecTracer::Reporters::CoverageJsonReporter.merge_parallel(
|
|
433
|
+
peer_paths: peer_paths,
|
|
434
|
+
output_path: ::File.join(base_dir, 'coverage.json'),
|
|
435
|
+
logger: RSpecTracer.logger
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
ending = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
439
|
+
elapsed = RSpecTracer::TimeFormatter.format_time(ending - starting)
|
|
440
|
+
|
|
441
|
+
RSpecTracer.logger.debug("RSpec tracer merged parallel tests coverage (took #{elapsed})") if log_rollups?
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Sweep every `parallel_tests_*` subdirectory under each managed
|
|
445
|
+
# base path. Globbing matches the same source-of-truth contract
|
|
446
|
+
# documented on `peer_paths_for`: the directories that actually
|
|
447
|
+
# exist are exactly the workers that ran, regardless of what
|
|
448
|
+
# PARALLEL_TEST_GROUPS reports.
|
|
449
|
+
def self.purge_worker_dirs!
|
|
450
|
+
[RSpecTracer.cache_path, RSpecTracer.coverage_path, RSpecTracer.report_path].each do |path|
|
|
451
|
+
base_dir = ::File.dirname(path)
|
|
452
|
+
::Dir.glob(::File.join(base_dir, 'parallel_tests_*')).each do |worker_dir|
|
|
453
|
+
::FileUtils.rm_rf(worker_dir)
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpecTracer
|
|
4
|
+
# Internal RSpec — see {RSpecTracer} for the user-facing surface.
|
|
5
|
+
# @api private
|
|
6
|
+
module RSpec
|
|
7
|
+
# Prepended onto `RSpec::Core::Reporter` by
|
|
8
|
+
# `RSpecTracer::RSpec::Installation.install!`. Replaces the 1.x
|
|
9
|
+
# `RSpecTracer::RSpecReporter` singleton-class prepend.
|
|
10
|
+
#
|
|
11
|
+
# Forwards RSpec's example lifecycle notifications into the engine,
|
|
12
|
+
# then chains to `super`. Every callback is no-op when either:
|
|
13
|
+
# - the engine isn't set up (start never called, graceful degrade)
|
|
14
|
+
# - the example carries no `:rspec_tracer_example_id` metadata
|
|
15
|
+
# (it was partitioned into `ignore_spec_files` by RunnerHook, so
|
|
16
|
+
# the tracer treats it as invisible)
|
|
17
|
+
#
|
|
18
|
+
# The per-example coverage peek+diff sequence (peek before, peek
|
|
19
|
+
# after) runs through Engine#example_started + Engine#example_finished
|
|
20
|
+
# only. 2.0 retired the legacy CoverageReporter that previously
|
|
21
|
+
# peeked a second time per example; coverage.json emission now
|
|
22
|
+
# consumes the Engine's per-example deltas + a single finalize-time
|
|
23
|
+
# peek through Tracker::CoverageAdapter#peek_unfiltered.
|
|
24
|
+
module ReporterHook
|
|
25
|
+
# Internal method on the tracer pipeline.
|
|
26
|
+
# @api private
|
|
27
|
+
def example_started(_example)
|
|
28
|
+
RSpecTracer.engine&.example_started
|
|
29
|
+
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Internal method on the tracer pipeline.
|
|
34
|
+
# @api private
|
|
35
|
+
def example_finished(example)
|
|
36
|
+
engine = RSpecTracer.engine
|
|
37
|
+
if engine
|
|
38
|
+
example_id = example.metadata[:rspec_tracer_example_id]
|
|
39
|
+
engine.example_finished(example_id) if example_id
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
super
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Internal method on the tracer pipeline.
|
|
46
|
+
# @api private
|
|
47
|
+
def example_passed(example)
|
|
48
|
+
_rspec_tracer_status(example, :on_example_passed)
|
|
49
|
+
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Internal method on the tracer pipeline.
|
|
54
|
+
# @api private
|
|
55
|
+
def example_failed(example)
|
|
56
|
+
_rspec_tracer_status(example, :on_example_failed)
|
|
57
|
+
|
|
58
|
+
super
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Internal method on the tracer pipeline.
|
|
62
|
+
# @api private
|
|
63
|
+
def example_pending(example)
|
|
64
|
+
_rspec_tracer_status(example, :on_example_pending)
|
|
65
|
+
|
|
66
|
+
super
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# Internal method on the tracer pipeline.
|
|
72
|
+
# @api private
|
|
73
|
+
def _rspec_tracer_status(example, method)
|
|
74
|
+
engine = RSpecTracer.engine
|
|
75
|
+
return unless engine
|
|
76
|
+
|
|
77
|
+
example_id = example.metadata[:rspec_tracer_example_id]
|
|
78
|
+
return unless example_id
|
|
79
|
+
|
|
80
|
+
engine.public_send(method, example_id, example.execution_result)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|