rspec-tracer 1.2.3 → 2.0.0.pre.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +384 -67
- data/README.md +454 -429
- data/bin/rspec-tracer +15 -0
- data/lib/rspec_tracer/cache/Rakefile +43 -0
- data/lib/rspec_tracer/cli/cache_clear.rb +111 -0
- data/lib/rspec_tracer/cli/cache_info.rb +104 -0
- data/lib/rspec_tracer/cli/doctor.rb +284 -0
- data/lib/rspec_tracer/cli/explain.rb +158 -0
- data/lib/rspec_tracer/cli/report_open.rb +82 -0
- data/lib/rspec_tracer/cli.rb +116 -0
- data/lib/rspec_tracer/configuration.rb +1196 -3
- data/lib/rspec_tracer/engine.rb +1168 -0
- data/lib/rspec_tracer/example.rb +141 -11
- data/lib/rspec_tracer/filter.rb +35 -0
- data/lib/rspec_tracer/line_stub.rb +61 -0
- data/lib/rspec_tracer/load_config.rb +2 -2
- data/lib/rspec_tracer/logger.rb +15 -0
- data/lib/rspec_tracer/rails/README.md +78 -0
- data/lib/rspec_tracer/rails/i18n_tracking.rb +137 -0
- data/lib/rspec_tracer/rails/notifications.rb +263 -0
- data/lib/rspec_tracer/rails/preset.rb +94 -0
- data/lib/rspec_tracer/rails/railtie.rb +22 -0
- data/lib/rspec_tracer/rails.rb +15 -0
- data/lib/rspec_tracer/remote_cache/README.md +140 -0
- data/lib/rspec_tracer/remote_cache/Rakefile +35 -11
- data/lib/rspec_tracer/remote_cache/archive.rb +137 -0
- data/lib/rspec_tracer/remote_cache/backend.rb +73 -0
- data/lib/rspec_tracer/remote_cache/git_ancestry.rb +241 -0
- data/lib/rspec_tracer/remote_cache/local_fs_backend.rb +439 -0
- data/lib/rspec_tracer/remote_cache/redis_backend.rb +554 -0
- data/lib/rspec_tracer/remote_cache/s3_backend.rb +712 -0
- data/lib/rspec_tracer/remote_cache/user_tasks.rb +436 -0
- data/lib/rspec_tracer/remote_cache/validator.rb +40 -62
- data/lib/rspec_tracer/remote_cache.rb +22 -0
- data/lib/rspec_tracer/reporters/README.md +103 -0
- data/lib/rspec_tracer/reporters/base.rb +87 -0
- data/lib/rspec_tracer/reporters/coverage_json_reporter.rb +338 -0
- data/lib/rspec_tracer/reporters/html/.gitignore +19 -0
- data/lib/rspec_tracer/reporters/html/.prettierignore +4 -0
- data/lib/rspec_tracer/reporters/html/.prettierrc.json +9 -0
- data/lib/rspec_tracer/reporters/html/README.md +80 -0
- data/lib/rspec_tracer/reporters/html/dist/assets/index.css +2 -0
- data/lib/rspec_tracer/reporters/html/dist/assets/index.js +1 -0
- data/lib/rspec_tracer/reporters/html/dist/index.html +24 -0
- data/lib/rspec_tracer/reporters/html/eslint.config.js +62 -0
- data/lib/rspec_tracer/reporters/html/package-lock.json +4941 -0
- data/lib/rspec_tracer/reporters/html/package.json +29 -0
- data/lib/rspec_tracer/reporters/html/src/app.jsx +130 -0
- data/lib/rspec_tracer/reporters/html/src/components/AllExamples.jsx +86 -0
- data/lib/rspec_tracer/reporters/html/src/components/DuplicateExamples.jsx +68 -0
- data/lib/rspec_tracer/reporters/html/src/components/ExamplesDependency.jsx +78 -0
- data/lib/rspec_tracer/reporters/html/src/components/FilesDependency.jsx +72 -0
- data/lib/rspec_tracer/reporters/html/src/components/FlakyExamples.jsx +42 -0
- data/lib/rspec_tracer/reporters/html/src/components/ReportTable.jsx +131 -0
- data/lib/rspec_tracer/reporters/html/src/components/SearchBar.jsx +19 -0
- data/lib/rspec_tracer/reporters/html/src/index.html +23 -0
- data/lib/rspec_tracer/reporters/html/src/main.jsx +37 -0
- data/lib/rspec_tracer/reporters/html/src/styles.css +434 -0
- data/lib/rspec_tracer/reporters/html/vite.config.js +42 -0
- data/lib/rspec_tracer/reporters/html_reporter.rb +266 -0
- data/lib/rspec_tracer/reporters/json_reporter.rb +88 -0
- data/lib/rspec_tracer/reporters/payload_builder.rb +235 -0
- data/lib/rspec_tracer/reporters/registry.rb +120 -0
- data/lib/rspec_tracer/reporters/terminal_reporter.rb +264 -0
- data/lib/rspec_tracer/rspec/README.md +73 -0
- data/lib/rspec_tracer/rspec/installation.rb +97 -0
- data/lib/rspec_tracer/rspec/metadata.rb +96 -0
- data/lib/rspec_tracer/rspec/parallel_tests.rb +459 -0
- data/lib/rspec_tracer/rspec/reporter_hook.rb +84 -0
- data/lib/rspec_tracer/rspec/runner_hook.rb +239 -0
- data/lib/rspec_tracer/source_file.rb +24 -7
- data/lib/rspec_tracer/storage/README.md +35 -0
- data/lib/rspec_tracer/storage/backend.rb +130 -0
- data/lib/rspec_tracer/storage/json_backend.rb +884 -0
- data/lib/rspec_tracer/storage/lazy_snapshot.rb +65 -0
- data/lib/rspec_tracer/storage/schema.rb +50 -0
- data/lib/rspec_tracer/storage/serializer/json.rb +41 -0
- data/lib/rspec_tracer/storage/serializer/msgpack.rb +167 -0
- data/lib/rspec_tracer/storage/snapshot.rb +141 -0
- data/lib/rspec_tracer/storage/sqlite_backend.rb +693 -0
- data/lib/rspec_tracer/time_formatter.rb +37 -18
- data/lib/rspec_tracer/tracker/README.md +36 -0
- data/lib/rspec_tracer/tracker/coverage_adapter.rb +174 -0
- data/lib/rspec_tracer/tracker/declared_globs.rb +100 -0
- data/lib/rspec_tracer/tracker/dependency_graph.rb +134 -0
- data/lib/rspec_tracer/tracker/env_matcher.rb +127 -0
- data/lib/rspec_tracer/tracker/env_snapshot.rb +77 -0
- data/lib/rspec_tracer/tracker/example_registry.rb +153 -0
- data/lib/rspec_tracer/tracker/file_digest.rb +61 -0
- data/lib/rspec_tracer/tracker/filter.rb +127 -0
- data/lib/rspec_tracer/tracker/input.rb +99 -0
- data/lib/rspec_tracer/tracker/io_hooks/file.rb +55 -0
- data/lib/rspec_tracer/tracker/io_hooks/io.rb +24 -0
- data/lib/rspec_tracer/tracker/io_hooks/json.rb +23 -0
- data/lib/rspec_tracer/tracker/io_hooks/kernel.rb +26 -0
- data/lib/rspec_tracer/tracker/io_hooks/yaml.rb +38 -0
- data/lib/rspec_tracer/tracker/io_hooks.rb +195 -0
- data/lib/rspec_tracer/tracker/loaded_files_tracker.rb +295 -0
- data/lib/rspec_tracer/tracker/new_file_detector.rb +62 -0
- data/lib/rspec_tracer/tracker/whole_suite_invalidators.rb +96 -0
- data/lib/rspec_tracer/version.rb +4 -1
- data/lib/rspec_tracer.rb +231 -491
- metadata +94 -43
- data/lib/rspec_tracer/cache.rb +0 -207
- data/lib/rspec_tracer/coverage_merger.rb +0 -42
- data/lib/rspec_tracer/coverage_reporter.rb +0 -187
- data/lib/rspec_tracer/coverage_writer.rb +0 -58
- data/lib/rspec_tracer/html_reporter/Rakefile +0 -18
- data/lib/rspec_tracer/html_reporter/assets/javascripts/application.js +0 -56
- data/lib/rspec_tracer/html_reporter/assets/javascripts/libraries/jquery.js +0 -10881
- data/lib/rspec_tracer/html_reporter/assets/javascripts/plugins/datatables.js +0 -15381
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/application.css +0 -196
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/datatables.css +0 -459
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/jquery-ui.css +0 -436
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/print.css +0 -92
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/reset.css +0 -265
- data/lib/rspec_tracer/html_reporter/public/application.css +0 -5
- data/lib/rspec_tracer/html_reporter/public/application.js +0 -6
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc_disabled.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_both.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc_disabled.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/favicon.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/loading.gif +0 -0
- data/lib/rspec_tracer/html_reporter/reporter.rb +0 -242
- data/lib/rspec_tracer/html_reporter/views/duplicate_examples.erb +0 -34
- data/lib/rspec_tracer/html_reporter/views/examples.erb +0 -58
- data/lib/rspec_tracer/html_reporter/views/examples_dependency.erb +0 -36
- data/lib/rspec_tracer/html_reporter/views/files_dependency.erb +0 -36
- data/lib/rspec_tracer/html_reporter/views/flaky_examples.erb +0 -38
- data/lib/rspec_tracer/html_reporter/views/layout.erb +0 -38
- data/lib/rspec_tracer/remote_cache/aws.rb +0 -176
- data/lib/rspec_tracer/remote_cache/cache.rb +0 -75
- data/lib/rspec_tracer/remote_cache/repo.rb +0 -210
- data/lib/rspec_tracer/report_generator.rb +0 -158
- data/lib/rspec_tracer/report_merger.rb +0 -68
- data/lib/rspec_tracer/report_writer.rb +0 -141
- data/lib/rspec_tracer/reporter.rb +0 -204
- data/lib/rspec_tracer/rspec_reporter.rb +0 -41
- data/lib/rspec_tracer/rspec_runner.rb +0 -56
- data/lib/rspec_tracer/ruby_coverage.rb +0 -9
- data/lib/rspec_tracer/runner.rb +0 -278
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpecTracer
|
|
4
|
+
# Internal Reporters — see {RSpecTracer} for the user-facing surface.
|
|
5
|
+
# @api private
|
|
6
|
+
module Reporters
|
|
7
|
+
# Orchestrates reporter emission at finalize-time. Called from
|
|
8
|
+
# `RSpecTracer#run_exit_tasks` once the Engine has persisted its
|
|
9
|
+
# Snapshot (architectural decision (a): wire from run_exit_tasks,
|
|
10
|
+
# not Engine-internal). Each configured reporter gets an isolated
|
|
11
|
+
# rescue; a buggy reporter warns + continues, never propagates a
|
|
12
|
+
# non-zero exit into the user's test suite.
|
|
13
|
+
#
|
|
14
|
+
# Reporter resolution:
|
|
15
|
+
# - Configuration#reporters returns `[[name_or_class, opts], ...]`
|
|
16
|
+
# when the user called `add_reporter`; `nil` otherwise.
|
|
17
|
+
# - When nil, falls back to `DEFAULTS` (`[:terminal, :json]`).
|
|
18
|
+
# - Symbol names resolve via `BUILT_INS` to in-tree reporter
|
|
19
|
+
# classes. Class values pass through as-is (custom reporters
|
|
20
|
+
# conforming to `Reporters::Base`).
|
|
21
|
+
# - Unknown symbols raise `ArgumentError` at emit time; the DSL
|
|
22
|
+
# validates eagerly, so this is the safety net for programmatic
|
|
23
|
+
# callers.
|
|
24
|
+
class Registry
|
|
25
|
+
# Symbol -> lazy class-name mapping. Strings (not Class constants)
|
|
26
|
+
# so the require order doesn't force load of reporter classes
|
|
27
|
+
# when the Registry module itself is loaded - matches how
|
|
28
|
+
# `storage_backend`'s Configuration DSL defers backend resolution.
|
|
29
|
+
BUILT_INS = {
|
|
30
|
+
terminal: 'RSpecTracer::Reporters::TerminalReporter',
|
|
31
|
+
json: 'RSpecTracer::Reporters::JsonReporter',
|
|
32
|
+
html: 'RSpecTracer::Reporters::HtmlReporter'
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
# Internal constant.
|
|
36
|
+
# @api private
|
|
37
|
+
DEFAULTS = %i[terminal json html].freeze
|
|
38
|
+
|
|
39
|
+
# Internal helper for the tracer pipeline.
|
|
40
|
+
# @api private
|
|
41
|
+
def self.emit_all(configuration:, snapshot:, report_dir:, run_metadata:)
|
|
42
|
+
new(configuration: configuration).emit_all(
|
|
43
|
+
snapshot: snapshot, report_dir: report_dir, run_metadata: run_metadata
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Internal method on the tracer pipeline.
|
|
48
|
+
# @api private
|
|
49
|
+
def initialize(configuration:)
|
|
50
|
+
@configuration = configuration
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Internal method on the tracer pipeline.
|
|
54
|
+
# @api private
|
|
55
|
+
def emit_all(snapshot:, report_dir:, run_metadata:)
|
|
56
|
+
entries = resolve_entries
|
|
57
|
+
return [] if entries.empty?
|
|
58
|
+
return [] if empty_snapshot?(snapshot)
|
|
59
|
+
|
|
60
|
+
entries.map { |klass, opts| emit_one(klass, opts, snapshot, report_dir, run_metadata) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Internal method on the tracer pipeline.
|
|
66
|
+
# @api private
|
|
67
|
+
def resolve_entries
|
|
68
|
+
declared = @configuration.respond_to?(:reporters) ? @configuration.reporters : nil
|
|
69
|
+
source = declared && !declared.empty? ? declared : DEFAULTS.map { |name| [name, {}] }
|
|
70
|
+
source.map { |name_or_class, opts| [resolve_class(name_or_class), opts || {}] }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Internal method on the tracer pipeline.
|
|
74
|
+
# @api private
|
|
75
|
+
def resolve_class(name_or_class)
|
|
76
|
+
return name_or_class if name_or_class.is_a?(Class)
|
|
77
|
+
|
|
78
|
+
const_name = BUILT_INS.fetch(name_or_class) do
|
|
79
|
+
raise ArgumentError, "unknown reporter: #{name_or_class.inspect}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
Object.const_get(const_name)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Internal method on the tracer pipeline.
|
|
86
|
+
# @api private
|
|
87
|
+
def emit_one(klass, opts, snapshot, report_dir, run_metadata)
|
|
88
|
+
reporter = klass.new(
|
|
89
|
+
snapshot: snapshot,
|
|
90
|
+
report_dir: report_dir,
|
|
91
|
+
run_metadata: run_metadata,
|
|
92
|
+
logger: logger,
|
|
93
|
+
**opts
|
|
94
|
+
)
|
|
95
|
+
reporter.generate
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
warn_continue(klass, e)
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Internal method on the tracer pipeline.
|
|
102
|
+
# @api private
|
|
103
|
+
def warn_continue(klass, err)
|
|
104
|
+
logger&.warn("rspec-tracer: reporter #{klass.name} failed (#{err.class}: #{err.message})")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Internal method on the tracer pipeline.
|
|
108
|
+
# @api private
|
|
109
|
+
def logger
|
|
110
|
+
@configuration.respond_to?(:logger) ? @configuration.logger : nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Internal method on the tracer pipeline.
|
|
114
|
+
# @api private
|
|
115
|
+
def empty_snapshot?(snapshot)
|
|
116
|
+
snapshot.nil? || snapshot.all_examples.nil? || snapshot.all_examples.empty?
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module RSpecTracer
|
|
6
|
+
# Internal Reporters — see {RSpecTracer} for the user-facing surface.
|
|
7
|
+
# @api private
|
|
8
|
+
module Reporters
|
|
9
|
+
# Concise stdout summary printed at finalize-time. Output is
|
|
10
|
+
# capped at 4 lines for a typical run (5 when duplicate /
|
|
11
|
+
# interrupted / flaky / pending counters are non-zero). Kind-less
|
|
12
|
+
# taxonomy by design - the in-memory Input#kind enum does not
|
|
13
|
+
# survive Storage::Snapshot persistence, so breaking down
|
|
14
|
+
# "Changed files: Templates / Locales / Ruby" would require a
|
|
15
|
+
# schema bump (deferred).
|
|
16
|
+
#
|
|
17
|
+
# Color policy:
|
|
18
|
+
# - Respects `NO_COLOR` per https://no-color.org/ (any value
|
|
19
|
+
# disables, even empty string).
|
|
20
|
+
# - Emits ANSI codes only when the output stream reports tty?.
|
|
21
|
+
# - The header line paints red on any failure / interrupted,
|
|
22
|
+
# yellow on pending-only, green otherwise.
|
|
23
|
+
class TerminalReporter < Base
|
|
24
|
+
# Internal constant.
|
|
25
|
+
# @api private
|
|
26
|
+
COLORS = {
|
|
27
|
+
reset: 0,
|
|
28
|
+
red: 31,
|
|
29
|
+
green: 32,
|
|
30
|
+
yellow: 33,
|
|
31
|
+
cyan: 36
|
|
32
|
+
}.freeze
|
|
33
|
+
private_constant :COLORS
|
|
34
|
+
|
|
35
|
+
# "\u00B7" = U+00B7 MIDDLE DOT. Written as the ASCII escape form
|
|
36
|
+
# so mutant's US-ASCII-defaulted parser doesn't choke on
|
|
37
|
+
# non-ASCII bytes in the source file. Same discipline as the
|
|
38
|
+
# dependency_graph.rb workaround.
|
|
39
|
+
SEPARATOR = " \u00B7 "
|
|
40
|
+
private_constant :SEPARATOR
|
|
41
|
+
|
|
42
|
+
# Snapshot-set-name => human label pairs for the tally line.
|
|
43
|
+
# Data-driven to keep tally_line's AbcSize below rubocop's
|
|
44
|
+
# threshold; new counters go here without growing the method.
|
|
45
|
+
TALLY_FIELDS = [
|
|
46
|
+
[:failed_examples, 'failed'],
|
|
47
|
+
[:pending_examples, 'pending'],
|
|
48
|
+
[:flaky_examples, 'flaky'],
|
|
49
|
+
[:interrupted_examples, 'interrupted']
|
|
50
|
+
].freeze
|
|
51
|
+
private_constant :TALLY_FIELDS
|
|
52
|
+
|
|
53
|
+
# Internal constant.
|
|
54
|
+
# @api private
|
|
55
|
+
BYTES_PER_MIB = 1_048_576.0
|
|
56
|
+
private_constant :BYTES_PER_MIB
|
|
57
|
+
|
|
58
|
+
# Concrete implementation of {RSpecTracer::Reporters::Base#generate}.
|
|
59
|
+
# Prints a per-run summary to the configured output stream.
|
|
60
|
+
#
|
|
61
|
+
# @return [Array<String>, nil] the lines emitted, or nil when the
|
|
62
|
+
# run had no examples worth reporting.
|
|
63
|
+
def generate
|
|
64
|
+
return nil if no_op?
|
|
65
|
+
|
|
66
|
+
lines = build_lines
|
|
67
|
+
stream = output_stream
|
|
68
|
+
lines.each { |line| stream.puts line }
|
|
69
|
+
lines
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Internal method on the tracer pipeline.
|
|
75
|
+
# @api private
|
|
76
|
+
def build_lines
|
|
77
|
+
[header_line, tally_line, kind_breakdown_line, cache_line, report_line].compact
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Internal method on the tracer pipeline.
|
|
81
|
+
# @api private
|
|
82
|
+
def header_line
|
|
83
|
+
total = snapshot.all_examples.size
|
|
84
|
+
run = run_count
|
|
85
|
+
skipped = snapshot.skipped_examples.size
|
|
86
|
+
cached_pct = cache_percent(total, skipped)
|
|
87
|
+
|
|
88
|
+
text = "rspec-tracer: #{total} examples tracked" \
|
|
89
|
+
"#{SEPARATOR}#{run} re-run#{SEPARATOR}#{skipped} skipped " \
|
|
90
|
+
"(#{cached_pct}% cached)"
|
|
91
|
+
paint(header_color, text)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Internal method on the tracer pipeline.
|
|
95
|
+
# @api private
|
|
96
|
+
def tally_line
|
|
97
|
+
parts = TALLY_FIELDS.filter_map do |field, label|
|
|
98
|
+
ids = snapshot.send(field)
|
|
99
|
+
"#{ids.size} #{label}" unless ids.empty?
|
|
100
|
+
end
|
|
101
|
+
parts << "#{duplicate_count} duplicate" if duplicate_count.positive?
|
|
102
|
+
return nil if parts.empty?
|
|
103
|
+
|
|
104
|
+
paint(:yellow, parts.join(SEPARATOR))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Internal method on the tracer pipeline.
|
|
108
|
+
# @api private
|
|
109
|
+
def cache_line
|
|
110
|
+
path = run_metadata[:cache_path]
|
|
111
|
+
return nil if path.nil? || path.to_s.empty?
|
|
112
|
+
|
|
113
|
+
size_part = cache_size_suffix(path.to_s)
|
|
114
|
+
"cache: #{path}#{size_part}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Per-reason breakdown of the run examples (e.g. 12 Files
|
|
118
|
+
# changed · 5 No cache). Sourced from snapshot.cache_hit_reason
|
|
119
|
+
# which the engine populated via @filtered_examples.values.tally
|
|
120
|
+
# at finalize. Empty {} (cold run with no engine cache) suppresses
|
|
121
|
+
# the line entirely. Sorted by count descending so the
|
|
122
|
+
# most-impactful reason leads.
|
|
123
|
+
def kind_breakdown_line
|
|
124
|
+
reasons = snapshot.cache_hit_reason
|
|
125
|
+
return nil if reasons.nil? || reasons.empty?
|
|
126
|
+
|
|
127
|
+
parts = reasons
|
|
128
|
+
.sort_by { |_reason, count| -count.to_i }
|
|
129
|
+
.map { |reason, count| "#{count} #{reason}" }
|
|
130
|
+
paint(:cyan, "by reason: #{parts.join(SEPARATOR)}")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Bytes -> "12.3 MiB" / "456 KiB" / "789 B" depending on order.
|
|
134
|
+
# Mirrors JsonBackend#format_mib's MiB-and-up presentation but
|
|
135
|
+
# collapses small caches to KiB so a fixture spec writing 4 KiB
|
|
136
|
+
# doesn't display as "0.0 MiB."
|
|
137
|
+
def format_size_bytes(bytes)
|
|
138
|
+
return "#{bytes} B" if bytes < 1024
|
|
139
|
+
return "#{(bytes / 1024.0).round(1)} KiB" if bytes < BYTES_PER_MIB
|
|
140
|
+
|
|
141
|
+
"#{(bytes / BYTES_PER_MIB).round(1)} MiB"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# `(<size>)` or `(<size>; <delta>)` suffix for the cache line.
|
|
145
|
+
# Walks the current run-id dir for the size; walks the prior
|
|
146
|
+
# run-id dir (mtime-newest peer) for the delta. Wrapped in
|
|
147
|
+
# rescue so a transient FS error never blocks the surrounding
|
|
148
|
+
# cache_line emission.
|
|
149
|
+
def cache_size_suffix(cache_path)
|
|
150
|
+
current_id = snapshot.run_id
|
|
151
|
+
return '' if current_id.nil? || current_id.to_s.empty?
|
|
152
|
+
|
|
153
|
+
current_dir = File.join(cache_path, current_id)
|
|
154
|
+
return '' unless File.directory?(current_dir)
|
|
155
|
+
|
|
156
|
+
current_bytes = directory_size_bytes(current_dir)
|
|
157
|
+
prior_bytes = previous_run_dir_bytes(cache_path, current_id)
|
|
158
|
+
format_cache_suffix(current_bytes, prior_bytes)
|
|
159
|
+
rescue StandardError
|
|
160
|
+
''
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Internal method on the tracer pipeline.
|
|
164
|
+
# @api private
|
|
165
|
+
def format_cache_suffix(current_bytes, prior_bytes)
|
|
166
|
+
size = format_size_bytes(current_bytes)
|
|
167
|
+
return " (#{size})" if prior_bytes.nil?
|
|
168
|
+
|
|
169
|
+
delta = current_bytes - prior_bytes
|
|
170
|
+
sign = if delta.positive?
|
|
171
|
+
'+'
|
|
172
|
+
else
|
|
173
|
+
(delta.negative? ? '-' : '')
|
|
174
|
+
end
|
|
175
|
+
" (#{size}; #{sign}#{format_size_bytes(delta.abs)} vs prev run)"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Internal method on the tracer pipeline.
|
|
179
|
+
# @api private
|
|
180
|
+
def directory_size_bytes(dir)
|
|
181
|
+
Dir[File.join(dir, '**', '*')].sum do |path|
|
|
182
|
+
File.file?(path) ? File.size(path) : 0
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Internal method on the tracer pipeline.
|
|
187
|
+
# @api private
|
|
188
|
+
def previous_run_dir_bytes(cache_path, current_id)
|
|
189
|
+
peer_dirs = Dir.children(cache_path).filter_map do |name|
|
|
190
|
+
next if name == current_id || name.start_with?('.')
|
|
191
|
+
|
|
192
|
+
full = File.join(cache_path, name)
|
|
193
|
+
File.directory?(full) ? [full, File.mtime(full).to_f] : nil
|
|
194
|
+
end
|
|
195
|
+
return nil if peer_dirs.empty?
|
|
196
|
+
|
|
197
|
+
newest_peer = peer_dirs.max_by(&:last).first
|
|
198
|
+
directory_size_bytes(newest_peer)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Internal method on the tracer pipeline.
|
|
202
|
+
# @api private
|
|
203
|
+
def report_line
|
|
204
|
+
return nil if report_dir.nil? || report_dir.to_s.empty?
|
|
205
|
+
|
|
206
|
+
"report: #{File.join(report_dir, JsonReporter::FILENAME)}"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Internal method on the tracer pipeline.
|
|
210
|
+
# @api private
|
|
211
|
+
def header_color
|
|
212
|
+
return :red if snapshot.failed_examples.any? || snapshot.interrupted_examples.any?
|
|
213
|
+
return :yellow if snapshot.pending_examples.any?
|
|
214
|
+
|
|
215
|
+
:green
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Internal method on the tracer pipeline.
|
|
219
|
+
# @api private
|
|
220
|
+
def run_count
|
|
221
|
+
snapshot.all_examples.count do |_, meta|
|
|
222
|
+
meta.is_a?(::Hash) && meta[:execution_result]
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Internal method on the tracer pipeline.
|
|
227
|
+
# @api private
|
|
228
|
+
def duplicate_count
|
|
229
|
+
snapshot.duplicate_examples.size
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Internal method on the tracer pipeline.
|
|
233
|
+
# @api private
|
|
234
|
+
def cache_percent(total, skipped)
|
|
235
|
+
return 0 if total.zero?
|
|
236
|
+
|
|
237
|
+
((skipped.to_f / total) * 100).round
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Internal method on the tracer pipeline.
|
|
241
|
+
# @api private
|
|
242
|
+
def paint(color_key, text)
|
|
243
|
+
return text unless use_color?
|
|
244
|
+
|
|
245
|
+
code = COLORS.fetch(color_key, COLORS[:reset])
|
|
246
|
+
"\e[#{code}m#{text}\e[#{COLORS[:reset]}m"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Internal method on the tracer pipeline.
|
|
250
|
+
# @api private
|
|
251
|
+
def use_color?
|
|
252
|
+
return false if ENV.key?('NO_COLOR')
|
|
253
|
+
|
|
254
|
+
output_stream.respond_to?(:tty?) && output_stream.tty?
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Internal method on the tracer pipeline.
|
|
258
|
+
# @api private
|
|
259
|
+
def output_stream
|
|
260
|
+
options[:io] || $stdout
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# RSpec integration
|
|
2
|
+
|
|
3
|
+
Glue between the v2 engine and RSpec's runner lifecycle. One file per
|
|
4
|
+
responsibility.
|
|
5
|
+
|
|
6
|
+
| File | Role |
|
|
7
|
+
|------|------|
|
|
8
|
+
| [`installation.rb`](installation.rb) | Prepends `RunnerHook` + `ReporterHook` onto `RSpec::Core::Runner` / `RSpec::Core::Reporter`. Idempotent. Called once from `RSpecTracer.start`. |
|
|
9
|
+
| [`runner_hook.rb`](runner_hook.rb) | Overrides `run_specs`. Two-pass filter walk: Pass 1 reads `tracks:` metadata + registers per-example glob/env declarations on the engine; Pass 2 asks `Engine#run_example?` for filter decisions. Mutates `RSpec.world` to the filtered set, logs the `RSpec tracer is running N examples` banner. |
|
|
10
|
+
| [`reporter_hook.rb`](reporter_hook.rb) | Overrides `example_started`, `example_finished`, `example_passed`, `example_failed`, `example_pending`. Forwards into `Engine` for dependency attribution; coverage.json emission lives on the dedicated `Reporters::CoverageJsonReporter` finalize path. |
|
|
11
|
+
| [`parallel_tests.rb`](parallel_tests.rb) | `TEST_ENV_NUMBER` + `PARALLEL_TEST_GROUPS` detection, `rspec_tracer.lock` lifecycle, narrator selection for log silencing, last-process merge via `Storage::JsonBackend#merge_from_peers`. |
|
|
12
|
+
| [`metadata.rb`](metadata.rb) | Per-example `tracks:` DSL walker. Reads `tracks: { files: ..., env: ... }` off an example plus every ancestor group, unions the entries (RSpec's default metadata cascade would clobber on shared keys), and returns the merged `{ files:, env: }` for RunnerHook to register with the engine. |
|
|
13
|
+
|
|
14
|
+
## Load-order contract
|
|
15
|
+
|
|
16
|
+
`RSpecTracer.start` installs the hooks at require time on the classes,
|
|
17
|
+
not on an already-constructed Runner instance. Any subsequent
|
|
18
|
+
`RSpec::Core::Runner.new` carries `RunnerHook` in its ancestors chain.
|
|
19
|
+
This lets `RSpecTracer.start` run before RSpec has constructed its
|
|
20
|
+
Runner - the 1.x ObjectSpace-based install required mid-boot timing.
|
|
21
|
+
|
|
22
|
+
The expected user-facing sequence, unchanged:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
# spec_helper.rb (no Rails)
|
|
26
|
+
require 'simplecov' # optional
|
|
27
|
+
SimpleCov.start # optional
|
|
28
|
+
require 'rspec_tracer'
|
|
29
|
+
RSpecTracer.start
|
|
30
|
+
# application code loads after this point
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
# rails_helper.rb (Rails)
|
|
35
|
+
require 'simplecov'
|
|
36
|
+
SimpleCov.start
|
|
37
|
+
require_relative '../config/environment'
|
|
38
|
+
require 'rspec_tracer'
|
|
39
|
+
RSpecTracer.start
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
If coverage has already accumulated before `RSpecTracer.start`, the
|
|
43
|
+
Installation module logs a warn-level line. Users are still free to
|
|
44
|
+
ignore it - the tracer degrades to "attribute whatever we can observe
|
|
45
|
+
from now on".
|
|
46
|
+
|
|
47
|
+
## Per-example metadata DSL
|
|
48
|
+
|
|
49
|
+
Annotate a describe / context / example with `tracks: { files: ..., env: ... }`
|
|
50
|
+
to declare additional dependencies that Coverage + IO observation cannot see:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
describe 'AdminController',
|
|
54
|
+
tracks: { files: 'app/policies/**/*.rb', env: 'ROLE_CONFIG' } do
|
|
55
|
+
it 'gates on the feature flag' do
|
|
56
|
+
expect(enabled?).to be(true)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Both keys accept a String glob / env name OR an Array of them. Nested groups
|
|
62
|
+
contribute additively — a child group declaring `tracks: { env: 'X' }` does
|
|
63
|
+
NOT clobber an ancestor's `tracks: { files: 'Y' }`; both contribute to the
|
|
64
|
+
example's dependency set.
|
|
65
|
+
|
|
66
|
+
Internally: `Metadata.tracks_for(example)` walks `example.example_group
|
|
67
|
+
.parent_groups` plus the example itself and returns the union. `RunnerHook`
|
|
68
|
+
hands that to `Engine#register_tracks`, which resolves file globs to
|
|
69
|
+
`:declared`-kind Inputs and accumulates the env names for the finalize-time
|
|
70
|
+
`env_snapshot.json` write. Warm runs compare the previous run's env_snapshot
|
|
71
|
+
to the current ENV via `Tracker::EnvSnapshot#invalidated_keys` and mark any
|
|
72
|
+
example whose tracked env key drifted as re-runnable
|
|
73
|
+
(`EXAMPLE_RUN_REASON[:env_changed] => "Environment changed"`).
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rspec/core'
|
|
4
|
+
|
|
5
|
+
require_relative 'reporter_hook'
|
|
6
|
+
require_relative 'runner_hook'
|
|
7
|
+
|
|
8
|
+
module RSpecTracer
|
|
9
|
+
# Internal RSpec — see {RSpecTracer} for the user-facing surface.
|
|
10
|
+
# @api private
|
|
11
|
+
module RSpec
|
|
12
|
+
# Prepends RunnerHook + ReporterHook onto RSpec's runner/reporter
|
|
13
|
+
# classes. Called once from `RSpecTracer.start`.
|
|
14
|
+
#
|
|
15
|
+
# 1.x used `ObjectSpace.each_object(::RSpec::Core::Runner)` to find
|
|
16
|
+
# the already-instantiated runner and prepend onto its singleton
|
|
17
|
+
# class - forcing start-time ordering (RSpec had to be mid-boot).
|
|
18
|
+
# 2.0 prepends onto the class itself at require time, so the hooks
|
|
19
|
+
# apply to every subsequent Runner / Reporter instance.
|
|
20
|
+
#
|
|
21
|
+
# Consequence: `RSpecTracer.start` no longer requires RSpec to be
|
|
22
|
+
# running. As long as `RSpec::Core::Runner` and `RSpec::Core::Reporter`
|
|
23
|
+
# are loaded (the standard `require 'rspec'` in spec_helper covers
|
|
24
|
+
# both), install! succeeds. Side effect: the JRuby FULL_TRACE_ENABLED
|
|
25
|
+
# / object_space_enabled guard that 1.x needed is no longer load-
|
|
26
|
+
# bearing - prepend doesn't touch ObjectSpace.
|
|
27
|
+
#
|
|
28
|
+
# Idempotence: `Module#prepend` is a no-op when the module is already
|
|
29
|
+
# in the ancestors chain. Double-calling install! is safe.
|
|
30
|
+
module Installation
|
|
31
|
+
# `install!` is named for its side effect (prepend the hooks), not
|
|
32
|
+
# as a predicate - rubocop's Naming/PredicateMethod defaults to
|
|
33
|
+
# flagging anything that doesn't return a boolean, but the trailing
|
|
34
|
+
# `true` is the Ruby idiom for "side-effect method ran fine".
|
|
35
|
+
#
|
|
36
|
+
# Target classes are parameterized so unit specs can pass anonymous
|
|
37
|
+
# fresh classes instead of stubbing the real RSpec constants - the
|
|
38
|
+
# latter collides with RSpec's own reporter finalization at at_exit
|
|
39
|
+
# time (RSpec::Core::Time vanishes while the outer suite is still
|
|
40
|
+
# finalizing).
|
|
41
|
+
# rubocop:disable Naming/PredicateMethod
|
|
42
|
+
def self.install!(runner_class: ::RSpec::Core::Runner, reporter_class: ::RSpec::Core::Reporter)
|
|
43
|
+
warn_if_coverage_already_accumulated
|
|
44
|
+
|
|
45
|
+
prepend_hook(runner_class, RSpecTracer::RSpec::RunnerHook)
|
|
46
|
+
prepend_hook(reporter_class, RSpecTracer::RSpec::ReporterHook)
|
|
47
|
+
|
|
48
|
+
true
|
|
49
|
+
end
|
|
50
|
+
# rubocop:enable Naming/PredicateMethod
|
|
51
|
+
|
|
52
|
+
def self.prepend_hook(target_class, hook_module)
|
|
53
|
+
return if target_class.ancestors.include?(hook_module)
|
|
54
|
+
|
|
55
|
+
target_class.prepend(hook_module)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Emit a single warn-level line when the user's spec_helper
|
|
59
|
+
# triggered substantial application-code loads before calling
|
|
60
|
+
# `RSpecTracer.start`. The Coverage peek_result is per-file; we
|
|
61
|
+
# count files that already have at least one executed line. A
|
|
62
|
+
# non-SimpleCov fresh boot with >10 tracked files is the signal
|
|
63
|
+
# that app code loaded before the tracer started, which means
|
|
64
|
+
# boot-set capture misses those files as transitive dependencies.
|
|
65
|
+
#
|
|
66
|
+
# Conservative by design: skip the warning when SimpleCov is
|
|
67
|
+
# running (SimpleCov-first is the documented load order), and
|
|
68
|
+
# when Coverage hasn't started (defended-library edge case).
|
|
69
|
+
def self.warn_if_coverage_already_accumulated
|
|
70
|
+
return unless defined?(::Coverage)
|
|
71
|
+
return unless ::Coverage.respond_to?(:running?) && ::Coverage.running?
|
|
72
|
+
return if defined?(::SimpleCov) && ::SimpleCov.running
|
|
73
|
+
|
|
74
|
+
accumulated = _count_accumulated_files
|
|
75
|
+
return if accumulated < 10
|
|
76
|
+
|
|
77
|
+
RSpecTracer.logger.warn(
|
|
78
|
+
"RSpec tracer: coverage has already accumulated for #{accumulated} file(s) " \
|
|
79
|
+
'before RSpecTracer.start. Expected load order: RSpecTracer.start runs before ' \
|
|
80
|
+
'any application code (or after SimpleCov.start if using SimpleCov). Dependency ' \
|
|
81
|
+
'attribution may miss boot-time loads.'
|
|
82
|
+
)
|
|
83
|
+
rescue StandardError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Internal helper for the tracer pipeline.
|
|
88
|
+
# @api private
|
|
89
|
+
def self._count_accumulated_files
|
|
90
|
+
::Coverage.peek_result.count do |_, cov|
|
|
91
|
+
lines = cov.is_a?(::Hash) ? cov[:lines] : cov
|
|
92
|
+
lines.is_a?(::Array) && lines.any? { |strength| strength.is_a?(::Integer) && strength.positive? }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module RSpecTracer
|
|
6
|
+
# Internal RSpec — see {RSpecTracer} for the user-facing surface.
|
|
7
|
+
# @api private
|
|
8
|
+
module RSpec
|
|
9
|
+
# Per-example tracking DSL. Reads the `tracks:` metadata key
|
|
10
|
+
# off an example and its ancestor example groups and emits a
|
|
11
|
+
# normalized union of declared file globs + env-var names.
|
|
12
|
+
#
|
|
13
|
+
# DSL shape (user-facing):
|
|
14
|
+
#
|
|
15
|
+
# RSpec.describe 'AdminController',
|
|
16
|
+
# tracks: { files: 'app/policies/**/*.rb', env: 'ROLE_CONFIG' } do
|
|
17
|
+
# ...
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# Values for `:files` and `:env` accept either a single String or
|
|
21
|
+
# an Array of Strings. Nested groups each contribute their own
|
|
22
|
+
# tracks hash; the union (not the replace) is what the example
|
|
23
|
+
# inherits. RSpec's built-in metadata cascade uses Hash#merge
|
|
24
|
+
# which would clobber a parent `tracks:` with a child `tracks:`
|
|
25
|
+
# on a shared key - almost never the user's intent. `tracks_for`
|
|
26
|
+
# bypasses the auto-cascade and walks ancestors explicitly.
|
|
27
|
+
#
|
|
28
|
+
# Returns `{ files: Set<String>, env: Set<String> }`. Empty sets
|
|
29
|
+
# when nothing is declared - callers can short-circuit on
|
|
30
|
+
# `result[:files].empty? && result[:env].empty?` to skip the
|
|
31
|
+
# attribution/env-snapshot plumbing.
|
|
32
|
+
#
|
|
33
|
+
# Pure-function (module-level, no state). Safe to call from the
|
|
34
|
+
# RunnerHook filter loop without synchronization.
|
|
35
|
+
module Metadata
|
|
36
|
+
# Internal constant.
|
|
37
|
+
# @api private
|
|
38
|
+
TRACKS_KEY = :tracks
|
|
39
|
+
# Internal constant.
|
|
40
|
+
# @api private
|
|
41
|
+
FILES_KEY = :files
|
|
42
|
+
# Internal constant.
|
|
43
|
+
# @api private
|
|
44
|
+
ENV_KEY = :env
|
|
45
|
+
|
|
46
|
+
# Keep methods module-level via `def self.x` (not module_function)
|
|
47
|
+
# so mutant can observe them - module_function attaches the
|
|
48
|
+
# methods to an anonymous singleton that Method#source_location
|
|
49
|
+
# can't trace (memory: feedback_mutation_friendly_modules).
|
|
50
|
+
def self.tracks_for(example)
|
|
51
|
+
files = Set.new
|
|
52
|
+
envs = Set.new
|
|
53
|
+
|
|
54
|
+
collect(example.example_group.parent_groups, files, envs)
|
|
55
|
+
merge_hash(example.metadata[TRACKS_KEY], files, envs)
|
|
56
|
+
|
|
57
|
+
{ FILES_KEY => files, ENV_KEY => envs }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# parent_groups returns outer-first. Walk from outer to inner so
|
|
61
|
+
# the set-union is order-agnostic anyway; the order is
|
|
62
|
+
# documented for future readers who need it deterministic.
|
|
63
|
+
def self.collect(parent_groups, files, envs)
|
|
64
|
+
parent_groups.reverse_each do |group|
|
|
65
|
+
merge_hash(group.metadata[TRACKS_KEY], files, envs)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A `tracks:` value of nil, non-Hash, or an empty Hash contributes
|
|
70
|
+
# nothing. Non-Hash values are silently ignored - tolerating
|
|
71
|
+
# user typos over raising is consistent with the rest of the
|
|
72
|
+
# DSL surface (add_filter, coverage_track_files).
|
|
73
|
+
def self.merge_hash(tracks, files, envs)
|
|
74
|
+
return unless tracks.is_a?(Hash)
|
|
75
|
+
|
|
76
|
+
normalize(tracks[FILES_KEY]).each { |v| files << v }
|
|
77
|
+
normalize(tracks[ENV_KEY]).each { |v| envs << v }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# String -> [String]; Array -> itself; anything else -> []. nil,
|
|
81
|
+
# empty-string, and blank values are filtered so
|
|
82
|
+
# `tracks: { files: '', env: nil }` doesn't inject empty entries
|
|
83
|
+
# into the attribution set.
|
|
84
|
+
def self.normalize(value)
|
|
85
|
+
case value
|
|
86
|
+
when String
|
|
87
|
+
value.empty? ? [] : [value]
|
|
88
|
+
when Array
|
|
89
|
+
value.map(&:to_s).reject(&:empty?)
|
|
90
|
+
else
|
|
91
|
+
[]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|