backspin 0.4.0 → 0.4.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/.gem_release.yml +13 -0
- data/CHANGELOG.md +5 -7
- data/CLAUDE.md +7 -3
- data/CONTRIBUTING.md +2 -2
- data/Gemfile +3 -1
- data/Gemfile.lock +3 -1
- data/MATCH_ON_USAGE.md +110 -0
- data/README.md +7 -2
- data/Rakefile +5 -1
- data/backspin.gemspec +6 -3
- data/examples/match_on_example.rb +116 -0
- data/fixtures/backspin/all_and_fields.yml +15 -0
- data/fixtures/backspin/all_bypass_equality.yml +14 -0
- data/fixtures/backspin/all_checks_equality.yml +17 -0
- data/fixtures/backspin/all_for_logging.yml +13 -0
- data/fixtures/backspin/all_matcher_basic.yml +14 -0
- data/fixtures/backspin/all_matcher_custom.yml +17 -0
- data/fixtures/backspin/all_matcher_demo.yml +14 -0
- data/fixtures/backspin/all_matcher_test.yml +14 -0
- data/fixtures/backspin/all_mode_filter.yml +14 -0
- data/fixtures/backspin/all_no_short_circuit.yml +14 -0
- data/fixtures/backspin/all_pass_field_fail.yml +14 -0
- data/fixtures/backspin/all_short_circuit.yml +14 -0
- data/fixtures/backspin/all_skips_equality.yml +17 -0
- data/fixtures/backspin/all_with_equality.yml +17 -0
- data/fixtures/backspin/all_with_fields.yml +17 -0
- data/fixtures/backspin/combined_fail_demo.yml +14 -0
- data/fixtures/backspin/combined_matcher_demo.yml +14 -0
- data/fixtures/backspin/credential_filter.yml +18 -0
- data/fixtures/backspin/echo_hello.yml +14 -0
- data/fixtures/backspin/echo_verify.yml +14 -0
- data/fixtures/backspin/episodes_filter.yml +26 -0
- data/fixtures/backspin/failure_test.yml +14 -0
- data/fixtures/backspin/field_matcher_demo.yml +17 -0
- data/fixtures/backspin/field_matcher_values.yml +14 -0
- data/fixtures/backspin/full_data_filter.yml +17 -0
- data/fixtures/backspin/key_confusion_test.yml +14 -0
- data/fixtures/backspin/match_on_any_fail.yml +21 -0
- data/fixtures/backspin/match_on_bad_format.yml +14 -0
- data/fixtures/backspin/match_on_fail.yml +15 -0
- data/fixtures/backspin/match_on_invalid.yml +14 -0
- data/fixtures/backspin/match_on_multiple.yml +28 -0
- data/fixtures/backspin/match_on_nil.yml +14 -0
- data/fixtures/backspin/match_on_other_fields.yml +23 -0
- data/fixtures/backspin/match_on_run_bang.yml +16 -0
- data/fixtures/backspin/match_on_run_bang_fail.yml +15 -0
- data/fixtures/backspin/match_on_single.yml +17 -0
- data/fixtures/backspin/mixed_calls.yml +24 -0
- data/fixtures/backspin/multi_command.yml +34 -0
- data/fixtures/backspin/multi_command_filter.yml +26 -0
- data/fixtures/backspin/multi_field_filter.yml +13 -0
- data/fixtures/backspin/multi_system.yml +20 -0
- data/fixtures/backspin/nil_filter.yml +14 -0
- data/fixtures/backspin/none_mode_test.yml +14 -0
- data/fixtures/backspin/path_test.yml +17 -0
- data/fixtures/backspin/playback_system.yml +12 -0
- data/fixtures/backspin/playback_test.yml +14 -0
- data/fixtures/backspin/stderr_test.yml +19 -0
- data/fixtures/backspin/string_symbol_test.yml +14 -0
- data/fixtures/backspin/system_echo.yml +12 -0
- data/fixtures/backspin/system_false.yml +18 -0
- data/fixtures/backspin/timestamp_test.yml +18 -0
- data/fixtures/backspin/use_record_filter.yml +15 -0
- data/fixtures/backspin/verify_system.yml +12 -0
- data/fixtures/backspin/verify_system_diff.yml +11 -0
- data/fixtures/backspin/version_test.yml +14 -0
- data/lib/backspin/command.rb +1 -5
- data/lib/backspin/command_diff.rb +98 -16
- data/lib/backspin/command_result.rb +2 -4
- data/lib/backspin/record.rb +31 -10
- data/lib/backspin/record_result.rb +20 -14
- data/lib/backspin/recorder.rb +100 -55
- data/lib/backspin/version.rb +3 -1
- data/lib/backspin.rb +36 -175
- data/release.rake +97 -0
- data/script/lint +6 -0
- metadata +79 -5
@@ -0,0 +1,15 @@
|
|
1
|
+
---
|
2
|
+
first_recorded_at: '2025-05-01T12:00:00Z'
|
3
|
+
format_version: '2.0'
|
4
|
+
commands:
|
5
|
+
- command_type: Open3::Capture3
|
6
|
+
args:
|
7
|
+
- echo
|
8
|
+
- "'hello"
|
9
|
+
- world'
|
10
|
+
stdout: 'HELLO WORLD
|
11
|
+
|
12
|
+
'
|
13
|
+
stderr: ''
|
14
|
+
status: 0
|
15
|
+
recorded_at: '2025-05-01T12:00:00Z'
|
@@ -0,0 +1,14 @@
|
|
1
|
+
---
|
2
|
+
first_recorded_at: '2025-05-01T12:00:00Z'
|
3
|
+
format_version: '2.0'
|
4
|
+
commands:
|
5
|
+
- command_type: Open3::Capture3
|
6
|
+
args:
|
7
|
+
- ruby
|
8
|
+
- "--version"
|
9
|
+
stdout: 'ruby 3.4.4 (2025-05-14 revision a38531fd3f) +PRISM [arm64-darwin24]
|
10
|
+
|
11
|
+
'
|
12
|
+
stderr: ''
|
13
|
+
status: 0
|
14
|
+
recorded_at: '2025-05-01T12:00:00Z'
|
data/lib/backspin/command.rb
CHANGED
@@ -12,11 +12,7 @@ module Backspin
|
|
12
12
|
@recorded_at = recorded_at
|
13
13
|
|
14
14
|
# Accept either a CommandResult or individual stdout/stderr/status
|
15
|
-
@result = result || CommandResult.new(
|
16
|
-
stdout: stdout || "",
|
17
|
-
stderr: stderr || "",
|
18
|
-
status: status || 0
|
19
|
-
)
|
15
|
+
@result = result || CommandResult.new(stdout: stdout || "", stderr: stderr || "", status: status || 0)
|
20
16
|
end
|
21
17
|
|
22
18
|
def stdout
|
@@ -4,20 +4,27 @@ module Backspin
|
|
4
4
|
# Represents the difference between a recorded command and actual execution
|
5
5
|
# Handles verification and diff generation for a single command
|
6
6
|
class CommandDiff
|
7
|
-
attr_reader :recorded_command, :
|
7
|
+
attr_reader :recorded_command, :actual_command, :matcher
|
8
8
|
|
9
|
-
def initialize(recorded_command:,
|
9
|
+
def initialize(recorded_command:, actual_command:, matcher: nil)
|
10
10
|
@recorded_command = recorded_command
|
11
|
-
@
|
12
|
-
@matcher = matcher
|
11
|
+
@actual_command = actual_command
|
12
|
+
@matcher = normalize_matcher(matcher)
|
13
13
|
end
|
14
14
|
|
15
15
|
# @return [Boolean] true if the command output matches
|
16
16
|
def verified?
|
17
|
-
if
|
18
|
-
|
17
|
+
# First check if method classes match
|
18
|
+
return false unless method_classes_match?
|
19
|
+
|
20
|
+
if matcher.nil?
|
21
|
+
recorded_command.result == actual_command.result
|
22
|
+
elsif matcher.is_a?(Proc) # basic all matcher: lambda { |recorded, actual| ...}
|
23
|
+
matcher.call(recorded_command.to_h, actual_command.to_h)
|
24
|
+
elsif matcher.is_a?(Hash) # matcher: {all: lambda { |recorded, actual| ...}, stdout: lambda { |recorded, actual| ...}}
|
25
|
+
verify_with_hash_matcher
|
19
26
|
else
|
20
|
-
|
27
|
+
raise ArgumentError, "Invalid matcher type: #{matcher.class}"
|
21
28
|
end
|
22
29
|
end
|
23
30
|
|
@@ -27,12 +34,17 @@ module Backspin
|
|
27
34
|
|
28
35
|
parts = []
|
29
36
|
|
30
|
-
|
37
|
+
# Check method class mismatch first
|
38
|
+
unless method_classes_match?
|
39
|
+
parts << "Command type mismatch: expected #{recorded_command.method_class.name}, got #{actual_command.method_class.name}"
|
40
|
+
end
|
41
|
+
|
42
|
+
parts << stdout_diff if recorded_command.stdout != actual_command.stdout
|
31
43
|
|
32
|
-
parts << stderr_diff if recorded_command.stderr !=
|
44
|
+
parts << stderr_diff if recorded_command.stderr != actual_command.stderr
|
33
45
|
|
34
|
-
if recorded_command.status !=
|
35
|
-
parts << "Exit status: expected #{recorded_command.status}, got #{
|
46
|
+
if recorded_command.status != actual_command.status
|
47
|
+
parts << "Exit status: expected #{recorded_command.status}, got #{actual_command.status}"
|
36
48
|
end
|
37
49
|
|
38
50
|
parts.join("\n\n")
|
@@ -49,20 +61,90 @@ module Backspin
|
|
49
61
|
|
50
62
|
private
|
51
63
|
|
64
|
+
def method_classes_match?
|
65
|
+
recorded_command.method_class == actual_command.method_class
|
66
|
+
end
|
67
|
+
|
68
|
+
def normalize_matcher(matcher)
|
69
|
+
return nil if matcher.nil?
|
70
|
+
return matcher if matcher.is_a?(Proc)
|
71
|
+
|
72
|
+
raise ArgumentError, "Matcher must be a Proc or Hash, got #{matcher.class}" unless matcher.is_a?(Hash)
|
73
|
+
|
74
|
+
# Validate hash keys and values
|
75
|
+
matcher.each do |key, value|
|
76
|
+
unless %i[all stdout stderr status].include?(key)
|
77
|
+
raise ArgumentError, "Invalid matcher key: #{key}. Must be one of: :all, :stdout, :stderr, :status"
|
78
|
+
end
|
79
|
+
raise ArgumentError, "Matcher for #{key} must be callable (Proc/Lambda)" unless value.respond_to?(:call)
|
80
|
+
end
|
81
|
+
matcher
|
82
|
+
end
|
83
|
+
|
84
|
+
def verify_with_hash_matcher
|
85
|
+
recorded_hash = recorded_command.to_h
|
86
|
+
actual_hash = actual_command.to_h
|
87
|
+
|
88
|
+
all_passed = matcher[:all].nil? || matcher[:all].call(recorded_hash, actual_hash)
|
89
|
+
|
90
|
+
fields_passed = %w[stdout stderr status].all? do |field|
|
91
|
+
field_sym = field.to_sym
|
92
|
+
if matcher[field_sym]
|
93
|
+
matcher[field_sym].call(recorded_hash[field], actual_hash[field])
|
94
|
+
else
|
95
|
+
recorded_hash[field] == actual_hash[field]
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
all_passed && fields_passed
|
100
|
+
end
|
101
|
+
|
52
102
|
def failure_reason
|
53
103
|
reasons = []
|
54
|
-
|
55
|
-
|
56
|
-
|
104
|
+
|
105
|
+
# Check method class first
|
106
|
+
unless method_classes_match?
|
107
|
+
reasons << "command type mismatch"
|
108
|
+
return reasons.join(", ")
|
109
|
+
end
|
110
|
+
|
111
|
+
if matcher.nil?
|
112
|
+
reasons << "stdout differs" if recorded_command.stdout != actual_command.stdout
|
113
|
+
reasons << "stderr differs" if recorded_command.stderr != actual_command.stderr
|
114
|
+
reasons << "exit status differs" if recorded_command.status != actual_command.status
|
115
|
+
elsif matcher.is_a?(Hash)
|
116
|
+
recorded_hash = recorded_command.to_h
|
117
|
+
actual_hash = actual_command.to_h
|
118
|
+
|
119
|
+
# Check :all matcher first
|
120
|
+
reasons << ":all matcher failed" if matcher[:all] && !matcher[:all].call(recorded_hash, actual_hash)
|
121
|
+
|
122
|
+
# Check field-specific matchers
|
123
|
+
%w[stdout stderr status].each do |field|
|
124
|
+
field_sym = field.to_sym
|
125
|
+
if matcher[field_sym]
|
126
|
+
unless matcher[field_sym].call(recorded_hash[field], actual_hash[field])
|
127
|
+
reasons << "#{field} custom matcher failed"
|
128
|
+
end
|
129
|
+
elsif recorded_hash[field] != actual_hash[field]
|
130
|
+
# Always check exact equality for fields without matchers
|
131
|
+
reasons << "#{field} differs"
|
132
|
+
end
|
133
|
+
end
|
134
|
+
else
|
135
|
+
# Proc matcher
|
136
|
+
reasons << "custom matcher failed"
|
137
|
+
end
|
138
|
+
|
57
139
|
reasons.join(", ")
|
58
140
|
end
|
59
141
|
|
60
142
|
def stdout_diff
|
61
|
-
"stdout diff:\n#{generate_line_diff(recorded_command.stdout,
|
143
|
+
"stdout diff:\n#{generate_line_diff(recorded_command.stdout, actual_command.stdout)}"
|
62
144
|
end
|
63
145
|
|
64
146
|
def stderr_diff
|
65
|
-
"stderr diff:\n#{generate_line_diff(recorded_command.stderr,
|
147
|
+
"stderr diff:\n#{generate_line_diff(recorded_command.stderr, actual_command.stderr)}"
|
66
148
|
end
|
67
149
|
|
68
150
|
def generate_line_diff(expected, actual)
|
@@ -35,13 +35,11 @@ module Backspin
|
|
35
35
|
def ==(other)
|
36
36
|
return false unless other.is_a?(CommandResult)
|
37
37
|
|
38
|
-
stdout == other.stdout &&
|
39
|
-
stderr == other.stderr &&
|
40
|
-
status == other.status
|
38
|
+
stdout == other.stdout && stderr == other.stderr && status == other.status
|
41
39
|
end
|
42
40
|
|
43
41
|
def inspect
|
44
|
-
"#<Backspin::CommandResult status=#{status} stdout=#{stdout
|
42
|
+
"#<Backspin::CommandResult status=#{status} stdout=#{stdout} stderr=#{stderr}>"
|
45
43
|
end
|
46
44
|
|
47
45
|
private
|
data/lib/backspin/record.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module Backspin
|
2
4
|
class RecordFormatError < StandardError; end
|
3
5
|
|
@@ -7,12 +9,37 @@ module Backspin
|
|
7
9
|
FORMAT_VERSION = "2.0"
|
8
10
|
attr_reader :path, :commands, :first_recorded_at
|
9
11
|
|
12
|
+
def self.load_or_create(path)
|
13
|
+
record = new(path)
|
14
|
+
record.load_from_file if File.exist?(path)
|
15
|
+
record
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.load_from_file(path)
|
19
|
+
raise Backspin::RecordNotFoundError unless File.exist?(path)
|
20
|
+
|
21
|
+
record = new(path)
|
22
|
+
record.load_from_file
|
23
|
+
record
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.build_record_path(name)
|
27
|
+
backspin_dir = Backspin.configuration.backspin_dir
|
28
|
+
backspin_dir.mkpath
|
29
|
+
|
30
|
+
File.join(backspin_dir, "#{name}.yml")
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.create(name)
|
34
|
+
path = build_record_path(name)
|
35
|
+
new(path)
|
36
|
+
end
|
37
|
+
|
10
38
|
def initialize(path)
|
11
39
|
@path = path
|
12
40
|
@commands = []
|
13
41
|
@first_recorded_at = nil
|
14
42
|
@playback_index = 0
|
15
|
-
load_from_file if File.exist?(@path)
|
16
43
|
end
|
17
44
|
|
18
45
|
def add_command(command)
|
@@ -35,7 +62,7 @@ module Backspin
|
|
35
62
|
@commands = []
|
36
63
|
@playback_index = 0
|
37
64
|
load_from_file if File.exist?(@path)
|
38
|
-
@playback_index = 0
|
65
|
+
@playback_index = 0 # Reset again after loading to ensure it's at 0
|
39
66
|
end
|
40
67
|
|
41
68
|
def exists?
|
@@ -51,9 +78,7 @@ module Backspin
|
|
51
78
|
end
|
52
79
|
|
53
80
|
def next_command
|
54
|
-
if @playback_index >= @commands.size
|
55
|
-
raise NoMoreRecordingsError, "No more recordings available for replay"
|
56
|
-
end
|
81
|
+
raise NoMoreRecordingsError, "No more recordings available for replay" if @playback_index >= @commands.size
|
57
82
|
|
58
83
|
command = @commands[@playback_index]
|
59
84
|
@playback_index += 1
|
@@ -65,11 +90,7 @@ module Backspin
|
|
65
90
|
@playback_index = 0
|
66
91
|
end
|
67
92
|
|
68
|
-
|
69
|
-
new(path)
|
70
|
-
end
|
71
|
-
|
72
|
-
private
|
93
|
+
# private
|
73
94
|
|
74
95
|
def load_from_file
|
75
96
|
data = YAML.load_file(@path.to_s)
|
@@ -4,13 +4,14 @@ module Backspin
|
|
4
4
|
# Result object for all Backspin record operations
|
5
5
|
# Provides a consistent interface whether recording, verifying, or playing back
|
6
6
|
class RecordResult
|
7
|
-
attr_reader :output, :
|
7
|
+
attr_reader :output, :commands, :mode, :command_diffs
|
8
|
+
attr_reader :record
|
8
9
|
|
9
|
-
def initialize(output:, mode:,
|
10
|
+
def initialize(output:, mode:, record:, verified: nil, command_diffs: nil)
|
10
11
|
@output = output
|
11
12
|
@mode = mode
|
12
|
-
@
|
13
|
-
@commands = commands
|
13
|
+
@record = record
|
14
|
+
@commands = record.commands
|
14
15
|
@verified = verified
|
15
16
|
@command_diffs = command_diffs || []
|
16
17
|
end
|
@@ -20,8 +21,16 @@ module Backspin
|
|
20
21
|
mode == :record
|
21
22
|
end
|
22
23
|
|
24
|
+
def record_path
|
25
|
+
record.path
|
26
|
+
end
|
27
|
+
|
23
28
|
# @return [Boolean, nil] true/false for verification results, nil for recording
|
24
29
|
def verified?
|
30
|
+
return @verified unless mode == :verify
|
31
|
+
|
32
|
+
return false if command_diffs.size < commands.size
|
33
|
+
|
25
34
|
@verified
|
26
35
|
end
|
27
36
|
|
@@ -33,6 +42,12 @@ module Backspin
|
|
33
42
|
# @return [String, nil] Human-readable error message if verification failed
|
34
43
|
def error_message
|
35
44
|
return nil unless verified? == false
|
45
|
+
|
46
|
+
# Check for command count mismatch first
|
47
|
+
if command_diffs.size < commands.size
|
48
|
+
return "Expected #{commands.size} commands but only #{command_diffs.size} were executed"
|
49
|
+
end
|
50
|
+
|
36
51
|
return "No commands to verify" if command_diffs.empty?
|
37
52
|
|
38
53
|
failed_diffs = command_diffs.reject(&:verified?)
|
@@ -130,24 +145,15 @@ module Backspin
|
|
130
145
|
playback: playback?,
|
131
146
|
stdout: stdout,
|
132
147
|
stderr: stderr,
|
133
|
-
status: status
|
134
|
-
record_path: record_path.to_s
|
148
|
+
status: status
|
135
149
|
}
|
136
150
|
|
137
|
-
# Only include verified if it's not nil
|
138
151
|
hash[:verified] = verified? unless verified?.nil?
|
139
|
-
|
140
|
-
# Only include diff if present
|
141
152
|
hash[:diff] = diff if diff
|
142
|
-
|
143
153
|
# Include number of failed commands if in verify mode
|
144
154
|
hash[:failed_commands] = command_diffs.count { |d| !d.verified? } if mode == :verify && command_diffs.any?
|
145
155
|
|
146
156
|
hash
|
147
157
|
end
|
148
|
-
|
149
|
-
def inspect
|
150
|
-
"#<Backspin::RecordResult mode=#{mode} verified=#{verified?.inspect} status=#{status}>"
|
151
|
-
end
|
152
158
|
end
|
153
159
|
end
|
data/lib/backspin/recorder.rb
CHANGED
@@ -1,25 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require "open3"
|
2
4
|
require "ostruct"
|
3
5
|
require "rspec/mocks"
|
6
|
+
require "backspin/command_result"
|
7
|
+
require "backspin/command_diff"
|
4
8
|
|
5
9
|
module Backspin
|
6
10
|
# Handles stubbing and recording of command executions
|
7
11
|
class Recorder
|
8
12
|
include RSpec::Mocks::ExampleMethods
|
9
|
-
SUPPORTED_COMMAND_TYPES = [
|
13
|
+
SUPPORTED_COMMAND_TYPES = %i[capture3 system].freeze
|
10
14
|
|
11
|
-
attr_reader :commands, :
|
15
|
+
attr_reader :commands, :mode, :record, :options
|
12
16
|
|
13
|
-
def initialize(mode: :record, record: nil)
|
17
|
+
def initialize(mode: :record, record: nil, options: {})
|
14
18
|
@mode = mode
|
15
19
|
@record = record
|
20
|
+
@options = options
|
16
21
|
@commands = []
|
17
|
-
@
|
22
|
+
@playback_index = 0
|
23
|
+
@command_diffs = []
|
18
24
|
end
|
19
25
|
|
20
|
-
def
|
26
|
+
def setup_recording_stubs(*command_types)
|
21
27
|
command_types = SUPPORTED_COMMAND_TYPES if command_types.empty?
|
22
|
-
|
23
28
|
command_types.each do |command_type|
|
24
29
|
record_call(command_type)
|
25
30
|
end
|
@@ -32,67 +37,110 @@ module Backspin
|
|
32
37
|
when :capture3
|
33
38
|
setup_capture3_call_stub
|
34
39
|
else
|
35
|
-
raise ArgumentError,
|
40
|
+
raise ArgumentError,
|
41
|
+
"Unsupported command type: #{command_type} - currently supported types: #{SUPPORTED_COMMAND_TYPES.join(", ")}"
|
36
42
|
end
|
37
43
|
end
|
38
44
|
|
39
|
-
#
|
40
|
-
def
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
elsif command.method_class == ::Kernel::System
|
45
|
-
# For system, return true if exit status was 0
|
46
|
-
allow_any_instance_of(Object).to receive(:system).and_return(command.status == 0)
|
47
|
-
end
|
45
|
+
# Records registered commands, adds them to the record, saves the record, and returns the overall RecordResult
|
46
|
+
def perform_recording
|
47
|
+
result = yield
|
48
|
+
record.save(filter: options[:filter])
|
49
|
+
RecordResult.new(output: result, mode: :record, record: record)
|
48
50
|
end
|
49
51
|
|
50
|
-
#
|
51
|
-
def
|
52
|
-
|
53
|
-
|
54
|
-
if command.method_class == Open3::Capture3
|
55
|
-
allow(Open3).to receive(:capture3).and_wrap_original do |original_method, *args|
|
56
|
-
stdout, stderr, status = original_method.call(*args)
|
57
|
-
@verification_data["stdout"] = stdout
|
58
|
-
@verification_data["stderr"] = stderr
|
59
|
-
@verification_data["status"] = status.exitstatus
|
60
|
-
[stdout, stderr, status]
|
61
|
-
end
|
62
|
-
elsif command.method_class == ::Kernel::System
|
63
|
-
allow_any_instance_of(Object).to receive(:system).and_wrap_original do |original_method, receiver, *args|
|
64
|
-
# Execute the real system call
|
65
|
-
result = original_method.call(receiver, *args)
|
52
|
+
# Performs verification by executing commands and comparing with recorded values
|
53
|
+
def perform_verification
|
54
|
+
raise RecordNotFoundError, "Record not found: #{record.path}" unless record.exists?
|
55
|
+
raise RecordNotFoundError, "No commands found in record" if record.empty?
|
66
56
|
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
@verification_data["status"] = result ? 0 : 1
|
57
|
+
# Initialize tracking variables
|
58
|
+
@command_diffs = []
|
59
|
+
@command_index = 0
|
71
60
|
|
72
|
-
|
61
|
+
# Setup verification stubs for capture3
|
62
|
+
allow(Open3).to receive(:capture3).and_wrap_original do |original_method, *args|
|
63
|
+
recorded_command = record.commands[@command_index]
|
64
|
+
|
65
|
+
if recorded_command.nil?
|
66
|
+
raise RecordNotFoundError, "No more recorded commands, but tried to execute: #{args.inspect}"
|
73
67
|
end
|
68
|
+
|
69
|
+
stdout, stderr, status = original_method.call(*args)
|
70
|
+
|
71
|
+
actual_command = Command.new(
|
72
|
+
method_class: Open3::Capture3,
|
73
|
+
args: args,
|
74
|
+
stdout: stdout,
|
75
|
+
stderr: stderr,
|
76
|
+
status: status.exitstatus
|
77
|
+
)
|
78
|
+
|
79
|
+
@command_diffs << CommandDiff.new(recorded_command: recorded_command, actual_command: actual_command, matcher: options[:matcher])
|
80
|
+
@command_index += 1
|
81
|
+
[stdout, stderr, status]
|
74
82
|
end
|
83
|
+
|
84
|
+
# Setup verification stubs for system
|
85
|
+
allow_any_instance_of(Object).to receive(:system).and_wrap_original do |original_method, receiver, *args|
|
86
|
+
recorded_command = record.commands[@command_index]
|
87
|
+
|
88
|
+
result = original_method.call(receiver, *args)
|
89
|
+
|
90
|
+
actual_command = Command.new(
|
91
|
+
method_class: ::Kernel::System,
|
92
|
+
args: args,
|
93
|
+
stdout: "",
|
94
|
+
stderr: "",
|
95
|
+
status: result ? 0 : 1
|
96
|
+
)
|
97
|
+
|
98
|
+
# Create CommandDiff to track the comparison
|
99
|
+
@command_diffs << CommandDiff.new(recorded_command: recorded_command, actual_command: actual_command, matcher: options[:matcher])
|
100
|
+
|
101
|
+
@command_index += 1
|
102
|
+
result
|
103
|
+
end
|
104
|
+
|
105
|
+
output = yield
|
106
|
+
|
107
|
+
all_verified = @command_diffs.all?(&:verified?)
|
108
|
+
|
109
|
+
RecordResult.new(
|
110
|
+
output: output,
|
111
|
+
mode: :verify,
|
112
|
+
verified: all_verified,
|
113
|
+
record: record,
|
114
|
+
command_diffs: @command_diffs
|
115
|
+
)
|
75
116
|
end
|
76
117
|
|
77
|
-
#
|
78
|
-
def
|
79
|
-
raise
|
118
|
+
# Performs playback by returning recorded values without executing actual commands
|
119
|
+
def perform_playback
|
120
|
+
raise RecordNotFoundError, "Record not found: #{record.path}" unless record.exists?
|
121
|
+
raise RecordNotFoundError, "No commands found in record" if record.empty?
|
80
122
|
|
123
|
+
# Setup replay stubs
|
81
124
|
setup_capture3_replay_stub
|
82
125
|
setup_system_replay_stub
|
126
|
+
|
127
|
+
# Execute block (all commands will be stubbed with recorded values)
|
128
|
+
output = yield
|
129
|
+
|
130
|
+
RecordResult.new(
|
131
|
+
output: output,
|
132
|
+
mode: :playback,
|
133
|
+
verified: true, # Always true for playback
|
134
|
+
record: record
|
135
|
+
)
|
83
136
|
end
|
84
137
|
|
85
138
|
private
|
86
139
|
|
87
140
|
def setup_capture3_replay_stub
|
88
|
-
allow(Open3).to receive(:capture3) do |*
|
141
|
+
allow(Open3).to receive(:capture3) do |*_args|
|
89
142
|
command = @record.next_command
|
90
143
|
|
91
|
-
# Make sure this is a capture3 command
|
92
|
-
unless command.method_class == Open3::Capture3
|
93
|
-
raise RecordNotFoundError, "Expected Open3::Capture3 command but got #{command.method_class.name}"
|
94
|
-
end
|
95
|
-
|
96
144
|
recorded_stdout = command.stdout
|
97
145
|
recorded_stderr = command.stderr
|
98
146
|
recorded_status = OpenStruct.new(exitstatus: command.status)
|
@@ -104,14 +152,10 @@ module Backspin
|
|
104
152
|
end
|
105
153
|
|
106
154
|
def setup_system_replay_stub
|
107
|
-
allow_any_instance_of(Object).to receive(:system) do |
|
155
|
+
allow_any_instance_of(Object).to receive(:system) do |_receiver, *_args|
|
108
156
|
command = @record.next_command
|
109
157
|
|
110
|
-
|
111
|
-
raise RecordNotFoundError, "Expected Kernel::System command but got #{command.method_class.name}"
|
112
|
-
end
|
113
|
-
|
114
|
-
command.status == 0
|
158
|
+
command.status.zero?
|
115
159
|
rescue NoMoreRecordingsError => e
|
116
160
|
raise RecordNotFoundError, e.message
|
117
161
|
end
|
@@ -135,7 +179,7 @@ module Backspin
|
|
135
179
|
status: status.exitstatus,
|
136
180
|
recorded_at: Time.now.iso8601
|
137
181
|
)
|
138
|
-
|
182
|
+
record.add_command(command)
|
139
183
|
|
140
184
|
[stdout, stderr, status]
|
141
185
|
end
|
@@ -154,7 +198,8 @@ module Backspin
|
|
154
198
|
args
|
155
199
|
end
|
156
200
|
|
157
|
-
stdout
|
201
|
+
stdout = ""
|
202
|
+
stderr = ""
|
158
203
|
status = result ? 0 : 1
|
159
204
|
|
160
205
|
command = Command.new(
|
@@ -165,7 +210,7 @@ module Backspin
|
|
165
210
|
status: status,
|
166
211
|
recorded_at: Time.now.iso8601
|
167
212
|
)
|
168
|
-
|
213
|
+
record.add_command(command)
|
169
214
|
|
170
215
|
result
|
171
216
|
end
|
data/lib/backspin/version.rb
CHANGED