backspin 0.8.0 → 0.10.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 +4 -4
- data/CHANGELOG.md +14 -0
- data/CLAUDE.md +13 -8
- data/CONTRIBUTING.md +11 -7
- data/Gemfile.lock +1 -1
- data/MATCHERS.md +18 -0
- data/README.md +42 -6
- data/docs/backspin-result-api-sketch.md +205 -0
- data/lib/backspin/backspin_result.rb +66 -0
- data/lib/backspin/command_diff.rb +80 -24
- data/lib/backspin/matcher.rb +65 -63
- data/lib/backspin/record.rb +36 -23
- data/lib/backspin/recorder.rb +28 -21
- data/lib/backspin/snapshot.rb +129 -0
- data/lib/backspin/version.rb +1 -1
- data/lib/backspin.rb +65 -37
- metadata +4 -4
- data/lib/backspin/command.rb +0 -117
- data/lib/backspin/command_result.rb +0 -58
- data/lib/backspin/record_result.rb +0 -153
data/lib/backspin/matcher.rb
CHANGED
|
@@ -1,63 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Backspin
|
|
4
|
-
# Handles matching logic between
|
|
4
|
+
# Handles matching logic between expected and actual snapshots.
|
|
5
5
|
class Matcher
|
|
6
|
-
attr_reader :config, :
|
|
6
|
+
attr_reader :config, :expected, :actual
|
|
7
7
|
|
|
8
|
-
def initialize(config:,
|
|
8
|
+
def initialize(config:, expected:, actual:)
|
|
9
9
|
@config = normalize_config(config)
|
|
10
|
-
@
|
|
11
|
-
@
|
|
10
|
+
@expected = expected
|
|
11
|
+
@actual = actual
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
# @return [Boolean] true if
|
|
14
|
+
# @return [Boolean] true if snapshots match according to configured matcher
|
|
15
15
|
def match?
|
|
16
|
-
|
|
17
|
-
# Default behavior: check all fields for equality
|
|
18
|
-
default_matcher.call(recorded_command.to_h, actual_command.to_h)
|
|
19
|
-
elsif config.is_a?(Proc)
|
|
20
|
-
config.call(recorded_command.to_h, actual_command.to_h)
|
|
21
|
-
elsif config.is_a?(Hash)
|
|
22
|
-
verify_with_hash_matcher
|
|
23
|
-
else
|
|
24
|
-
raise ArgumentError, "Invalid matcher type: #{config.class}"
|
|
25
|
-
end
|
|
16
|
+
evaluation[:match]
|
|
26
17
|
end
|
|
27
18
|
|
|
28
19
|
# @return [String] reason why matching failed
|
|
29
20
|
def failure_reason
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if config.nil?
|
|
33
|
-
# Default matcher checks all fields
|
|
34
|
-
recorded_hash = recorded_command.to_h
|
|
35
|
-
actual_hash = actual_command.to_h
|
|
36
|
-
|
|
37
|
-
reasons << "stdout differs" if recorded_hash["stdout"] != actual_hash["stdout"]
|
|
38
|
-
reasons << "stderr differs" if recorded_hash["stderr"] != actual_hash["stderr"]
|
|
39
|
-
reasons << "exit status differs" if recorded_hash["status"] != actual_hash["status"]
|
|
40
|
-
elsif config.is_a?(Hash)
|
|
41
|
-
recorded_hash = recorded_command.to_h
|
|
42
|
-
actual_hash = actual_command.to_h
|
|
43
|
-
|
|
44
|
-
# Only check matchers that were provided
|
|
45
|
-
config.each do |field, matcher_proc|
|
|
46
|
-
case field
|
|
47
|
-
when :all
|
|
48
|
-
reasons << ":all matcher failed" unless matcher_proc.call(recorded_hash, actual_hash)
|
|
49
|
-
when :stdout, :stderr, :status
|
|
50
|
-
unless matcher_proc.call(recorded_hash[field.to_s], actual_hash[field.to_s])
|
|
51
|
-
reasons << "#{field} custom matcher failed"
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
else
|
|
56
|
-
# Proc matcher
|
|
57
|
-
reasons << "custom matcher failed"
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
reasons.join(", ")
|
|
21
|
+
evaluation[:reason]
|
|
61
22
|
end
|
|
62
23
|
|
|
63
24
|
private
|
|
@@ -78,32 +39,73 @@ module Backspin
|
|
|
78
39
|
config
|
|
79
40
|
end
|
|
80
41
|
|
|
81
|
-
def
|
|
82
|
-
|
|
83
|
-
|
|
42
|
+
def evaluation
|
|
43
|
+
@evaluation ||= if config.nil?
|
|
44
|
+
evaluate_default
|
|
45
|
+
elsif config.is_a?(Proc)
|
|
46
|
+
evaluate_proc
|
|
47
|
+
elsif config.is_a?(Hash)
|
|
48
|
+
evaluate_hash
|
|
49
|
+
else
|
|
50
|
+
raise ArgumentError, "Invalid matcher type: #{config.class}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def evaluate_default
|
|
55
|
+
reasons = []
|
|
56
|
+
reasons << "stdout differs" if expected.stdout != actual.stdout
|
|
57
|
+
reasons << "stderr differs" if expected.stderr != actual.stderr
|
|
58
|
+
reasons << "exit status differs" if expected.status != actual.status
|
|
84
59
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
60
|
+
{match: reasons.empty?, reason: reasons.join(", ")}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def evaluate_proc
|
|
64
|
+
match = !!config.call(deep_dup(expected_hash), deep_dup(actual_hash))
|
|
65
|
+
reason = match ? "" : "custom matcher failed"
|
|
66
|
+
{match: match, reason: reason}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def evaluate_hash
|
|
70
|
+
reasons = []
|
|
71
|
+
|
|
72
|
+
config.each do |field, matcher_proc|
|
|
73
|
+
passed = case field
|
|
89
74
|
when :all
|
|
90
|
-
matcher_proc.call(
|
|
75
|
+
matcher_proc.call(deep_dup(expected_hash), deep_dup(actual_hash))
|
|
91
76
|
when :stdout, :stderr, :status
|
|
92
|
-
matcher_proc.call(
|
|
77
|
+
matcher_proc.call(deep_dup(expected.public_send(field)), deep_dup(actual.public_send(field)))
|
|
93
78
|
else
|
|
94
|
-
# This should never happen due to normalize_config validation
|
|
95
79
|
raise ArgumentError, "Unknown field: #{field}"
|
|
96
80
|
end
|
|
81
|
+
|
|
82
|
+
next if passed
|
|
83
|
+
|
|
84
|
+
reasons << ":all matcher failed" if field == :all
|
|
85
|
+
reasons << "#{field} custom matcher failed" if %i[stdout stderr status].include?(field)
|
|
97
86
|
end
|
|
98
87
|
|
|
99
|
-
|
|
88
|
+
{match: reasons.empty?, reason: reasons.join(", ")}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def expected_hash
|
|
92
|
+
@expected_hash ||= expected.to_h
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def actual_hash
|
|
96
|
+
@actual_hash ||= actual.to_h
|
|
100
97
|
end
|
|
101
98
|
|
|
102
|
-
def
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
99
|
+
def deep_dup(value)
|
|
100
|
+
case value
|
|
101
|
+
when Hash
|
|
102
|
+
value.transform_values { |entry| deep_dup(entry) }
|
|
103
|
+
when Array
|
|
104
|
+
value.map { |entry| deep_dup(entry) }
|
|
105
|
+
when String
|
|
106
|
+
value.dup
|
|
107
|
+
else
|
|
108
|
+
value
|
|
107
109
|
end
|
|
108
110
|
end
|
|
109
111
|
end
|
data/lib/backspin/record.rb
CHANGED
|
@@ -4,8 +4,8 @@ module Backspin
|
|
|
4
4
|
class RecordFormatError < StandardError; end
|
|
5
5
|
|
|
6
6
|
class Record
|
|
7
|
-
FORMAT_VERSION = "
|
|
8
|
-
attr_reader :path, :
|
|
7
|
+
FORMAT_VERSION = "4.0"
|
|
8
|
+
attr_reader :path, :snapshot, :recorded_at
|
|
9
9
|
|
|
10
10
|
def self.load_or_create(path)
|
|
11
11
|
record = new(path)
|
|
@@ -35,28 +35,31 @@ module Backspin
|
|
|
35
35
|
|
|
36
36
|
def initialize(path)
|
|
37
37
|
@path = path
|
|
38
|
-
@
|
|
39
|
-
@
|
|
38
|
+
@snapshot = nil
|
|
39
|
+
@recorded_at = nil
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
def
|
|
43
|
-
@
|
|
44
|
-
@
|
|
42
|
+
def set_snapshot(snapshot)
|
|
43
|
+
@snapshot = snapshot
|
|
44
|
+
@recorded_at ||= snapshot.recorded_at
|
|
45
45
|
self
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
def save(filter: nil)
|
|
49
49
|
FileUtils.mkdir_p(File.dirname(@path))
|
|
50
|
+
snapshot_data = @snapshot&.to_h
|
|
51
|
+
snapshot_data = filter.call(deep_dup(snapshot_data)) if snapshot_data && filter
|
|
50
52
|
record_data = {
|
|
51
|
-
"first_recorded_at" => @first_recorded_at,
|
|
52
53
|
"format_version" => FORMAT_VERSION,
|
|
53
|
-
"
|
|
54
|
+
"recorded_at" => @recorded_at,
|
|
55
|
+
"snapshot" => snapshot_data
|
|
54
56
|
}
|
|
55
57
|
File.write(@path, record_data.to_yaml)
|
|
56
58
|
end
|
|
57
59
|
|
|
58
60
|
def reload
|
|
59
|
-
@
|
|
61
|
+
@snapshot = nil
|
|
62
|
+
@recorded_at = nil
|
|
60
63
|
load_from_file if File.exist?(@path)
|
|
61
64
|
end
|
|
62
65
|
|
|
@@ -65,19 +68,14 @@ module Backspin
|
|
|
65
68
|
end
|
|
66
69
|
|
|
67
70
|
def empty?
|
|
68
|
-
@
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def size
|
|
72
|
-
@commands.size
|
|
71
|
+
@snapshot.nil?
|
|
73
72
|
end
|
|
74
73
|
|
|
75
74
|
def clear
|
|
76
|
-
@
|
|
75
|
+
@snapshot = nil
|
|
76
|
+
@recorded_at = nil
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
-
# private
|
|
80
|
-
|
|
81
79
|
def load_from_file
|
|
82
80
|
data = YAML.load_file(@path.to_s)
|
|
83
81
|
|
|
@@ -85,15 +83,30 @@ module Backspin
|
|
|
85
83
|
raise RecordFormatError, "Invalid record format: expected format version #{FORMAT_VERSION}"
|
|
86
84
|
end
|
|
87
85
|
|
|
88
|
-
|
|
89
|
-
unless
|
|
90
|
-
raise RecordFormatError, "Invalid record format: missing
|
|
86
|
+
snapshot_data = data["snapshot"]
|
|
87
|
+
unless snapshot_data.is_a?(Hash)
|
|
88
|
+
raise RecordFormatError, "Invalid record format: missing snapshot"
|
|
91
89
|
end
|
|
92
90
|
|
|
93
|
-
@
|
|
94
|
-
@
|
|
91
|
+
@recorded_at = data["recorded_at"]
|
|
92
|
+
@snapshot = Snapshot.from_h(snapshot_data)
|
|
95
93
|
rescue Psych::SyntaxError => e
|
|
96
94
|
raise RecordFormatError, "Invalid record format: #{e.message}"
|
|
97
95
|
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def deep_dup(value)
|
|
100
|
+
case value
|
|
101
|
+
when Hash
|
|
102
|
+
value.transform_values { |entry| deep_dup(entry) }
|
|
103
|
+
when Array
|
|
104
|
+
value.map { |entry| deep_dup(entry) }
|
|
105
|
+
when String
|
|
106
|
+
value.dup
|
|
107
|
+
else
|
|
108
|
+
value
|
|
109
|
+
end
|
|
110
|
+
end
|
|
98
111
|
end
|
|
99
112
|
end
|
data/lib/backspin/recorder.rb
CHANGED
|
@@ -6,21 +6,22 @@ require "backspin/command_diff"
|
|
|
6
6
|
module Backspin
|
|
7
7
|
# Handles capture-mode recording and verification
|
|
8
8
|
class Recorder
|
|
9
|
-
attr_reader :mode, :record, :matcher, :filter
|
|
9
|
+
attr_reader :mode, :record, :matcher, :filter, :filter_on
|
|
10
10
|
|
|
11
|
-
def initialize(mode: :record, record: nil, matcher: nil, filter: nil)
|
|
11
|
+
def initialize(mode: :record, record: nil, matcher: nil, filter: nil, filter_on: :both)
|
|
12
12
|
@mode = mode
|
|
13
13
|
@record = record
|
|
14
14
|
@matcher = matcher
|
|
15
15
|
@filter = filter
|
|
16
|
+
@filter_on = filter_on
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
# Performs capture recording by intercepting all stdout/stderr output
|
|
19
20
|
def perform_capture_recording
|
|
20
21
|
captured_stdout, captured_stderr, output = capture_output { yield }
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
actual_snapshot = Snapshot.new(
|
|
24
|
+
command_type: Backspin::Capturer,
|
|
24
25
|
args: ["<captured block>"],
|
|
25
26
|
stdout: captured_stdout,
|
|
26
27
|
stderr: captured_stderr,
|
|
@@ -28,29 +29,31 @@ module Backspin
|
|
|
28
29
|
recorded_at: Time.now.iso8601
|
|
29
30
|
)
|
|
30
31
|
|
|
31
|
-
record.
|
|
32
|
+
record.set_snapshot(actual_snapshot)
|
|
32
33
|
record.save(filter: @filter)
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
BackspinResult.new(
|
|
36
|
+
mode: :record,
|
|
37
|
+
record_path: record.path,
|
|
38
|
+
actual: actual_snapshot,
|
|
39
|
+
output: output
|
|
40
|
+
)
|
|
35
41
|
end
|
|
36
42
|
|
|
37
43
|
# Performs capture verification by capturing output and comparing with recorded values
|
|
38
44
|
def perform_capture_verification
|
|
39
45
|
raise RecordNotFoundError, "Record not found: #{record.path}" unless record.exists?
|
|
40
|
-
raise RecordNotFoundError, "No
|
|
41
|
-
if record.commands.size != 1
|
|
42
|
-
raise RecordFormatError, "Invalid record format: expected 1 command for capture, found #{record.commands.size}"
|
|
43
|
-
end
|
|
46
|
+
raise RecordNotFoundError, "No snapshot found in record #{record.path}" if record.empty?
|
|
44
47
|
|
|
45
|
-
|
|
46
|
-
unless
|
|
48
|
+
expected_snapshot = record.snapshot
|
|
49
|
+
unless expected_snapshot.command_type == Backspin::Capturer
|
|
47
50
|
raise RecordFormatError, "Invalid record format: expected Backspin::Capturer for capture"
|
|
48
51
|
end
|
|
49
52
|
|
|
50
53
|
captured_stdout, captured_stderr, output = capture_output { yield }
|
|
51
54
|
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
actual_snapshot = Snapshot.new(
|
|
56
|
+
command_type: Backspin::Capturer,
|
|
54
57
|
args: ["<captured block>"],
|
|
55
58
|
stdout: captured_stdout,
|
|
56
59
|
stderr: captured_stderr,
|
|
@@ -58,17 +61,21 @@ module Backspin
|
|
|
58
61
|
)
|
|
59
62
|
|
|
60
63
|
command_diff = CommandDiff.new(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
matcher: @matcher
|
|
64
|
+
expected: expected_snapshot,
|
|
65
|
+
actual: actual_snapshot,
|
|
66
|
+
matcher: @matcher,
|
|
67
|
+
filter: @filter,
|
|
68
|
+
filter_on: @filter_on
|
|
64
69
|
)
|
|
65
70
|
|
|
66
|
-
|
|
67
|
-
output: output,
|
|
71
|
+
BackspinResult.new(
|
|
68
72
|
mode: :verify,
|
|
73
|
+
record_path: record.path,
|
|
74
|
+
actual: actual_snapshot,
|
|
75
|
+
expected: expected_snapshot,
|
|
69
76
|
verified: command_diff.verified?,
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
command_diff: command_diff,
|
|
78
|
+
output: output
|
|
72
79
|
)
|
|
73
80
|
end
|
|
74
81
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Backspin
|
|
4
|
+
# Represents a single captured execution snapshot.
|
|
5
|
+
class Snapshot
|
|
6
|
+
attr_reader :command_type, :args, :env, :stdout, :stderr, :status, :recorded_at
|
|
7
|
+
|
|
8
|
+
def initialize(command_type:, args:, env: nil, stdout: "", stderr: "", status: 0, recorded_at: nil)
|
|
9
|
+
@command_type = command_type
|
|
10
|
+
@args = sanitize_args(args)
|
|
11
|
+
@env = env.nil? ? nil : sanitize_env(env)
|
|
12
|
+
@stdout = Backspin.scrub_text((stdout || "").dup).freeze
|
|
13
|
+
@stderr = Backspin.scrub_text((stderr || "").dup).freeze
|
|
14
|
+
@status = status || 0
|
|
15
|
+
@recorded_at = recorded_at.nil? ? nil : recorded_at.dup.freeze
|
|
16
|
+
@serialized_hash = build_serialized_hash
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def success?
|
|
20
|
+
status.zero?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def failure?
|
|
24
|
+
!success?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_h
|
|
28
|
+
@serialized_hash
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.from_h(data)
|
|
32
|
+
command_type = case data["command_type"]
|
|
33
|
+
when "Open3::Capture3"
|
|
34
|
+
Open3::Capture3
|
|
35
|
+
when "Backspin::Capturer"
|
|
36
|
+
Backspin::Capturer
|
|
37
|
+
else
|
|
38
|
+
raise RecordFormatError, "Unknown command type: #{data["command_type"]}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
new(
|
|
42
|
+
command_type: command_type,
|
|
43
|
+
args: data["args"],
|
|
44
|
+
env: data["env"],
|
|
45
|
+
stdout: data["stdout"],
|
|
46
|
+
stderr: data["stderr"],
|
|
47
|
+
status: data["status"],
|
|
48
|
+
recorded_at: data["recorded_at"]
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def build_serialized_hash
|
|
55
|
+
data = {
|
|
56
|
+
"command_type" => command_type.name,
|
|
57
|
+
"args" => args,
|
|
58
|
+
"stdout" => stdout,
|
|
59
|
+
"stderr" => stderr,
|
|
60
|
+
"status" => status,
|
|
61
|
+
"recorded_at" => recorded_at
|
|
62
|
+
}
|
|
63
|
+
data["env"] = env if env
|
|
64
|
+
deep_freeze(data)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def scrub_args(value)
|
|
68
|
+
return value unless Backspin.configuration.scrub_credentials && value
|
|
69
|
+
|
|
70
|
+
case value
|
|
71
|
+
when String
|
|
72
|
+
Backspin.scrub_text(value)
|
|
73
|
+
when Array
|
|
74
|
+
value.map { |entry| scrub_args(entry) }
|
|
75
|
+
when Hash
|
|
76
|
+
value.transform_values { |entry| entry.is_a?(String) ? Backspin.scrub_text(entry) : entry }
|
|
77
|
+
else
|
|
78
|
+
value
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def scrub_env(value)
|
|
83
|
+
return value unless Backspin.configuration.scrub_credentials && value
|
|
84
|
+
|
|
85
|
+
value.transform_values { |entry| entry.is_a?(String) ? Backspin.scrub_text(entry) : entry }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def sanitize_args(value)
|
|
89
|
+
deep_freeze(scrub_args(deep_dup(value)))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def sanitize_env(value)
|
|
93
|
+
deep_freeze(scrub_env(deep_dup(value)))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def deep_freeze(value)
|
|
97
|
+
case value
|
|
98
|
+
when Hash
|
|
99
|
+
value.each_value { |v| deep_freeze(v) }
|
|
100
|
+
when Array
|
|
101
|
+
value.each { |v| deep_freeze(v) }
|
|
102
|
+
end
|
|
103
|
+
value.freeze
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def deep_dup(value)
|
|
107
|
+
case value
|
|
108
|
+
when Hash
|
|
109
|
+
value.transform_values { |entry| deep_dup(entry) }
|
|
110
|
+
when Array
|
|
111
|
+
value.map { |entry| deep_dup(entry) }
|
|
112
|
+
when String
|
|
113
|
+
value.dup
|
|
114
|
+
else
|
|
115
|
+
value
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Define the Open3::Capture3 class for identification.
|
|
122
|
+
module Open3
|
|
123
|
+
class Capture3; end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Define the Backspin::Capturer class for identification.
|
|
127
|
+
module Backspin
|
|
128
|
+
class Capturer; end
|
|
129
|
+
end
|
data/lib/backspin/version.rb
CHANGED