testsift 0.0.1
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/CHANGELOG.md +22 -0
- data/LICENSE +22 -0
- data/README.md +572 -0
- data/bin/testsift +15 -0
- data/lib/testsift/cache/Rakefile +43 -0
- data/lib/testsift/cli/blast_radius.rb +318 -0
- data/lib/testsift/cli/cache_clear.rb +114 -0
- data/lib/testsift/cli/cache_info.rb +106 -0
- data/lib/testsift/cli/doctor.rb +307 -0
- data/lib/testsift/cli/explain.rb +273 -0
- data/lib/testsift/cli/report_open.rb +90 -0
- data/lib/testsift/cli/snapshot_helpers.rb +119 -0
- data/lib/testsift/cli.rb +219 -0
- data/lib/testsift/configuration.rb +1467 -0
- data/lib/testsift/defaults.rb +11 -0
- data/lib/testsift/engine/snapshot_builder.rb +117 -0
- data/lib/testsift/engine.rb +896 -0
- data/lib/testsift/example.rb +188 -0
- data/lib/testsift/filter.rb +103 -0
- data/lib/testsift/line_stub.rb +62 -0
- data/lib/testsift/load_config.rb +15 -0
- data/lib/testsift/load_default_config.rb +46 -0
- data/lib/testsift/load_global_config.rb +33 -0
- data/lib/testsift/load_local_config.rb +17 -0
- data/lib/testsift/logger.rb +59 -0
- data/lib/testsift/rails/README.md +78 -0
- data/lib/testsift/rails/i18n_tracking.rb +137 -0
- data/lib/testsift/rails/notifications.rb +263 -0
- data/lib/testsift/rails/preset.rb +94 -0
- data/lib/testsift/rails/railtie.rb +22 -0
- data/lib/testsift/rails.rb +15 -0
- data/lib/testsift/remote_cache/README.md +140 -0
- data/lib/testsift/remote_cache/Rakefile +61 -0
- data/lib/testsift/remote_cache/archive.rb +137 -0
- data/lib/testsift/remote_cache/backend.rb +73 -0
- data/lib/testsift/remote_cache/git_ancestry.rb +241 -0
- data/lib/testsift/remote_cache/local_fs_backend.rb +439 -0
- data/lib/testsift/remote_cache/redis_backend.rb +554 -0
- data/lib/testsift/remote_cache/s3_backend.rb +712 -0
- data/lib/testsift/remote_cache/user_tasks.rb +436 -0
- data/lib/testsift/remote_cache/validator.rb +50 -0
- data/lib/testsift/remote_cache.rb +22 -0
- data/lib/testsift/reporters/README.md +103 -0
- data/lib/testsift/reporters/base.rb +87 -0
- data/lib/testsift/reporters/coverage_json_reporter.rb +338 -0
- data/lib/testsift/reporters/html/.gitignore +19 -0
- data/lib/testsift/reporters/html/.prettierignore +4 -0
- data/lib/testsift/reporters/html/.prettierrc.json +9 -0
- data/lib/testsift/reporters/html/README.md +80 -0
- data/lib/testsift/reporters/html/dist/assets/index.css +2 -0
- data/lib/testsift/reporters/html/dist/assets/index.js +1 -0
- data/lib/testsift/reporters/html/dist/index.html +24 -0
- data/lib/testsift/reporters/html/eslint.config.js +62 -0
- data/lib/testsift/reporters/html/package-lock.json +4910 -0
- data/lib/testsift/reporters/html/package.json +29 -0
- data/lib/testsift/reporters/html/src/app.jsx +130 -0
- data/lib/testsift/reporters/html/src/components/AllExamples.jsx +86 -0
- data/lib/testsift/reporters/html/src/components/DuplicateExamples.jsx +68 -0
- data/lib/testsift/reporters/html/src/components/ExamplesDependency.jsx +78 -0
- data/lib/testsift/reporters/html/src/components/FilesDependency.jsx +72 -0
- data/lib/testsift/reporters/html/src/components/FlakyExamples.jsx +42 -0
- data/lib/testsift/reporters/html/src/components/ReportTable.jsx +131 -0
- data/lib/testsift/reporters/html/src/components/SearchBar.jsx +19 -0
- data/lib/testsift/reporters/html/src/index.html +23 -0
- data/lib/testsift/reporters/html/src/main.jsx +37 -0
- data/lib/testsift/reporters/html/src/styles.css +434 -0
- data/lib/testsift/reporters/html/vite.config.js +42 -0
- data/lib/testsift/reporters/html_reporter.rb +266 -0
- data/lib/testsift/reporters/json_reporter.rb +88 -0
- data/lib/testsift/reporters/payload_builder.rb +241 -0
- data/lib/testsift/reporters/registry.rb +120 -0
- data/lib/testsift/reporters/terminal_reporter.rb +264 -0
- data/lib/testsift/rspec/README.md +73 -0
- data/lib/testsift/rspec/installation.rb +97 -0
- data/lib/testsift/rspec/metadata.rb +96 -0
- data/lib/testsift/rspec/parallel_tests.rb +459 -0
- data/lib/testsift/rspec/rails_setup.rb +42 -0
- data/lib/testsift/rspec/reporter_hook.rb +90 -0
- data/lib/testsift/rspec/runner_hook.rb +253 -0
- data/lib/testsift/source_file.rb +56 -0
- data/lib/testsift/storage/README.md +35 -0
- data/lib/testsift/storage/backend.rb +130 -0
- data/lib/testsift/storage/json_backend.rb +884 -0
- data/lib/testsift/storage/lazy_snapshot.rb +65 -0
- data/lib/testsift/storage/schema.rb +50 -0
- data/lib/testsift/storage/serializer/json.rb +41 -0
- data/lib/testsift/storage/serializer/msgpack.rb +167 -0
- data/lib/testsift/storage/snapshot.rb +141 -0
- data/lib/testsift/storage/sqlite_backend.rb +709 -0
- data/lib/testsift/time_formatter.rb +74 -0
- data/lib/testsift/tracker/README.md +36 -0
- data/lib/testsift/tracker/coverage_adapter.rb +180 -0
- data/lib/testsift/tracker/declared_globs.rb +100 -0
- data/lib/testsift/tracker/dependency_graph.rb +134 -0
- data/lib/testsift/tracker/env_matcher.rb +127 -0
- data/lib/testsift/tracker/env_snapshot.rb +77 -0
- data/lib/testsift/tracker/example_registry.rb +153 -0
- data/lib/testsift/tracker/file_digest.rb +61 -0
- data/lib/testsift/tracker/filter.rb +127 -0
- data/lib/testsift/tracker/input.rb +99 -0
- data/lib/testsift/tracker/io_hooks/file.rb +55 -0
- data/lib/testsift/tracker/io_hooks/io.rb +24 -0
- data/lib/testsift/tracker/io_hooks/json.rb +23 -0
- data/lib/testsift/tracker/io_hooks/kernel.rb +26 -0
- data/lib/testsift/tracker/io_hooks/yaml.rb +38 -0
- data/lib/testsift/tracker/io_hooks.rb +195 -0
- data/lib/testsift/tracker/loaded_files_tracker.rb +290 -0
- data/lib/testsift/tracker/new_file_detector.rb +62 -0
- data/lib/testsift/tracker/whole_suite_invalidators.rb +96 -0
- data/lib/testsift/version.rb +7 -0
- data/lib/testsift.rb +335 -0
- metadata +193 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TestSift
|
|
4
|
+
# Internal CLI — see {TestSift} for the user-facing surface.
|
|
5
|
+
# @api private
|
|
6
|
+
module CLI
|
|
7
|
+
# `testsift doctor` — diagnose config + environment.
|
|
8
|
+
# Reports Ruby + testsift versions, project root resolution,
|
|
9
|
+
# cache / coverage / report directory state, and SimpleCov / Rails
|
|
10
|
+
# presence. Exits 0 on healthy diagnosis, 1 if any check fails.
|
|
11
|
+
module Doctor
|
|
12
|
+
# @param args [Array<String>] sub-command args (`-h` / `--help`).
|
|
13
|
+
# @param stdout [IO]
|
|
14
|
+
# @param stderr [IO]
|
|
15
|
+
# @return [Integer] exit status (0 = healthy, 1 = any check
|
|
16
|
+
# FAILed; warnings keep status 0).
|
|
17
|
+
def self.run(args, stdout: $stdout, stderr: $stderr)
|
|
18
|
+
return print_help(stdout) if args.include?('-h') || args.include?('--help')
|
|
19
|
+
|
|
20
|
+
checks = [
|
|
21
|
+
ruby_version_check,
|
|
22
|
+
tracer_version_check,
|
|
23
|
+
project_root_check,
|
|
24
|
+
cache_path_check,
|
|
25
|
+
coverage_path_check,
|
|
26
|
+
report_path_check,
|
|
27
|
+
git_check,
|
|
28
|
+
simplecov_check,
|
|
29
|
+
rails_check,
|
|
30
|
+
cache_schema_version_check,
|
|
31
|
+
remote_cache_check,
|
|
32
|
+
ar_schema_narrow_attribution_check,
|
|
33
|
+
ci_environment_check
|
|
34
|
+
]
|
|
35
|
+
checks.each { |line| stdout.puts line }
|
|
36
|
+
ok = checks.none? { |line| line.start_with?('FAIL') }
|
|
37
|
+
ok ? 0 : 1
|
|
38
|
+
rescue Errno::EPIPE
|
|
39
|
+
# Downstream pipe (`... | head`) closed early -- routine in
|
|
40
|
+
# shell pipelines, not a failure. Exit 0 silently.
|
|
41
|
+
0
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
stderr.puts "doctor: #{e.class}: #{e.message}"
|
|
44
|
+
1
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Internal helper for the tracer pipeline.
|
|
48
|
+
# @api private
|
|
49
|
+
def self.print_help(stdout)
|
|
50
|
+
stdout.puts <<~HELP
|
|
51
|
+
Usage: testsift doctor
|
|
52
|
+
|
|
53
|
+
Diagnose testsift config and environment. Prints a checklist
|
|
54
|
+
of versions, paths, and integrations; exits 0 if all checks pass.
|
|
55
|
+
HELP
|
|
56
|
+
0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Internal helper for the tracer pipeline.
|
|
60
|
+
# @api private
|
|
61
|
+
def self.ruby_version_check
|
|
62
|
+
"OK ruby: #{RUBY_DESCRIPTION}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Internal helper for the tracer pipeline.
|
|
66
|
+
# @api private
|
|
67
|
+
def self.tracer_version_check
|
|
68
|
+
"OK testsift: #{TestSift::VERSION}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Internal helper for the tracer pipeline.
|
|
72
|
+
# @api private
|
|
73
|
+
def self.project_root_check
|
|
74
|
+
"OK root: #{TestSift.root}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Internal helper for the tracer pipeline.
|
|
78
|
+
# @api private
|
|
79
|
+
def self.cache_path_check
|
|
80
|
+
path_check('cache_path:', TestSift.cache_path)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Internal helper for the tracer pipeline.
|
|
84
|
+
# @api private
|
|
85
|
+
def self.coverage_path_check
|
|
86
|
+
path_check('coverage_path:', TestSift.coverage_path)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Internal helper for the tracer pipeline.
|
|
90
|
+
# @api private
|
|
91
|
+
def self.report_path_check
|
|
92
|
+
path_check('report_path:', TestSift.report_path)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Internal helper for the tracer pipeline.
|
|
96
|
+
# @api private
|
|
97
|
+
def self.path_check(label, path)
|
|
98
|
+
return "FAIL #{label} <missing>" if path.nil? || path.empty?
|
|
99
|
+
return "FAIL #{label} #{path} (does not exist)" unless File.directory?(path)
|
|
100
|
+
return "FAIL #{label} #{path} (not writable)" unless File.writable?(path)
|
|
101
|
+
|
|
102
|
+
"OK #{label} #{path}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Internal helper for the tracer pipeline.
|
|
106
|
+
# @api private
|
|
107
|
+
def self.git_check
|
|
108
|
+
if system('git', 'rev-parse', 'HEAD', out: File::NULL, err: File::NULL)
|
|
109
|
+
'OK git: HEAD reachable (remote_cache will work)'
|
|
110
|
+
else
|
|
111
|
+
'WARN git: not in a git repo (remote_cache will degrade gracefully)'
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# `bundle exec testsift doctor` runs in its own process via
|
|
116
|
+
# the gem's `bin/testsift` binstub, NOT inside the user's
|
|
117
|
+
# rspec boot — so app code never loads here and a bare
|
|
118
|
+
# `defined?(::SimpleCov)` check would falsely report "not
|
|
119
|
+
# loaded" on projects that DO have SimpleCov in their
|
|
120
|
+
# Gemfile. Probe `Gem.loaded_specs` first to surface the
|
|
121
|
+
# "installed but not loaded in doctor's process" case
|
|
122
|
+
# separately from "actually not installed."
|
|
123
|
+
def self.simplecov_check
|
|
124
|
+
return 'OK SimpleCov: loaded (interop active)' if defined?(::SimpleCov)
|
|
125
|
+
|
|
126
|
+
spec = Gem.loaded_specs['simplecov']
|
|
127
|
+
return "INFO SimpleCov: installed (v#{spec.version}; not loaded in doctor's process)" if spec
|
|
128
|
+
|
|
129
|
+
'INFO SimpleCov: not installed (this is fine; SimpleCov is optional)'
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# See {.simplecov_check} for the doctor-runs-in-its-own-
|
|
133
|
+
# process rationale. Same three-state probe shape: loaded in
|
|
134
|
+
# this process / installed but not loaded / not installed.
|
|
135
|
+
def self.rails_check
|
|
136
|
+
return "OK Rails: #{::Rails::VERSION::STRING}" if defined?(::Rails::VERSION) && !::Rails::VERSION.nil?
|
|
137
|
+
|
|
138
|
+
spec = Gem.loaded_specs['rails']
|
|
139
|
+
return "INFO Rails: installed (v#{spec.version}; not loaded in doctor's process)" if spec
|
|
140
|
+
|
|
141
|
+
'INFO Rails: not installed (this is fine for non-Rails projects)'
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Surface a 1.x->2.0 cache mismatch BEFORE the user runs
|
|
145
|
+
# rspec and watches everything re-run mysteriously. Reads the
|
|
146
|
+
# cached `last_run.json` (if any) and compares its
|
|
147
|
+
# `schema_version` against the gem's `Schema::CURRENT`. Three
|
|
148
|
+
# outcomes: no cache yet (INFO), match (OK), or mismatch (WARN
|
|
149
|
+
# with the cold-run note). Never FAIL - schema mismatches are
|
|
150
|
+
# the documented cold-run path, not a hard error.
|
|
151
|
+
def self.cache_schema_version_check
|
|
152
|
+
require 'testsift/storage/schema'
|
|
153
|
+
require 'json'
|
|
154
|
+
|
|
155
|
+
cache_path = TestSift.cache_path
|
|
156
|
+
last_run_path = File.join(cache_path.to_s, 'last_run.json')
|
|
157
|
+
unless File.file?(last_run_path)
|
|
158
|
+
return 'INFO schema: no cache yet (next rspec run is cold; expected on first install)'
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
manifest = JSON.parse(File.read(last_run_path, encoding: 'UTF-8'))
|
|
162
|
+
stored = manifest['schema_version']
|
|
163
|
+
current = TestSift::Storage::Schema::CURRENT
|
|
164
|
+
if stored == current
|
|
165
|
+
"OK schema: v#{current} (matches gem)"
|
|
166
|
+
else
|
|
167
|
+
"WARN schema: stored v#{stored.inspect} != gem v#{current} " \
|
|
168
|
+
'(next rspec run is a cold run; expected on 1.x->2.0 upgrade)'
|
|
169
|
+
end
|
|
170
|
+
rescue StandardError => e
|
|
171
|
+
"WARN schema: could not read cache manifest: #{e.class}: #{e.message}"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# When remote_cache is configured, verify the backend is
|
|
175
|
+
# reachable from doctor's vantage point so the user catches
|
|
176
|
+
# misconfig (typo'd S3 path / unreachable Redis URL / unwritable
|
|
177
|
+
# local-fs dir) BEFORE the next CI run fails. Best-effort: never
|
|
178
|
+
# FAIL the gate, just surface a WARN/INFO line.
|
|
179
|
+
REMOTE_CACHE_PROBES = {
|
|
180
|
+
s3: ->(opts) { remote_cache_s3_check(opts) },
|
|
181
|
+
local_fs: ->(opts) { remote_cache_local_fs_check(opts) },
|
|
182
|
+
redis: ->(opts) { remote_cache_redis_check(opts) }
|
|
183
|
+
}.freeze
|
|
184
|
+
|
|
185
|
+
# Internal helper for the tracer pipeline.
|
|
186
|
+
# @api private
|
|
187
|
+
def self.remote_cache_check
|
|
188
|
+
entry = remote_cache_entry
|
|
189
|
+
return 'INFO remote_cache: not configured (skip)' unless entry
|
|
190
|
+
|
|
191
|
+
backend, opts = entry
|
|
192
|
+
probe = REMOTE_CACHE_PROBES[backend]
|
|
193
|
+
return "INFO remote_cache: custom backend #{backend.inspect} (skipping reachability probe)" if probe.nil?
|
|
194
|
+
|
|
195
|
+
instance_exec(opts, &probe)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Internal helper for the tracer pipeline.
|
|
199
|
+
# @api private
|
|
200
|
+
def self.remote_cache_entry
|
|
201
|
+
return nil unless TestSift.respond_to?(:remote_cache_backend_entry)
|
|
202
|
+
|
|
203
|
+
TestSift.remote_cache_backend_entry
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Internal helper for the tracer pipeline.
|
|
207
|
+
# @api private
|
|
208
|
+
def self.remote_cache_s3_check(opts)
|
|
209
|
+
bucket = opts[:bucket] || opts['bucket']
|
|
210
|
+
return 'WARN remote_cache: :s3 configured without :bucket' if bucket.nil? || bucket.empty?
|
|
211
|
+
|
|
212
|
+
"OK remote_cache: :s3 bucket=#{bucket} " \
|
|
213
|
+
'(reachability not probed locally; verified end-to-end on next CI run)'
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Internal helper for the tracer pipeline.
|
|
217
|
+
# @api private
|
|
218
|
+
def self.remote_cache_local_fs_check(opts)
|
|
219
|
+
path = opts[:path] || opts['path']
|
|
220
|
+
return 'WARN remote_cache: :local_fs configured without :path' if path.nil? || path.empty?
|
|
221
|
+
return "WARN remote_cache: :local_fs path #{path} does not exist" unless File.directory?(path)
|
|
222
|
+
return "WARN remote_cache: :local_fs path #{path} not writable" unless File.writable?(path)
|
|
223
|
+
|
|
224
|
+
"OK remote_cache: :local_fs path=#{path}"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Internal helper for the tracer pipeline.
|
|
228
|
+
# @api private
|
|
229
|
+
def self.remote_cache_redis_check(opts)
|
|
230
|
+
url = opts[:url] || opts['url'] || ENV.fetch('TESTSIFT_REMOTE_CACHE_URI', nil)
|
|
231
|
+
return 'WARN remote_cache: :redis configured without :url' if url.nil? || url.empty?
|
|
232
|
+
|
|
233
|
+
"OK remote_cache: :redis url=#{url} " \
|
|
234
|
+
'(reachability not probed locally; verified end-to-end on next CI run)'
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Surface the narrow-attribution precondition at diagnostic time.
|
|
238
|
+
# When `track_ar_schema_notifications` is enabled AND Rails is
|
|
239
|
+
# loaded AND the rspec-rails default `use_transactional_fixtures
|
|
240
|
+
# = true` is in effect, per-example BEGIN/COMMIT fires
|
|
241
|
+
# `sql.active_record` inside the testsift bucket and
|
|
242
|
+
# attribution silently widens. Same shape as the boot-time warn
|
|
243
|
+
# in TestSift.start, surfaced here so users running
|
|
244
|
+
# `testsift doctor` see the issue without having to boot a
|
|
245
|
+
# full rspec run first.
|
|
246
|
+
def self.ar_schema_narrow_attribution_check
|
|
247
|
+
return 'INFO AR schema: track_ar_schema_notifications not enabled' unless ar_schema_enabled?
|
|
248
|
+
return 'INFO AR schema: Rails not loaded' unless rails_loaded?
|
|
249
|
+
|
|
250
|
+
if transactional_fixtures_default?
|
|
251
|
+
'WARN AR schema: track_ar_schema_notifications + use_transactional_fixtures=true ' \
|
|
252
|
+
'silently widens to whole-suite-on-schema-change. See README ' \
|
|
253
|
+
'section "Narrow AR schema attribution".'
|
|
254
|
+
else
|
|
255
|
+
'OK AR schema: narrow attribution preconditions look good'
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Environment variables that signal a CI environment, one per
|
|
260
|
+
# major provider plus the near-universal `CI` convention.
|
|
261
|
+
CI_ENV_VARS = %w[CI GITHUB_ACTIONS GITLAB_CI CIRCLECI BUILDKITE TF_BUILD].freeze
|
|
262
|
+
|
|
263
|
+
# Surface the cache-persistence recipes exactly when the user is
|
|
264
|
+
# on CI, where a non-persisted cache silently degrades every run
|
|
265
|
+
# to a cold run. Detection requires a var that is set AND
|
|
266
|
+
# non-empty: `ENV['CI'] == ""` is truthy in Ruby (a shell with
|
|
267
|
+
# `CI=` exported would otherwise read as detected). INFO both
|
|
268
|
+
# ways - being in or out of CI is not a health state, so this
|
|
269
|
+
# check never affects the exit status.
|
|
270
|
+
def self.ci_environment_check
|
|
271
|
+
var = CI_ENV_VARS.find { |v| !ENV[v].to_s.empty? }
|
|
272
|
+
if var
|
|
273
|
+
"INFO ci: detected via ENV[#{var}] -- cache persistence recipes: docs/CI_RECIPES.md"
|
|
274
|
+
else
|
|
275
|
+
'INFO ci: not detected (local run)'
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Internal helper for the tracer pipeline.
|
|
280
|
+
# @api private
|
|
281
|
+
def self.ar_schema_enabled?
|
|
282
|
+
return false unless TestSift.respond_to?(:track_ar_schema_notifications?)
|
|
283
|
+
|
|
284
|
+
TestSift.track_ar_schema_notifications?
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Internal helper for the tracer pipeline.
|
|
288
|
+
# @api private
|
|
289
|
+
def self.rails_loaded?
|
|
290
|
+
defined?(::Rails::VERSION) && !::Rails::VERSION.nil?
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Internal helper for the tracer pipeline.
|
|
294
|
+
# @api private
|
|
295
|
+
def self.transactional_fixtures_default?
|
|
296
|
+
return false unless defined?(::RSpec) && ::RSpec.respond_to?(:configuration)
|
|
297
|
+
|
|
298
|
+
cfg = ::RSpec.configuration
|
|
299
|
+
return false unless cfg.respond_to?(:use_transactional_fixtures)
|
|
300
|
+
|
|
301
|
+
cfg.use_transactional_fixtures != false
|
|
302
|
+
rescue StandardError
|
|
303
|
+
false
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'testsift/cli/snapshot_helpers'
|
|
4
|
+
|
|
5
|
+
module TestSift
|
|
6
|
+
# Internal CLI -- see {TestSift} for the user-facing surface.
|
|
7
|
+
# @api private
|
|
8
|
+
module CLI
|
|
9
|
+
# `testsift explain <example>` -- show why a given example is
|
|
10
|
+
# scheduled to run or skip on the next rspec invocation. Backend-
|
|
11
|
+
# agnostic: dispatches through {TestSift::Storage::Backend.build}
|
|
12
|
+
# (via {SnapshotHelpers.load_snapshot}) so `storage_backend :sqlite`
|
|
13
|
+
# resolves the latest run from the meta table instead of the
|
|
14
|
+
# JsonBackend-only `last_run.json` file.
|
|
15
|
+
module Explain
|
|
16
|
+
# @param args [Array<String>] sub-command args. First positional
|
|
17
|
+
# arg is the example_id (or substring) to explain. `--not-run`
|
|
18
|
+
# flips the output to the skip-side view: why the example was
|
|
19
|
+
# NOT run last time, and what would make it run next time.
|
|
20
|
+
# @param stdout [IO]
|
|
21
|
+
# @param stderr [IO]
|
|
22
|
+
# @return [Integer] exit status (0 = explanation printed,
|
|
23
|
+
# 1 = example not found / cache missing).
|
|
24
|
+
def self.run(args, stdout: $stdout, stderr: $stderr)
|
|
25
|
+
args = args.dup
|
|
26
|
+
not_run = !args.delete('--not-run').nil?
|
|
27
|
+
return print_help(stdout) if SnapshotHelpers.help_requested?(args)
|
|
28
|
+
|
|
29
|
+
loaded = SnapshotHelpers.load_snapshot(TestSift.cache_path, command: 'explain', stderr: stderr)
|
|
30
|
+
return 1 if loaded.nil?
|
|
31
|
+
|
|
32
|
+
snapshot, backend = loaded
|
|
33
|
+
match = find_example(snapshot.all_examples, args.first)
|
|
34
|
+
return no_match(args.first, snapshot.all_examples, stderr) if match.nil?
|
|
35
|
+
|
|
36
|
+
if not_run
|
|
37
|
+
print_not_run_explanation(stdout, match, snapshot, backend)
|
|
38
|
+
else
|
|
39
|
+
print_explanation(stdout, match, snapshot)
|
|
40
|
+
end
|
|
41
|
+
0
|
|
42
|
+
rescue Errno::EPIPE
|
|
43
|
+
# Downstream pipe (`... | head`) closed early -- routine in
|
|
44
|
+
# shell pipelines, not a failure. Exit 0 silently.
|
|
45
|
+
0
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
stderr.puts "explain: #{e.class}: #{e.message}"
|
|
48
|
+
1
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Internal helper for the tracer pipeline.
|
|
52
|
+
# @api private
|
|
53
|
+
def self.no_match(query, all_examples, stderr)
|
|
54
|
+
stderr.puts "explain: no example matching #{query.inspect}"
|
|
55
|
+
stderr.puts " cache has #{all_examples.size} examples; pass an example_id or substring of description"
|
|
56
|
+
1
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Internal helper for the tracer pipeline.
|
|
60
|
+
# @api private
|
|
61
|
+
def self.print_help(stdout)
|
|
62
|
+
stdout.puts <<~HELP
|
|
63
|
+
Usage: testsift explain [--not-run] <example_id_or_substring>
|
|
64
|
+
|
|
65
|
+
Show why an example is scheduled to run or skip. Matches against
|
|
66
|
+
example_id exactly first, then falls back to a substring match
|
|
67
|
+
on the example's full_description. Backend-aware: works under
|
|
68
|
+
`storage_backend :json` (default) and `storage_backend :sqlite`.
|
|
69
|
+
Requires a prior rspec run.
|
|
70
|
+
|
|
71
|
+
--not-run flips to the skip-side view: whether the example was
|
|
72
|
+
skipped on the last run (and why no run trigger fired), its last
|
|
73
|
+
recorded status, and what would make it run on the next rspec
|
|
74
|
+
invocation. The cache keeps only the most recent snapshot, so
|
|
75
|
+
"last status" reflects the last run, not a run history.
|
|
76
|
+
HELP
|
|
77
|
+
0
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Internal helper for the tracer pipeline.
|
|
81
|
+
# @api private
|
|
82
|
+
def self.find_example(all_examples, query)
|
|
83
|
+
return all_examples[query] if all_examples.key?(query)
|
|
84
|
+
|
|
85
|
+
all_examples.find do |id, meta|
|
|
86
|
+
meta = {} unless meta.is_a?(::Hash)
|
|
87
|
+
desc = SnapshotHelpers.example_description(meta) || ''
|
|
88
|
+
id.include?(query) || desc.include?(query)
|
|
89
|
+
end&.last
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Internal helper for the tracer pipeline.
|
|
93
|
+
# @api private
|
|
94
|
+
def self.print_explanation(stdout, meta, snapshot)
|
|
95
|
+
meta = {} unless meta.is_a?(::Hash)
|
|
96
|
+
id = SnapshotHelpers.fetch_meta(meta, 'example_id', 'id')
|
|
97
|
+
format_lines(meta, skipped: skipped?(id, snapshot)).each { |line| stdout.puts line }
|
|
98
|
+
print_dependency_summary(stdout, meta, snapshot)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Internal helper for the tracer pipeline.
|
|
102
|
+
# @api private
|
|
103
|
+
def self.format_lines(meta, skipped: false)
|
|
104
|
+
id = SnapshotHelpers.fetch_meta(meta, 'example_id', 'id') || '<unknown>'
|
|
105
|
+
file, line = SnapshotHelpers.example_location(meta)
|
|
106
|
+
status = last_status(meta)
|
|
107
|
+
[
|
|
108
|
+
"id: #{id}",
|
|
109
|
+
"description: #{SnapshotHelpers.example_description(meta)}",
|
|
110
|
+
"location: #{file}:#{line}",
|
|
111
|
+
"last status: #{status}",
|
|
112
|
+
"run reason: #{run_reason_field(meta, skipped)}"
|
|
113
|
+
]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The run-side `run reason:` value. For an example the filter
|
|
117
|
+
# SKIPPED on the last run, the persisted run_reason is the
|
|
118
|
+
# carry-forward reason seeded from an earlier snapshot -- it says
|
|
119
|
+
# why the example ran back then, not why it is in its current
|
|
120
|
+
# state. Printing it bare would misreport a skipped example as
|
|
121
|
+
# having a current run trigger, so flag it and point at the
|
|
122
|
+
# `--not-run` skip-side view instead.
|
|
123
|
+
# @api private
|
|
124
|
+
def self.run_reason_field(meta, skipped)
|
|
125
|
+
reason = SnapshotHelpers.fetch_meta(meta, 'run_reason') || '<not recorded>'
|
|
126
|
+
return reason unless skipped
|
|
127
|
+
|
|
128
|
+
"#{reason} (carried forward from an earlier run; " \
|
|
129
|
+
'this example was SKIPPED last run -- use --not-run for the skip-side view)'
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Internal helper for the tracer pipeline.
|
|
133
|
+
# @api private
|
|
134
|
+
def self.last_status(meta)
|
|
135
|
+
SnapshotHelpers.dig_meta(meta, 'execution_result', 'status') ||
|
|
136
|
+
SnapshotHelpers.fetch_meta(meta, 'status') || 'unknown'
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The `--not-run` view. Deliberately does NOT print the
|
|
140
|
+
# `run reason:` field from all_examples meta: for a skipped
|
|
141
|
+
# example that field is the carry-forward PRIOR-run reason
|
|
142
|
+
# (seeded from the previous snapshot), so echoing it here would
|
|
143
|
+
# misreport why the example did not run. Everything below is
|
|
144
|
+
# derived from the per-run membership sets instead
|
|
145
|
+
# (skipped_examples / filtered_examples / the status id-sets).
|
|
146
|
+
def self.print_not_run_explanation(stdout, meta, snapshot, backend)
|
|
147
|
+
meta = {} unless meta.is_a?(::Hash)
|
|
148
|
+
id = SnapshotHelpers.fetch_meta(meta, 'example_id', 'id') || '<unknown>'
|
|
149
|
+
filter_persisted = SnapshotHelpers.filter_decisions_persisted?(backend)
|
|
150
|
+
print_not_run_header(stdout, meta, id)
|
|
151
|
+
stdout.puts last_run_line(id, snapshot, filter_persisted: filter_persisted)
|
|
152
|
+
not_run_detail_lines(id, snapshot).each { |detail| stdout.puts detail }
|
|
153
|
+
stdout.puts "last status: #{last_status(meta)} (most recent snapshot; this cache keeps only the last run)"
|
|
154
|
+
stdout.puts "next run: #{next_run_line(id, snapshot)}"
|
|
155
|
+
print_dependency_summary(stdout, meta, snapshot)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Internal helper for the tracer pipeline.
|
|
159
|
+
# @api private
|
|
160
|
+
def self.print_not_run_header(stdout, meta, id)
|
|
161
|
+
file, line = SnapshotHelpers.example_location(meta)
|
|
162
|
+
stdout.puts "id: #{id}"
|
|
163
|
+
stdout.puts "description: #{SnapshotHelpers.example_description(meta)}"
|
|
164
|
+
stdout.puts "location: #{file}:#{line}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Detail lines printed under `last run:`: the itemized skip
|
|
168
|
+
# derivation for a skipped id, a run-side hint for an id that
|
|
169
|
+
# actually ran, nothing for the no-decision fallbacks.
|
|
170
|
+
# @return [Array<String>]
|
|
171
|
+
def self.not_run_detail_lines(id, snapshot)
|
|
172
|
+
return skip_reason_lines(id, snapshot) if skipped?(id, snapshot)
|
|
173
|
+
|
|
174
|
+
return [" it was not skipped; run 'testsift explain #{id}' for the run-side view"] if ran_reason(id, snapshot)
|
|
175
|
+
|
|
176
|
+
[]
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# One `last run:` line for the `--not-run` view, derived from
|
|
180
|
+
# per-run membership: skipped_examples (the filter skipped it),
|
|
181
|
+
# filtered_examples (it ran, with the recorded reason), or
|
|
182
|
+
# neither. The neither case splits on `filter_persisted`: a
|
|
183
|
+
# backend that persists filter decisions (json) with an empty
|
|
184
|
+
# decision set means the last run recorded no decision for this
|
|
185
|
+
# id (a cold run persists empty sets by design, and an id absent
|
|
186
|
+
# from the last run has no entry either) -- only a
|
|
187
|
+
# non-persisting backend (sqlite) earns the storage-backend
|
|
188
|
+
# wording.
|
|
189
|
+
# @return [String]
|
|
190
|
+
def self.last_run_line(id, snapshot, filter_persisted:)
|
|
191
|
+
return 'last run: skipped (cache hit)' if skipped?(id, snapshot)
|
|
192
|
+
|
|
193
|
+
reason = ran_reason(id, snapshot)
|
|
194
|
+
return "last run: ran -- #{reason}" if reason
|
|
195
|
+
return 'last run: <not recorded by this storage backend>' unless filter_persisted
|
|
196
|
+
|
|
197
|
+
'last run: no filter decision recorded (cold run, or this example was not part of it)'
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Itemized derivation of WHY the filter skipped the example.
|
|
201
|
+
# Sound because a skip logically implies every trigger in the
|
|
202
|
+
# engine's precedence chain declined: no whole-suite invalidator
|
|
203
|
+
# fired, no boot file changed (the engine ORs the boot-set check
|
|
204
|
+
# into whole-suite invalidation), the example carried no
|
|
205
|
+
# always-re-run status, none of its tracked dependency files
|
|
206
|
+
# changed, and its tracked environment snapshot was unchanged.
|
|
207
|
+
# @return [Array<String>]
|
|
208
|
+
def self.skip_reason_lines(id, snapshot)
|
|
209
|
+
deps = Array((snapshot.dependency || {})[id])
|
|
210
|
+
[
|
|
211
|
+
'skip reason: no run trigger fired last run:',
|
|
212
|
+
' - whole-suite invalidators (Gemfile.lock, .ruby-version, .testsift, gem version): unchanged',
|
|
213
|
+
' - boot set: no boot file changed',
|
|
214
|
+
' - prior status: not failed / flaky / pending / interrupted',
|
|
215
|
+
" - dependency files: #{deps.size} tracked, none changed",
|
|
216
|
+
' - environment snapshot: unchanged for this example'
|
|
217
|
+
]
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Prediction line for the NEXT run, from the persisted status
|
|
221
|
+
# sets (the inputs to the next run's always-re-run triggers).
|
|
222
|
+
# @return [String]
|
|
223
|
+
def self.next_run_line(id, snapshot)
|
|
224
|
+
reason = always_rerun_reason(id, snapshot)
|
|
225
|
+
return "will re-run regardless (#{reason} last run)" if reason
|
|
226
|
+
|
|
227
|
+
'runs only if a dependency, whole-suite invalidator, boot file, or tracked env var changes'
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# First always-re-run status the id carries, in the filter's
|
|
231
|
+
# precedence order (interrupted > flaky > failed > pending), or
|
|
232
|
+
# nil when the example has no status-based re-run trigger.
|
|
233
|
+
# @return [String, nil]
|
|
234
|
+
def self.always_rerun_reason(id, snapshot)
|
|
235
|
+
return 'interrupted' if in_id_set?(snapshot.interrupted_examples, id)
|
|
236
|
+
return 'flaky' if in_id_set?(snapshot.flaky_examples, id)
|
|
237
|
+
return 'failed' if in_id_set?(snapshot.failed_examples, id)
|
|
238
|
+
return 'pending' if in_id_set?(snapshot.pending_examples, id)
|
|
239
|
+
|
|
240
|
+
nil
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Internal helper for the tracer pipeline.
|
|
244
|
+
# @api private
|
|
245
|
+
def self.skipped?(id, snapshot)
|
|
246
|
+
in_id_set?(snapshot.skipped_examples, id)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Internal helper for the tracer pipeline.
|
|
250
|
+
# @api private
|
|
251
|
+
def self.ran_reason(id, snapshot)
|
|
252
|
+
SnapshotHelpers.fetch_meta(snapshot.filtered_examples || {}, id)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Internal helper for the tracer pipeline.
|
|
256
|
+
# @api private
|
|
257
|
+
def self.in_id_set?(id_set, id)
|
|
258
|
+
(id_set || []).include?(id)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Internal helper for the tracer pipeline.
|
|
262
|
+
# @api private
|
|
263
|
+
def self.print_dependency_summary(stdout, meta, snapshot)
|
|
264
|
+
id = SnapshotHelpers.fetch_meta(meta, 'example_id', 'id')
|
|
265
|
+
deps = snapshot.dependency || {}
|
|
266
|
+
files = Array(deps[id])
|
|
267
|
+
stdout.puts "dependencies: #{files.size} files tracked"
|
|
268
|
+
files.first(10).each { |f| stdout.puts " - #{f}" }
|
|
269
|
+
stdout.puts " ... (#{files.size - 10} more)" if files.size > 10
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TestSift
|
|
4
|
+
# Internal CLI — see {TestSift} for the user-facing surface.
|
|
5
|
+
# @api private
|
|
6
|
+
module CLI
|
|
7
|
+
# `testsift report:open` — open the HTML report in the default
|
|
8
|
+
# browser. Resolves `report_path/index.html` and dispatches via
|
|
9
|
+
# `open` (macOS) / `xdg-open` (Linux). Falls back to printing the
|
|
10
|
+
# path when no opener is available.
|
|
11
|
+
module ReportOpen
|
|
12
|
+
# @param args [Array<String>] sub-command args (`-h` / `--help`).
|
|
13
|
+
# @param stdout [IO]
|
|
14
|
+
# @param stderr [IO]
|
|
15
|
+
# @return [Integer] exit status (0 = opened or path printed,
|
|
16
|
+
# 1 = report missing).
|
|
17
|
+
def self.run(args, stdout: $stdout, stderr: $stderr)
|
|
18
|
+
return print_help(stdout) if args.include?('-h') || args.include?('--help')
|
|
19
|
+
|
|
20
|
+
report_path = TestSift.report_path
|
|
21
|
+
index_path = File.join(report_path, 'index.html')
|
|
22
|
+
|
|
23
|
+
unless File.file?(index_path)
|
|
24
|
+
stderr.puts "report:open: no report at #{index_path}"
|
|
25
|
+
stderr.puts ' run rspec first to generate the HTML report'
|
|
26
|
+
return 1
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
opener = detect_opener
|
|
30
|
+
if opener.nil?
|
|
31
|
+
stdout.puts "report:open: report at #{index_path}"
|
|
32
|
+
stdout.puts ' no opener detected (open / xdg-open); open the path manually'
|
|
33
|
+
return 0
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
launch(opener, index_path, stdout, stderr)
|
|
37
|
+
rescue Errno::EPIPE
|
|
38
|
+
# Downstream pipe (`... | head`) closed early -- routine in
|
|
39
|
+
# shell pipelines, not a failure. Exit 0 silently.
|
|
40
|
+
0
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
stderr.puts "report:open: #{e.class}: #{e.message}"
|
|
43
|
+
1
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Internal helper for the tracer pipeline.
|
|
47
|
+
# @api private
|
|
48
|
+
def self.launch(opener, index_path, stdout, stderr)
|
|
49
|
+
if system(opener, index_path, out: File::NULL, err: File::NULL)
|
|
50
|
+
stdout.puts "report:open: opened #{index_path} via #{opener}"
|
|
51
|
+
0
|
|
52
|
+
else
|
|
53
|
+
stderr.puts "report:open: failed to launch #{opener} #{index_path}"
|
|
54
|
+
1
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Internal helper for the tracer pipeline.
|
|
59
|
+
# @api private
|
|
60
|
+
def self.print_help(stdout)
|
|
61
|
+
stdout.puts <<~HELP
|
|
62
|
+
Usage: testsift report:open
|
|
63
|
+
|
|
64
|
+
Open the HTML report (`report_path/index.html`) in the default
|
|
65
|
+
browser. Uses `open` on macOS and `xdg-open` on Linux. Prints
|
|
66
|
+
the path and exits 0 when no opener is available.
|
|
67
|
+
HELP
|
|
68
|
+
0
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Returns the opener binary name on PATH, or nil. Checked in
|
|
72
|
+
# priority order: macOS `open`, then Linux `xdg-open`. Windows
|
|
73
|
+
# is unsupported per `COMPATIBILITY_MATRIX.md`'s explicit drop;
|
|
74
|
+
# users on Windows see the print-the-path fallback.
|
|
75
|
+
def self.detect_opener
|
|
76
|
+
%w[open xdg-open].find { |bin| which(bin) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Internal helper for the tracer pipeline.
|
|
80
|
+
# @api private
|
|
81
|
+
def self.which(binary)
|
|
82
|
+
found = ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).any? do |dir|
|
|
83
|
+
path = File.join(dir, binary)
|
|
84
|
+
File.file?(path) && File.executable?(path)
|
|
85
|
+
end
|
|
86
|
+
found ? binary : nil
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|