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,896 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'coverage'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'set'
|
|
7
|
+
|
|
8
|
+
require_relative 'engine/snapshot_builder'
|
|
9
|
+
require_relative 'tracker/coverage_adapter'
|
|
10
|
+
require_relative 'tracker/declared_globs'
|
|
11
|
+
require_relative 'tracker/dependency_graph'
|
|
12
|
+
require_relative 'tracker/env_matcher'
|
|
13
|
+
require_relative 'tracker/env_snapshot'
|
|
14
|
+
require_relative 'tracker/example_registry'
|
|
15
|
+
require_relative 'tracker/file_digest'
|
|
16
|
+
require_relative 'tracker/filter'
|
|
17
|
+
require_relative 'tracker/input'
|
|
18
|
+
require_relative 'tracker/io_hooks'
|
|
19
|
+
require_relative 'tracker/loaded_files_tracker'
|
|
20
|
+
require_relative 'tracker/new_file_detector'
|
|
21
|
+
require_relative 'tracker/whole_suite_invalidators'
|
|
22
|
+
require_relative 'storage/backend'
|
|
23
|
+
require_relative 'storage/json_backend'
|
|
24
|
+
require_relative 'storage/schema'
|
|
25
|
+
require_relative 'storage/snapshot'
|
|
26
|
+
require_relative 'storage/sqlite_backend'
|
|
27
|
+
|
|
28
|
+
module TestSift
|
|
29
|
+
# rubocop:disable Metrics/ClassLength
|
|
30
|
+
class Engine
|
|
31
|
+
include SnapshotBuilder
|
|
32
|
+
|
|
33
|
+
# Internal constant.
|
|
34
|
+
# @api private
|
|
35
|
+
EXAMPLE_RUN_REASON = {
|
|
36
|
+
explicit_run: 'Explicit run',
|
|
37
|
+
no_cache: 'No cache',
|
|
38
|
+
interrupted: 'Interrupted previously',
|
|
39
|
+
flaky_example: 'Flaky example',
|
|
40
|
+
failed_example: 'Failed previously',
|
|
41
|
+
pending_example: 'Pending previously',
|
|
42
|
+
files_changed: 'Files changed',
|
|
43
|
+
whole_suite_invalidator: 'Whole-suite invalidator changed',
|
|
44
|
+
env_changed: 'Environment changed'
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
# Map from Filter#select reasons to the legacy-shaped strings
|
|
48
|
+
# users see in test output ("foo (Files changed)"). Keeps the
|
|
49
|
+
# user surface unchanged under v2.
|
|
50
|
+
FILTER_REASON_STRINGS = {
|
|
51
|
+
whole_suite_invalidator: EXAMPLE_RUN_REASON[:whole_suite_invalidator],
|
|
52
|
+
interrupted: EXAMPLE_RUN_REASON[:interrupted],
|
|
53
|
+
flaky_example: EXAMPLE_RUN_REASON[:flaky_example],
|
|
54
|
+
failed_example: EXAMPLE_RUN_REASON[:failed_example],
|
|
55
|
+
pending_example: EXAMPLE_RUN_REASON[:pending_example],
|
|
56
|
+
no_cache: EXAMPLE_RUN_REASON[:no_cache],
|
|
57
|
+
files_changed: EXAMPLE_RUN_REASON[:files_changed],
|
|
58
|
+
env_changed: EXAMPLE_RUN_REASON[:env_changed]
|
|
59
|
+
}.freeze
|
|
60
|
+
|
|
61
|
+
# Internal attribute.
|
|
62
|
+
# @api private
|
|
63
|
+
attr_reader :registry, :graph, :loaded_files_tracker, :coverage_adapter,
|
|
64
|
+
:declared_globs, :whole_suite_invalidators, :new_file_detector,
|
|
65
|
+
:env_snapshot, :storage_backend, :all_examples, :duplicate_examples,
|
|
66
|
+
:examples_coverage, :all_files
|
|
67
|
+
|
|
68
|
+
# Internal method on the tracer pipeline.
|
|
69
|
+
# @api private
|
|
70
|
+
def initialize(configuration:, read_cache_path: nil)
|
|
71
|
+
@configuration = configuration
|
|
72
|
+
@read_cache_path = read_cache_path
|
|
73
|
+
@filtered_examples = {}
|
|
74
|
+
@all_examples = {}
|
|
75
|
+
@duplicate_examples = {}
|
|
76
|
+
@examples_coverage = {}
|
|
77
|
+
@all_files = {}
|
|
78
|
+
@file_names = {}
|
|
79
|
+
@absolute_paths = {}
|
|
80
|
+
@filter_decisions = {}
|
|
81
|
+
@tracks_files = Hash.new { |h, id| h[id] = Set.new } # id => Set<abs_path>
|
|
82
|
+
@tracks_env = Hash.new { |h, id| h[id] = Set.new } # id => Set<env_name>
|
|
83
|
+
@tracked_env_names = Set.new
|
|
84
|
+
@config_tracked_env_names = Set.new # config-level subset (post-expansion)
|
|
85
|
+
@previous_snapshot = nil
|
|
86
|
+
@run_id = nil
|
|
87
|
+
@before_peek = nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Internal method on the tracer pipeline.
|
|
91
|
+
# @api private
|
|
92
|
+
def setup
|
|
93
|
+
@configuration.freeze_declared_globs!
|
|
94
|
+
|
|
95
|
+
build_observers
|
|
96
|
+
install_io_hooks
|
|
97
|
+
install_rails_observers if @configuration.rails?
|
|
98
|
+
ensure_coverage_started
|
|
99
|
+
|
|
100
|
+
@loaded_files_tracker.capture_boot_set!
|
|
101
|
+
@declared_globs.walk
|
|
102
|
+
|
|
103
|
+
@previous_snapshot = load_previous_snapshot
|
|
104
|
+
seed_state_from_previous(@previous_snapshot) if @previous_snapshot
|
|
105
|
+
register_config_tracked_env_names
|
|
106
|
+
compute_filter_decisions
|
|
107
|
+
self
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# --- filter-phase surface (mirrors legacy Runner) --------------
|
|
111
|
+
|
|
112
|
+
def run_example?(example_id)
|
|
113
|
+
return true if @configuration.run_all_examples
|
|
114
|
+
|
|
115
|
+
previously_seen = @previous_snapshot&.all_examples&.key?(example_id)
|
|
116
|
+
!previously_seen || @filtered_examples.key?(example_id)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Internal method on the tracer pipeline.
|
|
120
|
+
# @api private
|
|
121
|
+
def run_example_reason(example_id)
|
|
122
|
+
return EXAMPLE_RUN_REASON[:explicit_run] if @configuration.run_all_examples
|
|
123
|
+
|
|
124
|
+
@filtered_examples[example_id] || EXAMPLE_RUN_REASON[:no_cache]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def register_example(example)
|
|
128
|
+
example_id = example[:example_id]
|
|
129
|
+
@registry.register(example_id, metadata: example, identity_hash: example_id)
|
|
130
|
+
example.replace(@all_examples.fetch(example_id, {}).merge(example))
|
|
131
|
+
@all_examples[example_id] = example
|
|
132
|
+
@duplicate_examples[example_id] ||= []
|
|
133
|
+
@duplicate_examples[example_id] << example
|
|
134
|
+
self
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Per-example tracking DSL hook. Called from RunnerHook with the
|
|
138
|
+
# normalized `{files: Set<String>, env: Set<String>}` that
|
|
139
|
+
# `RSpec::Metadata.tracks_for(example)` produced. Resolves the
|
|
140
|
+
# file globs against the project root once per distinct glob
|
|
141
|
+
# string (memoized) and unions the matching Inputs into this
|
|
142
|
+
# example's dependency set. Env names are accumulated into
|
|
143
|
+
# `@tracked_env_names` so the finalize snapshot covers every key
|
|
144
|
+
# the run cared about.
|
|
145
|
+
#
|
|
146
|
+
# Per-example env entries may carry wildcard patterns (`tracks:
|
|
147
|
+
# { env: 'RAILS_*' }`). `EnvMatcher.expand` is the single funnel
|
|
148
|
+
# - literals pass through, wildcards expand against the live ENV,
|
|
149
|
+
# and unsupported syntax raises ArgumentError at this point
|
|
150
|
+
# (RunnerHook Pass 1, before any example body runs).
|
|
151
|
+
# rubocop:disable Metrics/PerceivedComplexity
|
|
152
|
+
def register_tracks(example_id, tracks)
|
|
153
|
+
files = tracks[:files] || tracks['files'] || Set.new
|
|
154
|
+
envs = tracks[:env] || tracks['env'] || Set.new
|
|
155
|
+
|
|
156
|
+
files.each { |glob| @tracks_files[example_id].merge(resolved_glob_inputs(glob)) } unless files.empty?
|
|
157
|
+
return self if envs.empty?
|
|
158
|
+
|
|
159
|
+
expanded = TestSift::Tracker::EnvMatcher.expand(envs.map(&:to_s))
|
|
160
|
+
@tracks_env[example_id].merge(expanded)
|
|
161
|
+
@tracked_env_names.merge(expanded)
|
|
162
|
+
self
|
|
163
|
+
end
|
|
164
|
+
# rubocop:enable Metrics/PerceivedComplexity
|
|
165
|
+
|
|
166
|
+
# Called from RunnerHook AFTER the filter-decision pre-walk has
|
|
167
|
+
# populated `@tracks_env` / `@tracked_env_names` for every
|
|
168
|
+
# example. Compares each declared env key against the previous
|
|
169
|
+
# snapshot's `env_snapshot` via Tracker::EnvSnapshot; marks any
|
|
170
|
+
# example whose tracked-env set intersects the invalidated set
|
|
171
|
+
# as re-runnable. Strictly additive vs other filter reasons - if
|
|
172
|
+
# the example was already in `@filtered_examples` for a stronger
|
|
173
|
+
# reason (files_changed / whole_suite_invalidator /
|
|
174
|
+
# failed_example / ...), env_changed does NOT overwrite.
|
|
175
|
+
#
|
|
176
|
+
# Config-level path: when an invalidated key intersects
|
|
177
|
+
# `@config_tracked_env_names` (the post-expansion config-level
|
|
178
|
+
# set), every previously-seen example re-runs - mirrors the
|
|
179
|
+
# `track_files` "declared globs attach to every example"
|
|
180
|
+
# semantics. New examples (not in @previous_snapshot.all_examples)
|
|
181
|
+
# already run via the no_cache path; no special-casing needed.
|
|
182
|
+
def apply_env_filter_decisions
|
|
183
|
+
return self if @previous_snapshot.nil?
|
|
184
|
+
return self if @tracked_env_names.empty?
|
|
185
|
+
|
|
186
|
+
invalidated = @env_snapshot.invalidated_keys(
|
|
187
|
+
@previous_snapshot.env_snapshot, @tracked_env_names
|
|
188
|
+
)
|
|
189
|
+
return self if invalidated.empty?
|
|
190
|
+
|
|
191
|
+
reason = FILTER_REASON_STRINGS.fetch(:env_changed)
|
|
192
|
+
mark_all_prev_examples(reason) if invalidated.intersect?(@config_tracked_env_names)
|
|
193
|
+
mark_per_example_intersections(invalidated, reason)
|
|
194
|
+
self
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Internal method on the tracer pipeline.
|
|
198
|
+
# @api private
|
|
199
|
+
def deregister_duplicate_examples
|
|
200
|
+
@duplicate_examples.select! { |_, entries| entries.count > 1 }
|
|
201
|
+
return if @duplicate_examples.empty?
|
|
202
|
+
|
|
203
|
+
@all_examples.reject! { |id, _| @duplicate_examples.key?(id) }
|
|
204
|
+
self
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# --- per-example surface --------------------------------------
|
|
208
|
+
|
|
209
|
+
# Internal method on the tracer pipeline.
|
|
210
|
+
# @api private
|
|
211
|
+
def example_started
|
|
212
|
+
@before_peek = @coverage_adapter.peek
|
|
213
|
+
@current_bucket = {}
|
|
214
|
+
@current_rails_bucket = {}
|
|
215
|
+
TestSift::Tracker::IOHooks.set_bucket(@current_bucket)
|
|
216
|
+
set_rails_bucket(@current_rails_bucket)
|
|
217
|
+
self
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Internal method on the tracer pipeline.
|
|
221
|
+
# @api private
|
|
222
|
+
def example_finished(example_id)
|
|
223
|
+
after_peek, coverage_paths = @coverage_adapter.peek_with_paths
|
|
224
|
+
record_coverage_delta(example_id, @before_peek, after_peek)
|
|
225
|
+
io_inputs = @current_bucket.values
|
|
226
|
+
rails_inputs = @current_rails_bucket ? @current_rails_bucket.values : []
|
|
227
|
+
TestSift::Tracker::IOHooks.clear_bucket
|
|
228
|
+
clear_rails_bucket
|
|
229
|
+
|
|
230
|
+
transitive_inputs = @loaded_files_tracker.loaded_set_inputs |
|
|
231
|
+
@loaded_files_tracker.stop_example(example_id, coverage_paths)
|
|
232
|
+
coverage_inputs = @coverage_adapter.compute_diff(@before_peek, after_peek)
|
|
233
|
+
declared_inputs = @declared_globs.walk
|
|
234
|
+
tracks_inputs = per_example_tracks_inputs(example_id)
|
|
235
|
+
attribute_to_example(
|
|
236
|
+
example_id,
|
|
237
|
+
coverage_inputs | transitive_inputs | io_inputs.to_set |
|
|
238
|
+
rails_inputs.to_set | declared_inputs | tracks_inputs
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
@before_peek = nil
|
|
242
|
+
@current_bucket = nil
|
|
243
|
+
@current_rails_bucket = nil
|
|
244
|
+
self
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Internal method on the tracer pipeline.
|
|
248
|
+
# @api private
|
|
249
|
+
def on_example_skipped(example_id)
|
|
250
|
+
@registry.register(example_id) unless @registry.registered?(example_id)
|
|
251
|
+
@registry.update_status(example_id, :skipped)
|
|
252
|
+
self
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Internal method on the tracer pipeline.
|
|
256
|
+
# @api private
|
|
257
|
+
def on_example_passed(example_id, result)
|
|
258
|
+
return if @duplicate_examples[example_id]&.count.to_i > 1
|
|
259
|
+
|
|
260
|
+
status = flaky_history?(example_id) ? :flaky : :passed
|
|
261
|
+
@registry.update_status(example_id, status)
|
|
262
|
+
record_execution_result(example_id, result)
|
|
263
|
+
self
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Internal method on the tracer pipeline.
|
|
267
|
+
# @api private
|
|
268
|
+
def on_example_failed(example_id, result)
|
|
269
|
+
return if @duplicate_examples[example_id]&.count.to_i > 1
|
|
270
|
+
|
|
271
|
+
status = previously_flaky?(example_id) ? :flaky : :failed
|
|
272
|
+
@registry.update_status(example_id, status)
|
|
273
|
+
record_execution_result(example_id, result)
|
|
274
|
+
self
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Internal method on the tracer pipeline.
|
|
278
|
+
# @api private
|
|
279
|
+
def on_example_pending(example_id, result)
|
|
280
|
+
return if @duplicate_examples[example_id]&.count.to_i > 1
|
|
281
|
+
|
|
282
|
+
@registry.update_status(example_id, :pending)
|
|
283
|
+
record_execution_result(example_id, result)
|
|
284
|
+
self
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# --- finalize ------------------------------------------------
|
|
288
|
+
|
|
289
|
+
def finalize
|
|
290
|
+
@registry.all_example_ids.each do |id|
|
|
291
|
+
next if @registry.status_of(id)
|
|
292
|
+
|
|
293
|
+
@registry.update_status(id, :interrupted)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
snapshot = build_snapshot
|
|
297
|
+
@storage_backend.save_graph(snapshot, schema_version: TestSift::Storage::Schema::CURRENT)
|
|
298
|
+
uninstall_rails_observers
|
|
299
|
+
snapshot
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# For every previously-skipped example id, accumulate per-line
|
|
303
|
+
# coverage strengths from the previous run's per-example coverage
|
|
304
|
+
# map into the missed_coverage return value. Deleted files /
|
|
305
|
+
# missing entries are skipped silently. Consumed by
|
|
306
|
+
# Reporters::CoverageJsonReporter at finalize time so coverage.json
|
|
307
|
+
# carries forward the contribution of skipped examples.
|
|
308
|
+
#
|
|
309
|
+
# Returns Hash[file_path => Hash[line_number => cumulative_strength]].
|
|
310
|
+
def merge_skipped_coverage(skipped_ids, previous_examples_coverage = nil)
|
|
311
|
+
source = previous_examples_coverage || @previous_snapshot&.examples_coverage || {}
|
|
312
|
+
missed = Hash.new { |h, f| h[f] = Hash.new(0) }
|
|
313
|
+
|
|
314
|
+
skipped_ids.each do |example_id|
|
|
315
|
+
example_coverage = source[example_id]
|
|
316
|
+
next if example_coverage.nil?
|
|
317
|
+
|
|
318
|
+
example_coverage.each do |file_path, line_coverage|
|
|
319
|
+
accumulate_line_coverage(missed[file_path], line_coverage)
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
missed
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# --- accessors used by specs ---------------------------------
|
|
327
|
+
|
|
328
|
+
def filtered_example_ids
|
|
329
|
+
@filtered_examples.keys
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Internal method on the tracer pipeline.
|
|
333
|
+
# @api private
|
|
334
|
+
def previous_snapshot_loaded?
|
|
335
|
+
!@previous_snapshot.nil?
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
private
|
|
339
|
+
|
|
340
|
+
# Read the config-level `track_env(*names)` accumulator,
|
|
341
|
+
# expand any wildcard patterns against the live ENV via
|
|
342
|
+
# EnvMatcher.expand (which raises ArgumentError on unsupported
|
|
343
|
+
# syntax - intentionally surfaces config errors at run start),
|
|
344
|
+
# and seed both `@config_tracked_env_names` (for the global
|
|
345
|
+
# mark-every-example branch in apply_env_filter_decisions) and
|
|
346
|
+
# `@tracked_env_names` (so the finalize snapshot includes every
|
|
347
|
+
# config-level key alongside per-example keys).
|
|
348
|
+
def register_config_tracked_env_names
|
|
349
|
+
patterns = @configuration.tracked_env_names
|
|
350
|
+
return if patterns.nil? || patterns.empty?
|
|
351
|
+
|
|
352
|
+
expanded = TestSift::Tracker::EnvMatcher.expand(patterns)
|
|
353
|
+
@config_tracked_env_names.merge(expanded)
|
|
354
|
+
@tracked_env_names.merge(expanded)
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Mark every previously-seen example for re-run. Called
|
|
358
|
+
# when a config-level env key flips between runs.
|
|
359
|
+
def mark_all_prev_examples(reason)
|
|
360
|
+
@previous_snapshot.all_examples.each_key do |example_id|
|
|
361
|
+
next if @filtered_examples.key?(example_id)
|
|
362
|
+
|
|
363
|
+
@filtered_examples[example_id] = reason
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Per-example env-changed mark. Walks @tracks_env,
|
|
368
|
+
# marks examples whose declared env set intersects invalidated.
|
|
369
|
+
# Additive vs other filter reasons (won't overwrite).
|
|
370
|
+
def mark_per_example_intersections(invalidated, reason)
|
|
371
|
+
@tracks_env.each do |example_id, envs|
|
|
372
|
+
next unless envs.intersect?(invalidated)
|
|
373
|
+
next if @filtered_examples.key?(example_id)
|
|
374
|
+
|
|
375
|
+
@filtered_examples[example_id] = reason
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Internal method on the tracer pipeline.
|
|
380
|
+
# @api private
|
|
381
|
+
def build_observers
|
|
382
|
+
@registry = TestSift::Tracker::ExampleRegistry.new
|
|
383
|
+
@graph = TestSift::Tracker::DependencyGraph.new
|
|
384
|
+
@coverage_adapter = TestSift::Tracker::CoverageAdapter.new(
|
|
385
|
+
root: @configuration.root, filters: @configuration.filters
|
|
386
|
+
)
|
|
387
|
+
@declared_globs = TestSift::Tracker::DeclaredGlobs.new(
|
|
388
|
+
root: @configuration.root, globs: @configuration.declared_globs
|
|
389
|
+
)
|
|
390
|
+
@whole_suite_invalidators = TestSift::Tracker::WholeSuiteInvalidators.new(
|
|
391
|
+
root: @configuration.root
|
|
392
|
+
)
|
|
393
|
+
@env_snapshot = TestSift::Tracker::EnvSnapshot.new
|
|
394
|
+
@new_file_detector = TestSift::Tracker::NewFileDetector.new(
|
|
395
|
+
root: @configuration.root, declared_globs: @configuration.declared_globs
|
|
396
|
+
)
|
|
397
|
+
@loaded_files_tracker = TestSift::Tracker::LoadedFilesTracker.new(
|
|
398
|
+
root: @configuration.root, enabled: @configuration.transitive_load_tracking
|
|
399
|
+
)
|
|
400
|
+
@storage_backend = build_storage_backend(@configuration.cache_path)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Resolve the configured storage backend to a concrete instance.
|
|
404
|
+
# Delegates to {TestSift::Storage::Backend.build}, the single
|
|
405
|
+
# factory shared with the CLI sub-commands so `cache:info` /
|
|
406
|
+
# `explain` compose correctly with `storage_backend :sqlite`.
|
|
407
|
+
def build_storage_backend(cache_path)
|
|
408
|
+
TestSift::Storage::Backend.build(
|
|
409
|
+
cache_path: cache_path, configuration: @configuration
|
|
410
|
+
)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Internal method on the tracer pipeline.
|
|
414
|
+
# @api private
|
|
415
|
+
def install_io_hooks
|
|
416
|
+
declared = @declared_globs
|
|
417
|
+
TestSift::Tracker::IOHooks.install(
|
|
418
|
+
root: @configuration.root,
|
|
419
|
+
filter: ->(path) { !declared.covers?(path) }
|
|
420
|
+
)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Subscriber-install body. Idempotent via `@rails_observers_installed`
|
|
424
|
+
# so a re-call (e.g. eager path then late-bind firing on a slow Rails
|
|
425
|
+
# load) is a no-op. Shared between the eager and late-bind paths.
|
|
426
|
+
def install_rails_observers
|
|
427
|
+
return if rails_observers_installed?
|
|
428
|
+
|
|
429
|
+
require_relative 'rails/notifications'
|
|
430
|
+
require_relative 'rails/i18n_tracking'
|
|
431
|
+
|
|
432
|
+
declared = @declared_globs
|
|
433
|
+
filter = ->(path) { !declared.covers?(path) }
|
|
434
|
+
ar_paths = ar_schema_notifications_enabled? ? ar_schema_path_probes : []
|
|
435
|
+
|
|
436
|
+
TestSift::Rails::Notifications.install(
|
|
437
|
+
root: @configuration.root, filter: filter, ar_schema_paths: ar_paths
|
|
438
|
+
)
|
|
439
|
+
TestSift::Rails::I18nTracking.install(
|
|
440
|
+
root: @configuration.root, filter: filter
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
@rails_observers_installed = true
|
|
444
|
+
rescue StandardError => e
|
|
445
|
+
@configuration.logger.warn(
|
|
446
|
+
"testsift: Rails observer install failed (#{e.class}: #{e.message})"
|
|
447
|
+
)
|
|
448
|
+
@rails_observers_installed = false
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Internal method on the tracer pipeline.
|
|
452
|
+
# @api private
|
|
453
|
+
def uninstall_rails_observers
|
|
454
|
+
return unless rails_observers_installed?
|
|
455
|
+
|
|
456
|
+
TestSift::Rails::Notifications.uninstall
|
|
457
|
+
TestSift::Rails::I18nTracking.uninstall
|
|
458
|
+
@rails_observers_installed = false
|
|
459
|
+
rescue StandardError
|
|
460
|
+
@rails_observers_installed = false
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# Internal method on the tracer pipeline.
|
|
464
|
+
# @api private
|
|
465
|
+
def rails_observers_installed?
|
|
466
|
+
defined?(@rails_observers_installed) && @rails_observers_installed == true
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
public :install_rails_observers, :rails_observers_installed?
|
|
470
|
+
|
|
471
|
+
# rubocop:disable Naming/AccessorMethodName
|
|
472
|
+
def set_rails_bucket(bucket)
|
|
473
|
+
return unless rails_observers_installed?
|
|
474
|
+
|
|
475
|
+
TestSift::Rails::Notifications.set_bucket(bucket)
|
|
476
|
+
end
|
|
477
|
+
# rubocop:enable Naming/AccessorMethodName
|
|
478
|
+
|
|
479
|
+
def clear_rails_bucket
|
|
480
|
+
return unless rails_observers_installed?
|
|
481
|
+
|
|
482
|
+
TestSift::Rails::Notifications.clear_bucket
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Internal method on the tracer pipeline.
|
|
486
|
+
# @api private
|
|
487
|
+
def ar_schema_notifications_enabled?
|
|
488
|
+
return false unless @configuration.respond_to?(:track_ar_schema_notifications?)
|
|
489
|
+
|
|
490
|
+
@configuration.track_ar_schema_notifications?
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# Internal method on the tracer pipeline.
|
|
494
|
+
# @api private
|
|
495
|
+
def ar_schema_path_probes
|
|
496
|
+
%w[db/schema.rb db/structure.sql].each_with_object([]) do |rel, acc|
|
|
497
|
+
abs = File.expand_path(rel, @configuration.root)
|
|
498
|
+
acc << abs if File.file?(abs)
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# Delegate to the legacy ::Coverage bootstrap if SimpleCov isn't
|
|
503
|
+
# already running. Matches TestSift.setup_coverage behavior so
|
|
504
|
+
# the two entry points agree on when to call ::Coverage.start
|
|
505
|
+
# AND on which Ruby Coverage modes are enabled.
|
|
506
|
+
def ensure_coverage_started
|
|
507
|
+
return if defined?(SimpleCov) && SimpleCov.running
|
|
508
|
+
return if ::Coverage.respond_to?(:running?) && ::Coverage.running?
|
|
509
|
+
|
|
510
|
+
::Coverage.start(**configuration_coverage_modes)
|
|
511
|
+
rescue RuntimeError
|
|
512
|
+
# ::Coverage.start raises if already started on some Rubies
|
|
513
|
+
# without a running? predicate; safe to ignore.
|
|
514
|
+
nil
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
# Reads `coverage_modes` off the configuration when available
|
|
518
|
+
# (extended with `TestSift::Configuration`). Defaults to an
|
|
519
|
+
# empty Hash for stubbed-configuration unit tests so the splat
|
|
520
|
+
# `Coverage.start(**{})` keeps lines-only legacy behavior.
|
|
521
|
+
def configuration_coverage_modes
|
|
522
|
+
return {} unless @configuration.respond_to?(:coverage_modes_for_start)
|
|
523
|
+
|
|
524
|
+
@configuration.coverage_modes_for_start
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
# Under parallel_tests, each worker writes to its own per-worker
|
|
528
|
+
# cache dir (`testsift_cache/parallel_tests_N/`) but warm-run
|
|
529
|
+
# reads come from the MERGED top-level cache (`testsift_cache/`,
|
|
530
|
+
# one level up). Last-process finalize at exit purges the per-worker
|
|
531
|
+
# dirs, so only the merged snapshot survives between runs. Match
|
|
532
|
+
# 1.x Cache#load_cache_for_run's `File.dirname(cache_path) if
|
|
533
|
+
# parallel_tests?` behavior so warm runs under parallel_tests
|
|
534
|
+
# actually skip examples.
|
|
535
|
+
def load_previous_snapshot
|
|
536
|
+
read_backend = build_read_backend
|
|
537
|
+
read_backend.load_graph(schema_version: TestSift::Storage::Schema::CURRENT)
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# Internal method on the tracer pipeline.
|
|
541
|
+
# @api private
|
|
542
|
+
def build_read_backend
|
|
543
|
+
return @storage_backend unless @read_cache_path
|
|
544
|
+
|
|
545
|
+
build_storage_backend(@read_cache_path)
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
# On warm runs, skipped examples don't re-populate @all_examples,
|
|
549
|
+
# @all_files, or @graph - only newly-run examples do. Without
|
|
550
|
+
# seeding, the next save would drop the skipped examples'
|
|
551
|
+
# metadata + deps, and the following warm run would see them as
|
|
552
|
+
# "not previously seen" and force a cold re-run of the entire
|
|
553
|
+
# suite. Seed the three state buckets from the previous snapshot
|
|
554
|
+
# so newly-run examples overwrite and skipped examples carry
|
|
555
|
+
# forward.
|
|
556
|
+
#
|
|
557
|
+
# examples_coverage is NOT seeded here. A populated cache can
|
|
558
|
+
# carry a large examples_coverage map; eagerly materializing it
|
|
559
|
+
# at setup defeats LazySnapshot's whole point for the SQLite
|
|
560
|
+
# backend. The merge happens at build_snapshot time via
|
|
561
|
+
# merge_examples_coverage_with_previous instead.
|
|
562
|
+
def seed_state_from_previous(prev)
|
|
563
|
+
seed_all_examples_from_previous(prev)
|
|
564
|
+
seed_all_files_from_previous(prev)
|
|
565
|
+
seed_graph_from_previous(prev)
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
# Internal method on the tracer pipeline.
|
|
569
|
+
# @api private
|
|
570
|
+
def seed_all_examples_from_previous(prev)
|
|
571
|
+
return unless prev.all_examples.is_a?(Hash)
|
|
572
|
+
|
|
573
|
+
prev.all_examples.each do |id, meta|
|
|
574
|
+
@all_examples[id] = meta
|
|
575
|
+
end
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# prev.all_files is file-name-keyed; the engine's @all_files is
|
|
579
|
+
# abs-path-keyed (path_to_file_name round-trips via the root prefix).
|
|
580
|
+
#
|
|
581
|
+
# Re-applies the current filter list at seed time so users who
|
|
582
|
+
# add a filter mid-development see the carried-over file entries
|
|
583
|
+
# drop on the next run (instead of waiting for a cold run). The
|
|
584
|
+
# filter contract should hold for both fresh attributions
|
|
585
|
+
# (attribute_to_example) and prior-snapshot carry-forward —
|
|
586
|
+
# otherwise add_filter has split semantics and a freshly-added
|
|
587
|
+
# filter does not exclude already-cached paths.
|
|
588
|
+
def seed_all_files_from_previous(prev)
|
|
589
|
+
return unless prev.all_files.is_a?(Hash)
|
|
590
|
+
|
|
591
|
+
prev.all_files.each_value do |meta|
|
|
592
|
+
next unless meta.is_a?(Hash)
|
|
593
|
+
|
|
594
|
+
file_path = symbol_or_string(meta, :file_path)
|
|
595
|
+
next if file_path.nil?
|
|
596
|
+
|
|
597
|
+
file_name = symbol_or_string(meta, :file_name) || path_to_file_name(file_path)
|
|
598
|
+
next if filtered_by_current_filters?(file_name)
|
|
599
|
+
|
|
600
|
+
@all_files[file_path] = {
|
|
601
|
+
file_name: file_name,
|
|
602
|
+
file_path: file_path,
|
|
603
|
+
digest: symbol_or_string(meta, :digest)
|
|
604
|
+
}
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
# prev.dependency keys on example_id, values are Set<file_name>.
|
|
609
|
+
# Register in @graph via absolute paths so the on-disk shape
|
|
610
|
+
# (file_name) converts at save time via dependency_by_name.
|
|
611
|
+
#
|
|
612
|
+
# Drops paths matching the current filter list so previously-
|
|
613
|
+
# cached deps that the new filter excludes do not leak through
|
|
614
|
+
# the carry-forward seed path. Same rationale as
|
|
615
|
+
# seed_all_files_from_previous: the filter contract is one
|
|
616
|
+
# contract, applied uniformly across fresh + carry-forward
|
|
617
|
+
# attributions.
|
|
618
|
+
def seed_graph_from_previous(prev)
|
|
619
|
+
return unless prev.dependency.is_a?(Hash)
|
|
620
|
+
|
|
621
|
+
prev.dependency.each do |example_id, file_names|
|
|
622
|
+
paths = []
|
|
623
|
+
file_names.each do |name|
|
|
624
|
+
next if filtered_by_current_filters?(name)
|
|
625
|
+
|
|
626
|
+
paths << absolute_path(name)
|
|
627
|
+
end
|
|
628
|
+
@graph.register_example(example_id, paths)
|
|
629
|
+
end
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
# Helper: returns true if the file name matches any currently-
|
|
633
|
+
# configured filter. Mirrors the check site at
|
|
634
|
+
# `attribute_to_example` (engine.rb:770) so both fresh + carry-
|
|
635
|
+
# forward attributions converge on the same filter behavior.
|
|
636
|
+
def filtered_by_current_filters?(file_name)
|
|
637
|
+
@filter_decisions.fetch(file_name) do
|
|
638
|
+
@filter_decisions[file_name] = @configuration.filters.any? { |f| f.match?(file_name: file_name) }
|
|
639
|
+
end
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
# Internal method on the tracer pipeline.
|
|
643
|
+
# @api private
|
|
644
|
+
def compute_filter_decisions
|
|
645
|
+
prev = @previous_snapshot
|
|
646
|
+
return if prev.nil?
|
|
647
|
+
|
|
648
|
+
seed_registry_from_previous(prev)
|
|
649
|
+
change_set = compute_change_set(prev)
|
|
650
|
+
whole_suite = whole_suite_changed?(prev)
|
|
651
|
+
|
|
652
|
+
result = TestSift::Tracker::Filter.select(
|
|
653
|
+
graph: graph_from_previous(prev),
|
|
654
|
+
change_set: change_set,
|
|
655
|
+
registry: @registry,
|
|
656
|
+
whole_suite_invalidated: whole_suite,
|
|
657
|
+
all_example_ids: prev.all_examples.keys.to_set
|
|
658
|
+
)
|
|
659
|
+
@filtered_examples = result.transform_values { |reason| FILTER_REASON_STRINGS.fetch(reason) }
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# Internal constant.
|
|
663
|
+
# @api private
|
|
664
|
+
SEED_STATUS_ORDER = %i[interrupted flaky failed pending].freeze
|
|
665
|
+
private_constant :SEED_STATUS_ORDER
|
|
666
|
+
|
|
667
|
+
# Internal method on the tracer pipeline.
|
|
668
|
+
# @api private
|
|
669
|
+
def seed_registry_from_previous(prev)
|
|
670
|
+
SEED_STATUS_ORDER.each do |status|
|
|
671
|
+
ids = prev.send(:"#{status}_examples")
|
|
672
|
+
ids.each { |id| seed_registry_entry(id, status, prev.all_examples[id] || {}) }
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
# Internal method on the tracer pipeline.
|
|
677
|
+
# @api private
|
|
678
|
+
def seed_registry_entry(id, status, metadata)
|
|
679
|
+
return if @registry.registered?(id)
|
|
680
|
+
|
|
681
|
+
@registry.register(id, metadata: metadata)
|
|
682
|
+
@registry.update_status(id, status)
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
# Rebuild a DependencyGraph from the previous Snapshot so Filter
|
|
686
|
+
# can intersect its cached dependency sets with the change_set.
|
|
687
|
+
def graph_from_previous(prev)
|
|
688
|
+
graph = TestSift::Tracker::DependencyGraph.new
|
|
689
|
+
prev.dependency.each { |id, paths| graph.register_example(id, paths) }
|
|
690
|
+
graph
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
# Internal method on the tracer pipeline.
|
|
694
|
+
# @api private
|
|
695
|
+
def compute_change_set(prev)
|
|
696
|
+
changed = Set.new
|
|
697
|
+
prev.all_files.each_value do |file_meta|
|
|
698
|
+
file_name = symbol_or_string(file_meta, :file_name)
|
|
699
|
+
cached_digest = symbol_or_string(file_meta, :digest)
|
|
700
|
+
next if file_name.nil? || cached_digest.nil?
|
|
701
|
+
|
|
702
|
+
current_digest = current_file_digest(file_name)
|
|
703
|
+
changed << file_name if current_digest.nil? || current_digest != cached_digest
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
new_files = @new_file_detector.new_files(
|
|
707
|
+
known_paths: prev.all_files.keys.to_set.to_set { |k| absolute_path(k) }
|
|
708
|
+
)
|
|
709
|
+
new_files.each { |input| changed << path_to_file_name(input.path) }
|
|
710
|
+
changed
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
# Internal method on the tracer pipeline.
|
|
714
|
+
# @api private
|
|
715
|
+
def whole_suite_changed?(prev)
|
|
716
|
+
wsi_prev = prev.wsi_snapshot if prev.respond_to?(:wsi_snapshot)
|
|
717
|
+
@whole_suite_invalidators.invalidated?(wsi_prev) ||
|
|
718
|
+
@loaded_files_tracker.boot_set_invalidated?(prev.boot_set)
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
# Internal method on the tracer pipeline.
|
|
722
|
+
# @api private
|
|
723
|
+
def current_file_digest(file_name)
|
|
724
|
+
TestSift::Tracker::FileDigest.compute(absolute_path(file_name))
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
# Internal method on the tracer pipeline.
|
|
728
|
+
# @api private
|
|
729
|
+
def record_coverage_delta(example_id, before, after)
|
|
730
|
+
entry = @examples_coverage[example_id] ||= {}
|
|
731
|
+
|
|
732
|
+
(before.keys | after.keys).each do |path|
|
|
733
|
+
b_lines = before[path]
|
|
734
|
+
a_lines = after[path]
|
|
735
|
+
next if b_lines == a_lines
|
|
736
|
+
|
|
737
|
+
file_entry = entry[path] ||= {}
|
|
738
|
+
accumulate_delta(file_entry, b_lines, a_lines)
|
|
739
|
+
end
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
# Internal method on the tracer pipeline.
|
|
743
|
+
# @api private
|
|
744
|
+
def accumulate_delta(file_entry, before_lines, after_lines)
|
|
745
|
+
length = (after_lines || before_lines || []).length
|
|
746
|
+
length.times do |i|
|
|
747
|
+
delta = line_delta(before_lines && before_lines[i], after_lines && after_lines[i])
|
|
748
|
+
file_entry[i] = delta if delta
|
|
749
|
+
end
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
# Returns the positive coverage delta for one line, or nil if the
|
|
753
|
+
# line isn't a delta worth recording (non-executable, identical,
|
|
754
|
+
# or a stale-going-backward entry).
|
|
755
|
+
def line_delta(before, after)
|
|
756
|
+
return nil if after.nil? || before == after
|
|
757
|
+
|
|
758
|
+
delta = after - (before || 0)
|
|
759
|
+
delta.positive? ? delta : nil
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
# Internal method on the tracer pipeline.
|
|
763
|
+
# @api private
|
|
764
|
+
def accumulate_line_coverage(accumulator, line_coverage)
|
|
765
|
+
line_coverage.each do |line_key, strength|
|
|
766
|
+
index = line_key.to_i
|
|
767
|
+
accumulator[index] += strength || 0
|
|
768
|
+
end
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
# Internal method on the tracer pipeline.
|
|
772
|
+
# @api private
|
|
773
|
+
def attribute_to_example(example_id, inputs)
|
|
774
|
+
paths = []
|
|
775
|
+
inputs.each do |input|
|
|
776
|
+
file_name = attributed_file_name(input.path)
|
|
777
|
+
next if file_name.nil?
|
|
778
|
+
|
|
779
|
+
paths << input.path
|
|
780
|
+
entry = @all_files[input.path]
|
|
781
|
+
next if entry && entry[:digest] == input.digest && entry[:file_name] == file_name
|
|
782
|
+
|
|
783
|
+
@all_files[input.path] = { file_name: file_name, file_path: input.path, digest: input.digest }
|
|
784
|
+
end
|
|
785
|
+
@graph.register_example(example_id, paths)
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
# Internal method on the tracer pipeline.
|
|
789
|
+
# @api private
|
|
790
|
+
def attributed_file_name(path)
|
|
791
|
+
file_name = path_to_file_name(path)
|
|
792
|
+
file_name unless filtered_by_current_filters?(file_name)
|
|
793
|
+
end
|
|
794
|
+
|
|
795
|
+
# Internal method on the tracer pipeline.
|
|
796
|
+
# @api private
|
|
797
|
+
def record_execution_result(example_id, result)
|
|
798
|
+
return unless @all_examples.key?(example_id)
|
|
799
|
+
|
|
800
|
+
@all_examples[example_id][:execution_result] = result.dup
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
# Ports the 1.x flaky-detection contract: an example transitions
|
|
804
|
+
# into `:flaky` when it (a) failed on the previous run and passes
|
|
805
|
+
# on this run (the canonical "intermittent" signal) OR (b) was
|
|
806
|
+
# already flaky on the previous run, regardless of this run's
|
|
807
|
+
# outcome (sticky once detected). Mirrors 1.x's
|
|
808
|
+
# `ReportGenerator#generate_flaky_examples_report` which iterated
|
|
809
|
+
# `prev_failed | prev_flaky` and registered the example as flaky
|
|
810
|
+
# unless the prev_failed branch failed again this run.
|
|
811
|
+
def flaky_history?(id)
|
|
812
|
+
previously_failed?(id) || previously_flaky?(id)
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
# Internal method on the tracer pipeline.
|
|
816
|
+
# @api private
|
|
817
|
+
def previously_failed?(id)
|
|
818
|
+
@previous_snapshot&.failed_examples&.include?(id)
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
# Internal method on the tracer pipeline.
|
|
822
|
+
# @api private
|
|
823
|
+
def previously_flaky?(id)
|
|
824
|
+
@previous_snapshot&.flaky_examples&.include?(id)
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
# Resolve one glob against the project root into a Set of
|
|
828
|
+
# `:declared`-kind Inputs. Walks via Dir.glob
|
|
829
|
+
# (FNM_PATHNAME+FNM_EXTGLOB like DeclaredGlobs so user muscle
|
|
830
|
+
# memory works identically) and digests each hit with SHA256.
|
|
831
|
+
# Unreadable files are skipped silently - graceful degradation.
|
|
832
|
+
# Memoized per distinct glob string so N examples declaring the
|
|
833
|
+
# same glob pay the filesystem walk cost exactly once.
|
|
834
|
+
def resolved_glob_inputs(glob)
|
|
835
|
+
@resolved_glob_cache ||= {}
|
|
836
|
+
@resolved_glob_cache[glob] ||= walk_one_glob(glob)
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
# Internal method on the tracer pipeline.
|
|
840
|
+
# @api private
|
|
841
|
+
def walk_one_glob(glob)
|
|
842
|
+
inputs = Set.new
|
|
843
|
+
root = @configuration.root
|
|
844
|
+
Dir.glob(glob, File::FNM_PATHNAME | File::FNM_EXTGLOB, base: root).each do |rel|
|
|
845
|
+
abs = File.expand_path(rel, root)
|
|
846
|
+
next unless abs.start_with?("#{root}/") && File.file?(abs)
|
|
847
|
+
|
|
848
|
+
digest = tracks_file_digest(abs)
|
|
849
|
+
next if digest.nil?
|
|
850
|
+
|
|
851
|
+
inputs << TestSift::Tracker::Input.for_file(
|
|
852
|
+
path: abs, kind: :declared, digest: digest, root: root
|
|
853
|
+
)
|
|
854
|
+
end
|
|
855
|
+
inputs
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
# Internal method on the tracer pipeline.
|
|
859
|
+
# @api private
|
|
860
|
+
def tracks_file_digest(path)
|
|
861
|
+
TestSift::Tracker::FileDigest.compute(path)
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
# Materialize the per-example tracks-file Input set. Returns an
|
|
865
|
+
# empty Set when the example has no `tracks: files:` metadata,
|
|
866
|
+
# keeping the downstream union cheap for the 99% case.
|
|
867
|
+
def per_example_tracks_inputs(example_id)
|
|
868
|
+
set = @tracks_files[example_id]
|
|
869
|
+
set.nil? || set.empty? ? Set.new : set.dup
|
|
870
|
+
end
|
|
871
|
+
|
|
872
|
+
# Internal method on the tracer pipeline.
|
|
873
|
+
# @api private
|
|
874
|
+
def absolute_path(file_name)
|
|
875
|
+
@absolute_paths[file_name] ||= -File.expand_path(file_name.to_s.sub(%r{^/}, ''), @configuration.root)
|
|
876
|
+
end
|
|
877
|
+
|
|
878
|
+
# Internal method on the tracer pipeline.
|
|
879
|
+
# @api private
|
|
880
|
+
def path_to_file_name(abs_path)
|
|
881
|
+
@file_names[abs_path] ||= begin
|
|
882
|
+
root_prefix = "#{@configuration.root}/"
|
|
883
|
+
-(abs_path.start_with?(root_prefix) ? "/#{abs_path[root_prefix.length..]}" : abs_path)
|
|
884
|
+
end
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
# Internal method on the tracer pipeline.
|
|
888
|
+
# @api private
|
|
889
|
+
def symbol_or_string(hash, key)
|
|
890
|
+
return hash[key] if hash.key?(key)
|
|
891
|
+
|
|
892
|
+
hash[key.to_s]
|
|
893
|
+
end
|
|
894
|
+
end
|
|
895
|
+
# rubocop:enable Metrics/ClassLength
|
|
896
|
+
end
|