bulldogger 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/LICENSE +21 -0
- data/README.md +262 -0
- data/docs/design-decisions.md +142 -0
- data/docs/evidence-schema.md +388 -0
- data/docs/maintenance.md +92 -0
- data/docs/trace-schema.md +118 -0
- data/lib/bulldogger/capture.rb +98 -0
- data/lib/bulldogger/config.rb +57 -0
- data/lib/bulldogger/evidence.rb +106 -0
- data/lib/bulldogger/formatter.rb +103 -0
- data/lib/bulldogger/frame_source.rb +147 -0
- data/lib/bulldogger/integrations/minitest.rb +87 -0
- data/lib/bulldogger/integrations/rspec.rb +56 -0
- data/lib/bulldogger/minitest.rb +7 -0
- data/lib/bulldogger/pending.rb +58 -0
- data/lib/bulldogger/probe/bucket.rb +90 -0
- data/lib/bulldogger/probe/comparator.rb +95 -0
- data/lib/bulldogger/probe/method_stats.rb +215 -0
- data/lib/bulldogger/probe/raise_tracker.rb +141 -0
- data/lib/bulldogger/probe/registry.rb +32 -0
- data/lib/bulldogger/probe/session.rb +159 -0
- data/lib/bulldogger/probe/target.rb +13 -0
- data/lib/bulldogger/probe/target_resolver.rb +86 -0
- data/lib/bulldogger/probe/writer.rb +61 -0
- data/lib/bulldogger/probe.rb +36 -0
- data/lib/bulldogger/record/session.rb +334 -0
- data/lib/bulldogger/record/sqlite_converter.rb +86 -0
- data/lib/bulldogger/record/writer.rb +67 -0
- data/lib/bulldogger/record.rb +51 -0
- data/lib/bulldogger/redactor.rb +30 -0
- data/lib/bulldogger/rspec.rb +7 -0
- data/lib/bulldogger/run.rb +113 -0
- data/lib/bulldogger/version.rb +5 -0
- data/lib/bulldogger.rb +133 -0
- data/skills/bulldogger/SKILL.md +37 -0
- data/skills/bulldogger/references/failure-evidence.md +56 -0
- data/skills/bulldogger/references/probe.md +36 -0
- data/skills/bulldogger/references/record.md +28 -0
- metadata +153 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulldogger
|
|
4
|
+
# Runtime knobs for capture, redaction, and file output. Each attribute
|
|
5
|
+
# has a hard-coded default so the tool works with zero setup; the
|
|
6
|
+
# environment variable overrides exist so an agent running tests in a
|
|
7
|
+
# child process can change behavior (for example, pointing output_dir
|
|
8
|
+
# at a scratch directory) without editing the app's own config file.
|
|
9
|
+
class Config
|
|
10
|
+
DEFAULT_REDACT_PATTERNS = [
|
|
11
|
+
/pass(?:word|wd)?/i,
|
|
12
|
+
/secret/i,
|
|
13
|
+
/token/i,
|
|
14
|
+
/api[_-]?key/i,
|
|
15
|
+
/\bkey\b/i,
|
|
16
|
+
/credential/i,
|
|
17
|
+
/auth/i,
|
|
18
|
+
/session/i,
|
|
19
|
+
/cookie/i
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
attr_accessor :enabled, :output_dir, :max_frames, :max_locals,
|
|
23
|
+
:max_value_length, :max_pending, :max_samples, :redact_patterns,
|
|
24
|
+
:frame_source
|
|
25
|
+
|
|
26
|
+
def initialize
|
|
27
|
+
@enabled = true
|
|
28
|
+
@output_dir = "tmp/bulldogger"
|
|
29
|
+
@max_frames = 20
|
|
30
|
+
@max_locals = 50
|
|
31
|
+
@max_value_length = 200
|
|
32
|
+
@max_pending = 32
|
|
33
|
+
@max_samples = 10
|
|
34
|
+
@redact_patterns = DEFAULT_REDACT_PATTERNS.dup
|
|
35
|
+
@frame_source = :auto
|
|
36
|
+
apply_env_overrides
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def apply_env_overrides
|
|
42
|
+
output_dir_override = ENV["BULLDOGGER_OUTPUT_DIR"]
|
|
43
|
+
@output_dir = output_dir_override if output_dir_override && !output_dir_override.empty?
|
|
44
|
+
|
|
45
|
+
case ENV["BULLDOGGER_FRAME_SOURCE"]
|
|
46
|
+
when "capture_frames" then @frame_source = :capture_frames
|
|
47
|
+
when "degraded" then @frame_source = :degraded
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# BULLDOGGER_DISABLE is the documented name; BULLDOGGER_DISABLED is
|
|
51
|
+
# accepted too. An adjective is the form English speakers reach
|
|
52
|
+
# for first, and a kill switch that silently ignores the natural
|
|
53
|
+
# spelling is worse than one that accepts an extra name.
|
|
54
|
+
@enabled = false if ENV["BULLDOGGER_DISABLE"] == "1" || ENV["BULLDOGGER_DISABLED"] == "1"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "version"
|
|
5
|
+
|
|
6
|
+
module Bulldogger
|
|
7
|
+
# Assembles and writes one evidence file: the exception, the test it
|
|
8
|
+
# failed in, and (if the ring still has it) the snapshot captured at
|
|
9
|
+
# :raise time.
|
|
10
|
+
#
|
|
11
|
+
# The exception's message and backtrace are read here, at report
|
|
12
|
+
# time, not inside the :raise hook. Exception#backtrace can still be
|
|
13
|
+
# nil when :raise fires -- Ruby fills it in as the exception
|
|
14
|
+
# unwinds -- so reading it from the hook would record less than what
|
|
15
|
+
# a test framework's own failure report already has by the time it
|
|
16
|
+
# calls record_failure.
|
|
17
|
+
class Evidence
|
|
18
|
+
SLUG_MAX_LENGTH = 80
|
|
19
|
+
|
|
20
|
+
def initialize(config:, run:, capture:)
|
|
21
|
+
@config = config
|
|
22
|
+
@run = run
|
|
23
|
+
@capture = capture
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def record_failure(exception:, test:)
|
|
27
|
+
# A disabled switch means "wrote nothing" -- not "wrote an empty
|
|
28
|
+
# missed record". Writing capture_mode: missed here would mean
|
|
29
|
+
# a user who turned this off still gets a run directory and a
|
|
30
|
+
# frames_unavailable_reason, which reads as "tried and failed"
|
|
31
|
+
# rather than "did not run", the opposite of what they asked for.
|
|
32
|
+
return nil unless @config.enabled
|
|
33
|
+
return nil if exception.nil?
|
|
34
|
+
|
|
35
|
+
path = @run.next_path(slug_for(test))
|
|
36
|
+
payload = build_payload(exception: exception, test: test)
|
|
37
|
+
File.write(path, "#{JSON.pretty_generate(payload)}\n")
|
|
38
|
+
@run.record(path, test: payload["test"], exception_summary: payload["exception"].slice("class", "message"))
|
|
39
|
+
path
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def build_payload(exception:, test:)
|
|
45
|
+
snapshot = @capture.snapshot_for(exception)
|
|
46
|
+
payload = {
|
|
47
|
+
"schema_version" => 1,
|
|
48
|
+
"tool" => { "name" => "bulldogger", "version" => Bulldogger::VERSION },
|
|
49
|
+
"captured_at" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
50
|
+
"capture_mode" => snapshot ? snapshot["capture_mode"] : "missed",
|
|
51
|
+
"test" => normalize_test(test),
|
|
52
|
+
"exception" => build_exception_section(exception),
|
|
53
|
+
"frames" => snapshot ? snapshot["frames"] : []
|
|
54
|
+
}
|
|
55
|
+
frames_omitted = snapshot ? snapshot["frames_omitted"] : 0
|
|
56
|
+
payload["frames_omitted"] = frames_omitted if frames_omitted&.positive?
|
|
57
|
+
payload["frames_unavailable_reason"] = @capture.reason_for_missing(exception) unless snapshot
|
|
58
|
+
payload["limits"] = limits_section
|
|
59
|
+
payload
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def build_exception_section(exception)
|
|
63
|
+
message = exception.message.to_s
|
|
64
|
+
limit = @config.max_value_length * 5
|
|
65
|
+
section = {
|
|
66
|
+
"class" => exception_class_name(exception),
|
|
67
|
+
"message" => message.length > limit ? "#{message[0, limit]}…" : message
|
|
68
|
+
}
|
|
69
|
+
if message.length > limit
|
|
70
|
+
section["message_truncated"] = true
|
|
71
|
+
section["message_original_length"] = message.length
|
|
72
|
+
end
|
|
73
|
+
section["backtrace"] = Array(exception.backtrace).first(@config.max_frames)
|
|
74
|
+
section
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def exception_class_name(exception)
|
|
78
|
+
exception.class.name || exception.class.to_s
|
|
79
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
80
|
+
"Object"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def normalize_test(test)
|
|
84
|
+
test ||= {}
|
|
85
|
+
{
|
|
86
|
+
"framework" => test[:framework],
|
|
87
|
+
"id" => test[:id],
|
|
88
|
+
"file" => test[:file],
|
|
89
|
+
"line" => test[:line]
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def limits_section
|
|
94
|
+
{
|
|
95
|
+
"max_frames" => @config.max_frames,
|
|
96
|
+
"max_locals" => @config.max_locals,
|
|
97
|
+
"max_value_length" => @config.max_value_length
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def slug_for(test)
|
|
102
|
+
raw = (test && test[:id] || "unknown").to_s
|
|
103
|
+
raw.gsub(/[^A-Za-z0-9_-]/, "-")[0, SLUG_MAX_LENGTH]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulldogger
|
|
4
|
+
# Turns an arbitrary Ruby value into a bounded, JSON-safe String.
|
|
5
|
+
# Bounded because a snapshot is written on every failing test, so an
|
|
6
|
+
# unbounded value (a huge String, a deep object graph) would make
|
|
7
|
+
# capture itself expensive. Safe because `inspect` is arbitrary user
|
|
8
|
+
# code: it can raise, and the object it runs on may descend from
|
|
9
|
+
# BasicObject, where even `#class` is undefined.
|
|
10
|
+
class Formatter
|
|
11
|
+
MAX_ELEMENTS = 10
|
|
12
|
+
|
|
13
|
+
def initialize(config:, redactor:)
|
|
14
|
+
@max_value_length = config.max_value_length
|
|
15
|
+
@redactor = redactor
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Returns the per-local entry shape: {"value" => str} normally, plus
|
|
19
|
+
# "truncated"/"original_length" when the final string was too long.
|
|
20
|
+
# Those extra keys are the record that something was cut -- they
|
|
21
|
+
# must never appear on a value that was not, so a reader can trust
|
|
22
|
+
# their absence.
|
|
23
|
+
def format(value)
|
|
24
|
+
truncate_entry(render(value))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Returns just the (possibly truncated) String, for JSON slots that
|
|
28
|
+
# hold a plain string rather than a {"value"=>...} entry -- a
|
|
29
|
+
# frame's "self", for example.
|
|
30
|
+
def format_self(value)
|
|
31
|
+
format(value)["value"]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def render(value)
|
|
37
|
+
case value
|
|
38
|
+
when Array
|
|
39
|
+
render_array(value)
|
|
40
|
+
when Hash
|
|
41
|
+
render_hash(value)
|
|
42
|
+
else
|
|
43
|
+
safe_inspect(value)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def render_array(array)
|
|
48
|
+
kept = array.first(MAX_ELEMENTS)
|
|
49
|
+
parts = kept.map { |element| render_nested(element) }
|
|
50
|
+
parts << "…" if array.size > kept.size
|
|
51
|
+
"[#{parts.join(', ')}]"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def render_hash(hash)
|
|
55
|
+
kept = hash.first(MAX_ELEMENTS)
|
|
56
|
+
parts = kept.map { |key, value| render_pair(key, value) }
|
|
57
|
+
parts << "…" if hash.size > kept.size
|
|
58
|
+
"{#{parts.join(', ')}}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def render_pair(key, value)
|
|
62
|
+
key_repr = render_nested(key)
|
|
63
|
+
value_repr = @redactor.redact_key?(key) ? '"[REDACTED]"' : render_nested(value)
|
|
64
|
+
"#{key_repr} => #{value_repr}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# One level of expansion only: an Array/Hash found *inside* an
|
|
68
|
+
# Array/Hash is shown as a placeholder, not walked further. Without
|
|
69
|
+
# this, a self-referential or very deep structure could make a
|
|
70
|
+
# single value's rendering unbounded even with max_value_length
|
|
71
|
+
# trimming the end result.
|
|
72
|
+
def render_nested(value)
|
|
73
|
+
case value
|
|
74
|
+
when Array then "[…]"
|
|
75
|
+
when Hash then "{…}"
|
|
76
|
+
else safe_inspect(value)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def safe_inspect(value)
|
|
81
|
+
value.inspect
|
|
82
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
83
|
+
"#<#{safe_class_name(value)} (inspect raised #{e.class})>"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def safe_class_name(value)
|
|
87
|
+
klass = value.class
|
|
88
|
+
klass.respond_to?(:name) ? (klass.name || klass.to_s) : klass.to_s
|
|
89
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
90
|
+
"Object"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def truncate_entry(str)
|
|
94
|
+
return { "value" => str } if str.length <= @max_value_length
|
|
95
|
+
|
|
96
|
+
{
|
|
97
|
+
"value" => str[0, @max_value_length] + "…",
|
|
98
|
+
"truncated" => true,
|
|
99
|
+
"original_length" => str.length
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulldogger
|
|
4
|
+
# Turns a :raise TracePoint event into a bounded array of frame
|
|
5
|
+
# descriptions. Prefers DEBUGGER__.capture_frames, which yields every
|
|
6
|
+
# frame's Binding; falls back to TracePoint#binding (raising frame
|
|
7
|
+
# only) plus the exception's backtrace locations (position only, no
|
|
8
|
+
# locals) when `debug/frame_info` cannot be loaded. That fallback is
|
|
9
|
+
# not a rare edge case: `debug` is a bundled gem, not a default gem,
|
|
10
|
+
# so under Bundler it is only present when the app's own Gemfile asks
|
|
11
|
+
# for it.
|
|
12
|
+
class FrameSource
|
|
13
|
+
def initialize(config:, formatter:, redactor:, skip_path_prefix: self.class.default_skip_path_prefix)
|
|
14
|
+
@config = config
|
|
15
|
+
@formatter = formatter
|
|
16
|
+
@redactor = redactor
|
|
17
|
+
@skip_path_prefix = skip_path_prefix
|
|
18
|
+
@mode = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# skip_path_prefix must be *this library's* lib directory, not the
|
|
22
|
+
# app's. capture_frames drops every frame whose path starts with
|
|
23
|
+
# the given prefix; pointing it at the app's own lib directory was
|
|
24
|
+
# measured to make the app's frames disappear instead of ours,
|
|
25
|
+
# leaving a snapshot with zero useful frames.
|
|
26
|
+
def self.default_skip_path_prefix
|
|
27
|
+
File.expand_path("..", __dir__)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# :auto resolves once, here, and is cached for the life of this
|
|
31
|
+
# object -- not re-checked on every raise, which would repeat a
|
|
32
|
+
# `require` check thousands of times in a large suite.
|
|
33
|
+
def resolve!
|
|
34
|
+
@mode ||= resolve_mode
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def mode
|
|
39
|
+
@mode ||= resolve_mode
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def capture(tp)
|
|
43
|
+
mode == :capture_frames ? capture_via_debugger(tp) : capture_degraded(tp)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def resolve_mode
|
|
49
|
+
configured = @config.frame_source
|
|
50
|
+
return :degraded if configured == :degraded
|
|
51
|
+
|
|
52
|
+
# Explicit :capture_frames still needs this require to have run --
|
|
53
|
+
# it is what defines the DEBUGGER__ constant this class calls
|
|
54
|
+
# into. Only :degraded can skip it; :auto needs the result to
|
|
55
|
+
# decide, and explicit :capture_frames needs it as a side effect
|
|
56
|
+
# even though the caller has already made the decision.
|
|
57
|
+
available = capture_frames_available?
|
|
58
|
+
return available ? :capture_frames : :degraded if configured == :auto
|
|
59
|
+
|
|
60
|
+
configured
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def capture_frames_available?
|
|
64
|
+
require "debug/frame_info"
|
|
65
|
+
DEBUGGER__.respond_to?(:capture_frames)
|
|
66
|
+
rescue LoadError
|
|
67
|
+
false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def capture_via_debugger(tp)
|
|
71
|
+
frame_infos = DEBUGGER__.capture_frames(@skip_path_prefix)
|
|
72
|
+
kept = frame_infos.first(@config.max_frames)
|
|
73
|
+
frames = kept.each_with_index.map { |frame_info, index| build_frame(frame_info, index) }
|
|
74
|
+
[frames, frame_infos.size - kept.size]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def build_frame(frame_info, index)
|
|
78
|
+
location = frame_info.location
|
|
79
|
+
frame = {
|
|
80
|
+
"index" => index,
|
|
81
|
+
"path" => location&.path,
|
|
82
|
+
"line" => location&.lineno,
|
|
83
|
+
"label" => frame_info.name,
|
|
84
|
+
"self" => @formatter.format_self(frame_info.self)
|
|
85
|
+
}
|
|
86
|
+
binding = frame_info.binding
|
|
87
|
+
locals, locals_omitted = binding ? build_locals(binding) : [{}, 0]
|
|
88
|
+
frame["locals"] = locals
|
|
89
|
+
frame["locals_omitted"] = locals_omitted if locals_omitted.positive?
|
|
90
|
+
frame
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def capture_degraded(tp)
|
|
94
|
+
locations = degraded_locations(tp)
|
|
95
|
+
kept = locations.first(@config.max_frames)
|
|
96
|
+
frames = kept.each_with_index.map { |location, index| build_degraded_frame(tp, location, index) }
|
|
97
|
+
[frames, locations.size - kept.size]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def degraded_locations(tp)
|
|
101
|
+
locations = tp.raised_exception.backtrace_locations
|
|
102
|
+
return locations if locations
|
|
103
|
+
|
|
104
|
+
# caller_locations here includes this hook's own frames (unlike
|
|
105
|
+
# backtrace_locations, which is the app's own backtrace and never
|
|
106
|
+
# contains ours), so they need the same prefix filter.
|
|
107
|
+
Array(caller_locations).reject { |location| location.path&.start_with?(@skip_path_prefix) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def build_degraded_frame(tp, location, index)
|
|
111
|
+
frame = {
|
|
112
|
+
"index" => index,
|
|
113
|
+
"path" => location.path,
|
|
114
|
+
"line" => location.lineno,
|
|
115
|
+
"label" => location.label
|
|
116
|
+
}
|
|
117
|
+
if index.zero?
|
|
118
|
+
locals, locals_omitted = build_frame0_locals(tp)
|
|
119
|
+
frame["locals"] = locals
|
|
120
|
+
frame["locals_omitted"] = locals_omitted if locals_omitted.positive?
|
|
121
|
+
frame["self"] = @formatter.format_self(tp.self)
|
|
122
|
+
else
|
|
123
|
+
frame["locals_unavailable"] = true
|
|
124
|
+
end
|
|
125
|
+
frame
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def build_frame0_locals(tp)
|
|
129
|
+
binding = tp.binding
|
|
130
|
+
binding ? build_locals(binding) : [{}, 0]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def build_locals(binding)
|
|
134
|
+
names = binding.local_variables
|
|
135
|
+
kept = names.first(@config.max_locals)
|
|
136
|
+
locals = {}
|
|
137
|
+
kept.each { |name| locals[name.to_s] = build_local_entry(name, binding) }
|
|
138
|
+
[locals, names.size - kept.size]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def build_local_entry(name, binding)
|
|
142
|
+
return { "redacted" => true, "reason" => "name" } if @redactor.redact_name?(name)
|
|
143
|
+
|
|
144
|
+
@formatter.format(binding.local_variable_get(name))
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest"
|
|
4
|
+
require_relative "../../bulldogger"
|
|
5
|
+
|
|
6
|
+
module Bulldogger
|
|
7
|
+
module Minitest
|
|
8
|
+
# Minitest's own extension point: Minitest.register_plugin with a
|
|
9
|
+
# Module makes Minitest call #minitest_plugin_init once, during
|
|
10
|
+
# Minitest.run, at the one moment Minitest.reporter is set (the
|
|
11
|
+
# accessor is nil outside that window). That is why Bulldogger.start
|
|
12
|
+
# and the reporter wiring happen here and not at require time --
|
|
13
|
+
# nothing has run yet, so nothing needs the TracePoint before this.
|
|
14
|
+
def self.minitest_plugin_init(_options)
|
|
15
|
+
return if @wired
|
|
16
|
+
|
|
17
|
+
@wired = true
|
|
18
|
+
Bulldogger.start
|
|
19
|
+
::Minitest.reporter << Reporter.new
|
|
20
|
+
::Minitest.after_run do
|
|
21
|
+
Bulldogger.finish
|
|
22
|
+
Bulldogger.stop
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Turns one finished test into a Bulldogger evidence file.
|
|
27
|
+
# Registered into Minitest's CompositeReporter, so #record runs
|
|
28
|
+
# once per test method, pass or fail. Minitest.run_one_method
|
|
29
|
+
# always returns a Minitest::Result, never the Test instance
|
|
30
|
+
# itself, so the test's id/file/line come from Result's own
|
|
31
|
+
# klass/name/source_location rather than from re-deriving them.
|
|
32
|
+
class Reporter < ::Minitest::AbstractReporter
|
|
33
|
+
def record(result)
|
|
34
|
+
failure = result.failure
|
|
35
|
+
return if failure.nil? || result.skipped?
|
|
36
|
+
|
|
37
|
+
exception = exception_for(failure)
|
|
38
|
+
path = Bulldogger.record_failure(exception: exception, test: test_for(result))
|
|
39
|
+
annotate!(failure, path) if path
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# UnexpectedError wraps whatever the app actually raised;
|
|
45
|
+
# #error unwraps it, and is defined to return self on a plain
|
|
46
|
+
# Assertion, so this one line also covers assertion failures
|
|
47
|
+
# without a separate branch. The wrapper itself is only ever
|
|
48
|
+
# `.new`'d, never `raise`'d, so :raise only fires for the inner
|
|
49
|
+
# exception -- snapshot_for is still tried on both, in that
|
|
50
|
+
# order, so nothing breaks if a future Minitest version starts
|
|
51
|
+
# re-raising the wrapper instead.
|
|
52
|
+
def exception_for(failure)
|
|
53
|
+
inner = failure.respond_to?(:error) ? failure.error : failure
|
|
54
|
+
return inner if Bulldogger.snapshot_for(inner)
|
|
55
|
+
return failure if Bulldogger.snapshot_for(failure)
|
|
56
|
+
|
|
57
|
+
inner
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def test_for(result)
|
|
61
|
+
file, line = result.source_location
|
|
62
|
+
{
|
|
63
|
+
framework: "minitest",
|
|
64
|
+
id: "#{result.class_name}##{result.name}",
|
|
65
|
+
file: file,
|
|
66
|
+
line: line
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# First choice: tag the failure exception's own #message, so the
|
|
71
|
+
# one line sits inside the same text Minitest's SummaryReporter
|
|
72
|
+
# already prints for this failure. A raised exception can be
|
|
73
|
+
# frozen (a re-raised literal, an app that freezes its errors),
|
|
74
|
+
# and #define_singleton_method on a frozen object raises --
|
|
75
|
+
# fall back to printing the line ourselves so a frozen exception
|
|
76
|
+
# never means a silently missing evidence line.
|
|
77
|
+
def annotate!(failure, path)
|
|
78
|
+
original = failure.message
|
|
79
|
+
failure.define_singleton_method(:message) { "#{original}\nbulldogger evidence: #{path}" }
|
|
80
|
+
rescue FrozenError
|
|
81
|
+
$stdout.puts "bulldogger evidence: #{path}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
::Minitest.register_plugin(Bulldogger::Minitest)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/core"
|
|
4
|
+
require_relative "../../bulldogger"
|
|
5
|
+
|
|
6
|
+
module Bulldogger
|
|
7
|
+
module RSpec
|
|
8
|
+
# RSpec has already scored the example's description and its
|
|
9
|
+
# file/line into metadata by the time after(:each) runs; test_for
|
|
10
|
+
# reads those off the Example instead of re-deriving them.
|
|
11
|
+
def self.test_for(example)
|
|
12
|
+
{
|
|
13
|
+
framework: "rspec",
|
|
14
|
+
id: example.full_description,
|
|
15
|
+
file: example.metadata[:file_path],
|
|
16
|
+
line: example.metadata[:line_number]
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Mirrors the minitest integration's annotate!: tag the exception's
|
|
21
|
+
# own #message first, so the evidence line sits inside the same
|
|
22
|
+
# text RSpec's own formatter already prints for this failure. A
|
|
23
|
+
# frozen exception can't take a singleton method; fall back to
|
|
24
|
+
# printing the line directly so a frozen exception never means a
|
|
25
|
+
# silently missing evidence line.
|
|
26
|
+
def self.annotate!(exception, path)
|
|
27
|
+
original = exception.message
|
|
28
|
+
exception.define_singleton_method(:message) { "#{original}\nbulldogger evidence: #{path}" }
|
|
29
|
+
rescue FrozenError
|
|
30
|
+
$stdout.puts "bulldogger evidence: #{path}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
::RSpec.configure do |config|
|
|
36
|
+
config.before(:suite) { Bulldogger.start }
|
|
37
|
+
|
|
38
|
+
# after(:each), not a formatter hook: example.exception is already
|
|
39
|
+
# set by the time this runs (Example#run assigns it before
|
|
40
|
+
# run_after_example fires the after(:each) hooks), and evidence
|
|
41
|
+
# must be written -- and the exception's #message annotated -- before
|
|
42
|
+
# RSpec's own formatters render the failure, or the added line would
|
|
43
|
+
# never reach the user's terminal.
|
|
44
|
+
config.after(:each) do |example|
|
|
45
|
+
exception = example.exception
|
|
46
|
+
next unless exception
|
|
47
|
+
|
|
48
|
+
path = Bulldogger.record_failure(exception: exception, test: Bulldogger::RSpec.test_for(example))
|
|
49
|
+
Bulldogger::RSpec.annotate!(exception, path) if path
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
config.after(:suite) do
|
|
53
|
+
Bulldogger.finish
|
|
54
|
+
Bulldogger.stop
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The one line a test_helper.rb adds to turn Bulldogger on for
|
|
4
|
+
# Minitest. Kept separate from lib/bulldogger/integrations/minitest.rb
|
|
5
|
+
# so that file can stay organized by framework alongside rspec.rb,
|
|
6
|
+
# while this stays the short, memorable require path.
|
|
7
|
+
require_relative "integrations/minitest"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulldogger
|
|
4
|
+
# Insertion-ordered, bounded map from an exception object to its
|
|
5
|
+
# captured snapshot. Bounded because most raises are caught and
|
|
6
|
+
# handled by the app and never become a test failure -- without a
|
|
7
|
+
# cap, a suite that raises-and-rescues heavily would grow this map
|
|
8
|
+
# without limit. Matches by object identity (`equal?`), not `hash`/
|
|
9
|
+
# `eql?`, so an exception class that overrides those can't collide
|
|
10
|
+
# with an unrelated exception instance.
|
|
11
|
+
#
|
|
12
|
+
# ObjectSpace::WeakMap was considered and rejected: its eviction is
|
|
13
|
+
# driven by GC timing, which we cannot observe or bound, and its
|
|
14
|
+
# membership semantics would need the same identity-matching logic
|
|
15
|
+
# this class already provides directly.
|
|
16
|
+
class Pending
|
|
17
|
+
Entry = Struct.new(:exception, :snapshot)
|
|
18
|
+
|
|
19
|
+
def initialize(max_size)
|
|
20
|
+
@max_size = max_size
|
|
21
|
+
@entries = []
|
|
22
|
+
@evicted = []
|
|
23
|
+
@mutex = Mutex.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# First-write-wins: a re-raised exception (rescue; raise) fires
|
|
27
|
+
# :raise again from the rescue frame. That second capture is worth
|
|
28
|
+
# less than the first -- it points at the handler, not the bug --
|
|
29
|
+
# so an exception already in the ring keeps its original snapshot.
|
|
30
|
+
def put(exception, snapshot)
|
|
31
|
+
@mutex.synchronize do
|
|
32
|
+
next if find(exception)
|
|
33
|
+
|
|
34
|
+
@entries << Entry.new(exception, snapshot)
|
|
35
|
+
next unless @entries.size > @max_size
|
|
36
|
+
|
|
37
|
+
evicted_entry = @entries.shift
|
|
38
|
+
@evicted << evicted_entry.exception
|
|
39
|
+
@evicted.shift if @evicted.size > @max_size
|
|
40
|
+
end
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def get(exception)
|
|
45
|
+
@mutex.synchronize { find(exception)&.snapshot }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def evicted?(exception)
|
|
49
|
+
@mutex.synchronize { @evicted.any? { |e| e.equal?(exception) } }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def find(exception)
|
|
55
|
+
@entries.find { |entry| entry.exception.equal?(exception) }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|