rspec-signal 0.1.0
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 +68 -0
- data/LICENSE +21 -0
- data/README.md +672 -0
- data/exe/rspec-signal +11 -0
- data/exe/rspec-signal-parallel +52 -0
- data/lib/rspec/signal/backtrace/classifier.rb +88 -0
- data/lib/rspec/signal/backtrace/frame.rb +31 -0
- data/lib/rspec/signal/backtrace/parser.rb +61 -0
- data/lib/rspec/signal/backtrace/reducer.rb +244 -0
- data/lib/rspec/signal/cluster.rb +68 -0
- data/lib/rspec/signal/clusterer.rb +49 -0
- data/lib/rspec/signal/configuration.rb +144 -0
- data/lib/rspec/signal/failure.rb +62 -0
- data/lib/rspec/signal/failure_builder.rb +224 -0
- data/lib/rspec/signal/fingerprint.rb +56 -0
- data/lib/rspec/signal/formatter.rb +207 -0
- data/lib/rspec/signal/group.rb +61 -0
- data/lib/rspec/signal/grouper.rb +28 -0
- data/lib/rspec/signal/html_summary.rb +224 -0
- data/lib/rspec/signal/integrations/capybara.rb +102 -0
- data/lib/rspec/signal/message.rb +161 -0
- data/lib/rspec/signal/parallel_merger.rb +125 -0
- data/lib/rspec/signal/parallel_run.rb +56 -0
- data/lib/rspec/signal/project.rb +158 -0
- data/lib/rspec/signal/redactor.rb +91 -0
- data/lib/rspec/signal/report.rb +88 -0
- data/lib/rspec/signal/reporters/full_output.rb +38 -0
- data/lib/rspec/signal/reporters/json_report.rb +20 -0
- data/lib/rspec/signal/reporters/markdown.rb +271 -0
- data/lib/rspec/signal/reporters/related_failures.rb +101 -0
- data/lib/rspec/signal/symptom.rb +22 -0
- data/lib/rspec/signal/symptoms/exception_class.rb +46 -0
- data/lib/rspec/signal/symptoms/http_status.rb +98 -0
- data/lib/rspec/signal/symptoms/record.rb +56 -0
- data/lib/rspec/signal/symptoms/route.rb +45 -0
- data/lib/rspec/signal/symptoms/ruby_error.rb +55 -0
- data/lib/rspec/signal/symptoms/selector.rb +72 -0
- data/lib/rspec/signal/symptoms.rb +42 -0
- data/lib/rspec/signal/version.rb +7 -0
- data/lib/rspec/signal/writer.rb +88 -0
- data/lib/rspec/signal.rb +156 -0
- data/lib/rspec-signal.rb +3 -0
- metadata +117 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module RSpec
|
|
7
|
+
module Signal
|
|
8
|
+
# Rehydrates worker JSON and performs grouping once, over the complete run.
|
|
9
|
+
class ParallelMerger
|
|
10
|
+
Result = Struct.new(:report, :workers, :missing, :write_result, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
def initialize(registry:, config: RSpec::Signal.configuration)
|
|
13
|
+
@registry = registry
|
|
14
|
+
@config = config
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
paths = Dir[File.join(@registry, "*.path")].map { |file| File.read(file).strip }
|
|
19
|
+
documents, missing = paths.partition { |path| File.file?(path) }
|
|
20
|
+
payloads = documents.map { |path| JSON.parse(File.read(path)) }
|
|
21
|
+
validate_configuration!(payloads)
|
|
22
|
+
apply_configuration(payloads.first&.fetch("configuration", {}) || {})
|
|
23
|
+
report = aggregate(payloads)
|
|
24
|
+
result = Result.new(
|
|
25
|
+
report: report,
|
|
26
|
+
workers: payloads.size,
|
|
27
|
+
missing: missing,
|
|
28
|
+
write_result: Writer.new(@config).write(report)
|
|
29
|
+
)
|
|
30
|
+
# Only once we have successfully produced a report: a worker JSON that
|
|
31
|
+
# failed to parse should stay on disk for a human to look at, not be
|
|
32
|
+
# deleted along with everything else.
|
|
33
|
+
cleanup_worker_artifacts(paths)
|
|
34
|
+
result
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# Worker payloads are per-run (`workers/<run-id>/<worker-id>/signal.json`),
|
|
40
|
+
# so once merged into the top-level report they would otherwise sit in
|
|
41
|
+
# the project forever: a green run's own hygiene guarantee (stale
|
|
42
|
+
# artifacts never survive a passing suite) does not reach them, and each
|
|
43
|
+
# payload can carry the full unreduced output of every failure.
|
|
44
|
+
def cleanup_worker_artifacts(paths)
|
|
45
|
+
run_directories(paths).each { |directory| FileUtils.rm_rf(directory) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def run_directories(paths)
|
|
49
|
+
paths.filter_map { |path| File.dirname(path, 2) if path.include?("/workers/") }.uniq
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def aggregate(payloads)
|
|
53
|
+
summaries = payloads.map { |payload| payload.fetch("summary", {}) }
|
|
54
|
+
Report.new(
|
|
55
|
+
failures: load_failures(payloads),
|
|
56
|
+
example_count: sum(summaries, "examples"), failure_count: sum(summaries, "failures"),
|
|
57
|
+
pending_count: sum(summaries, "pending"),
|
|
58
|
+
duration: summaries.filter_map { |item| item["duration_seconds"] }.max,
|
|
59
|
+
environment: payloads.first&.fetch("environment", {}) || {},
|
|
60
|
+
errors_outside_examples: sum(summaries, "errors_outside_examples"), relate_failures: @config.relate_failures
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def load_failures(payloads)
|
|
65
|
+
payloads.flat_map do |payload|
|
|
66
|
+
payload.fetch("failures", []).map { |failure| load_failure(failure) }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def load_failure(data)
|
|
71
|
+
entries = data.fetch("trace", []).map { |entry| load_entry(entry) }
|
|
72
|
+
frames = entries.select(&:frame?)
|
|
73
|
+
reduced = Backtrace::Reduced.new(entries: entries, total: frames.size + data.fetch("omitted_frames", 0),
|
|
74
|
+
omitted: { serialized: data.fetch("omitted_frames", 0) })
|
|
75
|
+
fingerprint = data["fingerprint"] || {}
|
|
76
|
+
Failure.new(**failure_attributes(data, reduced, frames), fingerprint: load_fingerprint(fingerprint))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def failure_attributes(data, reduced, frames)
|
|
80
|
+
{ description: data.fetch("description"), spec_location: data.fetch("location"),
|
|
81
|
+
rerun: data["rerun"], example_id: data["id"], exception_class: data.fetch("exception"),
|
|
82
|
+
message: load_message(data.fetch("message", [])), reduced: reduced, frames: frames,
|
|
83
|
+
diagnostics: data.fetch("diagnostics", {}), raw: data["raw"] }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def load_fingerprint(data)
|
|
87
|
+
Fingerprint.new(exception_class: data["exception"], message: "worker", culprit: data["culprit"],
|
|
88
|
+
app_context: data["app_context"]).tap do |fingerprint|
|
|
89
|
+
fingerprint.instance_variable_set(:@digest, data["digest"])
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def load_entry(entry)
|
|
94
|
+
return Backtrace::Gap.new(count: entry.fetch("omitted"), kind: entry.fetch("kind").to_sym) if entry["omitted"]
|
|
95
|
+
|
|
96
|
+
location = entry.fetch("location")
|
|
97
|
+
match = location.match(/\A(.+):(\d+)\z/)
|
|
98
|
+
path, line = match ? match.captures : [location, nil]
|
|
99
|
+
Backtrace::Frame.new(raw: location, path: path, display_path: path, line: line&.to_i,
|
|
100
|
+
label: entry["label"], kind: entry.fetch("kind").to_sym, gem_name: entry["gem"])
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def load_message(lines)
|
|
104
|
+
Message.new(lines, redactor: @config.redactor, project: @config.project, html_threshold: nil)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def sum(items, key)
|
|
108
|
+
number = items.sum { |item| item.fetch(key, 0).to_f }
|
|
109
|
+
(number % 1).zero? ? number.to_i : number
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def validate_configuration!(payloads)
|
|
113
|
+
configurations = payloads.map { |payload| payload.fetch("configuration", {}) }.uniq
|
|
114
|
+
return if configurations.size <= 1
|
|
115
|
+
|
|
116
|
+
raise ArgumentError, "workers reported inconsistent rspec-signal configuration"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def apply_configuration(values)
|
|
120
|
+
values.each { |name, value| @config.public_send("#{name}=", value) if @config.respond_to?("#{name}=") }
|
|
121
|
+
@config.reset_memoized!
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module RSpec
|
|
7
|
+
module Signal
|
|
8
|
+
# Filesystem protocol shared by parallel_tests workers and the parent merger.
|
|
9
|
+
module ParallelRun
|
|
10
|
+
RUN_ID = "RSPEC_SIGNAL_RUN_ID"
|
|
11
|
+
REGISTRY = "RSPEC_SIGNAL_RUN_REGISTRY"
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def worker?
|
|
16
|
+
!ENV[RUN_ID].to_s.empty? && ENV.key?("TEST_ENV_NUMBER")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def worker_id
|
|
20
|
+
value = ENV.fetch("TEST_ENV_NUMBER", "").to_s
|
|
21
|
+
value.empty? ? "1" : value
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def write_worker(report, config)
|
|
25
|
+
directory = File.join(config.output_path, "workers", ENV.fetch(RUN_ID), worker_id)
|
|
26
|
+
FileUtils.mkdir_p(directory)
|
|
27
|
+
path = File.join(directory, "signal.json")
|
|
28
|
+
payload = report.worker_h(write_full: config.write_full)
|
|
29
|
+
.merge(worker: worker_id, configuration: configuration_h(config))
|
|
30
|
+
atomic_write(path, "#{JSON.pretty_generate(payload)}\n")
|
|
31
|
+
register(path)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def configuration_h(config)
|
|
35
|
+
%i[output_dir project_root max_frames max_external_context max_project_frames fallback_frames
|
|
36
|
+
max_message_lines max_diff_lines reduce_html max_html_chars max_affected_examples max_groups relate_failures
|
|
37
|
+
max_clusters max_cluster_specs write_json write_full write_gitignore terminal_summary capture_capybara
|
|
38
|
+
capture_page_html].to_h { |name| [name, config.public_send(name)] }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def register(path)
|
|
42
|
+
registry = ENV.fetch(REGISTRY)
|
|
43
|
+
FileUtils.mkdir_p(registry)
|
|
44
|
+
atomic_write(File.join(registry, "#{worker_id}.path"), "#{path}\n")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def atomic_write(path, contents)
|
|
48
|
+
temporary = "#{path}.#{Process.pid}.tmp"
|
|
49
|
+
File.write(temporary, contents)
|
|
50
|
+
File.rename(temporary, path)
|
|
51
|
+
ensure
|
|
52
|
+
FileUtils.rm_f(temporary) if defined?(temporary)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Signal
|
|
5
|
+
# Knows which paths belong to the project under test ("first party") and how
|
|
6
|
+
# to render any path compactly.
|
|
7
|
+
#
|
|
8
|
+
# First-party means: the host application's own code, plus code from gems
|
|
9
|
+
# that are developed alongside it (Bundler `path:` sources, local Rails
|
|
10
|
+
# engines). Those are things the agent can actually open and edit.
|
|
11
|
+
class Project
|
|
12
|
+
# Directories that live inside the project root but are not project code.
|
|
13
|
+
VENDORED = %w[
|
|
14
|
+
vendor/bundle vendor/cache vendor/ruby .bundle node_modules
|
|
15
|
+
tmp/ .git/ coverage/
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
# Matches ".../gems/capybara-3.40.0/lib/capybara/node/finders.rb".
|
|
19
|
+
#
|
|
20
|
+
# The leading `.*/` is greedy on purpose: a RubyGems install path contains
|
|
21
|
+
# "/gems/" twice ("/lib/ruby/gems/3.3.0/gems/capybara-3.40.0/..."), and it
|
|
22
|
+
# is the last one that introduces the gem.
|
|
23
|
+
GEM_PATH = %r{
|
|
24
|
+
\A.*/(?:gems|bundler/gems)/
|
|
25
|
+
(?<name>[A-Za-z0-9_.-]+?)
|
|
26
|
+
# A release version, or the short SHA Bundler uses for git sources.
|
|
27
|
+
(?:-(?<version>\d[\w.]*(?:-[a-z0-9]+)?|[0-9a-f]{12,40}))?
|
|
28
|
+
/(?<rest>.*)\z
|
|
29
|
+
}x
|
|
30
|
+
|
|
31
|
+
# Matches ".../lib/ruby/3.3.0/json/common.rb" and ".../ruby/3.3.0/x86_64-linux/..."
|
|
32
|
+
STDLIB_PATH = %r{/lib/ruby/(?:\d+\.\d+\.\d+|site_ruby|vendor_ruby)/(?:[a-z0-9_]+-[a-z0-9_-]+/)?(?<rest>.*)\z}
|
|
33
|
+
|
|
34
|
+
attr_reader :root
|
|
35
|
+
|
|
36
|
+
# @param root [String] absolute path to the project root
|
|
37
|
+
# @param extra_first_party [Array<String>] additional absolute path prefixes
|
|
38
|
+
# to treat as first party (e.g. a sibling engine checkout)
|
|
39
|
+
def initialize(root: Dir.pwd, extra_first_party: [])
|
|
40
|
+
@root = File.expand_path(root)
|
|
41
|
+
@root_prefix = "#{@root}/"
|
|
42
|
+
@extra = extra_first_party.map { |p| "#{File.expand_path(p)}/" }
|
|
43
|
+
@cache = {}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Absolute path prefixes of Bundler `path:` gems (local gems and engines).
|
|
47
|
+
# Discovered lazily and defensively: any Bundler problem simply means we
|
|
48
|
+
# fall back to plain root-relative detection.
|
|
49
|
+
def local_gem_prefixes
|
|
50
|
+
@local_gem_prefixes ||= discover_local_gem_prefixes
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def first_party?(path)
|
|
54
|
+
return false if path.nil? || path.empty?
|
|
55
|
+
|
|
56
|
+
@cache[path] ||= compute_first_party(path)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A short, stable, human/agent readable rendering of a path.
|
|
60
|
+
#
|
|
61
|
+
# /app/spec/models/user_spec.rb -> spec/models/user_spec.rb
|
|
62
|
+
# .../gems/capybara-3.40.0/lib/capybara/node/finders.rb -> capybara/node/finders.rb
|
|
63
|
+
# .../lib/ruby/3.3.0/json/common.rb -> ruby/json/common.rb
|
|
64
|
+
def display_path(path)
|
|
65
|
+
return path if path.nil? || path.empty?
|
|
66
|
+
|
|
67
|
+
absolute = absolutize(path)
|
|
68
|
+
return relative_to_root(absolute) if under?(absolute, @root_prefix) || first_party?(path)
|
|
69
|
+
|
|
70
|
+
if (m = GEM_PATH.match(absolute))
|
|
71
|
+
return "#{m[:name]}/#{strip_redundant_prefix(m[:name], m[:rest].sub(%r{\Alib/}, ""))}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
if (stdlib = STDLIB_PATH.match(absolute))
|
|
75
|
+
return "ruby/#{stdlib[:rest]}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
relative_to_root(absolute)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The gem a path belongs to, or nil.
|
|
82
|
+
def gem_name(path)
|
|
83
|
+
m = GEM_PATH.match(absolutize(path))
|
|
84
|
+
m && m[:name]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def absolutize(path)
|
|
88
|
+
stripped = path.to_s.sub(%r{\A\./}, "")
|
|
89
|
+
# Ruby emits pseudo-frames that are not file paths at all: "<internal:...>",
|
|
90
|
+
# "-e", "(eval)", "(irb)". Joining those to the project root would wrongly
|
|
91
|
+
# make them look first party.
|
|
92
|
+
return stripped if stripped.start_with?("/", "<", "-", "(")
|
|
93
|
+
|
|
94
|
+
File.join(@root, stripped)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def relative_to_root(absolute)
|
|
98
|
+
return absolute[@root_prefix.length..] if under?(absolute, @root_prefix)
|
|
99
|
+
|
|
100
|
+
absolute
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
# "activerecord" + "active_record/validations.rb" -> "validations.rb", so the
|
|
106
|
+
# rendered frame reads `activerecord/validations.rb` rather than repeating
|
|
107
|
+
# the gem name in two spellings. Only leading *directory* segments that
|
|
108
|
+
# spell out the gem name are removed.
|
|
109
|
+
def strip_redundant_prefix(gem_name, rest)
|
|
110
|
+
segments = rest.split("/")
|
|
111
|
+
dirs = segments[0..-2] || []
|
|
112
|
+
target = normalize_gem_name(gem_name)
|
|
113
|
+
|
|
114
|
+
dirs.size.downto(1) do |count|
|
|
115
|
+
return segments[count..].join("/") if normalize_gem_name(dirs[0, count].join) == target
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
rest
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def normalize_gem_name(name) = name.to_s.delete("-_")
|
|
122
|
+
|
|
123
|
+
def compute_first_party(path)
|
|
124
|
+
absolute = absolutize(path)
|
|
125
|
+
|
|
126
|
+
if under?(absolute, @root_prefix)
|
|
127
|
+
relative = absolute[@root_prefix.length..]
|
|
128
|
+
return false if VENDORED.any? { |d| relative.start_with?(d) }
|
|
129
|
+
# A gem unpacked anywhere under the root is still vendored code.
|
|
130
|
+
return false if GEM_PATH.match?(absolute)
|
|
131
|
+
|
|
132
|
+
return true
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
@extra.any? { |p| under?(absolute, p) } ||
|
|
136
|
+
local_gem_prefixes.any? { |p| under?(absolute, p) }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def under?(absolute, prefix)
|
|
140
|
+
absolute.start_with?(prefix)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def discover_local_gem_prefixes
|
|
144
|
+
return [] unless defined?(::Bundler)
|
|
145
|
+
|
|
146
|
+
::Bundler.load.specs.filter_map do |gem_spec|
|
|
147
|
+
source = gem_spec.source
|
|
148
|
+
next unless source.class.name.to_s.include?("Source::Path")
|
|
149
|
+
next unless gem_spec.full_gem_path
|
|
150
|
+
|
|
151
|
+
"#{File.expand_path(gem_spec.full_gem_path)}/"
|
|
152
|
+
end.uniq
|
|
153
|
+
rescue StandardError, ::LoadError
|
|
154
|
+
[]
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Signal
|
|
5
|
+
# Best-effort scrubbing of obvious credentials before a report leaves the
|
|
6
|
+
# machine.
|
|
7
|
+
#
|
|
8
|
+
# This is a safety net, not a guarantee. It targets shapes that are
|
|
9
|
+
# unambiguous (token prefixes, auth headers, credential-ish assignments) and
|
|
10
|
+
# deliberately does not try to guess at arbitrary secret values, because
|
|
11
|
+
# false positives destroy the diagnostic value of a report.
|
|
12
|
+
#
|
|
13
|
+
# Always review artifacts before sending them somewhere you do not control.
|
|
14
|
+
class Redactor
|
|
15
|
+
PLACEHOLDER = "[REDACTED]"
|
|
16
|
+
|
|
17
|
+
# Keys whose *values* are considered sensitive.
|
|
18
|
+
SENSITIVE_KEY = /
|
|
19
|
+
(?:api[_-]?key|secret[_-]?key|access[_-]?key|client[_-]?secret|private[_-]?key|
|
|
20
|
+
secret|password|passwd|pwd|token|auth[_-]?token|access[_-]?token|refresh[_-]?token|
|
|
21
|
+
session[_-]?id|csrf|cookie|authorization|credentials?)
|
|
22
|
+
/xi
|
|
23
|
+
|
|
24
|
+
DEFAULT_PATTERNS = [
|
|
25
|
+
# Authorization: Bearer xyz / Basic xyz
|
|
26
|
+
/\b(Authorization\s*[:=]\s*["']?\s*(?:Bearer|Basic|Token)\s+)[^\s"',;)\]}]+/i,
|
|
27
|
+
# key: "value" / key => 'value' / key=value / "key":"value"
|
|
28
|
+
/(["']?#{SENSITIVE_KEY.source}["']?\s*(?:=>|[:=])\s*)(["'])(?:(?!\2).){3,}\2/xi,
|
|
29
|
+
/(\b#{SENSITIVE_KEY.source}\s*=\s*)(?!["'])[^\s"',;&)\]}]{3,}/xi,
|
|
30
|
+
# URL query parameters
|
|
31
|
+
/([?&]#{SENSITIVE_KEY.source}=)[^&\s"'<>]+/xi,
|
|
32
|
+
# URL userinfo: https://user:pass@host
|
|
33
|
+
%r{(\b[a-z][a-z0-9+.-]*://[^/\s:@]+:)[^/\s@]+(@)}i,
|
|
34
|
+
# Well-known token shapes
|
|
35
|
+
/\bAKIA[0-9A-Z]{16}\b/,
|
|
36
|
+
/\bASIA[0-9A-Z]{16}\b/,
|
|
37
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}\b/,
|
|
38
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/,
|
|
39
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/,
|
|
40
|
+
/\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{10,}\b/,
|
|
41
|
+
/\bglpat-[A-Za-z0-9_-]{16,}\b/,
|
|
42
|
+
/\bAIza[0-9A-Za-z_-]{30,}\b/,
|
|
43
|
+
# JWTs
|
|
44
|
+
/\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*/,
|
|
45
|
+
# PEM blocks
|
|
46
|
+
/-----BEGIN[A-Z ]*PRIVATE KEY-----.*?-----END[A-Z ]*PRIVATE KEY-----/m
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
def initialize(enabled: true, patterns: DEFAULT_PATTERNS, extra_patterns: [], filter: nil)
|
|
50
|
+
@enabled = enabled
|
|
51
|
+
@patterns = patterns + Array(extra_patterns)
|
|
52
|
+
@filter = filter
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def enabled? = @enabled
|
|
56
|
+
|
|
57
|
+
# @param text [String, nil]
|
|
58
|
+
# @return [String, nil]
|
|
59
|
+
def call(text)
|
|
60
|
+
return text if text.nil? || !@enabled
|
|
61
|
+
|
|
62
|
+
result = text.dup
|
|
63
|
+
@patterns.each do |pattern|
|
|
64
|
+
result = result.gsub(pattern) do |match|
|
|
65
|
+
replacement_for(pattern, match, Regexp.last_match)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
result = @filter.call(result) if @filter
|
|
69
|
+
result
|
|
70
|
+
end
|
|
71
|
+
alias scrub call
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# Patterns with capture groups keep the identifying prefix so the report
|
|
76
|
+
# still says *what* was redacted.
|
|
77
|
+
def replacement_for(_pattern, match, captures)
|
|
78
|
+
prefix = captures[1]
|
|
79
|
+
return PLACEHOLDER if prefix.nil?
|
|
80
|
+
|
|
81
|
+
suffix = captures[2] if captures[2] && captures[2] == "@"
|
|
82
|
+
quote = captures[2] if captures[2] && %w[" '].include?(captures[2])
|
|
83
|
+
|
|
84
|
+
return "#{prefix}#{quote}#{PLACEHOLDER}#{quote}" if quote
|
|
85
|
+
return "#{prefix}#{PLACEHOLDER}#{suffix}" if suffix
|
|
86
|
+
|
|
87
|
+
"#{prefix}#{PLACEHOLDER}" if match
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Signal
|
|
5
|
+
# The complete, renderer-independent result of a run.
|
|
6
|
+
class Report
|
|
7
|
+
attr_reader :failures, :groups, :clusters, :example_count, :failure_count, :pending_count,
|
|
8
|
+
:duration, :seed, :seed_used, :environment, :errors_outside_examples
|
|
9
|
+
|
|
10
|
+
def initialize(failures:, example_count: 0, failure_count: nil, pending_count: 0,
|
|
11
|
+
duration: nil, seed: nil, seed_used: false, environment: {},
|
|
12
|
+
errors_outside_examples: 0, relate_failures: true)
|
|
13
|
+
@failures = failures
|
|
14
|
+
@groups = Grouper.call(failures)
|
|
15
|
+
@clusters = relate_failures ? safely { Clusterer.call(failures) } : []
|
|
16
|
+
@example_count = example_count
|
|
17
|
+
@failure_count = failure_count || failures.size
|
|
18
|
+
@pending_count = pending_count
|
|
19
|
+
@duration = duration
|
|
20
|
+
@seed = seed
|
|
21
|
+
@seed_used = seed_used
|
|
22
|
+
@environment = environment
|
|
23
|
+
@errors_outside_examples = errors_outside_examples
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Clustering is the newest and least essential stage; a report without it
|
|
27
|
+
# is still worth having, so it is never allowed to take the run down.
|
|
28
|
+
def safely
|
|
29
|
+
yield
|
|
30
|
+
rescue StandardError
|
|
31
|
+
[]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def group_count = groups.size
|
|
35
|
+
def cluster_count = clusters.size
|
|
36
|
+
def any_failures? = !failures.empty?
|
|
37
|
+
def seed_used? = !!@seed_used
|
|
38
|
+
|
|
39
|
+
# Backtrace frames dropped across the whole run. This is the number that
|
|
40
|
+
# makes the reduction visible.
|
|
41
|
+
def omitted_frames
|
|
42
|
+
@omitted_frames ||= failures.sum { |failure| failure.reduced.omitted_count }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def total_frames
|
|
46
|
+
@total_frames ||= failures.sum { |failure| failure.reduced.total }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def kept_frames = total_frames - omitted_frames
|
|
50
|
+
|
|
51
|
+
def to_h
|
|
52
|
+
{
|
|
53
|
+
schema: 1,
|
|
54
|
+
generated_by: "rspec-signal #{VERSION}",
|
|
55
|
+
summary: {
|
|
56
|
+
examples: example_count,
|
|
57
|
+
failures: failure_count,
|
|
58
|
+
pending: pending_count,
|
|
59
|
+
signatures: group_count,
|
|
60
|
+
related_clusters: cluster_count.positive? ? cluster_count : nil,
|
|
61
|
+
duration_seconds: duration&.round(3),
|
|
62
|
+
seed: seed_used? ? seed : nil,
|
|
63
|
+
errors_outside_examples: errors_outside_examples.positive? ? errors_outside_examples : nil
|
|
64
|
+
}.compact,
|
|
65
|
+
environment: environment,
|
|
66
|
+
backtrace_reduction: { total_frames: total_frames, kept_frames: kept_frames, omitted_frames: omitted_frames },
|
|
67
|
+
signatures: groups.map(&:to_h),
|
|
68
|
+
related: clusters.map(&:to_h)
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Worker interchange data. Unlike the public report summary this retains
|
|
73
|
+
# every reduced failure so grouping can be repeated across all workers.
|
|
74
|
+
#
|
|
75
|
+
# `raw` -- the unreduced formatter output -- is included only when
|
|
76
|
+
# `write_full` is on, since it is otherwise discarded on merge and would
|
|
77
|
+
# just bloat every worker payload for no reason.
|
|
78
|
+
def worker_h(write_full: false)
|
|
79
|
+
serialized = failures.map do |failure|
|
|
80
|
+
attributes = failure.to_h.merge(fingerprint: failure.fingerprint.to_h)
|
|
81
|
+
attributes[:raw] = failure.raw if write_full
|
|
82
|
+
attributes
|
|
83
|
+
end
|
|
84
|
+
to_h.merge(schema: 2, failures: serialized)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Signal
|
|
5
|
+
module Reporters
|
|
6
|
+
# The unreduced failure output, preserved verbatim.
|
|
7
|
+
#
|
|
8
|
+
# This exists so reduction is never lossy in practice: if the compact
|
|
9
|
+
# report dropped the one frame that mattered, the original is one file
|
|
10
|
+
# away. It is not the artifact you hand to an agent.
|
|
11
|
+
class FullOutput
|
|
12
|
+
def initialize(report, config)
|
|
13
|
+
@report = report
|
|
14
|
+
@config = config
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def render
|
|
18
|
+
out = ["rspec-signal #{VERSION} -- unreduced failure output", ""]
|
|
19
|
+
@report.failures.each_with_index do |failure, index|
|
|
20
|
+
out << (failure.raw || fallback(failure, index + 1))
|
|
21
|
+
out << ""
|
|
22
|
+
end
|
|
23
|
+
"#{out.join("\n").rstrip}\n"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def fallback(failure, position)
|
|
29
|
+
lines = [" #{position}) #{failure.description}",
|
|
30
|
+
" #{failure.exception_class}:",
|
|
31
|
+
*failure.message.lines.map { |line| " #{line}" }]
|
|
32
|
+
lines.concat(failure.frames.map { |frame| " # #{frame}" })
|
|
33
|
+
lines.join("\n")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module Signal
|
|
7
|
+
module Reporters
|
|
8
|
+
# Machine-readable twin of the Markdown report, for tooling and CI.
|
|
9
|
+
class JsonReport
|
|
10
|
+
def initialize(report, _config = nil)
|
|
11
|
+
@report = report
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def render
|
|
15
|
+
"#{JSON.pretty_generate(@report.to_h)}\n"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|