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,318 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
require 'testsift/cli/snapshot_helpers'
|
|
6
|
+
require 'testsift/tracker/whole_suite_invalidators'
|
|
7
|
+
|
|
8
|
+
module TestSift
|
|
9
|
+
# Internal CLI -- see {TestSift} for the user-facing surface.
|
|
10
|
+
# @api private
|
|
11
|
+
module CLI
|
|
12
|
+
# `testsift blast-radius <file> [<file> ...]` -- show which
|
|
13
|
+
# examples a change to each given file would re-run on the next
|
|
14
|
+
# rspec invocation. Reads the persisted reverse-dependency map
|
|
15
|
+
# (file -> examples) plus the whole-suite invalidator watch list
|
|
16
|
+
# and the boot set, so whole-suite triggers (Gemfile.lock, boot
|
|
17
|
+
# files) report "re-runs all N examples" instead of under-reporting
|
|
18
|
+
# via a bare reverse-dependency lookup. Reports the file-change
|
|
19
|
+
# trigger only; examples that always re-run for status reasons
|
|
20
|
+
# (failed / flaky / pending / interrupted) are not included.
|
|
21
|
+
# Backend-agnostic: dispatches through
|
|
22
|
+
# {TestSift::Storage::Backend.build} (via
|
|
23
|
+
# {SnapshotHelpers.load_snapshot}) so `storage_backend :sqlite`
|
|
24
|
+
# resolves the latest run from the meta table instead of the
|
|
25
|
+
# JsonBackend-only `last_run.json` file.
|
|
26
|
+
module BlastRadius
|
|
27
|
+
# Internal constant.
|
|
28
|
+
# @api private
|
|
29
|
+
USAGE = 'testsift blast-radius [--list|--json] <file> [<file> ...]'
|
|
30
|
+
|
|
31
|
+
# Statuses whose blast radius is the entire suite (a change to
|
|
32
|
+
# the file invalidates every cached example, not just tracked
|
|
33
|
+
# dependents).
|
|
34
|
+
# @api private
|
|
35
|
+
WHOLE_SUITE_STATUSES = %w[whole_suite_invalidator boot_file].freeze
|
|
36
|
+
|
|
37
|
+
# @param args [Array<String>] sub-command args. `--list` / `--json`
|
|
38
|
+
# flags plus one or more file paths (relative to the project
|
|
39
|
+
# root or absolute).
|
|
40
|
+
# @param stdout [IO]
|
|
41
|
+
# @param stderr [IO]
|
|
42
|
+
# @return [Integer] exit status (0 = radius printed, including
|
|
43
|
+
# untracked / zero-dependent files; 1 = no files given, unknown
|
|
44
|
+
# option, cache missing, or schema mismatch).
|
|
45
|
+
def self.run(args, stdout: $stdout, stderr: $stderr)
|
|
46
|
+
return print_help(stdout) if SnapshotHelpers.help_requested?(args)
|
|
47
|
+
|
|
48
|
+
parsed = parse_args(args)
|
|
49
|
+
return unknown_option(parsed[:unknown], stderr) unless parsed[:unknown].nil?
|
|
50
|
+
return no_files(stderr) if parsed[:files].empty?
|
|
51
|
+
|
|
52
|
+
execute(parsed, stdout, stderr)
|
|
53
|
+
rescue Errno::EPIPE
|
|
54
|
+
# Downstream pipe (`... | head`, `... | jq`) closed early --
|
|
55
|
+
# routine in shell pipelines (the help text promotes them),
|
|
56
|
+
# not a failure. Exit 0 silently.
|
|
57
|
+
0
|
|
58
|
+
rescue StandardError => e
|
|
59
|
+
stderr.puts "blast-radius: #{e.class}: #{e.message}"
|
|
60
|
+
1
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Internal helper for the tracer pipeline.
|
|
64
|
+
# @api private
|
|
65
|
+
def self.execute(parsed, stdout, stderr)
|
|
66
|
+
loaded = SnapshotHelpers.load_snapshot(TestSift.cache_path, command: 'blast-radius', stderr: stderr)
|
|
67
|
+
return 1 if loaded.nil?
|
|
68
|
+
|
|
69
|
+
snapshot, = loaded
|
|
70
|
+
radii = parsed[:files].map { |file| radius_for(file, snapshot) }
|
|
71
|
+
if parsed[:json]
|
|
72
|
+
stdout.puts JSON.pretty_generate(json_payload(radii, snapshot))
|
|
73
|
+
else
|
|
74
|
+
print_report(stdout, radii, snapshot, list: parsed[:list])
|
|
75
|
+
end
|
|
76
|
+
0
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Internal helper for the tracer pipeline.
|
|
80
|
+
# @api private
|
|
81
|
+
def self.parse_args(args)
|
|
82
|
+
parsed = { list: false, json: false, files: [], unknown: nil }
|
|
83
|
+
args.each do |arg|
|
|
84
|
+
case arg
|
|
85
|
+
when '--list' then parsed[:list] = true
|
|
86
|
+
when '--json' then parsed[:json] = true
|
|
87
|
+
when /\A-/ then parsed[:unknown] ||= arg
|
|
88
|
+
else parsed[:files] << arg
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
parsed
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Internal helper for the tracer pipeline.
|
|
95
|
+
# @api private
|
|
96
|
+
def self.unknown_option(arg, stderr)
|
|
97
|
+
stderr.puts "blast-radius: unknown option #{arg.inspect}"
|
|
98
|
+
stderr.puts " usage: #{USAGE}"
|
|
99
|
+
1
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Internal helper for the tracer pipeline.
|
|
103
|
+
# @api private
|
|
104
|
+
def self.no_files(stderr)
|
|
105
|
+
stderr.puts 'blast-radius: no file paths given'
|
|
106
|
+
stderr.puts " usage: #{USAGE}"
|
|
107
|
+
1
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Internal helper for the tracer pipeline.
|
|
111
|
+
# @api private
|
|
112
|
+
def self.print_help(stdout)
|
|
113
|
+
stdout.puts <<~HELP
|
|
114
|
+
Usage: #{USAGE}
|
|
115
|
+
|
|
116
|
+
Show which examples a change to each given file would re-run on
|
|
117
|
+
the next rspec invocation. Reports the file-change trigger only;
|
|
118
|
+
examples that always re-run for status reasons (failed / flaky /
|
|
119
|
+
pending / interrupted) are not included. Paths may be relative to
|
|
120
|
+
the project root or absolute. Whole-suite invalidators
|
|
121
|
+
(Gemfile.lock, .ruby-version, .testsift) and boot files
|
|
122
|
+
re-run every example. Backend-aware: works under
|
|
123
|
+
`storage_backend :json` (default) and `storage_backend :sqlite`.
|
|
124
|
+
Requires a prior rspec run.
|
|
125
|
+
|
|
126
|
+
Multi-file input composes with git:
|
|
127
|
+
|
|
128
|
+
git diff --name-only main | xargs bundle exec testsift blast-radius
|
|
129
|
+
|
|
130
|
+
Options:
|
|
131
|
+
--list Enumerate affected examples (location + description).
|
|
132
|
+
--json Emit one machine-readable JSON document to stdout.
|
|
133
|
+
HELP
|
|
134
|
+
0
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Convert a user-supplied path into the cache's file_name
|
|
138
|
+
# convention (project-relative with a leading `/`, absolute when
|
|
139
|
+
# outside the project root). Computed at command runtime against
|
|
140
|
+
# `TestSift.root` -- `SourceFile.file_name` is unsuitable here
|
|
141
|
+
# because its PROJECT_ROOT_REGEX freezes the root at gem-require
|
|
142
|
+
# time, before the CLI loads the project's `.testsift` config.
|
|
143
|
+
# @api private
|
|
144
|
+
def self.normalize_file_name(path)
|
|
145
|
+
root = TestSift.root
|
|
146
|
+
abs = File.expand_path(path, root)
|
|
147
|
+
return abs unless abs.start_with?("#{root}/")
|
|
148
|
+
|
|
149
|
+
"/#{abs[(root.length + 1)..]}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Classify one input file and collect its affected examples.
|
|
153
|
+
# Precedence: whole-suite invalidator watch file, then boot file
|
|
154
|
+
# (boot-set changes OR into whole-suite invalidation), then
|
|
155
|
+
# tracked reverse dependents, then untracked / no-dependents.
|
|
156
|
+
# Note the key conventions: `boot_set` keys are project-relative
|
|
157
|
+
# (no leading slash) while `reverse_dependency` / `all_files`
|
|
158
|
+
# keys carry a leading slash.
|
|
159
|
+
# @api private
|
|
160
|
+
def self.radius_for(path, snapshot)
|
|
161
|
+
file_name = normalize_file_name(path)
|
|
162
|
+
relative = file_name.delete_prefix('/')
|
|
163
|
+
|
|
164
|
+
if Tracker::WholeSuiteInvalidators::WATCH_FILES.include?(relative)
|
|
165
|
+
whole_suite_radius(path, file_name, 'whole_suite_invalidator', snapshot)
|
|
166
|
+
elsif (snapshot.boot_set || {}).key?(relative)
|
|
167
|
+
whole_suite_radius(path, file_name, 'boot_file', snapshot)
|
|
168
|
+
else
|
|
169
|
+
dependent_radius(path, file_name, snapshot)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Internal helper for the tracer pipeline.
|
|
174
|
+
# @api private
|
|
175
|
+
def self.whole_suite_radius(path, file_name, status, snapshot)
|
|
176
|
+
ids = (snapshot.all_examples || {}).keys
|
|
177
|
+
{ path: path, file_name: file_name, status: status,
|
|
178
|
+
examples: example_entries(ids, snapshot) }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Internal helper for the tracer pipeline.
|
|
182
|
+
# @api private
|
|
183
|
+
def self.dependent_radius(path, file_name, snapshot)
|
|
184
|
+
ids = (snapshot.reverse_dependency || {})[file_name]
|
|
185
|
+
if ids.nil? || ids.empty?
|
|
186
|
+
status = (snapshot.all_files || {}).key?(file_name) ? 'no_dependents' : 'untracked'
|
|
187
|
+
{ path: path, file_name: file_name, status: status, examples: [] }
|
|
188
|
+
else
|
|
189
|
+
{ path: path, file_name: file_name, status: 'tracked',
|
|
190
|
+
examples: example_entries(ids, snapshot) }
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Internal helper for the tracer pipeline.
|
|
195
|
+
# @api private
|
|
196
|
+
def self.example_entries(ids, snapshot)
|
|
197
|
+
all = snapshot.all_examples || {}
|
|
198
|
+
ids.map { |id| example_entry(id.to_s, all[id]) }
|
|
199
|
+
.sort_by { |entry| [entry[:location], entry[:description]] }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Enrich one example_id from the persisted all_examples meta.
|
|
203
|
+
# A dangling id (present in reverse_dependency but missing from
|
|
204
|
+
# all_examples -- stale or partially-written cache) degrades to
|
|
205
|
+
# `<unknown>` fields instead of raising.
|
|
206
|
+
# @api private
|
|
207
|
+
def self.example_entry(id, meta)
|
|
208
|
+
meta = {} unless meta.is_a?(::Hash)
|
|
209
|
+
file, line = SnapshotHelpers.example_location(meta)
|
|
210
|
+
{
|
|
211
|
+
example_id: id,
|
|
212
|
+
spec_file: file || '<unknown>',
|
|
213
|
+
location: file.nil? ? '<unknown>' : "#{file}:#{line}",
|
|
214
|
+
description: SnapshotHelpers.example_description(meta) || '<unknown>'
|
|
215
|
+
}
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Internal helper for the tracer pipeline.
|
|
219
|
+
# @api private
|
|
220
|
+
def self.print_report(stdout, radii, snapshot, list:)
|
|
221
|
+
radii.each do |radius|
|
|
222
|
+
stdout.puts summary_line(radius)
|
|
223
|
+
next unless list
|
|
224
|
+
|
|
225
|
+
radius[:examples].each do |entry|
|
|
226
|
+
stdout.puts " #{entry[:location]} #{entry[:description]}"
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
print_total(stdout, radii, snapshot) if radii.size > 1
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Internal helper for the tracer pipeline.
|
|
233
|
+
# The untracked wording sticks to what the tracer can actually
|
|
234
|
+
# observe: absence of the file from the cache. It must NOT claim
|
|
235
|
+
# the file "never loaded" -- files consumed outside the hooked
|
|
236
|
+
# surface (spec_helper.rb itself, which executes before coverage
|
|
237
|
+
# tracking starts; reads via unhooked APIs, C extensions, or
|
|
238
|
+
# other threads) load fine yet never appear in the cache. See
|
|
239
|
+
# the soundness model in ARCHITECTURE.md.
|
|
240
|
+
# @api private
|
|
241
|
+
def self.summary_line(radius)
|
|
242
|
+
file_name = radius[:file_name]
|
|
243
|
+
count = radius[:examples].size
|
|
244
|
+
case radius[:status]
|
|
245
|
+
when 'whole_suite_invalidator'
|
|
246
|
+
"#{file_name}: whole-suite invalidator -- re-runs all #{count} examples"
|
|
247
|
+
when 'boot_file'
|
|
248
|
+
"#{file_name}: boot file -- re-runs all #{count} examples"
|
|
249
|
+
when 'untracked'
|
|
250
|
+
"#{file_name}: not tracked in cache (no recorded dependents -- the tracer never " \
|
|
251
|
+
'observed it as an input; see the soundness model in ARCHITECTURE.md)'
|
|
252
|
+
when 'no_dependents'
|
|
253
|
+
"#{file_name}: 0 examples (no tracked dependents)"
|
|
254
|
+
else
|
|
255
|
+
"#{file_name}: #{count} examples across #{spec_file_count(radius[:examples])} spec files"
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Internal helper for the tracer pipeline.
|
|
260
|
+
# @api private
|
|
261
|
+
def self.print_total(stdout, radii, snapshot)
|
|
262
|
+
if radii.any? { |radius| WHOLE_SUITE_STATUSES.include?(radius[:status]) }
|
|
263
|
+
total = (snapshot.all_examples || {}).size
|
|
264
|
+
stdout.puts "total: all #{total} examples (whole-suite invalidator)"
|
|
265
|
+
else
|
|
266
|
+
union = union_entries(radii)
|
|
267
|
+
stdout.puts "total: #{union.size} unique examples across #{spec_file_count(union)} spec files"
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Internal helper for the tracer pipeline.
|
|
272
|
+
# @api private
|
|
273
|
+
def self.union_entries(radii)
|
|
274
|
+
radii.flat_map { |radius| radius[:examples] }.uniq { |entry| entry[:example_id] }
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Internal helper for the tracer pipeline.
|
|
278
|
+
# @api private
|
|
279
|
+
def self.spec_file_count(entries)
|
|
280
|
+
entries.map { |entry| entry[:spec_file] }.uniq.size
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Internal helper for the tracer pipeline.
|
|
284
|
+
# @api private
|
|
285
|
+
def self.json_payload(radii, snapshot)
|
|
286
|
+
union = union_entries(radii)
|
|
287
|
+
{
|
|
288
|
+
'files' => radii.map { |radius| file_json(radius) },
|
|
289
|
+
'total' => {
|
|
290
|
+
'example_count' => union.size,
|
|
291
|
+
'spec_file_count' => spec_file_count(union),
|
|
292
|
+
'whole_suite' => radii.any? { |radius| WHOLE_SUITE_STATUSES.include?(radius[:status]) },
|
|
293
|
+
'all_examples_count' => (snapshot.all_examples || {}).size
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Internal helper for the tracer pipeline.
|
|
299
|
+
# @api private
|
|
300
|
+
def self.file_json(radius)
|
|
301
|
+
{
|
|
302
|
+
'path' => radius[:path],
|
|
303
|
+
'file_name' => radius[:file_name],
|
|
304
|
+
'status' => radius[:status],
|
|
305
|
+
'example_count' => radius[:examples].size,
|
|
306
|
+
'spec_file_count' => spec_file_count(radius[:examples]),
|
|
307
|
+
'examples' => radius[:examples].map do |entry|
|
|
308
|
+
{
|
|
309
|
+
'example_id' => entry[:example_id],
|
|
310
|
+
'location' => entry[:location],
|
|
311
|
+
'description' => entry[:description]
|
|
312
|
+
}
|
|
313
|
+
end
|
|
314
|
+
}
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module TestSift
|
|
6
|
+
# Internal CLI — see {TestSift} for the user-facing surface.
|
|
7
|
+
# @api private
|
|
8
|
+
module CLI
|
|
9
|
+
# `testsift cache:clear` — remove cache, coverage, and report
|
|
10
|
+
# directories. Prompts for confirmation unless `--yes` is passed.
|
|
11
|
+
# The next rspec run is a cold run.
|
|
12
|
+
module CacheClear
|
|
13
|
+
# @param args [Array<String>] sub-command args (`-y` / `--yes`
|
|
14
|
+
# skips confirmation; `-h` / `--help` prints help).
|
|
15
|
+
# @param stdout [IO]
|
|
16
|
+
# @param stderr [IO]
|
|
17
|
+
# @return [Integer] exit status (0 = success / aborted, 1 = error).
|
|
18
|
+
def self.run(args, stdout: $stdout, stderr: $stderr)
|
|
19
|
+
return print_help(stdout) if args.include?('-h') || args.include?('--help')
|
|
20
|
+
|
|
21
|
+
existing = existing_targets
|
|
22
|
+
return nothing_to_remove(stdout) if existing.empty?
|
|
23
|
+
|
|
24
|
+
announce(stdout, existing)
|
|
25
|
+
return aborted(stdout) unless skip_confirmation?(args) || confirm?(stdout)
|
|
26
|
+
|
|
27
|
+
remove_each(stdout, existing)
|
|
28
|
+
0
|
|
29
|
+
rescue Errno::EPIPE
|
|
30
|
+
# Downstream pipe (`... | head`) closed early -- routine in
|
|
31
|
+
# shell pipelines, not a failure. Exit 0 silently.
|
|
32
|
+
0
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
stderr.puts "cache:clear: #{e.class}: #{e.message}"
|
|
35
|
+
1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns true when any of the documented skip-confirmation
|
|
39
|
+
# flags is present. `--yes` / `-y` is the canonical form;
|
|
40
|
+
# `--force` / `-f` is the Unix-conventional synonym accepted
|
|
41
|
+
# so users' muscle memory works. Either form skips the
|
|
42
|
+
# interactive `Proceed? [y/N]` prompt.
|
|
43
|
+
# @api private
|
|
44
|
+
SKIP_CONFIRMATION_FLAGS = %w[--yes -y --force -f].freeze
|
|
45
|
+
|
|
46
|
+
# @api private
|
|
47
|
+
def self.skip_confirmation?(args)
|
|
48
|
+
args.intersect?(SKIP_CONFIRMATION_FLAGS)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Internal helper for the tracer pipeline.
|
|
52
|
+
# @api private
|
|
53
|
+
def self.existing_targets
|
|
54
|
+
[TestSift.cache_path, TestSift.coverage_path, TestSift.report_path]
|
|
55
|
+
.select { |path| File.directory?(path) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Internal helper for the tracer pipeline.
|
|
59
|
+
# @api private
|
|
60
|
+
def self.nothing_to_remove(stdout)
|
|
61
|
+
stdout.puts 'cache:clear: nothing to remove (cache directories do not exist)'
|
|
62
|
+
0
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Internal helper for the tracer pipeline.
|
|
66
|
+
# @api private
|
|
67
|
+
def self.announce(stdout, existing)
|
|
68
|
+
stdout.puts 'cache:clear: will remove:'
|
|
69
|
+
existing.each { |path| stdout.puts " - #{path}" }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Internal helper for the tracer pipeline.
|
|
73
|
+
# @api private
|
|
74
|
+
def self.confirm?(stdout)
|
|
75
|
+
stdout.print 'Proceed? [y/N] '
|
|
76
|
+
stdout.flush
|
|
77
|
+
response = $stdin.gets&.chomp&.downcase
|
|
78
|
+
%w[y yes].include?(response)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Internal helper for the tracer pipeline.
|
|
82
|
+
# @api private
|
|
83
|
+
def self.aborted(stdout)
|
|
84
|
+
stdout.puts 'cache:clear: aborted'
|
|
85
|
+
0
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Internal helper for the tracer pipeline.
|
|
89
|
+
# @api private
|
|
90
|
+
def self.remove_each(stdout, existing)
|
|
91
|
+
existing.each do |path|
|
|
92
|
+
FileUtils.rm_rf(path)
|
|
93
|
+
stdout.puts " removed #{path}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Internal helper for the tracer pipeline.
|
|
98
|
+
# @api private
|
|
99
|
+
def self.print_help(stdout)
|
|
100
|
+
stdout.puts <<~HELP
|
|
101
|
+
Usage: testsift cache:clear [--yes | --force]
|
|
102
|
+
|
|
103
|
+
Remove cache, coverage, and report directories. The next rspec
|
|
104
|
+
run will be a cold run (full re-execution + cache rebuild).
|
|
105
|
+
|
|
106
|
+
Options:
|
|
107
|
+
-y, --yes Skip the confirmation prompt.
|
|
108
|
+
-f, --force Synonym for --yes.
|
|
109
|
+
HELP
|
|
110
|
+
0
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'testsift/storage/backend'
|
|
4
|
+
require 'testsift/storage/json_backend'
|
|
5
|
+
require 'testsift/storage/schema'
|
|
6
|
+
require 'testsift/storage/sqlite_backend' if RUBY_ENGINE == 'ruby'
|
|
7
|
+
|
|
8
|
+
module TestSift
|
|
9
|
+
# Internal CLI — see {TestSift} for the user-facing surface.
|
|
10
|
+
# @api private
|
|
11
|
+
module CLI
|
|
12
|
+
# `testsift cache:info` — show cache size, last run, and
|
|
13
|
+
# invalidation stats. Backend-agnostic: dispatches through
|
|
14
|
+
# {TestSift::Storage::Backend.build} so `storage_backend
|
|
15
|
+
# :sqlite` reports the populated cache instead of the false
|
|
16
|
+
# "no cache yet" the JsonBackend-only path used to emit.
|
|
17
|
+
module CacheInfo
|
|
18
|
+
# @param args [Array<String>] sub-command args (`-h` / `--help`).
|
|
19
|
+
# @param stdout [IO]
|
|
20
|
+
# @param stderr [IO]
|
|
21
|
+
# @return [Integer] exit status (0 = success).
|
|
22
|
+
def self.run(args, stdout: $stdout, stderr: $stderr)
|
|
23
|
+
return print_help(stdout) if args.include?('-h') || args.include?('--help')
|
|
24
|
+
|
|
25
|
+
cache_path = TestSift.cache_path
|
|
26
|
+
stdout.puts "cache_path: #{cache_path}"
|
|
27
|
+
stdout.puts "size: #{format_bytes(directory_size(cache_path))}"
|
|
28
|
+
|
|
29
|
+
backend = Storage::Backend.build(cache_path: cache_path, configuration: TestSift)
|
|
30
|
+
run_id = backend.last_run_id
|
|
31
|
+
if run_id.nil? || run_id.to_s.empty?
|
|
32
|
+
stdout.puts 'last_run: no cache yet (run rspec first)'
|
|
33
|
+
return 0
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
stdout.puts "last_run: #{run_id}"
|
|
37
|
+
print_example_count(stdout, backend)
|
|
38
|
+
0
|
|
39
|
+
rescue Errno::EPIPE
|
|
40
|
+
# Downstream pipe (`... | head`) closed early -- routine in
|
|
41
|
+
# shell pipelines, not a failure. Exit 0 silently.
|
|
42
|
+
0
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
stderr.puts "cache:info: #{e.class}: #{e.message}"
|
|
45
|
+
1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Internal helper for the tracer pipeline.
|
|
49
|
+
# @api private
|
|
50
|
+
def self.print_help(stdout)
|
|
51
|
+
stdout.puts <<~HELP
|
|
52
|
+
Usage: testsift cache:info
|
|
53
|
+
|
|
54
|
+
Show the on-disk cache size, the last run id, and example counts
|
|
55
|
+
for the most recent run. Backend-aware: works under
|
|
56
|
+
`storage_backend :json` (default) and `storage_backend :sqlite`.
|
|
57
|
+
Read-only; does not modify the cache.
|
|
58
|
+
HELP
|
|
59
|
+
0
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Internal helper for the tracer pipeline.
|
|
63
|
+
# @api private
|
|
64
|
+
def self.print_example_count(stdout, backend)
|
|
65
|
+
snapshot = backend.load_graph(schema_version: Storage::Schema::CURRENT)
|
|
66
|
+
if snapshot.nil?
|
|
67
|
+
stdout.puts 'examples: <unknown> (schema mismatch; next rspec run will be cold)'
|
|
68
|
+
return
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
stdout.puts "examples: #{snapshot.all_examples.size} tracked"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Internal helper for the tracer pipeline.
|
|
75
|
+
# @api private
|
|
76
|
+
def self.directory_size(path)
|
|
77
|
+
return 0 unless File.directory?(path)
|
|
78
|
+
|
|
79
|
+
total = 0
|
|
80
|
+
Dir.glob(File.join(path, '**', '*'), File::FNM_DOTMATCH).each do |entry|
|
|
81
|
+
next unless File.file?(entry)
|
|
82
|
+
|
|
83
|
+
total += File.size(entry)
|
|
84
|
+
rescue SystemCallError
|
|
85
|
+
next
|
|
86
|
+
end
|
|
87
|
+
total
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Internal helper for the tracer pipeline.
|
|
91
|
+
# @api private
|
|
92
|
+
def self.format_bytes(bytes)
|
|
93
|
+
return '0 B' if bytes <= 0
|
|
94
|
+
|
|
95
|
+
units = %w[B KB MB GB]
|
|
96
|
+
scale = bytes
|
|
97
|
+
unit_index = 0
|
|
98
|
+
while scale >= 1024 && unit_index < units.length - 1
|
|
99
|
+
scale /= 1024.0
|
|
100
|
+
unit_index += 1
|
|
101
|
+
end
|
|
102
|
+
format('%<scale>.1f %<unit>s', scale: scale, unit: units[unit_index])
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|